Fundament: ISMS-Module entfernt; Craftvia-Rollen, Module, Navigation, i18n-Split

- ISMS-Routen, Actions, Server-/Lib-Code, Komponenten, Prisma-Modelle, Seeds,
  Importer, Skripte und ISMS-Tests entfernt (Fundament bleibt: Auth, Identity,
  MFA/WebAuthn, RBAC, Audit, Mail, Storage, Backup/DSGVO, Plattform-Admin)
- Schema auf Fundament-Modelle reduziert; TenantSettings generisch (+phone/email)
- TENANT_MODELS (db.ts, backup/topology.ts) und PII-Felder ausgedünnt
- RBAC: Rollen tenant-admin/backoffice/team-lead/technician + Craftvia-Permissions
- Modul-Katalog (customers, sites, teams, work_orders, imports, field, reports,
  emergency, documents, notifications, lotse) + Navigation aus src/lib/nav.ts
- Modul-Routen mit requireModule-Layout und Platzhalterseite
- Message-Katalog je Namespace (messages/<locale>/<namespace>.json), fs-Loader
- check-module-guards: Modul-Key aus src/server/actions/<moduleKey>/
- Provisionierung, Admin-Konsole, Einstellungen, Files-Route, Mail entkoppelt
- Seed minimal (demo/demo2, Nutzer je Rolle); Fundament-Tests auf Role/
  NotificationPreference-Fixtures umgestellt

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-14 11:35:44 +02:00
co-authored by Claude Opus 5
parent c8e6f30a27
commit 8491c7f173
443 changed files with 1325 additions and 87773 deletions
-294
View File
@@ -1,294 +0,0 @@
import { prisma } from "@/server/db";
import { absoluteUrl } from "./config";
import { enqueueMail } from "./service";
import { normalizeLocale, type Locale } from "./templates";
import {
openReportDeadlines,
type DeadlineKind,
} from "@/lib/incident-deadlines";
/**
* Vorfall-Benachrichtigungen (Fachkonzept §7, SEC1-Infrastruktur) — IM-B.
*
* Modelliert nach `notifications.ts` (Aufgaben), gleiche Zusagen:
* - **Opt-in per Default** (fehlt eine `NotificationPreference`, wird versendet).
* - **Mandantenisolation** — Empfänger immer innerhalb des auslösenden Mandanten.
* - **Sprache je Empfänger:** Präferenz → Mandanten-Locale → `de`.
* - **Kein Selbstversand** — der Auslöser bekommt keine Mail über die eigene Aktion.
* - **Idempotenz** über `dedupeKey`; der Fristen-Job hängt das Datum an.
*
* „Fire and forget": Fehler werden geloggt, nie an die Fachaktion durchgereicht.
*/
export type IncidentEvent =
| "incident_created"
| "incident_assigned"
| "incident_status_changed"
| "incident_report_due"
| "incident_closed";
type Recipient = { id: string; email: string; name: string; locale: Locale };
async function resolveRecipient(
tenantId: string,
userId: string | null | undefined,
event: IncidentEvent,
): Promise<Recipient | null> {
if (!userId) return null;
const [user, preference, settings] = await Promise.all([
prisma.user.findFirst({
where: { id: userId, tenantId, status: "ACTIVE" },
select: { id: true, email: true, name: true },
}),
prisma.notificationPreference.findUnique({
where: { userId_eventType: { userId, eventType: event } },
select: { email: true, locale: true },
}),
prisma.tenantSettings.findUnique({ where: { tenantId }, select: { locale: true } }),
]);
if (!user) return null;
if (preference && !preference.email) return null;
return {
id: user.id,
email: user.email,
name: user.name,
locale: normalizeLocale(preference?.locale ?? settings?.locale),
};
}
/**
* IDs der ISB/Incident-Manager eines Mandanten = aktive Nutzer mit dem Recht
* `incident:manage` (§1/§7). Streng mandantengebunden.
*/
export async function incidentManagerIds(tenantId: string): Promise<string[]> {
const users = await prisma.user.findMany({
where: {
tenantId,
status: "ACTIVE",
userRoles: {
some: { role: { rolePermissions: { some: { permission: { key: "incident:manage" } } } } },
},
},
select: { id: true },
});
return users.map((u) => u.id);
}
type TextBuilder = (title: string, refNo: string, detail?: string) => { subject: string; body: string };
const TEXTS: Record<IncidentEvent, Record<Locale, TextBuilder>> = {
incident_created: {
de: (title, refNo) => ({
subject: "Neuer Vorfall gemeldet",
body: `Ein neuer Vorfall „${title}" (${refNo}) wurde gemeldet und wartet auf Triage.`,
}),
en: (title, refNo) => ({
subject: "New incident reported",
body: `A new incident "${title}" (${refNo}) has been reported and awaits triage.`,
}),
},
incident_assigned: {
de: (title, refNo) => ({
subject: "Vorfall zugewiesen",
body: `Ihnen wurde der Vorfall „${title}" (${refNo}) zur Bearbeitung zugewiesen.`,
}),
en: (title, refNo) => ({
subject: "Incident assigned",
body: `The incident "${title}" (${refNo}) has been assigned to you.`,
}),
},
incident_status_changed: {
de: (title, refNo, detail) => ({
subject: "Statusänderung am Vorfall",
body: `Der Vorfall „${title}" (${refNo}) hat einen neuen Stand: ${detail ?? "aktualisiert"}.`,
}),
en: (title, refNo, detail) => ({
subject: "Incident status changed",
body: `The incident "${title}" (${refNo}) has a new state: ${detail ?? "updated"}.`,
}),
},
incident_report_due: {
de: (title, refNo, detail) => ({
subject: "Meldefrist für Vorfall",
body: `Für den Vorfall „${title}" (${refNo}) ${detail ?? "steht eine Meldefrist an"}.`,
}),
en: (title, refNo, detail) => ({
subject: "Reporting deadline for incident",
body: `For the incident "${title}" (${refNo}) ${detail ?? "a reporting deadline is due"}.`,
}),
},
incident_closed: {
de: (title, refNo) => ({
subject: "Vorfall abgeschlossen",
body: `Der Vorfall „${title}" (${refNo}) wurde abgeschlossen.`,
}),
en: (title, refNo) => ({
subject: "Incident closed",
body: `The incident "${title}" (${refNo}) has been closed.`,
}),
},
};
/**
* Versendet eine Vorfall-Benachrichtigung an eine Menge von Empfängern. Der
* Auslöser (`actorId`) wird ausgenommen, Empfänger werden dedupliziert und je
* Sprache/Präferenz aufgelöst.
*/
export async function notifyIncidentEvent(input: {
tenantId: string;
event: IncidentEvent;
incidentId: string;
refNo: string;
title: string;
recipientIds: (string | null | undefined)[];
actorId?: string | null;
detail?: string;
/** Ergänzt den Idempotenzschlüssel (der Fristen-Job hängt Frist-Art + Datum an). */
dedupeSuffix?: string;
}): Promise<void> {
try {
const ids = [...new Set(input.recipientIds.filter((x): x is string => !!x))].filter(
(id) => id !== input.actorId,
);
for (const id of ids) {
const recipient = await resolveRecipient(input.tenantId, id, input.event);
if (!recipient) continue;
const text = TEXTS[input.event][recipient.locale](input.title, input.refNo, input.detail);
await enqueueMail({
template: "incident_notification",
to: recipient.email,
tenantId: input.tenantId,
locale: recipient.locale,
dedupeKey: `${input.event}:${input.incidentId}:${recipient.id}${input.dedupeSuffix ? `:${input.dedupeSuffix}` : ""}`,
vars: {
name: recipient.name,
subject: text.subject,
body: text.body,
actionUrl: absoluteUrl(`/incidents?detail=${input.incidentId}`),
refNo: input.refNo,
},
});
}
} catch (err) {
console.error("[mail] Vorfall-Benachrichtigung fehlgeschlagen:", err);
}
}
const DEADLINE_LABEL: Record<DeadlineKind, Record<Locale, string>> = {
erstmeldung: { de: "Die NIS2-Erstmeldung (24 h)", en: "The NIS2 initial report (24 h)" },
folgemeldung: { de: "Die NIS2-Folgemeldung (72 h)", en: "The NIS2 follow-up report (72 h)" },
abschluss: { de: "Der NIS2-Abschlussbericht (1 Monat)", en: "The NIS2 final report (1 month)" },
dsgvo: { de: "Die DSGVO-Meldung (Art. 33, 72 h)", en: "The GDPR notification (Art. 33, 72 h)" },
reaction: { de: "Die interne Reaktionsfrist", en: "The internal reaction SLA" },
resolution: { de: "Die interne Behebungsfrist", en: "The internal resolution SLA" },
};
function deadlineDetail(kind: DeadlineKind, overdue: boolean, remainingMs: number, locale: Locale): string {
const label = DEADLINE_LABEL[kind][locale];
if (overdue) {
const days = Math.max(0, Math.floor(-remainingMs / 86_400_000));
return locale === "en"
? `${label} is overdue${days > 0 ? ` by ${days} day(s)` : ""}`
: `${label} ist überfällig${days > 0 ? ` (seit ${days} Tag(en))` : ""}`;
}
const hours = Math.max(0, Math.ceil(remainingMs / 3_600_000));
return locale === "en"
? `${label} is due within ${hours} h`
: `${label} läuft in ${hours} h ab`;
}
/**
* Fristen-Erinnerung/Eskalation für Meldepflichten (§6/§7). Läuft täglich im
* Reminder-Worker (mandantenübergreifend über den rohen Client; die Zuordnung
* kommt aus dem Vorfall). Pro Vorfall, Frist-Art und Tag höchstens eine Mail.
*
* Empfänger: drohend → owner/assignee (bzw. Manager, falls unbesetzt);
* überfällig → zusätzlich alle Incident-Manager (Eskalation).
*/
export async function sendIncidentDeadlineReminders(now: Date = new Date()): Promise<number> {
const incidents = await prisma.incident.findMany({
where: {
status: { not: "abgeschlossen" },
reportStatus: { not: "abschluss" },
OR: [
{ erstmeldungDueAt: { not: null } },
{ folgemeldungDueAt: { not: null } },
{ abschlussDueAt: { not: null } },
{ dsgvoDueAt: { not: null } },
],
},
select: {
id: true,
tenantId: true,
refNo: true,
title: true,
severity: true,
reportStatus: true,
ownerId: true,
assigneeId: true,
createdBy: true,
detectedAt: true,
reportedAt: true,
occurredAt: true,
createdAt: true,
erstmeldungDueAt: true,
folgemeldungDueAt: true,
abschlussDueAt: true,
dsgvoDueAt: true,
},
take: 500,
});
const day = now.toISOString().slice(0, 10);
let sent = 0;
const managerCache = new Map<string, string[]>();
for (const inc of incidents) {
const due = openReportDeadlines(inc, now).filter((d) => d.overdue || d.dueSoon);
if (due.length === 0) continue;
for (const item of due) {
// Basis-Empfänger: Bearbeiter/Owner; unbesetzt oder überfällig → Manager.
const recipients = new Set<string>();
if (inc.ownerId) recipients.add(inc.ownerId);
if (inc.assigneeId) recipients.add(inc.assigneeId);
if (item.overdue || recipients.size === 0) {
if (!managerCache.has(inc.tenantId)) {
managerCache.set(inc.tenantId, await incidentManagerIds(inc.tenantId));
}
managerCache.get(inc.tenantId)!.forEach((id) => recipients.add(id));
}
if (recipients.size === 0) continue;
// Sprache je Empfänger wird in notifyIncidentEvent aufgelöst; der Detailtext
// wird pro Empfänger benötigt → hier einmal in beiden Sprachen vorbereiten.
for (const id of recipients) {
const recipient = await resolveRecipient(inc.tenantId, id, "incident_report_due");
if (!recipient) continue;
const detail = deadlineDetail(item.kind, item.overdue, item.remainingMs, recipient.locale);
const text = TEXTS.incident_report_due[recipient.locale](inc.title, inc.refNo, detail);
const result = await enqueueMail({
template: "incident_notification",
to: recipient.email,
tenantId: inc.tenantId,
locale: recipient.locale,
dedupeKey: `incident_report_due:${inc.id}:${recipient.id}:${item.kind}:${day}`,
vars: {
name: recipient.name,
subject: text.subject,
body: text.body,
actionUrl: absoluteUrl(`/incidents?detail=${inc.id}`),
refNo: inc.refNo,
},
});
if (result.status !== "duplicate") sent++;
}
}
}
return sent;
}
+1 -1
View File
@@ -31,4 +31,4 @@ export const MAIL_DLQ = "mail-dead-letter";
*/
export const SCHEDULER_QUEUE = "mail-scheduler";
/** Wiederkehrender Job: fällige/überfällige Aufgaben erinnern. */
export const DUE_REMINDER_JOB = "task-due-reminder";
export const DUE_REMINDER_JOB = "daily-reminders";
+22 -139
View File
@@ -4,40 +4,33 @@ import { enqueueMail } from "./service";
import { formatWhen, normalizeLocale, type Locale } from "./templates";
/**
* SEC1 §6 — Benachrichtigungen aus Aufgaben-Ereignissen.
* SEC1 §6 — Benachrichtigungen aus Fach-Ereignissen (Andockpunkt für die Craftvia-Module,
* z. B. „Auftrag zugewiesen", „Bericht zur Freigabe", „Notdienst gemeldet").
*
* Regeln:
* - **Opt-in per Default.** Fehlt eine `NotificationPreference`-Zeile, wird
* versendet. Erst ein bewusstes `email = false` unterdrückt.
* - **Mandantenisolation.** Empfänger wird immer innerhalb des auslösenden
* Mandanten aufgelöst; ein Task verweist nie über die Mandantengrenze.
* Mandanten aufgelöst.
* - **Sprache je Empfänger:** Präferenz → Mandanten-Locale → `de`.
* - **Kein Selbstversand.** Wer die Aktion auslöst, bekommt keine Mail über
* die eigene Handlung.
* - **Idempotenz** über `dedupeKey`; der Fristen-Job nutzt zusätzlich das
* Datum, damit pro Aufgabe und Tag höchstens eine Erinnerung rausgeht.
* - **Idempotenz** über `dedupeKey`.
*
* Alle Funktionen sind „fire and forget": Fehler werden geloggt, aber nie an die
* auslösende Fachaktion durchgereicht.
*/
export type NotificationEvent =
| "task_assigned"
| "task_approval_requested"
| "task_decided"
| "task_due";
type Recipient = { id: string; email: string; name: string; locale: Locale };
/**
* Löst den Empfänger inklusive Sprache auf und berücksichtigt seine Präferenz.
* Gibt `null` zurück, wenn nicht versendet werden soll (kein Konto, inaktiv,
* abbestellt).
* Gibt `null` zurück, wenn nicht versendet werden soll (kein Konto, inaktiv, abbestellt).
*/
async function resolveRecipient(
export async function resolveRecipient(
tenantId: string,
userId: string | null | undefined,
event: NotificationEvent,
eventType: string,
): Promise<Recipient | null> {
if (!userId) return null;
@@ -47,7 +40,7 @@ async function resolveRecipient(
select: { id: true, email: true, name: true },
}),
prisma.notificationPreference.findUnique({
where: { userId_eventType: { userId, eventType: event } },
where: { userId_eventType: { userId, eventType } },
select: { email: true, locale: true },
}),
prisma.tenantSettings.findUnique({ where: { tenantId }, select: { locale: true } }),
@@ -65,156 +58,46 @@ async function resolveRecipient(
};
}
const TEXTS: Record<
NotificationEvent,
Record<Locale, (title: string, extra?: string) => { subject: string; body: string }>
> = {
task_assigned: {
de: (title) => ({
subject: "Neue Aufgabe für Sie",
body: `Ihnen wurde die Aufgabe „${title}" zugewiesen.`,
}),
en: (title) => ({
subject: "A new task for you",
body: `The task "${title}" has been assigned to you.`,
}),
},
task_approval_requested: {
de: (title) => ({
subject: "Freigabe angefragt",
body: `Sie wurden um die Freigabe von „${title}" gebeten.`,
}),
en: (title) => ({
subject: "Approval requested",
body: `You have been asked to approve "${title}".`,
}),
},
task_decided: {
de: (title, extra) => ({
subject: "Entscheidung zu Ihrer Freigabe-Anfrage",
body: `Zu „${title}" liegt eine Entscheidung vor: ${extra ?? "bearbeitet"}.`,
}),
en: (title, extra) => ({
subject: "Decision on your approval request",
body: `A decision has been made on "${title}": ${extra ?? "processed"}.`,
}),
},
task_due: {
de: (title, extra) => ({
subject: "Aufgabe fällig",
body: `Die Aufgabe „${title}" ist ${extra ?? "fällig"}.`,
}),
en: (title, extra) => ({
subject: "Task due",
body: `The task "${title}" is ${extra ?? "due"}.`,
}),
},
};
/**
* Versendet eine Aufgaben-Benachrichtigung. Wird aus den Task-Actions heraus
* aufgerufen und darf diese nie scheitern lassen.
* Versendet eine Fach-Benachrichtigung an eine Mitgliedschaft. Texte liefert der Aufrufer
* je Sprache (die Module halten ihre Texte selbst). Darf die Fachaktion nie scheitern lassen.
*/
export async function notifyTaskEvent(input: {
export async function notifyUser(input: {
tenantId: string;
event: NotificationEvent;
taskId: string;
taskTitle: string;
taskType: string;
eventType: string;
/** Objekt, auf das sich die Benachrichtigung bezieht (für Idempotenz). */
entityId: string;
recipientId: string | null | undefined;
/** Auslöser — bekommt keine Mail über die eigene Handlung. */
actorId?: string | null;
/** Zusatz, z. B. „freigegeben" / „abgelehnt" oder „seit 3 Tagen überfällig". */
detail?: string;
/** Überschreibt den Idempotenzschlüssel (Fristen-Job hängt das Datum an). */
texts: Record<Locale, { subject: string; body: string }>;
/** Relativer Pfad in der App, z. B. `/work-orders/<id>`. */
path?: string;
dedupeSuffix?: string;
}): Promise<void> {
try {
if (input.recipientId && input.actorId && input.recipientId === input.actorId) return;
const recipient = await resolveRecipient(input.tenantId, input.recipientId, input.event);
const recipient = await resolveRecipient(input.tenantId, input.recipientId, input.eventType);
if (!recipient) return;
const text = TEXTS[input.event][recipient.locale](input.taskTitle, input.detail);
const text = input.texts[recipient.locale];
await enqueueMail({
template: "notification",
to: recipient.email,
tenantId: input.tenantId,
locale: recipient.locale,
dedupeKey: `${input.event}:${input.taskId}:${recipient.id}${input.dedupeSuffix ? `:${input.dedupeSuffix}` : ""}`,
dedupeKey: `${input.eventType}:${input.entityId}:${recipient.id}${input.dedupeSuffix ? `:${input.dedupeSuffix}` : ""}`,
vars: {
name: recipient.name,
subject: text.subject,
body: text.body,
actionUrl: absoluteUrl(`/tasks?detail=${input.taskId}`),
taskType: input.taskType,
actionUrl: input.path ? absoluteUrl(input.path) : undefined,
eventType: input.eventType,
},
});
} catch (err) {
// Eine misslungene Benachrichtigung darf die Fachaktion nicht kippen.
console.error("[mail] Benachrichtigung fehlgeschlagen:", err);
}
}
/**
* Fristen-Erinnerung (SEC1 §6): einmal täglich für offene Aufgaben, die heute
* fällig sind oder es bereits waren.
*
* Doppelversand ist über den `dedupeKey` inklusive Datum ausgeschlossen: pro
* Aufgabe, Empfänger und Tag entsteht höchstens eine Mail — auch wenn der Job
* (z. B. nach einem Neustart) mehrfach läuft.
*
* Läuft mandantenübergreifend über den rohen Client, weil es keinen
* Request-/Session-Kontext gibt; die Mandantenzuordnung kommt aus der Aufgabe
* selbst und wird an jede Mail durchgereicht.
*/
export async function sendDueReminders(now: Date = new Date()): Promise<number> {
const endOfDay = new Date(now);
endOfDay.setHours(23, 59, 59, 999);
const day = now.toISOString().slice(0, 10);
const tasks = await prisma.task.findMany({
where: { status: "OPEN", dueDate: { not: null, lte: endOfDay }, assigneeId: { not: null } },
select: { id: true, tenantId: true, title: true, type: true, assigneeId: true, dueDate: true },
take: 500,
});
let sent = 0;
for (const task of tasks) {
const overdueDays = task.dueDate
? Math.floor((now.getTime() - task.dueDate.getTime()) / 86_400_000)
: 0;
const detailDe = overdueDays > 0 ? `seit ${overdueDays} Tag(en) überfällig` : "heute fällig";
const detailEn = overdueDays > 0 ? `overdue by ${overdueDays} day(s)` : "due today";
const recipient = await resolveRecipient(task.tenantId, task.assigneeId, "task_due");
if (!recipient) continue;
const text = TEXTS.task_due[recipient.locale](
task.title,
recipient.locale === "en" ? detailEn : detailDe,
);
const result = await enqueueMail({
template: "notification",
to: recipient.email,
tenantId: task.tenantId,
locale: recipient.locale,
dedupeKey: `task_due:${task.id}:${recipient.id}:${day}`,
vars: {
name: recipient.name,
subject: text.subject,
body: text.body,
actionUrl: absoluteUrl(`/tasks?detail=${task.id}`),
taskType: task.type,
},
});
if (result.status !== "duplicate") sent++;
}
return sent;
}
/** Zeitstempel für Transaktionsmails (Passwort/MFA/E-Mail-Änderung). */
export function nowFor(locale: Locale): string {
return formatWhen(new Date(), locale);
+1 -1
View File
@@ -29,7 +29,7 @@ export type EnqueueInput<K extends TemplateKey = TemplateKey> = {
tenantId: string | null;
locale?: string | null;
/**
* Idempotenzschlüssel, z. B. `task_assigned:<taskId>:<userId>`. Ohne Schlüssel
* Idempotenzschlüssel, z. B. `work_order_assigned:<orderId>:<userId>`. Ohne Schlüssel
* ist Mehrfachversand möglich — für Transaktionsmails gewollt (jede Anfrage
* erzeugt eine eigene Mail), für Benachrichtigungen gesetzt.
*/
+5 -33
View File
@@ -11,7 +11,7 @@ import { BRAND } from "@/lib/brand";
* und worker-tauglich; die UI-Kataloge in `messages/*.json` bleiben unberührt.
*
* Layout, Farben und die Dachmarken-Fußzeile kommen aus `src/lib/email-brand.ts`
* (Certvia-CD, Inline-Styles, Tabellenlayout für Outlook).
* (Craftvia-CD, Inline-Styles, Tabellenlayout für Outlook).
*
* WICHTIG: Templates erhalten fertige `actionUrl`s. Tokens werden von SEC2/SEC3/
* SEC4 erzeugt und tauchen weder im MailLog noch in Logs auf.
@@ -37,14 +37,7 @@ export type TemplateVars = {
subject: string;
body: string;
actionUrl?: string;
taskType: string;
};
incident_notification: {
name: string;
subject: string;
body: string;
actionUrl?: string;
refNo: string;
eventType: string;
};
test: { name: string; when: string };
};
@@ -59,20 +52,13 @@ export const TEMPLATE_KEYS = [
"email_changed_notice",
"mfa_changed",
"notification",
"incident_notification",
"test",
] 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, weil Ihnen eine Aufgabe zugewiesen ist. Die Einstellungen dazu finden Sie in Ihrem Profil.",
en: "You are receiving this notification because a task is assigned to you. You can change this in your profile.",
};
/** Fußnote für Vorfall-Benachrichtigungen (§7). */
const INCIDENT_FOOTER_NOTE: Record<Locale, string> = {
de: "Sie erhalten diese Benachrichtigung, weil Sie am Vorfallmanagement beteiligt sind. Die Einstellungen dazu finden Sie in Ihrem Profil.",
en: "You are receiving this notification because you are involved in incident management. You can change this in your profile.",
de: "Sie erhalten diese Benachrichtigung aufgrund Ihrer Rolle in Ihrem Betrieb. Die Einstellungen dazu finden Sie in Ihrem Profil.",
en: "You are receiving this notification because of your role in your company. You can change this in your profile.",
};
type Builder<K extends TemplateKey> = (vars: TemplateVars[K]) => EmailContent;
@@ -141,16 +127,9 @@ const de: { [K in TemplateKey]: Builder<K> } = {
subject: `${BRAND.name}: ${v.subject}`,
heading: v.subject,
paragraphs: [`Hallo ${v.name},`, v.body],
action: v.actionUrl ? { label: "In Certvia öffnen", url: v.actionUrl } : undefined,
action: v.actionUrl ? { label: `In ${BRAND.name} öffnen`, url: v.actionUrl } : undefined,
footerNote: FOOTER_NOTE.de,
}),
incident_notification: (v) => ({
subject: `${BRAND.name}: ${v.subject} (${v.refNo})`,
heading: v.subject,
paragraphs: [`Hallo ${v.name},`, v.body],
action: v.actionUrl ? { label: "Vorfall öffnen", url: v.actionUrl } : undefined,
footerNote: INCIDENT_FOOTER_NOTE.de,
}),
test: (v) => ({
subject: `${BRAND.name}: Test-Mail`,
heading: "Test-Mail",
@@ -229,13 +208,6 @@ const en: { [K in TemplateKey]: Builder<K> } = {
action: v.actionUrl ? { label: `Open in ${BRAND.name}`, url: v.actionUrl } : undefined,
footerNote: FOOTER_NOTE.en,
}),
incident_notification: (v) => ({
subject: `${BRAND.name}: ${v.subject} (${v.refNo})`,
heading: v.subject,
paragraphs: [`Hello ${v.name},`, v.body],
action: v.actionUrl ? { label: "Open incident", url: v.actionUrl } : undefined,
footerNote: INCIDENT_FOOTER_NOTE.en,
}),
test: (v) => ({
subject: `${BRAND.name}: test message`,
heading: "Test message",
+3 -6
View File
@@ -2,8 +2,6 @@ import { UnrecoverableError, Worker, type Job } from "bullmq";
import { deliverMail, markMailFailed, MailNotConfiguredError } from "./deliver";
import { closeQueues, getConnection, getDeadLetterQueue, getSchedulerQueue } from "./queue";
import { DUE_REMINDER_JOB, MAIL_QUEUE, SCHEDULER_QUEUE, type MailJob } from "./job";
import { sendDueReminders } from "./notifications";
import { sendIncidentDeadlineReminders } from "./incident-notifications";
import { closeMailProvider } from "./provider-smtp";
/**
@@ -103,10 +101,9 @@ export function startReminderWorker(): Worker | null {
SCHEDULER_QUEUE,
async (job: Job) => {
if (job.name !== DUE_REMINDER_JOB) return;
const count = await sendDueReminders();
console.info(`[mail] Aufgaben-Fristen-Erinnerungen eingestellt: ${count}`);
const incidentCount = await sendIncidentDeadlineReminders();
console.info(`[mail] Vorfall-Meldefristen-Erinnerungen/Eskalationen eingestellt: ${incidentCount}`);
// Andockpunkt: tägliche Erinnerungen der Fachmodule (z. B. überfällige Berichte,
// offene Einsätze) hier registrieren. Aktuell sind keine Erinnerungen definiert.
console.info("[mail] Täglicher Erinnerungslauf: keine Erinnerungen registriert.");
},
{ connection, concurrency: 1 },
);