- Aufträge: createWorkOrder/transitionWorkOrder/getCompletionBlockers aus L2 - Dokumente: storeFile aus L1; neu services/documents/read.ts (readStoredBytes, readDocumentBytes mit Prüfsummenprüfung); L2-Upload nutzt zentrale Ablage - Dubletten aus L1 (findDuplicateCustomers(ctx)); Objekt-Kandidaten als imports/site-candidates.ts; Dateityp-Erkennung mobil als field/mime.ts - Objekt-Historie mobil als Adapter auf L1 getSiteHistory, PDF über /api/v1/reports/:id/pdf - L4-Upload-Idempotenz: fester Upload-Lineage nach storeFile, Race → Soft-Delete + Replay - PDF-Worker-Kontext: document:write zum Ablegen des Berichts-PDF - Import: doppeltes Work-Order-Audit entfernt; bestätigte Aufträge starten planned - Dateilinks in Auftragsdetail auf /files/[documentId] - proxy: /api/v1 ohne Session → 401 JSON statt Login-Redirect - ARCHITEKTUR §2: Objekt-Historie für Feldrollen (freigegebene Einsätze aller Teams) Gate: tsc, lint, build, 42/42 Tests grün. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
18 KiB
Craftvia – Architektur & Team-Verträge (MVP)
Verbindlich für alle Lanes. Fachliche Quelle:
docs/craftvia/SPEC-CRAFTVIA.md(Spec) unddocs/craftvia/BRANDBOOK.md. Technische Regeln:AGENTS.md. Bei Widerspruch gilt: AGENTS.md (Sicherheit) > dieses Dokument (Verträge) > Spec (Fachlichkeit).
1. Architekturentscheidungen
| Thema | Entscheidung | Abweichung von Spec |
|---|---|---|
| Stack | Next.js 16 App Router + Server Actions + Route Handlers, Prisma 7, Postgres 16 mit RLS, BullMQ/Redis, Garage (S3), Auth.js v5 | Spec §30 empfiehlt NestJS – bewusst nicht, einheitliche Codebasis mit Certvia-Fundament |
| API | Backoffice: Server Actions. Mobile/PWA + Integrationen: versionierte REST-Route-Handler unter src/app/api/v1/** mit denselben Service-Funktionen |
– |
| Fachlogik | Service-Schicht src/server/services/<modul>/*.ts: reine Funktionen (ctx, input) → result, ctx = { db: TenantDb, session, tenantId, userId }. Server Actions und /api/v1 sind dünne Adapter (Guard → Zod → Service → Audit/Revalidate). |
– |
| Mandanten | tenantId auf jeder Fachtabelle, dbForTenant + RLS (enable_tenant_rls) |
– |
| KI | Claude (Anthropic SDK) für Extraktion/Lotse; Whisper-kompatible Transkription; Provider-Interfaces | – |
| HTML-Template → PDF via Playwright/Chromium im Worker (nicht im App-Container) | – | |
| Offline | Service Worker + IndexedDB-Outbox, Operation-basierter Sync /api/v1/sync mit Idempotenz-Keys und Versionsprüfung |
– |
| Tests | tsx-Skripte scripts/test-*.ts, Runner npm run test; Gate npm run gate |
Spec nennt Unit/E2E-Frameworks – MVP nutzt bestehenden Skript-Stil |
2. Rollen & Rechte
Siehe src/server/rbac.ts (vom Fundament angelegt). Rollen: tenant-admin, backoffice, team-lead, technician. Plattform-Admin = separater Store (kein Fachdatenzugriff).
Sichtbarkeit von Aufträgen (Pflicht, serverseitig, zentral): src/server/services/work-orders/visibility.ts#workOrderScope(ctx): Prisma.WorkOrderWhereInput
work_order:read_all→ alle (nicht gelöscht)- sonst
work_order:read_team→assignedTeamId ∈ aktive Teams des UsersODER User istWorkOrderAssigneeODERteamLeadUserId = userIdODER (Notdienst)createdById = userId - Kunden/Objekte für Monteure: nur lesbar, wenn über einen sichtbaren Auftrag erreichbar (
customerScope,siteScopein derselben Datei). Jede Query auf WorkOrder und abhängige Entitäten (Photos, Reports, Documents …) für Nicht-Backoffice-Rollen MUSS diesen Scope verwenden.
Objekt-Historie für Feldrollen (Entscheidung, US-005/US-011): Monteure/Teamleiter sehen an einem Objekt, das sie über einen eigenen sichtbaren Auftrag erreichen (siteScope), die FREIGEGEBENEN Einsätze aller Teams (Aufträge mit freigegebenem Bericht; getSiteHistory in services/sites/history.ts). Interne Hinweise sind nie Teil der Historie.
Dokument-Sichtbarkeit: documentVisibilityFilter(ctx): backoffice_only nur mit document:read_internal; team_lead nur Teamleiter/Backoffice; team/customer_report für berechtigte Auftragsbeteiligte.
3. Statusmodell Auftrag
Quelle: src/lib/work-orders/status.ts (client-safe, reine Daten + Funktionen). Server prüft jeden Übergang über assertTransition(from, to, ctx); kein direktes status-Update außerhalb von services/work-orders/transition.ts#transitionWorkOrder.
draft ─► review_required ─► planned ─► assigned ─► accepted ─► en_route ─► in_progress
│ ▲ │ │ │ ▲
└──────► assigned ────┘ └────────────┴──► in_progress
in_progress ⇄ paused, in_progress ⇄ waiting_material
in_progress ─► daily_report_created ─► in_progress (nächster Tag) | en_route
in_progress ─► technically_completed ─► signature_pending ─► in_review
technically_completed ─► in_review (Unterschrift erfasst/nicht erforderlich)
in_review ─► released_for_billing ─► billed
in_review ─► in_progress (Korrektur angefordert)
* (außer billed) ─► cancelled [work_order:cancel]
Rechte je Übergang:
| Übergang | Recht |
|---|---|
| draft/review_required → planned/assigned, Zuweisung | work_order:write / work_order:assign |
| assigned → accepted → en_route → in_progress, pause/resume, waiting_material, daily_report_created, technically_completed, signature_pending, → in_review | field:execute + Auftrag im Scope |
| in_review → in_progress (Korrektur) | report:approve_team oder report:approve |
| in_review → released_for_billing | work_order:release_billing (+ freigegebener Abschlussbericht) |
| released_for_billing → billed | work_order:release_billing |
| → cancelled | work_order:cancel |
Guards vor Abschluss (technically_completed): alle required Checklistenpunkte erledigt, alle PhotoRequirement mit ≥1 Foto, keine laufende WorkSession. Fehlende Punkte werden als strukturierte Liste zurückgegeben (CompletionBlocker[]), UI zeigt sie.
UI-Label-Gruppen (Brandbook §12.3) in status.ts#STATUS_GROUP:
Neu = draft, review_required · Geplant = planned, assigned, accepted · Unterwegs = en_route · In Arbeit = in_progress, paused, waiting_material, daily_report_created · Dokumentation unvollständig = technically_completed, signature_pending · Zur Prüfung = in_review · Bereit zur Abrechnung = released_for_billing · Abgerechnet = billed · Storniert = cancelled.
Jede Statusänderung: WorkOrderStatusChange + Audit + Event.
4. Querschnitts-Verträge (vom Architekten angelegt, Lanes nutzen sie)
4.1 Events → Benachrichtigungen
src/lib/events.ts: EVENT_TYPES (const) + Payload-Typen.
src/server/events.ts#emitEvent(ctx, event): schreibt nach erfolgreicher Mutation. Lanes rufen nur emitEvent auf – nie direkt Notification/Mail. Die Lane „Benachrichtigungen" implementiert Empfängerauflösung, In-App-Notification, E-Mail-Queue.
Events: work_order.assigned, work_order.changed, work_order.cancelled, work_order.started, work_order.daily_report_created, work_order.technically_completed, work_order.signature_missing, report.submitted, report.approved, report.rejected, work_order.released_for_billing, emergency.created, emergency.completed, work_order.missing_required, sync.failed, import.ready_for_review, import.failed.
4.2 Nummernkreise
src/server/services/numbering.ts#nextNumber(db, tenantId, key) – atomar (UPDATE … RETURNING in Transaktion). Keys: customer (K-), work_order (A-), emergency (N-), report (B-).
4.3 Dateien
src/server/services/documents/store.ts:
storeFile(ctx, { bytes, fileName, declaredMime, category, visibility, links:{customerId?,siteId?,workOrderId?}, lineageId? }) → Document– validiert (Allowlist, Größenlimit je Kategorie, Magic Bytes, Dateiname normalisiert), SHA-256,storage.put, Versionierung überlineageId.getDownloadUrl(ctx, documentId)→ interne Route/files/<documentId>(Autorisierung über Document + Sichtbarkeit + Auftrags-Scope; keine öffentlichen Links). Die bestehendefiles/[...key]-Route wird aufdocumentIdumgestellt.- Limits: Bilder 15 MB (Client komprimiert vorher auf max. 2560 px / JPEG 0.82 + Thumbnail 400 px), PDF 25 MB, Audio 20 MB.
- Malware-Scan: Interface
FileScanner(src/server/services/documents/scanner.ts), MVP-Implementierung = Magic-Byte/Typprüfung + Hook für ClamAV (CLAMAV_HOSToptional).
4.4 Jobs (BullMQ)
Queue-Namen in src/server/jobs/queues.ts: import-extraction, transcription, report-pdf, image-derivatives, notifications (Mail nutzt bestehende Mail-Queue). Worker-Einstieg scripts/craftvia-worker.ts (npm run worker:craftvia), jede Lane registriert ihren Processor in src/server/jobs/processors/<name>.ts und in processors/index.ts (eine Zeile je Lane).
4.5 KI-Provider
src/server/ai/providers.ts:
interface DocumentExtractionProvider { name; model; extract(input: { bytes: Buffer; mimeType: string }): Promise<WorkOrderExtraction> }
interface TranscriptionProvider { name; model; transcribe(input: { bytes: Buffer; mimeType: string; language: "de" }): Promise<{ text: string }> }
interface LotseProvider { name; model; draftReport(input: ReportDraftInput): Promise<ReportDraftOutput> }
Konfiguration per Env (AI_EXTRACTION_PROVIDER=anthropic, ANTHROPIC_API_KEY, ANTHROPIC_MODEL, TRANSCRIPTION_PROVIDER=openai-compatible, TRANSCRIPTION_API_URL, TRANSCRIPTION_API_KEY, TRANSCRIPTION_MODEL=whisper-1). Ohne Key: graceful degradation (Status disabled, manuelle Eingabe). Jede Nutzung → AiGeneration. Tests nutzen FakeProvider.
4.6 Offline-Sync
- Mobile Mutationen sind Operationen:
{ clientOpId: uuid, opType, entityType, entityId?, baseVersion?, payload, clientCreatedAt }. opType-Katalog (src/lib/sync/ops.ts, Zod-Schemas):session.start,session.pause,session.resume,session.end,work_order.transition,note.create,checklist.toggle,material.upsert,photo.attach(nach Blob-Upload),voice.attach,report.save_draft,report.submit,signature.capture,emergency.create.- Online und offline identischer Pfad: Client-Outbox →
POST /api/v1/sync(Batch) →services/sync/apply.ts→ dispatcht auf dieselben Services. Idempotent überSyncOperation(tenantId, clientOpId). - Binärdaten:
POST /api/v1/uploads(multipart,clientId) liefertdocumentId; die Op referenziertdocumentId. - Konflikte:
WorkOrder.version≠baseVersionbei konfliktbehafteten Ops (work_order.transition,report.submit) →status=conflict, nichts überschrieben, Eventsync.failed, Backoffice-Liste. Additive Ops (Notiz, Foto, Material, Zeiten) sind konfliktfrei. - Pull:
GET /api/v1/field/bundle?since=liefert Aufträge im Scope inkl. Kunde, Objekt, Kontakte, Checkliste, Material, Pflichtfotos, Dokument-Metadaten (Blobs werden vom SW gecacht), letzte freigegebene Berichte am Objekt.
4.7 Berichtsinhalt
src/lib/reports/content.ts#ReportContent (Zod): Snapshot aller Berichtsdaten (Spec §16.2/§17.2) – wird beim Erstellen/Freigeben aus DB gebaut (services/reports/build-content.ts) und ist nach approved unveränderlich. Änderungen nach Freigabe = neue Version (lineageId, version+1, alte → superseded).
4.8 Transaktionen, Header, Uploads (Fundament-Nachträge)
- Mehrschritt-Schreibvorgänge nur über
inTransaction(ctx, fn)(src/server/services/context.ts, basiert auftenantTransactionindb.ts). Direktesctx.db.$transaction(...)ist beiRLS_ENFORCED=truenicht atomar. - Datei-Routen, die im eigenen iframe angezeigt werden dürfen, stehen in
EMBEDDABLE_FILE_ROUTES(next.config.ts,frame-ancestors 'self'/SAMEORIGIN); alles andere bleibtDENY. - Request-Bodies über den Proxy:
experimental.proxyClientMaxBodySize = 26mb(größter Upload 25 MB). - Berichtsversionen (§4.7): Eine freigegebene Version wird erst
superseded, wenn die NEUE Version freigegeben wird – so existiert immer ein gültiges freigegebenes PDF. - Neue Personenreferenz-Felder (
…ById,userId) insrc/server/dsgvo/pii-fields.tseintragen.
5. Routen
| Bereich | Route | Modul |
|---|---|---|
| Backoffice Dashboard | /dashboard |
– |
| Aufträge | /work-orders, /work-orders/[id] |
work_orders |
| Import | /imports, /imports/[id] (Prüfmaske) |
imports |
| Kunden | /customers, /customers/[id] |
customers |
| Objekte | /sites, /sites/[id] (inkl. Historie) |
sites |
| Teams | /teams |
teams |
| Berichte | /reports, /reports/[id] |
reports |
| Dokumente | /documents |
documents |
| Benachrichtigungen | /notifications |
notifications |
| Einstellungen | /settings/order-types, /settings/checklists, /settings/numbering, /settings/email |
– (settings:templates / tenant:manage) |
| Suche | /search?q= |
– |
| Sync-Konflikte | /work-orders/conflicts |
work_orders |
| Mobile (PWA) | /m (Heute), /m/orders, /m/orders/[id], /m/orders/[id]/{time,materials,photos,notes,checklist,report,sign}, /m/emergency, /m/sync, /m/profile |
field / emergency |
| API | /api/v1/** |
je Modul |
Mobile-Layout src/app/(app)/m/(field)/layout.tsx (L4 darf /m in eine eigene Route-Group src/app/(field)/m/** verschieben, damit die Backoffice-Sidebar entfällt, und dafür die Zugriffsprüfungen aus (app)/layout.tsx in src/server/app-access.ts extrahieren – erlaubter Fundament-Eingriff): eigene Shell mit Bottom-Navigation (Heute · Aufträge · Notdienst · Sync · Profil), große Touch-Ziele (≥ 48 px), Offline-/Sync-Badge. Rollen team-lead/technician landen nach Login auf /m; Backoffice auf /dashboard.
6. Lanes & Datei-Ownership
Jede Lane arbeitet in eigenem Worktree/Branch lane/<name> von feature/craftvia-mvp, besitzt ihre Pfade exklusiv und fasst fremde Pfade nur über die in §4 genannten Einzeiler-Registrierungen an.
| Lane | Besitzt | Liefert |
|---|---|---|
| L1 Stammdaten | services/{customers,sites,teams}, actions/{customers,sites,teams}, app/(app)/{customers,sites,teams}, components/{customers,sites,teams}, lib/customers/duplicates.ts, api/v1/{customers,sites,teams}, messages customers/sites/teams |
CRUD, Ansprechpartner, Dublettenprüfung + Merge (mit Bestätigung), Objekt-Detail inkl. Dokumente-Tab (nutzt §4.3) und Objekt-Historie (§8.3), Teams + Mitglieder, customerScope/siteScope |
| L2 Aufträge | services/work-orders/**, lib/work-orders/**, actions/work_orders, app/(app)/work-orders, components/work-orders, api/v1/work-orders, app/(app)/settings/{order-types,checklists,numbering}, app/(app)/dashboard, app/(app)/search, messages workOrders/dashboard/search/settingsTemplates |
Auftrag CRUD, Statusmaschine + Guards, Zuweisung, Checklisten/Pflichtfotos/Materialvorgabe, Dokumente am Auftrag, Backoffice-Dashboard mit Filtern (§21), Abrechnungsfreigabe, Suche (§25), Konfliktliste |
| L3 Import | services/imports/**, lib/imports/**, actions/imports, app/(app)/imports, components/imports, jobs/processors/import-extraction.ts, ai/extraction/** |
Upload, Validierung, Claude-Extraktion (PDF nativ + Bild), Konfidenzen, Prüfmaske, Dublettenkandidaten (nutzt lib/customers/duplicates.ts von L1 – Vertrag: findDuplicateCustomers(db, candidate) → Candidate[], L3 nutzt Stub bis Merge), Bestätigung → Kunde/Objekt/Auftrag (über L2-Service createWorkOrder), Musterdokumente + Tests |
| L4 Einsatz mobil | app/(app)/m/(field)/ bzw. nach Umzug app/(field)/m/** (außer emergency, sync), services/field/**, lib/sync/ops.ts, services/sync/**, api/v1/{sync,uploads,field}, components/field/**, messages field |
Mobile Shell, Heute/Aufträge, Auftragsdetail (Infos, Dokumente, Objekt-Historie read-only), Zeiten (Start/Pause/Stopp + Korrektur mit Grund), Checkliste, Material (bestätigen/abweichen/zusätzlich), Fotos (Kamera, Kompression, Kategorie/Phase/Kommentar), Notizen, Sprachnotiz-Aufnahme, Sync-API serverseitig |
| L5 Berichte | services/reports/**, lib/reports/**, actions/reports, app/(app)/reports, components/reports/** (inkl. signature-pad.tsx), jobs/processors/report-pdf.ts, server/pdf/**, api/v1/reports, messages reports |
Tages-/Abschlussbericht (Content-Builder, Validierung Pflichtangaben), Unterschrift inkl. Ablehnungsgründe, PDF (HTML-Template, Mandantenlogo, Fotos, Unterschrift, Checksumme), Versionierung, Teamleiter-/Backoffice-Freigabe/Zurückweisung; mobile Seiten /m/orders/[id]/{report,sign} als Komponenten, die L4 einbindet |
| L6 Benachrichtigungen & Audit | server/events.ts (Implementierung), services/notifications/**, app/(app)/notifications, components/notifications/** (Glocke), mail/templates Craftvia-Events, app/(app)/settings/email, app/(app)/settings/audit, messages notifications |
Empfängerregeln je Event, In-App + E-Mail, Mandanten-Mailkonfiguration (§33.2), Audit-Viewer mit Filter |
| L7 Offline/PWA (Welle 2) | public/sw.js bzw. src/app/sw.ts-Build, lib/offline/** (IndexedDB, Outbox, Blob-Queue), app/(app)/m/(field)/ bzw. nach Umzug app/(field)/m/sync, components/offline/** |
Service Worker (App-Shell + Bundle-Cache + Dokument-Cache), Outbox mit Retry, Upload-Queue, Sync-Status/Fehler, Konfliktanzeige, Installierbarkeit |
| L8 Notdienst (Welle 2) | app/(app)/m/(field)/ bzw. nach Umzug app/(field)/m/emergency, services/emergency/**, actions/emergency, Backoffice-Review app/(app)/work-orders/emergency-review |
Notdienst-Erfassung (bestehender/vorläufiger Kunde + Objekt), Start, Abschluss → Events; Backoffice: bestätigen/zuordnen/zusammenführen/abrechnen |
| L9 Lotse (Welle 2) | services/lotse/**, ai/lotse/**, jobs/processors/transcription.ts, components/lotse/**, messages lotse |
Transkription, „Bericht mit Lotse vorbereiten" (Entwurf gekennzeichnet, editierbar), Vollständigkeitsprüfung „3 Angaben fehlen" |
| L10 Stabilisierung (Welle 3) | scripts/test-e2e-*.ts, scripts/test-security-*.ts, prisma/seed.ts Demo-Daten, Docs |
E2E-Prozessskripte (§43.3), Sicherheitstests (§43.4), Demo-Seed, Deploy-Doku, OpenAPI (/api/v1/openapi.json) |
Gemeinsame Einzeiler-Registrierungen (Konflikte trivial auflösbar): src/lib/nav.ts, src/server/jobs/processors/index.ts, scripts/check-module-guards.ts (nur falls Top-Level-Actions), src/server/audit Entity-Labels.
7. Definition of Done je Lane
npm run gategrün (generate, tsc, lint, build inkl. Guard-Check, alle Tests).- Neue Tests: mind. Service-Logik + Mandantentrennung (Mandant B sieht/ändert nichts von A) + Rollen (Monteur außerhalb Scope → Fehler) je Lane.
- Jede Mutation: Guard mit Permission, Zod, Audit (
before/after), ggf.emitEvent. - Keine hartkodierten UI-Texte (messages/de/.json; en mindestens Schlüssel mit DE-Fallback-Text).
- Responsive geprüft (Backoffice ≥ 1024 px und 768 px; Mobile 375 px).
- Lane-Bericht
docs/craftvia/lanes/<lane>.md: Umfang, Dateien, Tests, offene Punkte.