Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3b20abcb23 | ||
|
|
14cc4de9b8 | ||
|
|
91742a0dd7 | ||
|
|
2b53556175 | ||
|
|
d70acad4cd | ||
|
|
7196c7110c | ||
|
|
8081131eb7 | ||
|
|
f7ccd9a908 | ||
|
|
12243f177d | ||
|
|
0b8874da26 |
@@ -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
|
||||||
|
|||||||
@@ -0,0 +1,184 @@
|
|||||||
|
# =============================================================================
|
||||||
|
# Die Pruefung bei jedem Push.
|
||||||
|
#
|
||||||
|
# Bis Phase 10 gab es 581 Unit- und 266 E2E-Tests — und nichts, was sie
|
||||||
|
# ausfuehrte ausser einem Menschen, der daran dachte. In der Sitzung, in der
|
||||||
|
# die Kassenverwaltung entstand, haben die Tests vier echte Fehler gefunden,
|
||||||
|
# zwei davon in Code, der eine Stunde alt war. Genau das soll hier von allein
|
||||||
|
# passieren.
|
||||||
|
#
|
||||||
|
# Zwei Auftraege, bewusst getrennt:
|
||||||
|
#
|
||||||
|
# pruefung typecheck, lint und die Unit-Tests. Braucht eine Datenbank,
|
||||||
|
# weil ein Teil der Tests die Trigger und RLS gegen echtes
|
||||||
|
# Postgres prueft — mit einer Attrappe waeren sie wertlos.
|
||||||
|
#
|
||||||
|
# oberflaeche die E2E-Tests. Dauern eine Dreiviertelstunde und brauchen den
|
||||||
|
# halben Stack, deshalb laufen sie nur auf main und in Pull
|
||||||
|
# Requests, nicht bei jedem Zwischenstand auf einem Zweig.
|
||||||
|
# =============================================================================
|
||||||
|
name: Pruefung
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
pull_request:
|
||||||
|
|
||||||
|
env:
|
||||||
|
# Dieselben Werte wie in .env.example. Es sind Testdaten in einem
|
||||||
|
# Wegwerf-Container; echte Geheimnisse haben in einer Pipeline nichts zu
|
||||||
|
# suchen und stehen im Betrieb in der .env.
|
||||||
|
POSTGRES_USER: florian
|
||||||
|
POSTGRES_PASSWORD: pruefung
|
||||||
|
POSTGRES_DB: florian
|
||||||
|
POSTGRES_APP_PASSWORD: pruefung_app
|
||||||
|
DATABASE_URL: postgresql://florian_app:pruefung_app@localhost:5432/florian?schema=public
|
||||||
|
DIRECT_DATABASE_URL: postgresql://florian:pruefung@localhost:5432/florian?schema=public
|
||||||
|
AUTH_SECRET: nur-fuer-die-pruefung-nicht-fuer-den-betrieb
|
||||||
|
AUTH_TRUST_HOST: "true"
|
||||||
|
APP_URL: http://localhost:3100
|
||||||
|
S3_ENDPOINT: localhost
|
||||||
|
S3_PORT: "9000"
|
||||||
|
S3_USE_SSL: "false"
|
||||||
|
S3_ACCESS_KEY: florian
|
||||||
|
S3_SECRET_KEY: pruefung_minio
|
||||||
|
S3_BUCKET: florian
|
||||||
|
REDIS_URL: redis://localhost:6379
|
||||||
|
TZ: Europe/Berlin
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
pruefung:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: 22
|
||||||
|
cache: npm
|
||||||
|
|
||||||
|
# Das eigene Postgres-Image statt eines fertigen: FLORIAN braucht
|
||||||
|
# PostGIS **und** pgvector, und kein oeffentliches Image bringt beides
|
||||||
|
# mit. Ein Dienst-Container mit postgres:17 wuerde die Migrationen
|
||||||
|
# durchlassen und erst bei der ersten Umkreissuche scheitern.
|
||||||
|
- name: Datenbank bauen und starten
|
||||||
|
run: |
|
||||||
|
docker build -t florian-postgres:pruefung ./docker/postgres
|
||||||
|
docker run -d --name postgres -p 5432:5432 \
|
||||||
|
-e POSTGRES_USER=$POSTGRES_USER \
|
||||||
|
-e POSTGRES_PASSWORD=$POSTGRES_PASSWORD \
|
||||||
|
-e POSTGRES_DB=$POSTGRES_DB \
|
||||||
|
-e POSTGRES_APP_PASSWORD=$POSTGRES_APP_PASSWORD \
|
||||||
|
-e POSTGRES_INITDB_ARGS="--locale-provider=icu --icu-locale=de-DE --locale=C.UTF-8 --encoding=UTF8" \
|
||||||
|
-v "$(pwd)/docker/postgres:/docker-entrypoint-initdb.d:ro" \
|
||||||
|
florian-postgres:pruefung
|
||||||
|
|
||||||
|
- name: Redis und MinIO starten
|
||||||
|
run: |
|
||||||
|
docker run -d --name redis -p 6379:6379 redis:7-alpine
|
||||||
|
docker run -d --name minio -p 9000:9000 \
|
||||||
|
-e MINIO_ROOT_USER=$S3_ACCESS_KEY \
|
||||||
|
-e MINIO_ROOT_PASSWORD=$S3_SECRET_KEY \
|
||||||
|
minio/minio:latest server /data
|
||||||
|
|
||||||
|
- run: npm ci
|
||||||
|
|
||||||
|
- name: Auf die Datenbank warten
|
||||||
|
run: |
|
||||||
|
for i in $(seq 1 60); do
|
||||||
|
docker exec postgres pg_isready -U "$POSTGRES_USER" -d "$POSTGRES_DB" && exit 0
|
||||||
|
sleep 2
|
||||||
|
done
|
||||||
|
echo "Postgres kam nicht hoch:"
|
||||||
|
docker logs postgres
|
||||||
|
exit 1
|
||||||
|
|
||||||
|
- run: npx prisma generate
|
||||||
|
- run: npm run typecheck
|
||||||
|
- run: npm run lint
|
||||||
|
|
||||||
|
- name: Migrationen einspielen
|
||||||
|
run: npm run db:migrate
|
||||||
|
|
||||||
|
# Der Seed gehoert dazu: er ist selbst ein Test. Faellt eine Migration
|
||||||
|
# oder ein Standardwert um, merkt man es hier und nicht erst, wenn eine
|
||||||
|
# Wehr ihn zum ersten Mal ausfuehrt.
|
||||||
|
- name: Beispielbestand anlegen
|
||||||
|
run: npm run db:seed
|
||||||
|
|
||||||
|
- run: npm run test
|
||||||
|
|
||||||
|
- name: Aufraeumen
|
||||||
|
if: always()
|
||||||
|
run: docker rm -f postgres redis minio || true
|
||||||
|
|
||||||
|
oberflaeche:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
# Nur wo es sich lohnt: ein voller Lauf dauert etwa 45 Minuten.
|
||||||
|
if: github.event_name == 'pull_request' || github.ref == 'refs/heads/main'
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: 22
|
||||||
|
cache: npm
|
||||||
|
|
||||||
|
- name: Dienste starten
|
||||||
|
run: |
|
||||||
|
docker build -t florian-postgres:pruefung ./docker/postgres
|
||||||
|
docker run -d --name postgres -p 5432:5432 \
|
||||||
|
-e POSTGRES_USER=$POSTGRES_USER \
|
||||||
|
-e POSTGRES_PASSWORD=$POSTGRES_PASSWORD \
|
||||||
|
-e POSTGRES_DB=$POSTGRES_DB \
|
||||||
|
-e POSTGRES_APP_PASSWORD=$POSTGRES_APP_PASSWORD \
|
||||||
|
-e POSTGRES_INITDB_ARGS="--locale-provider=icu --icu-locale=de-DE --locale=C.UTF-8 --encoding=UTF8" \
|
||||||
|
-v "$(pwd)/docker/postgres:/docker-entrypoint-initdb.d:ro" \
|
||||||
|
florian-postgres:pruefung
|
||||||
|
docker run -d --name redis -p 6379:6379 redis:7-alpine
|
||||||
|
docker run -d --name minio -p 9000:9000 \
|
||||||
|
-e MINIO_ROOT_USER=$S3_ACCESS_KEY \
|
||||||
|
-e MINIO_ROOT_PASSWORD=$S3_SECRET_KEY \
|
||||||
|
minio/minio:latest server /data
|
||||||
|
docker run -d --name gotenberg -p 3001:3000 gotenberg/gotenberg:8
|
||||||
|
|
||||||
|
- run: npm ci
|
||||||
|
- run: npx playwright install --with-deps chromium
|
||||||
|
|
||||||
|
- name: Auf die Datenbank warten
|
||||||
|
run: |
|
||||||
|
for i in $(seq 1 60); do
|
||||||
|
docker exec postgres pg_isready -U "$POSTGRES_USER" -d "$POSTGRES_DB" && exit 0
|
||||||
|
sleep 2
|
||||||
|
done
|
||||||
|
docker logs postgres
|
||||||
|
exit 1
|
||||||
|
|
||||||
|
- run: npx prisma generate
|
||||||
|
- run: npm run db:migrate
|
||||||
|
|
||||||
|
# Die E2E-Tests laufen seit Phase 8 gegen eine eigene Wehr, nicht gegen
|
||||||
|
# den Beispielbestand. Das Skript legt beide an.
|
||||||
|
- name: Testwehr anlegen
|
||||||
|
run: npm run db:test-reset
|
||||||
|
|
||||||
|
- run: npm run build
|
||||||
|
- run: npx playwright test
|
||||||
|
env:
|
||||||
|
GOTENBERG_URL: http://localhost:3001
|
||||||
|
|
||||||
|
- name: Fehlerbilder sichern
|
||||||
|
if: failure()
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: playwright-bericht
|
||||||
|
path: |
|
||||||
|
playwright-report/
|
||||||
|
test-results/
|
||||||
|
retention-days: 7
|
||||||
|
|
||||||
|
- name: Aufraeumen
|
||||||
|
if: always()
|
||||||
|
run: docker rm -f postgres redis minio gotenberg || true
|
||||||
@@ -40,3 +40,6 @@ debug.log
|
|||||||
# Prisma
|
# Prisma
|
||||||
prisma/*.db
|
prisma/*.db
|
||||||
prisma/*.db-journal
|
prisma/*.db-journal
|
||||||
|
|
||||||
|
# Sicherungen bleiben lokal — sie enthalten den gesamten Bestand.
|
||||||
|
sicherungen/
|
||||||
|
|||||||
@@ -6,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.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,283 @@
|
|||||||
|
# FLORIAN — Kurzanleitung
|
||||||
|
|
||||||
|
Alles, was man am Anfang braucht, auf ein paar Seiten. Nach Rollen sortiert:
|
||||||
|
such dir den Abschnitt, der zu dir gehört, und lies den Rest später.
|
||||||
|
|
||||||
|
Ausführlich steht alles in der [Anleitung](ANLEITUNG.md) — 63 Kapitel, zum
|
||||||
|
Nachschlagen, nicht zum Durchlesen.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Für alle
|
||||||
|
|
||||||
|
### Anmelden
|
||||||
|
|
||||||
|
FLORIAN läuft im Browser. Die Adresse bekommst du vom Kommandanten.
|
||||||
|
|
||||||
|

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

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

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

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

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

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

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

|
||||||
|
|
||||||
|
### Buchen
|
||||||
|
|
||||||
|
**Kasse** → **Buchungen** → *Buchung erfassen*. Der Betrag ist immer positiv;
|
||||||
|
ob Einnahme oder Ausgabe, sagen die beiden Knöpfe daneben.
|
||||||
|
|
||||||
|
FLORIAN schlägt eine Kategorie vor — meist die richtige, weil er nachsieht,
|
||||||
|
wie ihr dieselbe Sache zuletzt gebucht habt.
|
||||||
|
|
||||||
|
### Kontoauszug einlesen
|
||||||
|
|
||||||
|
Statt fünfzig Umsätze abzutippen: im Online-Banking den **Umsatzexport**
|
||||||
|
herunterladen (CAMT.053 oder MT940), dann **Kasse** → **Buchungen** →
|
||||||
|
*Kontoauszug*.
|
||||||
|
|
||||||
|
Es entstehen **Entwürfe**, keine fertigen Buchungen — durchsehen musst du sie.
|
||||||
|
Umsätze, die schon in der Kasse stehen, erkennt FLORIAN und hakt sie nicht
|
||||||
|
noch einmal ab.
|
||||||
|
|
||||||
|
### Beiträge
|
||||||
|
|
||||||
|
Einmal im Jahr: **Kasse** → **Beiträge** → *Beitragslauf*. Vorher steht da, wie
|
||||||
|
viele Beiträge entstehen — und wer keinen passenden Satz hat.
|
||||||
|
|
||||||
|
Zahlungen hakst du in der Liste ab; dabei entsteht gleich die Buchung.
|
||||||
|
|
||||||
|
### Festschreiben — und was das bedeutet
|
||||||
|
|
||||||
|
Eine neue Buchung ist erst einmal ein **Entwurf** und änderbar. Mit
|
||||||
|
*Festschreiben* wird sie unveränderbar.
|
||||||
|
|
||||||
|
Ist danach etwas falsch, wird **gegengebucht**: eine zweite Buchung in die
|
||||||
|
andere Richtung, mit Begründung. Beide bleiben stehen. Das sieht nach mehr
|
||||||
|
Aufwand aus und ist genau der Punkt — eine verschwundene Buchung kann ein
|
||||||
|
Kassenprüfer nicht prüfen, eine berichtigte schon.
|
||||||
|
|
||||||
|
### Kassenprüfung
|
||||||
|
|
||||||
|
**Kasse** → **Abschluss** → *Kassenprüfung anlegen*. Links steht der Bestand
|
||||||
|
laut Buch, rechts trägst du ein, was gezählt wurde.
|
||||||
|
|
||||||
|
Bleibt eine Differenz, muss sie erklärt werden — sonst lässt sich der Abschluss
|
||||||
|
nicht festschreiben. In fünf Jahren weiß sonst niemand mehr, woher sie kam.
|
||||||
|
|
||||||
|
Danach entsteht der **Kassenprüfbericht als PDF** mit Unterschriftsfeldern.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Für den, der den Server betreibt
|
||||||
|
|
||||||
|
### Sichern
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run sicherung
|
||||||
|
```
|
||||||
|
|
||||||
|
Sichert Datenbank **und** Dateien. Das Ergebnis gehört auf ein anderes Gerät —
|
||||||
|
eine Sicherung auf derselben Festplatte hilft gegen einen Festplattenfehler
|
||||||
|
nicht.
|
||||||
|
|
||||||
|
### Einmal im Jahr proben
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run wiederherstellen -- --quelle sicherungen/<Ordner>
|
||||||
|
```
|
||||||
|
|
||||||
|
So passiert nichts, es wird nur angezeigt, was geschähe. Erst mit `--wirklich`
|
||||||
|
wird zurückgespielt.
|
||||||
|
|
||||||
|
> Eine Sicherung, die nie zurückgespielt wurde, ist eine Vermutung. Der Ordner
|
||||||
|
> kann leer sein, der Auszug abgeschnitten — man erfährt es sonst an dem Tag,
|
||||||
|
> an dem man es nicht erfahren will.
|
||||||
|
|
||||||
|
### Aktualisieren
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./update.sh --pruefen # nachsehen, ob es etwas Neues gibt
|
||||||
|
./update.sh # einspielen
|
||||||
|
```
|
||||||
|
|
||||||
|
Das Skript sichert von sich aus, bevor es etwas anfasst, und sagt bei einem
|
||||||
|
Fehler, wie der vorherige Stand zurückkommt.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Wenn etwas nicht geht
|
||||||
|
|
||||||
|
| Was | Woran es meist liegt |
|
||||||
|
|---|---|
|
||||||
|
| **Anmeldung geht nicht** | Nach fünf Fehlversuchen ist das Konto fünf Minuten gesperrt. Kurz warten. |
|
||||||
|
| **Ein Menüpunkt fehlt** | Die Rolle fehlt. Der Kommandant vergibt sie unter *Mitglieder*. |
|
||||||
|
| **Ein PDF entsteht nicht** | Es wird im Hintergrund erzeugt; nach ein paar Sekunden die Seite neu laden. |
|
||||||
|
| **Das Terminal meldet niemanden an** | PIN falsch, oder das Tablet ist nicht mehr eingerichtet. |
|
||||||
|
| **Eine Buchung lässt sich nicht ändern** | Sie ist festgeschrieben. Gegenbuchen. |
|
||||||
|
|
||||||
|
Steht die Antwort nicht dabei: [Anleitung, letztes Kapitel](ANLEITUNG.md).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*FLORIAN läuft auf einem Server, den die Feuerwehr selbst betreibt. Es gibt
|
||||||
|
keine Telemetrie, keine Cloud und keine Weitergabe an Dritte — die Daten
|
||||||
|
bleiben im Gerätehaus.*
|
||||||
@@ -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.
|
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 207 KiB After Width: | Height: | Size: 224 KiB |
|
Before Width: | Height: | Size: 212 KiB After Width: | Height: | Size: 226 KiB |
|
Before Width: | Height: | Size: 1.1 MiB After Width: | Height: | Size: 1.1 MiB |
|
Before Width: | Height: | Size: 1.1 MiB After Width: | Height: | Size: 1.1 MiB |
|
Before Width: | Height: | Size: 245 KiB After Width: | Height: | Size: 402 KiB |
|
Before Width: | Height: | Size: 253 KiB After Width: | Height: | Size: 413 KiB |
|
Before Width: | Height: | Size: 160 KiB After Width: | Height: | Size: 179 KiB |
|
Before Width: | Height: | Size: 164 KiB After Width: | Height: | Size: 180 KiB |
|
Before Width: | Height: | Size: 170 KiB After Width: | Height: | Size: 193 KiB |
|
Before Width: | Height: | Size: 174 KiB After Width: | Height: | Size: 194 KiB |
|
Before Width: | Height: | Size: 415 KiB After Width: | Height: | Size: 430 KiB |
|
Before Width: | Height: | Size: 424 KiB After Width: | Height: | Size: 438 KiB |
|
Before Width: | Height: | Size: 792 KiB After Width: | Height: | Size: 803 KiB |
|
Before Width: | Height: | Size: 816 KiB After Width: | Height: | Size: 824 KiB |
|
Before Width: | Height: | Size: 140 KiB After Width: | Height: | Size: 156 KiB |
|
Before Width: | Height: | Size: 142 KiB After Width: | Height: | Size: 156 KiB |
|
Before Width: | Height: | Size: 306 KiB After Width: | Height: | Size: 323 KiB |
|
Before Width: | Height: | Size: 318 KiB After Width: | Height: | Size: 332 KiB |
|
Before Width: | Height: | Size: 1012 KiB After Width: | Height: | Size: 1.0 MiB |
|
Before Width: | Height: | Size: 1.0 MiB After Width: | Height: | Size: 1.1 MiB |
|
Before Width: | Height: | Size: 210 KiB After Width: | Height: | Size: 226 KiB |
|
Before Width: | Height: | Size: 214 KiB After Width: | Height: | Size: 228 KiB |
|
Before Width: | Height: | Size: 1.5 MiB After Width: | Height: | Size: 326 KiB |
|
Before Width: | Height: | Size: 1.5 MiB After Width: | Height: | Size: 332 KiB |
|
Before Width: | Height: | Size: 123 KiB After Width: | Height: | Size: 139 KiB |
|
Before Width: | Height: | Size: 125 KiB After Width: | Height: | Size: 139 KiB |
|
Before Width: | Height: | Size: 258 KiB After Width: | Height: | Size: 274 KiB |
|
Before Width: | Height: | Size: 262 KiB After Width: | Height: | Size: 275 KiB |
|
Before Width: | Height: | Size: 830 KiB After Width: | Height: | Size: 156 KiB |
|
Before Width: | Height: | Size: 846 KiB After Width: | Height: | Size: 156 KiB |
|
Before Width: | Height: | Size: 271 KiB After Width: | Height: | Size: 287 KiB |
|
Before Width: | Height: | Size: 90 KiB After Width: | Height: | Size: 294 KiB |
|
Before Width: | Height: | Size: 243 KiB After Width: | Height: | Size: 280 KiB |
|
Before Width: | Height: | Size: 252 KiB After Width: | Height: | Size: 287 KiB |
|
Before Width: | Height: | Size: 663 KiB After Width: | Height: | Size: 336 KiB |
|
Before Width: | Height: | Size: 670 KiB After Width: | Height: | Size: 338 KiB |
|
Before Width: | Height: | Size: 362 KiB After Width: | Height: | Size: 129 KiB |
|
Before Width: | Height: | Size: 115 KiB After Width: | Height: | Size: 128 KiB |
|
Before Width: | Height: | Size: 245 KiB After Width: | Height: | Size: 261 KiB |
|
Before Width: | Height: | Size: 250 KiB After Width: | Height: | Size: 263 KiB |
|
Before Width: | Height: | Size: 535 KiB After Width: | Height: | Size: 789 KiB |
|
Before Width: | Height: | Size: 540 KiB After Width: | Height: | Size: 807 KiB |
|
Before Width: | Height: | Size: 226 KiB After Width: | Height: | Size: 244 KiB |
|
Before Width: | Height: | Size: 235 KiB After Width: | Height: | Size: 250 KiB |
|
Before Width: | Height: | Size: 143 KiB After Width: | Height: | Size: 189 KiB |
|
Before Width: | Height: | Size: 146 KiB After Width: | Height: | Size: 193 KiB |
|
Before Width: | Height: | Size: 192 KiB After Width: | Height: | Size: 183 KiB |
|
Before Width: | Height: | Size: 189 KiB After Width: | Height: | Size: 187 KiB |
|
Before Width: | Height: | Size: 203 KiB After Width: | Height: | Size: 125 KiB |
|
Before Width: | Height: | Size: 204 KiB After Width: | Height: | Size: 125 KiB |
|
Before Width: | Height: | Size: 195 KiB After Width: | Height: | Size: 291 KiB |
|
Before Width: | Height: | Size: 200 KiB After Width: | Height: | Size: 213 KiB |
|
Before Width: | Height: | Size: 160 KiB After Width: | Height: | Size: 175 KiB |
|
Before Width: | Height: | Size: 163 KiB After Width: | Height: | Size: 176 KiB |
|
Before Width: | Height: | Size: 2.1 MiB After Width: | Height: | Size: 19 KiB |
|
Before Width: | Height: | Size: 247 KiB After Width: | Height: | Size: 400 KiB |
|
Before Width: | Height: | Size: 299 KiB After Width: | Height: | Size: 317 KiB |
|
Before Width: | Height: | Size: 255 KiB After Width: | Height: | Size: 326 KiB |
|
Before Width: | Height: | Size: 265 KiB After Width: | Height: | Size: 270 KiB |
|
Before Width: | Height: | Size: 268 KiB After Width: | Height: | Size: 132 KiB |
|
Before Width: | Height: | Size: 215 KiB After Width: | Height: | Size: 230 KiB |
|
Before Width: | Height: | Size: 190 KiB After Width: | Height: | Size: 236 KiB |
|
Before Width: | Height: | Size: 160 KiB After Width: | Height: | Size: 175 KiB |
|
Before Width: | Height: | Size: 142 KiB After Width: | Height: | Size: 176 KiB |
|
Before Width: | Height: | Size: 227 KiB After Width: | Height: | Size: 242 KiB |
|
Before Width: | Height: | Size: 19 KiB After Width: | Height: | Size: 246 KiB |
|
After Width: | Height: | Size: 628 KiB |
|
After Width: | Height: | Size: 639 KiB |
|
After Width: | Height: | Size: 262 KiB |
|
After Width: | Height: | Size: 269 KiB |
|
After Width: | Height: | Size: 229 KiB |
|
After Width: | Height: | Size: 234 KiB |
|
After Width: | Height: | Size: 199 KiB |
|
After Width: | Height: | Size: 200 KiB |
|
After Width: | Height: | Size: 248 KiB |
|
After Width: | Height: | Size: 251 KiB |
|
After Width: | Height: | Size: 170 KiB |
|
After Width: | Height: | Size: 171 KiB |
|
After Width: | Height: | Size: 173 KiB |
|
After Width: | Height: | Size: 174 KiB |
|
After Width: | Height: | Size: 320 KiB |
|
After Width: | Height: | Size: 328 KiB |
|
After Width: | Height: | Size: 310 KiB |
|
After Width: | Height: | Size: 317 KiB |
|
After Width: | Height: | Size: 937 KiB |
|
After Width: | Height: | Size: 959 KiB |
|
After Width: | Height: | Size: 408 KiB |