Merge pull request #123 from Since-3/feature/added-guest-checkout

added the guest checkout
This commit is contained in:
Rajpreet0 2026-06-16 21:35:44 +02:00 • committed by GitHub
commit 1fcd71d038
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 638 additions and 274 deletions

View file

@ -150,20 +150,31 @@ model Room {
@@index([createdBy])
}
model GuestLead {
id String @id @default(uuid())
email String @unique
name String?
bookings CourseBooking[]
createdAt DateTime @default(now())
}
model CourseBooking {
id String @id @default(uuid())
courseId String
userId String
userId String?
guestLeadId String?
paymentStatus String @default("pending") // "pending" | "paid" | "failed" | "refunded"
stripeSessionId String? @unique
stripePaymentIntentId String? @unique
amountPaid Int? // tatsächlich gezahlter Betrag in Cents
platformFee Int? // unsere Service-Fee in Cents
course Course @relation(fields: [courseId], references: [id], onDelete: Cascade)
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
course Course @relation(fields: [courseId], references: [id], onDelete: Cascade)
user User? @relation(fields: [userId], references: [id], onDelete: Cascade)
guestLead GuestLead? @relation(fields: [guestLeadId], references: [id], onDelete: SetNull)
createdAt DateTime @default(now())
@@unique([courseId, userId]) // Ein User kann sich nur einmal pro Kurs anmelden
@@index([courseId])
@@index([userId])
@@index([guestLeadId])
}

View file

