mirror of
https://github.com/Since-3/Kuma.git
synced 2026-10-12 21:41:17 +00:00
943 lines
31 KiB
TypeScript
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" };
|
|
}
|
|
}
|