Workspace und Datenmodell
pnpm-Workspace mit web/, worker/, pdf/ und packages/db. Das Datenbankpaket ist eigenständig, weil Web-App und Worker denselben Prisma-Client brauchen. Schema nach docs/04-datenmodell.md, mit drei Abweichungen, die dort begründet sind: CaseParticipant bekommt einen eigenen Schlüssel statt eines zusammengesetzten mit NULL-Spalten, die Aktenzeichen laufen über CaseCounter statt über eine Sequenz pro Jahr, und ServiceHeartbeat kommt dazu, damit /api/health den Gateway-Zustand kennt, ohne Discord zu fragen. Die zweite Migration enthält, was Prisma nicht ausdrücken kann: - kein DELETE auf CaseEntry, CaseEntryVersion und AuditLog - authorId, createdAt, caseId und type eines Eintrags sind unveränderlich - an einer Eintragsversion darf nur minClearance angehoben werden - Volltextsuche mit deutscher Konfiguration; geschwärzte Bereiche werden vor dem Indizieren entfernt, sonst verrät ein Treffer den Inhalt - pg_notify bei Rollen- und Mapping-Änderungen Acht Tests prüfen diese Regeln gegen eine echte Postgres-Instanz, nicht gegen die Anwendungslogik. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
4446e3f0ac
commit
51984f17d4
@@ -0,0 +1,523 @@
|
||||
// AfiS-Aktensystem — Datenmodell
|
||||
// Fachliche Grundlage: docs/04-datenmodell.md
|
||||
//
|
||||
// Konventionen:
|
||||
// - Zeitstempel immer UTC, Anzeige in Europe/Berlin.
|
||||
// - IDs sind UUIDv7, damit sie zeitlich sortierbar und clientseitig erzeugbar sind.
|
||||
// Wo der Client die ID erzeugt (CaseEntry, ChatMessage), ist sie zugleich
|
||||
// Idempotenzschluessel, siehe docs/09-performance-und-ux.md.
|
||||
// - Tabellen bleiben in PascalCase, Spalten in camelCase. Rohes SQL in den Migrationen
|
||||
// quotet entsprechend.
|
||||
|
||||
// Prisma 7 erzeugt den Client als ECMAScript-Module in den Paketordner statt
|
||||
// nach node_modules/.prisma. Das ist hier zweimal wichtig: der Client überlebt
|
||||
// `pnpm deploy` beim Bauen der Images, und es entfällt der CommonJS-Umweg.
|
||||
generator client {
|
||||
provider = "prisma-client"
|
||||
output = "../src/generated"
|
||||
moduleFormat = "esm"
|
||||
// Node löst im ECMAScript-Modus keine endungslosen Importe auf, deshalb
|
||||
// müssen die erzeugten Dateien schon mit .js aufeinander verweisen.
|
||||
importFileExtension = "js"
|
||||
previewFeatures = ["postgresqlExtensions"]
|
||||
}
|
||||
|
||||
// Verbindungsdaten stehen in prisma.config.ts, seit Prisma 7 nicht mehr hier.
|
||||
datasource db {
|
||||
provider = "postgresql"
|
||||
extensions = [pg_trgm]
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Identitaet und Rechte
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
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())
|
||||
|
||||
notifyMode NotifyMode @default(OWN_CASES)
|
||||
|
||||
roles UserRole[]
|
||||
sessions Session[]
|
||||
personnelFiles PersonnelFile[]
|
||||
participations CaseParticipant[]
|
||||
exclusions CaseExclusion[]
|
||||
watches Watch[]
|
||||
readMarkers ReadMarker[]
|
||||
|
||||
@@index([isActive, displayName])
|
||||
}
|
||||
|
||||
enum NotifyMode {
|
||||
ALL
|
||||
OWN_CASES
|
||||
MENTIONS
|
||||
OFF
|
||||
}
|
||||
|
||||
model UserRole {
|
||||
userId String
|
||||
discordRoleId String
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@id([userId, discordRoleId])
|
||||
@@index([discordRoleId])
|
||||
}
|
||||
|
||||
/// Verbindet eine Discord-Rolle mit Freigabestufe und Rechten.
|
||||
/// Gepflegt im Adminbereich, siehe docs/03-auth-und-rechte.md.
|
||||
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
|
||||
hiddenBelow Boolean @default(false) // Existenz fuer niedrigere Stufen verbergen?
|
||||
colorToken String
|
||||
}
|
||||
|
||||
model Session {
|
||||
id String @id @default(uuid(7))
|
||||
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])
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Vorlagen
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
model CaseTemplate {
|
||||
id String @id @default(uuid(7))
|
||||
key String // "korruptionsverdacht"
|
||||
version Int
|
||||
name String
|
||||
description String?
|
||||
isActive Boolean @default(true)
|
||||
fields Json // TemplateField[]
|
||||
createdAt DateTime @default(now())
|
||||
createdById String?
|
||||
|
||||
cases Case[]
|
||||
|
||||
@@unique([key, version])
|
||||
@@index([key, isActive])
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Fallakten
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
model Case {
|
||||
id String @id @default(uuid(7))
|
||||
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
|
||||
|
||||
/// Von einem Trigger gepflegt, siehe Migration. Gewichtung A=Aktenzeichen/Titel,
|
||||
/// B=Abschnitte, C=Verlaufseintraege.
|
||||
searchVector Unsupported("tsvector")?
|
||||
|
||||
template CaseTemplate @relation(fields: [templateId], references: [id])
|
||||
sections CaseSection[]
|
||||
entries CaseEntry[]
|
||||
participants CaseParticipant[]
|
||||
exclusions CaseExclusion[]
|
||||
attachments Attachment[]
|
||||
chat ChatMessage[]
|
||||
watches Watch[]
|
||||
linksFrom CaseLink[] @relation("from")
|
||||
linksTo CaseLink[] @relation("to")
|
||||
|
||||
@@index([status, updatedAt(sort: Desc)])
|
||||
@@index([classification])
|
||||
@@index([dueAt])
|
||||
@@index([createdById])
|
||||
}
|
||||
|
||||
enum CaseStatus {
|
||||
DRAFT
|
||||
OPEN
|
||||
DORMANT
|
||||
REVIEW
|
||||
CLOSED
|
||||
ARCHIVED
|
||||
SEALED
|
||||
}
|
||||
|
||||
/// Kollisionsfreie Vergabe des Aktenzeichens. Gelesen mit SELECT ... FOR UPDATE
|
||||
/// in derselben Transaktion wie das Anlegen des Falls.
|
||||
model CaseCounter {
|
||||
year Int @id
|
||||
lastNumber Int @default(0)
|
||||
}
|
||||
|
||||
model CaseSection {
|
||||
id String @id @default(uuid(7))
|
||||
caseId String
|
||||
fieldKey String // Schluessel 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(7))
|
||||
sectionId String
|
||||
version Int
|
||||
value Json
|
||||
changeReason String?
|
||||
authorId String
|
||||
createdAt DateTime @default(now())
|
||||
section CaseSection @relation(fields: [sectionId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@unique([sectionId, version])
|
||||
@@index([sectionId, version(sort: Desc)])
|
||||
}
|
||||
|
||||
/// Verlaufseintrag. Nur anhaengend: es gibt kein DELETE, weder ueber die Anwendung
|
||||
/// noch ueber rohes SQL (Regel in der Migration). Aenderungen erzeugen eine Version,
|
||||
/// Zuruecknahmen lassen einen Grabstein stehen. Siehe docs/05-akten-und-vorlagen.md.
|
||||
model CaseEntry {
|
||||
id String @id // clientseitig erzeugt, UUIDv7
|
||||
caseId String
|
||||
type EntryType
|
||||
body String
|
||||
minClearance Int @default(0)
|
||||
authorId String // unveraenderlich
|
||||
createdAt DateTime @default(now()) // unveraenderlich
|
||||
correctsId String? // Korrektur eines frueheren Eintrags
|
||||
|
||||
version Int @default(1)
|
||||
editedAt DateTime?
|
||||
editedById String?
|
||||
editVisible Boolean @default(false) // false = innerhalb der Karenzzeit geaendert
|
||||
|
||||
retractedAt DateTime?
|
||||
retractedById String?
|
||||
retractionReason String?
|
||||
|
||||
case Case @relation(fields: [caseId], references: [id], onDelete: Cascade)
|
||||
corrects CaseEntry? @relation("corrections", fields: [correctsId], references: [id])
|
||||
correctedBy CaseEntry[] @relation("corrections")
|
||||
versions CaseEntryVersion[]
|
||||
attachments Attachment[]
|
||||
|
||||
@@index([caseId, createdAt(sort: Desc)])
|
||||
@@index([caseId, editedAt(sort: Desc)])
|
||||
@@index([authorId])
|
||||
@@index([correctsId])
|
||||
}
|
||||
|
||||
/// Strikt anhaengend. Enthaelt jeweils den VORHERIGEN Stand. Wird nie geloescht,
|
||||
/// auch nicht von Admins. Ein Trigger laesst als einzige Aenderung das Anheben von
|
||||
/// minClearance zu, damit eine Umklassifizierung rueckwirkend wirkt und der alte
|
||||
/// Text nicht ueber die Historie leckt.
|
||||
model CaseEntryVersion {
|
||||
id String @id @default(uuid(7))
|
||||
entryId String
|
||||
version Int
|
||||
body String
|
||||
minClearance Int // erbt Erhoehungen 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)])
|
||||
}
|
||||
|
||||
enum EntryType {
|
||||
NOTE
|
||||
INTERROGATION
|
||||
EVIDENCE
|
||||
STATUS_CHANGE
|
||||
CORRECTION
|
||||
SYSTEM
|
||||
}
|
||||
|
||||
/// 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
|
||||
}
|
||||
|
||||
/// userId und personnelFileId sind beide optional (Beteiligte ohne Discord-Konto),
|
||||
/// deshalb ein eigener Schluessel statt eines zusammengesetzten Primaerschluessels:
|
||||
/// Postgres laesst NULL in einem Primaerschluessel nicht zu. Die Eindeutigkeit
|
||||
/// sichern zwei partielle Indizes in der Migration.
|
||||
model CaseParticipant {
|
||||
id String @id @default(uuid(7))
|
||||
caseId String
|
||||
userId String?
|
||||
personnelFileId String?
|
||||
role ParticipantRole
|
||||
addedById String?
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
case Case @relation(fields: [caseId], references: [id], onDelete: Cascade)
|
||||
user User? @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
personnelFile PersonnelFile? @relation(fields: [personnelFileId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@index([caseId])
|
||||
@@index([userId])
|
||||
@@index([personnelFileId])
|
||||
}
|
||||
|
||||
enum ParticipantRole {
|
||||
LEAD
|
||||
INVESTIGATOR
|
||||
WITNESS
|
||||
SUBJECT
|
||||
OBSERVER
|
||||
}
|
||||
|
||||
/// Schlaegt jede Freigabestufe. Deckt den Fall ab, dass gegen einen hochrangigen
|
||||
/// Mitarbeiter ermittelt wird.
|
||||
model CaseExclusion {
|
||||
caseId String
|
||||
userId String
|
||||
reason String?
|
||||
addedById String?
|
||||
createdAt DateTime @default(now())
|
||||
case Case @relation(fields: [caseId], references: [id], onDelete: Cascade)
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@id([caseId, userId])
|
||||
@@index([userId])
|
||||
}
|
||||
|
||||
model CaseLink {
|
||||
fromId String
|
||||
toId String
|
||||
kind LinkKind
|
||||
createdAt DateTime @default(now())
|
||||
from Case @relation("from", fields: [fromId], references: [id], onDelete: Cascade)
|
||||
to Case @relation("to", fields: [toId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@id([fromId, toId, kind])
|
||||
@@index([toId])
|
||||
}
|
||||
|
||||
enum LinkKind {
|
||||
RELATED
|
||||
SUCCESSOR
|
||||
MERGED_INTO
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Personalakten
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
model PersonnelFile {
|
||||
id String @id @default(uuid(7))
|
||||
userId String? // null bei Personen ohne Discord-Konto
|
||||
displayName String
|
||||
serviceNumber String? @unique
|
||||
rank String?
|
||||
department String?
|
||||
status String?
|
||||
classification Int @default(1)
|
||||
fields Json @default("{}")
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
searchVector Unsupported("tsvector")?
|
||||
|
||||
user User? @relation(fields: [userId], references: [id])
|
||||
participants CaseParticipant[]
|
||||
|
||||
@@index([displayName])
|
||||
@@index([userId])
|
||||
@@index([classification])
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Dateien
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
model Attachment {
|
||||
id String @id @default(uuid(7))
|
||||
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)
|
||||
case Case? @relation(fields: [caseId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@index([sha256])
|
||||
@@index([scanStatus])
|
||||
@@index([caseId])
|
||||
@@index([entryId])
|
||||
@@index([uploadedById, createdAt])
|
||||
}
|
||||
|
||||
enum ScanStatus {
|
||||
PENDING
|
||||
CLEAN
|
||||
BLOCKED
|
||||
FAILED
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Chat
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Frei angelegter Raum fuer allgemeine Absprachen, mit eigener Mindeststufe.
|
||||
model Channel {
|
||||
id String @id @default(uuid(7))
|
||||
key String @unique
|
||||
name String
|
||||
description String?
|
||||
minClearance Int @default(0)
|
||||
isArchived Boolean @default(false)
|
||||
createdById String
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
messages ChatMessage[]
|
||||
|
||||
@@index([isArchived, name])
|
||||
}
|
||||
|
||||
model ChatMessage {
|
||||
id String @id // clientseitig erzeugt, UUIDv7
|
||||
caseId String?
|
||||
channelId String?
|
||||
authorId String
|
||||
body String
|
||||
replyToId String?
|
||||
editedAt DateTime?
|
||||
withdrawnAt DateTime?
|
||||
/// Gesetzt, sobald die Nachricht als Vermerk uebernommen wurde.
|
||||
adoptedEntryId String?
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
case Case? @relation(fields: [caseId], references: [id], onDelete: Cascade)
|
||||
channel Channel? @relation(fields: [channelId], references: [id], onDelete: Cascade)
|
||||
replyTo ChatMessage? @relation("replies", fields: [replyToId], references: [id])
|
||||
replies ChatMessage[] @relation("replies")
|
||||
reactions ChatReaction[]
|
||||
|
||||
@@index([caseId, createdAt(sort: Desc)])
|
||||
@@index([channelId, createdAt(sort: Desc)])
|
||||
@@index([authorId])
|
||||
@@index([replyToId])
|
||||
}
|
||||
|
||||
/// Kleiner fester Satz an Reaktionen, keine freie Emoji-Auswahl.
|
||||
model ChatReaction {
|
||||
messageId String
|
||||
userId String
|
||||
key String
|
||||
createdAt DateTime @default(now())
|
||||
message ChatMessage @relation(fields: [messageId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@id([messageId, userId, key])
|
||||
}
|
||||
|
||||
model ReadMarker {
|
||||
userId String
|
||||
scopeType String // "case" | "channel"
|
||||
scopeId String
|
||||
lastReadAt DateTime
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@id([userId, scopeType, scopeId])
|
||||
}
|
||||
|
||||
model Watch {
|
||||
userId String
|
||||
caseId String
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
case Case @relation(fields: [caseId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@id([userId, caseId])
|
||||
@@index([caseId])
|
||||
}
|
||||
|
||||
|
||||
/// Lebenszeichen der Dienste, die nicht im Web-Container laufen. Der Worker
|
||||
/// schreibt hier seinen Gateway-Zustand hinein, damit /api/health ihn pruefen
|
||||
/// kann, ohne Discord zu befragen. Siehe docs/11-betrieb.md.
|
||||
model ServiceHeartbeat {
|
||||
service String @id // "worker"
|
||||
status String // "up" | "degraded" | "down"
|
||||
detail String?
|
||||
at DateTime @default(now())
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Protokoll
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Zweite, unabhaengige Spur neben der Versionstabelle. Wird nie geloescht.
|
||||
model AuditLog {
|
||||
id BigInt @id @default(autoincrement())
|
||||
at DateTime @default(now())
|
||||
actorId String
|
||||
action String // "case.read", "entry.edit", "export.pdf", ...
|
||||
targetType String
|
||||
targetId String
|
||||
clearanceAtTime Int
|
||||
meta Json?
|
||||
|
||||
@@index([targetType, targetId, at(sort: Desc)])
|
||||
@@index([actorId, at(sort: Desc)])
|
||||
@@index([action, at(sort: Desc)])
|
||||
}
|
||||
Reference in New Issue
Block a user