Kuma/src/modules/courses/actions/booking-actions.ts
2026-10-02 17:31:08 +02:00

608 lines
20 KiB
TypeScript

/**
* Booking Actions - Server-seitige Aktionen für Kursbuchungen
*
* Diese Datei enthält alle Server Actions für Kursbuchungen.
* Funktionen umfassen: Kurs buchen, Buchungen abrufen und Status prüfen.
*/
"use server";
import { prisma } from "@/src/lib/prisma";
import { getUserData, isUser } from "@/src/lib/auth/getUser";
import { unstable_cache, revalidatePath, revalidateTag } from "next/cache";
import { executeBookingCancellation } from "./cancel-booking-shared";
import type { SeatSelection } from "../lib/seat-cancellation";
import { combineDateAndTime } from "@/src/lib/course-time";
import { toEmailBranding } from "@/src/modules/settings/lib/to-email-branding";
import { KUMASY_BRANDING } from "@/src/lib/mail/templates/tokens";
import { eurosToCents } from "@/src/lib/stripe";
import {
validateDiscountCode,
calculateDiscountedPriceCents,
} from "@/src/modules/settings/lib/discount-validation";
import { bookableSpots, parseFloorPlan, type FloorPlan } from "@/src/modules/rooms/lib/floor-plan";
import { getBookedSpotIds, getHeldSpotIds, spotHolderKey } from "../lib/course-spots";
import { MAX_SEATS_PER_BOOKING, paidSeatCounts } from "../lib/seat-counts";
import {
findUsablePassesForCourse,
type CustomerPassView,
} from "@/src/modules/products/lib/pass-queries";
import { hasMarketingConsent } from "@/src/modules/marketing/lib/consent";
import { consentLabel } from "@/src/modules/marketing/lib/consent-text";
export type UserBooking = {
bookingId: string;
courseId: string;
courseName: string;
date: string; // ISO
timeFrom: string;
timeTo: string;
roomName: string;
level: string;
price: number;
maxParticipants: number;
currentParticipants: number;
coverImage: string | null;
description: string;
trainerProfiles: {
id: string;
firstName: string | null;
lastName: string | null;
pbSrc: string | null;
}[];
trainers: string[];
trainersMap: Record<string, { label: string; pbSrc?: string }>;
paymentStatus: string;
/** Mit einer Karte gebucht statt bezahlt. */
paidWithPass: boolean;
/** Plätze dieser Buchung - mehr als 1, wenn für Begleitpersonen mitgebucht wurde. */
seats: number;
/** Gebuchte Plätze bei Kursen mit Platzwahl, z.B. ["Reformer 3", "Reformer 4"]. */
spotLabels: string[];
isPast: boolean;
businessName: string;
/** Kontaktdaten des Standorts für den Detail-Dialog (Ticket: Email + Ort anzeigen). */
businessAddress: string | null;
businessEmail: string | null;
businessBranding: { logoUrl: string | null; accentColor: string; accentTextColor: string };
};
export async function getUserBookings(): Promise<
{ success: true; bookings: UserBooking[] } | { success: false; error: string }
> {
const userData = await getUserData();
if (!userData || !isUser(userData)) {
return { success: false, error: "Nicht angemeldet." };
}
const userId = userData.id;
const fetchBookings = unstable_cache(
async () => {
const bookingsRaw = await prisma.courseBooking.findMany({
where: { userId },
select: {
id: true,
paymentStatus: true,
passId: true,
seats: true,
spotLabels: true,
course: {
select: {
id: true,
name: true,
date: true,
timeFrom: true,
timeTo: true,
room: true,
trainers: true,
level: true,
price: true,
maxParticipants: true,
coverImage: true,
description: true,
business: {
select: { name: true, address: true, email: true, branding: true },
},
},
},
},
orderBy: { createdAt: "desc" },
});
const seatCounts = await paidSeatCounts(
prisma,
bookingsRaw.map((b) => b.course.id)
);
// Collect all room and trainer IDs
const roomIds = [
...new Set(bookingsRaw.map((b) => b.course.room).filter(Boolean)),
] as string[];
const allTrainerIds = new Set<string>();
for (const b of bookingsRaw) {
for (const tid of b.course.trainers) allTrainerIds.add(tid);
}
const [rooms, trainersData] = await Promise.all([
roomIds.length > 0
? prisma.room.findMany({
where: { id: { in: roomIds } },
select: { id: true, name: true },
})
: [],
allTrainerIds.size > 0
? prisma.employee.findMany({
where: { id: { in: Array.from(allTrainerIds) } },
select: { id: true, firstName: true, lastName: true, pbSrc: true },
})
: [],
]);
const roomsMap: Record<string, string> = {};
for (const r of rooms) roomsMap[r.id] = r.name;
const trainersMap: Record<string, { label: string; pbSrc?: string }> = {};
const trainerProfilesMap: Record<
string,
{ id: string; firstName: string | null; lastName: string | null; pbSrc: string | null }
> = {};
for (const t of trainersData) {
const label = [t.firstName, t.lastName].filter(Boolean).join(" ") || "Trainer";
trainersMap[t.id] = { label, pbSrc: t.pbSrc ?? undefined };
trainerProfilesMap[t.id] = {
id: t.id,
firstName: t.firstName,
lastName: t.lastName,
pbSrc: t.pbSrc,
};
}
const now = new Date();
return bookingsRaw.map((b) => {
const courseStart = combineDateAndTime(b.course.date, b.course.timeFrom);
const branding = b.course.business ? toEmailBranding(b.course.business) : KUMASY_BRANDING;
return {
bookingId: b.id,
courseId: b.course.id,
courseName: b.course.name,
date: b.course.date.toISOString(),
timeFrom: b.course.timeFrom,
timeTo: b.course.timeTo,
roomName: roomsMap[b.course.room] ?? b.course.room,
level: b.course.level,
price: b.course.price,
maxParticipants: b.course.maxParticipants,
currentParticipants: seatCounts.get(b.course.id) ?? 0,
coverImage: b.course.coverImage ?? null,
description: b.course.description,
trainerProfiles: b.course.trainers.map((tid) => trainerProfilesMap[tid]).filter(Boolean),
trainers: b.course.trainers,
trainersMap,
paymentStatus: b.paymentStatus,
paidWithPass: b.passId !== null,
seats: b.seats,
spotLabels: b.spotLabels,
isPast: courseStart < now,
businessName: b.course.business?.name ?? branding.companyName,
businessAddress: b.course.business?.address ?? null,
businessEmail: b.course.business?.email ?? null,
businessBranding: {
logoUrl: branding.logoUrl,
accentColor: branding.accentColor,
accentTextColor: branding.accentTextColor,
},
} satisfies UserBooking;
});
},
[`user-bookings-${userId}`],
{ tags: [`user-bookings-${userId}`], revalidate: 60 }
);
try {
const bookings = await fetchBookings();
return { success: true, bookings };
} catch {
return { success: false, error: "Buchungen konnten nicht geladen werden." };
}
}
export async function cancelUserBooking(
bookingId: string,
seatSelection?: SeatSelection
): Promise<{ success: true } | { success: false; error: string }> {
const userData = await getUserData();
if (!userData || !isUser(userData)) {
return { success: false, error: "Nicht angemeldet." };
}
try {
const booking = await prisma.courseBooking.findUnique({
where: { id: bookingId },
select: {
userId: true,
course: { select: { businessId: true } },
},
});
if (!booking || booking.userId !== userData.id) {
return { success: false, error: "Buchung nicht gefunden." };
}
const result = await executeBookingCancellation(bookingId, "user", { seatSelection });
if (!result.success) return result;
revalidatePath("/courses/myCourses");
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(revalidateTag as any)(`business-courses-${booking.course.businessId}`);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(revalidateTag as any)(`user-bookings-${userData.id}`);
return { success: true };
} catch {
return { success: false, error: "Stornierung fehlgeschlagen." };
}
}
/**
* Überprüft, ob ein User bereits für einen Kurs gebucht hat
*
* @param courseId - Die eindeutige ID des Kurses
* @returns Ein Objekt mit success-Flag und isBooked Status
*/
export async function checkUserBookingStatus(courseId: string) {
try {
const userData = await getUserData();
// isLoggedIn sagt dem Buchungsdialog, ob er Gästen gleich das Gast-
// Formular zeigen soll, statt sie erst einen Buchungsversuch scheitern
// zu lassen.
if (!userData) {
return {
success: true,
isLoggedIn: false,
isBooked: false,
isOnWaitlist: false,
};
}
// Alle Checks im selben Aufruf statt separater Wartelisten-/Karten-Calls,
// damit der Dialog weiterhin nur einen Roundtrip beim Öffnen braucht.
const customerCourse = isUser(userData)
? await prisma.course.findUnique({
where: { id: courseId },
select: { businessId: true, sport: true, business: { select: { name: true } } },
})
: null;
const [booking, waitlistEntry, passes, consentGranted] = await Promise.all([
prisma.courseBooking.findUnique({
where: { courseId_userId: { courseId, userId: userData.id } },
}),
prisma.courseWaitlistEntry.findUnique({
where: { courseId_userId: { courseId, userId: userData.id } },
}),
// Karten, mit denen der Kunde diesen Kurs ohne Bezahlen buchen kann.
customerCourse && isUser(userData)
? findUsablePassesForCourse(userData, customerCourse)
: Promise.resolve([] as CustomerPassView[]),
customerCourse?.businessId
? hasMarketingConsent(userData.id, customerCourse.businessId)
: Promise.resolve(true),
]);
return {
success: true,
isLoggedIn: true,
// Nur Kunden-Accounts dürfen buchen - und nur sie bekommen den Platz-Zähler.
canBookMultiple: isUser(userData),
isBooked: booking?.paymentStatus === "paid",
bookedSeats: booking?.paymentStatus === "paid" ? booking.seats : 0,
isOnWaitlist: waitlistEntry !== null,
passes,
// Häkchen für Werbe-Mails des Studios - nur solange der Kunde noch nicht
// zugestimmt hat (Opt-in, standardmäßig leer).
marketingConsentLabel:
customerCourse?.business && !consentGranted
? consentLabel(customerCourse.business.name)
: null,
};
} catch (error) {
console.error("Error checking booking status:", error);
return {
success: false,
error: "Fehler beim Prüfen des Buchungsstatus",
// Unbekannt - der Dialog fällt dann auf den alten Weg zurück und zeigt
// das Gast-Formular erst, wenn die Buchung "nicht angemeldet" meldet.
isLoggedIn: null,
isBooked: false,
isOnWaitlist: false,
};
}
}
/**
* Lädt eine kompakte Kurs-Zusammenfassung inkl. Business-Slug.
* Wird für die Success-Page (Kursdetails anzeigen) und für den Redirect
* der alten Buchungs-View auf die öffentliche Business-Seite genutzt.
*
* @param courseId - Die eindeutige ID des Kurses
*/
export async function getCourseSummaryForConfirmation(courseId: string, sessionId?: string) {
try {
const course = await prisma.course.findFirst({
where: {
id: courseId,
business: { isPublic: true },
},
select: {
id: true,
name: true,
sport: true,
date: true,
timeFrom: true,
timeTo: true,
price: true,
room: true,
business: {
select: {
name: true,
slug: true,
address: true,
branding: true,
},
},
},
});
if (!course) {
return { success: false as const, error: "Kurs nicht gefunden" };
}
// course.price ist der reguläre Kurspreis, NICHT der tatsächlich gezahlte
// Betrag (kann durch einen Rabattcode abweichen). sessionId (aus der
// Stripe-success_url) erlaubt es, die konkrete Buchung nachzuladen - ohne
// sessionId (oder falls der Webhook noch nicht durchgelaufen ist) fällt
// die Anzeige auf den regulären Kurspreis zurück.
let paidPrice = course.price;
let discountAmount: number | null = null;
let seats = 1;
let spotLabels: string[] = [];
if (sessionId) {
// findFirst statt findUnique mit stripeSessionId allein: eine URL, die
// courseId von Kurs B mit der session_id von Kurs A kombiniert, würde
// sonst Kurs B mit den Zahlungsdaten von Kurs A anzeigen.
const booking = await prisma.courseBooking.findFirst({
where: { stripeSessionId: sessionId, courseId },
select: { amountPaid: true, discountAmount: true, seats: true, spotLabels: true },
});
if (booking?.amountPaid !== null && booking?.amountPaid !== undefined) {
paidPrice = booking.amountPaid / 100;
discountAmount = booking.discountAmount !== null ? booking.discountAmount / 100 : null;
}
seats = booking?.seats ?? 1;
spotLabels = booking?.spotLabels ?? [];
}
return { success: true as const, course, paidPrice, discountAmount, seats, spotLabels };
} catch (error) {
console.error("Error fetching course summary:", error);
return { success: false as const, error: "Fehler beim Laden des Kurses" };
}
}
export type CourseSpotMap = {
floorPlan: FloorPlan;
/** Bezahlt gebucht oder gerade von jemand anderem im Checkout gehalten. */
takenSpotIds: string[];
/** Plätze der eigenen bezahlten Buchung des eingeloggten Kunden. */
mySpotIds: string[];
};
/**
* Grundriss und Live-Belegung für die Platzwahl im Buchungsdialog. Bewusst
* ungecacht (die Kursliste ist 30 s gecacht) - wer den Dialog öffnet, soll
* keinen Platz wählen, der seit Sekunden weg ist. null = der Kurs wird ohne
* Platzwahl gebucht. Verrät nicht, WER auf einem Platz liegt.
*/
export async function getCourseSpotMap(courseId: string): Promise<CourseSpotMap | null> {
try {
const course = await prisma.course.findFirst({
where: { id: courseId, status: "published", business: { isPublic: true } },
select: { spotBooking: true, room: true },
});
if (!course?.spotBooking || !course.room) return null;
const room = await prisma.room.findUnique({
where: { id: course.room },
select: { floorPlan: true },
});
const floorPlan = parseFloorPlan(room?.floorPlan);
if (!floorPlan || bookableSpots(floorPlan).length === 0) return null;
const userData = await getUserData();
const userId = userData && isUser(userData) ? userData.id : null;
const [booked, held, myBooking] = await Promise.all([
getBookedSpotIds(prisma, courseId),
getHeldSpotIds(
prisma,
courseId,
userId ? { exceptHolderKey: spotHolderKey({ userId }) } : {}
),
userId
? prisma.courseBooking.findFirst({
where: { courseId, userId, paymentStatus: "paid" },
select: { spotIds: true },
})
: null,
]);
const mySpotIds = myBooking?.spotIds ?? [];
const takenSpotIds = [...new Set([...booked, ...held])].filter((id) => !mySpotIds.includes(id));
return { floorPlan, takenSpotIds, mySpotIds };
} catch (error) {
console.error("Error fetching course spot map:", error);
return null;
}
}
export async function getBusinessBySlug(slug: string) {
const fetch = unstable_cache(
async () => {
try {
const business = await prisma.business.findUnique({
where: { slug },
select: {
id: true,
name: true,
address: true,
email: true,
title: true,
slug: true,
isPublic: true,
branding: true,
},
});
if (!business || !business.isPublic) {
return { success: false as const, error: "Seite nicht gefunden" };
}
return { success: true as const, business };
} catch (error) {
console.error("Error fetching business by slug:", error);
return { success: false as const, error: "Fehler beim Laden" };
}
},
[`business-slug-${slug}`],
{ tags: [`business-slug-${slug}`], revalidate: 3600 }
);
return fetch();
}
export async function getPublishedCoursesForBusiness(
businessId: string,
options: { from: Date; to: Date }
) {
const fromKey = options.from.toISOString().slice(0, 10);
const toKey = options.to.toISOString().slice(0, 10);
const fetch = unstable_cache(
async () => _fetchCoursesForBusiness(businessId, options),
[`business-courses-${businessId}-${fromKey}-${toKey}`],
{ tags: [`business-courses-${businessId}`], revalidate: 30 }
);
return fetch();
}
async function _fetchCoursesForBusiness(businessId: string, options: { from: Date; to: Date }) {
try {
const courses = await prisma.course.findMany({
where: {
businessId,
status: "published",
date: { gte: options.from, lte: options.to },
},
orderBy: { date: "asc" },
});
const seatCounts = await paidSeatCounts(
prisma,
courses.map((c) => c.id)
);
const trainerIds = [...new Set(courses.flatMap((c) => c.trainers))];
const roomIds = [...new Set(courses.map((c) => c.room).filter(Boolean))] as string[];
const [trainers, rooms] = await Promise.all([
trainerIds.length > 0
? prisma.employee.findMany({
where: { id: { in: trainerIds } },
select: { id: true, firstName: true, lastName: true, pbSrc: true },
})
: Promise.resolve([]),
roomIds.length > 0
? prisma.room.findMany({
where: { id: { in: roomIds } },
select: { id: true, name: true },
})
: Promise.resolve([]),
]);
const trainerMap = Object.fromEntries(trainers.map((t) => [t.id, t]));
const roomMap = Object.fromEntries(rooms.map((r) => [r.id, r.name]));
return {
success: true,
courses: courses.map((c) => ({
...c,
currentParticipants: seatCounts.get(c.id) ?? 0,
roomName: c.room ? (roomMap[c.room] ?? null) : null,
trainerProfiles: c.trainers.map((id) => trainerMap[id]).filter(Boolean) as {
id: string;
firstName: string | null;
lastName: string | null;
pbSrc: string | null;
}[],
})),
};
} catch (error) {
console.error("Error fetching courses for business:", error);
return { success: false, error: "Fehler beim Laden der Kurse", courses: [] };
}
}
// Rein lesende Vorschau für die Buchungs-UI, damit ein Kunde den rabattierten
// Preis VOR dem Stripe-Checkout sieht. Legt keine Redemption an und erhöht
// keinen Counter (siehe redeemDiscountCode) - die Checkout-Route führt exakt
// dieselbe Validierung erneut aus, bevor die Session erstellt wird.
// Rechnet wie der Checkout auf den Gesamtpreis aller Plätze: discountedPrice
// ist der Kurspreis für `seats` Plätze nach Rabatt.
export async function previewDiscountCode(
courseId: string,
code: string,
seats = 1
): Promise<
{ valid: true; discountedPrice: number; discountAmount: number } | { valid: false; error: string }
> {
try {
const userData = await getUserData();
const course = await prisma.course.findUnique({
where: { id: courseId },
select: { price: true, businessId: true },
});
if (!course || !course.businessId) {
return { valid: false, error: "Kurs nicht gefunden." };
}
const validation = await validateDiscountCode(prisma, {
code,
businessId: course.businessId,
courseId,
userId: userData && isUser(userData) ? userData.id : null,
});
if (!validation.valid) {
return { valid: false, error: validation.reason };
}
const seatCount = Number.isInteger(seats)
? Math.min(Math.max(seats, 1), MAX_SEATS_PER_BOOKING)
: 1;
const { discountedPriceCents, discountAmountCents } = calculateDiscountedPriceCents(
eurosToCents(course.price) * seatCount,
validation.discountCode
);
return {
valid: true,
discountedPrice: discountedPriceCents / 100,
discountAmount: discountAmountCents / 100,
};
} catch (error) {
console.error("Error previewing discount code:", error);
return { valid: false, error: "Rabattcode konnte nicht geprüft werden." };
}
}