feat(editorial): validate publications and preserve partial event updates
CI / check (push) Successful in 57s
CI / deploy (push) Successful in 18s
CI / publish-container (push) Successful in 1m20s

This commit is contained in:
Simo committed 2026-09-11 00:36:55 +02:00
1 parent 76f0420d64
commit db4acbb46e
14 files changed
+613 -16

No files matched your search

+101
View File
@@ -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();
});
+26
View File
@@ -12,6 +12,7 @@ import {
WebsiteEventWinner, WebsiteEventWinner,
} from "@/lib/db"; } from "@/lib/db";
import { PERMS } from "@/lib/permissions"; import { PERMS } from "@/lib/permissions";
import { publicationIssues } from "@/lib/publication-preflight";
import { adminAction, authAction } from "@/lib/safe-action"; import { adminAction, authAction } from "@/lib/safe-action";
import { ActionError, actionError, actionOk } from "@/lib/safe-action-shared"; import { ActionError, actionError, actionOk } from "@/lib/safe-action-shared";
import { logAudit } from "@/lib/services/audit"; import { logAudit } from "@/lib/services/audit";
@@ -108,6 +109,15 @@ export const createEvent = adminAction(
{ permission: PERMS.EVENTS_EDIT, schema: createEventSchema }, { permission: PERMS.EVENTS_EDIT, schema: createEventSchema },
async (ctx) => { async (ctx) => {
const now = new Date(); 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({ const [result] = await db.insert(WebsiteEvent).values({
...ctx.data, ...ctx.data,
hostUserId: Number(ctx.session.user.id), hostUserId: Number(ctx.session.user.id),
@@ -143,11 +153,27 @@ export const updateEvent = adminAction(
id: WebsiteEvent.id, id: WebsiteEvent.id,
title: WebsiteEvent.title, title: WebsiteEvent.title,
status: WebsiteEvent.status, status: WebsiteEvent.status,
image: WebsiteEvent.image,
startsAt: WebsiteEvent.startsAt,
endsAt: WebsiteEvent.endsAt,
}) })
.from(WebsiteEvent) .from(WebsiteEvent)
.where(eq(WebsiteEvent.id, id)) .where(eq(WebsiteEvent.id, id))
.limit(1); .limit(1);
if (!existing) throw new ActionError("Event not found"); 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 await db
.update(WebsiteEvent) .update(WebsiteEvent)
+53 -3
View File
@@ -1,8 +1,11 @@
"use client"; "use client";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import { useState } from "react";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
import { createEvent, updateEvent } from "@/actions/events"; 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 { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { DateTimePicker } from "@/components/ui/date-time-picker"; import { DateTimePicker } from "@/components/ui/date-time-picker";
@@ -17,6 +20,7 @@ import {
} from "@/components/ui/select"; } from "@/components/ui/select";
import { Textarea } from "@/components/ui/textarea"; import { Textarea } from "@/components/ui/textarea";
import { useServerAction } from "@/hooks/use-server-action"; import { useServerAction } from "@/hooks/use-server-action";
import { publicationIssues } from "@/lib/publication-preflight";
import type { import type {
CreateEventInput, CreateEventInput,
UpdateEventInput, UpdateEventInput,
@@ -49,6 +53,8 @@ interface EventFormValues {
export function EventForm({ eventTypes, defaultValues }: EventFormProps) { export function EventForm({ eventTypes, defaultValues }: EventFormProps) {
const t = useTranslations("pages.admin.events"); const t = useTranslations("pages.admin.events");
const tAction = useTranslations("pages.admin.actions");
const [preview, setPreview] = useState(false);
const { run, isPending } = useServerAction(); const { run, isPending } = useServerAction();
const isEditing = !!defaultValues?.id; 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) { function onSubmit(data: EventFormValues) {
if (
data.status === "published" &&
publicationIssues({ kind: "event", ...data }).some(
(issue) => issue.severity === "error",
)
)
return;
if (isEditing && defaultValues?.id) { if (isEditing && defaultValues?.id) {
run( run(
() => () =>
@@ -152,7 +167,7 @@ export function EventForm({ eventTypes, defaultValues }: EventFormProps) {
<DateTimePicker <DateTimePicker
value={form.watch("endsAt")} value={form.watch("endsAt")}
onChange={(d) => onChange={(d) =>
form.setValue("endsAt", d, { shouldDirty: true }) form.setValue("endsAt", d ?? null, { shouldDirty: true })
} }
placeholder={t("formSelectEnd")} placeholder={t("formSelectEnd")}
/> />
@@ -214,8 +229,43 @@ export function EventForm({ eventTypes, defaultValues }: EventFormProps) {
/> />
</div> </div>
<div className="flex justify-end"> <PublicationPreflight
<Button type="submit" disabled={isPending}> issues={issues}
paths={[
"/events",
...(defaultValues?.id ? [`/events/${defaultValues.id}`] : []),
]}
/>
<ArticlePreview
title={tAction("preview")}
data={
preview
? {
title: values.title,
image: values.image ?? "",
summary: values.description,
body: "",
}
: null
}
onClose={() => setPreview(false)}
/>
<div className="flex justify-end gap-2">
<Button
type="button"
variant="outline"
onClick={() => setPreview(true)}
>
{tAction("preview")}
</Button>
<Button
type="submit"
disabled={
isPending ||
(values.status === "published" &&
issues.some((issue) => issue.severity === "error"))
}
>
{isPending {isPending
? t("formSaving") ? t("formSaving")
: isEditing : isEditing
+39 -4
View File
@@ -7,10 +7,12 @@ import { useUnsavedChanges } from "@/hooks/use-unsaved-changes";
import type { ArticleDraft } from "@/lib/article-draft"; import type { ArticleDraft } from "@/lib/article-draft";
import type { ArticleSaveResult } from "@/lib/article-input"; import type { ArticleSaveResult } from "@/lib/article-input";
import { slugify } from "@/lib/format"; import { slugify } from "@/lib/format";
import { publicationIssues } from "@/lib/publication-preflight";
import { ArticlePreview, type ArticlePreviewData } from "./article-preview"; import { ArticlePreview, type ArticlePreviewData } from "./article-preview";
import { useArticleRecovery } from "./article-recovery"; import { useArticleRecovery } from "./article-recovery";
import { FormFieldError, useFormErrorFocus } from "./form-feedback"; import { FormFieldError, useFormErrorFocus } from "./form-feedback";
import { MediaPicker } from "./media-picker"; import { MediaPicker } from "./media-picker";
import { PublicationPreflight } from "./publication-preflight";
import { RichText } from "./rich-text"; import { RichText } from "./rich-text";
export function ArticleForm({ export function ArticleForm({
@@ -91,14 +93,33 @@ export function ArticleForm({
useFormErrorFocus(formRef, fieldErrors, pending); useFormErrorFocus(formRef, fieldErrors, pending);
const [preview, setPreview] = useState<ArticlePreviewData | null>(null); const [preview, setPreview] = useState<ArticlePreviewData | null>(null);
const suggestedSlug = useMemo(() => slugify(title), [title]); const suggestedSlug = useMemo(() => slugify(title), [title]);
const effectiveSlug = slugify(slugTouched ? slug : suggestedSlug);
const issues = publicationIssues({
kind: "article",
image,
body,
slug: slugTouched ? slug : suggestedSlug,
normalizedSlug: effectiveSlug,
status,
publishAt,
});
return ( return (
<form <form
ref={formRef} ref={formRef}
onChangeCapture={markDirty} onChangeCapture={() => {
markDirty();
if (formRef.current)
setBody(String(new FormData(formRef.current).get("fullStory") ?? ""));
}}
onSubmit={(event) => { onSubmit={(event) => {
event.preventDefault(); event.preventDefault();
if (saving.current) return; if (
saving.current ||
(status !== "draft" &&
issues.some((issue) => issue.severity === "error"))
)
return;
const data = new FormData(event.currentTarget); const data = new FormData(event.currentTarget);
if (status === "scheduled" && publishAt) { if (status === "scheduled" && publishAt) {
const date = new Date(publishAt); const date = new Date(publishAt);
@@ -262,7 +283,13 @@ export function ArticleForm({
</span> </span>
<RichText <RichText
key={bodyVersion} key={bodyVersion}
onChange={markDirty} onChange={() => {
markDirty();
if (formRef.current)
setBody(
String(new FormData(formRef.current).get("fullStory") ?? ""),
);
}}
name="fullStory" name="fullStory"
defaultValue={body} defaultValue={body}
placeholder={t("bodyPlaceholder")} placeholder={t("bodyPlaceholder")}
@@ -314,6 +341,10 @@ export function ArticleForm({
) : null} ) : null}
</div> </div>
<PublicationPreflight
issues={issues}
paths={["/news", `/news/${effectiveSlug}`, "/"]}
/>
<div className="sticky bottom-0 flex flex-wrap gap-2 border-t border-[var(--admin-border)] bg-[var(--admin-surface)] py-3"> <div className="sticky bottom-0 flex flex-wrap gap-2 border-t border-[var(--admin-border)] bg-[var(--admin-surface)] py-3">
<button <button
type="button" type="button"
@@ -334,7 +365,11 @@ export function ArticleForm({
<button <button
type="submit" type="submit"
className="btn btn-primary" className="btn btn-primary"
disabled={pending} disabled={
pending ||
(status !== "draft" &&
issues.some((issue) => issue.severity === "error"))
}
aria-busy={pending} aria-busy={pending}
> >
{pending ? tAction("saving") : t("save")} {pending ? tAction("saving") : t("save")}
+4 -2
View File
@@ -25,9 +25,11 @@ export interface ArticlePreviewData {
export function ArticlePreview({ export function ArticlePreview({
data, data,
onClose, onClose,
title,
}: { }: {
data: ArticlePreviewData | null; data: ArticlePreviewData | null;
onClose: () => void; onClose: () => void;
title?: string;
}) { }) {
const t = useTranslations("pages.admin.articles.form"); const t = useTranslations("pages.admin.articles.form");
const document = data const document = data
@@ -42,13 +44,13 @@ export function ArticlePreview({
> >
<DialogContent className="sm:max-w-4xl h-[85dvh] flex flex-col min-h-0"> <DialogContent className="sm:max-w-4xl h-[85dvh] flex flex-col min-h-0">
<DialogHeader> <DialogHeader>
<DialogTitle>{t("previewTitle")}</DialogTitle> <DialogTitle>{title ?? t("previewTitle")}</DialogTitle>
</DialogHeader> </DialogHeader>
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
{t("previewDescription")} {t("previewDescription")}
</p> </p>
<iframe <iframe
title={t("previewFrame")} title={title ?? t("previewFrame")}
sandbox="" sandbox=""
srcDoc={document} srcDoc={document}
className="min-h-0 flex-1 w-full bg-background rounded border" className="min-h-0 flex-1 w-full bg-background rounded border"
@@ -0,0 +1,55 @@
"use client";
import { useTranslations } from "next-intl";
import Link from "@/components/link";
import type { PublicationIssue } from "@/lib/publication-preflight";
export function PublicationPreflight({
issues,
paths,
}: {
issues: PublicationIssue[];
paths: string[];
}) {
const t = useTranslations("pages.admin.publicationPreflight");
return (
<section
className="rounded-lg border border-[var(--admin-border)] p-4 space-y-2"
aria-label={t("title")}
>
<h3 className="font-semibold">{t("title")}</h3>
<p className="text-xs text-muted-foreground">{t("description")}</p>
{issues.length ? (
<ul className="space-y-1 text-sm">
{issues.map((issue) => (
<li
key={issue.code}
className={
issue.severity === "error"
? "text-destructive"
: "text-muted-foreground"
}
>
{t(issue.code)}
</li>
))}
</ul>
) : (
<p className="text-sm">{t("ready")}</p>
)}
<p className="text-xs font-medium">{t("affectedPages")}</p>
<div className="flex flex-wrap gap-3 text-sm">
{paths.map((path) => (
<Link
key={path}
href={path}
target="_blank"
rel="noopener noreferrer"
className="underline break-all"
>
{path}
</Link>
))}
</div>
<p className="text-xs text-muted-foreground">{t("previewHint")}</p>
</section>
);
}
+17 -1
View File
@@ -1,3 +1,4 @@
import { publicationIssues } from "./publication-preflight";
import type { ActionResult } from "./safe-action-shared"; import type { ActionResult } from "./safe-action-shared";
export type ArticleSaveResult = ActionResult<{ redirectTo: string }>; export type ArticleSaveResult = ActionResult<{ redirectTo: string }>;
export class ArticleInputError extends Error { export class ArticleInputError extends Error {
@@ -10,7 +11,9 @@ export class ArticleInputError extends Error {
| "summaryTooLong" | "summaryTooLong"
| "imageTooLong" | "imageTooLong"
| "invalidStatus" | "invalidStatus"
| "publishDateRequired", | "publishDateRequired"
| "imageInvalid"
| "linksInvalid",
) { ) {
super(code); super(code);
} }
@@ -34,6 +37,17 @@ export function readArticleInput(form: FormData) {
const publishAt = status === "scheduled" ? new Date(text("publishAt")) : null; const publishAt = status === "scheduled" ? new Date(text("publishAt")) : null;
if (publishAt && Number.isNaN(publishAt.getTime())) if (publishAt && Number.isNaN(publishAt.getTime()))
throw new ArticleInputError("publishDateRequired"); throw new ArticleInputError("publishDateRequired");
if (status !== "draft") {
const issue = publicationIssues({
kind: "article",
image,
body: fullStory,
status,
publishAt,
}).find((issue) => issue.severity === "error");
if (issue?.code === "imageInvalid" || issue?.code === "linksInvalid")
throw new ArticleInputError(issue.code);
}
return { return {
title, title,
shortStory, shortStory,
@@ -57,6 +71,8 @@ export function articleInputField(
titleTooLong: "title", titleTooLong: "title",
summaryTooLong: "shortStory", summaryTooLong: "shortStory",
imageTooLong: "image", imageTooLong: "image",
imageInvalid: "image",
linksInvalid: "fullStory",
invalidStatus: "status", invalidStatus: "status",
publishDateRequired: "publishAt", publishDateRequired: "publishAt",
}; };
+100
View File
@@ -0,0 +1,100 @@
import { describe, expect, it } from "vitest";
import { readArticleInput } from "./article-input";
import { publicationIssues, safePublicationUrl } from "./publication-preflight";
describe("publication preflight", () => {
it("allows local images and web links but rejects unsafe schemes and credentials", () => {
expect(safePublicationUrl("/images/news.png", true)).toBe(true);
expect(safePublicationUrl("https://example.com/image.png", true)).toBe(
true,
);
for (const url of [
"javascript:alert(1)",
"//external.test/image",
"https://user:[email protected]/a",
"data:text/html,test",
"https:\\example.com",
]) {
expect(safePublicationUrl(url, true)).toBe(false);
}
});
it("checks encoded link schemes without fetching destinations", () => {
const issues = publicationIssues({
kind: "article",
image: "/cover.png",
body: '<a href="java&#x73;cript:alert(1)">bad</a>',
});
expect(issues).toContainEqual({
code: "linksInvalid",
severity: "error",
field: "fullStory",
});
expect(
publicationIssues({
kind: "article",
image: "/cover.png",
body: '<a href="/help">Help</a><a href="mailto:[email protected]">Mail</a>',
}),
).toEqual([]);
});
it("rejects reversed event dates and distinguishes a past-date warning", () => {
expect(
publicationIssues({
kind: "event",
startsAt: "2030-01-02",
endsAt: "2030-01-01",
}),
).toEqual(
expect.arrayContaining([
expect.objectContaining({ code: "scheduleInvalid", severity: "error" }),
]),
);
expect(
publicationIssues(
{ kind: "event", startsAt: "2020-01-01" },
Date.parse("2021-01-01"),
),
).toEqual(
expect.arrayContaining([
expect.objectContaining({ code: "schedulePast", severity: "warning" }),
]),
);
});
it("warns for missing image and normalized slug without blocking drafts", () => {
const issues = publicationIssues({
kind: "article",
slug: "Hello World",
normalizedSlug: "hello-world",
});
expect(issues.map((i) => i.code)).toEqual([
"imageMissing",
"slugNormalized",
]);
expect(issues.every((i) => i.severity === "warning")).toBe(true);
});
it("requires a valid scheduled publication date", () => {
expect(
publicationIssues({
kind: "article",
status: "scheduled",
publishAt: "",
}),
).toEqual(
expect.arrayContaining([
expect.objectContaining({ code: "scheduleInvalid" }),
]),
);
});
it("enforces image and link checks in server article input but preserves draft saving", () => {
const form = new FormData();
form.set("title", "News");
form.set("image", "javascript:alert(1)");
expect(() => readArticleInput(form)).toThrow("imageInvalid");
form.set("status", "draft");
expect(readArticleInput(form).image).toBe("javascript:alert(1)");
form.set("status", "published");
form.set("image", "/cover.png");
form.set("fullStory", '<a href="javascript:alert(1)">bad</a>');
expect(() => readArticleInput(form)).toThrow("linksInvalid");
});
});
+116
View File
@@ -0,0 +1,116 @@
export type PublicationIssueCode =
| "imageMissing"
| "imageInvalid"
| "linksInvalid"
| "scheduleInvalid"
| "schedulePast"
| "slugNormalized";
export interface PublicationIssue {
code: PublicationIssueCode;
severity: "error" | "warning";
field: string;
}
export interface PublicationInput {
kind: "article" | "event";
image?: string | null;
body?: string;
slug?: string;
normalizedSlug?: string;
status?: string;
publishAt?: Date | string | null;
startsAt?: Date | string | null;
endsAt?: Date | string | null;
}
export function safePublicationUrl(value: string, image = false): boolean {
const text = value.trim();
if (
!text ||
[...text].some((char) => char.charCodeAt(0) <= 32 || char === "\\")
)
return false;
if (text.startsWith("/") && !text.startsWith("//")) return true;
if (!image && text.startsWith("#")) return true;
try {
const url = new URL(text);
return (
!url.username &&
!url.password &&
(url.protocol === "https:" ||
url.protocol === "http:" ||
(!image && url.protocol === "mailto:"))
);
} catch {
return false;
}
}
function decodeLink(value: string): string {
return value
.replace(/&#(x[\da-f]+|\d+);?/gi, (_all, code: string) => {
const n =
code[0].toLowerCase() === "x"
? Number.parseInt(code.slice(1), 16)
: Number(code);
return n > 0 && n <= 0x10ffff ? String.fromCodePoint(n) : "";
})
.replace(/&colon;/gi, ":")
.replace(/&amp;/gi, "&");
}
export function publicationIssues(
input: PublicationInput,
now = Date.now(),
): PublicationIssue[] {
const issues: PublicationIssue[] = [];
if (!input.image?.trim())
issues.push({ code: "imageMissing", severity: "warning", field: "image" });
else if (!safePublicationUrl(input.image, true))
issues.push({ code: "imageInvalid", severity: "error", field: "image" });
if (input.kind === "article") {
const links = [
...(input.body ?? "").matchAll(
/\bhref\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s>]+))/gi,
),
].map((match) => decodeLink(match[1] ?? match[2] ?? match[3] ?? ""));
if (links.some((link) => !safePublicationUrl(link)))
issues.push({
code: "linksInvalid",
severity: "error",
field: "fullStory",
});
if (
input.slug &&
input.normalizedSlug &&
input.slug !== input.normalizedSlug
)
issues.push({
code: "slugNormalized",
severity: "warning",
field: "slug",
});
}
const date =
input.kind === "event"
? input.startsAt
: input.status === "scheduled"
? input.publishAt
: null;
if (input.kind === "event" || input.status === "scheduled") {
const start = date ? new Date(date).getTime() : NaN;
const end = input.endsAt ? new Date(input.endsAt).getTime() : null;
if (
!Number.isFinite(start) ||
(end !== null && (!Number.isFinite(end) || end <= start))
)
issues.push({
code: "scheduleInvalid",
severity: "error",
field: input.kind === "event" ? "startsAt" : "publishAt",
});
else if (start < now)
issues.push({
code: "schedulePast",
severity: "warning",
field: input.kind === "event" ? "startsAt" : "publishAt",
});
}
return issues;
}
+25 -1
View File
@@ -1,6 +1,6 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { createEventSchema, eventTypeSchema } from "./event"; import { createEventSchema, eventTypeSchema, updateEventSchema } from "./event";
describe("eventTypeSchema", () => { describe("eventTypeSchema", () => {
it("accepts valid type", () => { it("accepts valid type", () => {
@@ -98,3 +98,27 @@ describe("createEventSchema", () => {
} }
}); });
}); });
it("preserves omitted status and recurrence on partial event updates", () => {
expect(updateEventSchema.parse({ title: "Changed" })).toEqual({
title: "Changed",
});
});
it("rejects a null event start instead of converting it to the Unix epoch", () => {
expect(
createEventSchema.safeParse({
title: "Event",
description: "Details",
typeId: 1,
startsAt: null,
status: "published",
}).success,
).toBe(false);
});
it("distinguishes an omitted image from explicit image removal", () => {
expect(updateEventSchema.parse({ title: "Changed" })).not.toHaveProperty(
"image",
);
expect(updateEventSchema.parse({ image: "" })).toEqual({ image: null });
});
+14 -2
View File
@@ -25,7 +25,11 @@ export const createEventSchema = z.object({
emptyToUndefined, emptyToUndefined,
z.coerce.number().int().positive().nullable().optional(), z.coerce.number().int().positive().nullable().optional(),
), ),
startsAt: z.coerce.date(), startsAt: z.preprocess(
(value) =>
value == null || typeof value === "boolean" ? undefined : value,
z.coerce.date(),
),
endsAt: z.preprocess(emptyToUndefined, z.coerce.date().nullable().optional()), endsAt: z.preprocess(emptyToUndefined, z.coerce.date().nullable().optional()),
maxPlayers: z.preprocess( maxPlayers: z.preprocess(
emptyToUndefined, emptyToUndefined,
@@ -45,7 +49,15 @@ export const createEventSchema = z.object({
), ),
}); });
export const updateEventSchema = createEventSchema.partial(); // Zod applies defaults inside optional schemas; partial edits must not reset state.
export const updateEventSchema = createEventSchema.partial().extend({
status: createEventSchema.shape.status.removeDefault().optional(),
isRecurring: createEventSchema.shape.isRecurring.removeDefault().optional(),
image: z.preprocess(
(value) => (value === "" ? null : value),
z.string().max(500).nullable().optional(),
),
});
export const eventPrizeSchema = z.object({ export const eventPrizeSchema = z.object({
eventId: z.coerce.number().int().positive(), eventId: z.coerce.number().int().positive(),
+21 -1
View File
@@ -1237,7 +1237,9 @@
"restoreRevision": "Restore into editor", "restoreRevision": "Restore into editor",
"confirmRevision": "Replace the current editor content with this previous version as a draft? Review it and choose a publication status before saving.", "confirmRevision": "Replace the current editor content with this previous version as a draft? Review it and choose a publication status before saving.",
"editConflict": "This article changed after you opened it. Your changes were not applied. Copy your work, reload the article, and merge the changes before saving again.", "editConflict": "This article changed after you opened it. Your changes were not applied. Copy your work, reload the article, and merge the changes before saving again.",
"retryRecovery": "Reload recovery without changing this form" "retryRecovery": "Reload recovery without changing this form",
"imageInvalid": "Use a local image path or an HTTP/HTTPS image URL without credentials.",
"linksInvalid": "Review the content links: only local, HTTP/HTTPS and email links are allowed."
}, },
"articlesShown": "Articles shown", "articlesShown": "Articles shown",
"latest": "Latest article", "latest": "Latest article",
@@ -4332,6 +4334,19 @@
"start": "Start synchronization", "start": "Start synchronization",
"description": "Synchronization runs in the background. Progress and safe retries remain in your import history after you leave this page.", "description": "Synchronization runs in the background. Progress and safe retries remain in your import history after you leave this page.",
"failed": "Could not queue synchronization" "failed": "Could not queue synchronization"
},
"publicationPreflight": {
"affectedPages": "Affected public pages",
"scheduleInvalid": "Choose a valid start or publication date. An event end must follow its start.",
"description": "Checks use the current form values. Remote image and link availability is not tested.",
"slugNormalized": "The public address will use the normalized slug shown below.",
"imageInvalid": "Use a local image path or an HTTP/HTTPS image URL without credentials.",
"imageMissing": "No cover image selected. The page will use its fallback.",
"title": "Publication checks",
"ready": "Image, links and schedule passed the local checks.",
"linksInvalid": "Review the content links: only local, HTTP/HTTPS and email links are allowed.",
"schedulePast": "The selected date is in the past. Review it before publishing.",
"previewHint": "These links show the current public pages. Use Preview to check unsaved content. The final article slug may receive a suffix if already in use."
} }
}, },
"mod": { "mod": {
@@ -4674,5 +4689,10 @@
"excludedConflicts": "{count, plural, one {# item needs review and is excluded from this import.} other {# items need review and are excluded from this import.}}" "excludedConflicts": "{count, plural, one {# item needs review and is excluded from this import.} other {# items need review and are excluded from this import.}}"
} }
} }
},
"publicData": {
"loadError": "This section could not be loaded. Please try again.",
"retry": "Retry",
"unavailable": "Unavailable"
} }
} }
+21 -1
View File
@@ -1211,7 +1211,9 @@
"restoreRevision": "Ripristina nell’editor", "restoreRevision": "Ripristina nell’editor",
"confirmRevision": "Sostituire il contenuto corrente con questa versione precedente come bozza? Controllala e scegli lo stato di pubblicazione prima di salvare.", "confirmRevision": "Sostituire il contenuto corrente con questa versione precedente come bozza? Controllala e scegli lo stato di pubblicazione prima di salvare.",
"editConflict": "Questo articolo è stato modificato dopo la tua apertura. Le tue modifiche non sono state applicate. Copia il lavoro, ricarica l’articolo e integra le modifiche prima di salvare di nuovo.", "editConflict": "Questo articolo è stato modificato dopo la tua apertura. Le tue modifiche non sono state applicate. Copia il lavoro, ricarica l’articolo e integra le modifiche prima di salvare di nuovo.",
"retryRecovery": "Ricarica il recupero senza modificare il modulo" "retryRecovery": "Ricarica il recupero senza modificare il modulo",
"imageInvalid": "Usa un percorso locale o un URL immagine HTTP/HTTPS senza credenziali.",
"linksInvalid": "Controlla i link del contenuto: sono ammessi link locali, HTTP/HTTPS ed email."
}, },
"articlesShown": "Articoli mostrati", "articlesShown": "Articoli mostrati",
"latest": "Ultimo articolo", "latest": "Ultimo articolo",
@@ -4307,6 +4309,19 @@
"start": "Avvia sincronizzazione", "start": "Avvia sincronizzazione",
"description": "La sincronizzazione prosegue in background. Avanzamento e tentativi sicuri restano nello storico anche dopo aver chiuso la pagina.", "description": "La sincronizzazione prosegue in background. Avanzamento e tentativi sicuri restano nello storico anche dopo aver chiuso la pagina.",
"failed": "Impossibile accodare la sincronizzazione" "failed": "Impossibile accodare la sincronizzazione"
},
"publicationPreflight": {
"affectedPages": "Pagine pubbliche interessate",
"scheduleInvalid": "Scegli una data valida. La fine di un evento deve seguire il suo inizio.",
"description": "I controlli usano i valori del modulo. La disponibilita remota di immagini e link non viene verificata.",
"slugNormalized": "Il percorso pubblico usera lo slug normalizzato indicato sotto.",
"imageInvalid": "Usa un percorso locale o un URL immagine HTTP/HTTPS senza credenziali.",
"imageMissing": "Nessuna copertina selezionata. La pagina usera la propria immagine alternativa.",
"title": "Controlli pubblicazione",
"ready": "Immagine, link e date hanno superato i controlli locali.",
"linksInvalid": "Controlla i link del contenuto: sono ammessi link locali, HTTP/HTTPS ed email.",
"schedulePast": "La data selezionata e nel passato. Controllala prima di pubblicare.",
"previewHint": "I link mostrano le pagine pubbliche attuali. Usa Anteprima per vedere il contenuto non salvato. Lo slug finale potrebbe ricevere un suffisso se gia utilizzato."
} }
}, },
"radioRequests": { "radioRequests": {
@@ -4679,5 +4694,10 @@
"excludedConflicts": "{count, plural, one {# elemento richiede un controllo ed è escluso da questa importazione.} other {# elementi richiedono un controllo e sono esclusi da questa importazione.}}" "excludedConflicts": "{count, plural, one {# elemento richiede un controllo ed è escluso da questa importazione.} other {# elementi richiedono un controllo e sono esclusi da questa importazione.}}"
} }
} }
},
"publicData": {
"loadError": "Non è stato possibile caricare questa sezione. Riprova.",
"retry": "Riprova",
"unavailable": "Non disponibile"
} }
} }
+21 -1
View File
@@ -1222,7 +1222,9 @@
"statusScheduled": "Ingepland", "statusScheduled": "Ingepland",
"statusPublished": "Gepubliceerd", "statusPublished": "Gepubliceerd",
"publishAt": "Publiceren op", "publishAt": "Publiceren op",
"publishedAt": "Gepubliceerd op" "publishedAt": "Gepubliceerd op",
"imageInvalid": "Gebruik een lokaal pad of een HTTP/HTTPS-afbeeldingsadres zonder inloggegevens.",
"linksInvalid": "Controleer de links: alleen lokale, HTTP/HTTPS- en e-maillinks zijn toegestaan."
}, },
"listActionError": "De actie voor dit artikel kon niet worden voltooid.", "listActionError": "De actie voor dit artikel kon niet worden voltooid.",
"listStatus": "Publicatiestatus", "listStatus": "Publicatiestatus",
@@ -3923,6 +3925,19 @@
"start": "Synchronisatie starten", "start": "Synchronisatie starten",
"description": "Synchronisatie draait op de achtergrond. Voortgang en veilige nieuwe pogingen blijven beschikbaar in je importgeschiedenis.", "description": "Synchronisatie draait op de achtergrond. Voortgang en veilige nieuwe pogingen blijven beschikbaar in je importgeschiedenis.",
"failed": "Synchronisatie kon niet worden ingepland" "failed": "Synchronisatie kon niet worden ingepland"
},
"publicationPreflight": {
"affectedPages": "Betrokken openbare paginas",
"scheduleInvalid": "Kies een geldige datum. De eindtijd van een evenement moet na de begintijd liggen.",
"description": "Controles gebruiken de huidige formulierwaarden. Externe afbeeldingen en links worden niet op beschikbaarheid getest.",
"slugNormalized": "Het openbare adres gebruikt de hieronder getoonde genormaliseerde slug.",
"imageInvalid": "Gebruik een lokaal pad of een HTTP/HTTPS-afbeeldingsadres zonder inloggegevens.",
"imageMissing": "Geen omslagafbeelding geselecteerd. De pagina gebruikt de standaardafbeelding.",
"title": "Publicatiecontroles",
"ready": "Afbeelding, links en planning voldoen aan de lokale controles.",
"linksInvalid": "Controleer de links: alleen lokale, HTTP/HTTPS- en e-maillinks zijn toegestaan.",
"schedulePast": "De geselecteerde datum ligt in het verleden. Controleer deze voor publicatie.",
"previewHint": "Deze links tonen de huidige openbare paginas. Gebruik Voorbeeld voor niet-opgeslagen inhoud. De definitieve artikelslug kan een achtervoegsel krijgen als deze al bestaat."
} }
}, },
"mod": { "mod": {
@@ -4150,5 +4165,10 @@
"excludedConflicts": "{count, plural, one {# item moet worden gecontroleerd en is uitgesloten van deze import.} other {# items moeten worden gecontroleerd en zijn uitgesloten van deze import.}}" "excludedConflicts": "{count, plural, one {# item moet worden gecontroleerd en is uitgesloten van deze import.} other {# items moeten worden gecontroleerd en zijn uitgesloten van deze import.}}"
} }
} }
},
"publicData": {
"loadError": "Dit onderdeel kon niet worden geladen. Probeer het opnieuw.",
"retry": "Opnieuw proberen",
"unavailable": "Niet beschikbaar"
} }
} }