feat(cms): add recovery history and operational reliability tools
CI / check (push) Failing after 22s
CI / deploy (push) Skipped
CI / publish-container (push) Skipped

This commit is contained in:
Simo committed 2026-09-09 21:57:56 +02:00
1 parent 89526af344
commit 1ef405be0a
92 files changed
+6384 -267

No files matched your search

+4
View File
@@ -8,6 +8,10 @@ const state = vi.hoisted(() => ({
log: vi.fn(() => "news-error-1"),
notify: vi.fn(),
}));
vi.mock("@/features/history/server", () => ({
historySnapshot: async () => ({}),
recordHistory: vi.fn(),
}));
vi.mock("@/lib/admin/guard", () => ({
requirePermission: async () => ({ id: 1, username: "staff" }),
}));
+13 -2
View File
@@ -5,11 +5,13 @@ 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 { requirePermission } from "@/lib/admin/guard";
import { articleEditToken } from "@/lib/article-edit-token";
import {
ArticleInputError,
type ArticleSaveResult,
articleInputField,
readArticleInput,
} from "@/lib/article-input";
import {
@@ -53,8 +55,14 @@ async function saveFailure(
operation: string,
): Promise<ArticleSaveResult> {
const t = await getTranslations("pages.admin.articles.form");
if (error instanceof ArticleInputError)
return { ok: false, error: t(error.code) };
if (error instanceof ArticleInputError) {
const field = articleInputField(error.code);
return {
ok: false,
error: t(error.code),
...(field ? { fieldErrors: { [field]: [t(error.code)] } } : {}),
};
}
const reference = logger.error("News save failed", {
module: "news",
operation,
@@ -124,6 +132,8 @@ export async function updateArticle(
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,
@@ -158,6 +168,7 @@ export async function updateArticle(
updatedAt: new Date(),
})
.where(eq(WebsiteArticles.id, id));
await recordHistory(tx, "news", historyId, staff.id, historyBefore);
});
} catch (error) {
return saveFailure(error, "update");
+3 -3
View File
@@ -75,7 +75,7 @@ export async function updateBcPage({
return { ok: false as const, error: "No valid fields to update" };
}
try {
await updatePageCommand("bc", id, data, expected);
await updatePageCommand("bc", id, data, expected, staff.id);
} catch (error) {
return { ok: false as const, error: catalogFailure(error).message };
}
@@ -176,9 +176,9 @@ export async function toggleBcPage({
id: number;
field: "enabled" | "visible";
}) {
await requirePermission(PERMS.CATALOG_EDIT);
const staff = await requirePermission(PERMS.CATALOG_EDIT);
return await withCatalogExport(async () => {
await togglePageCommand("bc", id, field);
await togglePageCommand("bc", id, field, staff.id);
await sendCatalogUpdate();
revalidatePath("/admin/catalog/builder-club");
return { ok: true as const };
+1 -1
View File
@@ -28,7 +28,7 @@ export async function applyBulkOffers(
const staff = await requirePermission(PERMS.CATALOG_EDIT);
try {
return await withCatalogExport(async () => {
const data = await applyBulkOffersCommand(input, fingerprint);
const data = await applyBulkOffersCommand(input, fingerprint, staff.id);
if (data.changedCount > 0) {
await logAudit({
userId: staff.id,
+72
View File
@@ -0,0 +1,72 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const mocks = vi.hoisted(() => ({
guard: vi.fn(),
inspect: vi.fn(),
preview: vi.fn(),
apply: vi.fn(),
}));
vi.mock("next/cache", () => ({ revalidatePath: vi.fn() }));
vi.mock("@/lib/admin/guard", () => ({
requirePermissionRateLimited: mocks.guard,
}));
vi.mock("@/lib/permissions", () => import("@/lib/permission-slugs"));
vi.mock("@/lib/safe-action-shared", () => ({
actionOk: (data: unknown) => ({ ok: true, data }),
actionError: (error: string) => ({ ok: false, error }),
handleActionError: () => ({ ok: false, error: "denied" }),
}));
vi.mock("@/lib/services/catalog-audit", () => ({
inspectLiveCatalogIntegrity: mocks.inspect,
}));
vi.mock("@/lib/services/catalog-repair", () => ({
previewCatalogParentRepair: mocks.preview,
applyCatalogParentRepair: mocks.apply,
}));
vi.mock("@/lib/services/catalog-git-queue", () => ({
withCatalogExport: (fn: () => unknown) => fn(),
}));
import { PERMS } from "@/lib/permission-slugs";
import {
applyCatalogIntegrityRepairAction,
inspectCatalogIntegrityAction,
previewCatalogIntegrityRepairAction,
} from "./catalog-integrity";
beforeEach(() => {
vi.clearAllMocks();
mocks.guard.mockResolvedValue({ id: 7 });
mocks.apply.mockResolvedValue({ applied: 1 });
});
describe("integrity action permissions", () => {
it("allows inspection only after catalog view and preview only after catalog edit", async () => {
await inspectCatalogIntegrityAction("normal");
expect(mocks.guard).toHaveBeenLastCalledWith(PERMS.CATALOG_VIEW);
await previewCatalogIntegrityRepairAction("bc");
expect(mocks.guard).toHaveBeenLastCalledWith(PERMS.CATALOG_EDIT);
expect(mocks.preview).toHaveBeenCalledWith("bc");
});
it("denies apply before reading or mutating data without edit permission", async () => {
mocks.guard.mockRejectedValue(new Error("denied"));
expect(await applyCatalogIntegrityRepairAction("normal", "abc")).toEqual({
ok: false,
error: "denied",
});
expect(mocks.guard).toHaveBeenCalledWith(PERMS.CATALOG_EDIT);
expect(mocks.apply).not.toHaveBeenCalled();
});
it("forwards actor identity and translates changed state to a recoverable conflict", async () => {
const error = new Error("changed");
error.name = "CatalogPreviewConflict";
mocks.apply.mockRejectedValue(error);
expect(
await applyCatalogIntegrityRepairAction("bc", "fingerprint"),
).toEqual({ ok: false, error: "stalePreview" });
expect(mocks.apply).toHaveBeenCalledWith("bc", "fingerprint", 7);
});
it("rejects unknown catalog names before executing queries", async () => {
await inspectCatalogIntegrityAction("catalog_pages; DELETE");
expect(mocks.inspect).not.toHaveBeenCalled();
});
});
+60
View File
@@ -0,0 +1,60 @@
"use server";
import { revalidatePath } from "next/cache";
import { requirePermissionRateLimited } from "@/lib/admin/guard";
import { PERMS } from "@/lib/permissions";
import {
actionError,
actionOk,
handleActionError,
} from "@/lib/safe-action-shared";
import { inspectLiveCatalogIntegrity } from "@/lib/services/catalog-audit";
import { withCatalogExport } from "@/lib/services/catalog-git-queue";
import { integrityCatalog } from "@/lib/services/catalog-integrity";
import {
applyCatalogParentRepair,
previewCatalogParentRepair,
} from "@/lib/services/catalog-repair";
export async function inspectCatalogIntegrityAction(catalog: unknown) {
try {
await requirePermissionRateLimited(PERMS.CATALOG_VIEW);
return actionOk(
await inspectLiveCatalogIntegrity(integrityCatalog(catalog)),
);
} catch (error) {
return handleActionError(error);
}
}
export async function previewCatalogIntegrityRepairAction(catalog: unknown) {
try {
await requirePermissionRateLimited(PERMS.CATALOG_EDIT);
return actionOk(
await previewCatalogParentRepair(integrityCatalog(catalog)),
);
} catch (error) {
return handleActionError(error);
}
}
export async function applyCatalogIntegrityRepairAction(
catalog: unknown,
fingerprint: unknown,
) {
try {
const staff = await requirePermissionRateLimited(PERMS.CATALOG_EDIT);
const kind = integrityCatalog(catalog);
if (typeof fingerprint !== "string") return actionError("Invalid preview");
const result = await withCatalogExport(() =>
applyCatalogParentRepair(kind, fingerprint, staff.id),
);
revalidatePath("/admin/catalog");
revalidatePath("/admin/catalog/builder-club");
return actionOk(result);
} catch (error) {
if (error instanceof Error && error.name === "CatalogPreviewConflict")
return { ok: false as const, error: "stalePreview" as const };
return handleActionError(error);
}
}
+1 -1
View File
@@ -251,7 +251,7 @@ export async function updateCatalogItem({
const staff = await requirePermission(PERMS.CATALOG_EDIT);
return await withCatalogExport(async () => {
try {
await updateOfferCommand({ id, catalogFields, baseItem });
await updateOfferCommand({ id, catalogFields, baseItem }, staff.id);
} catch (error) {
return {
ok: false as const,
+1 -1
View File
@@ -65,7 +65,7 @@ export async function publishCatalogPackage(input: PublishCatalogPackageInput) {
const warnings: string[] = [];
try {
await withCatalogExport(async () => {
committed = await publishCatalogPackageCommand(input);
committed = await publishCatalogPackageCommand(input, staff.id);
return committed;
});
} catch (error) {
+3 -2
View File
@@ -67,7 +67,7 @@ export async function updateCatalogPage({
data.captionSave = data.caption.slice(0, 25);
}
try {
await updatePageCommand("normal", id, data, expected);
await updatePageCommand("normal", id, data, expected, staff.id);
} catch (error) {
return { ok: false as const, error: catalogFailure(error).message };
}
@@ -108,12 +108,13 @@ export async function toggleCatalogPage({
id: number;
action: "toggleEnabled" | "toggleVisible";
}) {
await requirePermission(PERMS.CATALOG_EDIT);
const staff = await requirePermission(PERMS.CATALOG_EDIT);
return await withCatalogExport(async () => {
await togglePageCommand(
"normal",
id,
action === "toggleEnabled" ? "enabled" : "visible",
staff.id,
);
await sendCatalogUpdate();
revalidatePath("/admin/catalog");
+74
View File
@@ -0,0 +1,74 @@
import { beforeEach, expect, it, vi } from "vitest";
const state = vi.hoisted(() => ({
guard: vi.fn(),
apply: vi.fn(),
snapshot: vi.fn(),
entry: {
kind: "prices",
targetId: 1,
before: { costCredits: 1 },
after: { costCredits: 2 },
},
}));
vi.mock("@/lib/db", () => ({
db: {
transaction: async (callback: (tx: object) => unknown) => callback({}),
},
}));
vi.mock("@/lib/admin/guard", () => ({ requirePermission: state.guard }));
vi.mock("@/lib/permissions", () => ({
PERMS: { LOGS_VIEW: "logs", CATALOG_EDIT: "catalog", NEWS_EDIT: "news" },
}));
vi.mock("@/features/history/server", () => ({
readHistory: async () => state.entry,
historySnapshot: state.snapshot,
applyHistory: state.apply,
}));
vi.mock("@/lib/services/catalog-git-queue", () => ({
withCatalogExport: async (fn: () => unknown) => fn(),
}));
vi.mock("@/lib/services/news-cache", () => ({ invalidateNewsCache: vi.fn() }));
vi.mock("@/features/catalog/server/sync-status", () => ({
sendCatalogUpdate: vi.fn(),
}));
vi.mock("@/lib/logger", () => ({ logger: { error: vi.fn() } }));
vi.mock("next/cache", () => ({ revalidatePath: vi.fn() }));
import { previewHistoryRestore, restoreHistory } from "./history";
beforeEach(() => {
vi.clearAllMocks();
state.guard.mockResolvedValue({ id: 7 });
state.snapshot.mockResolvedValue(state.entry.after);
state.apply.mockResolvedValue(undefined);
state.entry.kind = "prices";
});
it("requires logs access and catalog edit on preview and restore", async () => {
await previewHistoryRestore(1);
await restoreHistory(1);
expect(state.guard.mock.calls.map((call) => call[0])).toEqual([
"logs",
"catalog",
"logs",
"catalog",
]);
});
it("requires news edit for news history", async () => {
state.entry.kind = "news";
await restoreHistory(1);
expect(state.guard).toHaveBeenCalledWith("news");
});
it("rejects forbidden restore before any mutation", async () => {
state.guard.mockRejectedValue(Error("Forbidden"));
await expect(restoreHistory(1)).rejects.toThrow("Forbidden");
expect(state.apply).not.toHaveBeenCalled();
});
it("returns conflict when current data changed after preview", async () => {
await previewHistoryRestore(1);
state.apply.mockRejectedValue(Error("conflict"));
await expect(restoreHistory(1)).resolves.toEqual({
ok: false,
code: "conflict",
});
});
+69
View File
@@ -0,0 +1,69 @@
"use server";
import { revalidatePath } from "next/cache";
import { sendCatalogUpdate } from "@/features/catalog/server/sync-status";
import { historyChanges, sameSnapshot } from "@/features/history/model";
import {
applyHistory,
historySnapshot,
readHistory,
} from "@/features/history/server";
import { requirePermission } from "@/lib/admin/guard";
import { db } from "@/lib/db";
import { logger } from "@/lib/logger";
import { PERMS } from "@/lib/permissions";
import { withCatalogExport } from "@/lib/services/catalog-git-queue";
import { invalidateNewsCache } from "@/lib/services/news-cache";
async function authorized(id: number) {
await requirePermission(PERMS.LOGS_VIEW);
if (!Number.isSafeInteger(id) || id < 1) throw Error("unavailable");
const entry = await db.transaction((tx) => readHistory(tx, id));
const staff = await requirePermission(
entry.kind === "news" ? PERMS.NEWS_EDIT : PERMS.CATALOG_EDIT,
);
return { entry, staff };
}
export async function previewHistoryRestore(id: number) {
const { entry } = await authorized(id);
return db.transaction(async (tx) => {
const current = await historySnapshot(tx, entry.kind, entry.targetId);
return {
canRestore: sameSnapshot(current, entry.after),
changes: historyChanges(current, entry.before),
};
});
}
export async function restoreHistory(id: number) {
const { entry, staff } = await authorized(id);
try {
const restore = () =>
db.transaction((tx) => applyHistory(tx, entry, staff.id));
if (entry.kind === "news") await restore();
else
await withCatalogExport(async () => {
await restore();
return { ok: true as const, data: {} };
});
} catch (error) {
if (error instanceof Error && error.message === "conflict")
return { ok: false as const, code: "conflict" as const };
logger.error("History restore failed", { module: "history", error });
return { ok: false as const, code: "failed" as const };
}
try {
if (entry.kind === "news") {
await invalidateNewsCache();
revalidatePath("/news", "layout");
revalidatePath("/admin/articles", "layout");
revalidatePath("/");
revalidatePath("/me");
} else {
await sendCatalogUpdate();
revalidatePath("/admin/catalog", "layout");
}
} catch (error) {
logger.error("History refresh failed", { module: "history", error });
}
revalidatePath("/admin/logs/audit");
return { ok: true as const };
}