feat(housekeeping): integrate studio operations

This commit is contained in:
Simo committed 2026-08-30 15:29:58 +02:00
1 parent 020c06f172
commit 2e95a106e0
36 files changed
+2296 -60

No files matched your search

@@ -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);
});
});
@@ -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<Record<string, unknown>>;
}
export interface StudioOperationExecution {
readonly operationId: string;
readonly correlationId: string;
readonly actorId: number;
readonly signal?: AbortSignal;
report(progress: StudioOperationProgress): Promise<void>;
}
export interface StudioOperationRunner {
run(
kind: StudioOperationKind,
input: Readonly<Record<string, unknown>>,
execution: StudioOperationExecution,
): Promise<StudioOperationRunnerResult>;
}
export interface StudioOperationService {
execute(
invocation: StudioOperationInvocation,
kind: StudioOperationKind,
input: Readonly<Record<string, unknown>>,
): Promise<HousekeepingResult<StudioOperationRecord>>;
}
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<HousekeepingCapabilityContext>;
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<Record<string, unknown>>,
) =>
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<Record<string, unknown>, unknown> & {
readonly operationKind: StudioOperationKind;
};
const commandInput = z.object({}).catchall(z.unknown());
export function createStudioCommands(
service: Pick<StudioOperationService, "execute">,
): 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<Record<string, unknown>>) {
const payload = input.payload;
return typeof payload === "object" &&
payload !== null &&
!Array.isArray(payload)
? (payload as Readonly<Record<string, unknown>>)
: input;
}
function requiredText(input: Readonly<Record<string, unknown>>, 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<Record<string, unknown>>, 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<Record<string, unknown>> {
return typeof value === "object" && value !== null
? ({ ...(value as Record<string, unknown>) } 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<Record<string, unknown>>,
execution: StudioOperationExecution,
): Promise<StudioOperationRunnerResult> {
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);
@@ -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(
<OperationProgress
event={{
operationId: "op-1",
kind: "furni",
phase: "running",
completed: 2,
total: 4,
messageKey: "pages.housekeeping.studio.running",
correlationId: "corr-1",
}}
/>,
);
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(
<OperationResult
operation={{
operationId: "op-1",
kind: "sync",
phase: "partial",
completed: 8,
total: 10,
messageKey: "pages.housekeeping.studio.partial",
correlationId: "corr-1",
actorId: 42,
createdAt: "2026-08-30T10:00:00.000Z",
updatedAt: "2026-08-30T10:01:00.000Z",
output: { succeeded: 8, failed: 2 },
}}
/>,
);
expect(partial).toContain('data-operation-result="partial"');
expect(partial).toContain("corr-1");
});
});
@@ -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 (
<div
role="progressbar"
aria-label={`${event.kind} operation ${event.phase}`}
aria-valuemin={0}
aria-valuenow={determinate ? event.completed : undefined}
aria-valuemax={determinate ? (event.total ?? undefined) : undefined}
data-operation-phase={event.phase}
>
<strong>{event.kind}</strong>: {event.messageKey}
<span>
{event.completed}
{event.total === null ? " completed" : ` / ${event.total}`}
</span>
</div>
);
}
@@ -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 (
<article data-operation-result={operation.phase} role="status">
<h3>{operation.phase}</h3>
<p>{operation.messageKey}</p>
<p>Correlation: {operation.correlationId}</p>
{operation.output ? (
<pre>{JSON.stringify(operation.output, null, 2)}</pre>
) : null}
</article>
);
}
@@ -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 } });
});
});
@@ -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<readonly StudioOperationRecord[]>;
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;
},
});
@@ -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;
@@ -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(
<StudioPage
context={context([PERMS.ASSETS_IMPORT])}
result={result as never}
routeId="hotel.studio.furni"
/>,
);
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(
<StudioPage
context={context([])}
result={ok(
{ kind: "studio", items: [], total: 0, partialDependencies: [] },
"read-only",
)}
routeId="hotel.studio.maintenance"
/>,
);
expect(markup).not.toContain("hotel.studio.maintenance.run");
});
it("keeps the audit destination read-only even for fully privileged operators", () => {
const markup = renderToStaticMarkup(
<StudioPage
context={context([PERMS.ASSETS_IMPORT, PERMS.CATALOG_EDIT])}
result={ok(
{ kind: "studio", items: [], total: 0, partialDependencies: [] },
"audit",
)}
routeId="hotel.studio.audit"
/>,
);
expect(markup).not.toContain("hotel.studio.maintenance.run");
expect(markup).not.toContain("Start maintenance operation");
});
});
@@ -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<StudioQueryData>;
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 (
<section data-housekeeping-state="loading">
<h1>Hotel Studio</h1>
<p>Loading Studio operations…</p>
</section>
);
}
if (!result.ok) {
return (
<section
data-housekeeping-state={
result.error.code === "FORBIDDEN" ? "forbidden" : "error"
}
role="alert"
>
<h1>Hotel Studio</h1>
<p>{result.error.messageKey}</p>
</section>
);
}
const state =
result.data.partialDependencies.length > 0
? "partial"
: result.data.items.length === 0
? "empty"
: "ready";
const operationKind = routeId ? hotelStudioOperationKind(routeId) : null;
return (
<section data-housekeeping-state={state} className="space-y-4">
<header>
<h1>Hotel Studio</h1>
<p>Audited long-running asset imports, repairs and synchronization.</p>
</header>
{result.data.partialDependencies.length > 0 ? (
<p role="status">
Temporarily unavailable: {result.data.partialDependencies.join(", ")}.
</p>
) : null}
{routeId && operationKind && canRun(context, routeId) ? (
<HotelCommandForm
commandId={`hotel.studio.${operationKind}.run`}
buttonLabel={`Start ${operationKind} operation`}
input={{}}
fields={[
{
name: "payload",
label: "Operation input (JSON)",
type: "json",
required: true,
maxLength: 100_000,
},
]}
requiresReason
/>
) : null}
{result.data.items.length === 0 ? (
<p>No Studio operations match this view.</p>
) : (
<ul className="space-y-3">
{result.data.items.map((operation) => (
<li key={operation.operationId}>
<OperationProgress event={operation} />
<OperationResult operation={operation} />
</li>
))}
</ul>
)}
</section>
);
}
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 (
<StudioPage context={input.context} result={result} routeId={routeId} />
);
}
@@ -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,
});
});
});
@@ -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<Record<string, unknown>>;
}
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<void>;
appendEvent(
event: StudioOperationEvent,
output?: Readonly<Record<string, unknown>>,
): Promise<void>;
get(operationId: string): Promise<StudioOperationRecord | null>;
list(input: StudioOperationListInput): Promise<StudioOperationListResult>;
}
const TERMINAL_PHASES = new Set<StudioOperationPhase>([
"completed",
"failed",
"partial",
]);
export function createInMemoryStudioOperationRepository(
now: () => string = () => new Date().toISOString(),
): StudioOperationRepository {
const records = new Map<string, StudioOperationRecord>();
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<StudioQueryData>;
}
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<StudioQueryInput, StudioQueryData> {
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,
};
},
});
@@ -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) }),
),
);
@@ -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",
]);
});
});
@@ -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<const Id extends HotelPrimaryRouteId>(
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<const Id extends HotelStudioRouteId>(
id: Id,
href: CanonicalHousekeepingHref,
): HousekeepingRouteDefinition & { readonly id: Id } {
return routeDefinition(
id,
href,
id === "hotel.studio.maintenance"
? [PERMS.CATALOG_EDIT]
: [PERMS.ASSETS_IMPORT],
);
}
function routeDefinition<const Id extends HotelRouteId>(
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";
@@ -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<readonly HotelSearchCandidate[]>;
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
}`,
}));
},
});
@@ -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<Readonly<Record<string, number>>>;
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,
};
},
});
@@ -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,
@@ -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<string, ReadonlySet<string>>([
[
"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<string, ReadonlySet<string>>([
],
[
"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<string, ReadonlySet<string>>([
"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<string, ReadonlySet<string>>([
"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<string, ReadonlySet<string>>([
"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<string, ReadonlySet<string>>([
"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")
@@ -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",
};
@@ -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 });
}
@@ -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,
]);
});
+14 -3
View File
@@ -134,13 +134,16 @@ async function fetchWithFallback(url: string): Promise<string> {
export async function fetchSourceFurnidata(
url: string,
now = Date.now(),
signal?: AbortSignal,
): Promise<SourceFurni[]> {
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<CloneResult> {
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,
+10 -2
View File
@@ -89,8 +89,10 @@ export async function importClothingSet(params: {
setType: string;
setId: number;
onProgress?: (status: string) => void;
signal?: AbortSignal;
}): Promise<ImportClothingSetResult> {
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) {
+9 -2
View File
@@ -76,8 +76,10 @@ export async function importSingleEffect(params: {
type: string;
revision: number;
onProgress?: (status: string) => void;
signal?: AbortSignal;
}): Promise<ImportEffectResult> {
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);
+8 -2
View File
@@ -78,8 +78,10 @@ async function nitroPathFor(lib: string): Promise<string> {
export async function importSingleFigure(params: {
lib: string;
onProgress?: (status: string) => void;
signal?: AbortSignal;
}): Promise<ImportFigureResult> {
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);
+18 -4
View File
@@ -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)) {
+8 -1
View File
@@ -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<FixAllResult> {
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 };
}
+22 -7
View File
@@ -17,7 +17,9 @@ export async function writeBadgeToExternalTexts(
code: string,
name: string,
description: string,
signal?: AbortSignal,
): Promise<ImportBadgeResult> {
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<string, string>;
} 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<void> {
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<ImportBadgeResult> {
export async function importBadgeSynced(
input: {
code: string;
name: string;
description: string;
},
signal?: AbortSignal,
): Promise<ImportBadgeResult> {
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 };
}
+14 -3
View File
@@ -22,9 +22,11 @@ export async function tryDownloadCandidates(
urls: string[],
destPath: string,
kind: "swf" | "png",
signal?: AbortSignal,
): Promise<boolean> {
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}:`,
+8 -2
View File
@@ -45,8 +45,10 @@ function nitroPathFor(lib: string): string {
export async function importSinglePet(params: {
lib: string;
onProgress?: (status: string) => void;
signal?: AbortSignal;
}): Promise<ImportPetResult> {
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);
+6 -2
View File
@@ -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") {
+8 -2
View File
@@ -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 {
+9
View File
@@ -234,6 +234,7 @@ export async function uploadSingleFurni(params: {
nitroBuffer: Buffer;
iconBuffer?: Buffer | null;
generateSql?: boolean;
signal?: AbortSignal;
}): Promise<UploadResult> {
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`
+13
View File
@@ -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": {
+13
View File
@@ -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": {