From 2f7e557d5e5f144ae3f792d0b58da19691154ac0 Mon Sep 17 00:00:00 2001 From: openhands Date: Tue, 29 Sep 2026 15:48:32 +0200 Subject: [PATCH] feat(catalog): add a Studio button to fix missing furnidata entries "Missing furnidata" was only a filter in the Studio status dropdown, so imported items whose classname was absent from FurnitureData.json could be found but not fixed from that screen. Only the Catalog Audit page could repair them, and only globally. Adds the same shape of quick action that "no nitro" already had: - studio-client.tsx: a "N no furnidata" shortcut next to the "N no nitro" button that sets the missingFurnidata status filter, and a bulk "Add missing furnidata (N)" button for the selected rows. Both only appear when there is something to act on. Rows that come back repaired flip to hasFurnidata: true so the badges and counts update in place; rows the server reported in errors keep their state. - resync/route.ts: accepts an optional { classnames: string[] } body to target exactly the selected rows. classnames are resolved through the same normalized local index the listing uses to decide hasFurnidata, so the rows written are the rows flagged as missing. The upsert is already idempotent, and RCON updateCatalog + updateItems run afterwards so the emulator picks the new entries up. Also clears the Studio furnidata cache after a write, which this route never did: without it the listing kept serving a stale hasFurnidata for up to the 30s cache TTL, so a repair looked like it had done nothing. PERMS is now imported from permission-slugs (identical re-export) so the route no longer pulls next-auth into tests. - studio-filters.test.ts: pins the missingFurnidata branch, in particular that an unchecked item (hasFurnidata undefined) is not treated as missing. The existing ?days / ?missing / ?broken / ?all modes are unchanged; the body is only consulted when it carries a classnames array. --- .../admin/import/furni/resync/route.test.ts | 175 ++++++++++++++++++ .../api/admin/import/furni/resync/route.ts | 64 +++++-- src/components/admin/studio/studio-client.tsx | 86 +++++++++ .../admin/studio/studio-filters.test.ts | 70 +++++++ 4 files changed, 384 insertions(+), 11 deletions(-) create mode 100644 src/app/api/admin/import/furni/resync/route.test.ts create mode 100644 src/components/admin/studio/studio-filters.test.ts diff --git a/src/app/api/admin/import/furni/resync/route.test.ts b/src/app/api/admin/import/furni/resync/route.test.ts new file mode 100644 index 00000000..0f5cdc58 --- /dev/null +++ b/src/app/api/admin/import/furni/resync/route.test.ts @@ -0,0 +1,175 @@ +import { NextRequest } from "next/server"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { emptyRouteContext } from "@/test/route-context"; + +const state = vi.hoisted(() => ({ + localIndex: new Map>(), + rows: [] as Array>, + append: vi.fn(async (_entries: unknown[]) => {}), + clear: vi.fn(), + rconCatalog: vi.fn(async () => true), + rconItems: vi.fn(async () => true), +})); + +vi.mock("@/lib/api-handler", () => ({ + withAdmin: (_options: unknown, handler: unknown) => handler, +})); + +vi.mock("drizzle-orm", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + inArray: (...args: unknown[]) => ({ op: "in", args }), + asc: (col: unknown) => ({ op: "asc", col }), + }; +}); + +vi.mock("@/lib/db", () => ({ + db: { + select: () => ({ + from: () => ({ + where: async () => state.rows, + orderBy: async () => state.rows, + }), + }), + }, + ItemsBase: { + id: "id", + spriteId: "spriteId", + itemName: "itemName", + publicName: "publicName", + type: "type", + width: "width", + length: "length", + stackHeight: "stackHeight", + }, + queryRows: vi.fn(async () => []), +})); + +vi.mock("@/lib/services/furni-import-cache", () => ({ + getLocalCatalogSnapshot: async () => ({ + localIndex: state.localIndex, + importedSet: new Set(state.localIndex.keys()), + timestamp: 0, + }), + clearFurniImportCache: state.clear, +})); + +vi.mock("@/lib/services/furni-data", () => ({ + appendFurniEntriesBatch: state.append, + buildFurniEntry: (params: { classname: string; id: number }) => ({ + classname: params.classname, + id: params.id, + }), + getFurnitureDataPath: async () => "/nonexistent/furnidata.json", + readFurniData: async () => ({}), +})); + +vi.mock("@/lib/services/habbo-furnidata-cache", () => ({ + lookupOfficialHabboFurni: async () => null, +})); + +vi.mock("@/lib/services/rcon", () => ({ + rcon: { + updateCatalog: state.rconCatalog, + updateItems: state.rconItems, + }, +})); + +import { POST } from "./route"; + +const post = (body?: unknown) => + new NextRequest("http://localhost/api/admin/import/furni/resync", { + method: "POST", + body: body === undefined ? undefined : JSON.stringify(body), + }); + +beforeEach(() => { + state.localIndex = new Map([ + ["chair", [{ id: 1, itemName: "chair" }]], + ["table", [{ id: 2, itemName: "table" }]], + ]); + state.rows = [ + { id: 1, itemName: "chair", spriteId: 1, type: "s" }, + { id: 2, itemName: "table", spriteId: 2, type: "s" }, + ]; + state.append.mockClear(); + state.clear.mockClear(); + state.rconCatalog.mockClear(); + state.rconItems.mockClear(); +}); + +describe("resync with an explicit classname selection", () => { + it("resyncs only the rows matching the sent classnames", async () => { + const response = await POST( + post({ classnames: ["chair", "table", "ghost"] }), + emptyRouteContext(), + ); + const body = await response.json(); + + expect(response.status).toBe(200); + expect(body).toMatchObject({ + mode: "classnames", + examined: 2, + resynced: 2, + failed: 0, + rconOk: true, + }); + expect(state.append).toHaveBeenCalledTimes(1); + const entries = state.append.mock.calls[0]?.[0] as Array<{ + entry: { classname: string }; + }>; + expect(entries.map((e) => e.entry.classname).sort()).toEqual([ + "chair", + "table", + ]); + }); + + it("matches classnames case-insensitively", async () => { + const response = await POST( + post({ classnames: ["CHAIR", " "] }), + emptyRouteContext(), + ); + const body = await response.json(); + expect(body.examined).toBe(1); + }); + + it("drops the cached classname set so the listing recomputes", async () => { + await POST(post({ classnames: ["chair"] }), emptyRouteContext()); + expect(state.clear).toHaveBeenCalledTimes(1); + }); + + it("is a no-op for an empty selection", async () => { + const response = await POST(post({ classnames: [] }), emptyRouteContext()); + const body = await response.json(); + expect(body).toMatchObject({ examined: 0, resynced: 0 }); + expect(state.append).not.toHaveBeenCalled(); + expect(state.clear).not.toHaveBeenCalled(); + expect(state.rconCatalog).not.toHaveBeenCalled(); + }); +}); + +describe("resync query-param modes", () => { + it("falls back to the default days mode when no body is sent", async () => { + const response = await POST(post(), emptyRouteContext()); + // post() sends no JSON at all, so request.json() must reject and be + // swallowed rather than turning the request into a targeted resync. + const body = await response.json(); + expect(response.status).toBe(200); + expect(body.mode).toBe("days"); + expect(state.append).not.toHaveBeenCalled(); + }); + + it("still honours the missing mode via query params", async () => { + const response = await POST( + new NextRequest( + "http://localhost/api/admin/import/furni/resync?missing=1", + { method: "POST" }, + ), + emptyRouteContext(), + ); + const body = await response.json(); + expect(body.mode).toBe("missing"); + expect(state.append).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/app/api/admin/import/furni/resync/route.ts b/src/app/api/admin/import/furni/resync/route.ts index e014a24a..16eac70e 100644 --- a/src/app/api/admin/import/furni/resync/route.ts +++ b/src/app/api/admin/import/furni/resync/route.ts @@ -3,24 +3,31 @@ import { asc, inArray, sql } from "drizzle-orm"; import { apiOk } from "@/lib/api"; import { withAdmin } from "@/lib/api-handler"; import { db, ItemsBase, queryRows } from "@/lib/db"; -import { PERMS } from "@/lib/permissions"; +import { normalizeClassname } from "@/lib/furni/classname"; +import { PERMS } from "@/lib/permission-slugs"; import { appendFurniEntriesBatch, buildFurniEntry, getFurnitureDataPath, readFurniData, } from "@/lib/services/furni-data"; +import { + clearFurniImportCache, + getLocalCatalogSnapshot, +} from "@/lib/services/furni-import-cache"; import { lookupOfficialHabboFurni } from "@/lib/services/habbo-furnidata-cache"; import { rcon } from "@/lib/services/rcon"; /** * POST /api/admin/import/furni/resync * - * Modes (query params, pick one): + * Modes (pick one): * ?days=N — items imported in the last N days (from admin_audit_log). * ?missing=1 — every items_base row whose classname is absent from * FurnitureData.json (authoritative for "what's broken"). * ?all=1 — every items_base row (rebuilds entries for all imports). + * body — { classnames: string[] } targets just those rows, which is + * what the Studio "Add missing furnidata" bulk action uses. * * Default: ?days=7 * @@ -31,21 +38,40 @@ export const POST = withAdmin( { permission: PERMS.ASSETS_IMPORT }, async (request) => { const qp = request.nextUrl.searchParams; - const mode: "days" | "missing" | "broken" | "all" = - qp.get("all") === "1" - ? "all" - : qp.get("broken") === "1" - ? "broken" - : qp.get("missing") === "1" - ? "missing" - : "days"; + + // Targeted mode: the Studio sends the classnames of the selected rows so + // only those get an entry. Everything else keeps using the query params. + const body = (await request.json().catch(() => null)) as { + classnames?: unknown; + } | null; + const classnames = Array.isArray(body?.classnames) + ? body.classnames + .filter((c): c is string => typeof c === "string") + .map((c) => c.trim()) + .filter(Boolean) + : null; const days = Math.max( 1, Math.min(parseInt(qp.get("days") || "7", 10) || 7, 365), ); - const targetIds = await resolveTargetIds(mode, days); + let mode: "days" | "missing" | "broken" | "all" | "classnames"; + let targetIds: number[]; + if (classnames != null) { + mode = "classnames"; + targetIds = await resolveClassnames(classnames); + } else { + mode = + qp.get("all") === "1" + ? "all" + : qp.get("broken") === "1" + ? "broken" + : qp.get("missing") === "1" + ? "missing" + : "days"; + targetIds = await resolveTargetIds(mode, days); + } if (targetIds.length === 0) { return apiOk({ @@ -133,6 +159,10 @@ export const POST = withAdmin( } } + // FurnitureData.json changed — drop the Studio's cached classname set so + // the next listing recomputes `hasFurnidata` instead of serving stale hits. + clearFurniImportCache(); + let rconOk = false; try { const a = await rcon.updateCatalog(); @@ -154,6 +184,18 @@ export const POST = withAdmin( }, ); +async function resolveClassnames(classnames: string[]): Promise { + if (classnames.length === 0) return []; + const { localIndex } = await getLocalCatalogSnapshot(); + const ids = new Set(); + for (const classname of classnames) { + for (const row of localIndex.get(normalizeClassname(classname)) ?? []) { + ids.add(row.id); + } + } + return [...ids]; +} + async function resolveTargetIds( mode: "days" | "missing" | "broken" | "all", days: number, diff --git a/src/components/admin/studio/studio-client.tsx b/src/components/admin/studio/studio-client.tsx index 32da1a2f..04ec7d38 100644 --- a/src/components/admin/studio/studio-client.tsx +++ b/src/components/admin/studio/studio-client.tsx @@ -8,6 +8,7 @@ import { Check, CircleAlert, CloudDownload, + FileJson2, Globe, Languages, Layers, @@ -1145,6 +1146,7 @@ export function StudioClient({ const [bulkDeleting, setBulkDeleting] = useState(false); const [bulkRegenning, setBulkRegenning] = useState(false); + const [bulkFurnidata, setBulkFurnidata] = useState(false); async function bulkDeleteSelected() { if (selectedImported.length === 0) return; @@ -1241,6 +1243,46 @@ export function StudioClient({ } } + async function bulkAddFurnidata() { + if (selectedMissingFurnidata.length === 0) return; + setBulkFurnidata(true); + const wanted = selectedMissingFurnidata.map((i) => i.classname); + try { + const res = await adminFetch("/api/admin/import/furni/resync", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ classnames: wanted }), + }); + const data = await res.json(); + if (res.ok) { + const failedClassnames = new Set( + ((data.errors ?? []) as Array<{ classname: string }>).map( + (e) => e.classname, + ), + ); + const fixedClassnames = new Set( + wanted.filter((cn) => !failedClassnames.has(cn)), + ); + setItems((prev) => + prev.map((i) => + fixedClassnames.has(i.classname) ? { ...i, hasFurnidata: true } : i, + ), + ); + const fixed = Number(data.resynced ?? fixedClassnames.size); + const failed = Number(data.failed ?? 0); + toast.success( + `Furnidata added: ${fixed}${failed > 0 ? `, failed: ${failed}` : ""}`, + ); + } else { + toast.error(data.error || "Adding furnidata failed"); + } + } catch { + toast.error("Network error"); + } finally { + setBulkFurnidata(false); + } + } + const selectedNonImported = useMemo( () => filteredItems.filter( @@ -1255,6 +1297,13 @@ export function StudioClient({ [filteredItems], ); + const missingFurnidataCount = useMemo( + () => + filteredItems.filter((i) => i.alreadyImported && i.hasFurnidata === false) + .length, + [filteredItems], + ); + const selectedImported = useMemo( () => filteredItems.filter( @@ -1268,6 +1317,11 @@ export function StudioClient({ [selectedImported], ); + const selectedMissingFurnidata = useMemo( + () => selectedImported.filter((i) => i.hasFurnidata === false), + [selectedImported], + ); + const notImportedCount = useMemo( () => activeSource && cloneStats @@ -1556,6 +1610,21 @@ export function StudioClient({ {missingNitroCount} no nitro )} + {missingFurnidataCount > 0 && ( + + )}
+ )} + {selectedImported.length > 0 && !batchProgress && (