diff --git a/.env.coolify.example b/.env.coolify.example index 0e401c1..48c5ea7 100644 --- a/.env.coolify.example +++ b/.env.coolify.example @@ -1,7 +1,8 @@ # Referenz für die Coolify-Environment-Variablen (Testserver, intern). -# ECHTE Secrets NUR in Coolify eintragen –€” diese Datei enthält nur Platzhalter. +# ECHTE Secrets NUR in Coolify eintragen – diese Datei enthält nur Platzhalter. # In Coolify: Ressource -> Environment Variables (Bulk-Paste möglich). # Hostnamen sind die Compose-Service-Namen (postgres/redis/garage), NICHT localhost. +# Betriebsdoku: docs/craftvia/DEPLOY.md # --- Datenbank (Service "postgres") --- POSTGRES_USER=craftvia @@ -9,6 +10,12 @@ POSTGRES_PASSWORD=CHANGE_ME_db_password POSTGRES_DB=craftvia DATABASE_URL=postgresql://craftvia:CHANGE_ME_db_password@postgres:5432/craftvia?schema=public +# --- Row Level Security (F-04) --- +# Auf dem Testserver zunächst false (Owner-Betrieb). Zum Scharfschalten craftvia_app mit +# LOGIN + Passwort versehen (ALTER ROLE craftvia_app WITH LOGIN PASSWORD '';), dann: +RLS_ENFORCED=false +# RLS_DATABASE_URL=postgresql://craftvia_app:CHANGE_ME_app_password@postgres:5432/craftvia?schema=public + # --- Redis (Service "redis") --- # F-18: Redis läuft mit requirepass. NUR REDIS_PASSWORD setzen — REDIS_URL wird in der # docker-compose.coolify.yml daraus abgeleitet (redis://:${REDIS_PASSWORD}@redis:6379) @@ -44,29 +51,60 @@ GARAGE_ADMIN_TOKEN=CHANGE_ME_openssl_rand_hex_32 # `backups` auf /app/.backups (app + backup-worker) — Pfad hier NICHT aendern, ausser # der Mount wird angepasst. BACKUP_LOCAL_DIR=/app/.backups +# BACKUP_ENC_KEY= (leer = AUTH_SECRET) -# --- Auth (NextAuth) --- +# --- Auth (Auth.js v5) --- # AUTH_SECRET: openssl rand -base64 32 # AUTH_URL: exakt die Coolify-Domain des app-Service (http:// für intern) AUTH_SECRET=CHANGE_ME_openssl_rand_base64_32 # PASSWORD_PEPPER (Härtung §1): openssl rand -hex 32 — frisch je Umgebung, NICHT rotierbar, nie ins Artefakt. PASSWORD_PEPPER=CHANGE_ME_openssl_rand_hex_32 +# MFA_ENC_KEY= (leer = aus AUTH_SECRET abgeleitet; nach dem Setzen nicht mehr ändern) AUTH_URL=http://REPLACE-WITH-COOLIFY-SSLIP-DOMAIN # Hinter Reverse-Proxy (Coolify/Traefik) für Auth.js v5 zwingend, sonst UntrustedHost: AUTH_TRUST_HOST=true # --- Demo-Seed (NUR Testserver!) --- -# true => migrate-Job legt nach der Migration den Demo-Mandanten + Nutzer an +# true => migrate-Job legt nach der Migration die Demo-Mandanten + Nutzer an # (admin@demo.example / Demo1234!). In Produktion NICHT setzen / auf false lassen. RUN_DEMO_SEED=true -# --- KI-Provider (optional, aktuell ungenutzt) --- -AI_PROVIDER=anthropic -AI_API_KEY= - -# --- E-Mail (optional, im Test ungenutzt) --- +# --- E-Mail (optional; ohne SMTP bleiben Mails "pending") --- SMTP_HOST= SMTP_PORT=1025 +SMTP_SECURE= SMTP_USER= SMTP_PASSWORD= SMTP_FROM=craftvia@example.com +MAIL_FROM_NAME=Craftvia +MAIL_REPLY_TO= +# Basis für absolute Links in Mails/PDFs; leer = AUTH_URL. +APP_BASE_URL= + +# --- KI: Auftragsimport-Extraktion & Lotse (Anthropic, optional) --- +# Ohne ANTHROPIC_API_KEY: manuelle Erfassung, kein Lotse-Entwurf. +AI_EXTRACTION_PROVIDER=anthropic +ANTHROPIC_API_KEY= +ANTHROPIC_MODEL= + +# --- KI: Transkription (Whisper-kompatibel, optional) --- +TRANSCRIPTION_PROVIDER=openai-compatible +TRANSCRIPTION_API_URL=https://api.openai.com/v1/audio/transcriptions +TRANSCRIPTION_API_KEY= +TRANSCRIPTION_MODEL=whisper-1 + +# --- KI: Kostenbremse & Aufbewahrung KI-Protokoll --- +# Tokens je Mandant je Kalendermonat (ein+aus), 0 = unbegrenzt. +AI_MONTHLY_TOKEN_LIMIT=0 +# Ein-/Ausgaben im KI-Protokoll (AiGeneration) nach N Tagen leeren/pseudonymisieren. +AI_GENERATION_RETENTION_DAYS=180 + +# --- Craftvia: API-Rate-Limits (je Nutzer/Minute) --- +API_RATE_LIMIT_PER_MINUTE=300 +# /api/v1/sync, /api/v1/uploads, /api/v1/field/** +API_FIELD_RATE_LIMIT_PER_MINUTE=1200 + +# --- Craftvia: Offline/PWA & Malware-Scan --- +OFFLINE_MAX_DAYS=7 +CLAMAV_HOST= +CLAMAV_PORT=3310 diff --git a/.env.example b/.env.example index 56ffbf9..a81f2c4 100644 --- a/.env.example +++ b/.env.example @@ -101,5 +101,44 @@ TRANSCRIPTION_API_KEY= TRANSCRIPTION_MODEL=whisper-1 # --- Craftvia: optionaler Malware-Scan für Uploads (ClamAV clamd) --- +# Leer = nur Allowlist/Magic-Byte-Prüfung; gesetzt = zusätzlich clamd INSTREAM. CLAMAV_HOST= CLAMAV_PORT=3310 + +# --- Craftvia: Berichts-PDF (Worker, playwright-core + Chromium) --- +# Leer = Playwright-Chromium bzw. lokal installiertes Google Chrome (Entwicklerrechner). +# Im Docker-Worker-Image fest /usr/bin/chromium. +PDF_CHROMIUM_PATH= + +# --- Craftvia: Offline/PWA --- +# Ab wie vielen Tagen ein lokal gespeichertes Auftragsbundle als veraltet gilt (1–365). +OFFLINE_MAX_DAYS=7 + +# --- Craftvia: Rate Limits der REST-API (je Nutzer, Anfragen pro Minute) --- +# Allgemein für /api/v1/**. +API_RATE_LIMIT_PER_MINUTE=300 +# Einsatz-/Sync-Endpunkte (/api/v1/sync, /api/v1/uploads, /api/v1/field/**) – höher, weil +# die PWA nach Offline-Phasen Outbox und Fotos in Schüben nachsendet. +API_FIELD_RATE_LIMIT_PER_MINUTE=1200 + +# --- Craftvia: KI-Protokoll & Kostenbremse --- +# Nach N Tagen leert/pseudonymisiert ein Worker-Job Ein-/Ausgaben im KI-Protokoll +# (AiGeneration); Metadaten (Art, Modell, Tokens, Zeitpunkt) bleiben erhalten. +AI_GENERATION_RETENTION_DAYS=180 +# Tokens (ein + aus) je Mandant je Kalendermonat; darüber lehnen Lotse und +# Import-Extraktion ab. 0 = unbegrenzt. +AI_MONTHLY_TOKEN_LIMIT=0 + +# --- Optionale Fundament-Variablen (Default leer) --- +# MFA_ENC_KEY: Schlüssel für TOTP-Secrets at-rest (leer = aus AUTH_SECRET abgeleitet). +# ⚠ Nach dem Setzen nicht mehr ändern. +# MFA_ENC_KEY= +# BACKUP_ENC_KEY: Verschlüsselung der Backup-Artefakte (leer = AUTH_SECRET). +# BACKUP_ENC_KEY= +# WebAuthn/Passkeys: Origin und RP-ID (leer = aus AUTH_URL abgeleitet). +# WEBAUTHN_ORIGIN=http://localhost:3000 +# WEBAUTHN_RP_ID=localhost +# Demo-Seed-Passwort (Default Demo1234!). +# SEED_PASSWORD= +# RLS-Test hart statt Skip, wenn craftvia_app kein LOGIN hat (CI). +# RLS_TEST_REQUIRED=true diff --git a/.env.prod.example b/.env.prod.example index 7c60439..4d8b616 100644 --- a/.env.prod.example +++ b/.env.prod.example @@ -1,6 +1,7 @@ -# Referenz für die PRODUKTIV-Env-Variablen (Contabo-VPS + Coolify). -# ECHTE Secrets NUR in Coolify eintragen –€” diese Datei enthält nur Platzhalter. -# Unterschiede zum Testserver: HTTPS-AUTH_URL, KEIN Demo-Seed, stattdessen Bootstrap-Admin. +# Referenz für die PRODUKTIV-Env-Variablen (Coolify, docker-compose.coolify[.prebuilt].yml). +# ECHTE Secrets NUR in Coolify eintragen – diese Datei enthält nur Platzhalter. +# Unterschiede zum Testserver: HTTPS-AUTH_URL, KEIN Demo-Seed, stattdessen Bootstrap-Admin, +# RLS scharf. Betriebsdoku: docs/craftvia/DEPLOY.md # --- Datenbank (Service "postgres") --- POSTGRES_USER=craftvia @@ -9,68 +10,124 @@ POSTGRES_DB=craftvia DATABASE_URL=postgresql://craftvia:CHANGE_ME_starkes_db_passwort@postgres:5432/craftvia?schema=public # --- Row Level Security scharfschalten (F-04) --- -# RLS_ENFORCED=true –†’ die App verbindet sich als eingeschränkte Rolle craftvia_app -# (NOBYPASSRLS) und setzt app.tenant_id pro Transaktion; FORCE ROW LEVEL SECURITY -# macht die Policies dann scharf. Ist der Kontext nicht gesetzt, sieht craftvia_app -# NULL Zeilen –€” daher NUR mit korrekt gesetztem RLS_DATABASE_URL einschalten. +# RLS_ENFORCED=true – app und craftvia-worker verbinden sich als eingeschränkte Rolle +# craftvia_app (NOBYPASSRLS) und setzen app.tenant_id pro Transaktion; FORCE ROW LEVEL +# SECURITY macht die Policies dann scharf. Ist der Kontext nicht gesetzt, sieht craftvia_app +# NULL Zeilen – daher NUR mit korrekt gesetztem RLS_DATABASE_URL einschalten (sonst +# bricht die App beim Start bewusst ab). # WICHTIG: Die Owner-/Migrate-Rolle in DATABASE_URL MUSS BYPASSRLS/Superuser sein -# (Migrationen, Seed und der mandantenübergreifende Login-Lookup laufen darüber), -# sonst sähe der Login keine Nutzer. craftvia_app in Prod EINMALIG mit LOGIN + starkem -# Passwort versehen: ALTER ROLE craftvia_app WITH LOGIN PASSWORD ''; +# (Migrationen, Seed, Mail-/Backup-Worker und der mandantenübergreifende Login-Lookup +# laufen darüber). craftvia_app wird von der Baseline-Migration NOLOGIN angelegt und in +# Prod EINMALIG mit LOGIN + starkem Passwort versehen: +# ALTER ROLE craftvia_app WITH LOGIN PASSWORD ''; RLS_ENFORCED=true RLS_DATABASE_URL=postgresql://craftvia_app:CHANGE_ME_starkes_craftvia_app_passwort@postgres:5432/craftvia?schema=public -# --- Redis --- -# F-18: Redis läuft mit requirepass. REDIS_PASSWORD setzen (stark!) und identisch -# in die REDIS_URL einsetzen (redis://:@redis:6379). +# --- Redis (Service "redis") --- +# F-18: Redis läuft mit requirepass. NUR REDIS_PASSWORD setzen – REDIS_URL wird in den +# Coolify-Compose-Dateien daraus abgeleitet (redis://:${REDIS_PASSWORD}@redis:6379). REDIS_PASSWORD=CHANGE_ME_starkes_redis_passwort -REDIS_URL=redis://:CHANGE_ME_starkes_redis_passwort@redis:6379 -# --- Objektspeicher (MinIO) –€” S3_* muss zu MINIO_ROOT_* passen --- -S3_ENDPOINT=http://minio:9000 -S3_ACCESS_KEY=CHANGE_ME_GK_plus_24_hex -S3_SECRET_KEY=CHANGE_ME_starkes_minio_passwort +# --- Objektspeicher (Service "garage", S3-kompatibel) --- +# Bucket/Key legt der Init-Job "garage-provision" an (Admin-API). Format erzwungen: +# S3_ACCESS_KEY = "GK" + 24 Hex -> echo "GK$(openssl rand -hex 12)" +# S3_SECRET_KEY = 64 Hex -> openssl rand -hex 32 +S3_ENDPOINT=http://garage:3900 +S3_ACCESS_KEY=GK000000000000000000000000 +S3_SECRET_KEY=CHANGE_ME_openssl_rand_hex_32 S3_BUCKET=craftvia-documents -MINIO_ROOT_USER=craftvia -MINIO_ROOT_PASSWORD=CHANGE_ME_starkes_minio_passwort +S3_REGION=us-east-1 +# Garage-Daemon-Secrets (LITERAL in Coolify setzen, nicht via ${...}). +GARAGE_RPC_SECRET=CHANGE_ME_openssl_rand_hex_32 +GARAGE_ADMIN_TOKEN=CHANGE_ME_openssl_rand_hex_32 +# GARAGE_ZONE=dc1 +# GARAGE_CAPACITY_BYTES=100000000000 # --- Backup-Zielspeicher (optional) --- # Ziel der Backup-/DSGVO-Artefakte ist im Betreiber-Portal (/admin/backup) waehlbar # (Lokal/S3) und wird verschluesselt in der DB gehalten. Praezedenz: DB-Config → -# Env (S3_*/BACKUP_LOCAL_DIR) → lokaler Default. Sobald im Portal gespeichert, hat -# die DB-Config Vorrang. Fuer „Lokal" auf ein gemountetes, persistentes Volume zeigen. +# Env (S3_*/BACKUP_LOCAL_DIR) → lokaler Default. Fuer „Lokal" mountet die Compose-Datei +# das persistente Volume `backups` auf /app/.backups (app + backup-worker). BACKUP_LOCAL_DIR=/app/.backups +# Optionaler eigener Backup-Bucket (nur wenn der Backup-Store auf S3 laeuft). +# BACKUP_S3_BUCKET= +# Verschluesselung der Backup-Artefakte (AES-256-GCM); leer = AUTH_SECRET. Je Umgebung +# eigener Wert, alte Keys bis Retention-Ende aufbewahren. +BACKUP_ENC_KEY=CHANGE_ME_openssl_rand_hex_32 -# --- Auth (NextAuth) –€” Produktiv über HTTPS --- +# --- Auth (Auth.js v5) – Produktiv über HTTPS --- # AUTH_SECRET: openssl rand -base64 32 (frisch, NICHT der Testwert) AUTH_SECRET=CHANGE_ME_openssl_rand_base64_32 # PASSWORD_PEPPER (Härtung §1): openssl rand -hex 32 — frisch je Umgebung, NICHT rotierbar, nie ins Artefakt. PASSWORD_PEPPER=CHANGE_ME_openssl_rand_hex_32 -AUTH_URL=https://app.craftvia.de -# AUTH_TRUST_HOST ist im Compose fest auf true (hinter dem Coolify-Proxy) –€” nicht nötig. +# MFA_ENC_KEY: TOTP-Secrets at-rest (leer = aus AUTH_SECRET). ⚠ Nach dem Setzen nicht mehr ändern. +MFA_ENC_KEY=CHANGE_ME_openssl_rand_hex_32 +AUTH_URL=https://app.craftvia.example +# AUTH_TRUST_HOST ist im Compose fest auf true (hinter dem Coolify-Proxy) – nicht nötig. +# Passkeys/WebAuthn: leer = aus AUTH_URL abgeleitet. +# WEBAUTHN_ORIGIN=https://app.craftvia.example +# WEBAUTHN_RP_ID=app.craftvia.example -# --- KI-Provider (optional) --- -AI_PROVIDER=anthropic -AI_API_KEY= - -# --- E-Mail (produktives SMTP-Relay, sobald Einladungs-/Mailflow aktiv) --- +# --- E-Mail (produktives SMTP-Relay; SPF/DKIM/DMARC der Absenderdomain vorher einrichten) --- SMTP_HOST= SMTP_PORT=587 +# true = implizites TLS (465), false = STARTTLS (587); leer = aus Port abgeleitet. +SMTP_SECURE= SMTP_USER= SMTP_PASSWORD= -SMTP_FROM=noreply@craftvia.de +SMTP_FROM=no-reply@craftvia.example +MAIL_FROM_NAME=Craftvia +MAIL_REPLY_TO= +# Basis für absolute Links in Mails/PDFs; leer = AUTH_URL. +APP_BASE_URL=https://app.craftvia.example + +# --- KI: Auftragsimport-Extraktion & Lotse (Anthropic) --- +# Ohne ANTHROPIC_API_KEY: graceful degradation (manuelle Erfassung, kein Lotse-Entwurf). +AI_EXTRACTION_PROVIDER=anthropic +ANTHROPIC_API_KEY= +# Leer = Default-Modell aus src/server/ai/client.ts +ANTHROPIC_MODEL= + +# --- KI: Transkription von Sprachnotizen (Whisper-kompatible API) --- +# Ohne TRANSCRIPTION_API_KEY bleibt die Transkription deaktiviert (Status "disabled"). +TRANSCRIPTION_PROVIDER=openai-compatible +TRANSCRIPTION_API_URL=https://api.openai.com/v1/audio/transcriptions +TRANSCRIPTION_API_KEY= +TRANSCRIPTION_MODEL=whisper-1 + +# --- KI: Kostenbremse & Aufbewahrung --- +# Tokens (ein + aus) je Mandant je Kalendermonat; darüber lehnen Lotse und +# Import-Extraktion ab. 0 = unbegrenzt. +AI_MONTHLY_TOKEN_LIMIT=0 +# Nach N Tagen leert/pseudonymisiert ein Worker-Job Ein-/Ausgaben im KI-Protokoll +# (AiGeneration). Frist mit dem DSB abstimmen. +AI_GENERATION_RETENTION_DAYS=180 + +# --- Craftvia: API-Rate-Limits (je Nutzer, Anfragen pro Minute) --- +API_RATE_LIMIT_PER_MINUTE=300 +# /api/v1/sync, /api/v1/uploads, /api/v1/field/** +API_FIELD_RATE_LIMIT_PER_MINUTE=1200 + +# --- Craftvia: Offline/PWA --- +OFFLINE_MAX_DAYS=7 + +# --- Craftvia: optionaler Malware-Scan (ClamAV clamd) --- +CLAMAV_HOST= +CLAMAV_PORT=3310 + +# PDF_CHROMIUM_PATH ist im Worker-Image/Compose fest /usr/bin/chromium – nicht setzen. # --- Demo-Seed: in PROD AUS lassen! --- RUN_DEMO_SEED=false -# --- Erst-Superadmin-Bootstrap (statt Demo-Seed) --- -# Beim ersten Deploy true setzen -> migrate-Job legt Admin + Mandant an (idempotent). -# Danach kann true bleiben (tut nichts, wenn der Admin existiert) oder auf false. +# --- Erst-Admin-Bootstrap (statt Demo-Seed) --- +# Beim ersten Deploy true setzen -> migrate-Job legt Plattform-Admin + ersten Mandanten an +# (idempotent). Danach auf false setzen oder stehen lassen (No-op, wenn vorhanden). BOOTSTRAP_ADMIN=true -BOOTSTRAP_ADMIN_EMAIL=admin@craftvia.de +BOOTSTRAP_ADMIN_EMAIL=admin@craftvia.example BOOTSTRAP_ADMIN_PASSWORD=CHANGE_ME_initiales_admin_passwort BOOTSTRAP_ADMIN_NAME=Craftvia Admin -BOOTSTRAP_TENANT_NAME=Craftvia -BOOTSTRAP_TENANT_SLUG=craftvia -BOOTSTRAP_TENANT_SHORT=Craftvia +BOOTSTRAP_TENANT_NAME=Musterbetrieb GmbH +BOOTSTRAP_TENANT_SLUG=musterbetrieb +BOOTSTRAP_TENANT_SHORT=Musterbetrieb BOOTSTRAP_TENANT_SECTOR= diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index 252e96d..011a3d6 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -13,8 +13,50 @@ on: branches: ["main", "dev", "dev-*"] jobs: - build-and-check: + # Vollständiges Qualitäts-Gate (= `npm run gate`) gegen echte Infrastruktur: + # Postgres 16 mit pgvector + Redis als Service-Container. Garage/S3 wird bewusst + # weggelassen: der Storage-Adapter fällt ohne S3_* auf den Stub zurück, S3-abhängige + # Prüfungen (test-garage-storage, Byte-Abruf in test-einsatz-sync/test-berichte-pdf) + # überspringen sich. Ohne SMTP_HOST überspringt test-mail den echten Versand, ohne + # ANTHROPIC_API_KEY/TRANSCRIPTION_API_KEY die Live-KI-Tests. test-berichte-pdf + # überspringt sich, wenn im Runner-Image kein Chromium/Chrome startbar ist. + gate: runs-on: ubuntu-latest + timeout-minutes: 60 + services: + postgres: + image: pgvector/pgvector:0.8.0-pg16 + env: + POSTGRES_USER: craftvia + POSTGRES_PASSWORD: craftvia + POSTGRES_DB: craftvia + options: >- + --health-cmd "pg_isready -U craftvia" + --health-interval 5s + --health-timeout 5s + --health-retries 20 + redis: + image: redis:7.4.2-alpine + options: >- + --health-cmd "redis-cli ping" + --health-interval 5s + --health-timeout 5s + --health-retries 20 + # Nur CI-Dummywerte (keine echten Secrets). Gitea act_runner führt den Job in einem + # Container im selben Netz wie die Services aus → Hostnamen = Service-Namen + # (postgres/redis), NICHT localhost. Läuft der Runner im Host-Modus + # (Label ubuntu-latest:host), Hosts auf localhost umstellen und ports: ergänzen. + env: + DATABASE_URL: "postgresql://craftvia:craftvia@postgres:5432/craftvia?schema=public" + # RLS-Test (scripts/test-rls-enforcement.ts): RLS_ENFORCED bleibt aus, der Test schaltet + # selbst scharf. RLS_TEST_REQUIRED=true macht aus dem Skip einen harten Fehler. + RLS_DATABASE_URL: "postgresql://craftvia_app:craftvia_app_ci@postgres:5432/craftvia?schema=public" + RLS_TEST_REQUIRED: "true" + REDIS_URL: "redis://redis:6379" + AUTH_SECRET: "ci-dummy-auth-secret-0000000000000000" + AUTH_URL: "http://localhost:3000" + APP_BASE_URL: "http://localhost:3000" + PASSWORD_PEPPER: "0000000000000000000000000000000000000000000000000000000000000abc" steps: - name: Checkout uses: actions/checkout@v4 @@ -34,11 +76,20 @@ jobs: run: npm ci --include=optional --no-audit --no-fund - name: Prisma Client generieren - # Platzhalter-URL nur fürs Laden von prisma.config.ts — keine echte DB-Verbindung. - env: - DATABASE_URL: "postgresql://build:build@localhost:5432/build?schema=public" run: npx prisma generate + - name: Migrationen anwenden + run: npx prisma migrate deploy + + - name: Demo-Seed + run: npx prisma db seed + + # Die Baseline-Migration legt craftvia_app NOLOGIN an; das Passwort ist ein Betriebs- + # Secret und wird nie migriert. Für den RLS-Test hier ein CI-Dummy-Passwort setzen + # (über die Prisma-Config-Datasource, damit kein psql-Client im Runner nötig ist). + - name: RLS-Rolle craftvia_app mit LOGIN versehen + run: echo "ALTER ROLE craftvia_app WITH LOGIN PASSWORD 'craftvia_app_ci';" | npx prisma db execute --stdin + - name: Typprüfung (tsc --noEmit) run: npx tsc --noEmit @@ -46,10 +97,11 @@ jobs: run: npm run lint - name: Build - env: - DATABASE_URL: "postgresql://build:build@localhost:5432/build?schema=public" run: npm run build + - name: Tests (scripts/test-*.ts) + run: npm run test + audit: runs-on: ubuntu-latest steps: @@ -103,7 +155,7 @@ jobs: # Lockfile die @swc/helpers-Inkonsistenz trägt (siehe Dockerfile / Folgeänderung # der Dependency-Lane), bricht der Schritt mit ESBOMPROBLEMS ab — daher # continue-on-error. Alternative ohne npm-Baum-Validierung: Syft gegen das - # gebaute Image (siehe docs/DEPLOY-PROD-CONTABO.md, Abschnitt SBOM). + # gebaute Image (siehe docs/_certvia-archiv/DEPLOY-PROD-CONTABO.md, Abschnitt SBOM). - name: SBOM erzeugen (CycloneDX) continue-on-error: true run: npm sbom --sbom-format cyclonedx --omit dev > sbom.cyclonedx.json diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 644ec8f..11a983a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -13,8 +13,51 @@ on: branches: ["main", "dev", "dev-*"] jobs: - build-and-check: + # Vollständiges Qualitäts-Gate (= `npm run gate`) gegen echte Infrastruktur: + # Postgres 16 mit pgvector + Redis als Service-Container. Garage/S3 wird bewusst + # weggelassen: der Storage-Adapter fällt ohne S3_* auf den Stub zurück, S3-abhängige + # Prüfungen (test-garage-storage, Byte-Abruf in test-einsatz-sync/test-berichte-pdf) + # überspringen sich. Ohne SMTP_HOST überspringt test-mail den echten Versand, ohne + # ANTHROPIC_API_KEY/TRANSCRIPTION_API_KEY die Live-KI-Tests. test-berichte-pdf nutzt + # Google Chrome des Runners (channel "chrome") oder überspringt sich ohne Browser. + gate: runs-on: ubuntu-latest + timeout-minutes: 60 + services: + postgres: + image: pgvector/pgvector:0.8.0-pg16 + env: + POSTGRES_USER: craftvia + POSTGRES_PASSWORD: craftvia + POSTGRES_DB: craftvia + ports: + - 5432:5432 + options: >- + --health-cmd "pg_isready -U craftvia" + --health-interval 5s + --health-timeout 5s + --health-retries 20 + redis: + image: redis:7.4.2-alpine + ports: + - 6379:6379 + options: >- + --health-cmd "redis-cli ping" + --health-interval 5s + --health-timeout 5s + --health-retries 20 + # Nur CI-Dummywerte (keine echten Secrets). GitHub-Hosted-Runner: Service-Ports auf localhost. + env: + DATABASE_URL: "postgresql://craftvia:craftvia@localhost:5432/craftvia?schema=public" + # RLS-Test (scripts/test-rls-enforcement.ts): RLS_ENFORCED bleibt aus, der Test schaltet + # selbst scharf. RLS_TEST_REQUIRED=true macht aus dem Skip einen harten Fehler. + RLS_DATABASE_URL: "postgresql://craftvia_app:craftvia_app_ci@localhost:5432/craftvia?schema=public" + RLS_TEST_REQUIRED: "true" + REDIS_URL: "redis://localhost:6379" + AUTH_SECRET: "ci-dummy-auth-secret-0000000000000000" + AUTH_URL: "http://localhost:3000" + APP_BASE_URL: "http://localhost:3000" + PASSWORD_PEPPER: "0000000000000000000000000000000000000000000000000000000000000abc" steps: - name: Checkout uses: actions/checkout@v4 @@ -34,10 +77,20 @@ jobs: run: npm ci --include=optional --no-audit --no-fund - name: Prisma Client generieren - env: - DATABASE_URL: "postgresql://build:build@localhost:5432/build?schema=public" run: npx prisma generate + - name: Migrationen anwenden + run: npx prisma migrate deploy + + - name: Demo-Seed + run: npx prisma db seed + + # Die Baseline-Migration legt craftvia_app NOLOGIN an; das Passwort ist ein Betriebs- + # Secret und wird nie migriert. Für den RLS-Test hier ein CI-Dummy-Passwort setzen + # (über die Prisma-Config-Datasource, damit kein psql-Client nötig ist). + - name: RLS-Rolle craftvia_app mit LOGIN versehen + run: echo "ALTER ROLE craftvia_app WITH LOGIN PASSWORD 'craftvia_app_ci';" | npx prisma db execute --stdin + - name: Typprüfung (tsc --noEmit) run: npx tsc --noEmit @@ -45,10 +98,11 @@ jobs: run: npm run lint - name: Build - env: - DATABASE_URL: "postgresql://build:build@localhost:5432/build?schema=public" run: npm run build + - name: Tests (scripts/test-*.ts) + run: npm run test + audit: runs-on: ubuntu-latest steps: @@ -100,7 +154,7 @@ jobs: # Lockfile die @swc/helpers-Inkonsistenz trägt (siehe Dockerfile / Folgeänderung # der Dependency-Lane), bricht der Schritt mit ESBOMPROBLEMS ab — daher # continue-on-error. Alternative ohne npm-Baum-Validierung: Syft gegen das - # gebaute Image (siehe docs/DEPLOY-PROD-CONTABO.md, Abschnitt SBOM). + # gebaute Image (siehe docs/_certvia-archiv/DEPLOY-PROD-CONTABO.md, Abschnitt SBOM). - name: SBOM erzeugen (CycloneDX) continue-on-error: true run: npm sbom --sbom-format cyclonedx --omit dev > sbom.cyclonedx.json diff --git a/Dockerfile b/Dockerfile index 1c50886..1f87b0a 100644 --- a/Dockerfile +++ b/Dockerfile @@ -123,7 +123,11 @@ COPY src ./src COPY messages ./messages ENV DATABASE_URL="postgresql://build:build@localhost:5432/build?schema=public" RUN npx prisma generate +# Chromium legt beim Start ein Profil-/Crashpad-Verzeichnis unter $HOME an. /app gehört root +# → als User "app" bricht der Start mit "Failed to create headless user data directory" ab. +# Daher eigenes, beschreibbares Home-Verzeichnis für den non-root-User. RUN groupadd --system --gid 1001 app \ - && useradd --system --uid 1001 --gid app --home-dir /app app + && useradd --system --uid 1001 --gid app --home-dir /home/app --create-home app +ENV HOME=/home/app USER app CMD ["npx", "tsx", "scripts/craftvia-worker.ts"] diff --git a/README.md b/README.md index 1135299..62384ea 100644 --- a/README.md +++ b/README.md @@ -46,7 +46,8 @@ Login: `admin@demo.example` / `Demo1234!` (weitere Demo-Nutzer siehe AGENTS.md). ## Betrieb -Container-Build über das Multi-Stage-`Dockerfile` (Targets `runner`, `migrate`, `garage`), +Container-Build über das Multi-Stage-`Dockerfile` (Targets `runner`, `migrate`, `garage`, `worker`), Deployment mit `docker-compose.coolify.yml` bzw. `docker-compose.coolify.prebuilt.yml`. -Hinweise: `docs/DEPLOY-COOLIFY.md`, `docs/SECRETS-REGISTER.md` (aus dem Fundament übernommen, -Namen teils noch Certvia). +Hinweise: [`docs/craftvia/DEPLOY.md`](docs/craftvia/DEPLOY.md) (Betrieb, Secrets, Worker, RLS, Backup), +[`docs/craftvia/API.md`](docs/craftvia/API.md) (`/api/v1`). Übernommene Certvia-Dokumente liegen in +`docs/_certvia-archiv/`. diff --git a/deploy/garage.toml b/deploy/garage.toml index e82e47d..6db8e9f 100644 --- a/deploy/garage.toml +++ b/deploy/garage.toml @@ -1,5 +1,5 @@ # Garage-Objektspeicher — Basiskonfiguration (Single-Node pro Environment). -# Gehört zur MinIO→Garage-Migration, siehe docs/KONZEPT-garage-migration.md (§5). +# Gehört zur MinIO→Garage-Migration, siehe docs/_certvia-archiv/KONZEPT-garage-migration.md (§5). # # WICHTIG — KEINE Secrets in dieser Datei (sie ist im Repo eingecheckt): # rpc_secret ← wird zur Laufzeit aus GARAGE_RPC_SECRET gelesen diff --git a/docker-compose.coolify.prebuilt.yml b/docker-compose.coolify.prebuilt.yml index b402054..c2f7a71 100644 --- a/docker-compose.coolify.prebuilt.yml +++ b/docker-compose.coolify.prebuilt.yml @@ -8,7 +8,9 @@ # - Konfiguration über Coolify-Env-Variablen statt env_file: .env # - Service "migrate": Init-Job (prisma migrate deploy + Rollen-Rechte-Sync), läuft einmalig VOR app # - kein mailhog (Dev); Service "worker" = SEC1 Mail-Worker (BullMQ/Redis-Queue) -# In Coolify als "Docker Compose Location" -> docker-compose.coolify.yml setzen. +# - Service "craftvia-worker" = Craftvia-Job-Worker (Image craftvia-worker, Stage "worker") +# In Coolify als "Docker Compose Location" -> docker-compose.coolify.prebuilt.yml setzen. +# Betriebsdoku: docs/craftvia/DEPLOY.md # # Härtung (F-11/F-18): # - F-11: migrate nutzt die schlanke "migrate"-Stage (kein Next-Build), Images gepinnt. @@ -97,13 +99,36 @@ services: # wählt bzw. als Env-Fallback. Muss auf das gemountete `backups`-Volume zeigen, # sonst sind Sicherungen beim Redeploy flüchtig. DB-Config hat Vorrang vor dieser Var. BACKUP_LOCAL_DIR: ${BACKUP_LOCAL_DIR:-/app/.backups} - AI_PROVIDER: ${AI_PROVIDER} - AI_API_KEY: ${AI_API_KEY} SMTP_HOST: ${SMTP_HOST} SMTP_PORT: ${SMTP_PORT} + SMTP_SECURE: ${SMTP_SECURE:-} SMTP_USER: ${SMTP_USER} SMTP_PASSWORD: ${SMTP_PASSWORD} SMTP_FROM: ${SMTP_FROM} + MAIL_FROM_NAME: ${MAIL_FROM_NAME:-} + MAIL_REPLY_TO: ${MAIL_REPLY_TO:-} + # Basis für absolute Links (Mails, PDFs); leer = Fallback AUTH_URL. + APP_BASE_URL: ${APP_BASE_URL:-} + # Craftvia-KI (ARCHITEKTUR §4.5). Ohne ANTHROPIC_API_KEY bzw. TRANSCRIPTION_API_KEY + # graceful degradation (Status "disabled", manuelle Eingabe). + AI_EXTRACTION_PROVIDER: ${AI_EXTRACTION_PROVIDER:-anthropic} + ANTHROPIC_API_KEY: ${ANTHROPIC_API_KEY:-} + ANTHROPIC_MODEL: ${ANTHROPIC_MODEL:-} + TRANSCRIPTION_PROVIDER: ${TRANSCRIPTION_PROVIDER:-openai-compatible} + TRANSCRIPTION_API_URL: ${TRANSCRIPTION_API_URL:-} + TRANSCRIPTION_API_KEY: ${TRANSCRIPTION_API_KEY:-} + TRANSCRIPTION_MODEL: ${TRANSCRIPTION_MODEL:-} + # Tokenbudget je Mandant je Kalendermonat (ein+aus); 0 = unbegrenzt. + AI_MONTHLY_TOKEN_LIMIT: ${AI_MONTHLY_TOKEN_LIMIT:-0} + # Optionaler Malware-Scan der Uploads (clamd INSTREAM); leer = nur Typ-/Magic-Byte-Prüfung. + CLAMAV_HOST: ${CLAMAV_HOST:-} + CLAMAV_PORT: ${CLAMAV_PORT:-3310} + # PWA: ab wie vielen Tagen ein Offline-Bundle als veraltet gilt (1–365, Default 7). + OFFLINE_MAX_DAYS: ${OFFLINE_MAX_DAYS:-7} + # Rate Limits je Nutzer/Minute: /api/v1/** allgemein bzw. Einsatz-/Sync-Endpunkte + # (/api/v1/sync, /api/v1/uploads, /api/v1/field/**). + API_RATE_LIMIT_PER_MINUTE: ${API_RATE_LIMIT_PER_MINUTE:-300} + API_FIELD_RATE_LIMIT_PER_MINUTE: ${API_FIELD_RATE_LIMIT_PER_MINUTE:-1200} # Persistenter lokaler Backup-Zielspeicher (überlebt Redeploys). volumes: - backups:/app/.backups @@ -238,6 +263,81 @@ services: condition: service_completed_successfully restart: unless-stopped + # Craftvia-Job-Worker (ARCHITEKTUR §4.4, scripts/craftvia-worker.ts): je BullMQ-Queue ein + # Worker für import-extraction, transcription, report-pdf, image-derivatives. + # OHNE diesen Dienst bleiben Import-Extraktion, Transkription, Berichts-PDFs und + # Bild-Derivate in der Queue liegen (die App reiht bei gesetztem REDIS_URL nur ein). + # Image craftvia-worker = Dockerfile-Stage "worker" (tsx + src + Prisma-Client + + # Debian-Chromium + Schriften) — HTML→PDF läuft NUR hier, nie in app. + # Processors greifen über dbForTenant zu → bei RLS_ENFORCED=true wie app über + # RLS_DATABASE_URL (Rolle craftvia_app). Egress (default-Netz) für Anthropic-/ + # Transkriptions-API und SMTP. Kein Port, kein Traefik. + craftvia-worker: + image: ${REGISTRY:-registry.example.com/craftvia}/craftvia-worker:${IMAGE_TAG:-main} + command: ["npx", "tsx", "scripts/craftvia-worker.ts"] + environment: + DATABASE_URL: ${DATABASE_URL} + RLS_ENFORCED: ${RLS_ENFORCED:-false} + RLS_DATABASE_URL: ${RLS_DATABASE_URL} + REDIS_URL: redis://:${REDIS_PASSWORD}@redis:6379 + AUTH_SECRET: ${AUTH_SECRET} + MFA_ENC_KEY: ${MFA_ENC_KEY:-} + AUTH_URL: ${AUTH_URL} + APP_BASE_URL: ${APP_BASE_URL:-} + # Objektspeicher (Garage): Import-PDFs/Sprachnotizen lesen, PDFs/Derivate schreiben. + S3_ENDPOINT: ${S3_ENDPOINT} + S3_ACCESS_KEY: ${S3_ACCESS_KEY} + S3_SECRET_KEY: ${S3_SECRET_KEY} + S3_BUCKET: ${S3_BUCKET} + S3_REGION: ${S3_REGION:-us-east-1} + # KI-Provider (siehe app). Ohne Key: Jobs enden mit Status "disabled". + AI_EXTRACTION_PROVIDER: ${AI_EXTRACTION_PROVIDER:-anthropic} + ANTHROPIC_API_KEY: ${ANTHROPIC_API_KEY:-} + ANTHROPIC_MODEL: ${ANTHROPIC_MODEL:-} + TRANSCRIPTION_PROVIDER: ${TRANSCRIPTION_PROVIDER:-openai-compatible} + TRANSCRIPTION_API_URL: ${TRANSCRIPTION_API_URL:-} + TRANSCRIPTION_API_KEY: ${TRANSCRIPTION_API_KEY:-} + TRANSCRIPTION_MODEL: ${TRANSCRIPTION_MODEL:-} + AI_MONTHLY_TOKEN_LIMIT: ${AI_MONTHLY_TOKEN_LIMIT:-0} + # Aufbewahrung KI-Protokoll (AiGeneration): Ein-/Ausgaben älter als N Tage leeren. + AI_GENERATION_RETENTION_DAYS: ${AI_GENERATION_RETENTION_DAYS:-180} + # Chromium aus dem Debian-Paket (im Image bereits gesetzt, hier explizit). + PDF_CHROMIUM_PATH: /usr/bin/chromium + SMTP_HOST: ${SMTP_HOST} + SMTP_PORT: ${SMTP_PORT} + SMTP_SECURE: ${SMTP_SECURE:-} + SMTP_USER: ${SMTP_USER} + SMTP_PASSWORD: ${SMTP_PASSWORD} + SMTP_FROM: ${SMTP_FROM} + MAIL_FROM_NAME: ${MAIL_FROM_NAME:-} + MAIL_REPLY_TO: ${MAIL_REPLY_TO:-} + # Chromium nutzt /dev/shm für Renderer-Speicher; render.ts setzt zusätzlich + # --disable-dev-shm-usage, 1 GB schützt dennoch vor Abstürzen bei Fotoberichten. + shm_size: "1gb" + networks: + - backend + - default + security_opt: + - "no-new-privileges:true" + cap_drop: + - ALL + deploy: + resources: + limits: + cpus: "1.0" + memory: 1536M + depends_on: + postgres: + condition: service_healthy + redis: + condition: service_started + garage: + condition: service_healthy + garage-provision: + condition: service_completed_successfully + migrate: + condition: service_completed_successfully + restart: unless-stopped postgres: image: pgvector/pgvector:0.8.0-pg16 @@ -297,7 +397,7 @@ services: restart: unless-stopped # Objektspeicher: Garage (S3-kompatibel) — ersetzt den früheren minio-Service - # (MinIO Community EOL/Maintenance-Mode). Konzept: docs/KONZEPT-garage-migration.md. + # (MinIO Community EOL/Maintenance-Mode). Konzept: docs/_certvia-archiv/KONZEPT-garage-migration.md. # Buckets/Keys werden NICHT über die S3-API angelegt, sondern vom Init-Job # "garage-provision" (Admin-API). Nichts nach außen (kein Traefik/ports:) — # rein clusterintern, wie minio zuvor. Version gepinnt (kein latest). diff --git a/docker-compose.coolify.yml b/docker-compose.coolify.yml index 819984e..364c8f0 100644 --- a/docker-compose.coolify.yml +++ b/docker-compose.coolify.yml @@ -4,7 +4,10 @@ # - Konfiguration über Coolify-Env-Variablen statt env_file: .env # - Service "migrate": Init-Job (prisma migrate deploy + Rollen-Rechte-Sync), läuft einmalig VOR app # - kein mailhog (Dev); Service "worker" = SEC1 Mail-Worker (BullMQ/Redis-Queue) +# - Service "craftvia-worker" = Craftvia-Job-Worker (Import-Extraktion, Transkription, +# Berichts-PDF mit Chromium, Bild-Derivate) aus der Dockerfile-Stage "worker" # In Coolify als "Docker Compose Location" -> docker-compose.coolify.yml setzen. +# Betriebsdoku: docs/craftvia/DEPLOY.md # # Härtung (F-11/F-18): # - F-11: migrate nutzt die schlanke "migrate"-Stage (kein Next-Build), Images gepinnt. @@ -97,13 +100,36 @@ services: # wählt bzw. als Env-Fallback. Muss auf das gemountete `backups`-Volume zeigen, # sonst sind Sicherungen beim Redeploy flüchtig. DB-Config hat Vorrang vor dieser Var. BACKUP_LOCAL_DIR: ${BACKUP_LOCAL_DIR:-/app/.backups} - AI_PROVIDER: ${AI_PROVIDER} - AI_API_KEY: ${AI_API_KEY} SMTP_HOST: ${SMTP_HOST} SMTP_PORT: ${SMTP_PORT} + SMTP_SECURE: ${SMTP_SECURE:-} SMTP_USER: ${SMTP_USER} SMTP_PASSWORD: ${SMTP_PASSWORD} SMTP_FROM: ${SMTP_FROM} + MAIL_FROM_NAME: ${MAIL_FROM_NAME:-} + MAIL_REPLY_TO: ${MAIL_REPLY_TO:-} + # Basis für absolute Links (Mails, PDFs); leer = Fallback AUTH_URL. + APP_BASE_URL: ${APP_BASE_URL:-} + # Craftvia-KI (ARCHITEKTUR §4.5). Ohne ANTHROPIC_API_KEY bzw. TRANSCRIPTION_API_KEY + # graceful degradation (Status "disabled", manuelle Eingabe). + AI_EXTRACTION_PROVIDER: ${AI_EXTRACTION_PROVIDER:-anthropic} + ANTHROPIC_API_KEY: ${ANTHROPIC_API_KEY:-} + ANTHROPIC_MODEL: ${ANTHROPIC_MODEL:-} + TRANSCRIPTION_PROVIDER: ${TRANSCRIPTION_PROVIDER:-openai-compatible} + TRANSCRIPTION_API_URL: ${TRANSCRIPTION_API_URL:-} + TRANSCRIPTION_API_KEY: ${TRANSCRIPTION_API_KEY:-} + TRANSCRIPTION_MODEL: ${TRANSCRIPTION_MODEL:-} + # Tokenbudget je Mandant je Kalendermonat (ein+aus); 0 = unbegrenzt. + AI_MONTHLY_TOKEN_LIMIT: ${AI_MONTHLY_TOKEN_LIMIT:-0} + # Optionaler Malware-Scan der Uploads (clamd INSTREAM); leer = nur Typ-/Magic-Byte-Prüfung. + CLAMAV_HOST: ${CLAMAV_HOST:-} + CLAMAV_PORT: ${CLAMAV_PORT:-3310} + # PWA: ab wie vielen Tagen ein Offline-Bundle als veraltet gilt (1–365, Default 7). + OFFLINE_MAX_DAYS: ${OFFLINE_MAX_DAYS:-7} + # Rate Limits je Nutzer/Minute: /api/v1/** allgemein bzw. Einsatz-/Sync-Endpunkte + # (/api/v1/sync, /api/v1/uploads, /api/v1/field/**). + API_RATE_LIMIT_PER_MINUTE: ${API_RATE_LIMIT_PER_MINUTE:-300} + API_FIELD_RATE_LIMIT_PER_MINUTE: ${API_FIELD_RATE_LIMIT_PER_MINUTE:-1200} # Persistenter lokaler Backup-Zielspeicher (überlebt Redeploys). volumes: - backups:/app/.backups @@ -242,6 +268,83 @@ services: condition: service_completed_successfully restart: unless-stopped + # Craftvia-Job-Worker (ARCHITEKTUR §4.4, scripts/craftvia-worker.ts): je BullMQ-Queue ein + # Worker für import-extraction, transcription, report-pdf, image-derivatives. + # OHNE diesen Dienst bleiben Import-Extraktion, Transkription, Berichts-PDFs und + # Bild-Derivate in der Queue liegen (die App reiht bei gesetztem REDIS_URL nur ein). + # Eigene Dockerfile-Stage "worker": tsx + src + Prisma-Client + Debian-Chromium und + # Schriften (fonts-dejavu-core, fonts-liberation) — HTML→PDF läuft NUR hier, nie in app. + # Processors greifen über dbForTenant zu → bei RLS_ENFORCED=true wie app über + # RLS_DATABASE_URL (Rolle craftvia_app). Egress (default-Netz) für Anthropic-/ + # Transkriptions-API und SMTP. Kein Port, kein Traefik. + craftvia-worker: + build: + context: . + target: worker + command: ["npx", "tsx", "scripts/craftvia-worker.ts"] + environment: + DATABASE_URL: ${DATABASE_URL} + RLS_ENFORCED: ${RLS_ENFORCED:-false} + RLS_DATABASE_URL: ${RLS_DATABASE_URL} + REDIS_URL: redis://:${REDIS_PASSWORD}@redis:6379 + AUTH_SECRET: ${AUTH_SECRET} + MFA_ENC_KEY: ${MFA_ENC_KEY:-} + AUTH_URL: ${AUTH_URL} + APP_BASE_URL: ${APP_BASE_URL:-} + # Objektspeicher (Garage): Import-PDFs/Sprachnotizen lesen, PDFs/Derivate schreiben. + S3_ENDPOINT: ${S3_ENDPOINT} + S3_ACCESS_KEY: ${S3_ACCESS_KEY} + S3_SECRET_KEY: ${S3_SECRET_KEY} + S3_BUCKET: ${S3_BUCKET} + S3_REGION: ${S3_REGION:-us-east-1} + # KI-Provider (siehe app). Ohne Key: Jobs enden mit Status "disabled". + AI_EXTRACTION_PROVIDER: ${AI_EXTRACTION_PROVIDER:-anthropic} + ANTHROPIC_API_KEY: ${ANTHROPIC_API_KEY:-} + ANTHROPIC_MODEL: ${ANTHROPIC_MODEL:-} + TRANSCRIPTION_PROVIDER: ${TRANSCRIPTION_PROVIDER:-openai-compatible} + TRANSCRIPTION_API_URL: ${TRANSCRIPTION_API_URL:-} + TRANSCRIPTION_API_KEY: ${TRANSCRIPTION_API_KEY:-} + TRANSCRIPTION_MODEL: ${TRANSCRIPTION_MODEL:-} + AI_MONTHLY_TOKEN_LIMIT: ${AI_MONTHLY_TOKEN_LIMIT:-0} + # Aufbewahrung KI-Protokoll (AiGeneration): Ein-/Ausgaben älter als N Tage leeren. + AI_GENERATION_RETENTION_DAYS: ${AI_GENERATION_RETENTION_DAYS:-180} + # Chromium aus dem Debian-Paket (im Image bereits gesetzt, hier explizit). + PDF_CHROMIUM_PATH: /usr/bin/chromium + SMTP_HOST: ${SMTP_HOST} + SMTP_PORT: ${SMTP_PORT} + SMTP_SECURE: ${SMTP_SECURE:-} + SMTP_USER: ${SMTP_USER} + SMTP_PASSWORD: ${SMTP_PASSWORD} + SMTP_FROM: ${SMTP_FROM} + MAIL_FROM_NAME: ${MAIL_FROM_NAME:-} + MAIL_REPLY_TO: ${MAIL_REPLY_TO:-} + # Chromium nutzt /dev/shm für Renderer-Speicher; render.ts setzt zusätzlich + # --disable-dev-shm-usage, 1 GB schützt dennoch vor Abstürzen bei Fotoberichten. + shm_size: "1gb" + networks: + - backend + - default + security_opt: + - "no-new-privileges:true" + cap_drop: + - ALL + deploy: + resources: + limits: + cpus: "1.0" + memory: 1536M + depends_on: + postgres: + condition: service_healthy + redis: + condition: service_started + garage: + condition: service_healthy + garage-provision: + condition: service_completed_successfully + migrate: + condition: service_completed_successfully + restart: unless-stopped postgres: image: pgvector/pgvector:0.8.0-pg16 @@ -301,7 +404,7 @@ services: restart: unless-stopped # Objektspeicher: Garage (S3-kompatibel) — ersetzt den früheren minio-Service - # (MinIO Community EOL/Maintenance-Mode). Konzept: docs/KONZEPT-garage-migration.md. + # (MinIO Community EOL/Maintenance-Mode). Konzept: docs/_certvia-archiv/KONZEPT-garage-migration.md. # Buckets/Keys werden NICHT über die S3-API angelegt, sondern vom Init-Job # "garage-provision" (Admin-API). Nichts nach außen (kein Traefik/ports:) — # rein clusterintern, wie minio zuvor. Version gepinnt (kein latest). diff --git a/docs/DEPLOY-COOLIFY.md b/docs/_certvia-archiv/DEPLOY-COOLIFY.md similarity index 100% rename from docs/DEPLOY-COOLIFY.md rename to docs/_certvia-archiv/DEPLOY-COOLIFY.md diff --git a/docs/DEPLOY-PROD-CONTABO.md b/docs/_certvia-archiv/DEPLOY-PROD-CONTABO.md similarity index 100% rename from docs/DEPLOY-PROD-CONTABO.md rename to docs/_certvia-archiv/DEPLOY-PROD-CONTABO.md diff --git a/docs/DEPLOY-PROD-PREBUILT.md b/docs/_certvia-archiv/DEPLOY-PROD-PREBUILT.md similarity index 100% rename from docs/DEPLOY-PROD-PREBUILT.md rename to docs/_certvia-archiv/DEPLOY-PROD-PREBUILT.md diff --git a/docs/DEVOPS-INTEGRATION-RUNBOOK.md b/docs/_certvia-archiv/DEVOPS-INTEGRATION-RUNBOOK.md similarity index 100% rename from docs/DEVOPS-INTEGRATION-RUNBOOK.md rename to docs/_certvia-archiv/DEVOPS-INTEGRATION-RUNBOOK.md diff --git a/docs/FEINDESIGN-identity-mandanten.md b/docs/_certvia-archiv/FEINDESIGN-identity-mandanten.md similarity index 100% rename from docs/FEINDESIGN-identity-mandanten.md rename to docs/_certvia-archiv/FEINDESIGN-identity-mandanten.md diff --git a/docs/HANDOVER-DEVOPS.md b/docs/_certvia-archiv/HANDOVER-DEVOPS.md similarity index 100% rename from docs/HANDOVER-DEVOPS.md rename to docs/_certvia-archiv/HANDOVER-DEVOPS.md diff --git a/docs/KONZEPT-backup-restore.md b/docs/_certvia-archiv/KONZEPT-backup-restore.md similarity index 100% rename from docs/KONZEPT-backup-restore.md rename to docs/_certvia-archiv/KONZEPT-backup-restore.md diff --git a/docs/KONZEPT-backup-target.md b/docs/_certvia-archiv/KONZEPT-backup-target.md similarity index 100% rename from docs/KONZEPT-backup-target.md rename to docs/_certvia-archiv/KONZEPT-backup-target.md diff --git a/docs/KONZEPT-garage-migration.md b/docs/_certvia-archiv/KONZEPT-garage-migration.md similarity index 100% rename from docs/KONZEPT-garage-migration.md rename to docs/_certvia-archiv/KONZEPT-garage-migration.md diff --git a/docs/KONZEPT-haertung.md b/docs/_certvia-archiv/KONZEPT-haertung.md similarity index 100% rename from docs/KONZEPT-haertung.md rename to docs/_certvia-archiv/KONZEPT-haertung.md diff --git a/docs/KONZEPT-identity-mandanten.md b/docs/_certvia-archiv/KONZEPT-identity-mandanten.md similarity index 100% rename from docs/KONZEPT-identity-mandanten.md rename to docs/_certvia-archiv/KONZEPT-identity-mandanten.md diff --git a/docs/KONZEPT-ui-i18n.md b/docs/_certvia-archiv/KONZEPT-ui-i18n.md similarity index 100% rename from docs/KONZEPT-ui-i18n.md rename to docs/_certvia-archiv/KONZEPT-ui-i18n.md diff --git a/docs/_certvia-archiv/README.md b/docs/_certvia-archiv/README.md new file mode 100644 index 0000000..0abccfb --- /dev/null +++ b/docs/_certvia-archiv/README.md @@ -0,0 +1,41 @@ +# Certvia-Archiv + +Craftvia ist aus dem Fundament des ISMS-Produkts **Certvia** hervorgegangen (Auth.js mit +Identity/Mitgliedschaften, RLS, Mail-/Backup-Worker, Garage, Härtung). Dieser Ordner hält +die dabei übernommenen Dokumente **unverändert** vor. Sie sind **nicht maßgeblich** für Craftvia. + +## Warum archiviert + +- Produkt-, Domain- und Personenbezug auf Certvia/ISMS (`app.certvia.de`, Gitea-/Coolify-Hosts, + Rollen ISB/DSB, Vorfall-Mail-Eingang, Risiko-Backfill), veraltete Namen (`isms_app`, + `isms-documents`, MinIO). +- Konzepte und Umsetzungs-Prompts sind umgesetzt. Der Ist-Stand steht im Code und in der + Craftvia-Doku. +- Der betriebsrelevante Inhalt (Coolify-Deploy, Prebuilt-Images, RLS-Aktivierung, Secrets, + Backup/Restore, Garage) ist in **[docs/craftvia/DEPLOY.md](../craftvia/DEPLOY.md)** + zusammengeführt und auf Craftvia umgeschrieben. + +Maßgeblich sind [AGENTS.md](../../AGENTS.md), [docs/craftvia/SPEC-CRAFTVIA.md](../craftvia/SPEC-CRAFTVIA.md), +[docs/craftvia/ARCHITEKTUR.md](../craftvia/ARCHITEKTUR.md) und [docs/craftvia/DEPLOY.md](../craftvia/DEPLOY.md). + +## Inhalt + +| Datei | Thema | Noch als Hintergrund nützlich für | +|---|---|---| +| `DEPLOY-COOLIFY.md` | Testserver via Coolify (Certvia) | – (ersetzt durch DEPLOY.md) | +| `DEPLOY-PROD-CONTABO.md` | Prod-VPS, LUKS, pgBackRest/age/restic, PITR, Vorfall-Mail-Eingang | Host-Encryption- und PITR-Details | +| `DEPLOY-PROD-PREBUILT.md` | Prebuilt-Images über die Registry | – (ersetzt durch DEPLOY.md) | +| `HANDOVER-DEVOPS.md` | frühe DevOps-Übergabe (Stand Juli 2026) | – | +| `DEVOPS-INTEGRATION-RUNBOOK.md` | Branch-Integration im Certvia-Team | – | +| `SECRETS-REGISTER.md` | Secrets-Register (Certvia) | Rotationsregeln (in DEPLOY.md übernommen) | +| `KONZEPT-backup-restore.md` | Backup-/Restore-/DSGVO-Engine | Designbegründung von `src/server/backup/**` | +| `KONZEPT-backup-target.md` | konfigurierbarer Backup-Zielspeicher | Designbegründung `/admin/backup` | +| `KONZEPT-garage-migration.md` | MinIO → Garage | Designbegründung Garage/`garage-provision` | +| `KONZEPT-haertung.md` | Pepper, Host-Encryption, Secrets | Designbegründung `PASSWORD_PEPPER` | +| `KONZEPT-identity-mandanten.md`, `FEINDESIGN-identity-mandanten.md`, `UEBERGABE-identity-mandanten.md` | zentrale Identity + Mandanten-Mitgliedschaften | Designbegründung Two-Step-Login/Mandantenwechsel | +| `KONZEPT-ui-i18n.md` | Betreiber-Konsole-UX, i18n | – | +| `SEC1-MAIL.md`, `SEC2-AUTH-SELFSERVICE.md` | Mail-Fundament, Passwort-Self-Service | Hintergrund zu `src/server/mail/**`, `scripts/test-mail.ts`, `scripts/test-auth-selfservice.ts` | +| `sicherheit/` | PO-Konzept und Claude-Code-Prompts SEC1–SEC6 (Certvia) | – | + +Die Querverweise **innerhalb** dieser Dokumente (`docs/…`) zeigen noch auf die alten Pfade. +Sie werden bewusst nicht nachgezogen. diff --git a/docs/SEC1-MAIL.md b/docs/_certvia-archiv/SEC1-MAIL.md similarity index 100% rename from docs/SEC1-MAIL.md rename to docs/_certvia-archiv/SEC1-MAIL.md diff --git a/docs/SEC2-AUTH-SELFSERVICE.md b/docs/_certvia-archiv/SEC2-AUTH-SELFSERVICE.md similarity index 100% rename from docs/SEC2-AUTH-SELFSERVICE.md rename to docs/_certvia-archiv/SEC2-AUTH-SELFSERVICE.md diff --git a/docs/SECRETS-REGISTER.md b/docs/_certvia-archiv/SECRETS-REGISTER.md similarity index 100% rename from docs/SECRETS-REGISTER.md rename to docs/_certvia-archiv/SECRETS-REGISTER.md diff --git a/docs/UEBERGABE-identity-mandanten.md b/docs/_certvia-archiv/UEBERGABE-identity-mandanten.md similarity index 100% rename from docs/UEBERGABE-identity-mandanten.md rename to docs/_certvia-archiv/UEBERGABE-identity-mandanten.md diff --git a/docs/sicherheit/Aufgabenpaket-Sicherheit-Administration.md b/docs/_certvia-archiv/sicherheit/Aufgabenpaket-Sicherheit-Administration.md similarity index 100% rename from docs/sicherheit/Aufgabenpaket-Sicherheit-Administration.md rename to docs/_certvia-archiv/sicherheit/Aufgabenpaket-Sicherheit-Administration.md diff --git a/docs/sicherheit/README.md b/docs/_certvia-archiv/sicherheit/README.md similarity index 100% rename from docs/sicherheit/README.md rename to docs/_certvia-archiv/sicherheit/README.md diff --git a/docs/sicherheit/SEC1-Mail-Fundament-Detail.md b/docs/_certvia-archiv/sicherheit/SEC1-Mail-Fundament-Detail.md similarity index 100% rename from docs/sicherheit/SEC1-Mail-Fundament-Detail.md rename to docs/_certvia-archiv/sicherheit/SEC1-Mail-Fundament-Detail.md diff --git a/docs/sicherheit/SEC2-Auth-SelfService-Detail.md b/docs/_certvia-archiv/sicherheit/SEC2-Auth-SelfService-Detail.md similarity index 100% rename from docs/sicherheit/SEC2-Auth-SelfService-Detail.md rename to docs/_certvia-archiv/sicherheit/SEC2-Auth-SelfService-Detail.md diff --git a/docs/sicherheit/Sicherheit-und-Administration-Konzept.md b/docs/_certvia-archiv/sicherheit/Sicherheit-und-Administration-Konzept.md similarity index 100% rename from docs/sicherheit/Sicherheit-und-Administration-Konzept.md rename to docs/_certvia-archiv/sicherheit/Sicherheit-und-Administration-Konzept.md diff --git a/docs/craftvia/API.md b/docs/craftvia/API.md new file mode 100644 index 0000000..02b2096 --- /dev/null +++ b/docs/craftvia/API.md @@ -0,0 +1,94 @@ +# Craftvia API (`/api/v1`) + +Versionierte JSON-API für Backoffice-Formulare, die Mobile-App/PWA (Offline-Sync) und künftige Integrationen. Die maschinenlesbare Spezifikation (OpenAPI 3.1) liefert `GET /api/v1/openapi.json` (gepflegt in `src/lib/api/openapi.ts`, `API_ROUTES` listet alle dokumentierten Pfade). Jede neue oder geänderte `src/app/api/v1/**/route.ts` muss dort nachgetragen werden. + +## Authentifizierung und CSRF + +- **Session-Cookie** von Auth.js: `authjs.session-token` (unter HTTPS `__Secure-authjs.session-token`). Ohne Cookie antwortet bereits der Proxy (`src/proxy.ts`) mit `401`. +- **Rechte** werden bei jedem Request aus der Datenbank gelesen (Mitgliedschaft, Identitätsstatus, Session-Kill-Switch, Passwortwechsel, effektive Rechte), nie aus dem JWT. Fehlt ein Recht oder ist das Modul des Mandanten deaktiviert, kommt `403`. +- **Sichtbarkeit:** Objekte eines fremden Mandanten oder außerhalb des eigenen Scopes (z. B. Monteur ↔ fremder Auftrag) liefern `404`, nicht `403`. +- **CSRF:** Schreibende Methoden (POST/PATCH) nur Same-Origin: Der `Origin`-Header muss zum Host passen, `Sec-Fetch-Site` muss `same-origin` oder `none` sein. Sonst `403 forbidden`. + +## Fehlerformat + +Alle Routen antworten im Fehlerfall mit `Cache-Control: no-store` und + +```json +{ "error": { "code": "invalid", "message": "validation failed", "details": [{ "path": "customerId", "code": "too_small" }] } } +``` + +| Code | HTTP | Bedeutung / `details` | +|---|---|---| +| `unauthorized` | 401 | nicht angemeldet, Konto inaktiv, Sitzung invalidiert | +| `forbidden` | 403 | Recht fehlt, Modul deaktiviert, Passwortwechsel nötig, Cross-Site-Request | +| `not_found` | 404 | unbekannt, fremder Mandant oder außerhalb des Scopes | +| `conflict` | 409 | Versionskonflikt (`baseVersion`), Doppelbestätigung/unzulässiger Zustand, mögliche Dubletten (`details.reason = "possible_duplicates"`, `details.candidates`) | +| `invalid` | 422 | Validierung (Zod: `details = [{ path, code }]`), fehlerhaftes JSON/Multipart | +| `blocked` | 422 | fachlich gesperrt, z. B. `details = CompletionBlocker[]` | +| `payload_too_large` | 413 | Datei/Body zu groß | +| `rate_limited` | 429 | Header `Retry-After` (Sekunden), `details.retryAfterSeconds` | +| `internal` | 500 | unerwarteter Fehler, keine internen Details | + +## Pagination + +`GET /customers`, `GET /sites` und `GET /sites/{id}/history` verwenden `?page` (≥ 1) und `?pageSize` (1–100, Standard 25, bei der Historie 50). Antwort: `{ "data": [...], "pagination": { "page", "pageSize", "total" } }` (Historie zusätzlich `meta.onlyApproved`). +`GET /work-orders` hat ein eigenes Format: `{ items, total, page, pageSize, groupCounts }` (`groupCounts` = Anzahl je Statusgruppe ohne Status-/Gruppenfilter). + +## Idempotenz und Konflikte (Sync) + +- `POST /sync` nimmt `{ deviceId, operations[] }` mit 1–100 Operationen an (die PWA-Outbox schickt Batches ≤ 50). Jede Operation hat eine `clientOpId` (UUID) und wird einzeln angewendet. Die HTTP-Antwort ist `200`, das Ergebnis steht je Operation in `results[]`: `applied` | `duplicate` | `conflict` | `rejected` (mit `errorCode`, `message`, `idMap`, `entityVersion`). +- **Idempotenz:** Eine wiederholte `clientOpId` (je Mandant) liefert `duplicate` mit dem gespeicherten Ergebnis. Ist die ID bereits durch einen anderen Nutzer belegt, wird die Operation `rejected`. +- **Konflikte:** `work_order.transition` und `report.submit` verlangen `baseVersion`. Weicht sie von `WorkOrder.version` ab, lautet das Ergebnis `conflict`, `entityVersion` ist dann die aktuelle Version. Alle anderen Operationen sind additiv (Client-IDs in den Payloads, z. B. `clientId`, werden über `idMap` auf Server-IDs abgebildet). +- Den opType-Katalog mit den Payload-Schemas enthält `src/lib/sync/ops.ts` (Spec: Komponenten `SyncPayload*`). +- REST-Schreibrouten für Aufträge (`PATCH /work-orders/{id}`, `/assign`, `/transition`) akzeptieren optional `baseVersion` und antworten bei Abweichung mit `409`. + +## Uploads + +- `POST /uploads` (Einsatz): multipart mit `file`, `clientId` (UUID), `workOrderId`, `kind` (`photo` | `voice_note`) und optional `preview` (Thumbnail ≤ 2 MB). Maximal 25 MB, der Inhalt wird per Magic Bytes geprüft. Idempotent über `clientId`: dieselbe clientId liefert `200 { documentId, duplicate: true }`, ein neuer Upload `201 { documentId, duplicate: false }`. Die `documentId` wird danach in `photo.attach`/`voice.attach` referenziert. +- `POST /work-orders/{id}/documents`: multipart mit `file`, `category`, `visibility`, `title?`. Antwort `201`. Mit `Accept: text/html` kommt stattdessen ein `303`-Redirect (Backoffice-Formular). +- `POST /work-orders/import`: multipart mit `file` (PDF/JPEG/PNG, ≤ 25 MB), Antwort `201 { id, status }`. Die Extraktion läuft asynchron. + +## Rate Limits + +Die Zählung erfolgt je Nutzer in einem Fenster von einer Minute, im Speicher je App-Instanz (bei mehreren Instanzen also pro Instanz). + +- Standard: `API_RATE_LIMIT_PER_MINUTE` (Default 300) +- Einsatz-Endpunkte `/sync`, `/uploads`, `/field/**`: `API_FIELD_RATE_LIMIT_PER_MINUTE` (Default 1200) + +Bei Überschreitung kommt `429` mit `Retry-After`. + +## Endpunkte + +Die Pfade sind relativ zu `/api/v1`. „Recht“ nennt das Gate der Route. Mit „Service“ markierte Rechte prüft der Service (zusätzlich zum Scope). + +| Methode | Pfad | Modul | Recht | Beschreibung | +|---|---|---|---|---| +| GET | `/customers` | customers | `customer:read` | Kunden suchen (`q`, `status`, paginiert) | +| POST | `/customers` | customers | `customer:write` | Kunde anlegen (409 bei möglichen Dubletten ohne `acknowledgeDuplicates`) | +| GET | `/customers/{id}` | customers | `customer:read` | Kunde inkl. Ansprechpartner | +| PATCH | `/customers/{id}` | customers | `customer:write` | Kunde ändern (fehlt = unverändert, `null` = leeren) | +| GET | `/sites` | sites | `site:read` | Standorte suchen (`q`, `customerId`, `status`, paginiert) | +| POST | `/sites` | sites | `site:write` | Standort anlegen | +| GET | `/sites/{id}/history` | sites | `site:read` | Einsatzhistorie (Außendienst: nur freigegebene Einsätze) | +| GET | `/work-orders` | work_orders | Scope (`work_order:read_all`/`read_team`) | Auftragsliste mit Filtern/Presets | +| POST | `/work-orders` | work_orders | Service: `work_order:write` (Notfall: `emergency:create`) | Auftrag anlegen | +| GET | `/work-orders/{id}` | work_orders | Scope | Detail + `availableTransitions` + `completionBlockers` | +| PATCH | `/work-orders/{id}` | work_orders | Service: `work_order:write` | Stammdaten ändern (`baseVersion`) | +| POST | `/work-orders/{id}/assign` | work_orders | `work_order:assign` | Team/Monteure zuweisen | +| POST | `/work-orders/{id}/transition` | work_orders | je Übergang (`requiredPermission`) | Statuswechsel (422 `blocked` mit Blockern) | +| GET | `/work-orders/{id}/materials` | work_orders | Scope | Material Soll/Ist | +| POST | `/work-orders/{id}/materials` | work_orders | `work_order:write` | Materialvorgabe hinzufügen | +| POST | `/work-orders/{id}/documents` | work_orders | `document:write` | Dokument hochladen (multipart) | +| POST | `/work-orders/{id}/daily-report` | reports | `report:write` | Tagesbericht-Entwurf anlegen/holen (201/200) | +| POST | `/work-orders/{id}/completion-report` | reports | `report:write` | Abschlussbericht-Entwurf anlegen/holen (422 bei Blockern) | +| POST | `/work-orders/import` | imports | `import:write` | Auftragsdokument importieren (multipart) | +| GET | `/imports/{id}` | imports | `import:write` | Importstatus, Extraktion, Kandidaten | +| POST | `/imports/{id}/confirm` | imports | `import:write`, `work_order:write` | Prüfformular bestätigen → Auftrag | +| POST | `/reports/{id}/approve` | reports | `report:read` + Service: `report:approve_team`/`report:approve` | Bericht freigeben | +| GET | `/reports/{id}/pdf` | reports | `report:read` | PDF des freigegebenen Berichts (`?download=1`) | +| GET | `/reports/{id}/files/{documentId}` | reports | `report:read` | Foto/Unterschrift/Logo aus dem Bericht | +| POST | `/sync` | field | Service je opType (`field:execute`, `emergency:create`, …) | Batch-Operationen (offline/online) | +| POST | `/uploads` | field | `field:execute` | Foto/Sprachnotiz hochladen → `documentId` | +| GET | `/field/bundle` | field | `field:execute` | Offline-Pull (`?since=`, max. 200 Aufträge) | +| GET | `/field/documents/{id}` | field | Service: `document:read` + Sichtbarkeit/Scope | Dokument für die Mobile-App (`?variant=preview`) | +| GET | `/openapi.json` | – | angemeldet | OpenAPI-3.1-Dokument | diff --git a/docs/craftvia/DEPLOY.md b/docs/craftvia/DEPLOY.md new file mode 100644 index 0000000..90fa1d3 --- /dev/null +++ b/docs/craftvia/DEPLOY.md @@ -0,0 +1,335 @@ +# Craftvia – Betrieb & Deployment + +> Maßgeblich für Test- und Produktivbetrieb. Abgeleitet aus den Fundament-Runbooks (archiviert +> unter `docs/_certvia-archiv/`) und auf Craftvia umgeschrieben. Domains in diesem Dokument sind +> Platzhalter (`app.craftvia.example`). +> Deploy-Dateien: `docker-compose.coolify.yml` (Build auf dem Host) bzw. +> `docker-compose.coolify.prebuilt.yml` (fertige Images aus der Registry). +> Env-Referenzen: `.env.coolify.example` (Testserver), `.env.prod.example` (Produktion), +> `.env.example` (lokal). + +## 1. Architekturüberblick + +``` + Internet ──► Coolify-Proxy (Traefik, TLS) ──► app:3000 + │ + ┌─────────────── Netz "backend" (internal: true, kein Egress) ─┼──────────────────────────┐ + │ postgres (pgvector/pg16, RLS) redis (requirepass) garage (S3 :3900, Admin :3903) │ + │ ▲ ▲ ▲ ▲ ▲ ▲ ▲ │ + │ migrate app craftvia-worker worker backup-worker garage-provision │ + └──────────────────────────────────────────────────────────────────────────────────────────┘ + app, craftvia-worker, worker, backup-worker zusätzlich im Netz "default" (Egress/Proxy) +``` + +| Dienst | Dockerfile-Target / Image | Aufgabe | Lebensdauer | +|---|---|---|---| +| `migrate` | `migrate` / `craftvia-migrate` | `prisma migrate deploy` → Rollen-Rechte-Sync (`scripts/sync-role-permissions.ts`) → optional Demo-Seed (`RUN_DEMO_SEED`) bzw. Erst-Admin (`BOOTSTRAP_ADMIN`) | Init-Job, `restart: "no"` | +| `app` | `runner` / `craftvia-app` | Next.js standalone (Backoffice, PWA `/m`, REST `/api/v1/**`, Betreiber-Portal `/admin`) | dauerhaft, Healthcheck auf `/` | +| `craftvia-worker` | `worker` / `craftvia-worker` | BullMQ-Queues `import-extraction`, `transcription`, `report-pdf`, `image-derivatives` (`scripts/craftvia-worker.ts`); enthält Chromium + Schriften | dauerhaft | +| `worker` | `migrate` / `craftvia-migrate` | Mail-Worker (`scripts/mail-worker.ts`): Zustellung mit Retry/DLQ, täglicher Erinnerungslauf | dauerhaft | +| `backup-worker` | `migrate` / `craftvia-migrate` | Queue `backup-ops` (`scripts/backup-worker.ts`): Mandanten-Export/-Restore, DSGVO-Export, seriell | dauerhaft | +| `postgres` | `pgvector/pgvector:0.8.0-pg16` | Primärdatenbank, RLS-Policies `tenant_isolation` | Volume `pgdata` | +| `redis` | `redis:7.4.2-alpine` | Queues (BullMQ) | Volume `redisdata` | +| `garage` | `garage` / `craftvia-garage` | Objektspeicher (Dokumente, Fotos, PDFs, Backups), Config eingebacken aus `deploy/garage.toml` | Volumes `garage_meta`, `garage_data` | +| `garage-provision` | `migrate` / `craftvia-migrate` | Layout, Bucket, Access-Key, Rechte über die Admin-API (idempotent) | Init-Job | + +Startreihenfolge: `postgres` (healthy) → `migrate`; `garage` (healthy) → `garage-provision`; danach +`app`, `craftvia-worker`, `worker`, `backup-worker`. + +**Ohne laufende Worker** reiht die App bei gesetztem `REDIS_URL` Jobs nur ein: Import-Extraktion, +Transkription, Berichts-PDFs und Bild-Derivate bleiben dann liegen (`craftvia-worker`), Mails bleiben +`pending` (`worker`), Restore/Export bleiben `queued` (`backup-worker`). Ohne Redis laufen die +Craftvia-Processors inline in der App. Das ist nur für Dev/Demo gedacht: der PDF-Processor ist absichtlich nicht im +App-Bundle, Freigaben bleiben gültig, „PDF erzeugen" auf `/reports/[id]` stößt den Job erneut an. + +**Härtung (alle Dienste):** `no-new-privileges`, `cap_drop: ALL` (gezielte `cap_add` nur für die +Entrypoints von postgres/redis), CPU-/RAM-Limits, gepinnte Image-Tags, non-root-User `app` (UID +1001) in allen Node-Images, Redis mit Passwort. + +## 2. Domains, Proxy, TLS + +- In Coolify beim Service **`app`** die Domain setzen, z. B. `https://app.craftvia.example:3000` + (Port 3000 = Container-Port, Schema `https`). Coolify/Traefik stellt das Let's-Encrypt-Zertifikat aus. + Kein Host-Port wird exponiert. `garage`, `postgres`, `redis` und die Worker erhalten **keine** + Domain. +- `AUTH_URL` = exakt die öffentliche App-URL (`https://app.craftvia.example`), `AUTH_TRUST_HOST=true` + (im Compose Default). `APP_BASE_URL` für absolute Links in Mails/PDFs (leer = `AUTH_URL`). +- Passkeys: `WEBAUTHN_ORIGIN`/`WEBAUTHN_RP_ID` nur setzen, wenn sie von `AUTH_URL` abweichen. +- Uploads bis 25 MB laufen über den Proxy (`experimental.proxyClientMaxBodySize = 26mb`). Vorgelagerte + Proxies dürfen kein niedrigeres Body-Limit haben. +- DNS: A/AAAA-Record `app.craftvia.example` → Server-IP; Firewall nur 80/443 + SSH. + +## 3. Ersteinrichtung (Coolify) + +1. **Ressource:** Git-Repo (Deploy-Key, nur lesend), Build Pack **Docker Compose**, Compose Location + `docker-compose.coolify.yml` (oder `…prebuilt.yml`, siehe §9). +2. **Environment-Variablen** aus `.env.prod.example` bzw. `.env.coolify.example` eintragen (§4). Secrets + **literal** setzen, nicht über `${…}` referenzieren (Coolify-Interpolation). +3. **Persistent Storage prüfen:** `pgdata`, `garage_meta`, `garage_data`, `redisdata`, `backups`. +4. **Erster Deploy Produktion:** `RUN_DEMO_SEED=false`, `BOOTSTRAP_ADMIN=true` + `BOOTSTRAP_ADMIN_*` + + `BOOTSTRAP_TENANT_*`. Der `migrate`-Job legt über `scripts/bootstrap-admin.ts` den ersten Mandanten mit Mandanten-Admin + (`/login`) **und** einen Plattform-Admin (`/platform/login`, MFA-Einrichtung beim ersten Login) + an. Das Skript ist idempotent und überschreibt kein Passwort. Log im `migrate`-Container prüfen, danach Passwort ändern und + `BOOTSTRAP_ADMIN=false`. +5. **Testserver:** `RUN_DEMO_SEED=true` legt die Demo-Mandanten an (Logins siehe `AGENTS.md`). Nie in Produktion. +6. **RLS scharfschalten** (§6), **Smoke** (§10). + +## 4. Konfiguration & Secrets + +### 4.1 Variablen + +| Variable | Dienste | Pflicht | Bedeutung | +|---|---|---|---| +| `POSTGRES_USER/PASSWORD/DB`, `DATABASE_URL` | postgres, alle Node-Dienste | ja | Owner-Verbindung (Superuser/BYPASSRLS), Host `postgres` | +| `RLS_ENFORCED`, `RLS_DATABASE_URL` | app, craftvia-worker | Prod ja | scharfe RLS über Rolle `craftvia_app` (§6) | +| `REDIS_PASSWORD` | redis + alle Queue-Nutzer | ja | `REDIS_URL` wird im Compose daraus gebildet, nicht separat setzen | +| `S3_ENDPOINT/ACCESS_KEY/SECRET_KEY/BUCKET/REGION` | app, craftvia-worker, backup-worker, garage-provision | ja | Garage: `http://garage:3900`, Key `GK`+24 Hex, Secret 64 Hex, Region `us-east-1`. Ohne S3 speichert der Adapter nur Metadaten (Stub), in Prod also unbrauchbar | +| `GARAGE_RPC_SECRET`, `GARAGE_ADMIN_TOKEN` | garage, garage-provision | ja | je `openssl rand -hex 32` | +| `AUTH_SECRET` | app, Worker | ja | ≥ 32 Zeichen, sonst Fail-Secure-Abbruch (`src/server/env.ts`) | +| `PASSWORD_PEPPER` | app, migrate, backup-worker | ja | 64 Hex, **nicht rotierbar** | +| `MFA_ENC_KEY` | app, craftvia-worker | empfohlen | TOTP-Secrets at-rest (leer = aus `AUTH_SECRET`), nach dem Setzen nicht ändern | +| `BACKUP_ENC_KEY` | backup-worker | empfohlen | AES-256-GCM der Backup-Artefakte (leer = `AUTH_SECRET`) | +| `AUTH_URL`, `APP_BASE_URL` | app, Worker | ja | öffentliche URL | +| `SMTP_HOST/PORT/SECURE/USER/PASSWORD/FROM`, `MAIL_FROM_NAME`, `MAIL_REPLY_TO` | app, worker, craftvia-worker | für Mailversand | ohne vollständige Konfiguration bleiben Mails `pending` mit Begründung | +| `AI_EXTRACTION_PROVIDER`, `ANTHROPIC_API_KEY`, `ANTHROPIC_MODEL` | app, craftvia-worker | optional | §7 | +| `TRANSCRIPTION_PROVIDER/API_URL/API_KEY/MODEL` | app, craftvia-worker | optional | §7 | +| `AI_MONTHLY_TOKEN_LIMIT` | app, craftvia-worker | optional | §7.2, Default 0 = unbegrenzt | +| `AI_GENERATION_RETENTION_DAYS` | craftvia-worker | optional | §7.3, Default 180 | +| `API_RATE_LIMIT_PER_MINUTE`, `API_FIELD_RATE_LIMIT_PER_MINUTE` | app | optional | §8, Default 300 / 1200 | +| `OFFLINE_MAX_DAYS` | app | optional | Offline-Bundle gilt nach N Tagen als veraltet (1–365, Default 7) | +| `CLAMAV_HOST`, `CLAMAV_PORT` | app | optional | zusätzlicher Malware-Scan per clamd INSTREAM (sonst Allowlist + Magic Bytes) | +| `PDF_CHROMIUM_PATH` | craftvia-worker | – | im Image/Compose fest `/usr/bin/chromium` | +| `BACKUP_LOCAL_DIR`, `BACKUP_S3_BUCKET` | app, backup-worker, garage-provision | optional | §11 | +| `RUN_DEMO_SEED`, `BOOTSTRAP_ADMIN*`, `BOOTSTRAP_TENANT_*` | migrate | – | §3 | + +### 4.2 Secrets-Register (Grundregeln) + +Werte liegen ausschließlich im Passwortmanager (je Umgebung eigener Ordner) plus versiegelter +Offline-Kopie und werden nur als Coolify-Env verteilt, **nie** im Repo, Image oder Backup-Bucket. +Test, Staging und Prod haben unterschiedliche Werte. + +| Secret | Rotierbar? | Folge einer Rotation | +|---|---|---| +| `AUTH_SECRET` | ja | alle Sessions ungültig, Nutzer loggen neu ein | +| `PASSWORD_PEPPER` | **nein** | erzwungener Passwort-Reset aller Konten | +| `MFA_ENC_KEY` | **nein** | alle Nutzer müssen MFA neu einrichten | +| `BACKUP_ENC_KEY` | bedingt | gilt nur für neue Artefakte, Altschlüssel bis Retention-Ende aufbewahren | +| `craftvia_app`-Passwort (`RLS_DATABASE_URL`) | ja | `ALTER ROLE … PASSWORD`, danach Env setzen und app + craftvia-worker neu starten | +| `POSTGRES_PASSWORD`, `REDIS_PASSWORD` | ja | koordiniert mit allen Diensten neu deployen | +| `GARAGE_*`, `S3_ACCESS_KEY/SECRET_KEY` | ja | neuen Key provisionieren (`garage-provision`), Env tauschen, alten Key entfernen | +| `ANTHROPIC_API_KEY`, `TRANSCRIPTION_API_KEY`, `SMTP_PASSWORD` | ja | Env tauschen, Dienste neu starten | + +## 5. Migrationen + +- Der `migrate`-Job führt bei **jedem** Deploy `npx prisma migrate deploy` aus. Das ist idempotent: nur neue + Migrationen werden angewandt. app und Worker starten erst nach erfolgreichem Abschluss + (`service_completed_successfully`). Der App-Container migriert nie selbst. +- Danach läuft `scripts/sync-role-permissions.ts`: additiv, zieht neu eingeführte Rechte für bestehende + Mandanten nach. Betroffene Nutzer sehen neue Rechte nach erneutem Login (JWT). +- Regeln für neue Migrationen (RLS für Tenant-Tabellen usw.): [MIGRATIONS.md](MIGRATIONS.md). +- Manuell (Coolify-Terminal des `migrate`-Containers oder `docker exec`): + `npx prisma migrate status` / `npx prisma migrate deploy`. +- **Vor** Migrationen mit Datenumbau: Cluster-Backup ziehen (§11.1). + +## 6. Row Level Security aktivieren + +Die Baseline-Migration legt die Rolle `craftvia_app` **NOLOGIN NOBYPASSRLS** an, vergibt die +Tabellenrechte und aktiviert je Tenant-Tabelle `ENABLE` + `FORCE ROW LEVEL SECURITY` mit Policy +`tenant_isolation` (`USING` + `WITH CHECK` auf `current_setting('app.tenant_id', true)`). +Mit `RLS_ENFORCED=true` verbinden sich app und craftvia-worker über `RLS_DATABASE_URL` als +`craftvia_app` und setzen `app.tenant_id` transaktionslokal (`src/server/db.ts`, `dbForTenant`, +`tenantTransaction`). Migrationen, Seed/Bootstrap, Login-Lookup, Mail- und Backup-Worker laufen weiter über die +Owner-`DATABASE_URL`. + +1. Passwort für die App-Rolle setzen (einmalig je Umgebung, Wert in den Passwortmanager): + ```bash + docker exec -it psql -U craftvia -d craftvia \ + -c "ALTER ROLE craftvia_app WITH LOGIN PASSWORD '';" + ``` +2. Env setzen: + `RLS_ENFORCED=true`, + `RLS_DATABASE_URL=postgresql://craftvia_app:@postgres:5432/craftvia?schema=public` +3. Neu deployen. Fehlt `RLS_DATABASE_URL` bei aktivem Flag, bricht der Prozess beim Start ab + (fail secure). +4. Nachweis: `npx tsx scripts/test-rls-enforcement.ts`. Der Test prüft Owner-Sicht, Isolation A/B, 0 Zeilen + ohne Kontext, `WITH CHECK` und `dbForTenant` end-to-end. Er gehört auch zum CI-Gate. + +**Warnungen:** +- Die Owner-Rolle in `DATABASE_URL` **muss** Superuser oder BYPASSRLS sein. Sonst sieht der Login keine + Nutzer, und Restore (`session_replication_role`) scheitert. +- Mehrschritt-Schreibvorgänge nur über `inTransaction(ctx, fn)`, direktes `ctx.db.$transaction` ist + unter `RLS_ENFORCED=true` nicht atomar (ARCHITEKTUR §4.8). +- Zeilen mit `tenant_id = NULL` (Plattform-Audit, Mail-Logs, Auth-Tokens) sind für `craftvia_app` + unsichtbar. Sie werden nur über den Owner-Client geschrieben. + +## 7. Worker, Chromium und KI-Provider + +### 7.1 craftvia-worker und Chromium + +- Image-Stage `worker` (`Dockerfile`): `node:22.14.0-slim` + Debian-Pakete `chromium`, `fonts-dejavu-core`, + `fonts-liberation`; `node_modules` inkl. `tsx`, generierter Prisma-Client, `scripts/`, `src/`, + `messages/`, `prisma/`. Start `npx tsx scripts/craftvia-worker.ts`. +- PDF-Rendering (`src/server/pdf/render.ts`): `playwright-core` startet `PDF_CHROMIUM_PATH` mit + `--no-sandbox --disable-dev-shm-usage`. Der Container braucht deshalb keine zusätzlichen Capabilities. + `shm_size: 1gb` ist als Reserve für fotoreiche Berichte gesetzt. Seiten laden keine Netzressourcen, alle Assets sind als `data:` + eingebettet. +- Concurrency: `report-pdf` 2, übrige Queues 4 je Worker-Prozess. Horizontal skalieren = weitere + `craftvia-worker`-Replicas (BullMQ verteilt). RAM-Limit im Compose 1,5 GB. +- Diagnose im Container: `chromium --version`; Logs zeigen `[worker] listening on ` bzw. + `[worker] job failed: …`. + +### 7.2 KI-Provider + +| Zweck | Env | Verhalten ohne Konfiguration | +|---|---|---| +| Auftragsimport-Extraktion (PDF/Bild) | `AI_EXTRACTION_PROVIDER=anthropic`, `ANTHROPIC_API_KEY`, `ANTHROPIC_MODEL` (leer = `claude-opus-5`) | Import bleibt manuell erfassbar | +| Lotse (Berichtsentwurf, Vollständigkeitsprüfung) | `ANTHROPIC_API_KEY`, `ANTHROPIC_MODEL` | kein Entwurf, UI funktioniert weiter | +| Transkription von Sprachnotizen | `TRANSCRIPTION_PROVIDER=openai-compatible`, `TRANSCRIPTION_API_URL` (Default OpenAI `/v1/audio/transcriptions`), `TRANSCRIPTION_API_KEY`, `TRANSCRIPTION_MODEL` (Default `whisper-1`) | Status `disabled` | + +- Jede KI-Nutzung wird in `AiGeneration` protokolliert (Art, Provider, Modell, Bezug, Tokens ein/aus, + auslösender Nutzer, Ein-/Ausgabe). +- **Kostenbremse:** `AI_MONTHLY_TOKEN_LIMIT` ist die Plattform-Vorgabe für Tokens (ein + aus) je Mandant je + Kalendermonat (UTC), `0` = unbegrenzt. Mandantenadministratoren können unter `/settings/lotse` einen + eigenen Wert setzen (`TenantSettings.aiMonthlyTokenLimit`; leer = Plattform-Vorgabe, `0` = unbegrenzt). + Ist das Kontingent aufgebraucht, lehnt der Lotse neue Entwürfe/Zusammenfassungen ab („Kontingent + aufgebraucht“), die Import-Extraktion fällt auf manuelle Erfassung zurück. Geprüft wird vor jedem + Aufruf – ein laufender Aufruf kann das Limit einmalig überschreiten. Transkription (Audio) liefert keine + Tokens und wird nicht gezählt. +- Datenschutz: Anbieter (Anthropic, Transkriptions-API) sind Auftragsverarbeiter, daher AVV und + Drittlandbewertung vor Aktivierung klären. Die Worker brauchen Egress (Netz `default`). + +### 7.3 Aufbewahrung KI-Protokoll + +`AI_GENERATION_RETENTION_DAYS` (Default 180): Der Job `ai-retention` (Queue gleichen Namens) läuft +täglich im `craftvia-worker` (BullMQ-Job-Scheduler `ai-retention-daily`, beim Worker-Start registriert, +idempotent auch bei mehreren Replikas). Er leert Ein- und Ausgaben (`input`/`output`) von +`AiGeneration`-Einträgen, die älter als die Frist sind, und entfernt den Personenbezug (`createdById`). +Metadaten (Art, Modell, Tokens, Zeitpunkt, Bezug) bleiben für Kosten- und Nachvollziehbarkeit erhalten; +je Mandant wird ein Audit-Eintrag `ai_generation_retention` geschrieben. Die Frist mit dem DSB abstimmen. +Die Variable muss im `craftvia-worker` gesetzt sein. + +## 8. Rate Limits + +| Bereich | Env | Default | Zählung | +|---|---|---|---| +| REST-API `/api/v1/**` | `API_RATE_LIMIT_PER_MINUTE` | 300 | je Nutzer pro Minute | +| Einsatz/Sync: `/api/v1/sync`, `/api/v1/uploads`, `/api/v1/field/**` | `API_FIELD_RATE_LIMIT_PER_MINUTE` | 1200 | je Nutzer pro Minute | +| Passwort-Reset, Alt-Passwort-Prüfung, E-Mail-Änderung | fest (`src/server/rate-limit.ts`) | 5–10 je Fenster | je IP und je Konto | + +Das Field-Limit ist höher, weil die PWA nach Offline-Phasen Outbox-Batches (≤ 50 Ops) und Fotos in +Schüben nachsendet. Wird es zu knapp gewählt, laufen die Clients in Retry/Backoff, und die Sync-Seite zeigt +Fehler. Limits je Prozess gelten pro App-Instanz. Bei mehreren Replicas multipliziert sich das +effektive Limit. + +## 9. Prebuilt-Images (Registry) + +Wenn der Host-Build in Coolify zu lange dauert, die Images auf einem Build-Host (amd64) bauen, in die +Registry pushen und in Coolify `docker-compose.coolify.prebuilt.yml` verwenden. + +| Image | Target | Dienste | +|---|---|---| +| `${REGISTRY}/craftvia-app:${IMAGE_TAG}` | `runner` | app | +| `${REGISTRY}/craftvia-migrate:${IMAGE_TAG}` | `migrate` | migrate, worker, backup-worker, garage-provision | +| `${REGISTRY}/craftvia-worker:${IMAGE_TAG}` | `worker` | craftvia-worker | +| `${REGISTRY}/craftvia-garage:${IMAGE_TAG}` | `garage` | garage | + +```bash +docker login +REGISTRY=registry.example.com/craftvia ALSO_MAIN=true ./scripts/build-and-push-images.sh +# worker-Image (bis das Skript es mitbaut): +docker build --platform linux/amd64 --target worker -t registry.example.com/craftvia/craftvia-worker:main . +docker push registry.example.com/craftvia/craftvia-worker:main +``` + +**Gotchas:** Coolify reicht `IMAGE_TAG` nicht zuverlässig in die Compose-Interpolation, daher immer auch +`:main` pushen. Coolify entfernt alte Container **vor** dem Pull: erst alle Images pushen, dann +Redeploy, sonst ist die Umgebung unten. Registry-Token mit Minimalrechten (`read/write:package`) +verwenden und nach Klartext-Nutzung widerrufen. + +## 10. Smoke nach Deploy + +1. `migrate` und `garage-provision` mit Exit 0 beendet. `app` ist healthy, `craftvia-worker`, `worker` und + `backup-worker` laufen, im Log steht `[worker] listening on report-pdf` usw. +2. `https://app.craftvia.example/login` lädt mit gültigem Zertifikat. `/sw.js` und `/site.webmanifest` sind ohne + Session erreichbar (PWA). +3. Login Backoffice → `/dashboard`, `/work-orders`, `/customers`, `/reports`. Login Monteur → `/m`. +4. Datei-Upload an einem Auftrag und Download über `/files/` (prüft Garage + S3-Keys). +5. Bericht freigeben → PDF erscheint am Auftrag (prüft Queue, craftvia-worker, Chromium). +6. Optional: Import-PDF hochladen → Extraktion (bei gesetztem API-Key). Sprachnotiz → Transkription. +7. Test-Mail (z. B. Passwort-Reset) kommt an bzw. steht nachvollziehbar auf `pending`. +8. Plattform-Login `/platform/login` → `/admin`, `/admin/backup` zeigt das Backup-Ziel. +9. Bei aktiver RLS: Login + Auftragsliste funktionieren (sonst prüfen: `RLS_DATABASE_URL`, Rolle hat LOGIN). + +Automatisierter HTTP-Smoke mit Session-Cookie (ohne Passworteingabe, braucht DB-Zugriff und +`AUTH_SECRET`): `BASE=https://app.craftvia.example npx tsx scripts/smoke-auth.ts` (z. B. im +`migrate`-Container oder von einem Admin-Host mit Tunnel zur DB). + +## 11. Backup & Restore + +Zwei Ebenen, **beide** sind nötig: DB **und** Objektspeicher. + +### 11.1 Ebene A: Cluster (gesamte Datenbank + Volumes) + +- **Postgres:** mindestens täglich `pg_dump -Fc` (Coolify Scheduled Task) in einen **separaten**, + verschlüsselten Speicher. Für PITR pgBackRest/wal-g mit WAL-Archiving und `repo-cipher-type=aes-256-cbc`. + Deckt auch die globalen Tabellen (Identity, Plattform-Admins, Kataloge) ab. + ```bash + docker exec pg_dump -U craftvia -d craftvia -Fc > craftvia-$(date +%F).dump + # Restore in leere DB (App + Worker gestoppt): + docker exec -i pg_restore -U craftvia -d craftvia --clean --if-exists < craftvia-YYYY-MM-DD.dump + ``` +- **Garage:** `garage_meta` (Bucket-/Key-/Layout-Definitionen, **kritisch**) und `garage_data` + sichern, z. B. mit restic (eigenes Repo, eigenes Passwort) oder als Volume-Snapshot bei gestopptem + `garage`. Ohne `garage_meta` sind die Objektdaten nicht adressierbar. +- **Volume `backups`:** enthält lokale App-Backup-Artefakte (Ebene B), mitsichern. +- **Host-Encryption:** Daten-Volumes auf LUKS bzw. provider-verschlüsseltem Block-Storage. Das + Boot-Unlock-Verfahren dokumentieren. +- **Restore-Test** mindestens quartalsweise in eine Wegwerf-Umgebung, Ergebnis protokollieren. + +### 11.2 Ebene B: Mandanten-Export/-Restore und DSGVO (Betreiber-Portal) + +- Ziel der Artefakte in `/admin/backup` wählbar (Lokal = Volume `/app/.backups` oder S3). Die + Konfiguration liegt verschlüsselt in der DB. Präzedenz: DB-Config → `S3_*`/`BACKUP_LOCAL_DIR` → lokaler Default. +- Export, Restore und DSGVO-Export je Mandant unter `/admin/[id]` (Plattform-Full-Admin + MFA-Step-up), ausgeführt + vom `backup-worker`. Die Artefakte sind mit `BACKUP_ENC_KEY` (AES-256-GCM) verschlüsselt, der Restore arbeitet + nur innerhalb von `tenant_id` und betrifft keine anderen Mandanten. `TENANT_MODELS` in `src/server/db.ts` und + `src/server/backup/topology.ts` müssen jede Tenant-Tabelle enthalten. + +### 11.3 Restore-Kohärenz (Vorbedingung) + +`PASSWORD_PEPPER`, `MFA_ENC_KEY` und `BACKUP_ENC_KEY` stehen **nicht** im Backup. Ein Restore in eine +Umgebung mit anderen Werten macht Logins (Pepper), MFA (`MFA_ENC_KEY`) bzw. das Entschlüsseln +der Artefakte (`BACKUP_ENC_KEY`) unmöglich. Vor jedem Restore die Secrets der Quellumgebung +bereitstellen oder einen Passwort-/MFA-Reset einplanen. Cross-Environment-Restores (prod → staging) +sind nur so lauffähig. + +## 12. Update & Rollback + +**Update (Standard):** +1. CI grün (Gate-Job: migrate, seed, tsc, lint, build, Tests). +2. Migrationen der Release sichten. Bei destruktiven Änderungen vorher `pg_dump` (§11.1). +3. Coolify-Redeploy (bzw. Images pushen, dann Redeploy). `migrate` läuft vor app und Workern. +4. Smoke (§10). Neue Env-Variablen aus den `.env.*.example`-Dateien vorher eintragen. + +**Rollback:** +- **Ohne Schemaänderung:** vorheriges Image-Tag als `:main` retaggen und pushen (Prebuilt) bzw. vorherigen Commit + deployen. Die Worker ziehen dasselbe Tag mit. +- **Mit Schemaänderung:** Prisma-Migrationen haben kein automatisches Down. Entweder Vorwärts-Fix + (neue Migration), oder App + Worker stoppen, DB aus dem Pre-Deploy-Dump wiederherstellen (§11.1), + dann den alten Stand deployen. `prisma migrate deploy` toleriert in der DB angewandte Migrationen, + die im alten Code fehlen. +- **Queues:** Beim Rollback können Jobs eines neueren Payload-Formats in Redis liegen. Vor dem Rollback + Worker-Logs prüfen, fehlgeschlagene Jobs nach dem Fix erneut anstoßen (z. B. „PDF erzeugen"). + +## 13. Go-Live-Checkliste + +- [ ] Frische, starke Secrets je Umgebung, im Passwortmanager + versiegelte Offline-Kopie +- [ ] `RUN_DEMO_SEED=false`, Bootstrap-Admin-Passwort geändert, `BOOTSTRAP_ADMIN=false` +- [ ] HTTPS aktiv, `AUTH_URL`/`APP_BASE_URL` korrekt +- [ ] `RLS_ENFORCED=true` + `RLS_DATABASE_URL`, Smoke mit aktiver RLS bestanden +- [ ] `craftvia-worker` läuft, Test-PDF erzeugt +- [ ] SMTP mit SPF/DKIM/DMARC der Absenderdomain +- [ ] KI: AVV geklärt, `AI_MONTHLY_TOKEN_LIMIT` und `AI_GENERATION_RETENTION_DAYS` festgelegt +- [ ] Backups Ebene A (Postgres + `garage_meta`/`garage_data` + `backups`) eingerichtet, Restore-Test dokumentiert +- [ ] Monitoring: Uptime-Check auf die App-URL, Log-Aggregation, Alarm bei Worker-Neustarts +- [ ] Firewall (80/443/SSH), SSH-Key-Login, unattended-upgrades diff --git a/docs/craftvia/lanes/betrieb.md b/docs/craftvia/lanes/betrieb.md new file mode 100644 index 0000000..ac83831 --- /dev/null +++ b/docs/craftvia/lanes/betrieb.md @@ -0,0 +1,97 @@ +# Lane L10b – Betrieb & Aufräumen (`lane/betrieb`) + +Stand: 2026-09-15 · Basis `a7d4b02` (`feature/craftvia-mvp`, L1–L9 integriert) · Spec §27, §31, §34, §42, §43 · ARCHITEKTUR §4.6, §4.8 + +## 1. Umfang / erfüllte Punkte + +| Punkt | Umsetzung | +|---|---| +| **API-Doku** | `src/lib/api/openapi.ts` (OpenAPI 3.1, statisch gepflegt): alle 23 v1-Pfade mit 29 Operationen, `cookieAuth`, einheitliches Fehlerformat, Pagination, Idempotenz (`clientOpId`, Upload-`clientId`), Konflikte, Rate Limits, Recht/Modul je Operation (`x-craftvia-module`, `x-craftvia-permissions`). `GET /api/v1/openapi.json` (angemeldet). Kurzdoku `docs/craftvia/API.md`. Test prüft, dass jede `route.ts` dokumentiert ist. | +| **Rate Limiting** | `requireApiContext` zählt je Nutzer über `rate-limit.ts`: Bucket `api` (`API_RATE_LIMIT_PER_MINUTE`, Default 300/min) für alle Module, `apiField` (`API_FIELD_RATE_LIMIT_PER_MINUTE`, Default 1200/min) für `field` (sync, uploads, bundle, Dokument-Cache). Überschritten → 429 `rate_limited` + `Retry-After`. Offline-Outbox behandelt 429 als transient (Backoff). | +| **Deploy/Betrieb** | Compose (Coolify + prebuilt): Service `craftvia-worker` (Target `worker`, Chromium, `shm_size 1gb`, Härtung wie übrige Worker). `Dockerfile`: worker-Stage mit `HOME=/home/app` (Chromium-Profil als non-root, vorher startete Chromium nicht). Env-Beispiele mit allen Craftvia-Variablen. `docs/craftvia/DEPLOY.md` (Architektur, Domains, Secrets, Worker, Migrationen, RLS-Aktivierung, Backup/Restore, KI, Rate Limits, Aufbewahrung, Smoke, Update/Rollback, Go-Live-Checkliste). CI (`.github`, `.gitea`): Job `gate` mit Postgres (pgvector) + Redis-Service. `build-and-push-images.sh` baut `craftvia-worker`. Certvia-/ISMS-Dokumente → `docs/_certvia-archiv/` (mit README). | +| **a) API-Kontexte** | `imports/_context.ts`, `sync/api-context.ts`, `reports/http.ts`, `work-orders/_http.ts` entfernt; alle 22 Fachrouten nutzen `requireApiContext` + `withApi`/`toErrorResponse`. `withApi` prüft Same-Origin für jede Mutation vor der Anmeldung (fehlte vorher bei imports, reports, work-orders). | +| **b) Konflikt übernehmen** | Entscheidung: **`report.submit` wird unterstützt.** `sync-reapply.ts` delegiert an `apply.ts#reapplyOperation` (gleiche Payload-Validierung und Services wie der Sync, ohne `baseVersion`, als Gerätenutzer). Erlaubt: `work_order.transition`, `report.submit` (mit gespeichertem `aiReviewed`); sonst `invalid reapply_unsupported`. Hinweistext der Konfliktliste angepasst. Ältere gespeicherte Ops ohne `payload.workOrderId` nutzen `entityId`. | +| **c) Bundle + Offline** | `getFieldBundle` liefert je Auftrag `mySession` (`{ id, status, startedAt }` der eigenen aktiven Session oder `null`). `bundle-core.ts#initialSession` nutzt es; nur für Bundles ohne Feld (vor L10b gespeichert) weiter Näherung über den Auftragsstatus. | +| **d) mergeCustomers** | über `inTransaction` (sequenziell, geschützter Statuswechsel `status ≠ merged`), einbettbar in äußere Transaktionen. | +| **e) Audit `read`** | `AuditAction` + Label „Lesezugriff"/„Read access" im Audit-Viewer; Notdienst-Kunden-/Objektsuche protokolliert `read`. | +| **f) clientId je Mandant** | Migration `20260915090000_betrieb_client_id_per_tenant`: 8 Tabellen `@@unique([tenantId, clientId])`. Kein Code nutzte `findUnique` über `clientId` (Replays laufen über `findFirst` im Mandanten-Client). | +| **g) Backoffice mobil** | `components/backoffice-frame.tsx`: unter **1024 px** Sidebar als Drawer hinter Menü-Button (44 px, `aria-expanded`/`aria-controls`, schließt bei Navigation, Hintergrund, Escape; geschlossen `invisible` → nicht fokussierbar). Damit sind 768 px und 375 px abgedeckt; ab 1024 px unverändert statisch. Header kompakter (Name/Mandant ab `sm`). | +| **h) Audit nach Commit** | **Umgesetzt, nicht zu invasiv:** `writeAuditLog` puffert innerhalb `inTransaction` (AsyncLocalStorage in `audit.ts`, `withDeferredAudit`) und schreibt nach dem Commit; Rollback verwirft die Einträge, `denied` bleibt. Verschachtelte Transaktionen teilen den äußeren Puffer. Schreibfehler beim Flush werden geloggt (die Fachänderung ist schon committet). | +| **i) Berichtseditor** | mobiler `ReportEditor` hält Eingaben mit `useOfflineDraft("report:")`; Wiederherstellung nur, solange die Servertexte die Basis des Entwurfs sind (sonst gewinnt der Server, z. B. nach Übernahme eines Lotse-Vorschlags); nach Speichern/Absenden gelöscht; Hinweis „Entwurf wiederhergestellt." | +| **j) report.submit offline** | `lib/sync/ops.ts`: Zod-Schemas `report.save_draft` `{ workOrderId, reportId, texts }` und `report.submit` `{ workOrderId, reportId, aiReviewed? }`; Registry-Einträge → `services/reports/sync-ops.ts`. `baseVersion` → `expectedWorkOrderVersion`, `aiReviewed` wird durchgereicht (ohne → `rejected invalid`, `field aiReviewed`). Bericht muss zum Auftrag der Op gehören. `signature.capture` bleibt unregistriert (s. Lücken). | +| **k) Lotse-Betrieb** | **Aufbewahrung:** `services/lotse/retention.ts` leert `input`/`output` und `createdById` älter als `AI_GENERATION_RETENTION_DAYS` (Default 180), Metadaten bleiben, Audit `ai_generation_retention` je Mandant; Queue/Processor `ai-retention`, täglicher BullMQ-Job-Scheduler beim Start von `craftvia-worker`. **Kontingent:** `services/lotse/budget.ts`, Tokens ein+aus je Kalendermonat (UTC); `TenantSettings.aiMonthlyTokenLimit` (Migration `20260915091000_betrieb_ai_token_limit`, nur Spalte) vor Env `AI_MONTHLY_TOKEN_LIMIT` (Default 0 = unbegrenzt). Lotse-Entwurf/Zusammenfassung → `blocked budget_exceeded` mit Klartext; Import-Extraktion → manuelle Erfassung + Hinweis `ai_budget_exceeded`. `/settings/lotse`: Kontingent setzen (leer = Plattform, 0 = unbegrenzt), Verbrauch + „Kontingent aufgebraucht" (Text + Icon). | + +### Verhaltensänderungen (bewusst, dokumentiert in API.md/OpenAPI) +- Fehlerformat überall `{ error: { code, message, details? } }` (vorher bei imports/sync/reports teils `{ error: "code" }`). +- `invalid` → **422** (vorher 400 bei imports, sync, uploads, reports); `blocked` → **422** (vorher 409 bei L1-Routen und im Import-Adapter). Clients angepasst: Import-Uploader (neues Format), `lib/field/upload.ts` und Outbox (422 = endgültig ungültig). +- POST-Routen von imports/reports/work-orders verlangen jetzt Same-Origin (Server-zu-Server-Aufrufe ohne `Origin`/`Sec-Fetch-Site` sind weiter möglich). +- `test-einsatz-sync.ts`: Prüfung „nicht verfügbare Op" nutzt `signature.capture`, weil `report.save_draft` jetzt registriert ist (Begründung j). + +## 2. Dateien + +**Neu:** `src/lib/api/openapi.ts`, `src/app/api/v1/openapi.json/route.ts`, `src/server/services/reports/{dto,sync-ops}.ts`, `src/server/services/lotse/{retention,budget}.ts`, `src/server/jobs/processors/ai-retention.ts`, `src/components/backoffice-frame.tsx`, `prisma/migrations/20260915090000_betrieb_client_id_per_tenant/`, `prisma/migrations/20260915091000_betrieb_ai_token_limit/`, `docs/craftvia/{API,DEPLOY}.md`, `docs/_certvia-archiv/README.md`, `scripts/test-betrieb-{api,sync,audit}.ts`, `scripts/smoke-betrieb.ts`, dieser Bericht. + +**Entfernt:** `src/app/api/v1/imports/_context.ts`, `src/app/api/v1/work-orders/_http.ts`, `src/server/services/sync/api-context.ts`, `src/server/services/reports/http.ts`. + +**Geändert (Aufräumpunkte a–k):** `src/server/api/{respond,context}.ts`, `src/server/rate-limit.ts`, alle `src/app/api/v1/**/route.ts` außer customers/sites, `src/components/imports/uploader.tsx`, `src/lib/field/upload.ts`, `src/lib/offline/{outbox,bundle-core,types}.ts`, `src/lib/sync/ops.ts`, `src/server/services/sync/{apply,external-ops}.ts`, `src/server/services/work-orders/sync-reapply.ts`, `src/server/services/field/queries.ts`, `src/server/audit.ts`, `src/server/services/context.ts`, `src/server/services/customers/merge.ts`, `src/server/services/emergency/lookup.ts`, `src/server/services/lotse/{settings,draft-report,voice}.ts`, `src/server/services/imports/process.ts`, `src/lib/imports/extraction.ts`, `src/lib/lotse/action-state.ts`, `src/server/actions/lotse-settings.ts`, `src/app/(app)/settings/lotse/page.tsx`, `src/app/(app)/layout.tsx`, `src/components/reports/mobile/report-editor.tsx`, `src/server/jobs/{queues,processors/index}.ts`, `scripts/craftvia-worker.ts`, `prisma/schema.prisma`, `messages/{de,en}/{nav,notifications,workOrders,lotse,imports}.json`, `scripts/test-einsatz-sync.ts`. + +**Deploy/Doku:** `Dockerfile`, `docker-compose.coolify.yml`, `docker-compose.coolify.prebuilt.yml`, `.env.example`, `.env.prod.example`, `.env.coolify.example`, `.github/workflows/ci.yml`, `.gitea/workflows/ci.yml`, `scripts/build-and-push-images.sh`, `docs/**` (Archiv-Verschiebung). + +**Minimale Eingriffe außerhalb der Ownership (je 1–3 Zeilen):** +- `README.md` Abschnitt „Betrieb": Verweise auf `docs/craftvia/DEPLOY.md`/`API.md` statt archivierter Certvia-Docs, Target `worker`. +- Kommentar-Pfade auf `docs/_certvia-archiv/…`: `scripts/garage-provision.ts`, `scripts/test-auth-selfservice.ts`, `scripts/bootstrap-admin.ts`, `scripts/test-garage-storage.ts`, `deploy/garage.toml`. +- `src/server/jobs/processors/index.ts` (erlaubter Einzeiler `ai-retention`), `messages/{de,en}/nav.json` (Menü-Labels). +- `src/server/audit.ts` (Fundament) – ausdrücklich Aufräumpunkte e/h. + +## 3. Tests + +| Skript | Prüfungen | Inhalt | +|---|---|---| +| `test-betrieb-api.ts` | 150 | jede v1-Route: `requireApiContext`, keine lane-lokalen Kontexte, Fehler über respond.ts; ohne Sitzung 401 im einheitlichen Format (alle Methoden); jede Mutation mit fremdem Origin bzw. `Sec-Fetch-Site: cross-site` → 403 vor der Anmeldung; Fehler-Mapping (404/403/422/409/422 blocked mit details, ZodError mit Feldpfaden, 500 ohne interne Details); `readJsonObject`; Rate Limit je Nutzer (Standard/Einsatz getrennt, 429 + Retry-After, anderer Nutzer unabhängig); OpenAPI 3.1 deckt jede `route.ts` ab, keine veralteten Pfade, Route liefert das Dokument | +| `test-betrieb-sync.ts` | 36 | c) Bundle `mySession` (Monteur laufend/pausiert, Teamleiter ohne eigene Session → `null`, Offline-Ableitung inkl. altem Bundle), Mandant B und Monteur ohne Zuweisung sehen den Auftrag nicht; f) gleiche Session-/Notiz-`clientId` in A und B, Idempotenz je Mandant, DB-Unique im selben Mandanten; j) `report.save_draft`, `report.submit` ohne reportId/ohne `aiReviewed` → invalid, Mandant B/Monteur ohne Zuweisung → not_found, fremder Bericht über eigenen Auftrag → not_found, veraltete Version → conflict; b) Übernehmen: Mandant B → not_found, Monteur → forbidden, nicht konfliktbehaftete Op → invalid, Backoffice → Bericht submitted + resolved, zweites Übernehmen → not_found, unzulässiger Übergang → abgelehnt ohne Änderung | +| `test-betrieb-audit.ts` | 48 | h) aufgeschoben/nach Commit/Rollback (nur `denied` bleibt)/verschachtelt/direkt; d) Merge: Monteur forbidden, Mandant B not_found, Rollback in äußerer Transaktion (nicht zusammengeführt, Objekt nicht umgehängt, kein Audit), Erfolg + Audit Quelle/Ziel, doppelt → conflict; e) Suche → Audit `read`, Mandant B findet nichts, ohne `emergency:create` → forbidden; k) Aufbewahrung (Default/Env, Processor registriert, Inhalte + Personenbezug entfernt, Metadaten bleiben, junge Einträge unverändert, Mandant B unberührt, Audit, idempotent, längere Frist) und Kontingent (unbegrenzt, Monteur forbidden, Mandanten-Limit → blocked `budget_exceeded`, Audit, Einstellungsseite, Mandant B unabhängig, Env-Default, Vormonat zählt nicht, 0 = unbegrenzt, Feld weggelassen = unverändert, null = Plattform) | + +**Gate (`npm run gate`) grün:** prisma generate, tsc, lint (0 Fehler, 3 Warnungen in fremden Dateien: `layout.tsx` ungenutzter Import `CraftviaLogo` – vorbestehend, `services/field/mime.ts`, u. a.), build inkl. Modul-Guard-Check, **52/52 Testskripte**. Lane-DB `craftvia_betrieb`, `RLS_DATABASE_URL` auf dieselbe DB. + +**Lauf mit `RLS_ENFORCED=true npm run test`:** 50/52 grün. Die zwei Abweichungen betreffen Dateien, die L10b nicht verändert hat (Diff zu `a7d4b02` nur ein Kommentar in `test-garage-storage.ts`; `db.ts`/`storage` unverändert): +- `test-tenant-isolation.ts` erwartet für `findUnique` über einen fremden Compound-Key (Role `tenantId_key`) einen Throw des Owner-Guards; mit scharfer RLS liefert die Abfrage `null` (kein Datenabfluss, aber andere Semantik). +- `test-garage-storage.ts` lädt `storage/backup-store.ts` → `db.ts` bricht fail-secure ab („RLS_ENFORCED=true, aber RLS_DATABASE_URL fehlt") – der Test läuft ohne die RLS-Umgebung. +→ Beide Tests sind auf den Owner-Betrieb ausgelegt; Anpassung für einen RLS-Modus gehört ins Fundament/L10a. + +**HTTP-Smoke** (Dev-Server :3111, Session-Cookies ohne Passworteingabe): `scripts/smoke-betrieb.ts` **18/18 grün** – anonym 401 JSON (`openapi.json`, `sync`); Admin: OpenAPI 3.1 (23 Pfade), customers mit Pagination, 404 `not_found` (work-orders, reports/pdf) im einheitlichen Format, fremder Origin → 403, Array-Body → 422, kaputtes JSON bei imports/confirm → 422, `/settings/lotse` mit KI-Kontingent, `/dashboard` mit Menü-Button, `/settings/audit?action=read`, Konfliktliste mit neuem Hinweis; Monteur: Bundle mit `mySession`, leerer Sync-Batch → 422, `report.submit` ohne reportId → `rejected invalid`, `/m` 200, `/dashboard` → 307. Mandantentest demo2 übersprungen (aktueller Seed enthält keine Aufträge; L10a liefert Demo-Daten). Zusätzlich `scripts/smoke-auth.ts` (Architekt) gegen :3111 **19/19 grün** (Backoffice-Seiten mit neuem Layout, Monteur-Seiten). + +**Docker:** `docker build --target runner` (400 MB) und `--target worker` (2,68 GB) lokal erfolgreich; im Worker-Image startet Chromium mit `cap_drop ALL`/`no-new-privileges`/1 GB shm und erzeugt ein PDF. Kein Push. + +## 4. Stubs / Abhängigkeiten + +- Keine neuen Stubs. Der L2-Stub `sync-reapply.ts` ist durch den L4-Dispatcher ersetzt. +- Genutzt: L1 `requireApiContext`/respond.ts, L4 `applyOperations`/Registry, L5 `submitReport`/`updateReportTexts`/`requireVisibleReport`, L7 `useOfflineDraft`, L9 `applyLotseReview`, L2 `applySyncConflict`. + +## 5. Bekannte Lücken / offene Punkte + +1. **Rate Limit je App-Instanz** (In-Memory wie SEC2); bei mehreren Replikas zählt jede Instanz getrennt. Geteilter Redis-Zähler = SEC5. +2. **`/api/v1` nur mit Session-Cookie** – kein Token für Integrationen (unverändert). +3. **`signature.capture` offline** nicht registriert: `/api/v1/uploads` kennt keine Upload-Art für das Unterschriftsbild (`kind: signature`, PNG). Die mobile Berichts-/Unterschrift-UI nutzt weiterhin Server Actions; die neuen Ops `report.save_draft`/`report.submit` stehen der Outbox bereit, die UI ist aber noch nicht auf `submitOp` umgestellt (L5/L7). +4. **Events in Transaktionen:** `emitEvent` innerhalb von `inTransaction` wird weiterhin sofort ausgeführt (Benachrichtigung bei späterem Rollback möglich) – analog zu h lösbar. +5. **Kontingent:** Monatsgrenze UTC; ein laufender Aufruf kann das Limit einmalig überschreiten; Transkription (Audio) zählt nicht; keine Warnung vor Erreichen. +6. **Aufbewahrung:** Sprachnotiz-Zusammenfassungen älter als die Frist können nicht mehr übernommen werden (Ausgabe geleert); das KI-Protokoll zeigt dann `null`. +7. **Sidebar** bewusst ab < 1024 px eingeklappt (Anforderung ≤ 768 px ist enthalten); **visuelle Browser-Prüfung nicht durchgeführt** (Login im Browser hätte eine Sitzung/Credential-Eingabe erfordert) – geprüft per Server-Rendering (Markup, Labels). Bitte manuell bei 375/768/1024 px ansehen. +8. **`POST /api/v1/work-orders/{id}/documents`** gibt das Dokument inkl. `storageKey` zurück (L2-Verhalten, unverändert) – interner Schlüssel sollte nicht nach außen. +9. **413 vs. 422:** Größenprüfungen in Import-/Dokument-Services melden `invalid file_too_large` (422); nur die Route-eigenen Vorprüfungen (`/uploads`) antworten 413. +10. **CI-Job nicht ausgeführt** (kein Runner): offen, ob `prisma db execute --stdin` für das `craftvia_app`-Passwort und Chromium im Gitea-Runner verfügbar sind (PDF-Test skippt sonst). +11. **Prebuilt-Deploy** braucht ein gepushtes `craftvia-worker`-Image (Skript baut es jetzt, Push nicht ausgeführt). +12. **RLS-Modus-Tests** (`test-tenant-isolation`, `test-garage-storage`) s. §3. + +## 6. Screens / Routen + +| Route | Änderung | +|---|---| +| `GET /api/v1/openapi.json` | neu – OpenAPI 3.1 | +| alle `/api/v1/**` | einheitliches Fehlerformat, Same-Origin für Mutationen, Rate Limit | +| `POST /api/v1/sync` | Ops `report.save_draft`, `report.submit` | +| `GET /api/v1/field/bundle` | `orders[].mySession` | +| Backoffice-Layout (alle `(app)`-Seiten) | Menü-Button + Drawer unter 1024 px | +| `/settings/lotse` | monatliches KI-Kontingent + Verbrauch | +| `/settings/audit` | Aktion „Lesezugriff" | +| `/work-orders/conflicts` | Übernehmen auch für abgesendete Berichte | +| `/m/orders/[id]/report` | Offline-Entwurf im Berichtseditor | diff --git a/messages/de/imports.json b/messages/de/imports.json index b64c649..d4bef60 100644 --- a/messages/de/imports.json +++ b/messages/de/imports.json @@ -170,7 +170,8 @@ "email_invalid": "{field}: E-Mail-Adresse hat kein gültiges Format.", "phone_invalid": "{field}: Telefonnummer hat kein gültiges Format.", "end_before_start": "{field}: Ende liegt vor dem Beginn.", - "manual_entry": "Keine automatische Erkennung verfügbar. Bitte manuell erfassen." + "manual_entry": "Keine automatische Erkennung verfügbar. Bitte manuell erfassen.", + "ai_budget_exceeded": "Das monatliche KI-Kontingent ist aufgebraucht – deshalb keine automatische Erkennung." }, "positions": { "name": "Bezeichnung", diff --git a/messages/de/lotse.json b/messages/de/lotse.json index a879388..313ff19 100644 --- a/messages/de/lotse.json +++ b/messages/de/lotse.json @@ -76,7 +76,8 @@ "no_transcript": "Die Sprachnotiz hat noch keinen Text.", "pending": "Die Transkription läuft noch.", "conflict": "Inzwischen geändert. Bitte Seite neu laden.", - "invalid": "Bitte Eingaben prüfen." + "invalid": "Bitte Eingaben prüfen.", + "budget_exceeded": "Das monatliche KI-Kontingent des Betriebs ist aufgebraucht. Bitte den Bericht selbst schreiben oder das Büro fragen." }, "settings": { "back": "Einstellungen", @@ -96,6 +97,11 @@ "du": "du" }, "save": "Speichern", + "budget": "Monatliches KI-Kontingent (Tokens)", + "budgetHint": "Leer = Vorgabe der Plattform ({platform}). 0 = unbegrenzt. Ist das Kontingent aufgebraucht, bereitet der Lotse bis Monatsende nichts mehr vor.", + "budgetUnlimited": "unbegrenzt", + "budgetUsage": "Verbraucht seit {since}: {used} von {limit}", + "budgetExceeded": "Kontingent aufgebraucht", "dataTitle": "Welche Daten an wen gehen", "draftProvider": "Berichtsentwurf und Zusammenfassung", "transcriptionProvider": "Transkription von Sprachnotizen", diff --git a/messages/de/nav.json b/messages/de/nav.json index 72a2338..efed3cc 100644 --- a/messages/de/nav.json +++ b/messages/de/nav.json @@ -14,5 +14,7 @@ "audit": "Audit-Protokoll", "email": "E-Mail-Versand", "lotse": "Lotse (KI)", - "admin": "Admin-Konsole" + "admin": "Admin-Konsole", + "openMenu": "Menü öffnen", + "closeMenu": "Menü schließen" } diff --git a/messages/de/notifications.json b/messages/de/notifications.json index 4262b25..3689c31 100644 --- a/messages/de/notifications.json +++ b/messages/de/notifications.json @@ -147,6 +147,7 @@ "logout": "Abmeldung", "denied": "Abgelehnt", "export": "Export", + "read": "Lesezugriff", "import": "Import", "provision": "Eingerichtet", "approve": "Freigegeben", diff --git a/messages/de/workOrders.json b/messages/de/workOrders.json index 3c02e50..0cb6433 100644 --- a/messages/de/workOrders.json +++ b/messages/de/workOrders.json @@ -406,7 +406,7 @@ "applied": "Übernommen.", "discarded": "Verworfen.", "applyHint": "Übernehmen wendet den Vorgang erneut auf den aktuellen Stand an – im Namen der Person, die ihn erfasst hat.", - "scopeHint": "Übernehmen ist derzeit nur für Statusänderungen möglich; andere Vorgänge bitte im Auftrag nacharbeiten." + "scopeHint": "Übernehmen ist für Statusänderungen und abgesendete Berichte möglich; andere Vorgänge bitte im Auftrag nacharbeiten." }, "errors": { "not_found": "Nicht gefunden oder keine Berechtigung.", diff --git a/messages/en/imports.json b/messages/en/imports.json index abf4b56..45b98f8 100644 --- a/messages/en/imports.json +++ b/messages/en/imports.json @@ -170,7 +170,8 @@ "email_invalid": "{field}: e-mail address format is invalid.", "phone_invalid": "{field}: phone number format is invalid.", "end_before_start": "{field}: end is before start.", - "manual_entry": "Automatic recognition is not available. Please enter manually." + "manual_entry": "Automatic recognition is not available. Please enter manually.", + "ai_budget_exceeded": "The monthly AI allowance is used up – therefore no automatic recognition." }, "positions": { "name": "Description", diff --git a/messages/en/lotse.json b/messages/en/lotse.json index 104753c..00b78a4 100644 --- a/messages/en/lotse.json +++ b/messages/en/lotse.json @@ -76,7 +76,8 @@ "no_transcript": "The voice note has no text yet.", "pending": "The transcription is still running.", "conflict": "Changed in the meantime. Please reload the page.", - "invalid": "Please check your input." + "invalid": "Please check your input.", + "budget_exceeded": "This business has used up its monthly AI allowance. Please write the report yourself or ask the office." }, "settings": { "back": "Settings", @@ -96,6 +97,11 @@ "du": "Informal (du)" }, "save": "Save", + "budget": "Monthly AI allowance (tokens)", + "budgetHint": "Empty = platform default ({platform}). 0 = unlimited. Once used up, Lotse prepares nothing until the end of the month.", + "budgetUnlimited": "unlimited", + "budgetUsage": "Used since {since}: {used} of {limit}", + "budgetExceeded": "Allowance used up", "dataTitle": "Which data goes where", "draftProvider": "Report draft and summary", "transcriptionProvider": "Voice note transcription", diff --git a/messages/en/nav.json b/messages/en/nav.json index 46a6776..91f4ba6 100644 --- a/messages/en/nav.json +++ b/messages/en/nav.json @@ -14,5 +14,7 @@ "audit": "Audit log", "email": "E-mail delivery", "lotse": "Lotse (AI)", - "admin": "Admin console" + "admin": "Admin console", + "openMenu": "Open menu", + "closeMenu": "Close menu" } diff --git a/messages/en/notifications.json b/messages/en/notifications.json index 2bd8e15..3206fdf 100644 --- a/messages/en/notifications.json +++ b/messages/en/notifications.json @@ -147,6 +147,7 @@ "logout": "Sign-out", "denied": "Denied", "export": "Export", + "read": "Read access", "import": "Import", "provision": "Provisioned", "approve": "Approved", diff --git a/messages/en/workOrders.json b/messages/en/workOrders.json index 24e6292..166e853 100644 --- a/messages/en/workOrders.json +++ b/messages/en/workOrders.json @@ -406,7 +406,7 @@ "applied": "Applied.", "discarded": "Discarded.", "applyHint": "Apply re-runs the operation against the current state – on behalf of the person who recorded it.", - "scopeHint": "Apply currently supports status changes only; please rework other operations in the order." + "scopeHint": "Apply supports status changes and submitted reports; please rework other operations in the order." }, "errors": { "not_found": "Not found or no permission.", diff --git a/prisma/migrations/20260915090000_betrieb_client_id_per_tenant/migration.sql b/prisma/migrations/20260915090000_betrieb_client_id_per_tenant/migration.sql new file mode 100644 index 0000000..7247323 --- /dev/null +++ b/prisma/migrations/20260915090000_betrieb_client_id_per_tenant/migration.sql @@ -0,0 +1,53 @@ +-- L10b Betrieb & Aufräumen (Aufräumpunkt f, L8 offener Punkt 6): +-- Offline client ids are generated per device and only need to be unique within a tenant. +-- A global unique index let a replay with the same client id in another tenant fail with an +-- internal error (and leaked the existence of the id across tenants). Tables already carry +-- tenant RLS; no new tables. + +-- DropIndex +DROP INDEX "activity_notes_client_id_key"; + +-- DropIndex +DROP INDEX "material_usages_client_id_key"; + +-- DropIndex +DROP INDEX "photos_client_id_key"; + +-- DropIndex +DROP INDEX "reports_client_id_key"; + +-- DropIndex +DROP INDEX "signatures_client_id_key"; + +-- DropIndex +DROP INDEX "time_entries_client_id_key"; + +-- DropIndex +DROP INDEX "voice_notes_client_id_key"; + +-- DropIndex +DROP INDEX "work_sessions_client_id_key"; + +-- CreateIndex +CREATE UNIQUE INDEX "activity_notes_tenant_id_client_id_key" ON "activity_notes"("tenant_id", "client_id"); + +-- CreateIndex +CREATE UNIQUE INDEX "material_usages_tenant_id_client_id_key" ON "material_usages"("tenant_id", "client_id"); + +-- CreateIndex +CREATE UNIQUE INDEX "photos_tenant_id_client_id_key" ON "photos"("tenant_id", "client_id"); + +-- CreateIndex +CREATE UNIQUE INDEX "reports_tenant_id_client_id_key" ON "reports"("tenant_id", "client_id"); + +-- CreateIndex +CREATE UNIQUE INDEX "signatures_tenant_id_client_id_key" ON "signatures"("tenant_id", "client_id"); + +-- CreateIndex +CREATE UNIQUE INDEX "time_entries_tenant_id_client_id_key" ON "time_entries"("tenant_id", "client_id"); + +-- CreateIndex +CREATE UNIQUE INDEX "voice_notes_tenant_id_client_id_key" ON "voice_notes"("tenant_id", "client_id"); + +-- CreateIndex +CREATE UNIQUE INDEX "work_sessions_tenant_id_client_id_key" ON "work_sessions"("tenant_id", "client_id"); diff --git a/prisma/migrations/20260915091000_betrieb_ai_token_limit/migration.sql b/prisma/migrations/20260915091000_betrieb_ai_token_limit/migration.sql new file mode 100644 index 0000000..552b81c --- /dev/null +++ b/prisma/migrations/20260915091000_betrieb_ai_token_limit/migration.sql @@ -0,0 +1,7 @@ +-- L10b Betrieb & Aufräumen (Aufräumpunkt k, Spec §31 Kostenlimit): +-- Optional monthly AI token budget (input + output tokens of AiGeneration) per tenant. +-- NULL = platform default from env AI_MONTHLY_TOKEN_LIMIT, 0 = unlimited. +-- tenant_settings is already tenant-bound (RLS, TENANT_MODELS) — no new table. + +-- AlterTable +ALTER TABLE "tenant_settings" ADD COLUMN "ai_monthly_token_limit" INTEGER; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index a89235a..bc29a6c 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -77,6 +77,8 @@ model TenantSettings { billingRecipients String[] @default([]) @map("billing_recipients") // Lotse (L9): "sie" | "du"; null = neutral without pronouns (Brandbook §9.2) lotseAddressForm String? @map("lotse_address_form") + // L10b (Spec §31): monthly AI token budget (input + output) per tenant; null = env AI_MONTHLY_TOKEN_LIMIT, 0 = unlimited + aiMonthlyTokenLimit Int? @map("ai_monthly_token_limit") createdAt DateTime @default(now()) @map("created_at") updatedAt DateTime @updatedAt @map("updated_at") @@ -856,7 +858,7 @@ model MaterialUsage { notes String? photoId String? @map("photo_id") recordedById String? @map("recorded_by_id") - clientId String? @unique @map("client_id") // offline local id + clientId String? @map("client_id") // offline local id createdAt DateTime @default(now()) @map("created_at") updatedAt DateTime @updatedAt @map("updated_at") @@ -865,6 +867,7 @@ model MaterialUsage { workSession WorkSession? @relation(fields: [workSessionId], references: [id], onDelete: SetNull) @@index([tenantId, workOrderId]) + @@unique([tenantId, clientId]) @@map("material_usages") } @@ -890,7 +893,7 @@ model WorkSession { startLng Float? @map("start_lng") startedOffline Boolean @default(false) @map("started_offline") deviceInfo String? @map("device_info") - clientId String? @unique @map("client_id") + clientId String? @map("client_id") createdAt DateTime @default(now()) @map("created_at") updatedAt DateTime @updatedAt @map("updated_at") @@ -901,6 +904,7 @@ model WorkSession { @@index([tenantId, workOrderId]) @@index([tenantId, userId, status]) + @@unique([tenantId, clientId]) @@map("work_sessions") } @@ -925,7 +929,7 @@ model TimeEntry { corrected Boolean @default(false) correctionReason String? @map("correction_reason") correctedById String? @map("corrected_by_id") - clientId String? @unique @map("client_id") + clientId String? @map("client_id") createdAt DateTime @default(now()) @map("created_at") updatedAt DateTime @updatedAt @map("updated_at") @@ -933,6 +937,7 @@ model TimeEntry { @@index([tenantId, workSessionId]) @@index([tenantId, userId, startedAt]) + @@unique([tenantId, clientId]) @@map("time_entries") } @@ -956,7 +961,7 @@ model ActivityNote { kind ActivityNoteKind @default(general) text String voiceNoteId String? @unique @map("voice_note_id") - clientId String? @unique @map("client_id") + clientId String? @map("client_id") createdAt DateTime @default(now()) @map("created_at") updatedAt DateTime @updatedAt @map("updated_at") deletedAt DateTime? @map("deleted_at") @@ -965,6 +970,7 @@ model ActivityNote { voiceNote VoiceNote? @relation(fields: [voiceNoteId], references: [id], onDelete: SetNull) @@index([tenantId, workOrderId]) + @@unique([tenantId, clientId]) @@map("activity_notes") } @@ -1060,7 +1066,7 @@ model Photo { longitude Float? takenById String? @map("taken_by_id") includeInReport Boolean @default(true) @map("include_in_report") - clientId String? @unique @map("client_id") + clientId String? @map("client_id") createdAt DateTime @default(now()) @map("created_at") workOrder WorkOrder @relation(fields: [workOrderId], references: [id], onDelete: Cascade) @@ -1068,6 +1074,7 @@ model Photo { photoRequirement PhotoRequirement? @relation(fields: [photoRequirementId], references: [id], onDelete: SetNull) @@index([tenantId, workOrderId]) + @@unique([tenantId, clientId]) @@map("photos") } @@ -1090,7 +1097,7 @@ model VoiceNote { transcriptionModel String? @map("transcription_model") recordedById String? @map("recorded_by_id") recordedAt DateTime @map("recorded_at") - clientId String? @unique @map("client_id") + clientId String? @map("client_id") createdAt DateTime @default(now()) @map("created_at") updatedAt DateTime @updatedAt @map("updated_at") @@ -1098,6 +1105,7 @@ model VoiceNote { activityNote ActivityNote? @@index([tenantId, workOrderId]) + @@unique([tenantId, clientId]) @@map("voice_notes") } @@ -1140,7 +1148,7 @@ model Report { approvedById String? @map("approved_by_id") approvedAt DateTime? @map("approved_at") rejectionReason String? @map("rejection_reason") - clientId String? @unique @map("client_id") + clientId String? @map("client_id") createdAt DateTime @default(now()) @map("created_at") updatedAt DateTime @updatedAt @map("updated_at") @@ -1150,6 +1158,7 @@ model Report { @@unique([lineageId, version]) @@index([tenantId, workOrderId]) @@index([tenantId, status]) + @@unique([tenantId, clientId]) @@map("reports") } @@ -1173,12 +1182,13 @@ model Signature { reason String? // required for absent/refused/later signedAt DateTime @map("signed_at") capturedById String? @map("captured_by_id") - clientId String? @unique @map("client_id") + clientId String? @map("client_id") createdAt DateTime @default(now()) @map("created_at") report Report @relation(fields: [reportId], references: [id], onDelete: Cascade) @@index([tenantId]) + @@unique([tenantId, clientId]) @@map("signatures") } diff --git a/scripts/bootstrap-admin.ts b/scripts/bootstrap-admin.ts index 850afca..7bad024 100644 --- a/scripts/bootstrap-admin.ts +++ b/scripts/bootstrap-admin.ts @@ -15,7 +15,7 @@ import { provisionTenant } from "@/server/provision"; * Beide mit denselben Zugangsdaten (BOOTSTRAP_ADMIN_*), analog zum Seed. * * Ausführung: im migrate-Job nach `prisma migrate deploy`, gesteuert per - * BOOTSTRAP_ADMIN=true (siehe docker-compose.coolify.yml, docs/DEPLOY-PROD-CONTABO.md). + * BOOTSTRAP_ADMIN=true (siehe docker-compose.coolify.yml, docs/_certvia-archiv/DEPLOY-PROD-CONTABO.md). * * Idempotent: provisionTenant und der platformAdmin.upsert nutzen upserts; ein bereits * gesetztes Passwort wird beim erneuten Lauf NICHT überschrieben. diff --git a/scripts/build-and-push-images.sh b/scripts/build-and-push-images.sh index 110308e..ec2b1d9 100755 --- a/scripts/build-and-push-images.sh +++ b/scripts/build-and-push-images.sh @@ -46,10 +46,12 @@ build_one() { build_one runner craftvia-app build_one migrate craftvia-migrate build_one garage craftvia-garage +# L10b: Hintergrund-Worker (BullMQ: Import-Extraktion, Transkription, Berichts-PDF mit Chromium, KI-Aufbewahrung) +build_one worker craftvia-worker echo echo ">> Push ..." -for name in craftvia-app craftvia-migrate craftvia-garage; do +for name in craftvia-app craftvia-migrate craftvia-garage craftvia-worker; do docker push "$REGISTRY/$name:$TAG" [ "$ALSO_MAIN" = "true" ] && docker push "$REGISTRY/$name:main" || true done diff --git a/scripts/craftvia-worker.ts b/scripts/craftvia-worker.ts index f4395a0..649e67e 100644 --- a/scripts/craftvia-worker.ts +++ b/scripts/craftvia-worker.ts @@ -1,6 +1,6 @@ import "dotenv/config"; import { Worker } from "bullmq"; -import { JOB_QUEUES, workerConnection, closeJobQueues, type JobPayload } from "../src/server/jobs/queues"; +import { JOB_QUEUES, workerConnection, closeJobQueues, scheduleRecurringJobs, type JobPayload } from "../src/server/jobs/queues"; import { PROCESSORS } from "../src/server/jobs/processors"; /** Craftvia background worker: `npm run worker:craftvia`. One BullMQ worker per registered queue. */ @@ -24,6 +24,11 @@ async function main() { workers.push(w); console.info(`[worker] listening on ${name}`); } + // L10b: recurring jobs (AI log retention); a scheduling failure must not stop the queue workers + await scheduleRecurringJobs(connection).then( + () => console.info("[worker] recurring jobs scheduled"), + (err) => console.error("[worker] scheduling recurring jobs failed:", (err as Error).message), + ); const shutdown = async () => { await Promise.all(workers.map((w) => w.close())); await closeJobQueues(); diff --git a/scripts/garage-provision.ts b/scripts/garage-provision.ts index e1f4591..6cb56b9 100644 --- a/scripts/garage-provision.ts +++ b/scripts/garage-provision.ts @@ -2,7 +2,7 @@ import "dotenv/config"; /** * IM/Garage-Migration — idempotentes Provisioning eines Single-Node-Garage - * (docs/KONZEPT-garage-migration.md §6 Lane B, §7 Runbook). + * (docs/_certvia-archiv/KONZEPT-garage-migration.md §6 Lane B, §7 Runbook). * * Garage verwaltet Buckets/Keys/Rechte NICHT über die S3-API (`CreateBucket` gibt es * dort nicht), sondern out-of-band. Dieser Init-Job (Compose-Service „garage-provision", diff --git a/scripts/smoke-betrieb.ts b/scripts/smoke-betrieb.ts new file mode 100644 index 0000000..f64d71f --- /dev/null +++ b/scripts/smoke-betrieb.ts @@ -0,0 +1,141 @@ +/** + * L10b HTTP smoke against a running dev server (no password input — session cookies via + * finalizeIdentityLogin + Auth.js encode, like scripts/smoke-auth.ts): + * unified /api/v1 error format, 401/403/404/422, OpenAPI, sync ops, bundle mySession, + * Lotse budget settings, collapsible backoffice sidebar, tenant separation. + * + * Usage (dev server running, seeded DB): BASE=http://localhost:3111 npx tsx scripts/smoke-betrieb.ts + */ +import "dotenv/config"; +import { randomUUID } from "node:crypto"; +import { encode } from "next-auth/jwt"; +import { prisma } from "../src/server/db"; +import { finalizeIdentityLogin } from "../src/server/auth"; + +const BASE = process.env.BASE ?? "http://localhost:3111"; +const HOST = new URL(BASE).host; +const COOKIE = BASE.startsWith("https") ? "__Secure-authjs.session-token" : "authjs.session-token"; + +let failures = 0; +const ok = (cond: boolean, msg: string) => { + console.log(`${cond ? "✓" : "✗"} ${msg}`); + if (!cond) failures++; +}; + +async function cookieFor(email: string, slug: string): Promise { + const identity = await prisma.identity.findUnique({ where: { email } }); + if (!identity) throw new Error(`identity ${email} not found (seed?)`); + const user = await finalizeIdentityLogin(identity.id, slug); + if (!user) throw new Error(`no active membership for ${email} in ${slug}`); + const token = { + sub: user.id, + name: user.name, + email: user.email, + userId: user.id, + identityId: user.identityId, + tenantId: user.tenantId, + tenantSlug: user.tenantSlug, + activeMembershipId: user.activeMembershipId, + memberships: user.memberships, + roles: user.roles, + permissions: user.permissions, + isPlatformAdmin: user.isPlatformAdmin, + mfaEnrolled: user.mfaEnrolled, + }; + return `${COOKIE}=${await encode({ token, secret: process.env.AUTH_SECRET!, salt: COOKIE, maxAge: 60 * 30 })}`; +} + +type Res = { status: number; body: string; json: unknown; headers: Headers }; +async function call(path: string, init: RequestInit & { cookie?: string } = {}): Promise { + const headers = new Headers(init.headers); + if (init.cookie) headers.set("cookie", init.cookie); + const res = await fetch(BASE + path, { ...init, headers, redirect: "manual" }); + const body = await res.text(); + let json: unknown = null; + try { + json = JSON.parse(body); + } catch { + json = null; + } + return { status: res.status, body, json, headers: res.headers }; +} +const errCode = (r: Res) => (r.json as { error?: { code?: string } } | null)?.error?.code; +const sameOrigin = { origin: BASE, "sec-fetch-site": "same-origin", "content-type": "application/json" }; + +async function main() { + console.log("\n== anonym"); + let r = await call("/api/v1/openapi.json"); + ok(r.status === 401 && errCode(r) === "unauthorized", `GET /api/v1/openapi.json ohne Sitzung → 401 (${r.status})`); + r = await call("/api/v1/sync", { method: "POST", headers: sameOrigin, body: "{}" }); + ok(r.status === 401 && errCode(r) === "unauthorized", `POST /api/v1/sync ohne Sitzung → 401 (${r.status})`); + + console.log("\n== admin@demo.example (demo)"); + const admin = await cookieFor("admin@demo.example", "demo"); + r = await call("/api/v1/openapi.json", { cookie: admin }); + const spec = r.json as { openapi?: string; paths?: Record } | null; + ok(r.status === 200 && spec?.openapi?.startsWith("3.1") === true && Object.keys(spec.paths ?? {}).length >= 23, `OpenAPI 3.1 mit ${Object.keys(spec?.paths ?? {}).length} Pfaden`); + r = await call("/api/v1/customers?pageSize=2", { cookie: admin }); + const list = r.json as { data?: unknown[]; pagination?: { total: number } } | null; + ok(r.status === 200 && Array.isArray(list?.data) && typeof list?.pagination?.total === "number", "GET /api/v1/customers → data + pagination"); + r = await call("/api/v1/work-orders/zz-unknown", { cookie: admin }); + ok(r.status === 404 && errCode(r) === "not_found", `GET /api/v1/work-orders/ → 404 not_found (${r.status})`); + r = await call("/api/v1/reports/zz-unknown/pdf", { cookie: admin }); + ok(r.status === 404 && errCode(r) === "not_found", `GET /api/v1/reports//pdf → 404 im einheitlichen Format (${r.status})`); + r = await call("/api/v1/work-orders/zz-unknown/transition", { cookie: admin, method: "POST", headers: { origin: "https://evil.example", "content-type": "application/json" }, body: "{}" }); + ok(r.status === 403 && errCode(r) === "forbidden", `POST transition mit fremdem Origin → 403 (${r.status})`); + r = await call("/api/v1/customers", { cookie: admin, method: "POST", headers: sameOrigin, body: "[]" }); + ok(r.status === 422 && errCode(r) === "invalid", `POST /api/v1/customers mit Array → 422 invalid (${r.status})`); + r = await call("/api/v1/imports/zz-unknown/confirm", { cookie: admin, method: "POST", headers: sameOrigin, body: "{nope" }); + ok(r.status === 422 && errCode(r) === "invalid", `POST imports/confirm mit kaputtem JSON → 422 (vorher 400, anderes Format) (${r.status})`); + r = await call("/settings/lotse", { cookie: admin }); + ok(r.status === 200 && r.body.includes("Monatliches KI-Kontingent"), "/settings/lotse zeigt das KI-Kontingent"); + r = await call("/dashboard", { cookie: admin }); + ok(r.status === 200 && r.body.includes("Menü öffnen") && r.body.includes('aria-controls="backoffice-sidebar"'), "Backoffice-Layout mit Menü-Button (einklappbare Sidebar)"); + r = await call("/settings/audit?action=read", { cookie: admin }); + ok(r.status === 200, "/settings/audit mit Filter action=read"); + r = await call("/work-orders/conflicts", { cookie: admin }); + ok(r.status === 200 && r.body.includes("abgesendete Berichte"), "Konfliktliste mit angepasstem Hinweis"); + + console.log("\n== monteur@demo.example (demo)"); + const tech = await cookieFor("monteur@demo.example", "demo"); + r = await call("/api/v1/field/bundle", { cookie: tech }); + const bundle = r.json as { orders?: { id: string; mySession?: unknown }[] } | null; + ok(r.status === 200 && Array.isArray(bundle?.orders) && (bundle!.orders!.length === 0 || bundle!.orders!.every((o) => "mySession" in o)), `Bundle mit mySession je Auftrag (${bundle?.orders?.length ?? 0} Aufträge)`); + r = await call("/api/v1/sync", { cookie: tech, method: "POST", headers: sameOrigin, body: JSON.stringify({ deviceId: "smoke", operations: [] }) }); + ok(r.status === 422 && errCode(r) === "invalid", `POST /api/v1/sync leerer Batch → 422 invalid (${r.status})`); + const orderId = bundle?.orders?.[0]?.id ?? "zz-unknown"; + r = await call("/api/v1/sync", { + cookie: tech, + method: "POST", + headers: sameOrigin, + body: JSON.stringify({ deviceId: "smoke", operations: [{ clientOpId: randomUUID(), opType: "report.submit", payload: { workOrderId: orderId }, baseVersion: 1, clientCreatedAt: new Date().toISOString() }] }), + }); + const result = (r.json as { results?: { status: string; errorCode?: string }[] } | null)?.results?.[0]; + ok(r.status === 200 && result?.status === "rejected" && result.errorCode === "invalid", "Sync report.submit ohne reportId → rejected invalid (Op registriert, Payload validiert)"); + r = await call("/m", { cookie: tech }); + ok(r.status === 200, "/m rendert"); + r = await call("/dashboard", { cookie: tech }); + ok(r.status === 307 || r.status === 308, `Monteur /dashboard → Redirect (${r.status})`); + + console.log("\n== admin2@demo.example (demo2)"); + const admin2 = await cookieFor("admin2@demo.example", "demo2"); + const demoTenant = await prisma.tenant.findUnique({ where: { slug: "demo" }, select: { id: true } }); + const demoOrder = demoTenant ? await prisma.workOrder.findFirst({ where: { tenantId: demoTenant.id }, select: { id: true } }) : null; + if (demoOrder) { + r = await call(`/api/v1/work-orders/${demoOrder.id}`, { cookie: admin2 }); + ok(r.status === 404 && errCode(r) === "not_found", `Mandant demo2: Auftrag von demo → 404 (${r.status})`); + } else { + console.log("(kein Demo-Auftrag im Seed — Mandantentest übersprungen)"); + } + + await prisma.$disconnect(); + console.log(failures ? `\n${failures} Fehler` : "\nOK"); + process.exit(failures ? 1 : 0); +} + +main().catch((err) => { + console.error(err); + process.exit(1); +}); + +void HOST; diff --git a/scripts/test-auth-selfservice.ts b/scripts/test-auth-selfservice.ts index c174d4d..3763270 100644 --- a/scripts/test-auth-selfservice.ts +++ b/scripts/test-auth-selfservice.ts @@ -24,7 +24,7 @@ import { closeMailProvider } from "../src/server/mail/provider-smtp"; * 9. Enumeration: unbekannte Adresse liefert kein Konto. * * Die Browser-Abläufe (Reset-Mail → Link → neues Passwort) stehen im Testplan - * von docs/SEC2-AUTH-SELFSERVICE.md. + * von docs/_certvia-archiv/SEC2-AUTH-SELFSERVICE.md. */ let failures = 0; diff --git a/scripts/test-betrieb-api.ts b/scripts/test-betrieb-api.ts new file mode 100644 index 0000000..ec575f1 --- /dev/null +++ b/scripts/test-betrieb-api.ts @@ -0,0 +1,178 @@ +// Lane L10b „Betrieb & Aufräumen" — /api/v1 vereinheitlicht (Aufräumpunkt a) + Rate Limiting: +// Jede Route läuft über requireApiContext + withApi (respond.ts): einheitliches Fehlerformat +// { error: { code, message, details? } }, Statuscodes je Code, Same-Origin-Prüfung für jede +// Mutation (vor der Authentifizierung), 401 ohne Sitzung, 429 + Retry-After beim Rate Limit. +// +// Lauf: npx tsx scripts/test-betrieb-api.ts (keine DB-Schreibzugriffe) + +import "dotenv/config"; +// Kleine Limits für den Test — rate-limit.ts liest die Env beim Laden des Moduls. +process.env.API_RATE_LIMIT_PER_MINUTE = "5"; +process.env.API_FIELD_RATE_LIMIT_PER_MINUTE = "12"; + +import { readdirSync, readFileSync, statSync } from "node:fs"; +import { join, relative } from "node:path"; +import { pathToFileURL } from "node:url"; +import { z } from "zod"; + +let failures = 0; +const ok = (cond: boolean, msg: string) => { + console.log(`${cond ? "✓" : "✗ FEHLER"} ${msg}`); + if (!cond) failures++; +}; + +const ROOT = join(process.cwd(), "src/app/api/v1"); +const HOST = "localhost:3111"; +const METHODS = ["GET", "POST", "PATCH", "PUT", "DELETE"] as const; +const MUTATING = new Set(["POST", "PATCH", "PUT", "DELETE"]); + +function routeFiles(dir: string): string[] { + return readdirSync(dir).flatMap((name) => { + const p = join(dir, name); + if (statSync(p).isDirectory()) return routeFiles(p); + return name === "route.ts" ? [p] : []; + }); +} + +/** `src/app/api/v1/work-orders/[id]/route.ts` → `/api/v1/work-orders/{id}` */ +function apiPath(file: string): string { + const rel = relative(ROOT, file).replace(/\/?route\.ts$/, ""); + return `/api/v1${rel ? `/${rel}` : ""}`.replace(/\[([^\]]+)\]/g, "{$1}"); +} + +type Handler = (req: Request, ctx: { params: Promise> }) => Promise; + +async function errorBody(res: Response): Promise<{ code?: string; message?: string } | null> { + try { + const body = (await res.json()) as { error?: { code?: string; message?: string } }; + return body.error && typeof body.error === "object" ? body.error : null; + } catch { + return null; + } +} + +async function main() { + const { ServiceError } = await import("../src/server/services/context"); + const { ApiError, API_ERROR_STATUS, readJsonObject, toErrorResponse } = await import("../src/server/api/respond"); + const { enforceApiRateLimit } = await import("../src/server/api/context"); + const { resetRateLimits } = await import("../src/server/rate-limit"); + + const files = routeFiles(ROOT).sort(); + const apiFiles = files.filter((f) => !f.includes("openapi.json")); + ok(apiFiles.length >= 22, `alle v1-Routen gefunden (${apiFiles.length})`); + + console.log("\n— OpenAPI deckt jede Route ab —"); + const { API_ROUTES, openApiDocument } = await import("../src/lib/api/openapi"); + const documented = new Set(API_ROUTES); + for (const file of files) ok(documented.has(apiPath(file)), `OpenAPI dokumentiert ${apiPath(file)}`); + ok(documented.size === files.length, `keine veralteten OpenAPI-Pfade (${documented.size} dokumentiert, ${files.length} Routen)`); + const doc = openApiDocument as { openapi?: string; paths?: Record> }; + ok(typeof doc.openapi === "string" && doc.openapi.startsWith("3.1"), "OpenAPI 3.1"); + const specRes = await ((await import(pathToFileURL(join(ROOT, "openapi.json/route.ts")).href)) as { GET: () => Promise }).GET(); + const spec = (await specRes.json()) as { paths?: Record }; + ok(specRes.status === 200 && Object.keys(spec.paths ?? {}).length === files.length, "GET /api/v1/openapi.json liefert das Dokument"); + + console.log("\n— Statisch: ein gemeinsamer Adapter —"); + for (const file of apiFiles) { + const src = readFileSync(file, "utf8"); + const path = apiPath(file); + ok(src.includes("requireApiContext(") && !/moduleGuard|action-guard|_context|api-context|reports\/http|_http/.test(src), `${path}: requireApiContext, keine lane-lokalen Kontexte`); + ok(/withApi\(|toErrorResponse\(/.test(src), `${path}: Fehler über respond.ts`); + } + + console.log("\n— Ohne Sitzung: 401 im einheitlichen Format —"); + const params = Promise.resolve({ id: "zz-unknown", documentId: "zz-unknown" }); + for (const file of apiFiles) { + const mod = (await import(pathToFileURL(file).href)) as Record; + const path = apiPath(file).replace(/\{[^}]+\}/g, "zz-unknown"); + for (const method of METHODS) { + const handler = mod[method] as Handler | undefined; + if (typeof handler !== "function") continue; + const headers: Record = { host: HOST, accept: "application/json" }; + const init: RequestInit = { method, headers }; + if (MUTATING.has(method)) { + headers.origin = `http://${HOST}`; + headers["sec-fetch-site"] = "same-origin"; + headers["content-type"] = "application/json"; + init.body = "{}"; + } + const res = await handler(new Request(`http://${HOST}${path}`, init), { params }); + const err = await errorBody(res); + ok(res.status === 401 && err?.code === "unauthorized" && res.headers.get("cache-control") === "no-store", `${method} ${apiPath(file)} ohne Sitzung → 401 unauthorized`); + + if (MUTATING.has(method)) { + const cross = await handler( + new Request(`http://${HOST}${path}`, { method, headers: { host: HOST, origin: "https://evil.example", "content-type": "application/json" }, body: "{}" }), + { params }, + ); + const crossErr = await errorBody(cross); + ok(cross.status === 403 && crossErr?.code === "forbidden", `${method} ${apiPath(file)} fremder Origin → 403 (vor der Anmeldung)`); + const site = await handler( + new Request(`http://${HOST}${path}`, { method, headers: { host: HOST, "sec-fetch-site": "cross-site", "content-type": "application/json" }, body: "{}" }), + { params }, + ); + ok(site.status === 403, `${method} ${apiPath(file)} Sec-Fetch-Site cross-site → 403`); + } + } + } + + console.log("\n— Fehler-Mapping (respond.ts) —"); + const expected: Record = { not_found: 404, forbidden: 403, invalid: 422, conflict: 409, blocked: 422 }; + for (const [code, status] of Object.entries(expected)) { + const res = toErrorResponse(new ServiceError(code as "not_found", `msg ${code}`, code === "blocked" ? [{ kind: "checklist_item", id: "c1" }] : undefined)); + const body = (await res.json()) as { error: { code: string; message: string; details?: unknown } }; + ok(res.status === status && body.error.code === code && body.error.message === `msg ${code}`, `ServiceError ${code} → ${status}`); + if (code === "blocked") ok(Array.isArray(body.error.details), "blocked → details (CompletionBlocker[])"); + } + const zodErr = z.object({ name: z.string() }).safeParse({ name: 1 }); + const zres = toErrorResponse(zodErr.error); + const zbody = (await zres.json()) as { error: { code: string; details: { path: string }[] } }; + ok(zres.status === 422 && zbody.error.code === "invalid" && zbody.error.details[0]?.path === "name", "ZodError → 422 invalid mit Feldpfaden"); + const origError = console.error; + console.error = () => {}; + const internal = toErrorResponse(new Error("SELECT secret FROM users")); + console.error = origError; + const ibody = await internal.text(); + ok(internal.status === 500 && !ibody.includes("secret"), "unbekannter Fehler → 500 ohne interne Details"); + ok(API_ERROR_STATUS.rate_limited === 429 && API_ERROR_STATUS.payload_too_large === 413 && API_ERROR_STATUS.unauthorized === 401, "Statuscodes 429/413/401"); + + const req = (body: string) => new Request(`http://${HOST}/x`, { method: "POST", body }); + const code = async (p: Promise) => p.then(() => "ok", (e: { code?: string }) => e.code ?? "error"); + ok(JSON.stringify(await readJsonObject(req(""), { allowEmpty: true })) === "{}", "readJsonObject: leerer Body mit allowEmpty → {}"); + ok((await code(readJsonObject(req("")))) === "invalid", "readJsonObject: leerer Body → invalid"); + ok((await code(readJsonObject(req("[1]")))) === "invalid", "readJsonObject: Array → invalid"); + ok((await code(readJsonObject(req("{nope")))) === "invalid", "readJsonObject: kaputtes JSON → invalid"); + + console.log("\n— Rate Limiting je Nutzer —"); + resetRateLimits(); + const hit = (user: string, moduleKey: "customers" | "field") => { + try { + enforceApiRateLimit(user, moduleKey); + return null; + } catch (err) { + return err as InstanceType; + } + }; + let firstBlocked = -1; + for (let i = 1; i <= 6; i++) if (hit("zz-user-a", "customers") && firstBlocked < 0) firstBlocked = i; + ok(firstBlocked === 6, "Standard-Bucket: 5 Anfragen erlaubt, die 6. abgelehnt"); + const blocked = hit("zz-user-a", "customers"); + ok(blocked?.code === "rate_limited" && ((blocked.details as { retryAfterSeconds: number }).retryAfterSeconds ?? 0) > 0, "Ablehnung als rate_limited mit retryAfterSeconds"); + const res429 = toErrorResponse(blocked); + ok(res429.status === 429 && Number(res429.headers.get("retry-after")) > 0, "429 mit Retry-After-Header"); + ok(hit("zz-user-b", "customers") === null, "anderer Nutzer hat eigenes Kontingent"); + let fieldBlocked = -1; + for (let i = 1; i <= 13; i++) if (hit("zz-user-a", "field") && fieldBlocked < 0) fieldBlocked = i; + ok(fieldBlocked === 13, "Einsatz-Bucket (sync/uploads/field) getrennt und großzügiger: 12 erlaubt, 13. abgelehnt"); + resetRateLimits(); +} + +main() + .catch((err) => { + console.error(err); + failures++; + }) + .finally(() => { + console.log(failures ? `\n✗ ${failures} Prüfung(en) fehlgeschlagen` : "\n✓ Alle API-Prüfungen grün"); + process.exit(failures ? 1 : 0); + }); diff --git a/scripts/test-betrieb-audit.ts b/scripts/test-betrieb-audit.ts new file mode 100644 index 0000000..bc1b029 --- /dev/null +++ b/scripts/test-betrieb-audit.ts @@ -0,0 +1,209 @@ +// Lane L10b „Betrieb & Aufräumen" — Transaktionen, Audit und Lotse-Betrieb: +// h) Audit-Einträge innerhalb von inTransaction erst nach dem Commit (Rollback → keine Einträge, +// außer „denied"; verschachtelt; aufgeschoben) +// d) mergeCustomers über inTransaction (atomar, in äußere Transaktion einbettbar) +// e) Audit-Aktion „read" für die Notdienst-Kundensuche +// k) KI-Protokoll: Aufbewahrungsfrist (Pseudonymisierung) + monatliches Token-Kontingent je Mandant +// Jeweils mit Mandantentrennung (B) und Rollen (Monteur → forbidden). +// +// Lauf: npx tsx scripts/test-betrieb-audit.ts (lokale Postgres-DB aus .env) + +import "dotenv/config"; +import { prisma, dbForTenant } from "../src/server/db"; +import { writeAuditLog } from "../src/server/audit"; +import { inTransaction } from "../src/server/services/context"; +import { mergeCustomers } from "../src/server/services/customers/merge"; +import { searchCustomersForEmergency } from "../src/server/services/emergency/lookup"; +import { aiGenerationRetentionDays, purgeExpiredAiGenerations } from "../src/server/services/lotse/retention"; +import { assertTokenBudget, getTokenBudget } from "../src/server/services/lotse/budget"; +import { getLotseSettings, updateLotseSettings } from "../src/server/services/lotse/settings"; +import { PROCESSORS } from "../src/server/jobs/processors"; +import { ctxFor, expectCode, failures, ok } from "./lib/einsatz-fixture"; + +const SLUG_A = "zz-l10b-audit-a"; +const SLUG_B = "zz-l10b-audit-b"; +const DOMAIN = "@zz-l10b-audit.test"; +const DAY = 24 * 60 * 60 * 1000; + +async function cleanup() { + for (const slug of [SLUG_A, SLUG_B]) { + const tenant = await prisma.tenant.findUnique({ where: { slug }, select: { id: true } }); + if (!tenant) continue; + const where = { tenantId: tenant.id }; + await prisma.auditLog.deleteMany({ where }); + await prisma.aiGeneration.deleteMany({ where }); + await prisma.workOrder.deleteMany({ where }); + await prisma.site.deleteMany({ where }); + await prisma.contact.deleteMany({ where }); + await prisma.customer.deleteMany({ where }); + await prisma.tenantModule.deleteMany({ where }); + await prisma.tenantSettings.deleteMany({ where }); + await prisma.user.deleteMany({ where }); + await prisma.tenant.delete({ where: { id: tenant.id } }); + } + await prisma.identity.deleteMany({ where: { email: { endsWith: DOMAIN }, memberships: { none: {} } } }); +} + +async function user(tenantId: string, local: string) { + const email = `${local}${DOMAIN}`; + const identity = await prisma.identity.upsert({ where: { email }, update: {}, create: { email, passwordHash: "x" } }); + return prisma.user.create({ data: { tenantId, identityId: identity.id, email, name: local } }); +} + +const auditCount = (tenantId: string, entity: string, entityId?: string, action?: string) => + prisma.auditLog.count({ where: { tenantId, entity, ...(entityId ? { entityId } : {}), ...(action ? { action } : {}) } }); + +async function main() { + await cleanup(); + const tenantA = await prisma.tenant.create({ data: { name: "L10b Audit A", slug: SLUG_A } }); + const tenantB = await prisma.tenant.create({ data: { name: "L10b Audit B", slug: SLUG_B } }); + const adminA = await user(tenantA.id, "admin-a"); + const techA = await user(tenantA.id, "tech-a"); + const adminB = await user(tenantB.id, "admin-b"); + const ctxAdminA = ctxFor(tenantA.id, adminA.id, "tenant-admin"); + const ctxTechA = ctxFor(tenantA.id, techA.id, "technician"); + const ctxAdminB = ctxFor(tenantB.id, adminB.id, "tenant-admin"); + await prisma.tenantSettings.create({ data: { tenantId: tenantA.id, orgName: "A" } }); + await prisma.tenantSettings.create({ data: { tenantId: tenantB.id, orgName: "B" } }); + + const customer = (tenantId: string, companyName: string) => prisma.customer.create({ data: { tenantId, companyName, city: "Kiel" } }); + const site = (tenantId: string, customerId: string) => + prisma.site.create({ data: { tenantId, customerId, name: "Halle", street: "Weg", houseNumber: "1", postalCode: "24103", city: "Kiel" } }); + + console.log("\n— h) Audit nach Commit —"); + const probe = await customer(tenantA.id, "Probe GmbH"); + await inTransaction(ctxAdminA, async (tx) => { + await tx.db.customer.update({ where: { id: probe.id }, data: { city: "Lübeck" } }); + await writeAuditLog({ tenantId: tx.tenantId, actorId: tx.userId, action: "update", entity: "zz_l10b_tx", entityId: "commit" }); + ok((await auditCount(tenantA.id, "zz_l10b_tx", "commit")) === 0, "innerhalb der Transaktion noch kein Audit-Eintrag (aufgeschoben)"); + }); + ok((await auditCount(tenantA.id, "zz_l10b_tx", "commit")) === 1, "nach Commit: Audit-Eintrag geschrieben"); + + await inTransaction(ctxAdminA, async (tx) => { + await tx.db.customer.update({ where: { id: probe.id }, data: { city: "Flensburg" } }); + await writeAuditLog({ tenantId: tx.tenantId, actorId: tx.userId, action: "update", entity: "zz_l10b_tx", entityId: "rollback" }); + await writeAuditLog({ tenantId: tx.tenantId, actorId: tx.userId, action: "denied", entity: "zz_l10b_tx", entityId: "rollback-denied" }); + throw new Error("zz rollback"); + }).catch(() => undefined); + ok((await prisma.customer.findUniqueOrThrow({ where: { id: probe.id } })).city === "Lübeck", "Rollback: Fachänderung verworfen"); + ok((await auditCount(tenantA.id, "zz_l10b_tx", "rollback")) === 0, "Rollback: kein Audit-Eintrag für die verworfene Änderung"); + ok((await auditCount(tenantA.id, "zz_l10b_tx", "rollback-denied", "denied")) === 1, "Rollback: „denied\"-Eintrag bleibt (Sicherheitsereignis)"); + + await inTransaction(ctxAdminA, async (outer) => { + await inTransaction(outer, async (inner) => { + await writeAuditLog({ tenantId: inner.tenantId, action: "update", entity: "zz_l10b_tx", entityId: "nested" }); + }); + ok((await auditCount(tenantA.id, "zz_l10b_tx", "nested")) === 0, "verschachtelt: innere Transaktion schreibt nicht vorzeitig"); + throw new Error("zz outer rollback"); + }).catch(() => undefined); + ok((await auditCount(tenantA.id, "zz_l10b_tx", "nested")) === 0, "verschachtelt: äußerer Rollback verwirft auch innere Audit-Einträge"); + await writeAuditLog({ tenantId: tenantA.id, action: "update", entity: "zz_l10b_tx", entityId: "direct" }); + ok((await auditCount(tenantA.id, "zz_l10b_tx", "direct")) === 1, "außerhalb einer Transaktion: sofort geschrieben"); + + console.log("\n— d) mergeCustomers atomar —"); + const src = await customer(tenantA.id, "Quelle GmbH"); + const tgt = await customer(tenantA.id, "Ziel GmbH"); + const srcSite = await site(tenantA.id, src.id); + await expectCode(() => mergeCustomers(ctxTechA, { sourceId: src.id, targetId: tgt.id, confirm: true }), "forbidden", "Monteur darf nicht zusammenführen"); + await expectCode(() => mergeCustomers(ctxAdminB, { sourceId: src.id, targetId: tgt.id, confirm: true }), "not_found", "Mandant B kann Kunden von A nicht zusammenführen"); + await inTransaction(ctxAdminA, async (tx) => { + await mergeCustomers(tx, { sourceId: src.id, targetId: tgt.id, confirm: true }); + throw new Error("zz merge rollback"); + }).catch(() => undefined); + ok((await prisma.customer.findUniqueOrThrow({ where: { id: src.id } })).status !== "merged", "Merge in äußerer Transaktion + Rollback → Quelle nicht zusammengeführt"); + ok((await prisma.site.findUniqueOrThrow({ where: { id: srcSite.id } })).customerId === src.id, "… und Objekt nicht umgehängt"); + ok((await auditCount(tenantA.id, "customer", src.id)) === 0, "… und keine Merge-Audit-Einträge"); + const merged = await mergeCustomers(ctxAdminA, { sourceId: src.id, targetId: tgt.id, confirm: true }); + ok(merged.moved.sites === 1 && (await prisma.site.findUniqueOrThrow({ where: { id: srcSite.id } })).customerId === tgt.id, "Merge: Objekt umgehängt"); + const srcAfter = await prisma.customer.findUniqueOrThrow({ where: { id: src.id } }); + ok(srcAfter.status === "merged" && srcAfter.mergedIntoId === tgt.id, "Merge: Quelle merged + mergedIntoId"); + ok((await auditCount(tenantA.id, "customer", src.id, "update")) === 1 && (await auditCount(tenantA.id, "customer", tgt.id, "update")) === 1, "Merge: Audit für Quelle und Ziel nach Commit"); + await expectCode(() => mergeCustomers(ctxAdminA, { sourceId: src.id, targetId: tgt.id, confirm: true }), "conflict", "zweites Zusammenführen → conflict"); + + console.log("\n— e) Audit-Aktion „read\" —"); + const hits = await searchCustomersForEmergency(ctxTechA, "Ziel"); + ok(hits.some((h) => h.id === tgt.id), "Notdienst-Suche findet Kunden"); + const searchAudit = await prisma.auditLog.findFirst({ where: { tenantId: tenantA.id, entity: "emergency_customer_search" }, orderBy: { createdAt: "desc" } }); + ok(searchAudit?.action === "read" && searchAudit.actorId === techA.id, "Suchzugriff als Aktion „read\" protokolliert"); + ok((await searchCustomersForEmergency(ctxFor(tenantB.id, adminB.id, "technician"), "Ziel")).length === 0, "Mandant B findet keine Kunden von A"); + await expectCode(() => searchCustomersForEmergency(ctxFor(tenantA.id, adminA.id, "backoffice"), "Ziel"), "forbidden", "ohne emergency:create → forbidden"); + + console.log("\n— k) Aufbewahrung KI-Protokoll —"); + const now = new Date(); + const gen = (tenantId: string, createdAt: Date, tokens = 10, createdById: string | null = null) => + prisma.aiGeneration.create({ + data: { tenantId, kind: "report_draft", provider: "fake", model: "fake-1", input: { text: "Kunde ruft an" }, output: { workPerformed: "x" }, inputTokens: tokens, outputTokens: tokens, createdById, createdAt }, + }); + const oldA = await gen(tenantA.id, new Date(now.getTime() - 200 * DAY), 10, techA.id); + const newA = await gen(tenantA.id, new Date(now.getTime() - 10 * DAY), 10, techA.id); + const oldB = await gen(tenantB.id, new Date(now.getTime() - 200 * DAY), 10, adminB.id); + ok(aiGenerationRetentionDays() === 180, "Default-Aufbewahrung 180 Tage"); + process.env.AI_GENERATION_RETENTION_DAYS = "30"; + ok(aiGenerationRetentionDays() === 30, "AI_GENERATION_RETENTION_DAYS überschreibt den Default"); + delete process.env.AI_GENERATION_RETENTION_DAYS; + ok(typeof PROCESSORS["ai-retention"] === "function", "Job ai-retention im Worker registriert"); + + const r1 = await purgeExpiredAiGenerations({ now, tenantIds: [tenantA.id] }); + const oldAAfter = await prisma.aiGeneration.findUniqueOrThrow({ where: { id: oldA.id } }); + ok(r1.pseudonymised === 1 && oldAAfter.input === null && oldAAfter.output === null && oldAAfter.createdById === null, "abgelaufener Eintrag: Inhalte gelöscht, Personenbezug entfernt"); + ok(oldAAfter.inputTokens === 10 && oldAAfter.model === "fake-1" && oldAAfter.kind === "report_draft", "Metadaten (Tokens, Modell, Art) bleiben"); + const newAAfter = await prisma.aiGeneration.findUniqueOrThrow({ where: { id: newA.id } }); + ok(newAAfter.input !== null && newAAfter.createdById === techA.id, "junger Eintrag unverändert"); + ok((await prisma.aiGeneration.findUniqueOrThrow({ where: { id: oldB.id } })).input !== null, "Lauf für Mandant A lässt Mandant B unberührt"); + ok((await auditCount(tenantA.id, "ai_generation_retention", undefined, "delete")) === 1, "Aufbewahrungslauf auditiert"); + ok((await purgeExpiredAiGenerations({ now, tenantIds: [tenantA.id] })).pseudonymised === 0, "zweiter Lauf idempotent"); + ok((await purgeExpiredAiGenerations({ now, days: 365, tenantIds: [tenantB.id] })).pseudonymised === 0, "längere Frist → nichts gelöscht"); + ok((await purgeExpiredAiGenerations({ now, tenantIds: [tenantB.id] })).pseudonymised === 1, "Mandant B eigener Lauf"); + + console.log("\n— k) Monatliches Token-Kontingent —"); + await prisma.aiGeneration.deleteMany({ where: { tenantId: { in: [tenantA.id, tenantB.id] } } }); + delete process.env.AI_MONTHLY_TOKEN_LIMIT; + await gen(tenantA.id, now, 600); + let budget = await getTokenBudget(ctxTechA, now); + ok(budget.limit === 0 && !budget.exceeded && budget.used === 1200, "ohne Limit: unbegrenzt, Verbrauch = Tokens ein+aus des Monats"); + await expectCode(() => updateLotseSettings(ctxTechA, { enabled: true, addressForm: "neutral", monthlyTokenLimit: 1000 }), "forbidden", "Monteur darf das Kontingent nicht setzen"); + await updateLotseSettings(ctxAdminA, { enabled: true, addressForm: "neutral", monthlyTokenLimit: 1000 }); + budget = await getTokenBudget(ctxTechA, now); + ok(budget.limit === 1000 && budget.source === "tenant" && budget.exceeded, "Mandanten-Limit 1000 bei 1200 Verbrauch → aufgebraucht"); + await expectCode(() => assertTokenBudget(ctxTechA, now), "blocked", "assertTokenBudget → blocked"); + try { + await assertTokenBudget(ctxTechA, now); + } catch (err) { + ok((err as { details?: { reason?: string } }).details?.reason === "budget_exceeded", "… mit reason budget_exceeded"); + } + const settingsAudit = await prisma.auditLog.findFirst({ where: { tenantId: tenantA.id, entity: "lotse_settings" }, orderBy: { createdAt: "desc" } }); + ok((settingsAudit?.after as { monthlyTokenLimit?: number } | null)?.monthlyTokenLimit === 1000, "Limit-Änderung auditiert"); + ok((await getLotseSettings(ctxAdminA)).budget.tenantLimit === 1000, "Einstellungsseite liefert Limit und Verbrauch"); + ok(!(await getTokenBudget(ctxAdminB, now)).exceeded && (await getTokenBudget(ctxAdminB, now)).used === 0, "Mandant B: eigener Verbrauch, nicht betroffen"); + ok((await prisma.tenantSettings.findFirstOrThrow({ where: { tenantId: tenantB.id } })).aiMonthlyTokenLimit === null, "Mandant B: Limit unverändert"); + + await gen(tenantB.id, new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), 1) - DAY), 5000); + process.env.AI_MONTHLY_TOKEN_LIMIT = "50"; + budget = await getTokenBudget(ctxAdminB, now); + ok(budget.limit === 50 && budget.source === "env" && budget.used === 0 && !budget.exceeded, "Env-Default greift; Verbrauch des Vormonats zählt nicht"); + await gen(tenantB.id, now, 30); + ok((await getTokenBudget(ctxAdminB, now)).exceeded, "Env-Limit 50 bei 60 Tokens → aufgebraucht"); + await updateLotseSettings(ctxAdminB, { enabled: true, addressForm: "neutral", monthlyTokenLimit: 0 }); + ok(!(await getTokenBudget(ctxAdminB, now)).exceeded, "Mandanten-Limit 0 = unbegrenzt überschreibt Env"); + await updateLotseSettings(ctxAdminB, { enabled: true, addressForm: "neutral" }); + ok((await prisma.tenantSettings.findFirstOrThrow({ where: { tenantId: tenantB.id } })).aiMonthlyTokenLimit === 0, "Speichern ohne Limit-Feld lässt das Limit unverändert"); + await updateLotseSettings(ctxAdminB, { enabled: true, addressForm: "neutral", monthlyTokenLimit: null }); + ok((await getTokenBudget(ctxAdminB, now)).source === "env", "null → wieder Plattform-Vorgabe"); + delete process.env.AI_MONTHLY_TOKEN_LIMIT; + + // guard: tenant db cannot read the other tenant's usage + const crossUsage = await dbForTenant(tenantB.id).aiGeneration.count({ where: { tenantId: tenantA.id } }).catch(() => 0); + ok(crossUsage === 0, "Mandanten-Client von B sieht keine KI-Nutzung von A"); +} + +main() + .catch((err) => { + console.error(err); + ok(false, `unerwarteter Fehler: ${(err as Error).message}`); + }) + .finally(async () => { + await cleanup().catch((e) => console.error("cleanup failed", e)); + await prisma.$disconnect(); + console.log(failures ? `\n✗ ${failures} Prüfung(en) fehlgeschlagen` : "\n✓ Alle Audit-/Transaktions-/Lotse-Betriebsprüfungen grün"); + process.exit(failures ? 1 : 0); + }); diff --git a/scripts/test-betrieb-sync.ts b/scripts/test-betrieb-sync.ts new file mode 100644 index 0000000..5eff294 --- /dev/null +++ b/scripts/test-betrieb-sync.ts @@ -0,0 +1,158 @@ +// Lane L10b „Betrieb & Aufräumen" — Sync-Aufräumpunkte: +// b) Konflikt „Übernehmen" für report.submit (Dispatcher statt L2-Stub) +// c) Bundle mit eigener laufender WorkSession je Auftrag (+ Offline-Ansicht nutzt sie) +// f) clientId eindeutig je Mandant (@@unique([tenantId, clientId])) +// j) Sync-Ops report.save_draft / report.submit inkl. aiReviewed (Lotse-Freigabeprinzip) +// Jeweils mit Mandantentrennung (B) und Scope (Monteur ohne Zuweisung). +// +// Lauf: npx tsx scripts/test-betrieb-sync.ts (lokale Postgres-DB aus .env) + +import "dotenv/config"; +import { randomUUID } from "node:crypto"; +import { prisma } from "../src/server/db"; +import { closeJobQueues } from "../src/server/jobs/queues"; +import { applyOperations, reapplyOperation } from "../src/server/services/sync/apply"; +import { getFieldBundle } from "../src/server/services/field/queries"; +import { createDailyReport } from "../src/server/services/reports/create"; +import { applySyncConflict } from "../src/server/services/work-orders/conflicts"; +import { initialSession } from "../src/lib/offline/bundle-core"; +import { ROLE_DEFS } from "../src/server/rbac"; +import type { SyncOperationInput, SyncOpType } from "../src/lib/sync/envelope"; +import type { ServiceCtx } from "../src/server/services/context"; +import { createFixture, ctxFor, expectCode, failures, ok } from "./lib/einsatz-fixture"; + +function op(opType: SyncOpType, payload: Record, extra: Partial = {}): SyncOperationInput { + return { clientOpId: randomUUID(), opType, payload, clientCreatedAt: new Date().toISOString(), ...extra }; +} + +async function one(ctx: ServiceCtx, operation: SyncOperationInput) { + const res = await applyOperations(ctx, { deviceId: "l10b-device", operations: [operation] }); + return res.results[0]; +} + +const version = async (id: string) => (await prisma.workOrder.findUniqueOrThrow({ where: { id } })).version; + +async function main() { + const f = await createFixture("l10bsync"); + const wo = f.orderA.id; + const cleanupReports = async () => { + await prisma.report.deleteMany({ where: { tenantId: { in: [f.tenantA.id, f.tenantB.id] } } }); + }; + try { + // backoffice user in tenant A (resolves conflicts) + const officeIdentity = await prisma.identity.upsert({ where: { email: "office@zz-l10bsync.test" }, update: {}, create: { email: "office@zz-l10bsync.test", passwordHash: "x" } }); + const office = await prisma.user.create({ data: { tenantId: f.tenantA.id, identityId: officeIdentity.id, email: "office@zz-l10bsync.test", name: "Office A" } }); + const ctxOffice = ctxFor(f.tenantA.id, office.id, "backoffice"); + const ctxOfficeB = ctxFor(f.tenantB.id, f.techB.id, "backoffice"); + // „Übernehmen" loads the device user's permissions from the DB → give the technician a real role + const techPerms = await prisma.permission.findMany({ where: { key: { in: [...ROLE_DEFS.technician.permissions] } }, select: { id: true } }); + const techRole = await prisma.role.create({ + data: { tenantId: f.tenantA.id, key: "technician", name: ROLE_DEFS.technician.name, rolePermissions: { create: techPerms.map((p) => ({ permissionId: p.id })) } }, + }); + await prisma.userRole.create({ data: { userId: f.tech.id, roleId: techRole.id } }); + + console.log("\n— c) Bundle: eigene laufende Session —"); + const acc = await one(f.ctxTech, op("work_order.transition", { workOrderId: wo, to: "accepted" }, { baseVersion: await version(wo) })); + ok(acc.status === "applied", "Auftrag angenommen"); + const sessionClientId = randomUUID(); + const start = await one(f.ctxTech, op("session.start", { workOrderId: wo, mode: "work", clientId: sessionClientId })); + ok(start.status === "applied", "Session gestartet (Sync)"); + const techBundle = (await getFieldBundle(f.ctxTech)).orders.find((o) => o.id === wo); + ok(techBundle?.mySession?.status === "running" && techBundle.mySession.id === start.idMap?.[sessionClientId], "Bundle des Monteurs: mySession = laufende eigene Session"); + const leadBundle = (await getFieldBundle(f.ctxLead)).orders.find((o) => o.id === wo); + ok(!!leadBundle && leadBundle.mySession === null, "Teamleiter sieht den Auftrag, aber keine eigene Session (nicht aus dem Status abgeleitet)"); + ok(initialSession({ status: "in_progress", mySession: null }) === null, "Offline-Ansicht: in Arbeit ohne eigene Session → keine Zeitaktion Pause/Ende"); + ok(initialSession({ status: "in_progress", mySession: { id: "s", status: "paused", startedAt: "" } }) === "paused", "Offline-Ansicht: eigene Session pausiert"); + ok(initialSession({ status: "en_route" }) === "en_route", "Offline-Ansicht: altes Bundle ohne mySession → Näherung über Status"); + await one(f.ctxTech, op("session.pause", { workOrderId: wo })); + const paused = (await getFieldBundle(f.ctxTech)).orders.find((o) => o.id === wo); + ok(paused?.mySession?.status === "paused", "nach Pause: mySession paused"); + await one(f.ctxTech, op("session.resume", { workOrderId: wo })); + ok(!(await getFieldBundle(f.ctxB)).orders.some((o) => o.id === wo), "Mandant B: Auftrag von A nicht im Bundle"); + ok(!(await getFieldBundle(f.ctxOutsider)).orders.some((o) => o.id === wo), "Monteur ohne Zuweisung: Auftrag nicht im Bundle"); + + console.log("\n— f) clientId je Mandant —"); + const sameSession = await one(f.ctxB, op("session.start", { workOrderId: f.orderB.id, mode: "work", clientId: sessionClientId })); + ok(sameSession.status === "applied" && !!sameSession.idMap?.[sessionClientId] && sameSession.idMap[sessionClientId] !== start.idMap?.[sessionClientId], "gleiche Session-clientId in Mandant B → eigene Session (kein interner Fehler)"); + const noteClientId = randomUUID(); + const nA = await one(f.ctxTech, op("note.create", { workOrderId: wo, clientId: noteClientId, kind: "general", text: "A" })); + const nB = await one(f.ctxB, op("note.create", { workOrderId: f.orderB.id, clientId: noteClientId, kind: "general", text: "B" })); + ok(nA.status === "applied" && nB.status === "applied" && nA.idMap?.[noteClientId] !== nB.idMap?.[noteClientId], "gleiche Notiz-clientId in A und B → zwei Notizen"); + const replayA = await one(f.ctxTech, op("note.create", { workOrderId: wo, clientId: noteClientId, kind: "general", text: "A nochmal" })); + ok(replayA.status === "applied" && replayA.idMap?.[noteClientId] === nA.idMap?.[noteClientId], "Wiederholung in A (neue clientOpId) → dieselbe Notiz (Idempotenz je Mandant)"); + ok((await prisma.activityNote.count({ where: { clientId: noteClientId } })) === 2, "genau eine Notiz je Mandant"); + let dupRejected = false; + try { + await prisma.workSession.create({ data: { tenantId: f.tenantA.id, workOrderId: wo, userId: f.tech.id, status: "ended", startedAt: new Date(), endedAt: new Date(), clientId: sessionClientId } }); + } catch (err) { + dupRejected = (err as { code?: string }).code === "P2002"; + } + ok(dupRejected, "DB: doppelte clientId im selben Mandanten → Unique-Verletzung"); + + console.log("\n— j) report.save_draft / report.submit —"); + const { report } = await createDailyReport(f.ctxTech, { workOrderId: wo }); + await prisma.report.update({ where: { id: report.id }, data: { aiDrafted: true } }); // Lotse-Entwurf simulieren + const saved = await one(f.ctxTech, op("report.save_draft", { workOrderId: wo, reportId: report.id, texts: { workPerformed: "Heizkörper montiert und entlüftet" } })); + const afterSave = await prisma.report.findUniqueOrThrow({ where: { id: report.id } }); + ok(saved.status === "applied" && (afterSave.content as { texts: { workPerformed: string } }).texts.workPerformed === "Heizkörper montiert und entlüftet", "report.save_draft → Texte gespeichert"); + const badPayload = await one(f.ctxTech, op("report.submit", { workOrderId: wo }, { baseVersion: await version(wo) })); + ok(badPayload.status === "rejected" && badPayload.errorCode === "invalid", "report.submit ohne reportId → rejected invalid"); + + const noReview = await one(f.ctxTech, op("report.submit", { workOrderId: wo, reportId: report.id }, { baseVersion: await version(wo) })); + ok(noReview.status === "rejected" && noReview.errorCode === "invalid" && /reviewed/.test(noReview.message ?? ""), "Lotse-Entwurf offline ohne aiReviewed → rejected invalid"); + ok((await prisma.report.findUniqueOrThrow({ where: { id: report.id } })).status === "draft", "Bericht bleibt Entwurf"); + + const foreignB = await one(f.ctxB, op("report.submit", { workOrderId: wo, reportId: report.id, aiReviewed: true }, { baseVersion: await version(wo) })); + ok(foreignB.status === "rejected" && foreignB.errorCode === "not_found", "Mandant B: report.submit auf A → not_found"); + const outsider = await one(f.ctxOutsider, op("report.submit", { workOrderId: wo, reportId: report.id, aiReviewed: true }, { baseVersion: await version(wo) })); + ok(outsider.status === "rejected" && outsider.errorCode === "not_found", "Monteur ohne Zuweisung: report.submit → not_found"); + const mismatch = await one(f.ctxB, op("report.save_draft", { workOrderId: f.orderB.id, reportId: report.id, texts: { hints: "x" } })); + ok(mismatch.status === "rejected" && mismatch.errorCode === "not_found", "Mandant B: Bericht von A über eigenen Auftrag → not_found"); + + const current = await version(wo); + const stale = await one(f.ctxTech, op("report.submit", { workOrderId: wo, reportId: report.id, aiReviewed: true }, { baseVersion: current - 1 })); + ok(stale.status === "conflict" && stale.entityVersion === current, "veraltete baseVersion → conflict"); + ok((await prisma.report.findUniqueOrThrow({ where: { id: report.id } })).status === "draft", "bei Konflikt nichts abgesendet"); + const conflictOp = await prisma.syncOperation.findFirstOrThrow({ where: { tenantId: f.tenantA.id, clientOpId: stale.clientOpId } }); + ok(conflictOp.status === "conflict" && conflictOp.opType === "report.submit", "Konflikt für die Backoffice-Liste gespeichert"); + + console.log("\n— b) Konflikt übernehmen (report.submit) —"); + await expectCode(() => applySyncConflict(ctxOfficeB, conflictOp.id), "not_found", "Mandant B kann den Konflikt von A nicht übernehmen"); + await expectCode(() => applySyncConflict(f.ctxTech, conflictOp.id), "forbidden", "Monteur (ohne work_order:write) kann Konflikte nicht übernehmen"); + await expectCode(() => reapplyOperation(f.ctxTech, { opType: "note.create", entityId: wo, payload: { workOrderId: wo, kind: "general", text: "x" } }), "invalid", "Übernehmen nur für konfliktbehaftete Ops"); + const taken = await applySyncConflict(ctxOffice, conflictOp.id); + const submitted = await prisma.report.findUniqueOrThrow({ where: { id: report.id } }); + ok(submitted.status === "submitted" && typeof taken.entityVersion === "number", "Übernehmen → Bericht abgesendet (als Gerätenutzer, aiReviewed aus der Op)"); + const resolved = await prisma.syncOperation.findUniqueOrThrow({ where: { id: conflictOp.id } }); + ok(resolved.status === "applied" && resolved.resolvedById === office.id, "Konflikt als übernommen markiert (resolvedBy Backoffice)"); + await expectCode(() => applySyncConflict(ctxOffice, conflictOp.id), "not_found", "zweites Übernehmen → not_found"); + + // transition conflicts keep working through the dispatcher + const staleTransition = await one(f.ctxTech, op("work_order.transition", { workOrderId: wo, to: "paused" }, { baseVersion: 1 })); + ok(staleTransition.status === "conflict", "Statuswechsel mit veralteter Version → conflict"); + const tOp = await prisma.syncOperation.findFirstOrThrow({ where: { tenantId: f.tenantA.id, clientOpId: staleTransition.clientOpId } }); + const statusBefore = (await prisma.workOrder.findUniqueOrThrow({ where: { id: wo } })).status; + const refused = await applySyncConflict(ctxOffice, tOp.id).then( + () => null, + (err: { code?: string }) => err.code ?? "error", + ); + ok(refused === "invalid" || refused === "forbidden", `Übernehmen gegen aktuellen Stand: unzulässiger Übergang wird abgelehnt (${refused})`); + ok((await prisma.workOrder.findUniqueOrThrow({ where: { id: wo } })).status === statusBefore, "… Auftragsstatus unverändert, Konflikt bleibt offen"); + ok((await prisma.syncOperation.findUniqueOrThrow({ where: { id: tOp.id } })).status === "conflict", "… SyncOperation weiterhin conflict"); + } finally { + await cleanupReports().catch((e) => console.error("report cleanup failed", e)); + await f.cleanup().catch((e) => console.error("cleanup failed", e)); + await closeJobQueues(); + await prisma.$disconnect(); + } +} + +main() + .catch((err) => { + console.error(err); + ok(false, `unerwarteter Fehler: ${(err as Error).message}`); + }) + .finally(() => { + console.log(failures ? `\n✗ ${failures} Prüfung(en) fehlgeschlagen` : "\n✓ Alle Sync-Aufräumprüfungen grün"); + process.exit(failures ? 1 : 0); + }); diff --git a/scripts/test-einsatz-sync.ts b/scripts/test-einsatz-sync.ts index 04b06a3..4ed2651 100644 --- a/scripts/test-einsatz-sync.ts +++ b/scripts/test-einsatz-sync.ts @@ -82,8 +82,9 @@ async function main() { ok(reuseOther.status === "rejected" && !reuseOther.idMap, "fremder Nutzer mit gleicher clientOpId erhält kein gespeichertes Ergebnis"); console.log("\n— Ops fremder Lanes —"); - const report = await one(f.ctxTech, op("report.save_draft", { workOrderId: wo })); - ok(report.status === "rejected" && report.errorCode === "invalid" && /not available/.test(report.message ?? ""), "report.save_draft ohne L5 → rejected invalid mit Hinweis"); + // L10b: report.save_draft/report.submit are registered now (test-betrieb-sync.ts); signature.capture is still unregistered + const report = await one(f.ctxTech, op("signature.capture", { workOrderId: wo })); + ok(report.status === "rejected" && report.errorCode === "invalid" && /not available/.test(report.message ?? ""), "signature.capture ohne Implementierung → rejected invalid mit Hinweis"); ok((await prisma.syncOperation.count({ where: { clientOpId: report.clientOpId } })) === 0, "nicht verfügbare Op wird nicht gespeichert (später wiederholbar)"); console.log("\n— Uploads & Mandantentrennung —"); diff --git a/scripts/test-garage-storage.ts b/scripts/test-garage-storage.ts index 119f6e3..27e1810 100644 --- a/scripts/test-garage-storage.ts +++ b/scripts/test-garage-storage.ts @@ -1,5 +1,5 @@ // Smoke-Test des S3-Objektspeichers gegen einen echten Garage-Node (Phase C/D der -// MinIO→Garage-Migration, docs/KONZEPT-garage-migration.md §9). +// MinIO→Garage-Migration, docs/_certvia-archiv/KONZEPT-garage-migration.md §9). // // Prüft den realen S3-Pfad (AWS SDK v3, path-style) über die exportierte // `resolveBackupStore()`: diff --git a/src/app/(app)/layout.tsx b/src/app/(app)/layout.tsx index a30979f..8f77b5e 100644 --- a/src/app/(app)/layout.tsx +++ b/src/app/(app)/layout.tsx @@ -13,6 +13,7 @@ import { UiLocaleSwitcher } from "@/components/ui-locale-switcher"; import { CraftviaLogo } from "@/components/brand/craftvia-logo"; import { NotificationBell } from "@/components/notifications/bell"; import { AccountInactiveNotice } from "@/components/account-inactive-notice"; +import { BackofficeFrame } from "@/components/backoffice-frame"; export default async function AppLayout({ children, @@ -56,9 +57,8 @@ export default async function AppLayout({ await signOut({ redirectTo: "/login" }); } - return ( -
- + + ); -
-
-
+ // L10b: collapsible sidebar below 1024 px (src/components/backoffice-frame.tsx) + return ( + +
-
+

