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:
2026-09-14 12:11:53 +02:00
co-authored by Claude Opus 5
parent bf4456718e
commit 7b37c41a83
9 changed files with 1452 additions and 4 deletions
+200
View File
@@ -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 };