From 7c5b0cc40f440995f49ca47268b3d7a48c8737e4 Mon Sep 17 00:00:00 2001 From: Rajpreet0 Date: Fri, 28 Aug 2026 18:20:28 +0200 Subject: [PATCH] added in-app notification system --- .../migration.sql | 22 +++ prisma/schema.prisma | 62 +++++--- .../(main)/settings/notifications/page.tsx | 23 ++- src/app/api/stripe/webhook/route.ts | 54 +++++++ .../courses/actions/cancel-booking-shared.ts | 30 ++++ .../courses/actions/cancel-course-shared.ts | 33 +++++ .../courses/actions/notify-waitlist.ts | 25 +++- .../ui/components/DashboardNavbar.tsx | 5 + .../actions/notification-actions.ts | 121 +++++++++++++++ .../notifications/lib/create-notification.ts | 102 +++++++++++++ .../notifications/lib/notification-types.ts | 92 ++++++++++++ .../ui/components/NotificationBell.tsx | 138 ++++++++++++++++++ .../ui/components/NotificationItem.tsx | 62 ++++++++ .../ui/components/NotificationTypeIcon.tsx | 38 +++++ .../actions/notification-settings-actions.ts | 95 ++++++++++++ .../views/settings-notification-page-view.tsx | 132 ++++++++++++++++- .../notifications/create-notification.test.ts | 125 ++++++++++++++++ .../notification-actions.test.ts | 109 ++++++++++++++ .../notification-settings-actions.test.ts | 88 +++++++++++ .../notifications/notification-types.test.ts | 54 +++++++ 20 files changed, 1381 insertions(+), 29 deletions(-) create mode 100644 prisma/migrations/20260828160127_add_notifications/migration.sql create mode 100644 src/modules/notifications/actions/notification-actions.ts create mode 100644 src/modules/notifications/lib/create-notification.ts create mode 100644 src/modules/notifications/lib/notification-types.ts create mode 100644 src/modules/notifications/ui/components/NotificationBell.tsx create mode 100644 src/modules/notifications/ui/components/NotificationItem.tsx create mode 100644 src/modules/notifications/ui/components/NotificationTypeIcon.tsx create mode 100644 src/modules/settings/actions/notification-settings-actions.ts create mode 100644 tests/notifications/create-notification.test.ts create mode 100644 tests/notifications/notification-actions.test.ts create mode 100644 tests/notifications/notification-settings-actions.test.ts create mode 100644 tests/notifications/notification-types.test.ts diff --git a/prisma/migrations/20260828160127_add_notifications/migration.sql b/prisma/migrations/20260828160127_add_notifications/migration.sql new file mode 100644 index 0000000..a0766a1 --- /dev/null +++ b/prisma/migrations/20260828160127_add_notifications/migration.sql @@ -0,0 +1,22 @@ +-- AlterTable +ALTER TABLE "Business" ADD COLUMN "notificationSettings" JSONB; + +-- CreateTable +CREATE TABLE "Notification" ( + "id" TEXT NOT NULL, + "recipientId" TEXT NOT NULL, + "type" TEXT NOT NULL, + "title" TEXT NOT NULL, + "body" TEXT NOT NULL, + "linkUrl" TEXT, + "readAt" TIMESTAMP(3), + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "Notification_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE INDEX "Notification_recipientId_readAt_idx" ON "Notification"("recipientId", "readAt"); + +-- CreateIndex +CREATE INDEX "Notification_recipientId_createdAt_idx" ON "Notification"("recipientId", "createdAt"); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 67dc5d5..fb0d1f7 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -97,27 +97,28 @@ model Manager { } model Business { - id String @id @default(uuid()) - managerId String - name String - address String - email String - title String - ustId String? - banking String? - plan String? - subscription Json? - slug String? @unique - isPublic Boolean @default(false) - stripeAccountId String? @unique // Stripe Connect Account ID - stripeAccountStatus String? // "pending" | "active" | "restricted" - invoiceCounter Int @default(0) // Fortlaufender Zähler für Rechnungsnummern - cancellationPolicy Json? // { tiers: [{ hoursBeforeStart: number, refundPercent: number }, ...] }, absteigend sortiert. null = immer 100% Rückerstattung. - branding Json? // { companyName, logoUrl, primaryColor, accentColor }. null = Kumasy-Standardbranding (siehe KUMASY_BRANDING). - manager Manager @relation(fields: [managerId], references: [id]) - courses Course[] - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt + id String @id @default(uuid()) + managerId String + name String + address String + email String + title String + ustId String? + banking String? + plan String? + subscription Json? + slug String? @unique + isPublic Boolean @default(false) + stripeAccountId String? @unique // Stripe Connect Account ID + stripeAccountStatus String? // "pending" | "active" | "restricted" + invoiceCounter Int @default(0) // Fortlaufender Zähler für Rechnungsnummern + cancellationPolicy Json? // { tiers: [{ hoursBeforeStart: number, refundPercent: number }, ...] }, absteigend sortiert. null = immer 100% Rückerstattung. + branding Json? // { companyName, logoUrl, primaryColor, accentColor }. null = Kumasy-Standardbranding (siehe KUMASY_BRANDING). + notificationSettings Json? // { "booking.created": true, ... } Opt-out pro In-App-Benachrichtigungstyp. null / fehlender Key = Registry-Default (siehe NOTIFICATION_TYPES). + manager Manager @relation(fields: [managerId], references: [id]) + courses Course[] + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt @@index([managerId]) } @@ -225,3 +226,22 @@ model CourseWaitlistEntry { @@index([courseId]) @@index([userId]) } + +// In-App-Benachrichtigungen für die Studio-Seite (Manager/Employee). +// recipientId ist bewusst polymorph ohne Relation (wie Employee.createdBy / +// CourseBooking.cancelledBy): Empfänger kann je nach Typ Manager oder Employee +// sein, perspektivisch auch User. Keine FK, damit kein Modell-Join nötig ist +// und ein späterer Kunden-Empfänger keine Migration braucht. +model Notification { + id String @id @default(uuid()) + recipientId String // Manager- oder Employee-ID + type String // Schlüssel aus NOTIFICATION_TYPES (src/modules/notifications/lib/notification-types.ts) + title String + body String + linkUrl String? // Ziel beim Klick, z.B. /courses/edit/ + readAt DateTime? // null = ungelesen + createdAt DateTime @default(now()) + + @@index([recipientId, readAt]) + @@index([recipientId, createdAt]) +} diff --git a/src/app/(main)/settings/notifications/page.tsx b/src/app/(main)/settings/notifications/page.tsx index 8700a13..ddd9171 100644 --- a/src/app/(main)/settings/notifications/page.tsx +++ b/src/app/(main)/settings/notifications/page.tsx @@ -1,13 +1,32 @@ import { Suspense } from "react"; +import { AlertTriangle } from "lucide-react"; import { requireManager } from "@/src/lib/auth/getUser"; +import { getNotificationSettings } from "@/src/modules/settings/actions/notification-settings-actions"; +import { resolveNotificationSettings } from "@/src/modules/notifications/lib/notification-types"; import SettingsNotificationPageView from "@/src/modules/settings/ui/views/settings-notification-page-view"; import SettingsLoading from "../loading"; export default async function SettingsNotificationPage() { - await requireManager(); + const manager = await requireManager(); + const businesses = manager.businesses.map((b) => ({ id: b.id, name: b.name })); + + const firstBusinessId = businesses[0]?.id; + const result = firstBusinessId ? await getNotificationSettings(firstBusinessId) : null; + + if (firstBusinessId && !result?.success) { + return ( +
+ +

{result?.error ?? "Benachrichtigungseinstellungen konnten nicht geladen werden."}

+
+ ); + } + + const initialSettings = result?.success ? result.settings : resolveNotificationSettings(null); + return ( }> - + ); } diff --git a/src/app/api/stripe/webhook/route.ts b/src/app/api/stripe/webhook/route.ts index a85c646..04bb8cc 100644 --- a/src/app/api/stripe/webhook/route.ts +++ b/src/app/api/stripe/webhook/route.ts @@ -11,6 +11,7 @@ import { type BookingRefundReason, } from "@/src/lib/mail/templates/booking-refund"; import { toEmailBranding } from "@/src/modules/settings/lib/to-email-branding"; +import { notifyBusinessManager } from "@/src/modules/notifications/lib/create-notification"; /** * Sucht einen registrierten User anhand der E-Mail, case-insensitive. @@ -315,6 +316,19 @@ async function handleCheckoutCompleted(session: Stripe.Checkout.Session) { await removeFromWaitlistIfPresent(courseId, outcome.bookingId).catch((err) => console.error("Warteliste-Aufräumen fehlgeschlagen:", err) ); + + // In-App-Benachrichtigung ans Studio - blockiert den Webhook nicht. + const customerLabel = + session.customer_details?.name?.trim() || + session.customer_email || + session.customer_details?.email || + "Ein Kunde"; + await withTimeout( + notifyManagerOfBooking(courseId, customerLabel).catch((err) => + console.error("Buchungs-Benachrichtigung fehlgeschlagen:", err) + ), + 2000 + ); } revalidatePath("/courses"); @@ -678,6 +692,46 @@ async function removeFromWaitlistIfPresent(courseId: string, bookingId: string): await prisma.courseWaitlistEntry.deleteMany({ where: { courseId, userId: booking.userId } }); } +/** + * Legt für den Studio-Manager eine In-App-Benachrichtigung über eine neue, + * bezahlte Buchung an - und zusätzlich eine "Kurs ausgebucht"-Benachrichtigung, + * falls diese Buchung den Kurs gefüllt hat. Wirft nie (notifyBusinessManager + * kapselt alle Fehler), der Aufrufer hängt zusätzlich ein .catch daran. + */ +async function notifyManagerOfBooking(courseId: string, customerLabel: string): Promise { + const course = await prisma.course.findUnique({ + where: { id: courseId }, + select: { name: true, date: true, businessId: true, maxParticipants: true }, + }); + if (!course?.businessId) return; + + const courseDate = course.date.toLocaleDateString("de-DE", { + day: "numeric", + month: "long", + timeZone: "Europe/Berlin", + }); + const linkUrl = `/courses/edit/${courseId}`; + + await notifyBusinessManager(course.businessId, { + type: "booking.created", + title: `Neue Buchung: ${course.name}`, + body: `${customerLabel} hat sich für den ${courseDate} angemeldet.`, + linkUrl, + }); + + const paidCount = await prisma.courseBooking.count({ + where: { courseId, paymentStatus: "paid" }, + }); + if (paidCount >= course.maxParticipants) { + await notifyBusinessManager(course.businessId, { + type: "course.full", + title: `Kurs ausgebucht: ${course.name}`, + body: `Der Kurs am ${courseDate} hat mit ${paidCount} Teilnehmern die maximale Kapazität erreicht.`, + linkUrl, + }); + } +} + async function sendBookingRefund( recipientEmail: string, courseId: string, diff --git a/src/modules/courses/actions/cancel-booking-shared.ts b/src/modules/courses/actions/cancel-booking-shared.ts index ffd1e2d..2efdb0f 100644 --- a/src/modules/courses/actions/cancel-booking-shared.ts +++ b/src/modules/courses/actions/cancel-booking-shared.ts @@ -5,6 +5,7 @@ import { generateBookingRefundEmail } from "@/src/lib/mail/templates/booking-ref import { combineDateAndTime, calculateRefundAmountCents } from "./cancellation-policy"; import { toEmailBranding } from "@/src/modules/settings/lib/to-email-branding"; import { notifyCourseWaitlist } from "./notify-waitlist"; +import { notifyBusinessManager } from "@/src/modules/notifications/lib/create-notification"; export type CancelBookingResult = | { success: true; refundedAmountCents: number; refundPercent: number } @@ -235,6 +236,35 @@ export async function executeBookingCancellation( ).catch((err) => console.error("Storno-Mail fehlgeschlagen:", err)); await withTimeout(emailPromise, 2000); + // In-App-Benachrichtigung ans Studio nur bei einer echten Selbst-Stornierung + // durch den Kunden. Manager-initiierte Stornos (cancelledBy === "manager") + // und der forceFullRefund-Pfad der Kurs-weiten Absage lösen hier bewusst + // nichts aus - für Letztere gibt es eine eigene "course.cancelled"- + // Benachrichtigung in executeCourseCancellation. + if (cancelledBy === "user" && !forceFullRefund) { + const notifyPromise = (async () => { + const customer = booking.userId + ? await prisma.user.findUnique({ + where: { id: booking.userId }, + select: { name: true, email: true }, + }) + : null; + const customerLabel = customer?.name || customer?.email || "Ein Kunde"; + const courseDate = booking.course.date.toLocaleDateString("de-DE", { + day: "numeric", + month: "long", + timeZone: "Europe/Berlin", + }); + await notifyBusinessManager(booking.course.businessId, { + type: "booking.cancelled", + title: `Stornierung: ${booking.course.name}`, + body: `${customerLabel} hat die Buchung für den ${courseDate} storniert.`, + linkUrl: `/courses/edit/${booking.course.id}`, + }); + })().catch((err) => console.error("Storno-Benachrichtigung fehlgeschlagen:", err)); + await withTimeout(notifyPromise, 2000); + } + // Ein frei gewordener Platz soll allen Wartelisten-Einträgen des Kurses // gemeldet werden - notifyCourseWaitlist prüft selbst frisch, ob der Kurs // noch veröffentlicht ist (bricht bei einer Kurs-weiten Absage von selbst ab). diff --git a/src/modules/courses/actions/cancel-course-shared.ts b/src/modules/courses/actions/cancel-course-shared.ts index 016c8ef..0c037d0 100644 --- a/src/modules/courses/actions/cancel-course-shared.ts +++ b/src/modules/courses/actions/cancel-course-shared.ts @@ -4,6 +4,7 @@ import { prisma } from "@/src/lib/prisma"; import { getUserData, isManager, isEmployee, getEffectiveManagerId } from "@/src/lib/auth/getUser"; import { revalidatePath, revalidateTag } from "next/cache"; import { executeBookingCancellation } from "./cancel-booking-shared"; +import { notifyBusinessManager } from "@/src/modules/notifications/lib/create-notification"; const COURSE_CANCELLATION_REASON = "Kurs abgesagt"; @@ -89,6 +90,11 @@ async function executeCourseCancellation(courseId: string): Promise 0 + ? `${cancelled} Buchung${cancelled === 1 ? "" : "en"} wurde${ + cancelled === 1 ? "" : "n" + } storniert und erstattet.` + : "Es waren keine bezahlten Buchungen betroffen."; + await notifyBusinessManager(course.businessId, { + type: "course.cancelled", + title: `Kurs abgesagt: ${course.name}`, + body: `Der Termin am ${courseDate} wurde abgesagt. ${refundInfo}${ + failed > 0 + ? ` ${failed} Erstattung(en) fehlgeschlagen – bitte im Stripe-Dashboard prüfen.` + : "" + }`, + linkUrl: `/courses/edit/${courseId}`, + }); + } + return { success: true, cancelled, failed, errors }; } diff --git a/src/modules/courses/actions/notify-waitlist.ts b/src/modules/courses/actions/notify-waitlist.ts index 8235eeb..352cf74 100644 --- a/src/modules/courses/actions/notify-waitlist.ts +++ b/src/modules/courses/actions/notify-waitlist.ts @@ -20,6 +20,7 @@ import { prisma } from "@/src/lib/prisma"; import { sendMail } from "@/src/lib/mail/resend"; import { generateWaitlistSpotAvailableEmail } from "@/src/lib/mail/templates/waitlist-spot-available"; import { toEmailBranding } from "@/src/modules/settings/lib/to-email-branding"; +import { notifyBusinessManager } from "@/src/modules/notifications/lib/create-notification"; export async function notifyCourseWaitlist(courseId: string): Promise { // Kurs frisch nachladen statt einem durchgereichten Flag zu vertrauen - @@ -31,7 +32,7 @@ export async function notifyCourseWaitlist(courseId: string): Promise { select: { status: true, name: true, - business: { select: { name: true, branding: true, slug: true } }, + business: { select: { id: true, name: true, branding: true, slug: true } }, }, }); @@ -86,7 +87,7 @@ export async function notifyCourseWaitlist(courseId: string): Promise { // Promise.allSettled statt Promise.all - ein fehlgeschlagener Versand // (z.B. ungültige/inaktive Adresse) soll die anderen Empfänger nicht blockieren. - await Promise.allSettled( + const results = await Promise.allSettled( recipients.map(async (r) => { const result = await sendMail({ to: r.email, subject, html, text }); // notifiedAt nur bei tatsächlich erfolgreichem Versand setzen - sonst @@ -99,9 +100,25 @@ export async function notifyCourseWaitlist(courseId: string): Promise { where: { id: r.entryId }, data: { notifiedAt: new Date() }, }); - } else { - console.error(`Wartelisten-Mail an ${r.email} fehlgeschlagen:`, result.error); + return true; } + console.error(`Wartelisten-Mail an ${r.email} fehlgeschlagen:`, result.error); + return false; }) ); + + // In-App-Benachrichtigung ans Studio: wie viele Wartende wurden über den + // frei gewordenen Platz informiert. Wirft nie und läuft nach dem Fan-out, + // damit ein präziser Zähler zur Verfügung steht. + const notifiedCount = results.filter((r) => r.status === "fulfilled" && r.value === true).length; + if (notifiedCount > 0) { + await notifyBusinessManager(course.business.id, { + type: "waitlist.spot", + title: `Wartelisten-Platz frei: ${course.name}`, + body: `Ein Platz ist frei geworden. ${notifiedCount} Wartende${ + notifiedCount === 1 ? "r wurde" : " wurden" + } benachrichtigt – wer zuerst bucht, bekommt den Platz.`, + linkUrl: `/courses/edit/${courseId}`, + }); + } } diff --git a/src/modules/dashboard/ui/components/DashboardNavbar.tsx b/src/modules/dashboard/ui/components/DashboardNavbar.tsx index d646162..70224f3 100644 --- a/src/modules/dashboard/ui/components/DashboardNavbar.tsx +++ b/src/modules/dashboard/ui/components/DashboardNavbar.tsx @@ -4,6 +4,7 @@ import { Button } from "@/src/components/ui/button"; import { useSidebar } from "@/src/components/ui/sidebar"; import { PanelLeftCloseIcon, PanelLeftIcon } from "lucide-react"; import { usePathname } from "next/navigation"; +import NotificationBell from "@/src/modules/notifications/ui/components/NotificationBell"; const menuTitles: Record = { "/dashboard": "Dashboard", @@ -69,6 +70,10 @@ const DashboardNavbar = () => {

{pageTitle}

+ +
+ +
); diff --git a/src/modules/notifications/actions/notification-actions.ts b/src/modules/notifications/actions/notification-actions.ts new file mode 100644 index 0000000..69fed44 --- /dev/null +++ b/src/modules/notifications/actions/notification-actions.ts @@ -0,0 +1,121 @@ +"use server"; + +import { prisma } from "@/src/lib/prisma"; +import { requireAuthWithData, isManager, isEmployee } from "@/src/lib/auth/getUser"; +import type { NotificationType } from "../lib/notification-types"; + +const MAX_ITEMS = 20; + +export interface NotificationDTO { + id: string; + type: NotificationType; + title: string; + body: string; + linkUrl: string | null; + readAt: string | null; + createdAt: string; +} + +export interface GetNotificationsResult { + items: NotificationDTO[]; + unreadCount: number; +} + +const EMPTY: GetNotificationsResult = { items: [], unreadCount: 0 }; + +/** + * Lädt die letzten Benachrichtigungen des eingeloggten Nutzers plus die + * Anzahl ungelesener. Nur die Studio-Seite (Manager/Employee) hat ein + * Notification-Center - für die User-Rolle (oder ohne Session) wird bewusst + * ein leeres Ergebnis zurückgegeben, damit die Bell-Komponente ohne + * Sonderfall gerendert werden kann. + */ +export async function getNotifications(): Promise { + let userData; + try { + userData = await requireAuthWithData(); + } catch { + return EMPTY; + } + + if (!isManager(userData) && !isEmployee(userData)) return EMPTY; + + try { + const [items, unreadCount] = await Promise.all([ + prisma.notification.findMany({ + where: { recipientId: userData.id }, + orderBy: { createdAt: "desc" }, + take: MAX_ITEMS, + }), + prisma.notification.count({ + where: { recipientId: userData.id, readAt: null }, + }), + ]); + + return { + items: items.map((n) => ({ + id: n.id, + type: n.type as NotificationType, + title: n.title, + body: n.body, + linkUrl: n.linkUrl, + readAt: n.readAt?.toISOString() ?? null, + createdAt: n.createdAt.toISOString(), + })), + unreadCount, + }; + } catch (error) { + console.error("getNotifications fehlgeschlagen:", error); + return EMPTY; + } +} + +/** + * Markiert eine einzelne Benachrichtigung als gelesen. Die recipientId steht + * in der WHERE-Bedingung (updateMany), damit niemand fremde Benachrichtigungen + * manipulieren kann - ein Treffer 0 ist kein Fehler. + */ +export async function markNotificationRead(id: string): Promise<{ success: boolean }> { + let userData; + try { + userData = await requireAuthWithData(); + } catch { + return { success: false }; + } + if (!isManager(userData) && !isEmployee(userData)) return { success: false }; + + try { + await prisma.notification.updateMany({ + where: { id, recipientId: userData.id, readAt: null }, + data: { readAt: new Date() }, + }); + return { success: true }; + } catch (error) { + console.error("markNotificationRead fehlgeschlagen:", error); + return { success: false }; + } +} + +/** + * Markiert alle ungelesenen Benachrichtigungen des Nutzers als gelesen. + */ +export async function markAllNotificationsRead(): Promise<{ success: boolean }> { + let userData; + try { + userData = await requireAuthWithData(); + } catch { + return { success: false }; + } + if (!isManager(userData) && !isEmployee(userData)) return { success: false }; + + try { + await prisma.notification.updateMany({ + where: { recipientId: userData.id, readAt: null }, + data: { readAt: new Date() }, + }); + return { success: true }; + } catch (error) { + console.error("markAllNotificationsRead fehlgeschlagen:", error); + return { success: false }; + } +} diff --git a/src/modules/notifications/lib/create-notification.ts b/src/modules/notifications/lib/create-notification.ts new file mode 100644 index 0000000..a162adb --- /dev/null +++ b/src/modules/notifications/lib/create-notification.ts @@ -0,0 +1,102 @@ +/** + * Kern-Helfer zum Erzeugen von In-App-Benachrichtigungen. + * + * Prinzipien: + * - Wirft NIE. Ein fehlgeschlagenes Insert (DB weg, o.ä.) darf niemals eine + * Buchung, Stornierung oder Kursabsage kippen. Alles in try/catch, Fehler + * landen nur im Log - gleiches Pattern wie der Mailversand. + * - Respektiert die pro-Business-Opt-outs aus Business.notificationSettings, + * wenn ein businessId übergeben wird. Ohne businessId (kein Business-Kontext) + * wird immer zugestellt. + * - Empfänger ist die Manager-ID des Business (kein Employee-Fanout im MVP). + */ + +import { prisma } from "@/src/lib/prisma"; +import { + NOTIFICATION_TYPES, + resolveNotificationSettings, + type NotificationType, +} from "./notification-types"; + +interface CreateNotificationInput { + recipientId: string; + type: NotificationType; + title: string; + body: string; + linkUrl?: string; +} + +/** + * Prüft, ob ein Benachrichtigungstyp für ein Business aktiv ist. + * Fällt bei fehlendem Business / fehlendem Key auf den Registry-Default zurück. + */ +export async function isNotificationEnabled( + businessId: string, + type: NotificationType +): Promise { + try { + const business = await prisma.business.findUnique({ + where: { id: businessId }, + select: { notificationSettings: true }, + }); + if (!business) return NOTIFICATION_TYPES[type].defaultOn; + return resolveNotificationSettings(business.notificationSettings)[type]; + } catch (error) { + console.error("isNotificationEnabled fehlgeschlagen:", error); + // Im Zweifel zustellen - eine verpasste Benachrichtigung ist schlimmer + // als eine, die trotz Opt-out durchrutscht. + return NOTIFICATION_TYPES[type].defaultOn; + } +} + +/** + * Legt eine einzelne Benachrichtigung an. Gibt true zurück, wenn ein Datensatz + * geschrieben wurde, false wenn übersprungen (Opt-out) oder fehlgeschlagen. + */ +export async function createNotification(input: CreateNotificationInput): Promise { + try { + await prisma.notification.create({ + data: { + recipientId: input.recipientId, + type: input.type, + title: input.title, + body: input.body, + linkUrl: input.linkUrl ?? null, + }, + }); + return true; + } catch (error) { + console.error("createNotification fehlgeschlagen:", error); + return false; + } +} + +/** + * Legt eine Benachrichtigung für den Manager eines Business an - sofern der + * Typ für dieses Business nicht abgeschaltet wurde. + * + * Bewusst fire-and-forget-tauglich: Rückgabe wird von den Aufrufern (Webhook, + * Storno-Action, ...) nicht ausgewertet, der Aufruf hängt an einem + * .catch(console.error) und blockiert den Request nicht. + */ +export async function notifyBusinessManager( + businessId: string | null | undefined, + input: Omit +): Promise { + try { + if (!businessId) return; + + const business = await prisma.business.findUnique({ + where: { id: businessId }, + select: { managerId: true, notificationSettings: true }, + }); + if (!business) return; + + const enabled = resolveNotificationSettings(business.notificationSettings)[input.type]; + if (!enabled) return; + + await createNotification({ ...input, recipientId: business.managerId }); + } catch (error) { + console.error("notifyBusinessManager fehlgeschlagen:", error); + } +} diff --git a/src/modules/notifications/lib/notification-types.ts b/src/modules/notifications/lib/notification-types.ts new file mode 100644 index 0000000..9088ee2 --- /dev/null +++ b/src/modules/notifications/lib/notification-types.ts @@ -0,0 +1,92 @@ +/** + * Registry aller In-App-Benachrichtigungstypen. + * + * Single Source of Truth für: + * - welche Typen es gibt (Schlüssel) + * - Anzeigelabel + Kurzbeschreibung in den Einstellungen + * - Icon (lucide-react Komponentenname, wird in NotificationItem/Settings gemappt) + * - defaultOn: ob der Typ ohne explizite Einstellung aktiv ist + * - audience: an wen der Typ gerichtet ist (aktuell nur "studio" = Manager) + * + * Ein neuer Typ wird ausschließlich hier ergänzt; Einstellungen-UI, Zod- + * Validierung und createNotification leiten sich daraus ab. + */ + +export type NotificationAudience = "studio"; + +export interface NotificationTypeDef { + label: string; + description: string; + /** lucide-react Icon-Komponentenname */ + icon: string; + defaultOn: boolean; + audience: NotificationAudience; +} + +export const NOTIFICATION_TYPES = { + "booking.created": { + label: "Neue Buchung", + description: "Ein Kunde hat einen Platz in einem deiner Kurse gebucht.", + icon: "CalendarPlus", + defaultOn: true, + audience: "studio", + }, + "booking.cancelled": { + label: "Stornierung durch Kunde", + description: "Ein Kunde hat seine Buchung selbst storniert.", + icon: "CalendarX", + defaultOn: true, + audience: "studio", + }, + "course.full": { + label: "Kurs ausgebucht", + description: "Ein Kurs hat seine maximale Teilnehmerzahl erreicht.", + icon: "Users", + defaultOn: true, + audience: "studio", + }, + "course.cancelled": { + label: "Kurs abgesagt", + description: "Ein kompletter Kurstermin wurde abgesagt und alle Buchungen erstattet.", + icon: "Ban", + defaultOn: true, + audience: "studio", + }, + "waitlist.spot": { + label: "Wartelisten-Platz frei", + description: + "In einem ausgebuchten Kurs ist ein Platz frei geworden und die Warteliste wurde benachrichtigt.", + icon: "BellRing", + defaultOn: true, + audience: "studio", + }, +} as const satisfies Record; + +export type NotificationType = keyof typeof NOTIFICATION_TYPES; + +export const NOTIFICATION_TYPE_KEYS = Object.keys(NOTIFICATION_TYPES) as NotificationType[]; + +export function isNotificationType(value: string): value is NotificationType { + // Object.hasOwn statt `in`: schließt geerbte Namen wie "__proto__" oder + // "constructor" aus, die sonst als gültige Typen durchrutschen würden. + return Object.hasOwn(NOTIFICATION_TYPES, value); +} + +/** + * Merged gespeicherte Einstellungen (Business.notificationSettings, evtl. null + * oder unvollständig) über die Registry-Defaults zu einer vollständigen Map. + * Unbekannte Keys im gespeicherten Wert werden ignoriert. + */ +export function resolveNotificationSettings(stored: unknown): Record { + const storedRecord = + stored && typeof stored === "object" && !Array.isArray(stored) + ? (stored as Record) + : {}; + + const result = {} as Record; + for (const key of NOTIFICATION_TYPE_KEYS) { + const value = Object.hasOwn(storedRecord, key) ? storedRecord[key] : undefined; + result[key] = typeof value === "boolean" ? value : NOTIFICATION_TYPES[key].defaultOn; + } + return result; +} diff --git a/src/modules/notifications/ui/components/NotificationBell.tsx b/src/modules/notifications/ui/components/NotificationBell.tsx new file mode 100644 index 0000000..bd57d18 --- /dev/null +++ b/src/modules/notifications/ui/components/NotificationBell.tsx @@ -0,0 +1,138 @@ +"use client"; + +import { useCallback, useEffect, useState, useTransition } from "react"; +import { useRouter, usePathname } from "next/navigation"; +import { Bell, CheckCheck } from "lucide-react"; +import { Popover, PopoverContent, PopoverTrigger } from "@/src/components/ui/popover"; +import { Button } from "@/src/components/ui/button"; +import { cn } from "@/src/lib/utils"; +import { + getNotifications, + markNotificationRead, + markAllNotificationsRead, + type NotificationDTO, +} from "../../actions/notification-actions"; +import NotificationItem from "./NotificationItem"; + +const NotificationBell = () => { + const router = useRouter(); + const pathname = usePathname(); + const [items, setItems] = useState([]); + const [unreadCount, setUnreadCount] = useState(0); + const [open, setOpen] = useState(false); + const [loaded, setLoaded] = useState(false); + const [, startTransition] = useTransition(); + + const refresh = useCallback(async () => { + const result = await getNotifications(); + setItems(result.items); + setUnreadCount(result.unreadCount); + setLoaded(true); + }, []); + + // Erstladen + Refetch bei jedem Seitenwechsel (kein Hintergrund-Polling). + // Das setState läuft erst nach dem await, nicht synchron im Effect-Body - + // gleiches Muster wie CancelBookingConfirmDialog. + useEffect(() => { + let cancelled = false; + (async () => { + const result = await getNotifications(); + if (cancelled) return; + setItems(result.items); + setUnreadCount(result.unreadCount); + setLoaded(true); + })(); + return () => { + cancelled = true; + }; + }, [pathname]); + + const handleOpenChange = (next: boolean) => { + setOpen(next); + if (next) void refresh(); + }; + + const handleSelect = (notification: NotificationDTO) => { + if (notification.readAt === null) { + // Optimistisch als gelesen markieren. + setItems((prev) => + prev.map((n) => (n.id === notification.id ? { ...n, readAt: new Date().toISOString() } : n)) + ); + setUnreadCount((c) => Math.max(0, c - 1)); + startTransition(async () => { + await markNotificationRead(notification.id); + }); + } + setOpen(false); + if (notification.linkUrl) router.push(notification.linkUrl); + }; + + const handleMarkAll = () => { + setItems((prev) => prev.map((n) => ({ ...n, readAt: n.readAt ?? new Date().toISOString() }))); + setUnreadCount(0); + startTransition(async () => { + await markAllNotificationsRead(); + }); + }; + + const badgeLabel = unreadCount > 99 ? "99+" : String(unreadCount); + + return ( + + + + + + +
+ Benachrichtigungen + {unreadCount > 0 && ( + + )} +
+ +
+ {!loaded ? ( +

Lädt …

+ ) : items.length === 0 ? ( +
+ +

Keine Benachrichtigungen

+
+ ) : ( +
    + {items.map((n) => ( +
  • + +
  • + ))} +
+ )} +
+
+
+ ); +}; + +export default NotificationBell; diff --git a/src/modules/notifications/ui/components/NotificationItem.tsx b/src/modules/notifications/ui/components/NotificationItem.tsx new file mode 100644 index 0000000..4eed144 --- /dev/null +++ b/src/modules/notifications/ui/components/NotificationItem.tsx @@ -0,0 +1,62 @@ +"use client"; + +import { formatDistanceToNow } from "date-fns"; +import { de } from "date-fns/locale"; +import { cn } from "@/src/lib/utils"; +import type { NotificationDTO } from "../../actions/notification-actions"; +import NotificationTypeIcon from "./NotificationTypeIcon"; + +interface NotificationItemProps { + notification: NotificationDTO; + onSelect: (notification: NotificationDTO) => void; +} + +const NotificationItem = ({ notification, onSelect }: NotificationItemProps) => { + const isUnread = notification.readAt === null; + const relativeTime = formatDistanceToNow(new Date(notification.createdAt), { + addSuffix: true, + locale: de, + }); + + return ( + + ); +}; + +export default NotificationItem; diff --git a/src/modules/notifications/ui/components/NotificationTypeIcon.tsx b/src/modules/notifications/ui/components/NotificationTypeIcon.tsx new file mode 100644 index 0000000..6ef20f8 --- /dev/null +++ b/src/modules/notifications/ui/components/NotificationTypeIcon.tsx @@ -0,0 +1,38 @@ +import { + Ban, + Bell, + BellRing, + CalendarPlus, + CalendarX, + Users, + type LucideProps, +} from "lucide-react"; +import { NOTIFICATION_TYPES, type NotificationType } from "../../lib/notification-types"; + +/** + * Rendert das zu einem Benachrichtigungstyp gehörende lucide-Icon. + * + * Bewusst als eigene, modul-scoped Komponente statt `const Icon = lookup(type)` + * im Aufrufer: Letzteres wertet der React-Compiler-Linter als "Komponente + * während Render erzeugt". Hier sind alle Icon-Referenzen statisch. + */ +const ICONS = { + Ban, + Bell, + BellRing, + CalendarPlus, + CalendarX, + Users, +} as const; + +interface NotificationTypeIconProps extends LucideProps { + type: NotificationType; +} + +const NotificationTypeIcon = ({ type, ...props }: NotificationTypeIconProps) => { + const name = NOTIFICATION_TYPES[type]?.icon as keyof typeof ICONS | undefined; + const Icon = (name && ICONS[name]) || Bell; + return ; +}; + +export default NotificationTypeIcon; diff --git a/src/modules/settings/actions/notification-settings-actions.ts b/src/modules/settings/actions/notification-settings-actions.ts new file mode 100644 index 0000000..c26629c --- /dev/null +++ b/src/modules/settings/actions/notification-settings-actions.ts @@ -0,0 +1,95 @@ +"use server"; + +import { z } from "zod"; +import { prisma } from "@/src/lib/prisma"; +import { requireManager } from "@/src/lib/auth/getUser"; +import { revalidatePath } from "next/cache"; +import { + NOTIFICATION_TYPE_KEYS, + resolveNotificationSettings, + type NotificationType, +} from "@/src/modules/notifications/lib/notification-types"; + +export type NotificationSettings = Record; + +// Alle Registry-Keys optional, jeweils boolean. Unbekannte Keys werden von +// Zod verworfen (strict), damit nur gültige Typen gespeichert werden. +const notificationSettingsSchema = z + .object( + Object.fromEntries(NOTIFICATION_TYPE_KEYS.map((k) => [k, z.boolean().optional()])) as Record< + NotificationType, + z.ZodOptional + > + ) + .strict(); + +export async function getNotificationSettings( + businessId: string +): Promise<{ success: true; settings: NotificationSettings } | { success: false; error: string }> { + const manager = await requireManager(); + + try { + const business = await prisma.business.findUnique({ + where: { id: businessId }, + select: { managerId: true, notificationSettings: true }, + }); + if (!business || business.managerId !== manager.id) { + return { success: false, error: "Keine Berechtigung für dieses Business." }; + } + + return { + success: true, + settings: resolveNotificationSettings(business.notificationSettings), + }; + } catch (error) { + console.error("getNotificationSettings fehlgeschlagen:", error); + return { + success: false, + error: "Benachrichtigungseinstellungen konnten nicht geladen werden.", + }; + } +} + +export async function saveNotificationSettings( + businessId: string, + partial: Partial +): Promise<{ success: true } | { success: false; error: string }> { + const manager = await requireManager(); + + try { + const parsed = notificationSettingsSchema.safeParse(partial); + if (!parsed.success) { + return { success: false, error: "Ungültige Benachrichtigungseinstellungen." }; + } + + const business = await prisma.business.findUnique({ + where: { id: businessId }, + select: { managerId: true, notificationSettings: true }, + }); + if (!business || business.managerId !== manager.id) { + return { success: false, error: "Keine Berechtigung für dieses Business." }; + } + + // Auf die vollständige, aufgelöste Map mergen und komplett persistieren - + // so ist der gespeicherte Wert immer selbsterklärend und unabhängig von + // späteren Default-Änderungen in der Registry. + const merged: NotificationSettings = { + ...resolveNotificationSettings(business.notificationSettings), + ...parsed.data, + }; + + await prisma.business.update({ + where: { id: businessId }, + data: { notificationSettings: merged }, + }); + + revalidatePath("/settings/notifications"); + return { success: true }; + } catch (error) { + console.error("saveNotificationSettings fehlgeschlagen:", error); + return { + success: false, + error: "Benachrichtigungseinstellungen konnten nicht gespeichert werden.", + }; + } +} diff --git a/src/modules/settings/ui/views/settings-notification-page-view.tsx b/src/modules/settings/ui/views/settings-notification-page-view.tsx index 26a5e1d..b75b481 100644 --- a/src/modules/settings/ui/views/settings-notification-page-view.tsx +++ b/src/modules/settings/ui/views/settings-notification-page-view.tsx @@ -1,5 +1,133 @@ -const SettingsNotificationPageView = () => { - return
SettingsNotificationPageView
; +"use client"; + +import { useState, useTransition } from "react"; +import { Bell } from "lucide-react"; +import { toast } from "sonner"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/src/components/ui/select"; +import { Switch } from "@/src/components/ui/switch"; +import { cn } from "@/src/lib/utils"; +import { + NOTIFICATION_TYPES, + NOTIFICATION_TYPE_KEYS, + type NotificationType, +} from "@/src/modules/notifications/lib/notification-types"; +import NotificationTypeIcon from "@/src/modules/notifications/ui/components/NotificationTypeIcon"; +import { + getNotificationSettings, + saveNotificationSettings, + type NotificationSettings, +} from "../../actions/notification-settings-actions"; + +interface SettingsNotificationPageViewProps { + businesses: { id: string; name: string }[]; + initialSettings: NotificationSettings; +} + +const SettingsNotificationPageView = ({ + businesses, + initialSettings, +}: SettingsNotificationPageViewProps) => { + const [selectedBusinessId, setSelectedBusinessId] = useState(businesses[0]?.id ?? ""); + const [settings, setSettings] = useState(initialSettings); + const [loading, startLoading] = useTransition(); + const [, startSaving] = useTransition(); + + const handleBusinessChange = (businessId: string) => { + const previous = selectedBusinessId; + setSelectedBusinessId(businessId); + startLoading(async () => { + const result = await getNotificationSettings(businessId); + if (result.success) { + setSettings(result.settings); + } else { + setSelectedBusinessId(previous); + toast.error(result.error); + } + }); + }; + + const handleToggle = (type: NotificationType, next: boolean) => { + const previous = settings[type]; + setSettings((s) => ({ ...s, [type]: next })); + startSaving(async () => { + const result = await saveNotificationSettings(selectedBusinessId, { [type]: next }); + if (!result.success) { + setSettings((s) => ({ ...s, [type]: previous })); + toast.error(result.error); + } + }); + }; + + return ( +
+
+

