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
@@ -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 };
|
||||
});
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
"use client";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useRef, useState } from "react";
|
||||
import { previewHistoryRestore, restoreHistory } from "@/actions/history";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
export function HistoryRestore({ id }: { id: number }) {
|
||||
const t = useTranslations("pages.admin.logs.history");
|
||||
const router = useRouter();
|
||||
const [open, setOpen] = useState(false);
|
||||
const pending = useRef<"preview" | "restore" | null>(null);
|
||||
const [busy, setBusy] = useState<"preview" | "restore" | null>(null);
|
||||
const [error, setError] = useState("");
|
||||
const [preview, setPreview] = useState<Awaited<
|
||||
ReturnType<typeof previewHistoryRestore>
|
||||
> | null>(null);
|
||||
async function load() {
|
||||
if (pending.current) return;
|
||||
pending.current = "preview";
|
||||
setOpen(true);
|
||||
setBusy("preview");
|
||||
setError("");
|
||||
setPreview(null);
|
||||
try {
|
||||
setPreview(await previewHistoryRestore(id));
|
||||
} catch {
|
||||
setError(t("unavailable"));
|
||||
} finally {
|
||||
pending.current = null;
|
||||
setBusy(null);
|
||||
}
|
||||
}
|
||||
async function restore() {
|
||||
if (pending.current || !preview?.canRestore || !preview.changes.length)
|
||||
return;
|
||||
pending.current = "restore";
|
||||
setBusy("restore");
|
||||
setError("");
|
||||
try {
|
||||
const result = await restoreHistory(id);
|
||||
if (!result.ok) {
|
||||
setError(t(result.code));
|
||||
setPreview(null);
|
||||
return;
|
||||
}
|
||||
setOpen(false);
|
||||
router.refresh();
|
||||
} catch {
|
||||
setError(t("failed"));
|
||||
} finally {
|
||||
pending.current = null;
|
||||
setBusy(null);
|
||||
}
|
||||
}
|
||||
return (
|
||||
<>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={load}
|
||||
disabled={busy !== null}
|
||||
>
|
||||
{t("preview")}
|
||||
</Button>
|
||||
<Dialog
|
||||
open={open}
|
||||
onOpenChange={(nextOpen, details) => {
|
||||
if (!nextOpen && pending.current === "restore") {
|
||||
details.cancel();
|
||||
return;
|
||||
}
|
||||
setOpen(nextOpen);
|
||||
}}
|
||||
>
|
||||
<DialogContent
|
||||
className="w-[calc(100%-2rem)] min-w-0 overflow-x-hidden sm:max-w-3xl"
|
||||
showCloseButton={busy !== "restore"}
|
||||
>
|
||||
<DialogTitle className="pr-6 leading-snug">{t("title")}</DialogTitle>
|
||||
<DialogDescription>{t("description")}</DialogDescription>
|
||||
{busy && (
|
||||
<p role="status">
|
||||
{t(busy === "restore" ? "restoring" : "loading")}
|
||||
</p>
|
||||
)}
|
||||
{error && (
|
||||
<p role="alert" className="text-destructive">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
{preview && (
|
||||
<>
|
||||
{!preview.canRestore && (
|
||||
<p role="alert" className="text-destructive">
|
||||
{t("conflict")}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="max-h-[50dvh] min-w-0 space-y-4 overflow-y-auto pr-1">
|
||||
{preview.changes.map((change) => (
|
||||
<section
|
||||
key={change.key}
|
||||
className="min-w-0 rounded-lg border p-3"
|
||||
>
|
||||
<h3 className="mb-2 text-sm font-semibold">
|
||||
{t.has(`fields.${change.key}`)
|
||||
? t(`fields.${change.key}`)
|
||||
: t("field")}
|
||||
</h3>
|
||||
<dl className="grid min-w-0 gap-3 sm:grid-cols-2">
|
||||
<div className="min-w-0">
|
||||
<dt className="mb-1 text-xs text-muted-foreground">
|
||||
{t("current")}
|
||||
</dt>
|
||||
<dd className="min-w-0">
|
||||
<pre className="max-h-52 overflow-y-auto whitespace-pre-wrap [overflow-wrap:anywhere] rounded-md bg-muted/40 p-2 text-xs">
|
||||
{String(change.current ?? "—")}
|
||||
</pre>
|
||||
</dd>
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<dt className="mb-1 text-xs text-muted-foreground">
|
||||
{t("restore")}
|
||||
</dt>
|
||||
<dd className="min-w-0">
|
||||
<pre className="max-h-52 overflow-y-auto whitespace-pre-wrap [overflow-wrap:anywhere] rounded-md bg-muted/40 p-2 text-xs">
|
||||
{String(change.restore ?? "—")}
|
||||
</pre>
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</section>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<Button
|
||||
disabled={
|
||||
busy !== null ||
|
||||
!preview.canRestore ||
|
||||
!preview.changes.length
|
||||
}
|
||||
onClick={restore}
|
||||
>
|
||||
{t("confirm")}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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] }));
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
import "server-only";
|
||||
import { eq, getTableColumns, sql } from "drizzle-orm";
|
||||
import {
|
||||
AdminAuditLog,
|
||||
CatalogItems,
|
||||
CatalogPages,
|
||||
CatalogPagesBc,
|
||||
WebsiteArticles,
|
||||
} from "@/db/schema";
|
||||
import type { db } from "@/lib/db";
|
||||
import { type HistorySnapshot, sameSnapshot } from "./model";
|
||||
export type HistoryKind = "category" | "category_bc" | "prices" | "news";
|
||||
export type HistoryTransaction = Parameters<
|
||||
Parameters<typeof db.transaction>[0]
|
||||
>[0];
|
||||
const categoryFields = [
|
||||
"caption",
|
||||
"captionSave",
|
||||
"pageLayout",
|
||||
"enabled",
|
||||
"visible",
|
||||
"clubOnly",
|
||||
"vipOnly",
|
||||
"minRank",
|
||||
"iconImage",
|
||||
"iconColor",
|
||||
"pageHeadline",
|
||||
"pageTeaser",
|
||||
"pageSpecial",
|
||||
"pageText1",
|
||||
"pageText2",
|
||||
"pageTextDetails",
|
||||
"pageTextTeaser",
|
||||
"includes",
|
||||
];
|
||||
const configs = {
|
||||
category: { table: CatalogPages, fields: categoryFields },
|
||||
category_bc: { table: CatalogPagesBc, fields: categoryFields },
|
||||
prices: {
|
||||
table: CatalogItems,
|
||||
fields: ["costCredits", "costPoints", "pointsType"],
|
||||
},
|
||||
news: {
|
||||
table: WebsiteArticles,
|
||||
fields: [
|
||||
"title",
|
||||
"slug",
|
||||
"image",
|
||||
"shortStory",
|
||||
"fullStory",
|
||||
"status",
|
||||
"publishAt",
|
||||
"publishedAt",
|
||||
],
|
||||
},
|
||||
};
|
||||
export function historyConfig(kind: string) {
|
||||
if (!Object.hasOwn(configs, kind)) throw Error("unavailable");
|
||||
const config = configs[kind as HistoryKind];
|
||||
const columns = getTableColumns(config.table);
|
||||
return {
|
||||
...config,
|
||||
fields: config.fields.filter((key) => Object.hasOwn(columns, key)),
|
||||
};
|
||||
}
|
||||
export async function historySnapshot(
|
||||
tx: HistoryTransaction,
|
||||
kind: HistoryKind,
|
||||
id: number | string,
|
||||
): Promise<HistorySnapshot> {
|
||||
const { table, fields } = historyConfig(kind);
|
||||
const columns = getTableColumns(table);
|
||||
const selected = fields.map(
|
||||
(key) =>
|
||||
sql`${sql.identifier(columns[key as keyof typeof columns].name)} AS ${sql.identifier(key)}`,
|
||||
);
|
||||
const [rows] = await tx.execute(
|
||||
sql`SELECT ${sql.join(selected, sql`, `)} FROM ${table} WHERE id=${id} FOR UPDATE`,
|
||||
);
|
||||
const row = (rows as unknown as Record<string, unknown>[])[0];
|
||||
if (!row) throw Error("unavailable");
|
||||
return Object.fromEntries(
|
||||
fields.map((key) => {
|
||||
const value = row[key];
|
||||
return [
|
||||
key,
|
||||
value instanceof Date
|
||||
? value.toISOString()
|
||||
: value == null
|
||||
? null
|
||||
: typeof value === "number"
|
||||
? value
|
||||
: String(value),
|
||||
];
|
||||
}),
|
||||
);
|
||||
}
|
||||
export async function recordHistory(
|
||||
tx: HistoryTransaction,
|
||||
kind: HistoryKind,
|
||||
id: number | string,
|
||||
userId: number,
|
||||
before: HistorySnapshot,
|
||||
action = "history_update",
|
||||
) {
|
||||
const after = await historySnapshot(tx, kind, id);
|
||||
if (sameSnapshot(before, after)) return;
|
||||
const serializedBefore = JSON.stringify(before);
|
||||
const serializedAfter = JSON.stringify(after);
|
||||
if (
|
||||
Buffer.byteLength(serializedBefore, "utf8") > 16_000_000 ||
|
||||
Buffer.byteLength(serializedAfter, "utf8") > 16_000_000
|
||||
)
|
||||
throw Error("History snapshot is too large");
|
||||
await tx.insert(AdminAuditLog).values({
|
||||
userId,
|
||||
action,
|
||||
target: kind,
|
||||
targetId: Number(id) <= 2147483647 ? Number(id) : null,
|
||||
details:
|
||||
Number(id) > 2147483647 ? JSON.stringify({ targetId: String(id) }) : null,
|
||||
before: serializedBefore,
|
||||
after: serializedAfter,
|
||||
createdAt: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
export async function readHistory(tx: HistoryTransaction, id: number) {
|
||||
const [entry] = await tx
|
||||
.select()
|
||||
.from(AdminAuditLog)
|
||||
.where(eq(AdminAuditLog.id, id))
|
||||
.limit(1);
|
||||
if (!entry || !["history_update", "history_restore"].includes(entry.action))
|
||||
throw Error("unavailable");
|
||||
let targetId: number | string | null = entry.targetId;
|
||||
if (targetId === null && entry.target === "news") {
|
||||
const details = JSON.parse(entry.details ?? "null");
|
||||
if (
|
||||
typeof details?.targetId === "string" &&
|
||||
/^[1-9]\d{0,19}$/.test(details.targetId)
|
||||
)
|
||||
targetId = details.targetId;
|
||||
}
|
||||
if (targetId === null) throw Error("unavailable");
|
||||
const config = historyConfig(entry.target);
|
||||
const parse = (raw: string | null): HistorySnapshot => {
|
||||
const value: unknown = JSON.parse(raw ?? "null");
|
||||
if (!value || typeof value !== "object" || Array.isArray(value))
|
||||
throw Error("unavailable");
|
||||
const data = value as HistorySnapshot;
|
||||
if (
|
||||
Object.keys(data).length !== config.fields.length ||
|
||||
!config.fields.every(
|
||||
(key) =>
|
||||
Object.hasOwn(data, key) &&
|
||||
(data[key] === null ||
|
||||
typeof data[key] === "string" ||
|
||||
typeof data[key] === "number"),
|
||||
)
|
||||
)
|
||||
throw Error("unavailable");
|
||||
return data;
|
||||
};
|
||||
return {
|
||||
kind: entry.target as HistoryKind,
|
||||
targetId,
|
||||
before: parse(entry.before),
|
||||
after: parse(entry.after),
|
||||
};
|
||||
}
|
||||
export async function applyHistory(
|
||||
tx: HistoryTransaction,
|
||||
entry: Awaited<ReturnType<typeof readHistory>>,
|
||||
userId: number,
|
||||
) {
|
||||
const current = await historySnapshot(tx, entry.kind, entry.targetId);
|
||||
if (!sameSnapshot(current, entry.after)) throw Error("conflict");
|
||||
const { table } = historyConfig(entry.kind);
|
||||
const columns = getTableColumns(table);
|
||||
const assignments = Object.entries(entry.before).map(
|
||||
([key, value]) =>
|
||||
sql`${sql.identifier(columns[key as keyof typeof columns].name)}=${entry.kind === "news" && (key === "publishAt" || key === "publishedAt") && value ? new Date(String(value)) : value}`,
|
||||
);
|
||||
if (entry.kind === "news") assignments.push(sql`updated_at=${new Date()}`);
|
||||
await tx.execute(
|
||||
sql`UPDATE ${table} SET ${sql.join(assignments, sql`, `)} WHERE id=${entry.targetId}`,
|
||||
);
|
||||
await recordHistory(
|
||||
tx,
|
||||
entry.kind,
|
||||
entry.targetId,
|
||||
userId,
|
||||
current,
|
||||
"history_restore",
|
||||
);
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
Reference in new issue
Block a user