Vier der acht Änderungswünsche. **Knöpfe.** Rund vierzig der siebzig Knöpfe waren blosser Text in Akzentfarbe, von einem Link nicht zu unterscheiden. Jeder trägt jetzt Rahmen und Grundfläche, über die Klasse .btn an einer Stelle definiert statt siebzigmal als Klassenkette. Beim Drücken zieht der Knopf sich zusammen und bekommt einen inneren Ring — er wirkt kurz dicker, ohne dass die Rahmenbreite sich ändert und das Umfeld springt; die Rückmeldung setzt ohne Verzögerung ein und klingt langsam ab, sonst bliebe ein kurzer Klick unsichtbar. Dazu die neue Regel: Flächen bleiben eckig, alles Bedienbare ist rund. Damit ist die Rundung eine Auskunft und keine Verzierung. **Verwaltung.** Rollen, Vorlagen und Änderungsrichtlinie waren drei Einträge in der Kopfzeile für etwas, das man selten und dann zusammenhängend braucht. Jetzt ein Eintrag /admin mit Reiterleiste, dazu eine gemeinsame Tabs-Komponente, die auch der Fallakte zeigt, welcher Reiter offen ist. **Kanäle.** Ersatzlos entfernt, mitsamt Tabelle. Der Chat gehört dorthin, wo der Vorgang steht; ein eigener Reiter mit freien Räumen war der Rückweg nach Discord. Die Personalakte hat jetzt denselben Chat wie die Fallakte — ohne „als Vermerk übernehmen", denn dafür braucht es einen Verlauf, und Meldungen gehen nur an Erwähnte, nie in den allgemeinen Kanal: dass es zu jemandem eine Personalakte gibt, ist selbst eine Auskunft. Ein CHECK erzwingt jetzt, dass eine Nachricht zu genau einem Ort gehört. **Kopfzeile.** Suchbalken statt Suchlink, als gewöhnliches Formular ohne ein Byte im Bündel. Thema, Benachrichtigungen und Abmelden sitzen hinter dem Profilbild, das von Discord kommt; wer keins hat, bekommt seine Initialen. Nebenbei: Aktenzeichen in Tests waren nur auf vier Stellen eindeutig und kollidierten zwischen Läufen, weil Akten nicht löschbar sind.
978 lines
32 KiB
Plaintext
978 lines
32 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])
|
|
}
|
|
|
|
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])
|
|
messages ChatMessage[]
|
|
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.
|
|
/// 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?
|
|
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)
|
|
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([authorId])
|
|
@@index([replyToId])
|
|
@@index([mentions], type: Gin)
|
|
}
|
|
|
|
/// Kleiner fester Satz an Reaktionen, keine freie Emoji-Auswahl.
|
|
model ChatReaction {
|
|
messageId String
|
|
userId String
|
|
key String
|
|
createdAt DateTime @default(now())
|
|
message ChatMessage @relation(fields: [messageId], references: [id], onDelete: Cascade)
|
|
|
|
@@id([messageId, userId, key])
|
|
}
|
|
|
|
model ReadMarker {
|
|
userId String
|
|
scopeType String // "case" | "personnel"
|
|
scopeId String
|
|
lastReadAt DateTime
|
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
|
|
|
@@id([userId, scopeType, scopeId])
|
|
}
|
|
|
|
model Watch {
|
|
userId String
|
|
caseId String
|
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
|
case Case @relation(fields: [caseId], references: [id], onDelete: Cascade)
|
|
|
|
@@id([userId, caseId])
|
|
@@index([caseId])
|
|
}
|
|
|
|
/// Was eine Sprungliste zeigen kann.
|
|
enum RecentTarget {
|
|
CASE
|
|
PERSONNEL
|
|
DOCUMENT
|
|
}
|
|
|
|
/// Die persoenliche Sprungliste: was dieser Nutzer zuletzt geoeffnet hat.
|
|
///
|
|
/// Bewusst **kein** Verlauf. Je Nutzer und Datensatz gibt es eine einzige
|
|
/// Zeile, die beim naechsten Oeffnen ueberschrieben wird, und aelteres faellt
|
|
/// hinten heraus. Damit steht hier, was jemand zuletzt gesehen hat, aber
|
|
/// weder wie oft noch in welcher Reihenfolge er gearbeitet hat.
|
|
///
|
|
/// Gelesen wird sie nur vom Nutzer selbst. Wer wann was gelesen hat,
|
|
/// beantwortet das Zugriffsprotokoll ab Stufe 3, und das bleibt die einzige
|
|
/// Auskunft dieser Art. Siehe docs/13-entscheidungen.md.
|
|
model RecentView {
|
|
userId String
|
|
targetType RecentTarget
|
|
targetId String
|
|
openedAt DateTime @default(now())
|
|
|
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
|
|
|
@@id([userId, targetType, targetId])
|
|
@@index([userId, openedAt(sort: Desc)])
|
|
}
|
|
|
|
|
|
/// Was die Web-App dem Worker zum Verschicken uebergibt.
|
|
///
|
|
/// Eine Tabelle statt eines direkten Aufrufs, weil der Worker ein eigener
|
|
/// Prozess ist und laufen oder auch gerade neu starten kann. Ein Eintrag, der
|
|
/// nicht rausging, bleibt liegen und wird beim naechsten Durchlauf abgeholt.
|
|
/// Kein Einmal-Container, siehe CLAUDE.md, harte Regel 8.
|
|
///
|
|
/// **Hier steht nie ein Inhalt.** Nur Kennungen und die Art des Ereignisses;
|
|
/// den Text baut der Worker daraus, und Discord kennt unsere Klassifizierung
|
|
/// nicht. Siehe docs/06-chat-und-realtime.md.
|
|
model NotificationOutbox {
|
|
id BigInt @id @default(autoincrement())
|
|
kind String // "entry.new", "chat.new", "chat.mention", "case.status"
|
|
caseId String?
|
|
personnelFileId String?
|
|
/// Leer heisst: in den allgemeinen Discord-Kanal. Sonst als Direktnachricht.
|
|
recipientId String?
|
|
actorId String
|
|
/// Nur Kennungen und Zahlen, nie Text aus einer Akte.
|
|
meta Json?
|
|
createdAt DateTime @default(now())
|
|
sentAt DateTime?
|
|
attempts Int @default(0)
|
|
lastError String?
|
|
|
|
@@index([sentAt, createdAt])
|
|
@@index([caseId])
|
|
}
|
|
|
|
/// 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])
|
|
}
|
|
|
|
/// 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)])
|
|
}
|