// Lane L10b „Betrieb & Aufräumen" — Sync-Aufräumpunkte: // b) Konflikt „Übernehmen" für report.submit (Dispatcher statt L2-Stub) // c) Bundle mit eigener laufender WorkSession je Auftrag (+ Offline-Ansicht nutzt sie) // f) clientId eindeutig je Mandant (@@unique([tenantId, clientId])) // j) Sync-Ops report.save_draft / report.submit inkl. aiReviewed (Lotse-Freigabeprinzip) // Jeweils mit Mandantentrennung (B) und Scope (Monteur ohne Zuweisung). // // Lauf: npx tsx scripts/test-betrieb-sync.ts (lokale Postgres-DB aus .env) import "dotenv/config"; import { randomUUID } from "node:crypto"; import { prisma } from "../src/server/db"; import { closeJobQueues } from "../src/server/jobs/queues"; import { applyOperations, reapplyOperation } from "../src/server/services/sync/apply"; import { getFieldBundle } from "../src/server/services/field/queries"; import { createDailyReport } from "../src/server/services/reports/create"; import { applySyncConflict } from "../src/server/services/work-orders/conflicts"; import { initialSession } from "../src/lib/offline/bundle-core"; import { ROLE_DEFS } from "../src/server/rbac"; import type { SyncOperationInput, SyncOpType } from "../src/lib/sync/envelope"; import type { ServiceCtx } from "../src/server/services/context"; import { createFixture, ctxFor, expectCode, failures, ok } from "./lib/einsatz-fixture"; function op(opType: SyncOpType, payload: Record, extra: Partial = {}): SyncOperationInput { return { clientOpId: randomUUID(), opType, payload, clientCreatedAt: new Date().toISOString(), ...extra }; } async function one(ctx: ServiceCtx, operation: SyncOperationInput) { const res = await applyOperations(ctx, { deviceId: "l10b-device", operations: [operation] }); return res.results[0]; } const version = async (id: string) => (await prisma.workOrder.findUniqueOrThrow({ where: { id } })).version; async function main() { const f = await createFixture("l10bsync"); const wo = f.orderA.id; const cleanupReports = async () => { await prisma.report.deleteMany({ where: { tenantId: { in: [f.tenantA.id, f.tenantB.id] } } }); }; try { // backoffice user in tenant A (resolves conflicts) const officeIdentity = await prisma.identity.upsert({ where: { email: "office@zz-l10bsync.test" }, update: {}, create: { email: "office@zz-l10bsync.test", passwordHash: "x" } }); const office = await prisma.user.create({ data: { tenantId: f.tenantA.id, identityId: officeIdentity.id, email: "office@zz-l10bsync.test", name: "Office A" } }); const ctxOffice = ctxFor(f.tenantA.id, office.id, "backoffice"); const ctxOfficeB = ctxFor(f.tenantB.id, f.techB.id, "backoffice"); // „Übernehmen" loads the device user's permissions from the DB → give the technician a real role const techPerms = await prisma.permission.findMany({ where: { key: { in: [...ROLE_DEFS.technician.permissions] } }, select: { id: true } }); const techRole = await prisma.role.create({ data: { tenantId: f.tenantA.id, key: "technician", name: ROLE_DEFS.technician.name, rolePermissions: { create: techPerms.map((p) => ({ permissionId: p.id })) } }, }); await prisma.userRole.create({ data: { userId: f.tech.id, roleId: techRole.id } }); console.log("\n— c) Bundle: eigene laufende Session —"); const acc = await one(f.ctxTech, op("work_order.transition", { workOrderId: wo, to: "accepted" }, { baseVersion: await version(wo) })); ok(acc.status === "applied", "Auftrag angenommen"); const sessionClientId = randomUUID(); const start = await one(f.ctxTech, op("session.start", { workOrderId: wo, mode: "work", clientId: sessionClientId })); ok(start.status === "applied", "Session gestartet (Sync)"); const techBundle = (await getFieldBundle(f.ctxTech)).orders.find((o) => o.id === wo); ok(techBundle?.mySession?.status === "running" && techBundle.mySession.id === start.idMap?.[sessionClientId], "Bundle des Monteurs: mySession = laufende eigene Session"); const leadBundle = (await getFieldBundle(f.ctxLead)).orders.find((o) => o.id === wo); ok(!!leadBundle && leadBundle.mySession === null, "Teamleiter sieht den Auftrag, aber keine eigene Session (nicht aus dem Status abgeleitet)"); ok(initialSession({ status: "in_progress", mySession: null }) === null, "Offline-Ansicht: in Arbeit ohne eigene Session → keine Zeitaktion Pause/Ende"); ok(initialSession({ status: "in_progress", mySession: { id: "s", status: "paused", startedAt: "" } }) === "paused", "Offline-Ansicht: eigene Session pausiert"); ok(initialSession({ status: "en_route" }) === "en_route", "Offline-Ansicht: altes Bundle ohne mySession → Näherung über Status"); await one(f.ctxTech, op("session.pause", { workOrderId: wo })); const paused = (await getFieldBundle(f.ctxTech)).orders.find((o) => o.id === wo); ok(paused?.mySession?.status === "paused", "nach Pause: mySession paused"); await one(f.ctxTech, op("session.resume", { workOrderId: wo })); ok(!(await getFieldBundle(f.ctxB)).orders.some((o) => o.id === wo), "Mandant B: Auftrag von A nicht im Bundle"); ok(!(await getFieldBundle(f.ctxOutsider)).orders.some((o) => o.id === wo), "Monteur ohne Zuweisung: Auftrag nicht im Bundle"); console.log("\n— f) clientId je Mandant —"); const sameSession = await one(f.ctxB, op("session.start", { workOrderId: f.orderB.id, mode: "work", clientId: sessionClientId })); ok(sameSession.status === "applied" && !!sameSession.idMap?.[sessionClientId] && sameSession.idMap[sessionClientId] !== start.idMap?.[sessionClientId], "gleiche Session-clientId in Mandant B → eigene Session (kein interner Fehler)"); const noteClientId = randomUUID(); const nA = await one(f.ctxTech, op("note.create", { workOrderId: wo, clientId: noteClientId, kind: "general", text: "A" })); const nB = await one(f.ctxB, op("note.create", { workOrderId: f.orderB.id, clientId: noteClientId, kind: "general", text: "B" })); ok(nA.status === "applied" && nB.status === "applied" && nA.idMap?.[noteClientId] !== nB.idMap?.[noteClientId], "gleiche Notiz-clientId in A und B → zwei Notizen"); const replayA = await one(f.ctxTech, op("note.create", { workOrderId: wo, clientId: noteClientId, kind: "general", text: "A nochmal" })); ok(replayA.status === "applied" && replayA.idMap?.[noteClientId] === nA.idMap?.[noteClientId], "Wiederholung in A (neue clientOpId) → dieselbe Notiz (Idempotenz je Mandant)"); ok((await prisma.activityNote.count({ where: { clientId: noteClientId } })) === 2, "genau eine Notiz je Mandant"); let dupRejected = false; try { await prisma.workSession.create({ data: { tenantId: f.tenantA.id, workOrderId: wo, userId: f.tech.id, status: "ended", startedAt: new Date(), endedAt: new Date(), clientId: sessionClientId } }); } catch (err) { dupRejected = (err as { code?: string }).code === "P2002"; } ok(dupRejected, "DB: doppelte clientId im selben Mandanten → Unique-Verletzung"); console.log("\n— j) report.save_draft / report.submit —"); const { report } = await createDailyReport(f.ctxTech, { workOrderId: wo }); await prisma.report.update({ where: { id: report.id }, data: { aiDrafted: true } }); // Lotse-Entwurf simulieren const saved = await one(f.ctxTech, op("report.save_draft", { workOrderId: wo, reportId: report.id, texts: { workPerformed: "Heizkörper montiert und entlüftet" } })); const afterSave = await prisma.report.findUniqueOrThrow({ where: { id: report.id } }); ok(saved.status === "applied" && (afterSave.content as { texts: { workPerformed: string } }).texts.workPerformed === "Heizkörper montiert und entlüftet", "report.save_draft → Texte gespeichert"); const badPayload = await one(f.ctxTech, op("report.submit", { workOrderId: wo }, { baseVersion: await version(wo) })); ok(badPayload.status === "rejected" && badPayload.errorCode === "invalid", "report.submit ohne reportId → rejected invalid"); const noReview = await one(f.ctxTech, op("report.submit", { workOrderId: wo, reportId: report.id }, { baseVersion: await version(wo) })); ok(noReview.status === "rejected" && noReview.errorCode === "invalid" && /reviewed/.test(noReview.message ?? ""), "Lotse-Entwurf offline ohne aiReviewed → rejected invalid"); ok((await prisma.report.findUniqueOrThrow({ where: { id: report.id } })).status === "draft", "Bericht bleibt Entwurf"); const foreignB = await one(f.ctxB, op("report.submit", { workOrderId: wo, reportId: report.id, aiReviewed: true }, { baseVersion: await version(wo) })); ok(foreignB.status === "rejected" && foreignB.errorCode === "not_found", "Mandant B: report.submit auf A → not_found"); const outsider = await one(f.ctxOutsider, op("report.submit", { workOrderId: wo, reportId: report.id, aiReviewed: true }, { baseVersion: await version(wo) })); ok(outsider.status === "rejected" && outsider.errorCode === "not_found", "Monteur ohne Zuweisung: report.submit → not_found"); const mismatch = await one(f.ctxB, op("report.save_draft", { workOrderId: f.orderB.id, reportId: report.id, texts: { hints: "x" } })); ok(mismatch.status === "rejected" && mismatch.errorCode === "not_found", "Mandant B: Bericht von A über eigenen Auftrag → not_found"); const current = await version(wo); const stale = await one(f.ctxTech, op("report.submit", { workOrderId: wo, reportId: report.id, aiReviewed: true }, { baseVersion: current - 1 })); ok(stale.status === "conflict" && stale.entityVersion === current, "veraltete baseVersion → conflict"); ok((await prisma.report.findUniqueOrThrow({ where: { id: report.id } })).status === "draft", "bei Konflikt nichts abgesendet"); const conflictOp = await prisma.syncOperation.findFirstOrThrow({ where: { tenantId: f.tenantA.id, clientOpId: stale.clientOpId } }); ok(conflictOp.status === "conflict" && conflictOp.opType === "report.submit", "Konflikt für die Backoffice-Liste gespeichert"); console.log("\n— b) Konflikt übernehmen (report.submit) —"); await expectCode(() => applySyncConflict(ctxOfficeB, conflictOp.id), "not_found", "Mandant B kann den Konflikt von A nicht übernehmen"); await expectCode(() => applySyncConflict(f.ctxTech, conflictOp.id), "forbidden", "Monteur (ohne work_order:write) kann Konflikte nicht übernehmen"); await expectCode(() => reapplyOperation(f.ctxTech, { opType: "note.create", entityId: wo, payload: { workOrderId: wo, kind: "general", text: "x" } }), "invalid", "Übernehmen nur für konfliktbehaftete Ops"); const taken = await applySyncConflict(ctxOffice, conflictOp.id); const submitted = await prisma.report.findUniqueOrThrow({ where: { id: report.id } }); ok(submitted.status === "submitted" && typeof taken.entityVersion === "number", "Übernehmen → Bericht abgesendet (als Gerätenutzer, aiReviewed aus der Op)"); const resolved = await prisma.syncOperation.findUniqueOrThrow({ where: { id: conflictOp.id } }); ok(resolved.status === "applied" && resolved.resolvedById === office.id, "Konflikt als übernommen markiert (resolvedBy Backoffice)"); await expectCode(() => applySyncConflict(ctxOffice, conflictOp.id), "not_found", "zweites Übernehmen → not_found"); // transition conflicts keep working through the dispatcher const staleTransition = await one(f.ctxTech, op("work_order.transition", { workOrderId: wo, to: "paused" }, { baseVersion: 1 })); ok(staleTransition.status === "conflict", "Statuswechsel mit veralteter Version → conflict"); const tOp = await prisma.syncOperation.findFirstOrThrow({ where: { tenantId: f.tenantA.id, clientOpId: staleTransition.clientOpId } }); const statusBefore = (await prisma.workOrder.findUniqueOrThrow({ where: { id: wo } })).status; const refused = await applySyncConflict(ctxOffice, tOp.id).then( () => null, (err: { code?: string }) => err.code ?? "error", ); ok(refused === "invalid" || refused === "forbidden", `Übernehmen gegen aktuellen Stand: unzulässiger Übergang wird abgelehnt (${refused})`); ok((await prisma.workOrder.findUniqueOrThrow({ where: { id: wo } })).status === statusBefore, "… Auftragsstatus unverändert, Konflikt bleibt offen"); ok((await prisma.syncOperation.findUniqueOrThrow({ where: { id: tOp.id } })).status === "conflict", "… SyncOperation weiterhin conflict"); } finally { await cleanupReports().catch((e) => console.error("report cleanup failed", e)); await f.cleanup().catch((e) => console.error("cleanup failed", e)); await closeJobQueues(); await prisma.$disconnect(); } } main() .catch((err) => { console.error(err); ok(false, `unerwarteter Fehler: ${(err as Error).message}`); }) .finally(() => { console.log(failures ? `\n✗ ${failures} Prüfung(en) fehlgeschlagen` : "\n✓ Alle Sync-Aufräumprüfungen grün"); process.exit(failures ? 1 : 0); });