This commit is contained in:
Carl-Gerhard Lindesvärd
2026-03-06 13:59:03 +01:00
parent 70ca44f039
commit 2981638893
21 changed files with 1609 additions and 1 deletions

View File

@@ -0,0 +1,132 @@
import { COOKIE_OPTIONS, googleGsc } from '@openpanel/auth';
import { db } from '@openpanel/db';
import type { FastifyReply, FastifyRequest } from 'fastify';
import { z } from 'zod';
import { LogError } from '@/utils/errors';
export async function gscInitiate(req: FastifyRequest, reply: FastifyReply) {
const schema = z.object({
state: z.string(),
code_verifier: z.string(),
project_id: z.string(),
redirect: z.string().url(),
});
const query = schema.safeParse(req.query);
if (!query.success) {
return reply.status(400).send({ error: 'Invalid parameters' });
}
const { state, code_verifier, project_id, redirect } = query.data;
reply.setCookie('gsc_oauth_state', state, { maxAge: 60 * 10, ...COOKIE_OPTIONS });
reply.setCookie('gsc_code_verifier', code_verifier, { maxAge: 60 * 10, ...COOKIE_OPTIONS });
reply.setCookie('gsc_project_id', project_id, { maxAge: 60 * 10, ...COOKIE_OPTIONS });
return reply.redirect(redirect);
}
export async function gscGoogleCallback(
req: FastifyRequest,
reply: FastifyReply
) {
try {
const schema = z.object({
code: z.string(),
state: z.string(),
});
const query = schema.safeParse(req.query);
if (!query.success) {
throw new LogError('Invalid GSC callback query params', {
error: query.error,
query: req.query,
});
}
const { code, state } = query.data;
const storedState = req.cookies.gsc_oauth_state ?? null;
const codeVerifier = req.cookies.gsc_code_verifier ?? null;
const projectId = req.cookies.gsc_project_id ?? null;
if (!storedState || !codeVerifier || !projectId) {
throw new LogError('Missing GSC OAuth cookies', {
storedState: storedState === null,
codeVerifier: codeVerifier === null,
projectId: projectId === null,
});
}
if (state !== storedState) {
throw new LogError('GSC OAuth state mismatch', { state, storedState });
}
const tokens = await googleGsc.validateAuthorizationCode(
code,
codeVerifier
);
const accessToken = tokens.accessToken();
const refreshToken = tokens.hasRefreshToken()
? tokens.refreshToken()
: null;
const accessTokenExpiresAt = tokens.accessTokenExpiresAt();
if (!refreshToken) {
throw new LogError('No refresh token returned from Google GSC OAuth');
}
const project = await db.project.findUnique({
where: { id: projectId },
select: { id: true, organizationId: true },
});
if (!project) {
throw new LogError('Project not found for GSC connection', { projectId });
}
await db.gscConnection.upsert({
where: { projectId },
create: {
projectId,
accessToken,
refreshToken,
accessTokenExpiresAt,
siteUrl: '',
},
update: {
accessToken,
refreshToken,
accessTokenExpiresAt,
lastSyncStatus: null,
lastSyncError: null,
},
});
reply.clearCookie('gsc_oauth_state');
reply.clearCookie('gsc_code_verifier');
reply.clearCookie('gsc_project_id');
const dashboardUrl =
process.env.DASHBOARD_URL || process.env.NEXT_PUBLIC_DASHBOARD_URL!;
const redirectUrl = `${dashboardUrl}/${project.organizationId}/${projectId}/settings/gsc`;
return reply.redirect(redirectUrl);
} catch (error) {
req.log.error(error);
return redirectWithError(reply, error);
}
}
function redirectWithError(reply: FastifyReply, error: LogError | unknown) {
const url = new URL(
process.env.DASHBOARD_URL || process.env.NEXT_PUBLIC_DASHBOARD_URL!
);
url.pathname = '/login';
if (error instanceof LogError) {
url.searchParams.set('error', error.message);
} else {
url.searchParams.set('error', 'Failed to connect Google Search Console');
}
url.searchParams.set('correlationId', reply.request.id);
return reply.redirect(url.toString());
}

View File

@@ -42,6 +42,7 @@ import liveRouter from './routes/live.router';
import manageRouter from './routes/manage.router';
import miscRouter from './routes/misc.router';
import oauthRouter from './routes/oauth-callback.router';
import gscCallbackRouter from './routes/gsc-callback.router';
import profileRouter from './routes/profile.router';
import trackRouter from './routes/track.router';
import webhookRouter from './routes/webhook.router';
@@ -194,6 +195,7 @@ const startServer = async () => {
instance.register(liveRouter, { prefix: '/live' });
instance.register(webhookRouter, { prefix: '/webhook' });
instance.register(oauthRouter, { prefix: '/oauth' });
instance.register(gscCallbackRouter, { prefix: '/gsc' });
instance.register(miscRouter, { prefix: '/misc' });
instance.register(aiRouter, { prefix: '/ai' });
});

View File

@@ -0,0 +1,17 @@
import { gscGoogleCallback, gscInitiate } from '@/controllers/gsc-oauth-callback.controller';
import type { FastifyPluginCallback } from 'fastify';
const router: FastifyPluginCallback = async (fastify) => {
fastify.route({
method: 'GET',
url: '/initiate',
handler: gscInitiate,
});
fastify.route({
method: 'GET',
url: '/callback',
handler: gscGoogleCallback,
});
};
export default router;

View File

