feat(cms): add recovery history and operational reliability tools

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

@@ -1,6 +1,7 @@
import "server-only";
import { createHash } from "node:crypto";
import { sql } from "drizzle-orm";
import { recordHistory } from "@/features/history/server";
import { db } from "@/lib/db";
import {
type BulkOfferInput,
@@ -108,6 +109,7 @@ export async function previewBulkOffersCommand(
export async function applyBulkOffersCommand(
value: BulkOfferInput,
fingerprint: string,
userId?: number,
) {
const input = bulkOfferInputSchema.parse(value);
if (!/^[a-f0-9]{64}$/.test(fingerprint))
@@ -144,6 +146,12 @@ export async function applyBulkOffersCommand(
sql`, `,
)} WHERE id=${row.id}`,
);
if (userId)
await recordHistory(tx, "prices", row.id, userId, {
costCredits: row.before.costCredits,
costPoints: row.before.costPoints,
pointsType: row.before.pointsType,
});
}
return { changedCount: result.changedCount };
});
+10 -1
View File
@@ -1,5 +1,6 @@
import "server-only";
import { getTableColumns, type SQL, sql } from "drizzle-orm";
import { historySnapshot, recordHistory } from "@/features/history/server";
import { CatalogItems, CatalogItemsBc, db, ItemsBase } from "@/lib/db";
import {
distinctOfferIds,
@@ -79,7 +80,10 @@ function assignments(
return sql`${sql.identifier(column.name)}=${key === "pageId" ? String(value) : value}`;
});
}
export async function updateOfferCommand(input: UpdateOfferInput) {
export async function updateOfferCommand(
input: UpdateOfferInput,
userId?: number,
) {
offerIdSchema.parse(input.id);
const fields = offerPatchSchema.parse(input.catalogFields);
const baseFields = input.baseItem
@@ -95,6 +99,9 @@ export async function updateOfferCommand(input: UpdateOfferInput) {
// Page locks precede offer locks, matching category deletion's lock order.
if (fields.pageId !== undefined) await lockPage(tx, fields.pageId);
const [offer] = await lockedOffers(tx, [input.id]);
const before = userId
? await historySnapshot(tx, "prices", input.id)
: null;
if (input.baseItem) {
const currentIds = String(offer.itemIds).split(";").map(Number);
const nextIds =
@@ -121,6 +128,8 @@ export async function updateOfferCommand(input: UpdateOfferInput) {
await tx.execute(
sql`UPDATE ${ItemsBase} SET ${sql.join(baseAssignments, sql`, `)} WHERE id=${input.baseItem.id}`,
);
if (before && userId)
await recordHistory(tx, "prices", input.id, userId, before);
});
}
export async function reorderOffersCommand(
+16 -2
View File
@@ -1,6 +1,7 @@
import "server-only";
import { randomUUID } from "node:crypto";
import { sql } from "drizzle-orm";
import { historySnapshot, recordHistory } from "@/features/history/server";
import { db } from "@/lib/db";
import { allocateCatalogItemId } from "@/lib/services/furni-import";
import { remapIncludes } from "../domain/duplicate";
@@ -261,6 +262,7 @@ async function updateRow(
}
async function publishCatalogPackageAttempt(
raw: PublishCatalogPackageInput,
userId?: number,
): Promise<PackagePublication> {
const input = publishPackageSchema.parse(raw);
const initial = await getCatalogPackageCommand(input.id);
@@ -292,21 +294,32 @@ async function publishCatalogPackageAttempt(
const pageIds: number[] = [];
if (p.mode === "update") {
for (const page of p.draft.pages) {
const historyBefore = userId
? await historySnapshot(tx, "category", page.id)
: null;
await updateRow(
tx,
"catalog_pages",
page.id,
columns(page, pageColumns),
);
if (historyBefore && userId)
await recordHistory(tx, "category", page.id, userId, historyBefore);
pageIds.push(page.id);
}
for (const offer of p.draft.offers)
for (const offer of p.draft.offers) {
const historyBefore = userId
? await historySnapshot(tx, "prices", offer.id)
: null;
await updateRow(
tx,
"catalog_items",
offer.id,
columns(offer, offerColumns),
);
if (historyBefore && userId)
await recordHistory(tx, "prices", offer.id, userId, historyBefore);
}
} else {
if (reserved.length !== source.offers.length)
throw new CatalogConflict(
@@ -399,10 +412,11 @@ async function publishCatalogPackageAttempt(
/** An ID reserved by another process can race our process-local allocator. A collision rolls back the entire copy before retry. */
export async function publishCatalogPackageCommand(
input: PublishCatalogPackageInput,
userId?: number,
): Promise<PackagePublication> {
for (let attempt = 0; ; attempt++) {
try {
return await publishCatalogPackageAttempt(input);
return await publishCatalogPackageAttempt(input, userId);
} catch (error) {
let cause: unknown = error,
duplicate = false;
@@ -1,5 +1,6 @@
import "server-only";
import { getTableColumns, type SQL, sql } from "drizzle-orm";
import { historySnapshot, recordHistory } from "@/features/history/server";
import { CatalogPages, CatalogPagesBc, db } from "@/lib/db";
import {
assertParent,
@@ -53,6 +54,7 @@ export async function updatePageCommand(
id: number,
fields: Record<string, unknown>,
expected?: Record<string, unknown>,
userId?: number,
) {
positiveId(id);
const data = pagePatchSchema.parse(fields);
@@ -76,6 +78,8 @@ export async function updatePageCommand(
);
}
}
const historyKind = kind === "bc" ? "category_bc" : "category";
const before = userId ? await historySnapshot(tx, historyKind, id) : null;
if (data.parentId !== undefined) assertParent(rows, id, data.parentId);
const assignments = fieldsSql(kind, data);
if (!assignments.length)
@@ -83,6 +87,8 @@ export async function updatePageCommand(
await tx.execute(
sql`UPDATE ${tables(kind).pages} SET ${sql.join(assignments, sql`, `)} WHERE id=${id}`,
);
if (before && userId)
await recordHistory(tx, historyKind, id, userId, before);
});
}
export async function createPageCommand(
@@ -153,6 +159,7 @@ export async function togglePageCommand(
kind: CatalogKind,
id: number,
field: "enabled" | "visible",
userId?: number,
) {
positiveId(id);
if (field !== "enabled" && field !== "visible")
@@ -161,9 +168,13 @@ export async function togglePageCommand(
const rows = await lockedPages(tx, kind);
if (!rows.some((row) => row.id === id))
throw new CatalogNotFound("Catalog page not found");
const historyKind = kind === "bc" ? "category_bc" : "category";
const before = userId ? await historySnapshot(tx, historyKind, id) : null;
const column = sql.identifier(field);
await tx.execute(
sql`UPDATE ${tables(kind).pages} SET ${column}=CASE WHEN ${column}='1' THEN '0' ELSE '1' END WHERE id=${id}`,
);
if (before && userId)
await recordHistory(tx, historyKind, id, userId, before);
});
}
File diff suppressed because it is too large. Load diff
+84
View File
@@ -0,0 +1,84 @@
import type { SQL } from "drizzle-orm";
import { MySqlDialect } from "drizzle-orm/mysql-core";
import { describe, expect, it, vi } from "vitest";
vi.mock("@/lib/db", async () => ({ ...(await import("@/db/schema")), db: {} }));
import { historyChanges, sameSnapshot } from "./model";
import {
applyHistory,
type HistoryTransaction,
historySnapshot,
} from "./server";
describe("history restore safety", () => {
const before = { costCredits: 10, costPoints: 0, pointsType: 0 };
const after = { costCredits: 20, costPoints: 0, pointsType: 0 };
function transaction(current = after) {
const writes: string[] = [];
const audit = vi.fn();
let snapshot = current;
const tx = {
execute: vi.fn(async (query: SQL) => {
const text = new MySqlDialect().sqlToQuery(query).sql;
if (text.startsWith("SELECT")) return [[snapshot]];
writes.push(text);
snapshot = before;
return [{}];
}),
insert: () => ({ values: audit }),
} as unknown as HistoryTransaction;
return { tx, writes, audit };
}
it("compares all recorded fields and does not confuse null with empty", () => {
expect(sameSnapshot({ a: null }, { a: "" })).toBe(false);
expect(sameSnapshot(after, { ...after, extra: 0 })).toBe(false);
expect(historyChanges(after, before)).toEqual([
{ key: "costCredits", current: 20, restore: 10 },
]);
});
it("rejects concurrent edits without any writes or audit", async () => {
const { tx, writes, audit } = transaction({ ...after, costPoints: 5 });
await expect(
applyHistory(tx, { kind: "prices", targetId: 1, before, after }, 7),
).rejects.toThrow("conflict");
expect(writes).toEqual([]);
expect(audit).not.toHaveBeenCalled();
});
it("restores only the price fields and audits in the same transaction", async () => {
const { tx, writes, audit } = transaction();
await applyHistory(tx, { kind: "prices", targetId: 1, before, after }, 7);
expect(writes).toHaveLength(1);
expect(writes[0]).not.toContain("limited_sells");
expect(audit).toHaveBeenCalledWith(
expect.objectContaining({
userId: 7,
action: "history_restore",
before: JSON.stringify(after),
after: JSON.stringify(before),
}),
);
});
it("propagates audit failure so the caller transaction rolls back", async () => {
const { tx, audit } = transaction();
audit.mockRejectedValue(Error("audit unavailable"));
await expect(
applyHistory(tx, { kind: "prices", targetId: 1, before, after }, 7),
).rejects.toThrow("audit unavailable");
});
it("rejects a second restore of the same revision", async () => {
const { tx, writes, audit } = transaction();
const entry = { kind: "prices" as const, targetId: 1, before, after };
await applyHistory(tx, entry, 7);
await expect(applyHistory(tx, entry, 7)).rejects.toThrow("conflict");
expect(writes).toHaveLength(1);
expect(audit).toHaveBeenCalledTimes(1);
});
it("reads only columns present in the smaller builder club schema", async () => {
const { tx } = transaction();
const snapshot = await historySnapshot(tx, "category_bc", 1);
expect(snapshot).not.toHaveProperty("captionSave");
expect(snapshot).not.toHaveProperty("includes");
expect(snapshot).toHaveProperty("caption");
});
});
+16
View File
@@ -0,0 +1,16 @@
export type HistorySnapshot = Record<string, string | number | null>;
export function sameSnapshot(a: HistorySnapshot, b: HistorySnapshot): boolean {
const keys = Object.keys(a);
return (
keys.length === Object.keys(b).length &&
keys.every((key) => Object.hasOwn(b, key) && a[key] === b[key])
);
}
export function historyChanges(
current: HistorySnapshot,
restore: HistorySnapshot,
) {
return Object.keys(restore)
.filter((key) => current[key] !== restore[key])
.map((key) => ({ key, current: current[key], restore: restore[key] }));
}
File diff suppressed because it is too large. Load diff
@@ -10,7 +10,7 @@ export const operationsManifest = {
descriptionKey: "pages.housekeeping.domains.operations.description",
iconId: "inbox",
previewHref: "/admin-next/operations",
capability: anyCapability(PERMS.ADMIN_DASHBOARD),
capability: anyCapability(PERMS.ADMIN_DASHBOARD, PERMS.ASSETS_IMPORT),
routes: [],
searchProviders: [],
inboxSources: [],
@@ -605,15 +605,15 @@ describe("housekeeping foundation completion contracts", () => {
expect(html).toContain(`>${sentinel}</button>`);
});
it("validates the complete 139-row migration matrix without issues", () => {
it("validates the complete 141-row migration matrix without issues", () => {
const discovered = discoverLegacyPages();
const issues = validateMigrationEntries(
discovered,
HOUSEKEEPING_MIGRATION_MATRIX,
);
expect(HOUSEKEEPING_MIGRATION_MATRIX).toHaveLength(139);
expect(discovered).toHaveLength(139);
expect(HOUSEKEEPING_MIGRATION_MATRIX).toHaveLength(141);
expect(discovered).toHaveLength(141);
expect(issues).toEqual([]);
});
});
@@ -76,7 +76,7 @@ const expectedManifests = [
previewHref: "/admin-next/operations",
labelKey: "pages.housekeeping.domains.operations.title",
descriptionKey: "pages.housekeeping.domains.operations.description",
slugs: [PERMS.ADMIN_DASHBOARD],
slugs: [PERMS.ADMIN_DASHBOARD, PERMS.ASSETS_IMPORT],
},
{
id: "people",
@@ -29,7 +29,7 @@ describe("discoverLegacyPages", () => {
it("discovers the exact legacy administration inventory", () => {
const pages = discoverLegacyPages();
expect(pages).toHaveLength(139);
expect(pages).toHaveLength(141);
expect(pages).toContainEqual({
surface: "admin",
legacyPath: "/admin/users/:id/edit",
@@ -4,10 +4,10 @@ import { HOUSEKEEPING_MIGRATION_MATRIX } from "./matrix";
import { validateMigrationEntries } from "./validate-matrix";
describe("HOUSEKEEPING_MIGRATION_MATRIX", () => {
it("covers all 139 legacy pages exactly once", () => {
it("covers all 141 legacy pages exactly once", () => {
const discovered = discoverLegacyPages();
expect(HOUSEKEEPING_MIGRATION_MATRIX).toHaveLength(139);
expect(HOUSEKEEPING_MIGRATION_MATRIX).toHaveLength(141);
expect(
validateMigrationEntries(discovered, HOUSEKEEPING_MIGRATION_MATRIX),
).toEqual([]);
@@ -4,7 +4,7 @@ import { operationsMigrationEntries } from "./operations";
describe("operationsMigrationEntries", () => {
it("covers the legacy dashboard once", () => {
expect(operationsMigrationEntries).toHaveLength(1);
expect(operationsMigrationEntries).toHaveLength(2);
expect(operationsMigrationEntries[0]).toMatchObject({
surface: "admin",
legacyPath: "/admin",
@@ -24,4 +24,27 @@ export const operationsMigrationEntries: readonly MigrationEntry[] = [
"Replace metric dashboard with capability-derived operational home",
],
},
{
surface: "admin",
legacyPath: "/admin/operations",
sourceFile: "src/app/admin/operations/page.tsx",
targetDomain: "operations",
targetPath: "/admin/work/jobs",
decision: "REHOST",
capabilities: {
read: [PERMS.ASSETS_IMPORT],
mutate: [PERMS.ASSETS_IMPORT],
},
dependencies: {
queries: ["ImportJobStore", "catalogExportStatus"],
mutations: ["retry furniture import", "cancel furniture import"],
},
auditRequirement: "MUTATION",
localization: "PARTIAL",
accessibility: "PARTIAL",
requiredTests: ["unit", "integration", "e2e", "visual"],
parityEvidence: [],
status: "PLANNED",
notes: ["Owner-scoped import history and shared export status"],
},
];
@@ -53,6 +53,7 @@ describe("peopleMigrationEntries", () => {
const expected = discoverLegacyPages().filter(
(page) =>
page.legacyPath === "/admin" ||
page.legacyPath === "/admin/operations" ||
PEOPLE_PREFIXES.some(
(prefix) =>
page.legacyPath === prefix ||
@@ -18,8 +18,8 @@ const SYSTEM_PREFIXES = [
] as const;
describe("systemMigrationEntries", () => {
it("covers all 20 System pages exactly once", () => {
expect(systemMigrationEntries).toHaveLength(20);
it("covers all 21 System pages exactly once", () => {
expect(systemMigrationEntries).toHaveLength(21);
expect(
validateMigrationEntries(
ownedLegacyPages(SYSTEM_PREFIXES),
@@ -21,6 +21,28 @@ function plannedSystemEntry(entry: PlannedSystemEntry): MigrationEntry {
}
export const systemMigrationEntries: readonly MigrationEntry[] = [
plannedSystemEntry({
surface: "admin",
legacyPath: "/admin/permissions/preview",
sourceFile: "src/app/admin/permissions/preview/page.tsx",
targetPath: "/admin/system/access/preview",
decision: "REHOST",
capabilities: { read: [PERMS.PERMISSIONS_MANAGE], mutate: [] },
dependencies: {
queries: [
"acl_roles",
"acl_permissions",
"acl_model_permissions",
"permission_ranks",
"users highest rank",
],
mutations: [],
},
auditRequirement: "NONE",
localization: "PARTIAL",
accessibility: "PARTIAL",
notes: ["Read-only role simulation; never changes sessions or grants"],
}),
plannedSystemEntry({
surface: "admin",
legacyPath: "/admin/devops/installation",