Files
AfiS-Aktensystem/packages/db/prisma/schema.prisma
T
Harlekin cf4348b8b1 Phase 6 abgeschlossen: Aktenchat, Kanäle, Echtzeit, Benachrichtigungen
Ein Ereignisstrom mit Themen (/api/stream), in dem der Dateistrom aus Phase 5
aufgegangen ist. Zwei Prüfungen: beim Verbinden, welche Themen der Nutzer
abonnieren darf, und je Ereignis wird der Inhalt mit seinen Rechten geladen.
Das zweite ist nicht nur Vorsicht — eine Umklassifizierung wirkt dadurch in
einer offenen Verbindung sofort. In der pg_notify-Nutzlast steht nie ein
Inhalt, nur Thema, Art und Kennung.

Der Aktenchat sendet optimistisch mit clientseitiger UUIDv7, die zugleich
Idempotenzschlüssel ist. Antworten, vier feste Reaktionen, Bearbeiten mit
sichtbarem editedAt, Zurückziehen mit Platzhalter — Löschen gibt es nicht, und
eine Regel in der Datenbank hält das fest. Eine zurückgezogene Nachricht kommt
ohne Text an, nicht ausgeblendet.

Aktenzeichen im Text werden zu Vorschaukarten, serverseitig aufgelöst. Eine
Akte, die der Leser nicht sehen darf, sieht darin genauso aus wie eine, die es
gar nicht gibt.

"Als Vermerk übernehmen" steht sichtbar an jeder Nachricht. Der Vermerk gehört
dem Verfasser, nicht dem Übernehmenden; ein Systemeintrag nennt den Ursprung,
damit der Wortlaut später nicht für die Akte geschrieben wirkt.

Bei den Discord-Benachrichtigungen entscheidet die Web-App, wer was erfährt,
und der Worker liefert nur aus. Die Alternative hätte geheissen, die
Sichtbarkeitsprüfung im Worker ein zweites Mal zu bauen. In meta steht ein
Titel nur, wenn er hinaus darf — ab Stufe 3 keiner. Postausgang als Tabelle,
angestossen über LISTEN, mit Takt als Sicherheitsnetz.

Nebenbei riss das Budget der Fallakte erneut (122 kB). Drei Schritte zurück
auf 119,6: Chattexte in eine eigene i18n-Datei, ein gemeinsames AddPanel statt
viermal derselbe Aufklapp-Code, und eine eigene UUIDv7 statt des Pakets — die
Sortierbarkeit, auf der die Nachlieferung beruht, steht damit im eigenen Code
und hat Tests.

Unterwegs habe ich NotifyMode und User.notifyMode ein zweites Mal angelegt,
obwohl es beides seit dem Grundgerüst gibt. Die Migration schlug fehl und die
Datenbank musste von Hand aufgeräumt werden; die Lehre steht in docs/13.

155 Tests im Web-Paket, 15 in der Datenbank, 7 im Worker, 32 e2e.
2026-09-02 21:06:03 +02:00

839 lines
27 KiB
Plaintext

// 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[]
exclusions CaseExclusion[]
watches Watch[]
readMarkers ReadMarker[]
@@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])
}
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])
}
/// 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?
department 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])
participants CaseParticipant[]
documents Document[]
assignments PersonnelAssignment[]
@@index([displayName])
@@index([userId])
@@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.
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?
/// 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)
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])
@@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" | "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])
}
/// 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?
channelId String?
/// Leer heisst: in den allgemeinen 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])
}
/// 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)])
}