From 952e74cddd353f71c4b486de8f2dd02709bc097c Mon Sep 17 00:00:00 2001 From: Martin Date: Mon, 14 Sep 2026 12:29:48 +0200 Subject: [PATCH] L4 Einsatz mobil: Field-Services, Sync-API, Uploads und Tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - services/field: Einsatz-Sessions (Anfahrt/Arbeit/Pause als TimeEntry-Segmente, eine aktive Session je User+Auftrag), Zeitkorrektur mit Recht + Grund + Audit, Checkliste, Material (Abweichung nur mit Begründung, Zusatzmaterial), Notizen, Fotos, Sprachnotizen (ohne Transkriptions-Processor Status disabled), Uploads (idempotent je Mandant), autorisierte Dokument-Auslieferung, Lesemodelle + Bundle - services/sync: applyOperations mit Idempotenz, baseVersion-Konfliktprüfung, Registry für Ops anderer Lanes, lane-lokaler requireApiContext - /api/v1/sync, /api/v1/uploads, /api/v1/field/bundle, /api/v1/field/documents/[id] - lib/sync/ops.ts (Zod-Payloads je opType), lib/field/material-rules.ts - Stubs mit Vertragssignatur: transitionWorkOrder (L2), storeFile (§4.3), getSiteHistory (L1) - Processor image-derivatives + Registrierung, Audit-Entity-Labels - Tests: test-einsatz-field (48 Prüfungen), test-einsatz-sync (38 Prüfungen) Co-Authored-By: Claude Opus 5 --- scripts/lib/einsatz-fixture.ts | 133 +++++++++ scripts/test-einsatz-field.ts | 146 +++++++++ scripts/test-einsatz-sync.ts | 158 ++++++++++ src/app/api/v1/field/bundle/route.ts | 14 + src/app/api/v1/field/documents/[id]/route.ts | 27 ++ src/app/api/v1/sync/route.ts | 14 + src/app/api/v1/uploads/route.ts | 34 +++ src/components/audit-trail.tsx | 2 + src/lib/field/material-rules.ts | 35 +++ src/lib/sync/ops.ts | 136 +++++++++ src/server/actions/field/time.ts | 35 +++ .../jobs/processors/image-derivatives.ts | 48 +++ src/server/jobs/processors/index.ts | 2 +- src/server/services/field/checklist.ts | 22 ++ src/server/services/field/common.ts | 65 ++++ src/server/services/field/documents.ts | 55 ++++ src/server/services/field/materials.ts | 72 +++++ src/server/services/field/notes.ts | 26 ++ src/server/services/field/page-context.ts | 15 + src/server/services/field/photos.ts | 71 +++++ src/server/services/field/queries.ts | 278 ++++++++++++++++++ src/server/services/field/sessions.ts | 179 +++++++++++ .../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/time-correction.ts | 56 ++++ src/server/services/field/uploads.ts | 98 ++++++ src/server/services/field/voice.ts | 74 +++++ src/server/services/sync/api-context.ts | 57 ++++ src/server/services/sync/apply.ts | 186 ++++++++++++ src/server/services/sync/external-ops.ts | 30 ++ 31 files changed, 2312 insertions(+), 1 deletion(-) create mode 100644 scripts/lib/einsatz-fixture.ts create mode 100644 scripts/test-einsatz-field.ts create mode 100644 scripts/test-einsatz-sync.ts create mode 100644 src/app/api/v1/field/bundle/route.ts create mode 100644 src/app/api/v1/field/documents/[id]/route.ts create mode 100644 src/app/api/v1/sync/route.ts create mode 100644 src/app/api/v1/uploads/route.ts create mode 100644 src/lib/field/material-rules.ts create mode 100644 src/lib/sync/ops.ts create mode 100644 src/server/actions/field/time.ts create mode 100644 src/server/jobs/processors/image-derivatives.ts create mode 100644 src/server/services/field/checklist.ts create mode 100644 src/server/services/field/common.ts create mode 100644 src/server/services/field/documents.ts create mode 100644 src/server/services/field/materials.ts create mode 100644 src/server/services/field/notes.ts create mode 100644 src/server/services/field/page-context.ts create mode 100644 src/server/services/field/photos.ts create mode 100644 src/server/services/field/queries.ts create mode 100644 src/server/services/field/sessions.ts create mode 100644 src/server/services/field/stubs/documents-store.ts create mode 100644 src/server/services/field/stubs/site-history.ts create mode 100644 src/server/services/field/stubs/work-order-transition.ts create mode 100644 src/server/services/field/time-correction.ts create mode 100644 src/server/services/field/uploads.ts create mode 100644 src/server/services/field/voice.ts create mode 100644 src/server/services/sync/api-context.ts create mode 100644 src/server/services/sync/apply.ts create mode 100644 src/server/services/sync/external-ops.ts diff --git a/scripts/lib/einsatz-fixture.ts b/scripts/lib/einsatz-fixture.ts new file mode 100644 index 0000000..9c4a6c7 --- /dev/null +++ b/scripts/lib/einsatz-fixture.ts @@ -0,0 +1,133 @@ +// Shared fixture of the lane L4 tests (scripts/test-einsatz-*.ts): two zz test tenants with +// users, customer, site, team and work orders. Not a test itself (the runner only picks up +// scripts/test-*.ts at top level). + +import { Prisma } from "@prisma/client"; +import { prisma, dbForTenant } from "../../src/server/db"; +import { ROLE_DEFS, type RoleKey } from "../../src/server/rbac"; +import type { ServiceCtx } from "../../src/server/services/context"; + +export let failures = 0; +export const ok = (cond: boolean, msg: string) => { + console.log(`${cond ? "✓" : "✗ FEHLER"} ${msg}`); + if (!cond) failures++; +}; + +/** Expects `fn` to throw a ServiceError with the given code. */ +export async function expectCode(fn: () => Promise, code: string, msg: string) { + try { + await fn(); + ok(false, `${msg} — kein Fehler (erwartet ${code})`); + } catch (err) { + const actual = (err as { code?: string }).code; + ok(actual === code, `${msg}${actual === code ? "" : ` — Code ${actual ?? (err as Error).message}`}`); + } +} + +export function ctxFor(tenantId: string, userId: string, role: RoleKey): ServiceCtx { + return { db: dbForTenant(tenantId), tenantId, userId, permissions: new Set(ROLE_DEFS[role].permissions) }; +} + +async function cleanupSlug(slug: string, emailDomain: string) { + const tenant = await prisma.tenant.findUnique({ where: { slug }, select: { id: true } }); + if (tenant) { + const where = { tenantId: tenant.id }; + await prisma.auditLog.deleteMany({ where }); + await prisma.syncOperation.deleteMany({ where }); + await prisma.photo.deleteMany({ where }); + await prisma.voiceNote.deleteMany({ where }); + await prisma.document.deleteMany({ where }); + await prisma.workOrder.deleteMany({ where }); + await prisma.teamMember.deleteMany({ where }); + await prisma.team.deleteMany({ where }); + await prisma.site.deleteMany({ where }); + await prisma.contact.deleteMany({ where }); + await prisma.customer.deleteMany({ where }); + await prisma.numberSequence.deleteMany({ where }); + await prisma.userRole.deleteMany({ where: { user: { tenantId: tenant.id } } }); + await prisma.user.deleteMany({ where }); + await prisma.role.deleteMany({ where }); + await prisma.tenant.delete({ where: { id: tenant.id } }); + } + await prisma.identity.deleteMany({ where: { email: { endsWith: emailDomain }, memberships: { none: {} } } }); +} + +async function user(tenantId: string, email: string, name: string) { + const identity = await prisma.identity.upsert({ where: { email }, update: {}, create: { email, passwordHash: "x" } }); + return prisma.user.create({ data: { tenantId, identityId: identity.id, email, name } }); +} + +export type Fixture = Awaited>; + +export async function createFixture(prefix: string) { + const slugA = `zz-${prefix}-a`; + const slugB = `zz-${prefix}-b`; + const domain = `@zz-${prefix}.test`; + await cleanupSlug(slugA, domain); + await cleanupSlug(slugB, domain); + + const tenantA = await prisma.tenant.create({ data: { name: `L4 ${prefix} A`, slug: slugA } }); + const tenantB = await prisma.tenant.create({ data: { name: `L4 ${prefix} B`, slug: slugB } }); + + const tech = await user(tenantA.id, `tech${domain}`, "Tech A"); + const lead = await user(tenantA.id, `lead${domain}`, "Lead A"); + const outsider = await user(tenantA.id, `outsider${domain}`, "Outsider A"); + const techB = await user(tenantB.id, `techb${domain}`, "Tech B"); + + const customerA = await prisma.customer.create({ data: { tenantId: tenantA.id, companyName: "Kunde A GmbH", city: "Hamburg" } }); + const siteA = await prisma.site.create({ data: { tenantId: tenantA.id, customerId: customerA.id, name: "Halle 1", street: "Hafenstraße", houseNumber: "1", postalCode: "20457", city: "Hamburg", accessNotes: "Schlüssel beim Pförtner" } }); + const team = await prisma.team.create({ data: { tenantId: tenantA.id, name: `Team ${prefix}`, leaderUserId: lead.id } }); + + const now = new Date(); + const orderA = await prisma.workOrder.create({ + data: { + tenantId: tenantA.id, + number: `A-${prefix}-1`, + customerId: customerA.id, + siteId: siteA.id, + title: "Heizung montieren", + status: "assigned", + plannedStart: new Date(now.getTime() - 60 * 60 * 1000), + plannedEnd: new Date(now.getTime() + 60 * 60 * 1000), + teamLeadUserId: lead.id, + assignees: { create: [{ tenantId: tenantA.id, userId: tech.id }] }, + checklistItems: { create: [{ tenantId: tenantA.id, key: "safe", label: "Arbeitsbereich abgesichert", required: true }] }, + photoRequirements: { create: [{ tenantId: tenantA.id, key: "typenschild", label: "Typenschild" }] }, + materialPlans: { create: [{ tenantId: tenantA.id, name: "Kupferrohr 15 mm", plannedQuantity: new Prisma.Decimal(10), unit: "m" }] }, + }, + include: { checklistItems: true, photoRequirements: true, materialPlans: true }, + }); + + const customerB = await prisma.customer.create({ data: { tenantId: tenantB.id, companyName: "Kunde B AG" } }); + const orderB = await prisma.workOrder.create({ + data: { + tenantId: tenantB.id, + number: `A-${prefix}-B1`, + customerId: customerB.id, + title: "Wartung B", + status: "assigned", + assignees: { create: [{ tenantId: tenantB.id, userId: techB.id }] }, + }, + }); + + return { + tenantA, + tenantB, + tech, + lead, + outsider, + techB, + team, + siteA, + orderA, + orderB, + ctxTech: ctxFor(tenantA.id, tech.id, "technician"), + ctxLead: ctxFor(tenantA.id, lead.id, "team-lead"), + ctxOutsider: ctxFor(tenantA.id, outsider.id, "technician"), + ctxB: ctxFor(tenantB.id, techB.id, "technician"), + cleanup: async () => { + await cleanupSlug(slugA, domain); + await cleanupSlug(slugB, domain); + }, + }; +} diff --git a/scripts/test-einsatz-field.ts b/scripts/test-einsatz-field.ts new file mode 100644 index 0000000..da11770 --- /dev/null +++ b/scripts/test-einsatz-field.ts @@ -0,0 +1,146 @@ +// Lane L4 „Einsatz mobil" — Service-Tests: Einsatz-Sessions (Zustände, Pause-Segmente, Dauer), +// Zeitkorrektur (Recht + Grund + Audit), Material (Abweichung ohne Grund → invalid, Zusatzmaterial, +// Idempotenz), Checkliste/Notizen, Abschluss-Guards, Rollen/Scope und Mandantentrennung. +// +// Lauf: npx tsx scripts/test-einsatz-field.ts (lokale Postgres-DB aus .env) + +import "dotenv/config"; +import { prisma } from "../src/server/db"; +import { closeJobQueues } from "../src/server/jobs/queues"; +import { endSession, pauseSession, resumeSession, startSession } from "../src/server/services/field/sessions"; +import { correctTimeEntry } from "../src/server/services/field/time-correction"; +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 { createFixture, expectCode, failures, ok } from "./lib/einsatz-fixture"; + +const min = (n: number) => n * 60 * 1000; + +async function main() { + const f = await createFixture("l4field"); + const wo = f.orderA.id; + try { + console.log("\n— Einsatz-Session: Zustände —"); + const t0 = Date.now() - min(120); + const at = (m: number) => new Date(t0 + min(m)).toISOString(); + + const travel = await startSession(f.ctxTech, { workOrderId: wo, mode: "travel", at: at(0), offline: true, deviceInfo: "test", latitude: 53.5, longitude: 10 }); + ok(travel.status === "en_route" && travel.workOrderStatus === "en_route", "Losfahren → Session en_route, Auftrag en_route"); + const s0 = await prisma.workSession.findUnique({ where: { id: travel.sessionId } }); + ok(s0?.startedOffline === true && s0.startLat === 53.5 && s0.deviceInfo === "test", "Start speichert Standort, Gerätestatus und Offline-Flag"); + await expectCode(() => startSession(f.ctxTech, { workOrderId: wo, mode: "travel", offline: false }), "conflict", "zweites Losfahren → conflict"); + + const work = await startSession(f.ctxTech, { workOrderId: wo, mode: "work", at: at(10), offline: false }); + ok(work.sessionId === travel.sessionId && work.status === "running" && work.workOrderStatus === "in_progress", "Arbeit starten übernimmt die Anfahrt-Session → running, Auftrag in_progress"); + await expectCode(() => startSession(f.ctxTech, { workOrderId: wo, mode: "work", offline: false }), "conflict", "keine doppelte laufende Session je User+Auftrag"); + const active = await prisma.workSession.count({ where: { workOrderId: wo, userId: f.tech.id, status: { in: ["en_route", "running", "paused"] } } }); + ok(active === 1, "genau eine aktive Session"); + + const paused = await pauseSession(f.ctxTech, { workOrderId: wo, at: at(40) }); + ok(paused.status === "paused" && paused.workOrderStatus === "paused", "Pause → Session paused, Auftrag paused"); + await expectCode(() => pauseSession(f.ctxTech, { workOrderId: wo }), "invalid", "Pause ohne laufende Session → invalid"); + const resumed = await resumeSession(f.ctxTech, { workOrderId: wo, at: at(50) }); + ok(resumed.status === "running" && resumed.workOrderStatus === "in_progress", "Weiter → running, Auftrag in_progress"); + + const entries = await prisma.timeEntry.findMany({ where: { workSessionId: travel.sessionId }, orderBy: { startedAt: "asc" } }); + ok(entries.map((e) => e.type).join(",") === "travel,work,break,work", "Zeitabschnitte als Segmente travel → work → break → work"); + ok(entries.slice(0, 3).every((e) => e.endedAt), "abgeschlossene Segmente haben ein Ende"); + + console.log("\n— Abschluss-Guards —"); + await expectCode(() => transitionWorkOrder(f.ctxTech, { workOrderId: wo, to: "technically_completed" }), "blocked", "Abschluss mit offener Pflicht-Checkliste/Pflichtfoto/laufender Session → blocked"); + + const ended = await endSession(f.ctxTech, { workOrderId: wo, at: at(80) }); + ok(ended.status === "ended", "Ende → Session ended"); + ok(ended.workSeconds === 3600 && ended.breakSeconds === 600 && ended.travelSeconds === 600 && ended.totalSeconds === 4800, `Ende berechnet Dauer (Arbeit ${ended.workSeconds}s, Pause ${ended.breakSeconds}s, Anfahrt ${ended.travelSeconds}s)`); + await expectCode(() => endSession(f.ctxTech, { workOrderId: wo }), "invalid", "Ende ohne aktive Session → invalid"); + + console.log("\n— Zeitkorrektur —"); + const entry = entries[1]; + await expectCode( + () => correctTimeEntry(f.ctxTech, { timeEntryId: entry.id, startedAt: at(12), endedAt: at(40), reason: "vergessen" }), + "forbidden", + "Monteur ohne field:correct_time → forbidden", + ); + await expectCode(() => correctTimeEntry(f.ctxLead, { timeEntryId: entry.id, startedAt: at(12), endedAt: at(40), reason: "" }), "invalid", "Korrektur ohne Grund → invalid"); + await expectCode(() => correctTimeEntry(f.ctxLead, { timeEntryId: entry.id, startedAt: at(40), endedAt: at(12), reason: "Ende vor Start" }), "invalid", "Ende vor Beginn → invalid"); + const corrected = await correctTimeEntry(f.ctxLead, { timeEntryId: entry.id, startedAt: at(12), endedAt: at(40), reason: "Start zu früh gestempelt" }); + ok(corrected.corrected && corrected.correctionReason === "Start zu früh gestempelt" && corrected.correctedById === f.lead.id, "Teamleiter korrigiert mit Grund → corrected-Flag + Grund + Bearbeiter"); + const auditRow = await prisma.auditLog.findFirst({ where: { tenantId: f.tenantA.id, entity: "time_entry", entityId: entry.id }, orderBy: { createdAt: "desc" } }); + const before = auditRow?.before as { startedAt?: string } | null; + const after = auditRow?.after as { startedAt?: string; correctionReason?: string } | null; + ok(!!auditRow && new Date(before!.startedAt!).getTime() === t0 + min(10) && new Date(after!.startedAt!).getTime() === t0 + min(12) && after?.correctionReason === "Start zu früh gestempelt", "Audit mit before/after der Korrektur"); + await expectCode(() => correctTimeEntry(f.ctxB as never, { timeEntryId: entry.id, startedAt: at(12), reason: "fremd" }), "forbidden", "Mandant B (Monteur) kann nicht korrigieren"); + const leadB = { ...f.ctxB, permissions: new Set([...f.ctxB.permissions, "field:correct_time"]) }; + await expectCode(() => correctTimeEntry(leadB, { timeEntryId: entry.id, startedAt: at(12), reason: "fremder Mandant" }), "not_found", "Mandant B mit Korrekturrecht sieht Zeiteintrag von A nicht → not_found"); + + console.log("\n— Material —"); + const plan = f.orderA.materialPlans[0]; + await expectCode(() => upsertMaterialUsage(f.ctxTech, { workOrderId: wo, materialPlanId: plan.id, quantity: 5, unit: "m", usageStatus: "partially_used" }), "invalid", "teilweise verwendet ohne Begründung → invalid"); + await expectCode(() => upsertMaterialUsage(f.ctxTech, { workOrderId: wo, materialPlanId: plan.id, quantity: 12, unit: "m", usageStatus: "fully_used" }), "invalid", "Mehrmenge ohne Begründung → invalid"); + await expectCode(() => upsertMaterialUsage(f.ctxTech, { workOrderId: wo, materialPlanId: plan.id, quantity: 3, unit: "m", usageStatus: "not_used", deviationReason: "x" }), "invalid", "nicht verwendet mit Menge > 0 → invalid"); + const partial = await upsertMaterialUsage(f.ctxTech, { workOrderId: wo, materialPlanId: plan.id, quantity: 5, unit: "m", usageStatus: "partially_used", deviationReason: "Leitung kürzer" }); + const full = await upsertMaterialUsage(f.ctxTech, { workOrderId: wo, materialPlanId: plan.id, quantity: 10, unit: "m", usageStatus: "fully_used" }); + ok(partial.usageId === full.usageId, "Bestätigung derselben Planposition aktualisiert statt doppelt anzulegen"); + const planUsage = await prisma.materialUsage.findUnique({ where: { id: full.usageId } }); + ok(planUsage?.usageStatus === "fully_used" && Number(planUsage.actualQuantity) === 10 && planUsage.deviationReason === null, "vollständig verwendet ohne Abweichung braucht keine Begründung"); + + await expectCode(() => upsertMaterialUsage(f.ctxTech, { workOrderId: wo, name: "Muffe", quantity: 2, unit: "Stk", usageStatus: "additional" }), "invalid", "Zusatzmaterial ohne Grund → invalid"); + const clientId = "7f1c1d2e-3a4b-4c5d-8e6f-7a8b9c0d1e2f"; + const add1 = await upsertMaterialUsage(f.ctxTech, { workOrderId: wo, clientId, name: "Muffe", quantity: 2, unit: "Stk", usageStatus: "additional", deviationReason: "Übergang fehlte" }); + const add2 = await upsertMaterialUsage(f.ctxTech, { workOrderId: wo, clientId, name: "Muffe", quantity: 3, unit: "Stk", usageStatus: "additional", deviationReason: "Übergang fehlte" }); + ok(add1.usageId === add2.usageId && (await prisma.materialUsage.count({ where: { workOrderId: wo } })) === 2, "Zusatzmaterial idempotent über clientId"); + + console.log("\n— Checkliste & Notizen —"); + const item = f.orderA.checklistItems[0]; + const toggled = await toggleChecklistItem(f.ctxTech, { workOrderId: wo, itemId: item.id, checked: true, comment: "erledigt" }); + const itemRow = await prisma.checklistItem.findUnique({ where: { id: item.id } }); + ok(toggled.checked && itemRow?.checkedById === f.tech.id && itemRow.comment === "erledigt", "Checklistenpunkt abhaken mit Kommentar"); + const noteClient = "1b2c3d4e-5f60-4718-8293-a4b5c6d7e8f9"; + const n1 = await createNote(f.ctxTech, { workOrderId: wo, clientId: noteClient, kind: "problem", text: "Ventil klemmt" }); + const n2 = await createNote(f.ctxTech, { workOrderId: wo, clientId: noteClient, kind: "problem", text: "Ventil klemmt" }); + ok(n1.noteId === n2.noteId && (await prisma.activityNote.count({ where: { workOrderId: wo } })) === 1, "Notiz idempotent über clientId"); + const noteAudit = await prisma.auditLog.count({ where: { tenantId: f.tenantA.id, entity: "activity_note", entityId: n1.noteId } }); + ok(noteAudit === 1, "Notiz erzeugt genau einen Audit-Eintrag"); + + console.log("\n— Rollen/Scope (Monteur ohne Zuweisung) —"); + await expectCode(() => startSession(f.ctxOutsider, { workOrderId: wo, mode: "work", offline: false }), "not_found", "fremder Auftrag: Session starten → not_found"); + await expectCode(() => toggleChecklistItem(f.ctxOutsider, { workOrderId: wo, itemId: item.id, checked: false }), "not_found", "fremder Auftrag: Checkliste → not_found"); + await expectCode(() => createNote(f.ctxOutsider, { workOrderId: wo, kind: "general", text: "x" }), "not_found", "fremder Auftrag: Notiz → not_found"); + await expectCode(() => getFieldOrderDetail(f.ctxOutsider, wo), "not_found", "fremder Auftrag: Detail → not_found"); + ok((await listFieldOrders(f.ctxOutsider, "running")).length === 0 && (await listTodayOrders(f.ctxOutsider)).length === 0, "Monteur ohne Zuweisung sieht keine Aufträge"); + const noExecute = { ...f.ctxTech, permissions: new Set([...f.ctxTech.permissions].filter((p) => p !== "field:execute")) }; + await expectCode(() => createNote(noExecute, { workOrderId: wo, kind: "general", text: "x" }), "forbidden", "ohne field:execute → forbidden"); + + console.log("\n— Sichtbarkeit für Zugewiesene/Teamleiter —"); + const running = await listFieldOrders(f.ctxTech, "running"); + ok(running.some((o) => o.id === wo), "Monteur sieht zugewiesenen Auftrag unter „laufend\""); + ok((await listTodayOrders(f.ctxTech)).some((o) => o.id === wo), "Auftrag erscheint unter „Heute\""); + const detail = await getFieldOrderDetail(f.ctxLead, wo); + ok(detail.card.address === "Hafenstraße 1, 20457 Hamburg" && detail.site?.accessNotes === "Schlüssel beim Pförtner", "Teamleiter sieht Detail inkl. Objektadresse und Zugangshinweis"); + ok(detail.blockers.some((b) => b.kind === "photo_requirement") && !detail.blockers.some((b) => b.kind === "checklist_item"), "Blocker: nur noch Pflichtfoto offen"); + const bundle = await getFieldBundle(f.ctxTech); + ok(bundle.orders.length === 1 && bundle.orders[0].id === wo && bundle.orders[0].materialPlans.length === 1, "Bundle liefert Auftrag im Scope inkl. Materialvorgabe"); + + console.log("\n— Mandantentrennung (B gegen A) —"); + await expectCode(() => getFieldOrderDetail(f.ctxB, wo), "not_found", "B liest Auftrag von A → not_found"); + await expectCode(() => startSession(f.ctxB, { workOrderId: wo, mode: "work", offline: false }), "not_found", "B startet Session auf Auftrag von A → not_found"); + await expectCode(() => upsertMaterialUsage(f.ctxB, { workOrderId: f.orderB.id, materialPlanId: plan.id, quantity: 1, unit: "m", usageStatus: "fully_used" }), "not_found", "B nutzt Planposition von A → not_found"); + await expectCode(() => toggleChecklistItem(f.ctxB, { workOrderId: f.orderB.id, itemId: item.id, checked: false }), "not_found", "B ändert Checklistenpunkt von A → not_found"); + const itemAfter = await prisma.checklistItem.findUnique({ where: { id: item.id } }); + ok(itemAfter?.checked === true, "Checklistenpunkt von A unverändert"); + ok((await getFieldBundle(f.ctxB)).orders.every((o) => o.id !== wo), "Bundle von B enthält keine Aufträge von A"); + } finally { + await f.cleanup(); + await closeJobQueues(); + await prisma.$disconnect(); + } + console.log(`\n${failures === 0 ? "✓ alle Prüfungen grün" : `✗ ${failures} Fehler`}`); + process.exit(failures ? 1 : 0); +} + +main().catch(async (err) => { + console.error(err); + process.exit(1); +}); diff --git a/scripts/test-einsatz-sync.ts b/scripts/test-einsatz-sync.ts new file mode 100644 index 0000000..04b06a3 --- /dev/null +++ b/scripts/test-einsatz-sync.ts @@ -0,0 +1,158 @@ +// Lane L4 „Einsatz mobil" — Sync- und Upload-Tests: Idempotenz (gleiche clientOpId → duplicate, +// keine Doppelanlage), Konflikt bei veralteter baseVersion (nichts überschrieben), Scope über die +// Sync-API (fremder Auftrag → not_found), Mandantentrennung bei Uploads/Dokumenten, Foto-/ +// Sprachnotiz-Anhang, Ops fremder Lanes ohne Implementierung, image-derivatives-Processor. +// +// Lauf: npx tsx scripts/test-einsatz-sync.ts (lokale Postgres-DB aus .env; S3 optional) + +import "dotenv/config"; +import { randomUUID } from "node:crypto"; +import sharp from "sharp"; +import { prisma } from "../src/server/db"; +import { closeJobQueues } from "../src/server/jobs/queues"; +import { applyOperations } from "../src/server/services/sync/apply"; +import { storeFieldUpload } from "../src/server/services/field/uploads"; +import { openFieldDocument } from "../src/server/services/field/documents"; +import { process as imageDerivatives } from "../src/server/jobs/processors/image-derivatives"; +import type { SyncOperationInput, SyncOpType } from "../src/lib/sync/envelope"; +import type { ServiceCtx } from "../src/server/services/context"; +import { createFixture, expectCode, failures, ok } from "./lib/einsatz-fixture"; + +const S3 = !!process.env.S3_ENDPOINT?.trim(); + +function op(opType: SyncOpType, payload: Record, extra: Partial = {}): SyncOperationInput { + return { clientOpId: randomUUID(), opType, payload, clientCreatedAt: new Date().toISOString(), ...extra }; +} + +async function one(ctx: ServiceCtx, operation: SyncOperationInput) { + const res = await applyOperations(ctx, { deviceId: "test-device", operations: [operation] }); + return res.results[0]; +} + +async function main() { + const f = await createFixture("l4sync"); + const wo = f.orderA.id; + try { + console.log("\n— Idempotenz —"); + const noteOp = op("note.create", { workOrderId: wo, clientId: randomUUID(), kind: "work_done", text: "Heizkörper montiert" }); + const r1 = await one(f.ctxTech, noteOp); + ok(r1.status === "applied" && !!r1.idMap && Object.keys(r1.idMap).length === 1, "erste Op → applied mit idMap"); + const r2 = await one(f.ctxTech, noteOp); + ok(r2.status === "duplicate" && JSON.stringify(r2.idMap) === JSON.stringify(r1.idMap), "gleiche clientOpId → duplicate mit gespeicherter idMap"); + ok((await prisma.activityNote.count({ where: { workOrderId: wo } })) === 1, "keine Doppelanlage der Notiz"); + ok((await prisma.syncOperation.count({ where: { tenantId: f.tenantA.id, clientOpId: noteOp.clientOpId } })) === 1, "SyncOperation einmal protokolliert"); + const batch = await applyOperations(f.ctxTech, { deviceId: "test-device", operations: [noteOp, noteOp] }); + ok(batch.results.every((r) => r.status === "duplicate"), "Batch mit Wiederholungen → duplicate"); + + const bad = await one(f.ctxTech, op("note.create", { workOrderId: wo, kind: "unbekannt", text: "" })); + ok(bad.status === "rejected" && bad.errorCode === "invalid", "ungültige Payload → rejected invalid"); + + console.log("\n— Konflikt (baseVersion) —"); + const before = await prisma.workOrder.findUniqueOrThrow({ where: { id: wo } }); + const stale = await one(f.ctxTech, op("work_order.transition", { workOrderId: wo, to: "accepted" }, { baseVersion: before.version + 5 })); + ok(stale.status === "conflict" && stale.entityVersion === before.version, "veraltete baseVersion → conflict mit aktueller Version"); + const afterConflict = await prisma.workOrder.findUniqueOrThrow({ where: { id: wo } }); + ok(afterConflict.status === before.status && afterConflict.version === before.version, "bei Konflikt nichts überschrieben"); + const stored = await prisma.syncOperation.findFirst({ where: { tenantId: f.tenantA.id, clientOpId: stale.clientOpId } }); + ok(stored?.status === "conflict" && stored.errorCode === "conflict", "Konflikt als SyncOperation(status=conflict) für das Backoffice gespeichert"); + const noBase = await one(f.ctxTech, op("work_order.transition", { workOrderId: wo, to: "accepted" })); + ok(noBase.status === "rejected" && noBase.errorCode === "invalid", "konfliktbehaftete Op ohne baseVersion → invalid"); + const fresh = await one(f.ctxTech, op("work_order.transition", { workOrderId: wo, to: "accepted" }, { baseVersion: before.version })); + const accepted = await prisma.workOrder.findUniqueOrThrow({ where: { id: wo } }); + ok(fresh.status === "applied" && fresh.entityVersion === before.version + 1 && accepted.status === "accepted", "aktuelle baseVersion → applied, Version +1"); + ok((await prisma.workOrderStatusChange.count({ where: { workOrderId: wo, toStatus: "accepted" } })) === 1, "Statuswechsel protokolliert"); + + console.log("\n— Sessions über Sync —"); + const st1 = await one(f.ctxTech, op("session.start", { workOrderId: wo, mode: "work", clientId: randomUUID() })); + const st2 = await one(f.ctxTech, op("session.start", { workOrderId: wo, mode: "work", clientId: randomUUID() })); + ok(st1.status === "applied" && st2.status === "rejected" && st2.errorCode === "conflict", "zweiter Start über Sync → rejected conflict"); + ok((await prisma.workSession.count({ where: { workOrderId: wo, status: "running" } })) === 1, "nur eine laufende Session"); + + console.log("\n— Scope über die Sync-API —"); + const foreign = await one(f.ctxOutsider, op("note.create", { workOrderId: wo, kind: "general", text: "darf nicht" })); + ok(foreign.status === "rejected" && foreign.errorCode === "not_found", "Monteur ohne Zuweisung → rejected not_found"); + const foreignTransition = await one(f.ctxOutsider, op("work_order.transition", { workOrderId: wo, to: "en_route" }, { baseVersion: accepted.version })); + ok(foreignTransition.status === "rejected" && foreignTransition.errorCode === "not_found", "fremder Auftrag bei Konfliktprüfung → not_found (kein Versions-Leak)"); + const crossTenant = await one(f.ctxB, op("note.create", { workOrderId: wo, kind: "general", text: "Mandant B" })); + ok(crossTenant.status === "rejected" && crossTenant.errorCode === "not_found", "Mandant B auf Auftrag von A → not_found"); + ok((await prisma.activityNote.count({ where: { workOrderId: wo } })) === 1, "keine fremden Notizen angelegt"); + const sameOpIdB = await one(f.ctxB, { ...noteOp, payload: { workOrderId: f.orderB.id, kind: "general", text: "B eigene Notiz" } }); + ok(sameOpIdB.status === "applied", "gleiche clientOpId in Mandant B ist unabhängig (Idempotenz je Mandant)"); + const reuseOther = await one(f.ctxLead, noteOp); + ok(reuseOther.status === "rejected" && !reuseOther.idMap, "fremder Nutzer mit gleicher clientOpId erhält kein gespeichertes Ergebnis"); + + console.log("\n— Ops fremder Lanes —"); + const report = await one(f.ctxTech, op("report.save_draft", { workOrderId: wo })); + ok(report.status === "rejected" && report.errorCode === "invalid" && /not available/.test(report.message ?? ""), "report.save_draft ohne L5 → rejected invalid mit Hinweis"); + ok((await prisma.syncOperation.count({ where: { clientOpId: report.clientOpId } })) === 0, "nicht verfügbare Op wird nicht gespeichert (später wiederholbar)"); + + console.log("\n— Uploads & Mandantentrennung —"); + const jpeg = await sharp({ create: { width: 1200, height: 800, channels: 3, background: { r: 200, g: 120, b: 40 } } }).jpeg().toBuffer(); + const thumb = await sharp(jpeg).resize(400).jpeg().toBuffer(); + const uploadClient = randomUUID(); + const up1 = await storeFieldUpload(f.ctxTech, { clientId: uploadClient, workOrderId: wo, kind: "photo" }, { bytes: jpeg, name: "foto.jpg", type: "image/jpeg" }, { bytes: thumb, name: "t.jpg", type: "image/jpeg" }); + const up2 = await storeFieldUpload(f.ctxTech, { clientId: uploadClient, workOrderId: wo, kind: "photo" }, { bytes: jpeg, name: "foto.jpg", type: "image/jpeg" }); + ok(!up1.duplicate && up2.duplicate && up1.documentId === up2.documentId, "Upload idempotent über clientId"); + const doc = await prisma.document.findUniqueOrThrow({ where: { id: up1.documentId } }); + ok(doc.tenantId === f.tenantA.id && doc.category === "photo" && doc.mimeType === "image/jpeg" && doc.checksum.length === 64 && !!doc.previewKey, "Dokument mit MIME, Prüfsumme und Vorschaubild"); + + const upB = await storeFieldUpload(f.ctxB, { clientId: uploadClient, workOrderId: f.orderB.id, kind: "photo" }, { bytes: jpeg, name: "foto.jpg", type: "image/jpeg" }); + ok(!upB.duplicate && upB.documentId !== up1.documentId, "gleiche clientId in Mandant B → eigenes Dokument, kein Zugriff auf A"); + await expectCode(() => storeFieldUpload(f.ctxB, { clientId: randomUUID(), workOrderId: wo, kind: "photo" }, { bytes: jpeg, name: "x.jpg", type: "image/jpeg" }), "not_found", "Mandant B lädt auf Auftrag von A hoch → not_found"); + await expectCode(() => openFieldDocument(f.ctxB, up1.documentId), "not_found", "Mandant B öffnet Dokument von A → not_found"); + await expectCode(() => openFieldDocument(f.ctxOutsider, up1.documentId), "not_found", "Monteur ohne Zuweisung öffnet Dokument → not_found"); + await expectCode( + () => storeFieldUpload(f.ctxTech, { clientId: randomUUID(), workOrderId: wo, kind: "photo" }, { bytes: Buffer.from("%PDF-1.7 fake pdf content"), name: "x.pdf", type: "application/pdf" }), + "invalid", + "PDF als Foto → invalid (Magic Bytes)", + ); + if (S3) { + const opened = await openFieldDocument(f.ctxTech, up1.documentId, "preview"); + ok(!!opened.content.stream, "zugewiesener Monteur öffnet eigenes Foto (Vorschau)"); + } else { + console.log("↷ Byte-Abruf übersprungen (kein S3_ENDPOINT)"); + } + const internal = await prisma.document.create({ + data: { tenantId: f.tenantA.id, workOrderId: wo, category: "other", fileName: "intern.pdf", storageKey: `${f.tenantA.id}/uploads/x`, mimeType: "application/pdf", fileSize: 1, checksum: "0", lineageId: randomUUID(), visibility: "backoffice_only" }, + }); + await expectCode(() => openFieldDocument(f.ctxTech, internal.id), "not_found", "Backoffice-internes Dokument bleibt für Monteur verborgen"); + + console.log("\n— Foto & Sprachnotiz anhängen —"); + const req = f.orderA.photoRequirements[0]; + const photoClient = randomUUID(); + const attach = await one(f.ctxTech, op("photo.attach", { workOrderId: wo, clientId: photoClient, documentId: up1.documentId, phase: "before", photoRequirementId: req.id, comment: "Typenschild" })); + ok(attach.status === "applied" && !!attach.idMap?.[photoClient], "photo.attach → applied"); + const photo = await prisma.photo.findFirst({ where: { documentId: up1.documentId } }); + ok(photo?.photoRequirementId === req.id && photo.phase === "before" && photo.takenById === f.tech.id, "Foto mit Phase, Pflichtfoto und Aufnehmendem"); + const attachB = await one(f.ctxB, op("photo.attach", { workOrderId: f.orderB.id, documentId: up1.documentId })); + ok(attachB.status === "rejected" && attachB.errorCode === "not_found", "Mandant B hängt Dokument von A an → not_found"); + const attachAgain = await one(f.ctxTech, op("photo.attach", { workOrderId: wo, documentId: up1.documentId })); + ok(attachAgain.status === "rejected" && attachAgain.errorCode === "invalid", "Dokument doppelt anhängen → invalid"); + + const webm = Buffer.concat([Buffer.from([0x1a, 0x45, 0xdf, 0xa3]), Buffer.alloc(512, 1)]); + const voiceUp = await storeFieldUpload(f.ctxTech, { clientId: randomUUID(), workOrderId: wo, kind: "voice_note" }, { bytes: webm, name: "sprachnotiz.webm", type: "audio/webm;codecs=opus" }); + const voice = await one(f.ctxTech, op("voice.attach", { workOrderId: wo, clientId: randomUUID(), documentId: voiceUp.documentId, durationSeconds: 12 })); + const vn = await prisma.voiceNote.findFirst({ where: { documentId: voiceUp.documentId } }); + ok(voice.status === "applied" && vn?.transcriptionStatus === "disabled", "Sprachnotiz ohne Transkriptions-Processor → Status disabled statt Fehler"); + + console.log("\n— image-derivatives —"); + const noThumb = await storeFieldUpload(f.ctxTech, { clientId: randomUUID(), workOrderId: wo, kind: "photo" }, { bytes: jpeg, name: "ohne-vorschau.jpg", type: "image/jpeg" }); + await imageDerivatives({ tenantId: f.tenantA.id, entityId: noThumb.documentId, actorId: f.tech.id }); + const derived = await prisma.document.findUniqueOrThrow({ where: { id: noThumb.documentId } }); + if (S3) ok(!!derived.previewKey && derived.previewKey.startsWith(`${f.tenantA.id}/`), "Processor erzeugt mandantenpräfixiertes Vorschaubild"); + else ok(derived.previewKey === null, "ohne Byte-Speicher kein Vorschaubild (kein Fehler)"); + await imageDerivatives({ tenantId: f.tenantB.id, entityId: noThumb.documentId }); + ok((await prisma.document.findUniqueOrThrow({ where: { id: noThumb.documentId } })).previewKey === derived.previewKey, "Processor mit fremdem Mandanten ändert nichts"); + } finally { + await f.cleanup(); + await closeJobQueues(); + await prisma.$disconnect(); + } + console.log(`\n${failures === 0 ? "✓ alle Prüfungen grün" : `✗ ${failures} Fehler`}`); + process.exit(failures ? 1 : 0); +} + +main().catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/src/app/api/v1/field/bundle/route.ts b/src/app/api/v1/field/bundle/route.ts new file mode 100644 index 0000000..0fe60db --- /dev/null +++ b/src/app/api/v1/field/bundle/route.ts @@ -0,0 +1,14 @@ +import { NextResponse } from "next/server"; +import { getFieldBundle } from "@/server/services/field/queries"; +import { apiError, requireApiContext, withApi } from "@/server/services/sync/api-context"; + +/** GET /api/v1/field/bundle?since= — offline pull of the orders in scope (ARCHITEKTUR §4.6). */ +export async function GET(req: Request) { + return withApi(req, async () => { + const ctx = await requireApiContext("field", "field:execute"); + const raw = new URL(req.url).searchParams.get("since"); + const since = raw ? new Date(raw) : null; + if (since && Number.isNaN(since.getTime())) return apiError("invalid", 400, "invalid since"); + return NextResponse.json(await getFieldBundle(ctx, since), { headers: { "Cache-Control": "private, no-store" } }); + }); +} diff --git a/src/app/api/v1/field/documents/[id]/route.ts b/src/app/api/v1/field/documents/[id]/route.ts new file mode 100644 index 0000000..778985b --- /dev/null +++ b/src/app/api/v1/field/documents/[id]/route.ts @@ -0,0 +1,27 @@ +import { openFieldDocument } from "@/server/services/field/documents"; +import { requireApiContext, withApi } from "@/server/services/sync/api-context"; + +/** + * GET /api/v1/field/documents/[?variant=preview] — authorised document delivery for the mobile + * app (visibility + scope checked in the service). Only magic-byte-verified media types are served + * inline; everything else is a download. + */ +const INLINE = /^(image\/(jpeg|png|webp)|application\/pdf|audio\/(webm|ogg|mp4|mpeg|wav))$/; + +export async function GET(req: Request, { params }: { params: Promise<{ id: string }> }) { + return withApi(req, async () => { + const ctx = await requireApiContext("field"); + const { id } = await params; + const variant = new URL(req.url).searchParams.get("variant") === "preview" ? "preview" : "original"; + const { content, mimeType, fileName } = await openFieldDocument(ctx, id, variant); + const safeName = fileName.replace(/["\\\r\n]/g, "_"); + const headers = new Headers({ + "Content-Type": mimeType, + "Content-Disposition": `${INLINE.test(mimeType) ? "inline" : "attachment"}; filename="${safeName}"`, + "X-Content-Type-Options": "nosniff", + "Cache-Control": "private, max-age=300", + }); + if (content.size != null) headers.set("Content-Length", String(content.size)); + return new Response(content.stream, { headers }); + }); +} diff --git a/src/app/api/v1/sync/route.ts b/src/app/api/v1/sync/route.ts new file mode 100644 index 0000000..145838a --- /dev/null +++ b/src/app/api/v1/sync/route.ts @@ -0,0 +1,14 @@ +import { NextResponse } from "next/server"; +import { syncRequestSchema } from "@/lib/sync/envelope"; +import { applyOperations } from "@/server/services/sync/apply"; +import { apiError, requireApiContext, withApi } from "@/server/services/sync/api-context"; + +/** POST /api/v1/sync — batch of offline/online operations (ARCHITEKTUR §4.6). */ +export async function POST(req: Request) { + return withApi(req, async () => { + const ctx = await requireApiContext("field"); + const body = syncRequestSchema.safeParse(await req.json().catch(() => null)); + if (!body.success) return apiError("invalid", 400, "invalid sync request", body.error.issues.slice(0, 10)); + return NextResponse.json(await applyOperations(ctx, body.data), { headers: { "Cache-Control": "no-store" } }); + }); +} diff --git a/src/app/api/v1/uploads/route.ts b/src/app/api/v1/uploads/route.ts new file mode 100644 index 0000000..0eac1ca --- /dev/null +++ b/src/app/api/v1/uploads/route.ts @@ -0,0 +1,34 @@ +import { NextResponse } from "next/server"; +import { storeFieldUpload, uploadMetaSchema } from "@/server/services/field/uploads"; +import { apiError, requireApiContext, withApi } from "@/server/services/sync/api-context"; + +/** + * POST /api/v1/uploads — multipart: file, clientId (uuid), workOrderId, kind (photo|voice_note), + * optional preview (thumbnail). Returns { documentId }; the same clientId returns the same document. + */ +const MAX_BYTES = 25 * 1024 * 1024; + +export async function POST(req: Request) { + return withApi(req, async () => { + const ctx = await requireApiContext("field", "field:execute"); + const declared = Number(req.headers.get("content-length") ?? "0"); + if (declared > MAX_BYTES + 3 * 1024 * 1024) return apiError("invalid", 413, "file too large"); + + const form = await req.formData().catch(() => null); + if (!form) return apiError("invalid", 400, "multipart body expected"); + const file = form.get("file"); + if (!(file instanceof File)) return apiError("invalid", 400, "file missing"); + if (file.size > MAX_BYTES) return apiError("invalid", 413, "file too large"); + const meta = uploadMetaSchema.safeParse({ clientId: form.get("clientId"), workOrderId: form.get("workOrderId"), kind: form.get("kind") }); + if (!meta.success) return apiError("invalid", 400, "invalid upload metadata"); + const preview = form.get("preview"); + + const result = await storeFieldUpload( + ctx, + meta.data, + { bytes: Buffer.from(await file.arrayBuffer()), name: file.name, type: file.type }, + preview instanceof File && preview.size > 0 ? { bytes: Buffer.from(await preview.arrayBuffer()), name: preview.name, type: preview.type } : null, + ); + return NextResponse.json(result, { status: result.duplicate ? 200 : 201, headers: { "Cache-Control": "no-store" } }); + }); +} diff --git a/src/components/audit-trail.tsx b/src/components/audit-trail.tsx index b9d10d6..efce472 100644 --- a/src/components/audit-trail.tsx +++ b/src/components/audit-trail.tsx @@ -66,6 +66,8 @@ const ENTITY_LABEL: Record = { report: "Bericht", emergency: "Notdienst", document: "Dokument", + // Einsatz mobil (L4) + work_session: "Einsatz-Zeiterfassung", time_entry: "Zeitabschnitt", checklist_item: "Checklistenpunkt", material_usage: "Materialverbrauch", photo: "Foto", voice_note: "Sprachnotiz", activity_note: "Tätigkeitsnotiz", sync_operation: "Sync-Vorgang", }; const fmt = new Intl.DateTimeFormat("de-DE", { dateStyle: "medium", timeStyle: "short" }); diff --git a/src/lib/field/material-rules.ts b/src/lib/field/material-rules.ts new file mode 100644 index 0000000..6290359 --- /dev/null +++ b/src/lib/field/material-rules.ts @@ -0,0 +1,35 @@ +import type { MaterialUsageStatus } from "@/lib/sync/ops"; + +/** + * Material confirmation rules (Spec §13.2/§13.3), shared by UI (early feedback) and server + * (authoritative, services/field/materials.ts). + * Planned position: fully_used (≥ planned) | partially_used (0 < q < planned) | not_used (q = 0); + * any deviation from the planned quantity requires a reason. + * Additional material: usageStatus additional, name, quantity > 0 and reason are mandatory. + * Returns null when valid, otherwise a short technical reason. + */ +export function validateMaterialUsage( + input: { usageStatus: MaterialUsageStatus; quantity: number; name?: string | null; deviationReason?: string | null }, + plannedQuantity: number | null, +): string | null { + const reason = input.deviationReason?.trim(); + if (!Number.isFinite(input.quantity) || input.quantity < 0) return "invalid quantity"; + if (plannedQuantity === null) { + if (input.usageStatus !== "additional") return "additional material requires usageStatus additional"; + if (!input.name?.trim()) return "name required"; + if (input.quantity <= 0) return "quantity must be positive"; + if (!reason) return "reason required for additional material"; + return null; + } + if (input.usageStatus === "additional") return "planned position cannot be additional"; + if (input.usageStatus === "not_used" && input.quantity !== 0) return "not used requires quantity 0"; + if (input.usageStatus === "partially_used" && (input.quantity <= 0 || input.quantity >= plannedQuantity)) return "partial quantity must be between 0 and planned"; + if (input.usageStatus === "fully_used" && input.quantity < plannedQuantity) return "fully used requires at least the planned quantity"; + if (materialDeviates(input, plannedQuantity) && !reason) return "reason required for deviation"; + return null; +} + +export function materialDeviates(input: { usageStatus: MaterialUsageStatus; quantity: number }, plannedQuantity: number | null): boolean { + if (plannedQuantity === null) return true; + return input.usageStatus !== "fully_used" || input.quantity !== plannedQuantity; +} diff --git a/src/lib/sync/ops.ts b/src/lib/sync/ops.ts new file mode 100644 index 0000000..3f8301e --- /dev/null +++ b/src/lib/sync/ops.ts @@ -0,0 +1,136 @@ +import { z } from "zod"; +import type { SyncOpType } from "./envelope"; +import { WORK_ORDER_STATUSES } from "@/lib/work-orders/status"; + +/** + * Payload schemas per sync opType (ARCHITEKTUR §4.6). Client-safe: used by the mobile UI to + * build ops and by src/server/services/sync/apply.ts to validate them. + * `clientId` fields are device-generated ids (uuid) that make additive creates idempotent + * and are mapped to server ids in SyncOpResult.idMap. + */ + +const id = z.string().min(1).max(64); +const clientId = z.string().uuid(); +const isoDate = z.string().datetime({ offset: true }); +const lat = z.number().min(-90).max(90); +const lng = z.number().min(-180).max(180); +const quantity = z.number().min(0).max(1_000_000); + +export const NOTE_KINDS = [ + "work_done", + "deviation", + "problem", + "additional_work", + "not_executable", + "follow_up", + "recommendation", + "customer_note", + "general", +] as const; +export type NoteKind = (typeof NOTE_KINDS)[number]; + +export const PHOTO_PHASES = ["before", "during", "after"] as const; +export type PhotoPhase = (typeof PHOTO_PHASES)[number]; + +export const MATERIAL_USAGE_STATUSES = ["fully_used", "partially_used", "not_used", "additional"] as const; +export type MaterialUsageStatus = (typeof MATERIAL_USAGE_STATUSES)[number]; + +/** Unit suggestions for the material stepper (free text stays allowed). */ +export const UNIT_SUGGESTIONS = ["Stk", "m", "m²", "m³", "kg", "l", "Pkg", "Rolle", "Satz", "h"] as const; + +export const sessionStartPayload = z.object({ + workOrderId: id, + clientId: clientId.optional(), + /** travel = "Losfahren" (in Anfahrt), work = "Arbeit starten" */ + mode: z.enum(["travel", "work"]).default("work"), + at: isoDate.optional(), + latitude: lat.optional(), + longitude: lng.optional(), + offline: z.boolean().default(false), + deviceInfo: z.string().max(200).optional(), +}); + +export const sessionControlPayload = z.object({ + workOrderId: id, + at: isoDate.optional(), +}); + +export const workOrderTransitionPayload = z.object({ + workOrderId: id, + to: z.enum(WORK_ORDER_STATUSES), + reason: z.string().max(1000).optional(), +}); + +export const noteCreatePayload = z.object({ + workOrderId: id, + clientId: clientId.optional(), + kind: z.enum(NOTE_KINDS).default("general"), + text: z.string().trim().min(1).max(10_000), +}); + +export const checklistTogglePayload = z.object({ + workOrderId: id, + itemId: id, + checked: z.boolean(), + comment: z.string().max(2000).nullish(), +}); + +export const materialUpsertPayload = z.object({ + workOrderId: id, + clientId: clientId.optional(), + materialPlanId: id.nullish(), + name: z.string().trim().max(200).optional(), + articleNumber: z.string().trim().max(100).nullish(), + quantity, + unit: z.string().trim().min(1).max(20), + usageStatus: z.enum(MATERIAL_USAGE_STATUSES), + deviationReason: z.string().trim().max(2000).nullish(), + notes: z.string().trim().max(2000).nullish(), + photoId: id.nullish(), +}); + +export const photoAttachPayload = z.object({ + workOrderId: id, + clientId: clientId.optional(), + documentId: id, + phase: z.enum(PHOTO_PHASES).nullish(), + photoRequirementId: id.nullish(), + checklistItemId: id.nullish(), + comment: z.string().trim().max(2000).nullish(), + takenAt: isoDate.optional(), + latitude: lat.optional(), + longitude: lng.optional(), +}); + +export const voiceAttachPayload = z.object({ + workOrderId: id, + clientId: clientId.optional(), + documentId: id, + durationSeconds: z.number().int().min(0).max(300).optional(), + recordedAt: isoDate.optional(), + /** optional note kind the transcript is filed under */ + kind: z.enum(NOTE_KINDS).optional(), +}); + +/** Schemas of ops owned by other lanes are validated there (reports: L5, emergency: L8). */ +const passthrough = z.record(z.string(), z.unknown()); + +export const OP_PAYLOAD_SCHEMAS = { + "session.start": sessionStartPayload, + "session.pause": sessionControlPayload, + "session.resume": sessionControlPayload, + "session.end": sessionControlPayload, + "work_order.transition": workOrderTransitionPayload, + "note.create": noteCreatePayload, + "checklist.toggle": checklistTogglePayload, + "material.upsert": materialUpsertPayload, + "photo.attach": photoAttachPayload, + "voice.attach": voiceAttachPayload, + "report.save_draft": passthrough, + "report.submit": passthrough, + "signature.capture": passthrough, + "emergency.create": passthrough, +} satisfies Record; + +export type OpPayload = z.input<(typeof OP_PAYLOAD_SCHEMAS)[T]>; +export type ParsedOpPayload = z.output<(typeof OP_PAYLOAD_SCHEMAS)[T]>; diff --git a/src/server/actions/field/time.ts b/src/server/actions/field/time.ts new file mode 100644 index 0000000..b5b8750 --- /dev/null +++ b/src/server/actions/field/time.ts @@ -0,0 +1,35 @@ +"use server"; + +import { revalidatePath } from "next/cache"; +import { moduleGuard } from "@/server/action-guard"; +import { ctxFromGuard, ServiceError } from "@/server/services/context"; +import { correctTimeEntry } from "@/server/services/field/time-correction"; + +const guard = moduleGuard("field"); + +export type TimeCorrectionResult = { ok: true } | { ok: false; error: "invalid" | "forbidden" | "not_found" | "failed" }; + +/** Manual time correction (Spec §12.2) — thin adapter over services/field/time-correction. */ +export async function correctTime(input: { + workOrderId: string; + timeEntryId: string; + startedAt: string; + endedAt: string | null; + reason: string; +}): Promise { + const g = await guard("field:correct_time"); + try { + await correctTimeEntry(ctxFromGuard(g), { + timeEntryId: input.timeEntryId, + startedAt: input.startedAt, + endedAt: input.endedAt, + reason: input.reason, + }); + } catch (err) { + if (err instanceof ServiceError && (err.code === "invalid" || err.code === "forbidden" || err.code === "not_found")) return { ok: false, error: err.code }; + console.error("[field] time correction failed:", err); + return { ok: false, error: "failed" }; + } + revalidatePath(`/m/orders/${input.workOrderId}/time`); + return { ok: true }; +} diff --git a/src/server/jobs/processors/image-derivatives.ts b/src/server/jobs/processors/image-derivatives.ts new file mode 100644 index 0000000..2628856 --- /dev/null +++ b/src/server/jobs/processors/image-derivatives.ts @@ -0,0 +1,48 @@ +import { dbForTenant } from "@/server/db"; +import { storage } from "@/server/storage/adapter"; +import { writeAuditLog } from "@/server/audit"; +import type { JobPayload } from "../queues"; + +/** + * Queue "image-derivatives" (lane L4): creates the 400 px JPEG thumbnail (Document.previewKey) + * for photos whose client did not send one. EXIF orientation is applied (sharp.rotate()). + * Idempotent: documents that already have a preview are skipped. + */ + +export const THUMBNAIL_SIZE = 400; + +async function streamToBuffer(stream: ReadableStream): Promise { + const chunks: Uint8Array[] = []; + const reader = stream.getReader(); + for (;;) { + const { done, value } = await reader.read(); + if (done) break; + if (value) chunks.push(value); + } + return Buffer.concat(chunks); +} + +export async function createThumbnail(bytes: Buffer): Promise { + const sharp = (await import("sharp")).default; + return sharp(bytes).rotate().resize(THUMBNAIL_SIZE, THUMBNAIL_SIZE, { fit: "inside", withoutEnlargement: true }).jpeg({ quality: 75 }).toBuffer(); +} + +export async function process(payload: JobPayload): Promise { + const db = dbForTenant(payload.tenantId); + const doc = await db.document.findFirst({ where: { id: payload.entityId, category: "photo", deletedAt: null } }); + if (!doc || doc.previewKey) return; + const content = await storage.get(doc.storageKey); + if (!content) return; // stub storage or object missing — nothing to derive + const thumb = await createThumbnail(await streamToBuffer(content.stream)); + const stored = await storage.put({ tenantId: payload.tenantId, filename: `thumb-${doc.fileName.replace(/\.[^.]+$/, "")}.jpg`, contentType: "image/jpeg", bytes: thumb }); + await db.document.update({ where: { id: doc.id }, data: { previewKey: stored.storageKey } }); + await writeAuditLog({ + tenantId: payload.tenantId, + actorId: payload.actorId ?? undefined, + action: "update", + entity: "document", + entityId: doc.id, + before: { previewKey: null }, + after: { previewKey: stored.storageKey }, + }); +} diff --git a/src/server/jobs/processors/index.ts b/src/server/jobs/processors/index.ts index 8727bd4..b4a045a 100644 --- a/src/server/jobs/processors/index.ts +++ b/src/server/jobs/processors/index.ts @@ -11,7 +11,7 @@ export const PROCESSORS: Partial Promise 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), + "image-derivatives": () => import("./image-derivatives").then((m) => m.process), }; /** Inline fallback when no Redis is available (dev/demo). */ diff --git a/src/server/services/field/checklist.ts b/src/server/services/field/checklist.ts new file mode 100644 index 0000000..5a2e7fb --- /dev/null +++ b/src/server/services/field/checklist.ts @@ -0,0 +1,22 @@ +import { ServiceError, type ServiceCtx } from "@/server/services/context"; +import type { ParsedOpPayload } from "@/lib/sync/ops"; +import { audit, requireFieldOrder } from "./common"; + +/** Checklist execution (Spec §12.4): toggle an item of a visible work order, optional comment. */ +export async function toggleChecklistItem(ctx: ServiceCtx, input: ParsedOpPayload<"checklist.toggle">) { + const wo = await requireFieldOrder(ctx, input.workOrderId, { editable: true }); + const item = await ctx.db.checklistItem.findFirst({ where: { id: input.itemId, workOrderId: wo.id } }); + if (!item) throw new ServiceError("not_found", "checklist item not found"); + + const updated = await ctx.db.checklistItem.update({ + where: { id: item.id }, + data: { + checked: input.checked, + checkedById: input.checked ? ctx.userId : null, + checkedAt: input.checked ? new Date() : null, + ...(input.comment !== undefined ? { comment: input.comment?.trim() || null } : {}), + }, + }); + await audit(ctx, "update", "checklist_item", item.id, { checked: item.checked, comment: item.comment }, { checked: updated.checked, comment: updated.comment }); + return { itemId: updated.id, checked: updated.checked }; +} diff --git a/src/server/services/field/common.ts b/src/server/services/field/common.ts new file mode 100644 index 0000000..cf1baa2 --- /dev/null +++ b/src/server/services/field/common.ts @@ -0,0 +1,65 @@ +import { writeAuditLog } from "@/server/audit"; +import { assertCan, ServiceError, type ServiceCtx } from "@/server/services/context"; +import { requireVisibleWorkOrder } from "@/server/services/work-orders/visibility"; +import { FIELD_EDITABLE, type WorkOrderStatus } from "@/lib/work-orders/status"; + +/** Shared helpers of the field services (lane L4). */ + +export type FieldOrder = { + id: string; + number: string; + status: WorkOrderStatus; + version: number; + siteId: string | null; + customerId: string; + assignedTeamId: string | null; +}; + +const FIELD_ORDER_SELECT = { + id: true, + number: true, + status: true, + version: true, + siteId: true, + customerId: true, + assignedTeamId: true, +} as const; + +/** + * Loads a work order for a field mutation: requires `field:execute`, the order must be in the + * user's visibility scope (otherwise not_found — existence is never revealed) and, when + * `editable`, in a status that allows field documentation. + */ +export async function requireFieldOrder(ctx: ServiceCtx, workOrderId: string, opts: { editable?: boolean } = {}): Promise { + assertCan(ctx, "field:execute"); + const wo = (await requireVisibleWorkOrder(ctx, workOrderId, FIELD_ORDER_SELECT)) as FieldOrder; + if (opts.editable && !FIELD_EDITABLE.includes(wo.status)) { + throw new ServiceError("invalid", `work order status ${wo.status} does not allow field documentation`); + } + return wo; +} + +/** Client timestamp of an operation; future values (clock skew) are clamped to now. */ +export function opTime(at?: string | null): Date { + const now = new Date(); + if (!at) return now; + const d = new Date(at); + if (Number.isNaN(d.getTime())) throw new ServiceError("invalid", "invalid timestamp"); + return d.getTime() > now.getTime() + 60_000 ? now : d; +} + +export async function audit( + ctx: ServiceCtx, + action: "create" | "update" | "delete", + entity: string, + entityId: string, + before?: unknown, + after?: unknown, +): Promise { + await writeAuditLog({ tenantId: ctx.tenantId, actorId: ctx.userId, action, entity, entityId, before, after }); +} + +/** Prisma unique-constraint violation (used for idempotent creates under races). */ +export function isUniqueViolation(err: unknown): boolean { + return (err as { code?: string })?.code === "P2002"; +} diff --git a/src/server/services/field/documents.ts b/src/server/services/field/documents.ts new file mode 100644 index 0000000..3bcbfa8 --- /dev/null +++ b/src/server/services/field/documents.ts @@ -0,0 +1,55 @@ +import { storage, type StoredContent } from "@/server/storage/adapter"; +import { assertCan, can, ServiceError, type ServiceCtx } from "@/server/services/context"; +import { allowedDocumentVisibility, customerScope, requireVisibleWorkOrder, siteScope } from "@/server/services/work-orders/visibility"; + +/** + * Authorised document access for the mobile app (US-005): document visibility level + * (backoffice-internal stays hidden) + work order / site / customer scope. Approved report PDFs + * of other orders are readable when their site is visible (site history, Spec §8.3). + */ +export async function openFieldDocument( + ctx: ServiceCtx, + documentId: string, + variant: "original" | "preview" = "original", +): Promise<{ content: StoredContent; mimeType: string; fileName: string }> { + assertCan(ctx, "document:read"); + const doc = await ctx.db.document.findFirst({ + where: { id: documentId, deletedAt: null, uploadStatus: "uploaded", visibility: { in: allowedDocumentVisibility(ctx) } }, + }); + if (!doc) throw new ServiceError("not_found", "document not found"); + + if (!can(ctx, "work_order:read_all")) { + let allowed = false; + if (doc.workOrderId) { + allowed = await requireVisibleWorkOrder(ctx, doc.workOrderId, { id: true }).then( + () => true, + () => false, + ); + } + if (!allowed && !doc.workOrderId && doc.siteId) { + allowed = !!(await ctx.db.site.findFirst({ where: { AND: [{ id: doc.siteId }, await siteScope(ctx)] }, select: { id: true } })); + } + if (!allowed && !doc.workOrderId && !doc.siteId && doc.customerId) { + allowed = !!(await ctx.db.customer.findFirst({ where: { AND: [{ id: doc.customerId }, await customerScope(ctx)] }, select: { id: true } })); + } + if (!allowed) { + // approved report PDF of an earlier order at a visible site + const report = await ctx.db.report.findFirst({ + where: { pdfDocumentId: doc.id, status: "approved", workOrder: { site: await siteScope(ctx) } }, + select: { id: true }, + }); + allowed = !!report; + } + if (!allowed) throw new ServiceError("not_found", "document not found"); + } + + const key = variant === "preview" && doc.previewKey ? doc.previewKey : doc.storageKey; + if (!key.startsWith(`${ctx.tenantId}/`)) throw new ServiceError("not_found", "document not available"); + const content = await storage.get(key); + if (!content) throw new ServiceError("not_found", "document not available"); + return { + content, + mimeType: variant === "preview" && doc.previewKey ? content.contentType ?? "image/jpeg" : doc.mimeType, + fileName: doc.fileName, + }; +} diff --git a/src/server/services/field/materials.ts b/src/server/services/field/materials.ts new file mode 100644 index 0000000..4509426 --- /dev/null +++ b/src/server/services/field/materials.ts @@ -0,0 +1,72 @@ +import { Prisma } from "@prisma/client"; +import { ServiceError, type ServiceCtx } from "@/server/services/context"; +import type { ParsedOpPayload } from "@/lib/sync/ops"; +import { validateMaterialUsage } from "@/lib/field/material-rules"; +import { audit, requireFieldOrder } from "./common"; + +/** + * Material documentation (Spec §13.2/§13.3). + * Validation rules: src/lib/field/material-rules.ts (shared with the UI). + */ + +type Input = ParsedOpPayload<"material.upsert">; + +export async function upsertMaterialUsage(ctx: ServiceCtx, input: Input) { + const wo = await requireFieldOrder(ctx, input.workOrderId, { editable: true }); + const plan = input.materialPlanId ? await ctx.db.materialPlan.findFirst({ where: { id: input.materialPlanId, workOrderId: wo.id } }) : null; + if (input.materialPlanId && !plan) throw new ServiceError("not_found", "material plan not found"); + + const problem = validateMaterialUsage(input, plan ? Number(plan.plannedQuantity) : null); + if (problem) throw new ServiceError("invalid", problem); + + const existing = + (input.clientId ? await ctx.db.materialUsage.findFirst({ where: { clientId: input.clientId } }) : null) ?? + (plan ? await ctx.db.materialUsage.findFirst({ where: { workOrderId: wo.id, materialPlanId: plan.id } }) : null); + if (existing && existing.workOrderId !== wo.id) throw new ServiceError("invalid", "clientId already used"); + + const activeSession = await ctx.db.workSession.findFirst({ + where: { workOrderId: wo.id, userId: ctx.userId, status: { in: ["running", "paused", "en_route"] } }, + select: { id: true }, + }); + if (input.photoId) { + const photo = await ctx.db.photo.findFirst({ where: { id: input.photoId, workOrderId: wo.id }, select: { id: true } }); + if (!photo) throw new ServiceError("not_found", "photo not found"); + } + + const data = { + name: plan?.name ?? input.name!.trim(), + articleNumber: plan?.articleNumber ?? input.articleNumber ?? null, + actualQuantity: new Prisma.Decimal(input.quantity), + unit: input.unit, + usageStatus: input.usageStatus, + deviationReason: input.deviationReason?.trim() || null, + notes: input.notes?.trim() || null, + photoId: input.photoId ?? null, + recordedById: ctx.userId, + }; + const snapshot = (u: { actualQuantity: Prisma.Decimal; usageStatus: string; deviationReason: string | null; unit: string; name: string }) => ({ + name: u.name, + quantity: u.actualQuantity.toString(), + unit: u.unit, + usageStatus: u.usageStatus, + deviationReason: u.deviationReason, + }); + + if (existing) { + const updated = await ctx.db.materialUsage.update({ where: { id: existing.id }, data }); + await audit(ctx, "update", "material_usage", updated.id, snapshot(existing), snapshot(updated)); + return { usageId: updated.id }; + } + const created = await ctx.db.materialUsage.create({ + data: { + ...data, + tenantId: ctx.tenantId, + workOrderId: wo.id, + materialPlanId: plan?.id ?? null, + workSessionId: activeSession?.id ?? null, + clientId: input.clientId ?? null, + }, + }); + await audit(ctx, "create", "material_usage", created.id, null, { workOrderId: wo.id, materialPlanId: plan?.id ?? null, ...snapshot(created) }); + return { usageId: created.id }; +} diff --git a/src/server/services/field/notes.ts b/src/server/services/field/notes.ts new file mode 100644 index 0000000..76d0489 --- /dev/null +++ b/src/server/services/field/notes.ts @@ -0,0 +1,26 @@ +import { ServiceError, type ServiceCtx } from "@/server/services/context"; +import type { ParsedOpPayload } from "@/lib/sync/ops"; +import { audit, isUniqueViolation, requireFieldOrder } from "./common"; + +/** Activity notes (Spec §12.3): structured kind + text. Idempotent over the device clientId. */ +export async function createNote(ctx: ServiceCtx, input: ParsedOpPayload<"note.create">) { + const wo = await requireFieldOrder(ctx, input.workOrderId, { editable: true }); + + if (input.clientId) { + const replay = await ctx.db.activityNote.findFirst({ where: { clientId: input.clientId } }); + if (replay) { + if (replay.workOrderId !== wo.id) throw new ServiceError("invalid", "clientId already used"); + return { noteId: replay.id }; + } + } + try { + const note = await ctx.db.activityNote.create({ + data: { tenantId: ctx.tenantId, workOrderId: wo.id, authorId: ctx.userId, kind: input.kind, text: input.text, clientId: input.clientId ?? null }, + }); + await audit(ctx, "create", "activity_note", note.id, null, { workOrderId: wo.id, kind: note.kind, text: note.text }); + return { noteId: note.id }; + } catch (err) { + if (isUniqueViolation(err)) throw new ServiceError("conflict", "clientId already used"); + throw err; + } +} diff --git a/src/server/services/field/page-context.ts b/src/server/services/field/page-context.ts new file mode 100644 index 0000000..d77b137 --- /dev/null +++ b/src/server/services/field/page-context.ts @@ -0,0 +1,15 @@ +import { moduleGuard } from "@/server/action-guard"; +import { can, ctxFromGuard, type ServiceCtx } from "@/server/services/context"; + +/** + * Service context for the mobile pages (server components). Uses the same DB-authoritative guard + * as mutations (account status, permissions, module "field"); page-level reads then go through + * the visibility scopes of the services. + */ +export async function fieldPageContext(): Promise { + return ctxFromGuard(await moduleGuard("field")()); +} + +export function canUseFieldApp(ctx: ServiceCtx): boolean { + return can(ctx, "work_order:read_team") || can(ctx, "work_order:read_all"); +} diff --git a/src/server/services/field/photos.ts b/src/server/services/field/photos.ts new file mode 100644 index 0000000..102d928 --- /dev/null +++ b/src/server/services/field/photos.ts @@ -0,0 +1,71 @@ +import { ServiceError, type ServiceCtx } from "@/server/services/context"; +import type { ParsedOpPayload } from "@/lib/sync/ops"; +import { audit, isUniqueViolation, opTime, requireFieldOrder } from "./common"; + +/** + * Photo documentation (Spec §14.1/§14.3). The binary is uploaded first (POST /api/v1/uploads → + * Document category photo); this attaches it to the work order with phase, photo requirement + * (Pflichtfoto/Kategorie), checklist item, comment and optional location. + */ +export async function attachPhoto(ctx: ServiceCtx, input: ParsedOpPayload<"photo.attach">) { + const wo = await requireFieldOrder(ctx, input.workOrderId, { editable: true }); + + if (input.clientId) { + const replay = await ctx.db.photo.findFirst({ where: { clientId: input.clientId } }); + if (replay) { + if (replay.workOrderId !== wo.id || replay.documentId !== input.documentId) throw new ServiceError("invalid", "clientId already used"); + return { photoId: replay.id }; + } + } + + const doc = await ctx.db.document.findFirst({ + where: { id: input.documentId, workOrderId: wo.id, category: "photo", deletedAt: null, uploadStatus: "uploaded" }, + select: { id: true, uploadedById: true }, + }); + if (!doc) throw new ServiceError("not_found", "uploaded photo not found"); + if (doc.uploadedById !== ctx.userId) throw new ServiceError("forbidden", "photo was uploaded by another user"); + if (await ctx.db.photo.findFirst({ where: { documentId: doc.id }, select: { id: true } })) { + throw new ServiceError("invalid", "document is already attached"); + } + if (input.photoRequirementId && !(await ctx.db.photoRequirement.findFirst({ where: { id: input.photoRequirementId, workOrderId: wo.id }, select: { id: true } }))) { + throw new ServiceError("not_found", "photo requirement not found"); + } + if (input.checklistItemId && !(await ctx.db.checklistItem.findFirst({ where: { id: input.checklistItemId, workOrderId: wo.id }, select: { id: true } }))) { + throw new ServiceError("not_found", "checklist item not found"); + } + const session = await ctx.db.workSession.findFirst({ + where: { workOrderId: wo.id, userId: ctx.userId, status: { in: ["running", "paused", "en_route"] } }, + select: { id: true }, + }); + + try { + const photo = await ctx.db.photo.create({ + data: { + tenantId: ctx.tenantId, + workOrderId: wo.id, + workSessionId: session?.id ?? null, + documentId: doc.id, + checklistItemId: input.checklistItemId ?? null, + photoRequirementId: input.photoRequirementId ?? null, + phase: input.phase ?? null, + comment: input.comment?.trim() || null, + takenAt: opTime(input.takenAt), + latitude: input.latitude ?? null, + longitude: input.longitude ?? null, + takenById: ctx.userId, + clientId: input.clientId ?? null, + }, + }); + await audit(ctx, "create", "photo", photo.id, null, { + workOrderId: wo.id, + documentId: doc.id, + phase: photo.phase, + photoRequirementId: photo.photoRequirementId, + checklistItemId: photo.checklistItemId, + }); + return { photoId: photo.id }; + } catch (err) { + if (isUniqueViolation(err)) throw new ServiceError("conflict", "photo already attached"); + throw err; + } +} diff --git a/src/server/services/field/queries.ts b/src/server/services/field/queries.ts new file mode 100644 index 0000000..6227ff9 --- /dev/null +++ b/src/server/services/field/queries.ts @@ -0,0 +1,278 @@ +import type { Prisma } from "@prisma/client"; +import { ServiceError, type ServiceCtx } from "@/server/services/context"; +import { allowedDocumentVisibility, requireVisibleWorkOrder, workOrderScope } from "@/server/services/work-orders/visibility"; +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"; + +/** Read models of the mobile app (Spec §11.2, §22, US-005). All reads go through workOrderScope. */ + +export const ORDER_TABS = ["upcoming", "running", "to_complete", "past"] as const; +export type OrderTab = (typeof ORDER_TABS)[number]; + +const TAB_STATUSES: Record = { + upcoming: ["planned", "assigned", "accepted"], + running: ["en_route", "in_progress", "paused", "waiting_material", "daily_report_created"], + to_complete: ["technically_completed", "signature_pending"], + past: ["in_review", "released_for_billing", "billed", "cancelled"], +}; + +/** Categories shown in the photo/voice sections instead of the document list. */ +const MEDIA_CATEGORIES = ["photo", "voice_note", "signature"] as const; + +export type OrderCard = { + id: string; + number: string; + title: string; + status: WorkOrderStatus; + statusGroup: StatusGroup; + priority: string; + isEmergency: boolean; + customerName: string; + siteName: string | null; + address: string | null; + mapsUrl: string | null; + plannedStart: Date | null; + plannedEnd: Date | null; + version: number; +}; + +const CARD_SELECT = { + id: true, + number: true, + title: true, + status: true, + priority: true, + isEmergency: true, + plannedStart: true, + plannedEnd: true, + version: true, + customer: { select: { companyName: true, firstName: true, lastName: true, street: true, houseNumber: true, postalCode: true, city: true } }, + site: { select: { name: true, street: true, houseNumber: true, postalCode: true, city: true } }, +} satisfies Prisma.WorkOrderSelect; + +type Addressable = { street: string | null; houseNumber: string | null; postalCode: string | null; city: string | null }; + +export function formatAddress(a: Addressable | null | undefined): string | null { + if (!a) return null; + const line1 = [a.street, a.houseNumber].filter(Boolean).join(" "); + const line2 = [a.postalCode, a.city].filter(Boolean).join(" "); + const s = [line1, line2].filter(Boolean).join(", "); + return s || null; +} + +export function mapsUrl(address: string | null): string | null { + return address ? `https://www.google.com/maps/search/?api=1&query=${encodeURIComponent(address)}` : null; +} + +export function customerDisplayName(c: { companyName: string | null; firstName: string | null; lastName: string | null }): string { + return c.companyName?.trim() || [c.firstName, c.lastName].filter(Boolean).join(" ") || "—"; +} + +function toCard(wo: Prisma.WorkOrderGetPayload<{ select: typeof CARD_SELECT }>): OrderCard { + const address = formatAddress(wo.site) ?? formatAddress(wo.customer); + return { + id: wo.id, + number: wo.number, + title: wo.title, + status: wo.status, + statusGroup: STATUS_GROUP[wo.status], + priority: wo.priority, + isEmergency: wo.isEmergency, + customerName: customerDisplayName(wo.customer), + siteName: wo.site?.name ?? null, + address, + mapsUrl: mapsUrl(address), + plannedStart: wo.plannedStart, + plannedEnd: wo.plannedEnd, + version: wo.version, + }; +} + +export async function listFieldOrders(ctx: ServiceCtx, tab: OrderTab): Promise { + const scope = await workOrderScope(ctx); + const rows = await ctx.db.workOrder.findMany({ + where: { AND: [scope, { status: { in: TAB_STATUSES[tab] } }] }, + orderBy: tab === "past" ? [{ updatedAt: "desc" }] : [{ plannedStart: { sort: "asc", nulls: "last" } }, { createdAt: "asc" }], + take: tab === "past" ? 50 : 200, + select: CARD_SELECT, + }); + return rows.map(toCard); +} + +/** "Heute": orders planned for today (not yet done) plus all running and paused ones. */ +export async function listTodayOrders(ctx: ServiceCtx, now = new Date()): Promise { + const start = new Date(now); + start.setHours(0, 0, 0, 0); + const end = new Date(start); + end.setDate(end.getDate() + 1); + const scope = await workOrderScope(ctx); + const rows = await ctx.db.workOrder.findMany({ + where: { + AND: [ + scope, + { + OR: [ + { status: { in: TAB_STATUSES.running } }, + { + status: { in: [...TAB_STATUSES.upcoming, ...TAB_STATUSES.to_complete] }, + plannedStart: { lt: end }, + OR: [{ plannedEnd: { gte: start } }, { plannedEnd: null, plannedStart: { gte: start } }], + }, + ], + }, + ], + }, + orderBy: [{ plannedStart: { sort: "asc", nulls: "last" } }, { createdAt: "asc" }], + take: 100, + select: CARD_SELECT, + }); + return rows.map(toCard); +} + +const DETAIL_SELECT = { + ...CARD_SELECT, + externalOrderNumber: true, + description: true, + scope: true, + technicianNotes: true, + signatureRequired: true, + siteId: true, + orderType: { select: { name: true } }, + customer: { + select: { id: true, companyName: true, firstName: true, lastName: true, street: true, houseNumber: true, postalCode: true, city: true, phone: true, mobile: true, email: true }, + }, + contact: { select: { name: true, role: true, phone: true, mobile: true, email: true } }, + site: { + select: { + id: true, + name: true, + street: true, + houseNumber: true, + postalCode: true, + city: true, + phone: true, + onSiteContact: true, + accessNotes: true, + parkingNotes: true, + safetyNotes: true, + technicalNotes: true, + contact: { select: { name: true, role: true, phone: true, mobile: true, email: true } }, + }, + }, + team: { select: { name: true } }, + checklistItems: { orderBy: { sortOrder: "asc" }, select: { id: true, label: true, required: true, requiresPhoto: true, checked: true, checkedAt: true, comment: true } }, + photoRequirements: { orderBy: { sortOrder: "asc" }, select: { id: true, key: true, label: true, _count: { select: { photos: true } } } }, + materialPlans: { orderBy: { sortOrder: "asc" }, select: { id: true, name: true, articleNumber: true, plannedQuantity: true, unit: true, notes: true } }, + materialUsages: { + orderBy: { createdAt: "asc" }, + select: { id: true, materialPlanId: true, name: true, articleNumber: true, actualQuantity: true, unit: true, usageStatus: true, deviationReason: true, notes: true, clientId: true }, + }, + notes: { where: { deletedAt: null }, orderBy: { createdAt: "desc" }, take: 100, select: { id: true, kind: true, text: true, createdAt: true, authorId: true } }, + photos: { + orderBy: { takenAt: "desc" }, + select: { id: true, documentId: true, phase: true, comment: true, takenAt: true, photoRequirementId: true, checklistItemId: true, takenById: true }, + }, + voiceNotes: { orderBy: { recordedAt: "desc" }, select: { id: true, documentId: true, durationSeconds: true, transcript: true, transcriptionStatus: true, recordedAt: true } }, + workSessions: { + orderBy: { startedAt: "desc" }, + select: { + id: true, + userId: true, + status: true, + startedAt: true, + endedAt: true, + startedOffline: true, + user: { select: { name: true } }, + entries: { orderBy: { startedAt: "asc" }, select: { id: true, type: true, startedAt: true, endedAt: true, corrected: true, correctionReason: true } }, + }, + }, +} satisfies Prisma.WorkOrderSelect; + +export type FieldOrderDetail = Prisma.WorkOrderGetPayload<{ select: typeof DETAIL_SELECT }> & { + card: OrderCard; + documents: Array<{ id: string; title: string | null; fileName: string; category: string; mimeType: string; fileSize: number; createdAt: Date; source: "order" | "site" }>; + siteHistory: SiteHistoryEntry[]; + blockers: CompletionBlocker[]; + mySession: { id: string; status: "en_route" | "running" | "paused" | "ended" } | null; +}; + +export async function getFieldOrderDetail(ctx: ServiceCtx, workOrderId: string): Promise { + const wo = (await requireVisibleWorkOrder(ctx, workOrderId, DETAIL_SELECT)) as unknown as Prisma.WorkOrderGetPayload<{ select: typeof DETAIL_SELECT }>; + const visibility = allowedDocumentVisibility(ctx); + const docWhere = { deletedAt: null, uploadStatus: "uploaded" as const, visibility: { in: visibility }, category: { notIn: [...MEDIA_CATEGORIES] } }; + const docSelect = { id: true, title: true, fileName: true, category: true, mimeType: true, fileSize: true, createdAt: true, lineageId: true, version: true } as const; + + 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), + ]); + + // only the newest version per lineage + const latest = (docs: T[]) => + docs.filter((d) => !docs.some((o) => o.lineageId === d.lineageId && o.version > d.version)); + + const mine = wo.workSessions.find((s) => s.userId === ctx.userId && ACTIVE_SESSION_STATUSES.includes(s.status)); + return { + ...wo, + card: toCard(wo), + documents: [ + ...latest(orderDocs).map((d) => ({ ...d, source: "order" as const })), + ...latest(siteDocs).map((d) => ({ ...d, source: "site" as const })), + ], + siteHistory, + blockers, + mySession: mine ? { id: mine.id, status: mine.status } : null, + }; +} + +/** + * Offline pull bundle (ARCHITEKTUR §4.6): open orders in scope (optionally only those changed + * since `since`) with customer, site, contacts, checklist, material plan, photo requirements, + * document metadata and the approved reports at the site. Blobs are fetched separately. + */ +export async function getFieldBundle(ctx: ServiceCtx, since?: Date | null) { + const scope = await workOrderScope(ctx); + const statuses: WorkOrderStatus[] = [...TAB_STATUSES.upcoming, ...TAB_STATUSES.running, ...TAB_STATUSES.to_complete]; + const serverTime = new Date(); + const orders = await ctx.db.workOrder.findMany({ + where: { AND: [scope, { status: { in: statuses } }, since ? { updatedAt: { gt: since } } : {}] }, + orderBy: [{ plannedStart: { sort: "asc", nulls: "last" } }], + take: 200, + select: { + ...CARD_SELECT, + externalOrderNumber: true, + description: true, + scope: true, + technicianNotes: true, + signatureRequired: true, + updatedAt: true, + orderType: DETAIL_SELECT.orderType, + customer: DETAIL_SELECT.customer, + contact: DETAIL_SELECT.contact, + site: DETAIL_SELECT.site, + checklistItems: DETAIL_SELECT.checklistItems, + photoRequirements: DETAIL_SELECT.photoRequirements, + materialPlans: DETAIL_SELECT.materialPlans, + materialUsages: DETAIL_SELECT.materialUsages, + documents: { + where: { deletedAt: null, uploadStatus: "uploaded", visibility: { in: allowedDocumentVisibility(ctx) }, category: { notIn: [...MEDIA_CATEGORIES] } }, + select: { id: true, title: true, fileName: true, category: true, mimeType: true, fileSize: true, checksum: true, version: true, lineageId: true }, + }, + }, + }); + + 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)), + ); + return { + serverTime: serverTime.toISOString(), + since: since?.toISOString() ?? null, + orders: orders.map((o) => ({ ...o, statusGroup: STATUS_GROUP[o.status], siteHistory: o.site ? histories[o.site.id] ?? [] : [] })), + }; +} diff --git a/src/server/services/field/sessions.ts b/src/server/services/field/sessions.ts new file mode 100644 index 0000000..66d6f7c --- /dev/null +++ b/src/server/services/field/sessions.ts @@ -0,0 +1,179 @@ +import type { TimeEntryType, WorkSessionStatus } from "@prisma/client"; +import { ServiceError, type ServiceCtx } from "@/server/services/context"; +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"; + +/** + * Work sessions (Spec §12.1/§12.2): one active session per user + work order. A session consists + * of TimeEntry segments (travel → work ⇄ break). Status changes of the work order are delegated to + * transitionWorkOrder (never a direct status update). + */ + +export const ACTIVE_SESSION_STATUSES: WorkSessionStatus[] = ["en_route", "running", "paused"]; + +export type SessionResult = { + sessionId: string; + status: WorkSessionStatus; + workOrderStatus: WorkOrderStatus; + workOrderVersion: number; +}; + +export type SessionEndResult = SessionResult & { + workSeconds: number; + breakSeconds: number; + travelSeconds: number; + totalSeconds: number; +}; + +function activeSession(ctx: ServiceCtx, workOrderId: string) { + return ctx.db.workSession.findFirst({ + where: { workOrderId, userId: ctx.userId, status: { in: ACTIVE_SESSION_STATUSES } }, + orderBy: { startedAt: "desc" }, + }); +} + +/** Other users' sessions that are still working on the order. */ +function othersRunning(ctx: ServiceCtx, workOrderId: string) { + return ctx.db.workSession.count({ where: { workOrderId, userId: { not: ctx.userId }, status: { in: ["running", "en_route"] } } }); +} + +async function closeOpenEntries(ctx: ServiceCtx, sessionId: string, at: Date) { + const open = await ctx.db.timeEntry.findMany({ where: { workSessionId: sessionId, endedAt: null } }); + for (const e of open) { + await ctx.db.timeEntry.update({ where: { id: e.id }, data: { endedAt: at < e.startedAt ? e.startedAt : at } }); + } +} + +function openEntry(ctx: ServiceCtx, sessionId: string, type: TimeEntryType, at: Date) { + return ctx.db.timeEntry.create({ data: { tenantId: ctx.tenantId, workSessionId: sessionId, userId: ctx.userId, type, startedAt: at } }); +} + +/** Transition the order when the status machine allows it; returns the (new) status + version. */ +async function moveOrder(ctx: ServiceCtx, wo: FieldOrder, to: WorkOrderStatus): Promise<{ status: WorkOrderStatus; version: number }> { + if (wo.status === to || !canTransition(wo.status, to)) return { status: wo.status, version: wo.version }; + const r = await transitionWorkOrder(ctx, { workOrderId: wo.id, to }); + return { status: r.status, version: r.version }; +} + +export async function startSession(ctx: ServiceCtx, input: ParsedOpPayload<"session.start">): Promise { + const wo = await requireFieldOrder(ctx, input.workOrderId, { editable: true }); + const at = opTime(input.at); + + if (input.clientId) { + const replay = await ctx.db.workSession.findFirst({ where: { clientId: input.clientId } }); + if (replay) { + if (replay.workOrderId !== wo.id || replay.userId !== ctx.userId) throw new ServiceError("invalid", "clientId already used"); + return { sessionId: replay.id, status: replay.status, workOrderStatus: wo.status, workOrderVersion: wo.version }; + } + } + + const existing = await activeSession(ctx, wo.id); + + if (input.mode === "travel") { + if (existing) throw new ServiceError("conflict", "a session is already active for this work order"); + const moved = await moveOrder(ctx, wo, "en_route"); + const session = await ctx.db.workSession.create({ + data: { + tenantId: ctx.tenantId, + workOrderId: wo.id, + userId: ctx.userId, + teamId: wo.assignedTeamId, + status: "en_route", + startedAt: at, + startLat: input.latitude ?? null, + startLng: input.longitude ?? null, + startedOffline: input.offline, + deviceInfo: input.deviceInfo ?? null, + clientId: input.clientId ?? null, + }, + }); + await openEntry(ctx, session.id, "travel", at); + await audit(ctx, "create", "work_session", session.id, null, { workOrderId: wo.id, status: "en_route", startedAt: at }); + return { sessionId: session.id, status: session.status, workOrderStatus: moved.status, workOrderVersion: moved.version }; + } + + // mode "work" + if (existing && existing.status !== "en_route") throw new ServiceError("conflict", "a session is already running for this work order"); + const moved = await moveOrder(ctx, wo, "in_progress"); + if (existing) { + await closeOpenEntries(ctx, existing.id, at); + await openEntry(ctx, existing.id, "work", at); + const session = await ctx.db.workSession.update({ where: { id: existing.id }, data: { status: "running" } }); + await audit(ctx, "update", "work_session", session.id, { status: existing.status }, { status: "running", at }); + return { sessionId: session.id, status: session.status, workOrderStatus: moved.status, workOrderVersion: moved.version }; + } + const session = await ctx.db.workSession.create({ + data: { + tenantId: ctx.tenantId, + workOrderId: wo.id, + userId: ctx.userId, + teamId: wo.assignedTeamId, + status: "running", + startedAt: at, + startLat: input.latitude ?? null, + startLng: input.longitude ?? null, + startedOffline: input.offline, + deviceInfo: input.deviceInfo ?? null, + clientId: input.clientId ?? null, + }, + }); + await openEntry(ctx, session.id, "work", at); + await audit(ctx, "create", "work_session", session.id, null, { workOrderId: wo.id, status: "running", startedAt: at }); + return { sessionId: session.id, status: session.status, workOrderStatus: moved.status, workOrderVersion: moved.version }; +} + +export async function pauseSession(ctx: ServiceCtx, input: ParsedOpPayload<"session.pause">): Promise { + const wo = await requireFieldOrder(ctx, input.workOrderId, { editable: true }); + const at = opTime(input.at); + const existing = await activeSession(ctx, wo.id); + if (!existing || existing.status !== "running") throw new ServiceError("invalid", "no running session to pause"); + await closeOpenEntries(ctx, existing.id, at); + await openEntry(ctx, existing.id, "break", at); + const session = await ctx.db.workSession.update({ where: { id: existing.id }, data: { status: "paused" } }); + const moved = wo.status === "in_progress" && (await othersRunning(ctx, wo.id)) === 0 ? await moveOrder(ctx, wo, "paused") : { status: wo.status, version: wo.version }; + await audit(ctx, "update", "work_session", session.id, { status: "running" }, { status: "paused", at }); + return { sessionId: session.id, status: session.status, workOrderStatus: moved.status, workOrderVersion: moved.version }; +} + +export async function resumeSession(ctx: ServiceCtx, input: ParsedOpPayload<"session.resume">): Promise { + const wo = await requireFieldOrder(ctx, input.workOrderId, { editable: true }); + const at = opTime(input.at); + const existing = await activeSession(ctx, wo.id); + if (!existing || existing.status !== "paused") throw new ServiceError("invalid", "no paused session to resume"); + await closeOpenEntries(ctx, existing.id, at); + await openEntry(ctx, existing.id, "work", at); + const session = await ctx.db.workSession.update({ where: { id: existing.id }, data: { status: "running" } }); + const moved = await moveOrder(ctx, wo, "in_progress"); + await audit(ctx, "update", "work_session", session.id, { status: "paused" }, { status: "running", at }); + return { sessionId: session.id, status: session.status, workOrderStatus: moved.status, workOrderVersion: moved.version }; +} + +export async function endSession(ctx: ServiceCtx, input: ParsedOpPayload<"session.end">): Promise { + const wo = await requireFieldOrder(ctx, input.workOrderId, { editable: true }); + const at = opTime(input.at); + const existing = await activeSession(ctx, wo.id); + if (!existing) throw new ServiceError("invalid", "no active session to end"); + await closeOpenEntries(ctx, existing.id, at); + const endedAt = at < existing.startedAt ? existing.startedAt : at; + const session = await ctx.db.workSession.update({ where: { id: existing.id }, data: { status: "ended", endedAt } }); + const entries = await ctx.db.timeEntry.findMany({ where: { workSessionId: session.id } }); + const sum = (types: TimeEntryType[]) => + Math.round(entries.filter((e) => types.includes(e.type)).reduce((acc, e) => acc + ((e.endedAt ?? endedAt).getTime() - e.startedAt.getTime()), 0) / 1000); + const workSeconds = sum(["work", "material_procurement", "interruption"]); + const breakSeconds = sum(["break"]); + const travelSeconds = sum(["travel", "return_travel"]); + await audit(ctx, "update", "work_session", session.id, { status: existing.status }, { status: "ended", endedAt, workSeconds, breakSeconds, travelSeconds }); + return { + sessionId: session.id, + status: session.status, + workOrderStatus: wo.status, + workOrderVersion: wo.version, + workSeconds, + breakSeconds, + travelSeconds, + totalSeconds: Math.round((endedAt.getTime() - session.startedAt.getTime()) / 1000), + }; +} diff --git a/src/server/services/field/stubs/documents-store.ts b/src/server/services/field/stubs/documents-store.ts new file mode 100644 index 0000000..fa921ab --- /dev/null +++ b/src/server/services/field/stubs/documents-store.ts @@ -0,0 +1,97 @@ +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 new file mode 100644 index 0000000..7554eab --- /dev/null +++ b/src/server/services/field/stubs/site-history.ts @@ -0,0 +1,51 @@ +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 new file mode 100644 index 0000000..a580b0d --- /dev/null +++ b/src/server/services/field/stubs/work-order-transition.ts @@ -0,0 +1,97 @@ +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/time-correction.ts b/src/server/services/field/time-correction.ts new file mode 100644 index 0000000..ec62c6a --- /dev/null +++ b/src/server/services/field/time-correction.ts @@ -0,0 +1,56 @@ +import { z } from "zod"; +import { assertCan, ServiceError, type ServiceCtx } from "@/server/services/context"; +import { requireVisibleWorkOrder } from "@/server/services/work-orders/visibility"; +import { audit } from "./common"; + +/** Manual time corrections (Spec §12.2): only with `field:correct_time`, reason mandatory, always audited. */ + +export const TIME_ENTRY_TYPES = ["travel", "work", "break", "material_procurement", "return_travel", "interruption"] as const; + +export const timeCorrectionSchema = z + .object({ + timeEntryId: z.string().min(1).max(64), + startedAt: z.coerce.date(), + endedAt: z.coerce.date().nullish(), + type: z.enum(TIME_ENTRY_TYPES).optional(), + reason: z.string().trim().min(3).max(1000), + }) + .refine((v) => !v.endedAt || v.endedAt.getTime() >= v.startedAt.getTime(), { message: "end before start", path: ["endedAt"] }); + +export type TimeCorrectionInput = z.input; + +export async function correctTimeEntry(ctx: ServiceCtx, raw: TimeCorrectionInput) { + assertCan(ctx, "field:correct_time"); + const parsed = timeCorrectionSchema.safeParse(raw); + if (!parsed.success) throw new ServiceError("invalid", "invalid time correction", parsed.error.issues); + const input = parsed.data; + + const entry = await ctx.db.timeEntry.findFirst({ + where: { id: input.timeEntryId }, + include: { workSession: { select: { workOrderId: true } } }, + }); + if (!entry) throw new ServiceError("not_found", "time entry not found"); + await requireVisibleWorkOrder(ctx, entry.workSession.workOrderId, { id: true }); + if (input.startedAt.getTime() > Date.now() + 60_000) throw new ServiceError("invalid", "start in the future"); + + const before = { type: entry.type, startedAt: entry.startedAt, endedAt: entry.endedAt, corrected: entry.corrected, correctionReason: entry.correctionReason }; + const updated = await ctx.db.timeEntry.update({ + where: { id: entry.id }, + data: { + startedAt: input.startedAt, + endedAt: input.endedAt === undefined ? entry.endedAt : input.endedAt, + type: input.type ?? entry.type, + corrected: true, + correctionReason: input.reason, + correctedById: ctx.userId, + }, + }); + await audit(ctx, "update", "time_entry", entry.id, before, { + type: updated.type, + startedAt: updated.startedAt, + endedAt: updated.endedAt, + corrected: true, + correctionReason: input.reason, + }); + return updated; +} diff --git a/src/server/services/field/uploads.ts b/src/server/services/field/uploads.ts new file mode 100644 index 0000000..92932db --- /dev/null +++ b/src/server/services/field/uploads.ts @@ -0,0 +1,98 @@ +import { z } from "zod"; +import { storage } from "@/server/storage/adapter"; +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"; + +/** + * Binary uploads of the mobile app (ARCHITEKTUR §4.6: POST /api/v1/uploads → documentId). + * Idempotent per tenant over the device `clientId` (stored as the document lineage + * `upload::`, so the same clientId in another tenant never collides). + * The client sends a compressed image plus an optional 400 px thumbnail; without a thumbnail + * the image-derivatives job creates one. + */ + +export const uploadMetaSchema = z.object({ + clientId: z.string().uuid(), + workOrderId: z.string().min(1).max(64), + kind: z.enum(["photo", "voice_note"]), +}); +export type UploadMeta = z.infer; + +export type UploadFile = { bytes: Buffer; name: string; type: string }; + +const MAX_PREVIEW_BYTES = 2 * 1024 * 1024; + +export function uploadLineageId(tenantId: string, clientId: string): string { + return `upload:${tenantId}:${clientId}`; +} + +export async function storeFieldUpload( + ctx: ServiceCtx, + meta: UploadMeta, + file: UploadFile, + preview?: UploadFile | null, +): Promise<{ documentId: string; duplicate: boolean }> { + const wo = await requireFieldOrder(ctx, meta.workOrderId, { editable: true }); + const lineageId = uploadLineageId(ctx.tenantId, meta.clientId); + + const replay = async () => { + const existing = await ctx.db.document.findFirst({ where: { lineageId }, orderBy: { version: "asc" }, select: { id: true, workOrderId: true, uploadedById: true } }); + if (!existing) return null; + if (existing.workOrderId !== wo.id || existing.uploadedById !== ctx.userId) throw new ServiceError("invalid", "clientId already used"); + return { documentId: existing.id, duplicate: true }; + }; + const prior = await replay(); + if (prior) return prior; + + const sniffed = sniffMime(file.bytes); + const expectedKind = meta.kind === "photo" ? "image" : "audio"; + if (!sniffed || sniffed.kind !== expectedKind) throw new ServiceError("invalid", `file is not a valid ${expectedKind}`); + + 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, + }); + } catch (err) { + if (isUniqueViolation(err)) { + const raced = await replay(); + if (raced) return raced; + } + throw err; + } + + if (meta.kind === "photo") { + const previewMime = preview ? sniffMime(preview.bytes) : null; + if (preview && previewMime?.kind === "image" && preview.bytes.byteLength <= MAX_PREVIEW_BYTES) { + const stored = await storage.put({ tenantId: ctx.tenantId, filename: `thumb-${doc.fileName}`, contentType: previewMime.mime, bytes: preview.bytes }); + doc = await ctx.db.document.update({ where: { id: doc.id }, data: { previewKey: stored.storageKey } }); + } else { + try { + await dispatchJob(JOB_QUEUES.imageDerivatives, { tenantId: ctx.tenantId, entityId: doc.id, actorId: ctx.userId }); + } catch (err) { + // thumbnails are optional — the original stays usable + console.error("[field] image-derivatives dispatch failed:", (err as Error).message); + } + } + } + + await audit(ctx, "create", "document", doc.id, null, { + workOrderId: wo.id, + category: doc.category, + fileName: doc.fileName, + mimeType: doc.mimeType, + fileSize: doc.fileSize, + checksum: doc.checksum, + }); + return { documentId: doc.id, duplicate: false }; +} diff --git a/src/server/services/field/voice.ts b/src/server/services/field/voice.ts new file mode 100644 index 0000000..2ecd8e7 --- /dev/null +++ b/src/server/services/field/voice.ts @@ -0,0 +1,74 @@ +import { ServiceError, type ServiceCtx } from "@/server/services/context"; +import { dispatchJob } from "@/server/jobs/dispatch"; +import { JOB_QUEUES } from "@/server/jobs/queues"; +import { PROCESSORS } from "@/server/jobs/processors"; +import type { ParsedOpPayload } from "@/lib/sync/ops"; +import { audit, isUniqueViolation, opTime, requireFieldOrder } from "./common"; + +/** + * Voice notes (Spec §15.1): attach an uploaded audio Document as VoiceNote (status pending) and + * queue the transcription job. The processor belongs to lane Lotse (L9); while none is registered + * the note is marked `disabled` (graceful degradation, no error for the technician). + */ +export async function attachVoiceNote(ctx: ServiceCtx, input: ParsedOpPayload<"voice.attach">) { + const wo = await requireFieldOrder(ctx, input.workOrderId, { editable: true }); + + if (input.clientId) { + const replay = await ctx.db.voiceNote.findFirst({ where: { clientId: input.clientId } }); + if (replay) { + if (replay.workOrderId !== wo.id || replay.documentId !== input.documentId) throw new ServiceError("invalid", "clientId already used"); + return { voiceNoteId: replay.id, transcriptionStatus: replay.transcriptionStatus }; + } + } + + const doc = await ctx.db.document.findFirst({ + where: { id: input.documentId, workOrderId: wo.id, category: "voice_note", deletedAt: null, uploadStatus: "uploaded" }, + select: { id: true, uploadedById: true }, + }); + if (!doc) throw new ServiceError("not_found", "uploaded audio not found"); + if (doc.uploadedById !== ctx.userId) throw new ServiceError("forbidden", "audio was uploaded by another user"); + if (await ctx.db.voiceNote.findFirst({ where: { documentId: doc.id }, select: { id: true } })) { + throw new ServiceError("invalid", "document is already attached"); + } + const session = await ctx.db.workSession.findFirst({ + where: { workOrderId: wo.id, userId: ctx.userId, status: { in: ["running", "paused", "en_route"] } }, + select: { id: true }, + }); + + let voice; + try { + voice = await ctx.db.voiceNote.create({ + data: { + tenantId: ctx.tenantId, + workOrderId: wo.id, + workSessionId: session?.id ?? null, + documentId: doc.id, + durationSeconds: input.durationSeconds ?? null, + transcriptionStatus: "pending", + recordedById: ctx.userId, + recordedAt: opTime(input.recordedAt), + clientId: input.clientId ?? null, + }, + }); + } catch (err) { + if (isUniqueViolation(err)) throw new ServiceError("conflict", "voice note already attached"); + throw err; + } + await audit(ctx, "create", "voice_note", voice.id, null, { workOrderId: wo.id, documentId: doc.id, durationSeconds: voice.durationSeconds }); + + let status = voice.transcriptionStatus; + if (!PROCESSORS[JOB_QUEUES.transcription]) { + status = "disabled"; + } else { + try { + await dispatchJob(JOB_QUEUES.transcription, { tenantId: ctx.tenantId, entityId: voice.id, actorId: ctx.userId }); + } catch (err) { + console.error("[field] transcription dispatch failed:", (err as Error).message); + status = "failed"; + } + } + if (status !== voice.transcriptionStatus) { + await ctx.db.voiceNote.update({ where: { id: voice.id }, data: { transcriptionStatus: status } }); + } + return { voiceNoteId: voice.id, transcriptionStatus: status }; +} diff --git a/src/server/services/sync/api-context.ts b/src/server/services/sync/api-context.ts new file mode 100644 index 0000000..f04c4fe --- /dev/null +++ b/src/server/services/sync/api-context.ts @@ -0,0 +1,57 @@ +import { NextResponse } from "next/server"; +import { moduleGuard } from "@/server/action-guard"; +import { ForbiddenError, type Permission } from "@/server/rbac"; +import { ModuleDisabledError } from "@/server/modules"; +import { ctxFromGuard, ServiceError, type ServiceCtx } from "@/server/services/context"; +import type { ModuleKey } from "@/lib/modules"; + +/** + * Context for /api/v1 route handlers (lane L4: sync, uploads, field). Reuses moduleGuard, so + * route handlers get exactly the same DB-authoritative checks as server actions (session, account + * status, kill switch, password change, permissions, module enabled). + * NOTE for the architect: a shared `requireApiContext` is referenced in services/context.ts but not + * provided by the foundation — this is the lane-local implementation. + */ +export async function requireApiContext(moduleKey: ModuleKey, ...permissions: Permission[]): Promise { + return ctxFromGuard(await moduleGuard(moduleKey)(...permissions)); +} + +const STATUS_FOR: Record = { + not_found: 404, + forbidden: 403, + invalid: 400, + conflict: 409, + blocked: 422, +}; + +export function apiError(code: string, status: number, message?: string, details?: unknown) { + return NextResponse.json({ error: code, ...(message ? { message } : {}), ...(details !== undefined ? { details } : {}) }, { status, headers: { "Cache-Control": "no-store" } }); +} + +/** Wraps a handler: same-origin check for mutations + uniform error mapping (no internals leaked). */ +export async function withApi(req: Request, fn: () => Promise): Promise { + if (req.method !== "GET" && req.method !== "HEAD") { + const origin = req.headers.get("origin"); + const host = req.headers.get("x-forwarded-host") ?? req.headers.get("host"); + if (origin && host) { + let originHost: string | null = null; + try { + originHost = new URL(origin).host; + } catch { + originHost = null; + } + if (originHost !== host) return apiError("forbidden", 403, "cross-origin request"); + } + } + try { + return await fn(); + } catch (err) { + if (err instanceof ServiceError) return apiError(err.code, STATUS_FOR[err.code], err.message, err.code === "blocked" ? err.details : undefined); + if (err instanceof ForbiddenError || err instanceof ModuleDisabledError) return apiError("forbidden", 403); + const msg = err instanceof Error ? err.message : ""; + if (/Nicht angemeldet|nicht mehr gueltig/.test(msg)) return apiError("unauthorized", 401); + if (/Konto ist nicht aktiv|Passwortwechsel erforderlich/.test(msg)) return apiError("forbidden", 403); + console.error("[api/v1] unhandled error:", err); + return apiError("internal", 500); + } +} diff --git a/src/server/services/sync/apply.ts b/src/server/services/sync/apply.ts new file mode 100644 index 0000000..9ec5960 --- /dev/null +++ b/src/server/services/sync/apply.ts @@ -0,0 +1,186 @@ +import type { Prisma } from "@prisma/client"; +import { emitEvent } from "@/server/events"; +import { writeAuditLog } from "@/server/audit"; +import { ServiceError, type ServiceCtx } from "@/server/services/context"; +import { requireVisibleWorkOrder } from "@/server/services/work-orders/visibility"; +import { CONFLICTING_OPS, type SyncOperationInput, type SyncOpResult, type SyncOpType, type SyncResponse } from "@/lib/sync/envelope"; +import { OP_PAYLOAD_SCHEMAS, type ParsedOpPayload } from "@/lib/sync/ops"; +import { endSession, pauseSession, resumeSession, startSession } from "@/server/services/field/sessions"; +import { createNote } from "@/server/services/field/notes"; +import { toggleChecklistItem } from "@/server/services/field/checklist"; +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 { EXTERNAL_OP_OWNERS, EXTERNAL_OPS } from "./external-ops"; + +/** + * Server side of the operation-based sync (ARCHITEKTUR §4.6). Online and offline clients use the + * same path. Per op: + * 1. idempotency: SyncOperation(tenantId, clientOpId) already stored → `duplicate` (stored result) + * 2. payload validation (src/lib/sync/ops.ts) → `rejected invalid` + * 3. conflict check for CONFLICTING_OPS: WorkOrder.version ≠ baseVersion → `conflict`, nothing + * written, SyncOperation(status=conflict) for the backoffice list, event `sync.failed` + * 4. dispatch to the domain services (the same ones the UI would use) + * Deterministic outcomes are stored; transient failures (internal errors, ops whose lane is not + * deployed yet) are NOT stored so the device can retry with the same clientOpId. + */ + +export type SyncRequest = { deviceId: string; operations: SyncOperationInput[] }; + +type HandlerResult = { idMap?: Record; entityVersion?: number }; +type Handler = (ctx: ServiceCtx, payload: unknown, op: SyncOperationInput) => Promise; + +const idMap = (clientId: string | undefined, serverId: string) => (clientId ? { [clientId]: serverId } : undefined); + +function h(fn: (ctx: ServiceCtx, payload: ParsedOpPayload, op: SyncOperationInput) => Promise): Handler { + return (ctx, payload, op) => fn(ctx, payload as ParsedOpPayload, op); +} + +const FIELD_HANDLERS: Partial> = { + "session.start": h<"session.start">(async (ctx, p) => { + const r = await startSession(ctx, p); + return { idMap: idMap(p.clientId, r.sessionId), entityVersion: r.workOrderVersion }; + }), + "session.pause": h<"session.pause">(async (ctx, p) => ({ entityVersion: (await pauseSession(ctx, p)).workOrderVersion })), + "session.resume": h<"session.resume">(async (ctx, p) => ({ entityVersion: (await resumeSession(ctx, p)).workOrderVersion })), + "session.end": h<"session.end">(async (ctx, p) => ({ entityVersion: (await endSession(ctx, p)).workOrderVersion })), + "work_order.transition": h<"work_order.transition">(async (ctx, p, op) => { + const r = await transitionWorkOrder(ctx, { workOrderId: p.workOrderId, to: p.to, reason: p.reason, baseVersion: op.baseVersion }); + return { entityVersion: r.version }; + }), + "note.create": h<"note.create">(async (ctx, p) => ({ idMap: idMap(p.clientId, (await createNote(ctx, p)).noteId) })), + "checklist.toggle": h<"checklist.toggle">(async (ctx, p) => { + await toggleChecklistItem(ctx, p); + return {}; + }), + "material.upsert": h<"material.upsert">(async (ctx, p) => ({ idMap: idMap(p.clientId, (await upsertMaterialUsage(ctx, p)).usageId) })), + "photo.attach": h<"photo.attach">(async (ctx, p) => ({ idMap: idMap(p.clientId, (await attachPhoto(ctx, p)).photoId) })), + "voice.attach": h<"voice.attach">(async (ctx, p) => ({ idMap: idMap(p.clientId, (await attachVoiceNote(ctx, p)).voiceNoteId) })), +}; + +class NotAvailable extends Error {} + +function workOrderIdOf(op: SyncOperationInput): string | undefined { + const fromPayload = (op.payload as { workOrderId?: unknown }).workOrderId; + if (typeof fromPayload === "string") return fromPayload; + return op.entityType === "work_order" ? op.entityId : undefined; +} + +async function record( + ctx: ServiceCtx, + op: SyncOperationInput, + deviceId: string, + status: "applied" | "conflict" | "rejected", + result: Record, + errorCode?: string, +): Promise<{ id: string } | "duplicate"> { + try { + return await ctx.db.syncOperation.create({ + data: { + tenantId: ctx.tenantId, + userId: ctx.userId, + clientOpId: op.clientOpId, + opType: op.opType, + entityType: op.entityType ?? (workOrderIdOf(op) ? "work_order" : null), + entityId: op.entityId ?? workOrderIdOf(op) ?? null, + baseVersion: op.baseVersion ?? null, + payload: op.payload as Prisma.InputJsonValue, + status, + result: { ...result, deviceId } as Prisma.InputJsonValue, + errorCode: errorCode ?? null, + clientCreatedAt: new Date(op.clientCreatedAt), + }, + select: { id: true }, + }); + } catch (err) { + if ((err as { code?: string }).code === "P2002") return "duplicate"; + throw err; + } +} + +async function applyOne(ctx: ServiceCtx, deviceId: string, op: SyncOperationInput): Promise { + const base = { clientOpId: op.clientOpId }; + + // 1. idempotency + const prior = await ctx.db.syncOperation.findFirst({ where: { clientOpId: op.clientOpId } }); + if (prior) { + if (prior.userId !== ctx.userId) return { ...base, status: "rejected", errorCode: "invalid", message: "clientOpId already used" }; + const stored = (prior.result ?? {}) as HandlerResult & { message?: string }; + return { + ...base, + status: "duplicate", + idMap: stored.idMap, + entityVersion: stored.entityVersion, + errorCode: (prior.errorCode as SyncOpResult["errorCode"]) ?? undefined, + message: prior.status === "applied" ? undefined : `original status: ${prior.status}`, + }; + } + + // 2. payload + const parsed = OP_PAYLOAD_SCHEMAS[op.opType].safeParse(op.payload); + if (!parsed.success) { + const message = parsed.error.issues.map((i) => `${i.path.join(".")}: ${i.message}`).join("; ").slice(0, 500); + const rec = await record(ctx, op, deviceId, "rejected", { message }, "invalid"); + return rec === "duplicate" ? { ...base, status: "duplicate" } : { ...base, status: "rejected", errorCode: "invalid", message }; + } + + try { + // 3. conflict check + if (CONFLICTING_OPS.includes(op.opType)) { + const workOrderId = workOrderIdOf(op); + if (!workOrderId || op.baseVersion === undefined) throw new ServiceError("invalid", "workOrderId and baseVersion are required"); + const wo = await requireVisibleWorkOrder(ctx, workOrderId, { id: true, number: true, version: true }); + if (wo.version !== op.baseVersion) { + const rec = await record(ctx, op, deviceId, "conflict", { currentVersion: wo.version, message: "work order was changed in the meantime" }, "conflict"); + if (rec === "duplicate") return { ...base, status: "duplicate" }; + await writeAuditLog({ + tenantId: ctx.tenantId, + actorId: ctx.userId, + action: "create", + entity: "sync_operation", + entityId: rec.id, + after: { opType: op.opType, workOrderId, status: "conflict", baseVersion: op.baseVersion, currentVersion: wo.version }, + }); + await emitEvent(ctx, { type: "sync.failed", entityType: "sync_operation", entityId: rec.id, data: { opType: op.opType, number: wo.number, reason: "conflict" } }); + return { ...base, status: "conflict", entityVersion: wo.version, errorCode: "conflict", message: "work order was changed in the meantime" }; + } + } + + // 4. dispatch + let handler = FIELD_HANDLERS[op.opType]; + if (!handler) { + const load = EXTERNAL_OPS[op.opType]; + const external = load ? await load() : null; + if (!external) throw new NotAvailable(`operation ${op.opType} is not available yet (lane ${EXTERNAL_OP_OWNERS[op.opType] ?? "unknown"})`); + handler = (c, _payload, o) => external(c, o); + } + const result = await handler(ctx, parsed.data, op); + const rec = await record(ctx, op, deviceId, "applied", { ...result }); + if (rec === "duplicate") return { ...base, status: "duplicate", ...result }; + return { ...base, status: "applied", ...result }; + } catch (err) { + if (err instanceof NotAvailable) return { ...base, status: "rejected", errorCode: "invalid", message: err.message }; + if (err instanceof ServiceError) { + // a transition conflict detected inside the service (race after the pre-check) + const status = err.code === "conflict" && CONFLICTING_OPS.includes(op.opType) ? "conflict" : "rejected"; + const details = err.code === "blocked" ? { blockers: err.details } : {}; + const rec = await record(ctx, op, deviceId, status, { message: err.message, ...details }, err.code); + if (rec === "duplicate") return { ...base, status: "duplicate" }; + if (status === "conflict") { + await emitEvent(ctx, { type: "sync.failed", entityType: "sync_operation", entityId: rec.id, data: { opType: op.opType, reason: "conflict" } }); + } + return { ...base, status, errorCode: err.code, message: err.code === "blocked" ? JSON.stringify(err.details ?? []) : err.message }; + } + console.error(`[sync] ${op.opType} ${op.clientOpId} failed:`, err); + return { ...base, status: "rejected", errorCode: "internal", message: "internal error" }; + } +} + +export async function applyOperations(ctx: ServiceCtx, request: SyncRequest): Promise { + const results: SyncOpResult[] = []; + // sequential on purpose: ops of one device depend on each other (start → pause → end) + for (const op of request.operations) results.push(await applyOne(ctx, request.deviceId, op)); + return { results, serverTime: new Date().toISOString() }; +} diff --git a/src/server/services/sync/external-ops.ts b/src/server/services/sync/external-ops.ts new file mode 100644 index 0000000..facc0d7 --- /dev/null +++ b/src/server/services/sync/external-ops.ts @@ -0,0 +1,30 @@ +import type { SyncOperationInput, SyncOpType } from "@/lib/sync/envelope"; +import type { ServiceCtx } from "@/server/services/context"; + +/** + * Registry of sync ops implemented by other lanes (reports: L5, emergency: L8). Each lane adds + * exactly ONE line with a lazy import of its module, e.g. + * "report.submit": () => import("@/server/services/reports/sync-ops").then((m) => m.applySyncOp), + * Contract of `applySyncOp(ctx, op)`: validate `op.payload`, check permissions/scope, throw + * ServiceError for deterministic failures and return `{ idMap?, entityVersion? }`. + * Unregistered ops are answered with `rejected invalid` and are NOT stored, so devices can retry + * them after the lane is deployed. (A computed `import()` path is not resolvable by Turbopack, + * hence the explicit registry.) + */ + +export type ExternalOpResult = { idMap?: Record; entityVersion?: number }; +export type ExternalOpHandler = (ctx: ServiceCtx, op: SyncOperationInput) => Promise; + +export const EXTERNAL_OP_OWNERS: Partial> = { + "report.save_draft": "reports (L5)", + "report.submit": "reports (L5)", + "signature.capture": "reports (L5)", + "emergency.create": "emergency (L8)", +}; + +export const EXTERNAL_OPS: Partial Promise>> = { + // lane-reports: "report.save_draft": () => import("@/server/services/reports/sync-ops").then((m) => m.applySyncOp), + // lane-reports: "report.submit": () => import("@/server/services/reports/sync-ops").then((m) => m.applySyncOp), + // lane-reports: "signature.capture": () => import("@/server/services/reports/sync-ops").then((m) => m.applySyncOp), + // lane-emergency: "emergency.create": () => import("@/server/services/emergency/sync-ops").then((m) => m.applySyncOp), +};