feat(cms): improve catalog, editorial recovery and operations
CI / check (push) Successful in 52s
CI / deploy (push) Successful in 2m10s
CI / publish-container (push) Failing after 1m18s

This commit is contained in:
Simo committed 2026-09-09 19:36:15 +02:00
1 parent 2fead134e6
commit c389c3893d
122 files changed
+8137 -703

No files matched your search

+60 -33
View File
@@ -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));
});