From 239d6700be3722f617d9caf2584b6a1e4c6d674e Mon Sep 17 00:00:00 2001 From: Rajpreet0 Date: Mon, 17 Aug 2026 20:47:16 +0200 Subject: [PATCH] see comments --- .../migration.sql | 24 +++++++++++ prisma/schema.prisma | 6 +++ scripts/check-duplicate-user-emails.sql | 23 ++++++++++ src/app/api/auth/register/admin/route.ts | 35 +++++++++------- src/app/api/auth/register/route.ts | 13 +++++- src/app/api/stripe/webhook/route.ts | 35 ++++++++++++++-- src/lib/auth/employee-auth-user.ts | 42 ++++++++++++++++++- src/lib/geocoding/verify-address.ts | 3 ++ src/lib/mail/templates/booking-refund.ts | 12 +++++- src/modules/courses/actions/course-actions.ts | 29 ++++++++++++- .../courses/ui/views/course-list-view.tsx | 32 +++++++------- .../employee/actions/employee-actions.ts | 14 +++++++ tests/api/auth/register-employee.test.ts | 4 +- 13 files changed, 231 insertions(+), 41 deletions(-) create mode 100644 prisma/migrations/20260817000000_case_insensitive_user_email/migration.sql create mode 100644 scripts/check-duplicate-user-emails.sql diff --git a/prisma/migrations/20260817000000_case_insensitive_user_email/migration.sql b/prisma/migrations/20260817000000_case_insensitive_user_email/migration.sql new file mode 100644 index 0000000..a321be0 --- /dev/null +++ b/prisma/migrations/20260817000000_case_insensitive_user_email/migration.sql @@ -0,0 +1,24 @@ +-- Case-insensitive uniqueness for User.email. +-- +-- The existing `email String @unique` constraint is case-sensitive at the +-- database level, so "Name@x.de" and "name@x.de" could coexist as two +-- distinct rows even though application code treats them as the same +-- address (registration normalizes new emails to lowercase and looks them +-- up with Prisma's `mode: "insensitive"`). Without a DB-level guarantee, +-- concurrent writes could still race past the application-level check and +-- create a case-duplicate, after which `findFirst` with an insensitive +-- filter would non-deterministically match either row. +-- +-- IMPORTANT: run scripts/check-duplicate-user-emails.sql against production +-- data BEFORE applying this migration. If it reports any duplicates, resolve +-- them manually (merge or rename) first — this migration will fail to apply +-- if any exist, since the unique index cannot be created over duplicates. + +-- Drop the old case-sensitive unique constraint (Prisma-managed). +ALTER TABLE "User" DROP CONSTRAINT IF EXISTS "User_email_key"; + +-- Case-insensitive unique index replaces it. Prisma's `email String @unique` +-- attribute in schema.prisma is intentionally left as-is (see comment there) +-- since Prisma has no first-class functional-index syntax; this migration +-- is the source of truth for the actual DB-level constraint. +CREATE UNIQUE INDEX "User_email_lower_key" ON "User" (LOWER("email")); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 64b17b4..5473b63 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -25,6 +25,12 @@ model User { city String? street String? houseNumber String? + // @unique is Prisma-managed and stays case-sensitive at the schema level — + // Prisma has no functional-index syntax. The actual enforced constraint is + // a case-insensitive unique index on lower(email), added via raw SQL in + // migration 20260817000000_case_insensitive_user_email. Do not remove + // @unique here; it keeps Prisma's drift detection from trying to recreate + // a duplicate case-sensitive constraint. email String @unique tel String? gender String? diff --git a/scripts/check-duplicate-user-emails.sql b/scripts/check-duplicate-user-emails.sql new file mode 100644 index 0000000..314a5e4 --- /dev/null +++ b/scripts/check-duplicate-user-emails.sql @@ -0,0 +1,23 @@ +-- Run this BEFORE applying migration 20260817000000_case_insensitive_user_email. +-- +-- Finds User rows whose emails are identical except for casing (e.g. +-- "Name@x.de" and "name@x.de"). The migration creates a unique index on +-- lower(email) and will fail to apply if any such duplicates exist. +-- +-- Usage: +-- npx prisma db execute --file scripts/check-duplicate-user-emails.sql --schema prisma/schema.prisma +-- or paste directly into psql / your DB console. +-- +-- If this returns any rows, resolve them manually first — e.g. decide which +-- account is canonical, merge or reassign its CourseBooking/UserTraining +-- rows to that account, then delete or rename the duplicate — before +-- applying the migration. + +SELECT + LOWER(email) AS normalized_email, + array_agg(id) AS user_ids, + array_agg(email) AS original_emails, + count(*) AS duplicate_count +FROM "User" +GROUP BY LOWER(email) +HAVING count(*) > 1; diff --git a/src/app/api/auth/register/admin/route.ts b/src/app/api/auth/register/admin/route.ts index 7eff555..47d2d63 100644 --- a/src/app/api/auth/register/admin/route.ts +++ b/src/app/api/auth/register/admin/route.ts @@ -7,22 +7,17 @@ import { verifyAddressExists } from "@/src/lib/geocoding/verify-address"; export async function POST(request: Request) { try { const body = await request.json(); - const { - email, - password, - firstName, - lastName, - tel, - plz, - city, - street, - companyName, - companyPlace, - companyPLZ, - companyStreet, - companyMail, - companyNumber, - } = body; + const { password, firstName, lastName, tel, companyName, companyMail, companyNumber } = body; + const email = typeof body.email === "string" ? body.email.trim().toLowerCase() : body.email; + const street = typeof body.street === "string" ? body.street.trim() : body.street; + const plz = typeof body.plz === "string" ? body.plz.trim() : body.plz; + const city = typeof body.city === "string" ? body.city.trim() : body.city; + const companyStreet = + typeof body.companyStreet === "string" ? body.companyStreet.trim() : body.companyStreet; + const companyPLZ = + typeof body.companyPLZ === "string" ? body.companyPLZ.trim() : body.companyPLZ; + const companyPlace = + typeof body.companyPlace === "string" ? body.companyPlace.trim() : body.companyPlace; // Validate required fields if (!email || !password || !firstName || !lastName || !companyName) { @@ -33,6 +28,14 @@ export async function POST(request: Request) { return NextResponse.json({ error: "Unternehmensadresse ist erforderlich" }, { status: 400 }); } + const managerAddressFieldsProvided = [street, plz, city].filter((v) => !!v).length; + if (managerAddressFieldsProvided > 0 && managerAddressFieldsProvided < 3) { + return NextResponse.json( + { error: "Bitte Straße, PLZ und Wohnort vollständig angeben." }, + { status: 400 } + ); + } + try { const companyAddressExists = await verifyAddressExists( companyStreet, diff --git a/src/app/api/auth/register/route.ts b/src/app/api/auth/register/route.ts index c22776e..891220f 100644 --- a/src/app/api/auth/register/route.ts +++ b/src/app/api/auth/register/route.ts @@ -7,13 +7,24 @@ import { verifyAddressExists } from "@/src/lib/geocoding/verify-address"; export async function POST(request: Request) { try { const body = await request.json(); - const { password, name, birthday, plz, city, street, houseNumber, gender } = body; + const { password, name, birthday, houseNumber, gender } = body; const email = typeof body.email === "string" ? body.email.trim().toLowerCase() : body.email; + const street = typeof body.street === "string" ? body.street.trim() : body.street; + const plz = typeof body.plz === "string" ? body.plz.trim() : body.plz; + const city = typeof body.city === "string" ? body.city.trim() : body.city; if (!email || !password || !name) { return NextResponse.json({ error: "Pflichtfelder fehlen" }, { status: 400 }); } + const addressFieldsProvided = [street, plz, city].filter((v) => !!v).length; + if (addressFieldsProvided > 0 && addressFieldsProvided < 3) { + return NextResponse.json( + { error: "Bitte Straße, PLZ und Wohnort vollständig angeben." }, + { status: 400 } + ); + } + if (street && plz && city) { try { const addressExists = await verifyAddressExists(street, plz, city); diff --git a/src/app/api/stripe/webhook/route.ts b/src/app/api/stripe/webhook/route.ts index db50f66..676a4d9 100644 --- a/src/app/api/stripe/webhook/route.ts +++ b/src/app/api/stripe/webhook/route.ts @@ -6,7 +6,10 @@ import { prisma } from "@/src/lib/prisma"; import { revalidatePath, revalidateTag } from "next/cache"; 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"; +import { + generateBookingRefundEmail, + type BookingRefundReason, +} from "@/src/lib/mail/templates/booking-refund"; /** * Sucht einen registrierten User anhand der E-Mail, case-insensitive. @@ -296,7 +299,10 @@ async function handleCheckoutCompleted(session: Stripe.Checkout.Session) { } 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); + await withTimeout( + sendBookingRefund(recipientEmail, courseId, amountPaidCents, "duplicate_payment"), + 2000 + ); } } @@ -378,6 +384,26 @@ async function runCapacityTransactionUser(params: { where: { courseId_userId: { courseId, userId } }, }); + // Diese Session kann bereits als Gast-Buchung verarbeitet worden sein + // (Gast→User-Routing ist über Zustellungen hinweg nicht stabil: eine + // frühere Zustellung fand noch keinen Account und lief über + // runCapacityTransactionGuest, eine spätere Zustellung/ein Retry + // findet den inzwischen registrierten Account und landet hier). Der + // courseId_userId-Lookup oben findet diese Gast-Buchung nicht (sie hat + // userId: null), da stripeSessionId aber `@unique` ist, würde der + // upsert unten mit P2002 kollidieren, wenn er erneut versucht, diese + // Session zu schreiben. + if (!existing) { + const sessionBooking = await tx.courseBooking.findFirst({ + where: { stripeSessionId: session.id }, + }); + if (sessionBooking) { + if (sessionBooking.paymentStatus === "paid") + return { result: "already_paid" as const, bookingId: sessionBooking.id }; + return { result: "already_processed" as const, bookingId: sessionBooking.id }; + } + } + if (existing && existing.stripeSessionId === session.id) { if (existing.paymentStatus === "paid") return { result: "already_paid" as const, bookingId: existing.id }; @@ -623,7 +649,8 @@ async function sendBookingConfirmation( async function sendBookingRefund( recipientEmail: string, courseId: string, - amountRefundedCents: number + amountRefundedCents: number, + reason: BookingRefundReason = "overbooked" ) { try { const course = await prisma.course.findUnique({ @@ -640,7 +667,7 @@ async function sendBookingRefund( businessName: course.business.name, amountRefundedCents, refundPercent: 100, - reason: "overbooked", + reason, companyName: process.env.COMPANY_NAME, }); diff --git a/src/lib/auth/employee-auth-user.ts b/src/lib/auth/employee-auth-user.ts index e3b3ef9..dc303c6 100644 --- a/src/lib/auth/employee-auth-user.ts +++ b/src/lib/auth/employee-auth-user.ts @@ -1,7 +1,28 @@ import { createAdminClient } from "@/src/lib/supabase/admin"; +import type { SupabaseClient } from "@supabase/supabase-js"; export const PASSWORD_REGEX = /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[^a-zA-Z\d]).{8,}$/; +/** + * Supabase Admin's listUsers() returns one page (default 50 users) and has + * no email filter or getUserByEmail. Without pagination, an "email_exists" + * retry would only find the matching user if it happens to be on page 1 — + * silently failing the retry for any account created after the first ~50 + * signups. Iterates pages until a match is found or pages run out. + */ +async function findAuthUserByEmail(supabase: SupabaseClient, email: string) { + const PER_PAGE = 200; + const MAX_PAGES = 50; // safety bound — 10k users; avoids an unbounded loop + for (let page = 1; page <= MAX_PAGES; page++) { + const { data, error } = await supabase.auth.admin.listUsers({ page, perPage: PER_PAGE }); + if (error) return { user: null, error }; + const match = data.users.find((u) => u.email === email); + if (match) return { user: match, error: null }; + if (data.users.length < PER_PAGE) return { user: null, error: null }; // last page reached + } + return { user: null, error: null }; +} + /** * 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 @@ -55,14 +76,31 @@ export async function createOrUpdateEmployeeAuthUser(input: { 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); + const { user: existingUser, error: listError } = await findAuthUserByEmail(supabase, email); if (listError || !existingUser) { console.error("Supabase Auth Error:", error.message); return { success: false, error: error.message, status: 400 }; } + // "email_exists" bedeutet nur, dass IRGENDEIN Supabase-Auth-User diese + // E-Mail hat — nicht zwingend ein zuvor teilweise fehlgeschlagener + // Onboarding-Versuch dieses Mitarbeiters. Ohne diese Prüfung könnte ein + // Angreifer über einen erneut abgeschickten (abgelaufenen/bereits + // verwendeten) Onboarding-Link das Passwort eines fremden Accounts + // überschreiben, sofern dessen E-Mail zufällig übereinstimmt. + if (existingUser.user_metadata?.role !== "employee") { + console.error( + `Supabase Auth: E-Mail ${email} gehört zu einem bestehenden Account, der nicht die Rolle ` + + `"employee" hat (role=${existingUser.user_metadata?.role}). Update wird verweigert.` + ); + return { + success: false, + error: "Für diese E-Mail-Adresse existiert bereits ein Account mit einer anderen Rolle.", + status: 409, + }; + } + // Passwort/Metadaten auf den zuletzt übermittelten Stand bringen, falls der // Mitarbeiter das Formular nach dem vorherigen Teilausfall erneut mit // geänderten Daten abgeschickt hat. diff --git a/src/lib/geocoding/verify-address.ts b/src/lib/geocoding/verify-address.ts index 6d194f7..24c2a8f 100644 --- a/src/lib/geocoding/verify-address.ts +++ b/src/lib/geocoding/verify-address.ts @@ -40,6 +40,9 @@ export async function verifyAddressExists( // Nominatim's usage policy requires a descriptive User-Agent identifying the app. "User-Agent": "s3-kuma-app/1.0 (address verification)", }, + // Ohne Timeout würde ein hängender Nominatim-Request Registrierung und + // Settings-Updates bis zum Plattform-Deadline blockieren. + signal: AbortSignal.timeout(5_000), }); if (!res.ok) { diff --git a/src/lib/mail/templates/booking-refund.ts b/src/lib/mail/templates/booking-refund.ts index 9c51b46..b0249e0 100644 --- a/src/lib/mail/templates/booking-refund.ts +++ b/src/lib/mail/templates/booking-refund.ts @@ -14,7 +14,11 @@ import { renderDivider, renderInfoBox } from "./components"; import { renderEmailLayout } from "./layout"; import { KUMASY_BRANDING, type EmailBranding } from "./tokens"; -export type BookingRefundReason = "overbooked" | "cancelled_by_user" | "cancelled_by_manager"; +export type BookingRefundReason = + | "overbooked" + | "cancelled_by_user" + | "cancelled_by_manager" + | "duplicate_payment"; interface BookingRefundEmailParams { courseName: string; @@ -43,6 +47,10 @@ const REASON_COPY: Record< intro: (courseName, businessName) => `Ihre Buchung für den Kurs ${courseName} wurde durch ${businessName} storniert.`, }, + duplicate_payment: { + intro: (courseName, businessName) => + `wir haben festgestellt, dass für den Kurs ${courseName} von ${businessName} eine doppelte Zahlung bei uns eingegangen ist.`, + }, }; const REASON_COPY_TEXT: Record< @@ -55,6 +63,8 @@ const REASON_COPY_TEXT: Record< `Ihre Stornierung für den Kurs "${courseName}" wurde bestätigt.`, cancelled_by_manager: (courseName, businessName) => `Ihre Buchung für den Kurs "${courseName}" wurde durch ${businessName} storniert.`, + duplicate_payment: (courseName, businessName) => + `wir haben festgestellt, dass für den Kurs "${courseName}" von ${businessName} eine doppelte Zahlung bei uns eingegangen ist.`, }; /** diff --git a/src/modules/courses/actions/course-actions.ts b/src/modules/courses/actions/course-actions.ts index bf965a6..c9690b5 100644 --- a/src/modules/courses/actions/course-actions.ts +++ b/src/modules/courses/actions/course-actions.ts @@ -511,7 +511,22 @@ export async function updateCourse( const startDate = new Date(validatedData.date || existingCourse.date); const endDate = new Date(validatedData.endDate!); - await prisma.$transaction(async (tx) => { + const txResult = await prisma.$transaction(async (tx) => { + // Re-check innerhalb der Transaktion: zwischen der Prüfung oben und + // hier kann der Stripe-Webhook (läuft außerhalb dieser Transaktion) + // eine neue "paid"-Buchung für diesen Kurs angelegt haben — z.B. wenn + // confirmedStandingOrderConversion bereits im ersten Request gesetzt + // war. Ohne diesen Re-Check könnte die Konvertierung die + // requiresStandingOrderConfirmation-Warnung umgehen. + if (!confirmedStandingOrderConversion) { + const currentBookingsCount = await tx.courseBooking.count({ + where: { courseId, paymentStatus: "paid" }, + }); + if (currentBookingsCount > 0) { + return { blocked: true as const, existingBookingsCount: currentBookingsCount }; + } + } + await tx.course.update({ where: { id: courseId }, data: { @@ -560,8 +575,20 @@ export async function updateCourse( })), }); } + + return { blocked: false as const }; }); + if (txResult.blocked) { + return { + success: false, + requiresStandingOrderConfirmation: true, + existingBookingsCount: txResult.existingBookingsCount, + error: + "Dieser Kurs hat inzwischen eine neue Buchung erhalten. 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.", + }; + } + // Schritt 9: Next.js Cache für /courses Seite invalidieren revalidatePath("/courses"); revalidatePath(`/courses/edit/${courseId}`); diff --git a/src/modules/courses/ui/views/course-list-view.tsx b/src/modules/courses/ui/views/course-list-view.tsx index 93e2540..d015ff5 100644 --- a/src/modules/courses/ui/views/course-list-view.tsx +++ b/src/modules/courses/ui/views/course-list-view.tsx @@ -115,23 +115,25 @@ const CourseListView = ({ // 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); + try { + 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"); + const result = await getMyCourses({ dateFrom: newDateFrom, dateTo: newDateTo }); + if (result.success) { + setCourses((prev) => { + const existingIds = new Set(prev.map((c) => c.id)); + return [...prev, ...result.courses.filter((c) => !existingIds.has(c.id))]; + }); + setLoadedDateTo(newDateTo); + } else { + toast.error(result.error || "Fehler beim Laden weiterer Kurse"); + } + } finally { + setIsLoadingMore(false); } - setIsLoadingMore(false); }; // Extend loaded date range only when filter dates go outside the server-fetched range diff --git a/src/modules/employee/actions/employee-actions.ts b/src/modules/employee/actions/employee-actions.ts index dbfe166..ae29ccf 100644 --- a/src/modules/employee/actions/employee-actions.ts +++ b/src/modules/employee/actions/employee-actions.ts @@ -814,6 +814,20 @@ export async function completeEmployeeOnboarding( return { success: false, error: "Invalid onboarding link" }; } + // Muss VOR dem Auth-Call geprüft werden: createOrUpdateEmployeeAuthUser + // überschreibt bei einem bereits existierenden Supabase-User Passwort und + // Metadaten. Ohne diese Prüfung könnte ein abgelaufener oder bereits + // verwendeter Onboarding-Link erneut abgeschickt werden und das Passwort + // eines längst onboardeten (oder anderweitig bestehenden) Accounts ändern, + // bevor die spätere updateMany-WHERE-Klausel das überhaupt bemerkt. + if (employee.isOnboarded) { + return { success: false, error: "Onboarding has already been completed" }; + } + + if (employee.onboardingTokenExpiry && employee.onboardingTokenExpiry < new Date()) { + return { success: false, error: "This onboarding link has expired" }; + } + if (!PASSWORD_REGEX.test(data.password)) { return { success: false, diff --git a/tests/api/auth/register-employee.test.ts b/tests/api/auth/register-employee.test.ts index 16578f8..0a8dfa9 100644 --- a/tests/api/auth/register-employee.test.ts +++ b/tests/api/auth/register-employee.test.ts @@ -171,7 +171,9 @@ describe("POST /api/auth/register/employee", () => { }, }); mockListUsers.mockResolvedValue({ - data: { users: [{ id: "emp-1", email: validBody.email }] }, + data: { + users: [{ id: "emp-1", email: validBody.email, user_metadata: { role: "employee" } }], + }, error: null, }); mockUpdateUserById.mockResolvedValue({