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 };
|
||||
}
|
||||
@@ -1,4 +1,6 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { CatalogIntegrityPanel } from "@/components/admin/catalog/catalog-integrity-panel";
|
||||
import { CatalogMaintenancePanel } from "@/components/admin/catalog/catalog-maintenance-panel";
|
||||
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
|
||||
|
||||
@@ -8,5 +10,26 @@ export default async function CatalogMaintenancePage() {
|
||||
redirect("/admin");
|
||||
}
|
||||
|
||||
return <CatalogMaintenancePanel />;
|
||||
const canRepair = canAccess(
|
||||
permissions,
|
||||
PERMS.CATALOG_EDIT,
|
||||
session.user.rank,
|
||||
);
|
||||
const t = await getTranslations("pages.admin.catalog.integrity");
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<CatalogIntegrityPanel canRepair={canRepair} />
|
||||
{canRepair && (
|
||||
<details className="rounded-xl border p-4">
|
||||
<summary className="cursor-pointer font-medium">
|
||||
{t("legacyTitle")}
|
||||
</summary>
|
||||
<p className="mt-3 text-sm text-muted-foreground">
|
||||
{t("legacyHint")}
|
||||
</p>
|
||||
<CatalogMaintenancePanel />
|
||||
</details>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -4,6 +4,7 @@ import { useSearchParams } from "next/navigation";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { DataTable } from "@/components/admin/data-table";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { HistoryRestore } from "@/features/history/history-restore";
|
||||
import { formatAuditValue, readAuditChanges } from "@/lib/services/audit-diff";
|
||||
import type { DataTableColumn, PaginatedResult } from "@/types/common";
|
||||
import { AuditFilters } from "./audit-filters";
|
||||
@@ -102,7 +103,14 @@ export function AuditTable({ data }: AuditTableProps) {
|
||||
{
|
||||
key: "diff",
|
||||
label: t("audit.colDetails"),
|
||||
render: (_value, row) => <DiffCell row={row} />,
|
||||
render: (_value, row) => (
|
||||
<div className="space-y-2">
|
||||
<DiffCell row={row} />
|
||||
{["history_update", "history_restore"].includes(row.action) && (
|
||||
<HistoryRestore id={row.id} />
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "createdAt",
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { OperationsCenter } from "@/components/admin/operations-center";
|
||||
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
|
||||
export default async function OperationsPage() {
|
||||
const { session, permissions } = await getAdminContext();
|
||||
if (!canAccess(permissions, PERMS.ASSETS_IMPORT, session.user.rank))
|
||||
redirect("/admin");
|
||||
return <OperationsCenter />;
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
import { sql } from "drizzle-orm";
|
||||
import { redirect } from "next/navigation";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import Link from "@/components/link";
|
||||
import { db } from "@/lib/db";
|
||||
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
|
||||
import { fetchEmulatorRankSummaries } from "@/lib/services/permission-ranks";
|
||||
@@ -16,6 +18,7 @@ export default async function PermissionsPage({
|
||||
redirect("/admin");
|
||||
}
|
||||
|
||||
const t = await getTranslations("pages.admin.permissionPreview");
|
||||
const sp = (await searchParams) ?? {};
|
||||
const fromHousekeeping = sp.from === "housekeeping";
|
||||
|
||||
@@ -36,9 +39,14 @@ export default async function PermissionsPage({
|
||||
}));
|
||||
|
||||
return (
|
||||
<PermissionsList
|
||||
ranks={ranksWithCounts}
|
||||
fromHousekeeping={fromHousekeeping}
|
||||
/>
|
||||
<div className="space-y-4">
|
||||
<Link href="/admin/permissions/preview" className="btn btn-outline">
|
||||
{t("title")}
|
||||
</Link>
|
||||
<PermissionsList
|
||||
ranks={ranksWithCounts}
|
||||
fromHousekeeping={fromHousekeeping}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { beforeEach, expect, it, vi } from "vitest";
|
||||
|
||||
const state = vi.hoisted(() => ({ access: vi.fn(), execute: vi.fn() }));
|
||||
vi.mock("@/lib/db", () => ({ db: { execute: state.execute } }));
|
||||
vi.mock("@/lib/permissions", () => ({
|
||||
PERMS: { PERMISSIONS_MANAGE: "admin.permissions.manage" },
|
||||
getAdminContext: async () => ({
|
||||
session: { user: { rank: 7 } },
|
||||
permissions: {},
|
||||
}),
|
||||
canAccess: state.access,
|
||||
}));
|
||||
vi.mock("next/navigation", () => ({
|
||||
redirect: () => {
|
||||
throw Error("redirect");
|
||||
},
|
||||
}));
|
||||
vi.mock("next-intl/server", () => ({
|
||||
getTranslations: async () => (key: string) => key,
|
||||
}));
|
||||
vi.mock("@/components/link", () => ({
|
||||
default: ({ children }: { children: React.ReactNode }) => (
|
||||
<span>{children}</span>
|
||||
),
|
||||
}));
|
||||
|
||||
import Page from "./page";
|
||||
|
||||
beforeEach(() => {
|
||||
state.access.mockReset().mockReturnValue(true);
|
||||
state.execute.mockReset();
|
||||
});
|
||||
it("rejects before querying role details", async () => {
|
||||
state.access.mockReturnValue(false);
|
||||
await expect(Page({ searchParams: Promise.resolve({}) })).rejects.toThrow(
|
||||
"redirect",
|
||||
);
|
||||
expect(state.execute).not.toHaveBeenCalled();
|
||||
});
|
||||
it("does not silently substitute an invalid requested role", async () => {
|
||||
state.execute.mockResolvedValueOnce([
|
||||
[{ id: 1, slug: "rank_1", title: "User" }],
|
||||
]);
|
||||
const html = renderToStaticMarkup(
|
||||
await Page({ searchParams: Promise.resolve({ role: "99" }) }),
|
||||
);
|
||||
expect(html).toContain("missing");
|
||||
expect(state.execute).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
it("simulates highest-rank override from live rank data", async () => {
|
||||
state.execute
|
||||
.mockResolvedValueOnce([[{ id: 10, slug: "rank_8", title: "Owner" }]])
|
||||
.mockResolvedValueOnce([[]])
|
||||
.mockResolvedValueOnce([[{ highest_rank: 8 }]])
|
||||
.mockResolvedValueOnce([[]]);
|
||||
const html = renderToStaticMarkup(
|
||||
await Page({ searchParams: Promise.resolve({ role: "10" }) }),
|
||||
);
|
||||
expect(html).toContain("superAdmin");
|
||||
expect(html).toContain("entryAllowed");
|
||||
});
|
||||
it("does not infer owner privileges from a title", async () => {
|
||||
state.execute
|
||||
.mockResolvedValueOnce([[{ id: 10, slug: "owner", title: "Owner" }]])
|
||||
.mockResolvedValueOnce([[]])
|
||||
.mockResolvedValueOnce([[{ highest_rank: 8 }]])
|
||||
.mockResolvedValueOnce([[]]);
|
||||
const html = renderToStaticMarkup(
|
||||
await Page({ searchParams: Promise.resolve({ role: "10" }) }),
|
||||
);
|
||||
expect(html).not.toContain("superAdmin");
|
||||
expect(html).toContain("entryDenied");
|
||||
});
|
||||
File diff suppressed because it is too large.
Load diff
@@ -20,6 +20,7 @@ import { Label } from "@/components/ui/label";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { useServerAction } from "@/hooks/use-server-action";
|
||||
import { useUnsavedChanges } from "@/hooks/use-unsaved-changes";
|
||||
import {
|
||||
BOOLEAN_KEYS,
|
||||
type ManagedField,
|
||||
@@ -45,7 +46,13 @@ export function CmsSettingsForm({
|
||||
canEdit: boolean;
|
||||
}) {
|
||||
const [values, setValues] = useState(initialValues);
|
||||
const { isPending, run } = useServerAction();
|
||||
const [savedValues, setSavedValues] = useState(initialValues);
|
||||
const tAction = useTranslations("pages.admin.actions");
|
||||
const dirty = Object.keys({ ...savedValues, ...values }).some(
|
||||
(key) => savedValues[key] !== values[key],
|
||||
);
|
||||
useUnsavedChanges(canEdit && dirty, tAction("leaveUnsaved"));
|
||||
const { isPending, run, fieldErrors } = useServerAction();
|
||||
const t = useTranslations("pages.admin.settings");
|
||||
|
||||
function handleChange(key: string, value: string) {
|
||||
@@ -55,6 +62,7 @@ export function CmsSettingsForm({
|
||||
function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
if (!canEdit) return;
|
||||
const submitted = { ...values };
|
||||
const normalized = { ...values };
|
||||
for (const key of BOOLEAN_KEYS) {
|
||||
normalized[key] = values[key] === "1" ? "1" : "0";
|
||||
@@ -62,24 +70,34 @@ export function CmsSettingsForm({
|
||||
run(() => saveManagedSettings({ settings: normalized }), {
|
||||
successMessage: t("saved"),
|
||||
errorMessage: t("saveFailed"),
|
||||
onSuccess: () => setSavedValues(submitted),
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="space-y-5">
|
||||
{Object.keys(fieldErrors).length > 0 && (
|
||||
<div role="alert" className="admin-card text-sm text-destructive">
|
||||
{Object.values(fieldErrors).flat().join(" · ")}
|
||||
</div>
|
||||
)}
|
||||
{SETTINGS_GROUPS.map((group) => (
|
||||
<GroupSection
|
||||
key={group.id}
|
||||
group={group}
|
||||
values={values}
|
||||
canEdit={canEdit}
|
||||
canEdit={canEdit && !isPending}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
))}
|
||||
|
||||
{canEdit ? (
|
||||
<div className="sticky bottom-3 z-10 flex justify-end">
|
||||
<Button type="submit" disabled={isPending} className="shadow-lg">
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={isPending || !dirty}
|
||||
className="shadow-lg"
|
||||
>
|
||||
{isPending ? (
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -7,6 +7,8 @@ const mocks = vi.hoisted(() => ({
|
||||
guard: vi.fn(),
|
||||
create: vi.fn(),
|
||||
list: vi.fn(),
|
||||
page: vi.fn(),
|
||||
retry: vi.fn(),
|
||||
cancel: vi.fn(),
|
||||
after: vi.fn(),
|
||||
ping: vi.fn(),
|
||||
@@ -31,6 +33,8 @@ vi.mock("@/lib/services/furni-job-store", async (original) => ({
|
||||
ImportJobStore: class {
|
||||
create = mocks.create;
|
||||
list = mocks.list;
|
||||
page = mocks.page;
|
||||
retry = mocks.retry;
|
||||
requestCancellation = mocks.cancel;
|
||||
},
|
||||
}));
|
||||
@@ -129,3 +133,49 @@ it("does not reveal other owners' jobs", async () => {
|
||||
it("rejects unsafe cancellation IDs", async () => {
|
||||
expect((await PATCH(request({ id: "../other" }), ctx)).status).toBe(400);
|
||||
});
|
||||
|
||||
it("delegates retry to owner-scoped server selection, ignoring client items", async () => {
|
||||
const id = randomUUID();
|
||||
mocks.retry.mockResolvedValue({ id });
|
||||
const response = await PATCH(
|
||||
request({ id, action: "retry", items: [item] }),
|
||||
ctx,
|
||||
);
|
||||
expect(response.status).toBe(200);
|
||||
expect(mocks.retry).toHaveBeenCalledWith(id, 7);
|
||||
});
|
||||
it("rejects retries while the queue is offline", async () => {
|
||||
mocks.ping.mockResolvedValue(null);
|
||||
mocks.retry.mockClear();
|
||||
expect(
|
||||
(await PATCH(request({ id: randomUUID(), action: "retry" }), ctx)).status,
|
||||
).toBe(503);
|
||||
expect(mocks.retry).not.toHaveBeenCalled();
|
||||
});
|
||||
it("loads a page only for the authenticated operator", async () => {
|
||||
mocks.page.mockResolvedValue({ jobs: [], nextCursor: null });
|
||||
const response = await GET(
|
||||
new NextRequest(
|
||||
"http://localhost/api/admin/studio/import-jobs?page=1&userId=99",
|
||||
),
|
||||
ctx,
|
||||
);
|
||||
expect(response.status).toBe(200);
|
||||
expect(mocks.page).toHaveBeenCalledWith({
|
||||
userId: 7,
|
||||
limit: 20,
|
||||
before: undefined,
|
||||
});
|
||||
});
|
||||
it("rejects malformed history cursors", async () => {
|
||||
expect(
|
||||
(
|
||||
await GET(
|
||||
new NextRequest(
|
||||
"http://localhost/api/admin/studio/import-jobs?page=1&before=broken",
|
||||
),
|
||||
ctx,
|
||||
)
|
||||
).status,
|
||||
).toBe(400);
|
||||
});
|
||||
@@ -36,7 +36,26 @@ const schema = z.object({
|
||||
});
|
||||
export const GET = withAdmin(
|
||||
{ permission: PERMS.ASSETS_IMPORT },
|
||||
async (_request, ctx) => {
|
||||
async (request, ctx) => {
|
||||
const query = new URL(request.url).searchParams;
|
||||
if (query.has("page")) {
|
||||
const before = query.get("before") || undefined;
|
||||
if (
|
||||
before &&
|
||||
!/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z\|[0-9a-f-]{36}$/i.test(
|
||||
before,
|
||||
)
|
||||
)
|
||||
return apiError("Invalid history cursor", 400);
|
||||
after(drainFurnitureImports);
|
||||
return apiOk(
|
||||
await new ImportJobStore().page({
|
||||
userId: ctx.session.user.id,
|
||||
limit: 20,
|
||||
before,
|
||||
}),
|
||||
);
|
||||
}
|
||||
after(drainFurnitureImports);
|
||||
const jobs = await new ImportJobStore().list({
|
||||
userId: ctx.session.user.id,
|
||||
@@ -88,6 +107,26 @@ export const PATCH = withAdmin(
|
||||
async (request, ctx) => {
|
||||
const body = await request.json().catch(() => null);
|
||||
if (!validJobId(body?.id)) return apiError("Invalid import ID", 400);
|
||||
if (body.action === "retry") {
|
||||
if (!redis || (await redis.ping().catch(() => null)) !== "PONG")
|
||||
return apiError(
|
||||
"Background import queue is temporarily unavailable",
|
||||
503,
|
||||
);
|
||||
const job = await new ImportJobStore().retry(
|
||||
body.id,
|
||||
ctx.session.user.id,
|
||||
);
|
||||
if (!job)
|
||||
return apiError(
|
||||
"No safe remaining items to retry, or import job not found",
|
||||
404,
|
||||
);
|
||||
after(drainFurnitureImports);
|
||||
return apiOk({ job });
|
||||
}
|
||||
if (body.action && body.action !== "cancel")
|
||||
return apiError("Invalid job action", 400);
|
||||
const job = await new ImportJobStore().requestCancellation(
|
||||
body.id,
|
||||
ctx.session.user.id,
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,32 @@
|
||||
"use client";
|
||||
import { type RefObject, useEffect } from "react";
|
||||
export function useFormErrorFocus(
|
||||
form: RefObject<HTMLFormElement | null>,
|
||||
errors: Record<string, string[]>,
|
||||
pending: boolean,
|
||||
) {
|
||||
useEffect(() => {
|
||||
if (pending) return;
|
||||
for (const field of Object.keys(errors)) {
|
||||
const control = form.current?.elements.namedItem(field);
|
||||
if (control instanceof HTMLElement && !control.hasAttribute("disabled")) {
|
||||
control.focus();
|
||||
break;
|
||||
}
|
||||
}
|
||||
}, [form, errors, pending]);
|
||||
}
|
||||
export function FormFieldError({
|
||||
name,
|
||||
errors,
|
||||
}: {
|
||||
name: string;
|
||||
errors: Record<string, string[]>;
|
||||
}) {
|
||||
const messages = errors[name];
|
||||
return messages?.length ? (
|
||||
<p id={`error-${name}`} role="alert" className="text-sm text-destructive">
|
||||
{messages.join(" · ")}
|
||||
</p>
|
||||
) : null;
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
"use client";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { CatalogExportClient } from "@/app/admin/studio/sync/catalog-export-client";
|
||||
import Link from "@/components/link";
|
||||
import { FurnitureJobHistory } from "./studio/furniture-jobs";
|
||||
import { useFurnitureJobs } from "./studio/use-furniture-jobs";
|
||||
export function OperationsCenter() {
|
||||
const t = useTranslations("pages.admin.operationsCenter");
|
||||
const jobs = useFurnitureJobs(() => {}, true);
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<h1 className="text-2xl font-bold">{t("title")}</h1>
|
||||
<p className="text-[var(--admin-text-muted)]">{t("hint")}</p>
|
||||
<FurnitureJobHistory state={jobs} expanded />
|
||||
<CatalogExportClient monitorOnly />
|
||||
<section className="admin-card space-y-3">
|
||||
<h2 className="text-lg font-semibold">{t("syncTitle")}</h2>
|
||||
<p>{t("syncHint")}</p>
|
||||
<Link href="/admin/studio/sync" className="btn btn-outline">
|
||||
{t("openSync")}
|
||||
</Link>
|
||||
<Link href="/admin/studio/furni" className="btn btn-outline">
|
||||
{t("openImports")}
|
||||
</Link>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -58,7 +58,7 @@ export async function OperationsInbox() {
|
||||
source: "imports",
|
||||
count: total,
|
||||
severity: 2,
|
||||
href: "/admin/studio/furni",
|
||||
href: "/admin/operations",
|
||||
},
|
||||
];
|
||||
},
|
||||
|
||||
@@ -7,14 +7,19 @@ import { retryableJobItems } from "@/lib/furni/import-job-retry";
|
||||
import type { useFurnitureJobs } from "./use-furniture-jobs";
|
||||
export function FurnitureJobHistory({
|
||||
state,
|
||||
expanded = false,
|
||||
}: {
|
||||
state: ReturnType<typeof useFurnitureJobs>;
|
||||
expanded?: boolean;
|
||||
}) {
|
||||
const t = useTranslations("pages.admin.importHistory");
|
||||
const format = useFormatter();
|
||||
const [limits, setLimits] = useState<Record<string, number>>({});
|
||||
return (
|
||||
<details className="shrink-0 border-b border-[var(--admin-border)] bg-[var(--admin-surface)] px-4 py-2 text-sm">
|
||||
<details
|
||||
open={expanded || undefined}
|
||||
className="shrink-0 border-b border-[var(--admin-border)] bg-[var(--admin-surface)] px-4 py-2 text-sm"
|
||||
>
|
||||
<summary className="cursor-pointer">
|
||||
{t("summary", {
|
||||
count: state.jobs.filter(
|
||||
@@ -22,15 +27,40 @@ export function FurnitureJobHistory({
|
||||
).length,
|
||||
})}
|
||||
</summary>
|
||||
<div className="max-h-72 space-y-2 overflow-auto py-2">
|
||||
<div
|
||||
className={
|
||||
expanded ? "space-y-2 py-2" : "max-h-72 space-y-2 overflow-auto py-2"
|
||||
}
|
||||
>
|
||||
{state.error && <p role="alert">{state.error}</p>}
|
||||
<p className="text-xs text-[var(--admin-text-muted)]">
|
||||
{t("historyLimit")}
|
||||
{state.paginated ? t("ownerHistory") : t("historyLimit")}
|
||||
</p>
|
||||
<Button variant="outline" onClick={() => void state.refresh()}>
|
||||
{t("refreshHistory")}
|
||||
</Button>
|
||||
{!state.jobs.length && <p>{t("empty")}</p>}
|
||||
{state.paginated && (
|
||||
<div className="flex items-center gap-3">
|
||||
<Button
|
||||
variant="outline"
|
||||
disabled={state.loading || state.page === 1}
|
||||
onClick={() => void state.navigate("previous")}
|
||||
>
|
||||
{t("previousPage")}
|
||||
</Button>
|
||||
<span>{t("page", { page: state.page })}</span>
|
||||
<Button
|
||||
variant="outline"
|
||||
disabled={state.loading || !state.nextCursor}
|
||||
onClick={() => void state.navigate("next")}
|
||||
>
|
||||
{t("nextPage")}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
{!state.jobs.length && !state.loading && !state.error && (
|
||||
<p>{t("empty")}</p>
|
||||
)}
|
||||
{state.jobs.map((job) => {
|
||||
const retryItems = retryableJobItems(job);
|
||||
const done = job.items.filter((i) => i.state === "done"),
|
||||
@@ -123,14 +153,7 @@ export function FurnitureJobHistory({
|
||||
<Button
|
||||
variant="outline"
|
||||
disabled={state.busy}
|
||||
onClick={() =>
|
||||
void state.submit(retryItems, {
|
||||
sourceId: job.sourceId,
|
||||
mode: job.mode,
|
||||
translate: job.translate,
|
||||
langs: job.langs,
|
||||
})
|
||||
}
|
||||
onClick={() => void state.retry(job.id)}
|
||||
>
|
||||
{t(
|
||||
job.state === "interrupted" || job.state === "cancelled"
|
||||
|
||||
@@ -5,8 +5,13 @@ import { toast } from "sonner";
|
||||
import { useVisiblePolling } from "@/hooks/use-visible-polling";
|
||||
import { adminFetch } from "@/lib/admin-fetch";
|
||||
import type { ImportJob, ImportJobItem } from "@/lib/furni/import-job";
|
||||
export function useFurnitureJobs(onComplete: () => void) {
|
||||
export function useFurnitureJobs(onComplete: () => void, paginated = false) {
|
||||
const t = useTranslations("pages.admin.importHistory");
|
||||
const cursor = useRef<string | null>(null);
|
||||
const previous = useRef<Array<string | null>>([]);
|
||||
const [nextCursor, setNextCursor] = useState<string | null>(null);
|
||||
const [page, setPage] = useState(1);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [cancelling, setCancelling] = useState<string | null>(null);
|
||||
const cancelInFlight = useRef(false);
|
||||
const [jobs, setJobs] = useState<ImportJob[]>([]),
|
||||
@@ -21,14 +26,18 @@ export function useFurnitureJobs(onComplete: () => void) {
|
||||
const refresh = useCallback(async () => {
|
||||
if (refreshing.current) return;
|
||||
refreshing.current = true;
|
||||
setLoading(true);
|
||||
try {
|
||||
const response = await adminFetch("/api/admin/studio/import-jobs");
|
||||
const response = await adminFetch(
|
||||
`/api/admin/studio/import-jobs${paginated ? `?page=1${cursor.current ? `&before=${encodeURIComponent(cursor.current)}` : ""}` : ""}`,
|
||||
);
|
||||
const data = await response.json();
|
||||
if (!response.ok)
|
||||
throw Error(
|
||||
data.error || `Import history unavailable (${response.status})`,
|
||||
);
|
||||
setJobs(data.jobs);
|
||||
setNextCursor(data.nextCursor ?? null);
|
||||
setError("");
|
||||
let changed = false;
|
||||
for (const job of data.jobs as ImportJob[])
|
||||
@@ -48,8 +57,45 @@ export function useFurnitureJobs(onComplete: () => void) {
|
||||
);
|
||||
} finally {
|
||||
refreshing.current = false;
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
}, [paginated]);
|
||||
const navigate = async (direction: "next" | "previous") => {
|
||||
if (refreshing.current) return;
|
||||
if (direction === "next" && nextCursor) {
|
||||
previous.current.push(cursor.current);
|
||||
cursor.current = nextCursor;
|
||||
} else if (direction === "previous" && previous.current.length)
|
||||
cursor.current = previous.current.pop() ?? null;
|
||||
else return;
|
||||
setPage(previous.current.length + 1);
|
||||
setJobs([]);
|
||||
setNextCursor(null);
|
||||
await refresh();
|
||||
};
|
||||
const retry = async (id: string) => {
|
||||
if (submitting.current) return;
|
||||
submitting.current = true;
|
||||
setBusy(true);
|
||||
try {
|
||||
const response = await adminFetch("/api/admin/studio/import-jobs", {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ id, action: "retry" }),
|
||||
});
|
||||
const data = await response.json();
|
||||
if (!response.ok) throw Error(data.error || t("cancelFailed"));
|
||||
cursor.current = null;
|
||||
previous.current = [];
|
||||
setPage(1);
|
||||
await refresh();
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : t("cancelFailed"));
|
||||
} finally {
|
||||
submitting.current = false;
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
const active = jobs.some(
|
||||
(job) => job.state === "queued" || job.state === "running",
|
||||
);
|
||||
@@ -120,5 +166,19 @@ export function useFurnitureJobs(onComplete: () => void) {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
return { jobs, busy, error, submit, refresh, cancel, cancelling };
|
||||
return {
|
||||
jobs,
|
||||
busy,
|
||||
error,
|
||||
submit,
|
||||
refresh,
|
||||
cancel,
|
||||
cancelling,
|
||||
retry,
|
||||
navigate,
|
||||
page,
|
||||
nextCursor,
|
||||
loading,
|
||||
paginated,
|
||||
};
|
||||
}
|
||||
+2
-2
@@ -1159,8 +1159,8 @@ export const AdminAuditLog = mysqlTable("admin_audit_log", {
|
||||
target: varchar("target", { length: 191 }).notNull().default(""),
|
||||
targetId: int("target_id"),
|
||||
details: text("details"),
|
||||
before: text("before"),
|
||||
after: text("after"),
|
||||
before: mediumtext("before"),
|
||||
after: mediumtext("after"),
|
||||
diff: text("diff"),
|
||||
ipAddress: varchar("ip_address", { length: 45 }),
|
||||
createdAt: varchar("created_at", { length: 64 }).notNull().default(""),
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -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] }));
|
||||
}
|
||||
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",
|
||||
|
||||
@@ -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",
|
||||
);
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -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 };
|
||||
});
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -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;
|
||||
|
||||
+140
-3
File diff suppressed because it is too large.
Load diff
+140
-3
File diff suppressed because it is too large.
Load diff
+140
-3
File diff suppressed because it is too large.
Load diff
+140
-3
File diff suppressed because it is too large.
Load diff
+140
-3
File diff suppressed because it is too large.
Load diff
+140
-3
File diff suppressed because it is too large.
Load diff
+140
-3
File diff suppressed because it is too large.
Load diff
+140
-3
File diff suppressed because it is too large.
Load diff
+140
-3
File diff suppressed because it is too large.
Load diff
+140
-3
File diff suppressed because it is too large.
Load diff
+140
-3
File diff suppressed because it is too large.
Load diff
+140
-3
File diff suppressed because it is too large.
Load diff
+140
-3
File diff suppressed because it is too large.
Load diff
+140
-3
File diff suppressed because it is too large.
Load diff
+140
-3
File diff suppressed because it is too large.
Load diff
+140
-3
File diff suppressed because it is too large.
Load diff
+140
-3
File diff suppressed because it is too large.
Load diff
+140
-3
File diff suppressed because it is too large.
Load diff
+140
-3
File diff suppressed because it is too large.
Load diff
+140
-3
File diff suppressed because it is too large.
Load diff
+140
-3
File diff suppressed because it is too large.
Load diff
+140
-3
File diff suppressed because it is too large.
Load diff
+140
-3
File diff suppressed because it is too large.
Load diff
+140
-3
File diff suppressed because it is too large.
Load diff
+140
-3
File diff suppressed because it is too large.
Load diff
Reference in new issue
Block a user