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:
@@ -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];
|
||||
}
|
||||
Reference in New Issue
Block a user