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,92 @@
|
||||
import type { CollectionConfig } from "payload";
|
||||
import { canViewAnalytics } from "@/access";
|
||||
|
||||
/**
|
||||
* One row per tracked event (page_view, booking_started, ...). Deliberately
|
||||
* append-only and never writable via the public/admin API — the only writer
|
||||
* is app/(payload)/api/analytics/track/route.ts, which uses the trusted
|
||||
* Local API (overrideAccess: true by default) to insert rows despite
|
||||
* `create` being locked to `() => false` here. The only deleter is the
|
||||
* cleanup cron (lib/analytics/cleanup.ts), same mechanism.
|
||||
*
|
||||
* No name/email/IP/UA is ever stored — see lib/analytics/salt.ts for how
|
||||
* `visitorHash` is derived without persisting anything identifying.
|
||||
*/
|
||||
export const AnalyticsEvents: CollectionConfig = {
|
||||
slug: "analytics-events",
|
||||
labels: {
|
||||
singular: "Analytics-Event",
|
||||
plural: "Analytics-Events",
|
||||
},
|
||||
admin: {
|
||||
// High-volume, low-signal raw data — not meant for manual browsing.
|
||||
// The Analytics dashboard (a custom admin view) reads it programmatically.
|
||||
hidden: true,
|
||||
},
|
||||
access: {
|
||||
create: () => false,
|
||||
read: canViewAnalytics,
|
||||
update: () => false,
|
||||
delete: () => false,
|
||||
},
|
||||
timestamps: true,
|
||||
indexes: [{ fields: ["visitorHash", "createdAt"] }, { fields: ["createdAt"] }],
|
||||
fields: [
|
||||
{
|
||||
name: "sessionId",
|
||||
type: "text",
|
||||
required: true,
|
||||
index: true,
|
||||
},
|
||||
{
|
||||
name: "visitorHash",
|
||||
type: "text",
|
||||
required: true,
|
||||
// Daily-rotating pseudonymous hash (see lib/analytics/salt.ts) — never
|
||||
// a raw IP/UA, never stable across days, never exposed to the client.
|
||||
},
|
||||
{
|
||||
name: "path",
|
||||
type: "text",
|
||||
required: true,
|
||||
index: true,
|
||||
},
|
||||
{
|
||||
name: "eventName",
|
||||
type: "text",
|
||||
required: true,
|
||||
index: true,
|
||||
// Deliberately free-text, not a select/enum: new event names (see
|
||||
// lib/analytics/events.ts) never require a schema migration.
|
||||
defaultValue: "page_view",
|
||||
},
|
||||
{
|
||||
name: "referrerHost",
|
||||
type: "text",
|
||||
// Hostname only (never a full URL/query string). document.referrer is
|
||||
// fixed for a tab's whole lifetime (it doesn't change on client-side
|
||||
// route transitions), so this naturally reflects how the visitor
|
||||
// originally arrived, sent unchanged with every event in the session.
|
||||
},
|
||||
{
|
||||
name: "device",
|
||||
type: "select",
|
||||
options: [
|
||||
{ label: "Mobile", value: "mobile" },
|
||||
{ label: "Desktop", value: "desktop" },
|
||||
{ label: "Tablet", value: "tablet" },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "browser",
|
||||
type: "select",
|
||||
options: [
|
||||
{ label: "Chrome", value: "chrome" },
|
||||
{ label: "Firefox", value: "firefox" },
|
||||
{ label: "Safari", value: "safari" },
|
||||
{ label: "Edge", value: "edge" },
|
||||
{ label: "Sonstige", value: "other" },
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
Reference in New Issue
Block a user