mirror of
https://github.com/Since-3/Kuma.git
synced 2026-10-12 19:51:17 +00:00
Merge pull request #166 from Since-3/feature/publish-draft-button
added button to publish all drafts at once
This commit is contained in:
commit
b5703aaf8e
4 changed files with 1133 additions and 1 deletions
291
src/modules/courses/actions/publish-drafts-actions.ts
Normal file
291
src/modules/courses/actions/publish-drafts-actions.ts
Normal file
|
|
@ -0,0 +1,291 @@
|
|||
/**
|
||||
* Publish-Drafts Actions — Kurs-Entwürfe gesammelt veröffentlichen
|
||||
*
|
||||
* Veröffentlichen ist kein reiner Status-Flip: publishedCourseSchema verlangt
|
||||
* Pflichtfelder (Name, Sportart, Datum, Zeiten, Raum, Teilnehmer, Preis), die
|
||||
* ein Entwurf leer lassen darf. Ein blindes updateMany würde also unvollständige
|
||||
* Kurse öffentlich buchbar machen. Beide Actions hier validieren deshalb jeden
|
||||
* Kurs einzeln gegen dasselbe Schema wie updateCourse.
|
||||
*/
|
||||
|
||||
"use server";
|
||||
|
||||
import { prisma } from "@/src/lib/prisma";
|
||||
import { getUserData, isManager, isEmployee, getEffectiveManagerId } from "@/src/lib/auth/getUser";
|
||||
import { publishedCourseSchema } from "../schemas/course-schema";
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { Prisma } from "@/src/generated/prisma/client";
|
||||
|
||||
/** Ein Entwurf in der Vorschau, inkl. Ergebnis der Veröffentlichbarkeits-Prüfung. */
|
||||
export type DraftCandidate = {
|
||||
id: string;
|
||||
name: string;
|
||||
date: Date;
|
||||
timeFrom: string;
|
||||
timeTo: string;
|
||||
room: string;
|
||||
price: number;
|
||||
maxParticipants: number;
|
||||
sport: string[];
|
||||
/** true, wenn der Kurs Teil eines Dauerauftrags ist (für das Serien-Badge). */
|
||||
isSeriesInstance: boolean;
|
||||
/** Leeres Array = veröffentlichbar. Sonst die fehlenden Pflichtfelder in Klartext. */
|
||||
missingFields: string[];
|
||||
};
|
||||
|
||||
/** Feldname → deutsches Label für die "fehlt:"-Anzeige im Dialog. */
|
||||
const FIELD_LABELS: Record<string, string> = {
|
||||
name: "Name",
|
||||
sport: "Sportart",
|
||||
date: "Datum",
|
||||
timeFrom: "Anfangszeit",
|
||||
timeTo: "Endzeit",
|
||||
trainers: "Trainer",
|
||||
room: "Raum",
|
||||
maxParticipants: "Teilnehmerzahl",
|
||||
price: "Preis",
|
||||
frequency: "Häufigkeit",
|
||||
endDate: "Enddatum",
|
||||
weekdays: "Wochentage",
|
||||
};
|
||||
|
||||
/**
|
||||
* Date → "yyyy-mm-dd" aus den LOKALEN Komponenten, nicht via toISOString():
|
||||
* letzteres rechnet nach UTC um und verschiebt ein Datum je nach Zeitzone um
|
||||
* einen Tag, was bei der Datums-Validierung (z.B. endDate > date) zu falschen
|
||||
* Ergebnissen führt. Gleiche Umwandlung wie im Kursformular (course-create-view).
|
||||
*/
|
||||
function toDateInput(date: Date): string {
|
||||
return [
|
||||
date.getFullYear(),
|
||||
String(date.getMonth() + 1).padStart(2, "0"),
|
||||
String(date.getDate()).padStart(2, "0"),
|
||||
].join("-");
|
||||
}
|
||||
|
||||
/** Prisma-Kurs → das Objekt, das publishedCourseSchema erwartet (ISO-Datum statt Date). */
|
||||
function toFormData(course: {
|
||||
name: string;
|
||||
sport: string[];
|
||||
level: string;
|
||||
date: Date;
|
||||
timeFrom: string;
|
||||
timeTo: string;
|
||||
trainers: string[];
|
||||
room: string;
|
||||
description: string;
|
||||
coverImage: string | null;
|
||||
maxParticipants: number;
|
||||
price: number;
|
||||
isStandingOrder: boolean;
|
||||
frequency: string | null;
|
||||
weekdays: string[];
|
||||
endDate: Date | null;
|
||||
}) {
|
||||
return {
|
||||
name: course.name,
|
||||
sport: course.sport,
|
||||
level: course.level,
|
||||
date: toDateInput(course.date),
|
||||
timeFrom: course.timeFrom,
|
||||
timeTo: course.timeTo,
|
||||
trainers: course.trainers,
|
||||
room: course.room,
|
||||
description: course.description,
|
||||
coverImage: course.coverImage ?? undefined,
|
||||
maxParticipants: course.maxParticipants,
|
||||
price: course.price,
|
||||
isStandingOrder: course.isStandingOrder,
|
||||
frequency: course.frequency ?? undefined,
|
||||
weekdays: course.weekdays,
|
||||
endDate: course.endDate ? toDateInput(course.endDate) : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Prüft einen Kurs gegen publishedCourseSchema und gibt die fehlenden Felder
|
||||
* als deutsche Labels zurück. Leeres Array = veröffentlichbar.
|
||||
*/
|
||||
function getMissingFields(course: Parameters<typeof toFormData>[0]): string[] {
|
||||
const validation = publishedCourseSchema.safeParse(toFormData(course));
|
||||
if (validation.success) return [];
|
||||
|
||||
const fieldErrors = validation.error.flatten().fieldErrors;
|
||||
const missing = Object.keys(fieldErrors).map((field) => FIELD_LABELS[field] ?? field);
|
||||
|
||||
// Refinements ohne path (z.B. Zeit-Vergleiche) landen in formErrors und
|
||||
// würden sonst als "veröffentlichbar" durchgehen, obwohl das Schema ablehnt.
|
||||
if (missing.length === 0) {
|
||||
const formErrors = validation.error.flatten().formErrors;
|
||||
return formErrors.length > 0 ? formErrors : ["Unvollständig"];
|
||||
}
|
||||
|
||||
return Array.from(new Set(missing));
|
||||
}
|
||||
|
||||
/** Gemeinsame Auth-Prüfung: Nur Manager/Employees mit edit-Recht dürfen veröffentlichen. */
|
||||
async function authorize() {
|
||||
const userData = await getUserData();
|
||||
if (!userData) {
|
||||
return { ok: false as const, error: "Sie müssen angemeldet sein" };
|
||||
}
|
||||
|
||||
const canEdit =
|
||||
isManager(userData) || (isEmployee(userData) && userData.permissions.courses.edit);
|
||||
if (!canEdit) {
|
||||
return { ok: false as const, error: "Keine Berechtigung zum Veröffentlichen von Kursen" };
|
||||
}
|
||||
|
||||
const managerId = getEffectiveManagerId(userData);
|
||||
if (!managerId) {
|
||||
return { ok: false as const, error: "Mitarbeiter-Account ist keinem Manager zugeordnet" };
|
||||
}
|
||||
|
||||
return { ok: true as const, managerId };
|
||||
}
|
||||
|
||||
/**
|
||||
* Lädt alle Entwürfe des Managers im angegebenen Zeitraum und markiert je Kurs,
|
||||
* ob er veröffentlicht werden kann. Dauerauftrag-Templates bleiben außen vor —
|
||||
* genau wie in getMyCourses zählen nur die sichtbaren Instanzen.
|
||||
*
|
||||
* @param options - Zeitraum-Grenzen (beide inklusive, beide optional)
|
||||
*/
|
||||
export async function getPublishableDrafts(options?: { dateFrom?: Date; dateTo?: Date }) {
|
||||
try {
|
||||
const auth = await authorize();
|
||||
if (!auth.ok) return { success: false as const, error: auth.error, drafts: [] };
|
||||
|
||||
const whereClause: Prisma.CourseWhereInput = {
|
||||
createdBy: auth.managerId,
|
||||
status: "draft",
|
||||
isStandingOrder: false,
|
||||
};
|
||||
|
||||
if (options?.dateFrom || options?.dateTo) {
|
||||
whereClause.date = {};
|
||||
if (options.dateFrom) whereClause.date.gte = options.dateFrom;
|
||||
if (options.dateTo) whereClause.date.lte = options.dateTo;
|
||||
}
|
||||
|
||||
const courses = await prisma.course.findMany({
|
||||
where: whereClause,
|
||||
orderBy: { date: "asc" },
|
||||
});
|
||||
|
||||
const drafts: DraftCandidate[] = courses.map((course) => ({
|
||||
id: course.id,
|
||||
name: course.name,
|
||||
date: course.date,
|
||||
timeFrom: course.timeFrom,
|
||||
timeTo: course.timeTo,
|
||||
room: course.room,
|
||||
price: course.price,
|
||||
maxParticipants: course.maxParticipants,
|
||||
sport: course.sport,
|
||||
isSeriesInstance: course.parentCourseId !== null,
|
||||
missingFields: getMissingFields(course),
|
||||
}));
|
||||
|
||||
return { success: true as const, drafts };
|
||||
} catch (error) {
|
||||
console.error("Error fetching publishable drafts:", error);
|
||||
return { success: false as const, error: "Fehler beim Laden der Entwürfe", drafts: [] };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Veröffentlicht die übergebenen Entwürfe.
|
||||
*
|
||||
* Die IDs kommen aus dem Client und werden deshalb serverseitig komplett neu
|
||||
* geprüft: Besitz (createdBy), Status (nur "draft" — ein bereits abgesagter Kurs
|
||||
* darf nicht über diesen Weg wiederbelebt werden) und Vollständigkeit. Kurse,
|
||||
* die durchfallen, werden übersprungen statt den ganzen Vorgang abzubrechen —
|
||||
* der Nutzer bekommt sie im Ergebnis namentlich zurückgemeldet.
|
||||
*
|
||||
* @param courseIds - IDs der zu veröffentlichenden Entwürfe
|
||||
*/
|
||||
export async function publishDraftCourses(courseIds: string[]) {
|
||||
try {
|
||||
const auth = await authorize();
|
||||
if (!auth.ok) return { success: false as const, error: auth.error };
|
||||
|
||||
if (courseIds.length === 0) {
|
||||
return { success: false as const, error: "Keine Kurse ausgewählt" };
|
||||
}
|
||||
|
||||
// Nur eigene Entwürfe — fremde oder bereits veröffentlichte IDs fallen hier raus.
|
||||
const courses = await prisma.course.findMany({
|
||||
where: {
|
||||
id: { in: courseIds },
|
||||
createdBy: auth.managerId,
|
||||
status: "draft",
|
||||
isStandingOrder: false,
|
||||
},
|
||||
});
|
||||
|
||||
const publishableIds: string[] = [];
|
||||
const skipped: { name: string; reason: string }[] = [];
|
||||
|
||||
for (const course of courses) {
|
||||
const missing = getMissingFields(course);
|
||||
if (missing.length === 0) {
|
||||
publishableIds.push(course.id);
|
||||
} else {
|
||||
skipped.push({
|
||||
name: course.name || "Unbenannter Entwurf",
|
||||
reason: `fehlt: ${missing.join(", ")}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// IDs, die die DB-Abfrage gar nicht zurückgegeben hat (gelöscht, fremd oder
|
||||
// inzwischen nicht mehr "draft"), sind übersprungen.
|
||||
const foundIds = new Set(courses.map((c) => c.id));
|
||||
let vanishedCount = courseIds.filter((id) => !foundIds.has(id)).length;
|
||||
|
||||
let publishedIds: string[] = [];
|
||||
if (publishableIds.length > 0) {
|
||||
// updateManyAndReturn liefert genau die Zeilen zurück, die DIESES Statement
|
||||
// geändert hat. Ein nachgelagertes findMany auf status "published" würde
|
||||
// dagegen auch Kurse zurückgeben, die ein paralleler Request zwischen dem
|
||||
// findMany oben und hier veröffentlicht hat — die würden wir uns fälschlich
|
||||
// zuschreiben und an den Client melden. status: "draft" im where sorgt dafür,
|
||||
// dass ein zwischenzeitlich abgesagter Kurs nicht doch noch live geht.
|
||||
const updated = await prisma.course.updateManyAndReturn({
|
||||
where: {
|
||||
id: { in: publishableIds },
|
||||
createdBy: auth.managerId,
|
||||
status: "draft",
|
||||
// Muss dieselbe Bedingung tragen wie die Auswahl-Abfrage oben:
|
||||
// updateCourse kann einen Entwurf parallel in einen Dauerauftrag
|
||||
// umwandeln (isStandingOrder: true bei unverändertem status "draft").
|
||||
// Ohne dieses Prädikat würde das Template hier veröffentlicht — und
|
||||
// wäre live, ohne je als Termin in der Kursliste zu erscheinen.
|
||||
isStandingOrder: false,
|
||||
},
|
||||
data: { status: "published" },
|
||||
select: { id: true },
|
||||
});
|
||||
|
||||
publishedIds = updated.map((c) => c.id);
|
||||
|
||||
// Differenz = Kurse, die zwischen Prüfung und Update von woanders
|
||||
// verändert wurden. Sie zählen als übersprungen, nicht als veröffentlicht.
|
||||
vanishedCount += publishableIds.length - publishedIds.length;
|
||||
}
|
||||
|
||||
revalidatePath("/courses");
|
||||
|
||||
return {
|
||||
success: true as const,
|
||||
published: publishedIds.length,
|
||||
publishedIds,
|
||||
skipped,
|
||||
vanished: vanishedCount,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error("Error publishing draft courses:", error);
|
||||
return { success: false as const, error: "Fehler beim Veröffentlichen der Entwürfe" };
|
||||
}
|
||||
}
|
||||
469
src/modules/courses/ui/components/PublishDraftsDialog.tsx
Normal file
469
src/modules/courses/ui/components/PublishDraftsDialog.tsx
Normal file
|
|
@ -0,0 +1,469 @@
|
|||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { AlertTriangle, ChevronLeft, ChevronRight, Pen, Search } from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { toast } from "sonner";
|
||||
import { Button } from "@/src/components/ui/button";
|
||||
import { Checkbox } from "@/src/components/ui/checkbox";
|
||||
import { Input } from "@/src/components/ui/input";
|
||||
import { Label } from "@/src/components/ui/label";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/src/components/ui/dialog";
|
||||
import {
|
||||
getPublishableDrafts,
|
||||
publishDraftCourses,
|
||||
type DraftCandidate,
|
||||
} from "../../actions/publish-drafts-actions";
|
||||
|
||||
const PAGE_SIZE = 8;
|
||||
|
||||
interface PublishDraftsDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
/** Vorbelegung der Zeitraum-Felder (ISO yyyy-mm-dd), i.d.R. aus dem Listenfilter. */
|
||||
defaultDateFrom: string;
|
||||
defaultDateTo: string;
|
||||
/** Wird nach erfolgreichem Veröffentlichen aufgerufen, um die Liste zu aktualisieren. */
|
||||
onPublished: (publishedIds: string[]) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Das Jahr wird mitgeschrieben, weil der Zeitraum ohne "bis"-Grenze über
|
||||
* Jahreswechsel hinausreichen kann — "Mi., 06.01." wäre sonst mehrdeutig.
|
||||
*/
|
||||
const formatDate = (date: Date) =>
|
||||
new Date(date).toLocaleDateString("de-DE", {
|
||||
weekday: "short",
|
||||
day: "2-digit",
|
||||
month: "2-digit",
|
||||
year: "2-digit",
|
||||
});
|
||||
|
||||
const formatEuro = (price: number) =>
|
||||
price.toLocaleString("de-DE", { style: "currency", currency: "EUR" });
|
||||
|
||||
/**
|
||||
* "yyyy-mm-dd" → lokaler Tagesanfang bzw. -ende. new Date("2026-10-01") würde
|
||||
* als UTC-Mitternacht geparst; der Kurs-Vergleich in der DB läuft aber gegen
|
||||
* lokale Zeitstempel, wodurch je nach Zeitzone der Rand-Tag herausfiele.
|
||||
*/
|
||||
const parseLocalDate = (value: string, endOfDay: boolean) => {
|
||||
const [year, month, day] = value.split("-").map(Number);
|
||||
return endOfDay
|
||||
? new Date(year, month - 1, day, 23, 59, 59, 999)
|
||||
: new Date(year, month - 1, day, 0, 0, 0, 0);
|
||||
};
|
||||
|
||||
const startOfLocalDay = (value: string) => parseLocalDate(value, false);
|
||||
const endOfLocalDay = (value: string) => parseLocalDate(value, true);
|
||||
|
||||
/**
|
||||
* Wird pro Öffnen frisch gemounted (key am Aufrufer), damit Auswahl, Suche und
|
||||
* Seite bei jedem Öffnen zurückgesetzt sind — dasselbe Muster wie in
|
||||
* CancelCourseConfirmDialog.
|
||||
*/
|
||||
function PublishDraftsDialogBody({
|
||||
defaultDateFrom,
|
||||
defaultDateTo,
|
||||
onClose,
|
||||
onPublished,
|
||||
}: {
|
||||
defaultDateFrom: string;
|
||||
defaultDateTo: string;
|
||||
onClose: () => void;
|
||||
onPublished: (publishedIds: string[]) => void;
|
||||
}) {
|
||||
const router = useRouter();
|
||||
|
||||
const [dateFrom, setDateFrom] = useState(defaultDateFrom);
|
||||
const [dateTo, setDateTo] = useState(defaultDateTo);
|
||||
const [drafts, setDrafts] = useState<DraftCandidate[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [isPending, setIsPending] = useState(false);
|
||||
const [search, setSearch] = useState("");
|
||||
const [page, setPage] = useState(0);
|
||||
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
|
||||
|
||||
// Entwürfe bei Öffnen und nach jeder Zeitraum-Änderung neu laden. Die Auswahl
|
||||
// wird dabei bewusst zurückgesetzt: ein Kurs außerhalb des neuen Zeitraums
|
||||
// wäre sonst unsichtbar ausgewählt und würde beim Veröffentlichen überraschen.
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
// Schlägt das Laden fehl, muss der alte Stand weg: publishDraftCourses
|
||||
// bekommt den Zeitraum nicht mit und würde einen noch gültigen Entwurf aus
|
||||
// dem VORHERIGEN Zeitraum anstandslos veröffentlichen, wenn er nach dem
|
||||
// Fehler ausgewählt im Dialog stehen bleibt.
|
||||
const clearDrafts = () => {
|
||||
setDrafts([]);
|
||||
setSelectedIds(new Set());
|
||||
setPage(0);
|
||||
};
|
||||
|
||||
(async () => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const result = await getPublishableDrafts({
|
||||
dateFrom: dateFrom ? startOfLocalDay(dateFrom) : undefined,
|
||||
dateTo: dateTo ? endOfLocalDay(dateTo) : undefined,
|
||||
});
|
||||
if (cancelled) return;
|
||||
|
||||
if (result.success) {
|
||||
setDrafts(result.drafts);
|
||||
// Standardmäßig ist alles Veröffentlichbare vorausgewählt — der
|
||||
// häufigste Fall ist "alle live stellen".
|
||||
setSelectedIds(
|
||||
new Set(result.drafts.filter((d) => d.missingFields.length === 0).map((d) => d.id))
|
||||
);
|
||||
setPage(0);
|
||||
} else {
|
||||
clearDrafts();
|
||||
toast.error(result.error);
|
||||
}
|
||||
} catch {
|
||||
if (!cancelled) {
|
||||
clearDrafts();
|
||||
toast.error("Entwürfe konnten nicht geladen werden.");
|
||||
}
|
||||
} finally {
|
||||
if (!cancelled) setIsLoading(false);
|
||||
}
|
||||
})();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [dateFrom, dateTo]);
|
||||
|
||||
const readyDrafts = useMemo(() => drafts.filter((d) => d.missingFields.length === 0), [drafts]);
|
||||
const incompleteDrafts = useMemo(
|
||||
() => drafts.filter((d) => d.missingFields.length > 0),
|
||||
[drafts]
|
||||
);
|
||||
|
||||
const filteredReady = useMemo(() => {
|
||||
const term = search.trim().toLowerCase();
|
||||
if (!term) return readyDrafts;
|
||||
return readyDrafts.filter(
|
||||
(d) =>
|
||||
d.name.toLowerCase().includes(term) ||
|
||||
d.room.toLowerCase().includes(term) ||
|
||||
d.sport.some((s) => s.toLowerCase().includes(term))
|
||||
);
|
||||
}, [readyDrafts, search]);
|
||||
|
||||
const pageCount = Math.max(1, Math.ceil(filteredReady.length / PAGE_SIZE));
|
||||
// Nach dem Tippen in der Suche kann die aktuelle Seite hinter dem Ende liegen.
|
||||
const safePage = Math.min(page, pageCount - 1);
|
||||
const pageItems = filteredReady.slice(safePage * PAGE_SIZE, safePage * PAGE_SIZE + PAGE_SIZE);
|
||||
|
||||
const toggleOne = (id: string) => {
|
||||
setSelectedIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(id)) next.delete(id);
|
||||
else next.add(id);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
// "Alle auswählen" bezieht sich auf die aktuelle Suchtreffermenge, nicht nur
|
||||
// auf die sichtbare Seite — sonst müsste man sich durch alle Seiten klicken.
|
||||
const allFilteredSelected =
|
||||
filteredReady.length > 0 && filteredReady.every((d) => selectedIds.has(d.id));
|
||||
|
||||
const toggleAllFiltered = () => {
|
||||
setSelectedIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (allFilteredSelected) {
|
||||
filteredReady.forEach((d) => next.delete(d.id));
|
||||
} else {
|
||||
filteredReady.forEach((d) => next.add(d.id));
|
||||
}
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const handlePublish = async () => {
|
||||
const ids = Array.from(selectedIds);
|
||||
if (ids.length === 0) return;
|
||||
|
||||
setIsPending(true);
|
||||
try {
|
||||
const result = await publishDraftCourses(ids);
|
||||
|
||||
if (!result.success) {
|
||||
toast.error(result.error);
|
||||
return;
|
||||
}
|
||||
|
||||
if (result.published === 0) {
|
||||
toast.error("Es wurde kein Kurs veröffentlicht.");
|
||||
return;
|
||||
}
|
||||
|
||||
const skippedCount = result.skipped.length + result.vanished;
|
||||
if (skippedCount > 0) {
|
||||
toast.warning(
|
||||
`${result.published} Kurs(e) veröffentlicht, ${skippedCount} übersprungen: ` +
|
||||
result.skipped
|
||||
.slice(0, 3)
|
||||
.map((s) => `${s.name} (${s.reason})`)
|
||||
.join("; ")
|
||||
);
|
||||
} else {
|
||||
toast.success(
|
||||
result.published === 1
|
||||
? "Kurs veröffentlicht."
|
||||
: `${result.published} Kurse veröffentlicht.`
|
||||
);
|
||||
}
|
||||
|
||||
onPublished(result.publishedIds);
|
||||
onClose();
|
||||
} catch {
|
||||
toast.error("Kurse konnten nicht veröffentlicht werden.");
|
||||
} finally {
|
||||
setIsPending(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Entwürfe veröffentlichen</DialogTitle>
|
||||
<DialogDescription>
|
||||
Ausgewählte Entwürfe werden sofort öffentlich buchbar. Kurse mit fehlenden Pflichtfeldern
|
||||
können nicht veröffentlicht werden.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{/* Zeitraum */}
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="publish-date-from" className="text-xs text-gray-500">
|
||||
Zeitraum von
|
||||
</Label>
|
||||
<Input
|
||||
id="publish-date-from"
|
||||
type="date"
|
||||
value={dateFrom}
|
||||
onChange={(e) => setDateFrom(e.target.value)}
|
||||
disabled={isPending}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="publish-date-to" className="text-xs text-gray-500">
|
||||
bis <span className="text-gray-400">(leer = alle)</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="publish-date-to"
|
||||
type="date"
|
||||
value={dateTo}
|
||||
onChange={(e) => setDateTo(e.target.value)}
|
||||
disabled={isPending}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<p className="py-8 text-center text-sm text-gray-500">Entwürfe werden geladen…</p>
|
||||
) : drafts.length === 0 ? (
|
||||
<p className="py-8 text-center text-sm text-gray-500">
|
||||
In diesem Zeitraum gibt es keine Entwürfe.
|
||||
</p>
|
||||
) : (
|
||||
<>
|
||||
{/* Suche */}
|
||||
{readyDrafts.length > PAGE_SIZE && (
|
||||
<div className="relative">
|
||||
<Search
|
||||
size={16}
|
||||
className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400 pointer-events-none"
|
||||
/>
|
||||
<Input
|
||||
placeholder="Nach Name, Raum oder Sportart suchen…"
|
||||
value={search}
|
||||
onChange={(e) => {
|
||||
setSearch(e.target.value);
|
||||
setPage(0);
|
||||
}}
|
||||
disabled={isPending}
|
||||
className="pl-9"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Veröffentlichbare Entwürfe */}
|
||||
{readyDrafts.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2.5 pb-2 border-b border-gray-200">
|
||||
<Checkbox
|
||||
id="publish-select-all"
|
||||
checked={allFilteredSelected}
|
||||
onCheckedChange={toggleAllFiltered}
|
||||
disabled={isPending || filteredReady.length === 0}
|
||||
/>
|
||||
<Label
|
||||
htmlFor="publish-select-all"
|
||||
className="text-sm font-medium cursor-pointer select-none"
|
||||
>
|
||||
Alle auswählen ({filteredReady.length})
|
||||
</Label>
|
||||
</div>
|
||||
|
||||
{pageItems.length === 0 ? (
|
||||
<p className="py-6 text-center text-sm text-gray-500">Keine Treffer.</p>
|
||||
) : (
|
||||
<ul className="divide-y divide-gray-100">
|
||||
{pageItems.map((draft) => (
|
||||
<li key={draft.id} className="flex items-center gap-2.5 py-2.5">
|
||||
<Checkbox
|
||||
id={`publish-${draft.id}`}
|
||||
checked={selectedIds.has(draft.id)}
|
||||
onCheckedChange={() => toggleOne(draft.id)}
|
||||
disabled={isPending}
|
||||
/>
|
||||
<Label
|
||||
htmlFor={`publish-${draft.id}`}
|
||||
className="flex flex-1 min-w-0 items-center gap-2 cursor-pointer font-normal"
|
||||
>
|
||||
<span className="flex-1 min-w-0 truncate text-sm font-medium text-gray-900">
|
||||
{draft.name}
|
||||
</span>
|
||||
{draft.isSeriesInstance && (
|
||||
<span className="shrink-0 text-[10px] font-semibold px-1.5 py-0.5 rounded bg-blue-100 text-blue-700">
|
||||
Serie
|
||||
</span>
|
||||
)}
|
||||
<span className="shrink-0 text-xs text-gray-500 tabular-nums">
|
||||
{formatDate(draft.date)} · {draft.timeFrom}
|
||||
</span>
|
||||
<span className="shrink-0 text-xs font-semibold text-gray-700 tabular-nums">
|
||||
{formatEuro(draft.price)}
|
||||
</span>
|
||||
</Label>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
{/* Pagination */}
|
||||
{pageCount > 1 && (
|
||||
<div className="flex items-center justify-between pt-1">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setPage(safePage - 1)}
|
||||
disabled={safePage === 0 || isPending}
|
||||
>
|
||||
<ChevronLeft size={16} />
|
||||
Zurück
|
||||
</Button>
|
||||
<span className="text-xs text-gray-500 tabular-nums">
|
||||
Seite {safePage + 1} von {pageCount}
|
||||
</span>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setPage(safePage + 1)}
|
||||
disabled={safePage >= pageCount - 1 || isPending}
|
||||
>
|
||||
Weiter
|
||||
<ChevronRight size={16} />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Unvollständige Entwürfe — sichtbar, aber nicht wählbar */}
|
||||
{incompleteDrafts.length > 0 && (
|
||||
<div className="rounded-lg border border-yellow-200 bg-yellow-50 p-3 space-y-2">
|
||||
<div className="flex items-center gap-2 text-sm text-yellow-800">
|
||||
<AlertTriangle size={16} className="shrink-0" />
|
||||
<span className="font-medium">
|
||||
{incompleteDrafts.length}{" "}
|
||||
{incompleteDrafts.length === 1 ? "Entwurf ist" : "Entwürfe sind"} unvollständig
|
||||
</span>
|
||||
</div>
|
||||
<ul className="space-y-1.5">
|
||||
{incompleteDrafts.map((draft) => (
|
||||
<li key={draft.id} className="flex items-center gap-2 text-xs">
|
||||
<span className="flex-1 min-w-0 truncate text-yellow-900">
|
||||
{draft.name || "Unbenannter Entwurf"}
|
||||
<span className="text-yellow-700">
|
||||
{" "}
|
||||
— fehlt: {draft.missingFields.join(", ")}
|
||||
</span>
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => router.push(`/courses/edit/${draft.id}`)}
|
||||
className="shrink-0 p-1 rounded hover:bg-yellow-100 transition"
|
||||
title="Entwurf bearbeiten"
|
||||
>
|
||||
<Pen size={14} className="text-yellow-800" />
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="ghost" onClick={onClose} disabled={isPending}>
|
||||
Abbrechen
|
||||
</Button>
|
||||
<Button onClick={handlePublish} disabled={isPending || isLoading || selectedIds.size === 0}>
|
||||
{isPending
|
||||
? "Wird veröffentlicht…"
|
||||
: selectedIds.size === 1
|
||||
? "1 Kurs veröffentlichen"
|
||||
: `${selectedIds.size} Kurse veröffentlichen`}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const PublishDraftsDialog = ({
|
||||
open,
|
||||
onOpenChange,
|
||||
defaultDateFrom,
|
||||
defaultDateTo,
|
||||
onPublished,
|
||||
}: PublishDraftsDialogProps) => {
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-lg max-h-[85vh] overflow-y-auto">
|
||||
{open && (
|
||||
<PublishDraftsDialogBody
|
||||
// Ohne key behält der Body seinen State über das Schließen hinaus,
|
||||
// und die useState-Vorbelegung der Datumsfelder greift nur beim
|
||||
// allerersten Öffnen — beim zweiten Öffnen stünde dort der zuletzt
|
||||
// getippte Zeitraum statt der aktuellen Vorbelegung.
|
||||
key={`${defaultDateFrom}-${defaultDateTo}`}
|
||||
defaultDateFrom={defaultDateFrom}
|
||||
defaultDateTo={defaultDateTo}
|
||||
onClose={() => onOpenChange(false)}
|
||||
onPublished={onPublished}
|
||||
/>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
export default PublishDraftsDialog;
|
||||
|
|
@ -8,11 +8,12 @@ import { Button } from "@/src/components/ui/button";
|
|||
import { toast } from "sonner";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import CourseListItem from "../components/CourseListItem";
|
||||
import { Filter } from "lucide-react";
|
||||
import { Filter, Upload } from "lucide-react";
|
||||
import DeleteDialog from "@/src/components/layout/DeleteDialog";
|
||||
import CourseFilter from "../components/CourseFilter";
|
||||
import { StandingOrderScopeDialog } from "../components/StandingOrderScopeDialog";
|
||||
import CancelCourseConfirmDialog from "../components/CancelCourseConfirmDialog";
|
||||
import PublishDraftsDialog from "../components/PublishDraftsDialog";
|
||||
import { useCancelCourse } from "../../hooks/useCancelCourse";
|
||||
|
||||
type Course = {
|
||||
|
|
@ -83,6 +84,7 @@ const CourseListView = ({
|
|||
const [priceMin, setPriceMin] = useState<number | null>(null);
|
||||
const [priceMax, setPriceMax] = useState<number | null>(null);
|
||||
const [deleteMode, setDeleteMode] = useState(false);
|
||||
const [publishDialogOpen, setPublishDialogOpen] = useState(false);
|
||||
|
||||
const {
|
||||
deleteDialogOpen,
|
||||
|
|
@ -276,6 +278,25 @@ const CourseListView = ({
|
|||
}));
|
||||
const uniqueRooms = Array.from(new Set(courses.map((c) => roomsMap[c.room] || c.room)));
|
||||
|
||||
// Lokale Datumskomponenten statt toISOString(), das nach UTC umrechnet und
|
||||
// das Datum je nach Zeitzone um einen Tag verschieben würde.
|
||||
const toInputDate = (date: Date) => {
|
||||
const d = new Date(date);
|
||||
return [
|
||||
d.getFullYear(),
|
||||
String(d.getMonth() + 1).padStart(2, "0"),
|
||||
String(d.getDate()).padStart(2, "0"),
|
||||
].join("-");
|
||||
};
|
||||
|
||||
// Der Dialog übernimmt den Datumsfilter des Nutzers, falls gesetzt. Ohne
|
||||
// Filter bleibt "bis" leer = keine obere Grenze, es werden also alle
|
||||
// kommenden Entwürfe gezeigt. Bewusst NICHT loadedDateTo: das ist der
|
||||
// Ladezustand der Liste (wächst mit jedem "Mehr laden") und würde als
|
||||
// Zeitraum-Grenze willkürlich Termine abschneiden.
|
||||
const publishDefaultFrom = dateFrom || toInputDate(new Date());
|
||||
const publishDefaultTo = dateTo;
|
||||
|
||||
const activeFiltersCount = [
|
||||
filterStatus !== "all",
|
||||
filterSport !== "all",
|
||||
|
|
@ -412,6 +433,22 @@ const CourseListView = ({
|
|||
)}
|
||||
</Button>
|
||||
</CourseFilter>
|
||||
{/* Bewusst nicht an die geladenen Kurse gekoppelt: die Liste zeigt nur
|
||||
ein Datumsfenster (initial heute ±14 Tage), der Dialog dagegen alle
|
||||
kommenden Entwürfe. Ein Entwurf außerhalb des Fensters würde den
|
||||
Button sonst verstecken — und bei leerem Fenster fehlt auch
|
||||
"Mehr laden", der Nutzer käme gar nicht mehr an seine Entwürfe.
|
||||
Der Dialog hat für "nichts gefunden" einen eigenen Leerzustand. */}
|
||||
{canEdit && (
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setPublishDialogOpen(true)}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<Upload size={18} />
|
||||
Entwürfe veröffentlichen
|
||||
</Button>
|
||||
)}
|
||||
{canDelete && (
|
||||
<Button
|
||||
variant={deleteMode ? "destructive" : "outline"}
|
||||
|
|
@ -530,6 +567,20 @@ const CourseListView = ({
|
|||
action="delete"
|
||||
/>
|
||||
|
||||
<PublishDraftsDialog
|
||||
open={publishDialogOpen}
|
||||
onOpenChange={setPublishDialogOpen}
|
||||
defaultDateFrom={publishDefaultFrom}
|
||||
defaultDateTo={publishDefaultTo}
|
||||
onPublished={(publishedIds) => {
|
||||
const ids = new Set(publishedIds);
|
||||
setCourses((prev) =>
|
||||
prev.map((c) => (ids.has(c.id) ? { ...c, status: "published" } : c))
|
||||
);
|
||||
router.refresh();
|
||||
}}
|
||||
/>
|
||||
|
||||
{courseToCancel && (
|
||||
<CancelCourseConfirmDialog
|
||||
open={cancelDialogOpen}
|
||||
|
|
|
|||
321
tests/courses/publish-drafts-actions.test.ts
Normal file
321
tests/courses/publish-drafts-actions.test.ts
Normal file
|
|
@ -0,0 +1,321 @@
|
|||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
|
||||
vi.mock("@/src/lib/prisma", () => ({
|
||||
prisma: {
|
||||
course: {
|
||||
findMany: vi.fn(),
|
||||
updateManyAndReturn: vi.fn(),
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/src/lib/auth/getUser", () => ({
|
||||
getUserData: vi.fn(),
|
||||
isManager: (u: { role: string }) => u?.role === "manager",
|
||||
isEmployee: (u: { role: string }) => u?.role === "employee",
|
||||
// Spiegelt die echte Implementierung: für Employees ist der Manager
|
||||
// EmployeeData.createdBy — nicht etwa ein managerId-Feld, das es nicht gibt.
|
||||
getEffectiveManagerId: (u: { role: string; id: string; createdBy?: string | null }) =>
|
||||
u?.role === "manager" ? u.id : (u?.createdBy ?? null),
|
||||
}));
|
||||
|
||||
vi.mock("next/cache", () => ({
|
||||
revalidatePath: vi.fn(),
|
||||
}));
|
||||
|
||||
import { prisma } from "@/src/lib/prisma";
|
||||
import { getUserData } from "@/src/lib/auth/getUser";
|
||||
import {
|
||||
getPublishableDrafts,
|
||||
publishDraftCourses,
|
||||
} from "@/src/modules/courses/actions/publish-drafts-actions";
|
||||
|
||||
const findMany = vi.mocked(prisma.course.findMany);
|
||||
const updateManyAndReturn = vi.mocked(prisma.course.updateManyAndReturn);
|
||||
const mockGetUserData = vi.mocked(getUserData);
|
||||
|
||||
const MANAGER = { id: "mgr-1", role: "manager" };
|
||||
|
||||
/** Ein vollständiger, veröffentlichbarer Entwurf. Einzelne Felder pro Test überschreibbar. */
|
||||
const completeDraft = (overrides: Record<string, unknown> = {}) => ({
|
||||
id: "c1",
|
||||
name: "Yoga Basics",
|
||||
sport: ["yoga"],
|
||||
level: "any",
|
||||
date: new Date(2026, 9, 2),
|
||||
timeFrom: "18:00",
|
||||
timeTo: "19:00",
|
||||
trainers: ["t1"],
|
||||
room: "room-1",
|
||||
description: "",
|
||||
coverImage: null,
|
||||
maxParticipants: 12,
|
||||
price: 15,
|
||||
isStandingOrder: false,
|
||||
frequency: null,
|
||||
weekdays: [],
|
||||
endDate: null,
|
||||
parentCourseId: null,
|
||||
status: "draft",
|
||||
createdBy: "mgr-1",
|
||||
...overrides,
|
||||
});
|
||||
|
||||
/**
|
||||
* Simuliert updateManyAndReturn: die DB gibt nur die Zeilen zurück, die dieses
|
||||
* Statement tatsächlich geändert hat. Wer nicht in updatedIds steht, wurde
|
||||
* zwischenzeitlich von anderer Seite verändert und bleibt unberührt.
|
||||
*/
|
||||
const mockUpdateReturning = (updatedIds: string[]) => {
|
||||
updateManyAndReturn.mockResolvedValueOnce(updatedIds.map((id) => ({ id })) as never);
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("getPublishableDrafts", () => {
|
||||
it("lehnt nicht angemeldete Nutzer ab", async () => {
|
||||
mockGetUserData.mockResolvedValueOnce(null as never);
|
||||
const result = await getPublishableDrafts();
|
||||
expect(result.success).toBe(false);
|
||||
expect(findMany).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("lehnt Employees ohne edit-Berechtigung ab", async () => {
|
||||
mockGetUserData.mockResolvedValueOnce({
|
||||
id: "emp-1",
|
||||
role: "employee",
|
||||
createdBy: "mgr-1",
|
||||
permissions: { courses: { edit: false } },
|
||||
} as never);
|
||||
|
||||
const result = await getPublishableDrafts();
|
||||
expect(result.success).toBe(false);
|
||||
expect(findMany).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("fragt nur eigene Entwurf-Instanzen ab, keine Dauerauftrag-Templates", async () => {
|
||||
mockGetUserData.mockResolvedValueOnce(MANAGER as never);
|
||||
findMany.mockResolvedValueOnce([] as never);
|
||||
|
||||
await getPublishableDrafts({
|
||||
dateFrom: new Date(2026, 9, 1),
|
||||
dateTo: new Date(2026, 9, 31),
|
||||
});
|
||||
|
||||
expect(findMany.mock.calls[0][0]?.where).toEqual({
|
||||
createdBy: "mgr-1",
|
||||
status: "draft",
|
||||
isStandingOrder: false,
|
||||
date: { gte: new Date(2026, 9, 1), lte: new Date(2026, 9, 31) },
|
||||
});
|
||||
});
|
||||
|
||||
it("setzt keine Datums-Bedingung, wenn kein Zeitraum übergeben wird", async () => {
|
||||
mockGetUserData.mockResolvedValueOnce(MANAGER as never);
|
||||
findMany.mockResolvedValueOnce([] as never);
|
||||
|
||||
await getPublishableDrafts();
|
||||
|
||||
expect(findMany.mock.calls[0][0]?.where).toEqual({
|
||||
createdBy: "mgr-1",
|
||||
status: "draft",
|
||||
isStandingOrder: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("lässt die obere Grenze offen, wenn nur dateFrom gesetzt ist", async () => {
|
||||
mockGetUserData.mockResolvedValueOnce(MANAGER as never);
|
||||
findMany.mockResolvedValueOnce([] as never);
|
||||
|
||||
await getPublishableDrafts({ dateFrom: new Date(2026, 8, 15) });
|
||||
|
||||
expect(findMany.mock.calls[0][0]?.where).toEqual({
|
||||
createdBy: "mgr-1",
|
||||
status: "draft",
|
||||
isStandingOrder: false,
|
||||
date: { gte: new Date(2026, 8, 15) },
|
||||
});
|
||||
});
|
||||
|
||||
it("markiert vollständige Entwürfe als veröffentlichbar", async () => {
|
||||
mockGetUserData.mockResolvedValueOnce(MANAGER as never);
|
||||
findMany.mockResolvedValueOnce([completeDraft()] as never);
|
||||
|
||||
const result = await getPublishableDrafts();
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.drafts[0].missingFields).toEqual([]);
|
||||
});
|
||||
|
||||
it("benennt fehlende Pflichtfelder auf Deutsch", async () => {
|
||||
mockGetUserData.mockResolvedValueOnce(MANAGER as never);
|
||||
findMany.mockResolvedValueOnce([
|
||||
completeDraft({ name: "", room: "", price: undefined }),
|
||||
] as never);
|
||||
|
||||
const result = await getPublishableDrafts();
|
||||
|
||||
expect(result.drafts[0].missingFields).toContain("Name");
|
||||
expect(result.drafts[0].missingFields).toContain("Raum");
|
||||
expect(result.drafts[0].missingFields).toContain("Preis");
|
||||
});
|
||||
|
||||
it("kennzeichnet Instanzen eines Dauerauftrags als Serie", async () => {
|
||||
mockGetUserData.mockResolvedValueOnce(MANAGER as never);
|
||||
findMany.mockResolvedValueOnce([
|
||||
completeDraft({ parentCourseId: "template-1" }),
|
||||
completeDraft({ id: "c2", parentCourseId: null }),
|
||||
] as never);
|
||||
|
||||
const result = await getPublishableDrafts();
|
||||
|
||||
expect(result.drafts[0].isSeriesInstance).toBe(true);
|
||||
expect(result.drafts[1].isSeriesInstance).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("publishDraftCourses", () => {
|
||||
it("lehnt nicht angemeldete Nutzer ab", async () => {
|
||||
mockGetUserData.mockResolvedValueOnce(null as never);
|
||||
const result = await publishDraftCourses(["c1"]);
|
||||
expect(result.success).toBe(false);
|
||||
expect(updateManyAndReturn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("lehnt eine leere Auswahl ab", async () => {
|
||||
mockGetUserData.mockResolvedValueOnce(MANAGER as never);
|
||||
const result = await publishDraftCourses([]);
|
||||
expect(result.success).toBe(false);
|
||||
expect(findMany).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("scoped die Abfrage auf eigene Entwürfe", async () => {
|
||||
mockGetUserData.mockResolvedValueOnce(MANAGER as never);
|
||||
findMany.mockResolvedValueOnce([] as never);
|
||||
|
||||
await publishDraftCourses(["c1", "fremd"]);
|
||||
|
||||
expect(findMany.mock.calls[0][0]?.where).toEqual({
|
||||
id: { in: ["c1", "fremd"] },
|
||||
createdBy: "mgr-1",
|
||||
status: "draft",
|
||||
isStandingOrder: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("veröffentlicht vollständige Entwürfe", async () => {
|
||||
mockGetUserData.mockResolvedValueOnce(MANAGER as never);
|
||||
findMany.mockResolvedValueOnce([completeDraft()] as never);
|
||||
mockUpdateReturning(["c1"]);
|
||||
|
||||
const result = await publishDraftCourses(["c1"]);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
if (!result.success) return;
|
||||
expect(result.published).toBe(1);
|
||||
expect(result.publishedIds).toEqual(["c1"]);
|
||||
expect(result.skipped).toEqual([]);
|
||||
});
|
||||
|
||||
it("überspringt unvollständige Entwürfe statt sie zu veröffentlichen", async () => {
|
||||
mockGetUserData.mockResolvedValueOnce(MANAGER as never);
|
||||
findMany.mockResolvedValueOnce([
|
||||
completeDraft(),
|
||||
completeDraft({ id: "c2", name: "Zumba", room: "" }),
|
||||
] as never);
|
||||
mockUpdateReturning(["c1"]);
|
||||
|
||||
const result = await publishDraftCourses(["c1", "c2"]);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
if (!result.success) return;
|
||||
expect(result.published).toBe(1);
|
||||
expect(result.publishedIds).toEqual(["c1"]);
|
||||
expect(result.skipped).toHaveLength(1);
|
||||
expect(result.skipped[0].name).toBe("Zumba");
|
||||
expect(result.skipped[0].reason).toContain("Raum");
|
||||
});
|
||||
|
||||
it("meldet nur Kurse als veröffentlicht, die dieses Update wirklich geändert hat", async () => {
|
||||
mockGetUserData.mockResolvedValueOnce(MANAGER as never);
|
||||
findMany.mockResolvedValueOnce([
|
||||
completeDraft(),
|
||||
completeDraft({ id: "c2", name: "Pilates" }),
|
||||
] as never);
|
||||
// c2 wurde zwischen Prüfung und Update von anderer Seite verändert (z.B.
|
||||
// parallel veröffentlicht oder abgesagt) — das Update erfasst nur c1.
|
||||
mockUpdateReturning(["c1"]);
|
||||
|
||||
const result = await publishDraftCourses(["c1", "c2"]);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
if (!result.success) return;
|
||||
expect(result.published).toBe(1);
|
||||
expect(result.publishedIds).toEqual(["c1"]);
|
||||
// c2 darf weder in publishedIds stehen noch mitgezählt werden.
|
||||
expect(result.publishedIds).not.toContain("c2");
|
||||
expect(result.vanished).toBe(1);
|
||||
});
|
||||
|
||||
it("schreibt nur eigene Entwurf-Einzelkurse, nie Dauerauftrag-Templates", async () => {
|
||||
mockGetUserData.mockResolvedValueOnce(MANAGER as never);
|
||||
findMany.mockResolvedValueOnce([completeDraft()] as never);
|
||||
mockUpdateReturning(["c1"]);
|
||||
|
||||
await publishDraftCourses(["c1"]);
|
||||
|
||||
// Das Update muss dieselben Bedingungen tragen wie die Auswahl-Abfrage:
|
||||
// ohne status "draft" könnte ein zwischenzeitlich abgesagter Kurs wieder
|
||||
// live gehen, ohne isStandingOrder: false ein parallel konvertiertes
|
||||
// Dauerauftrag-Template veröffentlicht werden, das nie in der Kursliste
|
||||
// erscheint.
|
||||
expect(updateManyAndReturn.mock.calls[0][0]?.where).toEqual({
|
||||
id: { in: ["c1"] },
|
||||
createdBy: "mgr-1",
|
||||
status: "draft",
|
||||
isStandingOrder: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("zählt IDs, die die Abfrage nicht zurückgibt, als verschwunden", async () => {
|
||||
mockGetUserData.mockResolvedValueOnce(MANAGER as never);
|
||||
findMany.mockResolvedValueOnce([completeDraft()] as never);
|
||||
mockUpdateReturning(["c1"]);
|
||||
|
||||
const result = await publishDraftCourses(["c1", "geloescht", "fremd"]);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
if (!result.success) return;
|
||||
expect(result.vanished).toBe(2);
|
||||
});
|
||||
|
||||
it("schreibt gar nicht, wenn kein Kurs veröffentlichbar ist", async () => {
|
||||
mockGetUserData.mockResolvedValueOnce(MANAGER as never);
|
||||
findMany.mockResolvedValueOnce([completeDraft({ name: "", room: "" })] as never);
|
||||
|
||||
const result = await publishDraftCourses(["c1"]);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
if (!result.success) return;
|
||||
expect(result.published).toBe(0);
|
||||
expect(updateManyAndReturn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("erlaubt Employees mit edit-Berechtigung das Veröffentlichen im Namen des Managers", async () => {
|
||||
mockGetUserData.mockResolvedValueOnce({
|
||||
id: "emp-1",
|
||||
role: "employee",
|
||||
createdBy: "mgr-1",
|
||||
permissions: { courses: { edit: true } },
|
||||
} as never);
|
||||
findMany.mockResolvedValueOnce([completeDraft()] as never);
|
||||
mockUpdateReturning(["c1"]);
|
||||
|
||||
const result = await publishDraftCourses(["c1"]);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(findMany.mock.calls[0][0]?.where).toMatchObject({ createdBy: "mgr-1" });
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue