feat(cms): improve catalog, editorial recovery and operations
This commit is contained in:
1 parent
2fead134e6
commit
c389c3893d
122 files changed
+8137
-703
No files matched your search
@@ -27,12 +27,16 @@ vi.mock("next/navigation", () => ({
|
||||
throw Error(`redirect ${url}`);
|
||||
},
|
||||
}));
|
||||
vi.mock("@/lib/db", async () => ({
|
||||
...(await import("@/db/schema")),
|
||||
db: {
|
||||
vi.mock("@/lib/db", async () => {
|
||||
const database = {
|
||||
select: () => ({
|
||||
from: () => ({
|
||||
where: () => ({ limit: async () => state.rows.shift() ?? [] }),
|
||||
where: () => ({
|
||||
limit: () => {
|
||||
const result = Promise.resolve(state.rows.shift() ?? []);
|
||||
return Object.assign(result, { for: () => result });
|
||||
},
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
insert: () => ({ values: state.insert }),
|
||||
@@ -42,9 +46,13 @@ vi.mock("@/lib/db", async () => ({
|
||||
return { where: async () => [{ affectedRows: 1 }] };
|
||||
},
|
||||
}),
|
||||
},
|
||||
}));
|
||||
transaction: async (fn: (tx: unknown) => unknown): Promise<unknown> =>
|
||||
fn(database),
|
||||
};
|
||||
return { ...(await import("@/db/schema")), db: database };
|
||||
});
|
||||
|
||||
import { articleEditToken } from "@/lib/article-edit-token";
|
||||
import { createArticle, updateArticle } from "./admin-articles";
|
||||
|
||||
function form(extra: Record<string, string> = {}) {
|
||||
@@ -89,11 +97,25 @@ describe("news saves", () => {
|
||||
expect(state.invalidate).toHaveBeenCalledOnce();
|
||||
});
|
||||
it("preserves the slug and clears old scheduling for immediate publication", async () => {
|
||||
state.rows = [[{ slug: "test-news", status: "draft", publishedAt: null }]];
|
||||
const existing = {
|
||||
title: "Old",
|
||||
slug: "test-news",
|
||||
image: "",
|
||||
shortStory: "",
|
||||
fullStory: "",
|
||||
status: "draft",
|
||||
publishAt: null,
|
||||
publishedAt: null,
|
||||
};
|
||||
state.rows = [[existing]];
|
||||
expect(
|
||||
(
|
||||
await updateArticle(
|
||||
form({ id: "1", publishAt: "2099-01-01T00:00:00Z" }),
|
||||
form({
|
||||
id: "1",
|
||||
baseToken: articleEditToken(existing),
|
||||
publishAt: "2099-01-01T00:00:00Z",
|
||||
}),
|
||||
)
|
||||
).ok,
|
||||
).toBe(true);
|
||||
@@ -106,6 +128,27 @@ describe("news saves", () => {
|
||||
);
|
||||
expect(state.invalidate).toHaveBeenCalledOnce();
|
||||
});
|
||||
it("rejects an outdated editor without inserting a revision or overwriting the article", async () => {
|
||||
state.rows = [
|
||||
[
|
||||
{
|
||||
title: "Changed by another editor",
|
||||
slug: "test-news",
|
||||
image: "",
|
||||
shortStory: "",
|
||||
fullStory: "",
|
||||
status: "published",
|
||||
publishAt: null,
|
||||
},
|
||||
],
|
||||
];
|
||||
expect(
|
||||
await updateArticle(form({ id: "1", baseToken: "outdated" })),
|
||||
).toEqual({ ok: false, error: "editConflict" });
|
||||
expect(state.insert).not.toHaveBeenCalled();
|
||||
expect(state.update).not.toHaveBeenCalled();
|
||||
expect(state.invalidate).not.toHaveBeenCalled();
|
||||
});
|
||||
it("rejects invalid scheduled dates before writing", async () => {
|
||||
expect(
|
||||
(await createArticle(form({ status: "scheduled", publishAt: "invalid" })))
|
||||
|
||||
@@ -4,7 +4,9 @@ import { and, eq, ne } from "drizzle-orm";
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { redirect } from "next/navigation";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { ArticleDrafts, ArticleRevisions } from "@/db/article-editor";
|
||||
import { requirePermission } from "@/lib/admin/guard";
|
||||
import { articleEditToken } from "@/lib/article-edit-token";
|
||||
import {
|
||||
ArticleInputError,
|
||||
type ArticleSaveResult,
|
||||
@@ -23,11 +25,15 @@ import { PERMS } from "@/lib/permissions";
|
||||
import { invalidateNewsCache } from "@/lib/services/news-cache";
|
||||
import { notify } from "@/lib/services/webhook";
|
||||
|
||||
async function uniqueSlug(title: string, excludeId?: bigint): Promise<string> {
|
||||
async function uniqueSlug(
|
||||
title: string,
|
||||
excludeId?: bigint,
|
||||
connection: Pick<typeof db, "select"> = db,
|
||||
): Promise<string> {
|
||||
const base = slugify(title);
|
||||
let slug = base;
|
||||
for (let n = 2; ; n++) {
|
||||
const [existing] = await db
|
||||
const [existing] = await connection
|
||||
.select({ id: WebsiteArticles.id })
|
||||
.from(WebsiteArticles)
|
||||
.where(
|
||||
@@ -105,39 +111,54 @@ export async function updateArticle(
|
||||
if (!id) return { ok: false, error: t("articleNotFound") };
|
||||
let input: ReturnType<typeof readArticleInput>;
|
||||
let becamePublished = false;
|
||||
let slug: string;
|
||||
let slug = "";
|
||||
try {
|
||||
input = readArticleInput(formData);
|
||||
const [existing] = await db
|
||||
.select({
|
||||
slug: WebsiteArticles.slug,
|
||||
status: WebsiteArticles.status,
|
||||
publishedAt: WebsiteArticles.publishedAt,
|
||||
})
|
||||
.from(WebsiteArticles)
|
||||
.where(eq(WebsiteArticles.id, id))
|
||||
.limit(1);
|
||||
if (!existing) return { ok: false, error: t("articleNotFound") };
|
||||
const { rawSlug, ...fields } = input;
|
||||
const requestedSlug = rawSlug ? slugify(rawSlug) : existing.slug;
|
||||
slug =
|
||||
requestedSlug === existing.slug
|
||||
? existing.slug
|
||||
: await uniqueSlug(requestedSlug, id);
|
||||
becamePublished =
|
||||
fields.status === "published" && existing.status !== "published";
|
||||
await db
|
||||
.update(WebsiteArticles)
|
||||
.set({
|
||||
...fields,
|
||||
slug,
|
||||
publishedAt:
|
||||
fields.status === "published"
|
||||
? (existing.publishedAt ?? new Date())
|
||||
: null,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(WebsiteArticles.id, id));
|
||||
await db.transaction(async (tx) => {
|
||||
const [existing] = await tx
|
||||
.select()
|
||||
.from(WebsiteArticles)
|
||||
.where(eq(WebsiteArticles.id, id))
|
||||
.limit(1)
|
||||
.for("update");
|
||||
if (!existing) throw new ArticleInputError("articleNotFound");
|
||||
if (formData.get("baseToken") !== articleEditToken(existing))
|
||||
throw new ArticleInputError("editConflict");
|
||||
await tx.insert(ArticleRevisions).values({
|
||||
articleId: id,
|
||||
userId: staff.id,
|
||||
payload: JSON.stringify({
|
||||
title: existing.title,
|
||||
slug: existing.slug,
|
||||
image: existing.image,
|
||||
shortStory: existing.shortStory,
|
||||
fullStory: existing.fullStory,
|
||||
status: existing.status,
|
||||
publishAt: existing.publishAt?.toISOString() ?? "",
|
||||
baseToken: "",
|
||||
}),
|
||||
});
|
||||
const { rawSlug, ...fields } = input;
|
||||
const requestedSlug = rawSlug ? slugify(rawSlug) : existing.slug;
|
||||
slug =
|
||||
requestedSlug === existing.slug
|
||||
? existing.slug
|
||||
: await uniqueSlug(requestedSlug, id, tx);
|
||||
becamePublished =
|
||||
fields.status === "published" && existing.status !== "published";
|
||||
await tx
|
||||
.update(WebsiteArticles)
|
||||
.set({
|
||||
...fields,
|
||||
slug,
|
||||
publishedAt:
|
||||
fields.status === "published"
|
||||
? (existing.publishedAt ?? new Date())
|
||||
: null,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(WebsiteArticles.id, id));
|
||||
});
|
||||
} catch (error) {
|
||||
return saveFailure(error, "update");
|
||||
}
|
||||
@@ -170,6 +191,12 @@ export async function deleteArticle(formData: FormData): Promise<void> {
|
||||
await tx
|
||||
.delete(WebsiteArticleComments)
|
||||
.where(eq(WebsiteArticleComments.articleId, id));
|
||||
await tx
|
||||
.delete(ArticleRevisions)
|
||||
.where(eq(ArticleRevisions.articleId, id));
|
||||
await tx
|
||||
.delete(ArticleDrafts)
|
||||
.where(eq(ArticleDrafts.articleKey, String(id)));
|
||||
await tx.delete(WebsiteArticles).where(eq(WebsiteArticles.id, id));
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
import { beforeEach, expect, it, vi } from "vitest";
|
||||
|
||||
const state = vi.hoisted(() => ({
|
||||
version: 0,
|
||||
update: vi.fn(),
|
||||
insert: vi.fn(),
|
||||
permission: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/lib/admin/guard", () => ({ requirePermission: state.permission }));
|
||||
vi.mock("@/lib/permissions", () => ({ PERMS: { NEWS_EDIT: "news.edit" } }));
|
||||
vi.mock("@/lib/db", async () => {
|
||||
const tx = {
|
||||
insert: (table: unknown) => ({
|
||||
values: (value: unknown) => {
|
||||
state.insert(table, value);
|
||||
return { onDuplicateKeyUpdate: async () => {} };
|
||||
},
|
||||
}),
|
||||
select: () => ({
|
||||
from: () => ({
|
||||
where: () => ({ for: async () => [{ version: state.version }] }),
|
||||
}),
|
||||
}),
|
||||
update: (table: unknown) => ({
|
||||
set: (value: unknown) => {
|
||||
state.update(table, value);
|
||||
return { where: async () => {} };
|
||||
},
|
||||
}),
|
||||
};
|
||||
return {
|
||||
...(await import("@/db/schema")),
|
||||
db: { transaction: async (fn: (value: typeof tx) => unknown) => fn(tx) },
|
||||
};
|
||||
});
|
||||
|
||||
import { ArticleDrafts } from "@/db/article-editor";
|
||||
import { autosaveArticle } from "./article-recovery";
|
||||
|
||||
function draft() {
|
||||
const form = new FormData();
|
||||
form.set("title", "Incomplete title");
|
||||
form.set("status", "published");
|
||||
form.set("fullStory", "Work in progress");
|
||||
return form;
|
||||
}
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
state.version = 0;
|
||||
state.permission.mockResolvedValue({ id: 17 });
|
||||
});
|
||||
it("stores incomplete editor content only in a private draft table even when publication is selected", async () => {
|
||||
expect(await autosaveArticle("new", 0, draft())).toEqual({
|
||||
ok: true,
|
||||
version: 1,
|
||||
});
|
||||
expect(state.permission).toHaveBeenCalledWith("news.edit");
|
||||
expect(state.insert).toHaveBeenCalledWith(
|
||||
ArticleDrafts,
|
||||
expect.objectContaining({ userId: 17, articleKey: "new" }),
|
||||
);
|
||||
expect(state.update).toHaveBeenCalledOnce();
|
||||
expect(state.update).toHaveBeenCalledWith(
|
||||
ArticleDrafts,
|
||||
expect.objectContaining({ version: 1 }),
|
||||
);
|
||||
});
|
||||
it("does not replace a more recent draft from another tab", async () => {
|
||||
state.version = 4;
|
||||
expect(await autosaveArticle("new", 3, draft())).toEqual({ ok: false });
|
||||
expect(state.update).not.toHaveBeenCalled();
|
||||
});
|
||||
it("does not access storage when the editor permission is denied", async () => {
|
||||
state.permission.mockRejectedValue(new Error("denied"));
|
||||
await expect(autosaveArticle("new", 0, draft())).rejects.toThrow("denied");
|
||||
expect(state.insert).not.toHaveBeenCalled();
|
||||
});
|
||||
it("rejects excessive article content before writing", async () => {
|
||||
const form = draft();
|
||||
form.set("fullStory", "x".repeat(500_001));
|
||||
await expect(autosaveArticle("new", 0, form)).rejects.toThrow("draftInvalid");
|
||||
expect(state.insert).not.toHaveBeenCalled();
|
||||
});
|
||||
@@ -0,0 +1,110 @@
|
||||
"use server";
|
||||
import { and, desc, eq } from "drizzle-orm";
|
||||
import { ArticleDrafts, ArticleRevisions } from "@/db/article-editor";
|
||||
import { requirePermission } from "@/lib/admin/guard";
|
||||
import {
|
||||
type ArticleDraft,
|
||||
articleKey,
|
||||
readArticleDraft,
|
||||
} from "@/lib/article-draft";
|
||||
import { db, WebsiteArticles } from "@/lib/db";
|
||||
import { PERMS } from "@/lib/permissions";
|
||||
|
||||
export async function loadArticleRecovery(key: string) {
|
||||
const staff = await requirePermission(PERMS.NEWS_EDIT);
|
||||
key = articleKey(key);
|
||||
const [draft] = await db
|
||||
.select()
|
||||
.from(ArticleDrafts)
|
||||
.where(
|
||||
and(
|
||||
eq(ArticleDrafts.userId, staff.id),
|
||||
eq(ArticleDrafts.articleKey, key),
|
||||
),
|
||||
)
|
||||
.limit(1);
|
||||
const revisions =
|
||||
key === "new"
|
||||
? []
|
||||
: await db
|
||||
.select()
|
||||
.from(ArticleRevisions)
|
||||
.where(eq(ArticleRevisions.articleId, BigInt(key)))
|
||||
.orderBy(desc(ArticleRevisions.id))
|
||||
.limit(20);
|
||||
return {
|
||||
version: draft?.version ?? 0,
|
||||
draft:
|
||||
draft && draft.payload !== "{}"
|
||||
? {
|
||||
version: draft.version,
|
||||
payload: JSON.parse(draft.payload) as ArticleDraft,
|
||||
}
|
||||
: null,
|
||||
revisions: revisions.map((r) => ({
|
||||
id: r.id,
|
||||
createdAt: r.createdAt.toISOString(),
|
||||
payload: JSON.parse(r.payload) as ArticleDraft,
|
||||
})),
|
||||
};
|
||||
}
|
||||
export async function autosaveArticle(
|
||||
key: string,
|
||||
expectedVersion: number,
|
||||
form: FormData,
|
||||
) {
|
||||
const staff = await requirePermission(PERMS.NEWS_EDIT);
|
||||
key = articleKey(key);
|
||||
const payload = JSON.stringify(readArticleDraft(form));
|
||||
if (!Number.isSafeInteger(expectedVersion) || expectedVersion < 0)
|
||||
throw new Error("draftInvalid");
|
||||
return db.transaction(async (tx) => {
|
||||
if (key !== "new") {
|
||||
const [article] = await tx
|
||||
.select({ id: WebsiteArticles.id })
|
||||
.from(WebsiteArticles)
|
||||
.where(eq(WebsiteArticles.id, BigInt(key)))
|
||||
.limit(1)
|
||||
.for("update");
|
||||
if (!article) return { ok: false as const };
|
||||
}
|
||||
await tx
|
||||
.insert(ArticleDrafts)
|
||||
.values({ userId: staff.id, articleKey: key, version: 0, payload: "{}" })
|
||||
.onDuplicateKeyUpdate({ set: { articleKey: key } });
|
||||
const where = and(
|
||||
eq(ArticleDrafts.userId, staff.id),
|
||||
eq(ArticleDrafts.articleKey, key),
|
||||
);
|
||||
const [draft] = await tx
|
||||
.select()
|
||||
.from(ArticleDrafts)
|
||||
.where(where)
|
||||
.for("update");
|
||||
if (!draft || draft.version !== expectedVersion)
|
||||
return { ok: false as const };
|
||||
await tx
|
||||
.update(ArticleDrafts)
|
||||
.set({ payload, version: expectedVersion + 1, updatedAt: new Date() })
|
||||
.where(where);
|
||||
return { ok: true as const, version: expectedVersion + 1 };
|
||||
});
|
||||
}
|
||||
|
||||
export async function clearArticleRecovery(
|
||||
key: string,
|
||||
expectedVersion: number,
|
||||
) {
|
||||
const staff = await requirePermission(PERMS.NEWS_EDIT);
|
||||
key = articleKey(key);
|
||||
await db
|
||||
.update(ArticleDrafts)
|
||||
.set({ payload: "{}", version: expectedVersion + 1, updatedAt: new Date() })
|
||||
.where(
|
||||
and(
|
||||
eq(ArticleDrafts.userId, staff.id),
|
||||
eq(ArticleDrafts.articleKey, key),
|
||||
eq(ArticleDrafts.version, expectedVersion),
|
||||
),
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user