# Datenmodell Prisma mit PostgreSQL. Zeitstempel immer `DateTime` in UTC, Anzeige in Europe/Berlin. IDs sind UUIDv7, damit sie zeitlich sortierbar und clientseitig erzeugbar sind (wichtig für optimistische Updates, siehe `docs/09-performance-und-ux.md`). ## Schema-Entwurf ```prisma model User { id String @id // Discord-Snowflake username String displayName String avatarHash String? isActive Boolean @default(true) firstSeenAt DateTime @default(now()) lastSeenAt DateTime @default(now()) roles UserRole[] sessions Session[] @@index([isActive, displayName]) } model UserRole { userId String discordRoleId String user User @relation(fields: [userId], references: [id], onDelete: Cascade) @@id([userId, discordRoleId]) } model RoleMapping { discordRoleId String @id label String // Anzeigename, aus Discord gespiegelt clearanceLevel Int @default(0) permissions String[] sortOrder Int @default(0) updatedAt DateTime @updatedAt } model ClearanceLevelConfig { level Int @id // 0..5 name String // "Vertraulich" hiddenBelow Boolean @default(false) // Existenz verbergen? colorToken String // siehe Design-System } model Session { id String @id @default(uuid()) userId String createdAt DateTime @default(now()) expiresAt DateTime lastSeenAt DateTime @default(now()) userAgent String? user User @relation(fields: [userId], references: [id], onDelete: Cascade) @@index([userId]) @@index([expiresAt]) } model CaseTemplate { id String @id @default(uuid()) key String // "korruptionsverdacht" version Int name String description String? isActive Boolean @default(true) fields Json // TemplateField[] createdAt DateTime @default(now()) @@unique([key, version]) } model Case { id String @id caseNumber String @unique // AfiS-2026-0042 title String status CaseStatus @default(DRAFT) classification Int @default(1) templateId String templateKey String dueAt DateTime? closedAt DateTime? outcome String? createdById String createdAt DateTime @default(now()) updatedAt DateTime @updatedAt searchVector Unsupported("tsvector")? sections CaseSection[] entries CaseEntry[] participants CaseParticipant[] exclusions CaseExclusion[] links CaseLink[] @relation("from") chat ChatMessage[] @@index([status, updatedAt(sort: Desc)]) @@index([classification]) @@index([dueAt]) } enum CaseStatus { DRAFT OPEN DORMANT REVIEW CLOSED ARCHIVED SEALED } model CaseSection { id String @id @default(uuid()) caseId String fieldKey String // Schlüssel aus der Vorlage minClearance Int @default(0) currentVersion Int @default(1) value Json updatedAt DateTime @updatedAt updatedById String case Case @relation(fields: [caseId], references: [id], onDelete: Cascade) versions CaseSectionVersion[] @@unique([caseId, fieldKey]) } model CaseSectionVersion { id String @id @default(uuid()) sectionId String version Int value Json authorId String createdAt DateTime @default(now()) section CaseSection @relation(fields: [sectionId], references: [id], onDelete: Cascade) @@unique([sectionId, version]) } model CaseEntry { id String @id // clientseitig erzeugt, UUIDv7 caseId String type EntryType body String minClearance Int @default(0) authorId String // unveränderlich createdAt DateTime @default(now()) // unveränderlich correctsId String? // Korrektur eines früheren Eintrags version Int @default(1) editedAt DateTime? editedById String? editVisible Boolean @default(false) // false = innerhalb der Karenzzeit geändert retractedAt DateTime? retractedById String? retractionReason String? versions CaseEntryVersion[] attachments Attachment[] case Case @relation(fields: [caseId], references: [id], onDelete: Cascade) @@index([caseId, createdAt(sort: Desc)]) @@index([caseId, editedAt(sort: Desc)]) } // Strikt anhängend. Enthält jeweils den VORHERIGEN Stand. Wird nie gelöscht, // auch nicht von Admins. Kein onDelete: Cascade nach oben durchreichen, das // Löschen eines Eintrags gibt es ohnehin nicht. model CaseEntryVersion { id String @id @default(uuid()) entryId String version Int body String minClearance Int // erbt Erhöhungen des Eintrags changeReason String? editedById String editedAt DateTime @default(now()) entry CaseEntry @relation(fields: [entryId], references: [id]) @@unique([entryId, version]) @@index([entryId, version(sort: Desc)]) } // Einstellungen, die der Leiter im Adminbereich pflegt. Einzeiler-Tabelle. model AppSetting { id Int @id @default(1) entryEditPolicy EditPolicy @default(GRACE_WINDOW) entryEditGraceMinutes Int @default(15) entryEditMarkGrace Boolean @default(false) updatedAt DateTime @updatedAt updatedById String? } enum EditPolicy { APPEND_ONLY GRACE_WINDOW OPEN } enum EntryType { NOTE INTERROGATION EVIDENCE STATUS_CHANGE CORRECTION SYSTEM } model CaseParticipant { caseId String userId String? personnelFileId String? role ParticipantRole case Case @relation(fields: [caseId], references: [id], onDelete: Cascade) @@id([caseId, role, userId, personnelFileId]) @@index([userId]) } enum ParticipantRole { LEAD INVESTIGATOR WITNESS SUBJECT OBSERVER } model CaseExclusion { caseId String userId String reason String? case Case @relation(fields: [caseId], references: [id], onDelete: Cascade) @@id([caseId, userId]) } model CaseLink { fromId String toId String kind LinkKind from Case @relation("from", fields: [fromId], references: [id], onDelete: Cascade) @@id([fromId, toId, kind]) } enum LinkKind { RELATED SUCCESSOR MERGED_INTO } // Ueberwiegend zu Externen — Sicherheitsdienst, Zeugen, Vertragspartner — // die kein Discord-Konto haben. userId ist der Ausnahmefall, nicht die Regel. model PersonnelFile { id String @id @default(uuid()) userId String? // null bei Personen ohne Discord-Konto displayName String note String? // Freitext im Kopf der Akte createdById String? documents Document[] assignments PersonnelAssignment[] rank String? department String? statusTags String[] @default([]) // Schluessel aus PersonnelStatusTag classification Int @default(1) fields Json searchVector Unsupported("tsvector")? @@index([displayName]) @@index([statusTags], type: Gin) } // Mehrere Etiketten zugleich: "Gekuendigt" und "Verhaftet" schliessen einander // nicht aus. Im Adminbereich pflegbar, deshalb eine Tabelle. model PersonnelStatusTag { key String @id // "tot", "gekuendigt" name String colorToken String @default("stamp-0") sortOrder Int @default(0) isActive Boolean @default(true) } // Ein AfiS-Mitglied an einer Personalakte. Wie CaseParticipant bei der Fallakte // zugleich der Weg zum ausnahmsweisen Zugang. model PersonnelAssignment { id String @id @default(uuid(7)) personnelFileId String userId String role AssignmentRole addedById String? @@unique([personnelFileId, userId, role]) } enum AssignmentRole { HANDLER INVESTIGATOR CONTACT } // Die Freigabestufen der Foundation, wie sie im Kopf eines Aktenblatts stehen. // Sie steuern KEINEN Zugriff — das tut allein ClearanceLevelConfig. model FoundationClearance { level Int @id // 1..5 name String // "Streng Geheim" colorHex String // Balkenfarbe im Aktenblatt sortOrder Int @default(0) } // --------------------------------------------------------------------------- // Dokumente (Phase 4) // // Eine Ausfertigung aus einer Vorlage: Vorfallsbericht, Sonderarbeitsvertrag, // Bussgeldbescheid. Besitzer ist eine Fallakte, eine Personalakte oder nichts; // hoechstens einer der beiden Fremdschluessel ist gesetzt, gesichert durch eine // CHECK-Bedingung in der Migration. // // Anhaengend wie ein Verlaufseintrag: es gibt kein DELETE, eine Zuruecknahme // laesst einen Grabstein stehen. Siehe docs/13-entscheidungen.md. // --------------------------------------------------------------------------- model Document { id String @id @default(uuid(7)) documentNumber String @unique // SAV-2026-0007 title String templateId String templateKey String templateVersion Int layout DocumentLayout @default(FORM) // Kopie aus der Vorlage status DocumentStatus @default(DRAFT) classification Int @default(1) caseId String? personnelFileId String? createdById String finalizedAt DateTime? retractedAt DateTime? retractionReason String? searchVector Unsupported("tsvector")? sections DocumentSection[] @@index([caseId, createdAt(sort: Desc)]) @@index([personnelFileId, createdAt(sort: Desc)]) } enum DocumentStatus { DRAFT FINAL RETRACTED } enum DocumentLayout { FORM PROSE NOTE } // Wie CaseSection, eine Ebene weiter: versioniert, minClearance je Feld, // Diff, Protokoll. Die Regeln stehen einmal in lib/sections/render.ts. model DocumentSection { id String @id @default(uuid(7)) documentId String fieldKey String minClearance Int @default(0) currentVersion Int @default(1) value Json updatedById String versions DocumentSectionVersion[] @@unique([documentId, fieldKey]) } model DocumentSectionVersion { id String @id @default(uuid(7)) sectionId String version Int value Json authorId String @@unique([sectionId, version]) } model Attachment { id String @id @default(uuid()) entryId String? caseId String? originalName String mimeType String sizeBytes Int sha256 String scanStatus ScanStatus @default(PENDING) scanMessage String? storageKey String // UUID-Pfad in store/ thumbKey String? blurhash String? uploadedById String createdAt DateTime @default(now()) entry CaseEntry? @relation(fields: [entryId], references: [id], onDelete: Cascade) @@index([sha256]) @@index([scanStatus]) } enum ScanStatus { PENDING CLEAN BLOCKED FAILED } model ChatMessage { id String @id // clientseitig, UUIDv7 caseId String? channelId String? authorId String body String replyToId String? editedAt DateTime? withdrawnAt DateTime? createdAt DateTime @default(now()) case Case? @relation(fields: [caseId], references: [id], onDelete: Cascade) @@index([caseId, createdAt(sort: Desc)]) @@index([channelId, createdAt(sort: Desc)]) } model ReadMarker { userId String scopeType String // "case" | "channel" scopeId String lastReadAt DateTime @@id([userId, scopeType, scopeId]) } model Watch { userId String caseId String @@id([userId, caseId]) } model AuditLog { id BigInt @id @default(autoincrement()) at DateTime @default(now()) actorId String action String // "case.read", "export.pdf", ... targetType String targetId String clearanceAtTime Int meta Json? @@index([targetType, targetId, at(sort: Desc)]) @@index([actorId, at(sort: Desc)]) } ``` ## Zusätzliche Migrationen von Hand Prisma kann das nicht ausdrücken, also als reines SQL in die Migration: ```sql -- Volltextsuche CREATE INDEX case_search_idx ON "Case" USING GIN (search_vector); CREATE INDEX entry_body_idx ON "CaseEntry" USING GIN (to_tsvector('german', body)); -- Trigger, der search_vector aus Titel, Aktenzeichen und Abschnittswerten füllt -- (Gewichtung: A = Aktenzeichen/Titel, B = Abschnitte, C = Verlaufseinträge) -- Verlaufseinträge dürfen nur angehängt und aktualisiert werden, nie gelöscht. -- Zweite Sicherung neben der Anwendungslogik, falls jemand direkt an der DB hantiert. CREATE RULE entry_no_delete AS ON DELETE TO "CaseEntry" DO INSTEAD NOTHING; CREATE RULE entry_version_no_delete AS ON DELETE TO "CaseEntryVersion" DO INSTEAD NOTHING; CREATE RULE entry_version_no_update AS ON UPDATE TO "CaseEntryVersion" DO INSTEAD NOTHING; -- search_vector muss auch bei UPDATE auf CaseEntry neu berechnet werden, -- sonst findet die Suche nach einer nachträglichen Änderung den alten Text. -- Nur offene Fälle, häufigste Abfrage der Startseite CREATE INDEX case_open_idx ON "Case" (updated_at DESC) WHERE status IN ('OPEN','REVIEW'); -- Aktenzeichen-Vergabe kollisionsfrei CREATE SEQUENCE case_number_2026 START 1; ``` Die Nummernvergabe läuft über eine Tabelle mit `INSERT ... ON CONFLICT DO UPDATE` in derselben Transaktion wie das Anlegen, damit bei parallelem Anlegen keine Lücken oder Doppelungen entstehen. **Abweichungen gegenüber dem Entwurf oben**, beide in Phase 4 entstanden und in `docs/13-entscheidungen.md` begründet: | Entwurf | Gebaut | |---|---| | `CaseTemplate` | `DocumentTemplate`, plus `uses` und `layout` | | `CaseCounter(year, lastNumber)` | `NumberSeries(prefix, year, lastNumber)` — ein Nummernkreis je Präfix, für Aktenzeichen und Dokumentnummern | | `PersonnelFile.serviceNumber` | entfallen, es gibt keine Dienstnummern | | `PersonnelFile.status` (ein Wert) | `statusTags` (mehrere), Auswahl aus `PersonnelStatusTag` |