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