feat(housekeeping): deliver hotel operations
CI / check (pull_request) Failing after 36s
CI / release (pull_request) Skipped
CI / deploy (pull_request) Skipped

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

+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");
}