see comments

This commit is contained in:
Rajpreet0 2026-06-16 21:33:31 +02:00
parent b4a4a9fdba
commit 2c601d7516
6 changed files with 101 additions and 59 deletions

View file

@ -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 {

View file

@ -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<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);
@ -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}`,

View file

@ -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,

View file

@ -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<string, string> = { 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 = ({
/>
<Button
onClick={() => handleBooking({ asGuest: true })}
disabled={isSubmitting || isFull || !guestEmail.trim()}
disabled={isSubmitting || isFull || !EMAIL_RE.test(guestEmail.trim().toLowerCase())}
className="w-full"
>
{isSubmitting ? "Wird gebucht…" : "Als Gast buchen"}

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,30 +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.
// Gast-Buchungen (user === null) werden hier übersprungen.
// Deduplizieren: pro User-ID bzw. GuestLead-ID nur den letzten Status
const seen = new Map<string, KundeRow>();
for (const booking of bookings) {
if (!booking.user) continue;
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>
))