mirror of
https://github.com/Since-3/Kuma.git
synced 2026-10-12 21:41:17 +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");
|
||||||
|
|
@ -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])
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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).
|
||||||
|
|
|
||||||
|
|
@ -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 };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
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 = () => {
|
"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;
|
||||||
|
|
|
||||||
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