bug fixes

This commit is contained in:
Rajpreet0 2026-09-11 12:06:58 +02:00
parent dad50d1c97
commit 40003e63d2
13 changed files with 464 additions and 397 deletions

View file

@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/src/lib/prisma"; import { prisma } from "@/src/lib/prisma";
import { stripe, calculatePlatformFeeCents, eurosToCents } from "@/src/lib/stripe"; import { stripe, calculatePlatformFeeCents, eurosToCents } from "@/src/lib/stripe";
import { getUserData, isUser } from "@/src/lib/auth/getUser"; import { getUserData, isUser } from "@/src/lib/auth/getUser";
import { isManagerOrEmployeeEmail } from "@/src/lib/auth/account-role-by-email";
// Simple in-memory rate limiter: max 10 checkout attempts per IP per minute. // Simple in-memory rate limiter: max 10 checkout attempts per IP per minute.
// Best-effort only — in multi-instance/serverless deployments each instance has // Best-effort only — in multi-instance/serverless deployments each instance has
@ -92,6 +93,20 @@ export async function POST(request: NextRequest) {
{ status: 400 } { status: 400 }
); );
} }
// Manager/Mitarbeiter dürfen nicht als Gast mit ihrer geschäftlichen
// E-Mail buchen: sie haben keinen User-Eintrag, die Buchung liefe als
// GuestLead durch und würde den Account-Kontext umgehen. Vor der
// Stripe-Session abfangen, damit kein Geld fließt.
if (await isManagerOrEmployeeEmail(guestEmail.trim().toLowerCase())) {
return NextResponse.json(
{
error:
"Diese E-Mail-Adresse gehört zu einem Team-Account und kann nicht für eine Gast-Buchung verwendet werden. Bitte mit einem Kunden-Konto anmelden oder eine andere E-Mail-Adresse verwenden.",
},
{ status: 403 }
);
}
} }
const course = await prisma.course.findUnique({ const course = await prisma.course.findUnique({

View file

@ -12,6 +12,7 @@ import {
} from "@/src/lib/mail/templates/booking-refund"; } from "@/src/lib/mail/templates/booking-refund";
import { toEmailBranding } from "@/src/modules/settings/lib/to-email-branding"; import { toEmailBranding } from "@/src/modules/settings/lib/to-email-branding";
import { notifyBusinessManager } from "@/src/modules/notifications/lib/create-notification"; import { notifyBusinessManager } from "@/src/modules/notifications/lib/create-notification";
import { isManagerOrEmployeeEmail } from "@/src/lib/auth/account-role-by-email";
/** /**
* Sucht einen registrierten User anhand der E-Mail, case-insensitive. * Sucht einen registrierten User anhand der E-Mail, case-insensitive.
@ -126,6 +127,29 @@ async function handleCheckoutCompleted(session: Stripe.Checkout.Session) {
return; return;
} }
// Sicherheitsnetz: Manager/Mitarbeiter dürfen nicht als Gast mit ihrer
// geschäftlichen E-Mail buchen. Die Checkout-Route fängt das bereits vor
// der Stripe-Session ab; falls eine Session diesen Pfad dennoch erreicht
// (direkter API-Aufruf, Race-Condition), wird hier keine Buchung
// angelegt und die Zahlung sofort erstattet.
if (await isManagerOrEmployeeEmail(guestEmail)) {
console.warn(
`checkout.session.completed: Gast-E-Mail ${guestEmail} gehört zu einem Team-Account. ` +
`Buchung wird abgelehnt, Zahlung wird erstattet (Session ${session.id}).`
);
if (paymentIntentId) {
const refundResult = await refundStripePayment(paymentIntentId);
if (!refundResult.success) {
console.error(
`Refund fehlgeschlagen für abgelehnte Team-Account-Gastbuchung ` +
`(PaymentIntent ${paymentIntentId}): ${refundResult.error}. ` +
`Manuelle Erstattung im Stripe Dashboard nötig.`
);
}
}
return;
}
// Zwischen Checkout-Start (als Gast) und Webhook-Zustellung kann sich der // Zwischen Checkout-Start (als Gast) und Webhook-Zustellung kann sich der
// Gast bereits mit derselben E-Mail registriert haben. In dem Fall die // Gast bereits mit derselben E-Mail registriert haben. In dem Fall die
// Buchung direkt dem neuen Account zuordnen statt einen verwaisten // Buchung direkt dem neuen Account zuordnen statt einen verwaisten
@ -161,6 +185,26 @@ async function handleCheckoutCompleted(session: Stripe.Checkout.Session) {
session.customer_details?.email || session.customer_details?.email ||
session.customer_email; session.customer_email;
if (fallbackEmail) { if (fallbackEmail) {
// Wie im Guest-Zweig: Team-Account-E-Mails abfangen und erstatten,
// statt eine GuestLead-Buchung anzulegen.
if (await isManagerOrEmployeeEmail(fallbackEmail)) {
console.warn(
`checkout.session.completed: Fallback-E-Mail ${fallbackEmail} gehört zu einem ` +
`Team-Account. Buchung wird abgelehnt, Zahlung wird erstattet (Session ${session.id}).`
);
if (paymentIntentId) {
const refundResult = await refundStripePayment(paymentIntentId);
if (!refundResult.success) {
console.error(
`Refund fehlgeschlagen für abgelehnte Team-Account-Gastbuchung ` +
`(PaymentIntent ${paymentIntentId}): ${refundResult.error}. ` +
`Manuelle Erstattung im Stripe Dashboard nötig.`
);
}
}
return;
}
const registeredUser = await findUserByEmailInsensitive(fallbackEmail); const registeredUser = await findUserByEmailInsensitive(fallbackEmail);
outcome = registeredUser outcome = registeredUser
? await runCapacityTransactionUser({ ? await runCapacityTransactionUser({

View file

@ -0,0 +1,28 @@
import { prisma } from "@/src/lib/prisma";
/**
* Prüft, ob eine E-Mail bereits zu einem Manager- oder Employee-Account gehört.
*
* Wird im Gast-Buchungsflow gebraucht: Manager/Mitarbeiter haben keinen Eintrag
* in der User-Tabelle und dürfen Kurse nicht als Gast mit ihrer geschäftlichen
* E-Mail buchen (siehe Checkout-Route und Stripe-Webhook). Der Vergleich ist
* case-insensitive, da Gast-E-Mails immer lowercase normalisiert werden, ältere
* Manager/Employee-Datensätze aber abweichende Schreibweisen haben können.
*/
export async function isManagerOrEmployeeEmail(email: string): Promise<boolean> {
const normalized = email.trim();
if (!normalized) return false;
const [manager, employee] = await Promise.all([
prisma.manager.findFirst({
where: { email: { equals: normalized, mode: "insensitive" } },
select: { id: true },
}),
prisma.employee.findFirst({
where: { email: { equals: normalized, mode: "insensitive" } },
select: { id: true },
}),
]);
return !!manager || !!employee;
}

View file

@ -0,0 +1,48 @@
"use client";
const OUTPUT_SIZE = 512;
/**
* Passt ein Logo automatisch in ein quadratisches, transparentes PNG ein
* ("contain"): das Seitenverhältnis bleibt erhalten, nichts wird abgeschnitten,
* der Rand bleibt transparent. Ersetzt das manuelle Zoom/Pan-Zuschneiden – da
* Logos überall via `object-contain` in einem Quadrat dargestellt werden
* (Header, Rechnung, öffentliche Seite), reicht ein zentriertes Einpassen.
*
* SVGs werden unverändert zurückgegeben (Vektor bleibt scharf, das Rastern
* würde nur Qualität kosten). Nicht-Bild-Dateien ebenso.
*/
export async function squareLogo(file: File, size: number = OUTPUT_SIZE): Promise<File> {
if (!file.type.startsWith("image/") || file.type === "image/svg+xml") {
return file;
}
const bitmap = await createImageBitmap(file).catch(() => null);
if (!bitmap) return file;
const canvas = document.createElement("canvas");
canvas.width = size;
canvas.height = size;
const ctx = canvas.getContext("2d");
if (!ctx) {
bitmap.close();
return file;
}
// Contain-Scale: längere Kante füllt das Quadrat, die kürzere wird zentriert.
const scale = Math.min(size / bitmap.width, size / bitmap.height);
const drawWidth = bitmap.width * scale;
const drawHeight = bitmap.height * scale;
const x = (size - drawWidth) / 2;
const y = (size - drawHeight) / 2;
ctx.clearRect(0, 0, size, size);
ctx.drawImage(bitmap, x, y, drawWidth, drawHeight);
bitmap.close();
const blob = await new Promise<Blob | null>((resolve) => canvas.toBlob(resolve, "image/png"));
if (!blob) return file;
const name = file.name.replace(/\.[^.]+$/, "") + ".png";
return new File([blob], name, { type: "image/png", lastModified: Date.now() });
}

View file

@ -40,6 +40,9 @@ export type UserBooking = {
paymentStatus: string; paymentStatus: string;
isPast: boolean; isPast: boolean;
businessName: string; 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 }; businessBranding: { logoUrl: string | null; accentColor: string; accentTextColor: string };
}; };
@ -75,7 +78,9 @@ export async function getUserBookings(): Promise<
coverImage: true, coverImage: true,
description: true, description: true,
_count: { select: { bookings: { where: { paymentStatus: "paid" } } } }, _count: { select: { bookings: { where: { paymentStatus: "paid" } } } },
business: { select: { name: true, branding: true } }, business: {
select: { name: true, address: true, email: true, branding: true },
},
}, },
}, },
}, },
@ -150,6 +155,8 @@ export async function getUserBookings(): Promise<
paymentStatus: b.paymentStatus, paymentStatus: b.paymentStatus,
isPast: courseStart < now, isPast: courseStart < now,
businessName: b.course.business?.name ?? branding.companyName, businessName: b.course.business?.name ?? branding.companyName,
businessAddress: b.course.business?.address ?? null,
businessEmail: b.course.business?.email ?? null,
businessBranding: { businessBranding: {
logoUrl: branding.logoUrl, logoUrl: branding.logoUrl,
accentColor: branding.accentColor, accentColor: branding.accentColor,

View file

@ -31,94 +31,60 @@ export const normalizeWeekdayTimings = (raw: RawWeekdayTimings): WeekdayTimings
return result; return result;
}; };
/** Ermittelt ein Standard-Zeitfenster, falls alle Tage genau ein identisches haben. */
const deriveDefaultSlot = (timings: WeekdayTimings): WeekdaySlot => {
const daySlots = Object.values(timings);
if (daySlots.length === 0) return emptySlot();
const first = daySlots[0][0];
if (!first) return emptySlot();
const allSame = daySlots.every(
(slots) =>
slots.length === 1 && slots[0].timeFrom === first.timeFrom && slots[0].timeTo === first.timeTo
);
return allSame ? { ...first } : emptySlot();
};
interface UseWeekdayTimingsOptions { interface UseWeekdayTimingsOptions {
/** Vorbelegung (Edit-Modus) — beliebiges gespeichertes Format. */ /** Vorbelegung (Edit-Modus) — beliebiges gespeichertes Format. */
initialWeekdays?: string[]; initialWeekdays?: string[];
initialTimings?: RawWeekdayTimings; initialTimings?: RawWeekdayTimings;
/**
* Uhrzeit, mit der neu ausgewählte Tage vorbefüllt werden — hier die
* allgemeinen Kurs-Zeitfelder ("Uhrzeit von/bis" oben im Formular). Wird bei
* jedem Render aktuell übergeben; leere Werte belegen einen leeren Slot.
*/
defaultSlot?: WeekdaySlot;
} }
/** /**
* Kapselt die Wochentag-Auswahl und deren Zeitfenster für die "custom"-Häufigkeit * Kapselt die Wochentag-Auswahl und deren Zeitfenster für die "custom"-Häufigkeit
* eines Dauerauftrags. Jeder Tag kann mehrere Zeitfenster haben; ein * eines Dauerauftrags. Jeder Tag kann mehrere Zeitfenster haben. Neu ausgewählte
* Standard-Zeitfenster kann auf alle Tage übertragen werden. * Tage (und neue Zeitfenster) werden mit der allgemeinen Kurs-Uhrzeit
* vorbelegt, falls diese gesetzt ist.
*/ */
export function useWeekdayTimings({ export function useWeekdayTimings({
initialWeekdays = [], initialWeekdays = [],
initialTimings, initialTimings,
defaultSlot,
}: UseWeekdayTimingsOptions = {}) { }: UseWeekdayTimingsOptions = {}) {
const [selectedWeekdays, setSelectedWeekdays] = useState<string[]>(initialWeekdays); const [selectedWeekdays, setSelectedWeekdays] = useState<string[]>(initialWeekdays);
const [weekdayTimings, setWeekdayTimings] = useState<WeekdayTimings>(() => const [weekdayTimings, setWeekdayTimings] = useState<WeekdayTimings>(() =>
normalizeWeekdayTimings(initialTimings) normalizeWeekdayTimings(initialTimings)
); );
const [defaultSlot, setDefaultSlot] = useState<WeekdaySlot>(() =>
deriveDefaultSlot(normalizeWeekdayTimings(initialTimings))
);
const isDefaultSlotFilled = !!defaultSlot.timeFrom && !!defaultSlot.timeTo; // Vorbelegung neuer Slots mit der aktuellen Kurs-Uhrzeit aus den oberen
// Zeitfeldern. Die Callbacks unten sind nicht memoisiert, lesen also bei
// jedem Render den aktuellen Wert.
const newSlot = (): WeekdaySlot => ({
timeFrom: defaultSlot?.timeFrom ?? "",
timeTo: defaultSlot?.timeTo ?? "",
});
const reset = () => { const reset = () => {
setSelectedWeekdays([]); setSelectedWeekdays([]);
setWeekdayTimings({}); setWeekdayTimings({});
setDefaultSlot(emptySlot());
}; };
// Hält weekdayTimings mit der Tagesauswahl synchron: jeder ausgewählte Tag // Hält weekdayTimings mit der Tagesauswahl synchron: jeder neu ausgewählte Tag
// bekommt mindestens einen (ggf. leeren) Slot, abgewählte Tage fallen raus. // bekommt einen mit der Kurs-Uhrzeit vorbefüllten Slot, abgewählte Tage fallen raus.
const syncWeekdays = (days: string[]) => { const syncWeekdays = (days: string[]) => {
setSelectedWeekdays(days); setSelectedWeekdays(days);
setWeekdayTimings((prev) => { setWeekdayTimings((prev) => {
const next: WeekdayTimings = {}; const next: WeekdayTimings = {};
days.forEach((d) => { days.forEach((d) => {
next[d] = prev[d]?.length ? prev[d] : [{ ...defaultSlot }]; next[d] = prev[d]?.length ? prev[d] : [newSlot()];
}); });
return next; return next;
}); });
}; };
// Standard-Zeitfenster auf alle ausgewählten Tage übertragen (ersetzt deren Slots).
const applyDefaultToAll = () => {
if (!isDefaultSlotFilled || selectedWeekdays.length === 0) return;
setWeekdayTimings(() => {
const next: WeekdayTimings = {};
selectedWeekdays.forEach((d) => {
next[d] = [{ ...defaultSlot }];
});
return next;
});
};
const updateDefaultSlot = (patch: Partial<WeekdaySlot>) => {
const updated = { ...defaultSlot, ...patch };
setDefaultSlot(updated);
// Solange noch kein Tag eine eigene Uhrzeit gesetzt hat, die Änderung direkt
// auf alle ausgewählten Tage spiegeln (initiale Bequemlichkeit).
const noDayCustomized = selectedWeekdays.every((d) =>
(weekdayTimings[d] ?? []).every((s) => !s.timeFrom && !s.timeTo)
);
if (noDayCustomized && selectedWeekdays.length > 0) {
setWeekdayTimings(() => {
const next: WeekdayTimings = {};
selectedWeekdays.forEach((d) => {
next[d] = [{ ...updated }];
});
return next;
});
}
};
const updateSlot = (day: string, index: number, patch: Partial<WeekdaySlot>) => { const updateSlot = (day: string, index: number, patch: Partial<WeekdaySlot>) => {
setWeekdayTimings((prev) => { setWeekdayTimings((prev) => {
const slots = (prev[day] ?? [emptySlot()]).map((s, i) => const slots = (prev[day] ?? [emptySlot()]).map((s, i) =>
@ -131,14 +97,14 @@ export function useWeekdayTimings({
const addSlot = (day: string) => { const addSlot = (day: string) => {
setWeekdayTimings((prev) => ({ setWeekdayTimings((prev) => ({
...prev, ...prev,
[day]: [...(prev[day] ?? []), emptySlot()], [day]: [...(prev[day] ?? []), newSlot()],
})); }));
}; };
const removeSlot = (day: string, index: number) => { const removeSlot = (day: string, index: number) => {
setWeekdayTimings((prev) => { setWeekdayTimings((prev) => {
const slots = (prev[day] ?? []).filter((_, i) => i !== index); const slots = (prev[day] ?? []).filter((_, i) => i !== index);
return { ...prev, [day]: slots.length ? slots : [emptySlot()] }; return { ...prev, [day]: slots.length ? slots : [newSlot()] };
}); });
}; };
@ -166,14 +132,10 @@ export function useWeekdayTimings({
return { return {
selectedWeekdays, selectedWeekdays,
weekdayTimings, weekdayTimings,
defaultSlot,
isDefaultSlotFilled,
cleanedTimings, cleanedTimings,
hasInvalidTiming, hasInvalidTiming,
reset, reset,
syncWeekdays, syncWeekdays,
applyDefaultToAll,
updateDefaultSlot,
updateSlot, updateSlot,
addSlot, addSlot,
removeSlot, removeSlot,

View file

@ -141,6 +141,11 @@ export const BookingDialog = ({
// Wenn gesetzt → Admin-Ansicht: kein Buchen-Button, nur Status-Badge // Wenn gesetzt → Admin-Ansicht: kein Buchen-Button, nur Status-Badge
paymentStatus, paymentStatus,
accentColor, accentColor,
// Wenn gesetzt → im Footer erscheint ein "Stornieren"-Button (z.B. "Meine
// Kurse": bezahlte, kommende Buchung). Das Öffnen des Bestätigungsdialogs
// liegt beim Aufrufer.
canCancel = false,
onRequestCancel,
}: { }: {
course: PublicCourse; course: PublicCourse;
business?: BusinessInfo; business?: BusinessInfo;
@ -148,6 +153,8 @@ export const BookingDialog = ({
onOpenChange: (v: boolean) => void; onOpenChange: (v: boolean) => void;
paymentStatus?: string; paymentStatus?: string;
accentColor?: string; accentColor?: string;
canCancel?: boolean;
onRequestCancel?: () => void;
}) => { }) => {
const accentButtonStyle = accentColor const accentButtonStyle = accentColor
? { backgroundColor: accentColor, color: getContrastTextColor(accentColor) } ? { backgroundColor: accentColor, color: getContrastTextColor(accentColor) }
@ -434,6 +441,16 @@ export const BookingDialog = ({
> >
{adminStatus!.label} {adminStatus!.label}
</span> </span>
{canCancel && onRequestCancel && (
<Button
variant="outline"
onClick={onRequestCancel}
className="flex-1 sm:flex-initial text-red-600 border-red-200 hover:bg-red-50 hover:text-red-700 hover:border-red-300"
>
<X size={14} className="mr-1.5 shrink-0" />
Stornieren
</Button>
)}
<Button <Button
variant="outline" variant="outline"
onClick={() => onOpenChange(false)} onClick={() => onOpenChange(false)}

View file

@ -3,16 +3,25 @@
import { useState, useTransition } from "react"; import { useState, useTransition } from "react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import Image from "next/image"; import Image from "next/image";
import { MapPin, Clock, X } from "lucide-react"; import { MapPin, Clock, Users } from "lucide-react";
import type { UserBooking } from "../../actions/booking-actions"; import type { UserBooking } from "../../actions/booking-actions";
import { cancelUserBooking } from "../../actions/booking-actions"; import { cancelUserBooking } from "../../actions/booking-actions";
import CancelBookingConfirmDialog from "./CancelBookingConfirmDialog"; import CancelBookingConfirmDialog from "./CancelBookingConfirmDialog";
import AbstractTooltip from "@/src/components/layout/AbstractTooltip"; import AbstractTooltip from "@/src/components/layout/AbstractTooltip";
import { BookingDialog } from "@/src/modules/courses/ui/components/PublicCourseCard"; import { BookingDialog } from "@/src/modules/courses/ui/components/PublicCourseCard";
import type { PublicCourse } from "@/src/modules/courses/ui/components/PublicCourseCard"; import type {
import { Button } from "@/src/components/ui/button"; PublicCourse,
BusinessInfo,
} from "@/src/modules/courses/ui/components/PublicCourseCard";
import { toast } from "sonner"; import { toast } from "sonner";
const LEVEL_LABELS: Record<string, { label: string; bg: string; text: string }> = {
any: { label: "Jedes Niveau", bg: "bg-gray-100", text: "text-gray-600" },
beginner: { label: "Anfänger", bg: "bg-blue-100", text: "text-blue-700" },
advanced: { label: "Fortgeschrittene", bg: "bg-orange-100", text: "text-orange-700" },
pro: { label: "Profi", bg: "bg-red-100", text: "text-red-700" },
};
const getDiceBearUrl = (seed: string) => const getDiceBearUrl = (seed: string) =>
`https://api.dicebear.com/9.x/thumbs/svg?seed=${encodeURIComponent(`t-${seed.slice(0, 6)}`)}`; `https://api.dicebear.com/9.x/thumbs/svg?seed=${encodeURIComponent(`t-${seed.slice(0, 6)}`)}`;
@ -49,6 +58,15 @@ function toPublicCourse(b: UserBooking): PublicCourse {
}; };
} }
/** Standort-Kontaktdaten für den Detail-Dialog (Email + Ort). */
function toBusinessInfo(b: UserBooking): BusinessInfo | undefined {
if (!b.businessAddress && !b.businessEmail) return undefined;
return {
address: b.businessAddress ?? "",
email: b.businessEmail ?? "",
};
}
interface UserBookingCardProps { interface UserBookingCardProps {
booking: UserBooking; booking: UserBooking;
} }
@ -71,6 +89,9 @@ const UserBookingCard = ({ booking }: UserBookingCardProps) => {
}); });
const canCancel = !booking.isPast && !cancelled && booking.paymentStatus === "paid"; const canCancel = !booking.isPast && !cancelled && booking.paymentStatus === "paid";
const level = LEVEL_LABELS[booking.level] ?? LEVEL_LABELS.any;
const safeCurrent = Math.max(0, booking.currentParticipants);
const safeMax = Math.max(0, booking.maxParticipants);
const handleConfirmCancel = () => const handleConfirmCancel = () =>
new Promise<{ success: true } | { success: false; error: string }>((resolve) => { new Promise<{ success: true } | { success: false; error: string }>((resolve) => {
@ -83,6 +104,27 @@ const UserBookingCard = ({ booking }: UserBookingCardProps) => {
return ( return (
<> <>
<div className="bg-white/70 backdrop-blur-sm border border-white/50 rounded-xl shadow-sm hover:shadow-md hover:bg-white/85 transition-all flex flex-col gap-0 overflow-hidden"> <div className="bg-white/70 backdrop-blur-sm border border-white/50 rounded-xl shadow-sm hover:shadow-md hover:bg-white/85 transition-all flex flex-col gap-0 overflow-hidden">
{/* Kursbild */}
<button
type="button"
onClick={() => setDialogOpen(true)}
className="relative w-full h-32 shrink-0 bg-gray-100 cursor-pointer select-none"
aria-label={`Kurs ${booking.courseName} öffnen`}
>
{booking.coverImage ? (
<Image
src={booking.coverImage}
alt={booking.courseName}
fill
className="object-cover"
/>
) : (
<div className="w-full h-full bg-linear-to-br from-gray-200 to-gray-300 flex items-center justify-center">
<span className="text-xs text-gray-400">Kein Bild</span>
</div>
)}
</button>
{/* Clickable card area */} {/* Clickable card area */}
<button <button
type="button" type="button"
@ -149,6 +191,19 @@ const UserBookingCard = ({ booking }: UserBookingCardProps) => {
</span> </span>
</div> </div>
{/* Schwierigkeitsgrad + Teilnehmerzahl */}
<div className="flex items-center justify-between gap-2">
<span
className={`inline-block text-xs font-semibold px-2 py-0.5 rounded-full ${level.bg} ${level.text}`}
>
{level.label}
</span>
<span className="flex items-center gap-1 text-xs text-gray-500 shrink-0">
<Users size={13} className="shrink-0" />
{safeCurrent}/{safeMax}
</span>
</div>
{/* Status Badge */} {/* Status Badge */}
<div> <div>
<span <span
@ -158,32 +213,20 @@ const UserBookingCard = ({ booking }: UserBookingCardProps) => {
</span> </span>
</div> </div>
</button> </button>
{/* Stornierung Button – nur für zukünftige, nicht stornierte Buchungen */}
{canCancel && (
<div className="px-4 pb-4">
<Button
variant="outline"
size="sm"
className="w-full text-red-600 border-red-200 hover:bg-red-50 hover:text-red-700 hover:border-red-300"
onClick={(e) => {
e.stopPropagation();
setConfirmOpen(true);
}}
>
<X size={14} className="mr-1.5 shrink-0" />
Stornieren
</Button>
</div>
)}
</div> </div>
<BookingDialog <BookingDialog
course={toPublicCourse(booking)} course={toPublicCourse(booking)}
business={toBusinessInfo(booking)}
open={dialogOpen} open={dialogOpen}
onOpenChange={setDialogOpen} onOpenChange={setDialogOpen}
paymentStatus={effectiveStatus} paymentStatus={effectiveStatus}
accentColor={booking.businessBranding.accentColor} accentColor={booking.businessBranding.accentColor}
canCancel={canCancel}
onRequestCancel={() => {
setDialogOpen(false);
setConfirmOpen(true);
}}
/> />
<CancelBookingConfirmDialog <CancelBookingConfirmDialog

View file

@ -13,7 +13,7 @@ import { getMyRooms } from "@/src/modules/rooms/actions/room-actions";
import { getMyTrainers } from "@/src/modules/employee/actions/employee-actions"; import { getMyTrainers } from "@/src/modules/employee/actions/employee-actions";
import { toast } from "sonner"; import { toast } from "sonner";
import { Course } from "../../types/course.types"; import { Course } from "../../types/course.types";
import { Trash2, ImagePlus, X, Loader2, AlertTriangle, Plus, CopyCheck } from "lucide-react"; import { Trash2, ImagePlus, X, Loader2, AlertTriangle, Plus } from "lucide-react";
import Image from "next/image"; import Image from "next/image";
import DeleteDialog from "@/src/components/layout/DeleteDialog"; import DeleteDialog from "@/src/components/layout/DeleteDialog";
import { import {
@ -129,8 +129,10 @@ const CourseCreateView = ({
isEdit && initialData!.weekdayTimings isEdit && initialData!.weekdayTimings
? (initialData!.weekdayTimings as Record<string, WeekdaySlot | WeekdaySlot[]>) ? (initialData!.weekdayTimings as Record<string, WeekdaySlot | WeekdaySlot[]>)
: undefined, : undefined,
// Neu ausgewählte Tage mit der allgemeinen Kurs-Uhrzeit oben vorbefüllen.
defaultSlot: { timeFrom, timeTo },
}); });
const { selectedWeekdays, weekdayTimings, defaultSlot, isDefaultSlotFilled } = weekdays; const { selectedWeekdays, weekdayTimings } = weekdays;
const [endDate, setEndDate] = useState(() => { const [endDate, setEndDate] = useState(() => {
if (isEdit && initialData!.endDate) { if (isEdit && initialData!.endDate) {
const d = new Date(initialData!.endDate); const d = new Date(initialData!.endDate);
@ -745,102 +747,67 @@ const CourseCreateView = ({
/> />
{selectedWeekdays.length > 0 && ( {selectedWeekdays.length > 0 && (
<div className="space-y-4"> <div className="space-y-3">
{/* Standard-Zeitfenster mit "für alle übernehmen" */} <p className="text-sm font-medium">Uhrzeiten pro Tag</p>
<div className="rounded-lg border border-gray-200 bg-gray-50 p-3"> {selectedWeekdays.map((day) => {
<p className="text-sm font-medium mb-2">Standard-Zeitfenster</p> const dayLabel = WEEKDAYS.find((w) => w.value === day)?.label ?? day;
<div className="flex flex-wrap items-center gap-3"> const slots: WeekdaySlot[] = weekdayTimings[day]?.length
<InputComponent ? weekdayTimings[day]
type="time" : [{ timeFrom: "", timeTo: "" }];
id="weekday-default-time-from" return (
value={defaultSlot.timeFrom} <div key={day} className="flex gap-3">
onChange={(e) => weekdays.updateDefaultSlot({ timeFrom: e.target.value })} <span className="w-24 text-sm shrink-0 pt-2.5">{dayLabel}</span>
/> <div className="flex flex-col gap-2">
<span className="text-sm">–</span> {slots.map((slot, index) => (
<InputComponent <div key={index} className="flex items-center gap-3">
type="time" <InputComponent
id="weekday-default-time-to" type="time"
value={defaultSlot.timeTo} id={`weekday-time-from-${day}-${index}`}
onChange={(e) => weekdays.updateDefaultSlot({ timeTo: e.target.value })} value={slot.timeFrom}
/> onChange={(e) =>
<button weekdays.updateSlot(day, index, {
type="button" timeFrom: e.target.value,
onClick={weekdays.applyDefaultToAll} })
disabled={!isDefaultSlotFilled} }
className="inline-flex items-center gap-1.5 rounded-md border border-gray-300 bg-white px-2.5 py-1.5 text-sm text-gray-700 hover:bg-gray-100 disabled:opacity-40 disabled:cursor-not-allowed" />
> <span className="text-sm">–</span>
<CopyCheck size={14} /> <InputComponent
Für alle Tage übernehmen type="time"
</button> id={`weekday-time-to-${day}-${index}`}
</div> value={slot.timeTo}
<p className="text-xs text-gray-500 mt-2"> onChange={(e) =>
Wird automatisch für alle Tage gesetzt, solange du keine Uhrzeit einzeln weekdays.updateSlot(day, index, {
anpasst. timeTo: e.target.value,
</p> })
</div> }
/>
<div className="space-y-3"> {slots.length > 1 && (
<p className="text-sm font-medium">Uhrzeiten pro Tag</p> <button
{selectedWeekdays.map((day) => { type="button"
const dayLabel = WEEKDAYS.find((w) => w.value === day)?.label ?? day; onClick={() => weekdays.removeSlot(day, index)}
const slots: WeekdaySlot[] = weekdayTimings[day]?.length aria-label="Zeitfenster entfernen"
? weekdayTimings[day] className="text-gray-400 hover:text-red-500"
: [{ timeFrom: "", timeTo: "" }]; >
return ( <X size={16} />
<div key={day} className="flex gap-3"> </button>
<span className="w-24 text-sm shrink-0 pt-2.5">{dayLabel}</span> )}
<div className="flex flex-col gap-2"> </div>
{slots.map((slot, index) => ( ))}
<div key={index} className="flex items-center gap-3"> <button
<InputComponent type="button"
type="time" onClick={() => weekdays.addSlot(day)}
id={`weekday-time-from-${day}-${index}`} className="inline-flex w-fit items-center gap-1 text-sm text-blue hover:underline"
value={slot.timeFrom} >
onChange={(e) => <Plus size={14} />
weekdays.updateSlot(day, index, { Weiteres Zeitfenster
timeFrom: e.target.value, </button>
})
}
/>
<span className="text-sm">–</span>
<InputComponent
type="time"
id={`weekday-time-to-${day}-${index}`}
value={slot.timeTo}
onChange={(e) =>
weekdays.updateSlot(day, index, {
timeTo: e.target.value,
})
}
/>
{slots.length > 1 && (
<button
type="button"
onClick={() => weekdays.removeSlot(day, index)}
aria-label="Zeitfenster entfernen"
className="text-gray-400 hover:text-red-500"
>
<X size={16} />
</button>
)}
</div>
))}
<button
type="button"
onClick={() => weekdays.addSlot(day)}
className="inline-flex w-fit items-center gap-1 text-sm text-blue hover:underline"
>
<Plus size={14} />
Weiteres Zeitfenster
</button>
</div>
</div> </div>
); </div>
})} );
{errors.weekdayTimings && ( })}
<p className="text-red-500 text-sm">{errors.weekdayTimings}</p> {errors.weekdayTimings && (
)} <p className="text-red-500 text-sm">{errors.weekdayTimings}</p>
</div> )}
</div> </div>
)} )}
</> </>

View file

@ -9,10 +9,10 @@ import { Input } from "@/src/components/ui/input";
import { Progress } from "@/src/components/ui/progress"; import { Progress } from "@/src/components/ui/progress";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/src/components/ui/tabs"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/src/components/ui/tabs";
import { downsizeImage } from "@/src/lib/image/downsize"; import { downsizeImage } from "@/src/lib/image/downsize";
import { squareLogo } from "@/src/lib/image/square-logo";
import { getContrastTextColor } from "../../lib/contrast"; import { getContrastTextColor } from "../../lib/contrast";
import type { BusinessBranding } from "../../schemas/business-branding-schema"; import type { BusinessBranding } from "../../schemas/business-branding-schema";
import ColorPickerField from "./ColorPickerField"; import ColorPickerField from "./ColorPickerField";
import LogoCropDialog from "./LogoCropDialog";
import EmailBrandingPreview from "./branding-previews/EmailBrandingPreview"; import EmailBrandingPreview from "./branding-previews/EmailBrandingPreview";
import PublicPageBrandingPreview from "./branding-previews/PublicPageBrandingPreview"; import PublicPageBrandingPreview from "./branding-previews/PublicPageBrandingPreview";
import InvoiceBrandingPreview from "./branding-previews/InvoiceBrandingPreview"; import InvoiceBrandingPreview from "./branding-previews/InvoiceBrandingPreview";
@ -37,7 +37,6 @@ const BusinessBrandingEditor = ({
const [accentColor, setAccentColor] = useState(initialBranding.accentColor); const [accentColor, setAccentColor] = useState(initialBranding.accentColor);
const [isUploadingLogo, setIsUploadingLogo] = useState(false); const [isUploadingLogo, setIsUploadingLogo] = useState(false);
const [uploadPhase, setUploadPhase] = useState<"optimizing" | "uploading" | null>(null); const [uploadPhase, setUploadPhase] = useState<"optimizing" | "uploading" | null>(null);
const [pendingCropFile, setPendingCropFile] = useState<File | null>(null);
const accentTextColor = getContrastTextColor(accentColor); const accentTextColor = getContrastTextColor(accentColor);
@ -45,7 +44,10 @@ const BusinessBrandingEditor = ({
setIsUploadingLogo(true); setIsUploadingLogo(true);
setUploadPhase("optimizing"); setUploadPhase("optimizing");
try { try {
const resizedFile = await downsizeImage(file); // Automatisch quadratisch einpassen (contain), dann komprimieren –
// kein manuelles Zoom/Pan mehr.
const squared = await squareLogo(file);
const resizedFile = await downsizeImage(squared);
setUploadPhase("uploading"); setUploadPhase("uploading");
const formData = new FormData(); const formData = new FormData();
formData.append("file", resizedFile); formData.append("file", resizedFile);
@ -68,12 +70,7 @@ const BusinessBrandingEditor = ({
// gewählt werden kann (sonst feuert onChange beim zweiten Mal nicht). // gewählt werden kann (sonst feuert onChange beim zweiten Mal nicht).
e.target.value = ""; e.target.value = "";
if (!file) return; if (!file) return;
setPendingCropFile(file); void uploadLogo(file);
};
const handleCropConfirm = (croppedFile: File) => {
setPendingCropFile(null);
uploadLogo(croppedFile);
}; };
const handleSave = async () => { const handleSave = async () => {
@ -160,7 +157,9 @@ const BusinessBrandingEditor = ({
</label> </label>
)} )}
</div> </div>
<p className="text-xs text-muted-foreground">Max. 5 MB, danach zuschneidbar.</p> <p className="text-xs text-muted-foreground">
Max. 5 MB. Wird automatisch quadratisch eingepasst.
</p>
</div> </div>
{/* Farben */} {/* Farben */}
@ -230,12 +229,6 @@ const BusinessBrandingEditor = ({
</TabsContent> </TabsContent>
</Tabs> </Tabs>
</div> </div>
<LogoCropDialog
file={pendingCropFile}
onCancel={() => setPendingCropFile(null)}
onConfirm={handleCropConfirm}
/>
</div> </div>
); );
}; };

View file

@ -1,201 +0,0 @@
"use client";
import { useCallback, useEffect, useRef, useState } from "react";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/src/components/ui/dialog";
import { Button } from "@/src/components/ui/button";
import { Slider } from "@/src/components/ui/slider";
const CANVAS_SIZE = 320;
const OUTPUT_SIZE = 512;
interface LogoCropDialogProps {
file: File | null;
onCancel: () => void;
onConfirm: (croppedFile: File) => void;
}
/** Größe, in der das Bild bei gegebenem Zoom gezeichnet wird (füllt CANVAS_SIZE mindestens aus). */
function getScaledSize(image: HTMLImageElement, zoom: number): { width: number; height: number } {
const baseScale = Math.max(CANVAS_SIZE / image.width, CANVAS_SIZE / image.height);
const scale = baseScale * zoom;
return { width: image.width * scale, height: image.height * scale };
}
/**
* Begrenzt den Offset auf den Bereich, in dem das skalierte Bild den
* quadratischen Canvas noch vollständig ausfüllt - ohne diese Klemmung könnte
* ein Drag oder ein Zoom-Wechsel das Bild aus dem sichtbaren Bereich schieben
* (grauer Freiraum in der Vorschau, transparente Fläche statt Logo im Export).
*/
function clampOffset(
offset: { x: number; y: number },
drawWidth: number,
drawHeight: number
): { x: number; y: number } {
const maxOffsetX = Math.max(0, (drawWidth - CANVAS_SIZE) / 2);
const maxOffsetY = Math.max(0, (drawHeight - CANVAS_SIZE) / 2);
return {
x: Math.min(maxOffsetX, Math.max(-maxOffsetX, offset.x)),
y: Math.min(maxOffsetY, Math.max(-maxOffsetY, offset.y)),
};
}
/**
* Einfacher Zoom/Pan-Crop direkt per Canvas, ohne zusätzliche Dependency -
* analog zum bestehenden Canvas-Ansatz in downsizeImage. Schneidet immer
* quadratisch zu (Logos werden überall als Quadrat/Kreis dargestellt: Header,
* Rechnung, öffentliche Seite), damit kein gestrecktes oder schief
* zugeschnittenes Logo entstehen kann.
*/
const LogoCropDialog = ({ file, onCancel, onConfirm }: LogoCropDialogProps) => {
const canvasRef = useRef<HTMLCanvasElement>(null);
const imageRef = useRef<HTMLImageElement | null>(null);
const [zoom, setZoom] = useState(1);
const [offset, setOffset] = useState({ x: 0, y: 0 });
const dragState = useRef<{
startX: number;
startY: number;
origin: { x: number; y: number };
} | null>(null);
const draw = useCallback(
(currentZoom: number, currentOffset: { x: number; y: number }, img?: HTMLImageElement) => {
const canvas = canvasRef.current;
const image = img ?? imageRef.current;
if (!canvas || !image) return;
const ctx = canvas.getContext("2d");
if (!ctx) return;
canvas.width = CANVAS_SIZE;
canvas.height = CANVAS_SIZE;
ctx.clearRect(0, 0, CANVAS_SIZE, CANVAS_SIZE);
ctx.fillStyle = "#f5f5f5";
ctx.fillRect(0, 0, CANVAS_SIZE, CANVAS_SIZE);
const { width: drawWidth, height: drawHeight } = getScaledSize(image, currentZoom);
const clampedOffset = clampOffset(currentOffset, drawWidth, drawHeight);
const x = (CANVAS_SIZE - drawWidth) / 2 + clampedOffset.x;
const y = (CANVAS_SIZE - drawHeight) / 2 + clampedOffset.y;
ctx.drawImage(image, x, y, drawWidth, drawHeight);
},
[]
);
useEffect(() => {
if (!file) return;
const url = URL.createObjectURL(file);
const img = new window.Image();
img.onload = () => {
imageRef.current = img;
setZoom(1);
setOffset({ x: 0, y: 0 });
draw(1, { x: 0, y: 0 }, img);
};
img.src = url;
return () => URL.revokeObjectURL(url);
}, [file, draw]);
const handleZoomChange = ([value]: number[]) => {
const image = imageRef.current;
if (!image) return;
const { width, height } = getScaledSize(image, value);
const clamped = clampOffset(offset, width, height);
setZoom(value);
setOffset(clamped);
draw(value, clamped);
};
const handlePointerDown = (e: React.PointerEvent<HTMLCanvasElement>) => {
dragState.current = { startX: e.clientX, startY: e.clientY, origin: offset };
e.currentTarget.setPointerCapture(e.pointerId);
};
const handlePointerMove = (e: React.PointerEvent<HTMLCanvasElement>) => {
if (!dragState.current) return;
const image = imageRef.current;
if (!image) return;
const dx = e.clientX - dragState.current.startX;
const dy = e.clientY - dragState.current.startY;
const raw = { x: dragState.current.origin.x + dx, y: dragState.current.origin.y + dy };
const { width, height } = getScaledSize(image, zoom);
const next = clampOffset(raw, width, height);
setOffset(next);
draw(zoom, next);
};
const handlePointerUp = () => {
dragState.current = null;
};
const handleConfirm = () => {
const image = imageRef.current;
if (!image) return;
const outputCanvas = document.createElement("canvas");
outputCanvas.width = OUTPUT_SIZE;
outputCanvas.height = OUTPUT_SIZE;
const ctx = outputCanvas.getContext("2d");
if (!ctx) return;
const outputScale = OUTPUT_SIZE / CANVAS_SIZE;
const { width: drawWidth, height: drawHeight } = getScaledSize(image, zoom);
const clampedOffset = clampOffset(offset, drawWidth, drawHeight);
const x = (OUTPUT_SIZE - drawWidth * outputScale) / 2 + clampedOffset.x * outputScale;
const y = (OUTPUT_SIZE - drawHeight * outputScale) / 2 + clampedOffset.y * outputScale;
ctx.drawImage(image, x, y, drawWidth * outputScale, drawHeight * outputScale);
outputCanvas.toBlob((blob) => {
if (!blob) return;
onConfirm(new File([blob], "logo.png", { type: "image/png" }));
}, "image/png");
};
return (
<Dialog open={!!file} onOpenChange={(open) => !open && onCancel()}>
<DialogContent>
<DialogHeader>
<DialogTitle>Logo zuschneiden</DialogTitle>
<DialogDescription>
Ziehe das Bild, um den Ausschnitt zu verschieben, und nutze den Regler zum Zoomen.
</DialogDescription>
</DialogHeader>
<div className="flex justify-center">
<canvas
ref={canvasRef}
width={CANVAS_SIZE}
height={CANVAS_SIZE}
className="rounded-lg border border-input touch-none cursor-grab active:cursor-grabbing"
onPointerDown={handlePointerDown}
onPointerMove={handlePointerMove}
onPointerUp={handlePointerUp}
onPointerLeave={handlePointerUp}
/>
</div>
<div className="flex items-center gap-3 px-1">
<span className="text-xs text-muted-foreground shrink-0">Zoom</span>
<Slider min={1} max={3} step={0.05} value={[zoom]} onValueChange={handleZoomChange} />
</div>
<DialogFooter>
<Button variant="ghost" onClick={onCancel}>
Abbrechen
</Button>
<Button onClick={handleConfirm}>Übernehmen</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
};
export default LogoCropDialog;

View file

@ -31,6 +31,12 @@ export type ZahlungRow = {
courseName: string; courseName: string;
businessId: string; businessId: string;
businessName: string; businessName: string;
/**
* Betrag, den das Business tatsächlich behält, in Cents. Bei
* `partially_refunded` ist das der Nettobetrag (gezahlt − erstattet),
* sonst der voll gezahlte Betrag. Die Rechnung selbst bleibt davon
* unberührt (siehe invoice-actions.ts).
*/
amountPaid: number | null; amountPaid: number | null;
paymentStatus: string; paymentStatus: string;
invoiceNumber: string | null; invoiceNumber: string | null;
@ -68,6 +74,7 @@ export async function getZahlungen(
createdAt: true, createdAt: true,
paymentStatus: true, paymentStatus: true,
amountPaid: true, amountPaid: true,
refundedAmount: true,
invoiceNumber: true, invoiceNumber: true,
course: { course: {
select: { select: {
@ -89,6 +96,14 @@ export async function getZahlungen(
b.user?.name ?? b.user?.email ?? b.guestLead?.name ?? b.guestLead?.email ?? "–"; b.user?.name ?? b.user?.email ?? b.guestLead?.name ?? b.guestLead?.email ?? "–";
const customerEmail = b.user?.email ?? b.guestLead?.email ?? "–"; const customerEmail = b.user?.email ?? b.guestLead?.email ?? "–";
// Bei Teilerstattung zeigt die Übersicht den Betrag, den das Business
// behält (netto). Voll erstattete / stornierte Zeilen behalten ihren
// Rohbetrag – dort trägt das Status-Badge die Bedeutung.
const netAmount =
b.paymentStatus === "partially_refunded" && b.amountPaid !== null
? b.amountPaid - (b.refundedAmount ?? 0)
: b.amountPaid;
return { return {
id: b.id, id: b.id,
createdAt: b.createdAt.toISOString(), createdAt: b.createdAt.toISOString(),
@ -98,7 +113,7 @@ export async function getZahlungen(
courseName: b.course.name, courseName: b.course.name,
businessId: b.course.business?.id ?? "", businessId: b.course.business?.id ?? "",
businessName: b.course.business?.name ?? "–", businessName: b.course.business?.name ?? "–",
amountPaid: b.amountPaid, amountPaid: netAmount,
paymentStatus: b.paymentStatus, paymentStatus: b.paymentStatus,
invoiceNumber: b.invoiceNumber, invoiceNumber: b.invoiceNumber,
}; };

View file

@ -0,0 +1,129 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { POST } from "@/src/app/api/stripe/checkout/course/route";
vi.mock("@/src/lib/auth/getUser", () => ({
getUserData: vi.fn(),
isUser: vi.fn(),
}));
vi.mock("@/src/lib/auth/account-role-by-email", () => ({
isManagerOrEmployeeEmail: vi.fn(),
}));
vi.mock("@/src/lib/prisma", () => ({
prisma: {
course: { findUnique: vi.fn() },
courseBooking: { findUnique: vi.fn() },
},
}));
vi.mock("@/src/lib/stripe", () => ({
stripe: { checkout: { sessions: { create: vi.fn() } } },
calculatePlatformFeeCents: vi.fn().mockReturnValue(100),
eurosToCents: vi.fn().mockReturnValue(2900),
}));
import { getUserData, isUser } from "@/src/lib/auth/getUser";
import { isManagerOrEmployeeEmail } from "@/src/lib/auth/account-role-by-email";
import { prisma } from "@/src/lib/prisma";
import { stripe } from "@/src/lib/stripe";
let ipCounter = 0;
function makeRequest(body: Record<string, unknown>) {
// Jede Anfrage aus einer eigenen IP, damit der In-Memory-Rate-Limiter
// (10/Minute/IP) die Tests nicht gegenseitig beeinflusst.
ipCounter += 1;
return new Request("http://localhost/api/stripe/checkout/course", {
method: "POST",
headers: {
"Content-Type": "application/json",
"x-forwarded-for": `10.0.0.${ipCounter}`,
},
body: JSON.stringify(body),
}) as never;
}
const publishedCourse = {
id: "course-1",
name: "Yoga",
status: "published",
price: 29,
date: new Date("2099-01-01"),
timeFrom: "10:00",
maxParticipants: 20,
_count: { bookings: 0 },
business: {
id: "biz-1",
name: "Studio",
stripeAccountId: "acct_123",
stripeAccountStatus: "active",
},
};
beforeEach(() => {
vi.mocked(getUserData).mockReset();
vi.mocked(isUser).mockReset();
vi.mocked(isManagerOrEmployeeEmail).mockReset();
vi.mocked(prisma.course.findUnique).mockReset();
vi.mocked(prisma.courseBooking.findUnique).mockReset();
vi.mocked(stripe.checkout.sessions.create).mockReset();
vi.mocked(stripe.checkout.sessions.create).mockResolvedValue({
url: "https://checkout.stripe.com/session",
} as never);
vi.mocked(prisma.course.findUnique).mockResolvedValue(publishedCourse as never);
});
describe("POST /api/stripe/checkout/course – Gast mit Team-Account-E-Mail (Ticket 6)", () => {
it("lehnt eine Gast-Buchung mit Manager/Employee-E-Mail mit 403 ab, ohne Stripe-Session", async () => {
vi.mocked(getUserData).mockResolvedValue(null as never);
vi.mocked(isManagerOrEmployeeEmail).mockResolvedValue(true);
const res = await POST(makeRequest({ courseId: "course-1", guestEmail: "Manager@Studio.de" }));
expect(res.status).toBe(403);
const data = await res.json();
expect(data.error).toMatch(/Team-Account/i);
expect(isManagerOrEmployeeEmail).toHaveBeenCalledWith("manager@studio.de");
expect(stripe.checkout.sessions.create).not.toHaveBeenCalled();
});
it("lässt eine normale Gast-Buchung durch, wenn die E-Mail zu keinem Team-Account gehört", async () => {
vi.mocked(getUserData).mockResolvedValue(null as never);
vi.mocked(isManagerOrEmployeeEmail).mockResolvedValue(false);
vi.mocked(prisma.courseBooking.findUnique).mockResolvedValue(null as never);
const res = await POST(makeRequest({ courseId: "course-1", guestEmail: "gast@example.com" }));
expect(res.status).toBe(200);
const data = await res.json();
expect(data.url).toBe("https://checkout.stripe.com/session");
expect(stripe.checkout.sessions.create).toHaveBeenCalledOnce();
});
it("prüft die Team-Account-E-Mail nicht für eingeloggte Kunden-Accounts", async () => {
vi.mocked(getUserData).mockResolvedValue({
id: "user-1",
email: "kunde@example.com",
} as never);
vi.mocked(isUser).mockReturnValue(true);
vi.mocked(prisma.courseBooking.findUnique).mockResolvedValue(null as never);
const res = await POST(makeRequest({ courseId: "course-1" }));
expect(res.status).toBe(200);
expect(isManagerOrEmployeeEmail).not.toHaveBeenCalled();
});
it("weist eine ungültige Gast-E-Mail weiterhin vor der Team-Account-Prüfung mit 400 ab", async () => {
vi.mocked(getUserData).mockResolvedValue(null as never);
const res = await POST(makeRequest({ courseId: "course-1", guestEmail: "keine-email" }));
expect(res.status).toBe(400);
const data = await res.json();
expect(data.isGuestRequired).toBe(true);
expect(isManagerOrEmployeeEmail).not.toHaveBeenCalled();
});
});