// 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()) /// Wie viel dieser Nutzer per Discord erfahren will. /// Siehe docs/06-chat-und-realtime.md. notifyMode NotifyMode @default(OWN_CASES) roles UserRole[] sessions Session[] personnelFiles PersonnelFile[] participations CaseParticipant[] assignments PersonnelAssignment[] savedViews SavedView[] exclusions CaseExclusion[] watches Watch[] readMarkers ReadMarker[] recentViews RecentView[] @@index([isActive, displayName]) } /// Was ein Nutzer per Discord erfahren will. enum NotifyMode { /// jede Aenderung in jeder Akte, die er sehen darf ALL /// nur Akten, an denen er beteiligt ist oder die er beobachtet OWN_CASES /// nur direkte Erwaehnungen 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 // --------------------------------------------------------------------------- /// Eine Vorlage beschreibt einen Bausteinsatz. Sie kann eine **Fallart** sein /// (bestimmt die Abschnitte einer Fallakte) oder eine **Dokumentart** (bestimmt /// den Aufbau eines Dokuments) oder beides. Was davon gilt, steht in `uses`. /// Eine neue Dokumentart ist damit ein Datensatz, kein Deployment. model DocumentTemplate { id String @id @default(uuid(7)) key String // "korruptionsverdacht" version Int name String description String? isActive Boolean @default(true) fields Json // TemplateField[] /// Wofuer die Vorlage verwendet werden darf. Liste statt Einzelwert, weil /// dieselbe Vorlage an mehreren Stellen sinnvoll sein kann. uses TemplateUse[] @default([CASE]) /// Bestimmt allein das Aussehen, nicht den Zugriff. Siehe docs/08. layout DocumentLayout @default(FORM) /// Kopfzeilen des Aktenblatts (layout = DOSSIER). Stehen in der Vorlage, /// damit eine weitere Dokumentart dieselbe Bauform mit eigener Beschriftung /// verwenden kann. sheetTitle String? sheetSubtitle String? /// 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[] documents Document[] @@unique([key, version]) @@index([key, isActive]) } /// Wofuer eine Vorlage taugt. enum TemplateUse { /// Fallart: bestimmt die Abschnitte einer Fallakte CASE /// Dokument in einer Fallakte CASE_DOCUMENT /// Dokument an einer Personalakte, etwa ein Sonderarbeitsvertrag PERSON_DOCUMENT /// Dokument ohne Besitzer, etwa ein Bussgeld STANDALONE } /// Bauform des Dokuments, siehe docs/13-entscheidungen.md. enum DocumentLayout { /// beschriftete Felder, ausgefuellt — der Vorfallsbericht FORM /// fester Wortlaut mit Platzhaltern, mehrseitig — der Sonderarbeitsvertrag PROSE /// ein einzelnes Freitextfeld, dargestellt wie ein Vermerk NOTE /// Aktenblatt der Foundation: schwarzer Rahmen, Kopf mit Kennung, /// Klassifizierungsbalken, Feldraster, Unterschriftenzeilen, Fusszeile DOSSIER } // --------------------------------------------------------------------------- // 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 DocumentTemplate @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") documents Document[] @@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 der Geschaeftsnummern, ein Nummernkreis je Praefix /// und Jahr. Hochgezaehlt in derselben Transaktion wie das Anlegen. Traegt /// Aktenzeichen und Dokumentnummern, das Praefix kommt aus der Vorlage. model NumberSeries { 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]) } /// Rollen eines Beteiligten. /// /// Zwei Gruppen, und der Unterschied entscheidet ueber den Zugriff: LEAD, /// INVESTIGATOR und OBSERVER **arbeiten** an der Akte und duerfen sie deshalb /// lesen. WITNESS, SUBJECT, SUSPECT und ACCOMPLICE sind Angaben **ueber** den /// Fall und verschaffen keinen Zugang — sonst laese der Beschuldigte die /// Ermittlung gegen sich selbst mit. Siehe lib/cases/terms.ts. enum ParticipantRole { LEAD INVESTIGATOR OBSERVER WITNESS SUBJECT SUSPECT ACCOMPLICE } /// 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]) } /// Verknuepfung zweier Akten. Umkehrbar loeschbar, siehe docs/09: kein /// Bestaetigungsdialog, sondern ein Rueckgaengig-Hinweis. model CaseLink { fromId String toId String kind LinkKind createdById String? 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 // --------------------------------------------------------------------------- /// Akte zu einer Person. Ueberwiegend zu **Externen** — Sicherheitsdienst, /// Zeugen, Vertragspartner — die kein Discord-Konto haben. `userId` bleibt /// deshalb der Ausnahmefall, nicht die Regel. model PersonnelFile { id String @id @default(uuid(7)) userId String? // null bei Personen ohne Discord-Konto displayName String rank String? /// Die Abteilung, der diese Person angehoert. Vorher stand hier ein /// Freitext; als Beziehung laesst sich fragen, was es zu einer Abteilung /// insgesamt gibt. Siehe docs/13-entscheidungen.md. departmentId String? /// Mehrere Etiketten zugleich: "Gekuendigt" und "Verhaftet" schliessen /// einander nicht aus. Die Werte sind Schluessel aus PersonnelStatusTag. statusTags String[] @default([]) classification Int @default(1) fields Json @default("{}") /// Freitext im Kopf der Akte, etwa "kein Discord-Konto, Aktenlage aus 2025". note String? createdById String? createdAt DateTime @default(now()) updatedAt DateTime @updatedAt searchVector Unsupported("tsvector")? user User? @relation(fields: [userId], references: [id]) department Department? @relation(fields: [departmentId], references: [id], onDelete: SetNull) messages ChatMessage[] participants CaseParticipant[] documents Document[] assignments PersonnelAssignment[] @@index([displayName]) @@index([userId]) @@index([departmentId]) @@index([classification]) @@index([statusTags], type: Gin) @@index([searchVector], map: "personnel_search_idx", type: Gin) @@index([displayName(ops: raw("gin_trgm_ops"))], map: "personnel_name_trgm_idx", type: Gin) } /// Auswahl der Status-Etiketten, im Adminbereich pflegbar. Die Namen sind /// Daten, keine Oberflaechentexte — siehe CLAUDE.md, Sprachkonvention. model PersonnelStatusTag { key String @id // "tot", "gekuendigt" name String // "Tot", "Gekuendigt" colorToken String @default("stamp-0") sortOrder Int @default(0) isActive Boolean @default(true) @@index([isActive, sortOrder]) } /// Ein AfiS-Mitglied, das an dieser Personalakte haengt: Bearbeiter, /// ermittelnder Beamter, Ansprechpartner. /// /// Wie `CaseParticipant` bei der Fallakte ist das zugleich der Weg zum /// **ausnahmsweisen Zugang**: wer hier eingetragen ist, liest die Akte /// unabhaengig von seiner Freigabestufe. Siehe docs/13-entscheidungen.md. model PersonnelAssignment { id String @id @default(uuid(7)) personnelFileId String userId String role AssignmentRole addedById String? createdAt DateTime @default(now()) personnelFile PersonnelFile @relation(fields: [personnelFileId], references: [id], onDelete: Cascade) user User @relation(fields: [userId], references: [id], onDelete: Cascade) @@unique([personnelFileId, userId, role]) @@index([userId]) } enum AssignmentRole { /// fuehrt die Akte HANDLER /// ermittelt gegen oder ueber die Person INVESTIGATOR /// Ansprechpartner, ohne eigene Ermittlung CONTACT } /// Die Freigabestufen der Foundation, wie sie im Kopf eines Dokuments stehen. /// **Sie steuern keinen Zugriff** — das tut allein ClearanceLevelConfig. /// Siehe docs/13-entscheidungen.md, "Zwei Freigaben im Kopf". model FoundationClearance { level Int @id // 1..5 name String // "Streng Geheim" colorHex String // Balkenfarbe im Aktenblatt sortOrder Int @default(0) } // --------------------------------------------------------------------------- // Dokumente // --------------------------------------------------------------------------- /// Eine Ausfertigung aus einer Vorlage: Vorfallsbericht, Sonderarbeitsvertrag, /// spaeter Bussgeld. 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 CLAUDE.md, harte Regel 2. model Document { id String @id @default(uuid(7)) documentNumber String @unique // SAV-2026-0007 title String templateId String templateKey String templateVersion Int /// Kopie aus der Vorlage: das Aussehen darf sich nicht aendern, wenn jemand /// spaeter die Vorlage umbaut. layout DocumentLayout @default(FORM) status DocumentStatus @default(DRAFT) classification Int @default(1) caseId String? personnelFileId String? createdById String createdAt DateTime @default(now()) updatedAt DateTime @updatedAt finalizedAt DateTime? finalizedById String? retractedAt DateTime? retractedById String? retractionReason String? /// Von einem Trigger gepflegt. Gewichtung A=Nummer/Titel, B=Abschnitte. searchVector Unsupported("tsvector")? template DocumentTemplate @relation(fields: [templateId], references: [id]) case Case? @relation(fields: [caseId], references: [id], onDelete: Restrict) personnelFile PersonnelFile? @relation(fields: [personnelFileId], references: [id], onDelete: Restrict) sections DocumentSection[] attachments Attachment[] @@index([caseId, createdAt(sort: Desc)]) @@index([personnelFileId, createdAt(sort: Desc)]) @@index([classification]) @@index([templateKey]) @@index([createdById]) // Von Hand in der Migration erzeugt, Prisma muss sie kennen. Siehe Case. @@index([searchVector], map: "document_search_idx", type: Gin) @@index([documentNumber(ops: raw("gin_trgm_ops"))], map: "document_number_trgm_idx", type: Gin) } enum DocumentStatus { /// in Arbeit, aenderbar DRAFT /// ausgefertigt, Aenderungen brauchen ein eigenes Recht FINAL /// zurueckgezogen, bleibt als Grabstein stehen RETRACTED } /// Ein ausgefuelltes Feld eines Dokuments. Dieselben Zusicherungen wie bei /// `CaseSection`: versioniert, `minClearance` je Feld, Diff, Protokoll. model DocumentSection { id String @id @default(uuid(7)) documentId String fieldKey String minClearance Int @default(0) currentVersion Int @default(1) value Json updatedAt DateTime @updatedAt updatedById String document Document @relation(fields: [documentId], references: [id], onDelete: Cascade) versions DocumentSectionVersion[] @@unique([documentId, fieldKey]) } model DocumentSectionVersion { id String @id @default(uuid(7)) sectionId String version Int value Json changeReason String? authorId String createdAt DateTime @default(now()) section DocumentSection @relation(fields: [sectionId], references: [id], onDelete: Cascade) @@unique([sectionId, version]) @@index([sectionId, version(sort: Desc)]) } // --------------------------------------------------------------------------- // Dateien // --------------------------------------------------------------------------- /// Eine hochgeladene Datei. Sie ist erst dann etwas wert, wenn sie die /// Pruefkette aus docs/07-dateien-und-sicherheit.md ganz durchlaufen hat: /// Typpruefung ueber Magic Bytes, ClamAV, Neukodierung. Bis dahin liegt sie in /// quarantine/ und wird nicht ausgeliefert. model Attachment { id String @id @default(uuid(7)) entryId String? caseId String? documentId String? originalName String /// Der **erkannte** Typ, nicht der gemeldete. Siehe lib/files/magic.ts. mimeType String sizeBytes Int sha256 String scanStatus ScanStatus @default(PENDING) scanMessage String? /// Pfad unterhalb von FILES_DIR, inklusive quarantine/ oder store/. storageKey String thumbKey String? blurhash String? width Int? height Int? uploadedById String createdAt DateTime @default(now()) /// Freigabe einer Datei, die die Pruefung nicht abschliessen konnte. /// Nur mit Recht 'admin', immer protokolliert. releasedById String? releasedAt DateTime? entry CaseEntry? @relation(fields: [entryId], references: [id], onDelete: Cascade) case Case? @relation(fields: [caseId], references: [id], onDelete: Cascade) document Document? @relation(fields: [documentId], references: [id], onDelete: Cascade) @@index([sha256]) @@index([scanStatus]) @@index([caseId]) @@index([entryId]) @@index([documentId]) @@index([uploadedById, createdAt]) } enum ScanStatus { PENDING CLEAN BLOCKED FAILED } // --------------------------------------------------------------------------- // Chat // --------------------------------------------------------------------------- /// Frei angelegter Raum fuer allgemeine Absprachen, mit eigener Mindeststufe. /// Eine Chatnachricht haengt immer an genau einer Akte: an einer Fallakte /// oder an einer Personalakte. Freie Kanaele gab es einmal; sie sind /// entfallen, weil das Gespraech dorthin gehoert, wo der Vorgang steht. /// Siehe docs/13-entscheidungen.md. model ChatMessage { id String @id // clientseitig erzeugt, UUIDv7 caseId String? personnelFileId String? departmentId String? authorId String body String replyToId String? editedAt DateTime? withdrawnAt DateTime? /// Gesetzt, sobald die Nachricht als Vermerk uebernommen wurde. adoptedEntryId String? /// Erwaehnte Nutzer, beim Senden aufgeloest. Steht hier und wird nicht beim /// Lesen aus dem Text geparst, weil der Worker sie fuer die /// Benachrichtigungen braucht, ohne den Text zu kennen. mentions String[] @default([]) createdAt DateTime @default(now()) case Case? @relation(fields: [caseId], references: [id], onDelete: Cascade) personnelFile PersonnelFile? @relation(fields: [personnelFileId], references: [id], onDelete: Cascade) department Department? @relation(fields: [departmentId], references: [id], onDelete: Cascade) replyTo ChatMessage? @relation("replies", fields: [replyToId], references: [id]) replies ChatMessage[] @relation("replies") reactions ChatReaction[] @@index([caseId, createdAt(sort: Desc)]) @@index([personnelFileId, createdAt(sort: Desc)]) @@index([departmentId, createdAt(sort: Desc)]) @@index([authorId]) @@index([replyToId]) @@index([mentions], type: Gin) } /// 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" | "personnel" 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]) } /// Was eine Sprungliste zeigen kann. enum RecentTarget { CASE PERSONNEL DOCUMENT } /// Die persoenliche Sprungliste: was dieser Nutzer zuletzt geoeffnet hat. /// /// Bewusst **kein** Verlauf. Je Nutzer und Datensatz gibt es eine einzige /// Zeile, die beim naechsten Oeffnen ueberschrieben wird, und aelteres faellt /// hinten heraus. Damit steht hier, was jemand zuletzt gesehen hat, aber /// weder wie oft noch in welcher Reihenfolge er gearbeitet hat. /// /// Gelesen wird sie nur vom Nutzer selbst. Wer wann was gelesen hat, /// beantwortet das Zugriffsprotokoll ab Stufe 3, und das bleibt die einzige /// Auskunft dieser Art. Siehe docs/13-entscheidungen.md. model RecentView { userId String targetType RecentTarget targetId String openedAt DateTime @default(now()) user User @relation(fields: [userId], references: [id], onDelete: Cascade) @@id([userId, targetType, targetId]) @@index([userId, openedAt(sort: Desc)]) } /// Was die Web-App dem Worker zum Verschicken uebergibt. /// /// Eine Tabelle statt eines direkten Aufrufs, weil der Worker ein eigener /// Prozess ist und laufen oder auch gerade neu starten kann. Ein Eintrag, der /// nicht rausging, bleibt liegen und wird beim naechsten Durchlauf abgeholt. /// Kein Einmal-Container, siehe CLAUDE.md, harte Regel 8. /// /// **Hier steht nie ein Inhalt.** Nur Kennungen und die Art des Ereignisses; /// den Text baut der Worker daraus, und Discord kennt unsere Klassifizierung /// nicht. Siehe docs/06-chat-und-realtime.md. model NotificationOutbox { id BigInt @id @default(autoincrement()) kind String // "entry.new", "chat.new", "chat.mention", "case.status" caseId String? personnelFileId String? /// Leer heisst: in den allgemeinen Discord-Kanal. Sonst als Direktnachricht. recipientId String? actorId String /// Nur Kennungen und Zahlen, nie Text aus einer Akte. meta Json? createdAt DateTime @default(now()) sentAt DateTime? attempts Int @default(0) lastError String? @@index([sentAt, createdAt]) @@index([caseId]) } /// Eine Abteilung der Foundation. /// /// Fast jede Person gehoert einer an, und die Abteilung ist die Ebene ueber /// der Personalakte: was zu ihr aktenkundig ist, ergibt sich aus ihren /// Mitgliedern. Deshalb traegt eine Fallakte **keine** Abteilung — die /// Zuordnung entsteht ueber die Beteiligten und deren Personalakten und ist /// damit immer aktuell. Siehe docs/13-entscheidungen.md. model Department { id String @id @default(uuid(7)) key String @unique // "SHD" name String description String? /// Wer diese Stufe nicht hat, sieht die Abteilung nicht. Sie steht auf 0, /// solange nichts dagegen spricht: dass es den Sicherheitsdienst gibt, ist /// kein Geheimnis. Was in ihm aktenkundig ist, filtert weiterhin jede Liste /// fuer sich. minClearance Int @default(0) isArchived Boolean @default(false) createdById String? createdAt DateTime @default(now()) updatedAt DateTime @updatedAt members PersonnelFile[] messages ChatMessage[] entries DepartmentEntry[] @@index([isArchived, name]) } /// Ein Vermerk an einer Abteilung. /// /// Dieselbe Zusicherung wie beim Verlauf einer Akte, in schmalerer Form: es /// gibt kein DELETE, jede Aenderung erzeugt eine Version, jede Zuruecknahme /// laesst einen Grabstein stehen. Was fehlt, sind die Teile, die nur eine /// Ermittlung braucht — Korrekturkette, Anhaenge, Karenzzeit. Ein /// Abteilungsvermerk ist ein laufendes Protokoll, kein Beweismittel. model DepartmentEntry { id String @id // clientseitig erzeugt, UUIDv7 departmentId String body String minClearance Int @default(0) authorId String // unveraenderlich createdAt DateTime @default(now()) // unveraenderlich version Int @default(1) editedAt DateTime? editedById String? retractedAt DateTime? retractedById String? retractionReason String? department Department @relation(fields: [departmentId], references: [id], onDelete: Cascade) versions DepartmentEntryVersion[] @@index([departmentId, createdAt(sort: Desc)]) @@index([authorId]) } /// Strikt anhaengend, enthaelt jeweils den VORHERIGEN Stand. model DepartmentEntryVersion { id String @id @default(uuid(7)) entryId String version Int body String minClearance Int editedById String editedAt DateTime @default(now()) entry DepartmentEntry @relation(fields: [entryId], references: [id]) @@unique([entryId, version]) @@index([entryId, version(sort: Desc)]) } /// Suchindex, **je Freigabestufe ein Vektor**. /// /// Der Vektor zur Stufe N enthaelt alles, was ein Leser dieser Stufe sehen /// darf: Kopfdaten, Abschnitte und Eintraege bis einschliesslich N, und aus /// dem Fliesstext die geschwaerzten Stellen bis N. Eine Suche nimmt genau /// eine Zeile — die zur Stufe des Suchenden — und kann deshalb nichts finden, /// was er nicht lesen darf. /// /// Der Vorgaenger war ein einziger Vektor mit nur dem, was jeder sehen darf. /// Der war leck-sicher, aber unbrauchbar: ab Stufe 1 fand die Suche nichts von /// dem, was den Fall ausmacht. Siehe docs/13-entscheidungen.md. model SearchIndex { targetType String // "case" | "document" | "personnel" targetId String minClearance Int // 0..5 vector Unsupported("tsvector") @@id([targetType, targetId, minClearance]) @@index([targetType, minClearance]) /// Der GIN-Index ueber den Vektor. Er muss **hier** stehen und nicht nur in /// der Migration: sonst sieht `prisma migrate dev` einen Unterschied zur /// Datenbank und will ihn wegraeumen. Siehe docs/13-entscheidungen.md. @@index([vector], type: Gin) } /// Eine gespeicherte Suche. Die mitgelieferten Ansichten stehen im Programm, /// hier stehen nur die selbst angelegten. model SavedView { id String @id @default(uuid(7)) userId String name String /// Die Filter als Abfrageparameter, so wie sie in der Adresse stehen. query String sortOrder Int @default(0) createdAt DateTime @default(now()) user User @relation(fields: [userId], references: [id], onDelete: Cascade) @@index([userId, sortOrder]) } /// Ein Exportauftrag. /// /// Der Export laeuft im Hintergrund, weil das Rendern auf einem Heimserver /// spuerbar CPU kostet. Der Klick erzeugt diesen Auftrag, die Oberflaeche /// wartet auf das Ereignis `export.ready`. /// /// `cacheKey` ist sha256 ueber Ziel, Stand, Stufe des Abrufers und /// Vorlagenversion: dieselbe Akte wird oft mehrfach exportiert, und zwei /// Abrufer verschiedener Stufen bekommen **nicht** dasselbe PDF. model ExportJob { id String @id @default(uuid(7)) kind ExportKind /// Fallakte oder Dokument. Leer beim Voll- und Sammelexport. targetId String? requestedById String /// Die Stufe des Abrufers zum Zeitpunkt der Anfrage. Sie steckt im /// Schluessel und im Wasserzeichen. clearanceAtTime Int status ExportStatus @default(QUEUED) cacheKey String /// Pfad unterhalb von FILES_DIR, sobald fertig. storageKey String? sizeBytes Int? error String? /// Vier-Augen-Prinzip ab Stufe 3, siehe docs/08-suche-und-export.md. approvedById String? approvedAt DateTime? rejectedById String? rejectReason String? createdAt DateTime @default(now()) startedAt DateTime? finishedAt DateTime? @@index([status, createdAt]) @@index([requestedById, createdAt(sort: Desc)]) @@index([cacheKey, status]) } enum ExportKind { /// eine Fallakte mit Verlauf und Anhangsverzeichnis CASE /// ein einzelnes Dokument, ohne Verlauf DOCUMENT /// mehrere Akten als ZIP, nur mit Recht 'admin' BUNDLE /// vollstaendiger JSON-Abzug, nur mit Recht 'admin' DATABASE } enum ExportStatus { /// wartet auf die Freigabe durch ein zweites Augenpaar PENDING_APPROVAL QUEUED RUNNING READY FAILED REJECTED } /// Gemessene Ladewerte aus den Browsern der Nutzer. /// /// Nur Zahlen und der Name der Route, **keine Kennungen und keine Adressen mit /// Aktenzeichen**: die Messung soll sagen, ob die Anwendung schnell ist, nicht /// wer wann welche Akte geoeffnet hat. Dafuer gibt es das Zugriffsprotokoll, /// und das ist der richtige Ort dafuer. model WebVital { id BigInt @id @default(autoincrement()) at DateTime @default(now()) /// "LCP", "INP", "CLS", "TTFB", "FCP" metric String value Float /// Das Routenmuster, nicht der konkrete Pfad: "/faelle/[caseNumber]". route String rating String // "good" | "needs-improvement" | "poor" @@index([metric, at]) @@index([route, metric]) } /// 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)]) }