diff --git a/apps/api/src/agents/tools/seo.ts b/apps/api/src/agents/tools/seo.ts index 05beff9fa..8311547d7 100644 --- a/apps/api/src/agents/tools/seo.ts +++ b/apps/api/src/agents/tools/seo.ts @@ -11,6 +11,26 @@ import { } from '@openpanel/db'; import { chatTool, resolveDateRange, truncateRows } from './helpers'; +/** + * Search Console stores one row per day: its ClickHouse `date` column is a `Date`, + * and Google's searchAnalytics API takes `YYYY-MM-DD`. `resolveDateRange` returns a + * full datetime, which is correct for the event and session tables and breaks every + * GSC query. Mirrors `resolveGscDates` in insights.controller.ts. + * + * Applied per call site rather than to the whole range: `correlate_seo_with_traffic` + * feeds the same range to a GSC core and an OpenPanel core, and the latter needs the + * datetime. + */ +function gscRange(range: { startDate: string; endDate: string }): { + startDate: string; + endDate: string; +} { + return { + startDate: range.startDate.slice(0, 10), + endDate: range.endDate.slice(0, 10), + }; +} + export const gscGetOverview = chatTool( { name: 'gsc_get_overview', @@ -30,8 +50,7 @@ export const gscGetOverview = chatTool( }); return gscGetOverviewCore({ projectId: context.projectId, - startDate: range.startDate, - endDate: range.endDate, + ...gscRange(range), interval, }); }, @@ -56,8 +75,7 @@ export const gscGetTopQueries = chatTool( }); const rows = await gscGetTopQueriesCore({ projectId: context.projectId, - startDate: range.startDate, - endDate: range.endDate, + ...gscRange(range), limit: limit ?? 50, }); return truncateRows(rows, 100); @@ -83,8 +101,7 @@ export const gscGetTopPages = chatTool( }); const rows = await gscGetTopPagesCore({ projectId: context.projectId, - startDate: range.startDate, - endDate: range.endDate, + ...gscRange(range), limit: limit ?? 50, }); return truncateRows(rows, 100); @@ -110,8 +127,7 @@ export const gscGetQueryDetails = chatTool( }); return gscGetQueryDetailsCore({ projectId: context.projectId, - startDate: range.startDate, - endDate: range.endDate, + ...gscRange(range), query, }); }, @@ -136,8 +152,7 @@ export const gscGetPageDetails = chatTool( }); return gscGetPageDetailsCore({ projectId: context.projectId, - startDate: range.startDate, - endDate: range.endDate, + ...gscRange(range), page, }); }, @@ -162,8 +177,7 @@ export const gscGetQueryOpportunities = chatTool( }); return gscGetQueryOpportunitiesCore({ projectId: context.projectId, - startDate: range.startDate, - endDate: range.endDate, + ...gscRange(range), minImpressions, }); }, @@ -187,8 +201,7 @@ export const gscGetCannibalization = chatTool( }); return gscGetCannibalizationCore({ projectId: context.projectId, - startDate: range.startDate, - endDate: range.endDate, + ...gscRange(range), }); }, ); @@ -214,8 +227,7 @@ export const correlateSeoWithTraffic = chatTool( const [gscPages, opPages] = await Promise.all([ gscGetTopPagesCore({ projectId: context.projectId, - startDate: range.startDate, - endDate: range.endDate, + ...gscRange(range), limit: 200, }), getTopPagesCore({ diff --git a/apps/api/src/controllers/insights.controller.ts b/apps/api/src/controllers/insights.controller.ts index 7f7daed16..057ba4527 100644 --- a/apps/api/src/controllers/insights.controller.ts +++ b/apps/api/src/controllers/insights.controller.ts @@ -92,10 +92,30 @@ function getProjectId(req: RequestWithProjectParam): Promise { }); } +/** + * Search Console stores one row per day: the ClickHouse `date` column is a + * `Date`, and Google's searchAnalytics API takes `YYYY-MM-DD`. `resolveDates` + * returns a full datetime, which is right for the event and session tables but + * breaks both GSC paths — ClickHouse rejects the comparison outright with + * `Cannot convert string '2026-08-07 00:00:00' to type Date`. + */ +async function resolveGscDates( + projectId: string, + data: DateRangeInput +): Promise<{ startDate: string; endDate: string }> { + const { startDate, endDate } = await resolveDates(projectId, data); + return { startDate: startDate.slice(0, 10), endDate: endDate.slice(0, 10) }; +} + function getOrgId(req: RequestWithProjectParam): string { return req.client!.organizationId; } +/** + * Resolve a date range to the full datetime bounds the event and session tables + * expect, whose ClickHouse columns are `DateTime`. Search Console stores one row + * per day and needs `resolveGscDates` instead. + */ async function resolveDates( projectId: string, data: DateRangeInput @@ -659,73 +679,83 @@ export const zGscOverviewQuery = zDateRange.extend({ interval: z.enum(['day', 'week', 'month']).default('day'), }); +/** Search performance over time: clicks, impressions, CTR, and average position. */ export async function gscOverview( req: FastifyRequest<{ Params: { projectId?: string }; Querystring: z.infer }>, reply: FastifyReply ) { const projectId = await getProjectId(req as RequestWithProjectParam); - const { startDate, endDate } = await resolveDates(projectId, req.query); + const { startDate, endDate } = await resolveGscDates(projectId, req.query); return reply.send(await gscGetOverviewCore({ projectId, startDate, endDate, interval: req.query.interval })); } export const zGscLimitQuery = zDateRange.extend({ limit: z.number().int().min(1).max(1000).default(100) }); +/** Top pages by clicks over the window. */ export async function gscPages( req: FastifyRequest<{ Params: { projectId?: string }; Querystring: z.infer }>, reply: FastifyReply ) { const projectId = await getProjectId(req as RequestWithProjectParam); - const { startDate, endDate } = await resolveDates(projectId, req.query); + const { startDate, endDate } = await resolveGscDates(projectId, req.query); return reply.send(await gscGetTopPagesCore({ projectId, startDate, endDate, limit: req.query.limit })); } export const zGscPageDetailsQuery = zDateRange.extend({ page: z.string().url() }); +/** One page's daily series, plus the queries that drove traffic to it. */ export async function gscPageDetails( req: FastifyRequest<{ Params: { projectId?: string }; Querystring: z.infer }>, reply: FastifyReply ) { const projectId = await getProjectId(req as RequestWithProjectParam); - const { startDate, endDate } = await resolveDates(projectId, req.query); + const { startDate, endDate } = await resolveGscDates(projectId, req.query); return reply.send(await gscGetPageDetailsCore({ projectId, startDate, endDate, page: req.query.page })); } +/** Top search queries by clicks over the window. */ export async function gscQueries( req: FastifyRequest<{ Params: { projectId?: string }; Querystring: z.infer }>, reply: FastifyReply ) { const projectId = await getProjectId(req as RequestWithProjectParam); - const { startDate, endDate } = await resolveDates(projectId, req.query); + const { startDate, endDate } = await resolveGscDates(projectId, req.query); return reply.send(await gscGetTopQueriesCore({ projectId, startDate, endDate, limit: req.query.limit })); } export const zGscQueryDetailsQuery = zDateRange.extend({ query: z.string() }); +/** One query's daily series, plus the pages that rank for it. */ export async function gscQueryDetails( req: FastifyRequest<{ Params: { projectId?: string }; Querystring: z.infer }>, reply: FastifyReply ) { const projectId = await getProjectId(req as RequestWithProjectParam); - const { startDate, endDate } = await resolveDates(projectId, req.query); + const { startDate, endDate } = await resolveGscDates(projectId, req.query); return reply.send(await gscGetQueryDetailsCore({ projectId, startDate, endDate, query: req.query.query })); } export const zGscOpportunitiesQuery = zDateRange.extend({ minImpressions: z.number().int().min(1).default(50) }); +/** + * Queries ranking 4-20 with enough impressions to be worth improving: already on or + * near the first page, so a position gain converts to clicks fastest. + */ export async function gscQueryOpportunities( req: FastifyRequest<{ Params: { projectId?: string }; Querystring: z.infer }>, reply: FastifyReply ) { const projectId = await getProjectId(req as RequestWithProjectParam); - const { startDate, endDate } = await resolveDates(projectId, req.query); + const { startDate, endDate } = await resolveGscDates(projectId, req.query); return reply.send(await gscGetQueryOpportunitiesCore({ projectId, startDate, endDate, minImpressions: req.query.minImpressions })); } +/** Queries where several of the project's own pages compete with each other. */ export async function gscCannibalization( req: FastifyRequest<{ Params: { projectId?: string }; Querystring: z.infer }>, reply: FastifyReply ) { const projectId = await getProjectId(req as RequestWithProjectParam); - const { startDate, endDate } = await resolveDates(projectId, req.query); + const { startDate, endDate } = await resolveGscDates(projectId, req.query); return reply.send(await gscGetCannibalizationCore({ projectId, startDate, endDate })); }