Phase 4 abgeschlossen: Personalakten, Dokumente, Beteiligte, Verknüpfungen

Die Dokumentebene aus docs/13 ist gebaut, additiv: Document, DocumentSection
und DocumentSectionVersion neben Case, CaseSection und CaseEntry. Ein Dokument
hängt an einer Fallakte, an einer Personalakte oder an nichts, höchstens an
einem — gesichert durch eine CHECK-Bedingung. Es gibt kein DELETE; eine
Zurücknahme lässt einen Grabstein stehen, der auch für den Admin zu ist.

Eine neue Dokumentart ist damit ein Datensatz, kein Deployment. Im Seed stehen
vier: vermerk, vorfallsbericht, sonderarbeitsvertrag (mehrseitiger Vertragstext
mit Platzhaltern und Unterschriftenzeilen) und bussgeld.

Zwei Umbenennungen, beide von Hand migriert, damit die vorhandenen Zeilen und
Zählerstände stehen bleiben:
- CaseTemplate -> DocumentTemplate, plus uses[] und layout
- CaseCounter -> NumberSeries, ein Nummernkreis je Präfix für Aktenzeichen
  und Dokumentnummern

Die Regeln für Abschnitte stehen jetzt einmal in lib/sections/render.ts und
gelten für Fallakten und Dokumente gleichermaßen. Die Frage aus docs/13, ob
CaseSection langfristig in die Dokumentebene wandert, ist damit verneint und
begründet.

Rückverweise auf der Personalakte laufen durch visibleCaseWhere, die Zahl
daneben entsteht aus dem gefilterten Ergebnis. Auch die Rollengruppe
verschwindet mit, sonst verriete "Betroffener" ohne Eintrag die Ermittlung.
Die eigene Personalakte ist keine Ausnahme von der Freigabestufe.

Vier neue Rechte: document.create, .edit, .finalize, .retract. Das letzte
steht bei den zurückgehaltenen, es greift in Geschriebenes ein.

91 Tests im Web-Paket, 15 in der Datenbank, 28 e2e. Größte Route 118 kB
gegen das Budget von 120 kB — die Luft wird knapp.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Harlekin
2026-09-02 14:15:16 +02:00
co-authored by Claude Opus 5
parent f5769ca58a
commit d65cc95eb2
62 changed files with 5312 additions and 225 deletions
+75 -4
View File
@@ -211,8 +211,11 @@ enum LinkKind { RELATED SUCCESSOR MERGED_INTO }
model PersonnelFile {
id String @id @default(uuid())
userId String? // null bei NPCs
userId String? // null bei Personen ohne Discord-Konto
displayName String
note String? // Freitext im Kopf der Akte
createdById String?
documents Document[]
serviceNumber String? @unique
rank String?
department String?
@@ -223,6 +226,66 @@ model PersonnelFile {
@@index([displayName])
}
// ---------------------------------------------------------------------------
// 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?
@@ -316,6 +379,14 @@ CREATE INDEX case_open_idx ON "Case" (updated_at DESC) WHERE status IN ('OPEN','
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.
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 |