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