import "server-only"; import { and, count, desc, eq, inArray, like, ne, or, type SQL, sql, } from "drizzle-orm"; import { alias } from "drizzle-orm/mysql-core"; import { calcPagination } from "@/lib/admin-helpers"; import { db, User, WebsiteHelpCenterTickets, WebsiteTicket } from "@/lib/db"; import { formatDate } from "@/lib/format-date"; export type TicketInboxKind = "cms" | "help"; export type TicketInboxTypeFilter = "all" | TicketInboxKind; export interface TicketInboxRow { key: string; kind: TicketInboxKind; id: string; title: string; user: string; userId: number | null; status: string; open: boolean; sortAt: number; date: string; href: string; } export interface FetchTicketInboxOptions { page: number; perPage: number; offset?: number; search?: string; type?: TicketInboxTypeFilter; /** Default true: only non-closed CMS + open help-center. */ openOnly?: boolean; base?: "admin" | "mod"; sort?: "id" | "subject" | "status" | "updatedAt"; order?: "asc" | "desc"; /** Fail-closed, DB-paged boundary for security-sensitive read models. */ strict?: boolean; } function toSortMs(value: Date | string | null | undefined): number { if (!value) return 0; const ms = value instanceof Date ? value.getTime() : Date.parse(String(value)); return Number.isFinite(ms) ? ms : 0; } async function fetchCmsCandidates( limit: number, search: string, openOnly: boolean, base: "admin" | "mod", ): Promise { const Creator = alias(User, "inbox_ticket_creator"); const conditions: SQL[] = []; if (openOnly) { conditions.push(ne(WebsiteTicket.status, "closed")); } if (search) { const asId = Number(search); const searchOr = [ like(WebsiteTicket.subject, `%${search}%`), like(WebsiteTicket.category, `%${search}%`), like(Creator.username, `%${search}%`), ...(Number.isFinite(asId) && asId > 0 ? [eq(WebsiteTicket.id, asId)] : []), ]; const searchFilter = or(...searchOr); if (searchFilter) conditions.push(searchFilter); } const where = conditions.length > 0 ? and(...conditions) : undefined; const rows = await db .select({ id: WebsiteTicket.id, subject: WebsiteTicket.subject, status: WebsiteTicket.status, creatorId: WebsiteTicket.creatorId, updatedAt: WebsiteTicket.updatedAt, createdAt: WebsiteTicket.createdAt, creatorUsername: Creator.username, }) .from(WebsiteTicket) .leftJoin(Creator, eq(WebsiteTicket.creatorId, Creator.id)) .where(where) .orderBy(desc(WebsiteTicket.updatedAt), desc(WebsiteTicket.id)) .limit(limit) .catch(() => []); const prefix = base === "mod" ? "/mod/tickets" : "/admin/tickets"; return rows.map((row) => ({ key: `cms-${row.id}`, kind: "cms" as const, id: String(row.id), title: row.subject, user: row.creatorUsername ?? "—", userId: row.creatorId, status: row.status, open: row.status !== "closed", sortAt: toSortMs(row.updatedAt ?? row.createdAt), date: formatDate(row.updatedAt ?? row.createdAt, "date"), href: `${prefix}/${row.id}`, })); } async function fetchHelpCandidates( limit: number, search: string, openOnly: boolean, base: "admin" | "mod", ): Promise { const conditions: SQL[] = []; if (openOnly) { conditions.push(eq(WebsiteHelpCenterTickets.open, true)); } if (search) { const asId = /^\d+$/.test(search) ? BigInt(search) : null; const matchingUsers = await db .select({ id: User.id }) .from(User) .where(like(User.username, `%${search}%`)) .limit(50) .catch(() => []); const searchOr = [ like(WebsiteHelpCenterTickets.title, `%${search}%`), like(WebsiteHelpCenterTickets.content, `%${search}%`), ...(matchingUsers.length > 0 ? [ inArray( WebsiteHelpCenterTickets.userId, matchingUsers.map((u) => u.id), ), ] : []), ...(asId !== null ? [eq(WebsiteHelpCenterTickets.id, asId)] : []), ]; const searchFilter = or(...searchOr); if (searchFilter) conditions.push(searchFilter); } const where = conditions.length > 0 ? and(...conditions) : undefined; const rows = await db .select({ id: WebsiteHelpCenterTickets.id, title: WebsiteHelpCenterTickets.title, open: WebsiteHelpCenterTickets.open, userId: WebsiteHelpCenterTickets.userId, updatedAt: WebsiteHelpCenterTickets.updatedAt, createdAt: WebsiteHelpCenterTickets.createdAt, }) .from(WebsiteHelpCenterTickets) .where(where) .orderBy( desc(WebsiteHelpCenterTickets.updatedAt), desc(WebsiteHelpCenterTickets.id), ) .limit(limit) .catch(() => []); const userIds = [ ...new Set( rows.map((row) => row.userId).filter((id): id is number => id != null), ), ]; const users = userIds.length > 0 ? await db .select({ id: User.id, username: User.username }) .from(User) .where(inArray(User.id, userIds)) .catch(() => []) : []; const usernameById = new Map(users.map((u) => [u.id, u.username])); const prefix = base === "mod" ? "/mod/help-tickets" : "/admin/help-tickets"; return rows.map((row) => ({ key: `help-${row.id}`, kind: "help" as const, id: String(row.id), title: row.title, user: row.userId != null ? (usernameById.get(row.userId) ?? `#${row.userId}`) : "—", userId: row.userId, status: row.open ? "open" : "closed", open: row.open, sortAt: toSortMs(row.updatedAt ?? row.createdAt), date: formatDate(row.updatedAt ?? row.createdAt, "date"), href: `${prefix}/${row.id}`, })); } async function countCms(search: string, openOnly: boolean): Promise { const Creator = alias(User, "inbox_ticket_creator_count"); const conditions: SQL[] = []; if (openOnly) conditions.push(ne(WebsiteTicket.status, "closed")); if (search) { const asId = Number(search); const searchFilter = or( like(WebsiteTicket.subject, `%${search}%`), like(WebsiteTicket.category, `%${search}%`), like(Creator.username, `%${search}%`), ...(Number.isFinite(asId) && asId > 0 ? [eq(WebsiteTicket.id, asId)] : []), ); if (searchFilter) conditions.push(searchFilter); } const where = conditions.length > 0 ? and(...conditions) : undefined; const rows = await db .select({ total: count() }) .from(WebsiteTicket) .leftJoin(Creator, eq(WebsiteTicket.creatorId, Creator.id)) .where(where) .catch(() => [{ total: 0 }]); return Number(rows[0]?.total ?? 0); } async function countHelp(search: string, openOnly: boolean): Promise { const conditions: SQL[] = []; if (openOnly) conditions.push(eq(WebsiteHelpCenterTickets.open, true)); if (search) { const asId = /^\d+$/.test(search) ? BigInt(search) : null; const matchingUsers = await db .select({ id: User.id }) .from(User) .where(like(User.username, `%${search}%`)) .limit(50) .catch(() => []); const searchFilter = or( like(WebsiteHelpCenterTickets.title, `%${search}%`), like(WebsiteHelpCenterTickets.content, `%${search}%`), ...(matchingUsers.length > 0 ? [ inArray( WebsiteHelpCenterTickets.userId, matchingUsers.map((u) => u.id), ), ] : []), ...(asId !== null ? [eq(WebsiteHelpCenterTickets.id, asId)] : []), ); if (searchFilter) conditions.push(searchFilter); } const where = conditions.length > 0 ? and(...conditions) : undefined; const rows = await db .select({ total: count() }) .from(WebsiteHelpCenterTickets) .where(where) .catch(() => [{ total: 0 }]); return Number(rows[0]?.total ?? 0); } function strictRows(result: unknown): T[] { if (!Array.isArray(result) || !Array.isArray(result[0])) { throw new Error("invalid ticket inbox driver result"); } return result[0] as T[]; } async function fetchStrictUnifiedTicketInbox( options: FetchTicketInboxOptions, ): Promise<{ rows: TicketInboxRow[]; total: number; page: number; perPage: number; lastPage: number; cmsTotal: number; helpTotal: number; }> { const type = options.type ?? "all"; const openOnly = options.openOnly !== false; const search = options.search?.trim() ?? ""; const pattern = `%${search}%`; const numericCandidate = /^\d+$/.test(search) ? Number(search) : null; const numericSearch = numericCandidate !== null && Number.isSafeInteger(numericCandidate) && numericCandidate > 0 ? numericCandidate : null; const base = options.base ?? "admin"; if ( !Number.isFinite(options.perPage) || !Number.isFinite(options.page) || (options.offset !== undefined && !Number.isFinite(options.offset)) ) { throw new Error("invalid strict ticket inbox pagination"); } const perPage = Math.min(Math.max(Math.trunc(options.perPage), 1), 100); const requestedPage = Math.max(Math.trunc(options.page), 1); const offset = Math.min( Math.max(Math.trunc(options.offset ?? (requestedPage - 1) * perPage), 0), 10_000, ); const page = Math.floor(offset / perPage) + 1; const cmsConditions: SQL[] = []; if (openOnly) cmsConditions.push(sql`t.status <> 'closed'`); if (search) { cmsConditions.push(sql`( t.subject LIKE ${pattern} OR t.category LIKE ${pattern} OR creator.username LIKE ${pattern} ${numericSearch !== null ? sql`OR t.id = ${numericSearch}` : sql``} )`); } const helpConditions: SQL[] = []; if (openOnly) helpConditions.push(sql`h.open = 1`); if (search) { helpConditions.push(sql`( h.title LIKE ${pattern} OR h.content LIKE ${pattern} OR help_user.username LIKE ${pattern} ${numericSearch !== null ? sql`OR h.id = ${numericSearch}` : sql``} )`); } const cmsWhere = cmsConditions.length > 0 ? sql`WHERE ${sql.join(cmsConditions, sql` AND `)}` : sql``; const helpWhere = helpConditions.length > 0 ? sql`WHERE ${sql.join(helpConditions, sql` AND `)}` : sql``; const cmsSelect = sql` SELECT 'cms' AS kind, t.id AS numericId, t.subject AS title, COALESCE(creator.username, '—') AS user, t.creator_id AS userId, t.status AS status, (t.status <> 'closed') AS open, COALESCE(UNIX_TIMESTAMP(t.updated_at), UNIX_TIMESTAMP(t.created_at), 0) * 1000 AS sortAt, COALESCE(t.updated_at, t.created_at) AS dateValue FROM website_tickets t LEFT JOIN users creator ON creator.id = t.creator_id ${cmsWhere} `; const helpSelect = sql` SELECT 'help' AS kind, h.id AS numericId, h.title AS title, COALESCE(help_user.username, '—') AS user, h.user_id AS userId, IF(h.open = 1, 'open', 'closed') AS status, h.open AS open, COALESCE(UNIX_TIMESTAMP(h.updated_at), UNIX_TIMESTAMP(h.created_at), 0) * 1000 AS sortAt, COALESCE(h.updated_at, h.created_at) AS dateValue FROM website_help_center_tickets h LEFT JOIN users help_user ON help_user.id = h.user_id ${helpWhere} `; const unified = type === "cms" ? cmsSelect : type === "help" ? helpSelect : sql`${cmsSelect} UNION ALL ${helpSelect}`; const sortColumn = options.sort === "id" ? sql`ticket_rows.numericId` : options.sort === "subject" ? sql`ticket_rows.title` : options.sort === "status" ? sql`ticket_rows.status` : sql`ticket_rows.sortAt`; const direction = options.order === "asc" ? sql`ASC` : sql`DESC`; const [rowsResult, countResult] = await Promise.all([ db.execute(sql` SELECT * FROM (${unified}) AS ticket_rows ORDER BY ${sortColumn} ${direction}, ticket_rows.kind ASC, ticket_rows.numericId ${direction} LIMIT ${perPage} OFFSET ${offset} `), db.execute(sql` SELECT COUNT(*) AS total, SUM(ticket_rows.kind = 'cms') AS cmsTotal, SUM(ticket_rows.kind = 'help') AS helpTotal FROM (${unified}) AS ticket_rows `), ]); const counts = strictRows<{ total: number | bigint; cmsTotal: number | bigint | null; helpTotal: number | bigint | null; }>(countResult)[0]; if (!counts) throw new Error("ticket inbox counts unavailable"); const total = Number(counts.total); const cmsTotal = Number(counts.cmsTotal ?? 0); const helpTotal = Number(counts.helpTotal ?? 0); if ( !Number.isSafeInteger(total) || total < 0 || !Number.isSafeInteger(cmsTotal) || cmsTotal < 0 || !Number.isSafeInteger(helpTotal) || helpTotal < 0 ) { throw new Error("invalid ticket inbox counts"); } const rows = strictRows<{ kind: TicketInboxKind; numericId: number | bigint; title: string; user: string; userId: number | null; status: string; open: number | boolean; sortAt: number | bigint; dateValue: Date | string | null; }>(rowsResult).map((row) => { const id = Number(row.numericId); const sortAt = Number(row.sortAt); if ( (row.kind !== "cms" && row.kind !== "help") || !Number.isSafeInteger(id) || id <= 0 || !Number.isSafeInteger(sortAt) || sortAt < 0 || typeof row.title !== "string" || typeof row.user !== "string" || typeof row.status !== "string" ) { throw new Error("invalid ticket inbox row"); } const prefix = row.kind === "cms" ? base === "mod" ? "/mod/tickets" : "/admin/tickets" : base === "mod" ? "/mod/help-tickets" : "/admin/help-tickets"; const dateValue = row.dateValue === null ? null : row.dateValue instanceof Date ? row.dateValue : new Date(row.dateValue); if (dateValue !== null && !Number.isFinite(dateValue.getTime())) { throw new Error("invalid ticket inbox date"); } return { key: `${row.kind}-${id}`, kind: row.kind, id: String(id), title: row.title, user: row.user, userId: row.userId === null ? null : Number(row.userId), status: row.status, open: Boolean(row.open), sortAt, date: formatDate(dateValue, "date"), href: `${prefix}/${id}`, }; }); return { rows, total, page, perPage, lastPage: Math.max(1, Math.ceil(total / perPage)), cmsTotal, helpTotal, }; } /** Merged read-model over CMS desk + help-center queues (no DB merge). */ export async function fetchUnifiedTicketInbox( options: FetchTicketInboxOptions, ): Promise<{ rows: TicketInboxRow[]; total: number; page: number; perPage: number; lastPage: number; cmsTotal: number; helpTotal: number; }> { if (options.strict) return fetchStrictUnifiedTicketInbox(options); const type = options.type ?? "all"; const openOnly = options.openOnly !== false; const search = options.search?.trim() ?? ""; const base = options.base ?? "admin"; const includeCms = type === "all" || type === "cms"; const includeHelp = type === "all" || type === "help"; const [cmsTotal, helpTotal] = await Promise.all([ includeCms ? countCms(search, openOnly) : Promise.resolve(0), includeHelp ? countHelp(search, openOnly) : Promise.resolve(0), ]); const total = cmsTotal + helpTotal; const pagination = calcPagination(total, options.page, options.perPage); const window = pagination.offset + pagination.perPage; const [cmsRows, helpRows] = await Promise.all([ includeCms ? fetchCmsCandidates(window, search, openOnly, base) : Promise.resolve([]), includeHelp ? fetchHelpCandidates(window, search, openOnly, base) : Promise.resolve([]), ]); const merged = [...cmsRows, ...helpRows].sort((a, b) => { if (b.sortAt !== a.sortAt) return b.sortAt - a.sortAt; return a.key < b.key ? 1 : -1; }); const rows = merged.slice( pagination.offset, pagination.offset + pagination.perPage, ); return { rows, total, page: pagination.page, perPage: pagination.perPage, lastPage: pagination.lastPage, cmsTotal, helpTotal, }; }