fix: converge legacy room mutations
This commit is contained in:
1 parent
8e54cdbc6a
commit
184052fbce
5 files changed
+651
-125
No files matched your search
@@ -0,0 +1,493 @@
|
|||||||
|
import { getTableName, type SQL } from "drizzle-orm";
|
||||||
|
import { MySqlDialect } from "drizzle-orm/mysql-core";
|
||||||
|
import { revalidatePath } from "next/cache";
|
||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { logAudit } from "@/lib/services/audit";
|
||||||
|
import { rcon } from "@/lib/services/rcon";
|
||||||
|
import { logStaffActivity } from "@/lib/services/staff-activity";
|
||||||
|
import { notify } from "@/lib/services/webhook";
|
||||||
|
|
||||||
|
type RecordedWrite = {
|
||||||
|
kind: "update" | "delete";
|
||||||
|
table: string;
|
||||||
|
values?: Record<string, unknown>;
|
||||||
|
condition: { sql: string; params: unknown[] };
|
||||||
|
};
|
||||||
|
|
||||||
|
const testState = vi.hoisted(() => ({
|
||||||
|
selectRows: [] as Array<Array<Record<string, unknown>>>,
|
||||||
|
readConditions: [] as Array<{ sql: string; params: unknown[] }>,
|
||||||
|
readLocks: [] as string[],
|
||||||
|
readOrders: [] as Array<{ sql: string; params: unknown[] }>,
|
||||||
|
committedWrites: [] as RecordedWrite[],
|
||||||
|
stagedWrites: null as RecordedWrite[] | null,
|
||||||
|
transactionCount: 0,
|
||||||
|
capabilityAllowed: true,
|
||||||
|
databaseWriteFailure: false,
|
||||||
|
auditFailure: false,
|
||||||
|
notifyFailure: false,
|
||||||
|
auditEntries: [] as Array<{
|
||||||
|
entry: Record<string, unknown>;
|
||||||
|
transactional: boolean;
|
||||||
|
}>,
|
||||||
|
rconSend: vi.fn(),
|
||||||
|
revalidate: vi.fn(),
|
||||||
|
notify: vi.fn(),
|
||||||
|
staffActivity: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const dialect = new MySqlDialect();
|
||||||
|
|
||||||
|
function renderCondition(condition: SQL | undefined) {
|
||||||
|
if (!condition) return { sql: "", params: [] };
|
||||||
|
const query = dialect.sqlToQuery(condition);
|
||||||
|
return { sql: query.sql, params: [...query.params] };
|
||||||
|
}
|
||||||
|
|
||||||
|
function nextRows() {
|
||||||
|
return testState.selectRows.shift() ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
|
function recordWrite(write: RecordedWrite) {
|
||||||
|
if (testState.databaseWriteFailure) throw new Error("database unavailable");
|
||||||
|
if (testState.stagedWrites) testState.stagedWrites.push(write);
|
||||||
|
else testState.committedWrites.push(write);
|
||||||
|
}
|
||||||
|
|
||||||
|
vi.mock("next/cache", () => ({ revalidatePath: testState.revalidate }));
|
||||||
|
vi.mock("@/lib/safe-action-shared", () => ({
|
||||||
|
actionOk: (data?: Record<string, unknown>) => ({
|
||||||
|
ok: true,
|
||||||
|
data: data ?? {},
|
||||||
|
}),
|
||||||
|
handleActionError: () => ({ ok: false, error: "Internal server error" }),
|
||||||
|
}));
|
||||||
|
vi.mock("@/lib/permissions", async () => import("@/lib/permission-slugs"));
|
||||||
|
vi.mock("@/lib/admin/guard", () => ({
|
||||||
|
requirePermission: async () => ({ id: 42, rank: 7, username: "operator" }),
|
||||||
|
}));
|
||||||
|
vi.mock("@/features/housekeeping/foundation/correlation", () => ({
|
||||||
|
createCorrelationId: () => "legacy-room-correlation",
|
||||||
|
}));
|
||||||
|
vi.mock("@/features/housekeeping/foundation/server-capability-context", () => ({
|
||||||
|
getHousekeepingCapabilityContext: async () => ({
|
||||||
|
actor: { id: 42, username: "operator", rank: 7 },
|
||||||
|
isSuperAdmin: false,
|
||||||
|
has: () => testState.capabilityAllowed,
|
||||||
|
hasAny: () => testState.capabilityAllowed,
|
||||||
|
hasAll: () => testState.capabilityAllowed,
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
vi.mock("@/lib/services/rcon", () => ({
|
||||||
|
rcon: { send: testState.rconSend },
|
||||||
|
}));
|
||||||
|
vi.mock("@/lib/services/audit", () => ({
|
||||||
|
logAudit: vi.fn(
|
||||||
|
async (entry: Record<string, unknown>, transaction?: unknown) => {
|
||||||
|
if (testState.auditFailure) throw new Error("audit unavailable");
|
||||||
|
testState.auditEntries.push({
|
||||||
|
entry,
|
||||||
|
transactional: transaction !== undefined,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
vi.mock("@/lib/services/webhook", () => ({
|
||||||
|
notify: testState.notify.mockImplementation(() => {
|
||||||
|
if (testState.notifyFailure) throw new Error("webhook unavailable");
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
vi.mock("@/lib/services/staff-activity", () => ({
|
||||||
|
logStaffActivity: testState.staffActivity,
|
||||||
|
}));
|
||||||
|
vi.mock("@/lib/db", async () => {
|
||||||
|
const schema = await import("@/db/schema");
|
||||||
|
const database = {
|
||||||
|
select: vi.fn(() => {
|
||||||
|
const query = {
|
||||||
|
from: vi.fn(() => query),
|
||||||
|
where: vi.fn((condition: SQL | undefined) => {
|
||||||
|
testState.readConditions.push(renderCondition(condition));
|
||||||
|
const rows = nextRows();
|
||||||
|
type QueryResult = Promise<typeof rows> & {
|
||||||
|
limit: (count: number) => QueryResult;
|
||||||
|
orderBy: (order: SQL) => QueryResult;
|
||||||
|
for: (lock: string) => QueryResult;
|
||||||
|
};
|
||||||
|
const result = Promise.resolve(rows) as QueryResult;
|
||||||
|
result.limit = vi.fn(() => result);
|
||||||
|
result.orderBy = vi.fn((order) => {
|
||||||
|
testState.readOrders.push(renderCondition(order));
|
||||||
|
return result;
|
||||||
|
});
|
||||||
|
result.for = vi.fn((lock) => {
|
||||||
|
testState.readLocks.push(lock);
|
||||||
|
return result;
|
||||||
|
});
|
||||||
|
return result;
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
return query;
|
||||||
|
}),
|
||||||
|
update: vi.fn((table: object) => ({
|
||||||
|
set: (values: Record<string, unknown>) => ({
|
||||||
|
where: async (condition: SQL | undefined) => {
|
||||||
|
recordWrite({
|
||||||
|
kind: "update",
|
||||||
|
table: getTableName(table as never),
|
||||||
|
values,
|
||||||
|
condition: renderCondition(condition),
|
||||||
|
});
|
||||||
|
return [{ affectedRows: 1 }];
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
})),
|
||||||
|
delete: vi.fn((table: object) => ({
|
||||||
|
where: async (condition: SQL | undefined) => {
|
||||||
|
recordWrite({
|
||||||
|
kind: "delete",
|
||||||
|
table: getTableName(table as never),
|
||||||
|
condition: renderCondition(condition),
|
||||||
|
});
|
||||||
|
return [{ affectedRows: 1 }];
|
||||||
|
},
|
||||||
|
})),
|
||||||
|
transaction: vi.fn(
|
||||||
|
async (run: (transaction: unknown) => Promise<unknown>) => {
|
||||||
|
testState.transactionCount += 1;
|
||||||
|
const staged: RecordedWrite[] = [];
|
||||||
|
testState.stagedWrites = staged;
|
||||||
|
try {
|
||||||
|
const result = await run(database);
|
||||||
|
testState.committedWrites.push(...staged);
|
||||||
|
return result;
|
||||||
|
} finally {
|
||||||
|
testState.stagedWrites = null;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
};
|
||||||
|
return { ...schema, db: database };
|
||||||
|
});
|
||||||
|
|
||||||
|
import {
|
||||||
|
bulkDeleteRoomItems,
|
||||||
|
deleteRoom,
|
||||||
|
deleteRoomItem,
|
||||||
|
roomRconAction,
|
||||||
|
updateRoom,
|
||||||
|
updateRoomItem,
|
||||||
|
} from "./rooms";
|
||||||
|
|
||||||
|
function itemRow(overrides: Record<string, unknown> = {}) {
|
||||||
|
return {
|
||||||
|
id: 11,
|
||||||
|
userId: 90,
|
||||||
|
roomId: 7,
|
||||||
|
itemId: 1001,
|
||||||
|
wallPos: "",
|
||||||
|
x: 1,
|
||||||
|
y: 2,
|
||||||
|
z: 0,
|
||||||
|
rot: 0,
|
||||||
|
extraData: "",
|
||||||
|
wiredData: "",
|
||||||
|
limitedData: "0:0",
|
||||||
|
guildId: 0,
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function roomRow(overrides: Record<string, unknown> = {}) {
|
||||||
|
return {
|
||||||
|
id: 7,
|
||||||
|
ownerId: 90,
|
||||||
|
ownerName: "owner",
|
||||||
|
name: "Test room",
|
||||||
|
description: "Before",
|
||||||
|
state: "open",
|
||||||
|
usersMax: 25,
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function expectFailure(result: unknown, text: RegExp) {
|
||||||
|
expect(result).toMatchObject({
|
||||||
|
ok: false,
|
||||||
|
error: expect.stringMatching(text),
|
||||||
|
});
|
||||||
|
expect(result).toMatchObject({
|
||||||
|
error: expect.stringContaining("legacy-room-correlation"),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
testState.selectRows = [];
|
||||||
|
testState.readConditions = [];
|
||||||
|
testState.readLocks = [];
|
||||||
|
testState.readOrders = [];
|
||||||
|
testState.committedWrites = [];
|
||||||
|
testState.stagedWrites = null;
|
||||||
|
testState.transactionCount = 0;
|
||||||
|
testState.capabilityAllowed = true;
|
||||||
|
testState.databaseWriteFailure = false;
|
||||||
|
testState.auditFailure = false;
|
||||||
|
testState.notifyFailure = false;
|
||||||
|
testState.auditEntries = [];
|
||||||
|
vi.clearAllMocks();
|
||||||
|
testState.rconSend.mockResolvedValue(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("legacy room actions through the Hotel mutation service", () => {
|
||||||
|
it.each([
|
||||||
|
[
|
||||||
|
"floor",
|
||||||
|
{ roomId: 7, itemId: 11, x: 4, y: 5, z: 1.25, rot: 6, extraData: "on" },
|
||||||
|
{ x: 4, y: 5, z: 1.25, rot: 6, extraData: "on" },
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"wall",
|
||||||
|
{ roomId: 7, itemId: 11, wallPos: ":w=1,2 l=3,4 r", extraData: "red" },
|
||||||
|
{ wallPos: ":w=1,2 l=3,4 r", extraData: "red" },
|
||||||
|
],
|
||||||
|
])(
|
||||||
|
"updates a valid %s item transactionally",
|
||||||
|
async (_kind, payload, changes) => {
|
||||||
|
testState.selectRows = [[itemRow()]];
|
||||||
|
const result = await updateRoomItem(payload);
|
||||||
|
expect(result).toMatchObject({ ok: true });
|
||||||
|
expect(testState.transactionCount).toBe(1);
|
||||||
|
expect(testState.committedWrites).toEqual([
|
||||||
|
expect.objectContaining({
|
||||||
|
kind: "update",
|
||||||
|
table: "items",
|
||||||
|
values: changes,
|
||||||
|
condition: {
|
||||||
|
sql: expect.stringMatching(/`items`\.`id`.*`items`\.`room_id`/),
|
||||||
|
params: [11, 7],
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
expect(logAudit).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
action: "hotel.room-item.update",
|
||||||
|
correlationId: "legacy-room-correlation",
|
||||||
|
outcome: "success",
|
||||||
|
}),
|
||||||
|
expect.anything(),
|
||||||
|
);
|
||||||
|
expect(logStaffActivity).not.toHaveBeenCalled();
|
||||||
|
expect(testState.readLocks).toEqual(["update"]);
|
||||||
|
expect(revalidatePath).toHaveBeenCalledWith("/admin/rooms/7/furni");
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
it.each(["userId", "type", "ownerId"])(
|
||||||
|
"rejects the forbidden room-item field %s",
|
||||||
|
async (field) => {
|
||||||
|
const result = await updateRoomItem({
|
||||||
|
roomId: 7,
|
||||||
|
itemId: 11,
|
||||||
|
[field]: "forbidden",
|
||||||
|
});
|
||||||
|
expectFailure(result, /invalid/i);
|
||||||
|
expect(testState.committedWrites).toEqual([]);
|
||||||
|
expect(revalidatePath).not.toHaveBeenCalled();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
["update", () => updateRoom({ id: 0, name: "Invalid" })],
|
||||||
|
["delete", () => deleteRoom({ id: -1 })],
|
||||||
|
["item delete", () => deleteRoomItem({ roomId: 7, itemId: 0 })],
|
||||||
|
])("rejects an invalid id for %s", async (_name, action) => {
|
||||||
|
const result = await action();
|
||||||
|
expectFailure(result, /invalid/i);
|
||||||
|
expect(testState.committedWrites).toEqual([]);
|
||||||
|
expect(revalidatePath).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects an item that is not in the supplied room", async () => {
|
||||||
|
testState.selectRows = [[]];
|
||||||
|
const result = await deleteRoomItem({ roomId: 7, itemId: 11 });
|
||||||
|
expectFailure(result, /not found/i);
|
||||||
|
expect(testState.readConditions).toEqual([
|
||||||
|
{
|
||||||
|
sql: expect.stringMatching(/`items`\.`id`.*`items`\.`room_id`/),
|
||||||
|
params: [11, 7],
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
expect(testState.readLocks).toEqual(["update"]);
|
||||||
|
expect(testState.committedWrites).toEqual([]);
|
||||||
|
expect(revalidatePath).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects an incomplete bulk selection atomically", async () => {
|
||||||
|
testState.selectRows = [[itemRow({ id: 11 })]];
|
||||||
|
const result = await bulkDeleteRoomItems({
|
||||||
|
roomId: 7,
|
||||||
|
itemIds: [11, 12],
|
||||||
|
});
|
||||||
|
expectFailure(result, /not found/i);
|
||||||
|
expect(testState.readConditions).toEqual([
|
||||||
|
{
|
||||||
|
sql: expect.stringMatching(/`items`\.`room_id`.*`items`\.`id` in/),
|
||||||
|
params: [7, 11, 12],
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
expect(testState.readOrders).toEqual([
|
||||||
|
{ sql: expect.stringMatching(/`items`\.`id` asc/), params: [] },
|
||||||
|
]);
|
||||||
|
expect(testState.readLocks).toEqual(["update"]);
|
||||||
|
expect(testState.committedWrites).toEqual([]);
|
||||||
|
expect(revalidatePath).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("deduplicates a bounded bulk selection before deleting", async () => {
|
||||||
|
testState.selectRows = [[itemRow({ id: 11 }), itemRow({ id: 12 })]];
|
||||||
|
const result = await bulkDeleteRoomItems({
|
||||||
|
roomId: 7,
|
||||||
|
itemIds: [11, 11, 12],
|
||||||
|
});
|
||||||
|
expect(result).toMatchObject({ ok: true });
|
||||||
|
expect(testState.committedWrites).toEqual([
|
||||||
|
expect.objectContaining({
|
||||||
|
kind: "delete",
|
||||||
|
table: "items",
|
||||||
|
condition: {
|
||||||
|
sql: expect.stringMatching(/`items`\.`room_id`.*`items`\.`id` in/),
|
||||||
|
params: [7, 11, 12],
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
expect(testState.readOrders).toEqual([
|
||||||
|
{ sql: expect.stringMatching(/`items`\.`id` asc/), params: [] },
|
||||||
|
]);
|
||||||
|
expect(testState.readLocks).toEqual(["update"]);
|
||||||
|
expect(revalidatePath).toHaveBeenCalledWith("/admin/rooms/7/furni");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects a bulk selection over the service bound", async () => {
|
||||||
|
const result = await bulkDeleteRoomItems({
|
||||||
|
roomId: 7,
|
||||||
|
itemIds: Array.from({ length: 501 }, (_, index) => index + 1),
|
||||||
|
});
|
||||||
|
expectFailure(result, /invalid/i);
|
||||||
|
expect(testState.committedWrites).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns a denied capability as an actionable action failure", async () => {
|
||||||
|
testState.capabilityAllowed = false;
|
||||||
|
const result = await updateRoom({ id: 7, name: "Denied" });
|
||||||
|
expectFailure(result, /permission/i);
|
||||||
|
expect(testState.transactionCount).toBe(0);
|
||||||
|
expect(revalidatePath).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rolls back a room update when its success audit fails", async () => {
|
||||||
|
testState.selectRows = [[roomRow()]];
|
||||||
|
testState.auditFailure = true;
|
||||||
|
const result = await updateRoom({ id: 7, name: "After" });
|
||||||
|
expectFailure(result, /could not be completed/i);
|
||||||
|
expect(testState.transactionCount).toBe(1);
|
||||||
|
expect(testState.committedWrites).toEqual([]);
|
||||||
|
expect(revalidatePath).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not audit or revalidate a failed database write", async () => {
|
||||||
|
testState.selectRows = [[roomRow()]];
|
||||||
|
testState.databaseWriteFailure = true;
|
||||||
|
const result = await updateRoom({ id: 7, description: "After" });
|
||||||
|
expectFailure(result, /could not be completed/i);
|
||||||
|
expect(testState.auditEntries).toEqual([]);
|
||||||
|
expect(testState.committedWrites).toEqual([]);
|
||||||
|
expect(revalidatePath).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("updates a room through the transactional Hotel path", async () => {
|
||||||
|
testState.selectRows = [[roomRow()]];
|
||||||
|
const result = await updateRoom({
|
||||||
|
id: 7,
|
||||||
|
name: "After",
|
||||||
|
description: "Changed",
|
||||||
|
state: "locked",
|
||||||
|
usersMax: 50,
|
||||||
|
});
|
||||||
|
expect(result).toMatchObject({ ok: true });
|
||||||
|
expect(testState.committedWrites).toEqual([
|
||||||
|
expect.objectContaining({
|
||||||
|
kind: "update",
|
||||||
|
table: "rooms",
|
||||||
|
values: {
|
||||||
|
name: "After",
|
||||||
|
description: "Changed",
|
||||||
|
state: "locked",
|
||||||
|
usersMax: 50,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
expect(testState.readLocks).toEqual(["update"]);
|
||||||
|
expect(revalidatePath).toHaveBeenCalledWith("/admin/rooms/7");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("commits room deletion even when its best-effort webhook throws", async () => {
|
||||||
|
testState.selectRows = [[roomRow()]];
|
||||||
|
testState.notifyFailure = true;
|
||||||
|
const result = await deleteRoom({ id: 7 });
|
||||||
|
expect(result).toMatchObject({ ok: true });
|
||||||
|
expect(testState.committedWrites).toEqual([
|
||||||
|
expect.objectContaining({ kind: "delete", table: "rooms" }),
|
||||||
|
]);
|
||||||
|
expect(notify).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
action: "room_delete",
|
||||||
|
actor: "operator",
|
||||||
|
target: "Test room",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(testState.readLocks).toEqual(["update"]);
|
||||||
|
expect(revalidatePath).toHaveBeenCalledWith("/admin/rooms");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects invalid runtime actions before RCON delivery", async () => {
|
||||||
|
const result = await roomRconAction({ roomId: 7, action: "explode" });
|
||||||
|
expectFailure(result, /invalid/i);
|
||||||
|
expect(rcon.send).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns failed RCON delivery instead of false success", async () => {
|
||||||
|
testState.rconSend.mockResolvedValue(false);
|
||||||
|
const result = await roomRconAction({ roomId: 7, action: "reload" });
|
||||||
|
expectFailure(result, /could not be completed/i);
|
||||||
|
expect(testState.auditEntries.map(({ entry }) => entry.outcome)).toEqual([
|
||||||
|
"intent",
|
||||||
|
"failure",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
["reload", "reloadroom", { room_id: 7 }],
|
||||||
|
["kick", "kickall", { room_id: 7 }],
|
||||||
|
["lock", "updateroom", { room_id: 7, state: "locked" }],
|
||||||
|
["unlock", "updateroom", { room_id: 7, state: "open" }],
|
||||||
|
])(
|
||||||
|
"delivers the valid %s runtime action",
|
||||||
|
async (action, command, payload) => {
|
||||||
|
const result = await roomRconAction({ roomId: 7, action });
|
||||||
|
expect(result).toMatchObject({ ok: true });
|
||||||
|
expect(rcon.send).toHaveBeenCalledWith(command, payload);
|
||||||
|
expect(testState.auditEntries.map(({ entry }) => entry.outcome)).toEqual([
|
||||||
|
"intent",
|
||||||
|
"success",
|
||||||
|
]);
|
||||||
|
expect(
|
||||||
|
testState.auditEntries.every(({ transactional }) => !transactional),
|
||||||
|
).toBe(true);
|
||||||
|
if (action === "kick") {
|
||||||
|
expect(notify).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ action: "kick", target: "7" }),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
+141
-118
@@ -1,140 +1,163 @@
|
|||||||
"use server";
|
"use server";
|
||||||
|
|
||||||
import { and, eq, inArray } from "drizzle-orm";
|
|
||||||
import { revalidatePath } from "next/cache";
|
import { revalidatePath } from "next/cache";
|
||||||
|
import {
|
||||||
|
executeLegacyHotelMutation,
|
||||||
|
HotelMutationFailure,
|
||||||
|
} from "@/features/housekeeping/domains/hotel/services/mutations";
|
||||||
|
import type { HousekeepingErrorCode } from "@/features/housekeeping/foundation/contracts";
|
||||||
import { requirePermission } from "@/lib/admin/guard";
|
import { requirePermission } from "@/lib/admin/guard";
|
||||||
import { db, Items, Rooms } from "@/lib/db";
|
|
||||||
import { PERMS } from "@/lib/permissions";
|
import { PERMS } from "@/lib/permissions";
|
||||||
import { rcon } from "@/lib/services/rcon";
|
import {
|
||||||
import { logStaffActivity } from "@/lib/services/staff-activity";
|
type ActionResult,
|
||||||
|
actionOk,
|
||||||
|
handleActionError,
|
||||||
|
} from "@/lib/safe-action-shared";
|
||||||
import { notify } from "@/lib/services/webhook";
|
import { notify } from "@/lib/services/webhook";
|
||||||
|
|
||||||
export async function updateRoomItem(payload: Record<string, unknown>) {
|
const HOTEL_ACTION_ERRORS = {
|
||||||
const staff = await requirePermission(PERMS.ROOMS_EDIT);
|
UNAUTHENTICATED: "Your session expired. Sign in again and retry.",
|
||||||
const { roomId, itemId, ...data } = payload as {
|
FORBIDDEN: "You do not have permission to perform this room action.",
|
||||||
roomId: number;
|
VALIDATION: "Invalid room action. Check the supplied fields and try again.",
|
||||||
itemId: number;
|
NOT_FOUND: "The room or furniture item was not found in the supplied room.",
|
||||||
[key: string]: unknown;
|
CONFLICT: "The room changed before this action completed. Refresh and retry.",
|
||||||
|
RATE_LIMITED: "Too many room actions. Wait a moment and retry.",
|
||||||
|
DEPENDENCY_UNAVAILABLE:
|
||||||
|
"The room action could not be completed. Check the database or emulator connection and try again.",
|
||||||
|
TIMEOUT:
|
||||||
|
"The room action timed out. Check its current state before retrying.",
|
||||||
|
INTERNAL: "The room action failed unexpectedly. Please retry.",
|
||||||
|
} satisfies Record<HousekeepingErrorCode, string>;
|
||||||
|
|
||||||
|
function actionFailure(error: unknown): ActionResult<never> {
|
||||||
|
if (!(error instanceof HotelMutationFailure)) return handleActionError(error);
|
||||||
|
const reference = error.correlationId
|
||||||
|
? ` Reference: ${error.correlationId}`
|
||||||
|
: "";
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
error: `${HOTEL_ACTION_ERRORS[error.code]}${reference}`,
|
||||||
|
...(error.fieldErrors
|
||||||
|
? {
|
||||||
|
fieldErrors: Object.fromEntries(
|
||||||
|
Object.entries(error.fieldErrors).map(([field, errors]) => [
|
||||||
|
field,
|
||||||
|
[...errors],
|
||||||
|
]),
|
||||||
|
),
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
};
|
};
|
||||||
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,
|
|
||||||
});
|
|
||||||
revalidatePath(`/admin/rooms/${roomId}/furni`);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function bulkDeleteRoomItems({
|
function notifyBestEffort(payload: Parameters<typeof notify>[0]): void {
|
||||||
roomId,
|
try {
|
||||||
itemIds,
|
notify(payload);
|
||||||
}: {
|
} catch {
|
||||||
roomId: number;
|
// A committed mutation remains successful if notification dispatch fails.
|
||||||
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`);
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function deleteRoomItem({
|
|
||||||
roomId,
|
|
||||||
itemId,
|
|
||||||
}: {
|
|
||||||
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`);
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function roomRconAction({
|
|
||||||
roomId,
|
|
||||||
action,
|
|
||||||
}: {
|
|
||||||
roomId: number;
|
|
||||||
action: string;
|
|
||||||
}) {
|
|
||||||
const staff = 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 });
|
|
||||||
notify({
|
|
||||||
action: "kick",
|
|
||||||
actor: staff.username,
|
|
||||||
target: String(roomId),
|
|
||||||
details: action,
|
|
||||||
});
|
|
||||||
} 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" });
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function deleteRoom({ id }: { id: number }) {
|
export async function updateRoomItem(
|
||||||
const staff = await requirePermission(PERMS.ROOMS_DELETE);
|
payload: Record<string, unknown>,
|
||||||
const [room] = await db
|
): Promise<ActionResult> {
|
||||||
.select({ name: Rooms.name })
|
const staff = await requirePermission(PERMS.ROOMS_EDIT);
|
||||||
.from(Rooms)
|
try {
|
||||||
.where(eq(Rooms.id, id))
|
await executeLegacyHotelMutation(staff, "room-item.update", payload);
|
||||||
.limit(1);
|
revalidatePath(`/admin/rooms/${String(payload.roomId)}/furni`);
|
||||||
await db.delete(Rooms).where(eq(Rooms.id, id));
|
return actionOk();
|
||||||
await logStaffActivity({
|
} catch (error) {
|
||||||
staffId: staff.id,
|
return actionFailure(error);
|
||||||
action: "room_delete",
|
}
|
||||||
description: `Deleted room #${id}`,
|
|
||||||
targetType: "room",
|
|
||||||
targetId: id,
|
|
||||||
});
|
|
||||||
notify({
|
|
||||||
action: "room_delete",
|
|
||||||
actor: staff.username,
|
|
||||||
target: room?.name ?? `#${id}`,
|
|
||||||
});
|
|
||||||
revalidatePath("/admin/rooms");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function updateRoom({
|
export async function bulkDeleteRoomItems(payload: {
|
||||||
id,
|
roomId: number;
|
||||||
...data
|
itemIds: number[];
|
||||||
}: {
|
}): Promise<ActionResult> {
|
||||||
|
const staff = await requirePermission(PERMS.ROOMS_EDIT);
|
||||||
|
try {
|
||||||
|
await executeLegacyHotelMutation(staff, "room-item.bulk-delete", payload);
|
||||||
|
revalidatePath(`/admin/rooms/${payload.roomId}/furni`);
|
||||||
|
return actionOk();
|
||||||
|
} catch (error) {
|
||||||
|
return actionFailure(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteRoomItem(payload: {
|
||||||
|
roomId: number;
|
||||||
|
itemId: number;
|
||||||
|
}): Promise<ActionResult> {
|
||||||
|
const staff = await requirePermission(PERMS.ROOMS_EDIT);
|
||||||
|
try {
|
||||||
|
await executeLegacyHotelMutation(staff, "room-item.delete", payload);
|
||||||
|
revalidatePath(`/admin/rooms/${payload.roomId}/furni`);
|
||||||
|
return actionOk();
|
||||||
|
} catch (error) {
|
||||||
|
return actionFailure(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function roomRconAction(payload: {
|
||||||
|
roomId: number;
|
||||||
|
action: string;
|
||||||
|
}): Promise<ActionResult> {
|
||||||
|
const staff = await requirePermission(PERMS.ROOMS_EDIT);
|
||||||
|
try {
|
||||||
|
await executeLegacyHotelMutation(staff, "room.runtime", payload);
|
||||||
|
if (payload.action === "kick") {
|
||||||
|
notifyBestEffort({
|
||||||
|
action: "kick",
|
||||||
|
actor: staff.username,
|
||||||
|
target: String(payload.roomId),
|
||||||
|
details: payload.action,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return actionOk();
|
||||||
|
} catch (error) {
|
||||||
|
return actionFailure(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteRoom(payload: {
|
||||||
|
id: number;
|
||||||
|
}): Promise<ActionResult> {
|
||||||
|
const staff = await requirePermission(PERMS.ROOMS_DELETE);
|
||||||
|
try {
|
||||||
|
const snapshot = await executeLegacyHotelMutation(
|
||||||
|
staff,
|
||||||
|
"room.delete",
|
||||||
|
payload,
|
||||||
|
);
|
||||||
|
notifyBestEffort({
|
||||||
|
action: "room_delete",
|
||||||
|
actor: staff.username,
|
||||||
|
target:
|
||||||
|
typeof snapshot.before?.name === "string"
|
||||||
|
? snapshot.before.name
|
||||||
|
: `#${payload.id}`,
|
||||||
|
});
|
||||||
|
revalidatePath("/admin/rooms");
|
||||||
|
return actionOk();
|
||||||
|
} catch (error) {
|
||||||
|
return actionFailure(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateRoom(payload: {
|
||||||
id: number;
|
id: number;
|
||||||
name?: string;
|
name?: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
state?: string;
|
state?: string;
|
||||||
usersMax?: number;
|
usersMax?: number;
|
||||||
}) {
|
}): Promise<ActionResult> {
|
||||||
const staff = await requirePermission(PERMS.ROOMS_EDIT);
|
const staff = await requirePermission(PERMS.ROOMS_EDIT);
|
||||||
await db.update(Rooms).set(data).where(eq(Rooms.id, id));
|
try {
|
||||||
await logStaffActivity({
|
await executeLegacyHotelMutation(staff, "room.update", payload);
|
||||||
staffId: staff.id,
|
revalidatePath(`/admin/rooms/${payload.id}`);
|
||||||
action: "room_update",
|
return actionOk();
|
||||||
description: `Updated room #${id}`,
|
} catch (error) {
|
||||||
targetType: "room",
|
return actionFailure(error);
|
||||||
targetId: id,
|
}
|
||||||
});
|
|
||||||
revalidatePath(`/admin/rooms/${id}`);
|
|
||||||
}
|
}
|
||||||
@@ -71,7 +71,7 @@ export function EditItemDialog({ roomId, item, open, onOpenChange }: Props) {
|
|||||||
extraData,
|
extraData,
|
||||||
};
|
};
|
||||||
run(() => updateRoomItem(payload), {
|
run(() => updateRoomItem(payload), {
|
||||||
successMessage: "Item updated and room reloaded",
|
successMessage: "Item updated successfully.",
|
||||||
onSuccess: () => onOpenChange(false),
|
onSuccess: () => onOpenChange(false),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import "server-only";
|
import "server-only";
|
||||||
|
|
||||||
import { randomBytes } from "node:crypto";
|
import { randomBytes } from "node:crypto";
|
||||||
import { and, eq, inArray } from "drizzle-orm";
|
import { and, asc, eq, inArray } from "drizzle-orm";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import type {
|
import type {
|
||||||
HotelMutationContext,
|
HotelMutationContext,
|
||||||
@@ -253,7 +253,8 @@ async function executeRoomMutation(
|
|||||||
.select()
|
.select()
|
||||||
.from(Rooms)
|
.from(Rooms)
|
||||||
.where(eq(Rooms.id, value.id))
|
.where(eq(Rooms.id, value.id))
|
||||||
.limit(1);
|
.limit(1)
|
||||||
|
.for("update");
|
||||||
requireFound(before);
|
requireFound(before);
|
||||||
const { id, ...changes } = value;
|
const { id, ...changes } = value;
|
||||||
await database.update(Rooms).set(changes).where(eq(Rooms.id, id));
|
await database.update(Rooms).set(changes).where(eq(Rooms.id, id));
|
||||||
@@ -268,7 +269,8 @@ async function executeRoomMutation(
|
|||||||
.select()
|
.select()
|
||||||
.from(Rooms)
|
.from(Rooms)
|
||||||
.where(eq(Rooms.id, value.id))
|
.where(eq(Rooms.id, value.id))
|
||||||
.limit(1);
|
.limit(1)
|
||||||
|
.for("update");
|
||||||
requireFound(before);
|
requireFound(before);
|
||||||
await database.delete(Rooms).where(eq(Rooms.id, value.id));
|
await database.delete(Rooms).where(eq(Rooms.id, value.id));
|
||||||
return { before: snapshotRow(before), after: null };
|
return { before: snapshotRow(before), after: null };
|
||||||
@@ -279,7 +281,8 @@ async function executeRoomMutation(
|
|||||||
.select()
|
.select()
|
||||||
.from(Items)
|
.from(Items)
|
||||||
.where(and(eq(Items.id, value.itemId), eq(Items.roomId, value.roomId)))
|
.where(and(eq(Items.id, value.itemId), eq(Items.roomId, value.roomId)))
|
||||||
.limit(1);
|
.limit(1)
|
||||||
|
.for("update");
|
||||||
requireFound(before);
|
requireFound(before);
|
||||||
const { itemId, roomId: _roomId, ...changes } = value;
|
const { itemId, roomId: _roomId, ...changes } = value;
|
||||||
await database
|
await database
|
||||||
@@ -297,7 +300,8 @@ async function executeRoomMutation(
|
|||||||
.select()
|
.select()
|
||||||
.from(Items)
|
.from(Items)
|
||||||
.where(and(eq(Items.id, value.itemId), eq(Items.roomId, value.roomId)))
|
.where(and(eq(Items.id, value.itemId), eq(Items.roomId, value.roomId)))
|
||||||
.limit(1);
|
.limit(1)
|
||||||
|
.for("update");
|
||||||
requireFound(before);
|
requireFound(before);
|
||||||
await database
|
await database
|
||||||
.delete(Items)
|
.delete(Items)
|
||||||
@@ -309,7 +313,9 @@ async function executeRoomMutation(
|
|||||||
const before = await database
|
const before = await database
|
||||||
.select()
|
.select()
|
||||||
.from(Items)
|
.from(Items)
|
||||||
.where(and(eq(Items.roomId, value.roomId), inArray(Items.id, itemIds)));
|
.where(and(eq(Items.roomId, value.roomId), inArray(Items.id, itemIds)))
|
||||||
|
.orderBy(asc(Items.id))
|
||||||
|
.for("update");
|
||||||
if (before.length !== itemIds.length) {
|
if (before.length !== itemIds.length) {
|
||||||
throw new HotelMutationFailure("NOT_FOUND", "errors.housekeeping.notFound");
|
throw new HotelMutationFailure("NOT_FOUND", "errors.housekeeping.notFound");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -80,6 +80,7 @@ export class HotelMutationFailure extends Error {
|
|||||||
readonly code: HousekeepingErrorCode,
|
readonly code: HousekeepingErrorCode,
|
||||||
readonly messageKey: string,
|
readonly messageKey: string,
|
||||||
readonly fieldErrors?: Readonly<Record<string, readonly string[]>>,
|
readonly fieldErrors?: Readonly<Record<string, readonly string[]>>,
|
||||||
|
readonly correlationId?: string,
|
||||||
) {
|
) {
|
||||||
super(messageKey);
|
super(messageKey);
|
||||||
this.name = "HotelMutationFailure";
|
this.name = "HotelMutationFailure";
|
||||||
@@ -187,12 +188,15 @@ export async function executeLegacyHotelMutation(
|
|||||||
result.error.code,
|
result.error.code,
|
||||||
result.error.messageKey,
|
result.error.messageKey,
|
||||||
result.error.fieldErrors,
|
result.error.fieldErrors,
|
||||||
|
result.correlationId,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (result.completion?.external === "failed") {
|
if (result.completion?.external === "failed") {
|
||||||
throw new HotelMutationFailure(
|
throw new HotelMutationFailure(
|
||||||
"DEPENDENCY_UNAVAILABLE",
|
"DEPENDENCY_UNAVAILABLE",
|
||||||
"errors.housekeeping.dependencyUnavailable",
|
"errors.housekeeping.dependencyUnavailable",
|
||||||
|
undefined,
|
||||||
|
result.correlationId,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return result.data;
|
return result.data;
|
||||||
|
|||||||
Reference in new issue
Block a user