diff --git a/apps/api/src/controllers/gsc-oauth-callback.controller.ts b/apps/api/src/controllers/gsc-oauth-callback.controller.ts new file mode 100644 index 00000000..639fc50f --- /dev/null +++ b/apps/api/src/controllers/gsc-oauth-callback.controller.ts @@ -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()); +} diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index bf933329..ee5e0fd7 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -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' }); }); diff --git a/apps/api/src/routes/gsc-callback.router.ts b/apps/api/src/routes/gsc-callback.router.ts new file mode 100644 index 00000000..0becfb77 --- /dev/null +++ b/apps/api/src/routes/gsc-callback.router.ts @@ -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; diff --git a/apps/start/src/components/sidebar-project-menu.tsx b/apps/start/src/components/sidebar-project-menu.tsx index 50498c89..8c0a5594 100644 --- a/apps/start/src/components/sidebar-project-menu.tsx +++ b/apps/start/src/components/sidebar-project-menu.tsx @@ -14,6 +14,7 @@ import { LayoutDashboardIcon, LayoutPanelTopIcon, PlusIcon, + SearchIcon, SparklesIcon, TrendingUpDownIcon, UndoDotIcon, @@ -59,6 +60,7 @@ export default function SidebarProjectMenu({ +
Manage
diff --git a/apps/start/src/routeTree.gen.ts b/apps/start/src/routeTree.gen.ts index 221cc39d..8742b541 100644 --- a/apps/start/src/routeTree.gen.ts +++ b/apps/start/src/routeTree.gen.ts @@ -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, diff --git a/apps/start/src/routes/_app.$organizationId.$projectId.seo.tsx b/apps/start/src/routes/_app.$organizationId.$projectId.seo.tsx new file mode 100644 index 00000000..ba87e5f9 --- /dev/null +++ b/apps/start/src/routes/_app.$organizationId.$projectId.seo.tsx @@ -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 ( + + +
+ + +
+
+ ); + } + + if (!isConnected) { + return ( + + +
+
+ + + +
+

No SEO data yet

+

+ Connect Google Search Console to track your search impressions, clicks, and keyword rankings. +

+ +
+
+ ); + } + + const overview = overviewQuery.data ?? []; + const pages = pagesQuery.data ?? []; + const queries = queriesQuery.data ?? []; + + return ( + + + +
+ {/* Summary metrics */} +
+ {(['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 ( +
+
{label}
+
{overviewQuery.isLoading ? : display}
+
+ ); + })} +
+ + {/* Clicks over time chart */} +
+

Clicks over time

+ {overviewQuery.isLoading ? ( + + ) : ( + + + + + + + + + + + + + + + + )} +
+ + {/* Pages and Queries tables */} +
+ + +
+
+
+ ); +} + +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 ( +
+
+

{title}

+
+ + + + {keyLabel} + Clicks + Impressions + CTR + Position + + + + {isLoading && + Array.from({ length: 5 }).map((_, i) => ( + + {Array.from({ length: 5 }).map((_, j) => ( + + + + ))} + + ))} + {!isLoading && rows.length === 0 && ( + + + No data yet + + + )} + {rows.map((row) => ( + + + {String(row[keyField])} + + {row.clicks.toLocaleString()} + {row.impressions.toLocaleString()} + {(row.ctr * 100).toFixed(1)}% + {row.position.toFixed(1)} + + ))} + +
+
+ ); +} diff --git a/apps/start/src/routes/_app.$organizationId.$projectId.settings._tabs.gsc.tsx b/apps/start/src/routes/_app.$organizationId.$projectId.settings._tabs.gsc.tsx new file mode 100644 index 00000000..af18c356 --- /dev/null +++ b/apps/start/src/routes/_app.$organizationId.$projectId.settings._tabs.gsc.tsx @@ -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 ( +
+ +
+ ); + } + + // Not connected at all + if (!connection) { + return ( +
+
+

Google Search Console

+

+ Connect your Google Search Console property to import search performance data. +

+
+
+

+ You will be redirected to Google to authorize access. Only read-only access to Search Console data is requested. +

