From 8c17658ac106faefc9f60eeb38955a77ba7f849c Mon Sep 17 00:00:00 2001 From: Mike Cao Date: Wed, 9 Sep 2026 18:54:05 -0700 Subject: [PATCH] feat(api): add saved funnel and goal resources and stats --- .../[websiteId]/(reports)/funnels/Funnel.tsx | 2 +- src/app/api/reports/goal/route.ts | 2 +- .../[websiteId]/funnels/[funnelId]/route.ts | 51 ++++++++ .../funnels/[funnelId]/stats/route.ts | 24 ++++ .../api/websites/[websiteId]/funnels/route.ts | 40 ++++++ .../[websiteId]/funnels/stats/route.ts | 19 +++ .../[websiteId]/goals/[goalId]/route.ts | 51 ++++++++ .../[websiteId]/goals/[goalId]/stats/route.ts | 24 ++++ .../api/websites/[websiteId]/goals/route.ts | 40 ++++++ .../websites/[websiteId]/goals/stats/route.ts | 19 +++ src/lib/analytics-schema.ts | 29 +++++ src/lib/saved-analytics-routes.test.ts | 115 ++++++++++++++++++ .../{reports => funnels}/getFunnel.test.ts | 0 .../sql/{reports => funnels}/getFunnel.ts | 0 src/queries/sql/{reports => goals}/getGoal.ts | 0 src/queries/sql/index.ts | 2 +- 16 files changed, 415 insertions(+), 3 deletions(-) create mode 100644 src/app/api/websites/[websiteId]/funnels/[funnelId]/route.ts create mode 100644 src/app/api/websites/[websiteId]/funnels/[funnelId]/stats/route.ts create mode 100644 src/app/api/websites/[websiteId]/funnels/route.ts create mode 100644 src/app/api/websites/[websiteId]/funnels/stats/route.ts create mode 100644 src/app/api/websites/[websiteId]/goals/[goalId]/route.ts create mode 100644 src/app/api/websites/[websiteId]/goals/[goalId]/stats/route.ts create mode 100644 src/app/api/websites/[websiteId]/goals/route.ts create mode 100644 src/app/api/websites/[websiteId]/goals/stats/route.ts create mode 100644 src/lib/saved-analytics-routes.test.ts rename src/queries/sql/{reports => funnels}/getFunnel.test.ts (100%) rename src/queries/sql/{reports => funnels}/getFunnel.ts (100%) rename src/queries/sql/{reports => goals}/getGoal.ts (100%) diff --git a/src/app/(main)/websites/[websiteId]/(reports)/funnels/Funnel.tsx b/src/app/(main)/websites/[websiteId]/(reports)/funnels/Funnel.tsx index 8c086a963..fe6fc0132 100644 --- a/src/app/(main)/websites/[websiteId]/(reports)/funnels/Funnel.tsx +++ b/src/app/(main)/websites/[websiteId]/(reports)/funnels/Funnel.tsx @@ -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 { diff --git a/src/app/api/reports/goal/route.ts b/src/app/api/reports/goal/route.ts index 2887a15cd..6dcb07e65 100644 --- a/src/app/api/reports/goal/route.ts +++ b/src/app/api/reports/goal/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 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); diff --git a/src/app/api/websites/[websiteId]/funnels/[funnelId]/route.ts b/src/app/api/websites/[websiteId]/funnels/[funnelId]/route.ts new file mode 100644 index 000000000..fa98b39e3 --- /dev/null +++ b/src/app/api/websites/[websiteId]/funnels/[funnelId]/route.ts @@ -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(); +} diff --git a/src/app/api/websites/[websiteId]/funnels/[funnelId]/stats/route.ts b/src/app/api/websites/[websiteId]/funnels/[funnelId]/stats/route.ts new file mode 100644 index 000000000..9e2acc27a --- /dev/null +++ b/src/app/api/websites/[websiteId]/funnels/[funnelId]/stats/route.ts @@ -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)); +} diff --git a/src/app/api/websites/[websiteId]/funnels/route.ts b/src/app/api/websites/[websiteId]/funnels/route.ts new file mode 100644 index 000000000..f0bb82c6d --- /dev/null +++ b/src/app/api/websites/[websiteId]/funnels/route.ts @@ -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, + }), + ); +} diff --git a/src/app/api/websites/[websiteId]/funnels/stats/route.ts b/src/app/api/websites/[websiteId]/funnels/stats/route.ts new file mode 100644 index 000000000..2ff706483 --- /dev/null +++ b/src/app/api/websites/[websiteId]/funnels/stats/route.ts @@ -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)); +} diff --git a/src/app/api/websites/[websiteId]/goals/[goalId]/route.ts b/src/app/api/websites/[websiteId]/goals/[goalId]/route.ts new file mode 100644 index 000000000..a42720213 --- /dev/null +++ b/src/app/api/websites/[websiteId]/goals/[goalId]/route.ts @@ -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(); +} diff --git a/src/app/api/websites/[websiteId]/goals/[goalId]/stats/route.ts b/src/app/api/websites/[websiteId]/goals/[goalId]/stats/route.ts new file mode 100644 index 000000000..7d9aafc42 --- /dev/null +++ b/src/app/api/websites/[websiteId]/goals/[goalId]/stats/route.ts @@ -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)); +} diff --git a/src/app/api/websites/[websiteId]/goals/route.ts b/src/app/api/websites/[websiteId]/goals/route.ts new file mode 100644 index 000000000..36a9c5d3f --- /dev/null +++ b/src/app/api/websites/[websiteId]/goals/route.ts @@ -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, + }), + ); +} diff --git a/src/app/api/websites/[websiteId]/goals/stats/route.ts b/src/app/api/websites/[websiteId]/goals/stats/route.ts new file mode 100644 index 000000000..2b0a46654 --- /dev/null +++ b/src/app/api/websites/[websiteId]/goals/stats/route.ts @@ -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)); +} diff --git a/src/lib/analytics-schema.ts b/src/lib/analytics-schema.ts index e45759c99..c14c68d8b 100644 --- a/src/lib/analytics-schema.ts +++ b/src/lib/analytics-schema.ts @@ -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, +}); diff --git a/src/lib/saved-analytics-routes.test.ts b/src/lib/saved-analytics-routes.test.ts new file mode 100644 index 000000000..bbe06703d --- /dev/null +++ b/src/lib/saved-analytics-routes.test.ts @@ -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, + }); + }); +}); diff --git a/src/queries/sql/reports/getFunnel.test.ts b/src/queries/sql/funnels/getFunnel.test.ts similarity index 100% rename from src/queries/sql/reports/getFunnel.test.ts rename to src/queries/sql/funnels/getFunnel.test.ts diff --git a/src/queries/sql/reports/getFunnel.ts b/src/queries/sql/funnels/getFunnel.ts similarity index 100% rename from src/queries/sql/reports/getFunnel.ts rename to src/queries/sql/funnels/getFunnel.ts diff --git a/src/queries/sql/reports/getGoal.ts b/src/queries/sql/goals/getGoal.ts similarity index 100% rename from src/queries/sql/reports/getGoal.ts rename to src/queries/sql/goals/getGoal.ts diff --git a/src/queries/sql/index.ts b/src/queries/sql/index.ts index 654f72c3f..aab63ca74 100644 --- a/src/queries/sql/index.ts +++ b/src/queries/sql/index.ts @@ -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';