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

This commit is contained in:
Simo committed 2026-09-09 21:57:56 +02:00
1 parent 89526af344
commit 1ef405be0a
92 files changed
+6384 -267

No files matched your search

+24 -1
View File
@@ -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
+9 -1
View File
@@ -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",
+9
View File
@@ -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 />;
}
+12 -4
View File
@@ -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
+21 -3
View File
@@ -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);
});
+40 -1
View File
@@ -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,