Architektur: Craftvia-Domänenmodell, Verträge und Team-Schnitte
- 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 <noreply@anthropic.com>
This commit is contained in:
@@ -88,3 +88,18 @@ MAIL_FROM_NAME=Craftvia
|
|||||||
MAIL_REPLY_TO=
|
MAIL_REPLY_TO=
|
||||||
# Basis fuer absolute Links in Mails. Faellt auf AUTH_URL zurueck.
|
# Basis fuer absolute Links in Mails. Faellt auf AUTH_URL zurueck.
|
||||||
APP_BASE_URL=http://localhost:3000
|
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
|
||||||
|
|||||||
@@ -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/<modul>/*.ts`: reine Funktionen `(ctx, input) → result`, `ctx = { db: TenantDb, session, tenantId, userId }`. Server Actions und `/api/v1` sind dünne Adapter (Guard → Zod → Service → Audit/Revalidate). | – |
|
||||||
|
| Mandanten | `tenantId` auf jeder Fachtabelle, `dbForTenant` + RLS (`enable_tenant_rls`) | – |
|
||||||
|
| KI | Claude (Anthropic SDK) für Extraktion/Lotse; Whisper-kompatible Transkription; Provider-Interfaces | – |
|
||||||
|
| 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/<documentId>` (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/<name>.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<WorkOrderExtraction> }
|
||||||
|
interface TranscriptionProvider { name; model; transcribe(input: { bytes: Buffer; mimeType: string; language: "de" }): Promise<{ text: string }> }
|
||||||
|
interface LotseProvider { name; model; draftReport(input: ReportDraftInput): Promise<ReportDraftOutput> }
|
||||||
|
```
|
||||||
|
Konfiguration per Env (`AI_EXTRACTION_PROVIDER=anthropic`, `ANTHROPIC_API_KEY`, `ANTHROPIC_MODEL`, `TRANSCRIPTION_PROVIDER=openai-compatible`, `TRANSCRIPTION_API_URL`, `TRANSCRIPTION_API_KEY`, `TRANSCRIPTION_MODEL=whisper-1`). Ohne Key: graceful degradation (Status `disabled`, manuelle Eingabe). Jede Nutzung → `AiGeneration`. Tests nutzen `FakeProvider`.
|
||||||
|
|
||||||
|
### 4.6 Offline-Sync
|
||||||
|
- Mobile Mutationen sind **Operationen**: `{ clientOpId: uuid, opType, entityType, entityId?, baseVersion?, payload, clientCreatedAt }`.
|
||||||
|
- `opType`-Katalog (`src/lib/sync/ops.ts`, Zod-Schemas): `session.start`, `session.pause`, `session.resume`, `session.end`, `work_order.transition`, `note.create`, `checklist.toggle`, `material.upsert`, `photo.attach` (nach Blob-Upload), `voice.attach`, `report.save_draft`, `report.submit`, `signature.capture`, `emergency.create`.
|
||||||
|
- Online und offline identischer Pfad: Client-Outbox → `POST /api/v1/sync` (Batch) → `services/sync/apply.ts` → dispatcht auf dieselben Services. Idempotent ü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/<name>` von `feature/craftvia-mvp`, besitzt ihre Pfade exklusiv und fasst fremde Pfade nur über die in §4 genannten Einzeiler-Registrierungen an.
|
||||||
|
|
||||||
|
| Lane | Besitzt | Liefert |
|
||||||
|
|---|---|---|
|
||||||
|
| **L1 Stammdaten** | `services/{customers,sites,teams}`, `actions/{customers,sites,teams}`, `app/(app)/{customers,sites,teams}`, `components/{customers,sites,teams}`, `lib/customers/duplicates.ts`, `api/v1/{customers,sites,teams}`, messages `customers/sites/teams` | CRUD, Ansprechpartner, Dublettenprüfung + Merge (mit Bestätigung), Objekt-Detail inkl. Dokumente-Tab (nutzt §4.3) und **Objekt-Historie** (§8.3), Teams + Mitglieder, `customerScope/siteScope` |
|
||||||
|
| **L2 Aufträge** | `services/work-orders/**`, `lib/work-orders/**`, `actions/work_orders`, `app/(app)/work-orders`, `components/work-orders`, `api/v1/work-orders`, `app/(app)/settings/{order-types,checklists,numbering}`, `app/(app)/dashboard`, `app/(app)/search`, messages `workOrders/dashboard/search/settingsTemplates` | Auftrag CRUD, Statusmaschine + Guards, Zuweisung, Checklisten/Pflichtfotos/Materialvorgabe, Dokumente am Auftrag, Backoffice-Dashboard mit Filtern (§21), Abrechnungsfreigabe, Suche (§25), Konfliktliste |
|
||||||
|
| **L3 Import** | `services/imports/**`, `lib/imports/**`, `actions/imports`, `app/(app)/imports`, `components/imports`, `jobs/processors/import-extraction.ts`, `ai/extraction/**` | Upload, Validierung, Claude-Extraktion (PDF nativ + Bild), Konfidenzen, Prüfmaske, Dublettenkandidaten (nutzt `lib/customers/duplicates.ts` von L1 – Vertrag: `findDuplicateCustomers(db, candidate) → Candidate[]`, L3 nutzt Stub bis Merge), Bestätigung → Kunde/Objekt/Auftrag (über L2-Service `createWorkOrder`), Musterdokumente + Tests |
|
||||||
|
| **L4 Einsatz mobil** | `app/(app)/m/(field)/ bzw. nach Umzug app/(field)/m/**` (außer emergency, sync), `services/field/**`, `lib/sync/ops.ts`, `services/sync/**`, `api/v1/{sync,uploads,field}`, `components/field/**`, messages `field` | Mobile Shell, Heute/Aufträge, Auftragsdetail (Infos, Dokumente, Objekt-Historie read-only), Zeiten (Start/Pause/Stopp + Korrektur mit Grund), Checkliste, Material (bestätigen/abweichen/zusätzlich), Fotos (Kamera, Kompression, Kategorie/Phase/Kommentar), Notizen, Sprachnotiz-Aufnahme, Sync-API serverseitig |
|
||||||
|
| **L5 Berichte** | `services/reports/**`, `lib/reports/**`, `actions/reports`, `app/(app)/reports`, `components/reports/**` (inkl. `signature-pad.tsx`), `jobs/processors/report-pdf.ts`, `server/pdf/**`, `api/v1/reports`, messages `reports` | Tages-/Abschlussbericht (Content-Builder, Validierung Pflichtangaben), Unterschrift inkl. Ablehnungsgründe, PDF (HTML-Template, Mandantenlogo, Fotos, Unterschrift, Checksumme), Versionierung, Teamleiter-/Backoffice-Freigabe/Zurückweisung; mobile Seiten `/m/orders/[id]/{report,sign}` als Komponenten, die L4 einbindet |
|
||||||
|
| **L6 Benachrichtigungen & Audit** | `server/events.ts` (Implementierung), `services/notifications/**`, `app/(app)/notifications`, `components/notifications/**` (Glocke), `mail/templates` Craftvia-Events, `app/(app)/settings/email`, `app/(app)/settings/audit`, messages `notifications` | Empfängerregeln je Event, In-App + E-Mail, Mandanten-Mailkonfiguration (§33.2), Audit-Viewer mit Filter |
|
||||||
|
| **L7 Offline/PWA** *(Welle 2)* | `public/sw.js` bzw. `src/app/sw.ts`-Build, `lib/offline/**` (IndexedDB, Outbox, Blob-Queue), `app/(app)/m/(field)/ bzw. nach Umzug app/(field)/m/sync`, `components/offline/**` | Service Worker (App-Shell + Bundle-Cache + Dokument-Cache), Outbox mit Retry, Upload-Queue, Sync-Status/Fehler, Konfliktanzeige, Installierbarkeit |
|
||||||
|
| **L8 Notdienst** *(Welle 2)* | `app/(app)/m/(field)/ bzw. nach Umzug app/(field)/m/emergency`, `services/emergency/**`, `actions/emergency`, Backoffice-Review `app/(app)/work-orders/emergency-review` | Notdienst-Erfassung (bestehender/vorläufiger Kunde + Objekt), Start, Abschluss → Events; Backoffice: bestätigen/zuordnen/zusammenführen/abrechnen |
|
||||||
|
| **L9 Lotse** *(Welle 2)* | `services/lotse/**`, `ai/lotse/**`, `jobs/processors/transcription.ts`, `components/lotse/**`, messages `lotse` | Transkription, „Bericht mit Lotse vorbereiten" (Entwurf gekennzeichnet, editierbar), Vollständigkeitsprüfung „3 Angaben fehlen" |
|
||||||
|
| **L10 Stabilisierung** *(Welle 3)* | `scripts/test-e2e-*.ts`, `scripts/test-security-*.ts`, `prisma/seed.ts` Demo-Daten, Docs | E2E-Prozessskripte (§43.3), Sicherheitstests (§43.4), Demo-Seed, Deploy-Doku, OpenAPI (`/api/v1/openapi.json`) |
|
||||||
|
|
||||||
|
Gemeinsame Einzeiler-Registrierungen (Konflikte trivial auflösbar): `src/lib/nav.ts`, `src/server/jobs/processors/index.ts`, `scripts/check-module-guards.ts` (nur falls Top-Level-Actions), `src/server/audit` Entity-Labels.
|
||||||
|
|
||||||
|
## 7. Definition of Done je Lane
|
||||||
|
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/<ns>.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/<lane>.md`: Umfang, Dateien, Tests, offene Punkte.
|
||||||
+2
-1
@@ -13,7 +13,8 @@
|
|||||||
"gate": "prisma generate && tsc --noEmit && npm run lint && npm run build && npm run test",
|
"gate": "prisma generate && tsc --noEmit && npm run lint && npm run build && npm run test",
|
||||||
"worker:mail": "tsx scripts/mail-worker.ts",
|
"worker:mail": "tsx scripts/mail-worker.ts",
|
||||||
"worker:backup": "tsx scripts/backup-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": {
|
"dependencies": {
|
||||||
"@anthropic-ai/sdk": "^0.115.0",
|
"@anthropic-ai/sdk": "^0.115.0",
|
||||||
|
|||||||
@@ -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');
|
||||||
@@ -144,6 +144,13 @@ model User {
|
|||||||
identity Identity @relation(fields: [identityId], references: [id])
|
identity Identity @relation(fields: [identityId], references: [id])
|
||||||
userRoles UserRole[]
|
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])
|
@@unique([tenantId, email])
|
||||||
// Eine Person (Identity) hat je Mandant höchstens EINE Mitgliedschaft.
|
// Eine Person (Identity) hat je Mandant höchstens EINE Mitgliedschaft.
|
||||||
@@unique([tenantId, identityId])
|
@@unique([tenantId, identityId])
|
||||||
@@ -422,3 +429,859 @@ model BackupJob {
|
|||||||
@@index([status])
|
@@index([status])
|
||||||
@@map("backup_jobs")
|
@@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") // <tenantId>/...
|
||||||
|
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")
|
||||||
|
}
|
||||||
|
|||||||
@@ -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<JobPayload>[] = [];
|
||||||
|
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<JobPayload>(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);
|
||||||
|
});
|
||||||
@@ -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<string, string | number | boolean | null>;
|
||||||
|
};
|
||||||
@@ -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<typeof syncOperationSchema>;
|
||||||
|
|
||||||
|
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<string, string>;
|
||||||
|
entityVersion?: number;
|
||||||
|
errorCode?: "not_found" | "forbidden" | "invalid" | "conflict" | "blocked" | "internal";
|
||||||
|
message?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type SyncResponse = { results: SyncOpResult[]; serverTime: string };
|
||||||
@@ -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<WorkOrderStatus, readonly WorkOrderStatus[]> = {
|
||||||
|
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/<locale>/workOrders.json → statusGroup.<key>. */
|
||||||
|
export const STATUS_GROUP: Record<WorkOrderStatus, StatusGroup> = {
|
||||||
|
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<StatusGroup, "neutral" | "info" | "accent" | "warning" | "success" | "danger"> = {
|
||||||
|
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 };
|
||||||
+10
-10
@@ -73,18 +73,18 @@ export function moduleGuard(moduleKey: string) {
|
|||||||
}
|
}
|
||||||
if (identity.mustChangePassword) throw new Error("Passwortwechsel erforderlich.");
|
if (identity.mustChangePassword) throw new Error("Passwortwechsel erforderlich.");
|
||||||
|
|
||||||
if (permissions.length > 0) {
|
const effective = new Set(
|
||||||
const effective = new Set(
|
account.userRoles.flatMap((ur) =>
|
||||||
account.userRoles.flatMap((ur) =>
|
ur.role.rolePermissions.map((rp) => rp.permission.key),
|
||||||
ur.role.rolePermissions.map((rp) => rp.permission.key),
|
),
|
||||||
),
|
);
|
||||||
);
|
for (const permission of permissions) {
|
||||||
for (const permission of permissions) {
|
if (!effective.has(permission)) throw new ForbiddenError(permission);
|
||||||
if (!effective.has(permission)) throw new ForbiddenError(permission);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
await assertModuleEnabled(session, moduleKey);
|
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<string> };
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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<T = string> = {
|
||||||
|
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<Array<{ position?: string; name: string; articleNumber?: string; quantity?: number; unit?: string; isMaterial?: boolean }>>;
|
||||||
|
notes: ExtractedField;
|
||||||
|
totalAmount: ExtractedField<number>;
|
||||||
|
references: ExtractedField<string[]>;
|
||||||
|
};
|
||||||
|
|
||||||
|
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<ReportDraftOutput>;
|
||||||
|
}
|
||||||
@@ -44,7 +44,34 @@ export const TENANT_MODELS: readonly string[] = [
|
|||||||
"AuthToken",
|
"AuthToken",
|
||||||
"TenantSettings",
|
"TenantSettings",
|
||||||
"TenantModule",
|
"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",
|
||||||
];
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -94,6 +94,34 @@ const TENANT_MODELS = new Set<string>([
|
|||||||
"AuthToken",
|
"AuthToken",
|
||||||
"TenantSettings",
|
"TenantSettings",
|
||||||
"TenantModule",
|
"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.
|
// WebAuthnCredential/Identity sind identitäts-global (kein tenant_id) → NICHT hier.
|
||||||
// Craftvia-Fachmodelle hier ergänzen — UND in src/server/backup/topology.ts
|
// Craftvia-Fachmodelle hier ergänzen — UND in src/server/backup/topology.ts
|
||||||
// (TENANT_MODELS) sowie per `SELECT enable_tenant_rls('<table>')` in der Migration
|
// (TENANT_MODELS) sowie per `SELECT enable_tenant_rls('<table>')` in der Migration
|
||||||
|
|||||||
@@ -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<void> {
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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";
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import type { JobPayload, JobQueueName } from "../queues";
|
||||||
|
|
||||||
|
export type JobProcessor = (payload: JobPayload) => Promise<void>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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<Record<JobQueueName, () => Promise<JobProcessor>>> = {
|
||||||
|
// 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<void> {
|
||||||
|
const load = PROCESSORS[name];
|
||||||
|
if (!load) throw new Error(`no processor registered for ${name}`);
|
||||||
|
const process = await load();
|
||||||
|
await process(payload);
|
||||||
|
}
|
||||||
@@ -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<JobQueueName, Queue<JobPayload>>();
|
||||||
|
|
||||||
|
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<JobPayload> | null {
|
||||||
|
const conn = producerConnection();
|
||||||
|
if (!conn || conn.status !== "ready") return null;
|
||||||
|
let q = queues.get(name);
|
||||||
|
if (!q) {
|
||||||
|
q = new Queue<JobPayload>(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<boolean> {
|
||||||
|
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<void> {
|
||||||
|
await Promise.all([...queues.values()].map((q) => q.close()));
|
||||||
|
queues.clear();
|
||||||
|
producer?.disconnect();
|
||||||
|
producer = null;
|
||||||
|
worker?.disconnect();
|
||||||
|
worker = null;
|
||||||
|
}
|
||||||
@@ -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<string>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function ctxFromGuard(g: { session: Session; db: TenantDb; permissions: ReadonlySet<string> }): 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}`);
|
||||||
|
}
|
||||||
@@ -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<void> {
|
||||||
|
// intentionally empty
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import type { TenantDb } from "@/server/db";
|
||||||
|
|
||||||
|
export type NumberKey = "customer" | "work_order" | "emergency" | "report";
|
||||||
|
|
||||||
|
const DEFAULT_PREFIX: Record<NumberKey, string> = {
|
||||||
|
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<string> {
|
||||||
|
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")}`;
|
||||||
|
}
|
||||||
@@ -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<string[]> {
|
||||||
|
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<Prisma.WorkOrderWhereInput> {
|
||||||
|
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<Prisma.CustomerWhereInput> {
|
||||||
|
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<Prisma.SiteWhereInput> {
|
||||||
|
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<S extends Prisma.WorkOrderSelect | undefined = undefined>(
|
||||||
|
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"];
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user