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 { PERMS } from "@/lib/permissions";
|
||||||
import { adminAction } from "@/lib/safe-action";
|
import { adminAction } from "@/lib/safe-action";
|
||||||
import { ActionError, actionOk } from "@/lib/safe-action-shared";
|
import { ActionError, actionOk } from "@/lib/safe-action-shared";
|
||||||
|
import { canonicalTicketId } from "@/lib/services/ticket-replies";
|
||||||
|
|
||||||
const ticketIdField = z
|
const ticketIdField = z
|
||||||
.union([z.string(), z.number(), z.bigint()])
|
.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({
|
const replyHelpCenterTicketSchema = z.object({
|
||||||
ticketId: ticketIdField,
|
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(
|
export const assignCfhTicket = adminAction(
|
||||||
{ permission: CFH_PERM, schema: cfhIdSchema },
|
{ permission: CFH_PERM, schema: cfhIdSchema },
|
||||||
async (ctx) => {
|
async (ctx) => {
|
||||||
@@ -85,13 +96,11 @@ const userIdSchema = z.object({ userId: z.coerce.number().int().positive() });
|
|||||||
|
|
||||||
export const quickKick = adminAction(
|
export const quickKick = adminAction(
|
||||||
{ permission: MOD_ACTION_PERM, schema: userIdSchema },
|
{ permission: MOD_ACTION_PERM, schema: userIdSchema },
|
||||||
async (ctx) => {
|
(ctx) =>
|
||||||
await execute(ctx.session.user, "moderation.action", {
|
executeLegacyModerationAction(ctx.session.user, {
|
||||||
action: "kick",
|
action: "kick",
|
||||||
userId: ctx.data.userId,
|
userId: ctx.data.userId,
|
||||||
});
|
}),
|
||||||
return actionOk();
|
|
||||||
},
|
|
||||||
);
|
);
|
||||||
|
|
||||||
const muteSchema = z.object({
|
const muteSchema = z.object({
|
||||||
@@ -101,24 +110,20 @@ const muteSchema = z.object({
|
|||||||
|
|
||||||
export const quickMute = adminAction(
|
export const quickMute = adminAction(
|
||||||
{ permission: MOD_ACTION_PERM, schema: muteSchema },
|
{ permission: MOD_ACTION_PERM, schema: muteSchema },
|
||||||
async (ctx) => {
|
(ctx) =>
|
||||||
await execute(ctx.session.user, "moderation.action", {
|
executeLegacyModerationAction(ctx.session.user, {
|
||||||
action: "mute",
|
action: "mute",
|
||||||
...ctx.data,
|
...ctx.data,
|
||||||
});
|
}),
|
||||||
return actionOk();
|
|
||||||
},
|
|
||||||
);
|
);
|
||||||
|
|
||||||
export const quickUnmute = adminAction(
|
export const quickUnmute = adminAction(
|
||||||
{ permission: MOD_ACTION_PERM, schema: userIdSchema },
|
{ permission: MOD_ACTION_PERM, schema: userIdSchema },
|
||||||
async (ctx) => {
|
(ctx) =>
|
||||||
await execute(ctx.session.user, "moderation.action", {
|
executeLegacyModerationAction(ctx.session.user, {
|
||||||
action: "unmute",
|
action: "unmute",
|
||||||
userId: ctx.data.userId,
|
userId: ctx.data.userId,
|
||||||
});
|
}),
|
||||||
return actionOk();
|
|
||||||
},
|
|
||||||
);
|
);
|
||||||
|
|
||||||
const alertSchema = z.object({
|
const alertSchema = z.object({
|
||||||
@@ -128,26 +133,22 @@ const alertSchema = z.object({
|
|||||||
|
|
||||||
export const quickAlert = adminAction(
|
export const quickAlert = adminAction(
|
||||||
{ permission: MOD_ACTION_PERM, schema: alertSchema },
|
{ permission: MOD_ACTION_PERM, schema: alertSchema },
|
||||||
async (ctx) => {
|
(ctx) =>
|
||||||
await execute(ctx.session.user, "moderation.action", {
|
executeLegacyModerationAction(ctx.session.user, {
|
||||||
action: "alert",
|
action: "alert",
|
||||||
...ctx.data,
|
...ctx.data,
|
||||||
});
|
}),
|
||||||
return actionOk();
|
|
||||||
},
|
|
||||||
);
|
);
|
||||||
|
|
||||||
const roomIdSchema = z.object({ roomId: z.coerce.number().int().positive() });
|
const roomIdSchema = z.object({ roomId: z.coerce.number().int().positive() });
|
||||||
|
|
||||||
export const quickRoomKick = adminAction(
|
export const quickRoomKick = adminAction(
|
||||||
{ permission: MOD_ACTION_PERM, schema: roomIdSchema },
|
{ permission: MOD_ACTION_PERM, schema: roomIdSchema },
|
||||||
async (ctx) => {
|
(ctx) =>
|
||||||
await execute(ctx.session.user, "moderation.action", {
|
executeLegacyModerationAction(ctx.session.user, {
|
||||||
action: "room-kick",
|
action: "room-kick",
|
||||||
roomId: ctx.data.roomId,
|
roomId: ctx.data.roomId,
|
||||||
});
|
}),
|
||||||
return actionOk();
|
|
||||||
},
|
|
||||||
);
|
);
|
||||||
|
|
||||||
const broadcastSchema = z.object({
|
const broadcastSchema = z.object({
|
||||||
@@ -157,11 +158,9 @@ const broadcastSchema = z.object({
|
|||||||
|
|
||||||
export const broadcastAlert = adminAction(
|
export const broadcastAlert = adminAction(
|
||||||
{ permission: MOD_ACTION_PERM, schema: broadcastSchema },
|
{ permission: MOD_ACTION_PERM, schema: broadcastSchema },
|
||||||
async (ctx) => {
|
(ctx) =>
|
||||||
await execute(ctx.session.user, "moderation.action", {
|
executeLegacyModerationAction(ctx.session.user, {
|
||||||
action: "broadcast",
|
action: "broadcast",
|
||||||
...ctx.data,
|
...ctx.data,
|
||||||
});
|
}),
|
||||||
return actionOk();
|
|
||||||
},
|
|
||||||
);
|
);
|
||||||
@@ -8,11 +8,34 @@ const { execute, registrations, staff } = vi.hoisted(() => ({
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
function wrapper(
|
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,
|
handler: (context: { data: unknown; session: { user: typeof staff } }) => unknown,
|
||||||
) {
|
) {
|
||||||
registrations.push(options);
|
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", () => ({
|
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 () => {
|
it("keeps close-CFH missing rows as a successful legacy no-op", async () => {
|
||||||
execute.mockResolvedValueOnce({
|
execute.mockResolvedValueOnce({
|
||||||
ok: false,
|
ok: false,
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import "server-only";
|
|||||||
|
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { PERMS } from "@/lib/permission-slugs";
|
import { PERMS } from "@/lib/permission-slugs";
|
||||||
|
import { CANONICAL_TICKET_ID_PATTERN } from "@/lib/services/ticket-replies";
|
||||||
import type { HousekeepingCommand } from "../../../foundation/commands/registry";
|
import type { HousekeepingCommand } from "../../../foundation/commands/registry";
|
||||||
import { anyCapability } from "../../../foundation/contracts";
|
import { anyCapability } from "../../../foundation/contracts";
|
||||||
import {
|
import {
|
||||||
@@ -24,26 +25,7 @@ export const SUPPORT_COMMAND_IDS = [
|
|||||||
type SupportCommandId = (typeof SUPPORT_COMMAND_IDS)[number];
|
type SupportCommandId = (typeof SUPPORT_COMMAND_IDS)[number];
|
||||||
const positiveId = z.number().int().positive();
|
const positiveId = z.number().int().positive();
|
||||||
const text = (max: number) => z.string().min(1).max(max).regex(/\S/u);
|
const text = (max: number) => z.string().min(1).max(max).regex(/\S/u);
|
||||||
const MAX_UNSIGNED_BIGINT = "18446744073709551615";
|
const bigintId = z.string().regex(CANONICAL_TICKET_ID_PATTERN);
|
||||||
function boundedDecimalPattern(maximum: string): RegExp {
|
|
||||||
const alternatives = [`[1-9]\\d{0,${maximum.length - 2}}`];
|
|
||||||
for (let index = 0; index < maximum.length; index += 1) {
|
|
||||||
const maximumDigit = Number(maximum[index]);
|
|
||||||
const minimumDigit = index === 0 ? 1 : 0;
|
|
||||||
const upperDigit = maximumDigit - 1;
|
|
||||||
if (upperDigit < minimumDigit) continue;
|
|
||||||
const digit =
|
|
||||||
upperDigit === minimumDigit
|
|
||||||
? String(minimumDigit)
|
|
||||||
: `[${minimumDigit}-${upperDigit}]`;
|
|
||||||
alternatives.push(
|
|
||||||
`${maximum.slice(0, index)}${digit}\\d{${maximum.length - index - 1}}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
alternatives.push(maximum);
|
|
||||||
return new RegExp(`^(?:${alternatives.join("|")})$`, "u");
|
|
||||||
}
|
|
||||||
const bigintId = z.string().regex(boundedDecimalPattern(MAX_UNSIGNED_BIGINT));
|
|
||||||
|
|
||||||
function command<I>(
|
function command<I>(
|
||||||
service: Pick<PeopleMutationService, "execute">,
|
service: Pick<PeopleMutationService, "execute">,
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
type HousekeepingWorkItem,
|
type HousekeepingWorkItem,
|
||||||
ok,
|
ok,
|
||||||
} from "../../foundation/contracts";
|
} from "../../foundation/contracts";
|
||||||
|
import { isSafeHousekeepingHref } from "../../foundation/housekeeping-href";
|
||||||
import { peopleModerationQuery } from "./queries/moderation";
|
import { peopleModerationQuery } from "./queries/moderation";
|
||||||
import { peopleSupportQuery } from "./queries/support";
|
import { peopleSupportQuery } from "./queries/support";
|
||||||
|
|
||||||
@@ -41,17 +42,6 @@ export interface PeopleInboxAdapters {
|
|||||||
): Promise<readonly HousekeepingWorkItem[]>;
|
): Promise<readonly HousekeepingWorkItem[]>;
|
||||||
}
|
}
|
||||||
|
|
||||||
function safeHref(href: string): boolean {
|
|
||||||
return (
|
|
||||||
href.startsWith("/ase/") &&
|
|
||||||
!href.includes("\\") &&
|
|
||||||
!Array.from(href).some((character) => {
|
|
||||||
const code = character.codePointAt(0) ?? 0;
|
|
||||||
return code < 32 || code === 127;
|
|
||||||
})
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function createSource(
|
function createSource(
|
||||||
id: (typeof PEOPLE_INBOX_SOURCE_IDS)[number],
|
id: (typeof PEOPLE_INBOX_SOURCE_IDS)[number],
|
||||||
capability: CapabilityRequirement,
|
capability: CapabilityRequirement,
|
||||||
@@ -72,7 +62,7 @@ function createSource(
|
|||||||
items: items
|
items: items
|
||||||
.filter(
|
.filter(
|
||||||
(item) =>
|
(item) =>
|
||||||
safeHref(item.href) &&
|
isSafeHousekeepingHref(item.href) &&
|
||||||
satisfiesCapability(context, item.capability),
|
satisfiesCapability(context, item.capability),
|
||||||
)
|
)
|
||||||
.slice(0, 25),
|
.slice(0, 25),
|
||||||
|
|||||||
@@ -1,14 +1,23 @@
|
|||||||
import { renderToStaticMarkup } from "react-dom/server";
|
import { renderToStaticMarkup } from "react-dom/server";
|
||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it, vi } from "vitest";
|
||||||
import { PERMS } from "@/lib/permission-slugs";
|
import { PERMS } from "@/lib/permission-slugs";
|
||||||
import type { HousekeepingCapabilityContext } from "../../../foundation/contracts";
|
import type { HousekeepingCapabilityContext } from "../../../foundation/contracts";
|
||||||
import { ok } from "../../../foundation/contracts";
|
import { ok } from "../../../foundation/contracts";
|
||||||
import { PeopleCfhDetailPage } from "./cfh-detail";
|
import { PeopleCfhDetailPage } from "./cfh-detail";
|
||||||
import { PeopleHelpTicketDetailPage } from "./help-ticket-detail";
|
import { PeopleHelpTicketDetailPage } from "./help-ticket-detail";
|
||||||
import { PeopleModerationPage } from "./moderation";
|
import { PeopleModerationPage } from "./moderation";
|
||||||
import { PeopleSupportPage } from "./support";
|
import { submitPeopleCommandForm } from "./people-command-form";
|
||||||
|
import {
|
||||||
|
PeopleSupportPage,
|
||||||
|
ticketTemplateUpdateSubmission,
|
||||||
|
} from "./support";
|
||||||
import { PeopleTicketDetailPage } from "./ticket-detail";
|
import { PeopleTicketDetailPage } from "./ticket-detail";
|
||||||
|
|
||||||
|
const executeHousekeepingCommand = vi.hoisted(() => vi.fn());
|
||||||
|
vi.mock("@/actions/housekeeping-command", () => ({
|
||||||
|
executeHousekeepingCommand,
|
||||||
|
}));
|
||||||
|
|
||||||
function context(granted: readonly string[]): HousekeepingCapabilityContext {
|
function context(granted: readonly string[]): HousekeepingCapabilityContext {
|
||||||
const permissions = new Set(granted);
|
const permissions = new Set(granted);
|
||||||
return {
|
return {
|
||||||
@@ -180,6 +189,86 @@ describe("People support/moderation pages", () => {
|
|||||||
expect(html).not.toContain("/mod/");
|
expect(html).not.toContain("/mod/");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("renders and submits a capability-gated template update with current defaults", async () => {
|
||||||
|
const template = {
|
||||||
|
id: 88,
|
||||||
|
title: "Greeting",
|
||||||
|
content: "Hello",
|
||||||
|
category: "general",
|
||||||
|
sortOrder: 4,
|
||||||
|
};
|
||||||
|
const html = renderToStaticMarkup(
|
||||||
|
<PeopleSupportPage
|
||||||
|
context={context([PERMS.TICKETS_EDIT])}
|
||||||
|
result={ok(
|
||||||
|
{
|
||||||
|
kind: "ticket-templates" as const,
|
||||||
|
page: {
|
||||||
|
items: [template],
|
||||||
|
total: 1,
|
||||||
|
pageSize: 20,
|
||||||
|
offset: 0,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"templates",
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
expect(html.match(/data-housekeeping-command="people\.ticket-template\.change"/gu)).toHaveLength(3);
|
||||||
|
expect(html).toContain("Update template");
|
||||||
|
expect(html).toContain('value="Greeting"');
|
||||||
|
expect(html).toContain('value="Hello"');
|
||||||
|
expect(html).toContain('value="general"');
|
||||||
|
expect(html).toContain('value="4"');
|
||||||
|
|
||||||
|
executeHousekeepingCommand.mockResolvedValue({
|
||||||
|
ok: true,
|
||||||
|
data: { before: template, after: { ...template, title: "Updated" } },
|
||||||
|
correlationId: "template-update",
|
||||||
|
});
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.set("title", "Updated");
|
||||||
|
formData.set("content", "Updated content");
|
||||||
|
formData.set("category", "appeals");
|
||||||
|
formData.set("sortOrder", "7");
|
||||||
|
await submitPeopleCommandForm(
|
||||||
|
ticketTemplateUpdateSubmission(template),
|
||||||
|
null,
|
||||||
|
formData,
|
||||||
|
);
|
||||||
|
expect(executeHousekeepingCommand).toHaveBeenCalledWith({
|
||||||
|
commandId: "people.ticket-template.change",
|
||||||
|
input: {
|
||||||
|
action: "update",
|
||||||
|
id: 88,
|
||||||
|
title: "Updated",
|
||||||
|
content: "Updated content",
|
||||||
|
category: "appeals",
|
||||||
|
sortOrder: 7,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const readOnly = renderToStaticMarkup(
|
||||||
|
<PeopleSupportPage
|
||||||
|
context={context([PERMS.TICKETS_VIEW])}
|
||||||
|
result={ok(
|
||||||
|
{
|
||||||
|
kind: "ticket-templates" as const,
|
||||||
|
page: {
|
||||||
|
items: [template],
|
||||||
|
total: 1,
|
||||||
|
pageSize: 20,
|
||||||
|
offset: 0,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"read-only-templates",
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
expect(readOnly).not.toContain("Update template");
|
||||||
|
expect(readOnly).not.toContain("Delete template");
|
||||||
|
});
|
||||||
|
|
||||||
it("renders the loading state before data arrives", () => {
|
it("renders the loading state before data arrives", () => {
|
||||||
expect(
|
expect(
|
||||||
renderToStaticMarkup(<PeopleSupportPage context={context([])} />),
|
renderToStaticMarkup(<PeopleSupportPage context={context([])} />),
|
||||||
|
|||||||
@@ -11,7 +11,10 @@ import {
|
|||||||
peopleSupportQuery,
|
peopleSupportQuery,
|
||||||
} from "../queries/support";
|
} from "../queries/support";
|
||||||
import { PeoplePageFrame, parsePeopleListInput } from "./page-state";
|
import { PeoplePageFrame, parsePeopleListInput } from "./page-state";
|
||||||
import { PeopleCommandForm } from "./people-command-form";
|
import {
|
||||||
|
PeopleCommandForm,
|
||||||
|
type PeopleCommandSubmission,
|
||||||
|
} from "./people-command-form";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
readonly context: HousekeepingCapabilityContext;
|
readonly context: HousekeepingCapabilityContext;
|
||||||
@@ -34,6 +37,52 @@ function Queue({ queue }: { readonly queue: { tickets: number; helpTickets: numb
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type PeopleTicketTemplate = Extract<
|
||||||
|
PeopleSupportQueryData,
|
||||||
|
{ readonly kind: "ticket-templates" }
|
||||||
|
>["page"]["items"][number];
|
||||||
|
|
||||||
|
export function ticketTemplateUpdateSubmission(
|
||||||
|
template: PeopleTicketTemplate,
|
||||||
|
): PeopleCommandSubmission {
|
||||||
|
return {
|
||||||
|
commandId: "people.ticket-template.change",
|
||||||
|
input: { action: "update", id: template.id },
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
name: "title",
|
||||||
|
label: "Title",
|
||||||
|
type: "text",
|
||||||
|
required: true,
|
||||||
|
maxLength: 255,
|
||||||
|
defaultValue: template.title,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "content",
|
||||||
|
label: "Content",
|
||||||
|
type: "text",
|
||||||
|
required: true,
|
||||||
|
maxLength: 5_000,
|
||||||
|
defaultValue: template.content,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "category",
|
||||||
|
label: "Category",
|
||||||
|
type: "text",
|
||||||
|
maxLength: 50,
|
||||||
|
defaultValue: template.category,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "sortOrder",
|
||||||
|
label: "Sort order",
|
||||||
|
type: "number",
|
||||||
|
min: 0,
|
||||||
|
defaultValue: template.sortOrder,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export function PeopleSupportPage({ context, result }: Props) {
|
export function PeopleSupportPage({ context, result }: Props) {
|
||||||
return (
|
return (
|
||||||
<PeoplePageFrame
|
<PeoplePageFrame
|
||||||
@@ -66,11 +115,17 @@ export function PeopleSupportPage({ context, result }: Props) {
|
|||||||
<h2>{template.title}</h2>
|
<h2>{template.title}</h2>
|
||||||
<p>{template.content}</p>
|
<p>{template.content}</p>
|
||||||
{context.has(PERMS.TICKETS_EDIT) ? (
|
{context.has(PERMS.TICKETS_EDIT) ? (
|
||||||
<PeopleCommandForm
|
<div className="space-y-3">
|
||||||
commandId="people.ticket-template.change"
|
<PeopleCommandForm
|
||||||
buttonLabel="Delete template"
|
{...ticketTemplateUpdateSubmission(template)}
|
||||||
input={{ action: "delete", id: template.id }}
|
buttonLabel="Update template"
|
||||||
/>
|
/>
|
||||||
|
<PeopleCommandForm
|
||||||
|
commandId="people.ticket-template.change"
|
||||||
|
buttonLabel="Delete template"
|
||||||
|
input={{ action: "delete", id: template.id }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it, vi } from "vitest";
|
||||||
import { PERMS } from "@/lib/permission-slugs";
|
import { PERMS } from "@/lib/permission-slugs";
|
||||||
import type { HousekeepingCapabilityContext } from "../../foundation/contracts";
|
import type { HousekeepingCapabilityContext } from "../../foundation/contracts";
|
||||||
import { anyCapability } from "../../foundation/contracts";
|
import { anyCapability } from "../../foundation/contracts";
|
||||||
@@ -11,7 +11,10 @@ import {
|
|||||||
createPeopleSearchProviders,
|
createPeopleSearchProviders,
|
||||||
PEOPLE_SEARCH_PROVIDER_IDS,
|
PEOPLE_SEARCH_PROVIDER_IDS,
|
||||||
} from "./search";
|
} from "./search";
|
||||||
import { createPeopleWidgets } from "./widgets";
|
import {
|
||||||
|
createPeopleQueueAggregateLoader,
|
||||||
|
createPeopleWidgets,
|
||||||
|
} from "./widgets";
|
||||||
|
|
||||||
function context(granted: readonly string[]): HousekeepingCapabilityContext {
|
function context(granted: readonly string[]): HousekeepingCapabilityContext {
|
||||||
const permissions = new Set(granted);
|
const permissions = new Set(granted);
|
||||||
@@ -66,6 +69,76 @@ describe("People providers", () => {
|
|||||||
expect(result.data.map((item) => item.id)).not.toContain("candidate-1");
|
expect(result.data.map((item) => item.id)).not.toContain("candidate-1");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("confines normalized search and inbox hrefs to Housekeeping", async () => {
|
||||||
|
const hrefs = [
|
||||||
|
"/ase/../admin",
|
||||||
|
"/ase/%2e%2e/admin",
|
||||||
|
"/ase/%2e%2e%2fadmin",
|
||||||
|
"/ase\\..\\admin",
|
||||||
|
"/ase/%5c../admin",
|
||||||
|
"//example.invalid/ase/people",
|
||||||
|
"https://example.invalid/ase/people",
|
||||||
|
"/ase/people/users/7?tab=profile#security",
|
||||||
|
"/ase?view=queue#top",
|
||||||
|
] as const;
|
||||||
|
const candidates = hrefs.map((href, index) => ({
|
||||||
|
id: `candidate-${index}`,
|
||||||
|
title: `Candidate ${index}`,
|
||||||
|
href,
|
||||||
|
capability: anyCapability(PERMS.MOD_USERS_VIEW),
|
||||||
|
}));
|
||||||
|
const search = await createPeopleSearchProviders({
|
||||||
|
users: async () => candidates,
|
||||||
|
guilds: async () => [],
|
||||||
|
tickets: async () => [],
|
||||||
|
})[0].search(context([PERMS.MOD_USERS_VIEW]), {
|
||||||
|
term: "candidate",
|
||||||
|
limit: 25,
|
||||||
|
});
|
||||||
|
expect(search).toMatchObject({
|
||||||
|
ok: true,
|
||||||
|
data: [
|
||||||
|
{ id: "candidate-7", href: "/ase/people/users/7?tab=profile#security" },
|
||||||
|
{ id: "candidate-8", href: "/ase?view=queue#top" },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const items = candidates.map((candidate, index) => ({
|
||||||
|
sourceId: "people.tickets",
|
||||||
|
itemId: String(index),
|
||||||
|
deduplicationKey: `ticket:${index}`,
|
||||||
|
domain: "people" as const,
|
||||||
|
capability: anyCapability(PERMS.MOD_TICKETS_VIEW),
|
||||||
|
severity: "info" as const,
|
||||||
|
priority: "normal" as const,
|
||||||
|
ageMs: 0,
|
||||||
|
state: "open",
|
||||||
|
occurredAt: "2026-08-29T00:00:00.000Z",
|
||||||
|
titleKey: "pages.housekeeping.items.ticket",
|
||||||
|
href: candidate.href as `/ase/${string}`,
|
||||||
|
freshness: "fresh" as const,
|
||||||
|
actions: [],
|
||||||
|
}));
|
||||||
|
const inbox = await createPeopleInboxSources({
|
||||||
|
tickets: async () => items,
|
||||||
|
helpTickets: async () => [],
|
||||||
|
cfh: async () => [],
|
||||||
|
activeBans: async () => [],
|
||||||
|
})[0].getItems(
|
||||||
|
context([PERMS.MOD_TICKETS_VIEW]),
|
||||||
|
new AbortController().signal,
|
||||||
|
);
|
||||||
|
expect(inbox).toMatchObject({
|
||||||
|
ok: true,
|
||||||
|
data: {
|
||||||
|
items: [
|
||||||
|
{ itemId: "7", href: "/ase/people/users/7?tab=profile#security" },
|
||||||
|
{ itemId: "8", href: "/ase?view=queue#top" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it("bounds inbox sources and loads the mandatory real queue widget", async () => {
|
it("bounds inbox sources and loads the mandatory real queue widget", async () => {
|
||||||
const items = Array.from({ length: 40 }, (_, index) => ({
|
const items = Array.from({ length: 40 }, (_, index) => ({
|
||||||
sourceId: "people.tickets",
|
sourceId: "people.tickets",
|
||||||
@@ -123,4 +196,64 @@ describe("People providers", () => {
|
|||||||
data: { tickets: 1, helpTickets: 2, cfh: 0, activeBans: 0 },
|
data: { tickets: 1, helpTickets: 2, cfh: 0, activeBans: 0 },
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("loads only capability-authorized queue aggregates for every union context", async () => {
|
||||||
|
const support = vi.fn(async () => ({ tickets: 1, helpTickets: 2 }));
|
||||||
|
const cfh = vi.fn(async () => 3);
|
||||||
|
const activeBans = vi.fn(async () => 4);
|
||||||
|
const loader = createPeopleQueueAggregateLoader({
|
||||||
|
support,
|
||||||
|
cfh,
|
||||||
|
activeBans,
|
||||||
|
});
|
||||||
|
const signal = new AbortController().signal;
|
||||||
|
const cases = [
|
||||||
|
{
|
||||||
|
name: "ticket-only",
|
||||||
|
granted: [PERMS.MOD_TICKETS_VIEW],
|
||||||
|
want: { tickets: 1, helpTickets: 2, cfh: 0, activeBans: 0 },
|
||||||
|
calls: [1, 0, 0],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "CFH-only",
|
||||||
|
granted: [PERMS.MOD_CFH_VIEW],
|
||||||
|
want: { tickets: 0, helpTickets: 0, cfh: 3, activeBans: 0 },
|
||||||
|
calls: [0, 1, 0],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "ban-only",
|
||||||
|
granted: [PERMS.MOD_BANS_VIEW],
|
||||||
|
want: { tickets: 0, helpTickets: 0, cfh: 0, activeBans: 4 },
|
||||||
|
calls: [0, 0, 1],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "mixed",
|
||||||
|
granted: [
|
||||||
|
PERMS.MOD_TICKETS_VIEW,
|
||||||
|
PERMS.MOD_CFH_VIEW,
|
||||||
|
PERMS.MOD_BANS_VIEW,
|
||||||
|
],
|
||||||
|
want: { tickets: 1, helpTickets: 2, cfh: 3, activeBans: 4 },
|
||||||
|
calls: [1, 1, 1],
|
||||||
|
},
|
||||||
|
] as const;
|
||||||
|
for (const entry of cases) {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
const widget = createPeopleWidgets({ queue: loader })[0];
|
||||||
|
await expect(widget.load(context(entry.granted), signal)).resolves.toMatchObject({
|
||||||
|
ok: true,
|
||||||
|
data: entry.want,
|
||||||
|
});
|
||||||
|
expect(
|
||||||
|
[support.mock.calls.length, cfh.mock.calls.length, activeBans.mock.calls.length],
|
||||||
|
entry.name,
|
||||||
|
).toEqual(entry.calls);
|
||||||
|
}
|
||||||
|
|
||||||
|
vi.clearAllMocks();
|
||||||
|
await expect(
|
||||||
|
createPeopleWidgets({ queue: loader })[0].load(context([]), signal),
|
||||||
|
).resolves.toMatchObject({ ok: false, error: { code: "FORBIDDEN" } });
|
||||||
|
expect([support, cfh, activeBans].every((load) => load.mock.calls.length === 0)).toBe(true);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { PERMS } from "@/lib/permission-slugs";
|
||||||
|
import type { HousekeepingCapabilityContext } from "../../foundation/contracts";
|
||||||
|
|
||||||
|
const mocks = vi.hoisted(() => ({
|
||||||
|
execute: vi.fn(),
|
||||||
|
fetchTicketQueueOpenCounts: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/lib/admin/ticket-queue-counts", () => ({
|
||||||
|
fetchTicketQueueOpenCounts: mocks.fetchTicketQueueOpenCounts,
|
||||||
|
}));
|
||||||
|
vi.mock("@/lib/db", () => {
|
||||||
|
return { db: { execute: mocks.execute } };
|
||||||
|
});
|
||||||
|
|
||||||
|
import { PEOPLE_WIDGETS } from "./widgets";
|
||||||
|
|
||||||
|
function context(granted: readonly string[]): HousekeepingCapabilityContext {
|
||||||
|
const permissions = new Set(granted);
|
||||||
|
return {
|
||||||
|
actor: { id: 42, username: "operator", rank: 4 },
|
||||||
|
isSuperAdmin: false,
|
||||||
|
has: (slug) => permissions.has(slug),
|
||||||
|
hasAny: (...slugs) => slugs.some((slug) => permissions.has(slug)),
|
||||||
|
hasAll: (...slugs) => slugs.every((slug) => permissions.has(slug)),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function load(granted: readonly string[]) {
|
||||||
|
return PEOPLE_WIDGETS[0].load(
|
||||||
|
context(granted),
|
||||||
|
new AbortController().signal,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
mocks.fetchTicketQueueOpenCounts.mockResolvedValue({
|
||||||
|
cmsOpen: 1,
|
||||||
|
helpOpen: 2,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("People production queue widget", () => {
|
||||||
|
it("wires ticket-only, CFH-only, ban-only, mixed, and denied contexts without unauthorized reads", async () => {
|
||||||
|
await expect(load([PERMS.MOD_TICKETS_VIEW])).resolves.toMatchObject({
|
||||||
|
ok: true,
|
||||||
|
data: { tickets: 1, helpTickets: 2, cfh: 0, activeBans: 0 },
|
||||||
|
});
|
||||||
|
expect(mocks.fetchTicketQueueOpenCounts).toHaveBeenCalledTimes(1);
|
||||||
|
expect(mocks.execute).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
vi.clearAllMocks();
|
||||||
|
mocks.execute.mockResolvedValueOnce([[{ total: 3 }], []]);
|
||||||
|
await expect(load([PERMS.MOD_CFH_VIEW])).resolves.toMatchObject({
|
||||||
|
ok: true,
|
||||||
|
data: { tickets: 0, helpTickets: 0, cfh: 3, activeBans: 0 },
|
||||||
|
});
|
||||||
|
expect(mocks.fetchTicketQueueOpenCounts).not.toHaveBeenCalled();
|
||||||
|
expect(mocks.execute).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
vi.clearAllMocks();
|
||||||
|
mocks.execute.mockResolvedValueOnce([[{ total: 4 }], []]);
|
||||||
|
await expect(load([PERMS.MOD_BANS_VIEW])).resolves.toMatchObject({
|
||||||
|
ok: true,
|
||||||
|
data: { tickets: 0, helpTickets: 0, cfh: 0, activeBans: 4 },
|
||||||
|
});
|
||||||
|
expect(mocks.fetchTicketQueueOpenCounts).not.toHaveBeenCalled();
|
||||||
|
expect(mocks.execute).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
vi.clearAllMocks();
|
||||||
|
mocks.fetchTicketQueueOpenCounts.mockResolvedValue({
|
||||||
|
cmsOpen: 1,
|
||||||
|
helpOpen: 2,
|
||||||
|
});
|
||||||
|
mocks.execute
|
||||||
|
.mockResolvedValueOnce([[{ total: 3 }], []])
|
||||||
|
.mockResolvedValueOnce([[{ total: 4 }], []]);
|
||||||
|
const mixed = await load([
|
||||||
|
PERMS.MOD_TICKETS_VIEW,
|
||||||
|
PERMS.MOD_CFH_VIEW,
|
||||||
|
PERMS.MOD_BANS_VIEW,
|
||||||
|
]);
|
||||||
|
expect(mocks.fetchTicketQueueOpenCounts).toHaveBeenCalledTimes(1);
|
||||||
|
expect(mocks.execute).toHaveBeenCalledTimes(2);
|
||||||
|
expect(mixed).toMatchObject({
|
||||||
|
ok: true,
|
||||||
|
data: { tickets: 1, helpTickets: 2, cfh: 3, activeBans: 4 },
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.clearAllMocks();
|
||||||
|
await expect(load([])).resolves.toMatchObject({
|
||||||
|
ok: false,
|
||||||
|
error: { code: "FORBIDDEN" },
|
||||||
|
});
|
||||||
|
expect(mocks.fetchTicketQueueOpenCounts).not.toHaveBeenCalled();
|
||||||
|
expect(mocks.execute).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
type HousekeepingSearchProvider,
|
type HousekeepingSearchProvider,
|
||||||
ok,
|
ok,
|
||||||
} from "../../foundation/contracts";
|
} from "../../foundation/contracts";
|
||||||
|
import { isSafeHousekeepingHref } from "../../foundation/housekeeping-href";
|
||||||
import { peopleCommunityQuery } from "./queries/community";
|
import { peopleCommunityQuery } from "./queries/community";
|
||||||
import { peopleSupportQuery } from "./queries/support";
|
import { peopleSupportQuery } from "./queries/support";
|
||||||
import { peopleUsersQuery } from "./queries/users";
|
import { peopleUsersQuery } from "./queries/users";
|
||||||
@@ -47,17 +48,6 @@ export interface PeopleSearchAdapters {
|
|||||||
): Promise<readonly PeopleSearchCandidate[]>;
|
): Promise<readonly PeopleSearchCandidate[]>;
|
||||||
}
|
}
|
||||||
|
|
||||||
function safeHref(href: string): href is `/ase/${string}` {
|
|
||||||
return (
|
|
||||||
href.startsWith("/ase/") &&
|
|
||||||
!href.includes("\\") &&
|
|
||||||
!Array.from(href).some((character) => {
|
|
||||||
const code = character.codePointAt(0) ?? 0;
|
|
||||||
return code < 32 || code === 127;
|
|
||||||
})
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function boundedLimit(limit: number): number {
|
function boundedLimit(limit: number): number {
|
||||||
return Number.isFinite(limit) ? Math.min(25, Math.max(1, Math.trunc(limit))) : 25;
|
return Number.isFinite(limit) ? Math.min(25, Math.max(1, Math.trunc(limit))) : 25;
|
||||||
}
|
}
|
||||||
@@ -82,7 +72,7 @@ function createProvider(
|
|||||||
candidates
|
candidates
|
||||||
.filter(
|
.filter(
|
||||||
(item) =>
|
(item) =>
|
||||||
safeHref(item.href) &&
|
isSafeHousekeepingHref(item.href) &&
|
||||||
satisfiesCapability(context, item.capability),
|
satisfiesCapability(context, item.capability),
|
||||||
)
|
)
|
||||||
.slice(0, limit)
|
.slice(0, limit)
|
||||||
|
|||||||
@@ -0,0 +1,423 @@
|
|||||||
|
import "server-only";
|
||||||
|
|
||||||
|
import { eq } from "drizzle-orm";
|
||||||
|
import { Ban, type Db, SupportTickets, User } from "@/lib/db";
|
||||||
|
import type {
|
||||||
|
AuditEntry,
|
||||||
|
HousekeepingAuditWriter,
|
||||||
|
} from "@/lib/services/audit";
|
||||||
|
import type {
|
||||||
|
ModerationAction,
|
||||||
|
ModerationActionTransport,
|
||||||
|
} from "@/lib/services/moderation";
|
||||||
|
import type {
|
||||||
|
HousekeepingCapabilityContext,
|
||||||
|
HousekeepingErrorCode,
|
||||||
|
HousekeepingPartialCompletion,
|
||||||
|
} from "../../../foundation/contracts";
|
||||||
|
|
||||||
|
export const PEOPLE_MODERATION_MUTATION_OPERATIONS = [
|
||||||
|
"cfh.sanction",
|
||||||
|
"ban.create",
|
||||||
|
"ban.lift",
|
||||||
|
"moderation.action",
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export type PeopleModerationMutationOperation =
|
||||||
|
(typeof PEOPLE_MODERATION_MUTATION_OPERATIONS)[number];
|
||||||
|
|
||||||
|
export interface PeopleModerationMutationContext {
|
||||||
|
readonly capability: HousekeepingCapabilityContext;
|
||||||
|
readonly correlationId: string;
|
||||||
|
readonly legacy: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PeopleModerationMutationSnapshot {
|
||||||
|
readonly before: Readonly<Record<string, unknown>> | null;
|
||||||
|
readonly after: Readonly<Record<string, unknown>> | null;
|
||||||
|
readonly output?: Readonly<Record<string, unknown>>;
|
||||||
|
readonly completion?: HousekeepingPartialCompletion;
|
||||||
|
}
|
||||||
|
|
||||||
|
type AuditOutcome = "intent" | "success" | "failure" | "partial";
|
||||||
|
type ModerationTransport = ModerationActionTransport & {
|
||||||
|
disconnectUser(userId: number, username?: string): Promise<boolean>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface PeopleModerationMutationDependencies {
|
||||||
|
readonly db: Db;
|
||||||
|
readonly writeAudit: HousekeepingAuditWriter["write"];
|
||||||
|
readonly auditEntry: (
|
||||||
|
context: PeopleModerationMutationContext,
|
||||||
|
operation: PeopleModerationMutationOperation,
|
||||||
|
target: string,
|
||||||
|
targetId: number | undefined,
|
||||||
|
snapshot: PeopleModerationMutationSnapshot,
|
||||||
|
outcome: AuditOutcome,
|
||||||
|
) => AuditEntry;
|
||||||
|
readonly failure: (
|
||||||
|
code: HousekeepingErrorCode,
|
||||||
|
messageKey: string,
|
||||||
|
) => Error;
|
||||||
|
readonly record: (input: unknown) => Record<string, unknown>;
|
||||||
|
readonly positiveInteger: (value: unknown) => number;
|
||||||
|
readonly nonNegativeInteger: (value: unknown) => number;
|
||||||
|
readonly normalizedText: (
|
||||||
|
value: unknown,
|
||||||
|
max: number,
|
||||||
|
required?: boolean,
|
||||||
|
) => string;
|
||||||
|
readonly requireRcon: (result: boolean) => Promise<void>;
|
||||||
|
readonly transport: ModerationTransport;
|
||||||
|
readonly executeModerationAction: (
|
||||||
|
transport: ModerationActionTransport,
|
||||||
|
input: ModerationAction,
|
||||||
|
) => Promise<boolean>;
|
||||||
|
readonly logStaffActivity: (input: {
|
||||||
|
staffId: number;
|
||||||
|
action: string;
|
||||||
|
description: string;
|
||||||
|
targetType?: string;
|
||||||
|
targetId?: number;
|
||||||
|
}) => Promise<void>;
|
||||||
|
readonly runExternalWithAudit: (
|
||||||
|
context: PeopleModerationMutationContext,
|
||||||
|
operation: PeopleModerationMutationOperation,
|
||||||
|
target: string,
|
||||||
|
targetId: number | undefined,
|
||||||
|
snapshot: PeopleModerationMutationSnapshot,
|
||||||
|
execute: () => Promise<void>,
|
||||||
|
confirmedFailureSnapshot: PeopleModerationMutationSnapshot,
|
||||||
|
) => Promise<PeopleModerationMutationSnapshot>;
|
||||||
|
readonly finalizeExternalWithAudit: (
|
||||||
|
context: PeopleModerationMutationContext,
|
||||||
|
operation: PeopleModerationMutationOperation,
|
||||||
|
target: string,
|
||||||
|
targetId: number | undefined,
|
||||||
|
snapshot: PeopleModerationMutationSnapshot,
|
||||||
|
execute: () => Promise<void>,
|
||||||
|
mutationCommitted?: boolean,
|
||||||
|
confirmedFailureSnapshot?: PeopleModerationMutationSnapshot,
|
||||||
|
) => Promise<PeopleModerationMutationSnapshot>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PeopleModerationMutationExecutor {
|
||||||
|
execute(
|
||||||
|
operation: PeopleModerationMutationOperation,
|
||||||
|
input: unknown,
|
||||||
|
context: PeopleModerationMutationContext,
|
||||||
|
): Promise<PeopleModerationMutationSnapshot>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isPeopleModerationMutationOperation(
|
||||||
|
operation: string,
|
||||||
|
): operation is PeopleModerationMutationOperation {
|
||||||
|
return (PEOPLE_MODERATION_MUTATION_OPERATIONS as readonly string[]).includes(
|
||||||
|
operation,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createPeopleModerationMutationExecutor(
|
||||||
|
dependencies: PeopleModerationMutationDependencies,
|
||||||
|
): PeopleModerationMutationExecutor {
|
||||||
|
const {
|
||||||
|
db,
|
||||||
|
writeAudit,
|
||||||
|
auditEntry,
|
||||||
|
failure,
|
||||||
|
record,
|
||||||
|
positiveInteger,
|
||||||
|
nonNegativeInteger,
|
||||||
|
normalizedText,
|
||||||
|
requireRcon,
|
||||||
|
transport,
|
||||||
|
executeModerationAction,
|
||||||
|
logStaffActivity,
|
||||||
|
runExternalWithAudit,
|
||||||
|
finalizeExternalWithAudit,
|
||||||
|
} = dependencies;
|
||||||
|
|
||||||
|
function moderationAction(data: Record<string, unknown>): ModerationAction {
|
||||||
|
const action = normalizedText(data.action, 32);
|
||||||
|
if (action === "kick" || action === "unmute") {
|
||||||
|
return { action, userId: positiveInteger(data.userId) };
|
||||||
|
}
|
||||||
|
if (action === "mute") {
|
||||||
|
const duration = nonNegativeInteger(data.duration);
|
||||||
|
if (duration > 525_600) {
|
||||||
|
throw failure("VALIDATION", "errors.housekeeping.validation");
|
||||||
|
}
|
||||||
|
return { action, userId: positiveInteger(data.userId), duration };
|
||||||
|
}
|
||||||
|
if (action === "alert") {
|
||||||
|
return {
|
||||||
|
action,
|
||||||
|
userId: positiveInteger(data.userId),
|
||||||
|
message: normalizedText(data.message, 500),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (action === "room-kick") {
|
||||||
|
return { action, roomId: positiveInteger(data.roomId) };
|
||||||
|
}
|
||||||
|
if (action === "broadcast") {
|
||||||
|
const type = normalizedText(data.type, 16);
|
||||||
|
if (type !== "hotel" && type !== "staff") {
|
||||||
|
throw failure("VALIDATION", "errors.housekeeping.validation");
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
action,
|
||||||
|
type,
|
||||||
|
message: normalizedText(data.message, 500),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
throw failure("VALIDATION", "errors.housekeeping.validation");
|
||||||
|
}
|
||||||
|
|
||||||
|
function actionTarget(input: ModerationAction): {
|
||||||
|
target: string;
|
||||||
|
targetId: number | undefined;
|
||||||
|
} {
|
||||||
|
if ("userId" in input) return { target: "User", targetId: input.userId };
|
||||||
|
if ("roomId" in input) return { target: "Room", targetId: input.roomId };
|
||||||
|
return { target: "broadcast", targetId: undefined };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deliverModerationAction(
|
||||||
|
input: ModerationAction,
|
||||||
|
context: PeopleModerationMutationContext,
|
||||||
|
): Promise<void> {
|
||||||
|
const delivered = await executeModerationAction(transport, input);
|
||||||
|
if (!context.legacy) await requireRcon(delivered);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function executeModerationMutation(
|
||||||
|
input: unknown,
|
||||||
|
context: PeopleModerationMutationContext,
|
||||||
|
): Promise<PeopleModerationMutationSnapshot> {
|
||||||
|
const action = moderationAction(record(input));
|
||||||
|
const target = actionTarget(action);
|
||||||
|
const snapshot = {
|
||||||
|
before: null,
|
||||||
|
after: { ...action },
|
||||||
|
} satisfies PeopleModerationMutationSnapshot;
|
||||||
|
return runExternalWithAudit(
|
||||||
|
context,
|
||||||
|
"moderation.action",
|
||||||
|
target.target,
|
||||||
|
target.targetId,
|
||||||
|
snapshot,
|
||||||
|
() => deliverModerationAction(action, context),
|
||||||
|
{ before: null, after: null },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function executeCfhSanction(
|
||||||
|
input: unknown,
|
||||||
|
context: PeopleModerationMutationContext,
|
||||||
|
): Promise<PeopleModerationMutationSnapshot> {
|
||||||
|
const data = record(input);
|
||||||
|
const ticketId = positiveInteger(data.ticketId);
|
||||||
|
const action = moderationAction({
|
||||||
|
...data,
|
||||||
|
message: data.message ?? data.reason,
|
||||||
|
});
|
||||||
|
const reason = normalizedText(data.reason, 500);
|
||||||
|
let snapshot!: PeopleModerationMutationSnapshot;
|
||||||
|
await db.transaction(async (tx) => {
|
||||||
|
const [ticket] = await tx
|
||||||
|
.select({
|
||||||
|
id: SupportTickets.id,
|
||||||
|
state: SupportTickets.state,
|
||||||
|
modId: SupportTickets.modId,
|
||||||
|
})
|
||||||
|
.from(SupportTickets)
|
||||||
|
.where(eq(SupportTickets.id, ticketId))
|
||||||
|
.limit(1);
|
||||||
|
if (!ticket) {
|
||||||
|
throw failure("NOT_FOUND", "errors.housekeeping.notFound");
|
||||||
|
}
|
||||||
|
snapshot = {
|
||||||
|
before: {
|
||||||
|
id: ticket.id,
|
||||||
|
state: ticket.state,
|
||||||
|
moderatorId: ticket.modId,
|
||||||
|
},
|
||||||
|
after: {
|
||||||
|
id: ticket.id,
|
||||||
|
state: 2,
|
||||||
|
moderatorId: context.capability.actor.id,
|
||||||
|
sanction: action.action,
|
||||||
|
reason,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
await tx
|
||||||
|
.update(SupportTickets)
|
||||||
|
.set({ state: 2, modId: context.capability.actor.id })
|
||||||
|
.where(eq(SupportTickets.id, ticketId));
|
||||||
|
await writeAudit(
|
||||||
|
auditEntry(
|
||||||
|
context,
|
||||||
|
"cfh.sanction",
|
||||||
|
"support_tickets",
|
||||||
|
ticketId,
|
||||||
|
snapshot,
|
||||||
|
"intent",
|
||||||
|
),
|
||||||
|
tx,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
return finalizeExternalWithAudit(
|
||||||
|
context,
|
||||||
|
"cfh.sanction",
|
||||||
|
"support_tickets",
|
||||||
|
ticketId,
|
||||||
|
snapshot,
|
||||||
|
() => deliverModerationAction(action, context),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function executeBanMutation(
|
||||||
|
operation: Extract<PeopleModerationMutationOperation, `ban.${string}`>,
|
||||||
|
input: unknown,
|
||||||
|
context: PeopleModerationMutationContext,
|
||||||
|
): Promise<PeopleModerationMutationSnapshot> {
|
||||||
|
const data = record(input);
|
||||||
|
if (operation === "ban.create") {
|
||||||
|
const userId = positiveInteger(data.userId);
|
||||||
|
const hours = nonNegativeInteger(data.hours);
|
||||||
|
const type = normalizedText(data.type, 16);
|
||||||
|
const reason = normalizedText(data.reason, 500);
|
||||||
|
if (
|
||||||
|
hours > 876_000 ||
|
||||||
|
!["account", "ip", "machine", "super"].includes(type)
|
||||||
|
) {
|
||||||
|
throw failure("VALIDATION", "errors.housekeeping.validation");
|
||||||
|
}
|
||||||
|
const now = Math.floor(Date.now() / 1_000);
|
||||||
|
const banExpire = hours > 0 ? now + hours * 3_600 : 0;
|
||||||
|
let username: string | null = null;
|
||||||
|
let banId = 0;
|
||||||
|
let snapshot!: PeopleModerationMutationSnapshot;
|
||||||
|
await db.transaction(async (tx) => {
|
||||||
|
const [user] = await tx
|
||||||
|
.select({ username: User.username })
|
||||||
|
.from(User)
|
||||||
|
.where(eq(User.id, userId))
|
||||||
|
.limit(1);
|
||||||
|
username = user?.username ?? null;
|
||||||
|
const [result] = await tx.insert(Ban).values({
|
||||||
|
userId,
|
||||||
|
ip: "",
|
||||||
|
machineId: "",
|
||||||
|
userStaffId: context.capability.actor.id,
|
||||||
|
timestamp: now,
|
||||||
|
banExpire,
|
||||||
|
banReason: reason,
|
||||||
|
type: type as "account" | "ip" | "machine" | "super",
|
||||||
|
cfhTopic: -1,
|
||||||
|
});
|
||||||
|
banId = positiveInteger(result.insertId);
|
||||||
|
snapshot = {
|
||||||
|
before: null,
|
||||||
|
after: {
|
||||||
|
id: banId,
|
||||||
|
userId,
|
||||||
|
type,
|
||||||
|
reason,
|
||||||
|
expiresAt: banExpire,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
await writeAudit(
|
||||||
|
auditEntry(
|
||||||
|
context,
|
||||||
|
operation,
|
||||||
|
"Ban",
|
||||||
|
banId,
|
||||||
|
snapshot,
|
||||||
|
"intent",
|
||||||
|
),
|
||||||
|
tx,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
return finalizeExternalWithAudit(
|
||||||
|
context,
|
||||||
|
operation,
|
||||||
|
"Ban",
|
||||||
|
banId,
|
||||||
|
snapshot,
|
||||||
|
async () => {
|
||||||
|
let delivered = true;
|
||||||
|
if (username !== null) {
|
||||||
|
delivered = await transport.disconnectUser(userId, username);
|
||||||
|
}
|
||||||
|
await logStaffActivity({
|
||||||
|
staffId: context.capability.actor.id,
|
||||||
|
action: "user_ban",
|
||||||
|
description: `Banned user #${userId} (${type}, ${
|
||||||
|
hours > 0 ? `${hours}h` : "permanent"
|
||||||
|
}): ${reason}`,
|
||||||
|
targetType: "user",
|
||||||
|
targetId: userId,
|
||||||
|
});
|
||||||
|
await requireRcon(delivered);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const id = positiveInteger(data.id);
|
||||||
|
let snapshot!: PeopleModerationMutationSnapshot;
|
||||||
|
await db.transaction(async (tx) => {
|
||||||
|
const [existing] = await tx
|
||||||
|
.select({
|
||||||
|
id: Ban.id,
|
||||||
|
userId: Ban.userId,
|
||||||
|
reason: Ban.banReason,
|
||||||
|
type: Ban.type,
|
||||||
|
})
|
||||||
|
.from(Ban)
|
||||||
|
.where(eq(Ban.id, id))
|
||||||
|
.limit(1);
|
||||||
|
await tx.delete(Ban).where(eq(Ban.id, id));
|
||||||
|
snapshot = { before: existing ?? null, after: null };
|
||||||
|
await writeAudit(
|
||||||
|
auditEntry(
|
||||||
|
context,
|
||||||
|
operation,
|
||||||
|
"Ban",
|
||||||
|
id,
|
||||||
|
snapshot,
|
||||||
|
"intent",
|
||||||
|
),
|
||||||
|
tx,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
return finalizeExternalWithAudit(
|
||||||
|
context,
|
||||||
|
operation,
|
||||||
|
"Ban",
|
||||||
|
id,
|
||||||
|
snapshot,
|
||||||
|
() =>
|
||||||
|
logStaffActivity({
|
||||||
|
staffId: context.capability.actor.id,
|
||||||
|
action: "ban_lift",
|
||||||
|
description: `Lifted ban #${id}`,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
execute(operation, input, context) {
|
||||||
|
if (operation === "moderation.action") {
|
||||||
|
return executeModerationMutation(input, context);
|
||||||
|
}
|
||||||
|
if (operation === "cfh.sanction") {
|
||||||
|
return executeCfhSanction(input, context);
|
||||||
|
}
|
||||||
|
if (operation === "ban.create" || operation === "ban.lift") {
|
||||||
|
return executeBanMutation(operation, input, context);
|
||||||
|
}
|
||||||
|
throw failure("VALIDATION", "errors.housekeeping.validation");
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
+291
-1
@@ -17,8 +17,11 @@ const mocks = vi.hoisted(() => ({
|
|||||||
giveCredits: vi.fn(),
|
giveCredits: vi.fn(),
|
||||||
giveDuckets: vi.fn(),
|
giveDuckets: vi.fn(),
|
||||||
givePointsGotw: vi.fn(),
|
givePointsGotw: vi.fn(),
|
||||||
|
hotelAlert: vi.fn(),
|
||||||
|
kickAll: vi.fn(),
|
||||||
muteUser: vi.fn(),
|
muteUser: vi.fn(),
|
||||||
setTradeLock: vi.fn(),
|
setTradeLock: vi.fn(),
|
||||||
|
staffAlert: vi.fn(),
|
||||||
unmuteUser: vi.fn(),
|
unmuteUser: vi.fn(),
|
||||||
updateWordFilter: vi.fn(),
|
updateWordFilter: vi.fn(),
|
||||||
},
|
},
|
||||||
@@ -101,7 +104,8 @@ vi.mock("@/lib/services/audit", async (importOriginal) => ({
|
|||||||
...(await importOriginal<typeof import("@/lib/services/audit")>()),
|
...(await importOriginal<typeof import("@/lib/services/audit")>()),
|
||||||
logAudit: mocks.audit,
|
logAudit: mocks.audit,
|
||||||
}));
|
}));
|
||||||
vi.mock("@/lib/services/moderation", () => ({
|
vi.mock("@/lib/services/moderation", async (importOriginal) => ({
|
||||||
|
...(await importOriginal<typeof import("@/lib/services/moderation")>()),
|
||||||
reloadWordFilter: mocks.reloadWordFilter,
|
reloadWordFilter: mocks.reloadWordFilter,
|
||||||
}));
|
}));
|
||||||
vi.mock("@/lib/services/rcon", () => ({ rcon: mocks.rcon }));
|
vi.mock("@/lib/services/rcon", () => ({ rcon: mocks.rcon }));
|
||||||
@@ -979,4 +983,290 @@ describe("People production workflow adapter", () => {
|
|||||||
mocks.audit.mock.calls.map((call) => (call[0] as AuditEntry).outcome),
|
mocks.audit.mock.calls.map((call) => (call[0] as AuditEntry).outcome),
|
||||||
).toEqual(["intent", "partial"]);
|
).toEqual(["intent", "partial"]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
{
|
||||||
|
operation: "ticket.reply",
|
||||||
|
input: { ticketId: 7, message: "Handled" },
|
||||||
|
rows: [[{ id: 7, assigneeId: null, status: "open", priority: "normal" }]],
|
||||||
|
transactional: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
operation: "ticket.assign",
|
||||||
|
input: { ticketId: 7, assigneeId: 42 },
|
||||||
|
rows: [[{ id: 7, assigneeId: null, status: "open", priority: "normal" }]],
|
||||||
|
transactional: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
operation: "ticket.status",
|
||||||
|
input: { ticketId: 7, status: "waiting" },
|
||||||
|
rows: [[{ id: 7, assigneeId: 42, status: "open", priority: "normal" }]],
|
||||||
|
transactional: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
operation: "ticket.priority",
|
||||||
|
input: { ticketId: 7, priority: "urgent" },
|
||||||
|
rows: [[{ id: 7, assigneeId: 42, status: "open", priority: "normal" }]],
|
||||||
|
transactional: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
operation: "ticket-template.change",
|
||||||
|
input: { action: "update", id: 88, title: "Updated" },
|
||||||
|
rows: [[{ id: 88, title: "Greeting", content: "Hello", category: "general", sortOrder: 0 }]],
|
||||||
|
transactional: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
operation: "help-ticket.reply",
|
||||||
|
input: { ticketId: "9007199254740993", content: "Handled" },
|
||||||
|
rows: [[{ id: 9_007_199_254_740_993n, userId: 7, open: true, title: "Appeal" }]],
|
||||||
|
transactional: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
operation: "help-ticket.status",
|
||||||
|
input: { ticketId: "9007199254740993", status: "close" },
|
||||||
|
rows: [[{ id: 9_007_199_254_740_993n, userId: 7, open: true, title: "Appeal" }]],
|
||||||
|
transactional: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
operation: "help-ticket.unban",
|
||||||
|
input: { ticketId: "9007199254740993" },
|
||||||
|
rows: [[{ id: 9_007_199_254_740_993n, userId: 7, open: true, title: "Appeal" }]],
|
||||||
|
transactional: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
operation: "cfh.resolve",
|
||||||
|
input: { ticketId: 9, state: 2 },
|
||||||
|
rows: [[{ id: 9, state: 1, modId: 8 }]],
|
||||||
|
transactional: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
operation: "cfh.sanction",
|
||||||
|
input: { ticketId: 9, action: "alert", userId: 7, reason: "Repeated abuse" },
|
||||||
|
rows: [[{ id: 9, state: 1, modId: 8 }]],
|
||||||
|
transactional: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
operation: "ban.create",
|
||||||
|
input: { userId: 7, hours: 24, type: "account", reason: "Repeated abuse" },
|
||||||
|
rows: [[{ username: "Alice" }]],
|
||||||
|
transactional: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
operation: "ban.lift",
|
||||||
|
input: { id: 12 },
|
||||||
|
rows: [[{ id: 12, userId: 7, reason: "Repeated abuse", type: "account" }]],
|
||||||
|
transactional: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
operation: "moderation.action",
|
||||||
|
input: { action: "kick", userId: 7 },
|
||||||
|
rows: [],
|
||||||
|
transactional: false,
|
||||||
|
},
|
||||||
|
] as const)(
|
||||||
|
"executes and audits the direct Task 13 production operation $operation",
|
||||||
|
async ({ operation, input, rows, transactional }) => {
|
||||||
|
mocks.selectQueue.push(...rows.map((row) => [...row]));
|
||||||
|
const result = await peopleMutationService.execute(
|
||||||
|
{ ...invocation, legacy: false, correlationId: `task13-${operation}` },
|
||||||
|
operation,
|
||||||
|
input,
|
||||||
|
);
|
||||||
|
expect(result).toMatchObject({
|
||||||
|
ok: true,
|
||||||
|
correlationId: `task13-${operation}`,
|
||||||
|
});
|
||||||
|
expect(JSON.stringify(result)).toContain(`task13-${operation}`);
|
||||||
|
expect(mocks.audit).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
action: `people.${operation}`,
|
||||||
|
correlationId: `task13-${operation}`,
|
||||||
|
}),
|
||||||
|
...(
|
||||||
|
transactional
|
||||||
|
? [expect.any(Object)]
|
||||||
|
: []
|
||||||
|
),
|
||||||
|
);
|
||||||
|
if (transactional) expect(mocks.transaction).toHaveBeenCalledTimes(1);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
it("clears closedAt when a closed CMS ticket is reopened", async () => {
|
||||||
|
mocks.selectQueue.push(
|
||||||
|
[{ id: 7, assigneeId: 42, status: "open", priority: "normal" }],
|
||||||
|
[{ id: 7, assigneeId: 42, status: "closed", priority: "normal" }],
|
||||||
|
);
|
||||||
|
await peopleMutationService.execute(invocation, "ticket.status", {
|
||||||
|
ticketId: 7,
|
||||||
|
status: "closed",
|
||||||
|
});
|
||||||
|
await peopleMutationService.execute(invocation, "ticket.status", {
|
||||||
|
ticketId: 7,
|
||||||
|
status: "open",
|
||||||
|
});
|
||||||
|
expect(mocks.updateSet).toHaveBeenNthCalledWith(
|
||||||
|
1,
|
||||||
|
expect.objectContaining({ status: "closed", closedAt: expect.any(Date) }),
|
||||||
|
);
|
||||||
|
expect(mocks.updateSet).toHaveBeenNthCalledWith(
|
||||||
|
2,
|
||||||
|
expect.objectContaining({ status: "open", closedAt: null }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
["kick", { action: "kick", userId: 7 }, "disconnectUser"],
|
||||||
|
["mute", { action: "mute", userId: 7, duration: 60 }, "muteUser"],
|
||||||
|
["unmute", { action: "unmute", userId: 7 }, "unmuteUser"],
|
||||||
|
["alert", { action: "alert", userId: 7, message: "Stop" }, "alertUser"],
|
||||||
|
["room-kick", { action: "room-kick", roomId: 12 }, "kickAll"],
|
||||||
|
["broadcast", { action: "broadcast", message: "Notice", type: "staff" }, "staffAlert"],
|
||||||
|
] as const)(
|
||||||
|
"preserves legacy confirmed-false success for %s while recording an observed outcome",
|
||||||
|
async (_label, input, transport) => {
|
||||||
|
mocks.rcon[transport].mockResolvedValueOnce(false);
|
||||||
|
const result = await peopleMutationService.execute(
|
||||||
|
{ ...invocation, correlationId: `legacy-false-${transport}` },
|
||||||
|
"moderation.action",
|
||||||
|
input,
|
||||||
|
);
|
||||||
|
expect(result).toMatchObject({ ok: true });
|
||||||
|
expect(
|
||||||
|
mocks.audit.mock.calls.map((call) => (call[0] as AuditEntry).outcome),
|
||||||
|
).toEqual(["intent", "success"]);
|
||||||
|
expect(mocks.audit.mock.invocationCallOrder[0]).toBeLessThan(
|
||||||
|
mocks.rcon[transport].mock.invocationCallOrder[0],
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
it("keeps Housekeeping false strict, propagates throws, and blocks delivery when intent audit fails", async () => {
|
||||||
|
mocks.rcon.disconnectUser.mockResolvedValueOnce(false);
|
||||||
|
await expect(
|
||||||
|
peopleMutationService.execute(
|
||||||
|
{ ...invocation, legacy: false, correlationId: "hk-false" },
|
||||||
|
"moderation.action",
|
||||||
|
{ action: "kick", userId: 7 },
|
||||||
|
),
|
||||||
|
).resolves.toMatchObject({
|
||||||
|
ok: false,
|
||||||
|
error: { code: "DEPENDENCY_UNAVAILABLE" },
|
||||||
|
});
|
||||||
|
expect(
|
||||||
|
mocks.audit.mock.calls.map((call) => (call[0] as AuditEntry).outcome),
|
||||||
|
).toEqual(["intent", "failure"]);
|
||||||
|
expect(mocks.audit.mock.invocationCallOrder[0]).toBeLessThan(
|
||||||
|
mocks.rcon.disconnectUser.mock.invocationCallOrder[0],
|
||||||
|
);
|
||||||
|
|
||||||
|
vi.clearAllMocks();
|
||||||
|
mocks.resolveServerContext.mockResolvedValue(context());
|
||||||
|
mocks.audit.mockResolvedValue(undefined);
|
||||||
|
mocks.rcon.disconnectUser.mockRejectedValueOnce(new Error("RCON unavailable"));
|
||||||
|
await expect(
|
||||||
|
peopleMutationService.execute(
|
||||||
|
{ ...invocation, correlationId: "legacy-throw" },
|
||||||
|
"moderation.action",
|
||||||
|
{ action: "kick", userId: 7 },
|
||||||
|
),
|
||||||
|
).resolves.toMatchObject({
|
||||||
|
ok: false,
|
||||||
|
error: { code: "DEPENDENCY_UNAVAILABLE" },
|
||||||
|
});
|
||||||
|
expect(
|
||||||
|
mocks.audit.mock.calls.map((call) => (call[0] as AuditEntry).outcome),
|
||||||
|
).toEqual(["intent", "failure"]);
|
||||||
|
|
||||||
|
vi.clearAllMocks();
|
||||||
|
mocks.resolveServerContext.mockResolvedValue(context());
|
||||||
|
mocks.audit.mockRejectedValueOnce(new Error("audit unavailable"));
|
||||||
|
await expect(
|
||||||
|
peopleMutationService.execute(
|
||||||
|
{ ...invocation, correlationId: "intent-failure" },
|
||||||
|
"moderation.action",
|
||||||
|
{ action: "kick", userId: 7 },
|
||||||
|
),
|
||||||
|
).resolves.toMatchObject({
|
||||||
|
ok: false,
|
||||||
|
error: { code: "DEPENDENCY_UNAVAILABLE" },
|
||||||
|
});
|
||||||
|
expect(mocks.rcon.disconnectUser).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rolls back mixed workflow intent failure before external delivery", async () => {
|
||||||
|
mocks.selectQueue.push([{ id: 9, state: 1, modId: 8 }]);
|
||||||
|
mocks.audit.mockRejectedValueOnce(new Error("intent audit unavailable"));
|
||||||
|
const result = await peopleMutationService.execute(
|
||||||
|
{ ...invocation, legacy: false, correlationId: "cfh-intent-failure" },
|
||||||
|
"cfh.sanction",
|
||||||
|
{ ticketId: 9, action: "alert", userId: 7, reason: "Repeated abuse" },
|
||||||
|
);
|
||||||
|
expect(result).toMatchObject({
|
||||||
|
ok: false,
|
||||||
|
error: { code: "DEPENDENCY_UNAVAILABLE" },
|
||||||
|
correlationId: "cfh-intent-failure",
|
||||||
|
});
|
||||||
|
expect(mocks.transaction).toHaveBeenCalledTimes(1);
|
||||||
|
expect(mocks.updateSet).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ state: 2, modId: 42 }),
|
||||||
|
);
|
||||||
|
expect(mocks.rcon.alertUser).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps help-ticket BIGINT identifiers canonical and JSON serializable", async () => {
|
||||||
|
mocks.selectQueue.push([
|
||||||
|
{
|
||||||
|
id: 9_007_199_254_740_993n,
|
||||||
|
userId: 7,
|
||||||
|
open: true,
|
||||||
|
title: "Appeal",
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
const result = await peopleMutationService.execute(
|
||||||
|
{ ...invocation, legacy: false, correlationId: "help-bigint" },
|
||||||
|
"help-ticket.reply",
|
||||||
|
{ ticketId: "9007199254740993", content: "Handled" },
|
||||||
|
);
|
||||||
|
expect(result).toMatchObject({
|
||||||
|
ok: true,
|
||||||
|
data: {
|
||||||
|
before: { id: "9007199254740993" },
|
||||||
|
after: { id: "9007199254740993" },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(() => JSON.stringify(result)).not.toThrow();
|
||||||
|
expect(mocks.audit).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
targetId: undefined,
|
||||||
|
before: expect.objectContaining({ id: "9007199254740993" }),
|
||||||
|
after: expect.objectContaining({ id: "9007199254740993" }),
|
||||||
|
}),
|
||||||
|
expect.any(Object),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reports committed CFH database work plus failed external sync as typed partial", async () => {
|
||||||
|
mocks.selectQueue.push([{ id: 9, state: 1, modId: 8 }]);
|
||||||
|
mocks.rcon.alertUser.mockResolvedValueOnce(false);
|
||||||
|
const result = await peopleMutationService.execute(
|
||||||
|
{ ...invocation, legacy: false, correlationId: "cfh-partial" },
|
||||||
|
"cfh.sanction",
|
||||||
|
{ ticketId: 9, action: "alert", userId: 7, reason: "Repeated abuse" },
|
||||||
|
);
|
||||||
|
expect(result).toMatchObject({
|
||||||
|
ok: true,
|
||||||
|
completion: {
|
||||||
|
status: "partial",
|
||||||
|
external: "failed",
|
||||||
|
audit: "persisted",
|
||||||
|
},
|
||||||
|
correlationId: "cfh-partial",
|
||||||
|
});
|
||||||
|
expect(mocks.transaction).toHaveBeenCalledTimes(1);
|
||||||
|
expect(
|
||||||
|
mocks.audit.mock.calls.map((call) => (call[0] as AuditEntry).outcome),
|
||||||
|
).toEqual(["intent", "partial"]);
|
||||||
|
expect(mocks.audit.mock.calls[0][1]).toBeDefined();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
@@ -93,4 +93,24 @@ describe("People production server authority", () => {
|
|||||||
expect(alertUser).not.toHaveBeenCalled();
|
expect(alertUser).not.toHaveBeenCalled();
|
||||||
expect(audit).not.toHaveBeenCalled();
|
expect(audit).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("blocks Task 13 moderation work before intent audit or transport on actor mismatch", async () => {
|
||||||
|
resolveServerContext.mockResolvedValue(context([PERMS.MOD_ACTIONS]));
|
||||||
|
const result = await peopleMutationService.execute(
|
||||||
|
{
|
||||||
|
correlationId: "task13-server-authority",
|
||||||
|
expectedActorId: 99,
|
||||||
|
legacy: true,
|
||||||
|
},
|
||||||
|
"moderation.action",
|
||||||
|
{ action: "alert", userId: 7, message: "Stop" },
|
||||||
|
);
|
||||||
|
expect(result).toMatchObject({
|
||||||
|
ok: false,
|
||||||
|
error: { code: "FORBIDDEN" },
|
||||||
|
correlationId: "task13-server-authority",
|
||||||
|
});
|
||||||
|
expect(alertUser).not.toHaveBeenCalled();
|
||||||
|
expect(audit).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
@@ -17,28 +17,21 @@ import {
|
|||||||
Items,
|
Items,
|
||||||
Rooms,
|
Rooms,
|
||||||
Sanctions,
|
Sanctions,
|
||||||
SupportTickets,
|
|
||||||
User,
|
User,
|
||||||
UsersBadges,
|
UsersBadges,
|
||||||
UsersCurrency,
|
UsersCurrency,
|
||||||
UsersSettings,
|
UsersSettings,
|
||||||
WebsiteHelpCenterTicketReplies,
|
|
||||||
WebsiteHelpCenterTickets,
|
|
||||||
WebsiteIpBlacklist,
|
WebsiteIpBlacklist,
|
||||||
WebsiteIpWhitelist,
|
WebsiteIpWhitelist,
|
||||||
WebsiteSetting,
|
WebsiteSetting,
|
||||||
WebsiteStaffApplications,
|
WebsiteStaffApplications,
|
||||||
WebsiteTeams,
|
WebsiteTeams,
|
||||||
WebsiteTicket,
|
|
||||||
WebsiteTicketMessage,
|
|
||||||
WebsiteTicketTemplate,
|
|
||||||
WebsiteWordfilter,
|
WebsiteWordfilter,
|
||||||
} from "@/lib/db";
|
} from "@/lib/db";
|
||||||
import { PERMS } from "@/lib/permission-slugs";
|
import { PERMS } from "@/lib/permission-slugs";
|
||||||
import { logAudit } from "@/lib/services/audit";
|
import { logAudit } from "@/lib/services/audit";
|
||||||
import {
|
import {
|
||||||
executeModerationAction,
|
executeModerationAction,
|
||||||
type ModerationAction,
|
|
||||||
reloadWordFilter,
|
reloadWordFilter,
|
||||||
} from "@/lib/services/moderation";
|
} from "@/lib/services/moderation";
|
||||||
import { rcon } from "@/lib/services/rcon";
|
import { rcon } from "@/lib/services/rcon";
|
||||||
@@ -57,6 +50,14 @@ import {
|
|||||||
ok,
|
ok,
|
||||||
} from "../../../foundation/contracts";
|
} from "../../../foundation/contracts";
|
||||||
import { getHousekeepingCapabilityContext } from "../../../foundation/server-capability-context";
|
import { getHousekeepingCapabilityContext } from "../../../foundation/server-capability-context";
|
||||||
|
import {
|
||||||
|
createPeopleModerationMutationExecutor,
|
||||||
|
isPeopleModerationMutationOperation,
|
||||||
|
} from "./moderation-mutations";
|
||||||
|
import {
|
||||||
|
createPeopleSupportMutationExecutor,
|
||||||
|
isPeopleSupportMutationOperation,
|
||||||
|
} from "./support-mutations";
|
||||||
|
|
||||||
export type PeopleMutationOperation =
|
export type PeopleMutationOperation =
|
||||||
| "user.update"
|
| "user.update"
|
||||||
@@ -1802,655 +1803,35 @@ async function executeWordFilterUpdate(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
type TicketMutationOperation = Extract<
|
const supportMutationExecutor = createPeopleSupportMutationExecutor({
|
||||||
PeopleMutationOperation,
|
db,
|
||||||
`ticket.${string}`
|
writeAudit: logAudit,
|
||||||
>;
|
auditEntry: canonicalAuditEntry,
|
||||||
|
failure: (code, messageKey) => new PeopleMutationFailure(code, messageKey),
|
||||||
|
record,
|
||||||
|
positiveInteger,
|
||||||
|
nonNegativeInteger,
|
||||||
|
normalizedText,
|
||||||
|
canonicalTicketId,
|
||||||
|
auditTargetId,
|
||||||
|
});
|
||||||
|
|
||||||
async function executeTicketMutation(
|
const moderationMutationExecutor = createPeopleModerationMutationExecutor({
|
||||||
operation: TicketMutationOperation,
|
db,
|
||||||
input: unknown,
|
writeAudit: logAudit,
|
||||||
context: PeopleMutationContext,
|
auditEntry: canonicalAuditEntry,
|
||||||
): Promise<PeopleMutationSnapshot> {
|
failure: (code, messageKey) => new PeopleMutationFailure(code, messageKey),
|
||||||
const data = record(input);
|
record,
|
||||||
const ticketId = positiveInteger(data.ticketId);
|
positiveInteger,
|
||||||
let snapshot!: PeopleMutationSnapshot;
|
nonNegativeInteger,
|
||||||
await db.transaction(async (tx) => {
|
normalizedText,
|
||||||
const [ticket] = await tx
|
requireRcon,
|
||||||
.select({
|
transport: rcon,
|
||||||
id: WebsiteTicket.id,
|
executeModerationAction,
|
||||||
assigneeId: WebsiteTicket.assigneeId,
|
logStaffActivity,
|
||||||
status: WebsiteTicket.status,
|
runExternalWithAudit,
|
||||||
priority: WebsiteTicket.priority,
|
finalizeExternalWithAudit,
|
||||||
})
|
});
|
||||||
.from(WebsiteTicket)
|
|
||||||
.where(eq(WebsiteTicket.id, ticketId))
|
|
||||||
.limit(1);
|
|
||||||
if (!ticket) {
|
|
||||||
throw new PeopleMutationFailure(
|
|
||||||
"NOT_FOUND",
|
|
||||||
"errors.housekeeping.notFound",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
const before = {
|
|
||||||
id: ticket.id,
|
|
||||||
assigneeId: ticket.assigneeId,
|
|
||||||
status: ticket.status,
|
|
||||||
priority: ticket.priority,
|
|
||||||
};
|
|
||||||
const now = new Date();
|
|
||||||
if (operation === "ticket.reply") {
|
|
||||||
const message = normalizedText(data.message, 5_000);
|
|
||||||
await tx.insert(WebsiteTicketMessage).values({
|
|
||||||
ticketId,
|
|
||||||
userId: context.capability.actor.id,
|
|
||||||
message,
|
|
||||||
isStaff: 1,
|
|
||||||
});
|
|
||||||
const assigneeId = ticket.assigneeId ?? context.capability.actor.id;
|
|
||||||
await tx
|
|
||||||
.update(WebsiteTicket)
|
|
||||||
.set({ status: "waiting", assigneeId, updatedAt: now })
|
|
||||||
.where(eq(WebsiteTicket.id, ticketId));
|
|
||||||
snapshot = {
|
|
||||||
before,
|
|
||||||
after: { ...before, assigneeId, status: "waiting" },
|
|
||||||
};
|
|
||||||
} else if (operation === "ticket.assign") {
|
|
||||||
const assigneeId =
|
|
||||||
data.assigneeId === null ? null : positiveInteger(data.assigneeId);
|
|
||||||
const status = assigneeId === null ? "open" : "in_progress";
|
|
||||||
await tx
|
|
||||||
.update(WebsiteTicket)
|
|
||||||
.set({ assigneeId, status, updatedAt: now })
|
|
||||||
.where(eq(WebsiteTicket.id, ticketId));
|
|
||||||
snapshot = { before, after: { ...before, assigneeId, status } };
|
|
||||||
} else if (operation === "ticket.status") {
|
|
||||||
const status = normalizedText(data.status, 32);
|
|
||||||
if (!["open", "in_progress", "waiting", "closed"].includes(status)) {
|
|
||||||
throw new PeopleMutationFailure(
|
|
||||||
"VALIDATION",
|
|
||||||
"errors.housekeeping.validation",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
const assigneeId =
|
|
||||||
status === "in_progress" && ticket.assigneeId === null
|
|
||||||
? context.capability.actor.id
|
|
||||||
: ticket.assigneeId;
|
|
||||||
await tx
|
|
||||||
.update(WebsiteTicket)
|
|
||||||
.set({
|
|
||||||
status,
|
|
||||||
assigneeId,
|
|
||||||
updatedAt: now,
|
|
||||||
...(status === "closed" ? { closedAt: now } : {}),
|
|
||||||
})
|
|
||||||
.where(eq(WebsiteTicket.id, ticketId));
|
|
||||||
snapshot = { before, after: { ...before, assigneeId, status } };
|
|
||||||
} else {
|
|
||||||
const priority = normalizedText(data.priority, 32);
|
|
||||||
if (!["low", "normal", "high", "urgent"].includes(priority)) {
|
|
||||||
throw new PeopleMutationFailure(
|
|
||||||
"VALIDATION",
|
|
||||||
"errors.housekeeping.validation",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
await tx
|
|
||||||
.update(WebsiteTicket)
|
|
||||||
.set({ priority, updatedAt: now })
|
|
||||||
.where(eq(WebsiteTicket.id, ticketId));
|
|
||||||
snapshot = { before, after: { ...before, priority } };
|
|
||||||
}
|
|
||||||
await logAudit(
|
|
||||||
canonicalAuditEntry(
|
|
||||||
context,
|
|
||||||
operation,
|
|
||||||
"WebsiteTicket",
|
|
||||||
ticketId,
|
|
||||||
snapshot,
|
|
||||||
"success",
|
|
||||||
),
|
|
||||||
tx,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
return snapshot;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function executeTicketTemplateChange(
|
|
||||||
input: unknown,
|
|
||||||
context: PeopleMutationContext,
|
|
||||||
): Promise<PeopleMutationSnapshot> {
|
|
||||||
const data = record(input);
|
|
||||||
const action = normalizedText(data.action, 16);
|
|
||||||
let snapshot!: PeopleMutationSnapshot;
|
|
||||||
await db.transaction(async (tx) => {
|
|
||||||
let targetId: number | undefined;
|
|
||||||
if (action === "create") {
|
|
||||||
const values = {
|
|
||||||
title: normalizedText(data.title, 255),
|
|
||||||
content: normalizedText(data.content, 5_000),
|
|
||||||
category: normalizedText(data.category ?? "general", 50),
|
|
||||||
sortOrder:
|
|
||||||
data.sortOrder === undefined ? 0 : nonNegativeInteger(data.sortOrder),
|
|
||||||
};
|
|
||||||
const [result] = await tx.insert(WebsiteTicketTemplate).values(values);
|
|
||||||
targetId = positiveInteger(result.insertId);
|
|
||||||
snapshot = { before: null, after: { id: targetId, ...values } };
|
|
||||||
} else {
|
|
||||||
const id = positiveInteger(data.id);
|
|
||||||
targetId = id;
|
|
||||||
const [existing] = await tx
|
|
||||||
.select({
|
|
||||||
id: WebsiteTicketTemplate.id,
|
|
||||||
title: WebsiteTicketTemplate.title,
|
|
||||||
content: WebsiteTicketTemplate.content,
|
|
||||||
category: WebsiteTicketTemplate.category,
|
|
||||||
sortOrder: WebsiteTicketTemplate.sortOrder,
|
|
||||||
})
|
|
||||||
.from(WebsiteTicketTemplate)
|
|
||||||
.where(eq(WebsiteTicketTemplate.id, id))
|
|
||||||
.limit(1);
|
|
||||||
if (action === "update" && !existing) {
|
|
||||||
throw new PeopleMutationFailure(
|
|
||||||
"NOT_FOUND",
|
|
||||||
"errors.housekeeping.notFound",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (action === "delete") {
|
|
||||||
await tx
|
|
||||||
.delete(WebsiteTicketTemplate)
|
|
||||||
.where(eq(WebsiteTicketTemplate.id, id));
|
|
||||||
snapshot = { before: existing ?? null, after: null };
|
|
||||||
} else if (action === "update") {
|
|
||||||
const changes = {
|
|
||||||
...(data.title === undefined
|
|
||||||
? {}
|
|
||||||
: { title: normalizedText(data.title, 255) }),
|
|
||||||
...(data.content === undefined
|
|
||||||
? {}
|
|
||||||
: { content: normalizedText(data.content, 5_000) }),
|
|
||||||
...(data.category === undefined
|
|
||||||
? {}
|
|
||||||
: { category: normalizedText(data.category, 50, false) }),
|
|
||||||
...(data.sortOrder === undefined
|
|
||||||
? {}
|
|
||||||
: { sortOrder: nonNegativeInteger(data.sortOrder) }),
|
|
||||||
};
|
|
||||||
await tx
|
|
||||||
.update(WebsiteTicketTemplate)
|
|
||||||
.set(changes)
|
|
||||||
.where(eq(WebsiteTicketTemplate.id, id));
|
|
||||||
snapshot = { before: existing ?? null, after: { ...existing, ...changes } };
|
|
||||||
} else {
|
|
||||||
throw new PeopleMutationFailure(
|
|
||||||
"VALIDATION",
|
|
||||||
"errors.housekeeping.validation",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
await logAudit(
|
|
||||||
canonicalAuditEntry(
|
|
||||||
context,
|
|
||||||
"ticket-template.change",
|
|
||||||
"WebsiteTicketTemplate",
|
|
||||||
targetId,
|
|
||||||
snapshot,
|
|
||||||
"success",
|
|
||||||
),
|
|
||||||
tx,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
return snapshot;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function executeHelpTicketMutation(
|
|
||||||
operation: Extract<PeopleMutationOperation, `help-ticket.${string}`>,
|
|
||||||
input: unknown,
|
|
||||||
context: PeopleMutationContext,
|
|
||||||
): Promise<PeopleMutationSnapshot> {
|
|
||||||
const data = record(input);
|
|
||||||
let ticketId: bigint;
|
|
||||||
try {
|
|
||||||
ticketId = canonicalTicketId(data.ticketId as string);
|
|
||||||
} catch {
|
|
||||||
throw new PeopleMutationFailure(
|
|
||||||
"VALIDATION",
|
|
||||||
"errors.housekeeping.validation",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
let snapshot!: PeopleMutationSnapshot;
|
|
||||||
await db.transaction(async (tx) => {
|
|
||||||
const [ticket] = await tx
|
|
||||||
.select({
|
|
||||||
id: WebsiteHelpCenterTickets.id,
|
|
||||||
userId: WebsiteHelpCenterTickets.userId,
|
|
||||||
open: WebsiteHelpCenterTickets.open,
|
|
||||||
title: WebsiteHelpCenterTickets.title,
|
|
||||||
})
|
|
||||||
.from(WebsiteHelpCenterTickets)
|
|
||||||
.where(eq(WebsiteHelpCenterTickets.id, ticketId))
|
|
||||||
.limit(1);
|
|
||||||
if (!ticket) {
|
|
||||||
throw new PeopleMutationFailure(
|
|
||||||
"NOT_FOUND",
|
|
||||||
"errors.housekeeping.notFound",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
const id = ticketId.toString();
|
|
||||||
const before = {
|
|
||||||
id,
|
|
||||||
userId: ticket.userId,
|
|
||||||
open: Boolean(ticket.open),
|
|
||||||
title: ticket.title,
|
|
||||||
};
|
|
||||||
const now = new Date();
|
|
||||||
if (operation === "help-ticket.reply") {
|
|
||||||
await tx.insert(WebsiteHelpCenterTicketReplies).values({
|
|
||||||
ticketId,
|
|
||||||
userId: context.capability.actor.id,
|
|
||||||
content: normalizedText(data.content, 5_000),
|
|
||||||
createdAt: now,
|
|
||||||
updatedAt: now,
|
|
||||||
});
|
|
||||||
await tx
|
|
||||||
.update(WebsiteHelpCenterTickets)
|
|
||||||
.set({ updatedAt: now })
|
|
||||||
.where(eq(WebsiteHelpCenterTickets.id, ticketId));
|
|
||||||
snapshot = { before, after: before };
|
|
||||||
} else if (operation === "help-ticket.status") {
|
|
||||||
const status = normalizedText(data.status, 16);
|
|
||||||
const open =
|
|
||||||
status === "reopen"
|
|
||||||
? true
|
|
||||||
: status === "close"
|
|
||||||
? false
|
|
||||||
: null;
|
|
||||||
if (open === null) {
|
|
||||||
throw new PeopleMutationFailure(
|
|
||||||
"VALIDATION",
|
|
||||||
"errors.housekeeping.validation",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (Boolean(ticket.open) === open) {
|
|
||||||
throw new PeopleMutationFailure(
|
|
||||||
"CONFLICT",
|
|
||||||
"errors.housekeeping.conflict",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
await tx
|
|
||||||
.update(WebsiteHelpCenterTickets)
|
|
||||||
.set({ open, updatedAt: now })
|
|
||||||
.where(eq(WebsiteHelpCenterTickets.id, ticketId));
|
|
||||||
snapshot = { before, after: { ...before, open } };
|
|
||||||
} else {
|
|
||||||
if (ticket.userId === null) {
|
|
||||||
throw new PeopleMutationFailure(
|
|
||||||
"CONFLICT",
|
|
||||||
"errors.housekeeping.conflict",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
const deleted = await tx.delete(Ban).where(eq(Ban.userId, ticket.userId));
|
|
||||||
const removed = Number(
|
|
||||||
(deleted as unknown as [{ affectedRows: number }])[0]?.affectedRows ?? 0,
|
|
||||||
);
|
|
||||||
if (ticket.open) {
|
|
||||||
await tx
|
|
||||||
.update(WebsiteHelpCenterTickets)
|
|
||||||
.set({ open: false, updatedAt: now })
|
|
||||||
.where(eq(WebsiteHelpCenterTickets.id, ticketId));
|
|
||||||
}
|
|
||||||
snapshot = {
|
|
||||||
before,
|
|
||||||
after: { ...before, open: false, removedBans: removed },
|
|
||||||
output: { removed, userId: ticket.userId },
|
|
||||||
};
|
|
||||||
}
|
|
||||||
await logAudit(
|
|
||||||
canonicalAuditEntry(
|
|
||||||
context,
|
|
||||||
operation,
|
|
||||||
"WebsiteHelpCenterTickets",
|
|
||||||
auditTargetId(ticketId),
|
|
||||||
snapshot,
|
|
||||||
"success",
|
|
||||||
),
|
|
||||||
tx,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
return snapshot;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function executeCfhResolve(
|
|
||||||
input: unknown,
|
|
||||||
context: PeopleMutationContext,
|
|
||||||
): Promise<PeopleMutationSnapshot> {
|
|
||||||
const data = record(input);
|
|
||||||
const ticketId = positiveInteger(data.ticketId);
|
|
||||||
const state = nonNegativeInteger(data.state);
|
|
||||||
if (state > 3) {
|
|
||||||
throw new PeopleMutationFailure(
|
|
||||||
"VALIDATION",
|
|
||||||
"errors.housekeeping.validation",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
let snapshot!: PeopleMutationSnapshot;
|
|
||||||
await db.transaction(async (tx) => {
|
|
||||||
const [ticket] = await tx
|
|
||||||
.select({
|
|
||||||
id: SupportTickets.id,
|
|
||||||
state: SupportTickets.state,
|
|
||||||
modId: SupportTickets.modId,
|
|
||||||
})
|
|
||||||
.from(SupportTickets)
|
|
||||||
.where(eq(SupportTickets.id, ticketId))
|
|
||||||
.limit(1);
|
|
||||||
if (!ticket) {
|
|
||||||
throw new PeopleMutationFailure(
|
|
||||||
"NOT_FOUND",
|
|
||||||
"errors.housekeeping.notFound",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
snapshot = {
|
|
||||||
before: { id: ticket.id, state: ticket.state, moderatorId: ticket.modId },
|
|
||||||
after: {
|
|
||||||
id: ticket.id,
|
|
||||||
state,
|
|
||||||
moderatorId: context.capability.actor.id,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
await tx
|
|
||||||
.update(SupportTickets)
|
|
||||||
.set({ state, modId: context.capability.actor.id })
|
|
||||||
.where(eq(SupportTickets.id, ticketId));
|
|
||||||
await logAudit(
|
|
||||||
canonicalAuditEntry(
|
|
||||||
context,
|
|
||||||
"cfh.resolve",
|
|
||||||
"support_tickets",
|
|
||||||
ticketId,
|
|
||||||
snapshot,
|
|
||||||
"success",
|
|
||||||
),
|
|
||||||
tx,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
return snapshot;
|
|
||||||
}
|
|
||||||
|
|
||||||
function moderationAction(data: Record<string, unknown>): ModerationAction {
|
|
||||||
const action = normalizedText(data.action, 32);
|
|
||||||
if (action === "kick" || action === "unmute") {
|
|
||||||
return { action, userId: positiveInteger(data.userId) };
|
|
||||||
}
|
|
||||||
if (action === "mute") {
|
|
||||||
const duration = nonNegativeInteger(data.duration);
|
|
||||||
if (duration > 525_600) {
|
|
||||||
throw new PeopleMutationFailure(
|
|
||||||
"VALIDATION",
|
|
||||||
"errors.housekeeping.validation",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return { action, userId: positiveInteger(data.userId), duration };
|
|
||||||
}
|
|
||||||
if (action === "alert") {
|
|
||||||
return {
|
|
||||||
action,
|
|
||||||
userId: positiveInteger(data.userId),
|
|
||||||
message: normalizedText(data.message, 500),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
if (action === "room-kick") {
|
|
||||||
return { action, roomId: positiveInteger(data.roomId) };
|
|
||||||
}
|
|
||||||
if (action === "broadcast") {
|
|
||||||
const type = normalizedText(data.type, 16);
|
|
||||||
if (type !== "hotel" && type !== "staff") {
|
|
||||||
throw new PeopleMutationFailure(
|
|
||||||
"VALIDATION",
|
|
||||||
"errors.housekeeping.validation",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
action,
|
|
||||||
type,
|
|
||||||
message: normalizedText(data.message, 500),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
throw new PeopleMutationFailure(
|
|
||||||
"VALIDATION",
|
|
||||||
"errors.housekeeping.validation",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function actionTarget(input: ModerationAction): {
|
|
||||||
target: string;
|
|
||||||
targetId: number | undefined;
|
|
||||||
} {
|
|
||||||
if ("userId" in input) return { target: "User", targetId: input.userId };
|
|
||||||
if ("roomId" in input) return { target: "Room", targetId: input.roomId };
|
|
||||||
return { target: "broadcast", targetId: undefined };
|
|
||||||
}
|
|
||||||
|
|
||||||
async function deliverModerationAction(input: ModerationAction): Promise<void> {
|
|
||||||
await requireRcon(await executeModerationAction(rcon, input));
|
|
||||||
}
|
|
||||||
|
|
||||||
async function executeModerationMutation(
|
|
||||||
input: unknown,
|
|
||||||
context: PeopleMutationContext,
|
|
||||||
): Promise<PeopleMutationSnapshot> {
|
|
||||||
const action = moderationAction(record(input));
|
|
||||||
const target = actionTarget(action);
|
|
||||||
const snapshot = {
|
|
||||||
before: null,
|
|
||||||
after: { ...action },
|
|
||||||
} satisfies PeopleMutationSnapshot;
|
|
||||||
return runExternalWithAudit(
|
|
||||||
context,
|
|
||||||
"moderation.action",
|
|
||||||
target.target,
|
|
||||||
target.targetId,
|
|
||||||
snapshot,
|
|
||||||
() => deliverModerationAction(action),
|
|
||||||
{ before: null, after: null },
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function executeCfhSanction(
|
|
||||||
input: unknown,
|
|
||||||
context: PeopleMutationContext,
|
|
||||||
): Promise<PeopleMutationSnapshot> {
|
|
||||||
const data = record(input);
|
|
||||||
const ticketId = positiveInteger(data.ticketId);
|
|
||||||
const action = moderationAction({
|
|
||||||
...data,
|
|
||||||
message: data.message ?? data.reason,
|
|
||||||
});
|
|
||||||
const reason = normalizedText(data.reason, 500);
|
|
||||||
let snapshot!: PeopleMutationSnapshot;
|
|
||||||
await db.transaction(async (tx) => {
|
|
||||||
const [ticket] = await tx
|
|
||||||
.select({
|
|
||||||
id: SupportTickets.id,
|
|
||||||
state: SupportTickets.state,
|
|
||||||
modId: SupportTickets.modId,
|
|
||||||
})
|
|
||||||
.from(SupportTickets)
|
|
||||||
.where(eq(SupportTickets.id, ticketId))
|
|
||||||
.limit(1);
|
|
||||||
if (!ticket) {
|
|
||||||
throw new PeopleMutationFailure(
|
|
||||||
"NOT_FOUND",
|
|
||||||
"errors.housekeeping.notFound",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
snapshot = {
|
|
||||||
before: { id: ticket.id, state: ticket.state, moderatorId: ticket.modId },
|
|
||||||
after: {
|
|
||||||
id: ticket.id,
|
|
||||||
state: 2,
|
|
||||||
moderatorId: context.capability.actor.id,
|
|
||||||
sanction: action.action,
|
|
||||||
reason,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
await tx
|
|
||||||
.update(SupportTickets)
|
|
||||||
.set({ state: 2, modId: context.capability.actor.id })
|
|
||||||
.where(eq(SupportTickets.id, ticketId));
|
|
||||||
await logAudit(
|
|
||||||
canonicalAuditEntry(
|
|
||||||
context,
|
|
||||||
"cfh.sanction",
|
|
||||||
"support_tickets",
|
|
||||||
ticketId,
|
|
||||||
snapshot,
|
|
||||||
"intent",
|
|
||||||
),
|
|
||||||
tx,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
return finalizeExternalWithAudit(
|
|
||||||
context,
|
|
||||||
"cfh.sanction",
|
|
||||||
"support_tickets",
|
|
||||||
ticketId,
|
|
||||||
snapshot,
|
|
||||||
() => deliverModerationAction(action),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function executeBanMutation(
|
|
||||||
operation: "ban.create" | "ban.lift",
|
|
||||||
input: unknown,
|
|
||||||
context: PeopleMutationContext,
|
|
||||||
): Promise<PeopleMutationSnapshot> {
|
|
||||||
const data = record(input);
|
|
||||||
if (operation === "ban.create") {
|
|
||||||
const userId = positiveInteger(data.userId);
|
|
||||||
const hours = nonNegativeInteger(data.hours);
|
|
||||||
const type = normalizedText(data.type, 16);
|
|
||||||
const reason = normalizedText(data.reason, 500);
|
|
||||||
if (
|
|
||||||
hours > 876_000 ||
|
|
||||||
!["account", "ip", "machine", "super"].includes(type)
|
|
||||||
) {
|
|
||||||
throw new PeopleMutationFailure(
|
|
||||||
"VALIDATION",
|
|
||||||
"errors.housekeeping.validation",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
const now = Math.floor(Date.now() / 1_000);
|
|
||||||
const banExpire = hours > 0 ? now + hours * 3_600 : 0;
|
|
||||||
let username: string | null = null;
|
|
||||||
let banId = 0;
|
|
||||||
let snapshot!: PeopleMutationSnapshot;
|
|
||||||
await db.transaction(async (tx) => {
|
|
||||||
const [user] = await tx
|
|
||||||
.select({ username: User.username })
|
|
||||||
.from(User)
|
|
||||||
.where(eq(User.id, userId))
|
|
||||||
.limit(1);
|
|
||||||
username = user?.username ?? null;
|
|
||||||
const [result] = await tx.insert(Ban).values({
|
|
||||||
userId,
|
|
||||||
ip: "",
|
|
||||||
machineId: "",
|
|
||||||
userStaffId: context.capability.actor.id,
|
|
||||||
timestamp: now,
|
|
||||||
banExpire,
|
|
||||||
banReason: reason,
|
|
||||||
type: type as "account" | "ip" | "machine" | "super",
|
|
||||||
cfhTopic: -1,
|
|
||||||
});
|
|
||||||
banId = positiveInteger(result.insertId);
|
|
||||||
snapshot = {
|
|
||||||
before: null,
|
|
||||||
after: {
|
|
||||||
id: banId,
|
|
||||||
userId,
|
|
||||||
type,
|
|
||||||
reason,
|
|
||||||
expiresAt: banExpire,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
await logAudit(
|
|
||||||
canonicalAuditEntry(
|
|
||||||
context,
|
|
||||||
operation,
|
|
||||||
"Ban",
|
|
||||||
banId,
|
|
||||||
snapshot,
|
|
||||||
"intent",
|
|
||||||
),
|
|
||||||
tx,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
return finalizeExternalWithAudit(
|
|
||||||
context,
|
|
||||||
operation,
|
|
||||||
"Ban",
|
|
||||||
banId,
|
|
||||||
snapshot,
|
|
||||||
async () => {
|
|
||||||
let delivered = true;
|
|
||||||
if (username !== null) {
|
|
||||||
delivered = await rcon.disconnectUser(userId, username);
|
|
||||||
}
|
|
||||||
await logStaffActivity({
|
|
||||||
staffId: context.capability.actor.id,
|
|
||||||
action: "user_ban",
|
|
||||||
description: `Banned user #${userId} (${type}, ${
|
|
||||||
hours > 0 ? `${hours}h` : "permanent"
|
|
||||||
}): ${reason}`,
|
|
||||||
targetType: "user",
|
|
||||||
targetId: userId,
|
|
||||||
});
|
|
||||||
await requireRcon(delivered);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
const id = positiveInteger(data.id);
|
|
||||||
let snapshot!: PeopleMutationSnapshot;
|
|
||||||
await db.transaction(async (tx) => {
|
|
||||||
const [existing] = await tx
|
|
||||||
.select({
|
|
||||||
id: Ban.id,
|
|
||||||
userId: Ban.userId,
|
|
||||||
reason: Ban.banReason,
|
|
||||||
type: Ban.type,
|
|
||||||
})
|
|
||||||
.from(Ban)
|
|
||||||
.where(eq(Ban.id, id))
|
|
||||||
.limit(1);
|
|
||||||
await tx.delete(Ban).where(eq(Ban.id, id));
|
|
||||||
snapshot = { before: existing ?? null, after: null };
|
|
||||||
await logAudit(
|
|
||||||
canonicalAuditEntry(
|
|
||||||
context,
|
|
||||||
operation,
|
|
||||||
"Ban",
|
|
||||||
id,
|
|
||||||
snapshot,
|
|
||||||
"intent",
|
|
||||||
),
|
|
||||||
tx,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
return finalizeExternalWithAudit(
|
|
||||||
context,
|
|
||||||
operation,
|
|
||||||
"Ban",
|
|
||||||
id,
|
|
||||||
snapshot,
|
|
||||||
() =>
|
|
||||||
logStaffActivity({
|
|
||||||
staffId: context.capability.actor.id,
|
|
||||||
action: "ban_lift",
|
|
||||||
description: `Lifted ban #${id}`,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const productionPeopleMutationAdapter: PeopleMutationAdapter = {
|
const productionPeopleMutationAdapter: PeopleMutationAdapter = {
|
||||||
async execute(operation, input, context) {
|
async execute(operation, input, context) {
|
||||||
@@ -2481,26 +1862,11 @@ const productionPeopleMutationAdapter: PeopleMutationAdapter = {
|
|||||||
if (operation === "word-filter.update") {
|
if (operation === "word-filter.update") {
|
||||||
return executeWordFilterUpdate(input, context);
|
return executeWordFilterUpdate(input, context);
|
||||||
}
|
}
|
||||||
if (operation.startsWith("ticket.")) {
|
if (isPeopleSupportMutationOperation(operation)) {
|
||||||
return executeTicketMutation(operation as TicketMutationOperation, input, context);
|
return supportMutationExecutor.execute(operation, input, context);
|
||||||
}
|
}
|
||||||
if (operation === "ticket-template.change") {
|
if (isPeopleModerationMutationOperation(operation)) {
|
||||||
return executeTicketTemplateChange(input, context);
|
return moderationMutationExecutor.execute(operation, input, context);
|
||||||
}
|
|
||||||
if (operation.startsWith("help-ticket.")) {
|
|
||||||
return executeHelpTicketMutation(
|
|
||||||
operation as Extract<PeopleMutationOperation, `help-ticket.${string}`>,
|
|
||||||
input,
|
|
||||||
context,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (operation === "cfh.resolve") return executeCfhResolve(input, context);
|
|
||||||
if (operation === "cfh.sanction") return executeCfhSanction(input, context);
|
|
||||||
if (operation === "ban.create" || operation === "ban.lift") {
|
|
||||||
return executeBanMutation(operation, input, context);
|
|
||||||
}
|
|
||||||
if (operation === "moderation.action") {
|
|
||||||
return executeModerationMutation(input, context);
|
|
||||||
}
|
}
|
||||||
throw new PeopleMutationFailure(
|
throw new PeopleMutationFailure(
|
||||||
"VALIDATION",
|
"VALIDATION",
|
||||||
|
|||||||
@@ -0,0 +1,497 @@
|
|||||||
|
import "server-only";
|
||||||
|
|
||||||
|
import { eq } from "drizzle-orm";
|
||||||
|
import {
|
||||||
|
Ban,
|
||||||
|
type Db,
|
||||||
|
SupportTickets,
|
||||||
|
WebsiteHelpCenterTicketReplies,
|
||||||
|
WebsiteHelpCenterTickets,
|
||||||
|
WebsiteTicket,
|
||||||
|
WebsiteTicketMessage,
|
||||||
|
WebsiteTicketTemplate,
|
||||||
|
} from "@/lib/db";
|
||||||
|
import type {
|
||||||
|
AuditEntry,
|
||||||
|
HousekeepingAuditWriter,
|
||||||
|
} from "@/lib/services/audit";
|
||||||
|
import type {
|
||||||
|
HousekeepingCapabilityContext,
|
||||||
|
HousekeepingErrorCode,
|
||||||
|
HousekeepingPartialCompletion,
|
||||||
|
} from "../../../foundation/contracts";
|
||||||
|
|
||||||
|
export const PEOPLE_SUPPORT_MUTATION_OPERATIONS = [
|
||||||
|
"ticket.reply",
|
||||||
|
"ticket.assign",
|
||||||
|
"ticket.status",
|
||||||
|
"ticket.priority",
|
||||||
|
"ticket-template.change",
|
||||||
|
"help-ticket.reply",
|
||||||
|
"help-ticket.status",
|
||||||
|
"help-ticket.unban",
|
||||||
|
"cfh.resolve",
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export type PeopleSupportMutationOperation =
|
||||||
|
(typeof PEOPLE_SUPPORT_MUTATION_OPERATIONS)[number];
|
||||||
|
|
||||||
|
export interface PeopleSupportMutationContext {
|
||||||
|
readonly capability: HousekeepingCapabilityContext;
|
||||||
|
readonly correlationId: string;
|
||||||
|
readonly legacy: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PeopleSupportMutationSnapshot {
|
||||||
|
readonly before: Readonly<Record<string, unknown>> | null;
|
||||||
|
readonly after: Readonly<Record<string, unknown>> | null;
|
||||||
|
readonly output?: Readonly<Record<string, unknown>>;
|
||||||
|
readonly completion?: HousekeepingPartialCompletion;
|
||||||
|
}
|
||||||
|
|
||||||
|
type AuditOutcome = "intent" | "success" | "failure" | "partial";
|
||||||
|
|
||||||
|
export interface PeopleSupportMutationDependencies {
|
||||||
|
readonly db: Db;
|
||||||
|
readonly writeAudit: HousekeepingAuditWriter["write"];
|
||||||
|
readonly auditEntry: (
|
||||||
|
context: PeopleSupportMutationContext,
|
||||||
|
operation: PeopleSupportMutationOperation,
|
||||||
|
target: string,
|
||||||
|
targetId: number | undefined,
|
||||||
|
snapshot: PeopleSupportMutationSnapshot,
|
||||||
|
outcome: AuditOutcome,
|
||||||
|
) => AuditEntry;
|
||||||
|
readonly failure: (
|
||||||
|
code: HousekeepingErrorCode,
|
||||||
|
messageKey: string,
|
||||||
|
) => Error;
|
||||||
|
readonly record: (input: unknown) => Record<string, unknown>;
|
||||||
|
readonly positiveInteger: (value: unknown) => number;
|
||||||
|
readonly nonNegativeInteger: (value: unknown) => number;
|
||||||
|
readonly normalizedText: (
|
||||||
|
value: unknown,
|
||||||
|
max: number,
|
||||||
|
required?: boolean,
|
||||||
|
) => string;
|
||||||
|
readonly canonicalTicketId: (
|
||||||
|
value: string | number | bigint,
|
||||||
|
) => bigint;
|
||||||
|
readonly auditTargetId: (value: bigint) => number | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PeopleSupportMutationExecutor {
|
||||||
|
execute(
|
||||||
|
operation: PeopleSupportMutationOperation,
|
||||||
|
input: unknown,
|
||||||
|
context: PeopleSupportMutationContext,
|
||||||
|
): Promise<PeopleSupportMutationSnapshot>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isPeopleSupportMutationOperation(
|
||||||
|
operation: string,
|
||||||
|
): operation is PeopleSupportMutationOperation {
|
||||||
|
return (PEOPLE_SUPPORT_MUTATION_OPERATIONS as readonly string[]).includes(
|
||||||
|
operation,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createPeopleSupportMutationExecutor(
|
||||||
|
dependencies: PeopleSupportMutationDependencies,
|
||||||
|
): PeopleSupportMutationExecutor {
|
||||||
|
const {
|
||||||
|
db,
|
||||||
|
writeAudit,
|
||||||
|
auditEntry,
|
||||||
|
failure,
|
||||||
|
record,
|
||||||
|
positiveInteger,
|
||||||
|
nonNegativeInteger,
|
||||||
|
normalizedText,
|
||||||
|
canonicalTicketId,
|
||||||
|
auditTargetId,
|
||||||
|
} = dependencies;
|
||||||
|
|
||||||
|
async function executeTicketMutation(
|
||||||
|
operation: Extract<PeopleSupportMutationOperation, `ticket.${string}`>,
|
||||||
|
input: unknown,
|
||||||
|
context: PeopleSupportMutationContext,
|
||||||
|
): Promise<PeopleSupportMutationSnapshot> {
|
||||||
|
const data = record(input);
|
||||||
|
const ticketId = positiveInteger(data.ticketId);
|
||||||
|
let snapshot!: PeopleSupportMutationSnapshot;
|
||||||
|
await db.transaction(async (tx) => {
|
||||||
|
const [ticket] = await tx
|
||||||
|
.select({
|
||||||
|
id: WebsiteTicket.id,
|
||||||
|
assigneeId: WebsiteTicket.assigneeId,
|
||||||
|
status: WebsiteTicket.status,
|
||||||
|
priority: WebsiteTicket.priority,
|
||||||
|
})
|
||||||
|
.from(WebsiteTicket)
|
||||||
|
.where(eq(WebsiteTicket.id, ticketId))
|
||||||
|
.limit(1);
|
||||||
|
if (!ticket) {
|
||||||
|
throw failure("NOT_FOUND", "errors.housekeeping.notFound");
|
||||||
|
}
|
||||||
|
const before = {
|
||||||
|
id: ticket.id,
|
||||||
|
assigneeId: ticket.assigneeId,
|
||||||
|
status: ticket.status,
|
||||||
|
priority: ticket.priority,
|
||||||
|
};
|
||||||
|
const now = new Date();
|
||||||
|
if (operation === "ticket.reply") {
|
||||||
|
const message = normalizedText(data.message, 5_000);
|
||||||
|
await tx.insert(WebsiteTicketMessage).values({
|
||||||
|
ticketId,
|
||||||
|
userId: context.capability.actor.id,
|
||||||
|
message,
|
||||||
|
isStaff: 1,
|
||||||
|
});
|
||||||
|
const assigneeId = ticket.assigneeId ?? context.capability.actor.id;
|
||||||
|
await tx
|
||||||
|
.update(WebsiteTicket)
|
||||||
|
.set({ status: "waiting", assigneeId, updatedAt: now })
|
||||||
|
.where(eq(WebsiteTicket.id, ticketId));
|
||||||
|
snapshot = {
|
||||||
|
before,
|
||||||
|
after: { ...before, assigneeId, status: "waiting" },
|
||||||
|
};
|
||||||
|
} else if (operation === "ticket.assign") {
|
||||||
|
const assigneeId =
|
||||||
|
data.assigneeId === null ? null : positiveInteger(data.assigneeId);
|
||||||
|
const status = assigneeId === null ? "open" : "in_progress";
|
||||||
|
await tx
|
||||||
|
.update(WebsiteTicket)
|
||||||
|
.set({ assigneeId, status, updatedAt: now })
|
||||||
|
.where(eq(WebsiteTicket.id, ticketId));
|
||||||
|
snapshot = { before, after: { ...before, assigneeId, status } };
|
||||||
|
} else if (operation === "ticket.status") {
|
||||||
|
const status = normalizedText(data.status, 32);
|
||||||
|
if (!["open", "in_progress", "waiting", "closed"].includes(status)) {
|
||||||
|
throw failure("VALIDATION", "errors.housekeeping.validation");
|
||||||
|
}
|
||||||
|
const assigneeId =
|
||||||
|
status === "in_progress" && ticket.assigneeId === null
|
||||||
|
? context.capability.actor.id
|
||||||
|
: ticket.assigneeId;
|
||||||
|
await tx
|
||||||
|
.update(WebsiteTicket)
|
||||||
|
.set({
|
||||||
|
status,
|
||||||
|
assigneeId,
|
||||||
|
updatedAt: now,
|
||||||
|
closedAt: status === "closed" ? now : null,
|
||||||
|
})
|
||||||
|
.where(eq(WebsiteTicket.id, ticketId));
|
||||||
|
snapshot = { before, after: { ...before, assigneeId, status } };
|
||||||
|
} else {
|
||||||
|
const priority = normalizedText(data.priority, 32);
|
||||||
|
if (!["low", "normal", "high", "urgent"].includes(priority)) {
|
||||||
|
throw failure("VALIDATION", "errors.housekeeping.validation");
|
||||||
|
}
|
||||||
|
await tx
|
||||||
|
.update(WebsiteTicket)
|
||||||
|
.set({ priority, updatedAt: now })
|
||||||
|
.where(eq(WebsiteTicket.id, ticketId));
|
||||||
|
snapshot = { before, after: { ...before, priority } };
|
||||||
|
}
|
||||||
|
await writeAudit(
|
||||||
|
auditEntry(
|
||||||
|
context,
|
||||||
|
operation,
|
||||||
|
"WebsiteTicket",
|
||||||
|
ticketId,
|
||||||
|
snapshot,
|
||||||
|
"success",
|
||||||
|
),
|
||||||
|
tx,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
return snapshot;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function executeTicketTemplateChange(
|
||||||
|
input: unknown,
|
||||||
|
context: PeopleSupportMutationContext,
|
||||||
|
): Promise<PeopleSupportMutationSnapshot> {
|
||||||
|
const data = record(input);
|
||||||
|
const action = normalizedText(data.action, 16);
|
||||||
|
let snapshot!: PeopleSupportMutationSnapshot;
|
||||||
|
await db.transaction(async (tx) => {
|
||||||
|
let targetId: number | undefined;
|
||||||
|
if (action === "create") {
|
||||||
|
const values = {
|
||||||
|
title: normalizedText(data.title, 255),
|
||||||
|
content: normalizedText(data.content, 5_000),
|
||||||
|
category: normalizedText(data.category ?? "general", 50),
|
||||||
|
sortOrder:
|
||||||
|
data.sortOrder === undefined
|
||||||
|
? 0
|
||||||
|
: nonNegativeInteger(data.sortOrder),
|
||||||
|
};
|
||||||
|
const [result] = await tx.insert(WebsiteTicketTemplate).values(values);
|
||||||
|
targetId = positiveInteger(result.insertId);
|
||||||
|
snapshot = { before: null, after: { id: targetId, ...values } };
|
||||||
|
} else {
|
||||||
|
const id = positiveInteger(data.id);
|
||||||
|
targetId = id;
|
||||||
|
const [existing] = await tx
|
||||||
|
.select({
|
||||||
|
id: WebsiteTicketTemplate.id,
|
||||||
|
title: WebsiteTicketTemplate.title,
|
||||||
|
content: WebsiteTicketTemplate.content,
|
||||||
|
category: WebsiteTicketTemplate.category,
|
||||||
|
sortOrder: WebsiteTicketTemplate.sortOrder,
|
||||||
|
})
|
||||||
|
.from(WebsiteTicketTemplate)
|
||||||
|
.where(eq(WebsiteTicketTemplate.id, id))
|
||||||
|
.limit(1);
|
||||||
|
if (action === "update" && !existing) {
|
||||||
|
throw failure("NOT_FOUND", "errors.housekeeping.notFound");
|
||||||
|
}
|
||||||
|
if (action === "delete") {
|
||||||
|
await tx
|
||||||
|
.delete(WebsiteTicketTemplate)
|
||||||
|
.where(eq(WebsiteTicketTemplate.id, id));
|
||||||
|
snapshot = { before: existing ?? null, after: null };
|
||||||
|
} else if (action === "update") {
|
||||||
|
const changes = {
|
||||||
|
...(data.title === undefined
|
||||||
|
? {}
|
||||||
|
: { title: normalizedText(data.title, 255) }),
|
||||||
|
...(data.content === undefined
|
||||||
|
? {}
|
||||||
|
: { content: normalizedText(data.content, 5_000) }),
|
||||||
|
...(data.category === undefined
|
||||||
|
? {}
|
||||||
|
: { category: normalizedText(data.category, 50, false) }),
|
||||||
|
...(data.sortOrder === undefined
|
||||||
|
? {}
|
||||||
|
: { sortOrder: nonNegativeInteger(data.sortOrder) }),
|
||||||
|
};
|
||||||
|
await tx
|
||||||
|
.update(WebsiteTicketTemplate)
|
||||||
|
.set(changes)
|
||||||
|
.where(eq(WebsiteTicketTemplate.id, id));
|
||||||
|
snapshot = {
|
||||||
|
before: existing ?? null,
|
||||||
|
after: { ...existing, ...changes },
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
throw failure("VALIDATION", "errors.housekeeping.validation");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await writeAudit(
|
||||||
|
auditEntry(
|
||||||
|
context,
|
||||||
|
"ticket-template.change",
|
||||||
|
"WebsiteTicketTemplate",
|
||||||
|
targetId,
|
||||||
|
snapshot,
|
||||||
|
"success",
|
||||||
|
),
|
||||||
|
tx,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
return snapshot;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function executeHelpTicketMutation(
|
||||||
|
operation: Extract<
|
||||||
|
PeopleSupportMutationOperation,
|
||||||
|
`help-ticket.${string}`
|
||||||
|
>,
|
||||||
|
input: unknown,
|
||||||
|
context: PeopleSupportMutationContext,
|
||||||
|
): Promise<PeopleSupportMutationSnapshot> {
|
||||||
|
const data = record(input);
|
||||||
|
let ticketId: bigint;
|
||||||
|
try {
|
||||||
|
ticketId = canonicalTicketId(
|
||||||
|
data.ticketId as string | number | bigint,
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
throw failure("VALIDATION", "errors.housekeeping.validation");
|
||||||
|
}
|
||||||
|
let snapshot!: PeopleSupportMutationSnapshot;
|
||||||
|
await db.transaction(async (tx) => {
|
||||||
|
const [ticket] = await tx
|
||||||
|
.select({
|
||||||
|
id: WebsiteHelpCenterTickets.id,
|
||||||
|
userId: WebsiteHelpCenterTickets.userId,
|
||||||
|
open: WebsiteHelpCenterTickets.open,
|
||||||
|
title: WebsiteHelpCenterTickets.title,
|
||||||
|
})
|
||||||
|
.from(WebsiteHelpCenterTickets)
|
||||||
|
.where(eq(WebsiteHelpCenterTickets.id, ticketId))
|
||||||
|
.limit(1);
|
||||||
|
if (!ticket) {
|
||||||
|
throw failure("NOT_FOUND", "errors.housekeeping.notFound");
|
||||||
|
}
|
||||||
|
const id = ticketId.toString();
|
||||||
|
const before = {
|
||||||
|
id,
|
||||||
|
userId: ticket.userId,
|
||||||
|
open: Boolean(ticket.open),
|
||||||
|
title: ticket.title,
|
||||||
|
};
|
||||||
|
const now = new Date();
|
||||||
|
if (operation === "help-ticket.reply") {
|
||||||
|
await tx.insert(WebsiteHelpCenterTicketReplies).values({
|
||||||
|
ticketId,
|
||||||
|
userId: context.capability.actor.id,
|
||||||
|
content: normalizedText(data.content, 5_000),
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
});
|
||||||
|
await tx
|
||||||
|
.update(WebsiteHelpCenterTickets)
|
||||||
|
.set({ updatedAt: now })
|
||||||
|
.where(eq(WebsiteHelpCenterTickets.id, ticketId));
|
||||||
|
snapshot = { before, after: before };
|
||||||
|
} else if (operation === "help-ticket.status") {
|
||||||
|
const status = normalizedText(data.status, 16);
|
||||||
|
const open =
|
||||||
|
status === "reopen"
|
||||||
|
? true
|
||||||
|
: status === "close"
|
||||||
|
? false
|
||||||
|
: null;
|
||||||
|
if (open === null) {
|
||||||
|
throw failure("VALIDATION", "errors.housekeeping.validation");
|
||||||
|
}
|
||||||
|
if (Boolean(ticket.open) === open) {
|
||||||
|
throw failure("CONFLICT", "errors.housekeeping.conflict");
|
||||||
|
}
|
||||||
|
await tx
|
||||||
|
.update(WebsiteHelpCenterTickets)
|
||||||
|
.set({ open, updatedAt: now })
|
||||||
|
.where(eq(WebsiteHelpCenterTickets.id, ticketId));
|
||||||
|
snapshot = { before, after: { ...before, open } };
|
||||||
|
} else {
|
||||||
|
if (ticket.userId === null) {
|
||||||
|
throw failure("CONFLICT", "errors.housekeeping.conflict");
|
||||||
|
}
|
||||||
|
const deleted = await tx
|
||||||
|
.delete(Ban)
|
||||||
|
.where(eq(Ban.userId, ticket.userId));
|
||||||
|
const removed = Number(
|
||||||
|
(deleted as unknown as [{ affectedRows: number }])[0]?.affectedRows ??
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
if (ticket.open) {
|
||||||
|
await tx
|
||||||
|
.update(WebsiteHelpCenterTickets)
|
||||||
|
.set({ open: false, updatedAt: now })
|
||||||
|
.where(eq(WebsiteHelpCenterTickets.id, ticketId));
|
||||||
|
}
|
||||||
|
snapshot = {
|
||||||
|
before,
|
||||||
|
after: { ...before, open: false, removedBans: removed },
|
||||||
|
output: { removed, userId: ticket.userId },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
await writeAudit(
|
||||||
|
auditEntry(
|
||||||
|
context,
|
||||||
|
operation,
|
||||||
|
"WebsiteHelpCenterTickets",
|
||||||
|
auditTargetId(ticketId),
|
||||||
|
snapshot,
|
||||||
|
"success",
|
||||||
|
),
|
||||||
|
tx,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
return snapshot;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function executeCfhResolve(
|
||||||
|
input: unknown,
|
||||||
|
context: PeopleSupportMutationContext,
|
||||||
|
): Promise<PeopleSupportMutationSnapshot> {
|
||||||
|
const data = record(input);
|
||||||
|
const ticketId = positiveInteger(data.ticketId);
|
||||||
|
const state = nonNegativeInteger(data.state);
|
||||||
|
if (state > 3) {
|
||||||
|
throw failure("VALIDATION", "errors.housekeeping.validation");
|
||||||
|
}
|
||||||
|
let snapshot!: PeopleSupportMutationSnapshot;
|
||||||
|
await db.transaction(async (tx) => {
|
||||||
|
const [ticket] = await tx
|
||||||
|
.select({
|
||||||
|
id: SupportTickets.id,
|
||||||
|
state: SupportTickets.state,
|
||||||
|
modId: SupportTickets.modId,
|
||||||
|
})
|
||||||
|
.from(SupportTickets)
|
||||||
|
.where(eq(SupportTickets.id, ticketId))
|
||||||
|
.limit(1);
|
||||||
|
if (!ticket) {
|
||||||
|
throw failure("NOT_FOUND", "errors.housekeeping.notFound");
|
||||||
|
}
|
||||||
|
snapshot = {
|
||||||
|
before: {
|
||||||
|
id: ticket.id,
|
||||||
|
state: ticket.state,
|
||||||
|
moderatorId: ticket.modId,
|
||||||
|
},
|
||||||
|
after: {
|
||||||
|
id: ticket.id,
|
||||||
|
state,
|
||||||
|
moderatorId: context.capability.actor.id,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
await tx
|
||||||
|
.update(SupportTickets)
|
||||||
|
.set({ state, modId: context.capability.actor.id })
|
||||||
|
.where(eq(SupportTickets.id, ticketId));
|
||||||
|
await writeAudit(
|
||||||
|
auditEntry(
|
||||||
|
context,
|
||||||
|
"cfh.resolve",
|
||||||
|
"support_tickets",
|
||||||
|
ticketId,
|
||||||
|
snapshot,
|
||||||
|
"success",
|
||||||
|
),
|
||||||
|
tx,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
return snapshot;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
async execute(operation, input, context) {
|
||||||
|
if (operation.startsWith("ticket.")) {
|
||||||
|
return executeTicketMutation(
|
||||||
|
operation as Extract<
|
||||||
|
PeopleSupportMutationOperation,
|
||||||
|
`ticket.${string}`
|
||||||
|
>,
|
||||||
|
input,
|
||||||
|
context,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (operation === "ticket-template.change") {
|
||||||
|
return executeTicketTemplateChange(input, context);
|
||||||
|
}
|
||||||
|
if (operation.startsWith("help-ticket.")) {
|
||||||
|
return executeHelpTicketMutation(
|
||||||
|
operation as Extract<
|
||||||
|
PeopleSupportMutationOperation,
|
||||||
|
`help-ticket.${string}`
|
||||||
|
>,
|
||||||
|
input,
|
||||||
|
context,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (operation === "cfh.resolve") {
|
||||||
|
return executeCfhResolve(input, context);
|
||||||
|
}
|
||||||
|
throw failure("VALIDATION", "errors.housekeeping.validation");
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import "server-only";
|
||||||
|
|
||||||
|
import { sql } from "drizzle-orm";
|
||||||
|
import { fetchTicketQueueOpenCounts } from "@/lib/admin/ticket-queue-counts";
|
||||||
|
import { db } from "@/lib/db";
|
||||||
|
import { createPeopleQueueAggregateLoader } from "./widgets";
|
||||||
|
|
||||||
|
function count(value: unknown): number {
|
||||||
|
const parsed = Number(value);
|
||||||
|
if (!Number.isSafeInteger(parsed) || parsed < 0) {
|
||||||
|
throw new Error("invalid queue count");
|
||||||
|
}
|
||||||
|
return parsed;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const loadPeopleQueueAggregate = createPeopleQueueAggregateLoader({
|
||||||
|
async support(signal) {
|
||||||
|
if (signal.aborted) throw new Error("aborted queue aggregate");
|
||||||
|
const counts = await fetchTicketQueueOpenCounts({ strict: true });
|
||||||
|
return { tickets: counts.cmsOpen, helpTickets: counts.helpOpen };
|
||||||
|
},
|
||||||
|
async cfh(signal) {
|
||||||
|
if (signal.aborted) throw new Error("aborted queue aggregate");
|
||||||
|
const result = await db.execute(
|
||||||
|
sql`SELECT COUNT(*) AS total FROM support_tickets WHERE state <> 2`,
|
||||||
|
);
|
||||||
|
if (!Array.isArray(result) || !Array.isArray(result[0])) {
|
||||||
|
throw new Error("invalid CFH queue count");
|
||||||
|
}
|
||||||
|
return count((result[0] as Array<{ total: unknown }>)[0]?.total);
|
||||||
|
},
|
||||||
|
async activeBans(signal) {
|
||||||
|
if (signal.aborted) throw new Error("aborted queue aggregate");
|
||||||
|
const result = await db.execute(sql`
|
||||||
|
SELECT COUNT(*) AS total FROM bans
|
||||||
|
WHERE ban_expire = 0 OR ban_expire > UNIX_TIMESTAMP()
|
||||||
|
`);
|
||||||
|
if (!Array.isArray(result) || !Array.isArray(result[0])) {
|
||||||
|
throw new Error("invalid ban queue count");
|
||||||
|
}
|
||||||
|
return count((result[0] as Array<{ total: unknown }>)[0]?.total);
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -11,7 +11,6 @@ import {
|
|||||||
ok,
|
ok,
|
||||||
} from "../../foundation/contracts";
|
} from "../../foundation/contracts";
|
||||||
import type { PeopleQueueSnapshot } from "./models";
|
import type { PeopleQueueSnapshot } from "./models";
|
||||||
import { peopleSupportQuery } from "./queries/support";
|
|
||||||
|
|
||||||
export interface PeopleWidgetAdapters {
|
export interface PeopleWidgetAdapters {
|
||||||
queue(
|
queue(
|
||||||
@@ -20,6 +19,14 @@ export interface PeopleWidgetAdapters {
|
|||||||
): Promise<PeopleQueueSnapshot>;
|
): Promise<PeopleQueueSnapshot>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface PeopleQueueAggregateAdapters {
|
||||||
|
support(
|
||||||
|
signal: AbortSignal,
|
||||||
|
): Promise<Readonly<{ tickets: number; helpTickets: number }>>;
|
||||||
|
cfh(signal: AbortSignal): Promise<number>;
|
||||||
|
activeBans(signal: AbortSignal): Promise<number>;
|
||||||
|
}
|
||||||
|
|
||||||
const supportQueueCapability = anyCapability(
|
const supportQueueCapability = anyCapability(
|
||||||
PERMS.TICKETS_VIEW,
|
PERMS.TICKETS_VIEW,
|
||||||
PERMS.MOD_TICKETS_VIEW,
|
PERMS.MOD_TICKETS_VIEW,
|
||||||
@@ -38,6 +45,40 @@ const queueCapability = anyCapability(
|
|||||||
...banQueueCapability.slugs,
|
...banQueueCapability.slugs,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
function count(value: unknown): number {
|
||||||
|
const parsed = Number(value);
|
||||||
|
if (!Number.isSafeInteger(parsed) || parsed < 0) {
|
||||||
|
throw new Error("invalid queue count");
|
||||||
|
}
|
||||||
|
return parsed;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createPeopleQueueAggregateLoader(
|
||||||
|
adapters: PeopleQueueAggregateAdapters,
|
||||||
|
): PeopleWidgetAdapters["queue"] {
|
||||||
|
return async (context, signal) => {
|
||||||
|
const authorization = authorizeHousekeeping(context, queueCapability);
|
||||||
|
if (!authorization.ok) throw new Error("forbidden queue aggregate");
|
||||||
|
if (signal.aborted) throw new Error("aborted queue aggregate");
|
||||||
|
const supportAllowed = satisfiesCapability(context, supportQueueCapability);
|
||||||
|
const cfhAllowed = satisfiesCapability(context, cfhQueueCapability);
|
||||||
|
const bansAllowed = satisfiesCapability(context, banQueueCapability);
|
||||||
|
const [support, cfh, activeBans] = await Promise.all([
|
||||||
|
supportAllowed
|
||||||
|
? adapters.support(signal)
|
||||||
|
: Promise.resolve({ tickets: 0, helpTickets: 0 }),
|
||||||
|
cfhAllowed ? adapters.cfh(signal) : Promise.resolve(0),
|
||||||
|
bansAllowed ? adapters.activeBans(signal) : Promise.resolve(0),
|
||||||
|
]);
|
||||||
|
return {
|
||||||
|
tickets: count(support.tickets),
|
||||||
|
helpTickets: count(support.helpTickets),
|
||||||
|
cfh: count(cfh),
|
||||||
|
activeBans: count(activeBans),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export function createPeopleWidgets(
|
export function createPeopleWidgets(
|
||||||
adapters: PeopleWidgetAdapters,
|
adapters: PeopleWidgetAdapters,
|
||||||
): readonly HousekeepingWidgetDefinition[] {
|
): readonly HousekeepingWidgetDefinition[] {
|
||||||
@@ -80,11 +121,8 @@ export function createPeopleWidgets(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const PEOPLE_WIDGETS = createPeopleWidgets({
|
export const PEOPLE_WIDGETS = createPeopleWidgets({
|
||||||
async queue(context) {
|
async queue(context, signal) {
|
||||||
const result = await peopleSupportQuery.run(context, {
|
const { loadPeopleQueueAggregate } = await import("./widgets-production");
|
||||||
routeId: "people.support.queue",
|
return loadPeopleQueueAggregate(context, signal);
|
||||||
});
|
|
||||||
if (!result.ok || result.data.kind !== "queue") throw new Error("queue");
|
|
||||||
return result.data.queue;
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -138,10 +138,20 @@ const approvedRuntimeImports = new Map<string, ReadonlySet<string>>([
|
|||||||
"src/lib/auth",
|
"src/lib/auth",
|
||||||
"src/lib/auth/password",
|
"src/lib/auth/password",
|
||||||
"src/lib/db",
|
"src/lib/db",
|
||||||
|
"src/features/housekeeping/domains/people/services/moderation-mutations",
|
||||||
|
"src/features/housekeeping/domains/people/services/support-mutations",
|
||||||
"drizzle-orm",
|
"drizzle-orm",
|
||||||
"mysql2",
|
"mysql2",
|
||||||
]),
|
]),
|
||||||
],
|
],
|
||||||
|
[
|
||||||
|
"src/features/housekeeping/domains/people/services/moderation-mutations.ts",
|
||||||
|
new Set(["src/lib/db", "drizzle-orm"]),
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"src/features/housekeeping/domains/people/services/support-mutations.ts",
|
||||||
|
new Set(["src/lib/db", "drizzle-orm"]),
|
||||||
|
],
|
||||||
[
|
[
|
||||||
"src/features/housekeeping/domains/people/manifest.ts",
|
"src/features/housekeeping/domains/people/manifest.ts",
|
||||||
new Set([
|
new Set([
|
||||||
@@ -170,7 +180,15 @@ const approvedRuntimeImports = new Map<string, ReadonlySet<string>>([
|
|||||||
"src/features/housekeeping/domains/people/widgets.ts",
|
"src/features/housekeeping/domains/people/widgets.ts",
|
||||||
new Set([
|
new Set([
|
||||||
"src/features/housekeeping/domains/people/models",
|
"src/features/housekeeping/domains/people/models",
|
||||||
"src/features/housekeeping/domains/people/queries/support",
|
"src/features/housekeeping/domains/people/widgets-production",
|
||||||
|
]),
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"src/features/housekeeping/domains/people/widgets-production.ts",
|
||||||
|
new Set([
|
||||||
|
"src/features/housekeeping/domains/people/widgets",
|
||||||
|
"src/lib/db",
|
||||||
|
"drizzle-orm",
|
||||||
]),
|
]),
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
|
|||||||
@@ -0,0 +1,70 @@
|
|||||||
|
const HOUSEKEEPING_ORIGIN = "https://housekeeping.invalid";
|
||||||
|
|
||||||
|
function containsControlCharacter(value: string): boolean {
|
||||||
|
return Array.from(value).some((character) => {
|
||||||
|
const code = character.codePointAt(0) ?? 0;
|
||||||
|
return code < 32 || code === 127;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function decodedVariants(value: string): readonly string[] | null {
|
||||||
|
const variants = [value];
|
||||||
|
for (let pass = 0; pass < 3; pass += 1) {
|
||||||
|
let decoded: string;
|
||||||
|
try {
|
||||||
|
decoded = decodeURIComponent(variants.at(-1) ?? "");
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (decoded === variants.at(-1)) break;
|
||||||
|
variants.push(decoded);
|
||||||
|
}
|
||||||
|
return variants;
|
||||||
|
}
|
||||||
|
|
||||||
|
function containsDotSegment(path: string): boolean {
|
||||||
|
return path.split("/").some((segment) => segment === "." || segment === "..");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isSafeHousekeepingHref(href: string): boolean {
|
||||||
|
if (
|
||||||
|
href.length === 0 ||
|
||||||
|
!href.startsWith("/") ||
|
||||||
|
href.startsWith("//") ||
|
||||||
|
containsControlCharacter(href) ||
|
||||||
|
href.includes("\\")
|
||||||
|
) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const variants = decodedVariants(href);
|
||||||
|
if (
|
||||||
|
variants === null ||
|
||||||
|
variants.some(
|
||||||
|
(value) => containsControlCharacter(value) || value.includes("\\"),
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const rawPath = href.split(/[?#]/u, 1)[0] ?? "";
|
||||||
|
const pathVariants = decodedVariants(rawPath);
|
||||||
|
if (
|
||||||
|
pathVariants === null ||
|
||||||
|
pathVariants.some((path) => containsDotSegment(path))
|
||||||
|
) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
let normalized: URL;
|
||||||
|
try {
|
||||||
|
normalized = new URL(href, HOUSEKEEPING_ORIGIN);
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
normalized.origin === HOUSEKEEPING_ORIGIN &&
|
||||||
|
(normalized.pathname === "/ase" ||
|
||||||
|
normalized.pathname.startsWith("/ase/"))
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -19,23 +19,51 @@ export interface TicketReplyDb {
|
|||||||
touchTicket(ticketId: bigint, updatedAt: Date): Promise<unknown>;
|
touchTicket(ticketId: bigint, updatedAt: Date): Promise<unknown>;
|
||||||
}
|
}
|
||||||
|
|
||||||
const MAX_UNSIGNED_BIGINT = 18_446_744_073_709_551_615n;
|
export const MAX_UNSIGNED_TICKET_ID = 18_446_744_073_709_551_615n;
|
||||||
|
|
||||||
|
function boundedDecimalPattern(maximum: string): RegExp {
|
||||||
|
const alternatives = [`[1-9]\\d{0,${maximum.length - 2}}`];
|
||||||
|
for (let index = 0; index < maximum.length; index += 1) {
|
||||||
|
const maximumDigit = Number(maximum[index]);
|
||||||
|
const minimumDigit = index === 0 ? 1 : 0;
|
||||||
|
const upperDigit = maximumDigit - 1;
|
||||||
|
if (upperDigit < minimumDigit) continue;
|
||||||
|
const digit =
|
||||||
|
upperDigit === minimumDigit
|
||||||
|
? String(minimumDigit)
|
||||||
|
: `[${minimumDigit}-${upperDigit}]`;
|
||||||
|
alternatives.push(
|
||||||
|
`${maximum.slice(0, index)}${digit}\\d{${maximum.length - index - 1}}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
alternatives.push(maximum);
|
||||||
|
return new RegExp(`^(?:${alternatives.join("|")})$`, "u");
|
||||||
|
}
|
||||||
|
|
||||||
|
export const CANONICAL_TICKET_ID_PATTERN = boundedDecimalPattern(
|
||||||
|
MAX_UNSIGNED_TICKET_ID.toString(),
|
||||||
|
);
|
||||||
|
|
||||||
// Canonicalize a SQL BIGINT identifier without passing through Number.
|
// Canonicalize a SQL BIGINT identifier without passing through Number.
|
||||||
export function canonicalTicketId(value: string | number | bigint): bigint {
|
export function canonicalTicketId(value: string | number | bigint): bigint {
|
||||||
let parsed: bigint;
|
let parsed: bigint;
|
||||||
try {
|
try {
|
||||||
if (
|
if (typeof value === "bigint") {
|
||||||
typeof value === "number" &&
|
parsed = value;
|
||||||
(!Number.isSafeInteger(value) || value <= 0)
|
} else if (typeof value === "string") {
|
||||||
) {
|
if (!CANONICAL_TICKET_ID_PATTERN.test(value)) {
|
||||||
|
throw new Error("noncanonical identifier");
|
||||||
|
}
|
||||||
|
parsed = BigInt(value);
|
||||||
|
} else if (Number.isSafeInteger(value) && value > 0) {
|
||||||
|
parsed = BigInt(value);
|
||||||
|
} else {
|
||||||
throw new Error("unsafe identifier");
|
throw new Error("unsafe identifier");
|
||||||
}
|
}
|
||||||
parsed = BigInt(String(value));
|
|
||||||
} catch {
|
} catch {
|
||||||
throw new Error("invalid ticket identifier");
|
throw new Error("invalid ticket identifier");
|
||||||
}
|
}
|
||||||
if (parsed <= 0n || parsed > MAX_UNSIGNED_BIGINT) {
|
if (parsed <= 0n || parsed > MAX_UNSIGNED_TICKET_ID) {
|
||||||
throw new Error("invalid ticket identifier");
|
throw new Error("invalid ticket identifier");
|
||||||
}
|
}
|
||||||
return parsed;
|
return parsed;
|
||||||
|
|||||||
Reference in new issue
Block a user