fix(news): persist publication requests and retry cache delivery
CI / check (push) Successful in 3m12s
CI / deploy (push) Successful in 1m13s
CI / publish-container (push) Successful in 42s

This commit is contained in:
Simo committed 2026-09-13 18:33:45 +02:00
1 parent c977fe95ba
commit 52f6d1491f
40 files changed
+733 -134

No files matched your search

+136 -80
View File
@@ -1,11 +1,13 @@
"use server";
import { createHash, randomUUID } from "node:crypto";
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 { historySnapshot, recordHistory } from "@/features/history/server";
import { enqueueEffect, runOperation } from "@/features/operations/server";
import { requirePermission } from "@/lib/admin/guard";
import { articleEditToken } from "@/lib/article-edit-token";
import {
@@ -77,37 +79,77 @@ async function refreshNews() {
revalidatePath("/me");
revalidatePath("/");
}
function operationInput(
form: FormData,
input: ReturnType<typeof readArticleInput>,
) {
return {
...input,
fullStory: createHash("sha256").update(input.fullStory).digest("hex"),
publishAt: input.publishAt?.toISOString() ?? null,
id: String(form.get("id") ?? ""),
baseToken: String(form.get("baseToken") ?? ""),
};
}
async function refreshSavedNews(id?: bigint) {
try {
await refreshNews();
if (id) revalidatePath(`/admin/articles/${id}`);
} catch (error) {
logger.error("News saved; cache refresh will be retried", {
module: "news",
error,
});
}
}
export async function createArticle(
formData: FormData,
): Promise<ArticleSaveResult> {
const staff = await requirePermission(PERMS.NEWS_EDIT);
let input: ReturnType<typeof readArticleInput>;
let slug: string;
let notification: Parameters<typeof notify>[0] | undefined;
try {
input = readArticleInput(formData);
const { rawSlug, ...fields } = input;
slug = await uniqueSlug(rawSlug || fields.title);
const now = new Date();
await db.insert(WebsiteArticles).values({
...fields,
slug,
userId: staff.id,
createdAt: now,
updatedAt: now,
publishedAt: fields.status === "published" ? now : null,
});
const input = readArticleInput(formData);
await runOperation(
{
actorId: staff.id,
kind: "news.create",
key: String(formData.get("requestKey") ?? randomUUID()),
input: operationInput(formData, input),
},
async (tx, operationId) => {
const { rawSlug, ...fields } = input;
const slug = await uniqueSlug(rawSlug || fields.title, undefined, tx);
const now = new Date();
await tx.insert(WebsiteArticles).values({
...fields,
slug,
userId: staff.id,
createdAt: now,
updatedAt: now,
publishedAt: fields.status === "published" ? now : null,
});
await enqueueEffect(tx, operationId, "news.refresh");
if (input.status === "published")
notification = {
action: "news_publish",
actor: staff.username,
target: input.title,
details: slug,
};
return { ok: true };
},
);
} catch (error) {
return saveFailure(error, "create");
}
// Auxiliary services must not turn a committed insert into an apparent failure.
if (input.status === "published")
notify({
action: "news_publish",
actor: staff.username,
target: input.title,
details: slug,
});
await refreshNews();
if (notification) {
try {
notify(notification);
} catch (error) {
logger.error("News notification failed", { module: "news", error });
}
}
await refreshSavedNews();
return { ok: true, data: { redirectTo: "/admin/articles" } };
}
export async function updateArticle(
@@ -122,66 +164,80 @@ export async function updateArticle(
let slug = "";
try {
input = readArticleInput(formData);
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");
const historyId = String(id);
const historyBefore = await historySnapshot(tx, "news", historyId);
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));
await recordHistory(tx, "news", historyId, staff.id, historyBefore);
});
await runOperation(
{
actorId: staff.id,
kind: "news.update",
key: String(formData.get("requestKey") ?? randomUUID()),
input: operationInput(formData, input),
},
async (tx, operationId) => {
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");
const historyId = String(id);
const historyBefore = await historySnapshot(tx, "news", historyId);
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));
await recordHistory(tx, "news", historyId, staff.id, historyBefore);
await enqueueEffect(tx, operationId, "news.refresh");
return { ok: true };
},
);
} catch (error) {
return saveFailure(error, "update");
}
if (becamePublished)
notify({
action: "news_publish",
actor: staff.username,
target: input.title,
details: slug,
});
await refreshNews();
revalidatePath(`/admin/articles/${id}`);
if (becamePublished) {
try {
notify({
action: "news_publish",
actor: staff.username,
target: input.title,
details: slug,
});
} catch (error) {
logger.error("News notification failed", { module: "news", error });
}
}
await refreshSavedNews(id);
return { ok: true, data: { redirectTo: "/admin/articles" } };
}