Phase 6: alles, was einen Ort hat — Loeschwasser, Objekte mit Feuerwehrplan, die Lagekarte eines Einsatzes und die geplante Foerderstrecke. Keine Kacheln aus dem Netz. Wo eine Wehr ausrueckt und wo ihre Hydranten liegen, ist niemandes Geschaeft; der Kartenserver laeuft selbst (tileserver-gl im Compose-Profil "karte"). Ohne ihn zeigt die Karte einen leeren Grund und sagt das auch — Punkte, Entfernungen und Umkreissuche funktionieren trotzdem. Dasselbe gilt fuer einen eingetragenen, aber nicht erreichbaren Server. Die Umkreissuche laeuft ueber PostGIS: ST_DWithin auf der Geografie mit GiST-Index rechnet in Metern und nutzt den Index. Gefragt wird waehrend der Fahrt, und bei tausend Hydranten waere alles andere ein vollstaendiger Tabellendurchlauf. Der Import aus GeoJSON und Stadtwerke-CSV ist nachsichtig, aber ehrlich: er erkennt "DN 100", deutsche Kommazahlen, Datumsangaben und vertauschte Achsen — raet aber nichts. Fehlt die Position, wird die Zeile uebersprungen; ohne Statusangabe steht "ungeprueft", nicht "einsatzbereit". 22 taktische Zeichen nach FwDV 102 selbst gezeichnet, die Lagekarte gleicht ueber denselben Ereignisstrom ab wie die Atemschutzueberwachung. Die Wasserfoerderung rechnet Reibung, Hoehe und Pumpenzahl; das Ergebnis wird nie gespeichert, sondern bei jeder Anzeige neu gerechnet. Geht eine Strecke nicht auf, sagt die Ausgabe das. Behoben: die Reibungsbeiwerte lagen im ersten Wurf um den Faktor zehn daneben. Aufgefallen ist es nur, weil die Tests gegen die Tabellenwerte der Ausbildung pruefen und nicht gegen die eigene Implementierung. Dazu ein Abruf fuer abgelegte Dateien (/api/dateien/[id]) — bis hierher gab es nur den Upload. 59 neue Tests (48 Unit, 11 E2E je Browser). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
394 lines
14 KiB
YAML
394 lines
14 KiB
YAML
# =============================================================================
|
|
# FLORIAN — Produktionsnaher Stack.
|
|
# Für die Entwicklung wird docker-compose.override.yml automatisch daraufgelegt;
|
|
# die veröffentlicht die Dienste direkt auf localhost und braucht kein Traefik.
|
|
# Produktion starten: docker compose -f docker-compose.yml up -d
|
|
# Entwicklung starten: docker compose up -d
|
|
# =============================================================================
|
|
|
|
name: florian
|
|
|
|
x-app-env: &app-env
|
|
NODE_ENV: ${NODE_ENV:-production}
|
|
TZ: ${TZ:-Europe/Berlin}
|
|
APP_URL: ${APP_URL:-http://localhost:3000}
|
|
DATABASE_URL: ${DATABASE_URL}
|
|
DIRECT_DATABASE_URL: ${DIRECT_DATABASE_URL}
|
|
REDIS_URL: ${REDIS_URL}
|
|
WORKER_CONCURRENCY: ${WORKER_CONCURRENCY:-5}
|
|
S3_ENDPOINT: ${S3_ENDPOINT:-minio}
|
|
S3_PORT: ${S3_PORT:-9000}
|
|
S3_USE_SSL: ${S3_USE_SSL:-false}
|
|
S3_ACCESS_KEY: ${S3_ACCESS_KEY}
|
|
S3_SECRET_KEY: ${S3_SECRET_KEY}
|
|
S3_REGION: ${S3_REGION:-eu-central-1}
|
|
S3_BUCKET: ${S3_BUCKET:-florian}
|
|
S3_PUBLIC_URL: ${S3_PUBLIC_URL}
|
|
S3_URL_TTL: ${S3_URL_TTL:-900}
|
|
GOTENBERG_URL: ${GOTENBERG_URL:-http://gotenberg:3000}
|
|
GOTENBERG_TIMEOUT: ${GOTENBERG_TIMEOUT:-30}
|
|
AUTH_SECRET: ${AUTH_SECRET}
|
|
AUTH_TRUST_HOST: ${AUTH_TRUST_HOST:-true}
|
|
AUTH_SESSION_MAX_AGE: ${AUTH_SESSION_MAX_AGE:-28800}
|
|
AUTH_OIDC_ENABLED: ${AUTH_OIDC_ENABLED:-false}
|
|
AUTH_OIDC_ISSUER: ${AUTH_OIDC_ISSUER:-}
|
|
AUTH_OIDC_CLIENT_ID: ${AUTH_OIDC_CLIENT_ID:-}
|
|
AUTH_OIDC_CLIENT_SECRET: ${AUTH_OIDC_CLIENT_SECRET:-}
|
|
AUTH_OIDC_NAME: ${AUTH_OIDC_NAME:-authentik}
|
|
AUTH_OIDC_GROUP_CLAIM: ${AUTH_OIDC_GROUP_CLAIM:-groups}
|
|
AUTH_OIDC_GROUP_MAPPING: ${AUTH_OIDC_GROUP_MAPPING:-}
|
|
AUTH_OIDC_AUTO_PROVISION: ${AUTH_OIDC_AUTO_PROVISION:-true}
|
|
AUTH_OIDC_STANDARD_ORGANISATION: ${AUTH_OIDC_STANDARD_ORGANISATION:-}
|
|
AUTH_LOCAL_ENABLED: ${AUTH_LOCAL_ENABLED:-true}
|
|
AUTH_BCRYPT_ROUNDS: ${AUTH_BCRYPT_ROUNDS:-12}
|
|
KIOSK_ENABLED: ${KIOSK_ENABLED:-true}
|
|
KIOSK_DEVICE_SECRET: ${KIOSK_DEVICE_SECRET}
|
|
KIOSK_PIN_LAENGE: ${KIOSK_PIN_LAENGE:-6}
|
|
KIOSK_MAX_FEHLVERSUCHE: ${KIOSK_MAX_FEHLVERSUCHE:-5}
|
|
KIOSK_SPERRDAUER: ${KIOSK_SPERRDAUER:-300}
|
|
KIOSK_IDLE_TIMEOUT: ${KIOSK_IDLE_TIMEOUT:-120}
|
|
KARTE_STIL_URL: ${KARTE_STIL_URL:-}
|
|
KARTE_BREITENGRAD: ${KARTE_BREITENGRAD:-48.5}
|
|
KARTE_LAENGENGRAD: ${KARTE_LAENGENGRAD:-12.15}
|
|
KARTE_ZOOM: ${KARTE_ZOOM:-13}
|
|
ATEMSCHUTZ_HINWEIS: ${ATEMSCHUTZ_HINWEIS:-true}
|
|
ATEMSCHUTZ_FREIGABE: ${ATEMSCHUTZ_FREIGABE:-}
|
|
ATEMSCHUTZ_ABFRAGE_INTERVALL: ${ATEMSCHUTZ_ABFRAGE_INTERVALL:-600}
|
|
ATEMSCHUTZ_WARNDRUCK: ${ATEMSCHUTZ_WARNDRUCK:-55}
|
|
ATEMSCHUTZ_ZUSCHLAG: ${ATEMSCHUTZ_ZUSCHLAG:-10}
|
|
WETTER_ENABLED: ${WETTER_ENABLED:-false}
|
|
DWD_WARNCELL: ${DWD_WARNCELL:-}
|
|
DWD_WARNUNGEN_URL: ${DWD_WARNUNGEN_URL:-https://www.dwd.de/DWD/warnungen/warnapp/json/warnings.json}
|
|
WETTER_TIMEOUT: ${WETTER_TIMEOUT:-5}
|
|
WETTER_ZWISCHENSPEICHER: ${WETTER_ZWISCHENSPEICHER:-300}
|
|
MAIL_ENABLED: ${MAIL_ENABLED:-false}
|
|
MAIL_HOST: ${MAIL_HOST:-}
|
|
MAIL_PORT: ${MAIL_PORT:-587}
|
|
MAIL_SECURE: ${MAIL_SECURE:-false}
|
|
MAIL_USER: ${MAIL_USER:-}
|
|
MAIL_PASSWORD: ${MAIL_PASSWORD:-}
|
|
MAIL_FROM: ${MAIL_FROM:-}
|
|
TELEGRAM_ENABLED: ${TELEGRAM_ENABLED:-false}
|
|
TELEGRAM_BOT_TOKEN: ${TELEGRAM_BOT_TOKEN:-}
|
|
TELEGRAM_CHAT_ID: ${TELEGRAM_CHAT_ID:-}
|
|
LLM_ENABLED: ${LLM_ENABLED:-false}
|
|
LLM_BASE_URL: ${LLM_BASE_URL:-}
|
|
LLM_API_KEY: ${LLM_API_KEY:-}
|
|
LLM_MODELL: ${LLM_MODELL:-}
|
|
GEOCODING_ENABLED: ${GEOCODING_ENABLED:-false}
|
|
NOMINATIM_URL: ${NOMINATIM_URL:-}
|
|
GEOCODING_TIMEOUT: ${GEOCODING_TIMEOUT:-5}
|
|
GEOCODING_LAENDER: ${GEOCODING_LAENDER:-de,at,ch}
|
|
PORTAL_ENABLED: ${PORTAL_ENABLED:-true}
|
|
PORTAL_GUELTIGKEIT_STUNDEN: ${PORTAL_GUELTIGKEIT_STUNDEN:-24}
|
|
PORTAL_MAX_ZUGRIFFE: ${PORTAL_MAX_ZUGRIFFE:-120}
|
|
WARTUNG_NACHTLAUF: ${WARTUNG_NACHTLAUF:-15 3 * * *}
|
|
WARTUNG_ERINNERUNG_LAUF: ${WARTUNG_ERINNERUNG_LAUF:-45 3 * * *}
|
|
WARTUNG_PLANUNGSHORIZONT_TAGE: ${WARTUNG_PLANUNGSHORIZONT_TAGE:-180}
|
|
AUFBEWAHRUNG_JAHRE: ${AUFBEWAHRUNG_JAHRE:-10}
|
|
AUDIT_AUFBEWAHRUNG_TAGE: ${AUDIT_AUFBEWAHRUNG_TAGE:-3650}
|
|
PERSON_ANONYMISIERUNG_TAGE: ${PERSON_ANONYMISIERUNG_TAGE:-365}
|
|
NEXT_TELEMETRY_DISABLED: "1"
|
|
|
|
x-logging: &logging
|
|
driver: json-file
|
|
options:
|
|
max-size: "10m"
|
|
max-file: "5"
|
|
|
|
services:
|
|
# ---------------------------------------------------------------------------
|
|
# Reverse Proxy. Terminiert TLS, verteilt auf App und MinIO.
|
|
# ---------------------------------------------------------------------------
|
|
traefik:
|
|
image: traefik:v3.3
|
|
container_name: florian-traefik
|
|
restart: unless-stopped
|
|
command:
|
|
- --providers.docker=true
|
|
- --providers.docker.exposedByDefault=false
|
|
- --providers.docker.network=florian-edge
|
|
- --entrypoints.web.address=:80
|
|
- --entrypoints.web.http.redirections.entryPoint.to=websecure
|
|
- --entrypoints.web.http.redirections.entryPoint.scheme=https
|
|
- --entrypoints.websecure.address=:443
|
|
- --certificatesresolvers.le.acme.email=${ACME_EMAIL}
|
|
- --certificatesresolvers.le.acme.storage=/acme/acme.json
|
|
- --certificatesresolvers.le.acme.tlschallenge=true
|
|
# Kein anonymes Nutzungs-Reporting.
|
|
- --global.sendAnonymousUsage=false
|
|
- --global.checkNewVersion=false
|
|
- --log.level=INFO
|
|
- --accesslog=true
|
|
ports:
|
|
- "80:80"
|
|
- "443:443"
|
|
volumes:
|
|
# Nur lesend — Traefik braucht die Socket-Verbindung zur Diensterkennung.
|
|
- /var/run/docker.sock:/var/run/docker.sock:ro
|
|
- traefik-acme:/acme
|
|
networks:
|
|
- edge
|
|
logging: *logging
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# PostgreSQL 17 mit PostGIS. Einziger Ort für relationale Daten.
|
|
# ---------------------------------------------------------------------------
|
|
postgres:
|
|
image: postgis/postgis:17-3.5
|
|
container_name: florian-postgres
|
|
restart: unless-stopped
|
|
environment:
|
|
POSTGRES_USER: ${POSTGRES_USER:-florian}
|
|
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
|
|
POSTGRES_DB: ${POSTGRES_DB:-florian}
|
|
POSTGRES_APP_PASSWORD: ${POSTGRES_APP_PASSWORD}
|
|
# Deutsche Sortierung ueber ICU — das Debian-Basisimage hat keine
|
|
# de_DE-Locale erzeugt, ICU liefert sie unabhaengig vom Betriebssystem.
|
|
POSTGRES_INITDB_ARGS: "--locale-provider=icu --icu-locale=de-DE --locale=C.UTF-8 --encoding=UTF8"
|
|
TZ: ${TZ:-Europe/Berlin}
|
|
volumes:
|
|
- postgres-data:/var/lib/postgresql/data
|
|
# Legt beim ERSTEN Start den RLS-pflichtigen Anwendungsbenutzer an.
|
|
- ./docker/postgres:/docker-entrypoint-initdb.d:ro
|
|
healthcheck:
|
|
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-florian} -d ${POSTGRES_DB:-florian}"]
|
|
interval: 10s
|
|
timeout: 5s
|
|
retries: 10
|
|
start_period: 30s
|
|
networks:
|
|
- intern
|
|
logging: *logging
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Redis. Warteschlangen (BullMQ) und Rate-Limiting. Keine Nutzdaten.
|
|
# ---------------------------------------------------------------------------
|
|
redis:
|
|
image: redis:7-alpine
|
|
container_name: florian-redis
|
|
restart: unless-stopped
|
|
command:
|
|
- redis-server
|
|
- --requirepass
|
|
- ${REDIS_PASSWORD}
|
|
- --appendonly
|
|
- "yes"
|
|
- --maxmemory-policy
|
|
- noeviction
|
|
volumes:
|
|
- redis-data:/data
|
|
healthcheck:
|
|
test: ["CMD", "redis-cli", "-a", "${REDIS_PASSWORD}", "ping"]
|
|
interval: 10s
|
|
timeout: 5s
|
|
retries: 10
|
|
start_period: 10s
|
|
networks:
|
|
- intern
|
|
logging: *logging
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# MinIO. Ablage ALLER Dateien — Fotos, Prüfprotokolle, Rechnungen, Exporte.
|
|
# In Produktion auf ein festes RELEASE-Tag pinnen.
|
|
# ---------------------------------------------------------------------------
|
|
minio:
|
|
image: minio/minio:latest
|
|
container_name: florian-minio
|
|
restart: unless-stopped
|
|
command: server /data --console-address ":9001"
|
|
environment:
|
|
MINIO_ROOT_USER: ${MINIO_ROOT_USER}
|
|
MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD}
|
|
MINIO_BROWSER_REDIRECT_URL: https://${MINIO_CONSOLE_DOMAIN}
|
|
MINIO_SERVER_URL: https://${MINIO_DOMAIN}
|
|
MINIO_UPDATE: "off"
|
|
TZ: ${TZ:-Europe/Berlin}
|
|
volumes:
|
|
- minio-data:/data
|
|
healthcheck:
|
|
test: ["CMD", "mc", "ready", "local"]
|
|
interval: 10s
|
|
timeout: 5s
|
|
retries: 10
|
|
start_period: 20s
|
|
networks:
|
|
- intern
|
|
- edge
|
|
labels:
|
|
- traefik.enable=true
|
|
# S3-API
|
|
- traefik.http.routers.minio-api.rule=Host(`${MINIO_DOMAIN}`)
|
|
- traefik.http.routers.minio-api.entrypoints=${TRAEFIK_ENTRYPOINT:-websecure}
|
|
- traefik.http.routers.minio-api.tls.certresolver=le
|
|
- traefik.http.routers.minio-api.service=minio-api
|
|
- traefik.http.services.minio-api.loadbalancer.server.port=9000
|
|
# Weboberfläche
|
|
- traefik.http.routers.minio-console.rule=Host(`${MINIO_CONSOLE_DOMAIN}`)
|
|
- traefik.http.routers.minio-console.entrypoints=${TRAEFIK_ENTRYPOINT:-websecure}
|
|
- traefik.http.routers.minio-console.tls.certresolver=le
|
|
- traefik.http.routers.minio-console.service=minio-console
|
|
- traefik.http.services.minio-console.loadbalancer.server.port=9001
|
|
logging: *logging
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Einmaliger Job: legt den Bucket an und setzt ihn auf privat.
|
|
# ---------------------------------------------------------------------------
|
|
minio-init:
|
|
image: minio/mc:latest
|
|
container_name: florian-minio-init
|
|
depends_on:
|
|
minio:
|
|
condition: service_healthy
|
|
entrypoint: /bin/sh
|
|
command:
|
|
- -c
|
|
- |
|
|
mc alias set florian http://minio:9000 "$${MINIO_ROOT_USER}" "$${MINIO_ROOT_PASSWORD}" &&
|
|
mc mb --ignore-existing florian/"$${S3_BUCKET}" &&
|
|
mc anonymous set none florian/"$${S3_BUCKET}" &&
|
|
mc version enable florian/"$${S3_BUCKET}" &&
|
|
echo "Bucket $${S3_BUCKET} bereit."
|
|
environment:
|
|
MINIO_ROOT_USER: ${MINIO_ROOT_USER}
|
|
MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD}
|
|
S3_BUCKET: ${S3_BUCKET:-florian}
|
|
networks:
|
|
- intern
|
|
restart: "no"
|
|
logging: *logging
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Gotenberg. Rendert HTML zu PDF (Prüfprotokolle, Berichte).
|
|
# ---------------------------------------------------------------------------
|
|
gotenberg:
|
|
image: gotenberg/gotenberg:8
|
|
container_name: florian-gotenberg
|
|
restart: unless-stopped
|
|
command:
|
|
- gotenberg
|
|
- --chromium-disable-javascript=false
|
|
- --chromium-allow-list=file:///tmp/.*
|
|
- --api-timeout=${GOTENBERG_TIMEOUT:-30}s
|
|
environment:
|
|
TZ: ${TZ:-Europe/Berlin}
|
|
networks:
|
|
- intern
|
|
logging: *logging
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Kartenserver (Phase 6).
|
|
#
|
|
# Nur mit Profil: "docker compose --profile karte up -d". FLORIAN läuft ohne
|
|
# ihn — die Karte zeigt dann einen leeren Hintergrund, Hydranten und
|
|
# Lagekarte funktionieren trotzdem.
|
|
#
|
|
# Gebraucht wird eine MBTiles-Datei des Ausrückegebiets unter
|
|
# docker/karten/. Woher man sie bekommt, steht in der README; sie liegt
|
|
# bewusst nicht im Repository — das sind je nach Zuschnitt hunderte Megabyte
|
|
# bis Gigabyte, und jede Wehr braucht einen anderen Ausschnitt.
|
|
#
|
|
# Der Dienst hängt im internen Netz und im Edge-Netz: der Browser des
|
|
# Anwenders holt die Kacheln direkt, nicht über die Anwendung.
|
|
# ---------------------------------------------------------------------------
|
|
tileserver:
|
|
image: maptiler/tileserver-gl:v4.11.1
|
|
container_name: florian-tileserver
|
|
restart: unless-stopped
|
|
profiles: [karte]
|
|
command: ["--port", "8080", "--public_url", "${KARTE_OEFFENTLICH:-http://localhost:8085/}"]
|
|
volumes:
|
|
- ./docker/karten:/data:ro
|
|
ports:
|
|
- "${KARTE_PORT:-8085}:8080"
|
|
networks:
|
|
- edge
|
|
- intern
|
|
logging: *logging
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Die Anwendung selbst (Next.js, Standalone-Build).
|
|
# Führt vor dem Start die ausstehenden Prisma-Migrationen aus.
|
|
# ---------------------------------------------------------------------------
|
|
app:
|
|
build:
|
|
context: .
|
|
dockerfile: docker/Dockerfile
|
|
target: runner
|
|
image: florian-app:${IMAGE_TAG:-latest}
|
|
container_name: florian-app
|
|
restart: unless-stopped
|
|
environment:
|
|
<<: *app-env
|
|
PORT: "3000"
|
|
HOSTNAME: "0.0.0.0"
|
|
depends_on:
|
|
postgres:
|
|
condition: service_healthy
|
|
redis:
|
|
condition: service_healthy
|
|
minio:
|
|
condition: service_healthy
|
|
networks:
|
|
- intern
|
|
- edge
|
|
labels:
|
|
- traefik.enable=true
|
|
- traefik.http.routers.app.rule=Host(`${APP_DOMAIN}`)
|
|
- traefik.http.routers.app.entrypoints=${TRAEFIK_ENTRYPOINT:-websecure}
|
|
- traefik.http.routers.app.tls.certresolver=le
|
|
- traefik.http.services.app.loadbalancer.server.port=3000
|
|
logging: *logging
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Hintergrundjobs: Fristenberechnung, Erinnerungen, PDF-Erzeugung, Exporte.
|
|
# Gleiches Image wie die App, anderer Einstiegspunkt.
|
|
# ---------------------------------------------------------------------------
|
|
worker:
|
|
build:
|
|
context: .
|
|
dockerfile: docker/Dockerfile
|
|
target: runner
|
|
image: florian-app:${IMAGE_TAG:-latest}
|
|
container_name: florian-worker
|
|
restart: unless-stopped
|
|
# --conditions=react-server: lib/storage.ts und lib/pdf.ts sichern sich mit
|
|
# dem Paket "server-only" ab. Unter node wirft dessen Standardexport;
|
|
# mit dieser Auflösungsbedingung greift der leere react-server-Export,
|
|
# und der Worker darf dieselben Bausteine benutzen wie die Anwendung.
|
|
command: ["node", "--enable-source-maps", "--conditions=react-server", "dist/worker/index.js"]
|
|
environment:
|
|
<<: *app-env
|
|
ROLLE: worker
|
|
healthcheck:
|
|
test: ["CMD", "node", "-e", "process.exit(0)"]
|
|
interval: 30s
|
|
timeout: 5s
|
|
retries: 3
|
|
start_period: 20s
|
|
depends_on:
|
|
postgres:
|
|
condition: service_healthy
|
|
redis:
|
|
condition: service_healthy
|
|
app:
|
|
condition: service_started
|
|
networks:
|
|
- intern
|
|
logging: *logging
|
|
|
|
volumes:
|
|
postgres-data:
|
|
redis-data:
|
|
minio-data:
|
|
traefik-acme:
|
|
|
|
networks:
|
|
# Nur was von außen erreichbar sein muss, hängt hier drin.
|
|
edge:
|
|
name: florian-edge
|
|
# Datenbank, Redis, Gotenberg: intern, kein Zugang von außen.
|
|
intern:
|
|
name: florian-intern
|
|
internal: false
|