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:
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "lageplan",
|
||||
"version": "1.7.0",
|
||||
"version": "1.7.1",
|
||||
"description": "Feuerwehr Lageplan - Krokier-App für Einsatzdokumentation",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -5,12 +5,16 @@ import { sendEmail } from '@/lib/email'
|
||||
import { randomBytes } from 'crypto'
|
||||
import { z } from 'zod'
|
||||
import { registerLimiter, getClientIp, rateLimitResponse } from '@/lib/rate-limit'
|
||||
import { recordAcceptances } from '@/lib/legal'
|
||||
|
||||
const registerSchema = z.object({
|
||||
organizationName: z.string().min(2, 'Organisationsname zu kurz').max(200),
|
||||
name: z.string().min(2, 'Name zu kurz').max(200),
|
||||
email: z.string().email('Ungültige E-Mail-Adresse'),
|
||||
password: z.string().min(8, 'Passwort muss mindestens 8 Zeichen haben'),
|
||||
// Aktive Zustimmung erforderlich (keine vorausgewählte Checkbox im UI).
|
||||
acceptTerms: z.literal(true, { errorMap: () => ({ message: 'Bitte die Nutzungsbedingungen akzeptieren.' }) }),
|
||||
acceptPrivacy: z.literal(true, { errorMap: () => ({ message: 'Bitte die Kenntnisnahme der Datenschutzerklärung bestätigen.' }) }),
|
||||
})
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
@@ -79,8 +83,8 @@ export async function POST(req: NextRequest) {
|
||||
maxUsers: 5,
|
||||
maxProjects: 10,
|
||||
contactEmail: data.email,
|
||||
privacyAccepted: body.privacyAccepted === true,
|
||||
privacyAcceptedAt: body.privacyAccepted ? new Date() : null,
|
||||
privacyAccepted: true,
|
||||
privacyAcceptedAt: new Date(),
|
||||
adminAccessAccepted: body.adminAccessAccepted === true,
|
||||
},
|
||||
})
|
||||
@@ -106,6 +110,20 @@ export async function POST(req: NextRequest) {
|
||||
},
|
||||
})
|
||||
|
||||
// Zustimmungen revisionssicher protokollieren (aktive Dokumentversion).
|
||||
// Die Organisationsbestätigung wird separat nach dem ersten Login abgefragt (Re-Consent).
|
||||
try {
|
||||
await recordAcceptances({
|
||||
userId: user.id,
|
||||
organizationId: tenant.id,
|
||||
context: 'REGISTRATION',
|
||||
types: ['TERMS', 'PRIVACY'],
|
||||
locale: 'de-CH',
|
||||
})
|
||||
} catch (e) {
|
||||
console.warn('[register] Zustimmungsprotokollierung fehlgeschlagen (nicht blockierend):', e)
|
||||
}
|
||||
|
||||
// Send verification email
|
||||
let baseUrl = process.env.NEXTAUTH_URL || req.headers.get('origin') || `${req.headers.get('x-forwarded-proto') || 'https'}://${req.headers.get('host')}` || 'http://localhost:3000'
|
||||
if (baseUrl && !baseUrl.startsWith('http://') && !baseUrl.startsWith('https://')) {
|
||||
|
||||
51
src/app/api/legal/accept/route.ts
Normal file
51
src/app/api/legal/accept/route.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { recordAcceptances, getPendingAcceptances, type LegalAcceptanceContext, type LegalDocumentType } from '@/lib/legal'
|
||||
|
||||
const VALID_TYPES: LegalDocumentType[] = ['TERMS', 'PRIVACY', 'ORGANIZATION_DECLARATION', 'RESPONSIBLE_USE', 'DATA_PROCESSING_AGREEMENT']
|
||||
const VALID_CONTEXTS: LegalAcceptanceContext[] = ['REGISTRATION', 'LOGIN_RECONSENT', 'ORGANIZATION_CREATION', 'ADMIN_ROLE_ACCEPTANCE', 'DONATION']
|
||||
|
||||
// POST: erfasst Zustimmungen des angemeldeten Benutzers revisionssicher.
|
||||
// body: { types: LegalDocumentType[], context: LegalAcceptanceContext }
|
||||
export async function POST(req: NextRequest) {
|
||||
try {
|
||||
const user = await getSession()
|
||||
if (!user) return NextResponse.json({ error: 'Nicht autorisiert' }, { status: 401 })
|
||||
|
||||
// CSRF-Schutz: nur same-origin JSON-Requests (Cookie ist sameSite=lax).
|
||||
const contentType = req.headers.get('content-type') || ''
|
||||
if (!contentType.includes('application/json')) {
|
||||
return NextResponse.json({ error: 'Ungültiger Request' }, { status: 400 })
|
||||
}
|
||||
|
||||
const body = await req.json().catch(() => ({}))
|
||||
const types: LegalDocumentType[] = Array.isArray(body.types)
|
||||
? body.types.filter((t: any) => VALID_TYPES.includes(t))
|
||||
: []
|
||||
const context: LegalAcceptanceContext = VALID_CONTEXTS.includes(body.context) ? body.context : 'LOGIN_RECONSENT'
|
||||
|
||||
if (types.length === 0) {
|
||||
return NextResponse.json({ error: 'Keine gültigen Dokumenttypen' }, { status: 400 })
|
||||
}
|
||||
|
||||
// Organisationsbezogene Zustimmung (Org-Bestätigung) an den Tenant binden.
|
||||
const orgBound = context === 'ORGANIZATION_CREATION' || context === 'ADMIN_ROLE_ACCEPTANCE' || types.includes('ORGANIZATION_DECLARATION')
|
||||
const organizationId = orgBound ? (user.tenantId ?? null) : null
|
||||
|
||||
const count = await recordAcceptances({
|
||||
userId: user.id,
|
||||
organizationId,
|
||||
context,
|
||||
types,
|
||||
locale: 'de-CH',
|
||||
})
|
||||
|
||||
const isOrgAdmin = user.role === 'TENANT_ADMIN'
|
||||
const pending = await getPendingAcceptances(user.id, isOrgAdmin)
|
||||
|
||||
return NextResponse.json({ success: true, recorded: count, needsAcceptance: pending.length > 0, pending })
|
||||
} catch (error) {
|
||||
console.error('Error recording legal acceptance:', error)
|
||||
return NextResponse.json({ error: 'Serverfehler' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
29
src/app/api/legal/status/route.ts
Normal file
29
src/app/api/legal/status/route.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { getActiveDocuments, getPendingAcceptances } from '@/lib/legal'
|
||||
|
||||
// GET: aktive Rechtsdokumente + offene Pflicht-Zustimmungen des aktuellen Benutzers.
|
||||
// Grundlage für den blockierenden Re-Consent-Dialog.
|
||||
export async function GET() {
|
||||
try {
|
||||
const user = await getSession()
|
||||
if (!user) return NextResponse.json({ error: 'Nicht autorisiert' }, { status: 401 })
|
||||
|
||||
const isOrgAdmin = user.role === 'TENANT_ADMIN'
|
||||
const [active, pending] = await Promise.all([
|
||||
getActiveDocuments(),
|
||||
getPendingAcceptances(user.id, isOrgAdmin),
|
||||
])
|
||||
|
||||
return NextResponse.json({
|
||||
isOrgAdmin,
|
||||
organizationId: user.tenantId ?? null,
|
||||
documents: active,
|
||||
pending, // leer = alles akzeptiert
|
||||
needsAcceptance: pending.length > 0,
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Error fetching legal status:', error)
|
||||
return NextResponse.json({ error: 'Serverfehler' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
@@ -16,7 +16,8 @@ export default function RegisterPage() {
|
||||
const [email, setEmail] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
const [confirmPassword, setConfirmPassword] = useState('')
|
||||
const [privacyAccepted, setPrivacyAccepted] = useState(false)
|
||||
const [acceptTerms, setAcceptTerms] = useState(false)
|
||||
const [acceptPrivacy, setAcceptPrivacy] = useState(false)
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [isSuccess, setIsSuccess] = useState(false)
|
||||
const router = useRouter()
|
||||
@@ -40,7 +41,7 @@ export default function RegisterPage() {
|
||||
const res = await fetch('/api/auth/register', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ organizationName, name, email, password, privacyAccepted }),
|
||||
body: JSON.stringify({ organizationName, name, email, password, acceptTerms, acceptPrivacy }),
|
||||
})
|
||||
|
||||
const data = await res.json()
|
||||
@@ -188,13 +189,28 @@ export default function RegisterPage() {
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Privacy Policy Checkbox */}
|
||||
<div className="pt-2 border-t border-border">
|
||||
{/* Zustimmungen — je einzeln aktiv zu setzen, keine Vorauswahl */}
|
||||
<div className="pt-2 border-t border-border space-y-2.5">
|
||||
<label className="flex items-start gap-2.5 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={privacyAccepted}
|
||||
onChange={(e) => setPrivacyAccepted(e.target.checked)}
|
||||
checked={acceptTerms}
|
||||
onChange={(e) => setAcceptTerms(e.target.checked)}
|
||||
className="mt-0.5 w-4 h-4 rounded border-gray-300 text-red-600 focus:ring-red-500 cursor-pointer"
|
||||
disabled={isLoading}
|
||||
/>
|
||||
<span className="text-xs text-muted-foreground leading-relaxed">
|
||||
Ich akzeptiere die{' '}
|
||||
<Link href="/nutzungsbedingungen" target="_blank" className="text-red-500 hover:text-red-400 underline font-medium">
|
||||
Nutzungsbedingungen
|
||||
</Link>.
|
||||
</span>
|
||||
</label>
|
||||
<label className="flex items-start gap-2.5 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={acceptPrivacy}
|
||||
onChange={(e) => setAcceptPrivacy(e.target.checked)}
|
||||
className="mt-0.5 w-4 h-4 rounded border-gray-300 text-red-600 focus:ring-red-500 cursor-pointer"
|
||||
disabled={isLoading}
|
||||
/>
|
||||
@@ -203,7 +219,7 @@ export default function RegisterPage() {
|
||||
<Link href="/datenschutz" target="_blank" className="text-red-500 hover:text-red-400 underline font-medium">
|
||||
Datenschutzerklärung
|
||||
</Link>{' '}
|
||||
gelesen und akzeptiere die Nutzungsbedingungen.
|
||||
zur Kenntnis genommen.
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
@@ -211,7 +227,7 @@ export default function RegisterPage() {
|
||||
<Button
|
||||
type="submit"
|
||||
className="w-full bg-red-600 hover:bg-red-700"
|
||||
disabled={isLoading || !organizationName || !name || !email || !password || !confirmPassword || !privacyAccepted}
|
||||
disabled={isLoading || !organizationName || !name || !email || !password || !confirmPassword || !acceptTerms || !acceptPrivacy}
|
||||
>
|
||||
{isLoading ? (
|
||||
<><Loader2 className="w-4 h-4 mr-2 animate-spin" /> Wird erstellt...</>
|
||||
|
||||
110
src/lib/legal.ts
Normal file
110
src/lib/legal.ts
Normal file
@@ -0,0 +1,110 @@
|
||||
import { prisma } from '@/lib/db'
|
||||
|
||||
/** Dokumenttypen (mit Prisma-String-Feldern konsistent). */
|
||||
export type LegalDocumentType =
|
||||
| 'TERMS'
|
||||
| 'PRIVACY'
|
||||
| 'ORGANIZATION_DECLARATION'
|
||||
| 'RESPONSIBLE_USE'
|
||||
| 'DATA_PROCESSING_AGREEMENT'
|
||||
|
||||
/** Zustimmungskontexte. */
|
||||
export type LegalAcceptanceContext =
|
||||
| 'REGISTRATION'
|
||||
| 'LOGIN_RECONSENT'
|
||||
| 'ORGANIZATION_CREATION'
|
||||
| 'ADMIN_ROLE_ACCEPTANCE'
|
||||
| 'DONATION'
|
||||
|
||||
/** Für ALLE Benutzer erforderliche Dokumente. */
|
||||
export const REQUIRED_FOR_ALL: LegalDocumentType[] = ['TERMS', 'PRIVACY']
|
||||
|
||||
/** Zusätzlich für Organisationsadministratoren erforderlich. */
|
||||
export const REQUIRED_FOR_ORG_ADMIN: LegalDocumentType[] = ['ORGANIZATION_DECLARATION']
|
||||
|
||||
export interface ActiveDocument {
|
||||
type: string
|
||||
version: string
|
||||
title: string
|
||||
url: string | null
|
||||
contentHash: string
|
||||
publishedAt: Date
|
||||
}
|
||||
|
||||
/** Alle aktuell aktiven Rechtsdokumente (eine aktive Version je Typ). */
|
||||
export async function getActiveDocuments(): Promise<ActiveDocument[]> {
|
||||
return (prisma as any).legalDocument.findMany({
|
||||
where: { isActive: true },
|
||||
select: { type: true, version: true, title: true, url: true, contentHash: true, publishedAt: true },
|
||||
})
|
||||
}
|
||||
|
||||
/** Aktives Dokument eines bestimmten Typs (oder null). */
|
||||
export async function getActiveDocument(type: LegalDocumentType): Promise<ActiveDocument | null> {
|
||||
return (prisma as any).legalDocument.findFirst({
|
||||
where: { type, isActive: true },
|
||||
select: { type: true, version: true, title: true, url: true, contentHash: true, publishedAt: true },
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Ermittelt die noch ausstehenden Pflicht-Zustimmungen eines Benutzers.
|
||||
* Ein Dokument gilt als offen, wenn für die aktive Version keine Zustimmung des Benutzers existiert.
|
||||
*/
|
||||
export async function getPendingAcceptances(userId: string, isOrgAdmin: boolean): Promise<ActiveDocument[]> {
|
||||
const required = new Set<string>([
|
||||
...REQUIRED_FOR_ALL,
|
||||
...(isOrgAdmin ? REQUIRED_FOR_ORG_ADMIN : []),
|
||||
])
|
||||
const active = (await getActiveDocuments()).filter(d => required.has(d.type))
|
||||
if (active.length === 0) return []
|
||||
|
||||
const accepted = await (prisma as any).legalAcceptance.findMany({
|
||||
where: {
|
||||
userId,
|
||||
OR: active.map(d => ({ documentType: d.type, documentVersion: d.version })),
|
||||
},
|
||||
select: { documentType: true, documentVersion: true },
|
||||
})
|
||||
const acceptedKeys = new Set(accepted.map((a: any) => `${a.documentType}@${a.documentVersion}`))
|
||||
return active.filter(d => !acceptedKeys.has(`${d.type}@${d.version}`))
|
||||
}
|
||||
|
||||
/** True, wenn der Benutzer alle erforderlichen aktiven Dokumente akzeptiert hat. */
|
||||
export async function hasAllRequiredAcceptances(userId: string, isOrgAdmin: boolean): Promise<boolean> {
|
||||
return (await getPendingAcceptances(userId, isOrgAdmin)).length === 0
|
||||
}
|
||||
|
||||
interface RecordInput {
|
||||
userId: string
|
||||
organizationId?: string | null
|
||||
context: LegalAcceptanceContext
|
||||
locale?: string | null
|
||||
/** Dokumenttypen, die zugestimmt werden. Es wird jeweils die AKTIVE Version protokolliert. */
|
||||
types: LegalDocumentType[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Protokolliert Zustimmungen revisionssicher. Bestehende identische Datensätze werden NICHT
|
||||
* überschrieben (Unique-Constraint + skipDuplicates). Nur aktive Dokumentversionen werden verbucht.
|
||||
* Gibt die Anzahl neu erfasster Zustimmungen zurück.
|
||||
*/
|
||||
export async function recordAcceptances(input: RecordInput): Promise<number> {
|
||||
const active = await getActiveDocuments()
|
||||
const byType = new Map(active.map(d => [d.type, d]))
|
||||
const rows = input.types
|
||||
.map(t => byType.get(t))
|
||||
.filter((d): d is ActiveDocument => !!d)
|
||||
.map(d => ({
|
||||
userId: input.userId,
|
||||
organizationId: input.organizationId ?? null,
|
||||
documentType: d.type,
|
||||
documentVersion: d.version,
|
||||
contentHash: d.contentHash,
|
||||
context: input.context,
|
||||
locale: input.locale ?? 'de-CH',
|
||||
}))
|
||||
if (rows.length === 0) return 0
|
||||
const res = await (prisma as any).legalAcceptance.createMany({ data: rows, skipDuplicates: true })
|
||||
return res?.count ?? 0
|
||||
}
|
||||
Reference in New Issue
Block a user