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:
+24
@@ -0,0 +1,24 @@
|
|||||||
|
node_modules/
|
||||||
|
.pnpm-store/
|
||||||
|
.next/
|
||||||
|
out/
|
||||||
|
dist/
|
||||||
|
build/
|
||||||
|
|
||||||
|
# Von `prisma generate` erzeugt, siehe packages/db/prisma/schema.prisma
|
||||||
|
packages/db/src/generated/
|
||||||
|
*.tsbuildinfo
|
||||||
|
|
||||||
|
# Secrets. Vorlage ist .env.example, die echten Werte stehen im .env-Reiter von Dockge.
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
!.env.example
|
||||||
|
|
||||||
|
coverage/
|
||||||
|
test-results/
|
||||||
|
playwright-report/
|
||||||
|
blob-report/
|
||||||
|
.playwright/
|
||||||
|
|
||||||
|
.DS_Store
|
||||||
|
*.log
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
# AfiS-Aktensystem
|
||||||
|
|
||||||
|
Interne Web-Anwendung für die Abteilung für innere Sicherheit (AfiS) eines SCP-RP-Servers.
|
||||||
|
Verwaltet Fallakten, Personalakten und Ermittlungen mit einem mehrstufigen Klassifizierungssystem.
|
||||||
|
Anmeldung und Berechtigungen laufen vollständig über Discord.
|
||||||
|
|
||||||
|
Dies ist ein Rollenspiel-Werkzeug für eine geschlossene Gruppe von etwa 10 bis 50 Personen,
|
||||||
|
selbst gehostet auf einem Homeserver. Kein Multi-Tenant, keine öffentliche Registrierung.
|
||||||
|
|
||||||
|
## Stack
|
||||||
|
|
||||||
|
- Next.js 15, App Router, React 19, TypeScript strict
|
||||||
|
- Auth.js v5 (Discord Provider)
|
||||||
|
- Prisma + PostgreSQL 16
|
||||||
|
- Tailwind CSS, keine Komponentenbibliothek (siehe `docs/10-design-system.md`)
|
||||||
|
- Separater Node-Dienst `worker/` mit discord.js für Rollen-Abgleich
|
||||||
|
- ClamAV und Playwright als eigene Container
|
||||||
|
- Betrieb als **ein** Dockge-Stack, siehe `docs/11-betrieb.md`. Kein Reverse Proxy im Stack,
|
||||||
|
TLS und Kompression macht der Cloudflare Tunnel.
|
||||||
|
|
||||||
|
## Dokumentation
|
||||||
|
|
||||||
|
Vor dem Bau eines Bereichs immer das passende Dokument lesen:
|
||||||
|
|
||||||
|
| Datei | Inhalt |
|
||||||
|
|---|---|
|
||||||
|
| `docs/01-produkt.md` | Domäne, Glossar, Rollen der Nutzer |
|
||||||
|
| `docs/02-architektur.md` | Dienste, Datenflüsse, Verzeichnisstruktur |
|
||||||
|
| `docs/03-auth-und-rechte.md` | Discord-Login, Rollen-Mapping, Zugriffsprüfung |
|
||||||
|
| `docs/04-datenmodell.md` | Prisma-Schema, Indizes, Migrationen |
|
||||||
|
| `docs/05-akten-und-vorlagen.md` | Aktenaufbau, Vorlagen, Versionierung, Schwärzung |
|
||||||
|
| `docs/06-chat-und-realtime.md` | Aktenchat, SSE, Benachrichtigungen |
|
||||||
|
| `docs/07-dateien-und-sicherheit.md` | Upload-Prüfkette, Auslieferung |
|
||||||
|
| `docs/08-suche-und-export.md` | Volltextsuche, PDF-Export |
|
||||||
|
| `docs/09-performance-und-ux.md` | Ladezustände, optimistische Updates, Latenzbudgets |
|
||||||
|
| `docs/10-design-system.md` | Farben, Typografie, Komponentenregeln |
|
||||||
|
| `docs/11-betrieb.md` | Dockge-Stack, compose.yaml, Backups |
|
||||||
|
| `docs/13-entscheidungen.md` | Aufgelöste Widersprüche der Dokumente, getroffene Festlegungen |
|
||||||
|
|
||||||
|
`TASKS.md` enthält die Bauphasen. Arbeite sie in Reihenfolge ab und hake ab, was fertig ist.
|
||||||
|
|
||||||
|
## Harte Regeln
|
||||||
|
|
||||||
|
1. **Berechtigungsprüfung immer serverseitig und immer in der Datenbankabfrage.**
|
||||||
|
Niemals alle Datensätze laden und danach filtern. Siehe `docs/03-auth-und-rechte.md`.
|
||||||
|
2. **Verlaufseinträge dürfen nachträglich geändert werden, aber niemals unsichtbar.**
|
||||||
|
Jede Änderung erzeugt eine Version, jede Zurücknahme lässt einen Grabstein stehen,
|
||||||
|
beides ist für alle sichtbar, die den Eintrag sehen dürfen. Es gibt kein `DELETE`.
|
||||||
|
Welche Änderungen erlaubt sind, steuert die Änderungsrichtlinie in
|
||||||
|
`docs/05-akten-und-vorlagen.md`.
|
||||||
|
3. **Jede schreibende Aktion und jeder Lesezugriff auf Stufe 3+ erzeugt einen Audit-Eintrag.**
|
||||||
|
4. **Keine Dateiendungen als Typprüfung.** Immer Magic Bytes. Kein SVG, kein HTML, keine Archive.
|
||||||
|
5. **Keine Blockade-Spinner.** Jede Ansicht hat ein Skelett, jede Mutation eine sofortige
|
||||||
|
Reaktion. Siehe `docs/09-performance-und-ux.md`, das ist keine Kür.
|
||||||
|
6. **Existenz ist auch eine Information.** Akten oberhalb der Sichtstufe des Nutzers tauchen
|
||||||
|
weder in Listen noch in Trefferzahlen noch in Rückverweisen auf.
|
||||||
|
7. Keine Secrets im Repo. Alles über `.env`, Vorlage in `.env.example`.
|
||||||
|
8. **Keine Einmal-Container und keine Init-Container.** Der Betrieb läuft über Dockge, das
|
||||||
|
beendete Container nicht dauerhaft anzeigt. Migrationen gehören ins Startkommando von
|
||||||
|
`web`, wiederkehrende Aufgaben in Dauerläufer mit eigenem Cron. Jeder Dienst braucht
|
||||||
|
einen Healthcheck, weil das in Dockge die Statusanzeige ist.
|
||||||
|
|
||||||
|
## Sprachkonvention
|
||||||
|
|
||||||
|
- **Code, Datenbank, API**: Englisch. `Case`, `CaseEntry`, `PersonnelFile`, `ClearanceLevel`.
|
||||||
|
- **Oberfläche und Dokumentation**: Deutsch. Alle UI-Texte zentral in `lib/i18n/de.ts`,
|
||||||
|
nie hart im JSX. Das Glossar in `docs/01-produkt.md` legt die Übersetzungen fest.
|
||||||
|
|
||||||
|
## Kommandos
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm dev # Next.js Dev-Server
|
||||||
|
pnpm worker:dev # Discord-Sync-Worker
|
||||||
|
pnpm db:migrate # Prisma Migration anwenden
|
||||||
|
pnpm db:seed # Testdaten inkl. Vorlagen
|
||||||
|
pnpm db:studio # Prisma Studio
|
||||||
|
pnpm test # Vitest
|
||||||
|
pnpm test:e2e # Playwright
|
||||||
|
pnpm lint && pnpm typecheck
|
||||||
|
pnpm build # Standalone-Build für das Container-Image
|
||||||
|
```
|
||||||
|
|
||||||
|
## Arbeitsweise
|
||||||
|
|
||||||
|
- Kleine Commits pro abgeschlossener Aufgabe aus `TASKS.md`.
|
||||||
|
- Für jede Zugriffsregel einen Test, der den **negativen** Fall prüft: Nutzer ohne Freigabe
|
||||||
|
sieht nichts, findet nichts, bekommt 404 statt 403.
|
||||||
|
- Bei Unklarheiten in der Fachlichkeit nicht raten, sondern nachfragen.
|
||||||
|
- Nach jeder Phase `pnpm lint && pnpm typecheck && pnpm test` laufen lassen.
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
# AfiS-Aktensystem
|
||||||
|
|
||||||
|
Fallakten-, Personalakten- und Ermittlungsverwaltung für die Abteilung für innere Sicherheit
|
||||||
|
eines SCP-RP-Servers. Anmeldung und Berechtigungen laufen vollständig über Discord.
|
||||||
|
|
||||||
|
## Los geht's
|
||||||
|
|
||||||
|
1. Ordner in ein leeres Git-Repository legen
|
||||||
|
2. Discord-Application anlegen, Bot einladen, **Server Members Intent aktivieren**
|
||||||
|
3. Claude Code im Projektordner starten
|
||||||
|
|
||||||
|
Gehostet wird als **ein Dockge-Stack** auf dem Homeserver. Die fertige `compose.yaml` und die
|
||||||
|
Einrichtungsschritte stehen in `docs/11-betrieb.md`. Die `.env` wird nicht im Repository
|
||||||
|
gepflegt, sondern im `.env`-Reiter von Dockge; `.env.example` ist nur die Vorlage.
|
||||||
|
|
||||||
|
Claude Code liest `CLAUDE.md` von selbst. Die Fachdokumente liegen in `docs/`, der Bauplan
|
||||||
|
in `TASKS.md`.
|
||||||
|
|
||||||
|
## Erste Anweisung an Claude Code
|
||||||
|
|
||||||
|
> Lies CLAUDE.md und alle Dateien in docs/. Fass in fünf Sätzen zusammen, was gebaut wird,
|
||||||
|
> und nenn mir alles, was in den Dokumenten widersprüchlich oder unklar ist, bevor du
|
||||||
|
> anfängst. Baue noch nichts.
|
||||||
|
|
||||||
|
Danach Phase für Phase aus `TASKS.md`. Siehe `docs/12-arbeiten-mit-claude-code.md`.
|
||||||
|
|
||||||
|
## Aufbau
|
||||||
|
|
||||||
|
```
|
||||||
|
CLAUDE.md Projektanweisung, wird automatisch gelesen
|
||||||
|
TASKS.md Bauplan in neun Phasen
|
||||||
|
docs/ Fachdokumente, dazu 13-entscheidungen.md
|
||||||
|
compose.yaml Der Dockge-Stack
|
||||||
|
.env.example Vorlage für den .env-Reiter in Dockge
|
||||||
|
|
||||||
|
web/ Next.js 15, App Router
|
||||||
|
worker/ discord.js, Rollen-Abgleich
|
||||||
|
pdf/ Playwright, PDF-Erzeugung
|
||||||
|
packages/db/ Prisma-Schema, Migrationen, Seed
|
||||||
|
docker/ Dockerfiles für web, worker und pdf
|
||||||
|
```
|
||||||
|
|
||||||
|
## Lokal entwickeln
|
||||||
|
|
||||||
|
Node 22 und pnpm 9.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
corepack enable && pnpm install
|
||||||
|
|
||||||
|
docker run -d --name afis-db -e POSTGRES_DB=afis -e POSTGRES_USER=afis \
|
||||||
|
-e POSTGRES_PASSWORD=afis -p 5434:5432 postgres:16-alpine
|
||||||
|
|
||||||
|
cp .env.example .env
|
||||||
|
# DATABASE_URL und DIRECT_DATABASE_URL auf localhost:5434 zeigen lassen
|
||||||
|
|
||||||
|
pnpm --filter @afis/db run build # erzeugt den Prisma-Client
|
||||||
|
pnpm db:deploy && pnpm db:seed
|
||||||
|
pnpm dev
|
||||||
|
```
|
||||||
|
|
||||||
|
Prüfen wie die CI:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm lint && pnpm typecheck && pnpm test
|
||||||
|
pnpm test:e2e
|
||||||
|
```
|
||||||
|
|
||||||
|
## Betrieb kurz gefasst
|
||||||
|
|
||||||
|
| | |
|
||||||
|
|---|---|
|
||||||
|
| Verwaltung | Dockge, ein Stack namens `afis` unter `/opt/stacks/afis/` |
|
||||||
|
| Erreichbarkeit | Cloudflare Tunnel, kein offener Port am Router |
|
||||||
|
| Aktualisieren | Dockge → Stack → Update, Migration läuft automatisch mit |
|
||||||
|
| Backup | zwei Dauerläufer mit Cron: nächtlicher Dump plus restic offsite |
|
||||||
@@ -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
|
||||||
@@ -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.
|
||||||
@@ -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
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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`.
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
Reference in New Issue
Block a user