Project dashboard: onboarding checklist + domain overview (#398)

This commit is contained in:
Ben Senescu 2026-07-18 21:52:35 -04:00 committed by GitHub
parent 040282511a
commit eda5e1e354
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
39 changed files with 9126 additions and 116 deletions

View 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");

File diff suppressed because it is too large Load Diff

View File

@ -85,6 +85,13 @@
"when": 1784410131656, "when": 1784410131656,
"tag": "0011_friendly_morlun", "tag": "0011_friendly_morlun",
"breakpoints": true "breakpoints": true
},
{
"idx": 12,
"version": "7",
"when": 1784423181488,
"tag": "0012_dashboard",
"breakpoints": true
} }
] ]
} }

View 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
);

File diff suppressed because it is too large Load Diff

View File

@ -246,6 +246,13 @@
"when": 1784410130115, "when": 1784410130115,
"tag": "0034_wonderful_skrulls", "tag": "0034_wonderful_skrulls",
"breakpoints": true "breakpoints": true
},
{
"idx": 35,
"version": "6",
"when": 1784423179842,
"tag": "0035_dashboard",
"breakpoints": true
} }
] ]
} }

View File

@ -57,9 +57,9 @@ function SidebarNavLink({
}) { }) {
return ( return (
<Link <Link
{...linkProps}
onClick={onNavigate} onClick={onNavigate}
activeOptions={{ exact: false, includeSearch: false }} activeOptions={{ exact: false, includeSearch: false }}
{...linkProps}
className={navItemClass} className={navItemClass}
activeProps={navItemActiveProps} 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 ( return (
<div className="flex h-full w-60 flex-col bg-base-200"> <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"> <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} icon={LayoutGrid}
label="Browse" label="Browse"
active={view === "browse"} active={view === "browse"}
onClick={() => setView("browse")} onClick={openBrowse}
/> />
<SidebarViewTab <SidebarViewTab
icon={MessageCircle} icon={MessageCircle}

View 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&rsquo;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&rsquo;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>
);
}

View 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>
);
}

View 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&rsquo;t limited on &ldquo;AI credits&rdquo;.
</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 &amp; MCP
</Link>
</>
)}
</div>
</div>
);
}

View 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",
});
}

View 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;
}

View File

