feat(housekeeping): integrate studio operations
CI / check (pull_request) Successful in 38s
CI / release (pull_request) Skipped
CI / deploy (pull_request) Skipped

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,
]);
});