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
+235
View File
@@ -79,6 +79,8 @@ export interface Config {
'availability-overrides': AvailabilityOverride;
'booking-requests': BookingRequest;
redirects: Redirect;
'analytics-events': AnalyticsEvent;
'analytics-daily-stats': AnalyticsDailyStat;
'payload-kv': PayloadKv;
'payload-locked-documents': PayloadLockedDocument;
'payload-preferences': PayloadPreference;
@@ -97,6 +99,8 @@ export interface Config {
'availability-overrides': AvailabilityOverridesSelect<false> | AvailabilityOverridesSelect<true>;
'booking-requests': BookingRequestsSelect<false> | BookingRequestsSelect<true>;
redirects: RedirectsSelect<false> | RedirectsSelect<true>;
'analytics-events': AnalyticsEventsSelect<false> | AnalyticsEventsSelect<true>;
'analytics-daily-stats': AnalyticsDailyStatsSelect<false> | AnalyticsDailyStatsSelect<true>;
'payload-kv': PayloadKvSelect<false> | PayloadKvSelect<true>;
'payload-locked-documents': PayloadLockedDocumentsSelect<false> | PayloadLockedDocumentsSelect<true>;
'payload-preferences': PayloadPreferencesSelect<false> | PayloadPreferencesSelect<true>;
@@ -116,6 +120,8 @@ export interface Config {
'meeting-settings': MeetingSetting;
'booking-settings': BookingSetting;
'seo-settings': SeoSetting;
'cookie-settings': CookieSetting;
'analytics-settings': AnalyticsSetting;
};
globalsSelect: {
home: HomeSelect<false> | HomeSelect<true>;
@@ -127,6 +133,8 @@ export interface Config {
'meeting-settings': MeetingSettingsSelect<false> | MeetingSettingsSelect<true>;
'booking-settings': BookingSettingsSelect<false> | BookingSettingsSelect<true>;
'seo-settings': SeoSettingsSelect<false> | SeoSettingsSelect<true>;
'cookie-settings': CookieSettingsSelect<false> | CookieSettingsSelect<true>;
'analytics-settings': AnalyticsSettingsSelect<false> | AnalyticsSettingsSelect<true>;
};
locale: null;
widgets: {
@@ -184,6 +192,10 @@ export interface User {
id: number;
name: string;
role: 'admin';
/**
* Steuert, ob dieser Zugang das Analytics-Dashboard im Admin-Bereich sehen kann.
*/
canViewAnalytics?: boolean | null;
calendarFeedTokenHash?: string | null;
updatedAt: string;
createdAt: string;
@@ -611,6 +623,89 @@ export interface Redirect {
updatedAt: string;
createdAt: string;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "analytics-events".
*/
export interface AnalyticsEvent {
id: number;
sessionId: string;
visitorHash: string;
path: string;
eventName: string;
referrerHost?: string | null;
device?: ('mobile' | 'desktop' | 'tablet') | null;
browser?: ('chrome' | 'firefox' | 'safari' | 'edge' | 'other') | null;
updatedAt: string;
createdAt: string;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "analytics-daily-stats".
*/
export interface AnalyticsDailyStat {
id: number;
date: string;
sessions: number;
pageViews: number;
uniqueVisitors: number;
eventCounts?:
| {
[k: string]: unknown;
}
| unknown[]
| string
| number
| boolean
| null;
deviceCounts?:
| {
[k: string]: unknown;
}
| unknown[]
| string
| number
| boolean
| null;
browserCounts?:
| {
[k: string]: unknown;
}
| unknown[]
| string
| number
| boolean
| null;
topPaths?:
| {
[k: string]: unknown;
}
| unknown[]
| string
| number
| boolean
| null;
entryPaths?:
| {
[k: string]: unknown;
}
| unknown[]
| string
| number
| boolean
| null;
exitPaths?:
| {
[k: string]: unknown;
}
| unknown[]
| string
| number
| boolean
| null;
updatedAt: string;
createdAt: string;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "payload-kv".
@@ -678,6 +773,14 @@ export interface PayloadLockedDocument {
| ({
relationTo: 'redirects';
value: number | Redirect;
} | null)
| ({
relationTo: 'analytics-events';
value: number | AnalyticsEvent;
} | null)
| ({
relationTo: 'analytics-daily-stats';
value: number | AnalyticsDailyStat;
} | null);
globalSlug?: string | null;
user:
@@ -738,6 +841,7 @@ export interface PayloadMigration {
export interface UsersSelect<T extends boolean = true> {
name?: T;
role?: T;
canViewAnalytics?: T;
calendarFeedTokenHash?: T;
updatedAt?: T;
createdAt?: T;
@@ -1004,6 +1108,39 @@ export interface RedirectsSelect<T extends boolean = true> {
updatedAt?: T;
createdAt?: T;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "analytics-events_select".
*/
export interface AnalyticsEventsSelect<T extends boolean = true> {
sessionId?: T;
visitorHash?: T;
path?: T;
eventName?: T;
referrerHost?: T;
device?: T;
browser?: T;
updatedAt?: T;
createdAt?: T;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "analytics-daily-stats_select".
*/
export interface AnalyticsDailyStatsSelect<T extends boolean = true> {
date?: T;
sessions?: T;
pageViews?: T;
uniqueVisitors?: T;
eventCounts?: T;
deviceCounts?: T;
browserCounts?: T;
topPaths?: T;
entryPaths?: T;
exitPaths?: T;
updatedAt?: T;
createdAt?: T;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "payload-kv_select".
@@ -1364,6 +1501,63 @@ export interface SeoSetting {
updatedAt?: string | null;
createdAt?: string | null;
}
/**
* Kategorien und Texte für den Cookie-Consent-Banner der Website.
*
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "cookie-settings".
*/
export interface CookieSetting {
id: number;
/**
* Bei Änderung müssen alle Besucher:innen erneut zustimmen — bereits gespeicherte Einwilligungen mit einer älteren Version werden ignoriert und der Banner erscheint erneut.
*/
consentVersion: string;
/**
* Immer aktiv — technisch erforderlich, kann nicht deaktiviert werden.
*/
necessary: {
label: string;
description: string;
};
preferences: {
label: string;
description: string;
/**
* Wenn deaktiviert, wird diese Kategorie im Banner nicht mehr angeboten.
*/
enabled?: boolean | null;
};
statistics: {
label: string;
description: string;
/**
* Globaler Schalter für das gesamte Analytics-System. Wenn deaktiviert, wird diese Kategorie im Banner nicht mehr angeboten und Analytics-Daten werden serverseitig abgelehnt — auch bei einer noch gespeicherten alten Zustimmung.
*/
enabled?: boolean | null;
};
updatedAt?: string | null;
createdAt?: string | null;
}
/**
* Aufbewahrungsdauer für Analytics-Rohdaten und aggregierte Tagesstatistiken.
*
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "analytics-settings".
*/
export interface AnalyticsSetting {
id: number;
/**
* Wie lange einzelne Analytics-Events gespeichert bleiben. Begrenzt auch den wählbaren Zeitraum im Analytics-Dashboard.
*/
rawRetentionDays: number;
/**
* Wie lange die täglichen Aggregat-Statistiken (ohne Einzel-Events) gespeichert bleiben.
*/
aggregateRetentionMonths: number;
updatedAt?: string | null;
createdAt?: string | null;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "home_select".
@@ -1575,6 +1769,47 @@ export interface SeoSettingsSelect<T extends boolean = true> {
createdAt?: T;
globalType?: T;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "cookie-settings_select".
*/
export interface CookieSettingsSelect<T extends boolean = true> {
consentVersion?: T;
necessary?:
| T
| {
label?: T;
description?: T;
};
preferences?:
| T
| {
label?: T;
description?: T;
enabled?: T;
};
statistics?:
| T
| {
label?: T;
description?: T;
enabled?: T;
};
updatedAt?: T;
createdAt?: T;
globalType?: T;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "analytics-settings_select".
*/
export interface AnalyticsSettingsSelect<T extends boolean = true> {
rawRetentionDays?: T;
aggregateRetentionMonths?: T;
updatedAt?: T;
createdAt?: T;
globalType?: T;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "collections_widget".