import { DateTime } from '@openpanel/common'; import type { GetEventListOptions } from '@openpanel/db'; import { ChartEngine, ClientType, db, getEventList, getEventsCount, getSettingsForProject, } from '@openpanel/db'; import { zChartEvent, zReport } from '@openpanel/validation'; import type { FastifyReply, FastifyRequest } from 'fastify'; import { z } from 'zod'; import { HttpError } from '@/utils/errors'; import { parseQueryString } from '@/utils/parse-zod-query-string'; async function getProjectId( request: FastifyRequest<{ Querystring: { project_id?: string; projectId?: string; }; }> ) { let projectId = request.query.projectId || request.query.project_id; if (projectId) { if ( request.client?.type === ClientType.read && request.client?.projectId !== projectId ) { throw new HttpError('You do not have access to this project', { status: 403, }); } const project = await db.project.findUnique({ where: { organizationId: request.client?.organizationId, id: projectId, }, }); if (!project) { throw new HttpError('Project not found', { status: 404, }); } } if (!projectId && request.client?.projectId) { projectId = request.client?.projectId; } if (!projectId) { throw new HttpError('project_id or projectId is required', { status: 400, }); } return projectId; } const eventsScheme = z.object({ project_id: z.string().optional(), projectId: z.string().optional(), profileId: z.string().optional(), event: z.union([z.string(), z.array(z.string())]).optional(), start: z.coerce.string().optional(), end: z.coerce.string().optional(), page: z.coerce.number().optional().default(1), limit: z.coerce.number().optional().default(50), includes: z .preprocess((arg) => { if (arg == null) { return undefined; } if (Array.isArray(arg)) { return arg; } if (typeof arg === 'string') { const parts = arg .split(',') .map((s) => s.trim()) .filter(Boolean); return parts; } return arg; }, z.array(z.string())) .optional(), }); export async function events( request: FastifyRequest<{ Querystring: z.infer; }>, reply: FastifyReply ) { const query = eventsScheme.safeParse(request.query); if (query.success === false) { return reply.status(400).send({ error: 'Bad Request', message: 'Invalid query parameters', details: query.error.errors, }); } const projectId = await getProjectId(request); const limit = query.data.limit; const page = Math.max(query.data.page, 1); const take = Math.max(Math.min(limit, 1000), 1); const cursor = page - 1; const options: GetEventListOptions = { projectId, events: (Array.isArray(query.data.event) ? query.data.event : [query.data.event] ).filter((s): s is string => typeof s === 'string'), startDate: query.data.start ? new Date(query.data.start) : undefined, endDate: query.data.end ? new Date(query.data.end) : undefined, cursor, take, profileId: query.data.profileId, select: { profile: false, meta: false, ...query.data.includes?.reduce( (acc, key) => ({ ...acc, [key]: true }), {} ), }, }; const [data, totalCount] = await Promise.all([ getEventList(options), getEventsCount(options), ]); reply.send({ meta: { count: data.length, totalCount, pages: Math.ceil(totalCount / options.take), current: cursor + 1, }, data, }); } const chartSchemeFull = zReport .pick({ breakdowns: true, interval: true, range: true, previous: true, startDate: true, endDate: true, }) .extend({ project_id: z.string().optional(), projectId: z.string().optional(), series: z .array( z.object({ name: z.string(), filters: zChartEvent.shape.filters.optional(), segment: zChartEvent.shape.segment.optional(), property: zChartEvent.shape.property.optional(), }) ) .optional(), // Backward compatibility - events will be migrated to series via preprocessing events: z .array( z.object({ name: z.string(), filters: zChartEvent.shape.filters.optional(), segment: zChartEvent.shape.segment.optional(), property: zChartEvent.shape.property.optional(), }) ) .optional(), }); export async function charts( request: FastifyRequest<{ Querystring: Record; }>, reply: FastifyReply ) { const query = chartSchemeFull.safeParse(parseQueryString(request.query)); if (query.success === false) { return reply.status(400).send({ error: 'Bad Request', message: 'Invalid query parameters', details: query.error.errors, }); } const projectId = await getProjectId(request); const { timezone } = await getSettingsForProject(projectId); const { events, series, ...rest } = query.data; // Use series if available, otherwise fall back to events (backward compat) const eventSeries = (series ?? events ?? []).map((event: any) => ({ ...event, type: event.type ?? 'event', segment: event.segment ?? 'event', filters: event.filters ?? [], })); return ChartEngine.execute({ ...rest, startDate: rest.startDate ? DateTime.fromISO(rest.startDate) .setZone(timezone) .toFormat('yyyy-MM-dd HH:mm:ss') : undefined, endDate: rest.endDate ? DateTime.fromISO(rest.endDate) .setZone(timezone) .toFormat('yyyy-MM-dd HH:mm:ss') : undefined, projectId, series: eventSeries, chartType: 'linear', metric: 'sum', }); }