see comments

This commit is contained in:
Rajpreet0 2026-08-17 20:47:16 +02:00
parent b52849ecbd
commit 239d6700be
13 changed files with 231 additions and 41 deletions

View file

@ -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"));

View file

@ -25,6 +25,12 @@ model User {
city String? city String?
street String? street String?
houseNumber 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 email String @unique
tel String? tel String?
gender String? gender String?

View file

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

View file

@ -7,22 +7,17 @@ import { verifyAddressExists } from "@/src/lib/geocoding/verify-address";
export async function POST(request: Request) { export async function POST(request: Request) {
try { try {
const body = await request.json(); const body = await request.json();
const { const { password, firstName, lastName, tel, companyName, companyMail, companyNumber } = body;
email, const email = typeof body.email === "string" ? body.email.trim().toLowerCase() : body.email;
password, const street = typeof body.street === "string" ? body.street.trim() : body.street;
firstName, const plz = typeof body.plz === "string" ? body.plz.trim() : body.plz;
lastName, const city = typeof body.city === "string" ? body.city.trim() : body.city;
tel, const companyStreet =
plz, typeof body.companyStreet === "string" ? body.companyStreet.trim() : body.companyStreet;
city, const companyPLZ =
street, typeof body.companyPLZ === "string" ? body.companyPLZ.trim() : body.companyPLZ;
companyName, const companyPlace =
companyPlace, typeof body.companyPlace === "string" ? body.companyPlace.trim() : body.companyPlace;
companyPLZ,
companyStreet,
companyMail,
companyNumber,
} = body;
// Validate required fields // Validate required fields
if (!email || !password || !firstName || !lastName || !companyName) { 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 }); 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 { try {
const companyAddressExists = await verifyAddressExists( const companyAddressExists = await verifyAddressExists(
companyStreet, companyStreet,

View file

@ -7,13 +7,24 @@ import { verifyAddressExists } from "@/src/lib/geocoding/verify-address";
export async function POST(request: Request) { export async function POST(request: Request) {
try { try {
const body = await request.json(); 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 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) { if (!email || !password || !name) {
return NextResponse.json({ error: "Pflichtfelder fehlen" }, { status: 400 }); 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) { if (street && plz && city) {
try { try {
const addressExists = await verifyAddressExists(street, plz, city); const addressExists = await verifyAddressExists(street, plz, city);

View file

@ -6,7 +6,10 @@ import { prisma } from "@/src/lib/prisma";
import { revalidatePath, revalidateTag } from "next/cache"; import { revalidatePath, revalidateTag } from "next/cache";
import { sendMail } from "@/src/lib/mail/resend"; import { sendMail } from "@/src/lib/mail/resend";
import { generateBookingConfirmationEmail } from "@/src/lib/mail/templates/booking-confirmation"; 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. * 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) { } else if (needsRefund && refundSucceeded) {
await withTimeout(sendBookingRefund(recipientEmail, courseId, amountPaidCents), 2000); await withTimeout(sendBookingRefund(recipientEmail, courseId, amountPaidCents), 2000);
} else if (outcome.result === "duplicate_payment" && duplicateRefundSucceeded) { } 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 } }, 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 && existing.stripeSessionId === session.id) {
if (existing.paymentStatus === "paid") if (existing.paymentStatus === "paid")
return { result: "already_paid" as const, bookingId: existing.id }; return { result: "already_paid" as const, bookingId: existing.id };
@ -623,7 +649,8 @@ async function sendBookingConfirmation(
async function sendBookingRefund( async function sendBookingRefund(
recipientEmail: string, recipientEmail: string,
courseId: string, courseId: string,
amountRefundedCents: number amountRefundedCents: number,
reason: BookingRefundReason = "overbooked"
) { ) {
try { try {
const course = await prisma.course.findUnique({ const course = await prisma.course.findUnique({
@ -640,7 +667,7 @@ async function sendBookingRefund(
businessName: course.business.name, businessName: course.business.name,
amountRefundedCents, amountRefundedCents,
refundPercent: 100, refundPercent: 100,
reason: "overbooked", reason,
companyName: process.env.COMPANY_NAME, companyName: process.env.COMPANY_NAME,
}); });

View file

@ -1,7 +1,28 @@
import { createAdminClient } from "@/src/lib/supabase/admin"; 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,}$/; 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 * 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 * 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 }; return { success: false, error: error.message, status: 400 };
} }
const { data: existingUsers, error: listError } = await supabase.auth.admin.listUsers(); const { user: existingUser, error: listError } = await findAuthUserByEmail(supabase, email);
const existingUser = existingUsers?.users.find((u) => u.email === email);
if (listError || !existingUser) { if (listError || !existingUser) {
console.error("Supabase Auth Error:", error.message); console.error("Supabase Auth Error:", error.message);
return { success: false, error: error.message, status: 400 }; 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 // Passwort/Metadaten auf den zuletzt übermittelten Stand bringen, falls der
// Mitarbeiter das Formular nach dem vorherigen Teilausfall erneut mit // Mitarbeiter das Formular nach dem vorherigen Teilausfall erneut mit
// geänderten Daten abgeschickt hat. // geänderten Daten abgeschickt hat.

View file

@ -40,6 +40,9 @@ export async function verifyAddressExists(
// Nominatim's usage policy requires a descriptive User-Agent identifying the app. // Nominatim's usage policy requires a descriptive User-Agent identifying the app.
"User-Agent": "s3-kuma-app/1.0 (address verification)", "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) { if (!res.ok) {

View file

@ -14,7 +14,11 @@ import { renderDivider, renderInfoBox } from "./components";
import { renderEmailLayout } from "./layout"; import { renderEmailLayout } from "./layout";
import { KUMASY_BRANDING, type EmailBranding } from "./tokens"; 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 { interface BookingRefundEmailParams {
courseName: string; courseName: string;
@ -43,6 +47,10 @@ const REASON_COPY: Record<
intro: (courseName, businessName) => intro: (courseName, businessName) =>
`Ihre Buchung für den Kurs <strong>${courseName}</strong> wurde durch <strong>${businessName}</strong> storniert.`, `Ihre Buchung für den Kurs <strong>${courseName}</strong> wurde durch <strong>${businessName}</strong> storniert.`,
}, },
duplicate_payment: {
intro: (courseName, businessName) =>
`wir haben festgestellt, dass für den Kurs <strong>${courseName}</strong> von <strong>${businessName}</strong> eine doppelte Zahlung bei uns eingegangen ist.`,
},
}; };
const REASON_COPY_TEXT: Record< const REASON_COPY_TEXT: Record<
@ -55,6 +63,8 @@ const REASON_COPY_TEXT: Record<
`Ihre Stornierung für den Kurs "${courseName}" wurde bestätigt.`, `Ihre Stornierung für den Kurs "${courseName}" wurde bestätigt.`,
cancelled_by_manager: (courseName, businessName) => cancelled_by_manager: (courseName, businessName) =>
`Ihre Buchung für den Kurs "${courseName}" wurde durch ${businessName} storniert.`, `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.`,
}; };
/** /**

View file

@ -511,7 +511,22 @@ export async function updateCourse(
const startDate = new Date(validatedData.date || existingCourse.date); const startDate = new Date(validatedData.date || existingCourse.date);
const endDate = new Date(validatedData.endDate!); 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({ await tx.course.update({
where: { id: courseId }, where: { id: courseId },
data: { 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 // Schritt 9: Next.js Cache für /courses Seite invalidieren
revalidatePath("/courses"); revalidatePath("/courses");
revalidatePath(`/courses/edit/${courseId}`); revalidatePath(`/courses/edit/${courseId}`);

View file

@ -115,23 +115,25 @@ const CourseListView = ({
// den kompletten Bereich neu lädt und ersetzt. // den kompletten Bereich neu lädt und ersetzt.
const handleLoadMore = async () => { const handleLoadMore = async () => {
setIsLoadingMore(true); setIsLoadingMore(true);
const newDateFrom = new Date(loadedDateTo); try {
newDateFrom.setDate(newDateFrom.getDate() + 1); const newDateFrom = new Date(loadedDateTo);
const newDateTo = new Date(loadedDateTo); newDateFrom.setDate(newDateFrom.getDate() + 1);
newDateTo.setDate(newDateTo.getDate() + LOAD_MORE_DAYS); const newDateTo = new Date(loadedDateTo);
newDateTo.setDate(newDateTo.getDate() + LOAD_MORE_DAYS);
const result = await getMyCourses({ dateFrom: newDateFrom, dateTo: newDateTo }); const result = await getMyCourses({ dateFrom: newDateFrom, dateTo: newDateTo });
if (result.success) { if (result.success) {
setCourses((prev) => { setCourses((prev) => {
const existingIds = new Set(prev.map((c) => c.id)); const existingIds = new Set(prev.map((c) => c.id));
const newOnes = result.courses.filter((c) => !existingIds.has(c.id)); return [...prev, ...result.courses.filter((c) => !existingIds.has(c.id))];
return [...prev, ...newOnes]; });
}); setLoadedDateTo(newDateTo);
setLoadedDateTo(newDateTo); } else {
} else { toast.error(result.error || "Fehler beim Laden weiterer Kurse");
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 // Extend loaded date range only when filter dates go outside the server-fetched range

View file

@ -814,6 +814,20 @@ export async function completeEmployeeOnboarding(
return { success: false, error: "Invalid onboarding link" }; 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)) { if (!PASSWORD_REGEX.test(data.password)) {
return { return {
success: false, success: false,

View file

@ -171,7 +171,9 @@ describe("POST /api/auth/register/employee", () => {
}, },
}); });
mockListUsers.mockResolvedValue({ mockListUsers.mockResolvedValue({
data: { users: [{ id: "emp-1", email: validBody.email }] }, data: {
users: [{ id: "emp-1", email: validBody.email, user_metadata: { role: "employee" } }],
},
error: null, error: null,
}); });
mockUpdateUserById.mockResolvedValue({ mockUpdateUserById.mockResolvedValue({