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"; /** * 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 { 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 ?? f.rejectionReason, }; } /** 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"; case "time_entry": // L12: approvers open their approval list, the technician „Meine Zeiten" if (event.type === "time.approval_requested") return backoffice && permissions.has("time:approve") ? "/work-orders/time-approvals" : "/m/approvals"; return "/m/time"; case "milestone": // L14: back office → billing tab of the order, field → mobile order detail if (!f.workOrderId) return null; return backoffice ? `/work-orders/${f.workOrderId}?tab=billing` : `/m/orders/${f.workOrderId}`; 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 }, }; } }