@ -96,6 +96,14 @@ export function SearchConsoleConnectionCard({
void queryClient.invalidateQueries({ void queryClient.invalidateQueries({
queryKey: ["searchPerformanceTable", projectId], 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)), onError: (error) => toast.error(getStandardErrorMessage(error)),
}); });
@ -116,6 +124,12 @@ export function SearchConsoleConnectionCard({
void queryClient.invalidateQueries({ void queryClient.invalidateQueries({
queryKey: ["searchPerformanceTable", projectId], queryKey: ["searchPerformanceTable", projectId],
}); });
void queryClient.invalidateQueries({
queryKey: ["dashboardActivation", projectId],
});
void queryClient.invalidateQueries({
queryKey: ["dashboardGscReport", projectId],
});
}, },
onError: (error) => toast.error(getStandardErrorMessage(error)), onError: (error) => toast.error(getStandardErrorMessage(error)),
}); });
@ -177,7 +191,8 @@ export function SearchConsoleConnectionCard({
) : ( ) : (
<div className="space-y-4"> <div className="space-y-4">
<p className="text-sm text-base-content/70"> <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> </p>
<button <button
type="button" type="button"

View File

@ -1,4 +1,4 @@
import { ArrowLeft, ArrowRight, Check } from "lucide-react"; import { ArrowRight, Check } from "lucide-react";
import type { ReactNode } from "react"; import type { ReactNode } from "react";
import { Fragment } from "react"; import { Fragment } from "react";
import { import {
@ -22,7 +22,7 @@ type PostSignupOnboardingProps = {
onNext: () => void; onNext: () => void;
onBack: () => void; onBack: () => void;
onSkip: () => void; onSkip: () => void;
onFinish: (mcpSetupIntent: "yes" | "no") => void; onFinish: () => void;
isSaving: boolean; isSaving: boolean;
accountMenu: ReactNode; accountMenu: ReactNode;
}; };
@ -122,18 +122,10 @@ export function PostSignupOnboarding({
otherValue={answers.sourceOther} otherValue={answers.sourceOther}
onOtherChange={(sourceOther) => updateAnswers({ sourceOther })} onOtherChange={(sourceOther) => updateAnswers({ sourceOther })}
/> />
) : step === 3 ? (
<SearchConsoleOnboardingStep />
) : ( ) : (
<McpRecommendation <SearchConsoleOnboardingStep />
isSaving={isSaving}
onBack={onBack}
onSetup={() => onFinish("yes")}
onSkip={() => onFinish("no")}
/>
)} )}
{step < ONBOARDING_LAST_STEP ? (
<div className="mt-5 flex items-center justify-between gap-3"> <div className="mt-5 flex items-center justify-between gap-3">
<button <button
type="button" type="button"
@ -143,6 +135,7 @@ export function PostSignupOnboarding({
> >
Back Back
</button> </button>
{step < ONBOARDING_LAST_STEP ? (
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<button <button
type="button" type="button"
@ -162,75 +155,19 @@ export function PostSignupOnboarding({
<ArrowRight className="size-4" /> <ArrowRight className="size-4" />
</button> </button>
</div> </div>
</div> ) : (
) : null}
</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 <button
type="button" type="button"
className="btn btn-ghost btn-sm -ml-2 mb-2 self-start gap-1.5 text-base-content/60" className="btn btn-primary"
disabled={isSaving} disabled={isSaving}
onClick={onBack} onClick={onFinish}
> >
<ArrowLeft className="size-4" /> Finish
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" /> <ArrowRight className="size-4" />
</button> </button>
<button )}
type="button" </div>
className="btn btn-ghost btn-sm mt-2 w-full text-base-content/60" </div>
disabled={isSaving}
onClick={onSkip}
>
Not now
</button>
</div> </div>
); );
} }

View File

@ -1,7 +1,7 @@
import { queryOptions } from "@tanstack/react-query"; import { queryOptions } from "@tanstack/react-query";
import { getOnboardingAnswers } from "@/serverFunctions/onboarding"; import { getOnboardingAnswers } from "@/serverFunctions/onboarding";
export const ONBOARDING_LAST_STEP = 4; export const ONBOARDING_LAST_STEP = 3;
export const INTEREST_OPTIONS = [ export const INTEREST_OPTIONS = [
"AI workflows with Claude or Codex (MCP)", "AI workflows with Claude or Codex (MCP)",
@ -32,6 +32,7 @@ export const CLIENT_WEBSITE_COUNT_OPTIONS = [
] as const; ] as const;
export const SOURCE_OPTIONS = [ export const SOURCE_OPTIONS = [
"Product Hunt",
"Google", "Google",
"Reddit", "Reddit",
"X / Twitter", "X / Twitter",
@ -110,7 +111,7 @@ export function restoreOnboardingAnswers(
export function buildOnboardingPayload( export function buildOnboardingPayload(
answers: OnboardingAnswers, answers: OnboardingAnswers,
step: number, step: number,
extra: { mcpSetupIntent?: "yes" | "no"; completed?: boolean } = {}, extra: { completed?: boolean } = {},
) { ) {
const interestedFeatures = answers.selectedInterests.map((value) => const interestedFeatures = answers.selectedInterests.map((value) =>
value === "Other" && answers.interestOther.trim() value === "Other" && answers.interestOther.trim()

View File

@ -30,7 +30,7 @@ export function ProjectSwitcher({
if (project.id === activeProjectId) return; if (project.id === activeProjectId) return;
setLastProjectId(project.id); setLastProjectId(project.id);
void navigate({ void navigate({
to: "/p/$projectId/keywords", to: "/p/$projectId",
params: { projectId: project.id }, params: { projectId: project.id },
}); });
}; };

View File

@ -3,6 +3,7 @@ import {
Bot, Bot,
ClipboardCheck, ClipboardCheck,
Globe, Globe,
LayoutDashboard,
Link2, Link2,
MessageSquare, MessageSquare,
Search, Search,
@ -13,6 +14,14 @@ import { linkOptions } from "@tanstack/react-router";
import { GoogleGlyphMuted } from "@/client/features/gsc/GoogleGlyph"; import { GoogleGlyphMuted } from "@/client/features/gsc/GoogleGlyph";
const projectNavItems = [ 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, to: "/p/$projectId/keywords" as const,
label: "Keyword Research", label: "Keyword Research",
@ -90,6 +99,10 @@ export function getProjectNavGroups(projectId: string) {
all.find((i) => i.to === path)!; all.find((i) => i.to === path)!;
return [ return [
{
label: "Overview",
items: [byPath("/p/$projectId")],
},
{ {
label: "Research", label: "Research",
items: [ items: [

View File

@ -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,
),
],
);

View File

@ -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,
),
],
);

View File

@ -72,6 +72,9 @@ export const {
rankTrackingKeywords, rankTrackingKeywords,
rankCheckRuns, rankCheckRuns,
rankSnapshots, rankSnapshots,
organizationActivationState,
projectActivationState,
backlinkSnapshots,
audits, audits,
auditPages, auditPages,
auditLinks, auditLinks,

View File

@ -40,7 +40,7 @@ function IndexRedirect() {
} }
void navigate({ void navigate({
to: "/p/$projectId/keywords", to: "/p/$projectId",
params: { projectId: (target ?? data[0]).id }, params: { projectId: (target ?? data[0]).id },
}); });
}, [data, navigate]); }, [data, navigate]);

View File

@ -81,10 +81,7 @@ function OnboardingFlow({
const [answers, setAnswers] = useState<OnboardingAnswers>(initialAnswers); const [answers, setAnswers] = useState<OnboardingAnswers>(initialAnswers);
const saveMutation = useMutation({ const saveMutation = useMutation({
mutationFn: (extra: { mutationFn: (extra: { completed?: boolean }) =>
mcpSetupIntent?: "yes" | "no";
completed?: boolean;
}) =>
saveOnboardingAnswers({ saveOnboardingAnswers({
data: buildOnboardingPayload(answers, step, extra), data: buildOnboardingPayload(answers, step, extra),
}), }),
@ -113,9 +110,9 @@ function OnboardingFlow({
goToStep(step + 1); goToStep(step + 1);
}; };
const handleFinish = async (mcpSetupIntent: "yes" | "no") => { const handleFinish = async () => {
try { try {
await saveMutation.mutateAsync({ mcpSetupIntent, completed: true }); await saveMutation.mutateAsync({ completed: true });
// Refresh the shared cache so the destination's onboarding-redirect guard // Refresh the shared cache so the destination's onboarding-redirect guard
// sees the completed state and doesn't bounce the user back here. // sees the completed state and doesn't bounce the user back here.
await queryClient.invalidateQueries({ queryKey: ["onboardingAnswers"] }); await queryClient.invalidateQueries({ queryKey: ["onboardingAnswers"] });
@ -126,13 +123,9 @@ function OnboardingFlow({
interests: answers.selectedInterests, interests: answers.selectedInterests,
work_for: answers.workFor, work_for: answers.workFor,
source: answers.source, source: answers.source,
wants_mcp_setup: mcpSetupIntent === "yes",
}); });
if (mcpSetupIntent === "yes") { // The dashboard's onboarding checklist owns MCP coaching now.
void navigate({ to: "/ai", replace: true });
} else {
void navigate({ to: "/", replace: true }); void navigate({ to: "/", replace: true });
}
}; };
return ( return (

View File

@ -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/")({ export const Route = createFileRoute("/_project/p/$projectId/")({
beforeLoad: ({ params }) => { component: DashboardRoute,
throw redirect({
to: "/p/$projectId/keywords",
params: { projectId: params.projectId },
});
},
}); });
function DashboardRoute() {
const { projectId } = Route.useParams();
return <DashboardPage projectId={projectId} />;
}

View 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);
}
}

View File

@ -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,
};

View File

@ -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);
}

View File

@ -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,
};

View 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,
};

View File

@ -101,6 +101,31 @@ async function updateProject(
return row; 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 // 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 // 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. // updateProject, whose `domain: input.domain ?? null` would clear the domain.
@ -197,6 +222,7 @@ export const ProjectRepository = {
getProjectById, getProjectById,
createProject, createProject,
updateProject, updateProject,
updateProjectDomain,
updateProjectMarket, updateProjectMarket,
tryCreateDefaultProject, tryCreateDefaultProject,
archiveProject, archiveProject,

View File

@ -6,6 +6,7 @@ import {
listProjects, listProjects,
listProjectsEnsuringOne, listProjectsEnsuringOne,
restoreProject, restoreProject,
setProjectDomain,
setProjectMarket, setProjectMarket,
updateProject, updateProject,
} from "@/server/features/projects/services/projects"; } from "@/server/features/projects/services/projects";
@ -15,6 +16,7 @@ export const ProjectService = {
listProjectsEnsuringOne, listProjectsEnsuringOne,
createProject, createProject,
updateProject, updateProject,
setProjectDomain,
setProjectMarket, setProjectMarket,
archiveProject, archiveProject,
restoreProject, restoreProject,

View File

@ -3,6 +3,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
const mocks = vi.hoisted(() => ({ const mocks = vi.hoisted(() => ({
createProject: vi.fn(), createProject: vi.fn(),
updateProject: vi.fn(), updateProject: vi.fn(),
updateProjectDomain: vi.fn(),
archiveProject: vi.fn(), archiveProject: vi.fn(),
restoreProject: vi.fn(), restoreProject: vi.fn(),
countProjects: 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", () => { describe("setProjectMarket", () => {
it("writes only the market columns, leaving name and domain untouched", async () => { it("writes only the market columns, leaving name and domain untouched", async () => {
// Onboarding sets the market before the project is named or given a // Onboarding sets the market before the project is named or given a

View File

@ -2,10 +2,12 @@ import type {
ArchiveProjectInput, ArchiveProjectInput,
CreateProjectInput, CreateProjectInput,
RestoreProjectInput, RestoreProjectInput,
SetProjectDomainInput,
SetProjectMarketInput, SetProjectMarketInput,
UpdateProjectInput, UpdateProjectInput,
} from "@/types/schemas/projects"; } from "@/types/schemas/projects";
import { ProjectRepository } from "@/server/features/projects/repositories/ProjectRepository"; import { ProjectRepository } from "@/server/features/projects/repositories/ProjectRepository";
import { normalizeBacklinksTarget } from "@/server/lib/dataforseoBacklinksTarget";
import { AppError } from "@/server/lib/errors"; import { AppError } from "@/server/lib/errors";
import { assertLanguageForLocation } from "@/server/lib/market"; import { assertLanguageForLocation } from "@/server/lib/market";
import { getLanguageCode } from "@/shared/keyword-locations"; import { getLanguageCode } from "@/shared/keyword-locations";
@ -83,6 +85,24 @@ export async function listProjectsEnsuringOne(organizationId: string) {
return listProjects(organizationId); 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( export async function createProject(
organizationId: string, organizationId: string,
input: CreateProjectInput, input: CreateProjectInput,
@ -91,7 +111,7 @@ export async function createProject(
const row = await ProjectRepository.createProject( const row = await ProjectRepository.createProject(
organizationId, organizationId,
input.name, input.name,
input.domain, normalizeProjectDomain(input.domain),
resolveMarketInput(input), resolveMarketInput(input),
); );
return mapProject(row); return mapProject(row);
@ -113,7 +133,7 @@ export async function updateProject(
organizationId, organizationId,
{ {
name: input.name, name: input.name,
domain: input.domain, domain: normalizeProjectDomain(input.domain),
market: resolveMarketInput(input), 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 * 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 * the market (onboarding). Writing just these two columns keeps the write from

View File

@ -11,6 +11,7 @@ import { AppError } from "@/server/lib/errors";
const mocks = vi.hoisted(() => ({ const mocks = vi.hoisted(() => ({
captureServerError: vi.fn(), captureServerError: vi.fn(),
captureServerEvent: vi.fn(), captureServerEvent: vi.fn(),
recordExternalMcpToolCall: vi.fn(),
incrementSelfHostMcpToolCallCount: vi.fn(), incrementSelfHostMcpToolCallCount: vi.fn(),
})); }));
@ -24,6 +25,12 @@ vi.mock("@/server/lib/posthog", () => ({
captureServerEvent: mocks.captureServerEvent, 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", () => ({ vi.mock("@/server/lib/self-host-telemetry", () => ({
incrementSelfHostMcpToolCallCount: mocks.incrementSelfHostMcpToolCallCount, incrementSelfHostMcpToolCallCount: mocks.incrementSelfHostMcpToolCallCount,
})); }));
@ -56,6 +63,7 @@ describe("instrumentMcpToolHandler", () => {
beforeEach(() => { beforeEach(() => {
mocks.captureServerError.mockReset(); mocks.captureServerError.mockReset();
mocks.captureServerEvent.mockReset(); mocks.captureServerEvent.mockReset();
mocks.recordExternalMcpToolCall.mockReset();
mocks.incrementSelfHostMcpToolCallCount.mockReset(); mocks.incrementSelfHostMcpToolCallCount.mockReset();
}); });
@ -172,5 +180,45 @@ describe("instrumentMcpToolHandler", () => {
await wrapped({}, toolExtra); await wrapped({}, toolExtra);
expect(mocks.captureServerEvent).not.toHaveBeenCalled(); 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();
}); });
}); });

View File

@ -8,6 +8,7 @@ import {
type ZodRawShapeCompat, type ZodRawShapeCompat,
} from "@modelcontextprotocol/sdk/server/zod-compat.js"; } from "@modelcontextprotocol/sdk/server/zod-compat.js";
import { asAppError } from "@/server/lib/errors"; import { asAppError } from "@/server/lib/errors";
import { recordExternalMcpToolCall } from "@/server/features/activation/mcpActivation";
import { captureServerError, captureServerEvent } from "@/server/lib/posthog"; import { captureServerError, captureServerEvent } from "@/server/lib/posthog";
import { shouldCaptureAppErrorCode } from "@/shared/error-codes"; import { shouldCaptureAppErrorCode } from "@/shared/error-codes";
import { getAuth, type ToolExtra } from "@/server/mcp/context"; import { getAuth, type ToolExtra } from "@/server/mcp/context";
@ -114,6 +115,21 @@ export function instrumentMcpToolHandler<TArgs>(
? { success: false, errorCode: "MCP_OUTPUT_VALIDATION" } ? { success: false, errorCode: "MCP_OUTPUT_VALIDATION" }
: { success: !result.isError }, : { 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; return result;
} catch (error) { } catch (error) {
const appError = asAppError(error); const appError = asAppError(error);

View File

@ -13,6 +13,7 @@ import {
MCP_SCOPE, MCP_SCOPE,
} from "@/lib/oauth-resource"; } from "@/lib/oauth-resource";
import { asAppError } from "@/server/lib/errors"; import { asAppError } from "@/server/lib/errors";
import { recordMcpAuthorized } from "@/server/features/activation/mcpActivation";
import { captureServerEvent } from "@/server/lib/posthog"; import { captureServerEvent } from "@/server/lib/posthog";
import { import {
createWorkersOAuthMcpProps, createWorkersOAuthMcpProps,
@ -366,6 +367,8 @@ async function handleOAuthConsentResponse(
props, props,
}); });
await recordMcpAuthorized(context.organizationId);
waitUntil( waitUntil(
captureServerEvent({ captureServerEvent({
distinctId: context.userId, distinctId: context.userId,

View 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 };
});

View File

@ -8,6 +8,7 @@ import {
archiveProjectSchema, archiveProjectSchema,
createProjectSchema, createProjectSchema,
restoreProjectSchema, restoreProjectSchema,
setProjectDomainSchema,
setProjectMarketSchema, setProjectMarketSchema,
updateProjectSchema, updateProjectSchema,
} from "@/types/schemas/projects"; } from "@/types/schemas/projects";
@ -35,6 +36,13 @@ export const updateProject = createServerFn({ method: "POST" })
ProjectService.updateProject(context.organizationId, data), 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" }) export const setProjectMarket = createServerFn({ method: "POST" })
.middleware(requireProjectContext) .middleware(requireProjectContext)
.validator(setProjectMarketSchema) .validator(setProjectMarketSchema)

View 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),
});

View File

@ -63,6 +63,13 @@ export const updateProjectSchema = z
}) })
.refine(hasLocationForLanguage, marketPairMessage); .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 // Market-only update (onboarding). Both halves are required: the caller picks
// them together, so the service can validate the pair without a stored row. // them together, so the service can validate the pair without a stored row.
export const setProjectMarketSchema = z.object({ export const setProjectMarketSchema = z.object({
@ -89,6 +96,7 @@ export const restoreProjectSchema = z.object({
export type CreateProjectInput = z.infer<typeof createProjectSchema>; export type CreateProjectInput = z.infer<typeof createProjectSchema>;
export type UpdateProjectInput = z.infer<typeof updateProjectSchema>; export type UpdateProjectInput = z.infer<typeof updateProjectSchema>;
export type SetProjectDomainInput = z.infer<typeof setProjectDomainSchema>;
export type SetProjectMarketInput = z.infer<typeof setProjectMarketSchema>; export type SetProjectMarketInput = z.infer<typeof setProjectMarketSchema>;
export type ArchiveProjectInput = z.infer<typeof archiveProjectSchema>; export type ArchiveProjectInput = z.infer<typeof archiveProjectSchema>;
export type RestoreProjectInput = z.infer<typeof restoreProjectSchema>; export type RestoreProjectInput = z.infer<typeof restoreProjectSchema>;