Version 1.0.0 — Freigabe für den Echtbetrieb #17
@@ -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/
|
||||
|
||||
@@ -6,6 +6,100 @@ Alle nennenswerten Änderungen an FLORIAN. Format nach
|
||||
|
||||
Die Regeln dazu stehen in [docs/VERSIONIERUNG.md](docs/VERSIONIERUNG.md).
|
||||
|
||||
## [0.12.0] — 2026-09-05
|
||||
|
||||
Phase 11: Freigabereife. Die drei Software-Voraussetzungen für 1.0.0 —
|
||||
Personenauskunft nach Art. 15 DSGVO, ein geprüftes Sicherungskonzept und eine
|
||||
Pipeline, die die Tests von allein ausführt.
|
||||
|
||||
**Das Auskunftsrecht war ein Versprechen ohne Umsetzung.** `person.export`
|
||||
stand seit Phase 0 im Berechtigungskatalog und war dem Kommandanten
|
||||
zugewiesen; implementiert war nichts. Die Löschseite gab es (Anonymisierung im
|
||||
Aufbewahrungslauf), die Auskunftsseite nicht — und ohne Art. 15 ist ein
|
||||
Löschkonzept nicht abnehmbar.
|
||||
|
||||
**Eine Sicherung, die nie zurückgespielt wurde, ist eine Vermutung.** Genau
|
||||
das hat sich hier bestätigt: der erste Rückspielversuch lief scheinbar
|
||||
sauber durch und hatte dabei sämtliche Unveränderlichkeitsgarantien der
|
||||
Datenbank entfernt.
|
||||
|
||||
### Hinzugefügt
|
||||
|
||||
**Auskunft nach Art. 15 DSGVO**
|
||||
|
||||
- `lib/dsgvo/auskunft.ts` sammelt alles, was über eine Person vorliegt:
|
||||
Stammdaten, Zugang, Rollen, Qualifikationen, arbeitsmedizinische Vorsorge,
|
||||
Belastungsübungen, Einsatz- und Übungsbeteiligung, Anwesenheiten, Fahrten,
|
||||
Beiträge, Benachrichtigungswege, Ausweise. Dreizehn Abschnitte.
|
||||
- **Über jedem Abschnitt steht, wozu die Daten vorliegen.** Art. 15 Abs. 1
|
||||
verlangt nicht nur die Daten, sondern auch Zwecke, Empfänger und den Hinweis
|
||||
auf die weiteren Rechte. Eine Liste ohne das sieht aus wie eine Auskunft und
|
||||
ist keine.
|
||||
- **Gesundheitsdaten sind als besondere Kategorie nach Art. 9 gekennzeichnet** —
|
||||
G26 und Belastungsübungen.
|
||||
- **Leere Abschnitte bleiben stehen.** „Dazu liegt nichts vor" ist eine
|
||||
Auskunft; ein weggelassener Abschnitt wirft die Frage auf, ob da doch etwas
|
||||
ist.
|
||||
- **Was die Person getan hat, wird gezählt, nicht mitgeliefert.** Prüfprotokolle
|
||||
und Mängelmeldungen gehören zum Nachweisbestand der Wehr. Vollständig
|
||||
ausgeliefert käme jeder über eine Selbstauskunft an die Geräteakten — die
|
||||
Auskunftspflicht wäre eine Hintertür.
|
||||
- Ein Durchgang liefert alles: die Anzeige, ein PDF für die Akte und JSON nach
|
||||
Art. 20. Bewusst nicht drei Knöpfe, von denen zwei vergessen werden können.
|
||||
- Der Vorgang wird protokolliert. Dass eine Auskunft erteilt wurde, gehört
|
||||
festgehalten — sie legt alles offen, was über einen Menschen gespeichert ist.
|
||||
|
||||
**Sicherung und Wiederherstellung**
|
||||
|
||||
- `npm run sicherung` — Datenbank über `pg_dump` im Container, dazu alle
|
||||
Dateien aus MinIO, dazu ein Manifest. Beides aus demselben Moment: eine
|
||||
Datenbank von heute mit Dateien von gestern ergibt Verweise ins Leere.
|
||||
- `npm run wiederherstellen` — mit Probelauf als Vorgabe. Ohne `--wirklich`
|
||||
wird nur angezeigt, was geschähe.
|
||||
- Nach dem Zurückspielen setzt das Skript die Sperren der Anwendungsrolle neu
|
||||
und **prüft danach nach**, ob es gewirkt hat.
|
||||
- `scripts/sicherung-sperren.ts` ist die eine Liste, aus der Wiederherstellen
|
||||
und Prüfen entstehen. Ein Test gleicht sie gegen die migrierte Datenbank ab —
|
||||
läuft sie auseinander, wäre der Schaden lautlos.
|
||||
|
||||
**Pipeline**
|
||||
|
||||
- `.gitea/workflows/pruefung.yml` mit zwei Aufträgen: `pruefung` (typecheck,
|
||||
lint, Migrationen, Seed, 599 Unit-Tests) bei jedem Push, `oberflaeche`
|
||||
(E2E) auf `main` und in Pull Requests.
|
||||
- Die Datenbank wird aus `docker/postgres` gebaut statt aus einem fertigen
|
||||
Image: FLORIAN braucht PostGIS **und** pgvector. Ein Dienst-Container mit
|
||||
`postgres:17` ließe die Migrationen durch und scheiterte erst bei der ersten
|
||||
Umkreissuche.
|
||||
- Der Seed läuft mit, weil er selbst ein Test ist.
|
||||
|
||||
### Behoben
|
||||
|
||||
- **Eine Wiederherstellung entfernte alle Unveränderlichkeitsgarantien.** Der
|
||||
Dump lief mit `--no-acl`, die Rechte kamen also nicht zurück; und selbst
|
||||
ohne dieses Flag greift beim Zurückspielen die Vorgabeprivilegierung aus
|
||||
`docker/postgres/10-init-app-role.sh` — jede von `pg_restore` neu angelegte
|
||||
Tabelle bekommt automatisch alle Rechte, und `GRANT` kann nur hinzufügen,
|
||||
nie wegnehmen. Nach einer Wiederherstellung durfte die Anwendungsrolle das
|
||||
Audit-Log ändern, Wiki-Fassungen löschen und Kiosk-Vorgänge umschreiben.
|
||||
Nichts hätte darauf hingewiesen.
|
||||
|
||||
Gefunden haben es vier Datenbanktests unmittelbar nach der ersten
|
||||
Rückspielprobe — also genau die Probe, wegen der 1.0.0 sie verlangt.
|
||||
- **Ein E2E-Test klickte, bevor die Seite lebendig war.** Unter Last dauert das
|
||||
Hydrieren länger; das Formular verschickte sich dann selbst, und die
|
||||
Fehlerfläche, auf die der Test wartete, entstand nie — obwohl der Server
|
||||
richtig ablehnte. Er wartet jetzt auf einen client-seitig erzeugten Knopf.
|
||||
|
||||
### Nachweise
|
||||
|
||||
- Sicherung und Wiederherstellung einmal vollständig durchgeführt: ein nach
|
||||
der Sicherung angelegter Datensatz war danach weg, alle Zahlen stimmten
|
||||
wieder, und **599 Tests liefen nach dem Zurückspielen grün** — einschließlich
|
||||
derer, die die Sperren prüfen.
|
||||
|
||||
---
|
||||
|
||||
## [0.11.0] — 2026-09-05
|
||||
|
||||
Phase 10: Mandantenfähigkeit. Ein Konto, mehrere Wehren, eine Kreisebene, die
|
||||
|
||||
@@ -34,6 +34,7 @@ Anmelden mit `SEED_ADMIN_EMAIL` und `SEED_ADMIN_PASSWORT` aus der `.env`
|
||||
| Phase 8 — Lokale KI | fertig |
|
||||
| Phase 9 — Kassenverwaltung | fertig |
|
||||
| Phase 10 — Mandantenfähigkeit | fertig |
|
||||
| Phase 11 — Freigabereife | fertig |
|
||||
|
||||
Vollständige Planung mit Aufwänden: [docs/ROADMAP.md](docs/ROADMAP.md) und
|
||||
[docs/AUFWANDSSCHAETZUNG.md](docs/AUFWANDSSCHAETZUNG.md).
|
||||
@@ -1447,9 +1448,140 @@ weitergegeben. FLORIAN verschickt keine.
|
||||
|
||||
---
|
||||
|
||||
## Phase 11 — Freigabereife
|
||||
|
||||
Die drei Dinge, die zwischen dem letzten Modul und dem Echtbetrieb standen:
|
||||
das Auskunftsrecht, ein geprüftes Sicherungskonzept und eine Pipeline.
|
||||
|
||||
### Auskunft nach Art. 15 DSGVO
|
||||
|
||||
Jede Person darf verlangen zu erfahren, welche Daten über sie verarbeitet
|
||||
werden. In einer Feuerwehr ist das mehr, als die meisten erwarten — und mit
|
||||
den G26-Untersuchungen sind Gesundheitsdaten dabei, also eine besondere
|
||||
Kategorie nach Art. 9.
|
||||
|
||||
Auf der Seite eines Mitglieds steht dafür der Knopf **Datenauskunft**
|
||||
(Recht `person.export`). Ein Durchgang liefert drei Dinge:
|
||||
|
||||
| | Wofür |
|
||||
|---|---|
|
||||
| **Anzeige** | Was ausgehändigt wird, vor dem Aushändigen |
|
||||
| **PDF** | Das Schreiben an die Person, für die Akte |
|
||||
| **JSON** | Datenübertragbarkeit nach Art. 20 |
|
||||
|
||||
Bewusst nicht drei Knöpfe: wer eine Auskunft erteilt, soll sie vollständig
|
||||
erteilen und nicht in Schritten, von denen zwei vergessen werden können.
|
||||
|
||||
**Über jedem Abschnitt steht, wozu die Daten vorliegen.** Art. 15 Abs. 1
|
||||
verlangt nicht nur die Daten, sondern auch Zwecke, Empfänger, Speicherdauer
|
||||
und den Hinweis auf die weiteren Rechte. Eine Liste ohne das sieht aus wie
|
||||
eine Auskunft und ist keine.
|
||||
|
||||
Zwei Festlegungen bestimmen den Zuschnitt:
|
||||
|
||||
**Was über die Person gespeichert ist, steht vollständig drin** — auch das
|
||||
Unangenehme: eine nicht bestandene G26, ein gemahnter Beitrag. Eine Auskunft,
|
||||
die auswählt, was gut aussieht, ist keine.
|
||||
|
||||
**Was die Person getan hat, steht als Zahl drin.** Wer zwölf Prüfprotokolle
|
||||
unterschrieben hat, bekommt die Zahl — nicht zwölf Protokolle. Die gehören zum
|
||||
Nachweisbestand der Wehr; sie mitzuliefern hieße, dass jeder über eine
|
||||
Selbstauskunft an die Geräteakten käme. Die Trennlinie ist nicht „viel oder
|
||||
wenig", sondern „über mich" gegen „von mir".
|
||||
|
||||
Leere Abschnitte bleiben stehen. „Dazu liegt nichts vor" ist eine Auskunft;
|
||||
ein weggelassener Abschnitt wirft die Frage auf, ob da doch etwas ist.
|
||||
|
||||
Der Vorgang wird protokolliert — dass eine Auskunft erteilt wurde, gehört
|
||||
festgehalten.
|
||||
|
||||
### Sicherung
|
||||
|
||||
```bash
|
||||
npm run sicherung # nach ./sicherungen/<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
|
||||
|
||||
+119
-2
@@ -1,6 +1,6 @@
|
||||
# Anleitung
|
||||
|
||||
Bedienung von FLORIAN, Stand Phase 10 (Version 0.11.0). Die Bilder zeigen die
|
||||
Bedienung von FLORIAN, Stand Phase 11 (Version 0.12.0). Die Bilder zeigen die
|
||||
Beispielwehr aus dem Seed und werden mit `npm run screenshots` neu erzeugt.
|
||||
|
||||
---
|
||||
@@ -2253,7 +2253,124 @@ jeweiligen Detailseite sichtbar.
|
||||
|
||||
---
|
||||
|
||||
## 61. Wenn etwas nicht geht
|
||||
## 61. Wenn jemand Auskunft über seine Daten verlangt
|
||||
|
||||
Jede Person darf verlangen zu erfahren, welche Daten über sie verarbeitet
|
||||
werden — Art. 15 DSGVO. Die Anfrage muss **innerhalb eines Monats** beantwortet
|
||||
werden, und sie ist kostenlos.
|
||||
|
||||
### So geht es
|
||||
|
||||
1. **Mitglieder** öffnen, die Person anklicken.
|
||||
2. Rechts oben auf **Datenauskunft**.
|
||||
3. **Auskunft erstellen**.
|
||||
|
||||
Danach stehen drei Dinge bereit:
|
||||
|
||||
| | Wofür |
|
||||
|---|---|
|
||||
| **Die Anzeige** | Was ausgehändigt wird — vor dem Aushändigen |
|
||||
| **PDF** | Das Schreiben an die Person. Ausdrucken oder als Datei geben |
|
||||
| **JSON** | Nur, wenn die Person die Daten maschinenlesbar verlangt (Art. 20) |
|
||||
|
||||
Das Recht dazu heißt `person.export` und liegt bei Kommandant und
|
||||
Administrator.
|
||||
|
||||
### Was drinsteht
|
||||
|
||||
Dreizehn Abschnitte: Stammdaten, Zugang, Rollen, Qualifikationen,
|
||||
arbeitsmedizinische Vorsorge, Belastungsübungen, Einsatz- und
|
||||
Übungsbeteiligung, Anwesenheiten, Fahrten, Beiträge, Benachrichtigungswege,
|
||||
Ausweise.
|
||||
|
||||
Über jedem steht, **wozu** diese Daten vorliegen. Das ist kein Beiwerk: Art. 15
|
||||
verlangt neben den Daten auch die Zwecke und den Hinweis auf die weiteren
|
||||
Rechte. Eine bloße Liste erfüllt die Auskunftspflicht nicht.
|
||||
|
||||
**Auch das Unangenehme steht drin** — ein gemahnter Beitrag, eine nicht
|
||||
bestandene G26. Eine Auskunft, die auswählt, was gut aussieht, ist keine.
|
||||
|
||||
**Leere Abschnitte bleiben stehen.** „Dazu liegen keine Daten vor" ist eine
|
||||
Auskunft.
|
||||
|
||||
**Gesundheitsdaten sind gekennzeichnet.** G26 und Belastungsübungen sind eine
|
||||
besondere Kategorie nach Art. 9 — das steht im Bericht ausdrücklich dabei.
|
||||
|
||||
### Was nicht drinsteht
|
||||
|
||||
Was die Person **getan** hat, erscheint nur als Zahl: „12 durchgeführte
|
||||
Prüfungen", „3 gemeldete Mängel".
|
||||
|
||||
Das ist Absicht. Die Prüfprotokolle selbst gehören zum Nachweisbestand der
|
||||
Feuerwehr und nicht zu den personenbezogenen Daten eines Mitglieds. Würden sie
|
||||
mitgeliefert, käme jeder über eine Selbstauskunft an die kompletten
|
||||
Geräteakten — die Auskunftspflicht wäre eine Hintertür.
|
||||
|
||||
### Der Vorgang wird protokolliert
|
||||
|
||||
Dass eine Auskunft erteilt wurde, steht danach unter **Protokoll**. Das ist
|
||||
richtig so: die Auskunft legt alles offen, was über einen Menschen gespeichert
|
||||
ist, einschließlich seiner Gesundheitsdaten. Wer sie erstellt hat und wann,
|
||||
gehört festgehalten.
|
||||
|
||||
### Wenn die Person in zwei Wehren geführt wird
|
||||
|
||||
Dann gibt es **zwei** Auskünfte, eine je Wehr. Das ist kein Versehen: jede Wehr
|
||||
ist eine eigene Verantwortliche im Sinne der Verordnung und kann nur über die
|
||||
Daten Auskunft geben, die sie selbst führt.
|
||||
|
||||
---
|
||||
|
||||
## 62. Sicherung und Wiederherstellung
|
||||
|
||||
> **Der wichtigste Satz dieses Kapitels:** Eine Sicherung, die nie
|
||||
> zurückgespielt wurde, ist eine Vermutung.
|
||||
|
||||
### Sichern
|
||||
|
||||
```bash
|
||||
npm run sicherung
|
||||
npm run sicherung -- --ziel D:\backups
|
||||
```
|
||||
|
||||
Gesichert wird die Datenbank **und** der Dateispeicher — Prüfprotokolle,
|
||||
Einsatzberichte, Fotos, Rechnungen. Beides aus demselben Moment: eine
|
||||
Datenbank von heute mit Dateien von gestern ergibt Einträge, die auf nichts
|
||||
zeigen.
|
||||
|
||||
Das Ergebnis ist ein Verzeichnis mit Zeitstempel. Es gehört auf ein anderes
|
||||
Gerät — eine Sicherung auf derselben Festplatte hilft gegen einen
|
||||
Festplattenfehler nicht.
|
||||
|
||||
### Zurückspielen
|
||||
|
||||
```bash
|
||||
npm run wiederherstellen -- --quelle sicherungen/2026-09-05T14-16-48
|
||||
```
|
||||
|
||||
So passiert **nichts**. Es wird nur angezeigt, aus welchem Moment die
|
||||
Sicherung stammt und wie groß sie ist. Erst mit `--wirklich` am Ende wird
|
||||
zurückgespielt — und dabei **der gesamte vorhandene Bestand überschrieben**.
|
||||
|
||||
### Einmal im Jahr proben
|
||||
|
||||
Nicht im Notfall zum ersten Mal. Der Ordner kann leer sein, der Auszug
|
||||
abgeschnitten, das Passwort falsch — man erfährt es sonst an dem Tag, an dem
|
||||
man es nicht erfahren will.
|
||||
|
||||
Nach dem Zurückspielen prüft das Skript selbst nach, ob die Schutzsperren der
|
||||
Datenbank wieder stehen. Drei Handgriffe bleiben trotzdem:
|
||||
|
||||
1. **Anmelden** — kommt jemand hinein?
|
||||
2. **Ein Prüfprotokoll öffnen** — ist das PDF da?
|
||||
3. **Die Mitgliederzahl** mit der Erwartung vergleichen.
|
||||
|
||||
Erst danach gilt die Sicherung als geprüft. Das Datum der letzten geglückten
|
||||
Probe gehört ins Sicherungskonzept.
|
||||
|
||||
---
|
||||
|
||||
## 63. Wenn etwas nicht geht
|
||||
|
||||
| Beobachtung | Wahrscheinliche Ursache | Was hilft |
|
||||
|---|---|---|
|
||||
|
||||
@@ -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
|
||||
|
||||
+81
-1
@@ -23,7 +23,87 @@ Die aufgewendete Zeit wandert nach jedem Abschnitt in die Spalte
|
||||
| Aufräumarbeiten (Issues #10–#12) | 1:20 h | 1 | 04.09.2026 |
|
||||
| Phase 9 — Kassenverwaltung | 4:10 h | 1 | 04.09.2026 |
|
||||
| Phase 10 — Mandantenfähigkeit | 4:20 h | 1 | 05.09.2026 |
|
||||
| **Summe** | **32:50 h** | **13** | |
|
||||
| Phase 11 — Freigabereife | 2:40 h | 1 | 05.09.2026 |
|
||||
| **Summe** | **35:30 h** | **14** | |
|
||||
|
||||
---
|
||||
|
||||
## 2026-09-05 · Phase 11 — Freigabereife
|
||||
|
||||
**14:30 – 17:10 · 2:40 h · Version 0.12.0**
|
||||
|
||||
### Was entstanden ist
|
||||
|
||||
Kein Modul, sondern die drei Software-Voraussetzungen für 1.0.0.
|
||||
|
||||
| Bereich | Umfang |
|
||||
|---|---|
|
||||
| Auskunft | `lib/dsgvo/auskunft.ts` und `bericht.ts`, `person.auskunft`, Dialog auf der Mitgliederseite |
|
||||
| Sicherung | `scripts/sicherung.ts`, `wiederherstellen.ts`, `sicherung-zugang.ts`, `sicherung-sperren.ts` |
|
||||
| Pipeline | `.gitea/workflows/pruefung.yml` mit zwei Aufträgen |
|
||||
| Tests | 18 neue: 9 gegen die Auskunft, 11 gegen die Sicherung |
|
||||
|
||||
### Entscheidungen
|
||||
|
||||
**Die Auskunft trennt „über mich" von „von mir".** Was über die Person
|
||||
gespeichert ist, steht vollständig drin — auch der gemahnte Beitrag und die
|
||||
nicht bestandene G26. Was die Person *getan* hat, steht als Zahl drin. Die
|
||||
zwölf Prüfprotokolle, die sie unterschrieben hat, gehören zum Nachweisbestand
|
||||
der Wehr; vollständig ausgeliefert käme jeder über eine Selbstauskunft an die
|
||||
Geräteakten, und die Auskunftspflicht wäre eine Hintertür.
|
||||
|
||||
**Über jedem Abschnitt steht der Zweck.** Art. 15 Abs. 1 verlangt nicht nur
|
||||
die Daten, sondern auch Zwecke, Empfänger und den Hinweis auf die weiteren
|
||||
Rechte. Eine Liste ohne das sieht aus wie eine Auskunft und ist keine — das ist
|
||||
der Unterschied zwischen einem Datenbankexport und einem Schreiben an einen
|
||||
Menschen.
|
||||
|
||||
**Eine Mutation, keine Abfrage.** Das Erstellen der Auskunft *ist* die
|
||||
Offenlegung, und die gehört ins Protokoll. Als Abfrage würde sie
|
||||
zwischengespeichert und mehrfach ausgeführt — sie wäre entweder nicht oder
|
||||
zwanzigmal vermerkt.
|
||||
|
||||
**Sicherung und Wiederherstellung gehören zusammen.** Deshalb sind es zwei
|
||||
Skripte in einem Zug und nicht eines. Der Probelauf ist die Vorgabe; ohne
|
||||
`--wirklich` geschieht nichts.
|
||||
|
||||
**Eine Liste, zwei Verwendungen.** `sicherung-sperren.ts` treibt sowohl das
|
||||
Wiederherstellen der Sperren als auch deren Prüfung. Zwei Listen wären zwei
|
||||
Wahrheiten, und die falsche fiele erst im Notfall auf.
|
||||
|
||||
**Die Pipeline baut das eigene Postgres-Image.** Ein Dienst-Container mit
|
||||
`postgres:17` ließe die Migrationen durchlaufen und scheiterte erst bei der
|
||||
ersten Umkreissuche — die Pipeline prüfte dann etwas anderes als den Betrieb.
|
||||
|
||||
### Wo es gehakt hat
|
||||
|
||||
| Problem | Ursache | Lösung |
|
||||
|---|---|---|
|
||||
| **Eine Wiederherstellung entfernte alle Unveränderlichkeitsgarantien** | Zuerst `--no-acl` im Dump. Und selbst ohne das Flag greift beim Zurückspielen die Vorgabeprivilegierung aus dem Init-Skript: jede von `pg_restore` neu angelegte Tabelle bekommt alle Rechte, und `GRANT` kann nur hinzufügen | Das Skript setzt die Sperren nach dem Zurückspielen neu und prüft danach nach. Gefunden haben es vier Datenbanktests direkt nach der ersten Rückspielprobe |
|
||||
| `pg_dump` lehnte die Verbindungszeichenkette ab | Prisma hängt `?schema=public` an — „invalid URI query parameter" | Die URL wird zerlegt, Benutzer und Datenbank gehen als Flags, das Passwort über `PGPASSWORD` |
|
||||
| Feldnamen im Auskunftssammler stimmten reihenweise nicht | Aus dem Gedächtnis geschrieben statt aus dem Schema gelesen: `alarmierung` statt `alarmiertUm`, `beginn` statt `beginnUm`, `gemeldetVonId` statt `melderId` | Der Typechecker hat sie alle einzeln benannt — zehn Durchgänge, jeder eine Zeile |
|
||||
| Ein eigener Test war falsch, nicht der Code | `new URL("localhost:5432")` gelingt in Node: „localhost:" gilt als Protokoll | Die Zusicherung prüft jetzt, was wirklich greift — die Protokollprüfung eine Zeile später |
|
||||
| Ein Vorlagentest fiel im vollen Lauf aus | Er klickte auf „Speichern“, bevor React lief — dann verschickt das Formular sich selbst, und die Fehlerfläche entsteht nie. Unter Last dauert das Hydrieren länger. Dasselbe Muster wie beim Anmeldeskript in Phase 8 | Er wartet jetzt auf einen client-seitig erzeugten Knopf, bevor er klickt |
|
||||
|
||||
### Abweichung von der Vorgabe
|
||||
|
||||
**Die Pipeline ist nicht erprobt.** Sie ist geschrieben und syntaktisch
|
||||
geprüft, aber in Gitea war zum Zeitpunkt dieser Phase kein `act_runner`
|
||||
registriert. Ohne Runner erscheinen die Läufe als „wartend" und laufen nie.
|
||||
Ob der Ablauf durchläuft, zeigt sich erst, wenn einer eingerichtet ist — das
|
||||
steht so auch im README, damit niemand sich auf eine Prüfung verlässt, die
|
||||
nicht stattfindet.
|
||||
|
||||
### Nachweise
|
||||
|
||||
- `npm run typecheck` — ohne Ausgabe
|
||||
- `npm run lint` — ohne Befund
|
||||
- `npm run test` — 599 Tests grün, davon 18 neu
|
||||
- Auskunft von Hand erzeugt: 13 Abschnitte, 28 Angaben, 58 kB PDF
|
||||
- **Sicherung und Wiederherstellung vollständig durchgeführt**: ein nach der
|
||||
Sicherung angelegter Datensatz war danach weg, alle Zahlen stimmten wieder,
|
||||
und die 599 Tests liefen nach dem Zurückspielen grün — einschließlich derer,
|
||||
die die Sperren prüfen
|
||||
|
||||
---
|
||||
|
||||
|
||||
+22
-2
@@ -20,6 +20,7 @@ Phase 7 ████████████████████ fertig
|
||||
Phase 8 ████████████████████ fertig 0.9.0
|
||||
Phase 9 ████████████████████ fertig 0.10.0
|
||||
Phase 10 ████████████████████ fertig 0.11.0
|
||||
Phase 11 ████████████████████ fertig 0.12.0
|
||||
Freigabe ░░░░░░░░░░░░░░░░░░░░ offen 1.0.0
|
||||
```
|
||||
|
||||
@@ -290,15 +291,34 @@ sie schlimmer als keine. Die Aufsicht sieht jede Wehr einzeln.
|
||||
jemand über die Grenze sieht — und die Kreisebene kann hineinschauen, ohne
|
||||
etwas anfassen zu können.
|
||||
|
||||
## Phase 11 — Freigabereife · fertig
|
||||
|
||||
Am 05.09.2026 umgesetzt. Kein Modul, sondern die drei Software-Voraussetzungen
|
||||
für die Freigabe.
|
||||
|
||||
- **Auskunft nach Art. 15 DSGVO.** Das Recht `person.export` stand seit Phase 0
|
||||
im Katalog und war zugewiesen — umgesetzt war nichts. Ohne Auskunftsseite ist
|
||||
ein Löschkonzept nicht abnehmbar.
|
||||
- **Sicherung und Wiederherstellung** mit einer durchgeführten Rückspielprobe.
|
||||
- **Pipeline** in Gitea Actions: typecheck, lint, Migrationen, Seed und Tests.
|
||||
|
||||
Die Rückspielprobe hat sich sofort bezahlt gemacht: der erste Versuch lief
|
||||
sauber durch und hatte dabei sämtliche Unveränderlichkeitsgarantien der
|
||||
Datenbank entfernt. Gefunden haben es die Datenbanktests unmittelbar danach —
|
||||
also genau die Probe, wegen der 1.0.0 sie verlangt.
|
||||
|
||||
**Die Wehr kann jetzt:** eine Selbstauskunft erteilen, ihren Bestand sichern
|
||||
und ihn nachweislich zurückholen.
|
||||
|
||||
## 1.0.0 — Freigabe
|
||||
|
||||
Kein neues Modul, sondern eine Entscheidung: die Wehr setzt FLORIAN im
|
||||
Echtbetrieb ein. Voraussetzungen:
|
||||
|
||||
- Datenübernahme aus der bisherigen Verwaltung abgeschlossen und abgeglichen
|
||||
- Sicherungskonzept steht und ist einmal wiederhergestellt worden
|
||||
- ~~Sicherungskonzept steht und ist einmal wiederhergestellt worden~~ — mit Phase 11 erledigt
|
||||
- Schulung von Kommandant, Gerätewart und Atemschutzgerätewart erfolgt
|
||||
- Löschkonzept nach DSGVO abgenommen
|
||||
- Löschkonzept nach DSGVO abgenommen (die Auskunftsseite dazu gibt es seit Phase 11)
|
||||
- Der Hinweis zur analogen Atemschutzüberwachung wird bewusst und
|
||||
dokumentiert abgeschaltet — oder bewusst stehen gelassen
|
||||
|
||||
|
||||
+3
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "florian",
|
||||
"version": "0.11.0",
|
||||
"version": "0.12.0",
|
||||
"private": true,
|
||||
"description": "FLORIAN — Feuerwehr-Verwaltungsplattform",
|
||||
"engines": {
|
||||
@@ -23,6 +23,8 @@
|
||||
"db:migrate:dev": "prisma migrate dev",
|
||||
"db:push": "prisma db push",
|
||||
"db:seed": "tsx prisma/seed.ts",
|
||||
"sicherung": "tsx scripts/sicherung.ts",
|
||||
"wiederherstellen": "tsx scripts/wiederherstellen.ts",
|
||||
"db:test-reset": "tsx scripts/testdaten.ts",
|
||||
"db:studio": "prisma studio",
|
||||
"screenshots": "tsx scripts/screenshots.ts",
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user