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:
@@ -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 {
|
||||
|
||||
@@ -134,10 +134,56 @@ model User {
|
||||
iconAssets IconAsset[]
|
||||
upgradeRequests UpgradeRequest[]
|
||||
rapports Rapport[]
|
||||
legalAcceptances LegalAcceptance[]
|
||||
|
||||
@@map("users")
|
||||
}
|
||||
|
||||
// ─── Rechtsdokumente & revisionssichere Zustimmungen ──────────
|
||||
// Dokumenttypen (als String gehalten, damit die idempotente Raw-SQL-Migration einfach bleibt):
|
||||
// TERMS | PRIVACY | ORGANIZATION_DECLARATION | RESPONSIBLE_USE | DATA_PROCESSING_AGREEMENT
|
||||
|
||||
model LegalDocument {
|
||||
id String @id @default(uuid())
|
||||
type String
|
||||
version String
|
||||
title String
|
||||
publishedAt DateTime @default(now())
|
||||
effectiveAt DateTime?
|
||||
contentHash String
|
||||
url String?
|
||||
isActive Boolean @default(false)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@unique([type, version])
|
||||
@@index([type, isActive])
|
||||
@@map("legal_documents")
|
||||
}
|
||||
|
||||
model LegalAcceptance {
|
||||
id String @id @default(uuid())
|
||||
userId String
|
||||
// NULL = persönliche Zustimmung (nicht organisationsbezogen)
|
||||
organizationId String?
|
||||
documentType String
|
||||
documentVersion String
|
||||
contentHash String
|
||||
acceptedAt DateTime @default(now())
|
||||
locale String?
|
||||
// REGISTRATION | LOGIN_RECONSENT | ORGANIZATION_CREATION | ADMIN_ROLE_ACCEPTANCE | DONATION
|
||||
context String
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
|
||||
// Verhindert doppelte identische Zustimmungen; neue Version erzeugt neuen Datensatz.
|
||||
@@unique([userId, organizationId, documentType, documentVersion, context])
|
||||
@@index([userId])
|
||||
@@index([documentType, documentVersion])
|
||||
@@map("legal_acceptances")
|
||||
}
|
||||
|
||||
model TenantMembership {
|
||||
id String @id @default(uuid())
|
||||
role Role @default(OPERATOR)
|
||||
|
||||
Reference in New Issue
Block a user