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
This commit is contained in:
@@ -0,0 +1,109 @@
|
||||
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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user