Auth.js übernimmt nur den OAuth-Ablauf mit state und PKCE, Scope identify. Das Zugangstoken des Nutzers wird danach verworfen: die Rollen holt der Bot mit seinem eigenen Token. Die Sitzung steht in der Datenbank, im Cookie liegt nur userId und sessionId. Ohne die Zeile in Session ist das Cookie wertlos, und nur so lässt sich eine Sitzung zurückziehen. Wer sich anmelden darf, steckt in lib/auth/signInPolicy.ts statt im Callback: das ist die Regel, an der alles hängt, und sie gehört einzeln prüfbar. Kein Guild-Mitglied, kein Zugang. Keine Rolle mit grantsMembership, kein Zugang. Notfall-Admins umgehen die Rolle, aber nicht die Guild-Mitgliedschaft. RoleMapping trägt jetzt grantsMembership, category und isPresent. Der Worker spiegelt jede Rolle der Guild hierher, damit im Adminbereich nichts von Hand eingetragen werden muss. Gelöschte Rollen werden nicht entfernt, sondern auf isPresent = false gesetzt: beim Umzug auf den echten Server bleiben sie als Vorlage stehen. Der Worker gleicht über Gateway-Ereignisse und zusätzlich alle fünf Minuten voll ab. Verliert jemand seine AfiS-Rolle, beendet er dessen Sitzungen sofort und setzt isActive auf false — der Datensatz bleibt, damit Autorenangaben in alten Akten gültig bleiben. Das passiert im Worker und nicht in der Web-App, weil es dort erst beim nächsten Request des Betroffenen griffe. Zwei Widersprüche der Dokumente aufgelöst, festgehalten in docs/13-entscheidungen.md: der Ausschluss schlägt auch den Admin (das Codebeispiel in 03-auth-und-rechte.md übergeht ihn, die Reihenfolge eine Zeile darüber nicht), und versiegelte Akten fallen im Where-Fragment heraus statt in einer Prüfung danach. 45 Tests, davon die Negativfälle aus docs/03 gegen eine echte Datenbank: Stufe 2 sieht die Stufe-4-Akte nicht und findet sie auch nicht in der Trefferzahl, ein Ausgeschlossener mit Stufe 5 kommt nicht heran, ein Rollenentzug beendet die Sitzungen, und ein Ausfall der Discord-API sieht nicht aus wie eine verweigerte Mitgliedschaft. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
563 lines
17 KiB
Plaintext
563 lines
17 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.
|
|
///
|
|
/// Der Worker spiegelt jede Rolle der Guild hierher, damit im Adminbereich
|
|
/// nichts von Hand eingetragen werden muss. Neue Rollen stehen zunaechst auf
|
|
/// Stufe 0, ohne Rechte und ohne Zugang.
|
|
model RoleMapping {
|
|
discordRoleId String @id
|
|
label String // Anzeigename, aus Discord gespiegelt
|
|
clearanceLevel Int @default(0)
|
|
permissions String[]
|
|
sortOrder Int @default(0)
|
|
|
|
/// Tuersteher: nur mit einer solchen Rolle ist eine Anmeldung moeglich.
|
|
/// Auf dem AfiS-Discord sind das "AfiS" und "Personalabteilung"; Gaeste auf
|
|
/// dem Server haben sie nicht. Siehe docs/13-entscheidungen.md.
|
|
grantsMembership Boolean @default(false)
|
|
|
|
/// Nur fuer die Gruppierung im Adminbereich. Achtzehn Rollen in einer Liste
|
|
/// sind unbrauchbar, drei benannte Abschnitte nicht.
|
|
category RoleCategory @default(UNSORTED)
|
|
|
|
/// Existiert die Rolle auf der Guild noch? Beim Umzug auf einen anderen
|
|
/// Server bleiben die alten Zuordnungen als Vorlage stehen, statt sie zu
|
|
/// loeschen: die Adminseite schlaegt sie anhand des Namens neu zu.
|
|
isPresent Boolean @default(true)
|
|
|
|
updatedAt DateTime @updatedAt
|
|
|
|
@@index([category, sortOrder])
|
|
}
|
|
|
|
enum RoleCategory {
|
|
/// "AfiS", "Personalabteilung" — regelt, wer sich anmelden darf
|
|
MEMBERSHIP
|
|
/// Dienstgrade, regeln die Rechte
|
|
RANK
|
|
/// Zugangsrollen, regeln die Freigabestufe
|
|
CLEARANCE
|
|
/// Noch nicht eingeordnet
|
|
UNSORTED
|
|
}
|
|
|
|
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])
|
|
// Diese beiden werden in der Migration von Hand erzeugt, muessen Prisma aber
|
|
// bekannt sein: sonst hält Prisma sie fuer Drift und loescht sie bei der
|
|
// naechsten Migration. Siehe docs/13-entscheidungen.md.
|
|
@@index([searchVector], map: "case_search_idx", type: Gin)
|
|
@@index([caseNumber(ops: raw("gin_trgm_ops"))], map: "case_number_trgm_idx", type: Gin)
|
|
}
|
|
|
|
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])
|
|
@@index([searchVector], map: "personnel_search_idx", type: Gin)
|
|
@@index([displayName(ops: raw("gin_trgm_ops"))], map: "personnel_name_trgm_idx", type: Gin)
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// 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)])
|
|
}
|