+ Benachrichtigungen +

+

+ Wähle, worüber du im Dashboard benachrichtigt werden möchtest. +

+
+ + {businesses.length === 0 ? ( +
+

Du hast noch keine Businesses angelegt.

+
+ ) : ( +
+ {businesses.length > 1 && ( + + )} + +
    + {NOTIFICATION_TYPE_KEYS.map((type) => { + const def = NOTIFICATION_TYPES[type]; + return ( +
  • + + + +
    +

    {def.label}

    +

    {def.description}

    +
    + handleToggle(type, next)} + disabled={loading} + aria-label={def.label} + className="mt-1" + /> +
  • + ); + })} +
+
+ )} +
+ ); }; export default SettingsNotificationPageView; diff --git a/tests/notifications/create-notification.test.ts b/tests/notifications/create-notification.test.ts new file mode 100644 index 0000000..6fec42d --- /dev/null +++ b/tests/notifications/create-notification.test.ts @@ -0,0 +1,125 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +vi.mock("@/src/lib/prisma", () => ({ + prisma: { + business: { findUnique: vi.fn() }, + notification: { create: vi.fn() }, + }, +})); + +import { prisma } from "@/src/lib/prisma"; +import { + createNotification, + isNotificationEnabled, + notifyBusinessManager, +} from "@/src/modules/notifications/lib/create-notification"; + +const businessFindUnique = vi.mocked(prisma.business.findUnique); +const notificationCreate = vi.mocked(prisma.notification.create); + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe("createNotification", () => { + it("schreibt einen Datensatz und gibt true zurück", async () => { + notificationCreate.mockResolvedValueOnce({} as never); + const ok = await createNotification({ + recipientId: "mgr-1", + type: "booking.created", + title: "Neue Buchung", + body: "…", + linkUrl: "/courses/edit/c1", + }); + expect(ok).toBe(true); + expect(notificationCreate).toHaveBeenCalledWith({ + data: { + recipientId: "mgr-1", + type: "booking.created", + title: "Neue Buchung", + body: "…", + linkUrl: "/courses/edit/c1", + }, + }); + }); + + it("wirft nie, sondern gibt bei DB-Fehler false zurück", async () => { + notificationCreate.mockRejectedValueOnce(new Error("db down")); + const ok = await createNotification({ + recipientId: "mgr-1", + type: "booking.created", + title: "x", + body: "y", + }); + expect(ok).toBe(false); + }); + + it("setzt linkUrl auf null wenn nicht angegeben", async () => { + notificationCreate.mockResolvedValueOnce({} as never); + await createNotification({ recipientId: "m", type: "course.full", title: "a", body: "b" }); + expect(notificationCreate.mock.calls[0][0].data.linkUrl).toBeNull(); + }); +}); + +describe("isNotificationEnabled", () => { + it("gibt den Registry-Default zurück wenn kein Business gefunden wird", async () => { + businessFindUnique.mockResolvedValueOnce(null); + expect(await isNotificationEnabled("nope", "booking.created")).toBe(true); + }); + + it("respektiert ein gespeichertes Opt-out", async () => { + businessFindUnique.mockResolvedValueOnce({ + notificationSettings: { "booking.created": false }, + } as never); + expect(await isNotificationEnabled("b1", "booking.created")).toBe(false); + }); + + it("fällt bei DB-Fehler auf den Default zurück (Zustellung im Zweifel)", async () => { + businessFindUnique.mockRejectedValueOnce(new Error("boom")); + expect(await isNotificationEnabled("b1", "waitlist.spot")).toBe(true); + }); +}); + +describe("notifyBusinessManager", () => { + it("no-op bei fehlender businessId", async () => { + await notifyBusinessManager(null, { type: "booking.created", title: "a", body: "b" }); + await notifyBusinessManager(undefined, { type: "booking.created", title: "a", body: "b" }); + expect(businessFindUnique).not.toHaveBeenCalled(); + expect(notificationCreate).not.toHaveBeenCalled(); + }); + + it("erstellt eine Notification für den Manager des Business", async () => { + businessFindUnique.mockResolvedValueOnce({ + managerId: "mgr-42", + notificationSettings: null, + } as never); + notificationCreate.mockResolvedValueOnce({} as never); + + await notifyBusinessManager("b1", { + type: "booking.created", + title: "Neue Buchung", + body: "…", + linkUrl: "/x", + }); + + expect(notificationCreate).toHaveBeenCalledTimes(1); + expect(notificationCreate.mock.calls[0][0].data.recipientId).toBe("mgr-42"); + }); + + it("stellt nichts zu, wenn der Typ für das Business abgeschaltet ist", async () => { + businessFindUnique.mockResolvedValueOnce({ + managerId: "mgr-42", + notificationSettings: { "booking.created": false }, + } as never); + + await notifyBusinessManager("b1", { type: "booking.created", title: "a", body: "b" }); + expect(notificationCreate).not.toHaveBeenCalled(); + }); + + it("wirft nie, auch wenn der Business-Lookup fehlschlägt", async () => { + businessFindUnique.mockRejectedValueOnce(new Error("boom")); + await expect( + notifyBusinessManager("b1", { type: "course.full", title: "a", body: "b" }) + ).resolves.toBeUndefined(); + }); +}); diff --git a/tests/notifications/notification-actions.test.ts b/tests/notifications/notification-actions.test.ts new file mode 100644 index 0000000..f13a369 --- /dev/null +++ b/tests/notifications/notification-actions.test.ts @@ -0,0 +1,109 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +vi.mock("@/src/lib/prisma", () => ({ + prisma: { + notification: { + findMany: vi.fn(), + count: vi.fn(), + updateMany: vi.fn(), + }, + }, +})); + +vi.mock("@/src/lib/auth/getUser", () => ({ + requireAuthWithData: vi.fn(), + isManager: (u: { role: string }) => u?.role === "manager", + isEmployee: (u: { role: string }) => u?.role === "employee", +})); + +import { prisma } from "@/src/lib/prisma"; +import { requireAuthWithData } from "@/src/lib/auth/getUser"; +import { + getNotifications, + markNotificationRead, + markAllNotificationsRead, +} from "@/src/modules/notifications/actions/notification-actions"; + +const findMany = vi.mocked(prisma.notification.findMany); +const count = vi.mocked(prisma.notification.count); +const updateMany = vi.mocked(prisma.notification.updateMany); +const requireAuth = vi.mocked(requireAuthWithData); + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe("getNotifications", () => { + it("liefert leeres Ergebnis für die User-Rolle", async () => { + requireAuth.mockResolvedValueOnce({ id: "u1", role: "user" } as never); + const result = await getNotifications(); + expect(result).toEqual({ items: [], unreadCount: 0 }); + expect(findMany).not.toHaveBeenCalled(); + }); + + it("liefert leeres Ergebnis wenn keine Session besteht", async () => { + requireAuth.mockRejectedValueOnce(new Error("redirect")); + const result = await getNotifications(); + expect(result).toEqual({ items: [], unreadCount: 0 }); + }); + + it("scoped die Abfrage auf die eigene recipientId und mappt Datumsfelder", async () => { + requireAuth.mockResolvedValueOnce({ id: "mgr-1", role: "manager" } as never); + const now = new Date("2026-08-28T10:00:00Z"); + findMany.mockResolvedValueOnce([ + { + id: "n1", + type: "booking.created", + title: "T", + body: "B", + linkUrl: "/x", + readAt: null, + createdAt: now, + }, + ] as never); + count.mockResolvedValueOnce(1); + + const result = await getNotifications(); + + expect(findMany.mock.calls[0][0]?.where).toEqual({ recipientId: "mgr-1" }); + expect(count.mock.calls[0][0]?.where).toEqual({ recipientId: "mgr-1", readAt: null }); + expect(result.unreadCount).toBe(1); + expect(result.items[0].createdAt).toBe(now.toISOString()); + expect(result.items[0].readAt).toBeNull(); + }); +}); + +describe("markNotificationRead", () => { + it("nimmt die recipientId in die WHERE-Bedingung auf (Ownership)", async () => { + requireAuth.mockResolvedValueOnce({ id: "mgr-1", role: "manager" } as never); + updateMany.mockResolvedValueOnce({ count: 1 } as never); + + await markNotificationRead("n1"); + + expect(updateMany).toHaveBeenCalledWith({ + where: { id: "n1", recipientId: "mgr-1", readAt: null }, + data: { readAt: expect.any(Date) }, + }); + }); + + it("verweigert die User-Rolle", async () => { + requireAuth.mockResolvedValueOnce({ id: "u1", role: "user" } as never); + const result = await markNotificationRead("n1"); + expect(result).toEqual({ success: false }); + expect(updateMany).not.toHaveBeenCalled(); + }); +}); + +describe("markAllNotificationsRead", () => { + it("markiert nur ungelesene der eigenen recipientId", async () => { + requireAuth.mockResolvedValueOnce({ id: "emp-9", role: "employee" } as never); + updateMany.mockResolvedValueOnce({ count: 3 } as never); + + await markAllNotificationsRead(); + + expect(updateMany).toHaveBeenCalledWith({ + where: { recipientId: "emp-9", readAt: null }, + data: { readAt: expect.any(Date) }, + }); + }); +}); diff --git a/tests/notifications/notification-settings-actions.test.ts b/tests/notifications/notification-settings-actions.test.ts new file mode 100644 index 0000000..6576964 --- /dev/null +++ b/tests/notifications/notification-settings-actions.test.ts @@ -0,0 +1,88 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +vi.mock("@/src/lib/prisma", () => ({ + prisma: { + business: { findUnique: vi.fn(), update: vi.fn() }, + }, +})); + +vi.mock("@/src/lib/auth/getUser", () => ({ + requireManager: vi.fn(), +})); + +vi.mock("next/cache", () => ({ + revalidatePath: vi.fn(), +})); + +import { prisma } from "@/src/lib/prisma"; +import { requireManager } from "@/src/lib/auth/getUser"; +import { + getNotificationSettings, + saveNotificationSettings, +} from "@/src/modules/settings/actions/notification-settings-actions"; +import { NOTIFICATION_TYPES } from "@/src/modules/notifications/lib/notification-types"; + +const findUnique = vi.mocked(prisma.business.findUnique); +const update = vi.mocked(prisma.business.update); +const requireMgr = vi.mocked(requireManager); + +beforeEach(() => { + vi.clearAllMocks(); + requireMgr.mockResolvedValue({ id: "mgr-1" } as never); +}); + +describe("getNotificationSettings", () => { + it("verweigert ein Business, das dem Manager nicht gehört", async () => { + findUnique.mockResolvedValueOnce({ + managerId: "someone-else", + notificationSettings: null, + } as never); + const result = await getNotificationSettings("b1"); + expect(result.success).toBe(false); + }); + + it("liefert die aufgelösten Defaults bei null", async () => { + findUnique.mockResolvedValueOnce({ managerId: "mgr-1", notificationSettings: null } as never); + const result = await getNotificationSettings("b1"); + expect(result.success).toBe(true); + if (result.success) { + expect(result.settings["booking.created"]).toBe( + NOTIFICATION_TYPES["booking.created"].defaultOn + ); + } + }); +}); + +describe("saveNotificationSettings", () => { + it("lehnt unbekannte Keys ab (strict schema)", async () => { + const result = await saveNotificationSettings("b1", { + "not.a.real.type": false, + } as never); + expect(result.success).toBe(false); + expect(findUnique).not.toHaveBeenCalled(); + }); + + it("merged partielle Änderungen auf die volle Map und persistiert sie", async () => { + findUnique.mockResolvedValueOnce({ + managerId: "mgr-1", + notificationSettings: null, + } as never); + update.mockResolvedValueOnce({} as never); + + const result = await saveNotificationSettings("b1", { "booking.created": false }); + expect(result.success).toBe(true); + + const written = update.mock.calls[0][0].data.notificationSettings as Record; + expect(written["booking.created"]).toBe(false); + // alle anderen Keys sind ebenfalls enthalten (volle Map) + expect(Object.keys(written).sort()).toEqual(Object.keys(NOTIFICATION_TYPES).sort()); + expect(written["course.cancelled"]).toBe(NOTIFICATION_TYPES["course.cancelled"].defaultOn); + }); + + it("verweigert ein fremdes Business auch beim Speichern", async () => { + findUnique.mockResolvedValueOnce({ managerId: "x", notificationSettings: null } as never); + const result = await saveNotificationSettings("b1", { "booking.created": true }); + expect(result.success).toBe(false); + expect(update).not.toHaveBeenCalled(); + }); +}); diff --git a/tests/notifications/notification-types.test.ts b/tests/notifications/notification-types.test.ts new file mode 100644 index 0000000..bda2d38 --- /dev/null +++ b/tests/notifications/notification-types.test.ts @@ -0,0 +1,54 @@ +import { describe, it, expect } from "vitest"; +import { + NOTIFICATION_TYPES, + NOTIFICATION_TYPE_KEYS, + isNotificationType, + resolveNotificationSettings, +} from "@/src/modules/notifications/lib/notification-types"; + +describe("isNotificationType", () => { + it("erkennt gültige Registry-Keys", () => { + expect(isNotificationType("booking.created")).toBe(true); + expect(isNotificationType("course.cancelled")).toBe(true); + }); + + it("lehnt unbekannte Keys ab", () => { + expect(isNotificationType("booking.unknown")).toBe(false); + expect(isNotificationType("")).toBe(false); + expect(isNotificationType("__proto__")).toBe(false); + }); +}); + +describe("resolveNotificationSettings", () => { + it("liefert bei null alle Registry-Defaults", () => { + const resolved = resolveNotificationSettings(null); + expect(Object.keys(resolved).sort()).toEqual([...NOTIFICATION_TYPE_KEYS].sort()); + for (const key of NOTIFICATION_TYPE_KEYS) { + expect(resolved[key]).toBe(NOTIFICATION_TYPES[key].defaultOn); + } + }); + + it("übersteuert einzelne Keys aus dem gespeicherten Wert", () => { + const resolved = resolveNotificationSettings({ "booking.created": false }); + expect(resolved["booking.created"]).toBe(false); + // nicht gesetzte Keys fallen weiterhin auf den Default zurück + expect(resolved["course.cancelled"]).toBe(NOTIFICATION_TYPES["course.cancelled"].defaultOn); + }); + + it("ignoriert unbekannte Keys und Nicht-Boolean-Werte", () => { + const resolved = resolveNotificationSettings({ + "booking.created": "yes", + "bogus.key": true, + "course.full": 1, + }); + expect(resolved["booking.created"]).toBe(NOTIFICATION_TYPES["booking.created"].defaultOn); + expect(resolved["course.full"]).toBe(NOTIFICATION_TYPES["course.full"].defaultOn); + expect("bogus.key" in resolved).toBe(false); + }); + + it("behandelt Arrays / primitive Werte als leer", () => { + expect(resolveNotificationSettings([1, 2, 3])).toEqual(resolveNotificationSettings(null)); + expect(resolveNotificationSettings("string")).toEqual(resolveNotificationSettings(null)); + expect(resolveNotificationSettings(42)).toEqual(resolveNotificationSettings(null)); + }); +});