Files
EpicNext-Cms/src/features/housekeeping/domains/people/services/support-mutations.ts
T

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");
},
};
}