diff --git a/src/actions/admin-radio-api-keys.ts b/src/actions/admin-radio-api-keys.ts index e89a88eede..98d67c3ed7 100644 --- a/src/actions/admin-radio-api-keys.ts +++ b/src/actions/admin-radio-api-keys.ts @@ -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 { 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 { 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 { 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"); } diff --git a/src/actions/admin-radio-autodj.ts b/src/actions/admin-radio-autodj.ts index 5671873f33..4867e5675b 100644 --- a/src/actions/admin-radio-autodj.ts +++ b/src/actions/admin-radio-autodj.ts @@ -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 { 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 { 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 { 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"); } diff --git a/src/actions/admin-radio-extra.ts b/src/actions/admin-radio-extra.ts index ca8ec20114..a851aebdb0 100644 --- a/src/actions/admin-radio-extra.ts +++ b/src/actions/admin-radio-extra.ts @@ -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 { - 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 { - 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 { - 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 { - 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 { - 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 { - 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 { - 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 { - 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"); } diff --git a/src/actions/admin-radio-moderation.ts b/src/actions/admin-radio-moderation.ts index 3472c1e3b6..9c26c40719 100644 --- a/src/actions/admin-radio-moderation.ts +++ b/src/actions/admin-radio-moderation.ts @@ -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 { 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"); } diff --git a/src/actions/admin-radio-points.ts b/src/actions/admin-radio-points.ts index 44d9febad4..345da209cb 100644 --- a/src/actions/admin-radio-points.ts +++ b/src/actions/admin-radio-points.ts @@ -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 { 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"); } diff --git a/src/actions/hotel-legacy-wrappers.test.ts b/src/actions/hotel-legacy-wrappers.test.ts new file mode 100644 index 0000000000..7f6fb4f6ba --- /dev/null +++ b/src/actions/hotel-legacy-wrappers.test.ts @@ -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>): 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"'); + } + }); +}); diff --git a/src/actions/rooms.ts b/src/actions/rooms.ts index 43381dddb8..231af9afb3 100644 --- a/src/actions/rooms.ts +++ b/src/actions/rooms.ts @@ -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) { 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) - .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}`); } diff --git a/src/features/housekeeping/domains/hotel/commands/asset-commands.ts b/src/features/housekeeping/domains/hotel/commands/asset-commands.ts new file mode 100644 index 0000000000..eea9d2c230 --- /dev/null +++ b/src/features/housekeeping/domains/hotel/commands/asset-commands.ts @@ -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); diff --git a/src/features/housekeeping/domains/hotel/commands/hotel-commands.test.ts b/src/features/housekeeping/domains/hotel/commands/hotel-commands.test.ts new file mode 100644 index 0000000000..c9c08e4186 --- /dev/null +++ b/src/features/housekeeping/domains/hotel/commands/hotel-commands.test.ts @@ -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); + }); +}); diff --git a/src/features/housekeeping/domains/hotel/commands/hotel-commands.ts b/src/features/housekeeping/domains/hotel/commands/hotel-commands.ts new file mode 100644 index 0000000000..0d1fc0b1d6 --- /dev/null +++ b/src/features/housekeeping/domains/hotel/commands/hotel-commands.ts @@ -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([ + "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, unknown> & { + readonly operation: HotelMutationOperation; +}; + +const commandInput = z.object({}).catchall(z.unknown()); + +export function createHotelCommands( + service: Pick, +): 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); diff --git a/src/features/housekeeping/domains/hotel/commands/radio-commands.ts b/src/features/housekeeping/domains/hotel/commands/radio-commands.ts new file mode 100644 index 0000000000..8b2027acc6 --- /dev/null +++ b/src/features/housekeeping/domains/hotel/commands/radio-commands.ts @@ -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; diff --git a/src/features/housekeeping/domains/hotel/commands/room-commands.ts b/src/features/housekeeping/domains/hotel/commands/room-commands.ts new file mode 100644 index 0000000000..0e328f97d2 --- /dev/null +++ b/src/features/housekeeping/domains/hotel/commands/room-commands.ts @@ -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; diff --git a/src/features/housekeeping/domains/hotel/manifest.ts b/src/features/housekeeping/domains/hotel/manifest.ts index 1c89cd05af..bfc3de75f1 100644 --- a/src/features/housekeeping/domains/hotel/manifest.ts +++ b/src/features/housekeeping/domains/hotel/manifest.ts @@ -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: [], diff --git a/src/features/housekeeping/domains/hotel/pages/hotel-command-form.tsx b/src/features/housekeeping/domains/hotel/pages/hotel-command-form.tsx new file mode 100644 index 0000000000..60b9aaea2f --- /dev/null +++ b/src/features/housekeeping/domains/hotel/pages/hotel-command-form.tsx @@ -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>; + readonly fields?: readonly HotelCommandField[]; + readonly requiresReason?: boolean; +} + +const initialState: HousekeepingResult | 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 | null, + formData: FormData, +): Promise> { + 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 ( +
+ {fields.map((field) => { + const id = `hotel-${commandId}-${field.name}`; + return ( +