fix(news): persist publication requests and retry cache delivery
This commit is contained in:
1 parent
c977fe95ba
commit
52f6d1491f
40 files changed
+733
-134
No files matched your search
+136
-80
@@ -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" } };
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user