feat(housekeeping): complete people moderation parity
This commit is contained in:
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
@@ -1,12 +1,13 @@
|
||||
"use server";
|
||||
|
||||
import { eq } from "drizzle-orm";
|
||||
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 { Ban, db, User } from "@/lib/db";
|
||||
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([
|
||||
"account",
|
||||
@@ -25,37 +26,17 @@ export async function createBan(formData: FormData): Promise<void> {
|
||||
const hours = Number(formData.get("hours"));
|
||||
const type = String(formData.get("type"));
|
||||
if (!(userId > 0) || !BAN_TYPES.has(type)) return;
|
||||
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
// Emulator convention: banExpire 0 = permanent (not a far-future timestamp).
|
||||
const banExpire = hours > 0 ? now + Math.floor(hours) * 3600 : 0;
|
||||
|
||||
const [user] = await db
|
||||
.select({ username: User.username })
|
||||
.from(User)
|
||||
.where(eq(User.id, userId))
|
||||
.limit(1);
|
||||
|
||||
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,
|
||||
});
|
||||
const result = await peopleMutationService.execute(
|
||||
createPeopleMutationInvocation(staff, createCorrelationId()),
|
||||
"ban.create",
|
||||
{
|
||||
userId,
|
||||
reason,
|
||||
hours: Number.isFinite(hours) && hours > 0 ? Math.floor(hours) : 0,
|
||||
type,
|
||||
},
|
||||
);
|
||||
if (!result.ok) throw new Error("Could not create ban");
|
||||
revalidatePath("/admin/bans");
|
||||
}
|
||||
|
||||
@@ -63,12 +44,12 @@ export async function liftBan(formData: FormData): Promise<void> {
|
||||
const staff = await requirePermissionRateLimited(PERMS.USERS_BAN);
|
||||
const id = Number(formData.get("id"));
|
||||
if (id > 0) {
|
||||
await db.delete(Ban).where(eq(Ban.id, id));
|
||||
await logStaffActivity({
|
||||
staffId: staff.id,
|
||||
action: "ban_lift",
|
||||
description: `Lifted ban #${id}`,
|
||||
});
|
||||
const result = await peopleMutationService.execute(
|
||||
createPeopleMutationInvocation(staff, createCorrelationId()),
|
||||
"ban.lift",
|
||||
{ id },
|
||||
);
|
||||
if (!result.ok) throw new Error("Could not lift ban");
|
||||
}
|
||||
revalidatePath("/admin/bans");
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -4,7 +4,6 @@ import { eq } from "drizzle-orm";
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { redirect } from "next/navigation";
|
||||
import { z } from "zod";
|
||||
import { positiveBigInt } from "@/lib/api";
|
||||
import { auth } from "@/lib/auth";
|
||||
import {
|
||||
db,
|
||||
@@ -14,7 +13,10 @@ import {
|
||||
} from "@/lib/db";
|
||||
import { clientIp, rateLimit } from "@/lib/rate-limit";
|
||||
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({
|
||||
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> {
|
||||
let outcome: TicketOutcome = "error";
|
||||
|
||||
@@ -177,7 +187,7 @@ const replyContentSchema = z.object({
|
||||
});
|
||||
|
||||
export async function replyHelpTicket(formData: FormData): Promise<void> {
|
||||
const ticketId = positiveBigInt(String(formData.get("ticketId") ?? ""));
|
||||
const ticketId = helpTicketId(formData);
|
||||
let outcome: TicketDetailOutcome = "error";
|
||||
|
||||
try {
|
||||
@@ -284,7 +294,7 @@ export async function replyHelpTicket(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";
|
||||
|
||||
try {
|
||||
|
||||
+57
-111
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -1,8 +1,11 @@
|
||||
"use server";
|
||||
|
||||
import { eq } from "drizzle-orm";
|
||||
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 { adminAction } from "@/lib/safe-action";
|
||||
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),
|
||||
});
|
||||
|
||||
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(
|
||||
{ permission: PERMS.TICKETS_EDIT, schema: templateSchema },
|
||||
async (ctx) => {
|
||||
const [result] = await db.insert(WebsiteTicketTemplate).values(ctx.data);
|
||||
return actionOk({ id: Number(result.insertId) });
|
||||
const snapshot = await execute(ctx.session.user, {
|
||||
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 },
|
||||
async (ctx) => {
|
||||
const { id, ...data } = ctx.data;
|
||||
const [existing] = await db
|
||||
.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));
|
||||
await execute(ctx.session.user, { action: "update", id, ...data });
|
||||
return actionOk({ id });
|
||||
},
|
||||
);
|
||||
@@ -51,9 +67,7 @@ const deleteTemplateInput = z.object({
|
||||
export const deleteTemplate = adminAction(
|
||||
{ permission: PERMS.TICKETS_EDIT, schema: deleteTemplateInput },
|
||||
async (ctx) => {
|
||||
await db
|
||||
.delete(WebsiteTicketTemplate)
|
||||
.where(eq(WebsiteTicketTemplate.id, ctx.data.id));
|
||||
await execute(ctx.session.user, { action: "delete", id: ctx.data.id });
|
||||
return actionOk();
|
||||
},
|
||||
);
|
||||
+30
-133
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,
|
||||
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;
|
||||
},
|
||||
});
|
||||
@@ -5,6 +5,8 @@ vi.mock("@/lib/auth", () => ({ invalidateLoginCache: vi.fn() }));
|
||||
vi.mock("@/lib/auth/password", () => ({ hashPassword: vi.fn() }));
|
||||
|
||||
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 { SYSTEM_COMMAND_IDS } from "../../domains/system/commands/system-commands";
|
||||
import { anyCapability, ok } from "../contracts";
|
||||
@@ -72,6 +74,12 @@ describe("housekeeping command bootstrap", () => {
|
||||
expect(
|
||||
COMMUNITY_COMMAND_IDS.map((id) => getHousekeepingCommand(id)?.id),
|
||||
).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(() =>
|
||||
registerHousekeepingCommand({
|
||||
id: "system.bootstrap.too-late",
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import "server-only";
|
||||
|
||||
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 { SYSTEM_COMMANDS } from "../../domains/system/commands/system-commands";
|
||||
import type { HousekeepingCommand } from "./registry";
|
||||
@@ -40,6 +42,8 @@ export function registerHousekeepingCommands<
|
||||
const currentHousekeepingCommands = defineHousekeepingCommands(
|
||||
...USER_COMMANDS,
|
||||
...COMMUNITY_COMMANDS,
|
||||
...SUPPORT_COMMANDS,
|
||||
...MODERATION_COMMANDS,
|
||||
...SYSTEM_COMMANDS,
|
||||
);
|
||||
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -1,6 +1,9 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
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 { HOUSEKEEPING_MANIFESTS } from "../manifests";
|
||||
import { HOUSEKEEPING_MIGRATION_MATRIX } from "../migration/matrix";
|
||||
@@ -357,14 +360,20 @@ describe("housekeeping registry", () => {
|
||||
});
|
||||
expect(actual.routes).toEqual(
|
||||
expected.id === "people"
|
||||
? PEOPLE_PRIMARY_ROUTES
|
||||
? PEOPLE_ROUTES
|
||||
: expected.id === "system"
|
||||
? SYSTEM_ROUTES
|
||||
: [],
|
||||
);
|
||||
expect(actual.searchProviders).toEqual([]);
|
||||
expect(actual.inboxSources).toEqual([]);
|
||||
expect(actual.widgets).toEqual([]);
|
||||
expect(actual.searchProviders).toEqual(
|
||||
expected.id === "people" ? PEOPLE_SEARCH_PROVIDERS : [],
|
||||
);
|
||||
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 });
|
||||
}
|
||||
});
|
||||
@@ -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(() =>
|
||||
createHousekeepingRegistry([
|
||||
{ ...manifest("people"), searchProviders: [searchProvider("shared")] },
|
||||
@@ -425,7 +434,7 @@ describe("housekeeping registry", () => {
|
||||
widgets: [widget("shared", "content")],
|
||||
},
|
||||
]),
|
||||
).toThrow("duplicate widget id: shared");
|
||||
).not.toThrow();
|
||||
});
|
||||
|
||||
it("rejects provider and widget ownership outside their manifest", () => {
|
||||
|
||||
@@ -24,7 +24,9 @@ export function createHousekeepingRegistry(
|
||||
const routeIds = new Set<string>();
|
||||
const routeHrefs = 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) {
|
||||
if (!approvedDomainIds.has(manifest.id)) {
|
||||
@@ -48,19 +50,19 @@ export function createHousekeepingRegistry(
|
||||
manifest.searchProviders,
|
||||
manifest.id,
|
||||
"search provider",
|
||||
registryEntryIds,
|
||||
searchProviderIds,
|
||||
);
|
||||
validateOwnedRegistryEntries(
|
||||
manifest.inboxSources,
|
||||
manifest.id,
|
||||
"inbox source",
|
||||
registryEntryIds,
|
||||
inboxSourceIds,
|
||||
);
|
||||
validateOwnedRegistryEntries(
|
||||
manifest.widgets,
|
||||
manifest.id,
|
||||
"widget",
|
||||
registryEntryIds,
|
||||
widgetIds,
|
||||
);
|
||||
for (const widget of manifest.widgets) {
|
||||
if (widget.kind !== "mandatory" && widget.kind !== "optional") {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
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 { createHousekeepingRegistry } from "./foundation/registry";
|
||||
import { HOUSEKEEPING_MANIFESTS } from "./manifests";
|
||||
@@ -18,7 +18,7 @@ describe("housekeeping route handlers", () => {
|
||||
expect(new Set(handlerIds).size).toBe(handlerIds.length);
|
||||
expect([...handlerIds].sort()).toEqual([...routeIds].sort());
|
||||
expect(handlerIds).toEqual([
|
||||
...PEOPLE_PRIMARY_ROUTE_IDS,
|
||||
...PEOPLE_ROUTE_IDS,
|
||||
...SYSTEM_ROUTE_IDS,
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
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 type { HousekeepingCapabilityContext } from "./foundation/contracts";
|
||||
import type { HousekeepingRouteMatch } from "./foundation/routing/match-route";
|
||||
@@ -18,4 +18,4 @@ export interface 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]);
|
||||
@@ -21,6 +21,53 @@ export interface ModerationResult {
|
||||
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";
|
||||
// Bound the AI call so a slow/hung endpoint can't stall a server action.
|
||||
const OPENAI_TIMEOUT_MS = 5_000;
|
||||
|
||||
@@ -19,6 +19,28 @@ export interface TicketReplyDb {
|
||||
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(
|
||||
db: TicketReplyDb,
|
||||
input: { ticketId: bigint; userId: number; content: string },
|
||||
|
||||
Reference in new issue
Block a user