Files
AfiS-Aktensystem/docs/04-datenmodell.md
T
HarlekinandClaude Opus 5 d65cc95eb2 Phase 4 abgeschlossen: Personalakten, Dokumente, Beteiligte, Verknüpfungen
Die Dokumentebene aus docs/13 ist gebaut, additiv: Document, DocumentSection
und DocumentSectionVersion neben Case, CaseSection und CaseEntry. Ein Dokument
hängt an einer Fallakte, an einer Personalakte oder an nichts, höchstens an
einem — gesichert durch eine CHECK-Bedingung. Es gibt kein DELETE; eine
Zurücknahme lässt einen Grabstein stehen, der auch für den Admin zu ist.

Eine neue Dokumentart ist damit ein Datensatz, kein Deployment. Im Seed stehen
vier: vermerk, vorfallsbericht, sonderarbeitsvertrag (mehrseitiger Vertragstext
mit Platzhaltern und Unterschriftenzeilen) und bussgeld.

Zwei Umbenennungen, beide von Hand migriert, damit die vorhandenen Zeilen und
Zählerstände stehen bleiben:
- CaseTemplate -> DocumentTemplate, plus uses[] und layout
- CaseCounter -> NumberSeries, ein Nummernkreis je Präfix für Aktenzeichen
  und Dokumentnummern

Die Regeln für Abschnitte stehen jetzt einmal in lib/sections/render.ts und
gelten für Fallakten und Dokumente gleichermaßen. Die Frage aus docs/13, ob
CaseSection langfristig in die Dokumentebene wandert, ist damit verneint und
begründet.

Rückverweise auf der Personalakte laufen durch visibleCaseWhere, die Zahl
daneben entsteht aus dem gefilterten Ergebnis. Auch die Rollengruppe
verschwindet mit, sonst verriete "Betroffener" ohne Eintrag die Ermittlung.
Die eigene Personalakte ist keine Ausnahme von der Freigabestufe.

Vier neue Rechte: document.create, .edit, .finalize, .retract. Das letzte
steht bei den zurückgehaltenen, es greift in Geschriebenes ein.

91 Tests im Web-Paket, 15 in der Datenbank, 28 e2e. Größte Route 118 kB
gegen das Budget von 120 kB — die Luft wird knapp.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-02 14:15:16 +02:00

393 lines
12 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 }
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[]
serviceNumber String? @unique
rank String?
department String?
status String?
classification Int @default(1)
fields Json
searchVector Unsupported("tsvector")?
@@index([displayName])
}
// ---------------------------------------------------------------------------
// 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 |