Files
AfiS-Aktensystem/docs/04-datenmodell.md
T
HarlekinandClaude Opus 5 68ad0debd3 Personalakte als Aktenblatt, Status als Etiketten, Verknüpfen von beiden Seiten
Das Formular der Personalabteilung ist übernommen, nicht durch eine
Bildschirmansicht ersetzt: schwarzer Rahmen, Kopf mit Kennung und
Klassifizierungsbalken, Raster aus fünf Spalten, Unterschriftenzeilen,
Fußzeile. Es steht bewusst außerhalb des Design-Systems — es wird gedruckt und
muss auf Papier aussehen wie das Blatt daneben.

Datengetrieben bleibt es trotzdem. Kein Feld steht im Programm: sheetTitle und
sheetSubtitle an der Vorlage, span für die Zellbreite, rows für das Lichtbild,
dateKey für das Datum unter einer Unterschrift, slot für den
Klassifizierungsbalken. Eine weitere Dokumentart mit layout = DOSSIER braucht
keine Zeile Code.

Der Balken zeigt die Foundation-Stufe aus der neuen Tabelle
FoundationClearance. Sie ist Inhalt, kein Zugriff — was jemand sehen darf,
entscheidet weiterhin allein die interne Stufe. Ohne Auswahl bleibt der Balken
neutral, statt eine Einstufung zu behaupten.

Die Dienstnummer ist entfallen, es gibt sie nicht. Der Status ist zu mehreren
Etiketten geworden: "Gekündigt" und "Verhaftet" schließen einander nicht aus.
Unbekannte Schlüssel werden verworfen und die Reihenfolge kommt aus der
Konfiguration, nicht aus der Eingabe.

PersonnelAssignment hängt AfiS-Mitglieder an eine Person — Aktenführung,
Ermittler, Ansprechpartner — und ist zugleich der ausnahmsweise Zugang, wie
CaseParticipant bei der Fallakte. Betroffen zu sein gibt weiterhin keinen
Zugang: das ist eine Eigenschaft der Person, die Zuordnung eine Entscheidung
der Abteilung.

Fall und Person lassen sich von beiden Seiten verknüpfen und schon beim
Anlegen einer Fallakte. Alle drei Wege laufen in addParticipant zusammen.

Noch offen: das Lichtbild. Feldtyp und Platz stehen, hochladen kommt mit der
Prüfkette in Phase 5 — ein Bild als Data-URL im Abschnittswert verstiesse
gegen harte Regel 4.

102 Tests im Web-Paket, davon sechs auf dem gerenderten Blatt selbst.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-02 19:56:28 +02:00

14 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 }

// Ueberwiegend zu Externen — Sicherheitsdienst, Zeugen, Vertragspartner —
// die kein Discord-Konto haben. userId ist der Ausnahmefall, nicht die Regel.
model PersonnelFile {
  id           String  @id @default(uuid())
  userId       String?                            // null bei Personen ohne Discord-Konto
  displayName  String
  note         String?                            // Freitext im Kopf der Akte
  createdById  String?
  documents    Document[]
  assignments  PersonnelAssignment[]
  rank         String?
  department   String?
  statusTags   String[] @default([])              // Schluessel aus PersonnelStatusTag
  classification Int    @default(1)
  fields       Json
  searchVector Unsupported("tsvector")?
  @@index([displayName])
  @@index([statusTags], type: Gin)
}

// Mehrere Etiketten zugleich: "Gekuendigt" und "Verhaftet" schliessen einander
// nicht aus. Im Adminbereich pflegbar, deshalb eine Tabelle.
model PersonnelStatusTag {
  key        String  @id                          // "tot", "gekuendigt"
  name       String
  colorToken String  @default("stamp-0")
  sortOrder  Int     @default(0)
  isActive   Boolean @default(true)
}

// Ein AfiS-Mitglied an einer Personalakte. Wie CaseParticipant bei der Fallakte
// zugleich der Weg zum ausnahmsweisen Zugang.
model PersonnelAssignment {
  id              String @id @default(uuid(7))
  personnelFileId String
  userId          String
  role            AssignmentRole
  addedById       String?
  @@unique([personnelFileId, userId, role])
}

enum AssignmentRole { HANDLER INVESTIGATOR CONTACT }

// Die Freigabestufen der Foundation, wie sie im Kopf eines Aktenblatts stehen.
// Sie steuern KEINEN Zugriff — das tut allein ClearanceLevelConfig.
model FoundationClearance {
  level     Int    @id                            // 1..5
  name      String                                // "Streng Geheim"
  colorHex  String                                // Balkenfarbe im Aktenblatt
  sortOrder Int    @default(0)
}

// ---------------------------------------------------------------------------
// Dokumente (Phase 4)
//
// Eine Ausfertigung aus einer Vorlage: Vorfallsbericht, Sonderarbeitsvertrag,
// Bussgeldbescheid. Besitzer ist eine Fallakte, eine Personalakte oder nichts;
// hoechstens einer der beiden Fremdschluessel ist gesetzt, gesichert durch eine
// CHECK-Bedingung in der Migration.
//
// Anhaengend wie ein Verlaufseintrag: es gibt kein DELETE, eine Zuruecknahme
// laesst einen Grabstein stehen. Siehe docs/13-entscheidungen.md.
// ---------------------------------------------------------------------------

model Document {
  id              String  @id @default(uuid(7))
  documentNumber  String  @unique                  // SAV-2026-0007
  title           String
  templateId      String
  templateKey     String
  templateVersion Int
  layout          DocumentLayout @default(FORM)    // Kopie aus der Vorlage
  status          DocumentStatus @default(DRAFT)
  classification  Int     @default(1)
  caseId          String?
  personnelFileId String?
  createdById     String
  finalizedAt     DateTime?
  retractedAt     DateTime?
  retractionReason String?
  searchVector    Unsupported("tsvector")?
  sections        DocumentSection[]
  @@index([caseId, createdAt(sort: Desc)])
  @@index([personnelFileId, createdAt(sort: Desc)])
}

enum DocumentStatus { DRAFT FINAL RETRACTED }
enum DocumentLayout { FORM PROSE NOTE }

// Wie CaseSection, eine Ebene weiter: versioniert, minClearance je Feld,
// Diff, Protokoll. Die Regeln stehen einmal in lib/sections/render.ts.
model DocumentSection {
  id             String @id @default(uuid(7))
  documentId     String
  fieldKey       String
  minClearance   Int    @default(0)
  currentVersion Int    @default(1)
  value          Json
  updatedById    String
  versions       DocumentSectionVersion[]
  @@unique([documentId, fieldKey])
}

model DocumentSectionVersion {
  id        String @id @default(uuid(7))
  sectionId String
  version   Int
  value     Json
  authorId  String
  @@unique([sectionId, version])
}

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 mit INSERT ... ON CONFLICT DO UPDATE in derselben Transaktion wie das Anlegen, damit bei parallelem Anlegen keine Lücken oder Doppelungen entstehen.

Abweichungen gegenüber dem Entwurf oben, beide in Phase 4 entstanden und in docs/13-entscheidungen.md begründet:

Entwurf Gebaut
CaseTemplate DocumentTemplate, plus uses und layout
CaseCounter(year, lastNumber) NumberSeries(prefix, year, lastNumber) — ein Nummernkreis je Präfix, für Aktenzeichen und Dokumentnummern
PersonnelFile.serviceNumber entfallen, es gibt keine Dienstnummern
PersonnelFile.status (ein Wert) statusTags (mehrere), Auswahl aus PersonnelStatusTag