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
@@ -9,13 +9,16 @@ import {
|
||||
ok,
|
||||
} from "../../../foundation/contracts";
|
||||
import {
|
||||
assertPeopleSerializable,
|
||||
createPeoplePage,
|
||||
type ListInput,
|
||||
normalizePeopleListInput,
|
||||
type Page,
|
||||
type PeopleBanSummary,
|
||||
type PeopleHelpTicketDetail,
|
||||
type PeopleHelpTicketSummary,
|
||||
type PeopleQueueSnapshot,
|
||||
type PeopleSupportStaff,
|
||||
type PeopleTicketDetail,
|
||||
type PeopleTicketSummary,
|
||||
type PeopleTicketTemplate,
|
||||
@@ -29,19 +32,27 @@ interface SupportRows<T> {
|
||||
readonly total: number;
|
||||
}
|
||||
|
||||
type PeopleTicketRecord = Omit<PeopleTicketDetail, "queue" | "staff">;
|
||||
type PeopleHelpTicketRecord = Omit<
|
||||
PeopleHelpTicketDetail,
|
||||
"queue" | "staff" | "activeBan"
|
||||
>;
|
||||
|
||||
export interface PeopleSupportAdapters {
|
||||
loadQueue(): Promise<PeopleQueueSnapshot>;
|
||||
loadTickets(
|
||||
input: ReturnType<typeof normalizePeopleListInput>,
|
||||
): Promise<SupportRows<PeopleTicketSummary>>;
|
||||
loadTicket(id: number): Promise<PeopleTicketDetail | null>;
|
||||
loadTicket(id: number): Promise<PeopleTicketRecord | null>;
|
||||
loadTemplates(
|
||||
input: ReturnType<typeof normalizePeopleListInput>,
|
||||
): Promise<SupportRows<PeopleTicketTemplate>>;
|
||||
loadHelpTickets(
|
||||
input: ReturnType<typeof normalizePeopleListInput>,
|
||||
): Promise<SupportRows<PeopleHelpTicketSummary>>;
|
||||
loadHelpTicket(id: number): Promise<PeopleHelpTicketDetail | null>;
|
||||
loadHelpTicket(id: number): Promise<PeopleHelpTicketRecord | null>;
|
||||
loadSupportStaff(): Promise<readonly PeopleSupportStaff[]>;
|
||||
loadActiveBan(userId: number): Promise<PeopleBanSummary | null>;
|
||||
}
|
||||
|
||||
export type PeopleSupportQueryInput =
|
||||
@@ -65,6 +76,12 @@ export type PeopleSupportQueryInput =
|
||||
export type PeopleSupportQueryData =
|
||||
| { readonly kind: "queue"; readonly queue: PeopleQueueSnapshot }
|
||||
| { readonly kind: "tickets"; readonly page: Page<PeopleTicketSummary> }
|
||||
| {
|
||||
readonly kind: "ticket-desk";
|
||||
readonly page: Page<PeopleTicketSummary>;
|
||||
readonly queue: PeopleQueueSnapshot;
|
||||
readonly staff: readonly PeopleSupportStaff[];
|
||||
}
|
||||
| {
|
||||
readonly kind: "ticket-templates";
|
||||
readonly page: Page<PeopleTicketTemplate>;
|
||||
@@ -119,10 +136,9 @@ export function createPeopleSupportQuery(
|
||||
|
||||
try {
|
||||
if (input.routeId === "people.support.queue") {
|
||||
return ok(
|
||||
{ kind: "queue" as const, queue: await adapters.loadQueue() },
|
||||
correlationId,
|
||||
);
|
||||
const queue = await adapters.loadQueue();
|
||||
assertPeopleSerializable(queue);
|
||||
return ok({ kind: "queue" as const, queue }, correlationId);
|
||||
}
|
||||
|
||||
if (
|
||||
@@ -142,40 +158,52 @@ export function createPeopleSupportQuery(
|
||||
correlationId,
|
||||
);
|
||||
}
|
||||
const helpTicket =
|
||||
input.routeId === "people.support.help-ticket-detail"
|
||||
? (ticket as PeopleHelpTicketRecord)
|
||||
: null;
|
||||
const [queue, staff, activeBan] = await Promise.all([
|
||||
adapters.loadQueue(),
|
||||
adapters.loadSupportStaff(),
|
||||
helpTicket?.userId !== null && helpTicket?.userId !== undefined
|
||||
? adapters.loadActiveBan(helpTicket.userId)
|
||||
: Promise.resolve(null),
|
||||
]);
|
||||
const hydrated =
|
||||
input.routeId === "people.support.ticket-detail"
|
||||
? { ...ticket, queue, staff }
|
||||
: { ...ticket, queue, staff, activeBan };
|
||||
assertPeopleSerializable(hydrated);
|
||||
return input.routeId === "people.support.ticket-detail"
|
||||
? ok(
|
||||
{
|
||||
kind: "ticket" as const,
|
||||
ticket: ticket as PeopleTicketDetail,
|
||||
ticket: hydrated as PeopleTicketDetail,
|
||||
},
|
||||
correlationId,
|
||||
)
|
||||
: ok(
|
||||
{
|
||||
kind: "help-ticket" as const,
|
||||
ticket: ticket as PeopleHelpTicketDetail,
|
||||
ticket: hydrated as PeopleHelpTicketDetail,
|
||||
},
|
||||
correlationId,
|
||||
);
|
||||
}
|
||||
|
||||
const list = normalizePeopleListInput(
|
||||
input.list,
|
||||
["id", "subject", "title", "status", "updatedAt", "sortOrder"],
|
||||
"id",
|
||||
);
|
||||
const allowedSorts =
|
||||
input.routeId === "people.support.ticket-templates"
|
||||
? ["id", "title", "sortOrder"]
|
||||
: input.routeId === "people.support.help-tickets"
|
||||
? ["id", "title", "updatedAt"]
|
||||
: ["id", "subject", "status", "updatedAt"];
|
||||
const list = normalizePeopleListInput(input.list, allowedSorts, "id");
|
||||
if (input.routeId === "people.support.ticket-templates") {
|
||||
const result = await adapters.loadTemplates(list);
|
||||
return ok(
|
||||
{
|
||||
kind: "ticket-templates" as const,
|
||||
page: createPeoplePage(result.rows, result.total, list, (row) =>
|
||||
list.sort === "title"
|
||||
? row.title
|
||||
: list.sort === "sortOrder"
|
||||
? row.sortOrder
|
||||
: row.id,
|
||||
),
|
||||
page: createPeoplePage(result.rows, result.total, list),
|
||||
},
|
||||
correlationId,
|
||||
);
|
||||
@@ -186,9 +214,25 @@ export function createPeopleSupportQuery(
|
||||
return ok(
|
||||
{
|
||||
kind: "help-tickets" as const,
|
||||
page: createPeoplePage(result.rows, result.total, list, (row) =>
|
||||
list.sort === "title" ? row.title : row.id,
|
||||
),
|
||||
page: createPeoplePage(result.rows, result.total, list),
|
||||
},
|
||||
correlationId,
|
||||
);
|
||||
}
|
||||
|
||||
if (input.routeId === "people.support.ticket-desk") {
|
||||
const [result, queue, staff] = await Promise.all([
|
||||
adapters.loadTickets(list),
|
||||
adapters.loadQueue(),
|
||||
adapters.loadSupportStaff(),
|
||||
]);
|
||||
assertPeopleSerializable({ queue, staff });
|
||||
return ok(
|
||||
{
|
||||
kind: "ticket-desk" as const,
|
||||
page: createPeoplePage(result.rows, result.total, list),
|
||||
queue,
|
||||
staff,
|
||||
},
|
||||
correlationId,
|
||||
);
|
||||
@@ -198,9 +242,7 @@ export function createPeopleSupportQuery(
|
||||
return ok(
|
||||
{
|
||||
kind: "tickets" as const,
|
||||
page: createPeoplePage(result.rows, result.total, list, (row) =>
|
||||
list.sort === "subject" ? row.subject : row.id,
|
||||
),
|
||||
page: createPeoplePage(result.rows, result.total, list),
|
||||
},
|
||||
correlationId,
|
||||
);
|
||||
@@ -212,84 +254,74 @@ export function createPeopleSupportQuery(
|
||||
}
|
||||
|
||||
function resultRows<T>(result: unknown): T[] {
|
||||
if (!Array.isArray(result)) return [];
|
||||
return Array.isArray(result[0]) ? (result[0] as T[]) : [];
|
||||
if (!Array.isArray(result) || !Array.isArray(result[0])) {
|
||||
throw new Error("invalid People driver result");
|
||||
}
|
||||
return result[0] as T[];
|
||||
}
|
||||
|
||||
export const peopleSupportAdapters: PeopleSupportAdapters = {
|
||||
const peopleSupportAdapters: PeopleSupportAdapters = {
|
||||
async loadQueue() {
|
||||
const [{ sql }, { db }] = await Promise.all([
|
||||
import("drizzle-orm"),
|
||||
import("@/lib/db"),
|
||||
]);
|
||||
const result = await db.execute(sql`
|
||||
const [{ sql }, { db }, { fetchTicketQueueOpenCounts }] = await Promise.all(
|
||||
[
|
||||
import("drizzle-orm"),
|
||||
import("@/lib/db"),
|
||||
import("@/lib/admin/ticket-queue-counts"),
|
||||
],
|
||||
);
|
||||
const [ticketCounts, result] = await Promise.all([
|
||||
fetchTicketQueueOpenCounts({ strict: true }),
|
||||
db.execute(sql`
|
||||
SELECT
|
||||
(SELECT COUNT(*) FROM website_tickets WHERE status <> 'closed') AS tickets,
|
||||
(SELECT COUNT(*) FROM website_help_center_tickets WHERE open = 1) AS helpTickets,
|
||||
(SELECT COUNT(*) FROM support_tickets WHERE state <> 2) AS cfh,
|
||||
(SELECT COUNT(*) FROM bans WHERE ban_expire = 0 OR ban_expire > UNIX_TIMESTAMP()) AS activeBans
|
||||
`);
|
||||
`),
|
||||
]);
|
||||
const row = resultRows<{
|
||||
tickets: number;
|
||||
helpTickets: number;
|
||||
cfh: number;
|
||||
activeBans: number;
|
||||
}>(result)[0];
|
||||
if (!row) throw new Error("queue counts unavailable");
|
||||
return {
|
||||
tickets: Number(row.tickets),
|
||||
helpTickets: Number(row.helpTickets),
|
||||
tickets: ticketCounts.cmsOpen,
|
||||
helpTickets: ticketCounts.helpOpen,
|
||||
cfh: Number(row.cfh),
|
||||
activeBans: Number(row.activeBans),
|
||||
};
|
||||
},
|
||||
async loadTickets(input) {
|
||||
const [{ sql }, { db }] = await Promise.all([
|
||||
import("drizzle-orm"),
|
||||
import("@/lib/db"),
|
||||
]);
|
||||
const pattern = `%${input.search}%`;
|
||||
const where = input.search
|
||||
? sql`WHERE t.subject LIKE ${pattern} OR t.status LIKE ${pattern} OR u.username LIKE ${pattern}`
|
||||
: sql``;
|
||||
const [rowsResult, countResult] = await Promise.all([
|
||||
db.execute(sql`
|
||||
SELECT t.id, t.subject, t.status, t.priority, t.creator_id AS creatorId,
|
||||
u.username AS creatorUsername, t.updated_at AS updatedAt
|
||||
FROM website_tickets t
|
||||
LEFT JOIN users u ON u.id = t.creator_id
|
||||
${where}
|
||||
ORDER BY ${input.sort === "subject" ? sql`t.subject` : input.sort === "status" ? sql`t.status` : input.sort === "updatedAt" ? sql`t.updated_at` : sql`t.id`} ${
|
||||
input.order === "asc" ? sql`ASC` : sql`DESC`
|
||||
}, t.id ASC
|
||||
LIMIT ${input.offset + input.pageSize}
|
||||
`),
|
||||
db.execute(sql`
|
||||
SELECT COUNT(*) AS total FROM website_tickets t
|
||||
LEFT JOIN users u ON u.id = t.creator_id ${where}
|
||||
`),
|
||||
]);
|
||||
const rows = resultRows<{
|
||||
id: number;
|
||||
subject: string;
|
||||
status: string;
|
||||
priority: string;
|
||||
creatorId: number;
|
||||
creatorUsername: string | null;
|
||||
updatedAt: Date | string | null;
|
||||
}>(rowsResult).map((row) => ({
|
||||
id: Number(row.id),
|
||||
subject: row.subject,
|
||||
status: row.status,
|
||||
priority: row.priority,
|
||||
creatorId: Number(row.creatorId),
|
||||
creatorUsername: row.creatorUsername,
|
||||
updatedAt: toPeopleIsoDate(row.updatedAt),
|
||||
href: peopleTicketHref(Number(row.id)),
|
||||
}));
|
||||
const { fetchUnifiedTicketInbox } = await import(
|
||||
"@/lib/admin/ticket-inbox"
|
||||
);
|
||||
const result = await fetchUnifiedTicketInbox({
|
||||
strict: true,
|
||||
page: Math.floor(input.offset / input.pageSize) + 1,
|
||||
perPage: input.pageSize,
|
||||
offset: input.offset,
|
||||
search: input.search,
|
||||
openOnly: false,
|
||||
base: "admin",
|
||||
sort: input.sort as "id" | "subject" | "status" | "updatedAt",
|
||||
order: input.order,
|
||||
});
|
||||
const rows = result.rows.map((row) => {
|
||||
const id = Number(row.id);
|
||||
return {
|
||||
source: row.kind,
|
||||
id,
|
||||
subject: row.title,
|
||||
status: row.status,
|
||||
priority: "normal",
|
||||
creatorId: row.userId,
|
||||
creatorUsername: row.user === "—" ? null : row.user,
|
||||
updatedAt: row.sortAt === 0 ? null : toPeopleIsoDate(row.sortAt),
|
||||
href:
|
||||
row.kind === "cms" ? peopleTicketHref(id) : peopleHelpTicketHref(id),
|
||||
};
|
||||
});
|
||||
return {
|
||||
rows,
|
||||
total: Number(resultRows<{ total: number }>(countResult)[0]?.total ?? 0),
|
||||
total: result.total,
|
||||
};
|
||||
},
|
||||
async loadTicket(id) {
|
||||
@@ -313,6 +345,7 @@ export const peopleSupportAdapters: PeopleSupportAdapters = {
|
||||
LEFT JOIN users u ON u.id = m.user_id
|
||||
WHERE m.ticket_id = ${id}
|
||||
ORDER BY m.created_at ASC, m.id ASC
|
||||
LIMIT 500
|
||||
`),
|
||||
]);
|
||||
const row = resultRows<{
|
||||
@@ -328,6 +361,7 @@ export const peopleSupportAdapters: PeopleSupportAdapters = {
|
||||
}>(ticketResult)[0];
|
||||
if (!row) return null;
|
||||
return {
|
||||
source: "cms",
|
||||
id: Number(row.id),
|
||||
subject: row.subject,
|
||||
category: row.category,
|
||||
@@ -371,7 +405,7 @@ export const peopleSupportAdapters: PeopleSupportAdapters = {
|
||||
ORDER BY ${input.sort === "title" ? sql`title` : input.sort === "sortOrder" ? sql`sort_order` : sql`id`} ${
|
||||
input.order === "desc" ? sql`DESC` : sql`ASC`
|
||||
}, id ASC
|
||||
LIMIT ${input.offset + input.pageSize}
|
||||
LIMIT ${input.pageSize} OFFSET ${input.offset}
|
||||
`),
|
||||
db.execute(sql`
|
||||
SELECT COUNT(*) AS total FROM website_ticket_templates ${where}
|
||||
@@ -408,7 +442,7 @@ export const peopleSupportAdapters: PeopleSupportAdapters = {
|
||||
ORDER BY ${input.sort === "title" ? sql`t.title` : input.sort === "updatedAt" ? sql`t.updated_at` : sql`t.id`} ${
|
||||
input.order === "asc" ? sql`ASC` : sql`DESC`
|
||||
}, t.id ASC
|
||||
LIMIT ${input.offset + input.pageSize}
|
||||
LIMIT ${input.pageSize} OFFSET ${input.offset}
|
||||
`),
|
||||
db.execute(sql`
|
||||
SELECT COUNT(*) AS total FROM website_help_center_tickets t
|
||||
@@ -458,6 +492,7 @@ export const peopleSupportAdapters: PeopleSupportAdapters = {
|
||||
LEFT JOIN users u ON u.id = r.user_id
|
||||
WHERE r.ticket_id = ${id}
|
||||
ORDER BY r.created_at ASC, r.id ASC
|
||||
LIMIT 500
|
||||
`),
|
||||
]);
|
||||
const row = resultRows<{
|
||||
@@ -498,6 +533,75 @@ export const peopleSupportAdapters: PeopleSupportAdapters = {
|
||||
})),
|
||||
};
|
||||
},
|
||||
async loadSupportStaff() {
|
||||
const [{ sql }, { db }, { getMinStaffRank }] = await Promise.all([
|
||||
import("drizzle-orm"),
|
||||
import("@/lib/db"),
|
||||
import("@/lib/admin/min-staff-rank"),
|
||||
]);
|
||||
const minStaffRank = await getMinStaffRank();
|
||||
const result = await db.execute(sql`
|
||||
SELECT id, username, rank FROM users
|
||||
WHERE rank >= ${minStaffRank}
|
||||
ORDER BY username ASC, id ASC
|
||||
LIMIT 200
|
||||
`);
|
||||
return resultRows<{ id: number; username: string; rank: number }>(
|
||||
result,
|
||||
).map((row) => ({
|
||||
id: Number(row.id),
|
||||
username: row.username,
|
||||
rank: Number(row.rank),
|
||||
href: `/ase/people/users/${Number(row.id)}` as const,
|
||||
}));
|
||||
},
|
||||
async loadActiveBan(userId) {
|
||||
const [{ sql }, { db }] = await Promise.all([
|
||||
import("drizzle-orm"),
|
||||
import("@/lib/db"),
|
||||
]);
|
||||
const result = await db.execute(sql`
|
||||
SELECT b.id, b.user_id AS userId, target.username,
|
||||
b.user_staff_id AS staffId, staff.username AS staffUsername,
|
||||
b.type, b.ban_reason AS reason, b.timestamp AS createdAt,
|
||||
b.ban_expire AS expiresAt
|
||||
FROM bans b
|
||||
LEFT JOIN users target ON target.id = b.user_id
|
||||
LEFT JOIN users staff ON staff.id = b.user_staff_id
|
||||
WHERE b.user_id = ${userId}
|
||||
AND (b.ban_expire = 0 OR b.ban_expire > UNIX_TIMESTAMP())
|
||||
ORDER BY (b.ban_expire = 0) DESC, b.ban_expire DESC,
|
||||
b.timestamp DESC, b.id DESC
|
||||
LIMIT 1
|
||||
`);
|
||||
const row = resultRows<{
|
||||
id: number;
|
||||
userId: number;
|
||||
username: string | null;
|
||||
staffId: number;
|
||||
staffUsername: string | null;
|
||||
type: string;
|
||||
reason: string;
|
||||
createdAt: Date | string | number | null;
|
||||
expiresAt: number;
|
||||
}>(result)[0];
|
||||
if (!row) return null;
|
||||
return {
|
||||
id: Number(row.id),
|
||||
userId: Number(row.userId),
|
||||
username: row.username,
|
||||
staffId: Number(row.staffId),
|
||||
staffUsername: row.staffUsername,
|
||||
type: row.type,
|
||||
reason: row.reason,
|
||||
createdAt: toPeopleIsoDate(row.createdAt),
|
||||
expiresAt:
|
||||
Number(row.expiresAt) === 0
|
||||
? 0
|
||||
: (toPeopleIsoDate(row.expiresAt) as string),
|
||||
active: true,
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
export const peopleSupportQuery = createPeopleSupportQuery(
|
||||
|
||||
Reference in new issue
Block a user