mirror of
https://github.com/Since-3/Kuma.git
synced 2026-10-12 20:51:18 +00:00
107 lines
4.3 KiB
TypeScript
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);
|
|
}
|
|
})
|
|
);
|
|
}
|