Files
craftvia/src/server/services/notifications/handle-event.ts
T
msolarczekandClaude Opus 5 ac2d857e26 L14 Abrechnungsübersicht: Services für Einträge, Aufstellung, Abrechnen/Stornieren, Meilensteine und PDF
syncBillingCandidates (idempotent, Event-Hook für work_order.released_for_billing und report.approved), Aufstellung ohne Preise (nur freigegebene Zeiten, Fahrzeit getrennt, Pausen informativ, Anfahrten-Zählung, Material), markBilled mit eingefrorenem Snapshot und Positionszuordnung, voidBilling mit Grund und neuem offenen Eintrag (billed → released_for_billing nur über applyTransition-Option billingVoid), Meilenstein-Flow mit Events, Job billing-pdf (Dokument other/backoffice_only), Sync-Op milestone.reach.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-15 10:35:38 +02:00

233 lines
9.0 KiB
TypeScript

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<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 ?? 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>): 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<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 },
};
}
}