mirror of
https://github.com/Since-3/Kuma.git
synced 2026-10-12 19:51:17 +00:00
added first come first serve waitinglist with email notification
This commit is contained in:
parent
7548473174
commit
bd10088e26
9 changed files with 594 additions and 56 deletions
|
|
@ -0,0 +1,30 @@
|
|||
-- CreateTable
|
||||
CREATE TABLE "CourseWaitlistEntry" (
|
||||
"id" TEXT NOT NULL,
|
||||
"courseId" TEXT NOT NULL,
|
||||
"userId" TEXT,
|
||||
"guestEmail" TEXT,
|
||||
"guestName" TEXT,
|
||||
"notifiedAt" TIMESTAMP(3),
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "CourseWaitlistEntry_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "CourseWaitlistEntry_courseId_idx" ON "CourseWaitlistEntry"("courseId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "CourseWaitlistEntry_userId_idx" ON "CourseWaitlistEntry"("userId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "CourseWaitlistEntry_courseId_userId_key" ON "CourseWaitlistEntry"("courseId", "userId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "CourseWaitlistEntry_courseId_guestEmail_key" ON "CourseWaitlistEntry"("courseId", "guestEmail");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "CourseWaitlistEntry" ADD CONSTRAINT "CourseWaitlistEntry_courseId_fkey" FOREIGN KEY ("courseId") REFERENCES "Course"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "CourseWaitlistEntry" ADD CONSTRAINT "CourseWaitlistEntry_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
|
@ -17,32 +17,33 @@ datasource db {
|
|||
}
|
||||
|
||||
model User {
|
||||
id String @id @default(uuid())
|
||||
name String?
|
||||
complaints String?
|
||||
birthday DateTime?
|
||||
plz String?
|
||||
city String?
|
||||
street String?
|
||||
houseNumber String?
|
||||
id String @id @default(uuid())
|
||||
name String?
|
||||
complaints String?
|
||||
birthday DateTime?
|
||||
plz String?
|
||||
city String?
|
||||
street String?
|
||||
houseNumber String?
|
||||
// @unique is Prisma-managed and stays case-sensitive at the schema level —
|
||||
// Prisma has no functional-index syntax. The actual enforced constraint is
|
||||
// a case-insensitive unique index on lower(email), added via raw SQL in
|
||||
// migration 20260817000000_case_insensitive_user_email. Do not remove
|
||||
// @unique here; it keeps Prisma's drift detection from trying to recreate
|
||||
// a duplicate case-sensitive constraint.
|
||||
email String @unique
|
||||
tel String?
|
||||
gender String?
|
||||
is2FA Boolean @default(false)
|
||||
isSendingMail Boolean @default(false)
|
||||
isNewsletter Boolean @default(false)
|
||||
pbSrc String?
|
||||
isValid Boolean @default(false)
|
||||
trainings UserTraining[]
|
||||
courseBookings CourseBooking[]
|
||||
createdAt DateTime? @default(now())
|
||||
updatedAt DateTime? @updatedAt
|
||||
email String @unique
|
||||
tel String?
|
||||
gender String?
|
||||
is2FA Boolean @default(false)
|
||||
isSendingMail Boolean @default(false)
|
||||
isNewsletter Boolean @default(false)
|
||||
pbSrc String?
|
||||
isValid Boolean @default(false)
|
||||
trainings UserTraining[]
|
||||
courseBookings CourseBooking[]
|
||||
courseWaitlistEntries CourseWaitlistEntry[]
|
||||
createdAt DateTime? @default(now())
|
||||
updatedAt DateTime? @updatedAt
|
||||
}
|
||||
|
||||
model Employee {
|
||||
|
|
@ -122,10 +123,10 @@ model Business {
|
|||
}
|
||||
|
||||
model Course {
|
||||
id String @id @default(uuid())
|
||||
id String @id @default(uuid())
|
||||
name String
|
||||
sport String[]
|
||||
level String @default("any")
|
||||
level String @default("any")
|
||||
date DateTime
|
||||
timeFrom String // Anfangszeit
|
||||
timeTo String // Endzeit
|
||||
|
|
@ -133,24 +134,25 @@ model Course {
|
|||
room String
|
||||
description String // Rich text HTML content
|
||||
maxParticipants Int // Maximum number of participants
|
||||
price Float @default(10.0) // Preis in Euro
|
||||
isStandingOrder Boolean @default(false)
|
||||
price Float @default(10.0) // Preis in Euro
|
||||
isStandingOrder Boolean @default(false)
|
||||
frequency String? // daily, weekly, biweekly, monthly, custom
|
||||
weekdays String[] // For custom frequency
|
||||
weekdayTimings Json? // { "monday": { "timeFrom": "08:00", "timeTo": "09:00" }, ... }
|
||||
endDate DateTime? // Required when isStandingOrder = true
|
||||
parentCourseId String? // Null on template; set on every child instance
|
||||
parentCourse Course? @relation("StandingOrderInstances", fields: [parentCourseId], references: [id], onDelete: Cascade)
|
||||
children Course[] @relation("StandingOrderInstances")
|
||||
status String @default("draft") // draft, published, cancelled
|
||||
parentCourse Course? @relation("StandingOrderInstances", fields: [parentCourseId], references: [id], onDelete: Cascade)
|
||||
children Course[] @relation("StandingOrderInstances")
|
||||
status String @default("draft") // draft, published, cancelled
|
||||
cancelledAt DateTime? // Zeitpunkt der Kursabsage (nur gesetzt wenn status = "cancelled")
|
||||
createdBy String // Manager ID who created the course
|
||||
businessId String?
|
||||
coverImage String?
|
||||
business Business? @relation(fields: [businessId], references: [id], onDelete: SetNull)
|
||||
business Business? @relation(fields: [businessId], references: [id], onDelete: SetNull)
|
||||
bookings CourseBooking[]
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
waitlistEntries CourseWaitlistEntry[]
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@index([createdBy])
|
||||
@@index([date])
|
||||
|
|
@ -204,3 +206,20 @@ model CourseBooking {
|
|||
@@index([userId])
|
||||
@@index([guestLeadId])
|
||||
}
|
||||
|
||||
model CourseWaitlistEntry {
|
||||
id String @id @default(uuid())
|
||||
courseId String
|
||||
userId String?
|
||||
guestEmail String? // normalisiert lowercase; null wenn userId gesetzt
|
||||
guestName String? // optional, nur bei Gast-Einträgen
|
||||
notifiedAt DateTime? // Zeitpunkt der letzten "Platz frei"-Benachrichtigung, null = noch nie benachrichtigt
|
||||
course Course @relation(fields: [courseId], references: [id], onDelete: Cascade)
|
||||
user User? @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
@@unique([courseId, userId]) // Ein User kann sich nur einmal pro Kurs eintragen (NULL zählt in Postgres nicht als Duplikat, betrifft also nur registrierte User)
|
||||
@@unique([courseId, guestEmail]) // Dieselbe Gast-E-Mail nur einmal pro Kurs (analog, betrifft nur Gast-Einträge)
|
||||
@@index([courseId])
|
||||
@@index([userId])
|
||||
}
|
||||
|
|
|
|||
|
|
@ -307,6 +307,16 @@ async function handleCheckoutCompleted(session: Stripe.Checkout.Session) {
|
|||
}
|
||||
}
|
||||
|
||||
// Unabhängig von recipientEmail (Bestätigungs-Mail könnte z.B. wegen
|
||||
// fehlender Adresse übersprungen worden sein) - eine erfolgreiche Buchung
|
||||
// entfernt diese Person von der Warteliste desselben Kurses, falls sie dort
|
||||
// eingetragen war.
|
||||
if (outcome.result === "booked" && outcome.bookingId) {
|
||||
await removeFromWaitlistIfPresent(courseId, outcome.bookingId).catch((err) =>
|
||||
console.error("Warteliste-Aufräumen fehlgeschlagen:", err)
|
||||
);
|
||||
}
|
||||
|
||||
revalidatePath("/courses");
|
||||
revalidatePath("/courses/myCourses");
|
||||
|
||||
|
|
@ -648,6 +658,31 @@ async function sendBookingConfirmation(
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Entfernt die Wartelisten-Eintragung einer Person für einen Kurs, sobald
|
||||
* ihre Buchung für genau diesen Kurs bezahlt wurde. Liest userId/GuestLead
|
||||
* über die soeben abgeschlossene Buchung erneut aus, statt die Identität
|
||||
* durch die verschachtelten Gast-/User-/Fallback-Zweige oben durchzuschleifen
|
||||
* - robuster gegen künftige Änderungen an diesen Zweigen. deleteMany statt
|
||||
* delete, damit "war nie auf der Warteliste" (Normalfall) ein stilles No-Op
|
||||
* bleibt statt einen Fehler zu werfen.
|
||||
*/
|
||||
async function removeFromWaitlistIfPresent(courseId: string, bookingId: string): Promise<void> {
|
||||
const booking = await prisma.courseBooking.findUnique({
|
||||
where: { id: bookingId },
|
||||
select: { userId: true, guestLead: { select: { email: true } } },
|
||||
});
|
||||
if (!booking) return;
|
||||
|
||||
if (booking.userId) {
|
||||
await prisma.courseWaitlistEntry.deleteMany({ where: { courseId, userId: booking.userId } });
|
||||
} else if (booking.guestLead?.email) {
|
||||
await prisma.courseWaitlistEntry.deleteMany({
|
||||
where: { courseId, guestEmail: booking.guestLead.email },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function sendBookingRefund(
|
||||
recipientEmail: string,
|
||||
courseId: string,
|
||||
|
|
|
|||
105
src/lib/mail/templates/waitlist-spot-available.ts
Normal file
105
src/lib/mail/templates/waitlist-spot-available.ts
Normal file
|
|
@ -0,0 +1,105 @@
|
|||
/**
|
||||
* E-Mail Template für "Ein Platz ist frei geworden"
|
||||
*
|
||||
* Wird an alle Wartelisten-Einträge eines Kurses gesendet, sobald eine
|
||||
* bezahlte Buchung storniert wird. Es gibt keine Reservierung - alle
|
||||
* Wartelisten-Einträge bekommen dieselbe Mail gleichzeitig, wer zuerst
|
||||
* tatsächlich bucht, bekommt den Platz. Der Text macht das bewusst explizit,
|
||||
* damit niemand denkt, ihm sei der Platz garantiert.
|
||||
*/
|
||||
|
||||
import { escapeHtml, sanitizeUrl, singleLine } from "./utils";
|
||||
import { renderButton, renderInfoBox } from "./components";
|
||||
import { renderEmailLayout } from "./layout";
|
||||
import { KUMASY_BRANDING, type EmailBranding } from "./tokens";
|
||||
|
||||
interface WaitlistSpotAvailableEmailParams {
|
||||
courseName: string;
|
||||
businessName: string;
|
||||
courseUrl: string;
|
||||
companyName?: string;
|
||||
branding?: EmailBranding;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generiert HTML für die "Platz frei"-Benachrichtigung
|
||||
*/
|
||||
export function generateWaitlistSpotAvailableEmail({
|
||||
courseName,
|
||||
businessName,
|
||||
courseUrl,
|
||||
companyName = "Ihr Unternehmen",
|
||||
branding = KUMASY_BRANDING,
|
||||
}: WaitlistSpotAvailableEmailParams): { subject: string; html: string; text: string } {
|
||||
const safeCourseName = escapeHtml(courseName);
|
||||
const safeBusinessName = escapeHtml(businessName);
|
||||
const safeCourseUrl = sanitizeUrl(courseUrl);
|
||||
|
||||
const subject = singleLine(`Ein Platz ist frei geworden – ${courseName}`);
|
||||
|
||||
const bodyHtml = `
|
||||
<p style="margin: 0 0 20px; color: #333333; font-size: 16px; line-height: 1.6;">
|
||||
Hallo,
|
||||
</p>
|
||||
|
||||
<p style="margin: 0 0 30px; color: #333333; font-size: 16px; line-height: 1.6;">
|
||||
gute Nachrichten: In Ihrem gewünschten Kurs ist gerade ein Platz frei geworden.
|
||||
</p>
|
||||
|
||||
${renderInfoBox({
|
||||
rows: [
|
||||
{
|
||||
label: "Kurs",
|
||||
valueHtml: `<span style="font-size: 18px; font-weight: 600;">${safeCourseName}</span>`,
|
||||
},
|
||||
{ label: "Anbieter", valueHtml: safeBusinessName },
|
||||
],
|
||||
})}
|
||||
|
||||
<p style="margin: 0 0 20px; color: #333333; font-size: 16px; line-height: 1.6;">
|
||||
Da mehrere Personen auf der Warteliste stehen können, wird der Platz nach dem
|
||||
Prinzip <strong>"first come, first served"</strong> vergeben - sichern Sie sich
|
||||
Ihren Platz am besten zügig.
|
||||
</p>
|
||||
|
||||
${renderButton({ label: "Jetzt buchen", href: courseUrl, branding })}
|
||||
|
||||
<p style="margin: 20px 0 0; color: #666666; font-size: 14px; line-height: 1.6; text-align: center;">
|
||||
Oder kopieren Sie diesen Link in Ihren Browser:
|
||||
</p>
|
||||
<p style="margin: 8px 0 0; color: #1E293B; font-size: 14px; word-break: break-all; text-align: center;">
|
||||
${safeCourseUrl}
|
||||
</p>
|
||||
`;
|
||||
|
||||
const html = renderEmailLayout({
|
||||
title: "Ein Platz ist frei geworden",
|
||||
headerHeading: "Ein Platz ist frei! 🎉",
|
||||
bodyHtml,
|
||||
branding,
|
||||
});
|
||||
|
||||
const text = `
|
||||
Ein Platz ist frei geworden – ${courseName}
|
||||
|
||||
Hallo,
|
||||
|
||||
gute Nachrichten: In Ihrem gewünschten Kurs ist gerade ein Platz frei geworden.
|
||||
|
||||
Kurs: ${courseName}
|
||||
Anbieter: ${businessName}
|
||||
|
||||
Da mehrere Personen auf der Warteliste stehen können, wird der Platz nach dem
|
||||
Prinzip "first come, first served" vergeben - sichern Sie sich Ihren Platz am
|
||||
besten zügig.
|
||||
|
||||
Jetzt buchen: ${safeCourseUrl}
|
||||
|
||||
${companyName}
|
||||
Diese E-Mail wurde automatisch generiert.
|
||||
|
||||
Powered by Kumasy
|
||||
`;
|
||||
|
||||
return { subject, html, text };
|
||||
}
|
||||
|
|
@ -219,21 +219,25 @@ export async function checkUserBookingStatus(courseId: string) {
|
|||
return {
|
||||
success: true,
|
||||
isBooked: false,
|
||||
isOnWaitlist: false,
|
||||
};
|
||||
}
|
||||
|
||||
const booking = await prisma.courseBooking.findUnique({
|
||||
where: {
|
||||
courseId_userId: {
|
||||
courseId,
|
||||
userId: userData.id,
|
||||
},
|
||||
},
|
||||
});
|
||||
// Beide Checks im selben Aufruf statt eines separaten Wartelisten-Calls,
|
||||
// damit der Dialog weiterhin nur einen Roundtrip beim Öffnen braucht.
|
||||
const [booking, waitlistEntry] = await Promise.all([
|
||||
prisma.courseBooking.findUnique({
|
||||
where: { courseId_userId: { courseId, userId: userData.id } },
|
||||
}),
|
||||
prisma.courseWaitlistEntry.findUnique({
|
||||
where: { courseId_userId: { courseId, userId: userData.id } },
|
||||
}),
|
||||
]);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
isBooked: booking?.paymentStatus === "paid",
|
||||
isOnWaitlist: waitlistEntry !== null,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error("Error checking booking status:", error);
|
||||
|
|
@ -241,6 +245,7 @@ export async function checkUserBookingStatus(courseId: string) {
|
|||
success: false,
|
||||
error: "Fehler beim Prüfen des Buchungsstatus",
|
||||
isBooked: false,
|
||||
isOnWaitlist: false,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import { sendMail } from "@/src/lib/mail/resend";
|
|||
import { generateBookingRefundEmail } from "@/src/lib/mail/templates/booking-refund";
|
||||
import { combineDateAndTime, calculateRefundAmountCents } from "./cancellation-policy";
|
||||
import { toEmailBranding } from "@/src/modules/settings/lib/to-email-branding";
|
||||
import { notifyCourseWaitlist } from "./notify-waitlist";
|
||||
|
||||
export type CancelBookingResult =
|
||||
| { success: true; refundedAmountCents: number; refundPercent: number }
|
||||
|
|
@ -215,6 +216,14 @@ export async function executeBookingCancellation(
|
|||
).catch((err) => console.error("Storno-Mail fehlgeschlagen:", err));
|
||||
await withTimeout(emailPromise, 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).
|
||||
const waitlistPromise = notifyCourseWaitlist(booking.course.id).catch((err) =>
|
||||
console.error("Warteliste-Benachrichtigung fehlgeschlagen:", err)
|
||||
);
|
||||
await withTimeout(waitlistPromise, 2000);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
refundedAmountCents: amountRefundedCents,
|
||||
|
|
|
|||
119
src/modules/courses/actions/notify-waitlist.ts
Normal file
119
src/modules/courses/actions/notify-waitlist.ts
Normal file
|
|
@ -0,0 +1,119 @@
|
|||
/**
|
||||
* Wartelisten-Benachrichtigung bei freiwerdendem Platz
|
||||
*
|
||||
* Wird von executeBookingCancellation aufgerufen, sobald eine bezahlte
|
||||
* Buchung erfolgreich storniert wurde. Benachrichtigt ALLE aktuellen
|
||||
* Wartelisten-Einträge des Kurses gleichzeitig - kein Reservierungs-
|
||||
* mechanismus, wer zuerst tatsächlich bucht, bekommt den Platz.
|
||||
*/
|
||||
|
||||
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";
|
||||
|
||||
export async function notifyCourseWaitlist(courseId: string): Promise<void> {
|
||||
// Kurs frisch nachladen statt einem durchgereichten Flag zu vertrauen -
|
||||
// robust unabhängig davon, ob eine Kurs-weite Absage (executeCourseCancellation)
|
||||
// den Status inzwischen bereits auf "cancelled" gesetzt hat. Ein Kurs, der
|
||||
// nicht mehr veröffentlicht ist, soll niemanden zum Buchen einladen.
|
||||
const course = await prisma.course.findUnique({
|
||||
where: { id: courseId },
|
||||
select: {
|
||||
status: true,
|
||||
name: true,
|
||||
business: { select: { name: true, branding: true, slug: true } },
|
||||
},
|
||||
});
|
||||
|
||||
if (!course || course.status !== "published" || !course.business?.slug) return;
|
||||
|
||||
const entries = await prisma.courseWaitlistEntry.findMany({
|
||||
where: { courseId },
|
||||
select: { id: true, userId: true, guestEmail: true },
|
||||
});
|
||||
if (entries.length === 0) return;
|
||||
|
||||
// Wer inzwischen bereits einen bezahlten Platz hat, soll nicht erneut
|
||||
// eingeladen werden - registrierte User direkt über userId, Gäste nur
|
||||
// lesend über die bestehende Buchung↔GuestLead-Verknüpfung (es wird dabei
|
||||
// kein GuestLead erzeugt oder verändert, nur eine vorhandene Buchung gelesen).
|
||||
const userIds = entries.map((e) => e.userId).filter((id): id is string => id !== null);
|
||||
const paidUserIds = new Set(
|
||||
userIds.length
|
||||
? (
|
||||
await prisma.courseBooking.findMany({
|
||||
where: { courseId, userId: { in: userIds }, paymentStatus: "paid" },
|
||||
select: { userId: true },
|
||||
})
|
||||
).map((b) => b.userId)
|
||||
: []
|
||||
);
|
||||
|
||||
const guestEmails = entries.map((e) => e.guestEmail).filter((e): e is string => e !== null);
|
||||
const paidGuestEmails = new Set(
|
||||
guestEmails.length
|
||||
? (
|
||||
await prisma.courseBooking.findMany({
|
||||
where: { courseId, paymentStatus: "paid", guestLead: { email: { in: guestEmails } } },
|
||||
select: { guestLead: { select: { email: true } } },
|
||||
})
|
||||
)
|
||||
.map((b) => b.guestLead?.email)
|
||||
.filter((email): email is string => !!email)
|
||||
: []
|
||||
);
|
||||
|
||||
const toNotify = entries.filter((e) =>
|
||||
e.userId
|
||||
? !paidUserIds.has(e.userId)
|
||||
: e.guestEmail
|
||||
? !paidGuestEmails.has(e.guestEmail)
|
||||
: false
|
||||
);
|
||||
if (toNotify.length === 0) return;
|
||||
|
||||
const notifyUserIds = toNotify.filter((e) => e.userId).map((e) => e.userId!);
|
||||
const usersById = new Map(
|
||||
notifyUserIds.length
|
||||
? (
|
||||
await prisma.user.findMany({
|
||||
where: { id: { in: notifyUserIds } },
|
||||
select: { id: true, email: true },
|
||||
})
|
||||
).map((u) => [u.id, u.email] as const)
|
||||
: []
|
||||
);
|
||||
|
||||
const recipients = toNotify
|
||||
.map((e) => ({
|
||||
entryId: e.id,
|
||||
email: e.userId ? usersById.get(e.userId) : e.guestEmail,
|
||||
}))
|
||||
.filter((r): r is { entryId: string; email: string } => !!r.email);
|
||||
if (recipients.length === 0) return;
|
||||
|
||||
const baseUrl = process.env.NEXT_PUBLIC_APP_URL ?? "http://localhost:3000";
|
||||
const courseUrl = `${baseUrl}/business/${course.business.slug}?course=${courseId}`;
|
||||
const branding = toEmailBranding(course.business);
|
||||
|
||||
const { subject, html, text } = generateWaitlistSpotAvailableEmail({
|
||||
courseName: course.name,
|
||||
businessName: course.business.name,
|
||||
courseUrl,
|
||||
companyName: process.env.COMPANY_NAME,
|
||||
branding,
|
||||
});
|
||||
|
||||
// Promise.allSettled statt Promise.all - ein fehlgeschlagener Versand
|
||||
// (z.B. ungültige/inaktive Adresse) soll die anderen Empfänger nicht blockieren.
|
||||
await Promise.allSettled(
|
||||
recipients.map(async (r) => {
|
||||
await sendMail({ to: r.email, subject, html, text });
|
||||
await prisma.courseWaitlistEntry.update({
|
||||
where: { id: r.entryId },
|
||||
data: { notifiedAt: new Date() },
|
||||
});
|
||||
})
|
||||
);
|
||||
}
|
||||
115
src/modules/courses/actions/waitlist-actions.ts
Normal file
115
src/modules/courses/actions/waitlist-actions.ts
Normal file
|
|
@ -0,0 +1,115 @@
|
|||
/**
|
||||
* Waitlist Actions - Server-seitige Aktionen für die Kurs-Warteliste
|
||||
*
|
||||
* Einfaches Wartelisten-Konzept ohne Reservierung: bei einem vollen Kurs
|
||||
* können sich registrierte User und Gäste eintragen. Wird ein Platz durch
|
||||
* eine Stornierung frei, werden alle eingetragenen Personen gleichzeitig per
|
||||
* E-Mail informiert (siehe notify-waitlist.ts) - wer zuerst tatsächlich
|
||||
* bucht, bekommt den Platz.
|
||||
*/
|
||||
|
||||
"use server";
|
||||
|
||||
import { prisma } from "@/src/lib/prisma";
|
||||
import { getUserData, isUser } from "@/src/lib/auth/getUser";
|
||||
|
||||
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
|
||||
export type JoinWaitlistResult =
|
||||
| { success: true; alreadyOnWaitlist: boolean }
|
||||
| { success: false; error: string };
|
||||
|
||||
/**
|
||||
* Prüft, ob ein Kurs überhaupt wartelisten-fähig ist: muss existieren,
|
||||
* veröffentlicht sein und aktuell voll sein (bezahlte Buchungen >=
|
||||
* maxParticipants) - dieselbe "voll"-Definition, die überall sonst im
|
||||
* Code verwendet wird (paymentStatus: "paid").
|
||||
*/
|
||||
async function assertCourseIsFullAndJoinable(
|
||||
courseId: string
|
||||
): Promise<{ ok: true } | { ok: false; error: string }> {
|
||||
const course = await prisma.course.findUnique({
|
||||
where: { id: courseId },
|
||||
select: {
|
||||
status: true,
|
||||
maxParticipants: true,
|
||||
_count: { select: { bookings: { where: { paymentStatus: "paid" } } } },
|
||||
},
|
||||
});
|
||||
|
||||
if (!course) return { ok: false, error: "Kurs nicht gefunden." };
|
||||
if (course.status !== "published") {
|
||||
return { ok: false, error: "Dieser Kurs ist nicht verfügbar." };
|
||||
}
|
||||
if (course._count.bookings < course.maxParticipants) {
|
||||
return { ok: false, error: "Dieser Kurs ist nicht ausgebucht." };
|
||||
}
|
||||
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
export async function joinWaitlistAsUser(courseId: string): Promise<JoinWaitlistResult> {
|
||||
const userData = await getUserData();
|
||||
if (!userData || !isUser(userData)) {
|
||||
return { success: false, error: "Nicht angemeldet." };
|
||||
}
|
||||
|
||||
const validation = await assertCourseIsFullAndJoinable(courseId);
|
||||
if (!validation.ok) return { success: false, error: validation.error };
|
||||
|
||||
// Bereits bezahlt angemeldet? Eintrag in die Warteliste wäre redundant.
|
||||
const existingBooking = await prisma.courseBooking.findUnique({
|
||||
where: { courseId_userId: { courseId, userId: userData.id } },
|
||||
select: { paymentStatus: true },
|
||||
});
|
||||
if (existingBooking?.paymentStatus === "paid") {
|
||||
return { success: false, error: "Sie sind für diesen Kurs bereits angemeldet." };
|
||||
}
|
||||
|
||||
const existingEntry = await prisma.courseWaitlistEntry.findUnique({
|
||||
where: { courseId_userId: { courseId, userId: userData.id } },
|
||||
});
|
||||
if (existingEntry) {
|
||||
return { success: true, alreadyOnWaitlist: true };
|
||||
}
|
||||
|
||||
await prisma.courseWaitlistEntry.create({
|
||||
data: { courseId, userId: userData.id },
|
||||
});
|
||||
|
||||
return { success: true, alreadyOnWaitlist: false };
|
||||
}
|
||||
|
||||
export async function joinWaitlistAsGuest(
|
||||
courseId: string,
|
||||
guestEmail: string,
|
||||
guestName?: string
|
||||
): Promise<JoinWaitlistResult> {
|
||||
const normalizedEmail = guestEmail.trim().toLowerCase();
|
||||
if (!EMAIL_RE.test(normalizedEmail)) {
|
||||
return { success: false, error: "Bitte eine gültige E-Mail-Adresse angeben." };
|
||||
}
|
||||
|
||||
const validation = await assertCourseIsFullAndJoinable(courseId);
|
||||
if (!validation.ok) return { success: false, error: validation.error };
|
||||
|
||||
// Kein Guard gegen bereits gebuchte Gäste - ohne GuestLead-Verknüpfung gibt
|
||||
// es keine verlässliche Vor-Identität; im schlimmsten Fall eine
|
||||
// überflüssige zusätzliche Mail später, unkritisch.
|
||||
const existingEntry = await prisma.courseWaitlistEntry.findUnique({
|
||||
where: { courseId_guestEmail: { courseId, guestEmail: normalizedEmail } },
|
||||
});
|
||||
if (existingEntry) {
|
||||
return { success: true, alreadyOnWaitlist: true };
|
||||
}
|
||||
|
||||
await prisma.courseWaitlistEntry.create({
|
||||
data: {
|
||||
courseId,
|
||||
guestEmail: normalizedEmail,
|
||||
guestName: guestName?.trim() || null,
|
||||
},
|
||||
});
|
||||
|
||||
return { success: true, alreadyOnWaitlist: false };
|
||||
}
|
||||
|
|
@ -10,6 +10,7 @@ import { VisuallyHidden } from "@radix-ui/react-visually-hidden";
|
|||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/src/components/ui/tooltip";
|
||||
import DOMPurify from "dompurify";
|
||||
import { checkUserBookingStatus } from "../../actions/booking-actions";
|
||||
import { joinWaitlistAsUser, joinWaitlistAsGuest } from "../../actions/waitlist-actions";
|
||||
import { getContrastTextColor } from "@/src/modules/settings/lib/contrast";
|
||||
import { toast } from "sonner";
|
||||
|
||||
|
|
@ -41,6 +42,17 @@ const levelConfig: Record<string, { label: string; bg: string; text: string }> =
|
|||
pro: { label: "Profi", bg: "bg-red-200", text: "text-red-700" },
|
||||
};
|
||||
|
||||
/**
|
||||
* Einheitliche "voll"-Definition für Karte und Dialog - vorher wurde dieselbe
|
||||
* Formel an zwei Stellen unabhängig dupliziert, was ein Auseinanderlaufen
|
||||
* riskierte, sobald eine dritte Stelle (Warteliste) dieselbe Prüfung braucht.
|
||||
*/
|
||||
function computeIsFull(course: Pick<PublicCourse, "currentParticipants" | "maxParticipants">) {
|
||||
const safeCurrent = Math.max(0, course.currentParticipants);
|
||||
const safeMax = Math.max(0, course.maxParticipants);
|
||||
return safeCurrent >= safeMax;
|
||||
}
|
||||
|
||||
export interface TrainerProfile {
|
||||
id: string;
|
||||
firstName: string | null;
|
||||
|
|
@ -143,6 +155,9 @@ export const BookingDialog = ({
|
|||
const sanitizedDescription = useSanitizedHtml(course.description);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [isBooked, setIsBooked] = useState(false);
|
||||
const [isOnWaitlist, setIsOnWaitlist] = useState(false);
|
||||
const [isJoiningWaitlist, setIsJoiningWaitlist] = useState(false);
|
||||
const [isJoiningGuestWaitlist, setIsJoiningGuestWaitlist] = useState(false);
|
||||
const [isCheckingStatus, setIsCheckingStatus] = useState(true);
|
||||
const [showGuestForm, setShowGuestForm] = useState(false);
|
||||
const [guestEmail, setGuestEmail] = useState("");
|
||||
|
|
@ -152,7 +167,7 @@ export const BookingDialog = ({
|
|||
|
||||
const safeCurrent = Math.max(0, course.currentParticipants);
|
||||
const safeMax = Math.max(0, course.maxParticipants);
|
||||
const isFull = safeCurrent >= safeMax;
|
||||
const isFull = computeIsFull(course);
|
||||
const level = levelConfig[course.level] ?? levelConfig.any;
|
||||
|
||||
const formattedDate = new Date(course.date).toLocaleDateString("de-DE", {
|
||||
|
|
@ -177,9 +192,11 @@ export const BookingDialog = ({
|
|||
const result = await checkUserBookingStatus(course.id);
|
||||
if (!active) return;
|
||||
setIsBooked(result.success ? result.isBooked : false);
|
||||
setIsOnWaitlist(result.isOnWaitlist);
|
||||
} catch {
|
||||
if (!active) return;
|
||||
setIsBooked(false);
|
||||
setIsOnWaitlist(false);
|
||||
} finally {
|
||||
if (active) setIsCheckingStatus(false);
|
||||
}
|
||||
|
|
@ -250,6 +267,52 @@ export const BookingDialog = ({
|
|||
}
|
||||
};
|
||||
|
||||
const handleJoinWaitlist = async () => {
|
||||
setIsJoiningWaitlist(true);
|
||||
try {
|
||||
const result = await joinWaitlistAsUser(course.id);
|
||||
if (!result.success) {
|
||||
toast.error(result.error);
|
||||
return;
|
||||
}
|
||||
setIsOnWaitlist(true);
|
||||
toast.success(
|
||||
result.alreadyOnWaitlist
|
||||
? "Sie stehen bereits auf der Warteliste."
|
||||
: "Sie wurden auf die Warteliste eingetragen."
|
||||
);
|
||||
} catch {
|
||||
toast.error("Ein unerwarteter Fehler ist aufgetreten");
|
||||
} finally {
|
||||
setIsJoiningWaitlist(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleGuestJoinWaitlist = async () => {
|
||||
const normalizedEmail = guestEmail.trim().toLowerCase();
|
||||
if (!EMAIL_RE.test(normalizedEmail)) {
|
||||
toast.error("Bitte eine gültige E-Mail-Adresse angeben.");
|
||||
return;
|
||||
}
|
||||
setIsJoiningGuestWaitlist(true);
|
||||
try {
|
||||
const result = await joinWaitlistAsGuest(course.id, normalizedEmail, guestName);
|
||||
if (!result.success) {
|
||||
toast.error(result.error);
|
||||
return;
|
||||
}
|
||||
toast.success(
|
||||
result.alreadyOnWaitlist
|
||||
? "Sie stehen bereits auf der Warteliste."
|
||||
: "Sie wurden auf die Warteliste eingetragen."
|
||||
);
|
||||
} catch {
|
||||
toast.error("Ein unerwarteter Fehler ist aufgetreten");
|
||||
} finally {
|
||||
setIsJoiningGuestWaitlist(false);
|
||||
}
|
||||
};
|
||||
|
||||
const adminStatus = isAdminView
|
||||
? (ADMIN_PAYMENT_LABELS[paymentStatus!] ?? ADMIN_PAYMENT_LABELS.pending)
|
||||
: null;
|
||||
|
|
@ -423,14 +486,29 @@ export const BookingDialog = ({
|
|||
<Button disabled className="flex-1 sm:flex-initial">
|
||||
✓ Angemeldet
|
||||
</Button>
|
||||
) : isFull ? (
|
||||
isOnWaitlist ? (
|
||||
<Button disabled className="flex-1 sm:flex-initial">
|
||||
✓ Auf Warteliste
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
onClick={handleJoinWaitlist}
|
||||
disabled={isJoiningWaitlist}
|
||||
className="flex-1 sm:flex-initial"
|
||||
style={accentButtonStyle}
|
||||
>
|
||||
{isJoiningWaitlist ? "Wird eingetragen…" : "Auf Warteliste eintragen"}
|
||||
</Button>
|
||||
)
|
||||
) : (
|
||||
<Button
|
||||
onClick={() => handleBooking()}
|
||||
disabled={isSubmitting || isFull}
|
||||
disabled={isSubmitting}
|
||||
className="flex-1 sm:flex-initial"
|
||||
style={!isFull ? accentButtonStyle : undefined}
|
||||
style={accentButtonStyle}
|
||||
>
|
||||
{isSubmitting ? "Wird gebucht…" : isFull ? "Ausgebucht" : "Buchen"}
|
||||
{isSubmitting ? "Wird gebucht…" : "Buchen"}
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
|
|
@ -438,11 +516,14 @@ export const BookingDialog = ({
|
|||
</div>
|
||||
</div>
|
||||
|
||||
{/* Gast-Formular: erscheint nach erstem 401 (nicht eingeloggt) */}
|
||||
{!isAdminView && showGuestForm && !isBooked && (
|
||||
{/* Gast-Formular: erscheint nach erstem 401 (nicht eingeloggt), oder
|
||||
direkt bei vollem Kurs - ein anonymer Gast hat sonst keinen Weg,
|
||||
sich für die Warteliste einzutragen, ohne vorher einen
|
||||
fehlschlagenden Buchungsversuch auszulösen. */}
|
||||
{!isAdminView && !isCheckingStatus && !isBooked && (showGuestForm || isFull) && (
|
||||
<div className="border-t border-gray-100 pt-3 flex flex-col gap-2">
|
||||
<p className="text-xs text-gray-500">
|
||||
Ohne Konto buchen – oder{" "}
|
||||
{isFull ? "Ohne Konto auf die Warteliste – oder" : "Ohne Konto buchen – oder"}{" "}
|
||||
<Link href="/login" className="underline text-gray-700 hover:text-gray-900">
|
||||
anmelden
|
||||
</Link>
|
||||
|
|
@ -463,14 +544,27 @@ export const BookingDialog = ({
|
|||
onChange={(e) => setGuestName(e.target.value)}
|
||||
className="w-full rounded-md border border-gray-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-gray-400"
|
||||
/>
|
||||
<Button
|
||||
onClick={() => handleBooking({ asGuest: true })}
|
||||
disabled={isSubmitting || isFull || !EMAIL_RE.test(guestEmail.trim().toLowerCase())}
|
||||
className="w-full"
|
||||
style={accentButtonStyle}
|
||||
>
|
||||
{isSubmitting ? "Wird gebucht…" : "Als Gast buchen"}
|
||||
</Button>
|
||||
{isFull ? (
|
||||
<Button
|
||||
onClick={handleGuestJoinWaitlist}
|
||||
disabled={
|
||||
isJoiningGuestWaitlist || !EMAIL_RE.test(guestEmail.trim().toLowerCase())
|
||||
}
|
||||
className="w-full"
|
||||
style={accentButtonStyle}
|
||||
>
|
||||
{isJoiningGuestWaitlist ? "Wird eingetragen…" : "Auf Warteliste eintragen"}
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
onClick={() => handleBooking({ asGuest: true })}
|
||||
disabled={isSubmitting || !EMAIL_RE.test(guestEmail.trim().toLowerCase())}
|
||||
className="w-full"
|
||||
style={accentButtonStyle}
|
||||
>
|
||||
{isSubmitting ? "Wird gebucht…" : "Als Gast buchen"}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
|
@ -489,7 +583,7 @@ const PublicCourseCard = ({
|
|||
|
||||
const cardSafeCurrent = Math.max(0, course.currentParticipants);
|
||||
const cardSafeMax = Math.max(0, course.maxParticipants);
|
||||
const isFull = cardSafeCurrent >= cardSafeMax;
|
||||
const isFull = computeIsFull(course);
|
||||
const currentLevel = levelConfig[course.level] ?? levelConfig.any;
|
||||
|
||||
const formattedDate = new Date(course.date).toLocaleDateString("de-DE", {
|
||||
|
|
@ -576,8 +670,15 @@ const PublicCourseCard = ({
|
|||
</div>
|
||||
|
||||
{isFull ? (
|
||||
<Button disabled variant="outline" className="w-full">
|
||||
Ausgebucht
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setDialogOpen(true);
|
||||
}}
|
||||
>
|
||||
Warteliste
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue