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> | null; readonly after: Readonly> | null; readonly output?: Readonly>; 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; 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; } 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, input: unknown, context: PeopleSupportMutationContext, ): Promise { 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 { 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, input: unknown, context: PeopleSupportMutationContext, ): Promise { 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 { 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"); }, }; }