// Lane L8 „Notdienst" — Erfassung, Transaktion, Sichtbarkeit, Such-Service, Sync-Op, Abschluss-Event. // // Nachweise: vorläufiger Kunde/Objekt + Auftrag N-… in_progress + WorkSession in EINER Transaktion // (Fehler im Auftrag → kein verwaister Kunde), vorläufiger Kunde für Monteure nur über den eigenen // Notdienstauftrag sichtbar, Such-Service mit minimalem Feldumfang + Audit + Mandantentrennung, // Sync-Op emergency.create idempotent, Events emergency.created / emergency.completed. // // Lauf: npx tsx scripts/test-notdienst-flow.ts import "dotenv/config"; import { randomUUID } from "node:crypto"; import { prisma } from "../src/server/db"; import type { SyncOperationInput } from "../src/lib/sync/envelope"; import type { EmergencyCreateInput } from "../src/lib/emergency/schemas"; import { applyOperations } from "../src/server/services/sync/apply"; import { createEmergencyOrder } from "../src/server/services/emergency/create"; import { listSitesForEmergency, searchCustomersForEmergency, emergencyTeamOptions } from "../src/server/services/emergency/lookup"; import { buildEmergencyCompletedData } from "../src/server/services/emergency/completion"; import { createWorkOrder } from "../src/server/services/work-orders/create"; import { requireVisibleWorkOrder } from "../src/server/services/work-orders/visibility"; import { getCustomer } from "../src/server/services/customers/customers"; import { endSession } from "../src/server/services/field/sessions"; import { createCompletionReport } from "../src/server/services/reports/create"; import { updateReportTexts } from "../src/server/services/reports/edit"; import { captureSignature } from "../src/server/services/reports/signature"; import { submitReport } from "../src/server/services/reports/submit"; import { cleanupTenants, createFixture, expectCode, failures, ok, rawWorkOrder } from "./lib-auftraege-fixtures"; const SLUG_A = "zz-l8-flow-a"; const SLUG_B = "zz-l8-flow-b"; export function emergencyPayload(over: Partial = {}): EmergencyCreateInput { return { clientIds: { workOrder: randomUUID(), session: randomUUID(), customer: randomUUID(), site: randomUUID() }, customer: { mode: "new", firstName: "Lutz", lastName: "Meier", phone: "0171 1234567", street: "Elbchaussee", houseNumber: "12", postalCode: "22763", city: "Hamburg" }, site: { mode: "new", street: "Elbchaussee", houseNumber: "12", postalCode: "22763", city: "Hamburg" }, onSiteContact: { name: "Lutz Meier", phone: "0171 1234567" }, reason: "Wasserrohrbruch im Keller", ...over, }; } function syncOp(payload: unknown, clientOpId = randomUUID()): SyncOperationInput { return { clientOpId, opType: "emergency.create", payload: payload as Record, clientCreatedAt: new Date().toISOString() }; } async function main() { await cleanupTenants([SLUG_A, SLUG_B]); const A = await createFixture(SLUG_A); const B = await createFixture(SLUG_B); try { console.log("\n— Erfassung mit vorläufigem Kunden —"); const p1 = emergencyPayload(); const r1 = await createEmergencyOrder(A.ctx.tech, p1); const wo = await prisma.workOrder.findUniqueOrThrow({ where: { id: r1.workOrderId }, include: { customer: true, site: true, contact: true, orderType: true, assignees: true, workSessions: { include: { entries: true } }, statusHistory: true }, }); ok(wo.isEmergency && /^N-\d+$/.test(wo.number), `Auftrag ist Notdienst mit Nummer aus Sequenz emergency (${wo.number})`); ok(wo.status === "in_progress" && wo.statusHistory.some((h) => h.toStatus === "in_progress"), "Status in_progress inkl. Statushistorie"); ok(wo.orderType?.key === "notdienst", "Auftragsart notdienst"); ok(wo.emergencyReason === "Wasserrohrbruch im Keller" && wo.title === "Wasserrohrbruch im Keller", "Grund als emergencyReason + Titel"); ok(wo.createdById === A.users.tech.id, "createdById = Monteur"); ok(wo.customer.status === "provisional" && wo.customer.isProvisional && wo.customer.customerNumber === null, "Kunde vorläufig, ohne Kundennummer"); ok(wo.site?.status === "provisional" && wo.site.customerId === wo.customerId && wo.site.city === "Hamburg", "Einsatzadresse als vorläufiges Objekt"); ok(wo.contact?.name === "Lutz Meier" && wo.contact.phone === "0171 1234567" && wo.site?.contactId === wo.contactId, "Ansprechpartner vor Ort + Telefon"); ok(wo.assignedTeamId === A.teamId && wo.teamLeadUserId === A.users.lead.id, "Default: eigenes Team + Teamleiter"); ok(wo.assignees.length === 1 && wo.assignees[0].userId === A.users.tech.id, "Default: eigener User zugewiesen"); const session = wo.workSessions[0]; ok(wo.workSessions.length === 1 && session.status === "running" && session.clientId === p1.clientIds.session, "WorkSession gestartet (clientId)"); ok(session.entries.length === 1 && session.entries[0].type === "work" && !session.entries[0].endedAt, "Arbeitszeit-Segment offen"); ok(r1.version === wo.version && r1.sessionId === session.id && !r1.replayed, "Ergebnis mit Version und Session-ID"); const auditRows = await prisma.auditLog.findMany({ where: { tenantId: A.tenantId, entityId: { in: [r1.workOrderId, r1.customerId, r1.siteId] } }, select: { entity: true, action: true } }); ok(["emergency", "customer", "site", "work_order"].every((e) => auditRows.some((a) => a.entity === e && a.action === "create")), "Audit: emergency, customer, site, work_order angelegt"); const created = await prisma.notification.count({ where: { tenantId: A.tenantId, type: "emergency.created", userId: A.users.backoffice.id, entityId: r1.workOrderId } }); ok(created === 1, "Event emergency.created → Benachrichtigung ans Backoffice"); ok((await prisma.notification.count({ where: { tenantId: A.tenantId, type: "emergency.created", userId: A.users.tech.id } })) === 0, "Akteur selbst nicht benachrichtigt"); console.log("\n— Transaktion —"); const failing = emergencyPayload({ customer: { mode: "new", lastName: "Rollback", phone: "040 999" }, site: { mode: "new", street: "Rollbackweg", city: "Hamburg" }, onSiteContact: { name: "Rolf Rollback", phone: "040 999" }, reason: "Rollback-Test", }); await expectCode( () => createEmergencyOrder(A.ctx.tech, failing, { createWorkOrder: async (c, i) => { await createWorkOrder(c, i); throw Object.assign(new Error("simulated failure after work order insert"), { code: "invalid" }); }, }), "invalid", "Fehler beim Auftrag → Fehler an Aufrufer", ); ok((await prisma.customer.count({ where: { tenantId: A.tenantId, lastName: "Rollback" } })) === 0, "kein verwaister vorläufiger Kunde"); ok((await prisma.site.count({ where: { tenantId: A.tenantId, street: "Rollbackweg" } })) === 0, "kein verwaistes Objekt"); ok((await prisma.contact.count({ where: { tenantId: A.tenantId, name: "Rolf Rollback" } })) === 0, "kein verwaister Ansprechpartner"); ok((await prisma.workOrder.count({ where: { tenantId: A.tenantId, emergencyReason: "Rollback-Test" } })) === 0, "kein Auftrag"); ok((await prisma.workSession.count({ where: { clientId: failing.clientIds.session } })) === 0, "keine WorkSession"); const r2 = await createEmergencyOrder(A.ctx.tech, emergencyPayload({ customer: { mode: "new", companyName: "Bäckerei Sonne", phone: "040 1111" } })); ok(Number(r2.number.slice(2)) === Number(r1.number.slice(2)) + 1, `Nummernkreis ohne Lücke nach Rollback (${r1.number} → ${r2.number})`); console.log("\n— Sichtbarkeit vorläufiger Kunde —"); ok((await getCustomer(A.ctx.tech, r1.customerId)).id === r1.customerId, "Monteur sieht vorläufigen Kunden über eigenen Notdienstauftrag"); await expectCode(() => getCustomer(A.ctx.outsider, r1.customerId), "not_found", "anderer Monteur (fremdes Team) → not_found (Kunde)"); await expectCode(() => requireVisibleWorkOrder(A.ctx.outsider, r1.workOrderId), "not_found", "anderer Monteur → not_found (Auftrag)"); ok((await requireVisibleWorkOrder(A.ctx.lead, r1.workOrderId)).id === r1.workOrderId, "Teamleiter des gewählten Teams sieht den Auftrag"); await expectCode(() => getCustomer(B.ctx.admin, r1.customerId), "not_found", "Mandant B → not_found (Kunde)"); await expectCode(() => requireVisibleWorkOrder(B.ctx.admin, r1.workOrderId), "not_found", "Mandant B → not_found (Auftrag)"); console.log("\n— Such-Service —"); const hits = await searchCustomersForEmergency(A.ctx.outsider, SLUG_A); ok(hits.some((h) => h.id === A.customerId), "Monteur findet fremden Kunden für den Notdienst"); ok(hits.every((h) => JSON.stringify(Object.keys(h).sort()) === JSON.stringify(["city", "customerNumber", "id", "name", "provisional"])), "nur minimale Felder (id, Nummer, Name, Ort, vorläufig)"); ok((await searchCustomersForEmergency(A.ctx.outsider, "Meier")).some((h) => h.id === r1.customerId && h.provisional), "vorläufige Kunden sind auffindbar (Dublettenvermeidung)"); ok((await prisma.auditLog.count({ where: { tenantId: A.tenantId, entity: "emergency_customer_search", actorId: A.users.outsider.id } })) >= 2, "Suchzugriffe auditiert"); ok((await searchCustomersForEmergency(B.ctx.tech, SLUG_A)).length === 0, "Mandant B findet keine Kunden von A"); ok((await searchCustomersForEmergency(A.ctx.tech, "a")).length === 0, "Suchbegriff < 2 Zeichen → keine Treffer"); await expectCode(() => searchCustomersForEmergency(A.ctx.backoffice, "Kunde"), "forbidden", "ohne emergency:create → forbidden"); const sites = await listSitesForEmergency(A.ctx.outsider, A.customerId); ok(sites.some((s) => s.id === A.siteId) && sites.every((s) => JSON.stringify(Object.keys(s).sort()) === JSON.stringify(["address", "id", "name"])), "Objekte des Kunden mit minimalen Feldern"); ok((await prisma.auditLog.count({ where: { tenantId: A.tenantId, entity: "emergency_site_lookup", entityId: A.customerId } })) === 1, "Objektabfrage auditiert"); await expectCode(() => listSitesForEmergency(B.ctx.tech, A.customerId), "not_found", "Mandant B: Objekte von A → not_found"); const teamsOfTech = await emergencyTeamOptions(A.ctx.tech); ok(teamsOfTech.length === 1 && teamsOfTech[0].id === A.teamId && teamsOfTech[0].members.some((m) => m.id === A.users.lead.id), "Teamauswahl: nur eigene Teams inkl. Mitglieder"); console.log("\n— Bestehender Kunde + Objekt, Validierung, Rechte —"); const r3 = await createEmergencyOrder( A.ctx.outsider, emergencyPayload({ customer: { mode: "existing", customerId: A.customerId }, site: { mode: "existing", siteId: A.siteId }, onSiteContact: { name: "Frau Kontakt", phone: "040 5555" } }), ); const wo3 = await prisma.workOrder.findUniqueOrThrow({ where: { id: r3.workOrderId }, include: { customer: true, contact: true } }); ok(wo3.customerId === A.customerId && wo3.siteId === A.siteId && wo3.customer.status === "active", "bestehender Kunde/Objekt bleiben unverändert aktiv"); ok(wo3.contact?.customerId === A.customerId && wo3.assignedTeamId === A.otherTeamId, "Kontakt am bestehenden Kunden, eigenes Team des Monteurs"); ok((await getCustomer(A.ctx.outsider, A.customerId)).id === A.customerId, "Monteur sieht bestehenden Kunden danach über eigenen Notdienst"); const r3b = await createEmergencyOrder( A.ctx.outsider, emergencyPayload({ customer: { mode: "existing", customerId: A.customerId }, site: { mode: "existing", siteId: A.siteId }, onSiteContact: { name: "frau kontakt", phone: "040 5555" } }), ); ok(r3b.contactId === r3.contactId, "gleicher Ansprechpartner (Name + Telefon) wird wiederverwendet"); await expectCode( () => createEmergencyOrder(A.ctx.tech, emergencyPayload({ customer: { mode: "existing", customerId: A.customerId }, site: { mode: "existing", siteId: r1.siteId } })), "invalid", "Objekt eines anderen Kunden → invalid", ); await expectCode(() => createEmergencyOrder(A.ctx.outsider, emergencyPayload({ teamId: A.teamId })), "invalid", "fremdes Team → invalid"); await expectCode(() => createEmergencyOrder(A.ctx.outsider, emergencyPayload({ assigneeIds: [A.users.tech.id] })), "invalid", "Kollege aus fremdem Team → invalid"); const withColleague = await createEmergencyOrder(A.ctx.tech, emergencyPayload({ assigneeIds: [A.users.lead.id] })); ok((await prisma.workOrderAssignee.count({ where: { workOrderId: withColleague.workOrderId } })) === 2, "Kollege aus eigenem Team zugewiesen"); await expectCode(() => createEmergencyOrder(B.ctx.tech, emergencyPayload({ customer: { mode: "existing", customerId: A.customerId } })), "invalid", "Mandant B mit Kunden-ID von A → invalid (nicht gefunden)"); ok((await prisma.workOrder.count({ where: { tenantId: B.tenantId, isEmergency: true } })) === 0, "in Mandant B nichts angelegt"); await expectCode(() => createEmergencyOrder(A.ctx.backoffice, emergencyPayload()), "forbidden", "Backoffice ohne emergency:create → forbidden"); await expectCode(() => createEmergencyOrder(A.ctx.tech, emergencyPayload({ reason: " " })), "invalid", "Grund fehlt → invalid"); await expectCode( () => createEmergencyOrder(A.ctx.tech, emergencyPayload({ customer: { mode: "new", lastName: "Ohne Telefon", phone: "" } })), "invalid", "vorläufiger Kunde ohne Telefon → invalid", ); await expectCode(() => createEmergencyOrder(A.ctx.tech, emergencyPayload({ onSiteContact: { name: "X", phone: "" } })), "invalid", "Ansprechpartner ohne Telefon → invalid"); console.log("\n— Sync-Op emergency.create —"); const syncPayload = emergencyPayload({ customer: { mode: "new", lastName: "Offline", phone: "040 2222" }, offline: true }); const op = syncOp(syncPayload); const s1 = (await applyOperations(A.ctx.tech, { deviceId: "test", operations: [op] })).results[0]; const syncWoId = s1.idMap?.[syncPayload.clientIds.workOrder]; ok(s1.status === "applied" && !!syncWoId && !!s1.idMap?.[syncPayload.clientIds.session] && !!s1.idMap?.[syncPayload.clientIds.customer!], "applied mit idMap (Auftrag, Session, Kunde)"); const s2 = (await applyOperations(A.ctx.tech, { deviceId: "test", operations: [op] })).results[0]; const sorted = (m?: Record) => JSON.stringify(Object.entries(m ?? {}).sort()); ok(s2.status === "duplicate" && sorted(s2.idMap) === sorted(s1.idMap), "gleiche clientOpId → duplicate mit gespeicherter idMap"); const s3 = (await applyOperations(A.ctx.tech, { deviceId: "test", operations: [syncOp(syncPayload)] })).results[0]; ok(s3.status === "applied" && s3.idMap?.[syncPayload.clientIds.workOrder] === syncWoId, "neue clientOpId, gleiche Client-IDs → derselbe Auftrag (Replay)"); ok((await prisma.customer.count({ where: { tenantId: A.tenantId, lastName: "Offline" } })) === 1, "keine Doppelanlage des Kunden"); ok((await prisma.workSession.findFirst({ where: { clientId: syncPayload.clientIds.session } }))?.startedOffline === true, "Offline-Flag an der Session"); const s4 = (await applyOperations(A.ctx.tech, { deviceId: "test", operations: [syncOp({ ...syncPayload, reason: "" })] })).results[0]; ok(s4.status === "rejected" && s4.errorCode === "invalid", "ungültige Payload → rejected invalid"); const s5 = (await applyOperations(A.ctx.outsider, { deviceId: "test", operations: [syncOp(syncPayload)] })).results[0]; ok(s5.status === "rejected" && s5.errorCode === "invalid", "Replay durch anderen Nutzer → rejected (keine Übernahme)"); await prisma.tenantModule.create({ data: { tenantId: A.tenantId, moduleKey: "emergency", enabled: false } }); const s6 = (await applyOperations(A.ctx.tech, { deviceId: "test", operations: [syncOp(emergencyPayload())] })).results[0]; ok(s6.status === "rejected" && s6.errorCode === "forbidden", "Modul emergency deaktiviert → rejected forbidden"); await prisma.tenantModule.deleteMany({ where: { tenantId: A.tenantId, moduleKey: "emergency" } }); console.log("\n— Abschluss → emergency.completed —"); ok((await buildEmergencyCompletedData(A.ctx.tech, (await rawWorkOrder(A, "in_progress")).id)) === null, "normaler Auftrag → kein Notdienst-Event"); await endSession(A.ctx.tech, { workOrderId: r1.workOrderId }); const report = (await createCompletionReport(A.ctx.tech, { workOrderId: r1.workOrderId })).report; await updateReportTexts(A.ctx.tech, { reportId: report.id, texts: { workPerformed: "Leitung abgedichtet, Wasser abgepumpt." } }); await captureSignature(A.ctx.tech, { reportId: report.id, outcome: "customer_absent", reason: "Kunde nachts nicht erreichbar", confirmationText: "Bestätigt" }); await submitReport(A.ctx.tech, { reportId: report.id }); const afterSubmit = await prisma.workOrder.findUniqueOrThrow({ where: { id: r1.workOrderId } }); ok(afterSubmit.status === "in_review", "Auftragsstatus nach Abschlussbericht: in_review"); const completedNotes = await prisma.notification.findMany({ where: { tenantId: A.tenantId, type: "emergency.completed", entityId: r1.workOrderId } }); ok(completedNotes.some((n) => n.userId === A.users.backoffice.id), "Event emergency.completed → Backoffice benachrichtigt"); const data = await buildEmergencyCompletedData(A.ctx.backoffice, r1.workOrderId); ok(!!data && data.technician === `tech ${SLUG_A}` && data.customer === "Lutz Meier" && data.status === "in_review", "Daten: Monteur, Kunde, Status"); ok(!!data && new Date(data.startedAt) <= new Date(data.endedAt), "Daten: Einsatzbeginn ≤ Einsatzende"); const mails = await prisma.mailLog.findMany({ where: { tenantId: A.tenantId, template: "craftvia_emergency" } }).catch(() => null); if (mails) ok(mails.length >= 1, "Pflicht-E-Mail craftvia_emergency protokolliert"); console.log(`\n${failures === 0 ? "✓ alle Prüfungen bestanden" : `✗ ${failures} Prüfung(en) fehlgeschlagen`}`); } finally { await cleanupTenants([SLUG_A, SLUG_B]); } } main() .catch((err) => { console.error(err); process.exitCode = 1; }) .finally(async () => { await prisma.$disconnect(); process.exit(failures > 0 || process.exitCode ? 1 : 0); });