wip
This commit is contained in:
132
apps/api/src/controllers/gsc-oauth-callback.controller.ts
Normal file
132
apps/api/src/controllers/gsc-oauth-callback.controller.ts
Normal 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());
|
||||||
|
}
|
||||||
@@ -42,6 +42,7 @@ import liveRouter from './routes/live.router';
|
|||||||
import manageRouter from './routes/manage.router';
|
import manageRouter from './routes/manage.router';
|
||||||
import miscRouter from './routes/misc.router';
|
import miscRouter from './routes/misc.router';
|
||||||
import oauthRouter from './routes/oauth-callback.router';
|
import oauthRouter from './routes/oauth-callback.router';
|
||||||
|
import gscCallbackRouter from './routes/gsc-callback.router';
|
||||||
import profileRouter from './routes/profile.router';
|
import profileRouter from './routes/profile.router';
|
||||||
import trackRouter from './routes/track.router';
|
import trackRouter from './routes/track.router';
|
||||||
import webhookRouter from './routes/webhook.router';
|
import webhookRouter from './routes/webhook.router';
|
||||||
@@ -194,6 +195,7 @@ const startServer = async () => {
|
|||||||
instance.register(liveRouter, { prefix: '/live' });
|
instance.register(liveRouter, { prefix: '/live' });
|
||||||
instance.register(webhookRouter, { prefix: '/webhook' });
|
instance.register(webhookRouter, { prefix: '/webhook' });
|
||||||
instance.register(oauthRouter, { prefix: '/oauth' });
|
instance.register(oauthRouter, { prefix: '/oauth' });
|
||||||
|
instance.register(gscCallbackRouter, { prefix: '/gsc' });
|
||||||
instance.register(miscRouter, { prefix: '/misc' });
|
instance.register(miscRouter, { prefix: '/misc' });
|
||||||
instance.register(aiRouter, { prefix: '/ai' });
|
instance.register(aiRouter, { prefix: '/ai' });
|
||||||
});
|
});
|
||||||
|
|||||||
17
apps/api/src/routes/gsc-callback.router.ts
Normal file
17
apps/api/src/routes/gsc-callback.router.ts
Normal 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;
|
||||||
@@ -14,6 +14,7 @@ import {
|
|||||||
LayoutDashboardIcon,
|
LayoutDashboardIcon,
|
||||||
LayoutPanelTopIcon,
|
LayoutPanelTopIcon,
|
||||||
PlusIcon,
|
PlusIcon,
|
||||||
|
SearchIcon,
|
||||||
SparklesIcon,
|
SparklesIcon,
|
||||||
TrendingUpDownIcon,
|
TrendingUpDownIcon,
|
||||||
UndoDotIcon,
|
UndoDotIcon,
|
||||||
@@ -59,6 +60,7 @@ export default function SidebarProjectMenu({
|
|||||||
<SidebarLink href={'/events'} icon={GanttChartIcon} label="Events" />
|
<SidebarLink href={'/events'} icon={GanttChartIcon} label="Events" />
|
||||||
<SidebarLink href={'/sessions'} icon={UsersIcon} label="Sessions" />
|
<SidebarLink href={'/sessions'} icon={UsersIcon} label="Sessions" />
|
||||||
<SidebarLink href={'/profiles'} icon={UsersIcon} label="Profiles" />
|
<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">
|
<div className="mt-4 mb-2 font-medium text-muted-foreground text-sm">
|
||||||
Manage
|
Manage
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ import { Route as AppOrganizationIdProfileTabsRouteImport } from './routes/_app.
|
|||||||
import { Route as AppOrganizationIdMembersTabsRouteImport } from './routes/_app.$organizationId.members._tabs'
|
import { Route as AppOrganizationIdMembersTabsRouteImport } from './routes/_app.$organizationId.members._tabs'
|
||||||
import { Route as AppOrganizationIdIntegrationsTabsRouteImport } from './routes/_app.$organizationId.integrations._tabs'
|
import { Route as AppOrganizationIdIntegrationsTabsRouteImport } from './routes/_app.$organizationId.integrations._tabs'
|
||||||
import { Route as AppOrganizationIdProjectIdSessionsRouteImport } from './routes/_app.$organizationId.$projectId.sessions'
|
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 AppOrganizationIdProjectIdReportsRouteImport } from './routes/_app.$organizationId.$projectId.reports'
|
||||||
import { Route as AppOrganizationIdProjectIdReferencesRouteImport } from './routes/_app.$organizationId.$projectId.references'
|
import { Route as AppOrganizationIdProjectIdReferencesRouteImport } from './routes/_app.$organizationId.$projectId.references'
|
||||||
import { Route as AppOrganizationIdProjectIdRealtimeRouteImport } from './routes/_app.$organizationId.$projectId.realtime'
|
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 AppOrganizationIdProjectIdSettingsTabsWidgetsRouteImport } from './routes/_app.$organizationId.$projectId.settings._tabs.widgets'
|
||||||
import { Route as AppOrganizationIdProjectIdSettingsTabsTrackingRouteImport } from './routes/_app.$organizationId.$projectId.settings._tabs.tracking'
|
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 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 AppOrganizationIdProjectIdSettingsTabsEventsRouteImport } from './routes/_app.$organizationId.$projectId.settings._tabs.events'
|
||||||
import { Route as AppOrganizationIdProjectIdSettingsTabsDetailsRouteImport } from './routes/_app.$organizationId.$projectId.settings._tabs.details'
|
import { Route as AppOrganizationIdProjectIdSettingsTabsDetailsRouteImport } from './routes/_app.$organizationId.$projectId.settings._tabs.details'
|
||||||
import { Route as AppOrganizationIdProjectIdSettingsTabsClientsRouteImport } from './routes/_app.$organizationId.$projectId.settings._tabs.clients'
|
import { Route as AppOrganizationIdProjectIdSettingsTabsClientsRouteImport } from './routes/_app.$organizationId.$projectId.settings._tabs.clients'
|
||||||
@@ -312,6 +314,12 @@ const AppOrganizationIdProjectIdSessionsRoute =
|
|||||||
path: '/sessions',
|
path: '/sessions',
|
||||||
getParentRoute: () => AppOrganizationIdProjectIdRoute,
|
getParentRoute: () => AppOrganizationIdProjectIdRoute,
|
||||||
} as any)
|
} as any)
|
||||||
|
const AppOrganizationIdProjectIdSeoRoute =
|
||||||
|
AppOrganizationIdProjectIdSeoRouteImport.update({
|
||||||
|
id: '/seo',
|
||||||
|
path: '/seo',
|
||||||
|
getParentRoute: () => AppOrganizationIdProjectIdRoute,
|
||||||
|
} as any)
|
||||||
const AppOrganizationIdProjectIdReportsRoute =
|
const AppOrganizationIdProjectIdReportsRoute =
|
||||||
AppOrganizationIdProjectIdReportsRouteImport.update({
|
AppOrganizationIdProjectIdReportsRouteImport.update({
|
||||||
id: '/reports',
|
id: '/reports',
|
||||||
@@ -488,6 +496,12 @@ const AppOrganizationIdProjectIdSettingsTabsImportsRoute =
|
|||||||
path: '/imports',
|
path: '/imports',
|
||||||
getParentRoute: () => AppOrganizationIdProjectIdSettingsTabsRoute,
|
getParentRoute: () => AppOrganizationIdProjectIdSettingsTabsRoute,
|
||||||
} as any)
|
} as any)
|
||||||
|
const AppOrganizationIdProjectIdSettingsTabsGscRoute =
|
||||||
|
AppOrganizationIdProjectIdSettingsTabsGscRouteImport.update({
|
||||||
|
id: '/gsc',
|
||||||
|
path: '/gsc',
|
||||||
|
getParentRoute: () => AppOrganizationIdProjectIdSettingsTabsRoute,
|
||||||
|
} as any)
|
||||||
const AppOrganizationIdProjectIdSettingsTabsEventsRoute =
|
const AppOrganizationIdProjectIdSettingsTabsEventsRoute =
|
||||||
AppOrganizationIdProjectIdSettingsTabsEventsRouteImport.update({
|
AppOrganizationIdProjectIdSettingsTabsEventsRouteImport.update({
|
||||||
id: '/events',
|
id: '/events',
|
||||||
@@ -606,6 +620,7 @@ export interface FileRoutesByFullPath {
|
|||||||
'/$organizationId/$projectId/realtime': typeof AppOrganizationIdProjectIdRealtimeRoute
|
'/$organizationId/$projectId/realtime': typeof AppOrganizationIdProjectIdRealtimeRoute
|
||||||
'/$organizationId/$projectId/references': typeof AppOrganizationIdProjectIdReferencesRoute
|
'/$organizationId/$projectId/references': typeof AppOrganizationIdProjectIdReferencesRoute
|
||||||
'/$organizationId/$projectId/reports': typeof AppOrganizationIdProjectIdReportsRoute
|
'/$organizationId/$projectId/reports': typeof AppOrganizationIdProjectIdReportsRoute
|
||||||
|
'/$organizationId/$projectId/seo': typeof AppOrganizationIdProjectIdSeoRoute
|
||||||
'/$organizationId/$projectId/sessions': typeof AppOrganizationIdProjectIdSessionsRoute
|
'/$organizationId/$projectId/sessions': typeof AppOrganizationIdProjectIdSessionsRoute
|
||||||
'/$organizationId/integrations': typeof AppOrganizationIdIntegrationsTabsRouteWithChildren
|
'/$organizationId/integrations': typeof AppOrganizationIdIntegrationsTabsRouteWithChildren
|
||||||
'/$organizationId/members': typeof AppOrganizationIdMembersTabsRouteWithChildren
|
'/$organizationId/members': typeof AppOrganizationIdMembersTabsRouteWithChildren
|
||||||
@@ -640,6 +655,7 @@ export interface FileRoutesByFullPath {
|
|||||||
'/$organizationId/$projectId/settings/clients': typeof AppOrganizationIdProjectIdSettingsTabsClientsRoute
|
'/$organizationId/$projectId/settings/clients': typeof AppOrganizationIdProjectIdSettingsTabsClientsRoute
|
||||||
'/$organizationId/$projectId/settings/details': typeof AppOrganizationIdProjectIdSettingsTabsDetailsRoute
|
'/$organizationId/$projectId/settings/details': typeof AppOrganizationIdProjectIdSettingsTabsDetailsRoute
|
||||||
'/$organizationId/$projectId/settings/events': typeof AppOrganizationIdProjectIdSettingsTabsEventsRoute
|
'/$organizationId/$projectId/settings/events': typeof AppOrganizationIdProjectIdSettingsTabsEventsRoute
|
||||||
|
'/$organizationId/$projectId/settings/gsc': typeof AppOrganizationIdProjectIdSettingsTabsGscRoute
|
||||||
'/$organizationId/$projectId/settings/imports': typeof AppOrganizationIdProjectIdSettingsTabsImportsRoute
|
'/$organizationId/$projectId/settings/imports': typeof AppOrganizationIdProjectIdSettingsTabsImportsRoute
|
||||||
'/$organizationId/$projectId/settings/tracking': typeof AppOrganizationIdProjectIdSettingsTabsTrackingRoute
|
'/$organizationId/$projectId/settings/tracking': typeof AppOrganizationIdProjectIdSettingsTabsTrackingRoute
|
||||||
'/$organizationId/$projectId/settings/widgets': typeof AppOrganizationIdProjectIdSettingsTabsWidgetsRoute
|
'/$organizationId/$projectId/settings/widgets': typeof AppOrganizationIdProjectIdSettingsTabsWidgetsRoute
|
||||||
@@ -677,6 +693,7 @@ export interface FileRoutesByTo {
|
|||||||
'/$organizationId/$projectId/realtime': typeof AppOrganizationIdProjectIdRealtimeRoute
|
'/$organizationId/$projectId/realtime': typeof AppOrganizationIdProjectIdRealtimeRoute
|
||||||
'/$organizationId/$projectId/references': typeof AppOrganizationIdProjectIdReferencesRoute
|
'/$organizationId/$projectId/references': typeof AppOrganizationIdProjectIdReferencesRoute
|
||||||
'/$organizationId/$projectId/reports': typeof AppOrganizationIdProjectIdReportsRoute
|
'/$organizationId/$projectId/reports': typeof AppOrganizationIdProjectIdReportsRoute
|
||||||
|
'/$organizationId/$projectId/seo': typeof AppOrganizationIdProjectIdSeoRoute
|
||||||
'/$organizationId/$projectId/sessions': typeof AppOrganizationIdProjectIdSessionsRoute
|
'/$organizationId/$projectId/sessions': typeof AppOrganizationIdProjectIdSessionsRoute
|
||||||
'/$organizationId/integrations': typeof AppOrganizationIdIntegrationsTabsIndexRoute
|
'/$organizationId/integrations': typeof AppOrganizationIdIntegrationsTabsIndexRoute
|
||||||
'/$organizationId/members': typeof AppOrganizationIdMembersTabsIndexRoute
|
'/$organizationId/members': typeof AppOrganizationIdMembersTabsIndexRoute
|
||||||
@@ -708,6 +725,7 @@ export interface FileRoutesByTo {
|
|||||||
'/$organizationId/$projectId/settings/clients': typeof AppOrganizationIdProjectIdSettingsTabsClientsRoute
|
'/$organizationId/$projectId/settings/clients': typeof AppOrganizationIdProjectIdSettingsTabsClientsRoute
|
||||||
'/$organizationId/$projectId/settings/details': typeof AppOrganizationIdProjectIdSettingsTabsDetailsRoute
|
'/$organizationId/$projectId/settings/details': typeof AppOrganizationIdProjectIdSettingsTabsDetailsRoute
|
||||||
'/$organizationId/$projectId/settings/events': typeof AppOrganizationIdProjectIdSettingsTabsEventsRoute
|
'/$organizationId/$projectId/settings/events': typeof AppOrganizationIdProjectIdSettingsTabsEventsRoute
|
||||||
|
'/$organizationId/$projectId/settings/gsc': typeof AppOrganizationIdProjectIdSettingsTabsGscRoute
|
||||||
'/$organizationId/$projectId/settings/imports': typeof AppOrganizationIdProjectIdSettingsTabsImportsRoute
|
'/$organizationId/$projectId/settings/imports': typeof AppOrganizationIdProjectIdSettingsTabsImportsRoute
|
||||||
'/$organizationId/$projectId/settings/tracking': typeof AppOrganizationIdProjectIdSettingsTabsTrackingRoute
|
'/$organizationId/$projectId/settings/tracking': typeof AppOrganizationIdProjectIdSettingsTabsTrackingRoute
|
||||||
'/$organizationId/$projectId/settings/widgets': typeof AppOrganizationIdProjectIdSettingsTabsWidgetsRoute
|
'/$organizationId/$projectId/settings/widgets': typeof AppOrganizationIdProjectIdSettingsTabsWidgetsRoute
|
||||||
@@ -747,6 +765,7 @@ export interface FileRoutesById {
|
|||||||
'/_app/$organizationId/$projectId/realtime': typeof AppOrganizationIdProjectIdRealtimeRoute
|
'/_app/$organizationId/$projectId/realtime': typeof AppOrganizationIdProjectIdRealtimeRoute
|
||||||
'/_app/$organizationId/$projectId/references': typeof AppOrganizationIdProjectIdReferencesRoute
|
'/_app/$organizationId/$projectId/references': typeof AppOrganizationIdProjectIdReferencesRoute
|
||||||
'/_app/$organizationId/$projectId/reports': typeof AppOrganizationIdProjectIdReportsRoute
|
'/_app/$organizationId/$projectId/reports': typeof AppOrganizationIdProjectIdReportsRoute
|
||||||
|
'/_app/$organizationId/$projectId/seo': typeof AppOrganizationIdProjectIdSeoRoute
|
||||||
'/_app/$organizationId/$projectId/sessions': typeof AppOrganizationIdProjectIdSessionsRoute
|
'/_app/$organizationId/$projectId/sessions': typeof AppOrganizationIdProjectIdSessionsRoute
|
||||||
'/_app/$organizationId/integrations': typeof AppOrganizationIdIntegrationsRouteWithChildren
|
'/_app/$organizationId/integrations': typeof AppOrganizationIdIntegrationsRouteWithChildren
|
||||||
'/_app/$organizationId/integrations/_tabs': typeof AppOrganizationIdIntegrationsTabsRouteWithChildren
|
'/_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/clients': typeof AppOrganizationIdProjectIdSettingsTabsClientsRoute
|
||||||
'/_app/$organizationId/$projectId/settings/_tabs/details': typeof AppOrganizationIdProjectIdSettingsTabsDetailsRoute
|
'/_app/$organizationId/$projectId/settings/_tabs/details': typeof AppOrganizationIdProjectIdSettingsTabsDetailsRoute
|
||||||
'/_app/$organizationId/$projectId/settings/_tabs/events': typeof AppOrganizationIdProjectIdSettingsTabsEventsRoute
|
'/_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/imports': typeof AppOrganizationIdProjectIdSettingsTabsImportsRoute
|
||||||
'/_app/$organizationId/$projectId/settings/_tabs/tracking': typeof AppOrganizationIdProjectIdSettingsTabsTrackingRoute
|
'/_app/$organizationId/$projectId/settings/_tabs/tracking': typeof AppOrganizationIdProjectIdSettingsTabsTrackingRoute
|
||||||
'/_app/$organizationId/$projectId/settings/_tabs/widgets': typeof AppOrganizationIdProjectIdSettingsTabsWidgetsRoute
|
'/_app/$organizationId/$projectId/settings/_tabs/widgets': typeof AppOrganizationIdProjectIdSettingsTabsWidgetsRoute
|
||||||
@@ -830,6 +850,7 @@ export interface FileRouteTypes {
|
|||||||
| '/$organizationId/$projectId/realtime'
|
| '/$organizationId/$projectId/realtime'
|
||||||
| '/$organizationId/$projectId/references'
|
| '/$organizationId/$projectId/references'
|
||||||
| '/$organizationId/$projectId/reports'
|
| '/$organizationId/$projectId/reports'
|
||||||
|
| '/$organizationId/$projectId/seo'
|
||||||
| '/$organizationId/$projectId/sessions'
|
| '/$organizationId/$projectId/sessions'
|
||||||
| '/$organizationId/integrations'
|
| '/$organizationId/integrations'
|
||||||
| '/$organizationId/members'
|
| '/$organizationId/members'
|
||||||
@@ -864,6 +885,7 @@ export interface FileRouteTypes {
|
|||||||
| '/$organizationId/$projectId/settings/clients'
|
| '/$organizationId/$projectId/settings/clients'
|
||||||
| '/$organizationId/$projectId/settings/details'
|
| '/$organizationId/$projectId/settings/details'
|
||||||
| '/$organizationId/$projectId/settings/events'
|
| '/$organizationId/$projectId/settings/events'
|
||||||
|
| '/$organizationId/$projectId/settings/gsc'
|
||||||
| '/$organizationId/$projectId/settings/imports'
|
| '/$organizationId/$projectId/settings/imports'
|
||||||
| '/$organizationId/$projectId/settings/tracking'
|
| '/$organizationId/$projectId/settings/tracking'
|
||||||
| '/$organizationId/$projectId/settings/widgets'
|
| '/$organizationId/$projectId/settings/widgets'
|
||||||
@@ -901,6 +923,7 @@ export interface FileRouteTypes {
|
|||||||
| '/$organizationId/$projectId/realtime'
|
| '/$organizationId/$projectId/realtime'
|
||||||
| '/$organizationId/$projectId/references'
|
| '/$organizationId/$projectId/references'
|
||||||
| '/$organizationId/$projectId/reports'
|
| '/$organizationId/$projectId/reports'
|
||||||
|
| '/$organizationId/$projectId/seo'
|
||||||
| '/$organizationId/$projectId/sessions'
|
| '/$organizationId/$projectId/sessions'
|
||||||
| '/$organizationId/integrations'
|
| '/$organizationId/integrations'
|
||||||
| '/$organizationId/members'
|
| '/$organizationId/members'
|
||||||
@@ -932,6 +955,7 @@ export interface FileRouteTypes {
|
|||||||
| '/$organizationId/$projectId/settings/clients'
|
| '/$organizationId/$projectId/settings/clients'
|
||||||
| '/$organizationId/$projectId/settings/details'
|
| '/$organizationId/$projectId/settings/details'
|
||||||
| '/$organizationId/$projectId/settings/events'
|
| '/$organizationId/$projectId/settings/events'
|
||||||
|
| '/$organizationId/$projectId/settings/gsc'
|
||||||
| '/$organizationId/$projectId/settings/imports'
|
| '/$organizationId/$projectId/settings/imports'
|
||||||
| '/$organizationId/$projectId/settings/tracking'
|
| '/$organizationId/$projectId/settings/tracking'
|
||||||
| '/$organizationId/$projectId/settings/widgets'
|
| '/$organizationId/$projectId/settings/widgets'
|
||||||
@@ -970,6 +994,7 @@ export interface FileRouteTypes {
|
|||||||
| '/_app/$organizationId/$projectId/realtime'
|
| '/_app/$organizationId/$projectId/realtime'
|
||||||
| '/_app/$organizationId/$projectId/references'
|
| '/_app/$organizationId/$projectId/references'
|
||||||
| '/_app/$organizationId/$projectId/reports'
|
| '/_app/$organizationId/$projectId/reports'
|
||||||
|
| '/_app/$organizationId/$projectId/seo'
|
||||||
| '/_app/$organizationId/$projectId/sessions'
|
| '/_app/$organizationId/$projectId/sessions'
|
||||||
| '/_app/$organizationId/integrations'
|
| '/_app/$organizationId/integrations'
|
||||||
| '/_app/$organizationId/integrations/_tabs'
|
| '/_app/$organizationId/integrations/_tabs'
|
||||||
@@ -1012,6 +1037,7 @@ export interface FileRouteTypes {
|
|||||||
| '/_app/$organizationId/$projectId/settings/_tabs/clients'
|
| '/_app/$organizationId/$projectId/settings/_tabs/clients'
|
||||||
| '/_app/$organizationId/$projectId/settings/_tabs/details'
|
| '/_app/$organizationId/$projectId/settings/_tabs/details'
|
||||||
| '/_app/$organizationId/$projectId/settings/_tabs/events'
|
| '/_app/$organizationId/$projectId/settings/_tabs/events'
|
||||||
|
| '/_app/$organizationId/$projectId/settings/_tabs/gsc'
|
||||||
| '/_app/$organizationId/$projectId/settings/_tabs/imports'
|
| '/_app/$organizationId/$projectId/settings/_tabs/imports'
|
||||||
| '/_app/$organizationId/$projectId/settings/_tabs/tracking'
|
| '/_app/$organizationId/$projectId/settings/_tabs/tracking'
|
||||||
| '/_app/$organizationId/$projectId/settings/_tabs/widgets'
|
| '/_app/$organizationId/$projectId/settings/_tabs/widgets'
|
||||||
@@ -1310,6 +1336,13 @@ declare module '@tanstack/react-router' {
|
|||||||
preLoaderRoute: typeof AppOrganizationIdProjectIdSessionsRouteImport
|
preLoaderRoute: typeof AppOrganizationIdProjectIdSessionsRouteImport
|
||||||
parentRoute: typeof AppOrganizationIdProjectIdRoute
|
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': {
|
'/_app/$organizationId/$projectId/reports': {
|
||||||
id: '/_app/$organizationId/$projectId/reports'
|
id: '/_app/$organizationId/$projectId/reports'
|
||||||
path: '/reports'
|
path: '/reports'
|
||||||
@@ -1520,6 +1553,13 @@ declare module '@tanstack/react-router' {
|
|||||||
preLoaderRoute: typeof AppOrganizationIdProjectIdSettingsTabsImportsRouteImport
|
preLoaderRoute: typeof AppOrganizationIdProjectIdSettingsTabsImportsRouteImport
|
||||||
parentRoute: typeof AppOrganizationIdProjectIdSettingsTabsRoute
|
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': {
|
'/_app/$organizationId/$projectId/settings/_tabs/events': {
|
||||||
id: '/_app/$organizationId/$projectId/settings/_tabs/events'
|
id: '/_app/$organizationId/$projectId/settings/_tabs/events'
|
||||||
path: '/events'
|
path: '/events'
|
||||||
@@ -1785,6 +1825,7 @@ interface AppOrganizationIdProjectIdSettingsTabsRouteChildren {
|
|||||||
AppOrganizationIdProjectIdSettingsTabsClientsRoute: typeof AppOrganizationIdProjectIdSettingsTabsClientsRoute
|
AppOrganizationIdProjectIdSettingsTabsClientsRoute: typeof AppOrganizationIdProjectIdSettingsTabsClientsRoute
|
||||||
AppOrganizationIdProjectIdSettingsTabsDetailsRoute: typeof AppOrganizationIdProjectIdSettingsTabsDetailsRoute
|
AppOrganizationIdProjectIdSettingsTabsDetailsRoute: typeof AppOrganizationIdProjectIdSettingsTabsDetailsRoute
|
||||||
AppOrganizationIdProjectIdSettingsTabsEventsRoute: typeof AppOrganizationIdProjectIdSettingsTabsEventsRoute
|
AppOrganizationIdProjectIdSettingsTabsEventsRoute: typeof AppOrganizationIdProjectIdSettingsTabsEventsRoute
|
||||||
|
AppOrganizationIdProjectIdSettingsTabsGscRoute: typeof AppOrganizationIdProjectIdSettingsTabsGscRoute
|
||||||
AppOrganizationIdProjectIdSettingsTabsImportsRoute: typeof AppOrganizationIdProjectIdSettingsTabsImportsRoute
|
AppOrganizationIdProjectIdSettingsTabsImportsRoute: typeof AppOrganizationIdProjectIdSettingsTabsImportsRoute
|
||||||
AppOrganizationIdProjectIdSettingsTabsTrackingRoute: typeof AppOrganizationIdProjectIdSettingsTabsTrackingRoute
|
AppOrganizationIdProjectIdSettingsTabsTrackingRoute: typeof AppOrganizationIdProjectIdSettingsTabsTrackingRoute
|
||||||
AppOrganizationIdProjectIdSettingsTabsWidgetsRoute: typeof AppOrganizationIdProjectIdSettingsTabsWidgetsRoute
|
AppOrganizationIdProjectIdSettingsTabsWidgetsRoute: typeof AppOrganizationIdProjectIdSettingsTabsWidgetsRoute
|
||||||
@@ -1799,6 +1840,8 @@ const AppOrganizationIdProjectIdSettingsTabsRouteChildren: AppOrganizationIdProj
|
|||||||
AppOrganizationIdProjectIdSettingsTabsDetailsRoute,
|
AppOrganizationIdProjectIdSettingsTabsDetailsRoute,
|
||||||
AppOrganizationIdProjectIdSettingsTabsEventsRoute:
|
AppOrganizationIdProjectIdSettingsTabsEventsRoute:
|
||||||
AppOrganizationIdProjectIdSettingsTabsEventsRoute,
|
AppOrganizationIdProjectIdSettingsTabsEventsRoute,
|
||||||
|
AppOrganizationIdProjectIdSettingsTabsGscRoute:
|
||||||
|
AppOrganizationIdProjectIdSettingsTabsGscRoute,
|
||||||
AppOrganizationIdProjectIdSettingsTabsImportsRoute:
|
AppOrganizationIdProjectIdSettingsTabsImportsRoute:
|
||||||
AppOrganizationIdProjectIdSettingsTabsImportsRoute,
|
AppOrganizationIdProjectIdSettingsTabsImportsRoute,
|
||||||
AppOrganizationIdProjectIdSettingsTabsTrackingRoute:
|
AppOrganizationIdProjectIdSettingsTabsTrackingRoute:
|
||||||
@@ -1837,6 +1880,7 @@ interface AppOrganizationIdProjectIdRouteChildren {
|
|||||||
AppOrganizationIdProjectIdRealtimeRoute: typeof AppOrganizationIdProjectIdRealtimeRoute
|
AppOrganizationIdProjectIdRealtimeRoute: typeof AppOrganizationIdProjectIdRealtimeRoute
|
||||||
AppOrganizationIdProjectIdReferencesRoute: typeof AppOrganizationIdProjectIdReferencesRoute
|
AppOrganizationIdProjectIdReferencesRoute: typeof AppOrganizationIdProjectIdReferencesRoute
|
||||||
AppOrganizationIdProjectIdReportsRoute: typeof AppOrganizationIdProjectIdReportsRoute
|
AppOrganizationIdProjectIdReportsRoute: typeof AppOrganizationIdProjectIdReportsRoute
|
||||||
|
AppOrganizationIdProjectIdSeoRoute: typeof AppOrganizationIdProjectIdSeoRoute
|
||||||
AppOrganizationIdProjectIdSessionsRoute: typeof AppOrganizationIdProjectIdSessionsRoute
|
AppOrganizationIdProjectIdSessionsRoute: typeof AppOrganizationIdProjectIdSessionsRoute
|
||||||
AppOrganizationIdProjectIdIndexRoute: typeof AppOrganizationIdProjectIdIndexRoute
|
AppOrganizationIdProjectIdIndexRoute: typeof AppOrganizationIdProjectIdIndexRoute
|
||||||
AppOrganizationIdProjectIdDashboardsDashboardIdRoute: typeof AppOrganizationIdProjectIdDashboardsDashboardIdRoute
|
AppOrganizationIdProjectIdDashboardsDashboardIdRoute: typeof AppOrganizationIdProjectIdDashboardsDashboardIdRoute
|
||||||
@@ -1862,6 +1906,7 @@ const AppOrganizationIdProjectIdRouteChildren: AppOrganizationIdProjectIdRouteCh
|
|||||||
AppOrganizationIdProjectIdReferencesRoute,
|
AppOrganizationIdProjectIdReferencesRoute,
|
||||||
AppOrganizationIdProjectIdReportsRoute:
|
AppOrganizationIdProjectIdReportsRoute:
|
||||||
AppOrganizationIdProjectIdReportsRoute,
|
AppOrganizationIdProjectIdReportsRoute,
|
||||||
|
AppOrganizationIdProjectIdSeoRoute: AppOrganizationIdProjectIdSeoRoute,
|
||||||
AppOrganizationIdProjectIdSessionsRoute:
|
AppOrganizationIdProjectIdSessionsRoute:
|
||||||
AppOrganizationIdProjectIdSessionsRoute,
|
AppOrganizationIdProjectIdSessionsRoute,
|
||||||
AppOrganizationIdProjectIdIndexRoute: AppOrganizationIdProjectIdIndexRoute,
|
AppOrganizationIdProjectIdIndexRoute: AppOrganizationIdProjectIdIndexRoute,
|
||||||
|
|||||||
289
apps/start/src/routes/_app.$organizationId.$projectId.seo.tsx
Normal file
289
apps/start/src/routes/_app.$organizationId.$projectId.seo.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -45,6 +45,7 @@ function ProjectDashboard() {
|
|||||||
{ id: 'tracking', label: 'Tracking script' },
|
{ id: 'tracking', label: 'Tracking script' },
|
||||||
{ id: 'widgets', label: 'Widgets' },
|
{ id: 'widgets', label: 'Widgets' },
|
||||||
{ id: 'imports', label: 'Imports' },
|
{ id: 'imports', label: 'Imports' },
|
||||||
|
{ id: 'gsc', label: 'Google Search' },
|
||||||
];
|
];
|
||||||
|
|
||||||
const handleTabChange = (tabId: string) => {
|
const handleTabChange = (tabId: string) => {
|
||||||
|
|||||||
@@ -78,6 +78,11 @@ export async function bootCron() {
|
|||||||
type: 'onboarding',
|
type: 'onboarding',
|
||||||
pattern: '0 * * * *',
|
pattern: '0 * * * *',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: 'gscSync',
|
||||||
|
type: 'gscSync',
|
||||||
|
pattern: '0 3 * * *',
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
if (process.env.SELF_HOSTED && process.env.NODE_ENV === 'production') {
|
if (process.env.SELF_HOSTED && process.env.NODE_ENV === 'production') {
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
type EventsQueuePayloadIncomingEvent,
|
type EventsQueuePayloadIncomingEvent,
|
||||||
cronQueue,
|
cronQueue,
|
||||||
eventsGroupQueues,
|
eventsGroupQueues,
|
||||||
|
gscQueue,
|
||||||
importQueue,
|
importQueue,
|
||||||
insightsQueue,
|
insightsQueue,
|
||||||
miscQueue,
|
miscQueue,
|
||||||
@@ -20,6 +21,7 @@ import { setTimeout as sleep } from 'node:timers/promises';
|
|||||||
import { Worker as GroupWorker } from 'groupmq';
|
import { Worker as GroupWorker } from 'groupmq';
|
||||||
|
|
||||||
import { cronJob } from './jobs/cron';
|
import { cronJob } from './jobs/cron';
|
||||||
|
import { gscJob } from './jobs/gsc';
|
||||||
import { incomingEvent } from './jobs/events.incoming-event';
|
import { incomingEvent } from './jobs/events.incoming-event';
|
||||||
import { importJob } from './jobs/import';
|
import { importJob } from './jobs/import';
|
||||||
import { insightsProjectJob } from './jobs/insights';
|
import { insightsProjectJob } from './jobs/insights';
|
||||||
@@ -59,6 +61,7 @@ function getEnabledQueues(): QueueName[] {
|
|||||||
'misc',
|
'misc',
|
||||||
'import',
|
'import',
|
||||||
'insights',
|
'insights',
|
||||||
|
'gsc',
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -208,6 +211,17 @@ export async function bootWorkers() {
|
|||||||
logger.info('Started worker for insights', { concurrency });
|
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) {
|
if (workers.length === 0) {
|
||||||
logger.warn(
|
logger.warn(
|
||||||
'No workers started. Check ENABLED_QUEUES environment variable.',
|
'No workers started. Check ENABLED_QUEUES environment variable.',
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { eventBuffer, profileBackfillBuffer, profileBuffer, replayBuffer, sessio
|
|||||||
import type { CronQueuePayload } from '@openpanel/queue';
|
import type { CronQueuePayload } from '@openpanel/queue';
|
||||||
|
|
||||||
import { jobdeleteProjects } from './cron.delete-projects';
|
import { jobdeleteProjects } from './cron.delete-projects';
|
||||||
|
import { gscSyncAllJob } from './gsc';
|
||||||
import { onboardingJob } from './cron.onboarding';
|
import { onboardingJob } from './cron.onboarding';
|
||||||
import { ping } from './cron.ping';
|
import { ping } from './cron.ping';
|
||||||
import { salt } from './cron.salt';
|
import { salt } from './cron.salt';
|
||||||
@@ -41,5 +42,8 @@ export async function cronJob(job: Job<CronQueuePayload>) {
|
|||||||
case 'onboarding': {
|
case 'onboarding': {
|
||||||
return await onboardingJob(job);
|
return await onboardingJob(job);
|
||||||
}
|
}
|
||||||
|
case 'gscSync': {
|
||||||
|
return await gscSyncAllJob();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
142
apps/worker/src/jobs/gsc.ts
Normal file
142
apps/worker/src/jobs/gsc.ts
Normal 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 },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -16,3 +16,9 @@ export const google = new Arctic.Google(
|
|||||||
process.env.GOOGLE_CLIENT_SECRET ?? '',
|
process.env.GOOGLE_CLIENT_SECRET ?? '',
|
||||||
process.env.GOOGLE_REDIRECT_URI ?? '',
|
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 ?? '',
|
||||||
|
);
|
||||||
|
|||||||
85
packages/db/code-migrations/12-add-gsc.ts
Normal file
85
packages/db/code-migrations/12-add-gsc.ts
Normal file
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -31,3 +31,4 @@ export * from './src/services/overview.service';
|
|||||||
export * from './src/services/pages.service';
|
export * from './src/services/pages.service';
|
||||||
export * from './src/services/insights';
|
export * from './src/services/insights';
|
||||||
export * from './src/session-context';
|
export * from './src/session-context';
|
||||||
|
export * from './src/gsc';
|
||||||
|
|||||||
@@ -203,6 +203,7 @@ model Project {
|
|||||||
notificationRules NotificationRule[]
|
notificationRules NotificationRule[]
|
||||||
notifications Notification[]
|
notifications Notification[]
|
||||||
imports Import[]
|
imports Import[]
|
||||||
|
gscConnection GscConnection?
|
||||||
|
|
||||||
// When deleteAt > now(), the project will be deleted
|
// When deleteAt > now(), the project will be deleted
|
||||||
deleteAt DateTime?
|
deleteAt DateTime?
|
||||||
@@ -612,6 +613,24 @@ model InsightEvent {
|
|||||||
@@map("insight_events")
|
@@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 {
|
model EmailUnsubscribe {
|
||||||
id String @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid
|
id String @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid
|
||||||
email String
|
email String
|
||||||
|
|||||||
@@ -58,6 +58,9 @@ export const TABLE_NAMES = {
|
|||||||
sessions: 'sessions',
|
sessions: 'sessions',
|
||||||
events_imports: 'events_imports',
|
events_imports: 'events_imports',
|
||||||
session_replay_chunks: 'session_replay_chunks',
|
session_replay_chunks: 'session_replay_chunks',
|
||||||
|
gsc_daily: 'gsc_daily',
|
||||||
|
gsc_pages_daily: 'gsc_pages_daily',
|
||||||
|
gsc_queries_daily: 'gsc_queries_daily',
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
341
packages/db/src/gsc.ts
Normal file
341
packages/db/src/gsc.ts
Normal file
@@ -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<string> {
|
||||||
|
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<GscSite[]> {
|
||||||
|
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<GscApiRow[]> {
|
||||||
|
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<void> {
|
||||||
|
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();
|
||||||
|
}
|
||||||
@@ -126,6 +126,10 @@ export type CronQueuePayloadFlushReplay = {
|
|||||||
type: 'flushReplay';
|
type: 'flushReplay';
|
||||||
payload: undefined;
|
payload: undefined;
|
||||||
};
|
};
|
||||||
|
export type CronQueuePayloadGscSync = {
|
||||||
|
type: 'gscSync';
|
||||||
|
payload: undefined;
|
||||||
|
};
|
||||||
export type CronQueuePayload =
|
export type CronQueuePayload =
|
||||||
| CronQueuePayloadSalt
|
| CronQueuePayloadSalt
|
||||||
| CronQueuePayloadFlushEvents
|
| CronQueuePayloadFlushEvents
|
||||||
@@ -136,7 +140,8 @@ export type CronQueuePayload =
|
|||||||
| CronQueuePayloadPing
|
| CronQueuePayloadPing
|
||||||
| CronQueuePayloadProject
|
| CronQueuePayloadProject
|
||||||
| CronQueuePayloadInsightsDaily
|
| CronQueuePayloadInsightsDaily
|
||||||
| CronQueuePayloadOnboarding;
|
| CronQueuePayloadOnboarding
|
||||||
|
| CronQueuePayloadGscSync;
|
||||||
|
|
||||||
export type MiscQueuePayloadTrialEndingSoon = {
|
export type MiscQueuePayloadTrialEndingSoon = {
|
||||||
type: 'trialEndingSoon';
|
type: 'trialEndingSoon';
|
||||||
@@ -268,3 +273,21 @@ export const insightsQueue = new Queue<InsightsQueuePayloadProject>(
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
|
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<GscQueuePayload>(getQueueName('gsc'), {
|
||||||
|
connection: getRedisQueue(),
|
||||||
|
defaultJobOptions: {
|
||||||
|
removeOnComplete: 50,
|
||||||
|
removeOnFail: 100,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { authRouter } from './routers/auth';
|
import { authRouter } from './routers/auth';
|
||||||
|
import { gscRouter } from './routers/gsc';
|
||||||
import { chartRouter } from './routers/chart';
|
import { chartRouter } from './routers/chart';
|
||||||
import { chatRouter } from './routers/chat';
|
import { chatRouter } from './routers/chat';
|
||||||
import { clientRouter } from './routers/client';
|
import { clientRouter } from './routers/client';
|
||||||
@@ -53,6 +54,7 @@ export const appRouter = createTRPCRouter({
|
|||||||
insight: insightRouter,
|
insight: insightRouter,
|
||||||
widget: widgetRouter,
|
widget: widgetRouter,
|
||||||
email: emailRouter,
|
email: emailRouter,
|
||||||
|
gsc: gscRouter,
|
||||||
});
|
});
|
||||||
|
|
||||||
// export type definition of API
|
// export type definition of API
|
||||||
|
|||||||
201
packages/trpc/src/routers/gsc.ts
Normal file
201
packages/trpc/src/routers/gsc.ts
Normal file
@@ -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
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user