Files
Cibello-app/apps/api/src/routes/analytics.ts
T

94 lines
3.2 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 });
},
);
}