From 2e95a106e0c68d9eb5e986a2e74743f43e08d4c4 Mon Sep 17 00:00:00 2001 From: simoleo89 Date: Sun, 30 Aug 2026 15:29:58 +0200 Subject: [PATCH] feat(housekeeping): integrate studio operations --- .../hotel/commands/studio-commands.test.ts | 207 +++++++ .../domains/hotel/commands/studio-commands.ts | 532 ++++++++++++++++++ .../components/operation-progress.test.tsx | 47 ++ .../hotel/components/operation-progress.tsx | 25 + .../hotel/components/operation-result.tsx | 20 + .../domains/hotel/hotel-providers.test.ts | 74 +++ .../housekeeping/domains/hotel/inbox.ts | 126 +++++ .../housekeeping/domains/hotel/manifest.ts | 13 +- .../domains/hotel/pages/studio.test.tsx | 114 ++++ .../domains/hotel/pages/studio.tsx | 120 ++++ .../domains/hotel/queries/studio.test.ts | 125 ++++ .../domains/hotel/queries/studio.ts | 348 ++++++++++++ .../domains/hotel/route-handlers.ts | 8 +- .../housekeeping/domains/hotel/routes.test.ts | 45 +- .../housekeeping/domains/hotel/routes.ts | 96 +++- .../housekeeping/domains/hotel/search.ts | 111 ++++ .../housekeeping/domains/hotel/widgets.ts | 68 +++ .../foundation/commands/bootstrap.ts | 2 + .../foundation-source-contract.test.ts | 51 +- .../foundation/preview-route-contract.test.ts | 11 + .../housekeeping/foundation/registry.test.ts | 19 +- .../housekeeping/route-handlers.test.ts | 4 +- src/lib/services/clone-import.ts | 17 +- src/lib/services/clothing-set-import.ts | 12 +- src/lib/services/effect-import.ts | 11 +- src/lib/services/figure-import.ts | 10 +- src/lib/services/furni-import.ts | 22 +- src/lib/services/furni-maintenance.ts | 9 +- src/lib/services/import-badge.ts | 29 +- src/lib/services/import/core/download.ts | 17 +- src/lib/services/pet-import.ts | 10 +- src/lib/services/repair-icons.ts | 8 +- src/lib/services/repair-nitros.ts | 10 +- src/lib/services/upload-import.ts | 9 + src/messages/en.json | 13 + src/messages/it.json | 13 + 36 files changed, 2296 insertions(+), 60 deletions(-) create mode 100644 src/features/housekeeping/domains/hotel/commands/studio-commands.test.ts create mode 100644 src/features/housekeeping/domains/hotel/commands/studio-commands.ts create mode 100644 src/features/housekeeping/domains/hotel/components/operation-progress.test.tsx create mode 100644 src/features/housekeeping/domains/hotel/components/operation-progress.tsx create mode 100644 src/features/housekeeping/domains/hotel/components/operation-result.tsx create mode 100644 src/features/housekeeping/domains/hotel/hotel-providers.test.ts create mode 100644 src/features/housekeeping/domains/hotel/inbox.ts create mode 100644 src/features/housekeeping/domains/hotel/pages/studio.test.tsx create mode 100644 src/features/housekeeping/domains/hotel/pages/studio.tsx create mode 100644 src/features/housekeeping/domains/hotel/queries/studio.test.ts create mode 100644 src/features/housekeeping/domains/hotel/queries/studio.ts create mode 100644 src/features/housekeeping/domains/hotel/search.ts create mode 100644 src/features/housekeeping/domains/hotel/widgets.ts diff --git a/src/features/housekeeping/domains/hotel/commands/studio-commands.test.ts b/src/features/housekeeping/domains/hotel/commands/studio-commands.test.ts new file mode 100644 index 00000000..a5360c4a --- /dev/null +++ b/src/features/housekeeping/domains/hotel/commands/studio-commands.test.ts @@ -0,0 +1,207 @@ +import { describe, expect, it, vi } from "vitest"; +import { PERMS } from "@/lib/permission-slugs"; +import type { HousekeepingCapabilityContext } from "../../../foundation/contracts"; +import { createInMemoryStudioOperationRepository } from "../queries/studio"; +import { + createStudioCommands, + createStudioOperationService, + STUDIO_COMMAND_IDS, + studioOperationRunner, +} from "./studio-commands"; + +const importBadgeSynced = vi.hoisted(() => vi.fn()); + +vi.mock("@/lib/services/import-badge", () => ({ importBadgeSynced })); + +function context(granted: readonly string[] = Object.values(PERMS)) { + const permissions = new Set(granted); + return { + actor: { id: 42, username: "operator", rank: 7 }, + isSuperAdmin: false, + has: (slug: string) => permissions.has(slug), + hasAny: (...slugs: string[]) => slugs.some((slug) => permissions.has(slug)), + hasAll: (...slugs: string[]) => + slugs.every((slug) => permissions.has(slug)), + } satisfies HousekeepingCapabilityContext; +} + +describe("Studio commands", () => { + it("registers one reason-required command for every Studio kind", () => { + const commands = createStudioCommands({ execute: vi.fn() } as never); + expect(STUDIO_COMMAND_IDS).toEqual([ + "hotel.studio.badge.run", + "hotel.studio.clone.run", + "hotel.studio.clothing.run", + "hotel.studio.effect.run", + "hotel.studio.furni.run", + "hotel.studio.maintenance.run", + "hotel.studio.pet.run", + "hotel.studio.repair-icons.run", + "hotel.studio.sync.run", + "hotel.studio.upload.run", + ]); + expect(commands.every((command) => command.requiresReason)).toBe(true); + expect(commands.every((command) => command.owner === "hotel")).toBe(true); + expect( + commands.find((command) => command.operationKind === "maintenance") + ?.capability.slugs, + ).toEqual([PERMS.CATALOG_EDIT]); + }); + + it("binds the expected actor and kind to server execution", async () => { + const execute = vi.fn(async () => ({ + ok: true as const, + data: {}, + correlationId: "studio-command", + })); + const command = createStudioCommands({ execute } as never)[1]; + await command.execute( + { + capability: context(), + correlationId: "studio-command", + ipAddress: "127.0.0.1", + }, + { sourceId: "hotel" }, + ); + expect(execute).toHaveBeenCalledWith( + { correlationId: "studio-command", expectedActorId: 42 }, + "clone", + { sourceId: "hotel" }, + ); + }); + + it("propagates cancellation to the production badge runner", async () => { + importBadgeSynced.mockResolvedValueOnce({ ok: true }); + const controller = new AbortController(); + await studioOperationRunner.run( + "badge", + { code: "ADM", name: "Admin", description: "Staff" }, + { + operationId: "op-badge", + correlationId: "corr-badge", + actorId: 42, + signal: controller.signal, + report: vi.fn(), + }, + ); + expect(importBadgeSynced).toHaveBeenCalledWith( + { code: "ADM", name: "Admin", description: "Staff" }, + controller.signal, + ); + }); +}); + +describe("Studio operation service", () => { + it("persists intent before work and records ordered partial progress", async () => { + const order: string[] = []; + const base = createInMemoryStudioOperationRepository(); + const repository = { + persistIntent: vi.fn(async (intent) => { + order.push("intent"); + await base.persistIntent(intent); + }), + appendEvent: vi.fn(async (event, result) => { + order.push(event.phase); + await base.appendEvent(event, result); + }), + get: base.get, + list: base.list, + }; + const runner = { + run: vi.fn(async (_kind, _input, execution) => { + order.push("work"); + await execution.report({ completed: 1, total: 2, messageKey: "step" }); + return { + phase: "partial" as const, + completed: 1, + total: 2, + output: { succeeded: 1, failed: 1 }, + }; + }), + }; + const service = createStudioOperationService({ + repository, + runner, + resolveCapabilityContext: async () => context(), + createOperationId: () => "op-1", + now: () => "2026-08-30T10:00:00.000Z", + }); + const result = await service.execute( + { correlationId: "corr-1", expectedActorId: 42 }, + "furni", + {}, + ); + expect(order.slice(0, 3)).toEqual(["intent", "running", "work"]); + expect(order).toEqual(["intent", "running", "work", "running", "partial"]); + expect(result).toMatchObject({ + ok: true, + data: { phase: "partial", completed: 1, total: 2 }, + completion: { + status: "partial", + external: "failed", + audit: "persisted", + }, + }); + }); + + it("propagates the caller AbortSignal and never starts work without intent", async () => { + const controller = new AbortController(); + controller.abort(); + const repository = createInMemoryStudioOperationRepository(); + const runner = { run: vi.fn() }; + const service = createStudioOperationService({ + repository, + runner, + resolveCapabilityContext: async () => context(), + createOperationId: () => "op-abort", + now: () => "2026-08-30T10:00:00.000Z", + }); + const result = await service.execute( + { + correlationId: "corr-abort", + expectedActorId: 42, + signal: controller.signal, + }, + "sync", + {}, + ); + expect(result).toMatchObject({ ok: false, error: { code: "TIMEOUT" } }); + expect(runner.run).not.toHaveBeenCalled(); + expect( + (await repository.list({ pageSize: 25, offset: 0 })).items[0], + ).toMatchObject({ + phase: "failed", + messageKey: "errors.housekeeping.operationCancelled", + }); + }); + + it("passes a live AbortSignal unchanged to the operation runner", async () => { + const controller = new AbortController(); + const repository = createInMemoryStudioOperationRepository(); + const runner = { + run: vi.fn(async (_kind, _input, execution) => ({ + phase: "completed" as const, + completed: execution.signal === controller.signal ? 1 : 0, + total: 1, + })), + }; + const service = createStudioOperationService({ + repository, + runner, + resolveCapabilityContext: async () => context(), + createOperationId: () => "op-live-signal", + now: () => "2026-08-30T10:00:00.000Z", + }); + const result = await service.execute( + { + correlationId: "corr-live-signal", + expectedActorId: 42, + signal: controller.signal, + }, + "upload", + {}, + ); + expect(result).toMatchObject({ ok: true, data: { completed: 1 } }); + expect(runner.run.mock.calls[0]?.[2].signal).toBe(controller.signal); + }); +}); diff --git a/src/features/housekeeping/domains/hotel/commands/studio-commands.ts b/src/features/housekeeping/domains/hotel/commands/studio-commands.ts new file mode 100644 index 00000000..6c3c2fd6 --- /dev/null +++ b/src/features/housekeeping/domains/hotel/commands/studio-commands.ts @@ -0,0 +1,532 @@ +import "server-only"; + +import { randomUUID } from "node:crypto"; +import { z } from "zod"; +import { PERMS } from "@/lib/permission-slugs"; +import { satisfiesCapability } from "../../../foundation/capability-context"; +import type { HousekeepingCommand } from "../../../foundation/commands/registry"; +import { + anyCapability, + fail, + type HousekeepingCapabilityContext, + type HousekeepingErrorCode, + type HousekeepingResult, + ok, +} from "../../../foundation/contracts"; +import { + STUDIO_OPERATION_KINDS, + type StudioOperationKind, + type StudioOperationPhase, + type StudioOperationRecord, + type StudioOperationRepository, + studioOperationRepository, +} from "../queries/studio"; + +const STUDIO_COMMAND_DEFINITIONS: readonly { + readonly id: `hotel.studio.${StudioOperationKind}.run`; + readonly operationKind: StudioOperationKind; + readonly permission: string; +}[] = STUDIO_OPERATION_KINDS.map((operationKind) => ({ + id: `hotel.studio.${operationKind}.run`, + operationKind, + permission: + operationKind === "maintenance" ? PERMS.CATALOG_EDIT : PERMS.ASSETS_IMPORT, +})); + +export const STUDIO_COMMAND_IDS = STUDIO_COMMAND_DEFINITIONS.map( + ({ id }) => id, +) as readonly `hotel.studio.${StudioOperationKind}.run`[]; + +export interface StudioOperationInvocation { + readonly correlationId: string; + readonly expectedActorId: number; + readonly signal?: AbortSignal; +} + +export interface StudioOperationProgress { + readonly completed: number; + readonly total: number | null; + readonly messageKey: string; +} + +export interface StudioOperationRunnerResult { + readonly phase: "completed" | "partial"; + readonly completed: number; + readonly total: number | null; + readonly output?: Readonly>; +} + +export interface StudioOperationExecution { + readonly operationId: string; + readonly correlationId: string; + readonly actorId: number; + readonly signal?: AbortSignal; + report(progress: StudioOperationProgress): Promise; +} + +export interface StudioOperationRunner { + run( + kind: StudioOperationKind, + input: Readonly>, + execution: StudioOperationExecution, + ): Promise; +} + +export interface StudioOperationService { + execute( + invocation: StudioOperationInvocation, + kind: StudioOperationKind, + input: Readonly>, + ): Promise>; +} + +export class StudioOperationFailure extends Error { + constructor( + readonly code: HousekeepingErrorCode, + readonly messageKey: string, + ) { + super(messageKey); + this.name = "StudioOperationFailure"; + } +} + +export function studioOperationCapability(kind: StudioOperationKind) { + return anyCapability( + kind === "maintenance" ? PERMS.CATALOG_EDIT : PERMS.ASSETS_IMPORT, + ); +} + +function cancelled(signal: AbortSignal | undefined, error?: unknown): boolean { + return ( + signal?.aborted === true || + (error instanceof Error && error.name === "AbortError") + ); +} + +export function createStudioOperationService(dependencies: { + readonly repository: StudioOperationRepository; + readonly runner: StudioOperationRunner; + readonly resolveCapabilityContext: () => Promise; + readonly createOperationId?: () => string; + readonly now?: () => string; +}): StudioOperationService { + const createOperationId = dependencies.createOperationId ?? randomUUID; + const now = dependencies.now ?? (() => new Date().toISOString()); + + return { + async execute(invocation, kind, input) { + let capability: HousekeepingCapabilityContext; + try { + capability = await dependencies.resolveCapabilityContext(); + } catch { + return fail( + "UNAUTHENTICATED", + "errors.housekeeping.unauthenticated", + invocation.correlationId, + ); + } + if ( + capability.actor.id !== invocation.expectedActorId || + !satisfiesCapability(capability, studioOperationCapability(kind)) + ) { + return fail( + "FORBIDDEN", + "errors.housekeeping.forbidden", + invocation.correlationId, + ); + } + + const operationId = createOperationId(); + const base = { + operationId, + kind, + correlationId: invocation.correlationId, + }; + let completed = 0; + let total: number | null = null; + try { + await dependencies.repository.persistIntent({ + ...base, + actorId: capability.actor.id, + createdAt: now(), + }); + } catch { + return fail( + "DEPENDENCY_UNAVAILABLE", + "errors.housekeeping.dependencyUnavailable", + invocation.correlationId, + ); + } + + const append = async ( + phase: StudioOperationPhase, + messageKey: string, + output?: Readonly>, + ) => + dependencies.repository.appendEvent( + { ...base, phase, completed, total, messageKey }, + output, + ); + const failOperation = async ( + code: HousekeepingErrorCode, + messageKey: string, + ) => { + try { + await append("failed", messageKey); + } catch { + return fail( + "DEPENDENCY_UNAVAILABLE", + "errors.housekeeping.dependencyUnavailable", + invocation.correlationId, + ); + } + return fail(code, messageKey, invocation.correlationId); + }; + + if (invocation.signal?.aborted) { + return failOperation( + "TIMEOUT", + "errors.housekeeping.operationCancelled", + ); + } + + try { + await append("running", "pages.housekeeping.studio.running"); + const result = await dependencies.runner.run(kind, input, { + ...base, + actorId: capability.actor.id, + signal: invocation.signal, + async report(progress) { + if (invocation.signal?.aborted) { + const error = new Error("Studio operation aborted"); + error.name = "AbortError"; + throw error; + } + completed = progress.completed; + total = progress.total; + await append("running", progress.messageKey); + }, + }); + completed = result.completed; + total = result.total; + await append( + result.phase, + `pages.housekeeping.studio.${result.phase}`, + result.output, + ); + const record = await dependencies.repository.get(operationId); + if (!record) throw new Error("Studio operation snapshot unavailable"); + return ok( + record, + invocation.correlationId, + result.phase === "partial" + ? { + status: "partial", + external: "failed", + audit: "persisted", + } + : undefined, + ); + } catch (error) { + if (error instanceof StudioOperationFailure) { + return failOperation(error.code, error.messageKey); + } + if (cancelled(invocation.signal, error)) { + return failOperation( + "TIMEOUT", + "errors.housekeeping.operationCancelled", + ); + } + return failOperation( + "DEPENDENCY_UNAVAILABLE", + "errors.housekeeping.dependencyUnavailable", + ); + } + }, + }; +} + +type StudioCommand = HousekeepingCommand, unknown> & { + readonly operationKind: StudioOperationKind; +}; + +const commandInput = z.object({}).catchall(z.unknown()); + +export function createStudioCommands( + service: Pick, +): readonly StudioCommand[] { + return STUDIO_COMMAND_DEFINITIONS.map( + ({ id, operationKind, permission }): StudioCommand => ({ + id, + owner: "hotel", + operationKind, + risk: "sensitive", + capability: anyCapability(permission), + input: commandInput, + requiresReason: true, + rateLimit: { attempts: 3, windowMs: 60_000 }, + execute: (context, input) => + service.execute( + { + correlationId: context.correlationId, + expectedActorId: context.capability.actor.id, + }, + operationKind, + input, + ), + }), + ); +} + +function recordInput(input: Readonly>) { + const payload = input.payload; + return typeof payload === "object" && + payload !== null && + !Array.isArray(payload) + ? (payload as Readonly>) + : input; +} + +function requiredText(input: Readonly>, key: string) { + const value = input[key]; + if (typeof value !== "string" || !value.normalize("NFC").trim()) { + throw new StudioOperationFailure( + "VALIDATION", + "errors.housekeeping.validation", + ); + } + return value.normalize("NFC").trim(); +} + +function requiredNumber(input: Readonly>, key: string) { + const value = Number(input[key]); + if (!Number.isSafeInteger(value)) { + throw new StudioOperationFailure( + "VALIDATION", + "errors.housekeeping.validation", + ); + } + return value; +} + +function outputRecord(value: unknown): Readonly> { + return typeof value === "object" && value !== null + ? ({ ...(value as Record) } as const) + : { value }; +} + +function normalizedResult(value: unknown): StudioOperationRunnerResult { + const output = outputRecord(value); + if (output.ok === false) { + throw new StudioOperationFailure( + "DEPENDENCY_UNAVAILABLE", + "errors.housekeeping.dependencyUnavailable", + ); + } + const warnings = Array.isArray(output.warnings) ? output.warnings : []; + const failed = Number(output.failed ?? 0); + const succeeded = Number(output.succeeded ?? (output.ok === true ? 1 : 0)); + const skipped = Number(output.skipped ?? 0); + const hasBatchCounts = + Number.isFinite(failed) && + Number.isFinite(succeeded) && + failed + succeeded > 0; + const completed = hasBatchCounts ? succeeded + failed + skipped : 1; + const total = hasBatchCounts ? completed : 1; + return { + phase: failed > 0 || warnings.length > 0 ? "partial" : "completed", + completed, + total, + output, + }; +} + +async function productionRun( + kind: StudioOperationKind, + rawInput: Readonly>, + execution: StudioOperationExecution, +): Promise { + const input = recordInput(rawInput); + execution.signal?.throwIfAborted(); + let progressChain = Promise.resolve(); + const progress = (status: string) => { + progressChain = progressChain.then(() => + execution.report({ + completed: 0, + total: 1, + messageKey: `pages.housekeeping.studio.progress.${status}`, + }), + ); + }; + let result: unknown; + if (kind === "badge") { + const { importBadgeSynced } = await import("@/lib/services/import-badge"); + result = await importBadgeSynced( + { + code: requiredText(input, "code"), + name: requiredText(input, "name"), + description: String(input.description ?? ""), + }, + 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, + }); + } else if (kind === "clothing") { + if (input.setType !== undefined || input.setId !== undefined) { + const { importClothingSet } = await import( + "@/lib/services/clothing-set-import" + ); + result = await importClothingSet({ + setType: requiredText(input, "setType"), + setId: requiredNumber(input, "setId"), + onProgress: progress, + signal: execution.signal, + }); + } else { + const { importSingleFigure } = await import( + "@/lib/services/figure-import" + ); + result = await importSingleFigure({ + lib: requiredText(input, "lib"), + onProgress: progress, + signal: execution.signal, + }); + } + } else if (kind === "effect") { + const { importSingleEffect } = await import("@/lib/services/effect-import"); + result = await importSingleEffect({ + id: requiredText(input, "id"), + lib: requiredText(input, "lib"), + type: requiredText(input, "type"), + revision: requiredNumber(input, "revision"), + onProgress: progress, + signal: execution.signal, + }); + } else if (kind === "furni") { + const { importSingleFurni } = await import("@/lib/services/furni-import"); + result = await importSingleFurni({ + id: requiredNumber(input, "id"), + classname: requiredText(input, "classname"), + name: requiredText(input, "name"), + description: String(input.description ?? ""), + type: requiredText(input, "type"), + revision: requiredNumber(input, "revision"), + category: requiredText(input, "category"), + updateExisting: input.updateExisting === true, + onProgress: progress, + signal: execution.signal, + }); + } else if (kind === "maintenance") { + const { fixEverything } = await import("@/lib/services/furni-maintenance"); + progress("maintenance"); + result = await fixEverything({ + dedupePages: input.dedupePages !== false, + signal: execution.signal, + }); + } else if (kind === "pet") { + const { importSinglePet } = await import("@/lib/services/pet-import"); + result = await importSinglePet({ + lib: requiredText(input, "lib"), + onProgress: progress, + signal: execution.signal, + }); + } else if (kind === "repair-icons") { + const { repairMissingIcons } = await import("@/lib/services/repair-icons"); + result = await repairMissingIcons( + (event) => { + if (event.type === "progress") { + progressChain = progressChain.then(() => + execution.report({ + completed: Math.min((event.index ?? 0) + 1, event.total ?? 1), + total: event.total ?? null, + messageKey: `pages.housekeeping.studio.progress.${event.status ?? "repair"}`, + }), + ); + } + }, + { signal: execution.signal }, + ); + if (input.repairNitros === true) { + const { repairMissingNitros } = await import( + "@/lib/services/repair-nitros" + ); + const nitros = await repairMissingNitros(undefined, { + signal: execution.signal, + }); + result = { ...outputRecord(result), nitros }; + } + } else if (kind === "sync") { + const { syncAssetsToGamedataBundle } = await import( + "@/lib/services/furni-import" + ); + progress("syncing"); + result = await syncAssetsToGamedataBundle(execution.signal); + } else { + const { uploadSingleFurni } = await import("@/lib/services/upload-import"); + result = await uploadSingleFurni({ + classname: requiredText(input, "classname"), + name: requiredText(input, "name"), + description: String(input.description ?? ""), + itemType: input.itemType === "i" ? "i" : "s", + nitroBuffer: Buffer.from(requiredText(input, "nitroBase64"), "base64"), + iconBuffer: + typeof input.iconBase64 === "string" + ? Buffer.from(input.iconBase64, "base64") + : null, + generateSql: input.generateSql === true, + signal: execution.signal, + }); + } + await progressChain; + execution.signal?.throwIfAborted(); + return normalizedResult(result); +} + +export const studioOperationRunner: StudioOperationRunner = { + run: productionRun, +}; + +export const studioOperationService = createStudioOperationService({ + repository: studioOperationRepository, + runner: studioOperationRunner, + async resolveCapabilityContext() { + const { getHousekeepingCapabilityContext } = await import( + "../../../foundation/server-capability-context" + ); + return getHousekeepingCapabilityContext(); + }, +}); + +export const STUDIO_COMMANDS = createStudioCommands(studioOperationService); diff --git a/src/features/housekeeping/domains/hotel/components/operation-progress.test.tsx b/src/features/housekeeping/domains/hotel/components/operation-progress.test.tsx new file mode 100644 index 00000000..6b94462b --- /dev/null +++ b/src/features/housekeeping/domains/hotel/components/operation-progress.test.tsx @@ -0,0 +1,47 @@ +import { renderToStaticMarkup } from "react-dom/server"; +import { describe, expect, it } from "vitest"; +import { OperationProgress } from "./operation-progress"; +import { OperationResult } from "./operation-result"; + +describe("Studio operation components", () => { + it("renders determinate progress with an accessible label", () => { + const markup = renderToStaticMarkup( + , + ); + expect(markup).toContain('role="progressbar"'); + expect(markup).toContain('aria-valuenow="2"'); + expect(markup).toContain("2 / 4"); + }); + + it("distinguishes partial and failed terminal results", () => { + const partial = renderToStaticMarkup( + , + ); + expect(partial).toContain('data-operation-result="partial"'); + expect(partial).toContain("corr-1"); + }); +}); diff --git a/src/features/housekeeping/domains/hotel/components/operation-progress.tsx b/src/features/housekeeping/domains/hotel/components/operation-progress.tsx new file mode 100644 index 00000000..2c40d035 --- /dev/null +++ b/src/features/housekeeping/domains/hotel/components/operation-progress.tsx @@ -0,0 +1,25 @@ +import type { StudioOperationEvent } from "../queries/studio"; + +export function OperationProgress({ + event, +}: { + readonly event: StudioOperationEvent; +}) { + const determinate = event.total !== null && event.total > 0; + return ( +
+ {event.kind}: {event.messageKey} + + {event.completed} + {event.total === null ? " completed" : ` / ${event.total}`} + +
+ ); +} diff --git a/src/features/housekeeping/domains/hotel/components/operation-result.tsx b/src/features/housekeeping/domains/hotel/components/operation-result.tsx new file mode 100644 index 00000000..ea917495 --- /dev/null +++ b/src/features/housekeeping/domains/hotel/components/operation-result.tsx @@ -0,0 +1,20 @@ +import type { StudioOperationRecord } from "../queries/studio"; + +export function OperationResult({ + operation, +}: { + readonly operation: StudioOperationRecord; +}) { + if (operation.phase === "queued" || operation.phase === "running") + return null; + return ( +
+

{operation.phase}

+

{operation.messageKey}

+

Correlation: {operation.correlationId}

+ {operation.output ? ( +
{JSON.stringify(operation.output, null, 2)}
+ ) : null} +
+ ); +} diff --git a/src/features/housekeeping/domains/hotel/hotel-providers.test.ts b/src/features/housekeeping/domains/hotel/hotel-providers.test.ts new file mode 100644 index 00000000..6585f37a --- /dev/null +++ b/src/features/housekeeping/domains/hotel/hotel-providers.test.ts @@ -0,0 +1,74 @@ +import { describe, expect, it, vi } from "vitest"; +import { PERMS } from "@/lib/permission-slugs"; +import type { HousekeepingCapabilityContext } from "../../foundation/contracts"; +import { createHotelInboxSources } from "./inbox"; +import { createHotelSearchProviders } from "./search"; +import { createHotelWidgets } from "./widgets"; + +function context(granted: readonly string[]): HousekeepingCapabilityContext { + const permissions = new Set(granted); + return { + actor: { id: 42, username: "operator", rank: 7 }, + isSuperAdmin: false, + has: (slug) => permissions.has(slug), + hasAny: (...slugs) => slugs.some((slug) => permissions.has(slug)), + hasAll: (...slugs) => slugs.every((slug) => permissions.has(slug)), + }; +} + +describe("Hotel Studio providers", () => { + it("publishes searchable Studio operations with capability filtering", async () => { + const [provider] = createHotelSearchProviders({ + studio: vi.fn(async () => [ + { + id: "op-1", + title: "Furniture import", + description: "running", + href: "/ase/hotel/studio/furni", + }, + ]), + }); + const result = await provider.search(context([PERMS.ASSETS_IMPORT]), { + term: " furni ", + limit: 50, + }); + expect(result).toMatchObject({ + ok: true, + data: [{ id: "op-1", domain: "hotel" }], + }); + }); + + it("exposes an operational inbox source and mandatory active widget", async () => { + const operation = { + operationId: "op-1", + kind: "sync" as const, + phase: "failed" as const, + completed: 3, + total: 5, + messageKey: "errors.housekeeping.dependencyUnavailable", + correlationId: "corr-1", + actorId: 42, + createdAt: "2026-08-30T10:00:00.000Z", + updatedAt: "2026-08-30T10:01:00.000Z", + }; + const [source] = createHotelInboxSources({ + operations: vi.fn(async () => [operation]), + }); + const [widget] = createHotelWidgets({ + activeOperations: vi.fn(async () => ({ running: 2, failed: 1 })), + }); + expect(widget.kind).toBe("mandatory"); + expect( + await source.getItems( + context([PERMS.ASSETS_IMPORT]), + new AbortController().signal, + ), + ).toMatchObject({ ok: true, data: { items: [{ state: "failed" }] } }); + expect( + await widget.load( + context([PERMS.ASSETS_IMPORT]), + new AbortController().signal, + ), + ).toMatchObject({ ok: true, data: { running: 2, failed: 1 } }); + }); +}); diff --git a/src/features/housekeeping/domains/hotel/inbox.ts b/src/features/housekeeping/domains/hotel/inbox.ts new file mode 100644 index 00000000..1536f455 --- /dev/null +++ b/src/features/housekeeping/domains/hotel/inbox.ts @@ -0,0 +1,126 @@ +import { PERMS } from "@/lib/permission-slugs"; +import { authorizeHousekeeping } from "../../foundation/authorization"; +import { satisfiesCapability } from "../../foundation/capability-context"; +import { + anyCapability, + fail, + type HousekeepingCapabilityContext, + type HousekeepingInboxSource, + ok, +} from "../../foundation/contracts"; +import { + type StudioOperationRecord, + studioOperationRepository, +} from "./queries/studio"; + +export const HOTEL_INBOX_SOURCE_IDS = ["hotel.studio.operations"] as const; + +type HotelOperationLoader = ( + context: HousekeepingCapabilityContext, + signal: AbortSignal, +) => Promise; + +export interface HotelInboxAdapters { + readonly operations: HotelOperationLoader; +} + +export function createHotelInboxSources( + adapters: HotelInboxAdapters, +): readonly HousekeepingInboxSource[] { + const capability = anyCapability(PERMS.ASSETS_IMPORT, PERMS.CATALOG_EDIT); + return [ + { + id: "hotel.studio.operations", + owner: "hotel", + capability, + async getItems(context, signal) { + const authorization = authorizeHousekeeping(context, capability); + if (!authorization.ok) return authorization; + try { + if (signal.aborted) throw new Error("aborted Hotel inbox"); + const operations = await adapters.operations(context, signal); + return ok( + { + availability: "available" as const, + items: operations + .filter((operation) => operation.phase !== "completed") + .filter((operation) => + satisfiesCapability( + context, + operation.kind === "maintenance" + ? anyCapability(PERMS.CATALOG_EDIT) + : anyCapability(PERMS.ASSETS_IMPORT), + ), + ) + .slice(0, 25) + .map((operation) => { + const timestamp = Date.parse(operation.updatedAt); + const ageMs = Number.isFinite(timestamp) + ? Math.max(0, Date.now() - timestamp) + : 0; + return { + sourceId: "hotel.studio.operations", + itemId: operation.operationId, + deduplicationKey: `hotel.studio.operations:${operation.operationId}`, + domain: "hotel" as const, + capability: + operation.kind === "maintenance" + ? anyCapability(PERMS.CATALOG_EDIT) + : anyCapability(PERMS.ASSETS_IMPORT), + severity: + operation.phase === "failed" + ? ("critical" as const) + : operation.phase === "partial" + ? ("warning" as const) + : ("info" as const), + priority: + operation.phase === "failed" + ? ("high" as const) + : ("normal" as const), + ageMs, + state: operation.phase, + occurredAt: operation.updatedAt, + titleKey: "pages.housekeeping.items.hotelStudioOperation", + context: { + kind: operation.kind, + completed: operation.completed, + total: operation.total, + }, + href: `/ase/hotel/studio/${ + operation.kind === "badge" + ? "badges" + : operation.kind === "effect" + ? "effects" + : operation.kind === "pet" + ? "pets" + : operation.kind + }` as `/ase/${string}`, + freshness: + ageMs > 86_400_000 + ? ("stale" as const) + : ("fresh" as const), + actions: [], + }; + }), + }, + authorization.correlationId, + ); + } catch { + return fail( + "DEPENDENCY_UNAVAILABLE", + "errors.housekeeping.dependencyUnavailable", + authorization.correlationId, + ); + } + }, + }, + ]; +} + +export const HOTEL_INBOX_SOURCES = createHotelInboxSources({ + async operations(_context, signal) { + if (signal.aborted) throw new Error("aborted Hotel operations"); + return (await studioOperationRepository.list({ pageSize: 25, offset: 0 })) + .items; + }, +}); diff --git a/src/features/housekeeping/domains/hotel/manifest.ts b/src/features/housekeeping/domains/hotel/manifest.ts index bfc3de75..fe7611da 100644 --- a/src/features/housekeeping/domains/hotel/manifest.ts +++ b/src/features/housekeeping/domains/hotel/manifest.ts @@ -3,7 +3,10 @@ import { anyCapability, type HousekeepingDomainManifest, } from "../../foundation/contracts"; -import { HOTEL_PRIMARY_ROUTES } from "./routes"; +import { HOTEL_INBOX_SOURCES } from "./inbox"; +import { HOTEL_ROUTES } from "./routes"; +import { HOTEL_SEARCH_PROVIDERS } from "./search"; +import { HOTEL_WIDGETS } from "./widgets"; export const hotelManifest = { id: "hotel", @@ -21,8 +24,8 @@ export const hotelManifest = { PERMS.PAGES_VIEW, PERMS.CATALOG_EDIT, ), - routes: HOTEL_PRIMARY_ROUTES, - searchProviders: [], - inboxSources: [], - widgets: [], + routes: HOTEL_ROUTES, + searchProviders: HOTEL_SEARCH_PROVIDERS, + inboxSources: HOTEL_INBOX_SOURCES, + widgets: HOTEL_WIDGETS, } satisfies HousekeepingDomainManifest; diff --git a/src/features/housekeeping/domains/hotel/pages/studio.test.tsx b/src/features/housekeeping/domains/hotel/pages/studio.test.tsx new file mode 100644 index 00000000..97c6b325 --- /dev/null +++ b/src/features/housekeeping/domains/hotel/pages/studio.test.tsx @@ -0,0 +1,114 @@ +import { renderToStaticMarkup } from "react-dom/server"; +import { describe, expect, it } from "vitest"; +import { PERMS } from "@/lib/permission-slugs"; +import { + fail, + type HousekeepingCapabilityContext, + ok, +} from "../../../foundation/contracts"; +import { StudioPage } from "./studio"; + +function context(granted: readonly string[]): HousekeepingCapabilityContext { + const permissions = new Set(granted); + return { + actor: { id: 42, username: "operator", rank: 7 }, + isSuperAdmin: false, + has: (slug) => permissions.has(slug), + hasAny: (...slugs) => slugs.some((slug) => permissions.has(slug)), + hasAll: (...slugs) => slugs.every((slug) => permissions.has(slug)), + }; +} + +describe("Hotel Studio page", () => { + it("renders loading, forbidden, partial, empty, and ready operation states", () => { + const render = (result?: unknown) => + renderToStaticMarkup( + , + ); + expect(render()).toContain('data-housekeeping-state="loading"'); + expect( + render(fail("FORBIDDEN", "errors.housekeeping.forbidden", "denied")), + ).toContain('data-housekeeping-state="forbidden"'); + expect( + render( + ok( + { + kind: "studio", + items: [], + total: 0, + partialDependencies: ["external-sources"], + }, + "partial", + ), + ), + ).toContain('data-housekeeping-state="partial"'); + expect( + render( + ok( + { kind: "studio", items: [], total: 0, partialDependencies: [] }, + "empty", + ), + ), + ).toContain('data-housekeeping-state="empty"'); + const ready = render( + ok( + { + kind: "studio", + items: [ + { + operationId: "op-1", + kind: "furni", + phase: "running", + completed: 1, + total: 2, + messageKey: "pages.housekeeping.studio.running", + correlationId: "corr-1", + actorId: 42, + createdAt: "2026-08-30T10:00:00.000Z", + updatedAt: "2026-08-30T10:00:01.000Z", + }, + ], + total: 1, + partialDependencies: [], + }, + "ready", + ), + ); + expect(ready).toContain('data-housekeeping-state="ready"'); + expect(ready).toContain("hotel.studio.furni.run"); + expect(ready).not.toContain("/admin"); + }); + + it("hides mutations without the route's exact capability", () => { + const markup = renderToStaticMarkup( + , + ); + expect(markup).not.toContain("hotel.studio.maintenance.run"); + }); + + it("keeps the audit destination read-only even for fully privileged operators", () => { + const markup = renderToStaticMarkup( + , + ); + expect(markup).not.toContain("hotel.studio.maintenance.run"); + expect(markup).not.toContain("Start maintenance operation"); + }); +}); diff --git a/src/features/housekeeping/domains/hotel/pages/studio.tsx b/src/features/housekeeping/domains/hotel/pages/studio.tsx new file mode 100644 index 00000000..315a2d18 --- /dev/null +++ b/src/features/housekeeping/domains/hotel/pages/studio.tsx @@ -0,0 +1,120 @@ +import { PERMS } from "@/lib/permission-slugs"; +import type { + HousekeepingCapabilityContext, + HousekeepingResult, +} from "../../../foundation/contracts"; +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 HotelStudioRouteId, hotelStudioOperationKind } from "../routes"; +import { HotelCommandForm } from "./hotel-command-form"; + +export interface StudioPageProps { + readonly context: HousekeepingCapabilityContext; + readonly result?: HousekeepingResult; + readonly routeId: HotelStudioRouteId | null; +} + +function canRun( + context: HousekeepingCapabilityContext, + routeId: HotelStudioRouteId, +) { + return context.has( + routeId === "hotel.studio.maintenance" + ? PERMS.CATALOG_EDIT + : PERMS.ASSETS_IMPORT, + ); +} + +export function StudioPage({ context, result, routeId }: StudioPageProps) { + if (!result) { + return ( +
+

Hotel Studio

+

Loading Studio operations…

+
+ ); + } + if (!result.ok) { + return ( +
+

Hotel Studio

+

{result.error.messageKey}

+
+ ); + } + const state = + result.data.partialDependencies.length > 0 + ? "partial" + : result.data.items.length === 0 + ? "empty" + : "ready"; + const operationKind = routeId ? hotelStudioOperationKind(routeId) : null; + return ( +
+
+

Hotel Studio

+

Audited long-running asset imports, repairs and synchronization.

+
+ {result.data.partialDependencies.length > 0 ? ( +

+ Temporarily unavailable: {result.data.partialDependencies.join(", ")}. +

+ ) : null} + {routeId && operationKind && canRun(context, routeId) ? ( + + ) : null} + {result.data.items.length === 0 ? ( +

No Studio operations match this view.

+ ) : ( +
    + {result.data.items.map((operation) => ( +
  • + + +
  • + ))} +
+ )} +
+ ); +} + +function first(value: string | readonly string[] | undefined) { + return Array.isArray(value) ? value[0] : value; +} + +export async function renderHotelStudioPage(input: HousekeepingPageInput) { + const routeId = input.match.routeId as HotelStudioRouteId; + const result = await studioOperationQuery.run(input.context, { + routeId, + list: { + search: first(input.searchParams?.search), + pageSize: Number(first(input.searchParams?.pageSize) ?? 25), + offset: Number(first(input.searchParams?.offset) ?? 0), + }, + }); + return ( + + ); +} diff --git a/src/features/housekeeping/domains/hotel/queries/studio.test.ts b/src/features/housekeeping/domains/hotel/queries/studio.test.ts new file mode 100644 index 00000000..4c34ecd8 --- /dev/null +++ b/src/features/housekeeping/domains/hotel/queries/studio.test.ts @@ -0,0 +1,125 @@ +import { describe, expect, it, vi } from "vitest"; +import { PERMS } from "@/lib/permission-slugs"; +import type { HousekeepingCapabilityContext } from "../../../foundation/contracts"; +import { + createInMemoryStudioOperationRepository, + createStudioOperationQuery, + STUDIO_OPERATION_KINDS, +} from "./studio"; + +function context(granted: readonly string[]): HousekeepingCapabilityContext { + const permissions = new Set(granted); + return { + actor: { id: 42, username: "operator", rank: 7 }, + isSuperAdmin: false, + has: (slug) => permissions.has(slug), + hasAny: (...slugs) => slugs.some((slug) => permissions.has(slug)), + hasAll: (...slugs) => slugs.every((slug) => permissions.has(slug)), + }; +} + +describe("Studio operation query", () => { + it("defines the exact ten operation kinds", () => { + expect(STUDIO_OPERATION_KINDS).toEqual([ + "badge", + "clone", + "clothing", + "effect", + "furni", + "maintenance", + "pet", + "repair-icons", + "sync", + "upload", + ]); + }); + + it("fails closed before invoking an unauthorized adapter", async () => { + const load = vi.fn(); + const result = await createStudioOperationQuery({ load }).run(context([]), { + routeId: "hotel.studio.furni", + }); + expect(result).toMatchObject({ ok: false, error: { code: "FORBIDDEN" } }); + expect(load).not.toHaveBeenCalled(); + }); + + it("normalizes filters and preserves truthful external-source outages", async () => { + const load = vi.fn(async (input) => ({ + kind: "studio" as const, + items: [], + total: 0, + partialDependencies: ["external-sources"] as const, + input, + })); + const result = await createStudioOperationQuery({ load }).run( + context([PERMS.ASSETS_IMPORT]), + { + routeId: "hotel.studio.clone", + list: { search: " sofa ", pageSize: 999, offset: 999_999 }, + }, + ); + expect(load).toHaveBeenCalledWith({ + routeId: "hotel.studio.clone", + operationKind: "clone", + list: { search: "sofa", pageSize: 100, offset: 100_000 }, + }); + expect(result).toMatchObject({ + ok: true, + data: { partialDependencies: ["external-sources"] }, + }); + }); + + it("loads the audit route across all operation kinds without inventing a command kind", async () => { + const load = vi.fn(async () => ({ + kind: "studio" as const, + items: [], + total: 0, + partialDependencies: [] as const, + })); + const result = await createStudioOperationQuery({ load }).run( + context([PERMS.ASSETS_IMPORT]), + { routeId: "hotel.studio.audit" }, + ); + expect(result.ok).toBe(true); + expect(load).toHaveBeenCalledWith( + expect.objectContaining({ operationKind: null }), + ); + }); +}); + +describe("Studio operation repository", () => { + it("enforces monotonic progress and terminal event ordering", async () => { + const repository = createInMemoryStudioOperationRepository(); + await repository.persistIntent({ + operationId: "op-1", + kind: "furni", + actorId: 42, + correlationId: "corr-1", + createdAt: "2026-08-30T10:00:00.000Z", + }); + await repository.appendEvent({ + operationId: "op-1", + kind: "furni", + phase: "running", + completed: 2, + total: 4, + messageKey: "pages.housekeeping.studio.running", + correlationId: "corr-1", + }); + await expect( + repository.appendEvent({ + operationId: "op-1", + kind: "furni", + phase: "running", + completed: 1, + total: 4, + messageKey: "pages.housekeeping.studio.running", + correlationId: "corr-1", + }), + ).rejects.toThrow("non-monotonic Studio progress"); + expect(await repository.get("op-1")).toMatchObject({ + operationId: "op-1", + completed: 2, + }); + }); +}); diff --git a/src/features/housekeeping/domains/hotel/queries/studio.ts b/src/features/housekeeping/domains/hotel/queries/studio.ts new file mode 100644 index 00000000..f5265aa2 --- /dev/null +++ b/src/features/housekeeping/domains/hotel/queries/studio.ts @@ -0,0 +1,348 @@ +import { authorizeHousekeeping } from "../../../foundation/authorization"; +import { + fail, + type HousekeepingQuery, + ok, +} from "../../../foundation/contracts"; +import { + type HotelStudioRouteId, + hotelRouteById, + hotelStudioOperationKind, +} from "../routes"; + +export const STUDIO_OPERATION_KINDS = [ + "badge", + "clone", + "clothing", + "effect", + "furni", + "maintenance", + "pet", + "repair-icons", + "sync", + "upload", +] as const; +export type StudioOperationKind = (typeof STUDIO_OPERATION_KINDS)[number]; +export type StudioOperationPhase = + | "queued" + | "running" + | "completed" + | "failed" + | "partial"; + +export interface StudioOperationEvent { + readonly operationId: string; + readonly kind: StudioOperationKind; + readonly phase: StudioOperationPhase; + readonly completed: number; + readonly total: number | null; + readonly messageKey: string; + readonly correlationId: string; +} + +export interface StudioOperationIntent { + readonly operationId: string; + readonly kind: StudioOperationKind; + readonly actorId: number; + readonly correlationId: string; + readonly createdAt: string; +} + +export interface StudioOperationRecord extends StudioOperationEvent { + readonly actorId: number; + readonly createdAt: string; + readonly updatedAt: string; + readonly events?: readonly StudioOperationEvent[]; + readonly output?: Readonly>; +} + +export interface StudioOperationListInput { + readonly kind?: StudioOperationKind; + readonly search?: string; + readonly pageSize: number; + readonly offset: number; +} + +export interface StudioOperationListResult { + readonly items: readonly StudioOperationRecord[]; + readonly total: number; +} + +export interface StudioOperationRepository { + persistIntent(intent: StudioOperationIntent): Promise; + appendEvent( + event: StudioOperationEvent, + output?: Readonly>, + ): Promise; + get(operationId: string): Promise; + list(input: StudioOperationListInput): Promise; +} + +const TERMINAL_PHASES = new Set([ + "completed", + "failed", + "partial", +]); + +export function createInMemoryStudioOperationRepository( + now: () => string = () => new Date().toISOString(), +): StudioOperationRepository { + const records = new Map(); + + return { + async persistIntent(intent) { + if (records.has(intent.operationId)) { + throw new Error(`duplicate Studio operation: ${intent.operationId}`); + } + const queued: StudioOperationEvent = { + operationId: intent.operationId, + kind: intent.kind, + phase: "queued", + completed: 0, + total: null, + messageKey: "pages.housekeeping.studio.queued", + correlationId: intent.correlationId, + }; + records.set(intent.operationId, { + ...queued, + actorId: intent.actorId, + createdAt: intent.createdAt, + updatedAt: intent.createdAt, + events: [queued], + }); + }, + async appendEvent(event, output) { + const previous = records.get(event.operationId); + if (!previous) + throw new Error(`unknown Studio operation: ${event.operationId}`); + if ( + previous.kind !== event.kind || + previous.correlationId !== event.correlationId + ) { + throw new Error("Studio operation identity mismatch"); + } + if (TERMINAL_PHASES.has(previous.phase)) { + throw new Error("Studio operation already terminal"); + } + if ( + !Number.isSafeInteger(event.completed) || + event.completed < previous.completed + ) { + throw new Error("non-monotonic Studio progress"); + } + if ( + event.total !== null && + (!Number.isSafeInteger(event.total) || + event.total < event.completed || + (previous.total !== null && event.total !== previous.total)) + ) { + throw new Error("invalid Studio operation total"); + } + const events = [...(previous.events ?? []), event]; + records.set(event.operationId, { + ...previous, + ...event, + updatedAt: now(), + events, + ...(output ? { output: Object.freeze({ ...output }) } : {}), + }); + }, + async get(operationId) { + return records.get(operationId) ?? null; + }, + async list(input) { + const term = String(input.search ?? "") + .normalize("NFC") + .trim() + .toLowerCase(); + const matches = [...records.values()] + .filter((record) => !input.kind || record.kind === input.kind) + .filter( + (record) => + !term || + record.operationId.toLowerCase().includes(term) || + record.correlationId.toLowerCase().includes(term) || + record.kind.includes(term), + ) + .sort((left, right) => right.updatedAt.localeCompare(left.updatedAt)); + return { + items: matches.slice(input.offset, input.offset + input.pageSize), + total: matches.length, + }; + }, + }; +} + +const globalStudio = globalThis as unknown as { + housekeepingStudioOperations?: StudioOperationRepository; +}; +const memoryStudioOperations = + globalStudio.housekeepingStudioOperations ?? + createInMemoryStudioOperationRepository(); +if (process.env.NODE_ENV !== "production") { + globalStudio.housekeepingStudioOperations = memoryStudioOperations; +} + +function auditOutcome(phase: StudioOperationPhase) { + if (phase === "completed") return "success" as const; + if (phase === "partial") return "partial" as const; + if (phase === "failed") return "failure" as const; + return "intent" as const; +} + +export const studioOperationRepository: StudioOperationRepository = { + async persistIntent(intent) { + const { logAudit } = await import("@/lib/services/audit"); + await logAudit({ + userId: intent.actorId, + action: "hotel_studio_operation_queued", + target: "StudioOperation", + after: { ...intent, phase: "queued", completed: 0, total: null }, + correlationId: intent.correlationId, + outcome: "intent", + domain: "hotel", + }); + await memoryStudioOperations.persistIntent(intent); + }, + async appendEvent(event, output) { + const actorId = (await memoryStudioOperations.get(event.operationId)) + ?.actorId; + if (!actorId) throw new Error(`unknown Studio actor: ${event.operationId}`); + const { logAudit } = await import("@/lib/services/audit"); + await logAudit({ + userId: actorId, + action: `hotel_studio_operation_${event.phase}`, + target: "StudioOperation", + after: { ...event, ...(output ? { output } : {}) }, + correlationId: event.correlationId, + outcome: auditOutcome(event.phase), + domain: "hotel", + }); + await memoryStudioOperations.appendEvent(event, output); + }, + get: (operationId) => memoryStudioOperations.get(operationId), + list: (input) => memoryStudioOperations.list(input), +}; + +export interface StudioQueryInput { + readonly routeId: HotelStudioRouteId; + readonly list?: Readonly<{ + search?: string; + pageSize?: number; + offset?: number; + }>; +} + +export interface NormalizedStudioQueryInput { + readonly routeId: HotelStudioRouteId; + readonly operationKind: StudioOperationKind | null; + readonly list: Readonly<{ search: string; pageSize: number; offset: number }>; +} + +export interface StudioQueryData { + readonly kind: "studio"; + readonly items: readonly StudioOperationRecord[]; + readonly total: number; + readonly partialDependencies: readonly string[]; +} + +export interface StudioQueryAdapters { + load(input: NormalizedStudioQueryInput): Promise; +} + +function boundedInteger( + value: unknown, + fallback: number, + min: number, + max: number, +) { + const parsed = Number(value); + if (!Number.isSafeInteger(parsed)) return fallback; + return Math.min(max, Math.max(min, parsed)); +} + +function normalizeInput(input: StudioQueryInput): NormalizedStudioQueryInput { + return { + routeId: input.routeId, + operationKind: hotelStudioOperationKind(input.routeId), + list: { + search: String(input.list?.search ?? "") + .normalize("NFC") + .trim() + .slice(0, 128), + pageSize: boundedInteger(input.list?.pageSize, 25, 1, 100), + offset: boundedInteger(input.list?.offset, 0, 0, 100_000), + }, + }; +} + +const STUDIO_DEPENDENCIES = new Set([ + "database", + "external-sources", + "filesystem", + "rcon", +]); + +function validData(data: StudioQueryData): boolean { + return ( + data.kind === "studio" && + Array.isArray(data.items) && + Number.isSafeInteger(data.total) && + data.total >= 0 && + data.partialDependencies.every((item) => STUDIO_DEPENDENCIES.has(item)) + ); +} + +export function createStudioOperationQuery( + adapters: StudioQueryAdapters, +): HousekeepingQuery { + return { + id: "hotel.studio.query", + owner: "hotel", + capability: hotelRouteById("hotel.studio.furni").capability, + async run(context, rawInput) { + const authorization = authorizeHousekeeping( + context, + hotelRouteById(rawInput.routeId).capability, + ); + if (!authorization.ok) return authorization; + try { + const data = await adapters.load(normalizeInput(rawInput)); + if (!validData(data)) throw new Error("invalid Studio query data"); + return ok(data, authorization.correlationId); + } catch { + return fail( + "DEPENDENCY_UNAVAILABLE", + "errors.housekeeping.dependencyUnavailable", + authorization.correlationId, + ); + } + }, + }; +} + +export const studioOperationQuery = createStudioOperationQuery({ + async load(input) { + const result = await studioOperationRepository.list({ + kind: input.operationKind ?? undefined, + ...input.list, + }); + const partialDependencies: string[] = []; + if (input.operationKind === "clone" || input.operationKind === "sync") { + try { + const { listSources } = await import("@/lib/services/clone-sources"); + if ((await listSources()).length === 0) { + partialDependencies.push("external-sources"); + } + } catch { + partialDependencies.push("external-sources"); + } + } + return { + kind: "studio", + items: result.items, + total: result.total, + partialDependencies, + }; + }, +}); diff --git a/src/features/housekeeping/domains/hotel/route-handlers.ts b/src/features/housekeeping/domains/hotel/route-handlers.ts index 5b34b8d5..d9f37d3f 100644 --- a/src/features/housekeeping/domains/hotel/route-handlers.ts +++ b/src/features/housekeeping/domains/hotel/route-handlers.ts @@ -4,11 +4,13 @@ import { renderHotelRadioPage } from "./pages/radio"; import { renderHotelRoomDetailPage } from "./pages/room-detail"; import { renderHotelRoomFurniPage } from "./pages/room-furni"; import { renderHotelRoomsPage } from "./pages/rooms"; -import { HOTEL_PRIMARY_ROUTE_IDS, type HotelPrimaryRouteId } from "./routes"; +import { renderHotelStudioPage } from "./pages/studio"; +import { HOTEL_ROUTE_IDS, type HotelRouteId } from "./routes"; function rendererFor( - routeId: HotelPrimaryRouteId, + routeId: HotelRouteId, ): HousekeepingRouteHandler["render"] { + if (routeId.startsWith("hotel.studio.")) return renderHotelStudioPage; if (routeId === "hotel.rooms") return renderHotelRoomsPage; if (routeId === "hotel.room-detail") return renderHotelRoomDetailPage; if (routeId === "hotel.room-furni") return renderHotelRoomFurniPage; @@ -18,7 +20,7 @@ function rendererFor( export const HOTEL_ROUTE_HANDLERS: readonly HousekeepingRouteHandler[] = Object.freeze( - HOTEL_PRIMARY_ROUTE_IDS.map((routeId) => + HOTEL_ROUTE_IDS.map((routeId) => Object.freeze({ routeId, render: rendererFor(routeId) }), ), ); diff --git a/src/features/housekeeping/domains/hotel/routes.test.ts b/src/features/housekeeping/domains/hotel/routes.test.ts index 6fac4be4..6cd9d66d 100644 --- a/src/features/housekeeping/domains/hotel/routes.test.ts +++ b/src/features/housekeeping/domains/hotel/routes.test.ts @@ -8,7 +8,12 @@ import { HOTEL_ASSET_CANONICAL_ROUTES, HOTEL_PRIMARY_ROUTE_IDS, HOTEL_PRIMARY_ROUTES, + HOTEL_ROUTE_IDS, + HOTEL_ROUTES, + HOTEL_STUDIO_ROUTE_IDS, + HOTEL_STUDIO_ROUTES, hotelRouteGroup, + hotelStudioOperationKind, } from "./routes"; const expected = [ @@ -74,11 +79,47 @@ describe("Hotel primary routes", () => { }); it("keeps manifest routes and handlers in exact equality", () => { - expect(hotelManifest.routes).toBe(HOTEL_PRIMARY_ROUTES); + expect(hotelManifest.routes).toBe(HOTEL_ROUTES); expect( HOUSEKEEPING_ROUTE_HANDLERS.filter((handler) => handler.routeId.startsWith("hotel."), ).map((handler) => handler.routeId), - ).toEqual(HOTEL_PRIMARY_ROUTE_IDS); + ).toEqual(HOTEL_ROUTE_IDS); + }); + + it("registers every canonical Studio destination exactly once", () => { + const matrixPaths = [ + ...new Set( + hotelMigrationEntries + .map((entry) => entry.targetPath) + .filter( + (path): path is string => + path?.startsWith("/ase/hotel/studio/") === true, + ), + ), + ].sort(); + expect(HOTEL_STUDIO_ROUTES.map((route) => route.href).sort()).toEqual( + matrixPaths, + ); + expect(new Set(HOTEL_STUDIO_ROUTE_IDS).size).toBe( + HOTEL_STUDIO_ROUTE_IDS.length, + ); + expect( + HOTEL_STUDIO_ROUTE_IDS.map((routeId) => + hotelStudioOperationKind(routeId), + ), + ).toEqual([ + null, + "badge", + "clone", + "clothing", + "effect", + "furni", + "maintenance", + "pet", + "repair-icons", + "sync", + "upload", + ]); }); }); diff --git a/src/features/housekeeping/domains/hotel/routes.ts b/src/features/housekeeping/domains/hotel/routes.ts index 9f4d26cc..e21117d6 100644 --- a/src/features/housekeeping/domains/hotel/routes.ts +++ b/src/features/housekeeping/domains/hotel/routes.ts @@ -28,17 +28,41 @@ export const HOTEL_PRIMARY_ROUTE_IDS = [ ] as const; export type HotelPrimaryRouteId = (typeof HOTEL_PRIMARY_ROUTE_IDS)[number]; +export const HOTEL_STUDIO_ROUTE_IDS = [ + "hotel.studio.audit", + "hotel.studio.badges", + "hotel.studio.clone", + "hotel.studio.clothing", + "hotel.studio.effects", + "hotel.studio.furni", + "hotel.studio.maintenance", + "hotel.studio.pets", + "hotel.studio.repair-icons", + "hotel.studio.sync", + "hotel.studio.upload", +] as const; +export type HotelStudioRouteId = (typeof HOTEL_STUDIO_ROUTE_IDS)[number]; +export type HotelRouteId = HotelPrimaryRouteId | HotelStudioRouteId; + +export const HOTEL_STUDIO_OPERATION_BY_ROUTE = Object.freeze({ + "hotel.studio.badges": "badge", + "hotel.studio.clone": "clone", + "hotel.studio.clothing": "clothing", + "hotel.studio.effects": "effect", + "hotel.studio.furni": "furni", + "hotel.studio.maintenance": "maintenance", + "hotel.studio.pets": "pet", + "hotel.studio.repair-icons": "repair-icons", + "hotel.studio.sync": "sync", + "hotel.studio.upload": "upload", +} as const); + function route( id: Id, href: CanonicalHousekeepingHref, permissions: readonly string[], ): HousekeepingRouteDefinition & { readonly id: Id } { - return { - id, - href, - labelKey: `pages.housekeeping.routes.${id}`, - capability: anyCapability(...permissions), - }; + return routeDefinition(id, href, permissions); } export const HOTEL_PRIMARY_ROUTES = [ @@ -69,18 +93,70 @@ export const HOTEL_PRIMARY_ROUTES = [ ).map(([id, href]) => route(id, href, [PERMS.RADIO_VIEW])), ] as const satisfies readonly HousekeepingRouteDefinition[]; +function studioRoute( + id: Id, + href: CanonicalHousekeepingHref, +): HousekeepingRouteDefinition & { readonly id: Id } { + return routeDefinition( + id, + href, + id === "hotel.studio.maintenance" + ? [PERMS.CATALOG_EDIT] + : [PERMS.ASSETS_IMPORT], + ); +} + +function routeDefinition( + id: Id, + href: CanonicalHousekeepingHref, + permissions: readonly string[], +): HousekeepingRouteDefinition & { readonly id: Id } { + return { + id, + href, + labelKey: `pages.housekeeping.routes.${id}`, + capability: anyCapability(...permissions), + }; +} + +export const HOTEL_STUDIO_ROUTES = [ + studioRoute("hotel.studio.audit", "/ase/hotel/studio/audit"), + studioRoute("hotel.studio.badges", "/ase/hotel/studio/badges"), + studioRoute("hotel.studio.clone", "/ase/hotel/studio/clone"), + studioRoute("hotel.studio.clothing", "/ase/hotel/studio/clothing"), + studioRoute("hotel.studio.effects", "/ase/hotel/studio/effects"), + studioRoute("hotel.studio.furni", "/ase/hotel/studio/furni"), + studioRoute("hotel.studio.maintenance", "/ase/hotel/studio/maintenance"), + studioRoute("hotel.studio.pets", "/ase/hotel/studio/pets"), + studioRoute("hotel.studio.repair-icons", "/ase/hotel/studio/repair-icons"), + studioRoute("hotel.studio.sync", "/ase/hotel/studio/sync"), + studioRoute("hotel.studio.upload", "/ase/hotel/studio/upload"), +] as const satisfies readonly HousekeepingRouteDefinition[]; + +export const HOTEL_ROUTE_IDS = [ + ...HOTEL_PRIMARY_ROUTE_IDS, + ...HOTEL_STUDIO_ROUTE_IDS, +] as const; +export const HOTEL_ROUTES = [ + ...HOTEL_PRIMARY_ROUTES, + ...HOTEL_STUDIO_ROUTES, +] as const satisfies readonly HousekeepingRouteDefinition[]; + const ROUTE_BY_ID = new Map( - HOTEL_PRIMARY_ROUTES.map((definition) => [definition.id, definition]), + HOTEL_ROUTES.map((definition) => [definition.id, definition]), ); -export function hotelRouteById( - id: HotelPrimaryRouteId, -): HousekeepingRouteDefinition { +export function hotelRouteById(id: HotelRouteId): HousekeepingRouteDefinition { const definition = ROUTE_BY_ID.get(id); if (!definition) throw new Error(`unknown Hotel route: ${id}`); return definition; } +export function hotelStudioOperationKind(id: HotelStudioRouteId) { + if (id === "hotel.studio.audit") return null; + return HOTEL_STUDIO_OPERATION_BY_ROUTE[id]; +} + export function hotelRouteGroup(id: HotelPrimaryRouteId): HotelRouteGroup { if (id === "hotel.navigation") return "navigation"; return id.startsWith("hotel.radio.") ? "radio" : "rooms"; diff --git a/src/features/housekeeping/domains/hotel/search.ts b/src/features/housekeeping/domains/hotel/search.ts new file mode 100644 index 00000000..a5553cd7 --- /dev/null +++ b/src/features/housekeeping/domains/hotel/search.ts @@ -0,0 +1,111 @@ +import { PERMS } from "@/lib/permission-slugs"; +import { authorizeHousekeeping } from "../../foundation/authorization"; +import { satisfiesCapability } from "../../foundation/capability-context"; +import { + anyCapability, + type CapabilityRequirement, + fail, + type HousekeepingCapabilityContext, + type HousekeepingSearchProvider, + ok, +} from "../../foundation/contracts"; +import { isSafeHousekeepingHref } from "../../foundation/housekeeping-href"; +import { studioOperationRepository } from "./queries/studio"; + +export const HOTEL_SEARCH_PROVIDER_IDS = ["hotel.studio"] as const; + +export interface HotelSearchCandidate { + readonly id: string; + readonly title: string; + readonly description?: string; + readonly href: string; + readonly capability?: CapabilityRequirement; +} + +type HotelSearchLoader = ( + context: HousekeepingCapabilityContext, + term: string, + limit: number, +) => Promise; + +export interface HotelSearchAdapters { + readonly studio: HotelSearchLoader; +} + +export function createHotelSearchProviders( + adapters: HotelSearchAdapters, +): readonly HousekeepingSearchProvider[] { + const capability = anyCapability(PERMS.ASSETS_IMPORT, PERMS.CATALOG_EDIT); + return [ + { + id: "hotel.studio", + owner: "hotel", + capability, + async search(context, input) { + const authorization = authorizeHousekeeping(context, capability); + if (!authorization.ok) return authorization; + const term = input.term.normalize("NFC").trim().slice(0, 128); + const limit = Number.isFinite(input.limit) + ? Math.min(25, Math.max(1, Math.trunc(input.limit))) + : 25; + try { + const candidates = await adapters.studio(context, term, limit); + return ok( + candidates + .filter( + (candidate) => + isSafeHousekeepingHref(candidate.href) && + satisfiesCapability( + context, + candidate.capability ?? capability, + ), + ) + .slice(0, limit) + .map((candidate) => ({ + ...candidate, + domain: "hotel" as const, + type: "entity" as const, + href: candidate.href as `/ase/${string}`, + capability: candidate.capability ?? capability, + })), + authorization.correlationId, + ); + } catch { + return fail( + "DEPENDENCY_UNAVAILABLE", + "errors.housekeeping.dependencyUnavailable", + authorization.correlationId, + ); + } + }, + }, + ]; +} + +export const HOTEL_SEARCH_PROVIDERS = createHotelSearchProviders({ + async studio(_context, term, limit) { + const result = await studioOperationRepository.list({ + search: term, + pageSize: limit, + offset: 0, + }); + return result.items.map((operation) => ({ + id: operation.operationId, + title: `${operation.kind} ${operation.operationId}`, + description: operation.phase, + capability: + operation.kind === "maintenance" + ? anyCapability(PERMS.CATALOG_EDIT) + : anyCapability(PERMS.ASSETS_IMPORT), + href: `/ase/hotel/studio/${ + operation.kind === "badge" + ? "badges" + : operation.kind === "effect" + ? "effects" + : operation.kind === "pet" + ? "pets" + : operation.kind + }`, + })); + }, +}); diff --git a/src/features/housekeeping/domains/hotel/widgets.ts b/src/features/housekeeping/domains/hotel/widgets.ts new file mode 100644 index 00000000..e76ba718 --- /dev/null +++ b/src/features/housekeeping/domains/hotel/widgets.ts @@ -0,0 +1,68 @@ +import { PERMS } from "@/lib/permission-slugs"; +import { authorizeHousekeeping } from "../../foundation/authorization"; +import { + anyCapability, + fail, + type HousekeepingCapabilityContext, + type HousekeepingWidgetDefinition, + ok, +} from "../../foundation/contracts"; +import { studioOperationRepository } from "./queries/studio"; + +export const HOTEL_WIDGET_IDS = ["hotel.studio.active-operations"] as const; + +type HotelWidgetLoader = ( + context: HousekeepingCapabilityContext, + signal: AbortSignal, +) => Promise>>; + +export interface HotelWidgetAdapters { + readonly activeOperations: HotelWidgetLoader; +} + +export function createHotelWidgets( + adapters: HotelWidgetAdapters, +): readonly HousekeepingWidgetDefinition[] { + const capability = anyCapability(PERMS.ASSETS_IMPORT, PERMS.CATALOG_EDIT); + return [ + { + id: "hotel.studio.active-operations", + owner: "hotel", + kind: "mandatory", + capability, + async load(context, signal) { + const authorization = authorizeHousekeeping(context, capability); + if (!authorization.ok) return authorization; + try { + if (signal.aborted) throw new Error("aborted Hotel widget"); + return ok( + await adapters.activeOperations(context, signal), + authorization.correlationId, + ); + } catch { + return fail( + "DEPENDENCY_UNAVAILABLE", + "errors.housekeeping.dependencyUnavailable", + authorization.correlationId, + ); + } + }, + }, + ]; +} + +export const HOTEL_WIDGETS = createHotelWidgets({ + async activeOperations(_context, signal) { + if (signal.aborted) throw new Error("aborted Hotel operations widget"); + const { items } = await studioOperationRepository.list({ + pageSize: 100, + offset: 0, + }); + return { + queued: items.filter((item) => item.phase === "queued").length, + running: items.filter((item) => item.phase === "running").length, + partial: items.filter((item) => item.phase === "partial").length, + failed: items.filter((item) => item.phase === "failed").length, + }; + }, +}); diff --git a/src/features/housekeeping/foundation/commands/bootstrap.ts b/src/features/housekeeping/foundation/commands/bootstrap.ts index 3b81ebd5..2aa2af07 100644 --- a/src/features/housekeeping/foundation/commands/bootstrap.ts +++ b/src/features/housekeeping/foundation/commands/bootstrap.ts @@ -3,6 +3,7 @@ import "server-only"; import { CONTENT_COMMANDS } from "../../domains/content/commands/content-commands"; import { ECONOMY_COMMANDS } from "../../domains/economy/commands/economy-commands"; import { HOTEL_COMMANDS } from "../../domains/hotel/commands/hotel-commands"; +import { STUDIO_COMMANDS } from "../../domains/hotel/commands/studio-commands"; import { COMMUNITY_COMMANDS } from "../../domains/people/commands/community-commands"; import { MODERATION_COMMANDS } from "../../domains/people/commands/moderation-commands"; import { SUPPORT_COMMANDS } from "../../domains/people/commands/support-commands"; @@ -46,6 +47,7 @@ const currentHousekeepingCommands = defineHousekeepingCommands( ...CONTENT_COMMANDS, ...ECONOMY_COMMANDS, ...HOTEL_COMMANDS, + ...STUDIO_COMMANDS, ...USER_COMMANDS, ...COMMUNITY_COMMANDS, ...SUPPORT_COMMANDS, diff --git a/src/features/housekeeping/foundation/foundation-source-contract.test.ts b/src/features/housekeeping/foundation/foundation-source-contract.test.ts index da34a31d..eb0366b1 100644 --- a/src/features/housekeeping/foundation/foundation-source-contract.test.ts +++ b/src/features/housekeeping/foundation/foundation-source-contract.test.ts @@ -6,7 +6,7 @@ import { renderToStaticMarkup } from "react-dom/server"; import { describe, expect, it } from "vitest"; import { CONTENT_ROUTE_IDS } from "../domains/content/routes"; import { ECONOMY_ROUTE_IDS } from "../domains/economy/routes"; -import { HOTEL_PRIMARY_ROUTE_IDS } from "../domains/hotel/routes"; +import { HOTEL_ROUTE_IDS } from "../domains/hotel/routes"; import { PEOPLE_ROUTE_IDS } from "../domains/people/routes"; import { SYSTEM_ROUTE_IDS } from "../domains/system/routes"; import { HOUSEKEEPING_MANIFESTS } from "../manifests"; @@ -23,6 +23,10 @@ const SERVER_CAPABILITY_CONTEXT = const PERMISSIONS_ADAPTER = "src/lib/permissions"; const DOMAIN_MODULE_ROOT = "src/features/housekeeping/domains"; const approvedRuntimeImports = new Map>([ + [ + "src/features/housekeeping/domains/hotel/commands/studio-commands.ts", + new Set(["src/features/housekeeping/domains/hotel/queries/studio"]), + ], [ "src/features/housekeeping/domains/hotel/routes.ts", new Set(["src/features/housekeeping/domains/hotel/queries/assets"]), @@ -713,7 +717,32 @@ const approvedRuntimeImports = new Map>([ ], [ "src/features/housekeeping/domains/hotel/manifest.ts", - new Set(["src/features/housekeeping/domains/hotel/routes"]), + new Set([ + "src/features/housekeeping/domains/hotel/inbox", + "src/features/housekeeping/domains/hotel/routes", + "src/features/housekeeping/domains/hotel/search", + "src/features/housekeeping/domains/hotel/widgets", + ]), + ], + [ + "src/features/housekeeping/domains/hotel/components/operation-progress.tsx", + new Set(["src/features/housekeeping/domains/hotel/queries/studio"]), + ], + [ + "src/features/housekeeping/domains/hotel/components/operation-result.tsx", + new Set(["src/features/housekeeping/domains/hotel/queries/studio"]), + ], + [ + "src/features/housekeeping/domains/hotel/inbox.ts", + new Set(["src/features/housekeeping/domains/hotel/queries/studio"]), + ], + [ + "src/features/housekeeping/domains/hotel/search.ts", + new Set(["src/features/housekeeping/domains/hotel/queries/studio"]), + ], + [ + "src/features/housekeeping/domains/hotel/widgets.ts", + new Set(["src/features/housekeeping/domains/hotel/queries/studio"]), ], [ "src/features/housekeeping/domains/hotel/pages/hotel-command-form.tsx", @@ -765,6 +794,16 @@ const approvedRuntimeImports = new Map>([ "src/features/housekeeping/domains/hotel/queries/rooms", ]), ], + [ + "src/features/housekeeping/domains/hotel/pages/studio.tsx", + new Set([ + "src/features/housekeeping/domains/hotel/components/operation-progress", + "src/features/housekeeping/domains/hotel/components/operation-result", + "src/features/housekeeping/domains/hotel/pages/hotel-command-form", + "src/features/housekeeping/domains/hotel/queries/studio", + "src/features/housekeeping/domains/hotel/routes", + ]), + ], [ "src/features/housekeeping/domains/hotel/queries/hotel-production.ts", new Set([ @@ -790,6 +829,10 @@ const approvedRuntimeImports = new Map>([ "src/features/housekeeping/domains/hotel/routes", ]), ], + [ + "src/features/housekeeping/domains/hotel/queries/studio.ts", + new Set(["src/features/housekeeping/domains/hotel/routes"]), + ], [ "src/features/housekeeping/domains/hotel/route-handlers.ts", new Set([ @@ -798,6 +841,7 @@ const approvedRuntimeImports = new Map>([ "src/features/housekeeping/domains/hotel/pages/room-detail", "src/features/housekeeping/domains/hotel/pages/room-furni", "src/features/housekeeping/domains/hotel/pages/rooms", + "src/features/housekeeping/domains/hotel/pages/studio", "src/features/housekeeping/domains/hotel/routes", ]), ], @@ -831,6 +875,7 @@ const approvedRuntimeImports = new Map>([ "src/features/housekeeping/domains/content/commands/content-commands", "src/features/housekeeping/domains/economy/commands/economy-commands", "src/features/housekeeping/domains/hotel/commands/hotel-commands", + "src/features/housekeeping/domains/hotel/commands/studio-commands", "src/features/housekeeping/domains/people/commands/community-commands", "src/features/housekeeping/domains/people/commands/moderation-commands", "src/features/housekeeping/domains/people/commands/support-commands", @@ -1484,7 +1529,7 @@ describe("housekeeping foundation completion contracts", () => { registry.domains .find((domain) => domain.id === "hotel") ?.routes.map((route) => route.id), - ).toEqual(HOTEL_PRIMARY_ROUTE_IDS); + ).toEqual(HOTEL_ROUTE_IDS); expect( registry.domains .find((domain) => domain.id === "system") diff --git a/src/features/housekeeping/foundation/preview-route-contract.test.ts b/src/features/housekeeping/foundation/preview-route-contract.test.ts index 7abed977..3632f24d 100644 --- a/src/features/housekeeping/foundation/preview-route-contract.test.ts +++ b/src/features/housekeeping/foundation/preview-route-contract.test.ts @@ -129,6 +129,17 @@ const routeMocks = vi.hoisted(() => { "routes.hotel.radio.api-keys": "HK::hotel-radio-api-keys", "routes.hotel.radio.banners": "HK::hotel-radio-banners", "routes.hotel.radio.embed": "HK::hotel-radio-embed", + "routes.hotel.studio.audit": "HK::hotel-studio-audit", + "routes.hotel.studio.badges": "HK::hotel-studio-badges", + "routes.hotel.studio.clone": "HK::hotel-studio-clone", + "routes.hotel.studio.clothing": "HK::hotel-studio-clothing", + "routes.hotel.studio.effects": "HK::hotel-studio-effects", + "routes.hotel.studio.furni": "HK::hotel-studio-furni", + "routes.hotel.studio.maintenance": "HK::hotel-studio-maintenance", + "routes.hotel.studio.pets": "HK::hotel-studio-pets", + "routes.hotel.studio.repair-icons": "HK::hotel-studio-repair-icons", + "routes.hotel.studio.sync": "HK::hotel-studio-sync", + "routes.hotel.studio.upload": "HK::hotel-studio-upload", "states.empty.title": "Localized empty title", "states.empty.description": "Localized empty description", }; diff --git a/src/features/housekeeping/foundation/registry.test.ts b/src/features/housekeeping/foundation/registry.test.ts index 6c1e9897..ecb21888 100644 --- a/src/features/housekeeping/foundation/registry.test.ts +++ b/src/features/housekeeping/foundation/registry.test.ts @@ -8,7 +8,10 @@ import { ECONOMY_INBOX_SOURCES } from "../domains/economy/inbox"; import { ECONOMY_ROUTES } from "../domains/economy/routes"; import { ECONOMY_SEARCH_PROVIDERS } from "../domains/economy/search"; import { ECONOMY_WIDGETS } from "../domains/economy/widgets"; -import { HOTEL_PRIMARY_ROUTES } from "../domains/hotel/routes"; +import { HOTEL_INBOX_SOURCES } from "../domains/hotel/inbox"; +import { HOTEL_ROUTES } from "../domains/hotel/routes"; +import { HOTEL_SEARCH_PROVIDERS } from "../domains/hotel/search"; +import { HOTEL_WIDGETS } from "../domains/hotel/widgets"; import { PEOPLE_INBOX_SOURCES } from "../domains/people/inbox"; import { PEOPLE_ROUTES } from "../domains/people/routes"; import { PEOPLE_SEARCH_PROVIDERS } from "../domains/people/search"; @@ -375,7 +378,7 @@ describe("housekeeping registry", () => { : expected.id === "economy" ? ECONOMY_ROUTES : expected.id === "hotel" - ? HOTEL_PRIMARY_ROUTES + ? HOTEL_ROUTES : expected.id === "system" ? SYSTEM_ROUTES : [], @@ -387,7 +390,9 @@ describe("housekeeping registry", () => { ? CONTENT_SEARCH_PROVIDERS : expected.id === "economy" ? ECONOMY_SEARCH_PROVIDERS - : [], + : expected.id === "hotel" + ? HOTEL_SEARCH_PROVIDERS + : [], ); expect(actual.inboxSources).toEqual( expected.id === "people" @@ -396,7 +401,9 @@ describe("housekeeping registry", () => { ? CONTENT_INBOX_SOURCES : expected.id === "economy" ? ECONOMY_INBOX_SOURCES - : [], + : expected.id === "hotel" + ? HOTEL_INBOX_SOURCES + : [], ); expect(actual.widgets).toEqual( expected.id === "people" @@ -405,7 +412,9 @@ describe("housekeeping registry", () => { ? CONTENT_WIDGETS : expected.id === "economy" ? ECONOMY_WIDGETS - : [], + : expected.id === "hotel" + ? HOTEL_WIDGETS + : [], ); expect(actual.capability).toEqual({ mode: "any", slugs: expected.slugs }); } diff --git a/src/features/housekeeping/route-handlers.test.ts b/src/features/housekeeping/route-handlers.test.ts index d499e7d2..0fedcbde 100644 --- a/src/features/housekeeping/route-handlers.test.ts +++ b/src/features/housekeeping/route-handlers.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from "vitest"; import { CONTENT_ROUTE_IDS } from "./domains/content/routes"; import { ECONOMY_ROUTE_IDS } from "./domains/economy/routes"; -import { HOTEL_PRIMARY_ROUTE_IDS } from "./domains/hotel/routes"; +import { HOTEL_ROUTE_IDS } from "./domains/hotel/routes"; import { PEOPLE_ROUTE_IDS } from "./domains/people/routes"; import { SYSTEM_ROUTE_IDS } from "./domains/system/routes"; import { createHousekeepingRegistry } from "./foundation/registry"; @@ -24,7 +24,7 @@ describe("housekeeping route handlers", () => { ...PEOPLE_ROUTE_IDS, ...CONTENT_ROUTE_IDS, ...ECONOMY_ROUTE_IDS, - ...HOTEL_PRIMARY_ROUTE_IDS, + ...HOTEL_ROUTE_IDS, ...SYSTEM_ROUTE_IDS, ]); }); diff --git a/src/lib/services/clone-import.ts b/src/lib/services/clone-import.ts index 37d7627a..80a5e957 100644 --- a/src/lib/services/clone-import.ts +++ b/src/lib/services/clone-import.ts @@ -134,13 +134,16 @@ async function fetchWithFallback(url: string): Promise { export async function fetchSourceFurnidata( url: string, now = Date.now(), + signal?: AbortSignal, ): Promise { + signal?.throwIfAborted(); const hit = cache.get(url); if (hit && now && now - hit.ts < TTL) return hit.list; let body: string; try { + const timeout = AbortSignal.timeout(30000); const res = await fetch(url, { - signal: AbortSignal.timeout(30000), + signal: signal ? AbortSignal.any([signal, timeout]) : timeout, headers: browserHeaders(), }); if (res.ok) { @@ -159,7 +162,8 @@ export async function fetchSourceFurnidata( } else { body = await fetchWithFallback(url); } - } catch { + } catch (error) { + if (signal?.aborted) throw error; body = await fetchWithFallback(url); } if (body.trimStart().startsWith("<")) { @@ -243,11 +247,13 @@ export async function cloneSingleFurni(params: { source: CloneSource; entry: SourceFurni; onProgress?: (status: string) => void; + signal?: AbortSignal; /** Skip the per-item FurnitureData.json write and return the entry instead, * so batch callers can append everything in one lock + write cycle. */ deferFurniData?: boolean; }): Promise { - const { source, entry, onProgress, deferFurniData } = params; + const { source, entry, onProgress, signal, deferFurniData } = params; + signal?.throwIfAborted(); const { classname, itemType } = entry; const warnings: string[] = []; @@ -287,6 +293,7 @@ export async function cloneSingleFurni(params: { // Download .nitro + icon directly from the source hotel. onProgress?.("downloading"); + signal?.throwIfAborted(); const nitroPath = getRuntimePath( /*turbopackIgnore: true*/ nitroDir, `${classname}.nitro`, @@ -300,6 +307,7 @@ export async function cloneSingleFurni(params: { nitroPath, { maxRetries: 2, + signal, }, ); if (!dl.ok) { @@ -334,6 +342,7 @@ export async function cloneSingleFurni(params: { { maxRetries: 1, validate: "png", + signal, }, ); if (!iconDl.ok) { @@ -366,6 +375,7 @@ export async function cloneSingleFurni(params: { } onProgress?.("writing_db"); + signal?.throwIfAborted(); const stackHeight = entry.canlayon || entry.cansiton ? 1.0 : entry.canstandon ? 1.0 : 0.0; // allow_stack: derived from stackHeight (mirrors furni-import.ts `dims.z > 0`). @@ -422,6 +432,7 @@ export async function cloneSingleFurni(params: { // AND our offerid. The source's offerid points at the source hotel's sprite // ids, which would silently break the emulator's catalog offer lookup. onProgress?.("writing_furnidata"); + signal?.throwIfAborted(); const furniDataEntry = { ...entry, id: newId, diff --git a/src/lib/services/clothing-set-import.ts b/src/lib/services/clothing-set-import.ts index b87c5b42..781ebae6 100644 --- a/src/lib/services/clothing-set-import.ts +++ b/src/lib/services/clothing-set-import.ts @@ -89,8 +89,10 @@ export async function importClothingSet(params: { setType: string; setId: number; onProgress?: (status: string) => void; + signal?: AbortSignal; }): Promise { - const { setType, setId, onProgress } = params; + const { setType, setId, onProgress, signal } = params; + signal?.throwIfAborted(); const warnings: string[] = []; const [officialFd, officialLibs] = await Promise.all([ @@ -136,12 +138,17 @@ export async function importClothingSet(params: { let hardFail = false; for (const libId of requiredLibIds) { + signal?.throwIfAborted(); const officialLib = officialLibs.find((l) => l.id === libId); if (!officialLib) continue; if (!localLibIds.has(libId)) { onProgress?.(`importing library ${libId}`); - const result = await importSingleFigure({ lib: libId, onProgress }); + const result = await importSingleFigure({ + lib: libId, + onProgress, + signal, + }); if (!result.ok) { hardFail = true; warnings.push( @@ -169,6 +176,7 @@ export async function importClothingSet(params: { } const mappedSet = mapOfficialSet(set); + signal?.throwIfAborted(); await mergeSet(setType, st.paletteId, mappedSet as never); if (palette) { diff --git a/src/lib/services/effect-import.ts b/src/lib/services/effect-import.ts index 183cfa9b..71cd68a3 100644 --- a/src/lib/services/effect-import.ts +++ b/src/lib/services/effect-import.ts @@ -76,8 +76,10 @@ export async function importSingleEffect(params: { type: string; revision: number; onProgress?: (status: string) => void; + signal?: AbortSignal; }): Promise { - const { id, lib, type, revision, onProgress } = params; + const { id, lib, type, revision, onProgress, signal } = params; + signal?.throwIfAborted(); const warnings: string[] = []; await ensureEffectDirs(); @@ -100,7 +102,10 @@ export async function importSingleEffect(params: { error: `Could not resolve SWF URL: ${(err as Error).message}`, }; } - const dl = await downloadFile(swfUrl, swfPath, { validate: "swf" }); + const dl = await downloadFile(swfUrl, swfPath, { + validate: "swf", + signal, + }); if (!dl.ok) return { ok: false, @@ -111,6 +116,7 @@ export async function importSingleEffect(params: { // Convert SWF → Nitro onProgress?.("converting"); + signal?.throwIfAborted(); try { const swfBuffer = await fs.readFile(/*turbopackIgnore: true*/ swfPath); const result = convertSwfToNitro(swfBuffer, lib); @@ -127,6 +133,7 @@ export async function importSingleEffect(params: { // Merge EffectMap.json (last step — no orphan entry if anything above failed) onProgress?.("writing_map"); + signal?.throwIfAborted(); try { const entry: EffectMapEntry = { id, lib, type, revision }; await mergeEffect(entry); diff --git a/src/lib/services/figure-import.ts b/src/lib/services/figure-import.ts index cc1d9fb7..2c1df0dc 100644 --- a/src/lib/services/figure-import.ts +++ b/src/lib/services/figure-import.ts @@ -78,8 +78,10 @@ async function nitroPathFor(lib: string): Promise { export async function importSingleFigure(params: { lib: string; onProgress?: (status: string) => void; + signal?: AbortSignal; }): Promise { - const { lib, onProgress } = params; + const { lib, onProgress, signal } = params; + signal?.throwIfAborted(); const warnings: string[] = []; await ensureFigureDirs(); @@ -99,7 +101,10 @@ export async function importSingleFigure(params: { error: `Could not resolve SWF URL: ${(err as Error).message}`, }; } - const dl = await downloadFile(swfUrl, swfPath, { validate: "swf" }); + const dl = await downloadFile(swfUrl, swfPath, { + validate: "swf", + signal, + }); if (!dl.ok) return { ok: false, @@ -109,6 +114,7 @@ export async function importSingleFigure(params: { }; onProgress?.("converting"); + signal?.throwIfAborted(); try { const swfBuffer = await fs.readFile(/*turbopackIgnore: true*/ swfPath); const result = convertSwfToNitro(swfBuffer, lib); diff --git a/src/lib/services/furni-import.ts b/src/lib/services/furni-import.ts index 055c26a3..c9c246b5 100644 --- a/src/lib/services/furni-import.ts +++ b/src/lib/services/furni-import.ts @@ -403,10 +403,13 @@ async function syncDirMissingFiles( * so all imported furniture stays renderable in-game even when a mirror * write was skipped. */ -export async function syncAssetsToGamedataBundle(): Promise<{ +export async function syncAssetsToGamedataBundle( + signal?: AbortSignal, +): Promise<{ copiedNitros: string[]; copiedIcons: string[]; }> { + signal?.throwIfAborted(); if (process.platform === "win32") return { copiedNitros: [], copiedIcons: [] }; @@ -423,6 +426,7 @@ export async function syncAssetsToGamedataBundle(): Promise<{ "bundled/furniture", ); if (nitroDir !== nitroTarget) { + signal?.throwIfAborted(); const copied = await syncDirMissingFiles(nitroDir, nitroTarget, (entry) => entry.endsWith(".nitro"), ); @@ -434,6 +438,7 @@ export async function syncAssetsToGamedataBundle(): Promise<{ "icons", ); if (iconDir !== iconTarget) { + signal?.throwIfAborted(); const copied = await syncDirMissingFiles(iconDir, iconTarget, (entry) => entry.endsWith("_icon.png"), ); @@ -508,6 +513,7 @@ export async function importSingleFurni(params: { skipFurniDataWrite?: boolean; updateExisting?: boolean; onProgress?: (status: string) => void; + signal?: AbortSignal; /** Per-source SWF download base URL (e.g. "https://virtualc.nl/dcr"). */ sourceSwfBaseUrl?: string; /** Per-source nitro bundle + icon base URL. */ @@ -525,10 +531,12 @@ export async function importSingleFurni(params: { skipFurniDataWrite, updateExisting, onProgress, + signal, sourceSwfBaseUrl, nitroBaseUrl: sourceNitroBaseUrl, iconBaseUrl: sourceIconBaseUrl, } = params; + signal?.throwIfAborted(); const warnings: string[] = []; // Check if already exists by classname OR by spriteId (primary key collision). @@ -604,6 +612,7 @@ export async function importSingleFurni(params: { // ── Insert into DB with enriched data ────────────────────────────── onProgress?.("writing_db"); + signal?.throwIfAborted(); try { if (isUpdate) { // NOTE: live Arcturus `items_base` has no description/revision/rare columns — @@ -660,6 +669,7 @@ export async function importSingleFurni(params: { // fall back to base-classname icon (Nitro reuses base for all colors // when no color-specific icon exists). onProgress?.("downloading"); + signal?.throwIfAborted(); const iconColorAware = classname.replace(/\*/g, "_"); @@ -701,8 +711,8 @@ export async function importSingleFurni(params: { // Download icon + SWF concurrently (independent assets). Each tries its // candidate URLs in order until one succeeds. const [iconOkResult, swfOk] = await Promise.all([ - tryDownloadCandidates(iconUrls, iconPath, "png"), - tryDownloadCandidates(swfUrls, swfPath, "swf"), + tryDownloadCandidates(iconUrls, iconPath, "png", signal), + tryDownloadCandidates(swfUrls, swfPath, "swf", signal), ]); let iconOk = iconOkResult; @@ -715,7 +725,10 @@ export async function importSingleFurni(params: { !existsSync(/*turbopackIgnore: true*/ nitroPath) ) { const nitroUrl = `${sourceNitroBaseUrl}/${encodeURIComponent(safeNitroName)}.nitro`; - const dl = await downloadFile(nitroUrl, nitroPath, { validate: "png" }); + const dl = await downloadFile(nitroUrl, nitroPath, { + validate: "png", + signal, + }); if (dl.ok) nitroDownloadOk = true; } @@ -744,6 +757,7 @@ export async function importSingleFurni(params: { // ── Convert SWF to Nitro ────────────────────────────────────────── onProgress?.("converting"); + signal?.throwIfAborted(); let conversionResult: ConversionResult | null = null; if (existsSync(/*turbopackIgnore: true*/ nitroPath)) { diff --git a/src/lib/services/furni-maintenance.ts b/src/lib/services/furni-maintenance.ts index 0538eabf..a02a2d3b 100644 --- a/src/lib/services/furni-maintenance.ts +++ b/src/lib/services/furni-maintenance.ts @@ -635,14 +635,21 @@ export interface FixAllResult { /** Run every repair in a sensible order, then return before/after health. */ export async function fixEverything( - opts: { dedupePages?: boolean } = {}, + opts: { dedupePages?: boolean; signal?: AbortSignal } = {}, ): Promise { + opts.signal?.throwIfAborted(); const healthBefore = await getFurniHealth(); + opts.signal?.throwIfAborted(); const sprite = await fixSpriteIds(); + opts.signal?.throwIfAborted(); const offers = await fixCatalogOffers(); + opts.signal?.throwIfAborted(); const reconcile = await reconcileIds(); + opts.signal?.throwIfAborted(); const dedup = await removeDuplicates(opts.dedupePages ?? true); + opts.signal?.throwIfAborted(); const align = await forceItemsBaseIdsToFurnidata(true); + opts.signal?.throwIfAborted(); const healthAfter = await getFurniHealth(); return { healthBefore, sprite, offers, reconcile, dedup, align, healthAfter }; } diff --git a/src/lib/services/import-badge.ts b/src/lib/services/import-badge.ts index 4b649f09..933eb3a6 100644 --- a/src/lib/services/import-badge.ts +++ b/src/lib/services/import-badge.ts @@ -17,7 +17,9 @@ export async function writeBadgeToExternalTexts( code: string, name: string, description: string, + signal?: AbortSignal, ): Promise { + signal?.throwIfAborted(); const gamedataRoot = await getGamedataRoot(); if (!gamedataRoot) { return { @@ -35,6 +37,7 @@ export async function writeBadgeToExternalTexts( const raw = await fs.readFile(extPath, "utf-8"); texts = JSON.parse(raw) as Record; } catch (err) { + if (signal?.aborted) throw err; // File not present yet (fresh deployment) — start with an empty object. if ((err as NodeJS.ErrnoException).code !== "ENOENT") { console.warn( @@ -49,6 +52,7 @@ export async function writeBadgeToExternalTexts( } texts = {}; } + signal?.throwIfAborted(); if (texts[nameKey] !== undefined) { return { @@ -63,9 +67,11 @@ export async function writeBadgeToExternalTexts( try { await fs.mkdir(path.dirname(extPath), { recursive: true }); + signal?.throwIfAborted(); await fs.writeFile(extPath, JSON.stringify(texts, null, 4), "utf-8"); return { ok: true }; } catch (err) { + if (signal?.aborted) throw err; console.warn( "[import-badges] Failed to update ExternalTexts.json:", (err as Error).message, @@ -83,7 +89,9 @@ export async function upsertWebsiteBadge( code: string, name: string, description: string, + signal?: AbortSignal, ): Promise { + signal?.throwIfAborted(); const now = new Date(); await db .insert(WebsiteBadges) @@ -107,21 +115,28 @@ export async function upsertWebsiteBadge( * Single import path: ExternalTexts.json + WebsiteBadges. * Callers should gate with ASSETS_IMPORT. */ -export async function importBadgeSynced(input: { - code: string; - name: string; - description: string; -}): Promise { +export async function importBadgeSynced( + input: { + code: string; + name: string; + description: string; + }, + signal?: AbortSignal, +): Promise { + signal?.throwIfAborted(); const texts = await writeBadgeToExternalTexts( input.code, input.name, input.description, + signal, ); if (!texts.ok) return texts; try { - await upsertWebsiteBadge(input.code, input.name, input.description); - } catch { + signal?.throwIfAborted(); + await upsertWebsiteBadge(input.code, input.name, input.description, signal); + } catch (error) { + if (signal?.aborted) throw error; return { ok: false, error: "Failed to import badge", status: 500 }; } diff --git a/src/lib/services/import/core/download.ts b/src/lib/services/import/core/download.ts index 603af917..45313467 100644 --- a/src/lib/services/import/core/download.ts +++ b/src/lib/services/import/core/download.ts @@ -22,9 +22,11 @@ export async function tryDownloadCandidates( urls: string[], destPath: string, kind: "swf" | "png", + signal?: AbortSignal, ): Promise { for (const url of urls) { - const res = await downloadFile(url, destPath, { validate: kind }); + signal?.throwIfAborted(); + const res = await downloadFile(url, destPath, { validate: kind, signal }); if (res.ok) return true; } return false; @@ -33,18 +35,26 @@ export async function tryDownloadCandidates( export async function downloadFile( url: string, destPath: string, - options?: { maxRetries?: number; validate?: "swf" | "png" }, + options?: { + maxRetries?: number; + validate?: "swf" | "png"; + signal?: AbortSignal; + }, ): Promise<{ ok: boolean; size: number }> { const maxRetries = options?.maxRetries ?? 3; const baseDelay = 1000; for (let attempt = 0; attempt <= maxRetries; attempt++) { try { + options?.signal?.throwIfAborted(); if (attempt > 0) { await new Promise((r) => setTimeout(r, baseDelay * 2 ** (attempt - 1))); } + const timeout = AbortSignal.timeout(15000); const res = await fetch(url, { - signal: AbortSignal.timeout(15000), + signal: options?.signal + ? AbortSignal.any([options.signal, timeout]) + : timeout, headers: browserHeaders("image", { Accept: "image/png,image/*,*/*;q=0.8", }), @@ -82,6 +92,7 @@ export async function downloadFile( await fs.writeFile(/*turbopackIgnore: true*/ destPath, buffer); return { ok: true, size: buffer.length }; } catch (err) { + if (options?.signal?.aborted) throw err; if (attempt === maxRetries) { console.warn( `[import-download] Download error ${url}:`, diff --git a/src/lib/services/pet-import.ts b/src/lib/services/pet-import.ts index 87765082..6391c4d2 100644 --- a/src/lib/services/pet-import.ts +++ b/src/lib/services/pet-import.ts @@ -45,8 +45,10 @@ function nitroPathFor(lib: string): string { export async function importSinglePet(params: { lib: string; onProgress?: (status: string) => void; + signal?: AbortSignal; }): Promise { - const { lib, onProgress } = params; + const { lib, onProgress, signal } = params; + signal?.throwIfAborted(); const warnings: string[] = []; await ensurePetDirs(); @@ -66,7 +68,10 @@ export async function importSinglePet(params: { error: `Could not resolve SWF URL: ${(err as Error).message}`, }; } - const dl = await downloadFile(swfUrl, swfPath, { validate: "swf" }); + const dl = await downloadFile(swfUrl, swfPath, { + validate: "swf", + signal, + }); if (!dl.ok) return { ok: false, @@ -76,6 +81,7 @@ export async function importSinglePet(params: { }; onProgress?.("converting"); + signal?.throwIfAborted(); try { const swfBuffer = await fs.readFile(/*turbopackIgnore: true*/ swfPath); const result = convertSwfToNitro(swfBuffer, lib); diff --git a/src/lib/services/repair-icons.ts b/src/lib/services/repair-icons.ts index 22319a03..af699f5d 100644 --- a/src/lib/services/repair-icons.ts +++ b/src/lib/services/repair-icons.ts @@ -104,7 +104,9 @@ export interface RepairEvent { export async function repairMissingIcons( onEvent?: (evt: RepairEvent) => void, + options?: { signal?: AbortSignal }, ): Promise<{ succeeded: number; failed: number; skipped: number }> { + options?.signal?.throwIfAborted(); let succeeded = 0; let failed = 0; let skipped = 0; @@ -204,6 +206,7 @@ export async function repairMissingIcons( onEvent?.({ type: "started", total }); const processItem = async (classname: string) => { + options?.signal?.throwIfAborted(); // Special items (badges, pets, effects, bots, sticky notes) don't have // .png icon files to repair. Skip them. if (dbSkipSet.has(classname)) { @@ -312,7 +315,7 @@ export async function repairMissingIcons( targets.iconDir, fileName, ), - { maxRetries: 1, validate: "png" }, + { maxRetries: 1, validate: "png", signal: options?.signal }, ); if (dl.ok) { const icon = await fs.readFile( @@ -350,7 +353,7 @@ export async function repairMissingIcons( const dl = await downloadFile( `${source.nitroBaseUrl}/${classname}.nitro`, nitroTmp, - { maxRetries: 1 }, + { maxRetries: 1, signal: options?.signal }, ); if (dl.ok) { const icon = extractFurniIconPng(await fs.readFile(nitroTmp)); @@ -390,6 +393,7 @@ export async function repairMissingIcons( }; await runPool(items, REPAIR_CONCURRENCY, async (item, i) => { + options?.signal?.throwIfAborted(); const classname = item.item_name; const result = await processItem(classname); if (result.status === "skipped") { diff --git a/src/lib/services/repair-nitros.ts b/src/lib/services/repair-nitros.ts index 08909e1d..a546a55b 100644 --- a/src/lib/services/repair-nitros.ts +++ b/src/lib/services/repair-nitros.ts @@ -106,7 +106,9 @@ export interface RepairNitroEvent { export async function repairMissingNitros( onEvent?: (evt: RepairNitroEvent) => void, + options?: { signal?: AbortSignal }, ): Promise<{ succeeded: number; failed: number; skipped: number }> { + options?.signal?.throwIfAborted(); let succeeded = 0; let failed = 0; let skipped = 0; @@ -201,6 +203,7 @@ export async function repairMissingNitros( onEvent?.({ type: "started", total }); const processItem = async (classname: string) => { + options?.signal?.throwIfAborted(); // Special items (badges, pets, effects, bots, sticky notes) don't have // furni .nitro bundles to repair. Skip them. if (skipSet.has(classname)) { @@ -276,7 +279,7 @@ export async function repairMissingNitros( const dl = await downloadFile( `${source.nitroBaseUrl}/${nitroName}`, tmpPath, - { maxRetries: 1 }, + { maxRetries: 1, signal: options?.signal }, ); if (dl.ok) { // Validate it is a real Nitro bundle before writing it @@ -315,6 +318,7 @@ export async function repairMissingNitros( }; await runPool(items, REPAIR_CONCURRENCY, async (item, i) => { + options?.signal?.throwIfAborted(); const classname = item.item_name; const result = await processItem(classname); if (result.status === "skipped") { @@ -384,13 +388,14 @@ export async function repairMissingNitros( */ export async function addSize32AllNitros( onEvent?: (evt: RepairNitroEvent) => void, - opts?: { concurrency?: number }, + opts?: { concurrency?: number; signal?: AbortSignal }, ): Promise<{ succeeded: number; failed: number; skipped: number; existing: number; }> { + opts?.signal?.throwIfAborted(); let succeeded = 0; let failed = 0; let skipped = 0; @@ -442,6 +447,7 @@ export async function addSize32AllNitros( [...fileNames], opts?.concurrency ?? 12, async (fileName, i) => { + opts?.signal?.throwIfAborted(); let buf: Buffer | null = null; for (const dir of nitroDirs) { try { diff --git a/src/lib/services/upload-import.ts b/src/lib/services/upload-import.ts index 10170dc6..85bcca68 100644 --- a/src/lib/services/upload-import.ts +++ b/src/lib/services/upload-import.ts @@ -234,6 +234,7 @@ export async function uploadSingleFurni(params: { nitroBuffer: Buffer; iconBuffer?: Buffer | null; generateSql?: boolean; + signal?: AbortSignal; }): Promise { const { classname, @@ -250,7 +251,9 @@ export async function uploadSingleFurni(params: { nitroBuffer, iconBuffer, generateSql = false, + signal, } = params; + signal?.throwIfAborted(); const warnings: string[] = []; if (!/^[\w\-.*]+$/.test(classname)) { @@ -280,6 +283,7 @@ export async function uploadSingleFurni(params: { } await ensureDirectories(); + signal?.throwIfAborted(); const assetTargets = await getFurniAssetWriteTargets(); const { iconDir, nitroDir } = assetTargets; @@ -297,6 +301,7 @@ export async function uploadSingleFurni(params: { }; } await fs.writeFile(nitroPath, nitroBuffer); + signal?.throwIfAborted(); let iconFileName: string | null = null; let iconPath: string | null = null; @@ -307,6 +312,7 @@ export async function uploadSingleFurni(params: { } const mirroredPaths: string[] = []; + signal?.throwIfAborted(); await mirrorUploadedAsset( nitroPath, nitroFileName, @@ -346,6 +352,7 @@ export async function uploadSingleFurni(params: { const interactionModesCount = autoInteraction.interactionModesCount; let newId: number; + signal?.throwIfAborted(); try { newId = await allocateItemsBaseId(async (nextId) => { await db.execute(sql` @@ -372,6 +379,7 @@ export async function uploadSingleFurni(params: { } try { + signal?.throwIfAborted(); const furniEntry = buildFurniEntry({ id: newId, classname, @@ -390,6 +398,7 @@ export async function uploadSingleFurni(params: { let catalogItemId: number | null = null; try { + signal?.throwIfAborted(); // Skip when the item already has a catalog row — re-uploads must never // create a second row for the same item_ids. const [existingCatalog] = (await db.execute(sql` diff --git a/src/messages/en.json b/src/messages/en.json index f3f9e641..9cd29d47 100644 --- a/src/messages/en.json +++ b/src/messages/en.json @@ -3368,6 +3368,19 @@ "api-keys": "API keys", "banners": "Radio banners", "embed": "Embed tools" + }, + "studio": { + "audit": "Asset audit", + "badges": "Badge Studio", + "clone": "Clone furniture", + "clothing": "Clothing Studio", + "effects": "Effect Studio", + "furni": "Furniture Studio", + "maintenance": "Furniture maintenance", + "pets": "Pet Studio", + "repair-icons": "Repair asset icons", + "sync": "Synchronize assets", + "upload": "Upload furniture" } }, "system": { diff --git a/src/messages/it.json b/src/messages/it.json index bc9829f6..80f3f0db 100644 --- a/src/messages/it.json +++ b/src/messages/it.json @@ -3368,6 +3368,19 @@ "api-keys": "Chiavi API", "banners": "Banner radio", "embed": "Strumenti di incorporamento" + }, + "studio": { + "audit": "Audit degli asset", + "badges": "Studio badge", + "clone": "Clona furni", + "clothing": "Studio abbigliamento", + "effects": "Studio effetti", + "furni": "Studio furni", + "maintenance": "Manutenzione furni", + "pets": "Studio pet", + "repair-icons": "Ripara icone asset", + "sync": "Sincronizza asset", + "upload": "Carica furni" } }, "system": {