Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 28 additions & 16 deletions apps/api/src/agents/tools/seo.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand All @@ -30,8 +50,7 @@ export const gscGetOverview = chatTool(
});
return gscGetOverviewCore({
projectId: context.projectId,
startDate: range.startDate,
endDate: range.endDate,
...gscRange(range),
interval,
});
},
Expand All @@ -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);
Expand All @@ -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);
Expand All @@ -110,8 +127,7 @@ export const gscGetQueryDetails = chatTool(
});
return gscGetQueryDetailsCore({
projectId: context.projectId,
startDate: range.startDate,
endDate: range.endDate,
...gscRange(range),
query,
});
},
Expand All @@ -136,8 +152,7 @@ export const gscGetPageDetails = chatTool(
});
return gscGetPageDetailsCore({
projectId: context.projectId,
startDate: range.startDate,
endDate: range.endDate,
...gscRange(range),
page,
});
},
Expand All @@ -162,8 +177,7 @@ export const gscGetQueryOpportunities = chatTool(
});
return gscGetQueryOpportunitiesCore({
projectId: context.projectId,
startDate: range.startDate,
endDate: range.endDate,
...gscRange(range),
minImpressions,
});
},
Expand All @@ -187,8 +201,7 @@ export const gscGetCannibalization = chatTool(
});
return gscGetCannibalizationCore({
projectId: context.projectId,
startDate: range.startDate,
endDate: range.endDate,
...gscRange(range),
});
},
);
Expand All @@ -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({
Expand Down
44 changes: 37 additions & 7 deletions apps/api/src/controllers/insights.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -92,10 +92,30 @@ function getProjectId(req: RequestWithProjectParam): Promise<string> {
});
}

/**
* 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
Expand Down Expand Up @@ -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<typeof zGscOverviewQuery> }>,
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<typeof zGscLimitQuery> }>,
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<typeof zGscPageDetailsQuery> }>,
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<typeof zGscLimitQuery> }>,
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<typeof zGscQueryDetailsQuery> }>,
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<typeof zGscOpportunitiesQuery> }>,
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<typeof zDateRange> }>,
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 }));
}