feat(api): add website-scoped analytics GET routes
This commit is contained in:
1 parent
1d7874b7d9
commit
f28fb0108a
15 files changed
+257
-4
No files matched your search
@@ -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));
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
);
|
||||
File renamed without changes.
File renamed without changes.
File renamed without changes.
@@ -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.
File renamed without changes.
Reference in new issue
Block a user