fixed a couple of bugs

This commit is contained in:
Rajpreet0 2026-08-17 15:32:22 +02:00
parent e1a4a7cb72
commit 25ac296917
21 changed files with 779 additions and 210 deletions

View file

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

View file

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

View file

@ -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 },
}); });

View file

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

View file

@ -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 },
}); });

View file

@ -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 },

View 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 };
}

View 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");
}
}

View file

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

View file

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

View file

@ -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 */}

View file

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

View file

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

View file

@ -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) => {

View file

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

View file

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

View file

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

View file

@ -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}`,

View file

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

View file

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

View file

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