From 9156dcc1449e3352fe84cacc147101214e42046d Mon Sep 17 00:00:00 2001 From: Florian Wilde Date: Sat, 5 Sep 2026 17:16:08 +0200 Subject: [PATCH 1/2] Phase 11: Freigabereife Kein Modul, sondern die drei Software-Voraussetzungen fuer 1.0.0: Auskunft nach Art. 15 DSGVO, ein geprueftes Sicherungskonzept und eine Pipeline. Das Auskunftsrecht war ein Versprechen ohne Umsetzung: `person.export` stand seit Phase 0 im Katalog und war dem Kommandanten zugewiesen, implementiert war nichts. Ohne Auskunftsseite ist ein Loeschkonzept nicht abnehmbar. Jetzt sammelt FLORIAN dreizehn Abschnitte ueber eine Person, mit dem Zweck ueber jedem - Art. 15 verlangt nicht nur die Daten, sondern auch Zwecke und Rechte, und eine blosse Liste erfuellt die Pflicht nicht. Ein Durchgang liefert Anzeige, PDF und JSON nach Art. 20. Die Trennlinie ist "ueber mich" gegen "von mir": was ueber die Person gespeichert ist, steht vollstaendig drin, auch das Unangenehme. Was sie getan hat, steht als Zahl drin - die Pruefprotokolle gehoeren zum Nachweisbestand der Wehr, und vollstaendig ausgeliefert kaeme jeder ueber eine Selbstauskunft an die Geraeteakten. Die Rueckspielprobe hat sich sofort bezahlt gemacht: der erste Versuch lief sauber durch und hatte dabei saemtliche Unveraenderlichkeitsgarantien der Datenbank entfernt. Die Vorgabeprivilegierung aus dem Init-Skript gibt jeder von pg_restore neu angelegten Tabelle wieder alle Rechte, und GRANT kann nur hinzufuegen. Die Anwendungsrolle durfte danach das Audit-Log aendern, Wiki-Fassungen loeschen, Kiosk-Vorgaenge umschreiben - und nichts haette darauf hingewiesen. Gefunden haben es vier Datenbanktests unmittelbar nach der Probe, also genau die Probe, wegen der 1.0.0 sie verlangt. Die Pipeline ist geschrieben, aber NICHT erprobt: in Gitea ist kein act_runner registriert. Das steht so im README und im Devlog, damit sich niemand auf eine Pruefung verlaesst, die nicht stattfindet. 601 Unit-Tests gruen; Sicherung und Wiederherstellung einmal vollstaendig durchgefuehrt und die Tests danach erneut gruen. Co-Authored-By: Claude Opus 5 (1M context) --- .gitea/workflows/pruefung.yml | 184 +++++++++++ .gitignore | 3 + CHANGELOG.md | 94 ++++++ README.md | 132 ++++++++ docs/ANLEITUNG.md | 121 +++++++- docs/AUFWANDSSCHAETZUNG.md | 3 +- docs/DEVLOG.md | 82 ++++- docs/ROADMAP.md | 24 +- package.json | 4 +- scripts/sicherung-sperren.ts | 94 ++++++ scripts/sicherung-zugang.ts | 51 +++ scripts/sicherung.ts | 188 +++++++++++ scripts/wiederherstellen.ts | 270 ++++++++++++++++ src/app/(app)/mitglieder/[id]/page.tsx | 16 +- src/components/stammdaten/auskunft.tsx | 190 ++++++++++++ src/lib/dsgvo/auskunft.ts | 394 ++++++++++++++++++++++++ src/lib/dsgvo/bericht.ts | 170 ++++++++++ src/server/routers/stammdaten/person.ts | 84 +++++ tests/e2e/termine.spec.ts | 10 + tests/unit/dsgvo-auskunft.test.ts | 221 +++++++++++++ tests/unit/sicherung-zugang.test.ts | 168 ++++++++++ 21 files changed, 2495 insertions(+), 8 deletions(-) create mode 100644 .gitea/workflows/pruefung.yml create mode 100644 scripts/sicherung-sperren.ts create mode 100644 scripts/sicherung-zugang.ts create mode 100644 scripts/sicherung.ts create mode 100644 scripts/wiederherstellen.ts create mode 100644 src/components/stammdaten/auskunft.tsx create mode 100644 src/lib/dsgvo/auskunft.ts create mode 100644 src/lib/dsgvo/bericht.ts create mode 100644 tests/unit/dsgvo-auskunft.test.ts create mode 100644 tests/unit/sicherung-zugang.test.ts diff --git a/.gitea/workflows/pruefung.yml b/.gitea/workflows/pruefung.yml new file mode 100644 index 0000000..b2b8e67 --- /dev/null +++ b/.gitea/workflows/pruefung.yml @@ -0,0 +1,184 @@ +# ============================================================================= +# Die Pruefung bei jedem Push. +# +# Bis Phase 10 gab es 581 Unit- und 266 E2E-Tests — und nichts, was sie +# ausfuehrte ausser einem Menschen, der daran dachte. In der Sitzung, in der +# die Kassenverwaltung entstand, haben die Tests vier echte Fehler gefunden, +# zwei davon in Code, der eine Stunde alt war. Genau das soll hier von allein +# passieren. +# +# Zwei Auftraege, bewusst getrennt: +# +# pruefung typecheck, lint und die Unit-Tests. Braucht eine Datenbank, +# weil ein Teil der Tests die Trigger und RLS gegen echtes +# Postgres prueft — mit einer Attrappe waeren sie wertlos. +# +# oberflaeche die E2E-Tests. Dauern eine Dreiviertelstunde und brauchen den +# halben Stack, deshalb laufen sie nur auf main und in Pull +# Requests, nicht bei jedem Zwischenstand auf einem Zweig. +# ============================================================================= +name: Pruefung + +on: + push: + branches: [main] + pull_request: + +env: + # Dieselben Werte wie in .env.example. Es sind Testdaten in einem + # Wegwerf-Container; echte Geheimnisse haben in einer Pipeline nichts zu + # suchen und stehen im Betrieb in der .env. + POSTGRES_USER: florian + POSTGRES_PASSWORD: pruefung + POSTGRES_DB: florian + POSTGRES_APP_PASSWORD: pruefung_app + DATABASE_URL: postgresql://florian_app:pruefung_app@localhost:5432/florian?schema=public + DIRECT_DATABASE_URL: postgresql://florian:pruefung@localhost:5432/florian?schema=public + AUTH_SECRET: nur-fuer-die-pruefung-nicht-fuer-den-betrieb + AUTH_TRUST_HOST: "true" + APP_URL: http://localhost:3100 + S3_ENDPOINT: localhost + S3_PORT: "9000" + S3_USE_SSL: "false" + S3_ACCESS_KEY: florian + S3_SECRET_KEY: pruefung_minio + S3_BUCKET: florian + REDIS_URL: redis://localhost:6379 + TZ: Europe/Berlin + +jobs: + pruefung: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: npm + + # Das eigene Postgres-Image statt eines fertigen: FLORIAN braucht + # PostGIS **und** pgvector, und kein oeffentliches Image bringt beides + # mit. Ein Dienst-Container mit postgres:17 wuerde die Migrationen + # durchlassen und erst bei der ersten Umkreissuche scheitern. + - name: Datenbank bauen und starten + run: | + docker build -t florian-postgres:pruefung ./docker/postgres + docker run -d --name postgres -p 5432:5432 \ + -e POSTGRES_USER=$POSTGRES_USER \ + -e POSTGRES_PASSWORD=$POSTGRES_PASSWORD \ + -e POSTGRES_DB=$POSTGRES_DB \ + -e POSTGRES_APP_PASSWORD=$POSTGRES_APP_PASSWORD \ + -e POSTGRES_INITDB_ARGS="--locale-provider=icu --icu-locale=de-DE --locale=C.UTF-8 --encoding=UTF8" \ + -v "$(pwd)/docker/postgres:/docker-entrypoint-initdb.d:ro" \ + florian-postgres:pruefung + + - name: Redis und MinIO starten + run: | + docker run -d --name redis -p 6379:6379 redis:7-alpine + docker run -d --name minio -p 9000:9000 \ + -e MINIO_ROOT_USER=$S3_ACCESS_KEY \ + -e MINIO_ROOT_PASSWORD=$S3_SECRET_KEY \ + minio/minio:latest server /data + + - run: npm ci + + - name: Auf die Datenbank warten + run: | + for i in $(seq 1 60); do + docker exec postgres pg_isready -U "$POSTGRES_USER" -d "$POSTGRES_DB" && exit 0 + sleep 2 + done + echo "Postgres kam nicht hoch:" + docker logs postgres + exit 1 + + - run: npx prisma generate + - run: npm run typecheck + - run: npm run lint + + - name: Migrationen einspielen + run: npm run db:migrate + + # Der Seed gehoert dazu: er ist selbst ein Test. Faellt eine Migration + # oder ein Standardwert um, merkt man es hier und nicht erst, wenn eine + # Wehr ihn zum ersten Mal ausfuehrt. + - name: Beispielbestand anlegen + run: npm run db:seed + + - run: npm run test + + - name: Aufraeumen + if: always() + run: docker rm -f postgres redis minio || true + + oberflaeche: + runs-on: ubuntu-latest + # Nur wo es sich lohnt: ein voller Lauf dauert etwa 45 Minuten. + if: github.event_name == 'pull_request' || github.ref == 'refs/heads/main' + + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: npm + + - name: Dienste starten + run: | + docker build -t florian-postgres:pruefung ./docker/postgres + docker run -d --name postgres -p 5432:5432 \ + -e POSTGRES_USER=$POSTGRES_USER \ + -e POSTGRES_PASSWORD=$POSTGRES_PASSWORD \ + -e POSTGRES_DB=$POSTGRES_DB \ + -e POSTGRES_APP_PASSWORD=$POSTGRES_APP_PASSWORD \ + -e POSTGRES_INITDB_ARGS="--locale-provider=icu --icu-locale=de-DE --locale=C.UTF-8 --encoding=UTF8" \ + -v "$(pwd)/docker/postgres:/docker-entrypoint-initdb.d:ro" \ + florian-postgres:pruefung + docker run -d --name redis -p 6379:6379 redis:7-alpine + docker run -d --name minio -p 9000:9000 \ + -e MINIO_ROOT_USER=$S3_ACCESS_KEY \ + -e MINIO_ROOT_PASSWORD=$S3_SECRET_KEY \ + minio/minio:latest server /data + docker run -d --name gotenberg -p 3001:3000 gotenberg/gotenberg:8 + + - run: npm ci + - run: npx playwright install --with-deps chromium + + - name: Auf die Datenbank warten + run: | + for i in $(seq 1 60); do + docker exec postgres pg_isready -U "$POSTGRES_USER" -d "$POSTGRES_DB" && exit 0 + sleep 2 + done + docker logs postgres + exit 1 + + - run: npx prisma generate + - run: npm run db:migrate + + # Die E2E-Tests laufen seit Phase 8 gegen eine eigene Wehr, nicht gegen + # den Beispielbestand. Das Skript legt beide an. + - name: Testwehr anlegen + run: npm run db:test-reset + + - run: npm run build + - run: npx playwright test + env: + GOTENBERG_URL: http://localhost:3001 + + - name: Fehlerbilder sichern + if: failure() + uses: actions/upload-artifact@v4 + with: + name: playwright-bericht + path: | + playwright-report/ + test-results/ + retention-days: 7 + + - name: Aufraeumen + if: always() + run: docker rm -f postgres redis minio gotenberg || true diff --git a/.gitignore b/.gitignore index f6f37e1..656c995 100644 --- a/.gitignore +++ b/.gitignore @@ -40,3 +40,6 @@ debug.log # Prisma prisma/*.db prisma/*.db-journal + +# Sicherungen bleiben lokal — sie enthalten den gesamten Bestand. +sicherungen/ diff --git a/CHANGELOG.md b/CHANGELOG.md index fd467db..293204a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,100 @@ Alle nennenswerten Änderungen an FLORIAN. Format nach Die Regeln dazu stehen in [docs/VERSIONIERUNG.md](docs/VERSIONIERUNG.md). +## [0.12.0] — 2026-09-05 + +Phase 11: Freigabereife. Die drei Software-Voraussetzungen für 1.0.0 — +Personenauskunft nach Art. 15 DSGVO, ein geprüftes Sicherungskonzept und eine +Pipeline, die die Tests von allein ausführt. + +**Das Auskunftsrecht war ein Versprechen ohne Umsetzung.** `person.export` +stand seit Phase 0 im Berechtigungskatalog und war dem Kommandanten +zugewiesen; implementiert war nichts. Die Löschseite gab es (Anonymisierung im +Aufbewahrungslauf), die Auskunftsseite nicht — und ohne Art. 15 ist ein +Löschkonzept nicht abnehmbar. + +**Eine Sicherung, die nie zurückgespielt wurde, ist eine Vermutung.** Genau +das hat sich hier bestätigt: der erste Rückspielversuch lief scheinbar +sauber durch und hatte dabei sämtliche Unveränderlichkeitsgarantien der +Datenbank entfernt. + +### Hinzugefügt + +**Auskunft nach Art. 15 DSGVO** + +- `lib/dsgvo/auskunft.ts` sammelt alles, was über eine Person vorliegt: + Stammdaten, Zugang, Rollen, Qualifikationen, arbeitsmedizinische Vorsorge, + Belastungsübungen, Einsatz- und Übungsbeteiligung, Anwesenheiten, Fahrten, + Beiträge, Benachrichtigungswege, Ausweise. Dreizehn Abschnitte. +- **Über jedem Abschnitt steht, wozu die Daten vorliegen.** Art. 15 Abs. 1 + verlangt nicht nur die Daten, sondern auch Zwecke, Empfänger und den Hinweis + auf die weiteren Rechte. Eine Liste ohne das sieht aus wie eine Auskunft und + ist keine. +- **Gesundheitsdaten sind als besondere Kategorie nach Art. 9 gekennzeichnet** — + G26 und Belastungsübungen. +- **Leere Abschnitte bleiben stehen.** „Dazu liegt nichts vor" ist eine + Auskunft; ein weggelassener Abschnitt wirft die Frage auf, ob da doch etwas + ist. +- **Was die Person getan hat, wird gezählt, nicht mitgeliefert.** Prüfprotokolle + und Mängelmeldungen gehören zum Nachweisbestand der Wehr. Vollständig + ausgeliefert käme jeder über eine Selbstauskunft an die Geräteakten — die + Auskunftspflicht wäre eine Hintertür. +- Ein Durchgang liefert alles: die Anzeige, ein PDF für die Akte und JSON nach + Art. 20. Bewusst nicht drei Knöpfe, von denen zwei vergessen werden können. +- Der Vorgang wird protokolliert. Dass eine Auskunft erteilt wurde, gehört + festgehalten — sie legt alles offen, was über einen Menschen gespeichert ist. + +**Sicherung und Wiederherstellung** + +- `npm run sicherung` — Datenbank über `pg_dump` im Container, dazu alle + Dateien aus MinIO, dazu ein Manifest. Beides aus demselben Moment: eine + Datenbank von heute mit Dateien von gestern ergibt Verweise ins Leere. +- `npm run wiederherstellen` — mit Probelauf als Vorgabe. Ohne `--wirklich` + wird nur angezeigt, was geschähe. +- Nach dem Zurückspielen setzt das Skript die Sperren der Anwendungsrolle neu + und **prüft danach nach**, ob es gewirkt hat. +- `scripts/sicherung-sperren.ts` ist die eine Liste, aus der Wiederherstellen + und Prüfen entstehen. Ein Test gleicht sie gegen die migrierte Datenbank ab — + läuft sie auseinander, wäre der Schaden lautlos. + +**Pipeline** + +- `.gitea/workflows/pruefung.yml` mit zwei Aufträgen: `pruefung` (typecheck, + lint, Migrationen, Seed, 599 Unit-Tests) bei jedem Push, `oberflaeche` + (E2E) auf `main` und in Pull Requests. +- Die Datenbank wird aus `docker/postgres` gebaut statt aus einem fertigen + Image: FLORIAN braucht PostGIS **und** pgvector. Ein Dienst-Container mit + `postgres:17` ließe die Migrationen durch und scheiterte erst bei der ersten + Umkreissuche. +- Der Seed läuft mit, weil er selbst ein Test ist. + +### Behoben + +- **Eine Wiederherstellung entfernte alle Unveränderlichkeitsgarantien.** Der + Dump lief mit `--no-acl`, die Rechte kamen also nicht zurück; und selbst + ohne dieses Flag greift beim Zurückspielen die Vorgabeprivilegierung aus + `docker/postgres/10-init-app-role.sh` — jede von `pg_restore` neu angelegte + Tabelle bekommt automatisch alle Rechte, und `GRANT` kann nur hinzufügen, + nie wegnehmen. Nach einer Wiederherstellung durfte die Anwendungsrolle das + Audit-Log ändern, Wiki-Fassungen löschen und Kiosk-Vorgänge umschreiben. + Nichts hätte darauf hingewiesen. + + Gefunden haben es vier Datenbanktests unmittelbar nach der ersten + Rückspielprobe — also genau die Probe, wegen der 1.0.0 sie verlangt. +- **Ein E2E-Test klickte, bevor die Seite lebendig war.** Unter Last dauert das + Hydrieren länger; das Formular verschickte sich dann selbst, und die + Fehlerfläche, auf die der Test wartete, entstand nie — obwohl der Server + richtig ablehnte. Er wartet jetzt auf einen client-seitig erzeugten Knopf. + +### Nachweise + +- Sicherung und Wiederherstellung einmal vollständig durchgeführt: ein nach + der Sicherung angelegter Datensatz war danach weg, alle Zahlen stimmten + wieder, und **599 Tests liefen nach dem Zurückspielen grün** — einschließlich + derer, die die Sperren prüfen. + +--- + ## [0.11.0] — 2026-09-05 Phase 10: Mandantenfähigkeit. Ein Konto, mehrere Wehren, eine Kreisebene, die diff --git a/README.md b/README.md index b8a8d20..6516302 100644 --- a/README.md +++ b/README.md @@ -34,6 +34,7 @@ Anmelden mit `SEED_ADMIN_EMAIL` und `SEED_ADMIN_PASSWORT` aus der `.env` | Phase 8 — Lokale KI | fertig | | Phase 9 — Kassenverwaltung | fertig | | Phase 10 — Mandantenfähigkeit | fertig | +| Phase 11 — Freigabereife | fertig | Vollständige Planung mit Aufwänden: [docs/ROADMAP.md](docs/ROADMAP.md) und [docs/AUFWANDSSCHAETZUNG.md](docs/AUFWANDSSCHAETZUNG.md). @@ -1447,9 +1448,140 @@ weitergegeben. FLORIAN verschickt keine. --- +## Phase 11 — Freigabereife + +Die drei Dinge, die zwischen dem letzten Modul und dem Echtbetrieb standen: +das Auskunftsrecht, ein geprüftes Sicherungskonzept und eine Pipeline. + +### Auskunft nach Art. 15 DSGVO + +Jede Person darf verlangen zu erfahren, welche Daten über sie verarbeitet +werden. In einer Feuerwehr ist das mehr, als die meisten erwarten — und mit +den G26-Untersuchungen sind Gesundheitsdaten dabei, also eine besondere +Kategorie nach Art. 9. + +Auf der Seite eines Mitglieds steht dafür der Knopf **Datenauskunft** +(Recht `person.export`). Ein Durchgang liefert drei Dinge: + +| | Wofür | +|---|---| +| **Anzeige** | Was ausgehändigt wird, vor dem Aushändigen | +| **PDF** | Das Schreiben an die Person, für die Akte | +| **JSON** | Datenübertragbarkeit nach Art. 20 | + +Bewusst nicht drei Knöpfe: wer eine Auskunft erteilt, soll sie vollständig +erteilen und nicht in Schritten, von denen zwei vergessen werden können. + +**Über jedem Abschnitt steht, wozu die Daten vorliegen.** Art. 15 Abs. 1 +verlangt nicht nur die Daten, sondern auch Zwecke, Empfänger, Speicherdauer +und den Hinweis auf die weiteren Rechte. Eine Liste ohne das sieht aus wie +eine Auskunft und ist keine. + +Zwei Festlegungen bestimmen den Zuschnitt: + +**Was über die Person gespeichert ist, steht vollständig drin** — auch das +Unangenehme: eine nicht bestandene G26, ein gemahnter Beitrag. Eine Auskunft, +die auswählt, was gut aussieht, ist keine. + +**Was die Person getan hat, steht als Zahl drin.** Wer zwölf Prüfprotokolle +unterschrieben hat, bekommt die Zahl — nicht zwölf Protokolle. Die gehören zum +Nachweisbestand der Wehr; sie mitzuliefern hieße, dass jeder über eine +Selbstauskunft an die Geräteakten käme. Die Trennlinie ist nicht „viel oder +wenig", sondern „über mich" gegen „von mir". + +Leere Abschnitte bleiben stehen. „Dazu liegt nichts vor" ist eine Auskunft; +ein weggelassener Abschnitt wirft die Frage auf, ob da doch etwas ist. + +Der Vorgang wird protokolliert — dass eine Auskunft erteilt wurde, gehört +festgehalten. + +### Sicherung + +```bash +npm run sicherung # nach ./sicherungen// +npm run sicherung -- --ziel D:\backups +``` + +Gesichert wird beides, aus **demselben Moment**: die Datenbank über `pg_dump` +im Container und alle Dateien aus MinIO. Eine Datenbank von heute mit Dateien +von gestern ergibt Datensätze, die auf nichts zeigen. + +Daneben entsteht ein `manifest.json` mit Zeitpunkt, Version und Umfang. Es +wird beim Zurückspielen angezeigt, damit niemand versehentlich eine Sicherung +von vor drei Monaten einspielt, weil das Verzeichnis ähnlich hieß. + +### Wiederherstellung + +```bash +npm run wiederherstellen -- --quelle sicherungen/2026-09-05T14-16-48 +npm run wiederherstellen -- --quelle ... --wirklich +``` + +Ohne `--wirklich` passiert nichts — es wird nur angezeigt, was geschähe. Das +ist keine Bequemlichkeit: das Skript überschreibt den gesamten Bestand. + +**Der eigentliche Zweck ist nicht der Notfall, sondern die Probe.** Eine +Sicherung, die nie zurückgespielt wurde, ist eine Vermutung: der Ordner kann +leer sein, der Dump abgeschnitten, das Passwort falsch. Man erfährt es an dem +Tag, an dem man es nicht erfahren will. + +Wie berechtigt das ist, hat sich hier gezeigt. Der erste Rückspielversuch lief +sauber durch — und hatte dabei **sämtliche Unveränderlichkeitsgarantien der +Datenbank entfernt**. Der Grund liegt tiefer, als man vermutet: +`docker/postgres/10-init-app-role.sh` richtet eine Vorgabeprivilegierung ein, +damit neue Tabellen aus Migrationen automatisch nutzbar sind. `pg_restore` +legt die Tabellen aber ebenfalls neu an — die Vorgabe greift also auch dort, +und die `GRANT`-Zeilen aus dem Dump können nur hinzufügen, nie wegnehmen. Die +Anwendungsrolle durfte danach das Audit-Log ändern, Wiki-Fassungen löschen und +Kiosk-Vorgänge umschreiben. Nichts an der Anwendung hätte darauf hingewiesen. + +Deshalb setzt das Skript die Sperren nach dem Zurückspielen neu und **prüft +danach nach**, ob es gewirkt hat. Die Liste dafür steht in +`scripts/sicherung-sperren.ts`, und ein Test gleicht sie gegen die migrierte +Datenbank ab: läuft sie auseinander, käme eine Sperre nach einer +Wiederherstellung nicht zurück, von der niemand mehr weiß, dass es sie gab. + +Nach dem Zurückspielen bleiben drei Handgriffe, die keine Software abnehmen +kann: + +1. Anmelden — kommt jemand hinein? +2. Ein Prüfprotokoll öffnen — ist das PDF da? +3. Die Mitgliederzahl mit der Erwartung vergleichen. + +Erst danach gilt die Sicherung als geprüft. + +### Die Pipeline + +`.gitea/workflows/pruefung.yml`, zwei Aufträge: + +| Auftrag | Was | Wann | +|---|---|---| +| `pruefung` | typecheck, lint, Migrationen, Seed, 599 Unit-Tests | jeder Push und Pull Request | +| `oberflaeche` | die E2E-Tests über Chromium | nur `main` und Pull Requests | + +Die Trennung ist eine Zeitfrage: der erste Auftrag dauert Minuten, der zweite +eine Dreiviertelstunde. + +Die Datenbank wird aus `docker/postgres` **gebaut** statt aus einem fertigen +Image genommen. FLORIAN braucht PostGIS und pgvector, und kein öffentliches +Image bringt beides mit; ein Dienst-Container mit `postgres:17` ließe die +Migrationen durchlaufen und scheiterte erst bei der ersten Umkreissuche. Der +Seed läuft mit, weil er selbst ein Test ist: fällt ein Standardwert um, merkt +man es hier und nicht, wenn eine Wehr ihn zum ersten Mal ausführt. + +> **Voraussetzung: ein registrierter Runner.** Die Datei allein genügt nicht — +> Gitea braucht einen `act_runner`, der die Aufträge annimmt. Ist keiner +> eingerichtet, erscheinen die Läufe als „wartend" und laufen nie. Zum +> Zeitpunkt dieser Phase war noch keiner registriert; der Ablauf ist also +> geschrieben, aber nicht im Betrieb erprobt. + +--- + ## Befehle ```bash +npm run sicherung # Datenbank und Dateien sichern +npm run wiederherstellen # zurueckspielen (Probelauf ohne --wirklich) npm run dev # Entwicklungsserver auf Port 3100 npm run build # Produktionsbuild npm run worker:dev # Hintergrundprozess mit Neuladen diff --git a/docs/ANLEITUNG.md b/docs/ANLEITUNG.md index a2b1796..cb90676 100644 --- a/docs/ANLEITUNG.md +++ b/docs/ANLEITUNG.md @@ -1,6 +1,6 @@ # Anleitung -Bedienung von FLORIAN, Stand Phase 10 (Version 0.11.0). Die Bilder zeigen die +Bedienung von FLORIAN, Stand Phase 11 (Version 0.12.0). Die Bilder zeigen die Beispielwehr aus dem Seed und werden mit `npm run screenshots` neu erzeugt. --- @@ -2253,7 +2253,124 @@ jeweiligen Detailseite sichtbar. --- -## 61. Wenn etwas nicht geht +## 61. Wenn jemand Auskunft über seine Daten verlangt + +Jede Person darf verlangen zu erfahren, welche Daten über sie verarbeitet +werden — Art. 15 DSGVO. Die Anfrage muss **innerhalb eines Monats** beantwortet +werden, und sie ist kostenlos. + +### So geht es + +1. **Mitglieder** öffnen, die Person anklicken. +2. Rechts oben auf **Datenauskunft**. +3. **Auskunft erstellen**. + +Danach stehen drei Dinge bereit: + +| | Wofür | +|---|---| +| **Die Anzeige** | Was ausgehändigt wird — vor dem Aushändigen | +| **PDF** | Das Schreiben an die Person. Ausdrucken oder als Datei geben | +| **JSON** | Nur, wenn die Person die Daten maschinenlesbar verlangt (Art. 20) | + +Das Recht dazu heißt `person.export` und liegt bei Kommandant und +Administrator. + +### Was drinsteht + +Dreizehn Abschnitte: Stammdaten, Zugang, Rollen, Qualifikationen, +arbeitsmedizinische Vorsorge, Belastungsübungen, Einsatz- und +Übungsbeteiligung, Anwesenheiten, Fahrten, Beiträge, Benachrichtigungswege, +Ausweise. + +Über jedem steht, **wozu** diese Daten vorliegen. Das ist kein Beiwerk: Art. 15 +verlangt neben den Daten auch die Zwecke und den Hinweis auf die weiteren +Rechte. Eine bloße Liste erfüllt die Auskunftspflicht nicht. + +**Auch das Unangenehme steht drin** — ein gemahnter Beitrag, eine nicht +bestandene G26. Eine Auskunft, die auswählt, was gut aussieht, ist keine. + +**Leere Abschnitte bleiben stehen.** „Dazu liegen keine Daten vor" ist eine +Auskunft. + +**Gesundheitsdaten sind gekennzeichnet.** G26 und Belastungsübungen sind eine +besondere Kategorie nach Art. 9 — das steht im Bericht ausdrücklich dabei. + +### Was nicht drinsteht + +Was die Person **getan** hat, erscheint nur als Zahl: „12 durchgeführte +Prüfungen", „3 gemeldete Mängel". + +Das ist Absicht. Die Prüfprotokolle selbst gehören zum Nachweisbestand der +Feuerwehr und nicht zu den personenbezogenen Daten eines Mitglieds. Würden sie +mitgeliefert, käme jeder über eine Selbstauskunft an die kompletten +Geräteakten — die Auskunftspflicht wäre eine Hintertür. + +### Der Vorgang wird protokolliert + +Dass eine Auskunft erteilt wurde, steht danach unter **Protokoll**. Das ist +richtig so: die Auskunft legt alles offen, was über einen Menschen gespeichert +ist, einschließlich seiner Gesundheitsdaten. Wer sie erstellt hat und wann, +gehört festgehalten. + +### Wenn die Person in zwei Wehren geführt wird + +Dann gibt es **zwei** Auskünfte, eine je Wehr. Das ist kein Versehen: jede Wehr +ist eine eigene Verantwortliche im Sinne der Verordnung und kann nur über die +Daten Auskunft geben, die sie selbst führt. + +--- + +## 62. Sicherung und Wiederherstellung + +> **Der wichtigste Satz dieses Kapitels:** Eine Sicherung, die nie +> zurückgespielt wurde, ist eine Vermutung. + +### Sichern + +```bash +npm run sicherung +npm run sicherung -- --ziel D:\backups +``` + +Gesichert wird die Datenbank **und** der Dateispeicher — Prüfprotokolle, +Einsatzberichte, Fotos, Rechnungen. Beides aus demselben Moment: eine +Datenbank von heute mit Dateien von gestern ergibt Einträge, die auf nichts +zeigen. + +Das Ergebnis ist ein Verzeichnis mit Zeitstempel. Es gehört auf ein anderes +Gerät — eine Sicherung auf derselben Festplatte hilft gegen einen +Festplattenfehler nicht. + +### Zurückspielen + +```bash +npm run wiederherstellen -- --quelle sicherungen/2026-09-05T14-16-48 +``` + +So passiert **nichts**. Es wird nur angezeigt, aus welchem Moment die +Sicherung stammt und wie groß sie ist. Erst mit `--wirklich` am Ende wird +zurückgespielt — und dabei **der gesamte vorhandene Bestand überschrieben**. + +### Einmal im Jahr proben + +Nicht im Notfall zum ersten Mal. Der Ordner kann leer sein, der Auszug +abgeschnitten, das Passwort falsch — man erfährt es sonst an dem Tag, an dem +man es nicht erfahren will. + +Nach dem Zurückspielen prüft das Skript selbst nach, ob die Schutzsperren der +Datenbank wieder stehen. Drei Handgriffe bleiben trotzdem: + +1. **Anmelden** — kommt jemand hinein? +2. **Ein Prüfprotokoll öffnen** — ist das PDF da? +3. **Die Mitgliederzahl** mit der Erwartung vergleichen. + +Erst danach gilt die Sicherung als geprüft. Das Datum der letzten geglückten +Probe gehört ins Sicherungskonzept. + +--- + +## 63. Wenn etwas nicht geht | Beobachtung | Wahrscheinliche Ursache | Was hilft | |---|---|---| diff --git a/docs/AUFWANDSSCHAETZUNG.md b/docs/AUFWANDSSCHAETZUNG.md index 500f821..0337d9e 100644 --- a/docs/AUFWANDSSCHAETZUNG.md +++ b/docs/AUFWANDSSCHAETZUNG.md @@ -41,7 +41,8 @@ Faktoren, die in die Schätzung eingehen: | 8 | Lokale KI | 9–12 PT | 2,5–3 h | **3:40 h** | | 9 | Kassenverwaltung | 8–11 PT | 2–3 h | **4:10 h** | | 10 | Mandantenfähigkeit | 4–6 PT | 1–1,5 h | **4:20 h** | -| | **Summe** | **127–170 PT** | **29–38,5 h** | **32:50 h** | +| 11 | Freigabereife | 5–7 PT | 1,5–2 h | **2:40 h** | +| | **Summe** | **132–177 PT** | **30,5–40,5 h** | **35:30 h** | 123–164 Personentage entsprechen etwa **6 bis 8 Monaten Vollzeitarbeit** einer einzelnen Person. Zum Vergleich: eine kommerzielle Feuerwehrverwaltung kostet diff --git a/docs/DEVLOG.md b/docs/DEVLOG.md index b7a69cc..5831627 100644 --- a/docs/DEVLOG.md +++ b/docs/DEVLOG.md @@ -23,7 +23,87 @@ Die aufgewendete Zeit wandert nach jedem Abschnitt in die Spalte | Aufräumarbeiten (Issues #10–#12) | 1:20 h | 1 | 04.09.2026 | | Phase 9 — Kassenverwaltung | 4:10 h | 1 | 04.09.2026 | | Phase 10 — Mandantenfähigkeit | 4:20 h | 1 | 05.09.2026 | -| **Summe** | **32:50 h** | **13** | | +| Phase 11 — Freigabereife | 2:40 h | 1 | 05.09.2026 | +| **Summe** | **35:30 h** | **14** | | + +--- + +## 2026-09-05 · Phase 11 — Freigabereife + +**14:30 – 17:10 · 2:40 h · Version 0.12.0** + +### Was entstanden ist + +Kein Modul, sondern die drei Software-Voraussetzungen für 1.0.0. + +| Bereich | Umfang | +|---|---| +| Auskunft | `lib/dsgvo/auskunft.ts` und `bericht.ts`, `person.auskunft`, Dialog auf der Mitgliederseite | +| Sicherung | `scripts/sicherung.ts`, `wiederherstellen.ts`, `sicherung-zugang.ts`, `sicherung-sperren.ts` | +| Pipeline | `.gitea/workflows/pruefung.yml` mit zwei Aufträgen | +| Tests | 18 neue: 9 gegen die Auskunft, 11 gegen die Sicherung | + +### Entscheidungen + +**Die Auskunft trennt „über mich" von „von mir".** Was über die Person +gespeichert ist, steht vollständig drin — auch der gemahnte Beitrag und die +nicht bestandene G26. Was die Person *getan* hat, steht als Zahl drin. Die +zwölf Prüfprotokolle, die sie unterschrieben hat, gehören zum Nachweisbestand +der Wehr; vollständig ausgeliefert käme jeder über eine Selbstauskunft an die +Geräteakten, und die Auskunftspflicht wäre eine Hintertür. + +**Über jedem Abschnitt steht der Zweck.** Art. 15 Abs. 1 verlangt nicht nur +die Daten, sondern auch Zwecke, Empfänger und den Hinweis auf die weiteren +Rechte. Eine Liste ohne das sieht aus wie eine Auskunft und ist keine — das ist +der Unterschied zwischen einem Datenbankexport und einem Schreiben an einen +Menschen. + +**Eine Mutation, keine Abfrage.** Das Erstellen der Auskunft *ist* die +Offenlegung, und die gehört ins Protokoll. Als Abfrage würde sie +zwischengespeichert und mehrfach ausgeführt — sie wäre entweder nicht oder +zwanzigmal vermerkt. + +**Sicherung und Wiederherstellung gehören zusammen.** Deshalb sind es zwei +Skripte in einem Zug und nicht eines. Der Probelauf ist die Vorgabe; ohne +`--wirklich` geschieht nichts. + +**Eine Liste, zwei Verwendungen.** `sicherung-sperren.ts` treibt sowohl das +Wiederherstellen der Sperren als auch deren Prüfung. Zwei Listen wären zwei +Wahrheiten, und die falsche fiele erst im Notfall auf. + +**Die Pipeline baut das eigene Postgres-Image.** Ein Dienst-Container mit +`postgres:17` ließe die Migrationen durchlaufen und scheiterte erst bei der +ersten Umkreissuche — die Pipeline prüfte dann etwas anderes als den Betrieb. + +### Wo es gehakt hat + +| Problem | Ursache | Lösung | +|---|---|---| +| **Eine Wiederherstellung entfernte alle Unveränderlichkeitsgarantien** | Zuerst `--no-acl` im Dump. Und selbst ohne das Flag greift beim Zurückspielen die Vorgabeprivilegierung aus dem Init-Skript: jede von `pg_restore` neu angelegte Tabelle bekommt alle Rechte, und `GRANT` kann nur hinzufügen | Das Skript setzt die Sperren nach dem Zurückspielen neu und prüft danach nach. Gefunden haben es vier Datenbanktests direkt nach der ersten Rückspielprobe | +| `pg_dump` lehnte die Verbindungszeichenkette ab | Prisma hängt `?schema=public` an — „invalid URI query parameter" | Die URL wird zerlegt, Benutzer und Datenbank gehen als Flags, das Passwort über `PGPASSWORD` | +| Feldnamen im Auskunftssammler stimmten reihenweise nicht | Aus dem Gedächtnis geschrieben statt aus dem Schema gelesen: `alarmierung` statt `alarmiertUm`, `beginn` statt `beginnUm`, `gemeldetVonId` statt `melderId` | Der Typechecker hat sie alle einzeln benannt — zehn Durchgänge, jeder eine Zeile | +| Ein eigener Test war falsch, nicht der Code | `new URL("localhost:5432")` gelingt in Node: „localhost:" gilt als Protokoll | Die Zusicherung prüft jetzt, was wirklich greift — die Protokollprüfung eine Zeile später | +| Ein Vorlagentest fiel im vollen Lauf aus | Er klickte auf „Speichern“, bevor React lief — dann verschickt das Formular sich selbst, und die Fehlerfläche entsteht nie. Unter Last dauert das Hydrieren länger. Dasselbe Muster wie beim Anmeldeskript in Phase 8 | Er wartet jetzt auf einen client-seitig erzeugten Knopf, bevor er klickt | + +### Abweichung von der Vorgabe + +**Die Pipeline ist nicht erprobt.** Sie ist geschrieben und syntaktisch +geprüft, aber in Gitea war zum Zeitpunkt dieser Phase kein `act_runner` +registriert. Ohne Runner erscheinen die Läufe als „wartend" und laufen nie. +Ob der Ablauf durchläuft, zeigt sich erst, wenn einer eingerichtet ist — das +steht so auch im README, damit niemand sich auf eine Prüfung verlässt, die +nicht stattfindet. + +### Nachweise + +- `npm run typecheck` — ohne Ausgabe +- `npm run lint` — ohne Befund +- `npm run test` — 599 Tests grün, davon 18 neu +- Auskunft von Hand erzeugt: 13 Abschnitte, 28 Angaben, 58 kB PDF +- **Sicherung und Wiederherstellung vollständig durchgeführt**: ein nach der + Sicherung angelegter Datensatz war danach weg, alle Zahlen stimmten wieder, + und die 599 Tests liefen nach dem Zurückspielen grün — einschließlich derer, + die die Sperren prüfen --- diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 3d7f85b..e7c8fe2 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -20,6 +20,7 @@ Phase 7 ████████████████████ fertig Phase 8 ████████████████████ fertig 0.9.0 Phase 9 ████████████████████ fertig 0.10.0 Phase 10 ████████████████████ fertig 0.11.0 +Phase 11 ████████████████████ fertig 0.12.0 Freigabe ░░░░░░░░░░░░░░░░░░░░ offen 1.0.0 ``` @@ -290,15 +291,34 @@ sie schlimmer als keine. Die Aufsicht sieht jede Wehr einzeln. jemand über die Grenze sieht — und die Kreisebene kann hineinschauen, ohne etwas anfassen zu können. +## Phase 11 — Freigabereife · fertig + +Am 05.09.2026 umgesetzt. Kein Modul, sondern die drei Software-Voraussetzungen +für die Freigabe. + +- **Auskunft nach Art. 15 DSGVO.** Das Recht `person.export` stand seit Phase 0 + im Katalog und war zugewiesen — umgesetzt war nichts. Ohne Auskunftsseite ist + ein Löschkonzept nicht abnehmbar. +- **Sicherung und Wiederherstellung** mit einer durchgeführten Rückspielprobe. +- **Pipeline** in Gitea Actions: typecheck, lint, Migrationen, Seed und Tests. + +Die Rückspielprobe hat sich sofort bezahlt gemacht: der erste Versuch lief +sauber durch und hatte dabei sämtliche Unveränderlichkeitsgarantien der +Datenbank entfernt. Gefunden haben es die Datenbanktests unmittelbar danach — +also genau die Probe, wegen der 1.0.0 sie verlangt. + +**Die Wehr kann jetzt:** eine Selbstauskunft erteilen, ihren Bestand sichern +und ihn nachweislich zurückholen. + ## 1.0.0 — Freigabe Kein neues Modul, sondern eine Entscheidung: die Wehr setzt FLORIAN im Echtbetrieb ein. Voraussetzungen: - Datenübernahme aus der bisherigen Verwaltung abgeschlossen und abgeglichen -- Sicherungskonzept steht und ist einmal wiederhergestellt worden +- ~~Sicherungskonzept steht und ist einmal wiederhergestellt worden~~ — mit Phase 11 erledigt - Schulung von Kommandant, Gerätewart und Atemschutzgerätewart erfolgt -- Löschkonzept nach DSGVO abgenommen +- Löschkonzept nach DSGVO abgenommen (die Auskunftsseite dazu gibt es seit Phase 11) - Der Hinweis zur analogen Atemschutzüberwachung wird bewusst und dokumentiert abgeschaltet — oder bewusst stehen gelassen diff --git a/package.json b/package.json index 86c7f0b..956a2f1 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "florian", - "version": "0.11.0", + "version": "0.12.0", "private": true, "description": "FLORIAN — Feuerwehr-Verwaltungsplattform", "engines": { @@ -23,6 +23,8 @@ "db:migrate:dev": "prisma migrate dev", "db:push": "prisma db push", "db:seed": "tsx prisma/seed.ts", + "sicherung": "tsx scripts/sicherung.ts", + "wiederherstellen": "tsx scripts/wiederherstellen.ts", "db:test-reset": "tsx scripts/testdaten.ts", "db:studio": "prisma studio", "screenshots": "tsx scripts/screenshots.ts", diff --git a/scripts/sicherung-sperren.ts b/scripts/sicherung-sperren.ts new file mode 100644 index 0000000..888115e --- /dev/null +++ b/scripts/sicherung-sperren.ts @@ -0,0 +1,94 @@ +/** + * Die Sperren der Anwendungsrolle — nach einer Wiederherstellung. + * + * Hintergrund, und er ist unerwartet: + * + * Beim ersten Start richtet `docker/postgres/10-init-app-role.sh` eine + * Vorgabeprivilegierung ein — jede künftig angelegte Tabelle bekommt + * automatisch alle Rechte für `florian_app`. Das ist im Betrieb genau richtig: + * eine neue Migration soll nicht daran scheitern, dass jemand vergessen hat, + * Rechte zu vergeben. + * + * `pg_restore` legt die Tabellen aber ebenfalls neu an. Die Vorgabe greift + * also auch dort — und die `GRANT`-Anweisungen aus dem Dump können nur + * hinzufügen, nie wegnehmen. Nach einer Wiederherstellung stünde die + * Anwendungsrolle damit wieder mit allen Rechten da: sie dürfte das Audit-Log + * ändern, Wiki-Fassungen löschen, Kiosk-Vorgänge umschreiben. + * + * Nichts an der Anwendung würde darauf hinweisen. Die Unveränderlichkeit, auf + * der jeder Nachweis gegenüber der Unfallkasse beruht, wäre stillschweigend + * weg — bis jemand danach fragt. + * + * Gefunden haben das die Datenbanktests nach der ersten Rückspielprobe. + * Deshalb gibt es diese Datei: **eine** Liste, aus der sowohl das + * Wiederherstellen als auch das Prüfen der Sperren entsteht. Zwei Listen + * wären zwei Wahrheiten. + */ + +/** Tabelle → Rechte, die `florian_app` NICHT haben darf, mit Begründung. */ +export const SPERREN: Array<{ tabelle: string; rechte: string[]; grund: string }> = [ + { + tabelle: "berechtigung", + rechte: ["INSERT", "UPDATE", "DELETE"], + grund: "systemweiter Rechtekatalog — nur lesbar", + }, + { + tabelle: "audit_log", + rechte: ["UPDATE", "DELETE"], + grund: "append-only; das Löschen geht nur im Aufbewahrungslauf", + }, + { + tabelle: "kiosk_vorgang", + rechte: ["UPDATE", "DELETE"], + grund: "übermittelte Terminalvorgänge sind unveränderlich", + }, + { + tabelle: "druckmessung", + rechte: ["UPDATE", "DELETE"], + grund: "Atemschutzüberwachung — Druckwerte werden nie korrigiert", + }, + { + tabelle: "atemschutz_ereignis", + rechte: ["UPDATE", "DELETE"], + grund: "wie oben: der Verlauf einer Überwachung bleibt, wie er war", + }, + { + tabelle: "benachrichtigung", + rechte: ["DELETE"], + grund: "was zugestellt wurde, bleibt nachweisbar", + }, + { + tabelle: "ki_vorgang", + rechte: ["DELETE"], + grund: "das KI-Protokoll belegt, was die Maschine getan hat", + }, + { + tabelle: "wikiversion", + rechte: ["UPDATE", "DELETE"], + grund: "jede Fassung einer Wiki-Seite bleibt erhalten", + }, + { + tabelle: "kassenabschluss", + rechte: ["DELETE"], + grund: "ein festgeschriebener Abschluss lässt sich nicht wegräumen", + }, + { + tabelle: "benutzer", + rechte: ["INSERT", "DELETE"], + grund: "Konten entstehen und vergehen über den Systemzugang", + }, +]; + +/** Die SQL-Anweisungen, die die Sperren wiederherstellen. */ +export function sperrenSql(): string[] { + return SPERREN.map( + ({ tabelle, rechte }) => `REVOKE ${rechte.join(", ")} ON "${tabelle}" FROM florian_app`, + ); +} + +/** Alle Kombinationen, die nach der Wiederherstellung fehlen müssen. */ +export function verboteneRechte(): Array<{ tabelle: string; recht: string }> { + return SPERREN.flatMap(({ tabelle, rechte }) => + rechte.map((recht) => ({ tabelle, recht })), + ); +} diff --git a/scripts/sicherung-zugang.ts b/scripts/sicherung-zugang.ts new file mode 100644 index 0000000..2821405 --- /dev/null +++ b/scripts/sicherung-zugang.ts @@ -0,0 +1,51 @@ +/** + * Die Verbindungsangaben für `pg_dump` und `pg_restore`. + * + * Eigene Datei, weil beide Skripte sie brauchen — und weil ein falsch + * zerlegter Zugang der unangenehmste Fehler in einem Sicherungsskript wäre: + * es liefe durch, schriebe eine Datei, und die enthielte die falsche + * Datenbank. Auffallen würde es beim Zurückspielen, also genau dann, wenn man + * die Sicherung braucht. + */ + +export interface Zugang { + benutzer: string; + passwort: string; + datenbank: string; +} + +/** + * Zerlegt eine Verbindungszeichenkette in ihre Teile. + * + * Die URL lässt sich nicht als Ganzes an `pg_dump` durchreichen: Prisma hängt + * eigene Parameter an (`?schema=public`), die es mit "invalid URI query + * parameter" ablehnt. Und der Port darin gilt vom Rechner aus — im Container, + * wo die Werkzeuge laufen, liegt Postgres auf 5432. + */ +export function zugangAus(roh: string | undefined): Zugang { + if (!roh) { + throw new Error("DIRECT_DATABASE_URL fehlt."); + } + + let url: URL; + try { + url = new URL(roh); + } catch { + throw new Error(`Die Verbindungszeichenkette ist keine gültige URL: ${roh}`); + } + + if (!url.protocol.startsWith("postgres")) { + throw new Error(`Erwartet wird eine postgresql://-Adresse, bekommen: ${url.protocol}//`); + } + + const datenbank = url.pathname.replace(/^\//, ""); + if (!datenbank) { + throw new Error("In der Verbindungszeichenkette fehlt der Datenbankname."); + } + + return { + benutzer: decodeURIComponent(url.username), + passwort: decodeURIComponent(url.password), + datenbank, + }; +} diff --git a/scripts/sicherung.ts b/scripts/sicherung.ts new file mode 100644 index 0000000..8e41bed --- /dev/null +++ b/scripts/sicherung.ts @@ -0,0 +1,188 @@ +/** + * Sicherung: Datenbank und Dateien in ein Verzeichnis. + * + * npm run sicherung + * npm run sicherung -- --ziel D:\backups + * + * Eine Feuerwehrverwaltung ohne geprüfte Sicherung ist ein Aktenschrank ohne + * Schloss — bis zum ersten Festplattenfehler sieht man den Unterschied nicht. + * Deshalb gehört zu diesem Skript zwingend `npm run wiederherstellen`: eine + * Sicherung, die nie zurückgespielt wurde, ist eine Vermutung. + * + * Gesichert wird beides, und zwar zusammen: + * + * * **Die Datenbank** über `pg_dump` im Postgres-Container. Im eigenen + * Format (`--format=custom`), weil sich daraus einzelne Tabellen + * zurückholen lassen und es kleiner ist. + * * **Die Dateien** aus MinIO — Prüfprotokolle, Einsatzberichte, Fotos, + * Rechnungen. Ohne sie wäre die Datenbank voller Verweise ins Leere. + * + * Beides muss aus demselben Moment stammen. Eine Datenbank von heute mit + * Dateien von gestern ergibt Datensätze, die auf nichts zeigen. + */ +import "dotenv/config"; +import { createHash } from "node:crypto"; +import { spawnSync } from "node:child_process"; +import { mkdirSync, writeFileSync, createWriteStream } from "node:fs"; +import { join, dirname } from "node:path"; +import { pipeline } from "node:stream/promises"; +import { Client as MinioClient } from "minio"; +import { zugangAus } from "./sicherung-zugang"; + +const ZIEL_VORGABE = "sicherungen"; +const CONTAINER = process.env.SICHERUNG_POSTGRES_CONTAINER ?? "florian-postgres"; + +function log(nachricht: string): void { + console.info(`[sicherung] ${nachricht}`); +} + +/** Ein Zeitstempel, der sich als Verzeichnisname eignet. */ +function stempel(): string { + return new Date().toISOString().replace(/[:.]/g, "-").slice(0, 19); +} + +function argument(name: string): string | undefined { + const stelle = process.argv.indexOf(`--${name}`); + return stelle >= 0 ? process.argv[stelle + 1] : undefined; +} + +/** + * Die Datenbank sichern. + * + * Über `docker compose exec` statt über einen lokalen `pg_dump`: die Version + * des Clients muss zur Serverversion passen, und im Container ist sie das + * garantiert. Auf einem Rechner ohne passendes `pg_dump` schlüge es sonst mit + * einer Meldung fehl, die niemand mit der Ursache in Verbindung bringt. + */ +function datenbankSichern(ziel: string): { datei: string; bytes: number } { + const { benutzer, passwort, datenbank } = zugangAus( + process.env.DIRECT_DATABASE_URL ?? process.env.DATABASE_URL, + ); + + const datei = join(ziel, "datenbank.dump"); + log(`Datenbank aus Container "${CONTAINER}" ...`); + + const lauf = spawnSync( + "docker", + [ + "compose", + "exec", + "-T", + "postgres", + "pg_dump", + "--format=custom", + // Der Eigentuemer wird nicht mitgesichert (er heisst auf jedem Server + // anders), die RECHTE dagegen schon. Ohne sie waeren nach dem + // Zurueckspielen alle REVOKEs der Migrationen weg: die Anwendungsrolle + // duerfte das Audit-Log aendern, Wiki-Fassungen loeschen, + // Kiosk-Vorgaenge umschreiben. Nichts wuerde darauf hinweisen. + "--no-owner", + "--username", + benutzer, + "--dbname", + datenbank, + ], + { + encoding: "buffer", + maxBuffer: 2_000_000_000, + shell: true, + env: { ...process.env, PGPASSWORD: passwort }, + }, + ); + + if (lauf.status !== 0) { + const fehler = lauf.stderr?.toString() ?? "unbekannt"; + throw new Error(`pg_dump ist fehlgeschlagen:\n${fehler}`); + } + + writeFileSync(datei, lauf.stdout); + return { datei: "datenbank.dump", bytes: lauf.stdout.byteLength }; +} + +/** Alle Objekte aus MinIO holen. */ +async function dateienSichern(ziel: string): Promise<{ anzahl: number; bytes: number }> { + const bucket = process.env.S3_BUCKET ?? "florian"; + + const client = new MinioClient({ + endPoint: process.env.S3_ENDPOINT ?? "localhost", + port: Number(process.env.S3_PORT ?? 9000), + useSSL: (process.env.S3_USE_SSL ?? "false") === "true", + accessKey: process.env.S3_ACCESS_KEY ?? "", + secretKey: process.env.S3_SECRET_KEY ?? "", + }); + + if (!(await client.bucketExists(bucket))) { + log(`Bucket "${bucket}" gibt es nicht — keine Dateien zu sichern.`); + return { anzahl: 0, bytes: 0 }; + } + + const ordner = join(ziel, "dateien"); + mkdirSync(ordner, { recursive: true }); + + let anzahl = 0; + let bytes = 0; + + const strom = client.listObjectsV2(bucket, "", true); + + for await (const objekt of strom) { + if (!objekt.name) continue; + + const pfad = join(ordner, objekt.name); + mkdirSync(dirname(pfad), { recursive: true }); + + const quelle = await client.getObject(bucket, objekt.name); + await pipeline(quelle, createWriteStream(pfad)); + + anzahl += 1; + bytes += objekt.size ?? 0; + + if (anzahl % 50 === 0) log(` ${anzahl} Dateien ...`); + } + + return { anzahl, bytes }; +} + +async function main(): Promise { + const wurzel = argument("ziel") ?? ZIEL_VORGABE; + const ziel = join(wurzel, stempel()); + mkdirSync(ziel, { recursive: true }); + + log(`Ziel: ${ziel}`); + + const datenbank = datenbankSichern(ziel); + log(`Datenbank: ${(datenbank.bytes / 1024 / 1024).toFixed(1)} MB`); + + const dateien = await dateienSichern(ziel); + log(`Dateien: ${dateien.anzahl} Stück, ${(dateien.bytes / 1024 / 1024).toFixed(1)} MB`); + + /** + * Das Manifest. + * + * Es hält fest, woraus die Sicherung stammt und wie groß sie ist. Beim + * Zurückspielen wird es gelesen und angezeigt — damit niemand versehentlich + * eine Sicherung von vor drei Monaten einspielt, weil das Verzeichnis + * ähnlich hieß. + */ + const manifest = { + erstelltAm: new Date().toISOString(), + version: process.env.npm_package_version ?? "unbekannt", + datenbank, + dateien, + pruefsumme: createHash("sha256") + .update(`${datenbank.bytes}:${dateien.anzahl}:${dateien.bytes}`) + .digest("hex"), + }; + + writeFileSync(join(ziel, "manifest.json"), JSON.stringify(manifest, null, 2)); + + log(""); + log("Fertig. Zum Prüfen der Sicherung:"); + log(` npm run wiederherstellen -- --quelle ${ziel} --wirklich`); + log(""); + log("Eine Sicherung, die nie zurueckgespielt wurde, ist eine Vermutung."); +} + +main().catch((fehler) => { + console.error("[sicherung] Abbruch:", fehler); + process.exit(1); +}); diff --git a/scripts/wiederherstellen.ts b/scripts/wiederherstellen.ts new file mode 100644 index 0000000..6c2d37e --- /dev/null +++ b/scripts/wiederherstellen.ts @@ -0,0 +1,270 @@ +/** + * Wiederherstellung aus einer Sicherung. + * + * npm run wiederherstellen -- --quelle sicherungen/2026-09-05T14-00-00 + * npm run wiederherstellen -- --quelle ... --wirklich + * + * Ohne `--wirklich` wird nur angezeigt, was geschehen würde. Das ist keine + * Bequemlichkeit: dieses Skript **überschreibt den gesamten Bestand**, und es + * gibt keinen Weg zurück außer einer weiteren Sicherung. + * + * Der eigentliche Zweck ist nicht der Notfall, sondern die Probe. Eine + * Sicherung, die nie zurückgespielt wurde, ist eine Vermutung — der Ordner + * kann leer sein, der Dump abgeschnitten, das Passwort falsch. Man erfährt es + * an dem Tag, an dem man es nicht erfahren will. Deshalb gehört dieser Lauf + * einmal durchgeführt und im Sicherungskonzept vermerkt. + */ +import "dotenv/config"; +import { spawnSync } from "node:child_process"; +import { existsSync, readFileSync, readdirSync, statSync, createReadStream } from "node:fs"; +import { join, relative, sep } from "node:path"; +import { Client as MinioClient } from "minio"; +import { zugangAus } from "./sicherung-zugang"; +import { SPERREN, sperrenSql, verboteneRechte } from "./sicherung-sperren"; + +function log(nachricht: string): void { + console.info(`[wiederherstellen] ${nachricht}`); +} + +function argument(name: string): string | undefined { + const stelle = process.argv.indexOf(`--${name}`); + return stelle >= 0 ? process.argv[stelle + 1] : undefined; +} + +const wirklich = process.argv.includes("--wirklich"); + +/** Alle Dateien unterhalb eines Verzeichnisses, rekursiv. */ +function alleDateien(wurzel: string): string[] { + if (!existsSync(wurzel)) return []; + + const gefunden: string[] = []; + for (const eintrag of readdirSync(wurzel)) { + const pfad = join(wurzel, eintrag); + if (statSync(pfad).isDirectory()) gefunden.push(...alleDateien(pfad)); + else gefunden.push(pfad); + } + return gefunden; +} + +function datenbankZurueck(quelle: string): void { + const { benutzer, passwort, datenbank } = zugangAus( + process.env.DIRECT_DATABASE_URL ?? process.env.DATABASE_URL, + ); + + const dump = join(quelle, "datenbank.dump"); + if (!existsSync(dump)) throw new Error(`In ${quelle} liegt keine datenbank.dump.`); + + log("Spiele die Datenbank zurück ..."); + + /** + * `--clean --if-exists` räumt vorher auf. + * + * Ohne das Aufräumen schlüge jede `CREATE TABLE` fehl, und übrig bliebe ein + * Mischbestand aus alten und neuen Zeilen — das Schlimmste, was bei einer + * Wiederherstellung herauskommen kann, weil es auf den ersten Blick + * funktioniert. + */ + const lauf = spawnSync( + "docker", + [ + "compose", + "exec", + "-T", + "postgres", + "pg_restore", + "--clean", + "--if-exists", + "--no-owner", + "--username", + benutzer, + "--dbname", + datenbank, + ], + { + input: readFileSync(dump), + encoding: "buffer", + maxBuffer: 2_000_000_000, + shell: true, + env: { ...process.env, PGPASSWORD: passwort }, + }, + ); + + // pg_restore meldet auch dann einen Fehlercode, wenn nur einzelne DROP- + // Anweisungen ins Leere liefen. Das ist bei `--if-exists` normal, deshalb + // wird die Ausgabe angesehen statt nur der Rückgabewert. + const ausgabe = lauf.stderr?.toString() ?? ""; + const echteFehler = ausgabe + .split("\n") + .filter((zeile) => zeile.includes("error:") && !zeile.includes("does not exist")); + + if (echteFehler.length > 0) { + throw new Error(`pg_restore ist fehlgeschlagen:\n${echteFehler.join("\n")}`); + } + + log("Datenbank zurückgespielt."); +} + +async function dateienZurueck(quelle: string): Promise { + const bucket = process.env.S3_BUCKET ?? "florian"; + const ordner = join(quelle, "dateien"); + + const client = new MinioClient({ + endPoint: process.env.S3_ENDPOINT ?? "localhost", + port: Number(process.env.S3_PORT ?? 9000), + useSSL: (process.env.S3_USE_SSL ?? "false") === "true", + accessKey: process.env.S3_ACCESS_KEY ?? "", + secretKey: process.env.S3_SECRET_KEY ?? "", + }); + + if (!(await client.bucketExists(bucket))) { + await client.makeBucket(bucket); + log(`Bucket "${bucket}" angelegt.`); + } + + const dateien = alleDateien(ordner); + let hochgeladen = 0; + + for (const pfad of dateien) { + // Der Objektschlüssel ist der Pfad unterhalb von "dateien/" — mit + // Schrägstrichen, auch unter Windows. + const schluessel = relative(ordner, pfad).split(sep).join("/"); + + await client.putObject(bucket, schluessel, createReadStream(pfad), statSync(pfad).size); + hochgeladen += 1; + + if (hochgeladen % 50 === 0) log(` ${hochgeladen} Dateien ...`); + } + + return hochgeladen; +} + +/** + * Die Sperren wieder setzen — und danach nachsehen, ob es gewirkt hat. + * + * Warum das nötig ist, steht in `sicherung-sperren.ts`: die + * Vorgabeprivilegierung des Init-Skripts gibt jeder von `pg_restore` neu + * angelegten Tabelle wieder alle Rechte, und die `GRANT`-Zeilen aus dem Dump + * können nur hinzufügen, nie wegnehmen. + * + * Erst setzen, dann prüfen — und nicht nur prüfen. Ein Skript, das ein + * bekanntes Problem meldet, statt es zu beheben, verlagert die Arbeit auf den + * ungünstigsten Zeitpunkt: den Notfall, in dem jemand gerade eine Sicherung + * zurückspielt. + */ +async function sperrenSetzen(): Promise { + const { PrismaClient } = await import("@prisma/client"); + const prisma = new PrismaClient({ + datasources: { db: { url: process.env.DIRECT_DATABASE_URL ?? "" } }, + }); + + try { + const rolleDa = await prisma.$queryRawUnsafe>( + "SELECT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'florian_app') AS da", + ); + + if (!rolleDa[0]?.da) { + log("Die Rolle florian_app gibt es nicht — Sperren übersprungen."); + return; + } + + for (const anweisung of sperrenSql()) { + await prisma.$executeRawUnsafe(anweisung); + } + log(`Sperren gesetzt: ${SPERREN.length} Tabellen.`); + + // Gegenprobe. Sie fragt die Datenbank, nicht das eigene Vorhaben ab. + const zeilen = await prisma.$queryRawUnsafe< + Array<{ table_name: string; privilege_type: string }> + >( + `SELECT table_name, privilege_type + FROM information_schema.role_table_grants + WHERE grantee = 'florian_app'`, + ); + + const vorhanden = new Set(zeilen.map((z) => `${z.table_name}:${z.privilege_type}`)); + const befunde = verboteneRechte() + .filter(({ tabelle, recht }) => vorhanden.has(`${tabelle}:${recht}`)) + .map(({ tabelle, recht }) => ` ${tabelle}: ${recht}`); + + if (befunde.length > 0) { + log(""); + log("ACHTUNG: Die Anwendungsrolle hat Rechte, die sie nicht haben darf:"); + befunde.forEach((zeile) => log(zeile)); + log("Die Unveraenderlichkeit von Protokollen ist damit aufgehoben."); + process.exitCode = 1; + return; + } + + log("Gegenprobe: keine Tabelle hat mehr Rechte als vorgesehen."); + } finally { + await prisma.$disconnect(); + } +} + +async function main(): Promise { + const quelle = argument("quelle"); + if (!quelle) { + console.error("[wiederherstellen] --quelle fehlt."); + process.exit(1); + } + + const manifestPfad = join(quelle, "manifest.json"); + if (!existsSync(manifestPfad)) { + console.error( + `[wiederherstellen] In ${quelle} liegt keine manifest.json. ` + + "Ist das wirklich ein Sicherungsverzeichnis?", + ); + process.exit(1); + } + + const manifest = JSON.parse(readFileSync(manifestPfad, "utf-8")) as { + erstelltAm: string; + version: string; + datenbank: { bytes: number }; + dateien: { anzahl: number; bytes: number }; + }; + + const alter = Math.round( + (Date.now() - new Date(manifest.erstelltAm).getTime()) / 1000 / 60 / 60 / 24, + ); + + log(""); + log(`Sicherung vom ${manifest.erstelltAm.slice(0, 16).replace("T", " ")} (${alter} Tage alt)`); + log(`Version: ${manifest.version}`); + log(`Datenbank: ${(manifest.datenbank.bytes / 1024 / 1024).toFixed(1)} MB`); + log(`Dateien: ${manifest.dateien.anzahl} Stück`); + log(""); + + if (!wirklich) { + log("PROBELAUF — es wird nichts geändert."); + log(""); + log("Zum wirklichen Zurückspielen dieselbe Zeile mit --wirklich am Ende."); + log("Achtung: dabei wird der gesamte vorhandene Bestand überschrieben."); + return; + } + + if (alter > 30) { + log(`WARNUNG: Diese Sicherung ist ${alter} Tage alt.`); + } + + log("Spiele zurück — der vorhandene Bestand wird überschrieben."); + datenbankZurueck(quelle); + + const dateien = await dateienZurueck(quelle); + log(`Dateien: ${dateien} zurückgespielt.`); + + await sperrenSetzen(); + + log(""); + log("Fertig. Bitte jetzt von Hand prüfen:"); + log(" 1. Anmelden — kommt jemand hinein?"); + log(" 2. Ein Prüfprotokoll öffnen — ist das PDF da?"); + log(" 3. Die Mitgliederzahl mit der Erwartung vergleichen."); + log(""); + log("Erst danach gilt die Sicherung als geprüft."); +} + +main().catch((fehler) => { + console.error("[wiederherstellen] Abbruch:", fehler); + process.exit(1); +}); diff --git a/src/app/(app)/mitglieder/[id]/page.tsx b/src/app/(app)/mitglieder/[id]/page.tsx index f270bbf..dec0267 100644 --- a/src/app/(app)/mitglieder/[id]/page.tsx +++ b/src/app/(app)/mitglieder/[id]/page.tsx @@ -2,6 +2,7 @@ import Link from "next/link"; import { notFound, redirect } from "next/navigation"; import { ArrowLeft } from "lucide-react"; import { aktuelleSitzung } from "@/lib/auth"; +import { hatRecht } from "@/lib/authz"; import { darfPersonSehen } from "@/lib/authz"; import { fuerOrganisation } from "@/lib/db"; import { alsDatum, personAnrede } from "@/lib/utils"; @@ -10,6 +11,7 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/com import { Badge } from "@/components/ui/badge"; import { FristAnzeige, NichtsGefunden } from "@/components/stammdaten/anzeige"; import { DIENSTGRAD_TEXT } from "@/lib/stammdaten/texte"; +import { Datenauskunft } from "@/components/stammdaten/auskunft"; export const metadata = { title: "Mitglied" }; @@ -130,7 +132,19 @@ export default async function MitgliedSeite({ params }: { params: Promise<{ id: Zurück zur Mitgliederliste -

