feat(housekeeping): integrate studio operations
This commit is contained in:
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,
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
|
||||
@@ -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}:`,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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") {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
Reference in new issue
Block a user