import type { Payload } from "payload"; import { aggregateEvents } from "@/lib/analytics/aggregate"; function dateKey(date: Date): string { return date.toISOString().slice(0, 10); } function startOfUTCDay(date: Date): Date { return new Date(`${dateKey(date)}T00:00:00.000Z`); } function addDays(date: Date, days: number): Date { return new Date(date.getTime() + days * 24 * 60 * 60 * 1000); } /** * Rolls up every not-yet-aggregated, fully-completed calendar day (UTC - * matching the UTC-day salt rotation in lib/analytics/salt.ts) from raw * analytics-events into analytics-daily-stats, THEN purges raw events older * than the configured retention, THEN purges old daily-stats rows. Order * matters: a day must be aggregated before its raw rows can ever be * deleted, or that history is gone for good. * * Only aggregates days that don't already have a daily-stats row (each day * is aggregated exactly once, ever) - safe to call this daily via * app/(payload)/api/cron/analytics-cleanup/route.ts, and safe to re-run. */ export async function runAnalyticsCleanup(payload: Payload) { const settings = await payload.findGlobal({ slug: "analytics-settings" }); const rawRetentionDays = settings.rawRetentionDays ?? 90; const aggregateRetentionMonths = settings.aggregateRetentionMonths ?? 12; const today = startOfUTCDay(new Date()); const { docs: earliestDocs } = await payload.find({ collection: "analytics-events", sort: "createdAt", limit: 1, depth: 0, }); const earliest = earliestDocs[0]; let daysAggregated = 0; if (earliest) { let cursor = startOfUTCDay(new Date(earliest.createdAt)); while (cursor < today) { const key = dateKey(cursor); const { totalDocs: alreadyAggregated } = await payload.count({ collection: "analytics-daily-stats", where: { date: { equals: key } }, }); if (alreadyAggregated === 0) { const dayEnd = addDays(cursor, 1); const { docs: dayEvents } = await payload.find({ collection: "analytics-events", where: { and: [ { createdAt: { greater_than_equal: cursor.toISOString() } }, { createdAt: { less_than: dayEnd.toISOString() } }, ], }, limit: 0, // no limit - pull the whole day, small-site scale depth: 0, }); if (dayEvents.length > 0) { const stats = aggregateEvents(dayEvents); await payload.create({ collection: "analytics-daily-stats", data: { date: key, ...stats }, }); daysAggregated += 1; } } cursor = addDays(cursor, 1); } } const rawCutoff = addDays(today, -rawRetentionDays).toISOString(); const { docs: staleEvents } = await payload.find({ collection: "analytics-events", where: { createdAt: { less_than: rawCutoff } }, limit: 0, depth: 0, }); for (const doc of staleEvents) { await payload.delete({ collection: "analytics-events", id: doc.id }); } const aggregateCutoff = new Date(today); aggregateCutoff.setUTCMonth(aggregateCutoff.getUTCMonth() - aggregateRetentionMonths); const { docs: staleDailyStats } = await payload.find({ collection: "analytics-daily-stats", where: { date: { less_than: dateKey(aggregateCutoff) } }, limit: 0, depth: 0, }); for (const doc of staleDailyStats) { await payload.delete({ collection: "analytics-daily-stats", id: doc.id }); } return { daysAggregated, rawEventsDeleted: staleEvents.length, dailyStatsDeleted: staleDailyStats.length, }; }