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
+5
View File
@@ -25,6 +25,11 @@ PAYLOAD_SECRET=replace-with-a-long-random-secret
# Generate with: openssl rand -hex 32
MEETING_SESSION_SECRET=replace-with-a-long-random-secret
# Salts the daily-rotating, non-reversible visitor hash used for Analytics
# session grouping (kept separate from PAYLOAD_SECRET on purpose, see
# lib/analytics/salt.ts). Generate with: openssl rand -hex 32
ANALYTICS_SALT_SECRET=replace-with-a-long-random-secret
# Sent as "Authorization: Bearer <value>" by the external cron job that
# triggers /api/cron/event-reminders. Generate with: openssl rand -hex 32
CRON_SECRET=replace-with-a-long-random-secret
+1 -1
View File
@@ -150,7 +150,7 @@ See `.env.example` for the full, commented list. `setup.sh` writes the real file
| Variable | Purpose |
| --- | --- |
| `DATABASE_URI`, `POSTGRES_*` | Postgres connection |
| `PAYLOAD_SECRET`, `MEETING_SESSION_SECRET`, `CRON_SECRET` | Auto-generated, never overwritten afterwards |
| `PAYLOAD_SECRET`, `MEETING_SESSION_SECRET`, `ANALYTICS_SALT_SECRET`, `CRON_SECRET` | Auto-generated, never overwritten afterwards |
| `NEXT_PUBLIC_SERVER_URL` | Public URL of the site |
| `SMTP_*` | The existing ANOUMA mail system |
| `STUN_SERVER`, `TURN_SERVER`, `TURN_USERNAME`, `TURN_PASSWORD` | WebRTC ICE servers |
+13 -3
View File
@@ -2,10 +2,11 @@ import type { Metadata } from "next";
import { Cormorant_Garamond, Inter } from "next/font/google";
import { Navbar } from "@/components/Navbar";
import { Footer } from "@/components/Footer";
import { Analytics } from "@/components/Analytics";
import { siteConfig } from "@/lib/site";
import { getOffers } from "@/lib/payload/content";
import { getCurrentCustomer } from "@/lib/auth/customer";
import { getBookingSettingsGlobal, getContactGlobal, getSEOSettingsGlobal } from "@/lib/payload/globals";
import { getBookingSettingsGlobal, getContactGlobal, getCookieSettingsGlobal, getSEOSettingsGlobal } from "@/lib/payload/globals";
import { getSiteUrl } from "@/lib/seo/config";
import { JsonLd, organizationOrLocalBusinessJsonLd, websiteJsonLd } from "@/lib/seo/jsonld";
import "./globals.css";
@@ -30,7 +31,7 @@ export const metadata: Metadata = {
metadataBase: new URL(siteConfig.domain),
title: {
default: siteConfig.title,
template: `%s — ${siteConfig.name}`,
template: `%s – ${siteConfig.name}`,
},
description: siteConfig.description,
openGraph: {
@@ -51,13 +52,14 @@ export const metadata: Metadata = {
};
export default async function RootLayout({ children }: LayoutProps<"/">) {
const [offers, customer, seoSettings, bookingSettings, contact, siteUrl] = await Promise.all([
const [offers, customer, seoSettings, bookingSettings, contact, siteUrl, cookieSettings] = await Promise.all([
getOffers(),
getCurrentCustomer(),
getSEOSettingsGlobal(),
getBookingSettingsGlobal(),
getContactGlobal(),
getSiteUrl(),
getCookieSettingsGlobal(),
]);
return (
@@ -84,6 +86,14 @@ export default async function RootLayout({ children }: LayoutProps<"/">) {
{children}
</main>
<Footer />
<Analytics
consentVersion={cookieSettings.consentVersion}
preferencesEnabled={cookieSettings.preferences?.enabled !== false}
statisticsEnabled={cookieSettings.statistics?.enabled !== false}
necessary={{ label: cookieSettings.necessary?.label ?? "Notwendig", description: cookieSettings.necessary?.description ?? "" }}
preferences={{ label: cookieSettings.preferences?.label ?? "Präferenzen", description: cookieSettings.preferences?.description ?? "" }}
statistics={{ label: cookieSettings.statistics?.label ?? "Statistik", description: cookieSettings.statistics?.description ?? "" }}
/>
</body>
</html>
);
@@ -0,0 +1,97 @@
import { NextResponse } from "next/server";
import { getPayload } from "payload";
import config from "@payload-config";
import { checkRateLimit, getClientIp } from "@/lib/auth/rateLimit";
import { visitorHash } from "@/lib/analytics/salt";
import { classifyBrowser, classifyDevice } from "@/lib/analytics/userAgent";
import { resolveSessionId } from "@/lib/analytics/session";
export const dynamic = "force-dynamic";
const MAX_PATH_LENGTH = 512;
const MAX_EVENT_NAME_LENGTH = 100;
function normalizePath(value: unknown): string | null {
if (typeof value !== "string" || !value.startsWith("/") || value.length > MAX_PATH_LENGTH) return null;
return value;
}
function normalizeEventName(value: unknown): string | null {
if (typeof value !== "string") return null;
const trimmed = value.trim();
if (!trimmed || trimmed.length > MAX_EVENT_NAME_LENGTH) return null;
return trimmed;
}
/** Hostname only (never path/query) — see collections/AnalyticsEvents.ts. */
function referrerHostFrom(referrer: unknown): string | undefined {
if (typeof referrer !== "string" || !referrer) return undefined;
try {
return new URL(referrer).hostname || undefined;
} catch {
return undefined;
}
}
/**
* Public tracking beacon, called by lib/analytics/client.ts only after the
* visitor has granted statistics consent — but the client's own claim is
* never trusted alone: this route re-checks the CookieSettings global's
* `statistics.enabled` flag server-side before writing anything, so a
* global kill-switch (or a stale/tampered client) can never bypass it.
*
* navigator.sendBeacon() can't reliably set a JSON Content-Type across
* browsers, so this accepts both application/json and a raw text body.
*/
export async function POST(request: Request) {
const ip = getClientIp(request);
if (!checkRateLimit(`analytics:${ip}`, { max: 120, windowMs: 60_000 })) {
return new NextResponse(null, { status: 204 });
}
let body: { eventName?: unknown; path?: unknown; referrer?: unknown };
try {
const raw = await request.text();
body = JSON.parse(raw);
} catch {
return new NextResponse(null, { status: 204 });
}
const path = normalizePath(body.path);
const eventName = normalizeEventName(body.eventName);
if (!path || !eventName) {
return new NextResponse(null, { status: 204 });
}
try {
const payload = await getPayload({ config });
const cookieSettings = await payload.findGlobal({ slug: "cookie-settings" });
if (cookieSettings.statistics?.enabled === false) {
return new NextResponse(null, { status: 204 });
}
const userAgent = request.headers.get("user-agent") ?? "";
const host = request.headers.get("host") ?? "";
const hash = visitorHash({ ip, userAgent, host });
const sessionId = await resolveSessionId(payload, hash);
await payload.create({
collection: "analytics-events",
data: {
sessionId,
visitorHash: hash,
path,
eventName,
referrerHost: referrerHostFrom(body.referrer),
device: classifyDevice(userAgent),
browser: classifyBrowser(userAgent),
},
});
return new NextResponse(null, { status: 204 });
} catch (error) {
console.error("analytics track failed", error);
return new NextResponse(null, { status: 204 });
}
}
@@ -0,0 +1,41 @@
import { timingSafeEqual } from "node:crypto";
import { NextResponse } from "next/server";
import { getPayload } from "payload";
import config from "@payload-config";
import { runAnalyticsCleanup } from "@/lib/analytics/cleanup";
export const dynamic = "force-dynamic";
function safeEqual(a: string, b: string): boolean {
const bufA = Buffer.from(a);
const bufB = Buffer.from(b);
if (bufA.length !== bufB.length) return false;
return timingSafeEqual(bufA, bufB);
}
/**
* Intended to be called by an external cron job once daily, e.g.:
* curl -H "Authorization: Bearer $CRON_SECRET" https://anouma.org/api/cron/analytics-cleanup
* (a second, separate cron entry from /api/cron/event-reminders — reuses
* the same CRON_SECRET, no new secret needed)
*/
export async function GET(request: Request) {
const secret = process.env.CRON_SECRET;
if (!secret) {
return NextResponse.json({ error: "CRON_SECRET is not configured" }, { status: 500 });
}
const authHeader = request.headers.get("authorization");
if (!authHeader || !safeEqual(authHeader, `Bearer ${secret}`)) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
try {
const payload = await getPayload({ config });
const result = await runAnalyticsCleanup(payload);
return NextResponse.json({ ok: true, ...result });
} catch (error) {
console.error("analytics-cleanup cron failed", error);
return NextResponse.json({ error: "Internal error" }, { status: 500 });
}
}
+52
View File
@@ -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" },
],
};
+92
View File
@@ -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" },
],
},
],
};
+16
View File
@@ -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",
+153
View File
@@ -0,0 +1,153 @@
"use client";
import { useEffect, useState } from "react";
import { usePathname } from "next/navigation";
import { getConsent, hasCurrentConsentDecision, setAnalyticsConfig, setConsent, track } from "@/lib/analytics/client";
type CategoryText = { label: string; description: string };
type AnalyticsProps = {
consentVersion: string;
preferencesEnabled: boolean;
statisticsEnabled: boolean;
necessary: CategoryText;
preferences: CategoryText;
statistics: CategoryText;
};
const buttonBase =
"inline-flex items-center justify-center rounded-full px-6 py-3 text-sm font-medium tracking-wide transition-colors duration-300 focus-visible:outline-2 focus-visible:outline-offset-4";
/**
* Mounted once in app/(frontend)/layout.tsx. Combines two independent jobs:
* (1) the cookie-consent banner, and (2) automatic page_view tracking on
* every client-side route change. Both only ever act through track()/
* setAnalyticsConfig() from lib/analytics/client.ts — no network call is
* possible from this component before a visitor has actually consented.
*/
export function Analytics({ consentVersion, preferencesEnabled, statisticsEnabled, necessary, preferences, statistics }: AnalyticsProps) {
// Plain assignment during render (not an effect): must be set before the
// page_view effect below ever fires, on every render including the first.
setAnalyticsConfig({ statisticsEnabled, consentVersion });
const pathname = usePathname();
const [showBanner, setShowBanner] = useState(false);
const [expanded, setExpanded] = useState(false);
const [prefChoice, setPrefChoice] = useState(false);
const [statChoice, setStatChoice] = useState(false);
useEffect(() => {
if (!hasCurrentConsentDecision()) {
// eslint-disable-next-line react-hooks/set-state-in-effect
setShowBanner(true);
const existing = getConsent();
setPrefChoice(existing?.preferences ?? false);
setStatChoice(existing?.statistics ?? false);
}
}, [consentVersion]);
useEffect(() => {
track("page_view", { path: pathname });
}, [pathname]);
function acceptAll() {
setConsent({ preferences: preferencesEnabled, statistics: statisticsEnabled }, consentVersion);
setShowBanner(false);
}
function acceptNecessaryOnly() {
setConsent({ preferences: false, statistics: false }, consentVersion);
setShowBanner(false);
}
function saveSelection() {
setConsent({ preferences: preferencesEnabled && prefChoice, statistics: statisticsEnabled && statChoice }, consentVersion);
setShowBanner(false);
}
if (!showBanner) return null;
return (
<div
role="dialog"
aria-modal="false"
aria-label="Cookie-Einstellungen"
className="fixed inset-x-0 bottom-0 z-[90] border-t border-anouma-taupe/30 bg-anouma-cream-light px-6 py-6 shadow-[0_-4px_24px_rgba(0,0,0,0.08)] sm:px-8"
>
<div className="mx-auto flex max-w-4xl flex-col gap-4">
<div>
<h2 className="font-serif text-xl font-medium text-anouma-plum">Cookie-Einstellungen</h2>
<p className="mt-2 text-sm leading-relaxed text-anouma-plum/90">
Wir nutzen Cookies, um die Website technisch bereitzustellen{statisticsEnabled ? " und, mit deiner Zustimmung, datensparsame Statistiken zu erheben" : ""}. Du
kannst deine Auswahl jederzeit ändern.
</p>
</div>
{expanded && (
<div className="grid gap-3 rounded-2xl border border-anouma-taupe/30 bg-white/60 p-4 sm:grid-cols-3">
<label className="flex items-start gap-2 text-sm text-anouma-plum opacity-70">
<input type="checkbox" checked disabled className="mt-1" />
<span>
<strong className="block">{necessary.label}</strong>
{necessary.description}
</span>
</label>
{preferencesEnabled && (
<label className="flex items-start gap-2 text-sm text-anouma-plum">
<input
type="checkbox"
checked={prefChoice}
onChange={(e) => setPrefChoice(e.target.checked)}
className="mt-1"
/>
<span>
<strong className="block">{preferences.label}</strong>
{preferences.description}
</span>
</label>
)}
{statisticsEnabled && (
<label className="flex items-start gap-2 text-sm text-anouma-plum">
<input
type="checkbox"
checked={statChoice}
onChange={(e) => setStatChoice(e.target.checked)}
className="mt-1"
/>
<span>
<strong className="block">{statistics.label}</strong>
{statistics.description}
</span>
</label>
)}
</div>
)}
<div className="flex flex-wrap items-center gap-3">
<button type="button" onClick={acceptAll} className={`${buttonBase} bg-anouma-mauve-dark text-white hover:bg-anouma-plum`}>
Alle akzeptieren
</button>
<button
type="button"
onClick={acceptNecessaryOnly}
className={`${buttonBase} border border-anouma-mauve-dark/40 text-anouma-plum hover:bg-anouma-mauve-dark hover:text-white`}
>
Nur notwendige
</button>
{!expanded && (
<button type="button" onClick={() => setExpanded(true)} className="text-sm text-anouma-plum underline underline-offset-4">
Einstellungen anpassen
</button>
)}
{expanded && (
<button type="button" onClick={saveSelection} className={`${buttonBase} border border-anouma-mauve-dark/40 text-anouma-plum hover:bg-anouma-mauve-dark hover:text-white`}>
Auswahl speichern
</button>
)}
</div>
</div>
</div>
);
}
@@ -0,0 +1,188 @@
.page {
padding: 24px;
max-width: 1100px;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
color: #1f1f1f;
}
.title {
font-size: 22px;
font-weight: 600;
margin: 0 0 4px;
}
.subtitle {
color: #666;
font-size: 14px;
margin: 0 0 24px;
}
.filterBar {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px;
margin-bottom: 24px;
}
.rangeLink {
display: inline-flex;
align-items: center;
padding: 6px 14px;
border-radius: 999px;
border: 1px solid #e2e2e2;
color: #333;
text-decoration: none;
font-size: 13px;
font-weight: 500;
}
.rangeLink:hover {
background: #f5f5f5;
}
.rangeLinkActive {
background: #8b616d;
border-color: #8b616d;
color: #fff;
}
.customForm {
display: flex;
align-items: center;
gap: 6px;
font-size: 13px;
color: #555;
}
.customForm input[type="date"] {
border: 1px solid #e2e2e2;
border-radius: 6px;
padding: 5px 8px;
font-size: 13px;
}
.customForm button {
border: 1px solid #e2e2e2;
border-radius: 999px;
padding: 6px 14px;
font-size: 13px;
background: #fff;
cursor: pointer;
}
.compareToggle {
margin-left: auto;
font-size: 13px;
}
.compareToggle a {
color: #8b616d;
text-decoration: none;
}
.compareToggle a:hover {
text-decoration: underline;
}
.tiles {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
gap: 12px;
margin-bottom: 28px;
}
.tile {
border: 1px solid #e6e6e6;
border-radius: 12px;
padding: 14px 16px;
background: #fafafa;
}
.tileLabel {
font-size: 12px;
color: #888;
margin: 0 0 6px;
}
.tileValue {
font-size: 24px;
font-weight: 700;
margin: 0;
}
.tileDelta {
font-size: 12px;
font-weight: 600;
margin-top: 4px;
}
.deltaUp {
color: #3a4f30;
}
.deltaDown {
color: #7a2020;
}
.deltaFlat {
color: #888;
}
.chartCard {
border: 1px solid #e6e6e6;
border-radius: 12px;
padding: 18px;
margin-bottom: 28px;
}
.sectionTitle {
font-size: 15px;
font-weight: 600;
margin: 0 0 12px;
}
.tableGrid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
gap: 20px;
margin-bottom: 28px;
}
.table {
width: 100%;
border-collapse: collapse;
font-size: 13px;
}
.table th {
text-align: left;
font-size: 11px;
text-transform: uppercase;
color: #888;
padding: 4px 8px;
border-bottom: 1px solid #e6e6e6;
}
.table td {
padding: 6px 8px;
border-bottom: 1px solid #f0f0f0;
}
.table tr:last-child td {
border-bottom: none;
}
.tableEmpty {
color: #888;
font-size: 13px;
padding: 8px;
}
.privacyPanel {
border: 1px solid #e6e6e6;
border-radius: 12px;
padding: 18px;
background: #f7f4f1;
font-size: 13px;
color: #444;
}
.privacyPanel h3 {
margin: 0 0 8px;
font-size: 14px;
color: #1f1f1f;
}
.privacyPanel ul {
margin: 8px 0;
padding-left: 18px;
}
.privacyPanel p {
margin: 6px 0;
}
+196
View File
@@ -0,0 +1,196 @@
import type { AdminViewServerProps } from "payload";
import { canViewAnalytics } from "@/access";
import { RANGE_OPTIONS, resolveRange } from "@/lib/analytics/range";
import { getDashboardComparison, percentDelta, type DashboardStats } from "@/lib/analytics/dashboardQueries";
import { topEntries } from "@/lib/analytics/aggregate";
import { analyticsEventLabel } from "@/lib/analytics/events";
import { AnalyticsChart } from "./AnalyticsChart";
import styles from "./AdminAnalyticsView.module.css";
const DEVICE_LABELS: Record<string, string> = { mobile: "Mobile", desktop: "Desktop", tablet: "Tablet" };
const BROWSER_LABELS: Record<string, string> = { chrome: "Chrome", firefox: "Firefox", safari: "Safari", edge: "Edge", other: "Sonstige" };
function fmtInt(n: number): string {
return n.toLocaleString("de-DE");
}
function DeltaBadge({ delta }: { delta: number | null }) {
if (delta === null) return null;
const cls = delta > 0 ? styles.deltaUp : delta < 0 ? styles.deltaDown : styles.deltaFlat;
const arrow = delta > 0 ? "↑" : delta < 0 ? "↓" : "→";
return (
<p className={`${styles.tileDelta} ${cls}`}>
{arrow} {Math.abs(delta).toLocaleString("de-DE")}%
</p>
);
}
function Tile({ label, value, delta }: { label: string; value: number; delta: number | null | undefined }) {
return (
<div className={styles.tile}>
<p className={styles.tileLabel}>{label}</p>
<p className={styles.tileValue}>{fmtInt(value)}</p>
{delta !== undefined && <DeltaBadge delta={delta} />}
</div>
);
}
function EntryTable({ title, entries }: { title: string; entries: [string, number][] }) {
return (
<div>
<h2 className={styles.sectionTitle}>{title}</h2>
{entries.length === 0 ? (
<p className={styles.tableEmpty}>Keine Daten in diesem Zeitraum.</p>
) : (
<table className={styles.table}>
<tbody>
{entries.map(([key, count]) => (
<tr key={key}>
<td>{key}</td>
<td style={{ textAlign: "right", fontWeight: 600 }}>{fmtInt(count)}</td>
</tr>
))}
</tbody>
</table>
)}
</div>
);
}
function buildHref(base: URLSearchParams, overrides: Record<string, string | null>): string {
const params = new URLSearchParams(base);
for (const [key, value] of Object.entries(overrides)) {
if (value === null) params.delete(key);
else params.set(key, value);
}
const qs = params.toString();
return qs ? `?${qs}` : "?";
}
export async function AdminAnalyticsView({ initPageResult }: AdminViewServerProps) {
const req = initPageResult.req;
if (!canViewAnalytics({ req })) {
return (
<div className={styles.page}>
<h1 className={styles.title}>Analytics</h1>
<p className={styles.subtitle}>Du hast keine Berechtigung, diesen Bereich zu sehen.</p>
</div>
);
}
const settings = await req.payload.findGlobal({ slug: "analytics-settings" });
const maxDaysBack = settings.rawRetentionDays ?? 90;
const searchParams = req.searchParams;
const compareEnabled = searchParams.get("compare") === "1";
const { key: activeRange, range, compareRange } = resolveRange(searchParams, maxDaysBack);
const { current, previous }: { current: DashboardStats; previous: DashboardStats } = await getDashboardComparison(
req.payload,
range,
compareRange,
);
const delta = (metric: keyof Pick<DashboardStats, "sessions" | "pageViews" | "uniqueVisitors" | "avgPagesPerSession">) =>
compareEnabled ? percentDelta(current[metric], previous[metric]) : undefined;
const eventDelta = (name: string) =>
compareEnabled ? percentDelta(current.eventCounts[name] ?? 0, previous.eventCounts[name] ?? 0) : undefined;
const fromParam = range.from.toISOString().slice(0, 10);
const toParam = range.to.toISOString().slice(0, 10);
return (
<div className={styles.page}>
<h1 className={styles.title}>Analytics</h1>
<p className={styles.subtitle}>Datensparsame Reichweitenmessung — keine personenbezogenen Profile. Rohdaten werden {maxDaysBack} Tage aufbewahrt.</p>
<div className={styles.filterBar}>
{RANGE_OPTIONS.map((option) => (
<a
key={option.key}
href={buildHref(searchParams, { range: option.key, from: null, to: null })}
className={`${styles.rangeLink} ${activeRange === option.key ? styles.rangeLinkActive : ""}`}
>
{option.label}
</a>
))}
<form method="get" className={styles.customForm}>
<input type="hidden" name="range" value="custom" />
{compareEnabled && <input type="hidden" name="compare" value="1" />}
<input type="date" name="from" defaultValue={fromParam} max={toParam} />
<span>bis</span>
<input type="date" name="to" defaultValue={toParam} max={new Date().toISOString().slice(0, 10)} />
<button type="submit">Anwenden</button>
</form>
<div className={styles.compareToggle}>
<a href={buildHref(searchParams, { compare: compareEnabled ? null : "1" })}>
{compareEnabled ? "Vergleich ausblenden" : "Mit Vorperiode vergleichen"}
</a>
</div>
</div>
{RANGE_OPTIONS.some((o) => typeof o.days === "number" && o.days > maxDaysBack) && (
<p style={{ color: "#888", fontSize: 12, marginTop: -16, marginBottom: 20 }}>
Zeiträume sind auf die konfigurierte Rohdaten-Aufbewahrung von {maxDaysBack} Tagen begrenzt (siehe „Analytics-Einstellungen“).
</p>
)}
<div className={styles.tiles}>
<Tile label="Sessions" value={current.sessions} delta={delta("sessions")} />
<Tile label="Seitenaufrufe" value={current.pageViews} delta={delta("pageViews")} />
<Tile label="Eindeutige Besucher" value={current.uniqueVisitors} delta={delta("uniqueVisitors")} />
<Tile label="Ø Seiten / Session" value={current.avgPagesPerSession} delta={delta("avgPagesPerSession")} />
<Tile label="Buchungsstarts" value={current.eventCounts.booking_started ?? 0} delta={eventDelta("booking_started")} />
<Tile label="Buchungen abgeschlossen" value={current.eventCounts.booking_completed ?? 0} delta={eventDelta("booking_completed")} />
</div>
<div className={styles.chartCard}>
<h2 className={styles.sectionTitle}>Seitenaufrufe &amp; Sessions</h2>
<AnalyticsChart series={current.series} />
</div>
<div className={styles.tableGrid}>
<EntryTable title="Beliebteste Seiten" entries={topEntries(current.topPaths)} />
<EntryTable title="Einstiegsseiten" entries={topEntries(current.entryPaths)} />
<EntryTable title="Ausstiegsseiten" entries={topEntries(current.exitPaths)} />
<EntryTable
title="Events"
entries={topEntries(current.eventCounts).map(([name, count]) => [analyticsEventLabel(name), count])}
/>
<EntryTable
title="Geräte"
entries={topEntries(current.deviceCounts).map(([key, count]) => [DEVICE_LABELS[key] ?? key, count])}
/>
<EntryTable
title="Browser"
entries={topEntries(current.browserCounts).map(([key, count]) => [BROWSER_LABELS[key] ?? key, count])}
/>
</div>
<div className={styles.privacyPanel}>
<h3>Analytics &amp; Datenschutz</h3>
<p>Erhobene Daten:</p>
<ul>
<li>Seitenaufrufe und Events (siehe „Events“-Tabelle)</li>
<li>Gerätetyp (Mobile/Desktop/Tablet)</li>
<li>Browser-Familie</li>
<li>Referrer-Hostname (nur die Domain, keine vollständige URL)</li>
</ul>
<p>
Keine Namen, E-Mail-Adressen, Telefonnummern, vollständigen IP-Adressen oder Formularinhalte. Kein Fingerprinting, keine
dauerhaften individuellen Nutzerprofile — Besucher:innen werden über einen täglich rotierenden, nicht zurückverfolgbaren Hash
gruppiert.
</p>
<p>
<strong>Aufbewahrung:</strong> {settings.rawRetentionDays} Tage für Rohdaten, {settings.aggregateRetentionMonths} Monate für
aggregierte Tagesstatistiken (konfigurierbar unter „Analytics-Einstellungen“).
</p>
<p style={{ fontStyle: "italic", color: "#777" }}>
Rechtliche Einordnung (z. B. ob dies für eure Website ausreichend ist) ist keine technische Aussage dieses Panels — bitte im
Zweifel rechtlich prüfen lassen, wie auch beim Impressum/Datenschutztext der Website.
</p>
</div>
</div>
);
}
+35
View File
@@ -0,0 +1,35 @@
"use client";
import { CartesianGrid, Line, LineChart, ResponsiveContainer, Tooltip, XAxis, YAxis } from "recharts";
import type { DailyPoint } from "@/lib/analytics/dashboardQueries";
const ACCENT = "#8b616d"; // matches the existing admin CSS modules' accent color
const ACCENT_MUTED = "#c9a8ae";
function fmtDay(iso: string): string {
return new Date(`${iso}T00:00:00Z`).toLocaleDateString("de-DE", { day: "2-digit", month: "2-digit" });
}
export function AnalyticsChart({ series }: { series: DailyPoint[] }) {
if (series.length === 0) {
return <p style={{ color: "#888", fontSize: 14 }}>Noch keine Daten in diesem Zeitraum.</p>;
}
const data = series.map((p) => ({ ...p, label: fmtDay(p.date) }));
return (
<ResponsiveContainer width="100%" height={260}>
<LineChart data={data} margin={{ top: 8, right: 16, bottom: 0, left: 0 }}>
<CartesianGrid stroke="#eee" vertical={false} />
<XAxis dataKey="label" tick={{ fontSize: 12, fill: "#888" }} axisLine={{ stroke: "#e2e2e2" }} tickLine={false} />
<YAxis tick={{ fontSize: 12, fill: "#888" }} axisLine={false} tickLine={false} allowDecimals={false} width={36} />
<Tooltip
contentStyle={{ borderRadius: 8, border: "1px solid #e2e2e2", fontSize: 13 }}
labelFormatter={(label) => `Tag: ${label}`}
/>
<Line type="monotone" dataKey="pageViews" name="Seitenaufrufe" stroke={ACCENT} strokeWidth={2} dot={false} />
<Line type="monotone" dataKey="sessions" name="Sessions" stroke={ACCENT_MUTED} strokeWidth={2} dot={false} />
</LineChart>
</ResponsiveContainer>
);
}
+30
View File
@@ -0,0 +1,30 @@
import Link from "next/link";
import type { ServerProps } from "payload";
import { canViewAnalytics } from "@/access";
export function AnalyticsNavLink({ user }: ServerProps) {
// Payload's custom views/nav links have no declarative `access` option —
// this component must gate itself (see also AdminAnalyticsView.tsx, which
// re-checks the same thing for direct navigation to /admin/analytics).
if (!canViewAnalytics({ req: { user } } as Parameters<typeof canViewAnalytics>[0])) return null;
return (
<div style={{ padding: "0 8px 8px" }}>
<Link
href="/admin/analytics"
style={{
display: "block",
padding: "8px 12px",
borderRadius: 6,
fontSize: 13,
fontWeight: 600,
color: "#8b616d",
textDecoration: "none",
border: "1px solid #e2e2e2",
}}
>
📊 Analytics
</Link>
</div>
);
}
+45
View File
@@ -0,0 +1,45 @@
import type { GlobalConfig } from "payload";
import { canViewAnalytics, isAdmin } from "@/access";
/**
* Retention configuration for the analytics system, enforced by the nightly
* cleanup cron (lib/analytics/cleanup.ts) and shown in the "Analytics &
* Datenschutz" panel of the dashboard (components/admin/AdminAnalyticsView.tsx).
*/
export const AnalyticsSettings: GlobalConfig = {
slug: "analytics-settings",
label: "Analytics-Einstellungen",
admin: {
description: "Aufbewahrungsdauer für Analytics-Rohdaten und aggregierte Tagesstatistiken.",
group: "Datenschutz",
},
access: {
read: canViewAnalytics,
update: isAdmin,
},
fields: [
{
name: "rawRetentionDays",
type: "number",
label: "Rohdaten-Aufbewahrung (Tage)",
required: true,
defaultValue: 90,
min: 7,
admin: {
description:
"Wie lange einzelne Analytics-Events gespeichert bleiben. Begrenzt auch den wählbaren Zeitraum im Analytics-Dashboard.",
},
},
{
name: "aggregateRetentionMonths",
type: "number",
label: "Aggregat-Aufbewahrung (Monate)",
required: true,
defaultValue: 12,
min: 1,
admin: {
description: "Wie lange die täglichen Aggregat-Statistiken (ohne Einzel-Events) gespeichert bleiben.",
},
},
],
};
+102
View File
@@ -0,0 +1,102 @@
import type { GlobalConfig } from "payload";
import { isAdmin } from "@/access";
/**
* Consent-category configuration shown in the public cookie banner
* (components/Analytics.tsx). Read access is intentionally public — the
* unauthenticated frontend needs the labels/descriptions and the
* enabled/disabled flags before a visitor has logged in anywhere.
*
* "necessary" deliberately has no `enabled` field at all, anywhere in this
* schema — there is no toggle to accidentally switch off, so it can never
* be disabled through the admin UI (see requirement: technically required
* functionality must not be disable-able by an admin).
*/
export const CookieSettings: GlobalConfig = {
slug: "cookie-settings",
label: "Cookie-Einstellungen",
admin: {
description: "Kategorien und Texte für den Cookie-Consent-Banner der Website.",
group: "Datenschutz",
},
access: {
read: () => true,
update: isAdmin,
},
fields: [
{
name: "consentVersion",
type: "text",
label: "Consent-Version",
required: true,
defaultValue: "1.0",
admin: {
description:
"Bei Änderung müssen alle Besucher:innen erneut zustimmen — bereits gespeicherte Einwilligungen mit einer älteren Version werden ignoriert und der Banner erscheint erneut.",
},
},
{
type: "group",
name: "necessary",
label: "Notwendig",
admin: { description: "Immer aktiv — technisch erforderlich, kann nicht deaktiviert werden." },
fields: [
{ name: "label", type: "text", label: "Bezeichnung", required: true, defaultValue: "Notwendig" },
{
name: "description",
type: "textarea",
label: "Beschreibung",
required: true,
defaultValue: "Für den Betrieb der Website technisch erforderlich (z. B. Login, Sitzung). Kann nicht deaktiviert werden.",
},
],
},
{
type: "group",
name: "preferences",
label: "Präferenzen",
fields: [
{ name: "label", type: "text", label: "Bezeichnung", required: true, defaultValue: "Präferenzen" },
{
name: "description",
type: "textarea",
label: "Beschreibung",
required: true,
defaultValue: "Speichert Einstellungen wie z. B. bevorzugte Anzeigeoptionen.",
},
{
name: "enabled",
type: "checkbox",
label: "Kategorie aktiv",
defaultValue: true,
admin: { description: "Wenn deaktiviert, wird diese Kategorie im Banner nicht mehr angeboten." },
},
],
},
{
type: "group",
name: "statistics",
label: "Statistik",
fields: [
{ name: "label", type: "text", label: "Bezeichnung", required: true, defaultValue: "Statistik" },
{
name: "description",
type: "textarea",
label: "Beschreibung",
required: true,
defaultValue: "Anonymisierte, datensparsame Reichweitenmessung (Seitenaufrufe, Ereignisse) ohne personenbezogene Profile.",
},
{
name: "enabled",
type: "checkbox",
label: "Kategorie aktiv",
defaultValue: true,
admin: {
description:
"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.",
},
},
],
},
],
};
+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" });
});
+383
View File
@@ -20,6 +20,7 @@
"payload": "^3.88.0",
"react": "19.2.8",
"react-dom": "19.2.8",
"recharts": "^3.10.1",
"sharp": "^0.35.3",
"tsx": "^4.23.12",
"ws": "^8.21.3"
@@ -3509,6 +3510,32 @@
"url": "https://opencollective.com/preact"
}
},
"node_modules/@reduxjs/toolkit": {
"version": "2.12.0",
"resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.12.0.tgz",
"integrity": "sha512-KiT+RzZbp6mQET+Mg+h2c97+9j1sNflUxQkIHI7Yuzf6Peu+OYpmkn6nbHWmLLWj+1ZODUJFwGZ7gx3L9R9EOw==",
"license": "MIT",
"dependencies": {
"@standard-schema/spec": "^1.0.0",
"@standard-schema/utils": "^0.3.0",
"immer": "^11.0.0",
"redux": "^5.0.1",
"redux-thunk": "^3.1.0",
"reselect": "^5.1.0"
},
"peerDependencies": {
"react": "^16.9.0 || ^17.0.0 || ^18 || ^19",
"react-redux": "^7.2.1 || ^8.1.3 || ^9.0.0"
},
"peerDependenciesMeta": {
"react": {
"optional": true
},
"react-redux": {
"optional": true
}
}
},
"node_modules/@rtsao/scc": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@rtsao/scc/-/scc-1.1.0.tgz",
@@ -3516,6 +3543,18 @@
"dev": true,
"license": "MIT"
},
"node_modules/@standard-schema/spec": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
"integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==",
"license": "MIT"
},
"node_modules/@standard-schema/utils": {
"version": "0.3.0",
"resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz",
"integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==",
"license": "MIT"
},
"node_modules/@swc/helpers": {
"version": "0.5.23",
"resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.23.tgz",
@@ -3848,6 +3887,69 @@
"@types/node": "*"
}
},
"node_modules/@types/d3-array": {
"version": "3.2.2",
"resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz",
"integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==",
"license": "MIT"
},
"node_modules/@types/d3-color": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz",
"integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==",
"license": "MIT"
},
"node_modules/@types/d3-ease": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz",
"integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==",
"license": "MIT"
},
"node_modules/@types/d3-interpolate": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz",
"integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==",
"license": "MIT",
"dependencies": {
"@types/d3-color": "*"
}
},
"node_modules/@types/d3-path": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz",
"integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==",
"license": "MIT"
},
"node_modules/@types/d3-scale": {
"version": "4.0.9",
"resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz",
"integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==",
"license": "MIT",
"dependencies": {
"@types/d3-time": "*"
}
},
"node_modules/@types/d3-shape": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.2.0.tgz",
"integrity": "sha512-kVd74ta9eof3eJOvbNd1vGKS/XERRyQbT26Og63hIsvDO84cjD5gEOhsXf26w3FSoNlPVz84DOFcKv/oou+fMw==",
"license": "MIT",
"dependencies": {
"@types/d3-path": "*"
}
},
"node_modules/@types/d3-time": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz",
"integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==",
"license": "MIT"
},
"node_modules/@types/d3-timer": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz",
"integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==",
"license": "MIT"
},
"node_modules/@types/debug": {
"version": "4.1.13",
"resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.13.tgz",
@@ -3993,6 +4095,12 @@
"integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==",
"license": "MIT"
},
"node_modules/@types/use-sync-external-store": {
"version": "0.0.6",
"resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz",
"integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==",
"license": "MIT"
},
"node_modules/@types/whatwg-mimetype": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/@types/whatwg-mimetype/-/whatwg-mimetype-3.0.2.tgz",
@@ -5446,6 +5554,127 @@
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
"license": "MIT"
},
"node_modules/d3-array": {
"version": "3.2.4",
"resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz",
"integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==",
"license": "ISC",
"dependencies": {
"internmap": "1 - 2"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-color": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz",
"integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-ease": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz",
"integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==",
"license": "BSD-3-Clause",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-format": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz",
"integrity": "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-interpolate": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz",
"integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==",
"license": "ISC",
"dependencies": {
"d3-color": "1 - 3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-path": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz",
"integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-scale": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz",
"integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==",
"license": "ISC",
"dependencies": {
"d3-array": "2.10.0 - 3",
"d3-format": "1 - 3",
"d3-interpolate": "1.2.0 - 3",
"d3-time": "2.1.1 - 3",
"d3-time-format": "2 - 4"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-shape": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz",
"integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==",
"license": "ISC",
"dependencies": {
"d3-path": "^3.1.0"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-time": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz",
"integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==",
"license": "ISC",
"dependencies": {
"d3-array": "2 - 3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-time-format": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz",
"integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==",
"license": "ISC",
"dependencies": {
"d3-time": "1 - 3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-timer": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz",
"integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/damerau-levenshtein": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/damerau-levenshtein/-/damerau-levenshtein-1.0.8.tgz",
@@ -5549,6 +5778,12 @@
}
}
},
"node_modules/decimal.js-light": {
"version": "2.5.1",
"resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz",
"integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==",
"license": "MIT"
},
"node_modules/decode-named-character-reference": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/decode-named-character-reference/-/decode-named-character-reference-1.3.0.tgz",
@@ -6089,6 +6324,18 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/es-toolkit": {
"version": "1.52.0",
"resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.52.0.tgz",
"integrity": "sha512-XTNEJQh1tY1ZJVcf6ayP/2n4ZPyaHlW2FWs7xvw5ddPuhUVjLD3olQVQS7kf58JbAB48iL0uL/jerTrjtV3lDA==",
"license": "MIT",
"workspaces": [
"docs",
"benchmarks",
"tests/types",
"tests/browser-compat"
]
},
"node_modules/esbuild": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz",
@@ -6601,6 +6848,12 @@
"node": ">=0.10.0"
}
},
"node_modules/eventemitter3": {
"version": "5.0.4",
"resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz",
"integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==",
"license": "MIT"
},
"node_modules/fast-copy": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/fast-copy/-/fast-copy-3.0.2.tgz",
@@ -7278,6 +7531,16 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/immer": {
"version": "11.1.18",
"resolved": "https://registry.npmjs.org/immer/-/immer-11.1.18.tgz",
"integrity": "sha512-EQyQtLiYW029lyoczMl/Hh4Xu7cDecSc58JRYpHyL4tIAu3eqd1yJzQX04d2BZHDkzFFvm6qJEJWOtfDSWAXbQ==",
"license": "MIT",
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/immer"
}
},
"node_modules/immutable": {
"version": "4.3.9",
"resolved": "https://registry.npmjs.org/immutable/-/immutable-4.3.9.tgz",
@@ -7325,6 +7588,15 @@
"node": ">= 0.4"
}
},
"node_modules/internmap": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz",
"integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/ipaddr.js": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-2.2.0.tgz",
@@ -10703,6 +10975,29 @@
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
"license": "MIT"
},
"node_modules/react-redux": {
"version": "9.3.0",
"resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.3.0.tgz",
"integrity": "sha512-KQopgqFo/p/fgmAs5qz6p5RWaNAzq40WAu7fJIXnQpYxFPbJYtsJPWvGeF2rOBaY/kEuV77AVsX8TsQzKm+A/g==",
"license": "MIT",
"dependencies": {
"@types/use-sync-external-store": "^0.0.6",
"use-sync-external-store": "^1.4.0"
},
"peerDependencies": {
"@types/react": "^18.2.25 || ^19",
"react": "^18.0 || ^19",
"redux": "^5.0.0"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"redux": {
"optional": true
}
}
},
"node_modules/react-select": {
"version": "5.9.0",
"resolved": "https://registry.npmjs.org/react-select/-/react-select-5.9.0.tgz",
@@ -10761,6 +11056,51 @@
"node": ">= 12.13.0"
}
},
"node_modules/recharts": {
"version": "3.10.1",
"resolved": "https://registry.npmjs.org/recharts/-/recharts-3.10.1.tgz",
"integrity": "sha512-QXFrvt6IVcw7eeZCoyXTwkIJAX3Dv1nyVhMicXJ47GsGDDpcN8z6o644DibE9XjpBTThtsomLKnTV6lc+cVFUA==",
"license": "MIT",
"workspaces": [
"www"
],
"dependencies": {
"@reduxjs/toolkit": "^1.9.0 || 2.x.x",
"clsx": "^2.1.1",
"decimal.js-light": "^2.5.1",
"es-toolkit": "^1.39.3",
"eventemitter3": "^5.0.1",
"immer": "^11.1.8",
"react-redux": "8.x.x || 9.x.x",
"reselect": "5.2.0",
"tiny-invariant": "^1.3.3",
"use-sync-external-store": "^1.2.2",
"victory-vendor": "^37.0.2"
},
"engines": {
"node": ">=18"
},
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"react-is": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/redux": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz",
"integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==",
"license": "MIT"
},
"node_modules/redux-thunk": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/redux-thunk/-/redux-thunk-3.1.0.tgz",
"integrity": "sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==",
"license": "MIT",
"peerDependencies": {
"redux": "^5.0.0"
}
},
"node_modules/reflect.getprototypeof": {
"version": "1.0.10",
"resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz",
@@ -10814,6 +11154,12 @@
"node": ">=0.10.0"
}
},
"node_modules/reselect": {
"version": "5.2.0",
"resolved": "https://registry.npmjs.org/reselect/-/reselect-5.2.0.tgz",
"integrity": "sha512-AgZ3UOZm3YndfrJ4OYjgrT7bmCm/1iqkjvEfH/oYjzh6PD2qw4QuT3jjnXIrpdt4MTpMXclMT3lXbmRY+XRakw==",
"license": "MIT"
},
"node_modules/resolve": {
"version": "2.0.0-next.7",
"resolved": "https://registry.npmjs.org/resolve/-/resolve-2.0.0-next.7.tgz",
@@ -11591,6 +11937,12 @@
"real-require": "^0.2.0"
}
},
"node_modules/tiny-invariant": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz",
"integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==",
"license": "MIT"
},
"node_modules/tinyglobby": {
"version": "0.2.17",
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz",
@@ -12579,6 +12931,15 @@
}
}
},
"node_modules/use-sync-external-store": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz",
"integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==",
"license": "MIT",
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/utf8-byte-length": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/utf8-byte-length/-/utf8-byte-length-1.0.5.tgz",
@@ -12612,6 +12973,28 @@
"url": "https://opencollective.com/unified"
}
},
"node_modules/victory-vendor": {
"version": "37.3.6",
"resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-37.3.6.tgz",
"integrity": "sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ==",
"license": "MIT AND ISC",
"dependencies": {
"@types/d3-array": "^3.0.3",
"@types/d3-ease": "^3.0.0",
"@types/d3-interpolate": "^3.0.1",
"@types/d3-scale": "^4.0.2",
"@types/d3-shape": "^3.1.0",
"@types/d3-time": "^3.0.0",
"@types/d3-timer": "^3.0.0",
"d3-array": "^3.1.6",
"d3-ease": "^3.0.1",
"d3-interpolate": "^3.0.1",
"d3-scale": "^4.0.2",
"d3-shape": "^3.1.0",
"d3-time": "^3.0.0",
"d3-timer": "^3.0.1"
}
},
"node_modules/whatwg-mimetype": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-3.0.0.tgz",
+1
View File
@@ -30,6 +30,7 @@
"payload": "^3.88.0",
"react": "19.2.8",
"react-dom": "19.2.8",
"recharts": "^3.10.1",
"sharp": "^0.35.3",
"tsx": "^4.23.12",
"ws": "^8.21.3"
+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".
+29 -2
View File
@@ -16,6 +16,8 @@ import { Availability } from "@/collections/Availability";
import { AvailabilityOverrides } from "@/collections/AvailabilityOverrides";
import { BookingRequests } from "@/collections/BookingRequests";
import { Redirects } from "@/collections/Redirects";
import { AnalyticsEvents } from "@/collections/AnalyticsEvents";
import { AnalyticsDailyStats } from "@/collections/AnalyticsDailyStats";
import { Home } from "@/globals/Home";
import { About } from "@/globals/About";
@@ -26,6 +28,8 @@ import { Booking } from "@/globals/Booking";
import { MeetingSettings } from "@/globals/MeetingSettings";
import { BookingSettings } from "@/globals/BookingSettings";
import { SEOSettings } from "@/globals/SEOSettings";
import { CookieSettings } from "@/globals/CookieSettings";
import { AnalyticsSettings } from "@/globals/AnalyticsSettings";
const filename = fileURLToPath(import.meta.url);
const dirname = path.dirname(filename);
@@ -41,7 +45,10 @@ export default buildConfig({
titleSuffix: "— Anouma Admin",
},
components: {
afterNavLinks: ["/components/admin/CalendarNavLink#CalendarNavLink"],
afterNavLinks: [
"/components/admin/CalendarNavLink#CalendarNavLink",
"/components/admin/AnalyticsNavLink#AnalyticsNavLink",
],
views: {
kalender: {
Component: "/components/admin/AdminCalendarView#AdminCalendarView",
@@ -49,6 +56,12 @@ export default buildConfig({
exact: true,
meta: { title: "Kalender" },
},
analytics: {
Component: "/components/admin/AdminAnalyticsView#AdminAnalyticsView",
path: "/analytics",
exact: true,
meta: { title: "Analytics" },
},
},
},
},
@@ -64,8 +77,22 @@ export default buildConfig({
AvailabilityOverrides,
BookingRequests,
Redirects,
AnalyticsEvents,
AnalyticsDailyStats,
],
globals: [
Home,
About,
AngeboteIntro,
AktuellesIntro,
Contact,
Booking,
MeetingSettings,
BookingSettings,
SEOSettings,
CookieSettings,
AnalyticsSettings,
],
globals: [Home, About, AngeboteIntro, AktuellesIntro, Contact, Booking, MeetingSettings, BookingSettings, SEOSettings],
editor: lexicalEditor(),
secret: process.env.PAYLOAD_SECRET || "",
typescript: {
+3
View File
@@ -301,6 +301,7 @@ POSTGRES_PASSWORD=
POSTGRES_DB=anouma
PAYLOAD_SECRET=
MEETING_SESSION_SECRET=
ANALYTICS_SALT_SECRET=
CRON_SECRET=
NEXT_PUBLIC_SERVER_URL=
APP_PORT=3000
@@ -329,6 +330,7 @@ fi
# --- secrets --------------------------------------------------------------
env_ensure PAYLOAD_SECRET "$(gen_secret)" && log_info "PAYLOAD_SECRET generiert." || log_info "PAYLOAD_SECRET bereits vorhanden — unverändert."
env_ensure MEETING_SESSION_SECRET "$(gen_secret)" && log_info "MEETING_SESSION_SECRET generiert." || log_info "MEETING_SESSION_SECRET bereits vorhanden — unverändert."
env_ensure ANALYTICS_SALT_SECRET "$(gen_secret)" && log_info "ANALYTICS_SALT_SECRET generiert." || log_info "ANALYTICS_SALT_SECRET bereits vorhanden — unverändert."
env_ensure CRON_SECRET "$(gen_secret)" && log_info "CRON_SECRET generiert." || log_info "CRON_SECRET bereits vorhanden — unverändert."
if env_ensure POSTGRES_PASSWORD "$(gen_secret)"; then
@@ -431,6 +433,7 @@ echo "POSTGRES_DB: $(env_get POSTGRES_DB)"
echo "POSTGRES_PASSWORD: $(mask "$(env_get POSTGRES_PASSWORD)")"
echo "PAYLOAD_SECRET: $(mask "$(env_get PAYLOAD_SECRET)")"
echo "MEETING_SESSION_SECRET: $(mask "$(env_get MEETING_SESSION_SECRET)")"
echo "ANALYTICS_SALT_SECRET: $(mask "$(env_get ANALYTICS_SALT_SECRET)")"
echo "CRON_SECRET: $(mask "$(env_get CRON_SECRET)")"
echo "SMTP_HOST: $(env_get SMTP_HOST)"
echo "SMTP_PASSWORD: $(mask "$(env_get SMTP_PASSWORD)")"