From f6cfdcd3699aca0e75a01a05b180caf33293c786 Mon Sep 17 00:00:00 2001 From: Radit Date: Sun, 6 Sep 2026 13:15:47 +0200 Subject: [PATCH 1/3] fix(api): use date-only bounds for Search Console queries MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The GSC ClickHouse table stores `date` as a `Date` and Google's searchAnalytics API takes `YYYY-MM-DD`, but `resolveDates` returns a full datetime — correct for the event and session tables, wrong for both GSC paths. Every /insights/:projectId/gsc/* route fails for any named `range`: Cannot convert string '2026-08-07 00:00:00' to type Date: while executing function greaterOrEquals on arguments __table1.date Date, ... Passing explicit date-only startDate/endDate already works, which is why the dashboard is unaffected — it reaches GSC over tRPC and does not go through this resolver. --- .../src/controllers/insights.controller.ts | 29 ++++++++++++++----- 1 file changed, 22 insertions(+), 7 deletions(-) diff --git a/apps/api/src/controllers/insights.controller.ts b/apps/api/src/controllers/insights.controller.ts index 7f7daed16..67379f861 100644 --- a/apps/api/src/controllers/insights.controller.ts +++ b/apps/api/src/controllers/insights.controller.ts @@ -92,6 +92,21 @@ 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; } @@ -664,7 +679,7 @@ export async function gscOverview( 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 })); } @@ -675,7 +690,7 @@ export async function gscPages( 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 })); } @@ -686,7 +701,7 @@ export async function gscPageDetails( 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 })); } @@ -695,7 +710,7 @@ export async function gscQueries( 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 })); } @@ -706,7 +721,7 @@ export async function gscQueryDetails( 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 })); } @@ -717,7 +732,7 @@ export async function gscQueryOpportunities( 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 })); } @@ -726,6 +741,6 @@ export async function gscCannibalization( 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 })); } From b1912ec32a0e461607f2c1c97b12267c6add8fa6 Mon Sep 17 00:00:00 2001 From: Radit Date: Sat, 12 Sep 2026 19:22:50 +0200 Subject: [PATCH 2/3] docs(api): document the Search Console handlers this change touches MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The pre-merge docstring check scopes coverage to functions the diff touches. Changing one line inside each handler counts the whole function, so the seven gsc* handlers were analysed undocumented. Also documents resolveDates, since the distinction it now carries — full datetime bounds for the DateTime-typed event and session tables, versus date-only for Search Console — is the point of this change. --- apps/api/src/controllers/insights.controller.ts | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/apps/api/src/controllers/insights.controller.ts b/apps/api/src/controllers/insights.controller.ts index 67379f861..057ba4527 100644 --- a/apps/api/src/controllers/insights.controller.ts +++ b/apps/api/src/controllers/insights.controller.ts @@ -111,6 +111,11 @@ 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 @@ -674,6 +679,7 @@ 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 @@ -685,6 +691,7 @@ export async function gscOverview( 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 @@ -696,6 +703,7 @@ export async function gscPages( 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 @@ -705,6 +713,7 @@ export async function gscPageDetails( 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 @@ -716,6 +725,7 @@ export async function gscQueries( 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 @@ -727,6 +737,10 @@ export async function gscQueryDetails( 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 @@ -736,6 +750,7 @@ export async function gscQueryOpportunities( 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 From c5b896d79e91b348cb529f2398199fb14066f15c Mon Sep 17 00:00:00 2001 From: Radit Date: Sat, 12 Sep 2026 19:28:47 +0200 Subject: [PATCH 3/3] fix(api): use date-only bounds for the Search Console agent tools too MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The eight gsc*Core calls in the SEO chat tools take their range from resolveDateRange, which returns a full datetime — the same mismatch the REST handlers had. Every SEO tool in the assistant fails the same way. Normalised per call site rather than for the whole range, because correlate_seo_with_traffic feeds one range to both gscGetTopPagesCore and the OpenPanel getTopPagesCore, and the OpenPanel tables are DateTime-typed. That call keeps the datetime. --- apps/api/src/agents/tools/seo.ts | 44 ++++++++++++++++++++------------ 1 file changed, 28 insertions(+), 16 deletions(-) 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({