feat: added google search console
This commit is contained in:
committed by
GitHub
parent
70ca44f039
commit
271d189ed0
@@ -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
|
||||
|
||||
@@ -320,7 +320,7 @@ export const eventRouter = createTRPCRouter({
|
||||
z.object({
|
||||
projectId: z.string(),
|
||||
cursor: z.number().optional(),
|
||||
take: z.number().default(20),
|
||||
take: z.number().min(1).optional(),
|
||||
search: z.string().optional(),
|
||||
range: zRange,
|
||||
interval: zTimeInterval,
|
||||
@@ -335,6 +335,80 @@ export const eventRouter = createTRPCRouter({
|
||||
endDate,
|
||||
timezone,
|
||||
search: input.search,
|
||||
limit: input.take,
|
||||
});
|
||||
}),
|
||||
|
||||
pagesTimeseries: protectedProcedure
|
||||
.input(
|
||||
z.object({
|
||||
projectId: z.string(),
|
||||
range: zRange,
|
||||
interval: zTimeInterval,
|
||||
}),
|
||||
)
|
||||
.query(async ({ input }) => {
|
||||
const { timezone } = await getSettingsForProject(input.projectId);
|
||||
const { startDate, endDate } = getChartStartEndDate(input, timezone);
|
||||
return pagesService.getPageTimeseries({
|
||||
projectId: input.projectId,
|
||||
startDate,
|
||||
endDate,
|
||||
timezone,
|
||||
interval: input.interval,
|
||||
});
|
||||
}),
|
||||
|
||||
previousPages: protectedProcedure
|
||||
.input(
|
||||
z.object({
|
||||
projectId: z.string(),
|
||||
range: zRange,
|
||||
interval: zTimeInterval,
|
||||
}),
|
||||
)
|
||||
.query(async ({ input }) => {
|
||||
const { timezone } = await getSettingsForProject(input.projectId);
|
||||
const { startDate, endDate } = getChartStartEndDate(input, timezone);
|
||||
|
||||
const startMs = new Date(startDate).getTime();
|
||||
const endMs = new Date(endDate).getTime();
|
||||
const duration = endMs - startMs;
|
||||
|
||||
const prevEnd = new Date(startMs - 1);
|
||||
const prevStart = new Date(prevEnd.getTime() - duration);
|
||||
const fmt = (d: Date) =>
|
||||
d.toISOString().slice(0, 19).replace('T', ' ');
|
||||
|
||||
return pagesService.getTopPages({
|
||||
projectId: input.projectId,
|
||||
startDate: fmt(prevStart),
|
||||
endDate: fmt(prevEnd),
|
||||
timezone,
|
||||
});
|
||||
}),
|
||||
|
||||
pageTimeseries: protectedProcedure
|
||||
.input(
|
||||
z.object({
|
||||
projectId: z.string(),
|
||||
range: zRange,
|
||||
interval: zTimeInterval,
|
||||
origin: z.string(),
|
||||
path: z.string(),
|
||||
}),
|
||||
)
|
||||
.query(async ({ input }) => {
|
||||
const { timezone } = await getSettingsForProject(input.projectId);
|
||||
const { startDate, endDate } = getChartStartEndDate(input, timezone);
|
||||
return pagesService.getPageTimeseries({
|
||||
projectId: input.projectId,
|
||||
startDate,
|
||||
endDate,
|
||||
timezone,
|
||||
interval: input.interval,
|
||||
filterOrigin: input.origin,
|
||||
filterPath: input.path,
|
||||
});
|
||||
}),
|
||||
|
||||
|
||||
416
packages/trpc/src/routers/gsc.ts
Normal file
416
packages/trpc/src/routers/gsc.ts
Normal file
@@ -0,0 +1,416 @@
|
||||
import { Arctic, googleGsc } from '@openpanel/auth';
|
||||
import {
|
||||
chQuery,
|
||||
db,
|
||||
getChartStartEndDate,
|
||||
getGscCannibalization,
|
||||
getGscOverview,
|
||||
getGscPageDetails,
|
||||
getGscPages,
|
||||
getGscQueries,
|
||||
getGscQueryDetails,
|
||||
getSettingsForProject,
|
||||
listGscSites,
|
||||
TABLE_NAMES,
|
||||
} from '@openpanel/db';
|
||||
import { gscQueue } from '@openpanel/queue';
|
||||
import { zRange, zTimeInterval } from '@openpanel/validation';
|
||||
import { z } from 'zod';
|
||||
import { getProjectAccess } from '../access';
|
||||
import { TRPCAccessError, TRPCNotFoundError } from '../errors';
|
||||
import { createTRPCRouter, protectedProcedure } from '../trpc';
|
||||
|
||||
const zGscDateInput = z.object({
|
||||
projectId: z.string(),
|
||||
range: zRange,
|
||||
interval: zTimeInterval.optional().default('day'),
|
||||
startDate: z.string().nullish(),
|
||||
endDate: z.string().nullish(),
|
||||
});
|
||||
|
||||
async function resolveDates(
|
||||
projectId: string,
|
||||
input: { range: string; startDate?: string | null; endDate?: string | null }
|
||||
) {
|
||||
const { timezone } = await getSettingsForProject(projectId);
|
||||
const { startDate, endDate } = getChartStartEndDate(
|
||||
{
|
||||
range: input.range as any,
|
||||
startDate: input.startDate,
|
||||
endDate: input.endDate,
|
||||
},
|
||||
timezone
|
||||
);
|
||||
return {
|
||||
startDate: startDate.slice(0, 10),
|
||||
endDate: endDate.slice(0, 10),
|
||||
};
|
||||
}
|
||||
|
||||
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/webmasters.readonly',
|
||||
]);
|
||||
url.searchParams.set('access_type', 'offline');
|
||||
url.searchParams.set('prompt', 'consent');
|
||||
|
||||
const cookieOpts = { maxAge: 60 * 10, signed: true };
|
||||
ctx.setCookie('gsc_oauth_state', state, cookieOpts);
|
||||
ctx.setCookie('gsc_code_verifier', codeVerifier, cookieOpts);
|
||||
ctx.setCookie('gsc_project_id', input.projectId, cookieOpts);
|
||||
|
||||
return { url: url.toString() };
|
||||
}),
|
||||
|
||||
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(zGscDateInput)
|
||||
.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');
|
||||
}
|
||||
const { startDate, endDate } = await resolveDates(input.projectId, input);
|
||||
const interval = ['day', 'week', 'month'].includes(input.interval)
|
||||
? (input.interval as 'day' | 'week' | 'month')
|
||||
: 'day';
|
||||
return getGscOverview(input.projectId, startDate, endDate, interval);
|
||||
}),
|
||||
|
||||
getPages: protectedProcedure
|
||||
.input(
|
||||
zGscDateInput.extend({
|
||||
limit: z.number().min(1).max(10_000).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');
|
||||
}
|
||||
const { startDate, endDate } = await resolveDates(input.projectId, input);
|
||||
return getGscPages(input.projectId, startDate, endDate, input.limit);
|
||||
}),
|
||||
|
||||
getPageDetails: protectedProcedure
|
||||
.input(zGscDateInput.extend({ page: 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');
|
||||
}
|
||||
const { startDate, endDate } = await resolveDates(input.projectId, input);
|
||||
return getGscPageDetails(input.projectId, input.page, startDate, endDate);
|
||||
}),
|
||||
|
||||
getQueryDetails: protectedProcedure
|
||||
.input(zGscDateInput.extend({ query: 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');
|
||||
}
|
||||
const { startDate, endDate } = await resolveDates(input.projectId, input);
|
||||
return getGscQueryDetails(
|
||||
input.projectId,
|
||||
input.query,
|
||||
startDate,
|
||||
endDate
|
||||
);
|
||||
}),
|
||||
|
||||
getQueries: protectedProcedure
|
||||
.input(
|
||||
zGscDateInput.extend({
|
||||
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');
|
||||
}
|
||||
const { startDate, endDate } = await resolveDates(input.projectId, input);
|
||||
return getGscQueries(input.projectId, startDate, endDate, input.limit);
|
||||
}),
|
||||
|
||||
getSearchEngines: protectedProcedure
|
||||
.input(zGscDateInput)
|
||||
.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');
|
||||
}
|
||||
const { startDate, endDate } = await resolveDates(input.projectId, input);
|
||||
|
||||
const startMs = new Date(startDate).getTime();
|
||||
const duration = new Date(endDate).getTime() - startMs;
|
||||
const prevEnd = new Date(startMs - 1);
|
||||
const prevStart = new Date(prevEnd.getTime() - duration);
|
||||
const fmt = (d: Date) => d.toISOString().slice(0, 10);
|
||||
|
||||
const [engines, [prevResult]] = await Promise.all([
|
||||
chQuery<{ name: string; sessions: number }>(
|
||||
`SELECT
|
||||
referrer_name as name,
|
||||
count(*) as sessions
|
||||
FROM ${TABLE_NAMES.sessions}
|
||||
WHERE project_id = '${input.projectId}'
|
||||
AND referrer_type = 'search'
|
||||
AND created_at >= '${startDate}'
|
||||
AND created_at <= '${endDate}'
|
||||
GROUP BY referrer_name
|
||||
ORDER BY sessions DESC
|
||||
LIMIT 10`
|
||||
),
|
||||
chQuery<{ sessions: number }>(
|
||||
`SELECT count(*) as sessions
|
||||
FROM ${TABLE_NAMES.sessions}
|
||||
WHERE project_id = '${input.projectId}'
|
||||
AND referrer_type = 'search'
|
||||
AND created_at >= '${fmt(prevStart)}'
|
||||
AND created_at <= '${fmt(prevEnd)}'`
|
||||
),
|
||||
]);
|
||||
|
||||
return {
|
||||
engines,
|
||||
total: engines.reduce((s, e) => s + e.sessions, 0),
|
||||
previousTotal: prevResult?.sessions ?? 0,
|
||||
};
|
||||
}),
|
||||
|
||||
getAiEngines: protectedProcedure
|
||||
.input(zGscDateInput)
|
||||
.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');
|
||||
}
|
||||
const { startDate, endDate } = await resolveDates(input.projectId, input);
|
||||
|
||||
const startMs = new Date(startDate).getTime();
|
||||
const duration = new Date(endDate).getTime() - startMs;
|
||||
const prevEnd = new Date(startMs - 1);
|
||||
const prevStart = new Date(prevEnd.getTime() - duration);
|
||||
const fmt = (d: Date) => d.toISOString().slice(0, 10);
|
||||
|
||||
// Known AI referrer names — will switch to referrer_type = 'ai' once available
|
||||
const aiNames = [
|
||||
'chatgpt.com',
|
||||
'openai.com',
|
||||
'claude.ai',
|
||||
'anthropic.com',
|
||||
'perplexity.ai',
|
||||
'gemini.google.com',
|
||||
'copilot.com',
|
||||
'grok.com',
|
||||
'mistral.ai',
|
||||
'kagi.com',
|
||||
]
|
||||
.map((n) => `'${n}', '${n.replace(/\.[^.]+$/, '')}'`)
|
||||
.join(', ');
|
||||
|
||||
const where = (start: string, end: string) =>
|
||||
`project_id = '${input.projectId}'
|
||||
AND referrer_name IN (${aiNames})
|
||||
AND created_at >= '${start}'
|
||||
AND created_at <= '${end}'`;
|
||||
|
||||
const [engines, [prevResult]] = await Promise.all([
|
||||
chQuery<{ referrer_name: string; sessions: number }>(
|
||||
`SELECT lower(
|
||||
regexp_replace(referrer_name, '^https?://', '')
|
||||
) as referrer_name, count(*) as sessions
|
||||
FROM ${TABLE_NAMES.sessions}
|
||||
WHERE ${where(startDate, endDate)}
|
||||
GROUP BY referrer_name
|
||||
ORDER BY sessions DESC
|
||||
LIMIT 10`
|
||||
),
|
||||
chQuery<{ sessions: number }>(
|
||||
`SELECT count(*) as sessions
|
||||
FROM ${TABLE_NAMES.sessions}
|
||||
WHERE ${where(fmt(prevStart), fmt(prevEnd))}`
|
||||
),
|
||||
]);
|
||||
|
||||
return {
|
||||
engines: engines.map((e) => ({
|
||||
name: e.referrer_name,
|
||||
sessions: e.sessions,
|
||||
})),
|
||||
total: engines.reduce((s, e) => s + e.sessions, 0),
|
||||
previousTotal: prevResult?.sessions ?? 0,
|
||||
};
|
||||
}),
|
||||
|
||||
getPreviousOverview: protectedProcedure
|
||||
.input(zGscDateInput)
|
||||
.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');
|
||||
}
|
||||
const { startDate, endDate } = await resolveDates(input.projectId, input);
|
||||
|
||||
const startMs = new Date(startDate).getTime();
|
||||
const duration = new Date(endDate).getTime() - startMs;
|
||||
const prevEnd = new Date(startMs - 1);
|
||||
const prevStart = new Date(prevEnd.getTime() - duration);
|
||||
const fmt = (d: Date) => d.toISOString().slice(0, 10);
|
||||
|
||||
const interval = (['day', 'week', 'month'] as const).includes(
|
||||
input.interval as 'day' | 'week' | 'month'
|
||||
)
|
||||
? (input.interval as 'day' | 'week' | 'month')
|
||||
: 'day';
|
||||
|
||||
return getGscOverview(
|
||||
input.projectId,
|
||||
fmt(prevStart),
|
||||
fmt(prevEnd),
|
||||
interval
|
||||
);
|
||||
}),
|
||||
|
||||
getCannibalization: protectedProcedure
|
||||
.input(zGscDateInput)
|
||||
.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');
|
||||
}
|
||||
const { startDate, endDate } = await resolveDates(input.projectId, input);
|
||||
return getGscCannibalization(input.projectId, startDate, endDate);
|
||||
}),
|
||||
});
|
||||
@@ -37,6 +37,7 @@ export async function createContext({ req, res }: CreateFastifyContextOptions) {
|
||||
// @ts-ignore
|
||||
res.setCookie(key, value, {
|
||||
maxAge: options.maxAge,
|
||||
signed: options.signed,
|
||||
...COOKIE_OPTIONS,
|
||||
});
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user