feat(housekeeping): deliver hotel operations
This commit is contained in:
1 parent
bfc5bd78a1
commit
abc707cfb2
43 files changed
+3736
-602
No files matched your search
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -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),
|
||||
await executeLegacyHotelMutation(staff, "radio.shout.delete", {
|
||||
id: String(formData.get("id") ?? "").trim(),
|
||||
});
|
||||
} catch {
|
||||
// Row may already be gone; ignore so the action does not throw.
|
||||
}
|
||||
|
||||
revalidatePath("/admin/radio/moderation");
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
+17
-83
File diff suppressed because it is too large.
Load diff
@@ -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);
|
||||
File diff suppressed because it is too large.
Load diff
@@ -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: [],
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -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),
|
||||
};
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -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"
|
||||
/>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -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"
|
||||
/>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -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);
|
||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -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"] : [],
|
||||
};
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -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);
|
||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -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();
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large.
Load diff
@@ -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,
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -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,6 +374,8 @@ describe("housekeeping registry", () => {
|
||||
? CONTENT_ROUTES
|
||||
: expected.id === "economy"
|
||||
? ECONOMY_ROUTES
|
||||
: expected.id === "hotel"
|
||||
? HOTEL_PRIMARY_ROUTES
|
||||
: expected.id === "system"
|
||||
? SYSTEM_ROUTES
|
||||
: [],
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { CONTENT_ROUTE_IDS } from "./domains/content/routes";
|
||||
import { ECONOMY_ROUTE_IDS } from "./domains/economy/routes";
|
||||
import { HOTEL_PRIMARY_ROUTE_IDS } from "./domains/hotel/routes";
|
||||
import { PEOPLE_ROUTE_IDS } from "./domains/people/routes";
|
||||
import { SYSTEM_ROUTE_IDS } from "./domains/system/routes";
|
||||
import { createHousekeepingRegistry } from "./foundation/registry";
|
||||
@@ -23,6 +24,7 @@ describe("housekeeping route handlers", () => {
|
||||
...PEOPLE_ROUTE_IDS,
|
||||
...CONTENT_ROUTE_IDS,
|
||||
...ECONOMY_ROUTE_IDS,
|
||||
...HOTEL_PRIMARY_ROUTE_IDS,
|
||||
...SYSTEM_ROUTE_IDS,
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { CONTENT_ROUTE_HANDLERS } from "./domains/content/route-handlers";
|
||||
import { ECONOMY_ROUTE_HANDLERS } from "./domains/economy/route-handlers";
|
||||
import { HOTEL_ROUTE_HANDLERS } from "./domains/hotel/route-handlers";
|
||||
import { PEOPLE_ROUTE_HANDLERS } from "./domains/people/route-handlers";
|
||||
import { SYSTEM_ROUTE_HANDLERS } from "./domains/system/route-handlers";
|
||||
import type { HousekeepingCapabilityContext } from "./foundation/contracts";
|
||||
@@ -24,5 +25,6 @@ export const HOUSEKEEPING_ROUTE_HANDLERS: readonly HousekeepingRouteHandler[] =
|
||||
...PEOPLE_ROUTE_HANDLERS,
|
||||
...CONTENT_ROUTE_HANDLERS,
|
||||
...ECONOMY_ROUTE_HANDLERS,
|
||||
...HOTEL_ROUTE_HANDLERS,
|
||||
...SYSTEM_ROUTE_HANDLERS,
|
||||
]);
|
||||
@@ -3351,6 +3351,25 @@
|
||||
"calendar-detail": "Calendar campaign"
|
||||
}
|
||||
},
|
||||
"hotel": {
|
||||
"rooms": "Rooms",
|
||||
"room-detail": "Room details",
|
||||
"room-furni": "Room furniture",
|
||||
"navigation": "Hotel navigation",
|
||||
"radio": {
|
||||
"overview": "Radio overview",
|
||||
"settings": "Radio settings",
|
||||
"monitoring": "Live monitoring",
|
||||
"moderation": "Radio moderation",
|
||||
"autodj": "AutoDJ",
|
||||
"history": "Radio history",
|
||||
"points": "Listener points",
|
||||
"ranks": "Radio ranks",
|
||||
"api-keys": "API keys",
|
||||
"banners": "Radio banners",
|
||||
"embed": "Embed tools"
|
||||
}
|
||||
},
|
||||
"system": {
|
||||
"access": {
|
||||
"permissions": "Permissions",
|
||||
|
||||
@@ -3351,6 +3351,25 @@
|
||||
"calendar-detail": "Campagna calendario"
|
||||
}
|
||||
},
|
||||
"hotel": {
|
||||
"rooms": "Stanze",
|
||||
"room-detail": "Dettagli stanza",
|
||||
"room-furni": "Arredi della stanza",
|
||||
"navigation": "Navigazione hotel",
|
||||
"radio": {
|
||||
"overview": "Panoramica radio",
|
||||
"settings": "Impostazioni radio",
|
||||
"monitoring": "Monitoraggio in tempo reale",
|
||||
"moderation": "Moderazione radio",
|
||||
"autodj": "AutoDJ",
|
||||
"history": "Cronologia radio",
|
||||
"points": "Punti ascoltatori",
|
||||
"ranks": "Ruoli radio",
|
||||
"api-keys": "Chiavi API",
|
||||
"banners": "Banner radio",
|
||||
"embed": "Strumenti di incorporamento"
|
||||
}
|
||||
},
|
||||
"system": {
|
||||
"access": {
|
||||
"permissions": "Permessi",
|
||||
|
||||
Reference in new issue
Block a user