diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 169fcbb..83a0037 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -152,12 +152,10 @@ model Room { model GuestLead { id String @id @default(uuid()) - email String + email String @unique name String? bookings CourseBooking[] createdAt DateTime @default(now()) - - @@index([email]) } model CourseBooking { diff --git a/src/app/api/stripe/checkout/course/route.ts b/src/app/api/stripe/checkout/course/route.ts index f17ee9e..d6815e5 100644 --- a/src/app/api/stripe/checkout/course/route.ts +++ b/src/app/api/stripe/checkout/course/route.ts @@ -4,11 +4,21 @@ import { stripe, calculatePlatformFeeCents, eurosToCents } from "@/src/lib/strip import { getUserData, isUser } from "@/src/lib/auth/getUser"; // Simple in-memory rate limiter: max 10 checkout attempts per IP per minute. -// Resets automatically as entries expire — no external dependency needed. +// 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(); 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); @@ -76,10 +86,10 @@ export async function POST(request: NextRequest) { } } else { // Guest flow: guestEmail required - if (!guestEmail || !EMAIL_RE.test(guestEmail)) { + if (!guestEmail || !EMAIL_RE.test(guestEmail.trim())) { return NextResponse.json( { error: "Bitte eine gültige E-Mail-Adresse angeben.", isGuestRequired: true }, - { status: 401 } + { status: 400 } ); } } @@ -150,7 +160,7 @@ export async function POST(request: NextRequest) { const priceCents = eurosToCents(course.price); const platformFeeCents = calculatePlatformFeeCents(course.price); - const customerEmail = isGuest ? guestEmail! : userData!.email; + const customerEmail = isGuest ? guestEmail!.trim().toLowerCase() : userData!.email; const session = await stripe.checkout.sessions.create({ mode: "payment", @@ -177,8 +187,8 @@ export async function POST(request: NextRequest) { userId: isGuest ? "" : userData!.id, businessId: course.business.id, isGuest: isGuest ? "true" : "false", - guestEmail: isGuest ? guestEmail! : "", - guestName: isGuest ? (guestName ?? "") : "", + guestEmail: isGuest ? customerEmail : "", + guestName: isGuest ? (guestName?.trim() ?? "") : "", }, }, metadata: { @@ -186,8 +196,8 @@ export async function POST(request: NextRequest) { userId: isGuest ? "" : userData!.id, businessId: course.business.id, isGuest: isGuest ? "true" : "false", - guestEmail: isGuest ? guestEmail! : "", - guestName: isGuest ? (guestName ?? "") : "", + guestEmail: isGuest ? customerEmail : "", + guestName: isGuest ? (guestName?.trim() ?? "") : "", }, customer_email: customerEmail, success_url: `${baseUrl}/courses/book/${course.id}/success?session_id={CHECKOUT_SESSION_ID}`, diff --git a/src/app/api/stripe/webhook/route.ts b/src/app/api/stripe/webhook/route.ts index 0964502..76e8ef2 100644 --- a/src/app/api/stripe/webhook/route.ts +++ b/src/app/api/stripe/webhook/route.ts @@ -392,12 +392,15 @@ async function runCapacityTransactionGuest(params: { where: { courseId, paymentStatus: "paid" }, }); - // GuestLead anlegen (kein Upsert — mehrere Buchungen eines Gastes sind erlaubt) - const guestLead = await tx.guestLead.create({ - data: { email: guestEmail, name: guestName ?? null }, - }); - + // 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, @@ -412,6 +415,11 @@ async function runCapacityTransactionGuest(params: { 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, diff --git a/src/modules/courses/ui/components/PublicCourseCard.tsx b/src/modules/courses/ui/components/PublicCourseCard.tsx index 6556f5c..08d5563 100644 --- a/src/modules/courses/ui/components/PublicCourseCard.tsx +++ b/src/modules/courses/ui/components/PublicCourseCard.tsx @@ -174,12 +174,20 @@ export const BookingDialog = ({ }; }, [open, course.id, isAdminView]); + const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + const handleBooking = async (opts?: { asGuest: boolean }) => { setIsSubmitting(true); try { const body: Record = { courseId: course.id }; if (opts?.asGuest) { - body.guestEmail = guestEmail; + 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(); } @@ -196,15 +204,9 @@ export const BookingDialog = ({ // keep fallback error below } - // Not logged in and no guest email provided → show guest form - if (response.status === 401 && data.isGuestRequired) { - setShowGuestForm(true); - setIsSubmitting(false); - return; - } - - // Not logged in, guest form not yet shown → show it - if (response.status === 401 && !opts?.asGuest) { + // 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; @@ -429,7 +431,7 @@ export const BookingDialog = ({ /> + {!kunde.isGuest && ( + + )} ))