+ +
+
+ ); + } + + // Connected but no site selected yet + if (!connection.siteUrl) { + const sites = sitesQuery.data ?? []; + return ( +
+
+

Select a property

+

+ Choose which Google Search Console property to connect to this project. +

+
+
+ {sitesQuery.isLoading ? ( + + ) : sites.length === 0 ? ( +

+ No Search Console properties found for this Google account. +

+ ) : ( + <> + + + + )} +
+ +
+ ); + } + + // Fully connected + const syncStatusIcon = + connection.lastSyncStatus === 'success' ? ( + + ) : connection.lastSyncStatus === 'error' ? ( + + ) : null; + + const syncStatusVariant = + connection.lastSyncStatus === 'success' + ? 'success' + : connection.lastSyncStatus === 'error' + ? 'destructive' + : 'secondary'; + + return ( +
+
+

Google Search Console

+

+ Connected to Google Search Console. +

+
+ +
+
+
Property
+
+ {connection.siteUrl} +
+
+ + {connection.backfillStatus && ( +
+
Backfill
+ + {connection.backfillStatus === 'running' && ( + + )} + {connection.backfillStatus} + +
+ )} + + {connection.lastSyncedAt && ( +
+
Last synced
+
+ {connection.lastSyncStatus && ( + + {syncStatusIcon} + {connection.lastSyncStatus} + + )} + + {formatDistanceToNow(new Date(connection.lastSyncedAt), { + addSuffix: true, + })} + +
+
+ )} + + {connection.lastSyncError && ( +
+
Last error
+
+ {connection.lastSyncError} +
+
+ )} +
+ + +
+ ); +} diff --git a/apps/start/src/routes/_app.$organizationId.$projectId.settings._tabs.tsx b/apps/start/src/routes/_app.$organizationId.$projectId.settings._tabs.tsx index 205fb7bc..b0037ed9 100644 --- a/apps/start/src/routes/_app.$organizationId.$projectId.settings._tabs.tsx +++ b/apps/start/src/routes/_app.$organizationId.$projectId.settings._tabs.tsx @@ -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) => { diff --git a/apps/worker/src/boot-cron.ts b/apps/worker/src/boot-cron.ts index 106cec07..e3835e91 100644 --- a/apps/worker/src/boot-cron.ts +++ b/apps/worker/src/boot-cron.ts @@ -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') { diff --git a/apps/worker/src/boot-workers.ts b/apps/worker/src/boot-workers.ts index 6d96dd61..5b4285ca 100644 --- a/apps/worker/src/boot-workers.ts +++ b/apps/worker/src/boot-workers.ts @@ -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.', diff --git a/apps/worker/src/jobs/cron.ts b/apps/worker/src/jobs/cron.ts index 8d69afec..f9428614 100644 --- a/apps/worker/src/jobs/cron.ts +++ b/apps/worker/src/jobs/cron.ts @@ -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) { case 'onboarding': { return await onboardingJob(job); } + case 'gscSync': { + return await gscSyncAllJob(); + } } } diff --git a/apps/worker/src/jobs/gsc.ts b/apps/worker/src/jobs/gsc.ts new file mode 100644 index 00000000..6ed9f51a --- /dev/null +++ b/apps/worker/src/jobs/gsc.ts @@ -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) { + 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 }, + }); + } +} diff --git a/packages/auth/src/oauth.ts b/packages/auth/src/oauth.ts index 18464f6a..9bd8cd14 100644 --- a/packages/auth/src/oauth.ts +++ b/packages/auth/src/oauth.ts @@ -16,3 +16,9 @@ export const google = new Arctic.Google( process.env.GOOGLE_CLIENT_SECRET ?? '', process.env.GOOGLE_REDIRECT_URI ?? '', ); + +export const googleGsc = new Arctic.Google( + process.env.GOOGLE_CLIENT_ID ?? '', + process.env.GOOGLE_CLIENT_SECRET ?? '', + process.env.GSC_GOOGLE_REDIRECT_URI ?? '', +); diff --git a/packages/db/code-migrations/12-add-gsc.ts b/packages/db/code-migrations/12-add-gsc.ts new file mode 100644 index 00000000..43d82d9f --- /dev/null +++ b/packages/db/code-migrations/12-add-gsc.ts @@ -0,0 +1,85 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { createTable, runClickhouseMigrationCommands } from '../src/clickhouse/migration'; +import { getIsCluster } from './helpers'; + +export async function up() { + const isClustered = getIsCluster(); + + const commonMetricColumns = [ + '`clicks` UInt32 CODEC(Delta(4), LZ4)', + '`impressions` UInt32 CODEC(Delta(4), LZ4)', + '`ctr` Float32 CODEC(Gorilla, LZ4)', + '`position` Float32 CODEC(Gorilla, LZ4)', + '`synced_at` DateTime DEFAULT now() CODEC(Delta(4), LZ4)', + ]; + + const sqls: string[] = [ + // Daily totals — accurate overview numbers + ...createTable({ + name: 'gsc_daily', + columns: [ + '`project_id` String CODEC(ZSTD(3))', + '`date` Date CODEC(Delta(2), LZ4)', + ...commonMetricColumns, + ], + orderBy: ['project_id', 'date'], + partitionBy: 'toYYYYMM(date)', + engine: 'ReplacingMergeTree(synced_at)', + distributionHash: 'cityHash64(project_id)', + replicatedVersion: '1', + isClustered, + }), + + // Per-page breakdown + ...createTable({ + name: 'gsc_pages_daily', + columns: [ + '`project_id` String CODEC(ZSTD(3))', + '`date` Date CODEC(Delta(2), LZ4)', + '`page` String CODEC(ZSTD(3))', + ...commonMetricColumns, + ], + orderBy: ['project_id', 'date', 'page'], + partitionBy: 'toYYYYMM(date)', + engine: 'ReplacingMergeTree(synced_at)', + distributionHash: 'cityHash64(project_id)', + replicatedVersion: '1', + isClustered, + }), + + // Per-query breakdown + ...createTable({ + name: 'gsc_queries_daily', + columns: [ + '`project_id` String CODEC(ZSTD(3))', + '`date` Date CODEC(Delta(2), LZ4)', + '`query` String CODEC(ZSTD(3))', + ...commonMetricColumns, + ], + orderBy: ['project_id', 'date', 'query'], + partitionBy: 'toYYYYMM(date)', + engine: 'ReplacingMergeTree(synced_at)', + distributionHash: 'cityHash64(project_id)', + replicatedVersion: '1', + isClustered, + }), + ]; + + fs.writeFileSync( + path.join(__filename.replace('.ts', '.sql')), + sqls + .map((sql) => + sql + .trim() + .replace(/;$/, '') + .replace(/\n{2,}/g, '\n') + .concat(';'), + ) + .join('\n\n---\n\n'), + ); + + if (!process.argv.includes('--dry')) { + await runClickhouseMigrationCommands(sqls); + } +} diff --git a/packages/db/index.ts b/packages/db/index.ts index f0e461c3..33e8902a 100644 --- a/packages/db/index.ts +++ b/packages/db/index.ts @@ -31,3 +31,4 @@ export * from './src/services/overview.service'; export * from './src/services/pages.service'; export * from './src/services/insights'; export * from './src/session-context'; +export * from './src/gsc'; diff --git a/packages/db/prisma/schema.prisma b/packages/db/prisma/schema.prisma index 784f94fb..8bf72e14 100644 --- a/packages/db/prisma/schema.prisma +++ b/packages/db/prisma/schema.prisma @@ -203,6 +203,7 @@ model Project { notificationRules NotificationRule[] notifications Notification[] imports Import[] + gscConnection GscConnection? // When deleteAt > now(), the project will be deleted deleteAt DateTime? @@ -612,6 +613,24 @@ model InsightEvent { @@map("insight_events") } +model GscConnection { + id String @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid + projectId String @unique + project Project @relation(fields: [projectId], references: [id], onDelete: Cascade) + siteUrl String @default("") + accessToken String + refreshToken String + accessTokenExpiresAt DateTime? + lastSyncedAt DateTime? + lastSyncStatus String? + lastSyncError String? + backfillStatus String? + createdAt DateTime @default(now()) + updatedAt DateTime @default(now()) @updatedAt + + @@map("gsc_connections") +} + model EmailUnsubscribe { id String @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid email String diff --git a/packages/db/src/clickhouse/client.ts b/packages/db/src/clickhouse/client.ts index d2899f82..3a0ba20a 100644 --- a/packages/db/src/clickhouse/client.ts +++ b/packages/db/src/clickhouse/client.ts @@ -58,6 +58,9 @@ export const TABLE_NAMES = { sessions: 'sessions', events_imports: 'events_imports', session_replay_chunks: 'session_replay_chunks', + gsc_daily: 'gsc_daily', + gsc_pages_daily: 'gsc_pages_daily', + gsc_queries_daily: 'gsc_queries_daily', }; /** diff --git a/packages/db/src/gsc.ts b/packages/db/src/gsc.ts new file mode 100644 index 00000000..6a8e5e08 --- /dev/null +++ b/packages/db/src/gsc.ts @@ -0,0 +1,341 @@ +import { originalCh } from './clickhouse/client'; +import { db } from './prisma-client'; + +export interface GscSite { + siteUrl: string; + permissionLevel: string; +} + +async function refreshGscToken( + refreshToken: string +): Promise<{ accessToken: string; expiresAt: Date }> { + const params = new URLSearchParams({ + client_id: process.env.GOOGLE_CLIENT_ID ?? '', + client_secret: process.env.GOOGLE_CLIENT_SECRET ?? '', + refresh_token: refreshToken, + grant_type: 'refresh_token', + }); + + const res = await fetch('https://oauth2.googleapis.com/token', { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: params.toString(), + }); + + if (!res.ok) { + const text = await res.text(); + throw new Error(`Failed to refresh GSC token: ${text}`); + } + + const data = (await res.json()) as { + access_token: string; + expires_in: number; + }; + const expiresAt = new Date(Date.now() + data.expires_in * 1000); + return { accessToken: data.access_token, expiresAt }; +} + +export async function getGscAccessToken(projectId: string): Promise { + const conn = await db.gscConnection.findUniqueOrThrow({ + where: { projectId }, + }); + + if ( + conn.accessTokenExpiresAt && + conn.accessTokenExpiresAt.getTime() > Date.now() + 60_000 + ) { + return conn.accessToken; + } + + const { accessToken, expiresAt } = await refreshGscToken(conn.refreshToken); + await db.gscConnection.update({ + where: { projectId }, + data: { accessToken, accessTokenExpiresAt: expiresAt }, + }); + return accessToken; +} + +export async function listGscSites(projectId: string): Promise { + const accessToken = await getGscAccessToken(projectId); + const res = await fetch('https://www.googleapis.com/webmaster/v3/sites', { + headers: { Authorization: `Bearer ${accessToken}` }, + }); + + if (!res.ok) { + const text = await res.text(); + throw new Error(`Failed to list GSC sites: ${text}`); + } + + const data = (await res.json()) as { + siteEntry?: Array<{ siteUrl: string; permissionLevel: string }>; + }; + return data.siteEntry ?? []; +} + +interface GscApiRow { + keys: string[]; + clicks: number; + impressions: number; + ctr: number; + position: number; +} + +async function queryGscSearchAnalytics( + accessToken: string, + siteUrl: string, + startDate: string, + endDate: string, + dimensions: string[] +): Promise { + const encodedSiteUrl = encodeURIComponent(siteUrl); + const url = `https://www.googleapis.com/webmaster/v3/sites/${encodedSiteUrl}/searchAnalytics/query`; + + const allRows: GscApiRow[] = []; + let startRow = 0; + const rowLimit = 25000; + + while (true) { + const res = await fetch(url, { + method: 'POST', + headers: { + Authorization: `Bearer ${accessToken}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + startDate, + endDate, + dimensions, + rowLimit, + startRow, + dataState: 'all', + }), + }); + + if (!res.ok) { + const text = await res.text(); + throw new Error(`GSC query failed for dimensions [${dimensions.join(',')}]: ${text}`); + } + + const data = (await res.json()) as { rows?: GscApiRow[] }; + const rows = data.rows ?? []; + allRows.push(...rows); + + if (rows.length < rowLimit) break; + startRow += rowLimit; + } + + return allRows; +} + +function formatDate(date: Date): string { + return date.toISOString().slice(0, 10); +} + +function nowString(): string { + return new Date().toISOString().replace('T', ' ').replace('Z', ''); +} + +export async function syncGscData( + projectId: string, + startDate: Date, + endDate: Date +): Promise { + const conn = await db.gscConnection.findUniqueOrThrow({ + where: { projectId }, + }); + + if (!conn.siteUrl) { + throw new Error('No GSC site URL configured for this project'); + } + + const accessToken = await getGscAccessToken(projectId); + const start = formatDate(startDate); + const end = formatDate(endDate); + const syncedAt = nowString(); + + // 1. Daily totals — authoritative numbers for overview chart + const dailyRows = await queryGscSearchAnalytics( + accessToken, + conn.siteUrl, + start, + end, + ['date'] + ); + + if (dailyRows.length > 0) { + await originalCh.insert({ + table: 'gsc_daily', + values: dailyRows.map((row) => ({ + project_id: projectId, + date: row.keys[0] ?? '', + clicks: row.clicks, + impressions: row.impressions, + ctr: row.ctr, + position: row.position, + synced_at: syncedAt, + })), + format: 'JSONEachRow', + }); + } + + // 2. Per-page breakdown + const pageRows = await queryGscSearchAnalytics( + accessToken, + conn.siteUrl, + start, + end, + ['date', 'page'] + ); + + if (pageRows.length > 0) { + await originalCh.insert({ + table: 'gsc_pages_daily', + values: pageRows.map((row) => ({ + project_id: projectId, + date: row.keys[0] ?? '', + page: row.keys[1] ?? '', + clicks: row.clicks, + impressions: row.impressions, + ctr: row.ctr, + position: row.position, + synced_at: syncedAt, + })), + format: 'JSONEachRow', + }); + } + + // 3. Per-query breakdown + const queryRows = await queryGscSearchAnalytics( + accessToken, + conn.siteUrl, + start, + end, + ['date', 'query'] + ); + + if (queryRows.length > 0) { + await originalCh.insert({ + table: 'gsc_queries_daily', + values: queryRows.map((row) => ({ + project_id: projectId, + date: row.keys[0] ?? '', + query: row.keys[1] ?? '', + clicks: row.clicks, + impressions: row.impressions, + ctr: row.ctr, + position: row.position, + synced_at: syncedAt, + })), + format: 'JSONEachRow', + }); + } +} + +export async function getGscOverview( + projectId: string, + startDate: string, + endDate: string +): Promise< + Array<{ + date: string; + clicks: number; + impressions: number; + ctr: number; + position: number; + }> +> { + const result = await originalCh.query({ + query: ` + SELECT + date, + sum(clicks) as clicks, + sum(impressions) as impressions, + avg(ctr) as ctr, + avg(position) as position + FROM gsc_daily + FINAL + WHERE project_id = {projectId: String} + AND date >= {startDate: String} + AND date <= {endDate: String} + GROUP BY date + ORDER BY date ASC + `, + query_params: { projectId, startDate, endDate }, + format: 'JSONEachRow', + }); + return result.json(); +} + +export async function getGscPages( + projectId: string, + startDate: string, + endDate: string, + limit = 100 +): Promise< + Array<{ + page: string; + clicks: number; + impressions: number; + ctr: number; + position: number; + }> +> { + const result = await originalCh.query({ + query: ` + SELECT + page, + sum(clicks) as clicks, + sum(impressions) as impressions, + avg(ctr) as ctr, + avg(position) as position + FROM gsc_pages_daily + FINAL + WHERE project_id = {projectId: String} + AND date >= {startDate: String} + AND date <= {endDate: String} + GROUP BY page + ORDER BY clicks DESC + LIMIT {limit: UInt32} + `, + query_params: { projectId, startDate, endDate, limit }, + format: 'JSONEachRow', + }); + return result.json(); +} + +export async function getGscQueries( + projectId: string, + startDate: string, + endDate: string, + limit = 100 +): Promise< + Array<{ + query: string; + clicks: number; + impressions: number; + ctr: number; + position: number; + }> +> { + const result = await originalCh.query({ + query: ` + SELECT + query, + sum(clicks) as clicks, + sum(impressions) as impressions, + avg(ctr) as ctr, + avg(position) as position + FROM gsc_queries_daily + FINAL + WHERE project_id = {projectId: String} + AND date >= {startDate: String} + AND date <= {endDate: String} + GROUP BY query + ORDER BY clicks DESC + LIMIT {limit: UInt32} + `, + query_params: { projectId, startDate, endDate, limit }, + format: 'JSONEachRow', + }); + return result.json(); +} diff --git a/packages/queue/src/queues.ts b/packages/queue/src/queues.ts index 4ba92b6a..aa769b48 100644 --- a/packages/queue/src/queues.ts +++ b/packages/queue/src/queues.ts @@ -126,6 +126,10 @@ export type CronQueuePayloadFlushReplay = { type: 'flushReplay'; payload: undefined; }; +export type CronQueuePayloadGscSync = { + type: 'gscSync'; + payload: undefined; +}; export type CronQueuePayload = | CronQueuePayloadSalt | CronQueuePayloadFlushEvents @@ -136,7 +140,8 @@ export type CronQueuePayload = | CronQueuePayloadPing | CronQueuePayloadProject | CronQueuePayloadInsightsDaily - | CronQueuePayloadOnboarding; + | CronQueuePayloadOnboarding + | CronQueuePayloadGscSync; export type MiscQueuePayloadTrialEndingSoon = { type: 'trialEndingSoon'; @@ -268,3 +273,21 @@ export const insightsQueue = new Queue( }, } ); + +export type GscQueuePayloadSync = { + type: 'gscProjectSync'; + payload: { projectId: string }; +}; +export type GscQueuePayloadBackfill = { + type: 'gscProjectBackfill'; + payload: { projectId: string }; +}; +export type GscQueuePayload = GscQueuePayloadSync | GscQueuePayloadBackfill; + +export const gscQueue = new Queue(getQueueName('gsc'), { + connection: getRedisQueue(), + defaultJobOptions: { + removeOnComplete: 50, + removeOnFail: 100, + }, +}); diff --git a/packages/trpc/src/root.ts b/packages/trpc/src/root.ts index 068a321d..626c1312 100644 --- a/packages/trpc/src/root.ts +++ b/packages/trpc/src/root.ts @@ -1,4 +1,5 @@ import { authRouter } from './routers/auth'; +import { gscRouter } from './routers/gsc'; import { chartRouter } from './routers/chart'; import { chatRouter } from './routers/chat'; import { clientRouter } from './routers/client'; @@ -53,6 +54,7 @@ export const appRouter = createTRPCRouter({ insight: insightRouter, widget: widgetRouter, email: emailRouter, + gsc: gscRouter, }); // export type definition of API diff --git a/packages/trpc/src/routers/gsc.ts b/packages/trpc/src/routers/gsc.ts new file mode 100644 index 00000000..2cab43cf --- /dev/null +++ b/packages/trpc/src/routers/gsc.ts @@ -0,0 +1,201 @@ +import { Arctic, googleGsc } from '@openpanel/auth'; +import { + db, + getGscOverview, + getGscPages, + getGscQueries, + listGscSites, +} from '@openpanel/db'; +import { gscQueue } from '@openpanel/queue'; +import { z } from 'zod'; +import { getProjectAccess } from '../access'; +import { TRPCAccessError, TRPCNotFoundError } from '../errors'; +import { createTRPCRouter, protectedProcedure } from '../trpc'; + +export const gscRouter = createTRPCRouter({ + getConnection: protectedProcedure + .input(z.object({ projectId: z.string() })) + .query(async ({ input, ctx }) => { + const access = await getProjectAccess({ + projectId: input.projectId, + userId: ctx.session.userId, + }); + if (!access) { + throw TRPCAccessError('You do not have access to this project'); + } + return db.gscConnection.findUnique({ + where: { projectId: input.projectId }, + select: { + id: true, + siteUrl: true, + lastSyncedAt: true, + lastSyncStatus: true, + lastSyncError: true, + backfillStatus: true, + createdAt: true, + updatedAt: true, + }, + }); + }), + + initiateOAuth: protectedProcedure + .input(z.object({ projectId: z.string() })) + .mutation(async ({ input, ctx }) => { + const access = await getProjectAccess({ + projectId: input.projectId, + userId: ctx.session.userId, + }); + if (!access) { + throw TRPCAccessError('You do not have access to this project'); + } + + const state = Arctic.generateState(); + const codeVerifier = Arctic.generateCodeVerifier(); + const url = googleGsc.createAuthorizationURL(state, codeVerifier, [ + 'https://www.googleapis.com/auth/webmaster.readonly', + ]); + url.searchParams.set('access_type', 'offline'); + url.searchParams.set('prompt', 'consent'); + + return { + url: url.toString(), + state, + codeVerifier, + projectId: input.projectId, + }; + }), + + getSites: protectedProcedure + .input(z.object({ projectId: z.string() })) + .query(async ({ input, ctx }) => { + const access = await getProjectAccess({ + projectId: input.projectId, + userId: ctx.session.userId, + }); + if (!access) { + throw TRPCAccessError('You do not have access to this project'); + } + return listGscSites(input.projectId); + }), + + selectSite: protectedProcedure + .input(z.object({ projectId: z.string(), siteUrl: z.string() })) + .mutation(async ({ input, ctx }) => { + const access = await getProjectAccess({ + projectId: input.projectId, + userId: ctx.session.userId, + }); + if (!access) { + throw TRPCAccessError('You do not have access to this project'); + } + + const conn = await db.gscConnection.findUnique({ + where: { projectId: input.projectId }, + }); + if (!conn) { + throw TRPCNotFoundError('GSC connection not found'); + } + + await db.gscConnection.update({ + where: { projectId: input.projectId }, + data: { + siteUrl: input.siteUrl, + backfillStatus: 'pending', + }, + }); + + await gscQueue.add('gscProjectBackfill', { + type: 'gscProjectBackfill', + payload: { projectId: input.projectId }, + }); + + return { ok: true }; + }), + + disconnect: protectedProcedure + .input(z.object({ projectId: z.string() })) + .mutation(async ({ input, ctx }) => { + const access = await getProjectAccess({ + projectId: input.projectId, + userId: ctx.session.userId, + }); + if (!access) { + throw TRPCAccessError('You do not have access to this project'); + } + + await db.gscConnection.deleteMany({ + where: { projectId: input.projectId }, + }); + + return { ok: true }; + }), + + getOverview: protectedProcedure + .input( + z.object({ + projectId: z.string(), + startDate: z.string(), + endDate: z.string(), + }) + ) + .query(async ({ input, ctx }) => { + const access = await getProjectAccess({ + projectId: input.projectId, + userId: ctx.session.userId, + }); + if (!access) { + throw TRPCAccessError('You do not have access to this project'); + } + return getGscOverview(input.projectId, input.startDate, input.endDate); + }), + + getPages: protectedProcedure + .input( + z.object({ + projectId: z.string(), + startDate: z.string(), + endDate: z.string(), + limit: z.number().min(1).max(1000).optional().default(100), + }) + ) + .query(async ({ input, ctx }) => { + const access = await getProjectAccess({ + projectId: input.projectId, + userId: ctx.session.userId, + }); + if (!access) { + throw TRPCAccessError('You do not have access to this project'); + } + return getGscPages( + input.projectId, + input.startDate, + input.endDate, + input.limit + ); + }), + + getQueries: protectedProcedure + .input( + z.object({ + projectId: z.string(), + startDate: z.string(), + endDate: z.string(), + limit: z.number().min(1).max(1000).optional().default(100), + }) + ) + .query(async ({ input, ctx }) => { + const access = await getProjectAccess({ + projectId: input.projectId, + userId: ctx.session.userId, + }); + if (!access) { + throw TRPCAccessError('You do not have access to this project'); + } + return getGscQueries( + input.projectId, + input.startDate, + input.endDate, + input.limit + ); + }), +});