94 lines
3.2 KiB
TypeScript
94 lines
3.2 KiB
TypeScript
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 });
|
||
},
|
||
);
|
||
}
|