feat(housekeeping): model people workflows
This commit is contained in:
1 parent
c325c53774
commit
e3f8b51d31
13 files changed
+3736
No files matched your search
@@ -0,0 +1,505 @@
|
||||
import "server-only";
|
||||
|
||||
import { PERMS } from "@/lib/permission-slugs";
|
||||
import { authorizeHousekeeping } from "../../../foundation/authorization";
|
||||
import {
|
||||
anyCapability,
|
||||
fail,
|
||||
type HousekeepingQuery,
|
||||
ok,
|
||||
} from "../../../foundation/contracts";
|
||||
import {
|
||||
createPeoplePage,
|
||||
type ListInput,
|
||||
normalizePeopleListInput,
|
||||
type Page,
|
||||
type PeopleHelpTicketDetail,
|
||||
type PeopleHelpTicketSummary,
|
||||
type PeopleQueueSnapshot,
|
||||
type PeopleTicketDetail,
|
||||
type PeopleTicketSummary,
|
||||
type PeopleTicketTemplate,
|
||||
peopleHelpTicketHref,
|
||||
peopleTicketHref,
|
||||
toPeopleIsoDate,
|
||||
} from "../models";
|
||||
|
||||
interface SupportRows<T> {
|
||||
readonly rows: readonly T[];
|
||||
readonly total: number;
|
||||
}
|
||||
|
||||
export interface PeopleSupportAdapters {
|
||||
loadQueue(): Promise<PeopleQueueSnapshot>;
|
||||
loadTickets(
|
||||
input: ReturnType<typeof normalizePeopleListInput>,
|
||||
): Promise<SupportRows<PeopleTicketSummary>>;
|
||||
loadTicket(id: number): Promise<PeopleTicketDetail | null>;
|
||||
loadTemplates(
|
||||
input: ReturnType<typeof normalizePeopleListInput>,
|
||||
): Promise<SupportRows<PeopleTicketTemplate>>;
|
||||
loadHelpTickets(
|
||||
input: ReturnType<typeof normalizePeopleListInput>,
|
||||
): Promise<SupportRows<PeopleHelpTicketSummary>>;
|
||||
loadHelpTicket(id: number): Promise<PeopleHelpTicketDetail | 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-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") {
|
||||
return ok(
|
||||
{ kind: "queue" as const, queue: await adapters.loadQueue() },
|
||||
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,
|
||||
);
|
||||
}
|
||||
return input.routeId === "people.support.ticket-detail"
|
||||
? ok(
|
||||
{
|
||||
kind: "ticket" as const,
|
||||
ticket: ticket as PeopleTicketDetail,
|
||||
},
|
||||
correlationId,
|
||||
)
|
||||
: ok(
|
||||
{
|
||||
kind: "help-ticket" as const,
|
||||
ticket: ticket as PeopleHelpTicketDetail,
|
||||
},
|
||||
correlationId,
|
||||
);
|
||||
}
|
||||
|
||||
const list = normalizePeopleListInput(
|
||||
input.list,
|
||||
["id", "subject", "title", "status", "updatedAt", "sortOrder"],
|
||||
"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,
|
||||
),
|
||||
},
|
||||
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, (row) =>
|
||||
list.sort === "title" ? row.title : row.id,
|
||||
),
|
||||
},
|
||||
correlationId,
|
||||
);
|
||||
}
|
||||
|
||||
const result = await adapters.loadTickets(list);
|
||||
return ok(
|
||||
{
|
||||
kind: "tickets" as const,
|
||||
page: createPeoplePage(result.rows, result.total, list, (row) =>
|
||||
list.sort === "subject" ? row.subject : row.id,
|
||||
),
|
||||
},
|
||||
correlationId,
|
||||
);
|
||||
} catch {
|
||||
return unavailable(correlationId);
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function resultRows<T>(result: unknown): T[] {
|
||||
if (!Array.isArray(result)) return [];
|
||||
return Array.isArray(result[0]) ? (result[0] as T[]) : [];
|
||||
}
|
||||
|
||||
export const peopleSupportAdapters: PeopleSupportAdapters = {
|
||||
async loadQueue() {
|
||||
const [{ sql }, { db }] = await Promise.all([
|
||||
import("drizzle-orm"),
|
||||
import("@/lib/db"),
|
||||
]);
|
||||
const result = await 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),
|
||||
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)),
|
||||
}));
|
||||
return {
|
||||
rows,
|
||||
total: Number(resultRows<{ total: number }>(countResult)[0]?.total ?? 0),
|
||||
};
|
||||
},
|
||||
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
|
||||
`),
|
||||
]);
|
||||
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 {
|
||||
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.offset + input.pageSize}
|
||||
`),
|
||||
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.offset + input.pageSize}
|
||||
`),
|
||||
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
|
||||
`),
|
||||
]);
|
||||
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),
|
||||
})),
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
export const peopleSupportQuery = createPeopleSupportQuery(
|
||||
peopleSupportAdapters,
|
||||
);
|
||||
Reference in new issue
Block a user