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>
This commit is contained in:
2026-09-14 12:11:53 +02:00
co-authored by Claude Opus 5
parent bf4456718e
commit 7b37c41a83
9 changed files with 1452 additions and 4 deletions
+183
View File
@@ -0,0 +1,183 @@
{
"bell": {
"label": "Benachrichtigungen",
"unreadCount": "{count, plural, =0 {Keine ungelesenen} one {# ungelesen} other {# ungelesen}}",
"empty": "Keine Benachrichtigungen.",
"markAllRead": "Alle gelesen",
"showAll": "Alle anzeigen"
},
"list": {
"crumb": "Übersicht",
"title": "Benachrichtigungen",
"sub": "Zuweisungen, Berichte, Notdienst und Fehler – neueste zuerst.",
"filterStatus": "Status",
"filterType": "Art",
"statusAll": "Alle",
"statusUnread": "Ungelesen",
"statusRead": "Gelesen",
"typeAll": "Alle Arten",
"apply": "Filtern",
"reset": "Zurücksetzen",
"empty": "Keine Benachrichtigungen für diese Auswahl.",
"unread": "Neu",
"read": "Gelesen",
"open": "Öffnen",
"markRead": "Als gelesen markieren",
"markAllRead": "Alle als gelesen markieren",
"previous": "Zurück",
"next": "Weiter",
"page": "Seite {page} von {pages}"
},
"types": {
"work_order_assigned": "Teamzuweisung",
"work_order_changed": "Auftrag geändert",
"work_order_cancelled": "Auftrag storniert",
"work_order_started": "Auftrag gestartet",
"work_order_daily_report_created": "Tagesbericht erstellt",
"work_order_technically_completed": "Technisch abgeschlossen",
"work_order_signature_missing": "Unterschrift fehlt",
"work_order_missing_required": "Pflichtangaben fehlen",
"work_order_released_for_billing": "Zur Abrechnung freigegeben",
"report_submitted": "Bericht zur Prüfung",
"report_approved": "Bericht freigegeben",
"report_rejected": "Bericht zurückgewiesen",
"emergency_created": "Notdiensteinsatz erstellt",
"emergency_completed": "Notdiensteinsatz abgeschlossen",
"import_ready_for_review": "Import zur Prüfung",
"import_failed": "Fehler bei Dokumentverarbeitung",
"sync_failed": "Synchronisationsfehler"
},
"events": {
"work_order_assigned": { "title": "Neuer Auftrag {number}", "message": "{title} – {customer} wurde Ihrem Team zugewiesen." },
"work_order_changed": { "title": "Auftrag {number} geändert", "message": "{title} – {customer}: Angaben wurden aktualisiert." },
"work_order_cancelled": { "title": "Auftrag {number} storniert", "message": "{title} – {customer} wurde storniert." },
"work_order_started": { "title": "Auftrag {number} gestartet", "message": "{actor} hat den Einsatz bei {customer} begonnen." },
"work_order_daily_report_created": { "title": "Tagesbericht zu {number}", "message": "{actor} hat einen Tagesbericht für {customer} erstellt." },
"work_order_technically_completed": { "title": "Auftrag {number} technisch abgeschlossen", "message": "{title} – {customer}: Einsatz vor Ort beendet." },
"work_order_signature_missing": { "title": "Unterschrift fehlt: {number}", "message": "{title} – {customer}: Kundenunterschrift liegt nicht vor." },
"work_order_missing_required": { "title": "Pflichtangaben fehlen: {number}", "message": "{title} – {customer}: Dokumentation unvollständig." },
"work_order_released_for_billing": { "title": "Auftrag {number} zur Abrechnung", "message": "{title} – {customer} ist bereit zur Abrechnung." },
"report_submitted": { "title": "Bericht zur Prüfung: {number}", "message": "{actor} hat einen Bericht zu {title} eingereicht." },
"report_approved": { "title": "Bericht freigegeben: {number}", "message": "Der Bericht zu {title} wurde freigegeben." },
"report_rejected": { "title": "Bericht zurückgewiesen: {number}", "message": "Der Bericht zu {title} braucht eine Korrektur." },
"emergency_created": { "title": "Notdiensteinsatz {number} erstellt", "message": "{actor} hat einen Notdiensteinsatz bei {customer} angelegt." },
"emergency_completed": { "title": "Notdiensteinsatz {number} abgeschlossen", "message": "{customer}: zur Prüfung und Abrechnung." },
"import_ready_for_review": { "title": "Import zur Prüfung bereit", "message": "Die Daten aus {fileName} liegen zur Prüfung vor." },
"import_failed": { "title": "Fehler bei Dokumentverarbeitung", "message": "{fileName} konnte nicht verarbeitet werden." },
"sync_failed": { "title": "Synchronisation fehlgeschlagen", "message": "Eine Änderung konnte nicht übernommen werden ({reason})." }
},
"fallback": {
"unknown": "unbekannt",
"system": "System",
"document": "Dokument"
},
"preferences": {
"title": "Benachrichtigungen",
"sub": "In der App erhalten Sie alle Benachrichtigungen. Hier legen Sie fest, welche zusätzlich per E-Mail kommen.",
"email": "E-Mail",
"mandatory": "Pflicht – nicht abbestellbar",
"save": "Speichern",
"saved": "Einstellungen gespeichert."
},
"mailSettings": {
"crumb": "Einstellungen",
"title": "E-Mail-Versand",
"sub": "Absender, Antwortadresse und feste Empfänger für Notdienst und Abrechnung.",
"back": "Zurück zu Einstellungen",
"sender": "Absender",
"fromName": "Absendername",
"fromNameHint": "Erscheint als Name im Posteingang. Leer = Firmenname.",
"fromAddress": "Absenderadresse",
"fromAddressHint": "Die Adresse bleibt {address}, damit Mails zugestellt werden (SPF/DKIM).",
"replyTo": "Antwortadresse",
"replyToHint": "Antworten auf Benachrichtigungen gehen an diese Adresse.",
"recipients": "Feste Empfänger",
"emergencyRecipients": "Empfänger Notdienst",
"emergencyHint": "Eine Adresse je Zeile. Erhalten jeden neuen und abgeschlossenen Notdiensteinsatz.",
"billingRecipients": "Empfänger Abrechnung",
"billingHint": "Eine Adresse je Zeile. Erhalten Aufträge, die zur Abrechnung freigegeben sind. Leer = Backoffice.",
"save": "Speichern",
"saved": "Einstellungen gespeichert.",
"invalid": "Bitte Eingaben prüfen: {detail}"
},
"audit": {
"crumb": "Einstellungen",
"title": "Audit-Protokoll",
"sub": "Wer hat wann was geändert – nur lesbar.",
"back": "Zurück zu Einstellungen",
"from": "Von",
"to": "Bis",
"actor": "Benutzer",
"actorAll": "Alle Benutzer",
"action": "Aktion",
"actionAll": "Alle Aktionen",
"entity": "Objektart",
"entityAll": "Alle Objektarten",
"entityId": "Objekt-ID",
"apply": "Filtern",
"reset": "Zurücksetzen",
"time": "Zeitpunkt",
"object": "Objekt",
"result": "Ergebnis",
"resultOk": "Erfolgreich",
"resultDenied": "Abgelehnt",
"details": "Details",
"empty": "Keine Einträge für diese Auswahl.",
"total": "{count, plural, one {# Eintrag} other {# Einträge}}",
"previous": "Zurück",
"next": "Weiter",
"page": "Seite {page} von {pages}",
"system": "System",
"platform": "Plattform",
"detailTitle": "Eintrag im Audit-Protokoll",
"close": "Schließen",
"field": "Feld",
"before": "Vorher",
"after": "Nachher",
"changed": "geändert",
"noValues": "Keine Werte protokolliert.",
"ip": "IP-Adresse",
"userAgent": "User-Agent",
"notCaptured": "nicht erfasst",
"actions": {
"create": "Erstellt",
"update": "Geändert",
"delete": "Gelöscht",
"login": "Anmeldung",
"logout": "Abmeldung",
"denied": "Abgelehnt",
"export": "Export",
"import": "Import",
"provision": "Eingerichtet",
"approve": "Freigegeben",
"reject": "Zurückgewiesen"
},
"entities": {
"user": "Benutzer",
"role": "Rolle",
"tenant": "Mandant",
"tenant_settings": "Einstellungen",
"tenant_mail_settings": "E-Mail-Versand",
"tenant_module": "Modul",
"module": "Modul",
"session": "Sitzung",
"account_inactive": "Inaktives Konto",
"work_order": "Auftrag",
"customer": "Kunde",
"site": "Objekt",
"team": "Team",
"report": "Bericht",
"signature": "Unterschrift",
"document": "Dokument",
"import_job": "Auftragsimport",
"material_usage": "Material",
"time_entry": "Arbeitszeit",
"work_session": "Einsatzzeit",
"photo": "Foto",
"voice_note": "Sprachnotiz",
"notification": "Benachrichtigung",
"notification_settings": "Benachrichtigungseinstellungen",
"sync_operation": "Synchronisation"
}
}
}
+183
View File
@@ -0,0 +1,183 @@
{
"bell": {
"label": "Notifications",
"unreadCount": "{count, plural, =0 {No unread} one {# unread} other {# unread}}",
"empty": "No notifications.",
"markAllRead": "Mark all read",
"showAll": "Show all"
},
"list": {
"crumb": "Overview",
"title": "Notifications",
"sub": "Assignments, reports, emergency call-outs and errors – newest first.",
"filterStatus": "Status",
"filterType": "Type",
"statusAll": "All",
"statusUnread": "Unread",
"statusRead": "Read",
"typeAll": "All types",
"apply": "Filter",
"reset": "Reset",
"empty": "No notifications for this selection.",
"unread": "New",
"read": "Read",
"open": "Open",
"markRead": "Mark as read",
"markAllRead": "Mark all as read",
"previous": "Previous",
"next": "Next",
"page": "Page {page} of {pages}"
},
"types": {
"work_order_assigned": "Team assignment",
"work_order_changed": "Work order changed",
"work_order_cancelled": "Work order cancelled",
"work_order_started": "Work order started",
"work_order_daily_report_created": "Daily report created",
"work_order_technically_completed": "Technically completed",
"work_order_signature_missing": "Signature missing",
"work_order_missing_required": "Required information missing",
"work_order_released_for_billing": "Released for billing",
"report_submitted": "Report for review",
"report_approved": "Report approved",
"report_rejected": "Report rejected",
"emergency_created": "Emergency call-out created",
"emergency_completed": "Emergency call-out completed",
"import_ready_for_review": "Import ready for review",
"import_failed": "Document processing failed",
"sync_failed": "Sync failed"
},
"events": {
"work_order_assigned": { "title": "New work order {number}", "message": "{title} – {customer} was assigned to your team." },
"work_order_changed": { "title": "Work order {number} changed", "message": "{title} – {customer}: details were updated." },
"work_order_cancelled": { "title": "Work order {number} cancelled", "message": "{title} – {customer} was cancelled." },
"work_order_started": { "title": "Work order {number} started", "message": "{actor} started work at {customer}." },
"work_order_daily_report_created": { "title": "Daily report for {number}", "message": "{actor} created a daily report for {customer}." },
"work_order_technically_completed": { "title": "Work order {number} technically completed", "message": "{title} – {customer}: on-site work finished." },
"work_order_signature_missing": { "title": "Signature missing: {number}", "message": "{title} – {customer}: no customer signature." },
"work_order_missing_required": { "title": "Required information missing: {number}", "message": "{title} – {customer}: documentation incomplete." },
"work_order_released_for_billing": { "title": "Work order {number} ready for billing", "message": "{title} – {customer} is ready for billing." },
"report_submitted": { "title": "Report for review: {number}", "message": "{actor} submitted a report for {title}." },
"report_approved": { "title": "Report approved: {number}", "message": "The report for {title} was approved." },
"report_rejected": { "title": "Report rejected: {number}", "message": "The report for {title} needs a correction." },
"emergency_created": { "title": "Emergency call-out {number} created", "message": "{actor} created an emergency call-out at {customer}." },
"emergency_completed": { "title": "Emergency call-out {number} completed", "message": "{customer}: ready for review and billing." },
"import_ready_for_review": { "title": "Import ready for review", "message": "The data from {fileName} is ready for review." },
"import_failed": { "title": "Document processing failed", "message": "{fileName} could not be processed." },
"sync_failed": { "title": "Sync failed", "message": "A change could not be applied ({reason})." }
},
"fallback": {
"unknown": "unknown",
"system": "System",
"document": "document"
},
"preferences": {
"title": "Notifications",
"sub": "You receive all notifications in the app. Choose which ones are also sent by e-mail.",
"email": "E-mail",
"mandatory": "Mandatory – cannot be turned off",
"save": "Save",
"saved": "Settings saved."
},
"mailSettings": {
"crumb": "Settings",
"title": "E-mail delivery",
"sub": "Sender, reply-to address and fixed recipients for emergency call-outs and billing.",
"back": "Back to settings",
"sender": "Sender",
"fromName": "Sender name",
"fromNameHint": "Shown as the name in the inbox. Empty = company name.",
"fromAddress": "Sender address",
"fromAddressHint": "The address stays {address} so that mail is delivered (SPF/DKIM).",
"replyTo": "Reply-to address",
"replyToHint": "Replies to notifications go to this address.",
"recipients": "Fixed recipients",
"emergencyRecipients": "Emergency recipients",
"emergencyHint": "One address per line. Receive every new and completed emergency call-out.",
"billingRecipients": "Billing recipients",
"billingHint": "One address per line. Receive work orders released for billing. Empty = back office.",
"save": "Save",
"saved": "Settings saved.",
"invalid": "Please check your input: {detail}"
},
"audit": {
"crumb": "Settings",
"title": "Audit log",
"sub": "Who changed what and when – read only.",
"back": "Back to settings",
"from": "From",
"to": "To",
"actor": "User",
"actorAll": "All users",
"action": "Action",
"actionAll": "All actions",
"entity": "Object type",
"entityAll": "All object types",
"entityId": "Object ID",
"apply": "Filter",
"reset": "Reset",
"time": "Time",
"object": "Object",
"result": "Result",
"resultOk": "Succeeded",
"resultDenied": "Denied",
"details": "Details",
"empty": "No entries for this selection.",
"total": "{count, plural, one {# entry} other {# entries}}",
"previous": "Previous",
"next": "Next",
"page": "Page {page} of {pages}",
"system": "System",
"platform": "Platform",
"detailTitle": "Audit log entry",
"close": "Close",
"field": "Field",
"before": "Before",
"after": "After",
"changed": "changed",
"noValues": "No values recorded.",
"ip": "IP address",
"userAgent": "User agent",
"notCaptured": "not recorded",
"actions": {
"create": "Created",
"update": "Updated",
"delete": "Deleted",
"login": "Sign-in",
"logout": "Sign-out",
"denied": "Denied",
"export": "Export",
"import": "Import",
"provision": "Provisioned",
"approve": "Approved",
"reject": "Rejected"
},
"entities": {
"user": "User",
"role": "Role",
"tenant": "Tenant",
"tenant_settings": "Settings",
"tenant_mail_settings": "E-mail delivery",
"tenant_module": "Module",
"module": "Module",
"session": "Session",
"account_inactive": "Inactive account",
"work_order": "Work order",
"customer": "Customer",
"site": "Site",
"team": "Team",
"report": "Report",
"signature": "Signature",
"document": "Document",
"import_job": "Work order import",
"material_usage": "Material",
"time_entry": "Working time",
"work_session": "Work session",
"photo": "Photo",
"voice_note": "Voice note",
"notification": "Notification",
"notification_settings": "Notification settings",
"sync_operation": "Sync"
}
}
}
@@ -0,0 +1,9 @@
-- L6 Benachrichtigungen: Mandanten-Mailkonfiguration (Spec §33.2).
-- Nur neue Spalten an tenant_settings (Tabelle ist bereits mandantengebunden/RLS), keine neue Tabelle.
-- Absenderadresse bleibt Plattform-Domain (SPF/DKIM); je Mandant nur Anzeigename und Reply-To.
-- AlterTable
ALTER TABLE "tenant_settings" ADD COLUMN "billing_recipients" TEXT[] DEFAULT ARRAY[]::TEXT[],
ADD COLUMN "emergency_recipients" TEXT[] DEFAULT ARRAY[]::TEXT[],
ADD COLUMN "mail_from_name" TEXT,
ADD COLUMN "mail_reply_to" TEXT;
+5
View File
@@ -70,6 +70,11 @@ model TenantSettings {
timezone String @default("Europe/Berlin")
securityPolicy Json @default("{}") @map("security_policy") // pw/mfa/session
smtp Json @default("{}")
// Craftvia §33.2 — tenant mail settings (sender address stays the platform domain for SPF/DKIM).
mailFromName String? @map("mail_from_name")
mailReplyTo String? @map("mail_reply_to")
emergencyRecipients String[] @default([]) @map("emergency_recipients")
billingRecipients String[] @default([]) @map("billing_recipients")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
+290
View File
@@ -0,0 +1,290 @@
// 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);
});
+200
View File
@@ -40,8 +40,33 @@ export type TemplateVars = {
eventType: string;
};
test: { name: string; when: string };
// ---- Craftvia domain notifications (lane L6). `name` may be "" for configured external recipients.
craftvia_team_assigned: {
name: string; number: string; title: string; customer: string;
site?: string; plannedStart?: string; team?: string; actionUrl: string; footer?: CraftviaFooter;
};
craftvia_report_review: {
name: string; number: string; title: string; customer: string;
submittedBy?: string; reportType?: "daily" | "completion"; actionUrl: string; footer?: CraftviaFooter;
};
craftvia_billing_release: {
name: string; number: string; title: string; customer: string; actionUrl: string; footer?: CraftviaFooter;
};
craftvia_emergency: {
name: string; phase: "created" | "completed"; number: string; technician: string; customer: string;
start: string; end?: string; actionUrl: string; footer?: CraftviaFooter;
};
craftvia_document_failed: {
name: string; fileName: string; error?: string; actionUrl: string; footer?: CraftviaFooter;
};
craftvia_notification: {
name: string; subject: string; body: string; actionUrl?: string; footer?: CraftviaFooter;
};
};
/** Why the recipient gets a Craftvia notification — controls the footer line. */
export type CraftviaFooter = "user" | "mandatory" | "configured";
export type TemplateKey = keyof TemplateVars;
export const TEMPLATE_KEYS = [
@@ -55,6 +80,16 @@ export const TEMPLATE_KEYS = [
"test",
] as const satisfies readonly TemplateKey[];
/** Craftvia domain templates (lane L6) — kept separate so SEC1 fixtures stay unchanged. */
export const CRAFTVIA_TEMPLATE_KEYS = [
"craftvia_team_assigned",
"craftvia_report_review",
"craftvia_billing_release",
"craftvia_emergency",
"craftvia_document_failed",
"craftvia_notification",
] as const satisfies readonly TemplateKey[];
/** Abmelde-/Präferenzhinweis — nur für Benachrichtigungen, nie für Transaktionsmails. */
const FOOTER_NOTE: Record<Locale, string> = {
de: "Sie erhalten diese Benachrichtigung aufgrund Ihrer Rolle in Ihrem Betrieb. Die Einstellungen dazu finden Sie in Ihrem Profil.",
@@ -63,6 +98,169 @@ const FOOTER_NOTE: Record<Locale, string> = {
type Builder<K extends TemplateKey> = (vars: TemplateVars[K]) => EmailContent;
// ---- Craftvia domain notifications (lane L6) ----
type CraftviaKey = (typeof CRAFTVIA_TEMPLATE_KEYS)[number];
const CRAFTVIA_FOOTER: Record<Locale, Record<CraftviaFooter, string>> = {
de: {
user: FOOTER_NOTE.de,
mandatory: "Pflichtbenachrichtigung zum Notdienst – sie lässt sich nicht abbestellen.",
configured: "Sie erhalten diese Nachricht, weil Ihre Adresse im Betrieb als fester Empfänger hinterlegt ist.",
},
en: {
user: FOOTER_NOTE.en,
mandatory: "Mandatory emergency call-out notification – it cannot be turned off.",
configured: "You are receiving this message because your address is set up as a fixed recipient in your company.",
},
};
const greetDe = (name: string) => (name ? `Hallo ${name},` : "Guten Tag,");
const greetEn = (name: string) => (name ? `Hello ${name},` : "Hello,");
const craftviaDe: { [K in CraftviaKey]: Builder<K> } = {
craftvia_team_assigned: (v) => ({
subject: `${BRAND.name}: Neuer Auftrag ${v.number}`,
heading: "Neuer Auftrag für Ihr Team",
paragraphs: [
greetDe(v.name),
`der Auftrag ${v.number} „${v.title}" wurde ${v.team ? `dem Team ${v.team}` : "Ihnen"} zugewiesen.`,
`Kunde: ${v.customer}`,
...(v.site ? [`Objekt: ${v.site}`] : []),
...(v.plannedStart ? [`Geplanter Beginn: ${v.plannedStart}`] : []),
],
action: { label: "Auftrag öffnen", url: v.actionUrl },
footerNote: CRAFTVIA_FOOTER.de[v.footer ?? "user"],
}),
craftvia_report_review: (v) => ({
subject: `${BRAND.name}: Bericht zur Prüfung – ${v.number}`,
heading: "Bericht zur Prüfung",
paragraphs: [
greetDe(v.name),
`${v.submittedBy ?? "Das Team"} hat ${v.reportType === "daily" ? "einen Tagesbericht" : v.reportType === "completion" ? "einen Abschlussbericht" : "einen Bericht"} zu ${v.number} „${v.title}" eingereicht.`,
`Kunde: ${v.customer}`,
"Bitte prüfen und freigeben oder eine Korrektur anfordern.",
],
action: { label: "Bericht prüfen", url: v.actionUrl },
footerNote: CRAFTVIA_FOOTER.de[v.footer ?? "user"],
}),
craftvia_billing_release: (v) => ({
subject: `${BRAND.name}: Auftrag ${v.number} zur Abrechnung`,
heading: "Auftrag bereit zur Abrechnung",
paragraphs: [
greetDe(v.name),
`der Auftrag ${v.number} „${v.title}" ist geprüft und zur Abrechnung freigegeben.`,
`Kunde: ${v.customer}`,
],
action: { label: "Auftrag öffnen", url: v.actionUrl },
footerNote: CRAFTVIA_FOOTER.de[v.footer ?? "user"],
}),
craftvia_emergency: (v) => ({
subject: `${BRAND.name}: Notdiensteinsatz ${v.number} ${v.phase === "completed" ? "abgeschlossen" : "erstellt"}`,
heading: v.phase === "completed" ? "Neuer Notdiensteinsatz abgeschlossen" : "Neuer Notdiensteinsatz erstellt",
paragraphs: [
greetDe(v.name),
`Monteur: ${v.technician}`,
`Kunde: ${v.customer}`,
`Einsatzbeginn: ${v.start}`,
...(v.end ? [`Einsatzende: ${v.end}`] : []),
`Status: ${v.phase === "completed" ? "Zur Prüfung und Abrechnung" : "In Arbeit"}`,
],
action: { label: "Einsatz öffnen", url: v.actionUrl },
footerNote: CRAFTVIA_FOOTER.de[v.footer ?? "mandatory"],
}),
craftvia_document_failed: (v) => ({
subject: `${BRAND.name}: Fehler bei der Dokumentverarbeitung`,
heading: "Dokument konnte nicht verarbeitet werden",
paragraphs: [
greetDe(v.name),
`die Datei „${v.fileName}" konnte nicht automatisch ausgelesen werden.`,
...(v.error ? [`Grund: ${v.error}`] : []),
"Die Datei bleibt gespeichert. Den Auftrag bitte manuell erfassen oder die Datei erneut hochladen.",
],
action: { label: "Import öffnen", url: v.actionUrl },
footerNote: CRAFTVIA_FOOTER.de[v.footer ?? "user"],
}),
craftvia_notification: (v) => ({
subject: `${BRAND.name}: ${v.subject}`,
heading: v.subject,
paragraphs: [greetDe(v.name), v.body],
action: v.actionUrl ? { label: `In ${BRAND.name} öffnen`, url: v.actionUrl } : undefined,
footerNote: CRAFTVIA_FOOTER.de[v.footer ?? "user"],
}),
};
const craftviaEn: { [K in CraftviaKey]: Builder<K> } = {
craftvia_team_assigned: (v) => ({
subject: `${BRAND.name}: New work order ${v.number}`,
heading: "New work order for your team",
paragraphs: [
greetEn(v.name),
`work order ${v.number} "${v.title}" was assigned to ${v.team ? `team ${v.team}` : "you"}.`,
`Customer: ${v.customer}`,
...(v.site ? [`Site: ${v.site}`] : []),
...(v.plannedStart ? [`Planned start: ${v.plannedStart}`] : []),
],
action: { label: "Open work order", url: v.actionUrl },
footerNote: CRAFTVIA_FOOTER.en[v.footer ?? "user"],
}),
craftvia_report_review: (v) => ({
subject: `${BRAND.name}: Report for review – ${v.number}`,
heading: "Report for review",
paragraphs: [
greetEn(v.name),
`${v.submittedBy ?? "The team"} submitted ${v.reportType === "daily" ? "a daily report" : v.reportType === "completion" ? "a completion report" : "a report"} for ${v.number} "${v.title}".`,
`Customer: ${v.customer}`,
"Please review and approve it or request a correction.",
],
action: { label: "Review report", url: v.actionUrl },
footerNote: CRAFTVIA_FOOTER.en[v.footer ?? "user"],
}),
craftvia_billing_release: (v) => ({
subject: `${BRAND.name}: Work order ${v.number} ready for billing`,
heading: "Work order ready for billing",
paragraphs: [
greetEn(v.name),
`work order ${v.number} "${v.title}" has been reviewed and released for billing.`,
`Customer: ${v.customer}`,
],
action: { label: "Open work order", url: v.actionUrl },
footerNote: CRAFTVIA_FOOTER.en[v.footer ?? "user"],
}),
craftvia_emergency: (v) => ({
subject: `${BRAND.name}: Emergency call-out ${v.number} ${v.phase === "completed" ? "completed" : "created"}`,
heading: v.phase === "completed" ? "New emergency call-out completed" : "New emergency call-out created",
paragraphs: [
greetEn(v.name),
`Technician: ${v.technician}`,
`Customer: ${v.customer}`,
`Start: ${v.start}`,
...(v.end ? [`End: ${v.end}`] : []),
`Status: ${v.phase === "completed" ? "Ready for review and billing" : "In progress"}`,
],
action: { label: "Open call-out", url: v.actionUrl },
footerNote: CRAFTVIA_FOOTER.en[v.footer ?? "mandatory"],
}),
craftvia_document_failed: (v) => ({
subject: `${BRAND.name}: Document processing failed`,
heading: "Document could not be processed",
paragraphs: [
greetEn(v.name),
`the file "${v.fileName}" could not be read automatically.`,
...(v.error ? [`Reason: ${v.error}`] : []),
"The file stays stored. Please enter the work order manually or upload the file again.",
],
action: { label: "Open import", url: v.actionUrl },
footerNote: CRAFTVIA_FOOTER.en[v.footer ?? "user"],
}),
craftvia_notification: (v) => ({
subject: `${BRAND.name}: ${v.subject}`,
heading: v.subject,
paragraphs: [greetEn(v.name), v.body],
action: v.actionUrl ? { label: `Open in ${BRAND.name}`, url: v.actionUrl } : undefined,
footerNote: CRAFTVIA_FOOTER.en[v.footer ?? "user"],
}),
};
const de: { [K in TemplateKey]: Builder<K> } = {
invitation: (v) => ({
subject: `Ihr Zugang zu ${BRAND.name}`,
@@ -139,6 +337,7 @@ const de: { [K in TemplateKey]: Builder<K> } = {
"Erreicht sie Sie, sind SMTP-Konfiguration und Versandweg in Ordnung.",
],
}),
...craftviaDe,
};
const en: { [K in TemplateKey]: Builder<K> } = {
@@ -217,6 +416,7 @@ const en: { [K in TemplateKey]: Builder<K> } = {
"If it reaches you, SMTP configuration and delivery path are working.",
],
}),
...craftviaEn,
};
const CATALOG: Record<Locale, { [K in TemplateKey]: Builder<K> }> = { de, en };
@@ -1,10 +1,224 @@
import type { DomainEvent } from "@/lib/events";
import { absoluteUrl } from "@/server/mail/config";
import { enqueueMail, type EnqueueInput } from "@/server/mail/service";
import { formatWhen, normalizeLocale, type CraftviaFooter, type Locale, type TemplateKey } from "@/server/mail/templates";
import type { ServiceCtx } from "@/server/services/context";
import { resolveRecipients, type EventFacts, type Target } from "./recipients";
import { eventText, fallbackText } from "./texts";
/**
* Placeholder — replaced by lane "notifications" (recipient rules, in-app Notification rows,
* e-mail via the mail queue). Keeps emitEvent() callable for all other lanes meanwhile.
* Domain event → in-app notifications + e-mails (ARCHITEKTUR §4.1). Called only via emitEvent().
*
* - Never throws: every failure is logged, business data is never rolled back.
* - In-app rows via ctx.db (tenant guard). An identical unread notification (same user, type,
* entity) is refreshed instead of duplicated.
* - E-mail via the mail queue with dedupeKey `event:entity:user` (+ optional `data.occurrenceId`
* for repeatable events such as daily reports), so the same event twice sends one mail.
* - NotificationPreference.email=false opts out per event type — except mandatory events
* (emergency call-outs).
*/
export async function handleEvent(_ctx: ServiceCtx, _event: DomainEvent): Promise<void> {
// intentionally empty
export async function handleEvent(ctx: ServiceCtx, event: DomainEvent): Promise<void> {
try {
await dispatch(ctx, event);
} catch (err) {
console.error(`[notifications] ${event.type} for ${event.entityType}:${event.entityId} failed:`, (err as Error)?.message ?? err);
}
}
async function dispatch(ctx: ServiceCtx, event: DomainEvent): Promise<void> {
const settings = await ctx.db.tenantSettings.findFirst({
select: { locale: true, emergencyRecipients: true, billingRecipients: true },
});
const plan = await resolveRecipients(ctx, event, settings);
if (!plan) return;
const occurrence = typeof event.data?.occurrenceId === "string" || typeof event.data?.occurrenceId === "number"
? `:${event.data.occurrenceId}`
: "";
const optedOut = await optedOutUserIds(ctx, event, plan.users);
for (const user of plan.users) {
try {
const link = linkFor(event, plan.facts, user.permissions);
const text = eventText(user.locale, event.type, textVars(user.locale, plan.facts));
const notificationId = await upsertInApp(ctx, event, user.userId, text, link);
const wantsMail = plan.mailUserIds.has(user.userId) && (plan.mandatory || !optedOut.has(user.userId));
if (!wantsMail) continue;
const result = await enqueueMail(
buildMail(ctx, event, plan.facts, {
to: user.email,
name: user.name,
locale: user.locale,
link,
footer: plan.mandatory ? "mandatory" : "user",
dedupeKey: `${event.type}:${event.entityId}:${user.userId}${occurrence}`,
text,
}),
);
if (result.status === "queued" || result.status === "sent") {
await ctx.db.notification.update({ where: { id: notificationId }, data: { emailedAt: new Date() } });
}
} catch (err) {
console.error(`[notifications] ${event.type} → user ${user.userId} failed:`, (err as Error)?.message ?? err);
}
}
const tenantLocale = normalizeLocale(settings?.locale);
for (const email of plan.externalEmails) {
try {
const link = linkFor(event, plan.facts, new Set(["work_order:read_all", "report:approve"]));
await enqueueMail(
buildMail(ctx, event, plan.facts, {
to: email,
name: "",
locale: tenantLocale,
link,
footer: plan.mandatory ? "mandatory" : "configured",
dedupeKey: `${event.type}:${event.entityId}:ext:${email}${occurrence}`,
text: eventText(tenantLocale, event.type, textVars(tenantLocale, plan.facts)),
}),
);
} catch (err) {
console.error(`[notifications] ${event.type} → configured recipient failed:`, (err as Error)?.message ?? err);
}
}
}
async function optedOutUserIds(ctx: ServiceCtx, event: DomainEvent, users: Target[]): Promise<Set<string>> {
if (!users.length) return new Set();
const prefs = await ctx.db.notificationPreference.findMany({
where: { eventType: event.type, email: false, userId: { in: users.map((u) => u.userId) } },
select: { userId: true },
});
return new Set(prefs.map((p) => p.userId));
}
async function upsertInApp(
ctx: ServiceCtx,
event: DomainEvent,
userId: string,
text: { title: string; message: string },
link: string | null,
): Promise<string> {
const existing = await ctx.db.notification.findFirst({
where: { userId, type: event.type, entityType: event.entityType, entityId: event.entityId, readAt: null },
select: { id: true },
});
if (existing) {
await ctx.db.notification.update({
where: { id: existing.id },
data: { title: text.title, message: text.message, link, createdAt: new Date() },
});
return existing.id;
}
const row = await ctx.db.notification.create({
data: {
tenantId: ctx.tenantId,
userId,
type: event.type,
title: text.title,
message: text.message,
entityType: event.entityType,
entityId: event.entityId,
link,
},
select: { id: true },
});
return row.id;
}
function textVars(locale: Locale, f: EventFacts): Record<string, string | undefined> {
return {
number: f.number,
title: f.title,
customer: f.customer,
actor: f.actorName ?? fallbackText(locale, "system"),
fileName: f.fileName ?? fallbackText(locale, "document"),
reason: f.syncErrorCode,
};
}
/** Relative in-app link, chosen by what the recipient can open (back office vs. mobile). */
export function linkFor(event: DomainEvent, f: EventFacts, permissions: ReadonlySet<string>): string | null {
const backoffice = permissions.has("work_order:read_all");
switch (event.entityType) {
case "work_order":
return backoffice ? `/work-orders/${event.entityId}` : `/m/orders/${event.entityId}`;
case "report":
if (backoffice) return `/reports/${event.entityId}`;
return f.workOrderId ? `/m/orders/${f.workOrderId}/report` : null;
case "import_job":
return `/imports/${event.entityId}`;
case "sync_operation":
return backoffice ? "/work-orders/conflicts" : "/m/sync";
default:
return null;
}
}
type MailTarget = {
to: string;
name: string;
locale: Locale;
link: string | null;
footer: CraftviaFooter;
dedupeKey: string;
text: { title: string; message: string };
};
function buildMail(ctx: ServiceCtx, event: DomainEvent, f: EventFacts, t: MailTarget): EnqueueInput<TemplateKey> {
const unknown = fallbackText(t.locale, "unknown");
const actionUrl = absoluteUrl(t.link ?? "/notifications");
const base = { to: t.to, tenantId: ctx.tenantId, locale: t.locale, dedupeKey: t.dedupeKey };
const order = { number: f.number ?? unknown, title: f.title ?? unknown, customer: f.customer ?? unknown };
switch (event.type) {
case "work_order.assigned":
return {
...base,
template: "craftvia_team_assigned",
vars: {
name: t.name, ...order, site: f.site, team: f.team, actionUrl, footer: t.footer,
plannedStart: f.plannedStart ? formatWhen(f.plannedStart, t.locale) : undefined,
},
};
case "report.submitted":
return {
...base,
template: "craftvia_report_review",
vars: { name: t.name, ...order, submittedBy: f.actorName, reportType: f.reportType, actionUrl, footer: t.footer },
};
case "work_order.released_for_billing":
return { ...base, template: "craftvia_billing_release", vars: { name: t.name, ...order, actionUrl, footer: t.footer } };
case "emergency.created":
case "emergency.completed":
return {
...base,
template: "craftvia_emergency",
vars: {
name: t.name,
phase: event.type === "emergency.completed" ? "completed" : "created",
number: order.number,
technician: f.actorName ?? unknown,
customer: order.customer,
start: f.emergencyStart ? formatWhen(f.emergencyStart, t.locale) : unknown,
end: f.emergencyEnd ? formatWhen(f.emergencyEnd, t.locale) : undefined,
actionUrl,
footer: t.footer,
},
};
case "import.failed":
return {
...base,
template: "craftvia_document_failed",
vars: { name: t.name, fileName: f.fileName ?? unknown, error: f.errorMessage, actionUrl, footer: t.footer },
};
default:
return {
...base,
template: "craftvia_notification",
vars: { name: t.name, subject: t.text.title, body: t.text.message, actionUrl, footer: t.footer },
};
}
}
@@ -0,0 +1,319 @@
import type { Prisma } from "@prisma/client";
import type { DomainEvent, EventType } from "@/lib/events";
import { normalizeLocale, type Locale } from "@/server/mail/templates";
import type { ServiceCtx } from "@/server/services/context";
/**
* Recipient rules per domain event (spec §19.4, §20, §33.1; ARCHITEKTUR §4.1).
*
* Tenant separation: every lookup runs through `ctx.db` (tenant guard + RLS). User ids coming
* from entity rows are re-resolved via `ctx.db.user` with status ACTIVE, so ids of other tenants
* (or deactivated users) can never become recipients.
*/
/** Events whose e-mail cannot be turned off by the user (spec §19.4). */
export const MANDATORY_EMAIL_EVENTS: ReadonlySet<EventType> = new Set(["emergency.created", "emergency.completed"]);
/**
* System-result events: the affected user is informed even if the event was emitted in their own
* context (a worker/sync run on their behalf) — the "actor excluded" rule does not apply to them.
*/
const SYSTEM_RESULT_EVENTS: ReadonlySet<EventType> = new Set(["import.ready_for_review", "import.failed", "sync.failed"]);
export type Target = {
userId: string;
email: string;
name: string;
locale: Locale;
permissions: ReadonlySet<string>;
};
/** Facts about the entity, used for texts, templates and links. */
export type EventFacts = {
workOrderId?: string;
number?: string;
title?: string;
customer?: string;
site?: string;
team?: string;
plannedStart?: Date | null;
isEmergency?: boolean;
reportId?: string;
reportType?: "daily" | "completion";
importJobId?: string;
fileName?: string;
errorMessage?: string;
syncErrorCode?: string;
actorName?: string;
emergencyStart?: Date | null;
emergencyEnd?: Date | null;
};
export type RecipientPlan = {
/** Users receiving an in-app notification (actor already removed where applicable). */
users: Target[];
/** Users who additionally get an e-mail (subject to preferences unless mandatory). */
mailUserIds: ReadonlySet<string>;
/** Configured external addresses (tenant mail settings) — e-mail only. */
externalEmails: string[];
mandatory: boolean;
facts: EventFacts;
};
const USER_SELECT = {
id: true,
email: true,
name: true,
identity: { select: { uiLocale: true } },
userRoles: { select: { role: { select: { rolePermissions: { select: { permission: { select: { key: true } } } } } } } },
} satisfies Prisma.UserSelect;
function hasPermissionWhere(key: string): Prisma.UserWhereInput {
return { userRoles: { some: { role: { rolePermissions: { some: { permission: { key } } } } } } };
}
/** Load active users of the current tenant matching `where` (always via the tenant client). */
export async function loadTargets(ctx: ServiceCtx, where: Prisma.UserWhereInput, fallbackLocale: string | null | undefined): Promise<Target[]> {
const rows = await ctx.db.user.findMany({ where: { AND: [where, { status: "ACTIVE" }] }, select: USER_SELECT });
return rows.map((u) => ({
userId: u.id,
email: u.email,
name: u.name,
locale: normalizeLocale(u.identity?.uiLocale ?? fallbackLocale),
permissions: new Set(u.userRoles.flatMap((ur) => ur.role.rolePermissions.map((rp) => rp.permission.key))),
}));
}
/** Back office responsible for reviews: users with work_order:read_all AND report:approve. */
export function backofficeWhere(): Prisma.UserWhereInput {
return { AND: [hasPermissionWhere("work_order:read_all"), hasPermissionWhere("report:approve")] };
}
export function billingWhere(): Prisma.UserWhereInput {
return hasPermissionWhere("work_order:release_billing");
}
type LoadedWorkOrder = {
id: string;
number: string;
title: string;
isEmergency: boolean;
plannedStart: Date | null;
createdAt: Date;
teamLeadUserId: string | null;
customer: { companyName: string | null; firstName: string | null; lastName: string | null } | null;
site: { name: string | null } | null;
team: { id: string; name: string; leaderUserId: string | null } | null;
assignees: { userId: string }[];
};
async function loadWorkOrder(ctx: ServiceCtx, id: string): Promise<LoadedWorkOrder | null> {
return ctx.db.workOrder.findFirst({
where: { id, deletedAt: null },
select: {
id: true,
number: true,
title: true,
isEmergency: true,
plannedStart: true,
createdAt: true,
teamLeadUserId: true,
customer: { select: { companyName: true, firstName: true, lastName: true } },
site: { select: { name: true } },
team: { select: { id: true, name: true, leaderUserId: true } },
assignees: { select: { userId: true } },
},
}) as Promise<LoadedWorkOrder | null>;
}
export function customerLabel(c: LoadedWorkOrder["customer"]): string | undefined {
if (!c) return undefined;
return c.companyName || [c.firstName, c.lastName].filter(Boolean).join(" ") || undefined;
}
/** Active team members + team leader + order team lead + individual assignees. */
async function participantIds(ctx: ServiceCtx, wo: LoadedWorkOrder): Promise<string[]> {
const ids = new Set<string>();
if (wo.team) {
const now = new Date();
const members = await ctx.db.teamMember.findMany({
where: { teamId: wo.team.id, validFrom: { lte: now }, OR: [{ validTo: null }, { validTo: { gt: now } }] },
select: { userId: true },
});
members.forEach((m) => ids.add(m.userId));
if (wo.team.leaderUserId) ids.add(wo.team.leaderUserId);
}
if (wo.teamLeadUserId) ids.add(wo.teamLeadUserId);
wo.assignees.forEach((a) => ids.add(a.userId));
return [...ids];
}
function teamLeadIds(wo: LoadedWorkOrder): string[] {
return [...new Set([wo.teamLeadUserId, wo.team?.leaderUserId].filter((x): x is string => !!x))];
}
function workOrderFacts(wo: LoadedWorkOrder): EventFacts {
return {
workOrderId: wo.id,
number: wo.number,
title: wo.title,
customer: customerLabel(wo.customer),
site: wo.site?.name ?? undefined,
team: wo.team?.name,
plannedStart: wo.plannedStart,
isEmergency: wo.isEmergency,
};
}
function dateFromData(v: unknown): Date | null {
if (typeof v !== "string" && typeof v !== "number") return null;
const d = new Date(v);
return Number.isNaN(d.getTime()) ? null : d;
}
type Rule = {
users: Prisma.UserWhereInput[];
/** ids from entity rows — re-resolved through ctx.db.user */
userIds: string[];
/** false = in-app only for the users (e-mail goes to `external`); default true */
mailToUsers?: boolean;
external?: string[];
};
/**
* Resolve recipients for an event. Returns null when the entity is not visible in this tenant
* (unknown id, other tenant, soft-deleted) — then nothing is sent.
*/
export async function resolveRecipients(
ctx: ServiceCtx,
event: DomainEvent,
settings: { locale?: string | null; emergencyRecipients?: string[]; billingRecipients?: string[] } | null,
): Promise<RecipientPlan | null> {
const facts: EventFacts = {};
let rule: Rule;
switch (event.type) {
case "work_order.assigned":
case "work_order.changed":
case "work_order.cancelled": {
const wo = await loadWorkOrder(ctx, event.entityId);
if (!wo) return null;
Object.assign(facts, workOrderFacts(wo));
rule = { users: [], userIds: await participantIds(ctx, wo) };
break;
}
case "work_order.started":
case "work_order.daily_report_created":
case "work_order.technically_completed":
case "work_order.signature_missing":
case "work_order.missing_required": {
const wo = await loadWorkOrder(ctx, event.entityId);
if (!wo) return null;
Object.assign(facts, workOrderFacts(wo));
rule = { users: [backofficeWhere()], userIds: [] };
break;
}
case "work_order.released_for_billing": {
const wo = await loadWorkOrder(ctx, event.entityId);
if (!wo) return null;
Object.assign(facts, workOrderFacts(wo));
const configured = settings?.billingRecipients ?? [];
// In-app for billing staff; e-mail to the configured billing recipients, or — if none are
// configured — to the billing staff themselves.
rule = {
users: [billingWhere()],
userIds: [],
mailToUsers: configured.length === 0,
external: configured,
};
break;
}
case "emergency.created":
case "emergency.completed": {
const wo = await loadWorkOrder(ctx, event.entityId);
if (!wo) return null;
Object.assign(facts, workOrderFacts(wo));
facts.emergencyStart = dateFromData(event.data?.startedAt) ?? wo.plannedStart ?? wo.createdAt;
facts.emergencyEnd = event.type === "emergency.completed" ? (dateFromData(event.data?.endedAt) ?? new Date()) : null;
rule = { users: [backofficeWhere()], userIds: [], external: settings?.emergencyRecipients ?? [] };
break;
}
case "report.submitted":
case "report.approved":
case "report.rejected": {
const report = await ctx.db.report.findFirst({
where: { id: event.entityId },
select: { id: true, type: true, workOrderId: true, createdById: true },
});
if (!report) return null;
const wo = await loadWorkOrder(ctx, report.workOrderId);
if (!wo) return null;
Object.assign(facts, workOrderFacts(wo), { reportId: report.id, reportType: report.type });
if (event.type === "report.submitted") {
// data.approvalStage (set by the reports lane): "team" → team leads only,
// "backoffice" → back office only, unset → both.
const stage = event.data?.approvalStage;
const leadsWhere: Prisma.UserWhereInput = {
AND: [hasPermissionWhere("report:approve_team"), { id: { in: teamLeadIds(wo) } }],
};
if (stage === "team") rule = { users: [leadsWhere], userIds: [] };
else if (stage === "backoffice") rule = { users: [backofficeWhere()], userIds: [] };
else rule = { users: [backofficeWhere(), leadsWhere], userIds: [] };
} else {
const ids = await participantIds(ctx, wo);
if (report.createdById) ids.push(report.createdById);
rule = { users: [], userIds: ids };
}
break;
}
case "import.ready_for_review":
case "import.failed": {
const job = await ctx.db.importJob.findFirst({
where: { id: event.entityId },
select: { id: true, importedById: true, errorMessage: true, documentId: true },
});
if (!job) return null;
const doc = await ctx.db.document.findFirst({ where: { id: job.documentId }, select: { fileName: true } });
Object.assign(facts, { importJobId: job.id, fileName: doc?.fileName, errorMessage: job.errorMessage ?? undefined });
rule = { users: [], userIds: job.importedById ? [job.importedById] : [] };
break;
}
case "sync.failed": {
const op = await ctx.db.syncOperation.findFirst({
where: { id: event.entityId },
select: { id: true, userId: true, errorCode: true, entityType: true, entityId: true },
});
if (!op) return null;
facts.syncErrorCode = op.errorCode ?? (typeof event.data?.reason === "string" ? event.data.reason : undefined);
if (op.entityType === "work_order" && op.entityId) {
const wo = await loadWorkOrder(ctx, op.entityId);
if (wo) Object.assign(facts, workOrderFacts(wo));
}
rule = { users: [backofficeWhere()], userIds: [op.userId] };
break;
}
default:
return null;
}
const or: Prisma.UserWhereInput[] = [...rule.users];
if (rule.userIds.length) or.push({ id: { in: [...new Set(rule.userIds)] } });
let users = or.length ? await loadTargets(ctx, { OR: or }, settings?.locale) : [];
const keepActor = SYSTEM_RESULT_EVENTS.has(event.type);
if (!keepActor) users = users.filter((u) => u.userId !== ctx.userId);
const mailUserIds = new Set(rule.mailToUsers === false ? [] : users.map((u) => u.userId));
// Configured external addresses: skip those already covered by a user mail (no double mail).
const userMails = new Set(users.filter((u) => mailUserIds.has(u.userId)).map((u) => u.email.toLowerCase()));
const externalEmails = [...new Set((rule.external ?? []).map((e) => e.trim().toLowerCase()).filter(Boolean))].filter(
(e) => !userMails.has(e),
);
// Actor display name (for "{actor} hat …" texts) — tenant-bound lookup.
const actor = await ctx.db.user.findFirst({ where: { id: ctx.userId }, select: { name: true } });
facts.actorName = typeof event.data?.technician === "string" ? event.data.technician : actor?.name;
return { users, mailUserIds, externalEmails, mandatory: MANDATORY_EMAIL_EVENTS.has(event.type), facts };
}
@@ -0,0 +1,45 @@
import type { EventType } from "@/lib/events";
import type { Locale } from "@/server/mail/templates";
// Single source for notification texts: the UI message catalog (namespace "notifications").
// Imported statically so the same texts work in requests, workers and test scripts
// (next-intl's request scope is not available outside a request).
import de from "../../../../messages/de/notifications.json";
import en from "../../../../messages/en/notifications.json";
const CATALOG = { de, en } as const;
export type EventKey = keyof typeof de.events;
/** "work_order.assigned" → "work_order_assigned" (next-intl keys must not contain dots). */
export function eventKey(type: EventType): EventKey {
return type.replace(".", "_") as EventKey;
}
function fill(template: string, vars: Record<string, string | undefined>, unknown: string): string {
return template.replace(/\{(\w+)\}/g, (_m, key: string) => {
const value = vars[key];
return value != null && value !== "" ? value : unknown;
});
}
/** In-app title/message for an event in the recipient's language. */
export function eventText(
locale: Locale,
type: EventType,
vars: Record<string, string | undefined>,
): { title: string; message: string } {
const cat = CATALOG[locale];
const entry = cat.events[eventKey(type)];
return {
title: fill(entry.title, vars, cat.fallback.unknown),
message: fill(entry.message, vars, cat.fallback.unknown),
};
}
export function typeLabel(locale: Locale, type: EventType): string {
return CATALOG[locale].types[eventKey(type)];
}
export function fallbackText(locale: Locale, key: keyof typeof de.fallback): string {
return CATALOG[locale].fallback[key];
}