feat(housekeeping): unify complete rebuild in gated preview
This commit is contained in:
1 parent
4ad007d241
commit
43470ebf82
402 files changed
+78313
-1663
No files matched your search
@@ -0,0 +1,4 @@
|
||||
export const HOTEL_ASSET_QUERY_OWNERSHIP = Object.freeze({
|
||||
badges: "economy.rewards.badges",
|
||||
sounds: "economy.rewards.sounds",
|
||||
} as const);
|
||||
@@ -0,0 +1,232 @@
|
||||
import "server-only";
|
||||
|
||||
import type { SQL } from "drizzle-orm";
|
||||
import { type HotelPrimaryRouteId, hotelRouteGroup } from "../routes";
|
||||
import type {
|
||||
HotelQueryData,
|
||||
HotelQueryItem,
|
||||
NormalizedHotelQueryInput,
|
||||
} from "./rooms";
|
||||
|
||||
type RawRow = Readonly<Record<string, unknown>>;
|
||||
|
||||
interface QueryDefinition {
|
||||
readonly statement: string;
|
||||
readonly href: string;
|
||||
readonly appendId?: boolean;
|
||||
readonly parameterAlias?: "id" | "room_id";
|
||||
readonly fallbackStatement?: string;
|
||||
readonly partialDependency?: string;
|
||||
}
|
||||
|
||||
export const HOTEL_QUERY_DEFINITIONS = {
|
||||
"hotel.rooms": {
|
||||
statement:
|
||||
"SELECT id, name AS title, CONCAT(COALESCE(NULLIF(owner_name, ''), 'unknown'), ' · ', users, '/', users_max) AS description, state AS status, NULL AS updated_at FROM rooms ORDER BY id DESC",
|
||||
href: "/ase-next/hotel/rooms/",
|
||||
appendId: true,
|
||||
},
|
||||
"hotel.room-detail": {
|
||||
statement:
|
||||
"SELECT id, name AS title, CONCAT(COALESCE(NULLIF(owner_name, ''), 'unknown'), ' · score ', score, ' · category ', category) AS description, state AS status, NULL AS updated_at FROM rooms ORDER BY id DESC",
|
||||
href: "/ase-next/hotel/rooms/",
|
||||
appendId: true,
|
||||
},
|
||||
"hotel.room-furni": {
|
||||
statement:
|
||||
"SELECT i.id, COALESCE(NULLIF(ib.public_name, ''), NULLIF(ib.item_name, ''), CONCAT('#', i.item_id)) AS title, CONCAT('owner-', i.user_id, ' @ ', i.x, ',', i.y, ',', i.z) AS description, CONCAT('rot-', i.rot) AS status, NULL AS updated_at, i.room_id FROM items i LEFT JOIN items_base ib ON ib.id = i.item_id ORDER BY i.id DESC",
|
||||
href: "/ase-next/hotel/rooms",
|
||||
parameterAlias: "room_id",
|
||||
},
|
||||
"hotel.navigation": {
|
||||
statement:
|
||||
"SELECT CONCAT('flat-', id) AS id, caption AS title, CONCAT('min-rank ', min_rank, ' · max-users ', max_user_count) AS description, CASE WHEN public = '1' THEN 'public' ELSE 'private' END AS status, NULL AS updated_at FROM navigator_flatcats UNION ALL SELECT CONCAT('public-', np.public_cat_id, '-', np.room_id) AS id, COALESCE(NULLIF(r.name, ''), CONCAT('Room #', np.room_id)) AS title, CONCAT('category-', np.public_cat_id) AS description, CASE WHEN np.visible = '1' THEN 'visible' ELSE 'hidden' END AS status, NULL AS updated_at FROM navigator_publics np LEFT JOIN rooms r ON r.id = np.room_id ORDER BY title ASC, id ASC",
|
||||
fallbackStatement:
|
||||
"SELECT CONCAT('flat-', id) AS id, caption AS title, CONCAT('min-rank ', min_rank, ' · max-users ', max_user_count) AS description, CASE WHEN public = '1' THEN 'public' ELSE 'private' END AS status, NULL AS updated_at FROM navigator_flatcats ORDER BY title ASC, id ASC",
|
||||
partialDependency: "navigator-publics",
|
||||
href: "/ase-next/hotel/navigation",
|
||||
},
|
||||
"hotel.radio.overview": {
|
||||
statement:
|
||||
"SELECT CONCAT('application-', id) AS id, real_name AS title, motivation AS description, status, created_at AS updated_at FROM radio_applications UNION ALL SELECT CONCAT('schedule-', id) AS id, COALESCE(NULLIF(show_name, ''), day) AS title, CONCAT(day, ' ', start_time, '-', end_time) AS description, CASE WHEN is_active = 1 THEN 'active' ELSE 'inactive' END AS status, created_at AS updated_at FROM radio_schedules UNION ALL SELECT CONCAT('shout-', id) AS id, CONCAT('User #', user_id) AS title, message AS description, 'shout' AS status, created_at AS updated_at FROM radio_shouts ORDER BY updated_at DESC, id DESC",
|
||||
href: "/ase-next/hotel/radio/overview",
|
||||
},
|
||||
"hotel.radio.settings": {
|
||||
statement:
|
||||
"SELECT id, `key` AS title, CASE WHEN LOWER(`key`) REGEXP '(key|secret|password|token|webhook|custom_js)' THEN '[redacted]' ELSE LEFT(value, 512) END AS description, 'configured' AS status, NULL AS updated_at FROM website_settings WHERE `key` LIKE 'radio_%' OR `key` LIKE 'auto_dj_%' ORDER BY `key` ASC, id ASC",
|
||||
href: "/ase-next/hotel/radio/settings",
|
||||
},
|
||||
"hotel.radio.moderation": {
|
||||
statement:
|
||||
"SELECT id, CONCAT('User #', user_id) AS title, message AS description, 'shout' AS status, created_at AS updated_at FROM radio_shouts ORDER BY created_at DESC, id DESC",
|
||||
href: "/ase-next/hotel/radio/moderation",
|
||||
},
|
||||
"hotel.radio.autodj": {
|
||||
statement:
|
||||
"SELECT id, title, CONCAT(COALESCE(artist, ''), CASE WHEN album IS NULL OR album = '' THEN '' ELSE CONCAT(' · ', album) END) AS description, CASE WHEN is_active = 1 THEN 'active' ELSE 'inactive' END AS status, updated_at FROM radio_auto_dj_playlist ORDER BY sort_order ASC, title ASC, id ASC",
|
||||
href: "/ase-next/hotel/radio/autodj",
|
||||
},
|
||||
"hotel.radio.history": {
|
||||
statement:
|
||||
"SELECT id, COALESCE(NULLIF(show_name, ''), CONCAT('DJ #', user_id)) AS title, CONCAT(listeners_count, ' listeners') AS description, CASE WHEN ended_at IS NULL THEN 'live' ELSE 'completed' END AS status, started_at AS updated_at FROM radio_history ORDER BY started_at DESC, id DESC",
|
||||
href: "/ase-next/hotel/radio/history",
|
||||
},
|
||||
"hotel.radio.points": {
|
||||
statement:
|
||||
"SELECT id, `key` AS title, value AS description, 'configured' AS status, NULL AS updated_at FROM website_settings WHERE `key` LIKE 'radio_points_%' ORDER BY `key` ASC, id ASC",
|
||||
href: "/ase-next/hotel/radio/points",
|
||||
},
|
||||
"hotel.radio.ranks": {
|
||||
statement:
|
||||
"SELECT id, name AS title, CONCAT(COALESCE(description, ''), CASE WHEN badge_code IS NULL OR badge_code = '' THEN '' ELSE CONCAT(' · ', badge_code) END) AS description, CASE WHEN is_active = 1 THEN 'active' ELSE 'inactive' END AS status, updated_at FROM radio_ranks ORDER BY name ASC, id ASC",
|
||||
href: "/ase-next/hotel/radio/ranks",
|
||||
},
|
||||
"hotel.radio.api-keys": {
|
||||
statement:
|
||||
"SELECT id, name AS title, CONCAT('****', RIGHT(`key`, 4), CASE WHEN allowed_ips IS NULL OR allowed_ips = '' THEN '' ELSE CONCAT(' · ', allowed_ips) END) AS description, CASE WHEN is_active = 1 THEN 'active' ELSE 'inactive' END AS status, updated_at FROM radio_api_keys ORDER BY id DESC",
|
||||
href: "/ase-next/hotel/radio/api-keys",
|
||||
},
|
||||
"hotel.radio.banners": {
|
||||
statement:
|
||||
"SELECT id, COALESCE(NULLIF(title, ''), image_path) AS title, image_path AS description, CASE WHEN is_active = 1 THEN 'active' ELSE 'inactive' END AS status, updated_at FROM radio_banners ORDER BY sort_order ASC, id DESC",
|
||||
href: "/ase-next/hotel/radio/banners",
|
||||
},
|
||||
"hotel.radio.embed": {
|
||||
statement:
|
||||
"SELECT id, `key` AS title, value AS description, 'embed' AS status, NULL AS updated_at FROM website_settings WHERE `key` IN ('radio_name', 'radio_stream_url', 'radio_logo_url') ORDER BY `key` ASC, id ASC",
|
||||
href: "/ase-next/hotel/radio/embed",
|
||||
},
|
||||
} as const satisfies Partial<Record<HotelPrimaryRouteId, QueryDefinition>>;
|
||||
|
||||
function rows(result: unknown): readonly RawRow[] {
|
||||
if (!Array.isArray(result) || !Array.isArray(result[0])) {
|
||||
throw new Error("invalid Hotel 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 Hotel query total");
|
||||
}
|
||||
return total;
|
||||
}
|
||||
|
||||
function mapRows(
|
||||
definition: QueryDefinition,
|
||||
rawRows: readonly RawRow[],
|
||||
): readonly HotelQueryItem[] {
|
||||
return rawRows.map((row) => {
|
||||
const id = value(row.id);
|
||||
if (!id) throw new Error("invalid Hotel identifier");
|
||||
return {
|
||||
id,
|
||||
title: value(row.title, "Untitled hotel entry"),
|
||||
description: value(row.description) || undefined,
|
||||
status: value(row.status) || undefined,
|
||||
updatedAt: date(row.updated_at),
|
||||
href: definition.appendId ? definition.href + id : definition.href,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export async function loadHotelDatabaseQuery(
|
||||
input: NormalizedHotelQueryInput,
|
||||
): Promise<HotelQueryData> {
|
||||
const { db } = await import("@/lib/db");
|
||||
return loadHotelDatabaseQueryWith(input, (query) => db.execute(query));
|
||||
}
|
||||
|
||||
export async function loadHotelDatabaseQueryWith(
|
||||
input: NormalizedHotelQueryInput,
|
||||
execute: (query: SQL) => Promise<unknown>,
|
||||
): Promise<HotelQueryData> {
|
||||
const definition = (
|
||||
HOTEL_QUERY_DEFINITIONS as Partial<
|
||||
Record<HotelPrimaryRouteId, QueryDefinition>
|
||||
>
|
||||
)[input.routeId];
|
||||
if (!definition) throw new Error("missing Hotel query adapter");
|
||||
const resolvedDefinition: QueryDefinition = definition;
|
||||
const { sql } = await import("drizzle-orm");
|
||||
async function run(
|
||||
statement: string,
|
||||
partialDependencies: readonly string[],
|
||||
): Promise<HotelQueryData> {
|
||||
const { selection, ordering } = statementParts(statement);
|
||||
const filters: SQL[] = [];
|
||||
if (input.list.search) {
|
||||
const pattern = `%${input.list.search.toLocaleLowerCase()}%`;
|
||||
filters.push(
|
||||
sql`(LOWER(COALESCE(title, '')) LIKE ${pattern} OR LOWER(COALESCE(description, '')) LIKE ${pattern})`,
|
||||
);
|
||||
}
|
||||
if (input.params.id) {
|
||||
const alias = resolvedDefinition.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 hotel_rows`,
|
||||
),
|
||||
),
|
||||
);
|
||||
const items = mapRows(
|
||||
resolvedDefinition,
|
||||
rows(
|
||||
await execute(
|
||||
sql`${filtered} ${sql.raw(ordering)} LIMIT ${input.list.pageSize} OFFSET ${input.list.offset}`,
|
||||
),
|
||||
),
|
||||
);
|
||||
return {
|
||||
kind: hotelRouteGroup(input.routeId),
|
||||
items,
|
||||
total,
|
||||
partialDependencies,
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
return await run(resolvedDefinition.statement, []);
|
||||
} catch (error) {
|
||||
if (
|
||||
!resolvedDefinition.fallbackStatement ||
|
||||
!resolvedDefinition.partialDependency
|
||||
) {
|
||||
throw error;
|
||||
}
|
||||
return run(resolvedDefinition.fallbackStatement, [
|
||||
resolvedDefinition.partialDependency,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { PERMS } from "@/lib/permission-slugs";
|
||||
import type { HousekeepingCapabilityContext } from "../../../foundation/contracts";
|
||||
import { HOTEL_PRIMARY_ROUTE_IDS } from "../routes";
|
||||
import { loadHotelDatabaseQueryWith } from "./hotel-production";
|
||||
import {
|
||||
createHotelQuery,
|
||||
type HotelQueryData,
|
||||
type NormalizedHotelQueryInput,
|
||||
} 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)),
|
||||
};
|
||||
}
|
||||
|
||||
describe("Hotel query adapters", () => {
|
||||
it("fails closed before invoking an unauthorized adapter", async () => {
|
||||
const load = vi.fn();
|
||||
const result = await createHotelQuery({ load }).run(context([]), {
|
||||
routeId: "hotel.rooms",
|
||||
});
|
||||
expect(result).toMatchObject({
|
||||
ok: false,
|
||||
error: { code: "FORBIDDEN" },
|
||||
});
|
||||
expect(load).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("normalizes bounded pagination and preserves BIGINT identifiers", async () => {
|
||||
const load = vi.fn(
|
||||
async (): Promise<HotelQueryData> => ({
|
||||
kind: "radio",
|
||||
items: [
|
||||
{
|
||||
id: "18446744073709551615",
|
||||
title: "Radio history",
|
||||
href: "/ase-next/hotel/radio/history",
|
||||
},
|
||||
],
|
||||
total: 501,
|
||||
partialDependencies: [],
|
||||
}),
|
||||
);
|
||||
const result = await createHotelQuery({ load }).run(
|
||||
context([PERMS.RADIO_VIEW]),
|
||||
{
|
||||
routeId: "hotel.radio.history",
|
||||
list: { search: " show ", pageSize: 999, offset: 999_999 },
|
||||
},
|
||||
);
|
||||
expect(load).toHaveBeenCalledWith({
|
||||
routeId: "hotel.radio.history",
|
||||
params: {},
|
||||
list: { search: "show", pageSize: 100, offset: 100_000 },
|
||||
});
|
||||
expect(result).toMatchObject({
|
||||
ok: true,
|
||||
data: { items: [{ id: "18446744073709551615" }], total: 501 },
|
||||
});
|
||||
});
|
||||
|
||||
it("reports only truthful unavailable radio and RCON dependencies", async () => {
|
||||
const query = createHotelQuery({
|
||||
load: async (input: NormalizedHotelQueryInput) => ({
|
||||
kind: input.routeId.startsWith("hotel.radio.") ? "radio" : "rooms",
|
||||
items: [],
|
||||
total: 0,
|
||||
partialDependencies: input.routeId.startsWith("hotel.radio.")
|
||||
? ["radio"]
|
||||
: ["rcon"],
|
||||
}),
|
||||
});
|
||||
expect(
|
||||
await query.run(context([PERMS.RADIO_VIEW]), {
|
||||
routeId: "hotel.radio.monitoring",
|
||||
}),
|
||||
).toMatchObject({ ok: true, data: { partialDependencies: ["radio"] } });
|
||||
expect(
|
||||
await query.run(context([PERMS.ROOMS_VIEW]), {
|
||||
routeId: "hotel.room-detail",
|
||||
}),
|
||||
).toMatchObject({ ok: true, data: { partialDependencies: ["rcon"] } });
|
||||
});
|
||||
|
||||
it("rejects fabricated dependencies and unsafe canonical hrefs", async () => {
|
||||
const query = createHotelQuery({
|
||||
load: async () => ({
|
||||
kind: "rooms",
|
||||
items: [{ id: "1", title: "Unsafe", href: "/ase-next/hotel/%2e%2e/system" }],
|
||||
total: 1,
|
||||
partialDependencies: ["imaginary"],
|
||||
}),
|
||||
});
|
||||
expect(
|
||||
await query.run(context([PERMS.ROOMS_VIEW]), { routeId: "hotel.rooms" }),
|
||||
).toMatchObject({
|
||||
ok: false,
|
||||
error: { code: "DEPENDENCY_UNAVAILABLE" },
|
||||
});
|
||||
});
|
||||
|
||||
it("has an authorized adapter path for every primary Hotel route", async () => {
|
||||
const load = vi.fn(async (input: NormalizedHotelQueryInput) => ({
|
||||
kind: input.routeId.startsWith("hotel.radio.")
|
||||
? ("radio" as const)
|
||||
: input.routeId === "hotel.navigation"
|
||||
? ("navigation" as const)
|
||||
: ("rooms" as const),
|
||||
items: [],
|
||||
total: 0,
|
||||
partialDependencies: [],
|
||||
}));
|
||||
const query = createHotelQuery({ load });
|
||||
for (const routeId of HOTEL_PRIMARY_ROUTE_IDS) {
|
||||
const result = await query.run(context(Object.values(PERMS)), {
|
||||
routeId,
|
||||
});
|
||||
expect(result.ok, routeId).toBe(true);
|
||||
}
|
||||
expect(load).toHaveBeenCalledTimes(HOTEL_PRIMARY_ROUTE_IDS.length);
|
||||
});
|
||||
|
||||
it("falls back to navigator categories and reports missing public rooms", async () => {
|
||||
const execute = vi
|
||||
.fn()
|
||||
.mockRejectedValueOnce(new Error("navigator_publics unavailable"))
|
||||
.mockResolvedValueOnce([[{ total: 1 }]])
|
||||
.mockResolvedValueOnce([
|
||||
[
|
||||
{
|
||||
id: "flat-1",
|
||||
title: "Public rooms",
|
||||
status: "public",
|
||||
updated_at: null,
|
||||
},
|
||||
],
|
||||
]);
|
||||
const result = await loadHotelDatabaseQueryWith(
|
||||
{
|
||||
routeId: "hotel.navigation",
|
||||
params: {},
|
||||
list: { search: "", pageSize: 25, offset: 0 },
|
||||
},
|
||||
execute,
|
||||
);
|
||||
expect(result).toMatchObject({
|
||||
total: 1,
|
||||
partialDependencies: ["navigator-publics"],
|
||||
items: [{ id: "flat-1", href: "/ase-next/hotel/navigation" }],
|
||||
});
|
||||
});
|
||||
|
||||
it("preserves masked API-key output from production rows", async () => {
|
||||
const execute = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce([[{ total: 1 }]])
|
||||
.mockResolvedValueOnce([
|
||||
[
|
||||
{
|
||||
id: 9n,
|
||||
title: "Bridge",
|
||||
description: "****cafe",
|
||||
status: "active",
|
||||
updated_at: null,
|
||||
},
|
||||
],
|
||||
]);
|
||||
const result = await loadHotelDatabaseQueryWith(
|
||||
{
|
||||
routeId: "hotel.radio.api-keys",
|
||||
params: {},
|
||||
list: { search: "", pageSize: 25, offset: 0 },
|
||||
},
|
||||
execute,
|
||||
);
|
||||
expect(result.items).toEqual([
|
||||
expect.objectContaining({ id: "9", description: "****cafe" }),
|
||||
]);
|
||||
expect(JSON.stringify(result)).not.toContain("secret-api-key");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,42 @@
|
||||
import type { HotelQueryData, NormalizedHotelQueryInput } from "./rooms";
|
||||
|
||||
export async function loadHotelRadioQuery(
|
||||
input: NormalizedHotelQueryInput,
|
||||
): Promise<HotelQueryData> {
|
||||
if (input.routeId !== "hotel.radio.monitoring") {
|
||||
const { loadHotelDatabaseQuery } = await import("./hotel-production");
|
||||
return loadHotelDatabaseQuery(input);
|
||||
}
|
||||
const { fetchListeners, fetchNowPlaying } = await import(
|
||||
"@/lib/services/radio"
|
||||
);
|
||||
const [nowPlaying, listeners] = await Promise.all([
|
||||
fetchNowPlaying(),
|
||||
fetchListeners(),
|
||||
]);
|
||||
const items: Array<HotelQueryData["items"][number]> = [];
|
||||
if (nowPlaying) {
|
||||
items.push({
|
||||
id: "now-playing",
|
||||
title: nowPlaying.title,
|
||||
description: nowPlaying.artist ?? undefined,
|
||||
status: "playing",
|
||||
href: "/ase-next/hotel/radio/monitoring",
|
||||
});
|
||||
}
|
||||
if (listeners !== null) {
|
||||
items.push({
|
||||
id: "listeners",
|
||||
title: `${listeners} listeners`,
|
||||
status: "live",
|
||||
href: "/ase-next/hotel/radio/monitoring",
|
||||
});
|
||||
}
|
||||
return {
|
||||
kind: "radio",
|
||||
items,
|
||||
total: items.length,
|
||||
partialDependencies:
|
||||
nowPlaying === null && listeners === null ? ["radio"] : [],
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
import { authorizeHousekeeping } from "../../../foundation/authorization";
|
||||
import {
|
||||
fail,
|
||||
type HousekeepingQuery,
|
||||
ok,
|
||||
} from "../../../foundation/contracts";
|
||||
import { isSafeHousekeepingHref } from "../../../foundation/housekeeping-href";
|
||||
import {
|
||||
type HotelPrimaryRouteId,
|
||||
type HotelRouteGroup,
|
||||
hotelRouteById,
|
||||
hotelRouteGroup,
|
||||
} from "../routes";
|
||||
|
||||
export interface HotelQueryInput {
|
||||
readonly routeId: HotelPrimaryRouteId;
|
||||
readonly params?: Readonly<Record<string, string>>;
|
||||
readonly list?: Readonly<{
|
||||
search?: string;
|
||||
pageSize?: number;
|
||||
offset?: number;
|
||||
}>;
|
||||
}
|
||||
|
||||
export interface HotelQueryItem {
|
||||
readonly id: string;
|
||||
readonly title: string;
|
||||
readonly status?: string;
|
||||
readonly href?: string;
|
||||
readonly description?: string;
|
||||
readonly updatedAt?: string | null;
|
||||
}
|
||||
|
||||
export interface HotelQueryData {
|
||||
readonly kind: HotelRouteGroup;
|
||||
readonly items: readonly HotelQueryItem[];
|
||||
readonly total: number;
|
||||
readonly partialDependencies: readonly string[];
|
||||
}
|
||||
|
||||
export interface NormalizedHotelQueryInput {
|
||||
readonly routeId: HotelPrimaryRouteId;
|
||||
readonly params: Readonly<Record<string, string>>;
|
||||
readonly list: Readonly<{ search: string; pageSize: number; offset: number }>;
|
||||
}
|
||||
|
||||
export interface HotelQueryAdapters {
|
||||
load(input: NormalizedHotelQueryInput): Promise<HotelQueryData>;
|
||||
}
|
||||
|
||||
const ALLOWED_PARTIAL_DEPENDENCIES = Object.freeze({
|
||||
"hotel.room-detail": new Set(["rcon"]),
|
||||
"hotel.room-furni": new Set(["rcon"]),
|
||||
"hotel.navigation": new Set(["navigator-publics"]),
|
||||
"hotel.radio.monitoring": new Set(["radio"]),
|
||||
} as Partial<Record<HotelPrimaryRouteId, ReadonlySet<string>>>);
|
||||
|
||||
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: HotelQueryInput): NormalizedHotelQueryInput {
|
||||
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: HotelQueryData,
|
||||
input: NormalizedHotelQueryInput,
|
||||
): boolean {
|
||||
if (
|
||||
data.kind !== hotelRouteGroup(input.routeId) ||
|
||||
!Array.isArray(data.items) ||
|
||||
!Number.isSafeInteger(data.total) ||
|
||||
data.total < 0 ||
|
||||
!Array.isArray(data.partialDependencies)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
const allowed = ALLOWED_PARTIAL_DEPENDENCIES[input.routeId] ?? new Set();
|
||||
if (data.partialDependencies.some((dependency) => !allowed.has(dependency))) {
|
||||
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 createHotelQuery(
|
||||
adapters: HotelQueryAdapters,
|
||||
): HousekeepingQuery<HotelQueryInput, HotelQueryData> {
|
||||
return {
|
||||
id: "hotel.query",
|
||||
owner: "hotel",
|
||||
capability: hotelRouteById("hotel.rooms").capability,
|
||||
async run(context, rawInput) {
|
||||
const route = hotelRouteById(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 Hotel query");
|
||||
return ok(data, authorization.correlationId);
|
||||
} catch {
|
||||
return fail(
|
||||
"DEPENDENCY_UNAVAILABLE",
|
||||
"errors.housekeeping.dependencyUnavailable",
|
||||
authorization.correlationId,
|
||||
);
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export async function loadHotelRoomsQuery(
|
||||
input: NormalizedHotelQueryInput,
|
||||
): Promise<HotelQueryData> {
|
||||
const { loadHotelDatabaseQuery } = await import("./hotel-production");
|
||||
return loadHotelDatabaseQuery(input);
|
||||
}
|
||||
|
||||
export const hotelQuery = createHotelQuery({
|
||||
async load(input) {
|
||||
if (input.routeId.startsWith("hotel.radio.")) {
|
||||
const { loadHotelRadioQuery } = await import("./radio");
|
||||
return loadHotelRadioQuery(input);
|
||||
}
|
||||
return loadHotelRoomsQuery(input);
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,125 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { PERMS } from "@/lib/permission-slugs";
|
||||
import type { HousekeepingCapabilityContext } from "../../../foundation/contracts";
|
||||
import {
|
||||
createInMemoryStudioOperationRepository,
|
||||
createStudioOperationQuery,
|
||||
STUDIO_OPERATION_KINDS,
|
||||
} from "./studio";
|
||||
|
||||
function context(granted: readonly string[]): HousekeepingCapabilityContext {
|
||||
const permissions = new Set(granted);
|
||||
return {
|
||||
actor: { id: 42, username: "operator", rank: 7 },
|
||||
isSuperAdmin: false,
|
||||
has: (slug) => permissions.has(slug),
|
||||
hasAny: (...slugs) => slugs.some((slug) => permissions.has(slug)),
|
||||
hasAll: (...slugs) => slugs.every((slug) => permissions.has(slug)),
|
||||
};
|
||||
}
|
||||
|
||||
describe("Studio operation query", () => {
|
||||
it("defines the exact ten operation kinds", () => {
|
||||
expect(STUDIO_OPERATION_KINDS).toEqual([
|
||||
"badge",
|
||||
"clone",
|
||||
"clothing",
|
||||
"effect",
|
||||
"furni",
|
||||
"maintenance",
|
||||
"pet",
|
||||
"repair-icons",
|
||||
"sync",
|
||||
"upload",
|
||||
]);
|
||||
});
|
||||
|
||||
it("fails closed before invoking an unauthorized adapter", async () => {
|
||||
const load = vi.fn();
|
||||
const result = await createStudioOperationQuery({ load }).run(context([]), {
|
||||
routeId: "hotel.studio.furni",
|
||||
});
|
||||
expect(result).toMatchObject({ ok: false, error: { code: "FORBIDDEN" } });
|
||||
expect(load).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("normalizes filters and preserves truthful external-source outages", async () => {
|
||||
const load = vi.fn(async (input) => ({
|
||||
kind: "studio" as const,
|
||||
items: [],
|
||||
total: 0,
|
||||
partialDependencies: ["external-sources"] as const,
|
||||
input,
|
||||
}));
|
||||
const result = await createStudioOperationQuery({ load }).run(
|
||||
context([PERMS.ASSETS_IMPORT]),
|
||||
{
|
||||
routeId: "hotel.studio.clone",
|
||||
list: { search: " sofa ", pageSize: 999, offset: 999_999 },
|
||||
},
|
||||
);
|
||||
expect(load).toHaveBeenCalledWith({
|
||||
routeId: "hotel.studio.clone",
|
||||
operationKind: "clone",
|
||||
list: { search: "sofa", pageSize: 100, offset: 100_000 },
|
||||
});
|
||||
expect(result).toMatchObject({
|
||||
ok: true,
|
||||
data: { partialDependencies: ["external-sources"] },
|
||||
});
|
||||
});
|
||||
|
||||
it("loads the audit route across all operation kinds without inventing a command kind", async () => {
|
||||
const load = vi.fn(async () => ({
|
||||
kind: "studio" as const,
|
||||
items: [],
|
||||
total: 0,
|
||||
partialDependencies: [] as const,
|
||||
}));
|
||||
const result = await createStudioOperationQuery({ load }).run(
|
||||
context([PERMS.ASSETS_IMPORT]),
|
||||
{ routeId: "hotel.studio.audit" },
|
||||
);
|
||||
expect(result.ok).toBe(true);
|
||||
expect(load).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ operationKind: null }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Studio operation repository", () => {
|
||||
it("enforces monotonic progress and terminal event ordering", async () => {
|
||||
const repository = createInMemoryStudioOperationRepository();
|
||||
await repository.persistIntent({
|
||||
operationId: "op-1",
|
||||
kind: "furni",
|
||||
actorId: 42,
|
||||
correlationId: "corr-1",
|
||||
createdAt: "2026-08-30T10:00:00.000Z",
|
||||
});
|
||||
await repository.appendEvent({
|
||||
operationId: "op-1",
|
||||
kind: "furni",
|
||||
phase: "running",
|
||||
completed: 2,
|
||||
total: 4,
|
||||
messageKey: "pages.housekeeping.studio.running",
|
||||
correlationId: "corr-1",
|
||||
});
|
||||
await expect(
|
||||
repository.appendEvent({
|
||||
operationId: "op-1",
|
||||
kind: "furni",
|
||||
phase: "running",
|
||||
completed: 1,
|
||||
total: 4,
|
||||
messageKey: "pages.housekeeping.studio.running",
|
||||
correlationId: "corr-1",
|
||||
}),
|
||||
).rejects.toThrow("non-monotonic Studio progress");
|
||||
expect(await repository.get("op-1")).toMatchObject({
|
||||
operationId: "op-1",
|
||||
completed: 2,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,348 @@
|
||||
import { authorizeHousekeeping } from "../../../foundation/authorization";
|
||||
import {
|
||||
fail,
|
||||
type HousekeepingQuery,
|
||||
ok,
|
||||
} from "../../../foundation/contracts";
|
||||
import {
|
||||
type HotelStudioRouteId,
|
||||
hotelRouteById,
|
||||
hotelStudioOperationKind,
|
||||
} from "../routes";
|
||||
|
||||
export const STUDIO_OPERATION_KINDS = [
|
||||
"badge",
|
||||
"clone",
|
||||
"clothing",
|
||||
"effect",
|
||||
"furni",
|
||||
"maintenance",
|
||||
"pet",
|
||||
"repair-icons",
|
||||
"sync",
|
||||
"upload",
|
||||
] as const;
|
||||
export type StudioOperationKind = (typeof STUDIO_OPERATION_KINDS)[number];
|
||||
export type StudioOperationPhase =
|
||||
| "queued"
|
||||
| "running"
|
||||
| "completed"
|
||||
| "failed"
|
||||
| "partial";
|
||||
|
||||
export interface StudioOperationEvent {
|
||||
readonly operationId: string;
|
||||
readonly kind: StudioOperationKind;
|
||||
readonly phase: StudioOperationPhase;
|
||||
readonly completed: number;
|
||||
readonly total: number | null;
|
||||
readonly messageKey: string;
|
||||
readonly correlationId: string;
|
||||
}
|
||||
|
||||
export interface StudioOperationIntent {
|
||||
readonly operationId: string;
|
||||
readonly kind: StudioOperationKind;
|
||||
readonly actorId: number;
|
||||
readonly correlationId: string;
|
||||
readonly createdAt: string;
|
||||
}
|
||||
|
||||
export interface StudioOperationRecord extends StudioOperationEvent {
|
||||
readonly actorId: number;
|
||||
readonly createdAt: string;
|
||||
readonly updatedAt: string;
|
||||
readonly events?: readonly StudioOperationEvent[];
|
||||
readonly output?: Readonly<Record<string, unknown>>;
|
||||
}
|
||||
|
||||
export interface StudioOperationListInput {
|
||||
readonly kind?: StudioOperationKind;
|
||||
readonly search?: string;
|
||||
readonly pageSize: number;
|
||||
readonly offset: number;
|
||||
}
|
||||
|
||||
export interface StudioOperationListResult {
|
||||
readonly items: readonly StudioOperationRecord[];
|
||||
readonly total: number;
|
||||
}
|
||||
|
||||
export interface StudioOperationRepository {
|
||||
persistIntent(intent: StudioOperationIntent): Promise<void>;
|
||||
appendEvent(
|
||||
event: StudioOperationEvent,
|
||||
output?: Readonly<Record<string, unknown>>,
|
||||
): Promise<void>;
|
||||
get(operationId: string): Promise<StudioOperationRecord | null>;
|
||||
list(input: StudioOperationListInput): Promise<StudioOperationListResult>;
|
||||
}
|
||||
|
||||
const TERMINAL_PHASES = new Set<StudioOperationPhase>([
|
||||
"completed",
|
||||
"failed",
|
||||
"partial",
|
||||
]);
|
||||
|
||||
export function createInMemoryStudioOperationRepository(
|
||||
now: () => string = () => new Date().toISOString(),
|
||||
): StudioOperationRepository {
|
||||
const records = new Map<string, StudioOperationRecord>();
|
||||
|
||||
return {
|
||||
async persistIntent(intent) {
|
||||
if (records.has(intent.operationId)) {
|
||||
throw new Error(`duplicate Studio operation: ${intent.operationId}`);
|
||||
}
|
||||
const queued: StudioOperationEvent = {
|
||||
operationId: intent.operationId,
|
||||
kind: intent.kind,
|
||||
phase: "queued",
|
||||
completed: 0,
|
||||
total: null,
|
||||
messageKey: "pages.housekeeping.studio.queued",
|
||||
correlationId: intent.correlationId,
|
||||
};
|
||||
records.set(intent.operationId, {
|
||||
...queued,
|
||||
actorId: intent.actorId,
|
||||
createdAt: intent.createdAt,
|
||||
updatedAt: intent.createdAt,
|
||||
events: [queued],
|
||||
});
|
||||
},
|
||||
async appendEvent(event, output) {
|
||||
const previous = records.get(event.operationId);
|
||||
if (!previous)
|
||||
throw new Error(`unknown Studio operation: ${event.operationId}`);
|
||||
if (
|
||||
previous.kind !== event.kind ||
|
||||
previous.correlationId !== event.correlationId
|
||||
) {
|
||||
throw new Error("Studio operation identity mismatch");
|
||||
}
|
||||
if (TERMINAL_PHASES.has(previous.phase)) {
|
||||
throw new Error("Studio operation already terminal");
|
||||
}
|
||||
if (
|
||||
!Number.isSafeInteger(event.completed) ||
|
||||
event.completed < previous.completed
|
||||
) {
|
||||
throw new Error("non-monotonic Studio progress");
|
||||
}
|
||||
if (
|
||||
event.total !== null &&
|
||||
(!Number.isSafeInteger(event.total) ||
|
||||
event.total < event.completed ||
|
||||
(previous.total !== null && event.total !== previous.total))
|
||||
) {
|
||||
throw new Error("invalid Studio operation total");
|
||||
}
|
||||
const events = [...(previous.events ?? []), event];
|
||||
records.set(event.operationId, {
|
||||
...previous,
|
||||
...event,
|
||||
updatedAt: now(),
|
||||
events,
|
||||
...(output ? { output: Object.freeze({ ...output }) } : {}),
|
||||
});
|
||||
},
|
||||
async get(operationId) {
|
||||
return records.get(operationId) ?? null;
|
||||
},
|
||||
async list(input) {
|
||||
const term = String(input.search ?? "")
|
||||
.normalize("NFC")
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
const matches = [...records.values()]
|
||||
.filter((record) => !input.kind || record.kind === input.kind)
|
||||
.filter(
|
||||
(record) =>
|
||||
!term ||
|
||||
record.operationId.toLowerCase().includes(term) ||
|
||||
record.correlationId.toLowerCase().includes(term) ||
|
||||
record.kind.includes(term),
|
||||
)
|
||||
.sort((left, right) => right.updatedAt.localeCompare(left.updatedAt));
|
||||
return {
|
||||
items: matches.slice(input.offset, input.offset + input.pageSize),
|
||||
total: matches.length,
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const globalStudio = globalThis as unknown as {
|
||||
housekeepingStudioOperations?: StudioOperationRepository;
|
||||
};
|
||||
const memoryStudioOperations =
|
||||
globalStudio.housekeepingStudioOperations ??
|
||||
createInMemoryStudioOperationRepository();
|
||||
if (process.env.NODE_ENV !== "production") {
|
||||
globalStudio.housekeepingStudioOperations = memoryStudioOperations;
|
||||
}
|
||||
|
||||
function auditOutcome(phase: StudioOperationPhase) {
|
||||
if (phase === "completed") return "success" as const;
|
||||
if (phase === "partial") return "partial" as const;
|
||||
if (phase === "failed") return "failure" as const;
|
||||
return "intent" as const;
|
||||
}
|
||||
|
||||
export const studioOperationRepository: StudioOperationRepository = {
|
||||
async persistIntent(intent) {
|
||||
const { logAudit } = await import("@/lib/services/audit");
|
||||
await logAudit({
|
||||
userId: intent.actorId,
|
||||
action: "hotel_studio_operation_queued",
|
||||
target: "StudioOperation",
|
||||
after: { ...intent, phase: "queued", completed: 0, total: null },
|
||||
correlationId: intent.correlationId,
|
||||
outcome: "intent",
|
||||
domain: "hotel",
|
||||
});
|
||||
await memoryStudioOperations.persistIntent(intent);
|
||||
},
|
||||
async appendEvent(event, output) {
|
||||
const actorId = (await memoryStudioOperations.get(event.operationId))
|
||||
?.actorId;
|
||||
if (!actorId) throw new Error(`unknown Studio actor: ${event.operationId}`);
|
||||
const { logAudit } = await import("@/lib/services/audit");
|
||||
await logAudit({
|
||||
userId: actorId,
|
||||
action: `hotel_studio_operation_${event.phase}`,
|
||||
target: "StudioOperation",
|
||||
after: { ...event, ...(output ? { output } : {}) },
|
||||
correlationId: event.correlationId,
|
||||
outcome: auditOutcome(event.phase),
|
||||
domain: "hotel",
|
||||
});
|
||||
await memoryStudioOperations.appendEvent(event, output);
|
||||
},
|
||||
get: (operationId) => memoryStudioOperations.get(operationId),
|
||||
list: (input) => memoryStudioOperations.list(input),
|
||||
};
|
||||
|
||||
export interface StudioQueryInput {
|
||||
readonly routeId: HotelStudioRouteId;
|
||||
readonly list?: Readonly<{
|
||||
search?: string;
|
||||
pageSize?: number;
|
||||
offset?: number;
|
||||
}>;
|
||||
}
|
||||
|
||||
export interface NormalizedStudioQueryInput {
|
||||
readonly routeId: HotelStudioRouteId;
|
||||
readonly operationKind: StudioOperationKind | null;
|
||||
readonly list: Readonly<{ search: string; pageSize: number; offset: number }>;
|
||||
}
|
||||
|
||||
export interface StudioQueryData {
|
||||
readonly kind: "studio";
|
||||
readonly items: readonly StudioOperationRecord[];
|
||||
readonly total: number;
|
||||
readonly partialDependencies: readonly string[];
|
||||
}
|
||||
|
||||
export interface StudioQueryAdapters {
|
||||
load(input: NormalizedStudioQueryInput): Promise<StudioQueryData>;
|
||||
}
|
||||
|
||||
function boundedInteger(
|
||||
value: unknown,
|
||||
fallback: number,
|
||||
min: number,
|
||||
max: number,
|
||||
) {
|
||||
const parsed = Number(value);
|
||||
if (!Number.isSafeInteger(parsed)) return fallback;
|
||||
return Math.min(max, Math.max(min, parsed));
|
||||
}
|
||||
|
||||
function normalizeInput(input: StudioQueryInput): NormalizedStudioQueryInput {
|
||||
return {
|
||||
routeId: input.routeId,
|
||||
operationKind: hotelStudioOperationKind(input.routeId),
|
||||
list: {
|
||||
search: String(input.list?.search ?? "")
|
||||
.normalize("NFC")
|
||||
.trim()
|
||||
.slice(0, 128),
|
||||
pageSize: boundedInteger(input.list?.pageSize, 25, 1, 100),
|
||||
offset: boundedInteger(input.list?.offset, 0, 0, 100_000),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const STUDIO_DEPENDENCIES = new Set([
|
||||
"database",
|
||||
"external-sources",
|
||||
"filesystem",
|
||||
"rcon",
|
||||
]);
|
||||
|
||||
function validData(data: StudioQueryData): boolean {
|
||||
return (
|
||||
data.kind === "studio" &&
|
||||
Array.isArray(data.items) &&
|
||||
Number.isSafeInteger(data.total) &&
|
||||
data.total >= 0 &&
|
||||
data.partialDependencies.every((item) => STUDIO_DEPENDENCIES.has(item))
|
||||
);
|
||||
}
|
||||
|
||||
export function createStudioOperationQuery(
|
||||
adapters: StudioQueryAdapters,
|
||||
): HousekeepingQuery<StudioQueryInput, StudioQueryData> {
|
||||
return {
|
||||
id: "hotel.studio.query",
|
||||
owner: "hotel",
|
||||
capability: hotelRouteById("hotel.studio.furni").capability,
|
||||
async run(context, rawInput) {
|
||||
const authorization = authorizeHousekeeping(
|
||||
context,
|
||||
hotelRouteById(rawInput.routeId).capability,
|
||||
);
|
||||
if (!authorization.ok) return authorization;
|
||||
try {
|
||||
const data = await adapters.load(normalizeInput(rawInput));
|
||||
if (!validData(data)) throw new Error("invalid Studio query data");
|
||||
return ok(data, authorization.correlationId);
|
||||
} catch {
|
||||
return fail(
|
||||
"DEPENDENCY_UNAVAILABLE",
|
||||
"errors.housekeeping.dependencyUnavailable",
|
||||
authorization.correlationId,
|
||||
);
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export const studioOperationQuery = createStudioOperationQuery({
|
||||
async load(input) {
|
||||
const result = await studioOperationRepository.list({
|
||||
kind: input.operationKind ?? undefined,
|
||||
...input.list,
|
||||
});
|
||||
const partialDependencies: string[] = [];
|
||||
if (input.operationKind === "clone" || input.operationKind === "sync") {
|
||||
try {
|
||||
const { listSources } = await import("@/lib/services/clone-sources");
|
||||
if ((await listSources()).length === 0) {
|
||||
partialDependencies.push("external-sources");
|
||||
}
|
||||
} catch {
|
||||
partialDependencies.push("external-sources");
|
||||
}
|
||||
}
|
||||
return {
|
||||
kind: "studio",
|
||||
items: result.items,
|
||||
total: result.total,
|
||||
partialDependencies,
|
||||
};
|
||||
},
|
||||
});
|
||||
Reference in new issue
Block a user