From db4acbb46e7d4d69f4a22fe43ee7e7aa0fa157bd Mon Sep 17 00:00:00 2001 From: simoleo89 Date: Fri, 11 Sep 2026 00:36:55 +0200 Subject: [PATCH] feat(editorial): validate publications and preserve partial event updates --- src/actions/events-preflight.test.ts | 101 +++++++++++++++ src/actions/events.ts | 26 ++++ src/app/admin/events/event-form.tsx | 56 ++++++++- src/components/admin/article-form.tsx | 43 ++++++- src/components/admin/article-preview.tsx | 6 +- .../admin/publication-preflight.tsx | 55 +++++++++ src/lib/article-input.ts | 18 ++- src/lib/publication-preflight.test.ts | 100 +++++++++++++++ src/lib/publication-preflight.ts | 116 ++++++++++++++++++ src/lib/validators/event.test.ts | 26 +++- src/lib/validators/event.ts | 16 ++- src/messages/en.json | 22 +++- src/messages/it.json | 22 +++- src/messages/nl.json | 22 +++- 14 files changed, 613 insertions(+), 16 deletions(-) create mode 100644 src/actions/events-preflight.test.ts create mode 100644 src/components/admin/publication-preflight.tsx create mode 100644 src/lib/publication-preflight.test.ts create mode 100644 src/lib/publication-preflight.ts diff --git a/src/actions/events-preflight.test.ts b/src/actions/events-preflight.test.ts new file mode 100644 index 00000000..aebec239 --- /dev/null +++ b/src/actions/events-preflight.test.ts @@ -0,0 +1,101 @@ +import { beforeEach, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ + existing: vi.fn(), + insert: vi.fn(), + update: vi.fn(), +})); +vi.mock("@/lib/db", async () => ({ + ...(await import("@/db/schema")), + db: { + select: () => ({ + from: () => ({ where: () => ({ limit: mocks.existing }) }), + }), + insert: () => ({ values: mocks.insert }), + update: () => ({ set: () => ({ where: mocks.update }) }), + }, +})); +vi.mock("@/lib/safe-action", () => ({ + adminAction: + ( + options: { schema: { parse: (data: unknown) => unknown } }, + handler: (ctx: unknown) => unknown, + ) => + (data: unknown) => + handler({ + data: options.schema.parse(data), + session: { user: { id: 7, username: "Staff" } }, + }), + authAction: () => vi.fn(), +})); +vi.mock("@/lib/services/audit", () => ({ logAudit: vi.fn() })); +vi.mock("@/lib/services/webhook", () => ({ notify: vi.fn() })); + +vi.mock("@/lib/foundation/action", () => ({ handleActionError: vi.fn() })); +vi.mock("@/lib/permissions", async () => import("@/lib/permission-slugs")); + +import { createEvent, updateEvent } from "./events"; + +const base = { + title: "Event", + description: "Details", + typeId: 1, + startsAt: new Date("2030-01-01"), + status: "published" as const, + isRecurring: 0, +}; +beforeEach(() => { + vi.clearAllMocks(); + mocks.existing.mockResolvedValue([ + { + id: 1, + status: "published", + title: "Event", + image: "/cover.png", + startsAt: new Date("2030-01-01"), + endsAt: new Date("2030-01-02"), + }, + ]); + mocks.insert.mockResolvedValue([{ insertId: 1 }]); + mocks.update.mockResolvedValue(undefined); +}); +it("rejects invalid published event images before writing", async () => { + await expect( + createEvent({ ...base, image: "javascript:alert(1)" }), + ).rejects.toThrow("Check the event image"); + expect(mocks.insert).not.toHaveBeenCalled(); +}); +it("validates an update against existing dates before writing", async () => { + await expect( + updateEvent({ id: 1, endsAt: new Date("2029-12-01") }), + ).rejects.toThrow("Check the event image"); + expect(mocks.update).not.toHaveBeenCalled(); +}); +it("allows draft saves and partial updates with unchanged valid dates", async () => { + await createEvent({ ...base, status: "draft", image: "javascript:alert(1)" }); + expect(mocks.insert).toHaveBeenCalledOnce(); + await updateEvent({ id: 1, title: "Changed", startsAt: undefined }); + expect(mocks.update).toHaveBeenCalledOnce(); +}); + +it("does not bypass published preflight when a partial update omits status", async () => { + await expect( + updateEvent({ id: 1, image: "javascript:alert(1)" }), + ).rejects.toThrow("Check the event image"); + expect(mocks.update).not.toHaveBeenCalled(); +}); + +it("allows explicitly removing an invalid image while publishing the draft", async () => { + mocks.existing.mockResolvedValue([ + { + id: 1, + status: "draft", + title: "Draft", + image: "javascript:alert(1)", + startsAt: new Date("2030-01-01"), + endsAt: null, + }, + ]); + await updateEvent({ id: 1, status: "published", image: "" }); + expect(mocks.update).toHaveBeenCalledOnce(); +}); diff --git a/src/actions/events.ts b/src/actions/events.ts index 709d969e..a07d916a 100644 --- a/src/actions/events.ts +++ b/src/actions/events.ts @@ -12,6 +12,7 @@ import { WebsiteEventWinner, } from "@/lib/db"; import { PERMS } from "@/lib/permissions"; +import { publicationIssues } from "@/lib/publication-preflight"; import { adminAction, authAction } from "@/lib/safe-action"; import { ActionError, actionError, actionOk } from "@/lib/safe-action-shared"; import { logAudit } from "@/lib/services/audit"; @@ -108,6 +109,15 @@ export const createEvent = adminAction( { permission: PERMS.EVENTS_EDIT, schema: createEventSchema }, async (ctx) => { const now = new Date(); + if ( + ctx.data.status === "published" && + publicationIssues({ kind: "event", ...ctx.data }).some( + (issue) => issue.severity === "error", + ) + ) + throw new ActionError( + "Check the event image and schedule before publishing", + ); const [result] = await db.insert(WebsiteEvent).values({ ...ctx.data, hostUserId: Number(ctx.session.user.id), @@ -143,11 +153,27 @@ export const updateEvent = adminAction( id: WebsiteEvent.id, title: WebsiteEvent.title, status: WebsiteEvent.status, + image: WebsiteEvent.image, + startsAt: WebsiteEvent.startsAt, + endsAt: WebsiteEvent.endsAt, }) .from(WebsiteEvent) .where(eq(WebsiteEvent.id, id)) .limit(1); if (!existing) throw new ActionError("Event not found"); + if ( + (data.status ?? existing.status) === "published" && + publicationIssues({ + kind: "event", + image: data.image === undefined ? existing.image : data.image, + startsAt: + data.startsAt === undefined ? existing.startsAt : data.startsAt, + endsAt: data.endsAt === undefined ? existing.endsAt : data.endsAt, + }).some((issue) => issue.severity === "error") + ) + throw new ActionError( + "Check the event image and schedule before publishing", + ); await db .update(WebsiteEvent) diff --git a/src/app/admin/events/event-form.tsx b/src/app/admin/events/event-form.tsx index de8af3cf..a8c9eea5 100644 --- a/src/app/admin/events/event-form.tsx +++ b/src/app/admin/events/event-form.tsx @@ -1,8 +1,11 @@ "use client"; import { useTranslations } from "next-intl"; +import { useState } from "react"; import { useForm } from "react-hook-form"; import { createEvent, updateEvent } from "@/actions/events"; +import { ArticlePreview } from "@/components/admin/article-preview"; +import { PublicationPreflight } from "@/components/admin/publication-preflight"; import { Button } from "@/components/ui/button"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { DateTimePicker } from "@/components/ui/date-time-picker"; @@ -17,6 +20,7 @@ import { } from "@/components/ui/select"; import { Textarea } from "@/components/ui/textarea"; import { useServerAction } from "@/hooks/use-server-action"; +import { publicationIssues } from "@/lib/publication-preflight"; import type { CreateEventInput, UpdateEventInput, @@ -49,6 +53,8 @@ interface EventFormValues { export function EventForm({ eventTypes, defaultValues }: EventFormProps) { const t = useTranslations("pages.admin.events"); + const tAction = useTranslations("pages.admin.actions"); + const [preview, setPreview] = useState(false); const { run, isPending } = useServerAction(); const isEditing = !!defaultValues?.id; @@ -63,7 +69,16 @@ export function EventForm({ eventTypes, defaultValues }: EventFormProps) { }, }); + const values = form.watch(); + const issues = publicationIssues({ kind: "event", ...values }); function onSubmit(data: EventFormValues) { + if ( + data.status === "published" && + publicationIssues({ kind: "event", ...data }).some( + (issue) => issue.severity === "error", + ) + ) + return; if (isEditing && defaultValues?.id) { run( () => @@ -152,7 +167,7 @@ export function EventForm({ eventTypes, defaultValues }: EventFormProps) { - form.setValue("endsAt", d, { shouldDirty: true }) + form.setValue("endsAt", d ?? null, { shouldDirty: true }) } placeholder={t("formSelectEnd")} /> @@ -214,8 +229,43 @@ export function EventForm({ eventTypes, defaultValues }: EventFormProps) { /> -
- +
+