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

@ -97,27 +97,28 @@ model Manager {
} }
model Business { model Business {
id String @id @default(uuid()) id String @id @default(uuid())
managerId String managerId String
name String name String
address String address String
email String email String
title String title String
ustId String? ustId String?
banking String? banking String?
plan String? plan String?
subscription Json? subscription Json?
slug String? @unique slug String? @unique
isPublic Boolean @default(false) isPublic Boolean @default(false)
stripeAccountId String? @unique // Stripe Connect Account ID stripeAccountId String? @unique // Stripe Connect Account ID
stripeAccountStatus String? // "pending" | "active" | "restricted" stripeAccountStatus String? // "pending" | "active" | "restricted"
invoiceCounter Int @default(0) // Fortlaufender Zähler für Rechnungsnummern invoiceCounter Int @default(0) // Fortlaufender Zähler für Rechnungsnummern
cancellationPolicy Json? // { tiers: [{ hoursBeforeStart: number, refundPercent: number }, ...] }, absteigend sortiert. null = immer 100% Rückerstattung. 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). branding Json? // { companyName, logoUrl, primaryColor, accentColor }. null = Kumasy-Standardbranding (siehe KUMASY_BRANDING).
manager Manager @relation(fields: [managerId], references: [id]) notificationSettings Json? // { "booking.created": true, ... } Opt-out pro In-App-Benachrichtigungstyp. null / fehlender Key = Registry-Default (siehe NOTIFICATION_TYPES).
courses Course[] manager Manager @relation(fields: [managerId], references: [id])
createdAt DateTime @default(now()) courses Course[]
updatedAt DateTime @updatedAt createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([managerId]) @@index([managerId])
} }
@ -225,3 +226,22 @@ model CourseWaitlistEntry {
@@index([courseId]) @@index([courseId])
@@index([userId]) @@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 { Suspense } from "react";
import { AlertTriangle } from "lucide-react";
import { requireManager } from "@/src/lib/auth/getUser"; 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 SettingsNotificationPageView from "@/src/modules/settings/ui/views/settings-notification-page-view";
import SettingsLoading from "../loading"; import SettingsLoading from "../loading";
export default async function SettingsNotificationPage() { 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 ( return (
<Suspense fallback={<SettingsLoading />}> <Suspense fallback={<SettingsLoading />}>
<SettingsNotificationPageView /> <SettingsNotificationPageView businesses={businesses} initialSettings={initialSettings} />
</Suspense> </Suspense>
); );
} }

View file

