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>
393 lines
12 KiB
Markdown
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 |
|