feat(legal): Consent-Datenmodell, Migration & Registrierungs-Zustimmung (v1.7.1)

Teil 2 des Compliance-Updates — revisionssichere Zustimmungsprotokollierung:

- Prisma: Modelle LegalDocument (versioniert, contentHash, isActive, unique[type,version])
  und LegalAcceptance (userId, organizationId?, documentType/-Version, contentHash, context,
  unique gegen Duplikate, User-Relation)
- migrate.js: idempotente Tabellen + Indizes + Seeding/Aktivierung der 5 Rechtsdokumente
  (eine aktive Version je Typ; contentHash aus bewusster Versionsangabe)
- src/lib/legal.ts: getActiveDocuments, getPendingAcceptances, recordAcceptances (skipDuplicates,
  nie überschreiben), Pflichtsets (alle: TERMS+PRIVACY; Org-Admin zusätzlich ORGANIZATION_DECLARATION)
- API: GET /api/legal/status (offene Zustimmungen), POST /api/legal/accept (erfassen, CSRF-Check)
- Registrierung: zwei getrennte, nicht vorausgewählte Zustimmungen (Nutzungsbedingungen akzeptieren
  + Datenschutz-Kenntnisnahme), serverseitig via zod erzwungen, Version protokolliert

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Pepe Ziberi
2026-07-23 12:02:20 +02:00
parent 872b8927cc
commit 8cf1aa4dbe
8 changed files with 343 additions and 11 deletions

View File

@@ -151,11 +151,73 @@ async function migrate() {
"projectId" TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE
)`,
`CREATE INDEX IF NOT EXISTS module_items_project_module_idx ON module_items("projectId", "moduleId")`,
// Rechtsdokumente (versioniert, bewusst gesetzte Version)
`CREATE TABLE IF NOT EXISTS legal_documents (
id TEXT PRIMARY KEY DEFAULT gen_random_uuid(),
type TEXT NOT NULL,
version TEXT NOT NULL,
title TEXT NOT NULL,
"publishedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"effectiveAt" TIMESTAMP(3),
"contentHash" TEXT NOT NULL,
url TEXT,
"isActive" BOOLEAN NOT NULL DEFAULT false,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
UNIQUE(type, version)
)`,
`CREATE INDEX IF NOT EXISTS legal_documents_type_active_idx ON legal_documents(type, "isActive")`,
// Revisionssichere Zustimmungen — nie überschreiben, neue Version = neuer Datensatz
`CREATE TABLE IF NOT EXISTS legal_acceptances (
id TEXT PRIMARY KEY DEFAULT gen_random_uuid(),
"userId" TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
"organizationId" TEXT,
"documentType" TEXT NOT NULL,
"documentVersion" TEXT NOT NULL,
"contentHash" TEXT NOT NULL,
"acceptedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
locale TEXT,
context TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
UNIQUE("userId", "organizationId", "documentType", "documentVersion", context)
)`,
`CREATE INDEX IF NOT EXISTS legal_acceptances_user_idx ON legal_acceptances("userId")`,
`CREATE INDEX IF NOT EXISTS legal_acceptances_doc_idx ON legal_acceptances("documentType", "documentVersion")`,
]
for (const sql of tableMigrations) {
try { await prisma.$executeRawUnsafe(sql) } catch (e) { /* table might already exist */ }
}
// ─── Step 4b: Rechtsdokumente seeden/aktivieren (idempotent) ───
// WICHTIG: Diese Metadaten müssen mit src/config/legal.ts übereinstimmen. Version bewusst
// erhöhen, wenn sich der Inhalt WESENTLICH ändert (löst Re-Consent aus).
console.log(' [4b] Seeding legal documents...')
const crypto = require('crypto')
const LEGAL_DOCS = [
{ type: 'TERMS', version: '1.0.0-draft', title: 'Nutzungsbedingungen', url: '/nutzungsbedingungen', publishedAt: '2026-07-23' },
{ type: 'PRIVACY', version: '1.0.0-draft', title: 'Datenschutzerklärung', url: '/datenschutz', publishedAt: '2026-07-23' },
{ type: 'ORGANIZATION_DECLARATION', version: '1.0.0-draft', title: 'Organisationsbestätigung', url: '/verantwortungsvolle-nutzung', publishedAt: '2026-07-23' },
{ type: 'RESPONSIBLE_USE', version: '1.0.0-draft', title: 'Verantwortungsvolle Nutzung', url: '/verantwortungsvolle-nutzung', publishedAt: '2026-07-23' },
{ type: 'DATA_PROCESSING_AGREEMENT', version: '1.0.0-draft', title: 'Datenschutzvereinbarung für Organisationen', url: '/organisationen/datenschutzvereinbarung', publishedAt: '2026-07-23' },
]
for (const doc of LEGAL_DOCS) {
const contentHash = crypto.createHash('sha256').update(`${doc.type}@${doc.version}`).digest('hex')
try {
// Neue Version anlegen (falls noch nicht vorhanden), als aktiv markieren
await prisma.$executeRawUnsafe(
`INSERT INTO legal_documents (type, version, title, url, "contentHash", "publishedAt", "effectiveAt", "isActive")
VALUES ($1,$2,$3,$4,$5,$6,$6,true)
ON CONFLICT (type, version) DO UPDATE SET title = EXCLUDED.title, url = EXCLUDED.url, "isActive" = true`,
doc.type, doc.version, doc.title, doc.url, contentHash, new Date(doc.publishedAt)
)
// Andere Versionen desselben Typs deaktivieren (nur eine aktive je Typ)
await prisma.$executeRawUnsafe(
`UPDATE legal_documents SET "isActive" = false WHERE type = $1 AND version <> $2`,
doc.type, doc.version
)
} catch (e) { /* table might not exist yet on very first run */ }
}
// ─── Step 5: Set safe defaults ───
console.log(' [5/7] Setting defaults...')
try {