feat(housekeeping): deliver hotel operations

This commit is contained in:
Simo committed 2026-08-30 14:34:26 +02:00
1 parent bfc5bd78a1
commit abc707cfb2
43 files changed
+3749 -615

No files matched your search

+16 -112
View File
@@ -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");
}
+24 -114
View File
@@ -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");
}
+72 -187
View File
@@ -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");
}
+4 -35
View File
@@ -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");
}
+24 -78
View File
@@ -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");
}
+167
View File
@@ -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
View File
@@ -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,
]);
+19
View File
@@ -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",
+19
View File
@@ -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",