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>
This commit is contained in:
@@ -0,0 +1,321 @@
|
||||
# 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.
|
||||
Reference in New Issue
Block a user