wip
This commit is contained in:
@@ -338,6 +338,79 @@ export const eventRouter = createTRPCRouter({
|
||||
});
|
||||
}),
|
||||
|
||||
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,
|
||||
});
|
||||
}),
|
||||
|
||||
origin: protectedProcedure
|
||||
.input(
|
||||
z.object({
|
||||
|
||||
@@ -1,17 +1,50 @@
|
||||
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'),
|
||||
});
|
||||
|
||||
async function resolveDates(
|
||||
projectId: string,
|
||||
input: { range: string; startDate?: string; endDate?: string }
|
||||
) {
|
||||
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() }))
|
||||
@@ -52,17 +85,17 @@ export const gscRouter = createTRPCRouter({
|
||||
const state = Arctic.generateState();
|
||||
const codeVerifier = Arctic.generateCodeVerifier();
|
||||
const url = googleGsc.createAuthorizationURL(state, codeVerifier, [
|
||||
'https://www.googleapis.com/auth/webmaster.readonly',
|
||||
'https://www.googleapis.com/auth/webmasters.readonly',
|
||||
]);
|
||||
url.searchParams.set('access_type', 'offline');
|
||||
url.searchParams.set('prompt', 'consent');
|
||||
|
||||
return {
|
||||
url: url.toString(),
|
||||
state,
|
||||
codeVerifier,
|
||||
projectId: input.projectId,
|
||||
};
|
||||
const cookieOpts = { maxAge: 60 * 10 };
|
||||
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
|
||||
@@ -131,13 +164,7 @@ export const gscRouter = createTRPCRouter({
|
||||
}),
|
||||
|
||||
getOverview: protectedProcedure
|
||||
.input(
|
||||
z.object({
|
||||
projectId: z.string(),
|
||||
startDate: z.string(),
|
||||
endDate: z.string(),
|
||||
})
|
||||
)
|
||||
.input(zGscDateInput)
|
||||
.query(async ({ input, ctx }) => {
|
||||
const access = await getProjectAccess({
|
||||
projectId: input.projectId,
|
||||
@@ -146,15 +173,16 @@ export const gscRouter = createTRPCRouter({
|
||||
if (!access) {
|
||||
throw TRPCAccessError('You do not have access to this project');
|
||||
}
|
||||
return getGscOverview(input.projectId, input.startDate, input.endDate);
|
||||
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(
|
||||
z.object({
|
||||
projectId: z.string(),
|
||||
startDate: z.string(),
|
||||
endDate: z.string(),
|
||||
zGscDateInput.extend({
|
||||
limit: z.number().min(1).max(1000).optional().default(100),
|
||||
})
|
||||
)
|
||||
@@ -166,20 +194,46 @@ export const gscRouter = createTRPCRouter({
|
||||
if (!access) {
|
||||
throw TRPCAccessError('You do not have access to this project');
|
||||
}
|
||||
return getGscPages(
|
||||
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.startDate,
|
||||
input.endDate,
|
||||
input.limit
|
||||
input.query,
|
||||
startDate,
|
||||
endDate
|
||||
);
|
||||
}),
|
||||
|
||||
getQueries: protectedProcedure
|
||||
.input(
|
||||
z.object({
|
||||
projectId: z.string(),
|
||||
startDate: z.string(),
|
||||
endDate: z.string(),
|
||||
zGscDateInput.extend({
|
||||
limit: z.number().min(1).max(1000).optional().default(100),
|
||||
})
|
||||
)
|
||||
@@ -191,11 +245,172 @@ export const gscRouter = createTRPCRouter({
|
||||
if (!access) {
|
||||
throw TRPCAccessError('You do not have access to this project');
|
||||
}
|
||||
return getGscQueries(
|
||||
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,
|
||||
input.startDate,
|
||||
input.endDate,
|
||||
input.limit
|
||||
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);
|
||||
// Clear stale cache so hash-stripping fix applies immediately
|
||||
await getGscCannibalization.clear(input.projectId, startDate, endDate);
|
||||
return getGscCannibalization(input.projectId, startDate, endDate);
|
||||
}),
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user