Phase 4: eine eigene Oberflaeche unter /kiosk fuer das Wandtablet in der Fahrzeughalle — mit Handschuhen bedienbar und arbeitsfaehig, wenn das Netz weg ist. Datenmodell: Fahrt, Anwesenheit, Waescheabgabe, Inventurgang, Inventurposten, KioskVorgang und SyncKonflikt. Die Kennung eines Vorgangs erzeugt der Browser beim Einreihen und ist damit der Idempotenzschluessel; kommt derselbe Vorgang nach abgebrochener Uebertragung erneut an, erkennt ihn der Server wieder. Die Datenbank setzt vier Regeln selbst durch und verweigert UPDATE und DELETE auf kiosk_vorgang. Offline: von Hand geschriebener Service Worker, Warteschlange in der IndexedDB (Dexie), Fotos als Blob daneben, sichtbarer Zustand als Leiste ueber allem. Was sich beim Nachtragen nicht anwenden laesst, landet als Konflikt unter /terminal statt verworfen zu werden. Dazu: Anmeldung per Ausweis, PIN oder signierter QR-Marke vom Handy, Hallenmonitor mit optionalen DWD-Warnungen, Installation als App. Behoben: die Warteschlange zog ihren Wiederholungstakt bei jedem Rendern neu auf und haette einen liegen gebliebenen Vorgang nie ein zweites Mal geschickt. Gefunden vom Offline-E2E-Test. 31 neue Tests (20 Unit, 11 gegen eine echte Datenbank, 9 E2E je Browser). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
355 lines
13 KiB
YAML
355 lines
13 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}
|
|
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
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 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
|