Das Formular der Personalabteilung ist übernommen, nicht durch eine Bildschirmansicht ersetzt: schwarzer Rahmen, Kopf mit Kennung und Klassifizierungsbalken, Raster aus fünf Spalten, Unterschriftenzeilen, Fußzeile. Es steht bewusst außerhalb des Design-Systems — es wird gedruckt und muss auf Papier aussehen wie das Blatt daneben. Datengetrieben bleibt es trotzdem. Kein Feld steht im Programm: sheetTitle und sheetSubtitle an der Vorlage, span für die Zellbreite, rows für das Lichtbild, dateKey für das Datum unter einer Unterschrift, slot für den Klassifizierungsbalken. Eine weitere Dokumentart mit layout = DOSSIER braucht keine Zeile Code. Der Balken zeigt die Foundation-Stufe aus der neuen Tabelle FoundationClearance. Sie ist Inhalt, kein Zugriff — was jemand sehen darf, entscheidet weiterhin allein die interne Stufe. Ohne Auswahl bleibt der Balken neutral, statt eine Einstufung zu behaupten. Die Dienstnummer ist entfallen, es gibt sie nicht. Der Status ist zu mehreren Etiketten geworden: "Gekündigt" und "Verhaftet" schließen einander nicht aus. Unbekannte Schlüssel werden verworfen und die Reihenfolge kommt aus der Konfiguration, nicht aus der Eingabe. PersonnelAssignment hängt AfiS-Mitglieder an eine Person — Aktenführung, Ermittler, Ansprechpartner — und ist zugleich der ausnahmsweise Zugang, wie CaseParticipant bei der Fallakte. Betroffen zu sein gibt weiterhin keinen Zugang: das ist eine Eigenschaft der Person, die Zuordnung eine Entscheidung der Abteilung. Fall und Person lassen sich von beiden Seiten verknüpfen und schon beim Anlegen einer Fallakte. Alle drei Wege laufen in addParticipant zusammen. Noch offen: das Lichtbild. Feldtyp und Platz stehen, hochladen kommt mit der Prüfkette in Phase 5 — ein Bild als Data-URL im Abschnittswert verstiesse gegen harte Regel 4. 102 Tests im Web-Paket, davon sechs auf dem gerenderten Blatt selbst. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
430 lines
14 KiB
Markdown
430 lines
14 KiB
Markdown
# Datenmodell
|
|
|
|
Prisma mit PostgreSQL. Zeitstempel immer `DateTime` in UTC, Anzeige in Europe/Berlin.
|
|
IDs sind UUIDv7, damit sie zeitlich sortierbar und clientseitig erzeugbar sind
|
|
(wichtig für optimistische Updates, siehe `docs/09-performance-und-ux.md`).
|
|
|
|
## Schema-Entwurf
|
|
|
|
```prisma
|
|
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())
|
|
roles UserRole[]
|
|
sessions Session[]
|
|
@@index([isActive, displayName])
|
|
}
|
|
|
|
model UserRole {
|
|
userId String
|
|
discordRoleId String
|
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
|
@@id([userId, discordRoleId])
|
|
}
|
|
|
|
model RoleMapping {
|
|
discordRoleId String @id
|
|
label String // Anzeigename, aus Discord gespiegelt
|
|
clearanceLevel Int @default(0)
|
|
permissions String[]
|
|
sortOrder Int @default(0)
|
|
updatedAt DateTime @updatedAt
|
|
}
|
|
|
|
model ClearanceLevelConfig {
|
|
level Int @id // 0..5
|
|
name String // "Vertraulich"
|
|
hiddenBelow Boolean @default(false) // Existenz verbergen?
|
|
colorToken String // siehe Design-System
|
|
}
|
|
|
|
model Session {
|
|
id String @id @default(uuid())
|
|
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])
|
|
}
|
|
|
|
model CaseTemplate {
|
|
id String @id @default(uuid())
|
|
key String // "korruptionsverdacht"
|
|
version Int
|
|
name String
|
|
description String?
|
|
isActive Boolean @default(true)
|
|
fields Json // TemplateField[]
|
|
createdAt DateTime @default(now())
|
|
@@unique([key, version])
|
|
}
|
|
|
|
model Case {
|
|
id String @id
|
|
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
|
|
searchVector Unsupported("tsvector")?
|
|
|
|
sections CaseSection[]
|
|
entries CaseEntry[]
|
|
participants CaseParticipant[]
|
|
exclusions CaseExclusion[]
|
|
links CaseLink[] @relation("from")
|
|
chat ChatMessage[]
|
|
|
|
@@index([status, updatedAt(sort: Desc)])
|
|
@@index([classification])
|
|
@@index([dueAt])
|
|
}
|
|
|
|
enum CaseStatus { DRAFT OPEN DORMANT REVIEW CLOSED ARCHIVED SEALED }
|
|
|
|
model CaseSection {
|
|
id String @id @default(uuid())
|
|
caseId String
|
|
fieldKey String // Schlüssel 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())
|
|
sectionId String
|
|
version Int
|
|
value Json
|
|
authorId String
|
|
createdAt DateTime @default(now())
|
|
section CaseSection @relation(fields: [sectionId], references: [id], onDelete: Cascade)
|
|
@@unique([sectionId, version])
|
|
}
|
|
|
|
model CaseEntry {
|
|
id String @id // clientseitig erzeugt, UUIDv7
|
|
caseId String
|
|
type EntryType
|
|
body String
|
|
minClearance Int @default(0)
|
|
authorId String // unveränderlich
|
|
createdAt DateTime @default(now()) // unveränderlich
|
|
correctsId String? // Korrektur eines früheren Eintrags
|
|
|
|
version Int @default(1)
|
|
editedAt DateTime?
|
|
editedById String?
|
|
editVisible Boolean @default(false) // false = innerhalb der Karenzzeit geändert
|
|
|
|
retractedAt DateTime?
|
|
retractedById String?
|
|
retractionReason String?
|
|
|
|
versions CaseEntryVersion[]
|
|
attachments Attachment[]
|
|
case Case @relation(fields: [caseId], references: [id], onDelete: Cascade)
|
|
@@index([caseId, createdAt(sort: Desc)])
|
|
@@index([caseId, editedAt(sort: Desc)])
|
|
}
|
|
|
|
// Strikt anhängend. Enthält jeweils den VORHERIGEN Stand. Wird nie gelöscht,
|
|
// auch nicht von Admins. Kein onDelete: Cascade nach oben durchreichen, das
|
|
// Löschen eines Eintrags gibt es ohnehin nicht.
|
|
model CaseEntryVersion {
|
|
id String @id @default(uuid())
|
|
entryId String
|
|
version Int
|
|
body String
|
|
minClearance Int // erbt Erhöhungen 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)])
|
|
}
|
|
|
|
// 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 }
|
|
|
|
enum EntryType { NOTE INTERROGATION EVIDENCE STATUS_CHANGE CORRECTION SYSTEM }
|
|
|
|
model CaseParticipant {
|
|
caseId String
|
|
userId String?
|
|
personnelFileId String?
|
|
role ParticipantRole
|
|
case Case @relation(fields: [caseId], references: [id], onDelete: Cascade)
|
|
@@id([caseId, role, userId, personnelFileId])
|
|
@@index([userId])
|
|
}
|
|
|
|
enum ParticipantRole { LEAD INVESTIGATOR WITNESS SUBJECT OBSERVER }
|
|
|
|
model CaseExclusion {
|
|
caseId String
|
|
userId String
|
|
reason String?
|
|
case Case @relation(fields: [caseId], references: [id], onDelete: Cascade)
|
|
@@id([caseId, userId])
|
|
}
|
|
|
|
model CaseLink {
|
|
fromId String
|
|
toId String
|
|
kind LinkKind
|
|
from Case @relation("from", fields: [fromId], references: [id], onDelete: Cascade)
|
|
@@id([fromId, toId, kind])
|
|
}
|
|
|
|
enum LinkKind { RELATED SUCCESSOR MERGED_INTO }
|
|
|
|
// Ueberwiegend zu Externen — Sicherheitsdienst, Zeugen, Vertragspartner —
|
|
// die kein Discord-Konto haben. userId ist der Ausnahmefall, nicht die Regel.
|
|
model PersonnelFile {
|
|
id String @id @default(uuid())
|
|
userId String? // null bei Personen ohne Discord-Konto
|
|
displayName String
|
|
note String? // Freitext im Kopf der Akte
|
|
createdById String?
|
|
documents Document[]
|
|
assignments PersonnelAssignment[]
|
|
rank String?
|
|
department String?
|
|
statusTags String[] @default([]) // Schluessel aus PersonnelStatusTag
|
|
classification Int @default(1)
|
|
fields Json
|
|
searchVector Unsupported("tsvector")?
|
|
@@index([displayName])
|
|
@@index([statusTags], type: Gin)
|
|
}
|
|
|
|
// Mehrere Etiketten zugleich: "Gekuendigt" und "Verhaftet" schliessen einander
|
|
// nicht aus. Im Adminbereich pflegbar, deshalb eine Tabelle.
|
|
model PersonnelStatusTag {
|
|
key String @id // "tot", "gekuendigt"
|
|
name String
|
|
colorToken String @default("stamp-0")
|
|
sortOrder Int @default(0)
|
|
isActive Boolean @default(true)
|
|
}
|
|
|
|
// Ein AfiS-Mitglied an einer Personalakte. Wie CaseParticipant bei der Fallakte
|
|
// zugleich der Weg zum ausnahmsweisen Zugang.
|
|
model PersonnelAssignment {
|
|
id String @id @default(uuid(7))
|
|
personnelFileId String
|
|
userId String
|
|
role AssignmentRole
|
|
addedById String?
|
|
@@unique([personnelFileId, userId, role])
|
|
}
|
|
|
|
enum AssignmentRole { HANDLER INVESTIGATOR CONTACT }
|
|
|
|
// Die Freigabestufen der Foundation, wie sie im Kopf eines Aktenblatts stehen.
|
|
// Sie steuern KEINEN Zugriff — das tut allein ClearanceLevelConfig.
|
|
model FoundationClearance {
|
|
level Int @id // 1..5
|
|
name String // "Streng Geheim"
|
|
colorHex String // Balkenfarbe im Aktenblatt
|
|
sortOrder Int @default(0)
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Dokumente (Phase 4)
|
|
//
|
|
// Eine Ausfertigung aus einer Vorlage: Vorfallsbericht, Sonderarbeitsvertrag,
|
|
// Bussgeldbescheid. 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 docs/13-entscheidungen.md.
|
|
// ---------------------------------------------------------------------------
|
|
|
|
model Document {
|
|
id String @id @default(uuid(7))
|
|
documentNumber String @unique // SAV-2026-0007
|
|
title String
|
|
templateId String
|
|
templateKey String
|
|
templateVersion Int
|
|
layout DocumentLayout @default(FORM) // Kopie aus der Vorlage
|
|
status DocumentStatus @default(DRAFT)
|
|
classification Int @default(1)
|
|
caseId String?
|
|
personnelFileId String?
|
|
createdById String
|
|
finalizedAt DateTime?
|
|
retractedAt DateTime?
|
|
retractionReason String?
|
|
searchVector Unsupported("tsvector")?
|
|
sections DocumentSection[]
|
|
@@index([caseId, createdAt(sort: Desc)])
|
|
@@index([personnelFileId, createdAt(sort: Desc)])
|
|
}
|
|
|
|
enum DocumentStatus { DRAFT FINAL RETRACTED }
|
|
enum DocumentLayout { FORM PROSE NOTE }
|
|
|
|
// Wie CaseSection, eine Ebene weiter: versioniert, minClearance je Feld,
|
|
// Diff, Protokoll. Die Regeln stehen einmal in lib/sections/render.ts.
|
|
model DocumentSection {
|
|
id String @id @default(uuid(7))
|
|
documentId String
|
|
fieldKey String
|
|
minClearance Int @default(0)
|
|
currentVersion Int @default(1)
|
|
value Json
|
|
updatedById String
|
|
versions DocumentSectionVersion[]
|
|
@@unique([documentId, fieldKey])
|
|
}
|
|
|
|
model DocumentSectionVersion {
|
|
id String @id @default(uuid(7))
|
|
sectionId String
|
|
version Int
|
|
value Json
|
|
authorId String
|
|
@@unique([sectionId, version])
|
|
}
|
|
|
|
model Attachment {
|
|
id String @id @default(uuid())
|
|
entryId String?
|
|
caseId String?
|
|
originalName String
|
|
mimeType String
|
|
sizeBytes Int
|
|
sha256 String
|
|
scanStatus ScanStatus @default(PENDING)
|
|
scanMessage String?
|
|
storageKey String // UUID-Pfad in store/
|
|
thumbKey String?
|
|
blurhash String?
|
|
uploadedById String
|
|
createdAt DateTime @default(now())
|
|
entry CaseEntry? @relation(fields: [entryId], references: [id], onDelete: Cascade)
|
|
@@index([sha256])
|
|
@@index([scanStatus])
|
|
}
|
|
|
|
enum ScanStatus { PENDING CLEAN BLOCKED FAILED }
|
|
|
|
model ChatMessage {
|
|
id String @id // clientseitig, UUIDv7
|
|
caseId String?
|
|
channelId String?
|
|
authorId String
|
|
body String
|
|
replyToId String?
|
|
editedAt DateTime?
|
|
withdrawnAt DateTime?
|
|
createdAt DateTime @default(now())
|
|
case Case? @relation(fields: [caseId], references: [id], onDelete: Cascade)
|
|
@@index([caseId, createdAt(sort: Desc)])
|
|
@@index([channelId, createdAt(sort: Desc)])
|
|
}
|
|
|
|
model ReadMarker {
|
|
userId String
|
|
scopeType String // "case" | "channel"
|
|
scopeId String
|
|
lastReadAt DateTime
|
|
@@id([userId, scopeType, scopeId])
|
|
}
|
|
|
|
model Watch {
|
|
userId String
|
|
caseId String
|
|
@@id([userId, caseId])
|
|
}
|
|
|
|
model AuditLog {
|
|
id BigInt @id @default(autoincrement())
|
|
at DateTime @default(now())
|
|
actorId String
|
|
action String // "case.read", "export.pdf", ...
|
|
targetType String
|
|
targetId String
|
|
clearanceAtTime Int
|
|
meta Json?
|
|
@@index([targetType, targetId, at(sort: Desc)])
|
|
@@index([actorId, at(sort: Desc)])
|
|
}
|
|
```
|
|
|
|
## Zusätzliche Migrationen von Hand
|
|
|
|
Prisma kann das nicht ausdrücken, also als reines SQL in die Migration:
|
|
|
|
```sql
|
|
-- Volltextsuche
|
|
CREATE INDEX case_search_idx ON "Case" USING GIN (search_vector);
|
|
CREATE INDEX entry_body_idx ON "CaseEntry" USING GIN (to_tsvector('german', body));
|
|
|
|
-- Trigger, der search_vector aus Titel, Aktenzeichen und Abschnittswerten füllt
|
|
-- (Gewichtung: A = Aktenzeichen/Titel, B = Abschnitte, C = Verlaufseinträge)
|
|
|
|
-- Verlaufseinträge dürfen nur angehängt und aktualisiert werden, nie gelöscht.
|
|
-- Zweite Sicherung neben der Anwendungslogik, falls jemand direkt an der DB hantiert.
|
|
CREATE RULE entry_no_delete AS ON DELETE TO "CaseEntry" DO INSTEAD NOTHING;
|
|
CREATE RULE entry_version_no_delete AS ON DELETE TO "CaseEntryVersion" DO INSTEAD NOTHING;
|
|
CREATE RULE entry_version_no_update AS ON UPDATE TO "CaseEntryVersion" DO INSTEAD NOTHING;
|
|
|
|
-- search_vector muss auch bei UPDATE auf CaseEntry neu berechnet werden,
|
|
-- sonst findet die Suche nach einer nachträglichen Änderung den alten Text.
|
|
|
|
-- Nur offene Fälle, häufigste Abfrage der Startseite
|
|
CREATE INDEX case_open_idx ON "Case" (updated_at DESC) WHERE status IN ('OPEN','REVIEW');
|
|
|
|
-- Aktenzeichen-Vergabe kollisionsfrei
|
|
CREATE SEQUENCE case_number_2026 START 1;
|
|
```
|
|
|
|
Die Nummernvergabe läuft über eine Tabelle mit `INSERT ... ON CONFLICT DO UPDATE` in
|
|
derselben Transaktion wie das Anlegen, damit bei parallelem Anlegen keine Lücken oder
|
|
Doppelungen entstehen.
|
|
|
|
**Abweichungen gegenüber dem Entwurf oben**, beide in Phase 4 entstanden und in
|
|
`docs/13-entscheidungen.md` begründet:
|
|
|
|
| Entwurf | Gebaut |
|
|
|---|---|
|
|
| `CaseTemplate` | `DocumentTemplate`, plus `uses` und `layout` |
|
|
| `CaseCounter(year, lastNumber)` | `NumberSeries(prefix, year, lastNumber)` — ein Nummernkreis je Präfix, für Aktenzeichen und Dokumentnummern |
|
|
| `PersonnelFile.serviceNumber` | entfallen, es gibt keine Dienstnummern |
|
|
| `PersonnelFile.status` (ein Wert) | `statusTags` (mehrere), Auswahl aus `PersonnelStatusTag` |
|