Freiwillige MFA, du kannst sie für dich aktivieren; Login verlangt danach den zweiten Faktor. - Schema/Migration: User.totpSecret/totpConfirmedAt/mfaBackupCodes + Tabelle webauthn_credentials (WebAuthn folgt), otplib v12 - lib/mfa.ts: TOTP-Secret/QR-URI/Verify + bcrypt-gehashte Backup-Codes - lib/auth.ts: buildSessionForUser, createMfaToken/verifyMfaToken (10-Min-Zwischentoken), userHasMfa - Login-Flow: nach Passwort -> bei aktiver MFA kein Cookie, sondern mfaToken; öffentlicher /api/auth/mfa-login/verify prüft TOTP ODER Backup-Code und setzt dann die Session (Rate-Limit) - Einrichtung in /settings: QR scannen, Code bestätigen, Backup-Codes (einmalig), deaktivieren - Admin-Reset-API: /api/admin/users/[id]/mfa-reset (SERVER_ADMIN alle, TENANT_ADMIN eigene Org) - Login-Seite: zweiter Schritt für Code/Backup-Code - SMS bewusst weggelassen (Kosten/SIM-Swap) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
614 lines
17 KiB
Plaintext
614 lines
17 KiB
Plaintext
generator client {
|
|
provider = "prisma-client-js"
|
|
}
|
|
|
|
datasource db {
|
|
provider = "postgresql"
|
|
url = env("DATABASE_URL")
|
|
}
|
|
|
|
enum Role {
|
|
SERVER_ADMIN
|
|
TENANT_ADMIN
|
|
OPERATOR
|
|
VIEWER
|
|
}
|
|
|
|
enum IconType {
|
|
STANDARD
|
|
RETTUNG
|
|
GEFAHRSTOFF
|
|
FEUER
|
|
WASSER
|
|
FAHRZEUG
|
|
}
|
|
|
|
enum ItemKind {
|
|
SYMBOL
|
|
LINE
|
|
POLYGON
|
|
RECTANGLE
|
|
CIRCLE
|
|
ARROW
|
|
TEXT
|
|
}
|
|
|
|
enum SubscriptionPlan {
|
|
FREE
|
|
PRO
|
|
}
|
|
|
|
enum DictionaryScope {
|
|
GLOBAL
|
|
TENANT
|
|
}
|
|
|
|
enum SubscriptionStatus {
|
|
ACTIVE
|
|
TRIAL
|
|
SUSPENDED
|
|
EXPIRED
|
|
CANCELLED
|
|
}
|
|
|
|
model Tenant {
|
|
id String @id @default(uuid())
|
|
name String
|
|
slug String @unique
|
|
description String?
|
|
isActive Boolean @default(true)
|
|
contactEmail String?
|
|
contactPhone String?
|
|
address String?
|
|
|
|
logoUrl String?
|
|
logoFileKey String?
|
|
|
|
// Subscription
|
|
plan SubscriptionPlan @default(FREE)
|
|
subscriptionStatus SubscriptionStatus @default(ACTIVE)
|
|
trialEndsAt DateTime?
|
|
subscriptionEndsAt DateTime?
|
|
maxUsers Int @default(5)
|
|
maxProjects Int @default(10)
|
|
notes String?
|
|
hiddenIconIds String[] @default([])
|
|
journalSuggestions String[] @default([])
|
|
// Modul-Baukasten: Cockpit-Konfiguration (siehe src/lib/modules.ts). NULL = Standard.
|
|
modulesConfig Json?
|
|
|
|
// Privacy consent
|
|
privacyAccepted Boolean @default(false)
|
|
privacyAcceptedAt DateTime?
|
|
adminAccessAccepted Boolean @default(false)
|
|
|
|
createdAt DateTime @default(now())
|
|
updatedAt DateTime @updatedAt
|
|
|
|
memberships TenantMembership[]
|
|
projects Project[]
|
|
hoseTypes HoseType[]
|
|
checkTemplates JournalCheckTemplate[]
|
|
iconCategories IconCategory[]
|
|
iconAssets IconAsset[]
|
|
tenantSymbols TenantSymbol[]
|
|
tenantCategories TenantCategory[]
|
|
upgradeRequests UpgradeRequest[]
|
|
dictionaryEntries DictionaryEntry[]
|
|
rapports Rapport[]
|
|
|
|
@@map("tenants")
|
|
}
|
|
|
|
// ─── System Settings (Key-Value, encrypted for secrets) ──────
|
|
|
|
model SystemSetting {
|
|
id String @id @default(uuid())
|
|
key String @unique
|
|
value String
|
|
isSecret Boolean @default(false)
|
|
category String @default("general")
|
|
updatedAt DateTime @updatedAt
|
|
|
|
@@map("system_settings")
|
|
}
|
|
|
|
model User {
|
|
id String @id @default(uuid())
|
|
email String @unique
|
|
password String
|
|
name String
|
|
role Role @default(OPERATOR)
|
|
emailVerified Boolean @default(true)
|
|
// Super-Admin kann einzelne Benutzer sperren; gesperrte können sich nicht mehr anmelden.
|
|
isActive Boolean @default(true)
|
|
emailVerificationToken String? @unique
|
|
resetToken String? @unique
|
|
resetTokenExpiry DateTime?
|
|
lastLoginAt DateTime?
|
|
// ─── Zwei-Faktor-Authentisierung (MFA) ───
|
|
// TOTP (Authenticator-App): Secret gesetzt = Einrichtung begonnen; confirmedAt gesetzt = aktiv.
|
|
totpSecret String?
|
|
totpConfirmedAt DateTime?
|
|
// Einmalige Backup-Codes (bcrypt-gehasht), Fallback wenn kein Faktor verfügbar.
|
|
mfaBackupCodes String[] @default([])
|
|
createdAt DateTime @default(now())
|
|
updatedAt DateTime @updatedAt
|
|
|
|
memberships TenantMembership[]
|
|
projects Project[]
|
|
iconAssets IconAsset[]
|
|
upgradeRequests UpgradeRequest[]
|
|
rapports Rapport[]
|
|
legalAcceptances LegalAcceptance[]
|
|
webauthnCredentials WebAuthnCredential[]
|
|
|
|
@@map("users")
|
|
}
|
|
|
|
// WebAuthn/FIDO2 (YubiKey, Passkeys, Touch-ID) — registrierte Sicherheitsschlüssel je Benutzer.
|
|
model WebAuthnCredential {
|
|
id String @id @default(uuid())
|
|
userId String
|
|
credentialId String @unique
|
|
publicKey String
|
|
counter Int @default(0)
|
|
transports String?
|
|
deviceName String?
|
|
createdAt DateTime @default(now())
|
|
lastUsedAt DateTime?
|
|
|
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
|
|
|
@@index([userId])
|
|
@@map("webauthn_credentials")
|
|
}
|
|
|
|
// ─── 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
|
|
// Nachweisdaten (zu Beweiszwecken der Einwilligung): IP + Gerät/Browser zum Zeitpunkt der Zustimmung.
|
|
ipAddress String?
|
|
userAgent 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)
|
|
createdAt DateTime @default(now())
|
|
|
|
userId String
|
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
|
tenantId String
|
|
tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade)
|
|
|
|
@@unique([userId, tenantId])
|
|
@@map("tenant_memberships")
|
|
}
|
|
|
|
model Project {
|
|
id String @id @default(uuid())
|
|
einsatzNr String?
|
|
// "EINSATZ" = echter Einsatz (mit Journal), "UEBUNG" = Übung (Cockpit statt Journal).
|
|
// Als String (nicht Enum) gehalten, damit die idempotente Raw-SQL-Migration einfach bleibt.
|
|
mode String @default("EINSATZ")
|
|
// Nur für Übungen: Gesamt-Auswertung / Lessons Learned (Freitext).
|
|
exerciseEvaluation String?
|
|
title String
|
|
location String?
|
|
description String?
|
|
einsatzleiter String?
|
|
journalfuehrer String?
|
|
mapCenter Json @default("{\"lng\": 8.5417, \"lat\": 47.3769}")
|
|
mapZoom Float @default(15)
|
|
// Windrichtung in Grad (meteorologisch „Wind aus"), fürs Lagebild. NULL = nicht gesetzt.
|
|
windDirection Int?
|
|
isLocked Boolean @default(false)
|
|
// Optimistische Nebenläufigkeit: wird bei jedem Features-Speichern hochgezählt.
|
|
// Verhindert, dass ein veralteter (z.B. offline zwischengespeicherter) Stand
|
|
// neuere Änderungen stillschweigend überschreibt.
|
|
featuresVersion Int @default(0)
|
|
|
|
// Öffentlicher Nur-Ansicht-Teilen-Link (live). shareToken = zufälliger Slug im Link,
|
|
// sharePin = bcrypt-Hash einer optionalen PIN (NULL = keine PIN), shareEnabled = aktiv/aus.
|
|
shareToken String? @unique
|
|
sharePin String?
|
|
shareEnabled Boolean @default(false)
|
|
|
|
// Live editing lock (session-based for same-account multi-device)
|
|
editingById String?
|
|
editingUserName String?
|
|
editingSessionId String?
|
|
editingStartedAt DateTime?
|
|
editingHeartbeat DateTime?
|
|
|
|
createdAt DateTime @default(now())
|
|
updatedAt DateTime @updatedAt
|
|
|
|
ownerId String?
|
|
owner User? @relation(fields: [ownerId], references: [id], onDelete: SetNull)
|
|
tenantId String?
|
|
tenant Tenant? @relation(fields: [tenantId], references: [id], onDelete: SetNull)
|
|
|
|
features Feature[]
|
|
items Item[]
|
|
journalEntries JournalEntry[]
|
|
journalCheckItems JournalCheckItem[]
|
|
journalPendenzen JournalPendenz[]
|
|
exerciseGoals ExerciseGoal[]
|
|
rapports Rapport[]
|
|
moduleItems ModuleItem[]
|
|
|
|
@@map("projects")
|
|
}
|
|
|
|
// ─── Übungs-Cockpit: Übungsziele mit Zielerreichung ──────────
|
|
|
|
model ExerciseGoal {
|
|
id String @id @default(uuid())
|
|
text String
|
|
// Zielerreichung: OPEN | REACHED | PARTIAL | MISSED
|
|
status String @default("OPEN")
|
|
note String?
|
|
sortOrder Int @default(0)
|
|
createdAt DateTime @default(now())
|
|
updatedAt DateTime @updatedAt
|
|
|
|
projectId String
|
|
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
|
|
|
|
@@index([projectId])
|
|
@@map("exercise_goals")
|
|
}
|
|
|
|
model Feature {
|
|
id String @id @default(uuid())
|
|
type String
|
|
geometry Json
|
|
properties Json @default("{}")
|
|
createdAt DateTime @default(now())
|
|
updatedAt DateTime @updatedAt
|
|
|
|
projectId String
|
|
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
|
|
|
|
@@map("features")
|
|
}
|
|
|
|
model IconCategory {
|
|
id String @id @default(uuid())
|
|
name String
|
|
description String?
|
|
sortOrder Int @default(0)
|
|
isGlobal Boolean @default(false)
|
|
createdAt DateTime @default(now())
|
|
|
|
tenantId String?
|
|
tenant Tenant? @relation(fields: [tenantId], references: [id], onDelete: SetNull)
|
|
|
|
icons IconAsset[]
|
|
|
|
@@map("icon_categories")
|
|
}
|
|
|
|
model IconAsset {
|
|
id String @id @default(uuid())
|
|
name String
|
|
fileKey String
|
|
mimeType String
|
|
width Int?
|
|
height Int?
|
|
isSystem Boolean @default(false)
|
|
isActive Boolean @default(true)
|
|
iconType IconType @default(STANDARD)
|
|
tags String[] @default([])
|
|
createdAt DateTime @default(now())
|
|
updatedAt DateTime @updatedAt
|
|
|
|
categoryId String?
|
|
category IconCategory? @relation(fields: [categoryId], references: [id], onDelete: SetNull)
|
|
|
|
ownerId String?
|
|
owner User? @relation(fields: [ownerId], references: [id], onDelete: SetNull)
|
|
|
|
tenantId String?
|
|
tenant Tenant? @relation(fields: [tenantId], references: [id], onDelete: SetNull)
|
|
|
|
tenantSymbols TenantSymbol[]
|
|
|
|
@@map("icon_assets")
|
|
}
|
|
|
|
model HoseType {
|
|
id String @id @default(uuid())
|
|
name String @unique
|
|
diameterMm Int
|
|
lengthPerPieceM Int @default(10)
|
|
flowRateLpm Float
|
|
frictionCoeff Float
|
|
description String?
|
|
isDefault Boolean @default(false)
|
|
isActive Boolean @default(true)
|
|
sortOrder Int @default(0)
|
|
createdAt DateTime @default(now())
|
|
updatedAt DateTime @updatedAt
|
|
|
|
tenantId String?
|
|
tenant Tenant? @relation(fields: [tenantId], references: [id], onDelete: SetNull)
|
|
|
|
@@map("hose_types")
|
|
}
|
|
|
|
// ─── Journal ───────────────────────────────────────────────
|
|
|
|
model JournalEntry {
|
|
id String @id @default(uuid())
|
|
time DateTime @default(now())
|
|
what String
|
|
who String?
|
|
done Boolean @default(false)
|
|
doneAt DateTime?
|
|
sortOrder Int @default(0)
|
|
isCorrected Boolean @default(false)
|
|
correctionOfId String?
|
|
createdAt DateTime @default(now())
|
|
updatedAt DateTime @updatedAt
|
|
|
|
projectId String
|
|
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
|
|
|
|
@@map("journal_entries")
|
|
}
|
|
|
|
model JournalCheckItem {
|
|
id String @id @default(uuid())
|
|
label String
|
|
confirmed Boolean @default(false)
|
|
confirmedAt DateTime?
|
|
ok Boolean @default(false)
|
|
okAt DateTime?
|
|
sortOrder Int @default(0)
|
|
createdAt DateTime @default(now())
|
|
updatedAt DateTime @updatedAt
|
|
|
|
projectId String
|
|
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
|
|
|
|
@@map("journal_check_items")
|
|
}
|
|
|
|
model JournalPendenz {
|
|
id String @id @default(uuid())
|
|
what String
|
|
who String?
|
|
whenHow String?
|
|
done Boolean @default(false)
|
|
doneAt DateTime?
|
|
sortOrder Int @default(0)
|
|
createdAt DateTime @default(now())
|
|
updatedAt DateTime @updatedAt
|
|
|
|
projectId String
|
|
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
|
|
|
|
@@map("journal_pendenzen")
|
|
}
|
|
|
|
// Generischer Datenspeicher für Tabellen-Module (Modul-Baukasten).
|
|
// data enthält die Zellwerte gemäss Spaltendefinition des Moduls,
|
|
// z.B. { "trupp": "Trupp 1", "druck": "280", "out": true, "outAt": "..." }
|
|
model ModuleItem {
|
|
id String @id @default(uuid())
|
|
moduleId String
|
|
data Json @default("{}")
|
|
sortOrder Int @default(0)
|
|
createdAt DateTime @default(now())
|
|
updatedAt DateTime @updatedAt
|
|
|
|
projectId String
|
|
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
|
|
|
|
@@index([projectId, moduleId])
|
|
@@map("module_items")
|
|
}
|
|
|
|
model JournalCheckTemplate {
|
|
id String @id @default(uuid())
|
|
label String
|
|
sortOrder Int @default(0)
|
|
isActive Boolean @default(true)
|
|
createdAt DateTime @default(now())
|
|
|
|
tenantId String?
|
|
tenant Tenant? @relation(fields: [tenantId], references: [id], onDelete: SetNull)
|
|
|
|
@@map("journal_check_templates")
|
|
}
|
|
|
|
model Item {
|
|
id String @id @default(uuid())
|
|
kind ItemKind
|
|
geometry Json
|
|
style Json @default("{}")
|
|
properties Json @default("{}")
|
|
isVisible Boolean @default(true)
|
|
sortOrder Int @default(0)
|
|
createdAt DateTime @default(now())
|
|
updatedAt DateTime @updatedAt
|
|
|
|
projectId String
|
|
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
|
|
|
|
iconId String?
|
|
|
|
@@map("items")
|
|
}
|
|
|
|
// ─── Upgrade Requests ─────────────────────────────────────
|
|
|
|
enum UpgradeRequestStatus {
|
|
PENDING
|
|
APPROVED
|
|
REJECTED
|
|
}
|
|
|
|
model UpgradeRequest {
|
|
id String @id @default(uuid())
|
|
requestedPlan SubscriptionPlan
|
|
currentPlan SubscriptionPlan
|
|
message String?
|
|
status UpgradeRequestStatus @default(PENDING)
|
|
adminNote String?
|
|
processedAt DateTime?
|
|
processedById String?
|
|
createdAt DateTime @default(now())
|
|
updatedAt DateTime @updatedAt
|
|
|
|
tenantId String
|
|
tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade)
|
|
|
|
requestedById String
|
|
requestedBy User @relation(fields: [requestedById], references: [id], onDelete: Cascade)
|
|
|
|
@@map("upgrade_requests")
|
|
}
|
|
|
|
// ─── Tenant Symbol Collection ─────────────────────────────
|
|
|
|
model TenantSymbol {
|
|
id String @id @default(uuid())
|
|
customName String?
|
|
sortOrder Int @default(0)
|
|
createdAt DateTime @default(now())
|
|
|
|
tenantId String
|
|
tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade)
|
|
|
|
iconId String?
|
|
icon IconAsset? @relation(fields: [iconId], references: [id], onDelete: SetNull)
|
|
|
|
// New fields for Phase 1 Symbol Architecture
|
|
categoryId String?
|
|
category TenantCategory? @relation(fields: [categoryId], references: [id], onDelete: SetNull)
|
|
|
|
name String? // Display name (migrated from customName || icon.name)
|
|
svgPath String? // e.g. "signaturen/TLF.svg" or tenant-specific MinIO key
|
|
isUploaded Boolean @default(false)
|
|
migratedFromIconId String?
|
|
|
|
@@index([tenantId])
|
|
@@index([categoryId])
|
|
@@map("tenant_symbols")
|
|
}
|
|
|
|
// ─── Symbol Templates (global read-only packages) ─────────
|
|
|
|
model SymbolTemplate {
|
|
id String @id @default(uuid())
|
|
packageId String // e.g. "feuerwehr-ch"
|
|
packageName String // e.g. "Feuerwehr Schweiz"
|
|
categoryName String // e.g. "Fahrzeuge"
|
|
name String
|
|
svgPath String // relative path in public/ or MinIO key
|
|
tags String[] @default([])
|
|
sortOrder Int @default(0)
|
|
|
|
@@index([packageId])
|
|
@@map("symbol_templates")
|
|
}
|
|
|
|
// ─── Tenant Categories (per-tenant, user-managed) ─────────
|
|
|
|
model TenantCategory {
|
|
id String @id @default(uuid())
|
|
tenantId String
|
|
tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade)
|
|
name String
|
|
sortOrder Int @default(0)
|
|
icon String? // Optional emoji or Lucide icon name for UI
|
|
|
|
symbols TenantSymbol[]
|
|
|
|
@@unique([tenantId, name])
|
|
@@index([tenantId])
|
|
@@map("tenant_categories")
|
|
}
|
|
|
|
// ─── Dictionary (Global + Tenant word library) ────────────
|
|
|
|
model DictionaryEntry {
|
|
id String @id @default(uuid())
|
|
word String
|
|
scope DictionaryScope @default(GLOBAL)
|
|
createdAt DateTime @default(now())
|
|
|
|
tenantId String?
|
|
tenant Tenant? @relation(fields: [tenantId], references: [id], onDelete: Cascade)
|
|
|
|
@@unique([word, tenantId])
|
|
@@map("dictionary_entries")
|
|
}
|
|
|
|
// ─── Rapport (PDF reports with public token access) ───────
|
|
|
|
model Rapport {
|
|
id String @id @default(uuid())
|
|
reportNumber String
|
|
token String @unique @default(uuid())
|
|
data Json
|
|
generatedAt DateTime @default(now())
|
|
createdAt DateTime @default(now())
|
|
updatedAt DateTime @updatedAt
|
|
|
|
projectId String
|
|
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
|
|
|
|
tenantId String?
|
|
tenant Tenant? @relation(fields: [tenantId], references: [id], onDelete: Cascade)
|
|
|
|
createdById String?
|
|
createdBy User? @relation(fields: [createdById], references: [id], onDelete: SetNull)
|
|
|
|
@@map("rapports")
|
|
}
|