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
|
||||||
prisma/*.db
|
prisma/*.db
|
||||||
prisma/*.db-journal
|
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).
|
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
|
## [0.11.0] — 2026-09-05
|
||||||
|
|
||||||
Phase 10: Mandantenfähigkeit. Ein Konto, mehrere Wehren, eine Kreisebene, die
|
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 8 — Lokale KI | fertig |
|
||||||
| Phase 9 — Kassenverwaltung | fertig |
|
| Phase 9 — Kassenverwaltung | fertig |
|
||||||
| Phase 10 — Mandantenfähigkeit | fertig |
|
| Phase 10 — Mandantenfähigkeit | fertig |
|
||||||
|
| Phase 11 — Freigabereife | fertig |
|
||||||
|
|
||||||
Vollständige Planung mit Aufwänden: [docs/ROADMAP.md](docs/ROADMAP.md) und
|
Vollständige Planung mit Aufwänden: [docs/ROADMAP.md](docs/ROADMAP.md) und
|
||||||
[docs/AUFWANDSSCHAETZUNG.md](docs/AUFWANDSSCHAETZUNG.md).
|
[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
|
## Befehle
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
npm run sicherung # Datenbank und Dateien sichern
|
||||||
|
npm run wiederherstellen # zurueckspielen (Probelauf ohne --wirklich)
|
||||||
npm run dev # Entwicklungsserver auf Port 3100
|
npm run dev # Entwicklungsserver auf Port 3100
|
||||||
npm run build # Produktionsbuild
|
npm run build # Produktionsbuild
|
||||||
npm run worker:dev # Hintergrundprozess mit Neuladen
|
npm run worker:dev # Hintergrundprozess mit Neuladen
|
||||||
|
|||||||
+119
-2
@@ -1,6 +1,6 @@
|
|||||||
# Anleitung
|
# 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.
|
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 |
|
| 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** |
|
| 8 | Lokale KI | 9–12 PT | 2,5–3 h | **3:40 h** |
|
||||||
| 9 | Kassenverwaltung | 8–11 PT | 2–3 h | **4:10 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** |
|
| 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
|
123–164 Personentage entsprechen etwa **6 bis 8 Monaten Vollzeitarbeit** einer
|
||||||
einzelnen Person. Zum Vergleich: eine kommerzielle Feuerwehrverwaltung kostet
|
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 |
|
| Aufräumarbeiten (Issues #10–#12) | 1:20 h | 1 | 04.09.2026 |
|
||||||
| Phase 9 — Kassenverwaltung | 4:10 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 |
|
| 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 8 ████████████████████ fertig 0.9.0
|
||||||
Phase 9 ████████████████████ fertig 0.10.0
|
Phase 9 ████████████████████ fertig 0.10.0
|
||||||
Phase 10 ████████████████████ fertig 0.11.0
|
Phase 10 ████████████████████ fertig 0.11.0
|
||||||
|
Phase 11 ████████████████████ fertig 0.12.0
|
||||||
Freigabe ░░░░░░░░░░░░░░░░░░░░ offen 1.0.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
|
jemand über die Grenze sieht — und die Kreisebene kann hineinschauen, ohne
|
||||||
etwas anfassen zu können.
|
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
|
## 1.0.0 — Freigabe
|
||||||
|
|
||||||
Kein neues Modul, sondern eine Entscheidung: die Wehr setzt FLORIAN im
|
Kein neues Modul, sondern eine Entscheidung: die Wehr setzt FLORIAN im
|
||||||
Echtbetrieb ein. Voraussetzungen:
|
Echtbetrieb ein. Voraussetzungen:
|
||||||
|
|
||||||
- Datenübernahme aus der bisherigen Verwaltung abgeschlossen und abgeglichen
|
- 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
|
- 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
|
- Der Hinweis zur analogen Atemschutzüberwachung wird bewusst und
|
||||||
dokumentiert abgeschaltet — oder bewusst stehen gelassen
|
dokumentiert abgeschaltet — oder bewusst stehen gelassen
|
||||||
|
|
||||||
|
|||||||
+3
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "florian",
|
"name": "florian",
|
||||||
"version": "0.11.0",
|
"version": "0.12.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"description": "FLORIAN — Feuerwehr-Verwaltungsplattform",
|
"description": "FLORIAN — Feuerwehr-Verwaltungsplattform",
|
||||||
"engines": {
|
"engines": {
|
||||||
@@ -23,6 +23,8 @@
|
|||||||
"db:migrate:dev": "prisma migrate dev",
|
"db:migrate:dev": "prisma migrate dev",
|
||||||
"db:push": "prisma db push",
|
"db:push": "prisma db push",
|
||||||
"db:seed": "tsx prisma/seed.ts",
|
"db:seed": "tsx prisma/seed.ts",
|
||||||
|
"sicherung": "tsx scripts/sicherung.ts",
|
||||||
|
"wiederherstellen": "tsx scripts/wiederherstellen.ts",
|
||||||
"db:test-reset": "tsx scripts/testdaten.ts",
|
"db:test-reset": "tsx scripts/testdaten.ts",
|
||||||
"db:studio": "prisma studio",
|
"db:studio": "prisma studio",
|
||||||
"screenshots": "tsx scripts/screenshots.ts",
|
"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 { notFound, redirect } from "next/navigation";
|
||||||
import { ArrowLeft } from "lucide-react";
|
import { ArrowLeft } from "lucide-react";
|
||||||
import { aktuelleSitzung } from "@/lib/auth";
|
import { aktuelleSitzung } from "@/lib/auth";
|
||||||
|
import { hatRecht } from "@/lib/authz";
|
||||||
import { darfPersonSehen } from "@/lib/authz";
|
import { darfPersonSehen } from "@/lib/authz";
|
||||||
import { fuerOrganisation } from "@/lib/db";
|
import { fuerOrganisation } from "@/lib/db";
|
||||||
import { alsDatum, personAnrede } from "@/lib/utils";
|
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 { Badge } from "@/components/ui/badge";
|
||||||
import { FristAnzeige, NichtsGefunden } from "@/components/stammdaten/anzeige";
|
import { FristAnzeige, NichtsGefunden } from "@/components/stammdaten/anzeige";
|
||||||
import { DIENSTGRAD_TEXT } from "@/lib/stammdaten/texte";
|
import { DIENSTGRAD_TEXT } from "@/lib/stammdaten/texte";
|
||||||
|
import { Datenauskunft } from "@/components/stammdaten/auskunft";
|
||||||
|
|
||||||
export const metadata = { title: "Mitglied" };
|
export const metadata = { title: "Mitglied" };
|
||||||
|
|
||||||
@@ -130,7 +132,19 @@ export default async function MitgliedSeite({ params }: { params: Promise<{ id:
|
|||||||
Zurück zur Mitgliederliste
|
Zurück zur Mitgliederliste
|
||||||
</Link>
|
</Link>
|
||||||
</Button>
|
</Button>
|
||||||
|
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||||
<h1 className="text-2xl font-semibold tracking-tight">{personAnrede(person)}</h1>
|
<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">
|
<div className="mt-1 flex flex-wrap items-center gap-2">
|
||||||
{person.rollen.map((zuweisung) => (
|
{person.rollen.map((zuweisung) => (
|
||||||
<Badge key={zuweisung.rolle.schluessel} variant="secondary">
|
<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 { Dienstgrad, Prisma } from "@prisma/client";
|
||||||
import { router, angemeldet, mitRecht } from "@/server/trpc";
|
import { router, angemeldet, mitRecht } from "@/server/trpc";
|
||||||
import { AuditAktion, protokolliereIn } from "@/lib/audit";
|
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 { darfPersonSehen, KeinZugriffFehler } from "@/lib/authz";
|
||||||
import {
|
import {
|
||||||
blaettern,
|
blaettern,
|
||||||
@@ -55,6 +58,87 @@ const personDaten = z.object({
|
|||||||
});
|
});
|
||||||
|
|
||||||
export const personRouter = router({
|
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")
|
liste: mitRecht("person.lesen")
|
||||||
.input(
|
.input(
|
||||||
listenEingabe(SORTIERFELDER).extend({
|
listenEingabe(SORTIERFELDER).extend({
|
||||||
|
|||||||
@@ -296,6 +296,16 @@ test.describe("Benachrichtigungen", () => {
|
|||||||
await anmelden(page);
|
await anmelden(page);
|
||||||
await page.goto("/benachrichtigungen/vorlagen");
|
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");
|
const feld = page.getByLabel("Nachricht");
|
||||||
await feld.fill("Am {datum} um {uhrzeit}. Und ein {tippfehler}.");
|
await feld.fill("Am {datum} um {uhrzeit}. Und ein {tippfehler}.");
|
||||||
await page.getByRole("button", { name: "Speichern" }).click();
|
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