L4 Einsatz mobil: Field-Services, Sync-API, Uploads und Tests

- 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 <noreply@anthropic.com>
This commit is contained in:
2026-09-14 12:29:48 +02:00
co-authored by Claude Opus 5
parent bf4456718e
commit 952e74cddd
31 changed files with 2312 additions and 1 deletions
+133
View File
@@ -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<unknown>, 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<string>(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<ReturnType<typeof createFixture>>;
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);
},
};
}
+146
View File
@@ -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);
});
+158
View File
@@ -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<string, unknown>, extra: Partial<SyncOperationInput> = {}): 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);
});
+14
View File
@@ -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=<ISO> — 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" } });
});
}
@@ -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/<id>[?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 });
});
}
+14
View File
@@ -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" } });
});
}
+34
View File
@@ -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" } });
});
}
+2
View File
@@ -66,6 +66,8 @@ const ENTITY_LABEL: Record<string, string> = {
report: "Bericht", report: "Bericht",
emergency: "Notdienst", emergency: "Notdienst",
document: "Dokument", 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" }); const fmt = new Intl.DateTimeFormat("de-DE", { dateStyle: "medium", timeStyle: "short" });
+35
View File
@@ -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;
}
+136
View File
@@ -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<SyncOpType, z.ZodType>;
export type OpPayload<T extends SyncOpType> = z.input<(typeof OP_PAYLOAD_SCHEMAS)[T]>;
export type ParsedOpPayload<T extends SyncOpType> = z.output<(typeof OP_PAYLOAD_SCHEMAS)[T]>;
+35
View File
@@ -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<TimeCorrectionResult> {
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 };
}
@@ -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<Uint8Array>): Promise<Buffer> {
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<Buffer> {
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<void> {
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 },
});
}
+1 -1
View File
@@ -11,7 +11,7 @@ export const PROCESSORS: Partial<Record<JobQueueName, () => Promise<JobProcessor
// lane-imports: "import-extraction": () => import("./import-extraction").then((m) => m.process), // lane-imports: "import-extraction": () => import("./import-extraction").then((m) => m.process),
// lane-lotse: "transcription": () => import("./transcription").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-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). */ /** Inline fallback when no Redis is available (dev/demo). */
+22
View File
@@ -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 };
}
+65
View File
@@ -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<FieldOrder> {
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<void> {
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";
}
+55
View File
@@ -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,
};
}
+72
View File
@@ -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 };
}
+26
View File
@@ -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;
}
}
+15
View File
@@ -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<ServiceCtx> {
return ctxFromGuard(await moduleGuard("field")());
}
export function canUseFieldApp(ctx: ServiceCtx): boolean {
return can(ctx, "work_order:read_team") || can(ctx, "work_order:read_all");
}
+71
View File
@@ -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;
}
}
+278
View File
@@ -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<OrderTab, WorkOrderStatus[]> = {
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<OrderCard[]> {
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<OrderCard[]> {
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<FieldOrderDetail> {
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 = <T extends { lineageId: string; version: number }>(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] ?? [] : [] })),
};
}
+179
View File
@@ -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<SessionResult> {
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<SessionResult> {
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<SessionResult> {
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<SessionEndResult> {
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),
};
}
@@ -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<Kind, number> = { 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<Document> {
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,
},
});
}
@@ -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<SiteHistoryEntry[]> {
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,
}));
}
@@ -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<Record<WorkOrderStatus, EventType>> = {
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<CompletionBlocker[]> {
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<TransitionResult> {
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 };
}
@@ -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<typeof timeCorrectionSchema>;
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;
}
+98
View File
@@ -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:<tenantId>:<clientId>`, 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<typeof uploadMetaSchema>;
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 };
}
+74
View File
@@ -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 };
}
+57
View File
@@ -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<ServiceCtx> {
return ctxFromGuard(await moduleGuard(moduleKey)(...permissions));
}
const STATUS_FOR: Record<ServiceError["code"], number> = {
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<Response>): Promise<Response> {
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);
}
}
+186
View File
@@ -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<string, string>; entityVersion?: number };
type Handler = (ctx: ServiceCtx, payload: unknown, op: SyncOperationInput) => Promise<HandlerResult>;
const idMap = (clientId: string | undefined, serverId: string) => (clientId ? { [clientId]: serverId } : undefined);
function h<T extends SyncOpType>(fn: (ctx: ServiceCtx, payload: ParsedOpPayload<T>, op: SyncOperationInput) => Promise<HandlerResult>): Handler {
return (ctx, payload, op) => fn(ctx, payload as ParsedOpPayload<T>, op);
}
const FIELD_HANDLERS: Partial<Record<SyncOpType, Handler>> = {
"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<string, unknown>,
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<SyncOpResult> {
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<SyncResponse> {
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() };
}
+30
View File
@@ -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<string, string>; entityVersion?: number };
export type ExternalOpHandler = (ctx: ServiceCtx, op: SyncOperationInput) => Promise<ExternalOpResult>;
export const EXTERNAL_OP_OWNERS: Partial<Record<SyncOpType, string>> = {
"report.save_draft": "reports (L5)",
"report.submit": "reports (L5)",
"signature.capture": "reports (L5)",
"emergency.create": "emergency (L8)",
};
export const EXTERNAL_OPS: Partial<Record<SyncOpType, () => Promise<ExternalOpHandler>>> = {
// 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),
};