610 lines
18 KiB
TypeScript
610 lines
18 KiB
TypeScript
import "server-only";
|
|
|
|
import { PERMS } from "@/lib/permission-slugs";
|
|
import { authorizeHousekeeping } from "../../../foundation/authorization";
|
|
import {
|
|
anyCapability,
|
|
fail,
|
|
type HousekeepingQuery,
|
|
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,
|
|
peopleHelpTicketHref,
|
|
peopleTicketHref,
|
|
toPeopleIsoDate,
|
|
} from "../models";
|
|
|
|
interface SupportRows<T> {
|
|
readonly rows: readonly 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<PeopleTicketRecord | null>;
|
|
loadTemplates(
|
|
input: ReturnType<typeof normalizePeopleListInput>,
|
|
): Promise<SupportRows<PeopleTicketTemplate>>;
|
|
loadHelpTickets(
|
|
input: ReturnType<typeof normalizePeopleListInput>,
|
|
): Promise<SupportRows<PeopleHelpTicketSummary>>;
|
|
loadHelpTicket(id: number): Promise<PeopleHelpTicketRecord | null>;
|
|
loadSupportStaff(): Promise<readonly PeopleSupportStaff[]>;
|
|
loadActiveBan(userId: number): Promise<PeopleBanSummary | null>;
|
|
}
|
|
|
|
export type PeopleSupportQueryInput =
|
|
| { readonly routeId: "people.support.queue" }
|
|
| { readonly routeId: "people.support.tickets"; readonly list: ListInput }
|
|
| { readonly routeId: "people.support.ticket-desk"; readonly list: ListInput }
|
|
| {
|
|
readonly routeId: "people.support.ticket-templates";
|
|
readonly list: ListInput;
|
|
}
|
|
| { readonly routeId: "people.support.ticket-detail"; readonly id: number }
|
|
| {
|
|
readonly routeId: "people.support.help-tickets";
|
|
readonly list: ListInput;
|
|
}
|
|
| {
|
|
readonly routeId: "people.support.help-ticket-detail";
|
|
readonly id: number;
|
|
};
|
|
|
|
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>;
|
|
}
|
|
| { readonly kind: "ticket"; readonly ticket: PeopleTicketDetail }
|
|
| {
|
|
readonly kind: "help-tickets";
|
|
readonly page: Page<PeopleHelpTicketSummary>;
|
|
}
|
|
| {
|
|
readonly kind: "help-ticket";
|
|
readonly ticket: PeopleHelpTicketDetail;
|
|
};
|
|
|
|
const broadCapability = anyCapability(
|
|
PERMS.TICKETS_VIEW,
|
|
PERMS.MOD_TICKETS_VIEW,
|
|
PERMS.TICKETS_EDIT,
|
|
);
|
|
|
|
function unavailable(correlationId: string) {
|
|
return fail(
|
|
"DEPENDENCY_UNAVAILABLE",
|
|
"errors.housekeeping.dependencyUnavailable",
|
|
correlationId,
|
|
);
|
|
}
|
|
|
|
function invalidId(id: number, correlationId: string) {
|
|
return !Number.isSafeInteger(id) || id <= 0
|
|
? fail("VALIDATION", "errors.housekeeping.validation", correlationId, {
|
|
id: ["invalid"],
|
|
})
|
|
: null;
|
|
}
|
|
|
|
export function createPeopleSupportQuery(
|
|
adapters: PeopleSupportAdapters,
|
|
): HousekeepingQuery<PeopleSupportQueryInput, PeopleSupportQueryData> {
|
|
return {
|
|
id: "people.support.query",
|
|
owner: "people",
|
|
capability: broadCapability,
|
|
async run(context, input) {
|
|
const requirement =
|
|
input.routeId === "people.support.ticket-templates"
|
|
? anyCapability(PERMS.TICKETS_EDIT)
|
|
: anyCapability(PERMS.TICKETS_VIEW, PERMS.MOD_TICKETS_VIEW);
|
|
const authorization = authorizeHousekeeping(context, requirement);
|
|
if (!authorization.ok) return authorization;
|
|
const correlationId = authorization.correlationId;
|
|
|
|
try {
|
|
if (input.routeId === "people.support.queue") {
|
|
const queue = await adapters.loadQueue();
|
|
assertPeopleSerializable(queue);
|
|
return ok({ kind: "queue" as const, queue }, correlationId);
|
|
}
|
|
|
|
if (
|
|
input.routeId === "people.support.ticket-detail" ||
|
|
input.routeId === "people.support.help-ticket-detail"
|
|
) {
|
|
const invalid = invalidId(input.id, correlationId);
|
|
if (invalid !== null) return invalid;
|
|
const ticket =
|
|
input.routeId === "people.support.ticket-detail"
|
|
? await adapters.loadTicket(input.id)
|
|
: await adapters.loadHelpTicket(input.id);
|
|
if (ticket === null) {
|
|
return fail(
|
|
"NOT_FOUND",
|
|
"errors.housekeeping.notFound",
|
|
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: hydrated as PeopleTicketDetail,
|
|
},
|
|
correlationId,
|
|
)
|
|
: ok(
|
|
{
|
|
kind: "help-ticket" as const,
|
|
ticket: hydrated as PeopleHelpTicketDetail,
|
|
},
|
|
correlationId,
|
|
);
|
|
}
|
|
|
|
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),
|
|
},
|
|
correlationId,
|
|
);
|
|
}
|
|
|
|
if (input.routeId === "people.support.help-tickets") {
|
|
const result = await adapters.loadHelpTickets(list);
|
|
return ok(
|
|
{
|
|
kind: "help-tickets" as const,
|
|
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,
|
|
);
|
|
}
|
|
|
|
const result = await adapters.loadTickets(list);
|
|
return ok(
|
|
{
|
|
kind: "tickets" as const,
|
|
page: createPeoplePage(result.rows, result.total, list),
|
|
},
|
|
correlationId,
|
|
);
|
|
} catch {
|
|
return unavailable(correlationId);
|
|
}
|
|
},
|
|
};
|
|
}
|
|
|
|
function resultRows<T>(result: unknown): T[] {
|
|
if (!Array.isArray(result) || !Array.isArray(result[0])) {
|
|
throw new Error("invalid People driver result");
|
|
}
|
|
return result[0] as T[];
|
|
}
|
|
|
|
const peopleSupportAdapters: PeopleSupportAdapters = {
|
|
async loadQueue() {
|
|
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 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<{
|
|
cfh: number;
|
|
activeBans: number;
|
|
}>(result)[0];
|
|
if (!row) throw new Error("queue counts unavailable");
|
|
return {
|
|
tickets: ticketCounts.cmsOpen,
|
|
helpTickets: ticketCounts.helpOpen,
|
|
cfh: Number(row.cfh),
|
|
activeBans: Number(row.activeBans),
|
|
};
|
|
},
|
|
async loadTickets(input) {
|
|
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: result.total,
|
|
};
|
|
},
|
|
async loadTicket(id) {
|
|
const [{ sql }, { db }] = await Promise.all([
|
|
import("drizzle-orm"),
|
|
import("@/lib/db"),
|
|
]);
|
|
const [ticketResult, messagesResult] = await Promise.all([
|
|
db.execute(sql`
|
|
SELECT t.id, t.subject, t.category, t.priority, t.status,
|
|
t.creator_id AS creatorId, creator.username AS creatorUsername,
|
|
t.assignee_id AS assigneeId, t.updated_at AS updatedAt
|
|
FROM website_tickets t
|
|
LEFT JOIN users creator ON creator.id = t.creator_id
|
|
WHERE t.id = ${id} LIMIT 1
|
|
`),
|
|
db.execute(sql`
|
|
SELECT m.id, m.user_id AS userId, u.username, m.message,
|
|
m.is_staff AS isStaff, m.created_at AS createdAt
|
|
FROM website_ticket_messages m
|
|
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<{
|
|
id: number;
|
|
subject: string;
|
|
category: string;
|
|
priority: string;
|
|
status: string;
|
|
creatorId: number;
|
|
creatorUsername: string | null;
|
|
assigneeId: number | null;
|
|
updatedAt: Date | string | null;
|
|
}>(ticketResult)[0];
|
|
if (!row) return null;
|
|
return {
|
|
source: "cms",
|
|
id: Number(row.id),
|
|
subject: row.subject,
|
|
category: row.category,
|
|
priority: row.priority,
|
|
status: row.status,
|
|
creatorId: Number(row.creatorId),
|
|
creatorUsername: row.creatorUsername,
|
|
assigneeId: row.assigneeId === null ? null : Number(row.assigneeId),
|
|
updatedAt: toPeopleIsoDate(row.updatedAt),
|
|
href: peopleTicketHref(Number(row.id)),
|
|
messages: resultRows<{
|
|
id: number;
|
|
userId: number;
|
|
username: string | null;
|
|
message: string;
|
|
isStaff: number | boolean;
|
|
createdAt: Date | string | null;
|
|
}>(messagesResult).map((message) => ({
|
|
id: Number(message.id),
|
|
userId: Number(message.userId),
|
|
username: message.username,
|
|
message: message.message,
|
|
isStaff: Boolean(message.isStaff),
|
|
createdAt: toPeopleIsoDate(message.createdAt),
|
|
})),
|
|
};
|
|
},
|
|
async loadTemplates(input) {
|
|
const [{ sql }, { db }] = await Promise.all([
|
|
import("drizzle-orm"),
|
|
import("@/lib/db"),
|
|
]);
|
|
const pattern = `%${input.search}%`;
|
|
const where = input.search
|
|
? sql`WHERE title LIKE ${pattern} OR category LIKE ${pattern}`
|
|
: sql``;
|
|
const [rowsResult, countResult] = await Promise.all([
|
|
db.execute(sql`
|
|
SELECT id, title, content, category, sort_order AS sortOrder
|
|
FROM website_ticket_templates ${where}
|
|
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.pageSize} OFFSET ${input.offset}
|
|
`),
|
|
db.execute(sql`
|
|
SELECT COUNT(*) AS total FROM website_ticket_templates ${where}
|
|
`),
|
|
]);
|
|
const rows = resultRows<PeopleTicketTemplate>(rowsResult).map((row) => ({
|
|
id: Number(row.id),
|
|
title: row.title,
|
|
content: row.content,
|
|
category: row.category,
|
|
sortOrder: Number(row.sortOrder),
|
|
}));
|
|
return {
|
|
rows,
|
|
total: Number(resultRows<{ total: number }>(countResult)[0]?.total ?? 0),
|
|
};
|
|
},
|
|
async loadHelpTickets(input) {
|
|
const [{ sql }, { db }] = await Promise.all([
|
|
import("drizzle-orm"),
|
|
import("@/lib/db"),
|
|
]);
|
|
const pattern = `%${input.search}%`;
|
|
const where = input.search
|
|
? sql`WHERE t.title LIKE ${pattern} OR u.username LIKE ${pattern}`
|
|
: sql``;
|
|
const [rowsResult, countResult] = await Promise.all([
|
|
db.execute(sql`
|
|
SELECT t.id, t.title, t.open, t.user_id AS userId, u.username,
|
|
t.updated_at AS updatedAt
|
|
FROM website_help_center_tickets t
|
|
LEFT JOIN users u ON u.id = t.user_id
|
|
${where}
|
|
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.pageSize} OFFSET ${input.offset}
|
|
`),
|
|
db.execute(sql`
|
|
SELECT COUNT(*) AS total FROM website_help_center_tickets t
|
|
LEFT JOIN users u ON u.id = t.user_id ${where}
|
|
`),
|
|
]);
|
|
const rows = resultRows<{
|
|
id: bigint | number;
|
|
title: string;
|
|
open: boolean | number;
|
|
userId: number | null;
|
|
username: string | null;
|
|
updatedAt: Date | string | null;
|
|
}>(rowsResult).map((row) => ({
|
|
id: Number(row.id),
|
|
title: row.title,
|
|
open: Boolean(row.open),
|
|
userId: row.userId === null ? null : Number(row.userId),
|
|
username: row.username,
|
|
updatedAt: toPeopleIsoDate(row.updatedAt),
|
|
href: peopleHelpTicketHref(Number(row.id)),
|
|
}));
|
|
return {
|
|
rows,
|
|
total: Number(resultRows<{ total: number }>(countResult)[0]?.total ?? 0),
|
|
};
|
|
},
|
|
async loadHelpTicket(id) {
|
|
const [{ sql }, { db }] = await Promise.all([
|
|
import("drizzle-orm"),
|
|
import("@/lib/db"),
|
|
]);
|
|
const [ticketResult, repliesResult] = await Promise.all([
|
|
db.execute(sql`
|
|
SELECT t.id, t.title, t.content, t.open, t.user_id AS userId,
|
|
u.username, t.category_id AS categoryId, c.name AS categoryName,
|
|
t.updated_at AS updatedAt
|
|
FROM website_help_center_tickets t
|
|
LEFT JOIN users u ON u.id = t.user_id
|
|
LEFT JOIN website_help_center_categories c ON c.id = t.category_id
|
|
WHERE t.id = ${id} LIMIT 1
|
|
`),
|
|
db.execute(sql`
|
|
SELECT r.id, r.user_id AS userId, u.username, r.content,
|
|
r.created_at AS createdAt
|
|
FROM website_help_center_ticket_replies r
|
|
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<{
|
|
id: bigint | number;
|
|
title: string;
|
|
content: string;
|
|
open: boolean | number;
|
|
userId: number | null;
|
|
username: string | null;
|
|
categoryId: bigint | number | null;
|
|
categoryName: string | null;
|
|
updatedAt: Date | string | null;
|
|
}>(ticketResult)[0];
|
|
if (!row) return null;
|
|
return {
|
|
id: Number(row.id),
|
|
title: row.title,
|
|
content: row.content,
|
|
open: Boolean(row.open),
|
|
userId: row.userId === null ? null : Number(row.userId),
|
|
username: row.username,
|
|
categoryId: row.categoryId === null ? null : Number(row.categoryId),
|
|
categoryName: row.categoryName,
|
|
updatedAt: toPeopleIsoDate(row.updatedAt),
|
|
href: peopleHelpTicketHref(Number(row.id)),
|
|
replies: resultRows<{
|
|
id: bigint | number;
|
|
userId: number;
|
|
username: string | null;
|
|
content: string;
|
|
createdAt: Date | string | null;
|
|
}>(repliesResult).map((reply) => ({
|
|
id: Number(reply.id),
|
|
userId: Number(reply.userId),
|
|
username: reply.username,
|
|
content: reply.content,
|
|
createdAt: toPeopleIsoDate(reply.createdAt),
|
|
})),
|
|
};
|
|
},
|
|
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(
|
|
peopleSupportAdapters,
|
|
);
|