feat(admin): unified ticket inbox over CMS and help-center queues
CI / check (push) Successful in 22s
CI / release (push) Skipped
CI / deploy (push) Successful in 57s

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:
SimoandCursor committed 2026-08-01 14:49:19 +02:00
1 parent 24d0b735c1
commit d8199ea1e4
31 files changed
+1580 -394

No files matched your search

+327
View File
@@ -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,
};
}