L6 Benachrichtigungen & Audit: Glocke, Posteingang, Nutzer-Einstellungen, Mailkonfiguration

- Glocke im Backoffice-Header (ungelesen-Zähler, letzte 10, alle gelesen), mobil
  einbindbar über variant="mobile".
- /notifications mit Filter gelesen/ungelesen/Art, Öffnen markiert gelesen (nur
  relative Links), Pagination.
- /account Abschnitt Benachrichtigungen: E-Mail-Opt-out je Typ, Notdienst Pflicht.
- /settings/email (tenant:manage): Absendername, Antwortadresse, Empfänger Notdienst
  und Abrechnung; Validierung gegen Header-Injection, max. 20 Adressen, Audit.
- Actions unter actions/notifications mit moduleGuard + guard; Navigation ergänzt.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-14 12:12:21 +02:00
co-authored by Claude Opus 5
parent 7b37c41a83
commit 879012415e
18 changed files with 793 additions and 3 deletions
+36
View File
@@ -0,0 +1,36 @@
"use server";
import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation";
import { moduleGuard } from "@/server/action-guard";
import { ctxFromGuard, ServiceError } from "@/server/services/context";
import { markAllRead, markRead } from "@/server/services/notifications/inbox";
const guard = moduleGuard("notifications");
/** Mark one own notification as read. */
export async function markNotificationRead(formData: FormData) {
const g = await guard("notification:read");
await markRead(ctxFromGuard(g), formData.get("id"));
revalidatePath("/", "layout");
}
/** Mark as read and open the linked entity (relative links only). */
export async function openNotification(formData: FormData) {
const g = await guard("notification:read");
let target = "/notifications";
try {
const { link } = await markRead(ctxFromGuard(g), formData.get("id"));
if (link) target = link;
} catch (err) {
if (!(err instanceof ServiceError)) throw err;
}
revalidatePath("/", "layout");
redirect(target);
}
export async function markAllNotificationsRead() {
const g = await guard("notification:read");
await markAllRead(ctxFromGuard(g));
revalidatePath("/", "layout");
}
@@ -0,0 +1,30 @@
"use server";
import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation";
import { ZodError } from "zod";
import { moduleGuard } from "@/server/action-guard";
import { ctxFromGuard } from "@/server/services/context";
import { updateMailSettings } from "@/server/services/notifications/mail-settings";
const guard = moduleGuard("notifications");
/** Tenant mail settings (§33.2) — tenant:manage. Result is reported via query string. */
export async function saveMailSettings(formData: FormData) {
const g = await guard("tenant:manage");
let target = "/settings/email?saved=1";
try {
await updateMailSettings(ctxFromGuard(g), {
mailFromName: String(formData.get("mailFromName") ?? ""),
mailReplyTo: String(formData.get("mailReplyTo") ?? ""),
emergencyRecipients: String(formData.get("emergencyRecipients") ?? ""),
billingRecipients: String(formData.get("billingRecipients") ?? ""),
});
} catch (err) {
if (!(err instanceof ZodError)) throw err;
const fields = [...new Set(err.issues.map((i) => String(i.path[0] ?? "")))].filter(Boolean).join(",");
target = `/settings/email?error=${encodeURIComponent(fields || "input")}`;
}
revalidatePath("/settings/email");
redirect(target);
}
@@ -0,0 +1,23 @@
"use server";
import { revalidatePath } from "next/cache";
import { moduleGuard } from "@/server/action-guard";
import { ctxFromGuard } from "@/server/services/context";
import { setPreferences } from "@/server/services/notifications/preferences";
const guard = moduleGuard("notifications");
export type PreferencesState = { status: "idle" | "saved" | "error" };
/** Save the current user's e-mail opt-outs (checked boxes = e-mail on). */
export async function saveNotificationPreferences(_prev: PreferencesState, formData: FormData): Promise<PreferencesState> {
const g = await guard("notification:read");
try {
await setPreferences(ctxFromGuard(g), { emailOn: formData.getAll("emailOn").map(String) });
} catch (err) {
console.error("[notifications] saving preferences failed:", (err as Error).message);
return { status: "error" };
}
revalidatePath("/account");
return { status: "saved" };
}
+103
View File
@@ -0,0 +1,103 @@
import { z } from "zod";
import { EVENT_TYPES } from "@/lib/events";
import { writeAuditLog } from "@/server/audit";
import { assertCan, ServiceError, type ServiceCtx } from "@/server/services/context";
/**
* Personal notification inbox. A user only ever sees/changes their OWN notifications
* (userId filter) inside their tenant (ctx.db). Foreign ids → not_found (no existence leak).
*/
export const PAGE_SIZE = 25;
export const listFilterSchema = z.object({
status: z.enum(["all", "unread", "read"]).catch("all"),
type: z.enum(EVENT_TYPES).optional().catch(undefined),
page: z.coerce.number().int().min(1).max(10_000).catch(1),
});
export type ListFilter = z.infer<typeof listFilterSchema>;
export async function listNotifications(ctx: ServiceCtx, input: unknown) {
assertCan(ctx, "notification:read");
const filter = listFilterSchema.parse(input ?? {});
const where = {
userId: ctx.userId,
...(filter.status === "unread" ? { readAt: null } : filter.status === "read" ? { readAt: { not: null } } : {}),
...(filter.type ? { type: filter.type } : {}),
};
const [rows, total] = await Promise.all([
ctx.db.notification.findMany({
where,
orderBy: { createdAt: "desc" },
skip: (filter.page - 1) * PAGE_SIZE,
take: PAGE_SIZE,
select: { id: true, type: true, title: true, message: true, link: true, readAt: true, createdAt: true, entityType: true, entityId: true },
}),
ctx.db.notification.count({ where }),
]);
return { rows, total, page: filter.page, pages: Math.max(1, Math.ceil(total / PAGE_SIZE)), filter };
}
/** Unread counter + latest 10 for the bell. */
export async function bellSummary(ctx: ServiceCtx) {
assertCan(ctx, "notification:read");
const [unread, latest] = await Promise.all([
ctx.db.notification.count({ where: { userId: ctx.userId, readAt: null } }),
ctx.db.notification.findMany({
where: { userId: ctx.userId },
orderBy: { createdAt: "desc" },
take: 10,
select: { id: true, type: true, title: true, message: true, link: true, readAt: true, createdAt: true },
}),
]);
return { unread, latest };
}
const idSchema = z.string().min(1).max(64);
/** Mark one own notification as read; returns its link (safe relative path or null). */
export async function markRead(ctx: ServiceCtx, id: unknown): Promise<{ link: string | null }> {
assertCan(ctx, "notification:read");
const notificationId = idSchema.parse(id);
const row = await ctx.db.notification.findFirst({
where: { id: notificationId, userId: ctx.userId },
select: { id: true, readAt: true, link: true },
});
if (!row) throw new ServiceError("not_found", "notification not found");
if (!row.readAt) {
const readAt = new Date();
await ctx.db.notification.update({ where: { id: row.id }, data: { readAt } });
await writeAuditLog({
tenantId: ctx.tenantId,
actorId: ctx.userId,
action: "update",
entity: "notification",
entityId: row.id,
before: { readAt: null },
after: { readAt: readAt.toISOString() },
});
}
return { link: safeLink(row.link) };
}
export async function markAllRead(ctx: ServiceCtx): Promise<{ count: number }> {
assertCan(ctx, "notification:read");
const res = await ctx.db.notification.updateMany({ where: { userId: ctx.userId, readAt: null }, data: { readAt: new Date() } });
if (res.count > 0) {
await writeAuditLog({
tenantId: ctx.tenantId,
actorId: ctx.userId,
action: "update",
entity: "notification",
before: { unread: res.count },
after: { unread: 0 },
});
}
return { count: res.count };
}
/** Only same-origin relative paths are followed (no open redirect). */
export function safeLink(link: string | null | undefined): string | null {
if (!link || !link.startsWith("/") || link.startsWith("//") || link.includes("\\")) return null;
return link;
}
@@ -0,0 +1,96 @@
import { z } from "zod";
import { writeAuditLog } from "@/server/audit";
import { assertCan, type ServiceCtx } from "@/server/services/context";
/**
* Tenant mail settings (spec §33.2). The sender ADDRESS stays the platform domain (SPF/DKIM);
* per tenant only the display name and reply-to address are configurable, plus fixed recipient
* lists for emergency call-outs and billing.
*/
export const MAX_RECIPIENTS = 20;
/** Split a textarea/comma list into trimmed, lower-cased, unique entries. */
export function splitAddressList(raw: unknown): string[] {
if (Array.isArray(raw)) return [...new Set(raw.map((x) => String(x).trim().toLowerCase()).filter(Boolean))];
if (typeof raw !== "string") return [];
return [...new Set(raw.split(/[\s,;]+/).map((x) => x.trim().toLowerCase()).filter(Boolean))];
}
const email = z.string().trim().toLowerCase().email().max(254);
export const mailSettingsSchema = z.object({
// No CR/LF or angle brackets/quotes: the name ends up in a mail header (header injection).
mailFromName: z
.string()
.trim()
.max(100)
.regex(/^[^\r\n<>"]*$/, "invalid characters")
.transform((v) => v || null),
mailReplyTo: z
.union([z.literal(""), email])
.transform((v) => v || null),
emergencyRecipients: z.preprocess(splitAddressList, z.array(email).max(MAX_RECIPIENTS)),
billingRecipients: z.preprocess(splitAddressList, z.array(email).max(MAX_RECIPIENTS)),
});
export type MailSettings = {
mailFromName: string | null;
mailReplyTo: string | null;
emergencyRecipients: string[];
billingRecipients: string[];
};
const SELECT = { mailFromName: true, mailReplyTo: true, emergencyRecipients: true, billingRecipients: true, orgName: true } as const;
export async function getMailSettings(ctx: ServiceCtx): Promise<MailSettings & { orgName: string | null }> {
assertCan(ctx, "tenant:manage");
const s = await ctx.db.tenantSettings.findFirst({ select: SELECT });
return {
mailFromName: s?.mailFromName ?? null,
mailReplyTo: s?.mailReplyTo ?? null,
emergencyRecipients: s?.emergencyRecipients ?? [],
billingRecipients: s?.billingRecipients ?? [],
orgName: s?.orgName ?? null,
};
}
export async function updateMailSettings(ctx: ServiceCtx, input: unknown): Promise<MailSettings> {
assertCan(ctx, "tenant:manage");
const data = mailSettingsSchema.parse(input);
const before = await ctx.db.tenantSettings.findFirst({ select: SELECT });
if (before) {
await ctx.db.tenantSettings.update({ where: { tenantId: ctx.tenantId }, data });
} else {
const tenant = await ctx.db.tenant.findUnique({ where: { id: ctx.tenantId }, select: { name: true } });
await ctx.db.tenantSettings.create({ data: { tenantId: ctx.tenantId, orgName: tenant?.name ?? "", ...data } });
}
await writeAuditLog({
tenantId: ctx.tenantId,
actorId: ctx.userId,
action: "update",
entity: "tenant_mail_settings",
before: before
? {
mailFromName: before.mailFromName,
mailReplyTo: before.mailReplyTo,
emergencyRecipients: before.emergencyRecipients,
billingRecipients: before.billingRecipients,
}
: undefined,
after: data,
});
return data;
}
/**
* Sender override for tenant mails — ready for the mail core once it accepts per-mail
* `fromName`/`replyTo` (reported as foundation requirement; deliver.ts currently uses the
* global MAIL_FROM_NAME/MAIL_REPLY_TO only).
*/
export async function tenantMailSender(ctx: ServiceCtx): Promise<{ fromName: string | null; replyTo: string | null }> {
const s = await ctx.db.tenantSettings.findFirst({ select: { mailFromName: true, mailReplyTo: true, orgName: true } });
return { fromName: s?.mailFromName || s?.orgName || null, replyTo: s?.mailReplyTo ?? null };
}
@@ -0,0 +1,26 @@
import { requireSession } from "@/server/auth";
import { dbForTenant } from "@/server/db";
import type { ServiceCtx } from "@/server/services/context";
/**
* Read-path context for server components (pages, bell). Permissions come from the session
* (JWT copy), which is accepted for READ paths (AGENTS.md); mutations go through moduleGuard,
* which re-checks permissions against the database.
*/
export async function pageCtx(): Promise<ServiceCtx> {
const session = await requireSession();
return {
db: dbForTenant(session.user.tenantId),
tenantId: session.user.tenantId,
userId: session.user.id,
permissions: new Set(session.user.permissions ?? []),
};
}
export async function isNotificationsModuleEnabled(ctx: ServiceCtx): Promise<boolean> {
const row = await ctx.db.tenantModule.findUnique({
where: { tenantId_moduleKey: { tenantId: ctx.tenantId, moduleKey: "notifications" } },
select: { enabled: true },
});
return !row || row.enabled;
}
@@ -0,0 +1,58 @@
import { z } from "zod";
import { EVENT_TYPES, type EventType } from "@/lib/events";
import { writeAuditLog } from "@/server/audit";
import { assertCan, type ServiceCtx } from "@/server/services/context";
import { MANDATORY_EMAIL_EVENTS } from "./recipients";
/**
* Per-user e-mail opt-out per event type (NotificationPreference, default opt-in).
* Mandatory events (emergency call-outs) are always on and cannot be stored as opted out.
*/
export type PreferenceRow = { type: EventType; email: boolean; mandatory: boolean };
export async function getPreferences(ctx: ServiceCtx): Promise<PreferenceRow[]> {
assertCan(ctx, "notification:read");
const rows = await ctx.db.notificationPreference.findMany({
where: { userId: ctx.userId },
select: { eventType: true, email: true },
});
const map = new Map(rows.map((r) => [r.eventType, r.email]));
return EVENT_TYPES.map((type) => {
const mandatory = MANDATORY_EMAIL_EVENTS.has(type);
return { type, mandatory, email: mandatory ? true : (map.get(type) ?? true) };
});
}
export const setPreferencesSchema = z.object({
/** event types that should still send e-mail; everything else (non-mandatory) is opted out */
emailOn: z.array(z.enum(EVENT_TYPES)).max(EVENT_TYPES.length),
});
export async function setPreferences(ctx: ServiceCtx, input: unknown): Promise<PreferenceRow[]> {
assertCan(ctx, "notification:read");
const { emailOn } = setPreferencesSchema.parse(input);
const on = new Set<EventType>(emailOn);
const before = await getPreferences(ctx);
for (const type of EVENT_TYPES) {
const email = MANDATORY_EMAIL_EVENTS.has(type) ? true : on.has(type);
await ctx.db.notificationPreference.upsert({
where: { userId_eventType: { userId: ctx.userId, eventType: type } },
update: { email },
create: { tenantId: ctx.tenantId, userId: ctx.userId, eventType: type, email },
});
}
const after = await getPreferences(ctx);
await writeAuditLog({
tenantId: ctx.tenantId,
actorId: ctx.userId,
action: "update",
entity: "notification_settings",
entityId: ctx.userId,
before: Object.fromEntries(before.map((p) => [p.type, p.email])),
after: Object.fromEntries(after.map((p) => [p.type, p.email])),
});
return after;
}