import "server-only"; import { and, count, desc, eq, inArray, like, ne, or, type 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; search?: string; type?: TicketInboxTypeFilter; /** Default true: only non-closed CMS + open help-center. */ openOnly?: boolean; base?: "admin" | "mod"; } 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); } /** 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; }> { 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, }; }