110 lines
3.5 KiB
TypeScript
110 lines
3.5 KiB
TypeScript
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,
|
|
};
|
|
}
|