mirror of
https://github.com/Since-3/Kuma.git
synced 2026-10-12 21:41:17 +00:00
see comments
This commit is contained in:
parent
b52849ecbd
commit
239d6700be
13 changed files with 231 additions and 41 deletions
|
|
@ -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"));
|
||||||
|
|
@ -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?
|
||||||
|
|
|
||||||
23
scripts/check-duplicate-user-emails.sql
Normal file
23
scripts/check-duplicate-user-emails.sql
Normal 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;
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
|
|
@ -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.`,
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -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}`);
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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({
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue