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
@@ -251,6 +251,12 @@ export const ADMIN_NAV_GROUPS: AdminNavGroup[] = [
|
||||
icon: LayoutDashboard,
|
||||
permission: PERMS.ADMIN_DASHBOARD,
|
||||
},
|
||||
{
|
||||
href: "/admin/operations",
|
||||
labelKey: "operationsCenter",
|
||||
icon: Activity,
|
||||
permission: PERMS.ASSETS_IMPORT,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import { expect, it } from "vitest";
|
||||
import { PERMS } from "../permission-slugs";
|
||||
import { previewRoleAccess } from "./permission-preview";
|
||||
|
||||
it("requires dashboard access before reporting a module usable", () => {
|
||||
const p = previewRoleAccess([PERMS.USERS_VIEW], false);
|
||||
expect(p.canEnter).toBe(false);
|
||||
expect(p.sections.find((s) => s.href === "/admin/users")?.allowed).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
it("shows grants and denies without combining another role", () => {
|
||||
const p = previewRoleAccess([PERMS.ADMIN_DASHBOARD, PERMS.USERS_VIEW], false);
|
||||
expect(p.sections.find((s) => s.href === "/admin/users")?.allowed).toBe(true);
|
||||
expect(p.actions.find((s) => s.slug === PERMS.USERS_EDIT)?.granted).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
it("applies the existing highest-rank override", () => {
|
||||
const p = previewRoleAccess([], true);
|
||||
expect(p.canEnter).toBe(true);
|
||||
expect(p.actions.every((a) => a.granted)).toBe(true);
|
||||
});
|
||||
@@ -0,0 +1,33 @@
|
||||
import { ADMIN_NAV_GROUPS } from "../admin-nav";
|
||||
import { PERMS } from "../permission-slugs";
|
||||
/** Role-only simulation; never used to authorize a request. */
|
||||
export function previewRoleAccess(
|
||||
slugs: readonly string[],
|
||||
superAdmin: boolean,
|
||||
) {
|
||||
const grants = new Set(slugs);
|
||||
const has = (slug: string) => superAdmin || grants.has(slug);
|
||||
const canEnter = has(PERMS.ADMIN_DASHBOARD);
|
||||
return {
|
||||
canEnter,
|
||||
sections: ADMIN_NAV_GROUPS.flatMap((group) =>
|
||||
group.items.map((item) => ({
|
||||
href: item.href,
|
||||
labelKey: item.labelKey,
|
||||
required:
|
||||
typeof item.permission === "string"
|
||||
? [item.permission]
|
||||
: [...(item.permission ?? [])],
|
||||
allowed:
|
||||
canEnter &&
|
||||
(!item.permission ||
|
||||
(typeof item.permission === "string"
|
||||
? has(item.permission)
|
||||
: item.permission.some(has))),
|
||||
})),
|
||||
),
|
||||
actions: [...new Set(Object.values(PERMS))]
|
||||
.sort()
|
||||
.map((slug) => ({ slug, granted: has(slug) })),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { expect, it } from "vitest";
|
||||
import { articleInputField } from "./article-input";
|
||||
|
||||
it("maps correctable article failures and keeps conflicts global", () => {
|
||||
expect(articleInputField("titleRequired")).toBe("title");
|
||||
expect(articleInputField("summaryTooLong")).toBe("shortStory");
|
||||
expect(articleInputField("publishDateRequired")).toBe("publishAt");
|
||||
expect(articleInputField("editConflict")).toBeUndefined();
|
||||
expect(articleInputField("articleNotFound")).toBeUndefined();
|
||||
});
|
||||
@@ -47,3 +47,18 @@ export function readArticleInput(form: FormData) {
|
||||
export function articleSlug(value: string | string[]): string {
|
||||
return Array.isArray(value) ? value.join("/") : value;
|
||||
}
|
||||
|
||||
/** Associate validation failures with the field the editor can correct. */
|
||||
export function articleInputField(
|
||||
code: ArticleInputError["code"],
|
||||
): string | undefined {
|
||||
const fields: Partial<Record<ArticleInputError["code"], string>> = {
|
||||
titleRequired: "title",
|
||||
titleTooLong: "title",
|
||||
summaryTooLong: "shortStory",
|
||||
imageTooLong: "image",
|
||||
invalidStatus: "status",
|
||||
publishDateRequired: "publishAt",
|
||||
};
|
||||
return fields[code];
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import { expect, it } from "vitest";
|
||||
import { queryErrorGroups } from "./error-group-query";
|
||||
import { createErrorRecord } from "./error-monitor";
|
||||
|
||||
const now = Date.parse("2026-09-09T12:00:00Z");
|
||||
const base = createErrorRecord("server", "save", "failed");
|
||||
it("filters occurrences by exact release before aggregating", () => {
|
||||
const old = { ...base, release: "old", at: "2026-09-09T10:00:00Z" };
|
||||
const fresh = { ...base, release: "new", at: "2026-09-09T11:00:00Z" };
|
||||
expect(
|
||||
queryErrorGroups([old, fresh], { release: "old" }, now)[0].events,
|
||||
).toEqual([old]);
|
||||
});
|
||||
it("filters finite recent windows and sorts frequent groups first", () => {
|
||||
const a = { ...base, fingerprint: "a", at: "2026-09-09T10:00:00Z" };
|
||||
const b = { ...a, fingerprint: "b", at: "2026-09-09T11:00:00Z" };
|
||||
const old = { ...a, at: "2026-09-01T10:00:00Z" };
|
||||
expect(
|
||||
queryErrorGroups(
|
||||
[b, a, a, old],
|
||||
{ period: "24h", sort: "frequency" },
|
||||
now,
|
||||
).map((g) => [g.fingerprint, g.events.length]),
|
||||
).toEqual([
|
||||
["a", 2],
|
||||
["b", 1],
|
||||
]);
|
||||
});
|
||||
it("uses latest group resolution while filtering historical releases", () => {
|
||||
const old = {
|
||||
...base,
|
||||
release: "old",
|
||||
at: "2026-09-08T10:00:00Z",
|
||||
resolved: false,
|
||||
};
|
||||
const latest = {
|
||||
...base,
|
||||
release: "new",
|
||||
at: "2026-09-09T11:00:00Z",
|
||||
resolved: true,
|
||||
};
|
||||
expect(
|
||||
queryErrorGroups([old, latest], { release: "old", status: "open" }, now),
|
||||
).toHaveLength(0);
|
||||
});
|
||||
@@ -0,0 +1,46 @@
|
||||
import type { CmsErrorRecord } from "./error-monitor";
|
||||
export function queryErrorGroups(
|
||||
records: CmsErrorRecord[],
|
||||
params: Record<string, string | undefined>,
|
||||
now = Date.now(),
|
||||
) {
|
||||
const groups = new Map<string, CmsErrorRecord[]>();
|
||||
for (const record of records) {
|
||||
const events = groups.get(record.fingerprint) ?? [];
|
||||
events.push(record);
|
||||
groups.set(record.fingerprint, events);
|
||||
}
|
||||
const query = (params.q ?? "").trim().slice(0, 200).toLowerCase();
|
||||
const duration =
|
||||
params.period === "24h"
|
||||
? 86400000
|
||||
: params.period === "7d"
|
||||
? 604800000
|
||||
: null;
|
||||
return [...groups]
|
||||
.flatMap(([fingerprint, events]) => {
|
||||
const latest = [...events].sort((a, b) => b.at.localeCompare(a.at))[0];
|
||||
if (
|
||||
(params.status === "open" && latest.resolved) ||
|
||||
(params.status === "resolved" && !latest.resolved)
|
||||
)
|
||||
return [];
|
||||
const matched = events.filter(
|
||||
(r) =>
|
||||
(!params.release || r.release === params.release) &&
|
||||
(!params.source || r.source === params.source) &&
|
||||
(!duration ||
|
||||
(Date.parse(r.at) >= now - duration && Date.parse(r.at) <= now)) &&
|
||||
(!query || JSON.stringify(r).toLowerCase().includes(query)),
|
||||
);
|
||||
if (!matched.length) return [];
|
||||
matched.sort((a, b) => b.at.localeCompare(a.at));
|
||||
return [{ fingerprint, events: matched, latest }];
|
||||
})
|
||||
.sort(
|
||||
(a, b) =>
|
||||
(params.sort === "frequency" ? b.events.length - a.events.length : 0) ||
|
||||
b.events[0].at.localeCompare(a.events[0].at) ||
|
||||
a.fingerprint.localeCompare(b.fingerprint),
|
||||
);
|
||||
}
|
||||
@@ -10,6 +10,7 @@ export interface ImportJobItem {
|
||||
}
|
||||
export interface ImportJob {
|
||||
mode?: "repair";
|
||||
retryOf?: string;
|
||||
id: string;
|
||||
userId: number;
|
||||
createdAt: string;
|
||||
|
||||
@@ -957,3 +957,50 @@ export async function runCatalogAudit(
|
||||
summary,
|
||||
});
|
||||
}
|
||||
|
||||
/** Read-only integrity report: never invokes import, repair, or directory creation. */
|
||||
export async function inspectLiveCatalogIntegrity(catalog: "normal" | "bc") {
|
||||
const { integrityCatalog, inspectCatalogIntegrity } = await import(
|
||||
"./catalog-integrity"
|
||||
);
|
||||
integrityCatalog(catalog);
|
||||
const pagesTable = sql.raw(
|
||||
catalog === "bc" ? "catalog_pages_bc" : "catalog_pages",
|
||||
);
|
||||
const offersTable = sql.raw(
|
||||
catalog === "bc" ? "catalog_items_bc" : "catalog_items",
|
||||
);
|
||||
const [pageResult, itemResult, offerResult] = await Promise.all([
|
||||
db.execute(
|
||||
sql`SELECT id, parent_id AS parentId, caption FROM ${pagesTable} ORDER BY id`,
|
||||
),
|
||||
db.execute(
|
||||
sql`SELECT id, item_name, public_name, type, interaction_type FROM items_base ORDER BY id`,
|
||||
),
|
||||
db.execute(
|
||||
sql`SELECT id, page_id AS pageId, item_ids AS itemIds, catalog_name AS name, ${catalog === "bc" ? sql`NULL` : sql`offer_id`} AS offerId FROM ${offersTable} ORDER BY id`,
|
||||
),
|
||||
]);
|
||||
let icons: Set<string> | null = null;
|
||||
try {
|
||||
const dirs = await getFurniAssetDirs();
|
||||
icons = new Set(await readdir(dirs.iconDir));
|
||||
} catch {
|
||||
/* Unknown asset state is surfaced separately from missing icons. */
|
||||
}
|
||||
return inspectCatalogIntegrity({
|
||||
catalog,
|
||||
pages:
|
||||
pageResult[0] as unknown as import("./catalog-integrity").IntegrityPage[],
|
||||
items:
|
||||
itemResult[0] as unknown as import("./catalog-integrity").IntegrityItem[],
|
||||
offers: (
|
||||
offerResult[0] as unknown as import("./catalog-integrity").IntegrityOffer[]
|
||||
).map((row) => ({
|
||||
...row,
|
||||
pageId: Number(row.pageId),
|
||||
offerId: row.offerId === null ? null : Number(row.offerId),
|
||||
})),
|
||||
icons,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import { MySqlDialect } from "drizzle-orm/mysql-core";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
execute: vi.fn(),
|
||||
readdir: vi.fn(),
|
||||
ensureDirectories: vi.fn(),
|
||||
repair: vi.fn(),
|
||||
}));
|
||||
vi.mock("node:fs/promises", () => ({ readdir: mocks.readdir }));
|
||||
vi.mock("@/lib/db", () => ({ db: { execute: mocks.execute } }));
|
||||
vi.mock("@/lib/services/clone-import", () => ({}));
|
||||
vi.mock("@/lib/services/clone-sources", () => ({}));
|
||||
vi.mock("@/lib/services/furni-asset-dirs", () => ({
|
||||
getFurniAssetDirs: async () => ({ iconDir: "/icons" }),
|
||||
}));
|
||||
vi.mock("@/lib/utils/runtime-path", () => ({}));
|
||||
vi.mock("./catalog-repair", () => ({}));
|
||||
vi.mock("./furni-data", () => ({}));
|
||||
vi.mock("./furni-import", () => ({
|
||||
ensureDirectories: mocks.ensureDirectories,
|
||||
}));
|
||||
vi.mock("./repair-icons", () => ({ repairMissingIcons: mocks.repair }));
|
||||
vi.mock("./repair-nitros", () => ({}));
|
||||
|
||||
import { inspectLiveCatalogIntegrity } from "./catalog-audit";
|
||||
|
||||
const dialect = new MySqlDialect();
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mocks.readdir.mockResolvedValue([]);
|
||||
mocks.execute.mockImplementation(async (query) => {
|
||||
const text = dialect.sqlToQuery(query).sql;
|
||||
if (text.includes("FROM items_base"))
|
||||
return [
|
||||
[{ id: 1, item_name: "chair", public_name: "Chair", type: "s" }],
|
||||
[],
|
||||
];
|
||||
if (text.includes("FROM catalog_pages"))
|
||||
return [[{ id: 1, parentId: -1, caption: "Root" }], []];
|
||||
return [
|
||||
[{ id: 8, pageId: "1", itemIds: "1", name: "Chair", offerId: null }],
|
||||
[],
|
||||
];
|
||||
});
|
||||
});
|
||||
describe("read-only integrity audit", () => {
|
||||
it("uses BC-specific pages and offers and never invokes directory creation or repair", async () => {
|
||||
const result = await inspectLiveCatalogIntegrity("bc");
|
||||
const queries = mocks.execute.mock.calls.map(
|
||||
([query]) => dialect.sqlToQuery(query).sql,
|
||||
);
|
||||
expect(queries.every((query) => query.startsWith("SELECT"))).toBe(true);
|
||||
expect(queries[0]).toContain("FROM catalog_pages_bc");
|
||||
expect(queries[2]).toContain("NULL AS offerId FROM catalog_items_bc");
|
||||
expect(result.counts.missing_icon).toBe(1);
|
||||
expect(result.counts.missing_page).toBeUndefined();
|
||||
expect(mocks.ensureDirectories).not.toHaveBeenCalled();
|
||||
expect(mocks.repair).not.toHaveBeenCalled();
|
||||
});
|
||||
it("distinguishes unavailable icon storage from confirmed missing files", async () => {
|
||||
mocks.readdir.mockRejectedValue(new Error("permission denied"));
|
||||
const result = await inspectLiveCatalogIntegrity("normal");
|
||||
expect(result.iconsAvailable).toBe(false);
|
||||
expect(result.counts.missing_icon).toBeUndefined();
|
||||
});
|
||||
it("does not turn a database failure into a healthy report", async () => {
|
||||
mocks.execute.mockRejectedValue(new Error("database unavailable"));
|
||||
await expect(inspectLiveCatalogIntegrity("normal")).rejects.toThrow(
|
||||
"database unavailable",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,126 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
type IntegrityPage,
|
||||
inspectCatalogIntegrity,
|
||||
planParentRepairs,
|
||||
} from "./catalog-integrity";
|
||||
|
||||
const pages: IntegrityPage[] = [
|
||||
{ id: 1, parentId: -1, caption: "Root" },
|
||||
{ id: 2, parentId: 999, caption: "Missing parent" },
|
||||
{ id: 3, parentId: 2, caption: "Child" },
|
||||
{ id: 4, parentId: 5, caption: "Cycle A" },
|
||||
{ id: 5, parentId: 4, caption: "Cycle B" },
|
||||
{ id: 6, parentId: 0, caption: "Root zero" },
|
||||
];
|
||||
describe("catalog integrity", () => {
|
||||
it("distinguishes missing-parent descendants, cycles and valid roots without proposing destructive fixes", () => {
|
||||
const result = inspectCatalogIntegrity({
|
||||
catalog: "normal",
|
||||
pages,
|
||||
items: [],
|
||||
offers: [],
|
||||
icons: new Set(),
|
||||
});
|
||||
expect(result.counts).toEqual({ unreachable_page: 2, category_cycle: 2 });
|
||||
expect(planParentRepairs(pages)).toEqual([
|
||||
{ id: 2, caption: "Missing parent", from: 999, to: -1 },
|
||||
]);
|
||||
expect(pages[1].parentId).toBe(999);
|
||||
});
|
||||
it("reports empty/malformed item lists, missing furniture and repeated offer IDs", () => {
|
||||
const result = inspectCatalogIntegrity({
|
||||
catalog: "normal",
|
||||
pages,
|
||||
items: [],
|
||||
icons: null,
|
||||
offers: [
|
||||
{
|
||||
id: 10,
|
||||
pageId: 1,
|
||||
itemIds: "2;garbage;0;3.5",
|
||||
name: "Bundle",
|
||||
offerId: 8,
|
||||
},
|
||||
{ id: 11, pageId: 999, itemIds: "", name: "Broken", offerId: 8 },
|
||||
],
|
||||
});
|
||||
expect(result.counts.missing_furniture).toBe(1);
|
||||
expect(result.counts.invalid_item_ids).toBe(2);
|
||||
expect(result.counts.duplicate_offer_id).toBe(2);
|
||||
expect(result.counts.missing_page).toBe(1);
|
||||
expect(result.iconsAvailable).toBe(false);
|
||||
expect(
|
||||
result.issues.find((issue) => issue.code === "missing_furniture")?.detail,
|
||||
).toBe("2");
|
||||
});
|
||||
it("checks actual icon candidates, excludes special item kinds and preserves BC detail paths", () => {
|
||||
const result = inspectCatalogIntegrity({
|
||||
catalog: "bc",
|
||||
pages: [pages[1]],
|
||||
offers: [],
|
||||
icons: new Set(["chair_icon.png"]),
|
||||
items: [
|
||||
{ id: 1, item_name: "chair", public_name: "Chair", type: "s" },
|
||||
{ id: 2, item_name: "table", public_name: "Table", type: "s" },
|
||||
{ id: 3, item_name: "badge", public_name: "Badge", type: "b" },
|
||||
],
|
||||
});
|
||||
expect(result.counts.missing_icon).toBe(1);
|
||||
expect(result.issues[0].href).toBe("/admin/catalog/builder-club/2");
|
||||
});
|
||||
it("preserves default and special offer IDs plus intentionally unlisted placements", () => {
|
||||
const result = inspectCatalogIntegrity({
|
||||
catalog: "normal",
|
||||
pages: [],
|
||||
icons: null,
|
||||
items: [{ id: 1, item_name: "chair", public_name: "Chair", type: "s" }],
|
||||
offers: [-1, 0, -10, -2147483648].map((offerId, index) => ({
|
||||
id: index + 1,
|
||||
pageId: index % 2 ? 0 : -1,
|
||||
itemIds: "001",
|
||||
name: "System offer",
|
||||
offerId,
|
||||
})),
|
||||
});
|
||||
expect(result.issues).toEqual([]);
|
||||
expect(result.counts.invalid_offer_id).toBeUndefined();
|
||||
expect(result.counts.duplicate_offer_id).toBeUndefined();
|
||||
expect(result.counts.missing_page).toBeUndefined();
|
||||
});
|
||||
it("still detects malformed offer IDs and missing positive category references", () => {
|
||||
const result = inspectCatalogIntegrity({
|
||||
catalog: "normal",
|
||||
pages: [],
|
||||
icons: null,
|
||||
items: [{ id: 1, item_name: "chair", public_name: "Chair", type: "s" }],
|
||||
offers: [1.5, 2147483648].map((offerId, index) => ({
|
||||
id: index + 1,
|
||||
pageId: 999,
|
||||
itemIds: "1",
|
||||
name: "Broken",
|
||||
offerId,
|
||||
})),
|
||||
});
|
||||
expect(result.counts.invalid_offer_id).toBe(2);
|
||||
expect(result.counts.missing_page).toBe(2);
|
||||
});
|
||||
|
||||
it("handles deep catalog paths iteratively and caps the returned issue detail", () => {
|
||||
const deep = Array.from({ length: 15000 }, (_, i) => ({
|
||||
id: i + 1,
|
||||
caption: "Page",
|
||||
parentId: i === 14999 ? 99999 : i + 2,
|
||||
}));
|
||||
const result = inspectCatalogIntegrity({
|
||||
catalog: "normal",
|
||||
pages: deep,
|
||||
items: [],
|
||||
offers: [],
|
||||
icons: null,
|
||||
});
|
||||
expect(result.total).toBe(15000);
|
||||
expect(result.issues).toHaveLength(200);
|
||||
expect(planParentRepairs(deep)).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,202 @@
|
||||
import {
|
||||
offerInteger,
|
||||
parseFurnitureIds,
|
||||
} from "@/features/catalog/domain/offer-input";
|
||||
import {
|
||||
assetNameCandidates,
|
||||
classifyCatalogItem,
|
||||
NO_ASSET_KINDS,
|
||||
} from "./item-kind";
|
||||
|
||||
export type IntegrityCatalog = "normal" | "bc";
|
||||
export interface IntegrityPage {
|
||||
id: number;
|
||||
parentId: number;
|
||||
caption: string;
|
||||
}
|
||||
export interface IntegrityItem {
|
||||
id: number;
|
||||
item_name: string;
|
||||
public_name: string;
|
||||
type: string;
|
||||
interaction_type?: string | null;
|
||||
}
|
||||
export interface IntegrityOffer {
|
||||
id: number;
|
||||
pageId: number;
|
||||
itemIds: string;
|
||||
name: string;
|
||||
offerId: number | null;
|
||||
}
|
||||
export type IntegrityCode =
|
||||
| "missing_furniture"
|
||||
| "invalid_item_ids"
|
||||
| "missing_page"
|
||||
| "unreachable_page"
|
||||
| "category_cycle"
|
||||
| "missing_icon"
|
||||
| "invalid_offer_id"
|
||||
| "duplicate_offer_id";
|
||||
export interface IntegrityIssue {
|
||||
code: IntegrityCode;
|
||||
id: number;
|
||||
name: string;
|
||||
detail: string;
|
||||
href: string;
|
||||
}
|
||||
export interface ParentRepair {
|
||||
id: number;
|
||||
caption: string;
|
||||
from: number;
|
||||
to: -1;
|
||||
}
|
||||
|
||||
export function integrityCatalog(value: unknown): IntegrityCatalog {
|
||||
if (value !== "normal" && value !== "bc") throw new Error("Invalid catalog");
|
||||
return value;
|
||||
}
|
||||
|
||||
/** Only missing parents have an unambiguous, record-preserving repair. */
|
||||
export function planParentRepairs(pages: IntegrityPage[]): ParentRepair[] {
|
||||
const ids = new Set(pages.map((page) => page.id));
|
||||
return pages
|
||||
.filter((page) => page.parentId > 0 && !ids.has(page.parentId))
|
||||
.map((page) => ({
|
||||
id: page.id,
|
||||
caption: page.caption,
|
||||
from: page.parentId,
|
||||
to: -1 as const,
|
||||
}))
|
||||
.sort((a, b) => a.id - b.id);
|
||||
}
|
||||
|
||||
export function inspectCatalogIntegrity(input: {
|
||||
catalog: IntegrityCatalog;
|
||||
pages: IntegrityPage[];
|
||||
items: IntegrityItem[];
|
||||
offers: IntegrityOffer[];
|
||||
icons: Set<string> | null;
|
||||
}): {
|
||||
issues: IntegrityIssue[];
|
||||
counts: Partial<Record<IntegrityCode, number>>;
|
||||
total: number;
|
||||
iconsAvailable: boolean;
|
||||
} {
|
||||
const issues: IntegrityIssue[] = [];
|
||||
const pageMap = new Map(input.pages.map((page) => [page.id, page]));
|
||||
const items = new Set(input.items.map((item) => item.id));
|
||||
const base =
|
||||
input.catalog === "bc" ? "/admin/catalog/builder-club" : "/admin/catalog";
|
||||
const state = new Map<
|
||||
number,
|
||||
"reachable" | "unreachable_page" | "category_cycle"
|
||||
>();
|
||||
// Iterative traversal avoids stack overflow on deep imported catalogs.
|
||||
for (const page of input.pages) {
|
||||
const chain: number[] = [];
|
||||
const visiting = new Set<number>();
|
||||
let current = page.id;
|
||||
let result: "reachable" | "unreachable_page" | "category_cycle" =
|
||||
"reachable";
|
||||
while (current > 0) {
|
||||
const known = state.get(current);
|
||||
if (known) {
|
||||
result = known;
|
||||
break;
|
||||
}
|
||||
if (visiting.has(current)) {
|
||||
result = "category_cycle";
|
||||
break;
|
||||
}
|
||||
const node = pageMap.get(current);
|
||||
if (!node) {
|
||||
result = "unreachable_page";
|
||||
break;
|
||||
}
|
||||
visiting.add(current);
|
||||
chain.push(current);
|
||||
current = node.parentId;
|
||||
}
|
||||
for (const id of chain) state.set(id, result);
|
||||
}
|
||||
for (const page of input.pages) {
|
||||
const code = state.get(page.id);
|
||||
if (code && code !== "reachable")
|
||||
issues.push({
|
||||
code,
|
||||
id: page.id,
|
||||
name: page.caption,
|
||||
detail: String(page.parentId),
|
||||
href: `${base}/${page.id}`,
|
||||
});
|
||||
}
|
||||
const offerIds = new Map<number, number>();
|
||||
for (const offer of input.offers)
|
||||
if (offer.offerId !== null && offer.offerId > 0)
|
||||
offerIds.set(offer.offerId, (offerIds.get(offer.offerId) ?? 0) + 1);
|
||||
for (const offer of input.offers) {
|
||||
const href = pageMap.has(offer.pageId)
|
||||
? `${base}/${offer.pageId}`
|
||||
: `${base}?view=table&search=${offer.id}`;
|
||||
const add = (code: IntegrityCode, detail: string) => {
|
||||
issues.push({ code, id: offer.id, name: offer.name, detail, href });
|
||||
};
|
||||
// Non-positive placement IDs may represent unlisted/system offers. Only missing positive category references are actionable.
|
||||
if (offer.pageId > 0 && !pageMap.has(offer.pageId))
|
||||
add("missing_page", String(offer.pageId));
|
||||
const tokens = String(offer.itemIds ?? "")
|
||||
.split(";")
|
||||
.map((token) => token.trim());
|
||||
try {
|
||||
parseFurnitureIds(String(offer.itemIds ?? ""));
|
||||
} catch {
|
||||
add("invalid_item_ids", String(offer.itemIds ?? ""));
|
||||
}
|
||||
|
||||
const missing = [
|
||||
...new Set(
|
||||
tokens.filter(
|
||||
(token) =>
|
||||
/^\d+$/.test(token) &&
|
||||
Number(token) > 0 &&
|
||||
Number.isSafeInteger(Number(token)) &&
|
||||
!items.has(Number(token)),
|
||||
),
|
||||
),
|
||||
];
|
||||
if (missing.length) add("missing_furniture", missing.join(";"));
|
||||
// Match the editor contract: zero and signed special values (including default -1) survive unchanged.
|
||||
if (
|
||||
offer.offerId !== null &&
|
||||
!offerInteger.safeParse(offer.offerId).success
|
||||
)
|
||||
add("invalid_offer_id", String(offer.offerId));
|
||||
else if (offer.offerId !== null && (offerIds.get(offer.offerId) ?? 0) > 1)
|
||||
add("duplicate_offer_id", String(offer.offerId));
|
||||
}
|
||||
if (input.icons)
|
||||
for (const item of input.items) {
|
||||
if (NO_ASSET_KINDS.has(classifyCatalogItem(item))) continue;
|
||||
if (
|
||||
!assetNameCandidates(item.item_name, item.public_name).icon.some(
|
||||
(name) => input.icons?.has(name),
|
||||
)
|
||||
)
|
||||
issues.push({
|
||||
code: "missing_icon",
|
||||
id: item.id,
|
||||
name: item.public_name || item.item_name,
|
||||
detail: item.item_name,
|
||||
href: `/admin/items/${item.id}`,
|
||||
});
|
||||
}
|
||||
const counts: Partial<Record<IntegrityCode, number>> = {};
|
||||
for (const issue of issues)
|
||||
counts[issue.code] = (counts[issue.code] ?? 0) + 1;
|
||||
return {
|
||||
issues: issues.slice(0, 200),
|
||||
counts,
|
||||
total: issues.length,
|
||||
iconsAvailable: input.icons !== null,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
import { MySqlDialect } from "drizzle-orm/mysql-core";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
execute: vi.fn(),
|
||||
transaction: vi.fn(),
|
||||
insert: vi.fn(),
|
||||
values: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/lib/db", () => ({
|
||||
AdminAuditLog: "audit",
|
||||
db: { execute: mocks.execute, transaction: mocks.transaction },
|
||||
}));
|
||||
vi.mock("@/lib/services/furni-data", () => ({}));
|
||||
vi.mock("@/lib/services/furni-import", () => ({}));
|
||||
|
||||
import {
|
||||
applyCatalogParentRepair,
|
||||
previewCatalogParentRepair,
|
||||
} from "./catalog-repair";
|
||||
|
||||
const dialect = new MySqlDialect();
|
||||
let pages: { id: number; parentId: number; caption: string }[];
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
pages = [
|
||||
{ id: 1, parentId: 999, caption: "Orphan" },
|
||||
{ id: 2, parentId: -1, caption: "Root" },
|
||||
];
|
||||
mocks.execute.mockImplementation(async (query) =>
|
||||
dialect.sqlToQuery(query).sql.startsWith("SELECT")
|
||||
? [pages, []]
|
||||
: [{ affectedRows: 1 }, []],
|
||||
);
|
||||
mocks.insert.mockReturnValue({ values: mocks.values });
|
||||
mocks.values.mockResolvedValue(undefined);
|
||||
mocks.transaction.mockImplementation((callback) =>
|
||||
callback({ execute: mocks.execute, insert: mocks.insert }),
|
||||
);
|
||||
});
|
||||
describe("catalog parent repair", () => {
|
||||
it("previews without writes, locks the graph and records each change inside the transaction", async () => {
|
||||
const preview = await previewCatalogParentRepair("normal");
|
||||
expect(preview.changes).toEqual([
|
||||
{ id: 1, caption: "Orphan", from: 999, to: -1 },
|
||||
]);
|
||||
expect(mocks.transaction).not.toHaveBeenCalled();
|
||||
expect(
|
||||
await applyCatalogParentRepair("normal", preview.fingerprint, 7),
|
||||
).toEqual({ applied: 1 });
|
||||
const queries = mocks.execute.mock.calls.map(([query]) =>
|
||||
dialect.sqlToQuery(query),
|
||||
);
|
||||
expect(queries[1].sql).toContain("FOR UPDATE");
|
||||
expect(queries[2].sql).toContain("UPDATE catalog_pages SET parent_id");
|
||||
expect(queries[2].params).toEqual([-1, 1, 999]);
|
||||
expect(queries.some((query) => /DELETE|INSERT/.test(query.sql))).toBe(
|
||||
false,
|
||||
);
|
||||
expect(mocks.values).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
userId: 7,
|
||||
targetId: 1,
|
||||
action: "catalog_parent_repair",
|
||||
}),
|
||||
);
|
||||
});
|
||||
it("rejects a stale preview before any mutation when the graph changed", async () => {
|
||||
const preview = await previewCatalogParentRepair("normal");
|
||||
pages = [...pages, { id: 999, parentId: -1, caption: "Restored" }];
|
||||
await expect(
|
||||
applyCatalogParentRepair("normal", preview.fingerprint, 7),
|
||||
).rejects.toMatchObject({ name: "CatalogPreviewConflict" });
|
||||
expect(mocks.insert).not.toHaveBeenCalled();
|
||||
expect(mocks.execute).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
it("binds previews to the selected catalog and uses BC tables for BC repairs", async () => {
|
||||
const preview = await previewCatalogParentRepair("bc");
|
||||
await expect(
|
||||
applyCatalogParentRepair("normal", preview.fingerprint, 7),
|
||||
).rejects.toMatchObject({ name: "CatalogPreviewConflict" });
|
||||
await applyCatalogParentRepair("bc", preview.fingerprint, 7);
|
||||
expect(
|
||||
dialect.sqlToQuery(mocks.execute.mock.calls.at(-1)?.[0]).sql,
|
||||
).toContain("UPDATE catalog_pages_bc");
|
||||
});
|
||||
it("propagates audit failure so the transaction rolls back, instead of claiming success", async () => {
|
||||
const preview = await previewCatalogParentRepair("normal");
|
||||
mocks.values.mockRejectedValue(new Error("Audit unavailable"));
|
||||
await expect(
|
||||
applyCatalogParentRepair("normal", preview.fingerprint, 7),
|
||||
).rejects.toThrow("Audit unavailable");
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,6 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { sql } from "drizzle-orm";
|
||||
import { db } from "@/lib/db";
|
||||
import { AdminAuditLog, db } from "@/lib/db";
|
||||
import {
|
||||
readFurniData,
|
||||
withFurniDataLock,
|
||||
@@ -784,3 +785,89 @@ export async function repairFurniData(
|
||||
return { added: added.length, removedDuplicates, removedIdConflicts };
|
||||
});
|
||||
}
|
||||
|
||||
export interface CatalogParentRepairPreview {
|
||||
fingerprint: string;
|
||||
catalog: "normal" | "bc";
|
||||
changes: import("./catalog-integrity").ParentRepair[];
|
||||
}
|
||||
|
||||
function parentStateFingerprint(
|
||||
catalog: "normal" | "bc",
|
||||
pages: import("./catalog-integrity").IntegrityPage[],
|
||||
) {
|
||||
return createHash("sha256")
|
||||
.update(JSON.stringify({ catalog, pages }))
|
||||
.digest("hex");
|
||||
}
|
||||
|
||||
export async function previewCatalogParentRepair(
|
||||
catalog: "normal" | "bc",
|
||||
): Promise<CatalogParentRepairPreview> {
|
||||
const { integrityCatalog, planParentRepairs } = await import(
|
||||
"./catalog-integrity"
|
||||
);
|
||||
integrityCatalog(catalog);
|
||||
const table = sql.raw(
|
||||
catalog === "bc" ? "catalog_pages_bc" : "catalog_pages",
|
||||
);
|
||||
const [rows] = await db.execute(
|
||||
sql`SELECT id, parent_id AS parentId, caption FROM ${table} ORDER BY id`,
|
||||
);
|
||||
const pages =
|
||||
rows as unknown as import("./catalog-integrity").IntegrityPage[];
|
||||
return {
|
||||
catalog,
|
||||
fingerprint: parentStateFingerprint(catalog, pages),
|
||||
changes: planParentRepairs(pages),
|
||||
};
|
||||
}
|
||||
|
||||
/** Locks and revalidates the complete parent graph before making the previewed changes. */
|
||||
export async function applyCatalogParentRepair(
|
||||
catalog: "normal" | "bc",
|
||||
fingerprint: string,
|
||||
userId: number,
|
||||
): Promise<{ applied: number }> {
|
||||
const { integrityCatalog, planParentRepairs } = await import(
|
||||
"./catalog-integrity"
|
||||
);
|
||||
integrityCatalog(catalog);
|
||||
if (!/^[a-f0-9]{64}$/.test(fingerprint))
|
||||
throw new Error("Invalid repair preview");
|
||||
const tableName = catalog === "bc" ? "catalog_pages_bc" : "catalog_pages";
|
||||
const table = sql.raw(tableName);
|
||||
return db.transaction(async (tx) => {
|
||||
const [rows] = await tx.execute(
|
||||
sql`SELECT id, parent_id AS parentId, caption FROM ${table} ORDER BY id FOR UPDATE`,
|
||||
);
|
||||
const pages =
|
||||
rows as unknown as import("./catalog-integrity").IntegrityPage[];
|
||||
if (parentStateFingerprint(catalog, pages) !== fingerprint) {
|
||||
const error = new Error("Catalog changed since preview");
|
||||
error.name = "CatalogPreviewConflict";
|
||||
throw error;
|
||||
}
|
||||
const changes = planParentRepairs(pages);
|
||||
for (const change of changes) {
|
||||
await tx.execute(
|
||||
sql`UPDATE ${table} SET parent_id = ${change.to} WHERE id = ${change.id} AND parent_id = ${change.from}`,
|
||||
);
|
||||
await tx
|
||||
.insert(AdminAuditLog)
|
||||
.values({
|
||||
userId,
|
||||
action: "catalog_parent_repair",
|
||||
target: tableName,
|
||||
targetId: change.id,
|
||||
before: JSON.stringify({ parentId: change.from }),
|
||||
after: JSON.stringify({ parentId: change.to }),
|
||||
diff: JSON.stringify({
|
||||
parentId: { from: change.from, to: change.to },
|
||||
}),
|
||||
createdAt: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
return { applied: changes.length };
|
||||
});
|
||||
}
|
||||
@@ -100,3 +100,104 @@ it("loads bounded history for the requesting owner", async () => {
|
||||
expect(result).toHaveLength(2);
|
||||
expect(result.every((job) => job.userId === 4)).toBe(true);
|
||||
});
|
||||
|
||||
it("keeps history pages stable while an older job changes", async () => {
|
||||
const s = await store();
|
||||
const older = { ...job(), createdAt: "2026-01-01T00:00:00.000Z" };
|
||||
const newer = { ...job(), createdAt: "2026-01-02T00:00:00.000Z" };
|
||||
await s.create(older);
|
||||
await s.create(newer);
|
||||
const first = await s.page({ userId: 4, limit: 1 });
|
||||
expect(first.jobs.map((j) => j.id)).toEqual([newer.id]);
|
||||
await s.save({ ...older, state: "completed" });
|
||||
const second = await s.page({
|
||||
userId: 4,
|
||||
limit: 1,
|
||||
before: first.nextCursor ?? undefined,
|
||||
});
|
||||
expect(second.jobs.map((j) => j.id)).toEqual([older.id]);
|
||||
expect(second.nextCursor).toBeNull();
|
||||
});
|
||||
it("creates a single retry across concurrent requests and excludes successful items", async () => {
|
||||
const s = await store();
|
||||
const original = {
|
||||
...job(),
|
||||
state: "completed" as const,
|
||||
items: [
|
||||
{
|
||||
id: 1,
|
||||
classname: "chair",
|
||||
name: "Chair",
|
||||
description: "",
|
||||
type: "flooritem",
|
||||
revision: 1,
|
||||
category: "",
|
||||
state: "failed" as const,
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
classname: "lamp",
|
||||
name: "Lamp",
|
||||
description: "",
|
||||
type: "flooritem",
|
||||
revision: 1,
|
||||
category: "",
|
||||
state: "done" as const,
|
||||
},
|
||||
],
|
||||
};
|
||||
await s.create(original);
|
||||
const [a, b] = await Promise.all([
|
||||
s.retry(original.id, 4),
|
||||
s.retry(original.id, 4),
|
||||
]);
|
||||
expect(a?.id).toBe(b?.id);
|
||||
expect(a?.items.map((i) => i.classname)).toEqual(["chair"]);
|
||||
expect(await s.list()).toHaveLength(2);
|
||||
expect(await s.retry(original.id, 99)).toBeNull();
|
||||
});
|
||||
|
||||
it("does not retry active jobs or uncertain outcomes", async () => {
|
||||
const s = await store();
|
||||
const original = {
|
||||
...job(),
|
||||
items: [
|
||||
{
|
||||
id: 1,
|
||||
classname: "chair",
|
||||
name: "Chair",
|
||||
description: "",
|
||||
type: "flooritem",
|
||||
revision: 1,
|
||||
category: "",
|
||||
state: "interrupted" as const,
|
||||
},
|
||||
],
|
||||
};
|
||||
await s.create(original);
|
||||
expect(await s.retry(original.id, 4)).toBeNull();
|
||||
await s.save({ ...original, state: "interrupted" });
|
||||
expect(await s.retry(original.id, 4)).toBeNull();
|
||||
expect(await s.list()).toHaveLength(1);
|
||||
});
|
||||
it("paginates timestamp ties without duplicates and excludes other owners", async () => {
|
||||
const s = await store();
|
||||
const createdAt = "2026-01-01T00:00:00.000Z";
|
||||
const own = [
|
||||
{ ...job(), createdAt },
|
||||
{ ...job(), createdAt },
|
||||
{ ...job(), createdAt },
|
||||
];
|
||||
for (const entry of [...own, { ...job(), createdAt, userId: 99 }])
|
||||
await s.create(entry);
|
||||
const first = await s.page({ userId: 4, limit: 2 });
|
||||
const second = await s.page({
|
||||
userId: 4,
|
||||
limit: 2,
|
||||
before: first.nextCursor ?? undefined,
|
||||
});
|
||||
expect(new Set([...first.jobs, ...second.jobs].map((j) => j.id))).toEqual(
|
||||
new Set(own.map((j) => j.id)),
|
||||
);
|
||||
expect(first.jobs.length + second.jobs.length).toBe(3);
|
||||
});
|
||||
@@ -1,7 +1,8 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { createHash, randomUUID } from "node:crypto";
|
||||
import { promises as fs } from "node:fs";
|
||||
import path from "node:path";
|
||||
import type { ImportJob } from "@/lib/furni/import-job";
|
||||
import { retryableJobItems } from "@/lib/furni/import-job-retry";
|
||||
export const importRoot = () =>
|
||||
path.join(process.cwd(), "storage", "furniture-imports");
|
||||
export const validJobId = (value: unknown): value is string =>
|
||||
@@ -73,6 +74,51 @@ export class ImportJobStore {
|
||||
});
|
||||
return this.read(id);
|
||||
}
|
||||
async retry(id: string, userId: number): Promise<ImportJob | null> {
|
||||
const original = await this.read(id).catch((error) => {
|
||||
if (error.code === "ENOENT") return null;
|
||||
throw error;
|
||||
});
|
||||
if (!original || original.userId !== userId) return null;
|
||||
const items = retryableJobItems(original);
|
||||
if (!items.length) return null;
|
||||
// A parent produces one durable child, including concurrent and uncertain responses.
|
||||
const hex = createHash("sha256").update(`retry:${id}`).digest("hex");
|
||||
const retryId = `${hex.slice(0, 8)}-${hex.slice(8, 12)}-4${hex.slice(13, 16)}-8${hex.slice(17, 20)}-${hex.slice(20, 32)}`;
|
||||
const now = new Date().toISOString();
|
||||
return this.create({
|
||||
...original,
|
||||
id: retryId,
|
||||
retryOf: id,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
state: "queued",
|
||||
cancelRequested: false,
|
||||
items: items.map(
|
||||
({ error: _error, warnings: _warnings, itemId: _itemId, ...item }) => ({
|
||||
...item,
|
||||
state: "pending",
|
||||
}),
|
||||
),
|
||||
});
|
||||
}
|
||||
async page(options: { userId: number; limit: number; before?: string }) {
|
||||
const limit = Math.min(30, Math.max(1, options.limit));
|
||||
const key = (job: ImportJob) => `${job.createdAt}|${job.id}`;
|
||||
const jobs = (await this.list())
|
||||
.filter(
|
||||
(job) =>
|
||||
job.userId === options.userId &&
|
||||
(!options.before || key(job) < options.before),
|
||||
)
|
||||
.sort((a, b) => key(b).localeCompare(key(a)));
|
||||
const page = jobs.slice(0, limit);
|
||||
return {
|
||||
jobs: page,
|
||||
nextCursor: jobs.length > limit ? key(page[page.length - 1]) : null,
|
||||
};
|
||||
}
|
||||
|
||||
async list(options?: {
|
||||
userId: number;
|
||||
limit: number;
|
||||
|
||||
Reference in new issue
Block a user