mirror of
https://github.com/Since-3/Kuma.git
synced 2026-10-12 20:51:18 +00:00
fixed a couple of bugs
This commit is contained in:
parent
e1a4a7cb72
commit
25ac296917
21 changed files with 779 additions and 210 deletions
|
|
@ -19,11 +19,11 @@ const Course = async () => {
|
||||||
today.setHours(0, 0, 0, 0);
|
today.setHours(0, 0, 0, 0);
|
||||||
const twoWeeksAgo = new Date(today);
|
const twoWeeksAgo = new Date(today);
|
||||||
twoWeeksAgo.setDate(today.getDate() - 14);
|
twoWeeksAgo.setDate(today.getDate() - 14);
|
||||||
const sixWeeksLater = new Date(today);
|
const twoWeeksLater = new Date(today);
|
||||||
sixWeeksLater.setDate(today.getDate() + 42);
|
twoWeeksLater.setDate(today.getDate() + 14);
|
||||||
|
|
||||||
const [coursesResult, roomsResult, trainersResult] = await Promise.all([
|
const [coursesResult, roomsResult, trainersResult] = await Promise.all([
|
||||||
getMyCourses({ dateFrom: twoWeeksAgo, dateTo: sixWeeksLater }),
|
getMyCourses({ dateFrom: twoWeeksAgo, dateTo: twoWeeksLater }),
|
||||||
getMyRooms(),
|
getMyRooms(),
|
||||||
getMyTrainers(),
|
getMyTrainers(),
|
||||||
]);
|
]);
|
||||||
|
|
@ -49,7 +49,7 @@ const Course = async () => {
|
||||||
<CourseView
|
<CourseView
|
||||||
initialCourses={initialCourses}
|
initialCourses={initialCourses}
|
||||||
initialDateFrom={twoWeeksAgo}
|
initialDateFrom={twoWeeksAgo}
|
||||||
initialDateTo={sixWeeksLater}
|
initialDateTo={twoWeeksLater}
|
||||||
roomsMap={roomsMap}
|
roomsMap={roomsMap}
|
||||||
trainersMap={trainersMap}
|
trainersMap={trainersMap}
|
||||||
canCreate={canCreate}
|
canCreate={canCreate}
|
||||||
|
|
|
||||||
|
|
@ -18,7 +18,7 @@ const Layout = ({ children }: Props) => {
|
||||||
<DashboardSidebarWrapper />
|
<DashboardSidebarWrapper />
|
||||||
<main className="flex flex-col h-screen w-full px-4">
|
<main className="flex flex-col h-screen w-full px-4">
|
||||||
<DashboardNavbar />
|
<DashboardNavbar />
|
||||||
{children}
|
<div className="pb-8">{children}</div>
|
||||||
</main>
|
</main>
|
||||||
</SidebarProvider>
|
</SidebarProvider>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@
|
||||||
import { createClient } from "@/src/lib/supabase/server";
|
import { createClient } from "@/src/lib/supabase/server";
|
||||||
import { prisma } from "@/src/lib/prisma";
|
import { prisma } from "@/src/lib/prisma";
|
||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
|
import { verifyAddressExists } from "@/src/lib/geocoding/verify-address";
|
||||||
|
|
||||||
export async function POST(request: Request) {
|
export async function POST(request: Request) {
|
||||||
try {
|
try {
|
||||||
|
|
@ -28,6 +29,42 @@ export async function POST(request: Request) {
|
||||||
return NextResponse.json({ error: "Pflichtfelder fehlen" }, { status: 400 });
|
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({
|
const existingUser = await prisma.user.findUnique({
|
||||||
where: { email },
|
where: { email },
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import { createAdminClient } from "@/src/lib/supabase/admin";
|
|
||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import { prisma } from "@/src/lib/prisma";
|
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
|
// Simple in-memory rate limiter: max 5 attempts per IP per 15 minutes
|
||||||
const rateLimitMap = new Map<string, { count: number; resetAt: number }>();
|
const rateLimitMap = new Map<string, { count: number; resetAt: number }>();
|
||||||
|
|
@ -22,8 +22,6 @@ function isRateLimited(ip: string): boolean {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
const PASSWORD_REGEX = /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[^a-zA-Z\d]).{8,}$/;
|
|
||||||
|
|
||||||
export async function POST(request: Request) {
|
export async function POST(request: Request) {
|
||||||
try {
|
try {
|
||||||
const ip =
|
const ip =
|
||||||
|
|
@ -70,75 +68,18 @@ export async function POST(request: Request) {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Der Mitarbeiter hat den Onboarding-Token bereits serverseitig bestätigt
|
const result = await createOrUpdateEmployeeAuthUser({
|
||||||
// (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({
|
|
||||||
email,
|
email,
|
||||||
password,
|
password,
|
||||||
email_confirm: true,
|
firstName,
|
||||||
user_metadata: {
|
lastName,
|
||||||
firstName,
|
tel,
|
||||||
lastName,
|
gender,
|
||||||
tel,
|
qualification,
|
||||||
gender,
|
|
||||||
qualification,
|
|
||||||
role: "employee",
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
if (error) {
|
if (!result.success) {
|
||||||
// Ein Retry nach einem vorherigen Teilausfall (Auth-User wurde bereits
|
return NextResponse.json({ error: result.error }, { status: result.status });
|
||||||
// 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 });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return NextResponse.json({ success: true }, { status: 201 });
|
return NextResponse.json({ success: true }, { status: 201 });
|
||||||
|
|
|
||||||
|
|
@ -2,18 +2,38 @@
|
||||||
import { createClient } from "@/src/lib/supabase/server";
|
import { createClient } from "@/src/lib/supabase/server";
|
||||||
import { prisma } from "@/src/lib/prisma";
|
import { prisma } from "@/src/lib/prisma";
|
||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
|
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 { 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) {
|
if (!email || !password || !name) {
|
||||||
return NextResponse.json({ error: "Pflichtfelder fehlen" }, { status: 400 });
|
return NextResponse.json({ error: "Pflichtfelder fehlen" }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const existingManager = await prisma.manager.findUnique({
|
if (street && plz && city) {
|
||||||
where: { email },
|
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) {
|
if (existingManager) {
|
||||||
|
|
@ -23,8 +43,9 @@ export async function POST(request: Request) {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const existingUser = await prisma.user.findUnique({
|
const existingUser = await prisma.user.findFirst({
|
||||||
where: { email },
|
where: { email: { equals: email, mode: "insensitive" } },
|
||||||
|
select: { id: true },
|
||||||
});
|
});
|
||||||
|
|
||||||
if (existingUser) {
|
if (existingUser) {
|
||||||
|
|
@ -71,9 +92,11 @@ export async function POST(request: Request) {
|
||||||
|
|
||||||
// Gast-Buchungen mit derselben E-Mail auf den neuen Account übertragen.
|
// Gast-Buchungen mit derselben E-Mail auf den neuen Account übertragen.
|
||||||
// GuestLead-Einträge mit dieser Mail suchen und deren Buchungen auf userId setzen.
|
// 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 {
|
try {
|
||||||
const guestLeads = await prisma.guestLead.findMany({
|
const guestLeads = await prisma.guestLead.findMany({
|
||||||
where: { email },
|
where: { email: { equals: email, mode: "insensitive" } },
|
||||||
select: { id: true },
|
select: { id: true },
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,20 @@ 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 } 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.
|
* Stripe Webhook Handler.
|
||||||
*
|
*
|
||||||
|
|
@ -107,15 +121,31 @@ async function handleCheckoutCompleted(session: Stripe.Checkout.Session) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
outcome = await runCapacityTransactionGuest({
|
// Zwischen Checkout-Start (als Gast) und Webhook-Zustellung kann sich der
|
||||||
courseId,
|
// Gast bereits mit derselben E-Mail registriert haben. In dem Fall die
|
||||||
guestEmail,
|
// Buchung direkt dem neuen Account zuordnen statt einen verwaisten
|
||||||
guestName: guestName || undefined,
|
// GuestLead-Eintrag anzulegen, der nie automatisch gemerged wird.
|
||||||
session,
|
const registeredUser = await findUserByEmailInsensitive(guestEmail);
|
||||||
paymentIntentId,
|
|
||||||
amountPaidCents,
|
outcome = registeredUser
|
||||||
platformFeeCents,
|
? await runCapacityTransactionUser({
|
||||||
});
|
courseId,
|
||||||
|
userId: registeredUser.id,
|
||||||
|
session,
|
||||||
|
paymentIntentId,
|
||||||
|
amountPaidCents,
|
||||||
|
platformFeeCents,
|
||||||
|
treatDistinctSessionAsDuplicate: true,
|
||||||
|
})
|
||||||
|
: await runCapacityTransactionGuest({
|
||||||
|
courseId,
|
||||||
|
guestEmail,
|
||||||
|
guestName: guestName || undefined,
|
||||||
|
session,
|
||||||
|
paymentIntentId,
|
||||||
|
amountPaidCents,
|
||||||
|
platformFeeCents,
|
||||||
|
});
|
||||||
} else {
|
} else {
|
||||||
const userId = session.metadata?.userId;
|
const userId = session.metadata?.userId;
|
||||||
// Leerer String bedeutet: Checkout wurde ohne Auth erstellt (Race condition
|
// 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_details?.email ||
|
||||||
session.customer_email;
|
session.customer_email;
|
||||||
if (fallbackEmail) {
|
if (fallbackEmail) {
|
||||||
outcome = await runCapacityTransactionGuest({
|
const registeredUser = await findUserByEmailInsensitive(fallbackEmail);
|
||||||
courseId,
|
outcome = registeredUser
|
||||||
guestEmail: fallbackEmail,
|
? await runCapacityTransactionUser({
|
||||||
guestName: session.metadata?.guestName || undefined,
|
courseId,
|
||||||
session,
|
userId: registeredUser.id,
|
||||||
paymentIntentId,
|
session,
|
||||||
amountPaidCents,
|
paymentIntentId,
|
||||||
platformFeeCents,
|
amountPaidCents,
|
||||||
});
|
platformFeeCents,
|
||||||
|
treatDistinctSessionAsDuplicate: true,
|
||||||
|
})
|
||||||
|
: await runCapacityTransactionGuest({
|
||||||
|
courseId,
|
||||||
|
guestEmail: fallbackEmail,
|
||||||
|
guestName: session.metadata?.guestName || undefined,
|
||||||
|
session,
|
||||||
|
paymentIntentId,
|
||||||
|
amountPaidCents,
|
||||||
|
platformFeeCents,
|
||||||
|
});
|
||||||
} else {
|
} else {
|
||||||
console.error(
|
console.error(
|
||||||
"checkout.session.completed: userId und guestEmail fehlen in Metadata. " +
|
"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.
|
// E-Mail-Versand – darf den Webhook NICHT zum Crashen bringen.
|
||||||
const recipientEmail = session.customer_email ?? session.customer_details?.email ?? null;
|
const recipientEmail = session.customer_email ?? session.customer_details?.email ?? null;
|
||||||
if (recipientEmail) {
|
if (recipientEmail) {
|
||||||
|
|
@ -232,6 +295,8 @@ async function handleCheckoutCompleted(session: Stripe.Checkout.Session) {
|
||||||
await withTimeout(sendBookingConfirmation(recipientEmail, courseId, amountPaidCents), 2000);
|
await withTimeout(sendBookingConfirmation(recipientEmail, courseId, amountPaidCents), 2000);
|
||||||
} 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) {
|
||||||
|
await withTimeout(sendBookingRefund(recipientEmail, courseId, amountPaidCents), 2000);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -258,7 +323,8 @@ type CapacityOutcome = {
|
||||||
| "already_refunded"
|
| "already_refunded"
|
||||||
| "already_failed"
|
| "already_failed"
|
||||||
| "already_processed"
|
| "already_processed"
|
||||||
| "course_not_found";
|
| "course_not_found"
|
||||||
|
| "duplicate_payment";
|
||||||
bookingId?: string;
|
bookingId?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -279,6 +345,13 @@ const ALREADY_PROCESSED_STATUSES = [
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Atomare Kapazitätsprüfung für authentifizierte User.
|
* 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: {
|
async function runCapacityTransactionUser(params: {
|
||||||
courseId: string;
|
courseId: string;
|
||||||
|
|
@ -287,8 +360,17 @@ async function runCapacityTransactionUser(params: {
|
||||||
paymentIntentId: string | null;
|
paymentIntentId: string | null;
|
||||||
amountPaidCents: number;
|
amountPaidCents: number;
|
||||||
platformFeeCents: number;
|
platformFeeCents: number;
|
||||||
|
treatDistinctSessionAsDuplicate?: boolean;
|
||||||
}): Promise<CapacityOutcome> {
|
}): Promise<CapacityOutcome> {
|
||||||
const { courseId, userId, session, paymentIntentId, amountPaidCents, platformFeeCents } = params;
|
const {
|
||||||
|
courseId,
|
||||||
|
userId,
|
||||||
|
session,
|
||||||
|
paymentIntentId,
|
||||||
|
amountPaidCents,
|
||||||
|
platformFeeCents,
|
||||||
|
treatDistinctSessionAsDuplicate = false,
|
||||||
|
} = params;
|
||||||
|
|
||||||
return prisma.$transaction(
|
return prisma.$transaction(
|
||||||
async (tx) => {
|
async (tx) => {
|
||||||
|
|
@ -312,6 +394,21 @@ async function runCapacityTransactionUser(params: {
|
||||||
return { result: "already_processed" as const, bookingId: existing.id };
|
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({
|
const course = await tx.course.findUnique({
|
||||||
where: { id: courseId },
|
where: { id: courseId },
|
||||||
select: { maxParticipants: true },
|
select: { maxParticipants: true },
|
||||||
|
|
|
||||||
95
src/lib/auth/employee-auth-user.ts
Normal file
95
src/lib/auth/employee-auth-user.ts
Normal file
|
|
@ -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 };
|
||||||
|
}
|
||||||
66
src/lib/geocoding/verify-address.ts
Normal file
66
src/lib/geocoding/verify-address.ts
Normal file
|
|
@ -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<boolean> {
|
||||||
|
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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -180,7 +180,9 @@ const EmployeeOnboardingView = () => {
|
||||||
toast.success("Account erfolgreich erstellt! Du kannst dich jetzt einloggen.");
|
toast.success("Account erfolgreich erstellt! Du kannst dich jetzt einloggen.");
|
||||||
router.push("/login");
|
router.push("/login");
|
||||||
} else {
|
} else {
|
||||||
toast.error(result.error || "Ein Fehler ist aufgetreten");
|
const message =
|
||||||
|
typeof result.error === "string" ? result.error : "Ein Fehler ist aufgetreten";
|
||||||
|
toast.error(message);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,11 @@ import { courseSchema, publishedCourseSchema, type CourseFormData } from "../sch
|
||||||
import { revalidatePath } from "next/cache";
|
import { revalidatePath } from "next/cache";
|
||||||
import { Prisma } from "@/src/generated/prisma/client";
|
import { Prisma } from "@/src/generated/prisma/client";
|
||||||
import { generateUniqueSlug } from "@/src/lib/slug";
|
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";
|
type StandingOrderScope = "this" | "this_and_following" | "all";
|
||||||
|
|
||||||
|
|
@ -179,6 +183,9 @@ export async function createCourse(data: CourseFormData, status: "draft" | "publ
|
||||||
: "Entwurf erfolgreich gespeichert",
|
: "Entwurf erfolgreich gespeichert",
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
if (error instanceof TooManyInstancesError) {
|
||||||
|
return { success: false, error: error.message };
|
||||||
|
}
|
||||||
console.error("Error creating course:", error);
|
console.error("Error creating course:", error);
|
||||||
return {
|
return {
|
||||||
success: false,
|
success: false,
|
||||||
|
|
@ -388,7 +395,8 @@ export async function updateCourse(
|
||||||
courseId: string,
|
courseId: string,
|
||||||
data: CourseFormData,
|
data: CourseFormData,
|
||||||
status: "draft" | "published",
|
status: "draft" | "published",
|
||||||
scope: StandingOrderScope = "this"
|
scope: StandingOrderScope = "this",
|
||||||
|
confirmedStandingOrderConversion = false
|
||||||
) {
|
) {
|
||||||
try {
|
try {
|
||||||
// Schritt 1: Aktuellen Benutzer abrufen
|
// Schritt 1: Aktuellen Benutzer abrufen
|
||||||
|
|
@ -472,6 +480,101 @@ export async function updateCourse(
|
||||||
const isTemplate = !existingCourse.parentCourseId;
|
const isTemplate = !existingCourse.parentCourseId;
|
||||||
const templateId = isTemplate ? existingCourse.id : 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
|
// Schritt 8: Kurs aktualisieren je nach Scope
|
||||||
if (scope === "this") {
|
if (scope === "this") {
|
||||||
await prisma.course.update({
|
await prisma.course.update({
|
||||||
|
|
@ -522,6 +625,9 @@ export async function updateCourse(
|
||||||
: "Entwurf erfolgreich aktualisiert",
|
: "Entwurf erfolgreich aktualisiert",
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
if (error instanceof TooManyInstancesError) {
|
||||||
|
return { success: false, error: error.message };
|
||||||
|
}
|
||||||
console.error("Error updating course:", error);
|
console.error("Error updating course:", error);
|
||||||
return {
|
return {
|
||||||
success: false,
|
success: false,
|
||||||
|
|
|
||||||
|
|
@ -122,42 +122,46 @@ const CourseListItem: React.FC<CourseListItemProps> = ({
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="flex flex-col gap-4 p-5">
|
<div className="flex flex-col gap-4 p-5">
|
||||||
<div className="flex items-center w-full">
|
{/* Date & Time */}
|
||||||
{trainers.map((trainerId, index) => {
|
<div className="flex items-center justify-between w-full">
|
||||||
const trainerInfo = trainersMap[trainerId];
|
<p className="text-gray-500 text-lg">
|
||||||
const displayName = trainerInfo?.label ?? "Ehemaliger Trainer";
|
{new Date(date).toLocaleDateString("de-DE", {
|
||||||
const avatarSrc = trainerInfo?.pbSrc;
|
day: "2-digit",
|
||||||
return (
|
month: "2-digit",
|
||||||
<AbstractTooltip key={`${trainerId}-${index}`} tooltipText={displayName}>
|
year: "numeric",
|
||||||
<div
|
})}
|
||||||
className={`relative ${index !== 0 ? "-ml-3" : ""}`}
|
</p>
|
||||||
style={{ zIndex: trainers.length - index }}
|
<p className="text-gray-500 text-lg">
|
||||||
>
|
{timeFrom} - {timeTo}
|
||||||
<Avatar className="rounded-full border-2 border-white">
|
</p>
|
||||||
{avatarSrc && <AvatarImage src={avatarSrc} alt={displayName} />}
|
|
||||||
<AvatarFallback>{getInitials(displayName)}</AvatarFallback>
|
|
||||||
</Avatar>
|
|
||||||
</div>
|
|
||||||
</AbstractTooltip>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
{/* Date & Time */}
|
|
||||||
<div className="w-full flex flex-col items-end">
|
|
||||||
<p className="text-xs text-gray-400 font-light">
|
|
||||||
{new Date(date).toLocaleDateString("de-DE", {
|
|
||||||
day: "2-digit",
|
|
||||||
month: "2-digit",
|
|
||||||
year: "numeric",
|
|
||||||
})}
|
|
||||||
</p>
|
|
||||||
<p className="text-gray-500 text-lg">
|
|
||||||
{timeFrom} - {timeTo}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Course Name */}
|
{/* Trainer(s) & Course Name */}
|
||||||
<h2 className="text-xl font-black text-gray-900 leading-tight">{courseName}</h2>
|
<div className="flex items-center gap-3 w-full">
|
||||||
|
{trainers.length > 0 && (
|
||||||
|
<div className="flex items-center shrink-0">
|
||||||
|
{trainers.map((trainerId, index) => {
|
||||||
|
const trainerInfo = trainersMap[trainerId];
|
||||||
|
const displayName = trainerInfo?.label ?? "Ehemaliger Trainer";
|
||||||
|
const avatarSrc = trainerInfo?.pbSrc;
|
||||||
|
return (
|
||||||
|
<AbstractTooltip key={`${trainerId}-${index}`} tooltipText={displayName}>
|
||||||
|
<div
|
||||||
|
className={`relative ${index !== 0 ? "-ml-3" : ""}`}
|
||||||
|
style={{ zIndex: trainers.length - index }}
|
||||||
|
>
|
||||||
|
<Avatar className="rounded-full border-2 border-white">
|
||||||
|
{avatarSrc && <AvatarImage src={avatarSrc} alt={displayName} />}
|
||||||
|
<AvatarFallback>{getInitials(displayName)}</AvatarFallback>
|
||||||
|
</Avatar>
|
||||||
|
</div>
|
||||||
|
</AbstractTooltip>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<h2 className="text-xl font-black text-gray-900 leading-tight">{courseName}</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center justify-between w-full">
|
<div className="flex items-center justify-between w-full">
|
||||||
{/* Participant Number */}
|
{/* Participant Number */}
|
||||||
|
|
|
||||||
|
|
@ -3,9 +3,9 @@
|
||||||
import { useState, useEffect, useSyncExternalStore } from "react";
|
import { useState, useEffect, useSyncExternalStore } from "react";
|
||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
import Link from "next/link";
|
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 { 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 { VisuallyHidden } from "@radix-ui/react-visually-hidden";
|
||||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/src/components/ui/tooltip";
|
import { Tooltip, TooltipContent, TooltipTrigger } from "@/src/components/ui/tooltip";
|
||||||
import DOMPurify from "dompurify";
|
import DOMPurify from "dompurify";
|
||||||
|
|
@ -247,11 +247,19 @@ export const BookingDialog = ({
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
<DialogContent className="sm:max-w-3xl! w-[95vw] max-w-[calc(100%-1rem)] p-0 overflow-hidden gap-0 max-h-[90vh] sm:max-h-[85vh] flex flex-col">
|
<DialogContent
|
||||||
|
showCloseButton={false}
|
||||||
|
className="sm:max-w-3xl! w-[95vw] max-w-[calc(100%-1rem)] p-0 overflow-hidden gap-0 max-h-[90vh] sm:max-h-[85vh] flex flex-col"
|
||||||
|
>
|
||||||
<VisuallyHidden>
|
<VisuallyHidden>
|
||||||
<DialogTitle>{course.name}</DialogTitle>
|
<DialogTitle>{course.name}</DialogTitle>
|
||||||
</VisuallyHidden>
|
</VisuallyHidden>
|
||||||
|
|
||||||
|
<DialogClose className="absolute top-4 right-4 z-10 flex items-center justify-center size-9 rounded-full bg-white/20 backdrop-blur-md border border-white/30 text-white shadow-sm transition-colors hover:bg-white/30 focus:outline-none focus:ring-2 focus:ring-white/50">
|
||||||
|
<X size={18} />
|
||||||
|
<span className="sr-only">Schließen</span>
|
||||||
|
</DialogClose>
|
||||||
|
|
||||||
{/* Scroll container für den Inhalt – Footer bleibt sichtbar */}
|
{/* Scroll container für den Inhalt – Footer bleibt sichtbar */}
|
||||||
<div className="flex-1 overflow-y-auto">
|
<div className="flex-1 overflow-y-auto">
|
||||||
{/* Cover image – auf Mobile dünner */}
|
{/* Cover image – auf Mobile dünner */}
|
||||||
|
|
@ -502,26 +510,27 @@ const PublicCourseCard = ({ course, business, defaultOpen = false }: PublicCours
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{course.trainerProfiles && course.trainerProfiles.length > 0 && (
|
<div className="flex items-center gap-3">
|
||||||
<div className="flex items-center gap-1.5">
|
{course.trainerProfiles && course.trainerProfiles.length > 0 && (
|
||||||
{course.trainerProfiles.map((trainer) => {
|
<div className="flex items-center gap-1.5 shrink-0">
|
||||||
const fullName =
|
{course.trainerProfiles.map((trainer) => {
|
||||||
[trainer.firstName, trainer.lastName].filter(Boolean).join(" ") || "Trainer";
|
const fullName =
|
||||||
return (
|
[trainer.firstName, trainer.lastName].filter(Boolean).join(" ") || "Trainer";
|
||||||
<Tooltip key={trainer.id}>
|
return (
|
||||||
<TooltipTrigger asChild>
|
<Tooltip key={trainer.id}>
|
||||||
<div>
|
<TooltipTrigger asChild>
|
||||||
<TrainerAvatar trainer={trainer} size="sm" />
|
<div>
|
||||||
</div>
|
<TrainerAvatar trainer={trainer} size="sm" />
|
||||||
</TooltipTrigger>
|
</div>
|
||||||
<TooltipContent side="top">{fullName}</TooltipContent>
|
</TooltipTrigger>
|
||||||
</Tooltip>
|
<TooltipContent side="top">{fullName}</TooltipContent>
|
||||||
);
|
</Tooltip>
|
||||||
})}
|
);
|
||||||
</div>
|
})}
|
||||||
)}
|
</div>
|
||||||
|
)}
|
||||||
<h2 className="text-xl font-semibold text-gray-900">{course.name}</h2>
|
<h2 className="text-xl font-semibold text-gray-900">{course.name}</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<span
|
<span
|
||||||
|
|
|
||||||
|
|
@ -13,12 +13,26 @@ import { getMyRooms } from "@/src/modules/rooms/actions/room-actions";
|
||||||
import { getMyTrainers } from "@/src/modules/employee/actions/employee-actions";
|
import { getMyTrainers } from "@/src/modules/employee/actions/employee-actions";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { Course } from "../../types/course.types";
|
import { Course } from "../../types/course.types";
|
||||||
import { Trash2, ImagePlus, X, Loader2 } from "lucide-react";
|
import { Trash2, ImagePlus, X, Loader2, AlertTriangle } from "lucide-react";
|
||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
import DeleteDialog from "@/src/components/layout/DeleteDialog";
|
import DeleteDialog from "@/src/components/layout/DeleteDialog";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogClose,
|
||||||
|
} from "@/src/components/ui/dialog";
|
||||||
import { Progress } from "@/src/components/ui/progress";
|
import { Progress } from "@/src/components/ui/progress";
|
||||||
import { downsizeImage } from "@/src/lib/image/downsize";
|
import { downsizeImage } from "@/src/lib/image/downsize";
|
||||||
import { useDeleteCourse } from "../../hooks/useDeleteCourse";
|
import { useDeleteCourse } from "../../hooks/useDeleteCourse";
|
||||||
|
import {
|
||||||
|
generateOccurrences,
|
||||||
|
TooManyInstancesError,
|
||||||
|
type FrequencyType,
|
||||||
|
} from "../../utils/generate-instances";
|
||||||
import {
|
import {
|
||||||
StandingOrderScopeDialog,
|
StandingOrderScopeDialog,
|
||||||
type StandingOrderScope,
|
type StandingOrderScope,
|
||||||
|
|
@ -134,6 +148,11 @@ const CourseCreateView = ({
|
||||||
const [pendingSubmitStatus, setPendingSubmitStatus] = useState<"draft" | "published" | null>(
|
const [pendingSubmitStatus, setPendingSubmitStatus] = useState<"draft" | "published" | null>(
|
||||||
null
|
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 [price, setPrice] = useState(isEdit ? initialData!.price?.toString() || "" : "");
|
||||||
const [priceDisplay, setPriceDisplay] = useState("");
|
const [priceDisplay, setPriceDisplay] = useState("");
|
||||||
const [isPriceFocused, setIsPriceFocused] = useState(false);
|
const [isPriceFocused, setIsPriceFocused] = useState(false);
|
||||||
|
|
@ -289,6 +308,24 @@ const CourseCreateView = ({
|
||||||
if (hasInvalidTiming)
|
if (hasInvalidTiming)
|
||||||
newErrors.weekdayTimings = "Bitte gültige Uhrzeiten für alle Wochentage eingeben";
|
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);
|
setErrors(newErrors);
|
||||||
|
|
@ -323,7 +360,11 @@ const CourseCreateView = ({
|
||||||
businessId: selectedBusinessId || undefined,
|
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);
|
setIsSubmitting(true);
|
||||||
try {
|
try {
|
||||||
const courseData = buildCourseData();
|
const courseData = buildCourseData();
|
||||||
|
|
@ -332,12 +373,25 @@ const CourseCreateView = ({
|
||||||
|
|
||||||
const result =
|
const result =
|
||||||
mode === "edit"
|
mode === "edit"
|
||||||
? await updateCourse(courseId!, courseData, status, scope)
|
? await updateCourse(
|
||||||
|
courseId!,
|
||||||
|
courseData,
|
||||||
|
status,
|
||||||
|
scope,
|
||||||
|
confirmedStandingOrderConversion
|
||||||
|
)
|
||||||
: await createCourse(courseData, status);
|
: await createCourse(courseData, status);
|
||||||
|
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
toast.success(result.message);
|
toast.success(result.message);
|
||||||
router.push("/courses");
|
router.push("/courses");
|
||||||
|
} else if ("requiresStandingOrderConfirmation" in result && courseId) {
|
||||||
|
setStandingOrderConfirmInfo({
|
||||||
|
existingBookingsCount: result.existingBookingsCount ?? 0,
|
||||||
|
message: result.error || "",
|
||||||
|
});
|
||||||
|
setPendingSubmitStatus(status);
|
||||||
|
setStandingOrderConfirmOpen(true);
|
||||||
} else {
|
} else {
|
||||||
toast.error(result.error || "Ein Fehler ist aufgetreten");
|
toast.error(result.error || "Ein Fehler ist aufgetreten");
|
||||||
if (result.fieldErrors) {
|
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") => {
|
const handleSubmit = async (status: "draft" | "published") => {
|
||||||
if (isUploadingImage) {
|
if (isUploadingImage) {
|
||||||
toast.info("Bitte warten Sie, bis das Kursbild hochgeladen wurde.");
|
toast.info("Bitte warten Sie, bis das Kursbild hochgeladen wurde.");
|
||||||
|
|
@ -791,6 +854,51 @@ const CourseCreateView = ({
|
||||||
onConfirm={handleEditScopeConfirm}
|
onConfirm={handleEditScopeConfirm}
|
||||||
action="edit"
|
action="edit"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<Dialog
|
||||||
|
open={standingOrderConfirmOpen}
|
||||||
|
onOpenChange={(open) => {
|
||||||
|
setStandingOrderConfirmOpen(open);
|
||||||
|
if (!open) {
|
||||||
|
setStandingOrderConfirmInfo(null);
|
||||||
|
setPendingSubmitStatus(null);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<DialogContent className="sm:max-w-md">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="flex gap-4 items-center text-xl">
|
||||||
|
<AlertTriangle /> Kurs hat bereits Buchungen
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogDescription className="mt-2 text-gray-600">
|
||||||
|
{standingOrderConfirmInfo?.existingBookingsCount ?? 0}{" "}
|
||||||
|
{standingOrderConfirmInfo?.existingBookingsCount === 1 ? "Buchung" : "Buchungen"}{" "}
|
||||||
|
existieren für diesen Kurs. {standingOrderConfirmInfo?.message}
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<DialogFooter>
|
||||||
|
<DialogClose className="mt-6 flex gap-2" asChild>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => {
|
||||||
|
setStandingOrderConfirmInfo(null);
|
||||||
|
setPendingSubmitStatus(null);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Abbrechen
|
||||||
|
</Button>
|
||||||
|
</DialogClose>
|
||||||
|
<Button
|
||||||
|
variant="destructive"
|
||||||
|
className="mt-6"
|
||||||
|
onClick={handleStandingOrderConfirm}
|
||||||
|
disabled={isSubmitting}
|
||||||
|
>
|
||||||
|
{isSubmitting ? "Wird umgewandelt..." : "Trotzdem umwandeln"}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -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 loadCourses = async (options: { dateFrom: Date; dateTo: Date }) => {
|
||||||
const result = await getMyCourses(options);
|
const result = await getMyCourses(options);
|
||||||
if (result.success) {
|
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
|
// Extend loaded date range only when filter dates go outside the server-fetched range
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isLoading) return;
|
if (isLoading) return;
|
||||||
|
|
@ -444,6 +471,14 @@ const CourseListView = ({
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{!dateTo && courses.length > 0 && (
|
||||||
|
<div className="flex justify-center mt-8">
|
||||||
|
<Button variant="outline" onClick={handleLoadMore} disabled={isLoadingMore}>
|
||||||
|
{isLoadingMore ? "Lädt…" : "Mehr laden"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<DeleteDialog
|
<DeleteDialog
|
||||||
open={deleteDialogOpen}
|
open={deleteDialogOpen}
|
||||||
onOpenChange={(open) => {
|
onOpenChange={(open) => {
|
||||||
|
|
|
||||||
|
|
@ -34,6 +34,15 @@ const toWeekdayNumber = (weekday: string): number | undefined =>
|
||||||
|
|
||||||
export const MAX_INSTANCES = 365;
|
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: {
|
export function generateOccurrences(params: {
|
||||||
startDate: Date;
|
startDate: Date;
|
||||||
endDate: Date;
|
endDate: Date;
|
||||||
|
|
@ -56,9 +65,7 @@ export function generateOccurrences(params: {
|
||||||
|
|
||||||
const addOccurrence = (d: Date, timeFrom: string, timeTo: string) => {
|
const addOccurrence = (d: Date, timeFrom: string, timeTo: string) => {
|
||||||
if (occurrences.length >= MAX_INSTANCES) {
|
if (occurrences.length >= MAX_INSTANCES) {
|
||||||
throw new Error(
|
throw new TooManyInstancesError(MAX_INSTANCES);
|
||||||
`Dauerauftrag überschreitet das Maximum von ${MAX_INSTANCES} Instanzen. Bitte ein kürzeres Enddatum wählen.`
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
occurrences.push({ date: new Date(d), timeFrom, timeTo });
|
occurrences.push({ date: new Date(d), timeFrom, timeTo });
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -10,15 +10,7 @@ import {
|
||||||
SidebarMenuButton,
|
SidebarMenuButton,
|
||||||
SidebarMenuItem,
|
SidebarMenuItem,
|
||||||
} from "@/src/components/ui/sidebar";
|
} from "@/src/components/ui/sidebar";
|
||||||
import {
|
import { CreditCard, DoorOpen, Dumbbell, IdCardLanyard, LayoutDashboard, User } from "lucide-react";
|
||||||
ChartNoAxesCombined,
|
|
||||||
CreditCard,
|
|
||||||
DoorOpen,
|
|
||||||
Dumbbell,
|
|
||||||
IdCardLanyard,
|
|
||||||
LayoutDashboard,
|
|
||||||
User,
|
|
||||||
} from "lucide-react";
|
|
||||||
|
|
||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
|
|
@ -101,12 +93,6 @@ const allMenuItems: MenuItem[] = [
|
||||||
roles: ["employee"],
|
roles: ["employee"],
|
||||||
permission: (p) => p.rooms.view,
|
permission: (p) => p.rooms.view,
|
||||||
},
|
},
|
||||||
{
|
|
||||||
icon: ChartNoAxesCombined,
|
|
||||||
label: "Marketing",
|
|
||||||
href: "/marketing",
|
|
||||||
roles: ["manager"],
|
|
||||||
},
|
|
||||||
];
|
];
|
||||||
|
|
||||||
interface DashboardSidebarProps {
|
interface DashboardSidebarProps {
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,7 @@ import { randomBytes } from "crypto";
|
||||||
import { sendMail } from "@/src/lib/mail/resend";
|
import { sendMail } from "@/src/lib/mail/resend";
|
||||||
import { generateOnboardingEmail } from "@/src/lib/mail/templates/employee-onboarding";
|
import { generateOnboardingEmail } from "@/src/lib/mail/templates/employee-onboarding";
|
||||||
import { generateOnboardingCompleteEmail } from "@/src/lib/mail/templates/onboarding-complete";
|
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
|
* Generates a secure onboarding token
|
||||||
|
|
@ -813,27 +814,29 @@ export async function completeEmployeeOnboarding(
|
||||||
return { success: false, error: "Invalid onboarding link" };
|
return { success: false, error: "Invalid onboarding link" };
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create the Supabase account via the API route
|
if (!PASSWORD_REGEX.test(data.password)) {
|
||||||
const baseUrl = process.env.NEXT_PUBLIC_APP_URL || "http://localhost:3000";
|
return {
|
||||||
const res = await fetch(`${baseUrl}/api/auth/register/employee`, {
|
success: false,
|
||||||
method: "POST",
|
error:
|
||||||
headers: { "Content-Type": "application/json" },
|
"Passwort muss mindestens 8 Zeichen, Groß-/Kleinbuchstaben, eine Zahl und ein Sonderzeichen enthalten",
|
||||||
body: JSON.stringify({
|
};
|
||||||
token,
|
}
|
||||||
email: employee.email,
|
|
||||||
password: data.password,
|
// Create the Supabase account directly (not via an HTTP self-fetch to the
|
||||||
firstName: data.firstName,
|
// API route — that would be blocked by Vercel Deployment Protection on
|
||||||
lastName: data.lastName,
|
// protected preview/staging deployments).
|
||||||
tel: data.tel,
|
const authResult = await createOrUpdateEmployeeAuthUser({
|
||||||
gender: data.gender,
|
email: employee.email,
|
||||||
qualification: data.qualification,
|
password: data.password,
|
||||||
}),
|
firstName: data.firstName,
|
||||||
|
lastName: data.lastName,
|
||||||
|
tel: data.tel,
|
||||||
|
gender: data.gender,
|
||||||
|
qualification: data.qualification,
|
||||||
});
|
});
|
||||||
|
|
||||||
const result = await res.json();
|
if (!authResult.success) {
|
||||||
|
return { success: false, error: authResult.error };
|
||||||
if (!res.ok) {
|
|
||||||
return { success: false, error: result.error || "Registration failed" };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Atomically mark the employee as onboarded — prevents TOCTOU race condition
|
// Atomically mark the employee as onboarded — prevents TOCTOU race condition
|
||||||
|
|
|
||||||
|
|
@ -407,7 +407,13 @@ export async function deleteRoom(roomId: string) {
|
||||||
});
|
});
|
||||||
|
|
||||||
if (futureCourses.length > 0) {
|
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 {
|
return {
|
||||||
success: false,
|
success: false,
|
||||||
error: `Raum kann nicht gelöscht werden, da er noch in ${futureCourses.length} zukünftigen Kursen verwendet wird: ${courseNames}`,
|
error: `Raum kann nicht gelöscht werden, da er noch in ${futureCourses.length} zukünftigen Kursen verwendet wird: ${courseNames}`,
|
||||||
|
|
|
||||||
|
|
@ -3,12 +3,15 @@
|
||||||
import { prisma } from "@/src/lib/prisma";
|
import { prisma } from "@/src/lib/prisma";
|
||||||
import { requireManager } from "@/src/lib/auth/getUser";
|
import { requireManager } from "@/src/lib/auth/getUser";
|
||||||
import { revalidateTag } from "next/cache";
|
import { revalidateTag } from "next/cache";
|
||||||
|
import { verifyAddressExists } from "@/src/lib/geocoding/verify-address";
|
||||||
|
|
||||||
type ActionResult = { success: boolean; error?: string };
|
type ActionResult = { success: boolean; error?: string };
|
||||||
|
|
||||||
export async function updateBusinessInfo(data: {
|
export async function updateBusinessInfo(data: {
|
||||||
businessId: string;
|
businessId: string;
|
||||||
address: string;
|
street: string;
|
||||||
|
plz: string;
|
||||||
|
place: string;
|
||||||
email: string;
|
email: string;
|
||||||
title: string;
|
title: string;
|
||||||
ustId?: string;
|
ustId?: string;
|
||||||
|
|
@ -27,8 +30,11 @@ export async function updateBusinessInfo(data: {
|
||||||
}
|
}
|
||||||
|
|
||||||
const email = data.email.trim();
|
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." };
|
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." };
|
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({
|
const updated = await prisma.business.update({
|
||||||
where: { id: data.businessId },
|
where: { id: data.businessId },
|
||||||
data: {
|
data: {
|
||||||
address: data.address.trim(),
|
address,
|
||||||
email,
|
email,
|
||||||
title: data.title.trim(),
|
title: data.title.trim(),
|
||||||
ustId: data.ustId?.trim() || null,
|
ustId: data.ustId?.trim() || null,
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ import { createClient } from "@/src/lib/supabase/server";
|
||||||
import { createAdminClient } from "@/src/lib/supabase/admin";
|
import { createAdminClient } from "@/src/lib/supabase/admin";
|
||||||
import { requireAuthWithData, isManager, isEmployee, isUser } from "@/src/lib/auth/getUser";
|
import { requireAuthWithData, isManager, isEmployee, isUser } from "@/src/lib/auth/getUser";
|
||||||
import { revalidateTag } from "next/cache";
|
import { revalidateTag } from "next/cache";
|
||||||
|
import { verifyAddressExists } from "@/src/lib/geocoding/verify-address";
|
||||||
|
|
||||||
type ActionResult = { success: boolean; error?: string };
|
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." };
|
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({
|
await prisma.user.update({
|
||||||
where: { id: userData.id },
|
where: { id: userData.id },
|
||||||
data: {
|
data: { street, plz, city },
|
||||||
street: data.street,
|
|
||||||
plz: data.plz,
|
|
||||||
city: data.city,
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
revalidateUserCache(userData.email);
|
revalidateUserCache(userData.email);
|
||||||
|
|
|
||||||
|
|
@ -82,10 +82,11 @@ const BusinessSettingsForm = ({
|
||||||
}
|
}
|
||||||
startTransition(async () => {
|
startTransition(async () => {
|
||||||
try {
|
try {
|
||||||
const address = `${street}, ${plz} ${place}`;
|
|
||||||
const result = await updateBusinessInfo({
|
const result = await updateBusinessInfo({
|
||||||
businessId,
|
businessId,
|
||||||
address,
|
street,
|
||||||
|
plz,
|
||||||
|
place,
|
||||||
email,
|
email,
|
||||||
title,
|
title,
|
||||||
ustId,
|
ustId,
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue