Phase 11: Freigabereife
Kein Modul, sondern die drei Software-Voraussetzungen fuer 1.0.0: Auskunft nach Art. 15 DSGVO, ein geprueftes Sicherungskonzept und eine Pipeline. Das Auskunftsrecht war ein Versprechen ohne Umsetzung: `person.export` stand seit Phase 0 im Katalog und war dem Kommandanten zugewiesen, implementiert war nichts. Ohne Auskunftsseite ist ein Loeschkonzept nicht abnehmbar. Jetzt sammelt FLORIAN dreizehn Abschnitte ueber eine Person, mit dem Zweck ueber jedem - Art. 15 verlangt nicht nur die Daten, sondern auch Zwecke und Rechte, und eine blosse Liste erfuellt die Pflicht nicht. Ein Durchgang liefert Anzeige, PDF und JSON nach Art. 20. Die Trennlinie ist "ueber mich" gegen "von mir": was ueber die Person gespeichert ist, steht vollstaendig drin, auch das Unangenehme. Was sie getan hat, steht als Zahl drin - die Pruefprotokolle gehoeren zum Nachweisbestand der Wehr, und vollstaendig ausgeliefert kaeme jeder ueber eine Selbstauskunft an die Geraeteakten. Die Rueckspielprobe hat sich sofort bezahlt gemacht: der erste Versuch lief sauber durch und hatte dabei saemtliche Unveraenderlichkeitsgarantien der Datenbank entfernt. Die Vorgabeprivilegierung aus dem Init-Skript gibt jeder von pg_restore neu angelegten Tabelle wieder alle Rechte, und GRANT kann nur hinzufuegen. Die Anwendungsrolle durfte danach das Audit-Log aendern, Wiki-Fassungen loeschen, Kiosk-Vorgaenge umschreiben - und nichts haette darauf hingewiesen. Gefunden haben es vier Datenbanktests unmittelbar nach der Probe, also genau die Probe, wegen der 1.0.0 sie verlangt. Die Pipeline ist geschrieben, aber NICHT erprobt: in Gitea ist kein act_runner registriert. Das steht so im README und im Devlog, damit sich niemand auf eine Pruefung verlaesst, die nicht stattfindet. 601 Unit-Tests gruen; Sicherung und Wiederherstellung einmal vollstaendig durchgefuehrt und die Tests danach erneut gruen. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
91742a0dd7
commit
9156dcc144
@@ -0,0 +1,184 @@
|
|||||||
|
# =============================================================================
|
||||||
|
# Die Pruefung bei jedem Push.
|
||||||
|
#
|
||||||
|
# Bis Phase 10 gab es 581 Unit- und 266 E2E-Tests — und nichts, was sie
|
||||||
|
# ausfuehrte ausser einem Menschen, der daran dachte. In der Sitzung, in der
|
||||||
|
# die Kassenverwaltung entstand, haben die Tests vier echte Fehler gefunden,
|
||||||
|
# zwei davon in Code, der eine Stunde alt war. Genau das soll hier von allein
|
||||||
|
# passieren.
|
||||||
|
#
|
||||||
|
# Zwei Auftraege, bewusst getrennt:
|
||||||
|
#
|
||||||
|
# pruefung typecheck, lint und die Unit-Tests. Braucht eine Datenbank,
|
||||||
|
# weil ein Teil der Tests die Trigger und RLS gegen echtes
|
||||||
|
# Postgres prueft — mit einer Attrappe waeren sie wertlos.
|
||||||
|
#
|
||||||
|
# oberflaeche die E2E-Tests. Dauern eine Dreiviertelstunde und brauchen den
|
||||||
|
# halben Stack, deshalb laufen sie nur auf main und in Pull
|
||||||
|
# Requests, nicht bei jedem Zwischenstand auf einem Zweig.
|
||||||
|
# =============================================================================
|
||||||
|
name: Pruefung
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
pull_request:
|
||||||
|
|
||||||
|
env:
|
||||||
|
# Dieselben Werte wie in .env.example. Es sind Testdaten in einem
|
||||||
|
# Wegwerf-Container; echte Geheimnisse haben in einer Pipeline nichts zu
|
||||||
|
# suchen und stehen im Betrieb in der .env.
|
||||||
|
POSTGRES_USER: florian
|
||||||
|
POSTGRES_PASSWORD: pruefung
|
||||||
|
POSTGRES_DB: florian
|
||||||
|
POSTGRES_APP_PASSWORD: pruefung_app
|
||||||
|
DATABASE_URL: postgresql://florian_app:pruefung_app@localhost:5432/florian?schema=public
|
||||||
|
DIRECT_DATABASE_URL: postgresql://florian:pruefung@localhost:5432/florian?schema=public
|
||||||
|
AUTH_SECRET: nur-fuer-die-pruefung-nicht-fuer-den-betrieb
|
||||||
|
AUTH_TRUST_HOST: "true"
|
||||||
|
APP_URL: http://localhost:3100
|
||||||
|
S3_ENDPOINT: localhost
|
||||||
|
S3_PORT: "9000"
|
||||||
|
S3_USE_SSL: "false"
|
||||||
|
S3_ACCESS_KEY: florian
|
||||||
|
S3_SECRET_KEY: pruefung_minio
|
||||||
|
S3_BUCKET: florian
|
||||||
|
REDIS_URL: redis://localhost:6379
|
||||||
|
TZ: Europe/Berlin
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
pruefung:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: 22
|
||||||
|
cache: npm
|
||||||
|
|
||||||
|
# Das eigene Postgres-Image statt eines fertigen: FLORIAN braucht
|
||||||
|
# PostGIS **und** pgvector, und kein oeffentliches Image bringt beides
|
||||||
|
# mit. Ein Dienst-Container mit postgres:17 wuerde die Migrationen
|
||||||
|
# durchlassen und erst bei der ersten Umkreissuche scheitern.
|
||||||
|
- name: Datenbank bauen und starten
|
||||||
|
run: |
|
||||||
|
docker build -t florian-postgres:pruefung ./docker/postgres
|
||||||
|
docker run -d --name postgres -p 5432:5432 \
|
||||||
|
-e POSTGRES_USER=$POSTGRES_USER \
|
||||||
|
-e POSTGRES_PASSWORD=$POSTGRES_PASSWORD \
|
||||||
|
-e POSTGRES_DB=$POSTGRES_DB \
|
||||||
|
-e POSTGRES_APP_PASSWORD=$POSTGRES_APP_PASSWORD \
|
||||||
|
-e POSTGRES_INITDB_ARGS="--locale-provider=icu --icu-locale=de-DE --locale=C.UTF-8 --encoding=UTF8" \
|
||||||
|
-v "$(pwd)/docker/postgres:/docker-entrypoint-initdb.d:ro" \
|
||||||
|
florian-postgres:pruefung
|
||||||
|
|
||||||
|
- name: Redis und MinIO starten
|
||||||
|
run: |
|
||||||
|
docker run -d --name redis -p 6379:6379 redis:7-alpine
|
||||||
|
docker run -d --name minio -p 9000:9000 \
|
||||||
|
-e MINIO_ROOT_USER=$S3_ACCESS_KEY \
|
||||||
|
-e MINIO_ROOT_PASSWORD=$S3_SECRET_KEY \
|
||||||
|
minio/minio:latest server /data
|
||||||
|
|
||||||
|
- run: npm ci
|
||||||
|
|
||||||
|
- name: Auf die Datenbank warten
|
||||||
|
run: |
|
||||||
|
for i in $(seq 1 60); do
|
||||||
|
docker exec postgres pg_isready -U "$POSTGRES_USER" -d "$POSTGRES_DB" && exit 0
|
||||||
|
sleep 2
|
||||||
|
done
|
||||||
|
echo "Postgres kam nicht hoch:"
|
||||||
|
docker logs postgres
|
||||||
|
exit 1
|
||||||
|
|
||||||
|
- run: npx prisma generate
|
||||||
|
- run: npm run typecheck
|
||||||
|
- run: npm run lint
|
||||||
|
|
||||||
|
- name: Migrationen einspielen
|
||||||
|
run: npm run db:migrate
|
||||||
|
|
||||||
|
# Der Seed gehoert dazu: er ist selbst ein Test. Faellt eine Migration
|
||||||
|
# oder ein Standardwert um, merkt man es hier und nicht erst, wenn eine
|
||||||
|
# Wehr ihn zum ersten Mal ausfuehrt.
|
||||||
|
- name: Beispielbestand anlegen
|
||||||
|
run: npm run db:seed
|
||||||
|
|
||||||
|
- run: npm run test
|
||||||
|
|
||||||
|
- name: Aufraeumen
|
||||||
|
if: always()
|
||||||
|
run: docker rm -f postgres redis minio || true
|
||||||
|
|
||||||
|
oberflaeche:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
# Nur wo es sich lohnt: ein voller Lauf dauert etwa 45 Minuten.
|
||||||
|
if: github.event_name == 'pull_request' || github.ref == 'refs/heads/main'
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: 22
|
||||||
|
cache: npm
|
||||||
|
|
||||||
|
- name: Dienste starten
|
||||||
|
run: |
|
||||||
|
docker build -t florian-postgres:pruefung ./docker/postgres
|
||||||
|
docker run -d --name postgres -p 5432:5432 \
|
||||||
|
-e POSTGRES_USER=$POSTGRES_USER \
|
||||||
|
-e POSTGRES_PASSWORD=$POSTGRES_PASSWORD \
|
||||||
|
-e POSTGRES_DB=$POSTGRES_DB \
|
||||||
|
-e POSTGRES_APP_PASSWORD=$POSTGRES_APP_PASSWORD \
|
||||||
|
-e POSTGRES_INITDB_ARGS="--locale-provider=icu --icu-locale=de-DE --locale=C.UTF-8 --encoding=UTF8" \
|
||||||
|
-v "$(pwd)/docker/postgres:/docker-entrypoint-initdb.d:ro" \
|
||||||
|
florian-postgres:pruefung
|
||||||
|
docker run -d --name redis -p 6379:6379 redis:7-alpine
|
||||||
|
docker run -d --name minio -p 9000:9000 \
|
||||||
|
-e MINIO_ROOT_USER=$S3_ACCESS_KEY \
|
||||||
|
-e MINIO_ROOT_PASSWORD=$S3_SECRET_KEY \
|
||||||
|
minio/minio:latest server /data
|
||||||
|
docker run -d --name gotenberg -p 3001:3000 gotenberg/gotenberg:8
|
||||||
|
|
||||||
|
- run: npm ci
|
||||||
|
- run: npx playwright install --with-deps chromium
|
||||||
|
|
||||||
|
- name: Auf die Datenbank warten
|
||||||
|
run: |
|
||||||
|
for i in $(seq 1 60); do
|
||||||
|
docker exec postgres pg_isready -U "$POSTGRES_USER" -d "$POSTGRES_DB" && exit 0
|
||||||
|
sleep 2
|
||||||
|
done
|
||||||
|
docker logs postgres
|
||||||
|
exit 1
|
||||||
|
|
||||||
|
- run: npx prisma generate
|
||||||
|
- run: npm run db:migrate
|
||||||
|
|
||||||
|
# Die E2E-Tests laufen seit Phase 8 gegen eine eigene Wehr, nicht gegen
|
||||||
|
# den Beispielbestand. Das Skript legt beide an.
|
||||||
|
- name: Testwehr anlegen
|
||||||
|
run: npm run db:test-reset
|
||||||
|
|
||||||
|
- run: npm run build
|
||||||
|
- run: npx playwright test
|
||||||
|
env:
|
||||||
|
GOTENBERG_URL: http://localhost:3001
|
||||||
|
|
||||||
|
- name: Fehlerbilder sichern
|
||||||
|
if: failure()
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: playwright-bericht
|
||||||
|
path: |
|
||||||
|
playwright-report/
|
||||||
|
test-results/
|
||||||
|
retention-days: 7
|
||||||
|
|
||||||
|
- name: Aufraeumen
|
||||||
|
if: always()
|
||||||
|
run: docker rm -f postgres redis minio gotenberg || true
|
||||||
@@ -40,3 +40,6 @@ debug.log
|
|||||||
# Prisma
|
# Prisma
|
||||||
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