From 6c9f1ad06b5e9d442d51bed992707d84521141a7 Mon Sep 17 00:00:00 2001 From: Mike Cao Date: Tue, 28 Jan 2025 10:21:56 -0800 Subject: [PATCH] Converted reports and share routes. --- src/app/api/reports/[reportId]/route.ts | 91 +++++++++++++++++++ src/app/api/reports/funnel/route.ts | 50 ++++++++++ src/app/api/reports/goals/route.ts | 53 +++++++++++ src/app/api/reports/insights/route.ts | 71 +++++++++++++++ src/app/api/reports/journey/route.ts | 46 ++++++++++ src/app/api/reports/retention/route.ts | 41 +++++++++ src/app/api/reports/revenue/route.ts | 75 +++++++++++++++ src/app/api/reports/route.ts | 73 +++++++++++++++ src/app/api/reports/utm/route.ts | 40 ++++++++ src/app/api/share/[shareId]/route.ts | 19 ++++ src/lib/prisma.ts | 4 +- src/lib/schema.ts | 12 ++- src/lib/types.ts | 4 +- .../reports/{[reportId].ts => _[reportId].ts} | 0 .../api/reports/{funnel.ts => _funnel.ts} | 0 src/pages/api/reports/{goals.ts => _goals.ts} | 0 src/pages/api/reports/{index.ts => _index.ts} | 0 .../api/reports/{insights.ts => _insights.ts} | 0 .../api/reports/{journey.ts => _journey.ts} | 0 .../reports/{retention.ts => _retention.ts} | 0 .../api/reports/{revenue.ts => _revenue.ts} | 0 src/pages/api/reports/{utm.ts => _utm.ts} | 0 .../api/share/{[shareId].ts => _[shareId].ts} | 0 23 files changed, 574 insertions(+), 5 deletions(-) create mode 100644 src/app/api/reports/[reportId]/route.ts create mode 100644 src/app/api/reports/funnel/route.ts create mode 100644 src/app/api/reports/goals/route.ts create mode 100644 src/app/api/reports/insights/route.ts create mode 100644 src/app/api/reports/journey/route.ts create mode 100644 src/app/api/reports/retention/route.ts create mode 100644 src/app/api/reports/revenue/route.ts create mode 100644 src/app/api/reports/route.ts create mode 100644 src/app/api/reports/utm/route.ts create mode 100644 src/app/api/share/[shareId]/route.ts rename src/pages/api/reports/{[reportId].ts => _[reportId].ts} (100%) rename src/pages/api/reports/{funnel.ts => _funnel.ts} (100%) rename src/pages/api/reports/{goals.ts => _goals.ts} (100%) rename src/pages/api/reports/{index.ts => _index.ts} (100%) rename src/pages/api/reports/{insights.ts => _insights.ts} (100%) rename src/pages/api/reports/{journey.ts => _journey.ts} (100%) rename src/pages/api/reports/{retention.ts => _retention.ts} (100%) rename src/pages/api/reports/{revenue.ts => _revenue.ts} (100%) rename src/pages/api/reports/{utm.ts => _utm.ts} (100%) rename src/pages/api/share/{[shareId].ts => _[shareId].ts} (100%) diff --git a/src/app/api/reports/[reportId]/route.ts b/src/app/api/reports/[reportId]/route.ts new file mode 100644 index 00000000..0d7c0845 --- /dev/null +++ b/src/app/api/reports/[reportId]/route.ts @@ -0,0 +1,91 @@ +import { z } from 'zod'; +import { parseRequest } from 'lib/request'; +import { deleteReport, getReport, updateReport } from 'queries'; +import { canDeleteReport, canUpdateReport, canViewReport } from 'lib/auth'; +import { unauthorized, json, notFound, ok } from 'lib/response'; +import { reportTypeParam } from 'lib/schema'; + +export async function GET(request: Request, { params }: { params: Promise<{ reportId: string }> }) { + const { auth, error } = await parseRequest(request); + + if (error) { + return error(); + } + + const { reportId } = await params; + + const report = await getReport(reportId); + + if (!(await canViewReport(auth, report))) { + return unauthorized(); + } + + report.parameters = JSON.parse(report.parameters); + + return json(report); +} + +export async function POST( + request: Request, + { params }: { params: Promise<{ reportId: string }> }, +) { + const schema = z.object({ + websiteId: z.string().uuid(), + type: reportTypeParam, + name: z.string().max(200), + description: z.string().max(500), + parameters: z.object({}).passthrough(), + }); + + const { auth, body, error } = await parseRequest(request, schema); + + if (error) { + return error(); + } + + const { reportId } = await params; + const { websiteId, type, name, description, parameters } = body; + + const report = await getReport(reportId); + + if (!report) { + return notFound(); + } + + if (!(await canUpdateReport(auth, report))) { + return unauthorized(); + } + + const result = await updateReport(reportId, { + websiteId, + userId: auth.user.id, + type, + name, + description, + parameters: JSON.stringify(parameters), + } as any); + + return json(result); +} + +export async function DELETE( + request: Request, + { params }: { params: Promise<{ reportId: string }> }, +) { + const { auth, error } = await parseRequest(request); + + if (error) { + return error(); + } + + const { reportId } = await params; + const report = await getReport(reportId); + + if (!(await canDeleteReport(auth, report))) { + return unauthorized(); + } + + await deleteReport(reportId); + + return ok(); +} diff --git a/src/app/api/reports/funnel/route.ts b/src/app/api/reports/funnel/route.ts new file mode 100644 index 00000000..9a0cfd65 --- /dev/null +++ b/src/app/api/reports/funnel/route.ts @@ -0,0 +1,50 @@ +import { z } from 'zod'; +import { canViewWebsite } from 'lib/auth'; +import { unauthorized, json } from 'lib/response'; +import { parseRequest } from 'lib/request'; +import { getFunnel } from 'queries'; + +export async function POST(request: Request) { + const schema = z.object({ + websiteId: z.string().uuid(), + steps: z + .array( + z.object({ + type: z.string(), + value: z.string(), + }), + ) + .min(2), + window: z.number().positive(), + dateRange: z.object({ + startDate: z.date(), + endDate: z.date(), + }), + }); + + const { auth, body, error } = await parseRequest(request, schema); + + if (error) { + return error(); + } + + const { + websiteId, + steps, + window, + dateRange: { startDate, endDate }, + } = body; + + if (!(await canViewWebsite(auth, websiteId))) { + return unauthorized(); + } + + const data = await getFunnel(websiteId, { + startDate: new Date(startDate), + endDate: new Date(endDate), + steps, + windowMinutes: +window, + }); + + return json(data); +} diff --git a/src/app/api/reports/goals/route.ts b/src/app/api/reports/goals/route.ts new file mode 100644 index 00000000..ee102bc6 --- /dev/null +++ b/src/app/api/reports/goals/route.ts @@ -0,0 +1,53 @@ +import { z } from 'zod'; +import { canViewWebsite } from 'lib/auth'; +import { unauthorized, json } from 'lib/response'; +import { parseRequest } from 'lib/request'; +import { getGoals } from 'queries/analytics/reports/getGoals'; + +export async function POST(request: Request) { + const schema = z.object({ + websiteId: z.string().uuid(), + dateRange: z.object({ + startDate: z.date(), + endDate: z.date(), + }), + goals: z + .array( + z.object({ + type: z.string().regex(/url|event|event-data/), + value: z.string(), + goal: z.number(), + operator: z + .string() + .regex(/count|sum|average/) + .refine(data => data['type'] === 'event-data'), + property: z.string().refine(data => data['type'] === 'event-data'), + }), + ) + .min(1), + }); + + const { auth, body, error } = await parseRequest(request, schema); + + if (error) { + return error(); + } + + const { + websiteId, + dateRange: { startDate, endDate }, + goals, + } = body; + + if (!(await canViewWebsite(auth, websiteId))) { + return unauthorized(); + } + + const data = await getGoals(websiteId, { + startDate: new Date(startDate), + endDate: new Date(endDate), + goals, + }); + + return json(data); +} diff --git a/src/app/api/reports/insights/route.ts b/src/app/api/reports/insights/route.ts new file mode 100644 index 00000000..ae361934 --- /dev/null +++ b/src/app/api/reports/insights/route.ts @@ -0,0 +1,71 @@ +import { z } from 'zod'; +import { canViewWebsite } from 'lib/auth'; +import { unauthorized, json } from 'lib/response'; +import { parseRequest } from 'lib/request'; +import { getInsights } from 'queries'; + +function convertFilters(filters: any[]) { + return filters.reduce((obj, filter) => { + obj[filter.name] = filter; + + return obj; + }, {}); +} + +export async function POST(request: Request) { + const schema = z.object({ + websiteId: z.string().uuid(), + dateRange: z.object({ + startDate: z.date(), + endDate: z.date(), + }), + fields: z + .array( + z.object({ + name: z.string(), + type: z.string(), + label: z.string(), + }), + ) + .min(1), + filters: z.array( + z.object({ + name: z.string(), + type: z.string(), + operator: z.string(), + value: z.string(), + }), + ), + groups: z.array( + z.object({ + name: z.string(), + type: z.string(), + }), + ), + }); + + const { auth, body, error } = await parseRequest(request, schema); + + if (error) { + return error(); + } + + const { + websiteId, + dateRange: { startDate, endDate }, + fields, + filters, + } = body; + + if (!(await canViewWebsite(auth, websiteId))) { + return unauthorized(); + } + + const data = await getInsights(websiteId, fields, { + ...convertFilters(filters), + startDate: new Date(startDate), + endDate: new Date(endDate), + }); + + return json(data); +} diff --git a/src/app/api/reports/journey/route.ts b/src/app/api/reports/journey/route.ts new file mode 100644 index 00000000..50b64952 --- /dev/null +++ b/src/app/api/reports/journey/route.ts @@ -0,0 +1,46 @@ +import { z } from 'zod'; +import { canViewWebsite } from 'lib/auth'; +import { unauthorized, json } from 'lib/response'; +import { parseRequest } from 'lib/request'; +import { getJourney } from 'queries'; + +export async function POST(request: Request) { + const schema = z.object({ + websiteId: z.string().uuid(), + dateRange: z.object({ + startDate: z.date(), + endDate: z.date(), + }), + steps: z.number().min(3).max(7), + startStep: z.string(), + endStep: z.string(), + }); + + const { auth, body, error } = await parseRequest(request, schema); + + if (error) { + return error(); + } + + const { + websiteId, + dateRange: { startDate, endDate }, + steps, + startStep, + endStep, + } = body; + + if (!(await canViewWebsite(auth, websiteId))) { + return unauthorized(); + } + + const data = await getJourney(websiteId, { + startDate: new Date(startDate), + endDate: new Date(endDate), + steps, + startStep, + endStep, + }); + + return json(data); +} diff --git a/src/app/api/reports/retention/route.ts b/src/app/api/reports/retention/route.ts new file mode 100644 index 00000000..794ebbe4 --- /dev/null +++ b/src/app/api/reports/retention/route.ts @@ -0,0 +1,41 @@ +import { z } from 'zod'; +import { canViewWebsite } from 'lib/auth'; +import { unauthorized, json } from 'lib/response'; +import { parseRequest } from 'lib/request'; +import { getRetention } from 'queries'; +import { timezoneParam } from 'lib/schema'; + +export async function POST(request: Request) { + const schema = z.object({ + websiteId: z.string().uuid(), + dateRange: z.object({ + startDate: z.date(), + endDate: z.date(), + }), + timezone: timezoneParam, + }); + + const { auth, body, error } = await parseRequest(request, schema); + + if (error) { + return error(); + } + + const { + websiteId, + dateRange: { startDate, endDate }, + timezone, + } = body; + + if (!(await canViewWebsite(auth, websiteId))) { + return unauthorized(); + } + + const data = await getRetention(websiteId, { + startDate: new Date(startDate), + endDate: new Date(endDate), + timezone, + }); + + return json(data); +} diff --git a/src/app/api/reports/revenue/route.ts b/src/app/api/reports/revenue/route.ts new file mode 100644 index 00000000..0f8f7d55 --- /dev/null +++ b/src/app/api/reports/revenue/route.ts @@ -0,0 +1,75 @@ +import { z } from 'zod'; +import { canViewWebsite } from 'lib/auth'; +import { unauthorized, json } from 'lib/response'; +import { parseRequest } from 'lib/request'; +import { timezoneParam, unitParam } from 'lib/schema'; +import { getRevenue } from 'queries/analytics/reports/getRevenue'; +import { getRevenueValues } from 'queries/analytics/reports/getRevenueValues'; + +export async function GET(request: Request) { + const schema = z.object({ + websiteId: z.string().uuid(), + dateRange: z.object({ + startDate: z.date(), + endDate: z.date(), + }), + }); + + const { auth, query, error } = await parseRequest(request, schema); + + if (error) { + return error(); + } + + const { websiteId, startDate, endDate } = query; + + if (!(await canViewWebsite(auth, websiteId))) { + return unauthorized(); + } + + const data = await getRevenueValues(websiteId, { + startDate: new Date(startDate), + endDate: new Date(endDate), + }); + + return json(data); +} + +export async function POST(request: Request) { + const schema = z.object({ + websiteId: z.string().uuid(), + dateRange: z.object({ + startDate: z.date(), + endDate: z.date(), + unit: unitParam, + }), + timezone: timezoneParam, + }); + + const { auth, body, error } = await parseRequest(request, schema); + + if (error) { + return error(); + } + + const { + websiteId, + currency, + timezone, + dateRange: { startDate, endDate, unit }, + } = body; + + if (!(await canViewWebsite(auth, websiteId))) { + return unauthorized(); + } + + const data = await getRevenue(websiteId, { + startDate: new Date(startDate), + endDate: new Date(endDate), + unit, + timezone, + currency, + }); + + return json(data); +} diff --git a/src/app/api/reports/route.ts b/src/app/api/reports/route.ts new file mode 100644 index 00000000..bc5284a3 --- /dev/null +++ b/src/app/api/reports/route.ts @@ -0,0 +1,73 @@ +import { z } from 'zod'; +import { pagingParams } from 'lib/schema'; +import { parseRequest } from 'lib/request'; +import { canViewTeam, canViewWebsite } from 'lib/auth'; +import { unauthorized, json } from 'lib/response'; +import { getReports } from 'queries/prisma/report'; + +export async function GET(request: Request) { + const schema = z.object({ + ...pagingParams, + }); + + const { auth, query, error } = await parseRequest(request, schema); + + if (error) { + return error(); + } + + const { page, search, pageSize, websiteId, teamId } = query; + const userId = auth.user.id; + const filters = { + page, + pageSize, + search, + }; + + if ( + (websiteId && !(await canViewWebsite(auth, websiteId))) || + (teamId && !(await canViewTeam(auth, teamId))) + ) { + return unauthorized(); + } + + const data = await getReports( + { + where: { + OR: [ + ...(websiteId ? [{ websiteId }] : []), + ...(teamId + ? [ + { + website: { + deletedAt: null, + teamId, + }, + }, + ] + : []), + ...(userId && !websiteId && !teamId + ? [ + { + website: { + deletedAt: null, + userId, + }, + }, + ] + : []), + ], + }, + include: { + website: { + select: { + domain: true, + }, + }, + }, + }, + filters, + ); + + return json(data); +} diff --git a/src/app/api/reports/utm/route.ts b/src/app/api/reports/utm/route.ts new file mode 100644 index 00000000..0af8b419 --- /dev/null +++ b/src/app/api/reports/utm/route.ts @@ -0,0 +1,40 @@ +import { z } from 'zod'; +import { canViewWebsite } from 'lib/auth'; +import { unauthorized, json } from 'lib/response'; +import { parseRequest } from 'lib/request'; +import { getUTM } from 'queries'; +import { timezoneParam } from 'lib/schema'; + +export async function POST(request: Request) { + const schema = z.object({ + websiteId: z.string().uuid(), + dateRange: z.object({ + startDate: z.date(), + endDate: z.date(), + timezone: timezoneParam, + }), + }); + + const { auth, body, error } = await parseRequest(request, schema); + + if (error) { + return error(); + } + + const { + websiteId, + dateRange: { startDate, endDate, timezone }, + } = body; + + if (!(await canViewWebsite(auth, websiteId))) { + return unauthorized(); + } + + const data = await getUTM(websiteId, { + startDate: new Date(startDate), + endDate: new Date(endDate), + timezone, + }); + + return json(data); +} diff --git a/src/app/api/share/[shareId]/route.ts b/src/app/api/share/[shareId]/route.ts new file mode 100644 index 00000000..f5c5ab5a --- /dev/null +++ b/src/app/api/share/[shareId]/route.ts @@ -0,0 +1,19 @@ +import { json, notFound } from 'lib/response'; +import { getSharedWebsite } from 'queries'; +import { createToken } from 'next-basics'; +import { secret } from 'lib/crypto'; + +export async function GET(request: Request, { params }: { params: Promise<{ shareId: string }> }) { + const { shareId } = await params; + + const website = await getSharedWebsite(shareId); + + if (!website) { + return notFound(); + } + + const data = { websiteId: website.id }; + const token = createToken(data, secret()); + + return json({ ...data, token }); +} diff --git a/src/lib/prisma.ts b/src/lib/prisma.ts index cc1b8734..deee67f7 100644 --- a/src/lib/prisma.ts +++ b/src/lib/prisma.ts @@ -243,7 +243,7 @@ async function pagedQuery(model: string, criteria: T, pageParams: PageParams) const data = await prisma.client[model].findMany({ ...criteria, ...{ - ...(size > 0 && { take: +size, skip: +size * (page - 1) }), + ...(size > 0 && { take: +size, skip: +size * (+page - 1) }), ...(orderBy && { orderBy: [ { @@ -266,7 +266,7 @@ async function pagedRawQuery( ) { const { page = 1, pageSize, orderBy, sortDescending = false } = pageParams; const size = +pageSize || DEFAULT_PAGE_SIZE; - const offset = +size * (page - 1); + const offset = +size * (+page - 1); const direction = sortDescending ? 'desc' : 'asc'; const statements = [ diff --git a/src/lib/schema.ts b/src/lib/schema.ts index 0410a965..80be7691 100644 --- a/src/lib/schema.ts +++ b/src/lib/schema.ts @@ -30,7 +30,17 @@ export const unitParam = z.string().refine(value => UNIT_TYPES.includes(value), message: 'Invalid unit', }); -export const roleParam = z.string().regex(/team-member|team-view-only|team-manager/); +export const roleParam = z.enum(['team-member', 'team-view-only', 'team-manager']); + +export const reportTypeParam = z.enum([ + 'funnel', + 'insights', + 'retention', + 'utm', + 'goals', + 'journey', + 'revenue', +]); export const filterParams = { url: z.string().optional(), diff --git a/src/lib/types.ts b/src/lib/types.ts index 70c2aae6..5c397d5e 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -26,8 +26,8 @@ export type ReportType = ObjectValues; export interface PageParams { search?: string; - page?: number; - pageSize?: number; + page?: string; + pageSize?: string; orderBy?: string; sortDescending?: boolean; } diff --git a/src/pages/api/reports/[reportId].ts b/src/pages/api/reports/_[reportId].ts similarity index 100% rename from src/pages/api/reports/[reportId].ts rename to src/pages/api/reports/_[reportId].ts diff --git a/src/pages/api/reports/funnel.ts b/src/pages/api/reports/_funnel.ts similarity index 100% rename from src/pages/api/reports/funnel.ts rename to src/pages/api/reports/_funnel.ts diff --git a/src/pages/api/reports/goals.ts b/src/pages/api/reports/_goals.ts similarity index 100% rename from src/pages/api/reports/goals.ts rename to src/pages/api/reports/_goals.ts diff --git a/src/pages/api/reports/index.ts b/src/pages/api/reports/_index.ts similarity index 100% rename from src/pages/api/reports/index.ts rename to src/pages/api/reports/_index.ts diff --git a/src/pages/api/reports/insights.ts b/src/pages/api/reports/_insights.ts similarity index 100% rename from src/pages/api/reports/insights.ts rename to src/pages/api/reports/_insights.ts diff --git a/src/pages/api/reports/journey.ts b/src/pages/api/reports/_journey.ts similarity index 100% rename from src/pages/api/reports/journey.ts rename to src/pages/api/reports/_journey.ts diff --git a/src/pages/api/reports/retention.ts b/src/pages/api/reports/_retention.ts similarity index 100% rename from src/pages/api/reports/retention.ts rename to src/pages/api/reports/_retention.ts diff --git a/src/pages/api/reports/revenue.ts b/src/pages/api/reports/_revenue.ts similarity index 100% rename from src/pages/api/reports/revenue.ts rename to src/pages/api/reports/_revenue.ts diff --git a/src/pages/api/reports/utm.ts b/src/pages/api/reports/_utm.ts similarity index 100% rename from src/pages/api/reports/utm.ts rename to src/pages/api/reports/_utm.ts diff --git a/src/pages/api/share/[shareId].ts b/src/pages/api/share/_[shareId].ts similarity index 100% rename from src/pages/api/share/[shareId].ts rename to src/pages/api/share/_[shareId].ts