Kuma/src/modules/courses/actions/notify-waitlist.ts

107 lines
4.3 KiB
TypeScript

/**
* 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.
*
* 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";
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 },
});
if (entries.length === 0) return;
// Wer inzwischen bereits einen bezahlten Platz hat, soll nicht erneut
// eingeladen werden.
const userIds = entries.map((e) => e.userId);
const paidUserIds = new Set(
(
await prisma.courseBooking.findMany({
where: { courseId, userId: { in: userIds }, paymentStatus: "paid" },
select: { userId: true },
})
).map((b) => b.userId)
);
const toNotify = entries.filter((e) => !paidUserIds.has(e.userId));
if (toNotify.length === 0) return;
const usersById = new Map(
(
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: usersById.get(e.userId) }))
.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) => {
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);
}
})
);
}