see comments

This commit is contained in:
Rajpreet0 2026-08-26 16:15:24 +02:00
parent cffca6b2dd
commit 94d65c4517
5 changed files with 76 additions and 24 deletions

View file

@ -24,7 +24,12 @@ export async function previewCourseCancellation(
{ success: true; preview: CourseCancellationPreview } | { success: false; error: string }
> {
const userData = await getUserData();
if (!userData) return { success: false, error: "Nicht angemeldet." };
if (
!userData ||
(!isManager(userData) && !(isEmployee(userData) && userData.permissions.courses.delete))
) {
return { success: false, error: "Keine Berechtigung zum Stornieren von Kursen" };
}
const course = await prisma.course.findUnique({
where: { id: courseId },
@ -64,14 +69,23 @@ export type CourseCancellationResult = {
* verschwindet, iteriert danach über alle bezahlten Buchungen und erstattet
* jede zu 100% über die geteilte executeBookingCancellation-Logik
* (forceFullRefund: die Kunden trifft hier keine Schuld, anders als bei einer
* Selbst-Stornierung gilt daher nicht die zeitbasierte Policy). Schlägt eine
* einzelne Stripe-Erstattung fehl, läuft die Schleife für die übrigen
* Buchungen trotzdem weiter - der Manager sieht am Ende, wie viele
* fehlgeschlagen sind und kann sie manuell nachbearbeiten.
* Selbst-Stornierung gilt daher nicht die zeitbasierte Policy).
*
* Idempotent/retrybar: die Buchungs-Query filtert live auf paymentStatus:
* "paid", nicht auf einen vor der Schleife eingefrorenen Snapshot-Zustand des
* Kurses. Schlägt eine einzelne Stripe-Erstattung fehl, bleibt diese Buchung
* "paid" (executeBookingCancellation rollt den "refunding"-Zwischenstatus bei
* einem Stripe-Fehler zurück) und wird bei einem erneuten Aufruf dieser
* Funktion (z.B. erneuter Klick auf "Kurs absagen" durch den Manager) wieder
* gefunden und erneut versucht - deshalb gibt cancelCourse unten KEINEN Fehler
* zurück, wenn der Kurs bereits "cancelled" ist, sondern lässt einen Retry zu.
*/
async function executeCourseCancellation(courseId: string): Promise<CourseCancellationResult> {
await prisma.course.update({
where: { id: courseId },
// updateMany + Bedingung status != "cancelled" statt update: bei einem
// Retry (Kurs ist schon "cancelled") soll cancelledAt NICHT überschrieben
// werden - der ursprüngliche Absage-Zeitpunkt muss stehen bleiben.
await prisma.course.updateMany({
where: { id: courseId, status: { not: "cancelled" } },
data: { status: "cancelled", cancelledAt: new Date() },
});
@ -147,10 +161,11 @@ export async function cancelCourse(
if (course.createdBy !== managerId) {
return { success: false, error: "Sie können nur Ihre eigenen Kurse stornieren" };
}
if (course.status === "cancelled") {
return { success: false, error: "Dieser Kurs wurde bereits storniert" };
}
// Kein Block bei status === "cancelled": ein erneuter Aufruf muss möglich
// bleiben, damit Buchungen, deren Erstattung beim letzten Versuch
// fehlgeschlagen ist, erneut versucht werden können (siehe Docblock an
// executeCourseCancellation).
const result = await executeCourseCancellation(courseId);
if (course.businessId) {

View file

@ -8,10 +8,14 @@ interface UseCancelCourseOptions {
export function useCancelCourse({ onSuccess }: UseCancelCourseOptions = {}) {
const [cancelDialogOpen, setCancelDialogOpen] = useState(false);
const [courseToCancel, setCourseToCancel] = useState<{ id: string; name: string } | null>(null);
const [courseToCancel, setCourseToCancel] = useState<{
id: string;
name: string;
isRetry: boolean;
} | null>(null);
const handleCancelClick = (id: string, name: string) => {
setCourseToCancel({ id, name });
const handleCancelClick = (id: string, name: string, isRetry = false) => {
setCourseToCancel({ id, name, isRetry });
setCancelDialogOpen(true);
};

View file

@ -23,6 +23,9 @@ interface CancelCourseConfirmDialogProps {
onOpenChange: (open: boolean) => void;
courseId: string;
courseName: string;
/** true, wenn der Kurs bereits "cancelled" ist und dies ein Retry für zuvor
* fehlgeschlagene Erstattungen ist, statt der Erst-Absage. */
isRetry?: boolean;
onConfirm: () => Promise<
| { success: true; cancelled: number; failed: number; errors: string[] }
| { success: false; error: string }
@ -38,12 +41,14 @@ interface CancelCourseConfirmDialogProps {
function CancelCourseDialogBody({
courseId,
courseName,
isRetry,
onConfirm,
onClose,
onSuccess,
}: {
courseId: string;
courseName: string;
isRetry?: boolean;
onConfirm: CancelCourseConfirmDialogProps["onConfirm"];
onClose: () => void;
onSuccess: () => void;
@ -87,17 +92,25 @@ function CancelCourseDialogBody({
if (result.success) {
if (result.failed > 0) {
toast.warning(
`Kurs abgesagt. ${result.cancelled} Erstattung(en) erfolgreich, ${result.failed} fehlgeschlagen — bitte manuell prüfen.`
`${result.cancelled} Erstattung(en) erfolgreich, ${result.failed} weiterhin fehlgeschlagen — bitte manuell prüfen.`
);
} else {
toast.success("Kurs abgesagt und alle Kunden erstattet.");
toast.success(
isRetry
? "Verbleibende Erstattungen erfolgreich."
: "Kurs abgesagt und alle Kunden erstattet."
);
}
onSuccess();
} else {
toast.error(result.error);
}
} catch {
toast.error("Kurs konnte nicht abgesagt werden.");
toast.error(
isRetry
? "Erstattungen konnten nicht erneut versucht werden."
: "Kurs konnte nicht abgesagt werden."
);
} finally {
setIsPending(false);
}
@ -106,10 +119,19 @@ function CancelCourseDialogBody({
return (
<>
<DialogHeader>
<DialogTitle>Kurs absagen?</DialogTitle>
<DialogTitle>{isRetry ? "Erstattungen erneut versuchen?" : "Kurs absagen?"}</DialogTitle>
<DialogDescription>
<strong>{courseName}</strong> wird als abgesagt markiert und verschwindet sofort aus der
öffentlichen Buchungsseite. Dieser Vorgang kann nicht rückgängig gemacht werden.
{isRetry ? (
<>
<strong>{courseName}</strong> ist bereits als abgesagt markiert. Für zuvor
fehlgeschlagene Erstattungen wird ein neuer Versuch gestartet.
</>
) : (
<>
<strong>{courseName}</strong> wird als abgesagt markiert und verschwindet sofort aus
der öffentlichen Buchungsseite. Dieser Vorgang kann nicht rückgängig gemacht werden.
</>
)}
</DialogDescription>
</DialogHeader>
@ -127,7 +149,8 @@ function CancelCourseDialogBody({
</p>
) : (
<p>
Es gibt keine bezahlten Buchungen für diesen Kurs — es wird niemand benachrichtigt.
Es gibt keine offenen bezahlten Buchungen für diesen Kurs — es wird niemand
benachrichtigt.
</p>
)}
</div>
@ -142,7 +165,7 @@ function CancelCourseDialogBody({
onClick={handleConfirm}
disabled={isPending || loadingPreview}
>
{isPending ? "Wird abgesagt…" : "Ja, Kurs absagen"}
{isPending ? "Wird verarbeitet…" : isRetry ? "Ja, erneut versuchen" : "Ja, Kurs absagen"}
</Button>
</DialogFooter>
</>
@ -154,6 +177,7 @@ const CancelCourseConfirmDialog = ({
onOpenChange,
courseId,
courseName,
isRetry,
onConfirm,
onSuccess,
}: CancelCourseConfirmDialogProps) => {
@ -165,6 +189,7 @@ const CancelCourseConfirmDialog = ({
key={courseId}
courseId={courseId}
courseName={courseName}
isRetry={isRetry}
onConfirm={onConfirm}
onClose={() => onOpenChange(false)}
onSuccess={() => {

View file

@ -234,11 +234,11 @@ const CourseListItem: React.FC<CourseListItemProps> = ({
<Link size={20} className="text-blue-600" />
</button>
)}
{status === "published" && !isPast && onCancel && (
{(status === "published" || status === "cancelled") && !isPast && onCancel && (
<button
onClick={onCancel}
className="p-2 rounded-md hover:bg-red-500/10 transition"
title="Kurs absagen"
title={status === "cancelled" ? "Erstattung erneut versuchen" : "Kurs absagen"}
>
<Ban size={20} className="text-red-600" />
</button>

View file

@ -485,7 +485,14 @@ const CourseListView = ({
}
onEdit={canEdit ? () => router.push(`/courses/edit/${course.id}`) : undefined}
onCancel={
canDelete ? () => handleCancelClick(course.id, course.name) : undefined
canDelete
? () =>
handleCancelClick(
course.id,
course.name,
course.status === "cancelled"
)
: undefined
}
/>
))}
@ -532,6 +539,7 @@ const CourseListView = ({
}}
courseId={courseToCancel.id}
courseName={courseToCancel.name}
isRetry={courseToCancel.isRetry}
onConfirm={handleCancelConfirm}
onSuccess={() => setCourseToCancel(null)}
/>