Files
EpicNext-Cms/src/features/housekeeping/domains/people/services/moderation-mutations.ts
T

478 lines
13 KiB
TypeScript

import "server-only";
import { eq } from "drizzle-orm";
import { Ban, type Db, SupportTickets } 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 loadTarget: (
userId: number,
context: PeopleModerationMutationContext,
guardHierarchy: boolean,
source?: Pick<Db, "select">,
lock?: boolean,
) => Promise<{
readonly id: number;
readonly username: string;
readonly rank: number;
readonly ipCurrent: string;
readonly machineId: string;
readonly online: string;
}>;
readonly runWithLockedTargetAuthority: (
userId: number,
context: PeopleModerationMutationContext,
execute: () => Promise<void>,
) => 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,
loadTarget,
runWithLockedTargetAuthority,
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;
if ("userId" in action) {
await db.transaction(async (tx) => {
await loadTarget(action.userId, context, true, tx, true);
await writeAudit(
auditEntry(
context,
"moderation.action",
"User",
action.userId,
snapshot,
"intent",
),
tx,
);
});
return finalizeExternalWithAudit(
context,
"moderation.action",
"User",
action.userId,
snapshot,
() =>
runWithLockedTargetAuthority(action.userId, context, () =>
deliverModerationAction(action, context),
),
false,
{ before: null, after: null },
);
}
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 requestedAction = normalizedText(data.action, 32);
if (!["kick", "mute", "alert"].includes(requestedAction)) {
throw failure("VALIDATION", "errors.housekeeping.validation");
}
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,
reportedId: SupportTickets.reportedId,
})
.from(SupportTickets)
.where(eq(SupportTickets.id, ticketId))
.for("update");
if (!ticket) {
throw failure("NOT_FOUND", "errors.housekeeping.notFound");
}
if (!("userId" in action) || action.userId !== ticket.reportedId) {
throw failure("FORBIDDEN", "errors.housekeeping.forbidden");
}
if (ticket.state !== 0 && ticket.state !== 1) {
throw failure("CONFLICT", "errors.housekeeping.conflict");
}
await loadTarget(ticket.reportedId, context, true, tx, true);
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,
() => {
if (!("userId" in action)) {
throw failure("VALIDATION", "errors.housekeeping.validation");
}
return runWithLockedTargetAuthority(action.userId, context, () =>
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 loadTarget(userId, context, true, tx, true);
username = user.username;
const needsIp = type === "ip" || type === "super";
const needsMachine = type === "machine" || type === "super";
if (
(needsIp && user.ipCurrent.trim().length === 0) ||
(needsMachine && user.machineId.trim().length === 0)
) {
throw failure("VALIDATION", "errors.housekeeping.validation");
}
const [result] = await tx.insert(Ban).values({
userId,
ip: needsIp ? user.ipCurrent : "",
machineId: needsMachine ? user.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,
() =>
runWithLockedTargetAuthority(userId, context, 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");
},
};
}