feat(editorial): validate publications and preserve partial event updates
This commit is contained in:
1 parent
76f0420d64
commit
db4acbb46e
14 files changed
+613
-16
No files matched your search
@@ -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();
|
||||
});
|
||||
@@ -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)
|
||||
|
||||
@@ -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) {
|
||||
<DateTimePicker
|
||||
value={form.watch("endsAt")}
|
||||
onChange={(d) =>
|
||||
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) {
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end">
|
||||
<Button type="submit" disabled={isPending}>
|
||||
<PublicationPreflight
|
||||
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
|
||||
? t("formSaving")
|
||||
: isEditing
|
||||
|
||||
@@ -7,10 +7,12 @@ import { useUnsavedChanges } from "@/hooks/use-unsaved-changes";
|
||||
import type { ArticleDraft } from "@/lib/article-draft";
|
||||
import type { ArticleSaveResult } from "@/lib/article-input";
|
||||
import { slugify } from "@/lib/format";
|
||||
import { publicationIssues } from "@/lib/publication-preflight";
|
||||
import { ArticlePreview, type ArticlePreviewData } from "./article-preview";
|
||||
import { useArticleRecovery } from "./article-recovery";
|
||||
import { FormFieldError, useFormErrorFocus } from "./form-feedback";
|
||||
import { MediaPicker } from "./media-picker";
|
||||
import { PublicationPreflight } from "./publication-preflight";
|
||||
import { RichText } from "./rich-text";
|
||||
|
||||
export function ArticleForm({
|
||||
@@ -91,14 +93,33 @@ export function ArticleForm({
|
||||
useFormErrorFocus(formRef, fieldErrors, pending);
|
||||
const [preview, setPreview] = useState<ArticlePreviewData | null>(null);
|
||||
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 (
|
||||
<form
|
||||
ref={formRef}
|
||||
onChangeCapture={markDirty}
|
||||
onChangeCapture={() => {
|
||||
markDirty();
|
||||
if (formRef.current)
|
||||
setBody(String(new FormData(formRef.current).get("fullStory") ?? ""));
|
||||
}}
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
if (saving.current) return;
|
||||
if (
|
||||
saving.current ||
|
||||
(status !== "draft" &&
|
||||
issues.some((issue) => issue.severity === "error"))
|
||||
)
|
||||
return;
|
||||
const data = new FormData(event.currentTarget);
|
||||
if (status === "scheduled" && publishAt) {
|
||||
const date = new Date(publishAt);
|
||||
@@ -262,7 +283,13 @@ export function ArticleForm({
|
||||
</span>
|
||||
<RichText
|
||||
key={bodyVersion}
|
||||
onChange={markDirty}
|
||||
onChange={() => {
|
||||
markDirty();
|
||||
if (formRef.current)
|
||||
setBody(
|
||||
String(new FormData(formRef.current).get("fullStory") ?? ""),
|
||||
);
|
||||
}}
|
||||
name="fullStory"
|
||||
defaultValue={body}
|
||||
placeholder={t("bodyPlaceholder")}
|
||||
@@ -314,6 +341,10 @@ export function ArticleForm({
|
||||
) : null}
|
||||
</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">
|
||||
<button
|
||||
type="button"
|
||||
@@ -334,7 +365,11 @@ export function ArticleForm({
|
||||
<button
|
||||
type="submit"
|
||||
className="btn btn-primary"
|
||||
disabled={pending}
|
||||
disabled={
|
||||
pending ||
|
||||
(status !== "draft" &&
|
||||
issues.some((issue) => issue.severity === "error"))
|
||||
}
|
||||
aria-busy={pending}
|
||||
>
|
||||
{pending ? tAction("saving") : t("save")}
|
||||
|
||||
@@ -25,9 +25,11 @@ export interface ArticlePreviewData {
|
||||
export function ArticlePreview({
|
||||
data,
|
||||
onClose,
|
||||
title,
|
||||
}: {
|
||||
data: ArticlePreviewData | null;
|
||||
onClose: () => void;
|
||||
title?: string;
|
||||
}) {
|
||||
const t = useTranslations("pages.admin.articles.form");
|
||||
const document = data
|
||||
@@ -42,13 +44,13 @@ export function ArticlePreview({
|
||||
>
|
||||
<DialogContent className="sm:max-w-4xl h-[85dvh] flex flex-col min-h-0">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t("previewTitle")}</DialogTitle>
|
||||
<DialogTitle>{title ?? t("previewTitle")}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("previewDescription")}
|
||||
</p>
|
||||
<iframe
|
||||
title={t("previewFrame")}
|
||||
title={title ?? t("previewFrame")}
|
||||
sandbox=""
|
||||
srcDoc={document}
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import { publicationIssues } from "./publication-preflight";
|
||||
import type { ActionResult } from "./safe-action-shared";
|
||||
export type ArticleSaveResult = ActionResult<{ redirectTo: string }>;
|
||||
export class ArticleInputError extends Error {
|
||||
@@ -10,7 +11,9 @@ export class ArticleInputError extends Error {
|
||||
| "summaryTooLong"
|
||||
| "imageTooLong"
|
||||
| "invalidStatus"
|
||||
| "publishDateRequired",
|
||||
| "publishDateRequired"
|
||||
| "imageInvalid"
|
||||
| "linksInvalid",
|
||||
) {
|
||||
super(code);
|
||||
}
|
||||
@@ -34,6 +37,17 @@ export function readArticleInput(form: FormData) {
|
||||
const publishAt = status === "scheduled" ? new Date(text("publishAt")) : null;
|
||||
if (publishAt && Number.isNaN(publishAt.getTime()))
|
||||
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 {
|
||||
title,
|
||||
shortStory,
|
||||
@@ -57,6 +71,8 @@ export function articleInputField(
|
||||
titleTooLong: "title",
|
||||
summaryTooLong: "shortStory",
|
||||
imageTooLong: "image",
|
||||
imageInvalid: "image",
|
||||
linksInvalid: "fullStory",
|
||||
invalidStatus: "status",
|
||||
publishDateRequired: "publishAt",
|
||||
};
|
||||
|
||||
@@ -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="javascript: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");
|
||||
});
|
||||
});
|
||||
@@ -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(/:/gi, ":")
|
||||
.replace(/&/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;
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { createEventSchema, eventTypeSchema } from "./event";
|
||||
import { createEventSchema, eventTypeSchema, updateEventSchema } from "./event";
|
||||
|
||||
describe("eventTypeSchema", () => {
|
||||
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 });
|
||||
});
|
||||
@@ -25,7 +25,11 @@ export const createEventSchema = z.object({
|
||||
emptyToUndefined,
|
||||
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()),
|
||||
maxPlayers: z.preprocess(
|
||||
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({
|
||||
eventId: z.coerce.number().int().positive(),
|
||||
|
||||
+21
-1
@@ -1237,7 +1237,9 @@
|
||||
"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.",
|
||||
"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",
|
||||
"latest": "Latest article",
|
||||
@@ -4332,6 +4334,19 @@
|
||||
"start": "Start synchronization",
|
||||
"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"
|
||||
},
|
||||
"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": {
|
||||
@@ -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.}}"
|
||||
}
|
||||
}
|
||||
},
|
||||
"publicData": {
|
||||
"loadError": "This section could not be loaded. Please try again.",
|
||||
"retry": "Retry",
|
||||
"unavailable": "Unavailable"
|
||||
}
|
||||
}
|
||||
+21
-1
@@ -1211,7 +1211,9 @@
|
||||
"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.",
|
||||
"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",
|
||||
"latest": "Ultimo articolo",
|
||||
@@ -4307,6 +4309,19 @@
|
||||
"start": "Avvia sincronizzazione",
|
||||
"description": "La sincronizzazione prosegue in background. Avanzamento e tentativi sicuri restano nello storico anche dopo aver chiuso la pagina.",
|
||||
"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": {
|
||||
@@ -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.}}"
|
||||
}
|
||||
}
|
||||
},
|
||||
"publicData": {
|
||||
"loadError": "Non è stato possibile caricare questa sezione. Riprova.",
|
||||
"retry": "Riprova",
|
||||
"unavailable": "Non disponibile"
|
||||
}
|
||||
}
|
||||
+21
-1
@@ -1222,7 +1222,9 @@
|
||||
"statusScheduled": "Ingepland",
|
||||
"statusPublished": "Gepubliceerd",
|
||||
"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.",
|
||||
"listStatus": "Publicatiestatus",
|
||||
@@ -3923,6 +3925,19 @@
|
||||
"start": "Synchronisatie starten",
|
||||
"description": "Synchronisatie draait op de achtergrond. Voortgang en veilige nieuwe pogingen blijven beschikbaar in je importgeschiedenis.",
|
||||
"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": {
|
||||
@@ -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.}}"
|
||||
}
|
||||
}
|
||||
},
|
||||
"publicData": {
|
||||
"loadError": "Dit onderdeel kon niet worden geladen. Probeer het opnieuw.",
|
||||
"retry": "Opnieuw proberen",
|
||||
"unavailable": "Niet beschikbaar"
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user