feat(api): add saved funnel and goal resources and stats
This commit is contained in:
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 {
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
@@ -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.
File renamed without changes.
@@ -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';
|
||||
|
||||
Reference in new issue
Block a user