import "server-only"; import { PERMS } from "@/lib/permission-slugs"; import { authorizeHousekeeping } from "../../../foundation/authorization"; import { anyCapability, fail, type HousekeepingQuery, ok, } from "../../../foundation/contracts"; import { assertPeopleSerializable, createPeoplePage, type ListInput, normalizePeopleListInput, type Page, type PeopleBanSummary, type PeopleHelpTicketDetail, type PeopleHelpTicketSummary, type PeopleQueueSnapshot, type PeopleSupportStaff, type PeopleTicketDetail, type PeopleTicketSummary, type PeopleTicketTemplate, peopleHelpTicketHref, peopleTicketHref, toPeopleIsoDate, } from "../models"; interface SupportRows { readonly rows: readonly T[]; readonly total: number; } type PeopleTicketRecord = Omit; type PeopleHelpTicketRecord = Omit< PeopleHelpTicketDetail, "queue" | "staff" | "activeBan" >; export interface PeopleSupportAdapters { loadQueue(): Promise; loadTickets( input: ReturnType, ): Promise>; loadTicket(id: number): Promise; loadTemplates( input: ReturnType, ): Promise>; loadHelpTickets( input: ReturnType, ): Promise>; loadHelpTicket(id: number): Promise; loadSupportStaff(): Promise; loadActiveBan(userId: number): Promise; } export type PeopleSupportQueryInput = | { readonly routeId: "people.support.queue" } | { readonly routeId: "people.support.tickets"; readonly list: ListInput } | { readonly routeId: "people.support.ticket-desk"; readonly list: ListInput } | { readonly routeId: "people.support.ticket-templates"; readonly list: ListInput; } | { readonly routeId: "people.support.ticket-detail"; readonly id: number } | { readonly routeId: "people.support.help-tickets"; readonly list: ListInput; } | { readonly routeId: "people.support.help-ticket-detail"; readonly id: number; }; export type PeopleSupportQueryData = | { readonly kind: "queue"; readonly queue: PeopleQueueSnapshot } | { readonly kind: "tickets"; readonly page: Page } | { readonly kind: "ticket-desk"; readonly page: Page; readonly queue: PeopleQueueSnapshot; readonly staff: readonly PeopleSupportStaff[]; } | { readonly kind: "ticket-templates"; readonly page: Page; } | { readonly kind: "ticket"; readonly ticket: PeopleTicketDetail } | { readonly kind: "help-tickets"; readonly page: Page; } | { readonly kind: "help-ticket"; readonly ticket: PeopleHelpTicketDetail; }; const broadCapability = anyCapability( PERMS.TICKETS_VIEW, PERMS.MOD_TICKETS_VIEW, PERMS.TICKETS_EDIT, ); function unavailable(correlationId: string) { return fail( "DEPENDENCY_UNAVAILABLE", "errors.housekeeping.dependencyUnavailable", correlationId, ); } function invalidId(id: number, correlationId: string) { return !Number.isSafeInteger(id) || id <= 0 ? fail("VALIDATION", "errors.housekeeping.validation", correlationId, { id: ["invalid"], }) : null; } export function createPeopleSupportQuery( adapters: PeopleSupportAdapters, ): HousekeepingQuery { return { id: "people.support.query", owner: "people", capability: broadCapability, async run(context, input) { const requirement = input.routeId === "people.support.ticket-templates" ? anyCapability(PERMS.TICKETS_EDIT) : anyCapability(PERMS.TICKETS_VIEW, PERMS.MOD_TICKETS_VIEW); const authorization = authorizeHousekeeping(context, requirement); if (!authorization.ok) return authorization; const correlationId = authorization.correlationId; try { if (input.routeId === "people.support.queue") { const queue = await adapters.loadQueue(); assertPeopleSerializable(queue); return ok({ kind: "queue" as const, queue }, correlationId); } if ( input.routeId === "people.support.ticket-detail" || input.routeId === "people.support.help-ticket-detail" ) { const invalid = invalidId(input.id, correlationId); if (invalid !== null) return invalid; const ticket = input.routeId === "people.support.ticket-detail" ? await adapters.loadTicket(input.id) : await adapters.loadHelpTicket(input.id); if (ticket === null) { return fail( "NOT_FOUND", "errors.housekeeping.notFound", correlationId, ); } const helpTicket = input.routeId === "people.support.help-ticket-detail" ? (ticket as PeopleHelpTicketRecord) : null; const [queue, staff, activeBan] = await Promise.all([ adapters.loadQueue(), adapters.loadSupportStaff(), helpTicket?.userId !== null && helpTicket?.userId !== undefined ? adapters.loadActiveBan(helpTicket.userId) : Promise.resolve(null), ]); const hydrated = input.routeId === "people.support.ticket-detail" ? { ...ticket, queue, staff } : { ...ticket, queue, staff, activeBan }; assertPeopleSerializable(hydrated); return input.routeId === "people.support.ticket-detail" ? ok( { kind: "ticket" as const, ticket: hydrated as PeopleTicketDetail, }, correlationId, ) : ok( { kind: "help-ticket" as const, ticket: hydrated as PeopleHelpTicketDetail, }, correlationId, ); } const allowedSorts = input.routeId === "people.support.ticket-templates" ? ["id", "title", "sortOrder"] : input.routeId === "people.support.help-tickets" ? ["id", "title", "updatedAt"] : ["id", "subject", "status", "updatedAt"]; const list = normalizePeopleListInput(input.list, allowedSorts, "id"); if (input.routeId === "people.support.ticket-templates") { const result = await adapters.loadTemplates(list); return ok( { kind: "ticket-templates" as const, page: createPeoplePage(result.rows, result.total, list), }, correlationId, ); } if (input.routeId === "people.support.help-tickets") { const result = await adapters.loadHelpTickets(list); return ok( { kind: "help-tickets" as const, page: createPeoplePage(result.rows, result.total, list), }, correlationId, ); } if (input.routeId === "people.support.ticket-desk") { const [result, queue, staff] = await Promise.all([ adapters.loadTickets(list), adapters.loadQueue(), adapters.loadSupportStaff(), ]); assertPeopleSerializable({ queue, staff }); return ok( { kind: "ticket-desk" as const, page: createPeoplePage(result.rows, result.total, list), queue, staff, }, correlationId, ); } const result = await adapters.loadTickets(list); return ok( { kind: "tickets" as const, page: createPeoplePage(result.rows, result.total, list), }, correlationId, ); } catch { return unavailable(correlationId); } }, }; } function resultRows(result: unknown): T[] { if (!Array.isArray(result) || !Array.isArray(result[0])) { throw new Error("invalid People driver result"); } return result[0] as T[]; } const peopleSupportAdapters: PeopleSupportAdapters = { async loadQueue() { const [{ sql }, { db }, { fetchTicketQueueOpenCounts }] = await Promise.all( [ import("drizzle-orm"), import("@/lib/db"), import("@/lib/admin/ticket-queue-counts"), ], ); const [ticketCounts, result] = await Promise.all([ fetchTicketQueueOpenCounts({ strict: true }), db.execute(sql` SELECT (SELECT COUNT(*) FROM support_tickets WHERE state <> 2) AS cfh, (SELECT COUNT(*) FROM bans WHERE ban_expire = 0 OR ban_expire > UNIX_TIMESTAMP()) AS activeBans `), ]); const row = resultRows<{ cfh: number; activeBans: number; }>(result)[0]; if (!row) throw new Error("queue counts unavailable"); return { tickets: ticketCounts.cmsOpen, helpTickets: ticketCounts.helpOpen, cfh: Number(row.cfh), activeBans: Number(row.activeBans), }; }, async loadTickets(input) { const { fetchUnifiedTicketInbox } = await import( "@/lib/admin/ticket-inbox" ); const result = await fetchUnifiedTicketInbox({ strict: true, page: Math.floor(input.offset / input.pageSize) + 1, perPage: input.pageSize, offset: input.offset, search: input.search, openOnly: false, base: "admin", sort: input.sort as "id" | "subject" | "status" | "updatedAt", order: input.order, }); const rows = result.rows.map((row) => { const id = Number(row.id); return { source: row.kind, id, subject: row.title, status: row.status, priority: "normal", creatorId: row.userId, creatorUsername: row.user === "—" ? null : row.user, updatedAt: row.sortAt === 0 ? null : toPeopleIsoDate(row.sortAt), href: row.kind === "cms" ? peopleTicketHref(id) : peopleHelpTicketHref(id), }; }); return { rows, total: result.total, }; }, async loadTicket(id) { const [{ sql }, { db }] = await Promise.all([ import("drizzle-orm"), import("@/lib/db"), ]); const [ticketResult, messagesResult] = await Promise.all([ db.execute(sql` SELECT t.id, t.subject, t.category, t.priority, t.status, t.creator_id AS creatorId, creator.username AS creatorUsername, t.assignee_id AS assigneeId, t.updated_at AS updatedAt FROM website_tickets t LEFT JOIN users creator ON creator.id = t.creator_id WHERE t.id = ${id} LIMIT 1 `), db.execute(sql` SELECT m.id, m.user_id AS userId, u.username, m.message, m.is_staff AS isStaff, m.created_at AS createdAt FROM website_ticket_messages m LEFT JOIN users u ON u.id = m.user_id WHERE m.ticket_id = ${id} ORDER BY m.created_at ASC, m.id ASC LIMIT 500 `), ]); const row = resultRows<{ id: number; subject: string; category: string; priority: string; status: string; creatorId: number; creatorUsername: string | null; assigneeId: number | null; updatedAt: Date | string | null; }>(ticketResult)[0]; if (!row) return null; return { source: "cms", id: Number(row.id), subject: row.subject, category: row.category, priority: row.priority, status: row.status, creatorId: Number(row.creatorId), creatorUsername: row.creatorUsername, assigneeId: row.assigneeId === null ? null : Number(row.assigneeId), updatedAt: toPeopleIsoDate(row.updatedAt), href: peopleTicketHref(Number(row.id)), messages: resultRows<{ id: number; userId: number; username: string | null; message: string; isStaff: number | boolean; createdAt: Date | string | null; }>(messagesResult).map((message) => ({ id: Number(message.id), userId: Number(message.userId), username: message.username, message: message.message, isStaff: Boolean(message.isStaff), createdAt: toPeopleIsoDate(message.createdAt), })), }; }, async loadTemplates(input) { const [{ sql }, { db }] = await Promise.all([ import("drizzle-orm"), import("@/lib/db"), ]); const pattern = `%${input.search}%`; const where = input.search ? sql`WHERE title LIKE ${pattern} OR category LIKE ${pattern}` : sql``; const [rowsResult, countResult] = await Promise.all([ db.execute(sql` SELECT id, title, content, category, sort_order AS sortOrder FROM website_ticket_templates ${where} ORDER BY ${input.sort === "title" ? sql`title` : input.sort === "sortOrder" ? sql`sort_order` : sql`id`} ${ input.order === "desc" ? sql`DESC` : sql`ASC` }, id ASC LIMIT ${input.pageSize} OFFSET ${input.offset} `), db.execute(sql` SELECT COUNT(*) AS total FROM website_ticket_templates ${where} `), ]); const rows = resultRows(rowsResult).map((row) => ({ id: Number(row.id), title: row.title, content: row.content, category: row.category, sortOrder: Number(row.sortOrder), })); return { rows, total: Number(resultRows<{ total: number }>(countResult)[0]?.total ?? 0), }; }, async loadHelpTickets(input) { const [{ sql }, { db }] = await Promise.all([ import("drizzle-orm"), import("@/lib/db"), ]); const pattern = `%${input.search}%`; const where = input.search ? sql`WHERE t.title LIKE ${pattern} OR u.username LIKE ${pattern}` : sql``; const [rowsResult, countResult] = await Promise.all([ db.execute(sql` SELECT t.id, t.title, t.open, t.user_id AS userId, u.username, t.updated_at AS updatedAt FROM website_help_center_tickets t LEFT JOIN users u ON u.id = t.user_id ${where} ORDER BY ${input.sort === "title" ? sql`t.title` : input.sort === "updatedAt" ? sql`t.updated_at` : sql`t.id`} ${ input.order === "asc" ? sql`ASC` : sql`DESC` }, t.id ASC LIMIT ${input.pageSize} OFFSET ${input.offset} `), db.execute(sql` SELECT COUNT(*) AS total FROM website_help_center_tickets t LEFT JOIN users u ON u.id = t.user_id ${where} `), ]); const rows = resultRows<{ id: bigint | number; title: string; open: boolean | number; userId: number | null; username: string | null; updatedAt: Date | string | null; }>(rowsResult).map((row) => ({ id: Number(row.id), title: row.title, open: Boolean(row.open), userId: row.userId === null ? null : Number(row.userId), username: row.username, updatedAt: toPeopleIsoDate(row.updatedAt), href: peopleHelpTicketHref(Number(row.id)), })); return { rows, total: Number(resultRows<{ total: number }>(countResult)[0]?.total ?? 0), }; }, async loadHelpTicket(id) { const [{ sql }, { db }] = await Promise.all([ import("drizzle-orm"), import("@/lib/db"), ]); const [ticketResult, repliesResult] = await Promise.all([ db.execute(sql` SELECT t.id, t.title, t.content, t.open, t.user_id AS userId, u.username, t.category_id AS categoryId, c.name AS categoryName, t.updated_at AS updatedAt FROM website_help_center_tickets t LEFT JOIN users u ON u.id = t.user_id LEFT JOIN website_help_center_categories c ON c.id = t.category_id WHERE t.id = ${id} LIMIT 1 `), db.execute(sql` SELECT r.id, r.user_id AS userId, u.username, r.content, r.created_at AS createdAt FROM website_help_center_ticket_replies r LEFT JOIN users u ON u.id = r.user_id WHERE r.ticket_id = ${id} ORDER BY r.created_at ASC, r.id ASC LIMIT 500 `), ]); const row = resultRows<{ id: bigint | number; title: string; content: string; open: boolean | number; userId: number | null; username: string | null; categoryId: bigint | number | null; categoryName: string | null; updatedAt: Date | string | null; }>(ticketResult)[0]; if (!row) return null; return { id: Number(row.id), title: row.title, content: row.content, open: Boolean(row.open), userId: row.userId === null ? null : Number(row.userId), username: row.username, categoryId: row.categoryId === null ? null : Number(row.categoryId), categoryName: row.categoryName, updatedAt: toPeopleIsoDate(row.updatedAt), href: peopleHelpTicketHref(Number(row.id)), replies: resultRows<{ id: bigint | number; userId: number; username: string | null; content: string; createdAt: Date | string | null; }>(repliesResult).map((reply) => ({ id: Number(reply.id), userId: Number(reply.userId), username: reply.username, content: reply.content, createdAt: toPeopleIsoDate(reply.createdAt), })), }; }, async loadSupportStaff() { const [{ sql }, { db }, { getMinStaffRank }] = await Promise.all([ import("drizzle-orm"), import("@/lib/db"), import("@/lib/admin/min-staff-rank"), ]); const minStaffRank = await getMinStaffRank(); const result = await db.execute(sql` SELECT id, username, rank FROM users WHERE rank >= ${minStaffRank} ORDER BY username ASC, id ASC LIMIT 200 `); return resultRows<{ id: number; username: string; rank: number }>( result, ).map((row) => ({ id: Number(row.id), username: row.username, rank: Number(row.rank), href: `/ase/people/users/${Number(row.id)}` as const, })); }, async loadActiveBan(userId) { const [{ sql }, { db }] = await Promise.all([ import("drizzle-orm"), import("@/lib/db"), ]); const result = await db.execute(sql` SELECT b.id, b.user_id AS userId, target.username, b.user_staff_id AS staffId, staff.username AS staffUsername, b.type, b.ban_reason AS reason, b.timestamp AS createdAt, b.ban_expire AS expiresAt FROM bans b LEFT JOIN users target ON target.id = b.user_id LEFT JOIN users staff ON staff.id = b.user_staff_id WHERE b.user_id = ${userId} AND (b.ban_expire = 0 OR b.ban_expire > UNIX_TIMESTAMP()) ORDER BY (b.ban_expire = 0) DESC, b.ban_expire DESC, b.timestamp DESC, b.id DESC LIMIT 1 `); const row = resultRows<{ id: number; userId: number; username: string | null; staffId: number; staffUsername: string | null; type: string; reason: string; createdAt: Date | string | number | null; expiresAt: number; }>(result)[0]; if (!row) return null; return { id: Number(row.id), userId: Number(row.userId), username: row.username, staffId: Number(row.staffId), staffUsername: row.staffUsername, type: row.type, reason: row.reason, createdAt: toPeopleIsoDate(row.createdAt), expiresAt: Number(row.expiresAt) === 0 ? 0 : (toPeopleIsoDate(row.expiresAt) as string), active: true, }; }, }; export const peopleSupportQuery = createPeopleSupportQuery( peopleSupportAdapters, );