cms.c2sgmbh/docs/PROMPT_CONSENT_PAYLOAD.md
Martin Porwoll a88e4f60d0 test: add E2E and integration tests with documentation
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>
2025-12-01 08:19:52 +00:00

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:

  1. CookieConfigurations - Mandantenspezifische Banner-Konfiguration
  2. CookieInventory - Cookie-Dokumentation für Datenschutzerklärung
  3. 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
  • consentId wird serverseitig generiert (nicht vom Client überschreibbar)
  • anonymizedIp ist ein Hash, keine echte IP
  • expiresAt wird automatisch auf +3 Jahre gesetzt

Schritt 12: Initiale Daten anlegen (Optional)

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

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)