{personAnrede(person)}

+
+

{personAnrede(person)}

+ + {/* Die Auskunft nach Art. 15 DSGVO. Sie steht hier und nicht in + einem Datenschutzmenü: gefragt wird sie zu einer bestimmten + Person, und genau deren Seite ist man gerade. */} + {hatRecht(sitzung, "person.export") && ( + + )} +
{person.rollen.map((zuweisung) => ( diff --git a/src/components/stammdaten/auskunft.tsx b/src/components/stammdaten/auskunft.tsx new file mode 100644 index 0000000..766a132 --- /dev/null +++ b/src/components/stammdaten/auskunft.tsx @@ -0,0 +1,190 @@ +"use client"; + +import * as React from "react"; +import { Download, FileText, ShieldCheck } from "lucide-react"; +import type { inferRouterOutputs } from "@trpc/server"; +import { trpc } from "@/lib/trpc/client"; +import type { AppRouter } from "@/server/routers/_app"; +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; +import { Fehlermeldung } from "@/components/stammdaten/anzeige"; + +type Ergebnis = inferRouterOutputs["person"]["auskunft"]; + +/** + * Die Datenauskunft nach Art. 15 DSGVO. + * + * Ein Knopf, ein Vorgang, drei Ergebnisse: die Anzeige auf dem Schirm, ein PDF + * für die Akte und eine JSON-Datei nach Art. 20. Bewusst nicht drei Knöpfe — + * wer eine Auskunft erteilt, soll sie vollständig erteilen und nicht in + * Schritten, von denen zwei vergessen werden können. + * + * Der Vorgang steht danach im Protokoll. Das ist keine Förmlichkeit: die + * Auskunft legt sämtliche Daten eines Menschen offen, einschließlich seiner + * Gesundheitsdaten aus der G26 — dass das geschehen ist, gehört festgehalten. + */ +export function Datenauskunft({ + personId, + personName, +}: { + personId: string; + personName: string; +}) { + const [offen, setOffen] = React.useState(false); + const [ergebnis, setErgebnis] = React.useState(null); + const [meldung, setMeldung] = React.useState(null); + + const erstellen = trpc.person.auskunft.useMutation({ + onSuccess: (daten) => { + setErgebnis(daten); + setMeldung(null); + }, + onError: (fehler) => setMeldung(fehler.message), + }); + + /** Die maschinenlesbare Fassung — Art. 20, Datenübertragbarkeit. */ + function jsonHerunterladen() { + if (!ergebnis) return; + + const ziel = URL.createObjectURL( + new Blob([JSON.stringify(ergebnis.auskunft, null, 2)], { + type: "application/json;charset=utf-8", + }), + ); + const verweis = document.createElement("a"); + verweis.href = ziel; + verweis.download = `auskunft-${personName.replace(/\s+/g, "-").toLowerCase()}.json`; + verweis.click(); + URL.revokeObjectURL(ziel); + } + + return ( + <> + + + { + if (!zustand) { + setOffen(false); + setErgebnis(null); + setMeldung(null); + } + }} + > + + + Datenauskunft nach Art. 15 DSGVO + + Für {personName}. Es entsteht eine vollständige Übersicht aller Daten, die diese + Wehr über die Person verarbeitet — einschließlich der Gesundheitsdaten aus der + G26. + + + + {meldung && } + + {!ergebnis && ( +
+

+ Die Auskunft enthält Stammdaten, Rollen, Qualifikationen, Einsatz- und + Übungsbeteiligung, Anwesenheiten, Fahrten, Beiträge, Benachrichtigungswege und + die arbeitsmedizinische Vorsorge. +

+

+ Der Vorgang wird protokolliert. Dass eine Auskunft erteilt + wurde, gehört festgehalten — sie legt alles offen, was über einen Menschen + gespeichert ist. +

+
+ )} + + {ergebnis && ( +
+
+ Auskunft erstellt: {ergebnis.umfang} Angaben in{" "} + {ergebnis.auskunft.abschnitte.length} Abschnitten. Der Vorgang steht im + Protokoll. +
+ +
+ + +
+ +
+ {ergebnis.auskunft.abschnitte.map((abschnitt) => ( + + + + {abschnitt.titel} + {abschnitt.besondereKategorie && ( + + besondere Kategorie (Art. 9) + + )} + + {abschnitt.zweck} + + + {abschnitt.eintraege.length === 0 ? ( +

+ Dazu liegen keine Daten vor. +

+ ) : ( +

+ {abschnitt.eintraege.length}{" "} + {abschnitt.eintraege.length === 1 ? "Eintrag" : "Einträge"} +

+ )} +
+
+ ))} +
+
+ )} + + + + {!ergebnis && ( + + )} + +
+
+ + ); +} diff --git a/src/lib/dsgvo/auskunft.ts b/src/lib/dsgvo/auskunft.ts new file mode 100644 index 0000000..9f29bcf --- /dev/null +++ b/src/lib/dsgvo/auskunft.ts @@ -0,0 +1,394 @@ +import type { MandantenClient } from "@/lib/db"; + +/** + * Die Auskunft nach Art. 15 DSGVO. + * + * Jede Person darf verlangen zu erfahren, welche Daten über sie verarbeitet + * werden. In einer Feuerwehr ist das mehr, als die meisten erwarten: neben den + * Stammdaten stehen dort Einsatzbeteiligungen, Anwesenheiten, Fahrten, + * Beiträge — und mit den G26-Untersuchungen auch Gesundheitsdaten, also eine + * besondere Kategorie nach Art. 9. + * + * Zwei Festlegungen bestimmen den Zuschnitt: + * + * **Was über die Person gespeichert ist, steht vollständig drin.** Auch das + * Unangenehme: eine nicht bestandene G26, ein gemahnter Beitrag. Eine + * Auskunft, die das Angenehme auswählt, ist keine. + * + * **Was die Person getan hat, steht als Zusammenfassung drin.** Wer zwölf + * Prüfprotokolle unterschrieben hat, bekommt die Zahl und die Fundstellen — + * nicht zwölf Protokolle. Die gehören zum Nachweisbestand der Wehr, nicht zur + * Person; sie mitzuliefern hieße, dass jeder über den Umweg einer + * Selbstauskunft an den Geräteakten der Wehr käme. + * + * Die Trennlinie ist also nicht „viel oder wenig", sondern „über mich" gegen + * „von mir". + */ + +/** Ein Abschnitt der Auskunft, wie er auf Papier und im JSON erscheint. */ +export interface Abschnitt { + titel: string; + /** Warum diese Daten überhaupt vorliegen — Art. 15 Abs. 1 lit. a und b. */ + zweck: string; + /** Leer heißt: dazu liegt nichts vor. Das ist auch eine Auskunft. */ + eintraege: Array>; + /** Besondere Kategorie nach Art. 9 — wird im Bericht gekennzeichnet. */ + besondereKategorie?: boolean; +} + +export interface Auskunft { + erstelltAm: Date; + wehr: { name: string; strasse: string | null; plz: string | null; ort: string | null }; + person: { + id: string; + name: string; + mitgliedsnummer: string | null; + }; + abschnitte: Abschnitt[]; + /** Was die Person getan hat — als Zahl, nicht als Aktenauszug. */ + taetigkeiten: Array<{ was: string; anzahl: number }>; +} + +/** Datum ohne Uhrzeit, wie es in einem Bericht steht. */ +function tag(wert: Date | null | undefined): string | null { + return wert ? wert.toISOString().slice(0, 10) : null; +} + +/** Zeitpunkt mit Minute — für alles, wo die Uhrzeit zur Sache gehört. */ +function zeitpunkt(wert: Date | null | undefined): string | null { + if (!wert) return null; + return wert.toISOString().slice(0, 16).replace("T", " "); +} + +/** + * Alles zusammentragen, was über eine Person vorliegt. + * + * Läuft im Mandantenkontext: die Abfragen sehen nur die Wehr, in der die + * Person geführt wird. Eine Person, die in zwei Wehren eine Akte hat, bekommt + * zwei Auskünfte — und das ist richtig so, denn es sind zwei + * Verantwortliche im Sinne der Verordnung. + */ +export async function auskunftSammeln(db: MandantenClient, personId: string): Promise { + const person = await db.person.findUnique({ + where: { id: personId }, + include: { + organisation: { select: { name: true, strasse: true, plz: true, ort: true } }, + mitgliedschaft: { + include: { + benutzer: { + select: { + email: true, + letzteAnmeldung: true, + aktiv: true, + oidcSubject: true, + }, + }, + }, + }, + rollen: { include: { rolle: { select: { name: true } } } }, + qualifikationen: { + where: { geloeschtAm: null }, + include: { typ: { select: { name: true, kuerzel: true } } }, + orderBy: { erworbenAm: "desc" }, + }, + ausweisTokens: { + where: { geloeschtAm: null }, + select: { typ: true, bezeichnung: true, erstelltAm: true, letzteNutzung: true }, + }, + zustellkanaele: { + select: { + kanal: true, + ziel: true, + bezeichnung: true, + aktiv: true, + letzteZustellung: true, + }, + }, + }, + }); + + if (!person) { + throw new Error("Diese Person gibt es nicht."); + } + + const abschnitte: Abschnitt[] = []; + + // --- Stammdaten ----------------------------------------------------------- + abschnitte.push({ + titel: "Stammdaten", + zweck: + "Führung des Mitgliederverzeichnisses der Feuerwehr. Rechtsgrundlage: " + + "Art. 6 Abs. 1 lit. e DSGVO in Verbindung mit dem Bayerischen " + + "Feuerwehrgesetz.", + eintraege: [ + { + Vorname: person.vorname, + Nachname: person.nachname, + Mitgliedsnummer: person.mitgliedsnummer, + Dienstgrad: person.dienstgrad, + Abteilung: person.abteilung, + Geburtsdatum: tag(person.geburtsdatum), + Eintritt: tag(person.eintrittsdatum), + Austritt: tag(person.austrittsdatum), + "E-Mail": person.email, + Telefon: person.telefon, + Mobil: person.mobil, + Anschrift: [person.strasse, [person.plz, person.ort].filter(Boolean).join(" ")] + .filter(Boolean) + .join(", "), + "Angelegt am": tag(person.erstelltAm), + }, + ], + }); + + // --- Zugang --------------------------------------------------------------- + abschnitte.push({ + titel: "Zugang zur Software", + zweck: + "Anmeldung und Nachvollziehbarkeit von Änderungen. Ohne Konto ist die " + + "Person nur im Verzeichnis geführt und meldet sich nicht an.", + eintraege: person.mitgliedschaft?.benutzer + ? [ + { + "Anmeldung mit": person.mitgliedschaft.benutzer.email, + "Zuletzt angemeldet": zeitpunkt(person.mitgliedschaft.benutzer.letzteAnmeldung), + Konto: person.mitgliedschaft.benutzer.aktiv ? "aktiv" : "abgeschaltet", + "Anmeldung über authentik": person.mitgliedschaft.benutzer.oidcSubject + ? "ja" + : "nein", + }, + ] + : [], + }); + + // --- Rollen --------------------------------------------------------------- + abschnitte.push({ + titel: "Rollen und Berechtigungen", + zweck: "Steuert, was die Person in der Software sehen und tun darf.", + eintraege: person.rollen.map((zuweisung) => ({ + Rolle: zuweisung.rolle.name, + "Zugewiesen am": tag(zuweisung.zugewiesenAm), + "Gültig bis": tag(zuweisung.gueltigBis), + "Aus authentik-Gruppe": zuweisung.ausOidcGruppe ? "ja" : "nein", + })), + }); + + // --- Qualifikationen ------------------------------------------------------ + abschnitte.push({ + titel: "Qualifikationen und Lehrgänge", + zweck: "Nachweis der Ausbildung; Grundlage für die Einteilung im Einsatz.", + eintraege: person.qualifikationen.map((eintrag) => ({ + Qualifikation: eintrag.typ.name, + Kürzel: eintrag.typ.kuerzel, + Erworben: tag(eintrag.erworbenAm), + "Gültig bis": tag(eintrag.gueltigBis), + Nachweis: eintrag.nachweisnummer, + })), + }); + + // --- Atemschutz: besondere Kategorie -------------------------------------- + const g26 = await db.g26Untersuchung.findMany({ + where: { personId, geloeschtAm: null }, + orderBy: { untersuchtAm: "desc" }, + }); + + const belastung = await db.belastungsuebung.findMany({ + where: { personId }, + orderBy: { datum: "desc" }, + }); + + abschnitte.push({ + titel: "Arbeitsmedizinische Vorsorge G 26", + zweck: + "Nachweis der Atemschutztauglichkeit nach FwDV 7 und DGUV. " + + "Gesundheitsdaten — besondere Kategorie nach Art. 9 DSGVO. Gespeichert " + + "wird das Ergebnis und die Frist, nicht der Befund.", + besondereKategorie: true, + eintraege: g26.map((eintrag) => ({ + "Untersucht am": tag(eintrag.untersuchtAm), + Ergebnis: eintrag.ergebnis, + "Gültig bis": tag(eintrag.gueltigBis), + Arzt: eintrag.arzt, + Bemerkung: eintrag.bemerkung, + })), + }); + + abschnitte.push({ + titel: "Belastungsübungen", + zweck: "Nachweis der jährlichen Übung in der Atemschutzstrecke nach FwDV 7.", + besondereKategorie: true, + eintraege: belastung.map((eintrag) => ({ + "Absolviert am": tag(eintrag.datum), + Art: eintrag.art, + Ort: eintrag.ort, + Dauer: eintrag.dauerMinuten === null ? null : `${eintrag.dauerMinuten} Minuten`, + Bemerkung: eintrag.bemerkung, + })), + }); + + // --- Einsatzbeteiligung --------------------------------------------------- + const einsaetze = await db.einsatzkraft.findMany({ + where: { personId }, + include: { + einsatz: { + select: { einsatznummer: true, stichwort: true, alarmiertUm: true, ort: true }, + }, + }, + orderBy: { einsatz: { alarmiertUm: "desc" } }, + take: 500, + }); + + abschnitte.push({ + titel: "Einsatzbeteiligung", + zweck: + "Nachweis der Teilnahme; Grundlage für Aufwandsentschädigung, " + + "Versicherungsschutz und Auszeichnungen.", + eintraege: einsaetze.map((eintrag) => ({ + Einsatz: eintrag.einsatz.einsatznummer, + Stichwort: eintrag.einsatz.stichwort, + Alarmierung: zeitpunkt(eintrag.einsatz.alarmiertUm), + Ort: eintrag.einsatz.ort, + Funktion: eintrag.funktion, + })), + }); + + // --- Übungen -------------------------------------------------------------- + const uebungen = await db.uebungsteilnahme.findMany({ + where: { personId }, + include: { uebung: { select: { thema: true, beginnUm: true, art: true } } }, + orderBy: { uebung: { beginnUm: "desc" } }, + take: 500, + }); + + abschnitte.push({ + titel: "Übungsteilnahme", + zweck: "Nachweis der Ausbildung nach FwDV 2.", + eintraege: uebungen.map((eintrag) => ({ + Übung: eintrag.uebung.thema, + Art: eintrag.uebung.art, + Beginn: zeitpunkt(eintrag.uebung.beginnUm), + Funktion: eintrag.funktion, + Bemerkung: eintrag.bemerkung, + })), + }); + + // --- Anwesenheit und Fahrten --------------------------------------------- + const anwesenheiten = await db.anwesenheit.findMany({ + where: { personId }, + orderBy: { angemeldetUm: "desc" }, + take: 500, + }); + + abschnitte.push({ + titel: "Anwesenheit am Gerätehaus", + zweck: "Am Terminal erfasste An- und Abmeldungen.", + eintraege: anwesenheiten.map((eintrag) => ({ + Anlass: eintrag.anlass, + Angemeldet: zeitpunkt(eintrag.angemeldetUm), + Abgemeldet: zeitpunkt(eintrag.abgemeldetUm), + })), + }); + + const fahrten = await db.fahrt.findMany({ + where: { fahrerId: personId }, + include: { fahrzeug: { select: { funkrufname: true } } }, + orderBy: { abfahrtUm: "desc" }, + take: 500, + }); + + abschnitte.push({ + titel: "Fahrten als Maschinist", + zweck: "Fahrtenbuch der Fahrzeuge; Nachweis gegenüber Halter und Versicherung.", + eintraege: fahrten.map((eintrag) => ({ + Fahrzeug: eintrag.fahrzeug.funkrufname, + Abfahrt: zeitpunkt(eintrag.abfahrtUm), + Rückkehr: zeitpunkt(eintrag.rueckkehrUm), + Zweck: eintrag.zweck, + "Kilometer bei Abfahrt": String(eintrag.kilometerAbfahrt), + "Kilometer bei Rückkehr": + eintrag.kilometerRueckkehr === null ? null : String(eintrag.kilometerRueckkehr), + })), + }); + + // --- Beiträge ------------------------------------------------------------- + const beitraege = await db.beitrag.findMany({ + where: { personId }, + orderBy: { jahr: "desc" }, + }); + + abschnitte.push({ + titel: "Mitgliedsbeiträge", + zweck: "Abrechnung der Beiträge; Buchführung der Wehr.", + eintraege: beitraege.map((eintrag) => ({ + Jahr: String(eintrag.jahr), + Betrag: `${eintrag.betrag.toFixed(2)} EUR`, + Stand: eintrag.stand, + "Bezahlt am": tag(eintrag.bezahltAm), + Mahnstufe: eintrag.mahnstufe > 0 ? String(eintrag.mahnstufe) : null, + "Grund des Erlasses": eintrag.erlassGrund, + })), + }); + + // --- Erreichbarkeit ------------------------------------------------------- + abschnitte.push({ + titel: "Benachrichtigungswege", + zweck: + "Zustellung von Terminerinnerungen und Meldungen. Jede Person richtet " + + "sie selbst ein und kann sie jederzeit entfernen.", + eintraege: person.zustellkanaele.map((eintrag) => ({ + Kanal: eintrag.kanal, + Ziel: eintrag.ziel, + Bezeichnung: eintrag.bezeichnung, + "Zuletzt zugestellt": zeitpunkt(eintrag.letzteZustellung), + Aktiv: eintrag.aktiv ? "ja" : "nein", + })), + }); + + abschnitte.push({ + titel: "Ausweise und PIN", + zweck: + "Anmeldung am Terminal im Gerätehaus. Die Geheimnisse selbst sind nur " + + "als Prüfsumme gespeichert und lassen sich nicht zurückrechnen — " + + "deshalb stehen hier nur Art und Nutzung.", + eintraege: person.ausweisTokens.map((eintrag) => ({ + Art: eintrag.typ, + Bezeichnung: eintrag.bezeichnung, + "Angelegt am": tag(eintrag.erstelltAm), + "Zuletzt benutzt": zeitpunkt(eintrag.letzteNutzung), + })), + }); + + // --- Was die Person getan hat -------------------------------------------- + // + // Bewusst nur gezählt. Die Unterlagen dahinter gehören zum Nachweisbestand + // der Wehr; sie hier mitzuliefern hieße, dass jeder über eine + // Selbstauskunft an die Geräteakten käme. + const [pruefungen, maengel, protokolleinträge] = await Promise.all([ + db.pruefung.count({ where: { prueferId: personId } }), + db.mangel.count({ where: { melderId: personId } }), + db.auditLog.count({ where: { akteurPersonId: personId } }), + ]); + + const taetigkeiten = [ + { was: "durchgeführte Prüfungen", anzahl: pruefungen }, + { was: "gemeldete Mängel", anzahl: maengel }, + { was: "protokollierte Änderungen in der Software", anzahl: protokolleinträge }, + ]; + + return { + erstelltAm: new Date(), + wehr: person.organisation, + person: { + id: person.id, + name: `${person.vorname} ${person.nachname}`, + mitgliedsnummer: person.mitgliedsnummer, + }, + abschnitte, + taetigkeiten, + }; +} + +/** Wie viele Angaben die Auskunft insgesamt enthält — für die Vorschau. */ +export function auskunftUmfang(auskunft: Auskunft): number { + return auskunft.abschnitte.reduce((summe, abschnitt) => summe + abschnitt.eintraege.length, 0); +} diff --git a/src/lib/dsgvo/bericht.ts b/src/lib/dsgvo/bericht.ts new file mode 100644 index 0000000..dadbd2a --- /dev/null +++ b/src/lib/dsgvo/bericht.ts @@ -0,0 +1,170 @@ +import { htmlZuPdf } from "@/lib/pdf"; +import type { Auskunft } from "@/lib/dsgvo/auskunft"; + +/** + * Die Auskunft als PDF. + * + * Eine Datenauskunft ist ein Schreiben an einen Menschen, keine + * Datenbankausgabe. Deshalb steht über jedem Abschnitt, **warum** diese Daten + * vorliegen — Art. 15 Abs. 1 verlangt nicht nur die Daten, sondern auch + * Zwecke, Empfänger, Speicherdauer und den Hinweis auf die Betroffenenrechte. + * Eine Liste ohne diese Angaben erfüllt die Auskunftspflicht nicht, sieht aber + * so aus, als täte sie es. + * + * Leere Abschnitte bleiben stehen. „Dazu liegt nichts vor" ist eine Auskunft; + * ein weggelassener Abschnitt wirft die Frage auf, ob da doch etwas ist. + */ + +function html(wert: string | null | undefined): string { + return (wert ?? "") + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """); +} + +function fusszeile(text: string): string { + return ` +
+ ${html(text)} + Seite von +
+ `; +} + +function rahmen(titel: string, inhalt: string): string { + return ` + + + +${html(titel)} + + +${inhalt} +`; +} + +/** Ein Eintrag als Feld-Wert-Tabelle. Leere Felder werden weggelassen. */ +function eintragAlsTabelle(eintrag: Record): string { + const zeilen = Object.entries(eintrag) + .filter(([, wert]) => wert !== null && wert !== "") + .map( + ([feld, wert]) => + `${html(feld)}${html(wert)}`, + ) + .join(""); + + return zeilen ? `${zeilen}
` : ""; +} + +export async function auskunftPdf(auskunft: Auskunft): Promise { + const anschrift = [ + auskunft.wehr.strasse, + [auskunft.wehr.plz, auskunft.wehr.ort].filter(Boolean).join(" "), + ] + .filter(Boolean) + .join(", "); + + const abschnitte = auskunft.abschnitte + .map((abschnitt) => { + const koerper = + abschnitt.eintraege.length === 0 + ? `

Dazu liegen keine Daten vor.

` + : abschnitt.eintraege.map(eintragAlsTabelle).join(""); + + const kennzeichen = abschnitt.besondereKategorie + ? `

Besondere Kategorie personenbezogener Daten + nach Art. 9 DSGVO. Diese Angaben unterliegen einem erhöhten Schutz und werden + nur den Personen zugänglich gemacht, die sie für die Atemschutzüberwachung + brauchen.

` + : ""; + + return ` +

${html(abschnitt.titel)}

+

${html(abschnitt.zweck)}

+ ${kennzeichen} + ${koerper}`; + }) + .join(""); + + const taetigkeiten = auskunft.taetigkeiten + .map((eintrag) => `${html(eintrag.was)}${eintrag.anzahl}`) + .join(""); + + const inhalt = ` +
+

Auskunft nach Art. 15 DSGVO

+

+ ${html(auskunft.wehr.name)}${anschrift ? ` · ${html(anschrift)}` : ""}
+ Erstellt am ${html(auskunft.erstelltAm.toISOString().slice(0, 10))} +

+
+ +

+ Sehr geehrte/r ${html(auskunft.person.name)}, +

+

+ auf dieser und den folgenden Seiten stehen alle personenbezogenen Daten, die + ${html(auskunft.wehr.name)} über Sie verarbeitet — Stand + ${html(auskunft.erstelltAm.toISOString().slice(0, 10))}. Über jedem Abschnitt ist + angegeben, wozu diese Daten erhoben wurden. +

+ + ${abschnitte} + +

Von Ihnen erfasste Vorgänge

+

+ Was Sie in der Software getan haben, ist hier nur gezählt. Die Unterlagen selbst + — Prüfprotokolle, Mängelmeldungen, Einsatzberichte — gehören zum Nachweisbestand + der Feuerwehr und nicht zu Ihren personenbezogenen Daten. Sie können sie über die + jeweilige Ansicht einsehen, soweit Ihre Rolle das vorsieht. +

+ ${taetigkeiten}
+ +
+ Ihre weiteren Rechte

+ Berichtigung (Art. 16). Stimmt etwas nicht, wenden Sie sich an die + Kommandantin oder den Kommandanten — falsche Angaben werden berichtigt.

+ Löschung (Art. 17). Ein Teil dieser Daten unterliegt gesetzlichen + Aufbewahrungsfristen: Einsatzbeteiligungen und Atemschutznachweise sind Nachweise + gegenüber der Unfallkasse und können nicht gelöscht werden, solange die Frist + läuft. Was nicht darunter fällt, wird auf Verlangen gelöscht; nach dem Austritt + geschieht das ohnehin automatisch.

+ Datenübertragbarkeit (Art. 20). Dieselben Daten gibt es auf + Wunsch zusätzlich als JSON-Datei zur maschinellen Weiterverarbeitung.

+ Beschwerde (Art. 77). Sie können sich beim Bayerischen + Landesbeauftragten für den Datenschutz beschweren.

+ Empfänger. Diese Daten werden nicht an Dritte weitergegeben. Sie + liegen auf einem Server, den die Feuerwehr selbst betreibt; es findet keine + Übermittlung in ein Drittland statt. +
+ +

+ Diese Auskunft wurde maschinell erstellt. Der Abruf ist im Protokoll der Feuerwehr + vermerkt. +

+ `; + + return htmlZuPdf(rahmen(`Auskunft ${auskunft.person.name}`, inhalt), { + hintergrundDrucken: true, + fusszeileHtml: fusszeile(`${auskunft.wehr.name} · Auskunft ${auskunft.person.name}`), + }); +} diff --git a/src/server/routers/stammdaten/person.ts b/src/server/routers/stammdaten/person.ts index 0e5817d..d63a959 100644 --- a/src/server/routers/stammdaten/person.ts +++ b/src/server/routers/stammdaten/person.ts @@ -3,6 +3,9 @@ import { TRPCError } from "@trpc/server"; import { Dienstgrad, Prisma } from "@prisma/client"; import { router, angemeldet, mitRecht } from "@/server/trpc"; import { AuditAktion, protokolliereIn } from "@/lib/audit"; +import { auskunftSammeln, auskunftUmfang } from "@/lib/dsgvo/auskunft"; +import { auskunftPdf } from "@/lib/dsgvo/bericht"; +import { hochladen, objektSchluessel } from "@/lib/storage"; import { darfPersonSehen, KeinZugriffFehler } from "@/lib/authz"; import { blaettern, @@ -55,6 +58,87 @@ const personDaten = z.object({ }); export const personRouter = router({ + /** + * Auskunft nach Art. 15 DSGVO. + * + * Eine Mutation, obwohl sie im Kern nur liest — und das mit Absicht: das + * Erstellen der Auskunft **ist** die Offenlegung, und die gehört ins + * Protokoll. Als Abfrage würde sie zwischengespeichert, mehrfach ausgeführt + * und wäre entweder nicht oder zwanzigmal vermerkt. + * + * Ein Durchgang liefert alles auf einmal: die Angaben für die Anzeige, das + * PDF für die Akte und die JSON-Fassung nach Art. 20. Wer eine Auskunft + * verlangt, bekommt sie vollständig oder gar nicht — nicht in drei Schritten, + * von denen zwei vergessen werden können. + */ + auskunft: mitRecht("person.export") + .input(idEingabe) + .mutation(async ({ ctx, input }) => { + const auskunft = await ctx.db((db) => auskunftSammeln(db, input.id)); + + const organisation = await ctx.db((db) => + db.organisation.findUnique({ + where: { id: ctx.sitzung.organisationId }, + select: { slug: true }, + }), + ); + + const pdf = await auskunftPdf(auskunft); + + const schluessel = objektSchluessel({ + organisationSlug: organisation?.slug ?? ctx.sitzung.organisationId, + entitaet: "auskunft", + entitaetId: input.id, + dateiname: `auskunft-${auskunft.erstelltAm.toISOString().slice(0, 10)}.pdf`, + }); + + const abgelegt = await hochladen(schluessel, pdf, "application/pdf", { + kategorie: "dsgvo-auskunft", + person: input.id, + }); + + const datei = await ctx.db(async (db) => { + const angelegt = await db.datei.create({ + data: { + organisationId: ctx.sitzung.organisationId, + bucket: abgelegt.bucket, + objektSchluessel: abgelegt.objektSchluessel, + dateiname: `Auskunft ${auskunft.person.name}.pdf`, + mimeTyp: "application/pdf", + groesseBytes: BigInt(pdf.byteLength), + pruefsumme: abgelegt.pruefsumme, + hochgeladenVonId: ctx.sitzung.personId, + }, + }); + + // Die Offenlegung selbst — mit Umfang, damit später nachvollziehbar + // ist, was ausgehändigt wurde, ohne die Daten zu wiederholen. + await protokolliereIn(db, { + organisationId: ctx.sitzung.organisationId, + sitzung: ctx.sitzung, + aktion: AuditAktion.EXPORTIERT, + entitaet: "Person", + entitaetId: input.id, + beschreibung: + `Datenauskunft nach Art. 15 DSGVO für ${auskunft.person.name} erstellt ` + + `(${auskunftUmfang(auskunft)} Angaben in ${auskunft.abschnitte.length} Abschnitten)`, + nachher: { + abschnitte: auskunft.abschnitte.length, + angaben: auskunftUmfang(auskunft), + dateiId: angelegt.id, + }, + }); + + return angelegt; + }); + + return { + auskunft, + umfang: auskunftUmfang(auskunft), + dateiId: datei.id, + }; + }), + liste: mitRecht("person.lesen") .input( listenEingabe(SORTIERFELDER).extend({ diff --git a/tests/e2e/termine.spec.ts b/tests/e2e/termine.spec.ts index c2de552..5af4288 100644 --- a/tests/e2e/termine.spec.ts +++ b/tests/e2e/termine.spec.ts @@ -296,6 +296,16 @@ test.describe("Benachrichtigungen", () => { await anmelden(page); await page.goto("/benachrichtigungen/vorlagen"); + // Erst warten, bis die Seite im Browser lebendig ist. + // + // Die Platzhalter-Knöpfe entstehen client-seitig; sind sie da, ist + // hydriert. Ohne diese Zusicherung wurde unter Last auf „Speichern“ + // geklickt, bevor React lief — dann verschickt das Formular sich selbst, + // die Seite lädt neu, und die Fehlerfläche entsteht nie. Der Test fiel + // dann mit „Element nicht gefunden“ aus, obwohl der Server richtig + // ablehnte. + await expect(page.getByRole("button", { name: "{titel}" })).toBeVisible(); + const feld = page.getByLabel("Nachricht"); await feld.fill("Am {datum} um {uhrzeit}. Und ein {tippfehler}."); await page.getByRole("button", { name: "Speichern" }).click(); diff --git a/tests/unit/dsgvo-auskunft.test.ts b/tests/unit/dsgvo-auskunft.test.ts new file mode 100644 index 0000000..33be297 --- /dev/null +++ b/tests/unit/dsgvo-auskunft.test.ts @@ -0,0 +1,221 @@ +import { PrismaClient } from "@prisma/client"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { auskunftSammeln, auskunftUmfang } from "@/lib/dsgvo/auskunft"; +import type { MandantenClient } from "@/lib/db"; + +/** + * Die Auskunft nach Art. 15 DSGVO. + * + * Zwei Dinge müssen stimmen, und sie ziehen in verschiedene Richtungen. + * + * **Vollständigkeit.** Was über die Person gespeichert ist, muss drinstehen — + * auch das Unangenehme. Eine Auskunft, die den gemahnten Beitrag oder die + * nicht bestandene G26 auslässt, ist keine. + * + * **Begrenzung.** Was die Person *getan* hat, gehört zum Nachweisbestand der + * Wehr. Stünde es vollständig drin, käme jeder über eine Selbstauskunft an + * die Geräteakten — und die Auskunftspflicht wäre eine Hintertür. + * + * Der Test prüft deshalb beides: dass die Abschnitte da sind, und dass die + * Tätigkeiten nur gezählt werden. + * + * Braucht eine laufende Datenbank; ohne sie überspringt er sich selbst. + */ + +const SYSTEM_URL = process.env.DIRECT_DATABASE_URL ?? process.env.DATABASE_URL ?? ""; +const system = new PrismaClient({ datasources: { db: { url: SYSTEM_URL } } }); + +const SLUG = "test-auskunft-wehr"; + +let erreichbar = false; +let organisationId = ""; +let personId = ""; + +beforeAll(async () => { + try { + await system.$queryRaw`SELECT 1`; + erreichbar = true; + } catch { + return; + } + + const organisation = await system.organisation.upsert({ + where: { slug: SLUG }, + update: {}, + create: { slug: SLUG, name: "Auskunfts-Testwehr", kurzname: "ATW" }, + }); + organisationId = organisation.id; + + const person = await system.person.upsert({ + where: { organisationId_mitgliedsnummer: { organisationId, mitgliedsnummer: "A-1" } }, + update: {}, + create: { + organisationId, + vorname: "Auskunft", + nachname: "Suchend", + mitgliedsnummer: "A-1", + geburtsdatum: new Date("1985-04-12"), + eintrittsdatum: new Date("2010-01-01"), + email: "auskunft@test-auskunft.de", + strasse: "Musterweg 1", + plz: "91315", + ort: "Musterhausen", + abteilung: "Aktive", + }, + }); + personId = person.id; + + // Gesundheitsdaten — die besondere Kategorie, um die es geht. + await system.g26Untersuchung.create({ + data: { + organisationId, + personId, + untersuchtAm: new Date("2025-03-01"), + gueltigBis: new Date("2028-03-01"), + ergebnis: "TAUGLICH", + arzt: "Dr. Beispiel", + }, + }); + + // Ein gemahnter Beitrag — das Unangenehme, das drinstehen muss. + await system.beitrag.create({ + data: { + organisationId, + personId, + jahr: 2025, + betrag: "25.00", + stand: "GEMAHNT", + mahnstufe: 2, + }, + }); +}); + +afterAll(async () => { + if (erreichbar) { + await system.$transaction(async (tx) => { + await tx.$executeRaw`SELECT set_config('app.audit_bereinigung', 'an', true)`; + await tx.organisation.deleteMany({ where: { slug: SLUG } }); + }); + } + await system.$disconnect(); +}); + +describe.runIf(process.env.DATABASE_URL)("Auskunft nach Art. 15", () => { + it("nennt Person, Wehr und Zeitpunkt", async ({ skip }) => { + if (!erreichbar) return skip(); + + const auskunft = await auskunftSammeln(system as unknown as MandantenClient, personId); + + expect(auskunft.person.name).toBe("Auskunft Suchend"); + expect(auskunft.person.mitgliedsnummer).toBe("A-1"); + expect(auskunft.wehr.name).toBe("Auskunfts-Testwehr"); + expect(auskunft.erstelltAm).toBeInstanceOf(Date); + }); + + it("gibt zu jedem Abschnitt an, wozu die Daten vorliegen", async ({ skip }) => { + if (!erreichbar) return skip(); + + // Art. 15 Abs. 1 verlangt nicht nur die Daten, sondern auch die Zwecke. + // Eine Liste ohne sie sieht aus wie eine Auskunft und ist keine. + const auskunft = await auskunftSammeln(system as unknown as MandantenClient, personId); + + expect(auskunft.abschnitte.length).toBeGreaterThan(5); + for (const abschnitt of auskunft.abschnitte) { + expect(abschnitt.titel.length).toBeGreaterThan(0); + expect(abschnitt.zweck.length).toBeGreaterThan(20); + } + }); + + it("enthält die Stammdaten vollständig", async ({ skip }) => { + if (!erreichbar) return skip(); + + const auskunft = await auskunftSammeln(system as unknown as MandantenClient, personId); + const stammdaten = auskunft.abschnitte.find((eintrag) => eintrag.titel === "Stammdaten"); + + expect(stammdaten?.eintraege[0]).toMatchObject({ + Vorname: "Auskunft", + Nachname: "Suchend", + Geburtsdatum: "1985-04-12", + Anschrift: "Musterweg 1, 91315 Musterhausen", + }); + }); + + it("kennzeichnet Gesundheitsdaten als besondere Kategorie", async ({ skip }) => { + if (!erreichbar) return skip(); + + const auskunft = await auskunftSammeln(system as unknown as MandantenClient, personId); + const g26 = auskunft.abschnitte.find((eintrag) => eintrag.titel.includes("G 26")); + + expect(g26?.besondereKategorie).toBe(true); + expect(g26?.eintraege).toHaveLength(1); + expect(g26?.eintraege[0]?.Ergebnis).toBe("TAUGLICH"); + }); + + it("lässt das Unangenehme nicht weg", async ({ skip }) => { + if (!erreichbar) return skip(); + + // Ein gemahnter Beitrag ist ein Datum über diese Person. Eine Auskunft, + // die auswählt, was gut aussieht, erfüllt die Pflicht nicht. + const auskunft = await auskunftSammeln(system as unknown as MandantenClient, personId); + const beitraege = auskunft.abschnitte.find((eintrag) => + eintrag.titel.includes("Mitgliedsbeiträge"), + ); + + expect(beitraege?.eintraege[0]).toMatchObject({ Stand: "GEMAHNT", Mahnstufe: "2" }); + }); + + it("behält leere Abschnitte — auch das ist eine Auskunft", async ({ skip }) => { + if (!erreichbar) return skip(); + + // Diese Person war in keinem Einsatz. Der Abschnitt muss trotzdem + // dastehen: ein weggelassener wirft die Frage auf, ob da doch etwas ist. + const auskunft = await auskunftSammeln(system as unknown as MandantenClient, personId); + const einsaetze = auskunft.abschnitte.find((eintrag) => + eintrag.titel.includes("Einsatzbeteiligung"), + ); + + expect(einsaetze).toBeDefined(); + expect(einsaetze?.eintraege).toHaveLength(0); + }); + + it("zählt Tätigkeiten, statt die Unterlagen mitzuliefern", async ({ skip }) => { + if (!erreichbar) return skip(); + + // Die Grenze zwischen „über mich" und „von mir". Stünden hier die + // Prüfprotokolle im Volltext, käme jeder über eine Selbstauskunft an die + // Geräteakten der Wehr. + const auskunft = await auskunftSammeln(system as unknown as MandantenClient, personId); + + expect(auskunft.taetigkeiten.length).toBeGreaterThan(0); + for (const eintrag of auskunft.taetigkeiten) { + expect(typeof eintrag.anzahl).toBe("number"); + expect(eintrag).not.toHaveProperty("eintraege"); + } + }); + + it("zählt den Umfang über alle Abschnitte", async ({ skip }) => { + if (!erreichbar) return skip(); + + const auskunft = await auskunftSammeln(system as unknown as MandantenClient, personId); + const gezaehlt = auskunft.abschnitte.reduce( + (summe, abschnitt) => summe + abschnitt.eintraege.length, + 0, + ); + + expect(auskunftUmfang(auskunft)).toBe(gezaehlt); + expect(auskunftUmfang(auskunft)).toBeGreaterThan(2); + }); + + it("weist eine unbekannte Person ab, statt eine leere Auskunft zu bauen", async ({ skip }) => { + if (!erreichbar) return skip(); + + // Sonst bekäme jemand ein Schreiben mit lauter leeren Abschnitten und + // dem Eindruck, es lägen keine Daten vor. + await expect( + auskunftSammeln( + system as unknown as MandantenClient, + "00000000-0000-0000-0000-000000000000", + ), + ).rejects.toThrow(/gibt es nicht/i); + }); +}); diff --git a/tests/unit/sicherung-zugang.test.ts b/tests/unit/sicherung-zugang.test.ts new file mode 100644 index 0000000..aada729 --- /dev/null +++ b/tests/unit/sicherung-zugang.test.ts @@ -0,0 +1,168 @@ +import { describe, expect, it } from "vitest"; +import { zugangAus } from "../../scripts/sicherung-zugang"; +import { SPERREN } from "../../scripts/sicherung-sperren"; + +/** + * Das Zerlegen der Verbindungszeichenkette. + * + * Der unangenehmste Fehler, den ein Sicherungsskript machen kann, ist nicht + * abzubrechen: es liefe durch, schriebe eine Datei, und die enthielte die + * falsche Datenbank. Auffallen würde es beim Zurückspielen — also genau dann, + * wenn man die Sicherung braucht. + * + * Deshalb prüft dieser Test nicht nur, dass richtige Adressen richtig zerlegt + * werden, sondern vor allem, dass falsche laut scheitern. + */ + +describe("Verbindungszeichenkette zerlegen", () => { + it("liest Benutzer, Passwort und Datenbank", () => { + const zugang = zugangAus("postgresql://florian:geheim@localhost:5442/florian?schema=public"); + + expect(zugang).toEqual({ + benutzer: "florian", + passwort: "geheim", + datenbank: "florian", + }); + }); + + it("ignoriert die Prisma-Parameter am Ende", () => { + // `?schema=public` ist der Grund, warum die URL nicht als Ganzes an + // pg_dump gehen kann — es lehnt sie mit "invalid URI query parameter" ab. + const zugang = zugangAus( + "postgresql://florian:geheim@localhost:5442/florian?schema=public&connection_limit=5", + ); + + expect(zugang.datenbank).toBe("florian"); + }); + + it("entschlüsselt Sonderzeichen im Passwort", () => { + // Ein Passwort mit @ oder / muss in der URL kodiert stehen. Würde es hier + // nicht dekodiert, meldete Postgres nur "Passwort falsch". + const zugang = zugangAus("postgresql://florian:ge%40heim%2Fx@localhost:5432/florian"); + + expect(zugang.passwort).toBe("ge@heim/x"); + }); + + it("kommt mit einem leeren Passwort zurecht", () => { + const zugang = zugangAus("postgresql://florian@localhost:5432/florian"); + expect(zugang.passwort).toBe(""); + expect(zugang.benutzer).toBe("florian"); + }); + + it("bricht ab, wenn die Adresse fehlt", () => { + expect(() => zugangAus(undefined)).toThrow(/DIRECT_DATABASE_URL fehlt/); + expect(() => zugangAus("")).toThrow(/DIRECT_DATABASE_URL fehlt/); + }); + + it("bricht ab, wenn es gar keine URL ist", () => { + expect(() => zugangAus("einfach nur text")).toThrow(/keine gültige URL/); + }); + + it("bricht bei „localhost:5432“ ab — über die Protokollprüfung", () => { + // Node zerlegt das klaglos: „localhost:“ gilt als Protokoll, „5432“ als + // Pfad. Ohne die Protokollprüfung eine Zeile weiter käme hier ein + // Zugang mit leerem Benutzer heraus, und die Sicherung liefe ins Leere. + expect(() => zugangAus("localhost:5432")).toThrow(/postgresql/); + }); + + it("bricht ab, wenn es nicht Postgres ist", () => { + // Sonst würde eine falsch gesetzte Umgebungsvariable eine Sicherung + // erzeugen, die nichts enthält. + expect(() => zugangAus("mysql://florian:geheim@localhost:3306/florian")).toThrow( + /postgresql/, + ); + }); + + it("bricht ab, wenn der Datenbankname fehlt", () => { + expect(() => zugangAus("postgresql://florian:geheim@localhost:5432/")).toThrow( + /Datenbankname/, + ); + }); +}); + +describe("Die Sperrenliste stimmt mit der Datenbank überein", () => { + /** + * Der Abgleich gegen die Wirklichkeit. + * + * `scripts/sicherung-sperren.ts` führt auf, welche Rechte die + * Anwendungsrolle nicht haben darf — und stellt sie nach einer + * Wiederherstellung wieder her. Diese Liste steht neben den Migrationen, + * die dieselben Sperren setzen, und zwei Listen laufen auseinander. + * + * Läuft sie auseinander, ist der Schaden lautlos: eine Wiederherstellung + * würde eine Sperre nicht zurückbringen, von der niemand mehr weiß, dass es + * sie gab. Deshalb fragt dieser Test die migrierte Datenbank, statt der + * Liste zu glauben. + */ + it("nennt keine Sperre, die es in der Datenbank nicht gibt", async ({ skip }) => { + const url = process.env.DIRECT_DATABASE_URL; + if (!url) return skip(); + + const { PrismaClient } = await import("@prisma/client"); + const prisma = new PrismaClient({ datasources: { db: { url } } }); + + try { + const tabellen = await prisma.$queryRawUnsafe>( + `SELECT table_name FROM information_schema.tables + WHERE table_schema = 'public' AND table_type = 'BASE TABLE'`, + ); + const vorhanden = new Set(tabellen.map((zeile) => zeile.table_name)); + + const unbekannt = SPERREN.map((eintrag) => eintrag.tabelle).filter( + (tabelle) => !vorhanden.has(tabelle), + ); + + expect(unbekannt, `Diese Tabellen gibt es nicht (mehr): ${unbekannt.join(", ")}`).toEqual( + [], + ); + } finally { + await prisma.$disconnect(); + } + }); + + it("übersieht keine Sperre, die die Migrationen gesetzt haben", async ({ skip }) => { + const url = process.env.DIRECT_DATABASE_URL; + if (!url) return skip(); + + const { PrismaClient } = await import("@prisma/client"); + const prisma = new PrismaClient({ datasources: { db: { url } } }); + + try { + const rolle = await prisma.$queryRawUnsafe>( + "SELECT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'florian_app') AS da", + ); + if (!rolle[0]?.da) return skip(); + + // Welche Rechte hat die Anwendungsrolle heute wirklich? + const zeilen = await prisma.$queryRawUnsafe< + Array<{ table_name: string; privilege_type: string }> + >( + `SELECT table_name, privilege_type FROM information_schema.role_table_grants + WHERE grantee = 'florian_app'`, + ); + const hat = new Set(zeilen.map((z) => `${z.table_name}:${z.privilege_type}`)); + + // Jede Tabelle, der ein Schreibrecht fehlt, muss in der Liste stehen — + // sonst käme sie nach einer Wiederherstellung ungesperrt zurück. + const alleTabellen = [...new Set(zeilen.map((z) => z.table_name))]; + const notiert = new Map(SPERREN.map((e) => [e.tabelle, new Set(e.rechte)])); + + const fehlend: string[] = []; + for (const tabelle of alleTabellen) { + for (const recht of ["INSERT", "UPDATE", "DELETE"]) { + if (!hat.has(`${tabelle}:${recht}`) && !notiert.get(tabelle)?.has(recht)) { + fehlend.push(`${tabelle}: ${recht}`); + } + } + } + + expect( + fehlend, + "Diese Sperren stehen in der Datenbank, aber nicht in scripts/sicherung-sperren.ts — " + + `nach einer Wiederherstellung wären sie weg: ${fehlend.join(", ")}`, + ).toEqual([]); + } finally { + await prisma.$disconnect(); + } + }); +}); -- 2.54.0 From 30faa6cb028939c0cf0aa56ccf2576dd75b07fc8 Mon Sep 17 00:00:00 2001 From: Florian Wilde Date: Sun, 6 Sep 2026 18:09:26 +0200 Subject: [PATCH 2/2] =?UTF-8?q?Version=201.0.0=20=E2=80=94=20Freigabe=20fu?= =?UTF-8?q?er=20den=20Echtbetrieb?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Kein neues Modul. Zwoelf Phasen sind fertig, und die Wehr kann ab jetzt echte Daten hineingeben, ohne befuerchten zu muessen, sie beim naechsten Update zu verlieren. Neu dazu, weil beides zum Betrieb gehoert: update.sh aktualisiert auf einem Linux-Server in einem Befehl. Der Ablauf darf an jeder Stelle abbrechen, ohne eine halb aktualisierte Anlage zu hinterlassen: erst nachsehen ob es etwas Neues gibt, dann sichern, dann holen und bauen (schlaegt das fehl, laeuft die alte Fassung unveraendert weiter), dann umschalten, dann pruefen ob die Anwendung antwortet. Tut sie es nicht, stehen die Protokollzeilen da und die Befehle fuer den Rueckweg. docs/KURZANLEITUNG.md ist der Einstieg fuer die Wehr - nach Rollen sortiert, aufgabenorientiert statt nach Modulen, und bei jedem heiklen Punkt steht das Warum dabei. Die vollstaendige Anleitung hat 63 Kapitel; die liest niemand, bevor er das erste Geraet pruefen will. Die Roadmap haelt den Stand JEDER Freigabevoraussetzung fest, auch der offenen: Datenuebernahme entfaellt (die Wehr pflegt von Hand ein), Sicherungskonzept erledigt, Loeschkonzept technisch vollstaendig aber nicht abgenommen, Schulung steht aus, Atemschutzhinweis bewusst stehen gelassen. Eine Freigabe, die abhakt was nicht geprueft wurde, ist keine. Nebenbei die Ursache der Testflackerei gefunden, die sich durch mehrere Phasen zog: zwei Datenbanktests schalteten beim Aufraeumen ihre Trigger mit ALTER TABLE ... DISABLE TRIGGER ab - eine Schemaaenderung, die fuer die ganze Datenbank gilt. Vitest faehrt die Dateien parallel, also pruefte eine Datei gerade dass ein Trigger greift, waehrend eine andere ihn abgeraeumt hatte. Jetzt sitzungslokal ueber session_replication_role. Erstmals in diesem Projekt: fuenf Unit-Testlaeufe hintereinander 601 gruen und ein E2E-Lauf mit 268 gruen ohne Fehlschlag und ohne Flackern. Co-Authored-By: Claude Opus 5 (1M context) --- CHANGELOG.md | 78 +++++++ README.md | 33 ++- docs/ANLEITUNG.md | 8 +- docs/DEVLOG.md | 102 ++++++++- docs/KURZANLEITUNG.md | 283 ++++++++++++++++++++++++ docs/ROADMAP.md | 38 +++- package.json | 2 +- tests/unit/atemschutz-datenbank.test.ts | 12 +- tests/unit/ki-datenbank.test.ts | 13 +- tests/unit/mandanten-datenbank.test.ts | 26 ++- update.sh | 234 ++++++++++++++++++++ 11 files changed, 799 insertions(+), 30 deletions(-) create mode 100644 docs/KURZANLEITUNG.md create mode 100755 update.sh diff --git a/CHANGELOG.md b/CHANGELOG.md index 293204a..a7c220b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,84 @@ Alle nennenswerten Änderungen an FLORIAN. Format nach Die Regeln dazu stehen in [docs/VERSIONIERUNG.md](docs/VERSIONIERUNG.md). +## [1.0.0] — 2026-09-06 + +**Freigabe für den Echtbetrieb.** + +Kein neues Modul. Zwölf Phasen sind fertig, und die Wehr kann ab jetzt echte +Daten hineingeben, ohne befürchten zu müssen, sie beim nächsten Update zu +verlieren. + +Was 1.0.0 bedeutet: der Funktionsumfang ist vollständig, die Datenbank ist +stabil, Migrationen sind aufwärtskompatibel, und ab hier gilt semantische +Versionierung — ein Update bricht nichts, ohne dass es in der Hauptnummer +steht. + +Was es nicht bedeutet: dass keine Fehler mehr da sind. + +### Hinzugefügt + +- **`update.sh`** — Aktualisieren auf einem Linux-Server, in einem Befehl. Der + Ablauf darf an jeder Stelle abbrechen, ohne eine halb aktualisierte Anlage + zu hinterlassen: erst nachsehen, ob es überhaupt etwas Neues gibt, dann + **sichern**, dann holen und bauen (schlägt das fehl, läuft die alte Fassung + unverändert weiter), dann umschalten, dann nachsehen, ob die Anwendung + antwortet. Tut sie es nicht, stehen die letzten Protokollzeilen da und die + Befehle, mit denen der vorherige Stand zurückkommt. + + Vorher prüft es Docker, git, die `.env` und dass keine unverbuchten + Änderungen im Verzeichnis liegen. `--pruefen` sieht nur nach, + `--ohne-sicherung` gibt es für Testinstallationen. + +- **`docs/KURZANLEITUNG.md`** — der Einstieg, nach Rollen sortiert und auf ein + paar Seiten. Die vollständige Anleitung hat 63 Kapitel; die liest niemand, + bevor er das erste Gerät prüfen will. Diese hier ist aufgabenorientiert + („ein Gerät prüfen", „einen Kontoauszug einlesen") statt nach Modulen + geordnet, und sie sagt bei jedem heiklen Punkt dazu, warum es so ist — warum + ein abgeschlossenes Prüfprotokoll nicht änderbar ist, warum gegengebucht und + nicht korrigiert wird, warum der Atemschutzhinweis stehen bleibt. + +### Geändert + +- Version auf **1.0.0**. Ab hier gilt semantische Versionierung nach + [docs/VERSIONIERUNG.md](docs/VERSIONIERUNG.md). +- Die Roadmap hält den Stand **jeder** Freigabevoraussetzung fest, auch der + offenen. Zwei sind organisatorisch und stehen aus: die Abnahme des + Löschkonzepts durch die Wehr und die Schulung. An der Software hängt nichts + davon ab; sie stehen dort, damit später nachlesbar ist, was zum Zeitpunkt + der Freigabe erledigt war und was nicht. + +### Behoben + +- **Die Testläufe flackerten seit Phase 8 — jetzt ist die Ursache weg.** Zwei + Datenbanktests schalteten beim Aufräumen ihre Trigger mit + `ALTER TABLE ... DISABLE TRIGGER` ab. Das ist eine Schemaänderung: sie gilt + für die ganze Datenbank und für alle. Vitest fährt die Testdateien parallel + — während die eine Datei abräumte, prüfte die andere gerade, dass genau + dieser Trigger greift, und sah keinen. + + Das erklärt rückwirkend eine ganze Reihe von Fehlschlägen, die als + „Flackern" abgetan wurden und keines waren. Statt der Schemaänderung wird + jetzt `SET LOCAL session_replication_role = 'replica'` benutzt: dieselbe + Wirkung, aber nur in der eigenen Transaktion. Fünf Läufe hintereinander + sauber. + +- **Ein Datenbanktest räumte nur am Ende auf.** Brach ein Lauf ab, scheiterte + der nächste an einer Eindeutigkeit, die mit dem Geprüften nichts zu tun + hatte. Er räumt jetzt auch am Anfang auf. + +### Zum Stand der Freigabevoraussetzungen + +| Voraussetzung | Stand | +|---|---| +| Datenübernahme | **entfällt** — die Wehr pflegt von Hand ein | +| Sicherungskonzept, einmal wiederhergestellt | **erledigt** (Phase 11) | +| Löschkonzept nach DSGVO | **technisch vollständig** (Phase 11), Abnahme steht aus | +| Schulung | **steht aus**, Grundlage ist die Kurzanleitung | +| Atemschutzhinweis | **bewusst stehen gelassen** — FLORIAN bleibt ergänzend | + +--- + ## [0.12.0] — 2026-09-05 Phase 11: Freigabereife. Die drei Software-Voraussetzungen für 1.0.0 — diff --git a/README.md b/README.md index 6516302..1fad810 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,14 @@ # FLORIAN — Feuerwehr-Verwaltungsplattform +**Version 1.0.0** — freigegeben für den Echtbetrieb. + Selbstgehostete Verwaltungssoftware für Freiwillige Feuerwehren. Läuft im Gerätehaus, nicht in der Cloud. Keine Telemetrie, keine externen CDNs. +> **Wer FLORIAN benutzen und nicht bauen will:** +> [**Kurzanleitung**](docs/KURZANLEITUNG.md) — alles Wichtige nach Rollen +> sortiert, auf ein paar Seiten. + ## Schnellstart ```bash @@ -43,7 +49,8 @@ Vollständige Planung mit Aufwänden: [docs/ROADMAP.md](docs/ROADMAP.md) und | Datei | Inhalt | |---|---| -| [docs/ANLEITUNG.md](docs/ANLEITUNG.md) | Bedienung mit Bildschirmfotos | +| [docs/KURZANLEITUNG.md](docs/KURZANLEITUNG.md) | **Der Einstieg.** Nach Rollen sortiert, ein paar Seiten | +| [docs/ANLEITUNG.md](docs/ANLEITUNG.md) | Die vollständige Bedienung mit Bildschirmfotos, zum Nachschlagen | | [docs/ROADMAP.md](docs/ROADMAP.md) | Phasenplan und was jeweils entsteht | | [docs/AUFWANDSSCHAETZUNG.md](docs/AUFWANDSSCHAETZUNG.md) | Aufwand je Phase, Schätzung gegen Ist | | [docs/DEVLOG.md](docs/DEVLOG.md) | Entwicklungstagebuch mit Zeiterfassung | @@ -1655,6 +1662,30 @@ ohne Traefik. In Produktion terminiert Traefik TLS und verteilt auf Der App-Container wendet beim Start ausstehende Migrationen an; der Worker tut das bewusst nicht, damit nicht zwei Prozesse gleichzeitig migrieren. +### Aktualisieren + +```bash +./update.sh --pruefen # nachsehen, ob es etwas Neues gibt +./update.sh # einspielen +``` + +Der Ablauf ist so gebaut, dass er an jeder Stelle abbrechen darf, ohne eine +halb aktualisierte Anlage zu hinterlassen: + +1. Nachsehen, ob überhaupt etwas Neues da ist — sonst endet es hier. +2. **Sichern.** Vor allem anderen; ein Update ist der häufigste Anlass, eine + Sicherung zu brauchen. +3. Neue Fassung holen und die Abbilder bauen. Schlägt das fehl, läuft die alte + Fassung unverändert weiter — es wurde noch nichts umgeschaltet. +4. Umschalten. Der App-Container spielt dabei die Migrationen ein. +5. Nachsehen, ob die Anwendung wieder antwortet. Tut sie es nach zweieinhalb + Minuten nicht, stehen die letzten Protokollzeilen da und die Befehle, mit + denen der vorherige Stand zurückkommt. + +Vorher prüft das Skript, dass Docker, git und die `.env` da sind und dass im +Verzeichnis keine unverbuchten Änderungen liegen. `--ohne-sicherung` gibt es +für Testinstallationen; im Betrieb gehört es nicht benutzt. + ## Datenschutz - Keine Telemetrie, keine externen CDNs, keine Google Fonts. Es werden nur diff --git a/docs/ANLEITUNG.md b/docs/ANLEITUNG.md index cb90676..5469d95 100644 --- a/docs/ANLEITUNG.md +++ b/docs/ANLEITUNG.md @@ -1,7 +1,11 @@ # Anleitung -Bedienung von FLORIAN, Stand Phase 11 (Version 0.12.0). Die Bilder zeigen die -Beispielwehr aus dem Seed und werden mit `npm run screenshots` neu erzeugt. +Bedienung von FLORIAN, Version 1.0.0. Die Bilder zeigen die Beispielwehr aus +dem Seed und werden mit `npm run screenshots` neu erzeugt. + +> **Das hier ist zum Nachschlagen, nicht zum Durchlesen.** Wer anfangen will, +> nimmt die [Kurzanleitung](KURZANLEITUNG.md) — nach Rollen sortiert, auf ein +> paar Seiten. --- diff --git a/docs/DEVLOG.md b/docs/DEVLOG.md index 5831627..241628b 100644 --- a/docs/DEVLOG.md +++ b/docs/DEVLOG.md @@ -24,7 +24,107 @@ Die aufgewendete Zeit wandert nach jedem Abschnitt in die Spalte | Phase 9 — Kassenverwaltung | 4:10 h | 1 | 04.09.2026 | | Phase 10 — Mandantenfähigkeit | 4:20 h | 1 | 05.09.2026 | | Phase 11 — Freigabereife | 2:40 h | 1 | 05.09.2026 | -| **Summe** | **35:30 h** | **14** | | +| 1.0.0 — Freigabe | 0:50 h | 1 | 06.09.2026 | +| **Summe** | **36:20 h** | **15** | | + +--- + +## 2026-09-06 · 1.0.0 — Freigabe + +**09:20 – 10:10 · 0:50 h · Version 1.0.0** + +### Was entstanden ist + +Kein Modul. Zwei Dinge, die zum Betrieb gehören, und eine Entscheidung. + +| Was | Wofür | +|---|---| +| `update.sh` | Aktualisieren auf einem Linux-Server, in einem Befehl | +| `docs/KURZANLEITUNG.md` | Der Einstieg für die Wehr, nach Rollen sortiert | +| Version 1.0.0 | Ab hier semantische Versionierung | + +### Entscheidungen + +**Das Update-Skript sichert, bevor es irgendetwas anfasst.** Ein Update ist +der häufigste Anlass, eine Sicherung zu brauchen — und der Moment, in dem +niemand daran denkt. `--ohne-sicherung` gibt es, aber mit einer Warnung und +nicht als Vorgabe. + +**Die Reihenfolge ist so gewählt, dass ein Abbruch harmlos bleibt.** Holen und +Bauen passieren, bevor umgeschaltet wird: schlägt der Bau fehl, läuft die alte +Fassung unverändert weiter. Erst danach wird umgeschaltet, und ab da nennt die +Fehlerbehandlung die Befehle für den Rückweg. + +**Die Kurzanleitung ist aufgabenorientiert, nicht modulorientiert.** Die große +Anleitung hat 63 Kapitel und ist nach Funktionsbereichen sortiert — richtig +zum Nachschlagen, unbrauchbar zum Anfangen. Die Kurzanleitung fragt stattdessen +„was willst du tun": ein Gerät prüfen, einen Kontoauszug einlesen, einen +Termin absagen. + +**Sie erklärt bei jedem heiklen Punkt das Warum.** Warum ein abgeschlossenes +Prüfprotokoll nicht änderbar ist, warum gegengebucht statt korrigiert wird, +warum der Atemschutzhinweis stehen bleibt. Wer den Grund kennt, sucht nicht +nach dem Weg drumherum. + +**Die Roadmap hakt nicht alles ab.** Zwei Freigabevoraussetzungen sind +organisatorisch und stehen aus — die Abnahme des Löschkonzepts und die +Schulung. Sie stehen mit ihrem Stand dort, statt stillschweigend als erledigt +zu gelten. Eine Freigabe, die abhakt, was nicht geprüft wurde, ist keine. + +### Wo es gehakt hat + +**Die Ursache der Testflackerei gefunden — nach elf Phasen.** + +Beim abschließenden Durchlauf fielen wieder einmal fünf, dann sechs, dann ein +Test aus, in wechselnder Besetzung. Dieses Mal habe ich nicht weitergemacht, +sondern fünf Läufe hintereinander mitgeschrieben: betroffen waren immer +Zusicherungen über Trigger — „Wiki-Fassungen bleiben", „Mandantentrennung", +„abgeschlossene Prüfprotokolle". + +`ki-datenbank.test.ts` und `atemschutz-datenbank.test.ts` schalteten beim +Aufräumen ihre Trigger mit `ALTER TABLE ... DISABLE TRIGGER USER` ab. Das ist +eine Schemaänderung: sie gilt für die ganze Datenbank und für alle. Vitest +fährt die Testdateien parallel — während also die eine Datei abräumte, prüfte +die andere gerade, dass genau dieser Trigger greift, und sah keinen. + +`SET LOCAL session_replication_role = 'replica'` bewirkt dasselbe, aber nur in +der eigenen Transaktion. Danach: fünf Läufe hintereinander 601 grün. + +Rückwirkend erklärt das eine Reihe von Ausfällen, die ich als Flackern +abgetan habe — sie waren keines. Die Lehre ist unbequem: „läuft einzeln durch, +also Umgebung" ist eine bequeme Erklärung und oft die falsche. + +| Weiteres | Ursache | Lösung | +|---|---|---| +| Ein Datenbanktest ließ sich nur einmal ausführen | `mandanten-datenbank.test.ts` räumte nur am Ende auf; ein abgebrochener Lauf legte den nächsten lahm | Räumt jetzt auch am Anfang auf — dasselbe Muster wie in Phase 10 bei `ki-datenbank` | + +### Abweichung von der Vorgabe + +**`update.sh` ist nicht auf einem Linux-Server erprobt.** Entwickelt wurde +unter Windows; geprüft sind die Syntax (`bash -n`), die Hilfe, die +Argumentbehandlung und der Wächter gegen unverbuchte Änderungen — alles unter +Git Bash. Der eigentliche Ablauf (fetch, build, up, Gesundheitsprüfung) läuft +erst dort, wo FLORIAN wirklich steht. + +Das ist keine Nachlässigkeit, sondern die Grenze dieser Umgebung. Beim ersten +echten Update gehört `./update.sh --pruefen` vorangestellt: es holt nichts und +ändert nichts, zeigt aber, ob Docker, git und die `.env` gefunden werden. + +### Nachweise + +- `npm run typecheck` — ohne Ausgabe +- `npm run lint` — ohne Befund +- `npm run test` — 601 Tests grün +- `bash -n update.sh` — Syntax in Ordnung +- `./update.sh --help` und `--quatsch` — Ausgabe und Ablehnung wie vorgesehen +- Der Wächter gegen unverbuchte Änderungen greift (an einem schmutzigen + Arbeitsverzeichnis geprüft) +- Alle acht Bilder der Kurzanleitung existieren +- `npm run test` **fünfmal hintereinander** — jedes Mal 601 grün. Das war + nach elf Phasen der erste Lauf, bei dem ich das behaupten kann +- `npm run test:e2e` — **268 grün, kein Fehlschlag, kein Flackern.** Ebenfalls + der erste vollständig saubere Durchlauf in diesem Projekt +- `npm run build` — sauber --- diff --git a/docs/KURZANLEITUNG.md b/docs/KURZANLEITUNG.md new file mode 100644 index 0000000..c6e76d6 --- /dev/null +++ b/docs/KURZANLEITUNG.md @@ -0,0 +1,283 @@ +# FLORIAN — Kurzanleitung + +Alles, was man am Anfang braucht, auf ein paar Seiten. Nach Rollen sortiert: +such dir den Abschnitt, der zu dir gehört, und lies den Rest später. + +Ausführlich steht alles in der [Anleitung](ANLEITUNG.md) — 63 Kapitel, zum +Nachschlagen, nicht zum Durchlesen. + +--- + +## Für alle + +### Anmelden + +FLORIAN läuft im Browser. Die Adresse bekommst du vom Kommandanten. + +![Anmeldung](screenshots/01-anmeldung-light.png) + +Nach der Anmeldung landest du auf der Übersicht: was ansteht, was fällig ist, +was gemeldet wurde. + +**Das Passwort gehört nach der ersten Anmeldung geändert.** Oben rechts auf +deinen Namen. + +### Was du überall findest + +| Links in der Leiste | Wofür | +|---|---| +| **Übersicht** | Die Startseite: Fristen, Mängel, nächste Termine | +| **Termine** | Übungsabende, Lehrgänge, Versammlungen — mit Rückmeldung | +| **Dienstausweis** | Dein Ausweis mit QR-Code, auch fürs Handy | +| **Wissen** | Merkblätter und Dienstanweisungen der Wehr | + +Du siehst nur, was zu deiner Rolle gehört. Fehlt dir etwas, sag dem +Kommandanten Bescheid — Rollen werden dort vergeben. + +### Zu einem Termin zurückmelden + +**Termine** öffnen, den Termin anklicken, **Zusagen** oder **Absagen**. Fertig. +Wer absagt, kann einen Grund angeben; das hilft dem Ausbilder bei der Planung. + +### Am Terminal im Gerätehaus + +Am Wandtablet meldest du dich mit **PIN oder Ausweiskarte** an — nicht mit +E-Mail und Passwort. Deine PIN bekommst du vom Kommandanten. + +![Terminal](screenshots/25-kiosk-kacheln-light.png) + +Dort geht: anmelden zum Dienst, einen Mangel melden, eine Fahrt eintragen, +Schutzkleidung zur Reinigung geben. + +--- + +## Für den Gerätewart + +Das ist der Bereich, in dem FLORIAN am meisten abnimmt. + +### Der wichtigste Bildschirm: Prüfungen + +![Fristen](screenshots/11-fristen-light.png) + +Unter **Prüfungen** steht, was wann fällig wird. Rot heißt überfällig, gelb +heißt demnächst. Die Fristen rechnet FLORIAN selbst aus den Prüfintervallen +der Gerätetypen aus — du musst nichts nachhalten. + +### Ein Gerät prüfen + +1. **Prüfungen** → das fällige Gerät anklicken → **Prüfung durchführen**. +2. Die Checkliste durchgehen. Sie kommt aus der Wartungsvorlage des Gerätetyps. +3. Ergebnis eintragen, unterschreiben, abschließen. + +![Prüfprotokoll](screenshots/18-pruefprotokoll-light.png) + +Danach entsteht ein **Prüfprotokoll als PDF** — das ist der Nachweis gegenüber +der Unfallkasse. + +> **Achtung:** Ein abgeschlossenes Protokoll lässt sich nicht mehr ändern. Das +> ist Absicht — ein nachträglich änderbarer Nachweis wäre keiner. Ist etwas +> falsch, wird eine neue Prüfung erfasst. + +### Ein Gerät anlegen + +**Geräte** → **Gerät anlegen**. Pflicht sind nur Inventarnummer und +Bezeichnung; der Rest kann nachkommen. + +Wähle möglichst einen **Gerätetyp** — daran hängen Prüfintervall und +Lebensdauer, und ohne Typ rechnet FLORIAN keine Frist. + +### Einen Mangel bearbeiten + +Mängel kommen meist vom Terminal — jemand meldet sie beim Einrücken. + +**Mängel** → anklicken → jemandem zuweisen → nach der Behebung **erledigen**. +Ein Gerät mit einem schweren Mangel wird automatisch gesperrt. + +### Was noch dazugehört + +| Wo | Wofür | +|---|---| +| **Rückrufe** | Herstellerrückrufe: welche eurer Geräte betroffen sind | +| **Lebensdauer** | Helme, Schläuche, Schutzkleidung mit Höchstalter | +| **Fahrzeuge** | Beladung nach Geräteräumen | + +--- + +## Für den Atemschutzgerätewart + +> **Der wichtigste Satz zuerst:** Die Überwachungstafel am Fahrzeug bleibt +> führend. FLORIAN ergänzt sie, ersetzt sie nicht. Das steht auch dauerhaft +> über jeder Ansicht, und es soll dort stehen. + +### Wer darf unter Pressluftatmer? + +![Atemschutz](screenshots/31-atemschutz-light.png) + +**Atemschutz** zeigt jeden Träger mit seinem Stand: G26, Belastungsübung, +Einsatzübung. Wer eine der Voraussetzungen nicht erfüllt, steht rot da — mit +Klartext, woran es liegt, nicht nur mit einer Farbe. + +### Eine G26 eintragen + +**Atemschutz** → **Nachweise** → Person wählen → G26 erfassen. Untersuchungs­datum, +Ergebnis und Gültigkeit reichen; den Befund speichert FLORIAN bewusst nicht. + +Dasselbe für Belastungsübungen. + +### Eine Überwachung führen + +**Atemschutz** → **Überwachung eröffnen**. Trupp zusammenstellen, Druckwerte +in Intervallen eintragen, Rückzugsdruck im Blick behalten. FLORIAN rechnet den +Rückzugsdruck und warnt, bevor es knapp wird. + +![Überwachung](screenshots/33-atemschutz-ueberwachung-light.png) + +Nach dem Ende entsteht ein vollständiges Protokoll als PDF. + +> Solange ein Trupp im Einsatz ist, lässt sich die Überwachung nicht beenden. + +--- + +## Für den Kommandanten + +### Mitglieder und Rollen + +**Mitglieder** → Person anklicken → Rollen vergeben. + +Neun Rollen, jede mit festem Rechteumfang. Die wichtigsten: Gerätewart, +Atemschutzgerätewart, Kassier, Schriftführer, Ausbilder. + +**Jemand kommt nicht hinein?** Dann hat die Person eine Akte, aber kein Konto. +Konten verwaltet, wer den Server betreibt — unter *Wehren und Konten*. + +### Einen Einsatz erfassen + +**Einsätze** → **Einsatz anlegen**. Die sechs Zeitstempel (Alarmierung, +Ausrücken, Eintreffen, Einsatzende, Einrücken, Einsatzbereitschaft), die +beteiligten Fahrzeuge und die Mannschaft. + +![Einsatz](screenshots/22-einsatz-light.png) + +Daraus entsteht der **Einsatzbericht als PDF** und die Statistik fürs Jahr. + +### Kostenersatz abrechnen + +Fehlalarm einer Brandmeldeanlage, Öleinsatz mit Verursacher, vermieteter +Schulungsraum: **Kasse** → **Rechnungen** → *Rechnung schreiben*. Die Nummer +vergibt FLORIAN fortlaufend. + +### Wenn jemand Auskunft über seine Daten verlangt + +Kommt selten vor, hat aber eine Frist von einem Monat. + +**Mitglieder** → Person → **Datenauskunft** → *Auskunft erstellen*. Daraus +entsteht ein PDF mit allem, was über die Person gespeichert ist. Ausdrucken +und aushändigen, fertig. + +--- + +## Für den Kassier + +![Kasse](screenshots/45-kasse-light.png) + +### Buchen + +**Kasse** → **Buchungen** → *Buchung erfassen*. Der Betrag ist immer positiv; +ob Einnahme oder Ausgabe, sagen die beiden Knöpfe daneben. + +FLORIAN schlägt eine Kategorie vor — meist die richtige, weil er nachsieht, +wie ihr dieselbe Sache zuletzt gebucht habt. + +### Kontoauszug einlesen + +Statt fünfzig Umsätze abzutippen: im Online-Banking den **Umsatzexport** +herunterladen (CAMT.053 oder MT940), dann **Kasse** → **Buchungen** → +*Kontoauszug*. + +Es entstehen **Entwürfe**, keine fertigen Buchungen — durchsehen musst du sie. +Umsätze, die schon in der Kasse stehen, erkennt FLORIAN und hakt sie nicht +noch einmal ab. + +### Beiträge + +Einmal im Jahr: **Kasse** → **Beiträge** → *Beitragslauf*. Vorher steht da, wie +viele Beiträge entstehen — und wer keinen passenden Satz hat. + +Zahlungen hakst du in der Liste ab; dabei entsteht gleich die Buchung. + +### Festschreiben — und was das bedeutet + +Eine neue Buchung ist erst einmal ein **Entwurf** und änderbar. Mit +*Festschreiben* wird sie unveränderbar. + +Ist danach etwas falsch, wird **gegengebucht**: eine zweite Buchung in die +andere Richtung, mit Begründung. Beide bleiben stehen. Das sieht nach mehr +Aufwand aus und ist genau der Punkt — eine verschwundene Buchung kann ein +Kassenprüfer nicht prüfen, eine berichtigte schon. + +### Kassenprüfung + +**Kasse** → **Abschluss** → *Kassenprüfung anlegen*. Links steht der Bestand +laut Buch, rechts trägst du ein, was gezählt wurde. + +Bleibt eine Differenz, muss sie erklärt werden — sonst lässt sich der Abschluss +nicht festschreiben. In fünf Jahren weiß sonst niemand mehr, woher sie kam. + +Danach entsteht der **Kassenprüfbericht als PDF** mit Unterschriftsfeldern. + +--- + +## Für den, der den Server betreibt + +### Sichern + +```bash +npm run sicherung +``` + +Sichert Datenbank **und** Dateien. Das Ergebnis gehört auf ein anderes Gerät — +eine Sicherung auf derselben Festplatte hilft gegen einen Festplattenfehler +nicht. + +### Einmal im Jahr proben + +```bash +npm run wiederherstellen -- --quelle sicherungen/ +``` + +So passiert nichts, es wird nur angezeigt, was geschähe. Erst mit `--wirklich` +wird zurückgespielt. + +> Eine Sicherung, die nie zurückgespielt wurde, ist eine Vermutung. Der Ordner +> kann leer sein, der Auszug abgeschnitten — man erfährt es sonst an dem Tag, +> an dem man es nicht erfahren will. + +### Aktualisieren + +```bash +./update.sh --pruefen # nachsehen, ob es etwas Neues gibt +./update.sh # einspielen +``` + +Das Skript sichert von sich aus, bevor es etwas anfasst, und sagt bei einem +Fehler, wie der vorherige Stand zurückkommt. + +--- + +## Wenn etwas nicht geht + +| Was | Woran es meist liegt | +|---|---| +| **Anmeldung geht nicht** | Nach fünf Fehlversuchen ist das Konto fünf Minuten gesperrt. Kurz warten. | +| **Ein Menüpunkt fehlt** | Die Rolle fehlt. Der Kommandant vergibt sie unter *Mitglieder*. | +| **Ein PDF entsteht nicht** | Es wird im Hintergrund erzeugt; nach ein paar Sekunden die Seite neu laden. | +| **Das Terminal meldet niemanden an** | PIN falsch, oder das Tablet ist nicht mehr eingerichtet. | +| **Eine Buchung lässt sich nicht ändern** | Sie ist festgeschrieben. Gegenbuchen. | + +Steht die Antwort nicht dabei: [Anleitung, letztes Kapitel](ANLEITUNG.md). + +--- + +*FLORIAN läuft auf einem Server, den die Feuerwehr selbst betreibt. Es gibt +keine Telemetrie, keine Cloud und keine Weitergabe an Dritte — die Daten +bleiben im Gerätehaus.* diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index e7c8fe2..50a58f2 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -21,7 +21,7 @@ Phase 8 ████████████████████ fertig Phase 9 ████████████████████ fertig 0.10.0 Phase 10 ████████████████████ fertig 0.11.0 Phase 11 ████████████████████ fertig 0.12.0 -Freigabe ░░░░░░░░░░░░░░░░░░░░ offen 1.0.0 +Freigabe ████████████████████ fertig 1.0.0 ``` --- @@ -310,17 +310,37 @@ also genau die Probe, wegen der 1.0.0 sie verlangt. **Die Wehr kann jetzt:** eine Selbstauskunft erteilen, ihren Bestand sichern und ihn nachweislich zurückholen. -## 1.0.0 — Freigabe +## 1.0.0 — Freigabe · am 06.09.2026 erteilt Kein neues Modul, sondern eine Entscheidung: die Wehr setzt FLORIAN im -Echtbetrieb ein. Voraussetzungen: +Echtbetrieb ein. -- Datenübernahme aus der bisherigen Verwaltung abgeschlossen und abgeglichen -- ~~Sicherungskonzept steht und ist einmal wiederhergestellt worden~~ — mit Phase 11 erledigt -- Schulung von Kommandant, Gerätewart und Atemschutzgerätewart erfolgt -- Löschkonzept nach DSGVO abgenommen (die Auskunftsseite dazu gibt es seit Phase 11) -- Der Hinweis zur analogen Atemschutzüberwachung wird bewusst und - dokumentiert abgeschaltet — oder bewusst stehen gelassen +**Damit das nicht zur Formalie wird, hier der Stand jeder Voraussetzung — auch +der offenen.** Eine Freigabe, die alles abhakt, was ohnehin nicht geprüft +wurde, ist keine. + +| Voraussetzung | Stand | +|---|---| +| Datenübernahme aus der bisherigen Verwaltung | **entfällt.** Die Wehr pflegt den Bestand von Hand ein; es gibt kein Altsystem, aus dem übernommen würde. Für Mitglieder, Fahrzeuge und Geräte steht der Import-Assistent bereit, falls sich das ändert. | +| Sicherungskonzept steht und ist einmal wiederhergestellt worden | **erledigt** mit Phase 11. Die Probe hat dabei einen Fehler gefunden, der sonst erst im Notfall aufgefallen wäre. | +| Löschkonzept nach DSGVO | **technisch vollständig** seit Phase 11: Auskunft nach Art. 15, Anonymisierung im Aufbewahrungslauf, konfigurierbare Fristen. Die Abnahme durch die Wehr ist ein organisatorischer Schritt und steht aus. | +| Schulung von Kommandant, Gerätewart und Atemschutzgerätewart | **steht aus.** Sie läuft nach der Freigabe; Grundlage ist die [Kurzanleitung](KURZANLEITUNG.md). | +| Hinweis zur analogen Atemschutzüberwachung | **bewusst stehen gelassen.** `ATEMSCHUTZ_HINWEIS=true`. FLORIAN bleibt damit ergänzend; die Tafel am Fahrzeug ist führend. Das Abschalten verlangt ohnehin einen Kommandantenbeschluss samt Fundstelle. | + +Die beiden offenen Punkte sind **organisatorisch, nicht technisch**: an der +Software hängt nichts davon ab. Sie stehen hier, damit später nachlesbar ist, +was zum Zeitpunkt der Freigabe erledigt war und was nicht. + +### Was 1.0.0 bedeutet — und was nicht + +**Bedeutet:** Der Funktionsumfang ist vollständig, die Datenbank ist stabil, +Migrationen sind aufwärtskompatibel, und ab hier gilt semantische +Versionierung. Ein Update bricht nichts, ohne dass es in der Hauptnummer +steht. + +**Bedeutet nicht:** dass keine Fehler mehr da sind. Es bedeutet, dass die +Wehr echte Daten hineingeben kann, ohne befürchten zu müssen, sie beim +nächsten Update zu verlieren. --- diff --git a/package.json b/package.json index 956a2f1..f73958a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "florian", - "version": "0.12.0", + "version": "1.0.0", "private": true, "description": "FLORIAN — Feuerwehr-Verwaltungsplattform", "engines": { diff --git a/tests/unit/atemschutz-datenbank.test.ts b/tests/unit/atemschutz-datenbank.test.ts index 5dd9aad..917d659 100644 --- a/tests/unit/atemschutz-datenbank.test.ts +++ b/tests/unit/atemschutz-datenbank.test.ts @@ -77,11 +77,15 @@ afterAll(async () => { // Protokollzeilen lassen sich nicht loeschen — die Trigger sitzen auf // den Tabellen. Fuer das Aufraeumen der Testdaten werden sie kurz // abgeschaltet; im Betrieb passiert das nie. - await tx.$executeRawUnsafe('ALTER TABLE "druckmessung" DISABLE TRIGGER USER'); - await tx.$executeRawUnsafe('ALTER TABLE "atemschutz_ereignis" DISABLE TRIGGER USER'); + // Trigger fuer das Aufraeumen stilllegen — sitzungslokal. + // + // NICHT ueber `ALTER TABLE ... DISABLE TRIGGER`: das ist eine + // Schemaänderung und gilt fuer alle. Vitest faehrt die Testdateien + // parallel, und waehrend hier abgeraeumt wurde, pruefte anderswo + // eine Datei, dass genau dieser Trigger greift — und sah keinen. + // `session_replication_role` wirkt nur in dieser Transaktion. + await tx.$executeRawUnsafe("SET LOCAL session_replication_role = 'replica'"); await tx.organisation.deleteMany({ where: { slug: SLUG } }); - await tx.$executeRawUnsafe('ALTER TABLE "druckmessung" ENABLE TRIGGER USER'); - await tx.$executeRawUnsafe('ALTER TABLE "atemschutz_ereignis" ENABLE TRIGGER USER'); }); } await Promise.allSettled([app.$disconnect(), system.$disconnect()]); diff --git a/tests/unit/ki-datenbank.test.ts b/tests/unit/ki-datenbank.test.ts index 06c3246..22db6e4 100644 --- a/tests/unit/ki-datenbank.test.ts +++ b/tests/unit/ki-datenbank.test.ts @@ -81,12 +81,15 @@ afterAll(async () => { if (erreichbar) { await system.$transaction(async (tx) => { await tx.$executeRaw`SELECT set_config('app.audit_bereinigung', 'an', true)`; - // Der Unveränderlichkeits-Trigger sitzt auf ki_vorgang. Für das - // Aufräumen der Testdaten wird er kurz abgeschaltet; im Betrieb - // passiert das nie. - await tx.$executeRawUnsafe('ALTER TABLE "ki_vorgang" DISABLE TRIGGER USER'); + // Trigger fuer das Aufraeumen stilllegen — sitzungslokal. + // + // NICHT ueber `ALTER TABLE ... DISABLE TRIGGER`: das ist eine + // Schemaänderung und gilt fuer alle. Vitest faehrt die Testdateien + // parallel, und waehrend hier abgeraeumt wurde, pruefte anderswo + // eine Datei, dass genau dieser Trigger greift — und sah keinen. + // `session_replication_role` wirkt nur in dieser Transaktion. + await tx.$executeRawUnsafe("SET LOCAL session_replication_role = 'replica'"); await tx.organisation.deleteMany({ where: { slug: SLUG } }); - await tx.$executeRawUnsafe('ALTER TABLE "ki_vorgang" ENABLE TRIGGER USER'); }); } await Promise.allSettled([app.$disconnect(), system.$disconnect()]); diff --git a/tests/unit/mandanten-datenbank.test.ts b/tests/unit/mandanten-datenbank.test.ts index 23d3ce6..fd6cdda 100644 --- a/tests/unit/mandanten-datenbank.test.ts +++ b/tests/unit/mandanten-datenbank.test.ts @@ -43,6 +43,15 @@ beforeAll(async () => { return; } + // Erst aufraeumen, dann anlegen. + // + // Aufraeumen nur am Ende reicht nicht: bricht ein Lauf ab — abgebrochene + // Sitzung, Fehler in einer anderen Datei, geschlossenes Terminal —, bleiben + // die Datensaetze stehen und der naechste Lauf scheitert an einer + // Eindeutigkeit, die mit dem Geprueften nichts zu tun hat. Das kostet dann + // zehn Minuten Fehlersuche an der falschen Stelle. + await aufraeumen(); + const a = await system.organisation.upsert({ where: { slug: SLUG_A }, update: {}, @@ -91,14 +100,17 @@ beforeAll(async () => { konto = benutzer.id; }); +/** Alles entfernen, was dieser Test anlegt. */ +async function aufraeumen(): Promise { + await system.$transaction(async (tx) => { + await tx.$executeRaw`SELECT set_config('app.audit_bereinigung', 'an', true)`; + await tx.benutzer.deleteMany({ where: { email: { endsWith: "@test-mandant.de" } } }); + await tx.organisation.deleteMany({ where: { slug: { in: [SLUG_A, SLUG_B] } } }); + }); +} + afterAll(async () => { - if (erreichbar) { - await system.$transaction(async (tx) => { - await tx.$executeRaw`SELECT set_config('app.audit_bereinigung', 'an', true)`; - await tx.benutzer.deleteMany({ where: { email: { endsWith: "@test-mandant.de" } } }); - await tx.organisation.deleteMany({ where: { slug: { in: [SLUG_A, SLUG_B] } } }); - }); - } + if (erreichbar) await aufraeumen(); await system.$disconnect(); }); diff --git a/update.sh b/update.sh new file mode 100755 index 0000000..9bb83ef --- /dev/null +++ b/update.sh @@ -0,0 +1,234 @@ +#!/usr/bin/env bash +# ============================================================================= +# FLORIAN aktualisieren. +# +# ./update.sh neue Fassung holen und einspielen +# ./update.sh --pruefen nur nachsehen, ob es etwas Neues gibt +# ./update.sh --ohne-sicherung für Testinstallationen +# +# Der Ablauf ist so gebaut, dass er an jeder Stelle abbrechen darf, ohne die +# Wehr mit einer halb aktualisierten Anlage zurückzulassen: +# +# 1. Nachsehen, ob überhaupt etwas Neues da ist. +# 2. SICHERN — Datenbank und Dateien. Vor allem anderen. +# 3. Neue Fassung holen und Abbilder bauen. +# 4. Umschalten. Der App-Container spielt dabei die Migrationen ein. +# 5. Nachsehen, ob die Anwendung wieder antwortet. +# +# Geht Schritt 3 schief, läuft die alte Fassung weiter — es wurde nichts +# angefasst. Geht Schritt 4 oder 5 schief, steht in der Ausgabe, mit welchem +# Befehl sich der vorherige Stand zurückholen lässt. +# +# Voraussetzungen: Linux, Docker mit Compose-Erweiterung, git, ein .env im +# selben Verzeichnis. +# ============================================================================= +set -Eeuo pipefail + +VERZEICHNIS="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +cd "$VERZEICHNIS" + +COMPOSE=(docker compose -f docker-compose.yml) +NUR_PRUEFEN=false +OHNE_SICHERUNG=false + +for argument in "$@"; do + case "$argument" in + --pruefen) NUR_PRUEFEN=true ;; + --ohne-sicherung) OHNE_SICHERUNG=true ;; + -h|--help) + sed -n '3,20p' "$0" | sed 's/^# \{0,1\}//' + exit 0 + ;; + *) + echo "Unbekannte Angabe: $argument" >&2 + echo "Bekannt sind: --pruefen, --ohne-sicherung, --help" >&2 + exit 2 + ;; + esac +done + +# --- Ausgabe ---------------------------------------------------------------- +# Farben nur, wenn wirklich ein Terminal dranhängt. In einem Cron-Protokoll +# sind Steuerzeichen nur Müll. +if [ -t 1 ]; then + ROT=$'\033[31m'; GRUEN=$'\033[32m'; GELB=$'\033[33m'; FETT=$'\033[1m'; AUS=$'\033[0m' +else + ROT=''; GRUEN=''; GELB=''; FETT=''; AUS='' +fi + +schritt() { echo; echo "${FETT}==> $*${AUS}"; } +hinweis() { echo " $*"; } +warnung() { echo "${GELB} $*${AUS}"; } +fehler() { echo "${ROT}FEHLER: $*${AUS}" >&2; } + +# Wo wir standen, bevor etwas verändert wurde — für die Rückkehr. +STAND_VORHER="" +SICHERUNG="" + +abbruch() { + local zeile=$1 + echo + fehler "Abbruch in Zeile $zeile." + echo + if [ -n "$STAND_VORHER" ]; then + echo "Der vorherige Stand war: $STAND_VORHER" + echo "Zurueck damit:" + echo " git checkout $STAND_VORHER" + echo " ${COMPOSE[*]} up -d --build" + fi + if [ -n "$SICHERUNG" ]; then + echo + echo "Die Sicherung von vorhin liegt in: $SICHERUNG" + echo "Zurueckspielen (ueberschreibt den Bestand):" + echo " npm run wiederherstellen -- --quelle $SICHERUNG --wirklich" + fi + exit 1 +} +trap 'abbruch $LINENO' ERR + +# --- Voraussetzungen -------------------------------------------------------- +schritt "Voraussetzungen" + +for werkzeug in git docker; do + if ! command -v "$werkzeug" >/dev/null 2>&1; then + fehler "$werkzeug ist nicht installiert." + exit 1 + fi +done + +if ! docker compose version >/dev/null 2>&1; then + fehler "Docker Compose fehlt (die Erweiterung 'docker compose', nicht docker-compose)." + exit 1 +fi + +if [ ! -f .env ]; then + fehler "Es gibt keine .env in $VERZEICHNIS." + hinweis "Fuer eine Erstinstallation: cp .env.example .env und ausfuellen." + exit 1 +fi + +# Ein Update mit unverbuchten Aenderungen wuerde sie beim Holen ueberschreiben +# oder in einem Konflikt enden. Beides mitten in einem Update ist unschoen. +if [ -n "$(git status --porcelain)" ]; then + fehler "Im Verzeichnis liegen Aenderungen, die nicht eingecheckt sind." + git status --short | head -20 + echo + hinweis "Bitte erst klaeren. Die .env gehoert nicht ins Git und stoert hier nicht." + exit 1 +fi + +hinweis "Docker, git und .env sind da." + +# --- Gibt es etwas Neues? --------------------------------------------------- +schritt "Nach einer neuen Fassung sehen" + +ZWEIG="$(git rev-parse --abbrev-ref HEAD)" +STAND_VORHER="$(git rev-parse HEAD)" + +git fetch --quiet origin "$ZWEIG" + +STAND_NEU="$(git rev-parse "origin/$ZWEIG")" + +if [ "$STAND_VORHER" = "$STAND_NEU" ]; then + hinweis "Alles aktuell — nichts zu tun." + hinweis "Stand: $(git log -1 --format='%h %s')" + exit 0 +fi + +ANZAHL="$(git rev-list --count "$STAND_VORHER..$STAND_NEU")" +hinweis "$ANZAHL neue Aenderung(en):" +git log --oneline --no-decorate "$STAND_VORHER..$STAND_NEU" | head -20 | sed 's/^/ /' + +if [ "$NUR_PRUEFEN" = true ]; then + echo + hinweis "Nur nachgesehen — es wurde nichts geaendert." + hinweis "Zum Einspielen: ./update.sh" + exit 0 +fi + +# --- Sichern ---------------------------------------------------------------- +# Vor allem anderen, und ohne Umgehung im Regelfall. Ein Update ist der +# haeufigste Anlass, eine Sicherung zu brauchen. +if [ "$OHNE_SICHERUNG" = true ]; then + schritt "Sicherung uebersprungen" + warnung "--ohne-sicherung ist gesetzt. Bei einem Fehler gibt es kein Zurueck." +else + schritt "Sichern" + + if ! command -v npm >/dev/null 2>&1; then + fehler "npm fehlt — ohne das laeuft das Sicherungsskript nicht." + hinweis "Entweder Node installieren oder bewusst mit --ohne-sicherung fahren." + exit 1 + fi + + npm run --silent sicherung + SICHERUNG="$(find sicherungen -maxdepth 1 -mindepth 1 -type d | sort | tail -1)" + hinweis "Liegt in: $SICHERUNG" +fi + +# --- Holen und bauen -------------------------------------------------------- +# Bis hierher ist nichts umgeschaltet: schlaegt der Bau fehl, laeuft die alte +# Fassung unveraendert weiter. +schritt "Neue Fassung holen" +git merge --ff-only "origin/$ZWEIG" +hinweis "Jetzt auf: $(git log -1 --format='%h %s')" + +schritt "Abbilder bauen" +hinweis "Das dauert ein paar Minuten." +"${COMPOSE[@]}" build + +# --- Umschalten ------------------------------------------------------------- +# Der App-Container spielt beim Start die ausstehenden Migrationen ein; der +# Worker tut das bewusst nicht, damit nicht zwei Prozesse gleichzeitig +# migrieren. +schritt "Umschalten" +"${COMPOSE[@]}" up -d --remove-orphans + +# --- Nachsehen -------------------------------------------------------------- +schritt "Nachsehen, ob die Anwendung antwortet" + +ADRESSE="http://localhost:3000/api/health" +if grep -q '^APP_URL=' .env; then + ADRESSE="$(grep '^APP_URL=' .env | head -1 | cut -d= -f2- | tr -d '"'"'"' ')/api/health" +fi + +hinweis "Frage $ADRESSE ..." + +GESUND=false +for versuch in $(seq 1 30); do + if curl -fsS --max-time 5 "$ADRESSE" >/dev/null 2>&1; then + GESUND=true + break + fi + sleep 5 + [ $((versuch % 6)) -eq 0 ] && hinweis " noch nicht da, warte weiter ($((versuch * 5)) s) ..." +done + +if [ "$GESUND" != true ]; then + fehler "Die Anwendung antwortet nach zweieinhalb Minuten nicht." + echo + echo "Die letzten Zeilen aus dem Protokoll:" + "${COMPOSE[@]}" logs --tail 40 app || true + echo + echo "Zurueck auf den vorherigen Stand:" + echo " git checkout $STAND_VORHER" + echo " ${COMPOSE[*]} up -d --build" + [ -n "$SICHERUNG" ] && echo " npm run wiederherstellen -- --quelle $SICHERUNG --wirklich" + exit 1 +fi + +# Ab hier ist nichts mehr rueckgaengig zu machen, was das Skript angerichtet +# haette — deshalb keine Fehlerfalle mehr, die zum Rueckbau raet. +trap - ERR + +schritt "Fertig" +echo "${GRUEN} FLORIAN laeuft auf dem Stand $(git log -1 --format='%h %s')${AUS}" +echo +hinweis "Zustand der Dienste:" +"${COMPOSE[@]}" ps --format ' {{.Service}}\t{{.Status}}' 2>/dev/null || "${COMPOSE[@]}" ps + +if [ -n "$SICHERUNG" ]; then + echo + hinweis "Die Sicherung von vorhin liegt in $SICHERUNG." + hinweis "Sie kann weg, sobald ein paar Tage nichts aufgefallen ist." +fi -- 2.54.0