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,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,
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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(() => {});
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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";
|
||||
}
|
||||
@@ -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" });
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user