Tests: - Update frontend.e2e.spec.ts with locale testing - Add search.e2e.spec.ts for search functionality - Add i18n.int.spec.ts for localization tests - Add search.int.spec.ts for search integration - Update playwright.config.ts Documentation: - Add CLAUDE.md with project instructions - Add docs/ directory with detailed documentation - Add scripts/ for utility scripts 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
23 KiB
PROMPT: Consent Management System - Payload Backend
Kontext
Du arbeitest auf dem Server sv-payload (10.10.181.100) im Verzeichnis /home/payload/payload-cms.
Dieses Projekt implementiert ein DSGVO-konformes Consent Management System gemäß Spezifikation SAS v2.6. Das System ist Multi-Tenant-fähig und nutzt die bestehende Tenants-Collection.
Referenz-Dokument
Basis: Systemarchitektur-Spezifikation v2.6 (Implementation Master)
Aufgabe
Erstelle drei neue Collections und die zugehörigen Hooks für das Consent Management:
- CookieConfigurations - Mandantenspezifische Banner-Konfiguration
- CookieInventory - Cookie-Dokumentation für Datenschutzerklärung
- ConsentLogs - WORM Audit-Trail für Einwilligungen
Schritt 1: Collection - CookieConfigurations
Erstelle src/collections/CookieConfigurations.ts:
import type { CollectionConfig } from 'payload'
export const CookieConfigurations: CollectionConfig = {
slug: 'cookie-configurations',
admin: {
useAsTitle: 'title',
group: 'Consent Management',
description: 'Cookie-Banner Konfiguration pro Tenant',
},
access: {
// Öffentlich lesbar für Frontend-Initialisierung
read: () => true,
// Nur authentifizierte User können bearbeiten
create: ({ req }) => !!req.user,
update: ({ req }) => !!req.user,
delete: ({ req }) => !!req.user,
},
fields: [
{
name: 'tenant',
type: 'relationship',
relationTo: 'tenants',
required: true,
unique: true,
admin: {
description: 'Jeder Tenant kann nur eine Konfiguration haben',
},
},
{
name: 'title',
type: 'text',
required: true,
defaultValue: 'Cookie-Einstellungen',
admin: {
description: 'Interner Titel zur Identifikation',
},
},
{
name: 'revision',
type: 'number',
required: true,
defaultValue: 1,
admin: {
description: 'Bei inhaltlichen Änderungen erhöhen → erzwingt erneuten Consent bei allen Nutzern',
},
},
{
name: 'enabledCategories',
type: 'select',
hasMany: true,
required: true,
defaultValue: ['necessary', 'analytics'],
options: [
{ label: 'Notwendig', value: 'necessary' },
{ label: 'Funktional', value: 'functional' },
{ label: 'Statistik', value: 'analytics' },
{ label: 'Marketing', value: 'marketing' },
],
admin: {
description: 'Welche Kategorien sollen im Banner angezeigt werden?',
},
},
{
name: 'translations',
type: 'group',
fields: [
{
name: 'de',
type: 'group',
label: 'Deutsch',
fields: [
{
name: 'bannerTitle',
type: 'text',
defaultValue: 'Wir respektieren Ihre Privatsphäre',
},
{
name: 'bannerDescription',
type: 'textarea',
defaultValue: 'Diese Website verwendet Cookies, um Ihnen die bestmögliche Erfahrung zu bieten. Sie können Ihre Einstellungen jederzeit anpassen.',
},
{
name: 'acceptAllButton',
type: 'text',
defaultValue: 'Alle akzeptieren',
},
{
name: 'acceptNecessaryButton',
type: 'text',
defaultValue: 'Nur notwendige',
},
{
name: 'settingsButton',
type: 'text',
defaultValue: 'Einstellungen',
},
{
name: 'saveButton',
type: 'text',
defaultValue: 'Auswahl speichern',
},
{
name: 'privacyPolicyUrl',
type: 'text',
defaultValue: '/datenschutz',
admin: {
description: 'Link zur Datenschutzerklärung',
},
},
{
name: 'categoryLabels',
type: 'group',
fields: [
{
name: 'necessary',
type: 'group',
fields: [
{ name: 'title', type: 'text', defaultValue: 'Notwendig' },
{ name: 'description', type: 'textarea', defaultValue: 'Diese Cookies sind für die Grundfunktionen der Website erforderlich.' },
],
},
{
name: 'functional',
type: 'group',
fields: [
{ name: 'title', type: 'text', defaultValue: 'Funktional' },
{ name: 'description', type: 'textarea', defaultValue: 'Diese Cookies ermöglichen erweiterte Funktionen und Personalisierung.' },
],
},
{
name: 'analytics',
type: 'group',
fields: [
{ name: 'title', type: 'text', defaultValue: 'Statistik' },
{ name: 'description', type: 'textarea', defaultValue: 'Diese Cookies helfen uns zu verstehen, wie Besucher mit der Website interagieren.' },
],
},
{
name: 'marketing',
type: 'group',
fields: [
{ name: 'title', type: 'text', defaultValue: 'Marketing' },
{ name: 'description', type: 'textarea', defaultValue: 'Diese Cookies werden verwendet, um Werbung relevanter für Sie zu gestalten.' },
],
},
],
},
],
},
],
},
{
name: 'styling',
type: 'group',
admin: {
description: 'Optionale Anpassung des Banner-Designs',
},
fields: [
{
name: 'position',
type: 'select',
defaultValue: 'bottom',
options: [
{ label: 'Unten', value: 'bottom' },
{ label: 'Oben', value: 'top' },
{ label: 'Mitte (Modal)', value: 'middle' },
],
},
{
name: 'theme',
type: 'select',
defaultValue: 'dark',
options: [
{ label: 'Dunkel', value: 'dark' },
{ label: 'Hell', value: 'light' },
{ label: 'Auto (System)', value: 'auto' },
],
},
],
},
],
}
Schritt 2: Collection - CookieInventory
Erstelle src/collections/CookieInventory.ts:
import type { CollectionConfig } from 'payload'
export const CookieInventory: CollectionConfig = {
slug: 'cookie-inventory',
admin: {
useAsTitle: 'name',
group: 'Consent Management',
description: 'Dokumentation aller verwendeten Cookies für die Datenschutzerklärung',
defaultColumns: ['name', 'provider', 'category', 'duration', 'tenant'],
},
access: {
// Öffentlich lesbar für Datenschutzerklärung
read: () => true,
create: ({ req }) => !!req.user,
update: ({ req }) => !!req.user,
delete: ({ req }) => !!req.user,
},
fields: [
{
name: 'tenant',
type: 'relationship',
relationTo: 'tenants',
required: true,
admin: {
description: 'Zuordnung zum Mandanten',
},
},
{
name: 'name',
type: 'text',
required: true,
admin: {
description: 'Technischer Name des Cookies (z.B. "_ga", "cc_cookie")',
},
},
{
name: 'provider',
type: 'text',
required: true,
admin: {
description: 'Anbieter/Setzer des Cookies (z.B. "Google LLC", "Eigene Website")',
},
},
{
name: 'category',
type: 'select',
required: true,
options: [
{ label: 'Notwendig', value: 'necessary' },
{ label: 'Funktional', value: 'functional' },
{ label: 'Statistik', value: 'analytics' },
{ label: 'Marketing', value: 'marketing' },
],
},
{
name: 'duration',
type: 'text',
required: true,
admin: {
description: 'Speicherdauer (z.B. "Session", "1 Jahr", "2 Jahre")',
},
},
{
name: 'description',
type: 'textarea',
required: true,
admin: {
description: 'Verständliche Erklärung des Zwecks für Endnutzer',
},
},
{
name: 'isActive',
type: 'checkbox',
defaultValue: true,
admin: {
description: 'Wird dieser Cookie aktuell verwendet?',
},
},
],
}
Schritt 3: Collection - ConsentLogs (WORM)
Erstelle src/collections/ConsentLogs.ts:
import type { CollectionConfig } from 'payload'
import crypto from 'crypto'
// Helper: Täglicher Salt für IP-Anonymisierung
const getDailySalt = (tenantId: string): string => {
const date = new Date().toISOString().split('T')[0] // YYYY-MM-DD
const pepper = process.env.IP_ANONYMIZATION_PEPPER || 'default-pepper-change-me'
return crypto.createHash('sha256').update(`${pepper}-${tenantId}-${date}`).digest('hex')
}
// Helper: IP anonymisieren
const anonymizeIp = (ip: string, tenantId: string): string => {
const salt = getDailySalt(tenantId)
return crypto.createHmac('sha256', salt).update(ip).digest('hex').substring(0, 32)
}
// Helper: IP aus Request extrahieren
const extractIp = (req: any): string => {
const forwarded = req.headers?.['x-forwarded-for']
if (typeof forwarded === 'string') {
return forwarded.split(',')[0].trim()
}
if (Array.isArray(forwarded)) {
return forwarded[0]
}
return req.socket?.remoteAddress || req.ip || 'unknown'
}
export const ConsentLogs: CollectionConfig = {
slug: 'consent-logs',
admin: {
useAsTitle: 'consentId',
group: 'Consent Management',
description: 'WORM Audit-Trail für Cookie-Einwilligungen (unveränderbar)',
defaultColumns: ['consentId', 'tenant', 'categories', 'revision', 'createdAt'],
},
// Keine Versionierung/Drafts für Performance bei hohem Schreibvolumen
versions: false,
access: {
// Erstellen nur mit API-Key (wird in Hook geprüft)
create: ({ req }) => {
const apiKey = req.headers?.['x-api-key']
const validKey = process.env.CONSENT_LOGGING_API_KEY
return apiKey === validKey
},
// Lesen nur für authentifizierte Admin-User
read: ({ req }) => !!req.user,
// WORM: Keine Updates erlaubt
update: () => false,
// WORM: Keine Deletes über API (nur via Retention Job)
delete: () => false,
},
hooks: {
beforeChange: [
({ data, req, operation }) => {
if (operation !== 'create') return data
// 1. Server-generierte Consent-ID (Trust Boundary)
data.consentId = crypto.randomUUID()
// 2. IP anonymisieren
const rawIp = data.ip || extractIp(req)
const tenantId = typeof data.tenant === 'object' ? data.tenant.id : data.tenant
data.anonymizedIp = anonymizeIp(rawIp, String(tenantId))
// Rohe IP entfernen (nie speichern!)
delete data.ip
// 3. Ablaufdatum setzen (3 Jahre Retention)
const threeYearsFromNow = new Date()
threeYearsFromNow.setFullYear(threeYearsFromNow.getFullYear() + 3)
data.expiresAt = threeYearsFromNow.toISOString()
// 4. User Agent kürzen (Datensparsamkeit)
if (data.userAgent && data.userAgent.length > 500) {
data.userAgent = data.userAgent.substring(0, 500)
}
return data
},
],
},
fields: [
{
name: 'consentId',
type: 'text',
required: true,
unique: true,
admin: {
readOnly: true,
description: 'Server-generierte eindeutige ID',
},
},
{
name: 'clientRef',
type: 'text',
admin: {
readOnly: true,
description: 'Client-seitige Referenz (Cookie-UUID) für Traceability',
},
},
{
name: 'tenant',
type: 'relationship',
relationTo: 'tenants',
required: true,
admin: {
readOnly: true,
},
},
{
name: 'categories',
type: 'json',
required: true,
admin: {
readOnly: true,
description: 'Akzeptierte Kategorien zum Zeitpunkt der Einwilligung',
},
},
{
name: 'revision',
type: 'number',
required: true,
admin: {
readOnly: true,
description: 'Version der Konfiguration zum Zeitpunkt der Zustimmung',
},
},
{
name: 'userAgent',
type: 'text',
admin: {
readOnly: true,
description: 'Browser/Device (für Forensik und Bot-Erkennung)',
},
},
{
name: 'anonymizedIp',
type: 'text',
admin: {
readOnly: true,
description: 'HMAC-Hash der IP (täglich rotierender, tenant-spezifischer Salt)',
},
},
{
name: 'expiresAt',
type: 'date',
required: true,
admin: {
readOnly: true,
description: 'Automatische Löschung nach 3 Jahren',
date: {
pickerAppearance: 'dayOnly',
},
},
},
],
}
Schritt 4: Retention Job (Automatische Löschung)
Erstelle src/jobs/consentRetentionJob.ts:
import type { Payload } from 'payload'
/**
* Consent Retention Job
*
* Löscht abgelaufene ConsentLogs gemäß DSGVO Art. 5 Abs. 1e (Speicherbegrenzung).
* Sollte täglich via Cron ausgeführt werden.
*/
export const runConsentRetentionJob = async (payload: Payload): Promise<void> => {
const now = new Date().toISOString()
try {
// Finde abgelaufene Einträge
const expired = await payload.find({
collection: 'consent-logs',
where: {
expiresAt: {
less_than: now,
},
},
limit: 1000, // Batch-Größe
})
if (expired.docs.length === 0) {
console.log('[ConsentRetention] Keine abgelaufenen Einträge gefunden.')
return
}
console.log(`[ConsentRetention] ${expired.docs.length} abgelaufene Einträge gefunden. Lösche...`)
// Lösche jeden Eintrag einzeln (WORM-Bypass via direktem DB-Zugriff)
// Da delete: () => false gesetzt ist, müssen wir den DB-Adapter direkt nutzen
for (const doc of expired.docs) {
await payload.db.deleteOne({
collection: 'consent-logs',
where: { id: { equals: doc.id } },
})
}
console.log(`[ConsentRetention] ${expired.docs.length} Einträge gelöscht.`)
// Falls mehr als 1000 Einträge: Rekursiv weitermachen
if (expired.docs.length === 1000) {
console.log('[ConsentRetention] Weitere Einträge vorhanden, führe nächsten Batch aus...')
await runConsentRetentionJob(payload)
}
} catch (error) {
console.error('[ConsentRetention] Fehler:', error)
throw error
}
}
Schritt 5: Job-Scheduler einrichten
Erstelle src/jobs/scheduler.ts:
import cron from 'node-cron'
import type { Payload } from 'payload'
import { runConsentRetentionJob } from './consentRetentionJob'
/**
* Initialisiert alle Scheduled Jobs
*/
export const initScheduledJobs = (payload: Payload): void => {
// Consent Retention: Täglich um 03:00 Uhr
cron.schedule('0 3 * * *', async () => {
console.log('[Scheduler] Starte Consent Retention Job...')
try {
await runConsentRetentionJob(payload)
} catch (error) {
console.error('[Scheduler] Consent Retention Job fehlgeschlagen:', error)
}
}, {
timezone: 'Europe/Berlin'
})
console.log('[Scheduler] Scheduled Jobs initialisiert.')
}
Schritt 6: Collections registrieren
Aktualisiere src/payload.config.ts:
import { buildConfig } from 'payload'
import { postgresAdapter } from '@payloadcms/db-postgres'
import { lexicalEditor } from '@payloadcms/richtext-lexical'
import { multiTenantPlugin } from '@payloadcms/plugin-multi-tenant'
import path from 'path'
import { fileURLToPath } from 'url'
// Existing Collections
import { Users } from './collections/Users'
import { Media } from './collections/Media'
import { Tenants } from './collections/Tenants'
import { Pages } from './collections/Pages'
import { Posts } from './collections/Posts'
import { Categories } from './collections/Categories'
import { SocialLinks } from './collections/SocialLinks'
// NEW: Consent Management Collections
import { CookieConfigurations } from './collections/CookieConfigurations'
import { CookieInventory } from './collections/CookieInventory'
import { ConsentLogs } from './collections/ConsentLogs'
// Existing Globals
import { SiteSettings } from './globals/SiteSettings'
import { Navigation } from './globals/Navigation'
const filename = fileURLToPath(import.meta.url)
const dirname = path.dirname(filename)
export default buildConfig({
admin: {
user: Users.slug,
},
serverURL: process.env.PAYLOAD_PUBLIC_SERVER_URL || 'https://pl.c2sgmbh.de',
cors: [
'http://localhost:3000',
'http://localhost:3001',
'http://10.10.181.102:3000',
'http://10.10.181.102:3001',
'https://dev.zh3.de',
'https://porwoll.de',
'https://www.porwoll.de',
'https://complexcaresolutions.de',
'https://www.complexcaresolutions.de',
'https://gunshin.de',
'https://www.gunshin.de',
],
csrf: [
'http://localhost:3000',
'http://localhost:3001',
'http://10.10.181.102:3000',
'http://10.10.181.102:3001',
'https://dev.zh3.de',
'https://porwoll.de',
'https://www.porwoll.de',
'https://complexcaresolutions.de',
'https://www.complexcaresolutions.de',
'https://gunshin.de',
'https://www.gunshin.de',
],
collections: [
Users,
Media,
Tenants,
Pages,
Posts,
Categories,
SocialLinks,
// NEW: Consent Management
CookieConfigurations,
CookieInventory,
ConsentLogs,
],
globals: [SiteSettings, Navigation],
editor: lexicalEditor(),
secret: process.env.PAYLOAD_SECRET || '',
typescript: {
outputFile: path.resolve(dirname, 'payload-types.ts'),
},
db: postgresAdapter({
pool: {
connectionString: process.env.DATABASE_URI || '',
},
}),
plugins: [
multiTenantPlugin({
tenantsSlug: 'tenants',
collections: {
media: {},
pages: {},
posts: {},
categories: {},
'social-links': {},
// NEW: Consent Collections mit Tenant-Scoping
'cookie-configurations': {},
'cookie-inventory': {},
'consent-logs': {},
},
}),
],
})
Schritt 7: Scheduler in Server einbinden
Aktualisiere src/server.ts (oder erstelle, falls nicht vorhanden):
import express from 'express'
import payload from 'payload'
import { initScheduledJobs } from './jobs/scheduler'
const app = express()
const start = async () => {
await payload.init({
secret: process.env.PAYLOAD_SECRET || '',
express: app,
onInit: async () => {
payload.logger.info(`Payload Admin URL: ${payload.getAdminURL()}`)
// Scheduled Jobs starten
initScheduledJobs(payload)
},
})
app.listen(3000)
}
start()
Schritt 8: Dependencies installieren
cd /home/payload/payload-cms
pnpm add node-cron
pnpm add -D @types/node-cron
Schritt 9: Environment Variables
Füge zu .env hinzu:
# Consent Management
CONSENT_LOGGING_API_KEY=GENERIERE_EINEN_SICHEREN_KEY_HIER
IP_ANONYMIZATION_PEPPER=GENERIERE_EINEN_ANDEREN_SICHEREN_KEY_HIER
Generiere sichere Keys:
# Auf sv-payload
openssl rand -hex 32 # Für CONSENT_LOGGING_API_KEY
openssl rand -hex 32 # Für IP_ANONYMIZATION_PEPPER
Schritt 10: Migrationen und Build
cd /home/payload/payload-cms
# TypeScript Types generieren
pnpm payload generate:types
# Migrationen erstellen und ausführen
pnpm payload migrate:create
pnpm payload migrate
# Build
pnpm build
# PM2 neustarten
pm2 restart payload
Schritt 11: Verifizierung
API-Endpoints testen
# CookieConfigurations (öffentlich)
curl -s http://localhost:3000/api/cookie-configurations | jq
# CookieInventory (öffentlich)
curl -s http://localhost:3000/api/cookie-inventory | jq
# ConsentLogs erstellen (mit API-Key)
curl -X POST http://localhost:3000/api/consent-logs \
-H "Content-Type: application/json" \
-H "x-api-key: DEIN_CONSENT_LOGGING_API_KEY" \
-d '{
"clientRef": "test-client-123",
"tenant": 1,
"categories": ["necessary", "analytics"],
"revision": 1,
"userAgent": "Mozilla/5.0 Test",
"ip": "192.168.1.100"
}' | jq
# ConsentLogs lesen (nur mit Admin-Auth)
# → Über Admin Panel: https://pl.c2sgmbh.de/admin/collections/consent-logs
Prüfpunkte
- CookieConfigurations Collection erscheint im Admin unter "Consent Management"
- CookieInventory Collection erscheint im Admin
- ConsentLogs Collection erscheint im Admin (nur lesbar)
- ConsentLogs: Update-Button ist deaktiviert
- ConsentLogs: Delete-Button ist deaktiviert
- API-Erstellung von ConsentLogs funktioniert nur mit korrektem API-Key
consentIdwird serverseitig generiert (nicht vom Client überschreibbar)anonymizedIpist ein Hash, keine echte IPexpiresAtwird automatisch auf +3 Jahre gesetzt
Schritt 12: Initiale Daten anlegen (Optional)
Cookie-Konfiguration für porwoll.de
Im Admin Panel unter Consent Management → Cookie Configurations → Create:
| Feld | Wert |
|---|---|
| Tenant | porwoll.de |
| Title | Cookie-Einstellungen porwoll.de |
| Revision | 1 |
| Enabled Categories | Notwendig, Statistik |
Cookie-Inventory für porwoll.de
Im Admin Panel unter Consent Management → Cookie Inventory → Create:
| Name | Provider | Category | Duration | Description |
|---|---|---|---|---|
| cc_cookie | Eigene Website | Notwendig | 1 Jahr | Speichert Ihre Cookie-Einstellungen |
| _ga | Google LLC | Statistik | 2 Jahre | Google Analytics - Unterscheidung von Nutzern |
| ga* | Google LLC | Statistik | 2 Jahre | Google Analytics - Session-Daten |
Zusammenfassung der erstellten Dateien
| Datei | Beschreibung |
|---|---|
src/collections/CookieConfigurations.ts |
Banner-Konfiguration pro Tenant |
src/collections/CookieInventory.ts |
Cookie-Dokumentation |
src/collections/ConsentLogs.ts |
WORM Audit-Trail mit Hooks |
src/jobs/consentRetentionJob.ts |
Automatische Löschung nach 3 Jahren |
src/jobs/scheduler.ts |
Cron-Scheduler für Jobs |
Neue Environment Variables
| Variable | Beschreibung |
|---|---|
CONSENT_LOGGING_API_KEY |
API-Key für Frontend-zu-Backend Logging |
IP_ANONYMIZATION_PEPPER |
Geheimer Pepper für IP-Hashing |
API-Endpoints
| Endpoint | Method | Auth | Beschreibung |
|---|---|---|---|
/api/cookie-configurations |
GET | Public | Banner-Config abrufen |
/api/cookie-inventory |
GET | Public | Cookie-Liste für Datenschutz |
/api/consent-logs |
POST | x-api-key | Consent loggen |
/api/consent-logs |
GET | Admin | Logs einsehen (nur Admin) |