Revert "Merge pull request 'Complete Housekeeping migration and /ase cutover' (#52) from codex/housekeeping-complete into main"
CI / check (push) Successful in 27s
CI / release (push) Skipped
CI / deploy (push) Successful in 43s

This reverts commit 488b6e57c4, reversing
changes made to b506b4499a.
This commit is contained in:
Simo committed 2026-08-30 21:31:34 +02:00
1 parent 488b6e57c4
commit b1ddda66ff
802 files changed
+61370 -76659

No files matched your search

-33
View File
@@ -1,33 +0,0 @@
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";
import { USER_COMMANDS } from "./domains/people/commands/user-commands";
import { SYSTEM_COMMANDS } from "./domains/system/commands/system-commands";
import {
defineHousekeepingCommands,
registerHousekeepingCommands,
} from "./foundation/commands/bootstrap";
import { sealHousekeepingCommandRegistry } from "./foundation/commands/registry";
const currentHousekeepingCommands = defineHousekeepingCommands(
...CONTENT_COMMANDS,
...ECONOMY_COMMANDS,
...HOTEL_COMMANDS,
...STUDIO_COMMANDS,
...USER_COMMANDS,
...COMMUNITY_COMMANDS,
...SUPPORT_COMMANDS,
...MODERATION_COMMANDS,
...SYSTEM_COMMANDS,
);
registerHousekeepingCommands(currentHousekeepingCommands);
sealHousekeepingCommandRegistry();
export const housekeepingCommandRegistryReady = true;
@@ -1,47 +0,0 @@
import { describe, expect, it } from "vitest";
import { createHousekeepingRegistry } from "../foundation/registry";
import { HOUSEKEEPING_MANIFESTS } from "../manifests";
import { discoverLegacyPages } from "../migration/discover-legacy-pages";
import { HOUSEKEEPING_MIGRATION_MATRIX } from "../migration/matrix";
import { HOUSEKEEPING_ROUTE_HANDLERS } from "../route-handlers";
import { verifyHousekeepingRuntimeParity } from "./parity";
describe("Housekeeping runtime parity", () => {
const report = verifyHousekeepingRuntimeParity(
HOUSEKEEPING_MIGRATION_MATRIX,
createHousekeepingRegistry(HOUSEKEEPING_MANIFESTS),
HOUSEKEEPING_ROUTE_HANDLERS,
);
it("closes all 137 recorded migration rows after removing legacy routes", () => {
expect(discoverLegacyPages()).toEqual([]);
expect(report).toEqual({
discovered: 137,
mapped: 137,
verified: 137,
removed: 2,
unresolved: [],
capabilityGaps: [],
handlerGaps: [],
});
});
it("keeps every retained target under /ase with concrete parity evidence", () => {
for (const row of HOUSEKEEPING_MIGRATION_MATRIX) {
if (row.decision === "REMOVE") {
expect(row.targetPath, row.legacyPath).toBeNull();
expect(row.status, row.legacyPath).toBe("REMOVED");
expect(row.parityEvidence, row.legacyPath).toContain(
`runtime-parity:${row.legacyPath}`,
);
continue;
}
expect(row.targetPath, row.legacyPath).toMatch(/^\/ase(?:\/|$)/);
expect(row.status, row.legacyPath).toBe("VERIFIED");
expect(row.parityEvidence, row.legacyPath).toContain(
`runtime-parity:${row.legacyPath}`,
);
}
});
});
-133
View File
@@ -1,133 +0,0 @@
import type { HousekeepingRegistry } from "../foundation/registry";
import type { MigrationEntry } from "../migration/types";
import type { HousekeepingRouteHandler } from "../route-handlers";
export interface HousekeepingParityReport {
readonly discovered: 137;
readonly mapped: number;
readonly verified: number;
readonly removed: number;
readonly unresolved: readonly string[];
readonly capabilityGaps: readonly string[];
readonly handlerGaps: readonly string[];
}
function expectedEvidence(row: MigrationEntry): string {
return `runtime-parity:${row.legacyPath}`;
}
function capabilityMatches(
row: MigrationEntry,
route: HousekeepingRegistry["domains"][number]["routes"][number],
): boolean {
const declared = new Set(row.capabilities.read);
if (route.capability.mode === "all") {
return route.capability.slugs.every((slug) => declared.has(slug));
}
return route.capability.slugs.some((slug) => declared.has(slug));
}
export function verifyHousekeepingRuntimeParity(
matrix: readonly MigrationEntry[],
registry: HousekeepingRegistry,
handlers: readonly HousekeepingRouteHandler[],
): HousekeepingParityReport {
if (matrix.length !== 137) {
throw new Error(
`Housekeeping parity requires exactly 137 discovered rows; received ${matrix.length}`,
);
}
const routes = registry.domains.flatMap((domain) => domain.routes);
const routesByHref = new Map<string, (typeof routes)[number]>(
routes.map((route) => [route.href, route]),
);
const routesById = new Map(routes.map((route) => [route.id, route]));
const handlersByRouteId = new Map<string, HousekeepingRouteHandler[]>();
for (const handler of handlers) {
const matching = handlersByRouteId.get(handler.routeId) ?? [];
matching.push(handler);
handlersByRouteId.set(handler.routeId, matching);
}
const unresolved = new Set<string>();
const capabilityGaps = new Set<string>();
const handlerGaps = new Set<string>();
let mapped = 0;
let verified = 0;
let removed = 0;
for (const row of matrix) {
let rowMapped = false;
let rowCapabilityVerified = true;
let rowHandlerVerified = true;
if (row.decision === "REMOVE") {
removed += 1;
rowMapped = row.targetPath === null;
if (!rowMapped)
unresolved.add(`${row.legacyPath}: removed target exists`);
} else if (row.targetPath === null) {
unresolved.add(`${row.legacyPath}: retained target is missing`);
} else {
const route = routesByHref.get(row.targetPath);
if (!route) {
unresolved.add(
`${row.legacyPath}: no registered route for ${row.targetPath}`,
);
} else {
rowMapped = true;
if (!capabilityMatches(row, route)) {
rowCapabilityVerified = false;
capabilityGaps.add(
`${row.legacyPath}: ${row.targetPath} capability mismatch`,
);
}
const matchingHandlers = handlersByRouteId.get(route.id) ?? [];
if (matchingHandlers.length !== 1) {
rowHandlerVerified = false;
handlerGaps.add(
`${row.legacyPath}: ${route.id} has ${matchingHandlers.length} handlers`,
);
}
}
}
if (rowMapped) mapped += 1;
const expectedStatus = row.decision === "REMOVE" ? "REMOVED" : "VERIFIED";
if (
rowMapped &&
rowCapabilityVerified &&
rowHandlerVerified &&
row.status === expectedStatus &&
row.parityEvidence.includes(expectedEvidence(row))
) {
verified += 1;
}
}
for (const route of routes) {
const matchingHandlers = handlersByRouteId.get(route.id) ?? [];
if (matchingHandlers.length !== 1) {
handlerGaps.add(
`registry route ${route.id} has ${matchingHandlers.length} handlers`,
);
}
}
for (const handler of handlers) {
if (!routesById.has(handler.routeId)) {
handlerGaps.add(`orphan handler ${handler.routeId}`);
}
}
return {
discovered: 137,
mapped,
verified,
removed,
unresolved: [...unresolved].sort(),
capabilityGaps: [...capabilityGaps].sort(),
handlerGaps: [...handlerGaps].sort(),
};
}
@@ -1,71 +0,0 @@
import { existsSync, readFileSync } from "node:fs";
import { describe, expect, it } from "vitest";
const CANONICAL_ENTRYPOINTS = [
"src/app/ase/layout.tsx",
"src/app/ase/page.tsx",
"src/app/ase/[domain]/layout.tsx",
"src/app/ase/[domain]/[[...segments]]/page.tsx",
"src/features/housekeeping/route-handlers.ts",
] as const;
const REMOVED_UI_ROOTS = [
"src/app/admin",
"src/app/admin-next",
"src/app/ase-next",
"src/app/mod",
] as const;
const CANONICAL_GLOBAL_SOURCES = [
"src/features/housekeeping/foundation/routing/href.ts",
"src/lib/admin/guard.ts",
"src/lib/proxy-access.ts",
"src/components/navigation.tsx",
"src/components/top-header.tsx",
] as const;
const REMOVED_UI_PATH = /\/(?:admin(?:-next)?|ase-next|mod)(?:[/?"'`]|$)/;
describe("atomic Housekeeping route cutover", () => {
it("publishes the canonical /ase entrypoints and dispatcher", () => {
for (const path of CANONICAL_ENTRYPOINTS) {
expect(existsSync(path), path).toBe(true);
}
});
it("removes every legacy UI tree while preserving internal admin APIs", () => {
for (const path of REMOVED_UI_ROOTS) {
expect(existsSync(path), path).toBe(false);
}
expect(existsSync("src/app/api/admin/csrf/route.ts")).toBe(true);
});
it("uses /ase for global links, proxy access, and authorization fallbacks", () => {
for (const path of CANONICAL_GLOBAL_SOURCES) {
const source = readFileSync(path, "utf8");
expect(source, path).toContain("/ase");
expect(source, path).not.toMatch(REMOVED_UI_PATH);
}
});
it("removes the preview gate and environment flag", () => {
expect(
existsSync("src/features/housekeeping/foundation/preview-gate.ts"),
).toBe(false);
for (const path of ["src/env.ts", ".env.example"] as const) {
expect(readFileSync(path, "utf8"), path).not.toContain(
"HOUSEKEEPING_NEXT_PREVIEW_ENABLED",
);
}
});
it("does not add redirects for removed UI paths", () => {
for (const path of ["next.config.ts", "src/proxy.ts"] as const) {
const source = readFileSync(path, "utf8");
expect(source, path).not.toMatch(
/(?:source|destination|redirect)\s*[:(][^\n]*(?:\/admin|\/admin-next|\/ase-next|\/mod)/,
);
}
});
});
@@ -1,93 +0,0 @@
import { readdirSync, readFileSync } from "node:fs";
import { dirname, relative, resolve, sep } from "node:path";
import { describe, expect, it } from "vitest";
const housekeepingRoot = resolve(process.cwd(), "src/features/housekeeping");
function sourceFiles(directory: string): string[] {
return readdirSync(directory, { withFileTypes: true }).flatMap((entry) => {
const path = resolve(directory, entry.name);
if (entry.isDirectory()) return sourceFiles(path);
if (!/\.(?:ts|tsx)$/u.test(entry.name) || /\.test\./u.test(entry.name)) {
return [];
}
return [path];
});
}
function moduleSpecifiers(source: string): string[] {
return [
...source.matchAll(
/(?:from\s+|import\s*\(|import\s+|require\s*\()\s*["']([^"']+)["']/gu,
),
].flatMap((match) => (match[1] ? [match[1]] : []));
}
function resolveHousekeepingImport(
file: string,
specifier: string,
): string | null {
if (specifier.startsWith(".")) {
return resolve(dirname(file), specifier).replaceAll("\\", "/");
}
const prefix = "@/features/housekeeping/";
if (specifier.startsWith(prefix)) {
return resolve(housekeepingRoot, specifier.slice(prefix.length)).replaceAll(
"\\",
"/",
);
}
return null;
}
function displayPath(path: string): string {
return relative(process.cwd(), path).split(sep).join("/");
}
describe("Housekeeping source boundaries", () => {
it("keeps foundation independent from domain internals, database, and actions", () => {
const violations = sourceFiles(resolve(housekeepingRoot, "foundation"))
.flatMap((file) =>
moduleSpecifiers(readFileSync(file, "utf8")).flatMap((specifier) => {
const resolved = resolveHousekeepingImport(file, specifier);
const importsDomain =
resolved?.includes("/features/housekeeping/domains/") === true;
const importsInfrastructure =
specifier === "@/db" ||
specifier.startsWith("@/db/") ||
specifier === "@/actions" ||
specifier.startsWith("@/actions/");
return importsDomain || importsInfrastructure
? [`${displayPath(file)} -> ${specifier}`]
: [];
}),
)
.sort();
expect(violations).toEqual([]);
});
it("prevents one domain from importing another domain's internals", () => {
const domainsRoot = resolve(housekeepingRoot, "domains");
const violations = sourceFiles(domainsRoot)
.flatMap((file) => {
const owner = relative(domainsRoot, file).split(sep)[0];
return moduleSpecifiers(readFileSync(file, "utf8")).flatMap(
(specifier) => {
const resolved = resolveHousekeepingImport(file, specifier);
if (!resolved) return [];
const marker = "/features/housekeeping/domains/";
const offset = resolved.indexOf(marker);
if (offset < 0) return [];
const target = resolved.slice(offset + marker.length).split("/")[0];
return target && target !== owner
? [`${displayPath(file)} -> ${specifier}`]
: [];
},
);
})
.sort();
expect(violations).toEqual([]);
});
});
@@ -1,179 +0,0 @@
import { describe, expect, it, vi } from "vitest";
import { PERMS } from "@/lib/permission-slugs";
import type { HousekeepingCapabilityContext } from "../../../foundation/contracts";
import { CONTENT_COMMAND_IDS, createContentCommands } from "./content-commands";
const expected = [
["content.editorial.article.change", "article.change", PERMS.NEWS_EDIT],
["content.media.ad.change", "ad.change", PERMS.PAGES_EDIT],
["content.media.banner.change", "banner.change", PERMS.BANNERS_EDIT],
[
"content.engagement.event-type.change",
"event-type.change",
PERMS.EVENTS_EDIT,
],
["content.engagement.event.change", "event.change", PERMS.EVENTS_EDIT],
[
"content.engagement.event-prize.change",
"event-prize.change",
PERMS.EVENTS_EDIT,
],
[
"content.engagement.event-winner.add",
"event-winner.add",
PERMS.EVENTS_EDIT,
],
["content.engagement.poll.change", "poll.change", PERMS.POLLS_EDIT],
[
"content.engagement.poll-question.change",
"poll-question.change",
PERMS.POLLS_EDIT,
],
["content.media.photo.delete", "photo.delete", PERMS.PAGES_EDIT],
["content.media.asset.upload", "media.upload", PERMS.PAGES_EDIT],
["content.media.asset.delete", "media.delete", PERMS.PAGES_EDIT],
["content.editorial.tag.change", "tag.change", PERMS.PAGES_EDIT],
["content.engagement.prefix.change", "prefix.change", PERMS.PREFIXES_EDIT],
[
"content.engagement.prefix-blacklist.change",
"prefix-blacklist.change",
PERMS.PREFIXES_EDIT,
],
[
"content.engagement.prefix-settings.update",
"prefix-settings.update",
PERMS.PREFIXES_EDIT,
],
["content.help.question.change", "help-question.change", PERMS.PAGES_EDIT],
[
"content.editorial.writeable-box.change",
"writeable-box.change",
PERMS.PAGES_EDIT,
],
[
"content.help.email-template.change",
"email-template.change",
PERMS.PAGES_EDIT,
],
["content.brand.theme.update", "theme.update", PERMS.SETTINGS_EDIT],
[
"content.brand.theme.apply-preset",
"theme.apply-preset",
PERMS.SETTINGS_EDIT,
],
[
"content.brand.theme.custom-change",
"theme.custom-change",
PERMS.SETTINGS_EDIT,
],
[
"content.brand.theme.apply-custom",
"theme.apply-custom",
PERMS.SETTINGS_EDIT,
],
["content.brand.favicon.save", "favicon.save", PERMS.SETTINGS_EDIT],
["content.brand.favicon.delete", "favicon.delete", PERMS.SETTINGS_EDIT],
["content.brand.logo.save", "logo.save", PERMS.SETTINGS_EDIT],
[
"content.localization.cms.save",
"translation.cms.save",
PERMS.SETTINGS_EDIT,
],
[
"content.localization.client.save",
"translation.client.save",
PERMS.SETTINGS_EDIT,
],
[
"content.localization.emulator.save",
"translation.emulator.save",
PERMS.SETTINGS_EDIT,
],
] as const;
function context(): HousekeepingCapabilityContext {
return {
actor: { id: 42, username: "operator", rank: 7 },
isSuperAdmin: false,
has: () => true,
hasAny: () => true,
hasAll: () => true,
};
}
describe("Content commands", () => {
it("registers the exact complete operation matrix with existing ACLs", () => {
const service = { execute: vi.fn() };
const commands = createContentCommands(service as never);
expect(CONTENT_COMMAND_IDS).toEqual(expected.map(([id]) => id));
expect(
commands.map((command) => [
command.id,
command.operation,
command.capability.slugs[0],
]),
).toEqual(expected);
expect(commands.every((command) => command.owner === "content")).toBe(true);
});
it("marks global brand and localization writes sensitive with reason confirmation", () => {
const commands = createContentCommands({ execute: vi.fn() } as never);
const globalCommands = commands.filter(
(command) =>
command.id.startsWith("content.brand.") ||
command.id.startsWith("content.localization."),
);
expect(globalCommands.length).toBeGreaterThan(0);
expect(
globalCommands.every(
(command) => command.risk === "sensitive" && command.requiresReason,
),
).toBe(true);
expect(
globalCommands.every(
(command) =>
command.capability.mode === "any" &&
command.capability.slugs[0] === PERMS.SETTINGS_EDIT,
),
).toBe(true);
});
it("executes the real mutation service with actor-bound authority", async () => {
const execute = vi.fn(async () => ({
ok: true as const,
data: { before: null, after: { id: "1" } },
correlationId: "command-correlation",
}));
const [command] = createContentCommands({ execute } as never);
const result = await command.execute(
{
capability: context(),
correlationId: "command-correlation",
ipAddress: "127.0.0.1",
},
{ action: "create", title: "Launch" },
);
expect(execute).toHaveBeenCalledWith(
{
correlationId: "command-correlation",
expectedActorId: 42,
},
"article.change",
{ action: "create", title: "Launch" },
);
expect(result).toMatchObject({ ok: true });
});
it("isolates command validation schemas from later caller mutation", () => {
const commands = createContentCommands({ execute: vi.fn() } as never);
for (const command of commands) {
expect(command.input.safeParse(null).success, command.id).toBe(false);
expect(command.rateLimit.attempts).toBeGreaterThan(0);
expect(command.rateLimit.windowMs).toBeGreaterThan(0);
}
});
});
@@ -1,139 +0,0 @@
import "server-only";
import { z } from "zod";
import { PERMS } from "@/lib/permission-slugs";
import type { HousekeepingCommand } from "../../../foundation/commands/registry";
import { anyCapability } from "../../../foundation/contracts";
import {
type ContentMutationOperation,
type ContentMutationService,
contentMutationService,
} from "../services/mutations";
const CONTENT_COMMAND_DEFINITIONS = [
["content.editorial.article.change", "article.change", PERMS.NEWS_EDIT],
["content.media.ad.change", "ad.change", PERMS.PAGES_EDIT],
["content.media.banner.change", "banner.change", PERMS.BANNERS_EDIT],
[
"content.engagement.event-type.change",
"event-type.change",
PERMS.EVENTS_EDIT,
],
["content.engagement.event.change", "event.change", PERMS.EVENTS_EDIT],
[
"content.engagement.event-prize.change",
"event-prize.change",
PERMS.EVENTS_EDIT,
],
[
"content.engagement.event-winner.add",
"event-winner.add",
PERMS.EVENTS_EDIT,
],
["content.engagement.poll.change", "poll.change", PERMS.POLLS_EDIT],
[
"content.engagement.poll-question.change",
"poll-question.change",
PERMS.POLLS_EDIT,
],
["content.media.photo.delete", "photo.delete", PERMS.PAGES_EDIT],
["content.media.asset.upload", "media.upload", PERMS.PAGES_EDIT],
["content.media.asset.delete", "media.delete", PERMS.PAGES_EDIT],
["content.editorial.tag.change", "tag.change", PERMS.PAGES_EDIT],
["content.engagement.prefix.change", "prefix.change", PERMS.PREFIXES_EDIT],
[
"content.engagement.prefix-blacklist.change",
"prefix-blacklist.change",
PERMS.PREFIXES_EDIT,
],
[
"content.engagement.prefix-settings.update",
"prefix-settings.update",
PERMS.PREFIXES_EDIT,
],
["content.help.question.change", "help-question.change", PERMS.PAGES_EDIT],
[
"content.editorial.writeable-box.change",
"writeable-box.change",
PERMS.PAGES_EDIT,
],
[
"content.help.email-template.change",
"email-template.change",
PERMS.PAGES_EDIT,
],
["content.brand.theme.update", "theme.update", PERMS.SETTINGS_EDIT],
[
"content.brand.theme.apply-preset",
"theme.apply-preset",
PERMS.SETTINGS_EDIT,
],
[
"content.brand.theme.custom-change",
"theme.custom-change",
PERMS.SETTINGS_EDIT,
],
[
"content.brand.theme.apply-custom",
"theme.apply-custom",
PERMS.SETTINGS_EDIT,
],
["content.brand.favicon.save", "favicon.save", PERMS.SETTINGS_EDIT],
["content.brand.favicon.delete", "favicon.delete", PERMS.SETTINGS_EDIT],
["content.brand.logo.save", "logo.save", PERMS.SETTINGS_EDIT],
[
"content.localization.cms.save",
"translation.cms.save",
PERMS.SETTINGS_EDIT,
],
[
"content.localization.client.save",
"translation.client.save",
PERMS.SETTINGS_EDIT,
],
[
"content.localization.emulator.save",
"translation.emulator.save",
PERMS.SETTINGS_EDIT,
],
] as const;
export const CONTENT_COMMAND_IDS = CONTENT_COMMAND_DEFINITIONS.map(
([id]) => id,
) as ReadonlyArray<(typeof CONTENT_COMMAND_DEFINITIONS)[number][0]>;
type ContentCommand = HousekeepingCommand<Record<string, unknown>, unknown> & {
readonly operation: ContentMutationOperation;
};
const commandInput = z.object({}).catchall(z.unknown());
export function createContentCommands(
service: Pick<ContentMutationService, "execute">,
): readonly ContentCommand[] {
return CONTENT_COMMAND_DEFINITIONS.map(
([id, operation, permission]): ContentCommand => ({
id,
owner: "content",
operation,
risk: "sensitive",
capability: anyCapability(permission),
input: commandInput,
requiresReason:
id.startsWith("content.brand.") ||
id.startsWith("content.localization."),
rateLimit: { attempts: 10, windowMs: 60_000 },
execute: (context, input) =>
service.execute(
{
correlationId: context.correlationId,
expectedActorId: context.capability.actor.id,
},
operation,
input,
),
}),
);
}
export const CONTENT_COMMANDS = createContentCommands(contentMutationService);
@@ -1,187 +0,0 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { PERMS } from "@/lib/permission-slugs";
import type { HousekeepingCapabilityContext } from "../../foundation/contracts";
import { loadContentInboxItems } from "./inbox-production";
import { loadContentSearchCandidates } from "./search-production";
import { loadContentWidget } from "./widgets-production";
const { run } = vi.hoisted(() => ({ run: vi.fn() }));
vi.mock("./queries/content-queries", () => ({
contentQuery: { run },
}));
const context = {
actor: { id: 42, username: "operator", rank: 7 },
isSuperAdmin: false,
has: () => true,
hasAny: () => true,
hasAll: () => true,
} satisfies HousekeepingCapabilityContext;
function capability(granted: readonly string[]): HousekeepingCapabilityContext {
const permissions = new Set(granted);
return {
...context,
has: (slug) => permissions.has(slug),
hasAny: (...slugs) => slugs.some((slug) => permissions.has(slug)),
hasAll: (...slugs) => slugs.every((slug) => permissions.has(slug)),
};
}
function result(
routeId: string,
total: number,
items: readonly Record<string, unknown>[] = [],
) {
return {
ok: true as const,
data: {
kind: routeId.split(".")[1],
items,
total,
partialDependencies: [],
},
correlationId: "provider-production",
};
}
describe("Content production providers", () => {
beforeEach(() => {
vi.clearAllMocks();
run.mockImplementation(async (_context, input) =>
result(input.routeId, input.routeId.includes("articles") ? 7 : 3),
);
});
it("returns only truthful persisted counts from editorial and localization widgets", async () => {
const signal = new AbortController().signal;
await expect(
loadContentWidget("editorial", context, signal),
).resolves.toEqual({ articles: 7 });
await expect(
loadContentWidget("localization", context, signal),
).resolves.toEqual({ stores: 3 });
});
it("loads real search candidates from bounded query routes", async () => {
run.mockImplementation(async (_context, input) =>
result(input.routeId, 1, [
{
id: "9",
title: "Launch",
description: "Published",
href: "/ase/content/editorial/articles/9",
},
]),
);
const candidates = await loadContentSearchCandidates(
"articles",
context,
"launch",
25,
);
expect(candidates).toEqual([
expect.objectContaining({
id: "content.editorial.articles:9",
href: "/ase/content/editorial/articles/9",
}),
]);
expect(run).toHaveBeenCalledWith(
context,
expect.objectContaining({
list: { search: "launch", pageSize: 25, offset: 0 },
}),
);
});
it("builds publication inbox items only from real query rows", async () => {
run.mockImplementation(async (_context, input) =>
result(input.routeId, 1, [
{
id: "5",
title: "Release",
status: "published",
updatedAt: new Date().toISOString(),
href: "/ase/content/editorial/articles/5",
},
]),
);
const items = await loadContentInboxItems(
"publication",
context,
new AbortController().signal,
);
expect(items).toEqual([]);
});
it("loads only capability-matched media counts", async () => {
const result = await loadContentWidget(
"media",
capability([PERMS.BANNERS_VIEW]),
new AbortController().signal,
);
expect(result).toEqual({ banners: 3 });
expect(run).toHaveBeenCalledTimes(1);
expect(run).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({ routeId: "content.media.banners" }),
);
});
it("does not invent a zero when a widget dependency is unavailable", async () => {
run.mockResolvedValueOnce({
ok: false,
error: { code: "DEPENDENCY_UNAVAILABLE", messageKey: "dependency" },
correlationId: "unavailable",
});
await expect(
loadContentWidget("editorial", context, new AbortController().signal),
).rejects.toThrow("Content widget query unavailable");
});
it("marks an inbox dependency unavailable instead of returning an available empty list", async () => {
run.mockResolvedValueOnce({
ok: false,
error: { code: "DEPENDENCY_UNAVAILABLE", messageKey: "dependency" },
correlationId: "unavailable",
});
await expect(
loadContentInboxItems(
"publication",
context,
new AbortController().signal,
),
).rejects.toThrow("Content inbox query unavailable");
});
it("emits only actionable publication statuses", async () => {
run.mockResolvedValueOnce(
result("content.editorial.articles", 2, [
{
id: "draft",
title: "Draft",
status: "draft",
updatedAt: new Date().toISOString(),
href: "/ase/content/editorial/articles/draft",
},
{
id: "published",
title: "Published",
status: "published",
updatedAt: new Date().toISOString(),
href: "/ase/content/editorial/articles/published",
},
]),
);
const items = await loadContentInboxItems(
"publication",
context,
new AbortController().signal,
);
expect(items.map((item) => item.itemId)).toEqual(["draft"]);
expect(items[0]?.state).toBe("draft");
});
});
@@ -1,163 +0,0 @@
import { describe, expect, it, vi } from "vitest";
import { PERMS } from "@/lib/permission-slugs";
import {
anyCapability,
type HousekeepingCapabilityContext,
} from "../../foundation/contracts";
import { CONTENT_INBOX_SOURCE_IDS, createContentInboxSources } from "./inbox";
import {
CONTENT_SEARCH_PROVIDER_IDS,
createContentSearchProviders,
} from "./search";
import { CONTENT_WIDGET_IDS, createContentWidgets } 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("Content search providers", () => {
it("uses the four exact IDs, filters item capabilities, and caps results at 25", async () => {
const visible = anyCapability(PERMS.NEWS_VIEW);
const hidden = anyCapability(PERMS.EVENTS_VIEW);
const candidates = Array.from({ length: 30 }, (_, index) => ({
id: `article-${index}`,
title: `Article ${index}`,
href: `/ase/content/editorial/articles/${index + 1}`,
capability: index === 0 ? hidden : visible,
}));
const providerAdapters = {
articles: vi.fn(async () => candidates),
events: vi.fn(async () => []),
media: vi.fn(async () => []),
help: vi.fn(async () => []),
};
const providers = createContentSearchProviders(providerAdapters);
expect(CONTENT_SEARCH_PROVIDER_IDS).toEqual([
"content.articles",
"content.events",
"content.media",
"content.help",
]);
expect(providers.map((provider) => provider.id)).toEqual(
CONTENT_SEARCH_PROVIDER_IDS,
);
const result = await providers[0].search(context([PERMS.NEWS_VIEW]), {
term: " launch ",
limit: 999,
});
expect(providerAdapters.articles).toHaveBeenCalledWith(
expect.anything(),
"launch",
25,
);
expect(result.ok).toBe(true);
if (!result.ok) return;
expect(result.data).toHaveLength(25);
expect(result.data.some((item) => item.id === "article-0")).toBe(false);
});
it.each([
"/ase/content/%2e%2e/system",
"/ase/content/%252e%252e/system",
"/ase/content/%252f..%252fsystem",
"/ase/content/%255c..%255csystem",
"https://example.test/ase/content/editorial",
"//example.test/ase/content/editorial",
])(
"rejects normalized and double-encoded traversal href %s",
async (href) => {
const adapters = {
articles: async () => [
{
id: "unsafe",
title: "Unsafe",
href,
capability: anyCapability(PERMS.NEWS_VIEW),
},
],
events: async () => [],
media: async () => [],
help: async () => [],
};
const [provider] = createContentSearchProviders(adapters);
const result = await provider.search(context([PERMS.NEWS_VIEW]), {
term: "",
limit: 25,
});
expect(result).toMatchObject({ ok: true, data: [] });
},
);
});
describe("Content inbox and widgets", () => {
it("provides capability-selective publication and attention sources", async () => {
const publication = vi.fn(async () => []);
const attention = vi.fn(async () => []);
const sources = createContentInboxSources({ publication, attention });
expect(CONTENT_INBOX_SOURCE_IDS).toEqual([
"content.publication",
"content.attention",
]);
const controller = new AbortController();
const news = context([PERMS.NEWS_VIEW]);
await sources[0].getItems(news, controller.signal);
const forbidden = await sources[1].getItems(news, controller.signal);
expect(publication).toHaveBeenCalledTimes(1);
expect(attention).not.toHaveBeenCalled();
expect(forbidden).toMatchObject({
ok: false,
error: { code: "FORBIDDEN" },
});
});
it("does not advertise media permissions for an event-and-poll attention source", async () => {
const attention = vi.fn(async () => []);
const [, source] = createContentInboxSources({
publication: async () => [],
attention,
});
const result = await source.getItems(
context([PERMS.PAGES_VIEW, PERMS.BANNERS_VIEW]),
new AbortController().signal,
);
expect(result).toMatchObject({ ok: false, error: { code: "FORBIDDEN" } });
expect(attention).not.toHaveBeenCalled();
});
it("keeps editorial mandatory and media/localization optional without preview DB imports", async () => {
const adapters = {
editorial: vi.fn(async () => ({ drafts: 2, scheduled: 1 })),
media: vi.fn(async () => ({ items: 4 })),
localization: vi.fn(async () => ({ stores: 3, pending: 0 })),
};
const widgets = createContentWidgets(adapters);
expect(CONTENT_WIDGET_IDS).toEqual([
"content.editorial-summary",
"content.media-summary",
"content.localization-summary",
]);
expect(widgets.map((widget) => widget.kind)).toEqual([
"mandatory",
"optional",
"optional",
]);
const result = await widgets[0].load(
context([PERMS.NEWS_VIEW]),
new AbortController().signal,
);
expect(result).toMatchObject({
ok: true,
data: { drafts: 2, scheduled: 1 },
});
});
});
@@ -1,82 +0,0 @@
import "server-only";
import { PERMS } from "@/lib/permission-slugs";
import {
anyCapability,
type HousekeepingCapabilityContext,
type HousekeepingWorkItem,
} from "../../foundation/contracts";
import { contentQuery } from "./queries/content-queries";
type ContentInboxKind = "publication" | "attention";
const ACTIONABLE_STATUS = {
publication: new Set(["draft", "scheduled", "pending", "failed"]),
attention: new Set(["draft", "cancelled", "closed", "failed"]),
} as const;
function time(value: string | null | undefined) {
if (!value) return null;
const timestamp = Date.parse(value);
if (!Number.isFinite(timestamp)) return null;
const ageMs = Math.max(0, Date.now() - timestamp);
return {
occurredAt: new Date(timestamp).toISOString(),
ageMs,
freshness: ageMs > 86_400_000 ? ("stale" as const) : ("fresh" as const),
};
}
export async function loadContentInboxItems(
kind: ContentInboxKind,
context: HousekeepingCapabilityContext,
signal: AbortSignal,
): Promise<readonly HousekeepingWorkItem[]> {
if (signal.aborted) throw new Error("aborted Content inbox");
const definitions =
kind === "publication"
? ([
[
"content.editorial.articles",
PERMS.NEWS_VIEW,
"content.publication",
],
] as const)
: ([
["content.engagement.events", PERMS.EVENTS_VIEW, "content.attention"],
["content.engagement.polls", PERMS.POLLS_VIEW, "content.attention"],
] as const);
const items: HousekeepingWorkItem[] = [];
for (const [routeId, permission, sourceId] of definitions) {
if (!context.has(permission)) continue;
const result = await contentQuery.run(context, {
routeId,
list: { pageSize: 25, offset: 0 },
});
if (!result.ok) throw new Error("Content inbox query unavailable");
for (const item of result.data.items) {
const status = item.status?.toLocaleLowerCase() ?? "";
if (!ACTIONABLE_STATUS[kind].has(status)) continue;
const date = time(item.updatedAt);
if (!date || !item.href) continue;
items.push({
sourceId,
itemId: item.id,
deduplicationKey: `${sourceId}:${routeId}:${item.id}`,
domain: "content",
capability: anyCapability(permission),
severity:
status === "failed" || status === "cancelled" ? "warning" : "info",
priority: status === "failed" ? "high" : "normal",
...date,
state: status,
titleKey: "pages.housekeeping.items.content",
context: { title: item.title },
href: item.href as `/ase/${string}`,
actions: [],
});
if (items.length >= 25) return items;
}
}
return items;
}
@@ -1,94 +0,0 @@
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 HousekeepingInboxSource,
type HousekeepingWorkItem,
ok,
} from "../../foundation/contracts";
import { isSafeHousekeepingHref } from "../../foundation/housekeeping-href";
export const CONTENT_INBOX_SOURCE_IDS = [
"content.publication",
"content.attention",
] as const;
type ContentInboxLoader = (
context: HousekeepingCapabilityContext,
signal: AbortSignal,
) => Promise<readonly HousekeepingWorkItem[]>;
export interface ContentInboxAdapters {
readonly publication: ContentInboxLoader;
readonly attention: ContentInboxLoader;
}
function createSource(
id: (typeof CONTENT_INBOX_SOURCE_IDS)[number],
capability: CapabilityRequirement,
load: ContentInboxLoader,
): HousekeepingInboxSource {
return {
id,
owner: "content",
capability,
async getItems(context, signal) {
const authorization = authorizeHousekeeping(context, capability);
if (!authorization.ok) return authorization;
try {
const items = await load(context, signal);
return ok(
{
availability: "available" as const,
items: items
.filter(
(item) =>
isSafeHousekeepingHref(item.href) &&
satisfiesCapability(context, item.capability),
)
.slice(0, 25),
},
authorization.correlationId,
);
} catch {
return fail(
"DEPENDENCY_UNAVAILABLE",
"errors.housekeeping.dependencyUnavailable",
authorization.correlationId,
);
}
},
};
}
export function createContentInboxSources(
adapters: ContentInboxAdapters,
): readonly HousekeepingInboxSource[] {
return [
createSource(
"content.publication",
anyCapability(PERMS.NEWS_VIEW),
adapters.publication,
),
createSource(
"content.attention",
anyCapability(PERMS.EVENTS_VIEW, PERMS.POLLS_VIEW),
adapters.attention,
),
];
}
export const CONTENT_INBOX_SOURCES = createContentInboxSources({
async publication(context, signal) {
const { loadContentInboxItems } = await import("./inbox-production");
return loadContentInboxItems("publication", context, signal);
},
async attention(context, signal) {
const { loadContentInboxItems } = await import("./inbox-production");
return loadContentInboxItems("attention", context, signal);
},
});
@@ -3,17 +3,13 @@ import {
anyCapability,
type HousekeepingDomainManifest,
} from "../../foundation/contracts";
import { CONTENT_INBOX_SOURCES } from "./inbox";
import { CONTENT_ROUTES } from "./routes";
import { CONTENT_SEARCH_PROVIDERS } from "./search";
import { CONTENT_WIDGETS } from "./widgets";
export const contentManifest = {
id: "content",
labelKey: "pages.housekeeping.domains.content.title",
descriptionKey: "pages.housekeeping.domains.content.description",
iconId: "file-text",
canonicalHref: "/ase/content",
previewHref: "/admin-next/content",
capability: anyCapability(
PERMS.NEWS_VIEW,
PERMS.PAGES_VIEW,
@@ -30,8 +26,8 @@ export const contentManifest = {
PERMS.SETTINGS_VIEW,
PERMS.SETTINGS_EDIT,
),
routes: CONTENT_ROUTES,
searchProviders: CONTENT_SEARCH_PROVIDERS,
inboxSources: CONTENT_INBOX_SOURCES,
widgets: CONTENT_WIDGETS,
routes: [],
searchProviders: [],
inboxSources: [],
widgets: [],
} satisfies HousekeepingDomainManifest;
@@ -1,143 +0,0 @@
import { PERMS } from "@/lib/permission-slugs";
import type { HousekeepingPageInput } from "../../../route-handlers";
import { contentQuery } from "../queries/content-queries";
import { ContentCommandForm } from "./content-command-form";
import {
ContentPageFrame,
type ContentPageProps,
parseContentListInput,
} from "./content-page-frame";
export function ContentBrandPage({
context,
result,
routeId,
}: ContentPageProps) {
const forms = context.has(PERMS.SETTINGS_EDIT) ? (
<div className="grid gap-3 lg:grid-cols-2">
{routeId === "content.brand.theme" ? (
<>
<ContentCommandForm
commandId="content.brand.theme.update"
buttonLabel="Save theme values"
input={{}}
fields={[
{
name: "values",
label: "Theme values JSON",
type: "json",
required: true,
maxLength: 20_000,
},
]}
requiresReason
/>
<ContentCommandForm
commandId="content.brand.theme.apply-preset"
buttonLabel="Apply preset"
input={{}}
fields={[
{
name: "preset",
label: "Preset",
type: "text",
required: true,
maxLength: 100,
},
]}
requiresReason
/>
<ContentCommandForm
commandId="content.brand.theme.custom-change"
buttonLabel="Save custom theme"
input={{ action: "update" }}
fields={[
{ name: "id", label: "Theme ID", type: "identifier" },
{
name: "name",
label: "Name",
type: "text",
required: true,
maxLength: 100,
},
{
name: "values",
label: "Theme values JSON",
type: "json",
required: true,
maxLength: 20_000,
},
]}
requiresReason
/>
<ContentCommandForm
commandId="content.brand.theme.apply-custom"
buttonLabel="Apply custom theme"
input={{}}
fields={[
{
name: "id",
label: "Theme ID",
type: "identifier",
required: true,
},
]}
requiresReason
/>
</>
) : null}
{routeId === "content.brand.favicon" ? (
<>
<ContentCommandForm
commandId="content.brand.favicon.save"
buttonLabel="Save favicon"
input={{}}
fields={[
{ name: "file", label: "Favicon", type: "file", required: true },
]}
requiresReason
/>
<ContentCommandForm
commandId="content.brand.favicon.delete"
buttonLabel="Delete favicon"
input={{}}
requiresReason
/>
<ContentCommandForm
commandId="content.brand.logo.save"
buttonLabel="Save logo"
input={{}}
fields={[
{ name: "file", label: "Logo", type: "file", required: true },
]}
requiresReason
/>
</>
) : null}
</div>
) : null;
return (
<ContentPageFrame
title="Brand"
description="Manage theme, favicon, and logo assets."
result={result}
forms={forms}
/>
);
}
export async function renderContentBrandPage(input: HousekeepingPageInput) {
const routeId = input.match.routeId as ContentPageProps["routeId"];
const result = await contentQuery.run(input.context, {
routeId: routeId ?? "content.brand.theme",
params: input.match.params,
list: parseContentListInput(input.searchParams ?? {}),
});
return (
<ContentBrandPage
context={input.context}
result={result}
routeId={routeId}
/>
);
}
@@ -1,250 +0,0 @@
"use client";
import { useActionState } from "react";
import type { HousekeepingResult } from "../../../foundation/contracts";
export interface ContentCommandField {
readonly name: string;
readonly label: string;
readonly type:
| "identifier"
| "json"
| "text"
| "textarea"
| "number"
| "checkbox"
| "select"
| "file";
readonly required?: boolean;
readonly min?: number;
readonly max?: number;
readonly maxLength?: number;
readonly defaultValue?: string | number | boolean;
readonly allowUnchanged?: boolean;
readonly options?: readonly Readonly<{
value: string | number;
label: string;
}>[];
}
export interface ContentCommandSubmission {
readonly commandId: string;
readonly input: Readonly<Record<string, unknown>>;
readonly fields?: readonly ContentCommandField[];
readonly requiresReason?: boolean;
}
interface ContentCommandFormProps extends ContentCommandSubmission {
readonly buttonLabel: string;
}
const OMIT_FIELD = Symbol("omit optional Content command field");
function parseField(field: ContentCommandField, formData: FormData): unknown {
const rawValue = formData.get(field.name);
if (field.type === "checkbox") {
if (field.allowUnchanged) {
if (rawValue === null || rawValue === "") return OMIT_FIELD;
if (rawValue === "true") return true;
if (rawValue === "false") return false;
return OMIT_FIELD;
}
return rawValue === "on";
}
if (rawValue === null && !field.required) return OMIT_FIELD;
if (field.type === "file") return rawValue instanceof File ? rawValue : null;
const raw = String(rawValue ?? "")
.normalize("NFC")
.trim();
if (!raw && !field.required) return OMIT_FIELD;
if (field.type === "number") {
if (!raw) return raw;
const value = Number(raw);
if (!Number.isSafeInteger(value)) return 0;
return Math.min(field.max ?? value, Math.max(field.min ?? value, value));
}
if (field.type === "select") {
const selected = field.options?.find(
(option) => String(option.value) === raw,
)?.value;
return selected ?? raw.slice(0, 500);
}
if (field.type === "json") {
try {
return JSON.parse(raw.slice(0, field.maxLength ?? 20_000));
} catch {
return null;
}
}
return raw.slice(
0,
field.maxLength ?? (field.type === "textarea" ? 20_000 : 500),
);
}
const initialState: HousekeepingResult<unknown> | null = null;
export async function submitContentCommandForm(
configuration: ContentCommandSubmission,
_previous: HousekeepingResult<unknown> | null,
formData: FormData,
): Promise<HousekeepingResult<unknown>> {
const submittedFields = (configuration.fields ?? []).flatMap((field) => {
const value = parseField(field, formData);
return value === OMIT_FIELD ? [] : [[field.name, value] as const];
});
const input = {
...configuration.input,
...Object.fromEntries(submittedFields),
};
const reason = String(formData.get("reason") ?? "")
.normalize("NFC")
.trim()
.slice(0, 1000);
const { executeHousekeepingCommand } = await import(
"@/actions/housekeeping-command"
);
return executeHousekeepingCommand({
commandId: configuration.commandId,
input,
...(configuration.requiresReason ? { reason } : {}),
});
}
export function ContentCommandForm({
commandId,
buttonLabel,
input,
fields = [],
requiresReason = false,
}: ContentCommandFormProps) {
const [result, submit, pending] = useActionState(
submitContentCommandForm.bind(null, {
commandId,
input,
fields,
requiresReason,
}),
initialState,
);
return (
<form
action={submit}
data-housekeeping-command={commandId}
className="space-y-3 rounded border border-[var(--admin-border)] p-3"
>
{fields.map((field) => (
<label
key={field.name}
htmlFor={`${commandId}-${field.name}`}
className="block text-sm"
>
{field.type === "checkbox" && field.allowUnchanged ? (
<>
{field.label}
<select
id={`${commandId}-${field.name}`}
name={field.name}
defaultValue=""
className="mt-1 block w-full"
>
<option value="">No change</option>
<option value="true">Enabled</option>
<option value="false">Disabled</option>
</select>
</>
) : field.type === "checkbox" ? (
<>
<input
id={`${commandId}-${field.name}`}
name={field.name}
type="checkbox"
defaultChecked={field.defaultValue === true}
/>{" "}
{field.label}
</>
) : field.type === "select" ? (
<>
{field.label}
<select
id={`${commandId}-${field.name}`}
name={field.name}
defaultValue={
typeof field.defaultValue === "boolean"
? undefined
: field.defaultValue
}
required={field.required}
className="mt-1 block w-full"
>
{field.required ? null : <option value="">No change</option>}
{field.options?.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
</>
) : field.type === "textarea" || field.type === "json" ? (
<>
{field.label}
<textarea
id={`${commandId}-${field.name}`}
name={field.name}
required={field.required}
maxLength={field.maxLength}
className="mt-1 block w-full"
/>
</>
) : (
<>
{field.label}
<input
id={`${commandId}-${field.name}`}
name={field.name}
type={
field.type === "number"
? "number"
: field.type === "file"
? "file"
: "text"
}
defaultValue={
field.type === "file" ||
typeof field.defaultValue === "boolean"
? undefined
: field.defaultValue
}
required={field.required}
min={field.min}
max={field.max}
maxLength={field.maxLength}
className="mt-1 block w-full"
/>
</>
)}
</label>
))}
{requiresReason ? (
<label htmlFor={`${commandId}-reason`} className="block text-sm">
Reason
<textarea
id={`${commandId}-reason`}
name="reason"
required
maxLength={1000}
className="mt-1 block w-full"
/>
</label>
) : null}
<button type="submit" disabled={pending}>
{pending ? "Working..." : buttonLabel}
</button>
{result ? (
<p role="status" className="text-xs text-[var(--admin-text-muted)]">
{result.ok ? "Completed" : "Failed"} ({result.correlationId})
</p>
) : null}
</form>
);
}
@@ -1,77 +0,0 @@
import type { ReactNode } from "react";
import type { HousekeepingResult } from "../../../foundation/contracts";
import type { ContentQueryData } from "../queries/content-queries";
export interface ContentPageProps {
readonly context: import("../../../foundation/contracts").HousekeepingCapabilityContext;
readonly result?: HousekeepingResult<ContentQueryData>;
readonly routeId: import("../routes").ContentRouteId | null;
}
export function ContentPageFrame({
title,
description,
result,
forms,
}: {
readonly title: string;
readonly description: string;
readonly result?: HousekeepingResult<ContentQueryData>;
readonly forms?: ReactNode;
}) {
if (!result) {
return (
<section data-housekeeping-state="loading">
<h1>{title}</h1>
<p>{description}</p>
<p>Loading content…</p>
</section>
);
}
if (!result.ok) {
const state = result.error.code === "FORBIDDEN" ? "forbidden" : "error";
return (
<section data-housekeeping-state={state} role="alert">
<h1>{title}</h1>
<p>{result.error.messageKey}</p>
</section>
);
}
const state = result.data.items.length === 0 ? "empty" : "ready";
return (
<section data-housekeeping-state={state} className="space-y-4">
<header>
<h1>{title}</h1>
<p>{description}</p>
</header>
{forms}
{result.data.items.length === 0 ? (
<p>No matching content.</p>
) : (
<ul className="divide-y divide-[var(--admin-border)]">
{result.data.items.map((item) => (
<li key={item.id} className="py-2">
{item.href ? <a href={item.href}>{item.title}</a> : item.title}
{item.status ? <span> — {item.status}</span> : null}
{item.description ? <p>{item.description}</p> : null}
</li>
))}
</ul>
)}
</section>
);
}
export function parseContentListInput(
searchParams: Readonly<
Record<string, string | readonly string[] | undefined>
>,
) {
const first = (value: string | readonly string[] | undefined) =>
Array.isArray(value) ? value[0] : value;
return {
search: first(searchParams.search),
pageSize: Number(first(searchParams.pageSize) ?? 25),
offset: Number(first(searchParams.offset) ?? 0),
};
}
@@ -1,410 +0,0 @@
import { renderToStaticMarkup } from "react-dom/server";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { executeHousekeepingCommand } from "@/actions/housekeeping-command";
import { PERMS } from "@/lib/permission-slugs";
import {
fail,
type HousekeepingCapabilityContext,
ok,
} from "../../../foundation/contracts";
import { ContentBrandPage } from "./brand";
import { submitContentCommandForm } from "./content-command-form";
import { ContentEditorialPage } from "./editorial";
import { ContentEngagementPage } from "./engagement";
import { ContentHelpPage } from "./help";
import { ContentLocalizationPage } from "./localization";
import { ContentMediaPage } from "./media";
vi.mock("@/actions/housekeeping-command", () => ({
executeHousekeepingCommand: vi.fn(),
}));
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)),
};
}
const cases = [
["editorial", ContentEditorialPage, PERMS.NEWS_EDIT],
["media", ContentMediaPage, PERMS.PAGES_EDIT],
["engagement", ContentEngagementPage, PERMS.EVENTS_EDIT],
["help", ContentHelpPage, PERMS.PAGES_EDIT],
["brand", ContentBrandPage, PERMS.SETTINGS_EDIT],
["localization", ContentLocalizationPage, PERMS.SETTINGS_EDIT],
] as const;
describe.each(cases)("Content %s page", (kind, Component, editPermission) => {
it("renders loading, forbidden, empty, and real ready states", () => {
const read = context(Object.values(PERMS));
const render = (result?: unknown) =>
renderToStaticMarkup(
<Component context={read} result={result as never} routeId={null} />,
);
expect(render()).toContain('data-housekeeping-state="loading"');
expect(
render(fail("FORBIDDEN", "errors.housekeeping.forbidden", "forbidden")),
).toContain('data-housekeeping-state="forbidden"');
expect(
render(
ok({ kind, items: [], total: 0, partialDependencies: [] }, "empty"),
),
).toContain('data-housekeeping-state="empty"');
const ready = render(
ok(
{
kind,
items: [
{
id: "18446744073709551615",
title: "Canonical item",
status: "ready",
href: `/ase/content/${kind}`,
},
],
total: 1,
partialDependencies: [],
},
"ready",
),
);
expect(ready).toContain('data-housekeeping-state="ready"');
expect(ready).toContain("Canonical item");
expect(ready).not.toContain("/admin");
expect(read.has(editPermission)).toBe(true);
});
});
describe("Content actionable form wiring", () => {
beforeEach(() => vi.clearAllMocks());
it("submits canonical identifiers and a bounded reason through the real server action", async () => {
vi.mocked(executeHousekeepingCommand).mockResolvedValue(
ok({ before: null, after: { id: "18446744073709551615" } }, "form"),
);
const formData = new FormData();
formData.set("id", "18446744073709551615");
formData.set("title", " Updated title ");
formData.set("reason", ` ${"r".repeat(1100)} `);
const result = await submitContentCommandForm(
{
commandId: "content.editorial.article.change",
input: { action: "update" },
fields: [
{ name: "id", label: "ID", type: "identifier" },
{ name: "title", label: "Title", type: "text", maxLength: 255 },
],
requiresReason: true,
},
null,
formData,
);
expect(executeHousekeepingCommand).toHaveBeenCalledWith({
commandId: "content.editorial.article.change",
input: {
action: "update",
id: "18446744073709551615",
title: "Updated title",
},
reason: "r".repeat(1000),
});
expect(result).toMatchObject({ ok: true });
});
it("parses structured JSON fields before dispatching real brand and localization forms", async () => {
vi.mocked(executeHousekeepingCommand).mockResolvedValue(
ok({ before: null, after: { keyCount: 1 } }, "json-form"),
);
const formData = new FormData();
formData.set("data", '{ "welcome": "Hello" }');
await submitContentCommandForm(
{
commandId: "content.localization.cms.save",
input: { locale: "en" },
fields: [
{
name: "data",
label: "Translations",
type: "json",
maxLength: 500_000,
},
],
requiresReason: true,
},
null,
formData,
);
expect(executeHousekeepingCommand).toHaveBeenCalledWith({
commandId: "content.localization.cms.save",
input: { locale: "en", data: { welcome: "Hello" } },
reason: "",
});
});
it("omits untouched optional text and tri-state checkbox fields during updates", async () => {
vi.mocked(executeHousekeepingCommand).mockResolvedValue(
ok({ before: null, after: { id: "7" } }, "partial-form"),
);
const formData = new FormData();
formData.set("id", "7");
formData.set("title", "Renamed");
formData.set("image", "");
await submitContentCommandForm(
{
commandId: "content.media.banner.change",
input: { action: "update" },
fields: [
{ name: "id", label: "ID", type: "identifier", required: true },
{ name: "title", label: "Title", type: "text" },
{ name: "image", label: "Image", type: "text" },
{
name: "isActive",
label: "Active",
type: "checkbox",
allowUnchanged: true,
},
],
},
null,
formData,
);
expect(executeHousekeepingCommand).toHaveBeenCalledWith({
commandId: "content.media.banner.change",
input: {
action: "update",
id: "7",
title: "Renamed",
},
});
});
it.each([
["false", false],
["true", true],
] as const)(
"submits an explicit tri-state checkbox value %s as %s",
async (submitted, expected) => {
vi.mocked(executeHousekeepingCommand).mockResolvedValue(
ok({ before: null, after: { id: "7" } }, "explicit-checkbox"),
);
const formData = new FormData();
formData.set("id", "7");
formData.set("isActive", submitted);
await submitContentCommandForm(
{
commandId: "content.engagement.event-type.change",
input: { action: "update" },
fields: [
{ name: "id", label: "ID", type: "identifier", required: true },
{
name: "isActive",
label: "Active",
type: "checkbox",
allowUnchanged: true,
},
],
},
null,
formData,
);
expect(executeHousekeepingCommand).toHaveBeenCalledWith({
commandId: "content.engagement.event-type.change",
input: { action: "update", id: "7", isActive: expected },
});
},
);
it("renders poll creation with show-results checked by default", () => {
const html = renderToStaticMarkup(
<ContentEngagementPage
context={context([PERMS.POLLS_EDIT])}
result={ok(
{ kind: "engagement", items: [], total: 0, partialDependencies: [] },
"poll-create-checkbox",
)}
routeId="content.engagement.poll-create"
/>,
);
expect(html).toMatch(/name="showResults"[^>]*checked=""/u);
const multipleChoice = html.match(
/<input[^>]*name="multipleChoice"[^>]*>/u,
)?.[0];
expect(multipleChoice).toBeDefined();
expect(multipleChoice).not.toContain("checked");
});
it("renders poll updates with explicit unchanged, enabled, and disabled choices", () => {
const html = renderToStaticMarkup(
<ContentEngagementPage
context={context([PERMS.POLLS_EDIT])}
result={ok(
{ kind: "engagement", items: [], total: 0, partialDependencies: [] },
"poll-update-checkbox",
)}
routeId="content.engagement.poll-detail"
/>,
);
expect(html).toMatch(/<select[^>]*name="showResults"/u);
expect(html).toContain("No change");
expect(html).toContain('value="true"');
expect(html).toContain('value="false"');
});
it("renders mutation forms only with the exact capability", () => {
const result = ok(
{
kind: "brand" as const,
items: [{ id: "theme", title: "Theme", status: "active" }],
total: 1,
partialDependencies: [],
},
"brand",
);
const readOnly = renderToStaticMarkup(
<ContentBrandPage
context={context([PERMS.SETTINGS_VIEW])}
result={result}
routeId="content.brand.theme"
/>,
);
const editor = renderToStaticMarkup(
<ContentBrandPage
context={context([PERMS.SETTINGS_VIEW, PERMS.SETTINGS_EDIT])}
result={result}
routeId="content.brand.theme"
/>,
);
expect(readOnly).not.toContain("content.brand.theme.update");
expect(editor).toContain("content.brand.theme.update");
expect(editor).toContain("<form");
});
it("does not render email template bodies or localization payloads in summaries", () => {
const html = renderToStaticMarkup(
<ContentHelpPage
context={context([PERMS.PAGES_VIEW])}
result={ok(
{
kind: "help",
items: [
{
id: "1",
title: "Welcome",
status: "active",
privatePayload: "secret template body",
},
],
total: 1,
partialDependencies: [],
},
"help",
)}
routeId="content.help.email-templates"
/>,
);
expect(html).toContain("Welcome");
expect(html).not.toContain("secret template body");
});
it("renders create-event fields required by the production validator", () => {
const html = renderToStaticMarkup(
<ContentEngagementPage
context={context([PERMS.EVENTS_EDIT])}
result={ok(
{ kind: "engagement", items: [], total: 0, partialDependencies: [] },
"event-form",
)}
routeId="content.engagement.event-create"
/>,
);
for (const name of ["title", "description", "typeId", "startsAt"]) {
expect(html).toContain(`name="${name}"`);
}
});
it("renders validator-shaped prize, question, and prefix inputs", () => {
const eventHtml = renderToStaticMarkup(
<ContentEngagementPage
context={context([PERMS.EVENTS_EDIT])}
result={ok(
{ kind: "engagement", items: [], total: 0, partialDependencies: [] },
"event-prize-form",
)}
routeId="content.engagement.event-detail"
/>,
);
for (const name of [
"position",
"prizeType",
"badgeCode",
"credits",
"pixels",
"points",
"itemId",
"description",
]) {
expect(eventHtml).toContain(`name="${name}"`);
}
expect(eventHtml).not.toContain('name="prize"');
const pollHtml = renderToStaticMarkup(
<ContentEngagementPage
context={context([PERMS.POLLS_EDIT])}
result={ok(
{ kind: "engagement", items: [], total: 0, partialDependencies: [] },
"poll-question-form",
)}
routeId="content.engagement.poll-detail"
/>,
);
expect(pollHtml).toContain('name="options"');
const prefixHtml = renderToStaticMarkup(
<ContentEngagementPage
context={context([PERMS.PREFIXES_EDIT])}
result={ok(
{ kind: "engagement", items: [], total: 0, partialDependencies: [] },
"prefix-form",
)}
routeId="content.engagement.prefixes"
/>,
);
expect(prefixHtml).toContain('name="color"');
});
it("matches emulator setting fields to the database column bounds", () => {
const html = renderToStaticMarkup(
<ContentLocalizationPage
context={context([PERMS.SETTINGS_EDIT])}
result={ok(
{
kind: "localization",
items: [],
total: 0,
partialDependencies: [],
},
"emulator-form",
)}
routeId="content.localization.emulator"
/>,
);
expect(html).toContain('name="key"');
expect(html).toContain('maxLength="100"');
expect(html).toContain('name="value"');
expect(html).toContain('maxLength="512"');
});
});
@@ -1,111 +0,0 @@
import { PERMS } from "@/lib/permission-slugs";
import type { HousekeepingPageInput } from "../../../route-handlers";
import { contentQuery } from "../queries/content-queries";
import { ContentCommandForm } from "./content-command-form";
import {
ContentPageFrame,
type ContentPageProps,
parseContentListInput,
} from "./content-page-frame";
export function ContentEditorialPage({
context,
result,
routeId,
}: ContentPageProps) {
const canNews = context.has(PERMS.NEWS_EDIT);
const canPages = context.has(PERMS.PAGES_EDIT);
return (
<ContentPageFrame
title="Editorial content"
description="Manage articles, navigation, tags, and writable boxes."
result={result}
forms={
<div className="grid gap-3 lg:grid-cols-2">
{canNews && routeId?.includes("article") ? (
<ContentCommandForm
commandId="content.editorial.article.change"
buttonLabel="Save article"
input={{
action: routeId.endsWith("create") ? "create" : "update",
}}
fields={[
{ name: "id", label: "Article ID", type: "identifier" },
{
name: "title",
label: "Title",
type: "text",
required: true,
maxLength: 255,
},
{
name: "content",
label: "Body",
type: "textarea",
required: true,
maxLength: 100_000,
},
]}
/>
) : null}
{canPages && routeId === "content.editorial.tags" ? (
<ContentCommandForm
commandId="content.editorial.tag.change"
buttonLabel="Save tag"
input={{ action: "update" }}
fields={[
{ name: "id", label: "Tag ID", type: "identifier" },
{
name: "name",
label: "Name",
type: "text",
required: true,
maxLength: 100,
},
]}
/>
) : null}
{canPages && routeId === "content.editorial.writeable-boxes" ? (
<ContentCommandForm
commandId="content.editorial.writeable-box.change"
buttonLabel="Save box"
input={{ action: "update" }}
fields={[
{ name: "id", label: "Box ID", type: "identifier" },
{
name: "title",
label: "Title",
type: "text",
required: true,
maxLength: 255,
},
{
name: "content",
label: "Content",
type: "textarea",
maxLength: 20_000,
},
]}
/>
) : null}
</div>
}
/>
);
}
export async function renderContentEditorialPage(input: HousekeepingPageInput) {
const routeId = input.match.routeId as ContentPageProps["routeId"];
const result = await contentQuery.run(input.context, {
routeId: routeId ?? "content.editorial.articles",
params: input.match.params,
list: parseContentListInput(input.searchParams ?? {}),
});
return (
<ContentEditorialPage
context={input.context}
result={result}
routeId={routeId}
/>
);
}
@@ -1,429 +0,0 @@
import { PERMS } from "@/lib/permission-slugs";
import type { HousekeepingPageInput } from "../../../route-handlers";
import { contentQuery } from "../queries/content-queries";
import {
type ContentCommandField,
ContentCommandForm,
} from "./content-command-form";
import {
ContentPageFrame,
type ContentPageProps,
parseContentListInput,
} from "./content-page-frame";
export function ContentEngagementPage({
context,
result,
routeId,
}: ContentPageProps) {
const canEvents = context.has(PERMS.EVENTS_EDIT);
const canPolls = context.has(PERMS.POLLS_EDIT);
const canPrefixes = context.has(PERMS.PREFIXES_EDIT);
const creatingEvent = routeId === "content.engagement.event-create";
const creatingPoll = routeId === "content.engagement.poll-create";
const eventFields: readonly ContentCommandField[] = [
{
name: "id",
label: "Event ID",
type: "identifier",
required: !creatingEvent,
},
{
name: "title",
label: "Title",
type: "text",
required: creatingEvent,
maxLength: 255,
},
{
name: "description",
label: "Description",
type: "textarea",
required: creatingEvent,
maxLength: 20_000,
},
{
name: "typeId",
label: "Event type ID",
type: "identifier",
required: creatingEvent,
},
{ name: "roomId", label: "Room ID", type: "identifier" },
{
name: "startsAt",
label: "Starts at",
type: "text",
required: creatingEvent,
maxLength: 50,
},
{ name: "endsAt", label: "Ends at", type: "text", maxLength: 50 },
{ name: "maxPlayers", label: "Maximum players", type: "number", min: 1 },
{
name: "isRecurring",
label: "Recurring",
type: "checkbox",
allowUnchanged: !creatingEvent,
},
{
name: "recurrenceRule",
label: "Recurrence rule",
type: "text",
maxLength: 255,
},
{
name: "status",
label: "Status",
type: "select",
options: [
{ value: "draft", label: "Draft" },
{ value: "published", label: "Published" },
{ value: "cancelled", label: "Cancelled" },
{ value: "completed", label: "Completed" },
],
},
{ name: "image", label: "Image URL", type: "text", maxLength: 500 },
];
const pollFields: readonly ContentCommandField[] = [
{
name: "id",
label: "Poll ID",
type: "identifier",
required: !creatingPoll,
},
{
name: "title",
label: "Title",
type: "text",
required: creatingPoll,
maxLength: 255,
},
{
name: "description",
label: "Description",
type: "textarea",
maxLength: 2_000,
},
{
name: "status",
label: "Status",
type: "select",
options: [
{ value: "draft", label: "Draft" },
{ value: "active", label: "Active" },
{ value: "closed", label: "Closed" },
],
},
{
name: "showResults",
label: "Show results",
type: "checkbox",
defaultValue: creatingPoll,
allowUnchanged: !creatingPoll,
},
{
name: "multipleChoice",
label: "Allow multiple choices",
type: "checkbox",
allowUnchanged: !creatingPoll,
},
{ name: "startsAt", label: "Starts at", type: "text", maxLength: 50 },
{ name: "endsAt", label: "Ends at", type: "text", maxLength: 50 },
];
return (
<ContentPageFrame
title="Engagement"
description="Manage events, polls, and community prefixes."
result={result}
forms={
<div className="grid gap-3 lg:grid-cols-2">
{canEvents && routeId === "content.engagement.event-types" ? (
<ContentCommandForm
commandId="content.engagement.event-type.change"
buttonLabel="Save event type"
input={{ action: "update" }}
fields={[
{
name: "id",
label: "Type ID",
type: "identifier",
required: true,
},
{ name: "name", label: "Name", type: "text", maxLength: 100 },
{ name: "slug", label: "Slug", type: "text", maxLength: 100 },
{
name: "description",
label: "Description",
type: "textarea",
maxLength: 500,
},
{ name: "color", label: "Color", type: "text", maxLength: 20 },
{ name: "icon", label: "Icon", type: "text", maxLength: 50 },
{
name: "isActive",
label: "Active",
type: "checkbox",
allowUnchanged: true,
},
{
name: "minRank",
label: "Minimum rank",
type: "number",
min: 0,
max: 7,
},
]}
/>
) : null}
{canEvents &&
routeId?.includes("event") &&
routeId !== "content.engagement.event-types" ? (
<>
<ContentCommandForm
commandId="content.engagement.event.change"
buttonLabel="Save event"
input={{ action: creatingEvent ? "create" : "update" }}
fields={eventFields}
/>
<ContentCommandForm
commandId="content.engagement.event-prize.change"
buttonLabel="Save prize"
input={{ action: "create" }}
fields={[
{
name: "eventId",
label: "Event ID",
type: "identifier",
required: true,
},
{
name: "position",
label: "Position",
type: "number",
min: 1,
defaultValue: 1,
},
{
name: "prizeType",
label: "Prize type",
type: "select",
options: [
{ value: "badge", label: "Badge" },
{ value: "credits", label: "Credits" },
{ value: "pixels", label: "Pixels" },
{ value: "points", label: "Points" },
{ value: "item", label: "Item" },
],
},
{
name: "badgeCode",
label: "Badge code",
type: "text",
maxLength: 50,
},
{
name: "credits",
label: "Credits",
type: "number",
min: 0,
defaultValue: 0,
},
{
name: "pixels",
label: "Pixels",
type: "number",
min: 0,
defaultValue: 0,
},
{
name: "points",
label: "Points",
type: "number",
min: 0,
defaultValue: 0,
},
{ name: "itemId", label: "Item ID", type: "identifier" },
{
name: "description",
label: "Description",
type: "text",
maxLength: 255,
},
]}
/>
<ContentCommandForm
commandId="content.engagement.event-winner.add"
buttonLabel="Add winner"
input={{}}
fields={[
{
name: "eventId",
label: "Event ID",
type: "identifier",
required: true,
},
{
name: "userId",
label: "User ID",
type: "identifier",
required: true,
},
{
name: "position",
label: "Position",
type: "number",
min: 1,
defaultValue: 1,
},
]}
/>
</>
) : null}
{canPolls && routeId?.includes("poll") ? (
<>
<ContentCommandForm
commandId="content.engagement.poll.change"
buttonLabel="Save poll"
input={{ action: creatingPoll ? "create" : "update" }}
fields={pollFields}
/>
<ContentCommandForm
commandId="content.engagement.poll-question.change"
buttonLabel="Save question"
input={{ action: "create" }}
fields={[
{
name: "pollId",
label: "Poll ID",
type: "identifier",
required: true,
},
{
name: "question",
label: "Question",
type: "text",
required: true,
maxLength: 500,
},
{
name: "type",
label: "Question type",
type: "select",
options: [
{ value: "single", label: "Single choice" },
{ value: "multiple", label: "Multiple choice" },
{ value: "text", label: "Free text" },
],
},
{
name: "sortOrder",
label: "Sort order",
type: "number",
min: 0,
defaultValue: 0,
},
{
name: "options",
label: "Options",
type: "textarea",
required: true,
maxLength: 20_000,
},
]}
/>
</>
) : null}
{canPrefixes && routeId === "content.engagement.prefixes" ? (
<>
<ContentCommandForm
commandId="content.engagement.prefix.change"
buttonLabel="Save prefix"
input={{ action: "update" }}
fields={[
{
name: "id",
label: "Prefix ID",
type: "identifier",
required: true,
},
{
name: "text",
label: "Text",
type: "text",
required: true,
maxLength: 255,
},
{
name: "color",
label: "Color",
type: "text",
required: true,
maxLength: 32,
},
{ name: "icon", label: "Icon", type: "text", maxLength: 255 },
{
name: "effect",
label: "Effect",
type: "text",
maxLength: 255,
},
{
name: "active",
label: "Active",
type: "checkbox",
allowUnchanged: true,
},
]}
/>
<ContentCommandForm
commandId="content.engagement.prefix-blacklist.change"
buttonLabel="Update blacklist"
input={{ action: "add" }}
fields={[
{
name: "word",
label: "Word",
type: "text",
required: true,
maxLength: 255,
},
]}
/>
<ContentCommandForm
commandId="content.engagement.prefix-settings.update"
buttonLabel="Save prefix settings"
input={{}}
fields={[
{
name: "settings",
label: "Prefix settings JSON",
type: "json",
required: true,
maxLength: 20_000,
},
]}
/>
</>
) : null}
</div>
}
/>
);
}
export async function renderContentEngagementPage(
input: HousekeepingPageInput,
) {
const routeId = input.match.routeId as ContentPageProps["routeId"];
const result = await contentQuery.run(input.context, {
routeId: routeId ?? "content.engagement.events",
params: input.match.params,
list: parseContentListInput(input.searchParams ?? {}),
});
return (
<ContentEngagementPage
context={input.context}
result={result}
routeId={routeId}
/>
);
}
@@ -1,97 +0,0 @@
import { PERMS } from "@/lib/permission-slugs";
import type { HousekeepingPageInput } from "../../../route-handlers";
import { contentQuery } from "../queries/content-queries";
import { ContentCommandForm } from "./content-command-form";
import {
ContentPageFrame,
type ContentPageProps,
parseContentListInput,
} from "./content-page-frame";
export function ContentHelpPage({
context,
result,
routeId,
}: ContentPageProps) {
const forms = context.has(PERMS.PAGES_EDIT) ? (
<div className="grid gap-3 lg:grid-cols-2">
{routeId?.includes("question") ? (
<ContentCommandForm
commandId="content.help.question.change"
buttonLabel="Save help question"
input={{ action: routeId.endsWith("create") ? "create" : "update" }}
fields={[
{ name: "id", label: "Question ID", type: "identifier" },
{
name: "name",
label: "Question",
type: "text",
required: true,
maxLength: 255,
},
{
name: "answer",
label: "Answer",
type: "textarea",
required: true,
maxLength: 20_000,
},
]}
/>
) : null}
{routeId === "content.help.email-templates" ? (
<ContentCommandForm
commandId="content.help.email-template.change"
buttonLabel="Save email template"
input={{ action: "update" }}
fields={[
{
name: "id",
label: "Template ID",
type: "identifier",
required: true,
},
{
name: "subject",
label: "Subject",
type: "text",
required: true,
maxLength: 255,
},
{
name: "body",
label: "Body",
type: "textarea",
required: true,
maxLength: 100_000,
},
]}
/>
) : null}
</div>
) : null;
return (
<ContentPageFrame
title="Help content"
description="Manage help questions and email templates."
result={result}
forms={forms}
/>
);
}
export async function renderContentHelpPage(input: HousekeepingPageInput) {
const routeId = input.match.routeId as ContentPageProps["routeId"];
const result = await contentQuery.run(input.context, {
routeId: routeId ?? "content.help.questions",
params: input.match.params,
list: parseContentListInput(input.searchParams ?? {}),
});
return (
<ContentHelpPage
context={input.context}
result={result}
routeId={routeId}
/>
);
}
@@ -1,118 +0,0 @@
import { PERMS } from "@/lib/permission-slugs";
import type { HousekeepingPageInput } from "../../../route-handlers";
import { contentQuery } from "../queries/content-queries";
import { ContentCommandForm } from "./content-command-form";
import {
ContentPageFrame,
type ContentPageProps,
parseContentListInput,
} from "./content-page-frame";
export function ContentLocalizationPage({
context,
result,
routeId,
}: ContentPageProps) {
const forms = context.has(PERMS.SETTINGS_EDIT) ? (
<div className="grid gap-3 lg:grid-cols-2">
{routeId === "content.localization.cms" ? (
<ContentCommandForm
commandId="content.localization.cms.save"
buttonLabel="Save CMS translations"
input={{}}
fields={[
{
name: "locale",
label: "Locale",
type: "text",
required: true,
maxLength: 16,
},
{
name: "data",
label: "Translation JSON",
type: "json",
required: true,
maxLength: 500_000,
},
]}
requiresReason
/>
) : null}
{routeId === "content.localization.client" ? (
<ContentCommandForm
commandId="content.localization.client.save"
buttonLabel="Save client translations"
input={{}}
fields={[
{
name: "fileId",
label: "Translation file",
type: "text",
required: true,
maxLength: 100,
},
{
name: "data",
label: "Translation JSON",
type: "json",
required: true,
maxLength: 500_000,
},
]}
requiresReason
/>
) : null}
{routeId === "content.localization.emulator" ? (
<ContentCommandForm
commandId="content.localization.emulator.save"
buttonLabel="Save emulator translation"
input={{}}
fields={[
{
name: "key",
label: "Key",
type: "text",
required: true,
maxLength: 100,
},
{
name: "value",
label: "Value",
type: "textarea",
required: true,
maxLength: 512,
},
]}
requiresReason
/>
) : null}
</div>
) : null;
return (
<ContentPageFrame
title="Localization"
description="Manage CMS, client, and emulator translation stores."
result={result}
forms={forms}
/>
);
}
export async function renderContentLocalizationPage(
input: HousekeepingPageInput,
) {
const routeId = input.match.routeId as ContentPageProps["routeId"];
const result = await contentQuery.run(input.context, {
routeId: routeId ?? "content.localization.overview",
params: input.match.params,
list: parseContentListInput(input.searchParams ?? {}),
});
return (
<ContentLocalizationPage
context={input.context}
result={result}
routeId={routeId}
/>
);
}
@@ -1,127 +0,0 @@
import { PERMS } from "@/lib/permission-slugs";
import type { HousekeepingPageInput } from "../../../route-handlers";
import { contentQuery } from "../queries/content-queries";
import { ContentCommandForm } from "./content-command-form";
import {
ContentPageFrame,
type ContentPageProps,
parseContentListInput,
} from "./content-page-frame";
export function ContentMediaPage({
context,
result,
routeId,
}: ContentPageProps) {
const canPages = context.has(PERMS.PAGES_EDIT);
const canBanners = context.has(PERMS.BANNERS_EDIT);
return (
<ContentPageFrame
title="Media"
description="Manage photos, uploaded media, banners, and advertisements."
result={result}
forms={
<div className="grid gap-3 lg:grid-cols-2">
{canPages && routeId === "content.media.photos" ? (
<ContentCommandForm
commandId="content.media.photo.delete"
buttonLabel="Delete photo"
input={{}}
fields={[
{
name: "id",
label: "Photo ID",
type: "identifier",
required: true,
},
]}
/>
) : null}
{canPages && routeId === "content.media.library" ? (
<>
<ContentCommandForm
commandId="content.media.asset.upload"
buttonLabel="Upload media"
input={{}}
fields={[
{
name: "file",
label: "Media file",
type: "file",
required: true,
},
]}
/>
<ContentCommandForm
commandId="content.media.asset.delete"
buttonLabel="Delete media"
input={{}}
fields={[
{
name: "filename",
label: "Filename",
type: "text",
required: true,
maxLength: 255,
},
]}
/>
</>
) : null}
{canBanners && routeId === "content.media.banners" ? (
<ContentCommandForm
commandId="content.media.banner.change"
buttonLabel="Save banner"
input={{ action: "update" }}
fields={[
{ name: "id", label: "Banner ID", type: "identifier" },
{ name: "title", label: "Title", type: "text", maxLength: 255 },
]}
/>
) : null}
{canPages && routeId?.includes("ad") ? (
<ContentCommandForm
commandId="content.media.ad.change"
buttonLabel="Save advertisement"
input={{
action: routeId.endsWith("create") ? "create" : "update",
}}
fields={[
{ name: "id", label: "Advertisement ID", type: "identifier" },
{
name: "image",
label: "Image path",
type: "text",
required: true,
maxLength: 500,
},
{
name: "url",
label: "Destination",
type: "text",
maxLength: 1000,
},
]}
/>
) : null}
</div>
}
/>
);
}
export async function renderContentMediaPage(input: HousekeepingPageInput) {
const routeId = input.match.routeId as ContentPageProps["routeId"];
const result = await contentQuery.run(input.context, {
routeId: routeId ?? "content.media.photos",
params: input.match.params,
list: parseContentListInput(input.searchParams ?? {}),
});
return (
<ContentMediaPage
context={input.context}
result={result}
routeId={routeId}
/>
);
}
@@ -1,418 +0,0 @@
import "server-only";
import type { SQL } from "drizzle-orm";
import { type ContentRouteId, contentRouteGroup } from "../routes";
import type {
ContentQueryData,
ContentQueryItem,
NormalizedContentQueryInput,
} from "./content-queries";
type RawRow = Readonly<Record<string, unknown>>;
interface QueryDefinition {
readonly statement: string;
readonly href: string;
readonly appendId?: boolean;
}
export const CONTENT_QUERY_DEFINITIONS = {
"content.editorial.articles": {
statement:
"SELECT id, title, slug AS description, 'published' AS status, updated_at FROM website_articles ORDER BY created_at DESC",
href: "/ase/content/editorial/articles/",
appendId: true,
},
"content.editorial.article-detail": {
statement:
"SELECT id, title, slug AS description, 'published' AS status, updated_at FROM website_articles ORDER BY created_at DESC",
href: "/ase/content/editorial/articles/",
appendId: true,
},
"content.editorial.tags": {
statement:
"SELECT id, name AS title, background_color AS status, updated_at FROM tags ORDER BY name",
href: "/ase/content/editorial/tags",
},
"content.editorial.writeable-boxes": {
statement:
"SELECT id, title, CASE WHEN is_active = 1 THEN 'active' ELSE 'hidden' END AS status, updated_at FROM website_writeable_boxes ORDER BY position, id",
href: "/ase/content/editorial/writeable-boxes",
},
"content.media.photos": {
statement:
"SELECT id, url AS title, 'published' AS status, FROM_UNIXTIME(timestamp) AS updated_at FROM camera_web ORDER BY id DESC",
href: "/ase/content/media/photos",
},
"content.media.banners": {
statement:
"SELECT id, title, CASE WHEN is_active = 1 THEN 'active' ELSE 'hidden' END AS status, NULL AS updated_at FROM website_banners ORDER BY sort_order, id",
href: "/ase/content/media/banners",
},
"content.media.ads": {
statement:
"SELECT id, image AS title, 'active' AS status, updated_at FROM website_ads ORDER BY created_at DESC",
href: "/ase/content/media/ads/",
appendId: true,
},
"content.media.ad-detail": {
statement:
"SELECT id, image AS title, 'active' AS status, updated_at FROM website_ads ORDER BY created_at DESC",
href: "/ase/content/media/ads/",
appendId: true,
},
"content.engagement.events": {
statement:
"SELECT id, title, status, updated_at FROM website_events ORDER BY starts_at DESC",
href: "/ase/content/engagement/events/",
appendId: true,
},
"content.engagement.event-detail": {
statement:
"SELECT id, title, status, updated_at FROM website_events ORDER BY starts_at DESC",
href: "/ase/content/engagement/events/",
appendId: true,
},
"content.engagement.event-types": {
statement:
"SELECT id, name AS title, CASE WHEN is_active = 1 THEN 'active' ELSE 'inactive' END AS status, NULL AS updated_at FROM website_event_types ORDER BY name",
href: "/ase/content/engagement/events/types",
},
"content.engagement.polls": {
statement:
"SELECT id, title, status, updated_at FROM website_polls ORDER BY created_at DESC",
href: "/ase/content/engagement/polls/",
appendId: true,
},
"content.engagement.poll-detail": {
statement:
"SELECT id, title, status, updated_at FROM website_polls ORDER BY created_at DESC",
href: "/ase/content/engagement/polls/",
appendId: true,
},
"content.engagement.prefixes": {
statement:
"SELECT id, text AS title, color AS description, CASE WHEN active = 1 THEN 'active' ELSE 'inactive' END AS status, NULL AS updated_at FROM custom_prefixes ORDER BY id DESC",
href: "/ase/content/engagement/prefixes",
},
"content.help.questions": {
statement:
"SELECT id, name AS title, CONCAT('position-', position) AS status, NULL AS updated_at FROM website_help_center_categories ORDER BY position, id",
href: "/ase/content/help/questions/",
appendId: true,
},
"content.help.question-detail": {
statement:
"SELECT id, name AS title, CONCAT('position-', position) AS status, NULL AS updated_at FROM website_help_center_categories ORDER BY position, id",
href: "/ase/content/help/questions/",
appendId: true,
},
"content.help.email-templates": {
statement:
"SELECT id, name AS title, subject AS description, CASE WHEN is_active = 1 THEN 'active' ELSE 'inactive' END AS status, updated_at FROM email_templates ORDER BY name",
href: "/ase/content/help/email-templates",
},
"content.localization.emulator": {
statement:
"SELECT emulator_settings.key AS id, emulator_settings.key AS title, LEFT(emulator_settings.value, 120) AS description, 'configured' AS status, NULL AS updated_at FROM emulator_settings ORDER BY emulator_settings.key",
href: "/ase/content/localization/emulator",
},
} as const satisfies Partial<Record<ContentRouteId, QueryDefinition>>;
const EMPTY_ROUTES = new Set<ContentRouteId>([
"content.editorial.article-create",
"content.media.ad-create",
"content.engagement.event-create",
"content.engagement.poll-create",
"content.help.question-create",
]);
function rows(result: unknown): readonly RawRow[] {
if (!Array.isArray(result) || !Array.isArray(result[0])) {
throw new Error("invalid Content query result");
}
return result[0] as readonly RawRow[];
}
function value(value: unknown, fallback = ""): string {
return typeof value === "string"
? value
: value == null
? fallback
: String(value);
}
function updatedAt(value: unknown): string | null {
if (value == null) return null;
const parsed = value instanceof Date ? value : new Date(String(value));
return Number.isFinite(parsed.getTime()) ? parsed.toISOString() : null;
}
function response(
input: NormalizedContentQueryInput,
items: readonly ContentQueryItem[],
total = items.length,
): ContentQueryData {
const start = input.list.offset;
return {
kind: contentRouteGroup(input.routeId),
items: items.slice(start, start + input.list.pageSize),
total,
partialDependencies: [],
};
}
function matchesListSearch(
input: NormalizedContentQueryInput,
item: ContentQueryItem,
): boolean {
const needle = input.list.search.toLocaleLowerCase();
return (
needle.length === 0 ||
item.title.toLocaleLowerCase().includes(needle) ||
item.description?.toLocaleLowerCase().includes(needle) === true
);
}
function mapRows(
definition: QueryDefinition,
rawRows: readonly RawRow[],
): readonly ContentQueryItem[] {
return rawRows.map((row) => {
const id = value(row.id);
if (!id) throw new Error("invalid Content identifier");
return {
id,
title: value(row.title, "Untitled content"),
description: value(row.description) || undefined,
status: value(row.status) || undefined,
updatedAt: updatedAt(row.updated_at),
href: definition.appendId ? definition.href + id : definition.href,
};
});
}
function statementParts(statement: string): {
readonly selection: string;
readonly ordering: string;
} {
const match = /^(.*?)(\s+ORDER BY\s+.+)$/iu.exec(statement);
return match
? { selection: match[1], ordering: match[2] }
: { selection: statement, ordering: "" };
}
function totalFromRows(rawRows: readonly RawRow[]): number {
const total = Number(rawRows[0]?.total ?? 0);
if (!Number.isSafeInteger(total) || total < 0)
throw new Error("invalid Content query total");
return total;
}
async function databaseRoute(
input: NormalizedContentQueryInput,
definition: QueryDefinition,
): Promise<ContentQueryData> {
const [{ sql }, { db }] = await Promise.all([
import("drizzle-orm"),
import("@/lib/db"),
]);
const { selection, ordering } = statementParts(definition.statement);
const filters: SQL[] = [];
if (input.list.search) {
const pattern = `%${input.list.search.toLocaleLowerCase()}%`;
filters.push(
/\bAS\s+description\b/iu.test(selection)
? sql`(LOWER(COALESCE(title, '')) LIKE ${pattern} OR LOWER(COALESCE(description, '')) LIKE ${pattern})`
: sql`LOWER(COALESCE(title, '')) LIKE ${pattern}`,
);
}
if (input.params.id) filters.push(sql`CAST(id AS CHAR) = ${input.params.id}`);
const filtered = filters.length
? sql`${sql.raw(selection)} HAVING ${sql.join(filters, sql` AND `)}`
: sql.raw(selection);
const total = totalFromRows(
rows(
await db.execute(
sql`SELECT COUNT(*) AS total FROM (${filtered}) AS content_rows`,
),
),
);
const items = mapRows(
definition,
rows(
await db.execute(
sql`${filtered} ${sql.raw(ordering)} LIMIT ${input.list.pageSize} OFFSET ${input.list.offset}`,
),
),
);
return {
kind: contentRouteGroup(input.routeId),
items,
total,
partialDependencies: [],
};
}
async function mediaLibrary(
input: NormalizedContentQueryInput,
): Promise<ContentQueryData> {
const [{ readdir, stat }, { resolve }, { MEDIA_ROOT }] = await Promise.all([
import("node:fs/promises"),
import("node:path"),
import("@/lib/media-storage"),
]);
let names: string[];
try {
names = (await readdir(MEDIA_ROOT)).filter((name) =>
/[.](png|jpe?g|gif|webp|svg|bmp)$/iu.test(name),
);
} catch (error) {
if ((error as NodeJS.ErrnoException).code === "ENOENT")
return response(input, []);
throw error;
}
const entries = await Promise.all(
names.map(async (name) => ({
name,
metadata: await stat(resolve(MEDIA_ROOT, name)),
})),
);
entries.sort((left, right) => right.metadata.mtimeMs - left.metadata.mtimeMs);
const items = entries
.map(({ name, metadata }) => ({
id: name,
title: name,
description: `${String(metadata.size)} bytes`,
status: "stored",
updatedAt: metadata.mtime.toISOString(),
href: "/ase/content/media/library",
}))
.filter((item) => matchesListSearch(input, item));
return response(input, items, items.length);
}
async function brand(
input: NormalizedContentQueryInput,
): Promise<ContentQueryData> {
const [{ siteSettings }, { listCustomThemes }] = await Promise.all([
import("@/lib/services/site-settings"),
import("@/lib/theme-custom-store"),
]);
if (input.routeId === "content.brand.favicon") {
const favicon = await siteSettings.get("cms_favicon", null);
return response(input, [
{
id: "favicon",
title: favicon || "Default favicon",
status: favicon ? "custom" : "default",
href: "/ase/content/brand/favicon",
},
]);
}
const [preset, customThemes] = await Promise.all([
siteSettings.get("theme_preset", "Atom (golden)"),
listCustomThemes(),
]);
return response(input, [
{
id: "active-theme",
title: preset || "Atom (golden)",
status: "active",
href: "/ase/content/brand/theme",
},
...customThemes.map((theme) => ({
id: theme.id,
title: theme.name,
status: "saved",
updatedAt: new Date(theme.createdAt).toISOString(),
href: "/ase/content/brand/theme",
})),
]);
}
async function localizationFiles(
input: NormalizedContentQueryInput,
): Promise<ContentQueryData> {
if (input.routeId === "content.localization.client") {
const { CLIENT_TRANSLATION_FILES } = await import(
"@/lib/client-translation-files"
);
return response(
input,
CLIENT_TRANSLATION_FILES.map((file) => ({
id: file.id,
title: file.id,
description: file.relPath,
status: file.readOnly ? "read-only" : "editable",
href: "/ase/content/localization/client",
})),
);
}
const [{ readdir }, { join }] = await Promise.all([
import("node:fs/promises"),
import("node:path"),
]);
let locales: string[] = [];
try {
locales = (await readdir(join(process.cwd(), "src", "messages")))
.filter((name) => name.endsWith(".json"))
.map((name) => name.slice(0, -5))
.sort();
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
}
if (input.routeId === "content.localization.overview") {
return response(input, [
{
id: "cms",
title: "CMS translations",
status: `${String(locales.length)} locales`,
href: "/ase/content/localization/cms",
},
{
id: "client",
title: "Client translations",
status: "configured sources",
href: "/ase/content/localization/client",
},
{
id: "emulator",
title: "Emulator translations",
status: "database store",
href: "/ase/content/localization/emulator",
},
]);
}
return response(
input,
locales.map((locale) => ({
id: locale,
title: locale,
status: "editable",
href: "/ase/content/localization/cms",
})),
);
}
export async function loadProductionContentQuery(
input: NormalizedContentQueryInput,
): Promise<ContentQueryData> {
if (EMPTY_ROUTES.has(input.routeId)) return response(input, []);
if (input.routeId === "content.media.library") return mediaLibrary(input);
if (input.routeId.startsWith("content.brand.")) return brand(input);
if (
input.routeId === "content.localization.overview" ||
input.routeId === "content.localization.client" ||
input.routeId === "content.localization.cms"
) {
return localizationFiles(input);
}
const definition = (
CONTENT_QUERY_DEFINITIONS as Partial<
Record<ContentRouteId, QueryDefinition>
>
)[input.routeId];
if (!definition) throw new Error("missing Content query adapter");
return databaseRoute(input, definition);
}
@@ -1,215 +0,0 @@
import { describe, expect, it, vi } from "vitest";
import { PERMS } from "@/lib/permission-slugs";
import type { HousekeepingCapabilityContext } from "../../../foundation/contracts";
import { CONTENT_ROUTE_IDS } from "../routes";
import {
type ContentQueryData,
type ContentQueryInput,
createContentQuery,
} from "./content-queries";
import {
CONTENT_QUERY_DEFINITIONS,
loadProductionContentQuery,
} from "./content-queries-production";
const queryMocks = vi.hoisted(() => ({
execute: vi.fn(),
join: vi.fn((chunks: unknown[], separator: unknown) => ({
chunks,
separator,
})),
raw: vi.fn((statement: string) => ({ statement })),
tagged: vi.fn((strings: TemplateStringsArray, ...values: unknown[]) => ({
strings: Array.from(strings),
values,
})),
}));
vi.mock("drizzle-orm", () => ({
sql: Object.assign(queryMocks.tagged, {
join: queryMocks.join,
raw: queryMocks.raw,
}),
}));
vi.mock("@/lib/db", () => ({ db: { execute: queryMocks.execute } }));
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)),
};
}
const ready: ContentQueryData = {
kind: "editorial",
items: [
{
id: "18446744073709551615",
title: "Published article",
status: "published",
href: "/ase/content/editorial/articles/18446744073709551615",
},
],
total: 1,
partialDependencies: [],
};
describe("Content query", () => {
it("reports totals beyond the former 500-row adapter cap", async () => {
const pageRows = Array.from({ length: 25 }, (_, index) => ({
id: index + 1,
title: `Article ${index + 1}`,
status: "published",
updated_at: null,
}));
queryMocks.execute
.mockResolvedValueOnce([[{ total: 600 }]])
.mockResolvedValueOnce([pageRows]);
const result = await loadProductionContentQuery({
routeId: "content.editorial.articles",
params: {},
list: { search: "", pageSize: 25, offset: 0 },
});
expect(result.total).toBe(600);
expect(result.items).toHaveLength(25);
expect(queryMocks.execute).toHaveBeenCalledTimes(2);
const pageQuery = queryMocks.execute.mock.calls[1]?.[0];
expect(JSON.stringify(pageQuery)).toContain("25");
expect(JSON.stringify(pageQuery)).toContain("0");
});
it("binds search, limit, and offset into the bounded page query", async () => {
queryMocks.execute
.mockResolvedValueOnce([[{ total: 1 }]])
.mockResolvedValueOnce([
[{ id: 9, title: "Launch", status: "published", updated_at: null }],
]);
await loadProductionContentQuery({
routeId: "content.editorial.articles",
params: {},
list: { search: "Launch", pageSize: 7, offset: 14 },
});
const serialized = JSON.stringify(queryMocks.execute.mock.calls);
expect(serialized).toContain("%launch%");
expect(serialized).toContain("7");
expect(serialized).toContain("14");
});
it("searches only projected aliases for routes without descriptions", async () => {
queryMocks.execute
.mockResolvedValueOnce([[{ total: 0 }]])
.mockResolvedValueOnce([[]]);
await loadProductionContentQuery({
routeId: "content.engagement.events",
params: {},
list: { search: "launch", pageSize: 25, offset: 0 },
});
const serialized = JSON.stringify(queryMocks.execute.mock.calls.slice(-2));
expect(serialized).toContain("COALESCE(title");
expect(serialized).not.toContain("COALESCE(description");
});
it("never selects email template bodies into summary query results", () => {
const emailTemplates =
CONTENT_QUERY_DEFINITIONS["content.help.email-templates"];
expect(emailTemplates.statement).not.toMatch(/\bbody\b|private_payload/iu);
});
it("fails closed before production adapters are invoked", async () => {
const load = vi.fn(async () => ready);
const query = createContentQuery({ load });
const result = await query.run(context([]), {
routeId: "content.editorial.articles",
list: { search: "", pageSize: 25, offset: 0 },
});
expect(result).toMatchObject({
ok: false,
error: { code: "FORBIDDEN" },
});
expect(load).not.toHaveBeenCalled();
});
it("bounds list input, preserves canonical BIGINT strings, and returns real adapter data", async () => {
const load = vi.fn(async (_input: ContentQueryInput) => ready);
const query = createContentQuery({ load });
const result = await query.run(context([PERMS.NEWS_VIEW]), {
routeId: "content.editorial.articles",
list: {
search: " launch ",
pageSize: 1000,
offset: 999_999,
},
});
expect(load).toHaveBeenCalledWith({
routeId: "content.editorial.articles",
params: {},
list: { search: "launch", pageSize: 100, offset: 100_000 },
});
expect(result).toMatchObject({
ok: true,
data: {
items: [{ id: "18446744073709551615" }],
total: 1,
partialDependencies: [],
},
});
});
it("rejects a fake partial result and maps complete adapter failure", async () => {
const malformed = createContentQuery({
load: async () =>
({
kind: "media",
items: [],
total: 0,
partialDependencies: ["imaginary"],
}) as ContentQueryData,
});
const unavailable = createContentQuery({
load: async () => {
throw new Error("database unavailable");
},
});
expect(
await malformed.run(context([PERMS.PAGES_VIEW]), {
routeId: "content.media.library",
}),
).toMatchObject({
ok: false,
error: { code: "DEPENDENCY_UNAVAILABLE" },
});
expect(
await unavailable.run(context([PERMS.PAGES_VIEW]), {
routeId: "content.media.library",
}),
).toMatchObject({
ok: false,
error: { code: "DEPENDENCY_UNAVAILABLE" },
});
});
it("has an authorized production query path for every Content route", async () => {
const load = vi.fn(async (input: ContentQueryInput) => ({
kind: input.routeId.split(".")[1] as ContentQueryData["kind"],
items: [],
total: 0,
partialDependencies: [],
}));
const query = createContentQuery({ load });
const all = context(Object.values(PERMS));
for (const routeId of CONTENT_ROUTE_IDS) {
const result = await query.run(all, { routeId });
expect(result.ok, routeId).toBe(true);
}
expect(load).toHaveBeenCalledTimes(CONTENT_ROUTE_IDS.length);
});
});
@@ -1,149 +0,0 @@
import { authorizeHousekeeping } from "../../../foundation/authorization";
import {
fail,
type HousekeepingQuery,
ok,
} from "../../../foundation/contracts";
import { isSafeHousekeepingHref } from "../../../foundation/housekeeping-href";
import {
type ContentRouteGroup,
type ContentRouteId,
contentRouteById,
contentRouteGroup,
} from "../routes";
export interface ContentQueryListInput {
readonly search?: string;
readonly pageSize?: number;
readonly offset?: number;
}
export interface ContentQueryInput {
readonly routeId: ContentRouteId;
readonly params?: Readonly<Record<string, string>>;
readonly list?: ContentQueryListInput;
}
export interface ContentQueryItem {
readonly id: string;
readonly title: string;
readonly status?: string;
readonly description?: string;
readonly href?: string;
readonly updatedAt?: string | null;
readonly privatePayload?: unknown;
}
export interface ContentQueryData {
readonly kind: ContentRouteGroup;
readonly items: readonly ContentQueryItem[];
readonly total: number;
readonly partialDependencies: readonly string[];
}
export interface NormalizedContentQueryInput {
readonly routeId: ContentRouteId;
readonly params: Readonly<Record<string, string>>;
readonly list: Readonly<{
search: string;
pageSize: number;
offset: number;
}>;
}
export interface ContentQueryAdapters {
load(input: NormalizedContentQueryInput): Promise<ContentQueryData>;
}
function boundedInteger(
value: unknown,
fallback: number,
minimum: number,
maximum: number,
): number {
const parsed = Number(value);
if (!Number.isSafeInteger(parsed)) return fallback;
return Math.min(maximum, Math.max(minimum, parsed));
}
function normalizeInput(input: ContentQueryInput): NormalizedContentQueryInput {
return {
routeId: input.routeId,
params: Object.fromEntries(
Object.entries(input.params ?? {}).map(([key, value]) => [
key.normalize("NFC").trim().slice(0, 128),
value.normalize("NFC").trim().slice(0, 128),
]),
),
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),
},
};
}
function isValidData(
data: ContentQueryData,
input: NormalizedContentQueryInput,
): boolean {
if (
data.kind !== contentRouteGroup(input.routeId) ||
!Array.isArray(data.items) ||
!Number.isSafeInteger(data.total) ||
data.total < 0 ||
!Array.isArray(data.partialDependencies) ||
data.partialDependencies.length !== 0
) {
return false;
}
return data.items.every(
(item) =>
typeof item.id === "string" &&
item.id.length > 0 &&
typeof item.title === "string" &&
item.title.length > 0 &&
(item.href === undefined || isSafeHousekeepingHref(item.href)),
);
}
export function createContentQuery(
adapters: ContentQueryAdapters,
): HousekeepingQuery<ContentQueryInput, ContentQueryData> {
return {
id: "content.query",
owner: "content",
capability: contentRouteById("content.editorial.articles").capability,
async run(context, input) {
const route = contentRouteById(input.routeId);
const authorization = authorizeHousekeeping(context, route.capability);
if (!authorization.ok) return authorization;
const normalized = normalizeInput(input);
try {
const data = await adapters.load(normalized);
if (!isValidData(data, normalized)) {
throw new Error("invalid Content query data");
}
return ok(data, authorization.correlationId);
} catch {
return fail(
"DEPENDENCY_UNAVAILABLE",
"errors.housekeeping.dependencyUnavailable",
authorization.correlationId,
);
}
},
};
}
export const contentQuery = createContentQuery({
async load(input) {
const { loadProductionContentQuery } = await import(
"./content-queries-production"
);
return loadProductionContentQuery(input);
},
});
@@ -1,31 +0,0 @@
import type { HousekeepingRouteHandler } from "../../route-handlers";
import { renderContentBrandPage } from "./pages/brand";
import { renderContentEditorialPage } from "./pages/editorial";
import { renderContentEngagementPage } from "./pages/engagement";
import { renderContentHelpPage } from "./pages/help";
import { renderContentLocalizationPage } from "./pages/localization";
import { renderContentMediaPage } from "./pages/media";
import {
CONTENT_ROUTE_IDS,
type ContentRouteId,
contentRouteGroup,
} from "./routes";
function rendererFor(
routeId: ContentRouteId,
): HousekeepingRouteHandler["render"] {
const group = contentRouteGroup(routeId);
if (group === "editorial") return renderContentEditorialPage;
if (group === "media") return renderContentMediaPage;
if (group === "engagement") return renderContentEngagementPage;
if (group === "help") return renderContentHelpPage;
if (group === "brand") return renderContentBrandPage;
return renderContentLocalizationPage;
}
export const CONTENT_ROUTE_HANDLERS: readonly HousekeepingRouteHandler[] =
Object.freeze(
CONTENT_ROUTE_IDS.map((routeId) =>
Object.freeze({ routeId, render: rendererFor(routeId) }),
),
);
@@ -1,140 +0,0 @@
import { describe, expect, it } from "vitest";
import { HOUSEKEEPING_MANIFESTS } from "../../manifests";
import { contentMigrationEntries } from "../../migration/content";
import { HOUSEKEEPING_ROUTE_HANDLERS } from "../../route-handlers";
import { contentManifest } from "./manifest";
import {
CONTENT_ROUTE_GROUPS,
CONTENT_ROUTE_IDS,
CONTENT_ROUTES,
type ContentRouteId,
contentRouteGroup,
} from "./routes";
const expected = [
[
"content.editorial.articles",
"/ase/content/editorial/articles",
"editorial",
],
[
"content.editorial.article-create",
"/ase/content/editorial/articles/new",
"editorial",
],
[
"content.editorial.article-detail",
"/ase/content/editorial/articles/:id",
"editorial",
],
["content.media.photos", "/ase/content/media/photos", "media"],
["content.media.library", "/ase/content/media/library", "media"],
["content.media.banners", "/ase/content/media/banners", "media"],
["content.media.ads", "/ase/content/media/ads", "media"],
["content.media.ad-create", "/ase/content/media/ads/new", "media"],
["content.media.ad-detail", "/ase/content/media/ads/:id", "media"],
["content.engagement.events", "/ase/content/engagement/events", "engagement"],
[
"content.engagement.event-create",
"/ase/content/engagement/events/create",
"engagement",
],
[
"content.engagement.event-types",
"/ase/content/engagement/events/types",
"engagement",
],
[
"content.engagement.event-detail",
"/ase/content/engagement/events/:id",
"engagement",
],
["content.engagement.polls", "/ase/content/engagement/polls", "engagement"],
[
"content.engagement.poll-create",
"/ase/content/engagement/polls/create",
"engagement",
],
[
"content.engagement.poll-detail",
"/ase/content/engagement/polls/:id",
"engagement",
],
["content.help.questions", "/ase/content/help/questions", "help"],
["content.help.question-create", "/ase/content/help/questions/new", "help"],
["content.help.question-detail", "/ase/content/help/questions/:id", "help"],
["content.editorial.tags", "/ase/content/editorial/tags", "editorial"],
[
"content.engagement.prefixes",
"/ase/content/engagement/prefixes",
"engagement",
],
[
"content.editorial.writeable-boxes",
"/ase/content/editorial/writeable-boxes",
"editorial",
],
["content.help.email-templates", "/ase/content/help/email-templates", "help"],
["content.brand.theme", "/ase/content/brand/theme", "brand"],
["content.brand.favicon", "/ase/content/brand/favicon", "brand"],
[
"content.localization.overview",
"/ase/content/localization",
"localization",
],
[
"content.localization.client",
"/ase/content/localization/client",
"localization",
],
["content.localization.cms", "/ase/content/localization/cms", "localization"],
[
"content.localization.emulator",
"/ase/content/localization/emulator",
"localization",
],
] as const;
describe("Content routes", () => {
it("declares the six exact route groups", () => {
expect(CONTENT_ROUTE_GROUPS).toEqual([
"editorial",
"media",
"engagement",
"help",
"brand",
"localization",
]);
});
it("maps every migration row to its canonical route and group", () => {
expect(
CONTENT_ROUTES.map((route) => [
route.id,
route.href,
contentRouteGroup(route.id as ContentRouteId),
]),
).toEqual(expected);
expect(CONTENT_ROUTE_IDS).toEqual(expected.map(([id]) => id));
expect(CONTENT_ROUTES.map((route) => route.href).sort()).toEqual(
contentMigrationEntries
.filter((entry) => entry.targetPath !== null)
.map((entry) => entry.targetPath)
.sort(),
);
});
it("keeps manifest routes and real handlers in exact equality", () => {
expect(contentManifest.routes).toBe(CONTENT_ROUTES);
expect(contentManifest.routes.map((route) => route.id)).toEqual(
HOUSEKEEPING_ROUTE_HANDLERS.filter((handler) =>
handler.routeId.startsWith("content."),
).map((handler) => handler.routeId),
);
expect(
HOUSEKEEPING_MANIFESTS.flatMap((manifest) =>
manifest.routes.map((route) => route.id),
),
).toEqual(HOUSEKEEPING_ROUTE_HANDLERS.map((handler) => handler.routeId));
});
});
@@ -1,205 +0,0 @@
import { PERMS } from "@/lib/permission-slugs";
import {
anyCapability,
type CanonicalHousekeepingHref,
type HousekeepingRouteDefinition,
} from "../../foundation/contracts";
export const CONTENT_ROUTE_GROUPS = [
"editorial",
"media",
"engagement",
"help",
"brand",
"localization",
] as const;
export type ContentRouteGroup = (typeof CONTENT_ROUTE_GROUPS)[number];
export const CONTENT_ROUTE_IDS = [
"content.editorial.articles",
"content.editorial.article-create",
"content.editorial.article-detail",
"content.media.photos",
"content.media.library",
"content.media.banners",
"content.media.ads",
"content.media.ad-create",
"content.media.ad-detail",
"content.engagement.events",
"content.engagement.event-create",
"content.engagement.event-types",
"content.engagement.event-detail",
"content.engagement.polls",
"content.engagement.poll-create",
"content.engagement.poll-detail",
"content.help.questions",
"content.help.question-create",
"content.help.question-detail",
"content.editorial.tags",
"content.engagement.prefixes",
"content.editorial.writeable-boxes",
"content.help.email-templates",
"content.brand.theme",
"content.brand.favicon",
"content.localization.overview",
"content.localization.client",
"content.localization.cms",
"content.localization.emulator",
] as const;
export type ContentRouteId = (typeof CONTENT_ROUTE_IDS)[number];
function contentRoute(
id: ContentRouteId,
href: CanonicalHousekeepingHref,
capabilities: readonly string[],
): HousekeepingRouteDefinition {
return {
id,
labelKey: `pages.housekeeping.routes.${id}`,
href,
capability: anyCapability(...capabilities),
};
}
export const CONTENT_ROUTES = [
contentRoute(
"content.editorial.articles",
"/ase/content/editorial/articles",
[PERMS.NEWS_VIEW],
),
contentRoute(
"content.editorial.article-create",
"/ase/content/editorial/articles/new",
[PERMS.NEWS_EDIT],
),
contentRoute(
"content.editorial.article-detail",
"/ase/content/editorial/articles/:id",
[PERMS.NEWS_VIEW],
),
contentRoute("content.media.photos", "/ase/content/media/photos", [
PERMS.PAGES_VIEW,
]),
contentRoute("content.media.library", "/ase/content/media/library", [
PERMS.PAGES_VIEW,
]),
contentRoute("content.media.banners", "/ase/content/media/banners", [
PERMS.BANNERS_VIEW,
]),
contentRoute("content.media.ads", "/ase/content/media/ads", [
PERMS.PAGES_VIEW,
]),
contentRoute("content.media.ad-create", "/ase/content/media/ads/new", [
PERMS.PAGES_EDIT,
]),
contentRoute("content.media.ad-detail", "/ase/content/media/ads/:id", [
PERMS.PAGES_VIEW,
]),
contentRoute("content.engagement.events", "/ase/content/engagement/events", [
PERMS.EVENTS_VIEW,
]),
contentRoute(
"content.engagement.event-create",
"/ase/content/engagement/events/create",
[PERMS.EVENTS_EDIT],
),
contentRoute(
"content.engagement.event-types",
"/ase/content/engagement/events/types",
[PERMS.EVENTS_EDIT],
),
contentRoute(
"content.engagement.event-detail",
"/ase/content/engagement/events/:id",
[PERMS.EVENTS_EDIT],
),
contentRoute("content.engagement.polls", "/ase/content/engagement/polls", [
PERMS.POLLS_VIEW,
]),
contentRoute(
"content.engagement.poll-create",
"/ase/content/engagement/polls/create",
[PERMS.POLLS_EDIT],
),
contentRoute(
"content.engagement.poll-detail",
"/ase/content/engagement/polls/:id",
[PERMS.POLLS_EDIT],
),
contentRoute("content.help.questions", "/ase/content/help/questions", [
PERMS.PAGES_VIEW,
]),
contentRoute(
"content.help.question-create",
"/ase/content/help/questions/new",
[PERMS.PAGES_EDIT],
),
contentRoute(
"content.help.question-detail",
"/ase/content/help/questions/:id",
[PERMS.PAGES_VIEW],
),
contentRoute("content.editorial.tags", "/ase/content/editorial/tags", [
PERMS.PAGES_VIEW,
]),
contentRoute(
"content.engagement.prefixes",
"/ase/content/engagement/prefixes",
[PERMS.PREFIXES_VIEW],
),
contentRoute(
"content.editorial.writeable-boxes",
"/ase/content/editorial/writeable-boxes",
[PERMS.PAGES_VIEW],
),
contentRoute(
"content.help.email-templates",
"/ase/content/help/email-templates",
[PERMS.PAGES_VIEW],
),
contentRoute("content.brand.theme", "/ase/content/brand/theme", [
PERMS.SETTINGS_VIEW,
]),
contentRoute("content.brand.favicon", "/ase/content/brand/favicon", [
PERMS.SETTINGS_VIEW,
]),
contentRoute("content.localization.overview", "/ase/content/localization", [
PERMS.SETTINGS_VIEW,
]),
contentRoute(
"content.localization.client",
"/ase/content/localization/client",
[PERMS.SETTINGS_VIEW],
),
contentRoute("content.localization.cms", "/ase/content/localization/cms", [
PERMS.SETTINGS_VIEW,
]),
contentRoute(
"content.localization.emulator",
"/ase/content/localization/emulator",
[PERMS.SETTINGS_VIEW],
),
] as const satisfies readonly HousekeepingRouteDefinition[];
const routeGroups = new Map<ContentRouteId, ContentRouteGroup>(
CONTENT_ROUTE_IDS.map((routeId) => [
routeId,
routeId.split(".")[1] as ContentRouteGroup,
]),
);
export function contentRouteGroup(routeId: ContentRouteId): ContentRouteGroup {
const group = routeGroups.get(routeId);
if (!group) throw new Error(`unknown Content route: ${routeId}`);
return group;
}
export function contentRouteById(
routeId: ContentRouteId,
): (typeof CONTENT_ROUTES)[number] {
const route = CONTENT_ROUTES.find((candidate) => candidate.id === routeId);
if (!route) throw new Error(`unknown Content route: ${routeId}`);
return route;
}
@@ -1,54 +0,0 @@
import "server-only";
import { PERMS } from "@/lib/permission-slugs";
import {
anyCapability,
type HousekeepingCapabilityContext,
} from "../../foundation/contracts";
import { contentQuery } from "./queries/content-queries";
import type { ContentSearchCandidate } from "./search";
type ContentSearchKind = "articles" | "events" | "media" | "help";
const SEARCH_ROUTES = {
articles: [["content.editorial.articles", anyCapability(PERMS.NEWS_VIEW)]],
events: [["content.engagement.events", anyCapability(PERMS.EVENTS_VIEW)]],
media: [
["content.media.photos", anyCapability(PERMS.PAGES_VIEW)],
["content.media.library", anyCapability(PERMS.PAGES_VIEW)],
["content.media.banners", anyCapability(PERMS.BANNERS_VIEW)],
["content.media.ads", anyCapability(PERMS.PAGES_VIEW)],
],
help: [
["content.help.questions", anyCapability(PERMS.PAGES_VIEW)],
["content.help.email-templates", anyCapability(PERMS.PAGES_VIEW)],
],
} as const;
export async function loadContentSearchCandidates(
kind: ContentSearchKind,
context: HousekeepingCapabilityContext,
term: string,
limit: number,
): Promise<readonly ContentSearchCandidate[]> {
const candidates: ContentSearchCandidate[] = [];
for (const [routeId, capability] of SEARCH_ROUTES[kind]) {
const result = await contentQuery.run(context, {
routeId,
list: { search: term, pageSize: limit, offset: 0 },
});
if (!result.ok) continue;
for (const item of result.data.items) {
if (!item.href) continue;
candidates.push({
id: `${routeId}:${item.id}`,
title: item.title,
description: item.description ?? item.status,
href: item.href,
capability,
});
if (candidates.length >= limit) return candidates;
}
}
return candidates;
}
@@ -1,131 +0,0 @@
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";
export const CONTENT_SEARCH_PROVIDER_IDS = [
"content.articles",
"content.events",
"content.media",
"content.help",
] as const;
export interface ContentSearchCandidate {
readonly id: string;
readonly title: string;
readonly description?: string;
readonly href: string;
readonly capability: CapabilityRequirement;
}
type ContentSearchLoader = (
context: HousekeepingCapabilityContext,
term: string,
limit: number,
) => Promise<readonly ContentSearchCandidate[]>;
export interface ContentSearchAdapters {
readonly articles: ContentSearchLoader;
readonly events: ContentSearchLoader;
readonly media: ContentSearchLoader;
readonly help: ContentSearchLoader;
}
function createProvider(
id: (typeof CONTENT_SEARCH_PROVIDER_IDS)[number],
capability: CapabilityRequirement,
load: ContentSearchLoader,
): HousekeepingSearchProvider {
return {
id,
owner: "content",
capability,
async search(context, input) {
const authorization = authorizeHousekeeping(context, capability);
if (!authorization.ok) return authorization;
const limit = Number.isFinite(input.limit)
? Math.min(25, Math.max(1, Math.trunc(input.limit)))
: 25;
const term = input.term.normalize("NFC").trim().slice(0, 128);
try {
const candidates = await load(context, term, limit);
return ok(
candidates
.filter(
(item) =>
isSafeHousekeepingHref(item.href) &&
satisfiesCapability(context, item.capability),
)
.slice(0, limit)
.map((item) => ({
...item,
domain: "content" as const,
type: "entity" as const,
href: item.href as `/ase/${string}`,
})),
authorization.correlationId,
);
} catch {
return fail(
"DEPENDENCY_UNAVAILABLE",
"errors.housekeeping.dependencyUnavailable",
authorization.correlationId,
);
}
},
};
}
export function createContentSearchProviders(
adapters: ContentSearchAdapters,
): readonly HousekeepingSearchProvider[] {
return [
createProvider(
"content.articles",
anyCapability(PERMS.NEWS_VIEW),
adapters.articles,
),
createProvider(
"content.events",
anyCapability(PERMS.EVENTS_VIEW),
adapters.events,
),
createProvider(
"content.media",
anyCapability(PERMS.PAGES_VIEW, PERMS.BANNERS_VIEW),
adapters.media,
),
createProvider(
"content.help",
anyCapability(PERMS.PAGES_VIEW),
adapters.help,
),
];
}
export const CONTENT_SEARCH_PROVIDERS = createContentSearchProviders({
async articles(context, term, limit) {
const { loadContentSearchCandidates } = await import("./search-production");
return loadContentSearchCandidates("articles", context, term, limit);
},
async events(context, term, limit) {
const { loadContentSearchCandidates } = await import("./search-production");
return loadContentSearchCandidates("events", context, term, limit);
},
async media(context, term, limit) {
const { loadContentSearchCandidates } = await import("./search-production");
return loadContentSearchCandidates("media", context, term, limit);
},
async help(context, term, limit) {
const { loadContentSearchCandidates } = await import("./search-production");
return loadContentSearchCandidates("help", context, term, limit);
},
});
@@ -1,197 +0,0 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { HousekeepingCapabilityContext } from "../../../foundation/contracts";
import { executeContentDatabaseMutation } from "./mutation-runtime-database";
const sqlMocks = vi.hoisted(() => ({
join: vi.fn((chunks: unknown[], separator: unknown) => ({
chunks,
separator,
})),
raw: vi.fn((statement: string) => statement),
tagged: vi.fn((strings: TemplateStringsArray, ...values: unknown[]) => ({
strings: Array.from(strings),
values,
})),
}));
const database = vi.hoisted(() => {
let selected: Record<string, unknown> = { id: 7, title: "Existing" };
const set = vi.fn((values: Record<string, unknown>) => ({
where: vi.fn(async () => undefined),
values,
}));
const update = vi.fn(() => ({ set }));
const limit = vi.fn(async () => [selected]);
const where = vi.fn(() => ({ limit }));
const from = vi.fn(() => ({ where }));
const select = vi.fn(() => ({ from }));
const execute = vi.fn(async () => undefined);
return {
execute,
set,
update,
select,
selected(value: Record<string, unknown>) {
selected = value;
},
};
});
vi.mock("drizzle-orm", () => ({
eq: vi.fn(),
sql: Object.assign(sqlMocks.tagged, {
join: sqlMocks.join,
raw: sqlMocks.raw,
}),
}));
vi.mock("@/lib/db", () => {
const table = new Proxy({}, { get: (_target, key) => String(key) });
return {
db: {
execute: database.execute,
select: database.select,
update: database.update,
},
EmailTemplates: table,
Taggables: table,
Tags: table,
User: table,
WebsiteAds: table,
WebsiteArticleComments: table,
WebsiteArticleReactions: table,
WebsiteArticles: table,
WebsiteBanner: table,
WebsiteEvent: table,
WebsiteEventPrize: table,
WebsiteEventType: table,
WebsiteEventWinner: table,
WebsiteHelpCenterCategories: table,
WebsitePoll: table,
WebsitePollQuestion: table,
WebsiteWriteableBoxes: table,
};
});
vi.mock("@/lib/services/staff-activity", () => ({
logStaffActivity: vi.fn(async () => undefined),
}));
const capability = {
actor: { id: 42, username: "operator", rank: 7 },
isSuperAdmin: false,
has: () => true,
hasAny: () => true,
hasAll: () => true,
} satisfies HousekeepingCapabilityContext;
const context = {
capability,
correlationId: "database-runtime",
legacy: false,
};
describe("Content database mutation runtime partial updates", () => {
beforeEach(() => {
vi.clearAllMocks();
database.selected({ id: 7, title: "Existing" });
});
it("does not reset omitted banner fields", async () => {
await executeContentDatabaseMutation(
"banner.change",
{ action: "update", id: 7, title: "Renamed" },
context,
undefined,
);
expect(database.set).toHaveBeenCalledWith({ title: "Renamed" });
});
it("does not reset a tag color omitted by the update form", async () => {
await executeContentDatabaseMutation(
"tag.change",
{ action: "update", id: "7", name: "News" },
context,
undefined,
);
const values = database.set.mock.calls[0]?.[0];
expect(values).toMatchObject({ name: "News" });
expect(values).not.toHaveProperty("backgroundColor");
});
it.each([
["help-question.change", { name: "Updated question" }, "name"],
["writeable-box.change", { title: "Updated box" }, "title"],
["email-template.change", { subject: "Updated subject" }, "subject"],
] as const)(
"updates only supplied fields for %s",
async (operation, patch, expectedKey) => {
await executeContentDatabaseMutation(
operation,
{ action: "update", id: "7", ...patch },
context,
undefined,
);
const values = database.set.mock.calls[0]?.[0];
expect(values).toHaveProperty(expectedKey);
for (const destructiveKey of [
"content",
"position",
"isActive",
"body",
"variables",
"imageUrl",
]) {
expect(values).not.toHaveProperty(destructiveKey);
}
},
);
it.each([
["help-question.change", { answer: "Updated answer" }, "content"],
["writeable-box.change", { content: "Updated content" }, "content"],
["email-template.change", { body: "Updated body" }, "body"],
] as const)(
"accepts a non-title partial patch for %s",
async (operation, patch, expectedKey) => {
await executeContentDatabaseMutation(
operation,
{ action: "update", id: "7", ...patch },
context,
undefined,
);
expect(database.set.mock.calls[0]?.[0]).toHaveProperty(expectedKey);
},
);
it("updates only submitted prefix columns and preserves omitted icon, effect, and active", async () => {
const snapshot = await executeContentDatabaseMutation(
"prefix.change",
{ action: "update", id: 7, text: "Renamed" },
context,
undefined,
);
const assignments = sqlMocks.join.mock.calls.at(-1)?.[0] as
| Array<{ strings: string[]; values: unknown[] }>
| undefined;
expect(assignments).toHaveLength(1);
expect(assignments?.[0]?.strings.join(" ")).toContain("text =");
expect(assignments?.[0]?.strings.join(" ")).not.toMatch(
/icon|effect|active/u,
);
expect(snapshot?.after).toEqual({ id: 7, text: "Renamed" });
});
it("submits an explicit false prefix active patch without touching other columns", async () => {
const snapshot = await executeContentDatabaseMutation(
"prefix.change",
{ action: "update", id: 7, active: false },
context,
undefined,
);
const assignments = sqlMocks.join.mock.calls.at(-1)?.[0] as
| Array<{ strings: string[]; values: unknown[] }>
| undefined;
expect(assignments).toHaveLength(1);
expect(assignments?.[0]?.strings.join(" ")).toContain("active =");
expect(assignments?.[0]?.values).toEqual([0]);
expect(snapshot?.after).toEqual({ id: 7, active: 0 });
});
});
File diff suppressed because it is too large. Load diff
@@ -1,316 +0,0 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { HousekeepingCapabilityContext } from "../../../foundation/contracts";
import { executeContentExternalMutation } from "./mutation-runtime-external";
import type { ContentMutationFailure } from "./mutations";
const fsMocks = vi.hoisted(() => ({
mkdir: vi.fn(),
readFile: vi.fn(),
unlink: vi.fn(),
writeFile: vi.fn(),
}));
const dbMocks = vi.hoisted(() => {
const onDuplicateKeyUpdate = vi.fn(async () => undefined);
const values = vi.fn((_value: Record<string, unknown>) => ({
onDuplicateKeyUpdate,
}));
const insert = vi.fn(() => ({ values }));
const where = vi.fn(async () => undefined);
const deleteFn = vi.fn(() => ({ where }));
let selectedPhoto: Record<string, unknown> = {
id: 7,
url: "/photos/7.png",
};
const limit = vi.fn(async () => [selectedPhoto]);
const selectWhere = vi.fn(() => ({ limit }));
const from = vi.fn(() => ({ where: selectWhere }));
const select = vi.fn(() => ({ from }));
return {
deleteFn,
insert,
limit,
onDuplicateKeyUpdate,
select,
selectedPhoto(value: Record<string, unknown>) {
selectedPhoto = value;
},
values,
where,
};
});
const siteMocks = vi.hoisted(() => ({
get: vi.fn(),
reload: vi.fn(),
update: vi.fn(),
}));
const photoMocks = vi.hoisted(() => ({
activity: vi.fn(),
remove: vi.fn(),
}));
vi.mock("node:fs/promises", () => fsMocks);
vi.mock("drizzle-orm", () => ({ eq: vi.fn() }));
vi.mock("@/lib/db", () => ({
CameraWeb: {},
EmulatorSettings: {},
WebsiteSetting: {},
db: {
delete: dbMocks.deleteFn,
insert: dbMocks.insert,
select: dbMocks.select,
},
}));
vi.mock("@/lib/services/rcon", () => ({
rcon: { updateConfig: vi.fn() },
}));
vi.mock("@/lib/services/site-settings", () => ({
siteSettings: siteMocks,
}));
vi.mock("@/lib/services/staff-activity", () => ({
logStaffActivity: photoMocks.activity,
}));
vi.mock("@/lib/admin/photo-files", () => ({
tryRemoveLocalPhotoFile: photoMocks.remove,
}));
const capability = {
actor: { id: 42, username: "operator", rank: 7 },
isSuperAdmin: false,
has: () => true,
hasAny: () => true,
hasAll: () => true,
} satisfies HousekeepingCapabilityContext;
const context = {
capability,
correlationId: "external-runtime",
legacy: false,
};
describe("Content external mutation runtime", () => {
beforeEach(() => {
vi.clearAllMocks();
siteMocks.get.mockResolvedValue(undefined);
photoMocks.activity.mockResolvedValue(undefined);
photoMocks.remove.mockResolvedValue(true);
dbMocks.selectedPhoto({ id: 7, url: "/photos/7.png" });
});
it("preserves media upload bytes, type, size, and canonical public URL", async () => {
const file = new File(
[new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])],
"photo.png",
{ type: "image/png" },
);
const snapshot = await executeContentExternalMutation(
"media.upload",
{ file },
context,
);
expect(snapshot).toMatchObject({
before: null,
after: { size: file.size, type: "image/png" },
output: { url: expect.stringMatching(/^\/api\/media\/.+[.]png$/u) },
});
expect(fsMocks.mkdir).toHaveBeenCalledTimes(1);
expect(fsMocks.writeFile).toHaveBeenCalledWith(
expect.stringContaining("storage"),
expect.any(Buffer),
);
});
it("propagates a real storage failure before optimistic success", async () => {
fsMocks.writeFile.mockRejectedValueOnce(new Error("disk offline"));
const file = new File(
[new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])],
"photo.png",
{ type: "image/png" },
);
await expect(
executeContentExternalMutation("media.upload", { file }, context),
).rejects.toThrow("disk offline");
});
it("maps normalized media traversal to validation instead of dependency failure", async () => {
await expect(
executeContentExternalMutation(
"media.delete",
{ filename: "../private.txt" },
context,
),
).rejects.toMatchObject({
code: "VALIDATION",
messageKey: "errors.housekeeping.validation",
} satisfies Partial<ContentMutationFailure>);
expect(fsMocks.unlink).not.toHaveBeenCalled();
});
it.each(["favicon/brand.ico", "logo/brand.png", "favicon\\brand.ico"])(
"rejects nested media deletion outside the generic upload namespace: %s",
async (filename) => {
await expect(
executeContentExternalMutation("media.delete", { filename }, context),
).rejects.toMatchObject({
code: "VALIDATION",
messageKey: "errors.housekeeping.validation",
} satisfies Partial<ContentMutationFailure>);
expect(fsMocks.unlink).not.toHaveBeenCalled();
},
);
it("rejects declared MIME, filename extension, and actual bytes that disagree", async () => {
const disguisedSvg = new File(
['<svg xmlns="http://www.w3.org/2000/svg"></svg>'],
"photo.svg",
{ type: "image/png" },
);
await expect(
executeContentExternalMutation(
"media.upload",
{ file: disguisedSvg },
context,
),
).rejects.toMatchObject({ code: "VALIDATION" });
expect(fsMocks.writeFile).not.toHaveBeenCalled();
});
it("rejects an oversized logo before reading its bytes", async () => {
const arrayBuffer = vi.fn();
const file = {
name: "logo.png",
type: "image/png",
size: 5 * 1024 * 1024 + 1,
arrayBuffer,
};
await expect(
executeContentExternalMutation("logo.save", { file }, context),
).rejects.toMatchObject({ code: "VALIDATION" });
expect(arrayBuffer).not.toHaveBeenCalled();
expect(fsMocks.writeFile).not.toHaveBeenCalled();
});
it("removes a newly written favicon when the database write fails", async () => {
dbMocks.onDuplicateKeyUpdate.mockRejectedValueOnce(
new Error("database offline"),
);
const file = new File(
[new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])],
"favicon.png",
{ type: "image/png" },
);
await expect(
executeContentExternalMutation("favicon.save", { file }, context),
).rejects.toThrow("database offline");
const writtenPath = fsMocks.writeFile.mock.calls[0]?.[0];
expect(fsMocks.unlink).toHaveBeenCalledWith(writtenPath);
});
it("reports a partial favicon result when database failure compensation also fails", async () => {
dbMocks.onDuplicateKeyUpdate.mockRejectedValueOnce(
new Error("database offline"),
);
fsMocks.unlink.mockRejectedValueOnce(new Error("cleanup failed"));
const file = new File(
[new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])],
"favicon.png",
{ type: "image/png" },
);
await expect(
executeContentExternalMutation("favicon.save", { file }, context),
).rejects.toMatchObject({
name: "ContentCommittedExternalFailure",
snapshot: {
before: { value: null },
after: { value: null },
output: { compensation: "failed" },
},
});
});
it("removes a newly written logo when the database write fails", async () => {
dbMocks.onDuplicateKeyUpdate.mockRejectedValueOnce(
new Error("database offline"),
);
const file = new File(
[new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])],
"logo.png",
{ type: "image/png" },
);
await expect(
executeContentExternalMutation("logo.save", { file }, context),
).rejects.toThrow("database offline");
expect(fsMocks.unlink).toHaveBeenCalledWith(
fsMocks.writeFile.mock.calls[0]?.[0],
);
});
it("does not delete the existing favicon before the database delete commits", async () => {
siteMocks.get.mockResolvedValueOnce("/api/media/favicon/old.png");
dbMocks.where.mockRejectedValueOnce(new Error("database offline"));
await expect(
executeContentExternalMutation("favicon.delete", {}, context),
).rejects.toThrow("database offline");
expect(fsMocks.unlink).not.toHaveBeenCalled();
});
it("renames a custom theme without replacing its saved settings with the active site theme", async () => {
const storedTheme = {
id: "saved-theme",
name: "Stored",
createdAt: 123,
settings: { primary: "#stored", accent: "#saved" },
};
siteMocks.get.mockImplementation(async (key: string) =>
key === "custom_themes" ? JSON.stringify([storedTheme]) : "#active",
);
await executeContentExternalMutation(
"theme.custom-change",
{ action: "rename", id: storedTheme.id, name: "Renamed" },
context,
);
const write = dbMocks.values.mock.calls.find(
([value]) => value.key === "custom_themes",
)?.[0];
const persisted = JSON.parse(String(write?.value));
expect(persisted).toEqual([
{ ...storedTheme, name: "Renamed", settings: storedTheme.settings },
]);
});
it("reports typed partial completion when a committed photo delete cannot purge the file", async () => {
photoMocks.remove.mockResolvedValueOnce(false);
await expect(
executeContentExternalMutation("photo.delete", { id: 7 }, context),
).rejects.toMatchObject({
name: "ContentCommittedExternalFailure",
snapshot: {
before: { id: 7, url: "/photos/7.png" },
after: null,
},
});
expect(dbMocks.where).toHaveBeenCalled();
expect(photoMocks.activity).not.toHaveBeenCalled();
});
it("reports typed partial completion when photo audit fails after the delete", async () => {
photoMocks.activity.mockRejectedValueOnce(new Error("audit offline"));
await expect(
executeContentExternalMutation("photo.delete", { id: 7 }, context),
).rejects.toMatchObject({
name: "ContentCommittedExternalFailure",
snapshot: {
before: { id: 7, url: "/photos/7.png" },
after: null,
},
});
expect(photoMocks.remove).toHaveBeenCalledWith("/photos/7.png");
});
});
@@ -1,799 +0,0 @@
import "server-only";
import { mkdir, readFile, unlink, writeFile } from "node:fs/promises";
import path from "node:path";
import { eq } from "drizzle-orm";
import * as JSONC from "jsonc-parser";
import { tryRemoveLocalPhotoFile } from "@/lib/admin/photo-files";
import { getClientTranslationFile } from "@/lib/client-translation-files";
import { CameraWeb, db, EmulatorSettings, WebsiteSetting } from "@/lib/db";
import { patchJson5 } from "@/lib/json5-patch";
import { MEDIA_ROOT, resolveMediaPath } from "@/lib/media-storage";
import { rcon } from "@/lib/services/rcon";
import { siteSettings } from "@/lib/services/site-settings";
import { logStaffActivity } from "@/lib/services/staff-activity";
import { ensureReadableThemeColors } from "@/lib/theme-contrast";
import {
deleteCustomThemeStore,
getCustomTheme,
snapshotCurrentTheme,
upsertCustomTheme,
} from "@/lib/theme-custom-store";
import { FONTS, PRESETS, THEME_COLOR_KEYS } from "@/lib/theme-presets";
import { presetSettings, settingKey } from "@/lib/theme-settings";
import {
type ContentMutationContext,
ContentMutationFailure,
type ContentMutationOperation,
type ContentMutationSnapshot,
} from "./mutations";
import { ContentCommittedExternalFailure } from "./mutations-production";
const MEDIA_TYPES = [
"image/png",
"image/jpeg",
"image/gif",
"image/webp",
] as const;
const FAVICON_TYPES = [
...MEDIA_TYPES,
"image/x-icon",
"image/svg+xml",
] as const;
const IMAGE_EXTENSIONS = {
"image/png": [".png"],
"image/jpeg": [".jpg", ".jpeg"],
"image/gif": [".gif"],
"image/webp": [".webp"],
"image/x-icon": [".ico"],
"image/svg+xml": [".svg"],
} as const;
const CMS_LOCALES = new Set([
"en",
"it",
"nl",
"de",
"fr",
"es",
"pt",
"pl",
"sv",
"tr",
"ro",
"hu",
"cs",
"sk",
"da",
"no",
"el",
"bg",
"hr",
"sr",
"uk",
"ru",
]);
const COLOR_RE = /^[#a-zA-Z0-9(),.\s%-]+$/;
const ADMIN_COLOR_KEYS = [
"admin_canvas",
"admin_surface",
"admin_text",
"admin_text_muted",
"admin_border",
"admin_sidebar_bg",
] as const;
const HEADING_KEYS = [
"size_heading_h1",
"size_heading_h2",
"size_heading_h3",
] as const;
function validation(): ContentMutationFailure {
return new ContentMutationFailure(
"VALIDATION",
"errors.housekeeping.validation",
);
}
function notFound(): ContentMutationFailure {
return new ContentMutationFailure(
"NOT_FOUND",
"errors.housekeeping.notFound",
);
}
function record(value: unknown): Record<string, unknown> {
if (typeof value !== "object" || value === null || Array.isArray(value))
throw validation();
return value as Record<string, unknown>;
}
function text(value: unknown, maximum: number, required = false): string {
const normalized = String(value ?? "")
.normalize("NFC")
.trim()
.slice(0, maximum);
if (required && !normalized) throw validation();
return normalized;
}
function jsonRecord(value: unknown): Record<string, unknown> {
if (typeof value === "string") {
try {
return record(JSON.parse(value));
} catch {
throw validation();
}
}
return record(value);
}
function fileValue(value: unknown): File {
if (
typeof value !== "object" ||
value === null ||
typeof (value as File).arrayBuffer !== "function" ||
typeof (value as File).name !== "string" ||
typeof (value as File).type !== "string" ||
typeof (value as File).size !== "number"
) {
throw validation();
}
return value as File;
}
type SupportedImageType = keyof typeof IMAGE_EXTENSIONS;
function detectedImageType(bytes: Buffer): SupportedImageType | null {
if (
bytes.length >= 8 &&
bytes
.subarray(0, 8)
.equals(Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]))
)
return "image/png";
if (
bytes.length >= 3 &&
bytes[0] === 0xff &&
bytes[1] === 0xd8 &&
bytes[2] === 0xff
)
return "image/jpeg";
const header = bytes.subarray(0, 12).toString("ascii");
if (header.startsWith("GIF87a") || header.startsWith("GIF89a"))
return "image/gif";
if (header.startsWith("RIFF") && header.slice(8, 12) === "WEBP")
return "image/webp";
if (
bytes.length >= 4 &&
bytes[0] === 0 &&
bytes[1] === 0 &&
bytes[2] === 1 &&
bytes[3] === 0
)
return "image/x-icon";
const source = bytes
.toString("utf8")
.replace(/^\uFEFF/u, "")
.trimStart();
const svg = source.replace(/^<\?xml[^>]*>\s*/iu, "");
if (/^<svg(?:\s|>)/iu.test(svg)) return "image/svg+xml";
return null;
}
function isSafeSvg(bytes: Buffer): boolean {
const source = bytes.toString("utf8");
return (
!/<(?:script|foreignObject|iframe|object|embed|link|meta)(?:\s|>)/iu.test(
source,
) &&
!/\son[a-z]+\s*=/iu.test(source) &&
!/(?:href|src)\s*=\s*["']?\s*(?:javascript:|data:text\/html)/iu.test(source)
);
}
async function validatedImage(
value: unknown,
maximum: number,
allowedTypes: readonly SupportedImageType[],
): Promise<{
file: File;
bytes: Buffer;
type: SupportedImageType;
extension: string;
}> {
const file = fileValue(value);
if (file.size <= 0 || file.size > maximum) throw validation();
const bytes = Buffer.from(await file.arrayBuffer());
if (bytes.length <= 0 || bytes.length > maximum || bytes.length !== file.size)
throw validation();
const type = detectedImageType(bytes);
if (!type || !allowedTypes.includes(type) || file.type.toLowerCase() !== type)
throw validation();
if (type === "image/svg+xml" && !isSafeSvg(bytes)) throw validation();
const extension = path.extname(file.name).toLowerCase();
if (!(IMAGE_EXTENSIONS[type] as readonly string[]).includes(extension))
throw validation();
return { file, bytes, type, extension: IMAGE_EXTENSIONS[type][0].slice(1) };
}
async function writeWebsiteSetting(
key: string,
value: string,
comment: string,
): Promise<void> {
await db
.insert(WebsiteSetting)
.values({ key, value, comment })
.onDuplicateKeyUpdate({ set: { value } });
}
async function mediaUpload(input: unknown): Promise<ContentMutationSnapshot> {
const { file, bytes, type, extension } = await validatedImage(
record(input).file,
5 * 1024 * 1024,
MEDIA_TYPES,
);
await mkdir(MEDIA_ROOT, { recursive: true });
const name = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}.${extension}`;
const filePath = resolveMediaPath(name);
if (!filePath.startsWith(MEDIA_ROOT + path.sep)) throw validation();
await writeFile(filePath, bytes);
return {
before: null,
after: { name, size: file.size, type },
output: { name, url: `/api/media/${name}` },
};
}
async function mediaDelete(input: unknown): Promise<ContentMutationSnapshot> {
const name = text(record(input).filename ?? record(input).name, 255, true);
if (name.includes("/") || name.includes("\\")) throw validation();
let filePath: string;
try {
filePath = resolveMediaPath(name);
} catch {
throw validation();
}
if (!filePath.startsWith(MEDIA_ROOT + path.sep)) throw validation();
try {
await unlink(filePath);
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
}
return { before: { name }, after: null };
}
async function photoDelete(
input: unknown,
context: ContentMutationContext,
): Promise<ContentMutationSnapshot> {
const id = Number(record(input).id);
if (!Number.isSafeInteger(id) || id <= 0) throw validation();
const [row] = await db
.select({ id: CameraWeb.id, url: CameraWeb.url })
.from(CameraWeb)
.where(eq(CameraWeb.id, id))
.limit(1);
if (!row) throw notFound();
await db.delete(CameraWeb).where(eq(CameraWeb.id, id));
const snapshot: ContentMutationSnapshot = {
before: { id, url: row.url },
after: null,
};
try {
const removed = await tryRemoveLocalPhotoFile(row.url);
if (!removed) throw new Error("Photo file purge failed");
await logStaffActivity({
staffId: context.capability.actor.id,
action: "photo_delete",
description: `Deleted camera photo #${id}`,
targetType: "camera_web",
targetId: id,
});
} catch {
throw new ContentCommittedExternalFailure(snapshot);
}
return snapshot;
}
async function themeUpdate(
input: unknown,
context: ContentMutationContext,
): Promise<ContentMutationSnapshot> {
const data = record(input);
const source = jsonRecord(data.values ?? data);
const changed: string[] = [];
await db.transaction(async (transaction) => {
for (const mode of ["light", "dark"] as const) {
const bag: Record<string, string> = {};
for (const key of THEME_COLOR_KEYS) {
const databaseKey = settingKey(key, mode);
const raw = text(source[databaseKey], 255);
if (raw && COLOR_RE.test(raw)) bag[key] = raw;
}
for (const [key, value] of Object.entries(
ensureReadableThemeColors(bag),
)) {
const databaseKey = settingKey(
key as (typeof THEME_COLOR_KEYS)[number],
mode,
);
await transaction
.insert(WebsiteSetting)
.values({ key: databaseKey, value, comment: "Theme (housekeeping)" })
.onDuplicateKeyUpdate({ set: { value } });
changed.push(databaseKey);
}
}
const adminBag: Record<string, string> = {};
for (const key of ADMIN_COLOR_KEYS) {
const raw = text(source[key], 255);
if (raw && COLOR_RE.test(raw)) adminBag[key] = raw;
}
for (const [key, value] of Object.entries(
ensureReadableThemeColors(adminBag),
)) {
await transaction
.insert(WebsiteSetting)
.values({ key, value, comment: "Theme (housekeeping)" })
.onDuplicateKeyUpdate({ set: { value } });
changed.push(key);
}
const radius = text(source.border_radius, 3);
if (/^\d{1,3}$/u.test(radius)) {
await transaction
.insert(WebsiteSetting)
.values({
key: "border_radius",
value: radius,
comment: "Theme (housekeeping)",
})
.onDuplicateKeyUpdate({ set: { value: radius } });
changed.push("border_radius");
}
const font = text(source.font_family, 100);
if (font in FONTS) {
await transaction
.insert(WebsiteSetting)
.values({
key: "font_family",
value: font,
comment: "Theme (housekeeping)",
})
.onDuplicateKeyUpdate({ set: { value: font } });
changed.push("font_family");
}
for (const key of HEADING_KEYS) {
const value = text(source[key], 3);
if (!/^\d{1,3}$/u.test(value)) continue;
await transaction
.insert(WebsiteSetting)
.values({ key, value, comment: "Theme (housekeeping)" })
.onDuplicateKeyUpdate({ set: { value } });
changed.push(key);
}
if (Object.hasOwn(source, "custom_css")) {
const value = String(source.custom_css ?? "")
.normalize("NFC")
.slice(0, 20_000);
await transaction
.insert(WebsiteSetting)
.values({ key: "custom_css", value, comment: "Theme (housekeeping)" })
.onDuplicateKeyUpdate({ set: { value } });
changed.push("custom_css");
}
});
const snapshot: ContentMutationSnapshot = {
before: null,
after: { changedKeys: changed.sort() },
};
try {
siteSettings.reload();
await logStaffActivity({
staffId: context.capability.actor.id,
action: "theme_update",
description: "Updated theme settings",
});
} catch {
throw new ContentCommittedExternalFailure(snapshot);
}
return snapshot;
}
async function themeApplyPreset(
input: unknown,
context: ContentMutationContext,
): Promise<ContentMutationSnapshot> {
const name = text(record(input).preset, 100, true);
const preset = PRESETS[name];
if (!preset) throw validation();
await db.transaction(async (transaction) => {
for (const [key, value] of presetSettings(preset)) {
await transaction
.insert(WebsiteSetting)
.values({ key, value, comment: "Theme (housekeeping)" })
.onDuplicateKeyUpdate({ set: { value } });
}
await transaction
.insert(WebsiteSetting)
.values({
key: "theme_preset",
value: name,
comment: "Theme (housekeeping)",
})
.onDuplicateKeyUpdate({ set: { value: name } });
});
const snapshot: ContentMutationSnapshot = {
before: null,
after: { preset: name },
output: { name },
};
try {
siteSettings.reload();
await logStaffActivity({
staffId: context.capability.actor.id,
action: "theme_preset",
description: `Applied theme preset ${name}`,
});
} catch {
throw new ContentCommittedExternalFailure(snapshot);
}
return snapshot;
}
async function themeCustomChange(
input: unknown,
context: ContentMutationContext,
): Promise<ContentMutationSnapshot> {
const data = record(input);
const action = text(data.action, 16, true);
const id = text(data.id, 100);
if (action === "delete") {
if (!id) throw validation();
const existing = await getCustomTheme(id);
if (!existing) throw notFound();
await deleteCustomThemeStore(id);
return { before: { id, name: existing.name }, after: null };
}
if (action !== "create" && action !== "update" && action !== "rename")
throw validation();
const name = text(data.name, 100, true);
let settings: Record<string, string>;
if (action === "rename") {
if (!id) throw validation();
const existing = await getCustomTheme(id);
if (!existing) throw notFound();
settings = existing.settings;
} else {
settings = data.values
? Object.fromEntries(
Object.entries(jsonRecord(data.values)).map(([key, value]) => [
key,
String(value),
]),
)
: await snapshotCurrentTheme();
}
const theme = await upsertCustomTheme(name, settings, id || undefined);
await logStaffActivity({
staffId: context.capability.actor.id,
action: "theme_preset",
description: `Saved custom theme ${theme.name}`,
});
return {
before: id ? { id } : null,
after: { id: theme.id, name: theme.name },
output: { id: theme.id, name: theme.name },
};
}
async function themeApplyCustom(
input: unknown,
context: ContentMutationContext,
): Promise<ContentMutationSnapshot> {
const id = text(record(input).id, 100, true);
const theme = await getCustomTheme(id);
if (!theme) throw notFound();
await db.transaction(async (transaction) => {
for (const [key, value] of Object.entries(theme.settings)) {
if (!value) continue;
await transaction
.insert(WebsiteSetting)
.values({ key, value, comment: "Theme (housekeeping)" })
.onDuplicateKeyUpdate({ set: { value } });
}
await transaction
.insert(WebsiteSetting)
.values({
key: "theme_preset",
value: theme.name,
comment: "Theme (housekeeping)",
})
.onDuplicateKeyUpdate({ set: { value: theme.name } });
});
const snapshot: ContentMutationSnapshot = {
before: null,
after: { id, name: theme.name },
output: { name: theme.name },
};
try {
siteSettings.reload();
await logStaffActivity({
staffId: context.capability.actor.id,
action: "theme_preset",
description: `Applied custom theme ${theme.name}`,
});
} catch {
throw new ContentCommittedExternalFailure(snapshot);
}
return snapshot;
}
async function removeStoredAsset(
url: string | null | undefined,
directory: string,
prefix: string,
): Promise<void> {
if (!url?.startsWith(prefix)) return;
const name = url.slice(prefix.length);
if (!name || name.includes("..") || name.includes("/") || name.includes("\\"))
return;
const filePath = path.resolve(directory, name);
if (!filePath.startsWith(directory + path.sep)) return;
try {
await unlink(filePath);
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
}
}
async function faviconSave(input: unknown): Promise<ContentMutationSnapshot> {
const { bytes, extension } = await validatedImage(
record(input).file,
2 * 1024 * 1024,
FAVICON_TYPES,
);
const directory = resolveMediaPath("favicon");
const filename = `favicon-${Date.now()}.${extension}`;
const filePath = path.resolve(directory, filename);
if (!filePath.startsWith(directory + path.sep)) throw validation();
const oldUrl = await siteSettings.get("cms_favicon");
await mkdir(directory, { recursive: true });
await writeFile(filePath, bytes);
const url = `/api/media/favicon/${filename}`;
const snapshot: ContentMutationSnapshot = {
before: { value: oldUrl ?? null },
after: { value: url },
output: { url },
};
try {
await writeWebsiteSetting("cms_favicon", url, "Favicon URL");
} catch (error) {
try {
await unlink(filePath);
} catch {
throw new ContentCommittedExternalFailure({
before: { value: oldUrl ?? null },
after: { value: oldUrl ?? null },
output: { compensation: "failed" },
});
}
throw error;
}
try {
await removeStoredAsset(oldUrl, directory, "/api/media/favicon/");
siteSettings.reload();
} catch {
throw new ContentCommittedExternalFailure(snapshot);
}
return snapshot;
}
async function faviconDelete(): Promise<ContentMutationSnapshot> {
const oldUrl = await siteSettings.get("cms_favicon");
await db.delete(WebsiteSetting).where(eq(WebsiteSetting.key, "cms_favicon"));
const snapshot: ContentMutationSnapshot = {
before: { value: oldUrl ?? null },
after: null,
};
try {
await removeStoredAsset(
oldUrl,
resolveMediaPath("favicon"),
"/api/media/favicon/",
);
siteSettings.reload();
} catch {
throw new ContentCommittedExternalFailure(snapshot);
}
return snapshot;
}
async function logoSave(input: unknown): Promise<ContentMutationSnapshot> {
const { bytes, extension } = await validatedImage(
record(input).file,
5 * 1024 * 1024,
MEDIA_TYPES,
);
const directory = resolveMediaPath("logo");
const filename =
"logo-" +
Date.now() +
"-" +
Math.random().toString(36).slice(2, 8) +
"." +
extension;
const filePath = path.resolve(directory, filename);
if (!filePath.startsWith(directory + path.sep)) throw validation();
const oldUrl = await siteSettings.get("cms_logo");
await mkdir(directory, { recursive: true });
await writeFile(filePath, bytes);
const url = `/api/media/logo/${filename}`;
const snapshot: ContentMutationSnapshot = {
before: { value: oldUrl ?? null },
after: { value: url },
output: { url },
};
try {
await writeWebsiteSetting("cms_logo", url, "Logo (generator)");
} catch (error) {
try {
await unlink(filePath);
} catch {
throw new ContentCommittedExternalFailure({
before: { value: oldUrl ?? null },
after: { value: oldUrl ?? null },
output: { compensation: "failed" },
});
}
throw error;
}
try {
await removeStoredAsset(oldUrl, directory, "/api/media/logo/");
siteSettings.reload();
} catch {
throw new ContentCommittedExternalFailure(snapshot);
}
return snapshot;
}
async function cmsTranslationSave(
input: unknown,
): Promise<ContentMutationSnapshot> {
const data = record(input);
const locale = text(data.locale, 16, true);
if (!CMS_LOCALES.has(locale)) throw validation();
const translations = jsonRecord(data.data);
const filePath = path.join(
process.cwd(),
"src",
"messages",
`${locale}.json`,
);
await writeFile(filePath, JSON.stringify(translations, null, 2), "utf-8");
return {
before: null,
after: { locale, keyCount: Object.keys(translations).length },
};
}
async function clientTranslationSave(
input: unknown,
): Promise<ContentMutationSnapshot> {
const data = record(input);
const fileId = text(data.fileId, 100, true);
const translations = Object.fromEntries(
Object.entries(jsonRecord(data.data)).map(([key, value]) => [
key,
String(value),
]),
);
const file = getClientTranslationFile(fileId);
if (!file || file.readOnly) throw validation();
const absolutePath = path.join(process.cwd(), file.relPath);
const raw = await readFile(absolutePath, "utf-8");
if (file.format === "json") {
await writeFile(
absolutePath,
JSON.stringify(translations, null, 4),
"utf-8",
);
return {
before: null,
after: { fileId, keyCount: Object.keys(translations).length },
output: { commentsLost: false, unpatchedKeys: [] },
};
}
const original: Record<string, string> = {};
const parsed = JSONC.parse(raw);
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
for (const [key, value] of Object.entries(parsed))
original[key] = value == null ? "" : String(value);
}
const patched = patchJson5(raw, original, translations);
if (patched.unpatchedKeys.length === 0) {
await writeFile(absolutePath, patched.content, "utf-8");
} else {
await writeFile(
absolutePath,
JSON.stringify(translations, null, 4),
"utf-8",
);
}
return {
before: null,
after: { fileId, keyCount: Object.keys(translations).length },
output: {
commentsLost: patched.unpatchedKeys.length > 0,
unpatchedKeys: patched.unpatchedKeys,
},
};
}
async function emulatorTranslationSave(
input: unknown,
context: ContentMutationContext,
): Promise<ContentMutationSnapshot> {
const data = record(input);
const source = data.settings
? jsonRecord(data.settings)
: data.key
? { [text(data.key, 100, true)]: text(data.value, 512) }
: jsonRecord(data);
const entries = Object.entries(source).map(
([key, value]) => [text(key, 100, true), text(value, 512)] as const,
);
await db.transaction(async (transaction) => {
for (const [key, value] of entries) {
await transaction
.insert(EmulatorSettings)
.values({ key, value })
.onDuplicateKeyUpdate({ set: { value } });
}
});
const snapshot: ContentMutationSnapshot = {
before: null,
after: { keys: entries.map(([key]) => key).sort() },
};
try {
const delivered = await rcon.updateConfig();
if (!context.legacy && !delivered)
throw new Error("emulator configuration sync failed");
} catch {
throw new ContentCommittedExternalFailure(snapshot);
}
return snapshot;
}
const EXTERNAL_HANDLERS: Partial<
Record<
ContentMutationOperation,
(
input: unknown,
context: ContentMutationContext,
) => Promise<ContentMutationSnapshot>
>
> = {
"media.upload": (input) => mediaUpload(input),
"media.delete": (input) => mediaDelete(input),
"photo.delete": photoDelete,
"theme.update": themeUpdate,
"theme.apply-preset": themeApplyPreset,
"theme.custom-change": themeCustomChange,
"theme.apply-custom": themeApplyCustom,
"favicon.save": (input) => faviconSave(input),
"favicon.delete": () => faviconDelete(),
"logo.save": (input) => logoSave(input),
"translation.cms.save": (input) => cmsTranslationSave(input),
"translation.client.save": (input) => clientTranslationSave(input),
"translation.emulator.save": emulatorTranslationSave,
};
export async function executeContentExternalMutation(
operation: ContentMutationOperation,
input: unknown,
context: ContentMutationContext,
): Promise<ContentMutationSnapshot | null> {
const handler = EXTERNAL_HANDLERS[operation];
return handler ? handler(input, context) : null;
}
@@ -1,232 +0,0 @@
import { describe, expect, it, vi } from "vitest";
import type { AuditEntry } from "@/lib/services/audit";
import type { HousekeepingCapabilityContext } from "../../../foundation/contracts";
import { CONTENT_MUTATION_OPERATIONS } from "./mutations";
import {
CONTENT_DATABASE_OPERATIONS,
CONTENT_EXTERNAL_OPERATIONS,
CONTENT_MIXED_OPERATIONS,
ContentCommittedExternalFailure,
createContentProductionMutationAdapter,
} from "./mutations-production";
const capability: HousekeepingCapabilityContext = {
actor: { id: 42, username: "operator", rank: 7 },
isSuperAdmin: false,
has: () => true,
hasAny: () => true,
hasAll: () => true,
};
const mutationContext = {
capability,
correlationId: "production-matrix",
legacy: false,
};
function dependencies() {
const transactionToken = { transaction: true };
const writeAudit = vi.fn(
async (_entry: AuditEntry, _transaction?: unknown) => undefined,
);
const executeOperation = vi.fn(async (operation: string) => ({
before: { operation, state: "before" },
after: { operation, state: "after" },
output: { id: "18446744073709551615" },
}));
const transaction = vi.fn(async (run) => run(transactionToken));
return {
transactionToken,
writeAudit,
executeOperation,
transaction,
adapter: createContentProductionMutationAdapter({
transaction,
writeAudit,
executeOperation,
}),
};
}
describe("Content production mutation adapter", () => {
it("classifies every operation exactly once", () => {
const classified = [
...CONTENT_DATABASE_OPERATIONS,
...CONTENT_EXTERNAL_OPERATIONS,
...CONTENT_MIXED_OPERATIONS,
];
expect([...classified].sort()).toEqual(
[...CONTENT_MUTATION_OPERATIONS].sort(),
);
expect(new Set(classified).size).toBe(classified.length);
});
it("executes every production operation and serializes identifiers", async () => {
const deps = dependencies();
for (const operation of CONTENT_MUTATION_OPERATIONS) {
const snapshot = await deps.adapter.execute(
operation,
{ action: "update" },
mutationContext,
);
expect(snapshot.output?.id, operation).toBe("18446744073709551615");
}
expect(deps.executeOperation).toHaveBeenCalledTimes(
CONTENT_MUTATION_OPERATIONS.length,
);
});
it("commits database mutation and canonical success audit in one transaction", async () => {
const deps = dependencies();
await deps.adapter.execute(
"article.change",
{ action: "update", id: "18446744073709551615" },
mutationContext,
);
expect(deps.transaction).toHaveBeenCalledTimes(1);
expect(deps.executeOperation).toHaveBeenCalledWith(
"article.change",
expect.anything(),
mutationContext,
deps.transactionToken,
);
expect(deps.writeAudit).toHaveBeenCalledWith(
expect.objectContaining({
action: "content.article.change",
outcome: "success",
domain: "content",
correlationId: "production-matrix",
}),
deps.transactionToken,
);
});
it("persists correlated intent and truthful outcome around external storage writes", async () => {
const deps = dependencies();
await deps.adapter.execute(
"translation.cms.save",
{ locale: "en", data: { welcome: "Hello" } },
mutationContext,
);
expect(deps.transaction).not.toHaveBeenCalled();
expect(deps.writeAudit.mock.calls.map(([entry]) => entry.outcome)).toEqual([
"intent",
"success",
]);
expect(
deps.writeAudit.mock.calls.every(
([entry]) => entry.correlationId === "production-matrix",
),
).toBe(true);
expect(JSON.stringify(deps.writeAudit.mock.calls)).not.toContain("Hello");
});
it.each(["favicon.save", "logo.save"] as const)(
"routes %s through correlated intent and outcome audit",
async (operation) => {
const deps = dependencies();
await deps.adapter.execute(
operation,
{ file: { name: "brand.png" } },
mutationContext,
);
expect(deps.executeOperation).toHaveBeenCalledWith(
operation,
expect.anything(),
mutationContext,
);
expect(deps.writeAudit.mock.calls.map(([entry]) => entry)).toEqual([
expect.objectContaining({
action: `content.${operation}`,
outcome: "intent",
correlationId: "production-matrix",
}),
expect.objectContaining({
action: `content.${operation}`,
outcome: "success",
correlationId: "production-matrix",
}),
]);
},
);
it.each(["favicon.save", "logo.save"] as const)(
"returns a typed partial for %s when its outcome audit is unavailable",
async (operation) => {
const deps = dependencies();
deps.writeAudit
.mockResolvedValueOnce(undefined)
.mockRejectedValueOnce(new Error("audit offline"));
const snapshot = await deps.adapter.execute(
operation,
{ file: { name: "brand.png" } },
mutationContext,
);
expect(snapshot.completion).toEqual({
status: "partial",
external: "completed",
audit: "unavailable",
});
expect(deps.writeAudit.mock.calls).toEqual([
[
expect.objectContaining({
action: `content.${operation}`,
outcome: "intent",
}),
],
[
expect.objectContaining({
action: `content.${operation}`,
outcome: "success",
}),
],
]);
},
);
it("returns typed partial when database committed but the external effect failed", async () => {
const deps = dependencies();
deps.executeOperation.mockRejectedValueOnce(
new ContentCommittedExternalFailure({
before: { value: "/old.ico" },
after: { value: "/new.ico" },
}),
);
const snapshot = await deps.adapter.execute(
"favicon.save",
{ file: { name: "favicon.ico" } },
mutationContext,
);
expect(snapshot).toMatchObject({
before: { value: "/old.ico" },
after: { value: "/new.ico" },
completion: {
status: "partial",
external: "failed",
audit: "persisted",
},
});
expect(deps.writeAudit.mock.calls.at(-1)?.[0]).toMatchObject({
outcome: "partial",
});
});
it("records failure without optimistic after-state when external storage fails before commit", async () => {
const deps = dependencies();
deps.executeOperation.mockRejectedValueOnce(new Error("disk offline"));
await expect(
deps.adapter.execute(
"media.upload",
{ file: { name: "image.png" } },
mutationContext,
),
).rejects.toThrow("disk offline");
expect(deps.writeAudit.mock.calls.at(-1)?.[0]).toMatchObject({
outcome: "failure",
before: undefined,
after: undefined,
});
});
});
@@ -1,214 +0,0 @@
import type { AuditEntry } from "@/lib/services/audit";
import type {
ContentMutationAdapter,
ContentMutationContext,
ContentMutationOperation,
ContentMutationSnapshot,
} from "./mutations";
export const CONTENT_DATABASE_OPERATIONS = [
"article.change",
"ad.change",
"banner.change",
"event-type.change",
"event.change",
"event-prize.change",
"event-winner.add",
"poll.change",
"poll-question.change",
"tag.change",
"prefix.change",
"prefix-blacklist.change",
"prefix-settings.update",
"help-question.change",
"writeable-box.change",
"email-template.change",
] as const satisfies readonly ContentMutationOperation[];
export const CONTENT_EXTERNAL_OPERATIONS = [
"media.upload",
"media.delete",
"translation.cms.save",
"translation.client.save",
] as const satisfies readonly ContentMutationOperation[];
export const CONTENT_MIXED_OPERATIONS = [
"photo.delete",
"theme.update",
"theme.apply-preset",
"theme.custom-change",
"theme.apply-custom",
"favicon.save",
"favicon.delete",
"logo.save",
"translation.emulator.save",
] as const satisfies readonly ContentMutationOperation[];
type TransactionToken = unknown;
export interface ContentProductionMutationDependencies {
transaction<T>(
run: (transaction: TransactionToken) => Promise<T>,
): Promise<T>;
writeAudit(entry: AuditEntry, transaction?: TransactionToken): Promise<void>;
executeOperation(
operation: ContentMutationOperation,
input: unknown,
context: ContentMutationContext,
transaction?: TransactionToken,
): Promise<ContentMutationSnapshot>;
}
export class ContentCommittedExternalFailure extends Error {
constructor(readonly snapshot: ContentMutationSnapshot) {
super("Content database change committed but external effect failed");
this.name = "ContentCommittedExternalFailure";
}
}
function auditEntry(
operation: ContentMutationOperation,
context: ContentMutationContext,
outcome: NonNullable<AuditEntry["outcome"]>,
snapshot?: ContentMutationSnapshot,
): AuditEntry {
return {
userId: context.capability.actor.id,
action: `content.${operation}`,
target: "Content",
correlationId: context.correlationId,
domain: "content",
outcome,
before:
snapshot?.before === null || snapshot?.before === undefined
? undefined
: { ...snapshot.before },
after:
snapshot?.after === null || snapshot?.after === undefined
? undefined
: { ...snapshot.after },
};
}
function includesOperation(
operations: readonly ContentMutationOperation[],
operation: ContentMutationOperation,
): boolean {
return operations.includes(operation);
}
async function writeOutcomeOrMarkUnavailable(
dependencies: ContentProductionMutationDependencies,
operation: ContentMutationOperation,
context: ContentMutationContext,
snapshot: ContentMutationSnapshot,
outcome: "success" | "partial",
): Promise<ContentMutationSnapshot> {
try {
await dependencies.writeAudit(
auditEntry(operation, context, outcome, snapshot),
);
return snapshot;
} catch {
const completion = {
status: "partial" as const,
external:
outcome === "partial" ? ("failed" as const) : ("completed" as const),
audit: "unavailable" as const,
};
return { ...snapshot, completion };
}
}
export function createContentProductionMutationAdapter(
dependencies: ContentProductionMutationDependencies,
): ContentMutationAdapter {
return {
async execute(operation, input, context) {
if (includesOperation(CONTENT_DATABASE_OPERATIONS, operation)) {
return dependencies.transaction(async (transaction) => {
const snapshot = await dependencies.executeOperation(
operation,
input,
context,
transaction,
);
await dependencies.writeAudit(
auditEntry(operation, context, "success", snapshot),
transaction,
);
return snapshot;
});
}
await dependencies.writeAudit(auditEntry(operation, context, "intent"));
try {
const snapshot = await dependencies.executeOperation(
operation,
input,
context,
);
return writeOutcomeOrMarkUnavailable(
dependencies,
operation,
context,
snapshot,
"success",
);
} catch (error) {
if (
includesOperation(CONTENT_MIXED_OPERATIONS, operation) &&
error instanceof ContentCommittedExternalFailure
) {
const snapshot: ContentMutationSnapshot = {
...error.snapshot,
completion: {
status: "partial",
external: "failed",
audit: "persisted",
},
};
return writeOutcomeOrMarkUnavailable(
dependencies,
operation,
context,
snapshot,
"partial",
);
}
try {
await dependencies.writeAudit(
auditEntry(operation, context, "failure"),
);
} catch {
// Preserve the original dependency failure; the missing outcome is observable
// through the durable correlated intent.
}
throw error;
}
},
};
}
export const contentProductionMutationAdapter =
createContentProductionMutationAdapter({
async transaction(run) {
const { db } = await import("@/lib/db");
return db.transaction((transaction) => run(transaction));
},
async writeAudit(entry, transaction) {
const { logAudit } = await import("@/lib/services/audit");
await logAudit(entry, transaction as never);
},
async executeOperation(operation, input, context, transaction) {
const { executeContentMutationOperation } = await import(
"./mutations-runtime"
);
return executeContentMutationOperation(
operation,
input,
context,
transaction,
);
},
});
@@ -1,35 +0,0 @@
import "server-only";
import { executeContentDatabaseMutation } from "./mutation-runtime-database";
import { executeContentExternalMutation } from "./mutation-runtime-external";
import type {
ContentMutationContext,
ContentMutationOperation,
ContentMutationSnapshot,
} from "./mutations";
import { ContentMutationFailure } from "./mutations";
export async function executeContentMutationOperation(
operation: ContentMutationOperation,
input: unknown,
context: ContentMutationContext,
transaction?: unknown,
): Promise<ContentMutationSnapshot> {
const databaseResult = await executeContentDatabaseMutation(
operation,
input,
context,
transaction,
);
if (databaseResult) return databaseResult;
const externalResult = await executeContentExternalMutation(
operation,
input,
context,
);
if (externalResult) return externalResult;
throw new ContentMutationFailure(
"VALIDATION",
"errors.housekeeping.validation",
);
}
@@ -1,106 +0,0 @@
import { describe, expect, it, vi } from "vitest";
import { PERMS } from "@/lib/permission-slugs";
import type { HousekeepingCapabilityContext } from "../../../foundation/contracts";
import {
CONTENT_MUTATION_OPERATIONS,
createContentMutationInvocation,
createContentMutationService,
} from "./mutations";
function context(
granted: readonly string[],
actorId = 42,
): HousekeepingCapabilityContext {
const permissions = new Set(granted);
return {
actor: { id: actorId, 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("Content mutation service authority", () => {
it("rehydrates server authority and rejects forged actor identity", async () => {
const adapter = { execute: vi.fn() };
const service = createContentMutationService(adapter, async () =>
context([PERMS.NEWS_EDIT], 42),
);
const result = await service.execute(
{ correlationId: "forged", expectedActorId: 7 },
"article.change",
{ action: "create", title: "Forged" },
);
expect(result).toMatchObject({
ok: false,
error: { code: "FORBIDDEN" },
});
expect(adapter.execute).not.toHaveBeenCalled();
});
it("requires the exact operation ACL even after dispatcher authorization", async () => {
const adapter = { execute: vi.fn() };
const service = createContentMutationService(adapter, async () =>
context([PERMS.NEWS_EDIT]),
);
const result = await service.execute(
{ correlationId: "brand", expectedActorId: 42 },
"theme.update",
{},
);
expect(result).toMatchObject({
ok: false,
error: { code: "FORBIDDEN" },
});
expect(adapter.execute).not.toHaveBeenCalled();
});
it("executes every declared operation through the real service boundary", async () => {
const execute = vi.fn(async (_operation, _input, mutationContext) => ({
before: null,
after: { actorId: mutationContext.capability.actor.id },
}));
const service = createContentMutationService({ execute }, async () =>
context(Object.values(PERMS)),
);
const invocation = createContentMutationInvocation(
{ id: 42 },
"operation-matrix",
);
for (const operation of CONTENT_MUTATION_OPERATIONS) {
const result = await service.execute(invocation, operation, {});
expect(result.ok, operation).toBe(true);
}
expect(execute).toHaveBeenCalledTimes(CONTENT_MUTATION_OPERATIONS.length);
});
it("maps adapter failures without exposing storage or template payloads", async () => {
const service = createContentMutationService(
{
execute: async () => {
throw new Error("C:\\private\\template.json: secret body");
},
},
async () => context([PERMS.SETTINGS_EDIT]),
);
const result = await service.execute(
{ correlationId: "redacted", expectedActorId: 42 },
"translation.cms.save",
{ data: { secret: "body" } },
);
expect(result).toMatchObject({
ok: false,
error: {
code: "DEPENDENCY_UNAVAILABLE",
messageKey: "errors.housekeeping.dependencyUnavailable",
},
});
expect(JSON.stringify(result)).not.toContain("secret body");
});
});
@@ -1,206 +0,0 @@
import { PERMS } from "@/lib/permission-slugs";
import { satisfiesCapability } from "../../../foundation/capability-context";
import {
anyCapability,
fail,
type HousekeepingCapabilityContext,
type HousekeepingErrorCode,
type HousekeepingPartialCompletion,
type HousekeepingResult,
ok,
} from "../../../foundation/contracts";
export const CONTENT_MUTATION_OPERATIONS = [
"article.change",
"ad.change",
"banner.change",
"event-type.change",
"event.change",
"event-prize.change",
"event-winner.add",
"poll.change",
"poll-question.change",
"photo.delete",
"media.upload",
"media.delete",
"tag.change",
"prefix.change",
"prefix-blacklist.change",
"prefix-settings.update",
"help-question.change",
"writeable-box.change",
"email-template.change",
"theme.update",
"theme.apply-preset",
"theme.custom-change",
"theme.apply-custom",
"favicon.save",
"favicon.delete",
"logo.save",
"translation.cms.save",
"translation.client.save",
"translation.emulator.save",
] as const;
export type ContentMutationOperation =
(typeof CONTENT_MUTATION_OPERATIONS)[number];
export interface ContentMutationSnapshot {
readonly before: Readonly<Record<string, unknown>> | null;
readonly after: Readonly<Record<string, unknown>> | null;
readonly output?: Readonly<Record<string, unknown>>;
readonly completion?: HousekeepingPartialCompletion;
}
export interface ContentMutationInvocation {
readonly correlationId: string;
readonly expectedActorId: number;
readonly legacy?: boolean;
}
export interface ContentMutationContext {
readonly capability: HousekeepingCapabilityContext;
readonly correlationId: string;
readonly legacy: boolean;
}
export interface ContentMutationAdapter {
execute(
operation: ContentMutationOperation,
input: unknown,
context: ContentMutationContext,
): Promise<ContentMutationSnapshot>;
}
export interface ContentMutationService {
execute(
invocation: ContentMutationInvocation,
operation: ContentMutationOperation,
input: unknown,
): Promise<HousekeepingResult<ContentMutationSnapshot>>;
}
export class ContentMutationFailure extends Error {
constructor(
readonly code: HousekeepingErrorCode,
readonly messageKey: string,
readonly fieldErrors?: Readonly<Record<string, readonly string[]>>,
) {
super(messageKey);
this.name = "ContentMutationFailure";
}
}
const OPERATION_PERMISSION = Object.freeze({
"article.change": PERMS.NEWS_EDIT,
"ad.change": PERMS.PAGES_EDIT,
"banner.change": PERMS.BANNERS_EDIT,
"event-type.change": PERMS.EVENTS_EDIT,
"event.change": PERMS.EVENTS_EDIT,
"event-prize.change": PERMS.EVENTS_EDIT,
"event-winner.add": PERMS.EVENTS_EDIT,
"poll.change": PERMS.POLLS_EDIT,
"poll-question.change": PERMS.POLLS_EDIT,
"photo.delete": PERMS.PAGES_EDIT,
"media.upload": PERMS.PAGES_EDIT,
"media.delete": PERMS.PAGES_EDIT,
"tag.change": PERMS.PAGES_EDIT,
"prefix.change": PERMS.PREFIXES_EDIT,
"prefix-blacklist.change": PERMS.PREFIXES_EDIT,
"prefix-settings.update": PERMS.PREFIXES_EDIT,
"help-question.change": PERMS.PAGES_EDIT,
"writeable-box.change": PERMS.PAGES_EDIT,
"email-template.change": PERMS.PAGES_EDIT,
"theme.update": PERMS.SETTINGS_EDIT,
"theme.apply-preset": PERMS.SETTINGS_EDIT,
"theme.custom-change": PERMS.SETTINGS_EDIT,
"theme.apply-custom": PERMS.SETTINGS_EDIT,
"favicon.save": PERMS.SETTINGS_EDIT,
"favicon.delete": PERMS.SETTINGS_EDIT,
"logo.save": PERMS.SETTINGS_EDIT,
"translation.cms.save": PERMS.SETTINGS_EDIT,
"translation.client.save": PERMS.SETTINGS_EDIT,
"translation.emulator.save": PERMS.SETTINGS_EDIT,
} satisfies Record<ContentMutationOperation, string>);
export function contentMutationCapability(operation: ContentMutationOperation) {
return anyCapability(OPERATION_PERMISSION[operation]);
}
export function createContentMutationService(
adapter: ContentMutationAdapter,
resolveCapabilityContext: () => Promise<HousekeepingCapabilityContext>,
): ContentMutationService {
return {
async execute(invocation, operation, input) {
let capability: HousekeepingCapabilityContext;
try {
capability = await resolveCapabilityContext();
} catch {
return fail(
"UNAUTHENTICATED",
"errors.housekeeping.unauthenticated",
invocation.correlationId,
);
}
if (
capability.actor.id !== invocation.expectedActorId ||
!satisfiesCapability(capability, contentMutationCapability(operation))
) {
return fail(
"FORBIDDEN",
"errors.housekeeping.forbidden",
invocation.correlationId,
);
}
try {
const snapshot = await adapter.execute(operation, input, {
capability,
correlationId: invocation.correlationId,
legacy: invocation.legacy === true,
});
return ok(snapshot, invocation.correlationId, snapshot.completion);
} catch (error) {
if (error instanceof ContentMutationFailure) {
return fail(
error.code,
error.messageKey,
invocation.correlationId,
error.fieldErrors,
);
}
return fail(
"DEPENDENCY_UNAVAILABLE",
"errors.housekeeping.dependencyUnavailable",
invocation.correlationId,
);
}
},
};
}
export function createContentMutationInvocation(
actor: { readonly id: number },
correlationId: string,
): ContentMutationInvocation {
return { correlationId, expectedActorId: actor.id, legacy: true };
}
const productionAdapter: ContentMutationAdapter = {
async execute(operation, input, context) {
const { contentProductionMutationAdapter } = await import(
"./mutations-production"
);
return contentProductionMutationAdapter.execute(operation, input, context);
},
};
export const contentMutationService = createContentMutationService(
productionAdapter,
async () => {
const { getHousekeepingCapabilityContext } = await import(
"../../../foundation/server-capability-context"
);
return getHousekeepingCapabilityContext();
},
);
@@ -1,54 +0,0 @@
import "server-only";
import { PERMS } from "@/lib/permission-slugs";
import type { HousekeepingCapabilityContext } from "../../foundation/contracts";
import { contentQuery } from "./queries/content-queries";
type ContentWidgetKind = "editorial" | "media" | "localization";
async function total(
context: HousekeepingCapabilityContext,
routeId:
| "content.editorial.articles"
| "content.media.banners"
| "content.media.photos"
| "content.media.library"
| "content.localization.overview",
): Promise<number> {
const result = await contentQuery.run(context, {
routeId,
list: { pageSize: 1, offset: 0 },
});
if (!result.ok) throw new Error("Content widget query unavailable");
return result.data.total;
}
export async function loadContentWidget(
kind: ContentWidgetKind,
context: HousekeepingCapabilityContext,
signal: AbortSignal,
): Promise<Readonly<Record<string, number>>> {
if (signal.aborted) throw new Error("aborted Content widget");
if (kind === "editorial") {
const articles = await total(context, "content.editorial.articles");
return { articles };
}
if (kind === "media") {
const counts: Record<string, number> = {};
if (context.has(PERMS.PAGES_VIEW)) {
const [photos, library] = await Promise.all([
total(context, "content.media.photos"),
total(context, "content.media.library"),
]);
counts.photos = photos;
counts.library = library;
counts.items = photos + library;
}
if (context.has(PERMS.BANNERS_VIEW)) {
counts.banners = await total(context, "content.media.banners");
}
return counts;
}
const stores = await total(context, "content.localization.overview");
return { stores };
}
@@ -1,94 +0,0 @@
import { PERMS } from "@/lib/permission-slugs";
import { authorizeHousekeeping } from "../../foundation/authorization";
import {
anyCapability,
type CapabilityRequirement,
fail,
type HousekeepingCapabilityContext,
type HousekeepingWidgetDefinition,
ok,
} from "../../foundation/contracts";
export const CONTENT_WIDGET_IDS = [
"content.editorial-summary",
"content.media-summary",
"content.localization-summary",
] as const;
type ContentWidgetLoader = (
context: HousekeepingCapabilityContext,
signal: AbortSignal,
) => Promise<Readonly<Record<string, number>>>;
export interface ContentWidgetAdapters {
readonly editorial: ContentWidgetLoader;
readonly media: ContentWidgetLoader;
readonly localization: ContentWidgetLoader;
}
function createWidget(
id: (typeof CONTENT_WIDGET_IDS)[number],
kind: "mandatory" | "optional",
capability: CapabilityRequirement,
load: ContentWidgetLoader,
): HousekeepingWidgetDefinition {
return {
id,
owner: "content",
kind,
capability,
async load(context, signal) {
const authorization = authorizeHousekeeping(context, capability);
if (!authorization.ok) return authorization;
try {
return ok(await load(context, signal), authorization.correlationId);
} catch {
return fail(
"DEPENDENCY_UNAVAILABLE",
"errors.housekeeping.dependencyUnavailable",
authorization.correlationId,
);
}
},
};
}
export function createContentWidgets(
adapters: ContentWidgetAdapters,
): readonly HousekeepingWidgetDefinition[] {
return [
createWidget(
"content.editorial-summary",
"mandatory",
anyCapability(PERMS.NEWS_VIEW),
adapters.editorial,
),
createWidget(
"content.media-summary",
"optional",
anyCapability(PERMS.PAGES_VIEW, PERMS.BANNERS_VIEW),
adapters.media,
),
createWidget(
"content.localization-summary",
"optional",
anyCapability(PERMS.SETTINGS_VIEW),
adapters.localization,
),
];
}
export const CONTENT_WIDGETS = createContentWidgets({
async editorial(context, signal) {
const { loadContentWidget } = await import("./widgets-production");
return loadContentWidget("editorial", context, signal);
},
async media(context, signal) {
const { loadContentWidget } = await import("./widgets-production");
return loadContentWidget("media", context, signal);
},
async localization(context, signal) {
const { loadContentWidget } = await import("./widgets-production");
return loadContentWidget("localization", context, signal);
},
});
@@ -1,156 +0,0 @@
import { describe, expect, it, vi } from "vitest";
import { PERMS } from "@/lib/permission-slugs";
import type { HousekeepingCapabilityContext } from "../../../foundation/contracts";
import { createEconomyCommands, ECONOMY_COMMAND_IDS } from "./economy-commands";
const expected = [
["economy.catalog.page.change", "catalog-page.change", PERMS.CATALOG_EDIT],
["economy.catalog.page.reorder", "catalog-page.reorder", PERMS.CATALOG_EDIT],
[
"economy.catalog.page.delete-tree",
"catalog-page.delete-tree",
PERMS.CATALOG_EDIT,
],
["economy.catalog.bc-page.change", "bc-page.change", PERMS.CATALOG_EDIT],
["economy.catalog.bc-page.reorder", "bc-page.reorder", PERMS.CATALOG_EDIT],
[
"economy.catalog.bc-page.delete-tree",
"bc-page.delete-tree",
PERMS.CATALOG_EDIT,
],
["economy.catalog.bc-item.change", "bc-item.change", PERMS.CATALOG_EDIT],
["economy.catalog.item.change", "catalog-item.change", PERMS.CATALOG_EDIT],
[
"economy.catalog.item.bulk-create",
"catalog-item.bulk-create",
PERMS.CATALOG_EDIT,
],
["economy.catalog.item.move", "catalog-item.move", PERMS.CATALOG_EDIT],
["economy.catalog.item.reorder", "catalog-item.reorder", PERMS.CATALOG_EDIT],
[
"economy.catalog.item.translate",
"catalog-item.translate",
PERMS.CATALOG_EDIT,
],
[
"economy.catalog.maintenance.fix-offers",
"maintenance.fix-offers",
PERMS.CATALOG_EDIT,
],
[
"economy.catalog.maintenance.fix-everything",
"maintenance.fix-everything",
PERMS.CATALOG_EDIT,
],
[
"economy.catalog.maintenance.fix-sprite-ids",
"maintenance.fix-sprite-ids",
PERMS.CATALOG_EDIT,
],
[
"economy.catalog.maintenance.reconcile-ids",
"maintenance.reconcile-ids",
PERMS.CATALOG_EDIT,
],
[
"economy.catalog.maintenance.align-ids",
"maintenance.align-ids",
PERMS.CATALOG_EDIT,
],
[
"economy.catalog.maintenance.remove-duplicates",
"maintenance.remove-duplicates",
PERMS.CATALOG_EDIT,
],
["economy.items.base.update", "items-base.update", PERMS.CATALOG_EDIT],
[
"economy.commerce.shop-article.change",
"shop-article.change",
PERMS.SHOP_EDIT,
],
[
"economy.commerce.marketplace.cancel",
"marketplace.cancel",
PERMS.SHOP_EDIT,
],
["economy.commerce.voucher.change", "voucher.change", PERMS.SHOP_EDIT],
["economy.value.category.change", "rare-category.change", PERMS.SHOP_EDIT],
["economy.value.rare.change", "rare-value.change", PERMS.SHOP_EDIT],
["economy.rewards.badge.give", "badge.give", PERMS.CATALOG_EDIT],
["economy.rewards.badge.upload", "badge.upload", PERMS.CATALOG_EDIT],
[
"economy.rewards.soundtrack.change",
"soundtrack.change",
PERMS.CATALOG_EDIT,
],
] as const;
function context(): HousekeepingCapabilityContext {
return {
actor: { id: 42, username: "operator", rank: 7 },
isSuperAdmin: false,
has: () => true,
hasAny: () => true,
hasAll: () => true,
};
}
describe("Economy commands", () => {
it("registers the exact 27-operation matrix with existing ACLs", () => {
const commands = createEconomyCommands({ execute: vi.fn() } as never);
expect(ECONOMY_COMMAND_IDS).toEqual(expected.map(([id]) => id));
expect(
commands.map((command) => [
command.id,
command.operation,
command.capability.slugs[0],
]),
).toEqual(expected);
expect(commands.every((command) => command.owner === "economy")).toBe(true);
expect(commands.every((command) => command.risk === "sensitive")).toBe(
true,
);
});
it("requires a reason for destructive, maintenance, cancellation, and grant operations", () => {
const commands = createEconomyCommands({ execute: vi.fn() } as never);
const required = commands
.filter((command) => command.requiresReason)
.map((command) => command.id);
expect(required).toEqual([
"economy.catalog.page.delete-tree",
"economy.catalog.bc-page.delete-tree",
"economy.catalog.maintenance.fix-offers",
"economy.catalog.maintenance.fix-everything",
"economy.catalog.maintenance.fix-sprite-ids",
"economy.catalog.maintenance.reconcile-ids",
"economy.catalog.maintenance.align-ids",
"economy.catalog.maintenance.remove-duplicates",
"economy.commerce.marketplace.cancel",
"economy.rewards.badge.give",
"economy.rewards.badge.upload",
]);
});
it("binds real execution to the server-authorized actor", async () => {
const execute = vi.fn(async () => ({
ok: true as const,
data: { before: null, after: { id: "1" } },
correlationId: "economy-command",
}));
const [command] = createEconomyCommands({ execute } as never);
await command.execute(
{
capability: context(),
correlationId: "economy-command",
ipAddress: "127.0.0.1",
},
{ action: "update", id: "1", caption: "Seasonal" },
);
expect(execute).toHaveBeenCalledWith(
{ correlationId: "economy-command", expectedActorId: 42 },
"catalog-page.change",
{ action: "update", id: "1", caption: "Seasonal" },
);
});
});
@@ -1,145 +0,0 @@
import "server-only";
import { z } from "zod";
import { PERMS } from "@/lib/permission-slugs";
import type { HousekeepingCommand } from "../../../foundation/commands/registry";
import { anyCapability } from "../../../foundation/contracts";
import {
type EconomyMutationOperation,
type EconomyMutationService,
economyMutationService,
} from "../services/mutations";
const ECONOMY_COMMAND_DEFINITIONS = [
["economy.catalog.page.change", "catalog-page.change", PERMS.CATALOG_EDIT],
["economy.catalog.page.reorder", "catalog-page.reorder", PERMS.CATALOG_EDIT],
[
"economy.catalog.page.delete-tree",
"catalog-page.delete-tree",
PERMS.CATALOG_EDIT,
],
["economy.catalog.bc-page.change", "bc-page.change", PERMS.CATALOG_EDIT],
["economy.catalog.bc-page.reorder", "bc-page.reorder", PERMS.CATALOG_EDIT],
[
"economy.catalog.bc-page.delete-tree",
"bc-page.delete-tree",
PERMS.CATALOG_EDIT,
],
["economy.catalog.bc-item.change", "bc-item.change", PERMS.CATALOG_EDIT],
["economy.catalog.item.change", "catalog-item.change", PERMS.CATALOG_EDIT],
[
"economy.catalog.item.bulk-create",
"catalog-item.bulk-create",
PERMS.CATALOG_EDIT,
],
["economy.catalog.item.move", "catalog-item.move", PERMS.CATALOG_EDIT],
["economy.catalog.item.reorder", "catalog-item.reorder", PERMS.CATALOG_EDIT],
[
"economy.catalog.item.translate",
"catalog-item.translate",
PERMS.CATALOG_EDIT,
],
[
"economy.catalog.maintenance.fix-offers",
"maintenance.fix-offers",
PERMS.CATALOG_EDIT,
],
[
"economy.catalog.maintenance.fix-everything",
"maintenance.fix-everything",
PERMS.CATALOG_EDIT,
],
[
"economy.catalog.maintenance.fix-sprite-ids",
"maintenance.fix-sprite-ids",
PERMS.CATALOG_EDIT,
],
[
"economy.catalog.maintenance.reconcile-ids",
"maintenance.reconcile-ids",
PERMS.CATALOG_EDIT,
],
[
"economy.catalog.maintenance.align-ids",
"maintenance.align-ids",
PERMS.CATALOG_EDIT,
],
[
"economy.catalog.maintenance.remove-duplicates",
"maintenance.remove-duplicates",
PERMS.CATALOG_EDIT,
],
["economy.items.base.update", "items-base.update", PERMS.CATALOG_EDIT],
[
"economy.commerce.shop-article.change",
"shop-article.change",
PERMS.SHOP_EDIT,
],
[
"economy.commerce.marketplace.cancel",
"marketplace.cancel",
PERMS.SHOP_EDIT,
],
["economy.commerce.voucher.change", "voucher.change", PERMS.SHOP_EDIT],
["economy.value.category.change", "rare-category.change", PERMS.SHOP_EDIT],
["economy.value.rare.change", "rare-value.change", PERMS.SHOP_EDIT],
["economy.rewards.badge.give", "badge.give", PERMS.CATALOG_EDIT],
["economy.rewards.badge.upload", "badge.upload", PERMS.CATALOG_EDIT],
[
"economy.rewards.soundtrack.change",
"soundtrack.change",
PERMS.CATALOG_EDIT,
],
] as const;
export const ECONOMY_COMMAND_IDS = ECONOMY_COMMAND_DEFINITIONS.map(
([id]) => id,
) as ReadonlyArray<(typeof ECONOMY_COMMAND_DEFINITIONS)[number][0]>;
const REASON_COMMANDS = new Set<string>([
"economy.catalog.page.delete-tree",
"economy.catalog.bc-page.delete-tree",
"economy.catalog.maintenance.fix-offers",
"economy.catalog.maintenance.fix-everything",
"economy.catalog.maintenance.fix-sprite-ids",
"economy.catalog.maintenance.reconcile-ids",
"economy.catalog.maintenance.align-ids",
"economy.catalog.maintenance.remove-duplicates",
"economy.commerce.marketplace.cancel",
"economy.rewards.badge.give",
"economy.rewards.badge.upload",
]);
type EconomyCommand = HousekeepingCommand<Record<string, unknown>, unknown> & {
readonly operation: EconomyMutationOperation;
};
const commandInput = z.object({}).catchall(z.unknown());
export function createEconomyCommands(
service: Pick<EconomyMutationService, "execute">,
): readonly EconomyCommand[] {
return ECONOMY_COMMAND_DEFINITIONS.map(
([id, operation, permission]): EconomyCommand => ({
id,
owner: "economy",
operation,
risk: "sensitive",
capability: anyCapability(permission),
input: commandInput,
requiresReason: REASON_COMMANDS.has(id),
rateLimit: { attempts: 10, windowMs: 60_000 },
execute: (context, input) =>
service.execute(
{
correlationId: context.correlationId,
expectedActorId: context.capability.actor.id,
},
operation,
input,
),
}),
);
}
export const ECONOMY_COMMANDS = createEconomyCommands(economyMutationService);
@@ -1,126 +0,0 @@
import { describe, expect, it, vi } from "vitest";
import { PERMS } from "@/lib/permission-slugs";
import type { HousekeepingCapabilityContext } from "../../foundation/contracts";
import { fail, ok } from "../../foundation/contracts";
import { loadEconomyInboxItems } from "./inbox-production";
import { loadEconomySearchCandidates } from "./search-production";
import { loadEconomyWidget } from "./widgets-production";
const context: HousekeepingCapabilityContext = {
actor: { id: 42, username: "operator", rank: 7 },
isSuperAdmin: false,
has: () => true,
hasAny: () => true,
hasAll: () => true,
};
function queryResult(
items: readonly {
id: string;
title: string;
description?: string;
status?: string;
updatedAt?: string | null;
href?: `/ase/${string}`;
}[],
total = items.length,
) {
return ok(
{
kind: "catalog" as const,
items,
total,
partialDependencies: [],
},
"economy-provider-production",
);
}
describe("Economy production providers", () => {
it("routes search through the bounded production query", async () => {
const run = vi.fn().mockResolvedValue(
queryResult([
{
id: "7",
title: "Root catalog",
description: "parent -1",
href: "/ase/economy/catalog/7",
},
]),
);
const candidates = await loadEconomySearchCandidates(
"catalog-pages",
context,
"root",
25,
{ run },
);
expect(run).toHaveBeenCalledWith(context, {
routeId: "economy.catalog.overview",
list: { search: "root", pageSize: 25, offset: 0 },
});
expect(candidates[0]).toMatchObject({
id: "economy.catalog.overview:7",
href: "/ase/economy/catalog/7",
capability: { mode: "any", slugs: [PERMS.CATALOG_VIEW] },
});
});
it("returns truthful widget totals and never invents zero on failure", async () => {
const run = vi
.fn()
.mockResolvedValueOnce(queryResult([], 12))
.mockResolvedValueOnce(queryResult([], 340));
await expect(
loadEconomyWidget("catalog", context, new AbortController().signal, {
run,
}),
).resolves.toEqual({ pages: 12, items: 340 });
const unavailable = vi
.fn()
.mockResolvedValue(
fail(
"DEPENDENCY_UNAVAILABLE",
"errors.housekeeping.dependencyUnavailable",
"offline",
),
);
await expect(
loadEconomyWidget("value", context, new AbortController().signal, {
run: unavailable,
}),
).rejects.toThrow("Economy widget query unavailable");
});
it("emits only actionable commerce anomalies", async () => {
const run = vi.fn().mockResolvedValue(
queryResult([
{
id: "1",
title: "Pending order",
status: "pending",
updatedAt: "2026-08-30T10:00:00.000Z",
href: "/ase/economy/history/transactions",
},
{
id: "2",
title: "Completed order",
status: "completed",
href: "/ase/economy/history/transactions",
},
]),
);
const items = await loadEconomyInboxItems(
"commerce",
context,
new AbortController().signal,
{ run },
);
expect(items).toHaveLength(1);
expect(items[0]).toMatchObject({
sourceId: "economy.commerce-anomalies",
itemId: "1",
state: "pending",
});
});
});
@@ -1,103 +0,0 @@
import { describe, expect, it, vi } from "vitest";
import { PERMS } from "@/lib/permission-slugs";
import {
anyCapability,
type HousekeepingCapabilityContext,
} from "../../foundation/contracts";
import { createEconomyInboxSources, ECONOMY_INBOX_SOURCE_IDS } from "./inbox";
import {
createEconomySearchProviders,
ECONOMY_SEARCH_PROVIDER_IDS,
} from "./search";
import { createEconomyWidgets, ECONOMY_WIDGET_IDS } 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("Economy providers", () => {
it("registers exact bounded catalog, item, and transaction search providers", async () => {
const candidates = Array.from({ length: 30 }, (_, index) => ({
id: `item-${index}`,
title: `Item ${index}`,
href: `/ase/economy/items/${index + 1}`,
capability: anyCapability(PERMS.CATALOG_VIEW),
}));
const adapters = {
catalogPages: vi.fn(async () => []),
items: vi.fn(async () => candidates),
transactions: vi.fn(async () => []),
};
const providers = createEconomySearchProviders(adapters);
expect(ECONOMY_SEARCH_PROVIDER_IDS).toEqual([
"economy.catalog-pages",
"economy.items",
"economy.transactions",
]);
expect(providers.map((provider) => provider.id)).toEqual(
ECONOMY_SEARCH_PROVIDER_IDS,
);
const result = await providers[1].search(context([PERMS.CATALOG_VIEW]), {
term: " sofa ",
limit: 999,
});
expect(adapters.items).toHaveBeenCalledWith(expect.anything(), "sofa", 25);
expect(result).toMatchObject({ ok: true });
if (result.ok) expect(result.data).toHaveLength(25);
});
it("exposes capability-selective catalog and commerce anomaly sources", async () => {
const catalog = vi.fn(async () => []);
const commerce = vi.fn(async () => []);
const sources = createEconomyInboxSources({ catalog, commerce });
expect(ECONOMY_INBOX_SOURCE_IDS).toEqual([
"economy.catalog-attention",
"economy.commerce-anomalies",
]);
await sources[0].getItems(
context([PERMS.CATALOG_VIEW]),
new AbortController().signal,
);
const forbidden = await sources[1].getItems(
context([PERMS.CATALOG_VIEW]),
new AbortController().signal,
);
expect(catalog).toHaveBeenCalledOnce();
expect(commerce).not.toHaveBeenCalled();
expect(forbidden).toMatchObject({
ok: false,
error: { code: "FORBIDDEN" },
});
});
it("keeps catalog mandatory and commerce/value widgets optional", async () => {
const widgets = createEconomyWidgets({
catalog: vi.fn(async () => ({ pages: 12, items: 40 })),
commerce: vi.fn(async () => ({ orders: 4 })),
value: vi.fn(async () => ({ rares: 9 })),
});
expect(ECONOMY_WIDGET_IDS).toEqual([
"economy.catalog-summary",
"economy.commerce-summary",
"economy.value-summary",
]);
expect(widgets.map((widget) => widget.kind)).toEqual([
"mandatory",
"optional",
"optional",
]);
expect(
await widgets[0].load(
context([PERMS.CATALOG_VIEW]),
new AbortController().signal,
),
).toMatchObject({ ok: true, data: { pages: 12, items: 40 } });
});
});
@@ -1,81 +0,0 @@
import "server-only";
import { PERMS } from "@/lib/permission-slugs";
import {
anyCapability,
type HousekeepingCapabilityContext,
type HousekeepingWorkItem,
} from "../../foundation/contracts";
import { economyQuery } from "./queries/catalog";
type EconomyInboxKind = "catalog" | "commerce";
function age(value: string | null | undefined, fallback: number) {
const parsed = value ? Date.parse(value) : fallback;
const timestamp = Number.isFinite(parsed) ? parsed : fallback;
const ageMs = Math.max(0, Date.now() - timestamp);
return {
occurredAt: new Date(timestamp).toISOString(),
ageMs,
freshness: ageMs > 86_400_000 ? ("stale" as const) : ("fresh" as const),
};
}
export async function loadEconomyInboxItems(
kind: EconomyInboxKind,
context: HousekeepingCapabilityContext,
signal: AbortSignal,
query: Pick<typeof economyQuery, "run"> = economyQuery,
): Promise<readonly HousekeepingWorkItem[]> {
if (signal.aborted) throw new Error("aborted Economy inbox");
const routeId =
kind === "catalog"
? ("economy.catalog.overview" as const)
: ("economy.history.transactions" as const);
const result = await query.run(context, {
routeId,
list: { pageSize: 25, offset: 0 },
});
if (!result.ok) throw new Error("Economy inbox query unavailable");
const observedAt = Date.now();
const actionable =
kind === "catalog"
? new Set(["hidden"])
: new Set([
"failed",
"denied",
"refunded",
"cancelled",
"canceled",
"pending",
]);
return result.data.items.flatMap((item) => {
const status = item.status?.toLocaleLowerCase() ?? "";
if (!actionable.has(status) || !item.href) return [];
const sourceId =
kind === "catalog"
? "economy.catalog-attention"
: "economy.commerce-anomalies";
const permission =
kind === "catalog" ? PERMS.CATALOG_VIEW : PERMS.SHOP_VIEW;
return [
{
sourceId,
itemId: item.id,
deduplicationKey: `${sourceId}:${routeId}:${item.id}`,
domain: "economy" as const,
capability: anyCapability(permission),
severity:
status === "pending" ? ("info" as const) : ("warning" as const),
priority:
status === "pending" ? ("normal" as const) : ("high" as const),
...age(item.updatedAt, observedAt),
state: status,
titleKey: "pages.housekeeping.items.economy",
context: { title: item.title },
href: item.href as `/ase/${string}`,
actions: [],
},
];
});
}
@@ -1,94 +0,0 @@
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 HousekeepingInboxSource,
type HousekeepingWorkItem,
ok,
} from "../../foundation/contracts";
import { isSafeHousekeepingHref } from "../../foundation/housekeeping-href";
export const ECONOMY_INBOX_SOURCE_IDS = [
"economy.catalog-attention",
"economy.commerce-anomalies",
] as const;
type EconomyInboxLoader = (
context: HousekeepingCapabilityContext,
signal: AbortSignal,
) => Promise<readonly HousekeepingWorkItem[]>;
export interface EconomyInboxAdapters {
readonly catalog: EconomyInboxLoader;
readonly commerce: EconomyInboxLoader;
}
function createSource(
id: (typeof ECONOMY_INBOX_SOURCE_IDS)[number],
capability: CapabilityRequirement,
load: EconomyInboxLoader,
): HousekeepingInboxSource {
return {
id,
owner: "economy",
capability,
async getItems(context, signal) {
const authorization = authorizeHousekeeping(context, capability);
if (!authorization.ok) return authorization;
try {
const items = await load(context, signal);
return ok(
{
availability: "available" as const,
items: items
.filter(
(item) =>
isSafeHousekeepingHref(item.href) &&
satisfiesCapability(context, item.capability),
)
.slice(0, 25),
},
authorization.correlationId,
);
} catch {
return fail(
"DEPENDENCY_UNAVAILABLE",
"errors.housekeeping.dependencyUnavailable",
authorization.correlationId,
);
}
},
};
}
export function createEconomyInboxSources(
adapters: EconomyInboxAdapters,
): readonly HousekeepingInboxSource[] {
return [
createSource(
"economy.catalog-attention",
anyCapability(PERMS.CATALOG_VIEW),
adapters.catalog,
),
createSource(
"economy.commerce-anomalies",
anyCapability(PERMS.SHOP_VIEW),
adapters.commerce,
),
];
}
export const ECONOMY_INBOX_SOURCES = createEconomyInboxSources({
async catalog(context, signal) {
const { loadEconomyInboxItems } = await import("./inbox-production");
return loadEconomyInboxItems("catalog", context, signal);
},
async commerce(context, signal) {
const { loadEconomyInboxItems } = await import("./inbox-production");
return loadEconomyInboxItems("commerce", context, signal);
},
});
@@ -3,25 +3,21 @@ import {
anyCapability,
type HousekeepingDomainManifest,
} from "../../foundation/contracts";
import { ECONOMY_INBOX_SOURCES } from "./inbox";
import { ECONOMY_ROUTES } from "./routes";
import { ECONOMY_SEARCH_PROVIDERS } from "./search";
import { ECONOMY_WIDGETS } from "./widgets";
export const economyManifest = {
id: "economy",
labelKey: "pages.housekeeping.domains.economy.title",
descriptionKey: "pages.housekeeping.domains.economy.description",
iconId: "gem",
canonicalHref: "/ase/economy",
previewHref: "/admin-next/economy",
capability: anyCapability(
PERMS.CATALOG_VIEW,
PERMS.SHOP_VIEW,
PERMS.CATALOG_EDIT,
PERMS.SHOP_EDIT,
),
routes: ECONOMY_ROUTES,
searchProviders: ECONOMY_SEARCH_PROVIDERS,
inboxSources: ECONOMY_INBOX_SOURCES,
widgets: ECONOMY_WIDGETS,
routes: [],
searchProviders: [],
inboxSources: [],
widgets: [],
} satisfies HousekeepingDomainManifest;
@@ -1,115 +0,0 @@
import type { ReactNode } from "react";
import { PERMS } from "@/lib/permission-slugs";
import type { HousekeepingPageInput } from "../../../route-handlers";
import { economyQuery } from "../queries/catalog";
import { EconomyCommandForm } from "./economy-command-form";
import {
EconomyPageFrame,
type EconomyPageProps,
parseEconomyListInput,
} from "./economy-page-frame";
function forms({ context, routeId }: EconomyPageProps) {
if (!context.has(PERMS.CATALOG_EDIT)) return null;
if (routeId === "economy.catalog.maintenance") {
return (
<div className="grid gap-3 lg:grid-cols-2">
{[
["fix-offers", "Fix catalog offers"],
["fix-everything", "Run complete catalog repair"],
["fix-sprite-ids", "Fix sprite identifiers"],
["reconcile-ids", "Reconcile identifiers"],
["align-ids", "Align identifiers"],
["remove-duplicates", "Remove duplicate base items"],
].map(([operation, label]) => (
<EconomyCommandForm
key={operation}
commandId={`economy.catalog.maintenance.${operation}`}
buttonLabel={label}
input={{}}
requiresReason
/>
))}
</div>
);
}
const builderClub = routeId === "economy.catalog.builder-club-detail";
return (
<EconomyCommandForm
commandId={
builderClub
? "economy.catalog.bc-page.change"
: "economy.catalog.page.change"
}
buttonLabel="Save catalog page"
input={{
action: routeId === "economy.catalog.overview" ? "create" : "update",
}}
fields={[
{ name: "id", label: "ID", type: "identifier" },
{
name: "caption",
label: "Caption",
type: "text",
required: true,
maxLength: 128,
},
{
name: "parentId",
label: "Parent ID",
type: "number",
required: true,
},
{
name: "pageLayout",
label: "Page layout",
type: "text",
required: true,
maxLength: 26,
},
]}
/>
);
}
export function EconomyCatalogPage(
props: EconomyPageProps & { readonly editor?: ReactNode },
) {
return (
<EconomyPageFrame
title="Catalog"
description="Catalog pages, Builder Club and maintenance workflows."
result={props.result}
forms={props.editor ?? forms(props)}
/>
);
}
export async function renderEconomyCatalogPage(input: HousekeepingPageInput) {
const routeId = input.match.routeId as EconomyPageProps["routeId"];
const result = await economyQuery.run(input.context, {
routeId: routeId ?? "economy.catalog.overview",
params: input.match.params,
list: parseEconomyListInput(input.searchParams ?? {}),
});
const editor =
result.ok &&
(routeId === "economy.catalog.detail" ||
routeId === "economy.catalog.builder-club-detail")
? await (
await import("./specialized-catalog-editor")
).renderSpecializedCatalogEditor(
routeId,
input.match.params,
input.context,
)
: null;
return (
<EconomyCatalogPage
context={input.context}
result={result}
routeId={routeId}
editor={editor}
/>
);
}
@@ -1,128 +0,0 @@
import { PERMS } from "@/lib/permission-slugs";
import type { HousekeepingPageInput } from "../../../route-handlers";
import { economyQuery } from "../queries/catalog";
import { EconomyCommandForm } from "./economy-command-form";
import {
EconomyPageFrame,
type EconomyPageProps,
parseEconomyListInput,
} from "./economy-page-frame";
function commerceForm({ context, routeId }: EconomyPageProps) {
if (!context.has(PERMS.SHOP_EDIT)) return null;
if (routeId === "economy.commerce.vouchers") {
return (
<EconomyCommandForm
commandId="economy.commerce.voucher.change"
buttonLabel="Create voucher"
input={{ action: "create" }}
fields={[
{
name: "code",
label: "Code",
type: "text",
required: true,
maxLength: 255,
},
{
name: "amount",
label: "Amount",
type: "number",
required: true,
min: 1,
},
{
name: "maxUses",
label: "Maximum uses",
type: "number",
required: true,
min: 1,
},
{ name: "expiresAt", label: "Expires at", type: "text" },
]}
/>
);
}
if (routeId === "economy.commerce.marketplace") {
return (
<EconomyCommandForm
commandId="economy.commerce.marketplace.cancel"
buttonLabel="Cancel listing"
input={{}}
fields={[
{
name: "id",
label: "Listing ID",
type: "identifier",
required: true,
},
]}
requiresReason
/>
);
}
if (routeId?.startsWith("economy.commerce.shop")) {
return (
<EconomyCommandForm
commandId="economy.commerce.shop-article.change"
buttonLabel="Save shop package"
input={{
action:
routeId === "economy.commerce.shop-detail" ? "update" : "create",
}}
fields={[
{ name: "id", label: "ID", type: "identifier" },
{
name: "name",
label: "Name",
type: "text",
required: true,
maxLength: 255,
},
{
name: "info",
label: "Information",
type: "text",
required: true,
maxLength: 255,
},
{
name: "costs",
label: "Costs",
type: "number",
required: true,
min: 0,
},
]}
/>
);
}
return null;
}
export function EconomyCommercePage(props: EconomyPageProps) {
return (
<EconomyPageFrame
title="Commerce"
description="Shop, marketplace, vouchers and subscriptions."
result={props.result}
forms={commerceForm(props)}
/>
);
}
export async function renderEconomyCommercePage(input: HousekeepingPageInput) {
const routeId = input.match.routeId as EconomyPageProps["routeId"];
const result = await economyQuery.run(input.context, {
routeId: routeId ?? "economy.commerce.shop",
params: input.match.params,
list: parseEconomyListInput(input.searchParams ?? {}),
});
return (
<EconomyCommercePage
context={input.context}
result={result}
routeId={routeId}
/>
);
}
@@ -1,199 +0,0 @@
"use client";
import { useActionState } from "react";
import type { HousekeepingResult } from "../../../foundation/contracts";
export interface EconomyCommandField {
readonly name: string;
readonly label: string;
readonly type:
| "identifier"
| "text"
| "textarea"
| "number"
| "checkbox"
| "file";
readonly required?: boolean;
readonly min?: number;
readonly max?: number;
readonly maxLength?: number;
readonly defaultValue?: string | number | boolean;
readonly allowUnchanged?: boolean;
}
export interface EconomyCommandSubmission {
readonly commandId: string;
readonly input: Readonly<Record<string, unknown>>;
readonly fields?: readonly EconomyCommandField[];
readonly requiresReason?: boolean;
}
interface EconomyCommandFormProps extends EconomyCommandSubmission {
readonly buttonLabel: string;
}
const OMIT_FIELD = Symbol("omit optional Economy command field");
function parseField(field: EconomyCommandField, formData: FormData): unknown {
const rawValue = formData.get(field.name);
if (field.type === "checkbox") {
if (field.allowUnchanged) {
if (rawValue === null || rawValue === "") return OMIT_FIELD;
if (rawValue === "true") return true;
if (rawValue === "false") return false;
return OMIT_FIELD;
}
return rawValue === "on";
}
if (rawValue === null && !field.required) return OMIT_FIELD;
if (field.type === "file") return rawValue instanceof File ? rawValue : null;
const raw = String(rawValue ?? "")
.normalize("NFC")
.trim();
if (!raw && !field.required) return OMIT_FIELD;
if (field.type === "number") {
const value = Number(raw);
if (!Number.isSafeInteger(value)) return 0;
return Math.min(field.max ?? value, Math.max(field.min ?? value, value));
}
return raw.slice(
0,
field.maxLength ?? (field.type === "textarea" ? 20_000 : 500),
);
}
const initialState: HousekeepingResult<unknown> | null = null;
export async function submitEconomyCommandForm(
configuration: EconomyCommandSubmission,
_previous: HousekeepingResult<unknown> | null,
formData: FormData,
): Promise<HousekeepingResult<unknown>> {
const submitted = (configuration.fields ?? []).flatMap((field) => {
const value = parseField(field, formData);
return value === OMIT_FIELD ? [] : [[field.name, value] as const];
});
const reason = String(formData.get("reason") ?? "")
.normalize("NFC")
.trim()
.slice(0, 1000);
const { executeHousekeepingCommand } = await import(
"@/actions/housekeeping-command"
);
return executeHousekeepingCommand({
commandId: configuration.commandId,
input: { ...configuration.input, ...Object.fromEntries(submitted) },
...(configuration.requiresReason ? { reason } : {}),
});
}
export function EconomyCommandForm({
commandId,
buttonLabel,
input,
fields = [],
requiresReason = false,
}: EconomyCommandFormProps) {
const [result, submit, pending] = useActionState(
submitEconomyCommandForm.bind(null, {
commandId,
input,
fields,
requiresReason,
}),
initialState,
);
return (
<form
action={submit}
data-housekeeping-command={commandId}
className="space-y-3 rounded border border-[var(--admin-border)] p-3"
>
{fields.map((field) => {
const fieldId = `economy-${commandId}-${field.name}`;
return (
<label key={field.name} htmlFor={fieldId} className="block text-sm">
{field.type === "checkbox" && field.allowUnchanged ? (
<>
{field.label}
<select
id={fieldId}
name={field.name}
defaultValue=""
className="mt-1 block w-full"
>
<option value="">No change</option>
<option value="true">Enabled</option>
<option value="false">Disabled</option>
</select>
</>
) : field.type === "checkbox" ? (
<>
<input
id={fieldId}
name={field.name}
type="checkbox"
defaultChecked={field.defaultValue === true}
/>{" "}
{field.label}
</>
) : field.type === "textarea" ? (
<>
{field.label}
<textarea
id={fieldId}
name={field.name}
required={field.required}
maxLength={field.maxLength}
className="mt-1 block w-full"
/>
</>
) : (
<>
{field.label}
<input
id={fieldId}
name={field.name}
type={
field.type === "number"
? "number"
: field.type === "file"
? "file"
: "text"
}
required={field.required}
min={field.min}
max={field.max}
maxLength={field.maxLength}
className="mt-1 block w-full"
/>
</>
)}
</label>
);
})}
{requiresReason ? (
<label
htmlFor={`economy-${commandId}-reason`}
className="block text-sm"
>
Reason
<textarea
id={`economy-${commandId}-reason`}
name="reason"
required
maxLength={1000}
/>
</label>
) : null}
<button type="submit" disabled={pending}>
{pending ? "Working..." : buttonLabel}
</button>
{result ? (
<p role="status">
{result.ok ? "Completed" : "Failed"} ({result.correlationId})
</p>
) : null}
</form>
);
}
@@ -1,82 +0,0 @@
import type { ReactNode } from "react";
import type {
HousekeepingCapabilityContext,
HousekeepingResult,
} from "../../../foundation/contracts";
import type { EconomyQueryData } from "../queries/catalog";
import type { EconomyRouteId } from "../routes";
export interface EconomyPageProps {
readonly context: HousekeepingCapabilityContext;
readonly result?: HousekeepingResult<EconomyQueryData>;
readonly routeId: EconomyRouteId | null;
}
export function EconomyPageFrame({
title,
description,
result,
forms,
}: {
readonly title: string;
readonly description: string;
readonly result?: HousekeepingResult<EconomyQueryData>;
readonly forms?: ReactNode;
}) {
if (!result) {
return (
<section data-housekeeping-state="loading">
<h1>{title}</h1>
<p>{description}</p>
<p>Loading economy data…</p>
</section>
);
}
if (!result.ok) {
const state = result.error.code === "FORBIDDEN" ? "forbidden" : "error";
return (
<section data-housekeeping-state={state} role="alert">
<h1>{title}</h1>
<p>{result.error.messageKey}</p>
</section>
);
}
const state = result.data.items.length === 0 ? "empty" : "ready";
return (
<section data-housekeeping-state={state} className="space-y-4">
<header>
<h1>{title}</h1>
<p>{description}</p>
</header>
{forms}
{result.data.items.length === 0 ? (
<p>No matching economy records.</p>
) : (
<ul className="divide-y divide-[var(--admin-border)]">
{result.data.items.map((item) => (
<li key={item.id} className="py-2">
{item.href ? <a href={item.href}>{item.title}</a> : item.title}
{item.status ? <span> — {item.status}</span> : null}
{item.amount ? <span> — {item.amount}</span> : null}
{item.description ? <p>{item.description}</p> : null}
</li>
))}
</ul>
)}
</section>
);
}
export function parseEconomyListInput(
searchParams: Readonly<
Record<string, string | readonly string[] | undefined>
>,
) {
const first = (value: string | readonly string[] | undefined) =>
Array.isArray(value) ? value[0] : value;
return {
search: first(searchParams.search),
pageSize: Number(first(searchParams.pageSize) ?? 25),
offset: Number(first(searchParams.offset) ?? 0),
};
}
@@ -1,159 +0,0 @@
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 { EconomyCatalogPage } from "./catalog";
import { EconomyCommercePage } from "./commerce";
import { EconomyHistoryPage } from "./history";
import { EconomyItemsPage } from "./items";
import { EconomyRewardsPage } from "./rewards";
import { EconomyValuePage } from "./value";
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)),
};
}
const cases = [
["catalog", EconomyCatalogPage],
["items", EconomyItemsPage],
["commerce", EconomyCommercePage],
["history", EconomyHistoryPage],
["value", EconomyValuePage],
["rewards", EconomyRewardsPage],
] as const;
describe.each(cases)("Economy %s page", (kind, Component) => {
it("renders loading, forbidden, empty, and canonical ready states", () => {
const render = (result?: unknown) =>
renderToStaticMarkup(
<Component
context={context(Object.values(PERMS))}
result={result as never}
routeId={null}
/>,
);
expect(render()).toContain('data-housekeeping-state="loading"');
expect(
render(fail("FORBIDDEN", "errors.housekeeping.forbidden", "denied")),
).toContain('data-housekeeping-state="forbidden"');
expect(
render(
ok({ kind, items: [], total: 0, partialDependencies: [] }, "empty"),
),
).toContain('data-housekeeping-state="empty"');
const ready = render(
ok(
{
kind,
items: [
{
id: "18446744073709551615",
title: "Canonical economy item",
status: "active",
href: `/ase/economy/${kind}`,
},
],
total: 1,
partialDependencies: [],
},
"ready",
),
);
expect(ready).toContain('data-housekeeping-state="ready"');
expect(ready).toContain("Canonical economy item");
expect(ready).not.toContain("/admin");
});
});
describe("Economy mutation forms", () => {
it("renders edit controls only with the exact mutation capability", () => {
const result = ok(
{
kind: "commerce" as const,
items: [{ id: "1", title: "Package", status: "active" }],
total: 1,
partialDependencies: [],
},
"shop",
);
const readOnly = renderToStaticMarkup(
<EconomyCommercePage
context={context([PERMS.SHOP_VIEW])}
result={result}
routeId="economy.commerce.shop"
/>,
);
const editor = renderToStaticMarkup(
<EconomyCommercePage
context={context([PERMS.SHOP_VIEW, PERMS.SHOP_EDIT])}
result={result}
routeId="economy.commerce.shop"
/>,
);
expect(readOnly).not.toContain("economy.commerce.shop-article.change");
expect(editor).toContain("economy.commerce.shop-article.change");
expect(editor).toContain("<form");
});
it("renders validator-shaped catalog, voucher, value, and soundtrack fields", () => {
const all = context(Object.values(PERMS));
const empty = (kind: "catalog" | "commerce" | "value" | "rewards") =>
ok({ kind, items: [], total: 0, partialDependencies: [] }, kind);
const catalog = renderToStaticMarkup(
<EconomyCatalogPage
context={all}
result={empty("catalog")}
routeId="economy.catalog.detail"
/>,
);
for (const field of ["id", "caption", "parentId", "pageLayout"]) {
expect(catalog).toContain(`name="${field}"`);
}
const commerce = renderToStaticMarkup(
<EconomyCommercePage
context={all}
result={empty("commerce")}
routeId="economy.commerce.vouchers"
/>,
);
for (const field of ["code", "amount", "maxUses", "expiresAt"]) {
expect(commerce).toContain(`name="${field}"`);
}
const value = renderToStaticMarkup(
<EconomyValuePage
context={all}
result={empty("value")}
routeId="economy.value.rare-values"
/>,
);
for (const field of [
"categoryId",
"name",
"currencyValue",
"currencyType",
]) {
expect(value).toContain(`name="${field}"`);
}
const rewards = renderToStaticMarkup(
<EconomyRewardsPage
context={all}
result={empty("rewards")}
routeId="economy.rewards.sounds"
/>,
);
for (const field of ["id", "name", "author", "track", "length"]) {
expect(rewards).toContain(`name="${field}"`);
}
});
});
@@ -1,33 +0,0 @@
import type { HousekeepingPageInput } from "../../../route-handlers";
import { economyQuery } from "../queries/catalog";
import {
EconomyPageFrame,
type EconomyPageProps,
parseEconomyListInput,
} from "./economy-page-frame";
export function EconomyHistoryPage(props: EconomyPageProps) {
return (
<EconomyPageFrame
title="Transaction history"
description="Stable, read-only commerce history."
result={props.result}
/>
);
}
export async function renderEconomyHistoryPage(input: HousekeepingPageInput) {
const routeId = input.match.routeId as EconomyPageProps["routeId"];
const result = await economyQuery.run(input.context, {
routeId: routeId ?? "economy.history.transactions",
params: input.match.params,
list: parseEconomyListInput(input.searchParams ?? {}),
});
return (
<EconomyHistoryPage
context={input.context}
result={result}
routeId={routeId}
/>
);
}
@@ -1,59 +0,0 @@
import { PERMS } from "@/lib/permission-slugs";
import type { HousekeepingPageInput } from "../../../route-handlers";
import { economyQuery } from "../queries/catalog";
import { EconomyCommandForm } from "./economy-command-form";
import {
EconomyPageFrame,
type EconomyPageProps,
parseEconomyListInput,
} from "./economy-page-frame";
export function EconomyItemsPage(props: EconomyPageProps) {
const edit = props.context.has(PERMS.CATALOG_EDIT) ? (
<EconomyCommandForm
commandId="economy.items.base.update"
buttonLabel="Save base item"
input={{}}
fields={[
{ name: "id", label: "ID", type: "identifier", required: true },
{
name: "publicName",
label: "Public name",
type: "text",
maxLength: 56,
},
{ name: "itemName", label: "Item name", type: "text", maxLength: 70 },
{
name: "interactionType",
label: "Interaction",
type: "text",
maxLength: 500,
},
]}
/>
) : null;
return (
<EconomyPageFrame
title="Items"
description="Base item definitions and catalog linkage."
result={props.result}
forms={edit}
/>
);
}
export async function renderEconomyItemsPage(input: HousekeepingPageInput) {
const routeId = input.match.routeId as EconomyPageProps["routeId"];
const result = await economyQuery.run(input.context, {
routeId: routeId ?? "economy.items.overview",
params: input.match.params,
list: parseEconomyListInput(input.searchParams ?? {}),
});
return (
<EconomyItemsPage
context={input.context}
result={result}
routeId={routeId}
/>
);
}
@@ -1,119 +0,0 @@
import { PERMS } from "@/lib/permission-slugs";
import type { HousekeepingPageInput } from "../../../route-handlers";
import { economyQuery } from "../queries/catalog";
import { EconomyCommandForm } from "./economy-command-form";
import {
EconomyPageFrame,
type EconomyPageProps,
parseEconomyListInput,
} from "./economy-page-frame";
function rewardForms({ context, routeId }: EconomyPageProps) {
if (!context.has(PERMS.CATALOG_EDIT)) return null;
if (routeId === "economy.rewards.sounds") {
return (
<EconomyCommandForm
commandId="economy.rewards.soundtrack.change"
buttonLabel="Save soundtrack"
input={{ action: "update" }}
fields={[
{ name: "id", label: "ID", type: "identifier", required: true },
{
name: "name",
label: "Name",
type: "text",
required: true,
maxLength: 100,
},
{
name: "author",
label: "Author",
type: "text",
required: true,
maxLength: 50,
},
{ name: "track", label: "Track", type: "textarea", required: true },
{
name: "length",
label: "Length",
type: "number",
required: true,
min: 0,
},
]}
/>
);
}
if (routeId === "economy.rewards.badges") {
return (
<div className="grid gap-3 lg:grid-cols-2">
<EconomyCommandForm
commandId="economy.rewards.badge.give"
buttonLabel="Give badge"
input={{}}
fields={[
{
name: "username",
label: "Username",
type: "text",
required: true,
maxLength: 25,
},
{
name: "badge",
label: "Badge code",
type: "text",
required: true,
maxLength: 32,
},
]}
requiresReason
/>
<EconomyCommandForm
commandId="economy.rewards.badge.upload"
buttonLabel="Upload badge"
input={{}}
fields={[
{
name: "code",
label: "Badge code",
type: "text",
required: true,
maxLength: 32,
},
{ name: "file", label: "Badge file", type: "file", required: true },
]}
requiresReason
/>
</div>
);
}
return null;
}
export function EconomyRewardsPage(props: EconomyPageProps) {
return (
<EconomyPageFrame
title="Rewards"
description="Badges, achievements, sounds and calendar rewards."
result={props.result}
forms={rewardForms(props)}
/>
);
}
export async function renderEconomyRewardsPage(input: HousekeepingPageInput) {
const routeId = input.match.routeId as EconomyPageProps["routeId"];
const result = await economyQuery.run(input.context, {
routeId: routeId ?? "economy.rewards.badges",
params: input.match.params,
list: parseEconomyListInput(input.searchParams ?? {}),
});
return (
<EconomyRewardsPage
context={input.context}
result={result}
routeId={routeId}
/>
);
}
@@ -1,136 +0,0 @@
import "server-only";
import { asc, eq } from "drizzle-orm";
import type { ReactNode } from "react";
import { BcPageDetail } from "@/components/admin/catalog/builder-club/bc-manager";
import { CatalogDetailTabs } from "@/components/admin/catalog/detail/catalog-detail-tabs";
import { CatalogItemsBc, CatalogPages, CatalogPagesBc, db } from "@/lib/db";
import { PERMS } from "@/lib/permission-slugs";
import { loadCatalogItemsData } from "@/lib/services/catalog-items-loader";
import type { HousekeepingCapabilityContext } from "../../../foundation/contracts";
import type { EconomyRouteId } from "../routes";
async function normalCatalogEditor(
id: number,
context: HousekeepingCapabilityContext,
): Promise<ReactNode> {
const [catalogPage] = await db
.select()
.from(CatalogPages)
.where(eq(CatalogPages.id, id))
.limit(1);
if (!catalogPage) return null;
const [children, itemsData] = await Promise.all([
db
.select({
id: CatalogPages.id,
caption: CatalogPages.caption,
enabled: CatalogPages.enabled,
})
.from(CatalogPages)
.where(eq(CatalogPages.parentId, id))
.orderBy(asc(CatalogPages.orderNum)),
loadCatalogItemsData(id),
]);
return (
<CatalogDetailTabs
catalogPage={{
id: catalogPage.id,
caption: catalogPage.caption,
parentId: catalogPage.parentId,
pageLayout: catalogPage.pageLayout,
enabled: catalogPage.enabled,
visible: catalogPage.visible,
minRank: catalogPage.minRank,
clubOnly: catalogPage.clubOnly,
orderNum: catalogPage.orderNum,
iconImage: catalogPage.iconImage,
iconColor: catalogPage.iconColor,
pageHeadline: catalogPage.pageHeadline,
pageTeaser: catalogPage.pageTeaser,
pageSpecial: catalogPage.pageSpecial,
pageText1: catalogPage.pageText1,
pageText2: catalogPage.pageText2,
pageTextDetails: catalogPage.pageTextDetails,
pageTextTeaser: catalogPage.pageTextTeaser,
}}
items={itemsData.items}
baseItems={itemsData.baseItems}
catalogNameMap={itemsData.catalogNameMap}
childPages={children}
pageId={catalogPage.id}
canEdit={context.has(PERMS.CATALOG_EDIT)}
furniDataIdList={itemsData.furniDataIdList}
furniDescriptionMap={itemsData.furniDescriptionMap}
furniRevisionMap={itemsData.furniRevisionMap}
allPages={itemsData.allPages}
interactionTypes={itemsData.interactionTypes}
gamedataHotel={itemsData.gamedataHotel}
/>
);
}
async function builderClubEditor(
id: number,
context: HousekeepingCapabilityContext,
): Promise<ReactNode> {
const [page] = await db
.select()
.from(CatalogPagesBc)
.where(eq(CatalogPagesBc.id, id))
.limit(1);
if (!page) return null;
const items = await db
.select()
.from(CatalogItemsBc)
.where(eq(CatalogItemsBc.pageId, id))
.orderBy(asc(CatalogItemsBc.orderNumber));
return (
<BcPageDetail
page={{
id: page.id,
parentId: page.parentId,
caption: page.caption,
pageLayout: page.pageLayout,
iconColor: page.iconColor,
iconImage: page.iconImage,
orderNum: page.orderNum,
visible: page.visible,
enabled: page.enabled,
pageHeadline: page.pageHeadline,
pageTeaser: page.pageTeaser,
pageSpecial: page.pageSpecial ?? "",
pageText1: page.pageText1 ?? "",
pageText2: page.pageText2 ?? "",
pageTextDetails: page.pageTextDetails ?? "",
pageTextTeaser: page.pageTextTeaser ?? "",
}}
items={items.map((item) => ({
id: item.id,
pageId: item.pageId,
itemIds: item.itemIds,
catalogName: item.catalogName,
orderNumber: item.orderNumber,
extradata: item.extradata,
}))}
canEdit={context.has(PERMS.CATALOG_EDIT)}
returnHref="/ase/economy/catalog"
/>
);
}
export async function renderSpecializedCatalogEditor(
routeId: EconomyRouteId,
params: Readonly<Record<string, string>>,
context: HousekeepingCapabilityContext,
): Promise<ReactNode> {
const id = Number(params.id);
if (!Number.isSafeInteger(id) || id <= 0) return null;
if (routeId === "economy.catalog.detail") {
return normalCatalogEditor(id, context);
}
if (routeId === "economy.catalog.builder-club-detail") {
return builderClubEditor(id, context);
}
return null;
}
@@ -1,102 +0,0 @@
import { PERMS } from "@/lib/permission-slugs";
import type { HousekeepingPageInput } from "../../../route-handlers";
import { economyQuery } from "../queries/catalog";
import { EconomyCommandForm } from "./economy-command-form";
import {
EconomyPageFrame,
type EconomyPageProps,
parseEconomyListInput,
} from "./economy-page-frame";
export function EconomyValuePage(props: EconomyPageProps) {
const forms = props.context.has(PERMS.SHOP_EDIT) ? (
<div className="grid gap-3 lg:grid-cols-2">
<EconomyCommandForm
commandId="economy.value.category.change"
buttonLabel="Create value category"
input={{ action: "create" }}
fields={[
{
name: "name",
label: "Name",
type: "text",
required: true,
maxLength: 255,
},
{
name: "badge",
label: "Badge",
type: "text",
required: true,
maxLength: 255,
},
{ name: "priority", label: "Priority", type: "number", min: 1 },
]}
/>
<EconomyCommandForm
commandId="economy.value.rare.change"
buttonLabel="Create rare value"
input={{ action: "create" }}
fields={[
{
name: "categoryId",
label: "Category ID",
type: "identifier",
required: true,
},
{
name: "name",
label: "Name",
type: "text",
required: true,
maxLength: 255,
},
{
name: "currencyValue",
label: "Currency value",
type: "text",
maxLength: 255,
},
{
name: "currencyType",
label: "Currency type",
type: "text",
required: true,
maxLength: 255,
},
{
name: "furnitureIcon",
label: "Furniture icon",
type: "text",
required: true,
maxLength: 255,
},
]}
/>
</div>
) : null;
return (
<EconomyPageFrame
title="Value tools"
description="Rare-value categories and valuations."
result={props.result}
forms={forms}
/>
);
}
export async function renderEconomyValuePage(input: HousekeepingPageInput) {
const routeId = input.match.routeId as EconomyPageProps["routeId"];
const result = await economyQuery.run(input.context, {
routeId: routeId ?? "economy.value.rare-values",
params: input.match.params,
list: parseEconomyListInput(input.searchParams ?? {}),
});
return (
<EconomyValuePage
context={input.context}
result={result}
routeId={routeId}
/>
);
}
@@ -1,150 +0,0 @@
import { authorizeHousekeeping } from "../../../foundation/authorization";
import {
fail,
type HousekeepingQuery,
ok,
} from "../../../foundation/contracts";
import { isSafeHousekeepingHref } from "../../../foundation/housekeeping-href";
import {
type EconomyRouteGroup,
type EconomyRouteId,
economyRouteById,
economyRouteGroup,
} from "../routes";
export interface EconomyQueryInput {
readonly routeId: EconomyRouteId;
readonly params?: Readonly<Record<string, string>>;
readonly list?: Readonly<{
search?: string;
pageSize?: number;
offset?: number;
}>;
}
export interface EconomyQueryItem {
readonly id: string;
readonly title: string;
readonly status?: string;
readonly amount?: string;
readonly href?: string;
readonly description?: string;
readonly updatedAt?: string | null;
}
export interface EconomyQueryData {
readonly kind: EconomyRouteGroup;
readonly items: readonly EconomyQueryItem[];
readonly total: number;
readonly partialDependencies: readonly string[];
}
export interface EconomyQueryAdapters {
load(input: NormalizedEconomyQueryInput): Promise<EconomyQueryData>;
}
export interface NormalizedEconomyQueryInput {
readonly routeId: EconomyRouteId;
readonly params: Readonly<Record<string, string>>;
readonly list: Readonly<{ search: string; pageSize: number; offset: number }>;
}
function boundedInteger(
value: unknown,
fallback: number,
minimum: number,
maximum: number,
): number {
const parsed = Number(value);
if (!Number.isSafeInteger(parsed)) return fallback;
return Math.min(maximum, Math.max(minimum, parsed));
}
function normalizeInput(input: EconomyQueryInput): NormalizedEconomyQueryInput {
return {
routeId: input.routeId,
params: Object.fromEntries(
Object.entries(input.params ?? {}).map(([key, value]) => [
key.normalize("NFC").trim().slice(0, 128),
value.normalize("NFC").trim().slice(0, 128),
]),
),
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),
},
};
}
function isValidData(
data: EconomyQueryData,
input: NormalizedEconomyQueryInput,
): boolean {
if (
data.kind !== economyRouteGroup(input.routeId) ||
!Array.isArray(data.items) ||
!Number.isSafeInteger(data.total) ||
data.total < 0 ||
!Array.isArray(data.partialDependencies) ||
data.partialDependencies.length !== 0
) {
return false;
}
return data.items.every(
(item) =>
typeof item.id === "string" &&
item.id.length > 0 &&
typeof item.title === "string" &&
item.title.length > 0 &&
(item.href === undefined || isSafeHousekeepingHref(item.href)),
);
}
export function createEconomyQuery(
adapters: EconomyQueryAdapters,
): HousekeepingQuery<EconomyQueryInput, EconomyQueryData> {
return {
id: "economy.query",
owner: "economy",
capability: economyRouteById("economy.catalog.overview").capability,
async run(context, rawInput) {
const route = economyRouteById(rawInput.routeId);
const authorization = authorizeHousekeeping(context, route.capability);
if (!authorization.ok) return authorization;
const input = normalizeInput(rawInput);
try {
const data = await adapters.load(input);
if (!isValidData(data, input)) throw new Error("invalid Economy query");
return ok(data, authorization.correlationId);
} catch {
return fail(
"DEPENDENCY_UNAVAILABLE",
"errors.housekeeping.dependencyUnavailable",
authorization.correlationId,
);
}
},
};
}
export async function loadEconomyCatalogQuery(
input: NormalizedEconomyQueryInput,
): Promise<EconomyQueryData> {
const { loadEconomyDatabaseQuery } = await import("./economy-production");
return loadEconomyDatabaseQuery(input);
}
export const economyQuery = createEconomyQuery({
async load(input) {
const group = economyRouteGroup(input.routeId);
if (group === "catalog" || group === "items") {
return loadEconomyCatalogQuery(input);
}
if (group === "commerce" || group === "history") {
const { loadEconomyCommerceQuery } = await import("./commerce");
return loadEconomyCommerceQuery(input);
}
const { loadEconomyValueQuery } = await import("./value");
return loadEconomyValueQuery(input);
},
});
@@ -1,19 +0,0 @@
import type { EconomyQueryData, NormalizedEconomyQueryInput } from "./catalog";
export function serializeEconomyAmount(
value: string | number,
currency: string,
): string {
const raw =
typeof value === "number" && Number.isFinite(value)
? value.toFixed(2)
: String(value).trim();
return `${currency.normalize("NFC").trim().toUpperCase().slice(0, 8)} ${raw}`;
}
export async function loadEconomyCommerceQuery(
input: NormalizedEconomyQueryInput,
): Promise<EconomyQueryData> {
const { loadEconomyDatabaseQuery } = await import("./economy-production");
return loadEconomyDatabaseQuery(input);
}
@@ -1,88 +0,0 @@
import { describe, expect, it, vi } from "vitest";
import type { EconomyRouteId } from "../routes";
import type { NormalizedEconomyQueryInput } from "./catalog";
import {
ECONOMY_QUERY_DEFINITIONS,
loadEconomyDatabaseQueryWith,
} from "./economy-production";
function input(
routeId: EconomyRouteId,
params: Readonly<Record<string, string>> = {},
): NormalizedEconomyQueryInput {
return {
routeId,
params,
list: { search: "", pageSize: 25, offset: 0 },
};
}
describe("Economy production query adapter", () => {
it("maps voucher rows without exposing codes and serializes money", async () => {
const execute = vi
.fn()
.mockResolvedValueOnce([[{ total: 1 }], []])
.mockResolvedValueOnce([
[
{
id: 18446744073709551615n,
title: "ABC...XYZ",
description: "0/5 uses",
status: "active",
updated_at: new Date("2026-08-30T10:00:00.000Z"),
amount: 50,
currency: "credits",
},
],
[],
]);
const result = await loadEconomyDatabaseQueryWith(
input("economy.commerce.vouchers"),
execute,
);
expect(result).toMatchObject({
kind: "commerce",
total: 1,
items: [
{
id: "18446744073709551615",
title: "ABC...XYZ",
amount: "CREDITS 50.00",
href: "/ase/economy/commerce/vouchers",
},
],
});
expect(JSON.stringify(result)).not.toContain("SECRET-CODE");
expect(execute).toHaveBeenCalledTimes(2);
});
it("uses the empty adapter for create-only routes", async () => {
const execute = vi.fn();
await expect(
loadEconomyDatabaseQueryWith(
input("economy.commerce.shop-create"),
execute,
),
).resolves.toMatchObject({ items: [], total: 0, kind: "commerce" });
expect(execute).not.toHaveBeenCalled();
});
it("fails closed on invalid totals returned by the dependency", async () => {
const execute = vi.fn().mockResolvedValueOnce([[{ total: -1 }], []]);
await expect(
loadEconomyDatabaseQueryWith(
input("economy.history.transactions"),
execute,
),
).rejects.toThrow("invalid Economy query total");
});
it("defines deterministic ordering for every list query", () => {
for (const [routeId, definition] of Object.entries(
ECONOMY_QUERY_DEFINITIONS,
)) {
if (routeId === "economy.catalog.maintenance") continue;
expect(definition.statement, routeId).toMatch(/\bORDER BY\b/iu);
}
});
});
@@ -1,257 +0,0 @@
import "server-only";
import type { SQL } from "drizzle-orm";
import { type EconomyRouteId, economyRouteGroup } from "../routes";
import type {
EconomyQueryData,
EconomyQueryItem,
NormalizedEconomyQueryInput,
} from "./catalog";
import { serializeEconomyAmount } from "./commerce";
type RawRow = Readonly<Record<string, unknown>>;
interface QueryDefinition {
readonly statement: string;
readonly href: string;
readonly appendId?: boolean;
readonly parameterAlias?: "id" | "campaign_id";
}
export const ECONOMY_QUERY_DEFINITIONS = {
"economy.catalog.overview": {
statement:
"SELECT id, caption AS title, CONCAT('parent-', parent_id) AS description, CASE WHEN enabled = '1' AND visible = '1' THEN 'active' ELSE 'hidden' END AS status, NULL AS updated_at FROM catalog_pages ORDER BY order_num ASC, id ASC",
href: "/ase/economy/catalog/",
appendId: true,
},
"economy.catalog.detail": {
statement:
"SELECT id, caption AS title, CONCAT('layout-', page_layout) AS description, CASE WHEN enabled = '1' AND visible = '1' THEN 'active' ELSE 'hidden' END AS status, NULL AS updated_at FROM catalog_pages ORDER BY order_num ASC, id ASC",
href: "/ase/economy/catalog/",
appendId: true,
},
"economy.catalog.builder-club-detail": {
statement:
"SELECT id, caption AS title, CONCAT('parent-', parent_id) AS description, CASE WHEN enabled = '1' AND visible = '1' THEN 'active' ELSE 'hidden' END AS status, NULL AS updated_at FROM catalog_pages_bc ORDER BY order_num ASC, id ASC",
href: "/ase/economy/catalog/builder-club/",
appendId: true,
},
"economy.catalog.maintenance": {
statement:
"SELECT 1 AS id, 'Catalog maintenance' AS title, CONCAT(COUNT(*), ' catalog pages available') AS description, 'ready' AS status, NULL AS updated_at FROM catalog_pages",
href: "/ase/economy/catalog/maintenance",
},
"economy.items.overview": {
statement:
"SELECT id, COALESCE(NULLIF(public_name, ''), item_name) AS title, item_name AS description, interaction_type AS status, NULL AS updated_at FROM items_base ORDER BY id DESC",
href: "/ase/economy/items/",
appendId: true,
},
"economy.items.detail": {
statement:
"SELECT id, COALESCE(NULLIF(public_name, ''), item_name) AS title, item_name AS description, interaction_type AS status, NULL AS updated_at FROM items_base ORDER BY id DESC",
href: "/ase/economy/items/",
appendId: true,
},
"economy.commerce.shop": {
statement:
"SELECT id, name AS title, info AS description, 'active' AS status, updated_at, costs AS amount, 'credits' AS currency FROM website_shop_articles ORDER BY position ASC, id DESC",
href: "/ase/economy/commerce/shop/",
appendId: true,
},
"economy.commerce.shop-detail": {
statement:
"SELECT id, name AS title, info AS description, 'active' AS status, updated_at, costs AS amount, 'credits' AS currency FROM website_shop_articles ORDER BY position ASC, id DESC",
href: "/ase/economy/commerce/shop/",
appendId: true,
},
"economy.commerce.marketplace": {
statement:
"SELECT id, CONCAT('Listing #', id) AS title, CONCAT('user-', user_id, ' item-', item_id) AS description, CASE state WHEN 1 THEN 'active' WHEN 2 THEN 'sold' ELSE 'cancelled' END AS status, FROM_UNIXTIME(timestamp) AS updated_at, price AS amount, 'credits' AS currency FROM marketplace_items ORDER BY timestamp DESC, id DESC",
href: "/ase/economy/commerce/marketplace",
},
"economy.commerce.vouchers": {
statement:
"SELECT id, CONCAT(LEFT(code, 3), '...', RIGHT(code, 3)) AS title, CONCAT(use_count, '/', max_uses, ' uses') AS description, CASE WHEN expires_at IS NOT NULL AND expires_at < CURRENT_TIMESTAMP THEN 'expired' WHEN use_count >= max_uses THEN 'exhausted' ELSE 'active' END AS status, updated_at, amount, 'credits' AS currency FROM website_shop_vouchers ORDER BY id DESC",
href: "/ase/economy/commerce/vouchers",
},
"economy.commerce.subscriptions": {
statement:
"SELECT id, CONCAT('User #', COALESCE(user_id, 0)) AS title, subscription_type AS description, CASE WHEN COALESCE(timestamp_start, 0) + COALESCE(duration, 0) > UNIX_TIMESTAMP() THEN 'active' ELSE 'expired' END AS status, FROM_UNIXTIME(COALESCE(timestamp_start, 0) + COALESCE(duration, 0)) AS updated_at FROM users_subscriptions WHERE active = 1 ORDER BY (COALESCE(timestamp_start, 0) + COALESCE(duration, 0)) ASC, id ASC",
href: "/ase/economy/commerce/subscriptions",
},
"economy.history.transactions": {
statement:
"SELECT id, transaction_id AS title, description, COALESCE(status, 'unknown') AS status, updated_at, amount, currency FROM website_paypal_transactions ORDER BY created_at DESC, id DESC",
href: "/ase/economy/history/transactions",
},
"economy.value.rare-values": {
statement:
"SELECT id, name AS title, CONCAT(COALESCE(credit_value, '0'), ' credits; ', COALESCE(currency_value, '0'), ' ', currency_type) AS description, currency_type AS status, updated_at FROM website_rare_values ORDER BY category_id ASC, id ASC",
href: "/ase/economy/value/rare-values",
},
"economy.rewards.badges": {
statement:
"SELECT id, badge_name AS title, badge_key AS description, 'available' AS status, updated_at FROM website_badges ORDER BY badge_name ASC, id ASC",
href: "/ase/economy/rewards/badges",
},
"economy.rewards.achievements": {
statement:
"SELECT id, name AS title, CONCAT(category, ' level ', level) AS description, CONCAT(reward_amount, ':', reward_type) AS status, NULL AS updated_at FROM achievements ORDER BY name ASC, level ASC, id ASC",
href: "/ase/economy/rewards/achievements",
},
"economy.rewards.sounds": {
statement:
"SELECT id, name AS title, author AS description, CONCAT(length, 'ms') AS status, NULL AS updated_at FROM soundtracks ORDER BY id ASC",
href: "/ase/economy/rewards/sounds",
},
"economy.rewards.calendar": {
statement:
"SELECT id, name AS title, CONCAT(total_days, ' days') AS description, CASE WHEN enabled = '1' THEN 'enabled' ELSE 'disabled' END AS status, FROM_UNIXTIME(start_timestamp) AS updated_at FROM calendar_campaigns ORDER BY id DESC",
href: "/ase/economy/rewards/calendar/",
appendId: true,
},
"economy.rewards.calendar-detail": {
statement:
"SELECT id, product_name AS title, CONCAT('item-', item_id, ' badge-', badge) AS description, CASE WHEN subscription_days > 0 THEN CONCAT('subscription-', subscription_days, 'd') ELSE 'reward' END AS status, NULL AS updated_at, campaign_id FROM calendar_rewards ORDER BY id ASC",
href: "/ase/economy/rewards/calendar",
parameterAlias: "campaign_id",
},
} as const satisfies Partial<Record<EconomyRouteId, QueryDefinition>>;
const EMPTY_ROUTES = new Set<EconomyRouteId>(["economy.commerce.shop-create"]);
function rows(result: unknown): readonly RawRow[] {
if (!Array.isArray(result) || !Array.isArray(result[0])) {
throw new Error("invalid Economy query result");
}
return result[0] as readonly RawRow[];
}
function value(input: unknown, fallback = ""): string {
return typeof input === "string"
? input
: input == null
? fallback
: String(input);
}
function date(valueToParse: unknown): string | null {
if (valueToParse == null) return null;
const parsed =
valueToParse instanceof Date
? valueToParse
: new Date(String(valueToParse));
return Number.isFinite(parsed.getTime()) ? parsed.toISOString() : null;
}
function statementParts(statement: string) {
const match = /^(.*?)(\s+ORDER BY\s+.+)$/iu.exec(statement);
return match
? { selection: match[1], ordering: match[2] }
: { selection: statement, ordering: "" };
}
function totalFromRows(rawRows: readonly RawRow[]): number {
const total = Number(rawRows[0]?.total ?? 0);
if (!Number.isSafeInteger(total) || total < 0) {
throw new Error("invalid Economy query total");
}
return total;
}
function mapRows(
definition: QueryDefinition,
rawRows: readonly RawRow[],
): readonly EconomyQueryItem[] {
return rawRows.map((row) => {
const id = value(row.id);
if (!id) throw new Error("invalid Economy identifier");
const amount =
row.amount == null
? undefined
: serializeEconomyAmount(
row.amount as string | number,
value(row.currency, "credits"),
);
return {
id,
title: value(row.title, "Untitled economy entry"),
description: value(row.description) || undefined,
status: value(row.status) || undefined,
updatedAt: date(row.updated_at),
amount,
href: definition.appendId ? definition.href + id : definition.href,
};
});
}
function empty(input: NormalizedEconomyQueryInput): EconomyQueryData {
return {
kind: economyRouteGroup(input.routeId),
items: [],
total: 0,
partialDependencies: [],
};
}
export async function loadEconomyDatabaseQuery(
input: NormalizedEconomyQueryInput,
): Promise<EconomyQueryData> {
const { db } = await import("@/lib/db");
return loadEconomyDatabaseQueryWith(input, (query) => db.execute(query));
}
export async function loadEconomyDatabaseQueryWith(
input: NormalizedEconomyQueryInput,
execute: (query: SQL) => Promise<unknown>,
): Promise<EconomyQueryData> {
if (EMPTY_ROUTES.has(input.routeId)) return empty(input);
const definition = (
ECONOMY_QUERY_DEFINITIONS as Partial<
Record<EconomyRouteId, QueryDefinition>
>
)[input.routeId];
if (!definition) throw new Error("missing Economy query adapter");
const { sql } = await import("drizzle-orm");
const { selection, ordering } = statementParts(definition.statement);
const filters: SQL[] = [];
if (input.list.search) {
const pattern = `%${input.list.search.toLocaleLowerCase()}%`;
filters.push(
/\bAS\s+description\b/iu.test(selection) ||
/\bdescription\b/iu.test(selection)
? sql`(LOWER(COALESCE(title, '')) LIKE ${pattern} OR LOWER(COALESCE(description, '')) LIKE ${pattern})`
: sql`LOWER(COALESCE(title, '')) LIKE ${pattern}`,
);
}
if (input.params.id) {
const alias = definition.parameterAlias ?? "id";
filters.push(sql`CAST(${sql.raw(alias)} AS CHAR) = ${input.params.id}`);
}
const filtered = filters.length
? sql`${sql.raw(selection)} HAVING ${sql.join(filters, sql` AND `)}`
: sql.raw(selection);
const total = totalFromRows(
rows(
await execute(
sql`SELECT COUNT(*) AS total FROM (${filtered}) AS economy_rows`,
),
),
);
const items = mapRows(
definition,
rows(
await execute(
sql`${filtered} ${sql.raw(ordering)} LIMIT ${input.list.pageSize} OFFSET ${input.list.offset}`,
),
),
);
return {
kind: economyRouteGroup(input.routeId),
items,
total,
partialDependencies: [],
};
}
@@ -1,114 +0,0 @@
import { describe, expect, it, vi } from "vitest";
import { PERMS } from "@/lib/permission-slugs";
import type { HousekeepingCapabilityContext } from "../../../foundation/contracts";
import { ECONOMY_ROUTE_IDS } from "../routes";
import { createEconomyQuery, type EconomyQueryData } from "./catalog";
import { serializeEconomyAmount } from "./commerce";
import { serializeEconomyValue } from "./value";
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("Economy query adapters", () => {
it("fails closed before invoking an unauthorized adapter", async () => {
const load = vi.fn();
const query = createEconomyQuery({ load });
const result = await query.run(context([]), {
routeId: "economy.catalog.overview",
});
expect(result).toMatchObject({
ok: false,
error: { code: "FORBIDDEN" },
});
expect(load).not.toHaveBeenCalled();
});
it("normalizes stable pagination and preserves BIGINT identifiers", async () => {
const load = vi.fn(
async (): Promise<EconomyQueryData> => ({
kind: "history",
items: [
{
id: "18446744073709551615",
title: "Completed order",
amount: "EUR 12.50",
href: "/ase/economy/history/transactions",
},
],
total: 501,
partialDependencies: [],
}),
);
const query = createEconomyQuery({ load });
const result = await query.run(context([PERMS.SHOP_VIEW]), {
routeId: "economy.history.transactions",
list: { search: " paid ", pageSize: 999, offset: 999_999 },
});
expect(load).toHaveBeenCalledWith({
routeId: "economy.history.transactions",
params: {},
list: { search: "paid", pageSize: 100, offset: 100_000 },
});
expect(result).toMatchObject({
ok: true,
data: { items: [{ id: "18446744073709551615" }], total: 501 },
});
});
it("serializes money and rare values without locale-dependent rounding", () => {
expect(serializeEconomyAmount(12.5, "eur")).toBe("EUR 12.50");
expect(serializeEconomyAmount("9007199254740993", "usd")).toBe(
"USD 9007199254740993",
);
expect(serializeEconomyValue("1250.50", "diamonds")).toEqual({
value: "1250.50",
currency: "diamonds",
});
});
it("rejects fabricated partial dependencies and unsafe hrefs", async () => {
const query = createEconomyQuery({
load: async () => ({
kind: "catalog",
items: [
{ id: "1", title: "Unsafe", href: "/ase/economy/%2e%2e/system" },
],
total: 1,
partialDependencies: ["imaginary"],
}),
});
expect(
await query.run(context([PERMS.CATALOG_VIEW]), {
routeId: "economy.catalog.overview",
}),
).toMatchObject({
ok: false,
error: { code: "DEPENDENCY_UNAVAILABLE" },
});
});
it("has an authorized adapter path for every Economy route", async () => {
const load = vi.fn(async (input) => ({
kind: input.routeId.split(".")[1] as EconomyQueryData["kind"],
items: [],
total: 0,
partialDependencies: [],
}));
const query = createEconomyQuery({ load });
for (const routeId of ECONOMY_ROUTE_IDS) {
const result = await query.run(context(Object.values(PERMS)), {
routeId,
});
expect(result.ok, routeId).toBe(true);
}
expect(load).toHaveBeenCalledTimes(ECONOMY_ROUTE_IDS.length);
});
});
@@ -1,15 +0,0 @@
import type { EconomyQueryData, NormalizedEconomyQueryInput } from "./catalog";
export function serializeEconomyValue(value: string, currency: string) {
return {
value: String(value).normalize("NFC").trim().slice(0, 255),
currency: String(currency).normalize("NFC").trim().slice(0, 255),
};
}
export async function loadEconomyValueQuery(
input: NormalizedEconomyQueryInput,
): Promise<EconomyQueryData> {
const { loadEconomyDatabaseQuery } = await import("./economy-production");
return loadEconomyDatabaseQuery(input);
}
@@ -1,31 +0,0 @@
import type { HousekeepingRouteHandler } from "../../route-handlers";
import { renderEconomyCatalogPage } from "./pages/catalog";
import { renderEconomyCommercePage } from "./pages/commerce";
import { renderEconomyHistoryPage } from "./pages/history";
import { renderEconomyItemsPage } from "./pages/items";
import { renderEconomyRewardsPage } from "./pages/rewards";
import { renderEconomyValuePage } from "./pages/value";
import {
ECONOMY_ROUTE_IDS,
type EconomyRouteId,
economyRouteGroup,
} from "./routes";
function rendererFor(
routeId: EconomyRouteId,
): HousekeepingRouteHandler["render"] {
const group = economyRouteGroup(routeId);
if (group === "catalog") return renderEconomyCatalogPage;
if (group === "items") return renderEconomyItemsPage;
if (group === "commerce") return renderEconomyCommercePage;
if (group === "history") return renderEconomyHistoryPage;
if (group === "value") return renderEconomyValuePage;
return renderEconomyRewardsPage;
}
export const ECONOMY_ROUTE_HANDLERS: readonly HousekeepingRouteHandler[] =
Object.freeze(
ECONOMY_ROUTE_IDS.map((routeId) =>
Object.freeze({ routeId, render: rendererFor(routeId) }),
),
);
@@ -1,119 +0,0 @@
import { describe, expect, it } from "vitest";
import { HOUSEKEEPING_MANIFESTS } from "../../manifests";
import { economyMigrationEntries } from "../../migration/economy";
import { HOUSEKEEPING_ROUTE_HANDLERS } from "../../route-handlers";
import { economyManifest } from "./manifest";
import {
ECONOMY_ROUTE_GROUPS,
ECONOMY_ROUTE_IDS,
ECONOMY_ROUTES,
type EconomyRouteId,
economyRouteGroup,
} from "./routes";
const expected = [
["economy.catalog.overview", "/ase/economy/catalog", "catalog"],
["economy.catalog.detail", "/ase/economy/catalog/:id", "catalog"],
[
"economy.catalog.builder-club-detail",
"/ase/economy/catalog/builder-club/:id",
"catalog",
],
[
"economy.catalog.maintenance",
"/ase/economy/catalog/maintenance",
"catalog",
],
["economy.items.overview", "/ase/economy/items", "items"],
["economy.items.detail", "/ase/economy/items/:id", "items"],
["economy.commerce.shop", "/ase/economy/commerce/shop", "commerce"],
[
"economy.commerce.shop-create",
"/ase/economy/commerce/shop/new",
"commerce",
],
[
"economy.commerce.shop-detail",
"/ase/economy/commerce/shop/:id",
"commerce",
],
[
"economy.commerce.marketplace",
"/ase/economy/commerce/marketplace",
"commerce",
],
["economy.commerce.vouchers", "/ase/economy/commerce/vouchers", "commerce"],
[
"economy.commerce.subscriptions",
"/ase/economy/commerce/subscriptions",
"commerce",
],
[
"economy.history.transactions",
"/ase/economy/history/transactions",
"history",
],
["economy.value.rare-values", "/ase/economy/value/rare-values", "value"],
["economy.rewards.badges", "/ase/economy/rewards/badges", "rewards"],
[
"economy.rewards.achievements",
"/ase/economy/rewards/achievements",
"rewards",
],
["economy.rewards.sounds", "/ase/economy/rewards/sounds", "rewards"],
["economy.rewards.calendar", "/ase/economy/rewards/calendar", "rewards"],
[
"economy.rewards.calendar-detail",
"/ase/economy/rewards/calendar/:id",
"rewards",
],
] as const;
describe("Economy routes", () => {
it("declares the six exact route groups", () => {
expect(ECONOMY_ROUTE_GROUPS).toEqual([
"catalog",
"items",
"commerce",
"history",
"value",
"rewards",
]);
});
it("maps all 20 matrix rows into 19 canonical workflows", () => {
expect(
ECONOMY_ROUTES.map((route) => [
route.id,
route.href,
economyRouteGroup(route.id as EconomyRouteId),
]),
).toEqual(expected);
expect(ECONOMY_ROUTE_IDS).toEqual(expected.map(([id]) => id));
expect(
[...new Set(ECONOMY_ROUTES.map((route) => route.href))].sort(),
).toEqual(
[
...new Set(
economyMigrationEntries
.filter((entry) => entry.targetPath !== null)
.map((entry) => entry.targetPath),
),
].sort(),
);
});
it("keeps manifest routes and real handlers in exact equality", () => {
expect(economyManifest.routes).toBe(ECONOMY_ROUTES);
expect(economyManifest.routes.map((route) => route.id)).toEqual(
HOUSEKEEPING_ROUTE_HANDLERS.filter((handler) =>
handler.routeId.startsWith("economy."),
).map((handler) => handler.routeId),
);
expect(
HOUSEKEEPING_MANIFESTS.flatMap((manifest) =>
manifest.routes.map((route) => route.id),
),
).toEqual(HOUSEKEEPING_ROUTE_HANDLERS.map((handler) => handler.routeId));
});
});
@@ -1,149 +0,0 @@
import { PERMS } from "@/lib/permission-slugs";
import {
anyCapability,
type CanonicalHousekeepingHref,
type HousekeepingRouteDefinition,
} from "../../foundation/contracts";
export const ECONOMY_ROUTE_GROUPS = [
"catalog",
"items",
"commerce",
"history",
"value",
"rewards",
] as const;
export type EconomyRouteGroup = (typeof ECONOMY_ROUTE_GROUPS)[number];
export const ECONOMY_ROUTE_IDS = [
"economy.catalog.overview",
"economy.catalog.detail",
"economy.catalog.builder-club-detail",
"economy.catalog.maintenance",
"economy.items.overview",
"economy.items.detail",
"economy.commerce.shop",
"economy.commerce.shop-create",
"economy.commerce.shop-detail",
"economy.commerce.marketplace",
"economy.commerce.vouchers",
"economy.commerce.subscriptions",
"economy.history.transactions",
"economy.value.rare-values",
"economy.rewards.badges",
"economy.rewards.achievements",
"economy.rewards.sounds",
"economy.rewards.calendar",
"economy.rewards.calendar-detail",
] as const;
export type EconomyRouteId = (typeof ECONOMY_ROUTE_IDS)[number];
function economyRoute(
id: EconomyRouteId,
href: CanonicalHousekeepingHref,
permissions: readonly string[],
): HousekeepingRouteDefinition {
return {
id,
labelKey: `pages.housekeeping.routes.${id}`,
href,
capability: anyCapability(...permissions),
};
}
export const ECONOMY_ROUTES = [
economyRoute("economy.catalog.overview", "/ase/economy/catalog", [
PERMS.CATALOG_VIEW,
]),
economyRoute("economy.catalog.detail", "/ase/economy/catalog/:id", [
PERMS.CATALOG_VIEW,
]),
economyRoute(
"economy.catalog.builder-club-detail",
"/ase/economy/catalog/builder-club/:id",
[PERMS.CATALOG_VIEW],
),
economyRoute(
"economy.catalog.maintenance",
"/ase/economy/catalog/maintenance",
[PERMS.CATALOG_VIEW],
),
economyRoute("economy.items.overview", "/ase/economy/items", [
PERMS.CATALOG_VIEW,
]),
economyRoute("economy.items.detail", "/ase/economy/items/:id", [
PERMS.CATALOG_VIEW,
]),
economyRoute("economy.commerce.shop", "/ase/economy/commerce/shop", [
PERMS.SHOP_VIEW,
]),
economyRoute(
"economy.commerce.shop-create",
"/ase/economy/commerce/shop/new",
[PERMS.SHOP_EDIT],
),
economyRoute(
"economy.commerce.shop-detail",
"/ase/economy/commerce/shop/:id",
[PERMS.SHOP_VIEW],
),
economyRoute(
"economy.commerce.marketplace",
"/ase/economy/commerce/marketplace",
[PERMS.SHOP_VIEW],
),
economyRoute("economy.commerce.vouchers", "/ase/economy/commerce/vouchers", [
PERMS.SHOP_VIEW,
]),
economyRoute(
"economy.commerce.subscriptions",
"/ase/economy/commerce/subscriptions",
[PERMS.SHOP_VIEW],
),
economyRoute(
"economy.history.transactions",
"/ase/economy/history/transactions",
[PERMS.SHOP_VIEW],
),
economyRoute("economy.value.rare-values", "/ase/economy/value/rare-values", [
PERMS.SHOP_VIEW,
]),
economyRoute("economy.rewards.badges", "/ase/economy/rewards/badges", [
PERMS.CATALOG_VIEW,
]),
economyRoute(
"economy.rewards.achievements",
"/ase/economy/rewards/achievements",
[PERMS.CATALOG_VIEW],
),
economyRoute("economy.rewards.sounds", "/ase/economy/rewards/sounds", [
PERMS.CATALOG_VIEW,
]),
economyRoute("economy.rewards.calendar", "/ase/economy/rewards/calendar", [
PERMS.SHOP_VIEW,
]),
economyRoute(
"economy.rewards.calendar-detail",
"/ase/economy/rewards/calendar/:id",
[PERMS.SHOP_VIEW],
),
] as const satisfies readonly HousekeepingRouteDefinition[];
const groups = new Map<EconomyRouteId, EconomyRouteGroup>(
ECONOMY_ROUTE_IDS.map((routeId) => [
routeId,
routeId.split(".")[1] as EconomyRouteGroup,
]),
);
export function economyRouteGroup(routeId: EconomyRouteId): EconomyRouteGroup {
const group = groups.get(routeId);
if (!group) throw new Error(`unknown Economy route: ${routeId}`);
return group;
}
export function economyRouteById(routeId: EconomyRouteId) {
const route = ECONOMY_ROUTES.find((candidate) => candidate.id === routeId);
if (!route) throw new Error(`unknown Economy route: ${routeId}`);
return route;
}
@@ -1,51 +0,0 @@
import "server-only";
import { PERMS } from "@/lib/permission-slugs";
import {
anyCapability,
type HousekeepingCapabilityContext,
} from "../../foundation/contracts";
import { economyQuery } from "./queries/catalog";
import type { EconomySearchCandidate } from "./search";
type EconomySearchKind = "catalog-pages" | "items" | "transactions";
const ROUTES = {
"catalog-pages": [
"economy.catalog.overview",
anyCapability(PERMS.CATALOG_VIEW),
],
items: ["economy.items.overview", anyCapability(PERMS.CATALOG_VIEW)],
transactions: [
"economy.history.transactions",
anyCapability(PERMS.SHOP_VIEW),
],
} as const;
export async function loadEconomySearchCandidates(
kind: EconomySearchKind,
context: HousekeepingCapabilityContext,
term: string,
limit: number,
query: Pick<typeof economyQuery, "run"> = economyQuery,
): Promise<readonly EconomySearchCandidate[]> {
const [routeId, capability] = ROUTES[kind];
const result = await query.run(context, {
routeId,
list: { search: term, pageSize: limit, offset: 0 },
});
if (!result.ok) throw new Error("Economy search query unavailable");
return result.data.items.flatMap((item) =>
item.href
? [
{
id: `${routeId}:${item.id}`,
title: item.title,
description: item.description ?? item.status ?? item.amount,
href: item.href,
capability,
},
]
: [],
);
}
@@ -1,120 +0,0 @@
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";
export const ECONOMY_SEARCH_PROVIDER_IDS = [
"economy.catalog-pages",
"economy.items",
"economy.transactions",
] as const;
export interface EconomySearchCandidate {
readonly id: string;
readonly title: string;
readonly description?: string;
readonly href: string;
readonly capability: CapabilityRequirement;
}
type EconomySearchLoader = (
context: HousekeepingCapabilityContext,
term: string,
limit: number,
) => Promise<readonly EconomySearchCandidate[]>;
export interface EconomySearchAdapters {
readonly catalogPages: EconomySearchLoader;
readonly items: EconomySearchLoader;
readonly transactions: EconomySearchLoader;
}
function createProvider(
id: (typeof ECONOMY_SEARCH_PROVIDER_IDS)[number],
capability: CapabilityRequirement,
load: EconomySearchLoader,
): HousekeepingSearchProvider {
return {
id,
owner: "economy",
capability,
async search(context, input) {
const authorization = authorizeHousekeeping(context, capability);
if (!authorization.ok) return authorization;
const limit = Number.isFinite(input.limit)
? Math.min(25, Math.max(1, Math.trunc(input.limit)))
: 25;
const term = input.term.normalize("NFC").trim().slice(0, 128);
try {
const candidates = await load(context, term, limit);
return ok(
candidates
.filter(
(candidate) =>
isSafeHousekeepingHref(candidate.href) &&
satisfiesCapability(context, candidate.capability),
)
.slice(0, limit)
.map((candidate) => ({
...candidate,
domain: "economy" as const,
type: "entity" as const,
href: candidate.href as `/ase/${string}`,
})),
authorization.correlationId,
);
} catch {
return fail(
"DEPENDENCY_UNAVAILABLE",
"errors.housekeeping.dependencyUnavailable",
authorization.correlationId,
);
}
},
};
}
export function createEconomySearchProviders(
adapters: EconomySearchAdapters,
): readonly HousekeepingSearchProvider[] {
return [
createProvider(
"economy.catalog-pages",
anyCapability(PERMS.CATALOG_VIEW),
adapters.catalogPages,
),
createProvider(
"economy.items",
anyCapability(PERMS.CATALOG_VIEW),
adapters.items,
),
createProvider(
"economy.transactions",
anyCapability(PERMS.SHOP_VIEW),
adapters.transactions,
),
];
}
export const ECONOMY_SEARCH_PROVIDERS = createEconomySearchProviders({
async catalogPages(context, term, limit) {
const { loadEconomySearchCandidates } = await import("./search-production");
return loadEconomySearchCandidates("catalog-pages", context, term, limit);
},
async items(context, term, limit) {
const { loadEconomySearchCandidates } = await import("./search-production");
return loadEconomySearchCandidates("items", context, term, limit);
},
async transactions(context, term, limit) {
const { loadEconomySearchCandidates } = await import("./search-production");
return loadEconomySearchCandidates("transactions", context, term, limit);
},
});
File diff suppressed because it is too large. Load diff
@@ -1,515 +0,0 @@
import "server-only";
import { eq } from "drizzle-orm";
import type { ResultSetHeader } from "mysql2";
import {
db,
MarketplaceItems,
Soundtracks,
WebsiteRareValueCategories,
WebsiteRareValues,
WebsiteShopArticles,
WebsiteShopVouchers,
} from "@/lib/db";
import {
action,
flag,
hasOwn,
integer,
notFound,
positiveBigInt,
positiveInteger,
rawText,
record,
requirePatch,
text,
validation,
} from "./mutation-runtime-input";
import type {
EconomyMutationContext,
EconomyMutationOperation,
EconomyMutationSnapshot,
} from "./mutations";
type EconomyDatabase = typeof db;
function database(transaction: unknown): EconomyDatabase {
return (transaction ?? db) as EconomyDatabase;
}
function insertedId(result: unknown): string {
return positiveBigInt(
(result as ResultSetHeader | undefined)?.insertId,
).toString();
}
function optionalUInt(value: unknown): number | null {
if (value === undefined || value === null || String(value).trim() === "") {
return null;
}
return integer(value);
}
async function shopArticleChange(
input: unknown,
transaction: unknown,
): Promise<EconomyMutationSnapshot> {
const data = record(input);
const selectedAction = action(data, ["create", "update", "delete"]);
const connection = database(transaction);
if (selectedAction === "create") {
const name = text(data.name, 255, true);
const now = new Date();
const [result] = (await connection.insert(WebsiteShopArticles).values({
name,
info: text(data.info, 255),
iconUrl: text(data.icon ?? data.iconUrl, 255),
color: text(data.color, 255),
costs: integer(data.costs),
giveRank: optionalUInt(data.giveRank),
isGiftable: hasOwn(data, "isGiftable")
? flag(data.isGiftable) === "1"
: false,
credits: optionalUInt(data.credits),
duckets: optionalUInt(data.duckets),
diamonds: optionalUInt(data.diamonds),
badges: text(data.badges, 255) || null,
furniture: rawText(data.furniture, 20_000) || null,
position: integer(data.position ?? 0),
createdAt: now,
updatedAt: now,
})) as unknown as [ResultSetHeader];
const id = insertedId(result);
return { before: null, after: { id, name }, output: { id } };
}
const id = positiveBigInt(data.id);
const [existing] = await connection
.select({
id: WebsiteShopArticles.id,
name: WebsiteShopArticles.name,
costs: WebsiteShopArticles.costs,
})
.from(WebsiteShopArticles)
.where(eq(WebsiteShopArticles.id, id))
.limit(1);
if (!existing) throw notFound();
if (selectedAction === "delete") {
await connection
.delete(WebsiteShopArticles)
.where(eq(WebsiteShopArticles.id, id));
return {
before: { id: id.toString(), name: existing.name, costs: existing.costs },
after: null,
};
}
const values: Partial<typeof WebsiteShopArticles.$inferInsert> = {};
if (hasOwn(data, "name")) values.name = text(data.name, 255, true);
if (hasOwn(data, "info")) values.info = text(data.info, 255);
if (hasOwn(data, "icon") || hasOwn(data, "iconUrl")) {
values.iconUrl = text(data.icon ?? data.iconUrl, 255);
}
if (hasOwn(data, "color")) values.color = text(data.color, 255);
if (hasOwn(data, "costs")) values.costs = integer(data.costs);
if (hasOwn(data, "giveRank")) values.giveRank = optionalUInt(data.giveRank);
if (hasOwn(data, "isGiftable"))
values.isGiftable = flag(data.isGiftable) === "1";
if (hasOwn(data, "credits")) values.credits = optionalUInt(data.credits);
if (hasOwn(data, "duckets")) values.duckets = optionalUInt(data.duckets);
if (hasOwn(data, "diamonds")) values.diamonds = optionalUInt(data.diamonds);
if (hasOwn(data, "badges")) values.badges = text(data.badges, 255) || null;
if (hasOwn(data, "furniture"))
values.furniture = rawText(data.furniture, 20_000) || null;
if (hasOwn(data, "position")) values.position = integer(data.position);
requirePatch(values as Record<string, unknown>);
values.updatedAt = new Date();
await connection
.update(WebsiteShopArticles)
.set(values)
.where(eq(WebsiteShopArticles.id, id));
return {
before: { id: id.toString(), name: existing.name, costs: existing.costs },
after: {
id: id.toString(),
name: values.name ?? existing.name,
costs: values.costs ?? existing.costs,
},
output: { id: id.toString() },
};
}
async function marketplaceCancel(
input: unknown,
transaction: unknown,
): Promise<EconomyMutationSnapshot> {
const id = positiveInteger(record(input).id);
const connection = database(transaction);
const [listing] = await connection
.select({
id: MarketplaceItems.id,
state: MarketplaceItems.state,
userId: MarketplaceItems.userId,
itemId: MarketplaceItems.itemId,
price: MarketplaceItems.price,
})
.from(MarketplaceItems)
.where(eq(MarketplaceItems.id, id))
.limit(1);
if (!listing) throw notFound();
if (listing.state !== 1) throw validation({ id: ["listing is not active"] });
await connection
.update(MarketplaceItems)
.set({ state: 0 })
.where(eq(MarketplaceItems.id, id));
return {
before: {
id: String(id),
state: 1,
userId: listing.userId,
itemId: listing.itemId,
price: listing.price,
},
after: {
id: String(id),
state: 0,
userId: listing.userId,
itemId: listing.itemId,
price: listing.price,
},
};
}
async function voucherChange(
input: unknown,
transaction: unknown,
): Promise<EconomyMutationSnapshot> {
const data = record(input);
const selectedAction = action(data, ["create", "update", "delete"]);
const connection = database(transaction);
if (selectedAction === "create") {
const code = text(data.code, 255, true);
const amount = positiveInteger(data.amount);
const maxUses = positiveInteger(data.maxUses ?? 1);
const expiresAt = data.expiresAt
? new Date(text(data.expiresAt, 64, true))
: null;
if (expiresAt && !Number.isFinite(expiresAt.getTime())) throw validation();
const now = new Date();
const [result] = (await connection.insert(WebsiteShopVouchers).values({
code,
amount,
maxUses,
useCount: 0,
expiresAt,
createdAt: now,
updatedAt: now,
})) as unknown as [ResultSetHeader];
const id = insertedId(result);
return { before: null, after: { id, amount, maxUses }, output: { id } };
}
const id = positiveBigInt(data.id);
const [existing] = await connection
.select({
id: WebsiteShopVouchers.id,
amount: WebsiteShopVouchers.amount,
maxUses: WebsiteShopVouchers.maxUses,
useCount: WebsiteShopVouchers.useCount,
})
.from(WebsiteShopVouchers)
.where(eq(WebsiteShopVouchers.id, id))
.limit(1);
if (!existing) throw notFound();
const before = {
id: id.toString(),
amount: existing.amount,
maxUses: existing.maxUses,
useCount: existing.useCount,
};
if (selectedAction === "delete") {
await connection
.delete(WebsiteShopVouchers)
.where(eq(WebsiteShopVouchers.id, id));
return { before, after: null };
}
const values: Partial<typeof WebsiteShopVouchers.$inferInsert> = {};
if (hasOwn(data, "amount")) values.amount = positiveInteger(data.amount);
if (hasOwn(data, "maxUses")) values.maxUses = positiveInteger(data.maxUses);
if (hasOwn(data, "expiresAt")) {
const parsed = data.expiresAt
? new Date(text(data.expiresAt, 64, true))
: null;
if (parsed && !Number.isFinite(parsed.getTime())) throw validation();
values.expiresAt = parsed;
}
requirePatch(values as Record<string, unknown>);
values.updatedAt = new Date();
await connection
.update(WebsiteShopVouchers)
.set(values)
.where(eq(WebsiteShopVouchers.id, id));
return {
before,
after: {
...before,
amount: values.amount ?? existing.amount,
maxUses: values.maxUses ?? existing.maxUses,
},
};
}
async function rareCategoryChange(
input: unknown,
transaction: unknown,
): Promise<EconomyMutationSnapshot> {
const data = record(input);
const selectedAction = action(data, ["create", "update", "delete"]);
const connection = database(transaction);
if (selectedAction === "create") {
const name = text(data.name, 255, true);
const badge = text(data.badge, 255, true);
const priority = positiveInteger(data.priority ?? 1);
const now = new Date();
const [result] = (await connection
.insert(WebsiteRareValueCategories)
.values({
name,
badge,
priority,
createdAt: now,
updatedAt: now,
})) as unknown as [ResultSetHeader];
const id = insertedId(result);
return {
before: null,
after: { id, name, badge, priority },
output: { id },
};
}
const id = positiveBigInt(data.id);
const [existing] = await connection
.select({
id: WebsiteRareValueCategories.id,
name: WebsiteRareValueCategories.name,
badge: WebsiteRareValueCategories.badge,
priority: WebsiteRareValueCategories.priority,
})
.from(WebsiteRareValueCategories)
.where(eq(WebsiteRareValueCategories.id, id))
.limit(1);
if (!existing) throw notFound();
const before = {
id: id.toString(),
name: existing.name,
badge: existing.badge,
priority: existing.priority,
};
if (selectedAction === "delete") {
await connection
.delete(WebsiteRareValues)
.where(eq(WebsiteRareValues.categoryId, id));
await connection
.delete(WebsiteRareValueCategories)
.where(eq(WebsiteRareValueCategories.id, id));
return { before, after: null };
}
const values: Partial<typeof WebsiteRareValueCategories.$inferInsert> = {};
if (hasOwn(data, "name")) values.name = text(data.name, 255, true);
if (hasOwn(data, "badge")) values.badge = text(data.badge, 255, true);
if (hasOwn(data, "priority"))
values.priority = positiveInteger(data.priority);
requirePatch(values as Record<string, unknown>);
values.updatedAt = new Date();
await connection
.update(WebsiteRareValueCategories)
.set(values)
.where(eq(WebsiteRareValueCategories.id, id));
return { before, after: { ...before, ...values, updatedAt: undefined } };
}
async function rareValueChange(
input: unknown,
transaction: unknown,
): Promise<EconomyMutationSnapshot> {
const data = record(input);
const selectedAction = action(data, ["create", "update", "delete"]);
const connection = database(transaction);
if (selectedAction === "create") {
const values = {
categoryId: positiveBigInt(data.categoryId),
itemId: data.itemId ? positiveInteger(data.itemId) : null,
name: text(data.name, 255, true),
creditValue: text(data.creditValue, 255) || null,
currencyValue: text(data.currencyValue, 255) || null,
currencyType: text(data.currencyType ?? "diamonds", 255, true),
furnitureIcon: text(data.furnitureIcon, 255, true),
createdAt: new Date(),
updatedAt: new Date(),
};
const [result] = (await connection
.insert(WebsiteRareValues)
.values(values)) as unknown as [ResultSetHeader];
const id = insertedId(result);
return {
before: null,
after: {
id,
categoryId: values.categoryId.toString(),
name: values.name,
currencyValue: values.currencyValue,
currencyType: values.currencyType,
},
output: { id },
};
}
const id = positiveBigInt(data.id);
const [existing] = await connection
.select({
id: WebsiteRareValues.id,
categoryId: WebsiteRareValues.categoryId,
name: WebsiteRareValues.name,
creditValue: WebsiteRareValues.creditValue,
currencyValue: WebsiteRareValues.currencyValue,
currencyType: WebsiteRareValues.currencyType,
furnitureIcon: WebsiteRareValues.furnitureIcon,
})
.from(WebsiteRareValues)
.where(eq(WebsiteRareValues.id, id))
.limit(1);
if (!existing) throw notFound();
const before = {
id: id.toString(),
categoryId: existing.categoryId.toString(),
name: existing.name,
creditValue: existing.creditValue,
currencyValue: existing.currencyValue,
currencyType: existing.currencyType,
furnitureIcon: existing.furnitureIcon,
};
if (selectedAction === "delete") {
await connection
.delete(WebsiteRareValues)
.where(eq(WebsiteRareValues.id, id));
return { before, after: null };
}
const values: Partial<typeof WebsiteRareValues.$inferInsert> = {};
if (hasOwn(data, "categoryId"))
values.categoryId = positiveBigInt(data.categoryId);
if (hasOwn(data, "itemId"))
values.itemId = data.itemId ? positiveInteger(data.itemId) : null;
if (hasOwn(data, "name")) values.name = text(data.name, 255, true);
if (hasOwn(data, "creditValue"))
values.creditValue = text(data.creditValue, 255) || null;
if (hasOwn(data, "currencyValue"))
values.currencyValue = text(data.currencyValue, 255) || null;
if (hasOwn(data, "currencyType"))
values.currencyType = text(data.currencyType, 255, true);
if (hasOwn(data, "furnitureIcon"))
values.furnitureIcon = text(data.furnitureIcon, 255, true);
requirePatch(values as Record<string, unknown>);
values.updatedAt = new Date();
await connection
.update(WebsiteRareValues)
.set(values)
.where(eq(WebsiteRareValues.id, id));
return {
before,
after: {
...before,
...values,
categoryId: values.categoryId?.toString() ?? before.categoryId,
updatedAt: undefined,
},
};
}
async function soundtrackChange(
input: unknown,
transaction: unknown,
): Promise<EconomyMutationSnapshot> {
const data = record(input);
const selectedAction = action(data, ["create", "update", "delete"]);
const connection = database(transaction);
if (selectedAction === "create") {
const values = {
code: text(data.code, 32, true),
name: text(data.name, 100, true),
author: text(data.author, 50, true),
track: rawText(data.track, 100_000),
length: integer(data.length),
};
const [result] = (await connection
.insert(Soundtracks)
.values(values)) as unknown as [ResultSetHeader];
const id = insertedId(result);
return {
before: null,
after: {
id,
code: values.code,
name: values.name,
author: values.author,
length: values.length,
},
output: { id },
};
}
const id = positiveInteger(data.id);
const [existing] = await connection
.select({
id: Soundtracks.id,
code: Soundtracks.code,
name: Soundtracks.name,
author: Soundtracks.author,
length: Soundtracks.length,
})
.from(Soundtracks)
.where(eq(Soundtracks.id, id))
.limit(1);
if (!existing) throw notFound();
const before = {
id: String(id),
code: existing.code,
name: existing.name,
author: existing.author,
length: existing.length,
};
if (selectedAction === "delete") {
await connection.delete(Soundtracks).where(eq(Soundtracks.id, id));
return { before, after: null };
}
const values: Partial<typeof Soundtracks.$inferInsert> = {};
if (hasOwn(data, "code")) values.code = text(data.code, 32, true);
if (hasOwn(data, "name")) values.name = text(data.name, 100, true);
if (hasOwn(data, "author")) values.author = text(data.author, 50, true);
if (hasOwn(data, "track")) values.track = rawText(data.track, 100_000);
if (hasOwn(data, "length")) values.length = integer(data.length);
requirePatch(values as Record<string, unknown>);
await connection
.update(Soundtracks)
.set(values)
.where(eq(Soundtracks.id, id));
return { before, after: { ...before, ...values } };
}
const HANDLERS: Partial<
Record<
EconomyMutationOperation,
(input: unknown, transaction: unknown) => Promise<EconomyMutationSnapshot>
>
> = {
"shop-article.change": shopArticleChange,
"marketplace.cancel": marketplaceCancel,
"voucher.change": voucherChange,
"rare-category.change": rareCategoryChange,
"rare-value.change": rareValueChange,
"soundtrack.change": soundtrackChange,
};
export async function executeEconomyDatabaseMutation(
operation: EconomyMutationOperation,
input: unknown,
_context: EconomyMutationContext,
transaction?: unknown,
): Promise<EconomyMutationSnapshot | null> {
const handler = HANDLERS[operation];
return handler ? handler(input, transaction) : null;
}
@@ -1,112 +0,0 @@
import { EconomyMutationFailure } from "./mutations";
export function validation(
fieldErrors?: Readonly<Record<string, readonly string[]>>,
): EconomyMutationFailure {
return new EconomyMutationFailure(
"VALIDATION",
"errors.housekeeping.validation",
fieldErrors,
);
}
export function notFound(): EconomyMutationFailure {
return new EconomyMutationFailure(
"NOT_FOUND",
"errors.housekeeping.notFound",
);
}
export function record(input: unknown): Record<string, unknown> {
if (typeof input !== "object" || input === null || Array.isArray(input)) {
throw validation();
}
return input as Record<string, unknown>;
}
export function text(
value: unknown,
maximum: number,
required = false,
): string {
const normalized = String(value ?? "")
.normalize("NFC")
.trim()
.slice(0, maximum);
if (required && !normalized) throw validation();
return normalized;
}
export function rawText(value: unknown, maximum = 100_000): string {
return String(value ?? "")
.normalize("NFC")
.slice(0, maximum);
}
export function positiveInteger(value: unknown): number {
const parsed = Number(value);
if (!Number.isSafeInteger(parsed) || parsed <= 0) throw validation();
return parsed;
}
export function integer(
value: unknown,
minimum = 0,
maximum = Number.MAX_SAFE_INTEGER,
): number {
const parsed = Number(value);
if (!Number.isSafeInteger(parsed) || parsed < minimum || parsed > maximum) {
throw validation();
}
return parsed;
}
export function positiveBigInt(value: unknown): bigint {
const normalized =
typeof value === "bigint" ? value.toString() : String(value ?? "").trim();
if (!/^[1-9]\d*$/u.test(normalized)) throw validation();
return BigInt(normalized);
}
export function action(
data: Record<string, unknown>,
allowed: readonly string[],
fallback = "update",
): string {
const normalized = text(data.action ?? fallback, 32, true);
if (!allowed.includes(normalized)) throw validation();
return normalized;
}
export function flag(value: unknown): "0" | "1" {
if (value === true || value === 1 || value === "1" || value === "on") {
return "1";
}
if (value === false || value === 0 || value === "0" || value === "") {
return "0";
}
throw validation();
}
export function hasOwn(data: Record<string, unknown>, key: string): boolean {
return Object.hasOwn(data, key);
}
export function requirePatch(data: Record<string, unknown>): void {
if (Object.keys(data).length === 0) throw validation();
}
export function integerArray(value: unknown, maximum = 500): number[] {
if (!Array.isArray(value) || value.length > maximum) throw validation();
return [...new Set(value.map(positiveInteger))];
}
export function safeOutput(value: unknown): Readonly<Record<string, unknown>> {
const serialized = JSON.stringify(value, (_key, item) =>
typeof item === "bigint" ? item.toString() : item,
);
const parsed = JSON.parse(serialized ?? "{}") as unknown;
return typeof parsed === "object" && parsed !== null && !Array.isArray(parsed)
? (parsed as Readonly<Record<string, unknown>>)
: { value: parsed };
}
@@ -1,139 +0,0 @@
import "server-only";
import { mkdir, writeFile } from "node:fs/promises";
import path from "node:path";
import { and, eq, max } from "drizzle-orm";
import { db, User, UsersBadges } from "@/lib/db";
import { toBadgeGif } from "@/lib/images/badge-gif";
import { rcon } from "@/lib/services/rcon";
import {
notFound,
positiveInteger,
record,
text,
validation,
} from "./mutation-runtime-input";
import type {
EconomyMutationContext,
EconomyMutationOperation,
EconomyMutationSnapshot,
} from "./mutations";
import { EconomyCommittedExternalFailure } from "./mutations-production";
const BADGE_CODE = /^[A-Za-z0-9_-]{1,32}$/u;
const BADGE_TYPES = new Set(["image/gif", "image/png"]);
const MAX_BADGE_BYTES = 1024 * 1024;
async function badgeGive(input: unknown): Promise<EconomyMutationSnapshot> {
const data = record(input);
const code = text(data.badge ?? data.code, 32, true);
if (!BADGE_CODE.test(code))
throw validation({ badge: ["invalid badge code"] });
let userId: number;
let username: string;
if (data.userId) {
userId = positiveInteger(data.userId);
const [user] = await db
.select({ id: User.id, username: User.username })
.from(User)
.where(eq(User.id, userId))
.limit(1);
if (!user) throw notFound();
username = user.username;
} else {
username = text(data.username, 25, true);
const [user] = await db
.select({ id: User.id, username: User.username })
.from(User)
.where(eq(User.username, username))
.limit(1);
if (!user) throw notFound();
userId = user.id;
}
const inserted = await db.transaction(async (transaction) => {
const [existing] = await transaction
.select({ id: UsersBadges.id, slotId: UsersBadges.slotId })
.from(UsersBadges)
.where(
and(eq(UsersBadges.userId, userId), eq(UsersBadges.badgeCode, code)),
)
.limit(1);
if (existing) return false;
const [aggregate] = await transaction
.select({ maxSlot: max(UsersBadges.slotId) })
.from(UsersBadges)
.where(eq(UsersBadges.userId, userId));
await transaction.insert(UsersBadges).values({
userId,
slotId: (aggregate?.maxSlot ?? 0) + 1,
badgeCode: code,
});
return true;
});
const snapshot: EconomyMutationSnapshot = {
before: { userId: String(userId), badge: code, present: !inserted },
after: { userId: String(userId), username, badge: code, present: true },
output: { userId: String(userId), badge: code, inserted },
};
try {
const delivered = await rcon.giveBadge(userId, code);
if (!delivered) throw new Error("badge grant rejected");
return snapshot;
} catch {
throw new EconomyCommittedExternalFailure(snapshot);
}
}
async function badgeUpload(input: unknown): Promise<EconomyMutationSnapshot> {
const data = record(input);
const code = text(data.code, 32, true);
if (!BADGE_CODE.test(code))
throw validation({ code: ["invalid badge code"] });
const file = data.file;
if (!(file instanceof File)) throw validation({ file: ["file required"] });
if (
file.size <= 0 ||
file.size > MAX_BADGE_BYTES ||
!BADGE_TYPES.has(file.type)
) {
throw validation({ file: ["GIF or PNG up to 1 MB required"] });
}
const configuredDirectory = process.env.BADGE_UPLOAD_DIR;
if (!configuredDirectory) {
throw new Error("badge upload directory unavailable");
}
const baseDirectory = path.resolve(configuredDirectory);
const target = path.resolve(baseDirectory, `${code}.gif`);
if (!target.startsWith(baseDirectory + path.sep)) throw validation();
const gif = await toBadgeGif(Buffer.from(await file.arrayBuffer()));
await mkdir(baseDirectory, { recursive: true });
await writeFile(target, gif);
return {
before: null,
after: { code, filename: `${code}.gif`, size: gif.byteLength },
output: { code },
};
}
const HANDLERS: Partial<
Record<
EconomyMutationOperation,
(
input: unknown,
context: EconomyMutationContext,
) => Promise<EconomyMutationSnapshot>
>
> = {
"badge.give": (input) => badgeGive(input),
"badge.upload": (input) => badgeUpload(input),
};
export async function executeEconomyRewardsMutation(
operation: EconomyMutationOperation,
input: unknown,
context: EconomyMutationContext,
): Promise<EconomyMutationSnapshot | null> {
const handler = HANDLERS[operation];
return handler ? handler(input, context) : null;
}
@@ -1,127 +0,0 @@
import { describe, expect, it, vi } from "vitest";
import type { AuditEntry } from "@/lib/services/audit";
import type { HousekeepingCapabilityContext } from "../../../foundation/contracts";
import { ECONOMY_MUTATION_OPERATIONS } from "./mutations";
import {
createEconomyProductionMutationAdapter,
ECONOMY_DATABASE_OPERATIONS,
ECONOMY_EXTERNAL_OPERATIONS,
ECONOMY_MIXED_OPERATIONS,
EconomyCommittedExternalFailure,
} from "./mutations-production";
const capability: HousekeepingCapabilityContext = {
actor: { id: 42, username: "operator", rank: 7 },
isSuperAdmin: false,
has: () => true,
hasAny: () => true,
hasAll: () => true,
};
const context = {
capability,
correlationId: "economy-production",
legacy: false,
};
function dependencies() {
const transactionToken = { transaction: true };
const writeAudit = vi.fn(
async (_entry: AuditEntry, _transaction?: unknown) => undefined,
);
const executeOperation = vi.fn(async (operation: string) => ({
before: { operation, state: "before" },
after: { operation, state: "after" },
output: { id: "18446744073709551615" },
}));
const transaction = vi.fn(async (run) => run(transactionToken));
return {
transactionToken,
writeAudit,
executeOperation,
transaction,
adapter: createEconomyProductionMutationAdapter({
transaction,
writeAudit,
executeOperation,
}),
};
}
describe("Economy production mutation adapter", () => {
it("classifies every operation exactly once", () => {
const classified = [
...ECONOMY_DATABASE_OPERATIONS,
...ECONOMY_EXTERNAL_OPERATIONS,
...ECONOMY_MIXED_OPERATIONS,
];
expect([...classified].sort()).toEqual(
[...ECONOMY_MUTATION_OPERATIONS].sort(),
);
expect(new Set(classified).size).toBe(classified.length);
});
it("keeps database changes and their success audit in one transaction", async () => {
const deps = dependencies();
await deps.adapter.execute("voucher.change", { action: "create" }, context);
expect(deps.executeOperation).toHaveBeenCalledWith(
"voucher.change",
expect.anything(),
context,
deps.transactionToken,
);
expect(deps.writeAudit).toHaveBeenCalledWith(
expect.objectContaining({
action: "economy.voucher.change",
domain: "economy",
outcome: "success",
correlationId: "economy-production",
}),
deps.transactionToken,
);
});
it("persists correlated intent and outcome around mixed changes", async () => {
const deps = dependencies();
await deps.adapter.execute("catalog-page.change", { id: 1 }, context);
expect(deps.transaction).not.toHaveBeenCalled();
expect(deps.writeAudit.mock.calls.map(([entry]) => entry.outcome)).toEqual([
"intent",
"success",
]);
});
it("returns a truthful partial when the database committed but RCON failed", async () => {
const deps = dependencies();
deps.executeOperation.mockRejectedValueOnce(
new EconomyCommittedExternalFailure({
before: { id: "1", enabled: "0" },
after: { id: "1", enabled: "1" },
}),
);
const snapshot = await deps.adapter.execute(
"catalog-page.change",
{ id: 1 },
context,
);
expect(snapshot.completion).toEqual({
status: "partial",
external: "failed",
audit: "persisted",
});
expect(deps.writeAudit.mock.calls.at(-1)?.[0]).toMatchObject({
outcome: "partial",
});
});
it("does not put voucher codes or uploaded bytes in audit records", async () => {
const deps = dependencies();
await deps.adapter.execute(
"voucher.change",
{ code: "SECRET-CODE", amount: 50 },
context,
);
expect(JSON.stringify(deps.writeAudit.mock.calls)).not.toContain(
"SECRET-CODE",
);
});
});
@@ -1,212 +0,0 @@
import type { AuditEntry } from "@/lib/services/audit";
import type {
EconomyMutationAdapter,
EconomyMutationContext,
EconomyMutationOperation,
EconomyMutationSnapshot,
} from "./mutations";
export const ECONOMY_DATABASE_OPERATIONS = [
"shop-article.change",
"marketplace.cancel",
"voucher.change",
"rare-category.change",
"rare-value.change",
"soundtrack.change",
] as const satisfies readonly EconomyMutationOperation[];
export const ECONOMY_EXTERNAL_OPERATIONS = [
"badge.upload",
] as const satisfies readonly EconomyMutationOperation[];
export const ECONOMY_MIXED_OPERATIONS = [
"catalog-page.change",
"catalog-page.reorder",
"catalog-page.delete-tree",
"bc-page.change",
"bc-page.reorder",
"bc-page.delete-tree",
"bc-item.change",
"catalog-item.change",
"catalog-item.bulk-create",
"catalog-item.move",
"catalog-item.reorder",
"catalog-item.translate",
"maintenance.fix-offers",
"maintenance.fix-everything",
"maintenance.fix-sprite-ids",
"maintenance.reconcile-ids",
"maintenance.align-ids",
"maintenance.remove-duplicates",
"items-base.update",
"badge.give",
] as const satisfies readonly EconomyMutationOperation[];
type TransactionToken = unknown;
export interface EconomyProductionMutationDependencies {
transaction<T>(
run: (transaction: TransactionToken) => Promise<T>,
): Promise<T>;
writeAudit(entry: AuditEntry, transaction?: TransactionToken): Promise<void>;
executeOperation(
operation: EconomyMutationOperation,
input: unknown,
context: EconomyMutationContext,
transaction?: TransactionToken,
): Promise<EconomyMutationSnapshot>;
}
export class EconomyCommittedExternalFailure extends Error {
constructor(readonly snapshot: EconomyMutationSnapshot) {
super("Economy database change committed but external effect failed");
this.name = "EconomyCommittedExternalFailure";
}
}
function auditEntry(
operation: EconomyMutationOperation,
context: EconomyMutationContext,
outcome: NonNullable<AuditEntry["outcome"]>,
snapshot?: EconomyMutationSnapshot,
): AuditEntry {
return {
userId: context.capability.actor.id,
action: `economy.${operation}`,
target: "Economy",
correlationId: context.correlationId,
domain: "economy",
outcome,
before:
snapshot?.before === null || snapshot?.before === undefined
? undefined
: { ...snapshot.before },
after:
snapshot?.after === null || snapshot?.after === undefined
? undefined
: { ...snapshot.after },
};
}
function includesOperation(
operations: readonly EconomyMutationOperation[],
operation: EconomyMutationOperation,
): boolean {
return operations.includes(operation);
}
async function writeOutcomeOrMarkUnavailable(
dependencies: EconomyProductionMutationDependencies,
operation: EconomyMutationOperation,
context: EconomyMutationContext,
snapshot: EconomyMutationSnapshot,
outcome: "success" | "partial",
): Promise<EconomyMutationSnapshot> {
try {
await dependencies.writeAudit(
auditEntry(operation, context, outcome, snapshot),
);
return snapshot;
} catch {
return {
...snapshot,
completion: {
status: "partial",
external: outcome === "partial" ? "failed" : "completed",
audit: "unavailable",
},
};
}
}
export function createEconomyProductionMutationAdapter(
dependencies: EconomyProductionMutationDependencies,
): EconomyMutationAdapter {
return {
async execute(operation, input, context) {
if (includesOperation(ECONOMY_DATABASE_OPERATIONS, operation)) {
return dependencies.transaction(async (transaction) => {
const snapshot = await dependencies.executeOperation(
operation,
input,
context,
transaction,
);
await dependencies.writeAudit(
auditEntry(operation, context, "success", snapshot),
transaction,
);
return snapshot;
});
}
await dependencies.writeAudit(auditEntry(operation, context, "intent"));
try {
const snapshot = await dependencies.executeOperation(
operation,
input,
context,
);
return writeOutcomeOrMarkUnavailable(
dependencies,
operation,
context,
snapshot,
"success",
);
} catch (error) {
if (
includesOperation(ECONOMY_MIXED_OPERATIONS, operation) &&
error instanceof EconomyCommittedExternalFailure
) {
const snapshot: EconomyMutationSnapshot = {
...error.snapshot,
completion: {
status: "partial",
external: "failed",
audit: "persisted",
},
};
return writeOutcomeOrMarkUnavailable(
dependencies,
operation,
context,
snapshot,
"partial",
);
}
try {
await dependencies.writeAudit(
auditEntry(operation, context, "failure"),
);
} catch {
// The durable intent remains correlated when the outcome store is down.
}
throw error;
}
},
};
}
export const economyProductionMutationAdapter =
createEconomyProductionMutationAdapter({
async transaction(run) {
const { db } = await import("@/lib/db");
return db.transaction((transaction) => run(transaction));
},
async writeAudit(entry, transaction) {
const { logAudit } = await import("@/lib/services/audit");
await logAudit(entry, transaction as never);
},
async executeOperation(operation, input, context, transaction) {
const { executeEconomyMutationOperation } = await import(
"./mutations-runtime"
);
return executeEconomyMutationOperation(
operation,
input,
context,
transaction,
);
},
});
@@ -1,42 +0,0 @@
import "server-only";
import { executeEconomyCatalogMutation } from "./mutation-runtime-catalog";
import { executeEconomyDatabaseMutation } from "./mutation-runtime-commerce";
import { executeEconomyRewardsMutation } from "./mutation-runtime-rewards";
import type {
EconomyMutationContext,
EconomyMutationOperation,
EconomyMutationSnapshot,
} from "./mutations";
import { EconomyMutationFailure } from "./mutations";
export async function executeEconomyMutationOperation(
operation: EconomyMutationOperation,
input: unknown,
context: EconomyMutationContext,
transaction?: unknown,
): Promise<EconomyMutationSnapshot> {
const databaseResult = await executeEconomyDatabaseMutation(
operation,
input,
context,
transaction,
);
if (databaseResult) return databaseResult;
const catalogResult = await executeEconomyCatalogMutation(
operation,
input,
context,
);
if (catalogResult) return catalogResult;
const rewardsResult = await executeEconomyRewardsMutation(
operation,
input,
context,
);
if (rewardsResult) return rewardsResult;
throw new EconomyMutationFailure(
"VALIDATION",
"errors.housekeeping.validation",
);
}
@@ -1,100 +0,0 @@
import { describe, expect, it, vi } from "vitest";
import type { HousekeepingCapabilityContext } from "../../../foundation/contracts";
import {
createEconomyMutationService,
EconomyMutationFailure,
} from "./mutations";
function capability(
overrides: Partial<HousekeepingCapabilityContext> = {},
): HousekeepingCapabilityContext {
return {
actor: { id: 42, username: "operator", rank: 7 },
isSuperAdmin: false,
has: () => true,
hasAny: () => true,
hasAll: () => true,
...overrides,
};
}
describe("Economy mutation service", () => {
it("rechecks the actor and capability before invoking the adapter", async () => {
const execute = vi.fn();
const service = createEconomyMutationService({ execute }, async () =>
capability({ hasAny: () => false }),
);
const result = await service.execute(
{ correlationId: "forbidden", expectedActorId: 42 },
"catalog-page.change",
{},
);
expect(result).toMatchObject({ ok: false, error: { code: "FORBIDDEN" } });
expect(execute).not.toHaveBeenCalled();
});
it("rejects a stale actor-bound invocation", async () => {
const execute = vi.fn();
const service = createEconomyMutationService({ execute }, async () =>
capability(),
);
const result = await service.execute(
{ correlationId: "stale", expectedActorId: 7 },
"voucher.change",
{},
);
expect(result).toMatchObject({ ok: false, error: { code: "FORBIDDEN" } });
expect(execute).not.toHaveBeenCalled();
});
it("preserves typed validation failures and correlation ids", async () => {
const service = createEconomyMutationService(
{
execute: async () => {
throw new EconomyMutationFailure(
"VALIDATION",
"errors.housekeeping.validation",
{ code: ["invalid"] },
);
},
},
async () => capability(),
);
const result = await service.execute(
{ correlationId: "validation", expectedActorId: 42 },
"badge.upload",
{},
);
expect(result).toMatchObject({
ok: false,
correlationId: "validation",
error: { code: "VALIDATION", fieldErrors: { code: ["invalid"] } },
});
});
it("returns snapshots and partial completion from the adapter", async () => {
const service = createEconomyMutationService(
{
execute: async () => ({
before: { id: "1" },
after: { id: "1", state: "updated" },
completion: {
status: "partial" as const,
external: "failed" as const,
audit: "persisted" as const,
},
}),
},
async () => capability(),
);
const result = await service.execute(
{ correlationId: "partial", expectedActorId: 42 },
"catalog-item.translate",
{},
);
expect(result).toMatchObject({
ok: true,
completion: { status: "partial", external: "failed" },
});
});
});
@@ -1,215 +0,0 @@
import { PERMS } from "@/lib/permission-slugs";
import { satisfiesCapability } from "../../../foundation/capability-context";
import {
anyCapability,
fail,
type HousekeepingCapabilityContext,
type HousekeepingErrorCode,
type HousekeepingPartialCompletion,
type HousekeepingResult,
ok,
} from "../../../foundation/contracts";
export const ECONOMY_MUTATION_OPERATIONS = [
"catalog-page.change",
"catalog-page.reorder",
"catalog-page.delete-tree",
"bc-page.change",
"bc-page.reorder",
"bc-page.delete-tree",
"bc-item.change",
"catalog-item.change",
"catalog-item.bulk-create",
"catalog-item.move",
"catalog-item.reorder",
"catalog-item.translate",
"maintenance.fix-offers",
"maintenance.fix-everything",
"maintenance.fix-sprite-ids",
"maintenance.reconcile-ids",
"maintenance.align-ids",
"maintenance.remove-duplicates",
"items-base.update",
"shop-article.change",
"marketplace.cancel",
"voucher.change",
"rare-category.change",
"rare-value.change",
"badge.give",
"badge.upload",
"soundtrack.change",
] as const;
export type EconomyMutationOperation =
(typeof ECONOMY_MUTATION_OPERATIONS)[number];
export interface EconomyMutationSnapshot {
readonly before: Readonly<Record<string, unknown>> | null;
readonly after: Readonly<Record<string, unknown>> | null;
readonly output?: Readonly<Record<string, unknown>>;
readonly completion?: HousekeepingPartialCompletion;
}
export interface EconomyMutationInvocation {
readonly correlationId: string;
readonly expectedActorId: number;
readonly legacy?: boolean;
}
export interface EconomyMutationContext {
readonly capability: HousekeepingCapabilityContext;
readonly correlationId: string;
readonly legacy: boolean;
}
export interface EconomyMutationAdapter {
execute(
operation: EconomyMutationOperation,
input: unknown,
context: EconomyMutationContext,
): Promise<EconomyMutationSnapshot>;
}
export interface EconomyMutationService {
execute(
invocation: EconomyMutationInvocation,
operation: EconomyMutationOperation,
input: unknown,
): Promise<HousekeepingResult<EconomyMutationSnapshot>>;
}
export class EconomyMutationFailure extends Error {
constructor(
readonly code: HousekeepingErrorCode,
readonly messageKey: string,
readonly fieldErrors?: Readonly<Record<string, readonly string[]>>,
) {
super(messageKey);
this.name = "EconomyMutationFailure";
}
}
const OPERATION_PERMISSION = Object.freeze(
Object.fromEntries(
ECONOMY_MUTATION_OPERATIONS.map((operation) => [
operation,
operation.startsWith("shop-") ||
operation.startsWith("marketplace.") ||
operation.startsWith("voucher.") ||
operation.startsWith("rare-")
? PERMS.SHOP_EDIT
: PERMS.CATALOG_EDIT,
]),
) as Record<EconomyMutationOperation, string>,
);
export function economyMutationCapability(operation: EconomyMutationOperation) {
return anyCapability(OPERATION_PERMISSION[operation]);
}
export function createEconomyMutationService(
adapter: EconomyMutationAdapter,
resolveCapabilityContext: () => Promise<HousekeepingCapabilityContext>,
): EconomyMutationService {
return {
async execute(invocation, operation, input) {
let capability: HousekeepingCapabilityContext;
try {
capability = await resolveCapabilityContext();
} catch {
return fail(
"UNAUTHENTICATED",
"errors.housekeeping.unauthenticated",
invocation.correlationId,
);
}
if (
capability.actor.id !== invocation.expectedActorId ||
!satisfiesCapability(capability, economyMutationCapability(operation))
) {
return fail(
"FORBIDDEN",
"errors.housekeeping.forbidden",
invocation.correlationId,
);
}
try {
const snapshot = await adapter.execute(operation, input, {
capability,
correlationId: invocation.correlationId,
legacy: invocation.legacy === true,
});
return ok(snapshot, invocation.correlationId, snapshot.completion);
} catch (error) {
if (error instanceof EconomyMutationFailure) {
return fail(
error.code,
error.messageKey,
invocation.correlationId,
error.fieldErrors,
);
}
return fail(
"DEPENDENCY_UNAVAILABLE",
"errors.housekeeping.dependencyUnavailable",
invocation.correlationId,
);
}
},
};
}
export function createEconomyMutationInvocation(
actor: { readonly id: number },
correlationId: string,
): EconomyMutationInvocation {
return { correlationId, expectedActorId: actor.id, legacy: true };
}
const productionAdapter: EconomyMutationAdapter = {
async execute(operation, input, context) {
const { economyProductionMutationAdapter } = await import(
"./mutations-production"
);
return economyProductionMutationAdapter.execute(operation, input, context);
},
};
export const economyMutationService = createEconomyMutationService(
productionAdapter,
async () => {
const { getHousekeepingCapabilityContext } = await import(
"../../../foundation/server-capability-context"
);
return getHousekeepingCapabilityContext();
},
);
export async function executeLegacyEconomyMutation(
actor: { readonly id: number },
operation: EconomyMutationOperation,
input: unknown,
): Promise<EconomyMutationSnapshot> {
const { createCorrelationId } = await import(
"../../../foundation/correlation"
);
const result = await economyMutationService.execute(
{
correlationId: createCorrelationId(),
expectedActorId: actor.id,
legacy: true,
},
operation,
input,
);
if (!result.ok) {
throw new EconomyMutationFailure(
result.error.code,
result.error.messageKey,
result.error.fieldErrors,
);
}
if (result.completion?.external === "failed") {
throw new EconomyMutationFailure(
"DEPENDENCY_UNAVAILABLE",
"errors.housekeeping.dependencyUnavailable",
);
}
return result.data;
}
@@ -1,50 +0,0 @@
import "server-only";
import type { HousekeepingCapabilityContext } from "../../foundation/contracts";
import { economyQuery } from "./queries/catalog";
type EconomyWidgetKind = "catalog" | "commerce" | "value";
async function total(
context: HousekeepingCapabilityContext,
routeId:
| "economy.catalog.overview"
| "economy.items.overview"
| "economy.commerce.shop"
| "economy.history.transactions"
| "economy.value.rare-values",
query: Pick<typeof economyQuery, "run">,
): Promise<number> {
const result = await query.run(context, {
routeId,
list: { pageSize: 1, offset: 0 },
});
if (!result.ok) throw new Error("Economy widget query unavailable");
return result.data.total;
}
export async function loadEconomyWidget(
kind: EconomyWidgetKind,
context: HousekeepingCapabilityContext,
signal: AbortSignal,
query: Pick<typeof economyQuery, "run"> = economyQuery,
): Promise<Readonly<Record<string, number>>> {
if (signal.aborted) throw new Error("aborted Economy widget");
if (kind === "catalog") {
const [pages, items] = await Promise.all([
total(context, "economy.catalog.overview", query),
total(context, "economy.items.overview", query),
]);
return { pages, items };
}
if (kind === "commerce") {
const [packages, transactions] = await Promise.all([
total(context, "economy.commerce.shop", query),
total(context, "economy.history.transactions", query),
]);
return { packages, transactions };
}
return {
rares: await total(context, "economy.value.rare-values", query),
};
}
@@ -1,94 +0,0 @@
import { PERMS } from "@/lib/permission-slugs";
import { authorizeHousekeeping } from "../../foundation/authorization";
import {
anyCapability,
type CapabilityRequirement,
fail,
type HousekeepingCapabilityContext,
type HousekeepingWidgetDefinition,
ok,
} from "../../foundation/contracts";
export const ECONOMY_WIDGET_IDS = [
"economy.catalog-summary",
"economy.commerce-summary",
"economy.value-summary",
] as const;
type EconomyWidgetLoader = (
context: HousekeepingCapabilityContext,
signal: AbortSignal,
) => Promise<Readonly<Record<string, number>>>;
export interface EconomyWidgetAdapters {
readonly catalog: EconomyWidgetLoader;
readonly commerce: EconomyWidgetLoader;
readonly value: EconomyWidgetLoader;
}
function createWidget(
id: (typeof ECONOMY_WIDGET_IDS)[number],
kind: "mandatory" | "optional",
capability: CapabilityRequirement,
load: EconomyWidgetLoader,
): HousekeepingWidgetDefinition {
return {
id,
owner: "economy",
kind,
capability,
async load(context, signal) {
const authorization = authorizeHousekeeping(context, capability);
if (!authorization.ok) return authorization;
try {
return ok(await load(context, signal), authorization.correlationId);
} catch {
return fail(
"DEPENDENCY_UNAVAILABLE",
"errors.housekeeping.dependencyUnavailable",
authorization.correlationId,
);
}
},
};
}
export function createEconomyWidgets(
adapters: EconomyWidgetAdapters,
): readonly HousekeepingWidgetDefinition[] {
return [
createWidget(
"economy.catalog-summary",
"mandatory",
anyCapability(PERMS.CATALOG_VIEW),
adapters.catalog,
),
createWidget(
"economy.commerce-summary",
"optional",
anyCapability(PERMS.SHOP_VIEW),
adapters.commerce,
),
createWidget(
"economy.value-summary",
"optional",
anyCapability(PERMS.SHOP_VIEW),
adapters.value,
),
];
}
export const ECONOMY_WIDGETS = createEconomyWidgets({
async catalog(context, signal) {
const { loadEconomyWidget } = await import("./widgets-production");
return loadEconomyWidget("catalog", context, signal);
},
async commerce(context, signal) {
const { loadEconomyWidget } = await import("./widgets-production");
return loadEconomyWidget("commerce", context, signal);
},
async value(context, signal) {
const { loadEconomyWidget } = await import("./widgets-production");
return loadEconomyWidget("value", context, signal);
},
});
@@ -1,5 +0,0 @@
export const HOTEL_ASSET_COMMAND_OWNERSHIP = Object.freeze({
badgeGive: "economy.rewards.badge.give",
badgeUpload: "economy.rewards.badge.upload",
soundtrackChange: "economy.rewards.soundtrack.change",
} as const);
@@ -1,115 +0,0 @@
import { describe, expect, it, vi } from "vitest";
import { PERMS } from "@/lib/permission-slugs";
import type { HousekeepingCapabilityContext } from "../../../foundation/contracts";
import { HOTEL_ASSET_COMMAND_OWNERSHIP } from "./asset-commands";
import { createHotelCommands, HOTEL_COMMAND_IDS } from "./hotel-commands";
const expected = [
["hotel.rooms.update", "room.update", PERMS.ROOMS_EDIT],
["hotel.rooms.delete", "room.delete", PERMS.ROOMS_DELETE],
["hotel.rooms.furni.update", "room-item.update", PERMS.ROOMS_EDIT],
["hotel.rooms.furni.delete", "room-item.delete", PERMS.ROOMS_EDIT],
["hotel.rooms.furni.bulk-delete", "room-item.bulk-delete", PERMS.ROOMS_EDIT],
["hotel.rooms.runtime.control", "room.runtime", PERMS.ROOMS_EDIT],
[
"hotel.radio.settings.save-one",
"radio.settings.save-one",
PERMS.RADIO_EDIT,
],
[
"hotel.radio.settings.save-many",
"radio.settings.save-many",
PERMS.RADIO_EDIT,
],
["hotel.radio.shout.delete", "radio.shout.delete", PERMS.RADIO_EDIT],
["hotel.radio.api-key.create", "radio.api-key.create", PERMS.RADIO_EDIT],
["hotel.radio.api-key.toggle", "radio.api-key.toggle", PERMS.RADIO_EDIT],
["hotel.radio.api-key.delete", "radio.api-key.delete", PERMS.RADIO_EDIT],
["hotel.radio.autodj.create", "radio.autodj.create", PERMS.RADIO_EDIT],
["hotel.radio.autodj.toggle", "radio.autodj.toggle", PERMS.RADIO_EDIT],
["hotel.radio.autodj.delete", "radio.autodj.delete", PERMS.RADIO_EDIT],
["hotel.radio.banner.create", "radio.banner.create", PERMS.RADIO_EDIT],
["hotel.radio.banner.update", "radio.banner.update", PERMS.RADIO_EDIT],
["hotel.radio.banner.delete", "radio.banner.delete", PERMS.RADIO_EDIT],
["hotel.radio.rank.create", "radio.rank.create", PERMS.RADIO_EDIT],
["hotel.radio.rank.update", "radio.rank.update", PERMS.RADIO_EDIT],
["hotel.radio.rank.delete", "radio.rank.delete", PERMS.RADIO_EDIT],
["hotel.radio.points.save", "radio.points.save", PERMS.RADIO_EDIT],
] as const;
function context(): HousekeepingCapabilityContext {
return {
actor: { id: 42, username: "operator", rank: 7 },
isSuperAdmin: false,
has: () => true,
hasAny: () => true,
hasAll: () => true,
};
}
describe("Hotel commands", () => {
it("registers the exact room and radio operation matrix", () => {
const commands = createHotelCommands({ execute: vi.fn() } as never);
expect(HOTEL_COMMAND_IDS).toEqual(expected.map(([id]) => id));
expect(
commands.map((command) => [
command.id,
command.operation,
command.capability.slugs[0],
]),
).toEqual(expected);
expect(commands.every((command) => command.owner === "hotel")).toBe(true);
});
it("requires a reason for destructive, credential, and external operations", () => {
const commands = createHotelCommands({ execute: vi.fn() } as never);
expect(
commands
.filter((command) => command.requiresReason)
.map((command) => command.id),
).toEqual([
"hotel.rooms.delete",
"hotel.rooms.furni.delete",
"hotel.rooms.furni.bulk-delete",
"hotel.rooms.runtime.control",
"hotel.radio.shout.delete",
"hotel.radio.api-key.create",
"hotel.radio.api-key.delete",
"hotel.radio.autodj.delete",
"hotel.radio.banner.delete",
"hotel.radio.rank.delete",
]);
});
it("binds execution to the server-authorized actor", async () => {
const execute = vi.fn(async () => ({
ok: true as const,
data: { before: null, after: { id: "1" } },
correlationId: "hotel-command",
}));
const [command] = createHotelCommands({ execute } as never);
await command.execute(
{
capability: context(),
correlationId: "hotel-command",
ipAddress: "127.0.0.1",
},
{ id: 1, name: "Lobby" },
);
expect(execute).toHaveBeenCalledWith(
{ correlationId: "hotel-command", expectedActorId: 42 },
"room.update",
{ id: 1, name: "Lobby" },
);
});
it("references badge and sound commands without registering duplicates", () => {
expect(HOTEL_ASSET_COMMAND_OWNERSHIP).toEqual({
badgeGive: "economy.rewards.badge.give",
badgeUpload: "economy.rewards.badge.upload",
soundtrackChange: "economy.rewards.soundtrack.change",
});
expect(HOTEL_COMMAND_IDS.some((id) => id.includes("badge"))).toBe(false);
expect(HOTEL_COMMAND_IDS.some((id) => id.includes("sound"))).toBe(false);
});
});
@@ -1,68 +0,0 @@
import "server-only";
import { z } from "zod";
import type { HousekeepingCommand } from "../../../foundation/commands/registry";
import { anyCapability } from "../../../foundation/contracts";
import {
type HotelMutationOperation,
type HotelMutationService,
hotelMutationService,
} from "../services/mutations";
import { HOTEL_RADIO_COMMAND_DEFINITIONS } from "./radio-commands";
import { HOTEL_ROOM_COMMAND_DEFINITIONS } from "./room-commands";
const HOTEL_COMMAND_DEFINITIONS = [
...HOTEL_ROOM_COMMAND_DEFINITIONS,
...HOTEL_RADIO_COMMAND_DEFINITIONS,
] as const;
export const HOTEL_COMMAND_IDS = HOTEL_COMMAND_DEFINITIONS.map(
([id]) => id,
) as ReadonlyArray<(typeof HOTEL_COMMAND_DEFINITIONS)[number][0]>;
const REASON_COMMANDS = new Set<string>([
"hotel.rooms.delete",
"hotel.rooms.furni.delete",
"hotel.rooms.furni.bulk-delete",
"hotel.rooms.runtime.control",
"hotel.radio.shout.delete",
"hotel.radio.api-key.create",
"hotel.radio.api-key.delete",
"hotel.radio.autodj.delete",
"hotel.radio.banner.delete",
"hotel.radio.rank.delete",
]);
type HotelCommand = HousekeepingCommand<Record<string, unknown>, unknown> & {
readonly operation: HotelMutationOperation;
};
const commandInput = z.object({}).catchall(z.unknown());
export function createHotelCommands(
service: Pick<HotelMutationService, "execute">,
): readonly HotelCommand[] {
return HOTEL_COMMAND_DEFINITIONS.map(
([id, operation, permission]): HotelCommand => ({
id,
owner: "hotel",
operation,
risk: "sensitive",
capability: anyCapability(permission),
input: commandInput,
requiresReason: REASON_COMMANDS.has(id),
rateLimit: { attempts: 10, windowMs: 60_000 },
execute: (context, input) =>
service.execute(
{
correlationId: context.correlationId,
expectedActorId: context.capability.actor.id,
},
operation,
input,
),
}),
);
}
export const HOTEL_COMMANDS = createHotelCommands(hotelMutationService);
@@ -1,28 +0,0 @@
import { PERMS } from "@/lib/permission-slugs";
export const HOTEL_RADIO_COMMAND_DEFINITIONS = [
[
"hotel.radio.settings.save-one",
"radio.settings.save-one",
PERMS.RADIO_EDIT,
],
[
"hotel.radio.settings.save-many",
"radio.settings.save-many",
PERMS.RADIO_EDIT,
],
["hotel.radio.shout.delete", "radio.shout.delete", PERMS.RADIO_EDIT],
["hotel.radio.api-key.create", "radio.api-key.create", PERMS.RADIO_EDIT],
["hotel.radio.api-key.toggle", "radio.api-key.toggle", PERMS.RADIO_EDIT],
["hotel.radio.api-key.delete", "radio.api-key.delete", PERMS.RADIO_EDIT],
["hotel.radio.autodj.create", "radio.autodj.create", PERMS.RADIO_EDIT],
["hotel.radio.autodj.toggle", "radio.autodj.toggle", PERMS.RADIO_EDIT],
["hotel.radio.autodj.delete", "radio.autodj.delete", PERMS.RADIO_EDIT],
["hotel.radio.banner.create", "radio.banner.create", PERMS.RADIO_EDIT],
["hotel.radio.banner.update", "radio.banner.update", PERMS.RADIO_EDIT],
["hotel.radio.banner.delete", "radio.banner.delete", PERMS.RADIO_EDIT],
["hotel.radio.rank.create", "radio.rank.create", PERMS.RADIO_EDIT],
["hotel.radio.rank.update", "radio.rank.update", PERMS.RADIO_EDIT],
["hotel.radio.rank.delete", "radio.rank.delete", PERMS.RADIO_EDIT],
["hotel.radio.points.save", "radio.points.save", PERMS.RADIO_EDIT],
] as const;
@@ -1,10 +0,0 @@
import { PERMS } from "@/lib/permission-slugs";
export const HOTEL_ROOM_COMMAND_DEFINITIONS = [
["hotel.rooms.update", "room.update", PERMS.ROOMS_EDIT],
["hotel.rooms.delete", "room.delete", PERMS.ROOMS_DELETE],
["hotel.rooms.furni.update", "room-item.update", PERMS.ROOMS_EDIT],
["hotel.rooms.furni.delete", "room-item.delete", PERMS.ROOMS_EDIT],
["hotel.rooms.furni.bulk-delete", "room-item.bulk-delete", PERMS.ROOMS_EDIT],
["hotel.rooms.runtime.control", "room.runtime", PERMS.ROOMS_EDIT],
] as const;
@@ -1,207 +0,0 @@
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);
});
});
@@ -1,532 +0,0 @@
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);
@@ -1,47 +0,0 @@
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");
});
});
@@ -1,25 +0,0 @@
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>
);
}
@@ -1,20 +0,0 @@
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>
);
}
@@ -1,74 +0,0 @@
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 } });
});
});
@@ -1,126 +0,0 @@
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,17 +3,13 @@ import {
anyCapability,
type HousekeepingDomainManifest,
} from "../../foundation/contracts";
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",
labelKey: "pages.housekeeping.domains.hotel.title",
descriptionKey: "pages.housekeeping.domains.hotel.description",
iconId: "hotel",
canonicalHref: "/ase/hotel",
previewHref: "/admin-next/hotel",
capability: anyCapability(
PERMS.ROOMS_VIEW,
PERMS.RADIO_VIEW,
@@ -24,8 +20,8 @@ export const hotelManifest = {
PERMS.PAGES_VIEW,
PERMS.CATALOG_EDIT,
),
routes: HOTEL_ROUTES,
searchProviders: HOTEL_SEARCH_PROVIDERS,
inboxSources: HOTEL_INBOX_SOURCES,
widgets: HOTEL_WIDGETS,
routes: [],
searchProviders: [],
inboxSources: [],
widgets: [],
} satisfies HousekeepingDomainManifest;
@@ -1,154 +0,0 @@
"use client";
import { useActionState } from "react";
import type { HousekeepingResult } from "../../../foundation/contracts";
export interface HotelCommandField {
readonly name: string;
readonly label: string;
readonly type:
| "text"
| "textarea"
| "number"
| "checkbox"
| "json"
| "id-list";
readonly required?: boolean;
readonly maxLength?: number;
}
export interface HotelCommandSubmission {
readonly commandId: string;
readonly input: Readonly<Record<string, unknown>>;
readonly fields?: readonly HotelCommandField[];
readonly requiresReason?: boolean;
}
const initialState: HousekeepingResult<unknown> | null = null;
const OMIT_FIELD = Symbol("omit optional Hotel command field");
function valueFor(field: HotelCommandField, formData: FormData): unknown {
const raw = formData.get(field.name);
if (field.type === "checkbox") return raw === "on";
const value = String(raw ?? "")
.normalize("NFC")
.trim();
if (!value && !field.required) return OMIT_FIELD;
if (field.type === "number") return Number(value);
if (field.type === "id-list") {
return value
.split(",")
.map((entry) => entry.trim())
.filter(Boolean);
}
if (field.type === "json") {
try {
return JSON.parse(value) as unknown;
} catch {
return value;
}
}
return value.slice(0, field.maxLength ?? 500);
}
export async function submitHotelCommandForm(
configuration: HotelCommandSubmission,
_previous: HousekeepingResult<unknown> | null,
formData: FormData,
): Promise<HousekeepingResult<unknown>> {
const submitted = Object.fromEntries(
(configuration.fields ?? []).flatMap((field) => {
const value = valueFor(field, formData);
return value === OMIT_FIELD ? [] : [[field.name, value] as const];
}),
);
const reason = String(formData.get("reason") ?? "")
.normalize("NFC")
.trim()
.slice(0, 1000);
const { executeHousekeepingCommand } = await import(
"@/actions/housekeeping-command"
);
return executeHousekeepingCommand({
commandId: configuration.commandId,
input: { ...configuration.input, ...submitted },
...(configuration.requiresReason ? { reason } : {}),
});
}
export function HotelCommandForm({
commandId,
buttonLabel,
input,
fields = [],
requiresReason = false,
}: HotelCommandSubmission & { readonly buttonLabel: string }) {
const [result, submit, pending] = useActionState(
submitHotelCommandForm.bind(null, {
commandId,
input,
fields,
requiresReason,
}),
initialState,
);
return (
<form
action={submit}
data-housekeeping-command={commandId}
className="space-y-3 rounded border border-[var(--admin-border)] p-3"
>
{fields.map((field) => {
const id = `hotel-${commandId}-${field.name}`;
return (
<label key={field.name} htmlFor={id} className="block text-sm">
{field.type === "checkbox" ? (
<>
<input id={id} name={field.name} type="checkbox" />{" "}
{field.label}
</>
) : field.type === "textarea" || field.type === "json" ? (
<>
{field.label}
<textarea
id={id}
name={field.name}
required={field.required}
maxLength={field.maxLength}
/>
</>
) : (
<>
{field.label}
<input
id={id}
name={field.name}
type={field.type === "id-list" ? "text" : field.type}
required={field.required}
maxLength={field.maxLength}
/>
</>
)}
</label>
);
})}
{requiresReason ? (
<label htmlFor={`hotel-${commandId}-reason`} className="block text-sm">
Reason
<textarea
id={`hotel-${commandId}-reason`}
name="reason"
required
maxLength={1000}
/>
</label>
) : null}
<button type="submit" disabled={pending}>
{pending ? "Working…" : buttonLabel}
</button>
{result ? (
<p role="status">{result.ok ? "Completed" : "Failed"}</p>
) : null}
</form>
);
}
@@ -1,94 +0,0 @@
import type { ReactNode } from "react";
import type {
HousekeepingCapabilityContext,
HousekeepingResult,
} from "../../../foundation/contracts";
import type { HotelQueryData } from "../queries/rooms";
import type { HotelPrimaryRouteId } from "../routes";
export interface HotelPageProps {
readonly context: HousekeepingCapabilityContext;
readonly result?: HousekeepingResult<HotelQueryData>;
readonly routeId: HotelPrimaryRouteId | null;
}
export function HotelPageFrame({
title,
description,
result,
forms,
}: {
readonly title: string;
readonly description: string;
readonly result?: HousekeepingResult<HotelQueryData>;
readonly forms?: ReactNode;
}) {
if (!result) {
return (
<section data-housekeeping-state="loading">
<h1>{title}</h1>
<p>Loading hotel data…</p>
</section>
);
}
if (!result.ok) {
return (
<section
data-housekeeping-state={
result.error.code === "FORBIDDEN" ? "forbidden" : "error"
}
role="alert"
>
<h1>{title}</h1>
<p>{result.error.messageKey}</p>
</section>
);
}
const state =
result.data.partialDependencies.length > 0
? "partial"
: result.data.items.length === 0
? "empty"
: "ready";
return (
<section data-housekeeping-state={state} className="space-y-4">
<header>
<h1>{title}</h1>
<p>{description}</p>
</header>
{result.data.partialDependencies.length > 0 ? (
<p role="status">
Temporarily unavailable: {result.data.partialDependencies.join(", ")}.
</p>
) : null}
{forms}
{result.data.items.length === 0 ? (
<p>No matching hotel records.</p>
) : (
<ul className="divide-y divide-[var(--admin-border)]">
{result.data.items.map((item) => (
<li key={item.id} className="py-2">
{item.href ? <a href={item.href}>{item.title}</a> : item.title}
{item.status ? <span> — {item.status}</span> : null}
{item.description ? <p>{item.description}</p> : null}
</li>
))}
</ul>
)}
</section>
);
}
export function parseHotelListInput(
searchParams: Readonly<
Record<string, string | readonly string[] | undefined>
>,
) {
const first = (value: string | readonly string[] | undefined) =>
Array.isArray(value) ? value[0] : value;
return {
search: first(searchParams.search),
pageSize: Number(first(searchParams.pageSize) ?? 25),
offset: Number(first(searchParams.offset) ?? 0),
};
}
@@ -1,229 +0,0 @@
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 { HotelNavigationPage } from "./navigation";
import { HotelRadioPage } from "./radio";
import { HotelRoomDetailPage } from "./room-detail";
import { HotelRoomFurniPage } from "./room-furni";
import { HotelRoomsPage } from "./rooms";
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)),
};
}
const cases = [
["rooms", HotelRoomsPage],
["rooms", HotelRoomDetailPage],
["rooms", HotelRoomFurniPage],
["navigation", HotelNavigationPage],
["radio", HotelRadioPage],
] as const;
describe.each(cases)("Hotel %s page", (kind, Component) => {
it("renders loading, forbidden, partial, empty, and canonical ready states", () => {
const render = (result?: unknown) =>
renderToStaticMarkup(
<Component
context={context(Object.values(PERMS))}
result={result as never}
routeId={null}
/>,
);
expect(render()).toContain('data-housekeeping-state="loading"');
expect(
render(fail("FORBIDDEN", "errors.housekeeping.forbidden", "denied")),
).toContain('data-housekeeping-state="forbidden"');
expect(
render(
ok(
{ kind, items: [], total: 0, partialDependencies: ["radio"] },
"partial",
),
),
).toContain('data-housekeeping-state="partial"');
expect(
render(
ok({ kind, items: [], total: 0, partialDependencies: [] }, "empty"),
),
).toContain('data-housekeeping-state="empty"');
const ready = render(
ok(
{
kind,
items: [
{
id: "18446744073709551615",
title: "Canonical hotel item",
href: "/ase/hotel/rooms",
},
],
total: 1,
partialDependencies: [],
},
"ready",
),
);
expect(ready).toContain('data-housekeeping-state="ready"');
expect(ready).toContain("Canonical hotel item");
expect(ready).not.toContain("/admin");
});
});
describe("Hotel mutation forms", () => {
it("renders room controls only with exact edit/delete capabilities", () => {
const result = ok(
{
kind: "rooms" as const,
items: [{ id: "7", title: "Lobby", status: "open" }],
total: 1,
partialDependencies: [],
},
"room",
);
const readOnly = renderToStaticMarkup(
<HotelRoomDetailPage
context={context([PERMS.ROOMS_VIEW])}
result={result}
routeId="hotel.room-detail"
/>,
);
const editor = renderToStaticMarkup(
<HotelRoomDetailPage
context={context([
PERMS.ROOMS_VIEW,
PERMS.ROOMS_EDIT,
PERMS.ROOMS_DELETE,
])}
result={result}
routeId="hotel.room-detail"
/>,
);
expect(readOnly).not.toContain("hotel.rooms.update");
expect(editor).toContain("hotel.rooms.update");
expect(editor).toContain("hotel.rooms.delete");
expect(editor).toContain("hotel.rooms.runtime.control");
for (const field of ["id", "name", "description", "state", "usersMax"]) {
expect(editor).toContain(`name="${field}"`);
}
});
it("exposes scoped furniture update and destructive controls", () => {
const markup = renderToStaticMarkup(
<HotelRoomFurniPage
context={context([PERMS.ROOMS_VIEW, PERMS.ROOMS_EDIT])}
result={ok(
{
kind: "rooms",
items: [{ id: "8", title: "Chair" }],
total: 1,
partialDependencies: [],
},
"furni",
)}
routeId="hotel.room-furni"
/>,
);
for (const commandId of [
"hotel.rooms.furni.update",
"hotel.rooms.furni.delete",
"hotel.rooms.furni.bulk-delete",
]) {
expect(markup).toContain(commandId);
}
});
it("renders route-shaped radio controls without exposing API keys", () => {
const result = ok(
{
kind: "radio" as const,
items: [
{
id: "9",
title: "Bridge key",
description: "****cafe",
},
],
total: 1,
partialDependencies: [],
},
"api-key",
);
const markup = renderToStaticMarkup(
<HotelRadioPage
context={context([PERMS.RADIO_VIEW, PERMS.RADIO_EDIT])}
result={result}
routeId="hotel.radio.api-keys"
/>,
);
expect(markup).toContain("hotel.radio.api-key.create");
expect(markup).toContain('name="allowedIps"');
expect(markup).toContain("****cafe");
expect(markup).not.toContain("secret-api-key");
});
it.each([
[
"hotel.radio.settings",
["hotel.radio.settings.save-one", "hotel.radio.settings.save-many"],
],
["hotel.radio.moderation", ["hotel.radio.shout.delete"]],
[
"hotel.radio.autodj",
[
"hotel.radio.autodj.create",
"hotel.radio.autodj.toggle",
"hotel.radio.autodj.delete",
],
],
[
"hotel.radio.api-keys",
[
"hotel.radio.api-key.create",
"hotel.radio.api-key.toggle",
"hotel.radio.api-key.delete",
],
],
[
"hotel.radio.banners",
[
"hotel.radio.banner.create",
"hotel.radio.banner.update",
"hotel.radio.banner.delete",
],
],
[
"hotel.radio.ranks",
[
"hotel.radio.rank.create",
"hotel.radio.rank.update",
"hotel.radio.rank.delete",
],
],
["hotel.radio.points", ["hotel.radio.points.save"]],
] as const)("exposes canonical %s command forms", (routeId, commandIds) => {
const markup = renderToStaticMarkup(
<HotelRadioPage
context={context([PERMS.RADIO_VIEW, PERMS.RADIO_EDIT])}
result={ok(
{ kind: "radio", items: [], total: 0, partialDependencies: [] },
"radio-forms",
)}
routeId={routeId}
/>,
);
for (const commandId of commandIds) expect(markup).toContain(commandId);
expect(markup).not.toContain("/admin");
});
});
@@ -1,27 +0,0 @@
import type { HousekeepingPageInput } from "../../../route-handlers";
import { hotelQuery } from "../queries/rooms";
import { HotelPageFrame, type HotelPageProps } from "./hotel-page-frame";
export function HotelNavigationPage(props: HotelPageProps) {
return (
<HotelPageFrame
title="Hotel navigation"
description="Inspect navigator categories and public rooms."
result={props.result}
/>
);
}
export async function renderHotelNavigationPage(input: HousekeepingPageInput) {
const result = await hotelQuery.run(input.context, {
routeId: "hotel.navigation",
params: input.match.params,
});
return (
<HotelNavigationPage
context={input.context}
result={result}
routeId="hotel.navigation"
/>
);
}
@@ -1,253 +0,0 @@
import { PERMS } from "@/lib/permission-slugs";
import type { HousekeepingPageInput } from "../../../route-handlers";
import { hotelQuery } from "../queries/rooms";
import { HotelCommandForm } from "./hotel-command-form";
import {
HotelPageFrame,
type HotelPageProps,
parseHotelListInput,
} from "./hotel-page-frame";
function radioForm(props: HotelPageProps) {
if (!props.context.has(PERMS.RADIO_EDIT)) return null;
if (props.routeId === "hotel.radio.api-keys") {
return (
<div className="grid gap-3 lg:grid-cols-2">
<HotelCommandForm
commandId="hotel.radio.api-key.create"
buttonLabel="Create API key"
input={{}}
requiresReason
fields={[
{ name: "name", label: "Name", type: "text", required: true },
{ name: "allowedIps", label: "Allowed IPs", type: "text" },
{ name: "rateLimit", label: "Rate limit", type: "number" },
]}
/>
<HotelCommandForm
commandId="hotel.radio.api-key.toggle"
buttonLabel="Set API key status"
input={{}}
fields={[
{ name: "id", label: "API key ID", type: "text", required: true },
{ name: "isActive", label: "Active", type: "checkbox" },
]}
/>
<HotelCommandForm
commandId="hotel.radio.api-key.delete"
buttonLabel="Delete API key"
input={{}}
requiresReason
fields={[
{ name: "id", label: "API key ID", type: "text", required: true },
]}
/>
</div>
);
}
if (props.routeId === "hotel.radio.settings") {
return (
<div className="grid gap-3 lg:grid-cols-2">
<HotelCommandForm
commandId="hotel.radio.settings.save-one"
buttonLabel="Save setting"
input={{}}
fields={[
{ name: "key", label: "Setting key", type: "text", required: true },
{ name: "value", label: "Value", type: "textarea", required: true },
{ name: "comment", label: "Comment", type: "text" },
]}
/>
<HotelCommandForm
commandId="hotel.radio.settings.save-many"
buttonLabel="Save settings batch"
input={{}}
fields={[
{
name: "entries",
label: "Settings JSON array",
type: "json",
required: true,
},
]}
/>
</div>
);
}
if (props.routeId === "hotel.radio.moderation") {
return (
<HotelCommandForm
commandId="hotel.radio.shout.delete"
buttonLabel="Delete shout"
input={{}}
requiresReason
fields={[
{ name: "id", label: "Shout ID", type: "text", required: true },
]}
/>
);
}
if (props.routeId === "hotel.radio.autodj") {
return (
<div className="grid gap-3 lg:grid-cols-2">
<HotelCommandForm
commandId="hotel.radio.autodj.create"
buttonLabel="Create AutoDJ track"
input={{}}
fields={[
{ name: "title", label: "Title", type: "text", required: true },
{ name: "artist", label: "Artist", type: "text" },
{ name: "album", label: "Album", type: "text" },
{ name: "artworkUrl", label: "Artwork URL", type: "text" },
{ name: "duration", label: "Duration", type: "number" },
{ name: "sortOrder", label: "Sort order", type: "number" },
{ name: "isActive", label: "Active", type: "checkbox" },
]}
/>
<HotelCommandForm
commandId="hotel.radio.autodj.toggle"
buttonLabel="Set AutoDJ status"
input={{}}
fields={[
{ name: "id", label: "Track ID", type: "text", required: true },
{ name: "isActive", label: "Active", type: "checkbox" },
]}
/>
<HotelCommandForm
commandId="hotel.radio.autodj.delete"
buttonLabel="Delete AutoDJ track"
input={{}}
requiresReason
fields={[
{ name: "id", label: "Track ID", type: "text", required: true },
]}
/>
</div>
);
}
if (props.routeId === "hotel.radio.banners") {
const bannerFields = [
{ name: "imagePath", label: "Image path", type: "text", required: true },
{ name: "title", label: "Title", type: "text" },
{ name: "description", label: "Description", type: "textarea" },
{ name: "sortOrder", label: "Sort order", type: "number" },
{ name: "isActive", label: "Active", type: "checkbox" },
] as const;
return (
<div className="grid gap-3 lg:grid-cols-2">
<HotelCommandForm
commandId="hotel.radio.banner.create"
buttonLabel="Create banner"
input={{}}
fields={bannerFields}
/>
<HotelCommandForm
commandId="hotel.radio.banner.update"
buttonLabel="Update banner"
input={{}}
fields={[
{ name: "id", label: "Banner ID", type: "text", required: true },
...bannerFields,
]}
/>
<HotelCommandForm
commandId="hotel.radio.banner.delete"
buttonLabel="Delete banner"
input={{}}
requiresReason
fields={[
{ name: "id", label: "Banner ID", type: "text", required: true },
]}
/>
</div>
);
}
if (props.routeId === "hotel.radio.ranks") {
const rankFields = [
{ name: "name", label: "Name", type: "text", required: true },
{ name: "description", label: "Description", type: "text" },
{ name: "badgeCode", label: "Badge code", type: "text" },
{ name: "isActive", label: "Active", type: "checkbox" },
] as const;
return (
<div className="grid gap-3 lg:grid-cols-2">
<HotelCommandForm
commandId="hotel.radio.rank.create"
buttonLabel="Create rank"
input={{}}
fields={rankFields}
/>
<HotelCommandForm
commandId="hotel.radio.rank.update"
buttonLabel="Update rank"
input={{}}
fields={[
{ name: "id", label: "Rank ID", type: "text", required: true },
...rankFields,
]}
/>
<HotelCommandForm
commandId="hotel.radio.rank.delete"
buttonLabel="Delete rank"
input={{}}
requiresReason
fields={[
{ name: "id", label: "Rank ID", type: "text", required: true },
]}
/>
</div>
);
}
if (props.routeId === "hotel.radio.points") {
return (
<HotelCommandForm
commandId="hotel.radio.points.save"
buttonLabel="Save listener points"
input={{}}
fields={[
{ name: "radio_points_enabled", label: "Enabled", type: "checkbox" },
{
name: "radio_points_per_minute",
label: "Points per minute",
type: "number",
},
{ name: "radio_points_currency", label: "Currency", type: "text" },
{
name: "radio_points_max_per_day",
label: "Maximum per day",
type: "number",
},
{
name: "radio_points_min_listeners",
label: "Minimum listeners",
type: "number",
},
]}
/>
);
}
return null;
}
export function HotelRadioPage(props: HotelPageProps) {
return (
<HotelPageFrame
title="Radio"
description="Configuration, monitoring and moderation for hotel radio."
result={props.result}
forms={radioForm(props)}
/>
);
}
export async function renderHotelRadioPage(input: HousekeepingPageInput) {
const routeId = input.match.routeId as HotelPageProps["routeId"];
const result = await hotelQuery.run(input.context, {
routeId: routeId ?? "hotel.radio.overview",
params: input.match.params,
list: parseHotelListInput(input.searchParams ?? {}),
});
return (
<HotelRadioPage context={input.context} result={result} routeId={routeId} />
);
}
@@ -1,87 +0,0 @@
import { PERMS } from "@/lib/permission-slugs";
import type { HousekeepingPageInput } from "../../../route-handlers";
import { hotelQuery } from "../queries/rooms";
import { HotelCommandForm } from "./hotel-command-form";
import { HotelPageFrame, type HotelPageProps } from "./hotel-page-frame";
export function HotelRoomDetailPage(props: HotelPageProps) {
const id = props.result?.ok ? props.result.data.items[0]?.id : undefined;
const canEdit = props.context.has(PERMS.ROOMS_EDIT);
return (
<HotelPageFrame
title="Room detail"
description="Inspect configuration and control a room."
result={props.result}
forms={
canEdit ? (
<div className="grid gap-3 lg:grid-cols-2">
<HotelCommandForm
commandId="hotel.rooms.update"
buttonLabel="Save room"
input={{}}
fields={[
{
name: "id",
label: "Room ID",
type: "number",
required: true,
},
{ name: "name", label: "Name", type: "text", required: true },
{ name: "description", label: "Description", type: "textarea" },
{ name: "state", label: "State", type: "text", required: true },
{
name: "usersMax",
label: "Maximum users",
type: "number",
required: true,
},
]}
/>
<HotelCommandForm
commandId="hotel.rooms.runtime.control"
buttonLabel="Run room control"
input={{}}
requiresReason
fields={[
{
name: "roomId",
label: "Room ID",
type: "number",
required: true,
},
{
name: "action",
label: "Action (reload, kick, lock, unlock)",
type: "text",
required: true,
},
]}
/>
{props.context.has(PERMS.ROOMS_DELETE) ? (
<HotelCommandForm
commandId="hotel.rooms.delete"
buttonLabel="Delete room"
input={{ id }}
requiresReason
/>
) : null}
</div>
) : null
}
/>
);
}
export async function renderHotelRoomDetailPage(input: HousekeepingPageInput) {
const result = await hotelQuery.run(input.context, {
routeId: "hotel.room-detail",
params: input.match.params,
});
return (
<HotelRoomDetailPage
context={input.context}
result={result}
routeId="hotel.room-detail"
/>
);
}
@@ -1,105 +0,0 @@
import { PERMS } from "@/lib/permission-slugs";
import type { HousekeepingPageInput } from "../../../route-handlers";
import { hotelQuery } from "../queries/rooms";
import { HotelCommandForm } from "./hotel-command-form";
import {
HotelPageFrame,
type HotelPageProps,
parseHotelListInput,
} from "./hotel-page-frame";
export function HotelRoomFurniPage(props: HotelPageProps) {
return (
<HotelPageFrame
title="Room furniture"
description="Inspect and safely edit furniture scoped to this room."
result={props.result}
forms={
props.context.has(PERMS.ROOMS_EDIT) ? (
<div className="grid gap-3 lg:grid-cols-2">
<HotelCommandForm
commandId="hotel.rooms.furni.update"
buttonLabel="Save furniture"
input={{}}
fields={[
{
name: "roomId",
label: "Room ID",
type: "number",
required: true,
},
{
name: "itemId",
label: "Item ID",
type: "number",
required: true,
},
{ name: "x", label: "X", type: "number" },
{ name: "y", label: "Y", type: "number" },
{ name: "z", label: "Z", type: "number" },
{ name: "rot", label: "Rotation", type: "number" },
{ name: "wallPos", label: "Wall position", type: "text" },
{ name: "extraData", label: "Extra data", type: "textarea" },
]}
/>
<HotelCommandForm
commandId="hotel.rooms.furni.delete"
buttonLabel="Delete furniture"
input={{}}
requiresReason
fields={[
{
name: "roomId",
label: "Room ID",
type: "number",
required: true,
},
{
name: "itemId",
label: "Item ID",
type: "number",
required: true,
},
]}
/>
<HotelCommandForm
commandId="hotel.rooms.furni.bulk-delete"
buttonLabel="Delete selected furniture"
input={{}}
requiresReason
fields={[
{
name: "roomId",
label: "Room ID",
type: "number",
required: true,
},
{
name: "itemIds",
label: "Item IDs (comma separated)",
type: "id-list",
required: true,
},
]}
/>
</div>
) : null
}
/>
);
}
export async function renderHotelRoomFurniPage(input: HousekeepingPageInput) {
const result = await hotelQuery.run(input.context, {
routeId: "hotel.room-furni",
params: input.match.params,
list: parseHotelListInput(input.searchParams ?? {}),
});
return (
<HotelRoomFurniPage
context={input.context}
result={result}
routeId="hotel.room-furni"
/>
);
}
@@ -1,47 +0,0 @@
import { PERMS } from "@/lib/permission-slugs";
import type { HousekeepingPageInput } from "../../../route-handlers";
import { hotelQuery } from "../queries/rooms";
import { HotelCommandForm } from "./hotel-command-form";
import {
HotelPageFrame,
type HotelPageProps,
parseHotelListInput,
} from "./hotel-page-frame";
export function HotelRoomsPage(props: HotelPageProps) {
return (
<HotelPageFrame
title="Rooms"
description="Search and operate hotel rooms."
result={props.result}
forms={
props.context.has(PERMS.ROOMS_EDIT) ? (
<HotelCommandForm
commandId="hotel.rooms.update"
buttonLabel="Save room"
input={{}}
fields={[
{ name: "id", label: "Room ID", type: "number", required: true },
{ name: "name", label: "Name", type: "text", required: true },
]}
/>
) : null
}
/>
);
}
export async function renderHotelRoomsPage(input: HousekeepingPageInput) {
const result = await hotelQuery.run(input.context, {
routeId: "hotel.rooms",
params: input.match.params,
list: parseHotelListInput(input.searchParams ?? {}),
});
return (
<HotelRoomsPage
context={input.context}
result={result}
routeId="hotel.rooms"
/>
);
}
Loaded 100 of 802 files, more files were not shown because too many files have changed in this diff. Show more