From 25ac296917f6a0a3a29309621a9682149720d979 Mon Sep 17 00:00:00 2001 From: Rajpreet0 Date: Mon, 17 Aug 2026 15:32:22 +0200 Subject: [PATCH] fixed a couple of bugs --- src/app/(main)/courses/page.tsx | 8 +- src/app/(main)/layout.tsx | 2 +- src/app/api/auth/register/admin/route.ts | 37 +++++ src/app/api/auth/register/employee/route.ts | 77 ++-------- src/app/api/auth/register/route.ts | 35 ++++- src/app/api/stripe/webhook/route.ts | 137 +++++++++++++++--- src/lib/auth/employee-auth-user.ts | 95 ++++++++++++ src/lib/geocoding/verify-address.ts | 66 +++++++++ .../auth/ui/views/employee-onboaring-view.tsx | 4 +- src/modules/courses/actions/course-actions.ts | 110 +++++++++++++- .../courses/ui/components/CourseListItem.tsx | 72 ++++----- .../ui/components/PublicCourseCard.tsx | 55 ++++--- .../courses/ui/views/course-create-view.tsx | 114 ++++++++++++++- .../courses/ui/views/course-list-view.tsx | 35 +++++ .../courses/utils/generate-instances.ts | 13 +- .../ui/components/DashboardSidebar.tsx | 16 +- .../employee/actions/employee-actions.ts | 41 +++--- src/modules/rooms/actions/room-actions.ts | 8 +- .../actions/business-settings-actions.ts | 29 +++- .../actions/personal-settings-actions.ts | 30 +++- .../ui/components/BusinessSettingsForm.tsx | 5 +- 21 files changed, 779 insertions(+), 210 deletions(-) create mode 100644 src/lib/auth/employee-auth-user.ts create mode 100644 src/lib/geocoding/verify-address.ts diff --git a/src/app/(main)/courses/page.tsx b/src/app/(main)/courses/page.tsx index fb9f597..b0a1719 100644 --- a/src/app/(main)/courses/page.tsx +++ b/src/app/(main)/courses/page.tsx @@ -19,11 +19,11 @@ const Course = async () => { today.setHours(0, 0, 0, 0); const twoWeeksAgo = new Date(today); twoWeeksAgo.setDate(today.getDate() - 14); - const sixWeeksLater = new Date(today); - sixWeeksLater.setDate(today.getDate() + 42); + const twoWeeksLater = new Date(today); + twoWeeksLater.setDate(today.getDate() + 14); const [coursesResult, roomsResult, trainersResult] = await Promise.all([ - getMyCourses({ dateFrom: twoWeeksAgo, dateTo: sixWeeksLater }), + getMyCourses({ dateFrom: twoWeeksAgo, dateTo: twoWeeksLater }), getMyRooms(), getMyTrainers(), ]); @@ -49,7 +49,7 @@ const Course = async () => { {
- {children} +
{children}
); diff --git a/src/app/api/auth/register/admin/route.ts b/src/app/api/auth/register/admin/route.ts index aa7aca3..7eff555 100644 --- a/src/app/api/auth/register/admin/route.ts +++ b/src/app/api/auth/register/admin/route.ts @@ -2,6 +2,7 @@ import { createClient } from "@/src/lib/supabase/server"; import { prisma } from "@/src/lib/prisma"; import { NextResponse } from "next/server"; +import { verifyAddressExists } from "@/src/lib/geocoding/verify-address"; export async function POST(request: Request) { try { @@ -28,6 +29,42 @@ export async function POST(request: Request) { return NextResponse.json({ error: "Pflichtfelder fehlen" }, { status: 400 }); } + if (!companyStreet || !companyPLZ || !companyPlace) { + return NextResponse.json({ error: "Unternehmensadresse ist erforderlich" }, { status: 400 }); + } + + try { + const companyAddressExists = await verifyAddressExists( + companyStreet, + companyPLZ, + companyPlace + ); + if (!companyAddressExists) { + return NextResponse.json( + { + error: + "Die Unternehmensadresse konnte nicht gefunden werden. Bitte überprüfe deine Eingabe.", + }, + { status: 400 } + ); + } + + if (street && plz && city) { + const managerAddressExists = await verifyAddressExists(street, plz, city); + if (!managerAddressExists) { + return NextResponse.json( + { error: "Diese Adresse konnte nicht gefunden werden. Bitte überprüfe deine Eingabe." }, + { status: 400 } + ); + } + } + } catch { + return NextResponse.json( + { error: "Adresse konnte nicht überprüft werden. Bitte versuche es erneut." }, + { status: 503 } + ); + } + const existingUser = await prisma.user.findUnique({ where: { email }, }); diff --git a/src/app/api/auth/register/employee/route.ts b/src/app/api/auth/register/employee/route.ts index 9e2107e..0788af6 100644 --- a/src/app/api/auth/register/employee/route.ts +++ b/src/app/api/auth/register/employee/route.ts @@ -1,6 +1,6 @@ -import { createAdminClient } from "@/src/lib/supabase/admin"; import { NextResponse } from "next/server"; import { prisma } from "@/src/lib/prisma"; +import { createOrUpdateEmployeeAuthUser, PASSWORD_REGEX } from "@/src/lib/auth/employee-auth-user"; // Simple in-memory rate limiter: max 5 attempts per IP per 15 minutes const rateLimitMap = new Map(); @@ -22,8 +22,6 @@ function isRateLimited(ip: string): boolean { return false; } -const PASSWORD_REGEX = /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[^a-zA-Z\d]).{8,}$/; - export async function POST(request: Request) { try { const ip = @@ -70,75 +68,18 @@ export async function POST(request: Request) { ); } - // Der Mitarbeiter hat den Onboarding-Token bereits serverseitig bestätigt - // (Link aus der Onboarding-Mail), womit der Zugriff auf die E-Mail-Adresse - // schon nachgewiesen ist. Deshalb wird der Account per Admin-API direkt als - // bestätigt angelegt — eine zusätzliche Supabase-Bestätigungsmail (ohne - // Hinweis in der UI) würde den Mitarbeiter sonst am Login hindern. - const supabase = createAdminClient(); - - const { data, error } = await supabase.auth.admin.createUser({ + const result = await createOrUpdateEmployeeAuthUser({ email, password, - email_confirm: true, - user_metadata: { - firstName, - lastName, - tel, - gender, - qualification, - role: "employee", - }, + firstName, + lastName, + tel, + gender, + qualification, }); - if (error) { - // Ein Retry nach einem vorherigen Teilausfall (Auth-User wurde bereits - // angelegt, aber die anschließende `isOnboarded`-Markierung ist fehlgeschlagen) - // darf nicht an "email_exists" scheitern — sonst bleibt der Mitarbeiter - // dauerhaft in einem inkonsistenten Zustand hängen. Stattdessen wird der - // bereits existierende Auth-User wiederverwendet. - const isDuplicateEmail = - error.code === "email_exists" || /already been registered/i.test(error.message); - - if (!isDuplicateEmail) { - console.error("Supabase Auth Error:", error.message); - return NextResponse.json({ error: error.message }, { status: 400 }); - } - - const { data: existingUsers, error: listError } = await supabase.auth.admin.listUsers(); - const existingUser = existingUsers?.users.find((u) => u.email === email); - - if (listError || !existingUser) { - console.error("Supabase Auth Error:", error.message); - return NextResponse.json({ error: error.message }, { status: 400 }); - } - - // Passwort/Metadaten auf den zuletzt übermittelten Stand bringen, falls der - // Mitarbeiter das Formular nach dem vorherigen Teilausfall erneut mit - // geänderten Daten abgeschickt hat. - const { error: updateError } = await supabase.auth.admin.updateUserById(existingUser.id, { - password, - email_confirm: true, - user_metadata: { - firstName, - lastName, - tel, - gender, - qualification, - role: "employee", - }, - }); - - if (updateError) { - console.error("Supabase Auth Error:", updateError.message); - return NextResponse.json({ error: updateError.message }, { status: 400 }); - } - - return NextResponse.json({ success: true }, { status: 201 }); - } - - if (!data.user) { - return NextResponse.json({ error: "Account konnte nicht erstellt werden" }, { status: 500 }); + if (!result.success) { + return NextResponse.json({ error: result.error }, { status: result.status }); } return NextResponse.json({ success: true }, { status: 201 }); diff --git a/src/app/api/auth/register/route.ts b/src/app/api/auth/register/route.ts index 3d078fa..c22776e 100644 --- a/src/app/api/auth/register/route.ts +++ b/src/app/api/auth/register/route.ts @@ -2,18 +2,38 @@ import { createClient } from "@/src/lib/supabase/server"; import { prisma } from "@/src/lib/prisma"; import { NextResponse } from "next/server"; +import { verifyAddressExists } from "@/src/lib/geocoding/verify-address"; export async function POST(request: Request) { try { const body = await request.json(); - const { email, password, name, birthday, plz, city, street, houseNumber, gender } = body; + const { password, name, birthday, plz, city, street, houseNumber, gender } = body; + const email = typeof body.email === "string" ? body.email.trim().toLowerCase() : body.email; if (!email || !password || !name) { return NextResponse.json({ error: "Pflichtfelder fehlen" }, { status: 400 }); } - const existingManager = await prisma.manager.findUnique({ - where: { email }, + if (street && plz && city) { + try { + const addressExists = await verifyAddressExists(street, plz, city); + if (!addressExists) { + return NextResponse.json( + { error: "Diese Adresse konnte nicht gefunden werden. Bitte überprüfe deine Eingabe." }, + { status: 400 } + ); + } + } catch { + return NextResponse.json( + { error: "Adresse konnte nicht überprüft werden. Bitte versuche es erneut." }, + { status: 503 } + ); + } + } + + const existingManager = await prisma.manager.findFirst({ + where: { email: { equals: email, mode: "insensitive" } }, + select: { id: true }, }); if (existingManager) { @@ -23,8 +43,9 @@ export async function POST(request: Request) { ); } - const existingUser = await prisma.user.findUnique({ - where: { email }, + const existingUser = await prisma.user.findFirst({ + where: { email: { equals: email, mode: "insensitive" } }, + select: { id: true }, }); if (existingUser) { @@ -71,9 +92,11 @@ 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. + // case-insensitive, da GuestLead-E-Mails aus dem Checkout-Formular stammen und + // nicht zwangsläufig in derselben Schreibweise wie die Registrierungs-E-Mail vorliegen. try { const guestLeads = await prisma.guestLead.findMany({ - where: { email }, + where: { email: { equals: email, mode: "insensitive" } }, select: { id: true }, }); diff --git a/src/app/api/stripe/webhook/route.ts b/src/app/api/stripe/webhook/route.ts index d33a3fb..db50f66 100644 --- a/src/app/api/stripe/webhook/route.ts +++ b/src/app/api/stripe/webhook/route.ts @@ -8,6 +8,20 @@ import { sendMail } from "@/src/lib/mail/resend"; import { generateBookingConfirmationEmail } from "@/src/lib/mail/templates/booking-confirmation"; import { generateBookingRefundEmail } from "@/src/lib/mail/templates/booking-refund"; +/** + * Sucht einen registrierten User anhand der E-Mail, case-insensitive. + * Guest-Checkout-E-Mails werden immer lowercase normalisiert, Registrierungs- + * E-Mails ebenfalls (siehe /api/auth/register) — der insensitive-Vergleich ist + * ein zusätzliches Sicherheitsnetz gegen abweichende Schreibweise in älteren + * Datensätzen. + */ +async function findUserByEmailInsensitive(email: string) { + return prisma.user.findFirst({ + where: { email: { equals: email, mode: "insensitive" } }, + select: { id: true }, + }); +} + /** * Stripe Webhook Handler. * @@ -107,15 +121,31 @@ async function handleCheckoutCompleted(session: Stripe.Checkout.Session) { return; } - outcome = await runCapacityTransactionGuest({ - courseId, - guestEmail, - guestName: guestName || undefined, - session, - paymentIntentId, - amountPaidCents, - platformFeeCents, - }); + // Zwischen Checkout-Start (als Gast) und Webhook-Zustellung kann sich der + // Gast bereits mit derselben E-Mail registriert haben. In dem Fall die + // Buchung direkt dem neuen Account zuordnen statt einen verwaisten + // GuestLead-Eintrag anzulegen, der nie automatisch gemerged wird. + const registeredUser = await findUserByEmailInsensitive(guestEmail); + + outcome = registeredUser + ? await runCapacityTransactionUser({ + courseId, + userId: registeredUser.id, + session, + paymentIntentId, + amountPaidCents, + platformFeeCents, + treatDistinctSessionAsDuplicate: true, + }) + : 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 @@ -126,15 +156,26 @@ async function handleCheckoutCompleted(session: Stripe.Checkout.Session) { session.customer_details?.email || session.customer_email; if (fallbackEmail) { - outcome = await runCapacityTransactionGuest({ - courseId, - guestEmail: fallbackEmail, - guestName: session.metadata?.guestName || undefined, - session, - paymentIntentId, - amountPaidCents, - platformFeeCents, - }); + const registeredUser = await findUserByEmailInsensitive(fallbackEmail); + outcome = registeredUser + ? await runCapacityTransactionUser({ + courseId, + userId: registeredUser.id, + session, + paymentIntentId, + amountPaidCents, + platformFeeCents, + treatDistinctSessionAsDuplicate: true, + }) + : 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. " + @@ -225,6 +266,28 @@ async function handleCheckoutCompleted(session: Stripe.Checkout.Session) { } } + // Unabhängige Doppelzahlung für einen bereits bezahlten Kurs (siehe + // runCapacityTransactionUser/treatDistinctSessionAsDuplicate): es existiert + // KEINE eigene Buchungszeile für diese Session, also nichts, das per Update + // auf "refunded" gesetzt werden könnte — nur die Stripe-Zahlung selbst wird + // storniert. outcome.bookingId zeigt hier auf die andere, bereits bezahlte + // Buchung und darf nicht verändert werden. + let duplicateRefundSucceeded = false; + if (outcome.result === "duplicate_payment" && paymentIntentId) { + console.warn( + `Doppelzahlung erkannt für Kurs ${courseId} (Buchung ${outcome.bookingId} existiert bereits ` + + `mit einer anderen Session). Automatische Rückerstattung wird ausgelöst (PaymentIntent ${paymentIntentId}).` + ); + const refundResult = await refundStripePayment(paymentIntentId); + duplicateRefundSucceeded = refundResult.success; + if (!refundResult.success) { + console.error( + `Refund der Doppelzahlung fehlgeschlagen für Kurs ${courseId}: ${refundResult.error}. ` + + `Manuelle Erstattung im Stripe Dashboard nötig (PaymentIntent ${paymentIntentId}).` + ); + } + } + // E-Mail-Versand – darf den Webhook NICHT zum Crashen bringen. const recipientEmail = session.customer_email ?? session.customer_details?.email ?? null; if (recipientEmail) { @@ -232,6 +295,8 @@ async function handleCheckoutCompleted(session: Stripe.Checkout.Session) { await withTimeout(sendBookingConfirmation(recipientEmail, courseId, amountPaidCents), 2000); } else if (needsRefund && refundSucceeded) { await withTimeout(sendBookingRefund(recipientEmail, courseId, amountPaidCents), 2000); + } else if (outcome.result === "duplicate_payment" && duplicateRefundSucceeded) { + await withTimeout(sendBookingRefund(recipientEmail, courseId, amountPaidCents), 2000); } } @@ -258,7 +323,8 @@ type CapacityOutcome = { | "already_refunded" | "already_failed" | "already_processed" - | "course_not_found"; + | "course_not_found" + | "duplicate_payment"; bookingId?: string; }; @@ -279,6 +345,13 @@ const ALREADY_PROCESSED_STATUSES = [ /** * Atomare Kapazitätsprüfung für authentifizierte User. + * + * @param treatDistinctSessionAsDuplicate - Wenn true: eine bereits "paid" + * Buchung für courseId+userId aus einer ANDEREN Session gilt als unabhängige + * Doppelzahlung statt per upsert überschrieben zu werden. Wird vom Gast→User- + * Merge-Pfad gesetzt (verspäteter Gast-Webhook nach zwischenzeitlicher + * Registrierung + erneuter Buchung) — im normalen eingeloggten Flow bleibt + * das bisherige upsert-Verhalten unverändert. */ async function runCapacityTransactionUser(params: { courseId: string; @@ -287,8 +360,17 @@ async function runCapacityTransactionUser(params: { paymentIntentId: string | null; amountPaidCents: number; platformFeeCents: number; + treatDistinctSessionAsDuplicate?: boolean; }): Promise { - const { courseId, userId, session, paymentIntentId, amountPaidCents, platformFeeCents } = params; + const { + courseId, + userId, + session, + paymentIntentId, + amountPaidCents, + platformFeeCents, + treatDistinctSessionAsDuplicate = false, + } = params; return prisma.$transaction( async (tx) => { @@ -312,6 +394,21 @@ async function runCapacityTransactionUser(params: { return { result: "already_processed" as const, bookingId: existing.id }; } + // Eine andere Session hat den Kurs für diesen User bereits erfolgreich + // bezahlt (typisch: verspäteter Gast-Webhook, nachdem der Gast sich + // registriert und den Kurs erneut selbst gebucht hat). Die aktuelle + // Zahlung ist dann eine unabhängige Doppelzahlung, kein Retry derselben + // Session — sie darf die bestehende, bereits bezahlte Buchung nicht per + // upsert überschreiben, sonst geht deren stripeSessionId/amountPaid verloren. + if ( + treatDistinctSessionAsDuplicate && + existing && + existing.stripeSessionId !== session.id && + existing.paymentStatus === "paid" + ) { + return { result: "duplicate_payment" as const, bookingId: existing.id }; + } + const course = await tx.course.findUnique({ where: { id: courseId }, select: { maxParticipants: true }, diff --git a/src/lib/auth/employee-auth-user.ts b/src/lib/auth/employee-auth-user.ts new file mode 100644 index 0000000..e3b3ef9 --- /dev/null +++ b/src/lib/auth/employee-auth-user.ts @@ -0,0 +1,95 @@ +import { createAdminClient } from "@/src/lib/supabase/admin"; + +export const PASSWORD_REGEX = /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[^a-zA-Z\d]).{8,}$/; + +/** + * Creates (or, on a duplicate-email retry, updates) the Supabase Auth user for + * a newly onboarded employee. Shared by the register-employee API route and by + * `completeEmployeeOnboarding`, which calls this directly instead of over HTTP + * — a self-fetch to the API route would otherwise be blocked by Vercel + * Deployment Protection on protected preview/staging deployments. + * + * Der Mitarbeiter hat den Onboarding-Token bereits serverseitig bestätigt + * (Link aus der Onboarding-Mail), womit der Zugriff auf die E-Mail-Adresse + * schon nachgewiesen ist. Deshalb wird der Account per Admin-API direkt als + * bestätigt angelegt — eine zusätzliche Supabase-Bestätigungsmail (ohne + * Hinweis in der UI) würde den Mitarbeiter sonst am Login hindern. + */ +export async function createOrUpdateEmployeeAuthUser(input: { + email: string; + password: string; + firstName: string; + lastName: string; + tel?: string; + gender?: string; + qualification?: string; +}): Promise<{ success: true } | { success: false; error: string; status: number }> { + const { email, password, firstName, lastName, tel, gender, qualification } = input; + const supabase = createAdminClient(); + + const { data, error } = await supabase.auth.admin.createUser({ + email, + password, + email_confirm: true, + user_metadata: { + firstName, + lastName, + tel, + gender, + qualification, + role: "employee", + }, + }); + + if (error) { + // Ein Retry nach einem vorherigen Teilausfall (Auth-User wurde bereits + // angelegt, aber die anschließende `isOnboarded`-Markierung ist fehlgeschlagen) + // darf nicht an "email_exists" scheitern — sonst bleibt der Mitarbeiter + // dauerhaft in einem inkonsistenten Zustand hängen. Stattdessen wird der + // bereits existierende Auth-User wiederverwendet. + const isDuplicateEmail = + error.code === "email_exists" || /already been registered/i.test(error.message); + + if (!isDuplicateEmail) { + console.error("Supabase Auth Error:", error.message); + return { success: false, error: error.message, status: 400 }; + } + + const { data: existingUsers, error: listError } = await supabase.auth.admin.listUsers(); + const existingUser = existingUsers?.users.find((u) => u.email === email); + + if (listError || !existingUser) { + console.error("Supabase Auth Error:", error.message); + return { success: false, error: error.message, status: 400 }; + } + + // Passwort/Metadaten auf den zuletzt übermittelten Stand bringen, falls der + // Mitarbeiter das Formular nach dem vorherigen Teilausfall erneut mit + // geänderten Daten abgeschickt hat. + const { error: updateError } = await supabase.auth.admin.updateUserById(existingUser.id, { + password, + email_confirm: true, + user_metadata: { + firstName, + lastName, + tel, + gender, + qualification, + role: "employee", + }, + }); + + if (updateError) { + console.error("Supabase Auth Error:", updateError.message); + return { success: false, error: updateError.message, status: 400 }; + } + + return { success: true }; + } + + if (!data.user) { + return { success: false, error: "Account konnte nicht erstellt werden", status: 500 }; + } + + return { success: true }; +} diff --git a/src/lib/geocoding/verify-address.ts b/src/lib/geocoding/verify-address.ts new file mode 100644 index 0000000..6d194f7 --- /dev/null +++ b/src/lib/geocoding/verify-address.ts @@ -0,0 +1,66 @@ +interface NominatimResult { + address: { + road?: string; + postcode?: string; + city?: string; + town?: string; + village?: string; + municipality?: string; + }; +} + +/** + * Verifies that a street/postcode/city combination corresponds to a real, + * geocodable address in Germany, using the same Nominatim (OpenStreetMap) + * service already used for the client-side autocomplete suggestions. + * + * Client-side autocomplete only offers suggestions — it never blocks + * submission of hand-typed text, so this server-side check is what actually + * enforces "the address must exist" before it's persisted. + */ +export async function verifyAddressExists( + street: string, + plz: string, + city: string +): Promise { + const query = `${street}, ${plz} ${city}`.trim(); + if (!street.trim() || !plz.trim() || !city.trim()) return false; + + try { + const params = new URLSearchParams({ + q: query, + format: "json", + addressdetails: "1", + limit: "5", + countrycodes: "de", + }); + const res = await fetch(`https://nominatim.openstreetmap.org/search?${params}`, { + headers: { + "Accept-Language": "de", + // Nominatim's usage policy requires a descriptive User-Agent identifying the app. + "User-Agent": "s3-kuma-app/1.0 (address verification)", + }, + }); + + if (!res.ok) { + // Treat provider errors/rate-limits as "cannot verify" rather than + // "invalid" — fail open on our side would be wrong, but blaming the + // user for a Nominatim outage would be worse than a clear retry message. + throw new Error(`Nominatim responded with ${res.status}`); + } + + const results: NominatimResult[] = await res.json(); + const normalizedPlz = plz.trim(); + + return results.some((r) => { + const addr = r.address; + if (!addr.road) return false; + if (addr.postcode && addr.postcode !== normalizedPlz) return false; + const resultCity = addr.city ?? addr.town ?? addr.village ?? addr.municipality ?? ""; + return resultCity.length > 0; + }); + } catch (error) { + console.error("Address verification failed:", error); + throw new Error("ADDRESS_VERIFICATION_UNAVAILABLE"); + } +} diff --git a/src/modules/auth/ui/views/employee-onboaring-view.tsx b/src/modules/auth/ui/views/employee-onboaring-view.tsx index d140674..83e4188 100644 --- a/src/modules/auth/ui/views/employee-onboaring-view.tsx +++ b/src/modules/auth/ui/views/employee-onboaring-view.tsx @@ -180,7 +180,9 @@ const EmployeeOnboardingView = () => { toast.success("Account erfolgreich erstellt! Du kannst dich jetzt einloggen."); router.push("/login"); } else { - toast.error(result.error || "Ein Fehler ist aufgetreten"); + const message = + typeof result.error === "string" ? result.error : "Ein Fehler ist aufgetreten"; + toast.error(message); } }; diff --git a/src/modules/courses/actions/course-actions.ts b/src/modules/courses/actions/course-actions.ts index 8dc24f5..bf965a6 100644 --- a/src/modules/courses/actions/course-actions.ts +++ b/src/modules/courses/actions/course-actions.ts @@ -14,7 +14,11 @@ import { courseSchema, publishedCourseSchema, type CourseFormData } from "../sch import { revalidatePath } from "next/cache"; import { Prisma } from "@/src/generated/prisma/client"; import { generateUniqueSlug } from "@/src/lib/slug"; -import { generateOccurrences, type FrequencyType } from "../utils/generate-instances"; +import { + generateOccurrences, + TooManyInstancesError, + type FrequencyType, +} from "../utils/generate-instances"; type StandingOrderScope = "this" | "this_and_following" | "all"; @@ -179,6 +183,9 @@ export async function createCourse(data: CourseFormData, status: "draft" | "publ : "Entwurf erfolgreich gespeichert", }; } catch (error) { + if (error instanceof TooManyInstancesError) { + return { success: false, error: error.message }; + } console.error("Error creating course:", error); return { success: false, @@ -388,7 +395,8 @@ export async function updateCourse( courseId: string, data: CourseFormData, status: "draft" | "published", - scope: StandingOrderScope = "this" + scope: StandingOrderScope = "this", + confirmedStandingOrderConversion = false ) { try { // Schritt 1: Aktuellen Benutzer abrufen @@ -472,6 +480,101 @@ export async function updateCourse( const isTemplate = !existingCourse.parentCourseId; const templateId = isTemplate ? existingCourse.id : existingCourse.parentCourseId!; + // Bestehender Einzelkurs wird jetzt erstmals zum Dauerauftrag gemacht: Instanzen + // wurden für ihn noch nie generiert, unabhängig vom gewählten Scope (der + // Scope-Dialog erscheint für diesen Fall im UI gar nicht erst, da der Kurs vorher + // kein Dauerauftrag war). Also Template-Flags setzen und Instanzen erzeugen. + const becomesStandingOrder = + isTemplate && + !existingCourse.isStandingOrder && + validatedData.isStandingOrder && + validatedData.frequency && + validatedData.endDate; + + if (becomesStandingOrder && !confirmedStandingOrderConversion) { + const existingBookingsCount = await prisma.courseBooking.count({ + where: { courseId, paymentStatus: "paid" }, + }); + + if (existingBookingsCount > 0) { + return { + success: false, + requiresStandingOrderConfirmation: true, + existingBookingsCount, + error: + "Dieser Kurs hat bereits Buchungen. Beim Umwandeln in einen Dauerauftrag wird er zum Vorlagen-Termin und verschwindet aus der Kursliste — bestehende Buchungen bleiben erhalten, sind dann aber nur noch über den Kunden-Account einsehbar.", + }; + } + } + + if (becomesStandingOrder) { + const startDate = new Date(validatedData.date || existingCourse.date); + const endDate = new Date(validatedData.endDate!); + + await prisma.$transaction(async (tx) => { + await tx.course.update({ + where: { id: courseId }, + data: { + ...sharedUpdateFields, + date: startDate, + timeFrom: validatedData.timeFrom || "", + timeTo: validatedData.timeTo || "", + endDate, + isStandingOrder: true, + frequency: validatedData.frequency || null, + weekdays: validatedData.weekdays || [], + weekdayTimings: validatedData.weekdayTimings + ? (validatedData.weekdayTimings as Prisma.InputJsonValue) + : Prisma.JsonNull, + }, + }); + + const occurrences = generateOccurrences({ + startDate, + endDate, + frequency: validatedData.frequency as FrequencyType, + defaultTimeFrom: validatedData.timeFrom || "", + defaultTimeTo: validatedData.timeTo || "", + weekdays: validatedData.weekdays || [], + weekdayTimings: + (validatedData.weekdayTimings as Record< + string, + { timeFrom: string; timeTo: string } + >) ?? {}, + }); + + // Das Template selbst wird (wie bei createCourse) nicht als sichtbarer + // Termin gezählt (isStandingOrder: false wird beim Filtern der Kursliste + // vorausgesetzt) — alle Occurrences werden daher als eigene Instanzen angelegt. + if (occurrences.length > 0) { + await tx.course.createMany({ + data: occurrences.map((o) => ({ + ...sharedUpdateFields, + date: o.date, + timeFrom: o.timeFrom, + timeTo: o.timeTo, + endDate: null, + isStandingOrder: false, + parentCourseId: templateId, + createdBy: existingCourse.createdBy, + })), + }); + } + }); + + // Schritt 9: Next.js Cache für /courses Seite invalidieren + revalidatePath("/courses"); + revalidatePath(`/courses/edit/${courseId}`); + + return { + success: true, + message: + status === "published" + ? "Kurs erfolgreich aktualisiert und veröffentlicht" + : "Entwurf erfolgreich aktualisiert", + }; + } + // Schritt 8: Kurs aktualisieren je nach Scope if (scope === "this") { await prisma.course.update({ @@ -522,6 +625,9 @@ export async function updateCourse( : "Entwurf erfolgreich aktualisiert", }; } catch (error) { + if (error instanceof TooManyInstancesError) { + return { success: false, error: error.message }; + } console.error("Error updating course:", error); return { success: false, diff --git a/src/modules/courses/ui/components/CourseListItem.tsx b/src/modules/courses/ui/components/CourseListItem.tsx index 99c2a1c..c66408f 100644 --- a/src/modules/courses/ui/components/CourseListItem.tsx +++ b/src/modules/courses/ui/components/CourseListItem.tsx @@ -122,42 +122,46 @@ const CourseListItem: React.FC = ({ )}
-
- {trainers.map((trainerId, index) => { - const trainerInfo = trainersMap[trainerId]; - const displayName = trainerInfo?.label ?? "Ehemaliger Trainer"; - const avatarSrc = trainerInfo?.pbSrc; - return ( - -
- - {avatarSrc && } - {getInitials(displayName)} - -
-
- ); - })} - {/* Date & Time */} -
-

- {new Date(date).toLocaleDateString("de-DE", { - day: "2-digit", - month: "2-digit", - year: "numeric", - })} -

-

- {timeFrom} - {timeTo} -

-
+ {/* Date & Time */} +
+

+ {new Date(date).toLocaleDateString("de-DE", { + day: "2-digit", + month: "2-digit", + year: "numeric", + })} +

+

+ {timeFrom} - {timeTo} +

- {/* Course Name */} -

{courseName}

+ {/* Trainer(s) & Course Name */} +
+ {trainers.length > 0 && ( +
+ {trainers.map((trainerId, index) => { + const trainerInfo = trainersMap[trainerId]; + const displayName = trainerInfo?.label ?? "Ehemaliger Trainer"; + const avatarSrc = trainerInfo?.pbSrc; + return ( + +
+ + {avatarSrc && } + {getInitials(displayName)} + +
+
+ ); + })} +
+ )} +

{courseName}

+
{/* Participant Number */} diff --git a/src/modules/courses/ui/components/PublicCourseCard.tsx b/src/modules/courses/ui/components/PublicCourseCard.tsx index 19dac2f..87696c6 100644 --- a/src/modules/courses/ui/components/PublicCourseCard.tsx +++ b/src/modules/courses/ui/components/PublicCourseCard.tsx @@ -3,9 +3,9 @@ import { useState, useEffect, useSyncExternalStore } from "react"; import Image from "next/image"; import Link from "next/link"; -import { Clock, MapPin, Users, Mail, Phone, DoorOpen, Info } from "lucide-react"; +import { Clock, MapPin, Users, Mail, Phone, DoorOpen, Info, X } from "lucide-react"; import { Button } from "@/src/components/ui/button"; -import { Dialog, DialogContent, DialogTitle } from "@/src/components/ui/dialog"; +import { Dialog, DialogClose, DialogContent, DialogTitle } from "@/src/components/ui/dialog"; import { VisuallyHidden } from "@radix-ui/react-visually-hidden"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/src/components/ui/tooltip"; import DOMPurify from "dompurify"; @@ -247,11 +247,19 @@ export const BookingDialog = ({ return ( - + {course.name} + + + Schließen + + {/* Scroll container für den Inhalt – Footer bleibt sichtbar */}
{/* Cover image – auf Mobile dünner */} @@ -502,26 +510,27 @@ const PublicCourseCard = ({ course, business, defaultOpen = false }: PublicCours

- {course.trainerProfiles && course.trainerProfiles.length > 0 && ( -
- {course.trainerProfiles.map((trainer) => { - const fullName = - [trainer.firstName, trainer.lastName].filter(Boolean).join(" ") || "Trainer"; - return ( - - -
- -
-
- {fullName} -
- ); - })} -
- )} - -

{course.name}

+
+ {course.trainerProfiles && course.trainerProfiles.length > 0 && ( +
+ {course.trainerProfiles.map((trainer) => { + const fullName = + [trainer.firstName, trainer.lastName].filter(Boolean).join(" ") || "Trainer"; + return ( + + +
+ +
+
+ {fullName} +
+ ); + })} +
+ )} +

{course.name}

+
( null ); + const [standingOrderConfirmOpen, setStandingOrderConfirmOpen] = useState(false); + const [standingOrderConfirmInfo, setStandingOrderConfirmInfo] = useState<{ + existingBookingsCount: number; + message: string; + } | null>(null); const [price, setPrice] = useState(isEdit ? initialData!.price?.toString() || "" : ""); const [priceDisplay, setPriceDisplay] = useState(""); const [isPriceFocused, setIsPriceFocused] = useState(false); @@ -289,6 +308,24 @@ const CourseCreateView = ({ if (hasInvalidTiming) newErrors.weekdayTimings = "Bitte gültige Uhrzeiten für alle Wochentage eingeben"; } + if (!newErrors.endDate && selectedFrequency && courseDate && endDate) { + try { + generateOccurrences({ + startDate: new Date(courseDate), + endDate: new Date(endDate), + frequency: selectedFrequency as FrequencyType, + defaultTimeFrom: timeFrom, + defaultTimeTo: timeTo, + weekdays: selectedWeekdays, + weekdayTimings, + }); + } catch (err) { + newErrors.endDate = + err instanceof TooManyInstancesError + ? err.message + : "Ungültiger Zeitraum für den Dauerauftrag"; + } + } } setErrors(newErrors); @@ -323,7 +360,11 @@ const CourseCreateView = ({ businessId: selectedBusinessId || undefined, }); - const doSubmit = async (status: "draft" | "published", scope: StandingOrderScope = "this") => { + const doSubmit = async ( + status: "draft" | "published", + scope: StandingOrderScope = "this", + confirmedStandingOrderConversion = false + ) => { setIsSubmitting(true); try { const courseData = buildCourseData(); @@ -332,12 +373,25 @@ const CourseCreateView = ({ const result = mode === "edit" - ? await updateCourse(courseId!, courseData, status, scope) + ? await updateCourse( + courseId!, + courseData, + status, + scope, + confirmedStandingOrderConversion + ) : await createCourse(courseData, status); if (result.success) { toast.success(result.message); router.push("/courses"); + } else if ("requiresStandingOrderConfirmation" in result && courseId) { + setStandingOrderConfirmInfo({ + existingBookingsCount: result.existingBookingsCount ?? 0, + message: result.error || "", + }); + setPendingSubmitStatus(status); + setStandingOrderConfirmOpen(true); } else { toast.error(result.error || "Ein Fehler ist aufgetreten"); if (result.fieldErrors) { @@ -358,6 +412,15 @@ const CourseCreateView = ({ } }; + const handleStandingOrderConfirm = async () => { + setStandingOrderConfirmOpen(false); + if (pendingSubmitStatus) { + await doSubmit(pendingSubmitStatus, "this", true); + setPendingSubmitStatus(null); + } + setStandingOrderConfirmInfo(null); + }; + const handleSubmit = async (status: "draft" | "published") => { if (isUploadingImage) { toast.info("Bitte warten Sie, bis das Kursbild hochgeladen wurde."); @@ -791,6 +854,51 @@ const CourseCreateView = ({ onConfirm={handleEditScopeConfirm} action="edit" /> + + { + setStandingOrderConfirmOpen(open); + if (!open) { + setStandingOrderConfirmInfo(null); + setPendingSubmitStatus(null); + } + }} + > + + + + Kurs hat bereits Buchungen + + + {standingOrderConfirmInfo?.existingBookingsCount ?? 0}{" "} + {standingOrderConfirmInfo?.existingBookingsCount === 1 ? "Buchung" : "Buchungen"}{" "} + existieren für diesen Kurs. {standingOrderConfirmInfo?.message} + + + + + + + + + +
); }; diff --git a/src/modules/courses/ui/views/course-list-view.tsx b/src/modules/courses/ui/views/course-list-view.tsx index c6f4c41..93e2540 100644 --- a/src/modules/courses/ui/views/course-list-view.tsx +++ b/src/modules/courses/ui/views/course-list-view.tsx @@ -98,6 +98,9 @@ const CourseListView = ({ }, }); + const [isLoadingMore, setIsLoadingMore] = useState(false); + const LOAD_MORE_DAYS = 14; + const loadCourses = async (options: { dateFrom: Date; dateTo: Date }) => { const result = await getMyCourses(options); if (result.success) { @@ -107,6 +110,30 @@ const CourseListView = ({ } }; + // Lädt nur das neue Zeitfenster nach dem aktuell geladenen Enddatum und hängt + // die Kurse an — im Gegensatz zu loadCourses (Filter-Range-Erweiterung), das + // den kompletten Bereich neu lädt und ersetzt. + const handleLoadMore = async () => { + setIsLoadingMore(true); + const newDateFrom = new Date(loadedDateTo); + newDateFrom.setDate(newDateFrom.getDate() + 1); + const newDateTo = new Date(loadedDateTo); + newDateTo.setDate(newDateTo.getDate() + LOAD_MORE_DAYS); + + const result = await getMyCourses({ dateFrom: newDateFrom, dateTo: newDateTo }); + if (result.success) { + setCourses((prev) => { + const existingIds = new Set(prev.map((c) => c.id)); + const newOnes = result.courses.filter((c) => !existingIds.has(c.id)); + return [...prev, ...newOnes]; + }); + setLoadedDateTo(newDateTo); + } else { + toast.error(result.error || "Fehler beim Laden weiterer Kurse"); + } + setIsLoadingMore(false); + }; + // Extend loaded date range only when filter dates go outside the server-fetched range useEffect(() => { if (isLoading) return; @@ -444,6 +471,14 @@ const CourseListView = ({
)} + {!dateTo && courses.length > 0 && ( +
+ +
+ )} + { diff --git a/src/modules/courses/utils/generate-instances.ts b/src/modules/courses/utils/generate-instances.ts index c10d866..dcc9f10 100644 --- a/src/modules/courses/utils/generate-instances.ts +++ b/src/modules/courses/utils/generate-instances.ts @@ -34,6 +34,15 @@ const toWeekdayNumber = (weekday: string): number | undefined => export const MAX_INSTANCES = 365; +export class TooManyInstancesError extends Error { + constructor(maxInstances: number) { + super( + `Dauerauftrag überschreitet das Maximum von ${maxInstances} Instanzen. Bitte ein kürzeres Enddatum oder eine seltenere Häufigkeit wählen.` + ); + this.name = "TooManyInstancesError"; + } +} + export function generateOccurrences(params: { startDate: Date; endDate: Date; @@ -56,9 +65,7 @@ export function generateOccurrences(params: { const addOccurrence = (d: Date, timeFrom: string, timeTo: string) => { if (occurrences.length >= MAX_INSTANCES) { - throw new Error( - `Dauerauftrag überschreitet das Maximum von ${MAX_INSTANCES} Instanzen. Bitte ein kürzeres Enddatum wählen.` - ); + throw new TooManyInstancesError(MAX_INSTANCES); } occurrences.push({ date: new Date(d), timeFrom, timeTo }); }; diff --git a/src/modules/dashboard/ui/components/DashboardSidebar.tsx b/src/modules/dashboard/ui/components/DashboardSidebar.tsx index 270d364..d70feb1 100644 --- a/src/modules/dashboard/ui/components/DashboardSidebar.tsx +++ b/src/modules/dashboard/ui/components/DashboardSidebar.tsx @@ -10,15 +10,7 @@ import { SidebarMenuButton, SidebarMenuItem, } from "@/src/components/ui/sidebar"; -import { - ChartNoAxesCombined, - CreditCard, - DoorOpen, - Dumbbell, - IdCardLanyard, - LayoutDashboard, - User, -} from "lucide-react"; +import { CreditCard, DoorOpen, Dumbbell, IdCardLanyard, LayoutDashboard, User } from "lucide-react"; import Image from "next/image"; import Link from "next/link"; @@ -101,12 +93,6 @@ const allMenuItems: MenuItem[] = [ roles: ["employee"], permission: (p) => p.rooms.view, }, - { - icon: ChartNoAxesCombined, - label: "Marketing", - href: "/marketing", - roles: ["manager"], - }, ]; interface DashboardSidebarProps { diff --git a/src/modules/employee/actions/employee-actions.ts b/src/modules/employee/actions/employee-actions.ts index 399ccfe..dbfe166 100644 --- a/src/modules/employee/actions/employee-actions.ts +++ b/src/modules/employee/actions/employee-actions.ts @@ -22,6 +22,7 @@ import { randomBytes } from "crypto"; import { sendMail } from "@/src/lib/mail/resend"; import { generateOnboardingEmail } from "@/src/lib/mail/templates/employee-onboarding"; import { generateOnboardingCompleteEmail } from "@/src/lib/mail/templates/onboarding-complete"; +import { createOrUpdateEmployeeAuthUser, PASSWORD_REGEX } from "@/src/lib/auth/employee-auth-user"; /** * Generates a secure onboarding token @@ -813,27 +814,29 @@ export async function completeEmployeeOnboarding( return { success: false, error: "Invalid onboarding link" }; } - // Create the Supabase account via the API route - const baseUrl = process.env.NEXT_PUBLIC_APP_URL || "http://localhost:3000"; - const res = await fetch(`${baseUrl}/api/auth/register/employee`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - token, - email: employee.email, - password: data.password, - firstName: data.firstName, - lastName: data.lastName, - tel: data.tel, - gender: data.gender, - qualification: data.qualification, - }), + if (!PASSWORD_REGEX.test(data.password)) { + return { + success: false, + error: + "Passwort muss mindestens 8 Zeichen, Groß-/Kleinbuchstaben, eine Zahl und ein Sonderzeichen enthalten", + }; + } + + // Create the Supabase account directly (not via an HTTP self-fetch to the + // API route — that would be blocked by Vercel Deployment Protection on + // protected preview/staging deployments). + const authResult = await createOrUpdateEmployeeAuthUser({ + email: employee.email, + password: data.password, + firstName: data.firstName, + lastName: data.lastName, + tel: data.tel, + gender: data.gender, + qualification: data.qualification, }); - const result = await res.json(); - - if (!res.ok) { - return { success: false, error: result.error || "Registration failed" }; + if (!authResult.success) { + return { success: false, error: authResult.error }; } // Atomically mark the employee as onboarded — prevents TOCTOU race condition diff --git a/src/modules/rooms/actions/room-actions.ts b/src/modules/rooms/actions/room-actions.ts index 3e9241a..e6c2154 100644 --- a/src/modules/rooms/actions/room-actions.ts +++ b/src/modules/rooms/actions/room-actions.ts @@ -407,7 +407,13 @@ export async function deleteRoom(roomId: string) { }); if (futureCourses.length > 0) { - const courseNames = futureCourses.map((c) => c.name).join(", "); + const MAX_LISTED_COURSES = 3; + const listedNames = futureCourses.slice(0, MAX_LISTED_COURSES).map((c) => c.name); + const remainingCount = futureCourses.length - listedNames.length; + const courseNames = + remainingCount > 0 + ? `${listedNames.join(", ")}, +${remainingCount} weitere` + : listedNames.join(", "); return { success: false, error: `Raum kann nicht gelöscht werden, da er noch in ${futureCourses.length} zukünftigen Kursen verwendet wird: ${courseNames}`, diff --git a/src/modules/settings/actions/business-settings-actions.ts b/src/modules/settings/actions/business-settings-actions.ts index 7935624..6325d21 100644 --- a/src/modules/settings/actions/business-settings-actions.ts +++ b/src/modules/settings/actions/business-settings-actions.ts @@ -3,12 +3,15 @@ import { prisma } from "@/src/lib/prisma"; import { requireManager } from "@/src/lib/auth/getUser"; import { revalidateTag } from "next/cache"; +import { verifyAddressExists } from "@/src/lib/geocoding/verify-address"; type ActionResult = { success: boolean; error?: string }; export async function updateBusinessInfo(data: { businessId: string; - address: string; + street: string; + plz: string; + place: string; email: string; title: string; ustId?: string; @@ -27,8 +30,11 @@ export async function updateBusinessInfo(data: { } const email = data.email.trim(); + const street = data.street.trim(); + const plz = data.plz.trim(); + const place = data.place.trim(); - if (!data.address.trim() || !email || !data.title.trim()) { + if (!street || !plz || !place || !email || !data.title.trim()) { return { success: false, error: "Bitte alle Pflichtfelder ausfüllen." }; } @@ -36,10 +42,27 @@ export async function updateBusinessInfo(data: { return { success: false, error: "Ungültige E-Mail-Adresse." }; } + try { + const addressExists = await verifyAddressExists(street, plz, place); + if (!addressExists) { + return { + success: false, + error: "Diese Adresse konnte nicht gefunden werden. Bitte überprüfe deine Eingabe.", + }; + } + } catch { + return { + success: false, + error: "Adresse konnte nicht überprüft werden. Bitte versuche es erneut.", + }; + } + + const address = `${street}, ${plz} ${place}`; + const updated = await prisma.business.update({ where: { id: data.businessId }, data: { - address: data.address.trim(), + address, email, title: data.title.trim(), ustId: data.ustId?.trim() || null, diff --git a/src/modules/settings/actions/personal-settings-actions.ts b/src/modules/settings/actions/personal-settings-actions.ts index c6f0c94..754c3be 100644 --- a/src/modules/settings/actions/personal-settings-actions.ts +++ b/src/modules/settings/actions/personal-settings-actions.ts @@ -5,6 +5,7 @@ import { createClient } from "@/src/lib/supabase/server"; import { createAdminClient } from "@/src/lib/supabase/admin"; import { requireAuthWithData, isManager, isEmployee, isUser } from "@/src/lib/auth/getUser"; import { revalidateTag } from "next/cache"; +import { verifyAddressExists } from "@/src/lib/geocoding/verify-address"; type ActionResult = { success: boolean; error?: string }; @@ -69,13 +70,32 @@ export async function updateAddress(data: { return { success: false, error: "Adresse kann nur von regulären Nutzern geändert werden." }; } + const street = data.street.trim(); + const plz = data.plz.trim(); + const city = data.city.trim(); + + if (!street || !plz || !city) { + return { success: false, error: "Bitte Straße, PLZ und Wohnort ausfüllen." }; + } + + try { + const addressExists = await verifyAddressExists(street, plz, city); + if (!addressExists) { + return { + success: false, + error: "Diese Adresse konnte nicht gefunden werden. Bitte überprüfe deine Eingabe.", + }; + } + } catch { + return { + success: false, + error: "Adresse konnte nicht überprüft werden. Bitte versuche es erneut.", + }; + } + await prisma.user.update({ where: { id: userData.id }, - data: { - street: data.street, - plz: data.plz, - city: data.city, - }, + data: { street, plz, city }, }); revalidateUserCache(userData.email); diff --git a/src/modules/settings/ui/components/BusinessSettingsForm.tsx b/src/modules/settings/ui/components/BusinessSettingsForm.tsx index c79f024..37b0ab8 100644 --- a/src/modules/settings/ui/components/BusinessSettingsForm.tsx +++ b/src/modules/settings/ui/components/BusinessSettingsForm.tsx @@ -82,10 +82,11 @@ const BusinessSettingsForm = ({ } startTransition(async () => { try { - const address = `${street}, ${plz} ${place}`; const result = await updateBusinessInfo({ businessId, - address, + street, + plz, + place, email, title, ustId,