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