@ -11,6 +11,7 @@ import {
type BookingRefundReason, type BookingRefundReason,
} from "@/src/lib/mail/templates/booking-refund"; } from "@/src/lib/mail/templates/booking-refund";
import { toEmailBranding } from "@/src/modules/settings/lib/to-email-branding"; 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. * 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) => await removeFromWaitlistIfPresent(courseId, outcome.bookingId).catch((err) =>
console.error("Warteliste-Aufräumen fehlgeschlagen:", 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"); revalidatePath("/courses");
@ -678,6 +692,46 @@ async function removeFromWaitlistIfPresent(courseId: string, bookingId: string):
await prisma.courseWaitlistEntry.deleteMany({ where: { courseId, userId: booking.userId } }); 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( async function sendBookingRefund(
recipientEmail: string, recipientEmail: string,
courseId: 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 { combineDateAndTime, calculateRefundAmountCents } from "./cancellation-policy";
import { toEmailBranding } from "@/src/modules/settings/lib/to-email-branding"; import { toEmailBranding } from "@/src/modules/settings/lib/to-email-branding";
import { notifyCourseWaitlist } from "./notify-waitlist"; import { notifyCourseWaitlist } from "./notify-waitlist";
import { notifyBusinessManager } from "@/src/modules/notifications/lib/create-notification";
export type CancelBookingResult = export type CancelBookingResult =
| { success: true; refundedAmountCents: number; refundPercent: number } | { success: true; refundedAmountCents: number; refundPercent: number }
@ -235,6 +236,35 @@ export async function executeBookingCancellation(
).catch((err) => console.error("Storno-Mail fehlgeschlagen:", err)); ).catch((err) => console.error("Storno-Mail fehlgeschlagen:", err));
await withTimeout(emailPromise, 2000); 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 // Ein frei gewordener Platz soll allen Wartelisten-Einträgen des Kurses
// gemeldet werden - notifyCourseWaitlist prüft selbst frisch, ob der Kurs // gemeldet werden - notifyCourseWaitlist prüft selbst frisch, ob der Kurs
// noch veröffentlicht ist (bricht bei einer Kurs-weiten Absage von selbst ab). // 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 { getUserData, isManager, isEmployee, getEffectiveManagerId } from "@/src/lib/auth/getUser";
import { revalidatePath, revalidateTag } from "next/cache"; import { revalidatePath, revalidateTag } from "next/cache";
import { executeBookingCancellation } from "./cancel-booking-shared"; import { executeBookingCancellation } from "./cancel-booking-shared";
import { notifyBusinessManager } from "@/src/modules/notifications/lib/create-notification";
const COURSE_CANCELLATION_REASON = "Kurs abgesagt"; const COURSE_CANCELLATION_REASON = "Kurs abgesagt";
@ -89,6 +90,11 @@ async function executeCourseCancellation(courseId: string): Promise<CourseCancel
data: { status: "cancelled", cancelledAt: new Date() }, 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({ const bookings = await prisma.courseBooking.findMany({
where: { courseId, paymentStatus: "paid" }, where: { courseId, paymentStatus: "paid" },
select: { id: true, userId: true }, select: { id: true, userId: true },
@ -124,6 +130,33 @@ async function executeCourseCancellation(courseId: string): Promise<CourseCancel
revalidatePath("/courses"); 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 }; 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 { sendMail } from "@/src/lib/mail/resend";
import { generateWaitlistSpotAvailableEmail } from "@/src/lib/mail/templates/waitlist-spot-available"; import { generateWaitlistSpotAvailableEmail } from "@/src/lib/mail/templates/waitlist-spot-available";
import { toEmailBranding } from "@/src/modules/settings/lib/to-email-branding"; 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> { export async function notifyCourseWaitlist(courseId: string): Promise<void> {
// Kurs frisch nachladen statt einem durchgereichten Flag zu vertrauen - // Kurs frisch nachladen statt einem durchgereichten Flag zu vertrauen -
@ -31,7 +32,7 @@ export async function notifyCourseWaitlist(courseId: string): Promise<void> {
select: { select: {
status: true, status: true,
name: 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 // Promise.allSettled statt Promise.all - ein fehlgeschlagener Versand
// (z.B. ungültige/inaktive Adresse) soll die anderen Empfänger nicht blockieren. // (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) => { recipients.map(async (r) => {
const result = await sendMail({ to: r.email, subject, html, text }); const result = await sendMail({ to: r.email, subject, html, text });
// notifiedAt nur bei tatsächlich erfolgreichem Versand setzen - sonst // 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 }, where: { id: r.entryId },
data: { notifiedAt: new Date() }, data: { notifiedAt: new Date() },
}); });
} else { return true;
console.error(`Wartelisten-Mail an ${r.email} fehlgeschlagen:`, result.error);
} }
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 { useSidebar } from "@/src/components/ui/sidebar";
import { PanelLeftCloseIcon, PanelLeftIcon } from "lucide-react"; import { PanelLeftCloseIcon, PanelLeftIcon } from "lucide-react";
import { usePathname } from "next/navigation"; import { usePathname } from "next/navigation";
import NotificationBell from "@/src/modules/notifications/ui/components/NotificationBell";
const menuTitles: Record<string, string> = { const menuTitles: Record<string, string> = {
"/dashboard": "Dashboard", "/dashboard": "Dashboard",
@ -69,6 +70,10 @@ const DashboardNavbar = () => {
<div className="w-px h-6 bg-border" /> <div className="w-px h-6 bg-border" />
<h1 className="text-xl font-semibold mt-1">{pageTitle}</h1> <h1 className="text-xl font-semibold mt-1">{pageTitle}</h1>
<div className="ml-auto">
<NotificationBell />
</div>
</div> </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 = () => { "use client";
return <div>SettingsNotificationPageView</div>;
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; 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));
});
});