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>
This commit is contained in:
Harlekin
2026-09-01 00:00:58 +02:00
co-authored by Claude Opus 5
commit 4446e3f0ac
16 changed files with 1919 additions and 0 deletions
+66
View File
@@ -0,0 +1,66 @@
# Produkt und Domäne
## Kontext
Die AfiS ist im Rollenspiel die interne Ermittlungsbehörde einer SCP-Foundation-Einrichtung.
Sie untersucht Korruption, Dienstvergehen und Sicherheitsvorfälle bei eigenem Personal.
Bisher organisiert sie sich über ein Discord-Forum, was für verknüpfte Akten und abgestufte
Sichtbarkeit nicht taugt.
Das System ersetzt das Forum, nicht Discord selbst. Discord bleibt der Ort für Identität,
Rollen und Benachrichtigungen.
## Nutzergruppen
| Gruppe | Typisches Verhalten | Wichtigste Ansicht |
|---|---|---|
| Ermittler | Legt Fälle an, schreibt Vermerke, lädt Belege hoch | Fallakte, eigene Fälle |
| Abteilungsleiter | Prüft, schließt Fälle ab, vergibt Rechte, exportiert | Dashboard, Rollenverwaltung |
| Beobachter mit niedriger Stufe | Liest mit, kommentiert im Chat | Fallliste, Chat |
| Administrator (du) | Betrieb, Vorlagen, Notfallzugriff | Adminbereich |
Der Abteilungsleiter ist ausdrücklich **nicht technisch**. Alles, was er regelmäßig braucht,
muss über die Oberfläche gehen. Keine Konfigurationsdateien, keine Datenbankeingriffe.
## Glossar
Verbindliche Zuordnung zwischen Fachbegriff und Code-Bezeichner.
| Deutsch (UI) | Code | Bedeutung |
|---|---|---|
| Fallakte | `Case` | Eine Ermittlung |
| Aktenzeichen | `caseNumber` | `AfiS-<Jahr>-<4-stellig>`, z. B. `AfiS-2026-0042` |
| Abschnitt | `CaseSection` | Aus der Vorlage erzeugtes, versioniertes Feld |
| Vermerk / Verlaufseintrag | `CaseEntry` | Chronologischer Eintrag, nur anhängend |
| Verlauf | `timeline` | Die Folge aller `CaseEntry` |
| Vorlage | `CaseTemplate` | Definiert Abschnitte einer Fallart |
| Fallart | `caseType` | Korruptionsverdacht, Dienstvergehen, Sicherheitsvorfall, Routineprüfung |
| Freigabestufe | `clearanceLevel` | 0 bis 5 |
| Klassifizierung | `classification` | Die Stufe eines Objekts |
| Schwärzung | `Redaction` | Für niedrigere Stufen unkenntlicher Bereich |
| Personalakte | `PersonnelFile` | Akte zu einer Person |
| Beteiligter | `CaseParticipant` | Ermittler, Zeuge oder Betroffener eines Falls |
| Aktenchat | `CaseChat` | Informelle Nachrichten zu einer Akte |
| Wiedervorlage | `dueAt` | Termin, zu dem der Fall wieder anzufassen ist |
| Zugriffsprotokoll | `AuditLog` | Wer hat wann was gelesen oder geändert |
## Freigabestufen
| Stufe | Bezeichnung | Existenz für niedrigere Stufen |
|---|---|---|
| 0 | Offen | sichtbar |
| 1 | Intern | sichtbar |
| 2 | Vertraulich | sichtbar, Inhalt gesperrt |
| 3 | Geheim | unsichtbar |
| 4 | Streng geheim | unsichtbar |
| 5 | Leitungssache | unsichtbar |
Die Spalte ganz rechts ist konfigurierbar (`ClearanceLevelConfig.hiddenBelow`), die Werte
oben sind der Auslieferungszustand.
## Nicht im Umfang
- Keine öffentliche Registrierung, kein Passwort-Login, keine E-Mail-Zustellung
- Kein mobiles Nativ-App-Ziel, aber die Weboberfläche muss auf dem Handy gut lesbar sein
- Keine Echtzeit-Zusammenarbeit im selben Textfeld (nur Konflikterkennung)
- Keine Volltextsuche in Anhängen (OCR) in Version 1
+90
View File
@@ -0,0 +1,90 @@
# Architektur
## Dienste
```
Browser
└─ cloudflared (Tunnel, TLS und Kompression liegen bei Cloudflare)
└─ web Next.js 15, App Router, Server Actions, SSE-Endpunkt
├─ pgbouncer ─ db PostgreSQL 16
├─ files Volume, getrennt in quarantine/ und store/
├─ clamav clamd, angesprochen über Unix-Socket
└─ pdf Playwright-Container für PDF-Erzeugung
worker discord.js, hängt am Gateway, schreibt Rollen in db
db-backup Dauerläufer mit Cron, nächtlicher Dump
offsite-backup Dauerläufer mit Cron, restic auf zweites Ziel
```
Alles liegt in **einem** Dockge-Stack. Kein Reverse Proxy im Stack: der Tunnel terminiert TLS,
Cloudflare komprimiert, und Next.js liefert seine statischen Dateien selbst mit korrekten
Cache-Headern aus. Am Router wird kein Port geöffnet.
Migrationen laufen im Startkommando von `web`, nicht in einem Init-Container. Begründung und
die vollständige `compose.yaml` in `docs/11-betrieb.md`.
## Warum ein eigener Worker
Die Web-App fragt nie live bei Discord an. Der Worker hält `GuildMember` in Postgres aktuell:
- Gateway-Ereignisse `guildMemberUpdate`, `guildMemberRemove`, `guildMemberAdd`, `roleUpdate`
- Voller Abgleich alle 5 Minuten als Sicherheitsnetz
- Bei jeder Änderung: `pg_notify('member_changed', userId)`, damit die Web-App laufende
Sessions invalidieren kann
Dadurch ist eine Rechteänderung in Discord in unter einer Sekunde in der App wirksam,
ohne dass ein einziger Request in der App auf die Discord-API warten muss.
## Verzeichnisstruktur
```
.
├─ app/
│ ├─ (auth)/ Login, Callback, Fehlerseiten
│ ├─ (app)/
│ │ ├─ dashboard/
│ │ ├─ faelle/
│ │ │ ├─ page.tsx Liste
│ │ │ ├─ loading.tsx Skelett
│ │ │ ├─ neu/
│ │ │ └─ [caseNumber]/
│ │ │ ├─ page.tsx Akte
│ │ │ ├─ loading.tsx
│ │ │ └─ @chat/ Paralleler Slot, lädt unabhängig
│ │ ├─ personal/
│ │ ├─ suche/
│ │ └─ admin/ Rollen-Mapping, Vorlagen, Protokoll
│ └─ api/
│ ├─ auth/[...nextauth]/
│ ├─ files/[id]/ Ausliefernder Endpunkt mit Rechteprüfung
│ ├─ upload/
│ └─ stream/ SSE
├─ lib/
│ ├─ auth/ Session, Rechteauflösung
│ ├─ access/ Zugriffsprüfung als Prisma-Where-Fragmente
│ ├─ db.ts
│ ├─ audit.ts
│ ├─ i18n/de.ts
│ └─ realtime/
├─ components/
├─ worker/ Eigenes Paket, discord.js
├─ prisma/
├─ docs/
└─ docker/
```
## Datenflüsse, die man kennen muss
**Seitenaufruf einer Akte.** Server Component lädt Kopfdaten und Abschnitte in einer Abfrage.
Verlauf und Chat hängen in eigenen `<Suspense>`-Grenzen und streamen nach. Die Seite ist
sichtbar, bevor der Verlauf da ist.
**Neuer Vermerk.** Server Action schreibt `CaseEntry`, danach `revalidateTag('case:<id>')`
und `pg_notify` für alle offenen SSE-Verbindungen dieser Akte. Der Client hat den Eintrag
schon optimistisch angezeigt und ersetzt ihn beim Eintreffen anhand der Client-ID.
**Upload.** Datei geht per XHR mit Fortschritt an `/api/upload`, landet in `quarantine/`,
Prüfkette läuft asynchron, Statuswechsel kommt über SSE zurück.
**Rechteänderung in Discord.** Worker schreibt, `pg_notify`, Web-App verwirft den Cache der
betroffenen Session, nächster Request rechnet die Stufe neu.
+124
View File
@@ -0,0 +1,124 @@
# Authentifizierung und Berechtigungen
## Grundsatz
**OAuth klärt, wer jemand ist. Der Bot klärt, was er darf.** Das Discord-Zugangstoken des
Nutzers wird nach dem Callback verworfen und nie wieder benutzt.
## Login-Ablauf
1. Nutzer klickt „Mit Discord anmelden". Auth.js startet OAuth2 mit Scope `identify`,
dazu `state` und PKCE.
2. Im Callback liest die App nur `id`, `username`, `global_name`, `avatar`.
3. Die App fragt **serverseitig mit dem Bot-Token** `GET /guilds/{GUILD_ID}/members/{userId}`.
- 404 → Login abgelehnt, Seite „Du bist nicht auf dem AfiS-Discord". Kein Konto anlegen.
- 200 → Rollen werden übernommen, `User` wird angelegt oder aktualisiert.
4. Eigene Session in der Datenbank, Cookie mit `HttpOnly`, `Secure`, `SameSite=Lax`,
Laufzeit 30 Tage, gleitende Verlängerung.
5. In der Session steht nur `userId` und `sessionId`. Rechte werden pro Request aufgelöst.
Im Developer Portal muss das **Server Members Intent** aktiviert sein, sonst liefert der Bot
keine Rollen.
## Rechteauflösung
```ts
type EffectivePermissions = {
userId: string
clearanceLevel: number // höchste Stufe aller Rollen
permissions: Set<Permission> // Vereinigung aller Rollen
isAdmin: boolean // aus BOOTSTRAP_ADMIN_IDS oder Recht 'admin'
}
```
Auflösung aus `UserRole` × `RoleMapping`. Ergebnis wird pro Request einmal berechnet und in
einem prozesslokalen LRU-Cache (60 s TTL) gehalten. Der Cache wird bei `pg_notify`
`member_changed` oder `role_mapping_changed` sofort verworfen.
Rechte-Enum:
```
case.read case.create case.edit case.close case.reopen case.delete
case.edit_closed # Änderungen an abgeschlossenen Fällen
entry.create entry.correct
entry.edit_own # eigene Einträge nachträglich ändern
entry.edit_any # fremde Einträge ändern, für die Leitung
entry.retract # Eintrag zurückziehen (kein Löschen)
personnel.read personnel.edit
template.manage roles.manage audit.read export.approve
admin
`entry.edit_own`, `entry.edit_any` und `entry.retract` sind im Auslieferungszustand **keiner
Rolle zugeordnet**. Der Leiter schaltet sie bewusst frei, wenn er sie will.
```
## Notfallzugang
`BOOTSTRAP_ADMIN_IDS` in der `.env` enthält eine oder zwei Discord-IDs. Diese Nutzer haben
immer Stufe 5 und alle Rechte, unabhängig vom Rollen-Mapping. Ohne das sperrt man sich beim
ersten Fehler in der Rollenverwaltung selbst aus.
## Zugriffsprüfung auf Akten
Reihenfolge, erste zutreffende Regel gewinnt:
1. Nutzer steht in `CaseExclusion` → **kein Zugriff**, egal welche Stufe.
Deckt den Fall ab, dass gegen einen hochrangigen Mitarbeiter ermittelt wird.
2. Nutzer ist Admin → Zugriff, wird protokolliert.
3. Nutzer ist Beteiligter (`CaseParticipant`) → Zugriff.
4. `user.clearanceLevel >= case.classification` → Zugriff.
5. Sonst kein Zugriff.
Kein Zugriff bedeutet:
- Ist die Stufe als `hiddenBelow` markiert: **404**, die Akte existiert für den Nutzer nicht.
- Sonst: Eintrag in Listen sichtbar, Titel und Inhalt gesperrt, Detailseite zeigt Sperrhinweis.
## Umsetzung in Prisma
Die Prüfung wird nie in JavaScript nachgelagert. `lib/access/caseFilter.ts` liefert ein
wiederverwendbares Where-Fragment:
```ts
export function visibleCaseWhere(p: EffectivePermissions): Prisma.CaseWhereInput {
if (p.isAdmin) return {}
return {
AND: [
{ exclusions: { none: { userId: p.userId } } },
{
OR: [
{ participants: { some: { userId: p.userId } } },
{ classification: { lte: p.clearanceLevel } },
],
},
],
}
}
```
Dasselbe Muster für `CaseEntry`, `Attachment`, `PersonnelFile` und die Suche. Trefferzahlen
und Paginierung laufen über dieselbe Abfrage, damit sich aus Lücken nichts ableiten lässt.
Für Abschnitte und Schwärzungen gibt es eine zweite Ebene: `CaseSection.minClearance`.
Felder oberhalb der Nutzerstufe werden serverseitig durch einen Platzhalter ersetzt,
**bevor** die Daten den Server verlassen. Nie im Client ausblenden.
## Sessions beenden
Der Worker meldet `guildMemberRemove` oder den Verlust einer AfiS-Rolle. Die App löscht dann
alle Sessions des Nutzers und markiert `User.isActive = false`. Der Datensatz bleibt, damit
Autorenangaben in alten Akten gültig bleiben.
## Tests, die es geben muss
- Nutzer ohne Guild-Mitgliedschaft kann sich nicht anmelden
- Nutzer mit Stufe 2 bekommt für eine Stufe-4-Akte 404, nicht 403
- Ausgeschlossener Nutzer mit Stufe 5 bekommt 404
- Suche mit Stufe 1 liefert für eine Stufe-3-Akte keinen Treffer und keine veränderte Trefferzahl
- Rollenentzug in Discord beendet laufende Sessions innerhalb von 5 Sekunden
- Nutzer ohne `entry.edit_any` kann einen fremden Eintrag nicht ändern, auch nicht über
einen direkten Aufruf der Server Action
- Nutzer ohne `entry.edit_own` kann nach Ablauf der Karenzzeit den eigenen Eintrag nicht ändern
- Ein Eintrag, dessen `minClearance` erhöht wurde, ist auch in der Versionshistorie für
niedrigere Stufen nicht lesbar
- Ein `DELETE` auf `CaseEntry` verändert nichts, weder über Prisma noch über rohes SQL
- Autor und `createdAt` bleiben nach einer Änderung unverändert
+321
View File
@@ -0,0 +1,321 @@
# 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
```prisma
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:
```sql
-- 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.
+201
View File
@@ -0,0 +1,201 @@
# Akten, Vorlagen und Versionierung
## Die vier Ebenen einer Akte
| Ebene | Änderbar | Versioniert | Zweck |
|---|---|---|---|
| Kopfdaten | ja | nein, aber im Audit-Log | Status, Klassifizierung, Frist, Beteiligte |
| Abschnitte | ja | ja, pro Feld | Aktueller Sachstand |
| Verlauf | ja, mit Version und Grabstein | ja, pro Eintrag | Wie es dazu kam |
| Chat | Bearbeiten mit Vermerk | nein | Informelle Absprache, kein Aktenbestandteil |
Die Trennung zwischen Abschnitt und Verlauf ist nicht verhandelbar. Ein Abschnitt beantwortet
„was wissen wir jetzt", der Verlauf „wie sind wir dahin gekommen".
Der Verlauf ist nachträglich änderbar, aber jede Änderung ist sichtbar und nachvollziehbar.
Siehe „Nachträgliche Änderungen" weiter unten.
## Vorlagen
```ts
type TemplateField = {
key: string // stabil, nie ändern
label: string
type: 'longtext' | 'shorttext' | 'select' | 'multiselect'
| 'date' | 'person' | 'number' | 'checklist' | 'table'
options?: string[] // für select/multiselect
required?: boolean
requiredToClose?: boolean // blockiert den Abschluss des Falls
minClearance?: number // Feld erst ab dieser Stufe sichtbar
help?: string
}
```
Vorlagen sind versioniert. Eine Akte merkt sich `templateId` und `templateKey`. Änderungen an
einer Vorlage erzeugen eine neue Version und lassen bestehende Akten unberührt. Im
Vorlagen-Editor gibt es dafür eine Vorschau „So sieht eine neue Akte damit aus".
Startvorlagen im Seed: Korruptionsverdacht, Dienstvergehen, Sicherheitsvorfall, Routineprüfung.
## Feldebene-Klassifizierung
`minClearance` pro Feld ist das, was das Stufensystem lebendig macht. Für Nutzer unterhalb der
Stufe wird der Wert serverseitig durch
```ts
{ redacted: true, requiredLevel: 4 }
```
ersetzt. Die Oberfläche zeigt einen Schwärzungsbalken mit „Geschwärzt, ab Stufe 4 sichtbar".
Der echte Wert verlässt den Server nicht.
## Schwärzung im Freitext
Im Editor markiert man einen Bereich und wählt eine Stufe. Gespeichert wird als Auszeichnung:
```
Der Hinweis kam von [[c4]]Wachmann Perreira, Sektor B[[/c4]] am 27.08.
```
Der Server ersetzt den Inhalt beim Rendern für niedrigere Stufen durch einen Balken gleicher
Länge (nicht gleicher Text, sonst lässt sich die Länge auswerten: immer auf ein festes Raster
runden). Der Parser gehört in `lib/redaction.ts` und wird sowohl für die Ansicht als auch für
den PDF-Export benutzt.
## Versionierung der Abschnitte
Jede Änderung schreibt `CaseSectionVersion` und erhöht `currentVersion`. Die UI zeigt
`v4 · Vogel` neben der Überschrift, Klick öffnet einen Diff (Zeilen- oder Wortdiff, `diff`-Paket).
Beim Speichern schickt der Client die Version mit, die er geladen hat. Weicht sie ab, kommt
409 zurück und die UI zeigt beide Fassungen nebeneinander mit „Meine übernehmen" oder
„Fremde übernehmen". Kein stilles Überschreiben.
## Verlauf
Statusänderungen und Klassifizierungsänderungen erzeugen automatisch einen `SYSTEM`-Eintrag.
`SYSTEM`-Einträge sind von jeder Änderung ausgenommen.
Neben der nachträglichen Änderung bleibt der Korrektureintrag bestehen: ein neuer Eintrag mit
`type = CORRECTION` und `correctsId`. Das ist der richtige Weg, wenn sich der **Sachverhalt**
geändert hat. Nachträgliches Ändern ist der richtige Weg, wenn sich die **Darstellung**
geändert hat, also Tippfehler, vergessener Zusatz, falsche Uhrzeit im eigenen Vermerk.
## Nachträgliche Änderungen
Verlaufseinträge dürfen geändert und zurückgezogen werden. Ein hartes Löschen gibt es nicht,
weder über die Oberfläche noch über eine Server Action.
### Änderungsrichtlinie
Global einstellbar im Adminbereich, `AppSetting.entryEditPolicy`:
| Wert | Bedeutung |
|---|---|
| `APPEND_ONLY` | Keine Änderung möglich, nur Korrektureinträge |
| `GRACE_WINDOW` | Eigene Einträge innerhalb von N Minuten frei änderbar, danach nur mit Recht **(Auslieferungszustand, N = 15)** |
| `OPEN` | Jederzeit änderbar, sofern das Recht vorliegt |
Zusätzlich: `entryEditGraceMinutes` (Standard 15) und `entryEditMarkGrace` (Standard `false`).
Steht letzteres auf `false`, bekommt eine Änderung innerhalb der Karenzzeit **keine sichtbare
Markierung**, wird aber trotzdem versioniert und protokolliert. Ein Tippfehler, der zwei
Minuten nach dem Absenden korrigiert wird, soll die Akte nicht mit „bearbeitet"-Marken
zupflastern. Alles danach ist sichtbar markiert.
### Was geändert werden darf
| Feld | Änderbar |
|---|---|
| `body` | ja |
| `minClearance` | ja, mit Recht `case.edit` |
| `type` | nein |
| `authorId` | **nie** |
| `createdAt` | **nie** |
| `caseId` | nein, ein Eintrag wandert nicht in eine andere Akte |
Autor und Entstehungszeit sind unantastbar. Wer das umgehen will, muss den Eintrag
zurückziehen und einen neuen schreiben, und genau das ist dann auch sichtbar.
### Versionierung
Jede Änderung schreibt den **vorherigen** Stand nach `CaseEntryVersion` und erhöht
`CaseEntry.version`. Die Versionstabelle ist strikt anhängend und wird auch von Admins nicht
gelöscht.
Die UI zeigt am Eintrag `bearbeitet · v3`. Ein Klick öffnet die Historie mit Wortdiff,
Zeitpunkt und bearbeitender Person. **Diese Historie sieht jeder, der den Eintrag sehen darf**,
nicht nur die Leitung. Das ist der ganze Sinn der Sache: In einer Abteilung, die Korruption
aufdeckt, muss nachprüfbar sein, dass niemand still etwas umgeschrieben hat.
Ab der zweiten Änderung oder bei Änderung eines fremden Eintrags ist eine **Begründung
Pflicht** (`changeReason`, mindestens 10 Zeichen). Sie steht in der Historie.
### Zurückziehen statt Löschen
`retractedAt`, `retractedById` und `retractionReason` (Pflicht) werden gesetzt. Der Eintrag
bleibt an seiner Stelle im Verlauf stehen und zeigt:
> Eintrag zurückgezogen am 31.08.2026 durch Ltr. Reinhardt · Begründung: Falscher Fall
Der ursprüngliche Text bleibt in der Datenbank und ist für Nutzer mit `audit.read` weiterhin
lesbar, für alle anderen nicht. Zurückziehen lässt sich rückgängig machen (`entry.retract`),
was wiederum protokolliert wird.
### Umklassifizierung wirkt rückwirkend
Wird `minClearance` eines Eintrags erhöht, **erben alle bestehenden Versionen den neuen Wert**.
Sonst liest jemand mit niedrigerer Stufe den ursprünglichen Text einfach in der Historie.
Das ist die Stelle, an der eine naive Umsetzung leckt.
### Gesperrte Zustände
- `CLOSED`: Änderungen nur mit `case.edit_closed`, erzeugen zusätzlich einen `SYSTEM`-Eintrag
„Nachträgliche Änderung nach Abschluss"
- `ARCHIVED` und `SEALED`: keine Änderungen, auch nicht für Admins
### Protokollierung
Jede Änderung schreibt einen `AuditLog`-Eintrag mit Aktion `entry.edit`, `entry.retract` oder
`entry.restore`, der Eintrags-ID, der neuen Versionsnummer, der Begründung und einem
kompakten Diff in `meta`. Der Audit-Log wird nie gelöscht und ist die zweite, unabhängige
Spur neben der Versionstabelle.
### Änderungsübersicht je Akte
Eigener Reiter an der Akte: alle nachträglichen Änderungen an Abschnitten und Verlauf,
chronologisch, mit Person, Zeitpunkt, Begründung und Diff. Für den Leiter ist das die Ansicht,
mit der er im Zweifel prüft, ob an einer Akte nachträglich gedreht wurde. Sichtbar für alle,
die die Akte lesen dürfen.
### Folgen für andere Teile
- **Export**: `updatedAt` ändert sich, der PDF-Cache verfällt automatisch. Auf dem Deckblatt
steht ein `Stand:`-Zeitstempel, damit eine ältere ausgedruckte Fassung erkennbar ist.
- **Suche**: Der Trigger auf `search_vector` muss auch bei `UPDATE` auf `CaseEntry` feuern.
- **Ungelesen**: Eine Änderung setzt die Akte für andere wieder auf ungelesen und löst
dieselbe Benachrichtigung aus wie ein neuer Eintrag, mit dem Zusatz „geändert".
- **Echtzeit**: Ereignis `entry.updated` über SSE, Clients tauschen den Eintrag anhand der ID.
## Lebenszyklus
```
DRAFT ──► OPEN ──► REVIEW ──► CLOSED ──► ARCHIVED
│ ▲ │
▼ │ └──► OPEN (Rückgabe zur Nachbesserung)
DORMANT
SEALED: aus jedem Zustand erreichbar, nur mit Recht 'admin' oder Stufe 5 lesbar
```
Regeln:
- `OPEN → REVIEW` braucht `case.edit`
- `REVIEW → CLOSED` braucht `case.close` und alle `requiredToClose`-Felder gefüllt
- `CLOSED → OPEN` braucht `case.reopen` und erzeugt einen Pflicht-Vermerk mit Begründung
- `ARCHIVED` ist schreibgeschützt, nur noch lesbar
## Rückverweise
Auf einer Personalakte stehen automatisch die Fälle, in denen die Person vorkommt, getrennt
nach Rolle. **Diese Liste läuft durch dieselbe Sichtbarkeitsprüfung wie die Fallliste.** Ein
Nutzer, der einen Fall nicht sehen darf, sieht ihn hier auch nicht und die Zählung enthält ihn
nicht. Das ist der häufigste Ort, an dem ein Aktensystem Informationen leaken lässt.
+73
View File
@@ -0,0 +1,73 @@
# Chat und Echtzeit
## Zwei Ebenen
- **Aktenchat**: hängt an einer Akte, formlos, ausdrücklich kein Aktenbestandteil.
Optisch klar vom Verlauf abgesetzt (siehe Design-System).
- **Kanäle**: frei angelegte Räume für allgemeine Absprachen, mit eigener Mindeststufe.
## Kernfunktion: „Als Vermerk übernehmen"
Eine Chatnachricht wird per Klick zu einem `CaseEntry` mit `type = NOTE`. Der erzeugte
Eintrag trägt einen Hinweis auf den Ursprung (Autor, Zeitpunkt, Nachrichten-ID). Die
Chatnachricht bleibt stehen und bekommt eine Markierung „übernommen".
Das ist die Brücke zwischen Discord-Gewohnheit und sauberem Aktenbestand und sollte
prominent sein, nicht in einem Kontextmenü versteckt.
## Funktionsumfang
- Erwähnungen `@` mit Autovervollständigung aus `User`
- Antworten auf einzelne Nachrichten (`replyToId`), zitierte Vorschau
- Reaktionen (kleiner fester Satz, keine freie Emoji-Auswahl)
- Bearbeiten mit sichtbarem `editedAt`
- Löschen nur als Zurückziehen: `withdrawnAt` gesetzt, Platzhalter „Nachricht zurückgezogen"
- Ungelesen-Markierung über `ReadMarker`, Trennlinie „Neu seit deinem letzten Besuch"
## Aktenzeichen-Erkennung
Text wie `AfiS-2026-0038` wird beim Rendern zu einer Vorschaukarte. **Die Karte wird
serverseitig aufgelöst und respektiert die Stufe des Lesers.** Wer die Akte nicht sehen darf,
bekommt eine neutrale Karte ohne Titel und ohne Status. Nie den Titel mitschicken und im
Client ausblenden.
## Echtzeit über SSE
Kein WebSocket, kein Redis. Postgres `LISTEN/NOTIFY` plus ein SSE-Endpunkt reicht bei dieser
Nutzerzahl vollständig.
```
GET /api/stream?topics=case:<id>,notifications
```
- Ein einziger `pg`-Client hält `LISTEN` offen und verteilt an die Verbindungen im Prozess
- Server sendet alle 25 Sekunden einen Kommentar-Heartbeat gegen Proxy-Timeouts
- Client nutzt `EventSource` mit `Last-Event-ID`, bei Reconnect werden verpasste Nachrichten
über `?since=<id>` nachgeladen
- Reconnect mit exponentiellem Backoff, gedeckelt bei 30 Sekunden
- Beim Wechsel des Tabs in den Hintergrund wird die Verbindung nach 5 Minuten geschlossen und
bei Rückkehr neu aufgebaut
Ereignistypen: `chat.new`, `chat.edit`, `entry.new`, `entry.updated`, `entry.retracted`,
`case.update`, `attachment.scanned`, `presence`.
`entry.updated` und `entry.retracted` tauschen den Eintrag im offenen Verlauf anhand der ID
aus. Wer die Akte gerade offen hat, sieht die Änderung sofort und nicht erst beim Neuladen.
## Dedupe mit Client-IDs
Der Client erzeugt die Nachrichten-ID (UUIDv7) selbst und zeigt die Nachricht sofort an.
Kommt dieselbe ID über SSE zurück, wird der optimistische Eintrag ersetzt statt verdoppelt.
Die ID ist gleichzeitig der Idempotenzschlüssel: ein wiederholtes Absenden nach Netzfehler
erzeugt keine zweite Nachricht (`INSERT ... ON CONFLICT DO NOTHING`).
## Discord-Benachrichtigungen
Der Worker postet in einen konfigurierbaren Channel und optional als DM an Beteiligte:
> Neuer Eintrag in **AfiS-2026-0042** · Insp. Vogel · https://afis.example.de/faelle/AfiS-2026-0042
**Niemals Inhalte, niemals Titel bei Akten ab Stufe 3.** Discord kennt eure Klassifizierung
nicht. Der Link führt zur App, die dann selbst prüft.
Konfigurierbar pro Nutzer: alles / nur meine Fälle / nur Erwähnungen / aus.
+82
View File
@@ -0,0 +1,82 @@
# Dateien und Sicherheit
## Bedrohungsmodell, ehrlich
Geschlossene Gruppe, alle kennen sich. Das realistische Risiko ist nicht die geschmuggelte
Ransomware, sondern eine hochgeladene SVG- oder HTML-Datei, die im Browser eines Kollegen
Skriptcode ausführt und dessen Session übernimmt. Deshalb die Maßnahmen nach Wirkung sortiert
und nicht nach Auffälligkeit.
## Prüfkette
```
Upload → quarantine/<uuid> → Typprüfung → ClamAV → Neu kodieren → store/<uuid>
│ │
└─────────────┴──► ScanStatus = BLOCKED
```
### 1. Annahme
- Ziel ist `quarantine/`, Dateiname wird durch die Attachment-UUID ersetzt
- Originalname nur in der Datenbank, beim Ausliefern wieder gesetzt
- Limits: 25 MB pro Datei, 500 MB Kontingent pro Nutzer, 20 Uploads pro Minute
- Der Datensatz wird sofort mit `scanStatus = PENDING` angelegt, damit die UI ihn zeigen kann
### 2. Typprüfung, Whitelist über Magic Bytes
Erlaubt: `image/png`, `image/jpeg`, `image/webp`, `application/pdf`, `text/plain`.
Geprüft wird mit `file-type` anhand des Inhalts, **nie anhand der Endung**. Stimmt der
erkannte Typ nicht mit dem gemeldeten überein, ist die Datei blockiert.
Ausdrücklich verboten, auch wenn es bequem wäre: SVG (führt Skripte aus), HTML, ZIP und
andere Archive, Office-Dokumente mit Makros, alles Ausführbare.
### 3. ClamAV
Eigener Container `clamav/clamav:stable`, angebunden über den clamd-Unix-Socket, geteiltes
Volume mit `quarantine/`. `freshclam` läuft im Container. Timeout 30 Sekunden, bei Timeout
`FAILED` und manuelle Freigabe durch einen Admin möglich.
### 4. Neu kodieren
- Bilder: einmal durch `sharp` (`.rotate().toFormat(...)`). Entfernt EXIF, zerstört angehängte
Nutzlast, erzeugt gleich das Vorschaubild (max. 480 px) und einen Blurhash für das
Platzhalterbild.
- PDF: `qpdf --decrypt --remove-page-labels` und Entfernen eingebetteter Dateien und
JavaScript. Erste Seite wird als Vorschaubild gerendert.
- Text: Zeichensatz nach UTF-8 normalisieren, Größe prüfen.
### 5. Freigabe
SHA-256 berechnen, in `store/` verschieben, `scanStatus = CLEAN`, SSE-Ereignis
`attachment.scanned` an alle offenen Ansichten der Akte.
Gleicher Hash bereits vorhanden: nur einen Verweis anlegen statt erneut zu speichern.
## Auslieferung
Alles über `GET /api/files/[id]`, niemals als statische Datei.
```
Content-Disposition: attachment; filename="protokoll_karlsen.pdf"
X-Content-Type-Options: nosniff
Cache-Control: private, max-age=31536000, immutable
Content-Security-Policy: default-src 'none'; sandbox
```
Der Endpunkt prüft vorher die Sichtbarkeit der zugehörigen Akte und protokolliert den Zugriff
ab Stufe 3. Vorschaubilder laufen über denselben Endpunkt mit `?variant=thumb` und dürfen
inline ausgeliefert werden, weil sie serverseitig neu kodiert wurden.
Wenn möglich: eigene Subdomain `files.afis.example.de` ohne Session-Cookie, mit signierter,
kurzlebiger URL. Dann kann selbst eine durchgerutschte Datei keine Session stehlen.
## Weitere Härtung
- Strikte CSP auf der App: `script-src 'self'`, kein `unsafe-inline` (Next.js Nonce nutzen)
- Rate Limits auf Login, Upload, Suche und Export
- Alle Server Actions prüfen die Berechtigung erneut, egal was das UI vorher versteckt hat
- Kein `dangerouslySetInnerHTML` für Nutzerinhalte. Markdown über `remark` mit
`rehype-sanitize`, feste Allowlist.
- Datenbank-Nutzer der App ohne `SUPERUSER`, Migrationen über einen zweiten Nutzer
+61
View File
@@ -0,0 +1,61 @@
# Suche und Export
## Suche
Postgres Full-Text-Search, deutsche Konfiguration. Kein Elasticsearch.
- `search_vector` auf `Case` per Trigger aus Aktenzeichen und Titel (Gewicht A),
Abschnittswerten (B) und Verlaufseinträgen (C)
- GIN-Index, `websearch_to_tsquery('german', $1)`
- Zusätzlich Trigram-Index (`pg_trgm`) auf `caseNumber` und `displayName` für Tippfehler
Filter: Status, Fallart, Zeitraum, Beteiligte, Klassifizierung, Tags, nur meine Fälle.
**Die Sichtbarkeitsprüfung gehört in dieselbe Abfrage**, nicht als Nachfilter. Sonst stimmen
Trefferzahl und Seitenlänge nicht und aus den Lücken lässt sich auf verborgene Akten
schließen. Gleiches gilt für Facettenzähler.
Gespeicherte Ansichten, die es ab Werk geben sollte: meine Fälle, Wiedervorlage diese Woche,
seit meinem letzten Besuch geändert, unbearbeitet seit 14 Tagen, zuletzt geöffnet.
## Export
### Einzelakte als PDF
Aufbau: Deckblatt mit Aktenzeichen, Klassifizierungsvermerk und `Stand:`-Zeitstempel,
Kopfdatentabelle, Abschnitte in Vorlagenreihenfolge, chronologischer Verlauf,
Anhangsverzeichnis mit Hashes.
Nachträglich geänderte Verlaufseinträge werden im Export als solche ausgewiesen
(`bearbeitet, v3, 31.08.2026`), zurückgezogene erscheinen als Grabstein mit Begründung.
Ein Export darf nie sauberer aussehen als die Akte ist. Der `Stand:`-Zeitstempel macht
erkennbar, dass ein älterer Ausdruck überholt sein kann.
Drei Regeln:
1. **Der Export respektiert die Stufe des Abrufers.** Gesperrte Felder werden als Schwärzung
ausgegeben, nicht weggelassen, mit dem Vermerk „Geschwärzt, Stufe 4". Sonst merkt der
Empfänger nicht, dass er eine gekürzte Fassung hat.
2. **Wasserzeichen** auf jeder Seite: Name des Abrufers, Zeitstempel, Freigabestufe.
3. **Jeder Export wird protokolliert.** Ab Stufe 3 zusätzlich Freigabe durch einen Nutzer mit
Recht `export.approve`, also Vier-Augen-Prinzip. Die Anfrage erscheint beim Leiter als
Aufgabe im Dashboard.
### Technik
Playwright-Container rendert eine dedizierte Druckansicht `/faelle/[caseNumber]/druck`
mit `@media print`-Styles. Kein PDF-Bibliotheks-Layout von Hand.
Erzeugte PDFs werden gecacht unter dem Schlüssel
`sha256(caseId + updatedAt + clearanceLevel + templateVersion)`. Auf einem Homeserver kostet
das Rendern spürbar CPU, und dieselbe Akte wird oft mehrfach exportiert.
Der Export läuft als Hintergrundauftrag: Klick erzeugt einen Job, die UI zeigt einen
Fortschrittsbalken und der Download startet automatisch, sobald der Job fertig ist. Bei über
3 Sekunden bekommt der Nutzer zusätzlich eine Benachrichtigung, damit er wegklicken kann.
### Weitere Wege
- ZIP mit PDFs und Anhängen für Sammelexporte, nur mit Recht `admin`
- Vollständiger JSON-Export der Datenbank als Umzugs- und Notfallformat, ohne
Stufenfilterung, nur für `admin`, immer protokolliert
+175
View File
@@ -0,0 +1,175 @@
# Performance und Bedienungsgefühl
Dieses Dokument ist verbindlich. Die Anwendung läuft auf einem Homeserver hinter einem
Tunnel, also mit spürbarer Grundlatenz. Das Gefühl von Geschwindigkeit entsteht deshalb nicht
durch schnelle Server, sondern durch die richtige Reaktion im Client.
## Latenzbudgets
| Aktion | Sichtbare Reaktion nach | Fertig nach |
|---|---|---|
| Klick auf einen Knopf | 100 ms (Zustandswechsel) | egal |
| Navigation zu einer Akte | 200 ms (Skelett oder Inhalt) | 1 s |
| Chatnachricht senden | 0 ms (optimistisch) | Hintergrund |
| Vermerk speichern | 0 ms (optimistisch) | Hintergrund |
| Suche tippen | 150 ms (alte Treffer gedimmt) | 600 ms |
| Datei-Upload | 0 ms (Vorschau lokal) | Hintergrund mit Balken |
| PDF-Export | 200 ms (Fortschritt) | Benachrichtigung |
Wenn eine Aktion diese Budgets reißt, ist nicht der Server das Problem, sondern die
fehlende Zwischenreaktion.
## Grundhaltung
1. **Nie eine leere Seite mit Spinner.** Jede Route hat eine `loading.tsx` mit einem Skelett,
das die spätere Struktur exakt nachbildet (gleiche Höhen, gleiche Spaltenbreiten). Damit
springt nichts, wenn die Daten kommen.
2. **Spinner nur für Vorgänge unbekannter Länge über 400 ms.** Vorher gar nichts anzeigen,
sonst blitzt es bei schnellen Antworten unangenehm auf.
3. **Server Components sind der Standard.** `'use client'` nur dort, wo wirklich Interaktion
stattfindet: Editoren, Chat, Uploads, Filterleisten.
4. **Streamen statt sammeln.** Kopfdaten sofort, Verlauf und Chat in eigenen `<Suspense>`-
Grenzen. Der Nutzer liest schon, während der Rest lädt.
## Optimistische Updates
React 19 `useOptimistic` plus Server Actions. Die Regel: **Was der Nutzer selbst auslöst und
was fast immer klappt, wird sofort angezeigt.**
Optimistisch behandeln:
| Aktion | Optimistische Darstellung | Bei Fehler |
|---|---|---|
| Chatnachricht senden | Nachricht erscheint, leicht gedimmt bis bestätigt | Roter Rahmen, „Erneut senden" |
| Reaktion setzen | Sofort umschalten | Zurücksetzen, dezenter Hinweis |
| Vermerk anlegen | Eintrag oben im Verlauf | Eintrag bleibt, markiert als „nicht gespeichert" |
| Eigenen Eintrag in der Karenzzeit ändern | Text sofort ersetzt | Alter Text zurück plus Toast |
| Anhang löschen | Verschwindet sofort | Kommt zurück plus Toast |
| Wiedervorlage ändern | Neues Datum sofort | Zurücksetzen |
| Beobachten an/aus | Sofort umschalten | Zurücksetzen |
| Als gelesen markieren | Sofort | Still ignorieren, unkritisch |
**Nicht** optimistisch behandeln, weil folgenreich oder nicht umkehrbar:
- Ändern eines fremden Eintrags oder eines Eintrags nach Ablauf der Karenzzeit.
Hier gehört die Begründung erst abgeschickt und bestätigt, bevor der neue Text steht.
- Zurückziehen eines Eintrags
- Statuswechsel eines Falls, besonders `CLOSED`
- Änderung der Klassifizierung
- Änderung des Rollen-Mappings
- Freigabe eines Exports
- Alles im Adminbereich
Diese bekommen stattdessen einen sofortigen Pending-Zustand am Knopf (`useTransition`,
Beschriftung wechselt, Knopf bleibt klickbar-aussehend, aber gesperrt) und eine klare
Bestätigung danach.
### Rückabwicklung sauber machen
```ts
const [optimistic, addOptimistic] = useOptimistic(entries, applyEntry)
async function submit(text: string) {
const id = uuidv7()
addOptimistic({ id, body: text, pending: true })
const res = await createEntry({ id, caseId, body: text })
if (!res.ok) toast.error('Vermerk nicht gespeichert', { action: retry(id) })
}
```
Die clientseitig erzeugte `id` ist zugleich Idempotenzschlüssel. Ein Wiederholungsversuch nach
Netzfehler erzeugt keinen zweiten Eintrag.
## Löschen: rückgängig statt nachfragen
Für umkehrbare Löschungen (Anhang, Chatnachricht, Verknüpfung) kein Bestätigungsdialog.
Stattdessen: Element verschwindet sofort, Toast „Gelöscht · Rückgängig" für 6 Sekunden, erst
danach geht der endgültige Aufruf raus. Das ist schneller **und** sicherer als ein Dialog,
den ohnehin niemand liest.
Bestätigungsdialoge bleiben für: Fall schließen, Fall versiegeln, Rollen-Mapping ändern,
Sammelexport.
## Navigation
- `<Link prefetch>` auf allen Falllisten-Zeilen, zusätzlich `router.prefetch` bei `mouseenter`
mit 80 ms Verzögerung, damit nicht beim bloßen Überfahren geladen wird
- Route-Cache über `revalidateTag('case:<id>')` statt `router.refresh()`, damit nicht die
ganze Seite neu berechnet wird
- Oben ein dünner Fortschrittsbalken für Routenwechsel, aber **erst nach 200 ms** einblenden.
Schnelle Wechsel sollen nicht flackern.
- Zurück-Navigation nutzt den Browser-Cache, keine Neuabfrage
## Listen und Verlauf
- **Cursorbasierte Paginierung**, kein `OFFSET`. Sortierschlüssel ist `(createdAt, id)`.
Stabil auch dann, wenn währenddessen neue Einträge dazukommen.
- Beim Nachladen bleiben die alten Zeilen stehen (`keepPreviousData`), keine leere Tabelle.
- Verlauf und Chat ab 200 Einträgen virtualisiert (`@tanstack/react-virtual`).
- Chat lädt initial die letzten 50 Nachrichten und beim Hochscrollen weitere 50, mit
Erhalt der Scrollposition (Höhe vor dem Einfügen merken, danach korrigieren).
## Suche
- Eingabe mit 250 ms Debounce
- Laufende Anfrage bei neuer Eingabe per `AbortController` abbrechen
- Während der Suche die alten Treffer auf 50 % Deckkraft setzen statt sie zu entfernen
- Erste Filterung lokal auf bereits geladenen Fällen, damit bei kurzen Listen gar keine
Anfrage nötig ist
- Ergebnisse werden pro Suchbegriff kurz gecacht, damit Zurücknavigieren sofort geht
## Uploads
1. Datei wird sofort im Verlauf gezeigt, Vorschau aus lokalem `URL.createObjectURL`
2. Fortschrittsbalken aus `XMLHttpRequest.upload.onprogress` (fetch kann das nicht)
3. Nach dem Upload Status „Wird geprüft" mit dezenter Animation
4. `attachment.scanned` über SSE tauscht den Zustand auf „Geprüft" oder „Blockiert"
5. Mehrere Dateien laufen parallel, aber höchstens drei gleichzeitig
6. Bilder werden vor dem Upload clientseitig auf max. 2560 px verkleinert, wenn sie größer sind
## Bilder
- `next/image` mit festen Dimensionen, damit nichts springt
- Platzhalter aus dem gespeicherten Blurhash, nicht grau
- Vorschaubilder serverseitig erzeugt, das Original wird nie in einer Liste geladen
- Anhänge in der Akte laden erst beim Scrollen (`loading="lazy"`)
## Datenbank
- Indizes wie in `docs/04-datenmodell.md`, besonders `(caseId, createdAt DESC)`
- Kein N+1: Beteiligte, Autoren und Anhänge immer per `include` mitladen
- Autoren-Daten (Name, Avatar) in einer Map im Speicher halten, TTL 5 Minuten
- Verbindungspool über pgbouncer im Transaktionsmodus, Prisma mit `pgbouncer=true`
- Das Rollen-Mapping wird prozesslokal gecacht und nur bei `pg_notify` verworfen
- **Der Audit-Eintrag darf die Antwort nicht blockieren.** Schreiben in eine Puffertabelle
und alle 2 Sekunden im Bündel wegschreiben, aber innerhalb derselben Transaktion für
schreibende Aktionen, damit nichts verloren geht.
## Auslieferung
- Brotli und HTTP/2 kommen von Cloudflare, im Stack ist dafür nichts zu tun
- Schriften selbst gehostet, als `woff2` untergesetzt, `font-display: swap`
- Statische Assets mit Hash im Namen und `immutable`-Cache
- Keine Komponentenbibliothek im Bundle, das spart mehr als jede Optimierung danach
- Bundle-Budget: erste Route unter 120 kB JavaScript nach Kompression.
`pnpm analyze` gehört in die CI.
## Wahrnehmung schlägt Messung
Ein paar Kleinigkeiten mit unverhältnismäßig großer Wirkung:
- Der Fokus springt nach dem Absenden eines Vermerks sofort zurück ins leere Eingabefeld
- Enter sendet, Shift+Enter macht einen Zeilenumbruch, überall gleich
- Strg+K öffnet die Kommandopalette: Aktenzeichen eintippen und direkt hinspringen
- Beim Öffnen einer Akte steht der Fokus auf dem Inhalt, nicht auf der Navigation
- Entwürfe werden alle 2 Sekunden lokal gespeichert (`localStorage` pro Feld), damit ein
versehentlicher Tab-Wechsel keinen Text kostet
- Beim Verlassen mit ungespeicherten Änderungen eine Warnung, aber nur bei echten Änderungen
- Offline erkannt: Eingaben werden gepuffert, Kopfzeile zeigt „Keine Verbindung, wird
nachgesendet"
## Messen
`web-vitals` an einen internen Endpunkt, angezeigt im Adminbereich. Zielwerte:
LCP unter 1,5 s, INP unter 200 ms, CLS unter 0,05. Dazu die Serverzeiten der fünf
häufigsten Abfragen aus `pg_stat_statements`.
+100
View File
@@ -0,0 +1,100 @@
# Design-System
## Gestaltungsidee
Der Bezugspunkt ist nicht „Dashboard" und nicht „Hacker-Terminal", sondern **eine
Registratur im Nachtdienst**: lackierter Stahl, Aktenpapier, Stempelfarbe. Ruhige, kühle
Flächen, in denen die einzige Farbe von der Klassifizierung kommt.
Das ist eine bewusste Absage an drei naheliegende Richtungen:
- Schwarz mit giftgrünem Akzent. Wirkt nach Filmrequisite, ermüdet beim Lesen langer Texte.
- Warmes Cremeweiß mit Serifen-Überschriften. Zu behaglich für eine Ermittlungsbehörde.
- Der übliche Karten-Baukasten mit gleichen Radien und weichen Schatten. Eine Akte ist kein
Produktkatalog, und identische Kacheln nehmen der Hierarchie jede Aussage.
## Farben
Dunkel ist der Auslieferungszustand, ein helles Thema ist verpflichtend mitzuliefern.
```css
--ink-900: #15181A; /* Grundfläche, kühles Anthrazit, leicht grünstichig */
--ink-800: #1D2124; /* Panel */
--ink-700: #262B2F; /* erhöhte Fläche, Eingabefelder */
--ink-500: #3A4147; /* Linien und Rahmen */
--paper: #E4E7E4; /* Primärtext, papierfarbenes Off-White */
--paper-dim:#939C99; /* Sekundärtext */
--steel: #7A97A6; /* interaktiv: Links, Fokus, aktive Zustände */
```
Klassifizierungsfarben sind das einzige Buntsystem und werden nirgends sonst benutzt:
| Stufe | Token | Hex | Anmutung |
|---|---|---|---|
| 0 Offen | `--stamp-0` | `#8A918D` | Grau |
| 1 Intern | `--stamp-1` | `#6D8A9C` | Stahlblau |
| 2 Vertraulich | `--stamp-2` | `#4E8F72` | Grünspan |
| 3 Geheim | `--stamp-3` | `#B08637` | Messing |
| 4 Streng geheim | `--stamp-4` | `#B4543C` | Oxidrot |
| 5 Leitungssache | `--stamp-5` | `#7E5E96` | Tiefviolett |
Status (offen, ruhend, abgeschlossen) wird **nicht** über Farbe transportiert, sondern über
Position und Beschriftung. Sonst konkurrieren zwei Farbsysteme um dieselbe Aufmerksamkeit.
## Typografie
IBM Plex, selbst gehostet, als `woff2` untergesetzt. Die Wahl ist inhaltlich begründet: Plex
wurde als Hausschrift einer Institution entworfen, hat einen schmalen Schnitt für
Tabellenköpfe und eine passende Mono für Kennzeichen. Drei Rollen aus einer Familie halten das
Bild geschlossen, ohne beliebig zu wirken.
| Rolle | Schnitt | Einsatz |
|---|---|---|
| Fließtext | Plex Sans 400 / 500 | Abschnitte, Vermerke, Chat |
| Struktur | Plex Sans Condensed 500 | Spaltenköpfe, Stempel, Reiter |
| Daten | Plex Mono 400 | Aktenzeichen, Zeitstempel, Hashes, Dienstnummern |
Skala: 12 / 13 / 15 / 18 / 24 px. Zeilenhöhe 1,6 im Fließtext. Zeilenlänge höchstens
72 Zeichen, bei breiten Fenstern durch `max-width` begrenzt statt die Spalte zu dehnen.
Kein Versalsatz für Beschriftungen. Der Stempelcharakter kommt aus dem schmalen Schnitt, der
Rahmenlinie und der Farbe, nicht aus Großbuchstaben. Auch keine hervorgehobenen Einzelwörter
in Überschriften.
## Struktur
- Eckenradius 2 px, überall gleich. Akten sind nicht abgerundet.
- Rahmen 1 px in `--ink-500`. Keine Schatten außer dem Fokusring.
- Trennung durch Linien und Weißraum, nicht durch gestapelte Karten.
- Die Klassifizierung erscheint als 3 px breite Kante links am Objekt plus einer
Stempelmarke oben rechts. Beides in derselben Stufenfarbe.
- Der Verlauf hat eine durchgehende senkrechte Linie als Zeitachse. Der Chat hat keine,
sondern nur eine Trennlinie zum Hauptbereich. Der Unterschied muss ohne Beschriftung
erkennbar sein.
## Der eine mutige Moment
Die Schwärzung. Ein massiver Balken in `--ink-900` mit feiner Rahmenlinie, der beim ersten
Erscheinen einmal von links nach rechts durchwischt (150 ms, `prefers-reduced-motion`
respektiert). Daneben in Mono die Stufe, ab der gelesen werden darf. Sonst bewegt sich in der
Oberfläche nichts von selbst.
Alle übrige Bewegung ist Antwort auf eine Handlung: Aufklappen, Bestätigen, Einfügen.
Keine Einblend-Animationen beim Scrollen, keine Hover-Effekte auf jeder Fläche.
## Schreibweise in der Oberfläche
- Aktive Verben, die sagen, was passiert: „Vermerk speichern", nicht „Absenden"
- Dieselbe Bezeichnung durch den ganzen Ablauf: Knopf „Fall schließen" → Meldung
„Fall geschlossen"
- Fehler nennen Ursache und nächsten Schritt, ohne Entschuldigung:
„Nicht gespeichert, die Akte wurde zwischenzeitlich geändert. Fassungen vergleichen."
- Leere Zustände sind eine Aufforderung: „Noch kein Vermerk. Ersten Vermerk anlegen."
Nicht „Keine Daten vorhanden."
- Keine Ausrufezeichen, kein „bitte", kein „erfolgreich"
## Barrierefreiheit als Untergrenze
Sichtbarer Fokusring in `--steel`, Kontrast mindestens 4,5:1 im Fließtext, vollständige
Tastaturbedienung inklusive Kommandopalette, `prefers-reduced-motion` respektiert, alle
Zustandsangaben zusätzlich als Text und nicht nur als Farbe.
+263
View File
@@ -0,0 +1,263 @@
# Betrieb mit Dockge
Der Stack wird über [Dockge](https://github.com/louislam/dockge) verwaltet, nicht über die
Kommandozeile. Das bestimmt ein paar Entscheidungen, die sonst anders aussähen.
## Was daraus folgt
1. **Ein einziger Stack, eine `compose.yaml`.** Dockge verwaltet Stacks getrennt und legt für
jeden ein eigenes Netzwerk an. Mehrere Stacks müsste man von Hand zusammenstecken, also
kommt alles in einen.
2. **Keine Einmal-Container.** Dockge zeigt beendete Container nicht dauerhaft an. Ein
Migrations- oder Backup-Job als `restart: no` wäre unsichtbar, wenn er seit Wochen
fehlschlägt. Migrationen laufen deshalb im Startkommando von `web`, Backups in
Dauerläufern mit eigenem Cron.
3. **Healthchecks sind die Statusanzeige.** Dockge zeigt sie direkt in der Übersicht. Jeder
Dienst braucht einen, sonst siehst du nur „läuft" und weißt nichts.
4. **Named Volumes statt Bind Mounts.** Überstehen Stack-Updates zuverlässig und tauchen in
Dockge sauber auf. Ausnahme ist das Backup-Ziel, das gehört auf eine echte Platte.
5. **Kein Caddy.** Der Cloudflare Tunnel übernimmt TLS und Kompression, und Next.js liefert
seine statischen Dateien selbst mit korrekten Cache-Headern aus. Ein zusätzlicher Proxy
wäre nur ein weiterer Container, der kaputtgehen kann.
## Ablage
```
/opt/stacks/afis/
├── compose.yaml im Dockge-Editor bearbeitbar
└── .env eigener Reiter in Dockge
```
Beides wird über die Weboberfläche gepflegt. `.env` gehört nicht ins Git-Repository, die
Vorlage dafür ist `.env.example`.
## compose.yaml
```yaml
services:
web:
image: ghcr.io/DEIN-USER/afis-web:latest
restart: unless-stopped
command: sh -c "npx prisma migrate deploy && node server.js"
env_file: .env
environment:
NODE_ENV: production
PORT: "3000"
volumes:
- files:/data/files
- clamav_socket:/var/run/clamav
ports:
- "3000:3000" # nur im LAN, von außen kommt der Tunnel
depends_on:
pgbouncer: { condition: service_healthy }
clamav: { condition: service_healthy }
healthcheck:
test: ["CMD", "wget", "-qO-", "http://localhost:3000/api/health"]
interval: 30s
timeout: 5s
retries: 3
start_period: 40s
worker:
image: ghcr.io/DEIN-USER/afis-worker:latest
restart: unless-stopped
env_file: .env
depends_on:
pgbouncer: { condition: service_healthy }
healthcheck:
test: ["CMD", "node", "healthcheck.js"] # prüft Gateway-Verbindung
interval: 60s
timeout: 10s
retries: 3
start_period: 30s
db:
image: postgres:16-alpine
restart: unless-stopped
environment:
POSTGRES_DB: afis
POSTGRES_USER: afis
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
TZ: ${TZ}
volumes:
- pgdata:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U afis -d afis"]
interval: 10s
timeout: 5s
retries: 5
pgbouncer:
image: edoburu/pgbouncer:latest
restart: unless-stopped
environment:
DB_HOST: db
DB_USER: afis
DB_PASSWORD: ${POSTGRES_PASSWORD}
DB_NAME: afis
POOL_MODE: transaction
MAX_CLIENT_CONN: "100"
DEFAULT_POOL_SIZE: "20"
AUTH_TYPE: scram-sha-256
depends_on:
db: { condition: service_healthy }
healthcheck:
test: ["CMD-SHELL", "pg_isready -h 127.0.0.1 -p 6432 -U afis"]
interval: 15s
timeout: 5s
retries: 5
clamav:
image: clamav/clamav:stable
restart: unless-stopped
environment:
CLAMAV_NO_MILTERD: "true"
FRESHCLAM_CHECKS: "2"
volumes:
- clamav_db:/var/lib/clamav
- clamav_socket:/var/run/clamav
- files:/data/files
healthcheck:
test: ["CMD-SHELL", "clamdscan --ping 1 || exit 1"]
interval: 60s
timeout: 15s
retries: 3
start_period: 300s # erster Signaturdownload dauert
pdf:
image: ghcr.io/DEIN-USER/afis-pdf:latest # Playwright-Basis
restart: unless-stopped
environment:
PORT: "3000"
healthcheck:
test: ["CMD", "wget", "-qO-", "http://localhost:3000/health"]
interval: 60s
timeout: 10s
retries: 3
start_period: 30s
cloudflared:
image: cloudflare/cloudflared:latest
restart: unless-stopped
command: tunnel --no-autoupdate run
environment:
TUNNEL_TOKEN: ${CLOUDFLARE_TUNNEL_TOKEN}
depends_on:
web: { condition: service_healthy }
db-backup:
image: prodrigestivill/postgres-backup-local:16
restart: unless-stopped
environment:
POSTGRES_HOST: db
POSTGRES_DB: afis
POSTGRES_USER: afis
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
POSTGRES_EXTRA_OPTS: "-Fc"
SCHEDULE: "0 15 4 * * *" # täglich 04:15
BACKUP_KEEP_DAYS: "7"
BACKUP_KEEP_WEEKS: "4"
BACKUP_KEEP_MONTHS: "6"
HEALTHCHECK_PORT: "8080"
TZ: ${TZ}
volumes:
- /mnt/backup/afis/db:/backups
depends_on:
db: { condition: service_healthy }
offsite-backup:
image: mazzolino/restic:latest
restart: unless-stopped
environment:
RUN_ON_STARTUP: "false"
BACKUP_CRON: "0 30 4 * * *" # 15 Minuten nach dem Dump
RESTIC_REPOSITORY: ${RESTIC_REPOSITORY}
RESTIC_PASSWORD: ${RESTIC_PASSWORD}
RESTIC_BACKUP_SOURCES: /data/db /data/files
RESTIC_FORGET_ARGS: "--keep-daily 7 --keep-weekly 4 --keep-monthly 6 --prune"
TZ: ${TZ}
volumes:
- /mnt/backup/afis/db:/data/db:ro
- files:/data/files:ro
volumes:
pgdata:
files:
clamav_db:
clamav_socket:
```
`DEIN-USER` durch deinen GitHub-Namen ersetzen. Die Images baut ein GitHub-Actions-Workflow
und schiebt sie in die GitHub Container Registry. Dockge zieht sie beim Update.
## Erste Einrichtung
1. In Dockge einen neuen Stack `afis` anlegen
2. `compose.yaml` einfügen, `DEIN-USER` ersetzen
3. Im Reiter `.env` die Werte aus `.env.example` eintragen
4. Deploy. Beim ersten Start dauert `clamav` mehrere Minuten, bis die Signaturen da sind.
Solange bleibt der Container gelb, das ist normal.
5. Prüfen, dass `web` grün wird. Dann `https://afis.example.de` aufrufen.
## Aktualisieren
Dockge: Stack öffnen, „Update", fertig. Das zieht neue Images und startet neu.
Die Migration läuft automatisch im Startkommando von `web`.
**Vor jedem Update ein Dump.** Der läuft zwar nachts, aber ein Update mittags nach einer
kaputten Migration ohne frisches Backup ist genau der Fall, den man einmal erlebt.
In der Dockge-Konsole des `db-backup`-Containers:
```
/backup.sh
```
## Wiederherstellung
Aus der Dockge-Konsole des `db`-Containers:
```bash
pg_restore -U afis -d afis --clean --if-exists /backups/daily/afis-<datum>.sql.gz
```
Dateien liegen im Volume `files`. Aus dem restic-Repository:
```bash
restic restore latest --target /restore --include /data/files
```
**Einmal im Monat wirklich durchspielen**, in eine Wegwerf-Datenbank. Ein ungetestetes Backup
ist keins, und das ist der Punkt, an dem Homeserver-Projekte reihenweise scheitern.
## Was nicht nach außen darf
Nur `web` hat eine Portfreigabe, und die geht ins LAN. `db`, `pgbouncer`, `clamav` und `pdf`
sind ausschließlich im Stack-Netz erreichbar. Am Router wird kein Port geöffnet, der Tunnel
verbindet sich ausgehend.
Der Dockge-Zugang selbst (Port 5001) gehört ebenfalls nicht ins Internet. Wenn du von
unterwegs dran willst: über Tailscale oder einen zweiten, per Cloudflare Access geschützten
Tunnel, nie offen.
## Beobachtung
- Dockge-Übersicht als erste Anlaufstelle: alle Healthchecks auf einen Blick
- `/api/health` prüft Datenbank, Discord-Gateway und ClamAV und wird vom Healthcheck genutzt
- Der Worker meldet Start, Verbindungsabbruch und fehlgeschlagenen Voll-Abgleich in den
Admin-Discord-Kanal. Das ist deine eigentliche Alarmierung, weil du Discord ohnehin offen hast.
- Logs über die Dockge-Oberfläche. Zusätzlich im Compose begrenzen, sonst läuft die Platte voll:
```yaml
x-logging: &logging
logging:
driver: json-file
options: { max-size: "10m", max-file: "3" }
```
Als Anker `<<: *logging` bei jedem Dienst einhängen.
## Aufräumen
Tägliche Aufgabe in der Anwendung, kein eigener Container: abgelaufene Sessions löschen,
Quarantäne-Dateien älter als 24 Stunden löschen, PDF-Cache älter als 30 Tage löschen.
Der Audit-Log wird nie gelöscht.
+76
View File
@@ -0,0 +1,76 @@
# Arbeiten mit Claude Code
## Grundregel
Eine Phase aus `TASKS.md` pro Sitzung, nicht mehr. Lange Sitzungen verlieren den Faden und
produzieren Code, den niemand mehr prüft. Nach jeder Phase committen.
## Ablauf pro Phase
```
Lies CLAUDE.md, docs/03-auth-und-rechte.md und TASKS.md.
Wir machen Phase 1. Erstell mir zuerst einen Plan mit den Dateien, die du anlegen oder
ändern willst, und den offenen Entscheidungen. Baue noch nichts.
```
Plan durchsehen, dann:
```
Der Plan passt. Setz ihn um. Nach jeder Teilaufgabe: Tests laufen lassen und mir sagen,
was fertig ist.
```
Am Ende:
```
Hak die erledigten Punkte in TASKS.md ab und schreib mir eine kurze Zusammenfassung,
was jetzt funktioniert und was noch offen ist.
```
## Nützliche Einzelaufträge
**Sicherheitsdurchsicht nach einer Phase**
> Geh alle neuen Endpunkte und Server Actions durch. Prüf für jeden: findet die
> Berechtigungsprüfung serverseitig statt, steckt sie in der Datenbankabfrage statt als
> Nachfilter, und gibt es einen Test für den Fall ohne Berechtigung? Liste Lücken auf.
**Performance-Durchsicht**
> Prüf die neuen Ansichten gegen docs/09-performance-und-ux.md. Für jede Mutation:
> optimistisch oder nicht, und passt das zur Tabelle im Dokument? Für jede Route:
> gibt es eine loading.tsx mit passendem Skelett?
**Existenz-Leaks**
> Such nach Stellen, an denen die Existenz einer nicht sichtbaren Akte durchsickern kann:
> Trefferzahlen, Facettenzähler, Rückverweise auf Personalakten, Fehlermeldungen,
> Antwortzeiten. Schreib Tests für jede gefundene Stelle.
**Änderbarkeit prüfen**
> Geh alle Pfade durch, über die ein CaseEntry verändert werden kann. Prüf für jeden:
> wird eine CaseEntryVersion geschrieben, bleiben authorId und createdAt unangetastet,
> gibt es einen AuditLog-Eintrag, und wird die Karenzzeit serverseitig aus AppSetting
> geprüft statt aus dem Request? Liste Lücken auf.
**Wenn etwas komisch aussieht**
> Vergleich die Umsetzung mit docs/10-design-system.md. Wo hast du Radien, Schatten oder
> Farben benutzt, die dort nicht vorgesehen sind?
## Was du nicht delegieren solltest
- Discord-Application und Bot-Einladung: mach das selbst im Developer Portal
- Die echten `.env`-Werte, die gehören in den Dockge-Reiter und nicht ins Repository
- Das Anlegen des Stacks in Dockge und den Cloudflare-Tunnel
- Die erste Wiederherstellung eines Backups
- Die Entscheidung, welche Discord-Rolle welche Stufe bekommt
## Wenn Claude Code sich verrennt
Neue Sitzung starten und den Kontext neu setzen, statt in der alten weiterzudiskutieren:
> Lies CLAUDE.md und docs/<passendes Dokument>. Der aktuelle Stand von <Datei> macht <X>,
> soll aber <Y> machen. Erklär mir zuerst, warum der jetzige Stand nicht passt,
> bevor du ihn änderst.
+99
View File
@@ -0,0 +1,99 @@
# Getroffene Entscheidungen
Stellen, an denen die Fachdokumente sich widersprechen, lückenhaft sind oder gegen eine
Eigenschaft von Postgres laufen. Jede Zeile nennt den Konflikt, die Entscheidung und den Grund.
Wo eine Entscheidung fachlich anders ausfallen kann, steht das ausdrücklich dabei.
## Struktur
| Konflikt | Entscheidung |
|---|---|
| `CLAUDE.md` und `README.md` verweisen auf `docs/`, die Dateien lagen im Wurzelverzeichnis | Nach `docs/` verschoben |
| `02-architektur.md` zeigt `app/` und `lib/` im Wurzelverzeichnis, `TASKS.md` Phase 0 verlangt einen pnpm-Workspace mit `web/` und `worker/` | Workspace mit `web/`, `worker/`, `pdf/` und `packages/db/`. Die Struktur aus `02-architektur.md` gilt innerhalb von `web/` |
| Der Worker braucht denselben Prisma-Client wie die Web-App | Eigenes Workspace-Paket `packages/db` (`@afis/db`), von `web` und `worker` benutzt. Migrationen und Seed liegen dort |
## Datenmodell
| Konflikt | Entscheidung |
|---|---|
| `CaseParticipant` hat den zusammengesetzten Primärschlüssel `[caseId, role, userId, personnelFileId]`, aber `userId` und `personnelFileId` sind beide optional. Postgres lässt NULL in einem Primärschlüssel nicht zu | Eigener Schlüssel `id`, Eindeutigkeit über zwei **partielle** Unique-Indizes plus eine CHECK-Bedingung, dass mindestens eines der beiden Felder gesetzt ist |
| `04-datenmodell.md` nennt zugleich `CREATE SEQUENCE case_number_2026` und die Tabelle `CaseCounter(year, lastNumber)` mit `SELECT … FOR UPDATE` | `CaseCounter`. Eine Sequenz pro Jahr müsste jedes Jahr von Hand angelegt werden, und der Abteilungsleiter ist ausdrücklich nicht technisch |
| Das rohe SQL benutzt `search_vector` und `updated_at` in Schlangenschrift, die Prisma-Modelle Höckerschrift | Prisma-Voreinstellung behalten: Tabellen in Höckerschrift-Pascal, Spalten in Höckerschrift. Das SQL quotet entsprechend (`"searchVector"`, `"updatedAt"`) |
| Kein Modell für Kanäle, obwohl `ChatMessage.channelId` existiert und `06-chat-und-realtime.md` Kanäle mit eigener Mindeststufe verlangt | Modell `Channel` ergänzt, dazu `ChatReaction` für den festen Reaktionssatz |
| `/api/health` soll den Discord-Gateway prüfen, aber die Web-App fragt nie live bei Discord an | Modell `ServiceHeartbeat`. Der Worker schreibt alle 30 Sekunden seinen Zustand, `web` liest ihn |
## Der wichtigste Widerspruch
`04-datenmodell.md` verlangt `CREATE RULE entry_version_no_update … DO INSTEAD NOTHING` auf
`CaseEntryVersion`. `05-akten-und-vorlagen.md` verlangt unter „Umklassifizierung wirkt
rückwirkend", dass beim Anheben von `minClearance` **alle bestehenden Versionen den neuen Wert
erben**. Beides zusammen geht nicht: die Regel würde genau die Nachführung verhindern, die das
Leck schließt.
Aufgelöst durch einen Trigger statt einer Regel. `CaseEntryVersion` bleibt anhängend, aber
`minClearance` darf nachgeführt werden, und nur nach oben. Jede andere Änderung wirft einen
Fehler. Das erhält beide Absichten: der Text ist unveränderlich, die Klassifizierung wirkt
rückwirkend.
Ein zweiter Trigger schützt `CaseEntry.authorId`, `createdAt`, `caseId` und `type` auf
Datenbankebene, nicht nur in der Anwendung.
## Suche
`08-suche-und-export.md` beschreibt einen `search_vector` je Akte aus Titel, Abschnitten und
Verlaufseinträgen. Ungeklärt bleibt, was mit Inhalten oberhalb der Stufe des Suchenden
passiert. Ein gemeinsamer Vektor über alle Abschnitte würde einen Nutzer mit Stufe 1 einen
Begriff finden lassen, der nur in einem als `[[c4]]…[[/c4]]` geschwärzten Bereich steht.
Das verstößt gegen harte Regel 6.
**Vorläufig aufgenommen wird nur, was jeder lesen darf, der die Akte überhaupt sehen darf:**
Aktenzeichen und Titel (Gewicht A), Abschnitte und Verlaufseinträge mit `minClearance = 0`
(B und C), Schwärzungs-Auszeichnungen vorher entfernt. Zurückgezogene Einträge fallen heraus.
Das ist sicher, aber für hohe Stufen zu sparsam: ein Nutzer mit Stufe 5 findet geheime Inhalte
nicht über die Volltextsuche. **Phase 7 muss das auflösen**, sinnvollerweise über getrennte
Vektorspalten je Stufe. Diese Entscheidung ist bewusst konservativ getroffen, weil ein Leck
hier schwerer zu bemerken ist als eine Lücke.
## Prisma 7
Die Dokumente nennen nur „Prisma + PostgreSQL 16". Eingesetzt wird Prisma 7, die
aktuelle Fassung. Sie unterscheidet sich in drei Punkten, die den Aufbau betreffen:
| Änderung | Folge hier |
|---|---|
| Verbindungsdaten stehen nicht mehr im Schema | `packages/db/prisma.config.ts`. Migrationen laufen über `DIRECT_DATABASE_URL`, also an pgbouncer vorbei |
| Keine Rust-Query-Engine mehr, stattdessen Treiber-Adapter | `@prisma/adapter-pg` über `pg`. Der Treiber wird für `LISTEN/NOTIFY` ohnehin gebraucht, und die Images müssen keine Engine nachladen |
| Neuer Generator `prisma-client` statt `prisma-client-js` | Der Client wird als ECMAScript-Module nach `packages/db/src/generated` erzeugt statt nach `node_modules/.prisma`. Nötig mit `importFileExtension = "js"`, sonst schreibt er endungslose Importe, die Node im ESM-Modus nicht auflöst |
Der erzeugte Client ist ein Bauartefakt und liegt nicht im Repository.
`pnpm --filter @afis/db run build` erzeugt ihn, die CI tut das vor lint, typecheck und test.
Für `prisma migrate deploy` braucht es weiterhin eine Rust-Schema-Engine. Sie wird beim
Bauen des Web-Images heruntergeladen und liegt unter `/app/migrator` im Image. Beim
Containerstart wird nichts nachgeladen: ein Container, der nur startet, wenn gerade eine
Registry erreichbar ist, wäre auf einem Homeserver die falsche Bauart.
## Betrieb
| Konflikt | Entscheidung |
|---|---|
| `11-betrieb.md` verlangt „Jeder Dienst braucht einen Healthcheck", die abgedruckte `compose.yaml` hat für `db-backup` und `offsite-backup` keinen | Ergänzt. `db-backup` über seinen HTTP-Port 8080, `offsite-backup` über Cron-Prozess und `restic version` |
| Das Startkommando `npx prisma migrate deploy` würde im Container aus dem Netz nachladen | Auf die im Image unter `/app/migrator` mitgelieferte Prisma-CLI umgestellt, zusammen mit Schema, Migrationsordner und Schema-Engine |
| `pnpm deploy` zieht die optionalen Peers von `@prisma/client` mit ins Image (CLI, Studio, TypeScript, rund 240 MB) | `--no-optional` beim Bündeln. Zur Laufzeit wird nichts davon gebraucht |
## Protokollierung
`09-performance-und-ux.md` verlangt eine Puffertabelle für Audit-Einträge, die alle zwei
Sekunden gebündelt geschrieben wird, zugleich aber, dass schreibende Aktionen ihren Eintrag
**in derselben Transaktion** erzeugen. Umgesetzt wird beides getrennt: schreibende Aktionen
protokollieren synchron in der Transaktion, Lesezugriffe ab Stufe 3 laufen über einen Puffer.
Der Puffer liegt im Prozessspeicher, nicht in einer Tabelle — eine Puffertabelle hätte
dieselbe Verlustgefahr bei einem Absturz, aber zusätzliche Schreiblast. Kommt in Phase 2.
## Vorlagen im Seed
`CLAUDE.md` beschreibt `pnpm db:seed` als „Testdaten inkl. Vorlagen", `TASKS.md` ordnet die
vier Startvorlagen aber Phase 3 zu. Der Seed legt vorerst nur an, was die Anwendung zum Laufen
braucht: die sechs Freigabestufen, die Änderungsrichtlinie und den Aktenzeichen-Zähler.
Die Vorlagen kommen mit dem Vorlagen-Editor in Phase 3.