feat(api): add saved funnel and goal resources and stats

This commit is contained in:
Mike Cao committed 2026-09-09 18:54:05 -07:00
1 parent f28fb0108a
commit 8c17658ac1
16 files changed
+415 -3

No files matched your search

@@ -6,7 +6,7 @@ import { ReportEditButton } from '@/components/input/ReportEditButton';
import { ChangeLabel } from '@/components/metrics/ChangeLabel';
import { Lightning } from '@/components/svg';
import { formatLongNumber } from '@/lib/format';
import type { FunnelResult } from '@/queries/sql/reports/getFunnel';
import type { FunnelResult } from '@/queries/sql/funnels/getFunnel';
import { FunnelEditForm } from './FunnelEditForm';
interface FunnelProps {
+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 GoalParameters, getGoal } from '@/queries/sql/reports/getGoal';
import { type GoalParameters, getGoal } from '@/queries/sql/goals/getGoal';
export async function POST(request: Request) {
const { auth, body, error } = await parseRequest(request, reportResultSchema);
@@ -0,0 +1,51 @@
import { funnelDefinitionSchema } from '@/lib/analytics-schema';
import { parseRequest } from '@/lib/request';
import { json, notFound, ok, unauthorized } from '@/lib/response';
import { canViewReport, canUpdateReport, canDeleteReport } from '@/permissions';
import { getReport, updateReport, deleteReport } from '@/queries/prisma';
export async function GET(
request: Request,
{ params }: { params: Promise<{ websiteId: string; funnelId: string }> },
) {
const { auth, error } = await parseRequest(request);
if (error) return error();
const { websiteId, funnelId } = await params;
const report = await getReport(funnelId);
if (!report || report.websiteId !== websiteId || report.type !== 'funnel') return notFound();
if (!(await canViewReport(auth, report))) return unauthorized();
return json(report);
}
export async function POST(
request: Request,
{ params }: { params: Promise<{ websiteId: string; funnelId: string }> },
) {
const { auth, body, error } = await parseRequest(request, funnelDefinitionSchema);
if (error) return error();
const { websiteId, funnelId } = await params;
const report = await getReport(funnelId);
if (!report || report.websiteId !== websiteId || report.type !== 'funnel') return notFound();
if (!(await canUpdateReport(auth, report))) return unauthorized();
return json(
await updateReport(report.id, {
name: body.name,
description: body.description,
parameters: body.parameters,
}),
);
}
export async function DELETE(
request: Request,
{ params }: { params: Promise<{ websiteId: string; funnelId: string }> },
) {
const { auth, error } = await parseRequest(request);
if (error) return error();
const { websiteId, funnelId } = await params;
const report = await getReport(funnelId);
if (!report || report.websiteId !== websiteId || report.type !== 'funnel') return notFound();
if (!(await canDeleteReport(auth, report))) return unauthorized();
await deleteReport(report.id);
return ok();
}
@@ -0,0 +1,24 @@
import { savedStatsQuerySchema, funnelParametersSchema } from '@/lib/analytics-schema';
import { getQueryFilters, parseRequest } from '@/lib/request';
import { json, unauthorized, notFound, badRequest } from '@/lib/response';
import { canViewWebsiteSection, canViewReport } from '@/permissions';
import { getReport } from '@/queries/prisma';
import { getFunnel, type FunnelParameters } from '@/queries/sql/funnels/getFunnel';
export async function GET(
request: Request,
{ params }: { params: Promise<{ websiteId: string; funnelId: string }> },
) {
const { auth, query, error } = await parseRequest(request, savedStatsQuerySchema);
if (error) return error();
const { websiteId, funnelId } = await params;
if (!(await canViewWebsiteSection(auth, websiteId, 'funnels'))) return unauthorized();
const report = await getReport(funnelId);
if (!report || report.websiteId !== websiteId || report.type !== 'funnel') return notFound();
if (!(await canViewReport(auth, report))) return unauthorized();
const parsed = funnelParametersSchema.safeParse(report.parameters);
if (!parsed.success) return badRequest();
const filters = await getQueryFilters(query, websiteId);
const parameters = { ...parsed.data, ...filters } as FunnelParameters;
return json(await getFunnel(websiteId, parameters, filters));
}
@@ -0,0 +1,40 @@
import { definitionListSchema, funnelDefinitionSchema } from '@/lib/analytics-schema';
import { uuid } from '@/lib/crypto';
import { parseRequest } from '@/lib/request';
import { json, unauthorized } from '@/lib/response';
import { canUpdateWebsite, canViewWebsiteSection } from '@/permissions';
import { createReport, getReports } from '@/queries/prisma';
export async function GET(
request: Request,
{ params }: { params: Promise<{ websiteId: string }> },
) {
const { auth, query, error } = await parseRequest(request, definitionListSchema);
if (error) return error();
const { websiteId } = await params;
if (!(await canViewWebsiteSection(auth, websiteId, 'funnels'))) return unauthorized();
return json(
await getReports({ where: { websiteId, type: 'funnel', website: { deletedAt: null } } }, query),
);
}
export async function POST(
request: Request,
{ params }: { params: Promise<{ websiteId: string }> },
) {
const { auth, body, error } = await parseRequest(request, funnelDefinitionSchema);
if (error) return error();
const { websiteId } = await params;
if (!(await canUpdateWebsite(auth, websiteId))) return unauthorized();
return json(
await createReport({
id: uuid(),
userId: auth.user.id,
websiteId,
type: 'funnel',
name: body.name,
description: body.description || '',
parameters: body.parameters,
}),
);
}
@@ -0,0 +1,19 @@
import { funnelQuerySchema } from '@/lib/analytics-schema';
import { getQueryFilters, parseRequest } from '@/lib/request';
import { json, unauthorized } from '@/lib/response';
import { canViewWebsiteSection } from '@/permissions';
import { getFunnel, type FunnelParameters } from '@/queries/sql/funnels/getFunnel';
export async function GET(
request: Request,
{ params }: { params: Promise<{ websiteId: string }> },
) {
const { auth, query, error } = await parseRequest(request, funnelQuerySchema);
if (error) return error();
const { websiteId } = await params;
if (!(await canViewWebsiteSection(auth, websiteId, 'funnels'))) return unauthorized();
const filters = await getQueryFilters(query, websiteId);
const parameters = { ...query, ...filters } as FunnelParameters;
return json(await getFunnel(websiteId, parameters, filters));
}
@@ -0,0 +1,51 @@
import { goalDefinitionSchema } from '@/lib/analytics-schema';
import { parseRequest } from '@/lib/request';
import { json, notFound, ok, unauthorized } from '@/lib/response';
import { canViewReport, canUpdateReport, canDeleteReport } from '@/permissions';
import { getReport, updateReport, deleteReport } from '@/queries/prisma';
export async function GET(
request: Request,
{ params }: { params: Promise<{ websiteId: string; goalId: string }> },
) {
const { auth, error } = await parseRequest(request);
if (error) return error();
const { websiteId, goalId } = await params;
const report = await getReport(goalId);
if (!report || report.websiteId !== websiteId || report.type !== 'goal') return notFound();
if (!(await canViewReport(auth, report))) return unauthorized();
return json(report);
}
export async function POST(
request: Request,
{ params }: { params: Promise<{ websiteId: string; goalId: string }> },
) {
const { auth, body, error } = await parseRequest(request, goalDefinitionSchema);
if (error) return error();
const { websiteId, goalId } = await params;
const report = await getReport(goalId);
if (!report || report.websiteId !== websiteId || report.type !== 'goal') return notFound();
if (!(await canUpdateReport(auth, report))) return unauthorized();
return json(
await updateReport(report.id, {
name: body.name,
description: body.description,
parameters: body.parameters,
}),
);
}
export async function DELETE(
request: Request,
{ params }: { params: Promise<{ websiteId: string; goalId: string }> },
) {
const { auth, error } = await parseRequest(request);
if (error) return error();
const { websiteId, goalId } = await params;
const report = await getReport(goalId);
if (!report || report.websiteId !== websiteId || report.type !== 'goal') return notFound();
if (!(await canDeleteReport(auth, report))) return unauthorized();
await deleteReport(report.id);
return ok();
}
@@ -0,0 +1,24 @@
import { savedStatsQuerySchema, goalParametersSchema } from '@/lib/analytics-schema';
import { getQueryFilters, parseRequest } from '@/lib/request';
import { json, unauthorized, notFound, badRequest } from '@/lib/response';
import { canViewWebsiteSection, canViewReport } from '@/permissions';
import { getReport } from '@/queries/prisma';
import { getGoal, type GoalParameters } from '@/queries/sql/goals/getGoal';
export async function GET(
request: Request,
{ params }: { params: Promise<{ websiteId: string; goalId: string }> },
) {
const { auth, query, error } = await parseRequest(request, savedStatsQuerySchema);
if (error) return error();
const { websiteId, goalId } = await params;
if (!(await canViewWebsiteSection(auth, websiteId, 'goals'))) return unauthorized();
const report = await getReport(goalId);
if (!report || report.websiteId !== websiteId || report.type !== 'goal') return notFound();
if (!(await canViewReport(auth, report))) return unauthorized();
const parsed = goalParametersSchema.safeParse(report.parameters);
if (!parsed.success) return badRequest();
const filters = await getQueryFilters(query, websiteId);
const parameters = { ...parsed.data, ...filters } as GoalParameters;
return json(await getGoal(websiteId, parameters, filters));
}
@@ -0,0 +1,40 @@
import { definitionListSchema, goalDefinitionSchema } from '@/lib/analytics-schema';
import { uuid } from '@/lib/crypto';
import { parseRequest } from '@/lib/request';
import { json, unauthorized } from '@/lib/response';
import { canUpdateWebsite, canViewWebsiteSection } from '@/permissions';
import { createReport, getReports } from '@/queries/prisma';
export async function GET(
request: Request,
{ params }: { params: Promise<{ websiteId: string }> },
) {
const { auth, query, error } = await parseRequest(request, definitionListSchema);
if (error) return error();
const { websiteId } = await params;
if (!(await canViewWebsiteSection(auth, websiteId, 'goals'))) return unauthorized();
return json(
await getReports({ where: { websiteId, type: 'goal', website: { deletedAt: null } } }, query),
);
}
export async function POST(
request: Request,
{ params }: { params: Promise<{ websiteId: string }> },
) {
const { auth, body, error } = await parseRequest(request, goalDefinitionSchema);
if (error) return error();
const { websiteId } = await params;
if (!(await canUpdateWebsite(auth, websiteId))) return unauthorized();
return json(
await createReport({
id: uuid(),
userId: auth.user.id,
websiteId,
type: 'goal',
name: body.name,
description: body.description || '',
parameters: body.parameters,
}),
);
}
@@ -0,0 +1,19 @@
import { goalQuerySchema } from '@/lib/analytics-schema';
import { getQueryFilters, parseRequest } from '@/lib/request';
import { json, unauthorized } from '@/lib/response';
import { canViewWebsiteSection } from '@/permissions';
import { getGoal, type GoalParameters } from '@/queries/sql/goals/getGoal';
export async function GET(
request: Request,
{ params }: { params: Promise<{ websiteId: string }> },
) {
const { auth, query, error } = await parseRequest(request, goalQuerySchema);
if (error) return error();
const { websiteId } = await params;
if (!(await canViewWebsiteSection(auth, websiteId, 'goals'))) return unauthorized();
const filters = await getQueryFilters(query, websiteId);
const parameters = { ...query, ...filters } as GoalParameters;
return json(await getGoal(websiteId, parameters, filters));
}
+29
View File
@@ -3,6 +3,9 @@ import {
attributionReportSchema,
breakdownReportSchema,
filterParams,
funnelReportSchema,
goalReportSchema,
pagingParams,
heatmapReportSchema,
journeyReportSchema,
timezoneParam,
@@ -54,3 +57,29 @@ export const attributionQuerySchema = analyticsSchema(
export const heatmapQuerySchema = analyticsSchema(
heatmapReportSchema.shape.parameters.omit({ startDate: true, endDate: true }).shape,
);
export const funnelParametersSchema = funnelReportSchema.shape.parameters.omit({
startDate: true,
endDate: true,
});
export const goalParametersSchema = goalReportSchema.shape.parameters.omit({
startDate: true,
endDate: true,
});
export const funnelQuerySchema = analyticsSchema({
...funnelParametersSchema.shape,
steps: jsonQuery(funnelParametersSchema.shape.steps),
});
export const goalQuerySchema = analyticsSchema(goalParametersSchema.shape);
export const savedStatsQuerySchema = analyticsSchema({});
export const definitionListSchema = z.object({ ...pagingParams, search: z.string().optional() });
export const funnelDefinitionSchema = z.object({
name: z.string().min(1).max(200),
description: z.string().max(500).optional(),
parameters: funnelParametersSchema,
});
export const goalDefinitionSchema = z.object({
name: z.string().min(1).max(200),
description: z.string().max(500).optional(),
parameters: goalParametersSchema,
});
+115
View File
@@ -0,0 +1,115 @@
import { beforeEach, describe, expect, test, vi } from 'vitest';
const state = vi.hoisted(() => ({
report: null as any,
sectionAllowed: true,
definitionAllowed: true,
query: vi.fn(async (..._args: any[]) => ({ num: 1, total: 2 })),
update: vi.fn(async (..._args: any[]) => ({})),
remove: vi.fn(),
}));
vi.mock('@/permissions', () => ({
canViewWebsiteSection: async () => state.sectionAllowed,
canViewReport: async () => state.definitionAllowed,
canUpdateReport: async () => state.definitionAllowed,
canDeleteReport: async () => state.definitionAllowed,
}));
vi.mock('@/queries/prisma', () => ({
getReport: async () => state.report,
updateReport: state.update,
deleteReport: state.remove,
}));
vi.mock('@/queries/sql/goals/getGoal', () => ({ getGoal: state.query }));
vi.mock('@/queries/sql/funnels/getFunnel', () => ({ getFunnel: state.query }));
vi.mock('@/lib/request', () => ({
parseRequest: async (request: Request, schema?: any) => {
const input =
request.method === 'GET'
? Object.fromEntries(new URL(request.url).searchParams)
: await request.json().catch(() => ({}));
const result = schema?.safeParse(input) ?? { success: true, data: input };
return result.success
? { auth: {}, query: result.data, body: result.data }
: { error: () => new Response(null, { status: 400 }) };
},
getQueryFilters: async () => ({ startDate: new Date(1500), endDate: new Date(2000) }),
}));
import * as goal from '@/app/api/websites/[websiteId]/goals/[goalId]/route';
import * as funnel from '@/app/api/websites/[websiteId]/funnels/[funnelId]/route';
import { GET as goalStats } from '@/app/api/websites/[websiteId]/goals/[goalId]/stats/route';
import { GET as funnelStats } from '@/app/api/websites/[websiteId]/funnels/[funnelId]/stats/route';
const context = { params: Promise.resolve({ websiteId: 'website', goalId: 'id', funnelId: 'id' }) };
const request = () =>
new Request('https://example.org/api?startAt=1000&endAt=2000&type=event&value=override');
describe.each([
['goal', goal, goalStats, { type: 'path', value: '/saved' }],
[
'funnel',
funnel,
funnelStats,
{
window: 60,
steps: [
{ type: 'path', value: '/a' },
{ type: 'event', value: 'signup' },
],
},
],
] as const)('saved %s resources', (type, handlers, stats, parameters) => {
beforeEach(() => {
vi.clearAllMocks();
state.report = { id: 'id', websiteId: 'website', type, parameters };
state.sectionAllowed = true;
state.definitionAllowed = true;
});
test('calculates using saved criteria and normalized dates', async () => {
expect((await stats(request(), context)).status).toBe(200);
expect(state.query.mock.calls[0][1]).toEqual({
...parameters,
startDate: new Date(1500),
endDate: new Date(2000),
});
});
test.each(['websiteId', 'type'])(
'rejects mismatched %s for reads, writes, deletion, and stats',
async field => {
state.report[field] = 'other';
const write = new Request('https://example.org/api', {
method: 'POST',
body: JSON.stringify({ name: 'Name', parameters }),
});
for (const [handler, req] of [
[handlers.GET, request()],
[handlers.POST, write],
[handlers.DELETE, request()],
[stats, request()],
] as const) {
expect((await handler(req, context)).status).toBe(404);
}
expect(state.query).not.toHaveBeenCalled();
expect(state.update).not.toHaveBeenCalled();
expect(state.remove).not.toHaveBeenCalled();
},
);
test('requires both definition and analytics permissions for stats', async () => {
state.sectionAllowed = false;
expect((await stats(request(), context)).status).toBe(401);
state.sectionAllowed = true;
state.definitionAllowed = false;
expect((await stats(request(), context)).status).toBe(401);
expect(state.query).not.toHaveBeenCalled();
});
test('cannot move or change type through updates', async () => {
const req = new Request('https://example.org/api', {
method: 'POST',
body: JSON.stringify({ websiteId: 'other', type: 'other', name: 'Updated', parameters }),
});
expect((await handlers.POST(req, context)).status).toBe(200);
expect(state.update.mock.calls[0][1]).toEqual({
name: 'Updated',
description: undefined,
parameters,
});
});
});
File renamed without changes.
File renamed without changes.
+1 -1
View File
@@ -38,7 +38,7 @@ export * from './replays/getReplayChunks';
export * from './replays/getSessionReplays';
export * from './replays/saveRecording';
export * from './breakdown/getBreakdown';
export * from './reports/getFunnel';
export * from './funnels/getFunnel';
export * from './journeys/getJourney';
export * from './retention/getRetention';
export * from './reports/getUTM';