see comments + dynamic timeout instead of schedule job

This commit is contained in:
Rajpreet0 2026-08-27 19:31:21 +02:00
parent bd10088e26
commit 034e7386c8
7 changed files with 165 additions and 169 deletions

View file

@ -0,0 +1,15 @@
/*
Warnings:
- You are about to drop the column `guestEmail` on the `CourseWaitlistEntry` table. All the data in the column will be lost.
- You are about to drop the column `guestName` on the `CourseWaitlistEntry` table. All the data in the column will be lost.
- Made the column `userId` on table `CourseWaitlistEntry` required. This step will fail if there are existing NULL values in that column.
*/
-- DropIndex
DROP INDEX "CourseWaitlistEntry_courseId_guestEmail_key";
-- AlterTable
ALTER TABLE "CourseWaitlistEntry" DROP COLUMN "guestEmail",
DROP COLUMN "guestName",
ALTER COLUMN "userId" SET NOT NULL;

View file

@ -207,19 +207,21 @@ model CourseBooking {
@@index([guestLeadId])
}
// Bewusst nur für registrierte User (kein guestEmail-Feld): ein
// unauthentifiziertes Gast-Formular würde eine beliebige, nicht verifizierte
// E-Mail-Adresse als Benachrichtigungsempfänger akzeptieren, ohne dass deren
// Kontrolle bestätigt wurde. Der Login-Zwang schließt das und wirkt nebenbei
// als Anreiz zur Registrierung (Lead-Generierung).
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
userId String
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)
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)
@@unique([courseId, userId]) // Ein User kann sich nur einmal pro Kurs eintragen
@@index([courseId])
@@index([userId])
}

View file

