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