From 3883296300102fd42fa240fd6a3d403f51c671ad Mon Sep 17 00:00:00 2001 From: Martin Date: Mon, 14 Sep 2026 12:49:19 +0200 Subject: [PATCH] Integration: Stubs von L2/L3/L4/L5 gegen echte Services getauscht MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Aufträge: createWorkOrder/transitionWorkOrder/getCompletionBlockers aus L2 - Dokumente: storeFile aus L1; neu services/documents/read.ts (readStoredBytes, readDocumentBytes mit Prüfsummenprüfung); L2-Upload nutzt zentrale Ablage - Dubletten aus L1 (findDuplicateCustomers(ctx)); Objekt-Kandidaten als imports/site-candidates.ts; Dateityp-Erkennung mobil als field/mime.ts - Objekt-Historie mobil als Adapter auf L1 getSiteHistory, PDF über /api/v1/reports/:id/pdf - L4-Upload-Idempotenz: fester Upload-Lineage nach storeFile, Race → Soft-Delete + Replay - PDF-Worker-Kontext: document:write zum Ablegen des Berichts-PDF - Import: doppeltes Work-Order-Audit entfernt; bestätigte Aufträge starten planned - Dateilinks in Auftragsdetail auf /files/[documentId] - proxy: /api/v1 ohne Session → 401 JSON statt Login-Redirect - ARCHITEKTUR §2: Objekt-Historie für Feldrollen (freigegebene Einsätze aller Teams) Gate: tsc, lint, build, 42/42 Tests grün. Co-Authored-By: Claude Opus 5 --- docs/craftvia/ARCHITEKTUR.md | 2 + scripts/test-berichte-flow.ts | 2 +- scripts/test-berichte-pdf.ts | 5 +- scripts/test-einsatz-field.ts | 2 +- scripts/test-import-flow.ts | 3 +- scripts/test-import-rules.ts | 7 +- src/app/(field)/m/(core)/orders/[id]/page.tsx | 4 +- src/components/work-orders/detail-tabs.tsx | 6 +- src/proxy.ts | 7 + src/server/actions/reports/signature.ts | 2 +- .../jobs/processors/import-extraction.ts | 4 +- src/server/jobs/processors/report-pdf.ts | 5 +- src/server/services/documents/read.ts | 45 ++++++ src/server/services/field/mime.ts | 32 ++++ src/server/services/field/queries.ts | 41 ++++- src/server/services/field/sessions.ts | 2 +- .../services/field/stubs/documents-store.ts | 97 ------------ .../services/field/stubs/site-history.ts | 51 ------- .../field/stubs/work-order-transition.ts | 97 ------------ src/server/services/field/uploads.ts | 27 ++-- src/server/services/imports/confirm.ts | 10 +- .../services/imports/document-store-stub.ts | 101 ------------- .../services/imports/duplicates-stub.ts | 142 ------------------ src/server/services/imports/process.ts | 5 +- .../services/imports/site-candidates.ts | 38 +++++ src/server/services/imports/upload.ts | 2 +- .../services/imports/work-orders-stub.ts | 87 ----------- .../services/reports/_stubs/documents.ts | 88 ----------- .../services/reports/_stubs/work-orders.ts | 80 ---------- src/server/services/reports/create.ts | 3 +- src/server/services/reports/pdf.ts | 6 +- src/server/services/reports/queries.ts | 2 +- src/server/services/reports/reject.ts | 2 +- src/server/services/reports/signature.ts | 2 +- src/server/services/reports/submit.ts | 3 +- src/server/services/sync/apply.ts | 2 +- src/server/services/work-orders/documents.ts | Bin 4781 -> 2942 bytes 37 files changed, 216 insertions(+), 798 deletions(-) create mode 100644 src/server/services/documents/read.ts create mode 100644 src/server/services/field/mime.ts delete mode 100644 src/server/services/field/stubs/documents-store.ts delete mode 100644 src/server/services/field/stubs/site-history.ts delete mode 100644 src/server/services/field/stubs/work-order-transition.ts delete mode 100644 src/server/services/imports/document-store-stub.ts delete mode 100644 src/server/services/imports/duplicates-stub.ts create mode 100644 src/server/services/imports/site-candidates.ts delete mode 100644 src/server/services/imports/work-orders-stub.ts delete mode 100644 src/server/services/reports/_stubs/documents.ts delete mode 100644 src/server/services/reports/_stubs/work-orders.ts diff --git a/docs/craftvia/ARCHITEKTUR.md b/docs/craftvia/ARCHITEKTUR.md index 6ef129d..af7f270 100644 --- a/docs/craftvia/ARCHITEKTUR.md +++ b/docs/craftvia/ARCHITEKTUR.md @@ -26,6 +26,8 @@ Siehe `src/server/rbac.ts` (vom Fundament angelegt). Rollen: `tenant-admin`, `ba - 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. +**Objekt-Historie für Feldrollen (Entscheidung, US-005/US-011):** Monteure/Teamleiter sehen an einem Objekt, das sie über einen eigenen sichtbaren Auftrag erreichen (`siteScope`), die FREIGEGEBENEN Einsätze aller Teams (Aufträge mit freigegebenem Bericht; `getSiteHistory` in `services/sites/history.ts`). Interne Hinweise sind nie Teil der Historie. + **Dokument-Sichtbarkeit:** `documentVisibilityFilter(ctx)`: `backoffice_only` nur mit `document:read_internal`; `team_lead` nur Teamleiter/Backoffice; `team`/`customer_report` für berechtigte Auftragsbeteiligte. ## 3. Statusmodell Auftrag diff --git a/scripts/test-berichte-flow.ts b/scripts/test-berichte-flow.ts index 082d2ef..07aed1e 100644 --- a/scripts/test-berichte-flow.ts +++ b/scripts/test-berichte-flow.ts @@ -22,7 +22,7 @@ import { rejectReport } from "../src/server/services/reports/reject"; import { createNewVersion } from "../src/server/services/reports/new-version"; import { requireVisibleReport } from "../src/server/services/reports/common"; import { listReports } from "../src/server/services/reports/queries"; -import { storeFile } from "../src/server/services/reports/_stubs/documents"; +import { storeFile } from "../src/server/services/documents/store"; let failures = 0; const ok = (cond: boolean, msg: string) => { diff --git a/scripts/test-berichte-pdf.ts b/scripts/test-berichte-pdf.ts index 4dfc166..b36db0a 100644 --- a/scripts/test-berichte-pdf.ts +++ b/scripts/test-berichte-pdf.ts @@ -15,7 +15,8 @@ import { submitReport } from "../src/server/services/reports/submit"; import { approveReport } from "../src/server/services/reports/approve"; import { generateReportPdf } from "../src/server/services/reports/pdf"; import { openReportFile } from "../src/server/services/reports/files"; -import { readFileBytes, sha256Hex, storeFile } from "../src/server/services/reports/_stubs/documents"; +import { readStoredBytes } from "../src/server/services/documents/read"; +import { sha256Hex, storeFile } from "../src/server/services/documents/store"; let failures = 0; const ok = (cond: boolean, msg: string) => { @@ -101,7 +102,7 @@ async function main() { ok(doc.fileSize > 0 && doc.mimeType === "application/pdf", `Dokument gespeichert (${doc.fileSize} Bytes)`); ok(doc.category === "daily_report" && doc.visibility === "customer_report" && doc.workOrderId === wo.id, "Kategorie daily_report, Sichtbarkeit customer_report, am Auftrag"); ok(stored.pdfChecksum === doc.checksum && /^[0-9a-f]{64}$/.test(stored.pdfChecksum ?? ""), "SHA-256-Prüfsumme am Bericht gespeichert"); - const bytes = await readFileBytes(doc.storageKey); + const bytes = await readStoredBytes(doc.storageKey); if (bytes) { ok(Buffer.from(bytes).subarray(0, 4).toString() === "%PDF", "Datei beginnt mit %PDF"); ok(sha256Hex(bytes) === stored.pdfChecksum, "Prüfsumme entspricht den gespeicherten Bytes"); diff --git a/scripts/test-einsatz-field.ts b/scripts/test-einsatz-field.ts index da11770..593a38f 100644 --- a/scripts/test-einsatz-field.ts +++ b/scripts/test-einsatz-field.ts @@ -13,7 +13,7 @@ import { upsertMaterialUsage } from "../src/server/services/field/materials"; import { toggleChecklistItem } from "../src/server/services/field/checklist"; import { createNote } from "../src/server/services/field/notes"; import { getFieldOrderDetail, listFieldOrders, listTodayOrders, getFieldBundle } from "../src/server/services/field/queries"; -import { transitionWorkOrder } from "../src/server/services/field/stubs/work-order-transition"; +import { transitionWorkOrder } from "../src/server/services/work-orders/transition"; import { createFixture, expectCode, failures, ok } from "./lib/einsatz-fixture"; const min = (n: number) => n * 60 * 1000; diff --git a/scripts/test-import-flow.ts b/scripts/test-import-flow.ts index 2ab4b2b..78b2e7d 100644 --- a/scripts/test-import-flow.ts +++ b/scripts/test-import-flow.ts @@ -180,7 +180,8 @@ async function main() { const wo1 = await prisma.workOrder.findUnique({ where: { id: res1.workOrderId }, include: { statusHistory: { orderBy: { createdAt: "asc" } }, materialPlans: true } }); ok(wo1?.status === "planned" && wo1.sourceImportId === job1.id && wo1.customerId === existing.id && wo1.siteId === existingSite.id, "(K3) work order planned, linked to import, customer and site"); ok(wo1?.number.startsWith("A-") === true && wo1.externalOrderNumber === "AB-2026-0815" && wo1.plannedStart?.toISOString().startsWith("2026-10-12") === true, "(K4) number allocated, document order number and dates taken over"); - ok(wo1?.statusHistory.map((s) => s.toStatus).join(">") === "review_required>planned", "(K5) status history review_required → planned"); + // The review happens on the ImportJob ("Entwurf – Prüfung erforderlich"); the confirmed order starts planned. + ok(wo1?.statusHistory.map((s) => s.toStatus).join(">") === "planned", "(K5) confirmed order starts with status history → planned"); ok(wo1?.materialPlans.length === 1 && wo1.materialPlans[0].articleNumber === "WP-AT-12", "(K6) only positions marked as material become material plan"); ok((await prisma.customer.count({ where: { tenantId: tA.id } })) === customersBefore, "(K7) no new customer when existing one is used"); const docAfter = await prisma.document.findUnique({ where: { id: job1.documentId } }); diff --git a/scripts/test-import-rules.ts b/scripts/test-import-rules.ts index e345307..a8c0f5f 100644 --- a/scripts/test-import-rules.ts +++ b/scripts/test-import-rules.ts @@ -9,8 +9,9 @@ import type { WorkOrderExtraction } from "../src/server/ai/providers"; import { emptyExtraction, extractionJsonSchema, parseExtraction, EXTRACTION_FIELDS } from "../src/lib/imports/extraction"; import { checkPlausibility, parseDate, toIsoDate, VIOLATION_CONFIDENCE } from "../src/lib/imports/plausibility"; import { computeCorrections, extractionToForm, formFieldMeta, reviewFormSchema } from "../src/lib/imports/review"; -import { normalizeCompany, normalizeStreet } from "../src/server/services/imports/duplicates-stub"; -import { normalizeFileName, sniffMime } from "../src/server/services/imports/document-store-stub"; +import { normalizeCompanyName as normalizeCompany, normalizeStreet } from "../src/lib/customers/duplicates"; +import { detectMime as sniffMime } from "../src/server/services/documents/scanner"; +import { normalizeFileName } from "../src/server/services/documents/store"; import { buildPdf } from "./make-sample-pdfs"; let failures = 0; @@ -191,7 +192,7 @@ function sample(): WorkOrderExtraction { ok(sniffMime(pdf) === "application/pdf", "(F1) generated sample is a PDF by magic bytes"); ok(sniffMime(Buffer.from([0xff, 0xd8, 0xff, 0xe0])) === "image/jpeg", "(F2) JPEG magic bytes"); ok(sniffMime(Buffer.from("MZ\x90\x00 fake exe", "latin1")) === null, "(F3) executable rejected"); - ok(normalizeFileName("../../etc/pass.pdf") === "pa_ss_wd_.pdf", "(F4) file name without path and control characters"); + ok(normalizeFileName("../../etc/pass.pdf") === "pass_wd_.pdf", "(F4) file name without path, control characters removed, reserved characters replaced"); const raw = pdf.toString("latin1"); ok(raw.includes("(Auftragsbest\\344tigung Gr\\366\\337e 5 \\200)") && raw.trimEnd().endsWith("%%EOF"), "(F5) PDF writer encodes umlauts/€ as WinAnsi octal escapes and closes the file"); } diff --git a/src/app/(field)/m/(core)/orders/[id]/page.tsx b/src/app/(field)/m/(core)/orders/[id]/page.tsx index f8d60a5..2e54d39 100644 --- a/src/app/(field)/m/(core)/orders/[id]/page.tsx +++ b/src/app/(field)/m/(core)/orders/[id]/page.tsx @@ -271,8 +271,8 @@ export default async function OrderDetailPage({ params }: { params: Promise<{ id {h.workOrderNumber} · {h.workOrderTitle} - {h.pdfDocumentId && ( - + {h.pdfHref && ( + {t("detail.openDocument")} )} diff --git a/src/components/work-orders/detail-tabs.tsx b/src/components/work-orders/detail-tabs.tsx index 38276a2..86de23b 100644 --- a/src/components/work-orders/detail-tabs.tsx +++ b/src/components/work-orders/detail-tabs.tsx @@ -339,9 +339,9 @@ export async function PhotosTab({ ctx, wo, locale, tz }: TabProps) {
    {photos.map((p) => (
  • - + {/* eslint-disable-next-line @next/next/no-img-element -- tenant file route, no next/image optimisation for private files */} - {p.comment + {p.comment

    @@ -452,7 +452,7 @@ export async function DocumentsTab({ ctx, wo, locale, tz, uploadError, uploaded

    - + {t("documents.download")} diff --git a/src/proxy.ts b/src/proxy.ts index e2e49b2..5e1aa8e 100644 --- a/src/proxy.ts +++ b/src/proxy.ts @@ -43,6 +43,13 @@ export function proxy(request: NextRequest) { request.cookies.has("__Secure-authjs.session-token"); if (!hasSessionCookie) { + // Versioned API: machine clients (PWA sync, integrations) need a status code, not a login page. + if (pathname === "/api/v1" || pathname.startsWith("/api/v1/")) { + return NextResponse.json( + { error: { code: "unauthorized", message: "Nicht angemeldet." } }, + { status: 401, headers: { "Cache-Control": "no-store" } }, + ); + } const loginUrl = new URL("/login", request.url); if (pathname !== "/") loginUrl.searchParams.set("callbackUrl", pathname); return NextResponse.redirect(loginUrl); diff --git a/src/server/actions/reports/signature.ts b/src/server/actions/reports/signature.ts index e20cc4a..59731e3 100644 --- a/src/server/actions/reports/signature.ts +++ b/src/server/actions/reports/signature.ts @@ -7,7 +7,7 @@ import { ctxFromGuard, ServiceError } from "@/server/services/context"; import { requireVisibleReport } from "@/server/services/reports/common"; import { captureSignature } from "@/server/services/reports/signature"; // TODO(merge L4): signature PNG could go through POST /api/v1/uploads once available -import { storeFile } from "@/server/services/reports/_stubs/documents"; +import { storeFile } from "@/server/services/documents/store"; import { errorState, okState, str } from "./_state"; const guard = moduleGuard("reports"); diff --git a/src/server/jobs/processors/import-extraction.ts b/src/server/jobs/processors/import-extraction.ts index 713689c..f9b4624 100644 --- a/src/server/jobs/processors/import-extraction.ts +++ b/src/server/jobs/processors/import-extraction.ts @@ -3,7 +3,7 @@ import type { JobPayload } from "@/server/jobs/queues"; import type { ServiceCtx } from "@/server/services/context"; import { getExtractionProvider } from "@/server/ai/extraction/anthropic"; import { processImport } from "@/server/services/imports/process"; -import { readDocumentBytes } from "@/server/services/imports/document-store-stub"; +import { readStoredBytes } from "@/server/services/documents/read"; /** * BullMQ processor for "import-extraction" (ARCHITEKTUR §4.4). System context: tenant from the @@ -20,6 +20,6 @@ export async function process(payload: JobPayload): Promise { }; await processImport(ctx, payload.entityId, { provider: getExtractionProvider(), - loadBytes: (doc) => readDocumentBytes(doc.storageKey), + loadBytes: (doc) => readStoredBytes(doc.storageKey), }); } diff --git a/src/server/jobs/processors/report-pdf.ts b/src/server/jobs/processors/report-pdf.ts index c21f140..d2c33af 100644 --- a/src/server/jobs/processors/report-pdf.ts +++ b/src/server/jobs/processors/report-pdf.ts @@ -5,14 +5,15 @@ import type { JobPayload } from "../queues"; /** * Queue "report-pdf": renders the PDF of an approved report (lane L5). - * System context: tenant-bound db, read access to all reports of the tenant — nothing else. + * System context: tenant-bound db, read access to all reports of the tenant, and storing the PDF document. */ export async function process(payload: JobPayload): Promise { const ctx: ServiceCtx = { db: dbForTenant(payload.tenantId), tenantId: payload.tenantId, userId: payload.actorId ?? "system", - permissions: new Set(["report:read", "work_order:read_all", "document:read_internal"]), + // document:write: the system stores the rendered PDF via the central document service. + permissions: new Set(["report:read", "work_order:read_all", "document:read_internal", "document:write"]), }; const res = await generateReportPdf(ctx, payload.entityId); console.info(`[report-pdf] ${payload.entityId}: ${res.skipped ? "already rendered" : `stored ${res.documentId}`}`); diff --git a/src/server/services/documents/read.ts b/src/server/services/documents/read.ts new file mode 100644 index 0000000..71f859c --- /dev/null +++ b/src/server/services/documents/read.ts @@ -0,0 +1,45 @@ +import { storage } from "@/server/storage/adapter"; +import { ServiceError, type ServiceCtx } from "@/server/services/context"; +import { sha256Hex } from "@/server/services/documents/store"; + +/** + * Server-side byte access to stored documents (workers, PDF rendering, import extraction). + * + * NO visibility/scope check here — this is not a download path. User-facing downloads must go + * through `services/documents/access.ts#openDocumentContent` (/files/[documentId]). + */ + +/** Read raw bytes by storage key; null when the object does not exist (or storage is the stub). */ +export async function readStoredBytes(storageKey: string): Promise { + const content = await storage.get(storageKey); + if (!content) return null; + const chunks: Uint8Array[] = []; + const reader = content.stream.getReader(); + for (;;) { + const { done, value } = await reader.read(); + if (done) break; + if (value) chunks.push(value); + } + return Buffer.concat(chunks); +} + +/** + * Load a tenant document's bytes and verify its SHA-256 checksum. + * Throws `not_found` (missing/soft-deleted/foreign tenant/no content) or `invalid` (checksum mismatch). + */ +export async function readDocumentBytes( + ctx: ServiceCtx, + documentId: string, +): Promise<{ bytes: Buffer; mimeType: string; fileName: string; checksum: string }> { + const document = await ctx.db.document.findFirst({ + where: { id: documentId, deletedAt: null }, + select: { storageKey: true, mimeType: true, fileName: true, checksum: true }, + }); + if (!document) throw new ServiceError("not_found", "document not found"); + // defense in depth: the key must carry the tenant prefix + if (!document.storageKey.startsWith(`${ctx.tenantId}/`)) throw new ServiceError("not_found", "document content not available"); + const bytes = await readStoredBytes(document.storageKey); + if (!bytes) throw new ServiceError("not_found", "document content not available"); + if (sha256Hex(bytes) !== document.checksum) throw new ServiceError("invalid", "document checksum mismatch"); + return { bytes, mimeType: document.mimeType, fileName: document.fileName, checksum: document.checksum }; +} diff --git a/src/server/services/field/mime.ts b/src/server/services/field/mime.ts new file mode 100644 index 0000000..699ba78 --- /dev/null +++ b/src/server/services/field/mime.ts @@ -0,0 +1,32 @@ +/** + * Magic-byte detection for mobile uploads (photo/voice note/preview): decides BEFORE storing + * whether the payload matches the declared kind. Final validation happens in + * services/documents/store.ts#storeFile (lane master data). + */ + +const MB = 1024 * 1024; + +export type Kind = "image" | "pdf" | "audio"; +const LIMITS: Record = { image: 15 * MB, pdf: 25 * MB, audio: 20 * MB }; + +/** Detects the real MIME type from magic bytes (null = not allowed). */ +export function sniffMime(bytes: Buffer): { mime: string; kind: Kind } | null { + const b = bytes; + const at = (offset: number, sig: number[]) => sig.every((v, i) => b[offset + i] === v); + const ascii = (offset: number, s: string) => b.length >= offset + s.length && b.toString("latin1", offset, offset + s.length) === s; + if (b.length < 12) return null; + if (at(0, [0xff, 0xd8, 0xff])) return { mime: "image/jpeg", kind: "image" }; + if (at(0, [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])) return { mime: "image/png", kind: "image" }; + if (ascii(0, "RIFF") && ascii(8, "WEBP")) return { mime: "image/webp", kind: "image" }; + if (ascii(0, "%PDF-")) return { mime: "application/pdf", kind: "pdf" }; + if (at(0, [0x1a, 0x45, 0xdf, 0xa3])) return { mime: "audio/webm", kind: "audio" }; + if (ascii(0, "OggS")) return { mime: "audio/ogg", kind: "audio" }; + if (ascii(0, "RIFF") && ascii(8, "WAVE")) return { mime: "audio/wav", kind: "audio" }; + if (ascii(4, "ftyp")) { + const brand = b.toString("latin1", 8, 12); + if (/^(heic|heix|mif1|msf1)$/.test(brand)) return { mime: "image/heic", kind: "image" }; + return { mime: "audio/mp4", kind: "audio" }; // M4A / MP4 audio from iOS MediaRecorder + } + if (ascii(0, "ID3") || (b[0] === 0xff && (b[1] & 0xe0) === 0xe0)) return { mime: "audio/mpeg", kind: "audio" }; + return null; +} diff --git a/src/server/services/field/queries.ts b/src/server/services/field/queries.ts index 6227ff9..4187d54 100644 --- a/src/server/services/field/queries.ts +++ b/src/server/services/field/queries.ts @@ -4,8 +4,39 @@ import { allowedDocumentVisibility, requireVisibleWorkOrder, workOrderScope } fr import { STATUS_GROUP, type CompletionBlocker, type StatusGroup, type WorkOrderStatus } from "@/lib/work-orders/status"; import { ACTIVE_SESSION_STATUSES } from "./sessions"; // TODO(merge L2/L1): replace with the lane implementations -import { completionBlockers } from "./stubs/work-order-transition"; -import { getSiteHistory, type SiteHistoryEntry } from "./stubs/site-history"; +import { getCompletionBlockers } from "@/server/services/work-orders/completion"; +import { getSiteHistory } from "@/server/services/sites/history"; + +/** One released report at the site, as shown in the mobile order detail (US-005). */ +export type SiteHistoryEntry = { + reportId: string; + reportType: "daily" | "completion"; + reportDate: Date; + workOrderId: string; + workOrderNumber: string; + workOrderTitle: string; + /** Immutable PDF of the approved report (lane reports API; requires report:read). */ + pdfHref: string; +}; + +/** Adapter over the site history service (lane master data): flattens approved reports, newest first. */ +async function fieldSiteHistory(ctx: ServiceCtx, siteId: string, limit = 20): Promise { + const { items } = await getSiteHistory(ctx, siteId, { onlyApproved: true, pageSize: Math.min(100, limit * 2) }); + return items + .flatMap((i) => + i.approvedReports.map((r) => ({ + reportId: r.id, + reportType: r.type, + reportDate: r.reportDate, + workOrderId: i.workOrderId, + workOrderNumber: i.number, + workOrderTitle: i.title, + pdfHref: `/api/v1/reports/${encodeURIComponent(r.id)}/pdf`, + })), + ) + .sort((a, b) => b.reportDate.getTime() - a.reportDate.getTime()) + .slice(0, limit); +} /** Read models of the mobile app (Spec §11.2, §22, US-005). All reads go through workOrderScope. */ @@ -208,8 +239,8 @@ export async function getFieldOrderDetail(ctx: ServiceCtx, workOrderId: string): const [orderDocs, siteDocs, siteHistory, blockers] = await Promise.all([ ctx.db.document.findMany({ where: { ...docWhere, workOrderId: wo.id }, orderBy: { createdAt: "desc" }, select: docSelect }), wo.siteId ? ctx.db.document.findMany({ where: { ...docWhere, siteId: wo.siteId, workOrderId: null }, orderBy: { createdAt: "desc" }, select: docSelect }) : Promise.resolve([]), - wo.siteId ? getSiteHistory(ctx, wo.siteId, { onlyApproved: true }).catch((err) => (err instanceof ServiceError ? [] : Promise.reject(err))) : Promise.resolve([]), - completionBlockers(ctx, wo.id), + wo.siteId ? fieldSiteHistory(ctx, wo.siteId).catch((err) => (err instanceof ServiceError ? [] : Promise.reject(err))) : Promise.resolve([]), + getCompletionBlockers(ctx, wo.id), ]); // only the newest version per lineage @@ -268,7 +299,7 @@ export async function getFieldBundle(ctx: ServiceCtx, since?: Date | null) { const siteIds = [...new Set(orders.map((o) => o.site?.id).filter((id): id is string => !!id))]; const histories = Object.fromEntries( - await Promise.all(siteIds.map(async (id) => [id, await getSiteHistory(ctx, id, { onlyApproved: true, limit: 5 }).catch(() => [])] as const)), + await Promise.all(siteIds.map(async (id) => [id, await fieldSiteHistory(ctx, id, 5).catch(() => [])] as const)), ); return { serverTime: serverTime.toISOString(), diff --git a/src/server/services/field/sessions.ts b/src/server/services/field/sessions.ts index 66d6f7c..d858b95 100644 --- a/src/server/services/field/sessions.ts +++ b/src/server/services/field/sessions.ts @@ -4,7 +4,7 @@ import { canTransition, type WorkOrderStatus } from "@/lib/work-orders/status"; import type { ParsedOpPayload } from "@/lib/sync/ops"; import { audit, opTime, requireFieldOrder, type FieldOrder } from "./common"; // TODO(merge L2): replace with "@/server/services/work-orders/transition" -import { transitionWorkOrder } from "./stubs/work-order-transition"; +import { transitionWorkOrder } from "@/server/services/work-orders/transition"; /** * Work sessions (Spec §12.1/§12.2): one active session per user + work order. A session consists diff --git a/src/server/services/field/stubs/documents-store.ts b/src/server/services/field/stubs/documents-store.ts deleted file mode 100644 index fa921ab..0000000 --- a/src/server/services/field/stubs/documents-store.ts +++ /dev/null @@ -1,97 +0,0 @@ -import { createHash, randomUUID } from "node:crypto"; -import type { Document, DocumentCategory, DocumentVisibility } from "@prisma/client"; -import { storage } from "@/server/storage/adapter"; -import { ServiceError, type ServiceCtx } from "@/server/services/context"; - -/** - * STUB (lane L4) — stands in for `src/server/services/documents/store.ts#storeFile` (ARCHITEKTUR §4.3), - * which does not exist on the base commit. Same signature; validates allowlist, size limit per - * kind, magic bytes, normalises the file name, computes SHA-256 and stores via the storage adapter. - * On merge of the documents contract: delete this file and import the shared implementation. - */ - -export type StoreFileInput = { - bytes: Buffer; - fileName: string; - declaredMime: string; - category: DocumentCategory; - visibility: DocumentVisibility; - links: { customerId?: string | null; siteId?: string | null; workOrderId?: string | null }; - lineageId?: string; - title?: string | null; -}; - -const MB = 1024 * 1024; - -type Kind = "image" | "pdf" | "audio"; -const LIMITS: Record = { image: 15 * MB, pdf: 25 * MB, audio: 20 * MB }; - -/** Detects the real MIME type from magic bytes (null = not allowed). */ -export function sniffMime(bytes: Buffer): { mime: string; kind: Kind } | null { - const b = bytes; - const at = (offset: number, sig: number[]) => sig.every((v, i) => b[offset + i] === v); - const ascii = (offset: number, s: string) => b.length >= offset + s.length && b.toString("latin1", offset, offset + s.length) === s; - if (b.length < 12) return null; - if (at(0, [0xff, 0xd8, 0xff])) return { mime: "image/jpeg", kind: "image" }; - if (at(0, [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])) return { mime: "image/png", kind: "image" }; - if (ascii(0, "RIFF") && ascii(8, "WEBP")) return { mime: "image/webp", kind: "image" }; - if (ascii(0, "%PDF-")) return { mime: "application/pdf", kind: "pdf" }; - if (at(0, [0x1a, 0x45, 0xdf, 0xa3])) return { mime: "audio/webm", kind: "audio" }; - if (ascii(0, "OggS")) return { mime: "audio/ogg", kind: "audio" }; - if (ascii(0, "RIFF") && ascii(8, "WAVE")) return { mime: "audio/wav", kind: "audio" }; - if (ascii(4, "ftyp")) { - const brand = b.toString("latin1", 8, 12); - if (/^(heic|heix|mif1|msf1)$/.test(brand)) return { mime: "image/heic", kind: "image" }; - return { mime: "audio/mp4", kind: "audio" }; // M4A / MP4 audio from iOS MediaRecorder - } - if (ascii(0, "ID3") || (b[0] === 0xff && (b[1] & 0xe0) === 0xe0)) return { mime: "audio/mpeg", kind: "audio" }; - return null; -} - -export function normalizeFileName(name: string): string { - const base = name.split(/[\\/]/).pop() ?? "datei"; - return base.normalize("NFC").replace(/[\u0000-\u001f<>:"|?*]+/g, "_").trim().slice(0, 180) || "datei"; -} - -export async function storeFile(ctx: ServiceCtx, input: StoreFileInput): Promise { - const sniffed = sniffMime(input.bytes); - if (!sniffed) throw new ServiceError("invalid", "file type not allowed"); - if (input.bytes.byteLength > LIMITS[sniffed.kind]) throw new ServiceError("invalid", "file too large"); - const declaredBase = input.declaredMime.split(";")[0].trim().toLowerCase(); - // declared type must at least belong to the same family (image/*, audio/*, video/webm|mp4 for audio containers, pdf) - const family = declaredBase.split("/")[0]; - const familyOk = - sniffed.kind === "pdf" ? declaredBase === "application/pdf" : sniffed.kind === "image" ? family === "image" : family === "audio" || declaredBase === "video/webm" || declaredBase === "video/mp4"; - if (!familyOk) throw new ServiceError("invalid", "declared MIME type does not match content"); - - const fileName = normalizeFileName(input.fileName); - const checksum = createHash("sha256").update(input.bytes).digest("hex"); - const stored = await storage.put({ tenantId: ctx.tenantId, filename: fileName, contentType: sniffed.mime, bytes: input.bytes }); - - let version = 1; - const lineageId = input.lineageId ?? randomUUID(); - if (input.lineageId) { - const last = await ctx.db.document.findFirst({ where: { lineageId }, orderBy: { version: "desc" }, select: { version: true } }); - if (last) version = last.version + 1; - } - return ctx.db.document.create({ - data: { - tenantId: ctx.tenantId, - customerId: input.links.customerId ?? null, - siteId: input.links.siteId ?? null, - workOrderId: input.links.workOrderId ?? null, - category: input.category, - title: input.title ?? null, - fileName, - storageKey: stored.storageKey, - mimeType: sniffed.mime, - fileSize: input.bytes.byteLength, - checksum, - version, - lineageId, - visibility: input.visibility, - uploadStatus: "uploaded", - uploadedById: ctx.userId, - }, - }); -} diff --git a/src/server/services/field/stubs/site-history.ts b/src/server/services/field/stubs/site-history.ts deleted file mode 100644 index 7554eab..0000000 --- a/src/server/services/field/stubs/site-history.ts +++ /dev/null @@ -1,51 +0,0 @@ -import { ServiceError, type ServiceCtx } from "@/server/services/context"; -import { siteScope } from "@/server/services/work-orders/visibility"; - -/** - * STUB (lane L4) — stands in for L1 `src/server/services/sites/history.ts#getSiteHistory` - * (Spec §8.3) until lane "Stammdaten" is merged. Contract used by the mobile order detail: - * getSiteHistory(ctx, siteId, { onlyApproved }) → SiteHistoryEntry[] (newest first) - * Read-only; the site must be in the user's site scope (otherwise not_found). - */ - -export type SiteHistoryEntry = { - reportId: string; - reportType: "daily" | "completion"; - reportDate: Date; - approvedAt: Date | null; - pdfDocumentId: string | null; - workOrderId: string; - workOrderNumber: string; - workOrderTitle: string; -}; - -export async function getSiteHistory(ctx: ServiceCtx, siteId: string, opts: { onlyApproved: boolean; limit?: number }): Promise { - const site = await ctx.db.site.findFirst({ where: { AND: [{ id: siteId }, await siteScope(ctx)] }, select: { id: true } }); - if (!site) throw new ServiceError("not_found", "site not found"); - const reports = await ctx.db.report.findMany({ - where: { - workOrder: { siteId, deletedAt: null }, - ...(opts.onlyApproved ? { status: "approved" as const } : { status: { not: "superseded" as const } }), - }, - orderBy: [{ reportDate: "desc" }, { createdAt: "desc" }], - take: opts.limit ?? 20, - select: { - id: true, - type: true, - reportDate: true, - approvedAt: true, - pdfDocumentId: true, - workOrder: { select: { id: true, number: true, title: true } }, - }, - }); - return reports.map((r) => ({ - reportId: r.id, - reportType: r.type, - reportDate: r.reportDate, - approvedAt: r.approvedAt, - pdfDocumentId: r.pdfDocumentId, - workOrderId: r.workOrder.id, - workOrderNumber: r.workOrder.number, - workOrderTitle: r.workOrder.title, - })); -} diff --git a/src/server/services/field/stubs/work-order-transition.ts b/src/server/services/field/stubs/work-order-transition.ts deleted file mode 100644 index a580b0d..0000000 --- a/src/server/services/field/stubs/work-order-transition.ts +++ /dev/null @@ -1,97 +0,0 @@ -import { emitEvent } from "@/server/events"; -import { writeAuditLog } from "@/server/audit"; -import { assertCan, can, ServiceError, type ServiceCtx } from "@/server/services/context"; -import { requireVisibleWorkOrder } from "@/server/services/work-orders/visibility"; -import type { EventType } from "@/lib/events"; -import { - canTransition, - requiredPermission, - type CompletionBlocker, - type WorkOrderStatus, -} from "@/lib/work-orders/status"; - -/** - * STUB (lane L4) — stands in for L2 `src/server/services/work-orders/transition.ts#transitionWorkOrder` - * until lane "Aufträge" is merged. Same contract as ARCHITEKTUR §3: - * transitionWorkOrder(ctx, { workOrderId, to, reason?, baseVersion? }) → { id, from, status, version } - * throws ServiceError not_found | forbidden | invalid | conflict | blocked (details: CompletionBlocker[]). - * On merge: delete this file and point the imports in services/field + services/sync to L2's module. - */ - -export type TransitionInput = { - workOrderId: string; - to: WorkOrderStatus; - reason?: string | null; - /** optimistic concurrency: must match WorkOrder.version when given */ - baseVersion?: number; -}; - -export type TransitionResult = { id: string; from: WorkOrderStatus; status: WorkOrderStatus; version: number }; - -const EVENT_FOR: Partial> = { - in_progress: "work_order.started", - daily_report_created: "work_order.daily_report_created", - technically_completed: "work_order.technically_completed", - released_for_billing: "work_order.released_for_billing", - cancelled: "work_order.cancelled", -}; - -/** Completion guards (ARCHITEKTUR §3): required checklist items, photo requirements, running sessions. */ -export async function completionBlockers(ctx: ServiceCtx, workOrderId: string): Promise { - const [items, requirements, sessions] = await Promise.all([ - ctx.db.checklistItem.findMany({ where: { workOrderId, required: true, checked: false }, select: { id: true, label: true }, orderBy: { sortOrder: "asc" } }), - ctx.db.photoRequirement.findMany({ where: { workOrderId, photos: { none: {} } }, select: { id: true, label: true }, orderBy: { sortOrder: "asc" } }), - ctx.db.workSession.findMany({ where: { workOrderId, status: { in: ["en_route", "running", "paused"] } }, select: { id: true, userId: true } }), - ]); - return [ - ...items.map((i): CompletionBlocker => ({ kind: "checklist_item", itemId: i.id, label: i.label })), - ...requirements.map((r): CompletionBlocker => ({ kind: "photo_requirement", requirementId: r.id, label: r.label })), - ...sessions.map((s): CompletionBlocker => ({ kind: "running_session", sessionId: s.id, userId: s.userId })), - ]; -} - -export async function transitionWorkOrder(ctx: ServiceCtx, input: TransitionInput): Promise { - const wo = await requireVisibleWorkOrder(ctx, input.workOrderId, { id: true, number: true, status: true, version: true }); - const from = wo.status as WorkOrderStatus; - if (!canTransition(from, input.to)) throw new ServiceError("invalid", `transition ${from} → ${input.to} not allowed`); - - const permission = requiredPermission(from, input.to); - if (permission === "report:approve_team") { - if (!can(ctx, "report:approve_team") && !can(ctx, "report:approve")) throw new ServiceError("forbidden", "missing permission report:approve_team"); - } else { - assertCan(ctx, permission); - } - if (input.baseVersion !== undefined && input.baseVersion !== wo.version) { - throw new ServiceError("conflict", "work order was changed in the meantime", { currentVersion: wo.version }); - } - if (input.to === "technically_completed") { - const blockers = await completionBlockers(ctx, wo.id); - if (blockers.length) throw new ServiceError("blocked", "completion requirements missing", blockers); - } - - const updated = await ctx.db.workOrder.updateMany({ - where: { id: wo.id, version: wo.version }, - data: { status: input.to, version: { increment: 1 } }, - }); - if (updated.count !== 1) throw new ServiceError("conflict", "work order was changed in the meantime"); - - await ctx.db.workOrderStatusChange.create({ - data: { tenantId: ctx.tenantId, workOrderId: wo.id, fromStatus: from, toStatus: input.to, actorId: ctx.userId, reason: input.reason ?? null }, - }); - await writeAuditLog({ - tenantId: ctx.tenantId, - actorId: ctx.userId, - action: "update", - entity: "work_order", - entityId: wo.id, - before: { status: from, version: wo.version }, - after: { status: input.to, version: wo.version + 1, reason: input.reason ?? null }, - }); - await emitEvent(ctx, { - type: EVENT_FOR[input.to] ?? "work_order.changed", - entityType: "work_order", - entityId: wo.id, - data: { number: wo.number, from, to: input.to }, - }); - return { id: wo.id, from, status: input.to, version: wo.version + 1 }; -} diff --git a/src/server/services/field/uploads.ts b/src/server/services/field/uploads.ts index 92932db..c6bc5a2 100644 --- a/src/server/services/field/uploads.ts +++ b/src/server/services/field/uploads.ts @@ -4,8 +4,8 @@ import { dispatchJob } from "@/server/jobs/dispatch"; import { JOB_QUEUES } from "@/server/jobs/queues"; import { ServiceError, type ServiceCtx } from "@/server/services/context"; import { audit, isUniqueViolation, requireFieldOrder } from "./common"; -// TODO(merge documents contract §4.3): replace with "@/server/services/documents/store" -import { sniffMime, storeFile } from "./stubs/documents-store"; +import { storeFile } from "@/server/services/documents/store"; +import { sniffMime } from "./mime"; /** * Binary uploads of the mobile app (ARCHITEKTUR §4.6: POST /api/v1/uploads → documentId). @@ -52,19 +52,24 @@ export async function storeFieldUpload( const expectedKind = meta.kind === "photo" ? "image" : "audio"; if (!sniffed || sniffed.kind !== expectedKind) throw new ServiceError("invalid", `file is not a valid ${expectedKind}`); + // The central store treats `lineageId` as "new version of an existing document", so the file is + // stored as a fresh document and the deterministic upload lineage (idempotency key per device + // clientId) is attached afterwards. A concurrent upload with the same clientId loses the unique + // (lineageId, version) race: its document is soft-deleted and the winner is returned. + const created = await storeFile(ctx, { + bytes: file.bytes, + fileName: file.name || (meta.kind === "photo" ? "foto.jpg" : "sprachnotiz.webm"), + declaredMime: file.type || sniffed.mime, + category: meta.kind, + visibility: "team", + links: { workOrderId: wo.id, siteId: wo.siteId, customerId: wo.customerId }, + }); let doc; try { - doc = await storeFile(ctx, { - bytes: file.bytes, - fileName: file.name || (meta.kind === "photo" ? "foto.jpg" : "sprachnotiz.webm"), - declaredMime: file.type || sniffed.mime, - category: meta.kind, - visibility: "team", - links: { workOrderId: wo.id, siteId: wo.siteId, customerId: wo.customerId }, - lineageId, - }); + doc = await ctx.db.document.update({ where: { id: created.id }, data: { lineageId } }); } catch (err) { if (isUniqueViolation(err)) { + await ctx.db.document.update({ where: { id: created.id }, data: { deletedAt: new Date() } }); const raced = await replay(); if (raced) return raced; } diff --git a/src/server/services/imports/confirm.ts b/src/server/services/imports/confirm.ts index 6ee359d..9104cd2 100644 --- a/src/server/services/imports/confirm.ts +++ b/src/server/services/imports/confirm.ts @@ -5,7 +5,7 @@ import { assertCan, inTransaction, ServiceError, type ServiceCtx } from "@/serve import { readStoredExtraction } from "@/lib/imports/extraction"; import { computeCorrections, reviewFormSchema, type ReviewForm } from "@/lib/imports/review"; // TODO(L3→L2): replace with the L2 work order service after merge (same input type). -import { createWorkOrder } from "./work-orders-stub"; +import { createWorkOrder } from "@/server/services/work-orders/create"; import { startExtraction, type Dispatch } from "./upload"; import { dispatchJob } from "@/server/jobs/dispatch"; @@ -168,13 +168,7 @@ export async function confirmImport(ctx: ServiceCtx, importId: string, rawForm: if (result.createdCustomer) await writeAuditLog({ ...base, action: "create", entity: "customer", entityId: result.customerId, after: { source: "import", importId: job.id } }); if (result.contactId) await writeAuditLog({ ...base, action: "create", entity: "contact", entityId: result.contactId, after: { customerId: result.customerId, source: "import" } }); if (result.createdSite && result.siteId) await writeAuditLog({ ...base, action: "create", entity: "site", entityId: result.siteId, after: { customerId: result.customerId, source: "import" } }); - await writeAuditLog({ - ...base, - action: "create", - entity: "work_order", - entityId: result.workOrderId, - after: { number: result.workOrderNumber, status: "planned", customerId: result.customerId, siteId: result.siteId, sourceImportId: job.id }, - }); + // work order creation is audited by services/work-orders/create (entity work_order) await writeAuditLog({ ...base, action: "import", diff --git a/src/server/services/imports/document-store-stub.ts b/src/server/services/imports/document-store-stub.ts deleted file mode 100644 index 960cd19..0000000 --- a/src/server/services/imports/document-store-stub.ts +++ /dev/null @@ -1,101 +0,0 @@ -import { createHash, randomUUID } from "node:crypto"; -import type { Document, DocumentCategory, DocumentVisibility } from "@prisma/client"; -import { storage } from "@/server/storage/adapter"; -import { ServiceError, type ServiceCtx } from "@/server/services/context"; - -/** - * STUB (lane L3) for the document service contract ARCHITEKTUR §4.3 - * `src/server/services/documents/store.ts#storeFile` — not present on the base commit. - * Same signature; replace the import in services/imports/upload.ts once the real service exists. - * - * Implements the parts the import needs: allowlist (PDF/JPEG/PNG), size limit per type - * (PDF 25 MB, images 15 MB), magic-byte check, normalised file name, SHA-256, storage.put, - * Document row with lineage. - */ - -export type StoreFileInput = { - bytes: Buffer; - fileName: string; - declaredMime: string; - category: DocumentCategory; - visibility: DocumentVisibility; - links: { customerId?: string | null; siteId?: string | null; workOrderId?: string | null }; - lineageId?: string; -}; - -const LIMITS: Record = { - "application/pdf": 25 * 1024 * 1024, - "image/jpeg": 15 * 1024 * 1024, - "image/png": 15 * 1024 * 1024, -}; - -/** Detect the real type from magic bytes (null = not allowed). */ -export function sniffMime(bytes: Buffer): string | null { - if (bytes.length >= 5 && bytes.subarray(0, 5).toString("latin1") === "%PDF-") return "application/pdf"; - if (bytes.length >= 3 && bytes[0] === 0xff && bytes[1] === 0xd8 && bytes[2] === 0xff) return "image/jpeg"; - if (bytes.length >= 8 && bytes.subarray(0, 8).equals(Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]))) return "image/png"; - return null; -} - -export function normalizeFileName(name: string): string { - const base = name.split(/[\\/]/).pop() ?? ""; - const cleaned = base.normalize("NFC").replace(/[\x00-\x1f<>:"|?*]+/g, "_").replace(/\s+/g, " ").trim(); - return (cleaned || "datei").slice(0, 180); -} - -export async function storeFile(ctx: ServiceCtx, input: StoreFileInput): Promise { - if (input.bytes.byteLength === 0) throw new ServiceError("invalid", "file_empty"); - const sniffed = sniffMime(input.bytes); - if (!sniffed) throw new ServiceError("invalid", "file_type_not_allowed"); - const declared = input.declaredMime === "image/jpg" ? "image/jpeg" : input.declaredMime; - // Browsers sometimes send application/octet-stream — the magic bytes are authoritative, - // but a declared allowed type must match them. - if (declared in LIMITS && declared !== sniffed) throw new ServiceError("invalid", "file_type_mismatch"); - if (input.bytes.byteLength > LIMITS[sniffed]) throw new ServiceError("invalid", "file_too_large"); - - const fileName = normalizeFileName(input.fileName); - const checksum = createHash("sha256").update(input.bytes).digest("hex"); - const stored = await storage.put({ tenantId: ctx.tenantId, filename: fileName, contentType: sniffed, bytes: input.bytes }); - - let version = 1; - const lineageId = input.lineageId ?? randomUUID(); - if (input.lineageId) { - const last = await ctx.db.document.findFirst({ where: { lineageId }, orderBy: { version: "desc" }, select: { version: true } }); - version = (last?.version ?? 0) + 1; - } - - return ctx.db.document.create({ - data: { - tenantId: ctx.tenantId, - customerId: input.links.customerId ?? null, - siteId: input.links.siteId ?? null, - workOrderId: input.links.workOrderId ?? null, - category: input.category, - title: fileName, - fileName, - storageKey: stored.storageKey, - mimeType: sniffed, - fileSize: input.bytes.byteLength, - checksum, - version, - lineageId, - visibility: input.visibility, - uploadStatus: "uploaded", - uploadedById: ctx.userId, - }, - }); -} - -/** Read the bytes of a stored document (null if the backend keeps no bytes, e.g. stub storage). */ -export async function readDocumentBytes(storageKey: string): Promise { - const content = await storage.get(storageKey); - if (!content) return null; - const chunks: Uint8Array[] = []; - const reader = content.stream.getReader(); - for (;;) { - const { done, value } = await reader.read(); - if (done) break; - if (value) chunks.push(value); - } - return Buffer.concat(chunks); -} diff --git a/src/server/services/imports/duplicates-stub.ts b/src/server/services/imports/duplicates-stub.ts deleted file mode 100644 index d0e5f76..0000000 --- a/src/server/services/imports/duplicates-stub.ts +++ /dev/null @@ -1,142 +0,0 @@ -import type { TenantDb } from "@/server/db"; -import type { DuplicateCandidate, SiteCandidate } from "@/lib/imports/extraction"; - -/** - * STUB (lane L3) for the L1 contract `src/lib/customers/duplicates.ts#findDuplicateCustomers(db, candidate) → Candidate[]` - * (ARCHITEKTUR §6, spec §7.3). Same signature and result shape `{ customerId, score, reasons[] }`; - * replace the import in services/imports/process.ts after the L1 merge. - * - * Compares customer number, company/person name, address, e-mail and phone. Never merges — - * it only proposes candidates; the backoffice decides (US-003). - */ - -export type CustomerCandidateInput = { - customerNumber?: string | null; - companyName?: string | null; - firstName?: string | null; - lastName?: string | null; - street?: string | null; - houseNumber?: string | null; - postalCode?: string | null; - city?: string | null; - email?: string | null; - phone?: string | null; -}; - -const LEGAL_FORMS = /\b(gmbh|mbh|ag|kg|ohg|gbr|ug|e\.?\s?k|e\.?\s?v|co|haftungsbeschränkt|und|&)\b/g; - -export function normalizeCompany(v: string | null | undefined): string { - return (v ?? "") - .toLowerCase() - .replace(/[.,;:()"'`´+/-]/g, " ") - .replace(LEGAL_FORMS, " ") - .replace(/\s+/g, " ") - .trim(); -} - -export function normalizeStreet(v: string | null | undefined): string { - return (v ?? "") - .toLowerCase() - .replace(/ß/g, "ss") - .replace(/strasse|str\./g, "str") - .replace(/[^a-z0-9äöü]/g, ""); -} - -const digits = (v: string | null | undefined) => (v ?? "").replace(/\D/g, "").replace(/^49/, "0").replace(/^00/, "0"); -const lc = (v: string | null | undefined) => (v ?? "").trim().toLowerCase(); - -export async function findDuplicateCustomers(db: TenantDb, candidate: CustomerCandidateInput): Promise { - const company = normalizeCompany(candidate.companyName); - const firstWord = company.split(" ").find((w) => w.length >= 3); - const or: object[] = []; - if (candidate.customerNumber) or.push({ customerNumber: candidate.customerNumber.trim() }); - if (candidate.email) or.push({ email: { equals: candidate.email.trim(), mode: "insensitive" } }); - if (firstWord) or.push({ companyName: { contains: firstWord, mode: "insensitive" } }); - if (candidate.lastName) or.push({ lastName: { equals: candidate.lastName.trim(), mode: "insensitive" } }); - if (candidate.postalCode) or.push({ postalCode: candidate.postalCode.trim() }); - if (candidate.phone) or.push({ phone: { not: null } }); - if (or.length === 0) return []; - - const rows = await db.customer.findMany({ - where: { deletedAt: null, status: { not: "merged" }, OR: or }, - select: { id: true, customerNumber: true, companyName: true, firstName: true, lastName: true, street: true, houseNumber: true, postalCode: true, email: true, phone: true, mobile: true }, - take: 200, - }); - - const out: DuplicateCandidate[] = []; - for (const c of rows) { - let score = 0; - const reasons: string[] = []; - if (candidate.customerNumber && c.customerNumber && c.customerNumber.trim() === candidate.customerNumber.trim()) { - score += 0.6; - reasons.push("customer_number"); - } - const cc = normalizeCompany(c.companyName); - if (company && cc) { - if (cc === company) { - score += 0.4; - reasons.push("company_name"); - } else if (cc.includes(company) || company.includes(cc)) { - score += 0.25; - reasons.push("company_name_similar"); - } - } - if (candidate.lastName && lc(c.lastName) === lc(candidate.lastName) && (!candidate.firstName || lc(c.firstName) === lc(candidate.firstName))) { - score += 0.3; - reasons.push("person_name"); - } - if (candidate.email && lc(c.email) === lc(candidate.email)) { - score += 0.3; - reasons.push("email"); - } - const phone = digits(candidate.phone); - if (phone.length >= 6 && [c.phone, c.mobile].some((p) => digits(p) === phone)) { - score += 0.2; - reasons.push("phone"); - } - if ( - candidate.postalCode && - c.postalCode === candidate.postalCode.trim() && - normalizeStreet(c.street) !== "" && - normalizeStreet(c.street) === normalizeStreet(candidate.street) - ) { - score += 0.25; - reasons.push("address"); - } - if (score >= 0.25) out.push({ customerId: c.id, score: Math.min(1, Math.round(score * 100) / 100), reasons }); - } - return out.sort((a, b) => b.score - a.score).slice(0, 5); -} - -/** Sites of the candidate customers at the same address (lane L3, spec §9.3 step 7). */ -export async function findSiteCandidates( - db: TenantDb, - customerIds: string[], - address: { name?: string | null; street?: string | null; houseNumber?: string | null; postalCode?: string | null } | null, -): Promise { - if (!customerIds.length || !address || (!address.street && !address.name)) return []; - const sites = await db.site.findMany({ - where: { customerId: { in: customerIds }, deletedAt: null }, - select: { id: true, customerId: true, name: true, street: true, houseNumber: true, postalCode: true }, - take: 200, - }); - const out: SiteCandidate[] = []; - for (const s of sites) { - let score = 0; - const reasons: string[] = []; - if (address.street && normalizeStreet(s.street) === normalizeStreet(address.street) && (!address.postalCode || s.postalCode === address.postalCode)) { - score += 0.6; - reasons.push("address"); - if (address.houseNumber && lc(s.houseNumber) === lc(address.houseNumber)) { - score += 0.3; - reasons.push("house_number"); - } - } - if (address.name && lc(s.name) === lc(address.name)) { - score += 0.3; - reasons.push("site_name"); - } - if (score >= 0.3) out.push({ siteId: s.id, customerId: s.customerId, name: s.name, score: Math.min(1, Math.round(score * 100) / 100), reasons }); - } - return out.sort((a, b) => b.score - a.score).slice(0, 5); -} diff --git a/src/server/services/imports/process.ts b/src/server/services/imports/process.ts index d72c8a3..0a17f59 100644 --- a/src/server/services/imports/process.ts +++ b/src/server/services/imports/process.ts @@ -11,7 +11,8 @@ import { } from "@/lib/imports/extraction"; import { checkPlausibility } from "@/lib/imports/plausibility"; // TODO(L3→L1): replace with "@/lib/customers/duplicates" after the L1 merge (same interface). -import { findDuplicateCustomers, findSiteCandidates } from "./duplicates-stub"; +import { findDuplicateCustomers } from "@/server/services/customers/duplicates"; +import { findSiteCandidates } from "./site-candidates"; export type ProcessDeps = { provider: DocumentExtractionProvider | null; @@ -76,7 +77,7 @@ export async function processImport(ctx: ServiceCtx, importId: string, deps: Pro } const address = fields.customerAddress.value ?? {}; - const duplicates = await findDuplicateCustomers(ctx.db, { + const duplicates = await findDuplicateCustomers(ctx, { customerNumber: fields.customerNumber.value, companyName: fields.companyName.value, firstName: fields.customerFirstName.value, diff --git a/src/server/services/imports/site-candidates.ts b/src/server/services/imports/site-candidates.ts new file mode 100644 index 0000000..6049bfd --- /dev/null +++ b/src/server/services/imports/site-candidates.ts @@ -0,0 +1,38 @@ +import type { TenantDb } from "@/server/db"; +import { normalizeStreet } from "@/lib/customers/duplicates"; +import type { SiteCandidate } from "@/lib/imports/extraction"; + +const lc = (v: string | null | undefined) => (v ?? "").trim().toLowerCase(); + +/** Sites of the candidate customers at the same address (lane L3, spec §9.3 step 7). */ +export async function findSiteCandidates( + db: TenantDb, + customerIds: string[], + address: { name?: string | null; street?: string | null; houseNumber?: string | null; postalCode?: string | null } | null, +): Promise { + if (!customerIds.length || !address || (!address.street && !address.name)) return []; + const sites = await db.site.findMany({ + where: { customerId: { in: customerIds }, deletedAt: null }, + select: { id: true, customerId: true, name: true, street: true, houseNumber: true, postalCode: true }, + take: 200, + }); + const out: SiteCandidate[] = []; + for (const s of sites) { + let score = 0; + const reasons: string[] = []; + if (address.street && normalizeStreet(s.street) === normalizeStreet(address.street) && (!address.postalCode || s.postalCode === address.postalCode)) { + score += 0.6; + reasons.push("address"); + if (address.houseNumber && lc(s.houseNumber) === lc(address.houseNumber)) { + score += 0.3; + reasons.push("house_number"); + } + } + if (address.name && lc(s.name) === lc(address.name)) { + score += 0.3; + reasons.push("site_name"); + } + if (score >= 0.3) out.push({ siteId: s.id, customerId: s.customerId, name: s.name, score: Math.min(1, Math.round(score * 100) / 100), reasons }); + } + return out.sort((a, b) => b.score - a.score).slice(0, 5); +} diff --git a/src/server/services/imports/upload.ts b/src/server/services/imports/upload.ts index 76450c4..1aa2371 100644 --- a/src/server/services/imports/upload.ts +++ b/src/server/services/imports/upload.ts @@ -5,7 +5,7 @@ import type { JobPayload } from "@/server/jobs/queues"; import { assertCan, ServiceError, type ServiceCtx } from "@/server/services/context"; import { IMPORT_MAX_BYTES, IMPORT_MIME_TYPES } from "@/lib/imports/status"; // TODO(L3→documents): replace with "@/server/services/documents/store" once available (ARCHITEKTUR §4.3). -import { storeFile } from "./document-store-stub"; +import { storeFile } from "@/server/services/documents/store"; export type UploadFile = { bytes: Buffer; fileName: string; mimeType: string }; export type Dispatch = (payload: JobPayload) => Promise; diff --git a/src/server/services/imports/work-orders-stub.ts b/src/server/services/imports/work-orders-stub.ts deleted file mode 100644 index 5da492b..0000000 --- a/src/server/services/imports/work-orders-stub.ts +++ /dev/null @@ -1,87 +0,0 @@ -import type { WorkOrder } from "@prisma/client"; -import { nextNumber } from "@/server/services/numbering"; -import { assertCan, type ServiceCtx } from "@/server/services/context"; - -/** - * STUB (lane L3) for the L2 contract `src/server/services/work-orders/*#createWorkOrder(ctx, input)` - * (ARCHITEKTUR §6: "Bestätigung → … Auftrag (über L2-Service createWorkOrder)"). - * Exported input type + function; after the L2 merge, services/imports/confirm.ts imports the - * real service instead. Kept minimal on purpose: number allocation, status history - * (review_required → planned), material plan. No events/audit here — the caller audits. - * - * `ctx.db` may be a transaction client (the confirm transaction passes one). - */ - -export type CreateWorkOrderInput = { - customerId: string; - siteId?: string | null; - contactId?: string | null; - title: string; - description?: string | null; - scope?: string | null; - externalOrderNumber?: string | null; - offerNumber?: string | null; - plannedStart?: Date | null; - plannedEnd?: Date | null; - internalNotes?: string | null; - sourceImportId?: string | null; - /** Initial status; imports pass "planned" after confirmation (history: review_required → planned). */ - status?: "draft" | "review_required" | "planned"; - materials?: Array<{ name: string; articleNumber?: string | null; plannedQuantity: number; unit: string }>; -}; - -export async function createWorkOrder(ctx: ServiceCtx, input: CreateWorkOrderInput): Promise { - assertCan(ctx, "work_order:write"); - const number = await nextNumber(ctx.db, ctx.tenantId, "work_order"); - const status = input.status ?? "draft"; - - const wo = await ctx.db.workOrder.create({ - data: { - tenantId: ctx.tenantId, - number, - customerId: input.customerId, - siteId: input.siteId ?? null, - contactId: input.contactId ?? null, - title: input.title, - description: input.description ?? null, - scope: input.scope ?? null, - externalOrderNumber: input.externalOrderNumber ?? null, - offerNumber: input.offerNumber ?? null, - plannedStart: input.plannedStart ?? null, - plannedEnd: input.plannedEnd ?? null, - internalNotes: input.internalNotes ?? null, - sourceImportId: input.sourceImportId ?? null, - status, - createdById: ctx.userId, - }, - }); - - const history: Array<{ fromStatus: WorkOrder["status"] | null; toStatus: WorkOrder["status"] }> = - status === "planned" && input.sourceImportId - ? [ - { fromStatus: null, toStatus: "review_required" }, - { fromStatus: "review_required", toStatus: "planned" }, - ] - : [{ fromStatus: null, toStatus: status }]; - for (const h of history) { - await ctx.db.workOrderStatusChange.create({ - data: { tenantId: ctx.tenantId, workOrderId: wo.id, fromStatus: h.fromStatus, toStatus: h.toStatus, actorId: ctx.userId }, - }); - } - - let sort = 0; - for (const m of input.materials ?? []) { - await ctx.db.materialPlan.create({ - data: { - tenantId: ctx.tenantId, - workOrderId: wo.id, - name: m.name, - articleNumber: m.articleNumber ?? null, - plannedQuantity: m.plannedQuantity, - unit: m.unit, - sortOrder: sort++, - }, - }); - } - return wo; -} diff --git a/src/server/services/reports/_stubs/documents.ts b/src/server/services/reports/_stubs/documents.ts deleted file mode 100644 index ee14efd..0000000 --- a/src/server/services/reports/_stubs/documents.ts +++ /dev/null @@ -1,88 +0,0 @@ -/** - * STUB (lane L5 „Berichte") for the shared file contract ARCHITEKTUR §4.3 - * `src/server/services/documents/store.ts#storeFile` (not yet provided by the architect / documents lane). - * - * Minimal implementation behind the contracted interface: allowlist + magic bytes + size limit, - * SHA-256, storage.put, Document row, lineage versioning. At merge the import in - * services/reports/*.ts is switched to the real store and this file is deleted. - */ -import { createHash, randomUUID } from "node:crypto"; -import type { Document, DocumentCategory, DocumentVisibility } from "@prisma/client"; -import { storage } from "@/server/storage/adapter"; -import { ServiceError, type ServiceCtx } from "@/server/services/context"; - -export type StoreFileInput = { - bytes: Uint8Array; - fileName: string; - declaredMime: string; - category: DocumentCategory; - visibility: DocumentVisibility; - links: { customerId?: string | null; siteId?: string | null; workOrderId?: string | null }; - lineageId?: string; - title?: string | null; -}; - -const LIMITS: Record = { "application/pdf": 25 * 1024 * 1024, "image/png": 15 * 1024 * 1024, "image/jpeg": 15 * 1024 * 1024, "image/webp": 15 * 1024 * 1024 }; - -export function sniffMime(bytes: Uint8Array): string | null { - const b = bytes; - if (b.length >= 5 && b[0] === 0x25 && b[1] === 0x50 && b[2] === 0x44 && b[3] === 0x46 && b[4] === 0x2d) return "application/pdf"; - if (b.length >= 8 && b[0] === 0x89 && b[1] === 0x50 && b[2] === 0x4e && b[3] === 0x47) return "image/png"; - if (b.length >= 3 && b[0] === 0xff && b[1] === 0xd8 && b[2] === 0xff) return "image/jpeg"; - if (b.length >= 12 && b[0] === 0x52 && b[1] === 0x49 && b[2] === 0x46 && b[3] === 0x46 && b[8] === 0x57 && b[9] === 0x45 && b[10] === 0x42 && b[11] === 0x50) return "image/webp"; - return null; -} - -export function sha256Hex(bytes: Uint8Array): string { - return createHash("sha256").update(bytes).digest("hex"); -} - -export async function storeFile(ctx: ServiceCtx, input: StoreFileInput): Promise { - const mime = sniffMime(input.bytes); - if (!mime || mime !== input.declaredMime) throw new ServiceError("invalid", "file type not allowed or does not match content"); - if (input.bytes.byteLength === 0 || input.bytes.byteLength > (LIMITS[mime] ?? 0)) throw new ServiceError("invalid", "file size not allowed"); - const fileName = input.fileName.normalize("NFC").replace(/[^\w.\- ]+/g, "_").slice(0, 120) || "datei"; - const checksum = sha256Hex(input.bytes); - const put = await storage.put({ tenantId: ctx.tenantId, filename: fileName, contentType: mime, bytes: input.bytes }); - - let version = 1; - const lineageId = input.lineageId ?? randomUUID(); - if (input.lineageId) { - const last = await ctx.db.document.findFirst({ where: { lineageId }, orderBy: { version: "desc" }, select: { version: true } }); - version = (last?.version ?? 0) + 1; - } - return ctx.db.document.create({ - data: { - tenantId: ctx.tenantId, - customerId: input.links.customerId ?? null, - siteId: input.links.siteId ?? null, - workOrderId: input.links.workOrderId ?? null, - category: input.category, - title: input.title ?? null, - fileName, - storageKey: put.storageKey, - mimeType: mime, - fileSize: input.bytes.byteLength, - checksum, - version, - lineageId, - visibility: input.visibility, - uploadStatus: "uploaded", - uploadedById: ctx.userId, - }, - }); -} - -/** Read the bytes of a stored document (worker/PDF rendering). null if the backend has no bytes. */ -export async function readFileBytes(storageKey: string): Promise { - const obj = await storage.get(storageKey); - if (!obj) return null; - const chunks: Uint8Array[] = []; - const reader = obj.stream.getReader(); - for (;;) { - const { done, value } = await reader.read(); - if (done) break; - if (value) chunks.push(value); - } - return Buffer.concat(chunks); -} diff --git a/src/server/services/reports/_stubs/work-orders.ts b/src/server/services/reports/_stubs/work-orders.ts deleted file mode 100644 index 901656d..0000000 --- a/src/server/services/reports/_stubs/work-orders.ts +++ /dev/null @@ -1,80 +0,0 @@ -/** - * STUB (lane L5 „Berichte") for contracts owned by lane L2 „Aufträge": - * - services/work-orders/transition.ts#transitionWorkOrder - * - services/work-orders/guards.ts#getCompletionBlockers (ARCHITEKTUR §3 „Guards vor Abschluss") - * - * Interface follows ARCHITEKTUR §3. At merge the architect replaces the imports in - * services/reports/*.ts with the L2 implementations and deletes this file. - * Contract extension used by L5 (for L6 mail deduplication): optional `eventData` is merged into the emitted event's - * `data` (e.g. `{ occurrenceId }`) — the L2 implementation should accept it as well. - */ -import type { WorkOrderStatus as DbWorkOrderStatus } from "@prisma/client"; -import type { DomainEvent } from "@/lib/events"; -import { canTransition, requiredPermission, type CompletionBlocker, type WorkOrderStatus } from "@/lib/work-orders/status"; -import { writeAuditLog } from "@/server/audit"; -import { emitEvent } from "@/server/events"; -import { can, ServiceError, type ServiceCtx } from "@/server/services/context"; -import { requireVisibleWorkOrder } from "@/server/services/work-orders/visibility"; - -export type TransitionInput = { - workOrderId: string; - to: WorkOrderStatus; - reason?: string | null; - /** optimistic concurrency (offline sync) */ - expectedVersion?: number; - /** extra event data, e.g. { occurrenceId } for repeatable events */ - eventData?: DomainEvent["data"]; -}; - -export async function transitionWorkOrder(ctx: ServiceCtx, input: TransitionInput): Promise<{ id: string; status: WorkOrderStatus; version: number }> { - const wo = await requireVisibleWorkOrder(ctx, input.workOrderId, { id: true, status: true, version: true, number: true }); - const from = wo.status as WorkOrderStatus; - if (!canTransition(from, input.to)) throw new ServiceError("invalid", `transition ${from} → ${input.to} not allowed`); - const perm = requiredPermission(from, input.to); - const allowed = perm === "report:approve_team" ? can(ctx, "report:approve_team") || can(ctx, "report:approve") : can(ctx, perm); - if (!allowed) throw new ServiceError("forbidden", `missing permission ${perm}`); - if (input.expectedVersion !== undefined && input.expectedVersion !== wo.version) { - throw new ServiceError("conflict", "work order version changed"); - } - const res = await ctx.db.workOrder.updateMany({ - where: { id: wo.id, version: wo.version }, - data: { status: input.to as DbWorkOrderStatus, version: { increment: 1 } }, - }); - if (res.count !== 1) throw new ServiceError("conflict", "work order changed concurrently"); - await ctx.db.workOrderStatusChange.create({ - data: { tenantId: ctx.tenantId, workOrderId: wo.id, fromStatus: from, toStatus: input.to, actorId: ctx.userId, reason: input.reason ?? null }, - }); - await writeAuditLog({ - tenantId: ctx.tenantId, - actorId: ctx.userId, - action: "update", - entity: "work_order", - entityId: wo.id, - before: { status: from }, - after: { status: input.to, reason: input.reason ?? null }, - }); - const eventType = - input.to === "daily_report_created" - ? "work_order.daily_report_created" - : input.to === "technically_completed" - ? "work_order.technically_completed" - : input.to === "signature_pending" - ? "work_order.signature_missing" - : "work_order.changed"; - await emitEvent(ctx, { type: eventType, entityType: "work_order", entityId: wo.id, data: { number: wo.number, from, to: input.to, ...input.eventData } }); - return { id: wo.id, status: input.to, version: wo.version + 1 }; -} - -export async function getCompletionBlockers(ctx: ServiceCtx, workOrderId: string): Promise { - await requireVisibleWorkOrder(ctx, workOrderId, { id: true }); - const [items, requirements, running] = await Promise.all([ - ctx.db.checklistItem.findMany({ where: { workOrderId, required: true, checked: false }, orderBy: { sortOrder: "asc" }, select: { id: true, label: true } }), - ctx.db.photoRequirement.findMany({ where: { workOrderId, photos: { none: {} } }, orderBy: { sortOrder: "asc" }, select: { id: true, label: true } }), - ctx.db.workSession.findMany({ where: { workOrderId, status: { in: ["en_route", "running", "paused"] } }, select: { id: true, userId: true } }), - ]); - return [ - ...items.map((i): CompletionBlocker => ({ kind: "checklist_item", itemId: i.id, label: i.label })), - ...requirements.map((r): CompletionBlocker => ({ kind: "photo_requirement", requirementId: r.id, label: r.label })), - ...running.map((s): CompletionBlocker => ({ kind: "running_session", sessionId: s.id, userId: s.userId })), - ]; -} diff --git a/src/server/services/reports/create.ts b/src/server/services/reports/create.ts index 7f4f561..ba61d95 100644 --- a/src/server/services/reports/create.ts +++ b/src/server/services/reports/create.ts @@ -7,7 +7,8 @@ import { assertCan, ServiceError, type ServiceCtx } from "@/server/services/cont import { nextNumber } from "@/server/services/numbering"; import { requireVisibleWorkOrder } from "@/server/services/work-orders/visibility"; // TODO(merge L2): import from "@/server/services/work-orders/transition" and ".../guards" -import { getCompletionBlockers, transitionWorkOrder } from "./_stubs/work-orders"; +import { getCompletionBlockers } from "@/server/services/work-orders/completion"; +import { transitionWorkOrder } from "@/server/services/work-orders/transition"; import { buildReportContent, tenantTimeZone } from "./build-content"; import { auditReport, reportAuditView } from "./common"; diff --git a/src/server/services/reports/pdf.ts b/src/server/services/reports/pdf.ts index f91c2ee..4ea7050 100644 --- a/src/server/services/reports/pdf.ts +++ b/src/server/services/reports/pdf.ts @@ -4,8 +4,8 @@ import { readFile } from "node:fs/promises"; import { join } from "node:path"; import { createTranslator } from "next-intl"; import { ServiceError, type ServiceCtx } from "@/server/services/context"; -// TODO(merge documents): import from "@/server/services/documents/store" -import { readFileBytes, storeFile } from "./_stubs/documents"; +import { readStoredBytes } from "@/server/services/documents/read"; +import { storeFile } from "@/server/services/documents/store"; import { tenantTimeZone } from "./build-content"; import { auditReport, contentOf, requireVisibleReport } from "./common"; @@ -18,7 +18,7 @@ async function loadReportMessages(locale: string): Promise { const doc = await ctx.db.document.findFirst({ where: { id: documentId, deletedAt: null }, select: { storageKey: true, mimeType: true } }); if (!doc || !doc.mimeType.startsWith("image/")) return null; - const bytes = await readFileBytes(doc.storageKey).catch(() => null); + const bytes = await readStoredBytes(doc.storageKey).catch(() => null); return bytes ? `data:${doc.mimeType};base64,${Buffer.from(bytes).toString("base64")}` : null; } diff --git a/src/server/services/reports/queries.ts b/src/server/services/reports/queries.ts index 713c5fd..20d6af9 100644 --- a/src/server/services/reports/queries.ts +++ b/src/server/services/reports/queries.ts @@ -5,7 +5,7 @@ import type { CompletionBlocker } from "@/lib/work-orders/status"; import { can, type ServiceCtx } from "@/server/services/context"; import { requireVisibleWorkOrder } from "@/server/services/work-orders/visibility"; // TODO(merge L2): import from "@/server/services/work-orders/guards" -import { getCompletionBlockers } from "./_stubs/work-orders"; +import { getCompletionBlockers } from "@/server/services/work-orders/completion"; import { tenantTimeZone } from "./build-content"; import { contentOf, reportScope, requireVisibleReport } from "./common"; diff --git a/src/server/services/reports/reject.ts b/src/server/services/reports/reject.ts index 856f9f0..5415611 100644 --- a/src/server/services/reports/reject.ts +++ b/src/server/services/reports/reject.ts @@ -3,7 +3,7 @@ import { z } from "zod"; import { emitEvent } from "@/server/events"; import { can, ServiceError, type ServiceCtx } from "@/server/services/context"; // TODO(merge L2): import from "@/server/services/work-orders/transition" -import { transitionWorkOrder } from "./_stubs/work-orders"; +import { transitionWorkOrder } from "@/server/services/work-orders/transition"; import { auditReport, contentOf, orderNumberOf, reportAuditView, requireVisibleReport } from "./common"; export const rejectReportSchema = z.object({ diff --git a/src/server/services/reports/signature.ts b/src/server/services/reports/signature.ts index e149fff..7f32d96 100644 --- a/src/server/services/reports/signature.ts +++ b/src/server/services/reports/signature.ts @@ -3,7 +3,7 @@ import { z } from "zod"; import { SIGNATURE_OUTCOMES, SIGNATURE_REASON_REQUIRED, type SignatureBlock } from "@/lib/reports/content"; import { can, assertCan, ServiceError, type ServiceCtx } from "@/server/services/context"; // TODO(merge L2): import from "@/server/services/work-orders/transition" -import { transitionWorkOrder } from "./_stubs/work-orders"; +import { transitionWorkOrder } from "@/server/services/work-orders/transition"; import { auditReport, contentOf, requireVisibleReport } from "./common"; const optText = (max: number) => diff --git a/src/server/services/reports/submit.ts b/src/server/services/reports/submit.ts index 41a707e..79bc2a4 100644 --- a/src/server/services/reports/submit.ts +++ b/src/server/services/reports/submit.ts @@ -5,7 +5,8 @@ import type { CompletionBlocker, WorkOrderStatus } from "@/lib/work-orders/statu import { emitEvent } from "@/server/events"; import { assertCan, ServiceError, type ServiceCtx } from "@/server/services/context"; // TODO(merge L2): import from "@/server/services/work-orders/transition" and ".../guards" -import { getCompletionBlockers, transitionWorkOrder } from "./_stubs/work-orders"; +import { getCompletionBlockers } from "@/server/services/work-orders/completion"; +import { transitionWorkOrder } from "@/server/services/work-orders/transition"; import { auditReport, assertEditable, orderNumberOf, refreshContent, reportAuditView, requireVisibleReport } from "./common"; export const submitReportSchema = z.object({ diff --git a/src/server/services/sync/apply.ts b/src/server/services/sync/apply.ts index 9ec5960..b44fa08 100644 --- a/src/server/services/sync/apply.ts +++ b/src/server/services/sync/apply.ts @@ -12,7 +12,7 @@ import { upsertMaterialUsage } from "@/server/services/field/materials"; import { attachPhoto } from "@/server/services/field/photos"; import { attachVoiceNote } from "@/server/services/field/voice"; // TODO(merge L2): replace with "@/server/services/work-orders/transition" -import { transitionWorkOrder } from "@/server/services/field/stubs/work-order-transition"; +import { transitionWorkOrder } from "@/server/services/work-orders/transition"; import { EXTERNAL_OP_OWNERS, EXTERNAL_OPS } from "./external-ops"; /** diff --git a/src/server/services/work-orders/documents.ts b/src/server/services/work-orders/documents.ts index f4fd854c892974f49d02e23bda51868d5c3b6e5e..d98f96c8e55c2d2e08c13f5ea12aeeaa0ecfc166 100644 GIT binary patch delta 673 zcmYjOu}&L75S4|L5DWVxE1?NerU(&^F6S6U6rgkoQi_C%G*rv(;2UwiwRYElMJ!8c zXee@83DHqfrJ_lLBJo)%5_a#{bGDj!^Y+b~nV+kFmy3WkvC{__t`U z8j)=`X!b$!@ep&Pff>ViiUN!T2FFqzf>Z((tmzSQxV!GZP5QmN>-Bft1irG21T!S_ z7`!qYU<^m7v_mK#0Lz_DicNS8;-Leuu^ByCKOFM{xL-K;+zZQTrL<)aEY*Tr@t&L89@D(c)b#T5?36vS zT&pRIN|lLWsuz`2(LqQ0Q+81a96eR(Y${570tFML7rjRE7ir{J7Sb*nMSrTaG*jMB z3yCAGt_NnU(tIM?oqZ$pozVSyq0cj3S)rHDaDzQCT=rN>KhAMNrsA#EO81!auNIxy z`wg1?cGFK)X~q4DJA9T&`&XsEeA1bKvO@99#Z!nz`YRgeIQdf1QPUrpTdqY0$2 z$~^xlry_P)7_v)Q+Wlj#`Jy{S^b`vFidc%mh2bL}BPh5304~d%#p9$9<;32yqeHgG zzWIiYuePt*;o%|MzDJl&uoDD(+g`961cQMWylMpBdchzFcC#+i!p?O0G?ANpc;(MG zKIFL9!0#}V>|l@?d#W8 zFG3Lau5)>(E`R>;>G*VXa(waj&!0Y@y*-DAS5&Qzfk)VsO9V<46}c+5BE2n9?Zqh5 ze2yCO;=E8wkKk0jZ7!+4Mw#Rj&8L13^_=OrSd1jNMbBKB8oy+CF6?3ySlbILtupLd zlLmeqmC6dgQGt{-e!XVg)X6q*+2m3f+#t_XSuC2~8oKS(HRz&F1~-c`Wes;cL+df! zrM(}9Zi&`NIdNTLjmHBJ4m_JIv(Ev`Ysj>lGLaV4>4>jdDLB>51;Z!4AF#V-LTFdp zso{WGX$$ct=n6#l*z0s1+co{)ccds}emK|YIZ>Ce?-4RrfCQg&X*YIqf8M1aS?Xg> z0r96_X}xvS8I5`RMddkAI-&y0^Kb`lVu>RR z0%MtF0`Ox|Yi-oZ8;`c)r=SOJ8l3pnaCO~HWSJJTOqe=|a2>zmhAi(u3K=;OcEf!y zg9A-cU2_r{GTXK(pDEXTT3^FP)Nt*^GE^vS{@99i3*9Lkq{MDINgb?d$rE=0;mFDo zx^));6OFNFvmZi4*o5s7&5u-{BR7#?fO7#oQmr(QaDgWD^Mh}&uBQ@NEhS=k{iq&C zu4+H8pGU(Jf0P=bz9tUX%_W2~BvO9D=H7|0#rf~+A-xsp7c-mUR6F{|5r#yJRp8!P zMI2tI+}~!B$%!yFj&8;Ma%s7-kU-$eYiS~jAD9&-FYQ@2B+5y`SI?1$Z#BU=%#1XJ zm-Yf_N5#|NBQQNWHUq(RBR&OrL?48u096T;D8{yVH7I4V@#ep7| z^@HyiKyjtAdQA`D#CnC^pLj2B0M*LHZo-*+y3#EW?{C5S^PElXB3HB0HP7IuYa(8{ zgu6|DQSj))x#cNjYw6HB!+CUmU={YGW@S_y1dd-&M-FWzYz^8f$<