Kuma/src/modules/courses/actions/course-actions.ts
2026-10-02 01:28:49 +02:00

943 lines
31 KiB
TypeScript

/**
* Course Actions - Server-seitige Aktionen für Kursverwaltung
*
* Diese Datei enthält alle Server Actions für das Kurs-Management-System.
* Funktionen umfassen: Kurse erstellen, abrufen, aktualisieren und löschen.
* Alle Funktionen sind mit Authentifizierung und Autorisierung geschützt.
*/
"use server";
import { prisma } from "@/src/lib/prisma";
import { getUserData, isManager, isEmployee, getEffectiveManagerId } from "@/src/lib/auth/getUser";
import { courseSchema, publishedCourseSchema, type CourseFormData } from "../schemas/course-schema";
import { revalidatePath } from "next/cache";
import { refreshPublicCourses } from "../lib/public-courses-cache";
import { Prisma } from "@/src/generated/prisma/client";
import { generateUniqueSlug } from "@/src/lib/slug";
import {
generateOccurrences,
TooManyInstancesError,
type FrequencyType,
} from "../utils/generate-instances";
import { bookableSpots, parseFloorPlan } from "@/src/modules/rooms/lib/floor-plan";
import { paidSeatCounts } from "../lib/seat-counts";
type StandingOrderScope = "this" | "this_and_following" | "all";
/**
* Platzwahl braucht einen Raum mit Geräten, und es dürfen nicht mehr
* Teilnehmer zugelassen sein, als es Plätze gibt - sonst bekäme der letzte
* Kunde zwar eine Zahlung abgebucht, aber keinen Platz. Gibt eine
* Fehlermeldung zurück oder null, wenn alles passt.
*
* Liest nur Räume des eigenen Managers - sonst ließe sich mit einer fremden
* Raum-ID dessen Platzanzahl auslesen.
*/
async function validateSpotBooking(
data: { spotBooking?: boolean; room?: string; maxParticipants?: number },
managerId: string
): Promise<{ error: string; field: "room" | "maxParticipants" } | null> {
if (!data.spotBooking) return null;
if (!data.room) {
return { error: "Für die Platzwahl braucht der Kurs einen Raum", field: "room" };
}
const room = await prisma.room.findFirst({
where: { id: data.room, createdBy: managerId },
select: { floorPlan: true },
});
const spotCount = bookableSpots(parseFloorPlan(room?.floorPlan)).length;
if (spotCount === 0) {
return {
error: "Dieser Raum hat keinen Grundriss mit Geräten - Platzwahl ist hier nicht möglich",
field: "room",
};
}
if (data.maxParticipants && data.maxParticipants > spotCount) {
return {
error: `Höchstens ${spotCount} Teilnehmer - so viele Plätze hat der Raum`,
field: "maxParticipants",
};
}
return null;
}
/**
* Erstellt einen neuen Kurs in der Datenbank
*
* Diese Funktion validiert die Eingabedaten, überprüft die Berechtigungen des Benutzers
* und erstellt einen neuen Kurs. Nur Manager können Kurse erstellen.
*
* @param data - Die Kursdaten aus dem Formular
* @param status - Der Status des Kurses ("draft" für Entwurf oder "published" für veröffentlicht)
* @returns Ein Objekt mit success-Flag, optionaler courseId und Nachricht oder Fehler
*/
export async function createCourse(data: CourseFormData, status: "draft" | "published") {
try {
// Schritt 1: Aktuellen Benutzer abrufen
const userData = await getUserData();
// Schritt 2: Überprüfen, ob Benutzer angemeldet ist
if (!userData) {
return {
success: false,
error: "Sie müssen angemeldet sein, um einen Kurs zu erstellen",
};
}
// Schritt 3: Manager oder Employee mit Berechtigung
const canCreate =
isManager(userData) || (isEmployee(userData) && userData.permissions.courses.create);
if (!canCreate) {
return {
success: false,
error: "Keine Berechtigung zum Erstellen von Kursen",
};
}
// Schritt 4: Formulardaten mit Zod-Schema validieren
const schema = status === "published" ? publishedCourseSchema : courseSchema;
const validation = schema.safeParse(data);
if (!validation.success) {
return {
success: false,
error: "Validierungsfehler",
fieldErrors: validation.error.flatten().fieldErrors,
};
}
const validatedData = validation.data;
// Schritt 5: Manager-ID ermitteln (Employee erbt createdBy vom eigenen Datensatz)
const creatorId = getEffectiveManagerId(userData);
if (!creatorId)
return { success: false, error: "Mitarbeiter-Account ist keinem Manager zugeordnet" };
const spotError = await validateSpotBooking(validatedData, creatorId);
if (spotError) {
return {
success: false,
error: spotError.error,
fieldErrors: { [spotError.field]: [spotError.error] },
};
}
// Schritt 6: Kurs erstellen — bei Dauerauftrag Template + Instanzen, sonst einzelner Kurs
const commonFields = {
name: validatedData.name || "",
sport: validatedData.sport || [],
level: validatedData.level || "any",
timeFrom: validatedData.timeFrom || "",
timeTo: validatedData.timeTo || "",
trainers: validatedData.trainers || [],
room: validatedData.room || "",
description: validatedData.description || "",
coverImage: validatedData.coverImage ?? null,
maxParticipants: validatedData.maxParticipants || 0,
price: validatedData.price ?? 0,
spotBooking: validatedData.spotBooking ?? false,
frequency: validatedData.frequency || null,
weekdays: validatedData.weekdays || [],
weekdayTimings: validatedData.weekdayTimings
? (validatedData.weekdayTimings as Prisma.InputJsonValue)
: Prisma.JsonNull,
status,
createdBy: creatorId,
businessId: validatedData.businessId ?? null,
};
let course: { id: string };
const isCompleteStandingOrder =
validatedData.isStandingOrder && validatedData.frequency && validatedData.endDate;
if (isCompleteStandingOrder) {
// Vollständiger Dauerauftrag: Template + Instanzen in einer Transaction
const startDate = new Date(validatedData.date || new Date());
const endDate = new Date(validatedData.endDate!);
course = await prisma.$transaction(async (tx) => {
const template = await tx.course.create({
data: {
...commonFields,
date: startDate,
endDate,
isStandingOrder: true,
parentCourseId: null,
},
});
const occurrences = generateOccurrences({
startDate,
endDate,
frequency: validatedData.frequency as FrequencyType,
defaultTimeFrom: validatedData.timeFrom || "",
defaultTimeTo: validatedData.timeTo || "",
weekdays: validatedData.weekdays || [],
weekdayTimings: validatedData.weekdayTimings ?? {},
});
if (occurrences.length > 0) {
await tx.course.createMany({
data: occurrences.map((o) => ({
...commonFields,
date: o.date,
timeFrom: o.timeFrom,
timeTo: o.timeTo,
endDate: null,
isStandingOrder: false,
parentCourseId: template.id,
})),
});
}
return template;
});
} else if (validatedData.isStandingOrder) {
// Unvollständiger Dauerauftrag-Entwurf: als Template ohne Instanzen speichern
course = await prisma.course.create({
data: {
...commonFields,
date: new Date(validatedData.date || new Date()),
endDate: validatedData.endDate ? new Date(validatedData.endDate) : null,
isStandingOrder: true,
parentCourseId: null,
},
});
} else {
course = await prisma.course.create({
data: {
...commonFields,
date: new Date(validatedData.date || new Date()),
endDate: null,
isStandingOrder: false,
parentCourseId: null,
},
});
}
// Next.js Cache für /courses Seite invalidieren
revalidatePath("/courses");
refreshPublicCourses(validatedData.businessId);
return {
success: true,
courseId: course.id,
message:
status === "published"
? "Kurs erfolgreich veröffentlicht"
: "Entwurf erfolgreich gespeichert",
};
} catch (error) {
if (error instanceof TooManyInstancesError) {
return { success: false, error: error.message };
}
console.error("Error creating course:", error);
return {
success: false,
error: "Ein Fehler ist beim Erstellen des Kurses aufgetreten",
};
}
}
/**
* Ruft alle veröffentlichten Kurse ab
*
* Diese Funktion gibt alle Kurse zurück, die den Status "published" haben.
* Die Kurse werden nach Datum aufsteigend sortiert.
*
* @returns Ein Objekt mit success-Flag und einem Array von Kursen
*/
export async function getAllCourses() {
try {
// Alle veröffentlichten Kurse aus der Datenbank abrufen
// Sortiert nach Datum aufsteigend (älteste zuerst)
const courses = await prisma.course.findMany({
where: {
status: "published", // Nur veröffentlichte Kurse
},
orderBy: {
date: "asc", // Aufsteigende Sortierung nach Datum
},
});
return {
success: true,
courses,
};
} catch (error) {
console.error("Error fetching courses:", error);
return {
success: false,
error: "Fehler beim Laden der Kurse",
courses: [],
};
}
}
/**
* Fetches all courses created by the logged-in manager
*
* This function returns all courses created by the currently logged-in manager,
* regardless of status (draft or published). Supports optional date range filtering
* to implement efficient loading for large datasets.
*
* @param options - Optional filtering parameters
* @param options.dateFrom - Start date for filtering (inclusive)
* @param options.dateTo - End date for filtering (inclusive)
* @returns An object with success flag and an array of the manager's courses
*/
export async function getMyCourses(options?: { dateFrom?: Date; dateTo?: Date }) {
try {
// Step 1: Get current user
const userData = await getUserData();
// Step 2: Manager oder Employee mit Berechtigung
if (
!userData ||
(!isManager(userData) && !(isEmployee(userData) && userData.permissions.courses.view))
) {
return {
success: false,
error: "Unauthorized",
courses: [],
};
}
// Step 3: Manager-ID ermitteln (Employee erbt createdBy vom eigenen Datensatz)
const managerId = getEffectiveManagerId(userData);
if (!managerId)
return {
success: false,
error: "Mitarbeiter-Account ist keinem Manager zugeordnet",
courses: [],
};
// Step 4: Build the where clause with optional date filtering
const whereClause: Prisma.CourseWhereInput = {
createdBy: managerId,
isStandingOrder: false, // Templates ausblenden — nur Instanzen anzeigen
};
// Add date range filter if provided
if (options?.dateFrom || options?.dateTo) {
whereClause.date = {};
if (options.dateFrom) {
// Filter courses from this date onwards (inclusive)
whereClause.date.gte = options.dateFrom;
}
if (options.dateTo) {
// Filter courses up to this date (inclusive)
whereClause.date.lte = options.dateTo;
}
}
// Step 4: Fetch courses created by this manager with optional date filtering
const rows = await prisma.course.findMany({
where: whereClause,
orderBy: {
date: "asc", // Sort by course date ascending (oldest first)
},
});
// _count.bookings bleibt die Form, die die Kursliste erwartet, zählt aber
// belegte PLÄTZE - eine Mehrfachbuchung belegt mehrere.
const seatCounts = await paidSeatCounts(
prisma,
rows.map((c) => c.id)
);
const courses = rows.map((c) => ({
...c,
_count: { bookings: seatCounts.get(c.id) ?? 0 },
}));
return {
success: true,
courses,
};
} catch (error) {
console.error("Error fetching my courses:", error);
return {
success: false,
error: "Fehler beim Laden der Kurse",
courses: [],
};
}
}
/**
* Ruft einen einzelnen Kurs nach ID ab
*
* Diese Funktion gibt einen Kurs zurück, wenn der eingeloggte Manager
* der Ersteller des Kurses ist.
*
* @param courseId - Die eindeutige ID des Kurses
* @returns Ein Objekt mit success-Flag und dem Kurs oder Fehler
*/
export async function getCourseById(courseId: string) {
try {
// Schritt 1: Aktuellen Benutzer abrufen
const userData = await getUserData();
// Schritt 2: Manager oder Employee mit Berechtigung
if (
!userData ||
(!isManager(userData) && !(isEmployee(userData) && userData.permissions.courses.view))
) {
return {
success: false,
error: "Nur Manager können Kurse bearbeiten",
};
}
// Schritt 3: Effektive Manager-ID ermitteln
const managerId = getEffectiveManagerId(userData);
if (!managerId)
return { success: false, error: "Mitarbeiter-Account ist keinem Manager zugeordnet" };
// Schritt 4: Kurs aus der Datenbank abrufen
const course = await prisma.course.findUnique({
where: { id: courseId },
});
// Schritt 5: Überprüfen, ob Kurs existiert
if (!course) {
return {
success: false,
error: "Kurs nicht gefunden",
};
}
// Schritt 6: Überprüfen, ob der Manager der Besitzer des Kurses ist
if (course.createdBy !== managerId) {
return {
success: false,
error: "Sie können nur Ihre eigenen Kurse bearbeiten",
};
}
return {
success: true,
course,
};
} catch (error) {
console.error("Error fetching course:", error);
return {
success: false,
error: "Fehler beim Laden des Kurses",
};
}
}
/**
* Aktualisiert einen bestehenden Kurs
*
* Diese Funktion validiert die Eingabedaten, überprüft die Berechtigungen des Benutzers
* und aktualisiert einen bestehenden Kurs. Nur der Manager, der den Kurs erstellt hat,
* kann ihn bearbeiten.
*
* @param courseId - Die eindeutige ID des zu aktualisierenden Kurses
* @param data - Die aktualisierten Kursdaten aus dem Formular
* @param status - Der Status des Kurses ("draft" für Entwurf oder "published" für veröffentlicht)
* @returns Ein Objekt mit success-Flag und Nachricht oder Fehler
*/
export async function updateCourse(
courseId: string,
data: CourseFormData,
status: "draft" | "published",
scope: StandingOrderScope = "this",
confirmedStandingOrderConversion = false
) {
try {
// Schritt 1: Aktuellen Benutzer abrufen
const userData = await getUserData();
// Schritt 2: Überprüfen, ob Benutzer angemeldet ist
if (!userData) {
return {
success: false,
error: "Sie müssen angemeldet sein, um einen Kurs zu bearbeiten",
};
}
// Schritt 3: Manager oder Employee mit Berechtigung
const canEdit =
isManager(userData) || (isEmployee(userData) && userData.permissions.courses.edit);
if (!canEdit) {
return {
success: false,
error: "Keine Berechtigung zum Bearbeiten von Kursen",
};
}
// Schritt 4: Effektive Manager-ID ermitteln
const managerId = getEffectiveManagerId(userData);
if (!managerId)
return { success: false, error: "Mitarbeiter-Account ist keinem Manager zugeordnet" };
// Schritt 5: Kurs aus der Datenbank abrufen
const existingCourse = await prisma.course.findUnique({
where: { id: courseId },
});
// Schritt 6: Überprüfen, ob Kurs existiert
if (!existingCourse) {
return {
success: false,
error: "Kurs nicht gefunden",
};
}
// Schritt 7: Überprüfen, ob der Manager der Besitzer des Kurses ist
if (existingCourse.createdBy !== managerId) {
return {
success: false,
error: "Sie können nur Ihre eigenen Kurse bearbeiten",
};
}
// Schritt 8: Formulardaten mit Zod-Schema validieren
const schema = status === "published" ? publishedCourseSchema : courseSchema;
const validation = schema.safeParse(data);
if (!validation.success) {
return {
success: false,
error: "Validierungsfehler",
fieldErrors: validation.error.flatten().fieldErrors,
};
}
const validatedData = validation.data;
const spotError = await validateSpotBooking(validatedData, managerId);
if (spotError) {
return {
success: false,
error: spotError.error,
fieldErrors: { [spotError.field]: [spotError.error] },
};
}
// Felder, die auf Instanzen angewendet werden (ohne date/timeFrom/timeTo)
const sharedUpdateFields = {
name: validatedData.name || "",
sport: validatedData.sport || [],
level: validatedData.level || "any",
trainers: validatedData.trainers || [],
room: validatedData.room || "",
description: validatedData.description || "",
coverImage: validatedData.coverImage ?? null,
maxParticipants: validatedData.maxParticipants || 0,
price: validatedData.price ?? 0,
spotBooking: validatedData.spotBooking ?? false,
status,
...(validatedData.businessId !== undefined
? { businessId: validatedData.businessId || null }
: {}),
};
const isTemplate = !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!);
const txResult = await prisma.$transaction(async (tx) => {
// Re-check innerhalb der Transaktion: zwischen der Prüfung oben und
// hier kann der Stripe-Webhook (läuft außerhalb dieser Transaktion)
// eine neue "paid"-Buchung für diesen Kurs angelegt haben — z.B. wenn
// confirmedStandingOrderConversion bereits im ersten Request gesetzt
// war. Ohne diesen Re-Check könnte die Konvertierung die
// requiresStandingOrderConfirmation-Warnung umgehen.
if (!confirmedStandingOrderConversion) {
const currentBookingsCount = await tx.courseBooking.count({
where: { courseId, paymentStatus: "paid" },
});
if (currentBookingsCount > 0) {
return { blocked: true as const, existingBookingsCount: currentBookingsCount };
}
}
await tx.course.update({
where: { id: courseId },
data: {
...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 ?? {},
});
// 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,
})),
});
}
return { blocked: false as const };
});
if (txResult.blocked) {
return {
success: false,
requiresStandingOrderConfirmation: true,
existingBookingsCount: txResult.existingBookingsCount,
error:
"Dieser Kurs hat inzwischen eine neue Buchung erhalten. Beim Umwandeln in einen Dauerauftrag wird er zum Vorlagen-Termin und verschwindet aus der Kursliste — bestehende Buchungen bleiben erhalten, sind dann aber nur noch über den Kunden-Account einsehbar.",
};
}
// Schritt 9: Next.js Cache für /courses Seite invalidieren
revalidatePath("/courses");
revalidatePath(`/courses/edit/${courseId}`);
refreshPublicCourses(existingCourse.businessId, validatedData.businessId);
return {
success: true,
message:
status === "published"
? "Kurs erfolgreich aktualisiert und veröffentlicht"
: "Entwurf erfolgreich aktualisiert",
};
}
// Schritt 8: Kurs aktualisieren je nach Scope
if (scope === "this") {
await prisma.course.update({
where: { id: courseId },
data: {
...sharedUpdateFields,
date: new Date(validatedData.date || existingCourse.date),
timeFrom: validatedData.timeFrom || "",
timeTo: validatedData.timeTo || "",
},
});
} else if (scope === "this_and_following") {
await prisma.course.updateMany({
where: { parentCourseId: templateId, date: { gte: existingCourse.date } },
data: sharedUpdateFields,
});
} else if (scope === "all") {
if (isTemplate) {
await prisma.course.update({
where: { id: courseId },
data: {
...sharedUpdateFields,
isStandingOrder: validatedData.isStandingOrder,
frequency: validatedData.frequency || null,
weekdays: validatedData.weekdays || [],
weekdayTimings: validatedData.weekdayTimings
? (validatedData.weekdayTimings as Prisma.InputJsonValue)
: Prisma.JsonNull,
endDate: validatedData.endDate ? new Date(validatedData.endDate) : null,
},
});
}
await prisma.course.updateMany({
where: { parentCourseId: templateId },
data: sharedUpdateFields,
});
}
// Schritt 9: Next.js Cache für /courses Seite invalidieren
revalidatePath("/courses");
revalidatePath(`/courses/edit/${courseId}`);
refreshPublicCourses(existingCourse.businessId, validatedData.businessId);
return {
success: true,
message:
status === "published"
? "Kurs erfolgreich aktualisiert und veröffentlicht"
: "Entwurf erfolgreich aktualisiert",
};
} catch (error) {
if (error instanceof TooManyInstancesError) {
return { success: false, error: error.message };
}
console.error("Error updating course:", error);
return {
success: false,
error: "Ein Fehler ist beim Aktualisieren des Kurses aufgetreten",
};
}
}
/**
* Löscht einen Kurs aus der Datenbank
*
* Diese Funktion löscht einen Kurs, überprüft aber vorher:
* - Ob der Benutzer ein Manager ist
* - Ob der Kurs existiert
* - Ob der Manager der Besitzer des Kurses ist (Manager können nur eigene Kurse löschen)
*
* @param courseId - Die eindeutige ID des zu löschenden Kurses
* @returns Ein Objekt mit success-Flag und Nachricht oder Fehler
*/
export async function deleteCourse(courseId: string, scope: StandingOrderScope = "this") {
try {
// Schritt 1: Aktuellen Benutzer abrufen
const userData = await getUserData();
// Schritt 2: Manager oder Employee mit Berechtigung
if (
!userData ||
(!isManager(userData) && !(isEmployee(userData) && userData.permissions.courses.delete))
) {
return {
success: false,
error: "Keine Berechtigung zum Löschen von Kursen",
};
}
// Schritt 3: Effektive Manager-ID ermitteln
const managerId = getEffectiveManagerId(userData);
if (!managerId)
return { success: false, error: "Mitarbeiter-Account ist keinem Manager zugeordnet" };
// Schritt 4: Kurs aus der Datenbank abrufen
const course = await prisma.course.findUnique({
where: { id: courseId },
});
// Schritt 5: Überprüfen, ob Kurs existiert
if (!course) {
return {
success: false,
error: "Kurs nicht gefunden",
};
}
// Schritt 6: Überprüfen, ob der Manager der Besitzer des Kurses ist
if (course.createdBy !== managerId) {
return {
success: false,
error: "Sie können nur Ihre eigenen Kurse löschen",
};
}
// Schritt 7: Kurs löschen je nach Scope
const isTemplate = !course.parentCourseId;
const templateId = isTemplate ? course.id : course.parentCourseId!;
if (scope === "this") {
await prisma.course.delete({ where: { id: courseId } });
} else if (scope === "this_and_following") {
await prisma.course.deleteMany({
where: { parentCourseId: templateId, date: { gte: course.date } },
});
} else if (scope === "all") {
// Cascade löscht automatisch alle Kinder
await prisma.course.delete({ where: { id: templateId } });
}
// Schritt 8: Next.js Cache invalidieren
revalidatePath("/courses");
refreshPublicCourses(course.businessId);
return {
success: true,
message: "Kurs erfolgreich gelöscht",
};
} catch (error) {
console.error("Error deleting course:", error);
return {
success: false,
error: "Fehler beim Löschen des Kurses",
};
}
}
/**
* Alle verwendeten Sportarten des aktuellen Managers abrufen
*/
export async function getMySportTypes() {
try {
const userData = await getUserData();
if (
!userData ||
(!isManager(userData) && !(isEmployee(userData) && userData.permissions.courses.view))
) {
return { success: false, error: "Unauthorized", sports: [] };
}
const managerId = getEffectiveManagerId(userData);
if (!managerId)
return {
success: false,
error: "Mitarbeiter-Account ist keinem Manager zugeordnet",
sports: [],
};
const courses = await prisma.course.findMany({
where: { createdBy: managerId },
select: { sport: true },
});
const allSports = courses.flatMap((course) => course.sport);
const uniqueSports = [...new Set(allSports)];
const formattedSports = uniqueSports.map((sport) => ({
value: sport,
label: sport.charAt(0).toUpperCase() + sport.slice(1),
}));
return { success: true, sports: formattedSports };
} catch (error) {
console.error("Error fetching sport types:", error);
return { success: false, error: "Fehler beim Laden der Sportarten", sports: [] };
}
}
export async function getMyBusinesses() {
try {
const userData = await getUserData();
if (!userData || (!isManager(userData) && !isEmployee(userData))) {
return { success: false, error: "Unauthorized", businesses: [] };
}
const managerId = getEffectiveManagerId(userData);
if (!managerId)
return {
success: false,
error: "Mitarbeiter-Account ist keinem Manager zugeordnet",
businesses: [],
};
const businesses = await prisma.business.findMany({
where: { managerId },
select: { id: true, name: true, slug: true, isPublic: true },
orderBy: { name: "asc" },
});
return { success: true, businesses };
} catch (error) {
console.error("Error fetching businesses:", error);
return { success: false, error: "Fehler beim Laden der Businesses", businesses: [] };
}
}
export async function toggleBusinessPublic(businessId: string, isPublic: boolean) {
try {
const userData = await getUserData();
if (!userData || !isManager(userData)) {
return { success: false, error: "Keine Berechtigung" };
}
const business = await prisma.business.findUnique({
where: { id: businessId },
select: { managerId: true, slug: true, name: true },
});
if (!business || business.managerId !== userData.id) {
return { success: false, error: "Business nicht gefunden" };
}
// Generate slug on first publish if missing, retry on P2002 race condition
let slug = business.slug;
let updated = false;
while (!updated) {
if (!slug) {
slug = await generateUniqueSlug(business.name, prisma);
}
try {
await prisma.business.update({
where: { id: businessId },
data: { isPublic, slug },
});
updated = true;
} catch (err) {
if (err instanceof Prisma.PrismaClientKnownRequestError && err.code === "P2002") {
// Another request claimed this slug concurrently — generate a new one and retry
slug = null;
} else {
throw err;
}
}
}
// Auto-assign courses that belong to this manager but have no businessId yet.
// Only do this when there is exactly one business — if there are multiple,
// the manager must assign courses manually via the course edit form.
const totalBusinesses = await prisma.business.count({ where: { managerId: userData.id } });
if (totalBusinesses === 1) {
await prisma.course.updateMany({
where: { createdBy: userData.id, businessId: null },
data: { businessId },
});
}
revalidatePath("/settings");
revalidatePath(`/business/${slug}`);
refreshPublicCourses(businessId);
return { success: true, slug };
} catch (error) {
console.error("Error toggling business public:", error);
return { success: false, error: "Fehler beim Aktualisieren" };
}
}