diff --git a/src/app/api/reports/attribution/route.ts b/src/app/api/reports/attribution/route.ts index 4b38d13f0..86fcb2231 100644 --- a/src/app/api/reports/attribution/route.ts +++ b/src/app/api/reports/attribution/route.ts @@ -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); diff --git a/src/app/api/websites/[websiteId]/attribution/route.ts b/src/app/api/websites/[websiteId]/attribution/route.ts new file mode 100644 index 000000000..7b4fd30c0 --- /dev/null +++ b/src/app/api/websites/[websiteId]/attribution/route.ts @@ -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)); +} diff --git a/src/app/api/websites/[websiteId]/breakdown/route.ts b/src/app/api/websites/[websiteId]/breakdown/route.ts new file mode 100644 index 000000000..d3ca0d563 --- /dev/null +++ b/src/app/api/websites/[websiteId]/breakdown/route.ts @@ -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)); +} diff --git a/src/app/api/websites/[websiteId]/heatmaps/route.ts b/src/app/api/websites/[websiteId]/heatmaps/route.ts new file mode 100644 index 000000000..c6cba8407 --- /dev/null +++ b/src/app/api/websites/[websiteId]/heatmaps/route.ts @@ -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)); +} diff --git a/src/app/api/websites/[websiteId]/journeys/route.ts b/src/app/api/websites/[websiteId]/journeys/route.ts new file mode 100644 index 000000000..6f1ed3cad --- /dev/null +++ b/src/app/api/websites/[websiteId]/journeys/route.ts @@ -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)); +} diff --git a/src/app/api/websites/[websiteId]/retention/route.ts b/src/app/api/websites/[websiteId]/retention/route.ts new file mode 100644 index 000000000..9acfdf6ed --- /dev/null +++ b/src/app/api/websites/[websiteId]/retention/route.ts @@ -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)); +} diff --git a/src/lib/analytics-routes.test.ts b/src/lib/analytics-routes.test.ts new file mode 100644 index 000000000..5163c4d5a --- /dev/null +++ b/src/lib/analytics-routes.test.ts @@ -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(); + }); +}); diff --git a/src/lib/analytics-schema.test.ts b/src/lib/analytics-schema.test.ts new file mode 100644 index 000000000..6f43f9c93 --- /dev/null +++ b/src/lib/analytics-schema.test.ts @@ -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, + }); + }); +}); diff --git a/src/lib/analytics-schema.ts b/src/lib/analytics-schema.ts new file mode 100644 index 000000000..e45759c99 --- /dev/null +++ b/src/lib/analytics-schema.ts @@ -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(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(shape: T) { + return z + .object({ ...analyticsParams, ...shape }) + .refine((data: Record) => 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, +); diff --git a/src/queries/sql/reports/getAttribution.test.ts b/src/queries/sql/attribution/getAttribution.test.ts similarity index 100% rename from src/queries/sql/reports/getAttribution.test.ts rename to src/queries/sql/attribution/getAttribution.test.ts diff --git a/src/queries/sql/reports/getAttribution.ts b/src/queries/sql/attribution/getAttribution.ts similarity index 100% rename from src/queries/sql/reports/getAttribution.ts rename to src/queries/sql/attribution/getAttribution.ts diff --git a/src/queries/sql/reports/getBreakdown.ts b/src/queries/sql/breakdown/getBreakdown.ts similarity index 100% rename from src/queries/sql/reports/getBreakdown.ts rename to src/queries/sql/breakdown/getBreakdown.ts diff --git a/src/queries/sql/index.ts b/src/queries/sql/index.ts index 321398838..654f72c3f 100644 --- a/src/queries/sql/index.ts +++ b/src/queries/sql/index.ts @@ -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'; diff --git a/src/queries/sql/reports/getJourney.ts b/src/queries/sql/journeys/getJourney.ts similarity index 100% rename from src/queries/sql/reports/getJourney.ts rename to src/queries/sql/journeys/getJourney.ts diff --git a/src/queries/sql/reports/getRetention.ts b/src/queries/sql/retention/getRetention.ts similarity index 100% rename from src/queries/sql/reports/getRetention.ts rename to src/queries/sql/retention/getRetention.ts