Files
AfiS-Aktensystem/docs/04-datenmodell.md
T
HarlekinandClaude Opus 5 4446e3f0ac Fachdokumente nach docs/, Entscheidungen festgehalten
CLAUDE.md und README.md verweisen auf docs/, die Dateien lagen aber im
Wurzelverzeichnis. Verschoben.

docs/13-entscheidungen.md hält fest, wo die Dokumente sich widersprechen und
wie es aufgelöst wurde. Der wichtigste Punkt: 04-datenmodell.md verlangt eine
Regel, die jedes UPDATE auf CaseEntryVersion verwirft, 05-akten-und-vorlagen.md
verlangt, dass eine Umklassifizierung rückwirkend auf alle Versionen wirkt.
Beides zusammen geht nicht. Aufgelöst über einen Trigger, der die Tabelle
anhängend hält, aber das Anheben von minClearance zulässt.

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

322 lines
10 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 NPCs
displayName String
serviceNumber String? @unique
rank String?
department String?
status String?
classification Int @default(1)
fields Json
searchVector Unsupported("tsvector")?
@@index([displayName])
}
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 `CaseCounter(year, lastNumber)` mit
`SELECT ... FOR UPDATE` in derselben Transaktion wie das Anlegen, damit bei parallelem
Anlegen keine Lücken oder Doppelungen entstehen.