Compare commits
2
Commits
30faa6cb02
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3b20abcb23 | ||
|
|
14cc4de9b8 |
@@ -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/
|
||||||
|
|||||||
+172
@@ -6,6 +6,178 @@ 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).
|
||||||
|
|
||||||
|
## [1.0.0] — 2026-09-06
|
||||||
|
|
||||||
|
**Freigabe für den Echtbetrieb.**
|
||||||
|
|
||||||
|
Kein neues Modul. Zwölf Phasen sind fertig, und die Wehr kann ab jetzt echte
|
||||||
|
Daten hineingeben, ohne befürchten zu müssen, sie beim nächsten Update zu
|
||||||
|
verlieren.
|
||||||
|
|
||||||
|
Was 1.0.0 bedeutet: der Funktionsumfang ist vollständig, die Datenbank ist
|
||||||
|
stabil, Migrationen sind aufwärtskompatibel, und ab hier gilt semantische
|
||||||
|
Versionierung — ein Update bricht nichts, ohne dass es in der Hauptnummer
|
||||||
|
steht.
|
||||||
|
|
||||||
|
Was es nicht bedeutet: dass keine Fehler mehr da sind.
|
||||||
|
|
||||||
|
### Hinzugefügt
|
||||||
|
|
||||||
|
- **`update.sh`** — Aktualisieren auf einem Linux-Server, in einem Befehl. Der
|
||||||
|
Ablauf darf an jeder Stelle abbrechen, ohne eine halb aktualisierte Anlage
|
||||||
|
zu hinterlassen: erst nachsehen, ob es überhaupt etwas Neues gibt, dann
|
||||||
|
**sichern**, dann holen und bauen (schlägt das fehl, läuft die alte Fassung
|
||||||
|
unverändert weiter), dann umschalten, dann nachsehen, ob die Anwendung
|
||||||
|
antwortet. Tut sie es nicht, stehen die letzten Protokollzeilen da und die
|
||||||
|
Befehle, mit denen der vorherige Stand zurückkommt.
|
||||||
|
|
||||||
|
Vorher prüft es Docker, git, die `.env` und dass keine unverbuchten
|
||||||
|
Änderungen im Verzeichnis liegen. `--pruefen` sieht nur nach,
|
||||||
|
`--ohne-sicherung` gibt es für Testinstallationen.
|
||||||
|
|
||||||
|
- **`docs/KURZANLEITUNG.md`** — der Einstieg, nach Rollen sortiert und auf ein
|
||||||
|
paar Seiten. Die vollständige Anleitung hat 63 Kapitel; die liest niemand,
|
||||||
|
bevor er das erste Gerät prüfen will. Diese hier ist aufgabenorientiert
|
||||||
|
(„ein Gerät prüfen", „einen Kontoauszug einlesen") statt nach Modulen
|
||||||
|
geordnet, und sie sagt bei jedem heiklen Punkt dazu, warum es so ist — warum
|
||||||
|
ein abgeschlossenes Prüfprotokoll nicht änderbar ist, warum gegengebucht und
|
||||||
|
nicht korrigiert wird, warum der Atemschutzhinweis stehen bleibt.
|
||||||
|
|
||||||
|
### Geändert
|
||||||
|
|
||||||
|
- Version auf **1.0.0**. Ab hier gilt semantische Versionierung nach
|
||||||
|
[docs/VERSIONIERUNG.md](docs/VERSIONIERUNG.md).
|
||||||
|
- Die Roadmap hält den Stand **jeder** Freigabevoraussetzung fest, auch der
|
||||||
|
offenen. Zwei sind organisatorisch und stehen aus: die Abnahme des
|
||||||
|
Löschkonzepts durch die Wehr und die Schulung. An der Software hängt nichts
|
||||||
|
davon ab; sie stehen dort, damit später nachlesbar ist, was zum Zeitpunkt
|
||||||
|
der Freigabe erledigt war und was nicht.
|
||||||
|
|
||||||
|
### Behoben
|
||||||
|
|
||||||
|
- **Die Testläufe flackerten seit Phase 8 — jetzt ist die Ursache weg.** Zwei
|
||||||
|
Datenbanktests schalteten beim Aufräumen ihre Trigger mit
|
||||||
|
`ALTER TABLE ... DISABLE TRIGGER` ab. Das ist eine Schemaänderung: sie gilt
|
||||||
|
für die ganze Datenbank und für alle. Vitest fährt die Testdateien parallel
|
||||||
|
— während die eine Datei abräumte, prüfte die andere gerade, dass genau
|
||||||
|
dieser Trigger greift, und sah keinen.
|
||||||
|
|
||||||
|
Das erklärt rückwirkend eine ganze Reihe von Fehlschlägen, die als
|
||||||
|
„Flackern" abgetan wurden und keines waren. Statt der Schemaänderung wird
|
||||||
|
jetzt `SET LOCAL session_replication_role = 'replica'` benutzt: dieselbe
|
||||||
|
Wirkung, aber nur in der eigenen Transaktion. Fünf Läufe hintereinander
|
||||||
|
sauber.
|
||||||
|
|
||||||
|
- **Ein Datenbanktest räumte nur am Ende auf.** Brach ein Lauf ab, scheiterte
|
||||||
|
der nächste an einer Eindeutigkeit, die mit dem Geprüften nichts zu tun
|
||||||
|
hatte. Er räumt jetzt auch am Anfang auf.
|
||||||
|
|
||||||
|
### Zum Stand der Freigabevoraussetzungen
|
||||||
|
|
||||||
|
| Voraussetzung | Stand |
|
||||||
|
|---|---|
|
||||||
|
| Datenübernahme | **entfällt** — die Wehr pflegt von Hand ein |
|
||||||
|
| Sicherungskonzept, einmal wiederhergestellt | **erledigt** (Phase 11) |
|
||||||
|
| Löschkonzept nach DSGVO | **technisch vollständig** (Phase 11), Abnahme steht aus |
|
||||||
|
| Schulung | **steht aus**, Grundlage ist die Kurzanleitung |
|
||||||
|
| Atemschutzhinweis | **bewusst stehen gelassen** — FLORIAN bleibt ergänzend |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [0.12.0] — 2026-09-05
|
||||||
|
|
||||||
|
Phase 11: Freigabereife. Die drei Software-Voraussetzungen für 1.0.0 —
|
||||||
|
Personenauskunft nach Art. 15 DSGVO, ein geprüftes Sicherungskonzept und eine
|
||||||
|
Pipeline, die die Tests von allein ausführt.
|
||||||
|
|
||||||
|
**Das Auskunftsrecht war ein Versprechen ohne Umsetzung.** `person.export`
|
||||||
|
stand seit Phase 0 im Berechtigungskatalog und war dem Kommandanten
|
||||||
|
zugewiesen; implementiert war nichts. Die Löschseite gab es (Anonymisierung im
|
||||||
|
Aufbewahrungslauf), die Auskunftsseite nicht — und ohne Art. 15 ist ein
|
||||||
|
Löschkonzept nicht abnehmbar.
|
||||||
|
|
||||||
|
**Eine Sicherung, die nie zurückgespielt wurde, ist eine Vermutung.** Genau
|
||||||
|
das hat sich hier bestätigt: der erste Rückspielversuch lief scheinbar
|
||||||
|
sauber durch und hatte dabei sämtliche Unveränderlichkeitsgarantien der
|
||||||
|
Datenbank entfernt.
|
||||||
|
|
||||||
|
### Hinzugefügt
|
||||||
|
|
||||||
|
**Auskunft nach Art. 15 DSGVO**
|
||||||
|
|
||||||
|
- `lib/dsgvo/auskunft.ts` sammelt alles, was über eine Person vorliegt:
|
||||||
|
Stammdaten, Zugang, Rollen, Qualifikationen, arbeitsmedizinische Vorsorge,
|
||||||
|
Belastungsübungen, Einsatz- und Übungsbeteiligung, Anwesenheiten, Fahrten,
|
||||||
|
Beiträge, Benachrichtigungswege, Ausweise. Dreizehn Abschnitte.
|
||||||
|
- **Über jedem Abschnitt steht, wozu die Daten vorliegen.** Art. 15 Abs. 1
|
||||||
|
verlangt nicht nur die Daten, sondern auch Zwecke, Empfänger und den Hinweis
|
||||||
|
auf die weiteren Rechte. Eine Liste ohne das sieht aus wie eine Auskunft und
|
||||||
|
ist keine.
|
||||||
|
- **Gesundheitsdaten sind als besondere Kategorie nach Art. 9 gekennzeichnet** —
|
||||||
|
G26 und Belastungsübungen.
|
||||||
|
- **Leere Abschnitte bleiben stehen.** „Dazu liegt nichts vor" ist eine
|
||||||
|
Auskunft; ein weggelassener Abschnitt wirft die Frage auf, ob da doch etwas
|
||||||
|
ist.
|
||||||
|
- **Was die Person getan hat, wird gezählt, nicht mitgeliefert.** Prüfprotokolle
|
||||||
|
und Mängelmeldungen gehören zum Nachweisbestand der Wehr. Vollständig
|
||||||
|
ausgeliefert käme jeder über eine Selbstauskunft an die Geräteakten — die
|
||||||
|
Auskunftspflicht wäre eine Hintertür.
|
||||||
|
- Ein Durchgang liefert alles: die Anzeige, ein PDF für die Akte und JSON nach
|
||||||
|
Art. 20. Bewusst nicht drei Knöpfe, von denen zwei vergessen werden können.
|
||||||
|
- Der Vorgang wird protokolliert. Dass eine Auskunft erteilt wurde, gehört
|
||||||
|
festgehalten — sie legt alles offen, was über einen Menschen gespeichert ist.
|
||||||
|
|
||||||
|
**Sicherung und Wiederherstellung**
|
||||||
|
|
||||||
|
- `npm run sicherung` — Datenbank über `pg_dump` im Container, dazu alle
|
||||||
|
Dateien aus MinIO, dazu ein Manifest. Beides aus demselben Moment: eine
|
||||||
|
Datenbank von heute mit Dateien von gestern ergibt Verweise ins Leere.
|
||||||
|
- `npm run wiederherstellen` — mit Probelauf als Vorgabe. Ohne `--wirklich`
|
||||||
|
wird nur angezeigt, was geschähe.
|
||||||
|
- Nach dem Zurückspielen setzt das Skript die Sperren der Anwendungsrolle neu
|
||||||
|
und **prüft danach nach**, ob es gewirkt hat.
|
||||||
|
- `scripts/sicherung-sperren.ts` ist die eine Liste, aus der Wiederherstellen
|
||||||
|
und Prüfen entstehen. Ein Test gleicht sie gegen die migrierte Datenbank ab —
|
||||||
|
läuft sie auseinander, wäre der Schaden lautlos.
|
||||||
|
|
||||||
|
**Pipeline**
|
||||||
|
|
||||||
|
- `.gitea/workflows/pruefung.yml` mit zwei Aufträgen: `pruefung` (typecheck,
|
||||||
|
lint, Migrationen, Seed, 599 Unit-Tests) bei jedem Push, `oberflaeche`
|
||||||
|
(E2E) auf `main` und in Pull Requests.
|
||||||
|
- Die Datenbank wird aus `docker/postgres` gebaut statt aus einem fertigen
|
||||||
|
Image: FLORIAN braucht PostGIS **und** pgvector. Ein Dienst-Container mit
|
||||||
|
`postgres:17` ließe die Migrationen durch und scheiterte erst bei der ersten
|
||||||
|
Umkreissuche.
|
||||||
|
- Der Seed läuft mit, weil er selbst ein Test ist.
|
||||||
|
|
||||||
|
### Behoben
|
||||||
|
|
||||||
|
- **Eine Wiederherstellung entfernte alle Unveränderlichkeitsgarantien.** Der
|
||||||
|
Dump lief mit `--no-acl`, die Rechte kamen also nicht zurück; und selbst
|
||||||
|
ohne dieses Flag greift beim Zurückspielen die Vorgabeprivilegierung aus
|
||||||
|
`docker/postgres/10-init-app-role.sh` — jede von `pg_restore` neu angelegte
|
||||||
|
Tabelle bekommt automatisch alle Rechte, und `GRANT` kann nur hinzufügen,
|
||||||
|
nie wegnehmen. Nach einer Wiederherstellung durfte die Anwendungsrolle das
|
||||||
|
Audit-Log ändern, Wiki-Fassungen löschen und Kiosk-Vorgänge umschreiben.
|
||||||
|
Nichts hätte darauf hingewiesen.
|
||||||
|
|
||||||
|
Gefunden haben es vier Datenbanktests unmittelbar nach der ersten
|
||||||
|
Rückspielprobe — also genau die Probe, wegen der 1.0.0 sie verlangt.
|
||||||
|
- **Ein E2E-Test klickte, bevor die Seite lebendig war.** Unter Last dauert das
|
||||||
|
Hydrieren länger; das Formular verschickte sich dann selbst, und die
|
||||||
|
Fehlerfläche, auf die der Test wartete, entstand nie — obwohl der Server
|
||||||
|
richtig ablehnte. Er wartet jetzt auf einen client-seitig erzeugten Knopf.
|
||||||
|
|
||||||
|
### Nachweise
|
||||||
|
|
||||||
|
- Sicherung und Wiederherstellung einmal vollständig durchgeführt: ein nach
|
||||||
|
der Sicherung angelegter Datensatz war danach weg, alle Zahlen stimmten
|
||||||
|
wieder, und **599 Tests liefen nach dem Zurückspielen grün** — einschließlich
|
||||||
|
derer, die die Sperren prüfen.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## [0.11.0] — 2026-09-05
|
## [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
|
||||||
|
|||||||
@@ -1,8 +1,14 @@
|
|||||||
# FLORIAN — Feuerwehr-Verwaltungsplattform
|
# FLORIAN — Feuerwehr-Verwaltungsplattform
|
||||||
|
|
||||||
|
**Version 1.0.0** — freigegeben für den Echtbetrieb.
|
||||||
|
|
||||||
Selbstgehostete Verwaltungssoftware für Freiwillige Feuerwehren. Läuft im
|
Selbstgehostete Verwaltungssoftware für Freiwillige Feuerwehren. Läuft im
|
||||||
Gerätehaus, nicht in der Cloud. Keine Telemetrie, keine externen CDNs.
|
Gerätehaus, nicht in der Cloud. Keine Telemetrie, keine externen CDNs.
|
||||||
|
|
||||||
|
> **Wer FLORIAN benutzen und nicht bauen will:**
|
||||||
|
> [**Kurzanleitung**](docs/KURZANLEITUNG.md) — alles Wichtige nach Rollen
|
||||||
|
> sortiert, auf ein paar Seiten.
|
||||||
|
|
||||||
## Schnellstart
|
## Schnellstart
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -34,6 +40,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).
|
||||||
@@ -42,7 +49,8 @@ Vollständige Planung mit Aufwänden: [docs/ROADMAP.md](docs/ROADMAP.md) und
|
|||||||
|
|
||||||
| Datei | Inhalt |
|
| Datei | Inhalt |
|
||||||
|---|---|
|
|---|---|
|
||||||
| [docs/ANLEITUNG.md](docs/ANLEITUNG.md) | Bedienung mit Bildschirmfotos |
|
| [docs/KURZANLEITUNG.md](docs/KURZANLEITUNG.md) | **Der Einstieg.** Nach Rollen sortiert, ein paar Seiten |
|
||||||
|
| [docs/ANLEITUNG.md](docs/ANLEITUNG.md) | Die vollständige Bedienung mit Bildschirmfotos, zum Nachschlagen |
|
||||||
| [docs/ROADMAP.md](docs/ROADMAP.md) | Phasenplan und was jeweils entsteht |
|
| [docs/ROADMAP.md](docs/ROADMAP.md) | Phasenplan und was jeweils entsteht |
|
||||||
| [docs/AUFWANDSSCHAETZUNG.md](docs/AUFWANDSSCHAETZUNG.md) | Aufwand je Phase, Schätzung gegen Ist |
|
| [docs/AUFWANDSSCHAETZUNG.md](docs/AUFWANDSSCHAETZUNG.md) | Aufwand je Phase, Schätzung gegen Ist |
|
||||||
| [docs/DEVLOG.md](docs/DEVLOG.md) | Entwicklungstagebuch mit Zeiterfassung |
|
| [docs/DEVLOG.md](docs/DEVLOG.md) | Entwicklungstagebuch mit Zeiterfassung |
|
||||||
@@ -1447,9 +1455,140 @@ weitergegeben. FLORIAN verschickt keine.
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## Phase 11 — Freigabereife
|
||||||
|
|
||||||
|
Die drei Dinge, die zwischen dem letzten Modul und dem Echtbetrieb standen:
|
||||||
|
das Auskunftsrecht, ein geprüftes Sicherungskonzept und eine Pipeline.
|
||||||
|
|
||||||
|
### Auskunft nach Art. 15 DSGVO
|
||||||
|
|
||||||
|
Jede Person darf verlangen zu erfahren, welche Daten über sie verarbeitet
|
||||||
|
werden. In einer Feuerwehr ist das mehr, als die meisten erwarten — und mit
|
||||||
|
den G26-Untersuchungen sind Gesundheitsdaten dabei, also eine besondere
|
||||||
|
Kategorie nach Art. 9.
|
||||||
|
|
||||||
|
Auf der Seite eines Mitglieds steht dafür der Knopf **Datenauskunft**
|
||||||
|
(Recht `person.export`). Ein Durchgang liefert drei Dinge:
|
||||||
|
|
||||||
|
| | Wofür |
|
||||||
|
|---|---|
|
||||||
|
| **Anzeige** | Was ausgehändigt wird, vor dem Aushändigen |
|
||||||
|
| **PDF** | Das Schreiben an die Person, für die Akte |
|
||||||
|
| **JSON** | Datenübertragbarkeit nach Art. 20 |
|
||||||
|
|
||||||
|
Bewusst nicht drei Knöpfe: wer eine Auskunft erteilt, soll sie vollständig
|
||||||
|
erteilen und nicht in Schritten, von denen zwei vergessen werden können.
|
||||||
|
|
||||||
|
**Über jedem Abschnitt steht, wozu die Daten vorliegen.** Art. 15 Abs. 1
|
||||||
|
verlangt nicht nur die Daten, sondern auch Zwecke, Empfänger, Speicherdauer
|
||||||
|
und den Hinweis auf die weiteren Rechte. Eine Liste ohne das sieht aus wie
|
||||||
|
eine Auskunft und ist keine.
|
||||||
|
|
||||||
|
Zwei Festlegungen bestimmen den Zuschnitt:
|
||||||
|
|
||||||
|
**Was über die Person gespeichert ist, steht vollständig drin** — auch das
|
||||||
|
Unangenehme: eine nicht bestandene G26, ein gemahnter Beitrag. Eine Auskunft,
|
||||||
|
die auswählt, was gut aussieht, ist keine.
|
||||||
|
|
||||||
|
**Was die Person getan hat, steht als Zahl drin.** Wer zwölf Prüfprotokolle
|
||||||
|
unterschrieben hat, bekommt die Zahl — nicht zwölf Protokolle. Die gehören zum
|
||||||
|
Nachweisbestand der Wehr; sie mitzuliefern hieße, dass jeder über eine
|
||||||
|
Selbstauskunft an die Geräteakten käme. Die Trennlinie ist nicht „viel oder
|
||||||
|
wenig", sondern „über mich" gegen „von mir".
|
||||||
|
|
||||||
|
Leere Abschnitte bleiben stehen. „Dazu liegt nichts vor" ist eine Auskunft;
|
||||||
|
ein weggelassener Abschnitt wirft die Frage auf, ob da doch etwas ist.
|
||||||
|
|
||||||
|
Der Vorgang wird protokolliert — dass eine Auskunft erteilt wurde, gehört
|
||||||
|
festgehalten.
|
||||||
|
|
||||||
|
### Sicherung
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run sicherung # nach ./sicherungen/<Zeitstempel>/
|
||||||
|
npm run sicherung -- --ziel D:\backups
|
||||||
|
```
|
||||||
|
|
||||||
|
Gesichert wird beides, aus **demselben Moment**: die Datenbank über `pg_dump`
|
||||||
|
im Container und alle Dateien aus MinIO. Eine Datenbank von heute mit Dateien
|
||||||
|
von gestern ergibt Datensätze, die auf nichts zeigen.
|
||||||
|
|
||||||
|
Daneben entsteht ein `manifest.json` mit Zeitpunkt, Version und Umfang. Es
|
||||||
|
wird beim Zurückspielen angezeigt, damit niemand versehentlich eine Sicherung
|
||||||
|
von vor drei Monaten einspielt, weil das Verzeichnis ähnlich hieß.
|
||||||
|
|
||||||
|
### Wiederherstellung
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run wiederherstellen -- --quelle sicherungen/2026-09-05T14-16-48
|
||||||
|
npm run wiederherstellen -- --quelle ... --wirklich
|
||||||
|
```
|
||||||
|
|
||||||
|
Ohne `--wirklich` passiert nichts — es wird nur angezeigt, was geschähe. Das
|
||||||
|
ist keine Bequemlichkeit: das Skript überschreibt den gesamten Bestand.
|
||||||
|
|
||||||
|
**Der eigentliche Zweck ist nicht der Notfall, sondern die Probe.** Eine
|
||||||
|
Sicherung, die nie zurückgespielt wurde, ist eine Vermutung: der Ordner kann
|
||||||
|
leer sein, der Dump abgeschnitten, das Passwort falsch. Man erfährt es an dem
|
||||||
|
Tag, an dem man es nicht erfahren will.
|
||||||
|
|
||||||
|
Wie berechtigt das ist, hat sich hier gezeigt. Der erste Rückspielversuch lief
|
||||||
|
sauber durch — und hatte dabei **sämtliche Unveränderlichkeitsgarantien der
|
||||||
|
Datenbank entfernt**. Der Grund liegt tiefer, als man vermutet:
|
||||||
|
`docker/postgres/10-init-app-role.sh` richtet eine Vorgabeprivilegierung ein,
|
||||||
|
damit neue Tabellen aus Migrationen automatisch nutzbar sind. `pg_restore`
|
||||||
|
legt die Tabellen aber ebenfalls neu an — die Vorgabe greift also auch dort,
|
||||||
|
und die `GRANT`-Zeilen aus dem Dump können nur hinzufügen, nie wegnehmen. Die
|
||||||
|
Anwendungsrolle durfte danach das Audit-Log ändern, Wiki-Fassungen löschen und
|
||||||
|
Kiosk-Vorgänge umschreiben. Nichts an der Anwendung hätte darauf hingewiesen.
|
||||||
|
|
||||||
|
Deshalb setzt das Skript die Sperren nach dem Zurückspielen neu und **prüft
|
||||||
|
danach nach**, ob es gewirkt hat. Die Liste dafür steht in
|
||||||
|
`scripts/sicherung-sperren.ts`, und ein Test gleicht sie gegen die migrierte
|
||||||
|
Datenbank ab: läuft sie auseinander, käme eine Sperre nach einer
|
||||||
|
Wiederherstellung nicht zurück, von der niemand mehr weiß, dass es sie gab.
|
||||||
|
|
||||||
|
Nach dem Zurückspielen bleiben drei Handgriffe, die keine Software abnehmen
|
||||||
|
kann:
|
||||||
|
|
||||||
|
1. Anmelden — kommt jemand hinein?
|
||||||
|
2. Ein Prüfprotokoll öffnen — ist das PDF da?
|
||||||
|
3. Die Mitgliederzahl mit der Erwartung vergleichen.
|
||||||
|
|
||||||
|
Erst danach gilt die Sicherung als geprüft.
|
||||||
|
|
||||||
|
### Die Pipeline
|
||||||
|
|
||||||
|
`.gitea/workflows/pruefung.yml`, zwei Aufträge:
|
||||||
|
|
||||||
|
| Auftrag | Was | Wann |
|
||||||
|
|---|---|---|
|
||||||
|
| `pruefung` | typecheck, lint, Migrationen, Seed, 599 Unit-Tests | jeder Push und Pull Request |
|
||||||
|
| `oberflaeche` | die E2E-Tests über Chromium | nur `main` und Pull Requests |
|
||||||
|
|
||||||
|
Die Trennung ist eine Zeitfrage: der erste Auftrag dauert Minuten, der zweite
|
||||||
|
eine Dreiviertelstunde.
|
||||||
|
|
||||||
|
Die Datenbank wird aus `docker/postgres` **gebaut** statt aus einem fertigen
|
||||||
|
Image genommen. FLORIAN braucht PostGIS und pgvector, und kein öffentliches
|
||||||
|
Image bringt beides mit; ein Dienst-Container mit `postgres:17` ließe die
|
||||||
|
Migrationen durchlaufen und scheiterte erst bei der ersten Umkreissuche. Der
|
||||||
|
Seed läuft mit, weil er selbst ein Test ist: fällt ein Standardwert um, merkt
|
||||||
|
man es hier und nicht, wenn eine Wehr ihn zum ersten Mal ausführt.
|
||||||
|
|
||||||
|
> **Voraussetzung: ein registrierter Runner.** Die Datei allein genügt nicht —
|
||||||
|
> Gitea braucht einen `act_runner`, der die Aufträge annimmt. Ist keiner
|
||||||
|
> eingerichtet, erscheinen die Läufe als „wartend" und laufen nie. Zum
|
||||||
|
> Zeitpunkt dieser Phase war noch keiner registriert; der Ablauf ist also
|
||||||
|
> geschrieben, aber nicht im Betrieb erprobt.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Befehle
|
## 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
|
||||||
@@ -1523,6 +1662,30 @@ ohne Traefik. In Produktion terminiert Traefik TLS und verteilt auf
|
|||||||
Der App-Container wendet beim Start ausstehende Migrationen an; der Worker
|
Der App-Container wendet beim Start ausstehende Migrationen an; der Worker
|
||||||
tut das bewusst nicht, damit nicht zwei Prozesse gleichzeitig migrieren.
|
tut das bewusst nicht, damit nicht zwei Prozesse gleichzeitig migrieren.
|
||||||
|
|
||||||
|
### Aktualisieren
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./update.sh --pruefen # nachsehen, ob es etwas Neues gibt
|
||||||
|
./update.sh # einspielen
|
||||||
|
```
|
||||||
|
|
||||||
|
Der Ablauf ist so gebaut, dass er an jeder Stelle abbrechen darf, ohne eine
|
||||||
|
halb aktualisierte Anlage zu hinterlassen:
|
||||||
|
|
||||||
|
1. Nachsehen, ob überhaupt etwas Neues da ist — sonst endet es hier.
|
||||||
|
2. **Sichern.** Vor allem anderen; ein Update ist der häufigste Anlass, eine
|
||||||
|
Sicherung zu brauchen.
|
||||||
|
3. Neue Fassung holen und die Abbilder bauen. Schlägt das fehl, läuft die alte
|
||||||
|
Fassung unverändert weiter — es wurde noch nichts umgeschaltet.
|
||||||
|
4. Umschalten. Der App-Container spielt dabei die Migrationen ein.
|
||||||
|
5. Nachsehen, ob die Anwendung wieder antwortet. Tut sie es nach zweieinhalb
|
||||||
|
Minuten nicht, stehen die letzten Protokollzeilen da und die Befehle, mit
|
||||||
|
denen der vorherige Stand zurückkommt.
|
||||||
|
|
||||||
|
Vorher prüft das Skript, dass Docker, git und die `.env` da sind und dass im
|
||||||
|
Verzeichnis keine unverbuchten Änderungen liegen. `--ohne-sicherung` gibt es
|
||||||
|
für Testinstallationen; im Betrieb gehört es nicht benutzt.
|
||||||
|
|
||||||
## Datenschutz
|
## Datenschutz
|
||||||
|
|
||||||
- Keine Telemetrie, keine externen CDNs, keine Google Fonts. Es werden nur
|
- Keine Telemetrie, keine externen CDNs, keine Google Fonts. Es werden nur
|
||||||
|
|||||||
+124
-3
@@ -1,7 +1,11 @@
|
|||||||
# Anleitung
|
# Anleitung
|
||||||
|
|
||||||
Bedienung von FLORIAN, Stand Phase 10 (Version 0.11.0). Die Bilder zeigen die
|
Bedienung von FLORIAN, Version 1.0.0. Die Bilder zeigen die Beispielwehr aus
|
||||||
Beispielwehr aus dem Seed und werden mit `npm run screenshots` neu erzeugt.
|
dem Seed und werden mit `npm run screenshots` neu erzeugt.
|
||||||
|
|
||||||
|
> **Das hier ist zum Nachschlagen, nicht zum Durchlesen.** Wer anfangen will,
|
||||||
|
> nimmt die [Kurzanleitung](KURZANLEITUNG.md) — nach Rollen sortiert, auf ein
|
||||||
|
> paar Seiten.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -2253,7 +2257,124 @@ jeweiligen Detailseite sichtbar.
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 61. Wenn etwas nicht geht
|
## 61. Wenn jemand Auskunft über seine Daten verlangt
|
||||||
|
|
||||||
|
Jede Person darf verlangen zu erfahren, welche Daten über sie verarbeitet
|
||||||
|
werden — Art. 15 DSGVO. Die Anfrage muss **innerhalb eines Monats** beantwortet
|
||||||
|
werden, und sie ist kostenlos.
|
||||||
|
|
||||||
|
### So geht es
|
||||||
|
|
||||||
|
1. **Mitglieder** öffnen, die Person anklicken.
|
||||||
|
2. Rechts oben auf **Datenauskunft**.
|
||||||
|
3. **Auskunft erstellen**.
|
||||||
|
|
||||||
|
Danach stehen drei Dinge bereit:
|
||||||
|
|
||||||
|
| | Wofür |
|
||||||
|
|---|---|
|
||||||
|
| **Die Anzeige** | Was ausgehändigt wird — vor dem Aushändigen |
|
||||||
|
| **PDF** | Das Schreiben an die Person. Ausdrucken oder als Datei geben |
|
||||||
|
| **JSON** | Nur, wenn die Person die Daten maschinenlesbar verlangt (Art. 20) |
|
||||||
|
|
||||||
|
Das Recht dazu heißt `person.export` und liegt bei Kommandant und
|
||||||
|
Administrator.
|
||||||
|
|
||||||
|
### Was drinsteht
|
||||||
|
|
||||||
|
Dreizehn Abschnitte: Stammdaten, Zugang, Rollen, Qualifikationen,
|
||||||
|
arbeitsmedizinische Vorsorge, Belastungsübungen, Einsatz- und
|
||||||
|
Übungsbeteiligung, Anwesenheiten, Fahrten, Beiträge, Benachrichtigungswege,
|
||||||
|
Ausweise.
|
||||||
|
|
||||||
|
Über jedem steht, **wozu** diese Daten vorliegen. Das ist kein Beiwerk: Art. 15
|
||||||
|
verlangt neben den Daten auch die Zwecke und den Hinweis auf die weiteren
|
||||||
|
Rechte. Eine bloße Liste erfüllt die Auskunftspflicht nicht.
|
||||||
|
|
||||||
|
**Auch das Unangenehme steht drin** — ein gemahnter Beitrag, eine nicht
|
||||||
|
bestandene G26. Eine Auskunft, die auswählt, was gut aussieht, ist keine.
|
||||||
|
|
||||||
|
**Leere Abschnitte bleiben stehen.** „Dazu liegen keine Daten vor" ist eine
|
||||||
|
Auskunft.
|
||||||
|
|
||||||
|
**Gesundheitsdaten sind gekennzeichnet.** G26 und Belastungsübungen sind eine
|
||||||
|
besondere Kategorie nach Art. 9 — das steht im Bericht ausdrücklich dabei.
|
||||||
|
|
||||||
|
### Was nicht drinsteht
|
||||||
|
|
||||||
|
Was die Person **getan** hat, erscheint nur als Zahl: „12 durchgeführte
|
||||||
|
Prüfungen", „3 gemeldete Mängel".
|
||||||
|
|
||||||
|
Das ist Absicht. Die Prüfprotokolle selbst gehören zum Nachweisbestand der
|
||||||
|
Feuerwehr und nicht zu den personenbezogenen Daten eines Mitglieds. Würden sie
|
||||||
|
mitgeliefert, käme jeder über eine Selbstauskunft an die kompletten
|
||||||
|
Geräteakten — die Auskunftspflicht wäre eine Hintertür.
|
||||||
|
|
||||||
|
### Der Vorgang wird protokolliert
|
||||||
|
|
||||||
|
Dass eine Auskunft erteilt wurde, steht danach unter **Protokoll**. Das ist
|
||||||
|
richtig so: die Auskunft legt alles offen, was über einen Menschen gespeichert
|
||||||
|
ist, einschließlich seiner Gesundheitsdaten. Wer sie erstellt hat und wann,
|
||||||
|
gehört festgehalten.
|
||||||
|
|
||||||
|
### Wenn die Person in zwei Wehren geführt wird
|
||||||
|
|
||||||
|
Dann gibt es **zwei** Auskünfte, eine je Wehr. Das ist kein Versehen: jede Wehr
|
||||||
|
ist eine eigene Verantwortliche im Sinne der Verordnung und kann nur über die
|
||||||
|
Daten Auskunft geben, die sie selbst führt.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 62. Sicherung und Wiederherstellung
|
||||||
|
|
||||||
|
> **Der wichtigste Satz dieses Kapitels:** Eine Sicherung, die nie
|
||||||
|
> zurückgespielt wurde, ist eine Vermutung.
|
||||||
|
|
||||||
|
### Sichern
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run sicherung
|
||||||
|
npm run sicherung -- --ziel D:\backups
|
||||||
|
```
|
||||||
|
|
||||||
|
Gesichert wird die Datenbank **und** der Dateispeicher — Prüfprotokolle,
|
||||||
|
Einsatzberichte, Fotos, Rechnungen. Beides aus demselben Moment: eine
|
||||||
|
Datenbank von heute mit Dateien von gestern ergibt Einträge, die auf nichts
|
||||||
|
zeigen.
|
||||||
|
|
||||||
|
Das Ergebnis ist ein Verzeichnis mit Zeitstempel. Es gehört auf ein anderes
|
||||||
|
Gerät — eine Sicherung auf derselben Festplatte hilft gegen einen
|
||||||
|
Festplattenfehler nicht.
|
||||||
|
|
||||||
|
### Zurückspielen
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run wiederherstellen -- --quelle sicherungen/2026-09-05T14-16-48
|
||||||
|
```
|
||||||
|
|
||||||
|
So passiert **nichts**. Es wird nur angezeigt, aus welchem Moment die
|
||||||
|
Sicherung stammt und wie groß sie ist. Erst mit `--wirklich` am Ende wird
|
||||||
|
zurückgespielt — und dabei **der gesamte vorhandene Bestand überschrieben**.
|
||||||
|
|
||||||
|
### Einmal im Jahr proben
|
||||||
|
|
||||||
|
Nicht im Notfall zum ersten Mal. Der Ordner kann leer sein, der Auszug
|
||||||
|
abgeschnitten, das Passwort falsch — man erfährt es sonst an dem Tag, an dem
|
||||||
|
man es nicht erfahren will.
|
||||||
|
|
||||||
|
Nach dem Zurückspielen prüft das Skript selbst nach, ob die Schutzsperren der
|
||||||
|
Datenbank wieder stehen. Drei Handgriffe bleiben trotzdem:
|
||||||
|
|
||||||
|
1. **Anmelden** — kommt jemand hinein?
|
||||||
|
2. **Ein Prüfprotokoll öffnen** — ist das PDF da?
|
||||||
|
3. **Die Mitgliederzahl** mit der Erwartung vergleichen.
|
||||||
|
|
||||||
|
Erst danach gilt die Sicherung als geprüft. Das Datum der letzten geglückten
|
||||||
|
Probe gehört ins Sicherungskonzept.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 63. Wenn etwas nicht geht
|
||||||
|
|
||||||
| Beobachtung | Wahrscheinliche Ursache | Was hilft |
|
| 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
|
||||||
|
|||||||
+181
-1
@@ -23,7 +23,187 @@ Die aufgewendete Zeit wandert nach jedem Abschnitt in die Spalte
|
|||||||
| Aufräumarbeiten (Issues #10–#12) | 1:20 h | 1 | 04.09.2026 |
|
| 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 |
|
||||||
|
| 1.0.0 — Freigabe | 0:50 h | 1 | 06.09.2026 |
|
||||||
|
| **Summe** | **36:20 h** | **15** | |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2026-09-06 · 1.0.0 — Freigabe
|
||||||
|
|
||||||
|
**09:20 – 10:10 · 0:50 h · Version 1.0.0**
|
||||||
|
|
||||||
|
### Was entstanden ist
|
||||||
|
|
||||||
|
Kein Modul. Zwei Dinge, die zum Betrieb gehören, und eine Entscheidung.
|
||||||
|
|
||||||
|
| Was | Wofür |
|
||||||
|
|---|---|
|
||||||
|
| `update.sh` | Aktualisieren auf einem Linux-Server, in einem Befehl |
|
||||||
|
| `docs/KURZANLEITUNG.md` | Der Einstieg für die Wehr, nach Rollen sortiert |
|
||||||
|
| Version 1.0.0 | Ab hier semantische Versionierung |
|
||||||
|
|
||||||
|
### Entscheidungen
|
||||||
|
|
||||||
|
**Das Update-Skript sichert, bevor es irgendetwas anfasst.** Ein Update ist
|
||||||
|
der häufigste Anlass, eine Sicherung zu brauchen — und der Moment, in dem
|
||||||
|
niemand daran denkt. `--ohne-sicherung` gibt es, aber mit einer Warnung und
|
||||||
|
nicht als Vorgabe.
|
||||||
|
|
||||||
|
**Die Reihenfolge ist so gewählt, dass ein Abbruch harmlos bleibt.** Holen und
|
||||||
|
Bauen passieren, bevor umgeschaltet wird: schlägt der Bau fehl, läuft die alte
|
||||||
|
Fassung unverändert weiter. Erst danach wird umgeschaltet, und ab da nennt die
|
||||||
|
Fehlerbehandlung die Befehle für den Rückweg.
|
||||||
|
|
||||||
|
**Die Kurzanleitung ist aufgabenorientiert, nicht modulorientiert.** Die große
|
||||||
|
Anleitung hat 63 Kapitel und ist nach Funktionsbereichen sortiert — richtig
|
||||||
|
zum Nachschlagen, unbrauchbar zum Anfangen. Die Kurzanleitung fragt stattdessen
|
||||||
|
„was willst du tun": ein Gerät prüfen, einen Kontoauszug einlesen, einen
|
||||||
|
Termin absagen.
|
||||||
|
|
||||||
|
**Sie erklärt bei jedem heiklen Punkt das Warum.** Warum ein abgeschlossenes
|
||||||
|
Prüfprotokoll nicht änderbar ist, warum gegengebucht statt korrigiert wird,
|
||||||
|
warum der Atemschutzhinweis stehen bleibt. Wer den Grund kennt, sucht nicht
|
||||||
|
nach dem Weg drumherum.
|
||||||
|
|
||||||
|
**Die Roadmap hakt nicht alles ab.** Zwei Freigabevoraussetzungen sind
|
||||||
|
organisatorisch und stehen aus — die Abnahme des Löschkonzepts und die
|
||||||
|
Schulung. Sie stehen mit ihrem Stand dort, statt stillschweigend als erledigt
|
||||||
|
zu gelten. Eine Freigabe, die abhakt, was nicht geprüft wurde, ist keine.
|
||||||
|
|
||||||
|
### Wo es gehakt hat
|
||||||
|
|
||||||
|
**Die Ursache der Testflackerei gefunden — nach elf Phasen.**
|
||||||
|
|
||||||
|
Beim abschließenden Durchlauf fielen wieder einmal fünf, dann sechs, dann ein
|
||||||
|
Test aus, in wechselnder Besetzung. Dieses Mal habe ich nicht weitergemacht,
|
||||||
|
sondern fünf Läufe hintereinander mitgeschrieben: betroffen waren immer
|
||||||
|
Zusicherungen über Trigger — „Wiki-Fassungen bleiben", „Mandantentrennung",
|
||||||
|
„abgeschlossene Prüfprotokolle".
|
||||||
|
|
||||||
|
`ki-datenbank.test.ts` und `atemschutz-datenbank.test.ts` schalteten beim
|
||||||
|
Aufräumen ihre Trigger mit `ALTER TABLE ... DISABLE TRIGGER USER` ab. Das ist
|
||||||
|
eine Schemaänderung: sie gilt für die ganze Datenbank und für alle. Vitest
|
||||||
|
fährt die Testdateien parallel — während also die eine Datei abräumte, prüfte
|
||||||
|
die andere gerade, dass genau dieser Trigger greift, und sah keinen.
|
||||||
|
|
||||||
|
`SET LOCAL session_replication_role = 'replica'` bewirkt dasselbe, aber nur in
|
||||||
|
der eigenen Transaktion. Danach: fünf Läufe hintereinander 601 grün.
|
||||||
|
|
||||||
|
Rückwirkend erklärt das eine Reihe von Ausfällen, die ich als Flackern
|
||||||
|
abgetan habe — sie waren keines. Die Lehre ist unbequem: „läuft einzeln durch,
|
||||||
|
also Umgebung" ist eine bequeme Erklärung und oft die falsche.
|
||||||
|
|
||||||
|
| Weiteres | Ursache | Lösung |
|
||||||
|
|---|---|---|
|
||||||
|
| Ein Datenbanktest ließ sich nur einmal ausführen | `mandanten-datenbank.test.ts` räumte nur am Ende auf; ein abgebrochener Lauf legte den nächsten lahm | Räumt jetzt auch am Anfang auf — dasselbe Muster wie in Phase 10 bei `ki-datenbank` |
|
||||||
|
|
||||||
|
### Abweichung von der Vorgabe
|
||||||
|
|
||||||
|
**`update.sh` ist nicht auf einem Linux-Server erprobt.** Entwickelt wurde
|
||||||
|
unter Windows; geprüft sind die Syntax (`bash -n`), die Hilfe, die
|
||||||
|
Argumentbehandlung und der Wächter gegen unverbuchte Änderungen — alles unter
|
||||||
|
Git Bash. Der eigentliche Ablauf (fetch, build, up, Gesundheitsprüfung) läuft
|
||||||
|
erst dort, wo FLORIAN wirklich steht.
|
||||||
|
|
||||||
|
Das ist keine Nachlässigkeit, sondern die Grenze dieser Umgebung. Beim ersten
|
||||||
|
echten Update gehört `./update.sh --pruefen` vorangestellt: es holt nichts und
|
||||||
|
ändert nichts, zeigt aber, ob Docker, git und die `.env` gefunden werden.
|
||||||
|
|
||||||
|
### Nachweise
|
||||||
|
|
||||||
|
- `npm run typecheck` — ohne Ausgabe
|
||||||
|
- `npm run lint` — ohne Befund
|
||||||
|
- `npm run test` — 601 Tests grün
|
||||||
|
- `bash -n update.sh` — Syntax in Ordnung
|
||||||
|
- `./update.sh --help` und `--quatsch` — Ausgabe und Ablehnung wie vorgesehen
|
||||||
|
- Der Wächter gegen unverbuchte Änderungen greift (an einem schmutzigen
|
||||||
|
Arbeitsverzeichnis geprüft)
|
||||||
|
- Alle acht Bilder der Kurzanleitung existieren
|
||||||
|
- `npm run test` **fünfmal hintereinander** — jedes Mal 601 grün. Das war
|
||||||
|
nach elf Phasen der erste Lauf, bei dem ich das behaupten kann
|
||||||
|
- `npm run test:e2e` — **268 grün, kein Fehlschlag, kein Flackern.** Ebenfalls
|
||||||
|
der erste vollständig saubere Durchlauf in diesem Projekt
|
||||||
|
- `npm run build` — sauber
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2026-09-05 · Phase 11 — Freigabereife
|
||||||
|
|
||||||
|
**14:30 – 17:10 · 2:40 h · Version 0.12.0**
|
||||||
|
|
||||||
|
### Was entstanden ist
|
||||||
|
|
||||||
|
Kein Modul, sondern die drei Software-Voraussetzungen für 1.0.0.
|
||||||
|
|
||||||
|
| Bereich | Umfang |
|
||||||
|
|---|---|
|
||||||
|
| Auskunft | `lib/dsgvo/auskunft.ts` und `bericht.ts`, `person.auskunft`, Dialog auf der Mitgliederseite |
|
||||||
|
| Sicherung | `scripts/sicherung.ts`, `wiederherstellen.ts`, `sicherung-zugang.ts`, `sicherung-sperren.ts` |
|
||||||
|
| Pipeline | `.gitea/workflows/pruefung.yml` mit zwei Aufträgen |
|
||||||
|
| Tests | 18 neue: 9 gegen die Auskunft, 11 gegen die Sicherung |
|
||||||
|
|
||||||
|
### Entscheidungen
|
||||||
|
|
||||||
|
**Die Auskunft trennt „über mich" von „von mir".** Was über die Person
|
||||||
|
gespeichert ist, steht vollständig drin — auch der gemahnte Beitrag und die
|
||||||
|
nicht bestandene G26. Was die Person *getan* hat, steht als Zahl drin. Die
|
||||||
|
zwölf Prüfprotokolle, die sie unterschrieben hat, gehören zum Nachweisbestand
|
||||||
|
der Wehr; vollständig ausgeliefert käme jeder über eine Selbstauskunft an die
|
||||||
|
Geräteakten, und die Auskunftspflicht wäre eine Hintertür.
|
||||||
|
|
||||||
|
**Über jedem Abschnitt steht der Zweck.** Art. 15 Abs. 1 verlangt nicht nur
|
||||||
|
die Daten, sondern auch Zwecke, Empfänger und den Hinweis auf die weiteren
|
||||||
|
Rechte. Eine Liste ohne das sieht aus wie eine Auskunft und ist keine — das ist
|
||||||
|
der Unterschied zwischen einem Datenbankexport und einem Schreiben an einen
|
||||||
|
Menschen.
|
||||||
|
|
||||||
|
**Eine Mutation, keine Abfrage.** Das Erstellen der Auskunft *ist* die
|
||||||
|
Offenlegung, und die gehört ins Protokoll. Als Abfrage würde sie
|
||||||
|
zwischengespeichert und mehrfach ausgeführt — sie wäre entweder nicht oder
|
||||||
|
zwanzigmal vermerkt.
|
||||||
|
|
||||||
|
**Sicherung und Wiederherstellung gehören zusammen.** Deshalb sind es zwei
|
||||||
|
Skripte in einem Zug und nicht eines. Der Probelauf ist die Vorgabe; ohne
|
||||||
|
`--wirklich` geschieht nichts.
|
||||||
|
|
||||||
|
**Eine Liste, zwei Verwendungen.** `sicherung-sperren.ts` treibt sowohl das
|
||||||
|
Wiederherstellen der Sperren als auch deren Prüfung. Zwei Listen wären zwei
|
||||||
|
Wahrheiten, und die falsche fiele erst im Notfall auf.
|
||||||
|
|
||||||
|
**Die Pipeline baut das eigene Postgres-Image.** Ein Dienst-Container mit
|
||||||
|
`postgres:17` ließe die Migrationen durchlaufen und scheiterte erst bei der
|
||||||
|
ersten Umkreissuche — die Pipeline prüfte dann etwas anderes als den Betrieb.
|
||||||
|
|
||||||
|
### Wo es gehakt hat
|
||||||
|
|
||||||
|
| Problem | Ursache | Lösung |
|
||||||
|
|---|---|---|
|
||||||
|
| **Eine Wiederherstellung entfernte alle Unveränderlichkeitsgarantien** | Zuerst `--no-acl` im Dump. Und selbst ohne das Flag greift beim Zurückspielen die Vorgabeprivilegierung aus dem Init-Skript: jede von `pg_restore` neu angelegte Tabelle bekommt alle Rechte, und `GRANT` kann nur hinzufügen | Das Skript setzt die Sperren nach dem Zurückspielen neu und prüft danach nach. Gefunden haben es vier Datenbanktests direkt nach der ersten Rückspielprobe |
|
||||||
|
| `pg_dump` lehnte die Verbindungszeichenkette ab | Prisma hängt `?schema=public` an — „invalid URI query parameter" | Die URL wird zerlegt, Benutzer und Datenbank gehen als Flags, das Passwort über `PGPASSWORD` |
|
||||||
|
| Feldnamen im Auskunftssammler stimmten reihenweise nicht | Aus dem Gedächtnis geschrieben statt aus dem Schema gelesen: `alarmierung` statt `alarmiertUm`, `beginn` statt `beginnUm`, `gemeldetVonId` statt `melderId` | Der Typechecker hat sie alle einzeln benannt — zehn Durchgänge, jeder eine Zeile |
|
||||||
|
| Ein eigener Test war falsch, nicht der Code | `new URL("localhost:5432")` gelingt in Node: „localhost:" gilt als Protokoll | Die Zusicherung prüft jetzt, was wirklich greift — die Protokollprüfung eine Zeile später |
|
||||||
|
| Ein Vorlagentest fiel im vollen Lauf aus | Er klickte auf „Speichern“, bevor React lief — dann verschickt das Formular sich selbst, und die Fehlerfläche entsteht nie. Unter Last dauert das Hydrieren länger. Dasselbe Muster wie beim Anmeldeskript in Phase 8 | Er wartet jetzt auf einen client-seitig erzeugten Knopf, bevor er klickt |
|
||||||
|
|
||||||
|
### Abweichung von der Vorgabe
|
||||||
|
|
||||||
|
**Die Pipeline ist nicht erprobt.** Sie ist geschrieben und syntaktisch
|
||||||
|
geprüft, aber in Gitea war zum Zeitpunkt dieser Phase kein `act_runner`
|
||||||
|
registriert. Ohne Runner erscheinen die Läufe als „wartend" und laufen nie.
|
||||||
|
Ob der Ablauf durchläuft, zeigt sich erst, wenn einer eingerichtet ist — das
|
||||||
|
steht so auch im README, damit niemand sich auf eine Prüfung verlässt, die
|
||||||
|
nicht stattfindet.
|
||||||
|
|
||||||
|
### Nachweise
|
||||||
|
|
||||||
|
- `npm run typecheck` — ohne Ausgabe
|
||||||
|
- `npm run lint` — ohne Befund
|
||||||
|
- `npm run test` — 599 Tests grün, davon 18 neu
|
||||||
|
- Auskunft von Hand erzeugt: 13 Abschnitte, 28 Angaben, 58 kB PDF
|
||||||
|
- **Sicherung und Wiederherstellung vollständig durchgeführt**: ein nach der
|
||||||
|
Sicherung angelegter Datensatz war danach weg, alle Zahlen stimmten wieder,
|
||||||
|
und die 599 Tests liefen nach dem Zurückspielen grün — einschließlich derer,
|
||||||
|
die die Sperren prüfen
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,283 @@
|
|||||||
|
# FLORIAN — Kurzanleitung
|
||||||
|
|
||||||
|
Alles, was man am Anfang braucht, auf ein paar Seiten. Nach Rollen sortiert:
|
||||||
|
such dir den Abschnitt, der zu dir gehört, und lies den Rest später.
|
||||||
|
|
||||||
|
Ausführlich steht alles in der [Anleitung](ANLEITUNG.md) — 63 Kapitel, zum
|
||||||
|
Nachschlagen, nicht zum Durchlesen.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Für alle
|
||||||
|
|
||||||
|
### Anmelden
|
||||||
|
|
||||||
|
FLORIAN läuft im Browser. Die Adresse bekommst du vom Kommandanten.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
Nach der Anmeldung landest du auf der Übersicht: was ansteht, was fällig ist,
|
||||||
|
was gemeldet wurde.
|
||||||
|
|
||||||
|
**Das Passwort gehört nach der ersten Anmeldung geändert.** Oben rechts auf
|
||||||
|
deinen Namen.
|
||||||
|
|
||||||
|
### Was du überall findest
|
||||||
|
|
||||||
|
| Links in der Leiste | Wofür |
|
||||||
|
|---|---|
|
||||||
|
| **Übersicht** | Die Startseite: Fristen, Mängel, nächste Termine |
|
||||||
|
| **Termine** | Übungsabende, Lehrgänge, Versammlungen — mit Rückmeldung |
|
||||||
|
| **Dienstausweis** | Dein Ausweis mit QR-Code, auch fürs Handy |
|
||||||
|
| **Wissen** | Merkblätter und Dienstanweisungen der Wehr |
|
||||||
|
|
||||||
|
Du siehst nur, was zu deiner Rolle gehört. Fehlt dir etwas, sag dem
|
||||||
|
Kommandanten Bescheid — Rollen werden dort vergeben.
|
||||||
|
|
||||||
|
### Zu einem Termin zurückmelden
|
||||||
|
|
||||||
|
**Termine** öffnen, den Termin anklicken, **Zusagen** oder **Absagen**. Fertig.
|
||||||
|
Wer absagt, kann einen Grund angeben; das hilft dem Ausbilder bei der Planung.
|
||||||
|
|
||||||
|
### Am Terminal im Gerätehaus
|
||||||
|
|
||||||
|
Am Wandtablet meldest du dich mit **PIN oder Ausweiskarte** an — nicht mit
|
||||||
|
E-Mail und Passwort. Deine PIN bekommst du vom Kommandanten.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
Dort geht: anmelden zum Dienst, einen Mangel melden, eine Fahrt eintragen,
|
||||||
|
Schutzkleidung zur Reinigung geben.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Für den Gerätewart
|
||||||
|
|
||||||
|
Das ist der Bereich, in dem FLORIAN am meisten abnimmt.
|
||||||
|
|
||||||
|
### Der wichtigste Bildschirm: Prüfungen
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
Unter **Prüfungen** steht, was wann fällig wird. Rot heißt überfällig, gelb
|
||||||
|
heißt demnächst. Die Fristen rechnet FLORIAN selbst aus den Prüfintervallen
|
||||||
|
der Gerätetypen aus — du musst nichts nachhalten.
|
||||||
|
|
||||||
|
### Ein Gerät prüfen
|
||||||
|
|
||||||
|
1. **Prüfungen** → das fällige Gerät anklicken → **Prüfung durchführen**.
|
||||||
|
2. Die Checkliste durchgehen. Sie kommt aus der Wartungsvorlage des Gerätetyps.
|
||||||
|
3. Ergebnis eintragen, unterschreiben, abschließen.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
Danach entsteht ein **Prüfprotokoll als PDF** — das ist der Nachweis gegenüber
|
||||||
|
der Unfallkasse.
|
||||||
|
|
||||||
|
> **Achtung:** Ein abgeschlossenes Protokoll lässt sich nicht mehr ändern. Das
|
||||||
|
> ist Absicht — ein nachträglich änderbarer Nachweis wäre keiner. Ist etwas
|
||||||
|
> falsch, wird eine neue Prüfung erfasst.
|
||||||
|
|
||||||
|
### Ein Gerät anlegen
|
||||||
|
|
||||||
|
**Geräte** → **Gerät anlegen**. Pflicht sind nur Inventarnummer und
|
||||||
|
Bezeichnung; der Rest kann nachkommen.
|
||||||
|
|
||||||
|
Wähle möglichst einen **Gerätetyp** — daran hängen Prüfintervall und
|
||||||
|
Lebensdauer, und ohne Typ rechnet FLORIAN keine Frist.
|
||||||
|
|
||||||
|
### Einen Mangel bearbeiten
|
||||||
|
|
||||||
|
Mängel kommen meist vom Terminal — jemand meldet sie beim Einrücken.
|
||||||
|
|
||||||
|
**Mängel** → anklicken → jemandem zuweisen → nach der Behebung **erledigen**.
|
||||||
|
Ein Gerät mit einem schweren Mangel wird automatisch gesperrt.
|
||||||
|
|
||||||
|
### Was noch dazugehört
|
||||||
|
|
||||||
|
| Wo | Wofür |
|
||||||
|
|---|---|
|
||||||
|
| **Rückrufe** | Herstellerrückrufe: welche eurer Geräte betroffen sind |
|
||||||
|
| **Lebensdauer** | Helme, Schläuche, Schutzkleidung mit Höchstalter |
|
||||||
|
| **Fahrzeuge** | Beladung nach Geräteräumen |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Für den Atemschutzgerätewart
|
||||||
|
|
||||||
|
> **Der wichtigste Satz zuerst:** Die Überwachungstafel am Fahrzeug bleibt
|
||||||
|
> führend. FLORIAN ergänzt sie, ersetzt sie nicht. Das steht auch dauerhaft
|
||||||
|
> über jeder Ansicht, und es soll dort stehen.
|
||||||
|
|
||||||
|
### Wer darf unter Pressluftatmer?
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
**Atemschutz** zeigt jeden Träger mit seinem Stand: G26, Belastungsübung,
|
||||||
|
Einsatzübung. Wer eine der Voraussetzungen nicht erfüllt, steht rot da — mit
|
||||||
|
Klartext, woran es liegt, nicht nur mit einer Farbe.
|
||||||
|
|
||||||
|
### Eine G26 eintragen
|
||||||
|
|
||||||
|
**Atemschutz** → **Nachweise** → Person wählen → G26 erfassen. Untersuchungsdatum,
|
||||||
|
Ergebnis und Gültigkeit reichen; den Befund speichert FLORIAN bewusst nicht.
|
||||||
|
|
||||||
|
Dasselbe für Belastungsübungen.
|
||||||
|
|
||||||
|
### Eine Überwachung führen
|
||||||
|
|
||||||
|
**Atemschutz** → **Überwachung eröffnen**. Trupp zusammenstellen, Druckwerte
|
||||||
|
in Intervallen eintragen, Rückzugsdruck im Blick behalten. FLORIAN rechnet den
|
||||||
|
Rückzugsdruck und warnt, bevor es knapp wird.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
Nach dem Ende entsteht ein vollständiges Protokoll als PDF.
|
||||||
|
|
||||||
|
> Solange ein Trupp im Einsatz ist, lässt sich die Überwachung nicht beenden.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Für den Kommandanten
|
||||||
|
|
||||||
|
### Mitglieder und Rollen
|
||||||
|
|
||||||
|
**Mitglieder** → Person anklicken → Rollen vergeben.
|
||||||
|
|
||||||
|
Neun Rollen, jede mit festem Rechteumfang. Die wichtigsten: Gerätewart,
|
||||||
|
Atemschutzgerätewart, Kassier, Schriftführer, Ausbilder.
|
||||||
|
|
||||||
|
**Jemand kommt nicht hinein?** Dann hat die Person eine Akte, aber kein Konto.
|
||||||
|
Konten verwaltet, wer den Server betreibt — unter *Wehren und Konten*.
|
||||||
|
|
||||||
|
### Einen Einsatz erfassen
|
||||||
|
|
||||||
|
**Einsätze** → **Einsatz anlegen**. Die sechs Zeitstempel (Alarmierung,
|
||||||
|
Ausrücken, Eintreffen, Einsatzende, Einrücken, Einsatzbereitschaft), die
|
||||||
|
beteiligten Fahrzeuge und die Mannschaft.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
Daraus entsteht der **Einsatzbericht als PDF** und die Statistik fürs Jahr.
|
||||||
|
|
||||||
|
### Kostenersatz abrechnen
|
||||||
|
|
||||||
|
Fehlalarm einer Brandmeldeanlage, Öleinsatz mit Verursacher, vermieteter
|
||||||
|
Schulungsraum: **Kasse** → **Rechnungen** → *Rechnung schreiben*. Die Nummer
|
||||||
|
vergibt FLORIAN fortlaufend.
|
||||||
|
|
||||||
|
### Wenn jemand Auskunft über seine Daten verlangt
|
||||||
|
|
||||||
|
Kommt selten vor, hat aber eine Frist von einem Monat.
|
||||||
|
|
||||||
|
**Mitglieder** → Person → **Datenauskunft** → *Auskunft erstellen*. Daraus
|
||||||
|
entsteht ein PDF mit allem, was über die Person gespeichert ist. Ausdrucken
|
||||||
|
und aushändigen, fertig.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Für den Kassier
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
### Buchen
|
||||||
|
|
||||||
|
**Kasse** → **Buchungen** → *Buchung erfassen*. Der Betrag ist immer positiv;
|
||||||
|
ob Einnahme oder Ausgabe, sagen die beiden Knöpfe daneben.
|
||||||
|
|
||||||
|
FLORIAN schlägt eine Kategorie vor — meist die richtige, weil er nachsieht,
|
||||||
|
wie ihr dieselbe Sache zuletzt gebucht habt.
|
||||||
|
|
||||||
|
### Kontoauszug einlesen
|
||||||
|
|
||||||
|
Statt fünfzig Umsätze abzutippen: im Online-Banking den **Umsatzexport**
|
||||||
|
herunterladen (CAMT.053 oder MT940), dann **Kasse** → **Buchungen** →
|
||||||
|
*Kontoauszug*.
|
||||||
|
|
||||||
|
Es entstehen **Entwürfe**, keine fertigen Buchungen — durchsehen musst du sie.
|
||||||
|
Umsätze, die schon in der Kasse stehen, erkennt FLORIAN und hakt sie nicht
|
||||||
|
noch einmal ab.
|
||||||
|
|
||||||
|
### Beiträge
|
||||||
|
|
||||||
|
Einmal im Jahr: **Kasse** → **Beiträge** → *Beitragslauf*. Vorher steht da, wie
|
||||||
|
viele Beiträge entstehen — und wer keinen passenden Satz hat.
|
||||||
|
|
||||||
|
Zahlungen hakst du in der Liste ab; dabei entsteht gleich die Buchung.
|
||||||
|
|
||||||
|
### Festschreiben — und was das bedeutet
|
||||||
|
|
||||||
|
Eine neue Buchung ist erst einmal ein **Entwurf** und änderbar. Mit
|
||||||
|
*Festschreiben* wird sie unveränderbar.
|
||||||
|
|
||||||
|
Ist danach etwas falsch, wird **gegengebucht**: eine zweite Buchung in die
|
||||||
|
andere Richtung, mit Begründung. Beide bleiben stehen. Das sieht nach mehr
|
||||||
|
Aufwand aus und ist genau der Punkt — eine verschwundene Buchung kann ein
|
||||||
|
Kassenprüfer nicht prüfen, eine berichtigte schon.
|
||||||
|
|
||||||
|
### Kassenprüfung
|
||||||
|
|
||||||
|
**Kasse** → **Abschluss** → *Kassenprüfung anlegen*. Links steht der Bestand
|
||||||
|
laut Buch, rechts trägst du ein, was gezählt wurde.
|
||||||
|
|
||||||
|
Bleibt eine Differenz, muss sie erklärt werden — sonst lässt sich der Abschluss
|
||||||
|
nicht festschreiben. In fünf Jahren weiß sonst niemand mehr, woher sie kam.
|
||||||
|
|
||||||
|
Danach entsteht der **Kassenprüfbericht als PDF** mit Unterschriftsfeldern.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Für den, der den Server betreibt
|
||||||
|
|
||||||
|
### Sichern
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run sicherung
|
||||||
|
```
|
||||||
|
|
||||||
|
Sichert Datenbank **und** Dateien. Das Ergebnis gehört auf ein anderes Gerät —
|
||||||
|
eine Sicherung auf derselben Festplatte hilft gegen einen Festplattenfehler
|
||||||
|
nicht.
|
||||||
|
|
||||||
|
### Einmal im Jahr proben
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run wiederherstellen -- --quelle sicherungen/<Ordner>
|
||||||
|
```
|
||||||
|
|
||||||
|
So passiert nichts, es wird nur angezeigt, was geschähe. Erst mit `--wirklich`
|
||||||
|
wird zurückgespielt.
|
||||||
|
|
||||||
|
> Eine Sicherung, die nie zurückgespielt wurde, ist eine Vermutung. Der Ordner
|
||||||
|
> kann leer sein, der Auszug abgeschnitten — man erfährt es sonst an dem Tag,
|
||||||
|
> an dem man es nicht erfahren will.
|
||||||
|
|
||||||
|
### Aktualisieren
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./update.sh --pruefen # nachsehen, ob es etwas Neues gibt
|
||||||
|
./update.sh # einspielen
|
||||||
|
```
|
||||||
|
|
||||||
|
Das Skript sichert von sich aus, bevor es etwas anfasst, und sagt bei einem
|
||||||
|
Fehler, wie der vorherige Stand zurückkommt.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Wenn etwas nicht geht
|
||||||
|
|
||||||
|
| Was | Woran es meist liegt |
|
||||||
|
|---|---|
|
||||||
|
| **Anmeldung geht nicht** | Nach fünf Fehlversuchen ist das Konto fünf Minuten gesperrt. Kurz warten. |
|
||||||
|
| **Ein Menüpunkt fehlt** | Die Rolle fehlt. Der Kommandant vergibt sie unter *Mitglieder*. |
|
||||||
|
| **Ein PDF entsteht nicht** | Es wird im Hintergrund erzeugt; nach ein paar Sekunden die Seite neu laden. |
|
||||||
|
| **Das Terminal meldet niemanden an** | PIN falsch, oder das Tablet ist nicht mehr eingerichtet. |
|
||||||
|
| **Eine Buchung lässt sich nicht ändern** | Sie ist festgeschrieben. Gegenbuchen. |
|
||||||
|
|
||||||
|
Steht die Antwort nicht dabei: [Anleitung, letztes Kapitel](ANLEITUNG.md).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*FLORIAN läuft auf einem Server, den die Feuerwehr selbst betreibt. Es gibt
|
||||||
|
keine Telemetrie, keine Cloud und keine Weitergabe an Dritte — die Daten
|
||||||
|
bleiben im Gerätehaus.*
|
||||||
+49
-9
@@ -20,7 +20,8 @@ Phase 7 ████████████████████ fertig
|
|||||||
Phase 8 ████████████████████ fertig 0.9.0
|
Phase 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
|
||||||
Freigabe ░░░░░░░░░░░░░░░░░░░░ offen 1.0.0
|
Phase 11 ████████████████████ fertig 0.12.0
|
||||||
|
Freigabe ████████████████████ fertig 1.0.0
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -290,17 +291,56 @@ sie schlimmer als keine. Die Aufsicht sieht jede Wehr einzeln.
|
|||||||
jemand über die Grenze sieht — und die Kreisebene kann hineinschauen, ohne
|
jemand über die Grenze sieht — und die Kreisebene kann hineinschauen, ohne
|
||||||
etwas anfassen zu können.
|
etwas anfassen zu können.
|
||||||
|
|
||||||
## 1.0.0 — Freigabe
|
## Phase 11 — Freigabereife · fertig
|
||||||
|
|
||||||
|
Am 05.09.2026 umgesetzt. Kein Modul, sondern die drei Software-Voraussetzungen
|
||||||
|
für die Freigabe.
|
||||||
|
|
||||||
|
- **Auskunft nach Art. 15 DSGVO.** Das Recht `person.export` stand seit Phase 0
|
||||||
|
im Katalog und war zugewiesen — umgesetzt war nichts. Ohne Auskunftsseite ist
|
||||||
|
ein Löschkonzept nicht abnehmbar.
|
||||||
|
- **Sicherung und Wiederherstellung** mit einer durchgeführten Rückspielprobe.
|
||||||
|
- **Pipeline** in Gitea Actions: typecheck, lint, Migrationen, Seed und Tests.
|
||||||
|
|
||||||
|
Die Rückspielprobe hat sich sofort bezahlt gemacht: der erste Versuch lief
|
||||||
|
sauber durch und hatte dabei sämtliche Unveränderlichkeitsgarantien der
|
||||||
|
Datenbank entfernt. Gefunden haben es die Datenbanktests unmittelbar danach —
|
||||||
|
also genau die Probe, wegen der 1.0.0 sie verlangt.
|
||||||
|
|
||||||
|
**Die Wehr kann jetzt:** eine Selbstauskunft erteilen, ihren Bestand sichern
|
||||||
|
und ihn nachweislich zurückholen.
|
||||||
|
|
||||||
|
## 1.0.0 — Freigabe · am 06.09.2026 erteilt
|
||||||
|
|
||||||
Kein neues Modul, sondern eine Entscheidung: die Wehr setzt FLORIAN im
|
Kein neues Modul, sondern eine Entscheidung: die Wehr setzt FLORIAN im
|
||||||
Echtbetrieb ein. Voraussetzungen:
|
Echtbetrieb ein.
|
||||||
|
|
||||||
- Datenübernahme aus der bisherigen Verwaltung abgeschlossen und abgeglichen
|
**Damit das nicht zur Formalie wird, hier der Stand jeder Voraussetzung — auch
|
||||||
- Sicherungskonzept steht und ist einmal wiederhergestellt worden
|
der offenen.** Eine Freigabe, die alles abhakt, was ohnehin nicht geprüft
|
||||||
- Schulung von Kommandant, Gerätewart und Atemschutzgerätewart erfolgt
|
wurde, ist keine.
|
||||||
- Löschkonzept nach DSGVO abgenommen
|
|
||||||
- Der Hinweis zur analogen Atemschutzüberwachung wird bewusst und
|
| Voraussetzung | Stand |
|
||||||
dokumentiert abgeschaltet — oder bewusst stehen gelassen
|
|---|---|
|
||||||
|
| Datenübernahme aus der bisherigen Verwaltung | **entfällt.** Die Wehr pflegt den Bestand von Hand ein; es gibt kein Altsystem, aus dem übernommen würde. Für Mitglieder, Fahrzeuge und Geräte steht der Import-Assistent bereit, falls sich das ändert. |
|
||||||
|
| Sicherungskonzept steht und ist einmal wiederhergestellt worden | **erledigt** mit Phase 11. Die Probe hat dabei einen Fehler gefunden, der sonst erst im Notfall aufgefallen wäre. |
|
||||||
|
| Löschkonzept nach DSGVO | **technisch vollständig** seit Phase 11: Auskunft nach Art. 15, Anonymisierung im Aufbewahrungslauf, konfigurierbare Fristen. Die Abnahme durch die Wehr ist ein organisatorischer Schritt und steht aus. |
|
||||||
|
| Schulung von Kommandant, Gerätewart und Atemschutzgerätewart | **steht aus.** Sie läuft nach der Freigabe; Grundlage ist die [Kurzanleitung](KURZANLEITUNG.md). |
|
||||||
|
| Hinweis zur analogen Atemschutzüberwachung | **bewusst stehen gelassen.** `ATEMSCHUTZ_HINWEIS=true`. FLORIAN bleibt damit ergänzend; die Tafel am Fahrzeug ist führend. Das Abschalten verlangt ohnehin einen Kommandantenbeschluss samt Fundstelle. |
|
||||||
|
|
||||||
|
Die beiden offenen Punkte sind **organisatorisch, nicht technisch**: an der
|
||||||
|
Software hängt nichts davon ab. Sie stehen hier, damit später nachlesbar ist,
|
||||||
|
was zum Zeitpunkt der Freigabe erledigt war und was nicht.
|
||||||
|
|
||||||
|
### Was 1.0.0 bedeutet — und was nicht
|
||||||
|
|
||||||
|
**Bedeutet:** Der Funktionsumfang ist vollständig, die Datenbank ist stabil,
|
||||||
|
Migrationen sind aufwärtskompatibel, und ab hier gilt semantische
|
||||||
|
Versionierung. Ein Update bricht nichts, ohne dass es in der Hauptnummer
|
||||||
|
steht.
|
||||||
|
|
||||||
|
**Bedeutet nicht:** dass keine Fehler mehr da sind. Es bedeutet, dass die
|
||||||
|
Wehr echte Daten hineingeben kann, ohne befürchten zu müssen, sie beim
|
||||||
|
nächsten Update zu verlieren.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
+3
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "florian",
|
"name": "florian",
|
||||||
"version": "0.11.0",
|
"version": "1.0.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();
|
||||||
|
|||||||
@@ -77,11 +77,15 @@ afterAll(async () => {
|
|||||||
// Protokollzeilen lassen sich nicht loeschen — die Trigger sitzen auf
|
// Protokollzeilen lassen sich nicht loeschen — die Trigger sitzen auf
|
||||||
// den Tabellen. Fuer das Aufraeumen der Testdaten werden sie kurz
|
// den Tabellen. Fuer das Aufraeumen der Testdaten werden sie kurz
|
||||||
// abgeschaltet; im Betrieb passiert das nie.
|
// abgeschaltet; im Betrieb passiert das nie.
|
||||||
await tx.$executeRawUnsafe('ALTER TABLE "druckmessung" DISABLE TRIGGER USER');
|
// Trigger fuer das Aufraeumen stilllegen — sitzungslokal.
|
||||||
await tx.$executeRawUnsafe('ALTER TABLE "atemschutz_ereignis" DISABLE TRIGGER USER');
|
//
|
||||||
|
// NICHT ueber `ALTER TABLE ... DISABLE TRIGGER`: das ist eine
|
||||||
|
// Schemaänderung und gilt fuer alle. Vitest faehrt die Testdateien
|
||||||
|
// parallel, und waehrend hier abgeraeumt wurde, pruefte anderswo
|
||||||
|
// eine Datei, dass genau dieser Trigger greift — und sah keinen.
|
||||||
|
// `session_replication_role` wirkt nur in dieser Transaktion.
|
||||||
|
await tx.$executeRawUnsafe("SET LOCAL session_replication_role = 'replica'");
|
||||||
await tx.organisation.deleteMany({ where: { slug: SLUG } });
|
await tx.organisation.deleteMany({ where: { slug: SLUG } });
|
||||||
await tx.$executeRawUnsafe('ALTER TABLE "druckmessung" ENABLE TRIGGER USER');
|
|
||||||
await tx.$executeRawUnsafe('ALTER TABLE "atemschutz_ereignis" ENABLE TRIGGER USER');
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
await Promise.allSettled([app.$disconnect(), system.$disconnect()]);
|
await Promise.allSettled([app.$disconnect(), system.$disconnect()]);
|
||||||
|
|||||||
@@ -0,0 +1,221 @@
|
|||||||
|
import { PrismaClient } from "@prisma/client";
|
||||||
|
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||||
|
import { auskunftSammeln, auskunftUmfang } from "@/lib/dsgvo/auskunft";
|
||||||
|
import type { MandantenClient } from "@/lib/db";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Die Auskunft nach Art. 15 DSGVO.
|
||||||
|
*
|
||||||
|
* Zwei Dinge müssen stimmen, und sie ziehen in verschiedene Richtungen.
|
||||||
|
*
|
||||||
|
* **Vollständigkeit.** Was über die Person gespeichert ist, muss drinstehen —
|
||||||
|
* auch das Unangenehme. Eine Auskunft, die den gemahnten Beitrag oder die
|
||||||
|
* nicht bestandene G26 auslässt, ist keine.
|
||||||
|
*
|
||||||
|
* **Begrenzung.** Was die Person *getan* hat, gehört zum Nachweisbestand der
|
||||||
|
* Wehr. Stünde es vollständig drin, käme jeder über eine Selbstauskunft an
|
||||||
|
* die Geräteakten — und die Auskunftspflicht wäre eine Hintertür.
|
||||||
|
*
|
||||||
|
* Der Test prüft deshalb beides: dass die Abschnitte da sind, und dass die
|
||||||
|
* Tätigkeiten nur gezählt werden.
|
||||||
|
*
|
||||||
|
* Braucht eine laufende Datenbank; ohne sie überspringt er sich selbst.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const SYSTEM_URL = process.env.DIRECT_DATABASE_URL ?? process.env.DATABASE_URL ?? "";
|
||||||
|
const system = new PrismaClient({ datasources: { db: { url: SYSTEM_URL } } });
|
||||||
|
|
||||||
|
const SLUG = "test-auskunft-wehr";
|
||||||
|
|
||||||
|
let erreichbar = false;
|
||||||
|
let organisationId = "";
|
||||||
|
let personId = "";
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
try {
|
||||||
|
await system.$queryRaw`SELECT 1`;
|
||||||
|
erreichbar = true;
|
||||||
|
} catch {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const organisation = await system.organisation.upsert({
|
||||||
|
where: { slug: SLUG },
|
||||||
|
update: {},
|
||||||
|
create: { slug: SLUG, name: "Auskunfts-Testwehr", kurzname: "ATW" },
|
||||||
|
});
|
||||||
|
organisationId = organisation.id;
|
||||||
|
|
||||||
|
const person = await system.person.upsert({
|
||||||
|
where: { organisationId_mitgliedsnummer: { organisationId, mitgliedsnummer: "A-1" } },
|
||||||
|
update: {},
|
||||||
|
create: {
|
||||||
|
organisationId,
|
||||||
|
vorname: "Auskunft",
|
||||||
|
nachname: "Suchend",
|
||||||
|
mitgliedsnummer: "A-1",
|
||||||
|
geburtsdatum: new Date("1985-04-12"),
|
||||||
|
eintrittsdatum: new Date("2010-01-01"),
|
||||||
|
email: "auskunft@test-auskunft.de",
|
||||||
|
strasse: "Musterweg 1",
|
||||||
|
plz: "91315",
|
||||||
|
ort: "Musterhausen",
|
||||||
|
abteilung: "Aktive",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
personId = person.id;
|
||||||
|
|
||||||
|
// Gesundheitsdaten — die besondere Kategorie, um die es geht.
|
||||||
|
await system.g26Untersuchung.create({
|
||||||
|
data: {
|
||||||
|
organisationId,
|
||||||
|
personId,
|
||||||
|
untersuchtAm: new Date("2025-03-01"),
|
||||||
|
gueltigBis: new Date("2028-03-01"),
|
||||||
|
ergebnis: "TAUGLICH",
|
||||||
|
arzt: "Dr. Beispiel",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Ein gemahnter Beitrag — das Unangenehme, das drinstehen muss.
|
||||||
|
await system.beitrag.create({
|
||||||
|
data: {
|
||||||
|
organisationId,
|
||||||
|
personId,
|
||||||
|
jahr: 2025,
|
||||||
|
betrag: "25.00",
|
||||||
|
stand: "GEMAHNT",
|
||||||
|
mahnstufe: 2,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
if (erreichbar) {
|
||||||
|
await system.$transaction(async (tx) => {
|
||||||
|
await tx.$executeRaw`SELECT set_config('app.audit_bereinigung', 'an', true)`;
|
||||||
|
await tx.organisation.deleteMany({ where: { slug: SLUG } });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
await system.$disconnect();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe.runIf(process.env.DATABASE_URL)("Auskunft nach Art. 15", () => {
|
||||||
|
it("nennt Person, Wehr und Zeitpunkt", async ({ skip }) => {
|
||||||
|
if (!erreichbar) return skip();
|
||||||
|
|
||||||
|
const auskunft = await auskunftSammeln(system as unknown as MandantenClient, personId);
|
||||||
|
|
||||||
|
expect(auskunft.person.name).toBe("Auskunft Suchend");
|
||||||
|
expect(auskunft.person.mitgliedsnummer).toBe("A-1");
|
||||||
|
expect(auskunft.wehr.name).toBe("Auskunfts-Testwehr");
|
||||||
|
expect(auskunft.erstelltAm).toBeInstanceOf(Date);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("gibt zu jedem Abschnitt an, wozu die Daten vorliegen", async ({ skip }) => {
|
||||||
|
if (!erreichbar) return skip();
|
||||||
|
|
||||||
|
// Art. 15 Abs. 1 verlangt nicht nur die Daten, sondern auch die Zwecke.
|
||||||
|
// Eine Liste ohne sie sieht aus wie eine Auskunft und ist keine.
|
||||||
|
const auskunft = await auskunftSammeln(system as unknown as MandantenClient, personId);
|
||||||
|
|
||||||
|
expect(auskunft.abschnitte.length).toBeGreaterThan(5);
|
||||||
|
for (const abschnitt of auskunft.abschnitte) {
|
||||||
|
expect(abschnitt.titel.length).toBeGreaterThan(0);
|
||||||
|
expect(abschnitt.zweck.length).toBeGreaterThan(20);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("enthält die Stammdaten vollständig", async ({ skip }) => {
|
||||||
|
if (!erreichbar) return skip();
|
||||||
|
|
||||||
|
const auskunft = await auskunftSammeln(system as unknown as MandantenClient, personId);
|
||||||
|
const stammdaten = auskunft.abschnitte.find((eintrag) => eintrag.titel === "Stammdaten");
|
||||||
|
|
||||||
|
expect(stammdaten?.eintraege[0]).toMatchObject({
|
||||||
|
Vorname: "Auskunft",
|
||||||
|
Nachname: "Suchend",
|
||||||
|
Geburtsdatum: "1985-04-12",
|
||||||
|
Anschrift: "Musterweg 1, 91315 Musterhausen",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("kennzeichnet Gesundheitsdaten als besondere Kategorie", async ({ skip }) => {
|
||||||
|
if (!erreichbar) return skip();
|
||||||
|
|
||||||
|
const auskunft = await auskunftSammeln(system as unknown as MandantenClient, personId);
|
||||||
|
const g26 = auskunft.abschnitte.find((eintrag) => eintrag.titel.includes("G 26"));
|
||||||
|
|
||||||
|
expect(g26?.besondereKategorie).toBe(true);
|
||||||
|
expect(g26?.eintraege).toHaveLength(1);
|
||||||
|
expect(g26?.eintraege[0]?.Ergebnis).toBe("TAUGLICH");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("lässt das Unangenehme nicht weg", async ({ skip }) => {
|
||||||
|
if (!erreichbar) return skip();
|
||||||
|
|
||||||
|
// Ein gemahnter Beitrag ist ein Datum über diese Person. Eine Auskunft,
|
||||||
|
// die auswählt, was gut aussieht, erfüllt die Pflicht nicht.
|
||||||
|
const auskunft = await auskunftSammeln(system as unknown as MandantenClient, personId);
|
||||||
|
const beitraege = auskunft.abschnitte.find((eintrag) =>
|
||||||
|
eintrag.titel.includes("Mitgliedsbeiträge"),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(beitraege?.eintraege[0]).toMatchObject({ Stand: "GEMAHNT", Mahnstufe: "2" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("behält leere Abschnitte — auch das ist eine Auskunft", async ({ skip }) => {
|
||||||
|
if (!erreichbar) return skip();
|
||||||
|
|
||||||
|
// Diese Person war in keinem Einsatz. Der Abschnitt muss trotzdem
|
||||||
|
// dastehen: ein weggelassener wirft die Frage auf, ob da doch etwas ist.
|
||||||
|
const auskunft = await auskunftSammeln(system as unknown as MandantenClient, personId);
|
||||||
|
const einsaetze = auskunft.abschnitte.find((eintrag) =>
|
||||||
|
eintrag.titel.includes("Einsatzbeteiligung"),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(einsaetze).toBeDefined();
|
||||||
|
expect(einsaetze?.eintraege).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("zählt Tätigkeiten, statt die Unterlagen mitzuliefern", async ({ skip }) => {
|
||||||
|
if (!erreichbar) return skip();
|
||||||
|
|
||||||
|
// Die Grenze zwischen „über mich" und „von mir". Stünden hier die
|
||||||
|
// Prüfprotokolle im Volltext, käme jeder über eine Selbstauskunft an die
|
||||||
|
// Geräteakten der Wehr.
|
||||||
|
const auskunft = await auskunftSammeln(system as unknown as MandantenClient, personId);
|
||||||
|
|
||||||
|
expect(auskunft.taetigkeiten.length).toBeGreaterThan(0);
|
||||||
|
for (const eintrag of auskunft.taetigkeiten) {
|
||||||
|
expect(typeof eintrag.anzahl).toBe("number");
|
||||||
|
expect(eintrag).not.toHaveProperty("eintraege");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("zählt den Umfang über alle Abschnitte", async ({ skip }) => {
|
||||||
|
if (!erreichbar) return skip();
|
||||||
|
|
||||||
|
const auskunft = await auskunftSammeln(system as unknown as MandantenClient, personId);
|
||||||
|
const gezaehlt = auskunft.abschnitte.reduce(
|
||||||
|
(summe, abschnitt) => summe + abschnitt.eintraege.length,
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(auskunftUmfang(auskunft)).toBe(gezaehlt);
|
||||||
|
expect(auskunftUmfang(auskunft)).toBeGreaterThan(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("weist eine unbekannte Person ab, statt eine leere Auskunft zu bauen", async ({ skip }) => {
|
||||||
|
if (!erreichbar) return skip();
|
||||||
|
|
||||||
|
// Sonst bekäme jemand ein Schreiben mit lauter leeren Abschnitten und
|
||||||
|
// dem Eindruck, es lägen keine Daten vor.
|
||||||
|
await expect(
|
||||||
|
auskunftSammeln(
|
||||||
|
system as unknown as MandantenClient,
|
||||||
|
"00000000-0000-0000-0000-000000000000",
|
||||||
|
),
|
||||||
|
).rejects.toThrow(/gibt es nicht/i);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -81,12 +81,15 @@ afterAll(async () => {
|
|||||||
if (erreichbar) {
|
if (erreichbar) {
|
||||||
await system.$transaction(async (tx) => {
|
await system.$transaction(async (tx) => {
|
||||||
await tx.$executeRaw`SELECT set_config('app.audit_bereinigung', 'an', true)`;
|
await tx.$executeRaw`SELECT set_config('app.audit_bereinigung', 'an', true)`;
|
||||||
// Der Unveränderlichkeits-Trigger sitzt auf ki_vorgang. Für das
|
// Trigger fuer das Aufraeumen stilllegen — sitzungslokal.
|
||||||
// Aufräumen der Testdaten wird er kurz abgeschaltet; im Betrieb
|
//
|
||||||
// passiert das nie.
|
// NICHT ueber `ALTER TABLE ... DISABLE TRIGGER`: das ist eine
|
||||||
await tx.$executeRawUnsafe('ALTER TABLE "ki_vorgang" DISABLE TRIGGER USER');
|
// Schemaänderung und gilt fuer alle. Vitest faehrt die Testdateien
|
||||||
|
// parallel, und waehrend hier abgeraeumt wurde, pruefte anderswo
|
||||||
|
// eine Datei, dass genau dieser Trigger greift — und sah keinen.
|
||||||
|
// `session_replication_role` wirkt nur in dieser Transaktion.
|
||||||
|
await tx.$executeRawUnsafe("SET LOCAL session_replication_role = 'replica'");
|
||||||
await tx.organisation.deleteMany({ where: { slug: SLUG } });
|
await tx.organisation.deleteMany({ where: { slug: SLUG } });
|
||||||
await tx.$executeRawUnsafe('ALTER TABLE "ki_vorgang" ENABLE TRIGGER USER');
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
await Promise.allSettled([app.$disconnect(), system.$disconnect()]);
|
await Promise.allSettled([app.$disconnect(), system.$disconnect()]);
|
||||||
|
|||||||
@@ -43,6 +43,15 @@ beforeAll(async () => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Erst aufraeumen, dann anlegen.
|
||||||
|
//
|
||||||
|
// Aufraeumen nur am Ende reicht nicht: bricht ein Lauf ab — abgebrochene
|
||||||
|
// Sitzung, Fehler in einer anderen Datei, geschlossenes Terminal —, bleiben
|
||||||
|
// die Datensaetze stehen und der naechste Lauf scheitert an einer
|
||||||
|
// Eindeutigkeit, die mit dem Geprueften nichts zu tun hat. Das kostet dann
|
||||||
|
// zehn Minuten Fehlersuche an der falschen Stelle.
|
||||||
|
await aufraeumen();
|
||||||
|
|
||||||
const a = await system.organisation.upsert({
|
const a = await system.organisation.upsert({
|
||||||
where: { slug: SLUG_A },
|
where: { slug: SLUG_A },
|
||||||
update: {},
|
update: {},
|
||||||
@@ -91,14 +100,17 @@ beforeAll(async () => {
|
|||||||
konto = benutzer.id;
|
konto = benutzer.id;
|
||||||
});
|
});
|
||||||
|
|
||||||
afterAll(async () => {
|
/** Alles entfernen, was dieser Test anlegt. */
|
||||||
if (erreichbar) {
|
async function aufraeumen(): Promise<void> {
|
||||||
await system.$transaction(async (tx) => {
|
await system.$transaction(async (tx) => {
|
||||||
await tx.$executeRaw`SELECT set_config('app.audit_bereinigung', 'an', true)`;
|
await tx.$executeRaw`SELECT set_config('app.audit_bereinigung', 'an', true)`;
|
||||||
await tx.benutzer.deleteMany({ where: { email: { endsWith: "@test-mandant.de" } } });
|
await tx.benutzer.deleteMany({ where: { email: { endsWith: "@test-mandant.de" } } });
|
||||||
await tx.organisation.deleteMany({ where: { slug: { in: [SLUG_A, SLUG_B] } } });
|
await tx.organisation.deleteMany({ where: { slug: { in: [SLUG_A, SLUG_B] } } });
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
if (erreichbar) await aufraeumen();
|
||||||
await system.$disconnect();
|
await system.$disconnect();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,168 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { zugangAus } from "../../scripts/sicherung-zugang";
|
||||||
|
import { SPERREN } from "../../scripts/sicherung-sperren";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Das Zerlegen der Verbindungszeichenkette.
|
||||||
|
*
|
||||||
|
* Der unangenehmste Fehler, den ein Sicherungsskript machen kann, ist nicht
|
||||||
|
* abzubrechen: es liefe durch, schriebe eine Datei, und die enthielte die
|
||||||
|
* falsche Datenbank. Auffallen würde es beim Zurückspielen — also genau dann,
|
||||||
|
* wenn man die Sicherung braucht.
|
||||||
|
*
|
||||||
|
* Deshalb prüft dieser Test nicht nur, dass richtige Adressen richtig zerlegt
|
||||||
|
* werden, sondern vor allem, dass falsche laut scheitern.
|
||||||
|
*/
|
||||||
|
|
||||||
|
describe("Verbindungszeichenkette zerlegen", () => {
|
||||||
|
it("liest Benutzer, Passwort und Datenbank", () => {
|
||||||
|
const zugang = zugangAus("postgresql://florian:geheim@localhost:5442/florian?schema=public");
|
||||||
|
|
||||||
|
expect(zugang).toEqual({
|
||||||
|
benutzer: "florian",
|
||||||
|
passwort: "geheim",
|
||||||
|
datenbank: "florian",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ignoriert die Prisma-Parameter am Ende", () => {
|
||||||
|
// `?schema=public` ist der Grund, warum die URL nicht als Ganzes an
|
||||||
|
// pg_dump gehen kann — es lehnt sie mit "invalid URI query parameter" ab.
|
||||||
|
const zugang = zugangAus(
|
||||||
|
"postgresql://florian:geheim@localhost:5442/florian?schema=public&connection_limit=5",
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(zugang.datenbank).toBe("florian");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("entschlüsselt Sonderzeichen im Passwort", () => {
|
||||||
|
// Ein Passwort mit @ oder / muss in der URL kodiert stehen. Würde es hier
|
||||||
|
// nicht dekodiert, meldete Postgres nur "Passwort falsch".
|
||||||
|
const zugang = zugangAus("postgresql://florian:ge%40heim%2Fx@localhost:5432/florian");
|
||||||
|
|
||||||
|
expect(zugang.passwort).toBe("ge@heim/x");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("kommt mit einem leeren Passwort zurecht", () => {
|
||||||
|
const zugang = zugangAus("postgresql://florian@localhost:5432/florian");
|
||||||
|
expect(zugang.passwort).toBe("");
|
||||||
|
expect(zugang.benutzer).toBe("florian");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("bricht ab, wenn die Adresse fehlt", () => {
|
||||||
|
expect(() => zugangAus(undefined)).toThrow(/DIRECT_DATABASE_URL fehlt/);
|
||||||
|
expect(() => zugangAus("")).toThrow(/DIRECT_DATABASE_URL fehlt/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("bricht ab, wenn es gar keine URL ist", () => {
|
||||||
|
expect(() => zugangAus("einfach nur text")).toThrow(/keine gültige URL/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("bricht bei „localhost:5432“ ab — über die Protokollprüfung", () => {
|
||||||
|
// Node zerlegt das klaglos: „localhost:“ gilt als Protokoll, „5432“ als
|
||||||
|
// Pfad. Ohne die Protokollprüfung eine Zeile weiter käme hier ein
|
||||||
|
// Zugang mit leerem Benutzer heraus, und die Sicherung liefe ins Leere.
|
||||||
|
expect(() => zugangAus("localhost:5432")).toThrow(/postgresql/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("bricht ab, wenn es nicht Postgres ist", () => {
|
||||||
|
// Sonst würde eine falsch gesetzte Umgebungsvariable eine Sicherung
|
||||||
|
// erzeugen, die nichts enthält.
|
||||||
|
expect(() => zugangAus("mysql://florian:geheim@localhost:3306/florian")).toThrow(
|
||||||
|
/postgresql/,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("bricht ab, wenn der Datenbankname fehlt", () => {
|
||||||
|
expect(() => zugangAus("postgresql://florian:geheim@localhost:5432/")).toThrow(
|
||||||
|
/Datenbankname/,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Die Sperrenliste stimmt mit der Datenbank überein", () => {
|
||||||
|
/**
|
||||||
|
* Der Abgleich gegen die Wirklichkeit.
|
||||||
|
*
|
||||||
|
* `scripts/sicherung-sperren.ts` führt auf, welche Rechte die
|
||||||
|
* Anwendungsrolle nicht haben darf — und stellt sie nach einer
|
||||||
|
* Wiederherstellung wieder her. Diese Liste steht neben den Migrationen,
|
||||||
|
* die dieselben Sperren setzen, und zwei Listen laufen auseinander.
|
||||||
|
*
|
||||||
|
* Läuft sie auseinander, ist der Schaden lautlos: eine Wiederherstellung
|
||||||
|
* würde eine Sperre nicht zurückbringen, von der niemand mehr weiß, dass es
|
||||||
|
* sie gab. Deshalb fragt dieser Test die migrierte Datenbank, statt der
|
||||||
|
* Liste zu glauben.
|
||||||
|
*/
|
||||||
|
it("nennt keine Sperre, die es in der Datenbank nicht gibt", async ({ skip }) => {
|
||||||
|
const url = process.env.DIRECT_DATABASE_URL;
|
||||||
|
if (!url) return skip();
|
||||||
|
|
||||||
|
const { PrismaClient } = await import("@prisma/client");
|
||||||
|
const prisma = new PrismaClient({ datasources: { db: { url } } });
|
||||||
|
|
||||||
|
try {
|
||||||
|
const tabellen = await prisma.$queryRawUnsafe<Array<{ table_name: string }>>(
|
||||||
|
`SELECT table_name FROM information_schema.tables
|
||||||
|
WHERE table_schema = 'public' AND table_type = 'BASE TABLE'`,
|
||||||
|
);
|
||||||
|
const vorhanden = new Set(tabellen.map((zeile) => zeile.table_name));
|
||||||
|
|
||||||
|
const unbekannt = SPERREN.map((eintrag) => eintrag.tabelle).filter(
|
||||||
|
(tabelle) => !vorhanden.has(tabelle),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(unbekannt, `Diese Tabellen gibt es nicht (mehr): ${unbekannt.join(", ")}`).toEqual(
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
await prisma.$disconnect();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("übersieht keine Sperre, die die Migrationen gesetzt haben", async ({ skip }) => {
|
||||||
|
const url = process.env.DIRECT_DATABASE_URL;
|
||||||
|
if (!url) return skip();
|
||||||
|
|
||||||
|
const { PrismaClient } = await import("@prisma/client");
|
||||||
|
const prisma = new PrismaClient({ datasources: { db: { url } } });
|
||||||
|
|
||||||
|
try {
|
||||||
|
const rolle = await prisma.$queryRawUnsafe<Array<{ da: boolean }>>(
|
||||||
|
"SELECT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'florian_app') AS da",
|
||||||
|
);
|
||||||
|
if (!rolle[0]?.da) return skip();
|
||||||
|
|
||||||
|
// Welche Rechte hat die Anwendungsrolle heute wirklich?
|
||||||
|
const zeilen = await prisma.$queryRawUnsafe<
|
||||||
|
Array<{ table_name: string; privilege_type: string }>
|
||||||
|
>(
|
||||||
|
`SELECT table_name, privilege_type FROM information_schema.role_table_grants
|
||||||
|
WHERE grantee = 'florian_app'`,
|
||||||
|
);
|
||||||
|
const hat = new Set(zeilen.map((z) => `${z.table_name}:${z.privilege_type}`));
|
||||||
|
|
||||||
|
// Jede Tabelle, der ein Schreibrecht fehlt, muss in der Liste stehen —
|
||||||
|
// sonst käme sie nach einer Wiederherstellung ungesperrt zurück.
|
||||||
|
const alleTabellen = [...new Set(zeilen.map((z) => z.table_name))];
|
||||||
|
const notiert = new Map(SPERREN.map((e) => [e.tabelle, new Set(e.rechte)]));
|
||||||
|
|
||||||
|
const fehlend: string[] = [];
|
||||||
|
for (const tabelle of alleTabellen) {
|
||||||
|
for (const recht of ["INSERT", "UPDATE", "DELETE"]) {
|
||||||
|
if (!hat.has(`${tabelle}:${recht}`) && !notiert.get(tabelle)?.has(recht)) {
|
||||||
|
fehlend.push(`${tabelle}: ${recht}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(
|
||||||
|
fehlend,
|
||||||
|
"Diese Sperren stehen in der Datenbank, aber nicht in scripts/sicherung-sperren.ts — "
|
||||||
|
+ `nach einer Wiederherstellung wären sie weg: ${fehlend.join(", ")}`,
|
||||||
|
).toEqual([]);
|
||||||
|
} finally {
|
||||||
|
await prisma.$disconnect();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,234 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# =============================================================================
|
||||||
|
# FLORIAN aktualisieren.
|
||||||
|
#
|
||||||
|
# ./update.sh neue Fassung holen und einspielen
|
||||||
|
# ./update.sh --pruefen nur nachsehen, ob es etwas Neues gibt
|
||||||
|
# ./update.sh --ohne-sicherung für Testinstallationen
|
||||||
|
#
|
||||||
|
# Der Ablauf ist so gebaut, dass er an jeder Stelle abbrechen darf, ohne die
|
||||||
|
# Wehr mit einer halb aktualisierten Anlage zurückzulassen:
|
||||||
|
#
|
||||||
|
# 1. Nachsehen, ob überhaupt etwas Neues da ist.
|
||||||
|
# 2. SICHERN — Datenbank und Dateien. Vor allem anderen.
|
||||||
|
# 3. Neue Fassung holen und Abbilder bauen.
|
||||||
|
# 4. Umschalten. Der App-Container spielt dabei die Migrationen ein.
|
||||||
|
# 5. Nachsehen, ob die Anwendung wieder antwortet.
|
||||||
|
#
|
||||||
|
# Geht Schritt 3 schief, läuft die alte Fassung weiter — es wurde nichts
|
||||||
|
# angefasst. Geht Schritt 4 oder 5 schief, steht in der Ausgabe, mit welchem
|
||||||
|
# Befehl sich der vorherige Stand zurückholen lässt.
|
||||||
|
#
|
||||||
|
# Voraussetzungen: Linux, Docker mit Compose-Erweiterung, git, ein .env im
|
||||||
|
# selben Verzeichnis.
|
||||||
|
# =============================================================================
|
||||||
|
set -Eeuo pipefail
|
||||||
|
|
||||||
|
VERZEICHNIS="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
cd "$VERZEICHNIS"
|
||||||
|
|
||||||
|
COMPOSE=(docker compose -f docker-compose.yml)
|
||||||
|
NUR_PRUEFEN=false
|
||||||
|
OHNE_SICHERUNG=false
|
||||||
|
|
||||||
|
for argument in "$@"; do
|
||||||
|
case "$argument" in
|
||||||
|
--pruefen) NUR_PRUEFEN=true ;;
|
||||||
|
--ohne-sicherung) OHNE_SICHERUNG=true ;;
|
||||||
|
-h|--help)
|
||||||
|
sed -n '3,20p' "$0" | sed 's/^# \{0,1\}//'
|
||||||
|
exit 0
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
echo "Unbekannte Angabe: $argument" >&2
|
||||||
|
echo "Bekannt sind: --pruefen, --ohne-sicherung, --help" >&2
|
||||||
|
exit 2
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
# --- Ausgabe ----------------------------------------------------------------
|
||||||
|
# Farben nur, wenn wirklich ein Terminal dranhängt. In einem Cron-Protokoll
|
||||||
|
# sind Steuerzeichen nur Müll.
|
||||||
|
if [ -t 1 ]; then
|
||||||
|
ROT=$'\033[31m'; GRUEN=$'\033[32m'; GELB=$'\033[33m'; FETT=$'\033[1m'; AUS=$'\033[0m'
|
||||||
|
else
|
||||||
|
ROT=''; GRUEN=''; GELB=''; FETT=''; AUS=''
|
||||||
|
fi
|
||||||
|
|
||||||
|
schritt() { echo; echo "${FETT}==> $*${AUS}"; }
|
||||||
|
hinweis() { echo " $*"; }
|
||||||
|
warnung() { echo "${GELB} $*${AUS}"; }
|
||||||
|
fehler() { echo "${ROT}FEHLER: $*${AUS}" >&2; }
|
||||||
|
|
||||||
|
# Wo wir standen, bevor etwas verändert wurde — für die Rückkehr.
|
||||||
|
STAND_VORHER=""
|
||||||
|
SICHERUNG=""
|
||||||
|
|
||||||
|
abbruch() {
|
||||||
|
local zeile=$1
|
||||||
|
echo
|
||||||
|
fehler "Abbruch in Zeile $zeile."
|
||||||
|
echo
|
||||||
|
if [ -n "$STAND_VORHER" ]; then
|
||||||
|
echo "Der vorherige Stand war: $STAND_VORHER"
|
||||||
|
echo "Zurueck damit:"
|
||||||
|
echo " git checkout $STAND_VORHER"
|
||||||
|
echo " ${COMPOSE[*]} up -d --build"
|
||||||
|
fi
|
||||||
|
if [ -n "$SICHERUNG" ]; then
|
||||||
|
echo
|
||||||
|
echo "Die Sicherung von vorhin liegt in: $SICHERUNG"
|
||||||
|
echo "Zurueckspielen (ueberschreibt den Bestand):"
|
||||||
|
echo " npm run wiederherstellen -- --quelle $SICHERUNG --wirklich"
|
||||||
|
fi
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
trap 'abbruch $LINENO' ERR
|
||||||
|
|
||||||
|
# --- Voraussetzungen --------------------------------------------------------
|
||||||
|
schritt "Voraussetzungen"
|
||||||
|
|
||||||
|
for werkzeug in git docker; do
|
||||||
|
if ! command -v "$werkzeug" >/dev/null 2>&1; then
|
||||||
|
fehler "$werkzeug ist nicht installiert."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
if ! docker compose version >/dev/null 2>&1; then
|
||||||
|
fehler "Docker Compose fehlt (die Erweiterung 'docker compose', nicht docker-compose)."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ ! -f .env ]; then
|
||||||
|
fehler "Es gibt keine .env in $VERZEICHNIS."
|
||||||
|
hinweis "Fuer eine Erstinstallation: cp .env.example .env und ausfuellen."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Ein Update mit unverbuchten Aenderungen wuerde sie beim Holen ueberschreiben
|
||||||
|
# oder in einem Konflikt enden. Beides mitten in einem Update ist unschoen.
|
||||||
|
if [ -n "$(git status --porcelain)" ]; then
|
||||||
|
fehler "Im Verzeichnis liegen Aenderungen, die nicht eingecheckt sind."
|
||||||
|
git status --short | head -20
|
||||||
|
echo
|
||||||
|
hinweis "Bitte erst klaeren. Die .env gehoert nicht ins Git und stoert hier nicht."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
hinweis "Docker, git und .env sind da."
|
||||||
|
|
||||||
|
# --- Gibt es etwas Neues? ---------------------------------------------------
|
||||||
|
schritt "Nach einer neuen Fassung sehen"
|
||||||
|
|
||||||
|
ZWEIG="$(git rev-parse --abbrev-ref HEAD)"
|
||||||
|
STAND_VORHER="$(git rev-parse HEAD)"
|
||||||
|
|
||||||
|
git fetch --quiet origin "$ZWEIG"
|
||||||
|
|
||||||
|
STAND_NEU="$(git rev-parse "origin/$ZWEIG")"
|
||||||
|
|
||||||
|
if [ "$STAND_VORHER" = "$STAND_NEU" ]; then
|
||||||
|
hinweis "Alles aktuell — nichts zu tun."
|
||||||
|
hinweis "Stand: $(git log -1 --format='%h %s')"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
ANZAHL="$(git rev-list --count "$STAND_VORHER..$STAND_NEU")"
|
||||||
|
hinweis "$ANZAHL neue Aenderung(en):"
|
||||||
|
git log --oneline --no-decorate "$STAND_VORHER..$STAND_NEU" | head -20 | sed 's/^/ /'
|
||||||
|
|
||||||
|
if [ "$NUR_PRUEFEN" = true ]; then
|
||||||
|
echo
|
||||||
|
hinweis "Nur nachgesehen — es wurde nichts geaendert."
|
||||||
|
hinweis "Zum Einspielen: ./update.sh"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
# --- Sichern ----------------------------------------------------------------
|
||||||
|
# Vor allem anderen, und ohne Umgehung im Regelfall. Ein Update ist der
|
||||||
|
# haeufigste Anlass, eine Sicherung zu brauchen.
|
||||||
|
if [ "$OHNE_SICHERUNG" = true ]; then
|
||||||
|
schritt "Sicherung uebersprungen"
|
||||||
|
warnung "--ohne-sicherung ist gesetzt. Bei einem Fehler gibt es kein Zurueck."
|
||||||
|
else
|
||||||
|
schritt "Sichern"
|
||||||
|
|
||||||
|
if ! command -v npm >/dev/null 2>&1; then
|
||||||
|
fehler "npm fehlt — ohne das laeuft das Sicherungsskript nicht."
|
||||||
|
hinweis "Entweder Node installieren oder bewusst mit --ohne-sicherung fahren."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
npm run --silent sicherung
|
||||||
|
SICHERUNG="$(find sicherungen -maxdepth 1 -mindepth 1 -type d | sort | tail -1)"
|
||||||
|
hinweis "Liegt in: $SICHERUNG"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# --- Holen und bauen --------------------------------------------------------
|
||||||
|
# Bis hierher ist nichts umgeschaltet: schlaegt der Bau fehl, laeuft die alte
|
||||||
|
# Fassung unveraendert weiter.
|
||||||
|
schritt "Neue Fassung holen"
|
||||||
|
git merge --ff-only "origin/$ZWEIG"
|
||||||
|
hinweis "Jetzt auf: $(git log -1 --format='%h %s')"
|
||||||
|
|
||||||
|
schritt "Abbilder bauen"
|
||||||
|
hinweis "Das dauert ein paar Minuten."
|
||||||
|
"${COMPOSE[@]}" build
|
||||||
|
|
||||||
|
# --- Umschalten -------------------------------------------------------------
|
||||||
|
# Der App-Container spielt beim Start die ausstehenden Migrationen ein; der
|
||||||
|
# Worker tut das bewusst nicht, damit nicht zwei Prozesse gleichzeitig
|
||||||
|
# migrieren.
|
||||||
|
schritt "Umschalten"
|
||||||
|
"${COMPOSE[@]}" up -d --remove-orphans
|
||||||
|
|
||||||
|
# --- Nachsehen --------------------------------------------------------------
|
||||||
|
schritt "Nachsehen, ob die Anwendung antwortet"
|
||||||
|
|
||||||
|
ADRESSE="http://localhost:3000/api/health"
|
||||||
|
if grep -q '^APP_URL=' .env; then
|
||||||
|
ADRESSE="$(grep '^APP_URL=' .env | head -1 | cut -d= -f2- | tr -d '"'"'"' ')/api/health"
|
||||||
|
fi
|
||||||
|
|
||||||
|
hinweis "Frage $ADRESSE ..."
|
||||||
|
|
||||||
|
GESUND=false
|
||||||
|
for versuch in $(seq 1 30); do
|
||||||
|
if curl -fsS --max-time 5 "$ADRESSE" >/dev/null 2>&1; then
|
||||||
|
GESUND=true
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
sleep 5
|
||||||
|
[ $((versuch % 6)) -eq 0 ] && hinweis " noch nicht da, warte weiter ($((versuch * 5)) s) ..."
|
||||||
|
done
|
||||||
|
|
||||||
|
if [ "$GESUND" != true ]; then
|
||||||
|
fehler "Die Anwendung antwortet nach zweieinhalb Minuten nicht."
|
||||||
|
echo
|
||||||
|
echo "Die letzten Zeilen aus dem Protokoll:"
|
||||||
|
"${COMPOSE[@]}" logs --tail 40 app || true
|
||||||
|
echo
|
||||||
|
echo "Zurueck auf den vorherigen Stand:"
|
||||||
|
echo " git checkout $STAND_VORHER"
|
||||||
|
echo " ${COMPOSE[*]} up -d --build"
|
||||||
|
[ -n "$SICHERUNG" ] && echo " npm run wiederherstellen -- --quelle $SICHERUNG --wirklich"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Ab hier ist nichts mehr rueckgaengig zu machen, was das Skript angerichtet
|
||||||
|
# haette — deshalb keine Fehlerfalle mehr, die zum Rueckbau raet.
|
||||||
|
trap - ERR
|
||||||
|
|
||||||
|
schritt "Fertig"
|
||||||
|
echo "${GRUEN} FLORIAN laeuft auf dem Stand $(git log -1 --format='%h %s')${AUS}"
|
||||||
|
echo
|
||||||
|
hinweis "Zustand der Dienste:"
|
||||||
|
"${COMPOSE[@]}" ps --format ' {{.Service}}\t{{.Status}}' 2>/dev/null || "${COMPOSE[@]}" ps
|
||||||
|
|
||||||
|
if [ -n "$SICHERUNG" ]; then
|
||||||
|
echo
|
||||||
|
hinweis "Die Sicherung von vorhin liegt in $SICHERUNG."
|
||||||
|
hinweis "Sie kann weg, sobald ein paar Tage nichts aufgefallen ist."
|
||||||
|
fi
|
||||||
Reference in New Issue
Block a user