feat(admin): unified ticket inbox over CMS and help-center queues
Merged read-model inbox at /admin/tickets and /mod/tickets with type badges and deep links; CMS-only lists moved to /desk. No DB schema merge. Co-authored-by: Cursor <[email protected]>
This commit is contained in:
1 parent
24d0b735c1
commit
d8199ea1e4
31 files changed
+1580
-394
No files matched your search
@@ -0,0 +1,327 @@
|
||||
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<TicketInboxRow[]> {
|
||||
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)]
|
||||
: []),
|
||||
];
|
||||
conditions.push(or(...searchOr)!);
|
||||
}
|
||||
|
||||
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<TicketInboxRow[]> {
|
||||
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)] : []),
|
||||
];
|
||||
conditions.push(or(...searchOr)!);
|
||||
}
|
||||
|
||||
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<number> {
|
||||
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);
|
||||
conditions.push(
|
||||
or(
|
||||
like(WebsiteTicket.subject, `%${search}%`),
|
||||
like(WebsiteTicket.category, `%${search}%`),
|
||||
like(Creator.username, `%${search}%`),
|
||||
...(Number.isFinite(asId) && asId > 0
|
||||
? [eq(WebsiteTicket.id, asId)]
|
||||
: []),
|
||||
)!,
|
||||
);
|
||||
}
|
||||
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<number> {
|
||||
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(() => []);
|
||||
conditions.push(
|
||||
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)] : []),
|
||||
)!,
|
||||
);
|
||||
}
|
||||
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,
|
||||
};
|
||||
}
|
||||
Reference in new issue
Block a user