From c7891b60c3556ccd29dc5edb96df3b45b5277513 Mon Sep 17 00:00:00 2001 From: simoleo89 Date: Tue, 1 Sep 2026 19:04:48 +0200 Subject: [PATCH] feat(housekeeping): adapt catalog studio workflow --- src/app/api/admin/import/furni/route.ts | 41 +-- .../hotel/commands/studio-commands.test.ts | 132 +++++++ .../domains/hotel/commands/studio-commands.ts | 284 +++++++++++++-- .../components/operation-progress.test.tsx | 22 ++ .../hotel/components/operation-progress.tsx | 35 +- .../domains/hotel/pages/studio.test.tsx | 126 +++++++ .../domains/hotel/pages/studio.tsx | 324 +++++++++++++++++- .../domains/hotel/queries/studio.test.ts | 35 ++ .../domains/hotel/queries/studio.ts | 159 ++++++++- src/lib/services/clone-inventory.test.ts | 97 ++++++ src/lib/services/clone-inventory.ts | 135 ++++++++ 11 files changed, 1305 insertions(+), 85 deletions(-) create mode 100644 src/lib/services/clone-inventory.test.ts create mode 100644 src/lib/services/clone-inventory.ts diff --git a/src/app/api/admin/import/furni/route.ts b/src/app/api/admin/import/furni/route.ts index 5c66de76..d76b2476 100644 --- a/src/app/api/admin/import/furni/route.ts +++ b/src/app/api/admin/import/furni/route.ts @@ -6,7 +6,7 @@ import { apiError, apiOk } from "@/lib/api-response"; import { db, ItemsBase } from "@/lib/db"; import { PERMS } from "@/lib/permissions"; import { logAudit } from "@/lib/services/audit"; -import { getCloneList } from "@/lib/services/clone-import"; +import { getCloneInventoryStatus } from "@/lib/services/clone-inventory"; import { getSource } from "@/lib/services/clone-sources"; import { getFurniAssetDirs } from "@/lib/services/furni-asset-dirs"; import { @@ -303,7 +303,7 @@ export const GET = withAdmin( const source = await getSource(sourceId); if (!source) return apiError("Source not found", 404); - const { items, meta } = await getCloneList({ + const { items, meta } = await getCloneInventoryStatus({ source, search, page, @@ -313,37 +313,6 @@ export const GET = withAdmin( const sourceIconBase = source.iconBaseUrl?.trim().replace(/\/+$/, ""); - // Bulk-load FurnitureData and catalog references for status checks - const furniDataSet = new Set(); - const catalogIdSet = new Set(); - try { - const fd = (await readFurniData()) as { - roomitemtypes?: { furnitype?: Array<{ classname?: string }> }; - wallitemtypes?: { furnitype?: Array<{ classname?: string }> }; - }; - for (const e of [ - ...(fd.roomitemtypes?.furnitype ?? []), - ...(fd.wallitemtypes?.furnitype ?? []), - ]) { - if (e.classname) furniDataSet.add(e.classname); - } - } catch { - /* best effort */ - } - try { - const rows = await db.execute(sql`SELECT item_ids FROM catalog_items`); - for (const row of ( - rows as unknown as [Array<{ item_ids: string | null }>, unknown] - )[0]) { - for (const part of String(row.item_ids ?? "").split(/[;,]/)) { - const n = Number(part.trim()); - if (Number.isFinite(n) && n > 0) catalogIdSet.add(n); - } - } - } catch { - /* best effort */ - } - const enriched = items.map((item) => { const imported = item.present; let nitroExists = false; @@ -368,9 +337,9 @@ export const GET = withAdmin( revision: 0, category: String(item.category ?? ""), alreadyImported: imported, - nitroExists, - hasFurnidata: imported ? furniDataSet.has(item.classname) : undefined, - hasCatalogEntry: imported ? catalogIdSet.has(item.id) : undefined, + nitroExists: item.nitroExists ?? nitroExists, + hasFurnidata: item.hasFurnidata, + hasCatalogEntry: item.hasCatalogEntry, iconUrl: sourceIconBase ? `${sourceIconBase}/${encodeURIComponent(item.classname)}_icon.png` : undefined, diff --git a/src/features/housekeeping/domains/hotel/commands/studio-commands.test.ts b/src/features/housekeeping/domains/hotel/commands/studio-commands.test.ts index a5360c4a..e117fc07 100644 --- a/src/features/housekeeping/domains/hotel/commands/studio-commands.test.ts +++ b/src/features/housekeeping/domains/hotel/commands/studio-commands.test.ts @@ -10,6 +10,57 @@ import { } from "./studio-commands"; const importBadgeSynced = vi.hoisted(() => vi.fn()); +const cloneServices = vi.hoisted(() => ({ + listSources: vi.fn(), + getClonableClassnames: vi.fn(), + fetchSourceFurnidata: vi.fn(), + cloneSingleFurni: vi.fn(), + appendFurniEntriesBatch: vi.fn(), + appendFurniEntry: vi.fn(), + reconcileImportedOfferIds: vi.fn(), + reconcileFurniDataWithItemsBase: vi.fn(), + rebuildCatalogOfferIds: vi.fn(), + verifyAndFixSpriteIds: vi.fn(), + verifyAndFixInteractionModesCount: vi.fn(), + buildLocalizedFurniDataFiles: vi.fn(), + ensureFurniOwnership: vi.fn(), + syncAssetsToGamedataBundle: vi.fn(), + updateCatalog: vi.fn(), + updateItems: vi.fn(), +})); + +vi.mock("@/lib/services/clone-sources", () => ({ + listSources: cloneServices.listSources, +})); +vi.mock("@/lib/services/clone-import", () => ({ + getClonableClassnames: cloneServices.getClonableClassnames, + fetchSourceFurnidata: cloneServices.fetchSourceFurnidata, + cloneSingleFurni: cloneServices.cloneSingleFurni, +})); +vi.mock("@/lib/services/furni-data", () => ({ + appendFurniEntriesBatch: cloneServices.appendFurniEntriesBatch, + appendFurniEntry: cloneServices.appendFurniEntry, + reconcileFurniDataWithItemsBase: + cloneServices.reconcileFurniDataWithItemsBase, + rebuildCatalogOfferIds: cloneServices.rebuildCatalogOfferIds, + verifyAndFixSpriteIds: cloneServices.verifyAndFixSpriteIds, +})); +vi.mock("@/lib/services/furni-data-i18n", () => ({ + buildLocalizedFurniDataFiles: cloneServices.buildLocalizedFurniDataFiles, +})); +vi.mock("@/lib/services/furni-import", () => ({ + reconcileImportedOfferIds: cloneServices.reconcileImportedOfferIds, + verifyAndFixInteractionModesCount: + cloneServices.verifyAndFixInteractionModesCount, + ensureFurniOwnership: cloneServices.ensureFurniOwnership, + syncAssetsToGamedataBundle: cloneServices.syncAssetsToGamedataBundle, +})); +vi.mock("@/lib/services/rcon", () => ({ + rcon: { + updateCatalog: cloneServices.updateCatalog, + updateItems: cloneServices.updateItems, + }, +})); vi.mock("@/lib/services/import-badge", () => ({ importBadgeSynced })); @@ -89,6 +140,87 @@ describe("Studio commands", () => { controller.signal, ); }); + + it("clones every missing item, reports durable progress and retains retry data", async () => { + cloneServices.listSources.mockResolvedValueOnce([ + { + id: "hotel-nl", + name: "Hotel NL", + furnidataUrl: "https://hotel.test/furnidata.json", + nitroBaseUrl: "https://hotel.test/nitro", + iconBaseUrl: "https://hotel.test/icons", + hotel: "nl", + }, + ]); + cloneServices.getClonableClassnames.mockResolvedValueOnce([ + "modern_chair", + "broken_lamp", + "modern_chair", + ]); + cloneServices.fetchSourceFurnidata.mockResolvedValueOnce([ + { classname: "modern_chair", itemType: "s" }, + { classname: "broken_lamp", itemType: "s" }, + ]); + cloneServices.cloneSingleFurni.mockImplementation(async ({ entry }) => + entry.classname === "modern_chair" + ? { + ok: true, + classname: entry.classname, + warnings: [], + furniDataEntry: { + entry: { classname: entry.classname }, + itemType: "s", + }, + } + : { + ok: false, + classname: entry.classname, + warnings: [], + error: "download failed", + }, + ); + const report = vi.fn(); + + await expect( + studioOperationRunner.run( + "clone", + { + mode: "all-missing", + sourceId: "hotel-nl", + translate: true, + langs: ["nl", "en"], + concurrency: 2, + }, + { + operationId: "op-clone-all", + correlationId: "corr-clone-all", + actorId: 42, + report, + }, + ), + ).resolves.toMatchObject({ + phase: "partial", + completed: 2, + total: 2, + output: { + sourceId: "hotel-nl", + succeeded: 1, + failed: 1, + skipped: 0, + failedClassnames: ["broken_lamp"], + }, + }); + expect(report.mock.calls.map(([progress]) => progress.completed)).toEqual([ + 1, 2, + ]); + expect(cloneServices.appendFurniEntriesBatch).toHaveBeenCalledTimes(1); + expect(cloneServices.buildLocalizedFurniDataFiles).toHaveBeenCalledWith( + true, + ["nl", "en"], + ); + expect(cloneServices.updateCatalog).toHaveBeenCalledTimes(1); + expect(cloneServices.updateItems).toHaveBeenCalledTimes(1); + }); }); describe("Studio operation service", () => { diff --git a/src/features/housekeeping/domains/hotel/commands/studio-commands.ts b/src/features/housekeeping/domains/hotel/commands/studio-commands.ts index 6c3c2fd6..d7d0e451 100644 --- a/src/features/housekeeping/domains/hotel/commands/studio-commands.ts +++ b/src/features/housekeeping/domains/hotel/commands/studio-commands.ts @@ -341,6 +341,256 @@ function normalizedResult(value: unknown): StudioOperationRunnerResult { }; } +function stringList(value: unknown): string[] { + const values = Array.isArray(value) + ? value + : typeof value === "string" + ? value.split(",") + : []; + return [ + ...new Set( + values + .filter((item): item is string => typeof item === "string") + .map((item) => item.normalize("NFC").trim()) + .filter(Boolean), + ), + ]; +} + +function cloneConcurrency(value: unknown): number { + const parsed = Number(value ?? 3); + return Number.isSafeInteger(parsed) ? Math.min(10, Math.max(1, parsed)) : 3; +} + +async function runCloneOperation( + input: Readonly>, + execution: StudioOperationExecution, +): Promise { + const [cloneSources, cloneImport] = await Promise.all([ + import("@/lib/services/clone-sources"), + import("@/lib/services/clone-import"), + ]); + const sourceId = requiredText(input, "sourceId"); + const source = (await cloneSources.listSources()).find( + (item) => item.id === sourceId, + ); + if (!source) { + throw new StudioOperationFailure( + "DEPENDENCY_UNAVAILABLE", + "errors.housekeeping.dependencyUnavailable", + ); + } + + const mode = typeof input.mode === "string" ? input.mode : "single"; + let classnames: string[]; + if (mode === "all-missing") { + classnames = stringList(await cloneImport.getClonableClassnames(source)); + } else if (mode === "retry") { + classnames = stringList(input.classnames); + if (classnames.length === 0) { + throw new StudioOperationFailure( + "VALIDATION", + "errors.housekeeping.validation", + ); + } + } else if (mode === "single") { + classnames = [requiredText(input, "classname")]; + } else { + throw new StudioOperationFailure( + "VALIDATION", + "errors.housekeeping.validation", + ); + } + + execution.signal?.throwIfAborted(); + const sourceEntries = await cloneImport.fetchSourceFurnidata( + source.furnidataUrl, + Date.now(), + execution.signal, + ); + const entriesByClassname = new Map( + sourceEntries.map((entry) => [entry.classname, entry] as const), + ); + type CloneOutcome = + | { + readonly classname: string; + readonly result: Awaited< + ReturnType + >; + } + | { readonly classname: string; readonly error: string }; + const outcomes: CloneOutcome[] = new Array(classnames.length); + let nextIndex = 0; + let completed = 0; + let reportChain = Promise.resolve(); + const report = () => { + completed += 1; + const snapshot = completed; + reportChain = reportChain.then(() => + execution.report({ + completed: snapshot, + total: classnames.length, + messageKey: "pages.housekeeping.studio.progress.clone", + }), + ); + }; + const worker = async () => { + while (nextIndex < classnames.length) { + const index = nextIndex; + nextIndex += 1; + const classname = classnames[index]; + const entry = entriesByClassname.get(classname); + if (!entry) { + outcomes[index] = { classname, error: "source entry unavailable" }; + report(); + continue; + } + try { + execution.signal?.throwIfAborted(); + outcomes[index] = { + classname, + result: await cloneImport.cloneSingleFurni({ + source, + entry, + signal: execution.signal, + deferFurniData: true, + }), + }; + } catch (error) { + if (cancelled(execution.signal, error)) throw error; + outcomes[index] = { + classname, + error: error instanceof Error ? error.message : "clone failed", + }; + } + report(); + } + }; + await Promise.all( + Array.from( + { + length: Math.min( + cloneConcurrency(input.concurrency), + classnames.length, + ), + }, + worker, + ), + ); + await reportChain; + execution.signal?.throwIfAborted(); + + const furniDataEntries: Array<{ + entry: Record; + itemType: string; + }> = []; + const failedClassnames: string[] = []; + const warnings: string[] = []; + let succeeded = 0; + let skipped = 0; + for (const outcome of outcomes) { + if ("error" in outcome) { + failedClassnames.push(outcome.classname); + warnings.push(`${outcome.classname}: ${outcome.error}`); + continue; + } + warnings.push( + ...outcome.result.warnings.map( + (warning) => `${outcome.classname}: ${warning}`, + ), + ); + if (outcome.result.skipped) { + skipped += 1; + } else if (outcome.result.ok) { + succeeded += 1; + if (outcome.result.furniDataEntry) { + furniDataEntries.push(outcome.result.furniDataEntry); + } + } else { + failedClassnames.push(outcome.classname); + warnings.push( + `${outcome.classname}: ${outcome.result.error ?? "clone failed"}`, + ); + } + } + + const [furniData, furniDataI18n, furniImport, { rcon }] = await Promise.all([ + import("@/lib/services/furni-data"), + import("@/lib/services/furni-data-i18n"), + import("@/lib/services/furni-import"), + import("@/lib/services/rcon"), + ]); + if (furniDataEntries.length > 0) { + try { + await furniData.appendFurniEntriesBatch(furniDataEntries); + } catch (error) { + warnings.push( + `FurnitureData batch write failed: ${(error as Error).message}`, + ); + for (const item of furniDataEntries) { + try { + await furniData.appendFurniEntry(item.entry, item.itemType); + } catch (fallbackError) { + warnings.push( + `FurnitureData fallback failed: ${(fallbackError as Error).message}`, + ); + } + } + } + } + const consolidate = async (label: string, task: () => Promise) => { + try { + await task(); + } catch (error) { + warnings.push(`${label}: ${(error as Error).message}`); + } + }; + await consolidate( + "offer reconciliation", + furniImport.reconcileImportedOfferIds, + ); + await consolidate( + "FurnitureData reconciliation", + furniData.reconcileFurniDataWithItemsBase, + ); + await consolidate("catalog offer rebuild", furniData.rebuildCatalogOfferIds); + await consolidate("sprite ID verification", furniData.verifyAndFixSpriteIds); + await consolidate( + "interaction mode verification", + furniImport.verifyAndFixInteractionModesCount, + ); + if (input.translate === true) { + const languages = stringList(input.langs); + await consolidate("FurnitureData translation", () => + furniDataI18n.buildLocalizedFurniDataFiles( + true, + languages.length > 0 ? languages : undefined, + ), + ); + } + await consolidate("asset ownership", furniImport.ensureFurniOwnership); + await consolidate("gamedata sync", () => + furniImport.syncAssetsToGamedataBundle(execution.signal), + ); + await consolidate("catalog refresh", () => rcon.updateCatalog()); + await consolidate("item refresh", () => rcon.updateItems()); + + const failed = failedClassnames.length; + return { + phase: failed > 0 || warnings.length > 0 ? "partial" : "completed", + completed: classnames.length, + total: classnames.length, + output: { + sourceId, + succeeded, + failed, + skipped, + failedClassnames, + warnings, + }, + }; +} + async function productionRun( kind: StudioOperationKind, rawInput: Readonly>, @@ -370,39 +620,7 @@ async function productionRun( execution.signal, ); } else if (kind === "clone") { - const [{ listSources }, { cloneSingleFurni, fetchSourceFurnidata }] = - await Promise.all([ - import("@/lib/services/clone-sources"), - import("@/lib/services/clone-import"), - ]); - const sourceId = requiredText(input, "sourceId"); - const classname = requiredText(input, "classname"); - const source = (await listSources()).find((item) => item.id === sourceId); - if (!source) { - throw new StudioOperationFailure( - "DEPENDENCY_UNAVAILABLE", - "errors.housekeeping.dependencyUnavailable", - ); - } - const entry = ( - await fetchSourceFurnidata( - source.furnidataUrl, - Date.now(), - execution.signal, - ) - ).find((item) => item.classname === classname); - if (!entry) { - throw new StudioOperationFailure( - "NOT_FOUND", - "errors.housekeeping.notFound", - ); - } - result = await cloneSingleFurni({ - source, - entry, - onProgress: progress, - signal: execution.signal, - }); + return runCloneOperation(input, execution); } else if (kind === "clothing") { if (input.setType !== undefined || input.setId !== undefined) { const { importClothingSet } = await import( diff --git a/src/features/housekeeping/domains/hotel/components/operation-progress.test.tsx b/src/features/housekeeping/domains/hotel/components/operation-progress.test.tsx index 6b94462b..8ef26131 100644 --- a/src/features/housekeeping/domains/hotel/components/operation-progress.test.tsx +++ b/src/features/housekeeping/domains/hotel/components/operation-progress.test.tsx @@ -23,6 +23,28 @@ describe("Studio operation components", () => { expect(markup).toContain("2 / 4"); }); + it("shows percentage and a stable ETA from persisted operation timestamps", () => { + const markup = renderToStaticMarkup( + , + ); + + expect(markup).toContain("50%"); + expect(markup).toContain("Estimated remaining: 20s"); + }); + it("distinguishes partial and failed terminal results", () => { const partial = renderToStaticMarkup( 0; + const percentage = determinate + ? Math.round((event.completed / (event.total ?? 1)) * 100) + : null; + let estimatedSeconds: number | null = null; + if ( + determinate && + event.completed > 0 && + "createdAt" in event && + "updatedAt" in event + ) { + const elapsedSeconds = Math.max( + 0, + (Date.parse(event.updatedAt) - Date.parse(event.createdAt)) / 1000, + ); + estimatedSeconds = Math.round( + (elapsedSeconds / event.completed) * + ((event.total ?? 0) - event.completed), + ); + } return (
{event.completed} {event.total === null ? " completed" : ` / ${event.total}`} + {percentage === null ? null : ` (${percentage}%)`} + {estimatedSeconds !== null && estimatedSeconds > 0 ? ( + + Estimated remaining:{" "} + {Math.floor(estimatedSeconds / 60) > 0 + ? `${Math.floor(estimatedSeconds / 60)}m ${estimatedSeconds % 60}s` + : `${estimatedSeconds}s`} + + ) : null}
); } diff --git a/src/features/housekeeping/domains/hotel/pages/studio.test.tsx b/src/features/housekeeping/domains/hotel/pages/studio.test.tsx index 97c6b325..a40f2118 100644 --- a/src/features/housekeeping/domains/hotel/pages/studio.test.tsx +++ b/src/features/housekeeping/domains/hotel/pages/studio.test.tsx @@ -111,4 +111,130 @@ describe("Hotel Studio page", () => { expect(markup).not.toContain("hotel.studio.maintenance.run"); expect(markup).not.toContain("Start maintenance operation"); }); + + it("renders a typed clone workspace with detailed status, clone-all and retry", () => { + const markup = renderToStaticMarkup( + , + ); + + expect(markup).toContain("Hotel NL"); + expect(markup).toContain("Clone all missing (3)"); + expect(markup).toContain("Modern chair"); + expect(markup).toContain("Missing Nitro"); + expect(markup).toContain("Missing catalog entry"); + expect(markup).toContain("Retry failed items (2)"); + expect(markup).toContain('name="classname"'); + expect(markup).toContain('name="translate"'); + expect(markup).not.toContain("Operation input (JSON)"); + expect(markup).toContain('value="chair"'); + expect(markup).toContain("itemPage=2"); + }); + + it("does not present unknown clone diagnostics as ready", () => { + const markup = renderToStaticMarkup( + , + ); + + expect(markup).not.toContain("Ready"); + }); + + it("uses route-specific fields instead of a generic JSON payload", () => { + const markup = renderToStaticMarkup( + , + ); + + expect(markup).toContain('name="classname"'); + expect(markup).toContain('name="revision"'); + expect(markup).toContain('name="updateExisting"'); + expect(markup).not.toContain("Operation input (JSON)"); + }); }); diff --git a/src/features/housekeeping/domains/hotel/pages/studio.tsx b/src/features/housekeeping/domains/hotel/pages/studio.tsx index 315a2d18..a8dc60b7 100644 --- a/src/features/housekeeping/domains/hotel/pages/studio.tsx +++ b/src/features/housekeeping/domains/hotel/pages/studio.tsx @@ -6,9 +6,16 @@ import type { import type { HousekeepingPageInput } from "../../../route-handlers"; import { OperationProgress } from "../components/operation-progress"; import { OperationResult } from "../components/operation-result"; -import { type StudioQueryData, studioOperationQuery } from "../queries/studio"; +import { + type StudioCloneFilter, + type StudioCloneWorkspace, + type StudioOperationKind, + type StudioOperationRecord, + type StudioQueryData, + studioOperationQuery, +} from "../queries/studio"; import { type HotelStudioRouteId, hotelStudioOperationKind } from "../routes"; -import { HotelCommandForm } from "./hotel-command-form"; +import { type HotelCommandField, HotelCommandForm } from "./hotel-command-form"; export interface StudioPageProps { readonly context: HousekeepingCapabilityContext; @@ -27,6 +34,281 @@ function canRun( ); } +const STUDIO_FIELDS: Record< + Exclude, + readonly HotelCommandField[] +> = { + badge: [ + { name: "code", label: "Badge code", type: "text", required: true }, + { name: "name", label: "Badge name", type: "text", required: true }, + { name: "description", label: "Description", type: "textarea" }, + ], + clothing: [ + { name: "lib", label: "Figure library", type: "text" }, + { name: "setType", label: "Set type", type: "text" }, + { name: "setId", label: "Set ID", type: "number" }, + ], + effect: [ + { name: "id", label: "Effect ID", type: "text", required: true }, + { name: "lib", label: "Library", type: "text", required: true }, + { name: "type", label: "Effect type", type: "text", required: true }, + { name: "revision", label: "Revision", type: "number", required: true }, + ], + furni: [ + { name: "id", label: "Sprite ID", type: "number", required: true }, + { name: "classname", label: "Classname", type: "text", required: true }, + { name: "name", label: "Display name", type: "text", required: true }, + { name: "description", label: "Description", type: "textarea" }, + { + name: "type", + label: "Type (flooritem or wallitem)", + type: "text", + required: true, + }, + { name: "revision", label: "Revision", type: "number", required: true }, + { name: "category", label: "Category", type: "text", required: true }, + { name: "updateExisting", label: "Update existing item", type: "checkbox" }, + ], + maintenance: [ + { + name: "dedupePages", + label: "Deduplicate catalog pages", + type: "checkbox", + }, + ], + pet: [{ name: "lib", label: "Pet library", type: "text", required: true }], + "repair-icons": [ + { + name: "repairNitros", + label: "Repair missing Nitro bundles too", + type: "checkbox", + }, + ], + sync: [], + upload: [ + { name: "classname", label: "Classname", type: "text", required: true }, + { name: "name", label: "Display name", type: "text", required: true }, + { name: "description", label: "Description", type: "textarea" }, + { + name: "itemType", + label: "Item type (s or i)", + type: "text", + required: true, + }, + { + name: "nitroBase64", + label: "Nitro bundle (base64)", + type: "textarea", + required: true, + maxLength: 10_000_000, + }, + { + name: "iconBase64", + label: "Catalog icon (base64)", + type: "textarea", + maxLength: 2_000_000, + }, + { name: "generateSql", label: "Generate SQL", type: "checkbox" }, + ], +}; + +const CLONE_TRANSLATION_FIELDS: readonly HotelCommandField[] = [ + { + name: "translate", + label: "Translate FurnitureData after import", + type: "checkbox", + }, + { + name: "langs", + label: "Languages (comma separated, empty means all)", + type: "id-list", + }, +]; + +function retryPayload(operation: StudioOperationRecord) { + const sourceId = operation.output?.sourceId; + const failedClassnames = operation.output?.failedClassnames; + if (typeof sourceId !== "string" || !Array.isArray(failedClassnames)) + return null; + const classnames = failedClassnames.filter( + (classname): classname is string => + typeof classname === "string" && Boolean(classname.trim()), + ); + return classnames.length > 0 ? { sourceId, classnames } : null; +} + +function cloneInventoryHref(workspace: StudioCloneWorkspace, page: number) { + const params = new URLSearchParams({ + sourceId: workspace.selectedSource.id, + status: workspace.query.filter, + itemPage: String(page), + itemPageSize: String(workspace.meta.pageSize), + }); + if (workspace.query.search) params.set("itemSearch", workspace.query.search); + return `?${params.toString()}`; +} + +function CloneWorkspace({ + workspace, + operations, +}: { + readonly workspace?: StudioCloneWorkspace; + readonly operations: readonly StudioOperationRecord[]; +}) { + if (!workspace) { + return

Clone inventory is temporarily unavailable.

; + } + const { selectedSource, stats } = workspace; + const lastPage = Math.max( + 1, + Math.ceil(workspace.meta.total / workspace.meta.pageSize), + ); + return ( +
+
+

+ Clone inventory — {selectedSource.name} +

+

+ {stats.present} imported · {stats.clonable} missing · {stats.total}{" "} + total +

+
+ +
+ + + + +
+ {stats.clonable > 0 ? ( + + ) : null} + +
+ + + + + + + + + + {workspace.items.map((item) => ( + + + + + + ))} + +
FurniClassnameStatus
{item.name}{item.classname} + {!item.alreadyImported ? Not imported : null} + {item.nitroExists === false ? ( + Missing Nitro + ) : null} + {item.hasFurnidata === false ? ( + Missing FurnitureData + ) : null} + {item.hasCatalogEntry === false ? ( + Missing catalog entry + ) : null} + {item.alreadyImported && + (item.nitroExists === undefined || + item.hasFurnidata === undefined || + item.hasCatalogEntry === undefined) ? ( + Status unavailable + ) : null} + {item.alreadyImported && + item.nitroExists === true && + item.hasFurnidata === true && + item.hasCatalogEntry === true ? ( + Ready + ) : null} +
+
+ + {operations.map((operation) => { + const retry = retryPayload(operation); + return retry ? ( + + ) : null; + })} +
+ ); +} + export function StudioPage({ context, result, routeId }: StudioPageProps) { if (!result) { return ( @@ -52,7 +334,7 @@ export function StudioPage({ context, result, routeId }: StudioPageProps) { const state = result.data.partialDependencies.length > 0 ? "partial" - : result.data.items.length === 0 + : result.data.items.length === 0 && !result.data.cloneWorkspace ? "empty" : "ready"; const operationKind = routeId ? hotelStudioOperationKind(routeId) : null; @@ -67,20 +349,21 @@ export function StudioPage({ context, result, routeId }: StudioPageProps) { Temporarily unavailable: {result.data.partialDependencies.join(", ")}.

) : null} - {routeId && operationKind && canRun(context, routeId) ? ( + {routeId && operationKind === "clone" && canRun(context, routeId) ? ( + + ) : null} + {routeId && + operationKind && + operationKind !== "clone" && + canRun(context, routeId) ? ( ) : null} @@ -104,6 +387,11 @@ function first(value: string | readonly string[] | undefined) { return Array.isArray(value) ? value[0] : value; } +function cloneFilter(value: string | undefined): StudioCloneFilter | undefined { + return value === "all" || value === "missing" || value === "present" + ? value + : undefined; +} export async function renderHotelStudioPage(input: HousekeepingPageInput) { const routeId = input.match.routeId as HotelStudioRouteId; const result = await studioOperationQuery.run(input.context, { @@ -113,6 +401,16 @@ export async function renderHotelStudioPage(input: HousekeepingPageInput) { pageSize: Number(first(input.searchParams?.pageSize) ?? 25), offset: Number(first(input.searchParams?.offset) ?? 0), }, + clone: + hotelStudioOperationKind(routeId) === "clone" + ? { + sourceId: first(input.searchParams?.sourceId), + search: first(input.searchParams?.itemSearch), + filter: cloneFilter(first(input.searchParams?.status)), + page: Number(first(input.searchParams?.itemPage) ?? 1), + pageSize: Number(first(input.searchParams?.itemPageSize) ?? 25), + } + : undefined, }); return ( diff --git a/src/features/housekeeping/domains/hotel/queries/studio.test.ts b/src/features/housekeeping/domains/hotel/queries/studio.test.ts index 4c34ecd8..349118bf 100644 --- a/src/features/housekeeping/domains/hotel/queries/studio.test.ts +++ b/src/features/housekeeping/domains/hotel/queries/studio.test.ts @@ -69,6 +69,41 @@ describe("Studio operation query", () => { }); }); + it("normalizes clone workspace source, paging and inventory filters", async () => { + const load = vi.fn(async () => ({ + kind: "studio" as const, + items: [], + total: 0, + partialDependencies: [] as const, + })); + await createStudioOperationQuery({ load }).run( + context([PERMS.ASSETS_IMPORT]), + { + routeId: "hotel.studio.clone", + clone: { + sourceId: " hotel-nl ", + search: " chair ", + filter: "present", + page: -4, + pageSize: 999, + }, + }, + ); + + expect(load).toHaveBeenCalledWith({ + routeId: "hotel.studio.clone", + operationKind: "clone", + list: { search: "", pageSize: 25, offset: 0 }, + clone: { + sourceId: "hotel-nl", + search: "chair", + filter: "present", + page: 1, + pageSize: 100, + }, + }); + }); + it("loads the audit route across all operation kinds without inventing a command kind", async () => { const load = vi.fn(async () => ({ kind: "studio" as const, diff --git a/src/features/housekeeping/domains/hotel/queries/studio.ts b/src/features/housekeeping/domains/hotel/queries/studio.ts index f5265aa2..c8c570de 100644 --- a/src/features/housekeeping/domains/hotel/queries/studio.ts +++ b/src/features/housekeeping/domains/hotel/queries/studio.ts @@ -224,6 +224,55 @@ export const studioOperationRepository: StudioOperationRepository = { list: (input) => memoryStudioOperations.list(input), }; +export type StudioCloneFilter = "all" | "missing" | "present"; + +export interface StudioCloneSourceSummary { + readonly id: string; + readonly name: string; + readonly hotel: string; +} + +export interface StudioCloneItemStatus { + readonly id: number; + readonly classname: string; + readonly name: string; + readonly type: "flooritem" | "wallitem"; + readonly category: string; + readonly alreadyImported: boolean; + readonly nitroExists?: boolean; + readonly hasFurnidata?: boolean; + readonly hasCatalogEntry?: boolean; +} + +export interface StudioCloneWorkspace { + readonly sources: readonly StudioCloneSourceSummary[]; + readonly selectedSource: StudioCloneSourceSummary; + readonly stats: Readonly<{ + total: number; + present: number; + clonable: number; + }>; + readonly items: readonly StudioCloneItemStatus[]; + readonly meta: Readonly<{ page: number; pageSize: number; total: number }>; + readonly query: Readonly<{ search: string; filter: StudioCloneFilter }>; +} + +export interface StudioCloneQueryInput { + readonly sourceId?: string; + readonly search?: string; + readonly filter?: StudioCloneFilter; + readonly page?: number; + readonly pageSize?: number; +} + +export interface NormalizedStudioCloneQueryInput { + readonly sourceId: string; + readonly search: string; + readonly filter: StudioCloneFilter; + readonly page: number; + readonly pageSize: number; +} + export interface StudioQueryInput { readonly routeId: HotelStudioRouteId; readonly list?: Readonly<{ @@ -231,12 +280,14 @@ export interface StudioQueryInput { pageSize?: number; offset?: number; }>; + readonly clone?: StudioCloneQueryInput; } export interface NormalizedStudioQueryInput { readonly routeId: HotelStudioRouteId; readonly operationKind: StudioOperationKind | null; readonly list: Readonly<{ search: string; pageSize: number; offset: number }>; + readonly clone?: NormalizedStudioCloneQueryInput; } export interface StudioQueryData { @@ -244,6 +295,7 @@ export interface StudioQueryData { readonly items: readonly StudioOperationRecord[]; readonly total: number; readonly partialDependencies: readonly string[]; + readonly cloneWorkspace?: StudioCloneWorkspace; } export interface StudioQueryAdapters { @@ -262,6 +314,11 @@ function boundedInteger( } function normalizeInput(input: StudioQueryInput): NormalizedStudioQueryInput { + const cloneFilter = input.clone?.filter; + const normalizedCloneFilter: StudioCloneFilter = + cloneFilter === "missing" || cloneFilter === "present" + ? cloneFilter + : "all"; return { routeId: input.routeId, operationKind: hotelStudioOperationKind(input.routeId), @@ -273,6 +330,23 @@ function normalizeInput(input: StudioQueryInput): NormalizedStudioQueryInput { pageSize: boundedInteger(input.list?.pageSize, 25, 1, 100), offset: boundedInteger(input.list?.offset, 0, 0, 100_000), }, + ...(input.clone + ? { + clone: { + sourceId: String(input.clone.sourceId ?? "") + .normalize("NFC") + .trim() + .slice(0, 128), + search: String(input.clone.search ?? "") + .normalize("NFC") + .trim() + .slice(0, 128), + filter: normalizedCloneFilter, + page: boundedInteger(input.clone.page, 1, 1, 100_000), + pageSize: boundedInteger(input.clone.pageSize, 25, 1, 100), + }, + } + : {}), }; } @@ -321,6 +395,74 @@ export function createStudioOperationQuery( }; } +async function loadStudioCloneWorkspace( + input: NormalizedStudioCloneQueryInput, +): Promise<{ + readonly workspace?: StudioCloneWorkspace; + readonly partialDependencies: readonly string[]; +}> { + try { + const [{ listSources }, { getCloneInventoryStatus }] = await Promise.all([ + import("@/lib/services/clone-sources"), + import("@/lib/services/clone-inventory"), + ]); + const sources = await listSources(); + const selected = + sources.find((source) => source.id === input.sourceId) ?? sources[0]; + if (!selected) { + return { partialDependencies: ["external-sources"] }; + } + const inventory = await getCloneInventoryStatus({ + source: selected, + search: input.search, + page: input.page, + perPage: input.pageSize, + filter: input.filter, + }); + return { + workspace: { + sources: sources.map(({ id, name, hotel }) => ({ id, name, hotel })), + selectedSource: { + id: selected.id, + name: selected.name, + hotel: selected.hotel, + }, + stats: inventory.stats, + items: inventory.items.map( + (item): StudioCloneItemStatus => ({ + id: item.id, + classname: item.classname, + name: item.name, + type: item.itemType === "i" ? "wallitem" : "flooritem", + category: item.category, + alreadyImported: item.present, + ...(item.nitroExists === undefined + ? {} + : { nitroExists: item.nitroExists }), + ...(item.hasFurnidata === undefined + ? {} + : { hasFurnidata: item.hasFurnidata }), + ...(item.hasCatalogEntry === undefined + ? {} + : { hasCatalogEntry: item.hasCatalogEntry }), + }), + ), + query: { + search: input.search, + filter: input.filter, + }, + meta: { + page: inventory.meta.page, + pageSize: inventory.meta.perPage, + total: inventory.meta.total, + }, + }, + partialDependencies: inventory.partialDependencies, + }; + } catch { + return { partialDependencies: ["external-sources"] }; + } +} export const studioOperationQuery = createStudioOperationQuery({ async load(input) { const result = await studioOperationRepository.list({ @@ -328,7 +470,21 @@ export const studioOperationQuery = createStudioOperationQuery({ ...input.list, }); const partialDependencies: string[] = []; - if (input.operationKind === "clone" || input.operationKind === "sync") { + let cloneWorkspace: StudioCloneWorkspace | undefined; + if (input.operationKind === "clone") { + const cloneResult = await loadStudioCloneWorkspace( + input.clone ?? { + sourceId: "", + search: "", + filter: "all", + page: 1, + pageSize: 25, + }, + ); + cloneWorkspace = cloneResult.workspace; + partialDependencies.push(...cloneResult.partialDependencies); + } + if (input.operationKind === "sync") { try { const { listSources } = await import("@/lib/services/clone-sources"); if ((await listSources()).length === 0) { @@ -343,6 +499,7 @@ export const studioOperationQuery = createStudioOperationQuery({ items: result.items, total: result.total, partialDependencies, + ...(cloneWorkspace ? { cloneWorkspace } : {}), }; }, }); diff --git a/src/lib/services/clone-inventory.test.ts b/src/lib/services/clone-inventory.test.ts new file mode 100644 index 00000000..e7d5a6f0 --- /dev/null +++ b/src/lib/services/clone-inventory.test.ts @@ -0,0 +1,97 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const dependencies = vi.hoisted(() => ({ + getCloneList: vi.fn(), + getCloneStats: vi.fn(), + localItems: vi.fn(), + execute: vi.fn(), + readFurniData: vi.fn(), + getFurniAssetDirs: vi.fn(), + existsSync: vi.fn(), +})); + +vi.mock("node:fs", () => ({ existsSync: dependencies.existsSync })); +vi.mock("drizzle-orm", () => ({ + inArray: vi.fn(() => "items-by-classname"), + sql: vi.fn(() => "catalog-item-ids"), +})); +vi.mock("@/lib/db", () => ({ + ItemsBase: { id: "id", itemName: "itemName" }, + db: { + select: vi.fn(() => ({ + from: vi.fn(() => ({ where: dependencies.localItems })), + })), + execute: dependencies.execute, + }, +})); +vi.mock("@/lib/services/clone-import", () => ({ + getCloneList: dependencies.getCloneList, + getCloneStats: dependencies.getCloneStats, +})); +vi.mock("@/lib/services/furni-data", () => ({ + readFurniData: dependencies.readFurniData, +})); +vi.mock("@/lib/services/furni-asset-dirs", () => ({ + getFurniAssetDirs: dependencies.getFurniAssetDirs, +})); + +import { getCloneInventoryStatus } from "./clone-inventory"; + +describe("clone inventory status", () => { + beforeEach(() => { + vi.clearAllMocks(); + dependencies.getCloneList.mockResolvedValue({ + items: [ + { + id: 900, + classname: "modern_chair", + name: "Modern chair", + description: "A modern chair", + itemType: "s", + category: "chair", + present: true, + }, + ], + meta: { page: 1, perPage: 25, total: 1 }, + }); + dependencies.getCloneStats.mockResolvedValue({ + total: 1, + present: 1, + clonable: 0, + }); + dependencies.localItems.mockResolvedValue([ + { id: 42, itemName: "modern_chair" }, + ]); + dependencies.execute.mockResolvedValue([[{ item_ids: "42" }], undefined]); + dependencies.readFurniData.mockResolvedValue({ + roomitemtypes: { furnitype: [{ classname: "modern_chair" }] }, + }); + dependencies.getFurniAssetDirs.mockResolvedValue({ nitroDir: "/nitros" }); + dependencies.existsSync.mockReturnValue(true); + }); + + it("checks catalog presence with the allocated local ID, not the remote source ID", async () => { + const result = await getCloneInventoryStatus({ + source: { + id: "hotel-nl", + name: "Hotel NL", + furnidataUrl: "https://hotel.test/furnidata.json", + nitroBaseUrl: "https://hotel.test/nitro", + iconBaseUrl: "https://hotel.test/icons", + hotel: "nl", + }, + search: "", + page: 1, + perPage: 25, + filter: "all", + }); + + expect(result.items[0]).toMatchObject({ + id: 900, + classname: "modern_chair", + hasCatalogEntry: true, + hasFurnidata: true, + nitroExists: true, + }); + }); +}); diff --git a/src/lib/services/clone-inventory.ts b/src/lib/services/clone-inventory.ts new file mode 100644 index 00000000..3c013038 --- /dev/null +++ b/src/lib/services/clone-inventory.ts @@ -0,0 +1,135 @@ +import { existsSync } from "node:fs"; +import path from "node:path"; +import { inArray, sql } from "drizzle-orm"; +import { db, ItemsBase } from "@/lib/db"; +import { getCloneList, getCloneStats } from "@/lib/services/clone-import"; +import type { CloneSource } from "@/lib/services/clone-sources"; +import { getFurniAssetDirs } from "@/lib/services/furni-asset-dirs"; +import { readFurniData } from "@/lib/services/furni-data"; + +export interface CloneInventoryStatusItem { + readonly id: number; + readonly classname: string; + readonly name: string; + readonly itemType: "s" | "i"; + readonly description: string; + readonly category: string; + readonly present: boolean; + readonly nitroExists?: boolean; + readonly hasFurnidata?: boolean; + readonly hasCatalogEntry?: boolean; +} + +export interface CloneInventoryStatus { + readonly items: readonly CloneInventoryStatusItem[]; + readonly stats: { + readonly total: number; + readonly present: number; + readonly clonable: number; + }; + readonly meta: { + readonly page: number; + readonly perPage: number; + readonly total: number; + }; + readonly partialDependencies: readonly ("database" | "filesystem")[]; +} + +export async function getCloneInventoryStatus(params: { + readonly source: CloneSource; + readonly search: string; + readonly page: number; + readonly perPage: number; + readonly filter: "all" | "missing" | "present"; +}): Promise { + const [listResult, stats] = await Promise.all([ + getCloneList(params), + getCloneStats(params.source), + ]); + const partialDependencies = new Set<"database" | "filesystem">(); + const importedNames = listResult.items + .filter((item) => item.present) + .map((item) => item.classname); + const localIdsByClassname = new Map(); + const catalogItemIds = new Set(); + if (importedNames.length > 0) { + try { + const localItems = await db + .select({ id: ItemsBase.id, itemName: ItemsBase.itemName }) + .from(ItemsBase) + .where(inArray(ItemsBase.itemName, importedNames)); + for (const item of localItems) { + if (item.itemName) localIdsByClassname.set(item.itemName, item.id); + } + const catalogRows = await db.execute( + sql`SELECT item_ids FROM catalog_items`, + ); + for (const row of ( + catalogRows as unknown as [Array<{ item_ids: string | null }>, unknown] + )[0]) { + for (const rawId of String(row.item_ids ?? "").split(/[;,]/)) { + const id = Number(rawId.trim()); + if (Number.isSafeInteger(id) && id > 0) catalogItemIds.add(id); + } + } + } catch { + partialDependencies.add("database"); + } + } + + let furnidataClassnames: Set | null = null; + try { + const furnidata = (await readFurniData()) as { + roomitemtypes?: { furnitype?: Array<{ classname?: string }> }; + wallitemtypes?: { furnitype?: Array<{ classname?: string }> }; + }; + furnidataClassnames = new Set( + [ + ...(furnidata.roomitemtypes?.furnitype ?? []), + ...(furnidata.wallitemtypes?.furnitype ?? []), + ] + .map((item) => item.classname) + .filter((classname): classname is string => Boolean(classname)), + ); + } catch { + partialDependencies.add("filesystem"); + } + + let nitroExists: ((classname: string) => boolean) | null = null; + try { + const { nitroDir } = await getFurniAssetDirs(); + nitroExists = (classname) => { + const base = classname.split("*", 1)[0]; + return existsSync(path.join(nitroDir, `${base}.nitro`)); + }; + } catch { + partialDependencies.add("filesystem"); + } + + return { + stats, + meta: listResult.meta, + partialDependencies: [...partialDependencies], + items: listResult.items.map((item) => { + const localId = localIdsByClassname.get(item.classname); + return { + id: item.id, + classname: item.classname, + name: item.name, + description: item.description, + itemType: item.itemType, + category: String(item.category ?? ""), + present: item.present, + ...(item.present && nitroExists + ? { nitroExists: nitroExists(item.classname) } + : {}), + ...(item.present && furnidataClassnames + ? { hasFurnidata: furnidataClassnames.has(item.classname) } + : {}), + ...(item.present && localId !== undefined + ? { hasCatalogEntry: catalogItemIds.has(localId) } + : {}), + }; + }), + }; +}