mirror of
https://github.com/Since-3/Kuma.git
synced 2026-10-12 20:51:18 +00:00
Merge pull request #154 from Since-3/feature/notification-system
added in-app notification system
This commit is contained in:
commit
285eeb1536
20 changed files with 1381 additions and 29 deletions
|
|
@ -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");
|
||||
|
|
@ -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])
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
121
src/modules/notifications/actions/notification-actions.ts
Normal file
121
src/modules/notifications/actions/notification-actions.ts
Normal 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 };
|
||||
}
|
||||
}
|
||||
102
src/modules/notifications/lib/create-notification.ts
Normal file
102
src/modules/notifications/lib/create-notification.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
92
src/modules/notifications/lib/notification-types.ts
Normal file
92
src/modules/notifications/lib/notification-types.ts
Normal 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;
|
||||
}
|
||||
138
src/modules/notifications/ui/components/NotificationBell.tsx
Normal file
138
src/modules/notifications/ui/components/NotificationBell.tsx
Normal 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;
|
||||
62
src/modules/notifications/ui/components/NotificationItem.tsx
Normal file
62
src/modules/notifications/ui/components/NotificationItem.tsx
Normal 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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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.",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
125
tests/notifications/create-notification.test.ts
Normal file
125
tests/notifications/create-notification.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
109
tests/notifications/notification-actions.test.ts
Normal file
109
tests/notifications/notification-actions.test.ts
Normal 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) },
|
||||
});
|
||||
});
|
||||
});
|
||||
88
tests/notifications/notification-settings-actions.test.ts
Normal file
88
tests/notifications/notification-settings-actions.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
54
tests/notifications/notification-types.test.ts
Normal file
54
tests/notifications/notification-types.test.ts
Normal 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));
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue