Files
Epicnabbo-Catalogus-Updated…/src/features/housekeeping/domains/hotel/commands/studio-commands.ts
T

533 lines
15 KiB
TypeScript

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