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,52 @@
|
||||
import type { CollectionConfig } from "payload";
|
||||
import { canViewAnalytics } from "@/access";
|
||||
|
||||
/**
|
||||
* One row per calendar day, upserted by the cleanup cron
|
||||
* (lib/analytics/cleanup.ts) from raw analytics-events BEFORE those raw rows
|
||||
* age out. This is what lets aggregated history survive well beyond the raw
|
||||
* retention window (default 90 days raw vs. 12 months aggregated — see
|
||||
* globals/AnalyticsSettings.ts) without keeping individual events around.
|
||||
*
|
||||
* Not currently read by the main Analytics dashboard filters (those all
|
||||
* stay within raw retention by design — see the implementation plan); this
|
||||
* exists so long-term aggregated history isn't silently lost once raw
|
||||
* events are purged, and is available for a future longer-range view.
|
||||
*/
|
||||
export const AnalyticsDailyStats: CollectionConfig = {
|
||||
slug: "analytics-daily-stats",
|
||||
labels: {
|
||||
singular: "Analytics-Tagesstatistik",
|
||||
plural: "Analytics-Tagesstatistiken",
|
||||
},
|
||||
admin: {
|
||||
hidden: true,
|
||||
},
|
||||
access: {
|
||||
create: () => false,
|
||||
read: canViewAnalytics,
|
||||
update: () => false,
|
||||
delete: () => false,
|
||||
},
|
||||
timestamps: true,
|
||||
fields: [
|
||||
{
|
||||
name: "date",
|
||||
type: "date",
|
||||
required: true,
|
||||
unique: true,
|
||||
index: true,
|
||||
admin: { date: { pickerAppearance: "dayOnly" } },
|
||||
},
|
||||
{ name: "sessions", type: "number", required: true, defaultValue: 0 },
|
||||
{ name: "pageViews", type: "number", required: true, defaultValue: 0 },
|
||||
{ name: "uniqueVisitors", type: "number", required: true, defaultValue: 0 },
|
||||
// Each a { [key: string]: number } map, e.g. eventCounts.booking_started.
|
||||
{ name: "eventCounts", type: "json" },
|
||||
{ name: "deviceCounts", type: "json" },
|
||||
{ name: "browserCounts", type: "json" },
|
||||
{ name: "topPaths", type: "json" },
|
||||
{ name: "entryPaths", type: "json" },
|
||||
{ name: "exitPaths", type: "json" },
|
||||
],
|
||||
};
|
||||
@@ -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" },
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -45,6 +45,22 @@ export const Users: CollectionConfig = {
|
||||
update: isAdminFieldLevel,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "canViewAnalytics",
|
||||
type: "checkbox",
|
||||
label: "Analytics sichtbar",
|
||||
defaultValue: true,
|
||||
// Admins always have access regardless of this flag (see
|
||||
// access/index.ts's canViewAnalytics) — this only matters once a
|
||||
// non-admin role exists, letting an admin grant/revoke Analytics
|
||||
// visibility per account without changing anyone's role.
|
||||
access: {
|
||||
update: isAdminFieldLevel,
|
||||
},
|
||||
admin: {
|
||||
description: "Steuert, ob dieser Zugang das Analytics-Dashboard im Admin-Bereich sehen kann.",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "calendarFeedTokenHash",
|
||||
type: "text",
|
||||
|
||||
Reference in New Issue
Block a user