{session.user.name}

{session.user.tenantSlug}

@@ -105,9 +108,9 @@ export default async function AppLayout({ {tc("logout")} -
- {children} -
-
+ + }> + {children} + ); } diff --git a/src/app/(app)/settings/lotse/page.tsx b/src/app/(app)/settings/lotse/page.tsx index 19f5d59..449a264 100644 --- a/src/app/(app)/settings/lotse/page.tsx +++ b/src/app/(app)/settings/lotse/page.tsx @@ -1,6 +1,6 @@ import Link from "next/link"; import { redirect } from "next/navigation"; -import { getTranslations } from "next-intl/server"; +import { getLocale, getTranslations } from "next-intl/server"; import { ArrowLeft, CheckCircle2, ListChecks, MinusCircle, ShieldCheck, XCircle } from "lucide-react"; import { LotseMark } from "@/components/lotse/lotse-mark"; import { PageHead } from "@/components/mockup-ui"; @@ -27,8 +27,9 @@ function ProviderStatus({ ok, labels }: { ok: boolean; labels: { ok: string; off export default async function LotseSettingsPage({ searchParams }: { searchParams: Promise<{ saved?: string; error?: string }> }) { const ctx = await readCtx(); if (!can(ctx, "tenant:manage")) redirect("/dashboard"); - const [sp, s, t] = await Promise.all([searchParams, getLotseSettings(ctx), getTranslations("lotse")]); + const [sp, s, t, locale] = await Promise.all([searchParams, getLotseSettings(ctx), getTranslations("lotse"), getLocale()]); const statusLabels = { ok: t("settings.configured"), off: t("settings.notConfigured") }; + const nf = new Intl.NumberFormat(locale); return (
@@ -72,6 +73,36 @@ export default async function LotseSettingsPage({ searchParams }: { searchParams ))} +
+ +

+ {t("settings.budgetHint", { platform: s.budget.platformLimit > 0 ? nf.format(s.budget.platformLimit) : t("settings.budgetUnlimited") })} +

+ +

+ {t("settings.budgetUsage", { + since: new Date(s.budget.periodStart).toLocaleDateString(locale, { timeZone: "UTC" }), + used: nf.format(s.budget.used), + limit: s.budget.limit > 0 ? nf.format(s.budget.limit) : t("settings.budgetUnlimited"), + })} + {s.budget.exceeded && ( + + {t("settings.budgetExceeded")} + + )} +

+
diff --git a/src/app/api/v1/field/bundle/route.ts b/src/app/api/v1/field/bundle/route.ts index 0fe60db..51cd780 100644 --- a/src/app/api/v1/field/bundle/route.ts +++ b/src/app/api/v1/field/bundle/route.ts @@ -1,14 +1,12 @@ -import { NextResponse } from "next/server"; +import { requireApiContext } from "@/server/api/context"; +import { ApiError, json, withApi } from "@/server/api/respond"; import { getFieldBundle } from "@/server/services/field/queries"; -import { apiError, requireApiContext, withApi } from "@/server/services/sync/api-context"; /** GET /api/v1/field/bundle?since= — offline pull of the orders in scope (ARCHITEKTUR §4.6). */ -export async function GET(req: Request) { - return withApi(req, async () => { - const ctx = await requireApiContext("field", "field:execute"); - const raw = new URL(req.url).searchParams.get("since"); - const since = raw ? new Date(raw) : null; - if (since && Number.isNaN(since.getTime())) return apiError("invalid", 400, "invalid since"); - return NextResponse.json(await getFieldBundle(ctx, since), { headers: { "Cache-Control": "private, no-store" } }); - }); -} +export const GET = withApi(async (req: Request) => { + const ctx = await requireApiContext("field", "field:execute"); + const raw = new URL(req.url).searchParams.get("since"); + const since = raw ? new Date(raw) : null; + if (since && Number.isNaN(since.getTime())) throw new ApiError("invalid", "invalid since"); + return json(await getFieldBundle(ctx, since), { headers: { "Cache-Control": "private, no-store" } }); +}); diff --git a/src/app/api/v1/field/documents/[id]/route.ts b/src/app/api/v1/field/documents/[id]/route.ts index 778985b..3a844a2 100644 --- a/src/app/api/v1/field/documents/[id]/route.ts +++ b/src/app/api/v1/field/documents/[id]/route.ts @@ -1,5 +1,6 @@ +import { requireApiContext } from "@/server/api/context"; +import { withApi } from "@/server/api/respond"; import { openFieldDocument } from "@/server/services/field/documents"; -import { requireApiContext, withApi } from "@/server/services/sync/api-context"; /** * GET /api/v1/field/documents/[?variant=preview] — authorised document delivery for the mobile @@ -8,20 +9,18 @@ import { requireApiContext, withApi } from "@/server/services/sync/api-context"; */ const INLINE = /^(image\/(jpeg|png|webp)|application\/pdf|audio\/(webm|ogg|mp4|mpeg|wav))$/; -export async function GET(req: Request, { params }: { params: Promise<{ id: string }> }) { - return withApi(req, async () => { - const ctx = await requireApiContext("field"); - const { id } = await params; - const variant = new URL(req.url).searchParams.get("variant") === "preview" ? "preview" : "original"; - const { content, mimeType, fileName } = await openFieldDocument(ctx, id, variant); - const safeName = fileName.replace(/["\\\r\n]/g, "_"); - const headers = new Headers({ - "Content-Type": mimeType, - "Content-Disposition": `${INLINE.test(mimeType) ? "inline" : "attachment"}; filename="${safeName}"`, - "X-Content-Type-Options": "nosniff", - "Cache-Control": "private, max-age=300", - }); - if (content.size != null) headers.set("Content-Length", String(content.size)); - return new Response(content.stream, { headers }); +export const GET = withApi(async (req: Request, { params }: { params: Promise<{ id: string }> }) => { + const ctx = await requireApiContext("field"); + const { id } = await params; + const variant = new URL(req.url).searchParams.get("variant") === "preview" ? "preview" : "original"; + const { content, mimeType, fileName } = await openFieldDocument(ctx, id, variant); + const safeName = fileName.replace(/["\\\r\n]/g, "_"); + const headers = new Headers({ + "Content-Type": mimeType, + "Content-Disposition": `${INLINE.test(mimeType) ? "inline" : "attachment"}; filename="${safeName}"`, + "X-Content-Type-Options": "nosniff", + "Cache-Control": "private, max-age=300", }); -} + if (content.size != null) headers.set("Content-Length", String(content.size)); + return new Response(content.stream, { headers }); +}); diff --git a/src/app/api/v1/imports/[id]/confirm/route.ts b/src/app/api/v1/imports/[id]/confirm/route.ts index 829ff5d..9935007 100644 --- a/src/app/api/v1/imports/[id]/confirm/route.ts +++ b/src/app/api/v1/imports/[id]/confirm/route.ts @@ -1,22 +1,13 @@ +import { requireApiContext } from "@/server/api/context"; +import { json, readJson, withApi } from "@/server/api/respond"; import { confirmImport } from "@/server/services/imports/confirm"; -import { apiError, importsApiContext } from "../../_context"; /** * POST /api/v1/imports/[id]/confirm — JSON body = review form (src/lib/imports/review.ts * `reviewFormSchema`). Creates/assigns customer, site, contact and the work order. Lane L3. */ -export async function POST(req: Request, { params }: { params: Promise<{ id: string }> }) { - try { - const ctx = await importsApiContext("import:write", "work_order:write"); - const { id } = await params; - let body: unknown; - try { - body = await req.json(); - } catch { - return Response.json({ error: "invalid", message: "json_required" }, { status: 400 }); - } - return Response.json(await confirmImport(ctx, id, body)); - } catch (err) { - return apiError(err); - } -} +export const POST = withApi(async (req: Request, { params }: { params: Promise<{ id: string }> }) => { + const ctx = await requireApiContext("imports", "import:write", "work_order:write"); + const { id } = await params; + return json(await confirmImport(ctx, id, await readJson(req))); +}); diff --git a/src/app/api/v1/imports/[id]/route.ts b/src/app/api/v1/imports/[id]/route.ts index c35a845..37cdc8d 100644 --- a/src/app/api/v1/imports/[id]/route.ts +++ b/src/app/api/v1/imports/[id]/route.ts @@ -1,13 +1,10 @@ +import { requireApiContext } from "@/server/api/context"; +import { json, withApi } from "@/server/api/respond"; import { getImportDetail } from "@/server/services/imports/queries"; -import { apiError, importsApiContext } from "../_context"; /** GET /api/v1/imports/[id] — import status, extraction (with confidences), candidates. Lane L3. */ -export async function GET(_req: Request, { params }: { params: Promise<{ id: string }> }) { - try { - const ctx = await importsApiContext("import:write"); - const { id } = await params; - return Response.json(await getImportDetail(ctx, id)); - } catch (err) { - return apiError(err); - } -} +export const GET = withApi(async (_req: Request, { params }: { params: Promise<{ id: string }> }) => { + const ctx = await requireApiContext("imports", "import:write"); + const { id } = await params; + return json(await getImportDetail(ctx, id)); +}); diff --git a/src/app/api/v1/imports/_context.ts b/src/app/api/v1/imports/_context.ts deleted file mode 100644 index ebdf8a2..0000000 --- a/src/app/api/v1/imports/_context.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { moduleGuard } from "@/server/action-guard"; -import { ForbiddenError, type Permission } from "@/server/rbac"; -import { ModuleDisabledError } from "@/server/modules"; -import { ctxFromGuard, ServiceError, type ServiceCtx } from "@/server/services/context"; - -/** - * Lane L3 helper for the /api/v1 import handlers (thin adapters). Uses the same DB-authoritative - * guard as the server actions (session → account/identity status → permissions → module). - * Not a route: files starting with "_" are ignored by the App Router. - * TODO(architecture): replace with a shared `requireApiContext` once it exists. - */ -const guard = moduleGuard("imports"); - -export async function importsApiContext(...permissions: Permission[]): Promise { - return ctxFromGuard(await guard(...permissions)); -} - -const STATUS: Record = { not_found: 404, forbidden: 403, invalid: 400, conflict: 409, blocked: 409 }; - -/** Map service/guard errors to JSON responses without leaking internals. */ -export function apiError(err: unknown): Response { - if (err instanceof ServiceError) { - return Response.json({ error: err.code, message: err.message, details: err.code === "invalid" ? err.details : undefined }, { status: STATUS[err.code] }); - } - if (err instanceof ForbiddenError || err instanceof ModuleDisabledError) { - return Response.json({ error: "forbidden" }, { status: 403 }); - } - if (err instanceof Error && /Nicht angemeldet|nicht mehr gueltig/.test(err.message)) { - return Response.json({ error: "unauthorized" }, { status: 401 }); - } - if (err instanceof Error && /Konto ist nicht aktiv|Passwortwechsel/.test(err.message)) { - return Response.json({ error: "forbidden" }, { status: 403 }); - } - console.error("[api/imports]", err); - return Response.json({ error: "internal" }, { status: 500 }); -} diff --git a/src/app/api/v1/openapi.json/route.ts b/src/app/api/v1/openapi.json/route.ts new file mode 100644 index 0000000..10ded33 --- /dev/null +++ b/src/app/api/v1/openapi.json/route.ts @@ -0,0 +1,12 @@ +import { openApiDocument } from "@/lib/api/openapi"; + +/** + * GET /api/v1/openapi.json — the statically maintained OpenAPI 3.1 document (src/lib/api/openapi.ts). + * + * Auth: src/proxy.ts rejects every /api/v1 request without a session cookie with 401, so the + * document is only reachable for signed-in users. It contains no tenant data, therefore no + * further permission/module check is done here (deliberately — any API client may read it). + */ +export function GET() { + return Response.json(openApiDocument, { headers: { "Cache-Control": "private, max-age=300" } }); +} diff --git a/src/app/api/v1/reports/[id]/approve/route.ts b/src/app/api/v1/reports/[id]/approve/route.ts index cc72288..cae4fb0 100644 --- a/src/app/api/v1/reports/[id]/approve/route.ts +++ b/src/app/api/v1/reports/[id]/approve/route.ts @@ -1,11 +1,12 @@ +import { requireApiContext } from "@/server/api/context"; +import { json, withApi } from "@/server/api/respond"; import { approveReport } from "@/server/services/reports/approve"; -import { reportDto, withReportsApi } from "@/server/services/reports/http"; +import { reportDto } from "@/server/services/reports/dto"; -/** POST /api/v1/reports/:id/approve — team lead → team_approved, backoffice → approved (+ PDF job). */ -export async function POST(_req: Request, { params }: { params: Promise<{ id: string }> }) { +/** POST /api/v1/reports/:id/approve — team lead → team_approved, backoffice → approved (+ PDF job). Approval rights are checked in the service. */ +export const POST = withApi(async (_req: Request, { params }: { params: Promise<{ id: string }> }) => { + const ctx = await requireApiContext("reports", "report:read"); const { id } = await params; - return withReportsApi(["report:read"], async (ctx) => { - const report = await approveReport(ctx, { reportId: id }); - return Response.json({ report: reportDto(report) }); - }); -} + const report = await approveReport(ctx, { reportId: id }); + return json({ report: reportDto(report) }); +}); diff --git a/src/app/api/v1/reports/[id]/files/[documentId]/route.ts b/src/app/api/v1/reports/[id]/files/[documentId]/route.ts index b3cb5b4..d825b64 100644 --- a/src/app/api/v1/reports/[id]/files/[documentId]/route.ts +++ b/src/app/api/v1/reports/[id]/files/[documentId]/route.ts @@ -1,9 +1,11 @@ +import { requireApiContext } from "@/server/api/context"; +import { withApi } from "@/server/api/respond"; import { fileResponse, openReportFile } from "@/server/services/reports/files"; -import { withReportsApi } from "@/server/services/reports/http"; /** GET /api/v1/reports/:id/files/:documentId — photo/signature/logo referenced by the report snapshot. */ -export async function GET(req: Request, { params }: { params: Promise<{ id: string; documentId: string }> }) { +export const GET = withApi(async (req: Request, { params }: { params: Promise<{ id: string; documentId: string }> }) => { + const ctx = await requireApiContext("reports", "report:read"); const { id, documentId } = await params; const download = new URL(req.url).searchParams.get("download") === "1"; - return withReportsApi(["report:read"], async (ctx) => fileResponse(await openReportFile(ctx, id, documentId), { download })); -} + return fileResponse(await openReportFile(ctx, id, documentId), { download }); +}); diff --git a/src/app/api/v1/reports/[id]/pdf/route.ts b/src/app/api/v1/reports/[id]/pdf/route.ts index d24183d..9fa9097 100644 --- a/src/app/api/v1/reports/[id]/pdf/route.ts +++ b/src/app/api/v1/reports/[id]/pdf/route.ts @@ -1,9 +1,11 @@ +import { requireApiContext } from "@/server/api/context"; +import { withApi } from "@/server/api/respond"; import { fileResponse, openReportFile } from "@/server/services/reports/files"; -import { withReportsApi } from "@/server/services/reports/http"; /** GET /api/v1/reports/:id/pdf — the immutable PDF of an approved report (?download=1 for attachment). */ -export async function GET(req: Request, { params }: { params: Promise<{ id: string }> }) { +export const GET = withApi(async (req: Request, { params }: { params: Promise<{ id: string }> }) => { + const ctx = await requireApiContext("reports", "report:read"); const { id } = await params; const download = new URL(req.url).searchParams.get("download") === "1"; - return withReportsApi(["report:read"], async (ctx) => fileResponse(await openReportFile(ctx, id, "pdf"), { download })); -} + return fileResponse(await openReportFile(ctx, id, "pdf"), { download }); +}); diff --git a/src/app/api/v1/sync/route.ts b/src/app/api/v1/sync/route.ts index 145838a..da4dd6d 100644 --- a/src/app/api/v1/sync/route.ts +++ b/src/app/api/v1/sync/route.ts @@ -1,14 +1,14 @@ -import { NextResponse } from "next/server"; import { syncRequestSchema } from "@/lib/sync/envelope"; +import { requireApiContext } from "@/server/api/context"; +import { ApiError, json, readJson, withApi } from "@/server/api/respond"; import { applyOperations } from "@/server/services/sync/apply"; -import { apiError, requireApiContext, withApi } from "@/server/services/sync/api-context"; /** POST /api/v1/sync — batch of offline/online operations (ARCHITEKTUR §4.6). */ -export async function POST(req: Request) { - return withApi(req, async () => { - const ctx = await requireApiContext("field"); - const body = syncRequestSchema.safeParse(await req.json().catch(() => null)); - if (!body.success) return apiError("invalid", 400, "invalid sync request", body.error.issues.slice(0, 10)); - return NextResponse.json(await applyOperations(ctx, body.data), { headers: { "Cache-Control": "no-store" } }); - }); -} +export const POST = withApi(async (req: Request) => { + const ctx = await requireApiContext("field"); + const body = syncRequestSchema.safeParse(await readJson(req)); + if (!body.success) { + throw new ApiError("invalid", "invalid sync request", body.error.issues.slice(0, 10).map((i) => ({ path: i.path.join("."), code: i.code }))); + } + return json(await applyOperations(ctx, body.data)); +}); diff --git a/src/app/api/v1/uploads/route.ts b/src/app/api/v1/uploads/route.ts index 0eac1ca..d2a8180 100644 --- a/src/app/api/v1/uploads/route.ts +++ b/src/app/api/v1/uploads/route.ts @@ -1,6 +1,6 @@ -import { NextResponse } from "next/server"; +import { requireApiContext } from "@/server/api/context"; +import { ApiError, json, readFormData, withApi } from "@/server/api/respond"; import { storeFieldUpload, uploadMetaSchema } from "@/server/services/field/uploads"; -import { apiError, requireApiContext, withApi } from "@/server/services/sync/api-context"; /** * POST /api/v1/uploads — multipart: file, clientId (uuid), workOrderId, kind (photo|voice_note), @@ -8,27 +8,24 @@ import { apiError, requireApiContext, withApi } from "@/server/services/sync/api */ const MAX_BYTES = 25 * 1024 * 1024; -export async function POST(req: Request) { - return withApi(req, async () => { - const ctx = await requireApiContext("field", "field:execute"); - const declared = Number(req.headers.get("content-length") ?? "0"); - if (declared > MAX_BYTES + 3 * 1024 * 1024) return apiError("invalid", 413, "file too large"); +export const POST = withApi(async (req: Request) => { + const ctx = await requireApiContext("field", "field:execute"); + const declared = Number(req.headers.get("content-length") ?? "0"); + if (declared > MAX_BYTES + 3 * 1024 * 1024) throw new ApiError("payload_too_large", "file too large"); - const form = await req.formData().catch(() => null); - if (!form) return apiError("invalid", 400, "multipart body expected"); - const file = form.get("file"); - if (!(file instanceof File)) return apiError("invalid", 400, "file missing"); - if (file.size > MAX_BYTES) return apiError("invalid", 413, "file too large"); - const meta = uploadMetaSchema.safeParse({ clientId: form.get("clientId"), workOrderId: form.get("workOrderId"), kind: form.get("kind") }); - if (!meta.success) return apiError("invalid", 400, "invalid upload metadata"); - const preview = form.get("preview"); + const form = await readFormData(req); + const file = form.get("file"); + if (!(file instanceof File)) throw new ApiError("invalid", "file missing"); + if (file.size > MAX_BYTES) throw new ApiError("payload_too_large", "file too large"); + const meta = uploadMetaSchema.safeParse({ clientId: form.get("clientId"), workOrderId: form.get("workOrderId"), kind: form.get("kind") }); + if (!meta.success) throw new ApiError("invalid", "invalid upload metadata"); + const preview = form.get("preview"); - const result = await storeFieldUpload( - ctx, - meta.data, - { bytes: Buffer.from(await file.arrayBuffer()), name: file.name, type: file.type }, - preview instanceof File && preview.size > 0 ? { bytes: Buffer.from(await preview.arrayBuffer()), name: preview.name, type: preview.type } : null, - ); - return NextResponse.json(result, { status: result.duplicate ? 200 : 201, headers: { "Cache-Control": "no-store" } }); - }); -} + const result = await storeFieldUpload( + ctx, + meta.data, + { bytes: Buffer.from(await file.arrayBuffer()), name: file.name, type: file.type }, + preview instanceof File && preview.size > 0 ? { bytes: Buffer.from(await preview.arrayBuffer()), name: preview.name, type: preview.type } : null, + ); + return json(result, { status: result.duplicate ? 200 : 201 }); +}); diff --git a/src/app/api/v1/work-orders/[id]/assign/route.ts b/src/app/api/v1/work-orders/[id]/assign/route.ts index 077d166..262ca1a 100644 --- a/src/app/api/v1/work-orders/[id]/assign/route.ts +++ b/src/app/api/v1/work-orders/[id]/assign/route.ts @@ -1,22 +1,18 @@ -import { NextResponse, type NextRequest } from "next/server"; +import { requireApiContext } from "@/server/api/context"; +import { json, optionalVersion, readJsonObject, withApi } from "@/server/api/respond"; import { assignWorkOrder } from "@/server/services/work-orders/assign"; -import { apiContext, apiError, optionalVersion, readJson } from "../../_http"; /** POST /api/v1/work-orders/[id]/assign — body { teamId, userIds?, teamLeadUserId?, baseVersion? }. */ -export async function POST(req: NextRequest, { params }: { params: Promise<{ id: string }> }) { - try { - const { id } = await params; - const ctx = await apiContext("work_order:assign"); - const body = await readJson(req); - const res = await assignWorkOrder(ctx, { - workOrderId: id, - teamId: String(body.teamId ?? ""), - userIds: Array.isArray(body.userIds) ? body.userIds.map(String) : [], - teamLeadUserId: typeof body.teamLeadUserId === "string" ? body.teamLeadUserId : null, - baseVersion: optionalVersion(body.baseVersion), - }); - return NextResponse.json(res); - } catch (err) { - return apiError(err); - } -} +export const POST = withApi(async (req: Request, { params }: { params: Promise<{ id: string }> }) => { + const ctx = await requireApiContext("work_orders", "work_order:assign"); + const { id } = await params; + const body = await readJsonObject(req); + const res = await assignWorkOrder(ctx, { + workOrderId: id, + teamId: String(body.teamId ?? ""), + userIds: Array.isArray(body.userIds) ? body.userIds.map(String) : [], + teamLeadUserId: typeof body.teamLeadUserId === "string" ? body.teamLeadUserId : null, + baseVersion: optionalVersion(body.baseVersion), + }); + return json(res); +}); diff --git a/src/app/api/v1/work-orders/[id]/completion-report/route.ts b/src/app/api/v1/work-orders/[id]/completion-report/route.ts index 1ee5d45..8199286 100644 --- a/src/app/api/v1/work-orders/[id]/completion-report/route.ts +++ b/src/app/api/v1/work-orders/[id]/completion-report/route.ts @@ -1,12 +1,13 @@ +import { requireApiContext } from "@/server/api/context"; +import { json, readJsonObject, withApi } from "@/server/api/respond"; import { createCompletionReport } from "@/server/services/reports/create"; -import { readJson, reportDto, withReportsApi } from "@/server/services/reports/http"; +import { reportDto } from "@/server/services/reports/dto"; -/** POST /api/v1/work-orders/:id/completion-report — create (or return) the completion report draft; 422 + blockers if blocked. */ -export async function POST(req: Request, { params }: { params: Promise<{ id: string }> }) { +/** POST /api/v1/work-orders/:id/completion-report — create (or return) the completion report draft; 422 blocked + blockers if incomplete. */ +export const POST = withApi(async (req: Request, { params }: { params: Promise<{ id: string }> }) => { + const ctx = await requireApiContext("reports", "report:write"); const { id } = await params; - return withReportsApi(["report:write"], async (ctx) => { - const body = await readJson(req); - const res = await createCompletionReport(ctx, { ...body, workOrderId: id } as Parameters[1]); - return Response.json({ report: reportDto(res.report), created: res.created }, { status: res.created ? 201 : 200 }); - }); -} + const body = await readJsonObject(req, { allowEmpty: true }); + const res = await createCompletionReport(ctx, { ...body, workOrderId: id } as Parameters[1]); + return json({ report: reportDto(res.report), created: res.created }, { status: res.created ? 201 : 200 }); +}); diff --git a/src/app/api/v1/work-orders/[id]/daily-report/route.ts b/src/app/api/v1/work-orders/[id]/daily-report/route.ts index c0bf641..44d09e0 100644 --- a/src/app/api/v1/work-orders/[id]/daily-report/route.ts +++ b/src/app/api/v1/work-orders/[id]/daily-report/route.ts @@ -1,12 +1,13 @@ +import { requireApiContext } from "@/server/api/context"; +import { json, readJsonObject, withApi } from "@/server/api/respond"; import { createDailyReport } from "@/server/services/reports/create"; -import { readJson, reportDto, withReportsApi } from "@/server/services/reports/http"; +import { reportDto } from "@/server/services/reports/dto"; /** POST /api/v1/work-orders/:id/daily-report — create (or return) the daily report draft. Body: { reportDate?, clientId? } */ -export async function POST(req: Request, { params }: { params: Promise<{ id: string }> }) { +export const POST = withApi(async (req: Request, { params }: { params: Promise<{ id: string }> }) => { + const ctx = await requireApiContext("reports", "report:write"); const { id } = await params; - return withReportsApi(["report:write"], async (ctx) => { - const body = await readJson(req); - const res = await createDailyReport(ctx, { ...body, workOrderId: id } as Parameters[1]); - return Response.json({ report: reportDto(res.report), created: res.created }, { status: res.created ? 201 : 200 }); - }); -} + const body = await readJsonObject(req, { allowEmpty: true }); + const res = await createDailyReport(ctx, { ...body, workOrderId: id } as Parameters[1]); + return json({ report: reportDto(res.report), created: res.created }, { status: res.created ? 201 : 200 }); +}); diff --git a/src/app/api/v1/work-orders/[id]/documents/route.ts b/src/app/api/v1/work-orders/[id]/documents/route.ts index 020ab21..94e10be 100644 --- a/src/app/api/v1/work-orders/[id]/documents/route.ts +++ b/src/app/api/v1/work-orders/[id]/documents/route.ts @@ -1,25 +1,26 @@ -import { NextResponse, type NextRequest } from "next/server"; import type { DocumentCategory, DocumentVisibility } from "@prisma/client"; +import { assertSameOrigin, requireApiContext } from "@/server/api/context"; +import { ApiError, json, readFormData, toErrorResponse } from "@/server/api/respond"; import { ServiceError } from "@/server/services/context"; import { uploadWorkOrderDocument } from "@/server/services/work-orders/documents"; -import { apiContext, apiError } from "../../_http"; /** * POST /api/v1/work-orders/[id]/documents — multipart upload (file, category, visibility, title?). * Used by the backoffice form (HTML post → 303 back to the documents tab) and by API clients (JSON). * A route handler instead of a server action avoids the 1 MB server-action body limit. + * Not wrapped in withApi because browser form posts get redirects instead of JSON errors. */ -export async function POST(req: NextRequest, { params }: { params: Promise<{ id: string }> }) { +export async function POST(req: Request, { params }: { params: Promise<{ id: string }> }) { const { id } = await params; + const origin = new URL(req.url).origin; const wantsHtml = (req.headers.get("accept") ?? "").includes("text/html"); try { - // CSRF defence for the cookie-authenticated form post: same-origin only. - const origin = req.headers.get("origin"); - if (origin && origin !== req.nextUrl.origin) throw new ServiceError("forbidden", "cross_origin"); - const ctx = await apiContext("document:write"); - const form = await req.formData(); + assertSameOrigin(req); + const ctx = await requireApiContext("work_orders", "document:write"); + const form = await readFormData(req); const file = form.get("file"); if (!(file instanceof File) || file.size === 0) throw new ServiceError("invalid", "file_missing"); + const title = form.get("title"); const doc = await uploadWorkOrderDocument(ctx, { workOrderId: id, bytes: new Uint8Array(await file.arrayBuffer()), @@ -27,14 +28,14 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id: declaredMime: file.type, category: String(form.get("category") ?? "other") as DocumentCategory, visibility: String(form.get("visibility") ?? "team") as DocumentVisibility, - title: typeof form.get("title") === "string" && String(form.get("title")).trim() ? String(form.get("title")).trim() : null, + title: typeof title === "string" && title.trim() ? title.trim() : null, }); - if (wantsHtml) return NextResponse.redirect(new URL(`/work-orders/${encodeURIComponent(id)}?tab=documents&uploaded=1`, req.nextUrl.origin), 303); - return NextResponse.json(doc, { status: 201 }); + if (wantsHtml) return Response.redirect(new URL(`/work-orders/${encodeURIComponent(id)}?tab=documents&uploaded=1`, origin), 303); + return json(doc, { status: 201 }); } catch (err) { - if (wantsHtml && err instanceof ServiceError) { - return NextResponse.redirect(new URL(`/work-orders/${encodeURIComponent(id)}?tab=documents&uploadError=${encodeURIComponent(err.message)}`, req.nextUrl.origin), 303); + if (wantsHtml && (err instanceof ServiceError || (err instanceof ApiError && err.code !== "unauthorized"))) { + return Response.redirect(new URL(`/work-orders/${encodeURIComponent(id)}?tab=documents&uploadError=${encodeURIComponent(err.message)}`, origin), 303); } - return apiError(err); + return toErrorResponse(err); } } diff --git a/src/app/api/v1/work-orders/[id]/materials/route.ts b/src/app/api/v1/work-orders/[id]/materials/route.ts index 72db57e..19bb75f 100644 --- a/src/app/api/v1/work-orders/[id]/materials/route.ts +++ b/src/app/api/v1/work-orders/[id]/materials/route.ts @@ -1,29 +1,21 @@ -import { NextResponse, type NextRequest } from "next/server"; import type { MaterialPlanInput } from "@/lib/work-orders/schemas"; +import { requireApiContext } from "@/server/api/context"; +import { json, readJsonObject, withApi } from "@/server/api/respond"; import { addMaterialPlan, getMaterialOverview } from "@/server/services/work-orders/materials"; -import { apiContext, apiError, readJson } from "../../_http"; type Params = { params: Promise<{ id: string }> }; /** GET /api/v1/work-orders/[id]/materials — planned vs. actual incl. deviations. */ -export async function GET(_req: NextRequest, { params }: Params) { - try { - const { id } = await params; - const ctx = await apiContext(); - return NextResponse.json({ items: await getMaterialOverview(ctx, id) }); - } catch (err) { - return apiError(err); - } -} +export const GET = withApi(async (_req: Request, { params }: Params) => { + const ctx = await requireApiContext("work_orders"); + const { id } = await params; + return json({ items: await getMaterialOverview(ctx, id) }); +}); /** POST /api/v1/work-orders/[id]/materials — add a material plan item { name, articleNumber?, plannedQuantity, unit, notes? }. */ -export async function POST(req: NextRequest, { params }: Params) { - try { - const { id } = await params; - const ctx = await apiContext("work_order:write"); - const plan = await addMaterialPlan(ctx, id, (await readJson(req)) as MaterialPlanInput); - return NextResponse.json({ ...plan, plannedQuantity: Number(plan.plannedQuantity) }, { status: 201 }); - } catch (err) { - return apiError(err); - } -} +export const POST = withApi(async (req: Request, { params }: Params) => { + const ctx = await requireApiContext("work_orders", "work_order:write"); + const { id } = await params; + const plan = await addMaterialPlan(ctx, id, (await readJsonObject(req)) as MaterialPlanInput); + return json({ ...plan, plannedQuantity: Number(plan.plannedQuantity) }, { status: 201 }); +}); diff --git a/src/app/api/v1/work-orders/[id]/route.ts b/src/app/api/v1/work-orders/[id]/route.ts index dd77e41..c982888 100644 --- a/src/app/api/v1/work-orders/[id]/route.ts +++ b/src/app/api/v1/work-orders/[id]/route.ts @@ -1,34 +1,25 @@ -import { NextResponse, type NextRequest } from "next/server"; import type { UpdateWorkOrderInput } from "@/lib/work-orders/schemas"; +import { requireApiContext } from "@/server/api/context"; +import { json, optionalVersion, readJsonObject, withApi } from "@/server/api/respond"; import { computeCompletionBlockers } from "@/server/services/work-orders/completion"; import { availableTransitions, getWorkOrderDetail } from "@/server/services/work-orders/detail"; import { updateWorkOrder } from "@/server/services/work-orders/update"; -import { apiContext, apiError, optionalVersion, readJson } from "../_http"; type Params = { params: Promise<{ id: string }> }; /** GET /api/v1/work-orders/[id] — detail incl. transitions available to the caller and completion blockers. */ -export async function GET(_req: NextRequest, { params }: Params) { - try { - const { id } = await params; - const ctx = await apiContext(); - const wo = await getWorkOrderDetail(ctx, id); - const [blockers] = await Promise.all([computeCompletionBlockers(ctx, id)]); - return NextResponse.json({ workOrder: wo, availableTransitions: availableTransitions(ctx, wo.status), completionBlockers: blockers }); - } catch (err) { - return apiError(err); - } -} +export const GET = withApi(async (_req: Request, { params }: Params) => { + const ctx = await requireApiContext("work_orders"); + const { id } = await params; + const wo = await getWorkOrderDetail(ctx, id); + const blockers = await computeCompletionBlockers(ctx, id); + return json({ workOrder: wo, availableTransitions: availableTransitions(ctx, wo.status), completionBlockers: blockers }); +}); /** PATCH /api/v1/work-orders/[id] — body: partial master data + optional baseVersion (409 on mismatch). */ -export async function PATCH(req: NextRequest, { params }: Params) { - try { - const { id } = await params; - const ctx = await apiContext(); - const { baseVersion, ...patch } = await readJson(req); - const res = await updateWorkOrder(ctx, id, patch as UpdateWorkOrderInput, optionalVersion(baseVersion)); - return NextResponse.json(res); - } catch (err) { - return apiError(err); - } -} +export const PATCH = withApi(async (req: Request, { params }: Params) => { + const ctx = await requireApiContext("work_orders"); + const { id } = await params; + const { baseVersion, ...patch } = await readJsonObject(req); + return json(await updateWorkOrder(ctx, id, patch as UpdateWorkOrderInput, optionalVersion(baseVersion))); +}); diff --git a/src/app/api/v1/work-orders/[id]/transition/route.ts b/src/app/api/v1/work-orders/[id]/transition/route.ts index ab0fdf8..038ff70 100644 --- a/src/app/api/v1/work-orders/[id]/transition/route.ts +++ b/src/app/api/v1/work-orders/[id]/transition/route.ts @@ -1,24 +1,20 @@ -import { NextResponse, type NextRequest } from "next/server"; +import { requireApiContext } from "@/server/api/context"; +import { json, optionalVersion, readJsonObject, withApi } from "@/server/api/respond"; import { transitionWorkOrder } from "@/server/services/work-orders/transition"; -import { apiContext, apiError, optionalVersion, readJson } from "../../_http"; /** * POST /api/v1/work-orders/[id]/transition — body { to, reason?, baseVersion? }. * 403 forbidden · 404 not in scope · 409 version conflict · 422 invalid / blocked (details: CompletionBlocker[]). */ -export async function POST(req: NextRequest, { params }: { params: Promise<{ id: string }> }) { - try { - const { id } = await params; - const ctx = await apiContext(); - const body = await readJson(req); - const res = await transitionWorkOrder(ctx, { - workOrderId: id, - to: String(body.to ?? "") as never, - reason: typeof body.reason === "string" ? body.reason : null, - baseVersion: optionalVersion(body.baseVersion), - }); - return NextResponse.json(res); - } catch (err) { - return apiError(err); - } -} +export const POST = withApi(async (req: Request, { params }: { params: Promise<{ id: string }> }) => { + const ctx = await requireApiContext("work_orders"); + const { id } = await params; + const body = await readJsonObject(req); + const res = await transitionWorkOrder(ctx, { + workOrderId: id, + to: String(body.to ?? "") as never, + reason: typeof body.reason === "string" ? body.reason : null, + baseVersion: optionalVersion(body.baseVersion), + }); + return json(res); +}); diff --git a/src/app/api/v1/work-orders/_http.ts b/src/app/api/v1/work-orders/_http.ts deleted file mode 100644 index 76f29f3..0000000 --- a/src/app/api/v1/work-orders/_http.ts +++ /dev/null @@ -1,48 +0,0 @@ -import { NextResponse } from "next/server"; -import { moduleGuard } from "@/server/action-guard"; -import { ModuleDisabledError } from "@/server/modules"; -import { ForbiddenError, type Permission } from "@/server/rbac"; -import { ctxFromGuard, ServiceError, type ServiceCtx } from "@/server/services/context"; - -/** - * /api/v1/work-orders helpers (lane L2). Authentication/authorisation reuses the DB-authoritative - * moduleGuard (session cookie); the fundament's generic `requireApiContext` does not exist yet — - * see docs/craftvia/lanes/auftraege.md. - */ -export async function apiContext(...permissions: Permission[]): Promise { - const g = await moduleGuard("work_orders")(...permissions); - return ctxFromGuard(g); -} - -const STATUS: Record = { - not_found: 404, - forbidden: 403, - invalid: 422, - conflict: 409, - blocked: 422, -}; - -export function apiError(err: unknown): NextResponse { - if (err instanceof ServiceError) { - return NextResponse.json({ error: { code: err.code, message: err.message, details: err.details ?? null } }, { status: STATUS[err.code] }); - } - if (err instanceof ForbiddenError) return NextResponse.json({ error: { code: "forbidden", message: "forbidden" } }, { status: 403 }); - if (err instanceof ModuleDisabledError) return NextResponse.json({ error: { code: "forbidden", message: "module_disabled" } }, { status: 403 }); - if (err instanceof SyntaxError) return NextResponse.json({ error: { code: "invalid", message: "invalid_json" } }, { status: 400 }); - const msg = err instanceof Error ? err.message : ""; - if (/Nicht angemeldet|nicht aktiv|nicht mehr gueltig|Passwortwechsel/.test(msg)) { - return NextResponse.json({ error: { code: "unauthorized", message: "unauthorized" } }, { status: 401 }); - } - console.error("[api/v1/work-orders]", err); - return NextResponse.json({ error: { code: "internal", message: "internal" } }, { status: 500 }); -} - -export async function readJson(req: Request): Promise> { - const body = (await req.json()) as unknown; - if (!body || typeof body !== "object" || Array.isArray(body)) throw new ServiceError("invalid", "body_must_be_object"); - return body as Record; -} - -export function optionalVersion(v: unknown): number | undefined { - return typeof v === "number" && Number.isInteger(v) && v > 0 ? v : undefined; -} diff --git a/src/app/api/v1/work-orders/import/route.ts b/src/app/api/v1/work-orders/import/route.ts index 7f63506..34e97dd 100644 --- a/src/app/api/v1/work-orders/import/route.ts +++ b/src/app/api/v1/work-orders/import/route.ts @@ -1,30 +1,21 @@ +import { requireApiContext } from "@/server/api/context"; +import { ApiError, json, readFormData, withApi } from "@/server/api/respond"; import { createImport } from "@/server/services/imports/upload"; -import { apiError, importsApiContext } from "../../imports/_context"; /** * POST /api/v1/work-orders/import — multipart upload of an order document (field `file`). * Lane L3 (import). Response 201 `{ id, status }`; the extraction runs in the background. - * Note: bodies > 10 MB need `experimental.proxyClientMaxBodySize` in next.config.ts (see lane report). */ -export async function POST(req: Request) { - try { - const ctx = await importsApiContext("import:write"); - let form: FormData; - try { - form = await req.formData(); - } catch { - return Response.json({ error: "invalid", message: "multipart_required" }, { status: 400 }); - } - const file = form.get("file"); - if (!(file instanceof File)) return Response.json({ error: "invalid", message: "file_missing" }, { status: 400 }); - const job = await createImport(ctx, { - bytes: Buffer.from(await file.arrayBuffer()), - fileName: file.name, - mimeType: file.type, - }); - const current = await ctx.db.importJob.findFirst({ where: { id: job.id }, select: { id: true, status: true } }); - return Response.json(current ?? { id: job.id, status: job.status }, { status: 201 }); - } catch (err) { - return apiError(err); - } -} +export const POST = withApi(async (req: Request) => { + const ctx = await requireApiContext("imports", "import:write"); + const form = await readFormData(req); + const file = form.get("file"); + if (!(file instanceof File)) throw new ApiError("invalid", "file_missing"); + const job = await createImport(ctx, { + bytes: Buffer.from(await file.arrayBuffer()), + fileName: file.name, + mimeType: file.type, + }); + const current = await ctx.db.importJob.findFirst({ where: { id: job.id }, select: { id: true, status: true } }); + return json(current ?? { id: job.id, status: job.status }, { status: 201 }); +}); diff --git a/src/app/api/v1/work-orders/route.ts b/src/app/api/v1/work-orders/route.ts index 9d580f3..8e351dd 100644 --- a/src/app/api/v1/work-orders/route.ts +++ b/src/app/api/v1/work-orders/route.ts @@ -1,30 +1,22 @@ -import { NextResponse, type NextRequest } from "next/server"; import { parseListParams } from "@/lib/work-orders/filters"; import type { CreateWorkOrderInput } from "@/lib/work-orders/schemas"; +import { requireApiContext } from "@/server/api/context"; +import { json, readJsonObject, withApi } from "@/server/api/respond"; import { createWorkOrder } from "@/server/services/work-orders/create"; import { listWorkOrders } from "@/server/services/work-orders/list"; -import { apiContext, apiError, readJson } from "./_http"; /** GET /api/v1/work-orders — filters as in the backoffice list (§21), always within workOrderScope. */ -export async function GET(req: NextRequest) { - try { - const ctx = await apiContext(); - return NextResponse.json(await listWorkOrders(ctx, parseListParams(req.nextUrl.searchParams))); - } catch (err) { - return apiError(err); - } -} +export const GET = withApi(async (req: Request) => { + const ctx = await requireApiContext("work_orders"); + return json(await listWorkOrders(ctx, parseListParams(new URL(req.url).searchParams))); +}); -/** POST /api/v1/work-orders — body: CreateWorkOrderInput (dates as ISO strings). */ -export async function POST(req: NextRequest) { - try { - const ctx = await apiContext(); - const body = await readJson(req); - // Import linkage and number keys are reserved for the import/emergency services. - delete body.sourceImportId; - const created = await createWorkOrder(ctx, body as CreateWorkOrderInput); - return NextResponse.json(created, { status: 201 }); - } catch (err) { - return apiError(err); - } -} +/** POST /api/v1/work-orders — body: CreateWorkOrderInput (dates as ISO strings). Rights are checked in the service. */ +export const POST = withApi(async (req: Request) => { + const ctx = await requireApiContext("work_orders"); + const body = await readJsonObject(req); + // Import linkage and number keys are reserved for the import/emergency services. + delete body.sourceImportId; + const created = await createWorkOrder(ctx, body as CreateWorkOrderInput); + return json(created, { status: 201 }); +}); diff --git a/src/components/backoffice-frame.tsx b/src/components/backoffice-frame.tsx new file mode 100644 index 0000000..d3473fd --- /dev/null +++ b/src/components/backoffice-frame.tsx @@ -0,0 +1,77 @@ +"use client"; + +import { useState } from "react"; +import { Menu, X } from "lucide-react"; +import { cn } from "@/lib/utils"; + +/** + * Backoffice shell (L10b, L1 offener Punkt 10): below 1024 px the sidebar is collapsed behind a + * menu button and opens as an overlay drawer, so backoffice pages are usable on tablets and + * phones. From 1024 px the sidebar is static as before. The drawer closes on navigation (link + * click), on the backdrop, the close button and Escape. Closed drawers are `invisible` below + * 1024 px, so their links leave the tab order. + */ +export function BackofficeFrame({ + sidebar, + header, + children, + labels, +}: { + sidebar: React.ReactNode; + header: React.ReactNode; + children: React.ReactNode; + labels: { open: string; close: string }; +}) { + const [open, setOpen] = useState(false); + + const closeOnLink = (e: React.MouseEvent) => { + if ((e.target as HTMLElement).closest("a")) setOpen(false); + }; + const closeOnEscape = (e: React.KeyboardEvent) => { + if (e.key === "Escape") setOpen(false); + }; + + return ( +
+ {open &&
setOpen(false)} />} + + +
+
+ + {header} +
+ {children} +
+
+ ); +} diff --git a/src/components/imports/uploader.tsx b/src/components/imports/uploader.tsx index 5559291..b190225 100644 --- a/src/components/imports/uploader.tsx +++ b/src/components/imports/uploader.tsx @@ -43,12 +43,14 @@ export function ImportUploader() { if (e.lengthComputable) setState({ kind: "uploading", percent: Math.round((e.loaded / e.total) * 100), name: file.name }); }; xhr.onload = () => { - const res = (xhr.response ?? {}) as { id?: string; error?: string; message?: string }; + // Unified /api/v1 error format: { error: { code, message, details? } } + const res = (xhr.response ?? {}) as { id?: string; error?: { code?: string; message?: string } }; if (xhr.status === 201 && res.id) { setState({ kind: "done", name: file.name, id: res.id }); router.refresh(); } else { - const code = res.message && KNOWN_ERRORS.has(res.message) ? res.message : res.error && KNOWN_ERRORS.has(res.error) ? res.error : "error"; + const { code: errCode, message } = res.error ?? {}; + const code = message && KNOWN_ERRORS.has(message) ? message : errCode && KNOWN_ERRORS.has(errCode) ? errCode : "error"; setState({ kind: "error", code }); } if (inputRef.current) inputRef.current.value = ""; diff --git a/src/components/reports/mobile/report-editor.tsx b/src/components/reports/mobile/report-editor.tsx index 9457e9c..d14f015 100644 --- a/src/components/reports/mobile/report-editor.tsx +++ b/src/components/reports/mobile/report-editor.tsx @@ -3,7 +3,7 @@ import { useRouter } from "next/navigation"; import { useActionState, useEffect, useState } from "react"; import { useTranslations } from "next-intl"; -import { ArrowRight, Save, Send } from "lucide-react"; +import { ArrowRight, History, Save, Send } from "lucide-react"; import { Button } from "@/components/ui/button"; import { Label } from "@/components/ui/label"; import { Textarea } from "@/components/ui/textarea"; @@ -12,30 +12,59 @@ import { REPORT_REQUIRED_TEXTS, REPORT_TEXT_FIELDS, TEXT_MAX, type ReportTexts, import { saveReportTextsAction, submitReportAction } from "@/server/actions/reports/workflow"; import { ActionMessage } from "../action-message"; import { LotseReviewConfirm } from "@/components/lotse/review-confirm"; +import { useOfflineDraft } from "@/components/offline/hooks"; + +/** Local draft (IndexedDB, L7) + the server texts it was based on. */ +type ReportDraft = { base: ReportTexts; texts: ReportTexts }; + +const sameTexts = (a: ReportTexts | undefined, b: ReportTexts) => !!a && REPORT_TEXT_FIELDS.every((f) => (a[f] ?? "") === (b[f] ?? "")); /** * Mobile report editor: technician checks/extends the prefilled texts. * Daily report: save or submit directly (signature optional). Completion: save and continue to signature. + * + * L10b (L7 offener Punkt 7): unsaved input is kept as an offline draft (`useOfflineDraft`, per + * tenant + user, removed on logout). A draft is only restored while the server texts are still the + * ones it was based on — if the report changed meanwhile (saved on another device, Lotse suggestion + * taken over) the server version wins and the stale draft is discarded. */ export function ReportEditor({ reportId, type, texts, signHref, aiDrafted = false }: { reportId: string; type: ReportType; texts: ReportTexts; signHref?: string; aiDrafted?: boolean }) { const t = useTranslations("reports"); + const tOffline = useTranslations("offline"); const router = useRouter(); const [intent, setIntent] = useState<"save" | "sign">("save"); const [saveState, save, saving] = useActionState(saveReportTextsAction, IDLE); const [submitState, submit, submitting] = useActionState(submitReportAction, IDLE); const required = new Set(REPORT_REQUIRED_TEXTS[type]); + const [draft, setDraft, clearDraft, restored] = useOfflineDraft(`report:${reportId}`, { base: texts, texts }); + const stale = !sameTexts(draft.base, texts); + const values = stale ? texts : draft.texts; + useEffect(() => { + if (restored && stale) void clearDraft(); + }, [restored, stale, clearDraft]); + useEffect(() => { + if (saveState.status === "ok") void clearDraft(); if (saveState.status === "ok" && intent === "sign" && signHref) router.push(signHref); - }, [saveState, intent, router, signHref]); + }, [saveState, intent, router, signHref, clearDraft]); useEffect(() => { - if (submitState.status === "ok") router.refresh(); - }, [submitState, router]); + if (submitState.status === "ok") { + void clearDraft(); + router.refresh(); + } + }, [submitState, router, clearDraft]); return (

{t("mobile.edit")}

+ {restored && !stale && ( +

+ + {tOffline("view.draftRestored")} +

+ )} {REPORT_TEXT_FIELDS.map((f) => (