Remove GA4 launch gating; dashboard shows an Organic traffic card once connected (#505)
This commit is contained in:
parent
9305ea89d6
commit
a6f96c516e
@ -1,5 +1,3 @@
|
|||||||
import { GA4_OAUTH_APP_PENDING } from "@/shared/ga4";
|
|
||||||
|
|
||||||
type McpTool = {
|
type McpTool = {
|
||||||
name: string;
|
name: string;
|
||||||
title: string;
|
title: string;
|
||||||
@ -254,14 +252,10 @@ const toolCategories: ToolCategory[] = [
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
const visibleCategories = GA4_OAUTH_APP_PENDING
|
|
||||||
? toolCategories.filter((cat) => cat.label !== "Google Analytics")
|
|
||||||
: toolCategories;
|
|
||||||
|
|
||||||
export function AvailableTools() {
|
export function AvailableTools() {
|
||||||
return (
|
return (
|
||||||
<div className="grid gap-x-8 gap-y-8 md:grid-cols-2">
|
<div className="grid gap-x-8 gap-y-8 md:grid-cols-2">
|
||||||
{visibleCategories.map((cat) => (
|
{toolCategories.map((cat) => (
|
||||||
<div key={cat.label}>
|
<div key={cat.label}>
|
||||||
<h3 className="text-xs font-semibold uppercase tracking-wide text-base-content/50">
|
<h3 className="text-xs font-semibold uppercase tracking-wide text-base-content/50">
|
||||||
{cat.label}
|
{cat.label}
|
||||||
|
|||||||
@ -4,7 +4,6 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
|||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { ChevronLeft, ChevronRight, Check } from "lucide-react";
|
import { ChevronLeft, ChevronRight, Check } from "lucide-react";
|
||||||
import { captureClientEvent } from "@/client/lib/posthog";
|
import { captureClientEvent } from "@/client/lib/posthog";
|
||||||
import { Ga4ConnectCard } from "@/client/features/dashboard/Ga4ConnectCard";
|
|
||||||
import {
|
import {
|
||||||
computeNextStep,
|
computeNextStep,
|
||||||
isStepDone,
|
isStepDone,
|
||||||
@ -15,6 +14,7 @@ import {
|
|||||||
BacklinkPulseCard,
|
BacklinkPulseCard,
|
||||||
GscCard,
|
GscCard,
|
||||||
} from "@/client/features/dashboard/DashboardCards";
|
} from "@/client/features/dashboard/DashboardCards";
|
||||||
|
import { Ga4Card } from "@/client/features/dashboard/Ga4Card";
|
||||||
import { McpConnectCard } from "@/client/features/dashboard/McpConnectCard";
|
import { McpConnectCard } from "@/client/features/dashboard/McpConnectCard";
|
||||||
import { WorkspaceMergeBanner } from "@/client/features/dashboard/WorkspaceMergeBanner";
|
import { WorkspaceMergeBanner } from "@/client/features/dashboard/WorkspaceMergeBanner";
|
||||||
import { getStandardErrorMessage } from "@/client/lib/error-messages";
|
import { getStandardErrorMessage } from "@/client/lib/error-messages";
|
||||||
@ -26,8 +26,6 @@ import {
|
|||||||
refreshDashboardBacklinkSnapshot,
|
refreshDashboardBacklinkSnapshot,
|
||||||
} from "@/serverFunctions/dashboard";
|
} from "@/serverFunctions/dashboard";
|
||||||
import { setProjectDomain } from "@/serverFunctions/projects";
|
import { setProjectDomain } from "@/serverFunctions/projects";
|
||||||
import { useSession } from "@/lib/auth-client";
|
|
||||||
import { isGa4ConnectAvailable } from "@/shared/ga4";
|
|
||||||
import type { DashboardHeroStep } from "@/types/schemas/dashboard";
|
import type { DashboardHeroStep } from "@/types/schemas/dashboard";
|
||||||
|
|
||||||
const HERO_COPY: Record<
|
const HERO_COPY: Record<
|
||||||
@ -236,7 +234,6 @@ function OnboardingChecklist({
|
|||||||
|
|
||||||
export function DashboardPage({ projectId }: { projectId: string }) {
|
export function DashboardPage({ projectId }: { projectId: string }) {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const { data: session } = useSession();
|
|
||||||
|
|
||||||
const activationQuery = useQuery({
|
const activationQuery = useQuery({
|
||||||
queryKey: ["dashboardActivation", projectId],
|
queryKey: ["dashboardActivation", projectId],
|
||||||
@ -338,17 +335,13 @@ export function DashboardPage({ projectId }: { projectId: string }) {
|
|||||||
hasData: gscConnected,
|
hasData: gscConnected,
|
||||||
node: <GscCard projectId={projectId} connected={gscConnected} />,
|
node: <GscCard projectId={projectId} connected={gscConnected} />,
|
||||||
},
|
},
|
||||||
...(isGa4ConnectAvailable(session?.user?.email) &&
|
...(ga4Connected || !activation.ga4.cardDismissedAt
|
||||||
(ga4Connected || !activation.ga4.cardDismissedAt)
|
|
||||||
? [
|
? [
|
||||||
{
|
{
|
||||||
key: "ga4",
|
key: "ga4",
|
||||||
hasData: ga4Connected,
|
hasData: ga4Connected,
|
||||||
node: (
|
node: (
|
||||||
<Ga4ConnectCard
|
<Ga4Card projectId={projectId} connected={ga4Connected} />
|
||||||
projectId={projectId}
|
|
||||||
connected={ga4Connected}
|
|
||||||
/>
|
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|||||||
185
src/client/features/dashboard/Ga4Card.tsx
Normal file
185
src/client/features/dashboard/Ga4Card.tsx
Normal file
@ -0,0 +1,185 @@
|
|||||||
|
import { Link } from "@tanstack/react-router";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import {
|
||||||
|
Area,
|
||||||
|
AreaChart,
|
||||||
|
ResponsiveContainer,
|
||||||
|
Tooltip,
|
||||||
|
XAxis,
|
||||||
|
YAxis,
|
||||||
|
} from "recharts";
|
||||||
|
import {
|
||||||
|
CardShell,
|
||||||
|
moreDetailsClass,
|
||||||
|
PercentDelta,
|
||||||
|
Stat,
|
||||||
|
} from "@/client/features/dashboard/cardParts";
|
||||||
|
import { Ga4ConnectCard } from "@/client/features/dashboard/Ga4ConnectCard";
|
||||||
|
import {
|
||||||
|
formatCount,
|
||||||
|
formatCtr,
|
||||||
|
} from "@/client/features/search-performance/SearchPerformanceColumns";
|
||||||
|
import { getGa4DashboardReport } from "@/serverFunctions/ga4";
|
||||||
|
|
||||||
|
function formatTrendDay(date: string): string {
|
||||||
|
// Construct in local time: Date.parse("2026-08-01") is UTC midnight, which
|
||||||
|
// toLocaleDateString would render as the previous day west of Greenwich.
|
||||||
|
const [year, month, day] = date.split("-").map(Number);
|
||||||
|
return new Date(year, month - 1, day).toLocaleDateString(undefined, {
|
||||||
|
month: "short",
|
||||||
|
day: "numeric",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function statValue(
|
||||||
|
value: number | null,
|
||||||
|
format: (value: number) => string,
|
||||||
|
): string {
|
||||||
|
return value === null ? "—" : format(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function statDelta(current: number | null, previous: number | null) {
|
||||||
|
return current !== null && previous !== null ? (
|
||||||
|
<PercentDelta current={current} previous={previous} />
|
||||||
|
) : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function SessionsTooltip({
|
||||||
|
active,
|
||||||
|
payload,
|
||||||
|
label,
|
||||||
|
}: {
|
||||||
|
active?: boolean;
|
||||||
|
payload?: Array<{ value: number }>;
|
||||||
|
label?: string;
|
||||||
|
}) {
|
||||||
|
if (!active || !payload?.length) return null;
|
||||||
|
return (
|
||||||
|
<div className="rounded-md border border-base-300 bg-base-100 px-3 py-2 shadow-sm">
|
||||||
|
<p className="text-xs text-base-content/60">
|
||||||
|
{label ? formatTrendDay(label) : ""}
|
||||||
|
</p>
|
||||||
|
<p className="text-sm font-medium tabular-nums">
|
||||||
|
{formatCount(payload[0].value)} sessions
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Ga4Card({
|
||||||
|
projectId,
|
||||||
|
connected,
|
||||||
|
}: {
|
||||||
|
projectId: string;
|
||||||
|
connected: boolean;
|
||||||
|
}) {
|
||||||
|
const reportQuery = useQuery({
|
||||||
|
queryKey: ["dashboardGa4Report", projectId],
|
||||||
|
queryFn: () => getGa4DashboardReport({ data: { projectId } }),
|
||||||
|
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 <Ga4ConnectCard projectId={projectId} connected={connected} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
const report = reportQuery.data;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<CardShell
|
||||||
|
title="Organic traffic"
|
||||||
|
stamp="Google Analytics · last 28 days"
|
||||||
|
action={
|
||||||
|
<Link
|
||||||
|
to="/p/$projectId/settings"
|
||||||
|
params={{ projectId }}
|
||||||
|
hash="google-analytics"
|
||||||
|
className={moreDetailsClass}
|
||||||
|
>
|
||||||
|
Manage
|
||||||
|
</Link>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{reportQuery.isPending ? (
|
||||||
|
<div className="space-y-3" aria-busy>
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
{Array.from({ length: 4 }, (_, i) => (
|
||||||
|
<div key={i} className="skeleton h-16" />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="skeleton h-24" />
|
||||||
|
</div>
|
||||||
|
) : reportQuery.isError ? (
|
||||||
|
<p className="text-sm text-base-content/60">
|
||||||
|
Couldn’t load Google Analytics data. Try again shortly.
|
||||||
|
</p>
|
||||||
|
) : report?.connected ? (
|
||||||
|
// Covers null (no report row) and 0: a zero-session period would
|
||||||
|
// otherwise render an all-zero flatline chart in an empty box.
|
||||||
|
!report.totals.sessions ? (
|
||||||
|
<p className="text-sm text-base-content/60">
|
||||||
|
No organic search traffic recorded in the last 28 days yet.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<Stat
|
||||||
|
label="Sessions"
|
||||||
|
value={statValue(report.totals.sessions, formatCount)}
|
||||||
|
sub={statDelta(
|
||||||
|
report.totals.sessions,
|
||||||
|
report.prevTotals.sessions,
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<Stat
|
||||||
|
label="Active users"
|
||||||
|
value={statValue(report.totals.activeUsers, formatCount)}
|
||||||
|
sub={statDelta(
|
||||||
|
report.totals.activeUsers,
|
||||||
|
report.prevTotals.activeUsers,
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<Stat
|
||||||
|
label="Engagement rate"
|
||||||
|
value={statValue(report.totals.engagementRate, formatCtr)}
|
||||||
|
/>
|
||||||
|
<Stat
|
||||||
|
label="Key events"
|
||||||
|
value={statValue(report.totals.keyEvents, formatCount)}
|
||||||
|
sub={statDelta(
|
||||||
|
report.totals.keyEvents,
|
||||||
|
report.prevTotals.keyEvents,
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="h-24">
|
||||||
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
|
<AreaChart
|
||||||
|
data={report.trend}
|
||||||
|
margin={{ top: 4, right: 0, bottom: 0, left: 0 }}
|
||||||
|
>
|
||||||
|
<XAxis dataKey="date" hide />
|
||||||
|
<YAxis hide domain={[0, "auto"]} />
|
||||||
|
<Tooltip
|
||||||
|
content={<SessionsTooltip />}
|
||||||
|
cursor={{ stroke: "currentColor", strokeOpacity: 0.2 }}
|
||||||
|
/>
|
||||||
|
<Area
|
||||||
|
type="monotone"
|
||||||
|
dataKey="sessions"
|
||||||
|
stroke="var(--color-primary)"
|
||||||
|
strokeWidth={2}
|
||||||
|
fill="var(--color-primary)"
|
||||||
|
fillOpacity={0.08}
|
||||||
|
/>
|
||||||
|
</AreaChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
) : null}
|
||||||
|
</CardShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -19,11 +19,7 @@ import {
|
|||||||
listGa4Properties,
|
listGa4Properties,
|
||||||
setGa4Property,
|
setGa4Property,
|
||||||
} from "@/serverFunctions/ga4";
|
} from "@/serverFunctions/ga4";
|
||||||
import { useSession } from "@/lib/auth-client";
|
import { GA4_SELF_HOSTED_SETUP_DOCS_URL } from "@/shared/ga4";
|
||||||
import {
|
|
||||||
GA4_SELF_HOSTED_SETUP_DOCS_URL,
|
|
||||||
isGa4ConnectAvailable,
|
|
||||||
} from "@/shared/ga4";
|
|
||||||
|
|
||||||
export function GoogleAnalyticsConnectionCard({
|
export function GoogleAnalyticsConnectionCard({
|
||||||
projectId,
|
projectId,
|
||||||
@ -38,7 +34,6 @@ export function GoogleAnalyticsConnectionCard({
|
|||||||
}) {
|
}) {
|
||||||
const hosted = isHostedClientAuthMode();
|
const hosted = isHostedClientAuthMode();
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const { data: session } = useSession();
|
|
||||||
const [picking, setPicking] = React.useState(false);
|
const [picking, setPicking] = React.useState(false);
|
||||||
const [selection, setSelection] = React.useState<Ga4PropertySelection | null>(
|
const [selection, setSelection] = React.useState<Ga4PropertySelection | null>(
|
||||||
null,
|
null,
|
||||||
@ -50,14 +45,6 @@ export function GoogleAnalyticsConnectionCard({
|
|||||||
});
|
});
|
||||||
const connection = connectionQuery.data;
|
const connection = connectionQuery.data;
|
||||||
const connected = Boolean(connection?.connected);
|
const connected = Boolean(connection?.connected);
|
||||||
// Hide the hosted connect surface while the OAuth app awaits Google's
|
|
||||||
// approval, but keep the card for users who already hold a grant so they
|
|
||||||
// can finish property selection or disconnect.
|
|
||||||
const hiddenPendingApproval =
|
|
||||||
!isGa4ConnectAvailable(session?.user?.email) &&
|
|
||||||
hosted &&
|
|
||||||
!connected &&
|
|
||||||
!connection?.currentUserHasGrant;
|
|
||||||
const selfHostedNeedsSetup =
|
const selfHostedNeedsSetup =
|
||||||
!hosted && connectionQuery.isSuccess && !connection?.googleOAuthConfigured;
|
!hosted && connectionQuery.isSuccess && !connection?.googleOAuthConfigured;
|
||||||
const showPicker = picking || (connection?.currentUserHasGrant && !connected);
|
const showPicker = picking || (connection?.currentUserHasGrant && !connected);
|
||||||
@ -92,6 +79,9 @@ export function GoogleAnalyticsConnectionCard({
|
|||||||
void queryClient.invalidateQueries({
|
void queryClient.invalidateQueries({
|
||||||
queryKey: ["dashboardActivation", projectId],
|
queryKey: ["dashboardActivation", projectId],
|
||||||
});
|
});
|
||||||
|
void queryClient.invalidateQueries({
|
||||||
|
queryKey: ["dashboardGa4Report", projectId],
|
||||||
|
});
|
||||||
};
|
};
|
||||||
const setPropertyMutation = useMutation({
|
const setPropertyMutation = useMutation({
|
||||||
mutationFn: (selected: Ga4PropertySelection) =>
|
mutationFn: (selected: Ga4PropertySelection) =>
|
||||||
@ -116,8 +106,6 @@ export function GoogleAnalyticsConnectionCard({
|
|||||||
});
|
});
|
||||||
const handleConnect = () => void startGoogleLink("ga4", window.location.href);
|
const handleConnect = () => void startGoogleLink("ga4", window.location.href);
|
||||||
|
|
||||||
if (hiddenPendingApproval) return null;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{heading}
|
{heading}
|
||||||
|
|||||||
@ -1,80 +0,0 @@
|
|||||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
||||||
import { createOpenSeoMcpServer } from "./server";
|
|
||||||
|
|
||||||
const mcpServerMocks = vi.hoisted(() => ({
|
|
||||||
registerTool: vi.fn<(name: string, ...args: unknown[]) => void>(),
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("@modelcontextprotocol/server", () => ({
|
|
||||||
McpServer: class {
|
|
||||||
registerTool(name: string, ...args: unknown[]) {
|
|
||||||
mcpServerMocks.registerTool(name, ...args);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("cloudflare:workers", () => ({
|
|
||||||
DurableObject: vi.fn(),
|
|
||||||
env: {},
|
|
||||||
waitUntil: vi.fn(),
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("@/server/mcp/instrumentation", () => ({
|
|
||||||
instrumentMcpToolHandler: (
|
|
||||||
_name: string,
|
|
||||||
_outputSchema: unknown,
|
|
||||||
handler: unknown,
|
|
||||||
) => handler,
|
|
||||||
}));
|
|
||||||
|
|
||||||
describe("createOpenSeoMcpServer", () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
mcpServerMocks.registerTool.mockClear();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("does not expose GA4-backed tools while OAuth approval is pending", () => {
|
|
||||||
createOpenSeoMcpServer({
|
|
||||||
openSeoAuth: {
|
|
||||||
userId: "user-1",
|
|
||||||
userEmail: "user@example.com",
|
|
||||||
organizationId: "org-1",
|
|
||||||
baseUrl: "https://example.com",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const registeredToolNames = mcpServerMocks.registerTool.mock.calls.map(
|
|
||||||
([name]) => name,
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(registeredToolNames).toContain("whoami");
|
|
||||||
expect(registeredToolNames).toContain("get_search_console_performance");
|
|
||||||
expect(registeredToolNames).not.toContain(
|
|
||||||
"get_google_analytics_organic_landing_pages",
|
|
||||||
);
|
|
||||||
expect(registeredToolNames).not.toContain(
|
|
||||||
"get_google_analytics_page_performance",
|
|
||||||
);
|
|
||||||
expect(registeredToolNames).not.toContain(
|
|
||||||
"get_google_analytics_key_events",
|
|
||||||
);
|
|
||||||
expect(registeredToolNames).not.toContain("get_search_opportunities");
|
|
||||||
expect(registeredToolNames).not.toContain(
|
|
||||||
"get_google_analytics_organic_overview",
|
|
||||||
);
|
|
||||||
expect(registeredToolNames).not.toContain(
|
|
||||||
"get_google_analytics_traffic_acquisition",
|
|
||||||
);
|
|
||||||
expect(registeredToolNames).not.toContain(
|
|
||||||
"get_google_analytics_measurement_health",
|
|
||||||
);
|
|
||||||
expect(registeredToolNames).not.toContain(
|
|
||||||
"get_google_analytics_ecommerce_performance",
|
|
||||||
);
|
|
||||||
expect(registeredToolNames).not.toContain(
|
|
||||||
"get_google_analytics_site_search",
|
|
||||||
);
|
|
||||||
expect(registeredToolNames).not.toContain(
|
|
||||||
"get_google_analytics_audience_breakdown",
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@ -6,7 +6,6 @@ import {
|
|||||||
import type { z } from "zod";
|
import type { z } from "zod";
|
||||||
import {
|
import {
|
||||||
createMcpToolContext,
|
createMcpToolContext,
|
||||||
MCP_AUTH_CONTEXT_PROP,
|
|
||||||
type McpProps,
|
type McpProps,
|
||||||
type ToolContext,
|
type ToolContext,
|
||||||
} from "@/server/mcp/context";
|
} from "@/server/mcp/context";
|
||||||
@ -63,7 +62,6 @@ import {
|
|||||||
getSearchConsolePerformanceTool,
|
getSearchConsolePerformanceTool,
|
||||||
inspectUrlsTool,
|
inspectUrlsTool,
|
||||||
} from "@/server/mcp/tools/search-console-tools";
|
} from "@/server/mcp/tools/search-console-tools";
|
||||||
import { GA4_OAUTH_APP_PENDING, isGa4ConnectAvailable } from "@/shared/ga4";
|
|
||||||
import {
|
import {
|
||||||
getAuditIssuesTool,
|
getAuditIssuesTool,
|
||||||
getAuditPagesTool,
|
getAuditPagesTool,
|
||||||
@ -186,21 +184,16 @@ export function createOpenSeoMcpServer(authProps: McpProps) {
|
|||||||
register(getKeywordMetricsTool);
|
register(getKeywordMetricsTool);
|
||||||
register(getSearchConsolePerformanceTool);
|
register(getSearchConsolePerformanceTool);
|
||||||
register(inspectUrlsTool);
|
register(inspectUrlsTool);
|
||||||
if (
|
register(getGoogleAnalyticsOrganicLandingPagesTool);
|
||||||
!GA4_OAUTH_APP_PENDING ||
|
register(getGoogleAnalyticsPagePerformanceTool);
|
||||||
isGa4ConnectAvailable(authProps[MCP_AUTH_CONTEXT_PROP].userEmail)
|
register(getGoogleAnalyticsKeyEventsTool);
|
||||||
) {
|
register(getSearchOpportunitiesTool);
|
||||||
register(getGoogleAnalyticsOrganicLandingPagesTool);
|
register(getGoogleAnalyticsOrganicOverviewTool);
|
||||||
register(getGoogleAnalyticsPagePerformanceTool);
|
register(getGoogleAnalyticsTrafficAcquisitionTool);
|
||||||
register(getGoogleAnalyticsKeyEventsTool);
|
register(getGoogleAnalyticsMeasurementHealthTool);
|
||||||
register(getSearchOpportunitiesTool);
|
register(getGoogleAnalyticsEcommercePerformanceTool);
|
||||||
register(getGoogleAnalyticsOrganicOverviewTool);
|
register(getGoogleAnalyticsSiteSearchTool);
|
||||||
register(getGoogleAnalyticsTrafficAcquisitionTool);
|
register(getGoogleAnalyticsAudienceBreakdownTool);
|
||||||
register(getGoogleAnalyticsMeasurementHealthTool);
|
|
||||||
register(getGoogleAnalyticsEcommercePerformanceTool);
|
|
||||||
register(getGoogleAnalyticsSiteSearchTool);
|
|
||||||
register(getGoogleAnalyticsAudienceBreakdownTool);
|
|
||||||
}
|
|
||||||
register(runSiteAuditTool);
|
register(runSiteAuditTool);
|
||||||
register(getAuditStatusTool);
|
register(getAuditStatusTool);
|
||||||
register(getAuditIssuesTool);
|
register(getAuditIssuesTool);
|
||||||
|
|||||||
@ -2,7 +2,10 @@ import { createServerFn } from "@tanstack/react-start";
|
|||||||
import { getRequest } from "@tanstack/react-start/server";
|
import { getRequest } from "@tanstack/react-start/server";
|
||||||
import { waitUntil } from "cloudflare:workers";
|
import { waitUntil } from "cloudflare:workers";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
import { shiftGa4Date } from "@/server/features/ga4/services/Ga4Dates";
|
||||||
|
import { Ga4OrganicOverviewService } from "@/server/features/ga4/services/Ga4OrganicOverviewService";
|
||||||
import { Ga4Service } from "@/server/features/ga4/services/Ga4Service";
|
import { Ga4Service } from "@/server/features/ga4/services/Ga4Service";
|
||||||
|
import { Ga4ReportError } from "@/server/lib/ga4Errors";
|
||||||
import { hasSelfHostedGoogleOAuthConfig } from "@/server/features/google/oauth-config";
|
import { hasSelfHostedGoogleOAuthConfig } from "@/server/features/google/oauth-config";
|
||||||
import {
|
import {
|
||||||
createSelfHostedGoogleAuthorizationUrl,
|
createSelfHostedGoogleAuthorizationUrl,
|
||||||
@ -49,6 +52,81 @@ export const getGa4Connection = createServerFn({ method: "POST" })
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
function overviewMetric(
|
||||||
|
row: Record<string, string | number | null> | null,
|
||||||
|
name: string,
|
||||||
|
): number | null {
|
||||||
|
const value = row?.[name];
|
||||||
|
return typeof value === "number" ? value : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Zero-fill the daily trend across the resolved range: GA4 omits days with
|
||||||
|
* no organic sessions, which would silently shrink the chart's x-axis. */
|
||||||
|
function fillDailySessions(
|
||||||
|
rows: Array<Record<string, string | number | null>>,
|
||||||
|
range: { startDate: string; endDate: string },
|
||||||
|
): Array<{ date: string; sessions: number }> {
|
||||||
|
const sessionsByDate = new Map<string, number>();
|
||||||
|
for (const row of rows) {
|
||||||
|
// GA4's `date` dimension is YYYYMMDD; the range dates are YYYY-MM-DD.
|
||||||
|
if (typeof row.date !== "string" || typeof row.sessions !== "number") {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const iso = `${row.date.slice(0, 4)}-${row.date.slice(4, 6)}-${row.date.slice(6, 8)}`;
|
||||||
|
sessionsByDate.set(iso, row.sessions);
|
||||||
|
}
|
||||||
|
const days: Array<{ date: string; sessions: number }> = [];
|
||||||
|
for (
|
||||||
|
let date = range.startDate;
|
||||||
|
date <= range.endDate;
|
||||||
|
date = shiftGa4Date(date, 1)
|
||||||
|
) {
|
||||||
|
days.push({ date, sessions: sessionsByDate.get(date) ?? 0 });
|
||||||
|
}
|
||||||
|
return days;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The dashboard's GA4 card: organic totals vs the previous period plus a
|
||||||
|
* daily sessions trend, over the default (last 28 complete days) range. */
|
||||||
|
export const getGa4DashboardReport = createServerFn({ method: "POST" })
|
||||||
|
.middleware(requireProjectContext)
|
||||||
|
.validator(projectScopedSchema)
|
||||||
|
.handler(async ({ context }) => {
|
||||||
|
try {
|
||||||
|
const overview = await Ga4OrganicOverviewService.getOrganicOverview({
|
||||||
|
projectId: context.projectId,
|
||||||
|
});
|
||||||
|
const totals = (row: Record<string, string | number | null> | null) => ({
|
||||||
|
sessions: overviewMetric(row, "sessions"),
|
||||||
|
activeUsers: overviewMetric(row, "activeUsers"),
|
||||||
|
engagementRate: overviewMetric(row, "engagementRate"),
|
||||||
|
keyEvents: overviewMetric(row, "keyEvents"),
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
connected: true as const,
|
||||||
|
totals: totals(overview.current),
|
||||||
|
prevTotals: totals(overview.previous),
|
||||||
|
trend: fillDailySessions(
|
||||||
|
overview.trend,
|
||||||
|
overview.request.resolvedDateRange,
|
||||||
|
),
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
// Not connected, a dead grant, or a lost/deleted property: the dashboard
|
||||||
|
// card falls back to the connect card instead of retrying a report that
|
||||||
|
// can never succeed. Other report errors are real faults.
|
||||||
|
if (
|
||||||
|
error instanceof Ga4ReportError &&
|
||||||
|
(error.code === "ga4_not_connected" ||
|
||||||
|
error.code === "ga4_reconnect_required" ||
|
||||||
|
error.code === "ga4_property_inaccessible")
|
||||||
|
) {
|
||||||
|
return { connected: false as const };
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
export const listGa4Properties = createServerFn({ method: "POST" })
|
export const listGa4Properties = createServerFn({ method: "POST" })
|
||||||
.middleware(requireProjectContext)
|
.middleware(requireProjectContext)
|
||||||
.validator(projectScopedSchema)
|
.validator(projectScopedSchema)
|
||||||
|
|||||||
@ -1,25 +1,6 @@
|
|||||||
/** Better Auth provider ID for the dedicated Google Analytics grant. */
|
/** Better Auth provider ID for the dedicated Google Analytics grant. */
|
||||||
export const GA4_OAUTH_PROVIDER_ID = "google-analytics";
|
export const GA4_OAUTH_PROVIDER_ID = "google-analytics";
|
||||||
|
|
||||||
// Google hasn't approved the GA4 OAuth app yet, so hosted connect attempts
|
|
||||||
// show Google's "unverified app" warning. Gates every GA4 connect surface;
|
|
||||||
// flip to false once the app is approved. Self-hosted deployments use their
|
|
||||||
// own OAuth app, so only hosted mode is gated.
|
|
||||||
export const GA4_OAUTH_APP_PENDING = true;
|
|
||||||
|
|
||||||
// Google's OAuth verification reviewer tests with this account, so the GA4
|
|
||||||
// connect/disconnect surfaces stay visible for it while the app is pending
|
|
||||||
// approval. MCP tool registration stays gated for everyone until approval.
|
|
||||||
const GA4_OAUTH_REVIEWER_EMAILS = new Set(["walkthrough@everyapp.dev"]);
|
|
||||||
|
|
||||||
/** Whether GA4 connect surfaces are visible to this user despite the pending gate. */
|
|
||||||
export function isGa4ConnectAvailable(
|
|
||||||
email: string | null | undefined,
|
|
||||||
): boolean {
|
|
||||||
if (!GA4_OAUTH_APP_PENDING) return true;
|
|
||||||
return email != null && GA4_OAUTH_REVIEWER_EMAILS.has(email);
|
|
||||||
}
|
|
||||||
|
|
||||||
export const GA4_OAUTH_SCOPES = [
|
export const GA4_OAUTH_SCOPES = [
|
||||||
"openid",
|
"openid",
|
||||||
"email",
|
"email",
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user