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:
2026-09-06 21:59:59 +02:00
parent 82c4261a81
commit 65b6eeac51
30 changed files with 2274 additions and 7 deletions
+94
View File
@@ -0,0 +1,94 @@
import type { AnalyticsEvent } from "@/payload-types";
export type AggregatedStats = {
sessions: number;
pageViews: number;
uniqueVisitors: number;
avgPagesPerSession: number;
eventCounts: Record<string, number>;
deviceCounts: Record<string, number>;
browserCounts: Record<string, number>;
topPaths: Record<string, number>;
entryPaths: Record<string, number>;
exitPaths: Record<string, number>;
};
function bump(map: Record<string, number>, key: string | null | undefined) {
if (!key) return;
map[key] = (map[key] ?? 0) + 1;
}
/** Sorted (descending) [key, count] pairs, capped at `limit` — for dashboard tables. */
export function topEntries(map: Record<string, number>, limit = 10): [string, number][] {
return Object.entries(map)
.sort(([, a], [, b]) => b - a)
.slice(0, limit);
}
/**
* Pure aggregation over a set of raw AnalyticsEvents rows (any date range —
* one day for the cleanup rollup, or a whole dashboard-selected range for
* live queries). Kept side-effect free and DB-agnostic so both callers
* (lib/analytics/cleanup.ts and lib/analytics/dashboardQueries.ts) share
* one implementation.
*/
export function aggregateEvents(events: AnalyticsEvent[]): AggregatedStats {
const eventCounts: Record<string, number> = {};
const deviceCounts: Record<string, number> = {};
const browserCounts: Record<string, number> = {};
const topPaths: Record<string, number> = {};
const entryPaths: Record<string, number> = {};
const exitPaths: Record<string, number> = {};
const visitorHashes = new Set<string>();
const sessionIds = new Set<string>();
let pageViews = 0;
// Per-session first/last page_view, in chronological order, for entry/exit pages.
const sessionPageViews = new Map<string, { first: { at: string; path: string }; last: { at: string; path: string } }>();
const sorted = [...events].sort((a, b) => a.createdAt.localeCompare(b.createdAt));
for (const ev of sorted) {
visitorHashes.add(ev.visitorHash);
sessionIds.add(ev.sessionId);
bump(eventCounts, ev.eventName);
bump(deviceCounts, ev.device);
bump(browserCounts, ev.browser);
if (ev.eventName === "page_view") {
pageViews += 1;
bump(topPaths, ev.path);
const existing = sessionPageViews.get(ev.sessionId);
if (!existing) {
sessionPageViews.set(ev.sessionId, {
first: { at: ev.createdAt, path: ev.path },
last: { at: ev.createdAt, path: ev.path },
});
} else {
existing.last = { at: ev.createdAt, path: ev.path };
}
}
}
for (const { first, last } of sessionPageViews.values()) {
bump(entryPaths, first.path);
bump(exitPaths, last.path);
}
const sessions = sessionIds.size;
return {
sessions,
pageViews,
uniqueVisitors: visitorHashes.size,
avgPagesPerSession: sessions > 0 ? Math.round((pageViews / sessions) * 10) / 10 : 0,
eventCounts,
deviceCounts,
browserCounts,
topPaths,
entryPaths,
exitPaths,
};
}
+109
View File
@@ -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,
};
}
+92
View File
@@ -0,0 +1,92 @@
"use client";
/**
* Public developer API + consent storage for the analytics system. Importing
* this module has zero side effects — no network call, nothing initialized
* — until `track()` is actually called, and even then it's a no-op unless
* consent was actually granted. Callers never check consent themselves:
*
* track("booking_started");
*
* The CMS-configured part of consent (whether the "statistics" category is
* enabled at all, and the current consent version) is pushed in once by
* components/Analytics.tsx via setAnalyticsConfig() — see that file. Until
* that happens, `cmsConfig` is null and track() fails safe (no-op), so a
* component whose effects happen to run before Analytics.tsx's can never
* accidentally send data before the live config is known.
*/
const CONSENT_COOKIE = "anouma-consent";
export type ConsentState = {
necessary: true;
preferences: boolean;
statistics: boolean;
version: string;
decidedAt: string;
};
type CmsConfig = { statisticsEnabled: boolean; consentVersion: string };
let cmsConfig: CmsConfig | null = null;
export function setAnalyticsConfig(config: CmsConfig) {
cmsConfig = config;
}
export function getConsent(): ConsentState | null {
if (typeof document === "undefined") return null;
const match = document.cookie.match(/(?:^|;\s*)anouma-consent=([^;]*)/);
if (!match) return null;
try {
const parsed = JSON.parse(decodeURIComponent(match[1]));
if (parsed && typeof parsed === "object" && typeof parsed.version === "string") return parsed as ConsentState;
return null;
} catch {
return null;
}
}
export function setConsent(choice: { preferences: boolean; statistics: boolean }, version: string): ConsentState {
const state: ConsentState = {
necessary: true,
preferences: choice.preferences,
statistics: choice.statistics,
version,
decidedAt: new Date().toISOString(),
};
const maxAgeSeconds = 60 * 60 * 24 * 180; // 180 days
const secure = typeof location !== "undefined" && location.protocol === "https:" ? "; Secure" : "";
document.cookie = `${CONSENT_COOKIE}=${encodeURIComponent(JSON.stringify(state))}; Max-Age=${maxAgeSeconds}; Path=/; SameSite=Lax${secure}`;
return state;
}
/** True once the visitor has made a decision that still matches the current CMS consent version. */
export function hasCurrentConsentDecision(): boolean {
if (!cmsConfig) return false;
const consent = getConsent();
return Boolean(consent && consent.version === cmsConfig.consentVersion);
}
function statisticsAllowed(): boolean {
if (!cmsConfig || !cmsConfig.statisticsEnabled) return false;
const consent = getConsent();
return Boolean(consent && consent.version === cmsConfig.consentVersion && consent.statistics === true);
}
export function track(eventName: string, extra?: { path?: string }): void {
if (typeof window === "undefined") return;
if (!statisticsAllowed()) return;
const path = extra?.path ?? window.location.pathname;
const body = JSON.stringify({ eventName, path, referrer: document.referrer || undefined });
// navigator.sendBeacon can't reliably set Content-Type: application/json
// across browsers, so the server route also accepts a plain-text body.
if (typeof navigator !== "undefined" && navigator.sendBeacon) {
navigator.sendBeacon("/api/analytics/track", new Blob([body], { type: "text/plain" }));
return;
}
fetch("/api/analytics/track", { method: "POST", body, keepalive: true, headers: { "Content-Type": "text/plain" } }).catch(() => {});
}
+69
View File
@@ -0,0 +1,69 @@
import type { Payload } from "payload";
import type { AnalyticsEvent } from "@/payload-types";
import { aggregateEvents, type AggregatedStats } from "@/lib/analytics/aggregate";
export type DashboardRange = { from: Date; to: Date };
export type DailyPoint = { date: string; pageViews: number; sessions: number };
export type DashboardStats = AggregatedStats & {
series: DailyPoint[];
};
function dateKey(iso: string): string {
return iso.slice(0, 10);
}
async function fetchEvents(payload: Payload, range: DashboardRange): Promise<AnalyticsEvent[]> {
const { docs } = await payload.find({
collection: "analytics-events",
where: {
and: [
{ createdAt: { greater_than_equal: range.from.toISOString() } },
{ createdAt: { less_than_equal: range.to.toISOString() } },
],
},
limit: 0,
depth: 0,
sort: "createdAt",
});
return docs;
}
function buildSeries(events: AnalyticsEvent[]): DailyPoint[] {
const byDay = new Map<string, { pageViews: number; sessions: Set<string> }>();
for (const ev of events) {
const key = dateKey(ev.createdAt);
const bucket = byDay.get(key) ?? { pageViews: 0, sessions: new Set<string>() };
if (ev.eventName === "page_view") bucket.pageViews += 1;
bucket.sessions.add(ev.sessionId);
byDay.set(key, bucket);
}
return [...byDay.entries()]
.sort(([a], [b]) => a.localeCompare(b))
.map(([date, bucket]) => ({ date, pageViews: bucket.pageViews, sessions: bucket.sessions.size }));
}
export async function getDashboardStats(payload: Payload, range: DashboardRange): Promise<DashboardStats> {
const events = await fetchEvents(payload, range);
return { ...aggregateEvents(events), series: buildSeries(events) };
}
export type PercentDelta = number | null; // null = no comparison basis (previous value was 0)
export function percentDelta(current: number, previous: number): PercentDelta {
if (previous === 0) return current === 0 ? 0 : null;
return Math.round(((current - previous) / previous) * 1000) / 10;
}
export async function getDashboardComparison(
payload: Payload,
range: DashboardRange,
compareRange: DashboardRange,
): Promise<{ current: DashboardStats; previous: DashboardStats }> {
const [current, previous] = await Promise.all([
getDashboardStats(payload, range),
getDashboardStats(payload, compareRange),
]);
return { current, previous };
}
+26
View File
@@ -0,0 +1,26 @@
/**
* Central, extensible registry of known analytics event names. Adding a new
* event elsewhere in the app never requires a migration — the database
* column (collections/AnalyticsEvents.ts#eventName) is free-text — this
* list exists purely so `track()` calls get autocomplete/type-checking and
* the dashboard can show a nice German label instead of a raw event name.
*
* To add a new event: add one entry here, then call `track("your_event")`
* anywhere in the app (see lib/analytics/client.ts).
*/
export const ANALYTICS_EVENTS = {
page_view: "Seitenaufruf",
booking_started: "Buchung gestartet",
booking_completed: "Buchung abgeschlossen",
contact_clicked: "Kontakt angeklickt",
navigation_clicked: "Navigation geklickt",
} as const;
export type KnownAnalyticsEvent = keyof typeof ANALYTICS_EVENTS;
/** Any string works (see the collection comment above) — known ones just get a label. */
export type AnalyticsEventName = KnownAnalyticsEvent | (string & {});
export function analyticsEventLabel(eventName: string): string {
return (ANALYTICS_EVENTS as Record<string, string>)[eventName] ?? eventName;
}
+71
View File
@@ -0,0 +1,71 @@
import type { DashboardRange } from "@/lib/analytics/dashboardQueries";
export type RangeKey = "today" | "7d" | "30d" | "90d" | "custom";
export const RANGE_OPTIONS: { key: RangeKey; label: string; days: number | "today" }[] = [
{ key: "today", label: "Heute", days: "today" },
{ key: "7d", label: "7 Tage", days: 7 },
{ key: "30d", label: "30 Tage", days: 30 },
{ key: "90d", label: "90 Tage", days: 90 },
];
const DAY_MS = 24 * 60 * 60 * 1000;
function startOfUTCDay(date: Date): Date {
return new Date(`${date.toISOString().slice(0, 10)}T00:00:00.000Z`);
}
function endOfUTCDay(date: Date): Date {
return new Date(`${date.toISOString().slice(0, 10)}T23:59:59.999Z`);
}
function parseDateParam(value: string | null): Date | null {
if (!value || !/^\d{4}-\d{2}-\d{2}$/.test(value)) return null;
const date = new Date(`${value}T00:00:00.000Z`);
return Number.isNaN(date.getTime()) ? null : date;
}
/**
* Resolves the dashboard's period filter (?range=&from=&to=) into an actual
* date range, clamped to `maxDaysBack` (the configured raw-event retention —
* see globals/AnalyticsSettings.ts) so the UI never claims to show data that
* has already been purged. Also computes the matching "previous period" for
* comparison (same length, immediately preceding).
*/
export function resolveRange(
searchParams: URLSearchParams,
maxDaysBack: number,
): { key: RangeKey; range: DashboardRange; compareRange: DashboardRange } {
const now = new Date();
const earliestAllowed = startOfUTCDay(new Date(now.getTime() - maxDaysBack * DAY_MS));
const rawKey = searchParams.get("range");
const key: RangeKey = (["today", "7d", "30d", "90d", "custom"] as const).includes(rawKey as RangeKey)
? (rawKey as RangeKey)
: "7d";
if (key === "custom") {
const fromParam = parseDateParam(searchParams.get("from"));
const toParam = parseDateParam(searchParams.get("to"));
let from = fromParam ? startOfUTCDay(fromParam) : startOfUTCDay(new Date(now.getTime() - 7 * DAY_MS));
let to = toParam ? endOfUTCDay(toParam) : now;
if (from < earliestAllowed) from = earliestAllowed;
if (to > now) to = now;
if (from > to) from = to;
const lengthMs = to.getTime() - from.getTime();
const compareRange: DashboardRange = { from: new Date(from.getTime() - lengthMs), to: new Date(from.getTime() - 1) };
return { key, range: { from, to }, compareRange };
}
if (key === "today") {
const from = startOfUTCDay(now);
const yesterday = new Date(from.getTime() - DAY_MS);
return { key, range: { from, to: now }, compareRange: { from: yesterday, to: new Date(from.getTime() - 1) } };
}
const days = RANGE_OPTIONS.find((o) => o.key === key)?.days;
const windowDays = typeof days === "number" ? Math.min(days, maxDaysBack) : 7;
const from = new Date(Math.max(now.getTime() - windowDays * DAY_MS, earliestAllowed.getTime()));
const compareRange: DashboardRange = { from: new Date(from.getTime() - windowDays * DAY_MS), to: new Date(from.getTime() - 1) };
return { key, range: { from, to: now }, compareRange };
}
+33
View File
@@ -0,0 +1,33 @@
import { createHmac } from "node:crypto";
/**
* Derives a daily-rotating, non-reversible pseudonymous visitor identifier
* for session grouping — the same approach used by privacy-friendly
* analytics tools (Plausible/Fathom-style): hash(ip + userAgent + host)
* salted with a value that changes every calendar day. Raw IP/UA are never
* persisted anywhere, only this hash — and since the salt rotates daily,
* the same visitor hashes differently tomorrow, so there is no cross-day
* linkage and no long-term individual profile.
*
* ANALYTICS_SALT_SECRET is a dedicated secret (not PAYLOAD_SECRET) — this
* codebase keeps one secret per purpose on purpose (see
* MEETING_SESSION_SECRET's comment in .env.example).
*/
function secret(): string {
const value = process.env.ANALYTICS_SALT_SECRET;
if (!value) throw new Error("ANALYTICS_SALT_SECRET is not set");
return value;
}
function dateKey(date: Date): string {
return date.toISOString().slice(0, 10);
}
function dailySalt(date: Date): string {
return createHmac("sha256", secret()).update(dateKey(date)).digest("hex");
}
export function visitorHash(args: { ip: string; userAgent: string; host: string; date?: Date }): string {
const salt = dailySalt(args.date ?? new Date());
return createHmac("sha256", salt).update(`${args.ip}|${args.userAgent}|${args.host}`).digest("hex");
}
+29
View File
@@ -0,0 +1,29 @@
import type { Payload } from "payload";
import { randomUUID } from "node:crypto";
const SESSION_IDLE_WINDOW_MS = 30 * 60 * 1000;
/**
* Groups events into sessions without any client-side identifier: looks up
* the most recent event from the same `visitorHash` within the last 30
* minutes and reuses its sessionId, or mints a new one. One extra indexed
* read per tracked request — acceptable at this site's traffic volume, and
* simpler/safer than trying to hold a lock across an unauthenticated,
* high-frequency public endpoint (unlike lib/booking/lock.ts's advisory
* lock, an occasional extra spurious session here has no correctness
* consequences worth the added complexity).
*/
export async function resolveSessionId(payload: Payload, visitorHash: string): Promise<string> {
const since = new Date(Date.now() - SESSION_IDLE_WINDOW_MS).toISOString();
const { docs } = await payload.find({
collection: "analytics-events",
where: {
and: [{ visitorHash: { equals: visitorHash } }, { createdAt: { greater_than_equal: since } }],
},
sort: "-createdAt",
limit: 1,
depth: 0,
});
return docs[0]?.sessionId ?? randomUUID();
}
+28
View File
@@ -0,0 +1,28 @@
/**
* Coarse device/browser classification from the User-Agent header. No
* library, no raw UA string ever stored — just one bucket per request,
* computed on the fly and discarded after classification. Not meant to be
* precise (e.g. WebView vs. real browser); good enough for the "device
* type" / "browser" breakdown in the Analytics dashboard.
*/
export type DeviceType = "mobile" | "desktop" | "tablet";
export type BrowserFamily = "chrome" | "firefox" | "safari" | "edge" | "other";
export function classifyDevice(userAgent: string): DeviceType {
const ua = userAgent.toLowerCase();
if (/ipad|tablet(?!.*mobile)|(android(?!.*mobile))/.test(ua)) return "tablet";
if (/mobi|iphone|ipod|android/.test(ua)) return "mobile";
return "desktop";
}
export function classifyBrowser(userAgent: string): BrowserFamily {
const ua = userAgent.toLowerCase();
// Order matters: Edge/Chrome UAs also contain "safari"; Chrome UAs also
// contain "chrome" but so do Edge/Opera — check the most specific tokens first.
if (/edg\//.test(ua)) return "edge";
if (/chrome\/|crios\//.test(ua)) return "chrome";
if (/firefox\/|fxios\//.test(ua)) return "firefox";
if (/safari\//.test(ua)) return "safari";
return "other";
}
+6 -1
View File
@@ -1,6 +1,6 @@
import { cache } from "react";
import { getCMS } from "@/lib/payload/getPayload";
import type { About, AktuellesIntro, AngeboteIntro, Booking, BookingSetting, Contact, Home, SeoSetting } from "@/payload-types";
import type { About, AktuellesIntro, AngeboteIntro, Booking, BookingSetting, Contact, CookieSetting, Home, SeoSetting } from "@/payload-types";
export const getHomeGlobal = cache(async (): Promise<Home> => {
const payload = await getCMS();
@@ -41,3 +41,8 @@ export const getSEOSettingsGlobal = cache(async (): Promise<SeoSetting> => {
const payload = await getCMS();
return payload.findGlobal({ slug: "seo-settings" });
});
export const getCookieSettingsGlobal = cache(async (): Promise<CookieSetting> => {
const payload = await getCMS();
return payload.findGlobal({ slug: "cookie-settings" });
});