- P1: new src/lib/admin/rank-guard.ts with tryGuardRank/guardRank + RankGuardTarget/Result; extracted from users.ts; applied to admin-bans (create/lift + rank redirect) and admin-badges (caps + rank guard + error surfacing); import-url-safety.ts SSRF guard wired into clone save+icon fetch; logAudit await in help-tickets; antiddos mutations SETTINGS_EDIT; markAllAlertsRead NOTIFICATIONS_EDIT; rankError/saveError in all 25 message files; tests rew/new for bans/badges/rank-guard/import-url-safety - P2: ticket-inbox merged pagination reworked to single UNION ALL SQL with LIMIT/OFFSET (avoids O(offset) materialization); getCachedAdminCount with cacheFiltered option applied to devops/errors/photos/items/guilds/help-tickets/moderation pages; admin-theme catches→?error=1+banner+25 locales; logger in silent catches (rare-values/writeable-boxes); catalog-items-loader CAST→plain equality; count caching helper extended with opt-in cacheFiltered - P3: removed dead modules is-staff/notice/recent-furni-resync/log-filters plus load-user-sanctions/user-sanction-timeline; removed orphaned CATALOG_PAGE_COLUMNS re-export; removed getFurniIconUrl from studio-furni-icons Build: lint 0, typecheck 0, 3480 tests pass, i18n:check exit 0, colors exit 0. Circular chunk warning (9452/10535/66463) is webpack code-splitting artifact; build succeeds.
265 lines
7.9 KiB
TypeScript
265 lines
7.9 KiB
TypeScript
import "server-only";
|
|
|
|
import { and, count, eq, like, ne, or, type SQL, sql } from "drizzle-orm";
|
|
import { alias } from "drizzle-orm/mysql-core";
|
|
import { calcPagination } from "@/lib/admin-helpers";
|
|
import {
|
|
db,
|
|
queryRows,
|
|
User,
|
|
WebsiteHelpCenterTickets,
|
|
WebsiteTicket,
|
|
} from "@/lib/db";
|
|
import { formatDate } from "@/lib/format-date";
|
|
import { logger } from "@/lib/logger";
|
|
|
|
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";
|
|
}
|
|
|
|
interface InboxRawRow {
|
|
kind: TicketInboxKind;
|
|
id: string;
|
|
title: string;
|
|
username: string | null;
|
|
user_id: number | null;
|
|
status: string;
|
|
is_open: number | boolean;
|
|
sort_at: Date | null;
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
/**
|
|
* Builds a single UNION ALL over both queues, normalized to the same column
|
|
* shape so the database can apply ORDER BY + LIMIT/OFFSET. This keeps deep
|
|
* pages O(perPage) instead of materializing every row up to the offset (the
|
|
* previous app-side merge fetched `offset + perPage` rows from each source).
|
|
*
|
|
* Ordering matches the old merge: newest first, ties broken by descending
|
|
* `kind` then descending string `id` (equivalent to the old `${kind}-${id}`
|
|
* key comparison).
|
|
*/
|
|
function buildInboxUnion(
|
|
includeCms: boolean,
|
|
includeHelp: boolean,
|
|
search: string,
|
|
openOnly: boolean,
|
|
): SQL {
|
|
const branches: SQL[] = [];
|
|
|
|
if (includeCms) {
|
|
const conditions: SQL[] = [];
|
|
if (openOnly) conditions.push(sql`t.status <> 'closed'`);
|
|
if (search) {
|
|
const pattern = `%${search}%`;
|
|
const parts: SQL[] = [
|
|
sql`t.subject LIKE ${pattern}`,
|
|
sql`t.category LIKE ${pattern}`,
|
|
sql`EXISTS (SELECT 1 FROM users cu WHERE cu.id = t.creator_id AND cu.username LIKE ${pattern})`,
|
|
];
|
|
const asId = Number(search);
|
|
if (Number.isFinite(asId) && asId > 0) parts.push(sql`t.id = ${asId}`);
|
|
conditions.push(sql`(${sql.join(parts, sql` OR `)})`);
|
|
}
|
|
const where =
|
|
conditions.length > 0
|
|
? sql` WHERE ${sql.join(conditions, sql` AND `)}`
|
|
: sql``;
|
|
branches.push(sql`
|
|
SELECT 'cms' AS kind, CAST(t.id AS CHAR) AS id, t.subject AS title,
|
|
cu.username AS username, t.creator_id AS user_id, t.status AS status,
|
|
(t.status <> 'closed') AS is_open,
|
|
COALESCE(t.updated_at, t.created_at) AS sort_at
|
|
FROM website_tickets t
|
|
LEFT JOIN users cu ON cu.id = t.creator_id${where}
|
|
`);
|
|
}
|
|
|
|
if (includeHelp) {
|
|
const conditions: SQL[] = [];
|
|
if (openOnly) conditions.push(sql`h.open = 1`);
|
|
if (search) {
|
|
const pattern = `%${search}%`;
|
|
const parts: SQL[] = [
|
|
sql`h.title LIKE ${pattern}`,
|
|
sql`h.content LIKE ${pattern}`,
|
|
sql`EXISTS (SELECT 1 FROM users hu WHERE hu.id = h.user_id AND hu.username LIKE ${pattern})`,
|
|
];
|
|
if (/^\d+$/.test(search)) parts.push(sql`h.id = ${search}`);
|
|
conditions.push(sql`(${sql.join(parts, sql` OR `)})`);
|
|
}
|
|
const where =
|
|
conditions.length > 0
|
|
? sql` WHERE ${sql.join(conditions, sql` AND `)}`
|
|
: sql``;
|
|
branches.push(sql`
|
|
SELECT 'help' AS kind, CAST(h.id AS CHAR) AS id, h.title AS title,
|
|
hu.username AS username, h.user_id AS user_id,
|
|
IF(h.open = 1, 'open', 'closed') AS status, h.open AS is_open,
|
|
COALESCE(h.updated_at, h.created_at) AS sort_at
|
|
FROM website_help_center_tickets h
|
|
LEFT JOIN users hu ON hu.id = h.user_id${where}
|
|
`);
|
|
}
|
|
|
|
return sql.join(branches, sql` UNION ALL `);
|
|
}
|
|
|
|
/** Merged read-model over CMS desk + help-center queues (single DB query). */
|
|
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 cmsPrefix = base === "mod" ? "/mod/tickets" : "/admin/tickets";
|
|
const helpPrefix =
|
|
base === "mod" ? "/mod/help-tickets" : "/admin/help-tickets";
|
|
|
|
let rows: TicketInboxRow[] = [];
|
|
if (includeCms || includeHelp) {
|
|
const union = buildInboxUnion(includeCms, includeHelp, search, openOnly);
|
|
try {
|
|
const raw = await queryRows<InboxRawRow>(sql`
|
|
SELECT * FROM (${union}) AS merged
|
|
ORDER BY sort_at DESC, kind DESC, id DESC
|
|
LIMIT ${pagination.perPage} OFFSET ${pagination.offset}
|
|
`);
|
|
rows = raw.map((row) => {
|
|
const open = Boolean(row.is_open);
|
|
return {
|
|
key: `${row.kind}-${row.id}`,
|
|
kind: row.kind,
|
|
id: String(row.id),
|
|
title: row.title,
|
|
user:
|
|
row.username ??
|
|
(row.kind === "help" && row.user_id != null
|
|
? `#${row.user_id}`
|
|
: "—"),
|
|
userId: row.user_id,
|
|
status: row.kind === "cms" ? row.status : open ? "open" : "closed",
|
|
open,
|
|
sortAt: toSortMs(row.sort_at),
|
|
date: formatDate(row.sort_at, "date"),
|
|
href:
|
|
row.kind === "cms"
|
|
? `${cmsPrefix}/${row.id}`
|
|
: `${helpPrefix}/${row.id}`,
|
|
};
|
|
});
|
|
} catch (error) {
|
|
logger.error("Failed to load unified ticket inbox", { error });
|
|
}
|
|
}
|
|
|
|
return {
|
|
rows,
|
|
total,
|
|
page: pagination.page,
|
|
perPage: pagination.perPage,
|
|
lastPage: pagination.lastPage,
|
|
cmsTotal,
|
|
helpTotal,
|
|
};
|
|
}
|
|
|
|
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);
|
|
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<number> {
|
|
const conditions: SQL[] = [];
|
|
if (openOnly) conditions.push(eq(WebsiteHelpCenterTickets.open, true));
|
|
if (search) {
|
|
const pattern = `%${search}%`;
|
|
const asId = /^\d+$/.test(search) ? BigInt(search) : null;
|
|
const searchFilter = or(
|
|
like(WebsiteHelpCenterTickets.title, pattern),
|
|
like(WebsiteHelpCenterTickets.content, pattern),
|
|
sql`EXISTS (SELECT 1 FROM users hu WHERE hu.id = ${WebsiteHelpCenterTickets.userId} AND hu.username LIKE ${pattern})`,
|
|
...(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);
|
|
}
|