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);