Project dashboard: onboarding checklist + domain overview (#398)
This commit is contained in:
parent
040282511a
commit
eda5e1e354
33
drizzle-pg/0012_dashboard.sql
Normal file
33
drizzle-pg/0012_dashboard.sql
Normal file
@ -0,0 +1,33 @@
|
||||
CREATE TABLE "backlink_snapshots" (
|
||||
"id" serial PRIMARY KEY NOT NULL,
|
||||
"project_id" text NOT NULL,
|
||||
"domain" text NOT NULL,
|
||||
"rank" integer,
|
||||
"backlinks" integer,
|
||||
"referring_domains" integer,
|
||||
"broken_backlinks" integer,
|
||||
"new_backlinks" integer,
|
||||
"lost_backlinks" integer,
|
||||
"new_referring_domains" integer,
|
||||
"lost_referring_domains" integer,
|
||||
"captured_at" text DEFAULT to_char(now() AT TIME ZONE 'utc', 'YYYY-MM-DD"T"HH24:MI:SS.MS"Z"') NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "organization_activation_state" (
|
||||
"organization_id" text PRIMARY KEY NOT NULL,
|
||||
"first_mcp_authorized_at" text,
|
||||
"first_mcp_tool_call_at" text,
|
||||
"updated_at" text DEFAULT to_char(now() AT TIME ZONE 'utc', 'YYYY-MM-DD"T"HH24:MI:SS.MS"Z"') NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "project_activation_state" (
|
||||
"project_id" text PRIMARY KEY NOT NULL,
|
||||
"competitor_step_clicked_at" text,
|
||||
"mcp_card_dismissed_at" text,
|
||||
"updated_at" text DEFAULT to_char(now() AT TIME ZONE 'utc', 'YYYY-MM-DD"T"HH24:MI:SS.MS"Z"') NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "backlink_snapshots" ADD CONSTRAINT "backlink_snapshots_project_id_projects_id_fk" FOREIGN KEY ("project_id") REFERENCES "public"."projects"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "organization_activation_state" ADD CONSTRAINT "organization_activation_state_organization_id_organization_id_fk" FOREIGN KEY ("organization_id") REFERENCES "public"."organization"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "project_activation_state" ADD CONSTRAINT "project_activation_state_project_id_projects_id_fk" FOREIGN KEY ("project_id") REFERENCES "public"."projects"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
CREATE INDEX "backlink_snapshots_project_captured_idx" ON "backlink_snapshots" USING btree ("project_id","captured_at");
|
||||
3708
drizzle-pg/meta/0012_snapshot.json
Normal file
3708
drizzle-pg/meta/0012_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@ -85,6 +85,13 @@
|
||||
"when": 1784410131656,
|
||||
"tag": "0011_friendly_morlun",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 12,
|
||||
"version": "7",
|
||||
"when": 1784423181488,
|
||||
"tag": "0012_dashboard",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
32
drizzle/0035_dashboard.sql
Normal file
32
drizzle/0035_dashboard.sql
Normal file
@ -0,0 +1,32 @@
|
||||
CREATE TABLE `backlink_snapshots` (
|
||||
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||
`project_id` text NOT NULL,
|
||||
`domain` text NOT NULL,
|
||||
`rank` integer,
|
||||
`backlinks` integer,
|
||||
`referring_domains` integer,
|
||||
`broken_backlinks` integer,
|
||||
`new_backlinks` integer,
|
||||
`lost_backlinks` integer,
|
||||
`new_referring_domains` integer,
|
||||
`lost_referring_domains` integer,
|
||||
`captured_at` text DEFAULT (current_timestamp) NOT NULL,
|
||||
FOREIGN KEY (`project_id`) REFERENCES `projects`(`id`) ON UPDATE no action ON DELETE cascade
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE INDEX `backlink_snapshots_project_captured_idx` ON `backlink_snapshots` (`project_id`,`captured_at`);--> statement-breakpoint
|
||||
CREATE TABLE `organization_activation_state` (
|
||||
`organization_id` text PRIMARY KEY NOT NULL,
|
||||
`first_mcp_authorized_at` text,
|
||||
`first_mcp_tool_call_at` text,
|
||||
`updated_at` text DEFAULT (current_timestamp) NOT NULL,
|
||||
FOREIGN KEY (`organization_id`) REFERENCES `organization`(`id`) ON UPDATE no action ON DELETE cascade
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE `project_activation_state` (
|
||||
`project_id` text PRIMARY KEY NOT NULL,
|
||||
`competitor_step_clicked_at` text,
|
||||
`mcp_card_dismissed_at` text,
|
||||
`updated_at` text DEFAULT (current_timestamp) NOT NULL,
|
||||
FOREIGN KEY (`project_id`) REFERENCES `projects`(`id`) ON UPDATE no action ON DELETE cascade
|
||||
);
|
||||
3373
drizzle/meta/0035_snapshot.json
Normal file
3373
drizzle/meta/0035_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@ -246,6 +246,13 @@
|
||||
"when": 1784410130115,
|
||||
"tag": "0034_wonderful_skrulls",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 35,
|
||||
"version": "6",
|
||||
"when": 1784423179842,
|
||||
"tag": "0035_dashboard",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@ -57,9 +57,9 @@ function SidebarNavLink({
|
||||
}) {
|
||||
return (
|
||||
<Link
|
||||
{...linkProps}
|
||||
onClick={onNavigate}
|
||||
activeOptions={{ exact: false, includeSearch: false }}
|
||||
{...linkProps}
|
||||
className={navItemClass}
|
||||
activeProps={navItemActiveProps}
|
||||
>
|
||||
@ -109,6 +109,15 @@ export function Sidebar({ projectId, onNavigate, onClose }: SidebarProps) {
|
||||
}
|
||||
};
|
||||
|
||||
// Coming back from Chat, land on the dashboard rather than leaving the
|
||||
// conversation filling the content panel next to a Browse nav.
|
||||
const openBrowse = () => {
|
||||
setView("browse");
|
||||
if (!projectId || !onSamRoute) return;
|
||||
void navigate({ to: "/p/$projectId", params: { projectId } });
|
||||
onNavigate?.();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex h-full w-60 flex-col bg-base-200">
|
||||
<div className="flex items-center justify-between px-4 pb-2 pt-3">
|
||||
@ -147,7 +156,7 @@ export function Sidebar({ projectId, onNavigate, onClose }: SidebarProps) {
|
||||
icon={LayoutGrid}
|
||||
label="Browse"
|
||||
active={view === "browse"}
|
||||
onClick={() => setView("browse")}
|
||||
onClick={openBrowse}
|
||||
/>
|
||||
<SidebarViewTab
|
||||
icon={MessageCircle}
|
||||
|
||||
356
src/client/features/dashboard/DashboardCards.tsx
Normal file
356
src/client/features/dashboard/DashboardCards.tsx
Normal file
@ -0,0 +1,356 @@
|
||||
import { Link } from "@tanstack/react-router";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Check } from "lucide-react";
|
||||
import { SearchConsoleConnectionCard } from "@/client/features/gsc/SearchConsoleConnectionCard";
|
||||
import { AUDIT_ISSUE_TYPES } from "@/shared/audit-issues";
|
||||
|
||||
import {
|
||||
formatCount,
|
||||
formatCtr,
|
||||
formatPosition,
|
||||
} from "@/client/features/search-performance/SearchPerformanceColumns";
|
||||
import { getSearchPerformanceReport } from "@/serverFunctions/searchPerformance";
|
||||
import {
|
||||
CardShell,
|
||||
EmptyCardBody,
|
||||
formatDay,
|
||||
moreDetailsClass,
|
||||
newLost,
|
||||
PercentDelta,
|
||||
Stat,
|
||||
} from "@/client/features/dashboard/cardParts";
|
||||
import type {
|
||||
DashboardAuditSummary,
|
||||
DashboardBacklinkSummary,
|
||||
DashboardRankSummary,
|
||||
} from "@/server/features/dashboard/services/DashboardService";
|
||||
|
||||
// Plain string-keyed view of the registry: issue types from the DB are not
|
||||
// statically guaranteed to be registry keys.
|
||||
const issueTitles: Record<string, string | undefined> = Object.fromEntries(
|
||||
Object.entries(AUDIT_ISSUE_TYPES).map(([key, value]) => [key, value.title]),
|
||||
);
|
||||
|
||||
export function GscCard({
|
||||
projectId,
|
||||
connected,
|
||||
}: {
|
||||
projectId: string;
|
||||
connected: boolean;
|
||||
}) {
|
||||
const reportQuery = useQuery({
|
||||
queryKey: ["dashboardGscReport", projectId],
|
||||
queryFn: () =>
|
||||
getSearchPerformanceReport({
|
||||
data: { projectId, dateRange: "last_28_days" },
|
||||
}),
|
||||
enabled: connected,
|
||||
});
|
||||
|
||||
// Not connected (or a dead grant discovered by the report call): the
|
||||
// connection card sells and runs the whole flow itself.
|
||||
if (!connected || (reportQuery.data && !reportQuery.data.connected)) {
|
||||
return (
|
||||
<div id="connect-gsc">
|
||||
<SearchConsoleConnectionCard projectId={projectId} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const report = reportQuery.data;
|
||||
|
||||
return (
|
||||
<CardShell
|
||||
title="Search performance"
|
||||
stamp="Google Search Console · last 28 days"
|
||||
action={
|
||||
<Link
|
||||
to="/p/$projectId/search-performance"
|
||||
params={{ projectId }}
|
||||
className={moreDetailsClass}
|
||||
>
|
||||
More details
|
||||
</Link>
|
||||
}
|
||||
>
|
||||
{reportQuery.isPending ? (
|
||||
<div className="grid grid-cols-2 gap-3" aria-busy>
|
||||
{Array.from({ length: 4 }, (_, i) => (
|
||||
<div key={i} className="skeleton h-20" />
|
||||
))}
|
||||
</div>
|
||||
) : reportQuery.isError ? (
|
||||
<p className="text-sm text-base-content/60">
|
||||
Couldn’t load Search Console data. Try again shortly.
|
||||
</p>
|
||||
) : report?.connected ? (
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<Stat
|
||||
label="Clicks"
|
||||
value={formatCount(report.totals.clicks)}
|
||||
sub={
|
||||
<PercentDelta
|
||||
current={report.totals.clicks}
|
||||
previous={report.prevTotals.clicks}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<Stat
|
||||
label="Impressions"
|
||||
value={formatCount(report.totals.impressions)}
|
||||
sub={
|
||||
<PercentDelta
|
||||
current={report.totals.impressions}
|
||||
previous={report.prevTotals.impressions}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<Stat label="CTR" value={formatCtr(report.totals.ctr)} />
|
||||
<Stat
|
||||
label="Avg position"
|
||||
value={formatPosition(report.totals.position)}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
</CardShell>
|
||||
);
|
||||
}
|
||||
|
||||
export function RankMovementCard({
|
||||
projectId,
|
||||
rank,
|
||||
}: {
|
||||
projectId: string;
|
||||
rank: DashboardRankSummary | null;
|
||||
}) {
|
||||
if (!rank) {
|
||||
return (
|
||||
<CardShell title="Rank tracking">
|
||||
<EmptyCardBody
|
||||
message="Track your money keywords and watch your ranking improve over time."
|
||||
cta={
|
||||
<Link
|
||||
to="/p/$projectId/rank-tracking"
|
||||
params={{ projectId }}
|
||||
className="btn btn-primary btn-sm"
|
||||
>
|
||||
Track your first keyword
|
||||
</Link>
|
||||
}
|
||||
/>
|
||||
</CardShell>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<CardShell
|
||||
title="Rank movement"
|
||||
stamp={
|
||||
rank.lastCheckedAt
|
||||
? `Rank data · checked ${formatDay(rank.lastCheckedAt)} · vs 7 days ago`
|
||||
: "Rank data · no completed checks yet"
|
||||
}
|
||||
action={
|
||||
<Link
|
||||
to="/p/$projectId/rank-tracking"
|
||||
params={{ projectId }}
|
||||
className={moreDetailsClass}
|
||||
>
|
||||
More details
|
||||
</Link>
|
||||
}
|
||||
>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<Stat label="Tracked" value={String(rank.trackedKeywords)} />
|
||||
<Stat
|
||||
label="Improved"
|
||||
value={`▲ ${rank.improved}`}
|
||||
tone={rank.improved > 0 ? "success" : undefined}
|
||||
/>
|
||||
<Stat
|
||||
label="Declined"
|
||||
value={`▼ ${rank.declined}`}
|
||||
tone={rank.declined > 0 ? "error" : undefined}
|
||||
/>
|
||||
<Stat label="Top 10" value={String(rank.top10)} />
|
||||
</div>
|
||||
</CardShell>
|
||||
);
|
||||
}
|
||||
|
||||
export function AuditHealthCard({
|
||||
projectId,
|
||||
audit,
|
||||
}: {
|
||||
projectId: string;
|
||||
audit: DashboardAuditSummary | null;
|
||||
}) {
|
||||
if (!audit) {
|
||||
return (
|
||||
<CardShell title="Site audit">
|
||||
<EmptyCardBody
|
||||
message="Crawl your site for broken links, missing tags and indexability problems."
|
||||
cta={
|
||||
<Link
|
||||
to="/p/$projectId/audit"
|
||||
params={{ projectId }}
|
||||
className="btn btn-primary btn-sm"
|
||||
>
|
||||
Run an audit
|
||||
</Link>
|
||||
}
|
||||
/>
|
||||
</CardShell>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<CardShell
|
||||
title="Site audit"
|
||||
stamp={`Site audit · ${
|
||||
audit.status === "completed"
|
||||
? `crawled ${audit.pagesCrawled} pages · ${formatDay(audit.startedAt)}`
|
||||
: audit.status === "running"
|
||||
? "crawl in progress"
|
||||
: "last crawl failed"
|
||||
}`}
|
||||
action={
|
||||
<Link
|
||||
to="/p/$projectId/audit"
|
||||
params={{ projectId }}
|
||||
className={moreDetailsClass}
|
||||
>
|
||||
More details
|
||||
</Link>
|
||||
}
|
||||
>
|
||||
{audit.topIssues.length === 0 ? (
|
||||
<div className="flex items-center gap-2 text-sm text-base-content/70">
|
||||
<Check className="size-4 text-success" />
|
||||
No issues found — your site looks healthy.
|
||||
</div>
|
||||
) : (
|
||||
<ul className="space-y-2">
|
||||
{audit.topIssues.map((issue) => (
|
||||
<li
|
||||
key={issue.issueType}
|
||||
className="flex items-center justify-between gap-2 text-sm"
|
||||
>
|
||||
<span className="flex min-w-0 items-center gap-2">
|
||||
<span
|
||||
className={`size-2 shrink-0 rounded-full ${
|
||||
issue.severity === "critical"
|
||||
? "bg-error"
|
||||
: issue.severity === "warning"
|
||||
? "bg-warning"
|
||||
: "bg-base-content/30"
|
||||
}`}
|
||||
/>
|
||||
<span className="truncate">
|
||||
{issueTitles[issue.issueType] ?? issue.issueType}
|
||||
</span>
|
||||
</span>
|
||||
<span className="shrink-0 tabular-nums text-base-content/60">
|
||||
{issue.count} {issue.count === 1 ? "page" : "pages"}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
{audit.totalIssueTypes > audit.topIssues.length ? (
|
||||
<li className="text-xs text-base-content/50">
|
||||
+ {audit.totalIssueTypes - audit.topIssues.length} more issue
|
||||
{audit.totalIssueTypes - audit.topIssues.length === 1 ? "" : "s"}
|
||||
</li>
|
||||
) : null}
|
||||
</ul>
|
||||
)}
|
||||
</CardShell>
|
||||
);
|
||||
}
|
||||
|
||||
export function BacklinkPulseCard({
|
||||
projectId,
|
||||
backlinks,
|
||||
refreshing,
|
||||
}: {
|
||||
projectId: string;
|
||||
backlinks: DashboardBacklinkSummary | null;
|
||||
refreshing: boolean;
|
||||
}) {
|
||||
if (!backlinks && refreshing) {
|
||||
return (
|
||||
<CardShell title="Backlink pulse" stamp="Taking your first snapshot…">
|
||||
<div className="grid grid-cols-2 gap-3" aria-busy>
|
||||
{Array.from({ length: 4 }, (_, i) => (
|
||||
<div key={i} className="skeleton h-20" />
|
||||
))}
|
||||
</div>
|
||||
</CardShell>
|
||||
);
|
||||
}
|
||||
|
||||
if (!backlinks) {
|
||||
return (
|
||||
<CardShell title="Backlink pulse">
|
||||
<p className="text-sm text-base-content/60">
|
||||
We’ll snapshot who links to your domain — nothing to set up.
|
||||
</p>
|
||||
</CardShell>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<CardShell
|
||||
title="Backlink pulse"
|
||||
stamp={`Backlinks · snapshot ${formatDay(backlinks.capturedAt)}${
|
||||
refreshing ? " · refreshing…" : ""
|
||||
}`}
|
||||
action={
|
||||
<Link
|
||||
to="/p/$projectId/backlinks"
|
||||
params={{ projectId }}
|
||||
search={{ target: backlinks.domain, scope: "domain" }}
|
||||
className={moreDetailsClass}
|
||||
>
|
||||
More details
|
||||
</Link>
|
||||
}
|
||||
>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<Stat
|
||||
label="Ref. domains"
|
||||
value={
|
||||
backlinks.referringDomains === null
|
||||
? "—"
|
||||
: backlinks.referringDomains.toLocaleString()
|
||||
}
|
||||
/>
|
||||
<Stat
|
||||
label="Backlinks"
|
||||
value={
|
||||
backlinks.backlinks === null
|
||||
? "—"
|
||||
: backlinks.backlinks.toLocaleString()
|
||||
}
|
||||
/>
|
||||
<Stat
|
||||
label="New links"
|
||||
value={`▲ ${newLost(backlinks.newBacklinks)}`}
|
||||
tone={
|
||||
backlinks.newBacklinks && backlinks.newBacklinks > 0
|
||||
? "success"
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
<Stat
|
||||
label="Lost links"
|
||||
value={`▼ ${newLost(backlinks.lostBacklinks)}`}
|
||||
tone={
|
||||
backlinks.lostBacklinks && backlinks.lostBacklinks > 0
|
||||
? "error"
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</CardShell>
|
||||
);
|
||||
}
|
||||
376
src/client/features/dashboard/DashboardPage.tsx
Normal file
376
src/client/features/dashboard/DashboardPage.tsx
Normal file
@ -0,0 +1,376 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { Link, useNavigate } from "@tanstack/react-router";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { toast } from "sonner";
|
||||
import { ChevronLeft, ChevronRight, Check } from "lucide-react";
|
||||
import { captureClientEvent } from "@/client/lib/posthog";
|
||||
import {
|
||||
computeNextStep,
|
||||
isStepDone,
|
||||
STEP_ORDER,
|
||||
} from "@/client/features/dashboard/dashboardSteps";
|
||||
import {
|
||||
AuditHealthCard,
|
||||
BacklinkPulseCard,
|
||||
GscCard,
|
||||
RankMovementCard,
|
||||
} from "@/client/features/dashboard/DashboardCards";
|
||||
import { McpConnectCard } from "@/client/features/dashboard/McpConnectCard";
|
||||
import { getStandardErrorMessage } from "@/client/lib/error-messages";
|
||||
import type { DashboardActivation } from "@/server/features/dashboard/services/DashboardService";
|
||||
import {
|
||||
getDashboardActivation,
|
||||
getDashboardOverview,
|
||||
markDashboardCompetitorClicked,
|
||||
refreshDashboardBacklinkSnapshot,
|
||||
} from "@/serverFunctions/dashboard";
|
||||
import { setProjectDomain } from "@/serverFunctions/projects";
|
||||
import type { DashboardHeroStep } from "@/types/schemas/dashboard";
|
||||
|
||||
const HERO_COPY: Record<
|
||||
DashboardHeroStep,
|
||||
{ title: string; body: string; cta: string }
|
||||
> = {
|
||||
domain: {
|
||||
title: "What site are you working on?",
|
||||
body: "Set your project's domain and every card on this page starts working for it — backlinks, audits, rank tracking.",
|
||||
cta: "Save",
|
||||
},
|
||||
mcp: {
|
||||
title: "Connect your AI agent",
|
||||
body: "OpenSEO is built to be used from agents like Claude. Connect once, then ask it to use OpenSEO to help build your SEO strategy.",
|
||||
cta: "Show me how",
|
||||
},
|
||||
gsc: {
|
||||
title: "Connect Search Console",
|
||||
body: "Your real queries and clicks, straight from Google.",
|
||||
cta: "Connect",
|
||||
},
|
||||
competitor: {
|
||||
title: "Size up a competitor",
|
||||
body: "Paste a competitor's domain to see what they rank for and who links to them.",
|
||||
cta: "Open domain lookup",
|
||||
},
|
||||
};
|
||||
|
||||
function scrollToCard(id: string) {
|
||||
document.getElementById(id)?.scrollIntoView({
|
||||
behavior: "smooth",
|
||||
block: "center",
|
||||
});
|
||||
}
|
||||
|
||||
// Users paste full URLs; store the bare host like settings expects.
|
||||
function normalizeDomainInput(value: string): string {
|
||||
return value
|
||||
.trim()
|
||||
.replace(/^https?:\/\//i, "")
|
||||
.replace(/\/.*$/, "");
|
||||
}
|
||||
|
||||
function OnboardingChecklist({
|
||||
projectId,
|
||||
activation,
|
||||
}: {
|
||||
projectId: string;
|
||||
activation: DashboardActivation;
|
||||
}) {
|
||||
const navigate = useNavigate();
|
||||
const queryClient = useQueryClient();
|
||||
const [domainInput, setDomainInput] = useState("");
|
||||
// null = follow the first actionable step; set once the user pages with ‹ ›.
|
||||
const [viewedIndex, setViewedIndex] = useState<number | null>(null);
|
||||
const invalidateActivation = () =>
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: ["dashboardActivation", projectId],
|
||||
});
|
||||
|
||||
const competitorClickMutation = useMutation({
|
||||
mutationFn: () => markDashboardCompetitorClicked({ data: { projectId } }),
|
||||
onSuccess: invalidateActivation,
|
||||
});
|
||||
const domainMutation = useMutation({
|
||||
mutationFn: (domain: string) =>
|
||||
setProjectDomain({ data: { projectId, domain } }),
|
||||
onSuccess: () => {
|
||||
invalidateActivation();
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: ["dashboardOverview", projectId],
|
||||
});
|
||||
void queryClient.invalidateQueries({ queryKey: ["projects"] });
|
||||
},
|
||||
onError: (error) =>
|
||||
toast.error(
|
||||
getStandardErrorMessage(error, "Couldn't save the domain. Try again."),
|
||||
),
|
||||
});
|
||||
|
||||
// Hidden once every step is done.
|
||||
const nextStep = computeNextStep(activation);
|
||||
if (!nextStep) return null;
|
||||
|
||||
const index = viewedIndex ?? STEP_ORDER.indexOf(nextStep);
|
||||
const step = STEP_ORDER[index];
|
||||
const copy = HERO_COPY[step];
|
||||
const done = isStepDone(activation, step);
|
||||
|
||||
const page = (delta: number) =>
|
||||
setViewedIndex(Math.min(Math.max(index + delta, 0), STEP_ORDER.length - 1));
|
||||
|
||||
const onSubmitDomain = () => {
|
||||
const domain = normalizeDomainInput(domainInput);
|
||||
if (!domain) return;
|
||||
captureClientEvent("dashboard:next_move_click", { step: "domain" });
|
||||
domainMutation.mutate(domain);
|
||||
};
|
||||
|
||||
// Only the gsc/competitor steps use the fallback CTA button — domain
|
||||
// renders an inline form and mcp renders a Link.
|
||||
const onCta = () => {
|
||||
captureClientEvent("dashboard:next_move_click", { step });
|
||||
if (step === "gsc") {
|
||||
scrollToCard("connect-gsc");
|
||||
} else if (step === "competitor") {
|
||||
competitorClickMutation.mutate();
|
||||
void navigate({ to: "/p/$projectId/domain", params: { projectId } });
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="rounded-xl border border-primary/25 bg-primary/5 shadow-sm">
|
||||
<div className="flex items-center justify-between gap-4 px-5 pt-4">
|
||||
<p className="text-xs font-medium uppercase tracking-wide text-primary">
|
||||
Onboarding checklist
|
||||
</p>
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
type="button"
|
||||
className={`btn btn-ghost btn-xs btn-square ${
|
||||
index === 0 ? "invisible" : ""
|
||||
}`}
|
||||
aria-label="Previous step"
|
||||
disabled={index === 0}
|
||||
onClick={() => page(-1)}
|
||||
>
|
||||
<ChevronLeft className="size-4" />
|
||||
</button>
|
||||
<span className="text-xs tabular-nums text-base-content/60">
|
||||
{index + 1} / {STEP_ORDER.length}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
className={`btn btn-ghost btn-xs btn-square ${
|
||||
index === STEP_ORDER.length - 1 ? "invisible" : ""
|
||||
}`}
|
||||
aria-label="Next step"
|
||||
disabled={index === STEP_ORDER.length - 1}
|
||||
onClick={() => page(1)}
|
||||
>
|
||||
<ChevronRight className="size-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-row flex-wrap items-center justify-between gap-4 p-5 pt-2">
|
||||
<div className="min-w-0">
|
||||
<h2 className="text-lg font-semibold">{copy.title}</h2>
|
||||
<p className="mt-1 max-w-xl text-sm text-base-content/70">
|
||||
{copy.body}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex shrink-0 flex-wrap items-center gap-3">
|
||||
{done ? (
|
||||
<span className="inline-flex items-center gap-1.5 text-sm font-medium text-success">
|
||||
<Check className="size-4" />
|
||||
Done
|
||||
</span>
|
||||
) : step === "domain" ? (
|
||||
<form
|
||||
className="join"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
onSubmitDomain();
|
||||
}}
|
||||
>
|
||||
<input
|
||||
type="text"
|
||||
className="input input-bordered join-item w-52"
|
||||
placeholder="acme.com"
|
||||
value={domainInput}
|
||||
onChange={(event) => setDomainInput(event.target.value)}
|
||||
aria-label="Your site's domain"
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
className="btn btn-primary join-item"
|
||||
disabled={
|
||||
domainMutation.isPending ||
|
||||
normalizeDomainInput(domainInput) === ""
|
||||
}
|
||||
>
|
||||
{copy.cta}
|
||||
</button>
|
||||
</form>
|
||||
) : step === "mcp" ? (
|
||||
<Link
|
||||
to="/ai"
|
||||
className="link link-primary text-sm font-medium"
|
||||
onClick={() =>
|
||||
captureClientEvent("dashboard:next_move_click", { step })
|
||||
}
|
||||
>
|
||||
{copy.cta} →
|
||||
</Link>
|
||||
) : (
|
||||
<button type="button" className="btn btn-primary" onClick={onCta}>
|
||||
{copy.cta}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function DashboardPage({ projectId }: { projectId: string }) {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const activationQuery = useQuery({
|
||||
queryKey: ["dashboardActivation", projectId],
|
||||
queryFn: () => getDashboardActivation({ data: { projectId } }),
|
||||
});
|
||||
const overviewQuery = useQuery({
|
||||
queryKey: ["dashboardOverview", projectId],
|
||||
queryFn: () => getDashboardOverview({ data: { projectId } }),
|
||||
});
|
||||
|
||||
const activation = activationQuery.data;
|
||||
const overview = overviewQuery.data;
|
||||
|
||||
// Visit-triggered backlink snapshot: fire once per page view when the
|
||||
// overview reports a missing or stale snapshot for a project with a domain.
|
||||
// The server re-checks freshness, so a stray double-fire costs nothing.
|
||||
const refreshMutation = useMutation({
|
||||
mutationFn: () => refreshDashboardBacklinkSnapshot({ data: { projectId } }),
|
||||
onSuccess: () =>
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: ["dashboardOverview", projectId],
|
||||
}),
|
||||
});
|
||||
const refreshFiredRef = useRef(false);
|
||||
const needsSnapshot =
|
||||
activation?.domain != null &&
|
||||
overview !== undefined &&
|
||||
(overview.backlinks === null || overview.backlinks.stale);
|
||||
useEffect(() => {
|
||||
if (!needsSnapshot || refreshFiredRef.current) return;
|
||||
refreshFiredRef.current = true;
|
||||
refreshMutation.mutate();
|
||||
}, [needsSnapshot, refreshMutation]);
|
||||
|
||||
if (activationQuery.isError) {
|
||||
return (
|
||||
<div className="px-4 py-4 md:px-6 md:py-6">
|
||||
<div className="alert alert-error">
|
||||
{getStandardErrorMessage(activationQuery.error)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!activation) {
|
||||
return (
|
||||
<div
|
||||
className="mx-auto flex max-w-5xl flex-col gap-5 px-4 py-4 md:px-6 md:py-6"
|
||||
aria-busy
|
||||
>
|
||||
<div className="skeleton h-8 w-52" />
|
||||
<div className="skeleton h-36" />
|
||||
<div className="grid gap-5 lg:grid-cols-2">
|
||||
<div className="skeleton h-44" />
|
||||
<div className="skeleton h-44" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const showBacklinks = activation.domain !== null;
|
||||
const gscConnected = activation.gsc.connected;
|
||||
|
||||
return (
|
||||
<div className="px-4 py-4 pb-24 md:px-6 md:py-6 md:pb-8">
|
||||
<div className="mx-auto flex max-w-5xl flex-col gap-5">
|
||||
<h1 className="text-2xl font-semibold">Dashboard</h1>
|
||||
|
||||
<OnboardingChecklist projectId={projectId} activation={activation} />
|
||||
|
||||
{/* Every card is half width on large screens (only the checklist spans).
|
||||
Cards with data render before setup pitches and empty states. */}
|
||||
<div className="grid items-start gap-5 lg:grid-cols-2">
|
||||
{[
|
||||
// Array order is the within-bucket order after the data-first sort:
|
||||
// the MCP pitch leads the setup cards.
|
||||
...(activation.mcp.firstToolCallAt || activation.mcp.cardDismissedAt
|
||||
? []
|
||||
: [
|
||||
{
|
||||
key: "mcp",
|
||||
hasData: false,
|
||||
node: (
|
||||
<McpConnectCard
|
||||
projectId={projectId}
|
||||
activation={activation}
|
||||
/>
|
||||
),
|
||||
},
|
||||
]),
|
||||
{
|
||||
key: "gsc",
|
||||
hasData: gscConnected,
|
||||
node: <GscCard projectId={projectId} connected={gscConnected} />,
|
||||
},
|
||||
{
|
||||
key: "rank",
|
||||
hasData: overview?.rank != null,
|
||||
node: (
|
||||
<RankMovementCard
|
||||
projectId={projectId}
|
||||
rank={overview?.rank ?? null}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "audit",
|
||||
hasData: overview?.audit != null,
|
||||
node: (
|
||||
<AuditHealthCard
|
||||
projectId={projectId}
|
||||
audit={overview?.audit ?? null}
|
||||
/>
|
||||
),
|
||||
},
|
||||
...(showBacklinks
|
||||
? [
|
||||
{
|
||||
key: "backlinks",
|
||||
hasData:
|
||||
overview?.backlinks != null || refreshMutation.isPending,
|
||||
node: (
|
||||
<BacklinkPulseCard
|
||||
projectId={projectId}
|
||||
backlinks={overview?.backlinks ?? null}
|
||||
refreshing={refreshMutation.isPending}
|
||||
/>
|
||||
),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
]
|
||||
.toSorted((a, b) => Number(b.hasData) - Number(a.hasData))
|
||||
.map((card) => (
|
||||
<div key={card.key}>{card.node}</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
126
src/client/features/dashboard/McpConnectCard.tsx
Normal file
126
src/client/features/dashboard/McpConnectCard.tsx
Normal file
@ -0,0 +1,126 @@
|
||||
import { Link } from "@tanstack/react-router";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { CopyButton } from "@/client/features/ai-mcp/SetupControls";
|
||||
import { captureClientEvent } from "@/client/lib/posthog";
|
||||
import type { DashboardActivation } from "@/server/features/dashboard/services/DashboardService";
|
||||
import { dismissDashboardMcpCard } from "@/serverFunctions/dashboard";
|
||||
|
||||
function firstPrompts(domain: string | null): string[] {
|
||||
const site = domain ?? "my site";
|
||||
return [
|
||||
`Review ${site}. Ideas for what keywords we could target? Use OpenSEO`,
|
||||
"Research my competitors top pages and keywords and tell me what's working. Use OpenSEO",
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* The MCP activation card. Pitches the agent workflow and links to the
|
||||
* AI & MCP page for setup; disappears for good after the org's first
|
||||
* external tool call (or an explicit "I already connected").
|
||||
*/
|
||||
export function McpConnectCard({
|
||||
projectId,
|
||||
activation,
|
||||
}: {
|
||||
projectId: string;
|
||||
activation: DashboardActivation;
|
||||
}) {
|
||||
const queryClient = useQueryClient();
|
||||
const dismissMutation = useMutation({
|
||||
mutationFn: () => dismissDashboardMcpCard({ data: { projectId } }),
|
||||
onSuccess: () =>
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: ["dashboardActivation", projectId],
|
||||
}),
|
||||
});
|
||||
|
||||
if (activation.mcp.firstToolCallAt || activation.mcp.cardDismissedAt) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const connected = activation.mcp.authorizedAt !== null;
|
||||
|
||||
return (
|
||||
<div className="overflow-hidden rounded-xl border border-base-300 bg-base-100 shadow-sm">
|
||||
<div className="flex items-center justify-between gap-4 px-5 py-4">
|
||||
<h2 className="text-base font-semibold leading-tight">
|
||||
Connect your AI agent
|
||||
</h2>
|
||||
<div className="flex items-center gap-2">
|
||||
{connected ? (
|
||||
<span className="badge badge-success badge-outline badge-sm">
|
||||
Connected
|
||||
</span>
|
||||
) : null}
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-ghost btn-xs text-base-content/60"
|
||||
disabled={dismissMutation.isPending}
|
||||
onClick={() => {
|
||||
captureClientEvent("dashboard:mcp_already_connected");
|
||||
dismissMutation.mutate();
|
||||
}}
|
||||
>
|
||||
I already connected
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-3 border-t border-base-300 p-5">
|
||||
{connected ? (
|
||||
<>
|
||||
<p className="text-sm text-base-content/70">
|
||||
Your agent is connected. Try asking it:
|
||||
</p>
|
||||
<ul className="space-y-2">
|
||||
{firstPrompts(activation.domain).map((prompt) => (
|
||||
<li
|
||||
key={prompt}
|
||||
className="flex items-center justify-between gap-2 rounded-md border border-base-300 bg-base-200/50 px-3 py-2"
|
||||
>
|
||||
<span className="min-w-0 truncate text-xs text-base-content/80">
|
||||
{prompt}
|
||||
</span>
|
||||
<CopyButton
|
||||
value={prompt}
|
||||
successMessage="Prompt copied"
|
||||
iconOnly
|
||||
onCopy={() =>
|
||||
captureClientEvent("dashboard:mcp_prompt_copy")
|
||||
}
|
||||
/>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<p className="text-xs text-base-content/50">
|
||||
Waiting for your first call — this card disappears once your agent
|
||||
talks to OpenSEO.
|
||||
</p>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="space-y-2 text-sm text-base-content/70">
|
||||
<p>
|
||||
OpenSEO is designed to give your AI agent the data it needs to
|
||||
build a great SEO strategy and help you execute it.
|
||||
</p>
|
||||
<p>
|
||||
This way you aren’t limited on “AI credits”.
|
||||
</p>
|
||||
<p>
|
||||
You can work with your agent to figure out what automations make
|
||||
sense for you and it can help you write content too.
|
||||
</p>
|
||||
</div>
|
||||
<Link
|
||||
to="/ai"
|
||||
className="link link-primary text-sm font-medium"
|
||||
onClick={() => captureClientEvent("dashboard:mcp_setup_open")}
|
||||
>
|
||||
Set up in AI & MCP →
|
||||
</Link>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
111
src/client/features/dashboard/cardParts.tsx
Normal file
111
src/client/features/dashboard/cardParts.tsx
Normal file
@ -0,0 +1,111 @@
|
||||
// Shared building blocks for the dashboard cards. Same visual language as
|
||||
// the GSC IntegrationCard (rounded-xl, shadow-sm, header row + divider) so
|
||||
// the embedded SearchConsoleConnectionCard doesn't read as a different
|
||||
// design system.
|
||||
export function CardShell({
|
||||
title,
|
||||
stamp,
|
||||
action,
|
||||
children,
|
||||
}: {
|
||||
title: string;
|
||||
stamp?: string;
|
||||
action?: React.ReactNode;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="overflow-hidden rounded-xl border border-base-300 bg-base-100 shadow-sm">
|
||||
<div className="flex items-center justify-between gap-4 px-5 py-4">
|
||||
<h2 className="text-base font-semibold leading-tight">{title}</h2>
|
||||
{action}
|
||||
</div>
|
||||
<div className="border-t border-base-300 p-5">
|
||||
{children}
|
||||
{stamp ? (
|
||||
<p className="mt-4 text-[11px] text-base-content/45">{stamp}</p>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function EmptyCardBody({
|
||||
message,
|
||||
cta,
|
||||
}: {
|
||||
message: string;
|
||||
cta: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex flex-col items-start gap-3">
|
||||
<p className="text-sm text-base-content/70">{message}</p>
|
||||
{cta}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function Stat({
|
||||
label,
|
||||
value,
|
||||
tone,
|
||||
sub,
|
||||
}: {
|
||||
label: string;
|
||||
value: string;
|
||||
tone?: "success" | "error";
|
||||
sub?: React.ReactNode;
|
||||
}) {
|
||||
const toneClass =
|
||||
tone === "success" ? "text-success" : tone === "error" ? "text-error" : "";
|
||||
return (
|
||||
<div>
|
||||
<p className="text-xs uppercase tracking-wide text-base-content/60">
|
||||
{label}
|
||||
</p>
|
||||
<p className={`text-2xl font-semibold tabular-nums ${toneClass}`}>
|
||||
{value}
|
||||
</p>
|
||||
{sub}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function PercentDelta({
|
||||
current,
|
||||
previous,
|
||||
}: {
|
||||
current: number;
|
||||
previous: number;
|
||||
}) {
|
||||
if (previous <= 0) return null;
|
||||
const pct = ((current - previous) / previous) * 100;
|
||||
if (!Number.isFinite(pct)) return null;
|
||||
const rounded = Math.round(pct);
|
||||
const tone = rounded > 0 ? "text-success" : rounded < 0 ? "text-error" : "";
|
||||
return (
|
||||
<p className={`text-xs tabular-nums ${tone}`}>
|
||||
{rounded > 0 ? "▲" : rounded < 0 ? "▼" : ""} {Math.abs(rounded)}%
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
export const moreDetailsClass = "btn btn-ghost btn-xs";
|
||||
|
||||
export function newLost(value: number | null): string {
|
||||
return value === null ? "—" : String(value);
|
||||
}
|
||||
|
||||
export function formatDay(timestamp: string): string {
|
||||
const ms = Date.parse(
|
||||
// SQLite's current_timestamp default has no timezone marker; treat it as
|
||||
// UTC rather than letting the browser parse it as local time.
|
||||
/^\d{4}-\d{2}-\d{2} /.test(timestamp)
|
||||
? `${timestamp.replace(" ", "T")}Z`
|
||||
: timestamp,
|
||||
);
|
||||
if (Number.isNaN(ms)) return timestamp;
|
||||
return new Date(ms).toLocaleDateString(undefined, {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
});
|
||||
}
|
||||
42
src/client/features/dashboard/dashboardSteps.ts
Normal file
42
src/client/features/dashboard/dashboardSteps.ts
Normal file
@ -0,0 +1,42 @@
|
||||
import type { DashboardActivation } from "@/server/features/dashboard/services/DashboardService";
|
||||
import type { DashboardHeroStep } from "@/types/schemas/dashboard";
|
||||
|
||||
export const STEP_ORDER: DashboardHeroStep[] = [
|
||||
"domain",
|
||||
"mcp",
|
||||
"gsc",
|
||||
"competitor",
|
||||
];
|
||||
|
||||
// A step is "done" when the underlying product state exists, regardless of
|
||||
// how it got there. The MCP hero step completes at authorization (the card
|
||||
// below owns the connected-but-no-call-yet coaching) or when the user said
|
||||
// "I already connected".
|
||||
export function isStepDone(
|
||||
activation: DashboardActivation,
|
||||
step: DashboardHeroStep,
|
||||
): boolean {
|
||||
switch (step) {
|
||||
case "domain":
|
||||
return activation.domain !== null;
|
||||
case "mcp":
|
||||
return (
|
||||
activation.mcp.authorizedAt !== null ||
|
||||
activation.mcp.cardDismissedAt !== null
|
||||
);
|
||||
case "gsc":
|
||||
return activation.gsc.connected;
|
||||
case "competitor":
|
||||
return activation.competitorClickedAt !== null;
|
||||
}
|
||||
}
|
||||
|
||||
/** The single step the hero should coach next; null = coaching is over. */
|
||||
export function computeNextStep(
|
||||
activation: DashboardActivation,
|
||||
): DashboardHeroStep | null {
|
||||
for (const step of STEP_ORDER) {
|
||||
if (!isStepDone(activation, step)) return step;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@ -96,6 +96,14 @@ export function SearchConsoleConnectionCard({
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: ["searchPerformanceTable", projectId],
|
||||
});
|
||||
// The dashboard embeds this card and swaps it for the Search
|
||||
// performance stats card once activation reports the connection.
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: ["dashboardActivation", projectId],
|
||||
});
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: ["dashboardGscReport", projectId],
|
||||
});
|
||||
},
|
||||
onError: (error) => toast.error(getStandardErrorMessage(error)),
|
||||
});
|
||||
@ -116,6 +124,12 @@ export function SearchConsoleConnectionCard({
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: ["searchPerformanceTable", projectId],
|
||||
});
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: ["dashboardActivation", projectId],
|
||||
});
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: ["dashboardGscReport", projectId],
|
||||
});
|
||||
},
|
||||
onError: (error) => toast.error(getStandardErrorMessage(error)),
|
||||
});
|
||||
@ -177,7 +191,8 @@ export function SearchConsoleConnectionCard({
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
<p className="text-sm text-base-content/70">
|
||||
Connect your Google Search Console to get insights in OpenSEO.
|
||||
Connect GSC to see how your website is actually performing in Google
|
||||
Search.
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { ArrowLeft, ArrowRight, Check } from "lucide-react";
|
||||
import { ArrowRight, Check } from "lucide-react";
|
||||
import type { ReactNode } from "react";
|
||||
import { Fragment } from "react";
|
||||
import {
|
||||
@ -22,7 +22,7 @@ type PostSignupOnboardingProps = {
|
||||
onNext: () => void;
|
||||
onBack: () => void;
|
||||
onSkip: () => void;
|
||||
onFinish: (mcpSetupIntent: "yes" | "no") => void;
|
||||
onFinish: () => void;
|
||||
isSaving: boolean;
|
||||
accountMenu: ReactNode;
|
||||
};
|
||||
@ -122,27 +122,20 @@ export function PostSignupOnboarding({
|
||||
otherValue={answers.sourceOther}
|
||||
onOtherChange={(sourceOther) => updateAnswers({ sourceOther })}
|
||||
/>
|
||||
) : step === 3 ? (
|
||||
<SearchConsoleOnboardingStep />
|
||||
) : (
|
||||
<McpRecommendation
|
||||
isSaving={isSaving}
|
||||
onBack={onBack}
|
||||
onSetup={() => onFinish("yes")}
|
||||
onSkip={() => onFinish("no")}
|
||||
/>
|
||||
<SearchConsoleOnboardingStep />
|
||||
)}
|
||||
|
||||
{step < ONBOARDING_LAST_STEP ? (
|
||||
<div className="mt-5 flex items-center justify-between gap-3">
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-ghost"
|
||||
disabled={step === 0 || isSaving}
|
||||
onClick={onBack}
|
||||
>
|
||||
Back
|
||||
</button>
|
||||
<div className="mt-5 flex items-center justify-between gap-3">
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-ghost"
|
||||
disabled={step === 0 || isSaving}
|
||||
onClick={onBack}
|
||||
>
|
||||
Back
|
||||
</button>
|
||||
{step < ONBOARDING_LAST_STEP ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
@ -162,79 +155,23 @@ export function PostSignupOnboarding({
|
||||
<ArrowRight className="size-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary"
|
||||
disabled={isSaving}
|
||||
onClick={onFinish}
|
||||
>
|
||||
Finish
|
||||
<ArrowRight className="size-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function McpRecommendation({
|
||||
isSaving,
|
||||
onBack,
|
||||
onSetup,
|
||||
onSkip,
|
||||
}: {
|
||||
isSaving: boolean;
|
||||
onBack: () => void;
|
||||
onSetup: () => void;
|
||||
onSkip: () => void;
|
||||
}) {
|
||||
const capabilities = [
|
||||
"Keyword research",
|
||||
"Competitor research",
|
||||
"Link prospecting",
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="flex flex-col">
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-ghost btn-sm -ml-2 mb-2 self-start gap-1.5 text-base-content/60"
|
||||
disabled={isSaving}
|
||||
onClick={onBack}
|
||||
>
|
||||
<ArrowLeft className="size-4" />
|
||||
Back
|
||||
</button>
|
||||
<h2 className="text-lg font-semibold">Set up OpenSEO MCP?</h2>
|
||||
<p className="mt-1.5 text-sm leading-relaxed text-base-content/70">
|
||||
The most powerful way to use OpenSEO — use AI to supercharge your SEO
|
||||
skills.
|
||||
</p>
|
||||
|
||||
<ul className="mt-4 w-full space-y-2">
|
||||
{capabilities.map((capability) => (
|
||||
<li key={capability} className="flex items-center gap-2.5 text-sm">
|
||||
<span className="flex size-5 shrink-0 items-center justify-center rounded-full bg-base-200 text-base-content">
|
||||
<Check className="size-3" />
|
||||
</span>
|
||||
<span className="text-base-content/80">{capability}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary mt-5 w-full"
|
||||
disabled={isSaving}
|
||||
onClick={onSetup}
|
||||
>
|
||||
Yes, set up MCP
|
||||
<ArrowRight className="size-4" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-ghost btn-sm mt-2 w-full text-base-content/60"
|
||||
disabled={isSaving}
|
||||
onClick={onSkip}
|
||||
>
|
||||
Not now
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function OnboardingChoiceGroup({
|
||||
title,
|
||||
description,
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
import { queryOptions } from "@tanstack/react-query";
|
||||
import { getOnboardingAnswers } from "@/serverFunctions/onboarding";
|
||||
|
||||
export const ONBOARDING_LAST_STEP = 4;
|
||||
export const ONBOARDING_LAST_STEP = 3;
|
||||
|
||||
export const INTEREST_OPTIONS = [
|
||||
"AI workflows with Claude or Codex (MCP)",
|
||||
@ -32,6 +32,7 @@ export const CLIENT_WEBSITE_COUNT_OPTIONS = [
|
||||
] as const;
|
||||
|
||||
export const SOURCE_OPTIONS = [
|
||||
"Product Hunt",
|
||||
"Google",
|
||||
"Reddit",
|
||||
"X / Twitter",
|
||||
@ -110,7 +111,7 @@ export function restoreOnboardingAnswers(
|
||||
export function buildOnboardingPayload(
|
||||
answers: OnboardingAnswers,
|
||||
step: number,
|
||||
extra: { mcpSetupIntent?: "yes" | "no"; completed?: boolean } = {},
|
||||
extra: { completed?: boolean } = {},
|
||||
) {
|
||||
const interestedFeatures = answers.selectedInterests.map((value) =>
|
||||
value === "Other" && answers.interestOther.trim()
|
||||
|
||||
@ -30,7 +30,7 @@ export function ProjectSwitcher({
|
||||
if (project.id === activeProjectId) return;
|
||||
setLastProjectId(project.id);
|
||||
void navigate({
|
||||
to: "/p/$projectId/keywords",
|
||||
to: "/p/$projectId",
|
||||
params: { projectId: project.id },
|
||||
});
|
||||
};
|
||||
|
||||
@ -3,6 +3,7 @@ import {
|
||||
Bot,
|
||||
ClipboardCheck,
|
||||
Globe,
|
||||
LayoutDashboard,
|
||||
Link2,
|
||||
MessageSquare,
|
||||
Search,
|
||||
@ -13,6 +14,14 @@ import { linkOptions } from "@tanstack/react-router";
|
||||
import { GoogleGlyphMuted } from "@/client/features/gsc/GoogleGlyph";
|
||||
|
||||
const projectNavItems = [
|
||||
{
|
||||
to: "/p/$projectId" as const,
|
||||
label: "Dashboard",
|
||||
icon: LayoutDashboard,
|
||||
// Without exact matching, the index path is a prefix of every project
|
||||
// route and the Dashboard item would render active everywhere.
|
||||
activeOptions: { exact: true, includeSearch: false },
|
||||
},
|
||||
{
|
||||
to: "/p/$projectId/keywords" as const,
|
||||
label: "Keyword Research",
|
||||
@ -90,6 +99,10 @@ export function getProjectNavGroups(projectId: string) {
|
||||
all.find((i) => i.to === path)!;
|
||||
|
||||
return [
|
||||
{
|
||||
label: "Overview",
|
||||
items: [byPath("/p/$projectId")],
|
||||
},
|
||||
{
|
||||
label: "Research",
|
||||
items: [
|
||||
|
||||
@ -350,3 +350,71 @@ export const rankSnapshots = sqliteTable(
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
// Dashboard activation milestones. Organization-scoped: MCP OAuth grants are
|
||||
// user-level, so any member connecting an external MCP client satisfies the
|
||||
// milestone for the whole organization. Timestamps are first-occurrence only
|
||||
// and never move once set.
|
||||
export const organizationActivationState = sqliteTable(
|
||||
"organization_activation_state",
|
||||
{
|
||||
organizationId: text("organization_id")
|
||||
.primaryKey()
|
||||
.references(() => organization.id, { onDelete: "cascade" }),
|
||||
firstMcpAuthorizedAt: text("first_mcp_authorized_at"),
|
||||
firstMcpToolCallAt: text("first_mcp_tool_call_at"),
|
||||
updatedAt: text("updated_at")
|
||||
.notNull()
|
||||
.default(sql`(current_timestamp)`),
|
||||
},
|
||||
);
|
||||
|
||||
// Per-project state for the dashboard's onboarding checklist. Most steps
|
||||
// complete via real product state (projects.domain, gsc_connections, MCP
|
||||
// activation); the competitor step completes on click-through.
|
||||
export const projectActivationState = sqliteTable("project_activation_state", {
|
||||
projectId: text("project_id")
|
||||
.primaryKey()
|
||||
.references(() => projects.id, { onDelete: "cascade" }),
|
||||
competitorStepClickedAt: text("competitor_step_clicked_at"),
|
||||
// "I already connected" on the MCP card: hides the card for this project
|
||||
// without faking the org-level first-tool-call milestone, which stays
|
||||
// truthful and self-heals when a real external call lands.
|
||||
mcpCardDismissedAt: text("mcp_card_dismissed_at"),
|
||||
updatedAt: text("updated_at")
|
||||
.notNull()
|
||||
.default(sql`(current_timestamp)`),
|
||||
});
|
||||
|
||||
// Point-in-time backlink profile summaries for the project's own domain,
|
||||
// written by the dashboard's visit-triggered refresh. DataForSEO's summary
|
||||
// already carries new/lost counts, so one snapshot renders a full card;
|
||||
// rows accumulate into history for future trend views. The domain is stored
|
||||
// per row so a later project-domain change doesn't rewrite history.
|
||||
export const backlinkSnapshots = sqliteTable(
|
||||
"backlink_snapshots",
|
||||
{
|
||||
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||
projectId: text("project_id")
|
||||
.notNull()
|
||||
.references(() => projects.id, { onDelete: "cascade" }),
|
||||
domain: text("domain").notNull(),
|
||||
rank: integer("rank"),
|
||||
backlinks: integer("backlinks"),
|
||||
referringDomains: integer("referring_domains"),
|
||||
brokenBacklinks: integer("broken_backlinks"),
|
||||
newBacklinks: integer("new_backlinks"),
|
||||
lostBacklinks: integer("lost_backlinks"),
|
||||
newReferringDomains: integer("new_referring_domains"),
|
||||
lostReferringDomains: integer("lost_referring_domains"),
|
||||
capturedAt: text("captured_at")
|
||||
.notNull()
|
||||
.default(sql`(current_timestamp)`),
|
||||
},
|
||||
(table) => [
|
||||
index("backlink_snapshots_project_captured_idx").on(
|
||||
table.projectId,
|
||||
table.capturedAt,
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
@ -343,3 +343,65 @@ export const rankSnapshots = pgTable(
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
// Dashboard activation milestones. Organization-scoped: MCP OAuth grants are
|
||||
// user-level, so any member connecting an external MCP client satisfies the
|
||||
// milestone for the whole organization. Timestamps are first-occurrence only
|
||||
// and never move once set.
|
||||
export const organizationActivationState = pgTable(
|
||||
"organization_activation_state",
|
||||
{
|
||||
organizationId: text("organization_id")
|
||||
.primaryKey()
|
||||
.references(() => organization.id, { onDelete: "cascade" }),
|
||||
firstMcpAuthorizedAt: timestampColumn("first_mcp_authorized_at"),
|
||||
firstMcpToolCallAt: timestampColumn("first_mcp_tool_call_at"),
|
||||
updatedAt: timestampColumn("updated_at").notNull().default(isoNow),
|
||||
},
|
||||
);
|
||||
|
||||
// Per-project state for the dashboard's onboarding checklist. Most steps
|
||||
// complete via real product state (projects.domain, gsc_connections, MCP
|
||||
// activation); the competitor step completes on click-through.
|
||||
export const projectActivationState = pgTable("project_activation_state", {
|
||||
projectId: text("project_id")
|
||||
.primaryKey()
|
||||
.references(() => projects.id, { onDelete: "cascade" }),
|
||||
competitorStepClickedAt: timestampColumn("competitor_step_clicked_at"),
|
||||
// "I already connected" on the MCP card: hides the card for this project
|
||||
// without faking the org-level first-tool-call milestone, which stays
|
||||
// truthful and self-heals when a real external call lands.
|
||||
mcpCardDismissedAt: timestampColumn("mcp_card_dismissed_at"),
|
||||
updatedAt: timestampColumn("updated_at").notNull().default(isoNow),
|
||||
});
|
||||
|
||||
// Point-in-time backlink profile summaries for the project's own domain,
|
||||
// written by the dashboard's visit-triggered refresh. DataForSEO's summary
|
||||
// already carries new/lost counts, so one snapshot renders a full card;
|
||||
// rows accumulate into history for future trend views. The domain is stored
|
||||
// per row so a later project-domain change doesn't rewrite history.
|
||||
export const backlinkSnapshots = pgTable(
|
||||
"backlink_snapshots",
|
||||
{
|
||||
id: serial("id").primaryKey(),
|
||||
projectId: text("project_id")
|
||||
.notNull()
|
||||
.references(() => projects.id, { onDelete: "cascade" }),
|
||||
domain: text("domain").notNull(),
|
||||
rank: integer("rank"),
|
||||
backlinks: integer("backlinks"),
|
||||
referringDomains: integer("referring_domains"),
|
||||
brokenBacklinks: integer("broken_backlinks"),
|
||||
newBacklinks: integer("new_backlinks"),
|
||||
lostBacklinks: integer("lost_backlinks"),
|
||||
newReferringDomains: integer("new_referring_domains"),
|
||||
lostReferringDomains: integer("lost_referring_domains"),
|
||||
capturedAt: timestampColumn("captured_at").notNull().default(isoNow),
|
||||
},
|
||||
(table) => [
|
||||
index("backlink_snapshots_project_captured_idx").on(
|
||||
table.projectId,
|
||||
table.capturedAt,
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
@ -72,6 +72,9 @@ export const {
|
||||
rankTrackingKeywords,
|
||||
rankCheckRuns,
|
||||
rankSnapshots,
|
||||
organizationActivationState,
|
||||
projectActivationState,
|
||||
backlinkSnapshots,
|
||||
audits,
|
||||
auditPages,
|
||||
auditLinks,
|
||||
|
||||
@ -40,7 +40,7 @@ function IndexRedirect() {
|
||||
}
|
||||
|
||||
void navigate({
|
||||
to: "/p/$projectId/keywords",
|
||||
to: "/p/$projectId",
|
||||
params: { projectId: (target ?? data[0]).id },
|
||||
});
|
||||
}, [data, navigate]);
|
||||
|
||||
@ -81,10 +81,7 @@ function OnboardingFlow({
|
||||
const [answers, setAnswers] = useState<OnboardingAnswers>(initialAnswers);
|
||||
|
||||
const saveMutation = useMutation({
|
||||
mutationFn: (extra: {
|
||||
mcpSetupIntent?: "yes" | "no";
|
||||
completed?: boolean;
|
||||
}) =>
|
||||
mutationFn: (extra: { completed?: boolean }) =>
|
||||
saveOnboardingAnswers({
|
||||
data: buildOnboardingPayload(answers, step, extra),
|
||||
}),
|
||||
@ -113,9 +110,9 @@ function OnboardingFlow({
|
||||
goToStep(step + 1);
|
||||
};
|
||||
|
||||
const handleFinish = async (mcpSetupIntent: "yes" | "no") => {
|
||||
const handleFinish = async () => {
|
||||
try {
|
||||
await saveMutation.mutateAsync({ mcpSetupIntent, completed: true });
|
||||
await saveMutation.mutateAsync({ completed: true });
|
||||
// Refresh the shared cache so the destination's onboarding-redirect guard
|
||||
// sees the completed state and doesn't bounce the user back here.
|
||||
await queryClient.invalidateQueries({ queryKey: ["onboardingAnswers"] });
|
||||
@ -126,13 +123,9 @@ function OnboardingFlow({
|
||||
interests: answers.selectedInterests,
|
||||
work_for: answers.workFor,
|
||||
source: answers.source,
|
||||
wants_mcp_setup: mcpSetupIntent === "yes",
|
||||
});
|
||||
if (mcpSetupIntent === "yes") {
|
||||
void navigate({ to: "/ai", replace: true });
|
||||
} else {
|
||||
void navigate({ to: "/", replace: true });
|
||||
}
|
||||
// The dashboard's onboarding checklist owns MCP coaching now.
|
||||
void navigate({ to: "/", replace: true });
|
||||
};
|
||||
|
||||
return (
|
||||
|
||||
@ -1,10 +1,11 @@
|
||||
import { createFileRoute, redirect } from "@tanstack/react-router";
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import { DashboardPage } from "@/client/features/dashboard/DashboardPage";
|
||||
|
||||
export const Route = createFileRoute("/_project/p/$projectId/")({
|
||||
beforeLoad: ({ params }) => {
|
||||
throw redirect({
|
||||
to: "/p/$projectId/keywords",
|
||||
params: { projectId: params.projectId },
|
||||
});
|
||||
},
|
||||
component: DashboardRoute,
|
||||
});
|
||||
|
||||
function DashboardRoute() {
|
||||
const { projectId } = Route.useParams();
|
||||
return <DashboardPage projectId={projectId} />;
|
||||
}
|
||||
|
||||
36
src/server/features/activation/mcpActivation.ts
Normal file
36
src/server/features/activation/mcpActivation.ts
Normal file
@ -0,0 +1,36 @@
|
||||
import { ActivationRepository } from "@/server/features/activation/repositories/ActivationRepository";
|
||||
|
||||
// Orgs whose first external MCP tool call is already recorded (or in flight)
|
||||
// in this isolate. Only *first* timestamps matter, so after one successful
|
||||
// write the tool-call hot path never touches the DB again for that org.
|
||||
const recordedToolCallOrgs = new Set<string>();
|
||||
|
||||
/**
|
||||
* Milestone writes for the dashboard's MCP activation card. Both are awaited
|
||||
* inline by their callers (not waitUntil) so the upsert runs inside the
|
||||
* request's DB client scope, and both swallow errors: activation tracking
|
||||
* must never fail an OAuth flow or a tool call.
|
||||
*/
|
||||
export async function recordMcpAuthorized(
|
||||
organizationId: string,
|
||||
): Promise<void> {
|
||||
try {
|
||||
await ActivationRepository.recordFirstMcpAuthorized(organizationId);
|
||||
} catch (error) {
|
||||
console.error("activation: recordMcpAuthorized failed", error);
|
||||
}
|
||||
}
|
||||
|
||||
export async function recordExternalMcpToolCall(
|
||||
organizationId: string,
|
||||
): Promise<void> {
|
||||
if (recordedToolCallOrgs.has(organizationId)) return;
|
||||
recordedToolCallOrgs.add(organizationId);
|
||||
try {
|
||||
await ActivationRepository.recordFirstMcpToolCall(organizationId);
|
||||
} catch (error) {
|
||||
// Allow a retry on a later call rather than losing the milestone.
|
||||
recordedToolCallOrgs.delete(organizationId);
|
||||
console.error("activation: recordExternalMcpToolCall failed", error);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,99 @@
|
||||
import { eq, sql } from "drizzle-orm";
|
||||
import { db } from "@/db";
|
||||
import {
|
||||
organizationActivationState,
|
||||
projectActivationState,
|
||||
} from "@/db/schema";
|
||||
|
||||
type OrganizationActivationState =
|
||||
typeof organizationActivationState.$inferSelect;
|
||||
type ProjectActivationState = typeof projectActivationState.$inferSelect;
|
||||
|
||||
async function getOrganizationActivation(
|
||||
organizationId: string,
|
||||
): Promise<OrganizationActivationState | null> {
|
||||
const rows = await db
|
||||
.select()
|
||||
.from(organizationActivationState)
|
||||
.where(eq(organizationActivationState.organizationId, organizationId))
|
||||
.limit(1);
|
||||
return rows[0] ?? null;
|
||||
}
|
||||
|
||||
async function getProjectActivation(
|
||||
projectId: string,
|
||||
): Promise<ProjectActivationState | null> {
|
||||
const rows = await db
|
||||
.select()
|
||||
.from(projectActivationState)
|
||||
.where(eq(projectActivationState.projectId, projectId))
|
||||
.limit(1);
|
||||
return rows[0] ?? null;
|
||||
}
|
||||
|
||||
// First-occurrence timestamps only: concurrent writers race harmlessly because
|
||||
// COALESCE keeps whichever value landed first.
|
||||
async function recordFirstMcpAuthorized(organizationId: string): Promise<void> {
|
||||
const now = new Date().toISOString();
|
||||
await db
|
||||
.insert(organizationActivationState)
|
||||
.values({ organizationId, firstMcpAuthorizedAt: now, updatedAt: now })
|
||||
.onConflictDoUpdate({
|
||||
target: organizationActivationState.organizationId,
|
||||
set: {
|
||||
firstMcpAuthorizedAt: sql`coalesce(${organizationActivationState.firstMcpAuthorizedAt}, ${now})`,
|
||||
updatedAt: now,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function recordFirstMcpToolCall(organizationId: string): Promise<void> {
|
||||
const now = new Date().toISOString();
|
||||
await db
|
||||
.insert(organizationActivationState)
|
||||
.values({ organizationId, firstMcpToolCallAt: now, updatedAt: now })
|
||||
.onConflictDoUpdate({
|
||||
target: organizationActivationState.organizationId,
|
||||
set: {
|
||||
firstMcpToolCallAt: sql`coalesce(${organizationActivationState.firstMcpToolCallAt}, ${now})`,
|
||||
updatedAt: now,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function markCompetitorStepClicked(projectId: string): Promise<void> {
|
||||
const now = new Date().toISOString();
|
||||
await db
|
||||
.insert(projectActivationState)
|
||||
.values({ projectId, competitorStepClickedAt: now, updatedAt: now })
|
||||
.onConflictDoUpdate({
|
||||
target: projectActivationState.projectId,
|
||||
set: {
|
||||
competitorStepClickedAt: sql`coalesce(${projectActivationState.competitorStepClickedAt}, ${now})`,
|
||||
updatedAt: now,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function markMcpCardDismissed(projectId: string): Promise<void> {
|
||||
const now = new Date().toISOString();
|
||||
await db
|
||||
.insert(projectActivationState)
|
||||
.values({ projectId, mcpCardDismissedAt: now, updatedAt: now })
|
||||
.onConflictDoUpdate({
|
||||
target: projectActivationState.projectId,
|
||||
set: {
|
||||
mcpCardDismissedAt: sql`coalesce(${projectActivationState.mcpCardDismissedAt}, ${now})`,
|
||||
updatedAt: now,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export const ActivationRepository = {
|
||||
getOrganizationActivation,
|
||||
getProjectActivation,
|
||||
recordFirstMcpAuthorized,
|
||||
recordFirstMcpToolCall,
|
||||
markCompetitorStepClicked,
|
||||
markMcpCardDismissed,
|
||||
};
|
||||
@ -0,0 +1,21 @@
|
||||
import { countDistinct, eq } from "drizzle-orm";
|
||||
import { db } from "@/db";
|
||||
import { auditIssues } from "@/db/schema";
|
||||
|
||||
/**
|
||||
* Distinct-page counts per issue type for one audit — link-level issues
|
||||
* write one row per occurrence, and consumers phrase this as "N pages".
|
||||
* Lives beside AuditRepository (same pattern as rank-tracking's
|
||||
* snapshotQueries) to keep the main repository under the file-size limit.
|
||||
*/
|
||||
export async function getIssueTypePageCountsForAudit(auditId: string) {
|
||||
return db
|
||||
.select({
|
||||
issueType: auditIssues.issueType,
|
||||
severity: auditIssues.severity,
|
||||
pages: countDistinct(auditIssues.pageUrl),
|
||||
})
|
||||
.from(auditIssues)
|
||||
.where(eq(auditIssues.auditId, auditId))
|
||||
.groupBy(auditIssues.issueType, auditIssues.severity);
|
||||
}
|
||||
@ -0,0 +1,34 @@
|
||||
import { desc, eq } from "drizzle-orm";
|
||||
import { db } from "@/db";
|
||||
import { backlinkSnapshots } from "@/db/schema";
|
||||
|
||||
type BacklinkSnapshot = typeof backlinkSnapshots.$inferSelect;
|
||||
|
||||
async function getLatestForProject(
|
||||
projectId: string,
|
||||
): Promise<BacklinkSnapshot | null> {
|
||||
const rows = await db
|
||||
.select()
|
||||
.from(backlinkSnapshots)
|
||||
.where(eq(backlinkSnapshots.projectId, projectId))
|
||||
// id, not capturedAt: autoincrement is monotonic and immune to the
|
||||
// sqlite-vs-pg timestamp text-format difference.
|
||||
.orderBy(desc(backlinkSnapshots.id))
|
||||
.limit(1);
|
||||
return rows[0] ?? null;
|
||||
}
|
||||
|
||||
async function insert(
|
||||
values: typeof backlinkSnapshots.$inferInsert,
|
||||
): Promise<BacklinkSnapshot> {
|
||||
const [row] = await db.insert(backlinkSnapshots).values(values).returning();
|
||||
if (!row) {
|
||||
throw new Error("Failed to insert backlink_snapshot");
|
||||
}
|
||||
return row;
|
||||
}
|
||||
|
||||
export const BacklinkSnapshotRepository = {
|
||||
getLatestForProject,
|
||||
insert,
|
||||
};
|
||||
273
src/server/features/dashboard/services/DashboardService.ts
Normal file
273
src/server/features/dashboard/services/DashboardService.ts
Normal file
@ -0,0 +1,273 @@
|
||||
import type { BillingCustomerContext } from "@/server/billing/subscription";
|
||||
import { ActivationRepository } from "@/server/features/activation/repositories/ActivationRepository";
|
||||
import { AuditRepository } from "@/server/features/audit/repositories/AuditRepository";
|
||||
import { getIssueTypePageCountsForAudit } from "@/server/features/audit/repositories/auditSummaryQueries";
|
||||
import { BacklinkSnapshotRepository } from "@/server/features/dashboard/repositories/BacklinkSnapshotRepository";
|
||||
import { GscConnectionRepository } from "@/server/features/gsc/repositories/GscConnectionRepository";
|
||||
import { RankTrackingRepository } from "@/server/features/rank-tracking/repositories/RankTrackingRepository";
|
||||
import { getLatestResults } from "@/server/features/rank-tracking/services/rankTrackingResults";
|
||||
import {
|
||||
createDataforseoClient,
|
||||
normalizeBacklinksTarget,
|
||||
} from "@/server/lib/dataforseo";
|
||||
|
||||
// Daily cadence: fresh numbers each visit without per-visit spend; a dormant
|
||||
// project costs nothing because refreshes are visit-triggered.
|
||||
const SNAPSHOT_MAX_AGE_MS = 24 * 60 * 60 * 1000;
|
||||
// Bounds the per-config result reads on the overview path; projects rarely
|
||||
// have more than a couple of configs.
|
||||
const MAX_CONFIGS_FOR_OVERVIEW = 5;
|
||||
|
||||
export type DashboardActivation = {
|
||||
domain: string | null;
|
||||
gsc: { connected: boolean; siteUrl: string | null };
|
||||
mcp: {
|
||||
authorizedAt: string | null;
|
||||
firstToolCallAt: string | null;
|
||||
cardDismissedAt: string | null;
|
||||
};
|
||||
competitorClickedAt: string | null;
|
||||
};
|
||||
|
||||
export type DashboardRankSummary = {
|
||||
trackedKeywords: number;
|
||||
improved: number;
|
||||
declined: number;
|
||||
top10: number;
|
||||
lastCheckedAt: string | null;
|
||||
};
|
||||
|
||||
export type DashboardAuditSummary = {
|
||||
status: "running" | "completed" | "failed";
|
||||
pagesCrawled: number;
|
||||
startedAt: string;
|
||||
// Top issue types by severity then affected-page count, for the card's list.
|
||||
topIssues: {
|
||||
issueType: string;
|
||||
severity: "critical" | "warning" | "info";
|
||||
count: number;
|
||||
}[];
|
||||
totalIssueTypes: number;
|
||||
};
|
||||
|
||||
export type DashboardBacklinkSummary = {
|
||||
domain: string;
|
||||
rank: number | null;
|
||||
backlinks: number | null;
|
||||
referringDomains: number | null;
|
||||
newBacklinks: number | null;
|
||||
lostBacklinks: number | null;
|
||||
newReferringDomains: number | null;
|
||||
lostReferringDomains: number | null;
|
||||
capturedAt: string;
|
||||
stale: boolean;
|
||||
};
|
||||
|
||||
type DashboardOverview = {
|
||||
rank: DashboardRankSummary | null;
|
||||
audit: DashboardAuditSummary | null;
|
||||
backlinks: DashboardBacklinkSummary | null;
|
||||
};
|
||||
|
||||
async function getActivation(input: {
|
||||
projectId: string;
|
||||
organizationId: string;
|
||||
domain: string | null;
|
||||
}): Promise<DashboardActivation> {
|
||||
const [gsc, orgActivation, projectActivation] = await Promise.all([
|
||||
GscConnectionRepository.getByProjectId(input.projectId),
|
||||
ActivationRepository.getOrganizationActivation(input.organizationId),
|
||||
ActivationRepository.getProjectActivation(input.projectId),
|
||||
]);
|
||||
|
||||
return {
|
||||
domain: input.domain,
|
||||
gsc: { connected: gsc !== null, siteUrl: gsc?.siteUrl ?? null },
|
||||
mcp: {
|
||||
authorizedAt: orgActivation?.firstMcpAuthorizedAt ?? null,
|
||||
firstToolCallAt: orgActivation?.firstMcpToolCallAt ?? null,
|
||||
cardDismissedAt: projectActivation?.mcpCardDismissedAt ?? null,
|
||||
},
|
||||
competitorClickedAt: projectActivation?.competitorStepClickedAt ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
async function getOverview(input: {
|
||||
projectId: string;
|
||||
domain: string | null;
|
||||
}): Promise<DashboardOverview> {
|
||||
const [rank, audit, backlinks] = await Promise.all([
|
||||
getRankSummary(input.projectId),
|
||||
getAuditSummary(input.projectId),
|
||||
getBacklinkSummary(input.projectId, input.domain),
|
||||
]);
|
||||
return { rank, audit, backlinks };
|
||||
}
|
||||
|
||||
async function getRankSummary(
|
||||
projectId: string,
|
||||
): Promise<DashboardRankSummary | null> {
|
||||
const configs = await RankTrackingRepository.getConfigsForProject(projectId);
|
||||
if (configs.length === 0) return null;
|
||||
|
||||
const results = await Promise.all(
|
||||
configs
|
||||
.slice(0, MAX_CONFIGS_FOR_OVERVIEW)
|
||||
.map((config) => getLatestResults(config.id, projectId, "7d")),
|
||||
);
|
||||
|
||||
const summary: DashboardRankSummary = {
|
||||
trackedKeywords: 0,
|
||||
improved: 0,
|
||||
declined: 0,
|
||||
top10: 0,
|
||||
lastCheckedAt: null,
|
||||
};
|
||||
|
||||
for (const result of results) {
|
||||
summary.trackedKeywords += result.rows.length;
|
||||
if (
|
||||
result.run &&
|
||||
(!summary.lastCheckedAt ||
|
||||
result.run.lastCheckedAt > summary.lastCheckedAt)
|
||||
) {
|
||||
summary.lastCheckedAt = result.run.lastCheckedAt;
|
||||
}
|
||||
for (const row of result.rows) {
|
||||
for (const device of ["desktop", "mobile"] as const) {
|
||||
const { position, previousPosition } = row[device];
|
||||
if (position !== null && position <= 10) summary.top10 += 1;
|
||||
if (position === null || previousPosition === null) continue;
|
||||
// Lower position number = better ranking.
|
||||
if (position < previousPosition) summary.improved += 1;
|
||||
else if (position > previousPosition) summary.declined += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return summary;
|
||||
}
|
||||
|
||||
async function getAuditSummary(
|
||||
projectId: string,
|
||||
): Promise<DashboardAuditSummary | null> {
|
||||
const audit = await AuditRepository.getLatestAuditForProject(projectId);
|
||||
if (!audit) return null;
|
||||
|
||||
const typeRows = await getIssueTypePageCountsForAudit(audit.id);
|
||||
|
||||
const severityRank = { critical: 0, warning: 1, info: 2 };
|
||||
const sorted = typeRows
|
||||
.map((row) => ({
|
||||
issueType: row.issueType,
|
||||
severity: row.severity,
|
||||
count: row.pages,
|
||||
}))
|
||||
.toSorted(
|
||||
(a, b) =>
|
||||
severityRank[a.severity] - severityRank[b.severity] ||
|
||||
b.count - a.count,
|
||||
);
|
||||
|
||||
return {
|
||||
status: audit.status,
|
||||
pagesCrawled: audit.pagesCrawled,
|
||||
startedAt: audit.startedAt,
|
||||
topIssues: sorted.slice(0, 3),
|
||||
totalIssueTypes: sorted.length,
|
||||
};
|
||||
}
|
||||
|
||||
function isSnapshotFresh(capturedAt: string): boolean {
|
||||
const capturedMs = Date.parse(capturedAt);
|
||||
if (Number.isNaN(capturedMs)) return false;
|
||||
return Date.now() - capturedMs < SNAPSHOT_MAX_AGE_MS;
|
||||
}
|
||||
|
||||
async function getBacklinkSummary(
|
||||
projectId: string,
|
||||
domain: string | null,
|
||||
): Promise<DashboardBacklinkSummary | null> {
|
||||
if (!domain) return null;
|
||||
const snapshot =
|
||||
await BacklinkSnapshotRepository.getLatestForProject(projectId);
|
||||
if (!snapshot || snapshot.domain !== domain) return null;
|
||||
return {
|
||||
domain: snapshot.domain,
|
||||
rank: snapshot.rank,
|
||||
backlinks: snapshot.backlinks,
|
||||
referringDomains: snapshot.referringDomains,
|
||||
newBacklinks: snapshot.newBacklinks,
|
||||
lostBacklinks: snapshot.lostBacklinks,
|
||||
newReferringDomains: snapshot.newReferringDomains,
|
||||
lostReferringDomains: snapshot.lostReferringDomains,
|
||||
capturedAt: snapshot.capturedAt,
|
||||
stale: !isSnapshotFresh(snapshot.capturedAt),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Visit-triggered snapshot refresh. Fetches only the DataForSEO backlinks
|
||||
* summary (not the history endpoint the backlinks page also pays for) and is
|
||||
* a no-op while the latest snapshot for the current domain is under a day
|
||||
* old. Concurrent loads racing the freshness check can each pay a metered
|
||||
* call — every call is metered, so the race duplicates customer spend on
|
||||
* identical data but never leaks revenue; accepted for now. On a fetch
|
||||
* failure with a stale snapshot in hand, the stale snapshot is returned
|
||||
* rather than surfacing an error card.
|
||||
*/
|
||||
async function ensureBacklinkSnapshot(input: {
|
||||
projectId: string;
|
||||
domain: string | null;
|
||||
billingCustomer: BillingCustomerContext;
|
||||
}): Promise<DashboardBacklinkSummary | null> {
|
||||
const { projectId, domain } = input;
|
||||
if (!domain) return null;
|
||||
|
||||
const latest =
|
||||
await BacklinkSnapshotRepository.getLatestForProject(projectId);
|
||||
const latestMatchesDomain = latest !== null && latest.domain === domain;
|
||||
if (latest && latestMatchesDomain && isSnapshotFresh(latest.capturedAt)) {
|
||||
return getBacklinkSummary(projectId, domain);
|
||||
}
|
||||
|
||||
const normalized = normalizeBacklinksTarget(domain, { scope: "domain" });
|
||||
const dataforseo = createDataforseoClient(input.billingCustomer);
|
||||
|
||||
try {
|
||||
const summary = await dataforseo.backlinks.summary({
|
||||
target: normalized.apiTarget,
|
||||
});
|
||||
await BacklinkSnapshotRepository.insert({
|
||||
projectId,
|
||||
domain,
|
||||
rank: summary.rank ?? null,
|
||||
backlinks: summary.backlinks ?? null,
|
||||
referringDomains: summary.referring_domains ?? null,
|
||||
brokenBacklinks: summary.broken_backlinks ?? null,
|
||||
newBacklinks: summary.new_backlinks ?? null,
|
||||
lostBacklinks: summary.lost_backlinks ?? null,
|
||||
newReferringDomains:
|
||||
summary.new_referring_domains ?? summary.new_reffering_domains ?? null,
|
||||
lostReferringDomains:
|
||||
summary.lost_referring_domains ??
|
||||
summary.lost_reffering_domains ??
|
||||
null,
|
||||
capturedAt: new Date().toISOString(),
|
||||
});
|
||||
} catch (error) {
|
||||
if (latestMatchesDomain) {
|
||||
console.error("dashboard: backlink snapshot refresh failed", error);
|
||||
return getBacklinkSummary(projectId, domain);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
return getBacklinkSummary(projectId, domain);
|
||||
}
|
||||
|
||||
export const DashboardService = {
|
||||
getActivation,
|
||||
getOverview,
|
||||
ensureBacklinkSnapshot,
|
||||
};
|
||||
@ -101,6 +101,31 @@ async function updateProject(
|
||||
return row;
|
||||
}
|
||||
|
||||
// Writes only the domain column, for the dashboard's inline domain input.
|
||||
async function updateProjectDomain(
|
||||
projectId: string,
|
||||
organizationId: string,
|
||||
domain: string,
|
||||
) {
|
||||
const [row] = await db
|
||||
.update(projects)
|
||||
.set({ domain })
|
||||
.where(
|
||||
and(
|
||||
eq(projects.id, projectId),
|
||||
eq(projects.organizationId, organizationId),
|
||||
isNull(projects.archivedAt),
|
||||
),
|
||||
)
|
||||
.returning();
|
||||
|
||||
if (!row) {
|
||||
throw new AppError("NOT_FOUND");
|
||||
}
|
||||
|
||||
return row;
|
||||
}
|
||||
|
||||
// Writes only the market columns. Onboarding sets the project's market before
|
||||
// the user has named the project or picked a domain, so it must not go through
|
||||
// updateProject, whose `domain: input.domain ?? null` would clear the domain.
|
||||
@ -197,6 +222,7 @@ export const ProjectRepository = {
|
||||
getProjectById,
|
||||
createProject,
|
||||
updateProject,
|
||||
updateProjectDomain,
|
||||
updateProjectMarket,
|
||||
tryCreateDefaultProject,
|
||||
archiveProject,
|
||||
|
||||
@ -6,6 +6,7 @@ import {
|
||||
listProjects,
|
||||
listProjectsEnsuringOne,
|
||||
restoreProject,
|
||||
setProjectDomain,
|
||||
setProjectMarket,
|
||||
updateProject,
|
||||
} from "@/server/features/projects/services/projects";
|
||||
@ -15,6 +16,7 @@ export const ProjectService = {
|
||||
listProjectsEnsuringOne,
|
||||
createProject,
|
||||
updateProject,
|
||||
setProjectDomain,
|
||||
setProjectMarket,
|
||||
archiveProject,
|
||||
restoreProject,
|
||||
|
||||
@ -3,6 +3,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
const mocks = vi.hoisted(() => ({
|
||||
createProject: vi.fn(),
|
||||
updateProject: vi.fn(),
|
||||
updateProjectDomain: vi.fn(),
|
||||
archiveProject: vi.fn(),
|
||||
restoreProject: vi.fn(),
|
||||
countProjects: vi.fn(),
|
||||
@ -208,6 +209,51 @@ describe("project service", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("setProjectDomain", () => {
|
||||
it("canonicalizes a pasted URL to the bare host before writing", async () => {
|
||||
mocks.updateProjectDomain.mockResolvedValue(namedProject);
|
||||
const { setProjectDomain } = await import("./projects");
|
||||
|
||||
await setProjectDomain("org_1", {
|
||||
projectId: "project_acme",
|
||||
domain: "https://www.Acme.com/pricing?ref=x",
|
||||
});
|
||||
|
||||
expect(mocks.updateProjectDomain).toHaveBeenCalledWith(
|
||||
"project_acme",
|
||||
"org_1",
|
||||
"acme.com",
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects junk that the backlink fetch would later refuse", async () => {
|
||||
const { setProjectDomain } = await import("./projects");
|
||||
|
||||
await expect(
|
||||
setProjectDomain("org_1", {
|
||||
projectId: "project_acme",
|
||||
domain: "not a domain",
|
||||
}),
|
||||
).rejects.toThrow("Enter a valid domain");
|
||||
expect(mocks.updateProjectDomain).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("updateProject domain validation", () => {
|
||||
it("rejects a junk domain instead of storing it", async () => {
|
||||
const { updateProject } = await import("./projects");
|
||||
|
||||
await expect(
|
||||
updateProject("org_1", {
|
||||
projectId: "project_acme",
|
||||
name: "Acme",
|
||||
domain: "999.999.999.999",
|
||||
}),
|
||||
).rejects.toThrow("Enter a valid domain");
|
||||
expect(mocks.updateProject).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("setProjectMarket", () => {
|
||||
it("writes only the market columns, leaving name and domain untouched", async () => {
|
||||
// Onboarding sets the market before the project is named or given a
|
||||
|
||||
@ -2,10 +2,12 @@ import type {
|
||||
ArchiveProjectInput,
|
||||
CreateProjectInput,
|
||||
RestoreProjectInput,
|
||||
SetProjectDomainInput,
|
||||
SetProjectMarketInput,
|
||||
UpdateProjectInput,
|
||||
} from "@/types/schemas/projects";
|
||||
import { ProjectRepository } from "@/server/features/projects/repositories/ProjectRepository";
|
||||
import { normalizeBacklinksTarget } from "@/server/lib/dataforseoBacklinksTarget";
|
||||
import { AppError } from "@/server/lib/errors";
|
||||
import { assertLanguageForLocation } from "@/server/lib/market";
|
||||
import { getLanguageCode } from "@/shared/keyword-locations";
|
||||
@ -83,6 +85,24 @@ export async function listProjectsEnsuringOne(organizationId: string) {
|
||||
return listProjects(organizationId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates and canonicalizes a project domain (lowercase bare host, www and
|
||||
* protocol/path stripped) with the same rules the backlink fetch will apply
|
||||
* later, so junk fails at save time instead of at the first paid call.
|
||||
* Undefined passes through — updateProject uses that to clear the domain.
|
||||
*/
|
||||
function normalizeProjectDomain(domain: string | undefined) {
|
||||
if (domain === undefined) return undefined;
|
||||
try {
|
||||
return normalizeBacklinksTarget(domain, { scope: "domain" }).apiTarget;
|
||||
} catch {
|
||||
throw new AppError(
|
||||
"VALIDATION_ERROR",
|
||||
"Enter a valid domain, like acme.com.",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function createProject(
|
||||
organizationId: string,
|
||||
input: CreateProjectInput,
|
||||
@ -91,7 +111,7 @@ export async function createProject(
|
||||
const row = await ProjectRepository.createProject(
|
||||
organizationId,
|
||||
input.name,
|
||||
input.domain,
|
||||
normalizeProjectDomain(input.domain),
|
||||
resolveMarketInput(input),
|
||||
);
|
||||
return mapProject(row);
|
||||
@ -113,7 +133,7 @@ export async function updateProject(
|
||||
organizationId,
|
||||
{
|
||||
name: input.name,
|
||||
domain: input.domain,
|
||||
domain: normalizeProjectDomain(input.domain),
|
||||
market: resolveMarketInput(input),
|
||||
},
|
||||
);
|
||||
@ -126,6 +146,27 @@ export async function updateProject(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets a project's domain on its own, for the dashboard hero's inline input.
|
||||
* Writing just this column keeps the write from echoing a name/market the
|
||||
* caller never edited.
|
||||
*/
|
||||
export async function setProjectDomain(
|
||||
organizationId: string,
|
||||
input: SetProjectDomainInput,
|
||||
) {
|
||||
const domain = normalizeProjectDomain(input.domain);
|
||||
if (domain === undefined) {
|
||||
throw new AppError("VALIDATION_ERROR", "Enter a valid domain.");
|
||||
}
|
||||
const row = await ProjectRepository.updateProjectDomain(
|
||||
input.projectId,
|
||||
organizationId,
|
||||
domain,
|
||||
);
|
||||
return mapProject(row);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets a project's default market on its own, for surfaces that only ask for
|
||||
* the market (onboarding). Writing just these two columns keeps the write from
|
||||
|
||||
@ -11,6 +11,7 @@ import { AppError } from "@/server/lib/errors";
|
||||
const mocks = vi.hoisted(() => ({
|
||||
captureServerError: vi.fn(),
|
||||
captureServerEvent: vi.fn(),
|
||||
recordExternalMcpToolCall: vi.fn(),
|
||||
incrementSelfHostMcpToolCallCount: vi.fn(),
|
||||
}));
|
||||
|
||||
@ -24,6 +25,12 @@ vi.mock("@/server/lib/posthog", () => ({
|
||||
captureServerEvent: mocks.captureServerEvent,
|
||||
}));
|
||||
|
||||
// The real module pulls in @/db (cloudflare:workers env) — mock it out and
|
||||
// assert the milestone hook at this boundary instead.
|
||||
vi.mock("@/server/features/activation/mcpActivation", () => ({
|
||||
recordExternalMcpToolCall: mocks.recordExternalMcpToolCall,
|
||||
}));
|
||||
|
||||
vi.mock("@/server/lib/self-host-telemetry", () => ({
|
||||
incrementSelfHostMcpToolCallCount: mocks.incrementSelfHostMcpToolCallCount,
|
||||
}));
|
||||
@ -56,6 +63,7 @@ describe("instrumentMcpToolHandler", () => {
|
||||
beforeEach(() => {
|
||||
mocks.captureServerError.mockReset();
|
||||
mocks.captureServerEvent.mockReset();
|
||||
mocks.recordExternalMcpToolCall.mockReset();
|
||||
mocks.incrementSelfHostMcpToolCallCount.mockReset();
|
||||
});
|
||||
|
||||
@ -172,5 +180,45 @@ describe("instrumentMcpToolHandler", () => {
|
||||
await wrapped({}, toolExtra);
|
||||
|
||||
expect(mocks.captureServerEvent).not.toHaveBeenCalled();
|
||||
expect(mocks.recordExternalMcpToolCall).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("records the activation milestone for a successful external call", async () => {
|
||||
const { instrumentMcpToolHandler } = await import("./instrumentation");
|
||||
const wrapped = instrumentMcpToolHandler("demo", outputSchema, async () =>
|
||||
okResult({ items: [] }),
|
||||
);
|
||||
|
||||
await runWithMcpToolAuthContext(authContext, () => wrapped({}, toolExtra));
|
||||
|
||||
expect(mocks.recordExternalMcpToolCall).toHaveBeenCalledExactlyOnceWith(
|
||||
"org-1",
|
||||
);
|
||||
});
|
||||
|
||||
it("skips the activation milestone for first-party (null clientId) calls", async () => {
|
||||
const { instrumentMcpToolHandler } = await import("./instrumentation");
|
||||
const wrapped = instrumentMcpToolHandler("demo", outputSchema, async () =>
|
||||
okResult({ items: [] }),
|
||||
);
|
||||
|
||||
await runWithMcpToolAuthContext({ ...authContext, clientId: null }, () =>
|
||||
wrapped({}, toolExtra),
|
||||
);
|
||||
|
||||
expect(mocks.recordExternalMcpToolCall).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("skips the activation milestone when the call fails", async () => {
|
||||
const { instrumentMcpToolHandler } = await import("./instrumentation");
|
||||
const wrapped = instrumentMcpToolHandler("demo", outputSchema, async () => {
|
||||
throw new AppError("NOT_FOUND");
|
||||
});
|
||||
|
||||
await expect(
|
||||
runWithMcpToolAuthContext(authContext, () => wrapped({}, toolExtra)),
|
||||
).rejects.toThrow("NOT_FOUND");
|
||||
|
||||
expect(mocks.recordExternalMcpToolCall).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@ -8,6 +8,7 @@ import {
|
||||
type ZodRawShapeCompat,
|
||||
} from "@modelcontextprotocol/sdk/server/zod-compat.js";
|
||||
import { asAppError } from "@/server/lib/errors";
|
||||
import { recordExternalMcpToolCall } from "@/server/features/activation/mcpActivation";
|
||||
import { captureServerError, captureServerEvent } from "@/server/lib/posthog";
|
||||
import { shouldCaptureAppErrorCode } from "@/shared/error-codes";
|
||||
import { getAuth, type ToolExtra } from "@/server/mcp/context";
|
||||
@ -114,6 +115,21 @@ export function instrumentMcpToolHandler<TArgs>(
|
||||
? { success: false, errorCode: "MCP_OUTPUT_VALIDATION" }
|
||||
: { success: !result.isError },
|
||||
);
|
||||
// Dashboard activation milestone: a successful call from an external
|
||||
// MCP client (OAuth clientId; SAM and the self-hosted transport are
|
||||
// first-party with clientId null). Awaited so the write stays inside
|
||||
// the request's DB scope; a per-isolate memo keeps this off the hot
|
||||
// path after the first call.
|
||||
if (!result.isError && !outputValidationFailed) {
|
||||
try {
|
||||
const auth = getAuth(extra);
|
||||
if (auth.clientId) {
|
||||
await recordExternalMcpToolCall(auth.organizationId);
|
||||
}
|
||||
} catch {
|
||||
// no auth context — skip milestone tracking
|
||||
}
|
||||
}
|
||||
return result;
|
||||
} catch (error) {
|
||||
const appError = asAppError(error);
|
||||
|
||||
@ -13,6 +13,7 @@ import {
|
||||
MCP_SCOPE,
|
||||
} from "@/lib/oauth-resource";
|
||||
import { asAppError } from "@/server/lib/errors";
|
||||
import { recordMcpAuthorized } from "@/server/features/activation/mcpActivation";
|
||||
import { captureServerEvent } from "@/server/lib/posthog";
|
||||
import {
|
||||
createWorkersOAuthMcpProps,
|
||||
@ -366,6 +367,8 @@ async function handleOAuthConsentResponse(
|
||||
props,
|
||||
});
|
||||
|
||||
await recordMcpAuthorized(context.organizationId);
|
||||
|
||||
waitUntil(
|
||||
captureServerEvent({
|
||||
distinctId: context.userId,
|
||||
|
||||
63
src/serverFunctions/dashboard.ts
Normal file
63
src/serverFunctions/dashboard.ts
Normal file
@ -0,0 +1,63 @@
|
||||
import { createServerFn } from "@tanstack/react-start";
|
||||
import { ActivationRepository } from "@/server/features/activation/repositories/ActivationRepository";
|
||||
import { DashboardService } from "@/server/features/dashboard/services/DashboardService";
|
||||
import { requireProjectContext } from "@/serverFunctions/middleware";
|
||||
import { dashboardProjectInputSchema } from "@/types/schemas/dashboard";
|
||||
|
||||
export const getDashboardActivation = createServerFn({ method: "POST" })
|
||||
.middleware(requireProjectContext)
|
||||
.validator(dashboardProjectInputSchema)
|
||||
.handler(({ context }) =>
|
||||
DashboardService.getActivation({
|
||||
projectId: context.projectId,
|
||||
organizationId: context.organizationId,
|
||||
domain: context.project.domain,
|
||||
}),
|
||||
);
|
||||
|
||||
export const getDashboardOverview = createServerFn({ method: "POST" })
|
||||
.middleware(requireProjectContext)
|
||||
.validator(dashboardProjectInputSchema)
|
||||
.handler(({ context }) =>
|
||||
DashboardService.getOverview({
|
||||
projectId: context.projectId,
|
||||
domain: context.project.domain,
|
||||
}),
|
||||
);
|
||||
|
||||
// Visit-triggered: the client calls this when the overview reports a missing
|
||||
// or stale backlink snapshot. Metered against org credits at most once per
|
||||
// project per day (the service re-checks freshness server-side).
|
||||
export const refreshDashboardBacklinkSnapshot = createServerFn({
|
||||
method: "POST",
|
||||
})
|
||||
.middleware(requireProjectContext)
|
||||
.validator(dashboardProjectInputSchema)
|
||||
.handler(({ context }) =>
|
||||
DashboardService.ensureBacklinkSnapshot({
|
||||
projectId: context.projectId,
|
||||
domain: context.project.domain,
|
||||
billingCustomer: context,
|
||||
}),
|
||||
);
|
||||
|
||||
export const markDashboardCompetitorClicked = createServerFn({
|
||||
method: "POST",
|
||||
})
|
||||
.middleware(requireProjectContext)
|
||||
.validator(dashboardProjectInputSchema)
|
||||
.handler(async ({ context }) => {
|
||||
await ActivationRepository.markCompetitorStepClicked(context.projectId);
|
||||
return { ok: true as const };
|
||||
});
|
||||
|
||||
// "I already connected" on the MCP card. Hides the card for this project;
|
||||
// the org-level milestone stays untouched and self-corrects on the next
|
||||
// real external tool call.
|
||||
export const dismissDashboardMcpCard = createServerFn({ method: "POST" })
|
||||
.middleware(requireProjectContext)
|
||||
.validator(dashboardProjectInputSchema)
|
||||
.handler(async ({ context }) => {
|
||||
await ActivationRepository.markMcpCardDismissed(context.projectId);
|
||||
return { ok: true as const };
|
||||
});
|
||||
@ -8,6 +8,7 @@ import {
|
||||
archiveProjectSchema,
|
||||
createProjectSchema,
|
||||
restoreProjectSchema,
|
||||
setProjectDomainSchema,
|
||||
setProjectMarketSchema,
|
||||
updateProjectSchema,
|
||||
} from "@/types/schemas/projects";
|
||||
@ -35,6 +36,13 @@ export const updateProject = createServerFn({ method: "POST" })
|
||||
ProjectService.updateProject(context.organizationId, data),
|
||||
);
|
||||
|
||||
export const setProjectDomain = createServerFn({ method: "POST" })
|
||||
.middleware(requireProjectContext)
|
||||
.validator(setProjectDomainSchema)
|
||||
.handler(async ({ data, context }) =>
|
||||
ProjectService.setProjectDomain(context.organizationId, data),
|
||||
);
|
||||
|
||||
export const setProjectMarket = createServerFn({ method: "POST" })
|
||||
.middleware(requireProjectContext)
|
||||
.validator(setProjectMarketSchema)
|
||||
|
||||
13
src/types/schemas/dashboard.ts
Normal file
13
src/types/schemas/dashboard.ts
Normal file
@ -0,0 +1,13 @@
|
||||
import { z } from "zod";
|
||||
|
||||
export const dashboardHeroStepSchema = z.enum([
|
||||
"domain",
|
||||
"mcp",
|
||||
"gsc",
|
||||
"competitor",
|
||||
]);
|
||||
export type DashboardHeroStep = z.infer<typeof dashboardHeroStepSchema>;
|
||||
|
||||
export const dashboardProjectInputSchema = z.object({
|
||||
projectId: z.string().min(1),
|
||||
});
|
||||
@ -63,6 +63,13 @@ export const updateProjectSchema = z
|
||||
})
|
||||
.refine(hasLocationForLanguage, marketPairMessage);
|
||||
|
||||
// Domain on its own, for the dashboard hero's inline input. Same loose shape
|
||||
// as updateProjectSchema's domain field, but required.
|
||||
export const setProjectDomainSchema = z.object({
|
||||
projectId: z.string().min(1),
|
||||
domain: z.string().trim().min(1).max(255),
|
||||
});
|
||||
|
||||
// Market-only update (onboarding). Both halves are required: the caller picks
|
||||
// them together, so the service can validate the pair without a stored row.
|
||||
export const setProjectMarketSchema = z.object({
|
||||
@ -89,6 +96,7 @@ export const restoreProjectSchema = z.object({
|
||||
|
||||
export type CreateProjectInput = z.infer<typeof createProjectSchema>;
|
||||
export type UpdateProjectInput = z.infer<typeof updateProjectSchema>;
|
||||
export type SetProjectDomainInput = z.infer<typeof setProjectDomainSchema>;
|
||||
export type SetProjectMarketInput = z.infer<typeof setProjectMarketSchema>;
|
||||
export type ArchiveProjectInput = z.infer<typeof archiveProjectSchema>;
|
||||
export type RestoreProjectInput = z.infer<typeof restoreProjectSchema>;
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user