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:
@@ -8,5 +8,8 @@
|
|||||||
"reports": "Berichte",
|
"reports": "Berichte",
|
||||||
"documents": "Dokumente",
|
"documents": "Dokumente",
|
||||||
"settings": "Einstellungen",
|
"settings": "Einstellungen",
|
||||||
|
"notifications": "Benachrichtigungen",
|
||||||
|
"audit": "Audit-Protokoll",
|
||||||
|
"email": "E-Mail-Versand",
|
||||||
"admin": "Admin-Konsole"
|
"admin": "Admin-Konsole"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,5 +8,8 @@
|
|||||||
"reports": "Reports",
|
"reports": "Reports",
|
||||||
"documents": "Documents",
|
"documents": "Documents",
|
||||||
"settings": "Settings",
|
"settings": "Settings",
|
||||||
|
"notifications": "Notifications",
|
||||||
|
"audit": "Audit log",
|
||||||
|
"email": "E-mail delivery",
|
||||||
"admin": "Admin console"
|
"admin": "Admin console"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import { TenantRecoveryRegenForm } from "@/components/tenant-recovery-regen-form
|
|||||||
import { PasskeyManager } from "@/components/passkey-manager";
|
import { PasskeyManager } from "@/components/passkey-manager";
|
||||||
import { ChangePasswordSelfForm } from "@/components/auth-recovery-forms";
|
import { ChangePasswordSelfForm } from "@/components/auth-recovery-forms";
|
||||||
import { describePasswordPolicy, resolvePasswordPolicy } from "@/lib/password-policy";
|
import { describePasswordPolicy, resolvePasswordPolicy } from "@/lib/password-policy";
|
||||||
|
import { NotificationPreferencesSection } from "@/components/notifications/preferences-section";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Persönliches Konto des Mandanten-Nutzers (Paket C): optionale MFA selbst
|
* Persönliches Konto des Mandanten-Nutzers (Paket C): optionale MFA selbst
|
||||||
@@ -117,6 +118,8 @@ export default async function AccountPage() {
|
|||||||
Eine Änderung ist derzeit nur über die Plattform-Administration möglich.
|
Eine Änderung ist derzeit nur über die Plattform-Administration möglich.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<NotificationPreferencesSection />
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import { TenantBrand } from "@/components/brand/tenant-brand";
|
|||||||
import { TenantSwitcher } from "@/components/tenant-switcher";
|
import { TenantSwitcher } from "@/components/tenant-switcher";
|
||||||
import { UiLocaleSwitcher } from "@/components/ui-locale-switcher";
|
import { UiLocaleSwitcher } from "@/components/ui-locale-switcher";
|
||||||
import { CraftviaLogo } from "@/components/brand/craftvia-logo";
|
import { CraftviaLogo } from "@/components/brand/craftvia-logo";
|
||||||
|
import { NotificationBell } from "@/components/notifications/bell";
|
||||||
|
|
||||||
export default async function AppLayout({
|
export default async function AppLayout({
|
||||||
children,
|
children,
|
||||||
@@ -135,6 +136,7 @@ export default async function AppLayout({
|
|||||||
<header className="sticky top-0 z-10 flex items-center gap-4 border-b bg-[var(--panel)] px-6 py-2.5 backdrop-blur-md">
|
<header className="sticky top-0 z-10 flex items-center gap-4 border-b bg-[var(--panel)] px-6 py-2.5 backdrop-blur-md">
|
||||||
{/* TODO(craftvia): globale Suche (Aufträge/Kunden/Objekte) — Andockpunkt für die Fachmodule. */}
|
{/* TODO(craftvia): globale Suche (Aufträge/Kunden/Objekte) — Andockpunkt für die Fachmodule. */}
|
||||||
<div className="flex-1" />
|
<div className="flex-1" />
|
||||||
|
<NotificationBell />
|
||||||
<UiLocaleSwitcher current={identity.uiLocale} />
|
<UiLocaleSwitcher current={identity.uiLocale} />
|
||||||
<Link href="/account" className="flex items-center gap-3">
|
<Link href="/account" className="flex items-center gap-3">
|
||||||
<div className="text-right leading-tight">
|
<div className="text-right leading-tight">
|
||||||
|
|||||||
@@ -1,5 +1,134 @@
|
|||||||
import { ModulePlaceholder } from "@/components/module-placeholder";
|
import Link from "next/link";
|
||||||
|
import { redirect } from "next/navigation";
|
||||||
|
import { BellDot, CheckCheck } from "lucide-react";
|
||||||
|
import { getFormatter, getTranslations } from "next-intl/server";
|
||||||
|
import { EVENT_TYPES } from "@/lib/events";
|
||||||
|
import { PageHead, Pill } from "@/components/mockup-ui";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { markAllNotificationsRead, markNotificationRead, openNotification } from "@/server/actions/notifications/inbox";
|
||||||
|
import { listNotifications } from "@/server/services/notifications/inbox";
|
||||||
|
import { pageCtx } from "@/server/services/notifications/page-ctx";
|
||||||
|
import { eventKey } from "@/server/services/notifications/texts";
|
||||||
|
|
||||||
export default function Page() {
|
const selectCls = "h-11 rounded-md border border-input bg-card px-3 text-sm";
|
||||||
return <ModulePlaceholder moduleKey="notifications" />;
|
|
||||||
|
export default async function NotificationsPage({
|
||||||
|
searchParams,
|
||||||
|
}: {
|
||||||
|
searchParams: Promise<Record<string, string | string[] | undefined>>;
|
||||||
|
}) {
|
||||||
|
const ctx = await pageCtx();
|
||||||
|
if (!ctx.permissions.has("notification:read")) redirect("/dashboard");
|
||||||
|
const sp = await searchParams;
|
||||||
|
const one = (v: string | string[] | undefined) => (Array.isArray(v) ? v[0] : v);
|
||||||
|
const data = await listNotifications(ctx, { status: one(sp.status), type: one(sp.type), page: one(sp.page) });
|
||||||
|
const [t, format] = await Promise.all([getTranslations("notifications"), getFormatter()]);
|
||||||
|
|
||||||
|
const pageHref = (page: number) => {
|
||||||
|
const q = new URLSearchParams();
|
||||||
|
if (data.filter.status !== "all") q.set("status", data.filter.status);
|
||||||
|
if (data.filter.type) q.set("type", data.filter.type);
|
||||||
|
q.set("page", String(page));
|
||||||
|
return `/notifications?${q.toString()}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="flex-1 p-4 md:p-6">
|
||||||
|
<PageHead
|
||||||
|
crumb={t("list.crumb")}
|
||||||
|
title={t("list.title")}
|
||||||
|
sub={t("list.sub")}
|
||||||
|
actions={
|
||||||
|
<form action={markAllNotificationsRead}>
|
||||||
|
<Button type="submit" variant="outline" className="min-h-11">
|
||||||
|
<CheckCheck aria-hidden /> {t("list.markAllRead")}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<form method="get" className="shadow-card mb-4 flex flex-wrap items-end gap-3 rounded-xl border bg-card p-4">
|
||||||
|
<label className="flex flex-col gap-1 text-[12px] font-semibold text-muted-foreground">
|
||||||
|
{t("list.filterStatus")}
|
||||||
|
<select name="status" defaultValue={data.filter.status} className={selectCls}>
|
||||||
|
<option value="all">{t("list.statusAll")}</option>
|
||||||
|
<option value="unread">{t("list.statusUnread")}</option>
|
||||||
|
<option value="read">{t("list.statusRead")}</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label className="flex flex-col gap-1 text-[12px] font-semibold text-muted-foreground">
|
||||||
|
{t("list.filterType")}
|
||||||
|
<select name="type" defaultValue={data.filter.type ?? ""} className={selectCls}>
|
||||||
|
<option value="">{t("list.typeAll")}</option>
|
||||||
|
{EVENT_TYPES.map((type) => (
|
||||||
|
<option key={type} value={type}>
|
||||||
|
{t(`types.${eventKey(type)}`)}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<Button type="submit" className="min-h-11">{t("list.apply")}</Button>
|
||||||
|
<Link href="/notifications" className="flex min-h-11 items-center px-2 text-[13px] font-semibold text-muted-foreground hover:text-foreground">
|
||||||
|
{t("list.reset")}
|
||||||
|
</Link>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{data.rows.length === 0 ? (
|
||||||
|
<p className="shadow-card rounded-xl border bg-card p-6 text-sm text-muted-foreground">{t("list.empty")}</p>
|
||||||
|
) : (
|
||||||
|
<ul className="shadow-card divide-y rounded-xl border bg-card">
|
||||||
|
{data.rows.map((n) => (
|
||||||
|
<li key={n.id} className="flex flex-col gap-3 p-4 sm:flex-row sm:items-center">
|
||||||
|
<div className="flex min-w-0 flex-1 items-start gap-3">
|
||||||
|
{n.readAt ? (
|
||||||
|
<Pill tone="mut">{t("list.read")}</Pill>
|
||||||
|
) : (
|
||||||
|
<Pill tone="orange">
|
||||||
|
<BellDot className="size-3.5" aria-hidden /> {t("list.unread")}
|
||||||
|
</Pill>
|
||||||
|
)}
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className={n.readAt ? "text-sm font-medium" : "text-sm font-semibold"}>{n.title}</p>
|
||||||
|
<p className="text-[13px] text-muted-foreground">{n.message}</p>
|
||||||
|
<p className="mt-0.5 text-[11.5px] text-muted-foreground">
|
||||||
|
{EVENT_TYPES.includes(n.type as (typeof EVENT_TYPES)[number])
|
||||||
|
? t(`types.${eventKey(n.type as (typeof EVENT_TYPES)[number])}`)
|
||||||
|
: n.type}
|
||||||
|
{" · "}
|
||||||
|
{format.dateTime(n.createdAt, { dateStyle: "medium", timeStyle: "short" })}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex shrink-0 gap-2">
|
||||||
|
{!n.readAt && (
|
||||||
|
<form action={markNotificationRead}>
|
||||||
|
<input type="hidden" name="id" value={n.id} />
|
||||||
|
<Button type="submit" variant="ghost" className="min-h-11">{t("list.markRead")}</Button>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
{n.link && (
|
||||||
|
<form action={openNotification}>
|
||||||
|
<input type="hidden" name="id" value={n.id} />
|
||||||
|
<Button type="submit" variant="outline" className="min-h-11">{t("list.open")}</Button>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{data.pages > 1 && (
|
||||||
|
<nav className="mt-4 flex items-center justify-between text-[13px]" aria-label={t("list.title")}>
|
||||||
|
{data.page > 1 ? (
|
||||||
|
<Link href={pageHref(data.page - 1)} className="flex min-h-11 items-center font-semibold text-[var(--primary)]">← {t("list.previous")}</Link>
|
||||||
|
) : <span />}
|
||||||
|
<span className="text-muted-foreground">{t("list.page", { page: data.page, pages: data.pages })}</span>
|
||||||
|
{data.page < data.pages ? (
|
||||||
|
<Link href={pageHref(data.page + 1)} className="flex min-h-11 items-center font-semibold text-[var(--primary)]">{t("list.next")} →</Link>
|
||||||
|
) : <span />}
|
||||||
|
</nav>
|
||||||
|
)}
|
||||||
|
</main>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import { requireModule } from "@/server/modules";
|
||||||
|
|
||||||
|
/** Tenant mail settings belong to the "notifications" module (actions use its moduleGuard). */
|
||||||
|
export default async function MailSettingsLayout({ children }: Readonly<{ children: React.ReactNode }>) {
|
||||||
|
await requireModule("notifications");
|
||||||
|
return <>{children}</>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
import Link from "next/link";
|
||||||
|
import { redirect } from "next/navigation";
|
||||||
|
import { ArrowLeft } from "lucide-react";
|
||||||
|
import { getTranslations } from "next-intl/server";
|
||||||
|
import { PageHead } from "@/components/mockup-ui";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { getMailConfig } from "@/server/mail/config";
|
||||||
|
import { saveMailSettings } from "@/server/actions/notifications/mail-settings";
|
||||||
|
import { getMailSettings } from "@/server/services/notifications/mail-settings";
|
||||||
|
import { pageCtx } from "@/server/services/notifications/page-ctx";
|
||||||
|
|
||||||
|
const areaCls = "min-h-28 w-full rounded-md border border-input bg-transparent px-3 py-2 font-mono text-sm";
|
||||||
|
|
||||||
|
export default async function MailSettingsPage({ searchParams }: { searchParams: Promise<{ saved?: string; error?: string }> }) {
|
||||||
|
const ctx = await pageCtx();
|
||||||
|
if (!ctx.permissions.has("tenant:manage")) redirect("/dashboard");
|
||||||
|
const [sp, s, t] = await Promise.all([searchParams, getMailSettings(ctx), getTranslations("notifications")]);
|
||||||
|
const platformFrom = getMailConfig().config?.from ?? "—";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="flex-1 p-4 md:p-6">
|
||||||
|
<Link href="/settings" className="inline-flex min-h-11 items-center gap-1.5 text-[12.5px] font-semibold text-muted-foreground hover:text-foreground">
|
||||||
|
<ArrowLeft className="size-4" aria-hidden /> {t("mailSettings.back")}
|
||||||
|
</Link>
|
||||||
|
<PageHead crumb={t("mailSettings.crumb")} title={t("mailSettings.title")} sub={t("mailSettings.sub")} />
|
||||||
|
|
||||||
|
{sp.saved && (
|
||||||
|
<p role="status" className="mb-4 rounded-lg border border-[var(--ok)] bg-card px-4 py-3 text-sm text-[var(--ok)]">
|
||||||
|
✓ {t("mailSettings.saved")}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{sp.error && (
|
||||||
|
<p role="alert" className="mb-4 rounded-lg border border-[var(--risk)] bg-card px-4 py-3 text-sm text-[var(--risk)]">
|
||||||
|
⚠ {t("mailSettings.invalid", { detail: sp.error.split(",").map((f) => t.has(`mailSettings.${f}`) ? t(`mailSettings.${f}`) : f).join(", ") })}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<form action={saveMailSettings} className="grid max-w-4xl gap-5 lg:grid-cols-2">
|
||||||
|
<section className="shadow-card rounded-xl border bg-card p-5">
|
||||||
|
<h2 className="mb-3 font-heading text-sm font-semibold">{t("mailSettings.sender")}</h2>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="mailFromName">{t("mailSettings.fromName")}</Label>
|
||||||
|
<Input id="mailFromName" name="mailFromName" maxLength={100} defaultValue={s.mailFromName ?? ""} placeholder={s.orgName ?? ""} className="mt-1 h-11" />
|
||||||
|
<p className="mt-1 text-[11.5px] text-muted-foreground">{t("mailSettings.fromNameHint")}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label>{t("mailSettings.fromAddress")}</Label>
|
||||||
|
<p className="mt-1 text-[11.5px] text-muted-foreground">{t("mailSettings.fromAddressHint", { address: platformFrom })}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="mailReplyTo">{t("mailSettings.replyTo")}</Label>
|
||||||
|
<Input id="mailReplyTo" name="mailReplyTo" type="email" defaultValue={s.mailReplyTo ?? ""} className="mt-1 h-11" />
|
||||||
|
<p className="mt-1 text-[11.5px] text-muted-foreground">{t("mailSettings.replyToHint")}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="shadow-card rounded-xl border bg-card p-5">
|
||||||
|
<h2 className="mb-3 font-heading text-sm font-semibold">{t("mailSettings.recipients")}</h2>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="emergencyRecipients">{t("mailSettings.emergencyRecipients")}</Label>
|
||||||
|
<textarea id="emergencyRecipients" name="emergencyRecipients" defaultValue={s.emergencyRecipients.join("\n")} className={`${areaCls} mt-1`} />
|
||||||
|
<p className="mt-1 text-[11.5px] text-muted-foreground">{t("mailSettings.emergencyHint")}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="billingRecipients">{t("mailSettings.billingRecipients")}</Label>
|
||||||
|
<textarea id="billingRecipients" name="billingRecipients" defaultValue={s.billingRecipients.join("\n")} className={`${areaCls} mt-1`} />
|
||||||
|
<p className="mt-1 text-[11.5px] text-muted-foreground">{t("mailSettings.billingHint")}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div className="lg:col-span-2">
|
||||||
|
<Button type="submit" className="min-h-11">{t("mailSettings.save")}</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
import Link from "next/link";
|
||||||
|
import { Bell } from "lucide-react";
|
||||||
|
import { getFormatter, getTranslations } from "next-intl/server";
|
||||||
|
import { markAllNotificationsRead, openNotification } from "@/server/actions/notifications/inbox";
|
||||||
|
import { bellSummary } from "@/server/services/notifications/inbox";
|
||||||
|
import { isNotificationsModuleEnabled, pageCtx } from "@/server/services/notifications/page-ctx";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Notification bell for the back office header and the mobile header (L4 embeds
|
||||||
|
* `<NotificationBell variant="mobile" />`). Server component, no client JS: the dropdown is a
|
||||||
|
* native <details>. Hidden without `notification:read` or with the module disabled.
|
||||||
|
*/
|
||||||
|
export async function NotificationBell({ variant = "desktop" }: { variant?: "desktop" | "mobile" }) {
|
||||||
|
const ctx = await pageCtx();
|
||||||
|
if (!ctx.permissions.has("notification:read") || !(await isNotificationsModuleEnabled(ctx))) return null;
|
||||||
|
|
||||||
|
const [{ unread, latest }, t, format] = await Promise.all([bellSummary(ctx), getTranslations("notifications"), getFormatter()]);
|
||||||
|
const touch = variant === "mobile" ? "size-12" : "size-11";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<details className="group relative">
|
||||||
|
<summary
|
||||||
|
className={cn(
|
||||||
|
"relative grid cursor-pointer list-none place-items-center rounded-lg text-foreground hover:bg-muted focus-visible:ring-3 focus-visible:ring-ring/50 [&::-webkit-details-marker]:hidden",
|
||||||
|
touch,
|
||||||
|
)}
|
||||||
|
aria-label={`${t("bell.label")} – ${t("bell.unreadCount", { count: unread })}`}
|
||||||
|
>
|
||||||
|
<Bell className="size-5" aria-hidden />
|
||||||
|
{unread > 0 && (
|
||||||
|
<span
|
||||||
|
aria-hidden
|
||||||
|
className="absolute top-1 right-1 min-w-[18px] rounded-full bg-[var(--ui-accent)] px-1 text-center text-[10.5px] leading-[18px] font-bold text-[var(--ui-accent-foreground)]"
|
||||||
|
>
|
||||||
|
{unread > 99 ? "99+" : unread}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</summary>
|
||||||
|
|
||||||
|
<div className="shadow-card absolute right-0 z-40 mt-2 w-[min(24rem,calc(100vw-2rem))] rounded-xl border bg-card">
|
||||||
|
<div className="flex items-center justify-between gap-3 border-b px-4 py-3">
|
||||||
|
<div>
|
||||||
|
<p className="font-heading text-sm font-semibold">{t("bell.label")}</p>
|
||||||
|
<p className="text-xs text-muted-foreground">{t("bell.unreadCount", { count: unread })}</p>
|
||||||
|
</div>
|
||||||
|
{unread > 0 && (
|
||||||
|
<form action={markAllNotificationsRead}>
|
||||||
|
<button type="submit" className="min-h-11 rounded-md px-2 text-[12.5px] font-semibold text-[var(--primary)] hover:bg-muted">
|
||||||
|
{t("bell.markAllRead")}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{latest.length === 0 ? (
|
||||||
|
<p className="px-4 py-6 text-sm text-muted-foreground">{t("bell.empty")}</p>
|
||||||
|
) : (
|
||||||
|
<ul className="max-h-[60vh] divide-y overflow-y-auto">
|
||||||
|
{latest.map((n) => (
|
||||||
|
<li key={n.id}>
|
||||||
|
<form action={openNotification}>
|
||||||
|
<input type="hidden" name="id" value={n.id} />
|
||||||
|
<button type="submit" className="flex min-h-11 w-full items-start gap-2.5 px-4 py-2.5 text-left hover:bg-muted/60">
|
||||||
|
<span
|
||||||
|
aria-hidden
|
||||||
|
className={cn("mt-1.5 size-2 shrink-0 rounded-full", n.readAt ? "bg-transparent" : "bg-[var(--ui-accent)]")}
|
||||||
|
/>
|
||||||
|
<span className="min-w-0 flex-1">
|
||||||
|
<span className="flex items-center gap-2">
|
||||||
|
<span className={cn("truncate text-[13px]", n.readAt ? "font-medium" : "font-semibold")}>{n.title}</span>
|
||||||
|
{!n.readAt && <span className="shrink-0 text-[10.5px] font-bold text-[var(--primary)] uppercase">{t("list.unread")}</span>}
|
||||||
|
</span>
|
||||||
|
<span className="line-clamp-2 block text-[12px] text-muted-foreground">{n.message}</span>
|
||||||
|
<span className="block text-[11px] text-muted-foreground">
|
||||||
|
{format.dateTime(n.createdAt, { dateStyle: "short", timeStyle: "short" })}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="border-t px-4 py-2">
|
||||||
|
<Link href="/notifications" className="flex min-h-11 items-center justify-center text-[13px] font-semibold text-[var(--primary)]">
|
||||||
|
{t("bell.showAll")}
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useActionState } from "react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { saveNotificationPreferences, type PreferencesState } from "@/server/actions/notifications/preferences";
|
||||||
|
|
||||||
|
export type PreferenceItem = { type: string; label: string; email: boolean; mandatory: boolean };
|
||||||
|
|
||||||
|
/** E-mail opt-out per notification type (in-app notifications are always on). */
|
||||||
|
export function NotificationPreferencesForm({
|
||||||
|
items,
|
||||||
|
labels,
|
||||||
|
}: {
|
||||||
|
items: PreferenceItem[];
|
||||||
|
labels: { email: string; mandatory: string; save: string; saved: string; error: string };
|
||||||
|
}) {
|
||||||
|
const [state, action, pending] = useActionState(saveNotificationPreferences, { status: "idle" } as PreferencesState);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form action={action} className="space-y-3">
|
||||||
|
<ul className="divide-y rounded-lg border">
|
||||||
|
{items.map((item) => {
|
||||||
|
const id = `pref-${item.type}`;
|
||||||
|
return (
|
||||||
|
<li key={item.type} className="flex min-h-11 items-center justify-between gap-3 px-3 py-1.5">
|
||||||
|
<label htmlFor={id} className="flex-1 text-[13px]">
|
||||||
|
{item.label}
|
||||||
|
{item.mandatory && <span className="block text-[11.5px] text-muted-foreground">{item.mandatory ? labels.mandatory : null}</span>}
|
||||||
|
</label>
|
||||||
|
<span className="flex items-center gap-2 text-[12px] text-muted-foreground">
|
||||||
|
{labels.email}
|
||||||
|
<input
|
||||||
|
id={id}
|
||||||
|
type="checkbox"
|
||||||
|
name="emailOn"
|
||||||
|
value={item.type}
|
||||||
|
defaultChecked={item.email}
|
||||||
|
disabled={item.mandatory}
|
||||||
|
className="size-5 accent-[var(--primary)]"
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Button type="submit" disabled={pending} className="min-h-11">
|
||||||
|
{labels.save}
|
||||||
|
</Button>
|
||||||
|
<p role="status" className="text-[12.5px]">
|
||||||
|
{state.status === "saved" && <span className="text-[var(--ok)]">✓ {labels.saved}</span>}
|
||||||
|
{state.status === "error" && <span className="text-[var(--risk)]">⚠ {labels.error}</span>}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import { getTranslations } from "next-intl/server";
|
||||||
|
import { getPreferences } from "@/server/services/notifications/preferences";
|
||||||
|
import { isNotificationsModuleEnabled, pageCtx } from "@/server/services/notifications/page-ctx";
|
||||||
|
import { eventKey } from "@/server/services/notifications/texts";
|
||||||
|
import { NotificationPreferencesForm } from "./preferences-form";
|
||||||
|
|
||||||
|
/** "Benachrichtigungen" section of /account (self-contained: loads its own data). */
|
||||||
|
export async function NotificationPreferencesSection() {
|
||||||
|
const ctx = await pageCtx();
|
||||||
|
if (!ctx.permissions.has("notification:read") || !(await isNotificationsModuleEnabled(ctx))) return null;
|
||||||
|
const [prefs, t, tc] = await Promise.all([getPreferences(ctx), getTranslations("notifications"), getTranslations("common")]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="shadow-card mt-5 rounded-xl border bg-card p-5" aria-labelledby="notification-preferences">
|
||||||
|
<p id="notification-preferences" className="mb-1 font-heading text-sm font-semibold">
|
||||||
|
{t("preferences.title")}
|
||||||
|
</p>
|
||||||
|
<p className="mb-3 text-[12.5px] text-muted-foreground">{t("preferences.sub")}</p>
|
||||||
|
<NotificationPreferencesForm
|
||||||
|
items={prefs.map((p) => ({ type: p.type, label: t(`types.${eventKey(p.type)}`), email: p.email, mandatory: p.mandatory }))}
|
||||||
|
labels={{
|
||||||
|
email: t("preferences.email"),
|
||||||
|
mandatory: t("preferences.mandatory"),
|
||||||
|
save: t("preferences.save"),
|
||||||
|
saved: t("preferences.saved"),
|
||||||
|
error: tc("readOnly"),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -8,6 +8,9 @@ import {
|
|||||||
FileText,
|
FileText,
|
||||||
FolderOpen,
|
FolderOpen,
|
||||||
Settings,
|
Settings,
|
||||||
|
Bell,
|
||||||
|
History,
|
||||||
|
Mail,
|
||||||
type LucideIcon,
|
type LucideIcon,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import type { ModuleKey } from "@/lib/modules";
|
import type { ModuleKey } from "@/lib/modules";
|
||||||
@@ -49,7 +52,10 @@ export const NAV_ITEMS: readonly NavItem[] = [
|
|||||||
{ href: "/teams", label: "teams", icon: UsersRound, module: "teams", permissions: ["team:read"], section: "main" },
|
{ href: "/teams", label: "teams", icon: UsersRound, module: "teams", permissions: ["team:read"], section: "main" },
|
||||||
{ href: "/reports", label: "reports", icon: FileText, module: "reports", permissions: ["report:read"], section: "main" },
|
{ href: "/reports", label: "reports", icon: FileText, module: "reports", permissions: ["report:read"], section: "main" },
|
||||||
{ href: "/documents", label: "documents", icon: FolderOpen, module: "documents", permissions: ["document:read"], section: "main" },
|
{ href: "/documents", label: "documents", icon: FolderOpen, module: "documents", permissions: ["document:read"], section: "main" },
|
||||||
|
{ href: "/notifications", label: "notifications", icon: Bell, module: "notifications", permissions: ["notification:read"], section: "main" },
|
||||||
{ href: "/settings", label: "settings", icon: Settings, permissions: ["tenant:manage"], section: "admin" },
|
{ href: "/settings", label: "settings", icon: Settings, permissions: ["tenant:manage"], section: "admin" },
|
||||||
|
{ href: "/settings/email", label: "email", icon: Mail, module: "notifications", permissions: ["tenant:manage"], section: "admin" },
|
||||||
|
{ href: "/settings/audit", label: "audit", icon: History, permissions: ["audit:read"], section: "admin" },
|
||||||
];
|
];
|
||||||
|
|
||||||
/** Filtert die Navigation nach aktiven Modulen und Rechten der Session. */
|
/** Filtert die Navigation nach aktiven Modulen und Rechten der Session. */
|
||||||
|
|||||||
@@ -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