From 29fe22297bbe321cf9d17d60fbedd07f433e7b75 Mon Sep 17 00:00:00 2001 From: simoleo89 Date: Sat, 29 Aug 2026 22:36:53 +0200 Subject: [PATCH] feat(housekeeping): complete people moderation parity --- src/actions/admin-bans.test.ts | 83 +- src/actions/admin-bans.ts | 63 +- src/actions/admin-help-tickets.ts | 186 ++--- src/actions/help-tickets.ts | 18 +- src/actions/moderation.ts | 168 ++-- ...people-support-moderation-wrappers.test.ts | 216 ++++++ src/actions/ticket-templates.ts | 48 +- src/actions/tickets.ts | 163 +--- .../commands/moderation-commands.test.ts | 142 ++++ .../people/commands/moderation-commands.ts | 132 ++++ .../people/commands/support-commands.test.ts | 104 +++ .../people/commands/support-commands.ts | 161 ++++ .../housekeeping/domains/people/inbox.ts | 244 ++++++ .../housekeeping/domains/people/manifest.ts | 13 +- .../housekeeping/domains/people/models.ts | 27 +- .../domains/people/pages/cfh-detail.tsx | 72 ++ .../people/pages/help-ticket-detail.tsx | 83 ++ .../domains/people/pages/moderation.tsx | 123 +++ .../pages/people-primary-pages.test.tsx | 14 +- .../pages/people-support-pages.test.tsx | 188 +++++ .../domains/people/pages/staff.tsx | 19 +- .../domains/people/pages/support.tsx | 139 ++++ .../domains/people/pages/ticket-detail.tsx | 96 +++ .../domains/people/people-providers.test.ts | 126 +++ .../people-adapters-production.test.ts | 4 +- .../people/queries/people-queries.test.ts | 8 +- .../domains/people/queries/support.ts | 106 +-- .../domains/people/route-handlers.ts | 37 + .../domains/people/routes.test.ts | 43 + .../housekeeping/domains/people/search.ts | 185 +++++ .../domains/people/services/mutations.ts | 732 +++++++++++++++++- .../housekeeping/domains/people/widgets.ts | 90 +++ .../foundation/commands/bootstrap.test.ts | 8 + .../foundation/commands/bootstrap.ts | 4 + .../foundation-source-contract.test.ts | 90 ++- .../housekeeping/foundation/registry.test.ts | 23 +- .../housekeeping/foundation/registry.ts | 10 +- .../housekeeping/route-handlers.test.ts | 4 +- src/features/housekeeping/route-handlers.ts | 4 +- src/lib/services/moderation.ts | 47 ++ src/lib/services/ticket-replies.ts | 22 + 41 files changed, 3465 insertions(+), 580 deletions(-) create mode 100644 src/actions/people-support-moderation-wrappers.test.ts create mode 100644 src/features/housekeeping/domains/people/commands/moderation-commands.test.ts create mode 100644 src/features/housekeeping/domains/people/commands/moderation-commands.ts create mode 100644 src/features/housekeeping/domains/people/commands/support-commands.test.ts create mode 100644 src/features/housekeeping/domains/people/commands/support-commands.ts create mode 100644 src/features/housekeeping/domains/people/inbox.ts create mode 100644 src/features/housekeeping/domains/people/pages/cfh-detail.tsx create mode 100644 src/features/housekeeping/domains/people/pages/help-ticket-detail.tsx create mode 100644 src/features/housekeeping/domains/people/pages/moderation.tsx create mode 100644 src/features/housekeeping/domains/people/pages/people-support-pages.test.tsx create mode 100644 src/features/housekeeping/domains/people/pages/support.tsx create mode 100644 src/features/housekeeping/domains/people/pages/ticket-detail.tsx create mode 100644 src/features/housekeeping/domains/people/people-providers.test.ts create mode 100644 src/features/housekeeping/domains/people/search.ts create mode 100644 src/features/housekeeping/domains/people/widgets.ts diff --git a/src/actions/admin-bans.test.ts b/src/actions/admin-bans.test.ts index 37b50d29..66d77318 100644 --- a/src/actions/admin-bans.test.ts +++ b/src/actions/admin-bans.test.ts @@ -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) => ({ - get: (key: string) => data[key] ?? null, -}); +const fakeForm = (data: Record) => + ({ 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"); }); }); diff --git a/src/actions/admin-bans.ts b/src/actions/admin-bans.ts index a6ed9deb..05ef181b 100644 --- a/src/actions/admin-bans.ts +++ b/src/actions/admin-bans.ts @@ -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 = new Set([ "account", @@ -25,37 +26,17 @@ export async function createBan(formData: FormData): Promise { 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 { 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"); } diff --git a/src/actions/admin-help-tickets.ts b/src/actions/admin-help-tickets.ts index e2a15942..6de92f99 100644 --- a/src/actions/admin-help-tickets.ts +++ b/src/actions/admin-help-tickets.ts @@ -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(); diff --git a/src/actions/help-tickets.ts b/src/actions/help-tickets.ts index 798bbf8e..339ce169 100644 --- a/src/actions/help-tickets.ts +++ b/src/actions/help-tickets.ts @@ -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 { let outcome: TicketOutcome = "error"; @@ -177,7 +187,7 @@ const replyContentSchema = z.object({ }); export async function replyHelpTicket(formData: FormData): Promise { - 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 { } export async function closeHelpTicket(formData: FormData): Promise { - const ticketId = positiveBigInt(String(formData.get("ticketId") ?? "")); + const ticketId = helpTicketId(formData); let outcome: TicketDetailOutcome = "error"; try { diff --git a/src/actions/moderation.ts b/src/actions/moderation.ts index 4a2ec30a..dfd3e002 100644 --- a/src/actions/moderation.ts +++ b/src/actions/moderation.ts @@ -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(); }, ); diff --git a/src/actions/people-support-moderation-wrappers.test.ts b/src/actions/people-support-moderation-wrappers.test.ts new file mode 100644 index 00000000..57f9e62c --- /dev/null +++ b/src/actions/people-support-moderation-wrappers.test.ts @@ -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; +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: {}, + }); + }); +}); diff --git a/src/actions/ticket-templates.ts b/src/actions/ticket-templates.ts index d6ff6409..e4b1e352 100644 --- a/src/actions/ticket-templates.ts +++ b/src/actions/ticket-templates.ts @@ -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(); }, ); diff --git a/src/actions/tickets.ts b/src/actions/tickets.ts index 4750138b..6cc91950 100644 --- a/src/actions/tickets.ts +++ b/src/actions/tickets.ts @@ -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 = { - 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 = { - 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(); }, ); diff --git a/src/features/housekeeping/domains/people/commands/moderation-commands.test.ts b/src/features/housekeeping/domains/people/commands/moderation-commands.test.ts new file mode 100644 index 00000000..cc8809a7 --- /dev/null +++ b/src/features/housekeeping/domains/people/commands/moderation-commands.test.ts @@ -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[]; + 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[]; + 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, + ); + }); +}); diff --git a/src/features/housekeeping/domains/people/commands/moderation-commands.ts b/src/features/housekeeping/domains/people/commands/moderation-commands.ts new file mode 100644 index 00000000..9f25a0d4 --- /dev/null +++ b/src/features/housekeeping/domains/people/commands/moderation-commands.ts @@ -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( + service: Pick, + options: { + id: ModerationCommandId; + operation: PeopleMutationOperation; + capability: readonly string[]; + input: z.ZodType; + requiresReason?: boolean; + }, +): HousekeepingCommand { + 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, +) { + 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); diff --git a/src/features/housekeeping/domains/people/commands/support-commands.test.ts b/src/features/housekeeping/domains/people/commands/support-commands.test.ts new file mode 100644 index 00000000..912639ee --- /dev/null +++ b/src/features/housekeeping/domains/people/commands/support-commands.test.ts @@ -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[]; + 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, + ); + }); +}); diff --git a/src/features/housekeeping/domains/people/commands/support-commands.ts b/src/features/housekeeping/domains/people/commands/support-commands.ts new file mode 100644 index 00000000..27cc4eac --- /dev/null +++ b/src/features/housekeeping/domains/people/commands/support-commands.ts @@ -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( + service: Pick, + options: { + id: SupportCommandId; + operation: PeopleMutationOperation; + capability: readonly string[]; + input: z.ZodType; + requiresReason?: boolean; + }, +): HousekeepingCommand { + 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, +) { + 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); diff --git a/src/features/housekeeping/domains/people/inbox.ts b/src/features/housekeeping/domains/people/inbox.ts new file mode 100644 index 00000000..4e542212 --- /dev/null +++ b/src/features/housekeeping/domains/people/inbox.ts @@ -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; + helpTickets( + context: HousekeepingCapabilityContext, + signal: AbortSignal, + ): Promise; + cfh( + context: HousekeepingCapabilityContext, + signal: AbortSignal, + ): Promise; + activeBans( + context: HousekeepingCapabilityContext, + signal: AbortSignal, + ): Promise; +} + +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); diff --git a/src/features/housekeeping/domains/people/manifest.ts b/src/features/housekeeping/domains/people/manifest.ts index ca4bac04..8128ec70 100644 --- a/src/features/housekeeping/domains/people/manifest.ts +++ b/src/features/housekeeping/domains/people/manifest.ts @@ -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; diff --git a/src/features/housekeeping/domains/people/models.ts b/src/features/housekeeping/domains/people/models.ts index 954a801a..28afd191 100644 --- a/src/features/housekeeping/domains/people/models.ts +++ b/src/features/housekeeping/domains/people/models.ts @@ -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( diff --git a/src/features/housekeeping/domains/people/pages/cfh-detail.tsx b/src/features/housekeeping/domains/people/pages/cfh-detail.tsx new file mode 100644 index 00000000..81f068bb --- /dev/null +++ b/src/features/housekeeping/domains/people/pages/cfh-detail.tsx @@ -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; +} + +export function PeopleCfhDetailPage({ context, result }: Props) { + const canEdit = context.hasAny(PERMS.MODERATION_EDIT, PERMS.MOD_CFH_EDIT); + return ( + data.kind !== "cfh-detail"} + > + {(data) => data.kind === "cfh-detail" ? ( +
+

CFH #{data.ticket.id}

+

{data.ticket.issue}

+

Reporter: {data.ticket.senderUsername ?? `#${data.ticket.senderId}`}

+

Reported: {data.ticket.reportedUsername ?? `#${data.ticket.reportedId}`}

+ {canEdit ? ( + <> + + ({ 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} +
+ ) : null} +
+ ); +} + +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 ; +} diff --git a/src/features/housekeeping/domains/people/pages/help-ticket-detail.tsx b/src/features/housekeeping/domains/people/pages/help-ticket-detail.tsx new file mode 100644 index 00000000..a0cb8a57 --- /dev/null +++ b/src/features/housekeeping/domains/people/pages/help-ticket-detail.tsx @@ -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; +} + +export function PeopleHelpTicketDetailPage({ context, result }: Props) { + const canEdit = context.hasAny(PERMS.TICKETS_EDIT, PERMS.MOD_TICKETS_EDIT); + return ( + data.kind !== "help-ticket"} + > + {(data) => data.kind === "help-ticket" ? ( +
+

{data.ticket.title}

{data.ticket.content}

+
    + {data.ticket.replies.map((reply) => ( +
  • {reply.username ?? `User #${reply.userId}`}: {reply.content}
  • + ))} +
+ {canEdit ? ( + <> + + + + ) : null} + {data.ticket.activeBan && context.has(PERMS.USERS_BAN) ? ( + + ) : null} +
+ ) : null} +
+ ); +} + +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 ; +} diff --git a/src/features/housekeeping/domains/people/pages/moderation.tsx b/src/features/housekeeping/domains/people/pages/moderation.tsx new file mode 100644 index 00000000..5faa1891 --- /dev/null +++ b/src/features/housekeeping/domains/people/pages/moderation.tsx @@ -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; +} + +function isEmpty(data: PeopleModerationQueryData) { + return "page" in data ? data.page.items.length === 0 : false; +} + +function QuickAction() { + return ( + ({ 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 ( + + {(data) => { + if (data.kind === "overview") return ( +
+
+ {Object.entries(data.snapshot).map(([label, value]) => ( +
{label}
{value}
+ ))} +
+ {canAct ? : null} +
+ ); + if (data.kind === "cfh") return ( + + ); + if (data.kind === "bans") return ( +
+ {context.has(PERMS.USERS_BAN) ? ( + ({value, label: value}))}, + ]} + requiresReason + includeReasonInInput + /> + ) : null} +
    {data.page.items.map((ban) => ( +
  • + User #{ban.userId}: {ban.reason} + {context.has(PERMS.USERS_BAN) ? ( + + ) : null} +
  • + ))}
+
+ ); + if (data.kind === "ip-rules") return
{JSON.stringify({blacklist: data.blacklist, whitelist: data.whitelist}, null, 2)}
; + if (data.kind === "vpn") return
    {data.settings.map((setting) =>
  • {setting.key}: {setting.value}
  • )}
; + if (data.kind === "word-filter") return
    {data.page.items.map((entry) =>
  • {entry.word}
  • )}
; + return null; + }} +
+ ); +} + +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 ; +} diff --git a/src/features/housekeeping/domains/people/pages/people-primary-pages.test.tsx b/src/features/housekeeping/domains/people/pages/people-primary-pages.test.tsx index 31a79902..99ed9428 100644 --- a/src/features/housekeeping/domains/people/pages/people-primary-pages.test.tsx +++ b/src/features/housekeeping/domains/people/pages/people-primary-pages.test.tsx @@ -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)); }); diff --git a/src/features/housekeeping/domains/people/pages/people-support-pages.test.tsx b/src/features/housekeeping/domains/people/pages/people-support-pages.test.tsx new file mode 100644 index 00000000..ebf45b7b --- /dev/null +++ b/src/features/housekeeping/domains/people/pages/people-support-pages.test.tsx @@ -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( + , + ); + expect(html).toContain("/ase/people/support/tickets/7"); + expect(html).not.toContain("/admin"); + expect(html).not.toContain("/mod/"); + + const detail = renderToStaticMarkup( + , + ); + 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( + , + ); + expect(cfh).toContain('data-housekeeping-command="people.cfh.resolve"'); + expect(cfh).toContain('data-housekeeping-command="people.cfh.sanction"'); + + const moderation = renderToStaticMarkup( + , + ); + 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( + , + ); + 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(), + ).toContain('data-housekeeping-state="loading"'); + }); +}); diff --git a/src/features/housekeeping/domains/people/pages/staff.tsx b/src/features/housekeeping/domains/people/pages/staff.tsx index def07312..0429dc33 100644 --- a/src/features/housekeeping/domains/people/pages/staff.tsx +++ b/src/features/housekeeping/domains/people/pages/staff.tsx @@ -121,6 +121,21 @@ export function PeopleStaffPage({ context, result }: PeopleStaffPageProps) { ))} + ) : data.kind === "moderation-team" ? ( +
    + {data.page.items.map((member) => ( +
  • + {member.username} +

    + {member.openCfh} CFH · {member.openTickets} tickets ·{" "} + {member.actionCount} actions +

    +
  • + ))} +
) : null} )} @@ -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 ?? {}), diff --git a/src/features/housekeeping/domains/people/pages/support.tsx b/src/features/housekeeping/domains/people/pages/support.tsx new file mode 100644 index 00000000..dfe6ebb5 --- /dev/null +++ b/src/features/housekeeping/domains/people/pages/support.tsx @@ -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; +} + +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 ( +
+ {Object.entries(queue).map(([label, value]) => ( +
+
{label}
{value}
+
+ ))} +
+ ); +} + +export function PeopleSupportPage({ context, result }: Props) { + return ( + + {(data) => { + if (data.kind === "queue") return ; + if (data.kind === "ticket-templates") { + return ( +
+ {context.has(PERMS.TICKETS_EDIT) ? ( + + ) : null} +
    + {data.page.items.map((template) => ( +
  • +

    {template.title}

    +

    {template.content}

    + {context.has(PERMS.TICKETS_EDIT) ? ( + + ) : null} +
  • + ))} +
+
+ ); + } + if (data.kind === "help-tickets") { + return ( +
    + {data.page.items.map((ticket) => ( +
  • + {ticket.title} +

    {ticket.open ? "Open" : "Closed"} · {ticket.replyCount} replies

    +
  • + ))} +
+ ); + } + if (data.kind !== "tickets" && data.kind !== "ticket-desk") { + return null; + } + const queue = data.kind === "ticket-desk" ? data.queue : null; + return ( +
+ {queue ? : null} +
+ + +
+
    + {data.page.items.map((ticket) => ( +
  • + {ticket.subject} +

    {ticket.status} · {ticket.priority}

    +
  • + ))} +
+
+ ); + }} +
+ ); +} + +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 ( + + ); + } + const result = await peopleSupportQuery.run(input.context, { + routeId, + list: parsePeopleListInput(input.searchParams ?? {}), + }); + return ; +} diff --git a/src/features/housekeeping/domains/people/pages/ticket-detail.tsx b/src/features/housekeeping/domains/people/pages/ticket-detail.tsx new file mode 100644 index 00000000..8f0d9261 --- /dev/null +++ b/src/features/housekeeping/domains/people/pages/ticket-detail.tsx @@ -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; +} + +export function PeopleTicketDetailPage({ context, result }: Props) { + const canEdit = context.hasAny(PERMS.TICKETS_EDIT, PERMS.MOD_TICKETS_EDIT); + return ( + data.kind !== "ticket"} + > + {(data) => data.kind === "ticket" ? ( +
+

{data.ticket.subject}

{data.ticket.status} · {data.ticket.priority}

+
    + {data.ticket.messages.map((message) => ( +
  • + {message.username ?? `User #${message.userId}`} +

    {message.message}

    +
  • + ))} +
+ {canEdit ? ( +
+ + ({ value: staff.id, label: staff.username })), + }]} + /> + ({ value, label: value })), + }]} + /> + ({ value, label: value })), + }]} + /> +
+ ) : null} +
+ ) : null} +
+ ); +} + +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 ; +} diff --git a/src/features/housekeeping/domains/people/people-providers.test.ts b/src/features/housekeeping/domains/people/people-providers.test.ts new file mode 100644 index 00000000..229791c9 --- /dev/null +++ b/src/features/housekeeping/domains/people/people-providers.test.ts @@ -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 }, + }); + }); +}); diff --git a/src/features/housekeeping/domains/people/queries/people-adapters-production.test.ts b/src/features/housekeeping/domains/people/queries/people-adapters-production.test.ts index 9bffbefa..37aacc24 100644 --- a/src/features/housekeeping/domains/people/queries/people-adapters-production.test.ts +++ b/src/features/housekeeping/domains/people/queries/people-adapters-production.test.ts @@ -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(); diff --git a/src/features/housekeeping/domains/people/queries/people-queries.test.ts b/src/features/housekeeping/domains/people/queries/people-queries.test.ts index 9b4fce76..aac1ab18 100644 --- a/src/features/housekeeping/domains/people/queries/people-queries.test.ts +++ b/src/features/housekeeping/domains/people/queries/people-queries.test.ts @@ -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), diff --git a/src/features/housekeeping/domains/people/queries/support.ts b/src/features/housekeeping/domains/people/queries/support.ts index 760693ec..a3878ad3 100644 --- a/src/features/housekeeping/domains/people/queries/support.ts +++ b/src/features/housekeeping/domains/people/queries/support.ts @@ -54,7 +54,7 @@ export interface PeopleSupportAdapters { loadHelpTickets( input: ReturnType, ): Promise>; - loadHelpTicket(id: number): Promise; + loadHelpTicket(id: string): Promise; loadSupportStaff(): Promise; loadActiveBan(userId: number): Promise; } @@ -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(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, diff --git a/src/features/housekeeping/domains/people/route-handlers.ts b/src/features/housekeeping/domains/people/route-handlers.ts index a0897889..8f1994b0 100644 --- a/src/features/housekeeping/domains/people/route-handlers.ts +++ b/src/features/housekeeping/domains/people/route-handlers.ts @@ -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) }), + ), + ); diff --git a/src/features/housekeeping/domains/people/routes.test.ts b/src/features/housekeeping/domains/people/routes.test.ts index fa02e081..6cc26ac7 100644 --- a/src/features/housekeeping/domains/people/routes.test.ts +++ b/src/features/housekeeping/domains/people/routes.test.ts @@ -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); + }); }); diff --git a/src/features/housekeeping/domains/people/search.ts b/src/features/housekeeping/domains/people/search.ts new file mode 100644 index 00000000..2efc2c6f --- /dev/null +++ b/src/features/housekeeping/domains/people/search.ts @@ -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; + guilds( + context: HousekeepingCapabilityContext, + term: string, + limit: number, + ): Promise; + tickets( + context: HousekeepingCapabilityContext, + term: string, + limit: number, + ): Promise; +} + +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); diff --git a/src/features/housekeeping/domains/people/services/mutations.ts b/src/features/housekeeping/domains/people/services/mutations.ts index 24833d68..33173b7d 100644 --- a/src/features/housekeeping/domains/people/services/mutations.ts +++ b/src/features/housekeeping/domains/people/services/mutations.ts @@ -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> | 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 { + 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 { + 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, + input: unknown, + context: PeopleMutationContext, +): Promise { + 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 { + 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): 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 { + await requireRcon(await executeModerationAction(rcon, input)); +} + +async function executeModerationMutation( + input: unknown, + context: PeopleMutationContext, +): Promise { + 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 { + 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 { + 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, + 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", + ); }, }; diff --git a/src/features/housekeeping/domains/people/widgets.ts b/src/features/housekeeping/domains/people/widgets.ts new file mode 100644 index 00000000..8b286bac --- /dev/null +++ b/src/features/housekeeping/domains/people/widgets.ts @@ -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; +} + +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; + }, +}); diff --git a/src/features/housekeeping/foundation/commands/bootstrap.test.ts b/src/features/housekeeping/foundation/commands/bootstrap.test.ts index 63987da5..c9b406b6 100644 --- a/src/features/housekeeping/foundation/commands/bootstrap.test.ts +++ b/src/features/housekeeping/foundation/commands/bootstrap.test.ts @@ -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", diff --git a/src/features/housekeeping/foundation/commands/bootstrap.ts b/src/features/housekeeping/foundation/commands/bootstrap.ts index 271a877e..561e0823 100644 --- a/src/features/housekeeping/foundation/commands/bootstrap.ts +++ b/src/features/housekeeping/foundation/commands/bootstrap.ts @@ -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, ); diff --git a/src/features/housekeeping/foundation/foundation-source-contract.test.ts b/src/features/housekeeping/foundation/foundation-source-contract.test.ts index 57852ac9..76048538 100644 --- a/src/features/housekeeping/foundation/foundation-source-contract.test.ts +++ b/src/features/housekeeping/foundation/foundation-source-contract.test.ts @@ -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>([ "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>([ "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>([ ], [ "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>([ "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") diff --git a/src/features/housekeeping/foundation/registry.test.ts b/src/features/housekeeping/foundation/registry.test.ts index 555215c4..266a036c 100644 --- a/src/features/housekeeping/foundation/registry.test.ts +++ b/src/features/housekeeping/foundation/registry.test.ts @@ -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", () => { diff --git a/src/features/housekeeping/foundation/registry.ts b/src/features/housekeeping/foundation/registry.ts index 07f97eca..f59e3c3d 100644 --- a/src/features/housekeeping/foundation/registry.ts +++ b/src/features/housekeeping/foundation/registry.ts @@ -24,7 +24,9 @@ export function createHousekeepingRegistry( const routeIds = new Set(); const routeHrefs = new Set(); const routeShapes = new Set(); - const registryEntryIds = new Set(); + const searchProviderIds = new Set(); + const inboxSourceIds = new Set(); + const widgetIds = new Set(); 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") { diff --git a/src/features/housekeeping/route-handlers.test.ts b/src/features/housekeeping/route-handlers.test.ts index b5f72e64..062aaa7a 100644 --- a/src/features/housekeeping/route-handlers.test.ts +++ b/src/features/housekeeping/route-handlers.test.ts @@ -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, ]); }); diff --git a/src/features/housekeeping/route-handlers.ts b/src/features/housekeeping/route-handlers.ts index fe749491..863466bd 100644 --- a/src/features/housekeeping/route-handlers.ts +++ b/src/features/housekeeping/route-handlers.ts @@ -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]); diff --git a/src/lib/services/moderation.ts b/src/lib/services/moderation.ts index 4e123b91..be8e72f2 100644 --- a/src/lib/services/moderation.ts +++ b/src/lib/services/moderation.ts @@ -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; + muteUser(userId: number, duration: number): Promise; + unmuteUser(userId: number): Promise; + alertUser(userId: number, message: string): Promise; + kickAll(roomId: number): Promise; + hotelAlert(message: string): Promise; + staffAlert(message: string): Promise; +} + +// Execute one already-authorized moderation effect and expose delivery truth. +export async function executeModerationAction( + transport: ModerationActionTransport, + input: ModerationAction, +): Promise { + 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; diff --git a/src/lib/services/ticket-replies.ts b/src/lib/services/ticket-replies.ts index e556bd72..1fd76ffc 100644 --- a/src/lib/services/ticket-replies.ts +++ b/src/lib/services/ticket-replies.ts @@ -19,6 +19,28 @@ export interface TicketReplyDb { touchTicket(ticketId: bigint, updatedAt: Date): Promise; } +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 },