// L6 Benachrichtigungen — Empfängerauflösung je Event, Akteur ausgenommen, Mandantentrennung, // Dedupe, Opt-out, Pflichtmail Notdienst, feste Empfänger, handleEvent wirft nie, Templates. // // Lauf: npx tsx scripts/test-benachrichtigungen-events.ts (lokale DB aus .env, SMTP optional) import "dotenv/config"; import { prisma, dbForTenant } from "../src/server/db"; import { provisionTenant } from "../src/server/provision"; import { ROLE_DEFS, type RoleKey } from "../src/server/rbac"; import type { ServiceCtx } from "../src/server/services/context"; import { EVENT_TYPES, type DomainEvent } from "../src/lib/events"; import { handleEvent } from "../src/server/services/notifications/handle-event"; import { emitEvent } from "../src/server/events"; import { setPreferences } from "../src/server/services/notifications/preferences"; import { CRAFTVIA_TEMPLATE_KEYS, renderTemplate } from "../src/server/mail/templates"; import { closeQueues } from "../src/server/mail/queue"; import { closeMailProvider } from "../src/server/mail/provider-smtp"; let failures = 0; const ok = (cond: boolean, msg: string) => { console.log(`${cond ? "✓" : "✗ FEHLER"} ${msg}`); if (!cond) failures++; }; const SLUG_A = "zz-l6-events-a"; const SLUG_B = "zz-l6-events-b"; const MAIL_DOMAIN = "zz-l6-events.test"; async function cleanup() { const tenants = await prisma.tenant.findMany({ where: { slug: { in: [SLUG_A, SLUG_B] } }, select: { id: true } }); const ids = tenants.map((t) => t.id); if (ids.length) { const where = { tenantId: { in: ids } }; await prisma.notification.deleteMany({ where }); await prisma.notificationPreference.deleteMany({ where }); await prisma.mailLog.deleteMany({ where }); await prisma.syncOperation.deleteMany({ where }); await prisma.importJob.deleteMany({ where }); await prisma.document.deleteMany({ where }); await prisma.report.deleteMany({ where }); await prisma.workOrderAssignee.deleteMany({ where }); await prisma.workOrder.deleteMany({ where }); await prisma.teamMember.deleteMany({ where }); await prisma.team.deleteMany({ where }); await prisma.customer.deleteMany({ where }); await prisma.auditLog.deleteMany({ where }); await prisma.tenantModule.deleteMany({ where }); await prisma.tenantSettings.deleteMany({ where }); await prisma.user.deleteMany({ where }); await prisma.role.deleteMany({ where }); await prisma.numberSequence.deleteMany({ where }); await prisma.tenant.deleteMany({ where: { id: { in: ids } } }); } await prisma.mailLog.deleteMany({ where: { to: { endsWith: `@${MAIL_DOMAIN}` } } }); await prisma.identity.deleteMany({ where: { email: { endsWith: `@${MAIL_DOMAIN}` }, memberships: { none: {} } } }); } async function createUser(tenantId: string, local: string, name: string, role: RoleKey) { const email = `${local}@${MAIL_DOMAIN}`; const identity = await prisma.identity.upsert({ where: { email }, update: {}, create: { email, passwordHash: "x" } }); const roleRow = await prisma.role.findUniqueOrThrow({ where: { tenantId_key: { tenantId, key: role } } }); return prisma.user.create({ data: { tenantId, identityId: identity.id, email, name, status: "ACTIVE", userRoles: { create: [{ roleId: roleRow.id }] } }, }); } const ctxFor = (tenantId: string, userId: string, role: RoleKey): ServiceCtx => ({ db: dbForTenant(tenantId), tenantId, userId, permissions: new Set(ROLE_DEFS[role].permissions), }); const notifs = (userId: string, type: string, entityId: string) => prisma.notification.count({ where: { userId, type, entityId } }); const mails = (dedupeKey: string) => prisma.mailLog.count({ where: { dedupeKey } }); async function main() { await cleanup(); // ---------- Fixtures ---------- const tA = await provisionTenant(prisma, { name: "L6 Events A", slug: SLUG_A, admin: { email: `admin-a@${MAIL_DOMAIN}`, name: "Admin A", password: "Zz-Test-1234!" } }); const tB = await provisionTenant(prisma, { name: "L6 Events B", slug: SLUG_B, admin: { email: `admin-b@${MAIL_DOMAIN}`, name: "Admin B", password: "Zz-Test-1234!" } }); const adminA = await prisma.user.findUniqueOrThrow({ where: { tenantId_email: { tenantId: tA.id, email: `admin-a@${MAIL_DOMAIN}` } } }); const backoffice = await createUser(tA.id, "backoffice-a", "Bea Backoffice", "backoffice"); const lead = await createUser(tA.id, "lead-a", "Tim Teamleiter", "team-lead"); const tech1 = await createUser(tA.id, "tech1-a", "Max Monteur", "technician"); const tech2 = await createUser(tA.id, "tech2-a", "Ole Ohneteam", "technician"); const backofficeB = await createUser(tB.id, "backoffice-b", "Bernd Fremd", "backoffice"); const customerA = await prisma.customer.create({ data: { tenantId: tA.id, companyName: "Lutz Meier GmbH" } }); const teamA = await prisma.team.create({ data: { tenantId: tA.id, name: "Team Nord", leaderUserId: lead.id } }); await prisma.teamMember.create({ data: { tenantId: tA.id, teamId: teamA.id, userId: tech1.id, validFrom: new Date(Date.now() - 86_400_000) } }); const wo = await prisma.workOrder.create({ data: { tenantId: tA.id, number: "A-ZZ-1", title: "Heizung warten", customerId: customerA.id, assignedTeamId: teamA.id, status: "assigned" }, }); const customerB = await prisma.customer.create({ data: { tenantId: tB.id, companyName: "Fremdkunde" } }); const woB = await prisma.workOrder.create({ data: { tenantId: tB.id, number: "B-ZZ-1", title: "Fremdauftrag", customerId: customerB.id } }); const boCtx = ctxFor(tA.id, backoffice.id, "backoffice"); const leadCtx = ctxFor(tA.id, lead.id, "team-lead"); const tech1Ctx = ctxFor(tA.id, tech1.id, "technician"); const ev = (type: DomainEvent["type"], entityId = wo.id, data?: DomainEvent["data"]): DomainEvent => ({ type, entityType: type.startsWith("report.") ? "report" : "work_order", entityId, data, }); // ---------- 1) work_order.assigned → Team (Mitglieder + Leiter), Akteur ausgenommen ---------- await handleEvent(boCtx, ev("work_order.assigned")); ok((await notifs(tech1.id, "work_order.assigned", wo.id)) === 1, "(1) assigned: Teammitglied erhält In-App-Benachrichtigung"); ok((await notifs(lead.id, "work_order.assigned", wo.id)) === 1, "(1) assigned: Teamleiter erhält In-App-Benachrichtigung"); ok((await notifs(tech2.id, "work_order.assigned", wo.id)) === 0, "(1) assigned: Monteur ohne Zuweisung erhält nichts"); ok((await notifs(backoffice.id, "work_order.assigned", wo.id)) === 0, "(1) assigned: Akteur (Backoffice) ausgenommen"); ok((await mails(`work_order.assigned:${wo.id}:${tech1.id}`)) === 1, "(1) assigned: E-Mail an Teammitglied eingestellt"); const assignedMail = await prisma.mailLog.findFirst({ where: { dedupeKey: `work_order.assigned:${wo.id}:${lead.id}` } }); ok(assignedMail?.template === "craftvia_team_assigned" && assignedMail.tenantId === tA.id, "(1) assigned: Template craftvia_team_assigned, Mandant A"); const n1 = await prisma.notification.findFirst({ where: { userId: tech1.id, type: "work_order.assigned" } }); ok(n1?.link === `/m/orders/${wo.id}` && !!n1?.title.includes("A-ZZ-1"), "(1) assigned: Titel mit Auftragsnummer, Link in die mobile Ansicht"); // Einzel-Assignee außerhalb des Teams await prisma.workOrderAssignee.create({ data: { tenantId: tA.id, workOrderId: wo.id, userId: tech2.id } }); await handleEvent(boCtx, ev("work_order.assigned")); ok((await notifs(tech2.id, "work_order.assigned", wo.id)) === 1, "(1b) assigned: Einzel-Assignee erhält Benachrichtigung"); await prisma.workOrderAssignee.deleteMany({ where: { workOrderId: wo.id } }); // ---------- 2) Dedupe: gleiches Event zweimal → eine Mail, keine doppelte In-App-Zeile ---------- ok((await mails(`work_order.assigned:${wo.id}:${tech1.id}`)) === 1, "(2) Dedupe: zweites assigned-Event → weiterhin genau eine Mail"); ok((await notifs(tech1.id, "work_order.assigned", wo.id)) === 1, "(2) Dedupe: ungelesene Benachrichtigung aufgefrischt statt verdoppelt"); // ---------- 3) Akteur ausgenommen (Teamleiter storniert) ---------- await handleEvent(leadCtx, ev("work_order.cancelled")); ok((await notifs(lead.id, "work_order.cancelled", wo.id)) === 0, "(3) cancelled: auslösender Teamleiter erhält nichts"); ok((await notifs(tech1.id, "work_order.cancelled", wo.id)) === 1, "(3) cancelled: Teammitglied erhält Benachrichtigung"); // ---------- 4) started → Backoffice (work_order:read_all + report:approve) ---------- await handleEvent(tech1Ctx, ev("work_order.started")); ok((await notifs(backoffice.id, "work_order.started", wo.id)) === 1, "(4) started: Backoffice erhält Benachrichtigung"); ok((await notifs(adminA.id, "work_order.started", wo.id)) === 1, "(4) started: Mandantenadmin (hat read_all + approve) erhält Benachrichtigung"); ok((await notifs(tech1.id, "work_order.started", wo.id)) === 0, "(4) started: Akteur (Monteur) ausgenommen"); ok((await notifs(lead.id, "work_order.started", wo.id)) === 0, "(4) started: Teamleiter ist kein Backoffice"); ok((await notifs(backofficeB.id, "work_order.started", wo.id)) === 0, "(4) started: Backoffice des Mandanten B erhält nichts"); const started = await prisma.notification.findFirst({ where: { userId: backoffice.id, type: "work_order.started" } }); ok(started?.link === `/work-orders/${wo.id}` && !!started?.message.includes("Max Monteur"), "(4) started: Backoffice-Link + Akteurname im Text"); // ---------- 5) Mandantentrennung ---------- const beforeCount = await prisma.notification.count({ where: { tenantId: { in: [tA.id, tB.id] } } }); await handleEvent(ctxFor(tB.id, backofficeB.id, "backoffice"), ev("work_order.assigned", wo.id)); ok((await prisma.notification.count({ where: { tenantId: { in: [tA.id, tB.id] } } })) === beforeCount, "(5) Kontext Mandant B + Auftrag von A → keinerlei Benachrichtigung"); await handleEvent(boCtx, ev("work_order.assigned", woB.id)); ok((await prisma.notification.count({ where: { tenantId: { in: [tA.id, tB.id] } } })) === beforeCount, "(5) Kontext Mandant A + Auftrag von B → keinerlei Benachrichtigung"); const report = await prisma.report.create({ data: { tenantId: tA.id, workOrderId: wo.id, type: "completion", reportDate: new Date(), lineageId: "zz-l6-lineage", content: {}, status: "approved", createdById: backofficeB.id }, }); await handleEvent(boCtx, ev("report.approved", report.id)); ok((await prisma.notification.count({ where: { userId: backofficeB.id } })) === 0, "(5) report.approved: fremde User-ID als Ersteller wird nie Empfänger"); ok((await notifs(tech1.id, "report.approved", report.id)) === 1, "(5) report.approved: Team des Auftrags erhält Benachrichtigung"); ok((await prisma.notification.count({ where: { tenantId: tB.id } })) === 0, "(5) Mandant B hat insgesamt keine Benachrichtigungen erhalten"); ok((await prisma.mailLog.count({ where: { tenantId: tA.id, to: backofficeB.email } })) === 0, "(5) keine Mail an Nutzer des Mandanten B"); // ---------- 6) report.submitted: Stufe team → nur Teamleiter; ohne Stufe → Backoffice + Teamleiter ---------- await prisma.report.update({ where: { id: report.id }, data: { status: "submitted" } }); await handleEvent(tech1Ctx, ev("report.submitted", report.id, { approvalStage: "team" })); ok((await notifs(lead.id, "report.submitted", report.id)) === 1, "(6) submitted/team: Teamleiter erhält Prüfauftrag"); ok((await notifs(backoffice.id, "report.submitted", report.id)) === 0, "(6) submitted/team: Backoffice nicht beteiligt"); await handleEvent(leadCtx, ev("report.submitted", report.id)); ok((await notifs(backoffice.id, "report.submitted", report.id)) === 1, "(6) submitted ohne Stufe: Backoffice erhält Prüfauftrag"); const reviewMail = await prisma.mailLog.findFirst({ where: { dedupeKey: `report.submitted:${report.id}:${backoffice.id}` } }); ok(reviewMail?.template === "craftvia_report_review", "(6) submitted: Template craftvia_report_review"); // ---------- 7) Opt-out je Typ ---------- const allButChanged = EVENT_TYPES.filter((t) => t !== "work_order.changed"); await setPreferences(tech1Ctx, { emailOn: allButChanged }); await handleEvent(boCtx, ev("work_order.changed")); ok((await notifs(tech1.id, "work_order.changed", wo.id)) === 1, "(7) Opt-out: In-App-Benachrichtigung bleibt"); ok((await mails(`work_order.changed:${wo.id}:${tech1.id}`)) === 0, "(7) Opt-out: keine E-Mail an abbestellenden Monteur"); ok((await mails(`work_order.changed:${wo.id}:${lead.id}`)) === 1, "(7) Opt-out: Teamleiter ohne Opt-out erhält E-Mail"); // occurrenceId erlaubt wiederholbare Events await handleEvent(tech1Ctx, ev("work_order.daily_report_created", wo.id, { occurrenceId: "2026-09-14" })); await handleEvent(tech1Ctx, ev("work_order.daily_report_created", wo.id, { occurrenceId: "2026-09-15" })); ok((await prisma.mailLog.count({ where: { dedupeKey: { startsWith: `work_order.daily_report_created:${wo.id}:${backoffice.id}` } } })) === 2, "(7b) occurrenceId: zwei Tagesberichte → zwei Mails"); // ---------- 8) Pflichtmail Notdienst trotz Opt-out + feste Notdienst-Empfänger ---------- await prisma.notificationPreference.upsert({ where: { userId_eventType: { userId: backoffice.id, eventType: "emergency.completed" } }, update: { email: false }, create: { tenantId: tA.id, userId: backoffice.id, eventType: "emergency.completed", email: false }, }); await prisma.tenantSettings.update({ where: { tenantId: tA.id }, data: { emergencyRecipients: [`notdienst@${MAIL_DOMAIN}`, backoffice.email], billingRecipients: [`abrechnung@${MAIL_DOMAIN}`] }, }); await prisma.workOrder.update({ where: { id: wo.id }, data: { isEmergency: true } }); await handleEvent(tech1Ctx, ev("emergency.completed", wo.id, { startedAt: "2026-07-28T21:42:00.000Z", endedAt: "2026-07-28T23:18:00.000Z" })); const emergencyMail = await prisma.mailLog.findFirst({ where: { dedupeKey: `emergency.completed:${wo.id}:${backoffice.id}` } }); ok(!!emergencyMail, "(8) Notdienst: Pflichtmail an Backoffice trotz gespeichertem Opt-out"); ok(emergencyMail?.template === "craftvia_emergency", "(8) Notdienst: Template craftvia_emergency"); ok((await mails(`emergency.completed:${wo.id}:ext:notdienst@${MAIL_DOMAIN}`)) === 1, "(8) Notdienst: fester Notdienst-Empfänger erhält Mail"); ok((await mails(`emergency.completed:${wo.id}:ext:${backoffice.email}`)) === 0, "(8) Notdienst: keine Doppelmail, wenn fester Empfänger zugleich Nutzer ist"); ok((await notifs(tech1.id, "emergency.completed", wo.id)) === 0, "(8) Notdienst: auslösender Monteur ausgenommen"); await setPreferences(ctxFor(tA.id, backoffice.id, "backoffice"), { emailOn: [] }); const emPref = await prisma.notificationPreference.findUnique({ where: { userId_eventType: { userId: backoffice.id, eventType: "emergency.created" } } }); ok(emPref?.email === true, "(8) Notdienst: Pflichttyp lässt sich über setPreferences nicht abbestellen"); // ---------- 9) Abrechnung → feste Abrechnungsempfänger (Mail), Backoffice In-App ---------- await handleEvent(leadCtx, ev("work_order.released_for_billing")); ok((await mails(`work_order.released_for_billing:${wo.id}:ext:abrechnung@${MAIL_DOMAIN}`)) === 1, "(9) Abrechnung: fester Abrechnungsempfänger erhält Mail"); ok((await notifs(backoffice.id, "work_order.released_for_billing", wo.id)) === 1, "(9) Abrechnung: Backoffice erhält In-App-Benachrichtigung"); ok((await mails(`work_order.released_for_billing:${wo.id}:${backoffice.id}`)) === 0, "(9) Abrechnung: bei festen Empfängern keine zusätzliche Nutzer-Mail"); // ---------- 10) import.* → importierender Nutzer (auch wenn er Akteur ist); sync.failed ---------- await setPreferences(boCtx, { emailOn: [...EVENT_TYPES] }); // undo the opt-out-all from step 8 const doc = await prisma.document.create({ data: { tenantId: tA.id, category: "order_confirmation", fileName: "auftrag.pdf", storageKey: `${tA.id}/zz-l6.pdf`, mimeType: "application/pdf", fileSize: 10, checksum: "zz", lineageId: "zz-l6-doc-lineage", }, }); { const job = await prisma.importJob.create({ data: { tenantId: tA.id, documentId: doc.id, status: "failed", errorMessage: "unlesbar", importedById: backoffice.id } }); await handleEvent(boCtx, { type: "import.failed", entityType: "import_job", entityId: job.id }); ok((await notifs(backoffice.id, "import.failed", job.id)) === 1, "(10) import.failed: importierender Nutzer informiert (System-Ergebnis)"); ok((await prisma.mailLog.findFirst({ where: { dedupeKey: `import.failed:${job.id}:${backoffice.id}` } }))?.template === "craftvia_document_failed", "(10) import.failed: Template craftvia_document_failed"); ok((await notifs(adminA.id, "import.failed", job.id)) === 0, "(10) import.failed: nur der importierende Nutzer"); } const op = await prisma.syncOperation.create({ data: { tenantId: tA.id, userId: tech1.id, clientOpId: "zz-l6-op", opType: "work_order.transition", entityType: "work_order", entityId: wo.id, payload: {}, status: "conflict", errorCode: "version_conflict", clientCreatedAt: new Date() }, }); await handleEvent(tech1Ctx, { type: "sync.failed", entityType: "sync_operation", entityId: op.id }); ok((await notifs(tech1.id, "sync.failed", op.id)) === 1, "(10) sync.failed: betroffener Nutzer informiert"); ok((await notifs(backoffice.id, "sync.failed", op.id)) === 1, "(10) sync.failed: Backoffice informiert"); ok((await prisma.notification.findFirst({ where: { userId: tech1.id, type: "sync.failed" } }))?.link === "/m/sync", "(10) sync.failed: Monteur-Link /m/sync"); // ---------- 11) handleEvent / emitEvent werfen nie ---------- const brokenDb = new Proxy({}, { get: () => { throw new Error("db down"); } }) as ServiceCtx["db"]; const origError = console.error; console.error = () => {}; let threw = false; try { await handleEvent({ ...boCtx, db: brokenDb }, ev("work_order.assigned")); await emitEvent({ ...boCtx, db: brokenDb }, ev("work_order.started")); await handleEvent(boCtx, ev("work_order.changed", "does-not-exist")); } catch { threw = true; } finally { console.error = origError; } ok(!threw, "(11) handleEvent/emitEvent werfen bei DB-Fehler oder unbekannter Entität nicht"); // ---------- 12) Craftvia-Templates de/en ---------- const sample = { craftvia_team_assigned: { name: "Max", number: "A-1", title: "Heizung", customer: "Meier", actionUrl: "https://x.test/a" }, craftvia_report_review: { name: "", number: "A-1", title: "Heizung", customer: "Meier", reportType: "completion", actionUrl: "https://x.test/r" }, craftvia_billing_release: { name: "", number: "A-1", title: "Heizung", customer: "Meier", actionUrl: "https://x.test/b", footer: "configured" }, craftvia_emergency: { name: "Bea", phase: "completed", number: "N-1", technician: "Martin Solarczek", customer: "Lutz Meier", start: "28.07.2026, 23:42", end: "29.07.2026, 01:18", actionUrl: "https://x.test/e" }, craftvia_document_failed: { name: "Bea", fileName: "a.pdf", error: "unlesbar", actionUrl: "https://x.test/i" }, craftvia_notification: { name: "Bea", subject: "Hinweis", body: "Text", actionUrl: "https://x.test/n" }, } as const; for (const key of CRAFTVIA_TEMPLATE_KEYS) { for (const locale of ["de", "en"] as const) { // eslint-disable-next-line @typescript-eslint/no-explicit-any const r = renderTemplate(key, locale, sample[key] as any); ok(r.subject.includes("Craftvia") && r.html.includes("") && r.text.length > 0 && !r.text.includes("<"), `(12) Template ${key}/${locale} rendert`); } } // eslint-disable-next-line @typescript-eslint/no-explicit-any const em = renderTemplate("craftvia_emergency", "de", sample.craftvia_emergency as any); ok(em.text.includes("Neuer Notdiensteinsatz abgeschlossen") && em.text.includes("Monteur: Martin Solarczek") && em.text.includes("Einsatzende: 29.07.2026, 01:18") && em.text.includes("Status: Zur Prüfung und Abrechnung"), "(12) Notdienst-Mail im Format Spec §19.4"); ok(em.text.includes("nicht abbestellen"), "(12) Notdienst-Mail trägt Pflicht-Hinweis"); // eslint-disable-next-line @typescript-eslint/no-explicit-any ok(renderTemplate("craftvia_report_review", "de", sample.craftvia_report_review as any).text.includes("Guten Tag,"), "(12) Anrede ohne Namen für feste Empfänger"); } main() .catch((err) => { console.error(err); failures++; }) .finally(async () => { await cleanup().catch((e) => console.error("cleanup:", e)); await closeQueues().catch(() => {}); await closeMailProvider().catch(() => {}); await prisma.$disconnect(); console.log(failures ? `\n${failures} Fehler.` : "\nAlle Prüfungen bestanden."); process.exit(failures ? 1 : 0); });