Files
craftvia/scripts/test-benachrichtigungen-events.ts
msolarczekandClaude Opus 5 7b37c41a83 L6 Benachrichtigungen & Audit: Empfängerregeln, In-App-Benachrichtigungen und Craftvia-Mails
- handleEvent (Signatur unverändert) löst alle 17 Domain-Events auf: Team/Assignees,
  Backoffice (read_all + report:approve), Teamleiter-Freigaben, Ersteller, Abrechnung,
  Notdienst, Import, Sync; Akteur ausgenommen, alle IDs über ctx.db neu aufgelöst.
- In-App-Notification über ctx.db (ungelesene gleiche Meldung wird aufgefrischt),
  E-Mail über enqueueMail mit dedupeKey event:entity:user (+ optional occurrenceId),
  Opt-out je Typ, Notdienst als Pflichtmail, feste Empfänger ohne Doppelmail.
- Mail-Templates craftvia_* (de/en) inkl. Notdienst-Format Spec §19.4 und Craftvia-Fußzeile.
- Migration tenant_mail_settings: mailFromName, mailReplyTo, emergencyRecipients,
  billingRecipients an tenant_settings (keine neue Tabelle).
- Texte aus messages/{de,en}/notifications.json.
- Test scripts/test-benachrichtigungen-events.ts (63 Prüfungen).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 12:11:53 +02:00

291 lines
20 KiB
TypeScript

// 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("<!doctype html>") && 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);
});