feat(api): add website-scoped analytics GET routes

This commit is contained in:
Mike Cao committed 2026-09-09 18:53:22 -07:00
1 parent 1d7874b7d9
commit f28fb0108a
15 files changed
+257 -4

No files matched your search

+1 -1
View File
@@ -2,7 +2,7 @@ import { getQueryFilters, parseRequest, setWebsiteDate } from '@/lib/request';
import { json, unauthorized } from '@/lib/response';
import { reportResultSchema } from '@/lib/schema';
import { canViewWebsiteSection } from '@/permissions';
import { type AttributionParameters, getAttribution } from '@/queries/sql/reports/getAttribution';
import { type AttributionParameters, getAttribution } from '@/queries/sql/attribution/getAttribution';
export async function POST(request: Request) {
const { auth, body, error } = await parseRequest(request, reportResultSchema);
@@ -0,0 +1,18 @@
import { attributionQuerySchema } from '@/lib/analytics-schema';
import { getQueryFilters, parseRequest } from '@/lib/request';
import { json, unauthorized } from '@/lib/response';
import { canViewWebsiteSection } from '@/permissions';
import { getAttribution, type AttributionParameters } from '@/queries/sql/attribution/getAttribution';
export async function GET(
request: Request,
{ params }: { params: Promise<{ websiteId: string }> },
) {
const { auth, query, error } = await parseRequest(request, attributionQuerySchema);
if (error) return error();
const { websiteId } = await params;
if (!(await canViewWebsiteSection(auth, websiteId, 'attribution'))) return unauthorized();
const filters = await getQueryFilters(query, websiteId);
const parameters = { ...query, ...filters } as AttributionParameters;
return json(await getAttribution(websiteId, parameters, filters));
}
@@ -0,0 +1,18 @@
import { breakdownQuerySchema } from '@/lib/analytics-schema';
import { getQueryFilters, parseRequest } from '@/lib/request';
import { json, unauthorized } from '@/lib/response';
import { canViewWebsiteSection } from '@/permissions';
import { getBreakdown, type BreakdownParameters } from '@/queries/sql/breakdown/getBreakdown';
export async function GET(
request: Request,
{ params }: { params: Promise<{ websiteId: string }> },
) {
const { auth, query, error } = await parseRequest(request, breakdownQuerySchema);
if (error) return error();
const { websiteId } = await params;
if (!(await canViewWebsiteSection(auth, websiteId, 'breakdown'))) return unauthorized();
const filters = await getQueryFilters(query, websiteId);
const parameters = { ...query, ...filters } as BreakdownParameters;
return json(await getBreakdown(websiteId, parameters, filters));
}
@@ -0,0 +1,18 @@
import { heatmapQuerySchema } from '@/lib/analytics-schema';
import { getQueryFilters, parseRequest } from '@/lib/request';
import { json, unauthorized } from '@/lib/response';
import { canViewAuthenticatedWebsite } from '@/permissions';
import { getHeatmap, type HeatmapParameters } from '@/queries/sql/heatmap/getHeatmap';
export async function GET(
request: Request,
{ params }: { params: Promise<{ websiteId: string }> },
) {
const { auth, query, error } = await parseRequest(request, heatmapQuerySchema);
if (error) return error();
const { websiteId } = await params;
if (!(await canViewAuthenticatedWebsite(auth, websiteId))) return unauthorized();
const filters = await getQueryFilters(query, websiteId);
const parameters = { ...query, ...filters } as HeatmapParameters;
return json(await getHeatmap(websiteId, parameters));
}
@@ -0,0 +1,18 @@
import { journeyQuerySchema } from '@/lib/analytics-schema';
import { getQueryFilters, parseRequest } from '@/lib/request';
import { json, unauthorized } from '@/lib/response';
import { canViewWebsiteSection } from '@/permissions';
import { getJourney, type JourneyParameters } from '@/queries/sql/journeys/getJourney';
export async function GET(
request: Request,
{ params }: { params: Promise<{ websiteId: string }> },
) {
const { auth, query, error } = await parseRequest(request, journeyQuerySchema);
if (error) return error();
const { websiteId } = await params;
if (!(await canViewWebsiteSection(auth, websiteId, 'journeys'))) return unauthorized();
const filters = await getQueryFilters(query, websiteId);
const parameters = { ...query, ...filters } as JourneyParameters;
return json(await getJourney(websiteId, parameters, filters));
}
@@ -0,0 +1,18 @@
import { retentionQuerySchema } from '@/lib/analytics-schema';
import { getQueryFilters, parseRequest } from '@/lib/request';
import { json, unauthorized } from '@/lib/response';
import { canViewWebsiteSection } from '@/permissions';
import { getRetention, type RetentionParameters } from '@/queries/sql/retention/getRetention';
export async function GET(
request: Request,
{ params }: { params: Promise<{ websiteId: string }> },
) {
const { auth, query, error } = await parseRequest(request, retentionQuerySchema);
if (error) return error();
const { websiteId } = await params;
if (!(await canViewWebsiteSection(auth, websiteId, 'retention'))) return unauthorized();
const filters = await getQueryFilters(query, websiteId);
const parameters = { ...query, ...filters } as RetentionParameters;
return json(await getRetention(websiteId, parameters, filters));
}
+72
View File
@@ -0,0 +1,72 @@
import { beforeEach, describe, expect, test, vi } from 'vitest';
const mocks = vi.hoisted(() => ({
allowed: true,
normalized: {
startDate: new Date(1500),
endDate: new Date(2000),
unit: 'day',
timezone: 'UTC',
path: '/pricing',
},
query: vi.fn(async (..._args: any[]) => [{ count: 7 }]),
}));
vi.mock('@/permissions', () => ({
canViewWebsiteSection: vi.fn(async () => mocks.allowed),
canViewAuthenticatedWebsite: vi.fn(async () => mocks.allowed),
}));
vi.mock('@/lib/request', () => ({
parseRequest: async (request: Request, schema: any) => {
const result = schema.safeParse(Object.fromEntries(new URL(request.url).searchParams));
return result.success
? { auth: {}, query: result.data }
: { error: () => new Response(null, { status: 400 }) };
},
getQueryFilters: vi.fn(async () => mocks.normalized),
}));
vi.mock('@/queries/sql/journeys/getJourney', () => ({ getJourney: mocks.query }));
vi.mock('@/queries/sql/retention/getRetention', () => ({ getRetention: mocks.query }));
vi.mock('@/queries/sql/breakdown/getBreakdown', () => ({ getBreakdown: mocks.query }));
vi.mock('@/queries/sql/attribution/getAttribution', () => ({ getAttribution: mocks.query }));
vi.mock('@/queries/sql/heatmap/getHeatmap', () => ({ getHeatmap: mocks.query }));
import { GET as journeys } from '@/app/api/websites/[websiteId]/journeys/route';
import { GET as retention } from '@/app/api/websites/[websiteId]/retention/route';
import { GET as breakdown } from '@/app/api/websites/[websiteId]/breakdown/route';
import { GET as attribution } from '@/app/api/websites/[websiteId]/attribution/route';
import { GET as heatmaps } from '@/app/api/websites/[websiteId]/heatmaps/route';
const cases = [
['journeys', journeys, { steps: '3' }],
['retention', retention, {}],
['breakdown', breakdown, { fields: '["path"]' }],
['attribution', attribution, { model: 'last-click', type: 'event', step: 'signup' }],
['heatmaps', heatmaps, { mode: 'click', urlPath: '/pricing' }],
] as const;
describe.each(cases)('%s GET analytics', (_name, handler, params) => {
beforeEach(() => {
mocks.allowed = true;
mocks.query.mockClear();
});
const context = { params: Promise.resolve({ websiteId: 'website' }) };
const request = () =>
new Request(
`https://example.org/api?${new URLSearchParams({ startAt: '1000', endAt: '2000', ...params })}`,
);
test('uses normalized dates and path-scoped website', async () => {
const response = await handler(request(), context);
expect(response.status).toBe(200);
expect(mocks.query.mock.calls[0]?.[0]).toBe('website');
expect(mocks.query.mock.calls[0]?.[1]).toMatchObject(mocks.normalized);
});
test('does not execute a query without permission', async () => {
mocks.allowed = false;
expect((await handler(request(), context)).status).toBe(401);
expect(mocks.query).not.toHaveBeenCalled();
});
test('rejects missing dates before querying', async () => {
expect((await handler(new Request('https://example.org/api'), context)).status).toBe(400);
expect(mocks.query).not.toHaveBeenCalled();
});
});
+35
View File
@@ -0,0 +1,35 @@
import { describe, expect, test } from 'vitest';
import { breakdownQuerySchema, journeyQuerySchema, retentionQuerySchema } from './analytics-schema';
const range = { startAt: '1000', endAt: '2000' };
describe('analytics GET schemas', () => {
test('decodes structured fields without losing escaped characters', () => {
expect(
breakdownQuerySchema.parse({ ...range, fields: JSON.stringify(['path', 'utmSource']) })
.fields,
).toEqual(['path', 'utmSource']);
});
test.each(['{', '["not-a-field"]', '{}'])('rejects invalid fields: %s', fields => {
expect(breakdownQuerySchema.safeParse({ ...range, fields }).success).toBe(false);
});
test('requires a finite, ordered timestamp range', () => {
for (const input of [
{},
{ startAt: 1000 },
{ startAt: 2000, endAt: 1000 },
{ startAt: 'NaN', endAt: 2000 },
{ startAt: 0, endAt: 1e20 },
]) {
expect(retentionQuerySchema.safeParse(input).success).toBe(false);
}
expect(retentionQuerySchema.parse(range)).toMatchObject({ startAt: 1000, endAt: 2000 });
});
test('validates feature parameters', () => {
expect(journeyQuerySchema.safeParse({ ...range, steps: 8 }).success).toBe(false);
expect(journeyQuerySchema.parse({ ...range, steps: '3', eventType: '2' })).toMatchObject({
steps: 3,
eventType: 2,
});
});
});
+56
View File
@@ -0,0 +1,56 @@
import { z } from 'zod';
import {
attributionReportSchema,
breakdownReportSchema,
filterParams,
heatmapReportSchema,
journeyReportSchema,
timezoneParam,
unitParam,
} from './schema';
// GET analytics have one authoritative timestamp range. Dynamic filters are retained by parseRequest.
export const analyticsParams = {
...filterParams,
startAt: z.coerce.number().int().min(0).max(8640000000000000),
endAt: z.coerce.number().int().min(0).max(8640000000000000),
timezone: timezoneParam.optional(),
unit: unitParam.optional(),
};
export function jsonQuery<T extends z.ZodType>(schema: T) {
return z
.string()
.transform((value, ctx) => {
try {
return JSON.parse(value);
} catch {
ctx.addIssue({ code: 'custom', message: 'Invalid JSON query parameter' });
return z.NEVER;
}
})
.pipe(schema);
}
export function analyticsSchema<T extends z.ZodRawShape>(shape: T) {
return z
.object({ ...analyticsParams, ...shape })
.refine((data: Record<string, unknown>) => Number(data.endAt) >= Number(data.startAt), {
message: 'endAt must be greater than or equal to startAt',
path: ['endAt'],
});
}
export const journeyQuerySchema = analyticsSchema(
journeyReportSchema.shape.parameters.omit({ startDate: true, endDate: true }).shape,
);
export const retentionQuerySchema = analyticsSchema({});
export const breakdownQuerySchema = analyticsSchema({
fields: jsonQuery(breakdownReportSchema.shape.parameters.shape.fields),
});
export const attributionQuerySchema = analyticsSchema(
attributionReportSchema.shape.parameters.omit({ startDate: true, endDate: true }).shape,
);
export const heatmapQuerySchema = analyticsSchema(
heatmapReportSchema.shape.parameters.omit({ startDate: true, endDate: true }).shape,
);
+3 -3
View File
@@ -37,10 +37,10 @@ export * from './performance/getPerformanceStats';
export * from './replays/getReplayChunks';
export * from './replays/getSessionReplays';
export * from './replays/saveRecording';
export * from './reports/getBreakdown';
export * from './breakdown/getBreakdown';
export * from './reports/getFunnel';
export * from './reports/getJourney';
export * from './reports/getRetention';
export * from './journeys/getJourney';
export * from './retention/getRetention';
export * from './reports/getUTM';
export * from './sessions/createSession';
export * from './sessions/getLinkedDistinctIds';
File renamed without changes.