Compare commits
2
Commits
main
...
30faa6cb02
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
30faa6cb02 | ||
|
|
9156dcc144 |
@@ -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
|
||||
@@ -40,3 +40,6 @@ debug.log
|
||||
# Prisma
|
||||
prisma/*.db
|
||||
prisma/*.db-journal
|
||||
|
||||
# Sicherungen bleiben lokal — sie enthalten den gesamten Bestand.
|
||||
sicherungen/
|
||||
|
||||
+172
@@ -6,6 +6,178 @@ 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 —
|
||||
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
|
||||
|
||||
@@ -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
|
||||
@@ -34,6 +40,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).
|
||||
@@ -42,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 |
|
||||
@@ -1447,9 +1455,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/<Zeitstempel>/
|
||||
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
|
||||
@@ -1523,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
|
||||
|
||||
+124
-3
@@ -1,7 +1,11 @@
|
||||
# Anleitung
|
||||
|
||||
Bedienung von FLORIAN, Stand Phase 10 (Version 0.11.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.
|
||||
|
||||
---
|
||||
|
||||
@@ -2253,7 +2257,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 |
|
||||
|---|---|---|
|
||||
|
||||
@@ -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
|
||||
|
||||
+181
-1
@@ -23,7 +23,187 @@ 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 |
|
||||
| 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
|
||||
|
||||
---
|
||||
|
||||
## 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
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||

|
||||
|
||||
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.
|
||||
|
||||

|
||||
|
||||
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
|
||||
|
||||

|
||||
|
||||
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.
|
||||
|
||||

|
||||
|
||||
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** 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. Untersuchungsdatum,
|
||||
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.
|
||||
|
||||

|
||||
|
||||
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.
|
||||
|
||||

|
||||
|
||||
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
|
||||
|
||||

|
||||
|
||||
### 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/<Ordner>
|
||||
```
|
||||
|
||||
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.*
|
||||
+49
-9
@@ -20,7 +20,8 @@ Phase 7 ████████████████████ fertig
|
||||
Phase 8 ████████████████████ fertig 0.9.0
|
||||
Phase 9 ████████████████████ fertig 0.10.0
|
||||
Phase 10 ████████████████████ fertig 0.11.0
|
||||
Freigabe ░░░░░░░░░░░░░░░░░░░░ offen 1.0.0
|
||||
Phase 11 ████████████████████ fertig 0.12.0
|
||||
Freigabe ████████████████████ fertig 1.0.0
|
||||
```
|
||||
|
||||
---
|
||||
@@ -290,17 +291,56 @@ 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.
|
||||
|
||||
## 1.0.0 — Freigabe
|
||||
## 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 · 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
|
||||
- Schulung von Kommandant, Gerätewart und Atemschutzgerätewart erfolgt
|
||||
- Löschkonzept nach DSGVO abgenommen
|
||||
- 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.
|
||||
|
||||
---
|
||||
|
||||
|
||||
+3
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "florian",
|
||||
"version": "0.11.0",
|
||||
"version": "1.0.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",
|
||||
|
||||
@@ -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 })),
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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<void> {
|
||||
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);
|
||||
});
|
||||
@@ -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<number> {
|
||||
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<void> {
|
||||
const { PrismaClient } = await import("@prisma/client");
|
||||
const prisma = new PrismaClient({
|
||||
datasources: { db: { url: process.env.DIRECT_DATABASE_URL ?? "" } },
|
||||
});
|
||||
|
||||
try {
|
||||
const rolleDa = await prisma.$queryRawUnsafe<Array<{ da: boolean }>>(
|
||||
"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<void> {
|
||||
const quelle = argument("quelle");
|
||||
if (!quelle) {
|
||||
console.error("[wiederherstellen] --quelle <Verzeichnis> 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);
|
||||
});
|
||||
@@ -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
|
||||
</Link>
|
||||
</Button>
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<h1 className="text-2xl font-semibold tracking-tight">{personAnrede(person)}</h1>
|
||||
|
||||
{/* 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") && (
|
||||
<Datenauskunft
|
||||
personId={person.id}
|
||||
personName={`${person.vorname} ${person.nachname}`}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-1 flex flex-wrap items-center gap-2">
|
||||
{person.rollen.map((zuweisung) => (
|
||||
<Badge key={zuweisung.rolle.schluessel} variant="secondary">
|
||||
|
||||
@@ -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<AppRouter>["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<Ergebnis | null>(null);
|
||||
const [meldung, setMeldung] = React.useState<string | null>(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 (
|
||||
<>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setOffen(true);
|
||||
setErgebnis(null);
|
||||
}}
|
||||
>
|
||||
<ShieldCheck aria-hidden />
|
||||
Datenauskunft
|
||||
</Button>
|
||||
|
||||
<Dialog
|
||||
open={offen}
|
||||
onOpenChange={(zustand) => {
|
||||
if (!zustand) {
|
||||
setOffen(false);
|
||||
setErgebnis(null);
|
||||
setMeldung(null);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DialogContent className="max-h-[90vh] overflow-y-auto sm:max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Datenauskunft nach Art. 15 DSGVO</DialogTitle>
|
||||
<DialogDescription>
|
||||
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.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{meldung && <Fehlermeldung meldung={meldung} kennung="auskunftsfehler" />}
|
||||
|
||||
{!ergebnis && (
|
||||
<div className="space-y-3 text-sm">
|
||||
<p>
|
||||
Die Auskunft enthält Stammdaten, Rollen, Qualifikationen, Einsatz- und
|
||||
Übungsbeteiligung, Anwesenheiten, Fahrten, Beiträge, Benachrichtigungswege und
|
||||
die arbeitsmedizinische Vorsorge.
|
||||
</p>
|
||||
<p className="rounded-md bg-[var(--muted)] p-3">
|
||||
<strong>Der Vorgang wird protokolliert.</strong> Dass eine Auskunft erteilt
|
||||
wurde, gehört festgehalten — sie legt alles offen, was über einen Menschen
|
||||
gespeichert ist.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{ergebnis && (
|
||||
<div className="space-y-4">
|
||||
<div className="rounded-md border border-[var(--einsatzbereit)] bg-[var(--einsatzbereit)]/10 p-3 text-sm">
|
||||
Auskunft erstellt: <strong>{ergebnis.umfang} Angaben</strong> in{" "}
|
||||
{ergebnis.auskunft.abschnitte.length} Abschnitten. Der Vorgang steht im
|
||||
Protokoll.
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button size="sm" asChild>
|
||||
<a href={`/api/dateien/${ergebnis.dateiId}`} target="_blank">
|
||||
<FileText aria-hidden />
|
||||
PDF herunterladen
|
||||
</a>
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" onClick={jsonHerunterladen}>
|
||||
<Download aria-hidden />
|
||||
Als JSON (Art. 20)
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
{ergebnis.auskunft.abschnitte.map((abschnitt) => (
|
||||
<Card key={abschnitt.titel}>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm">
|
||||
{abschnitt.titel}
|
||||
{abschnitt.besondereKategorie && (
|
||||
<span className="ml-2 text-xs font-normal text-[var(--pruefung-faellig)]">
|
||||
besondere Kategorie (Art. 9)
|
||||
</span>
|
||||
)}
|
||||
</CardTitle>
|
||||
<CardDescription className="text-xs">{abschnitt.zweck}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="text-sm">
|
||||
{abschnitt.eintraege.length === 0 ? (
|
||||
<p className="text-[var(--muted-foreground)]">
|
||||
Dazu liegen keine Daten vor.
|
||||
</p>
|
||||
) : (
|
||||
<p>
|
||||
{abschnitt.eintraege.length}{" "}
|
||||
{abschnitt.eintraege.length === 1 ? "Eintrag" : "Einträge"}
|
||||
</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setOffen(false)}>
|
||||
{ergebnis ? "Schließen" : "Abbrechen"}
|
||||
</Button>
|
||||
{!ergebnis && (
|
||||
<Button
|
||||
onClick={() => erstellen.mutate({ id: personId })}
|
||||
disabled={erstellen.isPending}
|
||||
>
|
||||
{erstellen.isPending ? "Wird zusammengestellt …" : "Auskunft erstellen"}
|
||||
</Button>
|
||||
)}
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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<Record<string, string | null>>;
|
||||
/** 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<Auskunft> {
|
||||
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);
|
||||
}
|
||||
@@ -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, ">")
|
||||
.replace(/"/g, """);
|
||||
}
|
||||
|
||||
function fusszeile(text: string): string {
|
||||
return `<!doctype html><html><head><meta charset="utf-8"><style>
|
||||
body { font-family: Helvetica, Arial, sans-serif; font-size: 7pt; color: #555;
|
||||
width: 100%; margin: 0; padding: 0 18mm; }
|
||||
</style></head><body>
|
||||
<div style="display:flex;justify-content:space-between">
|
||||
<span>${html(text)}</span>
|
||||
<span>Seite <span class="pageNumber"></span> von <span class="totalPages"></span></span>
|
||||
</div>
|
||||
</body></html>`;
|
||||
}
|
||||
|
||||
function rahmen(titel: string, inhalt: string): string {
|
||||
return `<!doctype html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>${html(titel)}</title>
|
||||
<style>
|
||||
@page { size: A4; margin: 18mm 18mm 22mm 18mm; }
|
||||
body { font-family: Helvetica, Arial, sans-serif; font-size: 10pt; color: #111; line-height: 1.45; }
|
||||
h1 { font-size: 17pt; margin: 0 0 2mm; }
|
||||
h2 { font-size: 12pt; margin: 8mm 0 1mm; border-bottom: 1px solid #ccc; padding-bottom: 1mm; }
|
||||
.kopf { border-bottom: 2px solid #b00; padding-bottom: 4mm; margin-bottom: 6mm; }
|
||||
.klein { font-size: 8.5pt; color: #555; }
|
||||
.zweck { font-size: 8.5pt; color: #555; margin: 0 0 2mm; font-style: italic; }
|
||||
table { width: 100%; border-collapse: collapse; margin-bottom: 3mm; }
|
||||
td { padding: 1.2mm 2mm; border-bottom: 0.4pt solid #e5e5e5; vertical-align: top; }
|
||||
td.feld { width: 38%; color: #555; }
|
||||
.leer { color: #777; font-size: 9pt; padding: 1mm 0 2mm; }
|
||||
.kategorie { background: #fff6e5; border-left: 3px solid #d98b00; padding: 2mm 3mm; margin: 0 0 3mm; font-size: 8.5pt; }
|
||||
.hinweis { background: #f5f5f5; padding: 4mm; margin-top: 6mm; font-size: 9pt; }
|
||||
.satz { margin-bottom: 3mm; }
|
||||
</style>
|
||||
</head>
|
||||
<body>${inhalt}</body>
|
||||
</html>`;
|
||||
}
|
||||
|
||||
/** Ein Eintrag als Feld-Wert-Tabelle. Leere Felder werden weggelassen. */
|
||||
function eintragAlsTabelle(eintrag: Record<string, string | null>): string {
|
||||
const zeilen = Object.entries(eintrag)
|
||||
.filter(([, wert]) => wert !== null && wert !== "")
|
||||
.map(
|
||||
([feld, wert]) =>
|
||||
`<tr><td class="feld">${html(feld)}</td><td>${html(wert)}</td></tr>`,
|
||||
)
|
||||
.join("");
|
||||
|
||||
return zeilen ? `<table>${zeilen}</table>` : "";
|
||||
}
|
||||
|
||||
export async function auskunftPdf(auskunft: Auskunft): Promise<Buffer> {
|
||||
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
|
||||
? `<p class="leer">Dazu liegen keine Daten vor.</p>`
|
||||
: abschnitt.eintraege.map(eintragAlsTabelle).join("");
|
||||
|
||||
const kennzeichen = abschnitt.besondereKategorie
|
||||
? `<p class="kategorie"><strong>Besondere Kategorie personenbezogener Daten</strong>
|
||||
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.</p>`
|
||||
: "";
|
||||
|
||||
return `
|
||||
<h2>${html(abschnitt.titel)}</h2>
|
||||
<p class="zweck">${html(abschnitt.zweck)}</p>
|
||||
${kennzeichen}
|
||||
${koerper}`;
|
||||
})
|
||||
.join("");
|
||||
|
||||
const taetigkeiten = auskunft.taetigkeiten
|
||||
.map((eintrag) => `<tr><td class="feld">${html(eintrag.was)}</td><td>${eintrag.anzahl}</td></tr>`)
|
||||
.join("");
|
||||
|
||||
const inhalt = `
|
||||
<div class="kopf">
|
||||
<h1>Auskunft nach Art. 15 DSGVO</h1>
|
||||
<p class="klein">
|
||||
${html(auskunft.wehr.name)}${anschrift ? ` · ${html(anschrift)}` : ""}<br>
|
||||
Erstellt am ${html(auskunft.erstelltAm.toISOString().slice(0, 10))}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<p class="satz">
|
||||
Sehr geehrte/r <strong>${html(auskunft.person.name)}</strong>,
|
||||
</p>
|
||||
<p class="satz">
|
||||
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.
|
||||
</p>
|
||||
|
||||
${abschnitte}
|
||||
|
||||
<h2>Von Ihnen erfasste Vorgänge</h2>
|
||||
<p class="zweck">
|
||||
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.
|
||||
</p>
|
||||
<table>${taetigkeiten}</table>
|
||||
|
||||
<div class="hinweis">
|
||||
<strong>Ihre weiteren Rechte</strong><br><br>
|
||||
<strong>Berichtigung (Art. 16).</strong> Stimmt etwas nicht, wenden Sie sich an die
|
||||
Kommandantin oder den Kommandanten — falsche Angaben werden berichtigt.<br><br>
|
||||
<strong>Löschung (Art. 17).</strong> 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.<br><br>
|
||||
<strong>Datenübertragbarkeit (Art. 20).</strong> Dieselben Daten gibt es auf
|
||||
Wunsch zusätzlich als JSON-Datei zur maschinellen Weiterverarbeitung.<br><br>
|
||||
<strong>Beschwerde (Art. 77).</strong> Sie können sich beim Bayerischen
|
||||
Landesbeauftragten für den Datenschutz beschweren.<br><br>
|
||||
<strong>Empfänger.</strong> 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.
|
||||
</div>
|
||||
|
||||
<p class="klein" style="margin-top:6mm">
|
||||
Diese Auskunft wurde maschinell erstellt. Der Abruf ist im Protokoll der Feuerwehr
|
||||
vermerkt.
|
||||
</p>
|
||||
`;
|
||||
|
||||
return htmlZuPdf(rahmen(`Auskunft ${auskunft.person.name}`, inhalt), {
|
||||
hintergrundDrucken: true,
|
||||
fusszeileHtml: fusszeile(`${auskunft.wehr.name} · Auskunft ${auskunft.person.name}`),
|
||||
});
|
||||
}
|
||||
@@ -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({
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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()]);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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()]);
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
if (erreichbar) {
|
||||
/** Alles entfernen, was dieser Test anlegt. */
|
||||
async function aufraeumen(): Promise<void> {
|
||||
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 aufraeumen();
|
||||
await system.$disconnect();
|
||||
});
|
||||
|
||||
|
||||
@@ -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<Array<{ table_name: string }>>(
|
||||
`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<Array<{ da: boolean }>>(
|
||||
"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();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user