fix(housekeeping): address people moderation review
This commit is contained in:
1 parent
29fe22297b
commit
3fa1119f5a
20 files changed
+2000
-777
No files matched your search
@@ -10,10 +10,21 @@ import { createCorrelationId } from "@/features/housekeeping/foundation/contract
|
||||
import { PERMS } from "@/lib/permissions";
|
||||
import { adminAction } from "@/lib/safe-action";
|
||||
import { ActionError, actionOk } from "@/lib/safe-action-shared";
|
||||
import { canonicalTicketId } from "@/lib/services/ticket-replies";
|
||||
|
||||
const ticketIdField = z
|
||||
.union([z.string(), z.number(), z.bigint()])
|
||||
.transform((v) => BigInt(String(v)));
|
||||
.transform((value, context) => {
|
||||
try {
|
||||
return canonicalTicketId(value);
|
||||
} catch {
|
||||
context.addIssue({
|
||||
code: "custom",
|
||||
message: "Invalid ticket identifier",
|
||||
});
|
||||
return z.NEVER;
|
||||
}
|
||||
});
|
||||
|
||||
const replyHelpCenterTicketSchema = z.object({
|
||||
ticketId: ticketIdField,
|
||||
|
||||
+29
-30
@@ -29,6 +29,17 @@ async function execute(
|
||||
);
|
||||
}
|
||||
|
||||
async function executeLegacyModerationAction(
|
||||
staff: { readonly id: number },
|
||||
input: unknown,
|
||||
) {
|
||||
const result = await execute(staff, "moderation.action", input);
|
||||
if (!result.ok) {
|
||||
throw new Error("Could not execute moderation action");
|
||||
}
|
||||
return actionOk();
|
||||
}
|
||||
|
||||
export const assignCfhTicket = adminAction(
|
||||
{ permission: CFH_PERM, schema: cfhIdSchema },
|
||||
async (ctx) => {
|
||||
@@ -85,13 +96,11 @@ const userIdSchema = z.object({ userId: z.coerce.number().int().positive() });
|
||||
|
||||
export const quickKick = adminAction(
|
||||
{ permission: MOD_ACTION_PERM, schema: userIdSchema },
|
||||
async (ctx) => {
|
||||
await execute(ctx.session.user, "moderation.action", {
|
||||
(ctx) =>
|
||||
executeLegacyModerationAction(ctx.session.user, {
|
||||
action: "kick",
|
||||
userId: ctx.data.userId,
|
||||
});
|
||||
return actionOk();
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const muteSchema = z.object({
|
||||
@@ -101,24 +110,20 @@ const muteSchema = z.object({
|
||||
|
||||
export const quickMute = adminAction(
|
||||
{ permission: MOD_ACTION_PERM, schema: muteSchema },
|
||||
async (ctx) => {
|
||||
await execute(ctx.session.user, "moderation.action", {
|
||||
(ctx) =>
|
||||
executeLegacyModerationAction(ctx.session.user, {
|
||||
action: "mute",
|
||||
...ctx.data,
|
||||
});
|
||||
return actionOk();
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
export const quickUnmute = adminAction(
|
||||
{ permission: MOD_ACTION_PERM, schema: userIdSchema },
|
||||
async (ctx) => {
|
||||
await execute(ctx.session.user, "moderation.action", {
|
||||
(ctx) =>
|
||||
executeLegacyModerationAction(ctx.session.user, {
|
||||
action: "unmute",
|
||||
userId: ctx.data.userId,
|
||||
});
|
||||
return actionOk();
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const alertSchema = z.object({
|
||||
@@ -128,26 +133,22 @@ const alertSchema = z.object({
|
||||
|
||||
export const quickAlert = adminAction(
|
||||
{ permission: MOD_ACTION_PERM, schema: alertSchema },
|
||||
async (ctx) => {
|
||||
await execute(ctx.session.user, "moderation.action", {
|
||||
(ctx) =>
|
||||
executeLegacyModerationAction(ctx.session.user, {
|
||||
action: "alert",
|
||||
...ctx.data,
|
||||
});
|
||||
return actionOk();
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const roomIdSchema = z.object({ roomId: z.coerce.number().int().positive() });
|
||||
|
||||
export const quickRoomKick = adminAction(
|
||||
{ permission: MOD_ACTION_PERM, schema: roomIdSchema },
|
||||
async (ctx) => {
|
||||
await execute(ctx.session.user, "moderation.action", {
|
||||
(ctx) =>
|
||||
executeLegacyModerationAction(ctx.session.user, {
|
||||
action: "room-kick",
|
||||
roomId: ctx.data.roomId,
|
||||
});
|
||||
return actionOk();
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const broadcastSchema = z.object({
|
||||
@@ -157,11 +158,9 @@ const broadcastSchema = z.object({
|
||||
|
||||
export const broadcastAlert = adminAction(
|
||||
{ permission: MOD_ACTION_PERM, schema: broadcastSchema },
|
||||
async (ctx) => {
|
||||
await execute(ctx.session.user, "moderation.action", {
|
||||
(ctx) =>
|
||||
executeLegacyModerationAction(ctx.session.user, {
|
||||
action: "broadcast",
|
||||
...ctx.data,
|
||||
});
|
||||
return actionOk();
|
||||
},
|
||||
}),
|
||||
);
|
||||
@@ -8,11 +8,34 @@ const { execute, registrations, staff } = vi.hoisted(() => ({
|
||||
}));
|
||||
|
||||
function wrapper(
|
||||
options: { permission: string | readonly string[] },
|
||||
options: {
|
||||
permission: string | readonly string[];
|
||||
schema?: {
|
||||
safeParse(value: unknown):
|
||||
| { success: true; data: unknown }
|
||||
| { success: false; error: unknown };
|
||||
};
|
||||
},
|
||||
handler: (context: { data: unknown; session: { user: typeof staff } }) => unknown,
|
||||
) {
|
||||
registrations.push(options);
|
||||
return (data: unknown) => handler({ data, session: { user: staff } });
|
||||
return async (data: unknown) => {
|
||||
const parsed = options.schema?.safeParse(data);
|
||||
if (parsed && !parsed.success) {
|
||||
return { ok: false, error: "Validation failed" };
|
||||
}
|
||||
try {
|
||||
return await handler({
|
||||
data: parsed?.data ?? data,
|
||||
session: { user: staff },
|
||||
});
|
||||
} catch (error) {
|
||||
return {
|
||||
ok: false,
|
||||
error: error instanceof Error ? error.message : "Internal server error",
|
||||
};
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
vi.mock("@/features/housekeeping/domains/people/services/mutations", () => ({
|
||||
@@ -202,6 +225,64 @@ describe("legacy People support and moderation wrappers", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it.each([
|
||||
["kick", quickKick, { userId: 7 }],
|
||||
["mute", quickMute, { userId: 7, duration: 60 }],
|
||||
["unmute", quickUnmute, { userId: 7 }],
|
||||
["alert", quickAlert, { userId: 7, message: "Stop" }],
|
||||
["room kick", quickRoomKick, { roomId: 12 }],
|
||||
["broadcast", broadcastAlert, { message: "Notice", type: "staff" }],
|
||||
] as const)(
|
||||
"maps a non-ok %s service result to the historical legacy failure boundary",
|
||||
async (_label, action, input) => {
|
||||
execute.mockResolvedValueOnce({
|
||||
ok: false,
|
||||
error: {
|
||||
code: "DEPENDENCY_UNAVAILABLE",
|
||||
messageKey: "errors.housekeeping.dependencyUnavailable",
|
||||
},
|
||||
correlationId: "quick-action-failure",
|
||||
});
|
||||
await expect(call(action, input)).resolves.toEqual({
|
||||
ok: false,
|
||||
error: "Could not execute moderation action",
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
it("maps a thrown moderation service failure instead of reporting success", async () => {
|
||||
execute.mockRejectedValueOnce(new Error("RCON unavailable"));
|
||||
await expect(call(quickKick, { userId: 7 })).resolves.toEqual({
|
||||
ok: false,
|
||||
error: "RCON unavailable",
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
9_007_199_254_740_992,
|
||||
"01",
|
||||
"0",
|
||||
0,
|
||||
-1,
|
||||
"18446744073709551616",
|
||||
] as const)(
|
||||
"rejects noncanonical help-ticket identifier %s at every legacy action schema",
|
||||
async (ticketId) => {
|
||||
for (const [action, input] of [
|
||||
[replyHelpCenterTicket, { ticketId, content: "Handled" }],
|
||||
[closeHelpCenterTicket, { ticketId }],
|
||||
[reopenHelpCenterTicket, { ticketId }],
|
||||
[liftBanFromHelpTicket, { ticketId }],
|
||||
] as const) {
|
||||
await expect(call(action, input)).resolves.toEqual({
|
||||
ok: false,
|
||||
error: "Validation failed",
|
||||
});
|
||||
}
|
||||
expect(execute).not.toHaveBeenCalled();
|
||||
},
|
||||
);
|
||||
|
||||
it("keeps close-CFH missing rows as a successful legacy no-op", async () => {
|
||||
execute.mockResolvedValueOnce({
|
||||
ok: false,
|
||||
|
||||
Reference in new issue
Block a user