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:
@@ -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);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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 });
|
||||
});
|
||||
}
|
||||
@@ -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" } });
|
||||
});
|
||||
}
|
||||
@@ -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" } });
|
||||
});
|
||||
}
|
||||
@@ -66,6 +66,8 @@ const ENTITY_LABEL: Record<string, string> = {
|
||||
report: "Bericht",
|
||||
emergency: "Notdienst",
|
||||
document: "Dokument",
|
||||
// Einsatz mobil (L4)
|
||||
work_session: "Einsatz-Zeiterfassung", time_entry: "Zeitabschnitt", checklist_item: "Checklistenpunkt", material_usage: "Materialverbrauch", photo: "Foto", voice_note: "Sprachnotiz", activity_note: "Tätigkeitsnotiz", sync_operation: "Sync-Vorgang",
|
||||
};
|
||||
|
||||
const fmt = new Intl.DateTimeFormat("de-DE", { dateStyle: "medium", timeStyle: "short" });
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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]>;
|
||||
@@ -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 },
|
||||
});
|
||||
}
|
||||
@@ -11,7 +11,7 @@ export const PROCESSORS: Partial<Record<JobQueueName, () => Promise<JobProcessor
|
||||
// lane-imports: "import-extraction": () => import("./import-extraction").then((m) => m.process),
|
||||
// lane-lotse: "transcription": () => import("./transcription").then((m) => m.process),
|
||||
// lane-reports: "report-pdf": () => import("./report-pdf").then((m) => m.process),
|
||||
// lane-field: "image-derivatives": () => import("./image-derivatives").then((m) => m.process),
|
||||
"image-derivatives": () => import("./image-derivatives").then((m) => m.process),
|
||||
};
|
||||
|
||||
/** Inline fallback when no Redis is available (dev/demo). */
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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";
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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] ?? [] : [] })),
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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() };
|
||||
}
|
||||
@@ -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),
|
||||
};
|
||||
Reference in New Issue
Block a user