feat(cms): add recovery history and operational reliability tools
This commit is contained in:
1 parent
89526af344
commit
1ef405be0a
92 files changed
+6384
-267
No files matched your search
@@ -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" }),
|
||||
}));
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
}
|
||||
Reference in new issue
Block a user