@ -69,6 +69,36 @@ export async function POST(request: Request) {
},
});
// Gast-Buchungen mit derselben E-Mail auf den neuen Account übertragen.
// GuestLead-Einträge mit dieser Mail suchen und deren Buchungen auf userId setzen.
try {
const guestLeads = await prisma.guestLead.findMany({
where: { email },
select: { id: true },
});
if (guestLeads.length > 0) {
const guestLeadIds = guestLeads.map((g) => g.id);
// Nur Buchungen übertragen die noch keinen User haben und paid/pending sind
// (failed/refunded sind nicht mehr relevant)
await prisma.courseBooking.updateMany({
where: {
guestLeadId: { in: guestLeadIds },
userId: null,
paymentStatus: { in: ["paid", "pending"] },
},
data: {
userId: newUser.id,
guestLeadId: null,
},
});
}
} catch (mergeError) {
// Nicht-fatal: User wurde erfolgreich angelegt, Merge kann manuell nachgeholt werden
console.error("Gast-Buchungen konnten nicht übertragen werden:", mergeError);
}
return NextResponse.json({ user: newUser }, { status: 201 });
} catch (error) {
console.error("Error in /api/auth/register:", error);

View file

@ -3,42 +3,97 @@ import { prisma } from "@/src/lib/prisma";
import { stripe, calculatePlatformFeeCents, eurosToCents } from "@/src/lib/stripe";
import { getUserData, isUser } from "@/src/lib/auth/getUser";
// Simple in-memory rate limiter: max 10 checkout attempts per IP per minute.
// Best-effort only — in multi-instance/serverless deployments each instance has
// its own Map, so a determined attacker can bypass it. The main purpose is to
// limit accidental bursts and casual abuse on a single instance.
const rateLimitMap = new Map<string, { count: number; resetAt: number }>();
const RATE_LIMIT_MAX = 10;
const RATE_LIMIT_WINDOW_MS = 60_000;
// Purge stale entries every 5 minutes to prevent unbounded memory growth.
setInterval(() => {
const now = Date.now();
for (const [ip, entry] of rateLimitMap) {
if (now > entry.resetAt) rateLimitMap.delete(ip);
}
}, 5 * 60_000);
function isRateLimited(ip: string): boolean {
const now = Date.now();
const entry = rateLimitMap.get(ip);
if (!entry || now > entry.resetAt) {
rateLimitMap.set(ip, { count: 1, resetAt: now + RATE_LIMIT_WINDOW_MS });
return false;
}
entry.count += 1;
return entry.count > RATE_LIMIT_MAX;
}
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
/**
* Erstellt eine Stripe Checkout Session für eine Kurs-Buchung.
* Geld geht direkt an das Business-Connect-Account (minus Plattform-Fee).
*
* Body: { courseId: string }
* Auth-Flow → Body: { courseId: string }
* Guest-Flow → Body: { courseId: string; guestEmail: string; guestName?: string }
*/
export async function POST(request: NextRequest) {
try {
// Rate limiting per IP
const ip =
request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ??
request.headers.get("x-real-ip") ??
"unknown";
if (isRateLimited(ip)) {
return NextResponse.json(
{ error: "Zu viele Anfragen. Bitte warte kurz und versuche es erneut." },
{ status: 429 }
);
}
// Auth check — always first, Guest-Path only when no session exists
const userData = await getUserData();
if (!userData) {
return NextResponse.json(
{ error: "Sie müssen angemeldet sein, um einen Kurs zu buchen" },
{ status: 401 }
);
}
// Nur normale User (role: "user") können Kurse buchen.
// Manager/Employee haben keine Einträge in der User-Tabelle, daher würde
// sonst der Foreign-Key-Constraint CourseBooking_userId_fkey im Webhook
// verletzt werden – und das erst NACHDEM das Geld geflossen ist.
if (!isUser(userData)) {
return NextResponse.json(
{
error: "Nur Kunden-Accounts können Kurse buchen. Bitte mit einem Nutzer-Konto anmelden.",
},
{ status: 403 }
);
}
const body = (await request.json()) as { courseId?: string };
const { courseId } = body;
const body = (await request.json()) as {
courseId?: string;
guestEmail?: string;
guestName?: string;
};
const { courseId, guestEmail, guestName } = body;
if (!courseId) {
return NextResponse.json({ error: "courseId fehlt" }, { status: 400 });
}
// Determine flow: authenticated user or guest
const isGuest = !userData;
if (!isGuest) {
// Nur normale User (role: "user") können Kurse buchen.
// Manager/Employee haben keine Einträge in der User-Tabelle, daher würde
// sonst der Foreign-Key-Constraint CourseBooking_userId_fkey im Webhook
// verletzt werden – und das erst NACHDEM das Geld geflossen ist.
if (!isUser(userData)) {
return NextResponse.json(
{
error:
"Nur Kunden-Accounts können Kurse buchen. Bitte mit einem Nutzer-Konto anmelden.",
},
{ status: 403 }
);
}
} else {
// Guest flow: guestEmail required
if (!guestEmail || !EMAIL_RE.test(guestEmail.trim())) {
return NextResponse.json(
{ error: "Bitte eine gültige E-Mail-Adresse angeben.", isGuestRequired: true },
{ status: 400 }
);
}
}
const course = await prisma.course.findUnique({
where: { id: courseId },
include: {
@ -85,30 +140,30 @@ export async function POST(request: NextRequest) {
);
}
const existingBooking = await prisma.courseBooking.findUnique({
where: {
courseId_userId: { courseId, userId: userData.id },
},
});
// Duplicate-booking check only for authenticated users (guests have no unique constraint)
if (!isGuest && userData) {
const existingBooking = await prisma.courseBooking.findUnique({
where: {
courseId_userId: { courseId, userId: userData.id },
},
});
if (existingBooking && existingBooking.paymentStatus === "paid") {
return NextResponse.json(
{ error: "Sie haben sich bereits für diesen Kurs angemeldet" },
{ status: 400 }
);
if (existingBooking && existingBooking.paymentStatus === "paid") {
return NextResponse.json(
{ error: "Sie haben sich bereits für diesen Kurs angemeldet" },
{ status: 400 }
);
}
}
const baseUrl = process.env.NEXT_PUBLIC_APP_URL ?? "http://localhost:3000";
const priceCents = eurosToCents(course.price);
const platformFeeCents = calculatePlatformFeeCents(course.price);
const customerEmail = isGuest ? guestEmail!.trim().toLowerCase() : userData!.email;
const session = await stripe.checkout.sessions.create({
mode: "payment",
// payment_method_types weggelassen → Checkout Sessions nutzen automatisch
// alle im Stripe Dashboard aktivierten und für diese Session kompatiblen
// Methoden (Dynamic Payment Methods).
// Karte/Apple Pay/Google Pay/Link sind sofort verfügbar. PayPal
// unterstützt kein Stripe Connect und muss über die PayPal Dev API eingerichtet werden
line_items: [
{
price_data: {
@ -129,16 +184,22 @@ export async function POST(request: NextRequest) {
},
metadata: {
courseId: course.id,
userId: userData.id,
userId: isGuest ? "" : userData!.id,
businessId: course.business.id,
isGuest: isGuest ? "true" : "false",
guestEmail: isGuest ? customerEmail : "",
guestName: isGuest ? (guestName?.trim() ?? "") : "",
},
},
metadata: {
courseId: course.id,
userId: userData.id,
userId: isGuest ? "" : userData!.id,
businessId: course.business.id,
isGuest: isGuest ? "true" : "false",
guestEmail: isGuest ? customerEmail : "",
guestName: isGuest ? (guestName?.trim() ?? "") : "",
},
customer_email: userData.email,
customer_email: customerEmail,
success_url: `${baseUrl}/courses/book/${course.id}/success?session_id={CHECKOUT_SESSION_ID}`,
cancel_url: `${baseUrl}/courses/book/${course.id}/cancel`,
});

View file

@ -2,7 +2,7 @@ import { NextRequest, NextResponse } from "next/server";
import Stripe from "stripe";
import { stripe, getWebhookSecret } from "@/src/lib/stripe";
import { prisma } from "@/src/lib/prisma";
import { revalidatePath } from "next/cache";
import { revalidatePath, revalidateTag } from "next/cache";
import { sendMail } from "@/src/lib/mail/nodemailer";
import { generateBookingConfirmationEmail } from "@/src/lib/mail/templates/booking-confirmation";
import { generateBookingRefundEmail } from "@/src/lib/mail/templates/booking-refund";
@ -63,13 +63,14 @@ export async function POST(request: NextRequest) {
/**
* Verarbeitet eine erfolgreiche Checkout-Session.
* Erstellt die CourseBooking (falls nicht vorhanden) und setzt sie auf "paid".
* Unterstützt Auth-User und Gäste (isGuest === "true" in Metadata).
*/
async function handleCheckoutCompleted(session: Stripe.Checkout.Session) {
const courseId = session.metadata?.courseId;
const userId = session.metadata?.userId;
const isGuest = session.metadata?.isGuest === "true";
if (!courseId || !userId) {
console.error("checkout.session.completed: courseId oder userId fehlt in Metadata");
if (!courseId) {
console.error("checkout.session.completed: courseId fehlt in Metadata");
return;
}
@ -80,50 +81,92 @@ async function handleCheckoutCompleted(session: Stripe.Checkout.Session) {
return;
}
// Sicherheitsnetz: User muss in der User-Tabelle existieren, sonst würde
// der Foreign-Key-Constraint die Buchung ablehnen. Wir loggen das und
// geben 200 zurück (kein Stripe-Retry für einen Fehler den ein Retry nicht behebt).
const userExists = await prisma.user.findUnique({
where: { id: userId },
select: { id: true },
});
if (!userExists) {
console.error(
`checkout.session.completed: User ${userId} existiert nicht in der User-Tabelle. ` +
`Session ${session.id} wird ignoriert. Zahlung muss ggf. manuell erstattet werden.`
);
return;
}
// Beträge in Cents (ganzzahlig) speichern – keine Float-Rundungsfehler.
const amountPaidCents = session.amount_total ?? 0;
const paymentIntentId = (session.payment_intent as string | null) ?? null;
const platformFeeCents = paymentIntentId
? await getApplicationFeeFromPaymentIntent(paymentIntentId)
: 0;
// Atomare Kapazitätsprüfung: verhindert dass zwei gleichzeitig zahlende User
// den letzten Platz bekommen. Innerhalb der Transaction wird gezählt und
// erst dann auf "paid" gesetzt.
//
// PostgreSQL kann bei Serializable-Isolation transiente Serialisierungsfehler
// (P2034) werfen, die Prisma nicht selbst wiederholt. Wir versuchen bis zu 3
// Mal mit exponentiellem Backoff+Jitter, bevor wir aufgeben und Stripe den
// Webhook wiederholen lassen.
// Guest-Flow und Auth-Flow teilen die Kapazitäts-Logik, aber nutzen
// unterschiedliche Identifikatoren und DB-Operationen.
const MAX_ATTEMPTS = 3;
let outcome: Awaited<ReturnType<typeof runCapacityTransaction>> | undefined;
let outcome: CapacityOutcome | undefined;
for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) {
try {
outcome = await runCapacityTransaction({
courseId,
userId,
session,
paymentIntentId,
amountPaidCents,
platformFeeCents,
});
if (isGuest) {
const guestEmail = session.metadata?.guestEmail ?? "";
const guestName = session.metadata?.guestName ?? undefined;
if (!guestEmail) {
console.error(
"checkout.session.completed: isGuest=true aber guestEmail fehlt in Metadata. " +
`Session ${session.id} wird ignoriert.`
);
return;
}
outcome = await runCapacityTransactionGuest({
courseId,
guestEmail,
guestName: guestName || undefined,
session,
paymentIntentId,
amountPaidCents,
platformFeeCents,
});
} else {
const userId = session.metadata?.userId;
// Leerer String bedeutet: Checkout wurde ohne Auth erstellt (Race condition
// oder alter Code-Pfad). Als Gast behandeln wenn guestEmail vorhanden.
if (!userId) {
const fallbackEmail =
session.metadata?.guestEmail ||
session.customer_details?.email ||
session.customer_email;
if (fallbackEmail) {
outcome = await runCapacityTransactionGuest({
courseId,
guestEmail: fallbackEmail,
guestName: session.metadata?.guestName || undefined,
session,
paymentIntentId,
amountPaidCents,
platformFeeCents,
});
} else {
console.error(
"checkout.session.completed: userId und guestEmail fehlen in Metadata. " +
`Session ${session.id} wird ignoriert. Zahlung muss ggf. manuell erstattet werden.`
);
return;
}
break;
}
// Sicherheitsnetz: User muss in der User-Tabelle existieren.
const userExists = await prisma.user.findUnique({
where: { id: userId },
select: { id: true },
});
if (!userExists) {
console.error(
`checkout.session.completed: User ${userId} existiert nicht in der User-Tabelle. ` +
`Session ${session.id} wird ignoriert. Zahlung muss ggf. manuell erstattet werden.`
);
return;
}
outcome = await runCapacityTransactionUser({
courseId,
userId,
session,
paymentIntentId,
amountPaidCents,
platformFeeCents,
});
}
break;
} catch (err: unknown) {
const isSerializationError =
@ -133,7 +176,7 @@ async function handleCheckoutCompleted(session: Stripe.Checkout.Session) {
const delay = 50 * 2 ** (attempt - 1) + Math.random() * 30;
console.warn(
`Serialisierungskonflikt (Versuch ${attempt}/${MAX_ATTEMPTS}) – ` +
`courseId=${courseId} userId=${userId} sessionId=${session.id}. ` +
`courseId=${courseId} isGuest=${isGuest} sessionId=${session.id}. ` +
`Retry in ${Math.round(delay)}ms.`
);
await new Promise((r) => setTimeout(r, delay));
@ -143,7 +186,7 @@ async function handleCheckoutCompleted(session: Stripe.Checkout.Session) {
if (isSerializationError) {
console.error(
`Serialisierungskonflikt nach ${MAX_ATTEMPTS} Versuchen – ` +
`courseId=${courseId} userId=${userId} sessionId=${session.id} ` +
`courseId=${courseId} isGuest=${isGuest} sessionId=${session.id} ` +
`paymentIntentId=${paymentIntentId}. Stripe wird den Webhook wiederholen.`
);
}
@ -153,42 +196,32 @@ async function handleCheckoutCompleted(session: Stripe.Checkout.Session) {
if (!outcome) throw new Error("runCapacityTransaction lieferte kein Ergebnis");
// "already_failed" = Buchung wurde bei früherem Versuch wegen voller Kapazität
// auf "failed" gesetzt, aber der Refund ist noch ausstehend (z.B. damals
// fehlgeschlagen). Bei Stripe-Retry erneut versuchen.
const needsRefund = outcome === "capacity_exceeded" || outcome === "already_failed";
const needsRefund = outcome.result === "capacity_exceeded" || outcome.result === "already_failed";
let refundSucceeded = false;
if (needsRefund && paymentIntentId) {
console.warn(
`Kurs ${courseId} war voll als Zahlung für User ${userId} ankam. ` +
`Kurs ${courseId} war voll als Zahlung ankam. ` +
`Automatische Rückerstattung wird ausgelöst (PaymentIntent ${paymentIntentId}).`
);
refundSucceeded = await refundPayment(paymentIntentId);
if (refundSucceeded) {
if (refundSucceeded && outcome.bookingId) {
await prisma.courseBooking.update({
where: { courseId_userId: { courseId, userId } },
where: { id: outcome.bookingId },
data: { paymentStatus: "refunded" },
});
} else {
// Refund fehlgeschlagen: Buchung bleibt "failed" für manuelle Bearbeitung.
// Stripe wiederholt den Webhook, sodass ein erneuter Refund-Versuch erfolgt.
} else if (!refundSucceeded) {
console.error(
`Refund fehlgeschlagen für Kurs ${courseId}, User ${userId}. ` +
`Refund fehlgeschlagen für Kurs ${courseId}. ` +
`Buchung bleibt "failed" – manuelle Erstattung im Stripe Dashboard nötig.`
);
}
}
// E-Mail-Versand (FR.10) – darf den Webhook NICHT zum Crashen bringen.
// Bei Fehler: nur loggen, sonst riskiert man Stripe-Retry-Schleifen und
// mehrfache Buchungs-Verarbeitung.
// Außerdem: harte 2-Sekunden-Grenze pro Mail, damit ein träger SMTP-Server
// den Webhook nicht über sein Timeout-Limit zieht. Der Versand läuft dann
// im Hintergrund weiter; der Webhook gibt nur früher 200 zurück.
// E-Mail-Versand – darf den Webhook NICHT zum Crashen bringen.
const recipientEmail = session.customer_email ?? session.customer_details?.email ?? null;
if (recipientEmail) {
if (outcome === "booked") {
if (outcome.result === "booked") {
await withTimeout(sendBookingConfirmation(recipientEmail, courseId, amountPaidCents), 2000);
} else if (needsRefund && refundSucceeded) {
await withTimeout(sendBookingRefund(recipientEmail, courseId, amountPaidCents), 2000);
@ -197,33 +230,34 @@ async function handleCheckoutCompleted(session: Stripe.Checkout.Session) {
revalidatePath("/courses");
revalidatePath("/courses/myCourses");
if (outcome.result === "booked" || outcome.result === "capacity_exceeded") {
const course = await prisma.course.findUnique({
where: { id: courseId },
select: { businessId: true },
});
if (course) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(revalidateTag as any)(`business-courses-${course.businessId}`);
}
}
}
/**
* Wartet maximal `ms` Millisekunden auf das Promise. Läuft das Promise länger,
* wird `null` zurückgegeben – das eigentliche Promise läuft im Hintergrund weiter.
* Wird genutzt, um den Stripe-Webhook nicht über sein Timeout-Limit hinauszuziehen.
*/
async function withTimeout<T>(promise: Promise<T>, ms: number): Promise<T | null> {
return Promise.race([
promise,
new Promise<null>((resolve) => setTimeout(() => resolve(null), ms)),
]);
}
type CapacityOutcome = {
result:
| "booked"
| "capacity_exceeded"
| "already_paid"
| "already_refunded"
| "already_failed"
| "course_not_found";
bookingId?: string;
};
/**
* Lädt Kurs+Business und schickt die Buchungs-Bestätigung an den Kunden.
* Schluckt Fehler bewusst (siehe Caller-Kommentar).
* Atomare Kapazitätsprüfung für authentifizierte User.
*/
type CapacityOutcome =
| "booked"
| "capacity_exceeded"
| "already_paid"
| "already_refunded"
| "already_failed"
| "course_not_found";
async function runCapacityTransaction(params: {
async function runCapacityTransactionUser(params: {
courseId: string;
userId: string;
session: Stripe.Checkout.Session;
@ -239,13 +273,13 @@ async function runCapacityTransaction(params: {
where: { courseId_userId: { courseId, userId } },
});
// Idempotenz: Wenn diese Buchung bereits einen Endzustand für DIESE Session
// erreicht hat, ist der Event schon verarbeitet. Erneutes Durchlaufen würde
// sonst einen zweiten Refund auslösen oder "refunded" → "failed" überschreiben.
if (existing && existing.stripeSessionId === session.id) {
if (existing.paymentStatus === "paid") return "already_paid" as const;
if (existing.paymentStatus === "refunded") return "already_refunded" as const;
if (existing.paymentStatus === "failed") return "already_failed" as const;
if (existing.paymentStatus === "paid")
return { result: "already_paid" as const, bookingId: existing.id };
if (existing.paymentStatus === "refunded")
return { result: "already_refunded" as const, bookingId: existing.id };
if (existing.paymentStatus === "failed")
return { result: "already_failed" as const, bookingId: existing.id };
}
const course = await tx.course.findUnique({
@ -253,15 +287,14 @@ async function runCapacityTransaction(params: {
select: { maxParticipants: true },
});
if (!course) return "course_not_found" as const;
if (!course) return { result: "course_not_found" as const };
const paidCount = await tx.courseBooking.count({
where: { courseId, paymentStatus: "paid" },
});
if (paidCount >= course.maxParticipants) {
// Platz ist weg – Buchung als failed markieren, Aufrufer löst Refund aus.
await tx.courseBooking.upsert({
const booking = await tx.courseBooking.upsert({
where: { courseId_userId: { courseId, userId } },
update: {
paymentStatus: "failed",
@ -280,10 +313,10 @@ async function runCapacityTransaction(params: {
platformFee: platformFeeCents,
},
});
return "capacity_exceeded" as const;
return { result: "capacity_exceeded" as const, bookingId: booking.id };
}
await tx.courseBooking.upsert({
const booking = await tx.courseBooking.upsert({
where: { courseId_userId: { courseId, userId } },
update: {
paymentStatus: "paid",
@ -302,12 +335,118 @@ async function runCapacityTransaction(params: {
platformFee: platformFeeCents,
},
});
return "booked" as const;
return { result: "booked" as const, bookingId: booking.id };
},
{ isolationLevel: "Serializable" }
);
}
/**
* Atomare Kapazitätsprüfung für Gäste.
* Erstellt einen GuestLead und eine CourseBooking ohne userId.
* Idempotenz über stripeSessionId (findFirst statt findUnique).
*/
async function runCapacityTransactionGuest(params: {
courseId: string;
guestEmail: string;
guestName?: string;
session: Stripe.Checkout.Session;
paymentIntentId: string | null;
amountPaidCents: number;
platformFeeCents: number;
}): Promise<CapacityOutcome> {
const {
courseId,
guestEmail,
guestName,
session,
paymentIntentId,
amountPaidCents,
platformFeeCents,
} = params;
return prisma.$transaction(
async (tx) => {
// Idempotenz: Wurde diese Session schon verarbeitet?
const existing = await tx.courseBooking.findFirst({
where: { stripeSessionId: session.id },
});
if (existing) {
if (existing.paymentStatus === "paid")
return { result: "already_paid" as const, bookingId: existing.id };
if (existing.paymentStatus === "refunded")
return { result: "already_refunded" as const, bookingId: existing.id };
if (existing.paymentStatus === "failed")
return { result: "already_failed" as const, bookingId: existing.id };
}
const course = await tx.course.findUnique({
where: { id: courseId },
select: { maxParticipants: true },
});
if (!course) return { result: "course_not_found" as const };
const paidCount = await tx.courseBooking.count({
where: { courseId, paymentStatus: "paid" },
});
// GuestLead erst nach Kapazitätsprüfung anlegen — verhindert Phantom-Einträge
// bei Überbuchung. upsert by email: ein Gast bekommt genau einen Lead-Eintrag,
// auch bei mehreren Buchungen (@@unique auf email im Schema).
if (paidCount >= course.maxParticipants) {
const guestLead = await tx.guestLead.upsert({
where: { email: guestEmail },
update: {},
create: { email: guestEmail, name: guestName ?? null },
});
const booking = await tx.courseBooking.create({
data: {
courseId,
guestLeadId: guestLead.id,
paymentStatus: "failed",
stripeSessionId: session.id,
stripePaymentIntentId: paymentIntentId,
amountPaid: amountPaidCents,
platformFee: platformFeeCents,
},
});
return { result: "capacity_exceeded" as const, bookingId: booking.id };
}
const guestLead = await tx.guestLead.upsert({
where: { email: guestEmail },
update: {},
create: { email: guestEmail, name: guestName ?? null },
});
const booking = await tx.courseBooking.create({
data: {
courseId,
guestLeadId: guestLead.id,
paymentStatus: "paid",
stripeSessionId: session.id,
stripePaymentIntentId: paymentIntentId,
amountPaid: amountPaidCents,
platformFee: platformFeeCents,
},
});
return { result: "booked" as const, bookingId: booking.id };
},
{ isolationLevel: "Serializable" }
);
}
/**
* Wartet maximal `ms` Millisekunden auf das Promise.
*/
async function withTimeout<T>(promise: Promise<T>, ms: number): Promise<T | null> {
return Promise.race([
promise,
new Promise<null>((resolve) => setTimeout(() => resolve(null), ms)),
]);
}
async function sendBookingConfirmation(
recipientEmail: string,
courseId: string,
@ -347,10 +486,6 @@ async function sendBookingConfirmation(
}
}
/**
* Lädt Kurs+Business und schickt die Rückerstattungs-Mail an den Kunden.
* Schluckt Fehler bewusst (siehe Caller-Kommentar).
*/
async function sendBookingRefund(
recipientEmail: string,
courseId: string,
@ -379,11 +514,6 @@ async function sendBookingRefund(
}
}
/**
* Erstattet eine Zahlung vollständig zurück (Reverse Transfer + Application Fee,
* damit das Geld komplett zurückfließt – auch der bereits transferierte Anteil
* beim Connected Account).
*/
async function refundPayment(paymentIntentId: string): Promise<boolean> {
try {
await stripe.refunds.create({
@ -398,10 +528,6 @@ async function refundPayment(paymentIntentId: string): Promise<boolean> {
}
}
/**
* Holt die application_fee_amount vom PaymentIntent.
* Wird gebraucht weil das Feld nicht direkt in der Session steht.
*/
async function getApplicationFeeFromPaymentIntent(paymentIntentId: string): Promise<number> {
try {
const intent = await stripe.paymentIntents.retrieve(paymentIntentId);
@ -418,9 +544,26 @@ async function getApplicationFeeFromPaymentIntent(paymentIntentId: string): Prom
*/
async function handleCheckoutExpired(session: Stripe.Checkout.Session) {
const courseId = session.metadata?.courseId;
const userId = session.metadata?.userId;
const isGuest = session.metadata?.isGuest === "true";
if (!courseId || !userId) return;
if (!courseId) return;
if (isGuest) {
// Gäste: per stripeSessionId suchen (kein courseId_userId Unique-Index)
const existing = await prisma.courseBooking.findFirst({
where: { stripeSessionId: session.id },
});
if (existing && existing.paymentStatus === "pending") {
await prisma.courseBooking.update({
where: { id: existing.id },
data: { paymentStatus: "failed" },
});
}
return;
}
const userId = session.metadata?.userId;
if (!userId) return;
const existing = await prisma.courseBooking.findUnique({
where: { courseId_userId: { courseId, userId } },
@ -436,7 +579,6 @@ async function handleCheckoutExpired(session: Stripe.Checkout.Session) {
/**
* Synchronisiert den Stripe Connect Account-Status mit der DB.
* Wird ausgelöst wenn z.B. das Onboarding abgeschlossen wird.
*/
async function handleAccountUpdated(account: Stripe.Account) {
const business = await prisma.business.findUnique({

View file

@ -15,9 +15,16 @@ export async function middleware(request: NextRequest) {
];
const authRoutes = ["/login", "/register", "/forgot-password"];
const isProtectedRoute = protectedRoutes.some((route) =>
request.nextUrl.pathname.startsWith(route)
// Guest-accessible sub-paths that sit under a protected prefix
const guestAllowedPaths = [
"/courses/book/", // success + cancel pages after guest checkout
];
const isGuestAllowed = guestAllowedPaths.some((path) =>
request.nextUrl.pathname.startsWith(path)
);
const isProtectedRoute =
!isGuestAllowed && protectedRoutes.some((route) => request.nextUrl.pathname.startsWith(route));
const isAuthRoute = authRoutes.some((route) => request.nextUrl.pathname.startsWith(route));
const isPasswordReset = request.nextUrl.pathname.startsWith("/reset-password");

View file

@ -9,7 +9,7 @@
import { prisma } from "@/src/lib/prisma";
import { getUserData, isUser } from "@/src/lib/auth/getUser";
import { unstable_cache, revalidatePath } from "next/cache";
import { unstable_cache, revalidatePath, revalidateTag } from "next/cache";
export type UserBooking = {
bookingId: string;
@ -167,7 +167,11 @@ export async function cancelUserBooking(
try {
const booking = await prisma.courseBooking.findUnique({
where: { id: bookingId },
select: { userId: true, paymentStatus: true, course: { select: { date: true } } },
select: {
userId: true,
paymentStatus: true,
course: { select: { date: true, businessId: true } },
},
});
if (!booking || booking.userId !== userData.id) {
@ -188,6 +192,8 @@ export async function cancelUserBooking(
});
revalidatePath("/courses/myCourses");
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(revalidateTag as any)(`business-courses-${booking.course.businessId}`);
return { success: true };
} catch {
return { success: false, error: "Stornierung fehlgeschlagen." };
@ -279,35 +285,56 @@ export async function getCourseSummaryForConfirmation(courseId: string) {
}
export async function getBusinessBySlug(slug: string) {
try {
const business = await prisma.business.findUnique({
where: { slug },
select: {
id: true,
name: true,
address: true,
email: true,
title: true,
slug: true,
isPublic: true,
},
});
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,
},
});
if (!business || !business.isPublic) {
return { success: false, error: "Seite nicht gefunden" };
}
if (!business || !business.isPublic) {
return { success: false as const, error: "Seite nicht gefunden" };
}
return { success: true, business };
} catch (error) {
console.error("Error fetching business by slug:", error);
return { success: false, error: "Fehler beim Laden" };
}
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: {
@ -322,23 +349,24 @@ export async function getPublishedCoursesForBusiness(
});
const trainerIds = [...new Set(courses.flatMap((c) => c.trainers))];
const trainers =
const roomIds = [...new Set(courses.map((c) => c.room).filter(Boolean))] as string[];
const [trainers, rooms] = await Promise.all([
trainerIds.length > 0
? await prisma.employee.findMany({
? prisma.employee.findMany({
where: { id: { in: trainerIds } },
select: { id: true, firstName: true, lastName: true, pbSrc: true },
})
: [];
const trainerMap = Object.fromEntries(trainers.map((t) => [t.id, t]));
const roomIds = [...new Set(courses.map((c) => c.room).filter(Boolean))] as string[];
const rooms =
: Promise.resolve([]),
roomIds.length > 0
? await prisma.room.findMany({
? 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 {

View file

@ -127,6 +127,9 @@ export const BookingDialog = ({
const [isSubmitting, setIsSubmitting] = useState(false);
const [isBooked, setIsBooked] = useState(false);
const [isCheckingStatus, setIsCheckingStatus] = useState(true);
const [showGuestForm, setShowGuestForm] = useState(false);
const [guestEmail, setGuestEmail] = useState("");
const [guestName, setGuestName] = useState("");
const isAdminView = paymentStatus !== undefined;
@ -171,28 +174,44 @@ export const BookingDialog = ({
};
}, [open, course.id, isAdminView]);
const handleBooking = async () => {
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
const handleBooking = async (opts?: { asGuest: boolean }) => {
setIsSubmitting(true);
try {
const body: Record<string, string> = { courseId: course.id };
if (opts?.asGuest) {
const normalizedEmail = guestEmail.trim().toLowerCase();
if (!EMAIL_RE.test(normalizedEmail)) {
toast.error("Bitte eine gültige E-Mail-Adresse angeben.");
setIsSubmitting(false);
return;
}
body.guestEmail = normalizedEmail;
if (guestName.trim()) body.guestName = guestName.trim();
}
const response = await fetch("/api/stripe/checkout/course", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ courseId: course.id }),
body: JSON.stringify(body),
});
if (response.status === 401) {
window.open("/login", "_blank");
setIsSubmitting(false);
return;
}
let data: { url?: string; error?: string } = {};
let data: { url?: string; error?: string; isGuestRequired?: boolean } = {};
try {
data = (await response.json()) as { url?: string; error?: string };
data = (await response.json()) as typeof data;
} catch {
// keep fallback error below
}
// Not logged in → show guest form (and show server error if already in guest mode)
if (response.status === 401 || (response.status === 400 && data.isGuestRequired)) {
if (opts?.asGuest && data.error) toast.error(data.error);
setShowGuestForm(true);
setIsSubmitting(false);
return;
}
if (!response.ok || !data.url) {
toast.error(data.error || "Ein Fehler ist aufgetreten");
setIsSubmitting(false);
@ -333,55 +352,92 @@ export const BookingDialog = ({
</div>
{/* Sticky Footer */}
<div className="shrink-0 border-t border-gray-200 bg-white px-4 sm:px-6 py-3 sm:py-4 flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3">
<span className="text-lg font-bold text-gray-900">Preis: {formattedPrice}</span>
<div className="flex items-center gap-3 w-full sm:w-auto">
{/* Admin-Ansicht: nur Status-Badge + Schließen */}
{isAdminView ? (
<>
<span
className={`text-sm font-semibold px-3 py-1.5 rounded-full ${adminStatus!.bg} ${adminStatus!.text}`}
>
{adminStatus!.label}
</span>
<Button
variant="outline"
onClick={() => onOpenChange(false)}
className="flex-1 sm:flex-initial"
>
Schließen
</Button>
</>
) : (
/* Public-Ansicht: Buchen-Button */
<>
<Button
variant="outline"
onClick={() => onOpenChange(false)}
className="flex-1 sm:flex-initial"
>
Schließen
</Button>
{isCheckingStatus ? (
<Button disabled className="flex-1 sm:flex-initial">
Lade…
</Button>
) : isBooked ? (
<Button disabled className="flex-1 sm:flex-initial">
✓ Angemeldet
</Button>
) : (
<div className="shrink-0 border-t border-gray-200 bg-white px-4 sm:px-6 py-3 sm:py-4 flex flex-col gap-3">
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3">
<span className="text-lg font-bold text-gray-900">Preis: {formattedPrice}</span>
<div className="flex items-center gap-3 w-full sm:w-auto">
{/* Admin-Ansicht: nur Status-Badge + Schließen */}
{isAdminView ? (
<>
<span
className={`text-sm font-semibold px-3 py-1.5 rounded-full ${adminStatus!.bg} ${adminStatus!.text}`}
>
{adminStatus!.label}
</span>
<Button
onClick={handleBooking}
disabled={isSubmitting || isFull}
variant="outline"
onClick={() => onOpenChange(false)}
className="flex-1 sm:flex-initial"
>
{isSubmitting ? "Wird gebucht…" : isFull ? "Ausgebucht" : "Buchen"}
Schließen
</Button>
)}
</>
)}
</>
) : (
/* Public-Ansicht: Buchen-Button */
<>
<Button
variant="outline"
onClick={() => onOpenChange(false)}
className="flex-1 sm:flex-initial"
>
Schließen
</Button>
{isCheckingStatus ? (
<Button disabled className="flex-1 sm:flex-initial">
Lade…
</Button>
) : isBooked ? (
<Button disabled className="flex-1 sm:flex-initial">
✓ Angemeldet
</Button>
) : (
<Button
onClick={() => handleBooking()}
disabled={isSubmitting || isFull}
className="flex-1 sm:flex-initial"
>
{isSubmitting ? "Wird gebucht…" : isFull ? "Ausgebucht" : "Buchen"}
</Button>
)}
</>
)}
</div>
</div>
{/* Gast-Formular: erscheint nach erstem 401 (nicht eingeloggt) */}
{!isAdminView && showGuestForm && !isBooked && (
<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{" "}
<a href="/login" className="underline text-gray-700 hover:text-gray-900">
anmelden
</a>
.
</p>
<input
type="email"
placeholder="E-Mail *"
value={guestEmail}
onChange={(e) => setGuestEmail(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"
required
/>
<input
type="text"
placeholder="Name (optional)"
value={guestName}
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"
>
{isSubmitting ? "Wird gebucht…" : "Als Gast buchen"}
</Button>
</div>
)}
</div>
</DialogContent>
</Dialog>
@ -414,7 +470,7 @@ const PublicCourseCard = ({ course, business, defaultOpen = false }: PublicCours
<>
<div
onClick={() => setDialogOpen(true)}
className="border border-white/60 bg-white/55 backdrop-blur-xl rounded-xl shadow-sm hover:shadow-md hover:bg-white/70 transition flex flex-col cursor-pointer overflow-hidden"
className="border border-gray-200 bg-white rounded-xl shadow-sm hover:shadow-md transition flex flex-col cursor-pointer overflow-hidden"
>
{course.coverImage && (
<div className="relative w-full h-36 shrink-0">

View file

@ -145,7 +145,7 @@ const BusinessPublicView = ({
return (
<div className="min-h-screen bg-gray-50">
{/* Header */}
<div className="border-b border-white/60 bg-white/55 backdrop-blur-xl">
<div className="border-b border-gray-200 bg-white/90 backdrop-blur-sm">
<div className="max-w-6xl mx-auto px-6 py-10">
<h1 className="text-4xl font-bold text-gray-900">{business.name}</h1>
{business.title && <p className="text-xl text-gray-500 mt-1">{business.title}</p>}
@ -169,7 +169,7 @@ const BusinessPublicView = ({
{/* Left: sticky calendar */}
<div className="lg:sticky lg:top-6 shrink-0 w-full lg:w-auto">
<h2 className="text-2xl font-semibold text-gray-900 mb-3">{formattedSelectedDate}</h2>
<div className="border border-white/60 bg-white/55 backdrop-blur-xl rounded-xl shadow-sm p-4 flex justify-center">
<div className="border border-gray-200 bg-white rounded-xl shadow-sm p-4 flex justify-center">
<Calendar
mode="single"
selected={selectedDate}
@ -194,7 +194,7 @@ const BusinessPublicView = ({
className={`px-4 py-1.5 rounded-full text-sm font-medium transition-colors ${
resolvedSport === sport
? "bg-gray-900 text-white"
: "border border-white/60 bg-white/55 backdrop-blur-sm text-gray-600 hover:bg-white/70"
: "border border-gray-200 bg-white text-gray-600 hover:bg-gray-50"
}`}
>
{sport === "Alle" ? "Alle" : capitalize(sport)}

View file

@ -9,6 +9,7 @@ export type KundeRow = {
email: string;
telefon: string;
status: "Bezahlt" | "Storniert" | "Ausstehend";
isGuest: boolean;
};
function mapPaymentStatus(paymentStatus: string): KundeRow["status"] {
@ -28,7 +29,6 @@ export async function getKunden(): Promise<
return { success: true, kunden: [] };
}
// Alle Bookings für Kurse die zu diesem Manager gehören, gruppiert nach User
const bookings = await prisma.courseBooking.findMany({
where: {
course: { businessId: { in: businessIds } },
@ -36,28 +36,43 @@ export async function getKunden(): Promise<
select: {
paymentStatus: true,
user: {
select: {
id: true,
name: true,
email: true,
tel: true,
},
select: { id: true, name: true, email: true, tel: true },
},
guestLead: {
select: { id: true, name: true, email: true },
},
},
orderBy: { createdAt: "desc" },
});
// Pro User nur den letzten (relevantesten) Status behalten
// Deduplizieren: pro User-ID bzw. GuestLead-ID nur den letzten Status
const seen = new Map<string, KundeRow>();
for (const booking of bookings) {
if (!seen.has(booking.user.id)) {
seen.set(booking.user.id, {
id: booking.user.id,
name: booking.user.name ?? booking.user.email,
email: booking.user.email,
telefon: booking.user.tel ?? "–",
status: mapPaymentStatus(booking.paymentStatus),
});
if (booking.user) {
const key = `user:${booking.user.id}`;
if (!seen.has(key)) {
seen.set(key, {
id: booking.user.id,
name: booking.user.name ?? booking.user.email,
email: booking.user.email,
telefon: booking.user.tel ?? "–",
status: mapPaymentStatus(booking.paymentStatus),
isGuest: false,
});
}
} else if (booking.guestLead) {
const key = `guest:${booking.guestLead.id}`;
if (!seen.has(key)) {
seen.set(key, {
id: booking.guestLead.id,
name: booking.guestLead.name ?? booking.guestLead.email,
email: booking.guestLead.email,
telefon: "–",
status: mapPaymentStatus(booking.paymentStatus),
isGuest: true,
});
}
}
}

View file

@ -75,10 +75,19 @@ const KundenTable = ({ kunden }: KundenTableProps) => {
kunden.map((kunde) => (
<TableRow
key={kunde.id}
className="border-b border-white/30 transition-colors duration-150 hover:bg-white/70 cursor-pointer"
onClick={() => router.push(`/kunden/${kunde.id}`)}
className={`border-b border-white/30 transition-colors duration-150 hover:bg-white/70 ${!kunde.isGuest ? "cursor-pointer" : ""}`}
onClick={() => !kunde.isGuest && router.push(`/kunden/${kunde.id}`)}
>
<TableCell className="font-medium">{kunde.name}</TableCell>
<TableCell className="font-medium">
<span className="flex items-center gap-2">
{kunde.name}
{kunde.isGuest && (
<span className="text-xs font-semibold px-2 py-0.5 rounded-full bg-gray-100 text-gray-500">
Gast
</span>
)}
</span>
</TableCell>
<TableCell className="hidden md:table-cell text-muted-foreground">
{kunde.email}
</TableCell>
@ -93,17 +102,19 @@ const KundenTable = ({ kunden }: KundenTableProps) => {
</span>
</TableCell>
<TableCell>
<Button
variant="ghost"
size="icon"
className="rounded-full hover:bg-white/60"
onClick={(e) => {
e.stopPropagation();
router.push(`/kunden/${kunde.id}`);
}}
>
<ArrowRight size={16} />
</Button>
{!kunde.isGuest && (
<Button
variant="ghost"
size="icon"
className="rounded-full hover:bg-white/60"
onClick={(e) => {
e.stopPropagation();
router.push(`/kunden/${kunde.id}`);
}}
>
<ArrowRight size={16} />
</Button>
)}
</TableCell>
</TableRow>
))

View file

@ -36,7 +36,7 @@ export async function updateBusinessInfo(data: {
return { success: false, error: "Ungültige E-Mail-Adresse." };
}
await prisma.business.update({
const updated = await prisma.business.update({
where: { id: data.businessId },
data: {
address: data.address.trim(),
@ -45,9 +45,12 @@ export async function updateBusinessInfo(data: {
ustId: data.ustId?.trim() || null,
banking: data.banking?.trim() || null,
},
select: { slug: true },
});
(revalidateTag as (tag: string, type: "max") => void)(`user-data-${manager.email}`, "max");
// eslint-disable-next-line @typescript-eslint/no-explicit-any
if (updated.slug) (revalidateTag as any)(`business-slug-${updated.slug}`);
return { success: true };
} catch {
return { success: false, error: "Fehler beim Speichern der Business-Daten." };