commit 4446e3f0aceb06a9085c7d6350647cd850ab7b84 Author: Harlekin Date: Tue Sep 1 00:00:58 2026 +0200 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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..982654a --- /dev/null +++ b/.gitignore @@ -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 diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..52f46e5 --- /dev/null +++ b/CLAUDE.md @@ -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. diff --git a/README.md b/README.md new file mode 100644 index 0000000..a6f6c7d --- /dev/null +++ b/README.md @@ -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 | diff --git a/docs/01-produkt.md b/docs/01-produkt.md new file mode 100644 index 0000000..03e7eaa --- /dev/null +++ b/docs/01-produkt.md @@ -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--<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 diff --git a/docs/02-architektur.md b/docs/02-architektur.md new file mode 100644 index 0000000..b964dda --- /dev/null +++ b/docs/02-architektur.md @@ -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 ``-Grenzen und streamen nach. Die Seite ist +sichtbar, bevor der Verlauf da ist. + +**Neuer Vermerk.** Server Action schreibt `CaseEntry`, danach `revalidateTag('case:')` +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. diff --git a/docs/03-auth-und-rechte.md b/docs/03-auth-und-rechte.md new file mode 100644 index 0000000..d594297 --- /dev/null +++ b/docs/03-auth-und-rechte.md @@ -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 // 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 diff --git a/docs/04-datenmodell.md b/docs/04-datenmodell.md new file mode 100644 index 0000000..88e01b8 --- /dev/null +++ b/docs/04-datenmodell.md @@ -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. diff --git a/docs/05-akten-und-vorlagen.md b/docs/05-akten-und-vorlagen.md new file mode 100644 index 0000000..e398f74 --- /dev/null +++ b/docs/05-akten-und-vorlagen.md @@ -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. diff --git a/docs/06-chat-und-realtime.md b/docs/06-chat-und-realtime.md new file mode 100644 index 0000000..c76148c --- /dev/null +++ b/docs/06-chat-und-realtime.md @@ -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:,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=` 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. diff --git a/docs/07-dateien-und-sicherheit.md b/docs/07-dateien-und-sicherheit.md new file mode 100644 index 0000000..54b5d2b --- /dev/null +++ b/docs/07-dateien-und-sicherheit.md @@ -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/ → Typprüfung → ClamAV → Neu kodieren → store/ + │ │ + └─────────────┴──► 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 diff --git a/docs/08-suche-und-export.md b/docs/08-suche-und-export.md new file mode 100644 index 0000000..08f8019 --- /dev/null +++ b/docs/08-suche-und-export.md @@ -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 diff --git a/docs/09-performance-und-ux.md b/docs/09-performance-und-ux.md new file mode 100644 index 0000000..033a1cf --- /dev/null +++ b/docs/09-performance-und-ux.md @@ -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 ``- + 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 + +- `` 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:')` 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`. diff --git a/docs/10-design-system.md b/docs/10-design-system.md new file mode 100644 index 0000000..5218673 --- /dev/null +++ b/docs/10-design-system.md @@ -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. diff --git a/docs/11-betrieb.md b/docs/11-betrieb.md new file mode 100644 index 0000000..8af00cf --- /dev/null +++ b/docs/11-betrieb.md @@ -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-.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. diff --git a/docs/12-arbeiten-mit-claude-code.md b/docs/12-arbeiten-mit-claude-code.md new file mode 100644 index 0000000..d1c7026 --- /dev/null +++ b/docs/12-arbeiten-mit-claude-code.md @@ -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/. Der aktuelle Stand von macht , +> soll aber machen. Erklär mir zuerst, warum der jetzige Stand nicht passt, +> bevor du ihn änderst. diff --git a/docs/13-entscheidungen.md b/docs/13-entscheidungen.md new file mode 100644 index 0000000..810220d --- /dev/null +++ b/docs/13-entscheidungen.md @@ -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.