feat(housekeeping): complete people moderation parity

This commit is contained in:
Simo committed 2026-08-29 22:38:50 +02:00
1 parent 3d385d1869
commit 29fe22297b
41 files changed
+3465 -580

No files matched your search

@@ -17,23 +17,34 @@ 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 { reloadWordFilter } from "@/lib/services/moderation";
import {
executeModerationAction,
type ModerationAction,
reloadWordFilter,
} from "@/lib/services/moderation";
import { rcon } from "@/lib/services/rcon";
import { siteSettings } from "@/lib/services/site-settings";
import { logStaffActivity } from "@/lib/services/staff-activity";
import { canonicalTicketId } from "@/lib/services/ticket-replies";
import { notify } from "@/lib/services/webhook";
import { satisfiesCapability } from "../../../foundation/capability-context";
import {
@@ -67,7 +78,20 @@ export type PeopleMutationOperation =
| "team.change"
| "ip.action"
| "vpn.configure"
| "word-filter.update";
| "word-filter.update"
| "ticket.reply"
| "ticket.assign"
| "ticket.status"
| "ticket.priority"
| "ticket-template.change"
| "help-ticket.reply"
| "help-ticket.status"
| "help-ticket.unban"
| "cfh.resolve"
| "cfh.sanction"
| "ban.create"
| "ban.lift"
| "moderation.action";
export interface PeopleMutationSnapshot {
readonly before: Readonly<Record<string, unknown>> | null;
@@ -121,9 +145,31 @@ class PeopleMutationFailure extends Error {
class ConfirmedExternalNoopFailure extends PeopleMutationFailure {}
function operationCapability(operation: PeopleMutationOperation) {
if (operation === "user.ban" || operation === "user.unban") {
if (
operation === "user.ban" ||
operation === "user.unban" ||
operation === "ban.create" ||
operation === "ban.lift" ||
operation === "help-ticket.unban"
) {
return anyCapability(PERMS.USERS_BAN);
}
if (
operation.startsWith("ticket.") ||
operation === "help-ticket.reply" ||
operation === "help-ticket.status"
) {
return anyCapability(PERMS.TICKETS_EDIT, PERMS.MOD_TICKETS_EDIT);
}
if (operation === "ticket-template.change") {
return anyCapability(PERMS.TICKETS_EDIT);
}
if (operation === "cfh.resolve" || operation === "cfh.sanction") {
return anyCapability(PERMS.MODERATION_EDIT, PERMS.MOD_CFH_EDIT);
}
if (operation === "moderation.action") {
return anyCapability(PERMS.MODERATION_EDIT, PERMS.MOD_ACTIONS);
}
if (operation === "user.reset-password") {
return anyCapability(PERMS.USERS_RESET_PASSWORD);
}
@@ -1755,6 +1801,657 @@ async function executeWordFilterUpdate(
},
);
}
type TicketMutationOperation = Extract<
PeopleMutationOperation,
`ticket.${string}`
>;
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 productionPeopleMutationAdapter: PeopleMutationAdapter = {
async execute(operation, input, context) {
if (operation.startsWith("user.")) {
@@ -1781,7 +2478,34 @@ const productionPeopleMutationAdapter: PeopleMutationAdapter = {
if (operation === "vpn.configure") {
return executeVpnConfiguration(input, context);
}
return executeWordFilterUpdate(input, context);
if (operation === "word-filter.update") {
return executeWordFilterUpdate(input, context);
}
if (operation.startsWith("ticket.")) {
return executeTicketMutation(operation as TicketMutationOperation, 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);
}
throw new PeopleMutationFailure(
"VALIDATION",
"errors.housekeeping.validation",
);
},
};