Files
AfiS-Aktensystem/packages/db/prisma/schema.prisma
T
Harlekin 8f0644e053 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.
2026-09-01 00:01:29 +02:00

524 lines
15 KiB
Plaintext

// 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)])
}