Merge pull request #154 from Since-3/feature/notification-system

added in-app notification system
This commit is contained in:
Rajpreet0 2026-08-28 18:31:10 +02:00 • committed by GitHub
commit 285eeb1536
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
20 changed files with 1381 additions and 29 deletions

View file

@ -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");

View file

@ -114,6 +114,7 @@ model Business {
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())
@ -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/<id>
readAt DateTime? // null = ungelesen
createdAt DateTime @default(now())
@@index([recipientId, readAt])
@@index([recipientId, createdAt])
}

View file

@ -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 (
<div className="flex gap-2 text-sm bg-red-50 border border-red-200 rounded-lg p-4 text-red-700">
<AlertTriangle size={16} className="shrink-0 mt-0.5" />
<p>{result?.error ?? "Benachrichtigungseinstellungen konnten nicht geladen werden."}</p>
</div>
);
}
const initialSettings = result?.success ? result.settings : resolveNotificationSettings(null);
return (
<Suspense fallback={<SettingsLoading />}>
<SettingsNotificationPageView />
<SettingsNotificationPageView businesses={businesses} initialSettings={initialSettings} />
</Suspense>
);
}

View file

@ -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<void> {
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,

View file

@ -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).

View file

@ -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<CourseCancel
data: { status: "cancelled", cancelledAt: new Date() },
});
const course = await prisma.course.findUnique({
where: { id: courseId },
select: { name: true, date: true, businessId: true },
});
const bookings = await prisma.courseBooking.findMany({
where: { courseId, paymentStatus: "paid" },
select: { id: true, userId: true },
@ -124,6 +130,33 @@ async function executeCourseCancellation(courseId: string): Promise<CourseCancel
revalidatePath("/courses");
// In-App-Benachrichtigung ans Studio, dass der Termin abgesagt wurde und
// wie viele Buchungen erstattet wurden. Fehler hier dürfen das Ergebnis der
// Absage nicht beeinflussen (notifyBusinessManager wirft ohnehin nie).
if (course?.businessId) {
const courseDate = course.date.toLocaleDateString("de-DE", {
day: "numeric",
month: "long",
timeZone: "Europe/Berlin",
});
const refundInfo =
cancelled > 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 };
}

View file

@ -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<void> {
// Kurs frisch nachladen statt einem durchgereichten Flag zu vertrauen -
@ -31,7 +32,7 @@ export async function notifyCourseWaitlist(courseId: string): Promise<void> {
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<void> {
// 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<void> {
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}`,
});
}
}

View file

@ -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<string, string> = {
"/dashboard": "Dashboard",
@ -69,6 +70,10 @@ const DashboardNavbar = () => {
<div className="w-px h-6 bg-border" />
<h1 className="text-xl font-semibold mt-1">{pageTitle}</h1>
<div className="ml-auto">
<NotificationBell />
</div>
</div>
</div>
);

View file

@ -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<GetNotificationsResult> {
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 };
}
}

View file

@ -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<boolean> {
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<boolean> {
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<CreateNotificationInput, "recipientId">
): Promise<void> {
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);
}
}

View file

@ -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<string, NotificationTypeDef>;
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<NotificationType, boolean> {
const storedRecord =
stored && typeof stored === "object" && !Array.isArray(stored)
? (stored as Record<string, unknown>)
: {};
const result = {} as Record<NotificationType, boolean>;
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;
}

View file

@ -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<NotificationDTO[]>([]);
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 (
<Popover open={open} onOpenChange={handleOpenChange}>
<PopoverTrigger asChild>
<Button
variant="outline"
size="icon"
className="relative size-9 cursor-pointer"
aria-label={
unreadCount > 0 ? `Benachrichtigungen, ${unreadCount} ungelesen` : "Benachrichtigungen"
}
>
<Bell className="size-4" />
{unreadCount > 0 && (
<span className="absolute -right-1 -top-1 flex min-w-4 items-center justify-center rounded-full bg-destructive px-1 text-[10px] font-semibold leading-4 text-white">
{badgeLabel}
</span>
)}
</Button>
</PopoverTrigger>
<PopoverContent align="end" sideOffset={8} className="w-88 p-0">
<div className="flex items-center justify-between border-b px-4 py-3">
<span className="text-sm font-semibold">Benachrichtigungen</span>
{unreadCount > 0 && (
<button
type="button"
onClick={handleMarkAll}
className="flex items-center gap-1 text-xs text-muted-foreground transition-colors hover:text-foreground"
>
<CheckCheck className="size-3.5" />
Alle gelesen
</button>
)}
</div>
<div className="max-h-96 overflow-y-auto">
{!loaded ? (
<p className="px-4 py-10 text-center text-sm text-muted-foreground">Lädt …</p>
) : items.length === 0 ? (
<div className="px-4 py-12 text-center">
<Bell className="mx-auto size-6 text-muted-foreground/40" />
<p className="mt-2 text-sm text-muted-foreground">Keine Benachrichtigungen</p>
</div>
) : (
<ul className={cn("divide-y")}>
{items.map((n) => (
<li key={n.id}>
<NotificationItem notification={n} onSelect={handleSelect} />
</li>
))}
</ul>
)}
</div>
</PopoverContent>
</Popover>
);
};
export default NotificationBell;

View file

@ -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 (
<button
type="button"
onClick={() => onSelect(notification)}
className={cn(
"flex w-full gap-3 px-4 py-3 text-left transition-colors hover:bg-accent focus-visible:bg-accent focus-visible:outline-none",
isUnread && "bg-accent/40"
)}
>
<span
className={cn(
"mt-0.5 flex size-8 shrink-0 items-center justify-center rounded-full",
isUnread ? "bg-primary/10 text-primary" : "bg-muted text-muted-foreground"
)}
>
<NotificationTypeIcon type={notification.type} className="size-4" />
</span>
<span className="min-w-0 flex-1">
<span className="flex items-start justify-between gap-2">
<span
className={cn(
"truncate text-sm",
isUnread ? "font-semibold text-foreground" : "font-medium text-foreground/90"
)}
>
{notification.title}
</span>
{isUnread && (
<span className="mt-1.5 size-2 shrink-0 rounded-full bg-primary" aria-hidden />
)}
</span>
<span className="mt-0.5 line-clamp-2 block text-sm text-muted-foreground">
{notification.body}
</span>
<span className="mt-1 block text-xs text-muted-foreground/70">{relativeTime}</span>
</span>
</button>
);
};
export default NotificationItem;

View file

@ -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 <Icon {...props} />;
};
export default NotificationTypeIcon;

View file

@ -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<NotificationType, boolean>;
// 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<z.ZodBoolean>
>
)
.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<NotificationSettings>
): 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.",
};
}
}

View file

@ -1,5 +1,133 @@
const SettingsNotificationPageView = () => {
return <div>SettingsNotificationPageView</div>;
"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<NotificationSettings>(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 (
<div className="space-y-6">
<div>
<h1 className="text-2xl flex items-center gap-2 font-bold">
<Bell /> Benachrichtigungen
</h1>
<p className="text-sm text-gray-500 mt-1">
Wähle, worüber du im Dashboard benachrichtigt werden möchtest.
</p>
</div>
{businesses.length === 0 ? (
<div className="border border-white/60 bg-white/55 backdrop-blur-xl rounded-xl p-6 text-center">
<p className="text-sm text-gray-500">Du hast noch keine Businesses angelegt.</p>
</div>
) : (
<div className="space-y-4">
{businesses.length > 1 && (
<Select value={selectedBusinessId} onValueChange={handleBusinessChange}>
<SelectTrigger className="bg-white/60 backdrop-blur-xl border-white/40 w-full sm:w-64">
<SelectValue placeholder="Business" />
</SelectTrigger>
<SelectContent>
{businesses.map((b) => (
<SelectItem key={b.id} value={b.id}>
{b.name}
</SelectItem>
))}
</SelectContent>
</Select>
)}
<ul
className={cn(
"divide-y divide-white/50 rounded-xl border border-white/60 bg-white/55 backdrop-blur-xl",
loading && "opacity-60 transition-opacity"
)}
>
{NOTIFICATION_TYPE_KEYS.map((type) => {
const def = NOTIFICATION_TYPES[type];
return (
<li key={type} className="flex items-start gap-4 p-4">
<span className="mt-0.5 flex size-9 shrink-0 items-center justify-center rounded-full bg-primary/10 text-primary">
<NotificationTypeIcon type={type} className="size-4" />
</span>
<div className="min-w-0 flex-1">
<p className="text-sm font-medium">{def.label}</p>
<p className="mt-0.5 text-sm text-gray-500">{def.description}</p>
</div>
<Switch
checked={settings[type]}
onCheckedChange={(next) => handleToggle(type, next)}
disabled={loading}
aria-label={def.label}
className="mt-1"
/>
</li>
);
})}
</ul>
</div>
)}
</div>
);
};
export default SettingsNotificationPageView;

View file

@ -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();
});
});

View file

@ -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) },
});
});
});

View file

@ -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<string, boolean>;
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();
});
});

View file

@ -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));
});
});