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
@@ -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(),
|
||||
giveDuckets: vi.fn(),
|
||||
givePointsGotw: vi.fn(),
|
||||
hotelAlert: vi.fn(),
|
||||
kickAll: vi.fn(),
|
||||
muteUser: vi.fn(),
|
||||
setTradeLock: vi.fn(),
|
||||
staffAlert: vi.fn(),
|
||||
unmuteUser: vi.fn(),
|
||||
updateWordFilter: vi.fn(),
|
||||
},
|
||||
@@ -101,7 +104,8 @@ vi.mock("@/lib/services/audit", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("@/lib/services/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,
|
||||
}));
|
||||
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),
|
||||
).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(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,
|
||||
Rooms,
|
||||
Sanctions,
|
||||
SupportTickets,
|
||||
User,
|
||||
UsersBadges,
|
||||
UsersCurrency,
|
||||
UsersSettings,
|
||||
WebsiteHelpCenterTicketReplies,
|
||||
WebsiteHelpCenterTickets,
|
||||
WebsiteIpBlacklist,
|
||||
WebsiteIpWhitelist,
|
||||
WebsiteSetting,
|
||||
WebsiteStaffApplications,
|
||||
WebsiteTeams,
|
||||
WebsiteTicket,
|
||||
WebsiteTicketMessage,
|
||||
WebsiteTicketTemplate,
|
||||
WebsiteWordfilter,
|
||||
} from "@/lib/db";
|
||||
import { PERMS } from "@/lib/permission-slugs";
|
||||
import { logAudit } from "@/lib/services/audit";
|
||||
import {
|
||||
executeModerationAction,
|
||||
type ModerationAction,
|
||||
reloadWordFilter,
|
||||
} from "@/lib/services/moderation";
|
||||
import { rcon } from "@/lib/services/rcon";
|
||||
@@ -57,6 +50,14 @@ import {
|
||||
ok,
|
||||
} from "../../../foundation/contracts";
|
||||
import { getHousekeepingCapabilityContext } from "../../../foundation/server-capability-context";
|
||||
import {
|
||||
createPeopleModerationMutationExecutor,
|
||||
isPeopleModerationMutationOperation,
|
||||
} from "./moderation-mutations";
|
||||
import {
|
||||
createPeopleSupportMutationExecutor,
|
||||
isPeopleSupportMutationOperation,
|
||||
} from "./support-mutations";
|
||||
|
||||
export type PeopleMutationOperation =
|
||||
| "user.update"
|
||||
@@ -1802,655 +1803,35 @@ async function executeWordFilterUpdate(
|
||||
);
|
||||
}
|
||||
|
||||
type TicketMutationOperation = Extract<
|
||||
PeopleMutationOperation,
|
||||
`ticket.${string}`
|
||||
>;
|
||||
const supportMutationExecutor = createPeopleSupportMutationExecutor({
|
||||
db,
|
||||
writeAudit: logAudit,
|
||||
auditEntry: canonicalAuditEntry,
|
||||
failure: (code, messageKey) => new PeopleMutationFailure(code, messageKey),
|
||||
record,
|
||||
positiveInteger,
|
||||
nonNegativeInteger,
|
||||
normalizedText,
|
||||
canonicalTicketId,
|
||||
auditTargetId,
|
||||
});
|
||||
|
||||
async function executeTicketMutation(
|
||||
operation: TicketMutationOperation,
|
||||
input: unknown,
|
||||
context: PeopleMutationContext,
|
||||
): Promise<PeopleMutationSnapshot> {
|
||||
const data = record(input);
|
||||
const ticketId = positiveInteger(data.ticketId);
|
||||
let snapshot!: PeopleMutationSnapshot;
|
||||
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 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 moderationMutationExecutor = createPeopleModerationMutationExecutor({
|
||||
db,
|
||||
writeAudit: logAudit,
|
||||
auditEntry: canonicalAuditEntry,
|
||||
failure: (code, messageKey) => new PeopleMutationFailure(code, messageKey),
|
||||
record,
|
||||
positiveInteger,
|
||||
nonNegativeInteger,
|
||||
normalizedText,
|
||||
requireRcon,
|
||||
transport: rcon,
|
||||
executeModerationAction,
|
||||
logStaffActivity,
|
||||
runExternalWithAudit,
|
||||
finalizeExternalWithAudit,
|
||||
});
|
||||
|
||||
const productionPeopleMutationAdapter: PeopleMutationAdapter = {
|
||||
async execute(operation, input, context) {
|
||||
@@ -2481,26 +1862,11 @@ const productionPeopleMutationAdapter: PeopleMutationAdapter = {
|
||||
if (operation === "word-filter.update") {
|
||||
return executeWordFilterUpdate(input, context);
|
||||
}
|
||||
if (operation.startsWith("ticket.")) {
|
||||
return executeTicketMutation(operation as TicketMutationOperation, input, context);
|
||||
if (isPeopleSupportMutationOperation(operation)) {
|
||||
return supportMutationExecutor.execute(operation, input, context);
|
||||
}
|
||||
if (operation === "ticket-template.change") {
|
||||
return executeTicketTemplateChange(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);
|
||||
if (isPeopleModerationMutationOperation(operation)) {
|
||||
return moderationMutationExecutor.execute(operation, input, context);
|
||||
}
|
||||
throw new PeopleMutationFailure(
|
||||
"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");
|
||||
},
|
||||
};
|
||||
}
|
||||
Reference in new issue
Block a user