diff --git a/messages/de/notifications.json b/messages/de/notifications.json new file mode 100644 index 0000000..4262b25 --- /dev/null +++ b/messages/de/notifications.json @@ -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" + } + } +} diff --git a/messages/en/notifications.json b/messages/en/notifications.json new file mode 100644 index 0000000..2bd8e15 --- /dev/null +++ b/messages/en/notifications.json @@ -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" + } + } +} diff --git a/prisma/migrations/20260914120000_benachrichtigungen_tenant_mail_settings/migration.sql b/prisma/migrations/20260914120000_benachrichtigungen_tenant_mail_settings/migration.sql new file mode 100644 index 0000000..85db4af --- /dev/null +++ b/prisma/migrations/20260914120000_benachrichtigungen_tenant_mail_settings/migration.sql @@ -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; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 0a65baf..640dcbe 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -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") diff --git a/scripts/test-benachrichtigungen-events.ts b/scripts/test-benachrichtigungen-events.ts new file mode 100644 index 0000000..a614f00 --- /dev/null +++ b/scripts/test-benachrichtigungen-events.ts @@ -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("") && 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); + }); diff --git a/src/server/mail/templates.ts b/src/server/mail/templates.ts index 19399c3..1a962ad 100644 --- a/src/server/mail/templates.ts +++ b/src/server/mail/templates.ts @@ -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 = { 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 = { type Builder = (vars: TemplateVars[K]) => EmailContent; +// ---- Craftvia domain notifications (lane L6) ---- +type CraftviaKey = (typeof CRAFTVIA_TEMPLATE_KEYS)[number]; + +const CRAFTVIA_FOOTER: Record> = { + 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 } = { + 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 } = { + 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 } = { invitation: (v) => ({ subject: `Ihr Zugang zu ${BRAND.name}`, @@ -139,6 +337,7 @@ const de: { [K in TemplateKey]: Builder } = { "Erreicht sie Sie, sind SMTP-Konfiguration und Versandweg in Ordnung.", ], }), + ...craftviaDe, }; const en: { [K in TemplateKey]: Builder } = { @@ -217,6 +416,7 @@ const en: { [K in TemplateKey]: Builder } = { "If it reaches you, SMTP configuration and delivery path are working.", ], }), + ...craftviaEn, }; const CATALOG: Record }> = { de, en }; diff --git a/src/server/services/notifications/handle-event.ts b/src/server/services/notifications/handle-event.ts index 6338513..aa09d5e 100644 --- a/src/server/services/notifications/handle-event.ts +++ b/src/server/services/notifications/handle-event.ts @@ -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 { - // intentionally empty +export async function handleEvent(ctx: ServiceCtx, event: DomainEvent): Promise { + 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 { + 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> { + 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 { + 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 { + 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 | 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 { + 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 }, + }; + } } diff --git a/src/server/services/notifications/recipients.ts b/src/server/services/notifications/recipients.ts new file mode 100644 index 0000000..52a6034 --- /dev/null +++ b/src/server/services/notifications/recipients.ts @@ -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 = 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 = new Set(["import.ready_for_review", "import.failed", "sync.failed"]); + +export type Target = { + userId: string; + email: string; + name: string; + locale: Locale; + permissions: ReadonlySet; +}; + +/** 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; + /** 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 { + 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 { + 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; +} + +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 { + const ids = new Set(); + 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 { + 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 }; +} diff --git a/src/server/services/notifications/texts.ts b/src/server/services/notifications/texts.ts new file mode 100644 index 0000000..23cf1d8 --- /dev/null +++ b/src/server/services/notifications/texts.ts @@ -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, 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, +): { 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]; +}