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
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,
type HousekeepingDomainManifest,
} 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 = {
id: "people",
@@ -35,8 +38,8 @@ export const peopleManifest = {
PERMS.MOD_CFH_EDIT,
PERMS.MOD_TICKETS_EDIT,
),
routes: PEOPLE_PRIMARY_ROUTES,
searchProviders: [],
inboxSources: [],
widgets: [],
routes: PEOPLE_ROUTES,
searchProviders: PEOPLE_SEARCH_PROVIDERS,
inboxSources: PEOPLE_INBOX_SOURCES,
widgets: PEOPLE_WIDGETS,
} satisfies HousekeepingDomainManifest;
@@ -154,7 +154,7 @@ export interface PeopleSupportStaff {
export interface PeopleTicketSummary {
readonly source: "cms" | "help";
readonly id: number;
readonly id: number | string;
readonly subject: string;
readonly status: string;
readonly priority: string;
@@ -163,7 +163,7 @@ export interface PeopleTicketSummary {
readonly updatedAt: string | null;
readonly href:
| `/ase/people/support/tickets/${number}`
| `/ase/people/support/help-tickets/${number}`;
| `/ase/people/support/help-tickets/${string}`;
}
export interface PeopleTicketDeskSummary extends PeopleTicketSummary {
@@ -189,22 +189,22 @@ export interface PeopleTicketDetail extends PeopleTicketSummary {
}
export interface PeopleHelpTicketSummary {
readonly id: number;
readonly id: string;
readonly title: string;
readonly open: boolean;
readonly userId: number | null;
readonly username: string | null;
readonly updatedAt: string | null;
readonly replyCount: number;
readonly href: `/ase/people/support/help-tickets/${number}`;
readonly href: `/ase/people/support/help-tickets/${string}`;
}
export interface PeopleHelpTicketDetail extends PeopleHelpTicketSummary {
readonly categoryId: number | null;
readonly categoryId: string | null;
readonly categoryName: string | null;
readonly content: string;
readonly replies: readonly {
readonly id: number;
readonly id: string;
readonly userId: number;
readonly username: string | null;
readonly content: string;
@@ -416,10 +416,17 @@ export function peopleTicketHref(
}
export function peopleHelpTicketHref(
id: number,
): `/ase/people/support/help-tickets/${number}` {
positiveSafeInteger(id);
return `/ase/people/support/help-tickets/${id}`;
id: string | number | bigint,
): `/ase/people/support/help-tickets/${string}` {
const text = String(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(
@@ -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 {
fail,
type HousekeepingCapabilityContext,
type HousekeepingDomainManifest,
type HousekeepingResult,
ok,
} from "../../../foundation/contracts";
@@ -24,6 +25,7 @@ import {
PEOPLE_PRIMARY_ROUTE_HANDLERS,
PEOPLE_PRIMARY_ROUTE_IDS,
} from "../route-handlers";
import { PEOPLE_ROUTE_IDS } from "../routes";
import { PeopleCommunityPage } from "./community";
import { PeopleMultiAccountsPage } from "./multi-accounts";
import { parsePeopleListInput } from "./page-state";
@@ -263,7 +265,7 @@ describe.each(cases)("People $name page", ({ render, empty, ready }) => {
});
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 = [
"people.users.list",
"people.users.edit",
@@ -276,7 +278,9 @@ describe("People primary route registration", () => {
"people.staff.teams",
];
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(
PEOPLE_PRIMARY_ROUTE_HANDLERS.map((handler) => handler.routeId),
).toEqual(expected);
@@ -284,9 +288,9 @@ describe("People primary route registration", () => {
HOUSEKEEPING_ROUTE_HANDLERS.map((handler) => handler.routeId),
).toEqual(expect.arrayContaining(expected));
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));
});
File diff suppressed because it is too large. Load diff
@@ -121,6 +121,21 @@ export function PeopleStaffPage({ context, result }: PeopleStaffPageProps) {
))}
</ul>
</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}
</div>
)}
@@ -131,7 +146,9 @@ export function PeopleStaffPage({ context, result }: PeopleStaffPageProps) {
export async function renderPeopleStaffPage(input: HousekeepingPageInput) {
const routeId = input.match.routeId;
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, {
routeId,
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,
data: {
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({
ok: true,
data: { page: { items: [{ id: 12, replyCount: 3 }] } },
data: { page: { items: [{ id: "12", replyCount: 3 }] } },
});
vi.resetModules();
@@ -611,14 +611,14 @@ describe("People support query", () => {
loadTemplates: async () => ({ rows: [], total: 0 }),
loadHelpTickets: async () => ({ rows: [], total: 0 }),
loadHelpTicket: async () => ({
id: 12,
id: "12",
title: "Help ticket",
open: true,
userId: 7,
username: "Seven",
updatedAt: "2026-08-20T00:00:00.000Z",
href: "/ase/people/support/help-tickets/12",
categoryId: 2,
categoryId: "2",
categoryName: "Help",
content: "Body",
replies: [],
@@ -637,7 +637,7 @@ describe("People support query", () => {
});
const help = await query.run(context([PERMS.TICKETS_VIEW]), {
routeId: "people.support.help-ticket-detail",
id: 12,
id: "12",
});
expect(desk).toMatchObject({
ok: true,
@@ -816,7 +816,7 @@ describe("People support query", () => {
).toMatchObject({ ok: true, data: { kind: "ticket-templates" } });
for (const input of [
{ 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(
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 { renderPeopleCfhDetailPage } from "./pages/cfh-detail";
import { renderPeopleCommunityPage } from "./pages/community";
import { renderPeopleHelpTicketDetailPage } from "./pages/help-ticket-detail";
import { renderPeopleModerationPage } from "./pages/moderation";
import { renderPeopleMultiAccountsPage } from "./pages/multi-accounts";
import { renderPeopleStaffPage } from "./pages/staff";
import { renderPeopleSupportPage } from "./pages/support";
import { renderPeopleTicketDetailPage } from "./pages/ticket-detail";
import { renderPeopleUserDetailPage } from "./pages/user-detail";
import { renderPeopleUserEditPage } from "./pages/user-edit";
import { renderPeopleUsersPage } from "./pages/users";
import { PEOPLE_ROUTE_IDS } from "./routes";
export const PEOPLE_PRIMARY_ROUTE_IDS = [
"people.users.list",
@@ -38,3 +44,34 @@ export const PEOPLE_PRIMARY_ROUTE_HANDLERS: readonly HousekeepingRouteHandler[]
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 { PERMS } from "@/lib/permission-slugs";
import { HOUSEKEEPING_MANIFESTS } from "../../manifests";
import { peopleMigrationEntries } from "../../migration/people";
import { HOUSEKEEPING_ROUTE_HANDLERS } from "../../route-handlers";
import { peopleManifest } from "./manifest";
import { PEOPLE_ROUTE_IDS, PEOPLE_ROUTES } from "./routes";
const expectedRoutes = [
@@ -166,4 +169,44 @@ describe("PEOPLE_ROUTES", () => {
expect(routeTargets).toHaveLength(24);
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;
},
});