481 lines
14 KiB
TypeScript
481 lines
14 KiB
TypeScript
import "server-only";
|
|
|
|
import { eq } from "drizzle-orm";
|
|
import {
|
|
Ban,
|
|
type Db,
|
|
SupportTickets,
|
|
WebsiteHelpCenterTicketReplies,
|
|
WebsiteHelpCenterTickets,
|
|
WebsiteTicket,
|
|
WebsiteTicketMessage,
|
|
WebsiteTicketTemplate,
|
|
} from "@/lib/db";
|
|
import type { AuditEntry, HousekeepingAuditWriter } from "@/lib/services/audit";
|
|
import type {
|
|
HousekeepingCapabilityContext,
|
|
HousekeepingErrorCode,
|
|
HousekeepingPartialCompletion,
|
|
} from "../../../foundation/contracts";
|
|
|
|
export const PEOPLE_SUPPORT_MUTATION_OPERATIONS = [
|
|
"ticket.reply",
|
|
"ticket.assign",
|
|
"ticket.status",
|
|
"ticket.priority",
|
|
"ticket-template.change",
|
|
"help-ticket.reply",
|
|
"help-ticket.status",
|
|
"help-ticket.unban",
|
|
"cfh.resolve",
|
|
] as const;
|
|
|
|
export type PeopleSupportMutationOperation =
|
|
(typeof PEOPLE_SUPPORT_MUTATION_OPERATIONS)[number];
|
|
|
|
export interface PeopleSupportMutationContext {
|
|
readonly capability: HousekeepingCapabilityContext;
|
|
readonly correlationId: string;
|
|
readonly legacy: boolean;
|
|
}
|
|
|
|
export interface PeopleSupportMutationSnapshot {
|
|
readonly before: Readonly<Record<string, unknown>> | null;
|
|
readonly after: Readonly<Record<string, unknown>> | null;
|
|
readonly output?: Readonly<Record<string, unknown>>;
|
|
readonly completion?: HousekeepingPartialCompletion;
|
|
}
|
|
|
|
type AuditOutcome = "intent" | "success" | "failure" | "partial";
|
|
|
|
export interface PeopleSupportMutationDependencies {
|
|
readonly db: Db;
|
|
readonly writeAudit: HousekeepingAuditWriter["write"];
|
|
readonly auditEntry: (
|
|
context: PeopleSupportMutationContext,
|
|
operation: PeopleSupportMutationOperation,
|
|
target: string,
|
|
targetId: number | undefined,
|
|
snapshot: PeopleSupportMutationSnapshot,
|
|
outcome: AuditOutcome,
|
|
) => AuditEntry;
|
|
readonly failure: (code: HousekeepingErrorCode, messageKey: string) => Error;
|
|
readonly record: (input: unknown) => Record<string, unknown>;
|
|
readonly positiveInteger: (value: unknown) => number;
|
|
readonly nonNegativeInteger: (value: unknown) => number;
|
|
readonly normalizedText: (
|
|
value: unknown,
|
|
max: number,
|
|
required?: boolean,
|
|
) => string;
|
|
readonly canonicalTicketId: (value: string | number | bigint) => bigint;
|
|
readonly auditTargetId: (value: bigint) => number | undefined;
|
|
}
|
|
|
|
export interface PeopleSupportMutationExecutor {
|
|
execute(
|
|
operation: PeopleSupportMutationOperation,
|
|
input: unknown,
|
|
context: PeopleSupportMutationContext,
|
|
): Promise<PeopleSupportMutationSnapshot>;
|
|
}
|
|
|
|
export function isPeopleSupportMutationOperation(
|
|
operation: string,
|
|
): operation is PeopleSupportMutationOperation {
|
|
return (PEOPLE_SUPPORT_MUTATION_OPERATIONS as readonly string[]).includes(
|
|
operation,
|
|
);
|
|
}
|
|
|
|
export function createPeopleSupportMutationExecutor(
|
|
dependencies: PeopleSupportMutationDependencies,
|
|
): PeopleSupportMutationExecutor {
|
|
const {
|
|
db,
|
|
writeAudit,
|
|
auditEntry,
|
|
failure,
|
|
record,
|
|
positiveInteger,
|
|
nonNegativeInteger,
|
|
normalizedText,
|
|
canonicalTicketId,
|
|
auditTargetId,
|
|
} = dependencies;
|
|
|
|
async function executeTicketMutation(
|
|
operation: Extract<PeopleSupportMutationOperation, `ticket.${string}`>,
|
|
input: unknown,
|
|
context: PeopleSupportMutationContext,
|
|
): Promise<PeopleSupportMutationSnapshot> {
|
|
const data = record(input);
|
|
const ticketId = positiveInteger(data.ticketId);
|
|
let snapshot!: PeopleSupportMutationSnapshot;
|
|
await db.transaction(async (tx) => {
|
|
const [ticket] = await tx
|
|
.select({
|
|
id: WebsiteTicket.id,
|
|
assigneeId: WebsiteTicket.assigneeId,
|
|
status: WebsiteTicket.status,
|
|
priority: WebsiteTicket.priority,
|
|
})
|
|
.from(WebsiteTicket)
|
|
.where(eq(WebsiteTicket.id, ticketId))
|
|
.limit(1);
|
|
if (!ticket) {
|
|
throw failure("NOT_FOUND", "errors.housekeeping.notFound");
|
|
}
|
|
const before = {
|
|
id: ticket.id,
|
|
assigneeId: ticket.assigneeId,
|
|
status: ticket.status,
|
|
priority: ticket.priority,
|
|
};
|
|
const now = new Date();
|
|
if (operation === "ticket.reply") {
|
|
const message = normalizedText(data.message, 5_000);
|
|
await tx.insert(WebsiteTicketMessage).values({
|
|
ticketId,
|
|
userId: context.capability.actor.id,
|
|
message,
|
|
isStaff: 1,
|
|
});
|
|
const assigneeId = ticket.assigneeId ?? context.capability.actor.id;
|
|
await tx
|
|
.update(WebsiteTicket)
|
|
.set({ status: "waiting", assigneeId, updatedAt: now })
|
|
.where(eq(WebsiteTicket.id, ticketId));
|
|
snapshot = {
|
|
before,
|
|
after: { ...before, assigneeId, status: "waiting" },
|
|
};
|
|
} else if (operation === "ticket.assign") {
|
|
const assigneeId =
|
|
data.assigneeId === null ? null : positiveInteger(data.assigneeId);
|
|
const status = assigneeId === null ? "open" : "in_progress";
|
|
await tx
|
|
.update(WebsiteTicket)
|
|
.set({ assigneeId, status, updatedAt: now })
|
|
.where(eq(WebsiteTicket.id, ticketId));
|
|
snapshot = { before, after: { ...before, assigneeId, status } };
|
|
} else if (operation === "ticket.status") {
|
|
const status = normalizedText(data.status, 32);
|
|
if (!["open", "in_progress", "waiting", "closed"].includes(status)) {
|
|
throw failure("VALIDATION", "errors.housekeeping.validation");
|
|
}
|
|
const assigneeId =
|
|
status === "in_progress" && ticket.assigneeId === null
|
|
? context.capability.actor.id
|
|
: ticket.assigneeId;
|
|
await tx
|
|
.update(WebsiteTicket)
|
|
.set({
|
|
status,
|
|
assigneeId,
|
|
updatedAt: now,
|
|
closedAt: status === "closed" ? now : null,
|
|
})
|
|
.where(eq(WebsiteTicket.id, ticketId));
|
|
snapshot = { before, after: { ...before, assigneeId, status } };
|
|
} else {
|
|
const priority = normalizedText(data.priority, 32);
|
|
if (!["low", "normal", "high", "urgent"].includes(priority)) {
|
|
throw failure("VALIDATION", "errors.housekeeping.validation");
|
|
}
|
|
await tx
|
|
.update(WebsiteTicket)
|
|
.set({ priority, updatedAt: now })
|
|
.where(eq(WebsiteTicket.id, ticketId));
|
|
snapshot = { before, after: { ...before, priority } };
|
|
}
|
|
await writeAudit(
|
|
auditEntry(
|
|
context,
|
|
operation,
|
|
"WebsiteTicket",
|
|
ticketId,
|
|
snapshot,
|
|
"success",
|
|
),
|
|
tx,
|
|
);
|
|
});
|
|
return snapshot;
|
|
}
|
|
|
|
async function executeTicketTemplateChange(
|
|
input: unknown,
|
|
context: PeopleSupportMutationContext,
|
|
): Promise<PeopleSupportMutationSnapshot> {
|
|
const data = record(input);
|
|
const action = normalizedText(data.action, 16);
|
|
let snapshot!: PeopleSupportMutationSnapshot;
|
|
await db.transaction(async (tx) => {
|
|
let targetId: number | undefined;
|
|
if (action === "create") {
|
|
const values = {
|
|
title: normalizedText(data.title, 255),
|
|
content: normalizedText(data.content, 5_000),
|
|
category: normalizedText(data.category ?? "general", 50),
|
|
sortOrder:
|
|
data.sortOrder === undefined
|
|
? 0
|
|
: nonNegativeInteger(data.sortOrder),
|
|
};
|
|
const [result] = await tx.insert(WebsiteTicketTemplate).values(values);
|
|
targetId = positiveInteger(result.insertId);
|
|
snapshot = { before: null, after: { id: targetId, ...values } };
|
|
} else {
|
|
const id = positiveInteger(data.id);
|
|
targetId = id;
|
|
const [existing] = await tx
|
|
.select({
|
|
id: WebsiteTicketTemplate.id,
|
|
title: WebsiteTicketTemplate.title,
|
|
content: WebsiteTicketTemplate.content,
|
|
category: WebsiteTicketTemplate.category,
|
|
sortOrder: WebsiteTicketTemplate.sortOrder,
|
|
})
|
|
.from(WebsiteTicketTemplate)
|
|
.where(eq(WebsiteTicketTemplate.id, id))
|
|
.limit(1);
|
|
if (action === "update" && !existing) {
|
|
throw failure("NOT_FOUND", "errors.housekeeping.notFound");
|
|
}
|
|
if (action === "delete") {
|
|
await tx
|
|
.delete(WebsiteTicketTemplate)
|
|
.where(eq(WebsiteTicketTemplate.id, id));
|
|
snapshot = { before: existing ?? null, after: null };
|
|
} else if (action === "update") {
|
|
const changes = {
|
|
...(data.title === undefined
|
|
? {}
|
|
: { title: normalizedText(data.title, 255) }),
|
|
...(data.content === undefined
|
|
? {}
|
|
: { content: normalizedText(data.content, 5_000) }),
|
|
...(data.category === undefined
|
|
? {}
|
|
: { category: normalizedText(data.category, 50, false) }),
|
|
...(data.sortOrder === undefined
|
|
? {}
|
|
: { sortOrder: nonNegativeInteger(data.sortOrder) }),
|
|
};
|
|
await tx
|
|
.update(WebsiteTicketTemplate)
|
|
.set(changes)
|
|
.where(eq(WebsiteTicketTemplate.id, id));
|
|
snapshot = {
|
|
before: existing ?? null,
|
|
after: { ...existing, ...changes },
|
|
};
|
|
} else {
|
|
throw failure("VALIDATION", "errors.housekeeping.validation");
|
|
}
|
|
}
|
|
await writeAudit(
|
|
auditEntry(
|
|
context,
|
|
"ticket-template.change",
|
|
"WebsiteTicketTemplate",
|
|
targetId,
|
|
snapshot,
|
|
"success",
|
|
),
|
|
tx,
|
|
);
|
|
});
|
|
return snapshot;
|
|
}
|
|
|
|
async function executeHelpTicketMutation(
|
|
operation: Extract<PeopleSupportMutationOperation, `help-ticket.${string}`>,
|
|
input: unknown,
|
|
context: PeopleSupportMutationContext,
|
|
): Promise<PeopleSupportMutationSnapshot> {
|
|
const data = record(input);
|
|
let ticketId: bigint;
|
|
try {
|
|
ticketId = canonicalTicketId(data.ticketId as string | number | bigint);
|
|
} catch {
|
|
throw failure("VALIDATION", "errors.housekeeping.validation");
|
|
}
|
|
let snapshot!: PeopleSupportMutationSnapshot;
|
|
await db.transaction(async (tx) => {
|
|
const [ticket] = await tx
|
|
.select({
|
|
id: WebsiteHelpCenterTickets.id,
|
|
userId: WebsiteHelpCenterTickets.userId,
|
|
open: WebsiteHelpCenterTickets.open,
|
|
title: WebsiteHelpCenterTickets.title,
|
|
})
|
|
.from(WebsiteHelpCenterTickets)
|
|
.where(eq(WebsiteHelpCenterTickets.id, ticketId))
|
|
.limit(1);
|
|
if (!ticket) {
|
|
throw failure("NOT_FOUND", "errors.housekeeping.notFound");
|
|
}
|
|
const id = ticketId.toString();
|
|
const before = {
|
|
id,
|
|
userId: ticket.userId,
|
|
open: Boolean(ticket.open),
|
|
title: ticket.title,
|
|
};
|
|
const now = new Date();
|
|
if (operation === "help-ticket.reply") {
|
|
await tx.insert(WebsiteHelpCenterTicketReplies).values({
|
|
ticketId,
|
|
userId: context.capability.actor.id,
|
|
content: normalizedText(data.content, 5_000),
|
|
createdAt: now,
|
|
updatedAt: now,
|
|
});
|
|
await tx
|
|
.update(WebsiteHelpCenterTickets)
|
|
.set({ updatedAt: now })
|
|
.where(eq(WebsiteHelpCenterTickets.id, ticketId));
|
|
snapshot = { before, after: before };
|
|
} else if (operation === "help-ticket.status") {
|
|
const status = normalizedText(data.status, 16);
|
|
const open =
|
|
status === "reopen" ? true : status === "close" ? false : null;
|
|
if (open === null) {
|
|
throw failure("VALIDATION", "errors.housekeeping.validation");
|
|
}
|
|
if (Boolean(ticket.open) === open) {
|
|
throw failure("CONFLICT", "errors.housekeeping.conflict");
|
|
}
|
|
await tx
|
|
.update(WebsiteHelpCenterTickets)
|
|
.set({ open, updatedAt: now })
|
|
.where(eq(WebsiteHelpCenterTickets.id, ticketId));
|
|
snapshot = { before, after: { ...before, open } };
|
|
} else {
|
|
if (ticket.userId === null) {
|
|
throw failure("CONFLICT", "errors.housekeeping.conflict");
|
|
}
|
|
const deleted = await tx
|
|
.delete(Ban)
|
|
.where(eq(Ban.userId, ticket.userId));
|
|
const removed = Number(
|
|
(deleted as unknown as [{ affectedRows: number }])[0]?.affectedRows ??
|
|
0,
|
|
);
|
|
if (ticket.open) {
|
|
await tx
|
|
.update(WebsiteHelpCenterTickets)
|
|
.set({ open: false, updatedAt: now })
|
|
.where(eq(WebsiteHelpCenterTickets.id, ticketId));
|
|
}
|
|
snapshot = {
|
|
before,
|
|
after: { ...before, open: false, removedBans: removed },
|
|
output: { removed, userId: ticket.userId },
|
|
};
|
|
}
|
|
await writeAudit(
|
|
auditEntry(
|
|
context,
|
|
operation,
|
|
"WebsiteHelpCenterTickets",
|
|
auditTargetId(ticketId),
|
|
snapshot,
|
|
"success",
|
|
),
|
|
tx,
|
|
);
|
|
});
|
|
return snapshot;
|
|
}
|
|
|
|
async function executeCfhResolve(
|
|
input: unknown,
|
|
context: PeopleSupportMutationContext,
|
|
): Promise<PeopleSupportMutationSnapshot> {
|
|
const data = record(input);
|
|
const ticketId = positiveInteger(data.ticketId);
|
|
const state = nonNegativeInteger(data.state);
|
|
if (state > 3) {
|
|
throw failure("VALIDATION", "errors.housekeeping.validation");
|
|
}
|
|
let snapshot!: PeopleSupportMutationSnapshot;
|
|
await db.transaction(async (tx) => {
|
|
const [ticket] = await tx
|
|
.select({
|
|
id: SupportTickets.id,
|
|
state: SupportTickets.state,
|
|
modId: SupportTickets.modId,
|
|
})
|
|
.from(SupportTickets)
|
|
.where(eq(SupportTickets.id, ticketId))
|
|
.limit(1);
|
|
if (!ticket) {
|
|
throw failure("NOT_FOUND", "errors.housekeeping.notFound");
|
|
}
|
|
snapshot = {
|
|
before: {
|
|
id: ticket.id,
|
|
state: ticket.state,
|
|
moderatorId: ticket.modId,
|
|
},
|
|
after: {
|
|
id: ticket.id,
|
|
state,
|
|
moderatorId: context.capability.actor.id,
|
|
},
|
|
};
|
|
await tx
|
|
.update(SupportTickets)
|
|
.set({ state, modId: context.capability.actor.id })
|
|
.where(eq(SupportTickets.id, ticketId));
|
|
await writeAudit(
|
|
auditEntry(
|
|
context,
|
|
"cfh.resolve",
|
|
"support_tickets",
|
|
ticketId,
|
|
snapshot,
|
|
"success",
|
|
),
|
|
tx,
|
|
);
|
|
});
|
|
return snapshot;
|
|
}
|
|
|
|
return {
|
|
async execute(operation, input, context) {
|
|
if (operation.startsWith("ticket.")) {
|
|
return executeTicketMutation(
|
|
operation as Extract<
|
|
PeopleSupportMutationOperation,
|
|
`ticket.${string}`
|
|
>,
|
|
input,
|
|
context,
|
|
);
|
|
}
|
|
if (operation === "ticket-template.change") {
|
|
return executeTicketTemplateChange(input, context);
|
|
}
|
|
if (operation.startsWith("help-ticket.")) {
|
|
return executeHelpTicketMutation(
|
|
operation as Extract<
|
|
PeopleSupportMutationOperation,
|
|
`help-ticket.${string}`
|
|
>,
|
|
input,
|
|
context,
|
|
);
|
|
}
|
|
if (operation === "cfh.resolve") {
|
|
return executeCfhResolve(input, context);
|
|
}
|
|
throw failure("VALIDATION", "errors.housekeeping.validation");
|
|
},
|
|
};
|
|
}
|