# 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 } model PersonnelFile { id String @id @default(uuid()) userId String? // null bei NPCs displayName String serviceNumber String? @unique rank String? department String? status String? classification Int @default(1) fields Json searchVector Unsupported("tsvector")? @@index([displayName]) } 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 `CaseCounter(year, lastNumber)` mit `SELECT ... FOR UPDATE` in derselben Transaktion wie das Anlegen, damit bei parallelem Anlegen keine Lücken oder Doppelungen entstehen.