Files
anouma/lib/analytics/cleanup.ts
T
maro 65b6eeac51 Add privacy-friendly analytics and cookie consent, integrated into the CMS
Adds a first-party analytics system with no third-party trackers and no
persistent per-visitor identifiers: events are grouped into sessions via a
daily-rotating pseudonymous hash (IP+UA+host, HMAC-salted, never stored raw).

- New "Analytics" section in the Payload admin, gated by a canViewAnalytics
  permission on Users, following the existing custom-admin-view pattern
  used by the calendar feature
- Sessions, page views, unique visitors, top/entry/exit pages, events,
  device/browser breakdown, date range filter with period comparison
- Raw events and daily aggregate rollups as their own collections, with
  configurable retention and a cron endpoint that rolls up before purging
- Cookie consent banner (necessary/preferences/statistics) with categories
  and copy editable from a new CMS global; a track() helper that is a
  complete no-op without statistics consent, so nothing is ever sent
  before a visitor opts in
- track() calls wired into the booking flow, contact form, and navigation
2026-09-06 21:59:59 +02:00

110 lines
3.6 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,
};
}