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

+34 -49
View File
@@ -1,84 +1,69 @@
// @ts-nocheck
import { revalidatePath } from "next/cache";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { requirePermissionRateLimited } from "@/lib/admin/guard";
import { rcon } from "@/lib/services/rcon";
import { createBan, liftBan } from "./admin-bans";
const { selectLimit, insertValues, deleteWhere } = vi.hoisted(() => {
const selectLimit = vi.fn();
const insertValues = vi.fn().mockResolvedValue([{ insertId: 1 }]);
const deleteWhere = vi.fn().mockResolvedValue([{ affectedRows: 1 }]);
return { selectLimit, insertValues, deleteWhere };
});
const { execute } = vi.hoisted(() => ({ execute: vi.fn() }));
vi.mock("@/features/housekeeping/domains/people/services/mutations", () => ({
createPeopleMutationInvocation: vi.fn((staff, correlationId) => ({
expectedActorId: staff.id,
correlationId,
legacy: true,
})),
peopleMutationService: { execute },
}));
vi.mock("@/lib/admin/guard", () => ({ requirePermissionRateLimited: vi.fn() }));
vi.mock("@/lib/permissions", () => ({ PERMS: { USERS_BAN: "users.ban" } }));
vi.mock("@/lib/db", () => ({
db: {
select: vi.fn(() => ({
from: vi.fn(() => ({
where: vi.fn(() => ({
limit: selectLimit,
})),
})),
})),
insert: vi.fn(() => ({ values: insertValues })),
delete: vi.fn(() => ({ where: deleteWhere })),
},
Ban: { id: "id", userId: "userId" },
User: { id: "id", username: "username" },
}));
vi.mock("@/lib/services/rcon", () => ({ rcon: { disconnectUser: vi.fn() } }));
vi.mock("@/lib/services/staff-activity", () => ({ logStaffActivity: vi.fn() }));
vi.mock("next/cache", () => ({ revalidatePath: vi.fn() }));
import { createBan, liftBan } from "./admin-bans";
const staff = { id: 1, rank: 7, username: "admin" };
const fakeForm = (data: Record<string, string>) => ({
get: (key: string) => data[key] ?? null,
});
const fakeForm = (data: Record<string, string>) =>
({ get: (key: string) => data[key] ?? null }) as unknown as FormData;
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(requirePermissionRateLimited).mockResolvedValue(staff as never);
selectLimit.mockResolvedValue([{ username: "baduser" }]);
insertValues.mockResolvedValue([{ insertId: 1 }]);
deleteWhere.mockResolvedValue([{ affectedRows: 1 }]);
execute.mockImplementation(async (invocation) => ({
ok: true,
data: { before: null, after: {} },
correlationId: invocation.correlationId,
}));
});
describe("createBan", () => {
it("creates a ban for valid inputs", async () => {
describe("legacy admin ban wrappers", () => {
it("preserves parsed create input, service delegation, and revalidation", async () => {
await createBan(
fakeForm({
userId: "42",
reason: "Spam",
hours: "24",
type: "account",
}) as unknown as FormData,
}),
);
expect(insertValues).toHaveBeenCalledWith(
expect.objectContaining({ userId: 42, type: "account" }),
expect(execute).toHaveBeenCalledWith(
expect.objectContaining({ expectedActorId: 1, legacy: true }),
"ban.create",
{ userId: 42, reason: "Spam", hours: 24, type: "account" },
);
expect(rcon.disconnectUser).toHaveBeenCalledWith(42, "baduser");
expect(revalidatePath).toHaveBeenCalledWith("/admin/bans");
});
it("returns early when userId is invalid", async () => {
await createBan(
fakeForm({
userId: "0",
hours: "1",
type: "account",
}) as unknown as FormData,
fakeForm({ userId: "0", hours: "1", type: "account" }),
);
expect(insertValues).not.toHaveBeenCalled();
expect(execute).not.toHaveBeenCalled();
});
});
describe("liftBan", () => {
it("deletes ban and revalidates", async () => {
await liftBan(fakeForm({ id: "42" }) as unknown as FormData);
expect(deleteWhere).toHaveBeenCalled();
it("delegates lift by exact ban id and preserves revalidation", async () => {
await liftBan(fakeForm({ id: "42" }));
expect(execute).toHaveBeenCalledWith(
expect.objectContaining({ expectedActorId: 1, legacy: true }),
"ban.lift",
{ id: 42 },
);
expect(revalidatePath).toHaveBeenCalledWith("/admin/bans");
});
});
+22 -41
View File
@@ -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");
}
+56 -130
View File
@@ -1,18 +1,15 @@
"use server";
import { eq } from "drizzle-orm";
import { revalidatePath } from "next/cache";
import { z } from "zod";
import {
Ban,
db,
WebsiteHelpCenterTicketReplies,
WebsiteHelpCenterTickets,
} from "@/lib/db";
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";
import { logAudit } from "@/lib/services/audit";
const ticketIdField = z
.union([z.string(), z.number(), z.bigint()])
@@ -37,6 +34,21 @@ function revalidateHelpCenterTicketPaths(ticketId: bigint) {
revalidatePath(`/help/tickets/${id}`);
}
async function execute(
staff: { readonly id: number },
operation:
| "help-ticket.reply"
| "help-ticket.status"
| "help-ticket.unban",
input: unknown,
) {
return peopleMutationService.execute(
createPeopleMutationInvocation(staff, createCorrelationId()),
operation,
input,
);
}
export const liftBanFromHelpTicket = adminAction(
{
permission: PERMS.USERS_BAN,
@@ -44,50 +56,23 @@ export const liftBanFromHelpTicket = adminAction(
},
async (ctx) => {
const ticketId = ctx.data.ticketId;
const [ticket] = await db
.select({
id: WebsiteHelpCenterTickets.id,
userId: WebsiteHelpCenterTickets.userId,
open: WebsiteHelpCenterTickets.open,
title: WebsiteHelpCenterTickets.title,
})
.from(WebsiteHelpCenterTickets)
.where(eq(WebsiteHelpCenterTickets.id, ticketId))
.limit(1);
if (!ticket) throw new ActionError("Ticket not found");
if (ticket.userId == null) {
throw new ActionError("Ticket has no requester to unban");
}
const result = await db.delete(Ban).where(eq(Ban.userId, ticket.userId));
const removed = Number(
(result as unknown as [{ affectedRows: number }])[0]?.affectedRows ?? 0,
);
const now = new Date();
if (ticket.open) {
await db
.update(WebsiteHelpCenterTickets)
.set({ open: false, updatedAt: now })
.where(eq(WebsiteHelpCenterTickets.id, ticketId));
}
logAudit({
userId: ctx.session.user.id,
action: "unban_via_help_ticket",
target: "User",
targetId: ticket.userId,
after: {
ticketId: String(ticketId),
removedBans: removed,
title: ticket.title,
},
const result = await execute(ctx.session.user, "help-ticket.unban", {
ticketId: ticketId.toString(),
});
if (!result.ok) {
throw new ActionError(
result.error.code === "CONFLICT"
? "Ticket has no requester to unban"
: "Ticket not found",
);
}
revalidateHelpCenterTicketPaths(ticketId);
revalidatePath("/admin/bans");
revalidatePath(`/admin/users/show/${ticket.userId}`);
return actionOk({ removed, userId: ticket.userId });
const removed = Number(result.data.output?.removed ?? 0);
const userId = Number(result.data.output?.userId);
revalidatePath(`/admin/users/show/${userId}`);
return actionOk({ removed, userId });
},
);
@@ -97,40 +82,11 @@ export const replyHelpCenterTicket = adminAction(
{ permission: HELP_TICKET_EDIT, schema: replyHelpCenterTicketSchema },
async (ctx) => {
const ticketId = ctx.data.ticketId;
const [ticket] = await db
.select({
id: WebsiteHelpCenterTickets.id,
open: WebsiteHelpCenterTickets.open,
})
.from(WebsiteHelpCenterTickets)
.where(eq(WebsiteHelpCenterTickets.id, ticketId))
.limit(1);
if (!ticket) throw new ActionError("Ticket not found");
const now = new Date();
const staffId = Number(ctx.session.user.id);
await db.transaction(async (tx) => {
await tx.insert(WebsiteHelpCenterTicketReplies).values({
ticketId,
userId: staffId,
content: ctx.data.content.trim(),
createdAt: now,
updatedAt: now,
});
await tx
.update(WebsiteHelpCenterTickets)
.set({ updatedAt: now })
.where(eq(WebsiteHelpCenterTickets.id, ticketId));
});
logAudit({
userId: staffId,
action: "help_center_ticket_reply",
target: "WebsiteHelpCenterTickets",
targetId: Number(ticketId),
const result = await execute(ctx.session.user, "help-ticket.reply", {
ticketId: ticketId.toString(),
content: ctx.data.content.trim(),
});
if (!result.ok) throw new ActionError("Ticket not found");
revalidateHelpCenterTicketPaths(ticketId);
return actionOk();
@@ -141,32 +97,17 @@ export const closeHelpCenterTicket = adminAction(
{ permission: HELP_TICKET_EDIT, schema: helpCenterTicketIdSchema },
async (ctx) => {
const ticketId = ctx.data.ticketId;
const [ticket] = await db
.select({
id: WebsiteHelpCenterTickets.id,
open: WebsiteHelpCenterTickets.open,
})
.from(WebsiteHelpCenterTickets)
.where(eq(WebsiteHelpCenterTickets.id, ticketId))
.limit(1);
if (!ticket) throw new ActionError("Ticket not found");
if (!ticket.open) throw new ActionError("Ticket is already closed");
const now = new Date();
await db
.update(WebsiteHelpCenterTickets)
.set({ open: false, updatedAt: now })
.where(eq(WebsiteHelpCenterTickets.id, ticketId));
logAudit({
userId: Number(ctx.session.user.id),
action: "help_center_ticket_close",
target: "WebsiteHelpCenterTickets",
targetId: Number(ticketId),
before: { open: true },
after: { open: false },
const result = await execute(ctx.session.user, "help-ticket.status", {
ticketId: ticketId.toString(),
status: "close",
});
if (!result.ok) {
throw new ActionError(
result.error.code === "CONFLICT"
? "Ticket is already closed"
: "Ticket not found",
);
}
revalidateHelpCenterTicketPaths(ticketId);
return actionOk();
@@ -177,32 +118,17 @@ export const reopenHelpCenterTicket = adminAction(
{ permission: HELP_TICKET_EDIT, schema: helpCenterTicketIdSchema },
async (ctx) => {
const ticketId = ctx.data.ticketId;
const [ticket] = await db
.select({
id: WebsiteHelpCenterTickets.id,
open: WebsiteHelpCenterTickets.open,
})
.from(WebsiteHelpCenterTickets)
.where(eq(WebsiteHelpCenterTickets.id, ticketId))
.limit(1);
if (!ticket) throw new ActionError("Ticket not found");
if (ticket.open) throw new ActionError("Ticket is already open");
const now = new Date();
await db
.update(WebsiteHelpCenterTickets)
.set({ open: true, updatedAt: now })
.where(eq(WebsiteHelpCenterTickets.id, ticketId));
logAudit({
userId: Number(ctx.session.user.id),
action: "help_center_ticket_reopen",
target: "WebsiteHelpCenterTickets",
targetId: Number(ticketId),
before: { open: false },
after: { open: true },
const result = await execute(ctx.session.user, "help-ticket.status", {
ticketId: ticketId.toString(),
status: "reopen",
});
if (!result.ok) {
throw new ActionError(
result.error.code === "CONFLICT"
? "Ticket is already open"
: "Ticket not found",
);
}
revalidateHelpCenterTicketPaths(ticketId);
return actionOk();
+14 -4
View File
@@ -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
View File
@@ -1,13 +1,14 @@
"use server";
import { eq } from "drizzle-orm";
import { z } from "zod";
import { db, SupportTickets } from "@/lib/db";
import {
createPeopleMutationInvocation,
peopleMutationService,
} from "@/features/housekeeping/domains/people/services/mutations";
import { createCorrelationId } from "@/features/housekeeping/foundation/contracts";
import { actionOk, adminAction } from "@/lib/foundation/action";
import { NotFoundError } from "@/lib/foundation/errors";
import { PERMS } from "@/lib/permissions";
import { logAudit } from "@/lib/services/audit";
import { rcon } from "@/lib/services/rcon";
// ── CFH Ticket Actions ──────────────────────────────────────────────
@@ -16,28 +17,31 @@ const cfhIdSchema = z.object({ ticketId: z.coerce.number().int().positive() });
const CFH_PERM = [PERMS.MODERATION_EDIT, PERMS.MOD_CFH_EDIT] as const;
const MOD_ACTION_PERM = [PERMS.MODERATION_EDIT, PERMS.MOD_ACTIONS] as const;
async function execute(
staff: { readonly id: number },
operation: "cfh.resolve" | "moderation.action",
input: unknown,
) {
return peopleMutationService.execute(
createPeopleMutationInvocation(staff, createCorrelationId()),
operation,
input,
);
}
export const assignCfhTicket = adminAction(
{ permission: CFH_PERM, schema: cfhIdSchema },
async (ctx) => {
const [ticket] = await db
.select({ id: SupportTickets.id })
.from(SupportTickets)
.where(eq(SupportTickets.id, ctx.data.ticketId))
.limit(1);
if (!ticket) throw new NotFoundError("SupportTicket", ctx.data.ticketId);
await db
.update(SupportTickets)
.set({ modId: ctx.session.user.id, state: 1 })
.where(eq(SupportTickets.id, ctx.data.ticketId));
logAudit({
userId: ctx.session.user.id,
action: "cfh_assign",
target: "support_tickets",
targetId: ctx.data.ticketId,
const result = await execute(ctx.session.user, "cfh.resolve", {
ticketId: ctx.data.ticketId,
state: 1,
});
if (!result.ok) {
if (result.error.code === "NOT_FOUND") {
throw new NotFoundError("SupportTicket", ctx.data.ticketId);
}
throw new Error("Could not assign support ticket");
}
return actionOk();
},
);
@@ -50,30 +54,13 @@ const cfhStateSchema = z.object({
export const updateCfhState = adminAction(
{ permission: CFH_PERM, schema: cfhStateSchema },
async (ctx) => {
const [ticket] = await db
.select({
id: SupportTickets.id,
state: SupportTickets.state,
})
.from(SupportTickets)
.where(eq(SupportTickets.id, ctx.data.ticketId))
.limit(1);
if (!ticket) throw new NotFoundError("SupportTicket", ctx.data.ticketId);
await db
.update(SupportTickets)
.set({ state: ctx.data.state, modId: ctx.session.user.id })
.where(eq(SupportTickets.id, ctx.data.ticketId));
logAudit({
userId: ctx.session.user.id,
action: "cfh_state_change",
target: "support_tickets",
targetId: ctx.data.ticketId,
before: { state: ticket.state },
after: { state: ctx.data.state },
});
const result = await execute(ctx.session.user, "cfh.resolve", ctx.data);
if (!result.ok) {
if (result.error.code === "NOT_FOUND") {
throw new NotFoundError("SupportTicket", ctx.data.ticketId);
}
throw new Error("Could not update support ticket");
}
return actionOk();
},
);
@@ -81,18 +68,13 @@ export const updateCfhState = adminAction(
export const closeCfhTicket = adminAction(
{ permission: CFH_PERM, schema: cfhIdSchema },
async (ctx) => {
await db
.update(SupportTickets)
.set({ state: 2, modId: ctx.session.user.id })
.where(eq(SupportTickets.id, ctx.data.ticketId));
logAudit({
userId: ctx.session.user.id,
action: "cfh_close",
target: "support_tickets",
targetId: ctx.data.ticketId,
const result = await execute(ctx.session.user, "cfh.resolve", {
ticketId: ctx.data.ticketId,
state: 2,
});
if (!result.ok && result.error.code !== "NOT_FOUND") {
throw new Error("Could not close support ticket");
}
return actionOk();
},
);
@@ -104,15 +86,10 @@ const userIdSchema = z.object({ userId: z.coerce.number().int().positive() });
export const quickKick = adminAction(
{ permission: MOD_ACTION_PERM, schema: userIdSchema },
async (ctx) => {
await rcon.disconnectUser(ctx.data.userId);
logAudit({
userId: ctx.session.user.id,
action: "mod_kick",
target: "User",
targetId: ctx.data.userId,
await execute(ctx.session.user, "moderation.action", {
action: "kick",
userId: ctx.data.userId,
});
return actionOk();
},
);
@@ -125,16 +102,10 @@ const muteSchema = z.object({
export const quickMute = adminAction(
{ permission: MOD_ACTION_PERM, schema: muteSchema },
async (ctx) => {
await rcon.muteUser(ctx.data.userId, ctx.data.duration);
logAudit({
userId: ctx.session.user.id,
action: "mod_mute",
target: "User",
targetId: ctx.data.userId,
after: { duration: ctx.data.duration },
await execute(ctx.session.user, "moderation.action", {
action: "mute",
...ctx.data,
});
return actionOk();
},
);
@@ -142,15 +113,10 @@ export const quickMute = adminAction(
export const quickUnmute = adminAction(
{ permission: MOD_ACTION_PERM, schema: userIdSchema },
async (ctx) => {
await rcon.unmuteUser(ctx.data.userId);
logAudit({
userId: ctx.session.user.id,
action: "mod_unmute",
target: "User",
targetId: ctx.data.userId,
await execute(ctx.session.user, "moderation.action", {
action: "unmute",
userId: ctx.data.userId,
});
return actionOk();
},
);
@@ -163,16 +129,10 @@ const alertSchema = z.object({
export const quickAlert = adminAction(
{ permission: MOD_ACTION_PERM, schema: alertSchema },
async (ctx) => {
await rcon.alertUser(ctx.data.userId, ctx.data.message);
logAudit({
userId: ctx.session.user.id,
action: "mod_alert",
target: "User",
targetId: ctx.data.userId,
after: { message: ctx.data.message },
await execute(ctx.session.user, "moderation.action", {
action: "alert",
...ctx.data,
});
return actionOk();
},
);
@@ -182,15 +142,10 @@ const roomIdSchema = z.object({ roomId: z.coerce.number().int().positive() });
export const quickRoomKick = adminAction(
{ permission: MOD_ACTION_PERM, schema: roomIdSchema },
async (ctx) => {
await rcon.kickAll(ctx.data.roomId);
logAudit({
userId: ctx.session.user.id,
action: "mod_room_kick",
target: "Room",
targetId: ctx.data.roomId,
await execute(ctx.session.user, "moderation.action", {
action: "room-kick",
roomId: ctx.data.roomId,
});
return actionOk();
},
);
@@ -203,19 +158,10 @@ const broadcastSchema = z.object({
export const broadcastAlert = adminAction(
{ permission: MOD_ACTION_PERM, schema: broadcastSchema },
async (ctx) => {
if (ctx.data.type === "hotel") {
await rcon.hotelAlert(ctx.data.message);
} else {
await rcon.staffAlert(ctx.data.message);
}
logAudit({
userId: ctx.session.user.id,
action: `mod_broadcast_${ctx.data.type}`,
target: "broadcast",
after: { message: ctx.data.message },
await execute(ctx.session.user, "moderation.action", {
action: "broadcast",
...ctx.data,
});
return actionOk();
},
);
@@ -0,0 +1,216 @@
import { revalidatePath } from "next/cache";
import { beforeEach, describe, expect, it, vi } from "vitest";
const { execute, registrations, staff } = vi.hoisted(() => ({
execute: vi.fn(),
registrations: [] as Array<{ permission: string | readonly string[] }>,
staff: { id: 42, rank: 4, username: "moderator" },
}));
function wrapper(
options: { permission: string | readonly string[] },
handler: (context: { data: unknown; session: { user: typeof staff } }) => unknown,
) {
registrations.push(options);
return (data: unknown) => handler({ data, session: { user: staff } });
}
vi.mock("@/features/housekeeping/domains/people/services/mutations", () => ({
createPeopleMutationInvocation: vi.fn((actor, correlationId) => ({
expectedActorId: actor.id,
correlationId,
legacy: true,
})),
peopleMutationService: { execute },
}));
vi.mock("@/lib/safe-action", () => ({ adminAction: wrapper }));
vi.mock("@/lib/safe-action-shared", () => ({
ActionError: class ActionError extends Error {},
actionOk: (data?: unknown) => ({ ok: true, data: data ?? {} }),
}));
vi.mock("@/lib/foundation/action", () => ({
adminAction: wrapper,
actionOk: (data?: unknown) => ({ ok: true, data: data ?? {} }),
}));
vi.mock("@/lib/permissions", () => ({
PERMS: {
TICKETS_EDIT: "admin.tickets.edit",
MOD_TICKETS_EDIT: "mod.tickets.edit",
USERS_BAN: "admin.users.ban",
MODERATION_EDIT: "admin.moderation.edit",
MOD_CFH_EDIT: "mod.cfh.edit",
MOD_ACTIONS: "mod.actions",
},
}));
vi.mock("next/cache", () => ({ revalidatePath: vi.fn() }));
import {
closeHelpCenterTicket,
liftBanFromHelpTicket,
reopenHelpCenterTicket,
replyHelpCenterTicket,
} from "./admin-help-tickets";
import {
assignCfhTicket,
broadcastAlert,
closeCfhTicket,
quickAlert,
quickKick,
quickMute,
quickRoomKick,
quickUnmute,
updateCfhState,
} from "./moderation";
import {
createTemplate,
deleteTemplate,
updateTemplate,
} from "./ticket-templates";
import {
adminReplyTicket,
assignTicket,
updateTicketPriority,
updateTicketStatus,
} from "./tickets";
type LegacyAction = (input: unknown) => Promise<unknown>;
const call = (action: unknown, input: unknown) =>
(action as LegacyAction)(input);
beforeEach(() => {
vi.clearAllMocks();
execute.mockImplementation(async (invocation, operation) => ({
ok: true,
data: {
before: null,
after: operation === "ticket-template.change" ? { id: "88" } : {},
output:
operation === "help-ticket.unban"
? { removed: 2, userId: 7 }
: undefined,
},
correlationId: invocation.correlationId,
}));
});
describe("legacy People support and moderation wrappers", () => {
it("keeps mid-rank ACL alternatives without an admin.dashboard dependency", () => {
const permissions = registrations.flatMap((entry) =>
typeof entry.permission === "string"
? [entry.permission]
: entry.permission,
);
expect(permissions).toEqual(
expect.arrayContaining([
"admin.tickets.edit",
"mod.tickets.edit",
"admin.moderation.edit",
"mod.cfh.edit",
"mod.actions",
]),
);
expect(permissions).not.toContain("admin.dashboard");
});
it("delegates tickets and templates with their established result shapes", async () => {
await expect(
call(adminReplyTicket, { ticketId: 7, message: "Handled" }),
).resolves.toEqual({ ok: true, data: {} });
await call(assignTicket, { ticketId: 7, assigneeId: 42 });
await call(updateTicketStatus, { ticketId: 7, status: "closed" });
await call(updateTicketPriority, { ticketId: 7, priority: "urgent" });
await expect(
call(createTemplate, {
title: "Greeting",
content: "Hello",
category: "general",
sortOrder: 0,
}),
).resolves.toEqual({ ok: true, data: { id: 88 } });
await expect(
call(updateTemplate, { id: 88, title: "Updated" }),
).resolves.toEqual({ ok: true, data: { id: 88 } });
await expect(call(deleteTemplate, { id: 88 })).resolves.toEqual({
ok: true,
data: {},
});
expect(execute.mock.calls.map((entry) => entry[1])).toEqual([
"ticket.reply",
"ticket.assign",
"ticket.status",
"ticket.priority",
"ticket-template.change",
"ticket-template.change",
"ticket-template.change",
]);
});
it("preserves BIGINT help-ticket IDs, outputs, and every legacy refresh", async () => {
const ticketId = 9_007_199_254_740_993n;
await call(replyHelpCenterTicket, { ticketId, content: " Handled " });
await call(closeHelpCenterTicket, { ticketId });
await call(reopenHelpCenterTicket, { ticketId });
await expect(
call(liftBanFromHelpTicket, { ticketId }),
).resolves.toEqual({ ok: true, data: { removed: 2, userId: 7 } });
expect(execute.mock.calls.map((entry) => entry[2])).toEqual([
{ ticketId: "9007199254740993", content: "Handled" },
{ ticketId: "9007199254740993", status: "close" },
{ ticketId: "9007199254740993", status: "reopen" },
{ ticketId: "9007199254740993" },
]);
expect(revalidatePath).toHaveBeenCalledWith("/admin/help-tickets");
expect(revalidatePath).toHaveBeenCalledWith(
"/admin/help-tickets/9007199254740993",
);
expect(revalidatePath).toHaveBeenCalledWith(
"/mod/help-tickets/9007199254740993",
);
expect(revalidatePath).toHaveBeenCalledWith(
"/help/tickets/9007199254740993",
);
expect(revalidatePath).toHaveBeenCalledWith("/admin/bans");
expect(revalidatePath).toHaveBeenCalledWith("/admin/users/show/7");
});
it("delegates every CFH and moderation transport action", async () => {
await call(assignCfhTicket, { ticketId: 9 });
await call(updateCfhState, { ticketId: 9, state: 3 });
await call(closeCfhTicket, { ticketId: 9 });
await call(quickKick, { userId: 7 });
await call(quickMute, { userId: 7, duration: 60 });
await call(quickUnmute, { userId: 7 });
await call(quickAlert, { userId: 7, message: "Stop" });
await call(quickRoomKick, { roomId: 12 });
await call(broadcastAlert, { message: "Notice", type: "staff" });
expect(execute.mock.calls.map((entry) => entry[1])).toEqual([
"cfh.resolve",
"cfh.resolve",
"cfh.resolve",
"moderation.action",
"moderation.action",
"moderation.action",
"moderation.action",
"moderation.action",
"moderation.action",
]);
expect(execute.mock.calls.map((entry) => entry[0].expectedActorId)).toEqual(
Array(9).fill(42),
);
});
it("keeps close-CFH missing rows as a successful legacy no-op", async () => {
execute.mockResolvedValueOnce({
ok: false,
error: { code: "NOT_FOUND", messageKey: "errors.housekeeping.notFound" },
correlationId: "missing-cfh",
});
await expect(call(closeCfhTicket, { ticketId: 404 })).resolves.toEqual({
ok: true,
data: {},
});
});
});
+31 -17
View File
@@ -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
View File
@@ -1,11 +1,13 @@
"use server";
import { eq } from "drizzle-orm";
import { db, WebsiteTicket, WebsiteTicketMessage } 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";
import { logAudit } from "@/lib/services/audit";
import {
assignTicketSchema,
replyTicketSchema,
@@ -13,6 +15,27 @@ import {
updateTicketStatusSchema,
} from "@/lib/validators/ticket";
async function execute(
staff: { readonly id: number },
operation:
| "ticket.reply"
| "ticket.assign"
| "ticket.status"
| "ticket.priority",
input: unknown,
) {
const result = await peopleMutationService.execute(
createPeopleMutationInvocation(staff, createCorrelationId()),
operation,
input,
);
if (!result.ok) {
throw new ActionError(
result.error.code === "NOT_FOUND" ? "Ticket not found" : "Ticket update failed",
);
}
}
// ── User actions (authenticated, no admin perms needed) ──────────────
export const adminReplyTicket = adminAction(
@@ -21,45 +44,7 @@ export const adminReplyTicket = adminAction(
schema: replyTicketSchema,
},
async (ctx) => {
const [ticket] = await db
.select({
id: WebsiteTicket.id,
assigneeId: WebsiteTicket.assigneeId,
})
.from(WebsiteTicket)
.where(eq(WebsiteTicket.id, ctx.data.ticketId))
.limit(1);
if (!ticket) throw new ActionError("Ticket not found");
await db.insert(WebsiteTicketMessage).values({
ticketId: ctx.data.ticketId,
userId: ctx.session.user.id,
message: ctx.data.message,
isStaff: 1,
});
// Auto-assign if not assigned yet
const updates: Partial<typeof WebsiteTicket.$inferInsert> = {
status: "waiting",
updatedAt: new Date(),
};
if (!ticket.assigneeId) {
updates.assigneeId = ctx.session.user.id;
}
await db
.update(WebsiteTicket)
.set(updates)
.where(eq(WebsiteTicket.id, ctx.data.ticketId));
logAudit({
userId: ctx.session.user.id,
action: "ticket_reply",
target: "WebsiteTicket",
targetId: ctx.data.ticketId,
});
await execute(ctx.session.user, "ticket.reply", ctx.data);
return actionOk();
},
);
@@ -70,43 +55,7 @@ export const updateTicketStatus = adminAction(
schema: updateTicketStatusSchema,
},
async (ctx) => {
const [ticket] = await db
.select({
id: WebsiteTicket.id,
assigneeId: WebsiteTicket.assigneeId,
status: WebsiteTicket.status,
})
.from(WebsiteTicket)
.where(eq(WebsiteTicket.id, ctx.data.ticketId))
.limit(1);
if (!ticket) throw new ActionError("Ticket not found");
const data: Partial<typeof WebsiteTicket.$inferInsert> = {
status: ctx.data.status,
updatedAt: new Date(),
};
if (ctx.data.status === "closed") {
data.closedAt = new Date();
}
if (ctx.data.status === "in_progress" && !ticket.assigneeId) {
data.assigneeId = ctx.session.user.id;
}
await db
.update(WebsiteTicket)
.set(data)
.where(eq(WebsiteTicket.id, ctx.data.ticketId));
logAudit({
userId: ctx.session.user.id,
action: "ticket_status_change",
target: "WebsiteTicket",
targetId: ctx.data.ticketId,
before: { status: ticket.status },
after: { status: ctx.data.status },
});
await execute(ctx.session.user, "ticket.status", ctx.data);
return actionOk();
},
);
@@ -117,35 +66,7 @@ export const assignTicket = adminAction(
schema: assignTicketSchema,
},
async (ctx) => {
const [ticket] = await db
.select({
id: WebsiteTicket.id,
assigneeId: WebsiteTicket.assigneeId,
})
.from(WebsiteTicket)
.where(eq(WebsiteTicket.id, ctx.data.ticketId))
.limit(1);
if (!ticket) throw new ActionError("Ticket not found");
await db
.update(WebsiteTicket)
.set({
assigneeId: ctx.data.assigneeId,
status: ctx.data.assigneeId ? "in_progress" : "open",
updatedAt: new Date(),
})
.where(eq(WebsiteTicket.id, ctx.data.ticketId));
logAudit({
userId: ctx.session.user.id,
action: "ticket_assign",
target: "WebsiteTicket",
targetId: ctx.data.ticketId,
before: { assigneeId: ticket.assigneeId },
after: { assigneeId: ctx.data.assigneeId },
});
await execute(ctx.session.user, "ticket.assign", ctx.data);
return actionOk();
},
);
@@ -156,31 +77,7 @@ export const updateTicketPriority = adminAction(
schema: updateTicketPrioritySchema,
},
async (ctx) => {
const [ticket] = await db
.select({
id: WebsiteTicket.id,
priority: WebsiteTicket.priority,
})
.from(WebsiteTicket)
.where(eq(WebsiteTicket.id, ctx.data.ticketId))
.limit(1);
if (!ticket) throw new ActionError("Ticket not found");
await db
.update(WebsiteTicket)
.set({ priority: ctx.data.priority, updatedAt: new Date() })
.where(eq(WebsiteTicket.id, ctx.data.ticketId));
logAudit({
userId: ctx.session.user.id,
action: "ticket_priority_change",
target: "WebsiteTicket",
targetId: ctx.data.ticketId,
before: { priority: ticket.priority },
after: { priority: ctx.data.priority },
});
await execute(ctx.session.user, "ticket.priority", ctx.data);
return actionOk();
},
);
@@ -0,0 +1,142 @@
import { describe, expect, it, vi } from "vitest";
import { PERMS } from "@/lib/permission-slugs";
import { confirmHousekeepingCommand } from "../../../foundation/commands/confirmation";
import type { HousekeepingCommand } from "../../../foundation/commands/registry";
import {
createModerationCommands,
MODERATION_COMMAND_IDS,
MODERATION_COMMANDS,
} from "./moderation-commands";
vi.mock("@/lib/auth", () => ({ invalidateLoginCache: vi.fn() }));
vi.mock("@/lib/auth/password", () => ({ hashPassword: vi.fn() }));
const commands = MODERATION_COMMANDS as unknown as readonly HousekeepingCommand<
unknown,
unknown
>[];
describe("People moderation commands", () => {
it("declares CFH resolve/sanction, ban/unban, and moderation action", () => {
expect(MODERATION_COMMAND_IDS).toEqual([
"people.cfh.resolve",
"people.cfh.sanction",
"people.ban.create",
"people.ban.lift",
"people.moderation.action",
]);
expect(commands.map((command) => command.id)).toEqual(
MODERATION_COMMAND_IDS,
);
expect(commands[0]?.capability.slugs).toEqual([
PERMS.MODERATION_EDIT,
PERMS.MOD_CFH_EDIT,
]);
expect(commands[4]?.capability.slugs).toEqual([
PERMS.MODERATION_EDIT,
PERMS.MOD_ACTIONS,
]);
});
it("requires reasons for sanctions and bans", () => {
for (const id of [
"people.cfh.sanction",
"people.ban.create",
"people.ban.lift",
]) {
const command = commands.find((entry) => entry.id === id);
if (!command) throw new Error("command missing");
expect(command.requiresReason).toBe(true);
expect(confirmHousekeepingCommand(command, " ", "moderation")).toMatchObject({
ok: false,
error: { code: "VALIDATION" },
});
}
});
it("bounds and delegates a CFH sanction", async () => {
const execute = vi.fn(async (invocation) => ({
ok: true as const,
data: { before: null, after: null },
correlationId: invocation.correlationId,
}));
const created = createModerationCommands({
execute,
}) as unknown as readonly HousekeepingCommand<unknown, unknown>[];
const command = created.find(
(entry) => entry.id === "people.cfh.sanction",
);
if (!command) throw new Error("command missing");
expect(
command.input.safeParse({
ticketId: 9,
userId: 7,
action: "mute",
duration: 525601,
reason: "spam",
}).success,
).toBe(false);
const input = command.input.parse({
ticketId: 9,
userId: 7,
action: "mute",
duration: 60,
reason: "spam",
});
await command.execute(
{
capability: {
actor: { id: 42, username: "mod", rank: 4 },
isSuperAdmin: false,
has: () => false,
hasAny: () => true,
hasAll: () => false,
},
correlationId: "moderation-red",
ipAddress: "198.51.100.9",
},
input,
);
expect(execute).toHaveBeenCalledWith(
expect.objectContaining({ expectedActorId: 42 }),
"cfh.sanction",
input,
);
});
it.each([
["people.cfh.resolve", { ticketId: 9, state: 2 }, "cfh.resolve"],
["people.ban.create", { userId: 7, reason: "spam", hours: 24, type: "account" }, "ban.create"],
["people.ban.lift", { id: 12 }, "ban.lift"],
["people.moderation.action", { action: "alert", userId: 7, message: "Stop" }, "moderation.action"],
] as const)("delegates %s to %s", async (id, input, operation) => {
const execute = vi.fn(async (invocation) => ({
ok: true as const,
data: { before: null, after: null },
correlationId: invocation.correlationId,
}));
const created = createModerationCommands({ execute }) as unknown as readonly HousekeepingCommand<unknown, unknown>[];
const command = created.find((entry) => entry.id === id);
if (!command) throw new Error("command missing");
const parsed = command.input.parse(input);
await command.execute(
{
capability: {
actor: { id: 42, username: "mod", rank: 4 },
isSuperAdmin: false,
has: () => false,
hasAny: () => true,
hasAll: () => false,
},
correlationId: "moderation-matrix",
ipAddress: "198.51.100.9",
},
parsed,
);
expect(execute).toHaveBeenCalledWith(
expect.objectContaining({ expectedActorId: 42 }),
operation,
parsed,
);
});
});
@@ -0,0 +1,132 @@
import "server-only";
import { z } from "zod";
import { PERMS } from "@/lib/permission-slugs";
import type { HousekeepingCommand } from "../../../foundation/commands/registry";
import { anyCapability } from "../../../foundation/contracts";
import {
type PeopleMutationOperation,
type PeopleMutationService,
peopleMutationService,
} from "../services/mutations";
export const MODERATION_COMMAND_IDS = [
"people.cfh.resolve",
"people.cfh.sanction",
"people.ban.create",
"people.ban.lift",
"people.moderation.action",
] as const;
type ModerationCommandId = (typeof MODERATION_COMMAND_IDS)[number];
const positiveId = z.number().int().positive();
const requiredText = (max: number) => z.string().min(1).max(max).regex(/\S/u);
function command<I>(
service: Pick<PeopleMutationService, "execute">,
options: {
id: ModerationCommandId;
operation: PeopleMutationOperation;
capability: readonly string[];
input: z.ZodType<I>;
requiresReason?: boolean;
},
): HousekeepingCommand<I, unknown> {
return {
id: options.id,
owner: "people",
risk: "sensitive",
capability: anyCapability(...options.capability),
input: options.input,
requiresReason: options.requiresReason === true,
rateLimit: { attempts: 5, windowMs: 60_000 },
execute: (context, input) =>
service.execute(
{
correlationId: context.correlationId,
expectedActorId: context.capability.actor.id,
},
options.operation,
input,
),
};
}
export function createModerationCommands(
service: Pick<PeopleMutationService, "execute">,
) {
const cfhEdit = [PERMS.MODERATION_EDIT, PERMS.MOD_CFH_EDIT] as const;
const modAction = [PERMS.MODERATION_EDIT, PERMS.MOD_ACTIONS] as const;
return [
command(service, {
id: "people.cfh.resolve",
operation: "cfh.resolve",
capability: cfhEdit,
input: z.object({
ticketId: positiveId,
state: z.number().int().min(0).max(3),
}),
}),
command(service, {
id: "people.cfh.sanction",
operation: "cfh.sanction",
capability: cfhEdit,
input: z.object({
ticketId: positiveId,
userId: positiveId,
action: z.enum(["kick", "mute", "alert"]),
duration: z.number().int().min(0).max(525_600).optional(),
message: z.string().max(500).optional(),
reason: requiredText(500),
}),
requiresReason: true,
}),
command(service, {
id: "people.ban.create",
operation: "ban.create",
capability: [PERMS.USERS_BAN],
input: z.object({
userId: positiveId,
reason: requiredText(500),
hours: z.number().int().min(0).max(876_000),
type: z.enum(["account", "ip", "machine", "super"]),
}),
requiresReason: true,
}),
command(service, {
id: "people.ban.lift",
operation: "ban.lift",
capability: [PERMS.USERS_BAN],
input: z.object({ id: positiveId }),
requiresReason: true,
}),
command(service, {
id: "people.moderation.action",
operation: "moderation.action",
capability: modAction,
input: z.discriminatedUnion("action", [
z.object({ action: z.literal("kick"), userId: positiveId }),
z.object({
action: z.literal("mute"),
userId: positiveId,
duration: z.number().int().min(0).max(525_600),
}),
z.object({ action: z.literal("unmute"), userId: positiveId }),
z.object({
action: z.literal("alert"),
userId: positiveId,
message: requiredText(500),
}),
z.object({ action: z.literal("room-kick"), roomId: positiveId }),
z.object({
action: z.literal("broadcast"),
message: requiredText(500),
type: z.enum(["hotel", "staff"]),
}),
]),
}),
] as const;
}
export const MODERATION_COMMANDS =
createModerationCommands(peopleMutationService);
@@ -0,0 +1,104 @@
import { describe, expect, it, vi } from "vitest";
import { PERMS } from "@/lib/permission-slugs";
import type { HousekeepingCommand } from "../../../foundation/commands/registry";
import {
createSupportCommands,
SUPPORT_COMMAND_IDS,
SUPPORT_COMMANDS,
} from "./support-commands";
vi.mock("@/lib/auth", () => ({ invalidateLoginCache: vi.fn() }));
vi.mock("@/lib/auth/password", () => ({ hashPassword: vi.fn() }));
const commands = SUPPORT_COMMANDS as unknown as readonly HousekeepingCommand<
unknown,
unknown
>[];
describe("People support commands", () => {
it("declares assign, reply, close/reopen, template, and help-ticket workflows", () => {
expect(SUPPORT_COMMAND_IDS).toEqual([
"people.ticket.reply",
"people.ticket.assign",
"people.ticket.status",
"people.ticket.priority",
"people.ticket-template.change",
"people.help-ticket.reply",
"people.help-ticket.status",
"people.help-ticket.unban",
]);
expect(commands.map((command) => command.id)).toEqual(SUPPORT_COMMAND_IDS);
});
it("preserves mod.* editing and canonical decimal BIGINT help IDs", () => {
for (const command of commands.filter((entry) =>
entry.id.startsWith("people.ticket."),
)) {
expect(command.capability.slugs).toEqual([
PERMS.TICKETS_EDIT,
PERMS.MOD_TICKETS_EDIT,
]);
}
const reply = commands.find(
(entry) => entry.id === "people.help-ticket.reply",
);
if (!reply) throw new Error("command missing");
expect(
reply.input.parse({
ticketId: "18446744073709551615",
content: "Handled",
}),
).toMatchObject({ ticketId: "18446744073709551615" });
expect(
reply.input.safeParse({
ticketId: "18446744073709551616",
content: "Handled",
}).success,
).toBe(false);
});
it.each([
["people.ticket.reply", { ticketId: 7, message: "Handled" }, "ticket.reply"],
["people.ticket.assign", { ticketId: 7, assigneeId: 42 }, "ticket.assign"],
["people.ticket.status", { ticketId: 7, status: "closed" }, "ticket.status"],
["people.ticket-template.change", { action: "create", title: "Greeting", content: "Hello" }, "ticket-template.change"],
["people.help-ticket.reply", { ticketId: "9007199254740993", content: "Handled" }, "help-ticket.reply"],
["people.help-ticket.status", { ticketId: "9007199254740993", status: "close" }, "help-ticket.status"],
["people.help-ticket.status", { ticketId: "9007199254740993", status: "reopen" }, "help-ticket.status"],
["people.help-ticket.unban", { ticketId: "9007199254740993" }, "help-ticket.unban"],
] as const)("delegates %s to the redirect-free %s operation", async (id, input, operation) => {
const execute = vi.fn(async (invocation) => ({
ok: true as const,
data: { before: null, after: null },
correlationId: invocation.correlationId,
}));
const created = createSupportCommands({
execute,
}) as unknown as readonly HousekeepingCommand<unknown, unknown>[];
const command = created.find((entry) => entry.id === id);
if (!command) throw new Error("command missing");
const parsed = command.input.parse(input);
await command.execute(
{
capability: {
actor: { id: 42, username: "mod", rank: 4 },
isSuperAdmin: false,
has: () => false,
hasAny: () => true,
hasAll: () => false,
},
correlationId: "support-red",
ipAddress: "198.51.100.8",
},
parsed,
);
expect(execute).toHaveBeenCalledWith(
expect.objectContaining({
correlationId: "support-red",
expectedActorId: 42,
}),
operation,
parsed,
);
});
});
@@ -0,0 +1,161 @@
import "server-only";
import { z } from "zod";
import { PERMS } from "@/lib/permission-slugs";
import type { HousekeepingCommand } from "../../../foundation/commands/registry";
import { anyCapability } from "../../../foundation/contracts";
import {
type PeopleMutationOperation,
type PeopleMutationService,
peopleMutationService,
} from "../services/mutations";
export const SUPPORT_COMMAND_IDS = [
"people.ticket.reply",
"people.ticket.assign",
"people.ticket.status",
"people.ticket.priority",
"people.ticket-template.change",
"people.help-ticket.reply",
"people.help-ticket.status",
"people.help-ticket.unban",
] as const;
type SupportCommandId = (typeof SUPPORT_COMMAND_IDS)[number];
const positiveId = z.number().int().positive();
const text = (max: number) => z.string().min(1).max(max).regex(/\S/u);
const MAX_UNSIGNED_BIGINT = "18446744073709551615";
function boundedDecimalPattern(maximum: string): RegExp {
const alternatives = [`[1-9]\\d{0,${maximum.length - 2}}`];
for (let index = 0; index < maximum.length; index += 1) {
const maximumDigit = Number(maximum[index]);
const minimumDigit = index === 0 ? 1 : 0;
const upperDigit = maximumDigit - 1;
if (upperDigit < minimumDigit) continue;
const digit =
upperDigit === minimumDigit
? String(minimumDigit)
: `[${minimumDigit}-${upperDigit}]`;
alternatives.push(
`${maximum.slice(0, index)}${digit}\\d{${maximum.length - index - 1}}`,
);
}
alternatives.push(maximum);
return new RegExp(`^(?:${alternatives.join("|")})$`, "u");
}
const bigintId = z.string().regex(boundedDecimalPattern(MAX_UNSIGNED_BIGINT));
function command<I>(
service: Pick<PeopleMutationService, "execute">,
options: {
id: SupportCommandId;
operation: PeopleMutationOperation;
capability: readonly string[];
input: z.ZodType<I>;
requiresReason?: boolean;
},
): HousekeepingCommand<I, unknown> {
return {
id: options.id,
owner: "people",
risk: "sensitive",
capability: anyCapability(...options.capability),
input: options.input,
requiresReason: options.requiresReason === true,
rateLimit: { attempts: 10, windowMs: 60_000 },
execute: (context, input) =>
service.execute(
{
correlationId: context.correlationId,
expectedActorId: context.capability.actor.id,
},
options.operation,
input,
),
};
}
export function createSupportCommands(
service: Pick<PeopleMutationService, "execute">,
) {
const ticketEdit = [PERMS.TICKETS_EDIT, PERMS.MOD_TICKETS_EDIT] as const;
return [
command(service, {
id: "people.ticket.reply",
operation: "ticket.reply",
capability: ticketEdit,
input: z.object({ ticketId: positiveId, message: text(5_000) }),
}),
command(service, {
id: "people.ticket.assign",
operation: "ticket.assign",
capability: ticketEdit,
input: z.object({ ticketId: positiveId, assigneeId: positiveId.nullable() }),
}),
command(service, {
id: "people.ticket.status",
operation: "ticket.status",
capability: ticketEdit,
input: z.object({
ticketId: positiveId,
status: z.enum(["open", "in_progress", "waiting", "closed"]),
}),
}),
command(service, {
id: "people.ticket.priority",
operation: "ticket.priority",
capability: ticketEdit,
input: z.object({
ticketId: positiveId,
priority: z.enum(["low", "normal", "high", "urgent"]),
}),
}),
command(service, {
id: "people.ticket-template.change",
operation: "ticket-template.change",
capability: [PERMS.TICKETS_EDIT],
input: z.discriminatedUnion("action", [
z.object({
action: z.literal("create"),
title: text(255),
content: text(5_000),
category: z.string().max(50).optional(),
sortOrder: z.number().int().min(0).max(2_147_483_647).optional(),
}),
z.object({
action: z.literal("update"),
id: positiveId,
title: text(255).optional(),
content: text(5_000).optional(),
category: z.string().max(50).optional(),
sortOrder: z.number().int().min(0).max(2_147_483_647).optional(),
}),
z.object({ action: z.literal("delete"), id: positiveId }),
]),
}),
command(service, {
id: "people.help-ticket.reply",
operation: "help-ticket.reply",
capability: ticketEdit,
input: z.object({ ticketId: bigintId, content: text(5_000) }),
}),
command(service, {
id: "people.help-ticket.status",
operation: "help-ticket.status",
capability: ticketEdit,
input: z.object({
ticketId: bigintId,
status: z.enum(["close", "reopen"]),
}),
}),
command(service, {
id: "people.help-ticket.unban",
operation: "help-ticket.unban",
capability: [PERMS.USERS_BAN],
input: z.object({ ticketId: bigintId }),
requiresReason: true,
}),
] as const;
}
export const SUPPORT_COMMANDS = createSupportCommands(peopleMutationService);
@@ -0,0 +1,244 @@
import "server-only";
import { PERMS } from "@/lib/permission-slugs";
import { authorizeHousekeeping } from "../../foundation/authorization";
import { satisfiesCapability } from "../../foundation/capability-context";
import {
anyCapability,
type CapabilityRequirement,
fail,
type HousekeepingCapabilityContext,
type HousekeepingInboxSource,
type HousekeepingWorkItem,
ok,
} from "../../foundation/contracts";
import { peopleModerationQuery } from "./queries/moderation";
import { peopleSupportQuery } from "./queries/support";
export const PEOPLE_INBOX_SOURCE_IDS = [
"people.tickets",
"people.help-tickets",
"people.cfh",
"people.active-bans",
] as const;
export interface PeopleInboxAdapters {
tickets(
context: HousekeepingCapabilityContext,
signal: AbortSignal,
): Promise<readonly HousekeepingWorkItem[]>;
helpTickets(
context: HousekeepingCapabilityContext,
signal: AbortSignal,
): Promise<readonly HousekeepingWorkItem[]>;
cfh(
context: HousekeepingCapabilityContext,
signal: AbortSignal,
): Promise<readonly HousekeepingWorkItem[]>;
activeBans(
context: HousekeepingCapabilityContext,
signal: AbortSignal,
): Promise<readonly HousekeepingWorkItem[]>;
}
function safeHref(href: string): boolean {
return (
href.startsWith("/ase/") &&
!href.includes("\\") &&
!Array.from(href).some((character) => {
const code = character.codePointAt(0) ?? 0;
return code < 32 || code === 127;
})
);
}
function createSource(
id: (typeof PEOPLE_INBOX_SOURCE_IDS)[number],
capability: CapabilityRequirement,
load: PeopleInboxAdapters[keyof PeopleInboxAdapters],
): HousekeepingInboxSource {
return {
id,
owner: "people",
capability,
async getItems(context, signal) {
const authorization = authorizeHousekeeping(context, capability);
if (!authorization.ok) return authorization;
try {
const items = await load(context, signal);
return ok(
{
availability: "available" as const,
items: items
.filter(
(item) =>
safeHref(item.href) &&
satisfiesCapability(context, item.capability),
)
.slice(0, 25),
},
authorization.correlationId,
);
} catch {
return fail(
"DEPENDENCY_UNAVAILABLE",
"errors.housekeeping.dependencyUnavailable",
authorization.correlationId,
);
}
},
};
}
export function createPeopleInboxSources(
adapters: PeopleInboxAdapters,
): readonly HousekeepingInboxSource[] {
return [
createSource(
"people.tickets",
anyCapability(PERMS.TICKETS_VIEW, PERMS.MOD_TICKETS_VIEW),
adapters.tickets,
),
createSource(
"people.help-tickets",
anyCapability(PERMS.TICKETS_VIEW, PERMS.MOD_TICKETS_VIEW),
adapters.helpTickets,
),
createSource(
"people.cfh",
anyCapability(PERMS.MODERATION_VIEW, PERMS.MOD_CFH_VIEW),
adapters.cfh,
),
createSource(
"people.active-bans",
anyCapability(PERMS.BANS_VIEW, PERMS.MOD_BANS_VIEW),
adapters.activeBans,
),
];
}
function time(occurredAt: string | null) {
if (occurredAt === null) return null;
const timestamp = Date.parse(occurredAt);
if (!Number.isFinite(timestamp)) return null;
return {
occurredAt,
ageMs: Math.max(0, Date.now() - timestamp),
freshness:
Date.now() - timestamp > 86_400_000
? ("stale" as const)
: ("fresh" as const),
};
}
const productionAdapters: PeopleInboxAdapters = {
async tickets(context) {
const result = await peopleSupportQuery.run(context, {
routeId: "people.support.tickets",
list: { pageSize: 25, offset: 0, sort: "updatedAt", order: "desc" },
});
if (!result.ok || result.data.kind !== "tickets") throw new Error("tickets");
const capability = anyCapability(PERMS.TICKETS_VIEW, PERMS.MOD_TICKETS_VIEW);
return result.data.page.items.flatMap((ticket) => {
const date = time(ticket.updatedAt);
return date === null
? []
: [{
sourceId: "people.tickets",
itemId: String(ticket.id),
deduplicationKey: `${ticket.source}-ticket:${ticket.id}`,
domain: "people" as const,
capability,
severity: ticket.priority === "urgent" ? "critical" as const : "info" as const,
priority: ticket.priority === "urgent" ? "critical" as const : "normal" as const,
...date,
state: ticket.status,
titleKey: "pages.housekeeping.items.ticket",
context: { subject: ticket.subject },
href: ticket.href,
actions: [],
}];
});
},
async helpTickets(context) {
const result = await peopleSupportQuery.run(context, {
routeId: "people.support.help-tickets",
list: { pageSize: 25, offset: 0, sort: "updatedAt", order: "desc" },
});
if (!result.ok || result.data.kind !== "help-tickets") throw new Error("help");
const capability = anyCapability(PERMS.TICKETS_VIEW, PERMS.MOD_TICKETS_VIEW);
return result.data.page.items.flatMap((ticket) => {
const date = time(ticket.updatedAt);
return date === null ? [] : [{
sourceId: "people.help-tickets",
itemId: ticket.id,
deduplicationKey: `help-ticket:${ticket.id}`,
domain: "people" as const,
capability,
severity: "info" as const,
priority: "normal" as const,
...date,
state: ticket.open ? "open" : "closed",
titleKey: "pages.housekeeping.items.helpTicket",
context: { title: ticket.title },
href: ticket.href,
actions: [],
}];
});
},
async cfh(context) {
const result = await peopleModerationQuery.run(context, {
routeId: "people.moderation.cfh",
list: { pageSize: 25, offset: 0, sort: "createdAt", order: "desc" },
});
if (!result.ok || result.data.kind !== "cfh") throw new Error("cfh");
const capability = anyCapability(PERMS.MODERATION_VIEW, PERMS.MOD_CFH_VIEW);
return result.data.page.items.flatMap((ticket) => {
const date = time(ticket.createdAt);
return date === null ? [] : [{
sourceId: "people.cfh",
itemId: String(ticket.id),
deduplicationKey: `cfh:${ticket.id}`,
domain: "people" as const,
capability,
severity: "warning" as const,
priority: "high" as const,
...date,
state: String(ticket.state),
titleKey: "pages.housekeeping.items.cfh",
context: { issue: ticket.issue },
href: ticket.href,
actions: [],
}];
});
},
async activeBans(context) {
const result = await peopleModerationQuery.run(context, {
routeId: "people.moderation.bans",
list: { pageSize: 25, offset: 0, sort: "createdAt", order: "desc" },
});
if (!result.ok || result.data.kind !== "bans") throw new Error("bans");
const capability = anyCapability(PERMS.BANS_VIEW, PERMS.MOD_BANS_VIEW);
return result.data.page.items.flatMap((ban) => {
const date = time(ban.createdAt);
return date === null ? [] : [{
sourceId: "people.active-bans",
itemId: String(ban.id),
deduplicationKey: `ban:${ban.id}`,
domain: "people" as const,
capability,
severity: "warning" as const,
priority: "normal" as const,
...date,
state: "active",
titleKey: "pages.housekeeping.items.activeBan",
context: { userId: ban.userId, reason: ban.reason },
href: `/ase/people/users/${ban.userId}` as const,
actions: [],
}];
});
},
};
export const PEOPLE_INBOX_SOURCES =
createPeopleInboxSources(productionAdapters);
@@ -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} />;
}
@@ -0,0 +1,123 @@
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, parsePeopleListInput } from "./page-state";
import { PeopleCommandForm } from "./people-command-form";
interface Props {
readonly context: HousekeepingCapabilityContext;
readonly result?: HousekeepingResult<PeopleModerationQueryData>;
}
function isEmpty(data: PeopleModerationQueryData) {
return "page" in data ? data.page.items.length === 0 : false;
}
function QuickAction() {
return (
<PeopleCommandForm
commandId="people.moderation.action"
buttonLabel="Run moderation action"
input={{}}
fields={[
{name: "action", label: "Action", type: "select", options: ["kick", "mute", "unmute", "alert", "room-kick", "broadcast"].map((value) => ({ value, label: value }))},
{name: "userId", label: "User ID", type: "number", min: 1},
{name: "roomId", label: "Room ID", type: "number", min: 1},
{name: "duration", label: "Duration", type: "number", min: 0, max: 525600},
{name: "message", label: "Message", type: "text", maxLength: 500},
{name: "type", label: "Audience", type: "select", options: [{value: "hotel", label: "hotel"}, {value: "staff", label: "staff"}]},
]}
/>
);
}
export function PeopleModerationPage({ context, result }: Props) {
const canAct = context.hasAny(PERMS.MODERATION_EDIT, PERMS.MOD_ACTIONS);
return (
<PeoplePageFrame
title="Moderation"
description="Review live moderation queues and act with capability-scoped controls."
result={result}
isEmpty={isEmpty}
>
{(data) => {
if (data.kind === "overview") return (
<div className="space-y-4">
<dl className="grid gap-3 sm:grid-cols-3">
{Object.entries(data.snapshot).map(([label, value]) => (
<div key={label} className="rounded border border-[var(--admin-border)] p-3"><dt>{label}</dt><dd>{value}</dd></div>
))}
</dl>
{canAct ? <QuickAction /> : null}
</div>
);
if (data.kind === "cfh") return (
<ul>{data.page.items.map((ticket) => (
<li key={ticket.id}><a href={ticket.href}>CFH #{ticket.id}</a> · {ticket.issue}</li>
))}</ul>
);
if (data.kind === "bans") return (
<div className="space-y-3">
{context.has(PERMS.USERS_BAN) ? (
<PeopleCommandForm
commandId="people.ban.create"
buttonLabel="Create ban"
input={{}}
fields={[
{name: "userId", label: "User ID", type: "number", min: 1, required: true},
{name: "hours", label: "Hours", type: "number", min: 0, max: 876000, defaultValue: 0},
{name: "type", label: "Type", type: "select", options: ["account", "ip", "machine", "super"].map((value) => ({value, label: value}))},
]}
requiresReason
includeReasonInInput
/>
) : null}
<ul>{data.page.items.map((ban) => (
<li key={ban.id}>
User #{ban.userId}: {ban.reason}
{context.has(PERMS.USERS_BAN) ? (
<PeopleCommandForm commandId="people.ban.lift" buttonLabel="Lift ban" input={{id: ban.id}} requiresReason />
) : null}
</li>
))}</ul>
</div>
);
if (data.kind === "ip-rules") return <pre>{JSON.stringify({blacklist: data.blacklist, whitelist: data.whitelist}, null, 2)}</pre>;
if (data.kind === "vpn") return <ul>{data.settings.map((setting) => <li key={setting.key}>{setting.key}: {setting.value}</li>)}</ul>;
if (data.kind === "word-filter") return <ul>{data.page.items.map((entry) => <li key={entry.id}>{entry.word}</li>)}</ul>;
return null;
}}
</PeoplePageFrame>
);
}
export async function renderPeopleModerationPage(input: HousekeepingPageInput) {
const routeId = input.match.routeId;
const queryRoute =
routeId === "people.moderation.actions"
? "people.moderation.overview"
: routeId;
const result =
queryRoute === "people.moderation.overview" ||
queryRoute === "people.moderation.ip" ||
queryRoute === "people.moderation.vpn"
? await peopleModerationQuery.run(input.context, { routeId: queryRoute })
: queryRoute === "people.moderation.cfh" ||
queryRoute === "people.moderation.bans" ||
queryRoute === "people.moderation.word-filter"
? await peopleModerationQuery.run(input.context, {
routeId: queryRoute,
list: parsePeopleListInput(input.searchParams ?? {}),
})
: fail("NOT_FOUND", "errors.housekeeping.notFound", createCorrelationId());
return <PeopleModerationPage context={input.context} result={result} />;
}
@@ -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));
});
@@ -0,0 +1,188 @@
import { renderToStaticMarkup } from "react-dom/server";
import { describe, expect, it } from "vitest";
import { PERMS } from "@/lib/permission-slugs";
import type { HousekeepingCapabilityContext } from "../../../foundation/contracts";
import { ok } from "../../../foundation/contracts";
import { PeopleCfhDetailPage } from "./cfh-detail";
import { PeopleHelpTicketDetailPage } from "./help-ticket-detail";
import { PeopleModerationPage } from "./moderation";
import { PeopleSupportPage } from "./support";
import { PeopleTicketDetailPage } from "./ticket-detail";
function context(granted: readonly string[]): HousekeepingCapabilityContext {
const permissions = new Set(granted);
return {
actor: { id: 42, username: "mod", rank: 4 },
isSuperAdmin: false,
has: (slug) => permissions.has(slug),
hasAny: (...slugs) => slugs.some((slug) => permissions.has(slug)),
hasAll: (...slugs) => slugs.every((slug) => permissions.has(slug)),
};
}
describe("People support/moderation pages", () => {
it("renders safe support links and real mid-rank ticket forms", () => {
const html = renderToStaticMarkup(
<PeopleSupportPage
context={context([PERMS.MOD_TICKETS_VIEW])}
result={ok(
{
kind: "tickets" as const,
page: {
items: [{
source: "cms" as const,
id: 7,
subject: "Need help",
status: "open",
priority: "normal",
creatorId: 9,
creatorUsername: "visitor",
updatedAt: null,
href: "/ase/people/support/tickets/7" as const,
}],
total: 1,
pageSize: 20,
offset: 0,
},
},
"support",
)}
/>,
);
expect(html).toContain("/ase/people/support/tickets/7");
expect(html).not.toContain("/admin");
expect(html).not.toContain("/mod/");
const detail = renderToStaticMarkup(
<PeopleTicketDetailPage
context={context([PERMS.MOD_TICKETS_EDIT])}
result={ok(
{
kind: "ticket" as const,
ticket: {
source: "cms" as const,
id: 7,
subject: "Need help",
status: "open",
priority: "normal",
creatorId: 9,
creatorUsername: "visitor",
updatedAt: null,
href: "/ase/people/support/tickets/7" as const,
category: "general",
assigneeId: null,
messages: [],
queue: { tickets: 1, helpTickets: 1, cfh: 1, activeBans: 1 },
staff: [],
},
},
"detail",
)}
/>,
);
expect(detail).toContain('data-housekeeping-command="people.ticket.reply"');
expect(detail).toContain('data-housekeeping-command="people.ticket.assign"');
});
it("renders CFH and quick moderation forms with mod.* only", () => {
const cfh = renderToStaticMarkup(
<PeopleCfhDetailPage
context={context([PERMS.MOD_CFH_VIEW, PERMS.MOD_CFH_EDIT])}
result={ok(
{
kind: "cfh-detail" as const,
ticket: {
id: 4, state: 0, senderId: 2, senderUsername: "sender",
reportedId: 3, reportedUsername: "reported", moderatorId: 0,
issue: "spam", createdAt: null,
href: "/ase/people/moderation/cfh/4" as const,
roomId: 0, activeBan: null,
},
},
"cfh",
)}
/>,
);
expect(cfh).toContain('data-housekeeping-command="people.cfh.resolve"');
expect(cfh).toContain('data-housekeeping-command="people.cfh.sanction"');
const moderation = renderToStaticMarkup(
<PeopleModerationPage
context={context([PERMS.MOD_ACTIONS])}
result={ok(
{
kind: "overview" as const,
snapshot: {
tickets: 1, helpTickets: 1, cfh: 1, activeBans: 1,
staffOnline: 1, recentActions: 1,
},
},
"moderation",
)}
/>,
);
expect(moderation).toContain(
'data-housekeeping-command="people.moderation.action"',
);
expect(moderation).not.toContain("admin.dashboard");
});
it("wires BIGINT help-ticket reply, reopen, and unban commands", () => {
const html = renderToStaticMarkup(
<PeopleHelpTicketDetailPage
context={context([PERMS.MOD_TICKETS_EDIT, PERMS.USERS_BAN])}
result={ok(
{
kind: "help-ticket" as const,
ticket: {
id: "9007199254740993",
title: "Ban appeal",
open: false,
userId: 7,
username: "visitor",
updatedAt: "2026-08-29T00:00:00.000Z",
replyCount: 1,
href: "/ase/people/support/help-tickets/9007199254740993" as const,
categoryId: "2",
categoryName: "Appeal",
content: "Please review",
replies: [{
id: "9007199254740994",
userId: 7,
username: "visitor",
content: "More context",
createdAt: "2026-08-29T00:00:00.000Z",
}],
queue: { tickets: 1, helpTickets: 1, cfh: 1, activeBans: 1 },
staff: [],
activeBan: {
id: 3,
userId: 7,
username: "visitor",
staffId: 42,
staffUsername: "mod",
type: "account",
reason: "spam",
createdAt: "2026-08-29T00:00:00.000Z",
expiresAt: 0,
active: true,
},
},
},
"help-detail",
)}
/>,
);
expect(html).toContain('data-housekeeping-command="people.help-ticket.reply"');
expect(html).toContain('data-housekeeping-command="people.help-ticket.status"');
expect(html).toContain('data-housekeeping-command="people.help-ticket.unban"');
expect(html).not.toContain("/admin");
expect(html).not.toContain("/mod/");
});
it("renders the loading state before data arrives", () => {
expect(
renderToStaticMarkup(<PeopleSupportPage context={context([])} />),
).toContain('data-housekeeping-state="loading"');
});
});
@@ -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 ?? {}),
@@ -0,0 +1,139 @@
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, parsePeopleListInput } from "./page-state";
import { PeopleCommandForm } from "./people-command-form";
interface Props {
readonly context: HousekeepingCapabilityContext;
readonly result?: HousekeepingResult<PeopleSupportQueryData>;
}
function empty(data: PeopleSupportQueryData): boolean {
return "page" in data ? data.page.items.length === 0 : false;
}
function Queue({ queue }: { readonly queue: { tickets: number; helpTickets: number; cfh: number; activeBans: number } }) {
return (
<dl className="grid gap-3 sm:grid-cols-4">
{Object.entries(queue).map(([label, value]) => (
<div key={label} className="rounded border border-[var(--admin-border)] p-3">
<dt>{label}</dt><dd>{value}</dd>
</div>
))}
</dl>
);
}
export function PeopleSupportPage({ context, result }: Props) {
return (
<PeoplePageFrame
title="Support"
description="Review ticket queues, assignments, templates, and help requests."
result={result}
isEmpty={empty}
>
{(data) => {
if (data.kind === "queue") return <Queue queue={data.queue} />;
if (data.kind === "ticket-templates") {
return (
<div className="space-y-4">
{context.has(PERMS.TICKETS_EDIT) ? (
<PeopleCommandForm
commandId="people.ticket-template.change"
buttonLabel="Create template"
input={{ action: "create" }}
fields={[
{ name: "title", label: "Title", type: "text", required: true, maxLength: 255 },
{ name: "content", label: "Content", type: "text", required: true, maxLength: 5000 },
{ name: "category", label: "Category", type: "text", maxLength: 50, defaultValue: "general" },
{ name: "sortOrder", label: "Sort order", type: "number", min: 0, defaultValue: 0 },
]}
/>
) : null}
<ul className="space-y-2">
{data.page.items.map((template) => (
<li key={template.id} className="rounded border border-[var(--admin-border)] p-3">
<h2>{template.title}</h2>
<p>{template.content}</p>
{context.has(PERMS.TICKETS_EDIT) ? (
<PeopleCommandForm
commandId="people.ticket-template.change"
buttonLabel="Delete template"
input={{ action: "delete", id: template.id }}
/>
) : null}
</li>
))}
</ul>
</div>
);
}
if (data.kind === "help-tickets") {
return (
<ul className="space-y-2">
{data.page.items.map((ticket) => (
<li key={ticket.id} className="rounded border border-[var(--admin-border)] p-3">
<a href={ticket.href}>{ticket.title}</a>
<p>{ticket.open ? "Open" : "Closed"} · {ticket.replyCount} replies</p>
</li>
))}
</ul>
);
}
if (data.kind !== "tickets" && data.kind !== "ticket-desk") {
return null;
}
const queue = data.kind === "ticket-desk" ? data.queue : null;
return (
<div className="space-y-4">
{queue ? <Queue queue={queue} /> : null}
<form method="get" className="flex gap-2">
<input name="search" maxLength={100} aria-label="Search tickets" />
<button type="submit">Search</button>
</form>
<ul className="space-y-2">
{data.page.items.map((ticket) => (
<li key={`${ticket.source}:${ticket.id}`} className="rounded border border-[var(--admin-border)] p-3">
<a href={ticket.href}>{ticket.subject}</a>
<p>{ticket.status} · {ticket.priority}</p>
</li>
))}
</ul>
</div>
);
}}
</PeoplePageFrame>
);
}
export async function renderPeopleSupportPage(input: HousekeepingPageInput) {
const routeId = input.match.routeId;
if (
routeId !== "people.support.tickets" &&
routeId !== "people.support.ticket-desk" &&
routeId !== "people.support.ticket-templates" &&
routeId !== "people.support.help-tickets"
) {
return (
<PeopleSupportPage
context={input.context}
result={fail("NOT_FOUND", "errors.housekeeping.notFound", createCorrelationId())}
/>
);
}
const result = await peopleSupportQuery.run(input.context, {
routeId,
list: parsePeopleListInput(input.searchParams ?? {}),
});
return <PeopleSupportPage context={input.context} result={result} />;
}
@@ -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} />;
}
@@ -0,0 +1,126 @@
import { describe, expect, it } from "vitest";
import { PERMS } from "@/lib/permission-slugs";
import type { HousekeepingCapabilityContext } from "../../foundation/contracts";
import { anyCapability } from "../../foundation/contracts";
import {
createPeopleInboxSources,
PEOPLE_INBOX_SOURCE_IDS,
} from "./inbox";
import { peopleManifest } from "./manifest";
import {
createPeopleSearchProviders,
PEOPLE_SEARCH_PROVIDER_IDS,
} from "./search";
import { createPeopleWidgets } from "./widgets";
function context(granted: readonly string[]): HousekeepingCapabilityContext {
const permissions = new Set(granted);
return {
actor: { id: 42, username: "operator", rank: 4 },
isSuperAdmin: false,
has: (slug) => permissions.has(slug),
hasAny: (...slugs) => slugs.some((slug) => permissions.has(slug)),
hasAll: (...slugs) => slugs.every((slug) => permissions.has(slug)),
};
}
describe("People providers", () => {
it("declares exact provider IDs and no empty manifest collection", () => {
expect(PEOPLE_SEARCH_PROVIDER_IDS).toEqual([
"people.users", "people.guilds", "people.tickets",
]);
expect(PEOPLE_INBOX_SOURCE_IDS).toEqual([
"people.tickets", "people.help-tickets", "people.cfh", "people.active-bans",
]);
expect([
peopleManifest.routes,
peopleManifest.searchProviders,
peopleManifest.inboxSources,
peopleManifest.widgets,
].every((collection) => collection.length > 0)).toBe(true);
});
it("bounds search at 25 and item-filters capabilities and unsafe links", async () => {
const candidates = Array.from({ length: 40 }, (_, index) => ({
id: `candidate-${index}`,
title: `Candidate ${index}`,
href: index === 0
? ("https://example.invalid" as const)
: (`/ase/people/users/${index + 1}` as const),
capability: index === 1
? anyCapability(PERMS.USERS_EDIT)
: anyCapability(PERMS.MOD_USERS_VIEW),
}));
const result = await createPeopleSearchProviders({
users: async () => candidates,
guilds: async () => [],
tickets: async () => [],
})[0].search(context([PERMS.MOD_USERS_VIEW]), {
term: "candidate",
limit: 100,
});
expect(result.ok).toBe(true);
if (!result.ok) return;
expect(result.data).toHaveLength(25);
expect(result.data.every((item) => item.href.startsWith("/ase/"))).toBe(true);
expect(result.data.map((item) => item.id)).not.toContain("candidate-1");
});
it("bounds inbox sources and loads the mandatory real queue widget", async () => {
const items = Array.from({ length: 40 }, (_, index) => ({
sourceId: "people.tickets",
itemId: String(index),
deduplicationKey: `ticket:${index}`,
domain: "people" as const,
capability: index === 0
? anyCapability(PERMS.TICKETS_EDIT)
: anyCapability(PERMS.MOD_TICKETS_VIEW),
severity: "info" as const,
priority: "normal" as const,
ageMs: 0,
state: "open",
occurredAt: "2026-08-29T00:00:00.000Z",
titleKey: "pages.housekeeping.items.ticket",
href: index === 1
? (`/ase/people/support/tickets/${index + 1}\u0000` as const)
: (`/ase/people/support/tickets/${index + 1}` as const),
freshness: "fresh" as const,
actions: [],
}));
const inbox = await createPeopleInboxSources({
tickets: async () => items,
helpTickets: async () => [],
cfh: async () => [],
activeBans: async () => [],
})[0].getItems(
context([PERMS.MOD_TICKETS_VIEW]),
new AbortController().signal,
);
expect(inbox.ok).toBe(true);
if (inbox.ok) {
expect(inbox.data.items).toHaveLength(25);
expect(inbox.data.items.map((item) => item.itemId)).not.toContain("0");
expect(inbox.data.items.map((item) => item.itemId)).not.toContain("1");
}
const widgets = createPeopleWidgets({
queue: async () => ({
tickets: 1, helpTickets: 2, cfh: 3, activeBans: 4,
}),
});
expect(widgets[0]).toMatchObject({
id: "people.queue",
owner: "people",
kind: "mandatory",
});
expect(
await widgets[0].load(
context([PERMS.MOD_TICKETS_VIEW]),
new AbortController().signal,
),
).toMatchObject({
ok: true,
data: { tickets: 1, helpTickets: 2, cfh: 0, activeBans: 0 },
});
});
});
@@ -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),
@@ -54,7 +54,7 @@ export interface PeopleSupportAdapters {
loadHelpTickets(
input: ReturnType<typeof normalizePeopleListInput>,
): Promise<SupportRows<PeopleHelpTicketSummary>>;
loadHelpTicket(id: number): Promise<PeopleHelpTicketRecord | null>;
loadHelpTicket(id: string): Promise<PeopleHelpTicketRecord | null>;
loadSupportStaff(): Promise<readonly PeopleSupportStaff[]>;
loadActiveBan(userId: number): Promise<PeopleBanSummary | null>;
}
@@ -74,7 +74,7 @@ export type PeopleSupportQueryInput =
}
| {
readonly routeId: "people.support.help-ticket-detail";
readonly id: number;
readonly id: string;
};
export type PeopleSupportQueryData =
@@ -145,16 +145,42 @@ export function createPeopleSupportQuery(
return ok({ kind: "queue" as const, queue }, correlationId);
}
if (
input.routeId === "people.support.ticket-detail" ||
input.routeId === "people.support.help-ticket-detail"
) {
if (input.routeId === "people.support.ticket-detail") {
const invalid = invalidId(input.id, correlationId);
if (invalid !== null) return invalid;
const ticket =
input.routeId === "people.support.ticket-detail"
? await adapters.loadTicket(input.id)
: await adapters.loadHelpTicket(input.id);
const ticket = await adapters.loadTicket(input.id);
if (ticket === null) {
return fail(
"NOT_FOUND",
"errors.housekeeping.notFound",
correlationId,
);
}
const [queue, staff] = await Promise.all([
adapters.loadQueue(),
adapters.loadSupportStaff(),
]);
const hydrated = { ...ticket, queue, staff };
assertPeopleSerializable(hydrated);
return ok(
{ kind: "ticket" as const, ticket: hydrated },
correlationId,
);
}
if (input.routeId === "people.support.help-ticket-detail") {
let id: string;
try {
id = peopleHelpTicketHref(input.id).split("/").at(-1) ?? "";
} catch {
return fail(
"VALIDATION",
"errors.housekeeping.validation",
correlationId,
{ id: ["invalid"] },
);
}
const ticket = await adapters.loadHelpTicket(id);
if (ticket === null) {
return fail(
"NOT_FOUND",
@@ -162,37 +188,19 @@ export function createPeopleSupportQuery(
correlationId,
);
}
const helpTicket =
input.routeId === "people.support.help-ticket-detail"
? (ticket as PeopleHelpTicketRecord)
: null;
const [queue, staff, activeBan] = await Promise.all([
adapters.loadQueue(),
adapters.loadSupportStaff(),
helpTicket?.userId !== null && helpTicket?.userId !== undefined
? adapters.loadActiveBan(helpTicket.userId)
: Promise.resolve(null),
ticket.userId === null
? Promise.resolve(null)
: adapters.loadActiveBan(ticket.userId),
]);
const hydrated =
input.routeId === "people.support.ticket-detail"
? { ...ticket, queue, staff }
: { ...ticket, queue, staff, activeBan };
const hydrated = { ...ticket, queue, staff, activeBan };
assertPeopleSerializable(hydrated);
return input.routeId === "people.support.ticket-detail"
? ok(
{
kind: "ticket" as const,
ticket: hydrated as PeopleTicketDetail,
},
correlationId,
)
: ok(
{
kind: "help-ticket" as const,
ticket: hydrated as PeopleHelpTicketDetail,
},
correlationId,
);
return ok(
{ kind: "help-ticket" as const, ticket: hydrated },
correlationId,
);
}
const allowedSorts =
@@ -309,7 +317,8 @@ const peopleSupportAdapters: PeopleSupportAdapters = {
order: input.order,
});
const rows = result.rows.map((row) => {
const id = Number(row.id);
const id =
row.kind === "cms" ? Number(row.id) : BigInt(String(row.id)).toString();
return {
source: row.kind,
id,
@@ -320,7 +329,9 @@ const peopleSupportAdapters: PeopleSupportAdapters = {
creatorUsername: row.user === "—" ? null : row.user,
updatedAt: row.sortAt === 0 ? null : toPeopleIsoDate(row.sortAt),
href:
row.kind === "cms" ? peopleTicketHref(id) : peopleHelpTicketHref(id),
row.kind === "cms"
? peopleTicketHref(id as number)
: peopleHelpTicketHref(id),
};
});
return {
@@ -527,14 +538,14 @@ const peopleSupportAdapters: PeopleSupportAdapters = {
updatedAt: Date | string | null;
replyCount: number | bigint;
}>(rowsResult).map((row) => ({
id: Number(row.id),
id: BigInt(String(row.id)).toString(),
title: row.title,
open: Boolean(row.open),
userId: row.userId === null ? null : Number(row.userId),
username: row.username,
updatedAt: toPeopleIsoDate(row.updatedAt),
replyCount: Number(row.replyCount),
href: peopleHelpTicketHref(Number(row.id)),
href: peopleHelpTicketHref(row.id),
}));
return {
rows,
@@ -554,14 +565,14 @@ const peopleSupportAdapters: PeopleSupportAdapters = {
FROM website_help_center_tickets t
LEFT JOIN users u ON u.id = t.user_id
LEFT JOIN website_help_center_categories c ON c.id = t.category_id
WHERE t.id = ${id} LIMIT 1
WHERE t.id = ${BigInt(id)} LIMIT 1
`),
db.execute(sql`
SELECT r.id, r.user_id AS userId, u.username, r.content,
r.created_at AS createdAt
FROM website_help_center_ticket_replies r
LEFT JOIN users u ON u.id = r.user_id
WHERE r.ticket_id = ${id}
WHERE r.ticket_id = ${BigInt(id)}
ORDER BY r.created_at ASC, r.id ASC
LIMIT 500
`),
@@ -579,17 +590,20 @@ const peopleSupportAdapters: PeopleSupportAdapters = {
}>(ticketResult)[0];
if (!row) return null;
return {
id: Number(row.id),
id: BigInt(String(row.id)).toString(),
title: row.title,
content: row.content,
open: Boolean(row.open),
userId: row.userId === null ? null : Number(row.userId),
username: row.username,
categoryId: row.categoryId === null ? null : Number(row.categoryId),
categoryId:
row.categoryId === null
? null
: BigInt(String(row.categoryId)).toString(),
categoryName: row.categoryName,
updatedAt: toPeopleIsoDate(row.updatedAt),
replyCount: resultRows<unknown>(repliesResult).length,
href: peopleHelpTicketHref(Number(row.id)),
href: peopleHelpTicketHref(row.id),
replies: resultRows<{
id: bigint | number;
userId: number;
@@ -597,7 +611,7 @@ const peopleSupportAdapters: PeopleSupportAdapters = {
content: string;
createdAt: Date | string | null;
}>(repliesResult).map((reply) => ({
id: Number(reply.id),
id: BigInt(String(reply.id)).toString(),
userId: Number(reply.userId),
username: reply.username,
content: reply.content,
@@ -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);
});
});
@@ -0,0 +1,185 @@
import "server-only";
import { PERMS } from "@/lib/permission-slugs";
import { authorizeHousekeeping } from "../../foundation/authorization";
import { satisfiesCapability } from "../../foundation/capability-context";
import {
anyCapability,
type CapabilityRequirement,
fail,
type HousekeepingCapabilityContext,
type HousekeepingSearchProvider,
ok,
} from "../../foundation/contracts";
import { peopleCommunityQuery } from "./queries/community";
import { peopleSupportQuery } from "./queries/support";
import { peopleUsersQuery } from "./queries/users";
export const PEOPLE_SEARCH_PROVIDER_IDS = [
"people.users",
"people.guilds",
"people.tickets",
] as const;
export interface PeopleSearchCandidate {
readonly id: string;
readonly title: string;
readonly description?: string;
readonly href: string;
readonly capability: CapabilityRequirement;
}
export interface PeopleSearchAdapters {
users(
context: HousekeepingCapabilityContext,
term: string,
limit: number,
): Promise<readonly PeopleSearchCandidate[]>;
guilds(
context: HousekeepingCapabilityContext,
term: string,
limit: number,
): Promise<readonly PeopleSearchCandidate[]>;
tickets(
context: HousekeepingCapabilityContext,
term: string,
limit: number,
): Promise<readonly PeopleSearchCandidate[]>;
}
function safeHref(href: string): href is `/ase/${string}` {
return (
href.startsWith("/ase/") &&
!href.includes("\\") &&
!Array.from(href).some((character) => {
const code = character.codePointAt(0) ?? 0;
return code < 32 || code === 127;
})
);
}
function boundedLimit(limit: number): number {
return Number.isFinite(limit) ? Math.min(25, Math.max(1, Math.trunc(limit))) : 25;
}
function createProvider(
id: (typeof PEOPLE_SEARCH_PROVIDER_IDS)[number],
capability: CapabilityRequirement,
load: PeopleSearchAdapters[keyof PeopleSearchAdapters],
): HousekeepingSearchProvider {
return {
id,
owner: "people",
capability,
async search(context, input) {
const authorization = authorizeHousekeeping(context, capability);
if (!authorization.ok) return authorization;
const limit = boundedLimit(input.limit);
const term = input.term.normalize("NFC").trim().slice(0, 128);
try {
const candidates = await load(context, term, limit);
return ok(
candidates
.filter(
(item) =>
safeHref(item.href) &&
satisfiesCapability(context, item.capability),
)
.slice(0, limit)
.map((item) => ({
...item,
domain: "people" as const,
type: "entity" as const,
href: item.href as `/ase/${string}`,
})),
authorization.correlationId,
);
} catch {
return fail(
"DEPENDENCY_UNAVAILABLE",
"errors.housekeeping.dependencyUnavailable",
authorization.correlationId,
);
}
},
};
}
export function createPeopleSearchProviders(
adapters: PeopleSearchAdapters,
): readonly HousekeepingSearchProvider[] {
return [
createProvider(
"people.users",
anyCapability(PERMS.USERS_VIEW, PERMS.MOD_USERS_VIEW),
adapters.users,
),
createProvider(
"people.guilds",
anyCapability(PERMS.USERS_VIEW),
adapters.guilds,
),
createProvider(
"people.tickets",
anyCapability(PERMS.TICKETS_VIEW, PERMS.MOD_TICKETS_VIEW),
adapters.tickets,
),
];
}
const productionAdapters: PeopleSearchAdapters = {
async users(context, term, limit) {
const result = await peopleUsersQuery.run(context, {
routeId: "people.users.list",
list: { search: term, pageSize: limit, offset: 0, sort: "username" },
});
if (!result.ok || result.data.kind !== "users") {
if (!result.ok) throw new Error(result.error.code);
return [];
}
return result.data.page.items.map((user) => ({
id: `user-${user.id}`,
title: user.username,
description: `User #${user.id}`,
href: user.href,
capability: anyCapability(PERMS.USERS_VIEW, PERMS.MOD_USERS_VIEW),
}));
},
async guilds(context, term, limit) {
const result = await peopleCommunityQuery.run(context, {
routeId: "people.community.guilds",
list: { search: term, pageSize: limit, offset: 0, sort: "name" },
});
if (!result.ok || result.data.kind !== "guilds") {
if (!result.ok) throw new Error(result.error.code);
return [];
}
return result.data.page.items.map((guild) => ({
id: `guild-${guild.id}`,
title: guild.name,
description: `${guild.memberCount} members`,
href: guild.href,
capability: anyCapability(PERMS.USERS_VIEW),
}));
},
async tickets(context, term, limit) {
const result = await peopleSupportQuery.run(context, {
routeId: "people.support.tickets",
list: { search: term, pageSize: limit, offset: 0, sort: "id" },
});
if (!result.ok || result.data.kind !== "tickets") {
if (!result.ok) throw new Error(result.error.code);
return [];
}
return result.data.page.items.map((ticket) => ({
id: `${ticket.source}-ticket-${ticket.id}`,
title: ticket.subject,
description: `${ticket.status} · ${ticket.creatorUsername ?? "unknown"}`,
href: ticket.href,
capability: anyCapability(PERMS.TICKETS_VIEW, PERMS.MOD_TICKETS_VIEW),
}));
},
};
export const PEOPLE_SEARCH_PROVIDERS =
createPeopleSearchProviders(productionAdapters);
@@ -17,23 +17,34 @@ import {
Items,
Rooms,
Sanctions,
SupportTickets,
User,
UsersBadges,
UsersCurrency,
UsersSettings,
WebsiteHelpCenterTicketReplies,
WebsiteHelpCenterTickets,
WebsiteIpBlacklist,
WebsiteIpWhitelist,
WebsiteSetting,
WebsiteStaffApplications,
WebsiteTeams,
WebsiteTicket,
WebsiteTicketMessage,
WebsiteTicketTemplate,
WebsiteWordfilter,
} from "@/lib/db";
import { PERMS } from "@/lib/permission-slugs";
import { logAudit } from "@/lib/services/audit";
import { reloadWordFilter } from "@/lib/services/moderation";
import {
executeModerationAction,
type ModerationAction,
reloadWordFilter,
} from "@/lib/services/moderation";
import { rcon } from "@/lib/services/rcon";
import { siteSettings } from "@/lib/services/site-settings";
import { logStaffActivity } from "@/lib/services/staff-activity";
import { canonicalTicketId } from "@/lib/services/ticket-replies";
import { notify } from "@/lib/services/webhook";
import { satisfiesCapability } from "../../../foundation/capability-context";
import {
@@ -67,7 +78,20 @@ export type PeopleMutationOperation =
| "team.change"
| "ip.action"
| "vpn.configure"
| "word-filter.update";
| "word-filter.update"
| "ticket.reply"
| "ticket.assign"
| "ticket.status"
| "ticket.priority"
| "ticket-template.change"
| "help-ticket.reply"
| "help-ticket.status"
| "help-ticket.unban"
| "cfh.resolve"
| "cfh.sanction"
| "ban.create"
| "ban.lift"
| "moderation.action";
export interface PeopleMutationSnapshot {
readonly before: Readonly<Record<string, unknown>> | null;
@@ -121,9 +145,31 @@ class PeopleMutationFailure extends Error {
class ConfirmedExternalNoopFailure extends PeopleMutationFailure {}
function operationCapability(operation: PeopleMutationOperation) {
if (operation === "user.ban" || operation === "user.unban") {
if (
operation === "user.ban" ||
operation === "user.unban" ||
operation === "ban.create" ||
operation === "ban.lift" ||
operation === "help-ticket.unban"
) {
return anyCapability(PERMS.USERS_BAN);
}
if (
operation.startsWith("ticket.") ||
operation === "help-ticket.reply" ||
operation === "help-ticket.status"
) {
return anyCapability(PERMS.TICKETS_EDIT, PERMS.MOD_TICKETS_EDIT);
}
if (operation === "ticket-template.change") {
return anyCapability(PERMS.TICKETS_EDIT);
}
if (operation === "cfh.resolve" || operation === "cfh.sanction") {
return anyCapability(PERMS.MODERATION_EDIT, PERMS.MOD_CFH_EDIT);
}
if (operation === "moderation.action") {
return anyCapability(PERMS.MODERATION_EDIT, PERMS.MOD_ACTIONS);
}
if (operation === "user.reset-password") {
return anyCapability(PERMS.USERS_RESET_PASSWORD);
}
@@ -1755,6 +1801,657 @@ async function executeWordFilterUpdate(
},
);
}
type TicketMutationOperation = Extract<
PeopleMutationOperation,
`ticket.${string}`
>;
async function executeTicketMutation(
operation: TicketMutationOperation,
input: unknown,
context: PeopleMutationContext,
): Promise<PeopleMutationSnapshot> {
const data = record(input);
const ticketId = positiveInteger(data.ticketId);
let snapshot!: PeopleMutationSnapshot;
await db.transaction(async (tx) => {
const [ticket] = await tx
.select({
id: WebsiteTicket.id,
assigneeId: WebsiteTicket.assigneeId,
status: WebsiteTicket.status,
priority: WebsiteTicket.priority,
})
.from(WebsiteTicket)
.where(eq(WebsiteTicket.id, ticketId))
.limit(1);
if (!ticket) {
throw new PeopleMutationFailure(
"NOT_FOUND",
"errors.housekeeping.notFound",
);
}
const before = {
id: ticket.id,
assigneeId: ticket.assigneeId,
status: ticket.status,
priority: ticket.priority,
};
const now = new Date();
if (operation === "ticket.reply") {
const message = normalizedText(data.message, 5_000);
await tx.insert(WebsiteTicketMessage).values({
ticketId,
userId: context.capability.actor.id,
message,
isStaff: 1,
});
const assigneeId = ticket.assigneeId ?? context.capability.actor.id;
await tx
.update(WebsiteTicket)
.set({ status: "waiting", assigneeId, updatedAt: now })
.where(eq(WebsiteTicket.id, ticketId));
snapshot = {
before,
after: { ...before, assigneeId, status: "waiting" },
};
} else if (operation === "ticket.assign") {
const assigneeId =
data.assigneeId === null ? null : positiveInteger(data.assigneeId);
const status = assigneeId === null ? "open" : "in_progress";
await tx
.update(WebsiteTicket)
.set({ assigneeId, status, updatedAt: now })
.where(eq(WebsiteTicket.id, ticketId));
snapshot = { before, after: { ...before, assigneeId, status } };
} else if (operation === "ticket.status") {
const status = normalizedText(data.status, 32);
if (!["open", "in_progress", "waiting", "closed"].includes(status)) {
throw new PeopleMutationFailure(
"VALIDATION",
"errors.housekeeping.validation",
);
}
const assigneeId =
status === "in_progress" && ticket.assigneeId === null
? context.capability.actor.id
: ticket.assigneeId;
await tx
.update(WebsiteTicket)
.set({
status,
assigneeId,
updatedAt: now,
...(status === "closed" ? { closedAt: now } : {}),
})
.where(eq(WebsiteTicket.id, ticketId));
snapshot = { before, after: { ...before, assigneeId, status } };
} else {
const priority = normalizedText(data.priority, 32);
if (!["low", "normal", "high", "urgent"].includes(priority)) {
throw new PeopleMutationFailure(
"VALIDATION",
"errors.housekeeping.validation",
);
}
await tx
.update(WebsiteTicket)
.set({ priority, updatedAt: now })
.where(eq(WebsiteTicket.id, ticketId));
snapshot = { before, after: { ...before, priority } };
}
await logAudit(
canonicalAuditEntry(
context,
operation,
"WebsiteTicket",
ticketId,
snapshot,
"success",
),
tx,
);
});
return snapshot;
}
async function executeTicketTemplateChange(
input: unknown,
context: PeopleMutationContext,
): Promise<PeopleMutationSnapshot> {
const data = record(input);
const action = normalizedText(data.action, 16);
let snapshot!: PeopleMutationSnapshot;
await db.transaction(async (tx) => {
let targetId: number | undefined;
if (action === "create") {
const values = {
title: normalizedText(data.title, 255),
content: normalizedText(data.content, 5_000),
category: normalizedText(data.category ?? "general", 50),
sortOrder:
data.sortOrder === undefined ? 0 : nonNegativeInteger(data.sortOrder),
};
const [result] = await tx.insert(WebsiteTicketTemplate).values(values);
targetId = positiveInteger(result.insertId);
snapshot = { before: null, after: { id: targetId, ...values } };
} else {
const id = positiveInteger(data.id);
targetId = id;
const [existing] = await tx
.select({
id: WebsiteTicketTemplate.id,
title: WebsiteTicketTemplate.title,
content: WebsiteTicketTemplate.content,
category: WebsiteTicketTemplate.category,
sortOrder: WebsiteTicketTemplate.sortOrder,
})
.from(WebsiteTicketTemplate)
.where(eq(WebsiteTicketTemplate.id, id))
.limit(1);
if (action === "update" && !existing) {
throw new PeopleMutationFailure(
"NOT_FOUND",
"errors.housekeeping.notFound",
);
}
if (action === "delete") {
await tx
.delete(WebsiteTicketTemplate)
.where(eq(WebsiteTicketTemplate.id, id));
snapshot = { before: existing ?? null, after: null };
} else if (action === "update") {
const changes = {
...(data.title === undefined
? {}
: { title: normalizedText(data.title, 255) }),
...(data.content === undefined
? {}
: { content: normalizedText(data.content, 5_000) }),
...(data.category === undefined
? {}
: { category: normalizedText(data.category, 50, false) }),
...(data.sortOrder === undefined
? {}
: { sortOrder: nonNegativeInteger(data.sortOrder) }),
};
await tx
.update(WebsiteTicketTemplate)
.set(changes)
.where(eq(WebsiteTicketTemplate.id, id));
snapshot = { before: existing ?? null, after: { ...existing, ...changes } };
} else {
throw new PeopleMutationFailure(
"VALIDATION",
"errors.housekeeping.validation",
);
}
}
await logAudit(
canonicalAuditEntry(
context,
"ticket-template.change",
"WebsiteTicketTemplate",
targetId,
snapshot,
"success",
),
tx,
);
});
return snapshot;
}
async function executeHelpTicketMutation(
operation: Extract<PeopleMutationOperation, `help-ticket.${string}`>,
input: unknown,
context: PeopleMutationContext,
): Promise<PeopleMutationSnapshot> {
const data = record(input);
let ticketId: bigint;
try {
ticketId = canonicalTicketId(data.ticketId as string);
} catch {
throw new PeopleMutationFailure(
"VALIDATION",
"errors.housekeeping.validation",
);
}
let snapshot!: PeopleMutationSnapshot;
await db.transaction(async (tx) => {
const [ticket] = await tx
.select({
id: WebsiteHelpCenterTickets.id,
userId: WebsiteHelpCenterTickets.userId,
open: WebsiteHelpCenterTickets.open,
title: WebsiteHelpCenterTickets.title,
})
.from(WebsiteHelpCenterTickets)
.where(eq(WebsiteHelpCenterTickets.id, ticketId))
.limit(1);
if (!ticket) {
throw new PeopleMutationFailure(
"NOT_FOUND",
"errors.housekeeping.notFound",
);
}
const id = ticketId.toString();
const before = {
id,
userId: ticket.userId,
open: Boolean(ticket.open),
title: ticket.title,
};
const now = new Date();
if (operation === "help-ticket.reply") {
await tx.insert(WebsiteHelpCenterTicketReplies).values({
ticketId,
userId: context.capability.actor.id,
content: normalizedText(data.content, 5_000),
createdAt: now,
updatedAt: now,
});
await tx
.update(WebsiteHelpCenterTickets)
.set({ updatedAt: now })
.where(eq(WebsiteHelpCenterTickets.id, ticketId));
snapshot = { before, after: before };
} else if (operation === "help-ticket.status") {
const status = normalizedText(data.status, 16);
const open =
status === "reopen"
? true
: status === "close"
? false
: null;
if (open === null) {
throw new PeopleMutationFailure(
"VALIDATION",
"errors.housekeeping.validation",
);
}
if (Boolean(ticket.open) === open) {
throw new PeopleMutationFailure(
"CONFLICT",
"errors.housekeeping.conflict",
);
}
await tx
.update(WebsiteHelpCenterTickets)
.set({ open, updatedAt: now })
.where(eq(WebsiteHelpCenterTickets.id, ticketId));
snapshot = { before, after: { ...before, open } };
} else {
if (ticket.userId === null) {
throw new PeopleMutationFailure(
"CONFLICT",
"errors.housekeeping.conflict",
);
}
const deleted = await tx.delete(Ban).where(eq(Ban.userId, ticket.userId));
const removed = Number(
(deleted as unknown as [{ affectedRows: number }])[0]?.affectedRows ?? 0,
);
if (ticket.open) {
await tx
.update(WebsiteHelpCenterTickets)
.set({ open: false, updatedAt: now })
.where(eq(WebsiteHelpCenterTickets.id, ticketId));
}
snapshot = {
before,
after: { ...before, open: false, removedBans: removed },
output: { removed, userId: ticket.userId },
};
}
await logAudit(
canonicalAuditEntry(
context,
operation,
"WebsiteHelpCenterTickets",
auditTargetId(ticketId),
snapshot,
"success",
),
tx,
);
});
return snapshot;
}
async function executeCfhResolve(
input: unknown,
context: PeopleMutationContext,
): Promise<PeopleMutationSnapshot> {
const data = record(input);
const ticketId = positiveInteger(data.ticketId);
const state = nonNegativeInteger(data.state);
if (state > 3) {
throw new PeopleMutationFailure(
"VALIDATION",
"errors.housekeeping.validation",
);
}
let snapshot!: PeopleMutationSnapshot;
await db.transaction(async (tx) => {
const [ticket] = await tx
.select({
id: SupportTickets.id,
state: SupportTickets.state,
modId: SupportTickets.modId,
})
.from(SupportTickets)
.where(eq(SupportTickets.id, ticketId))
.limit(1);
if (!ticket) {
throw new PeopleMutationFailure(
"NOT_FOUND",
"errors.housekeeping.notFound",
);
}
snapshot = {
before: { id: ticket.id, state: ticket.state, moderatorId: ticket.modId },
after: {
id: ticket.id,
state,
moderatorId: context.capability.actor.id,
},
};
await tx
.update(SupportTickets)
.set({ state, modId: context.capability.actor.id })
.where(eq(SupportTickets.id, ticketId));
await logAudit(
canonicalAuditEntry(
context,
"cfh.resolve",
"support_tickets",
ticketId,
snapshot,
"success",
),
tx,
);
});
return snapshot;
}
function moderationAction(data: Record<string, unknown>): ModerationAction {
const action = normalizedText(data.action, 32);
if (action === "kick" || action === "unmute") {
return { action, userId: positiveInteger(data.userId) };
}
if (action === "mute") {
const duration = nonNegativeInteger(data.duration);
if (duration > 525_600) {
throw new PeopleMutationFailure(
"VALIDATION",
"errors.housekeeping.validation",
);
}
return { action, userId: positiveInteger(data.userId), duration };
}
if (action === "alert") {
return {
action,
userId: positiveInteger(data.userId),
message: normalizedText(data.message, 500),
};
}
if (action === "room-kick") {
return { action, roomId: positiveInteger(data.roomId) };
}
if (action === "broadcast") {
const type = normalizedText(data.type, 16);
if (type !== "hotel" && type !== "staff") {
throw new PeopleMutationFailure(
"VALIDATION",
"errors.housekeeping.validation",
);
}
return {
action,
type,
message: normalizedText(data.message, 500),
};
}
throw new PeopleMutationFailure(
"VALIDATION",
"errors.housekeeping.validation",
);
}
function actionTarget(input: ModerationAction): {
target: string;
targetId: number | undefined;
} {
if ("userId" in input) return { target: "User", targetId: input.userId };
if ("roomId" in input) return { target: "Room", targetId: input.roomId };
return { target: "broadcast", targetId: undefined };
}
async function deliverModerationAction(input: ModerationAction): Promise<void> {
await requireRcon(await executeModerationAction(rcon, input));
}
async function executeModerationMutation(
input: unknown,
context: PeopleMutationContext,
): Promise<PeopleMutationSnapshot> {
const action = moderationAction(record(input));
const target = actionTarget(action);
const snapshot = {
before: null,
after: { ...action },
} satisfies PeopleMutationSnapshot;
return runExternalWithAudit(
context,
"moderation.action",
target.target,
target.targetId,
snapshot,
() => deliverModerationAction(action),
{ before: null, after: null },
);
}
async function executeCfhSanction(
input: unknown,
context: PeopleMutationContext,
): Promise<PeopleMutationSnapshot> {
const data = record(input);
const ticketId = positiveInteger(data.ticketId);
const action = moderationAction({
...data,
message: data.message ?? data.reason,
});
const reason = normalizedText(data.reason, 500);
let snapshot!: PeopleMutationSnapshot;
await db.transaction(async (tx) => {
const [ticket] = await tx
.select({
id: SupportTickets.id,
state: SupportTickets.state,
modId: SupportTickets.modId,
})
.from(SupportTickets)
.where(eq(SupportTickets.id, ticketId))
.limit(1);
if (!ticket) {
throw new PeopleMutationFailure(
"NOT_FOUND",
"errors.housekeeping.notFound",
);
}
snapshot = {
before: { id: ticket.id, state: ticket.state, moderatorId: ticket.modId },
after: {
id: ticket.id,
state: 2,
moderatorId: context.capability.actor.id,
sanction: action.action,
reason,
},
};
await tx
.update(SupportTickets)
.set({ state: 2, modId: context.capability.actor.id })
.where(eq(SupportTickets.id, ticketId));
await logAudit(
canonicalAuditEntry(
context,
"cfh.sanction",
"support_tickets",
ticketId,
snapshot,
"intent",
),
tx,
);
});
return finalizeExternalWithAudit(
context,
"cfh.sanction",
"support_tickets",
ticketId,
snapshot,
() => deliverModerationAction(action),
);
}
async function executeBanMutation(
operation: "ban.create" | "ban.lift",
input: unknown,
context: PeopleMutationContext,
): Promise<PeopleMutationSnapshot> {
const data = record(input);
if (operation === "ban.create") {
const userId = positiveInteger(data.userId);
const hours = nonNegativeInteger(data.hours);
const type = normalizedText(data.type, 16);
const reason = normalizedText(data.reason, 500);
if (
hours > 876_000 ||
!["account", "ip", "machine", "super"].includes(type)
) {
throw new PeopleMutationFailure(
"VALIDATION",
"errors.housekeeping.validation",
);
}
const now = Math.floor(Date.now() / 1_000);
const banExpire = hours > 0 ? now + hours * 3_600 : 0;
let username: string | null = null;
let banId = 0;
let snapshot!: PeopleMutationSnapshot;
await db.transaction(async (tx) => {
const [user] = await tx
.select({ username: User.username })
.from(User)
.where(eq(User.id, userId))
.limit(1);
username = user?.username ?? null;
const [result] = await tx.insert(Ban).values({
userId,
ip: "",
machineId: "",
userStaffId: context.capability.actor.id,
timestamp: now,
banExpire,
banReason: reason,
type: type as "account" | "ip" | "machine" | "super",
cfhTopic: -1,
});
banId = positiveInteger(result.insertId);
snapshot = {
before: null,
after: {
id: banId,
userId,
type,
reason,
expiresAt: banExpire,
},
};
await logAudit(
canonicalAuditEntry(
context,
operation,
"Ban",
banId,
snapshot,
"intent",
),
tx,
);
});
return finalizeExternalWithAudit(
context,
operation,
"Ban",
banId,
snapshot,
async () => {
let delivered = true;
if (username !== null) {
delivered = await rcon.disconnectUser(userId, username);
}
await logStaffActivity({
staffId: context.capability.actor.id,
action: "user_ban",
description: `Banned user #${userId} (${type}, ${
hours > 0 ? `${hours}h` : "permanent"
}): ${reason}`,
targetType: "user",
targetId: userId,
});
await requireRcon(delivered);
},
);
}
const id = positiveInteger(data.id);
let snapshot!: PeopleMutationSnapshot;
await db.transaction(async (tx) => {
const [existing] = await tx
.select({
id: Ban.id,
userId: Ban.userId,
reason: Ban.banReason,
type: Ban.type,
})
.from(Ban)
.where(eq(Ban.id, id))
.limit(1);
await tx.delete(Ban).where(eq(Ban.id, id));
snapshot = { before: existing ?? null, after: null };
await logAudit(
canonicalAuditEntry(
context,
operation,
"Ban",
id,
snapshot,
"intent",
),
tx,
);
});
return finalizeExternalWithAudit(
context,
operation,
"Ban",
id,
snapshot,
() =>
logStaffActivity({
staffId: context.capability.actor.id,
action: "ban_lift",
description: `Lifted ban #${id}`,
}),
);
}
const productionPeopleMutationAdapter: PeopleMutationAdapter = {
async execute(operation, input, context) {
if (operation.startsWith("user.")) {
@@ -1781,7 +2478,34 @@ const productionPeopleMutationAdapter: PeopleMutationAdapter = {
if (operation === "vpn.configure") {
return executeVpnConfiguration(input, context);
}
return executeWordFilterUpdate(input, context);
if (operation === "word-filter.update") {
return executeWordFilterUpdate(input, context);
}
if (operation.startsWith("ticket.")) {
return executeTicketMutation(operation as TicketMutationOperation, input, context);
}
if (operation === "ticket-template.change") {
return executeTicketTemplateChange(input, context);
}
if (operation.startsWith("help-ticket.")) {
return executeHelpTicketMutation(
operation as Extract<PeopleMutationOperation, `help-ticket.${string}`>,
input,
context,
);
}
if (operation === "cfh.resolve") return executeCfhResolve(input, context);
if (operation === "cfh.sanction") return executeCfhSanction(input, context);
if (operation === "ban.create" || operation === "ban.lift") {
return executeBanMutation(operation, input, context);
}
if (operation === "moderation.action") {
return executeModerationMutation(input, context);
}
throw new PeopleMutationFailure(
"VALIDATION",
"errors.housekeeping.validation",
);
},
};
@@ -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,
);
@@ -4,7 +4,7 @@ import { join, posix } from "node:path";
import { createElement, type ReactElement } from "react";
import { renderToStaticMarkup } from "react-dom/server";
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 { HOUSEKEEPING_MANIFESTS } from "../manifests";
import { discoverLegacyPages } from "../migration/discover-legacy-pages";
@@ -28,6 +28,14 @@ const approvedRuntimeImports = new Map<string, ReadonlySet<string>>([
"src/features/housekeeping/domains/people/commands/user-commands.ts",
new Set(["src/features/housekeeping/domains/people/services/mutations"]),
],
[
"src/features/housekeeping/domains/people/commands/support-commands.ts",
new Set(["src/features/housekeeping/domains/people/services/mutations"]),
],
[
"src/features/housekeeping/domains/people/commands/moderation-commands.ts",
new Set(["src/features/housekeeping/domains/people/services/mutations"]),
],
[
"src/features/housekeeping/domains/people/pages/community.tsx",
new Set([
@@ -75,6 +83,47 @@ const approvedRuntimeImports = new Map<string, ReadonlySet<string>>([
"src/features/housekeeping/domains/people/queries/users",
]),
],
[
"src/features/housekeeping/domains/people/pages/support.tsx",
new Set([
"src/features/housekeeping/domains/people/pages/people-command-form",
"src/features/housekeeping/domains/people/pages/page-state",
"src/features/housekeeping/domains/people/queries/support",
]),
],
[
"src/features/housekeeping/domains/people/pages/ticket-detail.tsx",
new Set([
"src/features/housekeeping/domains/people/pages/people-command-form",
"src/features/housekeeping/domains/people/pages/page-state",
"src/features/housekeeping/domains/people/queries/support",
]),
],
[
"src/features/housekeeping/domains/people/pages/help-ticket-detail.tsx",
new Set([
"src/features/housekeeping/domains/people/models",
"src/features/housekeeping/domains/people/pages/people-command-form",
"src/features/housekeeping/domains/people/pages/page-state",
"src/features/housekeeping/domains/people/queries/support",
]),
],
[
"src/features/housekeeping/domains/people/pages/moderation.tsx",
new Set([
"src/features/housekeeping/domains/people/pages/people-command-form",
"src/features/housekeeping/domains/people/pages/page-state",
"src/features/housekeeping/domains/people/queries/moderation",
]),
],
[
"src/features/housekeeping/domains/people/pages/cfh-detail.tsx",
new Set([
"src/features/housekeeping/domains/people/pages/people-command-form",
"src/features/housekeeping/domains/people/pages/page-state",
"src/features/housekeeping/domains/people/queries/moderation",
]),
],
[
"src/features/housekeeping/domains/people/pages/page-state.tsx",
new Set(["src/features/housekeeping/domains/people/models"]),
@@ -95,17 +144,50 @@ const approvedRuntimeImports = new Map<string, ReadonlySet<string>>([
],
[
"src/features/housekeeping/domains/people/manifest.ts",
new Set(["src/features/housekeeping/domains/people/routes"]),
new Set([
"src/features/housekeeping/domains/people/inbox",
"src/features/housekeeping/domains/people/routes",
"src/features/housekeeping/domains/people/search",
"src/features/housekeeping/domains/people/widgets",
]),
],
[
"src/features/housekeeping/domains/people/search.ts",
new Set([
"src/features/housekeeping/domains/people/queries/community",
"src/features/housekeeping/domains/people/queries/support",
"src/features/housekeeping/domains/people/queries/users",
]),
],
[
"src/features/housekeeping/domains/people/inbox.ts",
new Set([
"src/features/housekeeping/domains/people/queries/moderation",
"src/features/housekeeping/domains/people/queries/support",
]),
],
[
"src/features/housekeeping/domains/people/widgets.ts",
new Set([
"src/features/housekeeping/domains/people/models",
"src/features/housekeeping/domains/people/queries/support",
]),
],
[
"src/features/housekeeping/domains/people/route-handlers.ts",
new Set([
"src/features/housekeeping/domains/people/pages/community",
"src/features/housekeeping/domains/people/pages/cfh-detail",
"src/features/housekeeping/domains/people/pages/help-ticket-detail",
"src/features/housekeeping/domains/people/pages/moderation",
"src/features/housekeeping/domains/people/pages/multi-accounts",
"src/features/housekeeping/domains/people/pages/staff",
"src/features/housekeeping/domains/people/pages/support",
"src/features/housekeeping/domains/people/pages/ticket-detail",
"src/features/housekeeping/domains/people/pages/user-detail",
"src/features/housekeeping/domains/people/pages/user-edit",
"src/features/housekeeping/domains/people/pages/users",
"src/features/housekeeping/domains/people/routes",
]),
],
[
@@ -217,6 +299,8 @@ const approvedRuntimeImports = new Map<string, ReadonlySet<string>>([
"src/features/housekeeping/foundation/commands/bootstrap.ts",
new Set([
"src/features/housekeeping/domains/people/commands/community-commands",
"src/features/housekeeping/domains/people/commands/moderation-commands",
"src/features/housekeeping/domains/people/commands/support-commands",
"src/features/housekeeping/domains/people/commands/user-commands",
"src/features/housekeeping/domains/system/commands/system-commands",
]),
@@ -838,7 +922,7 @@ describe("housekeeping foundation completion contracts", () => {
registry.domains
.find((domain) => domain.id === "people")
?.routes.map((route) => route.id),
).toEqual(PEOPLE_PRIMARY_ROUTE_IDS);
).toEqual(PEOPLE_ROUTE_IDS);
expect(
registry.domains
.find((domain) => domain.id === "system")
@@ -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,
]);
});
+2 -2
View File
@@ -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]);
+47
View File
@@ -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;
+22
View File
@@ -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 },