From 879012415e6f2d1c4b0d9bf054f9343e6d5abe62 Mon Sep 17 00:00:00 2001 From: Martin Date: Mon, 14 Sep 2026 12:12:21 +0200 Subject: [PATCH] L6 Benachrichtigungen & Audit: Glocke, Posteingang, Nutzer-Einstellungen, Mailkonfiguration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- messages/de/nav.json | 3 + messages/en/nav.json | 3 + src/app/(app)/account/page.tsx | 3 + src/app/(app)/layout.tsx | 2 + src/app/(app)/notifications/page.tsx | 135 +++++++++++++++++- src/app/(app)/settings/email/layout.tsx | 7 + src/app/(app)/settings/email/page.tsx | 83 +++++++++++ src/components/notifications/bell.tsx | 94 ++++++++++++ .../notifications/preferences-form.tsx | 57 ++++++++ .../notifications/preferences-section.tsx | 31 ++++ src/lib/nav.ts | 6 + src/server/actions/notifications/inbox.ts | 36 +++++ .../actions/notifications/mail-settings.ts | 30 ++++ .../actions/notifications/preferences.ts | 23 +++ src/server/services/notifications/inbox.ts | 103 +++++++++++++ .../services/notifications/mail-settings.ts | 96 +++++++++++++ src/server/services/notifications/page-ctx.ts | 26 ++++ .../services/notifications/preferences.ts | 58 ++++++++ 18 files changed, 793 insertions(+), 3 deletions(-) create mode 100644 src/app/(app)/settings/email/layout.tsx create mode 100644 src/app/(app)/settings/email/page.tsx create mode 100644 src/components/notifications/bell.tsx create mode 100644 src/components/notifications/preferences-form.tsx create mode 100644 src/components/notifications/preferences-section.tsx create mode 100644 src/server/actions/notifications/inbox.ts create mode 100644 src/server/actions/notifications/mail-settings.ts create mode 100644 src/server/actions/notifications/preferences.ts create mode 100644 src/server/services/notifications/inbox.ts create mode 100644 src/server/services/notifications/mail-settings.ts create mode 100644 src/server/services/notifications/page-ctx.ts create mode 100644 src/server/services/notifications/preferences.ts diff --git a/messages/de/nav.json b/messages/de/nav.json index a3ecd70..c9996f9 100644 --- a/messages/de/nav.json +++ b/messages/de/nav.json @@ -8,5 +8,8 @@ "reports": "Berichte", "documents": "Dokumente", "settings": "Einstellungen", + "notifications": "Benachrichtigungen", + "audit": "Audit-Protokoll", + "email": "E-Mail-Versand", "admin": "Admin-Konsole" } diff --git a/messages/en/nav.json b/messages/en/nav.json index 8a208a6..ebd2d6e 100644 --- a/messages/en/nav.json +++ b/messages/en/nav.json @@ -8,5 +8,8 @@ "reports": "Reports", "documents": "Documents", "settings": "Settings", + "notifications": "Notifications", + "audit": "Audit log", + "email": "E-mail delivery", "admin": "Admin console" } diff --git a/src/app/(app)/account/page.tsx b/src/app/(app)/account/page.tsx index 77f1aea..0333a75 100644 --- a/src/app/(app)/account/page.tsx +++ b/src/app/(app)/account/page.tsx @@ -12,6 +12,7 @@ import { TenantRecoveryRegenForm } from "@/components/tenant-recovery-regen-form import { PasskeyManager } from "@/components/passkey-manager"; import { ChangePasswordSelfForm } from "@/components/auth-recovery-forms"; 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 @@ -117,6 +118,8 @@ export default async function AccountPage() { Eine Änderung ist derzeit nur über die Plattform-Administration möglich.

+ + ); diff --git a/src/app/(app)/layout.tsx b/src/app/(app)/layout.tsx index 9b5527e..1d7808c 100644 --- a/src/app/(app)/layout.tsx +++ b/src/app/(app)/layout.tsx @@ -13,6 +13,7 @@ import { TenantBrand } from "@/components/brand/tenant-brand"; import { TenantSwitcher } from "@/components/tenant-switcher"; import { UiLocaleSwitcher } from "@/components/ui-locale-switcher"; import { CraftviaLogo } from "@/components/brand/craftvia-logo"; +import { NotificationBell } from "@/components/notifications/bell"; export default async function AppLayout({ children, @@ -135,6 +136,7 @@ export default async function AppLayout({
{/* TODO(craftvia): globale Suche (Aufträge/Kunden/Objekte) — Andockpunkt für die Fachmodule. */}
+
diff --git a/src/app/(app)/notifications/page.tsx b/src/app/(app)/notifications/page.tsx index dceecbf..498ae23 100644 --- a/src/app/(app)/notifications/page.tsx +++ b/src/app/(app)/notifications/page.tsx @@ -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() { - return ; +const selectCls = "h-11 rounded-md border border-input bg-card px-3 text-sm"; + +export default async function NotificationsPage({ + searchParams, +}: { + searchParams: Promise>; +}) { + 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 ( +
+ + + + } + /> + +
+ + + + + {t("list.reset")} + +
+ + {data.rows.length === 0 ? ( +

{t("list.empty")}

+ ) : ( +
    + {data.rows.map((n) => ( +
  • +
    + {n.readAt ? ( + {t("list.read")} + ) : ( + + {t("list.unread")} + + )} +
    +

    {n.title}

    +

    {n.message}

    +

    + {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" })} +

    +
    +
    +
    + {!n.readAt && ( +
    + + +
    + )} + {n.link && ( +
    + + +
    + )} +
    +
  • + ))} +
+ )} + + {data.pages > 1 && ( + + )} +
+ ); } diff --git a/src/app/(app)/settings/email/layout.tsx b/src/app/(app)/settings/email/layout.tsx new file mode 100644 index 0000000..dcc4d43 --- /dev/null +++ b/src/app/(app)/settings/email/layout.tsx @@ -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}; +} diff --git a/src/app/(app)/settings/email/page.tsx b/src/app/(app)/settings/email/page.tsx new file mode 100644 index 0000000..afad8b3 --- /dev/null +++ b/src/app/(app)/settings/email/page.tsx @@ -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 ( +
+ + {t("mailSettings.back")} + + + + {sp.saved && ( +

+ ✓ {t("mailSettings.saved")} +

+ )} + {sp.error && ( +

+ ⚠ {t("mailSettings.invalid", { detail: sp.error.split(",").map((f) => t.has(`mailSettings.${f}`) ? t(`mailSettings.${f}`) : f).join(", ") })} +

+ )} + +
+
+

{t("mailSettings.sender")}

+
+
+ + +

{t("mailSettings.fromNameHint")}

+
+
+ +

{t("mailSettings.fromAddressHint", { address: platformFrom })}

+
+
+ + +

{t("mailSettings.replyToHint")}

+
+
+
+ +
+

{t("mailSettings.recipients")}

+
+
+ +