mirror of
https://github.com/Since-3/Kuma.git
synced 2026-10-12 20:51:18 +00:00
see comments
This commit is contained in:
parent
b4a4a9fdba
commit
2c601d7516
6 changed files with 101 additions and 59 deletions
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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}`,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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"}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
))
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue