Merge lane/betrieb in feature/craftvia-mvp
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
+46
-8
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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"]
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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).
|
||||
|
||||
@@ -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.
|
||||
@@ -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 |
|
||||
@@ -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
|
||||
@@ -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 |
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -147,6 +147,7 @@
|
||||
"logout": "Abmeldung",
|
||||
"denied": "Abgelehnt",
|
||||
"export": "Export",
|
||||
"read": "Lesezugriff",
|
||||
"import": "Import",
|
||||
"provision": "Eingerichtet",
|
||||
"approve": "Freigegeben",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -147,6 +147,7 @@
|
||||
"logout": "Sign-out",
|
||||
"denied": "Denied",
|
||||
"export": "Export",
|
||||
"read": "Read access",
|
||||
"import": "Import",
|
||||
"provision": "Provisioned",
|
||||
"approve": "Approved",
|
||||
|
||||
@@ -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
@@ -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")
|
||||
}
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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,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
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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" } });
|
||||
});
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
|
||||
@@ -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)));
|
||||
});
|
||||
|
||||
@@ -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));
|
||||
});
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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" } });
|
||||
}
|
||||
@@ -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 });
|
||||
});
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
|
||||
@@ -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));
|
||||
});
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 });
|
||||
});
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -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);
|
||||
|
||||
@@ -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 };
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ export const LOTSE_ERROR_CODES = [
|
||||
"pending",
|
||||
"conflict",
|
||||
"invalid",
|
||||
"budget_exceeded",
|
||||
] as const;
|
||||
export type LotseActionErrorCode = (typeof LOTSE_ERROR_CODES)[number];
|
||||
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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" });
|
||||
|
||||
@@ -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
@@ -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>;
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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)`);
|
||||
}
|
||||
@@ -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). */
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user