@@ -14,6 +14,7 @@ import {
LayoutDashboardIcon,
LayoutPanelTopIcon,
PlusIcon,
SearchIcon,
SparklesIcon,
TrendingUpDownIcon,
UndoDotIcon,
@@ -59,6 +60,7 @@ export default function SidebarProjectMenu({
<SidebarLink href={'/events'} icon={GanttChartIcon} label="Events" />
<SidebarLink href={'/sessions'} icon={UsersIcon} label="Sessions" />
<SidebarLink href={'/profiles'} icon={UsersIcon} label="Profiles" />
<SidebarLink href={'/seo'} icon={SearchIcon} label="SEO" />
<div className="mt-4 mb-2 font-medium text-muted-foreground text-sm">
Manage
</div>

View File

@@ -42,6 +42,7 @@ import { Route as AppOrganizationIdProfileTabsRouteImport } from './routes/_app.
import { Route as AppOrganizationIdMembersTabsRouteImport } from './routes/_app.$organizationId.members._tabs'
import { Route as AppOrganizationIdIntegrationsTabsRouteImport } from './routes/_app.$organizationId.integrations._tabs'
import { Route as AppOrganizationIdProjectIdSessionsRouteImport } from './routes/_app.$organizationId.$projectId.sessions'
import { Route as AppOrganizationIdProjectIdSeoRouteImport } from './routes/_app.$organizationId.$projectId.seo'
import { Route as AppOrganizationIdProjectIdReportsRouteImport } from './routes/_app.$organizationId.$projectId.reports'
import { Route as AppOrganizationIdProjectIdReferencesRouteImport } from './routes/_app.$organizationId.$projectId.references'
import { Route as AppOrganizationIdProjectIdRealtimeRouteImport } from './routes/_app.$organizationId.$projectId.realtime'
@@ -71,6 +72,7 @@ import { Route as AppOrganizationIdProjectIdEventsTabsIndexRouteImport } from '.
import { Route as AppOrganizationIdProjectIdSettingsTabsWidgetsRouteImport } from './routes/_app.$organizationId.$projectId.settings._tabs.widgets'
import { Route as AppOrganizationIdProjectIdSettingsTabsTrackingRouteImport } from './routes/_app.$organizationId.$projectId.settings._tabs.tracking'
import { Route as AppOrganizationIdProjectIdSettingsTabsImportsRouteImport } from './routes/_app.$organizationId.$projectId.settings._tabs.imports'
import { Route as AppOrganizationIdProjectIdSettingsTabsGscRouteImport } from './routes/_app.$organizationId.$projectId.settings._tabs.gsc'
import { Route as AppOrganizationIdProjectIdSettingsTabsEventsRouteImport } from './routes/_app.$organizationId.$projectId.settings._tabs.events'
import { Route as AppOrganizationIdProjectIdSettingsTabsDetailsRouteImport } from './routes/_app.$organizationId.$projectId.settings._tabs.details'
import { Route as AppOrganizationIdProjectIdSettingsTabsClientsRouteImport } from './routes/_app.$organizationId.$projectId.settings._tabs.clients'
@@ -312,6 +314,12 @@ const AppOrganizationIdProjectIdSessionsRoute =
path: '/sessions',
getParentRoute: () => AppOrganizationIdProjectIdRoute,
} as any)
const AppOrganizationIdProjectIdSeoRoute =
AppOrganizationIdProjectIdSeoRouteImport.update({
id: '/seo',
path: '/seo',
getParentRoute: () => AppOrganizationIdProjectIdRoute,
} as any)
const AppOrganizationIdProjectIdReportsRoute =
AppOrganizationIdProjectIdReportsRouteImport.update({
id: '/reports',
@@ -488,6 +496,12 @@ const AppOrganizationIdProjectIdSettingsTabsImportsRoute =
path: '/imports',
getParentRoute: () => AppOrganizationIdProjectIdSettingsTabsRoute,
} as any)
const AppOrganizationIdProjectIdSettingsTabsGscRoute =
AppOrganizationIdProjectIdSettingsTabsGscRouteImport.update({
id: '/gsc',
path: '/gsc',
getParentRoute: () => AppOrganizationIdProjectIdSettingsTabsRoute,
} as any)
const AppOrganizationIdProjectIdSettingsTabsEventsRoute =
AppOrganizationIdProjectIdSettingsTabsEventsRouteImport.update({
id: '/events',
@@ -606,6 +620,7 @@ export interface FileRoutesByFullPath {
'/$organizationId/$projectId/realtime': typeof AppOrganizationIdProjectIdRealtimeRoute
'/$organizationId/$projectId/references': typeof AppOrganizationIdProjectIdReferencesRoute
'/$organizationId/$projectId/reports': typeof AppOrganizationIdProjectIdReportsRoute
'/$organizationId/$projectId/seo': typeof AppOrganizationIdProjectIdSeoRoute
'/$organizationId/$projectId/sessions': typeof AppOrganizationIdProjectIdSessionsRoute
'/$organizationId/integrations': typeof AppOrganizationIdIntegrationsTabsRouteWithChildren
'/$organizationId/members': typeof AppOrganizationIdMembersTabsRouteWithChildren
@@ -640,6 +655,7 @@ export interface FileRoutesByFullPath {
'/$organizationId/$projectId/settings/clients': typeof AppOrganizationIdProjectIdSettingsTabsClientsRoute
'/$organizationId/$projectId/settings/details': typeof AppOrganizationIdProjectIdSettingsTabsDetailsRoute
'/$organizationId/$projectId/settings/events': typeof AppOrganizationIdProjectIdSettingsTabsEventsRoute
'/$organizationId/$projectId/settings/gsc': typeof AppOrganizationIdProjectIdSettingsTabsGscRoute
'/$organizationId/$projectId/settings/imports': typeof AppOrganizationIdProjectIdSettingsTabsImportsRoute
'/$organizationId/$projectId/settings/tracking': typeof AppOrganizationIdProjectIdSettingsTabsTrackingRoute
'/$organizationId/$projectId/settings/widgets': typeof AppOrganizationIdProjectIdSettingsTabsWidgetsRoute
@@ -677,6 +693,7 @@ export interface FileRoutesByTo {
'/$organizationId/$projectId/realtime': typeof AppOrganizationIdProjectIdRealtimeRoute
'/$organizationId/$projectId/references': typeof AppOrganizationIdProjectIdReferencesRoute
'/$organizationId/$projectId/reports': typeof AppOrganizationIdProjectIdReportsRoute
'/$organizationId/$projectId/seo': typeof AppOrganizationIdProjectIdSeoRoute
'/$organizationId/$projectId/sessions': typeof AppOrganizationIdProjectIdSessionsRoute
'/$organizationId/integrations': typeof AppOrganizationIdIntegrationsTabsIndexRoute
'/$organizationId/members': typeof AppOrganizationIdMembersTabsIndexRoute
@@ -708,6 +725,7 @@ export interface FileRoutesByTo {
'/$organizationId/$projectId/settings/clients': typeof AppOrganizationIdProjectIdSettingsTabsClientsRoute
'/$organizationId/$projectId/settings/details': typeof AppOrganizationIdProjectIdSettingsTabsDetailsRoute
'/$organizationId/$projectId/settings/events': typeof AppOrganizationIdProjectIdSettingsTabsEventsRoute
'/$organizationId/$projectId/settings/gsc': typeof AppOrganizationIdProjectIdSettingsTabsGscRoute
'/$organizationId/$projectId/settings/imports': typeof AppOrganizationIdProjectIdSettingsTabsImportsRoute
'/$organizationId/$projectId/settings/tracking': typeof AppOrganizationIdProjectIdSettingsTabsTrackingRoute
'/$organizationId/$projectId/settings/widgets': typeof AppOrganizationIdProjectIdSettingsTabsWidgetsRoute
@@ -747,6 +765,7 @@ export interface FileRoutesById {
'/_app/$organizationId/$projectId/realtime': typeof AppOrganizationIdProjectIdRealtimeRoute
'/_app/$organizationId/$projectId/references': typeof AppOrganizationIdProjectIdReferencesRoute
'/_app/$organizationId/$projectId/reports': typeof AppOrganizationIdProjectIdReportsRoute
'/_app/$organizationId/$projectId/seo': typeof AppOrganizationIdProjectIdSeoRoute
'/_app/$organizationId/$projectId/sessions': typeof AppOrganizationIdProjectIdSessionsRoute
'/_app/$organizationId/integrations': typeof AppOrganizationIdIntegrationsRouteWithChildren
'/_app/$organizationId/integrations/_tabs': typeof AppOrganizationIdIntegrationsTabsRouteWithChildren
@@ -789,6 +808,7 @@ export interface FileRoutesById {
'/_app/$organizationId/$projectId/settings/_tabs/clients': typeof AppOrganizationIdProjectIdSettingsTabsClientsRoute
'/_app/$organizationId/$projectId/settings/_tabs/details': typeof AppOrganizationIdProjectIdSettingsTabsDetailsRoute
'/_app/$organizationId/$projectId/settings/_tabs/events': typeof AppOrganizationIdProjectIdSettingsTabsEventsRoute
'/_app/$organizationId/$projectId/settings/_tabs/gsc': typeof AppOrganizationIdProjectIdSettingsTabsGscRoute
'/_app/$organizationId/$projectId/settings/_tabs/imports': typeof AppOrganizationIdProjectIdSettingsTabsImportsRoute
'/_app/$organizationId/$projectId/settings/_tabs/tracking': typeof AppOrganizationIdProjectIdSettingsTabsTrackingRoute
'/_app/$organizationId/$projectId/settings/_tabs/widgets': typeof AppOrganizationIdProjectIdSettingsTabsWidgetsRoute
@@ -830,6 +850,7 @@ export interface FileRouteTypes {
| '/$organizationId/$projectId/realtime'
| '/$organizationId/$projectId/references'
| '/$organizationId/$projectId/reports'
| '/$organizationId/$projectId/seo'
| '/$organizationId/$projectId/sessions'
| '/$organizationId/integrations'
| '/$organizationId/members'
@@ -864,6 +885,7 @@ export interface FileRouteTypes {
| '/$organizationId/$projectId/settings/clients'
| '/$organizationId/$projectId/settings/details'
| '/$organizationId/$projectId/settings/events'
| '/$organizationId/$projectId/settings/gsc'
| '/$organizationId/$projectId/settings/imports'
| '/$organizationId/$projectId/settings/tracking'
| '/$organizationId/$projectId/settings/widgets'
@@ -901,6 +923,7 @@ export interface FileRouteTypes {
| '/$organizationId/$projectId/realtime'
| '/$organizationId/$projectId/references'
| '/$organizationId/$projectId/reports'
| '/$organizationId/$projectId/seo'
| '/$organizationId/$projectId/sessions'
| '/$organizationId/integrations'
| '/$organizationId/members'
@@ -932,6 +955,7 @@ export interface FileRouteTypes {
| '/$organizationId/$projectId/settings/clients'
| '/$organizationId/$projectId/settings/details'
| '/$organizationId/$projectId/settings/events'
| '/$organizationId/$projectId/settings/gsc'
| '/$organizationId/$projectId/settings/imports'
| '/$organizationId/$projectId/settings/tracking'
| '/$organizationId/$projectId/settings/widgets'
@@ -970,6 +994,7 @@ export interface FileRouteTypes {
| '/_app/$organizationId/$projectId/realtime'
| '/_app/$organizationId/$projectId/references'
| '/_app/$organizationId/$projectId/reports'
| '/_app/$organizationId/$projectId/seo'
| '/_app/$organizationId/$projectId/sessions'
| '/_app/$organizationId/integrations'
| '/_app/$organizationId/integrations/_tabs'
@@ -1012,6 +1037,7 @@ export interface FileRouteTypes {
| '/_app/$organizationId/$projectId/settings/_tabs/clients'
| '/_app/$organizationId/$projectId/settings/_tabs/details'
| '/_app/$organizationId/$projectId/settings/_tabs/events'
| '/_app/$organizationId/$projectId/settings/_tabs/gsc'
| '/_app/$organizationId/$projectId/settings/_tabs/imports'
| '/_app/$organizationId/$projectId/settings/_tabs/tracking'
| '/_app/$organizationId/$projectId/settings/_tabs/widgets'
@@ -1310,6 +1336,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof AppOrganizationIdProjectIdSessionsRouteImport
parentRoute: typeof AppOrganizationIdProjectIdRoute
}
'/_app/$organizationId/$projectId/seo': {
id: '/_app/$organizationId/$projectId/seo'
path: '/seo'
fullPath: '/$organizationId/$projectId/seo'
preLoaderRoute: typeof AppOrganizationIdProjectIdSeoRouteImport
parentRoute: typeof AppOrganizationIdProjectIdRoute
}
'/_app/$organizationId/$projectId/reports': {
id: '/_app/$organizationId/$projectId/reports'
path: '/reports'
@@ -1520,6 +1553,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof AppOrganizationIdProjectIdSettingsTabsImportsRouteImport
parentRoute: typeof AppOrganizationIdProjectIdSettingsTabsRoute
}
'/_app/$organizationId/$projectId/settings/_tabs/gsc': {
id: '/_app/$organizationId/$projectId/settings/_tabs/gsc'
path: '/gsc'
fullPath: '/$organizationId/$projectId/settings/gsc'
preLoaderRoute: typeof AppOrganizationIdProjectIdSettingsTabsGscRouteImport
parentRoute: typeof AppOrganizationIdProjectIdSettingsTabsRoute
}
'/_app/$organizationId/$projectId/settings/_tabs/events': {
id: '/_app/$organizationId/$projectId/settings/_tabs/events'
path: '/events'
@@ -1785,6 +1825,7 @@ interface AppOrganizationIdProjectIdSettingsTabsRouteChildren {
AppOrganizationIdProjectIdSettingsTabsClientsRoute: typeof AppOrganizationIdProjectIdSettingsTabsClientsRoute
AppOrganizationIdProjectIdSettingsTabsDetailsRoute: typeof AppOrganizationIdProjectIdSettingsTabsDetailsRoute
AppOrganizationIdProjectIdSettingsTabsEventsRoute: typeof AppOrganizationIdProjectIdSettingsTabsEventsRoute
AppOrganizationIdProjectIdSettingsTabsGscRoute: typeof AppOrganizationIdProjectIdSettingsTabsGscRoute
AppOrganizationIdProjectIdSettingsTabsImportsRoute: typeof AppOrganizationIdProjectIdSettingsTabsImportsRoute
AppOrganizationIdProjectIdSettingsTabsTrackingRoute: typeof AppOrganizationIdProjectIdSettingsTabsTrackingRoute
AppOrganizationIdProjectIdSettingsTabsWidgetsRoute: typeof AppOrganizationIdProjectIdSettingsTabsWidgetsRoute
@@ -1799,6 +1840,8 @@ const AppOrganizationIdProjectIdSettingsTabsRouteChildren: AppOrganizationIdProj
AppOrganizationIdProjectIdSettingsTabsDetailsRoute,
AppOrganizationIdProjectIdSettingsTabsEventsRoute:
AppOrganizationIdProjectIdSettingsTabsEventsRoute,
AppOrganizationIdProjectIdSettingsTabsGscRoute:
AppOrganizationIdProjectIdSettingsTabsGscRoute,
AppOrganizationIdProjectIdSettingsTabsImportsRoute:
AppOrganizationIdProjectIdSettingsTabsImportsRoute,
AppOrganizationIdProjectIdSettingsTabsTrackingRoute:
@@ -1837,6 +1880,7 @@ interface AppOrganizationIdProjectIdRouteChildren {
AppOrganizationIdProjectIdRealtimeRoute: typeof AppOrganizationIdProjectIdRealtimeRoute
AppOrganizationIdProjectIdReferencesRoute: typeof AppOrganizationIdProjectIdReferencesRoute
AppOrganizationIdProjectIdReportsRoute: typeof AppOrganizationIdProjectIdReportsRoute
AppOrganizationIdProjectIdSeoRoute: typeof AppOrganizationIdProjectIdSeoRoute
AppOrganizationIdProjectIdSessionsRoute: typeof AppOrganizationIdProjectIdSessionsRoute
AppOrganizationIdProjectIdIndexRoute: typeof AppOrganizationIdProjectIdIndexRoute
AppOrganizationIdProjectIdDashboardsDashboardIdRoute: typeof AppOrganizationIdProjectIdDashboardsDashboardIdRoute
@@ -1862,6 +1906,7 @@ const AppOrganizationIdProjectIdRouteChildren: AppOrganizationIdProjectIdRouteCh
AppOrganizationIdProjectIdReferencesRoute,
AppOrganizationIdProjectIdReportsRoute:
AppOrganizationIdProjectIdReportsRoute,
AppOrganizationIdProjectIdSeoRoute: AppOrganizationIdProjectIdSeoRoute,
AppOrganizationIdProjectIdSessionsRoute:
AppOrganizationIdProjectIdSessionsRoute,
AppOrganizationIdProjectIdIndexRoute: AppOrganizationIdProjectIdIndexRoute,

View File

@@ -0,0 +1,289 @@
import { PageContainer } from '@/components/page-container';
import { PageHeader } from '@/components/page-header';
import { Skeleton } from '@/components/skeleton';
import { Button } from '@/components/ui/button';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
import { useAppParams } from '@/hooks/use-app-params';
import { useTRPC } from '@/integrations/trpc/react';
import { createProjectTitle } from '@/utils/title';
import { useQuery } from '@tanstack/react-query';
import { createFileRoute, useNavigate } from '@tanstack/react-router';
import { subDays, format } from 'date-fns';
import {
Area,
AreaChart,
CartesianGrid,
ResponsiveContainer,
Tooltip,
XAxis,
YAxis,
} from 'recharts';
export const Route = createFileRoute(
'/_app/$organizationId/$projectId/seo'
)({
component: SeoPage,
head: () => ({
meta: [{ title: createProjectTitle('SEO') }],
}),
});
const startDate = format(subDays(new Date(), 30), 'yyyy-MM-dd');
const endDate = format(subDays(new Date(), 1), 'yyyy-MM-dd');
function SeoPage() {
const { projectId, organizationId } = useAppParams();
const trpc = useTRPC();
const navigate = useNavigate();
const connectionQuery = useQuery(
trpc.gsc.getConnection.queryOptions({ projectId })
);
const connection = connectionQuery.data;
const isConnected = connection && connection.siteUrl;
const overviewQuery = useQuery(
trpc.gsc.getOverview.queryOptions(
{ projectId, startDate, endDate },
{ enabled: !!isConnected }
)
);
const pagesQuery = useQuery(
trpc.gsc.getPages.queryOptions(
{ projectId, startDate, endDate, limit: 50 },
{ enabled: !!isConnected }
)
);
const queriesQuery = useQuery(
trpc.gsc.getQueries.queryOptions(
{ projectId, startDate, endDate, limit: 50 },
{ enabled: !!isConnected }
)
);
if (connectionQuery.isLoading) {
return (
<PageContainer>
<PageHeader title="SEO" description="Google Search Console data" />
<div className="space-y-4 mt-8">
<Skeleton className="h-64 w-full" />
<Skeleton className="h-96 w-full" />
</div>
</PageContainer>
);
}
if (!isConnected) {
return (
<PageContainer>
<PageHeader title="SEO" description="Connect Google Search Console to see your search performance data." />
<div className="mt-16 flex flex-col items-center gap-4 text-center">
<div className="rounded-full bg-muted p-6">
<svg
className="h-12 w-12 text-muted-foreground"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={1.5}
d="M21 21l-4.35-4.35M17 11A6 6 0 1 1 5 11a6 6 0 0 1 12 0z"
/>
</svg>
</div>
<h2 className="text-xl font-semibold">No SEO data yet</h2>
<p className="text-muted-foreground max-w-sm">
Connect Google Search Console to track your search impressions, clicks, and keyword rankings.
</p>
<Button
onClick={() =>
navigate({
to: '/$organizationId/$projectId/settings/gsc',
params: { organizationId, projectId },
})
}
>
Connect Google Search Console
</Button>
</div>
</PageContainer>
);
}
const overview = overviewQuery.data ?? [];
const pages = pagesQuery.data ?? [];
const queries = queriesQuery.data ?? [];
return (
<PageContainer>
<PageHeader
title="SEO"
description={`Search performance for ${connection.siteUrl}`}
/>
<div className="mt-8 space-y-8">
{/* Summary metrics */}
<div className="grid grid-cols-2 gap-4 md:grid-cols-4">
{(['clicks', 'impressions', 'ctr', 'position'] as const).map((metric) => {
const total = overview.reduce((sum, row) => {
if (metric === 'ctr' || metric === 'position') {
return sum + row[metric];
}
return sum + row[metric];
}, 0);
const display =
metric === 'ctr'
? `${((total / Math.max(overview.length, 1)) * 100).toFixed(1)}%`
: metric === 'position'
? (total / Math.max(overview.length, 1)).toFixed(1)
: total.toLocaleString();
const label =
metric === 'ctr'
? 'Avg CTR'
: metric === 'position'
? 'Avg Position'
: metric.charAt(0).toUpperCase() + metric.slice(1);
return (
<div key={metric} className="rounded-lg border p-4">
<div className="text-sm text-muted-foreground">{label}</div>
<div className="text-2xl font-semibold mt-1">{overviewQuery.isLoading ? <Skeleton className="h-8 w-24" /> : display}</div>
</div>
);
})}
</div>
{/* Clicks over time chart */}
<div className="rounded-lg border p-4">
<h3 className="font-medium mb-4">Clicks over time</h3>
{overviewQuery.isLoading ? (
<Skeleton className="h-48 w-full" />
) : (
<ResponsiveContainer width="100%" height={200}>
<AreaChart data={overview}>
<defs>
<linearGradient id="colorClicks" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="hsl(var(--primary))" stopOpacity={0.3} />
<stop offset="95%" stopColor="hsl(var(--primary))" stopOpacity={0} />
</linearGradient>
</defs>
<CartesianGrid strokeDasharray="3 3" className="stroke-border" />
<XAxis dataKey="date" tick={{ fontSize: 12 }} />
<YAxis tick={{ fontSize: 12 }} />
<Tooltip />
<Area
type="monotone"
dataKey="clicks"
stroke="hsl(var(--primary))"
fill="url(#colorClicks)"
strokeWidth={2}
/>
</AreaChart>
</ResponsiveContainer>
)}
</div>
{/* Pages and Queries tables */}
<div className="grid grid-cols-1 gap-8 lg:grid-cols-2">
<GscTable
title="Top pages"
rows={pages}
keyLabel="Page"
keyField="page"
isLoading={pagesQuery.isLoading}
/>
<GscTable
title="Top queries"
rows={queries}
keyLabel="Query"
keyField="query"
isLoading={queriesQuery.isLoading}
/>
</div>
</div>
</PageContainer>
);
}
interface GscTableRow {
clicks: number;
impressions: number;
ctr: number;
position: number;
[key: string]: string | number;
}
function GscTable({
title,
rows,
keyLabel,
keyField,
isLoading,
}: {
title: string;
rows: GscTableRow[];
keyLabel: string;
keyField: string;
isLoading: boolean;
}) {
return (
<div className="rounded-lg border">
<div className="p-4 border-b">
<h3 className="font-medium">{title}</h3>
</div>
<Table>
<TableHeader>
<TableRow>
<TableHead>{keyLabel}</TableHead>
<TableHead className="text-right">Clicks</TableHead>
<TableHead className="text-right">Impressions</TableHead>
<TableHead className="text-right">CTR</TableHead>
<TableHead className="text-right">Position</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{isLoading &&
Array.from({ length: 5 }).map((_, i) => (
<TableRow key={i}>
{Array.from({ length: 5 }).map((_, j) => (
<TableCell key={j}>
<Skeleton className="h-4 w-full" />
</TableCell>
))}
</TableRow>
))}
{!isLoading && rows.length === 0 && (
<TableRow>
<TableCell colSpan={5} className="text-center text-muted-foreground py-8">
No data yet
</TableCell>
</TableRow>
)}
{rows.map((row) => (
<TableRow key={String(row[keyField])}>
<TableCell className="max-w-[200px] truncate font-mono text-xs">
{String(row[keyField])}
</TableCell>
<TableCell className="text-right">{row.clicks.toLocaleString()}</TableCell>
<TableCell className="text-right">{row.impressions.toLocaleString()}</TableCell>
<TableCell className="text-right">{(row.ctr * 100).toFixed(1)}%</TableCell>
<TableCell className="text-right">{row.position.toFixed(1)}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
);
}

View File

@@ -0,0 +1,274 @@
import { Skeleton } from '@/components/skeleton';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { useAppParams } from '@/hooks/use-app-params';
import { useTRPC } from '@/integrations/trpc/react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { createFileRoute } from '@tanstack/react-router';
import { formatDistanceToNow } from 'date-fns';
import { CheckCircleIcon, Loader2Icon, XCircleIcon } from 'lucide-react';
import { useState } from 'react';
import { toast } from 'sonner';
export const Route = createFileRoute(
'/_app/$organizationId/$projectId/settings/_tabs/gsc'
)({
component: GscSettings,
});
function GscSettings() {
const { projectId } = useAppParams();
const trpc = useTRPC();
const queryClient = useQueryClient();
const [selectedSite, setSelectedSite] = useState('');
const connectionQuery = useQuery(
trpc.gsc.getConnection.queryOptions(
{ projectId },
{ refetchInterval: 5000 }
)
);
const sitesQuery = useQuery(
trpc.gsc.getSites.queryOptions(
{ projectId },
{ enabled: !!connectionQuery.data && !connectionQuery.data.siteUrl }
)
);
const initiateOAuth = useMutation(
trpc.gsc.initiateOAuth.mutationOptions({
onSuccess: (data) => {
// Route through the API /gsc/initiate endpoint which sets cookies then redirects to Google
const apiUrl = (import.meta.env.VITE_API_URL as string) ?? '';
const initiateUrl = new URL(`${apiUrl}/gsc/initiate`);
initiateUrl.searchParams.set('state', data.state);
initiateUrl.searchParams.set('code_verifier', data.codeVerifier);
initiateUrl.searchParams.set('project_id', data.projectId);
initiateUrl.searchParams.set('redirect', data.url);
window.location.href = initiateUrl.toString();
},
onError: () => {
toast.error('Failed to initiate Google Search Console connection');
},
})
);
const selectSite = useMutation(
trpc.gsc.selectSite.mutationOptions({
onSuccess: () => {
toast.success('Site connected', {
description: 'Backfill of 6 months of data has started.',
});
queryClient.invalidateQueries(trpc.gsc.getConnection.pathFilter());
},
onError: () => {
toast.error('Failed to select site');
},
})
);
const disconnect = useMutation(
trpc.gsc.disconnect.mutationOptions({
onSuccess: () => {
toast.success('Disconnected from Google Search Console');
queryClient.invalidateQueries(trpc.gsc.getConnection.pathFilter());
},
onError: () => {
toast.error('Failed to disconnect');
},
})
);
const connection = connectionQuery.data;
if (connectionQuery.isLoading) {
return (
<div className="space-y-4">
<Skeleton className="h-32 w-full" />
</div>
);
}
// Not connected at all
if (!connection) {
return (
<div className="space-y-6">
<div>
<h3 className="text-lg font-medium">Google Search Console</h3>
<p className="text-sm text-muted-foreground mt-1">
Connect your Google Search Console property to import search performance data.
</p>
</div>
<div className="rounded-lg border p-6 flex flex-col gap-4">
<p className="text-sm text-muted-foreground">
You will be redirected to Google to authorize access. Only read-only access to Search Console data is requested.
</p>
<Button
className="w-fit"
onClick={() => initiateOAuth.mutate({ projectId })}
disabled={initiateOAuth.isPending}
>
{initiateOAuth.isPending && (
<Loader2Icon className="mr-2 h-4 w-4 animate-spin" />
)}
Connect Google Search Console
</Button>
</div>
</div>
);
}
// Connected but no site selected yet
if (!connection.siteUrl) {
const sites = sitesQuery.data ?? [];
return (
<div className="space-y-6">
<div>
<h3 className="text-lg font-medium">Select a property</h3>
<p className="text-sm text-muted-foreground mt-1">
Choose which Google Search Console property to connect to this project.
</p>
</div>
<div className="rounded-lg border p-6 space-y-4">
{sitesQuery.isLoading ? (
<Skeleton className="h-10 w-full" />
) : sites.length === 0 ? (
<p className="text-sm text-muted-foreground">
No Search Console properties found for this Google account.
</p>
) : (
<>
<Select value={selectedSite} onValueChange={setSelectedSite}>
<SelectTrigger>
<SelectValue placeholder="Select a property..." />
</SelectTrigger>
<SelectContent>
{sites.map((site) => (
<SelectItem key={site.siteUrl} value={site.siteUrl}>
{site.siteUrl}
</SelectItem>
))}
</SelectContent>
</Select>
<Button
disabled={!selectedSite || selectSite.isPending}
onClick={() => selectSite.mutate({ projectId, siteUrl: selectedSite })}
>
{selectSite.isPending && (
<Loader2Icon className="mr-2 h-4 w-4 animate-spin" />
)}
Connect property
</Button>
</>
)}
</div>
<Button
variant="ghost"
size="sm"
onClick={() => disconnect.mutate({ projectId })}
>
Cancel
</Button>
</div>
);
}
// Fully connected
const syncStatusIcon =
connection.lastSyncStatus === 'success' ? (
<CheckCircleIcon className="h-4 w-4" />
) : connection.lastSyncStatus === 'error' ? (
<XCircleIcon className="h-4 w-4" />
) : null;
const syncStatusVariant =
connection.lastSyncStatus === 'success'
? 'success'
: connection.lastSyncStatus === 'error'
? 'destructive'
: 'secondary';
return (
<div className="space-y-6">
<div>
<h3 className="text-lg font-medium">Google Search Console</h3>
<p className="text-sm text-muted-foreground mt-1">
Connected to Google Search Console.
</p>
</div>
<div className="rounded-lg border divide-y">
<div className="p-4 flex items-center justify-between">
<div className="text-sm font-medium">Property</div>
<div className="font-mono text-sm text-muted-foreground">
{connection.siteUrl}
</div>
</div>
{connection.backfillStatus && (
<div className="p-4 flex items-center justify-between">
<div className="text-sm font-medium">Backfill</div>
<Badge className="capitalize" variant={
connection.backfillStatus === 'completed' ? 'success' :
connection.backfillStatus === 'failed' ? 'destructive' :
connection.backfillStatus === 'running' ? 'default' : 'secondary'
}>
{connection.backfillStatus === 'running' && (
<Loader2Icon className="mr-1 h-3 w-3 animate-spin" />
)}
{connection.backfillStatus}
</Badge>
</div>
)}
{connection.lastSyncedAt && (
<div className="p-4 flex items-center justify-between">
<div className="text-sm font-medium">Last synced</div>
<div className="flex items-center gap-2">
{connection.lastSyncStatus && (
<Badge className="capitalize" variant={syncStatusVariant as any}>
{syncStatusIcon}
{connection.lastSyncStatus}
</Badge>
)}
<span className="text-sm text-muted-foreground">
{formatDistanceToNow(new Date(connection.lastSyncedAt), {
addSuffix: true,
})}
</span>
</div>
</div>
)}
{connection.lastSyncError && (
<div className="p-4">
<div className="text-sm font-medium text-destructive">Last error</div>
<div className="mt-1 text-sm text-muted-foreground font-mono break-words">
{connection.lastSyncError}
</div>
</div>
)}
</div>
<Button
variant="destructive"
size="sm"
onClick={() => disconnect.mutate({ projectId })}
disabled={disconnect.isPending}
>
{disconnect.isPending && (
<Loader2Icon className="mr-2 h-4 w-4 animate-spin" />
)}
Disconnect
</Button>
</div>
);
}

View File

@@ -45,6 +45,7 @@ function ProjectDashboard() {
{ id: 'tracking', label: 'Tracking script' },
{ id: 'widgets', label: 'Widgets' },
{ id: 'imports', label: 'Imports' },
{ id: 'gsc', label: 'Google Search' },
];
const handleTabChange = (tabId: string) => {

View File

@@ -78,6 +78,11 @@ export async function bootCron() {
type: 'onboarding',
pattern: '0 * * * *',
},
{
name: 'gscSync',
type: 'gscSync',
pattern: '0 3 * * *',
},
];
if (process.env.SELF_HOSTED && process.env.NODE_ENV === 'production') {

View File

@@ -6,6 +6,7 @@ import {
type EventsQueuePayloadIncomingEvent,
cronQueue,
eventsGroupQueues,
gscQueue,
importQueue,
insightsQueue,
miscQueue,
@@ -20,6 +21,7 @@ import { setTimeout as sleep } from 'node:timers/promises';
import { Worker as GroupWorker } from 'groupmq';
import { cronJob } from './jobs/cron';
import { gscJob } from './jobs/gsc';
import { incomingEvent } from './jobs/events.incoming-event';
import { importJob } from './jobs/import';
import { insightsProjectJob } from './jobs/insights';
@@ -59,6 +61,7 @@ function getEnabledQueues(): QueueName[] {
'misc',
'import',
'insights',
'gsc',
];
}
@@ -208,6 +211,17 @@ export async function bootWorkers() {
logger.info('Started worker for insights', { concurrency });
}
// Start gsc worker
if (enabledQueues.includes('gsc')) {
const concurrency = getConcurrencyFor('gsc', 5);
const gscWorker = new Worker(gscQueue.name, gscJob, {
...workerOptions,
concurrency,
});
workers.push(gscWorker);
logger.info('Started worker for gsc', { concurrency });
}
if (workers.length === 0) {
logger.warn(
'No workers started. Check ENABLED_QUEUES environment variable.',

View File

@@ -4,6 +4,7 @@ import { eventBuffer, profileBackfillBuffer, profileBuffer, replayBuffer, sessio
import type { CronQueuePayload } from '@openpanel/queue';
import { jobdeleteProjects } from './cron.delete-projects';
import { gscSyncAllJob } from './gsc';
import { onboardingJob } from './cron.onboarding';
import { ping } from './cron.ping';
import { salt } from './cron.salt';
@@ -41,5 +42,8 @@ export async function cronJob(job: Job<CronQueuePayload>) {
case 'onboarding': {
return await onboardingJob(job);
}
case 'gscSync': {
return await gscSyncAllJob();
}
}
}

142
apps/worker/src/jobs/gsc.ts Normal file
View File

@@ -0,0 +1,142 @@
import { db, syncGscData } from '@openpanel/db';
import { gscQueue } from '@openpanel/queue';
import type { GscQueuePayload } from '@openpanel/queue';
import type { Job } from 'bullmq';
import { logger } from '../utils/logger';
const BACKFILL_MONTHS = 6;
const CHUNK_DAYS = 14;
export async function gscJob(job: Job<GscQueuePayload>) {
switch (job.data.type) {
case 'gscProjectSync':
return gscProjectSyncJob(job.data.payload.projectId);
case 'gscProjectBackfill':
return gscProjectBackfillJob(job.data.payload.projectId);
}
}
async function gscProjectSyncJob(projectId: string) {
const conn = await db.gscConnection.findUnique({ where: { projectId } });
if (!conn?.siteUrl) {
logger.warn('GSC sync skipped: no connection or siteUrl', { projectId });
return;
}
try {
// Sync rolling 3-day window (GSC data can arrive late)
const endDate = new Date();
endDate.setDate(endDate.getDate() - 1); // yesterday
const startDate = new Date(endDate);
startDate.setDate(startDate.getDate() - 2); // 3 days total
await syncGscData(projectId, startDate, endDate);
await db.gscConnection.update({
where: { projectId },
data: {
lastSyncedAt: new Date(),
lastSyncStatus: 'success',
lastSyncError: null,
},
});
logger.info('GSC sync completed', { projectId });
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
await db.gscConnection.update({
where: { projectId },
data: {
lastSyncedAt: new Date(),
lastSyncStatus: 'error',
lastSyncError: message,
},
});
logger.error('GSC sync failed', { projectId, error });
throw error;
}
}
async function gscProjectBackfillJob(projectId: string) {
const conn = await db.gscConnection.findUnique({ where: { projectId } });
if (!conn?.siteUrl) {
logger.warn('GSC backfill skipped: no connection or siteUrl', { projectId });
return;
}
await db.gscConnection.update({
where: { projectId },
data: { backfillStatus: 'running' },
});
try {
const endDate = new Date();
endDate.setDate(endDate.getDate() - 1); // yesterday
const startDate = new Date(endDate);
startDate.setMonth(startDate.getMonth() - BACKFILL_MONTHS);
// Process in chunks to avoid timeouts and respect API limits
let chunkEnd = new Date(endDate);
while (chunkEnd > startDate) {
const chunkStart = new Date(chunkEnd);
chunkStart.setDate(chunkStart.getDate() - CHUNK_DAYS + 1);
if (chunkStart < startDate) {
chunkStart.setTime(startDate.getTime());
}
logger.info('GSC backfill chunk', {
projectId,
from: chunkStart.toISOString().slice(0, 10),
to: chunkEnd.toISOString().slice(0, 10),
});
await syncGscData(projectId, chunkStart, chunkEnd);
chunkEnd = new Date(chunkStart);
chunkEnd.setDate(chunkEnd.getDate() - 1);
}
await db.gscConnection.update({
where: { projectId },
data: {
backfillStatus: 'completed',
lastSyncedAt: new Date(),
lastSyncStatus: 'success',
lastSyncError: null,
},
});
logger.info('GSC backfill completed', { projectId });
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
await db.gscConnection.update({
where: { projectId },
data: {
backfillStatus: 'failed',
lastSyncStatus: 'error',
lastSyncError: message,
},
});
logger.error('GSC backfill failed', { projectId, error });
throw error;
}
}
export async function gscSyncAllJob() {
const connections = await db.gscConnection.findMany({
where: {
siteUrl: { not: '' },
},
select: { projectId: true },
});
logger.info('GSC nightly sync: enqueuing projects', {
count: connections.length,
});
for (const conn of connections) {
await gscQueue.add('gscProjectSync', {
type: 'gscProjectSync',
payload: { projectId: conn.projectId },
});
}
}