feat(housekeeping): complete people moderation parity

This commit is contained in:
Simo committed 2026-08-29 22:38:50 +02:00
1 parent 3d385d1869
commit 29fe22297b
41 files changed
+3465 -580

No files matched your search

File diff suppressed because it is too large. Load diff
+22 -41
View File
@@ -1,12 +1,13 @@
"use server"; "use server";
import { eq } from "drizzle-orm";
import { revalidatePath } from "next/cache"; import { revalidatePath } from "next/cache";
import {
createPeopleMutationInvocation,
peopleMutationService,
} from "@/features/housekeeping/domains/people/services/mutations";
import { createCorrelationId } from "@/features/housekeeping/foundation/contracts";
import { requirePermissionRateLimited } from "@/lib/admin/guard"; import { requirePermissionRateLimited } from "@/lib/admin/guard";
import { Ban, db, User } from "@/lib/db";
import { PERMS } from "@/lib/permissions"; import { PERMS } from "@/lib/permissions";
import { rcon } from "@/lib/services/rcon";
import { logStaffActivity } from "@/lib/services/staff-activity";
const BAN_TYPES: ReadonlySet<string> = new Set([ const BAN_TYPES: ReadonlySet<string> = new Set([
"account", "account",
@@ -25,37 +26,17 @@ export async function createBan(formData: FormData): Promise<void> {
const hours = Number(formData.get("hours")); const hours = Number(formData.get("hours"));
const type = String(formData.get("type")); const type = String(formData.get("type"));
if (!(userId > 0) || !BAN_TYPES.has(type)) return; if (!(userId > 0) || !BAN_TYPES.has(type)) return;
const result = await peopleMutationService.execute(
const now = Math.floor(Date.now() / 1000); createPeopleMutationInvocation(staff, createCorrelationId()),
// Emulator convention: banExpire 0 = permanent (not a far-future timestamp). "ban.create",
const banExpire = hours > 0 ? now + Math.floor(hours) * 3600 : 0; {
userId,
const [user] = await db reason,
.select({ username: User.username }) hours: Number.isFinite(hours) && hours > 0 ? Math.floor(hours) : 0,
.from(User) type,
.where(eq(User.id, userId)) },
.limit(1); );
if (!result.ok) throw new Error("Could not create ban");
await db.insert(Ban).values({
userId,
ip: "",
machineId: "",
userStaffId: staff.id,
timestamp: now,
banExpire,
banReason: reason,
type: type as "account" | "ip" | "machine" | "super",
cfhTopic: -1,
});
if (user) await rcon.disconnectUser(userId, user.username);
await logStaffActivity({
staffId: staff.id,
action: "user_ban",
description: `Banned user #${userId} (${type}, ${hours > 0 ? `${hours}h` : "permanent"}): ${reason}`,
targetType: "user",
targetId: userId,
});
revalidatePath("/admin/bans"); revalidatePath("/admin/bans");
} }
@@ -63,12 +44,12 @@ export async function liftBan(formData: FormData): Promise<void> {
const staff = await requirePermissionRateLimited(PERMS.USERS_BAN); const staff = await requirePermissionRateLimited(PERMS.USERS_BAN);
const id = Number(formData.get("id")); const id = Number(formData.get("id"));
if (id > 0) { if (id > 0) {
await db.delete(Ban).where(eq(Ban.id, id)); const result = await peopleMutationService.execute(
await logStaffActivity({ createPeopleMutationInvocation(staff, createCorrelationId()),
staffId: staff.id, "ban.lift",
action: "ban_lift", { id },
description: `Lifted ban #${id}`, );
}); if (!result.ok) throw new Error("Could not lift ban");
} }
revalidatePath("/admin/bans"); revalidatePath("/admin/bans");
} }
File diff suppressed because it is too large. Load diff
+14 -4
View File
@@ -4,7 +4,6 @@ import { eq } from "drizzle-orm";
import { revalidatePath } from "next/cache"; import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { z } from "zod"; import { z } from "zod";
import { positiveBigInt } from "@/lib/api";
import { auth } from "@/lib/auth"; import { auth } from "@/lib/auth";
import { import {
db, db,
@@ -14,7 +13,10 @@ import {
} from "@/lib/db"; } from "@/lib/db";
import { clientIp, rateLimit } from "@/lib/rate-limit"; import { clientIp, rateLimit } from "@/lib/rate-limit";
import { moderateOrThrow } from "@/lib/services/moderation"; import { moderateOrThrow } from "@/lib/services/moderation";
import { createOwnedTicketReply } from "@/lib/services/ticket-replies"; import {
canonicalTicketId,
createOwnedTicketReply,
} from "@/lib/services/ticket-replies";
const ticketSchema = z.object({ const ticketSchema = z.object({
title: z.string().min(1, "Title is required").max(255), title: z.string().min(1, "Title is required").max(255),
@@ -64,6 +66,14 @@ function isNextRedirect(e: unknown): boolean {
); );
} }
function helpTicketId(formData: FormData): bigint | null {
try {
return canonicalTicketId(String(formData.get("ticketId") ?? ""));
} catch {
return null;
}
}
export async function createTicket(formData: FormData): Promise<void> { export async function createTicket(formData: FormData): Promise<void> {
let outcome: TicketOutcome = "error"; let outcome: TicketOutcome = "error";
@@ -177,7 +187,7 @@ const replyContentSchema = z.object({
}); });
export async function replyHelpTicket(formData: FormData): Promise<void> { export async function replyHelpTicket(formData: FormData): Promise<void> {
const ticketId = positiveBigInt(String(formData.get("ticketId") ?? "")); const ticketId = helpTicketId(formData);
let outcome: TicketDetailOutcome = "error"; let outcome: TicketDetailOutcome = "error";
try { try {
@@ -284,7 +294,7 @@ export async function replyHelpTicket(formData: FormData): Promise<void> {
} }
export async function closeHelpTicket(formData: FormData): Promise<void> { export async function closeHelpTicket(formData: FormData): Promise<void> {
const ticketId = positiveBigInt(String(formData.get("ticketId") ?? "")); const ticketId = helpTicketId(formData);
let outcome: TicketDetailOutcome = "error"; let outcome: TicketDetailOutcome = "error";
try { try {
+57 -111
View File
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+31 -17
View File
@@ -1,8 +1,11 @@
"use server"; "use server";
import { eq } from "drizzle-orm";
import { z } from "zod"; import { z } from "zod";
import { db, WebsiteTicketTemplate } from "@/lib/db"; import {
createPeopleMutationInvocation,
peopleMutationService,
} from "@/features/housekeeping/domains/people/services/mutations";
import { createCorrelationId } from "@/features/housekeeping/foundation/contracts";
import { PERMS } from "@/lib/permissions"; import { PERMS } from "@/lib/permissions";
import { adminAction } from "@/lib/safe-action"; import { adminAction } from "@/lib/safe-action";
import { ActionError, actionOk } from "@/lib/safe-action-shared"; import { ActionError, actionOk } from "@/lib/safe-action-shared";
@@ -14,11 +17,33 @@ const templateSchema = z.object({
sortOrder: z.coerce.number().int().min(0).default(0), sortOrder: z.coerce.number().int().min(0).default(0),
}); });
async function execute(
staff: { readonly id: number },
input: unknown,
) {
const result = await peopleMutationService.execute(
createPeopleMutationInvocation(staff, createCorrelationId()),
"ticket-template.change",
input,
);
if (!result.ok) {
throw new ActionError(
result.error.code === "NOT_FOUND"
? "Template not found"
: "Template update failed",
);
}
return result.data;
}
export const createTemplate = adminAction( export const createTemplate = adminAction(
{ permission: PERMS.TICKETS_EDIT, schema: templateSchema }, { permission: PERMS.TICKETS_EDIT, schema: templateSchema },
async (ctx) => { async (ctx) => {
const [result] = await db.insert(WebsiteTicketTemplate).values(ctx.data); const snapshot = await execute(ctx.session.user, {
return actionOk({ id: Number(result.insertId) }); action: "create",
...ctx.data,
});
return actionOk({ id: Number(snapshot.after?.id) });
}, },
); );
@@ -30,16 +55,7 @@ export const updateTemplate = adminAction(
{ permission: PERMS.TICKETS_EDIT, schema: updateTemplateInput }, { permission: PERMS.TICKETS_EDIT, schema: updateTemplateInput },
async (ctx) => { async (ctx) => {
const { id, ...data } = ctx.data; const { id, ...data } = ctx.data;
const [existing] = await db await execute(ctx.session.user, { action: "update", id, ...data });
.select({ id: WebsiteTicketTemplate.id })
.from(WebsiteTicketTemplate)
.where(eq(WebsiteTicketTemplate.id, id))
.limit(1);
if (!existing) throw new ActionError("Template not found");
await db
.update(WebsiteTicketTemplate)
.set(data)
.where(eq(WebsiteTicketTemplate.id, id));
return actionOk({ id }); return actionOk({ id });
}, },
); );
@@ -51,9 +67,7 @@ const deleteTemplateInput = z.object({
export const deleteTemplate = adminAction( export const deleteTemplate = adminAction(
{ permission: PERMS.TICKETS_EDIT, schema: deleteTemplateInput }, { permission: PERMS.TICKETS_EDIT, schema: deleteTemplateInput },
async (ctx) => { async (ctx) => {
await db await execute(ctx.session.user, { action: "delete", id: ctx.data.id });
.delete(WebsiteTicketTemplate)
.where(eq(WebsiteTicketTemplate.id, ctx.data.id));
return actionOk(); return actionOk();
}, },
); );
+30 -133
View File
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
@@ -3,7 +3,10 @@ import {
anyCapability, anyCapability,
type HousekeepingDomainManifest, type HousekeepingDomainManifest,
} from "../../foundation/contracts"; } from "../../foundation/contracts";
import { PEOPLE_PRIMARY_ROUTES } from "./routes"; import { PEOPLE_INBOX_SOURCES } from "./inbox";
import { PEOPLE_ROUTES } from "./routes";
import { PEOPLE_SEARCH_PROVIDERS } from "./search";
import { PEOPLE_WIDGETS } from "./widgets";
export const peopleManifest = { export const peopleManifest = {
id: "people", id: "people",
@@ -35,8 +38,8 @@ export const peopleManifest = {
PERMS.MOD_CFH_EDIT, PERMS.MOD_CFH_EDIT,
PERMS.MOD_TICKETS_EDIT, PERMS.MOD_TICKETS_EDIT,
), ),
routes: PEOPLE_PRIMARY_ROUTES, routes: PEOPLE_ROUTES,
searchProviders: [], searchProviders: PEOPLE_SEARCH_PROVIDERS,
inboxSources: [], inboxSources: PEOPLE_INBOX_SOURCES,
widgets: [], widgets: PEOPLE_WIDGETS,
} satisfies HousekeepingDomainManifest; } satisfies HousekeepingDomainManifest;
@@ -154,7 +154,7 @@ export interface PeopleSupportStaff {
export interface PeopleTicketSummary { export interface PeopleTicketSummary {
readonly source: "cms" | "help"; readonly source: "cms" | "help";
readonly id: number; readonly id: number | string;
readonly subject: string; readonly subject: string;
readonly status: string; readonly status: string;
readonly priority: string; readonly priority: string;
@@ -163,7 +163,7 @@ export interface PeopleTicketSummary {
readonly updatedAt: string | null; readonly updatedAt: string | null;
readonly href: readonly href:
| `/ase/people/support/tickets/${number}` | `/ase/people/support/tickets/${number}`
| `/ase/people/support/help-tickets/${number}`; | `/ase/people/support/help-tickets/${string}`;
} }
export interface PeopleTicketDeskSummary extends PeopleTicketSummary { export interface PeopleTicketDeskSummary extends PeopleTicketSummary {
@@ -189,22 +189,22 @@ export interface PeopleTicketDetail extends PeopleTicketSummary {
} }
export interface PeopleHelpTicketSummary { export interface PeopleHelpTicketSummary {
readonly id: number; readonly id: string;
readonly title: string; readonly title: string;
readonly open: boolean; readonly open: boolean;
readonly userId: number | null; readonly userId: number | null;
readonly username: string | null; readonly username: string | null;
readonly updatedAt: string | null; readonly updatedAt: string | null;
readonly replyCount: number; readonly replyCount: number;
readonly href: `/ase/people/support/help-tickets/${number}`; readonly href: `/ase/people/support/help-tickets/${string}`;
} }
export interface PeopleHelpTicketDetail extends PeopleHelpTicketSummary { export interface PeopleHelpTicketDetail extends PeopleHelpTicketSummary {
readonly categoryId: number | null; readonly categoryId: string | null;
readonly categoryName: string | null; readonly categoryName: string | null;
readonly content: string; readonly content: string;
readonly replies: readonly { readonly replies: readonly {
readonly id: number; readonly id: string;
readonly userId: number; readonly userId: number;
readonly username: string | null; readonly username: string | null;
readonly content: string; readonly content: string;
@@ -416,10 +416,17 @@ export function peopleTicketHref(
} }
export function peopleHelpTicketHref( export function peopleHelpTicketHref(
id: number, id: string | number | bigint,
): `/ase/people/support/help-tickets/${number}` { ): `/ase/people/support/help-tickets/${string}` {
positiveSafeInteger(id); const text = String(id);
return `/ase/people/support/help-tickets/${id}`; if (!/^[1-9]\d{0,19}$/u.test(text)) {
throw new Error("invalid People identifier");
}
const parsed = BigInt(text);
if (parsed > 18_446_744_073_709_551_615n) {
throw new Error("invalid People identifier");
}
return `/ase/people/support/help-tickets/${parsed.toString()}`;
} }
export function peopleCfhHref( export function peopleCfhHref(
@@ -0,0 +1,72 @@
import { PERMS } from "@/lib/permission-slugs";
import {
createCorrelationId,
fail,
type HousekeepingCapabilityContext,
type HousekeepingResult,
} from "../../../foundation/contracts";
import type { HousekeepingPageInput } from "../../../route-handlers";
import {
type PeopleModerationQueryData,
peopleModerationQuery,
} from "../queries/moderation";
import { PeoplePageFrame } from "./page-state";
import { PeopleCommandForm } from "./people-command-form";
interface Props {
readonly context: HousekeepingCapabilityContext;
readonly result?: HousekeepingResult<PeopleModerationQueryData>;
}
export function PeopleCfhDetailPage({ context, result }: Props) {
const canEdit = context.hasAny(PERMS.MODERATION_EDIT, PERMS.MOD_CFH_EDIT);
return (
<PeoplePageFrame
title="Call for help"
description="Review the report, resolve it, and apply correlated sanctions."
result={result}
isEmpty={(data) => data.kind !== "cfh-detail"}
>
{(data) => data.kind === "cfh-detail" ? (
<article className="space-y-4">
<h2>CFH #{data.ticket.id}</h2>
<p>{data.ticket.issue}</p>
<p>Reporter: {data.ticket.senderUsername ?? `#${data.ticket.senderId}`}</p>
<p>Reported: {data.ticket.reportedUsername ?? `#${data.ticket.reportedId}`}</p>
{canEdit ? (
<>
<PeopleCommandForm
commandId="people.cfh.resolve"
buttonLabel="Resolve"
input={{ ticketId: data.ticket.id, state: 2 }}
/>
<PeopleCommandForm
commandId="people.cfh.sanction"
buttonLabel="Apply sanction"
input={{ ticketId: data.ticket.id, userId: data.ticket.reportedId }}
fields={[
{name: "action", label: "Action", type: "select", options: ["kick", "mute", "alert"].map((value) => ({ value, label: value }))},
{name: "duration", label: "Duration", type: "number", min: 0, max: 525600, defaultValue: 0},
{name: "message", label: "Message", type: "text", maxLength: 500},
]}
requiresReason
includeReasonInInput
/>
</>
) : null}
</article>
) : null}
</PeoplePageFrame>
);
}
export async function renderPeopleCfhDetailPage(input: HousekeepingPageInput) {
const id = Number(input.match.params.id);
const result = Number.isSafeInteger(id) && id > 0
? await peopleModerationQuery.run(input.context, {
routeId: "people.moderation.cfh-detail",
id,
})
: fail("VALIDATION", "errors.housekeeping.validation", createCorrelationId());
return <PeopleCfhDetailPage context={input.context} result={result} />;
}
@@ -0,0 +1,83 @@
import { PERMS } from "@/lib/permission-slugs";
import {
createCorrelationId,
fail,
type HousekeepingCapabilityContext,
type HousekeepingResult,
} from "../../../foundation/contracts";
import type { HousekeepingPageInput } from "../../../route-handlers";
import { peopleHelpTicketHref } from "../models";
import {
type PeopleSupportQueryData,
peopleSupportQuery,
} from "../queries/support";
import { PeoplePageFrame } from "./page-state";
import { PeopleCommandForm } from "./people-command-form";
interface Props {
readonly context: HousekeepingCapabilityContext;
readonly result?: HousekeepingResult<PeopleSupportQueryData>;
}
export function PeopleHelpTicketDetailPage({ context, result }: Props) {
const canEdit = context.hasAny(PERMS.TICKETS_EDIT, PERMS.MOD_TICKETS_EDIT);
return (
<PeoplePageFrame
title="Help ticket"
description="Review the requester appeal and its complete reply history."
result={result}
isEmpty={(data) => data.kind !== "help-ticket"}
>
{(data) => data.kind === "help-ticket" ? (
<article className="space-y-4">
<header><h2>{data.ticket.title}</h2><p>{data.ticket.content}</p></header>
<ul>
{data.ticket.replies.map((reply) => (
<li key={reply.id}><strong>{reply.username ?? `User #${reply.userId}`}</strong>: {reply.content}</li>
))}
</ul>
{canEdit ? (
<>
<PeopleCommandForm
commandId="people.help-ticket.reply"
buttonLabel="Reply"
input={{ ticketId: data.ticket.id }}
fields={[{ name: "content", label: "Reply", type: "text", required: true, maxLength: 5000 }]}
/>
<PeopleCommandForm
commandId="people.help-ticket.status"
buttonLabel={data.ticket.open ? "Close ticket" : "Reopen ticket"}
input={{ ticketId: data.ticket.id, status: data.ticket.open ? "close" : "reopen" }}
/>
</>
) : null}
{data.ticket.activeBan && context.has(PERMS.USERS_BAN) ? (
<PeopleCommandForm
commandId="people.help-ticket.unban"
buttonLabel="Lift requester bans and close"
input={{ ticketId: data.ticket.id }}
requiresReason
/>
) : null}
</article>
) : null}
</PeoplePageFrame>
);
}
export async function renderPeopleHelpTicketDetailPage(input: HousekeepingPageInput) {
const raw = input.match.params.id ?? "";
let id: string | null = null;
try {
id = peopleHelpTicketHref(raw).split("/").at(-1) ?? null;
} catch {
id = null;
}
const result = id
? await peopleSupportQuery.run(input.context, {
routeId: "people.support.help-ticket-detail",
id,
})
: fail("VALIDATION", "errors.housekeeping.validation", createCorrelationId());
return <PeopleHelpTicketDetailPage context={input.context} result={result} />;
}
File diff suppressed because it is too large. Load diff
@@ -11,6 +11,7 @@ vi.mock("@/actions/housekeeping-command", () => ({
import { import {
fail, fail,
type HousekeepingCapabilityContext, type HousekeepingCapabilityContext,
type HousekeepingDomainManifest,
type HousekeepingResult, type HousekeepingResult,
ok, ok,
} from "../../../foundation/contracts"; } from "../../../foundation/contracts";
@@ -24,6 +25,7 @@ import {
PEOPLE_PRIMARY_ROUTE_HANDLERS, PEOPLE_PRIMARY_ROUTE_HANDLERS,
PEOPLE_PRIMARY_ROUTE_IDS, PEOPLE_PRIMARY_ROUTE_IDS,
} from "../route-handlers"; } from "../route-handlers";
import { PEOPLE_ROUTE_IDS } from "../routes";
import { PeopleCommunityPage } from "./community"; import { PeopleCommunityPage } from "./community";
import { PeopleMultiAccountsPage } from "./multi-accounts"; import { PeopleMultiAccountsPage } from "./multi-accounts";
import { parsePeopleListInput } from "./page-state"; import { parsePeopleListInput } from "./page-state";
@@ -263,7 +265,7 @@ describe.each(cases)("People $name page", ({ render, empty, ready }) => {
}); });
describe("People primary route registration", () => { describe("People primary route registration", () => {
it("registers exactly the nine real primary routes and handlers", () => { it("keeps the nine primary handlers and registers the complete People catalog", () => {
const expected = [ const expected = [
"people.users.list", "people.users.list",
"people.users.edit", "people.users.edit",
@@ -276,7 +278,9 @@ describe("People primary route registration", () => {
"people.staff.teams", "people.staff.teams",
]; ];
expect(PEOPLE_PRIMARY_ROUTE_IDS).toEqual(expected); expect(PEOPLE_PRIMARY_ROUTE_IDS).toEqual(expected);
expect(peopleManifest.routes.map((route) => route.id)).toEqual(expected); expect(peopleManifest.routes.map((route) => route.id)).toEqual(
PEOPLE_ROUTE_IDS,
);
expect( expect(
PEOPLE_PRIMARY_ROUTE_HANDLERS.map((handler) => handler.routeId), PEOPLE_PRIMARY_ROUTE_HANDLERS.map((handler) => handler.routeId),
).toEqual(expected); ).toEqual(expected);
@@ -284,9 +288,9 @@ describe("People primary route registration", () => {
HOUSEKEEPING_ROUTE_HANDLERS.map((handler) => handler.routeId), HOUSEKEEPING_ROUTE_HANDLERS.map((handler) => handler.routeId),
).toEqual(expect.arrayContaining(expected)); ).toEqual(expect.arrayContaining(expected));
expect( expect(
HOUSEKEEPING_MANIFESTS.flatMap((manifest) => manifest.routes).map( (
(route) => route.id, HOUSEKEEPING_MANIFESTS as readonly HousekeepingDomainManifest[]
), ).flatMap((manifest) => manifest.routes.map((route) => route.id)),
).toEqual(HOUSEKEEPING_ROUTE_HANDLERS.map((handler) => handler.routeId)); ).toEqual(HOUSEKEEPING_ROUTE_HANDLERS.map((handler) => handler.routeId));
}); });
File diff suppressed because it is too large. Load diff
@@ -121,6 +121,21 @@ export function PeopleStaffPage({ context, result }: PeopleStaffPageProps) {
))} ))}
</ul> </ul>
</div> </div>
) : data.kind === "moderation-team" ? (
<ul className="space-y-2">
{data.page.items.map((member) => (
<li
key={member.id}
className="rounded-lg border border-[var(--admin-border)] bg-[var(--admin-surface)] p-4"
>
<a href={member.href}>{member.username}</a>
<p>
{member.openCfh} CFH · {member.openTickets} tickets ·{" "}
{member.actionCount} actions
</p>
</li>
))}
</ul>
) : null} ) : null}
</div> </div>
)} )}
@@ -131,7 +146,9 @@ export function PeopleStaffPage({ context, result }: PeopleStaffPageProps) {
export async function renderPeopleStaffPage(input: HousekeepingPageInput) { export async function renderPeopleStaffPage(input: HousekeepingPageInput) {
const routeId = input.match.routeId; const routeId = input.match.routeId;
const result = const result =
routeId === "people.staff.applications" || routeId === "people.staff.teams" routeId === "people.staff.applications" ||
routeId === "people.staff.teams" ||
routeId === "people.staff.moderation-team"
? await peopleStaffQuery.run(input.context, { ? await peopleStaffQuery.run(input.context, {
routeId, routeId,
list: parsePeopleListInput(input.searchParams ?? {}), list: parsePeopleListInput(input.searchParams ?? {}),
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,96 @@
import { PERMS } from "@/lib/permission-slugs";
import {
createCorrelationId,
fail,
type HousekeepingCapabilityContext,
type HousekeepingResult,
} from "../../../foundation/contracts";
import type { HousekeepingPageInput } from "../../../route-handlers";
import {
type PeopleSupportQueryData,
peopleSupportQuery,
} from "../queries/support";
import { PeoplePageFrame } from "./page-state";
import { PeopleCommandForm } from "./people-command-form";
interface Props {
readonly context: HousekeepingCapabilityContext;
readonly result?: HousekeepingResult<PeopleSupportQueryData>;
}
export function PeopleTicketDetailPage({ context, result }: Props) {
const canEdit = context.hasAny(PERMS.TICKETS_EDIT, PERMS.MOD_TICKETS_EDIT);
return (
<PeoplePageFrame
title="Ticket"
description="Review the conversation and coordinate the support response."
result={result}
isEmpty={(data) => data.kind !== "ticket"}
>
{(data) => data.kind === "ticket" ? (
<article className="space-y-4">
<header><h2>{data.ticket.subject}</h2><p>{data.ticket.status} · {data.ticket.priority}</p></header>
<ul className="space-y-2">
{data.ticket.messages.map((message) => (
<li key={message.id} className="rounded border border-[var(--admin-border)] p-3">
<strong>{message.username ?? `User #${message.userId}`}</strong>
<p>{message.message}</p>
</li>
))}
</ul>
{canEdit ? (
<div className="grid gap-3 lg:grid-cols-2">
<PeopleCommandForm
commandId="people.ticket.reply"
buttonLabel="Reply"
input={{ ticketId: data.ticket.id }}
fields={[{ name: "message", label: "Message", type: "text", required: true, maxLength: 5000 }]}
/>
<PeopleCommandForm
commandId="people.ticket.assign"
buttonLabel="Assign"
input={{ ticketId: data.ticket.id }}
fields={[{
name: "assigneeId",
label: "Assignee",
type: "select",
options: data.ticket.staff.map((staff) => ({ value: staff.id, label: staff.username })),
}]}
/>
<PeopleCommandForm
commandId="people.ticket.status"
buttonLabel="Change status"
input={{ ticketId: data.ticket.id }}
fields={[{
name: "status", label: "Status", type: "select",
options: ["open", "in_progress", "waiting", "closed"].map((value) => ({ value, label: value })),
}]}
/>
<PeopleCommandForm
commandId="people.ticket.priority"
buttonLabel="Change priority"
input={{ ticketId: data.ticket.id }}
fields={[{
name: "priority", label: "Priority", type: "select",
options: ["low", "normal", "high", "urgent"].map((value) => ({ value, label: value })),
}]}
/>
</div>
) : null}
</article>
) : null}
</PeoplePageFrame>
);
}
export async function renderPeopleTicketDetailPage(input: HousekeepingPageInput) {
const id = Number(input.match.params.id);
const result =
Number.isSafeInteger(id) && id > 0
? await peopleSupportQuery.run(input.context, {
routeId: "people.support.ticket-detail",
id,
})
: fail("VALIDATION", "errors.housekeeping.validation", createCorrelationId());
return <PeopleTicketDetailPage context={input.context} result={result} />;
}
File diff suppressed because it is too large. Load diff
@@ -379,7 +379,7 @@ describe("People production authorization boundary", () => {
ok: true, ok: true,
data: { data: {
page: { page: {
items: [{ id: 12, href: "/ase/people/support/help-tickets/12" }], items: [{ id: "12", href: "/ase/people/support/help-tickets/12" }],
}, },
}, },
}); });
@@ -427,7 +427,7 @@ describe("People production authorization boundary", () => {
}); });
expect(result).toMatchObject({ expect(result).toMatchObject({
ok: true, ok: true,
data: { page: { items: [{ id: 12, replyCount: 3 }] } }, data: { page: { items: [{ id: "12", replyCount: 3 }] } },
}); });
vi.resetModules(); vi.resetModules();
@@ -611,14 +611,14 @@ describe("People support query", () => {
loadTemplates: async () => ({ rows: [], total: 0 }), loadTemplates: async () => ({ rows: [], total: 0 }),
loadHelpTickets: async () => ({ rows: [], total: 0 }), loadHelpTickets: async () => ({ rows: [], total: 0 }),
loadHelpTicket: async () => ({ loadHelpTicket: async () => ({
id: 12, id: "12",
title: "Help ticket", title: "Help ticket",
open: true, open: true,
userId: 7, userId: 7,
username: "Seven", username: "Seven",
updatedAt: "2026-08-20T00:00:00.000Z", updatedAt: "2026-08-20T00:00:00.000Z",
href: "/ase/people/support/help-tickets/12", href: "/ase/people/support/help-tickets/12",
categoryId: 2, categoryId: "2",
categoryName: "Help", categoryName: "Help",
content: "Body", content: "Body",
replies: [], replies: [],
@@ -637,7 +637,7 @@ describe("People support query", () => {
}); });
const help = await query.run(context([PERMS.TICKETS_VIEW]), { const help = await query.run(context([PERMS.TICKETS_VIEW]), {
routeId: "people.support.help-ticket-detail", routeId: "people.support.help-ticket-detail",
id: 12, id: "12",
}); });
expect(desk).toMatchObject({ expect(desk).toMatchObject({
ok: true, ok: true,
@@ -816,7 +816,7 @@ describe("People support query", () => {
).toMatchObject({ ok: true, data: { kind: "ticket-templates" } }); ).toMatchObject({ ok: true, data: { kind: "ticket-templates" } });
for (const input of [ for (const input of [
{ routeId: "people.support.ticket-detail" as const, id: 91 }, { routeId: "people.support.ticket-detail" as const, id: 91 },
{ routeId: "people.support.help-ticket-detail" as const, id: 92 }, { routeId: "people.support.help-ticket-detail" as const, id: "92" },
]) { ]) {
expect( expect(
await query.run(context([PERMS.TICKETS_VIEW]), input), await query.run(context([PERMS.TICKETS_VIEW]), input),
File diff suppressed because it is too large. Load diff
@@ -1,10 +1,16 @@
import type { HousekeepingRouteHandler } from "../../route-handlers"; import type { HousekeepingRouteHandler } from "../../route-handlers";
import { renderPeopleCfhDetailPage } from "./pages/cfh-detail";
import { renderPeopleCommunityPage } from "./pages/community"; import { renderPeopleCommunityPage } from "./pages/community";
import { renderPeopleHelpTicketDetailPage } from "./pages/help-ticket-detail";
import { renderPeopleModerationPage } from "./pages/moderation";
import { renderPeopleMultiAccountsPage } from "./pages/multi-accounts"; import { renderPeopleMultiAccountsPage } from "./pages/multi-accounts";
import { renderPeopleStaffPage } from "./pages/staff"; import { renderPeopleStaffPage } from "./pages/staff";
import { renderPeopleSupportPage } from "./pages/support";
import { renderPeopleTicketDetailPage } from "./pages/ticket-detail";
import { renderPeopleUserDetailPage } from "./pages/user-detail"; import { renderPeopleUserDetailPage } from "./pages/user-detail";
import { renderPeopleUserEditPage } from "./pages/user-edit"; import { renderPeopleUserEditPage } from "./pages/user-edit";
import { renderPeopleUsersPage } from "./pages/users"; import { renderPeopleUsersPage } from "./pages/users";
import { PEOPLE_ROUTE_IDS } from "./routes";
export const PEOPLE_PRIMARY_ROUTE_IDS = [ export const PEOPLE_PRIMARY_ROUTE_IDS = [
"people.users.list", "people.users.list",
@@ -38,3 +44,34 @@ export const PEOPLE_PRIMARY_ROUTE_HANDLERS: readonly HousekeepingRouteHandler[]
Object.freeze({ routeId, render: rendererFor(routeId) }), Object.freeze({ routeId, render: rendererFor(routeId) }),
), ),
); );
function peopleRenderer(
routeId: (typeof PEOPLE_ROUTE_IDS)[number],
): HousekeepingRouteHandler["render"] {
if ((PEOPLE_PRIMARY_ROUTE_IDS as readonly string[]).includes(routeId)) {
return rendererFor(routeId as PeoplePrimaryRouteId);
}
if (routeId === "people.moderation.cfh-detail") {
return renderPeopleCfhDetailPage;
}
if (routeId.startsWith("people.moderation.")) {
return renderPeopleModerationPage;
}
if (routeId === "people.staff.moderation-team") {
return renderPeopleStaffPage;
}
if (routeId === "people.support.ticket-detail") {
return renderPeopleTicketDetailPage;
}
if (routeId === "people.support.help-ticket-detail") {
return renderPeopleHelpTicketDetailPage;
}
return renderPeopleSupportPage;
}
export const PEOPLE_ROUTE_HANDLERS: readonly HousekeepingRouteHandler[] =
Object.freeze(
PEOPLE_ROUTE_IDS.map((routeId) =>
Object.freeze({ routeId, render: peopleRenderer(routeId) }),
),
);
@@ -1,6 +1,9 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { PERMS } from "@/lib/permission-slugs"; import { PERMS } from "@/lib/permission-slugs";
import { HOUSEKEEPING_MANIFESTS } from "../../manifests";
import { peopleMigrationEntries } from "../../migration/people"; import { peopleMigrationEntries } from "../../migration/people";
import { HOUSEKEEPING_ROUTE_HANDLERS } from "../../route-handlers";
import { peopleManifest } from "./manifest";
import { PEOPLE_ROUTE_IDS, PEOPLE_ROUTES } from "./routes"; import { PEOPLE_ROUTE_IDS, PEOPLE_ROUTES } from "./routes";
const expectedRoutes = [ const expectedRoutes = [
@@ -166,4 +169,44 @@ describe("PEOPLE_ROUTES", () => {
expect(routeTargets).toHaveLength(24); expect(routeTargets).toHaveLength(24);
expect(new Set(routeTargets).size).toBe(routeTargets.length); expect(new Set(routeTargets).size).toBe(routeTargets.length);
}); });
it("registers a real manifest route and handler for every migrated People row", () => {
const registeredRouteIds = new Set(
peopleManifest.routes.map((route) => route.id),
);
const registeredHandlerIds = new Set(
HOUSEKEEPING_ROUTE_HANDLERS.map((handler) => handler.routeId),
);
const routeByHref = new Map(
PEOPLE_ROUTES.map((route) => [route.href, route.id]),
);
const unresolved = peopleMigrationEntries
.filter((entry) => entry.targetPath !== null)
.filter(
(entry) =>
!routeByHref.has(entry.targetPath as never) ||
!registeredRouteIds.has(
routeByHref.get(entry.targetPath as never) as never,
) ||
!registeredHandlerIds.has(
routeByHref.get(entry.targetPath as never) as never,
),
);
expect(unresolved).toEqual([]);
expect(
peopleMigrationEntries.filter((entry) =>
entry.legacyPath.startsWith("/mod"),
),
).toHaveLength(13);
expect([...registeredRouteIds]).toEqual([...PEOPLE_ROUTE_IDS]);
const manifestRouteIds = HOUSEKEEPING_MANIFESTS.flatMap((manifest) =>
manifest.routes.map((route) => route.id),
).sort();
const handlerIds = HOUSEKEEPING_ROUTE_HANDLERS.map(
(handler) => handler.routeId,
).sort();
expect(handlerIds).toEqual(manifestRouteIds);
});
}); });
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,90 @@
import "server-only";
import { PERMS } from "@/lib/permission-slugs";
import { authorizeHousekeeping } from "../../foundation/authorization";
import { satisfiesCapability } from "../../foundation/capability-context";
import {
anyCapability,
fail,
type HousekeepingCapabilityContext,
type HousekeepingWidgetDefinition,
ok,
} from "../../foundation/contracts";
import type { PeopleQueueSnapshot } from "./models";
import { peopleSupportQuery } from "./queries/support";
export interface PeopleWidgetAdapters {
queue(
context: HousekeepingCapabilityContext,
signal: AbortSignal,
): Promise<PeopleQueueSnapshot>;
}
const supportQueueCapability = anyCapability(
PERMS.TICKETS_VIEW,
PERMS.MOD_TICKETS_VIEW,
);
const cfhQueueCapability = anyCapability(
PERMS.MODERATION_VIEW,
PERMS.MOD_CFH_VIEW,
);
const banQueueCapability = anyCapability(
PERMS.BANS_VIEW,
PERMS.MOD_BANS_VIEW,
);
const queueCapability = anyCapability(
...supportQueueCapability.slugs,
...cfhQueueCapability.slugs,
...banQueueCapability.slugs,
);
export function createPeopleWidgets(
adapters: PeopleWidgetAdapters,
): readonly HousekeepingWidgetDefinition[] {
return [{
id: "people.queue",
owner: "people",
capability: queueCapability,
kind: "mandatory",
async load(context, signal) {
const authorization = authorizeHousekeeping(context, queueCapability);
if (!authorization.ok) return authorization;
try {
const queue = await adapters.queue(context, signal);
return ok(
{
tickets: satisfiesCapability(context, supportQueueCapability)
? queue.tickets
: 0,
helpTickets: satisfiesCapability(context, supportQueueCapability)
? queue.helpTickets
: 0,
cfh: satisfiesCapability(context, cfhQueueCapability)
? queue.cfh
: 0,
activeBans: satisfiesCapability(context, banQueueCapability)
? queue.activeBans
: 0,
},
authorization.correlationId,
);
} catch {
return fail(
"DEPENDENCY_UNAVAILABLE",
"errors.housekeeping.dependencyUnavailable",
authorization.correlationId,
);
}
},
}];
}
export const PEOPLE_WIDGETS = createPeopleWidgets({
async queue(context) {
const result = await peopleSupportQuery.run(context, {
routeId: "people.support.queue",
});
if (!result.ok || result.data.kind !== "queue") throw new Error("queue");
return result.data.queue;
},
});
@@ -5,6 +5,8 @@ vi.mock("@/lib/auth", () => ({ invalidateLoginCache: vi.fn() }));
vi.mock("@/lib/auth/password", () => ({ hashPassword: vi.fn() })); vi.mock("@/lib/auth/password", () => ({ hashPassword: vi.fn() }));
import { COMMUNITY_COMMAND_IDS } from "../../domains/people/commands/community-commands"; import { COMMUNITY_COMMAND_IDS } from "../../domains/people/commands/community-commands";
import { MODERATION_COMMAND_IDS } from "../../domains/people/commands/moderation-commands";
import { SUPPORT_COMMAND_IDS } from "../../domains/people/commands/support-commands";
import { USER_COMMAND_IDS } from "../../domains/people/commands/user-commands"; import { USER_COMMAND_IDS } from "../../domains/people/commands/user-commands";
import { SYSTEM_COMMAND_IDS } from "../../domains/system/commands/system-commands"; import { SYSTEM_COMMAND_IDS } from "../../domains/system/commands/system-commands";
import { anyCapability, ok } from "../contracts"; import { anyCapability, ok } from "../contracts";
@@ -72,6 +74,12 @@ describe("housekeeping command bootstrap", () => {
expect( expect(
COMMUNITY_COMMAND_IDS.map((id) => getHousekeepingCommand(id)?.id), COMMUNITY_COMMAND_IDS.map((id) => getHousekeepingCommand(id)?.id),
).toEqual(COMMUNITY_COMMAND_IDS); ).toEqual(COMMUNITY_COMMAND_IDS);
expect(
SUPPORT_COMMAND_IDS.map((id) => getHousekeepingCommand(id)?.id),
).toEqual(SUPPORT_COMMAND_IDS);
expect(
MODERATION_COMMAND_IDS.map((id) => getHousekeepingCommand(id)?.id),
).toEqual(MODERATION_COMMAND_IDS);
expect(() => expect(() =>
registerHousekeepingCommand({ registerHousekeepingCommand({
id: "system.bootstrap.too-late", id: "system.bootstrap.too-late",
@@ -1,6 +1,8 @@
import "server-only"; import "server-only";
import { COMMUNITY_COMMANDS } from "../../domains/people/commands/community-commands"; import { COMMUNITY_COMMANDS } from "../../domains/people/commands/community-commands";
import { MODERATION_COMMANDS } from "../../domains/people/commands/moderation-commands";
import { SUPPORT_COMMANDS } from "../../domains/people/commands/support-commands";
import { USER_COMMANDS } from "../../domains/people/commands/user-commands"; import { USER_COMMANDS } from "../../domains/people/commands/user-commands";
import { SYSTEM_COMMANDS } from "../../domains/system/commands/system-commands"; import { SYSTEM_COMMANDS } from "../../domains/system/commands/system-commands";
import type { HousekeepingCommand } from "./registry"; import type { HousekeepingCommand } from "./registry";
@@ -40,6 +42,8 @@ export function registerHousekeepingCommands<
const currentHousekeepingCommands = defineHousekeepingCommands( const currentHousekeepingCommands = defineHousekeepingCommands(
...USER_COMMANDS, ...USER_COMMANDS,
...COMMUNITY_COMMANDS, ...COMMUNITY_COMMANDS,
...SUPPORT_COMMANDS,
...MODERATION_COMMANDS,
...SYSTEM_COMMANDS, ...SYSTEM_COMMANDS,
); );
File diff suppressed because it is too large. Load diff
@@ -1,6 +1,9 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { PERMS } from "@/lib/permission-slugs"; import { PERMS } from "@/lib/permission-slugs";
import { PEOPLE_PRIMARY_ROUTES } from "../domains/people/routes"; import { PEOPLE_INBOX_SOURCES } from "../domains/people/inbox";
import { PEOPLE_ROUTES } from "../domains/people/routes";
import { PEOPLE_SEARCH_PROVIDERS } from "../domains/people/search";
import { PEOPLE_WIDGETS } from "../domains/people/widgets";
import { SYSTEM_ROUTES } from "../domains/system/routes"; import { SYSTEM_ROUTES } from "../domains/system/routes";
import { HOUSEKEEPING_MANIFESTS } from "../manifests"; import { HOUSEKEEPING_MANIFESTS } from "../manifests";
import { HOUSEKEEPING_MIGRATION_MATRIX } from "../migration/matrix"; import { HOUSEKEEPING_MIGRATION_MATRIX } from "../migration/matrix";
@@ -357,14 +360,20 @@ describe("housekeeping registry", () => {
}); });
expect(actual.routes).toEqual( expect(actual.routes).toEqual(
expected.id === "people" expected.id === "people"
? PEOPLE_PRIMARY_ROUTES ? PEOPLE_ROUTES
: expected.id === "system" : expected.id === "system"
? SYSTEM_ROUTES ? SYSTEM_ROUTES
: [], : [],
); );
expect(actual.searchProviders).toEqual([]); expect(actual.searchProviders).toEqual(
expect(actual.inboxSources).toEqual([]); expected.id === "people" ? PEOPLE_SEARCH_PROVIDERS : [],
expect(actual.widgets).toEqual([]); );
expect(actual.inboxSources).toEqual(
expected.id === "people" ? PEOPLE_INBOX_SOURCES : [],
);
expect(actual.widgets).toEqual(
expected.id === "people" ? PEOPLE_WIDGETS : [],
);
expect(actual.capability).toEqual({ mode: "any", slugs: expected.slugs }); expect(actual.capability).toEqual({ mode: "any", slugs: expected.slugs });
} }
}); });
@@ -389,7 +398,7 @@ describe("housekeeping registry", () => {
} }
}); });
it("rejects duplicate provider and widget ids across domain manifests", () => { it("rejects duplicates within each provider kind across domain manifests", () => {
expect(() => expect(() =>
createHousekeepingRegistry([ createHousekeepingRegistry([
{ ...manifest("people"), searchProviders: [searchProvider("shared")] }, { ...manifest("people"), searchProviders: [searchProvider("shared")] },
@@ -425,7 +434,7 @@ describe("housekeeping registry", () => {
widgets: [widget("shared", "content")], widgets: [widget("shared", "content")],
}, },
]), ]),
).toThrow("duplicate widget id: shared"); ).not.toThrow();
}); });
it("rejects provider and widget ownership outside their manifest", () => { it("rejects provider and widget ownership outside their manifest", () => {
@@ -24,7 +24,9 @@ export function createHousekeepingRegistry(
const routeIds = new Set<string>(); const routeIds = new Set<string>();
const routeHrefs = new Set<string>(); const routeHrefs = new Set<string>();
const routeShapes = new Set<string>(); const routeShapes = new Set<string>();
const registryEntryIds = new Set<string>(); const searchProviderIds = new Set<string>();
const inboxSourceIds = new Set<string>();
const widgetIds = new Set<string>();
for (const manifest of manifests) { for (const manifest of manifests) {
if (!approvedDomainIds.has(manifest.id)) { if (!approvedDomainIds.has(manifest.id)) {
@@ -48,19 +50,19 @@ export function createHousekeepingRegistry(
manifest.searchProviders, manifest.searchProviders,
manifest.id, manifest.id,
"search provider", "search provider",
registryEntryIds, searchProviderIds,
); );
validateOwnedRegistryEntries( validateOwnedRegistryEntries(
manifest.inboxSources, manifest.inboxSources,
manifest.id, manifest.id,
"inbox source", "inbox source",
registryEntryIds, inboxSourceIds,
); );
validateOwnedRegistryEntries( validateOwnedRegistryEntries(
manifest.widgets, manifest.widgets,
manifest.id, manifest.id,
"widget", "widget",
registryEntryIds, widgetIds,
); );
for (const widget of manifest.widgets) { for (const widget of manifest.widgets) {
if (widget.kind !== "mandatory" && widget.kind !== "optional") { if (widget.kind !== "mandatory" && widget.kind !== "optional") {
@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { PEOPLE_PRIMARY_ROUTE_IDS } from "./domains/people/route-handlers"; import { PEOPLE_ROUTE_IDS } from "./domains/people/routes";
import { SYSTEM_ROUTE_IDS } from "./domains/system/routes"; import { SYSTEM_ROUTE_IDS } from "./domains/system/routes";
import { createHousekeepingRegistry } from "./foundation/registry"; import { createHousekeepingRegistry } from "./foundation/registry";
import { HOUSEKEEPING_MANIFESTS } from "./manifests"; import { HOUSEKEEPING_MANIFESTS } from "./manifests";
@@ -18,7 +18,7 @@ describe("housekeeping route handlers", () => {
expect(new Set(handlerIds).size).toBe(handlerIds.length); expect(new Set(handlerIds).size).toBe(handlerIds.length);
expect([...handlerIds].sort()).toEqual([...routeIds].sort()); expect([...handlerIds].sort()).toEqual([...routeIds].sort());
expect(handlerIds).toEqual([ expect(handlerIds).toEqual([
...PEOPLE_PRIMARY_ROUTE_IDS, ...PEOPLE_ROUTE_IDS,
...SYSTEM_ROUTE_IDS, ...SYSTEM_ROUTE_IDS,
]); ]);
}); });
+2 -2
View File
@@ -1,5 +1,5 @@
import type { ReactNode } from "react"; import type { ReactNode } from "react";
import { PEOPLE_PRIMARY_ROUTE_HANDLERS } from "./domains/people/route-handlers"; import { PEOPLE_ROUTE_HANDLERS } from "./domains/people/route-handlers";
import { SYSTEM_ROUTE_HANDLERS } from "./domains/system/route-handlers"; import { SYSTEM_ROUTE_HANDLERS } from "./domains/system/route-handlers";
import type { HousekeepingCapabilityContext } from "./foundation/contracts"; import type { HousekeepingCapabilityContext } from "./foundation/contracts";
import type { HousekeepingRouteMatch } from "./foundation/routing/match-route"; import type { HousekeepingRouteMatch } from "./foundation/routing/match-route";
@@ -18,4 +18,4 @@ export interface HousekeepingRouteHandler {
} }
export const HOUSEKEEPING_ROUTE_HANDLERS: readonly HousekeepingRouteHandler[] = export const HOUSEKEEPING_ROUTE_HANDLERS: readonly HousekeepingRouteHandler[] =
Object.freeze([...PEOPLE_PRIMARY_ROUTE_HANDLERS, ...SYSTEM_ROUTE_HANDLERS]); Object.freeze([...PEOPLE_ROUTE_HANDLERS, ...SYSTEM_ROUTE_HANDLERS]);
+47
View File
@@ -21,6 +21,53 @@ export interface ModerationResult {
reason?: string; reason?: string;
} }
export type ModerationAction =
| { readonly action: "kick"; readonly userId: number }
| {
readonly action: "mute";
readonly userId: number;
readonly duration: number;
}
| { readonly action: "unmute"; readonly userId: number }
| {
readonly action: "alert";
readonly userId: number;
readonly message: string;
}
| { readonly action: "room-kick"; readonly roomId: number }
| {
readonly action: "broadcast";
readonly message: string;
readonly type: "hotel" | "staff";
};
export interface ModerationActionTransport {
disconnectUser(userId: number): Promise<boolean>;
muteUser(userId: number, duration: number): Promise<boolean>;
unmuteUser(userId: number): Promise<boolean>;
alertUser(userId: number, message: string): Promise<boolean>;
kickAll(roomId: number): Promise<boolean>;
hotelAlert(message: string): Promise<boolean>;
staffAlert(message: string): Promise<boolean>;
}
// Execute one already-authorized moderation effect and expose delivery truth.
export async function executeModerationAction(
transport: ModerationActionTransport,
input: ModerationAction,
): Promise<boolean> {
if (input.action === "kick") return transport.disconnectUser(input.userId);
if (input.action === "mute")
return transport.muteUser(input.userId, input.duration);
if (input.action === "unmute") return transport.unmuteUser(input.userId);
if (input.action === "alert")
return transport.alertUser(input.userId, input.message);
if (input.action === "room-kick") return transport.kickAll(input.roomId);
return input.type === "hotel"
? transport.hotelAlert(input.message)
: transport.staffAlert(input.message);
}
const OPENAI_MODERATIONS_URL = "https://api.openai.com/v1/moderations"; const OPENAI_MODERATIONS_URL = "https://api.openai.com/v1/moderations";
// Bound the AI call so a slow/hung endpoint can't stall a server action. // Bound the AI call so a slow/hung endpoint can't stall a server action.
const OPENAI_TIMEOUT_MS = 5_000; const OPENAI_TIMEOUT_MS = 5_000;
+22
View File
@@ -19,6 +19,28 @@ export interface TicketReplyDb {
touchTicket(ticketId: bigint, updatedAt: Date): Promise<unknown>; touchTicket(ticketId: bigint, updatedAt: Date): Promise<unknown>;
} }
const MAX_UNSIGNED_BIGINT = 18_446_744_073_709_551_615n;
// Canonicalize a SQL BIGINT identifier without passing through Number.
export function canonicalTicketId(value: string | number | bigint): bigint {
let parsed: bigint;
try {
if (
typeof value === "number" &&
(!Number.isSafeInteger(value) || value <= 0)
) {
throw new Error("unsafe identifier");
}
parsed = BigInt(String(value));
} catch {
throw new Error("invalid ticket identifier");
}
if (parsed <= 0n || parsed > MAX_UNSIGNED_BIGINT) {
throw new Error("invalid ticket identifier");
}
return parsed;
}
export async function createOwnedTicketReply( export async function createOwnedTicketReply(
db: TicketReplyDb, db: TicketReplyDb,
input: { ticketId: bigint; userId: number; content: string }, input: { ticketId: bigint; userId: number; content: string },