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
+151 -12
View File
@@ -137,7 +137,11 @@ model Session {
// Vorlagen
// ---------------------------------------------------------------------------
model CaseTemplate {
/// Eine Vorlage beschreibt einen Bausteinsatz. Sie kann eine **Fallart** sein
/// (bestimmt die Abschnitte einer Fallakte) oder eine **Dokumentart** (bestimmt
/// den Aufbau eines Dokuments) oder beides. Was davon gilt, steht in `uses`.
/// Eine neue Dokumentart ist damit ein Datensatz, kein Deployment.
model DocumentTemplate {
id String @id @default(uuid(7))
key String // "korruptionsverdacht"
version Int
@@ -146,6 +150,13 @@ model CaseTemplate {
isActive Boolean @default(true)
fields Json // TemplateField[]
/// Wofuer die Vorlage verwendet werden darf. Liste statt Einzelwert, weil
/// dieselbe Vorlage an mehreren Stellen sinnvoll sein kann.
uses TemplateUse[] @default([CASE])
/// Bestimmt allein das Aussehen, nicht den Zugriff. Siehe docs/08.
layout DocumentLayout @default(FORM)
/// Muster des Aktenzeichens, siehe docs/13-entscheidungen.md. Die Abteilung
/// fuehrt gewachsene Praefixe wie "AKT-PA"; der Nummernkreis laeuft je
/// Praefix und Jahr, damit zwei Fallarten sich nicht in die Quere kommen.
@@ -154,12 +165,35 @@ model CaseTemplate {
createdAt DateTime @default(now())
createdById String?
cases Case[]
cases Case[]
documents Document[]
@@unique([key, version])
@@index([key, isActive])
}
/// Wofuer eine Vorlage taugt.
enum TemplateUse {
/// Fallart: bestimmt die Abschnitte einer Fallakte
CASE
/// Dokument in einer Fallakte
CASE_DOCUMENT
/// Dokument an einer Personalakte, etwa ein Sonderarbeitsvertrag
PERSON_DOCUMENT
/// Dokument ohne Besitzer, etwa ein Bussgeld
STANDALONE
}
/// Bauform des Dokuments, siehe docs/13-entscheidungen.md.
enum DocumentLayout {
/// beschriftete Felder, ausgefuellt — der Vorfallsbericht
FORM
/// fester Wortlaut mit Platzhaltern, mehrseitig — der Sonderarbeitsvertrag
PROSE
/// ein einzelnes Freitextfeld, dargestellt wie ein Vermerk
NOTE
}
// ---------------------------------------------------------------------------
// Fallakten
// ---------------------------------------------------------------------------
@@ -183,7 +217,7 @@ model Case {
/// B=Abschnitte, C=Verlaufseintraege.
searchVector Unsupported("tsvector")?
template CaseTemplate @relation(fields: [templateId], references: [id])
template DocumentTemplate @relation(fields: [templateId], references: [id])
sections CaseSection[]
entries CaseEntry[]
participants CaseParticipant[]
@@ -193,6 +227,7 @@ model Case {
watches Watch[]
linksFrom CaseLink[] @relation("from")
linksTo CaseLink[] @relation("to")
documents Document[]
@@index([status, updatedAt(sort: Desc)])
@@index([classification])
@@ -215,9 +250,10 @@ enum CaseStatus {
SEALED
}
/// Kollisionsfreie Vergabe des Aktenzeichens, ein Nummernkreis je Praefix und
/// Jahr. Hochgezaehlt in derselben Transaktion wie das Anlegen des Falls.
model CaseCounter {
/// Kollisionsfreie Vergabe der Geschaeftsnummern, ein Nummernkreis je Praefix
/// und Jahr. Hochgezaehlt in derselben Transaktion wie das Anlegen. Traegt
/// Aktenzeichen und Dokumentnummern, das Praefix kommt aus der Vorlage.
model NumberSeries {
prefix String
year Int
lastNumber Int @default(0)
@@ -378,11 +414,14 @@ model CaseExclusion {
@@index([userId])
}
/// Verknuepfung zweier Akten. Umkehrbar loeschbar, siehe docs/09: kein
/// Bestaetigungsdialog, sondern ein Rueckgaengig-Hinweis.
model CaseLink {
fromId String
toId String
kind LinkKind
createdAt DateTime @default(now())
fromId String
toId String
kind LinkKind
createdById String?
createdAt DateTime @default(now())
from Case @relation("from", fields: [fromId], references: [id], onDelete: Cascade)
to Case @relation("to", fields: [toId], references: [id], onDelete: Cascade)
@@ -410,14 +449,18 @@ model PersonnelFile {
status String?
classification Int @default(1)
fields Json @default("{}")
/// Freitext im Kopf der Akte, etwa "kein Discord-Konto, Aktenlage aus 2025".
note String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
createdById String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
searchVector Unsupported("tsvector")?
user User? @relation(fields: [userId], references: [id])
participants CaseParticipant[]
documents Document[]
@@index([displayName])
@@index([userId])
@@ -426,6 +469,102 @@ model PersonnelFile {
@@index([displayName(ops: raw("gin_trgm_ops"))], map: "personnel_name_trgm_idx", type: Gin)
}
// ---------------------------------------------------------------------------
// Dokumente
// ---------------------------------------------------------------------------
/// Eine Ausfertigung aus einer Vorlage: Vorfallsbericht, Sonderarbeitsvertrag,
/// spaeter Bussgeld. 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 CLAUDE.md, harte Regel 2.
model Document {
id String @id @default(uuid(7))
documentNumber String @unique // SAV-2026-0007
title String
templateId String
templateKey String
templateVersion Int
/// Kopie aus der Vorlage: das Aussehen darf sich nicht aendern, wenn jemand
/// spaeter die Vorlage umbaut.
layout DocumentLayout @default(FORM)
status DocumentStatus @default(DRAFT)
classification Int @default(1)
caseId String?
personnelFileId String?
createdById String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
finalizedAt DateTime?
finalizedById String?
retractedAt DateTime?
retractedById String?
retractionReason String?
/// Von einem Trigger gepflegt. Gewichtung A=Nummer/Titel, B=Abschnitte.
searchVector Unsupported("tsvector")?
template DocumentTemplate @relation(fields: [templateId], references: [id])
case Case? @relation(fields: [caseId], references: [id], onDelete: Restrict)
personnelFile PersonnelFile? @relation(fields: [personnelFileId], references: [id], onDelete: Restrict)
sections DocumentSection[]
@@index([caseId, createdAt(sort: Desc)])
@@index([personnelFileId, createdAt(sort: Desc)])
@@index([classification])
@@index([templateKey])
@@index([createdById])
// Von Hand in der Migration erzeugt, Prisma muss sie kennen. Siehe Case.
@@index([searchVector], map: "document_search_idx", type: Gin)
@@index([documentNumber(ops: raw("gin_trgm_ops"))], map: "document_number_trgm_idx", type: Gin)
}
enum DocumentStatus {
/// in Arbeit, aenderbar
DRAFT
/// ausgefertigt, Aenderungen brauchen ein eigenes Recht
FINAL
/// zurueckgezogen, bleibt als Grabstein stehen
RETRACTED
}
/// Ein ausgefuelltes Feld eines Dokuments. Dieselben Zusicherungen wie bei
/// `CaseSection`: versioniert, `minClearance` je Feld, Diff, Protokoll.
model DocumentSection {
id String @id @default(uuid(7))
documentId String
fieldKey String
minClearance Int @default(0)
currentVersion Int @default(1)
value Json
updatedAt DateTime @updatedAt
updatedById String
document Document @relation(fields: [documentId], references: [id], onDelete: Cascade)
versions DocumentSectionVersion[]
@@unique([documentId, fieldKey])
}
model DocumentSectionVersion {
id String @id @default(uuid(7))
sectionId String
version Int
value Json
changeReason String?
authorId String
createdAt DateTime @default(now())
section DocumentSection @relation(fields: [sectionId], references: [id], onDelete: Cascade)
@@unique([sectionId, version])
@@index([sectionId, version(sort: Desc)])
}
// ---------------------------------------------------------------------------
// Dateien
// ---------------------------------------------------------------------------