@ -660,27 +660,22 @@ 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.
* ihre Buchung für genau diesen Kurs bezahlt wurde. Liest userId ü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. Die Warteliste kennt
* ausschließlich registrierte User (kein Gast-Beitritt möglich), daher genügt
* ein einzelner userId-Check. 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 } } },
select: { userId: true },
});
if (!booking) return;
if (!booking?.userId) 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 },
});
}
await prisma.courseWaitlistEntry.deleteMany({ where: { courseId, userId: booking.userId } });
}
async function sendBookingRefund(

View file

@ -21,6 +21,25 @@ async function withTimeout<T>(promise: Promise<T>, ms: number): Promise<T | null
]);
}
// Sockelzeit für den Wartelisten-Mailversand (deckt Kurs-/User-Lookups plus
// eine Handvoll E-Mails ab) + zusätzliche Zeit pro Wartelisten-Eintrag, da
// der Versand als Promise.allSettled-Fan-out läuft und mit mehr Empfängern
// länger dauert. Gedeckelt, damit ein sehr großer Kurs die serverlose
// Funktion nicht in die Nähe ihres eigenen Zeitlimits treibt (Vercel Hobby:
// 60s/Funktion) - danach bleiben unvollständige Sends bewusst hingenommen
// (siehe Docblock an notifyCourseWaitlist), bis der Versand auf einen
// eigenen Hintergrund-Job umzieht.
const WAITLIST_TIMEOUT_BASE_MS = 2000;
const WAITLIST_TIMEOUT_PER_ENTRY_MS = 300;
const WAITLIST_TIMEOUT_MAX_MS = 25000;
function computeWaitlistTimeoutMs(entryCount: number): number {
return Math.min(
WAITLIST_TIMEOUT_MAX_MS,
WAITLIST_TIMEOUT_BASE_MS + entryCount * WAITLIST_TIMEOUT_PER_ENTRY_MS
);
}
async function sendCancellationEmail(
booking: {
userId: string | null;
@ -219,10 +238,22 @@ export async function executeBookingCancellation(
// 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).
//
// Das Timeout richtet sich nach der Anzahl der Wartelisten-Einträge statt
// eines festen Werts - ein Kurs mit vielen Einträgen braucht für den
// Promise.allSettled-Fan-out entsprechend länger, ein fixes kurzes Limit
// würde dort systematisch Sends abschneiden (siehe computeWaitlistTimeoutMs
// oben). Bei einer sehr großen Warteliste greift trotzdem der Deckel -
// verbleibende Sends bleiben dann unvollständig (kein Retry), bis der
// Versand auf einen eigenen, vom Request-Lebenszyklus unabhängigen
// Hintergrund-Job umzieht.
const waitlistEntryCount = await prisma.courseWaitlistEntry.count({
where: { courseId: booking.course.id },
});
const waitlistPromise = notifyCourseWaitlist(booking.course.id).catch((err) =>
console.error("Warteliste-Benachrichtigung fehlgeschlagen:", err)
);
await withTimeout(waitlistPromise, 2000);
await withTimeout(waitlistPromise, computeWaitlistTimeoutMs(waitlistEntryCount));
return {
success: true,

View file

@ -5,6 +5,15 @@
* Buchung erfolgreich storniert wurde. Benachrichtigt ALLE aktuellen
* Wartelisten-Einträge des Kurses gleichzeitig - kein Reservierungs-
* mechanismus, wer zuerst tatsächlich bucht, bekommt den Platz.
*
* Bekannte Einschränkung: läuft aktuell synchron innerhalb des
* Stornierungs-Requests. Das dortige withTimeout-Limit skaliert mit der
* Anzahl der Wartelisten-Einträge (siehe computeWaitlistTimeoutMs in
* cancel-booking-shared.ts), ist aber gedeckelt - bei einem außergewöhnlich
* großen Kurs können danach einzelne Sends nicht mehr abgewartet werden und
* bleiben unvollständig. Bewusst zurückgestellt, bis die geplante
* Scheduled-Job-Infrastruktur steht; dann läuft dieser Versand als eigener,
* vom Request-Lebenszyklus unabhängiger Job.
*/
import { prisma } from "@/src/lib/prisma";
@ -30,66 +39,36 @@ export async function notifyCourseWaitlist(courseId: string): Promise<void> {
const entries = await prisma.courseWaitlistEntry.findMany({
where: { courseId },
select: { id: true, userId: true, guestEmail: true },
select: { id: true, userId: 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);
// eingeladen werden.
const userIds = entries.map((e) => e.userId);
const paidUserIds = new Set(
userIds.length
? (
await prisma.courseBooking.findMany({
where: { courseId, userId: { in: userIds }, paymentStatus: "paid" },
select: { userId: true },
})
).map((b) => b.userId)
: []
(
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
);
const toNotify = entries.filter((e) => !paidUserIds.has(e.userId));
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)
: []
(
await prisma.user.findMany({
where: { id: { in: toNotify.map((e) => e.userId) } },
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,
}))
.map((e) => ({ entryId: e.id, email: usersById.get(e.userId) }))
.filter((r): r is { entryId: string; email: string } => !!r.email);
if (recipients.length === 0) return;
@ -109,11 +88,20 @@ export async function notifyCourseWaitlist(courseId: string): Promise<void> {
// (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() },
});
const result = await sendMail({ to: r.email, subject, html, text });
// notifiedAt nur bei tatsächlich erfolgreichem Versand setzen - sonst
// würde ein Resend-Fehler (Bounce, Rate-Limit, ...) fälschlich als
// "wurde benachrichtigt" festgehalten und dieser Eintrag bekäme bei
// einem später erneut frei werdenden Platz keine zweite Chance auf
// eine erfolgreiche Zustellung mehr, obwohl er nie wirklich erreicht wurde.
if (result.success) {
await prisma.courseWaitlistEntry.update({
where: { id: r.entryId },
data: { notifiedAt: new Date() },
});
} else {
console.error(`Wartelisten-Mail an ${r.email} fehlgeschlagen:`, result.error);
}
})
);
}

View file

@ -2,18 +2,23 @@
* 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
* können sich registrierte User 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.
*
* Bewusst nur für registrierte User: ein unauthentifiziertes Gast-Formular
* würde eine beliebige, nicht verifizierte E-Mail-Adresse als
* Benachrichtigungsempfänger akzeptieren, ohne dass die Kontrolle über diese
* Adresse bestätigt wurde. Der Login-Zwang schließt das und erzeugt nebenbei
* einen Anreiz zur Registrierung.
*/
"use server";
import { prisma } from "@/src/lib/prisma";
import { getUserData, isUser } from "@/src/lib/auth/getUser";
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
import { Prisma } from "@/src/generated/prisma/client";
export type JoinWaitlistResult =
| { success: true; alreadyOnWaitlist: boolean }
@ -48,6 +53,11 @@ async function assertCourseIsFullAndJoinable(
return { ok: true };
}
/** True, wenn err eine Prisma-Unique-Constraint-Verletzung (P2002) ist. */
function isUniqueConstraintError(err: unknown): boolean {
return err instanceof Prisma.PrismaClientKnownRequestError && err.code === "P2002";
}
export async function joinWaitlistAsUser(courseId: string): Promise<JoinWaitlistResult> {
const userData = await getUserData();
if (!userData || !isUser(userData)) {
@ -73,43 +83,21 @@ export async function joinWaitlistAsUser(courseId: string): Promise<JoinWaitlist
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,
},
});
try {
await prisma.courseWaitlistEntry.create({
data: { courseId, userId: userData.id },
});
} catch (err) {
// Zwei parallele Beitritts-Versuche (Doppelklick, zwei Tabs) können beide
// den obigen "existingEntry === null"-Read sehen, aber nur einer gewinnt
// den Insert - der andere läuft in die Unique-Constraint (courseId,
// userId). Das ist kein Fehler, sondern derselbe Fall wie "war schon
// eingetragen".
if (isUniqueConstraintError(err)) {
return { success: true, alreadyOnWaitlist: true };
}
throw err;
}
return { success: true, alreadyOnWaitlist: false };
}

