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
375 Vorkommen in 210 Dateien. Betrifft Kommentare, Oberflaechentexte und die README; an keiner Stelle Bedeutung, nur Zeichensetzung.
1075 lines
36 KiB
Plaintext
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)])
|
|
}
|