From bf4456718ec5a4c561b8fc6e18df8f76ee92b499 Mon Sep 17 00:00:00 2001 From: Martin Date: Mon, 14 Sep 2026 11:49:21 +0200 Subject: [PATCH] =?UTF-8?q?Architektur:=20Craftvia-Dom=C3=A4nenmodell,=20V?= =?UTF-8?q?ertr=C3=A4ge=20und=20Team-Schnitte?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Migration 0002_craftvia_domain: 27 Fachtabellen inkl. RLS (enable_tenant_rls) - TENANT_MODELS (db.ts, backup/topology.ts) um alle Fachmodelle ergänzt - moduleGuard liefert DB-autoritative Rechte; ServiceCtx für Domänen-Services - Verträge: Statusmaschine, Events, Nummernkreise, Sichtbarkeits-Scopes, Job-Queues + Worker, KI-Provider-Interfaces, Sync-Envelope - docs/craftvia/ARCHITEKTUR.md mit Lanes, Ownership und DoD Gate: tsc, lint, build, 22/22 Tests grün. Co-Authored-By: Claude Opus 5 --- .env.example | 15 + docs/craftvia/ARCHITEKTUR.md | 150 +++ package.json | 3 +- .../0002_craftvia_domain/migration.sql | 926 ++++++++++++++++++ prisma/schema.prisma | 863 ++++++++++++++++ scripts/craftvia-worker.ts | 39 + src/lib/events.ts | 32 + src/lib/sync/envelope.ts | 57 ++ src/lib/work-orders/status.ts | 129 +++ src/server/action-guard.ts | 20 +- src/server/ai/providers.ts | 85 ++ src/server/backup/topology.ts | 29 +- src/server/db.ts | 28 + src/server/events.ts | 16 + src/server/jobs/dispatch.ts | 16 + src/server/jobs/processors/index.ts | 23 + src/server/jobs/queues.ts | 89 ++ src/server/services/context.ts | 43 + .../services/notifications/handle-event.ts | 10 + src/server/services/numbering.ts | 41 + src/server/services/work-orders/visibility.ts | 68 ++ 21 files changed, 2670 insertions(+), 12 deletions(-) create mode 100644 docs/craftvia/ARCHITEKTUR.md create mode 100644 prisma/migrations/0002_craftvia_domain/migration.sql create mode 100644 scripts/craftvia-worker.ts create mode 100644 src/lib/events.ts create mode 100644 src/lib/sync/envelope.ts create mode 100644 src/lib/work-orders/status.ts create mode 100644 src/server/ai/providers.ts create mode 100644 src/server/events.ts create mode 100644 src/server/jobs/dispatch.ts create mode 100644 src/server/jobs/processors/index.ts create mode 100644 src/server/jobs/queues.ts create mode 100644 src/server/services/context.ts create mode 100644 src/server/services/notifications/handle-event.ts create mode 100644 src/server/services/numbering.ts create mode 100644 src/server/services/work-orders/visibility.ts diff --git a/.env.example b/.env.example index 1ef7798..56ffbf9 100644 --- a/.env.example +++ b/.env.example @@ -88,3 +88,18 @@ MAIL_FROM_NAME=Craftvia MAIL_REPLY_TO= # Basis fuer absolute Links in Mails. Faellt auf AUTH_URL zurueck. APP_BASE_URL=http://localhost:3000 + +# --- Craftvia: KI-Extraktion (Auftragsimport) & Lotse --- +# Nutzt ANTHROPIC_API_KEY / ANTHROPIC_MODEL (s. oben). Ohne Key: manuelle Erfassung. +AI_EXTRACTION_PROVIDER=anthropic + +# --- Craftvia: 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 + +# --- Craftvia: optionaler Malware-Scan für Uploads (ClamAV clamd) --- +CLAMAV_HOST= +CLAMAV_PORT=3310 diff --git a/docs/craftvia/ARCHITEKTUR.md b/docs/craftvia/ARCHITEKTUR.md new file mode 100644 index 0000000..1cde6df --- /dev/null +++ b/docs/craftvia/ARCHITEKTUR.md @@ -0,0 +1,150 @@ +# Craftvia – Architektur & Team-Verträge (MVP) + +> Verbindlich für alle Lanes. Fachliche Quelle: `docs/craftvia/SPEC-CRAFTVIA.md` (Spec) und `docs/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//*.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 | – | +| PDF | 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 Users` ODER User ist `WorkOrderAssignee` ODER `teamLeadUserId = userId` ODER (Notdienst) `createdById = userId` +- Kunden/Objekte für Monteure: nur lesbar, wenn über einen sichtbaren Auftrag erreichbar (`customerScope`, `siteScope` in derselben Datei). +Jede Query auf WorkOrder und abhängige Entitäten (Photos, Reports, Documents …) für Nicht-Backoffice-Rollen MUSS diesen Scope verwenden. + +**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 über `lineageId`. +- `getDownloadUrl(ctx, documentId)` → interne Route `/files/` (Autorisierung über Document + Sichtbarkeit + Auftrags-Scope; keine öffentlichen Links). Die bestehende `files/[...key]`-Route wird auf `documentId` umgestellt. +- 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_HOST` optional). + +### 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/.ts` und in `processors/index.ts` (eine Zeile je Lane). + +### 4.5 KI-Provider +`src/server/ai/providers.ts`: +```ts +interface DocumentExtractionProvider { name; model; extract(input: { bytes: Buffer; mimeType: string }): Promise } +interface TranscriptionProvider { name; model; transcribe(input: { bytes: Buffer; mimeType: string; language: "de" }): Promise<{ text: string }> } +interface LotseProvider { name; model; draftReport(input: ReportDraftInput): Promise } +``` +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 über `SyncOperation(tenantId, clientOpId)`. +- Binärdaten: `POST /api/v1/uploads` (multipart, `clientId`) liefert `documentId`; die Op referenziert `documentId`. +- Konflikte: `WorkOrder.version` ≠ `baseVersion` bei konfliktbehafteten Ops (`work_order.transition`, `report.submit`) → `status=conflict`, nichts überschrieben, Event `sync.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`). + +## 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/` 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 +1. `npm run gate` grün (generate, tsc, lint, build inkl. Guard-Check, alle Tests). +2. Neue Tests: mind. Service-Logik + **Mandantentrennung** (Mandant B sieht/ändert nichts von A) + **Rollen** (Monteur außerhalb Scope → Fehler) je Lane. +3. Jede Mutation: Guard mit Permission, Zod, Audit (`before/after`), ggf. `emitEvent`. +4. Keine hartkodierten UI-Texte (messages/de/.json; en mindestens Schlüssel mit DE-Fallback-Text). +5. Responsive geprüft (Backoffice ≥ 1024 px und 768 px; Mobile 375 px). +6. Lane-Bericht `docs/craftvia/lanes/.md`: Umfang, Dateien, Tests, offene Punkte. diff --git a/package.json b/package.json index c74cf23..6e47ce7 100644 --- a/package.json +++ b/package.json @@ -13,7 +13,8 @@ "gate": "prisma generate && tsc --noEmit && npm run lint && npm run build && npm run test", "worker:mail": "tsx scripts/mail-worker.ts", "worker:backup": "tsx scripts/backup-worker.ts", - "brand:icons": "tsx scripts/generate-brand-icons.ts" + "brand:icons": "tsx scripts/generate-brand-icons.ts", + "worker:craftvia": "tsx scripts/craftvia-worker.ts" }, "dependencies": { "@anthropic-ai/sdk": "^0.115.0", diff --git a/prisma/migrations/0002_craftvia_domain/migration.sql b/prisma/migrations/0002_craftvia_domain/migration.sql new file mode 100644 index 0000000..b7ee84f --- /dev/null +++ b/prisma/migrations/0002_craftvia_domain/migration.sql @@ -0,0 +1,926 @@ +-- Craftvia domain model (MVP). Generated via prisma migrate diff + RLS block. + +-- CreateEnum +CREATE TYPE "CustomerStatus" AS ENUM ('active', 'inactive', 'provisional', 'merged'); + +-- CreateEnum +CREATE TYPE "SiteStatus" AS ENUM ('active', 'inactive', 'provisional'); + +-- CreateEnum +CREATE TYPE "TeamStatus" AS ENUM ('active', 'inactive'); + +-- CreateEnum +CREATE TYPE "WorkOrderStatus" AS ENUM ('draft', 'review_required', 'planned', 'assigned', 'accepted', 'en_route', 'in_progress', 'paused', 'waiting_material', 'daily_report_created', 'technically_completed', 'signature_pending', 'in_review', 'released_for_billing', 'billed', 'cancelled'); + +-- CreateEnum +CREATE TYPE "WorkOrderPriority" AS ENUM ('low', 'normal', 'high', 'urgent'); + +-- CreateEnum +CREATE TYPE "MaterialUsageStatus" AS ENUM ('fully_used', 'partially_used', 'not_used', 'additional'); + +-- CreateEnum +CREATE TYPE "WorkSessionStatus" AS ENUM ('en_route', 'running', 'paused', 'ended'); + +-- CreateEnum +CREATE TYPE "TimeEntryType" AS ENUM ('travel', 'work', 'break', 'material_procurement', 'return_travel', 'interruption'); + +-- CreateEnum +CREATE TYPE "ActivityNoteKind" AS ENUM ('work_done', 'deviation', 'problem', 'additional_work', 'not_executable', 'follow_up', 'recommendation', 'customer_note', 'general'); + +-- CreateEnum +CREATE TYPE "DocumentCategory" AS ENUM ('order_confirmation', 'technical_drawing', 'floor_plan', 'wiring_diagram', 'assembly_instructions', 'safety_document', 'product_document', 'customer_note', 'work_record', 'daily_report', 'completion_report', 'customer_approval', 'photo', 'voice_note', 'signature', 'other'); + +-- CreateEnum +CREATE TYPE "DocumentVisibility" AS ENUM ('backoffice_only', 'team_lead', 'team', 'customer_report'); + +-- CreateEnum +CREATE TYPE "UploadStatus" AS ENUM ('pending', 'uploaded', 'failed', 'quarantined'); + +-- CreateEnum +CREATE TYPE "PhotoPhase" AS ENUM ('before', 'during', 'after'); + +-- CreateEnum +CREATE TYPE "TranscriptionStatus" AS ENUM ('pending', 'done', 'failed', 'disabled'); + +-- CreateEnum +CREATE TYPE "ReportType" AS ENUM ('daily', 'completion'); + +-- CreateEnum +CREATE TYPE "ReportStatus" AS ENUM ('draft', 'submitted', 'team_approved', 'approved', 'rejected', 'superseded'); + +-- CreateEnum +CREATE TYPE "SignatureOutcome" AS ENUM ('signed', 'customer_absent', 'refused', 'later', 'not_required'); + +-- CreateEnum +CREATE TYPE "ImportStatus" AS ENUM ('uploaded', 'processing', 'review_required', 'confirmed', 'failed', 'discarded'); + +-- CreateEnum +CREATE TYPE "SyncOpStatus" AS ENUM ('applied', 'conflict', 'rejected'); + +-- CreateTable +CREATE TABLE "number_sequences" ( + "id" TEXT NOT NULL, + "tenant_id" TEXT NOT NULL, + "key" TEXT NOT NULL, + "prefix" TEXT NOT NULL DEFAULT '', + "next_value" INTEGER NOT NULL DEFAULT 1, + "padding" INTEGER NOT NULL DEFAULT 5, + "updated_at" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "number_sequences_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "order_types" ( + "id" TEXT NOT NULL, + "tenant_id" TEXT NOT NULL, + "key" TEXT NOT NULL, + "name" TEXT NOT NULL, + "active" BOOLEAN NOT NULL DEFAULT true, + "signature_required" BOOLEAN NOT NULL DEFAULT true, + "sort_order" INTEGER NOT NULL DEFAULT 0, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "order_types_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "checklist_templates" ( + "id" TEXT NOT NULL, + "tenant_id" TEXT NOT NULL, + "order_type_id" TEXT, + "name" TEXT NOT NULL, + "active" BOOLEAN NOT NULL DEFAULT true, + "items" JSONB NOT NULL DEFAULT '[]', + "required_photos" JSONB NOT NULL DEFAULT '[]', + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "checklist_templates_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "customers" ( + "id" TEXT NOT NULL, + "tenant_id" TEXT NOT NULL, + "customer_number" TEXT, + "company_name" TEXT, + "salutation" TEXT, + "first_name" TEXT, + "last_name" TEXT, + "street" TEXT, + "house_number" TEXT, + "postal_code" TEXT, + "city" TEXT, + "country" TEXT NOT NULL DEFAULT 'DE', + "phone" TEXT, + "mobile" TEXT, + "email" TEXT, + "notes" TEXT, + "billing_notes" TEXT, + "status" "CustomerStatus" NOT NULL DEFAULT 'active', + "is_provisional" BOOLEAN NOT NULL DEFAULT false, + "merged_into_id" TEXT, + "created_by_id" TEXT, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL, + "deleted_at" TIMESTAMP(3), + + CONSTRAINT "customers_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "contacts" ( + "id" TEXT NOT NULL, + "tenant_id" TEXT NOT NULL, + "customer_id" TEXT NOT NULL, + "name" TEXT NOT NULL, + "role" TEXT, + "phone" TEXT, + "mobile" TEXT, + "email" TEXT, + "preferred_channel" TEXT, + "notes" TEXT, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL, + "deleted_at" TIMESTAMP(3), + + CONSTRAINT "contacts_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "sites" ( + "id" TEXT NOT NULL, + "tenant_id" TEXT NOT NULL, + "customer_id" TEXT NOT NULL, + "name" TEXT NOT NULL, + "street" TEXT, + "house_number" TEXT, + "postal_code" TEXT, + "city" TEXT, + "country" TEXT NOT NULL DEFAULT 'DE', + "contact_id" TEXT, + "on_site_contact" TEXT, + "phone" TEXT, + "access_notes" TEXT, + "parking_notes" TEXT, + "safety_notes" TEXT, + "technical_notes" TEXT, + "status" "SiteStatus" NOT NULL DEFAULT 'active', + "latitude" DOUBLE PRECISION, + "longitude" DOUBLE PRECISION, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL, + "deleted_at" TIMESTAMP(3), + + CONSTRAINT "sites_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "teams" ( + "id" TEXT NOT NULL, + "tenant_id" TEXT NOT NULL, + "name" TEXT NOT NULL, + "leader_user_id" TEXT, + "status" "TeamStatus" NOT NULL DEFAULT 'active', + "phone" TEXT, + "vehicle" TEXT, + "area" TEXT, + "notes" TEXT, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL, + "deleted_at" TIMESTAMP(3), + + CONSTRAINT "teams_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "team_members" ( + "id" TEXT NOT NULL, + "tenant_id" TEXT NOT NULL, + "team_id" TEXT NOT NULL, + "user_id" TEXT NOT NULL, + "valid_from" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "valid_to" TIMESTAMP(3), + + CONSTRAINT "team_members_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "work_orders" ( + "id" TEXT NOT NULL, + "tenant_id" TEXT NOT NULL, + "number" TEXT NOT NULL, + "external_order_number" TEXT, + "offer_number" TEXT, + "customer_id" TEXT NOT NULL, + "site_id" TEXT, + "contact_id" TEXT, + "order_type_id" TEXT, + "priority" "WorkOrderPriority" NOT NULL DEFAULT 'normal', + "status" "WorkOrderStatus" NOT NULL DEFAULT 'draft', + "title" TEXT NOT NULL, + "description" TEXT, + "scope" TEXT, + "planned_start" TIMESTAMP(3), + "planned_end" TIMESTAMP(3), + "assigned_team_id" TEXT, + "team_lead_user_id" TEXT, + "signature_required" BOOLEAN NOT NULL DEFAULT true, + "billing_type" TEXT, + "internal_notes" TEXT, + "technician_notes" TEXT, + "is_emergency" BOOLEAN NOT NULL DEFAULT false, + "emergency_reason" TEXT, + "source_import_id" TEXT, + "follow_up_work" TEXT, + "version" INTEGER NOT NULL DEFAULT 1, + "created_by_id" TEXT, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL, + "deleted_at" TIMESTAMP(3), + + CONSTRAINT "work_orders_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "work_order_assignees" ( + "id" TEXT NOT NULL, + "tenant_id" TEXT NOT NULL, + "work_order_id" TEXT NOT NULL, + "user_id" TEXT NOT NULL, + + CONSTRAINT "work_order_assignees_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "work_order_status_changes" ( + "id" TEXT NOT NULL, + "tenant_id" TEXT NOT NULL, + "work_order_id" TEXT NOT NULL, + "from_status" "WorkOrderStatus", + "to_status" "WorkOrderStatus" NOT NULL, + "actor_id" TEXT, + "reason" TEXT, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "work_order_status_changes_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "checklist_items" ( + "id" TEXT NOT NULL, + "tenant_id" TEXT NOT NULL, + "work_order_id" TEXT NOT NULL, + "key" TEXT NOT NULL, + "label" TEXT NOT NULL, + "required" BOOLEAN NOT NULL DEFAULT false, + "requires_photo" BOOLEAN NOT NULL DEFAULT false, + "sort_order" INTEGER NOT NULL DEFAULT 0, + "checked" BOOLEAN NOT NULL DEFAULT false, + "checked_by_id" TEXT, + "checked_at" TIMESTAMP(3), + "comment" TEXT, + + CONSTRAINT "checklist_items_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "photo_requirements" ( + "id" TEXT NOT NULL, + "tenant_id" TEXT NOT NULL, + "work_order_id" TEXT NOT NULL, + "key" TEXT NOT NULL, + "label" TEXT NOT NULL, + "sort_order" INTEGER NOT NULL DEFAULT 0, + + CONSTRAINT "photo_requirements_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "material_plans" ( + "id" TEXT NOT NULL, + "tenant_id" TEXT NOT NULL, + "work_order_id" TEXT NOT NULL, + "name" TEXT NOT NULL, + "article_number" TEXT, + "planned_quantity" DECIMAL(12,3) NOT NULL, + "unit" TEXT NOT NULL, + "notes" TEXT, + "sort_order" INTEGER NOT NULL DEFAULT 0, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "material_plans_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "material_usages" ( + "id" TEXT NOT NULL, + "tenant_id" TEXT NOT NULL, + "work_order_id" TEXT NOT NULL, + "material_plan_id" TEXT, + "work_session_id" TEXT, + "name" TEXT NOT NULL, + "article_number" TEXT, + "actual_quantity" DECIMAL(12,3) NOT NULL, + "unit" TEXT NOT NULL, + "usage_status" "MaterialUsageStatus" NOT NULL, + "deviation_reason" TEXT, + "notes" TEXT, + "photo_id" TEXT, + "recorded_by_id" TEXT, + "client_id" TEXT, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "material_usages_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "work_sessions" ( + "id" TEXT NOT NULL, + "tenant_id" TEXT NOT NULL, + "work_order_id" TEXT NOT NULL, + "user_id" TEXT NOT NULL, + "team_id" TEXT, + "status" "WorkSessionStatus" NOT NULL DEFAULT 'running', + "started_at" TIMESTAMP(3) NOT NULL, + "ended_at" TIMESTAMP(3), + "start_lat" DOUBLE PRECISION, + "start_lng" DOUBLE PRECISION, + "started_offline" BOOLEAN NOT NULL DEFAULT false, + "device_info" TEXT, + "client_id" TEXT, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "work_sessions_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "time_entries" ( + "id" TEXT NOT NULL, + "tenant_id" TEXT NOT NULL, + "work_session_id" TEXT NOT NULL, + "user_id" TEXT NOT NULL, + "type" "TimeEntryType" NOT NULL DEFAULT 'work', + "started_at" TIMESTAMP(3) NOT NULL, + "ended_at" TIMESTAMP(3), + "corrected" BOOLEAN NOT NULL DEFAULT false, + "correction_reason" TEXT, + "corrected_by_id" TEXT, + "client_id" TEXT, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "time_entries_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "activity_notes" ( + "id" TEXT NOT NULL, + "tenant_id" TEXT NOT NULL, + "work_order_id" TEXT NOT NULL, + "author_id" TEXT, + "kind" "ActivityNoteKind" NOT NULL DEFAULT 'general', + "text" TEXT NOT NULL, + "voice_note_id" TEXT, + "client_id" TEXT, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL, + "deleted_at" TIMESTAMP(3), + + CONSTRAINT "activity_notes_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "documents" ( + "id" TEXT NOT NULL, + "tenant_id" TEXT NOT NULL, + "customer_id" TEXT, + "site_id" TEXT, + "work_order_id" TEXT, + "category" "DocumentCategory" NOT NULL, + "title" TEXT, + "file_name" TEXT NOT NULL, + "storage_key" TEXT NOT NULL, + "preview_key" TEXT, + "mime_type" TEXT NOT NULL, + "file_size" INTEGER NOT NULL, + "checksum" TEXT NOT NULL, + "version" INTEGER NOT NULL DEFAULT 1, + "lineage_id" TEXT NOT NULL, + "visibility" "DocumentVisibility" NOT NULL DEFAULT 'team', + "approval_status" TEXT, + "upload_status" "UploadStatus" NOT NULL DEFAULT 'uploaded', + "uploaded_by_id" TEXT, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL, + "deleted_at" TIMESTAMP(3), + + CONSTRAINT "documents_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "photos" ( + "id" TEXT NOT NULL, + "tenant_id" TEXT NOT NULL, + "work_order_id" TEXT NOT NULL, + "work_session_id" TEXT, + "document_id" TEXT NOT NULL, + "checklist_item_id" TEXT, + "photo_requirement_id" TEXT, + "phase" "PhotoPhase", + "comment" TEXT, + "taken_at" TIMESTAMP(3) NOT NULL, + "latitude" DOUBLE PRECISION, + "longitude" DOUBLE PRECISION, + "taken_by_id" TEXT, + "include_in_report" BOOLEAN NOT NULL DEFAULT true, + "client_id" TEXT, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "photos_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "voice_notes" ( + "id" TEXT NOT NULL, + "tenant_id" TEXT NOT NULL, + "work_order_id" TEXT NOT NULL, + "work_session_id" TEXT, + "document_id" TEXT NOT NULL, + "duration_seconds" INTEGER, + "transcript" TEXT, + "transcription_status" "TranscriptionStatus" NOT NULL DEFAULT 'pending', + "transcription_model" TEXT, + "recorded_by_id" TEXT, + "recorded_at" TIMESTAMP(3) NOT NULL, + "client_id" TEXT, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "voice_notes_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "reports" ( + "id" TEXT NOT NULL, + "tenant_id" TEXT NOT NULL, + "work_order_id" TEXT NOT NULL, + "type" "ReportType" NOT NULL, + "report_date" DATE NOT NULL, + "version" INTEGER NOT NULL DEFAULT 1, + "lineage_id" TEXT NOT NULL, + "status" "ReportStatus" NOT NULL DEFAULT 'draft', + "content" JSONB NOT NULL, + "ai_drafted" BOOLEAN NOT NULL DEFAULT false, + "ai_generation_id" TEXT, + "pdf_document_id" TEXT, + "pdf_checksum" TEXT, + "created_by_id" TEXT, + "submitted_at" TIMESTAMP(3), + "team_approved_by_id" TEXT, + "team_approved_at" TIMESTAMP(3), + "approved_by_id" TEXT, + "approved_at" TIMESTAMP(3), + "rejection_reason" TEXT, + "client_id" TEXT, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "reports_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "signatures" ( + "id" TEXT NOT NULL, + "tenant_id" TEXT NOT NULL, + "report_id" TEXT NOT NULL, + "outcome" "SignatureOutcome" NOT NULL, + "signer_name" TEXT, + "signer_role" TEXT, + "image_document_id" TEXT, + "confirmation_text" TEXT, + "reason" TEXT, + "signed_at" TIMESTAMP(3) NOT NULL, + "captured_by_id" TEXT, + "client_id" TEXT, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "signatures_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "import_jobs" ( + "id" TEXT NOT NULL, + "tenant_id" TEXT NOT NULL, + "document_id" TEXT NOT NULL, + "status" "ImportStatus" NOT NULL DEFAULT 'uploaded', + "error_message" TEXT, + "extracted_text" TEXT, + "extraction" JSONB, + "extraction_version" TEXT, + "extraction_model" TEXT, + "provider" TEXT, + "corrections" JSONB, + "duplicate_candidates" JSONB, + "imported_by_id" TEXT, + "confirmed_by_id" TEXT, + "confirmed_at" TIMESTAMP(3), + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "import_jobs_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "notifications" ( + "id" TEXT NOT NULL, + "tenant_id" TEXT NOT NULL, + "user_id" TEXT NOT NULL, + "type" TEXT NOT NULL, + "title" TEXT NOT NULL, + "message" TEXT NOT NULL, + "entity_type" TEXT, + "entity_id" TEXT, + "link" TEXT, + "read_at" TIMESTAMP(3), + "emailed_at" TIMESTAMP(3), + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "notifications_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "sync_operations" ( + "id" TEXT NOT NULL, + "tenant_id" TEXT NOT NULL, + "user_id" TEXT NOT NULL, + "client_op_id" TEXT NOT NULL, + "op_type" TEXT NOT NULL, + "entity_type" TEXT, + "entity_id" TEXT, + "base_version" INTEGER, + "payload" JSONB NOT NULL, + "status" "SyncOpStatus" NOT NULL, + "result" JSONB, + "error_code" TEXT, + "client_created_at" TIMESTAMP(3) NOT NULL, + "received_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "resolved_by_id" TEXT, + "resolved_at" TIMESTAMP(3), + + CONSTRAINT "sync_operations_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "ai_generations" ( + "id" TEXT NOT NULL, + "tenant_id" TEXT NOT NULL, + "kind" TEXT NOT NULL, + "provider" TEXT NOT NULL, + "model" TEXT NOT NULL, + "entity_type" TEXT, + "entity_id" TEXT, + "input" JSONB, + "output" JSONB, + "input_tokens" INTEGER, + "output_tokens" INTEGER, + "created_by_id" TEXT, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "ai_generations_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "number_sequences_tenant_id_key_key" ON "number_sequences"("tenant_id", "key"); + +-- CreateIndex +CREATE UNIQUE INDEX "order_types_tenant_id_key_key" ON "order_types"("tenant_id", "key"); + +-- CreateIndex +CREATE INDEX "checklist_templates_tenant_id_idx" ON "checklist_templates"("tenant_id"); + +-- CreateIndex +CREATE INDEX "customers_tenant_id_status_idx" ON "customers"("tenant_id", "status"); + +-- CreateIndex +CREATE INDEX "customers_tenant_id_company_name_idx" ON "customers"("tenant_id", "company_name"); + +-- CreateIndex +CREATE INDEX "customers_tenant_id_last_name_idx" ON "customers"("tenant_id", "last_name"); + +-- CreateIndex +CREATE UNIQUE INDEX "customers_tenant_id_customer_number_key" ON "customers"("tenant_id", "customer_number"); + +-- CreateIndex +CREATE INDEX "contacts_tenant_id_customer_id_idx" ON "contacts"("tenant_id", "customer_id"); + +-- CreateIndex +CREATE INDEX "sites_tenant_id_customer_id_idx" ON "sites"("tenant_id", "customer_id"); + +-- CreateIndex +CREATE UNIQUE INDEX "teams_tenant_id_name_key" ON "teams"("tenant_id", "name"); + +-- CreateIndex +CREATE INDEX "team_members_tenant_id_user_id_idx" ON "team_members"("tenant_id", "user_id"); + +-- CreateIndex +CREATE INDEX "team_members_team_id_idx" ON "team_members"("team_id"); + +-- CreateIndex +CREATE UNIQUE INDEX "work_orders_source_import_id_key" ON "work_orders"("source_import_id"); + +-- CreateIndex +CREATE INDEX "work_orders_tenant_id_status_idx" ON "work_orders"("tenant_id", "status"); + +-- CreateIndex +CREATE INDEX "work_orders_tenant_id_assigned_team_id_status_idx" ON "work_orders"("tenant_id", "assigned_team_id", "status"); + +-- CreateIndex +CREATE INDEX "work_orders_tenant_id_planned_start_idx" ON "work_orders"("tenant_id", "planned_start"); + +-- CreateIndex +CREATE INDEX "work_orders_tenant_id_customer_id_idx" ON "work_orders"("tenant_id", "customer_id"); + +-- CreateIndex +CREATE INDEX "work_orders_tenant_id_site_id_idx" ON "work_orders"("tenant_id", "site_id"); + +-- CreateIndex +CREATE UNIQUE INDEX "work_orders_tenant_id_number_key" ON "work_orders"("tenant_id", "number"); + +-- CreateIndex +CREATE INDEX "work_order_assignees_tenant_id_user_id_idx" ON "work_order_assignees"("tenant_id", "user_id"); + +-- CreateIndex +CREATE UNIQUE INDEX "work_order_assignees_work_order_id_user_id_key" ON "work_order_assignees"("work_order_id", "user_id"); + +-- CreateIndex +CREATE INDEX "work_order_status_changes_tenant_id_work_order_id_idx" ON "work_order_status_changes"("tenant_id", "work_order_id"); + +-- CreateIndex +CREATE INDEX "checklist_items_tenant_id_work_order_id_idx" ON "checklist_items"("tenant_id", "work_order_id"); + +-- CreateIndex +CREATE INDEX "photo_requirements_tenant_id_idx" ON "photo_requirements"("tenant_id"); + +-- CreateIndex +CREATE UNIQUE INDEX "photo_requirements_work_order_id_key_key" ON "photo_requirements"("work_order_id", "key"); + +-- CreateIndex +CREATE INDEX "material_plans_tenant_id_work_order_id_idx" ON "material_plans"("tenant_id", "work_order_id"); + +-- CreateIndex +CREATE UNIQUE INDEX "material_usages_client_id_key" ON "material_usages"("client_id"); + +-- CreateIndex +CREATE INDEX "material_usages_tenant_id_work_order_id_idx" ON "material_usages"("tenant_id", "work_order_id"); + +-- CreateIndex +CREATE UNIQUE INDEX "work_sessions_client_id_key" ON "work_sessions"("client_id"); + +-- CreateIndex +CREATE INDEX "work_sessions_tenant_id_work_order_id_idx" ON "work_sessions"("tenant_id", "work_order_id"); + +-- CreateIndex +CREATE INDEX "work_sessions_tenant_id_user_id_status_idx" ON "work_sessions"("tenant_id", "user_id", "status"); + +-- CreateIndex +CREATE UNIQUE INDEX "time_entries_client_id_key" ON "time_entries"("client_id"); + +-- CreateIndex +CREATE INDEX "time_entries_tenant_id_work_session_id_idx" ON "time_entries"("tenant_id", "work_session_id"); + +-- CreateIndex +CREATE INDEX "time_entries_tenant_id_user_id_started_at_idx" ON "time_entries"("tenant_id", "user_id", "started_at"); + +-- CreateIndex +CREATE UNIQUE INDEX "activity_notes_voice_note_id_key" ON "activity_notes"("voice_note_id"); + +-- CreateIndex +CREATE UNIQUE INDEX "activity_notes_client_id_key" ON "activity_notes"("client_id"); + +-- CreateIndex +CREATE INDEX "activity_notes_tenant_id_work_order_id_idx" ON "activity_notes"("tenant_id", "work_order_id"); + +-- CreateIndex +CREATE INDEX "documents_tenant_id_work_order_id_idx" ON "documents"("tenant_id", "work_order_id"); + +-- CreateIndex +CREATE INDEX "documents_tenant_id_site_id_idx" ON "documents"("tenant_id", "site_id"); + +-- CreateIndex +CREATE INDEX "documents_tenant_id_customer_id_idx" ON "documents"("tenant_id", "customer_id"); + +-- CreateIndex +CREATE UNIQUE INDEX "documents_lineage_id_version_key" ON "documents"("lineage_id", "version"); + +-- CreateIndex +CREATE UNIQUE INDEX "photos_document_id_key" ON "photos"("document_id"); + +-- CreateIndex +CREATE UNIQUE INDEX "photos_client_id_key" ON "photos"("client_id"); + +-- CreateIndex +CREATE INDEX "photos_tenant_id_work_order_id_idx" ON "photos"("tenant_id", "work_order_id"); + +-- CreateIndex +CREATE UNIQUE INDEX "voice_notes_document_id_key" ON "voice_notes"("document_id"); + +-- CreateIndex +CREATE UNIQUE INDEX "voice_notes_client_id_key" ON "voice_notes"("client_id"); + +-- CreateIndex +CREATE INDEX "voice_notes_tenant_id_work_order_id_idx" ON "voice_notes"("tenant_id", "work_order_id"); + +-- CreateIndex +CREATE UNIQUE INDEX "reports_pdf_document_id_key" ON "reports"("pdf_document_id"); + +-- CreateIndex +CREATE UNIQUE INDEX "reports_client_id_key" ON "reports"("client_id"); + +-- CreateIndex +CREATE INDEX "reports_tenant_id_work_order_id_idx" ON "reports"("tenant_id", "work_order_id"); + +-- CreateIndex +CREATE INDEX "reports_tenant_id_status_idx" ON "reports"("tenant_id", "status"); + +-- CreateIndex +CREATE UNIQUE INDEX "reports_lineage_id_version_key" ON "reports"("lineage_id", "version"); + +-- CreateIndex +CREATE UNIQUE INDEX "signatures_report_id_key" ON "signatures"("report_id"); + +-- CreateIndex +CREATE UNIQUE INDEX "signatures_image_document_id_key" ON "signatures"("image_document_id"); + +-- CreateIndex +CREATE UNIQUE INDEX "signatures_client_id_key" ON "signatures"("client_id"); + +-- CreateIndex +CREATE INDEX "signatures_tenant_id_idx" ON "signatures"("tenant_id"); + +-- CreateIndex +CREATE UNIQUE INDEX "import_jobs_document_id_key" ON "import_jobs"("document_id"); + +-- CreateIndex +CREATE INDEX "import_jobs_tenant_id_status_idx" ON "import_jobs"("tenant_id", "status"); + +-- CreateIndex +CREATE INDEX "notifications_tenant_id_user_id_read_at_idx" ON "notifications"("tenant_id", "user_id", "read_at"); + +-- CreateIndex +CREATE INDEX "sync_operations_tenant_id_status_idx" ON "sync_operations"("tenant_id", "status"); + +-- CreateIndex +CREATE UNIQUE INDEX "sync_operations_tenant_id_client_op_id_key" ON "sync_operations"("tenant_id", "client_op_id"); + +-- CreateIndex +CREATE INDEX "ai_generations_tenant_id_entity_type_entity_id_idx" ON "ai_generations"("tenant_id", "entity_type", "entity_id"); + +-- AddForeignKey +ALTER TABLE "checklist_templates" ADD CONSTRAINT "checklist_templates_order_type_id_fkey" FOREIGN KEY ("order_type_id") REFERENCES "order_types"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "contacts" ADD CONSTRAINT "contacts_customer_id_fkey" FOREIGN KEY ("customer_id") REFERENCES "customers"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "sites" ADD CONSTRAINT "sites_customer_id_fkey" FOREIGN KEY ("customer_id") REFERENCES "customers"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "sites" ADD CONSTRAINT "sites_contact_id_fkey" FOREIGN KEY ("contact_id") REFERENCES "contacts"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "teams" ADD CONSTRAINT "teams_leader_user_id_fkey" FOREIGN KEY ("leader_user_id") REFERENCES "users"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "team_members" ADD CONSTRAINT "team_members_team_id_fkey" FOREIGN KEY ("team_id") REFERENCES "teams"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "team_members" ADD CONSTRAINT "team_members_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "work_orders" ADD CONSTRAINT "work_orders_customer_id_fkey" FOREIGN KEY ("customer_id") REFERENCES "customers"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "work_orders" ADD CONSTRAINT "work_orders_site_id_fkey" FOREIGN KEY ("site_id") REFERENCES "sites"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "work_orders" ADD CONSTRAINT "work_orders_contact_id_fkey" FOREIGN KEY ("contact_id") REFERENCES "contacts"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "work_orders" ADD CONSTRAINT "work_orders_order_type_id_fkey" FOREIGN KEY ("order_type_id") REFERENCES "order_types"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "work_orders" ADD CONSTRAINT "work_orders_assigned_team_id_fkey" FOREIGN KEY ("assigned_team_id") REFERENCES "teams"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "work_orders" ADD CONSTRAINT "work_orders_team_lead_user_id_fkey" FOREIGN KEY ("team_lead_user_id") REFERENCES "users"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "work_orders" ADD CONSTRAINT "work_orders_source_import_id_fkey" FOREIGN KEY ("source_import_id") REFERENCES "import_jobs"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "work_order_assignees" ADD CONSTRAINT "work_order_assignees_work_order_id_fkey" FOREIGN KEY ("work_order_id") REFERENCES "work_orders"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "work_order_assignees" ADD CONSTRAINT "work_order_assignees_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "work_order_status_changes" ADD CONSTRAINT "work_order_status_changes_work_order_id_fkey" FOREIGN KEY ("work_order_id") REFERENCES "work_orders"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "checklist_items" ADD CONSTRAINT "checklist_items_work_order_id_fkey" FOREIGN KEY ("work_order_id") REFERENCES "work_orders"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "photo_requirements" ADD CONSTRAINT "photo_requirements_work_order_id_fkey" FOREIGN KEY ("work_order_id") REFERENCES "work_orders"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "material_plans" ADD CONSTRAINT "material_plans_work_order_id_fkey" FOREIGN KEY ("work_order_id") REFERENCES "work_orders"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "material_usages" ADD CONSTRAINT "material_usages_work_order_id_fkey" FOREIGN KEY ("work_order_id") REFERENCES "work_orders"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "material_usages" ADD CONSTRAINT "material_usages_material_plan_id_fkey" FOREIGN KEY ("material_plan_id") REFERENCES "material_plans"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "material_usages" ADD CONSTRAINT "material_usages_work_session_id_fkey" FOREIGN KEY ("work_session_id") REFERENCES "work_sessions"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "work_sessions" ADD CONSTRAINT "work_sessions_work_order_id_fkey" FOREIGN KEY ("work_order_id") REFERENCES "work_orders"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "work_sessions" ADD CONSTRAINT "work_sessions_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "time_entries" ADD CONSTRAINT "time_entries_work_session_id_fkey" FOREIGN KEY ("work_session_id") REFERENCES "work_sessions"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "activity_notes" ADD CONSTRAINT "activity_notes_work_order_id_fkey" FOREIGN KEY ("work_order_id") REFERENCES "work_orders"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "activity_notes" ADD CONSTRAINT "activity_notes_voice_note_id_fkey" FOREIGN KEY ("voice_note_id") REFERENCES "voice_notes"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "documents" ADD CONSTRAINT "documents_customer_id_fkey" FOREIGN KEY ("customer_id") REFERENCES "customers"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "documents" ADD CONSTRAINT "documents_site_id_fkey" FOREIGN KEY ("site_id") REFERENCES "sites"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "documents" ADD CONSTRAINT "documents_work_order_id_fkey" FOREIGN KEY ("work_order_id") REFERENCES "work_orders"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "photos" ADD CONSTRAINT "photos_work_order_id_fkey" FOREIGN KEY ("work_order_id") REFERENCES "work_orders"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "photos" ADD CONSTRAINT "photos_checklist_item_id_fkey" FOREIGN KEY ("checklist_item_id") REFERENCES "checklist_items"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "photos" ADD CONSTRAINT "photos_photo_requirement_id_fkey" FOREIGN KEY ("photo_requirement_id") REFERENCES "photo_requirements"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "voice_notes" ADD CONSTRAINT "voice_notes_work_order_id_fkey" FOREIGN KEY ("work_order_id") REFERENCES "work_orders"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "reports" ADD CONSTRAINT "reports_work_order_id_fkey" FOREIGN KEY ("work_order_id") REFERENCES "work_orders"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "signatures" ADD CONSTRAINT "signatures_report_id_fkey" FOREIGN KEY ("report_id") REFERENCES "reports"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "notifications" ADD CONSTRAINT "notifications_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE; + + +-- Row Level Security for all Craftvia tenant tables (mirror of TENANT_MODELS) +SELECT enable_tenant_rls('number_sequences'); +SELECT enable_tenant_rls('order_types'); +SELECT enable_tenant_rls('checklist_templates'); +SELECT enable_tenant_rls('customers'); +SELECT enable_tenant_rls('contacts'); +SELECT enable_tenant_rls('sites'); +SELECT enable_tenant_rls('teams'); +SELECT enable_tenant_rls('team_members'); +SELECT enable_tenant_rls('work_orders'); +SELECT enable_tenant_rls('work_order_assignees'); +SELECT enable_tenant_rls('work_order_status_changes'); +SELECT enable_tenant_rls('checklist_items'); +SELECT enable_tenant_rls('photo_requirements'); +SELECT enable_tenant_rls('material_plans'); +SELECT enable_tenant_rls('material_usages'); +SELECT enable_tenant_rls('work_sessions'); +SELECT enable_tenant_rls('time_entries'); +SELECT enable_tenant_rls('activity_notes'); +SELECT enable_tenant_rls('documents'); +SELECT enable_tenant_rls('photos'); +SELECT enable_tenant_rls('voice_notes'); +SELECT enable_tenant_rls('reports'); +SELECT enable_tenant_rls('signatures'); +SELECT enable_tenant_rls('import_jobs'); +SELECT enable_tenant_rls('notifications'); +SELECT enable_tenant_rls('sync_operations'); +SELECT enable_tenant_rls('ai_generations'); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 92331ac..0a65baf 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -144,6 +144,13 @@ model User { identity Identity @relation(fields: [identityId], references: [id]) userRoles UserRole[] + teamsLed Team[] @relation("TeamLeader") + teamMemberships TeamMember[] @relation("TeamMemberships") + workOrdersLed WorkOrder[] @relation("WorkOrderTeamLead") + workOrderAssignments WorkOrderAssignee[] @relation("WorkOrderAssignments") + workSessions WorkSession[] @relation("WorkSessionUser") + notifications Notification[] @relation("UserNotifications") + @@unique([tenantId, email]) // Eine Person (Identity) hat je Mandant höchstens EINE Mitgliedschaft. @@unique([tenantId, identityId]) @@ -422,3 +429,859 @@ model BackupJob { @@index([status]) @@map("backup_jobs") } + +// ============================================================================ +// Craftvia domain model (MVP) — appended to prisma/schema.prisma +// Conventions: camelCase fields @map snake_case, tables @@map plural snake_case. +// Every tenant table carries tenantId (plain column, guarded by dbForTenant + RLS) +// and is listed in BOTH TENANT_MODELS lists (src/server/db.ts, backup/topology.ts). +// Soft delete: deletedAt on business records (spec §27.5). +// ============================================================================ + +// ---------- Numbering / configuration ---------- + +model NumberSequence { + id String @id @default(cuid()) + tenantId String @map("tenant_id") + key String // "customer" | "work_order" | "report" | "emergency" + prefix String @default("") + nextValue Int @default(1) @map("next_value") + padding Int @default(5) + updatedAt DateTime @updatedAt @map("updated_at") + + @@unique([tenantId, key]) + @@map("number_sequences") +} + +model OrderType { + id String @id @default(cuid()) + tenantId String @map("tenant_id") + key String // montage | reparatur | wartung | stoerung | notdienst | besichtigung | abnahme | nacharbeit | custom + name String + active Boolean @default(true) + signatureRequired Boolean @default(true) @map("signature_required") + sortOrder Int @default(0) @map("sort_order") + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + + checklistTemplates ChecklistTemplate[] + workOrders WorkOrder[] + + @@unique([tenantId, key]) + @@map("order_types") +} + +model ChecklistTemplate { + id String @id @default(cuid()) + tenantId String @map("tenant_id") + orderTypeId String? @map("order_type_id") + name String + active Boolean @default(true) + // items: [{ key, label, required, requiresPhoto }] + items Json @default("[]") + // requiredPhotos: [{ key, label }] + requiredPhotos Json @default("[]") @map("required_photos") + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + + orderType OrderType? @relation(fields: [orderTypeId], references: [id], onDelete: SetNull) + + @@index([tenantId]) + @@map("checklist_templates") +} + +// ---------- Customers / contacts / sites ---------- + +enum CustomerStatus { + active + inactive + provisional // "Vorläufig – Prüfung durch Backoffice erforderlich" + merged +} + +model Customer { + id String @id @default(cuid()) + tenantId String @map("tenant_id") + customerNumber String? @map("customer_number") + companyName String? @map("company_name") + salutation String? + firstName String? @map("first_name") + lastName String? @map("last_name") + street String? + houseNumber String? @map("house_number") + postalCode String? @map("postal_code") + city String? + country String @default("DE") + phone String? + mobile String? + email String? + notes String? + billingNotes String? @map("billing_notes") + status CustomerStatus @default(active) + isProvisional Boolean @default(false) @map("is_provisional") + mergedIntoId String? @map("merged_into_id") + createdById String? @map("created_by_id") + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + deletedAt DateTime? @map("deleted_at") + + contacts Contact[] + sites Site[] + workOrders WorkOrder[] + documents Document[] + + @@unique([tenantId, customerNumber]) + @@index([tenantId, status]) + @@index([tenantId, companyName]) + @@index([tenantId, lastName]) + @@map("customers") +} + +model Contact { + id String @id @default(cuid()) + tenantId String @map("tenant_id") + customerId String @map("customer_id") + name String + role String? // Funktion + phone String? + mobile String? + email String? + preferredChannel String? @map("preferred_channel") // phone | mobile | email + notes String? + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + deletedAt DateTime? @map("deleted_at") + + customer Customer @relation(fields: [customerId], references: [id], onDelete: Cascade) + sites Site[] + workOrders WorkOrder[] + + @@index([tenantId, customerId]) + @@map("contacts") +} + +enum SiteStatus { + active + inactive + provisional +} + +model Site { + id String @id @default(cuid()) + tenantId String @map("tenant_id") + customerId String @map("customer_id") + name String + street String? + houseNumber String? @map("house_number") + postalCode String? @map("postal_code") + city String? + country String @default("DE") + contactId String? @map("contact_id") + onSiteContact String? @map("on_site_contact") // free text if no Contact + phone String? + accessNotes String? @map("access_notes") + parkingNotes String? @map("parking_notes") + safetyNotes String? @map("safety_notes") + technicalNotes String? @map("technical_notes") + status SiteStatus @default(active) + latitude Float? + longitude Float? + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + deletedAt DateTime? @map("deleted_at") + + customer Customer @relation(fields: [customerId], references: [id], onDelete: Restrict) + contact Contact? @relation(fields: [contactId], references: [id], onDelete: SetNull) + workOrders WorkOrder[] + documents Document[] + + @@index([tenantId, customerId]) + @@map("sites") +} + +// ---------- Teams ---------- + +enum TeamStatus { + active + inactive +} + +model Team { + id String @id @default(cuid()) + tenantId String @map("tenant_id") + name String + leaderUserId String? @map("leader_user_id") + status TeamStatus @default(active) + phone String? + vehicle String? + area String? + notes String? + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + deletedAt DateTime? @map("deleted_at") + + leader User? @relation("TeamLeader", fields: [leaderUserId], references: [id], onDelete: SetNull) + members TeamMember[] + workOrders WorkOrder[] + + @@unique([tenantId, name]) + @@map("teams") +} + +model TeamMember { + id String @id @default(cuid()) + tenantId String @map("tenant_id") + teamId String @map("team_id") + userId String @map("user_id") + validFrom DateTime @default(now()) @map("valid_from") + validTo DateTime? @map("valid_to") + + team Team @relation(fields: [teamId], references: [id], onDelete: Cascade) + user User @relation("TeamMemberships", fields: [userId], references: [id], onDelete: Cascade) + + @@index([tenantId, userId]) + @@index([teamId]) + @@map("team_members") +} + +// ---------- Work orders ---------- + +enum WorkOrderStatus { + draft + review_required + planned + assigned + accepted + en_route + in_progress + paused + waiting_material + daily_report_created + technically_completed + signature_pending + in_review + released_for_billing + billed + cancelled +} + +enum WorkOrderPriority { + low + normal + high + urgent +} + +model WorkOrder { + id String @id @default(cuid()) + tenantId String @map("tenant_id") + number String // internal, from NumberSequence "work_order" + externalOrderNumber String? @map("external_order_number") + offerNumber String? @map("offer_number") + customerId String @map("customer_id") + siteId String? @map("site_id") + contactId String? @map("contact_id") + orderTypeId String? @map("order_type_id") + priority WorkOrderPriority @default(normal) + status WorkOrderStatus @default(draft) + title String + description String? + scope String? // Leistungsumfang + plannedStart DateTime? @map("planned_start") + plannedEnd DateTime? @map("planned_end") + assignedTeamId String? @map("assigned_team_id") + teamLeadUserId String? @map("team_lead_user_id") + signatureRequired Boolean @default(true) @map("signature_required") + billingType String? @map("billing_type") // fixed | time_material | maintenance_contract | warranty + internalNotes String? @map("internal_notes") + technicianNotes String? @map("technician_notes") + isEmergency Boolean @default(false) @map("is_emergency") + emergencyReason String? @map("emergency_reason") + sourceImportId String? @unique @map("source_import_id") + followUpWork String? @map("follow_up_work") // offene Folgearbeiten (aggregiert) + // incremented on every mutation — used by offline sync conflict detection + version Int @default(1) + createdById String? @map("created_by_id") + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + deletedAt DateTime? @map("deleted_at") + + customer Customer @relation(fields: [customerId], references: [id], onDelete: Restrict) + site Site? @relation(fields: [siteId], references: [id], onDelete: SetNull) + contact Contact? @relation(fields: [contactId], references: [id], onDelete: SetNull) + orderType OrderType? @relation(fields: [orderTypeId], references: [id], onDelete: SetNull) + team Team? @relation(fields: [assignedTeamId], references: [id], onDelete: SetNull) + teamLead User? @relation("WorkOrderTeamLead", fields: [teamLeadUserId], references: [id], onDelete: SetNull) + sourceImport ImportJob? @relation("ImportCreatedOrder", fields: [sourceImportId], references: [id], onDelete: SetNull) + + assignees WorkOrderAssignee[] + statusHistory WorkOrderStatusChange[] + checklistItems ChecklistItem[] + photoRequirements PhotoRequirement[] + materialPlans MaterialPlan[] + materialUsages MaterialUsage[] + workSessions WorkSession[] + notes ActivityNote[] + photos Photo[] + voiceNotes VoiceNote[] + reports Report[] + documents Document[] + + @@unique([tenantId, number]) + @@index([tenantId, status]) + @@index([tenantId, assignedTeamId, status]) + @@index([tenantId, plannedStart]) + @@index([tenantId, customerId]) + @@index([tenantId, siteId]) + @@map("work_orders") +} + +model WorkOrderAssignee { + id String @id @default(cuid()) + tenantId String @map("tenant_id") + workOrderId String @map("work_order_id") + userId String @map("user_id") + + workOrder WorkOrder @relation(fields: [workOrderId], references: [id], onDelete: Cascade) + user User @relation("WorkOrderAssignments", fields: [userId], references: [id], onDelete: Cascade) + + @@unique([workOrderId, userId]) + @@index([tenantId, userId]) + @@map("work_order_assignees") +} + +model WorkOrderStatusChange { + id String @id @default(cuid()) + tenantId String @map("tenant_id") + workOrderId String @map("work_order_id") + fromStatus WorkOrderStatus? @map("from_status") + toStatus WorkOrderStatus @map("to_status") + actorId String? @map("actor_id") + reason String? + createdAt DateTime @default(now()) @map("created_at") + + workOrder WorkOrder @relation(fields: [workOrderId], references: [id], onDelete: Cascade) + + @@index([tenantId, workOrderId]) + @@map("work_order_status_changes") +} + +model ChecklistItem { + id String @id @default(cuid()) + tenantId String @map("tenant_id") + workOrderId String @map("work_order_id") + key String + label String + required Boolean @default(false) + requiresPhoto Boolean @default(false) @map("requires_photo") + sortOrder Int @default(0) @map("sort_order") + checked Boolean @default(false) + checkedById String? @map("checked_by_id") + checkedAt DateTime? @map("checked_at") + comment String? + + workOrder WorkOrder @relation(fields: [workOrderId], references: [id], onDelete: Cascade) + photos Photo[] + + @@index([tenantId, workOrderId]) + @@map("checklist_items") +} + +model PhotoRequirement { + id String @id @default(cuid()) + tenantId String @map("tenant_id") + workOrderId String @map("work_order_id") + key String // ausgangszustand | typenschild | leitungsverlauf | zwischenschritt | fertige_montage | funktionspruefung | arbeitsbereich_abschluss | custom + label String + sortOrder Int @default(0) @map("sort_order") + + workOrder WorkOrder @relation(fields: [workOrderId], references: [id], onDelete: Cascade) + photos Photo[] + + @@unique([workOrderId, key]) + @@index([tenantId]) + @@map("photo_requirements") +} + +// ---------- Materials ---------- + +model MaterialPlan { + id String @id @default(cuid()) + tenantId String @map("tenant_id") + workOrderId String @map("work_order_id") + name String + articleNumber String? @map("article_number") + plannedQuantity Decimal @map("planned_quantity") @db.Decimal(12, 3) + unit String + notes String? + sortOrder Int @default(0) @map("sort_order") + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + + workOrder WorkOrder @relation(fields: [workOrderId], references: [id], onDelete: Cascade) + usages MaterialUsage[] + + @@index([tenantId, workOrderId]) + @@map("material_plans") +} + +enum MaterialUsageStatus { + fully_used + partially_used + not_used + additional +} + +model MaterialUsage { + id String @id @default(cuid()) + tenantId String @map("tenant_id") + workOrderId String @map("work_order_id") + materialPlanId String? @map("material_plan_id") + workSessionId String? @map("work_session_id") + name String + articleNumber String? @map("article_number") + actualQuantity Decimal @map("actual_quantity") @db.Decimal(12, 3) + unit String + usageStatus MaterialUsageStatus @map("usage_status") + deviationReason String? @map("deviation_reason") + notes String? + photoId String? @map("photo_id") + recordedById String? @map("recorded_by_id") + clientId String? @unique @map("client_id") // offline local id + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + + workOrder WorkOrder @relation(fields: [workOrderId], references: [id], onDelete: Cascade) + materialPlan MaterialPlan? @relation(fields: [materialPlanId], references: [id], onDelete: SetNull) + workSession WorkSession? @relation(fields: [workSessionId], references: [id], onDelete: SetNull) + + @@index([tenantId, workOrderId]) + @@map("material_usages") +} + +// ---------- Field execution ---------- + +enum WorkSessionStatus { + en_route + running + paused + ended +} + +model WorkSession { + id String @id @default(cuid()) + tenantId String @map("tenant_id") + workOrderId String @map("work_order_id") + userId String @map("user_id") + teamId String? @map("team_id") + status WorkSessionStatus @default(running) + startedAt DateTime @map("started_at") + endedAt DateTime? @map("ended_at") + startLat Float? @map("start_lat") + startLng Float? @map("start_lng") + startedOffline Boolean @default(false) @map("started_offline") + deviceInfo String? @map("device_info") + clientId String? @unique @map("client_id") + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + + workOrder WorkOrder @relation(fields: [workOrderId], references: [id], onDelete: Cascade) + user User @relation("WorkSessionUser", fields: [userId], references: [id], onDelete: Restrict) + entries TimeEntry[] + materials MaterialUsage[] + + @@index([tenantId, workOrderId]) + @@index([tenantId, userId, status]) + @@map("work_sessions") +} + +enum TimeEntryType { + travel + work + break + material_procurement + return_travel + interruption +} + +model TimeEntry { + id String @id @default(cuid()) + tenantId String @map("tenant_id") + workSessionId String @map("work_session_id") + userId String @map("user_id") + type TimeEntryType @default(work) + startedAt DateTime @map("started_at") + endedAt DateTime? @map("ended_at") + // manual corrections must be audited (spec §12.2) + corrected Boolean @default(false) + correctionReason String? @map("correction_reason") + correctedById String? @map("corrected_by_id") + clientId String? @unique @map("client_id") + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + + workSession WorkSession @relation(fields: [workSessionId], references: [id], onDelete: Cascade) + + @@index([tenantId, workSessionId]) + @@index([tenantId, userId, startedAt]) + @@map("time_entries") +} + +enum ActivityNoteKind { + work_done + deviation + problem + additional_work + not_executable + follow_up + recommendation + customer_note + general +} + +model ActivityNote { + id String @id @default(cuid()) + tenantId String @map("tenant_id") + workOrderId String @map("work_order_id") + authorId String? @map("author_id") + kind ActivityNoteKind @default(general) + text String + voiceNoteId String? @unique @map("voice_note_id") + clientId String? @unique @map("client_id") + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + deletedAt DateTime? @map("deleted_at") + + workOrder WorkOrder @relation(fields: [workOrderId], references: [id], onDelete: Cascade) + voiceNote VoiceNote? @relation(fields: [voiceNoteId], references: [id], onDelete: SetNull) + + @@index([tenantId, workOrderId]) + @@map("activity_notes") +} + +// ---------- Files ---------- + +enum DocumentCategory { + order_confirmation + technical_drawing + floor_plan + wiring_diagram + assembly_instructions + safety_document + product_document + customer_note + work_record + daily_report + completion_report + customer_approval + photo + voice_note + signature + other +} + +enum DocumentVisibility { + backoffice_only + team_lead + team + customer_report +} + +enum UploadStatus { + pending + uploaded + failed + quarantined +} + +model Document { + id String @id @default(cuid()) + tenantId String @map("tenant_id") + customerId String? @map("customer_id") + siteId String? @map("site_id") + workOrderId String? @map("work_order_id") + category DocumentCategory + title String? + fileName String @map("file_name") + storageKey String @map("storage_key") // /... + previewKey String? @map("preview_key") // thumbnail + mimeType String @map("mime_type") + fileSize Int @map("file_size") + checksum String // sha256 hex + version Int @default(1) + // groups versions of the same logical document + lineageId String @map("lineage_id") + visibility DocumentVisibility @default(team) + approvalStatus String? @map("approval_status") // draft | approved + uploadStatus UploadStatus @default(uploaded) @map("upload_status") + uploadedById String? @map("uploaded_by_id") + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + deletedAt DateTime? @map("deleted_at") + + customer Customer? @relation(fields: [customerId], references: [id], onDelete: SetNull) + site Site? @relation(fields: [siteId], references: [id], onDelete: SetNull) + workOrder WorkOrder? @relation(fields: [workOrderId], references: [id], onDelete: SetNull) + + @@unique([lineageId, version]) + @@index([tenantId, workOrderId]) + @@index([tenantId, siteId]) + @@index([tenantId, customerId]) + @@map("documents") +} + +enum PhotoPhase { + before + during + after +} + +model Photo { + id String @id @default(cuid()) + tenantId String @map("tenant_id") + workOrderId String @map("work_order_id") + workSessionId String? @map("work_session_id") + documentId String @unique @map("document_id") + checklistItemId String? @map("checklist_item_id") + photoRequirementId String? @map("photo_requirement_id") + phase PhotoPhase? + comment String? + takenAt DateTime @map("taken_at") + latitude Float? + longitude Float? + takenById String? @map("taken_by_id") + includeInReport Boolean @default(true) @map("include_in_report") + clientId String? @unique @map("client_id") + createdAt DateTime @default(now()) @map("created_at") + + workOrder WorkOrder @relation(fields: [workOrderId], references: [id], onDelete: Cascade) + checklistItem ChecklistItem? @relation(fields: [checklistItemId], references: [id], onDelete: SetNull) + photoRequirement PhotoRequirement? @relation(fields: [photoRequirementId], references: [id], onDelete: SetNull) + + @@index([tenantId, workOrderId]) + @@map("photos") +} + +enum TranscriptionStatus { + pending + done + failed + disabled +} + +model VoiceNote { + id String @id @default(cuid()) + tenantId String @map("tenant_id") + workOrderId String @map("work_order_id") + workSessionId String? @map("work_session_id") + documentId String @unique @map("document_id") + durationSeconds Int? @map("duration_seconds") + transcript String? + transcriptionStatus TranscriptionStatus @default(pending) @map("transcription_status") + transcriptionModel String? @map("transcription_model") + recordedById String? @map("recorded_by_id") + recordedAt DateTime @map("recorded_at") + clientId String? @unique @map("client_id") + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + + workOrder WorkOrder @relation(fields: [workOrderId], references: [id], onDelete: Cascade) + activityNote ActivityNote? + + @@index([tenantId, workOrderId]) + @@map("voice_notes") +} + +// ---------- Reports & signatures ---------- + +enum ReportType { + daily + completion +} + +enum ReportStatus { + draft // incl. Lotse-Entwurf + submitted // by technician, "Zur Prüfung" + team_approved + approved // final → immutable PDF + rejected // correction requested + superseded +} + +model Report { + id String @id @default(cuid()) + tenantId String @map("tenant_id") + workOrderId String @map("work_order_id") + type ReportType + reportDate DateTime @map("report_date") @db.Date + version Int @default(1) + // groups versions of the same report + lineageId String @map("lineage_id") + status ReportStatus @default(draft) + // structured snapshot (see src/lib/reports/content.ts ReportContent) + content Json + aiDrafted Boolean @default(false) @map("ai_drafted") + aiGenerationId String? @map("ai_generation_id") + pdfDocumentId String? @unique @map("pdf_document_id") + pdfChecksum String? @map("pdf_checksum") + createdById String? @map("created_by_id") + submittedAt DateTime? @map("submitted_at") + teamApprovedById String? @map("team_approved_by_id") + teamApprovedAt DateTime? @map("team_approved_at") + approvedById String? @map("approved_by_id") + approvedAt DateTime? @map("approved_at") + rejectionReason String? @map("rejection_reason") + clientId String? @unique @map("client_id") + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + + workOrder WorkOrder @relation(fields: [workOrderId], references: [id], onDelete: Restrict) + signature Signature? + + @@unique([lineageId, version]) + @@index([tenantId, workOrderId]) + @@index([tenantId, status]) + @@map("reports") +} + +enum SignatureOutcome { + signed + customer_absent + refused + later + not_required +} + +model Signature { + id String @id @default(cuid()) + tenantId String @map("tenant_id") + reportId String @unique @map("report_id") + outcome SignatureOutcome + signerName String? @map("signer_name") + signerRole String? @map("signer_role") + imageDocumentId String? @unique @map("image_document_id") // PNG of the drawn signature + confirmationText String? @map("confirmation_text") + reason String? // required for absent/refused/later + signedAt DateTime @map("signed_at") + capturedById String? @map("captured_by_id") + clientId String? @unique @map("client_id") + createdAt DateTime @default(now()) @map("created_at") + + report Report @relation(fields: [reportId], references: [id], onDelete: Cascade) + + @@index([tenantId]) + @@map("signatures") +} + +// ---------- PDF import ---------- + +enum ImportStatus { + uploaded + processing + review_required // "Entwurf – Prüfung erforderlich" + confirmed + failed + discarded +} + +model ImportJob { + id String @id @default(cuid()) + tenantId String @map("tenant_id") + documentId String @unique @map("document_id") // original file (kept forever) + status ImportStatus @default(uploaded) + errorMessage String? @map("error_message") + extractedText String? @map("extracted_text") + // [{ field, value, confidence }] + positions — see src/lib/imports/extraction.ts + extraction Json? + extractionVersion String? @map("extraction_version") + extractionModel String? @map("extraction_model") + provider String? + // user corrections: { field: { from, to } } + corrections Json? + // duplicate candidates: [{ customerId, score, reasons[] }] + duplicateCandidates Json? @map("duplicate_candidates") + importedById String? @map("imported_by_id") + confirmedById String? @map("confirmed_by_id") + confirmedAt DateTime? @map("confirmed_at") + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + + createdWorkOrder WorkOrder? @relation("ImportCreatedOrder") + + @@index([tenantId, status]) + @@map("import_jobs") +} + +// ---------- Notifications ---------- + +model Notification { + id String @id @default(cuid()) + tenantId String @map("tenant_id") + userId String @map("user_id") + type String // see src/lib/events.ts EVENT_TYPES + title String + message String + entityType String? @map("entity_type") + entityId String? @map("entity_id") + link String? + readAt DateTime? @map("read_at") + emailedAt DateTime? @map("emailed_at") + createdAt DateTime @default(now()) @map("created_at") + + user User @relation("UserNotifications", fields: [userId], references: [id], onDelete: Cascade) + + @@index([tenantId, userId, readAt]) + @@map("notifications") +} + +// ---------- Offline sync ---------- + +enum SyncOpStatus { + applied + conflict + rejected +} + +model SyncOperation { + id String @id @default(cuid()) + tenantId String @map("tenant_id") + userId String @map("user_id") + clientOpId String @map("client_op_id") // idempotency key from device + opType String @map("op_type") + entityType String? @map("entity_type") + entityId String? @map("entity_id") + baseVersion Int? @map("base_version") + payload Json + status SyncOpStatus + result Json? + errorCode String? @map("error_code") + clientCreatedAt DateTime @map("client_created_at") + receivedAt DateTime @default(now()) @map("received_at") + resolvedById String? @map("resolved_by_id") + resolvedAt DateTime? @map("resolved_at") + + @@unique([tenantId, clientOpId]) + @@index([tenantId, status]) + @@map("sync_operations") +} + +// ---------- Lotse (AI) ---------- + +model AiGeneration { + id String @id @default(cuid()) + tenantId String @map("tenant_id") + kind String // report_draft | voice_summary | completeness_check | import_extraction + provider String + model String + entityType String? @map("entity_type") + entityId String? @map("entity_id") + input Json? + output Json? + inputTokens Int? @map("input_tokens") + outputTokens Int? @map("output_tokens") + createdById String? @map("created_by_id") + createdAt DateTime @default(now()) @map("created_at") + + @@index([tenantId, entityType, entityId]) + @@map("ai_generations") +} diff --git a/scripts/craftvia-worker.ts b/scripts/craftvia-worker.ts new file mode 100644 index 0000000..f4395a0 --- /dev/null +++ b/scripts/craftvia-worker.ts @@ -0,0 +1,39 @@ +import "dotenv/config"; +import { Worker } from "bullmq"; +import { JOB_QUEUES, workerConnection, closeJobQueues, 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. */ +async function main() { + const connection = workerConnection(); + if (!connection) { + console.error("[worker] REDIS_URL is not set — nothing to do."); + process.exit(1); + } + const workers: Worker[] = []; + for (const name of Object.values(JOB_QUEUES)) { + const load = PROCESSORS[name]; + if (!load) { + console.warn(`[worker] no processor registered for ${name} — skipped`); + continue; + } + const processor = await load(); + const concurrency = name === JOB_QUEUES.reportPdf ? 2 : 4; + const w = new Worker(name, async (job) => processor(job.data), { connection, concurrency }); + w.on("failed", (job, err) => console.error(`[worker] ${name} job ${job?.id} failed:`, err.message)); + workers.push(w); + console.info(`[worker] listening on ${name}`); + } + const shutdown = async () => { + await Promise.all(workers.map((w) => w.close())); + await closeJobQueues(); + process.exit(0); + }; + process.on("SIGTERM", shutdown); + process.on("SIGINT", shutdown); +} + +main().catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/src/lib/events.ts b/src/lib/events.ts new file mode 100644 index 0000000..0a05092 --- /dev/null +++ b/src/lib/events.ts @@ -0,0 +1,32 @@ +// Domain event catalogue (client-safe). Lanes emit via src/server/events.ts#emitEvent; +// the notifications lane maps events to recipients, in-app notifications and e-mails. + +export const EVENT_TYPES = [ + "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", + "work_order.missing_required", + "work_order.released_for_billing", + "report.submitted", + "report.approved", + "report.rejected", + "emergency.created", + "emergency.completed", + "import.ready_for_review", + "import.failed", + "sync.failed", +] as const; + +export type EventType = (typeof EVENT_TYPES)[number]; + +export type DomainEvent = { + type: EventType; + entityType: "work_order" | "report" | "import_job" | "sync_operation"; + entityId: string; + /** Short, human-readable facts for templates (no PII beyond what the recipient may see). */ + data?: Record; +}; diff --git a/src/lib/sync/envelope.ts b/src/lib/sync/envelope.ts new file mode 100644 index 0000000..0b3d2c1 --- /dev/null +++ b/src/lib/sync/envelope.ts @@ -0,0 +1,57 @@ +import { z } from "zod"; + +/** + * Offline sync envelope (ARCHITEKTUR §4.6). The per-op payload schemas live in + * src/lib/sync/ops.ts (owned by lane field); this file only fixes the wire format. + */ + +export const SYNC_OP_TYPES = [ + "session.start", + "session.pause", + "session.resume", + "session.end", + "work_order.transition", + "note.create", + "checklist.toggle", + "material.upsert", + "photo.attach", + "voice.attach", + "report.save_draft", + "report.submit", + "signature.capture", + "emergency.create", +] as const; + +export type SyncOpType = (typeof SYNC_OP_TYPES)[number]; + +/** Ops that compare WorkOrder.version and may produce a conflict. All others are additive. */ +export const CONFLICTING_OPS: readonly SyncOpType[] = ["work_order.transition", "report.submit"]; + +export const syncOperationSchema = z.object({ + clientOpId: z.string().uuid(), + opType: z.enum(SYNC_OP_TYPES), + entityType: z.string().max(40).optional(), + entityId: z.string().max(64).optional(), + baseVersion: z.number().int().positive().optional(), + payload: z.record(z.string(), z.unknown()), + clientCreatedAt: z.string().datetime(), +}); + +export type SyncOperationInput = z.infer; + +export const syncRequestSchema = z.object({ + deviceId: z.string().max(64), + operations: z.array(syncOperationSchema).min(1).max(100), +}); + +export type SyncOpResult = { + clientOpId: string; + status: "applied" | "duplicate" | "conflict" | "rejected"; + /** server ids created by the op, keyed by the client id they replace */ + idMap?: Record; + entityVersion?: number; + errorCode?: "not_found" | "forbidden" | "invalid" | "conflict" | "blocked" | "internal"; + message?: string; +}; + +export type SyncResponse = { results: SyncOpResult[]; serverTime: string }; diff --git a/src/lib/work-orders/status.ts b/src/lib/work-orders/status.ts new file mode 100644 index 0000000..8e36283 --- /dev/null +++ b/src/lib/work-orders/status.ts @@ -0,0 +1,129 @@ +// Work order status machine (client-safe: no server imports). +// Server enforcement lives in src/server/services/work-orders/transition.ts, +// which MUST call canTransition()/requiredPermission() — never update status directly. + +export const WORK_ORDER_STATUSES = [ + "draft", + "review_required", + "planned", + "assigned", + "accepted", + "en_route", + "in_progress", + "paused", + "waiting_material", + "daily_report_created", + "technically_completed", + "signature_pending", + "in_review", + "released_for_billing", + "billed", + "cancelled", +] as const; + +export type WorkOrderStatus = (typeof WORK_ORDER_STATUSES)[number]; + +const T: Record = { + draft: ["review_required", "planned", "assigned", "cancelled"], + review_required: ["planned", "assigned", "cancelled"], + planned: ["assigned", "cancelled"], + assigned: ["accepted", "en_route", "in_progress", "planned", "cancelled"], + accepted: ["en_route", "in_progress", "assigned", "cancelled"], + en_route: ["in_progress", "cancelled"], + in_progress: ["paused", "waiting_material", "daily_report_created", "technically_completed", "cancelled"], + paused: ["in_progress", "en_route", "cancelled"], + waiting_material: ["in_progress", "en_route", "cancelled"], + daily_report_created: ["en_route", "in_progress", "cancelled"], + technically_completed: ["signature_pending", "in_review", "in_progress", "cancelled"], + signature_pending: ["in_review", "cancelled"], + in_review: ["released_for_billing", "in_progress", "cancelled"], + released_for_billing: ["billed", "in_review"], + billed: [], + cancelled: [], +}; + +export function allowedTransitions(from: WorkOrderStatus): readonly WorkOrderStatus[] { + return T[from]; +} + +export function canTransition(from: WorkOrderStatus, to: WorkOrderStatus): boolean { + return T[from].includes(to); +} + +/** Permission required for a transition (server additionally checks work order scope for field roles). */ +export function requiredPermission(from: WorkOrderStatus, to: WorkOrderStatus): string { + if (to === "cancelled") return "work_order:cancel"; + if (to === "released_for_billing" || to === "billed") return "work_order:release_billing"; + if (from === "released_for_billing" && to === "in_review") return "work_order:release_billing"; + if (from === "in_review" && to === "in_progress") return "report:approve_team"; // or report:approve (checked server-side) + if (["draft", "review_required", "planned"].includes(from)) return to === "assigned" ? "work_order:assign" : "work_order:write"; + if (from === "assigned" && to === "planned") return "work_order:assign"; + return "field:execute"; +} + +/** Statuses in which field users may record time/material/photos/notes. */ +export const FIELD_EDITABLE: readonly WorkOrderStatus[] = [ + "assigned", + "accepted", + "en_route", + "in_progress", + "paused", + "waiting_material", + "daily_report_created", + "technically_completed", + "signature_pending", +]; + +export const OPEN_STATUSES: readonly WorkOrderStatus[] = WORK_ORDER_STATUSES.filter( + (s) => !["billed", "cancelled", "released_for_billing"].includes(s), +); + +/** UI groups per Brandbook §12.3 — label keys live in messages//workOrders.json → statusGroup.. */ +export const STATUS_GROUP: Record = { + draft: "new", + review_required: "new", + planned: "planned", + assigned: "planned", + accepted: "planned", + en_route: "en_route", + in_progress: "in_progress", + paused: "in_progress", + waiting_material: "in_progress", + daily_report_created: "in_progress", + technically_completed: "documentation_incomplete", + signature_pending: "documentation_incomplete", + in_review: "in_review", + released_for_billing: "ready_for_billing", + billed: "billed", + cancelled: "cancelled", +}; + +export type StatusGroup = + | "new" + | "planned" + | "en_route" + | "in_progress" + | "documentation_incomplete" + | "in_review" + | "ready_for_billing" + | "billed" + | "cancelled"; + +/** Semantic tone for badges (always combined with text, Brandbook §11.4). */ +export const STATUS_GROUP_TONE: Record = { + new: "neutral", + planned: "info", + en_route: "accent", + in_progress: "accent", + documentation_incomplete: "warning", + in_review: "info", + ready_for_billing: "success", + billed: "success", + cancelled: "danger", +}; + +export type CompletionBlocker = + | { kind: "checklist_item"; itemId: string; label: string } + | { kind: "photo_requirement"; requirementId: string; label: string } + | { kind: "running_session"; sessionId: string; userId: string } + | { kind: "missing_field"; field: string }; diff --git a/src/server/action-guard.ts b/src/server/action-guard.ts index b27d942..1f48cd4 100644 --- a/src/server/action-guard.ts +++ b/src/server/action-guard.ts @@ -73,18 +73,18 @@ export function moduleGuard(moduleKey: string) { } if (identity.mustChangePassword) throw new Error("Passwortwechsel erforderlich."); - if (permissions.length > 0) { - const effective = new Set( - account.userRoles.flatMap((ur) => - ur.role.rolePermissions.map((rp) => rp.permission.key), - ), - ); - for (const permission of permissions) { - if (!effective.has(permission)) throw new ForbiddenError(permission); - } + const effective = new Set( + account.userRoles.flatMap((ur) => + ur.role.rolePermissions.map((rp) => rp.permission.key), + ), + ); + for (const permission of permissions) { + if (!effective.has(permission)) throw new ForbiddenError(permission); } await assertModuleEnabled(session, moduleKey); - return { session, db }; + // `permissions` = DB-authoritative effective set; domain services derive their + // scope decisions from it (src/server/services/context.ts#ctxFromGuard). + return { session, db, permissions: effective as ReadonlySet }; }; } diff --git a/src/server/ai/providers.ts b/src/server/ai/providers.ts new file mode 100644 index 0000000..62d2018 --- /dev/null +++ b/src/server/ai/providers.ts @@ -0,0 +1,85 @@ +// Provider abstraction for AI/OCR (spec §31). Concrete implementations: +// src/server/ai/extraction/anthropic.ts (imports lane) +// src/server/ai/transcription/openai-compatible.ts (lotse lane) +// src/server/ai/lotse/anthropic.ts (lotse lane) +// Every call must be recorded as AiGeneration. Without configuration the getters return null +// and callers degrade gracefully (manual entry). + +export type ExtractedField = { + value: T | null; + /** 0..1 */ + confidence: number; + /** optional source snippet for the review mask */ + source?: string; +}; + +export type WorkOrderExtraction = { + orderNumber: ExtractedField; + offerNumber: ExtractedField; + customerNumber: ExtractedField; + companyName: ExtractedField; + customerFirstName: ExtractedField; + customerLastName: ExtractedField; + customerAddress: ExtractedField<{ street?: string; houseNumber?: string; postalCode?: string; city?: string; country?: string }>; + siteName: ExtractedField; + siteAddress: ExtractedField<{ street?: string; houseNumber?: string; postalCode?: string; city?: string; country?: string }>; + contactName: ExtractedField; + phone: ExtractedField; + email: ExtractedField; + orderDate: ExtractedField; // ISO date + documentDate: ExtractedField; // ISO date + plannedStart: ExtractedField; // ISO date + plannedEnd: ExtractedField; // ISO date + title: ExtractedField; + description: ExtractedField; + positions: ExtractedField>; + notes: ExtractedField; + totalAmount: ExtractedField; + references: ExtractedField; +}; + +export type ProviderMeta = { provider: string; model: string; inputTokens?: number; outputTokens?: number }; + +export interface DocumentExtractionProvider { + readonly name: string; + readonly model: string; + extract(input: { bytes: Buffer; mimeType: string; fileName: string }): Promise<{ + text: string; + extraction: WorkOrderExtraction; + meta: ProviderMeta; + }>; +} + +export interface TranscriptionProvider { + readonly name: string; + readonly model: string; + transcribe(input: { bytes: Buffer; mimeType: string; language: "de" | "en" }): Promise<{ text: string; meta: ProviderMeta }>; +} + +export type ReportDraftInput = { + locale: "de" | "en"; + addressForm: "sie" | "du"; + workOrder: { title: string; description?: string | null; scope?: string | null; orderType?: string | null }; + notes: Array<{ kind: string; text: string; at: string }>; + checklist: Array<{ label: string; checked: boolean; comment?: string | null }>; + materials: Array<{ name: string; planned?: string; actual: string; unit: string; status: string; reason?: string | null }>; + photos: Array<{ phase?: string | null; comment?: string | null; requirement?: string | null }>; + time: Array<{ type: string; minutes: number; user: string }>; +}; + +export type ReportDraftOutput = { + workPerformed: string; + deviations: string; + additionalWork: string; + openItems: string; + nextSteps: string; + hints: string; + missingInformation: string[]; + meta: ProviderMeta; +}; + +export interface LotseProvider { + readonly name: string; + readonly model: string; + draftReport(input: ReportDraftInput): Promise; +} diff --git a/src/server/backup/topology.ts b/src/server/backup/topology.ts index 96766a2..8c2c856 100644 --- a/src/server/backup/topology.ts +++ b/src/server/backup/topology.ts @@ -44,7 +44,34 @@ export const TENANT_MODELS: readonly string[] = [ "AuthToken", "TenantSettings", "TenantModule", - // Craftvia-Fachmodelle hier ergänzen (identisch zu src/server/db.ts). + // Craftvia domain (0002_craftvia_domain) — identisch zu src/server/db.ts. + "NumberSequence", + "OrderType", + "ChecklistTemplate", + "Customer", + "Contact", + "Site", + "Team", + "TeamMember", + "WorkOrder", + "WorkOrderAssignee", + "WorkOrderStatusChange", + "ChecklistItem", + "PhotoRequirement", + "MaterialPlan", + "MaterialUsage", + "WorkSession", + "TimeEntry", + "ActivityNote", + "Document", + "Photo", + "VoiceNote", + "Report", + "Signature", + "ImportJob", + "Notification", + "SyncOperation", + "AiGeneration", ]; /** diff --git a/src/server/db.ts b/src/server/db.ts index e258062..f75babc 100644 --- a/src/server/db.ts +++ b/src/server/db.ts @@ -94,6 +94,34 @@ const TENANT_MODELS = new Set([ "AuthToken", "TenantSettings", "TenantModule", + // Craftvia domain (0002_craftvia_domain) + "NumberSequence", + "OrderType", + "ChecklistTemplate", + "Customer", + "Contact", + "Site", + "Team", + "TeamMember", + "WorkOrder", + "WorkOrderAssignee", + "WorkOrderStatusChange", + "ChecklistItem", + "PhotoRequirement", + "MaterialPlan", + "MaterialUsage", + "WorkSession", + "TimeEntry", + "ActivityNote", + "Document", + "Photo", + "VoiceNote", + "Report", + "Signature", + "ImportJob", + "Notification", + "SyncOperation", + "AiGeneration", // WebAuthnCredential/Identity sind identitäts-global (kein tenant_id) → NICHT hier. // Craftvia-Fachmodelle hier ergänzen — UND in src/server/backup/topology.ts // (TENANT_MODELS) sowie per `SELECT enable_tenant_rls('')` in der Migration diff --git a/src/server/events.ts b/src/server/events.ts new file mode 100644 index 0000000..f592edd --- /dev/null +++ b/src/server/events.ts @@ -0,0 +1,16 @@ +import type { DomainEvent } from "@/lib/events"; +import type { ServiceCtx } from "@/server/services/context"; + +/** + * Emit a domain event AFTER the mutation succeeded. Never throws: a failing notification + * must not roll back business data — failures are logged and surfaced via sync/notification + * monitoring. Lanes call ONLY this function; the notifications lane owns the handler. + */ +export async function emitEvent(ctx: ServiceCtx, event: DomainEvent): Promise { + try { + const { handleEvent } = await import("@/server/services/notifications/handle-event"); + await handleEvent(ctx, event); + } catch (err) { + console.error(`[events] ${event.type} for ${event.entityType}:${event.entityId} failed:`, (err as Error).message); + } +} diff --git a/src/server/jobs/dispatch.ts b/src/server/jobs/dispatch.ts new file mode 100644 index 0000000..8edc3fa --- /dev/null +++ b/src/server/jobs/dispatch.ts @@ -0,0 +1,16 @@ +import { enqueueJob, type JobPayload, type JobQueueName } from "./queues"; +import { runInline } from "./processors"; + +/** + * Queue a background job, or run it inline when no queue is available. + * Inline runs are awaited so dev/demo behaves deterministically. + */ +export async function dispatchJob(name: JobQueueName, payload: JobPayload): Promise<"queued" | "inline"> { + try { + if (await enqueueJob(name, payload)) return "queued"; + } catch (err) { + console.error(`[jobs] enqueue ${name} failed, running inline:`, (err as Error).message); + } + await runInline(name, payload); + return "inline"; +} diff --git a/src/server/jobs/processors/index.ts b/src/server/jobs/processors/index.ts new file mode 100644 index 0000000..8727bd4 --- /dev/null +++ b/src/server/jobs/processors/index.ts @@ -0,0 +1,23 @@ +import type { JobPayload, JobQueueName } from "../queues"; + +export type JobProcessor = (payload: JobPayload) => Promise; + +/** + * Processor registry. Each lane adds exactly ONE line for its queue, e.g. + * [JOB_QUEUES.importExtraction]: () => import("./import-extraction").then((m) => m.process), + * Lazy imports keep the app bundle free of worker-only dependencies (Playwright etc.). + */ +export const PROCESSORS: Partial Promise>> = { + // lane-imports: "import-extraction": () => import("./import-extraction").then((m) => m.process), + // lane-lotse: "transcription": () => import("./transcription").then((m) => m.process), + // lane-reports: "report-pdf": () => import("./report-pdf").then((m) => m.process), + // lane-field: "image-derivatives": () => import("./image-derivatives").then((m) => m.process), +}; + +/** Inline fallback when no Redis is available (dev/demo). */ +export async function runInline(name: JobQueueName, payload: JobPayload): Promise { + const load = PROCESSORS[name]; + if (!load) throw new Error(`no processor registered for ${name}`); + const process = await load(); + await process(payload); +} diff --git a/src/server/jobs/queues.ts b/src/server/jobs/queues.ts new file mode 100644 index 0000000..231859b --- /dev/null +++ b/src/server/jobs/queues.ts @@ -0,0 +1,89 @@ +import { Queue } from "bullmq"; +import IORedis, { type Redis } from "ioredis"; + +/** + * Craftvia background job queues (spec §30.4). Same operating model as src/server/mail/queue.ts: + * with REDIS_URL jobs run asynchronously in `npm run worker:craftvia`; without Redis + * enqueueJob() returns false and callers run the processor inline (dev/demo only). + */ + +export const JOB_QUEUES = { + importExtraction: "import-extraction", + transcription: "transcription", + reportPdf: "report-pdf", + imageDerivatives: "image-derivatives", +} as const; + +export type JobQueueName = (typeof JOB_QUEUES)[keyof typeof JOB_QUEUES]; + +/** Every job carries its tenant; processors MUST use dbForTenant(tenantId). */ +export type JobPayload = { tenantId: string; entityId: string; actorId?: string | null }; + +let producer: Redis | null = null; +let worker: Redis | null = null; +const queues = new Map>(); + +function url(): string | undefined { + return process.env.REDIS_URL?.trim() || undefined; +} + +function producerConnection(): Redis | null { + const u = url(); + if (!u) return null; + if (!producer) { + producer = new IORedis(u, { + maxRetriesPerRequest: 1, + enableReadyCheck: false, + enableOfflineQueue: false, + connectTimeout: 3_000, + }); + producer.on("error", (err) => console.error("[jobs] Redis (producer) unavailable:", err.message)); + } + return producer; +} + +export function workerConnection(): Redis | null { + const u = url(); + if (!u) return null; + if (!worker) { + worker = new IORedis(u, { maxRetriesPerRequest: null, enableReadyCheck: false }); + worker.on("error", (err) => console.error("[jobs] Redis (worker) error:", err.message)); + } + return worker; +} + +function queue(name: JobQueueName): Queue | null { + const conn = producerConnection(); + if (!conn || conn.status !== "ready") return null; + let q = queues.get(name); + if (!q) { + q = new Queue(name, { + connection: conn, + defaultJobOptions: { + attempts: 3, + backoff: { type: "exponential", delay: 15_000 }, + removeOnComplete: { age: 7 * 24 * 3600, count: 1000 }, + removeOnFail: { age: 30 * 24 * 3600 }, + }, + }); + queues.set(name, q); + } + return q; +} + +/** Returns true if the job was queued; false means: caller must process inline. */ +export async function enqueueJob(name: JobQueueName, payload: JobPayload): Promise { + const q = queue(name); + if (!q) return false; + await q.add(name, payload, { jobId: `${name}:${payload.tenantId}:${payload.entityId}:${Date.now()}` }); + return true; +} + +export async function closeJobQueues(): Promise { + await Promise.all([...queues.values()].map((q) => q.close())); + queues.clear(); + producer?.disconnect(); + producer = null; + worker?.disconnect(); + worker = null; +} diff --git a/src/server/services/context.ts b/src/server/services/context.ts new file mode 100644 index 0000000..29284d7 --- /dev/null +++ b/src/server/services/context.ts @@ -0,0 +1,43 @@ +import type { Session } from "next-auth"; +import type { TenantDb } from "@/server/db"; + +/** + * Context passed to every domain service. Created by server actions (from moduleGuard) + * and by /api/v1 route handlers (from requireApiContext). Services never read the + * session themselves and never create their own db client. + */ +export type ServiceCtx = { + db: TenantDb; + tenantId: string; + userId: string; + /** DB-authoritative effective permissions (never the JWT copy). */ + permissions: ReadonlySet; +}; + +export function ctxFromGuard(g: { session: Session; db: TenantDb; permissions: ReadonlySet }): ServiceCtx { + return { + db: g.db, + tenantId: g.session.user.tenantId, + userId: g.session.user.id, + permissions: g.permissions, + }; +} + +export function can(ctx: ServiceCtx, permission: string): boolean { + return ctx.permissions.has(permission); +} + +export class ServiceError extends Error { + constructor( + public code: "not_found" | "forbidden" | "invalid" | "conflict" | "blocked", + message: string, + public details?: unknown, + ) { + super(message); + this.name = "ServiceError"; + } +} + +export function assertCan(ctx: ServiceCtx, permission: string): void { + if (!can(ctx, permission)) throw new ServiceError("forbidden", `missing permission ${permission}`); +} diff --git a/src/server/services/notifications/handle-event.ts b/src/server/services/notifications/handle-event.ts new file mode 100644 index 0000000..6338513 --- /dev/null +++ b/src/server/services/notifications/handle-event.ts @@ -0,0 +1,10 @@ +import type { DomainEvent } from "@/lib/events"; +import type { ServiceCtx } from "@/server/services/context"; + +/** + * Placeholder — replaced by lane "notifications" (recipient rules, in-app Notification rows, + * e-mail via the mail queue). Keeps emitEvent() callable for all other lanes meanwhile. + */ +export async function handleEvent(_ctx: ServiceCtx, _event: DomainEvent): Promise { + // intentionally empty +} diff --git a/src/server/services/numbering.ts b/src/server/services/numbering.ts new file mode 100644 index 0000000..069eeb8 --- /dev/null +++ b/src/server/services/numbering.ts @@ -0,0 +1,41 @@ +import type { TenantDb } from "@/server/db"; + +export type NumberKey = "customer" | "work_order" | "emergency" | "report"; + +const DEFAULT_PREFIX: Record = { + customer: "K-", + work_order: "A-", + emergency: "N-", + report: "B-", +}; + +/** + * Allocate the next number of a tenant sequence, e.g. "A-00042". + * The increment is a single UPDATE … RETURNING (atomic); the very first call per key + * creates the row and retries once if a concurrent call created it first. + */ +export async function nextNumber(db: TenantDb, tenantId: string, key: NumberKey): Promise { + for (let attempt = 0; attempt < 3; attempt++) { + const existing = await db.numberSequence.findFirst({ where: { tenantId, key }, select: { id: true } }); + if (existing) { + const seq = await db.numberSequence.update({ + where: { id: existing.id }, + data: { nextValue: { increment: 1 } }, + }); + return format(seq.prefix, seq.nextValue - 1, seq.padding); + } + try { + const seq = await db.numberSequence.create({ + data: { tenantId, key, prefix: DEFAULT_PREFIX[key], nextValue: 2 }, + }); + return format(seq.prefix, 1, seq.padding); + } catch (err) { + if ((err as { code?: string }).code !== "P2002") throw err; // unique race → retry via update path + } + } + throw new Error(`could not allocate number for ${key}`); +} + +function format(prefix: string, value: number, padding: number): string { + return `${prefix}${String(value).padStart(padding, "0")}`; +} diff --git a/src/server/services/work-orders/visibility.ts b/src/server/services/work-orders/visibility.ts new file mode 100644 index 0000000..3d6dc0c --- /dev/null +++ b/src/server/services/work-orders/visibility.ts @@ -0,0 +1,68 @@ +import type { Prisma } from "@prisma/client"; +import { can, ServiceError, type ServiceCtx } from "@/server/services/context"; + +/** + * Central work order visibility (spec §4.4: technicians only see their own/team orders). + * EVERY read of work orders or dependent entities for non-backoffice users MUST use these scopes. + * The tenant itself is enforced by dbForTenant/RLS; these filters restrict within the tenant. + */ + +/** Active team ids of the current user (member or leader). */ +export async function activeTeamIds(ctx: ServiceCtx): Promise { + const now = new Date(); + const [memberships, led] = await Promise.all([ + ctx.db.teamMember.findMany({ + where: { userId: ctx.userId, validFrom: { lte: now }, OR: [{ validTo: null }, { validTo: { gt: now } }], team: { status: "active", deletedAt: null } }, + select: { teamId: true }, + }), + ctx.db.team.findMany({ where: { leaderUserId: ctx.userId, status: "active", deletedAt: null }, select: { id: true } }), + ]); + return [...new Set([...memberships.map((m) => m.teamId), ...led.map((t) => t.id)])]; +} + +export async function workOrderScope(ctx: ServiceCtx): Promise { + if (can(ctx, "work_order:read_all")) return { deletedAt: null }; + if (!can(ctx, "work_order:read_team")) return { id: "__none__" }; + const teamIds = await activeTeamIds(ctx); + return { + deletedAt: null, + OR: [ + ...(teamIds.length ? [{ assignedTeamId: { in: teamIds } }] : []), + { assignees: { some: { userId: ctx.userId } } }, + { teamLeadUserId: ctx.userId }, + { isEmergency: true, createdById: ctx.userId }, + ], + }; +} + +/** Customers readable by the user: all for customer:read + read_all, otherwise only via visible orders. */ +export async function customerScope(ctx: ServiceCtx): Promise { + if (!can(ctx, "customer:read")) return { id: "__none__" }; + if (can(ctx, "work_order:read_all")) return { deletedAt: null }; + return { deletedAt: null, workOrders: { some: await workOrderScope(ctx) } }; +} + +export async function siteScope(ctx: ServiceCtx): Promise { + if (!can(ctx, "site:read")) return { id: "__none__" }; + if (can(ctx, "work_order:read_all")) return { deletedAt: null }; + return { deletedAt: null, workOrders: { some: await workOrderScope(ctx) } }; +} + +/** Load a work order the user may see, or throw not_found (never reveal existence). */ +export async function requireVisibleWorkOrder( + ctx: ServiceCtx, + workOrderId: string, + select?: S, +) { + const scope = await workOrderScope(ctx); + const wo = await ctx.db.workOrder.findFirst({ where: { AND: [{ id: workOrderId }, scope] }, ...(select ? { select } : {}) }); + if (!wo) throw new ServiceError("not_found", "work order not found"); + return wo; +} + +/** Document visibility levels the user may read (spec §24.3). */ +export function allowedDocumentVisibility(ctx: ServiceCtx): Array<"backoffice_only" | "team_lead" | "team" | "customer_report"> { + if (can(ctx, "document:read_internal")) return ["backoffice_only", "team_lead", "team", "customer_report"]; + if (can(ctx, "report:approve_team")) return ["team_lead", "team", "customer_report"]; + return ["team", "customer_report"]; +}