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

10 KiB

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

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:

-- 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.