Merge lane/betrieb in feature/craftvia-mvp

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-14 18:26:36 +02:00
co-authored by Claude Opus 5
118 changed files with 3985 additions and 698 deletions
+46 -8
View File
@@ -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 '<pw>';), 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
+39
View File
@@ -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
+95 -38
View File
@@ -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 '<stark>';
# (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 '<stark>';
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://:<pw>@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=
+59 -7
View File
@@ -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
+60 -6
View File
@@ -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
+5 -1
View File
@@ -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"]
+4 -3
View File
@@ -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/`.
+1 -1
View File
@@ -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
+104 -4
View File
@@ -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).
+106 -3
View File
@@ -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).
+41
View File
@@ -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.
+94
View File
@@ -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=<ISO>`, 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 |
+335
View File
@@ -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 <postgres-container> psql -U craftvia -d craftvia \
-c "ALTER ROLE craftvia_app WITH LOGIN PASSWORD '<STARKES_PASSWORT>';"
```
2. Env setzen:
`RLS_ENFORCED=true`,
`RLS_DATABASE_URL=postgresql://craftvia_app:<STARKES_PASSWORT>@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 <queue>` bzw.
`[worker] <queue> job <id> 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-host>
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/<documentId>` (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 <postgres-container> pg_dump -U craftvia -d craftvia -Fc > craftvia-$(date +%F).dump
# Restore in leere DB (App + Worker gestoppt):
docker exec -i <postgres-container> 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
+97
View File
@@ -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:<reportId>")`; 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 |
+2 -1
View File
@@ -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",
+7 -1
View File
@@ -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",
+3 -1
View File
@@ -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"
}
+1
View File
@@ -147,6 +147,7 @@
"logout": "Abmeldung",
"denied": "Abgelehnt",
"export": "Export",
"read": "Lesezugriff",
"import": "Import",
"provision": "Eingerichtet",
"approve": "Freigegeben",
+1 -1
View File
@@ -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.",
+2 -1
View File
@@ -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",
+7 -1
View File
@@ -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",
+3 -1
View File
@@ -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"
}
+1
View File
@@ -147,6 +147,7 @@
"logout": "Sign-out",
"denied": "Denied",
"export": "Export",
"read": "Read access",
"import": "Import",
"provision": "Provisioned",
"approve": "Approved",
+1 -1
View File
@@ -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.",
@@ -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");
@@ -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;
+18 -8
View File
@@ -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")
}
+1 -1
View File
@@ -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.
+3 -1
View File
@@ -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
+6 -1
View File
@@ -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();
+1 -1
View File
@@ -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",
+141
View File
@@ -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<string> {
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<Res> {
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<string, unknown> } | 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/<unbekannt> → 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/<unbekannt>/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;
+1 -1
View File
@@ -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;
+178
View File
@@ -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<Record<string, string>> }) => Promise<Response>;
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<string>(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<string, Record<string, unknown>> };
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<Response> }).GET();
const spec = (await specRes.json()) as { paths?: Record<string, unknown> };
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<string, unknown>;
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<string, string> = { 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<string, number> = { 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<unknown>) => 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<typeof ApiError>;
}
};
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);
});
+209
View File
@@ -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);
});
+158
View File
@@ -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<string, unknown>, extra: Partial<SyncOperationInput> = {}): 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);
});
+3 -2
View File
@@ -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 —");
+1 -1
View File
@@ -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()`:
+15 -12
View File
@@ -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 (
<div className="flex min-h-screen">
<aside className="flex w-60 shrink-0 flex-col border-r border-sidebar-border bg-sidebar">
const sidebar = (
<>
<div className="border-b border-sidebar-border px-4 pt-5 pb-3.5">
<Link href="/dashboard" aria-label={branding.productName}>
<TenantBrand branding={branding} height={34} />
@@ -84,15 +84,18 @@ export default async function AppLayout({
</div>
)}
</nav>
</aside>
</>
);
<div className="flex min-w-0 flex-1 flex-col">
<header className="sticky top-0 z-10 flex items-center gap-4 border-b bg-[var(--panel)] px-6 py-2.5 backdrop-blur-md">
<form action="/search" role="search" className="flex-1"><input type="search" name="q" aria-label={tc("search")} placeholder={tc("search")} className="h-10 w-full max-w-md rounded-lg border border-input bg-background px-3 text-sm" /></form>
// L10b: collapsible sidebar below 1024 px (src/components/backoffice-frame.tsx)
return (
<BackofficeFrame sidebar={sidebar} labels={{ open: t("openMenu"), close: t("closeMenu") }} header={
<>
<form action="/search" role="search" className="min-w-0 flex-1"><input type="search" name="q" aria-label={tc("search")} placeholder={tc("search")} className="h-10 w-full max-w-md rounded-lg border border-input bg-background px-3 text-sm" /></form>
<NotificationBell />
<UiLocaleSwitcher current={identity.uiLocale} />
<Link href="/account" className="flex items-center gap-3">
<div className="text-right leading-tight">
<div className="hidden text-right leading-tight sm:block">
<p className="text-[13px] font-semibold">{session.user.name}</p>
<p className="text-xs text-muted-foreground">{session.user.tenantSlug}</p>
</div>
@@ -105,9 +108,9 @@ export default async function AppLayout({
{tc("logout")}
</Button>
</form>
</header>
{children}
</div>
</div>
</>
}>
{children}
</BackofficeFrame>
);
}
+33 -2
View File
@@ -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 (
<main className="flex-1 p-4 md:p-6">
@@ -72,6 +73,36 @@ export default async function LotseSettingsPage({ searchParams }: { searchParams
))}
</div>
</fieldset>
<div>
<label htmlFor="monthlyTokenLimit" className="text-sm font-semibold">
{t("settings.budget")}
</label>
<p className="text-[12px] text-muted-foreground">
{t("settings.budgetHint", { platform: s.budget.platformLimit > 0 ? nf.format(s.budget.platformLimit) : t("settings.budgetUnlimited") })}
</p>
<input
id="monthlyTokenLimit"
name="monthlyTokenLimit"
type="number"
inputMode="numeric"
min={0}
step={1000}
defaultValue={s.budget.tenantLimit ?? ""}
className="mt-2 h-11 w-full max-w-60 rounded-lg border border-input bg-background px-3 text-sm"
/>
<p className="mt-2 flex flex-wrap items-center gap-2 text-[12.5px] text-muted-foreground">
{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 && (
<span className="inline-flex items-center gap-1 font-semibold text-[var(--risk)]">
<XCircle className="size-4" aria-hidden /> {t("settings.budgetExceeded")}
</span>
)}
</p>
</div>
<Button type="submit" className="min-h-11">
{t("settings.save")}
</Button>
+9 -11
View File
@@ -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=<ISO> — 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" } });
});
+16 -17
View File
@@ -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/<id>[?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 });
});
+7 -16
View File
@@ -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)));
});
+7 -10
View File
@@ -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));
});
-36
View File
@@ -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<ServiceCtx> {
return ctxFromGuard(await guard(...permissions));
}
const STATUS: Record<ServiceError["code"], number> = { 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 });
}
+12
View File
@@ -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" } });
}
+9 -8
View File
@@ -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) });
});
@@ -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 });
});
+6 -4
View File
@@ -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 });
});
+10 -10
View File
@@ -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));
});
+21 -24
View File
@@ -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 });
});
+15 -19
View File
@@ -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);
});
@@ -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<typeof createCompletionReport>[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<typeof createCompletionReport>[1]);
return json({ report: reportDto(res.report), created: res.created }, { status: res.created ? 201 : 200 });
});
@@ -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<typeof createDailyReport>[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<typeof createDailyReport>[1]);
return json({ report: reportDto(res.report), created: res.created }, { status: res.created ? 201 : 200 });
});
@@ -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);
}
}
@@ -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 });
});
+15 -24
View File
@@ -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)));
});
@@ -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);
});
-48
View File
@@ -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<ServiceCtx> {
const g = await moduleGuard("work_orders")(...permissions);
return ctxFromGuard(g);
}
const STATUS: Record<ServiceError["code"], number> = {
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<Record<string, unknown>> {
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<string, unknown>;
}
export function optionalVersion(v: unknown): number | undefined {
return typeof v === "number" && Number.isInteger(v) && v > 0 ? v : undefined;
}
+15 -24
View File
@@ -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 });
});
+15 -23
View File
@@ -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 });
});
+77
View File
@@ -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 (
<div className="flex min-h-screen" onKeyDown={closeOnEscape}>
{open && <div aria-hidden className="fixed inset-0 z-30 bg-foreground/30 lg:hidden" onClick={() => setOpen(false)} />}
<aside
id="backoffice-sidebar"
onClick={closeOnLink}
className={cn(
"fixed inset-y-0 left-0 z-40 flex w-72 max-w-[85vw] shrink-0 flex-col border-r border-sidebar-border bg-sidebar shadow-lg transition-transform duration-200",
"lg:sticky lg:top-0 lg:h-screen lg:w-60 lg:translate-x-0 lg:shadow-none",
open ? "translate-x-0" : "max-lg:invisible -translate-x-full",
)}
>
<div className="flex justify-end px-2 pt-2 lg:hidden">
<button
type="button"
onClick={() => setOpen(false)}
aria-label={labels.close}
className="grid size-11 place-items-center rounded-lg text-sidebar-foreground hover:bg-secondary"
>
<X className="size-5" aria-hidden />
</button>
</div>
{sidebar}
</aside>
<div className="flex min-w-0 flex-1 flex-col">
<header className="sticky top-0 z-10 flex items-center gap-2 border-b bg-[var(--panel)] px-3 py-2.5 backdrop-blur-md sm:gap-4 md:px-6">
<button
type="button"
onClick={() => setOpen(true)}
aria-label={labels.open}
aria-controls="backoffice-sidebar"
aria-expanded={open}
className="grid size-11 shrink-0 place-items-center rounded-lg hover:bg-secondary lg:hidden"
>
<Menu className="size-5" aria-hidden />
</button>
{header}
</header>
{children}
</div>
</div>
);
}
+4 -2
View File
@@ -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 = "";
@@ -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<string>(REPORT_REQUIRED_TEXTS[type]);
const [draft, setDraft, clearDraft, restored] = useOfflineDraft<ReportDraft>(`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 (
<form action={save} className="shadow-card space-y-4 rounded-xl border bg-card p-4">
<input type="hidden" name="reportId" value={reportId} />
<h2 className="font-heading text-[15px] font-semibold">{t("mobile.edit")}</h2>
{restored && !stale && (
<p role="status" className="flex items-center gap-2 rounded-lg bg-muted px-3 py-2 text-[13px]">
<History className="size-4 shrink-0" aria-hidden />
{tOffline("view.draftRestored")}
</p>
)}
{REPORT_TEXT_FIELDS.map((f) => (
<div key={f}>
<Label htmlFor={`rt-${f}`} className="text-[13px]">
@@ -45,7 +74,8 @@ export function ReportEditor({ reportId, type, texts, signHref, aiDrafted = fals
<Textarea
id={`rt-${f}`}
name={f}
defaultValue={texts[f]}
value={values[f] ?? ""}
onChange={(e) => setDraft({ base: texts, texts: { ...values, [f]: e.target.value } })}
maxLength={TEXT_MAX}
rows={f === "workPerformed" ? 5 : 2}
className="mt-1 min-h-12 text-base"
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -40,7 +40,7 @@ export function uploadFieldFile(opts: {
}
return;
}
const error = xhr.status === 400 || xhr.status === 413 ? "invalid" : xhr.status === 403 || xhr.status === 401 ? "forbidden" : xhr.status === 404 ? "not_found" : "internal";
const error = xhr.status === 400 || xhr.status === 413 || xhr.status === 422 ? "invalid" : xhr.status === 403 || xhr.status === 401 ? "forbidden" : xhr.status === 404 ? "not_found" : "internal";
resolve({ ok: false, error });
};
xhr.send(form);
+3 -1
View File
@@ -199,7 +199,9 @@ export type PlausibilityHintCode =
| "email_invalid"
| "phone_invalid"
| "end_before_start"
| "manual_entry";
| "manual_entry"
/** L10b: monthly AI token budget of the tenant used up → manual entry */
| "ai_budget_exceeded";
export type PlausibilityHint = { field: ExtractionFieldKey | null; code: PlausibilityHintCode };
+1
View File
@@ -12,6 +12,7 @@ export const LOTSE_ERROR_CODES = [
"pending",
"conflict",
"invalid",
"budget_exceeded",
] as const;
export type LotseActionErrorCode = (typeof LOTSE_ERROR_CODES)[number];
+9 -3
View File
@@ -64,7 +64,7 @@ const str = (v: unknown): string | null => (typeof v === "string" ? v : null);
/** Server snapshot + own ops (pending, or applied but not yet contained in the snapshot). */
export function buildOrderView(record: BundleRecord, ops: OutboxEntry[]): OrderView {
const data: BundleOrderData = structuredCloneSafe(record.data);
const view: OrderView = { ...data, local: { notes: [], photos: [], voiceNotes: 0, session: initialSession(data.status), pendingOps: 0, conflict: false, rejected: false } };
const view: OrderView = { ...data, local: { notes: [], photos: [], voiceNotes: 0, session: initialSession(data), pendingOps: 0, conflict: false, rejected: false } };
const mine = ops.filter((o) => o.workOrderId === data.id).sort((a, b) => a.seq - b.seq);
for (const op of mine) {
@@ -79,8 +79,14 @@ export function buildOrderView(record: BundleRecord, ops: OutboxEntry[]): OrderV
return view;
}
function initialSession(status: string): SessionState {
// The bundle carries no sessions; the order status is the best local approximation.
export function initialSession(data: Pick<BundleOrderData, "status" | "mySession">): SessionState {
// L10b: bundles carry the caller's own active session — exact also on team orders.
if (data.mySession !== undefined) {
const s = data.mySession?.status;
return s === "en_route" || s === "running" || s === "paused" ? s : null;
}
// Bundles stored before L10b: the order status is the best local approximation.
const status = data.status;
if (status === "en_route") return "en_route";
if (status === "in_progress") return "running";
if (status === "paused") return "paused";
+2 -1
View File
@@ -141,7 +141,8 @@ const browserTransport: Transport = {
return;
}
if (xhr.status === 401) return resolve({ ok: false, error: "unauthorized" });
if (xhr.status === 400 || xhr.status === 413) return resolve({ ok: false, error: "invalid" });
// 422 = unified /api/v1 validation error; 429 (rate limit) stays transient → backoff
if (xhr.status === 400 || xhr.status === 413 || xhr.status === 422) return resolve({ ok: false, error: "invalid" });
if (xhr.status === 403) return resolve({ ok: false, error: "forbidden" });
if (xhr.status === 404) return resolve({ ok: false, error: "not_found" });
resolve({ ok: false, error: xhr.status === 0 ? "network" : "internal" });
+2
View File
@@ -92,6 +92,8 @@ export type BundleOrderData = {
scope?: string | null;
technicianNotes?: string | null;
signatureRequired?: boolean;
/** L10b: own active work session of the signed-in user (absent in bundles stored before L10b) */
mySession?: { id: string; status: string; startedAt: string } | null;
orderType?: { name: string } | null;
customer: {
id?: string;
+22 -3
View File
@@ -2,6 +2,7 @@ import { z } from "zod";
import type { SyncOpType } from "./envelope";
import { WORK_ORDER_STATUSES } from "@/lib/work-orders/status";
import { emergencyCreatePayload } from "@/lib/emergency/schemas";
import { reportTextsSchema } from "@/lib/reports/content";
/**
* Payload schemas per sync opType (ARCHITEKTUR §4.6). Client-safe: used by the mobile UI to
@@ -113,7 +114,25 @@ export const voiceAttachPayload = z.object({
kind: z.enum(NOTE_KINDS).optional(),
});
/** Schemas of ops owned by other lanes are validated there (reports: L5, emergency: L8). */
/**
* report.save_draft / report.submit (L10b, registered in services/sync/external-ops.ts →
* services/reports/sync-ops.ts). `workOrderId` is required: the sync pipeline checks scope and the
* conflict version (`baseVersion` = WorkOrder.version seen by the device) on that order.
*/
export const reportSaveDraftPayload = z.object({
workOrderId: id,
reportId: id,
texts: reportTextsSchema.partial(),
});
export const reportSubmitPayload = z.object({
workOrderId: id,
reportId: id,
/** L9 Freigabeprinzip: "Ich habe den Vorschlag vom Lotsen geprüft" — mandatory for Lotse drafts */
aiReviewed: z.boolean().optional(),
});
/** Schemas of ops owned by other lanes are validated there (signature.capture: not offline-capable yet). */
const passthrough = z.record(z.string(), z.unknown());
export const OP_PAYLOAD_SCHEMAS = {
@@ -127,8 +146,8 @@ export const OP_PAYLOAD_SCHEMAS = {
"material.upsert": materialUpsertPayload,
"photo.attach": photoAttachPayload,
"voice.attach": voiceAttachPayload,
"report.save_draft": passthrough,
"report.submit": passthrough,
"report.save_draft": reportSaveDraftPayload,
"report.submit": reportSubmitPayload,
"signature.capture": passthrough,
"emergency.create": emergencyCreatePayload,
} satisfies Record<SyncOpType, z.ZodType>;
+3
View File
@@ -19,9 +19,12 @@ export async function saveLotseSettings(fd: FormData): Promise<void> {
try {
requirePermission(session, "tenant:manage"); // fast JWT check; requireApiContext re-checks against the DB
const ctx = await requireApiContext(null, "tenant:manage");
// L10b: empty = platform default (null); invalid numbers are rejected by the service schema
const rawLimit = String(fd.get("monthlyTokenLimit") ?? "").trim();
await updateLotseSettings(ctx, {
enabled: fd.get("enabled") === "on",
addressForm: (["sie", "du"].includes(String(fd.get("addressForm"))) ? String(fd.get("addressForm")) : "neutral") as "sie" | "du" | "neutral",
monthlyTokenLimit: rawLimit === "" ? null : Number(rawLimit),
});
revalidatePath("/settings/lotse");
revalidatePath("/", "layout");
+16 -15
View File
@@ -7,6 +7,11 @@ import type { Permission } from "@/server/rbac";
import type { ModuleKey } from "@/lib/modules";
import type { ServiceCtx } from "@/server/services/context";
import { ApiError } from "@/server/api/respond";
import { consumeRateLimit } from "@/server/rate-limit";
// assertSameOrigin lives in respond.ts (withApi applies it to every mutation); re-exported for
// route handlers that do not use withApi (e.g. /documents/upload with HTML redirects).
export { assertSameOrigin } from "@/server/api/respond";
/**
* Service context for /api/v1 route handlers and other route handlers (e.g. /files/<id>).
@@ -57,27 +62,23 @@ export async function requireApiContext(moduleKey: ModuleKey | null, ...permissi
}
}
if (moduleKey) await assertModuleEnabled(session, moduleKey); // throws ModuleDisabledError → 403
if (moduleKey) enforceApiRateLimit(session.user.id, moduleKey);
return { db, tenantId, userId: session.user.id, permissions: effective };
}
/**
* CSRF defense for cookie-authenticated, state-changing route handlers: reject requests whose
* Origin (or Sec-Fetch-Site) shows a foreign site. Server actions have this built in.
* Per-user request budget for /api/v1 (in-memory, per app instance — see rate-limit.ts).
* Field endpoints (sync outbox, uploads, offline pre-download, document cache) get the generous
* `apiField` bucket, everything else `api`. `moduleKey = null` callers (/files downloads, the
* EXEMPT lotse-settings action) are not /api/v1 endpoints and are not counted.
* Exceeded → ApiError `rate_limited` (429 + Retry-After).
*/
export function assertSameOrigin(req: Request): void {
const site = req.headers.get("sec-fetch-site");
if (site && site !== "same-origin" && site !== "none") throw new ApiError("forbidden", "cross-site request");
const origin = req.headers.get("origin");
if (origin) {
const host = req.headers.get("x-forwarded-host") ?? req.headers.get("host");
let originHost: string | null = null;
try {
originHost = new URL(origin).host;
} catch {
originHost = null;
}
if (!host || originHost !== host) throw new ApiError("forbidden", "cross-site request");
export function enforceApiRateLimit(userId: string, moduleKey: ModuleKey): void {
const scope = moduleKey === "field" ? "apiField" : "api";
const res = consumeRateLimit(scope, userId);
if (!res.allowed) {
throw new ApiError("rate_limited", "too many requests", { retryAfterSeconds: res.retryAfterSeconds });
}
}
+84 -11
View File
@@ -4,10 +4,12 @@ import { ForbiddenError } from "@/server/rbac";
import { ModuleDisabledError } from "@/server/modules";
/**
* JSON response helpers for /api/v1 route handlers (spec §29.2).
* JSON response helpers for ALL /api/v1 route handlers (spec §29.2, L10b: single adapter —
* the former lane-local variants in imports/_context.ts, sync/api-context.ts, reports/http.ts and
* work-orders/_http.ts are gone).
* Error format: `{ error: { code, message, details? } }`; list format:
* `{ data: [...], pagination: { page, pageSize, total } }`.
* Internal error details never leave the server (CWE-209).
* Internal error details never leave the server (CWE-209). Documented in docs/craftvia/API.md.
*/
export type ApiErrorCode =
@@ -18,16 +20,19 @@ export type ApiErrorCode =
| "conflict"
| "blocked"
| "payload_too_large"
| "rate_limited"
| "internal";
const STATUS: Record<ApiErrorCode, number> = {
export const API_ERROR_STATUS: Record<ApiErrorCode, number> = {
unauthorized: 401,
forbidden: 403,
not_found: 404,
invalid: 422,
conflict: 409,
blocked: 409,
// domain rule prevents the action (e.g. completion blockers) — request itself is well-formed
blocked: 422,
payload_too_large: 413,
rate_limited: 429,
internal: 500,
};
@@ -42,16 +47,22 @@ export class ApiError extends Error {
}
}
export function errorResponse(code: ApiErrorCode, message: string, details?: unknown): Response {
export function errorResponse(code: ApiErrorCode, message: string, details?: unknown, headers?: Record<string, string>): Response {
return Response.json(
{ error: { code, message, ...(details !== undefined ? { details } : {}) } },
{ status: STATUS[code], headers: { "Cache-Control": "no-store" } },
{ status: API_ERROR_STATUS[code], headers: { "Cache-Control": "no-store", ...headers } },
);
}
/** Map any thrown error to a JSON error response. */
export function toErrorResponse(err: unknown): Response {
if (err instanceof ApiError) return errorResponse(err.code, err.message, err.details);
if (err instanceof ApiError) {
if (err.code === "rate_limited") {
const retry = (err.details as { retryAfterSeconds?: number } | undefined)?.retryAfterSeconds ?? 60;
return errorResponse(err.code, err.message, err.details, { "Retry-After": String(retry) });
}
return errorResponse(err.code, err.message, err.details);
}
if (err instanceof ServiceError) return errorResponse(err.code, err.message, err.details);
if (err instanceof ZodError) {
return errorResponse(
@@ -67,8 +78,8 @@ export function toErrorResponse(err: unknown): Response {
return errorResponse("internal", "internal error");
}
export function json(data: unknown, init?: { status?: number }): Response {
return Response.json(data, { status: init?.status ?? 200, headers: { "Cache-Control": "no-store" } });
export function json(data: unknown, init?: { status?: number; headers?: Record<string, string> }): Response {
return Response.json(data, { status: init?.status ?? 200, headers: { "Cache-Control": "no-store", ...init?.headers } });
}
export function paginated<T>(items: T[], total: number, page: number, pageSize: number): Response {
@@ -83,10 +94,38 @@ export function parsePagination(url: URL | string, defaults = { pageSize: 25 }):
return { page, pageSize };
}
/** Wrap a handler so every thrown error becomes a JSON error response. */
export function withApi<A extends unknown[]>(handler: (...args: A) => Promise<Response>) {
const SAFE_METHODS = new Set(["GET", "HEAD", "OPTIONS"]);
/**
* CSRF defense for cookie-authenticated, state-changing route handlers: reject requests whose
* Origin (or Sec-Fetch-Site) shows a foreign site. Server actions have this built in.
* Requests without Origin/Sec-Fetch-Site (server-to-server clients, curl) pass — they carry no
* ambient browser cookies of a victim.
*/
export function assertSameOrigin(req: Request): void {
const site = req.headers.get("sec-fetch-site");
if (site && site !== "same-origin" && site !== "none") throw new ApiError("forbidden", "cross-site request");
const origin = req.headers.get("origin");
if (origin) {
const host = req.headers.get("x-forwarded-host") ?? req.headers.get("host");
let originHost: string | null = null;
try {
originHost = new URL(origin).host;
} catch {
originHost = null;
}
if (!host || originHost !== host) throw new ApiError("forbidden", "cross-site request");
}
}
/**
* Wrap a handler: same-origin check for every non-safe method (before authentication, so a
* cross-site request never reaches a service) + every thrown error becomes a JSON error response.
*/
export function withApi<A extends [Request, ...unknown[]]>(handler: (...args: A) => Promise<Response>) {
return async (...args: A): Promise<Response> => {
try {
if (!SAFE_METHODS.has(args[0].method.toUpperCase())) assertSameOrigin(args[0]);
return await handler(...args);
} catch (err) {
return toErrorResponse(err);
@@ -102,3 +141,37 @@ export async function readJson(req: Request): Promise<unknown> {
throw new ApiError("invalid", "malformed JSON body");
}
}
/**
* Read a JSON object body. Arrays/primitives → 422. With `allowEmpty` an empty body is `{}`
* (endpoints whose body fields are all optional).
*/
export async function readJsonObject(req: Request, opts: { allowEmpty?: boolean } = {}): Promise<Record<string, unknown>> {
const text = await req.text();
if (!text.trim()) {
if (opts.allowEmpty) return {};
throw new ApiError("invalid", "JSON body required");
}
let body: unknown;
try {
body = JSON.parse(text);
} catch {
throw new ApiError("invalid", "malformed JSON body");
}
if (!body || typeof body !== "object" || Array.isArray(body)) throw new ApiError("invalid", "JSON object expected");
return body as Record<string, unknown>;
}
/** Read a multipart body; anything else → 422. */
export async function readFormData(req: Request): Promise<FormData> {
try {
return await req.formData();
} catch {
throw new ApiError("invalid", "multipart body expected");
}
}
/** Optimistic-locking version from a body field (positive integer), otherwise undefined. */
export function optionalVersion(v: unknown): number | undefined {
return typeof v === "number" && Number.isInteger(v) && v > 0 ? v : undefined;
}
+76 -16
View File
@@ -1,3 +1,4 @@
import { AsyncLocalStorage } from "node:async_hooks";
import { prisma } from "./db";
/**
@@ -6,14 +7,28 @@ import { prisma } from "./db";
* filtered, and tenantId is passed explicitly by the caller.
*/
type AuditAction = "create" | "update" | "delete" | "login" | "logout" | "export" | "import" | "denied";
/** `read` (L10b): sensitive read access that must be traceable (e.g. emergency customer search). */
export type AuditAction = "create" | "update" | "delete" | "login" | "logout" | "export" | "import" | "denied" | "read";
type RequestContext = { ipAddress: string | null; userAgent: string | null };
type AuditEntry = {
tenantId: string;
actorId?: string;
action: AuditAction;
scope?: "tenant" | "platform";
entity: string;
entityId?: string;
before?: unknown;
after?: unknown;
};
/**
* IP address and user agent of the current request, if there is one.
* Outside a request scope (workers, scripts, tests) `headers()` throws → both null.
* Behind the Coolify/Traefik proxy the client IP is the first X-Forwarded-For hop.
*/
async function requestContext(): Promise<{ ipAddress: string | null; userAgent: string | null }> {
async function requestContext(): Promise<RequestContext> {
try {
const { headers } = await import("next/headers");
const h = await headers();
@@ -26,18 +41,8 @@ async function requestContext(): Promise<{ ipAddress: string | null; userAgent:
}
}
export async function writeAuditLog(entry: {
tenantId: string;
actorId?: string;
action: AuditAction;
scope?: "tenant" | "platform";
entity: string;
entityId?: string;
before?: unknown;
after?: unknown;
}) {
const ctx = await requestContext();
await prisma.auditLog.create({
function insertAudit(entry: AuditEntry, request: RequestContext) {
return prisma.auditLog.create({
data: {
tenantId: entry.tenantId,
scope: entry.scope ?? "tenant",
@@ -47,12 +52,67 @@ export async function writeAuditLog(entry: {
entityId: entry.entityId,
before: entry.before as object | undefined,
after: entry.after as object | undefined,
ipAddress: ctx.ipAddress,
userAgent: ctx.userAgent,
ipAddress: request.ipAddress,
userAgent: request.userAgent,
},
});
}
/**
* L10b (ARCHITEKTUR §4.8): audit entries written inside `inTransaction` are buffered and flushed
* after the commit. The audit insert uses the owner client (outside the tenant transaction), so
* without the buffer a rolled-back transaction would leave "create/update" entries for changes
* that never happened. On rollback only `denied` entries are kept (security relevant, independent
* of the business change).
*/
const deferredAudit = new AsyncLocalStorage<{ entries: { entry: AuditEntry; request: RequestContext }[] }>();
export async function writeAuditLog(entry: AuditEntry) {
const request = await requestContext();
const buffer = deferredAudit.getStore();
if (buffer) {
// snapshot before/after now — callers may mutate the objects after the call
buffer.entries.push({ entry: structuredCloneSafe(entry), request });
return;
}
await insertAudit(entry, request);
}
/** Run `fn` with deferred audit writes (see above). Nested calls join the outer buffer. */
export async function withDeferredAudit<T>(fn: () => Promise<T>): Promise<T> {
if (deferredAudit.getStore()) return fn();
const buffer: { entries: { entry: AuditEntry; request: RequestContext }[] } = { entries: [] };
let result: T;
try {
result = await deferredAudit.run(buffer, fn);
} catch (err) {
await flush(buffer.entries.filter((e) => e.entry.action === "denied"));
throw err;
}
await flush(buffer.entries);
return result;
}
async function flush(entries: { entry: AuditEntry; request: RequestContext }[]) {
for (const { entry, request } of entries) {
try {
await insertAudit(entry, request);
} catch (err) {
// the business change is already committed — never turn it into an error for the caller
console.error("[audit] deferred write failed:", (err as Error).message);
}
}
}
/** Deep copy for JSON-like audit payloads; falls back to the original for non-cloneable values. */
function structuredCloneSafe<T>(value: T): T {
try {
return structuredClone(value);
} catch {
return value;
}
}
/**
* Audit-Eintrag der Plattform-Ebene (kein Mandantenbezug, scope="platform").
* Für Superadmin-Anmeldungen und -Aktionen (Phase-1-Härtung Paket 2).
@@ -0,0 +1,11 @@
import { purgeExpiredAiGenerations } from "@/server/services/lotse/retention";
/**
* Daily retention job for the AI log (L10b, Spec §31). Scheduled by the craftvia worker
* (`scheduleRecurringJobs`); the payload carries no tenant — the service iterates all tenants and
* writes through dbForTenant.
*/
export async function process(): Promise<void> {
const res = await purgeExpiredAiGenerations();
console.info(`[ai-retention] ${res.pseudonymised} entries older than ${res.days} days pseudonymised (${res.tenants} tenants)`);
}
+1
View File
@@ -12,6 +12,7 @@ export const PROCESSORS: Partial<Record<JobQueueName, () => Promise<JobProcessor
transcription: () => import("./transcription").then((m) => m.process),
"report-pdf": () => import(/* turbopackIgnore: true */ "./report-pdf").then((m) => m.process), // worker-only (react-dom/server + Chromium), kept out of the app bundle
"image-derivatives": () => import("./image-derivatives").then((m) => m.process),
"ai-retention": () => import("./ai-retention").then((m) => m.process), // L10b: daily AI log retention (scheduled by the worker)
};
/** Inline fallback when no Redis is available (dev/demo). */
+19
View File
@@ -12,6 +12,7 @@ export const JOB_QUEUES = {
transcription: "transcription",
reportPdf: "report-pdf",
imageDerivatives: "image-derivatives",
aiRetention: "ai-retention",
} as const;
export type JobQueueName = (typeof JOB_QUEUES)[keyof typeof JOB_QUEUES];
@@ -80,6 +81,24 @@ export async function enqueueJob(name: JobQueueName, payload: JobPayload): Promi
return true;
}
/**
* Recurring jobs (L10b), registered once by the craftvia worker at start. BullMQ job schedulers
* are idempotent per id, so several worker replicas do not create duplicates.
* - ai-retention: daily pseudonymisation of AI log contents (services/lotse/retention.ts)
*/
export async function scheduleRecurringJobs(connection: Redis): Promise<void> {
const q = new Queue<JobPayload>(JOB_QUEUES.aiRetention, { connection });
try {
await q.upsertJobScheduler(
"ai-retention-daily",
{ every: 24 * 60 * 60 * 1000 },
{ name: JOB_QUEUES.aiRetention, data: { tenantId: "*", entityId: "retention" }, opts: { removeOnComplete: { count: 30 }, removeOnFail: { count: 30 } } },
);
} finally {
await q.close();
}
}
export async function closeJobQueues(): Promise<void> {
await Promise.all([...queues.values()].map((q) => q.close()));
queues.clear();

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