View file

@ -10,7 +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 { joinWaitlistAsUser } from "../../actions/waitlist-actions";
import { getContrastTextColor } from "@/src/modules/settings/lib/contrast";
import { toast } from "sonner";
@ -157,7 +157,6 @@ export const BookingDialog = ({
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("");
@ -288,31 +287,6 @@ export const BookingDialog = ({
}
};
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;
@ -516,14 +490,30 @@ export const BookingDialog = ({
</div>
</div>
{/* 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) && (
{/* Warteliste ist bewusst nur registrierten Usern vorbehalten - ein
unauthentifiziertes Gast-Formular würde beliebige fremde
E-Mail-Adressen als Benachrichtigungsempfänger akzeptieren, ohne
deren Kontrolle über die Adresse zu bestätigen. Ein Login-Hinweis
statt eines Eintrags-Formulars schließt das und wirkt nebenbei
als Anreiz zur Registrierung. */}
{!isAdminView && !isCheckingStatus && !isBooked && isFull && (
<div className="border-t border-gray-100 pt-3">
<p className="text-xs text-gray-500 text-center">
<Link href="/login" className="underline text-gray-700 hover:text-gray-900">
Melden Sie sich an
</Link>
, um sich auf die Warteliste einzutragen.
</p>
</div>
)}
{/* Gast-Formular fürs reguläre Buchen: erscheint nach erstem 401
(nicht eingeloggt). Bei vollem Kurs entfällt es zugunsten des
Login-Hinweises oben. */}
{!isAdminView && !isFull && showGuestForm && !isBooked && (
<div className="border-t border-gray-100 pt-3 flex flex-col gap-2">
<p className="text-xs text-gray-500">
{isFull ? "Ohne Konto auf die Warteliste – oder" : "Ohne Konto buchen – oder"}{" "}
Ohne Konto buchen – oder{" "}
<Link href="/login" className="underline text-gray-700 hover:text-gray-900">
anmelden
</Link>
@ -544,27 +534,14 @@ 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"
/>
{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>
)}
<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>