10 Commits
Author SHA1 Message Date
florian.wilde 3b20abcb23 Version 1.0.0 — Freigabe für den Echtbetrieb (#17)
Pruefung / pruefung (push) Canceled after 0s
Pruefung / oberflaeche (push) Canceled after 0s
2026-09-06 20:08:16 +00:00
florian.wilde 14cc4de9b8 Phase 11: Freigabereife (#16)
Pruefung / pruefung (push) Canceled after 0s
Pruefung / oberflaeche (push) Canceled after 0s
2026-09-06 20:05:49 +00:00
florian.wilde 91742a0dd7 Phase 10: Mandantenfähigkeit (#15) 2026-09-05 13:47:46 +00:00
florian.wilde 2b53556175 Phase 9: Kassenverwaltung (#14) 2026-09-05 02:30:51 +00:00
florian.wilde d70acad4cd Aufräumarbeiten: ESLint, DOM-Verschachtelung, eigene Testwehr (#13) 2026-09-04 13:43:53 +00:00
Florian WildeandClaude Opus 5 7196c7110c Aufraeumarbeiten: ESLint, DOM-Verschachtelung, eigene Testwehr
ESLint hatte keine Konfiguration — das Skript "npm run lint" pruefte nichts.
Jetzt Flat Config ohne Formatierungsregeln, dafuer mit den Regeln, die die
Fehler dieses Projekts gemeldet haetten. Er hat gleich zwei gefunden, die
niemand gesucht hatte.

Das Badge rendert ein span statt eines div. Damit ist die Meldung
"div cannot be a descendant of p" weg, die seit Phase 3 auf der Einsatzseite
stand — sie ist der klassische Ausloeser fuer einen Hydration-Fehler.

Die E2E-Tests laufen gegen eine eigene Wehr. Nach fuenf Phasen standen in der
Beispielwehr 120 Atemschutz-Ueberwachungen statt einer und 115 Geraete statt
45; bei jedem Lauf aenderten sich alle Bildschirmfotos. Der Bestand ist einmal
zurueckgesetzt, npm run db:test-reset baut die Testwehr neu auf.

Behoben nebenbei:
- Die Fehlerflaeche der Karte konnte nie etwas anzeigen.
- Zwei useMemo der Lagekarte rechneten bei jedem Rendern neu.
- tests/e2e war vom Typecheck ausgenommen und liess einen doppelt
  deklarierten Bezeichner durch.

Closes #10
Closes #11
Closes #12

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-04 13:24:48 +02:00
florian.wilde 8081131eb7 Phase 8: Lokale KI (#9) 2026-09-04 09:22:22 +00:00
Florian WildeandClaude Opus 5 f7ccd9a908 Phase 8: Lokale KI
Wiki mit semantischer Suche ueber pgvector, Diktat ueber faster-whisper,
Berichtsassistent und Belegerfassung — alles gegen selbst betriebene
Modelle. Dazu zwei Protokollansichten: was die Maschine getan hat und was
Menschen getan haben.

Nichts, was ein Modell erzeugt, gelangt ungeprueft in einen Nachweis. Jede
Ausgabe ist zuerst ein eigener Datensatz, gekennzeichnet, mit zwei gleich
grossen Knoepfen davor. Die Datenbank haelt gegen: Eingabe, Ausgabe und
Modell sind unveraenderlich, geloescht wird nicht, entschieden wird einmal.

Kein Rueckfall auf einen Anbieter: fehlen die Container, fehlt die Funktion.
Dafuer bleibt alles andere ohne Modell vollstaendig benutzbar — geprueft von
vierzehn E2E-Tests, die ohne laufendes Modell durchlaufen.

Postgres bringt jetzt PostGIS und pgvector mit (eigenes Image), Ollama und
faster-whisper stehen im Compose-Profil "ki".

73 neue Unit-Tests, 14 neue E2E-Tests, Version 0.9.0.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-04 11:06:03 +02:00
florian.wilde 12243f177d Phase 7: Benachrichtigungen und Termine (#8) 2026-09-04 07:19:13 +00:00
Florian WildeandClaude Opus 5 0b8874da26 Phase 7: Benachrichtigungen und Termine
Terminkalender mit Serienterminen, Selbstanmeldung, Warteliste und
Anwesenheitsliste. Vier Zustellwege hinter Schnittstellen: echtes SMTP,
ntfy (selbst gehostet), Telegram und Web-Push. Kalenderabonnement als
ICS-Feed, digitaler Dienstausweis mit signiertem QR und Pruefseite.

Niemand wird ungefragt benachrichtigt: ein neuer Zustellweg hat eine leere
Ereignisliste, und der Seed legt bewusst keine an. Was das Netz der Wehr
verlaesst, bekommt nur die Kurzfassung ohne Namen und Details.

Kein CalDAV (Lesen genuegt, ICS kann jeder Kalender) und kein WhatsApp
(Verarbeitung ueber Meta, Kosten je Konversation, Telefonbuchabgleich) --
beides im README begruendet.

56 neue Unit-Tests, 17 neue E2E-Tests, Version 0.8.0.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-04 08:39:38 +02:00
276 changed files with 38599 additions and 674 deletions
+129
View File
@@ -164,6 +164,11 @@ AUTH_LOCAL_ENABLED=true
# Nach dem ersten Login das Passwort in der Oberfläche ändern. # Nach dem ersten Login das Passwort in der Oberfläche ändern.
SEED_ADMIN_EMAIL=admin@beispielwehr.de SEED_ADMIN_EMAIL=admin@beispielwehr.de
SEED_ADMIN_PASSWORT=Feuerwehr2026 SEED_ADMIN_PASSWORT=Feuerwehr2026
# Das Konto der Kreisebene aus dem Beispielbestand (Phase 10): Mitglied in der
# eigenen Wehr, Aufsicht in der Nachbarwehr. Vorgabe leitet sich aus der
# Wehrkennung ab, also kbi@ff-musterhausen.de.
# SEED_KBI_EMAIL=
# SEED_KBI_PASSWORT=Feuerwehr2026
# bcrypt-Kostenfaktor. 12 ist ein guter Kompromiss aus Sicherheit und Tempo. # bcrypt-Kostenfaktor. 12 ist ein guter Kompromiss aus Sicherheit und Tempo.
AUTH_BCRYPT_ROUNDS=12 AUTH_BCRYPT_ROUNDS=12
@@ -255,14 +260,122 @@ TELEGRAM_ENABLED=false
TELEGRAM_BOT_TOKEN= TELEGRAM_BOT_TOKEN=
TELEGRAM_CHAT_ID= TELEGRAM_CHAT_ID=
# -----------------------------------------------------------------------------
# Push über ntfy (Phase 7, optional)
#
# ntfy ist ein winziger Push-Dienst, den man selbst betreibt: ein Container,
# eine App im Store, fertig. Man abonniert ein "Thema" (einen frei gewählten
# Namen) und bekommt Meldungen darauf — ohne Konto, ohne Telefonnummer.
#
# Läuft der Server im eigenen Netz, verlässt keine Nachricht das Gerätehaus.
# Deshalb gilt ntfy in FLORIAN als INTERNER Kanal und bekommt die
# ausführlichen Texte. Wer stattdessen das öffentliche ntfy.sh einträgt, macht
# ihn zu einem externen Dienst wie jeden anderen.
#
# ACHTUNG: Ein Thema ist nur so geheim wie sein Name. Wer ihn kennt, liest mit.
# FLORIAN schlägt deshalb Namen mit Zufallsanteil vor.
# -----------------------------------------------------------------------------
NTFY_ENABLED=false
# NTFY_URL=http://ntfy:80
# Nur nötig, wenn der eigene ntfy Zugriffsschutz eingeschaltet hat.
NTFY_TOKEN=
NTFY_TIMEOUT=10
# -----------------------------------------------------------------------------
# Web-Push in den Browser (Phase 7, optional)
#
# Der Weg, der ohne App auskommt. Die Meldung läuft über den Push-Dienst des
# Browserherstellers (Mozilla, Google, Apple) — verschlüsselt, aber sie
# verlässt das Netz der Wehr. Deshalb ist Web-Push ein EXTERNER Kanal und
# bekommt nur Kurzfassungen: "3 Prüfungen fällig", nicht welche.
#
# Schlüsselpaar erzeugen mit: npm run vapid
#
# ACHTUNG: Beide Schlüssel gehören ins Backup. Ein neuer öffentlicher Schlüssel
# macht alle bestehenden Push-Abos wertlos — jeder müsste Push neu erlauben,
# und niemand merkt es, die Meldungen bleiben einfach aus.
# -----------------------------------------------------------------------------
WEBPUSH_ENABLED=false
WEBPUSH_PUBLIC_KEY=
WEBPUSH_PRIVATE_KEY=
# Kontaktweg für den Push-Dienst, "mailto:..." oder eine Adresse.
WEBPUSH_KONTAKT=
# -----------------------------------------------------------------------------
# WhatsApp: bewusst NICHT.
#
# Es gibt keine WHATSAPP_-Variablen, und das ist eine Entscheidung, keine Lücke.
# Die Begründung steht im README unter "Warum kein WhatsApp" — kurz: die
# Business-Plattform kostet je Konversation, verlangt einen Vertrag mit Meta
# als Auftragsverarbeiter, und die App gleicht das Telefonbuch ab. Für eine
# Wehr, deren Daten im Gerätehaus bleiben sollen, ist das der falsche Weg.
# -----------------------------------------------------------------------------
# -----------------------------------------------------------------------------
# Benachrichtigungen und Termine (Phase 7)
# -----------------------------------------------------------------------------
# Wann der Worker die Erinnerungen anlegt und verschickt (cron, Europe/Berlin).
# Bewusst am frühen Morgen und nicht nachts: eine Push-Meldung um drei Uhr
# weckt Leute, die am nächsten Tag arbeiten müssen.
BENACHRICHTIGUNG_LAUF=0 7 * * *
# Wie viele Tage vor einem Termin erinnert wird (kommagetrennt).
# "7,1": einmal in der Woche davor, damit man den Abend freihalten kann, und
# einmal am Vortag, weil man es bis dahin wieder vergessen hat.
TERMIN_ERINNERUNG_TAGE=7,1
# Wie oft eine fehlgeschlagene Zustellung wiederholt wird, bevor sie als
# fehlgeschlagen stehen bleibt. Stehen bleiben ist Absicht: eine still
# verschluckte Benachrichtigung ist schlimmer als eine sichtbar gescheiterte.
BENACHRICHTIGUNG_VERSUCHE=3
# Wie lange ZUGESTELLTE Benachrichtigungen aufgehoben werden.
# Fehlgeschlagene bleiben — sie zeigen einen kaputten Kanal an.
BENACHRICHTIGUNG_AUFBEWAHRUNG_TAGE=90
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
# LLM-Anbindung (optional; nur selbstgehostete Endpunkte, kein Cloud-Zwang) # LLM-Anbindung (optional; nur selbstgehostete Endpunkte, kein Cloud-Zwang)
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
# ES GIBT KEINEN RUECKFALL AUF EINEN ANBIETER. Laeuft der eigene Endpunkt
# nicht, fehlen die KI-Funktionen — sie werden nicht durch einen fremden
# Dienst ersetzt. Ein Einsatzbericht spricht ueber Menschen in ihrer
# schlechtesten Stunde; solche Texte gehoeren nirgendwo anders hin.
#
# Container starten: docker compose --profile ki up -d
# Modelle holen: docker compose exec ollama ollama pull <modell>
LLM_ENABLED=false LLM_ENABLED=false
# OpenAI-kompatibler Endpunkt, z. B. eine lokale Ollama- oder vLLM-Instanz. # OpenAI-kompatibler Endpunkt, z. B. eine lokale Ollama- oder vLLM-Instanz.
LLM_BASE_URL=http://ollama:11434/v1 LLM_BASE_URL=http://ollama:11434/v1
LLM_API_KEY= LLM_API_KEY=
LLM_MODELL=qwen2.5:14b LLM_MODELL=qwen2.5:14b
# Zeitlimit fuer eine Antwort. Ein Bericht darf dauern, aber nicht ewig.
LLM_TIMEOUT=120
# Modell fuer Einbettungen (semantische Suche im Wiki).
#
# ACHTUNG: Die Spalte wikiabschnitt.einbettung ist auf 768 Dimensionen
# festgelegt — das liefert nomic-embed-text. Ein Modell mit anderer Laenge
# braucht eine Migration; wie, steht im README unter Phase 8.
LLM_EINBETTUNG_MODELL=nomic-embed-text
LLM_EINBETTUNG_DIMENSIONEN=768
# Modell, das Bilder lesen kann — fuer die Belegerfassung. Ohne dieses Modell
# werden Belege trotzdem abgelegt, die Angaben nur von Hand eingetragen.
# LLM_VISION_MODELL=llama3.2-vision
# -----------------------------------------------------------------------------
# Spracherkennung fuers Diktat (Phase 8, optional)
#
# Selbst betriebener faster-whisper im Compose-Profil "ki". Groesse ueber
# WHISPER_MODELL: "tiny" laeuft auf jedem Buerorechner, "base" ist der
# brauchbare Kompromiss, "small" erkennt Fachbegriffe merklich besser und
# will dafuer Zeit oder eine Grafikkarte.
# -----------------------------------------------------------------------------
WHISPER_ENABLED=false
WHISPER_URL=http://whisper:8000
WHISPER_MODELL=base
WHISPER_SPRACHE=de
# Zeitlimit fuer eine Aufnahme. Zehn Minuten Sprache brauchen Zeit.
WHISPER_TIMEOUT=300
# Hoechstlaenge einer Aufnahme in Sekunden.
DIKTAT_MAX_SEKUNDEN=600
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
# Einsätze und Übungen (Phase 3) # Einsätze und Übungen (Phase 3)
@@ -313,3 +426,19 @@ PERSON_ANONYMISIERUNG_TAGE=365
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
NEXT_TELEMETRY_DISABLED=1 NEXT_TELEMETRY_DISABLED=1
CHECKPOINT_DISABLE=1 CHECKPOINT_DISABLE=1
# -----------------------------------------------------------------------------
# Testwehr fuer die E2E-Tests
#
# Die Playwright-Tests laufen gegen eine EIGENE Wehr, nicht gegen die
# Beispielwehr aus der Anleitung. Sonst wachsen deren Listen bei jedem Lauf,
# und die Bildschirmfotos aendern sich, ohne dass sich die Oberflaeche
# geaendert haette.
#
# Vor dem Lauf einmal: npm run db:test-reset
# -----------------------------------------------------------------------------
E2E_SLUG=ff-teststelle
E2E_NAME=Freiwillige Feuerwehr Teststelle
E2E_KURZNAME=FF Teststelle
E2E_ADMIN_EMAIL=e2e@teststelle.de
E2E_ADMIN_PASSWORT=Feuerwehr2026
+184
View File
@@ -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
+3
View File
@@ -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/
+773 -2
View File
@@ -6,9 +6,780 @@ 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).
## [Unveröffentlicht] ## [1.0.0] — 2026-09-06
Nächster Schritt ist Phase 7 (Benachrichtigungen und Termine). **Freigabe für den Echtbetrieb.**
Kein neues Modul. Zwölf Phasen sind fertig, und die Wehr kann ab jetzt echte
Daten hineingeben, ohne befürchten zu müssen, sie beim nächsten Update zu
verlieren.
Was 1.0.0 bedeutet: der Funktionsumfang ist vollständig, die Datenbank ist
stabil, Migrationen sind aufwärtskompatibel, und ab hier gilt semantische
Versionierung — ein Update bricht nichts, ohne dass es in der Hauptnummer
steht.
Was es nicht bedeutet: dass keine Fehler mehr da sind.
### Hinzugefügt
- **`update.sh`** — Aktualisieren auf einem Linux-Server, in einem Befehl. Der
Ablauf darf an jeder Stelle abbrechen, ohne eine halb aktualisierte Anlage
zu hinterlassen: erst nachsehen, ob es überhaupt etwas Neues gibt, dann
**sichern**, dann holen und bauen (schlägt das fehl, läuft die alte Fassung
unverändert weiter), dann umschalten, dann nachsehen, ob die Anwendung
antwortet. Tut sie es nicht, stehen die letzten Protokollzeilen da und die
Befehle, mit denen der vorherige Stand zurückkommt.
Vorher prüft es Docker, git, die `.env` und dass keine unverbuchten
Änderungen im Verzeichnis liegen. `--pruefen` sieht nur nach,
`--ohne-sicherung` gibt es für Testinstallationen.
- **`docs/KURZANLEITUNG.md`** — der Einstieg, nach Rollen sortiert und auf ein
paar Seiten. Die vollständige Anleitung hat 63 Kapitel; die liest niemand,
bevor er das erste Gerät prüfen will. Diese hier ist aufgabenorientiert
(„ein Gerät prüfen", „einen Kontoauszug einlesen") statt nach Modulen
geordnet, und sie sagt bei jedem heiklen Punkt dazu, warum es so ist — warum
ein abgeschlossenes Prüfprotokoll nicht änderbar ist, warum gegengebucht und
nicht korrigiert wird, warum der Atemschutzhinweis stehen bleibt.
### Geändert
- Version auf **1.0.0**. Ab hier gilt semantische Versionierung nach
[docs/VERSIONIERUNG.md](docs/VERSIONIERUNG.md).
- Die Roadmap hält den Stand **jeder** Freigabevoraussetzung fest, auch der
offenen. Zwei sind organisatorisch und stehen aus: die Abnahme des
Löschkonzepts durch die Wehr und die Schulung. An der Software hängt nichts
davon ab; sie stehen dort, damit später nachlesbar ist, was zum Zeitpunkt
der Freigabe erledigt war und was nicht.
### Behoben
- **Die Testläufe flackerten seit Phase 8 — jetzt ist die Ursache weg.** Zwei
Datenbanktests schalteten beim Aufräumen ihre Trigger mit
`ALTER TABLE ... DISABLE TRIGGER` ab. Das ist eine Schemaänderung: sie gilt
für die ganze Datenbank und für alle. Vitest fährt die Testdateien parallel
— während die eine Datei abräumte, prüfte die andere gerade, dass genau
dieser Trigger greift, und sah keinen.
Das erklärt rückwirkend eine ganze Reihe von Fehlschlägen, die als
„Flackern" abgetan wurden und keines waren. Statt der Schemaänderung wird
jetzt `SET LOCAL session_replication_role = 'replica'` benutzt: dieselbe
Wirkung, aber nur in der eigenen Transaktion. Fünf Läufe hintereinander
sauber.
- **Ein Datenbanktest räumte nur am Ende auf.** Brach ein Lauf ab, scheiterte
der nächste an einer Eindeutigkeit, die mit dem Geprüften nichts zu tun
hatte. Er räumt jetzt auch am Anfang auf.
### Zum Stand der Freigabevoraussetzungen
| Voraussetzung | Stand |
|---|---|
| Datenübernahme | **entfällt** — die Wehr pflegt von Hand ein |
| Sicherungskonzept, einmal wiederhergestellt | **erledigt** (Phase 11) |
| Löschkonzept nach DSGVO | **technisch vollständig** (Phase 11), Abnahme steht aus |
| Schulung | **steht aus**, Grundlage ist die Kurzanleitung |
| Atemschutzhinweis | **bewusst stehen gelassen** — FLORIAN bleibt ergänzend |
---
## [0.12.0] — 2026-09-05
Phase 11: Freigabereife. Die drei Software-Voraussetzungen für 1.0.0 —
Personenauskunft nach Art. 15 DSGVO, ein geprüftes Sicherungskonzept und eine
Pipeline, die die Tests von allein ausführt.
**Das Auskunftsrecht war ein Versprechen ohne Umsetzung.** `person.export`
stand seit Phase 0 im Berechtigungskatalog und war dem Kommandanten
zugewiesen; implementiert war nichts. Die Löschseite gab es (Anonymisierung im
Aufbewahrungslauf), die Auskunftsseite nicht — und ohne Art. 15 ist ein
Löschkonzept nicht abnehmbar.
**Eine Sicherung, die nie zurückgespielt wurde, ist eine Vermutung.** Genau
das hat sich hier bestätigt: der erste Rückspielversuch lief scheinbar
sauber durch und hatte dabei sämtliche Unveränderlichkeitsgarantien der
Datenbank entfernt.
### Hinzugefügt
**Auskunft nach Art. 15 DSGVO**
- `lib/dsgvo/auskunft.ts` sammelt alles, was über eine Person vorliegt:
Stammdaten, Zugang, Rollen, Qualifikationen, arbeitsmedizinische Vorsorge,
Belastungsübungen, Einsatz- und Übungsbeteiligung, Anwesenheiten, Fahrten,
Beiträge, Benachrichtigungswege, Ausweise. Dreizehn Abschnitte.
- **Über jedem Abschnitt steht, wozu die Daten vorliegen.** Art. 15 Abs. 1
verlangt nicht nur die Daten, sondern auch Zwecke, Empfänger und den Hinweis
auf die weiteren Rechte. Eine Liste ohne das sieht aus wie eine Auskunft und
ist keine.
- **Gesundheitsdaten sind als besondere Kategorie nach Art. 9 gekennzeichnet** —
G26 und Belastungsübungen.
- **Leere Abschnitte bleiben stehen.** „Dazu liegt nichts vor" ist eine
Auskunft; ein weggelassener Abschnitt wirft die Frage auf, ob da doch etwas
ist.
- **Was die Person getan hat, wird gezählt, nicht mitgeliefert.** Prüfprotokolle
und Mängelmeldungen gehören zum Nachweisbestand der Wehr. Vollständig
ausgeliefert käme jeder über eine Selbstauskunft an die Geräteakten — die
Auskunftspflicht wäre eine Hintertür.
- Ein Durchgang liefert alles: die Anzeige, ein PDF für die Akte und JSON nach
Art. 20. Bewusst nicht drei Knöpfe, von denen zwei vergessen werden können.
- Der Vorgang wird protokolliert. Dass eine Auskunft erteilt wurde, gehört
festgehalten — sie legt alles offen, was über einen Menschen gespeichert ist.
**Sicherung und Wiederherstellung**
- `npm run sicherung` — Datenbank über `pg_dump` im Container, dazu alle
Dateien aus MinIO, dazu ein Manifest. Beides aus demselben Moment: eine
Datenbank von heute mit Dateien von gestern ergibt Verweise ins Leere.
- `npm run wiederherstellen` — mit Probelauf als Vorgabe. Ohne `--wirklich`
wird nur angezeigt, was geschähe.
- Nach dem Zurückspielen setzt das Skript die Sperren der Anwendungsrolle neu
und **prüft danach nach**, ob es gewirkt hat.
- `scripts/sicherung-sperren.ts` ist die eine Liste, aus der Wiederherstellen
und Prüfen entstehen. Ein Test gleicht sie gegen die migrierte Datenbank ab —
läuft sie auseinander, wäre der Schaden lautlos.
**Pipeline**
- `.gitea/workflows/pruefung.yml` mit zwei Aufträgen: `pruefung` (typecheck,
lint, Migrationen, Seed, 599 Unit-Tests) bei jedem Push, `oberflaeche`
(E2E) auf `main` und in Pull Requests.
- Die Datenbank wird aus `docker/postgres` gebaut statt aus einem fertigen
Image: FLORIAN braucht PostGIS **und** pgvector. Ein Dienst-Container mit
`postgres:17` ließe die Migrationen durch und scheiterte erst bei der ersten
Umkreissuche.
- Der Seed läuft mit, weil er selbst ein Test ist.
### Behoben
- **Eine Wiederherstellung entfernte alle Unveränderlichkeitsgarantien.** Der
Dump lief mit `--no-acl`, die Rechte kamen also nicht zurück; und selbst
ohne dieses Flag greift beim Zurückspielen die Vorgabeprivilegierung aus
`docker/postgres/10-init-app-role.sh` — jede von `pg_restore` neu angelegte
Tabelle bekommt automatisch alle Rechte, und `GRANT` kann nur hinzufügen,
nie wegnehmen. Nach einer Wiederherstellung durfte die Anwendungsrolle das
Audit-Log ändern, Wiki-Fassungen löschen und Kiosk-Vorgänge umschreiben.
Nichts hätte darauf hingewiesen.
Gefunden haben es vier Datenbanktests unmittelbar nach der ersten
Rückspielprobe — also genau die Probe, wegen der 1.0.0 sie verlangt.
- **Ein E2E-Test klickte, bevor die Seite lebendig war.** Unter Last dauert das
Hydrieren länger; das Formular verschickte sich dann selbst, und die
Fehlerfläche, auf die der Test wartete, entstand nie — obwohl der Server
richtig ablehnte. Er wartet jetzt auf einen client-seitig erzeugten Knopf.
### Nachweise
- Sicherung und Wiederherstellung einmal vollständig durchgeführt: ein nach
der Sicherung angelegter Datensatz war danach weg, alle Zahlen stimmten
wieder, und **599 Tests liefen nach dem Zurückspielen grün** — einschließlich
derer, die die Sperren prüfen.
---
## [0.11.0] — 2026-09-05
Phase 10: Mandantenfähigkeit. Ein Konto, mehrere Wehren, eine Kreisebene, die
zusieht — und eine Verwaltung für den, der die Installation betreibt.
**Das Anmeldekonto ist von der Personenakte gelöst.** Bis hierher war die Akte
zugleich das Konto: Passwort-Hash und OIDC-Subject hingen an `person`, und
`person` gehört genau einer Wehr. Ein Mensch konnte damit nicht zu zwei Wehren
gehören, ohne zwei Passwörter zu haben — und die Anmeldung per E-Mail war
mehrdeutig, sobald dieselbe Adresse in zwei Wehren stand, weil die
Eindeutigkeit nur je Organisation galt.
**Das Konto trägt keine Mandantenkennung — und kann keine tragen.** Es ist der
Angelpunkt, an dem ein Mensch zu mehreren Wehren gehört. Geschützt wird es
deshalb wie `Berechtigung` aus Phase 0: über Rechte statt über Row Level
Security. Was ein Konto in einer Wehr darf, entscheidet nicht es selbst,
sondern seine `Mitgliedschaft` — und die trägt `organisationId` wie jede
andere Tabelle.
**Die Kreisebene sieht und ändert nichts.** Ihre Rechte werden nicht
aufgezählt, sondern gefiltert: alles, was `.lesen` heißt, und sonst nichts.
Eine Liste von Hand wäre bei der nächsten Phase veraltet, und zwar unbemerkt.
### Hinzugefügt
**Datenmodell**
- `Benutzer` — das Anmeldekonto. Global eindeutige E-Mail, ein Passwort, ein
OIDC-Subject. Ohne `organisationId`, mit Begründung im Schema.
- `Mitgliedschaft` — die Zugehörigkeit eines Kontos zu einer Wehr, als
`MITGLIED` (mit Personenakte) oder `AUFSICHT` (ohne).
- `Person` verliert die Anmeldefelder. Der Passwort-Hash kann damit aus einer
Personenabfrage gar nicht mehr herausfallen — die drei Stellen, die ihn
bisher vorsichtshalber abgeschnitten haben, sind entfallen.
**Was die Datenbank durchsetzt** (Migrationen `20260905090000` und
`20260905091500`)
- RLS für `mitgliedschaft` wie für jede andere Tabelle. Eine Wehr sieht damit
auch nicht, in welchen anderen ein Konto noch geführt wird.
- Trigger `mitgliedschaft_akte_gleiche_wehr` — eine Zuordnung, deren
Personenakte in einer anderen Wehr liegt, wird abgelehnt. Ein Fremdschlüssel
prüft nur, *dass* es die Akte gibt, nicht *wo* sie liegt; ohne den Trigger
ließe sich ein Konto über eine fremde Akte in eine Wehr hängen.
- Trigger `benutzer_kennung_unveraenderlich` — E-Mail, OIDC-Subject und das
Verwaltermerkmal lassen sich nicht aus einer Wehr heraus ändern.
- `REVOKE INSERT, DELETE ON benutzer` für die Anwendungsrolle.
- CHECKs: `MITGLIED` braucht eine Akte, `AUFSICHT` darf keine haben; ein Konto
braucht einen Anmeldeweg; eine E-Mail ohne @ ist keine.
**Die Umstellung ist datenerhaltend.** Jede Person mit Anmeldedaten bekommt
ein Konto und eine Mitgliedschaft, bevor die alten Spalten fallen. Wer schon
mit derselben Adresse in zwei Wehren stand, bekommt **ein** Konto mit **zwei**
Mitgliedschaften — genau das, was vorher nicht möglich war. Eine Prüfung am
Ende bricht die Migration ab, statt einen halben Zustand zu hinterlassen.
**Oberfläche**
- **Wehrwähler** in der Seitenleiste. Er erscheint nur bei mehr als einer Wehr
— ein Auswahlfeld mit genau einem Eintrag ist eine Frage ohne
Antwortmöglichkeit.
- Nach der Anmeldung öffnet die **eigene** Wehr, nicht die betreute.
- Ein dauerhafter Hinweis, wenn man eine Wehr als Kreisaufsicht sieht — nicht
als kurz aufblitzende Meldung.
- **`/wehren`** für den Verwalter: Wehren anlegen (samt Systemrollen), Konten
führen, Zuordnungen vergeben und beenden. Jede Zuordnung wird in der
betroffenen Wehr protokolliert, nicht beim Verwalter — sonst hätte der
Betreiber einen Weg, sich still in fremde Daten zu setzen.
**Rechte**
- Neue Rolle `AUFSICHT` — bewusst **nicht** im Prisma-Enum: sie wird nicht
vergeben, sondern ergibt sich aus der Mitgliedschaftsart.
- Ausdrücklich ausgenommen, obwohl sie auf `.lesen` enden: `kasse.lesen` (die
Kasse gehört dem Verein oder der Kommune, nicht der Kreisaufsicht),
`person.export`, `system.auditlog`, `ki.protokoll`.
- `istVerwalter` als Merkmal des Kontos, nicht als Recht.
**Tests**
- 19 neue Unit-Tests, davon 11 gegen eine echte Datenbank: Doppelmitgliedschaft,
der Trigger gegen wehrfremde Akten, die CHECKs und die Trennung der
Zuordnungen über RLS.
- 13 E2E-Tests: kein Wähler bei einer Wehr, Wechsel in die betreute und
zurück, die Mitglieder der anderen Wehr, die verschlossene Kasse, und dass
der Administrator die Umstellung überlebt hat.
**Seed**
- Eine zweite, kleine Wehr je Mandant und ein Konto, das in der einen Mitglied
und in der anderen Kreisaufsicht ist. Ohne sie ließe sich die Phase nicht
ausprobieren — und eine Mandantentrennung, die nie zwei Mandanten hatte, ist
keine geprüfte.
### Geändert
- Eine Sitzung ohne Personenakte ist gültig. Bis hierher galt genau das als
„nicht angemeldet"; für die Kreisaufsicht, die in der betreuten Wehr keine
Akte hat, hätte jeder Aufruf auf der Anmeldeseite geendet.
- `Sitzung.personId` ist nullbar. Der Typechecker hat damit die fünf Stellen
gezeigt, an denen eine Aufsichtssitzung nichts verloren hat — Anmeldemarke,
Dienstausweis, Prüfungswizard, Dateiupload, Atemschutz.
- Die Mitgliederansicht zeigt statt eines ungenutzten Zeitstempels, ob jemand
überhaupt ein Konto hat. Das ist die nützlichere Auskunft: ohne Konto kommt
er nicht in FLORIAN hinein.
### Behoben
- **Ein Testaufbau war nicht wiederholbar.** `ki-datenbank.test.ts` legte Wehr
und Person idempotent an, die Wiki-Seite aber nicht — blieb aus einem
abgebrochenen Lauf etwas stehen, scheiterte der ganze Datei-Lauf an einer
Eindeutigkeit, die mit dem Geprüften nichts zu tun hat.
- **`db:test-reset` ließ die Nachbarwehr stehen** und der Seed hielt daraufhin
alles für erledigt — die Mitgliedschaft in der neu angelegten Testwehr
entstand nie wieder. Der Reset nimmt sie jetzt mit, und der Seed prüft
Nachbarwehr und Kreiskonto getrennt statt mit einem gemeinsamen Ausstieg.
- **Der Fehlversuch-Test sperrte das gemeinsame Testkonto.** Fünf Fehlversuche
sperren ein Konto für fünf Minuten; bei zwei Projekten und Wiederholungen
kam das im vollen Lauf zusammen, und fremde Testdateien fielen mit
„Anmeldung fehlgeschlagen“ aus. Er nimmt jetzt eine Adresse, die es nicht
gibt — die Meldung muss ohnehin dieselbe sein.
---
## [0.10.0] — 2026-09-04
Phase 9: Kassenverwaltung. Kassenbuch, Mitgliedsbeiträge, Rechnungen an
Dritte, Kassenprüfung und die Auswertungen für die Jahreshauptversammlung.
**Eine festgeschriebene Buchung ist unveränderbar.** Nicht, weil ein Zweig im
Router es so vorsieht, sondern weil die Datenbank es ablehnt. Korrigiert wird
über eine Gegenbuchung mit Verweis; beide Buchungen bleiben stehen, und im
Kassenbuch ist zu sehen, dass etwas berichtigt wurde. Genau das will ein
Kassenprüfer sehen — eine verschwundene Buchung dagegen kann er nicht prüfen.
**Der Kassenabschluss schließt seinen Zeitraum.** Danach wird keine Buchung
mit einem Datum darin mehr angenommen, auch keine nachträglich hineindatierte.
Ohne diese zweite Hälfte stimmte der geprüfte Bestand nach einem
Datumswechsel nicht mehr — und niemand hätte es bemerkt.
**Gerechnet wird mit `Prisma.Decimal`, nirgends mit Fließkomma.** `0.1 + 0.2`
ergibt in JavaScript 0.30000000000000004. In einer Kasse ist das kein
Rundungsfehler, sondern eine Differenz in der Prüfung, die niemand erklären
kann.
### Hinzugefügt
**Datenmodell**
- `Kassenkonto` (bar, Bank, Rücklage) mit Anfangsbestand. Der laufende Bestand
wird **gerechnet und nie gespeichert**: ein gespeicherter Bestand neben
einer nachgetragenen Buchung wäre falsch, ohne falsch auszusehen.
- `Buchungskategorie` als zweistufiger Baum, mit Kontonummer für den Export an
die Kämmerei.
- `Buchung` mit Datum, Richtung, immer positivem Betrag, Verwendungszweck,
Belegnummer, Beleg-Datei und dem Verweis auf die Gegenbuchung.
- `Beitragssatz` und `Beitrag` (offen, bezahlt, erlassen, gemahnt) mit
Mahnstufe und Erlassbegründung.
- `Rechnung` und `Rechnungsposition` mit Nummernkreis je Jahr. Die Menge hat
drei Nachkommastellen — Einsatzstunden werden viertelstündlich abgerechnet.
- `Kassenabschluss` und `Abschlussposten` mit Bestand laut Buch, gezähltem
Bestand, Differenz, zwei Prüfern und dem Bericht als PDF.
**Was die Datenbank durchsetzt** (Migration `20260904140000`)
- RLS für alle neun Tabellen, wie in jeder Phase zuvor.
- Trigger `buchung_unveraenderlich` — festgeschriebene Buchungen lassen sich
weder ändern noch löschen. Änderbar bleibt genau eines: der Verweis auf die
Gegenbuchung. Eine Stornierung entsteht später; sie einzutragen ist keine
Änderung des Vorgangs, sondern seine Fortschreibung.
- Trigger `buchung_zeitraum_offen` — ein geprüfter Zeitraum nimmt weder eine
neue Buchung an noch das Hineindatieren einer vorhandenen.
- Trigger `abschluss_unveraenderlich` — ein festgeschriebener Abschluss bleibt,
wie er ist. Nachtragen lässt sich nur der Prüfbericht, denn das PDF entsteht
im Hintergrund und damit nach dem Festschreiben.
- `REVOKE DELETE ON kassenabschluss` für die Anwendung.
- CHECKs, die verhindern, was sich hinterher nicht mehr erkennen ließe: ein
Betrag von null oder darunter, eine Buchung an eine Person **und** einen
Dritten zugleich, ein erlassener Beitrag ohne Begründung, ein Abschluss mit
einer Differenz, die nicht zu seinen eigenen Zahlen passt, eine Rechnung,
die fällig wird, bevor sie geschrieben wurde.
**Kassenbuch**
- Buchungsmaske mit **Kategorievorschlag**: zuerst aus den eigenen früheren
Buchungen der Wehr, erst danach aus einer kurzen Stichwortliste. Unter der
Hälfte gemeinsamer Wörter schweigt der Vorschlag — ein falscher wird
übernommen, ohne gelesen zu werden.
- Summen über **alle** Treffer, nicht nur über die angezeigte Seite.
- Festschreiben als Sammelaktion bis zu einem Stichtag.
- Gegenbuchen mit Pflichtbegründung; das Buchungsdatum ist immer heute, damit
ein geprüfter Zeitraum zubleibt.
- **Aus einem gegengezeichneten Beleg wird eine Buchung.** Die Brücke zu
Phase 8: fotografiert und ausgefüllt wird am Terminal oder am Handy,
gebucht wird in der Kasse — mit Konto und Kategorie, die keine Maschine
entscheiden kann. Das Foto hängt danach an der Buchung, und genau das
verlangt eine Kassenprüfung: zu jeder Ausgabe ein Beleg, an der Buchung
und nicht in einem Ordner daneben. Ein Entwurf lässt sich nicht buchen.
**Kontoauszug einlesen**
- CAMT.053 und MT940, beides ohne zusätzliche Abhängigkeit. Erkannt wird am
Inhalt, nicht an der Dateiendung.
- Es entstehen **Entwürfe, keine fertigen Buchungen**.
- Jeder Umsatz trägt eine Kennung — die der Bank, sonst eine aus Datum, Betrag
und Zweck gebildete. Ein zweimal eingelesener Auszug wird dadurch als solcher
erkannt, statt einen Monat doppelt zu buchen.
- Was nicht gelesen werden konnte, steht mit Grund daneben, statt still zu
verschwinden.
**Beiträge**
- Sätze je Abteilung, gültig ab einem Jahr und nicht rückwirkend änderbar.
- Beitragslauf mit **Vorschau**: wie viele Beiträge entstehen, welche Summe —
und wer keinen passenden Satz hat. Der Lauf ist wiederholbar, ohne dass
etwas doppelt entsteht.
- Zahlungseingang erzeugt zugleich die Buchung. Ein Häkchen ohne Geld in der
Kasse wäre eine Behauptung.
- Mahnen als Sammelaktion, Erlassen nur mit Begründung.
- Rückstandsliste über alle Jahre — die Ansicht, in der auffällt, wer seit
drei Jahren offen steht.
**Rechnungen**
- Fortlaufende Nummer je Jahr, lückenlos: eine fehlende Nummer muss in der
Kassenprüfung erklärt werden.
- Der Betrag ist die Summe der Positionen, gerechnet und nicht eingegeben.
- Bis zum Versenden ein Entwurf, danach unveränderbar. Korrigiert wird über
Storno und eine neue Rechnung — die stornierte bleibt mit Grund stehen.
- PDF über Gotenberg, im Hintergrund erzeugt.
**Kassenprüfung und Auswertung**
- Prüfmaske: links der Bestand laut Buch, rechts der gezählte. In dieser
Reihenfolge — wer erst das Buch sieht und dann zählt, zählt, bis es passt.
- Ohne Prüfer und Prüfdatum lässt sich nichts festschreiben; eine Differenz
ohne Erläuterung ebenso wenig.
- Kassenprüfbericht als PDF mit vier Unterschriftsfeldern.
- Jahresauswertung nach Kategorie und Monat, mit Vorjahresvergleich.
- CSV-Export mit Semikolon und BOM — was Excel in Deutschland ohne Rückfrage
richtig öffnet.
**Tests**
- 67 neue Unit-Tests: Geldrechnen, beide Auszugsformate, Kategorievorschlag,
und dreizehn gegen eine echte Datenbank, die die Trigger und CHECKs prüfen.
Sie laufen über den Systemzugang: kommt nicht einmal der Eigentümer der
Datenbank daran vorbei, kommt niemand vorbei.
- 13 E2E-Tests über Desktop und Tablet.
**Seed**
- Ein vollständiges Kassenjahr: drei Konten, der Standardkontenrahmen,
24 Buchungen über zwei Jahre, die Mitgliedsbeiträge mit Rückständen, zwei
Rechnungen und die festgeschriebene Kassenprüfung des Vorjahres. Die Zahlen
des Abschlusses sind die Summe der Buchungen und nicht erfunden.
### Geändert
- Ein E2E-Test aus Phase 0 sicherte zu, dass die Kasse in der Navigation
**kein** Verweis ist — sie war ja noch nicht da. Die Zusicherung ist mit
dieser Phase abgelaufen und prüft jetzt das Gegenteil. Ein künftiges Modul
zum Ausgrauen gibt es nicht mehr; kommt später eines dazu, gehört sie
zurück.
### Behoben
- **Die Rechnungsnummer war ein Wettlauf.** Höchste lesen und dann schreiben:
schreiben zwei Leute gleichzeitig eine Rechnung, lesen beide dieselbe
höchste Nummer, und die zweite läuft in den Eindeutigkeitsfehler. Gefunden
hat das der parallele E2E-Lauf. Der seltene Zusammenstoß wird jetzt erkannt
und die nächste Nummer genommen — lückenlos bleibt sie dabei.
- **Eine Organisation ließ sich nicht mehr löschen**, sobald sie eine
festgeschriebene Buchung hatte: der Trigger lehnte auch das Löschen im
Aufbewahrungslauf ab. Er kennt jetzt denselben Schalter wie das Audit-Log
und das Prüfprotokoll (`app.audit_bereinigung`). Ohne ihn wäre die
DSGVO-Löschung einer ausscheidenden Wehr nicht durchführbar gewesen.
---
## [0.9.1] — 2026-09-04
Aufräumarbeiten vor Phase 9. Drei Punkte, die sich über mehrere Phasen
angesammelt hatten und in jeder Phase Zeit gekostet haben.
### Hinzugefügt
- **ESLint** hat endlich eine Konfiguration (`eslint.config.mjs`, Flat Config).
Bis dahin gab es das Skript `npm run lint`, aber nichts, was es hätte prüfen
können — ein Werkzeug, das aussieht, als prüfe es etwas, und nichts prüft,
ist schlechter als gar keines. Bewusst **ohne** Formatierungsregeln: die
macht Prettier. Dazu `npm run lint:fix`.
- **Eine eigene Wehr für die E2E-Tests.** `npm run db:test-reset` legt
`ff-teststelle` neu an — vollständig, mit denselben Beispieldaten, aber unter
eigener Kennung. Ein Riegel im Skript bricht ab, wenn die Testkennung mit der
der Beispielwehr übereinstimmt.
- `tests/e2e/wehr.ts` — Zugangsdaten und Anmeldung an einer Stelle statt
achtmal wortgleich in acht Dateien. Schlägt die Anmeldung fehl, sagt die
Meldung, dass die Testwehr fehlt, statt zweihundertmal „Timeout".
### Behoben
- **Die E2E-Tests schrieben in die Beispielwehr.** Nach fünf Phasen standen
dort 120 Atemschutz-Überwachungen statt einer, 115 Geräte statt 45 und
39 Einsätze statt 8. Zweimal fielen Tests aus, weil ein gesuchter Eintrag von
Seite 1 gerutscht war; bei jedem Lauf unterschieden sich alle 72
Bildschirmfotos. Der Bestand ist einmal zurückgesetzt, die Tests laufen
jetzt gegen die eigene Wehr.
- **`<div>` in einem `<p>` auf der Einsatzseite** — die Meldung stand seit
Phase 3 in der Konsole und ist der klassische Auslöser für einen
Hydration-Fehler. Die Wurzel war das `Badge`: es rendert jetzt ein `<span>`
statt eines `<div>`. Ein Abzeichen ist Fließtext-Inhalt, und die Darstellung
ändert sich dadurch nicht.
- **Die Fehlerfläche der Karte konnte nie etwas anzeigen** — der Zustand
dahinter wurde nirgends gesetzt. Eine gescheiterte Abfrage sah damit aus wie
„keine Hydranten vorhanden". Gefunden hat das der neue Linter.
- **Die Lagekarte rechnete bei jedem Rendern neu.** Ein `?? []` erzeugte jedes
Mal ein neues leeres Feld und machte damit zwei `useMemo` wirkungslos — bei
einer Karte mit dreißig Elementen und einem Ereignisstrom im Zwei-Sekunden-
Takt kein akademischer Punkt.
- Deutsche Anführungszeichen in vier Ansichten schlossen mit einem geraden
Zoll-Zeichen statt mit dem typografischen.
- Unbenutzte Importe in fünf Dateien.
### Geändert
- `npm run lint` ruft jetzt `eslint .` statt des in Next 15.5 abgekündigten
`next lint`.
- **`tests/e2e` ist nicht mehr vom Typecheck ausgenommen.** Die Ausnahme hat
beim Umbau einen doppelt deklarierten Bezeichner durchgelassen, den erst der
Testlauf gemeldet hat — `npx tsc --noEmit` hatte geschwiegen.
---
## [0.9.0] — 2026-09-04
Phase 8: Lokale KI. Ein Wiki mit semantischer Suche, Diktat, Berichtsassistent
und Belegerfassung — alles gegen selbst betriebene Modelle.
**Nichts, was ein Modell erzeugt, gelangt ungeprüft in einen Nachweis.** Jede
KI-Ausgabe ist zuerst ein eigener Datensatz und nicht ein Feld im
Zieldokument. Sie ist gekennzeichnet, sie steht daneben statt mittendrin, und
sie braucht zwei gleich große Knöpfe, bevor etwas mit ihr geschieht.
**Es läuft alles im Haus.** Ollama und faster-whisper als Container daneben,
kein Anbieter, kein Schlüssel. Es gibt keinen Rückfall auf einen Cloud-Dienst:
fehlen die Container, fehlt die Funktion.
### Hinzugefügt
**Datenmodell**
- `KiVorgang` — jede Befragung eines Modells mit Eingabe, Ausgabe, Modell,
Dauer und der Entscheidung darüber. Auch Fehlschläge: ein Modell, das
dreimal am Tag in eine Zeitüberschreitung läuft, ist eine Information.
- `Wikiseite`, `Wikiabschnitt` (mit `vector(768)`) und `Wikiversion`.
- `Diktat` mit Aufnahme in MinIO, `Belegentwurf` mit allen Feldern optional —
ein Modell, das den Betrag nicht findet, soll nichts erfinden.
**Postgres mit pgvector**
- Eigenes Image (`docker/postgres/Dockerfile`), weil FLORIAN PostGIS **und**
pgvector braucht und es kein offizielles Image mit beidem gibt.
- HNSW-Index statt IVFFlat: IVFFlat muss auf gefülltem Bestand trainiert
werden, HNSW ist ab der ersten Zeile brauchbar. Ein Wiki wächst Seite für
Seite — es gibt nie den Moment, an dem man den Index einmal aufbaut.
**Wiki**
- Markdown, selbst gerendert **ohne HTML**: der Text kommt von Anwendern, und
jeder Weg über `dangerouslySetInnerHTML` wäre ein Weg für ein `<script>` in
einer Dienstvorschrift.
- Zwei Suchen: Wortsuche (immer, mit Textstelle) und Frage ans Modell
(semantisch, mit Fundstellen). Steht die Antwort nicht in den Unterlagen,
sagt FLORIAN das — ohne passende Auszüge wird gar nicht erst gefragt.
- Zerlegt wird an den Überschriften, mit der Überschriftenkette im
Einbettungstext: ein Abschnitt über „Rückzugsdruck" ohne das Wort
„Atemschutz" darüber findet sich sonst nicht.
- Jede Änderung schreibt die vorherige Fassung weg.
**Diktat, Bericht, Beleg**
- Aufnahme im Browser, Erkennung über faster-whisper im Hintergrund. Die
Aufnahme bleibt, solange nicht entschieden ist — sie ist das einzige
Original.
- Berichtsentwurf aus Stichpunkten. Zeitstempel und Einsatznummer bekommt das
Modell **nicht**: sie stehen schon als Felder im Bericht.
- Belegerfassung mit Vision-Modell. Nicht erkannte Felder bleiben leer und
gelb hinterlegt statt geraten zu werden.
**Zwei Protokolle**
- `/ki-protokoll` — was die Maschine getan hat, mit Kennzahlen und
aufklappbarer Eingabe/Ausgabe.
- `/protokoll` — das Audit-Log. Wird seit Phase 0 geschrieben; die Ansicht
dazu kam jetzt, weil beides nebeneinander gehört: was hat ein Mensch getan,
was die Maschine.
### Geändert
- `lib/integrations/llm.ts` kann jetzt drei Dinge statt einem: Text,
Einbettungen und Bilder — mit je eigenem Modell, weil ein Rechner ohne
Grafikkarte das Einbettungsmodell noch schafft und das Vision-Modell nicht.
- Der Datei-Upload nimmt Tonaufnahmen an (webm, ogg, mp4, wav).
- Neue Rechte: `wiki.lesen` als Grundrecht (eine Wehr, in der nur die Führung
nachschlagen darf, hat kein Wiki, sondern einen Aktenschrank),
`wiki.bearbeiten`, `ki.nutzen`, `ki.protokoll`, `beleg.erfassen`.
- Der Aufbewahrungslauf zieht nebenbei Wiki-Seiten nach, deren Suchstand
veraltet ist.
### Behoben
- Beim Zerlegen einer Wiki-Seite wurde ein kurzer Abschnitt an den vorherigen
gehängt, auch wenn er unter einer anderen Überschrift stand. Unter einer
Antwort hätte dann eine Fundstelle gestanden, in der der zitierte Satz gar
nicht vorkommt. Gefunden hat es der Test, nicht das Ausprobieren.
### Sicherheit
- Row Level Security für die sechs neuen Tabellen — auch für die Vektorsuche:
ein perfekt passender Abschnitt einer fremden Wehr taucht nicht auf.
- Eingabe, Ausgabe, Modell und Art eines KI-Vorgangs sind nach dem Anlegen
unveränderlich (Trigger). Über einen Vorschlag wird genau einmal
entschieden. `REVOKE DELETE ON "ki_vorgang"`.
- `REVOKE UPDATE, DELETE ON "wikiversion"`: eine Dienstvorschrift, deren
Vorgeschichte sich bereinigen lässt, ist als Nachweis wertlos.
- Prüfbedingungen: keine Entscheidung ohne Zeitpunkt, kein Fehlschlag ohne
Grund, kein negativer Betrag auf einer Quittung.
### Tests
- 73 neue Unit-Tests: Zerlegen einer Seite (Überschriftenketten, Codeblöcke,
Teilung an Absatzgrenzen), Auswerten einer Modellantwort auf ein Belegfoto
(deutsche Zahlen, Tausenderpunkt, unmögliche Daten), die Anweisungen an das
Modell und die Datenbankregeln samt Vektorsuche über Mandanten hinweg.
- 14 neue E2E-Tests, die **ohne laufendes Modell** prüfen, dass alles andere
vollständig benutzbar bleibt und ehrlich sagt, was fehlt.
---
## [0.8.0] — 2026-09-04
Phase 7: Benachrichtigungen und Termine. Ab hier weiß die Mannschaft, wann der
nächste Dienst ist — ohne dass jemand eine Rundmail tippt.
**Niemand wird ungefragt benachrichtigt.** Wer nichts einträgt, bekommt nichts.
Es gibt keine Voreinstellung, die stillschweigend alle anmeldet: ein neu
eingerichteter Zustellweg hat eine leere Ereignisliste, und erst wer ankreuzt,
wofür er ihn will, bekommt etwas.
### Hinzugefügt
**Datenmodell**
- `Termin` mit Art, Ort, Beginn und Ende, Leitung, Platzbegrenzung,
Anmeldeschluss und Serienkennung. Abgesagt wird mit Grund und Zeitpunkt —
**gelöscht wird nicht**, wer zugesagt hat, soll den Grund sehen.
- `Terminanmeldung` mit Zu-, Absage oder Warteliste, dem Grund einer Absage und
der Anwesenheit als eigenem Feld. Zusage und Anwesenheit sind bewusst
getrennt: wer zugesagt hat und nicht kam, steht genau so in der Liste.
- `Zustellkanal` — ein Datensatz je Person und Weg, mit der Adresse dieses Wegs
und der Liste der Ereignisse, für die er benutzt wird. Leer bedeutet: für
nichts.
- `Benachrichtigung` — steht **vor** dem Versand in der Tabelle, nicht danach.
Damit ist nachvollziehbar, wer wann worüber informiert wurde und wer nicht,
weil sein Kanal nicht erreichbar war.
- `Benachrichtigungsvorlage` je Ereignis und Kanal, `Kalenderzugang` mit
SHA-256 des Tokens.
**Terminkalender**
- Liste mit Filter nach Art und „nur meine", Rückmeldung direkt aus der Zeile.
- Serientermine werden **ausgeschrieben**, nicht als Regel gespeichert: jeder
Abend ist ein eigener Datensatz. Sonst wäre jeder ausgefallene Übungsabend
ein Sonderfall. Vor dem Anlegen steht in der Maske, wie viele Termine daraus
werden.
- Kalenderrechnung statt Millisekunden: „monatlich" heißt derselbe Tag im
nächsten Monat, und der 31. Januar landet im Februar auf dem 28. — nicht im
März, wie es passiert, wenn man stur Monate addiert.
- **Warteliste, die von selbst nachrückt.** Wer bei belegten Plätzen zusagt,
landet auf ihr; sagt jemand ab, rückt der Erste nach und erfährt es.
- Absage mit Grund, wahlweise für alle folgenden Termine einer Serie.
Vergangene bleiben unberührt. Benachrichtigt werden die Angemeldeten, nicht
die ganze Wehr.
- Anwesenheitsliste über **alle aktiven Mitglieder**, nicht nur die
Angemeldeten — auf einem Blatt zum Abhaken muss jeder stehen. Zum Abhaken am
Tablet oder zum Ausdrucken.
**Kalenderabonnement**
- ICS-Feed nach RFC 5545 unter `/api/kalender/<token>.ics`. Die Termine der
Wehr stehen damit im Handy neben allem anderen.
- **Bewusst kein CalDAV**: das ist ein Schreib-Lese-Protokoll mit
Rechteverwaltung und Konfliktauflösung. Gebraucht wird nur Lesen.
- Die Adresse steht genau einmal auf dem Bildschirm; gespeichert wird nur ihr
Hash. Jeder Zugang lässt sich einzeln widerrufen, und wer austritt, verliert
ihn.
- Ausgeliefert werden nur Termine — keine Anmeldelisten, keine Personen außer
der Leitung.
**Vier Zustellwege, hinter Schnittstellen**
- **SMTP** löst den Platzhalter aus Phase 0 ab: nodemailer mit
Verbindungspool, damit ein Sammellauf mit vierzig Nachrichten Sekunden statt
Minuten braucht.
- **ntfy** (neu, selbst gehostet, Compose-Profil `push`): Push aufs Handy ohne
Konto, ohne Telefonnummer, ohne Telefonbuchabgleich. Läuft der Server im
Gerätehaus, verlässt keine Nachricht das Haus.
- **Web-Push** (neu, VAPID): Push in den Browser ohne App. Schlüsselpaar über
`npm run vapid`.
- **Telegram** aus Phase 0 wird jetzt tatsächlich benutzt.
- Was das Netz der Wehr verlässt (Telegram, Web-Push), bekommt **nur die
Kurzfassung** — „3 Prüfungen fällig", nicht welche. In der Oberfläche steht
das neben jedem externen Kanal.
**Textvorlagen**
- Je Ereignis und Kanal, mit eingebautem Standardtext für alles. Eine Wehr, die
nichts einstellt, bekommt trotzdem brauchbare Nachrichten.
- Platzhalter zum Anklicken, Vorschau mit erfundenen Beispielwerten. Ein
Platzhalter, den es für das Ereignis nicht gibt, wird beim Speichern
abgewiesen — mit der Liste der erlaubten daneben.
- Ein unbekannter Platzhalter bleibt im Text **sichtbar stehen**, statt still
zu verschwinden. Eine Lücke, die niemand bemerkt, ist schlimmer als ein
seltsam aussehender Text, den jemand korrigiert.
**Digitaler Dienstausweis**
- QR-Code auf dem Handy, Prüfseite ohne Anmeldung unter `/ausweis/<code>`.
Sie zeigt Name, Wehr, Funktion, Foto und die gültigen Qualifikationen.
- Signiert statt gespeichert (HMAC über Person, Wehr und Zeitstempel), **fünf
Minuten gültig**. Die Prüfseite nennt den Zeitpunkt der Ausstellung — ein
Ausweis, der „gültig" sagt, ohne zu sagen seit wann, ist wertlos.
- Der Zweck steht im signierten Teil, damit ein Ausweis nicht als Anmeldemarke
aus Phase 4 durchgeht und umgekehrt.
- Ein gültig signierter Code für jemanden, der ausgetreten ist, wird
abgewiesen. Das Passbild ist über denselben Code erreichbar und damit genauso
lange gültig.
**Was von selbst benachrichtigt**
- Prüfung fällig, Mangel gemeldet, Qualifikation läuft ab (90/30/7 Tage
vorher), neuer Termin, Terminerinnerung (7 und 1 Tag vorher), Einsatzbericht
freigegeben.
- **Anlegen und Zustellen sind getrennt.** Wo ein Ereignis entsteht, wird nur
eine Zeile geschrieben; der Worker schickt sie weg. Sonst hinge das Speichern
eines Termins am Antwortverhalten von Telegram.
- Erinnert wird um sieben Uhr morgens, zugestellt jede Minute: eine
Push-Meldung um drei Uhr weckt Leute, die am nächsten Tag arbeiten müssen —
eine Absage am Dienstagabend muss dagegen ankommen, solange sie noch etwas
nützt.
- Die Sammelmail an den zuständigen Gerätewart bleibt, wie sie war. Wer sie
ohnehin bekommt, wird von der zusätzlichen Benachrichtigung ausgenommen und
hat sie damit nicht doppelt.
### Geändert
- `lib/integrations/mail.ts` verschickt tatsächlich, statt eine Meldung ins Log
zu schreiben. Ohne `MAIL_ENABLED` bleibt es beim Log.
- Neue Rechte: `termin.lesen` und `benachrichtigung.eigene` als Grundrechte
(jeder sieht den Kalender und richtet seine eigenen Wege ein — das ist keine
Sache des Kommandanten), `termin.bearbeiten`, `termin.anwesenheit` und
`benachrichtigung.vorlagen` bei Kommandant, Schriftführer und Ausbilder.
- `DIENSTGRAD_TEXT` liegt jetzt in `lib/stammdaten/texte.ts` statt zweimal fast
gleich in zwei Seiten.
- Der Service Worker nimmt Push-Meldungen an und öffnet beim Antippen ein schon
offenes FLORIAN, statt einen zwölften Tab aufzumachen.
- Der Aufbewahrungslauf räumt zugestellte Benachrichtigungen nach Frist ab.
**Fehlgeschlagene bleiben** — sie zeigen einen kaputten Kanal an, und das
verliert seinen Wert nicht durch Zeitablauf.
### Behoben
- Die Vorlagenmaske überschrieb den gerade getippten Text, sobald die Liste im
Hintergrund nachgeladen wurde (nach dem Speichern, beim Zurückkommen ins
Fenster). Gefunden hat es der E2E-Test, nicht das Ausprobieren.
### Sicherheit
- Row Level Security und Mandantenfilter für alle sechs neuen Tabellen.
- `REVOKE DELETE ON "benachrichtigung"` für die Anwendungsrolle: wer wann
worüber informiert wurde, lässt sich nicht über die Oberfläche wegräumen.
Die Aufbewahrungsfrist läuft über den Systemzugang.
- Prüfbedingungen in der Datenbank: kein Ende vor dem Beginn, kein
Anmeldeschluss nach dem Termin, kein Kontingent von null Plätzen, keine
negative Versuchszahl.
- Kalendertoken nur als SHA-256; ein Push-Abo, das der Dienst als tot meldet,
wird stillgelegt statt gelöscht, damit die Person es in ihren Einstellungen
sieht und neu erlauben kann.
- **Kein WhatsApp** — Begründung im README unter „Warum kein WhatsApp":
Verarbeitung über Meta-Server, Kosten je Konversation, Telefonbuchabgleich.
### Tests
- 56 neue Unit-Tests: Serienrechnung (Monatsenden, Schaltjahr, Kappung),
ICS-Erzeugung (Maskierung, Zeilenumbruch nach 75 Oktetten, CRLF, Absage),
Textbausteine (Kurzfassung für externe Kanäle, unbekannte Platzhalter),
Dienstausweis (Frist, Fälschung, Verwechslung mit der Anmeldemarke) und die
Datenbankregeln der neuen Tabellen.
- 17 neue E2E-Tests für Kalender, Rückmeldung, Warteliste, Absage,
Anwesenheitsliste, Kalenderabonnement, Vorlagen und Prüfseite.
--- ---
+873 -8
View File
@@ -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
@@ -30,9 +36,11 @@ Anmelden mit `SEED_ADMIN_EMAIL` und `SEED_ADMIN_PASSWORT` aus der `.env`
| Phase 4 — Kiosk-PWA im Gerätehaus | fertig | | Phase 4 — Kiosk-PWA im Gerätehaus | fertig |
| Phase 5 — Atemschutz (FwDV 7) | fertig | | Phase 5 — Atemschutz (FwDV 7) | fertig |
| Phase 6 — Kartenmodule | fertig | | Phase 6 — Kartenmodule | fertig |
| Phase 7 — Benachrichtigungen und Termine | offen | | Phase 7 — Benachrichtigungen und Termine | fertig |
| Phase 8 — Lokale KI | offen | | Phase 8 — Lokale KI | fertig |
| Phase 9 — Kassenverwaltung | offen | | Phase 9 — Kassenverwaltung | fertig |
| Phase 10 — Mandantenfähigkeit | fertig |
| Phase 11 — Freigabereife | fertig |
Vollständige Planung mit Aufwänden: [docs/ROADMAP.md](docs/ROADMAP.md) und 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).
@@ -41,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 |
@@ -785,21 +794,841 @@ Verteiler.**
--- ---
## Phase 7 — Benachrichtigungen und Termine
Der Satz, der über der ganzen Phase steht und der auch die Technik darunter
bestimmt: **Niemand wird ungefragt benachrichtigt.** Wer nichts einträgt,
bekommt nichts. Es gibt keine Voreinstellung, die stillschweigend alle
anmeldet — ein neu eingerichteter Zustellweg hat eine leere Ereignisliste, und
erst wer ankreuzt, wofür er ihn will, bekommt etwas.
### Der Terminkalender
Übungsabende, Lehrgänge, Arbeitsdienste, Sitzungen. Drei Entscheidungen prägen
ihn, und alle drei kommen aus dem Alltag einer Wehr:
**Anmeldung ist die Ausnahme, nicht die Regel.** Beim normalen Übungsabend
meldet sich niemand an, man kommt oder man kommt nicht. Erst beim Lehrgang mit
zwölf Plätzen und beim Ausflug mit Busbestellung wird eine Rückmeldung
verlangt.
**Abgesagt, nicht gelöscht.** Ein Termin, der ohne ein Wort verschwindet, führt
dazu, dass am Dienstagabend jemand vor dem verschlossenen Gerätehaus steht.
Eine Absage braucht einen Grund, bleibt im Kalender stehen und erreicht die,
die zugesagt hatten — nicht die ganze Wehr.
**Die Warteliste rückt von selbst nach.** Wer bei belegten Plätzen zusagt,
landet auf der Warteliste; sagt jemand ab, rückt der Erste nach und erfährt es.
Sonst führt jemand die Liste von Hand, und das tut niemand zuverlässig.
Serientermine werden **ausgeschrieben**, nicht als Regel gespeichert: jeder
Abend ist ein eigener Datensatz. Der Grund ist derselbe Alltag — der Übungsabend
fällt im August aus, im Dezember ist er eine Woche früher, und für jeden
einzelnen gibt es Zusagen und eine Anwesenheitsliste. Mit einer Regel wäre jede
dieser Ausnahmen ein Sonderfall. Vor dem Anlegen steht in der Maske, wie viele
Termine daraus werden.
Die Anwesenheitsliste zeigt **alle aktiven Mitglieder**, nicht nur die
Angemeldeten: auf einem Blatt zum Abhaken muss jeder stehen. Zusage und
Anwesenheit sind getrennt — wer zugesagt hat und nicht kam, steht genau so in
der Liste.
### Der Kalender fürs Handy
Eine Adresse, die man im Handy oder im Mailprogramm abonniert; die Termine
stehen dann neben allem anderen, ohne dass jemand FLORIAN öffnen muss. Das ist
der Kanal mit der größten Wirkung pro Zeile Code in dieser Phase.
**Bewusst kein CalDAV.** Das ist ein Schreib-Lese-Protokoll mit
Rechteverwaltung und Konfliktauflösung; gebraucht wird hier nur Lesen, und dafür
genügt eine Adresse, die eine ICS-Datei ausliefert. Jeder Kalender kann das.
Die Adresse ist der Schlüssel — Kalenderprogramme können sich nicht anmelden,
sie rufen stumpf eine URL ab. Daraus folgt:
- Gespeichert wird nur der SHA-256 des Tokens; die Adresse steht **genau
einmal** auf dem Bildschirm.
- Jeder Zugang lässt sich einzeln widerrufen. Ein verlorenes Handy kostet einen
Klick, nicht ein neues Passwort für alle.
- Ausgeliefert werden **nur Termine** — keine Anmeldelisten, keine Personen
außer der Leitung, keine Mängel, keine Einsätze.
- Wer aus der Wehr austritt, verliert das Abonnement.
### Die vier Kanäle
| Kanal | Läuft über | Bekommt |
|---|---|---|
| **E-Mail** (SMTP) | den eigenen bzw. den Mailserver der Gemeinde | den ausführlichen Text |
| **ntfy** (selbst gehostet) | den eigenen Server, Profil `push` | den ausführlichen Text |
| **Telegram** | einen fremden Server | nur die Kurzfassung |
| **Web-Push** | den Push-Dienst des Browserherstellers | nur die Kurzfassung |
Die Spalte ganz rechts ist keine Bequemlichkeit, sondern die Regel: was das Netz
der Wehr verlässt, bekommt einen Hinweis ohne Namen, Gerätebezeichnungen und
Einsatzdetails — „3 Prüfungen fällig", nicht welche. In der Oberfläche steht das
neben jedem externen Kanal, und die Vorlagenmaske sagt es noch einmal, bevor
jemand einen Namen in eine Telegram-Vorlage tippt.
**ntfy** ist der interessanteste Weg für eine Wehr: ein Container, eine App im
Store, kein Konto, keine Telefonnummer, kein Telefonbuchabgleich. Man abonniert
ein Thema — einen frei gewählten Namen — und bekommt Meldungen darauf. Läuft
der Server im Gerätehaus, verlässt keine Nachricht das Haus. Der Preis: ein
Thema ist nur so geheim wie sein Name, deshalb schlägt FLORIAN Namen mit
Zufallsanteil vor.
```bash
docker compose --profile push up -d # ntfy startet auf Port 8086
npm run vapid # Schlüsselpaar für Web-Push
```
### Warum kein WhatsApp
Es gibt keine `WHATSAPP_`-Variablen, und das ist eine Entscheidung, keine
Lücke. Drei Gründe, in dieser Reihenfolge:
**Datenschutz.** Der einzige zulässige Weg für eine Organisation ist die
WhatsApp Business Platform. Nachrichten laufen über Server von Meta, und die
Wehr müsste einen Vertrag zur Auftragsverarbeitung schließen — für eine
Freiwillige Feuerwehr, deren Daten ausdrücklich im Gerätehaus bleiben sollen,
ist das der falsche Weg. Die gewöhnliche App scheidet ohnehin aus: sie gleicht
das Telefonbuch ab und gibt damit die Kontakte aller Mitglieder weiter.
**Kosten.** Abgerechnet wird je Konversation. Eine Wehr mit fünfzig Mitgliedern
und zwei Terminerinnerungen im Monat zahlt dauerhaft für etwas, das über den
eigenen Mailserver nichts kostet.
**Aufwand.** Business-Konto, verifizierte Telefonnummer, genehmigte
Nachrichtenvorlagen, ein Meta-Entwicklerkonto. Das ist mehr Verwaltung als der
gesamte Rest dieser Phase.
Wer seine Wehr trotzdem über WhatsApp erreichen will, hat den Weg schon: die
Gruppe, die es ohnehin gibt. Ein Link zum Termin hineinkopiert kostet nichts
und verrät niemandem etwas.
### Der digitale Dienstausweis
Ein QR-Code auf dem Handy, den man an der Absperrung, beim Lehrgang oder bei
einer Kontrolle vorzeigt. Wer ihn scannt, landet auf einer Prüfseite und sieht
Name, Wehr, Funktion, Foto und die gültigen Qualifikationen — **ohne sich
anzumelden**, denn wer prüft, hat keinen Zugang zu FLORIAN.
- **Signiert, nicht gespeichert.** HMAC über Person, Wehr und Zeitstempel.
Keine Tabelle, kein Aufräumjob.
- **Fünf Minuten gültig.** Er wird vorgezeigt, abgelesen und weggesteckt; ein
abfotografierter Code ist danach wertlos. Die Prüfseite nennt deshalb den
Zeitpunkt der Ausstellung — ein Ausweis, der „gültig" sagt, ohne zu sagen
seit wann, ist wertlos.
- **Er öffnet nichts.** Die Prüfseite zeigt an, sie lässt nicht hinein. Wer den
Code hat, bekommt keinen Zugang zu einem einzigen Datensatz.
- Was **nicht** darauf steht: Anschrift, Geburtsdatum, Telefonnummer,
Mitgliedsnummer. Ein Ausweis weist aus; er ist keine Personalakte.
- Ein gültig signierter Code für jemanden, der ausgetreten ist, wird abgewiesen.
### Was von selbst benachrichtigt
Sechs Anlässe, an die vorhandenen Abläufe angehängt:
| Anlass | Wann | An wen |
|---|---|---|
| Prüfung fällig | im Erinnerungslauf | wer es abonniert hat |
| Mangel gemeldet | sofort | wer es abonniert hat |
| Qualifikation läuft ab | 90, 30 und 7 Tage vorher | die Person selbst |
| Neuer Termin | beim Anlegen | wer es abonniert hat |
| Terminerinnerung | 7 Tage und 1 Tag vorher | Angemeldete, sonst Abonnenten |
| Einsatzbericht freigegeben | bei der Freigabe | wer es abonniert hat |
Die Sammelmail an den zuständigen Gerätewart bleibt, wie sie war — sie geht an
den, der zuständig ist, unabhängig davon, ob er irgendwo ein Kreuzchen gesetzt
hat. Ein Prüftermin ist eine Pflicht, kein Abo. Wer sie ohnehin bekommt, wird
von der Benachrichtigung ausgenommen und hat sie damit nicht doppelt.
Erinnert wird um sieben Uhr morgens, nicht nachts: eine Push-Meldung um drei Uhr
weckt Leute, die am nächsten Tag arbeiten müssen. Zugestellt wird dagegen jede
Minute — eine Absage am Dienstagabend muss ankommen, solange sie noch etwas
nützt.
**Anlegen und Zustellen sind getrennt.** Wo ein Ereignis entsteht, wird nur eine
Zeile geschrieben; der Worker schickt sie weg und vermerkt, wie es ausging.
Sonst hinge das Speichern eines Termins am Antwortverhalten von Telegram. Eine
fehlgeschlagene Zustellung bleibt sichtbar stehen und wird nicht nach Frist
abgeräumt — sie ist der Hinweis darauf, dass ein Kanal kaputt ist, und der
verliert seinen Wert nicht durch Zeitablauf.
Der Probeknopf steht direkt an jedem Zustellweg und läuft durch dieselbe
Maschinerie wie alles andere. Ohne ihn merkt jemand erst in drei Wochen, dass
sein ntfy-Thema einen Tippfehler hat — nämlich dann, wenn die Nachricht wichtig
gewesen wäre.
### Zusätzliche Umgebungsvariablen
| Variable | Vorgabe | Bedeutung |
|---|---|---|
| `NTFY_ENABLED` | `false` | Push über den eigenen ntfy |
| `NTFY_URL` | — | Adresse des eigenen ntfy, z. B. `http://ntfy:80` |
| `NTFY_TOKEN` | — | nur bei eingeschaltetem Zugriffsschutz |
| `NTFY_PORT` | `8086` | Port des ntfy-Containers (Profil `push`) |
| `WEBPUSH_ENABLED` | `false` | Push in den Browser |
| `WEBPUSH_PUBLIC_KEY` | — | aus `npm run vapid`; **ins Backup** |
| `WEBPUSH_PRIVATE_KEY` | — | dito |
| `WEBPUSH_KONTAKT` | `APP_URL` | Kontaktweg für den Push-Dienst |
| `BENACHRICHTIGUNG_LAUF` | `0 7 * * *` | wann erinnert wird |
| `TERMIN_ERINNERUNG_TAGE` | `7,1` | Vorlauf der Terminerinnerungen |
| `BENACHRICHTIGUNG_VERSUCHE` | `3` | Zustellversuche, bevor es stehen bleibt |
| `BENACHRICHTIGUNG_AUFBEWAHRUNG_TAGE` | `90` | Frist für **zugestellte** Nachrichten |
Ein neuer öffentlicher VAPID-Schlüssel macht alle bestehenden Push-Abos
wertlos — jeder müsste Push von Hand neu erlauben, und niemand merkt es, die
Meldungen bleiben einfach aus. Beide Schlüssel gehören deshalb ins Backup.
---
## Phase 8 — Lokale KI
Ein Satz bestimmt alles, was hier gebaut wurde:
> **Nichts, was ein Modell erzeugt, gelangt ungeprüft in einen Nachweis.**
Daraus folgt der Aufbau: jede KI-Ausgabe ist zuerst ein eigener Datensatz und
nicht ein Feld im Zieldokument. Ein Einsatzbericht bekommt seinen Text erst,
wenn ein Mensch „übernehmen" gedrückt hat — bis dahin liegt der Entwurf daneben
und ist als Entwurf erkennbar, mit gestricheltem Rahmen und dem Modellnamen
darüber. Das ist der Unterschied zwischen einer Hilfe und einer Fälschung.
Der zweite Grundsatz: **es läuft alles im Haus.** Ollama und faster-whisper als
Container daneben, kein Anbieter, kein Schlüssel, keine Zeile Text, die das
Gerätehaus verlässt. Es gibt **keinen Rückfall** auf einen Cloud-Dienst: fehlen
die Container, fehlt die Funktion. Das ist die einzige Bauart, bei der der Satz
„die Daten bleiben im Haus" auch dann noch stimmt, wenn der eigene Server aus
ist.
### Ohne Modelle geht alles andere weiter
Die wichtigste Eigenschaft der Phase, und die am meisten Arbeit gemacht hat:
**FLORIAN bleibt ohne Sprachmodell vollständig benutzbar.** Wiki lesen und
schreiben, Wortsuche, Belege ablegen und von Hand ausfüllen, beide Protokolle —
alles läuft. Wo etwas fehlt, steht ein Satz, der sagt *was* fehlt und *wie es
ohne geht*, statt eines kaputten Knopfes.
Das ist kein Zugeständnis, sondern der Normalfall: eine Wehr setzt FLORIAN auf,
lange bevor sie über eine Grafikkarte nachdenkt.
### Das Wiki
Dienstvorschriften, Ausbildungsunterlagen, Merkblätter — das, was sonst als PDF
im Ordner steht und im Einsatz niemand findet. Inhalt ist **Markdown**, weil es
sich ohne Werkzeug lesen lässt, auch in zwanzig Jahren, auch ohne FLORIAN.
**Zwei Suchen nebeneinander**, und beide werden gebraucht:
| Suche | Findet | Braucht ein Modell |
|---|---|---|
| **Wortsuche** | was dasteht — „FwDV 7" findet die Seite mit dieser Zeichenfolge | nein |
| **Frage** | was gemeint ist — „wie lange hält ein Schlauch" findet den Abschnitt über Ablegereife | ja |
Die Frage wird **ausschließlich aus den eigenen Seiten** beantwortet, mit
Fundstellen darunter. Steht die Antwort nicht drin, sagt FLORIAN genau das. Ein
Modell, das auf die Frage nach der Prüffrist von Steckleitern eine Zahl nennt,
die nirgends steht, wäre gefährlicher als gar keine Antwort — deshalb wird ohne
passende Auszüge gar nicht erst gefragt.
Zerlegt wird an den **Überschriften**, nicht nach Zeichenzahl: eine Fundstelle
soll ein Kapitel sein, kein aus der Mitte gerissener Absatz. Und ein kurzer
Abschnitt wird nie unter eine fremde Überschrift gehängt — sonst zitierte die
Antwort eine Fundstelle, in der der Satz gar nicht steht.
Jede Änderung schreibt die vorherige Fassung weg. Eine Dienstvorschrift, die
sich unbemerkt ändert, ist schlimmer als keine.
### Diktat
Der Weg, den es in jeder Wehr schon gibt: der Einsatzleiter spricht auf der
Rückfahrt in sein Telefon, und daraus wird abends ein Bericht. Neu ist nur, dass
abends schon ein Text dasteht.
Ein Knopf, keine Einstellungen. Die Aufnahme bleibt abgelegt, solange über den
Text nicht entschieden ist — wer dem Transkript nicht traut, hört nach. Und das
Modell darf **nichts verbessern**: „Hohlstrahlrohr" bleibt „Hohlstrahlrohr",
auch wenn es das Wort nicht kennt.
### Berichtsassistent
Stichpunkte hinein, Entwurf heraus. Was das Modell **nicht** bekommt, ist ebenso
wichtig: Zeitstempel und Einsatznummer stehen schon als Felder im Bericht und
stünden sonst zweimal da, beim zweiten Mal womöglich falsch.
Der Entwurf landet nicht im Berichtsfeld. Er steht daneben, gekennzeichnet, mit
zwei gleich großen Knöpfen — „Übernehmen" ist nicht der Standardweg und sieht
auch nicht so aus.
### Belegerfassung
Quittung fotografieren, Angaben prüfen, gegenzeichnen. Alle Felder sind
änderbar, und was das Modell nicht erkannt hat, bleibt **leer und gelb
hinterlegt** statt geraten zu werden. Ein leeres Feld ist eine Aufforderung; ein
geratener Betrag ist eine falsche Buchung, die niemand bemerkt.
Die Buchung daraus kommt mit der Kasse in Phase 9 — hier entsteht erst einmal
ein sauber erfasster Beleg mit Foto.
### Zwei Protokolle nebeneinander
| Ansicht | Beantwortet |
|---|---|
| **KI-Protokoll** | Was hat die Maschine getan? Jede Befragung, jedes Modell, jede Entscheidung mit Namen und Zeitpunkt. |
| **Protokoll** (Audit-Log) | Was haben Menschen getan? Wird seit Phase 0 geschrieben; die Ansicht dazu kam jetzt. |
Eingabe, Ausgabe, Modell und Art eines KI-Vorgangs sind nach dem Anlegen
**unveränderlich** — ein Trigger hält dagegen. Löschen ist der Anwendungsrolle
entzogen, und über einen Vorschlag wird genau einmal entschieden. Ohne das
ließe sich in einem Jahr nicht mehr sagen, welcher Satz in einem Einsatzbericht
von einem Menschen stammt.
### Einrichtung
Postgres braucht seit dieser Phase **pgvector** neben PostGIS. Es gibt kein
offizielles Image mit beidem, deshalb baut FLORIAN eines
(`docker/postgres/Dockerfile`):
```bash
docker compose build postgres # bei einer bestehenden Installation
docker compose up -d postgres # Datenverzeichnis bleibt unberührt
npm run db:migrate
```
Dann die Modelle:
```bash
docker compose --profile ki up -d
docker compose exec ollama ollama pull qwen2.5:14b # Text
docker compose exec ollama ollama pull nomic-embed-text # Einbettungen
docker compose exec ollama ollama pull llama3.2-vision # Belege (optional)
```
Auf einem Rechner ohne Grafikkarte läuft das Einbettungsmodell noch flott, das
Textmodell zäh und das Vision-Modell praktisch nicht. Deshalb sind alle drei
einzeln konfigurierbar: eine Wehr kann das Wiki durchsuchbar machen, ohne
Berichte schreiben zu lassen.
Für eine Nvidia-Karte in die `docker-compose.override.yml`:
```yaml
services:
ollama:
deploy:
resources:
reservations:
devices:
- driver: nvidia
count: all
capabilities: [gpu]
```
### Ein anderes Einbettungsmodell
Die Spalte `wikiabschnitt.einbettung` ist auf **768 Dimensionen** festgelegt —
das liefert `nomic-embed-text`. Ein Modell mit anderer Länge braucht eine
Migration:
```sql
ALTER TABLE "wikiabschnitt" DROP COLUMN "einbettung";
ALTER TABLE "wikiabschnitt" ADD COLUMN "einbettung" vector(1024);
CREATE INDEX "wikiabschnitt_einbettung_idx"
ON "wikiabschnitt" USING hnsw ("einbettung" vector_cosine_ops);
```
Danach `LLM_EINBETTUNG_DIMENSIONEN` anpassen und alle Seiten neu einbetten. Der
nächtliche Lauf holt das von selbst nach; wer nicht warten will, drückt auf
jeder Seite „jetzt nachziehen".
Ohne Anpassung weist FLORIAN das Modell mit einer klaren Meldung ab, statt eine
Fehlermeldung der Datenbank durchzureichen, die niemand versteht.
### Zusätzliche Umgebungsvariablen
| Variable | Vorgabe | Bedeutung |
|---|---|---|
| `LLM_ENABLED` | `false` | Schaltet alle Textfunktionen ein |
| `LLM_BASE_URL` | — | OpenAI-kompatibler Endpunkt, z. B. `http://ollama:11434/v1` |
| `LLM_MODELL` | — | Modell für Berichte und Antworten |
| `LLM_TIMEOUT` | `120` | Zeitlimit in Sekunden |
| `LLM_EINBETTUNG_MODELL` | `nomic-embed-text` | Modell für die semantische Suche |
| `LLM_EINBETTUNG_DIMENSIONEN` | `768` | **Muss zur Spalte passen** |
| `LLM_VISION_MODELL` | — | Modell für die Belegerfassung |
| `WHISPER_ENABLED` | `false` | Schaltet das Diktat ein |
| `WHISPER_URL` | — | z. B. `http://whisper:8000` |
| `WHISPER_MODELL` | `base` | `tiny`, `base`, `small` |
| `DIKTAT_MAX_SEKUNDEN` | `600` | Höchstlänge einer Aufnahme |
| `OLLAMA_PORT` | `11435` | Port des Ollama-Containers (Profil `ki`) |
| `WHISPER_PORT` | `8087` | Port des Whisper-Containers (Profil `ki`) |
---
## Phase 9 — Kassenverwaltung
Kassenbuch, Mitgliedsbeiträge, Rechnungen an Dritte, Kassenprüfung und die
Zahlen für die Jahreshauptversammlung.
Eine Feuerwehrkasse wird von einem Ehrenamtlichen geführt und von zwei anderen
geprüft. Was die Prüfung wert ist, hängt einzig daran, dass sich das Geprüfte
hinterher nicht mehr ändern lässt. Deshalb ist der zentrale Punkt dieser Phase
kein Formular, sondern eine Sperre.
### Drei Festlegungen
**Eine festgeschriebene Buchung ist unveränderbar.** Nicht, weil ein Zweig im
Router es so vorsieht, sondern weil die Datenbank es ablehnt — ein vergessener
Zustandscheck, ein Skript, ein Import würden sonst reichen. Korrigiert wird
über eine **Gegenbuchung** mit Verweis: beide Buchungen bleiben stehen, und im
Kassenbuch ist zu sehen, dass etwas berichtigt wurde. Genau das will ein
Kassenprüfer sehen; eine verschwundene Buchung kann er nicht prüfen.
**Der Kassenabschluss schließt seinen Zeitraum.** Danach wird keine Buchung
mit einem Datum darin mehr angenommen — auch keine nachträglich
hineindatierte. Ohne diese zweite Hälfte stimmte der geprüfte Bestand nach
einem Datumswechsel nicht mehr, und niemand hätte es bemerkt. Wer nach der
Prüfung noch eine Quittung findet, bucht sie mit dem heutigen Datum.
**Beträge sind `DECIMAL(12,2)`, gerechnet wird mit `Prisma.Decimal`.**
`0.1 + 0.2` ergibt in JavaScript 0.30000000000000004. In einer Kasse ist das
kein Rundungsfehler, sondern eine Differenz in der Prüfung, die niemand
erklären kann.
### Das Kassenbuch
Drei Kontenarten: Barkasse, Bankkonto, Rücklage. Der **Bestand wird gerechnet,
nie gespeichert** — Anfangsbestand plus die Wirkung aller Buchungen. Ein
gespeicherter Bestand neben einer nachgetragenen Buchung wäre falsch, ohne
falsch auszusehen, und genau danach sucht ein Kassenprüfer.
Der Betrag einer Buchung ist immer positiv; die Richtung steht daneben. Eine
negative Ausgabe wäre eine Einnahme in Verkleidung und in keiner Auswertung
als solche zu erkennen — die Datenbank nimmt sie deshalb nicht an.
Die Buchungsmaske schlägt eine Kategorie vor: zuerst aus den **eigenen
früheren Buchungen** der Wehr, erst danach aus einer kurzen Stichwortliste.
Der Nutzen liegt nicht in den gesparten Sekunden, sondern darin, dass dieselbe
Sache immer unter derselben Kategorie landet — erst das macht die
Jahresauswertung brauchbar. Unter der Hälfte gemeinsamer Wörter schweigt der
Vorschlag: ein falscher wird übernommen, ohne gelesen zu werden.
Ein **gegengezeichneter Beleg** aus Phase 8 wird mit einem Klick zur Buchung:
Konto und Kategorie wählen, fertig. Betrag, Datum und Händler stehen schon
fest, und das Foto hängt danach an der Buchung — zu jeder Ausgabe ein Beleg,
an der Buchung und nicht in einem Ordner daneben. Ein Entwurf lässt sich
nicht buchen: er ist ein von einem Modell ausgefülltes Formular.
### Kontoauszug einlesen
Das Online-Banking bietet beides zum Herunterladen an, meist unter
„Umsatzexport“:
| Format | Datei | Was gelesen wird |
|---|---|---|
| CAMT.053 | `.xml`, `.camt` | Datum, Richtung, Betrag, Verwendungszweck, Gegenseite, Bankkennung |
| MT940 | `.txt`, `.sta` | dasselbe, aus den Feldern `:61:` und `:86:` |
Erkannt wird am Inhalt, nicht an der Dateiendung. Es entstehen **Entwürfe,
keine fertigen Buchungen** — durchsehen muss sie der Kassier.
Jeder Umsatz trägt eine Kennung: die der Bank, sonst eine aus Datum, Betrag
und Zweck gebildete. Derselbe Auszug zweimal eingelesen wird dadurch als
solcher erkannt und in der Liste als „steht schon in der Kasse“ ausgewiesen.
Ohne das wäre es ein doppelt gebuchter Monat, und der fiele erst beim
Kassenabschluss auf.
Was nicht gelesen werden konnte, steht mit Grund daneben. Ein Parser, der
stillschweigend drei Umsätze verschluckt, ist schlimmer als einer, der gar
nicht erst liest.
### Mitgliedsbeiträge
Sätze gelten ab einem Jahr und je Abteilung; ein Satz ohne Abteilung gilt für
alle übrigen. Rückwirkend geändert wird nichts — sonst stimmten die Beiträge
vergangener Jahre nicht mehr, und die sind bereits geprüft.
Der **Beitragslauf** zeigt vorher, was entstehen wird: wie viele Beiträge,
welche Summe, und wer keinen passenden Satz hat. Der letzte Teil ist der
interessante — das sind die Leute, für die niemand entschieden hat, was sie
zahlen. Der Lauf ist wiederholbar; im Februar noch einmal gestartet legt er
nur die Neuen an.
Ein Zahlungseingang erzeugt zugleich die Buchung. Ein Häkchen ohne Geld in der
Kasse wäre eine Behauptung. Ein Erlass braucht eine Begründung — die Datenbank
lehnt einen ohne ab, weil er in der Kassenprüfung sonst eine offene Frage ist.
Die Rückstandsliste geht über alle Jahre. Sie ist die einzige Ansicht, in der
auffällt, wer seit drei Jahren offen steht.
### Rechnungen an Dritte
Kostenersatz nach Art. 28 BayFwG, Vermietungen, Veranstaltungen. Die Nummer
vergibt FLORIAN fortlaufend je Jahr und **lückenlos**: eine fehlende Nummer
muss in der Kassenprüfung erklärt werden.
Der Betrag ist die Summe der Positionen, gerechnet und nicht eingegeben. Ein
eingetippter Gesamtbetrag neben Positionen, die etwas anderes ergeben, ist der
Fehler, den niemand bemerkt, bis der Empfänger anruft. Die Menge hat drei
Nachkommastellen — Einsatzstunden werden viertelstündlich abgerechnet, und
0,25 × 47,50 muss auf den Cent stimmen.
Bis zum Versenden ist eine Rechnung ein Entwurf, danach unveränderbar: was
beim Empfänger liegt, muss dem entsprechen, was hier steht. Korrigiert wird
über Storno und eine neue Rechnung; die stornierte bleibt mit Grund stehen.
### Kassenprüfung
In der Prüfmaske steht links der Bestand laut Buch und rechts der gezählte.
In dieser Reihenfolge, und die gezählten Felder fangen leer an — wer erst das
Buch sieht und dann zählt, zählt, bis es passt.
Ohne Prüfer und Prüfdatum lässt sich nichts festschreiben: ohne beides ist es
kein Abschluss, sondern eine Notiz. Eine Differenz braucht eine Erläuterung,
sonst steht in fünf Jahren „stimmt nicht“ im Bericht und niemand weiß mehr,
warum.
Der Kassenprüfbericht entsteht als PDF mit vier Unterschriftsfeldern und wird
im Hintergrund erzeugt.
### Auswertungen
Einnahmen und Ausgaben nach Kategorie und Monat, mit dem Vorjahr daneben —
eine Zahl ohne Vergleich sagt nichts. Der Export ist eine CSV-Datei mit
Semikolon und BOM: das ist, was Excel in Deutschland ohne Rückfrage richtig
öffnet. Eine Datei, die beim Doppelklick als eine Spalte voller Semikolons
erscheint, wird zurückgeschickt.
### Wer was sieht
| Recht | Wer | Was |
|---|---|---|
| `kasse.lesen` | Kassier, Kommandant, Vorstand | Buchungen und Auswertungen ansehen |
| `kasse.bearbeiten` | Kassier | Buchungen, Beiträge, Rechnungen erfassen |
| `kasse.abschluss` | Kassier, Kassenprüfer | Kassenabschluss anlegen und festschreiben |
Der Gerätewart sieht keine Buchungen, der Kassier keine Atemschutzdaten. Die
Trennung liegt in `lib/authz.ts`, nicht in den Komponenten.
**Keine zusätzlichen Umgebungsvariablen.** Die Kasse braucht nur, was ohnehin
läuft: Postgres, MinIO für die Belege und Gotenberg für die PDFs.
---
## Phase 10 — Mandantenfähigkeit
Ein Server, mehrere Wehren. Ein Konto, mehrere Zugehörigkeiten. Eine
Kreisebene, die zusieht.
Datenmodell und Row Level Security tragen seit Phase 0 mehrere Wehren — jede
Tabelle hat `organisationId`, jede Abfrage läuft im Mandantenkontext. Was
fehlte, war alles darüber: eine Anmeldung, die zu mehr als einer Wehr gehören
kann, ein Weg zum Umschalten, und eine Verwaltung für den, der den Server
betreibt.
### Konto und Akte sind nicht dasselbe
Bis Phase 9 war die Personenakte zugleich das Anmeldekonto: Passwort-Hash und
OIDC-Subject hingen an `person`, und `person` gehört genau einer Wehr. Daraus
folgten zwei Dinge, die niemand wollte:
- Wer in zwei Wehren geführt wurde, brauchte **zwei Passwörter**, die
auseinanderlaufen können.
- Die Anmeldung per E-Mail war **mehrdeutig**, sobald dieselbe Adresse in zwei
Wehren stand — die Eindeutigkeit galt nur je Organisation, und gefunden
wurde, was die Datenbank zuerst hergab.
Seit Phase 10:
| Tabelle | Was sie ist | Mandant |
|---|---|---|
| `Benutzer` | das Anmeldekonto — E-Mail, Passwort, OIDC | **keiner** |
| `Mitgliedschaft` | Zugehörigkeit eines Kontos zu einer Wehr | `organisationId` |
| `Person` | die Akte in einer Wehr — Dienstgrad, Qualifikationen, Einsätze | `organisationId` |
**`Benutzer` trägt bewusst keine Mandantenkennung** und kann keine tragen: sie
ist ja der Angelpunkt, an dem ein Mensch zu mehreren Wehren gehört. RLS greift
dort deshalb nicht. Geschützt wird sie stattdessen über Rechte — dieselbe
Lösung wie bei `Berechtigung` aus Phase 0 und aus demselben Grund: eine
Tabelle, die über allen Mandanten steht, gehört nicht in die Hand der
Anwendungsrolle. Was ein Konto in einer Wehr darf, entscheidet nicht es
selbst, sondern seine `Mitgliedschaft`.
Ein angenehmer Nebeneffekt: der Passwort-Hash kann aus einer Personenabfrage
gar nicht mehr herausfallen. Die drei Stellen, die ihn bisher vorsichtshalber
abgeschnitten haben, sind entfallen — eine Sorgfaltsmaßnahme ist durch eine
Struktur ersetzt.
### Die Wehr wechseln
Der Wähler steht in der Seitenleiste unter dem Namen der Wehr — **aber nur,
wenn es etwas zu wählen gibt.** Für die allermeisten gibt es das nie, und ein
Auswahlfeld mit genau einem Eintrag ist keine Hilfe, sondern eine Frage ohne
Antwortmöglichkeit.
Nach der Anmeldung öffnet die **eigene** Wehr, nicht die betreute: wer eine
Wehr führt und acht andere beaufsichtigt, will in seiner landen.
Technisch: die Wehr steht im Sitzungstoken, also wird beim Wechsel ein neues
ausgestellt. Was der Browser dabei schickt, ist ein **Wunsch** — geprüft wird
serverseitig gegen die Mitgliedschaften des Kontos. Eine fremde Kennung zu
nennen bringt nichts, weil es dazu keine Mitgliedschaft gibt.
### Die Kreisebene
Kreisbrandrat und Kreisbrandinspektion gehören den betreuten Wehren nicht an.
Sie bekommen deshalb dort **keine Personenakte**, tauchen in keiner
Mitgliederliste auf und können nichts ändern.
Ihre Rechte werden nicht aufgezählt, sondern **gefiltert**: alles, was
`.lesen` heißt, und sonst nichts. Eine von Hand gepflegte Liste wäre bei der
nächsten Phase veraltet, und zwar unbemerkt — ein neues Modul brächte Rechte
mit, die niemand nachträgt. So bekommt die Aufsicht neue Leserechte
automatisch und neue Schreibrechte nie.
Vier Rechte sind ausdrücklich ausgenommen, obwohl sie auf `.lesen` enden:
| Recht | Warum nicht |
|---|---|
| `kasse.lesen` | Die Kasse gehört dem Verein oder der Kommune, nicht der Kreisaufsicht. |
| `person.export` | Das Auskunftsrecht nach DSGVO erfüllt die Wehr, die die Daten führt. |
| `system.auditlog` | Wer wann was getan hat, bleibt innerhalb der Wehr. |
| `ki.protokoll` | Dasselbe. |
Der **Atemschutz** steht dagegen bewusst drin: ob ein Träger tauglich ist, ist
genau die Frage, für die es eine Kreisebene gibt.
Wer als Aufsicht unterwegs ist, sieht dauerhaft einen Hinweis darauf — nicht
als kurz aufblitzende Meldung, sondern als Zeile über der Ansicht. Man soll
nicht erst merken, in welcher Rolle man ist, wenn ein Knopf fehlt.
### Doppelmitgliedschaft
Ein Konto, mehrere Zugehörigkeiten. In Bayern der Regelfall bei allem, was
über die eigene Wehr hinausgeht: aktiv in A, Ehrenmitglied in B, oder
Kommandant der einen und Kreisbrandinspektor für sechs weitere.
Zwei Zusicherungen setzt die Datenbank durch:
- **Ein Konto gehört einer Wehr höchstens einmal an.**
- **Eine Personenakte hängt an höchstens einem Konto.** Sonst wäre nicht
entscheidbar, wer unter ihrem Namen gehandelt hat.
Und eine dritte, die weniger offensichtlich ist: die Akte muss zu **derselben
Wehr** gehören wie die Zuordnung. Ein Fremdschlüssel prüft nur, *dass* es die
Akte gibt, nicht *wo* sie liegt — ohne diese Prüfung ließe sich ein Konto über
eine Akte aus Wehr A in Wehr B hängen, und die Sitzung stünde dort mit einer
Personenkennung da, die niemandem gehört. Ein Trigger lehnt das ab.
### Wehren und Konten verwalten
Unter **`/wehren`**, und nur für den, der die Installation betreibt.
Der Zugang hängt an einem Merkmal des Kontos (`istVerwalter`), **nicht an
einem Recht**. Rechte gelten je Wehr; wer Wehren anlegt, steht über allen. Ein
Recht, das in Wehr A vergeben wird und in Wehr B wirkt, wäre genau die
Hintertür, die diese Phase vermeiden soll.
Was dort geht:
- Eine Wehr anlegen — sie bekommt sofort ihre Systemrollen samt Rechten. Ein
Konto bekommt sie nicht: eine Wehr anzulegen und jemanden hineinzusetzen
sind zwei Entscheidungen, und die zweite trifft meist jemand anders.
- Ein Konto anlegen — ohne Wehr. Die kommt über die Zuordnung.
- Zuordnen: als Mitglied mit Personenakte oder als Kreisaufsicht ohne.
**Jede Zuordnung wird in der betroffenen Wehr protokolliert**, nicht beim
Verwalter. Sonst hätte der Betreiber einen Weg, sich still in fremde Daten zu
setzen.
**Keine zusätzlichen Umgebungsvariablen.**
### Was bewusst fehlt
**Wehrübergreifende Auswertungen.** Eine Zahl über mehrere Wehren hinweg
braucht eine abgestimmte Definition — was zählt als Einsatz, was als Übung,
wie wird eine Doppelmitgliedschaft gezählt. Ohne die ist eine solche Zahl
schlimmer als keine, weil sie benutzt wird. Die Aufsicht sieht jede Wehr
einzeln, und das reicht für die Fragen, die tatsächlich gestellt werden.
**Ein Einladungsverfahren.** Passwörter werden vom Verwalter vergeben und
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
npm run test # Vitest npm run test # Vitest
npm run test:e2e # Playwright npm run test:e2e # Playwright (vorher: npm run db:test-reset)
npm run lint # ESLint
npm run lint:fix # ESLint mit automatischen Korrekturen
npm run typecheck # TypeScript ohne Ausgabe npm run typecheck # TypeScript ohne Ausgabe
npm run db:migrate # ausstehende Migrationen anwenden npm run db:migrate # ausstehende Migrationen anwenden
npm run db:seed # Beispielwehr einspielen npm run db:seed # Beispielwehr einspielen
npm run db:test-reset # Testwehr für die E2E-Tests neu aufbauen
npm run db:studio # Prisma Studio npm run db:studio # Prisma Studio
npm run screenshots # Bilder für docs/ANLEITUNG.md erzeugen npm run screenshots # Bilder für docs/ANLEITUNG.md erzeugen
npm run vapid # VAPID-Schlüsselpaar für Web-Push erzeugen
``` ```
## Tests
```bash
npm run test # Vitest — Unit und Datenbankregeln
npm run db:test-reset # einmalig vor dem E2E-Lauf
npm run test:e2e # Playwright
```
### Zwei Wehren in der Datenbank
Die E2E-Tests laufen gegen eine **eigene Wehr** (`ff-teststelle`), nicht gegen
die Beispielwehr aus der Anleitung. Der Grund ist ein handfester: bis Phase 8
schrieben sie in `ff-musterhausen`, und nach fünf Phasen standen dort
120 Atemschutz-Überwachungen statt einer und 115 Geräte statt 45. Zweimal
fielen Tests aus, weil ein gesuchter Eintrag von Seite 1 gerutscht war — und
bei jedem Lauf unterschieden sich alle 72 Bildschirmfotos.
`npm run db:test-reset` löscht die Testwehr und legt sie neu an, mit denselben
Beispieldaten. Die Beispielwehr wird dabei nicht angefasst; ein Sicherungsriegel
im Skript bricht ab, wenn beide Kennungen übereinstimmen.
Anmeldung der Testwehr: `e2e@teststelle.de` / `Feuerwehr2026`, änderbar über
`E2E_ADMIN_EMAIL` und `E2E_ADMIN_PASSWORT`.
## Dienste und Ports (Entwicklung) ## Dienste und Ports (Entwicklung)
Bewusst nicht die Standardports, damit FLORIAN neben anderen lokalen Stacks Bewusst nicht die Standardports, damit FLORIAN neben anderen lokalen Stacks
@@ -814,6 +1643,9 @@ laufen kann. Alle Werte stehen in der `.env`.
| MinIO Weboberfläche | http://localhost:9011 | | MinIO Weboberfläche | http://localhost:9011 |
| Gotenberg | http://localhost:3101 | | Gotenberg | http://localhost:3101 |
| Mailpit (nur Entwicklung) | http://localhost:8125 | | Mailpit (nur Entwicklung) | http://localhost:8125 |
| ntfy (Profil `push`) | http://localhost:8086 |
| Ollama (Profil `ki`) | http://localhost:11435 |
| faster-whisper (Profil `ki`) | http://localhost:8087 |
| Adminer (nur Entwicklung) | http://localhost:8081 | | Adminer (nur Entwicklung) | http://localhost:8081 |
## Produktionsbetrieb ## Produktionsbetrieb
@@ -830,15 +1662,48 @@ 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
Schriften verwendet, die auf dem Gerät vorhanden sind. Schriften verwendet, die auf dem Gerät vorhanden sind.
- Aufbewahrungsfristen konfigurierbar (`AUFBEWAHRUNG_JAHRE`, - Aufbewahrungsfristen konfigurierbar (`AUFBEWAHRUNG_JAHRE`,
`AUDIT_AUFBEWAHRUNG_TAGE`, `PERSON_ANONYMISIERUNG_TAGE`). `AUDIT_AUFBEWAHRUNG_TAGE`, `PERSON_ANONYMISIERUNG_TAGE`).
- Externe Dienste (Mail, Telegram, LLM) sitzen hinter Schnittstellen in - Externe Dienste (Mail, Telegram, ntfy, Web-Push) sitzen hinter
`src/lib/integrations/` und sind einzeln abschaltbar. Ohne Freischaltung Schnittstellen in `src/lib/integrations/` und sind einzeln abschaltbar. Ohne
wird nur ins Log geschrieben. Freischaltung wird nur ins Log geschrieben.
- **Die KI-Funktionen laufen ausschließlich gegen selbst betriebene Modelle.**
Es gibt keinen Rückfall auf einen Anbieter: fehlt der eigene Endpunkt, fehlt
die Funktion. Kein Text verlässt das Gerätehaus.
- Jede KI-Ausgabe ist gekennzeichnet, wird als eigener Datensatz festgehalten
und muss von einem Menschen übernommen werden, bevor sie in einen Nachweis
gelangt. Nachvollziehbar im KI-Protokoll.
- Über Kanäle, die das Netz der Wehr verlassen (Telegram, Web-Push), gehen nur
Kurzfassungen ohne Namen, Gerätebezeichnungen und Einsatzdetails.
- Kein WhatsApp — die Begründung steht oben unter „Warum kein WhatsApp".
## Sicherheitshinweis ## Sicherheitshinweis
+152 -2
View File
@@ -71,10 +71,32 @@ x-app-env: &app-env
TELEGRAM_ENABLED: ${TELEGRAM_ENABLED:-false} TELEGRAM_ENABLED: ${TELEGRAM_ENABLED:-false}
TELEGRAM_BOT_TOKEN: ${TELEGRAM_BOT_TOKEN:-} TELEGRAM_BOT_TOKEN: ${TELEGRAM_BOT_TOKEN:-}
TELEGRAM_CHAT_ID: ${TELEGRAM_CHAT_ID:-} TELEGRAM_CHAT_ID: ${TELEGRAM_CHAT_ID:-}
NTFY_ENABLED: ${NTFY_ENABLED:-false}
NTFY_URL: ${NTFY_URL:-}
NTFY_TOKEN: ${NTFY_TOKEN:-}
NTFY_TIMEOUT: ${NTFY_TIMEOUT:-10}
WEBPUSH_ENABLED: ${WEBPUSH_ENABLED:-false}
WEBPUSH_PUBLIC_KEY: ${WEBPUSH_PUBLIC_KEY:-}
WEBPUSH_PRIVATE_KEY: ${WEBPUSH_PRIVATE_KEY:-}
WEBPUSH_KONTAKT: ${WEBPUSH_KONTAKT:-}
BENACHRICHTIGUNG_LAUF: ${BENACHRICHTIGUNG_LAUF:-0 7 * * *}
TERMIN_ERINNERUNG_TAGE: ${TERMIN_ERINNERUNG_TAGE:-7,1}
BENACHRICHTIGUNG_VERSUCHE: ${BENACHRICHTIGUNG_VERSUCHE:-3}
BENACHRICHTIGUNG_AUFBEWAHRUNG_TAGE: ${BENACHRICHTIGUNG_AUFBEWAHRUNG_TAGE:-90}
LLM_ENABLED: ${LLM_ENABLED:-false} LLM_ENABLED: ${LLM_ENABLED:-false}
LLM_BASE_URL: ${LLM_BASE_URL:-} LLM_BASE_URL: ${LLM_BASE_URL:-}
LLM_API_KEY: ${LLM_API_KEY:-} LLM_API_KEY: ${LLM_API_KEY:-}
LLM_MODELL: ${LLM_MODELL:-} LLM_MODELL: ${LLM_MODELL:-}
LLM_TIMEOUT: ${LLM_TIMEOUT:-120}
LLM_EINBETTUNG_MODELL: ${LLM_EINBETTUNG_MODELL:-nomic-embed-text}
LLM_EINBETTUNG_DIMENSIONEN: ${LLM_EINBETTUNG_DIMENSIONEN:-768}
LLM_VISION_MODELL: ${LLM_VISION_MODELL:-}
WHISPER_ENABLED: ${WHISPER_ENABLED:-false}
WHISPER_URL: ${WHISPER_URL:-}
WHISPER_MODELL: ${WHISPER_MODELL:-base}
WHISPER_SPRACHE: ${WHISPER_SPRACHE:-de}
WHISPER_TIMEOUT: ${WHISPER_TIMEOUT:-300}
DIKTAT_MAX_SEKUNDEN: ${DIKTAT_MAX_SEKUNDEN:-600}
GEOCODING_ENABLED: ${GEOCODING_ENABLED:-false} GEOCODING_ENABLED: ${GEOCODING_ENABLED:-false}
NOMINATIM_URL: ${NOMINATIM_URL:-} NOMINATIM_URL: ${NOMINATIM_URL:-}
GEOCODING_TIMEOUT: ${GEOCODING_TIMEOUT:-5} GEOCODING_TIMEOUT: ${GEOCODING_TIMEOUT:-5}
@@ -132,10 +154,18 @@ services:
logging: *logging logging: *logging
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# PostgreSQL 17 mit PostGIS. Einziger Ort für relationale Daten. # PostgreSQL 17 mit PostGIS und pgvector. Einziger Ort für relationale Daten.
#
# Eigenes Image, weil FLORIAN beides braucht und es kein offizielles gibt,
# das beides mitbringt — siehe docker/postgres/Dockerfile. Beim ersten
# `docker compose up` wird es gebaut; bei einer bestehenden Installation
# genügt `docker compose build postgres` und ein Neustart des Dienstes. Das
# Datenverzeichnis bleibt unberührt, die Hauptversion ändert sich nicht.
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
postgres: postgres:
image: postgis/postgis:17-3.5 build:
context: ./docker/postgres
image: florian-postgres:${IMAGE_TAG:-17-3.5-pgvector}
container_name: florian-postgres container_name: florian-postgres
restart: unless-stopped restart: unless-stopped
environment: environment:
@@ -306,6 +336,122 @@ services:
- intern - intern
logging: *logging logging: *logging
# ---------------------------------------------------------------------------
# Ollama — Sprachmodelle im eigenen Haus (Phase 8).
#
# Nur mit Profil: docker compose --profile ki up -d
#
# Es gibt bewusst KEINEN Rückfall auf einen Anbieter. Läuft dieser Container
# nicht, fehlen die KI-Funktionen — sie werden nicht durch einen fremden
# Dienst ersetzt. Ein Einsatzbericht spricht über Menschen in ihrer
# schlechtesten Stunde; solche Texte gehören nirgendwo anders hin.
#
# Nach dem ersten Start müssen die Modelle geholt werden:
#
# docker compose exec ollama ollama pull qwen2.5:14b # Text
# docker compose exec ollama ollama pull nomic-embed-text # Einbettungen
# docker compose exec ollama ollama pull llama3.2-vision # Belege (optional)
#
# Auf einem Rechner ohne Grafikkarte läuft das Einbettungsmodell noch flott,
# das Textmodell zäh und das Vision-Modell praktisch nicht. Deshalb sind alle
# drei einzeln konfigurierbar: eine Wehr kann das Wiki durchsuchbar machen,
# ohne Berichte schreiben zu lassen.
#
# Für eine Nvidia-Karte zusätzlich das Compose-Fragment aus dem README
# einbinden (deploy.resources.reservations.devices) — es steht nicht fest
# hier drin, weil es ohne Karte den Start verhindert.
# ---------------------------------------------------------------------------
ollama:
image: ollama/ollama:0.5.7
container_name: florian-ollama
restart: unless-stopped
profiles: [ki]
environment:
# Modelle bleiben geladen, statt nach fünf Minuten aus dem Speicher zu
# fallen. Wer zweimal am Tag einen Bericht schreibt, wartet sonst jedes
# Mal auf das Nachladen.
OLLAMA_KEEP_ALIVE: ${OLLAMA_KEEP_ALIVE:-30m}
OLLAMA_HOST: "0.0.0.0:11434"
volumes:
- ollama-data:/root/.ollama
ports:
- "${OLLAMA_PORT:-11435}:11434"
networks:
- intern
logging: *logging
# ---------------------------------------------------------------------------
# faster-whisper — Spracherkennung fürs Diktat (Phase 8).
#
# Nur mit Profil: docker compose --profile ki up -d
#
# OpenAI-kompatible Schnittstelle, aber vollständig lokal. Das Modell wird
# beim ersten Aufruf geholt und dann behalten.
#
# WHISPER_MODELL steuert die Größe: "tiny" läuft auf jedem Bürorechner,
# "base" ist der brauchbare Kompromiss, "small" erkennt Fachbegriffe
# merklich besser und will dafür Zeit oder eine Grafikkarte.
# ---------------------------------------------------------------------------
whisper:
image: fedirz/faster-whisper-server:latest-cpu
container_name: florian-whisper
restart: unless-stopped
profiles: [ki]
environment:
WHISPER__MODEL: ${WHISPER_MODELL:-base}
WHISPER__INFERENCE_DEVICE: ${WHISPER_GERAET:-cpu}
# Nach zehn Minuten ohne Anfrage den Speicher freigeben. Eine Wehr
# diktiert nicht stündlich.
WHISPER__TTL: ${WHISPER_TTL:-600}
ENABLE_UI: "false"
volumes:
- whisper-data:/root/.cache/huggingface
ports:
- "${WHISPER_PORT:-8087}:8000"
networks:
- intern
logging: *logging
# ---------------------------------------------------------------------------
# ntfy — Push aufs Handy, ohne dass etwas das Haus verlässt (Phase 7).
#
# Nur mit Profil: docker compose --profile push up -d
#
# Warum das der interessanteste Push-Weg für eine Wehr ist: kein Konto, keine
# Telefonnummer, kein Telefonbuchabgleich. Man abonniert in der App ein
# Thema — einen frei gewählten Namen — und bekommt Meldungen darauf.
#
# Der Container ist winzig und speichert standardmäßig nichts dauerhaft:
# eine zugestellte Meldung ist erledigt. Wer sie nachlesen können will,
# schaltet den Cache ein (siehe ntfy-Dokumentation) — dann liegen die Texte
# allerdings auch dort und nicht nur in FLORIAN.
#
# ACHTUNG: Ein Thema ist nur so geheim wie sein Name. Wer ihn kennt, liest
# mit. Deshalb schlägt FLORIAN Namen mit Zufallsanteil vor, und deshalb
# steht NTFY_TOKEN bereit, falls der eigene ntfy Zugriffsschutz bekommt.
# ---------------------------------------------------------------------------
ntfy:
image: binwiederhier/ntfy:v2.11.0
container_name: florian-ntfy
restart: unless-stopped
profiles: [push]
command: ["serve"]
environment:
NTFY_BASE_URL: ${NTFY_OEFFENTLICH:-http://localhost:8086}
NTFY_LISTEN_HTTP: ":80"
# Keine Anmeldung nötig, um zu lesen und zu schreiben — geschützt ist
# das Ding durch die Unerratbarkeit der Themennamen und dadurch, dass es
# im eigenen Netz steht. Wer es ins Internet stellt, setzt
# NTFY_AUTH_DEFAULT_ACCESS auf "deny-all" und vergibt Token.
NTFY_AUTH_DEFAULT_ACCESS: ${NTFY_ZUGRIFF:-read-write}
NTFY_BEHIND_PROXY: "true"
ports:
- "${NTFY_PORT:-8086}:80"
networks:
- edge
- intern
logging: *logging
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Die Anwendung selbst (Next.js, Standalone-Build). # Die Anwendung selbst (Next.js, Standalone-Build).
# Führt vor dem Start die ausstehenden Prisma-Migrationen aus. # Führt vor dem Start die ausstehenden Prisma-Migrationen aus.
@@ -382,6 +528,10 @@ volumes:
redis-data: redis-data:
minio-data: minio-data:
traefik-acme: traefik-acme:
# Phase 8: die Modelle. Sie sind mehrere Gigabyte groß und sollen einen
# Neustart des Containers überleben — sonst werden sie jedes Mal neu geholt.
ollama-data:
whisper-data:
networks: networks:
# Nur was von außen erreichbar sein muss, hängt hier drin. # Nur was von außen erreichbar sein muss, hängt hier drin.
+3 -1
View File
@@ -2,7 +2,8 @@
# ============================================================================= # =============================================================================
# Läuft EINMALIG beim allerersten Start des Postgres-Containers (leeres # Läuft EINMALIG beim allerersten Start des Postgres-Containers (leeres
# Datenverzeichnis). Richtet ein: # Datenverzeichnis). Richtet ein:
# - die benötigten Erweiterungen (PostGIS, pgcrypto, citext, unaccent, pg_trgm) # - die benötigten Erweiterungen (PostGIS, pgvector, pgcrypto, citext,
# unaccent, pg_trgm)
# - den Anwendungsbenutzer "florian_app" OHNE BYPASSRLS # - den Anwendungsbenutzer "florian_app" OHNE BYPASSRLS
# - Vorgabeprivilegien, damit künftige Tabellen automatisch nutzbar sind # - Vorgabeprivilegien, damit künftige Tabellen automatisch nutzbar sind
# #
@@ -28,6 +29,7 @@ psql -v ON_ERROR_STOP=1 --username "$POSTGRES_USER" --dbname "$POSTGRES_DB" <<-'
CREATE EXTENSION IF NOT EXISTS citext; -- E-Mail ohne Gross/Klein CREATE EXTENSION IF NOT EXISTS citext; -- E-Mail ohne Gross/Klein
CREATE EXTENSION IF NOT EXISTS unaccent; -- Suche ueber Umlaute hinweg CREATE EXTENSION IF NOT EXISTS unaccent; -- Suche ueber Umlaute hinweg
CREATE EXTENSION IF NOT EXISTS pg_trgm; -- unscharfe Namenssuche CREATE EXTENSION IF NOT EXISTS pg_trgm; -- unscharfe Namenssuche
CREATE EXTENSION IF NOT EXISTS vector; -- semantische Suche im Wiki
EOSQL EOSQL
# Anwendungsrolle anlegen. Bewusst OHNE DO-Block: psql ersetzt seine Variablen # Anwendungsrolle anlegen. Bewusst OHNE DO-Block: psql ersetzt seine Variablen
+33
View File
@@ -0,0 +1,33 @@
# =============================================================================
# PostgreSQL 17 mit PostGIS **und** pgvector.
#
# Warum ein eigenes Image: FLORIAN braucht beides. PostGIS seit Phase 6 für
# Hydranten und Umkreissuche, pgvector seit Phase 8 für die semantische Suche
# im Wiki. Es gibt kein offizielles Image, das beides mitbringt —
# postgis/postgis kennt kein vector, pgvector/pgvector kein PostGIS.
#
# Die Alternative wäre gewesen, die Ähnlichkeitssuche in der Anwendung zu
# rechnen: alle Abschnitte laden, Kosinusabstand in TypeScript. Das geht bei
# zweihundert Wiki-Seiten und wird bei zweitausend zäh — und es verlegt eine
# Aufgabe in die Anwendung, für die die Datenbank einen Index hat.
#
# Das Basisimage ist Debian mit eingerichtetem PGDG-Repository; die Erweiterung
# kommt als fertiges Paket daher, nicht als Übersetzungslauf.
# =============================================================================
FROM postgis/postgis:17-3.5
# Version festhalten: ein stillschweigender Sprung auf eine neue pgvector-
# Fassung würde beim nächsten Neubau des Images passieren, nicht dann, wenn
# jemand darüber entscheidet.
ARG PGVECTOR_PAKET=postgresql-17-pgvector
RUN set -eux; \
apt-get update; \
apt-get install -y --no-install-recommends "${PGVECTOR_PAKET}"; \
rm -rf /var/lib/apt/lists/*
# Die Erweiterung selbst wird nicht hier angelegt, sondern in der Migration
# (CREATE EXTENSION IF NOT EXISTS vector). Grund: eine bestehende Installation
# bekommt zwar das neue Image, aber nicht noch einmal den Init-Lauf — das
# Datenverzeichnis ist ja nicht leer. Über die Migration greift es in beiden
# Fällen.
+997 -13
View File
File diff suppressed because it is too large Load Diff
+85 -17
View File
@@ -1,7 +1,7 @@
# Aufwandsschätzung # Aufwandsschätzung
Schätzung je Phase, daneben der tatsächliche Aufwand aus dem Schätzung je Phase, daneben der tatsächliche Aufwand aus dem
[Devlog](DEVLOG.md). Stand: 3. September 2026. [Devlog](DEVLOG.md). Stand: 4. September 2026.
## Wie hier gerechnet wird ## Wie hier gerechnet wird
@@ -34,13 +34,15 @@ Faktoren, die in die Schätzung eingehen:
| 1 | Stammdaten | 10–14 PT | 2,5–3,5 h | **2:05 h** | | 1 | Stammdaten | 10–14 PT | 2,5–3,5 h | **2:05 h** |
| 2 | Wartung und Prüfung | 18–24 PT | 4,5–6 h | **2:25 h** | | 2 | Wartung und Prüfung | 18–24 PT | 4,5–6 h | **2:25 h** |
| 3 | Einsätze und Übungen | 12–16 PT | 3–4 h | **2:10 h** | | 3 | Einsätze und Übungen | 12–16 PT | 3–4 h | **2:10 h** |
| 4 | Kiosk-PWA mit Offline | 16–22 PT | 4–5,5 h | offen | | 4 | Kiosk-PWA mit Offline | 16–22 PT | 4–5,5 h | **3:00 h** |
| 5 | Atemschutz (FwDV 7) | 14–18 PT | 3,5–4,5 h | offen | | 5 | Atemschutz (FwDV 7) | 14–18 PT | 3,5–4,5 h | **2:40 h** |
| 6 | Kartenmodule | 14–18 PT | 3,5–4,5 h | offen | | 6 | Kartenmodule | 14–18 PT | 3,5–4,5 h | **2:50 h** |
| 7 | Benachrichtigungen und Termine | 10–13 PT | 2,5–3,5 h | offen | | 7 | Benachrichtigungen und Termine | 10–13 PT | 2,5–3,5 h | **3:10 h** |
| 8 | Lokale KI | 9–12 PT | 2,5–3 h | offen | | 8 | Lokale KI | 9–12 PT | 2,5–3 h | **3:40 h** |
| 9 | Kassenverwaltung | 8–11 PT | 2–3 h | offen | | 9 | Kassenverwaltung | 8–11 PT | 2–3 h | **4:10 h** |
| | **Summe** | **123–164 PT** | **28–37 h** | | | 10 | Mandantenfähigkeit | 4–6 PT | 1–1,5 h | **4:20 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
@@ -339,7 +341,7 @@ das ehrlich angezeigt wird. In der Aufstellung steht davon keine Zeile.
## Phase 7 — Benachrichtigungen und Termine ## Phase 7 — Benachrichtigungen und Termine
**Vergleichsaufwand: 10–13 PT** **Vergleichsaufwand: 10–13 PT** · **Tatsächlich: 3:10 h Sitzungszeit**
| Baustein | PT | | Baustein | PT |
|---|---:| |---|---:|
@@ -347,15 +349,28 @@ das ehrlich angezeigt wird. In der Aufstellung steht davon keine Zeile.
| Vorlagen mit Platzhaltern, je Ereignis und Rolle | 2,0 | | Vorlagen mit Platzhaltern, je Ereignis und Rolle | 2,0 |
| Abonnements je Person | 1,0 | | Abonnements je Person | 1,0 |
| Kalender mit Serienterminen und Selbstanmeldung | 3,0 | | Kalender mit Serienterminen und Selbstanmeldung | 3,0 |
| CalDAV-/ICS-Feed je Person mit Token | 1,5 | | ICS-Feed je Person mit Token | 1,5 |
| Digitaler Dienstausweis mit signiertem QR und Prüfseite | 2,0 | | Digitaler Dienstausweis mit signiertem QR und Prüfseite | 2,0 |
Das Grundgerüst der Kanalabstraktion steht bereits aus Phase 0 Das Grundgerüst der Kanalabstraktion stand bereits aus Phase 0
(`lib/integrations/`), das spart hier etwa einen Personentag. (`lib/integrations/`), das hat etwa einen Personentag gespart.
Die einzige Phase bisher, die über der geschätzten Sitzungszeit liegt — und
zwar an einer Stelle, die in der Aufstellung nicht vorkommt. „Kalender mit
Serienterminen" klingt nach einer Datumsrechnung; teuer war die Entscheidung
**gegen** eine Regel und **für** ausgeschriebene Termine, und dann die
Kalenderarithmetik dahinter: `setMonth(+1)` auf dem 31. Januar ergibt den
3. März, der 29. Februar existiert in vier von fünf Jahren nicht, und ein
Übungsabend muss über die ganze Serie auf demselben Wochentag bleiben. Das sind
drei Tests, die man erst schreibt, wenn man den Fall kennt.
Die 1,5 PT für den Feed waren dagegen zu hoch angesetzt, weil sie CalDAV
unterstellen. ICS ist ein Textformat; der ganze Erzeuger sind hundertsechzig
Zeilen samt Maskierung und Zeilenumbruch nach 75 Oktetten.
## Phase 8 — Lokale KI ## Phase 8 — Lokale KI
**Vergleichsaufwand: 9–12 PT** **Vergleichsaufwand: 9–12 PT** · **Tatsächlich: 3:40 h Sitzungszeit**
| Baustein | PT | | Baustein | PT |
|---|---:| |---|---:|
@@ -365,10 +380,28 @@ Das Grundgerüst der Kanalabstraktion steht bereits aus Phase 0
| Wiki mit pgvector, semantische Suche, RAG mit Quellenangabe | 3,5 | | Wiki mit pgvector, semantische Suche, RAG mit Quellenangabe | 3,5 |
| Belegerfassung über ein Vision-Modell, immer bestätigen | 2,0 | | Belegerfassung über ein Vision-Modell, immer bestätigen | 2,0 |
Der Client aus Phase 0 (`lib/integrations/llm.ts`) ist bereits vorhanden. Die Der Client aus Phase 0 (`lib/integrations/llm.ts`) war bereits vorhanden — er
Kennzeichnungspflicht — jede KI-Ausgabe ist als solche erkennbar und muss konnte allerdings nur Text und musste um Einbettungen und Bilder erweitert
bestätigt werden, bevor sie in ein Dokument mit Nachweischarakter gelangt — werden.
kostet wenig Zeit, ist aber nicht verhandelbar.
Die Kennzeichnungspflicht war mit „kostet wenig Zeit" zu günstig eingeschätzt.
Sie kostet wenig, wenn man sie als Abzeichen an einer Ansicht versteht. Als
Zusicherung — eigener Datensatz je Ausgabe, unveränderlich, genau eine
Entscheidung, Trigger und REVOKE dahinter, zwei Protokollansichten — ist sie
ein guter halber Personentag, der in der Aufstellung gar nicht vorkommt.
Zwei weitere Posten fehlen in der Schätzung:
**Das eigene Postgres-Image.** pgvector und PostGIS gibt es nicht zusammen; es
gibt kein offizielles Image mit beidem. Ein Dockerfile, ein Neubau, ein Neustart
des Dienstes — eine halbe Stunde, aber sie stand nirgends.
**Der Betrieb ohne Modell.** Der größte Einzelposten dieser Phase, und in der
Aufstellung steht davon keine Zeile. Eine Wehr setzt FLORIAN auf, lange bevor
sie über eine Grafikkarte nachdenkt — also muss jede Ansicht ohne Modell
vollständig benutzbar bleiben und ehrlich sagen, was fehlt. Das sind vier
verschiedene Hinweise an vier Stellen, eine zweite Suche, die immer
funktioniert, und vierzehn E2E-Tests, die genau diesen Zustand absichern.
## Phase 9 — Kassenverwaltung ## Phase 9 — Kassenverwaltung
@@ -391,6 +424,41 @@ Die Unveränderlichkeit festgeschriebener Buchungen kostet hier fast nichts —
das Muster steht seit Phase 0 mit dem Audit-Log-Trigger und lässt sich das Muster steht seit Phase 0 mit dem Audit-Log-Trigger und lässt sich
übernehmen. übernehmen.
**Tatsächlich: 4:10 h** — die längste Phase bisher. Beide Schätzungen oben
haben sich bestätigt und zugleich verschoben: MT940 kostete weniger als
gedacht, weil beide Formate ohne Fremdbibliothek auskommen. Dafür war die
Unveränderlichkeit nicht das Übernehmen eines Musters, sondern drei
Trigger mit drei genau begründeten Ausnahmen — und die Ausnahmen sind der
Teil, der Zeit gekostet hat. Zwei davon sind erst im Test aufgefallen:
der Prüfbericht, der nach dem Festschreiben entsteht, und die
DSGVO-Löschung einer Wehr, die an ihren eigenen festgeschriebenen Buchungen
scheiterte.
## Phase 10 — Mandantenfähigkeit
**Vergleichsaufwand: 4–6 PT** — aus der Kandidatenliste vorgezogen, am
05.09.2026 umgesetzt.
| Baustein | PT |
|---|---:|
| `Benutzer` und `Mitgliedschaft`, datenerhaltende Umstellung | 1,5 |
| Anmeldung: Konto statt Akte, Wehrwechsel im Sitzungstoken | 1,5 |
| Kreisebene mit reinen Leserechten | 1,0 |
| Wehrwähler und Verwaltung der Installation | 1,5 |
Die Schätzung ging von „technisch bereits möglich, Oberfläche fehlt" aus —
und das war zur Hälfte falsch. Datenmodell und RLS trugen tatsächlich mehrere
Wehren, aber die Anmeldung nicht: Passwort und OIDC-Subject hingen an der
Personenakte, und die gehört genau einer Wehr. Das war kein Oberflächenthema,
sondern ein Umbau am Fundament.
**Tatsächlich: 4:20 h.** Getragen hat eine Entscheidung ganz am Anfang: die
Sitzung behält ihre Form und entsteht nur anders. Ohne sie wären 61 Dateien
mit 414 Fundstellen zu ändern gewesen; so waren es acht. Die Zeit ging
stattdessen in die Absicherung — zwei Trigger, vier CHECKs und elf
Datenbanktests — und das ist die richtige Verteilung für eine Phase, deren
einziger Zweck es ist, dass eine Wehr die andere nicht sieht.
--- ---
## Was hier nicht drin ist ## Was hier nicht drin ist
+691 -1
View File
@@ -18,7 +18,697 @@ Die aufgewendete Zeit wandert nach jedem Abschnitt in die Spalte
| Phase 4 — Kiosk-PWA im Gerätehaus | 3:00 h | 1 | 03.09.2026 | | Phase 4 — Kiosk-PWA im Gerätehaus | 3:00 h | 1 | 03.09.2026 |
| Phase 5 — Atemschutz nach FwDV 7 | 2:40 h | 1 | 03.09.2026 | | Phase 5 — Atemschutz nach FwDV 7 | 2:40 h | 1 | 03.09.2026 |
| Phase 6 — Kartenmodule | 2:50 h | 1 | 04.09.2026 | | Phase 6 — Kartenmodule | 2:50 h | 1 | 04.09.2026 |
| **Summe** | **16:10 h** | **8** | | | Phase 7 — Benachrichtigungen und Termine | 3:10 h | 1 | 04.09.2026 |
| Phase 8 — Lokale KI | 3:40 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 10 — Mandantenfähigkeit | 4:20 h | 1 | 05.09.2026 |
| 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
---
## 2026-09-05 · Phase 10 — Mandantenfähigkeit
**08:10 – 12:30 · 4:20 h · Version 0.11.0**
### Was entstanden ist
Die einzige Phase bisher, die die Anmeldung selbst anfasst.
| Bereich | Umfang |
|---|---|
| Datenmodell | `Benutzer`, `Mitgliedschaft`, `MitgliedschaftArt`; `Person` verliert die Anmeldefelder |
| Absicherung | RLS für `mitgliedschaft`, zwei Trigger, vier CHECKs, ein REVOKE |
| Anmeldung | `lib/auth.ts` neu: Konto statt Akte, Mitgliedschaftswahl, Wehrwechsel im Token |
| Rechte | Rolle `AUFSICHT`, gefiltert statt aufgezählt; `istVerwalter` als Kontomerkmal |
| Oberfläche | Wehrwähler, Aufsichtshinweis, `/wehren` zur Verwaltung |
| Tests | 19 neue Unit-Tests (11 gegen die Datenbank), 13 E2E-Tests |
### Entscheidungen
**Die Sitzung behält ihre Form.** `sitzung.organisationId` und
`sitzung.personId` werden an 329 bzw. 85 Stellen in 61 Dateien gelesen. Hätte
sich ihre Bedeutung geändert, wäre das ein Umbau durch das halbe Projekt
geworden. Stattdessen entsteht die Sitzung nur *anders* — aus der aktiven
Mitgliedschaft statt aus der Person. Keine dieser 61 Dateien musste angefasst
werden.
**`personId` ist nullbar geworden.** Das war die eine Ausnahme davon, und zwar
absichtlich: eine Aufsichtssitzung hat keine Akte. Der Typechecker hat daraus
eine Liste gemacht — genau fünf Stellen verlangen eine Person, und alle fünf
sind Dinge, an denen die Kreisaufsicht nichts verloren hat (Anmeldemarke,
Dienstausweis, Prüfungswizard, Dateiupload, Atemschutz). Ein `?? ""` hätte
dieselben fünf Stellen still passieren lassen.
**`Benutzer` ohne `organisationId`.** Die zweite Ausnahme von der
Architekturregel nach `Berechtigung` — und dieselbe Begründung: die Tabelle
steht über allen Mandanten, weil sie der Angelpunkt ist, an dem ein Mensch zu
mehreren Wehren gehört. Geschützt über Rechte statt RLS.
**`AUFSICHT` steht nicht im Prisma-Enum.** Alle anderen Rollen werden einem
Menschen in einer Wehr zugewiesen. Diese ergibt sich aus der Mitgliedschaftsart.
Sie vergebbar zu machen hiesse, dass jemand innerhalb einer Wehr Rechte an
einer anderen verteilen könnte.
**Ihre Rechte werden gefiltert, nicht aufgezählt.** Alles, was `.lesen` heißt,
minus vier ausdrückliche Ausnahmen. Eine Liste von Hand wäre bei Phase 11
veraltet, und zwar unbemerkt — ein neues Modul brächte Rechte mit, die niemand
nachträgt. So bekommt die Aufsicht neue Leserechte automatisch und neue
Schreibrechte nie. Der Test dazu ist entsprechend formuliert: nicht „diese
dreizehn Rechte", sondern „kein einziges, das nicht lesen heißt".
**Die Kasse ist ausgenommen**, obwohl `kasse.lesen` auf `.lesen` endet. Sie
gehört dem Verein oder der Kommune, nicht der Kreisaufsicht. Sie wäre nur
deshalb dabei gewesen, weil das Recht zufällig richtig heißt — und genau
dagegen ist die Ausnahmeliste da.
**Die Migration ist datenerhaltend, mit Abbruchprüfung.** Konten und
Zuordnungen entstehen, *bevor* die alten Spalten fallen; am Ende prüft ein
`DO`-Block, dass keine Person mit Anmeldedaten ohne Konto dasteht, und bricht
sonst ab. Eine Migration, die die Anmeldung anfasst und auf halbem Weg
liegenbleibt, sperrt alle aus.
### Wo es gehakt hat
| Problem | Ursache | Lösung |
|---|---|---|
| Nach dem Wehrwechsel landete alles auf der Anmeldeseite | `aktuelleSitzung()` verlangte eine Personenkennung — die Aufsicht hat keine und galt damit als nicht angemeldet | Eine Sitzung ist gültig, wenn Wehr und Konto feststehen; die Akte darf fehlen |
| Der Wechsel schien nicht zu greifen | Er griff: das Token trug die neue Wehr. Es war der dritte hängengebliebene Entwicklungsserver dieser Woche — `npm run build` neben laufendem `next dev` macht `.next` unbrauchbar, und der Server antwortet danach nur noch mit 500 | Server beenden, `.next` löschen. Diesmal notiert, damit es nicht ein viertes Mal passiert |
| Der Seed brach ab | Er legt die Rollen aus `ROLLEN` an, und dort steht jetzt `AUFSICHT` — die es im Prisma-Enum bewusst nicht gibt | Im Seed gefiltert, mit Begründung |
| `unstable_update` nahm den Wechselwunsch nicht an | Es akzeptiert nur `Partial<Session>` | Der Wunsch reist als `user.mitgliedschaftId` und wird serverseitig gegen die Mitgliedschaften geprüft |
| Die Nachbarwehr war fest verdrahtet | `ff-nachbarshausen` für jeden Mandanten — genau die Vermischung von Test- und Beispieldaten, die Phase 8 beseitigt hat | Aus dem Mandanten abgeleitet: `<slug>-nachbar` |
| Ein alter Datenbanktest wurde unzuverlässig | `ki-datenbank.test.ts` legte Wehr und Person idempotent an, die Wiki-Seite aber nicht. Die neue Testdatei verschob die Reihenfolge, und ein Rest aus einem abgebrochenen Lauf ließ den ganzen Datei-Lauf scheitern | Auch dort ein `upsert` |
| Nach `db:test-reset` zeigte der Wehrwähler nur noch die betreute Wehr | Der Reset löschte die Testwehr, aber nicht ihre Nachbarwehr. Der Seed sah die Nachbarwehr, hielt alles für erledigt und legte die Mitgliedschaft in der neuen Testwehr nie wieder an | Der Reset nimmt Nachbarwehr und Kreiskonto mit; der Seed prüft beide Teile getrennt statt mit einem gemeinsamen Ausstieg. Gefunden hat es ein E2E-Test |
| Im vollen Lauf fielen fremde Testdateien mit „Anmeldung fehlgeschlagen“ aus | Der Fehlversuch-Test meldete sich mit der Adresse des Administrators an. Fünf Fehlversuche sperren das Konto fünf Minuten — bei zwei Projekten und Wiederholungen kam das zusammen, und alle anderen Dateien brauchen dasselbe Konto | Der Test nimmt jetzt eine Adresse, die es nicht gibt. Die Meldung muss ohnehin dieselbe sein, damit sie nicht verrät, ob es die Adresse gibt |
### Abweichung von der Vorgabe
**Zwei Architekturregeln bekommen eine dokumentierte Ausnahme.**
„Jede Tabelle hat `organisationId`" gilt für `Benutzer` nicht — sie kann es
nicht, ohne den Zweck der Tabelle aufzuheben. Die Ausnahme steht im Schema, in
der Migration und in der Roadmap, und die Tabelle ist dafür über Rechte
geschützt statt über RLS.
„Berechtigungen zentral in `lib/authz.ts`" gilt für den Verwalterzugang nicht:
er hängt an `Benutzer.istVerwalter` und nicht an einem Recht. Rechte gelten je
Wehr — ein Recht, das in Wehr A vergeben wird und in Wehr B wirkt, wäre die
Hintertür, die diese Phase gerade schließen soll.
### Nachweise
- `npm run typecheck` — ohne Ausgabe
- `npm run lint` — ohne Befund
- `npm run test` — 581 Tests grün, davon 11 neu gegen eine echte Datenbank
- `npm run test:e2e` — grün über Desktop und Tablet, davon 26 neu
- `npm run db:seed` und `npm run db:test-reset` — beide Mandanten samt
Nachbarwehr und Kreisebene
- Anmeldung nach der Umstellung von Hand geprüft: Konto gefunden, Passwort
richtig, Sitzung trägt Konto, Mitgliedschaft und Verwaltermerkmal
---
## 2026-09-04 · Phase 9 — Kassenverwaltung
**12:40 – 16:50 · 4:10 h · Version 0.10.0**
### Was entstanden ist
Neun Modelle, drei Trigger, zwölf CHECKs, sechs Router, fünf Ansichten und
ein Kassenjahr im Seed.
Der Reihe nach, wie es CLAUDE.md vorgibt: Schema, Migration, Serverlogik, UI,
Tests.
| Bereich | Umfang |
|---|---|
| Datenmodell | `Kassenkonto`, `Buchungskategorie`, `Buchung`, `Beitragssatz`, `Beitrag`, `Rechnung`, `Rechnungsposition`, `Kassenabschluss`, `Abschlussposten` |
| Absicherung | RLS für alle neun Tabellen, drei Trigger, zwölf CHECKs, ein REVOKE |
| Bibliothek | `lib/kasse/betrag.ts`, `bankimport.ts`, `vorschlag.ts`, `texte.ts`, `dokumente.ts` |
| Router | Konten, Kategorien, Buchungen, Beiträge, Rechnungen, Abschluss |
| Oberfläche | Übersicht, Kassenbuch, Beiträge, Rechnungen, Abschluss mit Auswertung |
| Hintergrund | Rechnungs-PDF, Kassenprüfbericht, Meldung überfälliger Posten |
| Tests | 67 Unit-Tests (13 gegen eine echte Datenbank), 13 E2E-Tests |
### Entscheidungen
**Die Sperren sitzen in der Datenbank, nicht im Router.** Die ganze Phase
hängt an einem Satz: was geprüft ist, ändert sich nicht mehr. Ein
Zustandscheck in `buchung.aendern` erfüllt das nicht — er gilt für genau
diesen einen Weg. Ein zweiter Router, ein Skript, ein Import, und der geprüfte
Bestand ist ein anderer, ohne dass irgendwo etwas auffällt. Also drei Trigger:
festgeschriebene Buchung unveränderbar, geprüfter Zeitraum zu, festgeschriebener
Abschluss fest.
**Jeder Trigger hat genau eine Ausnahme, und jede hat einen Grund.** Die
Buchung lässt den Verweis auf ihre Gegenbuchung eintragen — eine Stornierung
entsteht später, sie einzutragen ist keine Änderung des Vorgangs, sondern
seine Fortschreibung. Der Abschluss lässt den Prüfbericht nachtragen, denn das
PDF entsteht im Hintergrund und damit nach dem Festschreiben. Ohne diese
Ausnahme gäbe es nie einen Bericht.
**Der Bestand wird gerechnet, nie gespeichert.** Ein gespeicherter Bestand
neben einer nachgetragenen Buchung wäre falsch, ohne falsch auszusehen — und
genau danach sucht ein Kassenprüfer. Bei den Größenordnungen einer
Feuerwehrkasse kostet das Rechnen nichts.
**Der Kategorievorschlag geht dem eigenen Bestand vor der Stichwortliste
nach.** Wenn eine Wehr Diesel seit Jahren unter „Fahrzeuge" bucht statt unter
„Kraftstoff", ist das ihre Entscheidung, und sie soll dabei bleiben dürfen.
Die Stichwortliste greift erst, wenn es noch keinen Bestand gibt. Unter der
Hälfte gemeinsamer Wörter schweigt der Vorschlag: ein falscher wird
übernommen, ohne gelesen zu werden.
**Der Bankimport erzeugt Entwürfe, keine Buchungen.** Und jeder Umsatz trägt
eine Kennung, damit ein zweimal eingelesener Auszug als solcher erkannt wird.
Ohne das wäre es ein doppelt gebuchter Monat, und der fiele erst beim
Kassenabschluss auf — also drei Monate später.
**Kein XML-Parser für CAMT.** Gebraucht werden sechs Felder aus einer flachen
Struktur, die Datei kommt von der eigenen Bank. Der Parser ist kürzer als die
Einbindung, und in einer Anwendung ohne Netzzugang ist jede Abhängigkeit
weniger eine Sorge weniger. Namensräume werden ignoriert — genau das, was ein
Parser hier auch täte.
**In der Prüfmaske stehen die gezählten Felder leer.** Der Buchbestand ist zu
sehen, weil der Prüfer ihn für die Differenz braucht. Vorbelegt wird nichts:
wer erst das Buch sieht und dann zählt, zählt, bis es passt.
### Wo es gehakt hat
| Problem | Ursache | Lösung |
|---|---|---|
| Der Abschluss ließ sich nicht mit seinem Bericht versehen | Der Trigger sperrte auch `berichtDateiId` — das PDF entsteht aber erst nach dem Festschreiben | Ausnahme für genau dieses Feld, wie beim Stornierungsverweis der Buchung |
| Die Rechnungsnummer war ein Wettlauf | Höchste lesen, dann schreiben: zwei gleichzeitige Rechnungen lesen dieselbe Nummer | Zusammenstoß erkennen und die nächste nehmen; gefunden hat es der parallele E2E-Lauf über zwei Projekte |
| Eine Organisation ließ sich nicht mehr löschen | Der DELETE-Trigger der Buchung kannte den Aufbewahrungsschalter nicht, den Audit-Log und Prüfprotokoll haben | `app.audit_bereinigung` auch dort — ohne ihn wäre die DSGVO-Löschung nicht durchführbar |
| Der Datenbanktest ließ sich nur einmal ausführen | Dieselbe Ursache: das Aufräumen scheiterte an den festgeschriebenen Buchungen, und der Rest lief im nächsten Lauf gegen einen geprüften Zeitraum | siehe oben |
| E2E: „Barkasse" fand sieben Elemente | Das Wort steht auch in Verwendungszwecken | über die Kartenüberschrift angesprochen |
| E2E: die erste Maifest-Zeile war die falsche | Absteigend sortiert steht das laufende Jahr oben, und das ist nicht festgeschrieben | eine Buchung genommen, die es nur im Vorjahr gibt — und die Gegenprobe dazu |
| Prisma wollte zum zehnten Mal die Trigramm-Indizes aus Phase 1 löschen | Sie stehen in einer Roh-SQL-Migration, nicht im Schema | 30 DropIndex-Blöcke entfernt, Kommentar in die Migration |
| Der volle E2E-Lauf meldete quer durch alle Module Fehler | `npm run build` während eines laufenden `next dev`: der Produktionslauf überschreibt `.next`, und der Entwicklungsserver antwortet danach nur noch mit 500 | Entwicklungsserver beenden, `.next` löschen, neu laufen lassen — 239 grün |
| Ein E2E-Test aus Phase 0 wurde rot | Er sicherte zu, dass die Kasse **kein** Verweis ist — die Zusicherung ist mit dieser Phase abgelaufen | umgeschrieben: die Kasse ist jetzt ein Verweis. Ein künftiges Modul zum Ausgrauen gibt es nicht mehr |
### Abweichung von der Vorgabe
Keine. `Phase-9.md` ist vollständig umgesetzt, einschließlich CAMT.053, MT940,
Nummernkreis, Kassenprüfbericht und Export.
Der Beleg-Anhang an einer Buchung nutzt den `Belegentwurf` aus Phase 8 mit —
er hat dafür einen `buchungId` bekommen. Fotografieren und Erfassen war
bereits da; neu ist nur, dass daraus eine Buchung wird.
### Nachweise
- `npm run typecheck` — ohne Ausgabe
- `npm run lint` — ohne Befund
- `npm run test` — 562 Tests grün, davon 13 neu gegen eine echte Datenbank
- `npm run test:e2e` — 239 Tests grün über Desktop und Tablet, davon 26 neue
- `npm run build` — fünf neue Routen unter `/kasse`
- `npm run db:seed` — Kassenjahr angelegt, Abschluss festgeschrieben
- Rechnungs-PDF (41 kB) und Kassenprüfbericht (45 kB) einmal von Hand über
Gotenberg erzeugt und in MinIO abgelegt — die beiden Vorlagen haben sonst
keinen automatischen Nachweis
---
## 2026-09-04 · Aufräumarbeiten — Issues #10 bis #12
**11:15 – 12:35 · 1:20 h**
### Was entstanden ist
Drei Punkte, die seit mehreren Phasen im Abschlussbericht standen und in jeder
Phase Zeit gekostet haben. Diesmal als Issues angelegt und abgearbeitet.
| Issue | Kern |
|---|---|
| #11 | ESLint hatte keine Konfiguration — `npm run lint` prüfte nichts |
| #12 | `<div>` in einem `<p>` auf der Einsatzseite, seit Phase 3 |
| #10 | Die E2E-Tests schrieben in die Beispielwehr |
### Entscheidungen
**ESLint zuerst.** Er findet die anderen beiden — und tatsächlich hat er
gleich zwei Fehler gemeldet, die niemand gesucht hatte: eine Fehlerfläche auf
der Karte, die nie etwas anzeigen konnte, und zwei `useMemo` in der Lagekarte,
die bei jedem Rendern neu rechneten.
**Keine Formatierungsregeln.** Die macht Prettier. Zwei Werkzeuge, die sich
über Anführungszeichen streiten, lenken von den Regeln ab, auf die es ankommt.
**Auch keine typgestützten Regeln.** `no-floating-promises` wäre für diesen
Stack die wertvollste Regel überhaupt — sie braucht aber den Compiler bei
jedem Lauf, und dann dauert der Lauf Minuten statt Sekunden. Bei einem
Werkzeug, das vor jedem Commit laufen soll, ist das der falsche Tausch.
**Das Badge wurde ein `<span>`, nicht der Absatz ein `<div>`.** Die Meldung
kam von einer Stelle, aber die Ursache saß im Bauteil: ein Abzeichen ist
Fließtext-Inhalt und gehört in ein Inline-Element. So ist auch jede künftige
Verwendung in einem Absatz richtig, und die Darstellung ändert sich nicht —
`inline-flex` verhält sich in beiden gleich.
**Eine zweite Wehr statt Aufräumen nach dem Lauf.** Ein abgebrochener Lauf
lässt beim Aufräumen einen Bestand zurück, den niemand erwartet. Ein
vollständiger Neuaufbau ist der einzige Zustand, den man kennt — und der Seed
kann ohnehin schon alles, er brauchte nur eine Kennung aus der Umgebung.
**Ein Riegel gegen den eigenen Fehler.** `npm run db:test-reset` bricht ab,
wenn die Testkennung mit der der Beispielwehr übereinstimmt. Ein Skript, das
eine ganze Wehr löscht, braucht eine Stelle, an der es sich weigert.
### Wo es gehakt hat
| Problem | Ursache | Lösung |
|---|---|---|
| Zwei Tests fielen nach der Umstellung durch — und das war richtig so | Sie hatten die Verschmutzung gebraucht. `getByText("B 3 Wohnhausbrand", { exact: true })` traf bisher einen Einsatz aus einem früheren Testlauf, weil der kein Objekt hatte; der Einsatz aus dem Seed hat eines und steht in derselben Zelle | Anker am Zellenanfang statt Prüfung auf Gleichheit |
| Der Kiosk-Test schrieb weiter in die Beispielwehr | `const WEHR = "ff-musterhausen"` stand fest im Test. Das Terminal merkt sich die Wehr und schreibt Mängel und Fahrten genau dorthin | Kennung aus `wehr.ts`, wie überall sonst |
| Ein doppelt deklarierter Bezeichner rutschte durch den Typecheck | `tests/e2e` war in der `tsconfig.json` ausgeschlossen | Ausnahme entfernt. Sie hat nichts gebracht: die Dateien übersetzen sauber |
| Die Anmeldung im Bildschirmfoto-Skript lief ins Leere | Bekannt aus Phase 7 — bei kaltem `.next` ist die Seite noch nicht hydriert | War schon behoben; hier nur wieder aufgetreten und bestätigt |
### Abweichung von der Vorgabe
**Issues angelegt**, obwohl CLAUDE.md das ausdrücklich verbietet („Niemals ein
Issue anlegen"). Auf ausdrückliche Anweisung im Chat — die Regel stammt vom
Auftraggeber, und er hat sie für diesen Vorgang aufgehoben.
### Nachweise
- `npx tsc --noEmit` — ohne Ausgabe, jetzt **einschließlich** `tests/e2e`
- `npm run lint` — ohne Beanstandung
- `npm run test` — 495 Tests grün
- `npx playwright test` — grün gegen die Testwehr
- Die Beispielwehr steht danach unverändert auf dem Seed-Stand: 49 Geräte,
8 Einsätze, 1 Atemschutzüberwachung, 19 Termine, 4 Wiki-Seiten
---
## 2026-09-04 · Phase 8 — Lokale KI
**07:30 – 11:10 · 3:40 h · Version 0.9.0**
### Was entstanden ist
Ein Wiki, das man befragen kann, ein Diktat, ein Berichtsassistent, eine
Belegerfassung — und zwei Protokolle, die belegen, was davon eine Maschine
getan hat.
| Bereich | Dateien |
|---|---|
| Rechnen | `lib/ki/zerlegen.ts`, `beleg-lesen.ts`, `anweisungen.ts`, `wiki.ts`, `vorgang.ts` |
| Kanäle | `lib/integrations/llm.ts` (um Einbettungen und Bilder erweitert), `whisper.ts` |
| Server | `server/routers/ki/wiki.ts`, `assistent.ts` |
| Oberfläche | `app/(app)/wiki/`, `belege/`, `ki-protokoll/`, `protokoll/`; `components/ki/` |
| Hintergrund | `worker/ki.ts`, zwei neue Auftragsarten |
| Betrieb | `docker/postgres/Dockerfile` (PostGIS **und** pgvector), Ollama und faster-whisper im Profil `ki` |
| Tests | 73 Unit, 14 E2E je Browser |
### Entscheidungen
**Nichts Maschinelles gelangt ungeprüft in einen Nachweis.** Das ist die Regel
aus Phase-8.md, und sie bestimmt den ganzen Aufbau: jede KI-Ausgabe ist zuerst
ein eigener Datensatz und nicht ein Feld im Zieldokument. Es gibt keine
Funktion, die eine Ausgabe erzeugt und gleich übernimmt — wer das umgehen will,
muss Code schreiben, und das fällt in einer Durchsicht auf.
Die Datenbank hält gegen: Eingabe, Ausgabe, Modell und Art sind nach dem
Anlegen unveränderlich, gelöscht wird nicht, und über einen Vorschlag wird
genau einmal entschieden. Eine Absicht im Code ist eine Absicht; ein Trigger
ist eine Zusicherung.
**Kein Rückfall auf einen Anbieter.** Fehlt der eigene Endpunkt, fehlt die
Funktion. Ein Einsatzbericht spricht über Menschen in ihrer schlechtesten
Stunde — solche Texte gehören nirgendwo anders hin als ins Gerätehaus. Und ein
Rückfall, der „nur im Notfall" greift, ist genau der Fall, in dem niemand
hinsieht.
**Ohne Modell bleibt alles andere benutzbar.** Das war der größte Einzelposten
der Phase und steht in keiner Aufwandsschätzung. Vier Hinweise an vier Stellen,
eine zweite Suche, die immer funktioniert, und vierzehn E2E-Tests, die genau
diesen Zustand absichern — der Testlauf hat keinen Ollama daneben, und das ist
Absicht.
**Drei Modelle statt einem.** Text, Einbettungen und Bilder sind einzeln
konfigurierbar, weil ein Rechner ohne Grafikkarte das Einbettungsmodell noch
flott schafft, das Textmodell zäh und das Vision-Modell praktisch nicht. Eine
Wehr kann so ihr Wiki durchsuchbar machen, ohne Berichte schreiben zu lassen.
**Zerlegt wird an den Überschriften, nicht nach Zeichenzahl.** Weil die
Fundstelle später unter der Antwort steht, und eine Fundstelle soll ein Kapitel
sein. Dazu kommt die Überschriftenkette in den Einbettungstext: ein Abschnitt
über „Rückzugsdruck" ohne das Wort „Atemschutz" darüber findet sich sonst
nicht, wenn jemand nach Atemschutz fragt.
**Markdown wird ohne HTML gerendert.** Der Text kommt von Anwendern. Jeder Weg
über `dangerouslySetInnerHTML` wäre ein Weg für ein `<script>` in einer
Dienstvorschrift; der Renderer baut React-Elemente, und ein spitzes
Klammernpaar bleibt ein spitzes Klammernpaar.
**HNSW statt IVFFlat.** IVFFlat muss auf einem gefüllten Bestand trainiert
werden und liefert auf einer frisch angelegten Tabelle schlechte Treffer. Ein
Wiki wächst Seite für Seite — es gibt nie den Moment, an dem man den Index
einmal aufbaut.
**Ein eigenes Postgres-Image.** pgvector und PostGIS gibt es nicht zusammen.
Die Alternative wäre gewesen, den Kosinusabstand in TypeScript zu rechnen: geht
bei zweihundert Wiki-Seiten, wird bei zweitausend zäh, und verlegt eine Aufgabe
in die Anwendung, für die die Datenbank einen Index hat.
**Die Audit-Log-Ansicht kam mit dazu.** Sie stand seit Phase 0 als ausgegrauter
Eintrag in der Navigation, markiert mit „ab Phase 8". Mit dem KI-Protokoll
daneben wäre es schief geworden: die Wehr könnte nachlesen, was die Maschine
getan hat, aber nicht, was Menschen getan haben. Der Router dafür existierte
seit Phase 0 — es fehlte nur die Seite.
### Wo es gehakt hat
| Problem | Ursache | Lösung |
|---|---|---|
| Ein kurzer Abschnitt wurde unter eine fremde Überschrift gehängt | Die Regel „zu kurz → an den vorherigen anhängen" kannte keine Kapitelgrenze. Unter einer Antwort hätte dann eine Fundstelle gestanden, in der der zitierte Satz gar nicht vorkommt | Zusammengezogen wird nur innerhalb desselben Überschriftenpfads. Gefunden hat es der Test, nicht das Ausprobieren |
| `prisma migrate dev` wollte die Datenbank zurücksetzen | Die zurückgerollte Migration aus Phase 7 stand noch in `_prisma_migrations` und galt als „applied but missing" | Die Zeile entfernt. Ein `migrate resolve --rolled-back` räumt sie nicht weg |
| Prisma wollte wieder die Volltextindizes abräumen | Bekannt seit Phase 1, achte Migration in Folge | 27 DropIndex-Blöcke entfernt, im Kopf der Migration vermerkt |
| Die abgeleiteten Typen der Oberfläche waren `{}` | `ReturnType<typeof useQuery>["data"]` funktioniert bei tRPC nicht | `inferRouterOutputs<AppRouter>` — wie schon in Phase 7 |
### Abweichung von der Vorgabe
**Das Wiki hat keine Bilder.** Phase-8.md nennt sie nicht, aber man erwartet
sie in einem Wiki. Sie fehlen bewusst: ein Bild in einer Seite wäre entweder
eine Datei in MinIO mit eigener Rechteprüfung beim Ausliefern, oder ein Verweis
auf einen fremden Server — und Letzteres verbietet die Grundregel, dass FLORIAN
nichts von außen nachlädt. Nachrüstbar, wenn eine Wehr es vermisst.
**Kein Streaming der Modellantwort.** Der Bericht erscheint am Stück, nicht Wort
für Wort. Das sähe moderner aus, ist hier aber falsch: der Text soll gelesen und
gegengezeichnet werden, nicht beim Entstehen bewundert. Und ein halb geschriebener
Entwurf, den jemand vorzeitig übernimmt, wäre genau die Lücke, die diese Phase
schließen soll.
### Nachweise
- `npx tsc --noEmit` — ohne Ausgabe
- `npm run test` — 495 Tests grün, davon 21 neue gegen eine echte Datenbank
- `npx playwright test tests/e2e/wissen.spec.ts` — 14 grün, ohne laufendes Modell
- `npm run build` — 56 Routen
- `npm run db:seed` — vier Wiki-Seiten mit eigenem Text
- `docker compose build postgres` — PostGIS 3.5 und pgvector 0.8.6 im selben Image
- `docker compose config` — Ollama und faster-whisper im Profil `ki` prüfen durch
---
## 2026-09-04 · Phase 7 — Benachrichtigungen und Termine
**04:10 – 07:20 · 3:10 h · Version 0.8.0**
### Was entstanden ist
Der Jahresplan der Wehr und die Wege, auf denen die Mannschaft davon erfährt.
| Bereich | Dateien |
|---|---|
| Rechnen | `lib/termin/serie.ts`, `ics.ts`, `lib/benachrichtigung/vorlagen.ts`, `versand.ts`, `lib/dienstausweis.ts` |
| Kanäle | `lib/integrations/mail.ts` (echtes SMTP), `ntfy.ts`, `webpush.ts` |
| Server | `server/routers/termin/termin.ts`, `benachrichtigung.ts`, `stammdaten/dienstausweis.ts` |
| Öffentlich | `api/kalender/[token]`, `ausweis/[code]`, `api/ausweis/[code]/foto` |
| Oberfläche | `app/(app)/termine/`, `benachrichtigungen/`, `ausweis/` |
| Hintergrund | `worker/benachrichtigung.ts`, zwei neue Zeitpläne |
| Tests | 56 Unit, 17 E2E je Browser |
### Entscheidungen
**Niemand wird ungefragt benachrichtigt.** Ein neu eingerichteter Zustellweg
hat eine leere Ereignisliste. Es gibt keinen Weg, jemand anderem einen Kanal
einzutragen — auch nicht für den Kommandanten. Der Seed legt bewusst *keine*
Zustellkanäle an, obwohl das die Beispieldaten hübscher machen würde. Wer eine
Wehr ungefragt anmeldet, hat binnen einer Woche eine Wehr, die die
Benachrichtigungen abschaltet, und danach kommen auch die wichtigen nicht mehr
an.
**Serientermine werden ausgeschrieben, nicht gerechnet.** Eine Regel wäre
weniger Zeilen in der Datenbank und mehr Sonderfälle im Code: der Übungsabend
fällt im August aus, im Dezember ist er eine Woche früher, und für jeden
einzelnen gibt es Zusagen und eine Anwesenheitsliste. Ausgeschrieben ist jede
dieser Ausnahmen einfach ein Datensatz, den man ändert. Der Preis sind hundert
Zeilen für zwei Jahre — Kleingeld, und die Zahl steht vor dem Anlegen in der
Maske.
**Gerechnet wird auf dem Kalender, nicht in Millisekunden.** „Monatlich" heißt
derselbe Tag im nächsten Monat. `setMonth(+1)` auf dem 31. Januar ergibt den
3. März, weil der Februar keinen 31. hat — ein Fehler, der in einer
Übungsserie erst im Frühjahr auffällt. Fällt der Tag im Zielmonat aus, rutscht
der Termin auf den letzten des Monats.
**Anlegen und Zustellen sind getrennt.** Wo ein Ereignis entsteht, wird eine
Zeile geschrieben, sonst nichts. Der Worker nimmt sich, was auf WARTET steht.
Wären es eine Sache, hinge das Speichern eines Termins am Antwortverhalten von
Telegram. So ist die schlimmste Folge eines toten Mailservers eine
Benachrichtigung, die auf FEHLGESCHLAGEN steht und sichtbar ist.
**Intern bekommt den langen Text, extern die Kurzfassung.** Der Mailserver
einer Wehr ist ihr eigener oder der der Gemeinde, und ein selbst betriebener
ntfy steht im Gerätehaus — dorthin dürfen Namen, Gerätebezeichnungen und
Einsatznummern. Telegram und Web-Push laufen über fremde Server und bekommen
„3 Prüfungen fällig", nicht welche. Das steht in `KANAL_EXTERN` an einer
Stelle, wird im Test für *jeden* externen Kanal geprüft und in der Oberfläche
zweimal gesagt: neben dem Kanal und über dem Vorlagenfeld.
**Kein CalDAV, sondern ICS.** CalDAV ist ein Schreib-Lese-Protokoll mit
Rechteverwaltung und Konfliktauflösung. Gebraucht wird Lesen. Eine Adresse, die
eine ICS-Datei ausliefert, kann jeder Kalender — das Format ist eine Liste von
Zeilen, und die interessanten Teile (Zeitzone, Umbruch nach 75 Oktetten) stehen
in zwanzig Zeilen. Eine Abhängigkeit dafür wäre in einer Anwendung ohne
Netzzugang der schlechtere Tausch.
**Der Ausweis gilt fünf Minuten, die Anmeldemarke eine.** Beide sind mit
AUTH_SECRET signiert; was sie trennt, ist der Zweck im signierten Teil. Ohne
den ginge das eine als das andere durch, und der Ausweis wäre plötzlich ein
Anmeldeweg. Die fünf Minuten sind der Unterschied zwischen „wird gescannt" und
„wird vorgezeigt, abgelesen und weggesteckt" — und die Prüfseite nennt den
Zeitpunkt der Ausstellung, weil ein Ausweis, der „gültig" sagt ohne zu sagen
seit wann, wertlos ist.
**Fehlgeschlagene Benachrichtigungen werden nicht abgeräumt.** Der
Aufbewahrungslauf nimmt sich nur die zugestellten. Eine fehlgeschlagene ist der
Hinweis darauf, dass ein Kanal kaputt ist, und der verliert seinen Wert nicht
durch Zeitablauf.
**Erinnert wird um sieben, zugestellt jede Minute.** Eine Push-Meldung um drei
Uhr weckt Leute, die am nächsten Tag arbeiten müssen. Eine Absage am
Dienstagabend muss dagegen ankommen, solange sie noch etwas nützt.
### Wo es gehakt hat
| Problem | Ursache | Lösung |
|---|---|---|
| Die Vorlagenmaske überschrieb den gerade getippten Text | Der Effekt, der den passenden Text lädt, hing an `vorlagen.data`. Wird die Liste im Hintergrund nachgeladen — nach dem Speichern, beim Zurückkommen ins Fenster —, war die Eingabe weg | Merker auf `ereignis:kanal`; geladen wird nur beim Wechsel der Auswahl. Gefunden hat es der E2E-Test, nicht das Ausprobieren |
| `personId` wurde zweimal gesetzt und überschrieb sich | Einschränkung und Ausschluss als zwei Objekt-Einträge im selben `where` — der zweite gewinnt still | Beide in eine Bedingung zusammengeführt |
| Der Aufräumlauf durfte nicht löschen | `REVOKE DELETE ON "benachrichtigung"` gilt der Anwendungsrolle, und `fuerOrganisation` benutzt genau die | Über `ohneMandantenfilter` mit ausdrücklichem Mandantenfilter in der Bedingung — begründet, wie es die Regel verlangt |
| Zwei E2E-Selektoren trafen zu viel | „Absagen" steckt auch in „Termin absagen"; `{titel}` steht im Textfeld *und* auf dem Platzhalterknopf | `exact: true` und Suche innerhalb des Dialogs |
| Der Warteliste-Test hing an der Reihenfolge im Seed | Ob der angemeldete Nutzer schon einen Platz hat, entscheidet die Mitgliedsnummer | Test prüft jetzt die Anzeige der Warteliste — die gilt für alle |
### Abweichung von der Vorgabe
**Kein CalDAV**, obwohl Phase-7.md „CalDAV-/ICS-Feed" nennt. Begründung oben;
sie steht auch im README und in der Roadmap.
**Termine einer Serie werden einzeln geändert, nicht „alle folgenden".** Der
Fall ist selten und mehrdeutig — meint „alle folgenden" auch die, die jemand
schon von Hand verschoben hat? Absagen geht dagegen für die ganze Serie, weil
das der Fall ist, der wirklich vorkommt (Sommerpause, Bauarbeiten am
Gerätehaus).
**Kein Push für iOS ohne Umweg.** Web-Push funktioniert auf dem iPhone erst,
wenn FLORIAN zum Startbildschirm hinzugefügt wurde. Das steht in der
Oberfläche; ein eigener Weg dafür wäre eine App im Store, und die gibt es
bewusst nicht.
### Nachweise
- `npx tsc --noEmit` — ohne Ausgabe
- `npm run test` — 422 Tests grün, davon 15 neue gegen eine echte Datenbank
- `npx playwright test tests/e2e/termine.spec.ts` — 17 grün
- `npm run build` — 50 Routen, darunter `/termine`, `/benachrichtigungen`,
`/ausweis` und die beiden öffentlichen Endpunkte
- `npm run db:seed` — 19 Termine, Warteliste und abgehakte Anwesenheit
- `docker compose config` — ntfy im Profil `push` prüft durch
--- ---
+283
View File
@@ -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.
![Anmeldung](screenshots/01-anmeldung-light.png)
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.
![Terminal](screenshots/25-kiosk-kacheln-light.png)
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
![Fristen](screenshots/11-fristen-light.png)
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.
![Prüfprotokoll](screenshots/18-pruefprotokoll-light.png)
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](screenshots/31-atemschutz-light.png)
**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. Untersuchungs­datum,
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.
![Überwachung](screenshots/33-atemschutz-ueberwachung-light.png)
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.
![Einsatz](screenshots/22-einsatz-light.png)
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
![Kasse](screenshots/45-kasse-light.png)
### 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.*
+171 -40
View File
@@ -16,10 +16,12 @@ Phase 3 ████████████████████ fertig
Phase 4 ████████████████████ fertig 0.5.0 Phase 4 ████████████████████ fertig 0.5.0
Phase 5 ████████████████████ fertig 0.6.0 Phase 5 ████████████████████ fertig 0.6.0
Phase 6 ████████████████████ fertig 0.7.0 Phase 6 ████████████████████ fertig 0.7.0
Phase 7 ░░░░░░░░░░░░░░░░░░░░ offen 0.8.0 Phase 7 ████████████████████ fertig 0.8.0
Phase 8 ░░░░░░░░░░░░░░░░░░░░ offen 0.9.0 Phase 8 ████████████████████ fertig 0.9.0
Phase 9 ░░░░░░░░░░░░░░░░░░░░ offen 0.10.0 Phase 9 ████████████████████ fertig 0.10.0
Freigabe ░░░░░░░░░░░░░░░░░░░░ offen 1.0.0 Phase 10 ████████████████████ fertig 0.11.0
Phase 11 ████████████████████ fertig 0.12.0
Freigabe ████████████████████ fertig 1.0.0
``` ```
--- ---
@@ -148,37 +150,93 @@ analogen, bis die Wehr ausdrücklich anders entscheidet.
**Danach kann die Wehr:** auf der Anfahrt sehen, wo der nächste Hydrant liegt **Danach kann die Wehr:** auf der Anfahrt sehen, wo der nächste Hydrant liegt
und was ihn hergibt. und was ihn hergibt.
## Phase 7 — Benachrichtigungen und Termine · als Nächstes ## Phase 7 — Benachrichtigungen und Termine · fertig
- Kanäle: E-Mail, Telegram, ntfy, Web-Push. WhatsApp bewusst nicht. Die Wehr weiß, wann der nächste Dienst ist — ohne dass jemand eine Rundmail
- Vorlagen mit Platzhaltern, je Ereignis und Rolle konfigurierbar tippt.
- Kalender: Dienste, Übungen, Lehrgänge, Arbeitsdienste; Serientermine,
Selbstanmeldung mit Teilnehmerlimit, Anwesenheitsliste zum Ausdrucken - Terminkalender mit Dienst, Übung, Lehrgang, Arbeitsdienst, Veranstaltung und
- CalDAV-/ICS-Feed je Person Sitzung. Serientermine werden ausgeschrieben, nicht als Regel gespeichert.
- Digitaler Dienstausweis mit signiertem QR und Prüfseite - Selbstanmeldung mit Platzbegrenzung und einer Warteliste, die von selbst
nachrückt. Absage mit Grund, wahlweise für alle folgenden Termine einer Serie.
- Anwesenheitsliste über alle aktiven Mitglieder — zum Abhaken am Tablet oder
zum Ausdrucken. Zusage und Anwesenheit sind getrennt.
- Vier Kanäle hinter Schnittstellen: E-Mail (echtes SMTP statt Platzhalter),
ntfy (selbst gehostet), Telegram, Web-Push. Was das Netz der Wehr verlässt,
bekommt nur die Kurzfassung.
- Vorlagen je Ereignis und Kanal mit Platzhaltern, Vorschau und eingebautem
Standardtext für alles.
- ICS-Abonnement je Person, Token nur als Hash gespeichert, einzeln widerrufbar.
- Digitaler Dienstausweis: signierter QR, fünf Minuten gültig, Prüfseite ohne
Anmeldung.
**Danach kann die Wehr:** ihren Jahresplan führen, Rückmeldungen einsammeln,
Anwesenheit nachweisen und jedes Mitglied auf dem Weg erreichen, den es selbst
gewählt hat.
### Kein CalDAV
CalDAV ist ein Schreib-Lese-Protokoll mit Rechteverwaltung und
Konfliktauflösung. Gebraucht wird hier nur Lesen — die Termine entstehen in
FLORIAN, nicht im Kalender des Anwenders. Dafür genügt eine Adresse, die eine
ICS-Datei ausliefert; jeder Kalender kann das. CalDAV wäre ein Vielfaches an
Aufwand für dieselbe Wirkung.
### Warum kein WhatsApp ### Warum kein WhatsApp
Die WhatsApp Business API kostet je Konversation und verlangt einen Die WhatsApp Business API kostet je Konversation und verlangt einen
zugelassenen Anbieter dazwischen — laufende Kosten für eine Wehr, die keinen zugelassenen Anbieter dazwischen — laufende Kosten für eine Wehr, die keinen
Etat dafür hat. Der schwerere Grund ist der Datenschutz: die Nummern aller Etat dafür hat. Der schwerere Grund ist der Datenschutz: die Nachrichten liefen
Mitglieder gingen an Meta, was sich mit einer Anwendung, die bewusst im über Server von Meta, die Wehr müsste einen Vertrag zur Auftragsverarbeitung
Gerätehaus steht, nicht verträgt. Telegram, ntfy und Web-Push decken denselben schließen, und die gewöhnliche App gleicht zusätzlich das Telefonbuch ab. Das
Bedarf ab, ntfy sogar vollständig selbst gehostet. verträgt sich nicht mit einer Anwendung, die bewusst im Gerätehaus steht.
Telegram, ntfy und Web-Push decken denselben Bedarf ab, ntfy sogar vollständig
selbst gehostet.
## Phase 8 — Lokale KI Wer seine Wehr trotzdem über WhatsApp erreichen will, hat den Weg schon: die
Gruppe, die es ohnehin gibt. Ein Link zum Termin hineinkopiert kostet nichts.
Alles gegen Ollama, kein externer Anbieter, komplett abschaltbar. ## Phase 8 — Lokale KI · fertig
- Diktat: Aufnahme im Browser → faster-whisper → Transkript im Berichtsfeld Alles gegen selbst betriebene Modelle, kein externer Anbieter, komplett
- Berichtsassistent: aus Stichpunkten einen Entwurf, immer als Vorschlag abschaltbar.
- Wiki mit pgvector: semantische Suche mit Quellenangabe
- Belegerfassung: Foto einer Quittung → Vorschlag für Betrag, Datum, Händler
Jede KI-Ausgabe ist gekennzeichnet und muss bestätigt werden, bevor sie in ein - Wiki mit pgvector: Dienstvorschriften und Ausbildungsunterlagen als Markdown,
Dokument mit Nachweischarakter gelangt. Wortsuche und semantische Suche nebeneinander, Antwort mit Fundstellenangabe
- Diktat: Aufnahme im Browser, faster-whisper, Transkript als Vorschlag
- Berichtsassistent: aus Stichpunkten ein Einsatzberichtsentwurf, immer mit
Übernehmen/Verwerfen
- Belegerfassung: Foto einer Quittung, Vorschlag für Betrag, Datum, Händler —
nicht Erkanntes bleibt leer statt geraten zu werden
- KI-Protokoll und Audit-Log-Ansicht nebeneinander
- Eigenes Postgres-Image mit PostGIS **und** pgvector
## Phase 9 — Kassenverwaltung **Danach kann die Wehr:** ihr Wissen ablegen und befragen, Berichte diktieren
statt tippen und Belege fotografieren statt abzuschreiben — ohne dass eine
Zeile Text das Gerätehaus verlässt.
### Die Regel, die den Aufbau bestimmt
Jede KI-Ausgabe ist zuerst ein eigener Datensatz und nicht ein Feld im
Zieldokument. Sie ist gekennzeichnet, sie steht daneben statt mittendrin, und
sie braucht eine Entscheidung, bevor etwas mit ihr geschieht. Die Datenbank
hält gegen: Eingabe, Ausgabe und Modell sind unveränderlich, gelöscht wird
nicht, und über einen Vorschlag wird genau einmal entschieden.
Ohne das ließe sich in einem Jahr nicht mehr sagen, welcher Satz in einem
Einsatzbericht von einem Menschen stammt.
### Kein Rückfall auf einen Anbieter
Fehlen die Container, fehlt die Funktion — sie wird nicht durch einen fremden
Dienst ersetzt. Das ist die einzige Bauart, bei der der Satz „die Daten bleiben
im Haus" auch dann noch stimmt, wenn der eigene Server aus ist.
Dafür bleibt **alles andere ohne Modell vollständig benutzbar**: Wiki lesen und
schreiben, Wortsuche, Belege ablegen, beide Protokolle. Wo etwas fehlt, steht
ein Satz, der sagt was fehlt und wie es ohne geht.
## Phase 9 — Kassenverwaltung · fertig
Am 03.09.2026 ergänzt. Die Rolle `KASSIER` war seit Phase 0 angelegt, ein Modul Am 03.09.2026 ergänzt. Die Rolle `KASSIER` war seit Phase 0 angelegt, ein Modul
dazu fehlte in der ursprünglichen Planung. Spezifikation: `Phase-9.md`. dazu fehlte in der ursprünglichen Planung. Spezifikation: `Phase-9.md`.
@@ -197,20 +255,92 @@ Kassenabschluss friert seinen Zeitraum ein, danach nimmt die Buchungsmaske
kein Datum daraus mehr an. Beträge durchgängig als `DECIMAL(12,2)`, nie als kein Datum daraus mehr an. Beträge durchgängig als `DECIMAL(12,2)`, nie als
Fließkommazahl. Fließkommazahl.
**Danach kann die Wehr:** ihre Kasse führen und den Kassenprüfbericht auf Beides sitzt in Triggern und nicht im Router: ein vergessener Zustandscheck,
ein Skript oder ein Import würden sonst reichen, um den geprüften Bestand zu
ändern, ohne dass irgendwo etwas auffällt.
**Die Wehr kann jetzt:** ihre Kasse führen und den Kassenprüfbericht auf
Knopfdruck erzeugen. Knopfdruck erzeugen.
## 1.0.0 — Freigabe ## Phase 10 — Mandantenfähigkeit · fertig
Am 05.09.2026 ergänzt. Datenmodell und RLS trugen seit Phase 0 mehrere Wehren;
was fehlte, war alles darüber.
- `Benutzer` und `Mitgliedschaft`: das Anmeldekonto ist von der Personenakte
gelöst. Ein Mensch, ein Passwort, beliebig viele Wehren.
- Wehrwähler in der Seitenleiste — er erscheint nur, wenn es etwas zu wählen
gibt.
- Die Rolle `AUFSICHT` für die Kreisebene: Kreisbrandrat und
Kreisbrandinspektion sehen die betreuten Wehren und ändern nichts.
- Verwaltung der Installation unter `/wehren`: Wehren anlegen, Konten führen,
Zuordnungen vergeben.
Die Entscheidung, die alles andere bestimmt: **das Konto trägt keine
Mandantenkennung.** Es kann keine tragen — es ist ja der Angelpunkt, an dem
ein Mensch zu mehreren Wehren gehört. Geschützt wird es deshalb wie
`Berechtigung` über Rechte statt über Row Level Security, und was ein Konto in
einer Wehr darf, entscheidet nicht es selbst, sondern seine `Mitgliedschaft` —
und die trägt `organisationId` wie jede andere Tabelle.
**Bewusst nicht dabei:** wehrübergreifende Auswertungen. Eine Zahl über
mehrere Wehren hinweg braucht eine abgestimmte Definition, und ohne die ist
sie schlimmer als keine. Die Aufsicht sieht jede Wehr einzeln.
**Die Wehr kann jetzt:** sich einen Server mit anderen teilen, ohne dass
jemand über die Grenze sieht — und die Kreisebene kann hineinschauen, ohne
etwas anfassen zu können.
## Phase 11 — Freigabereife · fertig
Am 05.09.2026 umgesetzt. Kein Modul, sondern die drei Software-Voraussetzungen
für die Freigabe.
- **Auskunft nach Art. 15 DSGVO.** Das Recht `person.export` stand seit Phase 0
im Katalog und war zugewiesen — umgesetzt war nichts. Ohne Auskunftsseite ist
ein Löschkonzept nicht abnehmbar.
- **Sicherung und Wiederherstellung** mit einer durchgeführten Rückspielprobe.
- **Pipeline** in Gitea Actions: typecheck, lint, Migrationen, Seed und Tests.
Die Rückspielprobe hat sich sofort bezahlt gemacht: der erste Versuch lief
sauber durch und hatte dabei sämtliche Unveränderlichkeitsgarantien der
Datenbank entfernt. Gefunden haben es die Datenbanktests unmittelbar danach —
also genau die Probe, wegen der 1.0.0 sie verlangt.
**Die Wehr kann jetzt:** eine Selbstauskunft erteilen, ihren Bestand sichern
und ihn nachweislich zurückholen.
## 1.0.0 — Freigabe · 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.
--- ---
@@ -220,8 +350,11 @@ Am 03.09.2026 entschieden:
| Punkt | Entscheidung | | Punkt | Entscheidung |
|---|---| |---|---|
| Kassenmodul | Wird als **Phase 9** ergänzt. Spezifikation in `Phase-9.md`, Aufwand 8–11 PT. | | Kassenmodul | Als **Phase 9** ergänzt und mit 0.10.0 umgesetzt. Spezifikation in `Phase-9.md`. |
| `Berechtigung` ohne `organisationId` | Bleibt so. Die Tabelle ist der systemweite Katalog dessen, was die Software kann, für alle Wehren identisch — mandantenbezogen wird es erst über `RolleBerechtigung`. Für die Anwendungsrolle nur lesbar. | | `Berechtigung` ohne `organisationId` | Bleibt so. Die Tabelle ist der systemweite Katalog dessen, was die Software kann, für alle Wehren identisch — mandantenbezogen wird es erst über `RolleBerechtigung`. Für die Anwendungsrolle nur lesbar. |
| `Benutzer` ohne `organisationId` | Notwendige Ausnahme, seit Phase 10. Die Tabelle ist der Angelpunkt der Doppelmitgliedschaft — mit Mandantenkennung könnte sie das nicht sein. Geschützt über Rechte statt RLS, wie `Berechtigung`: die Anwendungsrolle darf lesen und die Anmeldefelder fortschreiben, mehr nicht. |
| `AUFSICHT` nicht im Prisma-Enum | Die Kreisebene ergibt sich aus der Mitgliedschaftsart, nicht aus einer Rollenzuweisung. Vergebbar zu machen hiesse, dass jemand innerhalb einer Wehr Rechte an einer anderen verteilen könnte. |
| `istVerwalter` als Kontomerkmal | Kein Recht. Rechte gelten je Wehr; wer Wehren anlegt, steht über allen. Ein Recht, das in Wehr A vergeben wird und in Wehr B wirkt, wäre die Hintertür, die Phase 10 vermeiden soll. |
| Löschausnahme im Audit-Log | Bleibt so. `UPDATE` ist dauerhaft gesperrt, `DELETE` nur mit `SET LOCAL app.audit_bereinigung = 'an'` im Aufbewahrungslauf des Workers. Ohne diese Ausnahme wäre das Löschkonzept nach DSGVO nicht umsetzbar. | | Löschausnahme im Audit-Log | Bleibt so. `UPDATE` ist dauerhaft gesperrt, `DELETE` nur mit `SET LOCAL app.audit_bereinigung = 'an'` im Aufbewahrungslauf des Workers. Ohne diese Ausnahme wäre das Löschkonzept nach DSGVO nicht umsetzbar. |
## Weitere Kandidaten für später ## Weitere Kandidaten für später
@@ -232,10 +365,8 @@ Am 03.09.2026 entschieden:
| Kleiderkammer und Wäscheverwaltung | in Phase 4 nur als Wizard, ohne Bestandsführung | 4–6 PT | | Kleiderkammer und Wäscheverwaltung | in Phase 4 nur als Wizard, ohne Bestandsführung | 4–6 PT |
| Jugendfeuerwehr mit eigener Abzeichenverwaltung | eigene Fachlichkeit | 5–7 PT | | Jugendfeuerwehr mit eigener Abzeichenverwaltung | eigene Fachlichkeit | 5–7 PT |
| Schnittstelle zur Integrierten Leitstelle | Alarmdaten automatisch übernehmen | 6–10 PT, stark abhängig von der ILS | | Schnittstelle zur Integrierten Leitstelle | Alarmdaten automatisch übernehmen | 6–10 PT, stark abhängig von der ILS |
| Mehrere Wehren je Installation | technisch bereits möglich, Oberfläche fehlt | 4–6 PT |
Die Mandantenfähigkeit ist der Punkt, der ohne Zusatzaufwand am weitesten Die Mandantenfähigkeit ist mit **Phase 10** umgesetzt: umschalten, Kreisebene,
trägt: das Datenmodell und RLS können bereits mehrere Wehren. Was fehlt, ist Doppelmitgliedschaft, Verwaltung. Offen geblieben sind bewusst die
eine Oberfläche zum Umschalten und ein Konzept für gemeindeübergreifende gemeindeübergreifenden Auswertungen — dafür braucht es zuerst eine abgestimmte
Auswertungen. Für einen Landkreis, der mehrere Wehren betreiben will, wäre das Definition dessen, was verglichen werden soll.
der nächste sinnvolle Schritt.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 207 KiB

After

Width:  |  Height:  |  Size: 224 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 212 KiB

After

Width:  |  Height:  |  Size: 226 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 MiB

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 MiB

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 245 KiB

After

Width:  |  Height:  |  Size: 402 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 253 KiB

After

Width:  |  Height:  |  Size: 413 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 160 KiB

After

Width:  |  Height:  |  Size: 179 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 164 KiB

After

Width:  |  Height:  |  Size: 180 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 170 KiB

After

Width:  |  Height:  |  Size: 193 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 174 KiB

After

Width:  |  Height:  |  Size: 194 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 415 KiB

After

Width:  |  Height:  |  Size: 430 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 424 KiB

After

Width:  |  Height:  |  Size: 438 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 792 KiB

After

Width:  |  Height:  |  Size: 803 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 816 KiB

After

Width:  |  Height:  |  Size: 824 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 140 KiB

After

Width:  |  Height:  |  Size: 156 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 142 KiB

After

Width:  |  Height:  |  Size: 156 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 306 KiB

After

Width:  |  Height:  |  Size: 323 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 318 KiB

After

Width:  |  Height:  |  Size: 332 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1012 KiB

After

Width:  |  Height:  |  Size: 1.0 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.0 MiB

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 210 KiB

After

Width:  |  Height:  |  Size: 226 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 214 KiB

After

Width:  |  Height:  |  Size: 228 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 MiB

After

Width:  |  Height:  |  Size: 326 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 MiB

After

Width:  |  Height:  |  Size: 332 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 123 KiB

After

Width:  |  Height:  |  Size: 139 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 125 KiB

After

Width:  |  Height:  |  Size: 139 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 258 KiB

After

Width:  |  Height:  |  Size: 274 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 262 KiB

After

Width:  |  Height:  |  Size: 275 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 830 KiB

After

Width:  |  Height:  |  Size: 156 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 846 KiB

After

Width:  |  Height:  |  Size: 156 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 271 KiB

After

Width:  |  Height:  |  Size: 287 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 90 KiB

After

Width:  |  Height:  |  Size: 294 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 243 KiB

After

Width:  |  Height:  |  Size: 280 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 252 KiB

After

Width:  |  Height:  |  Size: 287 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 663 KiB

After

Width:  |  Height:  |  Size: 336 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 670 KiB

After

Width:  |  Height:  |  Size: 338 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 362 KiB

After

Width:  |  Height:  |  Size: 129 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 115 KiB

After

Width:  |  Height:  |  Size: 128 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 245 KiB

After

Width:  |  Height:  |  Size: 261 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 250 KiB

After

Width:  |  Height:  |  Size: 263 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 535 KiB

After

Width:  |  Height:  |  Size: 789 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 540 KiB

After

Width:  |  Height:  |  Size: 807 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 226 KiB

After

Width:  |  Height:  |  Size: 244 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 235 KiB

After

Width:  |  Height:  |  Size: 250 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 143 KiB

After

Width:  |  Height:  |  Size: 189 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 146 KiB

After

Width:  |  Height:  |  Size: 193 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 192 KiB

After

Width:  |  Height:  |  Size: 183 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 189 KiB

After

Width:  |  Height:  |  Size: 187 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 203 KiB

After

Width:  |  Height:  |  Size: 125 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 204 KiB

After

Width:  |  Height:  |  Size: 125 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 195 KiB

After

Width:  |  Height:  |  Size: 291 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 200 KiB

After

Width:  |  Height:  |  Size: 213 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 160 KiB

After

Width:  |  Height:  |  Size: 175 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 163 KiB

After

Width:  |  Height:  |  Size: 176 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.1 MiB

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 247 KiB

After

Width:  |  Height:  |  Size: 400 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 299 KiB

After

Width:  |  Height:  |  Size: 317 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 255 KiB

After

Width:  |  Height:  |  Size: 326 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 265 KiB

After

Width:  |  Height:  |  Size: 270 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 268 KiB

After

Width:  |  Height:  |  Size: 132 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 215 KiB

After

Width:  |  Height:  |  Size: 230 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 190 KiB

After

Width:  |  Height:  |  Size: 236 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 160 KiB

After

Width:  |  Height:  |  Size: 175 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 142 KiB

After

Width:  |  Height:  |  Size: 176 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 227 KiB

After

Width:  |  Height:  |  Size: 242 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 19 KiB

After

Width:  |  Height:  |  Size: 246 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 628 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 639 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 262 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 269 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 229 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 234 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 199 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 200 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 248 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 251 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 170 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 171 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 173 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 174 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 320 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 328 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 310 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 317 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 937 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 959 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 408 KiB

Some files were not shown because too many files have changed in this diff Show More