fix(housekeeping): harden people read boundaries
This commit is contained in:
1 parent
e3f8b51d31
commit
d1382c839e
12 files changed
+1635
-446
No files matched your search
@@ -10,6 +10,7 @@ import {
|
||||
ne,
|
||||
or,
|
||||
type SQL,
|
||||
sql,
|
||||
} from "drizzle-orm";
|
||||
import { alias } from "drizzle-orm/mysql-core";
|
||||
import { calcPagination } from "@/lib/admin-helpers";
|
||||
@@ -36,11 +37,16 @@ export interface TicketInboxRow {
|
||||
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 {
|
||||
@@ -267,6 +273,214 @@ async function countHelp(search: string, openOnly: boolean): Promise<number> {
|
||||
return Number(rows[0]?.total ?? 0);
|
||||
}
|
||||
|
||||
function strictRows<T>(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,
|
||||
@@ -279,6 +493,7 @@ export async function fetchUnifiedTicketInbox(
|
||||
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() ?? "";
|
||||
|
||||
@@ -4,23 +4,33 @@ import { count, eq, ne } from "drizzle-orm";
|
||||
import { db, WebsiteHelpCenterTickets, WebsiteTicket } from "@/lib/db";
|
||||
|
||||
/** Open-queue sizes for dual ticket products (CMS desk vs help-center). */
|
||||
export async function fetchTicketQueueOpenCounts(): Promise<{
|
||||
export async function fetchTicketQueueOpenCounts(options?: {
|
||||
readonly strict?: boolean;
|
||||
}): Promise<{
|
||||
cmsOpen: number;
|
||||
helpOpen: number;
|
||||
}> {
|
||||
const cms = db
|
||||
.select({ total: count() })
|
||||
.from(WebsiteTicket)
|
||||
.where(ne(WebsiteTicket.status, "closed"));
|
||||
const help = db
|
||||
.select({ total: count() })
|
||||
.from(WebsiteHelpCenterTickets)
|
||||
.where(eq(WebsiteHelpCenterTickets.open, true));
|
||||
const readCount = async (
|
||||
query: typeof cms | typeof help,
|
||||
): Promise<number> => {
|
||||
const rows = await query;
|
||||
const value = Number(rows[0]?.total);
|
||||
if (!Number.isSafeInteger(value) || value < 0) {
|
||||
throw new Error("invalid ticket queue count");
|
||||
}
|
||||
return value;
|
||||
};
|
||||
const [cmsOpen, helpOpen] = await Promise.all([
|
||||
db
|
||||
.select({ total: count() })
|
||||
.from(WebsiteTicket)
|
||||
.where(ne(WebsiteTicket.status, "closed"))
|
||||
.then((rows) => rows[0]?.total ?? 0)
|
||||
.catch(() => 0),
|
||||
db
|
||||
.select({ total: count() })
|
||||
.from(WebsiteHelpCenterTickets)
|
||||
.where(eq(WebsiteHelpCenterTickets.open, true))
|
||||
.then((rows) => rows[0]?.total ?? 0)
|
||||
.catch(() => 0),
|
||||
options?.strict ? readCount(cms) : readCount(cms).catch(() => 0),
|
||||
options?.strict ? readCount(help) : readCount(help).catch(() => 0),
|
||||
]);
|
||||
return { cmsOpen, helpOpen };
|
||||
}
|
||||
Reference in new issue
Block a user