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
@@ -101,3 +101,16 @@ other locales receive English fallback strings. This is a focused editorial pass
|
||||
not a full translation of every CMS page. No database migration or dependency
|
||||
change is required. Browser checks use real components with simulated data at
|
||||
1280 and 390 pixels; production database behavior still needs deployment validation.
|
||||
|
||||
## Operational reliability and recovery
|
||||
|
||||
- Audit history now records category settings (normal/Club), individual/bulk offer prices, update-mode package publication and news edits inside the write transaction. The audit screen previews and restores individual changes after locking and comparing the current recorded fields. Deleted records, hierarchy changes, LTD counters, imports and historical entries without complete snapshots cannot be restored. Restores create their own history entry. Apply migration `0028_history_snapshots.sql` before running this version: it widens audit snapshots to MEDIUMTEXT without deleting existing data.
|
||||
- `/admin/operations` reuses durable import jobs, stable history pagination and owner-scoped failed-item retries. A deterministic child ID prevents duplicate retries. The shared Git export queue displays actual pending/running state and latest result; synchronous/SSE synchronization remains linked rather than represented as a durable job history.
|
||||
- Catalog maintenance includes a read-only integrity report for normal/Club categories, offers, furniture references and local icons. Known sentinel IDs are preserved. Only categories pointing to a missing positive parent have an automated repair: preview lists every affected category and apply compares the locked graph before reattaching those categories at the root. No records are deleted. Other issues require an explicit manual edit. Reports display up to 200 issues with complete counts; unavailable icon storage is distinguished from missing assets.
|
||||
- CMS errors support exact release and time filters plus frequency sorting. Counts refer to retained matching events, while group resolution remains current across releases.
|
||||
- User/settings forms now protect unsaved edits and preserve failed submissions. User/news validation errors appear at the affected fields; settings show returned validation errors inline. Existing submission locking is retained and tested in a browser.
|
||||
- `/admin/permissions/preview` shows one role's section access and known CMS grants using live ACL and the existing highest-rank policy. It never changes sessions. Additional user roles, navigation customization and record-specific authorization remain explicit limits of the preview.
|
||||
|
||||
New UI copy is supplied in English, Italian and Dutch; other locales receive English fallback strings. No new runtime dependencies. Browser fixtures use real UI components with simulated server responses; the database migration and production behavior have not been exercised on the live hotel.
|
||||
|
||||
Validation for this increment: 1,589 tests passed, six skipped; TypeScript, Biome, translation contracts and fixture production build passed. Browser checks covered user/settings/news forms, permission preview, CMS errors, integrity preview and history restore at 1280 and 390 pixels. Double submission, stale preview, blocked navigation, field focus and horizontal overflow were checked with simulated server actions. No live database writes or deployment were performed.
|
||||
@@ -0,0 +1,2 @@
|
||||
-- Preserve complete before/after snapshots for long news articles.
|
||||
ALTER TABLE admin_audit_log MODIFY COLUMN `before` MEDIUMTEXT NULL, MODIFY COLUMN `after` MEDIUMTEXT NULL;
|
||||
@@ -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