Files
AfiS-Aktensystem/packages/db/prisma/schema.prisma
T
Harlekin e0b03ea336
Prüfung / Lint, Typen, Tests (push) Canceled after 0s
Prüfung / Images bauen (pdf) (push) Canceled after 0s
Prüfung / Images bauen (web) (push) Canceled after 0s
Prüfung / Images bauen (worker) (push) Canceled after 0s
Images / bauen (pdf, docker/pdf.Dockerfile) (push) Canceled after 0s
Images / bauen (web, docker/web.Dockerfile) (push) Canceled after 0s
Images / bauen (worker, docker/worker.Dockerfile) (push) Canceled after 0s
Gedankenstriche durch Bindestriche ersetzt
375 Vorkommen in 210 Dateien. Betrifft Kommentare, Oberflaechentexte und die
README; an keiner Stelle Bedeutung, nur Zeichensetzung.
2026-09-04 08:54:52 +02:00

1075 lines
36 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[]
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 die Sprachkonvention: Code englisch, Oberflaeche deutsch.
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 Zusicherung 2 der README.
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 Zusicherung 8 der README.
///
/// **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)])
}