see comments

This commit is contained in:
Rajpreet0 2026-09-15 13:53:27 +02:00
parent a26e2a6245
commit 94e632681e
4 changed files with 98 additions and 51 deletions

View file

@ -240,29 +240,29 @@ export async function publishDraftCourses(courseIds: string[]) {
} }
// IDs, die die DB-Abfrage gar nicht zurückgegeben hat (gelöscht, fremd oder // IDs, die die DB-Abfrage gar nicht zurückgegeben hat (gelöscht, fremd oder
// inzwischen nicht mehr "draft"), sind ebenfalls übersprungen. // inzwischen nicht mehr "draft"), sind übersprungen.
const foundIds = new Set(courses.map((c) => c.id)); const foundIds = new Set(courses.map((c) => c.id));
const vanishedCount = courseIds.filter((id) => !foundIds.has(id)).length; let vanishedCount = courseIds.filter((id) => !foundIds.has(id)).length;
let publishedIds: string[] = []; let publishedIds: string[] = [];
if (publishableIds.length > 0) { if (publishableIds.length > 0) {
// In einer Transaktion, damit das nachgelagerte findMany nur die Zeilen // updateManyAndReturn liefert genau die Zeilen zurück, die DIESES Statement
// sieht, die dieses updateMany erfasst hat. status erneut im where: // geändert hat. Ein nachgelagertes findMany auf status "published" würde
// zwischen findMany und updateMany kann der Kurs in einem parallelen // dagegen auch Kurse zurückgeben, die ein paralleler Request zwischen dem
// Request veröffentlicht oder abgesagt worden sein. // findMany oben und hier veröffentlicht hat — die würden wir uns fälschlich
publishedIds = await prisma.$transaction(async (tx) => { // zuschreiben und an den Client melden. status: "draft" im where sorgt dafür,
await tx.course.updateMany({ // dass ein zwischenzeitlich abgesagter Kurs nicht doch noch live geht.
where: { id: { in: publishableIds }, createdBy: auth.managerId, status: "draft" }, const updated = await prisma.course.updateManyAndReturn({
data: { status: "published" }, where: { id: { in: publishableIds }, createdBy: auth.managerId, status: "draft" },
}); data: { status: "published" },
select: { id: true },
const updated = await tx.course.findMany({
where: { id: { in: publishableIds }, createdBy: auth.managerId, status: "published" },
select: { id: true },
});
return updated.map((c) => c.id);
}); });
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"); revalidatePath("/courses");

View file

@ -97,6 +97,16 @@ function PublishDraftsDialogBody({
useEffect(() => { useEffect(() => {
let cancelled = false; 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 () => { (async () => {
setIsLoading(true); setIsLoading(true);
try { try {
@ -115,10 +125,14 @@ function PublishDraftsDialogBody({
); );
setPage(0); setPage(0);
} else { } else {
clearDrafts();
toast.error(result.error); toast.error(result.error);
} }
} catch { } catch {
if (!cancelled) toast.error("Entwürfe konnten nicht geladen werden."); if (!cancelled) {
clearDrafts();
toast.error("Entwürfe konnten nicht geladen werden.");
}
} finally { } finally {
if (!cancelled) setIsLoading(false); if (!cancelled) setIsLoading(false);
} }

View file

@ -289,11 +289,6 @@ const CourseListView = ({
].join("-"); ].join("-");
}; };
// Steuert nur, OB der Veröffentlichen-Button erscheint. Bewusst ohne Zahl am
// Button: hier sind nur die geladenen Kurse bekannt, der Dialog zeigt aber
// alle kommenden Entwürfe — eine Zahl hier würde der Zahl dort widersprechen.
const hasDrafts = courses.some((c) => c.status === "draft" && !isPastCourse(c.date));
// Der Dialog übernimmt den Datumsfilter des Nutzers, falls gesetzt. Ohne // Der Dialog übernimmt den Datumsfilter des Nutzers, falls gesetzt. Ohne
// Filter bleibt "bis" leer = keine obere Grenze, es werden also alle // Filter bleibt "bis" leer = keine obere Grenze, es werden also alle
// kommenden Entwürfe gezeigt. Bewusst NICHT loadedDateTo: das ist der // kommenden Entwürfe gezeigt. Bewusst NICHT loadedDateTo: das ist der
@ -438,7 +433,13 @@ const CourseListView = ({
)} )}
</Button> </Button>
</CourseFilter> </CourseFilter>
{canEdit && hasDrafts && ( {/* 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 <Button
variant="outline" variant="outline"
onClick={() => setPublishDialogOpen(true)} onClick={() => setPublishDialogOpen(true)}

View file

@ -4,9 +4,8 @@ vi.mock("@/src/lib/prisma", () => ({
prisma: { prisma: {
course: { course: {
findMany: vi.fn(), findMany: vi.fn(),
updateMany: vi.fn(), updateManyAndReturn: vi.fn(),
}, },
$transaction: vi.fn(),
}, },
})); }));
@ -14,8 +13,10 @@ vi.mock("@/src/lib/auth/getUser", () => ({
getUserData: vi.fn(), getUserData: vi.fn(),
isManager: (u: { role: string }) => u?.role === "manager", isManager: (u: { role: string }) => u?.role === "manager",
isEmployee: (u: { role: string }) => u?.role === "employee", isEmployee: (u: { role: string }) => u?.role === "employee",
getEffectiveManagerId: (u: { role: string; id: string; managerId?: string }) => // Spiegelt die echte Implementierung: für Employees ist der Manager
u?.role === "manager" ? u.id : (u?.managerId ?? null), // 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", () => ({ vi.mock("next/cache", () => ({
@ -30,8 +31,7 @@ import {
} from "@/src/modules/courses/actions/publish-drafts-actions"; } from "@/src/modules/courses/actions/publish-drafts-actions";
const findMany = vi.mocked(prisma.course.findMany); const findMany = vi.mocked(prisma.course.findMany);
const updateMany = vi.mocked(prisma.course.updateMany); const updateManyAndReturn = vi.mocked(prisma.course.updateManyAndReturn);
const transaction = vi.mocked(prisma.$transaction);
const mockGetUserData = vi.mocked(getUserData); const mockGetUserData = vi.mocked(getUserData);
const MANAGER = { id: "mgr-1", role: "manager" }; const MANAGER = { id: "mgr-1", role: "manager" };
@ -61,17 +61,13 @@ const completeDraft = (overrides: Record<string, unknown> = {}) => ({
...overrides, ...overrides,
}); });
/** $transaction mit Callback ausführen, als liefe sie gegen die echte DB. */ /**
const runTransaction = (updatedIds: string[]) => { * Simuliert updateManyAndReturn: die DB gibt nur die Zeilen zurück, die dieses
transaction.mockImplementationOnce(async (cb: unknown) => { * Statement tatsächlich geändert hat. Wer nicht in updatedIds steht, wurde
const tx = { * zwischenzeitlich von anderer Seite verändert und bleibt unberührt.
course: { */
updateMany: vi.fn().mockResolvedValue({ count: updatedIds.length }), const mockUpdateReturning = (updatedIds: string[]) => {
findMany: vi.fn().mockResolvedValue(updatedIds.map((id) => ({ id }))), updateManyAndReturn.mockResolvedValueOnce(updatedIds.map((id) => ({ id })) as never);
},
};
return (cb as (t: typeof tx) => Promise<string[]>)(tx);
});
}; };
beforeEach(() => { beforeEach(() => {
@ -90,7 +86,7 @@ describe("getPublishableDrafts", () => {
mockGetUserData.mockResolvedValueOnce({ mockGetUserData.mockResolvedValueOnce({
id: "emp-1", id: "emp-1",
role: "employee", role: "employee",
managerId: "mgr-1", createdBy: "mgr-1",
permissions: { courses: { edit: false } }, permissions: { courses: { edit: false } },
} as never); } as never);
@ -185,7 +181,7 @@ describe("publishDraftCourses", () => {
mockGetUserData.mockResolvedValueOnce(null as never); mockGetUserData.mockResolvedValueOnce(null as never);
const result = await publishDraftCourses(["c1"]); const result = await publishDraftCourses(["c1"]);
expect(result.success).toBe(false); expect(result.success).toBe(false);
expect(transaction).not.toHaveBeenCalled(); expect(updateManyAndReturn).not.toHaveBeenCalled();
}); });
it("lehnt eine leere Auswahl ab", async () => { it("lehnt eine leere Auswahl ab", async () => {
@ -212,7 +208,7 @@ describe("publishDraftCourses", () => {
it("veröffentlicht vollständige Entwürfe", async () => { it("veröffentlicht vollständige Entwürfe", async () => {
mockGetUserData.mockResolvedValueOnce(MANAGER as never); mockGetUserData.mockResolvedValueOnce(MANAGER as never);
findMany.mockResolvedValueOnce([completeDraft()] as never); findMany.mockResolvedValueOnce([completeDraft()] as never);
runTransaction(["c1"]); mockUpdateReturning(["c1"]);
const result = await publishDraftCourses(["c1"]); const result = await publishDraftCourses(["c1"]);
@ -229,7 +225,7 @@ describe("publishDraftCourses", () => {
completeDraft(), completeDraft(),
completeDraft({ id: "c2", name: "Zumba", room: "" }), completeDraft({ id: "c2", name: "Zumba", room: "" }),
] as never); ] as never);
runTransaction(["c1"]); mockUpdateReturning(["c1"]);
const result = await publishDraftCourses(["c1", "c2"]); const result = await publishDraftCourses(["c1", "c2"]);
@ -242,10 +238,47 @@ describe("publishDraftCourses", () => {
expect(result.skipped[0].reason).toContain("Raum"); 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("veröffentlicht nur Kurse, die noch den Status draft haben", async () => {
mockGetUserData.mockResolvedValueOnce(MANAGER as never);
findMany.mockResolvedValueOnce([completeDraft()] as never);
mockUpdateReturning(["c1"]);
await publishDraftCourses(["c1"]);
// Ohne status: "draft" im where könnte ein zwischenzeitlich abgesagter
// Kurs wieder live gehen.
expect(updateManyAndReturn.mock.calls[0][0]?.where).toEqual({
id: { in: ["c1"] },
createdBy: "mgr-1",
status: "draft",
});
});
it("zählt IDs, die die Abfrage nicht zurückgibt, als verschwunden", async () => { it("zählt IDs, die die Abfrage nicht zurückgibt, als verschwunden", async () => {
mockGetUserData.mockResolvedValueOnce(MANAGER as never); mockGetUserData.mockResolvedValueOnce(MANAGER as never);
findMany.mockResolvedValueOnce([completeDraft()] as never); findMany.mockResolvedValueOnce([completeDraft()] as never);
runTransaction(["c1"]); mockUpdateReturning(["c1"]);
const result = await publishDraftCourses(["c1", "geloescht", "fremd"]); const result = await publishDraftCourses(["c1", "geloescht", "fremd"]);
@ -263,19 +296,18 @@ describe("publishDraftCourses", () => {
expect(result.success).toBe(true); expect(result.success).toBe(true);
if (!result.success) return; if (!result.success) return;
expect(result.published).toBe(0); expect(result.published).toBe(0);
expect(transaction).not.toHaveBeenCalled(); expect(updateManyAndReturn).not.toHaveBeenCalled();
expect(updateMany).not.toHaveBeenCalled();
}); });
it("erlaubt Employees mit edit-Berechtigung das Veröffentlichen im Namen des Managers", async () => { it("erlaubt Employees mit edit-Berechtigung das Veröffentlichen im Namen des Managers", async () => {
mockGetUserData.mockResolvedValueOnce({ mockGetUserData.mockResolvedValueOnce({
id: "emp-1", id: "emp-1",
role: "employee", role: "employee",
managerId: "mgr-1", createdBy: "mgr-1",
permissions: { courses: { edit: true } }, permissions: { courses: { edit: true } },
} as never); } as never);
findMany.mockResolvedValueOnce([completeDraft()] as never); findMany.mockResolvedValueOnce([completeDraft()] as never);
runTransaction(["c1"]); mockUpdateReturning(["c1"]);
const result = await publishDraftCourses(["c1"]); const result = await publishDraftCourses(["c1"]);