// 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('');` 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") }