// AfiS-Aktensystem — Datenmodell // Fachliche Grundlage: docs/04-datenmodell.md // // Konventionen: // - Zeitstempel immer UTC, Anzeige in Europe/Berlin. // - IDs sind UUIDv7, damit sie zeitlich sortierbar und clientseitig erzeugbar sind. // Wo der Client die ID erzeugt (CaseEntry, ChatMessage), ist sie zugleich // Idempotenzschluessel, siehe docs/09-performance-und-ux.md. // - Tabellen bleiben in PascalCase, Spalten in camelCase. Rohes SQL in den Migrationen // quotet entsprechend. // Prisma 7 erzeugt den Client als ECMAScript-Module in den Paketordner statt // nach node_modules/.prisma. Das ist hier zweimal wichtig: der Client überlebt // `pnpm deploy` beim Bauen der Images, und es entfällt der CommonJS-Umweg. generator client { provider = "prisma-client" output = "../src/generated" moduleFormat = "esm" // Node löst im ECMAScript-Modus keine endungslosen Importe auf, deshalb // müssen die erzeugten Dateien schon mit .js aufeinander verweisen. importFileExtension = "js" previewFeatures = ["postgresqlExtensions"] } // Verbindungsdaten stehen in prisma.config.ts, seit Prisma 7 nicht mehr hier. datasource db { provider = "postgresql" extensions = [pg_trgm] } // --------------------------------------------------------------------------- // Identitaet und Rechte // --------------------------------------------------------------------------- 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()) notifyMode NotifyMode @default(OWN_CASES) roles UserRole[] sessions Session[] personnelFiles PersonnelFile[] participations CaseParticipant[] exclusions CaseExclusion[] watches Watch[] readMarkers ReadMarker[] @@index([isActive, displayName]) } enum NotifyMode { ALL OWN_CASES MENTIONS OFF } model UserRole { userId String discordRoleId String user User @relation(fields: [userId], references: [id], onDelete: Cascade) @@id([userId, discordRoleId]) @@index([discordRoleId]) } /// Verbindet eine Discord-Rolle mit Freigabestufe und Rechten. /// Gepflegt im Adminbereich, siehe docs/03-auth-und-rechte.md. 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 hiddenBelow Boolean @default(false) // Existenz fuer niedrigere Stufen verbergen? colorToken String } model Session { id String @id @default(uuid(7)) 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]) } // --------------------------------------------------------------------------- // Vorlagen // --------------------------------------------------------------------------- model CaseTemplate { id String @id @default(uuid(7)) key String // "korruptionsverdacht" version Int name String description String? isActive Boolean @default(true) fields Json // TemplateField[] createdAt DateTime @default(now()) createdById String? cases Case[] @@unique([key, version]) @@index([key, isActive]) } // --------------------------------------------------------------------------- // Fallakten // --------------------------------------------------------------------------- model Case { id String @id @default(uuid(7)) 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 /// Von einem Trigger gepflegt, siehe Migration. Gewichtung A=Aktenzeichen/Titel, /// B=Abschnitte, C=Verlaufseintraege. searchVector Unsupported("tsvector")? template CaseTemplate @relation(fields: [templateId], references: [id]) sections CaseSection[] entries CaseEntry[] participants CaseParticipant[] exclusions CaseExclusion[] attachments Attachment[] chat ChatMessage[] watches Watch[] linksFrom CaseLink[] @relation("from") linksTo CaseLink[] @relation("to") @@index([status, updatedAt(sort: Desc)]) @@index([classification]) @@index([dueAt]) @@index([createdById]) } enum CaseStatus { DRAFT OPEN DORMANT REVIEW CLOSED ARCHIVED SEALED } /// Kollisionsfreie Vergabe des Aktenzeichens. Gelesen mit SELECT ... FOR UPDATE /// in derselben Transaktion wie das Anlegen des Falls. model CaseCounter { year Int @id lastNumber Int @default(0) } model CaseSection { id String @id @default(uuid(7)) caseId String fieldKey String // Schluessel 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(7)) sectionId String version Int value Json changeReason String? authorId String createdAt DateTime @default(now()) section CaseSection @relation(fields: [sectionId], references: [id], onDelete: Cascade) @@unique([sectionId, version]) @@index([sectionId, version(sort: Desc)]) } /// Verlaufseintrag. Nur anhaengend: es gibt kein DELETE, weder ueber die Anwendung /// noch ueber rohes SQL (Regel in der Migration). Aenderungen erzeugen eine Version, /// Zuruecknahmen lassen einen Grabstein stehen. Siehe docs/05-akten-und-vorlagen.md. model CaseEntry { id String @id // clientseitig erzeugt, UUIDv7 caseId String type EntryType body String minClearance Int @default(0) authorId String // unveraenderlich createdAt DateTime @default(now()) // unveraenderlich correctsId String? // Korrektur eines frueheren Eintrags version Int @default(1) editedAt DateTime? editedById String? editVisible Boolean @default(false) // false = innerhalb der Karenzzeit geaendert retractedAt DateTime? retractedById String? retractionReason String? case Case @relation(fields: [caseId], references: [id], onDelete: Cascade) corrects CaseEntry? @relation("corrections", fields: [correctsId], references: [id]) correctedBy CaseEntry[] @relation("corrections") versions CaseEntryVersion[] attachments Attachment[] @@index([caseId, createdAt(sort: Desc)]) @@index([caseId, editedAt(sort: Desc)]) @@index([authorId]) @@index([correctsId]) } /// Strikt anhaengend. Enthaelt jeweils den VORHERIGEN Stand. Wird nie geloescht, /// auch nicht von Admins. Ein Trigger laesst als einzige Aenderung das Anheben von /// minClearance zu, damit eine Umklassifizierung rueckwirkend wirkt und der alte /// Text nicht ueber die Historie leckt. model CaseEntryVersion { id String @id @default(uuid(7)) entryId String version Int body String minClearance Int // erbt Erhoehungen 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)]) } enum EntryType { NOTE INTERROGATION EVIDENCE STATUS_CHANGE CORRECTION SYSTEM } /// 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 } /// userId und personnelFileId sind beide optional (Beteiligte ohne Discord-Konto), /// deshalb ein eigener Schluessel statt eines zusammengesetzten Primaerschluessels: /// Postgres laesst NULL in einem Primaerschluessel nicht zu. Die Eindeutigkeit /// sichern zwei partielle Indizes in der Migration. model CaseParticipant { id String @id @default(uuid(7)) caseId String userId String? personnelFileId String? role ParticipantRole addedById String? createdAt DateTime @default(now()) case Case @relation(fields: [caseId], references: [id], onDelete: Cascade) user User? @relation(fields: [userId], references: [id], onDelete: Cascade) personnelFile PersonnelFile? @relation(fields: [personnelFileId], references: [id], onDelete: Cascade) @@index([caseId]) @@index([userId]) @@index([personnelFileId]) } enum ParticipantRole { LEAD INVESTIGATOR WITNESS SUBJECT OBSERVER } /// Schlaegt jede Freigabestufe. Deckt den Fall ab, dass gegen einen hochrangigen /// Mitarbeiter ermittelt wird. model CaseExclusion { caseId String userId String reason String? addedById String? createdAt DateTime @default(now()) case Case @relation(fields: [caseId], references: [id], onDelete: Cascade) user User @relation(fields: [userId], references: [id], onDelete: Cascade) @@id([caseId, userId]) @@index([userId]) } model CaseLink { fromId String toId String kind LinkKind createdAt DateTime @default(now()) from Case @relation("from", fields: [fromId], references: [id], onDelete: Cascade) to Case @relation("to", fields: [toId], references: [id], onDelete: Cascade) @@id([fromId, toId, kind]) @@index([toId]) } enum LinkKind { RELATED SUCCESSOR MERGED_INTO } // --------------------------------------------------------------------------- // Personalakten // --------------------------------------------------------------------------- model PersonnelFile { id String @id @default(uuid(7)) userId String? // null bei Personen ohne Discord-Konto displayName String serviceNumber String? @unique rank String? department String? status String? classification Int @default(1) fields Json @default("{}") createdAt DateTime @default(now()) updatedAt DateTime @updatedAt searchVector Unsupported("tsvector")? user User? @relation(fields: [userId], references: [id]) participants CaseParticipant[] @@index([displayName]) @@index([userId]) @@index([classification]) } // --------------------------------------------------------------------------- // Dateien // --------------------------------------------------------------------------- model Attachment { id String @id @default(uuid(7)) 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) case Case? @relation(fields: [caseId], references: [id], onDelete: Cascade) @@index([sha256]) @@index([scanStatus]) @@index([caseId]) @@index([entryId]) @@index([uploadedById, createdAt]) } enum ScanStatus { PENDING CLEAN BLOCKED FAILED } // --------------------------------------------------------------------------- // Chat // --------------------------------------------------------------------------- /// Frei angelegter Raum fuer allgemeine Absprachen, mit eigener Mindeststufe. model Channel { id String @id @default(uuid(7)) key String @unique name String description String? minClearance Int @default(0) isArchived Boolean @default(false) createdById String createdAt DateTime @default(now()) messages ChatMessage[] @@index([isArchived, name]) } model ChatMessage { id String @id // clientseitig erzeugt, UUIDv7 caseId String? channelId String? authorId String body String replyToId String? editedAt DateTime? withdrawnAt DateTime? /// Gesetzt, sobald die Nachricht als Vermerk uebernommen wurde. adoptedEntryId String? createdAt DateTime @default(now()) case Case? @relation(fields: [caseId], references: [id], onDelete: Cascade) channel Channel? @relation(fields: [channelId], references: [id], onDelete: Cascade) replyTo ChatMessage? @relation("replies", fields: [replyToId], references: [id]) replies ChatMessage[] @relation("replies") reactions ChatReaction[] @@index([caseId, createdAt(sort: Desc)]) @@index([channelId, createdAt(sort: Desc)]) @@index([authorId]) @@index([replyToId]) } /// Kleiner fester Satz an Reaktionen, keine freie Emoji-Auswahl. model ChatReaction { messageId String userId String key String createdAt DateTime @default(now()) message ChatMessage @relation(fields: [messageId], references: [id], onDelete: Cascade) @@id([messageId, userId, key]) } model ReadMarker { userId String scopeType String // "case" | "channel" scopeId String lastReadAt DateTime user User @relation(fields: [userId], references: [id], onDelete: Cascade) @@id([userId, scopeType, scopeId]) } model Watch { userId String caseId String user User @relation(fields: [userId], references: [id], onDelete: Cascade) case Case @relation(fields: [caseId], references: [id], onDelete: Cascade) @@id([userId, caseId]) @@index([caseId]) } /// Lebenszeichen der Dienste, die nicht im Web-Container laufen. Der Worker /// schreibt hier seinen Gateway-Zustand hinein, damit /api/health ihn pruefen /// kann, ohne Discord zu befragen. Siehe docs/11-betrieb.md. model ServiceHeartbeat { service String @id // "worker" status String // "up" | "degraded" | "down" detail String? at DateTime @default(now()) } // --------------------------------------------------------------------------- // Protokoll // --------------------------------------------------------------------------- /// Zweite, unabhaengige Spur neben der Versionstabelle. Wird nie geloescht. model AuditLog { id BigInt @id @default(autoincrement()) at DateTime @default(now()) actorId String action String // "case.read", "entry.edit", "export.pdf", ... targetType String targetId String clearanceAtTime Int meta Json? @@index([targetType, targetId, at(sort: Desc)]) @@index([actorId, at(sort: Desc)]) @@index([action, at(sort: Desc)]) }