// 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. /// /// Der Worker spiegelt jede Rolle der Guild hierher, damit im Adminbereich /// nichts von Hand eingetragen werden muss. Neue Rollen stehen zunaechst auf /// Stufe 0, ohne Rechte und ohne Zugang. model RoleMapping { discordRoleId String @id label String // Anzeigename, aus Discord gespiegelt clearanceLevel Int @default(0) permissions String[] sortOrder Int @default(0) /// Tuersteher: nur mit einer solchen Rolle ist eine Anmeldung moeglich. /// Auf dem AfiS-Discord sind das "AfiS" und "Personalabteilung"; Gaeste auf /// dem Server haben sie nicht. Siehe docs/13-entscheidungen.md. grantsMembership Boolean @default(false) /// Nur fuer die Gruppierung im Adminbereich. Achtzehn Rollen in einer Liste /// sind unbrauchbar, drei benannte Abschnitte nicht. category RoleCategory @default(UNSORTED) /// Existiert die Rolle auf der Guild noch? Beim Umzug auf einen anderen /// Server bleiben die alten Zuordnungen als Vorlage stehen, statt sie zu /// loeschen: die Adminseite schlaegt sie anhand des Namens neu zu. isPresent Boolean @default(true) updatedAt DateTime @updatedAt @@index([category, sortOrder]) } enum RoleCategory { /// "AfiS", "Personalabteilung" — regelt, wer sich anmelden darf MEMBERSHIP /// Dienstgrade, regeln die Rechte RANK /// Zugangsrollen, regeln die Freigabestufe CLEARANCE /// Noch nicht eingeordnet UNSORTED } 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[] /// Muster des Aktenzeichens, siehe docs/13-entscheidungen.md. Die Abteilung /// fuehrt gewachsene Praefixe wie "AKT-PA"; der Nummernkreis laeuft je /// Praefix und Jahr, damit zwei Fallarten sich nicht in die Quere kommen. numberPrefix String @default("AfiS") numberPadding Int @default(4) 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]) // Diese beiden werden in der Migration von Hand erzeugt, muessen Prisma aber // bekannt sein: sonst hält Prisma sie fuer Drift und loescht sie bei der // naechsten Migration. Siehe docs/13-entscheidungen.md. @@index([searchVector], map: "case_search_idx", type: Gin) @@index([caseNumber(ops: raw("gin_trgm_ops"))], map: "case_number_trgm_idx", type: Gin) } enum CaseStatus { DRAFT OPEN DORMANT REVIEW CLOSED ARCHIVED SEALED } /// Kollisionsfreie Vergabe des Aktenzeichens, ein Nummernkreis je Praefix und /// Jahr. Hochgezaehlt in derselben Transaktion wie das Anlegen des Falls. model CaseCounter { prefix String year Int lastNumber Int @default(0) @@id([prefix, year]) } 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]) @@index([searchVector], map: "personnel_search_idx", type: Gin) @@index([displayName(ops: raw("gin_trgm_ops"))], map: "personnel_name_trgm_idx", type: Gin) } // --------------------------------------------------------------------------- // 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)]) }