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> | null; readonly after: Readonly> | null; readonly output?: Readonly>; readonly completion?: HousekeepingPartialCompletion; } type AuditOutcome = "intent" | "success" | "failure" | "partial"; type ModerationTransport = ModerationActionTransport & { disconnectUser(userId: number, username?: string): Promise; }; 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; readonly positiveInteger: (value: unknown) => number; readonly nonNegativeInteger: (value: unknown) => number; readonly normalizedText: ( value: unknown, max: number, required?: boolean, ) => string; readonly requireRcon: (result: boolean) => Promise; readonly loadTarget: ( userId: number, context: PeopleModerationMutationContext, guardHierarchy: boolean, source?: Pick, 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, ) => Promise; readonly transport: ModerationTransport; readonly executeModerationAction: ( transport: ModerationActionTransport, input: ModerationAction, ) => Promise; readonly logStaffActivity: (input: { staffId: number; action: string; description: string; targetType?: string; targetId?: number; }) => Promise; readonly runExternalWithAudit: ( context: PeopleModerationMutationContext, operation: PeopleModerationMutationOperation, target: string, targetId: number | undefined, snapshot: PeopleModerationMutationSnapshot, execute: () => Promise, confirmedFailureSnapshot: PeopleModerationMutationSnapshot, ) => Promise; readonly finalizeExternalWithAudit: ( context: PeopleModerationMutationContext, operation: PeopleModerationMutationOperation, target: string, targetId: number | undefined, snapshot: PeopleModerationMutationSnapshot, execute: () => Promise, mutationCommitted?: boolean, confirmedFailureSnapshot?: PeopleModerationMutationSnapshot, ) => Promise; } export interface PeopleModerationMutationExecutor { execute( operation: PeopleModerationMutationOperation, input: unknown, context: PeopleModerationMutationContext, ): Promise; } 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): 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 { const delivered = await executeModerationAction(transport, input); if (!context.legacy) await requireRcon(delivered); } async function executeModerationMutation( input: unknown, context: PeopleModerationMutationContext, ): Promise { 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 { 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, input: unknown, context: PeopleModerationMutationContext, ): Promise { 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"); }, }; }