- docs/craftvia/lanes/betrieb.md: Umfang a–k, Entscheidungen (b: report.submit übernehmbar, h: Audit nach Commit umgesetzt), Verhaltensänderungen (Fehlerformat, 422), Dateien, Tests (api 150, sync 36, audit 48), Gate 52/52, RLS_ENFORCED-Lauf 50/52 (zwei Owner-Modus-Tests unverändert), Docker-Build, offene Punkte. - scripts/smoke-betrieb.ts: HTTP-Smoke gegen den Dev-Server (Session-Cookies ohne Passworteingabe) für Fehlerformat, OpenAPI, Sync-Ops, Bundle, Lotse-Kontingent, Sidebar; 18/18 grün auf :3111. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
16 KiB
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.tsund 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-Sitesind weiter möglich). test-einsatz-sync.ts: Prüfung „nicht verfügbare Op" nutztsignature.capture, weilreport.save_draftjetzt 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.mdAbschnitt „Betrieb": Verweise aufdocs/craftvia/DEPLOY.md/API.mdstatt archivierter Certvia-Docs, Targetworker.- 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 Einzeilerai-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.tserwartet fürfindUniqueüber einen fremden Compound-Key (RoletenantId_key) einen Throw des Owner-Guards; mit scharfer RLS liefert die Abfragenull(kein Datenabfluss, aber andere Semantik).test-garage-storage.tslädtstorage/backup-store.ts→db.tsbricht 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.tsist durch den L4-Dispatcher ersetzt. - Genutzt: L1
requireApiContext/respond.ts, L4applyOperations/Registry, L5submitReport/updateReportTexts/requireVisibleReport, L7useOfflineDraft, L9applyLotseReview, L2applySyncConflict.
5. Bekannte Lücken / offene Punkte
- Rate Limit je App-Instanz (In-Memory wie SEC2); bei mehreren Replikas zählt jede Instanz getrennt. Geteilter Redis-Zähler = SEC5.
/api/v1nur mit Session-Cookie – kein Token für Integrationen (unverändert).signature.captureoffline nicht registriert:/api/v1/uploadskennt keine Upload-Art für das Unterschriftsbild (kind: signature, PNG). Die mobile Berichts-/Unterschrift-UI nutzt weiterhin Server Actions; die neuen Opsreport.save_draft/report.submitstehen der Outbox bereit, die UI ist aber noch nicht aufsubmitOpumgestellt (L5/L7).- Events in Transaktionen:
emitEventinnerhalb voninTransactionwird weiterhin sofort ausgeführt (Benachrichtigung bei späterem Rollback möglich) – analog zu h lösbar. - Kontingent: Monatsgrenze UTC; ein laufender Aufruf kann das Limit einmalig überschreiten; Transkription (Audio) zählt nicht; keine Warnung vor Erreichen.
- Aufbewahrung: Sprachnotiz-Zusammenfassungen älter als die Frist können nicht mehr übernommen werden (Ausgabe geleert); das KI-Protokoll zeigt dann
null. - 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.
POST /api/v1/work-orders/{id}/documentsgibt das Dokument inkl.storageKeyzurück (L2-Verhalten, unverändert) – interner Schlüssel sollte nicht nach außen.- 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. - CI-Job nicht ausgeführt (kein Runner): offen, ob
prisma db execute --stdinfür dascraftvia_app-Passwort und Chromium im Gitea-Runner verfügbar sind (PDF-Test skippt sonst). - Prebuilt-Deploy braucht ein gepushtes
craftvia-worker-Image (Skript baut es jetzt, Push nicht ausgeführt). - 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 |