import type { FastifyInstance, FastifyRequest } from "fastify"; import { isAnalyticsOptedIn, schema } from "@app/database"; import { analyticsBatchInputSchema, MAX_ANALYTICS_BATCH_SIZE, validateAnalyticsPropertySize, } from "@app/validation"; import { parse, errors } from "../lib/errors.js"; import { audit } from "../lib/helpers.js"; /** Max events per batch is also the per-minute rate-limit budget for this endpoint. */ const ANALYTICS_RATE_LIMIT_MAX = MAX_ANALYTICS_BATCH_SIZE; /** * Product analytics ingestion (spec §8.2). * Accepts both authenticated and anonymous events. Anonymous events must have * anonymousId and can be backfilled to userId later when the user logs in. */ export async function analyticsRoutes(app: FastifyInstance) { app.post( "/v1/analytics/events", { config: { rateLimit: { max: ANALYTICS_RATE_LIMIT_MAX, timeWindow: "1 minute", keyGenerator: (req) => `analytics:${req.ip}:${(req as unknown as { userId?: string }).userId ?? "anon"}`, }, }, }, async (req, reply) => { const input = parse(analyticsBatchInputSchema, req.body, "analytics batch"); validateAnalyticsPropertySize(input.events); let userId: string | undefined; try { const payload = await req.jwtVerify<{ sub: string; role: string; type: string }>(); if (payload.type === "access" && payload.sub) { userId = payload.sub; } } catch { // Anonymous event – fine as long as the batch carries anonymousId. } const isAuthenticated = !!userId; if (isAuthenticated && userId) { const optedIn = await isAnalyticsOptedIn(app.db, userId); if (!optedIn) { throw errors.forbidden("Analytics collection disabled by user consent."); } } const now = new Date(); const rows = input.events.map((event, index) => { const occurredAt = event.occurredAt ? new Date(event.occurredAt) : now; if (Number.isNaN(occurredAt.getTime())) { throw errors.badRequest(`Invalid occurredAt for event ${index}`); } return { occurredAt, receivedAt: now, eventName: event.name, anonymousId: event.anonymousId ?? null, sessionId: event.sessionId ?? null, userId: isAuthenticated ? userId : null, householdId: event.householdId ?? null, appVersion: event.appVersion ?? null, platform: event.platform ?? null, locale: event.locale ?? null, experimentVariant: event.experimentVariant ?? null, properties: event.properties ?? {}, }; }); // Single insert for the whole batch. On conflict is impossible because // events are insert-only with random UUIDs. const result = await app.db.insert(schema.productAnalyticsEvents).values(rows); const accepted = result.rowCount ?? rows.length; if (isAuthenticated) { await audit(app.db, { actorUserId: userId, action: "analytics.ingest", targetType: "analytics_batch", targetId: String(accepted), ip: req.ip, }); } return reply.code(202).send({ accepted, rejected: 0 }); }, ); }