feat(housekeeping): deliver hotel operations
This commit is contained in:
1 parent
bfc5bd78a1
commit
abc707cfb2
43 files changed
+3749
-615
No files matched your search
@@ -1,136 +1,40 @@
|
||||
"use server";
|
||||
|
||||
import { randomBytes } from "node:crypto";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { redirect } from "next/navigation";
|
||||
import { executeLegacyHotelMutation } from "@/features/housekeeping/domains/hotel/services/mutations";
|
||||
import { requirePermission } from "@/lib/admin/guard";
|
||||
import { db, RadioApiKeys } from "@/lib/db";
|
||||
import { PERMS } from "@/lib/permissions";
|
||||
import { logStaffActivity } from "@/lib/services/staff-activity";
|
||||
|
||||
// Radio API keys (radio_api_keys). External integrations (AzureCast bridges,
|
||||
// widgets, bots) authenticate with a server-generated key. The key itself is
|
||||
// minted here with crypto.randomBytes — never accepted from the form — and the
|
||||
// `permissions` JSON column is intentionally left untouched by this CMS slice.
|
||||
|
||||
function str(raw: FormDataEntryValue | null): string {
|
||||
return typeof raw === "string" ? raw : "";
|
||||
}
|
||||
|
||||
/** Parse a BigInt id from a form value, or null when blank/invalid. */
|
||||
function parseId(raw: FormDataEntryValue | null): bigint | null {
|
||||
const s = str(raw).trim();
|
||||
if (!s) return null;
|
||||
try {
|
||||
return BigInt(s);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Clamp a form value to a non-negative integer (defaulting to `fallback`). */
|
||||
function intOr(raw: FormDataEntryValue | null, fallback: number): number {
|
||||
const n = Number(str(raw).trim());
|
||||
if (!Number.isFinite(n) || n < 0) return fallback;
|
||||
return Math.floor(n);
|
||||
function text(formData: FormData, key: string): string {
|
||||
return String(formData.get(key) ?? "")
|
||||
.normalize("NFC")
|
||||
.trim();
|
||||
}
|
||||
|
||||
export async function createApiKey(formData: FormData): Promise<void> {
|
||||
const staff = await requirePermission(PERMS.RADIO_EDIT);
|
||||
|
||||
const name = str(formData.get("name")).trim().slice(0, 255);
|
||||
if (!name) return;
|
||||
|
||||
const rateLimit = intOr(formData.get("rateLimit"), 300);
|
||||
const allowedIps =
|
||||
str(formData.get("allowedIps")).trim().slice(0, 255) || null;
|
||||
|
||||
// Server-side key generation — 24 random bytes → 48 hex chars (fits VarChar(64)).
|
||||
const key = randomBytes(24).toString("hex");
|
||||
|
||||
const now = new Date();
|
||||
try {
|
||||
const [result] = await db.insert(RadioApiKeys).values({
|
||||
name,
|
||||
key,
|
||||
allowedIps,
|
||||
rateLimit,
|
||||
isActive: true,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
});
|
||||
const createdId = BigInt(result.insertId);
|
||||
await logStaffActivity({
|
||||
staffId: staff.id,
|
||||
action: "radio_api_key_create",
|
||||
description: `Created radio API key "${name}" (#${createdId}, rate limit ${rateLimit})`,
|
||||
targetType: "radio_api_key",
|
||||
targetId: Number(createdId),
|
||||
});
|
||||
} catch {
|
||||
// Unique-key collision (astronomically unlikely) or DB down — fail soft.
|
||||
return;
|
||||
}
|
||||
|
||||
await executeLegacyHotelMutation(staff, "radio.api-key.create", {
|
||||
name: text(formData, "name"),
|
||||
allowedIps: text(formData, "allowedIps") || undefined,
|
||||
rateLimit: Number(text(formData, "rateLimit") || 300),
|
||||
});
|
||||
revalidatePath("/admin/radio/api-keys");
|
||||
redirect("/admin/radio/api-keys?created=1");
|
||||
}
|
||||
|
||||
export async function toggleApiKey(formData: FormData): Promise<void> {
|
||||
const staff = await requirePermission(PERMS.RADIO_EDIT);
|
||||
|
||||
const id = parseId(formData.get("id"));
|
||||
if (id == null) return;
|
||||
|
||||
try {
|
||||
const [existing] = await db
|
||||
.select({
|
||||
name: RadioApiKeys.name,
|
||||
isActive: RadioApiKeys.isActive,
|
||||
})
|
||||
.from(RadioApiKeys)
|
||||
.where(eq(RadioApiKeys.id, id))
|
||||
.limit(1);
|
||||
if (!existing) return;
|
||||
|
||||
const next = !existing.isActive;
|
||||
await db
|
||||
.update(RadioApiKeys)
|
||||
.set({ isActive: next, updatedAt: new Date() })
|
||||
.where(eq(RadioApiKeys.id, id));
|
||||
await logStaffActivity({
|
||||
staffId: staff.id,
|
||||
action: "radio_api_key_toggle",
|
||||
description: `${next ? "Activated" : "Deactivated"} radio API key "${existing.name}" (#${id})`,
|
||||
targetType: "radio_api_key",
|
||||
targetId: Number(id),
|
||||
});
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
|
||||
await executeLegacyHotelMutation(staff, "radio.api-key.toggle", {
|
||||
id: text(formData, "id"),
|
||||
});
|
||||
revalidatePath("/admin/radio/api-keys");
|
||||
}
|
||||
|
||||
export async function deleteApiKey(formData: FormData): Promise<void> {
|
||||
const staff = await requirePermission(PERMS.RADIO_EDIT);
|
||||
|
||||
const id = parseId(formData.get("id"));
|
||||
if (id == null) return;
|
||||
|
||||
try {
|
||||
await db.delete(RadioApiKeys).where(eq(RadioApiKeys.id, id));
|
||||
await logStaffActivity({
|
||||
staffId: staff.id,
|
||||
action: "radio_api_key_delete",
|
||||
description: `Deleted radio API key #${id}`,
|
||||
targetType: "radio_api_key",
|
||||
targetId: Number(id),
|
||||
});
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
|
||||
await executeLegacyHotelMutation(staff, "radio.api-key.delete", {
|
||||
id: text(formData, "id"),
|
||||
});
|
||||
revalidatePath("/admin/radio/api-keys");
|
||||
}
|
||||
@@ -1,138 +1,48 @@
|
||||
"use server";
|
||||
|
||||
import { eq } from "drizzle-orm";
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { executeLegacyHotelMutation } from "@/features/housekeeping/domains/hotel/services/mutations";
|
||||
import { requirePermission } from "@/lib/admin/guard";
|
||||
import { db, RadioAutoDjPlaylist } from "@/lib/db";
|
||||
import { PERMS } from "@/lib/permissions";
|
||||
import { logStaffActivity } from "@/lib/services/staff-activity";
|
||||
|
||||
// AutoDJ playlist CRUD (radio_auto_dj_playlist). CMS-owned table backing the
|
||||
// fallback playlist the radio rotates through when no live DJ is streaming.
|
||||
// Faithful to AtomCMS: a flat list of tracks ordered by sort_order then title.
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────────────────
|
||||
|
||||
/** Parse a FormData field into a positive BigInt id, or null when invalid. */
|
||||
function parseId(raw: FormDataEntryValue | null): bigint | null {
|
||||
if (typeof raw !== "string" || raw.trim() === "") return null;
|
||||
try {
|
||||
const id = BigInt(raw.trim());
|
||||
return id > 0n ? id : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
function text(formData: FormData, key: string): string {
|
||||
return String(formData.get(key) ?? "")
|
||||
.normalize("NFC")
|
||||
.trim();
|
||||
}
|
||||
|
||||
function str(raw: FormDataEntryValue | null): string {
|
||||
return typeof raw === "string" ? raw : "";
|
||||
function enabled(formData: FormData, key: string): boolean {
|
||||
return ["1", "true", "on"].includes(text(formData, key).toLowerCase());
|
||||
}
|
||||
|
||||
/** Checkbox/select truthiness: '1', 'true', 'on' → true. */
|
||||
function bool(raw: FormDataEntryValue | null): boolean {
|
||||
const v = str(raw).trim().toLowerCase();
|
||||
return v === "1" || v === "true" || v === "on";
|
||||
}
|
||||
|
||||
/** Parse a non-negative UnsignedInt, falling back to 0. */
|
||||
function reqUInt(raw: FormDataEntryValue | null): number {
|
||||
const n = Number(str(raw).trim());
|
||||
if (!Number.isFinite(n) || n < 0) return 0;
|
||||
return Math.trunc(n);
|
||||
}
|
||||
|
||||
/** Parse an optional non-negative UnsignedInt; blank/invalid/negative → null. */
|
||||
function optUInt(raw: FormDataEntryValue | null): number | null {
|
||||
const s = str(raw).trim();
|
||||
if (s === "") return null;
|
||||
const n = Number(s);
|
||||
if (!Number.isFinite(n) || n < 0) return null;
|
||||
return Math.trunc(n);
|
||||
}
|
||||
|
||||
// ── AutoDJ playlist CRUD (radio_auto_dj_playlist) ────────────────────────
|
||||
|
||||
export async function createTrack(formData: FormData): Promise<void> {
|
||||
const staff = await requirePermission(PERMS.RADIO_EDIT);
|
||||
const title = str(formData.get("title")).trim().slice(0, 255);
|
||||
if (!title) return;
|
||||
|
||||
const artist = str(formData.get("artist")).trim().slice(0, 255);
|
||||
const album = str(formData.get("album")).trim().slice(0, 255);
|
||||
const artworkUrl = str(formData.get("artworkUrl")).trim().slice(0, 255);
|
||||
const duration = optUInt(formData.get("duration"));
|
||||
const sortOrder = reqUInt(formData.get("sortOrder"));
|
||||
const isActive = bool(formData.get("isActive"));
|
||||
const now = new Date();
|
||||
|
||||
try {
|
||||
const [result] = await db.insert(RadioAutoDjPlaylist).values({
|
||||
title,
|
||||
artist: artist || null,
|
||||
album: album || null,
|
||||
artworkUrl: artworkUrl || null,
|
||||
duration,
|
||||
sortOrder,
|
||||
isActive,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
});
|
||||
const createdId = Number(result.insertId);
|
||||
await logStaffActivity({
|
||||
staffId: staff.id,
|
||||
action: "radio_autodj_create",
|
||||
description: `Created AutoDJ track "${title}"${artist ? ` by ${artist}` : ""}`,
|
||||
targetType: "radio_auto_dj_track",
|
||||
targetId: createdId,
|
||||
});
|
||||
} catch {
|
||||
// Fail soft — DB unavailable; re-render without throwing.
|
||||
}
|
||||
const duration = text(formData, "duration");
|
||||
await executeLegacyHotelMutation(staff, "radio.autodj.create", {
|
||||
title: text(formData, "title"),
|
||||
artist: text(formData, "artist") || undefined,
|
||||
album: text(formData, "album") || undefined,
|
||||
artworkUrl: text(formData, "artworkUrl") || undefined,
|
||||
duration: duration ? Number(duration) : null,
|
||||
sortOrder: Number(text(formData, "sortOrder") || 0),
|
||||
isActive: enabled(formData, "isActive"),
|
||||
});
|
||||
revalidatePath("/admin/radio/autodj");
|
||||
}
|
||||
|
||||
export async function toggleTrack(formData: FormData): Promise<void> {
|
||||
const staff = await requirePermission(PERMS.RADIO_EDIT);
|
||||
const id = parseId(formData.get("id"));
|
||||
if (id === null) return;
|
||||
|
||||
// The form posts the desired next state so the toggle is idempotent.
|
||||
const isActive = bool(formData.get("isActive"));
|
||||
|
||||
try {
|
||||
await db
|
||||
.update(RadioAutoDjPlaylist)
|
||||
.set({ isActive, updatedAt: new Date() })
|
||||
.where(eq(RadioAutoDjPlaylist.id, id));
|
||||
await logStaffActivity({
|
||||
staffId: staff.id,
|
||||
action: "radio_autodj_toggle",
|
||||
description: `${isActive ? "Activated" : "Deactivated"} AutoDJ track #${id}`,
|
||||
targetType: "radio_auto_dj_track",
|
||||
targetId: Number(id),
|
||||
});
|
||||
} catch {
|
||||
// Row may be gone; ignore.
|
||||
}
|
||||
await executeLegacyHotelMutation(staff, "radio.autodj.toggle", {
|
||||
id: text(formData, "id"),
|
||||
isActive: enabled(formData, "isActive"),
|
||||
});
|
||||
revalidatePath("/admin/radio/autodj");
|
||||
}
|
||||
|
||||
export async function deleteTrack(formData: FormData): Promise<void> {
|
||||
const staff = await requirePermission(PERMS.RADIO_EDIT);
|
||||
const id = parseId(formData.get("id"));
|
||||
if (id === null) return;
|
||||
|
||||
try {
|
||||
await db.delete(RadioAutoDjPlaylist).where(eq(RadioAutoDjPlaylist.id, id));
|
||||
await logStaffActivity({
|
||||
staffId: staff.id,
|
||||
action: "radio_autodj_delete",
|
||||
description: `Deleted AutoDJ track #${id}`,
|
||||
targetType: "radio_auto_dj_track",
|
||||
targetId: Number(id),
|
||||
});
|
||||
} catch {
|
||||
// Already deleted; ignore.
|
||||
}
|
||||
await executeLegacyHotelMutation(staff, "radio.autodj.delete", {
|
||||
id: text(formData, "id"),
|
||||
});
|
||||
revalidatePath("/admin/radio/autodj");
|
||||
}
|
||||
@@ -1,234 +1,119 @@
|
||||
"use server";
|
||||
|
||||
import { eq } from "drizzle-orm";
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { executeLegacyHotelMutation } from "@/features/housekeeping/domains/hotel/services/mutations";
|
||||
import { requirePermission } from "@/lib/admin/guard";
|
||||
import { db, RadioBanners, RadioRanks, WebsiteSetting } from "@/lib/db";
|
||||
import { logger } from "@/lib/logger";
|
||||
import { PERMS } from "@/lib/permissions";
|
||||
import { siteSettings } from "@/lib/services/site-settings";
|
||||
|
||||
// ── Helpers ────────────────────────────────────────────────────────────────
|
||||
|
||||
/** Parse a FormData field into a positive BigInt id, or null when invalid. */
|
||||
function parseId(raw: FormDataEntryValue | null): bigint | null {
|
||||
if (typeof raw !== "string" || raw.trim() === "") return null;
|
||||
try {
|
||||
const id = BigInt(raw.trim());
|
||||
return id > 0n ? id : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
function text(formData: FormData, key: string): string {
|
||||
return String(formData.get(key) ?? "")
|
||||
.normalize("NFC")
|
||||
.trim();
|
||||
}
|
||||
|
||||
function str(raw: FormDataEntryValue | null): string {
|
||||
return typeof raw === "string" ? raw : "";
|
||||
function enabled(formData: FormData, key: string): boolean {
|
||||
return ["1", "true", "on"].includes(text(formData, key).toLowerCase());
|
||||
}
|
||||
|
||||
/** Checkbox/select truthiness: '1', 'true', 'on' → true. */
|
||||
function bool(raw: FormDataEntryValue | null): boolean {
|
||||
const v = str(raw).trim().toLowerCase();
|
||||
return v === "1" || v === "true" || v === "on";
|
||||
async function actor() {
|
||||
return requirePermission(PERMS.RADIO_EDIT);
|
||||
}
|
||||
|
||||
// ── Radio settings (website_settings radio_* keys) ─────────────────────────
|
||||
|
||||
/**
|
||||
* Upsert one radio_* website_settings key. Mirrors AtomCMS's
|
||||
* RadioSettings Filament page (key/value rows in website_settings). Busts the
|
||||
* siteSettings cache so the public radio pages pick the change up immediately.
|
||||
*/
|
||||
export async function saveRadioSetting(formData: FormData): Promise<void> {
|
||||
await requirePermission(PERMS.RADIO_EDIT);
|
||||
const key = str(formData.get("key")).trim().slice(0, 255);
|
||||
const value = str(formData.get("value"));
|
||||
const comment = str(formData.get("comment")).trim().slice(0, 255);
|
||||
if (!key) return;
|
||||
|
||||
try {
|
||||
await db
|
||||
.insert(WebsiteSetting)
|
||||
.values({ key, value, comment: comment || null })
|
||||
.onDuplicateKeyUpdate({ set: { value } });
|
||||
siteSettings.reload();
|
||||
} catch (err) {
|
||||
logger.error("Failed to save radio setting", { err, key });
|
||||
}
|
||||
const staff = await actor();
|
||||
await executeLegacyHotelMutation(staff, "radio.settings.save-one", {
|
||||
key: text(formData, "key"),
|
||||
value: String(formData.get("value") ?? ""),
|
||||
comment: text(formData, "comment") || undefined,
|
||||
});
|
||||
siteSettings.reload();
|
||||
revalidatePath("/admin/radio/settings");
|
||||
}
|
||||
|
||||
/**
|
||||
* Bulk-save every radio_* field submitted by the settings form in one pass.
|
||||
* The form posts a hidden `__keys` field listing the keys it rendered so we
|
||||
* only touch those (and never wipe unrelated settings).
|
||||
*/
|
||||
export async function saveRadioSettings(formData: FormData): Promise<void> {
|
||||
await requirePermission(PERMS.RADIO_EDIT);
|
||||
const keysRaw = str(formData.get("__keys"));
|
||||
const keys = keysRaw
|
||||
const staff = await actor();
|
||||
const entries = text(formData, "__keys")
|
||||
.split(",")
|
||||
.map((k) => k.trim())
|
||||
.filter((k) => k.startsWith("radio_") || k.startsWith("auto_dj_"));
|
||||
if (keys.length === 0) return;
|
||||
|
||||
try {
|
||||
await Promise.all(
|
||||
keys.map((key) => {
|
||||
const value = str(formData.get(key));
|
||||
return db
|
||||
.insert(WebsiteSetting)
|
||||
.values({ key, value, comment: null })
|
||||
.onDuplicateKeyUpdate({ set: { value } });
|
||||
}),
|
||||
);
|
||||
siteSettings.reload();
|
||||
} catch (err) {
|
||||
logger.error("Failed to bulk-save radio settings", { err, keys });
|
||||
}
|
||||
.map((key) => key.trim())
|
||||
.filter(Boolean)
|
||||
.map((key) => ({ key, value: String(formData.get(key) ?? "") }));
|
||||
await executeLegacyHotelMutation(staff, "radio.settings.save-many", {
|
||||
entries,
|
||||
});
|
||||
siteSettings.reload();
|
||||
revalidatePath("/admin/radio/settings");
|
||||
}
|
||||
|
||||
// ── Radio banners CRUD (radio_banners) ─────────────────────────────────────
|
||||
function bannerInput(formData: FormData) {
|
||||
return {
|
||||
imagePath: text(formData, "imagePath"),
|
||||
title: text(formData, "title") || undefined,
|
||||
description: text(formData, "description") || undefined,
|
||||
sortOrder: Number(text(formData, "sortOrder") || 0),
|
||||
isActive: enabled(formData, "isActive"),
|
||||
};
|
||||
}
|
||||
|
||||
export async function createRadioBanner(formData: FormData): Promise<void> {
|
||||
const staff = await requirePermission(PERMS.RADIO_EDIT);
|
||||
const imagePath = str(formData.get("imagePath")).trim().slice(0, 255);
|
||||
if (!imagePath) return;
|
||||
|
||||
const title = str(formData.get("title")).trim().slice(0, 255);
|
||||
const description = str(formData.get("description")).trim();
|
||||
const sortOrderNum = Number(str(formData.get("sortOrder")));
|
||||
const sortOrder = Number.isFinite(sortOrderNum)
|
||||
? Math.trunc(sortOrderNum)
|
||||
: 0;
|
||||
const isActive = bool(formData.get("isActive"));
|
||||
const now = new Date();
|
||||
|
||||
try {
|
||||
await db.insert(RadioBanners).values({
|
||||
userId: BigInt(staff.id),
|
||||
imagePath,
|
||||
title: title || null,
|
||||
description: description || null,
|
||||
sortOrder,
|
||||
isActive,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
});
|
||||
} catch (err) {
|
||||
logger.error("Failed to create radio banner", { err, imagePath });
|
||||
}
|
||||
const staff = await actor();
|
||||
await executeLegacyHotelMutation(
|
||||
staff,
|
||||
"radio.banner.create",
|
||||
bannerInput(formData),
|
||||
);
|
||||
revalidatePath("/admin/radio/banners");
|
||||
}
|
||||
|
||||
export async function updateRadioBanner(formData: FormData): Promise<void> {
|
||||
await requirePermission(PERMS.RADIO_EDIT);
|
||||
const id = parseId(formData.get("id"));
|
||||
if (id === null) return;
|
||||
|
||||
const imagePath = str(formData.get("imagePath")).trim().slice(0, 255);
|
||||
const title = str(formData.get("title")).trim().slice(0, 255);
|
||||
const description = str(formData.get("description")).trim();
|
||||
const sortOrderNum = Number(str(formData.get("sortOrder")));
|
||||
const sortOrder = Number.isFinite(sortOrderNum)
|
||||
? Math.trunc(sortOrderNum)
|
||||
: 0;
|
||||
const isActive = bool(formData.get("isActive"));
|
||||
if (!imagePath) return;
|
||||
|
||||
try {
|
||||
await db
|
||||
.update(RadioBanners)
|
||||
.set({
|
||||
imagePath,
|
||||
title: title || null,
|
||||
description: description || null,
|
||||
sortOrder,
|
||||
isActive,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(RadioBanners.id, id));
|
||||
} catch (err) {
|
||||
logger.error("Failed to update radio banner", { err, id: String(id) });
|
||||
}
|
||||
const staff = await actor();
|
||||
await executeLegacyHotelMutation(staff, "radio.banner.update", {
|
||||
id: text(formData, "id"),
|
||||
...bannerInput(formData),
|
||||
});
|
||||
revalidatePath("/admin/radio/banners");
|
||||
}
|
||||
|
||||
export async function deleteRadioBanner(formData: FormData): Promise<void> {
|
||||
await requirePermission(PERMS.RADIO_EDIT);
|
||||
const id = parseId(formData.get("id"));
|
||||
if (id === null) return;
|
||||
try {
|
||||
await db.delete(RadioBanners).where(eq(RadioBanners.id, id));
|
||||
} catch (err) {
|
||||
logger.error("Failed to delete radio banner", { err, id: String(id) });
|
||||
}
|
||||
const staff = await actor();
|
||||
await executeLegacyHotelMutation(staff, "radio.banner.delete", {
|
||||
id: text(formData, "id"),
|
||||
});
|
||||
revalidatePath("/admin/radio/banners");
|
||||
}
|
||||
|
||||
// ── Radio ranks CRUD (radio_ranks) ─────────────────────────────────────────
|
||||
function rankInput(formData: FormData) {
|
||||
return {
|
||||
name: text(formData, "name"),
|
||||
description: text(formData, "description") || undefined,
|
||||
badgeCode: text(formData, "badgeCode") || undefined,
|
||||
isActive: enabled(formData, "isActive"),
|
||||
};
|
||||
}
|
||||
|
||||
export async function createRadioRank(formData: FormData): Promise<void> {
|
||||
await requirePermission(PERMS.RADIO_EDIT);
|
||||
const name = str(formData.get("name")).trim().slice(0, 255);
|
||||
if (!name) return;
|
||||
|
||||
const description = str(formData.get("description")).trim().slice(0, 255);
|
||||
const badgeCode = str(formData.get("badgeCode")).trim().slice(0, 255);
|
||||
const isActive = bool(formData.get("isActive"));
|
||||
const now = new Date();
|
||||
|
||||
try {
|
||||
await db.insert(RadioRanks).values({
|
||||
name,
|
||||
description: description || null,
|
||||
badgeCode: badgeCode || null,
|
||||
isActive,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
});
|
||||
} catch (err) {
|
||||
logger.error("Failed to create radio rank", { err, name });
|
||||
}
|
||||
const staff = await actor();
|
||||
await executeLegacyHotelMutation(
|
||||
staff,
|
||||
"radio.rank.create",
|
||||
rankInput(formData),
|
||||
);
|
||||
revalidatePath("/admin/radio/ranks");
|
||||
}
|
||||
|
||||
export async function updateRadioRank(formData: FormData): Promise<void> {
|
||||
await requirePermission(PERMS.RADIO_EDIT);
|
||||
const id = parseId(formData.get("id"));
|
||||
if (id === null) return;
|
||||
|
||||
const name = str(formData.get("name")).trim().slice(0, 255);
|
||||
const description = str(formData.get("description")).trim().slice(0, 255);
|
||||
const badgeCode = str(formData.get("badgeCode")).trim().slice(0, 255);
|
||||
const isActive = bool(formData.get("isActive"));
|
||||
if (!name) return;
|
||||
|
||||
try {
|
||||
await db
|
||||
.update(RadioRanks)
|
||||
.set({
|
||||
name,
|
||||
description: description || null,
|
||||
badgeCode: badgeCode || null,
|
||||
isActive,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(RadioRanks.id, id));
|
||||
} catch (err) {
|
||||
logger.error("Failed to update radio rank", { err, id: String(id) });
|
||||
}
|
||||
const staff = await actor();
|
||||
await executeLegacyHotelMutation(staff, "radio.rank.update", {
|
||||
id: text(formData, "id"),
|
||||
...rankInput(formData),
|
||||
});
|
||||
revalidatePath("/admin/radio/ranks");
|
||||
}
|
||||
|
||||
export async function deleteRadioRank(formData: FormData): Promise<void> {
|
||||
await requirePermission(PERMS.RADIO_EDIT);
|
||||
const id = parseId(formData.get("id"));
|
||||
if (id === null) return;
|
||||
try {
|
||||
await db.delete(RadioRanks).where(eq(RadioRanks.id, id));
|
||||
} catch (err) {
|
||||
logger.error("Failed to delete radio rank", { err, id: String(id) });
|
||||
}
|
||||
const staff = await actor();
|
||||
await executeLegacyHotelMutation(staff, "radio.rank.delete", {
|
||||
id: text(formData, "id"),
|
||||
});
|
||||
revalidatePath("/admin/radio/ranks");
|
||||
}
|
||||
@@ -1,45 +1,14 @@
|
||||
"use server";
|
||||
|
||||
import { eq } from "drizzle-orm";
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { executeLegacyHotelMutation } from "@/features/housekeeping/domains/hotel/services/mutations";
|
||||
import { requirePermission } from "@/lib/admin/guard";
|
||||
import { db, RadioShouts } from "@/lib/db";
|
||||
import { PERMS } from "@/lib/permissions";
|
||||
import { logStaffActivity } from "@/lib/services/staff-activity";
|
||||
|
||||
/** Parse a FormData field into a positive BigInt id, or null when invalid. */
|
||||
function parseId(raw: FormDataEntryValue | null): bigint | null {
|
||||
if (typeof raw !== "string" || raw.trim() === "") return null;
|
||||
try {
|
||||
const id = BigInt(raw.trim());
|
||||
return id > 0n ? id : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a radio shout from the DJ moderation page. Re-reads auth via
|
||||
* requireStaff, writes a staff-activity audit entry and revalidates the
|
||||
* moderation route. Fails soft if the row is already gone.
|
||||
*/
|
||||
export async function deleteShout(formData: FormData): Promise<void> {
|
||||
const staff = await requirePermission(PERMS.RADIO_EDIT);
|
||||
const id = parseId(formData.get("id"));
|
||||
if (id === null) return;
|
||||
|
||||
try {
|
||||
await db.delete(RadioShouts).where(eq(RadioShouts.id, id));
|
||||
await logStaffActivity({
|
||||
staffId: staff.id,
|
||||
action: "radio.shout.delete",
|
||||
description: `Deleted radio shout #${id}`,
|
||||
targetType: "radio_shout",
|
||||
targetId: Number(id),
|
||||
});
|
||||
} catch {
|
||||
// Row may already be gone; ignore so the action does not throw.
|
||||
}
|
||||
|
||||
await executeLegacyHotelMutation(staff, "radio.shout.delete", {
|
||||
id: String(formData.get("id") ?? "").trim(),
|
||||
});
|
||||
revalidatePath("/admin/radio/moderation");
|
||||
}
|
||||
@@ -2,93 +2,39 @@
|
||||
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { redirect } from "next/navigation";
|
||||
import { executeLegacyHotelMutation } from "@/features/housekeeping/domains/hotel/services/mutations";
|
||||
import { requirePermission } from "@/lib/admin/guard";
|
||||
import { db, WebsiteSetting } from "@/lib/db";
|
||||
import { PERMS } from "@/lib/permissions";
|
||||
import { siteSettings } from "@/lib/services/site-settings";
|
||||
import { logStaffActivity } from "@/lib/services/staff-activity";
|
||||
|
||||
// Radio listener-points settings (website_settings radio_points_* keys).
|
||||
// Mirrors AtomCMS's RadioPoints Filament page: key/value rows in
|
||||
// website_settings that reward listeners for time spent on the radio. Booleans
|
||||
// use the string '0' / '1'. Busts the siteSettings cache so the public radio
|
||||
// pages pick the change up immediately.
|
||||
|
||||
const POINTS_KEYS = [
|
||||
"radio_points_enabled",
|
||||
"radio_points_per_minute",
|
||||
"radio_points_currency",
|
||||
"radio_points_max_per_day",
|
||||
"radio_points_min_listeners",
|
||||
] as const;
|
||||
|
||||
const ALLOWED_CURRENCIES = new Set([
|
||||
"credits",
|
||||
"duckets",
|
||||
"diamonds",
|
||||
"points",
|
||||
]);
|
||||
|
||||
function str(raw: FormDataEntryValue | null): string {
|
||||
return typeof raw === "string" ? raw : "";
|
||||
}
|
||||
|
||||
/** Checkbox/select truthiness → '1' / '0'. */
|
||||
function boolStr(raw: FormDataEntryValue | null): "0" | "1" {
|
||||
const v = str(raw).trim().toLowerCase();
|
||||
return v === "1" || v === "true" || v === "on" ? "1" : "0";
|
||||
}
|
||||
|
||||
/** Clamp a form value to a non-negative integer string (defaulting to 0). */
|
||||
function intStr(raw: FormDataEntryValue | null): string {
|
||||
const n = Number(str(raw).trim());
|
||||
if (!Number.isFinite(n) || n < 0) return "0";
|
||||
return String(Math.floor(n));
|
||||
function text(formData: FormData, key: string): string {
|
||||
return String(formData.get(key) ?? "")
|
||||
.normalize("NFC")
|
||||
.trim();
|
||||
}
|
||||
|
||||
export async function savePoints(formData: FormData): Promise<void> {
|
||||
const staff = await requirePermission(PERMS.RADIO_EDIT);
|
||||
|
||||
const currencyRaw = str(formData.get("radio_points_currency"))
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
const currency = ALLOWED_CURRENCIES.has(currencyRaw)
|
||||
? currencyRaw
|
||||
: "credits";
|
||||
|
||||
const values: Record<(typeof POINTS_KEYS)[number], string> = {
|
||||
radio_points_enabled: boolStr(formData.get("radio_points_enabled")),
|
||||
radio_points_per_minute: intStr(formData.get("radio_points_per_minute")),
|
||||
radio_points_currency: currency,
|
||||
radio_points_max_per_day: intStr(formData.get("radio_points_max_per_day")),
|
||||
radio_points_min_listeners: intStr(
|
||||
formData.get("radio_points_min_listeners"),
|
||||
const enabled = ["1", "true", "on"].includes(
|
||||
text(formData, "radio_points_enabled").toLowerCase(),
|
||||
);
|
||||
await executeLegacyHotelMutation(staff, "radio.points.save", {
|
||||
radio_points_enabled: enabled,
|
||||
radio_points_per_minute: Number(
|
||||
text(formData, "radio_points_per_minute") || 0,
|
||||
),
|
||||
};
|
||||
|
||||
try {
|
||||
await Promise.all(
|
||||
POINTS_KEYS.map((key) =>
|
||||
db
|
||||
.insert(WebsiteSetting)
|
||||
// eslint-disable-next-line security/detect-object-injection -- key from POINTS_KEYS const
|
||||
.values({ key, value: values[key], comment: "Radio points" })
|
||||
.onDuplicateKeyUpdate({
|
||||
// eslint-disable-next-line security/detect-object-injection -- key from POINTS_KEYS const
|
||||
set: { value: values[key] },
|
||||
}),
|
||||
),
|
||||
);
|
||||
siteSettings.reload();
|
||||
await logStaffActivity({
|
||||
staffId: staff.id,
|
||||
action: "radio_points_update",
|
||||
description: `Updated radio listener-points settings (enabled=${values.radio_points_enabled}, ${values.radio_points_per_minute}/min ${currency})`,
|
||||
});
|
||||
} catch {
|
||||
// DB unavailable — fail soft so the action does not throw.
|
||||
}
|
||||
|
||||
radio_points_currency: text(
|
||||
formData,
|
||||
"radio_points_currency",
|
||||
).toLowerCase(),
|
||||
radio_points_max_per_day: Number(
|
||||
text(formData, "radio_points_max_per_day") || 0,
|
||||
),
|
||||
radio_points_min_listeners: Number(
|
||||
text(formData, "radio_points_min_listeners") || 0,
|
||||
),
|
||||
});
|
||||
siteSettings.reload();
|
||||
revalidatePath("/admin/radio/points");
|
||||
redirect("/admin/radio/points?saved=1");
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { requirePermission } from "@/lib/admin/guard";
|
||||
|
||||
const { executeLegacyHotelMutation, staff } = vi.hoisted(() => ({
|
||||
executeLegacyHotelMutation: vi.fn(
|
||||
async (
|
||||
_actor: { readonly id: number },
|
||||
_operation: string,
|
||||
_input: unknown,
|
||||
) => ({ before: null, after: {} }),
|
||||
),
|
||||
staff: { id: 42, rank: 7, username: "operator" },
|
||||
}));
|
||||
|
||||
vi.mock("@/features/housekeeping/domains/hotel/services/mutations", () => ({
|
||||
executeLegacyHotelMutation,
|
||||
}));
|
||||
vi.mock("@/lib/admin/guard", () => ({ requirePermission: vi.fn() }));
|
||||
vi.mock("@/lib/permissions", () => ({
|
||||
PERMS: {
|
||||
ROOMS_EDIT: "admin.room.edit",
|
||||
ROOMS_DELETE: "admin.room.delete",
|
||||
RADIO_EDIT: "admin.radio.edit",
|
||||
},
|
||||
}));
|
||||
vi.mock("@/lib/services/site-settings", () => ({
|
||||
siteSettings: { reload: vi.fn() },
|
||||
}));
|
||||
vi.mock("next/cache", () => ({ revalidatePath: vi.fn() }));
|
||||
vi.mock("next/navigation", () => ({ redirect: vi.fn() }));
|
||||
|
||||
import {
|
||||
createApiKey,
|
||||
deleteApiKey,
|
||||
toggleApiKey,
|
||||
} from "./admin-radio-api-keys";
|
||||
import { createTrack, deleteTrack, toggleTrack } from "./admin-radio-autodj";
|
||||
import {
|
||||
createRadioBanner,
|
||||
createRadioRank,
|
||||
deleteRadioBanner,
|
||||
deleteRadioRank,
|
||||
saveRadioSetting,
|
||||
saveRadioSettings,
|
||||
updateRadioBanner,
|
||||
updateRadioRank,
|
||||
} from "./admin-radio-extra";
|
||||
import { deleteShout } from "./admin-radio-moderation";
|
||||
import { savePoints } from "./admin-radio-points";
|
||||
import {
|
||||
bulkDeleteRoomItems,
|
||||
deleteRoom,
|
||||
deleteRoomItem,
|
||||
roomRconAction,
|
||||
updateRoom,
|
||||
updateRoomItem,
|
||||
} from "./rooms";
|
||||
|
||||
function form(data: Readonly<Record<string, string>>): FormData {
|
||||
return {
|
||||
get: (key: string) => data[key] ?? null,
|
||||
} as FormData;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.mocked(requirePermission).mockResolvedValue(staff as never);
|
||||
});
|
||||
|
||||
describe("Hotel legacy wrappers", () => {
|
||||
it("delegates every room operation through the actor-bound Hotel service", async () => {
|
||||
await updateRoom({ id: 7, name: "Lobby" });
|
||||
await deleteRoom({ id: 7 });
|
||||
await updateRoomItem({ roomId: 7, itemId: 8, x: 1 });
|
||||
await deleteRoomItem({ roomId: 7, itemId: 8 });
|
||||
await bulkDeleteRoomItems({ roomId: 7, itemIds: [8, 9] });
|
||||
await roomRconAction({ roomId: 7, action: "reload" });
|
||||
|
||||
expect(
|
||||
executeLegacyHotelMutation.mock.calls.map((call) => call[1]),
|
||||
).toEqual([
|
||||
"room.update",
|
||||
"room.delete",
|
||||
"room-item.update",
|
||||
"room-item.delete",
|
||||
"room-item.bulk-delete",
|
||||
"room.runtime",
|
||||
]);
|
||||
expect(
|
||||
executeLegacyHotelMutation.mock.calls.every(([actor]) => actor === staff),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("delegates all radio mutations without accepting a client API-key secret", async () => {
|
||||
await saveRadioSetting(form({ key: "radio_name", value: "Epic" }));
|
||||
await saveRadioSettings(
|
||||
form({
|
||||
__keys: "radio_name,auto_dj_enabled",
|
||||
radio_name: "Epic",
|
||||
auto_dj_enabled: "1",
|
||||
}),
|
||||
);
|
||||
await deleteShout(form({ id: "1" }));
|
||||
await createApiKey(form({ name: "Bridge", allowedIps: "127.0.0.1" }));
|
||||
await toggleApiKey(form({ id: "2" }));
|
||||
await deleteApiKey(form({ id: "2" }));
|
||||
await createTrack(form({ title: "Song", isActive: "on" }));
|
||||
await toggleTrack(form({ id: "3", isActive: "on" }));
|
||||
await deleteTrack(form({ id: "3" }));
|
||||
await createRadioBanner(form({ imagePath: "/banner.png" }));
|
||||
await updateRadioBanner(form({ id: "4", imagePath: "/banner.png" }));
|
||||
await deleteRadioBanner(form({ id: "4" }));
|
||||
await createRadioRank(form({ name: "DJ" }));
|
||||
await updateRadioRank(form({ id: "5", name: "DJ" }));
|
||||
await deleteRadioRank(form({ id: "5" }));
|
||||
await savePoints(
|
||||
form({
|
||||
radio_points_enabled: "on",
|
||||
radio_points_per_minute: "1",
|
||||
radio_points_currency: "credits",
|
||||
radio_points_max_per_day: "100",
|
||||
radio_points_min_listeners: "2",
|
||||
}),
|
||||
);
|
||||
|
||||
expect(
|
||||
executeLegacyHotelMutation.mock.calls.map((call) => call[1]),
|
||||
).toEqual([
|
||||
"radio.settings.save-one",
|
||||
"radio.settings.save-many",
|
||||
"radio.shout.delete",
|
||||
"radio.api-key.create",
|
||||
"radio.api-key.toggle",
|
||||
"radio.api-key.delete",
|
||||
"radio.autodj.create",
|
||||
"radio.autodj.toggle",
|
||||
"radio.autodj.delete",
|
||||
"radio.banner.create",
|
||||
"radio.banner.update",
|
||||
"radio.banner.delete",
|
||||
"radio.rank.create",
|
||||
"radio.rank.update",
|
||||
"radio.rank.delete",
|
||||
"radio.points.save",
|
||||
]);
|
||||
const createInput = executeLegacyHotelMutation.mock.calls.find(
|
||||
([, operation]) => operation === "radio.api-key.create",
|
||||
)?.[2];
|
||||
expect(createInput).not.toHaveProperty("key");
|
||||
});
|
||||
|
||||
it("keeps the six legacy modules as thin shared-service wrappers", () => {
|
||||
for (const path of [
|
||||
"src/actions/rooms.ts",
|
||||
"src/actions/admin-radio-api-keys.ts",
|
||||
"src/actions/admin-radio-autodj.ts",
|
||||
"src/actions/admin-radio-extra.ts",
|
||||
"src/actions/admin-radio-moderation.ts",
|
||||
"src/actions/admin-radio-points.ts",
|
||||
]) {
|
||||
const source = readFileSync(path, "utf8");
|
||||
expect(source, path).toContain("executeLegacyHotelMutation");
|
||||
expect(source, path).not.toContain('from "@/lib/db"');
|
||||
}
|
||||
});
|
||||
});
|
||||
+17
-83
@@ -1,109 +1,50 @@
|
||||
"use server";
|
||||
|
||||
import { and, eq, inArray } from "drizzle-orm";
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { executeLegacyHotelMutation } from "@/features/housekeeping/domains/hotel/services/mutations";
|
||||
import { requirePermission } from "@/lib/admin/guard";
|
||||
import { db, Items, Rooms } from "@/lib/db";
|
||||
import { PERMS } from "@/lib/permissions";
|
||||
import { rcon } from "@/lib/services/rcon";
|
||||
import { logStaffActivity } from "@/lib/services/staff-activity";
|
||||
|
||||
export async function updateRoomItem(payload: Record<string, unknown>) {
|
||||
const staff = await requirePermission(PERMS.ROOMS_EDIT);
|
||||
const { roomId, itemId, ...data } = payload as {
|
||||
roomId: number;
|
||||
itemId: number;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
await db
|
||||
.update(Items)
|
||||
.set(data as Partial<typeof Items.$inferInsert>)
|
||||
.where(eq(Items.id, itemId));
|
||||
await logStaffActivity({
|
||||
staffId: staff.id,
|
||||
action: "room_item_update",
|
||||
description: `Updated item #${itemId} in room #${roomId}`,
|
||||
targetType: "room_item",
|
||||
targetId: itemId,
|
||||
});
|
||||
await executeLegacyHotelMutation(staff, "room-item.update", payload);
|
||||
const roomId = Number(payload.roomId);
|
||||
revalidatePath(`/admin/rooms/${roomId}/furni`);
|
||||
}
|
||||
|
||||
export async function bulkDeleteRoomItems({
|
||||
roomId,
|
||||
itemIds,
|
||||
}: {
|
||||
export async function bulkDeleteRoomItems(input: {
|
||||
roomId: number;
|
||||
itemIds: number[];
|
||||
}) {
|
||||
const staff = await requirePermission(PERMS.ROOMS_EDIT);
|
||||
await db
|
||||
.delete(Items)
|
||||
.where(and(inArray(Items.id, itemIds), eq(Items.roomId, roomId)));
|
||||
await logStaffActivity({
|
||||
staffId: staff.id,
|
||||
action: "room_items_bulk_delete",
|
||||
description: `Deleted ${itemIds.length} item(s) from room #${roomId}`,
|
||||
targetType: "room_item",
|
||||
});
|
||||
revalidatePath(`/admin/rooms/${roomId}/furni`);
|
||||
await executeLegacyHotelMutation(staff, "room-item.bulk-delete", input);
|
||||
revalidatePath(`/admin/rooms/${input.roomId}/furni`);
|
||||
}
|
||||
|
||||
export async function deleteRoomItem({
|
||||
roomId,
|
||||
itemId,
|
||||
}: {
|
||||
export async function deleteRoomItem(input: {
|
||||
roomId: number;
|
||||
itemId: number;
|
||||
}) {
|
||||
const staff = await requirePermission(PERMS.ROOMS_EDIT);
|
||||
await db.delete(Items).where(eq(Items.id, itemId));
|
||||
await logStaffActivity({
|
||||
staffId: staff.id,
|
||||
action: "room_item_delete",
|
||||
description: `Deleted item #${itemId} from room #${roomId}`,
|
||||
targetType: "room_item",
|
||||
targetId: itemId,
|
||||
});
|
||||
revalidatePath(`/admin/rooms/${roomId}/furni`);
|
||||
await executeLegacyHotelMutation(staff, "room-item.delete", input);
|
||||
revalidatePath(`/admin/rooms/${input.roomId}/furni`);
|
||||
}
|
||||
|
||||
export async function roomRconAction({
|
||||
roomId,
|
||||
action,
|
||||
}: {
|
||||
export async function roomRconAction(input: {
|
||||
roomId: number;
|
||||
action: string;
|
||||
}) {
|
||||
await requirePermission(PERMS.ROOMS_EDIT);
|
||||
if (action === "reload") {
|
||||
await rcon.send("reloadroom", { room_id: roomId });
|
||||
} else if (action === "kick") {
|
||||
await rcon.send("kickall", { room_id: roomId });
|
||||
} else if (action === "lock") {
|
||||
await rcon.send("updateroom", { room_id: roomId, state: "locked" });
|
||||
} else if (action === "unlock") {
|
||||
await rcon.send("updateroom", { room_id: roomId, state: "open" });
|
||||
}
|
||||
const staff = await requirePermission(PERMS.ROOMS_EDIT);
|
||||
await executeLegacyHotelMutation(staff, "room.runtime", input);
|
||||
}
|
||||
|
||||
export async function deleteRoom({ id }: { id: number }) {
|
||||
export async function deleteRoom(input: { id: number }) {
|
||||
const staff = await requirePermission(PERMS.ROOMS_DELETE);
|
||||
await db.delete(Rooms).where(eq(Rooms.id, id));
|
||||
await logStaffActivity({
|
||||
staffId: staff.id,
|
||||
action: "room_delete",
|
||||
description: `Deleted room #${id}`,
|
||||
targetType: "room",
|
||||
targetId: id,
|
||||
});
|
||||
await executeLegacyHotelMutation(staff, "room.delete", input);
|
||||
revalidatePath("/admin/rooms");
|
||||
}
|
||||
|
||||
export async function updateRoom({
|
||||
id,
|
||||
...data
|
||||
}: {
|
||||
export async function updateRoom(input: {
|
||||
id: number;
|
||||
name?: string;
|
||||
description?: string;
|
||||
@@ -111,13 +52,6 @@ export async function updateRoom({
|
||||
usersMax?: number;
|
||||
}) {
|
||||
const staff = await requirePermission(PERMS.ROOMS_EDIT);
|
||||
await db.update(Rooms).set(data).where(eq(Rooms.id, id));
|
||||
await logStaffActivity({
|
||||
staffId: staff.id,
|
||||
action: "room_update",
|
||||
description: `Updated room #${id}`,
|
||||
targetType: "room",
|
||||
targetId: id,
|
||||
});
|
||||
revalidatePath(`/admin/rooms/${id}`);
|
||||
await executeLegacyHotelMutation(staff, "room.update", input);
|
||||
revalidatePath(`/admin/rooms/${input.id}`);
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
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);
|
||||
@@ -0,0 +1,115 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,68 @@
|
||||
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);
|
||||
@@ -0,0 +1,28 @@
|
||||
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;
|
||||
@@ -0,0 +1,10 @@
|
||||
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;
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
anyCapability,
|
||||
type HousekeepingDomainManifest,
|
||||
} from "../../foundation/contracts";
|
||||
import { HOTEL_PRIMARY_ROUTES } from "./routes";
|
||||
|
||||
export const hotelManifest = {
|
||||
id: "hotel",
|
||||
@@ -20,7 +21,7 @@ export const hotelManifest = {
|
||||
PERMS.PAGES_VIEW,
|
||||
PERMS.CATALOG_EDIT,
|
||||
),
|
||||
routes: [],
|
||||
routes: HOTEL_PRIMARY_ROUTES,
|
||||
searchProviders: [],
|
||||
inboxSources: [],
|
||||
widgets: [],
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
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),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,229 @@
|
||||
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");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,27 @@
|
||||
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"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,253 @@
|
||||
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} />
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
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"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
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"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
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"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/hotel/radio/monitoring",
|
||||
});
|
||||
}
|
||||
if (listeners !== null) {
|
||||
items.push({
|
||||
id: "listeners",
|
||||
title: `${listeners} listeners`,
|
||||
status: "live",
|
||||
href: "/ase/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,24 @@
|
||||
import type { HousekeepingRouteHandler } from "../../route-handlers";
|
||||
import { renderHotelNavigationPage } from "./pages/navigation";
|
||||
import { renderHotelRadioPage } from "./pages/radio";
|
||||
import { renderHotelRoomDetailPage } from "./pages/room-detail";
|
||||
import { renderHotelRoomFurniPage } from "./pages/room-furni";
|
||||
import { renderHotelRoomsPage } from "./pages/rooms";
|
||||
import { HOTEL_PRIMARY_ROUTE_IDS, type HotelPrimaryRouteId } from "./routes";
|
||||
|
||||
function rendererFor(
|
||||
routeId: HotelPrimaryRouteId,
|
||||
): HousekeepingRouteHandler["render"] {
|
||||
if (routeId === "hotel.rooms") return renderHotelRoomsPage;
|
||||
if (routeId === "hotel.room-detail") return renderHotelRoomDetailPage;
|
||||
if (routeId === "hotel.room-furni") return renderHotelRoomFurniPage;
|
||||
if (routeId === "hotel.navigation") return renderHotelNavigationPage;
|
||||
return renderHotelRadioPage;
|
||||
}
|
||||
|
||||
export const HOTEL_ROUTE_HANDLERS: readonly HousekeepingRouteHandler[] =
|
||||
Object.freeze(
|
||||
HOTEL_PRIMARY_ROUTE_IDS.map((routeId) =>
|
||||
Object.freeze({ routeId, render: rendererFor(routeId) }),
|
||||
),
|
||||
);
|
||||
@@ -0,0 +1,78 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { economyMigrationEntries } from "../../migration/economy";
|
||||
import { hotelMigrationEntries } from "../../migration/hotel";
|
||||
import { HOUSEKEEPING_ROUTE_HANDLERS } from "../../route-handlers";
|
||||
import { hotelManifest } from "./manifest";
|
||||
import {
|
||||
HOTEL_ASSET_CANONICAL_ROUTES,
|
||||
HOTEL_PRIMARY_ROUTE_IDS,
|
||||
HOTEL_PRIMARY_ROUTES,
|
||||
hotelRouteGroup,
|
||||
} from "./routes";
|
||||
|
||||
const expected = [
|
||||
["hotel.rooms", "/ase/hotel/rooms", "rooms"],
|
||||
["hotel.room-detail", "/ase/hotel/rooms/:id", "rooms"],
|
||||
["hotel.room-furni", "/ase/hotel/rooms/:id/furni", "rooms"],
|
||||
["hotel.navigation", "/ase/hotel/navigation", "navigation"],
|
||||
["hotel.radio.overview", "/ase/hotel/radio/overview", "radio"],
|
||||
["hotel.radio.settings", "/ase/hotel/radio/settings", "radio"],
|
||||
["hotel.radio.monitoring", "/ase/hotel/radio/monitoring", "radio"],
|
||||
["hotel.radio.moderation", "/ase/hotel/radio/moderation", "radio"],
|
||||
["hotel.radio.autodj", "/ase/hotel/radio/autodj", "radio"],
|
||||
["hotel.radio.history", "/ase/hotel/radio/history", "radio"],
|
||||
["hotel.radio.points", "/ase/hotel/radio/points", "radio"],
|
||||
["hotel.radio.ranks", "/ase/hotel/radio/ranks", "radio"],
|
||||
["hotel.radio.api-keys", "/ase/hotel/radio/api-keys", "radio"],
|
||||
["hotel.radio.banners", "/ase/hotel/radio/banners", "radio"],
|
||||
["hotel.radio.embed", "/ase/hotel/radio/embed", "radio"],
|
||||
] as const;
|
||||
|
||||
describe("Hotel primary routes", () => {
|
||||
it("merges room show/edit aliases and maps all non-Studio Hotel rows", () => {
|
||||
expect(
|
||||
HOTEL_PRIMARY_ROUTES.map((route) => [
|
||||
route.id,
|
||||
route.href,
|
||||
hotelRouteGroup(route.id),
|
||||
]),
|
||||
).toEqual(expected);
|
||||
expect(HOTEL_PRIMARY_ROUTE_IDS).toEqual(expected.map(([id]) => id));
|
||||
|
||||
const primaryMatrixPaths = [
|
||||
...new Set(
|
||||
hotelMigrationEntries
|
||||
.map((entry) => entry.targetPath)
|
||||
.filter(
|
||||
(path): path is string =>
|
||||
path !== null && !path.startsWith("/ase/hotel/studio/"),
|
||||
),
|
||||
),
|
||||
].sort();
|
||||
expect(HOTEL_PRIMARY_ROUTES.map((route) => route.href).sort()).toEqual(
|
||||
primaryMatrixPaths,
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps badge and soundtrack ownership canonical in Economy", () => {
|
||||
for (const asset of HOTEL_ASSET_CANONICAL_ROUTES) {
|
||||
const entry = economyMigrationEntries.find(
|
||||
(candidate) => candidate.legacyPath === asset.legacyPath,
|
||||
);
|
||||
expect(entry?.targetPath).toBe(asset.href);
|
||||
expect(asset.routeId.startsWith("economy.")).toBe(true);
|
||||
expect(
|
||||
HOTEL_PRIMARY_ROUTES.some((route) => route.href === asset.href),
|
||||
).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps manifest routes and handlers in exact equality", () => {
|
||||
expect(hotelManifest.routes).toBe(HOTEL_PRIMARY_ROUTES);
|
||||
expect(
|
||||
HOUSEKEEPING_ROUTE_HANDLERS.filter((handler) =>
|
||||
handler.routeId.startsWith("hotel."),
|
||||
).map((handler) => handler.routeId),
|
||||
).toEqual(HOTEL_PRIMARY_ROUTE_IDS);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,99 @@
|
||||
import { PERMS } from "@/lib/permission-slugs";
|
||||
import {
|
||||
anyCapability,
|
||||
type CanonicalHousekeepingHref,
|
||||
type HousekeepingRouteDefinition,
|
||||
} from "../../foundation/contracts";
|
||||
|
||||
export const HOTEL_ROUTE_GROUPS = ["rooms", "navigation", "radio"] as const;
|
||||
export type HotelRouteGroup = (typeof HOTEL_ROUTE_GROUPS)[number];
|
||||
|
||||
export const HOTEL_PRIMARY_ROUTE_IDS = [
|
||||
"hotel.rooms",
|
||||
"hotel.room-detail",
|
||||
"hotel.room-furni",
|
||||
"hotel.navigation",
|
||||
"hotel.radio.overview",
|
||||
"hotel.radio.settings",
|
||||
"hotel.radio.monitoring",
|
||||
"hotel.radio.moderation",
|
||||
"hotel.radio.autodj",
|
||||
"hotel.radio.history",
|
||||
"hotel.radio.points",
|
||||
"hotel.radio.ranks",
|
||||
"hotel.radio.api-keys",
|
||||
"hotel.radio.banners",
|
||||
"hotel.radio.embed",
|
||||
] as const;
|
||||
export type HotelPrimaryRouteId = (typeof HOTEL_PRIMARY_ROUTE_IDS)[number];
|
||||
|
||||
function route<const Id extends HotelPrimaryRouteId>(
|
||||
id: Id,
|
||||
href: CanonicalHousekeepingHref,
|
||||
permissions: readonly string[],
|
||||
): HousekeepingRouteDefinition & { readonly id: Id } {
|
||||
return {
|
||||
id,
|
||||
href,
|
||||
labelKey: `pages.housekeeping.routes.${id}`,
|
||||
capability: anyCapability(...permissions),
|
||||
};
|
||||
}
|
||||
|
||||
export const HOTEL_PRIMARY_ROUTES = [
|
||||
route("hotel.rooms", "/ase/hotel/rooms", [PERMS.ROOMS_VIEW]),
|
||||
route("hotel.room-detail", "/ase/hotel/rooms/:id", [
|
||||
PERMS.ROOMS_VIEW,
|
||||
PERMS.ROOMS_EDIT,
|
||||
]),
|
||||
route("hotel.room-furni", "/ase/hotel/rooms/:id/furni", [
|
||||
PERMS.ROOMS_VIEW,
|
||||
PERMS.ROOMS_EDIT,
|
||||
]),
|
||||
route("hotel.navigation", "/ase/hotel/navigation", [PERMS.PAGES_VIEW]),
|
||||
...(
|
||||
[
|
||||
["hotel.radio.overview", "/ase/hotel/radio/overview"],
|
||||
["hotel.radio.settings", "/ase/hotel/radio/settings"],
|
||||
["hotel.radio.monitoring", "/ase/hotel/radio/monitoring"],
|
||||
["hotel.radio.moderation", "/ase/hotel/radio/moderation"],
|
||||
["hotel.radio.autodj", "/ase/hotel/radio/autodj"],
|
||||
["hotel.radio.history", "/ase/hotel/radio/history"],
|
||||
["hotel.radio.points", "/ase/hotel/radio/points"],
|
||||
["hotel.radio.ranks", "/ase/hotel/radio/ranks"],
|
||||
["hotel.radio.api-keys", "/ase/hotel/radio/api-keys"],
|
||||
["hotel.radio.banners", "/ase/hotel/radio/banners"],
|
||||
["hotel.radio.embed", "/ase/hotel/radio/embed"],
|
||||
] as const
|
||||
).map(([id, href]) => route(id, href, [PERMS.RADIO_VIEW])),
|
||||
] as const satisfies readonly HousekeepingRouteDefinition[];
|
||||
|
||||
const ROUTE_BY_ID = new Map(
|
||||
HOTEL_PRIMARY_ROUTES.map((definition) => [definition.id, definition]),
|
||||
);
|
||||
|
||||
export function hotelRouteById(
|
||||
id: HotelPrimaryRouteId,
|
||||
): HousekeepingRouteDefinition {
|
||||
const definition = ROUTE_BY_ID.get(id);
|
||||
if (!definition) throw new Error(`unknown Hotel route: ${id}`);
|
||||
return definition;
|
||||
}
|
||||
|
||||
export function hotelRouteGroup(id: HotelPrimaryRouteId): HotelRouteGroup {
|
||||
if (id === "hotel.navigation") return "navigation";
|
||||
return id.startsWith("hotel.radio.") ? "radio" : "rooms";
|
||||
}
|
||||
|
||||
export const HOTEL_ASSET_CANONICAL_ROUTES = Object.freeze([
|
||||
Object.freeze({
|
||||
legacyPath: "/admin/badges",
|
||||
routeId: "economy.rewards.badges",
|
||||
href: "/ase/economy/rewards/badges",
|
||||
}),
|
||||
Object.freeze({
|
||||
legacyPath: "/admin/sounds",
|
||||
routeId: "economy.rewards.sounds",
|
||||
href: "/ase/economy/rewards/sounds",
|
||||
}),
|
||||
] as const);
|
||||
@@ -0,0 +1,108 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import type { AuditEntry } from "@/lib/services/audit";
|
||||
import type { HousekeepingCapabilityContext } from "../../../foundation/contracts";
|
||||
import { HOTEL_MUTATION_OPERATIONS } from "./mutations";
|
||||
import {
|
||||
createHotelProductionMutationAdapter,
|
||||
HOTEL_DATABASE_OPERATIONS,
|
||||
HOTEL_EXTERNAL_OPERATIONS,
|
||||
} 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: "hotel-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" },
|
||||
}));
|
||||
const transaction = vi.fn(async (run) => run(transactionToken));
|
||||
return {
|
||||
transactionToken,
|
||||
writeAudit,
|
||||
executeOperation,
|
||||
transaction,
|
||||
adapter: createHotelProductionMutationAdapter({
|
||||
transaction,
|
||||
writeAudit,
|
||||
executeOperation,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
describe("Hotel production mutation adapter", () => {
|
||||
it("classifies every operation exactly once", () => {
|
||||
const classified = [
|
||||
...HOTEL_DATABASE_OPERATIONS,
|
||||
...HOTEL_EXTERNAL_OPERATIONS,
|
||||
];
|
||||
expect([...classified].sort()).toEqual(
|
||||
[...HOTEL_MUTATION_OPERATIONS].sort(),
|
||||
);
|
||||
expect(new Set(classified).size).toBe(classified.length);
|
||||
});
|
||||
|
||||
it("keeps database changes and success audit in one transaction", async () => {
|
||||
const deps = dependencies();
|
||||
await deps.adapter.execute("room.update", { id: 7 }, context);
|
||||
expect(deps.executeOperation).toHaveBeenCalledWith(
|
||||
"room.update",
|
||||
expect.anything(),
|
||||
context,
|
||||
deps.transactionToken,
|
||||
);
|
||||
expect(deps.writeAudit).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
action: "hotel.room.update",
|
||||
domain: "hotel",
|
||||
outcome: "success",
|
||||
correlationId: "hotel-production",
|
||||
}),
|
||||
deps.transactionToken,
|
||||
);
|
||||
});
|
||||
|
||||
it("persists intent and outcome around RCON operations", async () => {
|
||||
const deps = dependencies();
|
||||
await deps.adapter.execute(
|
||||
"room.runtime",
|
||||
{ roomId: 7, action: "reload" },
|
||||
context,
|
||||
);
|
||||
expect(deps.transaction).not.toHaveBeenCalled();
|
||||
expect(deps.writeAudit.mock.calls.map(([entry]) => entry.outcome)).toEqual([
|
||||
"intent",
|
||||
"success",
|
||||
]);
|
||||
});
|
||||
|
||||
it("records a failure outcome when RCON fails", async () => {
|
||||
const deps = dependencies();
|
||||
deps.executeOperation.mockRejectedValueOnce(new Error("RCON unavailable"));
|
||||
await expect(
|
||||
deps.adapter.execute(
|
||||
"room.runtime",
|
||||
{ roomId: 7, action: "reload" },
|
||||
context,
|
||||
),
|
||||
).rejects.toThrow("RCON unavailable");
|
||||
expect(deps.writeAudit.mock.calls.map(([entry]) => entry.outcome)).toEqual([
|
||||
"intent",
|
||||
"failure",
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,118 @@
|
||||
import type { AuditEntry } from "@/lib/services/audit";
|
||||
import type {
|
||||
HotelMutationAdapter,
|
||||
HotelMutationContext,
|
||||
HotelMutationOperation,
|
||||
HotelMutationSnapshot,
|
||||
} from "./mutations";
|
||||
import { HOTEL_MUTATION_OPERATIONS } from "./mutations";
|
||||
|
||||
export const HOTEL_DATABASE_OPERATIONS = HOTEL_MUTATION_OPERATIONS.filter(
|
||||
(operation) => operation !== "room.runtime",
|
||||
);
|
||||
|
||||
export const HOTEL_EXTERNAL_OPERATIONS = [
|
||||
"room.runtime",
|
||||
] as const satisfies readonly HotelMutationOperation[];
|
||||
|
||||
type TransactionToken = unknown;
|
||||
|
||||
export interface HotelProductionMutationDependencies {
|
||||
transaction<T>(
|
||||
run: (transaction: TransactionToken) => Promise<T>,
|
||||
): Promise<T>;
|
||||
writeAudit(entry: AuditEntry, transaction?: TransactionToken): Promise<void>;
|
||||
executeOperation(
|
||||
operation: HotelMutationOperation,
|
||||
input: unknown,
|
||||
context: HotelMutationContext,
|
||||
transaction?: TransactionToken,
|
||||
): Promise<HotelMutationSnapshot>;
|
||||
}
|
||||
|
||||
function auditEntry(
|
||||
operation: HotelMutationOperation,
|
||||
context: HotelMutationContext,
|
||||
outcome: NonNullable<AuditEntry["outcome"]>,
|
||||
snapshot?: HotelMutationSnapshot,
|
||||
): AuditEntry {
|
||||
return {
|
||||
userId: context.capability.actor.id,
|
||||
action: `hotel.${operation}`,
|
||||
target: "Hotel",
|
||||
correlationId: context.correlationId,
|
||||
domain: "hotel",
|
||||
outcome,
|
||||
before: snapshot?.before ? { ...snapshot.before } : undefined,
|
||||
after: snapshot?.after ? { ...snapshot.after } : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
export function createHotelProductionMutationAdapter(
|
||||
dependencies: HotelProductionMutationDependencies,
|
||||
): HotelMutationAdapter {
|
||||
return {
|
||||
async execute(operation, input, context) {
|
||||
if (operation !== "room.runtime") {
|
||||
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,
|
||||
);
|
||||
await dependencies.writeAudit(
|
||||
auditEntry(operation, context, "success", snapshot),
|
||||
);
|
||||
return snapshot;
|
||||
} catch (error) {
|
||||
try {
|
||||
await dependencies.writeAudit(
|
||||
auditEntry(operation, context, "failure"),
|
||||
);
|
||||
} catch {
|
||||
// The durable intent remains correlated when outcome persistence fails.
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export const hotelProductionMutationAdapter =
|
||||
createHotelProductionMutationAdapter({
|
||||
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 { executeHotelMutationOperation } = await import(
|
||||
"./mutations-runtime"
|
||||
);
|
||||
return executeHotelMutationOperation(
|
||||
operation,
|
||||
input,
|
||||
context,
|
||||
transaction,
|
||||
);
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,645 @@
|
||||
import "server-only";
|
||||
|
||||
import { randomBytes } from "node:crypto";
|
||||
import { and, eq, inArray } from "drizzle-orm";
|
||||
import { z } from "zod";
|
||||
import type {
|
||||
HotelMutationContext,
|
||||
HotelMutationOperation,
|
||||
HotelMutationSnapshot,
|
||||
} from "./mutations";
|
||||
import { HotelMutationFailure } from "./mutations";
|
||||
|
||||
const positiveInt = z.coerce.number().int().positive().max(2_147_483_647);
|
||||
const positiveBigInt = z.coerce.bigint().refine((value) => value > 0n);
|
||||
const boundedText = (length: number) =>
|
||||
z
|
||||
.string()
|
||||
.transform((value) => value.normalize("NFC").trim())
|
||||
.pipe(z.string().max(length));
|
||||
const requiredText = (length: number) =>
|
||||
boundedText(length).pipe(z.string().min(1));
|
||||
|
||||
function validationFailure(error?: z.ZodError): never {
|
||||
const fieldErrors: Record<string, readonly string[]> = {};
|
||||
for (const issue of error?.issues ?? []) {
|
||||
fieldErrors[String(issue.path[0] ?? "input")] = [
|
||||
"errors.validation.invalid",
|
||||
];
|
||||
}
|
||||
throw new HotelMutationFailure(
|
||||
"VALIDATION",
|
||||
"errors.housekeeping.validation",
|
||||
fieldErrors,
|
||||
);
|
||||
}
|
||||
|
||||
function parse<T>(schema: z.ZodType<T>, input: unknown): T {
|
||||
const result = schema.safeParse(input);
|
||||
if (!result.success) validationFailure(result.error);
|
||||
return result.data;
|
||||
}
|
||||
|
||||
function plain(value: unknown): Record<string, unknown> {
|
||||
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
||||
validationFailure();
|
||||
}
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function snapshotRow(
|
||||
row: Readonly<Record<string, unknown>> | undefined,
|
||||
): Readonly<Record<string, unknown>> | null {
|
||||
if (!row) return null;
|
||||
return Object.fromEntries(
|
||||
Object.entries(row).map(([key, value]) => [
|
||||
key,
|
||||
typeof value === "bigint"
|
||||
? value.toString()
|
||||
: value instanceof Date
|
||||
? value.toISOString()
|
||||
: value,
|
||||
]),
|
||||
);
|
||||
}
|
||||
|
||||
function requireFound(
|
||||
row: Readonly<Record<string, unknown>> | undefined,
|
||||
): Readonly<Record<string, unknown>> {
|
||||
if (!row) {
|
||||
throw new HotelMutationFailure("NOT_FOUND", "errors.housekeeping.notFound");
|
||||
}
|
||||
return row;
|
||||
}
|
||||
|
||||
function isSensitiveSetting(key: string): boolean {
|
||||
return /secret|token|password|api[_-]?key|webhook|custom_js/i.test(key);
|
||||
}
|
||||
|
||||
function settingSnapshot(key: string, value: string) {
|
||||
return { key, value: isSensitiveSetting(key) ? "[Redacted]" : value };
|
||||
}
|
||||
|
||||
function safeImagePath(value: string): boolean {
|
||||
if (value.startsWith("/") && !value.startsWith("//")) return true;
|
||||
try {
|
||||
const url = new URL(value);
|
||||
return url.protocol === "https:" || url.protocol === "http:";
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
const roomUpdateSchema = z
|
||||
.object({
|
||||
id: positiveInt,
|
||||
name: requiredText(50).optional(),
|
||||
description: boundedText(512).optional(),
|
||||
state: z.enum(["open", "locked", "password", "invisible"]).optional(),
|
||||
usersMax: z.coerce.number().int().min(1).max(500).optional(),
|
||||
})
|
||||
.strict();
|
||||
const roomItemUpdateSchema = z
|
||||
.object({
|
||||
roomId: positiveInt,
|
||||
itemId: positiveInt,
|
||||
x: z.coerce.number().int().min(-1000).max(1000).optional(),
|
||||
y: z.coerce.number().int().min(-1000).max(1000).optional(),
|
||||
z: z.coerce.number().finite().min(-1000).max(1000).optional(),
|
||||
rot: z.coerce.number().int().min(0).max(7).optional(),
|
||||
wallPos: boundedText(20).optional(),
|
||||
extraData: boundedText(1024).optional(),
|
||||
})
|
||||
.strict();
|
||||
const roomItemDeleteSchema = z
|
||||
.object({ roomId: positiveInt, itemId: positiveInt })
|
||||
.strict();
|
||||
const roomItemBulkDeleteSchema = z
|
||||
.object({
|
||||
roomId: positiveInt,
|
||||
itemIds: z.array(positiveInt).min(1).max(500),
|
||||
})
|
||||
.strict();
|
||||
const roomRuntimeSchema = z
|
||||
.object({
|
||||
roomId: positiveInt,
|
||||
action: z.enum(["reload", "kick", "lock", "unlock"]),
|
||||
})
|
||||
.strict();
|
||||
|
||||
const settingKey = requiredText(255).refine(
|
||||
(key) => key.startsWith("radio_") || key.startsWith("auto_dj_"),
|
||||
);
|
||||
const settingSchema = z
|
||||
.object({
|
||||
key: settingKey,
|
||||
value: z.string().max(20_000),
|
||||
comment: boundedText(255).optional(),
|
||||
})
|
||||
.strict();
|
||||
const settingsSchema = z
|
||||
.object({
|
||||
entries: z
|
||||
.array(
|
||||
z.object({ key: settingKey, value: z.string().max(20_000) }).strict(),
|
||||
)
|
||||
.min(1)
|
||||
.max(100),
|
||||
})
|
||||
.strict();
|
||||
const idSchema = z.object({ id: positiveBigInt }).strict();
|
||||
const apiKeyCreateSchema = z
|
||||
.object({
|
||||
name: requiredText(255),
|
||||
allowedIps: boundedText(255).optional(),
|
||||
rateLimit: z.coerce.number().int().min(1).max(100_000).default(300),
|
||||
})
|
||||
.strict();
|
||||
const apiKeyToggleSchema = z
|
||||
.object({ id: positiveBigInt, isActive: z.boolean().optional() })
|
||||
.strict();
|
||||
const autoDjCreateSchema = z
|
||||
.object({
|
||||
title: requiredText(255),
|
||||
artist: boundedText(255).optional(),
|
||||
album: boundedText(255).optional(),
|
||||
artworkUrl: boundedText(255).optional(),
|
||||
duration: z.coerce.number().int().min(0).max(86_400).nullable().optional(),
|
||||
sortOrder: z.coerce.number().int().min(0).max(1_000_000).default(0),
|
||||
isActive: z.boolean().default(true),
|
||||
})
|
||||
.strict();
|
||||
const toggleSchema = z
|
||||
.object({ id: positiveBigInt, isActive: z.boolean() })
|
||||
.strict();
|
||||
const bannerCreateSchema = z
|
||||
.object({
|
||||
imagePath: requiredText(255).refine(safeImagePath),
|
||||
title: boundedText(255).optional(),
|
||||
description: boundedText(20_000).optional(),
|
||||
sortOrder: z.coerce
|
||||
.number()
|
||||
.int()
|
||||
.min(-1_000_000)
|
||||
.max(1_000_000)
|
||||
.default(0),
|
||||
isActive: z.boolean().default(true),
|
||||
})
|
||||
.strict();
|
||||
const bannerUpdateSchema = bannerCreateSchema.extend({ id: positiveBigInt });
|
||||
const rankCreateSchema = z
|
||||
.object({
|
||||
name: requiredText(255),
|
||||
description: boundedText(255).optional(),
|
||||
badgeCode: boundedText(255).optional(),
|
||||
isActive: z.boolean().default(true),
|
||||
})
|
||||
.strict();
|
||||
const rankUpdateSchema = rankCreateSchema.extend({ id: positiveBigInt });
|
||||
const pointsSchema = z
|
||||
.object({
|
||||
radio_points_enabled: z.union([z.boolean(), z.enum(["0", "1"])]),
|
||||
radio_points_per_minute: z.coerce.number().int().min(0).max(1_000_000),
|
||||
radio_points_currency: z.enum(["credits", "duckets", "diamonds", "points"]),
|
||||
radio_points_max_per_day: z.coerce.number().int().min(0).max(1_000_000),
|
||||
radio_points_min_listeners: z.coerce.number().int().min(0).max(1_000_000),
|
||||
})
|
||||
.strict();
|
||||
|
||||
async function executeRoomMutation(
|
||||
operation: HotelMutationOperation,
|
||||
input: unknown,
|
||||
transaction?: unknown,
|
||||
): Promise<HotelMutationSnapshot | null> {
|
||||
if (!operation.startsWith("room")) return null;
|
||||
if (operation === "room.runtime") {
|
||||
const value = parse(roomRuntimeSchema, input);
|
||||
const { rcon } = await import("@/lib/services/rcon");
|
||||
const request =
|
||||
value.action === "reload"
|
||||
? ["reloadroom", { room_id: value.roomId }]
|
||||
: value.action === "kick"
|
||||
? ["kickall", { room_id: value.roomId }]
|
||||
: [
|
||||
"updateroom",
|
||||
{
|
||||
room_id: value.roomId,
|
||||
state: value.action === "lock" ? "locked" : "open",
|
||||
},
|
||||
];
|
||||
const sent = await rcon.send(
|
||||
request[0] as string,
|
||||
request[1] as Record<string, unknown>,
|
||||
);
|
||||
if (!sent) {
|
||||
throw new HotelMutationFailure(
|
||||
"DEPENDENCY_UNAVAILABLE",
|
||||
"errors.housekeeping.dependencyUnavailable",
|
||||
);
|
||||
}
|
||||
return {
|
||||
before: null,
|
||||
after: { roomId: value.roomId, action: value.action },
|
||||
};
|
||||
}
|
||||
|
||||
const databaseModule = await import("@/lib/db");
|
||||
const database = (transaction ??
|
||||
databaseModule.db) as typeof databaseModule.db;
|
||||
const { Items, Rooms } = databaseModule;
|
||||
if (operation === "room.update") {
|
||||
const value = parse(roomUpdateSchema, input);
|
||||
const [before] = await database
|
||||
.select()
|
||||
.from(Rooms)
|
||||
.where(eq(Rooms.id, value.id))
|
||||
.limit(1);
|
||||
requireFound(before);
|
||||
const { id, ...changes } = value;
|
||||
await database.update(Rooms).set(changes).where(eq(Rooms.id, id));
|
||||
return {
|
||||
before: snapshotRow(before),
|
||||
after: snapshotRow({ ...before, ...changes }),
|
||||
};
|
||||
}
|
||||
if (operation === "room.delete") {
|
||||
const value = parse(z.object({ id: positiveInt }).strict(), input);
|
||||
const [before] = await database
|
||||
.select()
|
||||
.from(Rooms)
|
||||
.where(eq(Rooms.id, value.id))
|
||||
.limit(1);
|
||||
requireFound(before);
|
||||
await database.delete(Rooms).where(eq(Rooms.id, value.id));
|
||||
return { before: snapshotRow(before), after: null };
|
||||
}
|
||||
if (operation === "room-item.update") {
|
||||
const value = parse(roomItemUpdateSchema, input);
|
||||
const [before] = await database
|
||||
.select()
|
||||
.from(Items)
|
||||
.where(and(eq(Items.id, value.itemId), eq(Items.roomId, value.roomId)))
|
||||
.limit(1);
|
||||
requireFound(before);
|
||||
const { itemId, roomId: _roomId, ...changes } = value;
|
||||
await database
|
||||
.update(Items)
|
||||
.set(changes)
|
||||
.where(and(eq(Items.id, itemId), eq(Items.roomId, value.roomId)));
|
||||
return {
|
||||
before: snapshotRow(before),
|
||||
after: snapshotRow({ ...before, ...changes }),
|
||||
};
|
||||
}
|
||||
if (operation === "room-item.delete") {
|
||||
const value = parse(roomItemDeleteSchema, input);
|
||||
const [before] = await database
|
||||
.select()
|
||||
.from(Items)
|
||||
.where(and(eq(Items.id, value.itemId), eq(Items.roomId, value.roomId)))
|
||||
.limit(1);
|
||||
requireFound(before);
|
||||
await database
|
||||
.delete(Items)
|
||||
.where(and(eq(Items.id, value.itemId), eq(Items.roomId, value.roomId)));
|
||||
return { before: snapshotRow(before), after: null };
|
||||
}
|
||||
const value = parse(roomItemBulkDeleteSchema, input);
|
||||
const itemIds = [...new Set(value.itemIds)];
|
||||
const before = await database
|
||||
.select()
|
||||
.from(Items)
|
||||
.where(and(eq(Items.roomId, value.roomId), inArray(Items.id, itemIds)));
|
||||
if (before.length !== itemIds.length) {
|
||||
throw new HotelMutationFailure("NOT_FOUND", "errors.housekeeping.notFound");
|
||||
}
|
||||
await database
|
||||
.delete(Items)
|
||||
.where(and(eq(Items.roomId, value.roomId), inArray(Items.id, itemIds)));
|
||||
return {
|
||||
before: {
|
||||
roomId: value.roomId,
|
||||
items: before.map((row) => snapshotRow(row)),
|
||||
},
|
||||
after: { roomId: value.roomId, deletedItemIds: itemIds },
|
||||
};
|
||||
}
|
||||
|
||||
async function executeRadioMutation(
|
||||
operation: HotelMutationOperation,
|
||||
input: unknown,
|
||||
context: HotelMutationContext,
|
||||
transaction?: unknown,
|
||||
): Promise<HotelMutationSnapshot | null> {
|
||||
if (!operation.startsWith("radio.")) return null;
|
||||
const databaseModule = await import("@/lib/db");
|
||||
const database = (transaction ??
|
||||
databaseModule.db) as typeof databaseModule.db;
|
||||
const {
|
||||
RadioApiKeys,
|
||||
RadioAutoDjPlaylist,
|
||||
RadioBanners,
|
||||
RadioRanks,
|
||||
RadioShouts,
|
||||
WebsiteSetting,
|
||||
} = databaseModule;
|
||||
|
||||
if (operation === "radio.settings.save-one") {
|
||||
const value = parse(settingSchema, input);
|
||||
const [before] = await database
|
||||
.select()
|
||||
.from(WebsiteSetting)
|
||||
.where(eq(WebsiteSetting.key, value.key))
|
||||
.limit(1);
|
||||
await database
|
||||
.insert(WebsiteSetting)
|
||||
.values({
|
||||
key: value.key,
|
||||
value: value.value,
|
||||
comment: value.comment || null,
|
||||
})
|
||||
.onDuplicateKeyUpdate({
|
||||
set: { value: value.value, comment: value.comment || null },
|
||||
});
|
||||
return {
|
||||
before: before ? settingSnapshot(before.key, before.value) : null,
|
||||
after: settingSnapshot(value.key, value.value),
|
||||
};
|
||||
}
|
||||
if (operation === "radio.settings.save-many") {
|
||||
const value = parse(settingsSchema, input);
|
||||
const keys = value.entries.map((entry) => entry.key);
|
||||
if (new Set(keys).size !== keys.length) validationFailure();
|
||||
const before = await database
|
||||
.select()
|
||||
.from(WebsiteSetting)
|
||||
.where(inArray(WebsiteSetting.key, keys));
|
||||
for (const entry of value.entries) {
|
||||
await database
|
||||
.insert(WebsiteSetting)
|
||||
.values({ key: entry.key, value: entry.value, comment: null })
|
||||
.onDuplicateKeyUpdate({ set: { value: entry.value } });
|
||||
}
|
||||
return {
|
||||
before: {
|
||||
settings: before.map((entry) =>
|
||||
settingSnapshot(entry.key, entry.value),
|
||||
),
|
||||
},
|
||||
after: {
|
||||
settings: value.entries.map((entry) =>
|
||||
settingSnapshot(entry.key, entry.value),
|
||||
),
|
||||
},
|
||||
};
|
||||
}
|
||||
if (operation === "radio.shout.delete") {
|
||||
const value = parse(idSchema, input);
|
||||
const [before] = await database
|
||||
.select()
|
||||
.from(RadioShouts)
|
||||
.where(eq(RadioShouts.id, value.id))
|
||||
.limit(1);
|
||||
requireFound(before);
|
||||
await database.delete(RadioShouts).where(eq(RadioShouts.id, value.id));
|
||||
return { before: snapshotRow(before), after: null };
|
||||
}
|
||||
if (operation === "radio.api-key.create") {
|
||||
const value = parse(apiKeyCreateSchema, input);
|
||||
const key = randomBytes(24).toString("hex");
|
||||
const now = new Date();
|
||||
const [result] = await database.insert(RadioApiKeys).values({
|
||||
name: value.name,
|
||||
key,
|
||||
allowedIps: value.allowedIps || null,
|
||||
rateLimit: value.rateLimit,
|
||||
isActive: true,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
});
|
||||
return {
|
||||
before: null,
|
||||
after: {
|
||||
id: String(result.insertId),
|
||||
name: value.name,
|
||||
allowedIps: value.allowedIps || null,
|
||||
rateLimit: value.rateLimit,
|
||||
isActive: true,
|
||||
},
|
||||
};
|
||||
}
|
||||
if (operation === "radio.api-key.toggle") {
|
||||
const value = parse(apiKeyToggleSchema, input);
|
||||
const [before] = await database
|
||||
.select()
|
||||
.from(RadioApiKeys)
|
||||
.where(eq(RadioApiKeys.id, value.id))
|
||||
.limit(1);
|
||||
requireFound(before);
|
||||
const isActive = value.isActive ?? !before.isActive;
|
||||
await database
|
||||
.update(RadioApiKeys)
|
||||
.set({ isActive, updatedAt: new Date() })
|
||||
.where(eq(RadioApiKeys.id, value.id));
|
||||
const safeBefore = { ...snapshotRow(before), key: "[Redacted]" };
|
||||
return { before: safeBefore, after: { ...safeBefore, isActive } };
|
||||
}
|
||||
if (operation === "radio.api-key.delete") {
|
||||
const value = parse(idSchema, input);
|
||||
const [before] = await database
|
||||
.select()
|
||||
.from(RadioApiKeys)
|
||||
.where(eq(RadioApiKeys.id, value.id))
|
||||
.limit(1);
|
||||
requireFound(before);
|
||||
await database.delete(RadioApiKeys).where(eq(RadioApiKeys.id, value.id));
|
||||
return {
|
||||
before: { ...snapshotRow(before), key: "[Redacted]" },
|
||||
after: null,
|
||||
};
|
||||
}
|
||||
if (operation === "radio.autodj.create") {
|
||||
const value = parse(autoDjCreateSchema, input);
|
||||
if (value.artworkUrl && !safeImagePath(value.artworkUrl))
|
||||
validationFailure();
|
||||
const now = new Date();
|
||||
const [result] = await database.insert(RadioAutoDjPlaylist).values({
|
||||
...value,
|
||||
artist: value.artist || null,
|
||||
album: value.album || null,
|
||||
artworkUrl: value.artworkUrl || null,
|
||||
duration: value.duration ?? null,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
});
|
||||
return { before: null, after: { id: String(result.insertId), ...value } };
|
||||
}
|
||||
if (operation === "radio.autodj.toggle") {
|
||||
const value = parse(toggleSchema, input);
|
||||
const [before] = await database
|
||||
.select()
|
||||
.from(RadioAutoDjPlaylist)
|
||||
.where(eq(RadioAutoDjPlaylist.id, value.id))
|
||||
.limit(1);
|
||||
requireFound(before);
|
||||
await database
|
||||
.update(RadioAutoDjPlaylist)
|
||||
.set({ isActive: value.isActive, updatedAt: new Date() })
|
||||
.where(eq(RadioAutoDjPlaylist.id, value.id));
|
||||
return {
|
||||
before: snapshotRow(before),
|
||||
after: snapshotRow({ ...before, isActive: value.isActive }),
|
||||
};
|
||||
}
|
||||
if (operation === "radio.autodj.delete") {
|
||||
const value = parse(idSchema, input);
|
||||
const [before] = await database
|
||||
.select()
|
||||
.from(RadioAutoDjPlaylist)
|
||||
.where(eq(RadioAutoDjPlaylist.id, value.id))
|
||||
.limit(1);
|
||||
requireFound(before);
|
||||
await database
|
||||
.delete(RadioAutoDjPlaylist)
|
||||
.where(eq(RadioAutoDjPlaylist.id, value.id));
|
||||
return { before: snapshotRow(before), after: null };
|
||||
}
|
||||
if (operation === "radio.banner.create") {
|
||||
const value = parse(bannerCreateSchema, input);
|
||||
const now = new Date();
|
||||
const [result] = await database.insert(RadioBanners).values({
|
||||
...value,
|
||||
userId: BigInt(context.capability.actor.id),
|
||||
title: value.title || null,
|
||||
description: value.description || null,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
});
|
||||
return { before: null, after: { id: String(result.insertId), ...value } };
|
||||
}
|
||||
if (operation === "radio.banner.update") {
|
||||
const value = parse(bannerUpdateSchema, input);
|
||||
const [before] = await database
|
||||
.select()
|
||||
.from(RadioBanners)
|
||||
.where(eq(RadioBanners.id, value.id))
|
||||
.limit(1);
|
||||
requireFound(before);
|
||||
const { id, ...changes } = value;
|
||||
await database
|
||||
.update(RadioBanners)
|
||||
.set({
|
||||
...changes,
|
||||
title: changes.title || null,
|
||||
description: changes.description || null,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(RadioBanners.id, id));
|
||||
return {
|
||||
before: snapshotRow(before),
|
||||
after: snapshotRow({ ...before, ...changes }),
|
||||
};
|
||||
}
|
||||
if (operation === "radio.banner.delete") {
|
||||
const value = parse(idSchema, input);
|
||||
const [before] = await database
|
||||
.select()
|
||||
.from(RadioBanners)
|
||||
.where(eq(RadioBanners.id, value.id))
|
||||
.limit(1);
|
||||
requireFound(before);
|
||||
await database.delete(RadioBanners).where(eq(RadioBanners.id, value.id));
|
||||
return { before: snapshotRow(before), after: null };
|
||||
}
|
||||
if (operation === "radio.rank.create") {
|
||||
const value = parse(rankCreateSchema, input);
|
||||
const now = new Date();
|
||||
const [result] = await database.insert(RadioRanks).values({
|
||||
...value,
|
||||
description: value.description || null,
|
||||
badgeCode: value.badgeCode || null,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
});
|
||||
return { before: null, after: { id: String(result.insertId), ...value } };
|
||||
}
|
||||
if (operation === "radio.rank.update") {
|
||||
const value = parse(rankUpdateSchema, input);
|
||||
const [before] = await database
|
||||
.select()
|
||||
.from(RadioRanks)
|
||||
.where(eq(RadioRanks.id, value.id))
|
||||
.limit(1);
|
||||
requireFound(before);
|
||||
const { id, ...changes } = value;
|
||||
await database
|
||||
.update(RadioRanks)
|
||||
.set({
|
||||
...changes,
|
||||
description: changes.description || null,
|
||||
badgeCode: changes.badgeCode || null,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(RadioRanks.id, id));
|
||||
return {
|
||||
before: snapshotRow(before),
|
||||
after: snapshotRow({ ...before, ...changes }),
|
||||
};
|
||||
}
|
||||
if (operation === "radio.rank.delete") {
|
||||
const value = parse(idSchema, input);
|
||||
const [before] = await database
|
||||
.select()
|
||||
.from(RadioRanks)
|
||||
.where(eq(RadioRanks.id, value.id))
|
||||
.limit(1);
|
||||
requireFound(before);
|
||||
await database.delete(RadioRanks).where(eq(RadioRanks.id, value.id));
|
||||
return { before: snapshotRow(before), after: null };
|
||||
}
|
||||
const value = parse(pointsSchema, input);
|
||||
const entries = Object.entries(value).map(([key, raw]) => ({
|
||||
key,
|
||||
value: typeof raw === "boolean" ? (raw ? "1" : "0") : String(raw),
|
||||
}));
|
||||
const before = await database
|
||||
.select()
|
||||
.from(WebsiteSetting)
|
||||
.where(
|
||||
inArray(
|
||||
WebsiteSetting.key,
|
||||
entries.map((entry) => entry.key),
|
||||
),
|
||||
);
|
||||
for (const entry of entries) {
|
||||
await database
|
||||
.insert(WebsiteSetting)
|
||||
.values({ ...entry, comment: "Radio points" })
|
||||
.onDuplicateKeyUpdate({ set: { value: entry.value } });
|
||||
}
|
||||
return {
|
||||
before: {
|
||||
settings: before.map((entry) => settingSnapshot(entry.key, entry.value)),
|
||||
},
|
||||
after: { settings: entries },
|
||||
};
|
||||
}
|
||||
|
||||
export async function executeHotelMutationOperation(
|
||||
operation: HotelMutationOperation,
|
||||
input: unknown,
|
||||
context: HotelMutationContext,
|
||||
transaction?: unknown,
|
||||
): Promise<HotelMutationSnapshot> {
|
||||
plain(input);
|
||||
const room = await executeRoomMutation(operation, input, transaction);
|
||||
if (room) return room;
|
||||
const radio = await executeRadioMutation(
|
||||
operation,
|
||||
input,
|
||||
context,
|
||||
transaction,
|
||||
);
|
||||
if (radio) return radio;
|
||||
validationFailure();
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import type { HousekeepingCapabilityContext } from "../../../foundation/contracts";
|
||||
import { createHotelMutationService, HotelMutationFailure } 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("Hotel mutation service", () => {
|
||||
it("rechecks capability before invoking the adapter", async () => {
|
||||
const execute = vi.fn();
|
||||
const service = createHotelMutationService({ execute }, async () =>
|
||||
capability({ hasAny: () => false }),
|
||||
);
|
||||
const result = await service.execute(
|
||||
{ correlationId: "forbidden", expectedActorId: 42 },
|
||||
"room.update",
|
||||
{},
|
||||
);
|
||||
expect(result).toMatchObject({ ok: false, error: { code: "FORBIDDEN" } });
|
||||
expect(execute).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects stale actor-bound invocations", async () => {
|
||||
const execute = vi.fn();
|
||||
const service = createHotelMutationService({ execute }, async () =>
|
||||
capability(),
|
||||
);
|
||||
const result = await service.execute(
|
||||
{ correlationId: "stale", expectedActorId: 7 },
|
||||
"radio.api-key.create",
|
||||
{},
|
||||
);
|
||||
expect(result).toMatchObject({ ok: false, error: { code: "FORBIDDEN" } });
|
||||
expect(execute).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("preserves typed validation failures and correlation ids", async () => {
|
||||
const service = createHotelMutationService(
|
||||
{
|
||||
execute: async () => {
|
||||
throw new HotelMutationFailure(
|
||||
"VALIDATION",
|
||||
"errors.housekeeping.validation",
|
||||
{ roomId: ["invalid"] },
|
||||
);
|
||||
},
|
||||
},
|
||||
async () => capability(),
|
||||
);
|
||||
const result = await service.execute(
|
||||
{ correlationId: "validation", expectedActorId: 42 },
|
||||
"room-item.delete",
|
||||
{},
|
||||
);
|
||||
expect(result).toMatchObject({
|
||||
ok: false,
|
||||
correlationId: "validation",
|
||||
error: { code: "VALIDATION", fieldErrors: { roomId: ["invalid"] } },
|
||||
});
|
||||
});
|
||||
|
||||
it("fails closed when server capability resolution is unavailable", async () => {
|
||||
const execute = vi.fn();
|
||||
const service = createHotelMutationService({ execute }, async () => {
|
||||
throw new Error("session unavailable");
|
||||
});
|
||||
const result = await service.execute(
|
||||
{ correlationId: "auth", expectedActorId: 42 },
|
||||
"radio.points.save",
|
||||
{},
|
||||
);
|
||||
expect(result).toMatchObject({
|
||||
ok: false,
|
||||
error: { code: "UNAUTHENTICATED" },
|
||||
});
|
||||
expect(execute).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,194 @@
|
||||
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 HOTEL_MUTATION_OPERATIONS = [
|
||||
"room.update",
|
||||
"room.delete",
|
||||
"room-item.update",
|
||||
"room-item.delete",
|
||||
"room-item.bulk-delete",
|
||||
"room.runtime",
|
||||
"radio.settings.save-one",
|
||||
"radio.settings.save-many",
|
||||
"radio.shout.delete",
|
||||
"radio.api-key.create",
|
||||
"radio.api-key.toggle",
|
||||
"radio.api-key.delete",
|
||||
"radio.autodj.create",
|
||||
"radio.autodj.toggle",
|
||||
"radio.autodj.delete",
|
||||
"radio.banner.create",
|
||||
"radio.banner.update",
|
||||
"radio.banner.delete",
|
||||
"radio.rank.create",
|
||||
"radio.rank.update",
|
||||
"radio.rank.delete",
|
||||
"radio.points.save",
|
||||
] as const;
|
||||
|
||||
export type HotelMutationOperation = (typeof HOTEL_MUTATION_OPERATIONS)[number];
|
||||
|
||||
export interface HotelMutationSnapshot {
|
||||
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 HotelMutationInvocation {
|
||||
readonly correlationId: string;
|
||||
readonly expectedActorId: number;
|
||||
readonly legacy?: boolean;
|
||||
}
|
||||
|
||||
export interface HotelMutationContext {
|
||||
readonly capability: HousekeepingCapabilityContext;
|
||||
readonly correlationId: string;
|
||||
readonly legacy: boolean;
|
||||
}
|
||||
|
||||
export interface HotelMutationAdapter {
|
||||
execute(
|
||||
operation: HotelMutationOperation,
|
||||
input: unknown,
|
||||
context: HotelMutationContext,
|
||||
): Promise<HotelMutationSnapshot>;
|
||||
}
|
||||
|
||||
export interface HotelMutationService {
|
||||
execute(
|
||||
invocation: HotelMutationInvocation,
|
||||
operation: HotelMutationOperation,
|
||||
input: unknown,
|
||||
): Promise<HousekeepingResult<HotelMutationSnapshot>>;
|
||||
}
|
||||
|
||||
export class HotelMutationFailure extends Error {
|
||||
constructor(
|
||||
readonly code: HousekeepingErrorCode,
|
||||
readonly messageKey: string,
|
||||
readonly fieldErrors?: Readonly<Record<string, readonly string[]>>,
|
||||
) {
|
||||
super(messageKey);
|
||||
this.name = "HotelMutationFailure";
|
||||
}
|
||||
}
|
||||
|
||||
export function hotelMutationCapability(operation: HotelMutationOperation) {
|
||||
if (operation === "room.delete") return anyCapability(PERMS.ROOMS_DELETE);
|
||||
if (operation.startsWith("room")) return anyCapability(PERMS.ROOMS_EDIT);
|
||||
return anyCapability(PERMS.RADIO_EDIT);
|
||||
}
|
||||
|
||||
export function createHotelMutationService(
|
||||
adapter: HotelMutationAdapter,
|
||||
resolveCapabilityContext: () => Promise<HousekeepingCapabilityContext>,
|
||||
): HotelMutationService {
|
||||
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, hotelMutationCapability(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 HotelMutationFailure) {
|
||||
return fail(
|
||||
error.code,
|
||||
error.messageKey,
|
||||
invocation.correlationId,
|
||||
error.fieldErrors,
|
||||
);
|
||||
}
|
||||
return fail(
|
||||
"DEPENDENCY_UNAVAILABLE",
|
||||
"errors.housekeeping.dependencyUnavailable",
|
||||
invocation.correlationId,
|
||||
);
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const productionAdapter: HotelMutationAdapter = {
|
||||
async execute(operation, input, context) {
|
||||
const { hotelProductionMutationAdapter } = await import(
|
||||
"./mutations-production"
|
||||
);
|
||||
return hotelProductionMutationAdapter.execute(operation, input, context);
|
||||
},
|
||||
};
|
||||
|
||||
export const hotelMutationService = createHotelMutationService(
|
||||
productionAdapter,
|
||||
async () => {
|
||||
const { getHousekeepingCapabilityContext } = await import(
|
||||
"../../../foundation/server-capability-context"
|
||||
);
|
||||
return getHousekeepingCapabilityContext();
|
||||
},
|
||||
);
|
||||
|
||||
export async function executeLegacyHotelMutation(
|
||||
actor: { readonly id: number },
|
||||
operation: HotelMutationOperation,
|
||||
input: unknown,
|
||||
): Promise<HotelMutationSnapshot> {
|
||||
const { createCorrelationId } = await import(
|
||||
"../../../foundation/correlation"
|
||||
);
|
||||
const result = await hotelMutationService.execute(
|
||||
{
|
||||
correlationId: createCorrelationId(),
|
||||
expectedActorId: actor.id,
|
||||
legacy: true,
|
||||
},
|
||||
operation,
|
||||
input,
|
||||
);
|
||||
if (!result.ok) {
|
||||
throw new HotelMutationFailure(
|
||||
result.error.code,
|
||||
result.error.messageKey,
|
||||
result.error.fieldErrors,
|
||||
);
|
||||
}
|
||||
if (result.completion?.external === "failed") {
|
||||
throw new HotelMutationFailure(
|
||||
"DEPENDENCY_UNAVAILABLE",
|
||||
"errors.housekeeping.dependencyUnavailable",
|
||||
);
|
||||
}
|
||||
return result.data;
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import "server-only";
|
||||
|
||||
import { CONTENT_COMMANDS } from "../../domains/content/commands/content-commands";
|
||||
import { ECONOMY_COMMANDS } from "../../domains/economy/commands/economy-commands";
|
||||
import { HOTEL_COMMANDS } from "../../domains/hotel/commands/hotel-commands";
|
||||
import { 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";
|
||||
@@ -44,6 +45,7 @@ export function registerHousekeepingCommands<
|
||||
const currentHousekeepingCommands = defineHousekeepingCommands(
|
||||
...CONTENT_COMMANDS,
|
||||
...ECONOMY_COMMANDS,
|
||||
...HOTEL_COMMANDS,
|
||||
...USER_COMMANDS,
|
||||
...COMMUNITY_COMMANDS,
|
||||
...SUPPORT_COMMANDS,
|
||||
|
||||
@@ -6,6 +6,7 @@ import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { CONTENT_ROUTE_IDS } from "../domains/content/routes";
|
||||
import { ECONOMY_ROUTE_IDS } from "../domains/economy/routes";
|
||||
import { HOTEL_PRIMARY_ROUTE_IDS } from "../domains/hotel/routes";
|
||||
import { PEOPLE_ROUTE_IDS } from "../domains/people/routes";
|
||||
import { SYSTEM_ROUTE_IDS } from "../domains/system/routes";
|
||||
import { HOUSEKEEPING_MANIFESTS } from "../manifests";
|
||||
@@ -698,11 +699,134 @@ const approvedRuntimeImports = new Map<string, ReadonlySet<string>>([
|
||||
"src/features/housekeeping/domains/system/routes",
|
||||
]),
|
||||
],
|
||||
[
|
||||
"src/features/housekeeping/domains/hotel/commands/hotel-commands.ts",
|
||||
new Set([
|
||||
"src/features/housekeeping/domains/hotel/commands/radio-commands",
|
||||
"src/features/housekeeping/domains/hotel/commands/room-commands",
|
||||
"src/features/housekeeping/domains/hotel/services/mutations",
|
||||
]),
|
||||
],
|
||||
[
|
||||
"src/features/housekeeping/domains/hotel/manifest.ts",
|
||||
new Set(["src/features/housekeeping/domains/hotel/routes"]),
|
||||
],
|
||||
[
|
||||
"src/features/housekeeping/domains/hotel/pages/hotel-command-form.tsx",
|
||||
new Set(["src/actions/housekeeping-command"]),
|
||||
],
|
||||
[
|
||||
"src/features/housekeeping/domains/hotel/pages/hotel-page-frame.tsx",
|
||||
new Set([
|
||||
"src/features/housekeeping/domains/hotel/queries/rooms",
|
||||
"src/features/housekeeping/domains/hotel/routes",
|
||||
]),
|
||||
],
|
||||
[
|
||||
"src/features/housekeeping/domains/hotel/pages/navigation.tsx",
|
||||
new Set([
|
||||
"src/features/housekeeping/domains/hotel/pages/hotel-page-frame",
|
||||
"src/features/housekeeping/domains/hotel/queries/rooms",
|
||||
]),
|
||||
],
|
||||
[
|
||||
"src/features/housekeeping/domains/hotel/pages/radio.tsx",
|
||||
new Set([
|
||||
"src/features/housekeeping/domains/hotel/pages/hotel-command-form",
|
||||
"src/features/housekeeping/domains/hotel/pages/hotel-page-frame",
|
||||
"src/features/housekeeping/domains/hotel/queries/rooms",
|
||||
]),
|
||||
],
|
||||
[
|
||||
"src/features/housekeeping/domains/hotel/pages/room-detail.tsx",
|
||||
new Set([
|
||||
"src/features/housekeeping/domains/hotel/pages/hotel-command-form",
|
||||
"src/features/housekeeping/domains/hotel/pages/hotel-page-frame",
|
||||
"src/features/housekeeping/domains/hotel/queries/rooms",
|
||||
]),
|
||||
],
|
||||
[
|
||||
"src/features/housekeeping/domains/hotel/pages/room-furni.tsx",
|
||||
new Set([
|
||||
"src/features/housekeeping/domains/hotel/pages/hotel-command-form",
|
||||
"src/features/housekeeping/domains/hotel/pages/hotel-page-frame",
|
||||
"src/features/housekeeping/domains/hotel/queries/rooms",
|
||||
]),
|
||||
],
|
||||
[
|
||||
"src/features/housekeeping/domains/hotel/pages/rooms.tsx",
|
||||
new Set([
|
||||
"src/features/housekeeping/domains/hotel/pages/hotel-command-form",
|
||||
"src/features/housekeeping/domains/hotel/pages/hotel-page-frame",
|
||||
"src/features/housekeeping/domains/hotel/queries/rooms",
|
||||
]),
|
||||
],
|
||||
[
|
||||
"src/features/housekeeping/domains/hotel/queries/hotel-production.ts",
|
||||
new Set([
|
||||
"src/features/housekeeping/domains/hotel/queries/rooms",
|
||||
"src/features/housekeeping/domains/hotel/routes",
|
||||
"src/lib/db",
|
||||
"drizzle-orm",
|
||||
]),
|
||||
],
|
||||
[
|
||||
"src/features/housekeeping/domains/hotel/queries/radio.ts",
|
||||
new Set([
|
||||
"src/features/housekeeping/domains/hotel/queries/hotel-production",
|
||||
"src/features/housekeeping/domains/hotel/queries/rooms",
|
||||
"src/lib/services/radio",
|
||||
]),
|
||||
],
|
||||
[
|
||||
"src/features/housekeeping/domains/hotel/queries/rooms.ts",
|
||||
new Set([
|
||||
"src/features/housekeeping/domains/hotel/queries/hotel-production",
|
||||
"src/features/housekeeping/domains/hotel/queries/radio",
|
||||
"src/features/housekeeping/domains/hotel/routes",
|
||||
]),
|
||||
],
|
||||
[
|
||||
"src/features/housekeeping/domains/hotel/route-handlers.ts",
|
||||
new Set([
|
||||
"src/features/housekeeping/domains/hotel/pages/navigation",
|
||||
"src/features/housekeeping/domains/hotel/pages/radio",
|
||||
"src/features/housekeeping/domains/hotel/pages/room-detail",
|
||||
"src/features/housekeeping/domains/hotel/pages/room-furni",
|
||||
"src/features/housekeeping/domains/hotel/pages/rooms",
|
||||
"src/features/housekeeping/domains/hotel/routes",
|
||||
]),
|
||||
],
|
||||
[
|
||||
"src/features/housekeeping/domains/hotel/services/mutations-production.ts",
|
||||
new Set([
|
||||
"src/features/housekeeping/domains/hotel/services/mutations",
|
||||
"src/features/housekeeping/domains/hotel/services/mutations-runtime",
|
||||
"src/lib/db",
|
||||
"src/lib/services/audit",
|
||||
]),
|
||||
],
|
||||
[
|
||||
"src/features/housekeeping/domains/hotel/services/mutations-runtime.ts",
|
||||
new Set([
|
||||
"src/features/housekeeping/domains/hotel/services/mutations",
|
||||
"src/lib/db",
|
||||
"src/lib/services/rcon",
|
||||
"drizzle-orm",
|
||||
]),
|
||||
],
|
||||
[
|
||||
"src/features/housekeeping/domains/hotel/services/mutations.ts",
|
||||
new Set([
|
||||
"src/features/housekeeping/domains/hotel/services/mutations-production",
|
||||
]),
|
||||
],
|
||||
[
|
||||
"src/features/housekeeping/foundation/commands/bootstrap.ts",
|
||||
new Set([
|
||||
"src/features/housekeeping/domains/content/commands/content-commands",
|
||||
"src/features/housekeeping/domains/economy/commands/economy-commands",
|
||||
"src/features/housekeeping/domains/hotel/commands/hotel-commands",
|
||||
"src/features/housekeeping/domains/people/commands/community-commands",
|
||||
"src/features/housekeeping/domains/people/commands/moderation-commands",
|
||||
"src/features/housekeeping/domains/people/commands/support-commands",
|
||||
@@ -715,6 +839,7 @@ const approvedRuntimeImports = new Map<string, ReadonlySet<string>>([
|
||||
new Set([
|
||||
"src/features/housekeeping/domains/content/route-handlers",
|
||||
"src/features/housekeeping/domains/economy/route-handlers",
|
||||
"src/features/housekeeping/domains/hotel/route-handlers",
|
||||
"src/features/housekeeping/domains/people/route-handlers",
|
||||
"src/features/housekeeping/domains/system/route-handlers",
|
||||
]),
|
||||
@@ -1315,7 +1440,7 @@ describe("housekeeping foundation completion contracts", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("creates the real six-domain registry with People, Content, Economy, and System routes enabled", () => {
|
||||
it("creates the real six-domain registry with People, Content, Economy, Hotel, and System routes enabled", () => {
|
||||
const registry = createHousekeepingRegistry(HOUSEKEEPING_MANIFESTS);
|
||||
|
||||
expect(registry.domains.map((domain) => domain.id)).toEqual([
|
||||
@@ -1330,7 +1455,9 @@ describe("housekeeping foundation completion contracts", () => {
|
||||
registry.domains
|
||||
.filter(
|
||||
(domain) =>
|
||||
!["people", "content", "economy", "system"].includes(domain.id),
|
||||
!["people", "content", "economy", "hotel", "system"].includes(
|
||||
domain.id,
|
||||
),
|
||||
)
|
||||
.every((domain) => domain.routes.length === 0),
|
||||
).toBe(true);
|
||||
@@ -1349,6 +1476,11 @@ describe("housekeeping foundation completion contracts", () => {
|
||||
.find((domain) => domain.id === "economy")
|
||||
?.routes.map((route) => route.id),
|
||||
).toEqual(ECONOMY_ROUTE_IDS);
|
||||
expect(
|
||||
registry.domains
|
||||
.find((domain) => domain.id === "hotel")
|
||||
?.routes.map((route) => route.id),
|
||||
).toEqual(HOTEL_PRIMARY_ROUTE_IDS);
|
||||
expect(
|
||||
registry.domains
|
||||
.find((domain) => domain.id === "system")
|
||||
|
||||
@@ -63,6 +63,24 @@ const economyRouteMessageKeys = [
|
||||
"pages.housekeeping.routes.economy.rewards.calendar-detail",
|
||||
] as const;
|
||||
|
||||
const hotelRouteMessageKeys = [
|
||||
"pages.housekeeping.routes.hotel.rooms",
|
||||
"pages.housekeeping.routes.hotel.room-detail",
|
||||
"pages.housekeeping.routes.hotel.room-furni",
|
||||
"pages.housekeeping.routes.hotel.navigation",
|
||||
"pages.housekeeping.routes.hotel.radio.overview",
|
||||
"pages.housekeeping.routes.hotel.radio.settings",
|
||||
"pages.housekeeping.routes.hotel.radio.monitoring",
|
||||
"pages.housekeeping.routes.hotel.radio.moderation",
|
||||
"pages.housekeeping.routes.hotel.radio.autodj",
|
||||
"pages.housekeeping.routes.hotel.radio.history",
|
||||
"pages.housekeeping.routes.hotel.radio.points",
|
||||
"pages.housekeeping.routes.hotel.radio.ranks",
|
||||
"pages.housekeeping.routes.hotel.radio.api-keys",
|
||||
"pages.housekeeping.routes.hotel.radio.banners",
|
||||
"pages.housekeeping.routes.hotel.radio.embed",
|
||||
] as const;
|
||||
|
||||
const expectedDomainMessages = [
|
||||
{
|
||||
id: "operations",
|
||||
@@ -136,6 +154,12 @@ describe("housekeeping localization contract", () => {
|
||||
expect((message as string).trim(), key).not.toBe("");
|
||||
}
|
||||
|
||||
for (const key of hotelRouteMessageKeys) {
|
||||
const message = resolveMessage(messages, key);
|
||||
expect(message, key).toEqual(expect.any(String));
|
||||
expect((message as string).trim(), key).not.toBe("");
|
||||
}
|
||||
|
||||
for (const [index, expected] of expectedDomainMessages.entries()) {
|
||||
const manifest = HOUSEKEEPING_MANIFESTS[index];
|
||||
|
||||
|
||||
@@ -114,6 +114,21 @@ const routeMocks = vi.hoisted(() => {
|
||||
"routes.economy.rewards.calendar": "HK::economy-rewards-calendar",
|
||||
"routes.economy.rewards.calendar-detail":
|
||||
"HK::economy-rewards-calendar-detail",
|
||||
"routes.hotel.rooms": "HK::hotel-rooms",
|
||||
"routes.hotel.room-detail": "HK::hotel-room-detail",
|
||||
"routes.hotel.room-furni": "HK::hotel-room-furni",
|
||||
"routes.hotel.navigation": "HK::hotel-navigation",
|
||||
"routes.hotel.radio.overview": "HK::hotel-radio-overview",
|
||||
"routes.hotel.radio.settings": "HK::hotel-radio-settings",
|
||||
"routes.hotel.radio.monitoring": "HK::hotel-radio-monitoring",
|
||||
"routes.hotel.radio.moderation": "HK::hotel-radio-moderation",
|
||||
"routes.hotel.radio.autodj": "HK::hotel-radio-autodj",
|
||||
"routes.hotel.radio.history": "HK::hotel-radio-history",
|
||||
"routes.hotel.radio.points": "HK::hotel-radio-points",
|
||||
"routes.hotel.radio.ranks": "HK::hotel-radio-ranks",
|
||||
"routes.hotel.radio.api-keys": "HK::hotel-radio-api-keys",
|
||||
"routes.hotel.radio.banners": "HK::hotel-radio-banners",
|
||||
"routes.hotel.radio.embed": "HK::hotel-radio-embed",
|
||||
"states.empty.title": "Localized empty title",
|
||||
"states.empty.description": "Localized empty description",
|
||||
};
|
||||
|
||||
@@ -8,6 +8,7 @@ import { ECONOMY_INBOX_SOURCES } from "../domains/economy/inbox";
|
||||
import { ECONOMY_ROUTES } from "../domains/economy/routes";
|
||||
import { ECONOMY_SEARCH_PROVIDERS } from "../domains/economy/search";
|
||||
import { ECONOMY_WIDGETS } from "../domains/economy/widgets";
|
||||
import { HOTEL_PRIMARY_ROUTES } from "../domains/hotel/routes";
|
||||
import { PEOPLE_INBOX_SOURCES } from "../domains/people/inbox";
|
||||
import { PEOPLE_ROUTES } from "../domains/people/routes";
|
||||
import { PEOPLE_SEARCH_PROVIDERS } from "../domains/people/search";
|
||||
@@ -373,9 +374,11 @@ describe("housekeeping registry", () => {
|
||||
? CONTENT_ROUTES
|
||||
: expected.id === "economy"
|
||||
? ECONOMY_ROUTES
|
||||
: expected.id === "system"
|
||||
? SYSTEM_ROUTES
|
||||
: [],
|
||||
: expected.id === "hotel"
|
||||
? HOTEL_PRIMARY_ROUTES
|
||||
: expected.id === "system"
|
||||
? SYSTEM_ROUTES
|
||||
: [],
|
||||
);
|
||||
expect(actual.searchProviders).toEqual(
|
||||
expected.id === "people"
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { CONTENT_ROUTE_IDS } from "./domains/content/routes";
|
||||
import { ECONOMY_ROUTE_IDS } from "./domains/economy/routes";
|
||||
import { HOTEL_PRIMARY_ROUTE_IDS } from "./domains/hotel/routes";
|
||||
import { PEOPLE_ROUTE_IDS } from "./domains/people/routes";
|
||||
import { SYSTEM_ROUTE_IDS } from "./domains/system/routes";
|
||||
import { createHousekeepingRegistry } from "./foundation/registry";
|
||||
@@ -23,6 +24,7 @@ describe("housekeeping route handlers", () => {
|
||||
...PEOPLE_ROUTE_IDS,
|
||||
...CONTENT_ROUTE_IDS,
|
||||
...ECONOMY_ROUTE_IDS,
|
||||
...HOTEL_PRIMARY_ROUTE_IDS,
|
||||
...SYSTEM_ROUTE_IDS,
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { CONTENT_ROUTE_HANDLERS } from "./domains/content/route-handlers";
|
||||
import { ECONOMY_ROUTE_HANDLERS } from "./domains/economy/route-handlers";
|
||||
import { HOTEL_ROUTE_HANDLERS } from "./domains/hotel/route-handlers";
|
||||
import { PEOPLE_ROUTE_HANDLERS } from "./domains/people/route-handlers";
|
||||
import { SYSTEM_ROUTE_HANDLERS } from "./domains/system/route-handlers";
|
||||
import type { HousekeepingCapabilityContext } from "./foundation/contracts";
|
||||
@@ -24,5 +25,6 @@ export const HOUSEKEEPING_ROUTE_HANDLERS: readonly HousekeepingRouteHandler[] =
|
||||
...PEOPLE_ROUTE_HANDLERS,
|
||||
...CONTENT_ROUTE_HANDLERS,
|
||||
...ECONOMY_ROUTE_HANDLERS,
|
||||
...HOTEL_ROUTE_HANDLERS,
|
||||
...SYSTEM_ROUTE_HANDLERS,
|
||||
]);
|
||||
@@ -3351,6 +3351,25 @@
|
||||
"calendar-detail": "Calendar campaign"
|
||||
}
|
||||
},
|
||||
"hotel": {
|
||||
"rooms": "Rooms",
|
||||
"room-detail": "Room details",
|
||||
"room-furni": "Room furniture",
|
||||
"navigation": "Hotel navigation",
|
||||
"radio": {
|
||||
"overview": "Radio overview",
|
||||
"settings": "Radio settings",
|
||||
"monitoring": "Live monitoring",
|
||||
"moderation": "Radio moderation",
|
||||
"autodj": "AutoDJ",
|
||||
"history": "Radio history",
|
||||
"points": "Listener points",
|
||||
"ranks": "Radio ranks",
|
||||
"api-keys": "API keys",
|
||||
"banners": "Radio banners",
|
||||
"embed": "Embed tools"
|
||||
}
|
||||
},
|
||||
"system": {
|
||||
"access": {
|
||||
"permissions": "Permissions",
|
||||
|
||||
@@ -3351,6 +3351,25 @@
|
||||
"calendar-detail": "Campagna calendario"
|
||||
}
|
||||
},
|
||||
"hotel": {
|
||||
"rooms": "Stanze",
|
||||
"room-detail": "Dettagli stanza",
|
||||
"room-furni": "Arredi della stanza",
|
||||
"navigation": "Navigazione hotel",
|
||||
"radio": {
|
||||
"overview": "Panoramica radio",
|
||||
"settings": "Impostazioni radio",
|
||||
"monitoring": "Monitoraggio in tempo reale",
|
||||
"moderation": "Moderazione radio",
|
||||
"autodj": "AutoDJ",
|
||||
"history": "Cronologia radio",
|
||||
"points": "Punti ascoltatori",
|
||||
"ranks": "Ruoli radio",
|
||||
"api-keys": "Chiavi API",
|
||||
"banners": "Banner radio",
|
||||
"embed": "Strumenti di incorporamento"
|
||||
}
|
||||
},
|
||||
"system": {
|
||||
"access": {
|
||||
"permissions": "Permessi",
|
||||
|
||||
Reference in new issue
Block a user