- ISMS-Routen, Actions, Server-/Lib-Code, Komponenten, Prisma-Modelle, Seeds, Importer, Skripte und ISMS-Tests entfernt (Fundament bleibt: Auth, Identity, MFA/WebAuthn, RBAC, Audit, Mail, Storage, Backup/DSGVO, Plattform-Admin) - Schema auf Fundament-Modelle reduziert; TenantSettings generisch (+phone/email) - TENANT_MODELS (db.ts, backup/topology.ts) und PII-Felder ausgedünnt - RBAC: Rollen tenant-admin/backoffice/team-lead/technician + Craftvia-Permissions - Modul-Katalog (customers, sites, teams, work_orders, imports, field, reports, emergency, documents, notifications, lotse) + Navigation aus src/lib/nav.ts - Modul-Routen mit requireModule-Layout und Platzhalterseite - Message-Katalog je Namespace (messages/<locale>/<namespace>.json), fs-Loader - check-module-guards: Modul-Key aus src/server/actions/<moduleKey>/ - Provisionierung, Admin-Konsole, Einstellungen, Files-Route, Mail entkoppelt - Seed minimal (demo/demo2, Nutzer je Rolle); Fundament-Tests auf Role/ NotificationPreference-Fixtures umgestellt Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
425 lines
16 KiB
Plaintext
425 lines
16 KiB
Plaintext
// Craftvia — Prisma-Schema (Fundament)
|
|
//
|
|
// Enthält ausschließlich die Plattform-Grundlage: Mandanten, Identitäten/Mitgliedschaften,
|
|
// RBAC, Audit-Log, Mail, Auth-Tokens, Backup/DSGVO. Die Craftvia-Fachmodelle (Kunden,
|
|
// Objekte, Aufträge, …) liefert die Architektur separat (docs/craftvia/ARCHITEKTUR.md).
|
|
//
|
|
// Multi-Tenant-Regel: Jede fachliche Tabelle trägt tenantId. Zugriff nur über den
|
|
// zentralen Tenant-Guard (src/server/db.ts); zusätzlich Postgres RLS.
|
|
// Neue Tenant-Tabelle ⇒ in der Migration `SELECT enable_tenant_rls('<table>');` aufrufen
|
|
// UND das Modell in beide TENANT_MODELS-Listen (src/server/db.ts, src/server/backup/topology.ts)
|
|
// eintragen — siehe docs/craftvia/MIGRATIONS.md.
|
|
|
|
generator client {
|
|
provider = "prisma-client-js"
|
|
previewFeatures = ["postgresqlExtensions"]
|
|
}
|
|
|
|
datasource db {
|
|
provider = "postgresql"
|
|
extensions = [pgvector(map: "vector")]
|
|
}
|
|
|
|
enum TenantStatus {
|
|
ACTIVE
|
|
SUSPENDED
|
|
ARCHIVED
|
|
}
|
|
|
|
enum UserStatus {
|
|
ACTIVE
|
|
INVITED
|
|
LOCKED
|
|
DEACTIVATED
|
|
}
|
|
|
|
model Tenant {
|
|
id String @id @default(cuid())
|
|
name String
|
|
slug String @unique
|
|
short String?
|
|
sector String?
|
|
status TenantStatus @default(ACTIVE)
|
|
config Json @default("{}")
|
|
createdAt DateTime @default(now()) @map("created_at")
|
|
updatedAt DateTime @updatedAt @map("updated_at")
|
|
|
|
users User[]
|
|
roles Role[]
|
|
auditLogs AuditLog[]
|
|
settings TenantSettings?
|
|
modules TenantModule[]
|
|
|
|
@@map("tenants")
|
|
}
|
|
|
|
// Mandanten-Einstellungen: Unternehmensdaten, Branding, Sicherheits-Policy.
|
|
model TenantSettings {
|
|
id String @id @default(cuid())
|
|
tenantId String @unique @map("tenant_id")
|
|
orgName String @map("org_name")
|
|
orgShort String? @map("org_short")
|
|
address String?
|
|
phone String?
|
|
email String?
|
|
sector String?
|
|
// Platzhalter für das Mandanten-Logo im Objektspeicher (Upload folgt).
|
|
logoKey String? @map("logo_key")
|
|
accent String?
|
|
locale String @default("de")
|
|
timezone String @default("Europe/Berlin")
|
|
securityPolicy Json @default("{}") @map("security_policy") // pw/mfa/session
|
|
smtp Json @default("{}")
|
|
createdAt DateTime @default(now()) @map("created_at")
|
|
updatedAt DateTime @updatedAt @map("updated_at")
|
|
|
|
tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade)
|
|
|
|
@@index([tenantId])
|
|
@@map("tenant_settings")
|
|
}
|
|
|
|
// Modul-Freischaltung je Mandant (Feature-Toggle) — gated Navigation, API und Daten
|
|
model TenantModule {
|
|
id String @id @default(cuid())
|
|
tenantId String @map("tenant_id")
|
|
moduleKey String @map("module_key")
|
|
enabled Boolean @default(true)
|
|
createdAt DateTime @default(now()) @map("created_at")
|
|
updatedAt DateTime @updatedAt @map("updated_at")
|
|
|
|
tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade)
|
|
|
|
@@unique([tenantId, moduleKey])
|
|
@@index([tenantId])
|
|
@@map("tenant_modules")
|
|
}
|
|
|
|
// Zentrale, GLOBALE Anmelde-Identität einer Person. E-Mail/Passwort/MFA leben hier;
|
|
// die per-Mandant-Zeile `User` ist die "Mitgliedschaft" und verweist per identityId hierher.
|
|
// - Identity ist GLOBAL: KEIN tenant_id, NICHT in TENANT_MODELS, KEINE RLS-Policy.
|
|
// Lookup läuft über den Owner-`prisma`-Client (Login).
|
|
model Identity {
|
|
id String @id @default(cuid())
|
|
email String @unique
|
|
passwordHash String @map("password_hash")
|
|
// Erzwungener Passwortwechsel beim nächsten Login (Initial-/Reset-Passwort).
|
|
mustChangePassword Boolean @default(false) @map("must_change_password")
|
|
// Optionale TOTP-MFA der Identity. recoveryCodes = Argon2id-Hashes (F-17).
|
|
mfaSecret String? @map("mfa_secret")
|
|
mfaEnrolledAt DateTime? @map("mfa_enrolled_at")
|
|
recoveryCodes Json @default("[]") @map("recovery_codes")
|
|
// F-17: zuletzt akzeptierter TOTP-Zeitschritt (RFC 6238) — Replay-Schutz.
|
|
lastTotpStep BigInt? @map("last_totp_step")
|
|
// Brute-Force-Schutz Login (F-05): Fehlversuchszähler + Sperre bis.
|
|
failedLogins Int @default(0) @map("failed_logins")
|
|
lockedUntil DateTime? @map("locked_until")
|
|
// SEC2: globaler Session-Kill-Switch. Ein Token, dessen `iat` davor liegt, gilt als ungültig.
|
|
sessionsValidAfter DateTime? @map("sessions_valid_after")
|
|
status String @default("ACTIVE") // ACTIVE | LOCKED | DISABLED
|
|
// Persönliche UI-Sprache der Person (folgt der Identity über alle Mandanten).
|
|
uiLocale String @default("de") @map("ui_locale") // de | en
|
|
createdAt DateTime @default(now()) @map("created_at")
|
|
updatedAt DateTime @updatedAt @map("updated_at")
|
|
|
|
memberships User[]
|
|
// Passkeys sind identitäts-, nicht mandantengebunden.
|
|
webauthnCredentials WebAuthnCredential[]
|
|
|
|
@@map("identities")
|
|
}
|
|
|
|
// Mitgliedschaft einer Identity in einem Mandanten.
|
|
model User {
|
|
id String @id @default(cuid())
|
|
tenantId String @map("tenant_id")
|
|
identityId String @map("identity_id")
|
|
email String // Denormalisierung (Anzeige/Filter, Upsert-Key tenantId_email)
|
|
name String
|
|
status UserStatus @default(ACTIVE)
|
|
createdAt DateTime @default(now()) @map("created_at")
|
|
updatedAt DateTime @updatedAt @map("updated_at")
|
|
|
|
tenant Tenant @relation(fields: [tenantId], references: [id])
|
|
identity Identity @relation(fields: [identityId], references: [id])
|
|
userRoles UserRole[]
|
|
|
|
@@unique([tenantId, email])
|
|
// Eine Person (Identity) hat je Mandant höchstens EINE Mitgliedschaft.
|
|
@@unique([tenantId, identityId])
|
|
@@index([tenantId])
|
|
@@index([identityId])
|
|
@@map("users")
|
|
}
|
|
|
|
// WebAuthn/Passkey-Credential einer GLOBALEN Identity (2. Faktor, Alternative zu TOTP).
|
|
// KEIN tenant_id, NICHT in TENANT_MODELS, keine RLS. credentialId/publicKey base64url.
|
|
model WebAuthnCredential {
|
|
id String @id @default(cuid())
|
|
identityId String @map("identity_id")
|
|
credentialId String @unique @map("credential_id")
|
|
publicKey String @map("public_key")
|
|
counter BigInt @default(0)
|
|
transports String[] @default([])
|
|
deviceName String? @map("device_name")
|
|
createdAt DateTime @default(now()) @map("created_at")
|
|
lastUsedAt DateTime? @map("last_used_at")
|
|
|
|
identity Identity @relation(fields: [identityId], references: [id], onDelete: Cascade)
|
|
|
|
@@index([identityId])
|
|
@@map("webauthn_credentials")
|
|
}
|
|
|
|
model Role {
|
|
id String @id @default(cuid())
|
|
tenantId String @map("tenant_id")
|
|
key String // z. B. tenant-admin, backoffice, team-lead, technician
|
|
name String
|
|
|
|
tenant Tenant @relation(fields: [tenantId], references: [id])
|
|
userRoles UserRole[]
|
|
rolePermissions RolePermission[]
|
|
|
|
@@unique([tenantId, key])
|
|
@@index([tenantId])
|
|
@@map("roles")
|
|
}
|
|
|
|
model Permission {
|
|
id String @id @default(cuid())
|
|
key String @unique // z. B. customer:read, work_order:write
|
|
|
|
rolePermissions RolePermission[]
|
|
|
|
@@map("permissions")
|
|
}
|
|
|
|
model UserRole {
|
|
userId String @map("user_id")
|
|
roleId String @map("role_id")
|
|
|
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
|
role Role @relation(fields: [roleId], references: [id], onDelete: Cascade)
|
|
|
|
@@id([userId, roleId])
|
|
@@map("user_roles")
|
|
}
|
|
|
|
model RolePermission {
|
|
roleId String @map("role_id")
|
|
permissionId String @map("permission_id")
|
|
|
|
role Role @relation(fields: [roleId], references: [id], onDelete: Cascade)
|
|
permission Permission @relation(fields: [permissionId], references: [id], onDelete: Cascade)
|
|
|
|
@@id([roleId, permissionId])
|
|
@@map("role_permissions")
|
|
}
|
|
|
|
// Plattform-Administratoren (Betreiber). Getrennter Store OHNE tenant_id, eigener
|
|
// Login (/platform/login), TOTP-MFA + Recovery-Codes. Kein Zugriff auf Mandanten-Fachdaten.
|
|
model PlatformAdmin {
|
|
id String @id @default(cuid())
|
|
email String @unique
|
|
passwordHash String @map("password_hash")
|
|
name String
|
|
status String @default("ACTIVE") // ACTIVE | LOCKED | DISABLED
|
|
// SEC4: Plattform-Rolle. "full" = Voll-Admin (darf verwalten), "readonly" = nur lesen.
|
|
role String @default("full") @map("role") // full | readonly
|
|
mfaSecret String? @map("mfa_secret")
|
|
mfaEnrolledAt DateTime? @map("mfa_enrolled_at")
|
|
recoveryCodes Json @default("[]") @map("recovery_codes")
|
|
failedLogins Int @default(0) @map("failed_logins")
|
|
lockedUntil DateTime? @map("locked_until")
|
|
lastTotpStep BigInt? @map("last_totp_step")
|
|
lastLoginAt DateTime? @map("last_login_at")
|
|
sessionsValidAfter DateTime? @map("sessions_valid_after")
|
|
createdAt DateTime @default(now()) @map("created_at")
|
|
updatedAt DateTime @updatedAt @map("updated_at")
|
|
|
|
@@map("platform_admins")
|
|
}
|
|
|
|
// Plattformweite Betriebseinstellungen (Singleton).
|
|
model PlatformSetting {
|
|
id String @id @default("singleton")
|
|
mfaRequired Boolean @default(false) @map("mfa_required")
|
|
|
|
// Backup-Zielspeicher. Präzedenz zur Laufzeit: DB-Config → Env (S3_*/BACKUP_LOCAL_DIR)
|
|
// → lokaler Default `.backups`. Das S3-Secret liegt NUR verschlüsselt (secret-crypto).
|
|
backupTarget String @default("local") @map("backup_target") // local | s3
|
|
backupLocalDir String? @map("backup_local_dir")
|
|
backupS3Endpoint String? @map("backup_s3_endpoint")
|
|
backupS3Bucket String? @map("backup_s3_bucket")
|
|
backupS3Region String? @map("backup_s3_region")
|
|
backupS3AccessKey String? @map("backup_s3_access_key")
|
|
backupS3SecretKeyEnc String? @map("backup_s3_secret_key_enc")
|
|
|
|
updatedAt DateTime @updatedAt @map("updated_at")
|
|
|
|
@@map("platform_settings")
|
|
}
|
|
|
|
model AuditLog {
|
|
id String @id @default(cuid())
|
|
// Nullable: Plattform-Ereignisse (scope=platform) haben keinen Mandantenbezug.
|
|
tenantId String? @map("tenant_id")
|
|
scope String @default("tenant") // tenant | platform
|
|
actorId String? @map("actor_id")
|
|
action String // create | update | delete | login | denied | export | …
|
|
entity String // z. B. user, role, tenant
|
|
entityId String? @map("entity_id")
|
|
before Json?
|
|
after Json?
|
|
createdAt DateTime @default(now()) @map("created_at")
|
|
|
|
tenant Tenant? @relation(fields: [tenantId], references: [id])
|
|
|
|
@@index([tenantId, entity, entityId])
|
|
@@index([tenantId, createdAt])
|
|
@@map("audit_logs")
|
|
}
|
|
|
|
/// Versandprotokoll jeder Mail (Auditierbarkeit + Idempotenz).
|
|
///
|
|
/// `tenantId` ist nullable: Plattform-Mails haben keinen Mandantenbezug (scope=platform).
|
|
/// `dedupeKey` ist plattformweit unique und verhindert Doppelversand.
|
|
/// Bewusst NICHT gespeichert: Mail-Inhalt und alle Token/Secrets.
|
|
model MailLog {
|
|
id String @id @default(cuid())
|
|
tenantId String? @map("tenant_id")
|
|
scope String @default("tenant") // tenant | platform
|
|
to String
|
|
template String
|
|
locale String @default("de")
|
|
status String @default("pending") // pending | sent | failed | bounced | suppressed
|
|
providerMessageId String? @map("provider_message_id")
|
|
error String?
|
|
attempts Int @default(0)
|
|
dedupeKey String? @unique @map("dedupe_key")
|
|
createdAt DateTime @default(now()) @map("created_at")
|
|
sentAt DateTime? @map("sent_at")
|
|
|
|
@@index([tenantId, createdAt])
|
|
@@index([status])
|
|
@@map("mail_logs")
|
|
}
|
|
|
|
/// Benachrichtigungs-Einstellung je Nutzer und Ereignistyp.
|
|
///
|
|
/// Default ist opt-in: fehlt eine Zeile, gilt `email = true`. Transaktionsmails
|
|
/// (Reset, Passwortwechsel) unterliegen dieser Steuerung NICHT.
|
|
model NotificationPreference {
|
|
id String @id @default(cuid())
|
|
tenantId String @map("tenant_id")
|
|
userId String @map("user_id")
|
|
eventType String @map("event_type") // Ereignistyp der Fachmodule, z. B. work_order_assigned
|
|
email Boolean @default(true)
|
|
locale String?
|
|
createdAt DateTime @default(now()) @map("created_at")
|
|
updatedAt DateTime @updatedAt @map("updated_at")
|
|
|
|
@@unique([userId, eventType])
|
|
@@index([tenantId])
|
|
@@map("notification_preferences")
|
|
}
|
|
|
|
/// Einmal-Token für Passwort-Reset, Einladung und E-Mail-Änderung.
|
|
///
|
|
/// Das Rohtoken steht ausschließlich im Link; in der DB liegt nur sein SHA-256-Hash.
|
|
/// `principalType` unterscheidet Mandanten-Nutzer und Plattform-Admins. `tenantId` ist
|
|
/// nur bei `tenant_user` gesetzt; Plattform-Zeilen haben `tenantId = null`.
|
|
model AuthToken {
|
|
id String @id @default(cuid())
|
|
principalType String @map("principal_type") // tenant_user | platform_admin
|
|
principalId String @map("principal_id")
|
|
tenantId String? @map("tenant_id")
|
|
type String // password_reset | email_change | invitation
|
|
tokenHash String @unique @map("token_hash")
|
|
/// Nur bei type=email_change: die zu bestätigende neue Adresse.
|
|
newEmail String? @map("new_email")
|
|
expiresAt DateTime @map("expires_at")
|
|
usedAt DateTime? @map("used_at")
|
|
requestIp String? @map("request_ip")
|
|
createdAt DateTime @default(now()) @map("created_at")
|
|
|
|
@@index([principalType, principalId, type])
|
|
@@index([expiresAt])
|
|
@@map("auth_tokens")
|
|
}
|
|
|
|
// =============================================================================
|
|
// Backup/DSGVO — GLOBALE Modelle (kein tenant_id-RLS)
|
|
// =============================================================================
|
|
|
|
/// Löschnachweis (Art. 17 DSGVO). GLOBAL — überdauert eine Mandanten-Löschung.
|
|
/// Enthält nur Metadaten, KEINE gelöschten Personendaten selbst.
|
|
model DeletionCertificate {
|
|
id String @id @default(cuid())
|
|
/// tenant | person
|
|
scope String
|
|
tenantId String? @map("tenant_id")
|
|
tenantSlug String? @map("tenant_slug")
|
|
subjectIdentityId String? @map("subject_identity_id")
|
|
subjectEmail String? @map("subject_email")
|
|
actorId String? @map("actor_id")
|
|
/// Zeilenzahlen je Modell, die HART gelöscht wurden (JSON: {model: count}).
|
|
deletedCounts Json @default("{}") @map("deleted_counts")
|
|
/// Zeilenzahlen je Modell, die ANONYMISIERT wurden.
|
|
anonymizedCounts Json @default("{}") @map("anonymized_counts")
|
|
snapshotId String? @map("snapshot_id")
|
|
reason String?
|
|
createdAt DateTime @default(now()) @map("created_at")
|
|
|
|
@@index([tenantId])
|
|
@@index([subjectIdentityId])
|
|
@@map("deletion_certificates")
|
|
}
|
|
|
|
/// Tombstone/Löschliste. GLOBAL — überlebt einen Tenant-Restore bewusst (nicht in
|
|
/// TENANT_MODELS), damit ein alter Snapshot gelöschte PII NICHT zurückbringt.
|
|
model TombstoneEntry {
|
|
id String @id @default(cuid())
|
|
tenantId String @map("tenant_id")
|
|
/// Prisma-Modellname der betroffenen Tabelle (z. B. "User").
|
|
model String
|
|
targetField String @map("target_field")
|
|
targetValue String @map("target_value")
|
|
/// delete | anonymize
|
|
action String
|
|
anonymizedFields Json @default("{}") @map("anonymized_fields")
|
|
reason String?
|
|
createdAt DateTime @default(now()) @map("created_at")
|
|
|
|
@@unique([tenantId, model, targetValue])
|
|
@@index([tenantId])
|
|
@@map("tombstone_entries")
|
|
}
|
|
|
|
/// Betreiber-Portal: Status eines Backup-/DSGVO-Jobs. GLOBAL (Plattform-Fähigkeit).
|
|
/// Enthält KEINE PII und KEINE Secrets — nur Ausführungs-Metadaten.
|
|
model BackupJob {
|
|
id String @id @default(cuid())
|
|
/// tenant_restore | tenant_export | dsgvo_export
|
|
kind String
|
|
/// queued | running | done | failed
|
|
status String @default("queued")
|
|
tenantId String @map("tenant_id")
|
|
tenantSlug String? @map("tenant_slug")
|
|
subjectIdentityId String? @map("subject_identity_id")
|
|
actorId String? @map("actor_id")
|
|
snapshotId String? @map("snapshot_id")
|
|
params Json @default("{}")
|
|
result Json?
|
|
error String?
|
|
downloadToken String? @unique @map("download_token")
|
|
downloadExpiresAt DateTime? @map("download_expires_at")
|
|
createdAt DateTime @default(now()) @map("created_at")
|
|
updatedAt DateTime @updatedAt @map("updated_at")
|
|
|
|
@@index([tenantId])
|
|
@@index([status])
|
|
@@map("backup_jobs")
|
|
}
|