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
@@ -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 };
}