feat(housekeeping): complete people moderation parity
This commit is contained in:
1 parent
3d385d1869
commit
29fe22297b
41 files changed
+3465
-580
No files matched your search
@@ -1,84 +1,69 @@
|
||||
// @ts-nocheck
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { requirePermissionRateLimited } from "@/lib/admin/guard";
|
||||
import { rcon } from "@/lib/services/rcon";
|
||||
import { createBan, liftBan } from "./admin-bans";
|
||||
|
||||
const { selectLimit, insertValues, deleteWhere } = vi.hoisted(() => {
|
||||
const selectLimit = vi.fn();
|
||||
const insertValues = vi.fn().mockResolvedValue([{ insertId: 1 }]);
|
||||
const deleteWhere = vi.fn().mockResolvedValue([{ affectedRows: 1 }]);
|
||||
return { selectLimit, insertValues, deleteWhere };
|
||||
});
|
||||
const { execute } = vi.hoisted(() => ({ execute: vi.fn() }));
|
||||
|
||||
vi.mock("@/features/housekeeping/domains/people/services/mutations", () => ({
|
||||
createPeopleMutationInvocation: vi.fn((staff, correlationId) => ({
|
||||
expectedActorId: staff.id,
|
||||
correlationId,
|
||||
legacy: true,
|
||||
})),
|
||||
peopleMutationService: { execute },
|
||||
}));
|
||||
vi.mock("@/lib/admin/guard", () => ({ requirePermissionRateLimited: vi.fn() }));
|
||||
vi.mock("@/lib/permissions", () => ({ PERMS: { USERS_BAN: "users.ban" } }));
|
||||
vi.mock("@/lib/db", () => ({
|
||||
db: {
|
||||
select: vi.fn(() => ({
|
||||
from: vi.fn(() => ({
|
||||
where: vi.fn(() => ({
|
||||
limit: selectLimit,
|
||||
})),
|
||||
})),
|
||||
})),
|
||||
insert: vi.fn(() => ({ values: insertValues })),
|
||||
delete: vi.fn(() => ({ where: deleteWhere })),
|
||||
},
|
||||
Ban: { id: "id", userId: "userId" },
|
||||
User: { id: "id", username: "username" },
|
||||
}));
|
||||
vi.mock("@/lib/services/rcon", () => ({ rcon: { disconnectUser: vi.fn() } }));
|
||||
vi.mock("@/lib/services/staff-activity", () => ({ logStaffActivity: vi.fn() }));
|
||||
vi.mock("next/cache", () => ({ revalidatePath: vi.fn() }));
|
||||
|
||||
import { createBan, liftBan } from "./admin-bans";
|
||||
|
||||
const staff = { id: 1, rank: 7, username: "admin" };
|
||||
const fakeForm = (data: Record<string, string>) => ({
|
||||
get: (key: string) => data[key] ?? null,
|
||||
});
|
||||
const fakeForm = (data: Record<string, string>) =>
|
||||
({ get: (key: string) => data[key] ?? null }) as unknown as FormData;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.mocked(requirePermissionRateLimited).mockResolvedValue(staff as never);
|
||||
selectLimit.mockResolvedValue([{ username: "baduser" }]);
|
||||
insertValues.mockResolvedValue([{ insertId: 1 }]);
|
||||
deleteWhere.mockResolvedValue([{ affectedRows: 1 }]);
|
||||
execute.mockImplementation(async (invocation) => ({
|
||||
ok: true,
|
||||
data: { before: null, after: {} },
|
||||
correlationId: invocation.correlationId,
|
||||
}));
|
||||
});
|
||||
|
||||
describe("createBan", () => {
|
||||
it("creates a ban for valid inputs", async () => {
|
||||
describe("legacy admin ban wrappers", () => {
|
||||
it("preserves parsed create input, service delegation, and revalidation", async () => {
|
||||
await createBan(
|
||||
fakeForm({
|
||||
userId: "42",
|
||||
reason: "Spam",
|
||||
hours: "24",
|
||||
type: "account",
|
||||
}) as unknown as FormData,
|
||||
}),
|
||||
);
|
||||
expect(insertValues).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ userId: 42, type: "account" }),
|
||||
expect(execute).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ expectedActorId: 1, legacy: true }),
|
||||
"ban.create",
|
||||
{ userId: 42, reason: "Spam", hours: 24, type: "account" },
|
||||
);
|
||||
expect(rcon.disconnectUser).toHaveBeenCalledWith(42, "baduser");
|
||||
expect(revalidatePath).toHaveBeenCalledWith("/admin/bans");
|
||||
});
|
||||
|
||||
it("returns early when userId is invalid", async () => {
|
||||
await createBan(
|
||||
fakeForm({
|
||||
userId: "0",
|
||||
hours: "1",
|
||||
type: "account",
|
||||
}) as unknown as FormData,
|
||||
fakeForm({ userId: "0", hours: "1", type: "account" }),
|
||||
);
|
||||
expect(insertValues).not.toHaveBeenCalled();
|
||||
expect(execute).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("liftBan", () => {
|
||||
it("deletes ban and revalidates", async () => {
|
||||
await liftBan(fakeForm({ id: "42" }) as unknown as FormData);
|
||||
expect(deleteWhere).toHaveBeenCalled();
|
||||
it("delegates lift by exact ban id and preserves revalidation", async () => {
|
||||
await liftBan(fakeForm({ id: "42" }));
|
||||
expect(execute).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ expectedActorId: 1, legacy: true }),
|
||||
"ban.lift",
|
||||
{ id: 42 },
|
||||
);
|
||||
expect(revalidatePath).toHaveBeenCalledWith("/admin/bans");
|
||||
});
|
||||
});
|
||||
+22
-41
@@ -1,12 +1,13 @@
|
||||
"use server";
|
||||
|
||||
import { eq } from "drizzle-orm";
|
||||
import { revalidatePath } from "next/cache";
|
||||
import {
|
||||
createPeopleMutationInvocation,
|
||||
peopleMutationService,
|
||||
} from "@/features/housekeeping/domains/people/services/mutations";
|
||||
import { createCorrelationId } from "@/features/housekeeping/foundation/contracts";
|
||||
import { requirePermissionRateLimited } from "@/lib/admin/guard";
|
||||
import { Ban, db, User } from "@/lib/db";
|
||||
import { PERMS } from "@/lib/permissions";
|
||||
import { rcon } from "@/lib/services/rcon";
|
||||
import { logStaffActivity } from "@/lib/services/staff-activity";
|
||||
|
||||
const BAN_TYPES: ReadonlySet<string> = new Set([
|
||||
"account",
|
||||
@@ -25,37 +26,17 @@ export async function createBan(formData: FormData): Promise<void> {
|
||||
const hours = Number(formData.get("hours"));
|
||||
const type = String(formData.get("type"));
|
||||
if (!(userId > 0) || !BAN_TYPES.has(type)) return;
|
||||
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
// Emulator convention: banExpire 0 = permanent (not a far-future timestamp).
|
||||
const banExpire = hours > 0 ? now + Math.floor(hours) * 3600 : 0;
|
||||
|
||||
const [user] = await db
|
||||
.select({ username: User.username })
|
||||
.from(User)
|
||||
.where(eq(User.id, userId))
|
||||
.limit(1);
|
||||
|
||||
await db.insert(Ban).values({
|
||||
userId,
|
||||
ip: "",
|
||||
machineId: "",
|
||||
userStaffId: staff.id,
|
||||
timestamp: now,
|
||||
banExpire,
|
||||
banReason: reason,
|
||||
type: type as "account" | "ip" | "machine" | "super",
|
||||
cfhTopic: -1,
|
||||
});
|
||||
|
||||
if (user) await rcon.disconnectUser(userId, user.username);
|
||||
await logStaffActivity({
|
||||
staffId: staff.id,
|
||||
action: "user_ban",
|
||||
description: `Banned user #${userId} (${type}, ${hours > 0 ? `${hours}h` : "permanent"}): ${reason}`,
|
||||
targetType: "user",
|
||||
targetId: userId,
|
||||
});
|
||||
const result = await peopleMutationService.execute(
|
||||
createPeopleMutationInvocation(staff, createCorrelationId()),
|
||||
"ban.create",
|
||||
{
|
||||
userId,
|
||||
reason,
|
||||
hours: Number.isFinite(hours) && hours > 0 ? Math.floor(hours) : 0,
|
||||
type,
|
||||
},
|
||||
);
|
||||
if (!result.ok) throw new Error("Could not create ban");
|
||||
revalidatePath("/admin/bans");
|
||||
}
|
||||
|
||||
@@ -63,12 +44,12 @@ export async function liftBan(formData: FormData): Promise<void> {
|
||||
const staff = await requirePermissionRateLimited(PERMS.USERS_BAN);
|
||||
const id = Number(formData.get("id"));
|
||||
if (id > 0) {
|
||||
await db.delete(Ban).where(eq(Ban.id, id));
|
||||
await logStaffActivity({
|
||||
staffId: staff.id,
|
||||
action: "ban_lift",
|
||||
description: `Lifted ban #${id}`,
|
||||
});
|
||||
const result = await peopleMutationService.execute(
|
||||
createPeopleMutationInvocation(staff, createCorrelationId()),
|
||||
"ban.lift",
|
||||
{ id },
|
||||
);
|
||||
if (!result.ok) throw new Error("Could not lift ban");
|
||||
}
|
||||
revalidatePath("/admin/bans");
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -4,7 +4,6 @@ import { eq } from "drizzle-orm";
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { redirect } from "next/navigation";
|
||||
import { z } from "zod";
|
||||
import { positiveBigInt } from "@/lib/api";
|
||||
import { auth } from "@/lib/auth";
|
||||
import {
|
||||
db,
|
||||
@@ -14,7 +13,10 @@ import {
|
||||
} from "@/lib/db";
|
||||
import { clientIp, rateLimit } from "@/lib/rate-limit";
|
||||
import { moderateOrThrow } from "@/lib/services/moderation";
|
||||
import { createOwnedTicketReply } from "@/lib/services/ticket-replies";
|
||||
import {
|
||||
canonicalTicketId,
|
||||
createOwnedTicketReply,
|
||||
} from "@/lib/services/ticket-replies";
|
||||
|
||||
const ticketSchema = z.object({
|
||||
title: z.string().min(1, "Title is required").max(255),
|
||||
@@ -64,6 +66,14 @@ function isNextRedirect(e: unknown): boolean {
|
||||
);
|
||||
}
|
||||
|
||||
function helpTicketId(formData: FormData): bigint | null {
|
||||
try {
|
||||
return canonicalTicketId(String(formData.get("ticketId") ?? ""));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function createTicket(formData: FormData): Promise<void> {
|
||||
let outcome: TicketOutcome = "error";
|
||||
|
||||
@@ -177,7 +187,7 @@ const replyContentSchema = z.object({
|
||||
});
|
||||
|
||||
export async function replyHelpTicket(formData: FormData): Promise<void> {
|
||||
const ticketId = positiveBigInt(String(formData.get("ticketId") ?? ""));
|
||||
const ticketId = helpTicketId(formData);
|
||||
let outcome: TicketDetailOutcome = "error";
|
||||
|
||||
try {
|
||||
@@ -284,7 +294,7 @@ export async function replyHelpTicket(formData: FormData): Promise<void> {
|
||||
}
|
||||
|
||||
export async function closeHelpTicket(formData: FormData): Promise<void> {
|
||||
const ticketId = positiveBigInt(String(formData.get("ticketId") ?? ""));
|
||||
const ticketId = helpTicketId(formData);
|
||||
let outcome: TicketDetailOutcome = "error";
|
||||
|
||||
try {
|
||||
|
||||
+57
-111
@@ -1,13 +1,14 @@
|
||||
"use server";
|
||||
|
||||
import { eq } from "drizzle-orm";
|
||||
import { z } from "zod";
|
||||
import { db, SupportTickets } from "@/lib/db";
|
||||
import {
|
||||
createPeopleMutationInvocation,
|
||||
peopleMutationService,
|
||||
} from "@/features/housekeeping/domains/people/services/mutations";
|
||||
import { createCorrelationId } from "@/features/housekeeping/foundation/contracts";
|
||||
import { actionOk, adminAction } from "@/lib/foundation/action";
|
||||
import { NotFoundError } from "@/lib/foundation/errors";
|
||||
import { PERMS } from "@/lib/permissions";
|
||||
import { logAudit } from "@/lib/services/audit";
|
||||
import { rcon } from "@/lib/services/rcon";
|
||||
|
||||
// ── CFH Ticket Actions ──────────────────────────────────────────────
|
||||
|
||||
@@ -16,28 +17,31 @@ const cfhIdSchema = z.object({ ticketId: z.coerce.number().int().positive() });
|
||||
const CFH_PERM = [PERMS.MODERATION_EDIT, PERMS.MOD_CFH_EDIT] as const;
|
||||
const MOD_ACTION_PERM = [PERMS.MODERATION_EDIT, PERMS.MOD_ACTIONS] as const;
|
||||
|
||||
async function execute(
|
||||
staff: { readonly id: number },
|
||||
operation: "cfh.resolve" | "moderation.action",
|
||||
input: unknown,
|
||||
) {
|
||||
return peopleMutationService.execute(
|
||||
createPeopleMutationInvocation(staff, createCorrelationId()),
|
||||
operation,
|
||||
input,
|
||||
);
|
||||
}
|
||||
|
||||
export const assignCfhTicket = adminAction(
|
||||
{ permission: CFH_PERM, schema: cfhIdSchema },
|
||||
async (ctx) => {
|
||||
const [ticket] = await db
|
||||
.select({ id: SupportTickets.id })
|
||||
.from(SupportTickets)
|
||||
.where(eq(SupportTickets.id, ctx.data.ticketId))
|
||||
.limit(1);
|
||||
if (!ticket) throw new NotFoundError("SupportTicket", ctx.data.ticketId);
|
||||
|
||||
await db
|
||||
.update(SupportTickets)
|
||||
.set({ modId: ctx.session.user.id, state: 1 })
|
||||
.where(eq(SupportTickets.id, ctx.data.ticketId));
|
||||
|
||||
logAudit({
|
||||
userId: ctx.session.user.id,
|
||||
action: "cfh_assign",
|
||||
target: "support_tickets",
|
||||
targetId: ctx.data.ticketId,
|
||||
const result = await execute(ctx.session.user, "cfh.resolve", {
|
||||
ticketId: ctx.data.ticketId,
|
||||
state: 1,
|
||||
});
|
||||
|
||||
if (!result.ok) {
|
||||
if (result.error.code === "NOT_FOUND") {
|
||||
throw new NotFoundError("SupportTicket", ctx.data.ticketId);
|
||||
}
|
||||
throw new Error("Could not assign support ticket");
|
||||
}
|
||||
return actionOk();
|
||||
},
|
||||
);
|
||||
@@ -50,30 +54,13 @@ const cfhStateSchema = z.object({
|
||||
export const updateCfhState = adminAction(
|
||||
{ permission: CFH_PERM, schema: cfhStateSchema },
|
||||
async (ctx) => {
|
||||
const [ticket] = await db
|
||||
.select({
|
||||
id: SupportTickets.id,
|
||||
state: SupportTickets.state,
|
||||
})
|
||||
.from(SupportTickets)
|
||||
.where(eq(SupportTickets.id, ctx.data.ticketId))
|
||||
.limit(1);
|
||||
if (!ticket) throw new NotFoundError("SupportTicket", ctx.data.ticketId);
|
||||
|
||||
await db
|
||||
.update(SupportTickets)
|
||||
.set({ state: ctx.data.state, modId: ctx.session.user.id })
|
||||
.where(eq(SupportTickets.id, ctx.data.ticketId));
|
||||
|
||||
logAudit({
|
||||
userId: ctx.session.user.id,
|
||||
action: "cfh_state_change",
|
||||
target: "support_tickets",
|
||||
targetId: ctx.data.ticketId,
|
||||
before: { state: ticket.state },
|
||||
after: { state: ctx.data.state },
|
||||
});
|
||||
|
||||
const result = await execute(ctx.session.user, "cfh.resolve", ctx.data);
|
||||
if (!result.ok) {
|
||||
if (result.error.code === "NOT_FOUND") {
|
||||
throw new NotFoundError("SupportTicket", ctx.data.ticketId);
|
||||
}
|
||||
throw new Error("Could not update support ticket");
|
||||
}
|
||||
return actionOk();
|
||||
},
|
||||
);
|
||||
@@ -81,18 +68,13 @@ export const updateCfhState = adminAction(
|
||||
export const closeCfhTicket = adminAction(
|
||||
{ permission: CFH_PERM, schema: cfhIdSchema },
|
||||
async (ctx) => {
|
||||
await db
|
||||
.update(SupportTickets)
|
||||
.set({ state: 2, modId: ctx.session.user.id })
|
||||
.where(eq(SupportTickets.id, ctx.data.ticketId));
|
||||
|
||||
logAudit({
|
||||
userId: ctx.session.user.id,
|
||||
action: "cfh_close",
|
||||
target: "support_tickets",
|
||||
targetId: ctx.data.ticketId,
|
||||
const result = await execute(ctx.session.user, "cfh.resolve", {
|
||||
ticketId: ctx.data.ticketId,
|
||||
state: 2,
|
||||
});
|
||||
|
||||
if (!result.ok && result.error.code !== "NOT_FOUND") {
|
||||
throw new Error("Could not close support ticket");
|
||||
}
|
||||
return actionOk();
|
||||
},
|
||||
);
|
||||
@@ -104,15 +86,10 @@ const userIdSchema = z.object({ userId: z.coerce.number().int().positive() });
|
||||
export const quickKick = adminAction(
|
||||
{ permission: MOD_ACTION_PERM, schema: userIdSchema },
|
||||
async (ctx) => {
|
||||
await rcon.disconnectUser(ctx.data.userId);
|
||||
|
||||
logAudit({
|
||||
userId: ctx.session.user.id,
|
||||
action: "mod_kick",
|
||||
target: "User",
|
||||
targetId: ctx.data.userId,
|
||||
await execute(ctx.session.user, "moderation.action", {
|
||||
action: "kick",
|
||||
userId: ctx.data.userId,
|
||||
});
|
||||
|
||||
return actionOk();
|
||||
},
|
||||
);
|
||||
@@ -125,16 +102,10 @@ const muteSchema = z.object({
|
||||
export const quickMute = adminAction(
|
||||
{ permission: MOD_ACTION_PERM, schema: muteSchema },
|
||||
async (ctx) => {
|
||||
await rcon.muteUser(ctx.data.userId, ctx.data.duration);
|
||||
|
||||
logAudit({
|
||||
userId: ctx.session.user.id,
|
||||
action: "mod_mute",
|
||||
target: "User",
|
||||
targetId: ctx.data.userId,
|
||||
after: { duration: ctx.data.duration },
|
||||
await execute(ctx.session.user, "moderation.action", {
|
||||
action: "mute",
|
||||
...ctx.data,
|
||||
});
|
||||
|
||||
return actionOk();
|
||||
},
|
||||
);
|
||||
@@ -142,15 +113,10 @@ export const quickMute = adminAction(
|
||||
export const quickUnmute = adminAction(
|
||||
{ permission: MOD_ACTION_PERM, schema: userIdSchema },
|
||||
async (ctx) => {
|
||||
await rcon.unmuteUser(ctx.data.userId);
|
||||
|
||||
logAudit({
|
||||
userId: ctx.session.user.id,
|
||||
action: "mod_unmute",
|
||||
target: "User",
|
||||
targetId: ctx.data.userId,
|
||||
await execute(ctx.session.user, "moderation.action", {
|
||||
action: "unmute",
|
||||
userId: ctx.data.userId,
|
||||
});
|
||||
|
||||
return actionOk();
|
||||
},
|
||||
);
|
||||
@@ -163,16 +129,10 @@ const alertSchema = z.object({
|
||||
export const quickAlert = adminAction(
|
||||
{ permission: MOD_ACTION_PERM, schema: alertSchema },
|
||||
async (ctx) => {
|
||||
await rcon.alertUser(ctx.data.userId, ctx.data.message);
|
||||
|
||||
logAudit({
|
||||
userId: ctx.session.user.id,
|
||||
action: "mod_alert",
|
||||
target: "User",
|
||||
targetId: ctx.data.userId,
|
||||
after: { message: ctx.data.message },
|
||||
await execute(ctx.session.user, "moderation.action", {
|
||||
action: "alert",
|
||||
...ctx.data,
|
||||
});
|
||||
|
||||
return actionOk();
|
||||
},
|
||||
);
|
||||
@@ -182,15 +142,10 @@ const roomIdSchema = z.object({ roomId: z.coerce.number().int().positive() });
|
||||
export const quickRoomKick = adminAction(
|
||||
{ permission: MOD_ACTION_PERM, schema: roomIdSchema },
|
||||
async (ctx) => {
|
||||
await rcon.kickAll(ctx.data.roomId);
|
||||
|
||||
logAudit({
|
||||
userId: ctx.session.user.id,
|
||||
action: "mod_room_kick",
|
||||
target: "Room",
|
||||
targetId: ctx.data.roomId,
|
||||
await execute(ctx.session.user, "moderation.action", {
|
||||
action: "room-kick",
|
||||
roomId: ctx.data.roomId,
|
||||
});
|
||||
|
||||
return actionOk();
|
||||
},
|
||||
);
|
||||
@@ -203,19 +158,10 @@ const broadcastSchema = z.object({
|
||||
export const broadcastAlert = adminAction(
|
||||
{ permission: MOD_ACTION_PERM, schema: broadcastSchema },
|
||||
async (ctx) => {
|
||||
if (ctx.data.type === "hotel") {
|
||||
await rcon.hotelAlert(ctx.data.message);
|
||||
} else {
|
||||
await rcon.staffAlert(ctx.data.message);
|
||||
}
|
||||
|
||||
logAudit({
|
||||
userId: ctx.session.user.id,
|
||||
action: `mod_broadcast_${ctx.data.type}`,
|
||||
target: "broadcast",
|
||||
after: { message: ctx.data.message },
|
||||
await execute(ctx.session.user, "moderation.action", {
|
||||
action: "broadcast",
|
||||
...ctx.data,
|
||||
});
|
||||
|
||||
return actionOk();
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,216 @@
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const { execute, registrations, staff } = vi.hoisted(() => ({
|
||||
execute: vi.fn(),
|
||||
registrations: [] as Array<{ permission: string | readonly string[] }>,
|
||||
staff: { id: 42, rank: 4, username: "moderator" },
|
||||
}));
|
||||
|
||||
function wrapper(
|
||||
options: { permission: string | readonly string[] },
|
||||
handler: (context: { data: unknown; session: { user: typeof staff } }) => unknown,
|
||||
) {
|
||||
registrations.push(options);
|
||||
return (data: unknown) => handler({ data, session: { user: staff } });
|
||||
}
|
||||
|
||||
vi.mock("@/features/housekeeping/domains/people/services/mutations", () => ({
|
||||
createPeopleMutationInvocation: vi.fn((actor, correlationId) => ({
|
||||
expectedActorId: actor.id,
|
||||
correlationId,
|
||||
legacy: true,
|
||||
})),
|
||||
peopleMutationService: { execute },
|
||||
}));
|
||||
vi.mock("@/lib/safe-action", () => ({ adminAction: wrapper }));
|
||||
vi.mock("@/lib/safe-action-shared", () => ({
|
||||
ActionError: class ActionError extends Error {},
|
||||
actionOk: (data?: unknown) => ({ ok: true, data: data ?? {} }),
|
||||
}));
|
||||
vi.mock("@/lib/foundation/action", () => ({
|
||||
adminAction: wrapper,
|
||||
actionOk: (data?: unknown) => ({ ok: true, data: data ?? {} }),
|
||||
}));
|
||||
vi.mock("@/lib/permissions", () => ({
|
||||
PERMS: {
|
||||
TICKETS_EDIT: "admin.tickets.edit",
|
||||
MOD_TICKETS_EDIT: "mod.tickets.edit",
|
||||
USERS_BAN: "admin.users.ban",
|
||||
MODERATION_EDIT: "admin.moderation.edit",
|
||||
MOD_CFH_EDIT: "mod.cfh.edit",
|
||||
MOD_ACTIONS: "mod.actions",
|
||||
},
|
||||
}));
|
||||
vi.mock("next/cache", () => ({ revalidatePath: vi.fn() }));
|
||||
|
||||
import {
|
||||
closeHelpCenterTicket,
|
||||
liftBanFromHelpTicket,
|
||||
reopenHelpCenterTicket,
|
||||
replyHelpCenterTicket,
|
||||
} from "./admin-help-tickets";
|
||||
import {
|
||||
assignCfhTicket,
|
||||
broadcastAlert,
|
||||
closeCfhTicket,
|
||||
quickAlert,
|
||||
quickKick,
|
||||
quickMute,
|
||||
quickRoomKick,
|
||||
quickUnmute,
|
||||
updateCfhState,
|
||||
} from "./moderation";
|
||||
import {
|
||||
createTemplate,
|
||||
deleteTemplate,
|
||||
updateTemplate,
|
||||
} from "./ticket-templates";
|
||||
import {
|
||||
adminReplyTicket,
|
||||
assignTicket,
|
||||
updateTicketPriority,
|
||||
updateTicketStatus,
|
||||
} from "./tickets";
|
||||
|
||||
type LegacyAction = (input: unknown) => Promise<unknown>;
|
||||
const call = (action: unknown, input: unknown) =>
|
||||
(action as LegacyAction)(input);
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
execute.mockImplementation(async (invocation, operation) => ({
|
||||
ok: true,
|
||||
data: {
|
||||
before: null,
|
||||
after: operation === "ticket-template.change" ? { id: "88" } : {},
|
||||
output:
|
||||
operation === "help-ticket.unban"
|
||||
? { removed: 2, userId: 7 }
|
||||
: undefined,
|
||||
},
|
||||
correlationId: invocation.correlationId,
|
||||
}));
|
||||
});
|
||||
|
||||
describe("legacy People support and moderation wrappers", () => {
|
||||
it("keeps mid-rank ACL alternatives without an admin.dashboard dependency", () => {
|
||||
const permissions = registrations.flatMap((entry) =>
|
||||
typeof entry.permission === "string"
|
||||
? [entry.permission]
|
||||
: entry.permission,
|
||||
);
|
||||
expect(permissions).toEqual(
|
||||
expect.arrayContaining([
|
||||
"admin.tickets.edit",
|
||||
"mod.tickets.edit",
|
||||
"admin.moderation.edit",
|
||||
"mod.cfh.edit",
|
||||
"mod.actions",
|
||||
]),
|
||||
);
|
||||
expect(permissions).not.toContain("admin.dashboard");
|
||||
});
|
||||
|
||||
it("delegates tickets and templates with their established result shapes", async () => {
|
||||
await expect(
|
||||
call(adminReplyTicket, { ticketId: 7, message: "Handled" }),
|
||||
).resolves.toEqual({ ok: true, data: {} });
|
||||
await call(assignTicket, { ticketId: 7, assigneeId: 42 });
|
||||
await call(updateTicketStatus, { ticketId: 7, status: "closed" });
|
||||
await call(updateTicketPriority, { ticketId: 7, priority: "urgent" });
|
||||
await expect(
|
||||
call(createTemplate, {
|
||||
title: "Greeting",
|
||||
content: "Hello",
|
||||
category: "general",
|
||||
sortOrder: 0,
|
||||
}),
|
||||
).resolves.toEqual({ ok: true, data: { id: 88 } });
|
||||
await expect(
|
||||
call(updateTemplate, { id: 88, title: "Updated" }),
|
||||
).resolves.toEqual({ ok: true, data: { id: 88 } });
|
||||
await expect(call(deleteTemplate, { id: 88 })).resolves.toEqual({
|
||||
ok: true,
|
||||
data: {},
|
||||
});
|
||||
|
||||
expect(execute.mock.calls.map((entry) => entry[1])).toEqual([
|
||||
"ticket.reply",
|
||||
"ticket.assign",
|
||||
"ticket.status",
|
||||
"ticket.priority",
|
||||
"ticket-template.change",
|
||||
"ticket-template.change",
|
||||
"ticket-template.change",
|
||||
]);
|
||||
});
|
||||
|
||||
it("preserves BIGINT help-ticket IDs, outputs, and every legacy refresh", async () => {
|
||||
const ticketId = 9_007_199_254_740_993n;
|
||||
await call(replyHelpCenterTicket, { ticketId, content: " Handled " });
|
||||
await call(closeHelpCenterTicket, { ticketId });
|
||||
await call(reopenHelpCenterTicket, { ticketId });
|
||||
await expect(
|
||||
call(liftBanFromHelpTicket, { ticketId }),
|
||||
).resolves.toEqual({ ok: true, data: { removed: 2, userId: 7 } });
|
||||
|
||||
expect(execute.mock.calls.map((entry) => entry[2])).toEqual([
|
||||
{ ticketId: "9007199254740993", content: "Handled" },
|
||||
{ ticketId: "9007199254740993", status: "close" },
|
||||
{ ticketId: "9007199254740993", status: "reopen" },
|
||||
{ ticketId: "9007199254740993" },
|
||||
]);
|
||||
expect(revalidatePath).toHaveBeenCalledWith("/admin/help-tickets");
|
||||
expect(revalidatePath).toHaveBeenCalledWith(
|
||||
"/admin/help-tickets/9007199254740993",
|
||||
);
|
||||
expect(revalidatePath).toHaveBeenCalledWith(
|
||||
"/mod/help-tickets/9007199254740993",
|
||||
);
|
||||
expect(revalidatePath).toHaveBeenCalledWith(
|
||||
"/help/tickets/9007199254740993",
|
||||
);
|
||||
expect(revalidatePath).toHaveBeenCalledWith("/admin/bans");
|
||||
expect(revalidatePath).toHaveBeenCalledWith("/admin/users/show/7");
|
||||
});
|
||||
|
||||
it("delegates every CFH and moderation transport action", async () => {
|
||||
await call(assignCfhTicket, { ticketId: 9 });
|
||||
await call(updateCfhState, { ticketId: 9, state: 3 });
|
||||
await call(closeCfhTicket, { ticketId: 9 });
|
||||
await call(quickKick, { userId: 7 });
|
||||
await call(quickMute, { userId: 7, duration: 60 });
|
||||
await call(quickUnmute, { userId: 7 });
|
||||
await call(quickAlert, { userId: 7, message: "Stop" });
|
||||
await call(quickRoomKick, { roomId: 12 });
|
||||
await call(broadcastAlert, { message: "Notice", type: "staff" });
|
||||
|
||||
expect(execute.mock.calls.map((entry) => entry[1])).toEqual([
|
||||
"cfh.resolve",
|
||||
"cfh.resolve",
|
||||
"cfh.resolve",
|
||||
"moderation.action",
|
||||
"moderation.action",
|
||||
"moderation.action",
|
||||
"moderation.action",
|
||||
"moderation.action",
|
||||
"moderation.action",
|
||||
]);
|
||||
expect(execute.mock.calls.map((entry) => entry[0].expectedActorId)).toEqual(
|
||||
Array(9).fill(42),
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps close-CFH missing rows as a successful legacy no-op", async () => {
|
||||
execute.mockResolvedValueOnce({
|
||||
ok: false,
|
||||
error: { code: "NOT_FOUND", messageKey: "errors.housekeeping.notFound" },
|
||||
correlationId: "missing-cfh",
|
||||
});
|
||||
await expect(call(closeCfhTicket, { ticketId: 404 })).resolves.toEqual({
|
||||
ok: true,
|
||||
data: {},
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,8 +1,11 @@
|
||||
"use server";
|
||||
|
||||
import { eq } from "drizzle-orm";
|
||||
import { z } from "zod";
|
||||
import { db, WebsiteTicketTemplate } from "@/lib/db";
|
||||
import {
|
||||
createPeopleMutationInvocation,
|
||||
peopleMutationService,
|
||||
} from "@/features/housekeeping/domains/people/services/mutations";
|
||||
import { createCorrelationId } from "@/features/housekeeping/foundation/contracts";
|
||||
import { PERMS } from "@/lib/permissions";
|
||||
import { adminAction } from "@/lib/safe-action";
|
||||
import { ActionError, actionOk } from "@/lib/safe-action-shared";
|
||||
@@ -14,11 +17,33 @@ const templateSchema = z.object({
|
||||
sortOrder: z.coerce.number().int().min(0).default(0),
|
||||
});
|
||||
|
||||
async function execute(
|
||||
staff: { readonly id: number },
|
||||
input: unknown,
|
||||
) {
|
||||
const result = await peopleMutationService.execute(
|
||||
createPeopleMutationInvocation(staff, createCorrelationId()),
|
||||
"ticket-template.change",
|
||||
input,
|
||||
);
|
||||
if (!result.ok) {
|
||||
throw new ActionError(
|
||||
result.error.code === "NOT_FOUND"
|
||||
? "Template not found"
|
||||
: "Template update failed",
|
||||
);
|
||||
}
|
||||
return result.data;
|
||||
}
|
||||
|
||||
export const createTemplate = adminAction(
|
||||
{ permission: PERMS.TICKETS_EDIT, schema: templateSchema },
|
||||
async (ctx) => {
|
||||
const [result] = await db.insert(WebsiteTicketTemplate).values(ctx.data);
|
||||
return actionOk({ id: Number(result.insertId) });
|
||||
const snapshot = await execute(ctx.session.user, {
|
||||
action: "create",
|
||||
...ctx.data,
|
||||
});
|
||||
return actionOk({ id: Number(snapshot.after?.id) });
|
||||
},
|
||||
);
|
||||
|
||||
@@ -30,16 +55,7 @@ export const updateTemplate = adminAction(
|
||||
{ permission: PERMS.TICKETS_EDIT, schema: updateTemplateInput },
|
||||
async (ctx) => {
|
||||
const { id, ...data } = ctx.data;
|
||||
const [existing] = await db
|
||||
.select({ id: WebsiteTicketTemplate.id })
|
||||
.from(WebsiteTicketTemplate)
|
||||
.where(eq(WebsiteTicketTemplate.id, id))
|
||||
.limit(1);
|
||||
if (!existing) throw new ActionError("Template not found");
|
||||
await db
|
||||
.update(WebsiteTicketTemplate)
|
||||
.set(data)
|
||||
.where(eq(WebsiteTicketTemplate.id, id));
|
||||
await execute(ctx.session.user, { action: "update", id, ...data });
|
||||
return actionOk({ id });
|
||||
},
|
||||
);
|
||||
@@ -51,9 +67,7 @@ const deleteTemplateInput = z.object({
|
||||
export const deleteTemplate = adminAction(
|
||||
{ permission: PERMS.TICKETS_EDIT, schema: deleteTemplateInput },
|
||||
async (ctx) => {
|
||||
await db
|
||||
.delete(WebsiteTicketTemplate)
|
||||
.where(eq(WebsiteTicketTemplate.id, ctx.data.id));
|
||||
await execute(ctx.session.user, { action: "delete", id: ctx.data.id });
|
||||
return actionOk();
|
||||
},
|
||||
);
|
||||
+30
-133
@@ -1,11 +1,13 @@
|
||||
"use server";
|
||||
|
||||
import { eq } from "drizzle-orm";
|
||||
import { db, WebsiteTicket, WebsiteTicketMessage } from "@/lib/db";
|
||||
import {
|
||||
createPeopleMutationInvocation,
|
||||
peopleMutationService,
|
||||
} from "@/features/housekeeping/domains/people/services/mutations";
|
||||
import { createCorrelationId } from "@/features/housekeeping/foundation/contracts";
|
||||
import { PERMS } from "@/lib/permissions";
|
||||
import { adminAction } from "@/lib/safe-action";
|
||||
import { ActionError, actionOk } from "@/lib/safe-action-shared";
|
||||
import { logAudit } from "@/lib/services/audit";
|
||||
import {
|
||||
assignTicketSchema,
|
||||
replyTicketSchema,
|
||||
@@ -13,6 +15,27 @@ import {
|
||||
updateTicketStatusSchema,
|
||||
} from "@/lib/validators/ticket";
|
||||
|
||||
async function execute(
|
||||
staff: { readonly id: number },
|
||||
operation:
|
||||
| "ticket.reply"
|
||||
| "ticket.assign"
|
||||
| "ticket.status"
|
||||
| "ticket.priority",
|
||||
input: unknown,
|
||||
) {
|
||||
const result = await peopleMutationService.execute(
|
||||
createPeopleMutationInvocation(staff, createCorrelationId()),
|
||||
operation,
|
||||
input,
|
||||
);
|
||||
if (!result.ok) {
|
||||
throw new ActionError(
|
||||
result.error.code === "NOT_FOUND" ? "Ticket not found" : "Ticket update failed",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ── User actions (authenticated, no admin perms needed) ──────────────
|
||||
|
||||
export const adminReplyTicket = adminAction(
|
||||
@@ -21,45 +44,7 @@ export const adminReplyTicket = adminAction(
|
||||
schema: replyTicketSchema,
|
||||
},
|
||||
async (ctx) => {
|
||||
const [ticket] = await db
|
||||
.select({
|
||||
id: WebsiteTicket.id,
|
||||
assigneeId: WebsiteTicket.assigneeId,
|
||||
})
|
||||
.from(WebsiteTicket)
|
||||
.where(eq(WebsiteTicket.id, ctx.data.ticketId))
|
||||
.limit(1);
|
||||
|
||||
if (!ticket) throw new ActionError("Ticket not found");
|
||||
|
||||
await db.insert(WebsiteTicketMessage).values({
|
||||
ticketId: ctx.data.ticketId,
|
||||
userId: ctx.session.user.id,
|
||||
message: ctx.data.message,
|
||||
isStaff: 1,
|
||||
});
|
||||
|
||||
// Auto-assign if not assigned yet
|
||||
const updates: Partial<typeof WebsiteTicket.$inferInsert> = {
|
||||
status: "waiting",
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
if (!ticket.assigneeId) {
|
||||
updates.assigneeId = ctx.session.user.id;
|
||||
}
|
||||
|
||||
await db
|
||||
.update(WebsiteTicket)
|
||||
.set(updates)
|
||||
.where(eq(WebsiteTicket.id, ctx.data.ticketId));
|
||||
|
||||
logAudit({
|
||||
userId: ctx.session.user.id,
|
||||
action: "ticket_reply",
|
||||
target: "WebsiteTicket",
|
||||
targetId: ctx.data.ticketId,
|
||||
});
|
||||
|
||||
await execute(ctx.session.user, "ticket.reply", ctx.data);
|
||||
return actionOk();
|
||||
},
|
||||
);
|
||||
@@ -70,43 +55,7 @@ export const updateTicketStatus = adminAction(
|
||||
schema: updateTicketStatusSchema,
|
||||
},
|
||||
async (ctx) => {
|
||||
const [ticket] = await db
|
||||
.select({
|
||||
id: WebsiteTicket.id,
|
||||
assigneeId: WebsiteTicket.assigneeId,
|
||||
status: WebsiteTicket.status,
|
||||
})
|
||||
.from(WebsiteTicket)
|
||||
.where(eq(WebsiteTicket.id, ctx.data.ticketId))
|
||||
.limit(1);
|
||||
|
||||
if (!ticket) throw new ActionError("Ticket not found");
|
||||
|
||||
const data: Partial<typeof WebsiteTicket.$inferInsert> = {
|
||||
status: ctx.data.status,
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
if (ctx.data.status === "closed") {
|
||||
data.closedAt = new Date();
|
||||
}
|
||||
if (ctx.data.status === "in_progress" && !ticket.assigneeId) {
|
||||
data.assigneeId = ctx.session.user.id;
|
||||
}
|
||||
|
||||
await db
|
||||
.update(WebsiteTicket)
|
||||
.set(data)
|
||||
.where(eq(WebsiteTicket.id, ctx.data.ticketId));
|
||||
|
||||
logAudit({
|
||||
userId: ctx.session.user.id,
|
||||
action: "ticket_status_change",
|
||||
target: "WebsiteTicket",
|
||||
targetId: ctx.data.ticketId,
|
||||
before: { status: ticket.status },
|
||||
after: { status: ctx.data.status },
|
||||
});
|
||||
|
||||
await execute(ctx.session.user, "ticket.status", ctx.data);
|
||||
return actionOk();
|
||||
},
|
||||
);
|
||||
@@ -117,35 +66,7 @@ export const assignTicket = adminAction(
|
||||
schema: assignTicketSchema,
|
||||
},
|
||||
async (ctx) => {
|
||||
const [ticket] = await db
|
||||
.select({
|
||||
id: WebsiteTicket.id,
|
||||
assigneeId: WebsiteTicket.assigneeId,
|
||||
})
|
||||
.from(WebsiteTicket)
|
||||
.where(eq(WebsiteTicket.id, ctx.data.ticketId))
|
||||
.limit(1);
|
||||
|
||||
if (!ticket) throw new ActionError("Ticket not found");
|
||||
|
||||
await db
|
||||
.update(WebsiteTicket)
|
||||
.set({
|
||||
assigneeId: ctx.data.assigneeId,
|
||||
status: ctx.data.assigneeId ? "in_progress" : "open",
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(WebsiteTicket.id, ctx.data.ticketId));
|
||||
|
||||
logAudit({
|
||||
userId: ctx.session.user.id,
|
||||
action: "ticket_assign",
|
||||
target: "WebsiteTicket",
|
||||
targetId: ctx.data.ticketId,
|
||||
before: { assigneeId: ticket.assigneeId },
|
||||
after: { assigneeId: ctx.data.assigneeId },
|
||||
});
|
||||
|
||||
await execute(ctx.session.user, "ticket.assign", ctx.data);
|
||||
return actionOk();
|
||||
},
|
||||
);
|
||||
@@ -156,31 +77,7 @@ export const updateTicketPriority = adminAction(
|
||||
schema: updateTicketPrioritySchema,
|
||||
},
|
||||
async (ctx) => {
|
||||
const [ticket] = await db
|
||||
.select({
|
||||
id: WebsiteTicket.id,
|
||||
priority: WebsiteTicket.priority,
|
||||
})
|
||||
.from(WebsiteTicket)
|
||||
.where(eq(WebsiteTicket.id, ctx.data.ticketId))
|
||||
.limit(1);
|
||||
|
||||
if (!ticket) throw new ActionError("Ticket not found");
|
||||
|
||||
await db
|
||||
.update(WebsiteTicket)
|
||||
.set({ priority: ctx.data.priority, updatedAt: new Date() })
|
||||
.where(eq(WebsiteTicket.id, ctx.data.ticketId));
|
||||
|
||||
logAudit({
|
||||
userId: ctx.session.user.id,
|
||||
action: "ticket_priority_change",
|
||||
target: "WebsiteTicket",
|
||||
targetId: ctx.data.ticketId,
|
||||
before: { priority: ticket.priority },
|
||||
after: { priority: ctx.data.priority },
|
||||
});
|
||||
|
||||
await execute(ctx.session.user, "ticket.priority", ctx.data);
|
||||
return actionOk();
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,142 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { PERMS } from "@/lib/permission-slugs";
|
||||
import { confirmHousekeepingCommand } from "../../../foundation/commands/confirmation";
|
||||
import type { HousekeepingCommand } from "../../../foundation/commands/registry";
|
||||
import {
|
||||
createModerationCommands,
|
||||
MODERATION_COMMAND_IDS,
|
||||
MODERATION_COMMANDS,
|
||||
} from "./moderation-commands";
|
||||
|
||||
vi.mock("@/lib/auth", () => ({ invalidateLoginCache: vi.fn() }));
|
||||
vi.mock("@/lib/auth/password", () => ({ hashPassword: vi.fn() }));
|
||||
|
||||
const commands = MODERATION_COMMANDS as unknown as readonly HousekeepingCommand<
|
||||
unknown,
|
||||
unknown
|
||||
>[];
|
||||
|
||||
describe("People moderation commands", () => {
|
||||
it("declares CFH resolve/sanction, ban/unban, and moderation action", () => {
|
||||
expect(MODERATION_COMMAND_IDS).toEqual([
|
||||
"people.cfh.resolve",
|
||||
"people.cfh.sanction",
|
||||
"people.ban.create",
|
||||
"people.ban.lift",
|
||||
"people.moderation.action",
|
||||
]);
|
||||
expect(commands.map((command) => command.id)).toEqual(
|
||||
MODERATION_COMMAND_IDS,
|
||||
);
|
||||
expect(commands[0]?.capability.slugs).toEqual([
|
||||
PERMS.MODERATION_EDIT,
|
||||
PERMS.MOD_CFH_EDIT,
|
||||
]);
|
||||
expect(commands[4]?.capability.slugs).toEqual([
|
||||
PERMS.MODERATION_EDIT,
|
||||
PERMS.MOD_ACTIONS,
|
||||
]);
|
||||
});
|
||||
|
||||
it("requires reasons for sanctions and bans", () => {
|
||||
for (const id of [
|
||||
"people.cfh.sanction",
|
||||
"people.ban.create",
|
||||
"people.ban.lift",
|
||||
]) {
|
||||
const command = commands.find((entry) => entry.id === id);
|
||||
if (!command) throw new Error("command missing");
|
||||
expect(command.requiresReason).toBe(true);
|
||||
expect(confirmHousekeepingCommand(command, " ", "moderation")).toMatchObject({
|
||||
ok: false,
|
||||
error: { code: "VALIDATION" },
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it("bounds and delegates a CFH sanction", async () => {
|
||||
const execute = vi.fn(async (invocation) => ({
|
||||
ok: true as const,
|
||||
data: { before: null, after: null },
|
||||
correlationId: invocation.correlationId,
|
||||
}));
|
||||
const created = createModerationCommands({
|
||||
execute,
|
||||
}) as unknown as readonly HousekeepingCommand<unknown, unknown>[];
|
||||
const command = created.find(
|
||||
(entry) => entry.id === "people.cfh.sanction",
|
||||
);
|
||||
if (!command) throw new Error("command missing");
|
||||
expect(
|
||||
command.input.safeParse({
|
||||
ticketId: 9,
|
||||
userId: 7,
|
||||
action: "mute",
|
||||
duration: 525601,
|
||||
reason: "spam",
|
||||
}).success,
|
||||
).toBe(false);
|
||||
const input = command.input.parse({
|
||||
ticketId: 9,
|
||||
userId: 7,
|
||||
action: "mute",
|
||||
duration: 60,
|
||||
reason: "spam",
|
||||
});
|
||||
await command.execute(
|
||||
{
|
||||
capability: {
|
||||
actor: { id: 42, username: "mod", rank: 4 },
|
||||
isSuperAdmin: false,
|
||||
has: () => false,
|
||||
hasAny: () => true,
|
||||
hasAll: () => false,
|
||||
},
|
||||
correlationId: "moderation-red",
|
||||
ipAddress: "198.51.100.9",
|
||||
},
|
||||
input,
|
||||
);
|
||||
expect(execute).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ expectedActorId: 42 }),
|
||||
"cfh.sanction",
|
||||
input,
|
||||
);
|
||||
});
|
||||
|
||||
it.each([
|
||||
["people.cfh.resolve", { ticketId: 9, state: 2 }, "cfh.resolve"],
|
||||
["people.ban.create", { userId: 7, reason: "spam", hours: 24, type: "account" }, "ban.create"],
|
||||
["people.ban.lift", { id: 12 }, "ban.lift"],
|
||||
["people.moderation.action", { action: "alert", userId: 7, message: "Stop" }, "moderation.action"],
|
||||
] as const)("delegates %s to %s", async (id, input, operation) => {
|
||||
const execute = vi.fn(async (invocation) => ({
|
||||
ok: true as const,
|
||||
data: { before: null, after: null },
|
||||
correlationId: invocation.correlationId,
|
||||
}));
|
||||
const created = createModerationCommands({ execute }) as unknown as readonly HousekeepingCommand<unknown, unknown>[];
|
||||
const command = created.find((entry) => entry.id === id);
|
||||
if (!command) throw new Error("command missing");
|
||||
const parsed = command.input.parse(input);
|
||||
await command.execute(
|
||||
{
|
||||
capability: {
|
||||
actor: { id: 42, username: "mod", rank: 4 },
|
||||
isSuperAdmin: false,
|
||||
has: () => false,
|
||||
hasAny: () => true,
|
||||
hasAll: () => false,
|
||||
},
|
||||
correlationId: "moderation-matrix",
|
||||
ipAddress: "198.51.100.9",
|
||||
},
|
||||
parsed,
|
||||
);
|
||||
expect(execute).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ expectedActorId: 42 }),
|
||||
operation,
|
||||
parsed,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,132 @@
|
||||
import "server-only";
|
||||
|
||||
import { z } from "zod";
|
||||
import { PERMS } from "@/lib/permission-slugs";
|
||||
import type { HousekeepingCommand } from "../../../foundation/commands/registry";
|
||||
import { anyCapability } from "../../../foundation/contracts";
|
||||
import {
|
||||
type PeopleMutationOperation,
|
||||
type PeopleMutationService,
|
||||
peopleMutationService,
|
||||
} from "../services/mutations";
|
||||
|
||||
export const MODERATION_COMMAND_IDS = [
|
||||
"people.cfh.resolve",
|
||||
"people.cfh.sanction",
|
||||
"people.ban.create",
|
||||
"people.ban.lift",
|
||||
"people.moderation.action",
|
||||
] as const;
|
||||
|
||||
type ModerationCommandId = (typeof MODERATION_COMMAND_IDS)[number];
|
||||
const positiveId = z.number().int().positive();
|
||||
const requiredText = (max: number) => z.string().min(1).max(max).regex(/\S/u);
|
||||
|
||||
function command<I>(
|
||||
service: Pick<PeopleMutationService, "execute">,
|
||||
options: {
|
||||
id: ModerationCommandId;
|
||||
operation: PeopleMutationOperation;
|
||||
capability: readonly string[];
|
||||
input: z.ZodType<I>;
|
||||
requiresReason?: boolean;
|
||||
},
|
||||
): HousekeepingCommand<I, unknown> {
|
||||
return {
|
||||
id: options.id,
|
||||
owner: "people",
|
||||
risk: "sensitive",
|
||||
capability: anyCapability(...options.capability),
|
||||
input: options.input,
|
||||
requiresReason: options.requiresReason === true,
|
||||
rateLimit: { attempts: 5, windowMs: 60_000 },
|
||||
execute: (context, input) =>
|
||||
service.execute(
|
||||
{
|
||||
correlationId: context.correlationId,
|
||||
expectedActorId: context.capability.actor.id,
|
||||
},
|
||||
options.operation,
|
||||
input,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
export function createModerationCommands(
|
||||
service: Pick<PeopleMutationService, "execute">,
|
||||
) {
|
||||
const cfhEdit = [PERMS.MODERATION_EDIT, PERMS.MOD_CFH_EDIT] as const;
|
||||
const modAction = [PERMS.MODERATION_EDIT, PERMS.MOD_ACTIONS] as const;
|
||||
return [
|
||||
command(service, {
|
||||
id: "people.cfh.resolve",
|
||||
operation: "cfh.resolve",
|
||||
capability: cfhEdit,
|
||||
input: z.object({
|
||||
ticketId: positiveId,
|
||||
state: z.number().int().min(0).max(3),
|
||||
}),
|
||||
}),
|
||||
command(service, {
|
||||
id: "people.cfh.sanction",
|
||||
operation: "cfh.sanction",
|
||||
capability: cfhEdit,
|
||||
input: z.object({
|
||||
ticketId: positiveId,
|
||||
userId: positiveId,
|
||||
action: z.enum(["kick", "mute", "alert"]),
|
||||
duration: z.number().int().min(0).max(525_600).optional(),
|
||||
message: z.string().max(500).optional(),
|
||||
reason: requiredText(500),
|
||||
}),
|
||||
requiresReason: true,
|
||||
}),
|
||||
command(service, {
|
||||
id: "people.ban.create",
|
||||
operation: "ban.create",
|
||||
capability: [PERMS.USERS_BAN],
|
||||
input: z.object({
|
||||
userId: positiveId,
|
||||
reason: requiredText(500),
|
||||
hours: z.number().int().min(0).max(876_000),
|
||||
type: z.enum(["account", "ip", "machine", "super"]),
|
||||
}),
|
||||
requiresReason: true,
|
||||
}),
|
||||
command(service, {
|
||||
id: "people.ban.lift",
|
||||
operation: "ban.lift",
|
||||
capability: [PERMS.USERS_BAN],
|
||||
input: z.object({ id: positiveId }),
|
||||
requiresReason: true,
|
||||
}),
|
||||
command(service, {
|
||||
id: "people.moderation.action",
|
||||
operation: "moderation.action",
|
||||
capability: modAction,
|
||||
input: z.discriminatedUnion("action", [
|
||||
z.object({ action: z.literal("kick"), userId: positiveId }),
|
||||
z.object({
|
||||
action: z.literal("mute"),
|
||||
userId: positiveId,
|
||||
duration: z.number().int().min(0).max(525_600),
|
||||
}),
|
||||
z.object({ action: z.literal("unmute"), userId: positiveId }),
|
||||
z.object({
|
||||
action: z.literal("alert"),
|
||||
userId: positiveId,
|
||||
message: requiredText(500),
|
||||
}),
|
||||
z.object({ action: z.literal("room-kick"), roomId: positiveId }),
|
||||
z.object({
|
||||
action: z.literal("broadcast"),
|
||||
message: requiredText(500),
|
||||
type: z.enum(["hotel", "staff"]),
|
||||
}),
|
||||
]),
|
||||
}),
|
||||
] as const;
|
||||
}
|
||||
|
||||
export const MODERATION_COMMANDS =
|
||||
createModerationCommands(peopleMutationService);
|
||||
@@ -0,0 +1,104 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { PERMS } from "@/lib/permission-slugs";
|
||||
import type { HousekeepingCommand } from "../../../foundation/commands/registry";
|
||||
import {
|
||||
createSupportCommands,
|
||||
SUPPORT_COMMAND_IDS,
|
||||
SUPPORT_COMMANDS,
|
||||
} from "./support-commands";
|
||||
|
||||
vi.mock("@/lib/auth", () => ({ invalidateLoginCache: vi.fn() }));
|
||||
vi.mock("@/lib/auth/password", () => ({ hashPassword: vi.fn() }));
|
||||
|
||||
const commands = SUPPORT_COMMANDS as unknown as readonly HousekeepingCommand<
|
||||
unknown,
|
||||
unknown
|
||||
>[];
|
||||
|
||||
describe("People support commands", () => {
|
||||
it("declares assign, reply, close/reopen, template, and help-ticket workflows", () => {
|
||||
expect(SUPPORT_COMMAND_IDS).toEqual([
|
||||
"people.ticket.reply",
|
||||
"people.ticket.assign",
|
||||
"people.ticket.status",
|
||||
"people.ticket.priority",
|
||||
"people.ticket-template.change",
|
||||
"people.help-ticket.reply",
|
||||
"people.help-ticket.status",
|
||||
"people.help-ticket.unban",
|
||||
]);
|
||||
expect(commands.map((command) => command.id)).toEqual(SUPPORT_COMMAND_IDS);
|
||||
});
|
||||
|
||||
it("preserves mod.* editing and canonical decimal BIGINT help IDs", () => {
|
||||
for (const command of commands.filter((entry) =>
|
||||
entry.id.startsWith("people.ticket."),
|
||||
)) {
|
||||
expect(command.capability.slugs).toEqual([
|
||||
PERMS.TICKETS_EDIT,
|
||||
PERMS.MOD_TICKETS_EDIT,
|
||||
]);
|
||||
}
|
||||
const reply = commands.find(
|
||||
(entry) => entry.id === "people.help-ticket.reply",
|
||||
);
|
||||
if (!reply) throw new Error("command missing");
|
||||
expect(
|
||||
reply.input.parse({
|
||||
ticketId: "18446744073709551615",
|
||||
content: "Handled",
|
||||
}),
|
||||
).toMatchObject({ ticketId: "18446744073709551615" });
|
||||
expect(
|
||||
reply.input.safeParse({
|
||||
ticketId: "18446744073709551616",
|
||||
content: "Handled",
|
||||
}).success,
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it.each([
|
||||
["people.ticket.reply", { ticketId: 7, message: "Handled" }, "ticket.reply"],
|
||||
["people.ticket.assign", { ticketId: 7, assigneeId: 42 }, "ticket.assign"],
|
||||
["people.ticket.status", { ticketId: 7, status: "closed" }, "ticket.status"],
|
||||
["people.ticket-template.change", { action: "create", title: "Greeting", content: "Hello" }, "ticket-template.change"],
|
||||
["people.help-ticket.reply", { ticketId: "9007199254740993", content: "Handled" }, "help-ticket.reply"],
|
||||
["people.help-ticket.status", { ticketId: "9007199254740993", status: "close" }, "help-ticket.status"],
|
||||
["people.help-ticket.status", { ticketId: "9007199254740993", status: "reopen" }, "help-ticket.status"],
|
||||
["people.help-ticket.unban", { ticketId: "9007199254740993" }, "help-ticket.unban"],
|
||||
] as const)("delegates %s to the redirect-free %s operation", async (id, input, operation) => {
|
||||
const execute = vi.fn(async (invocation) => ({
|
||||
ok: true as const,
|
||||
data: { before: null, after: null },
|
||||
correlationId: invocation.correlationId,
|
||||
}));
|
||||
const created = createSupportCommands({
|
||||
execute,
|
||||
}) as unknown as readonly HousekeepingCommand<unknown, unknown>[];
|
||||
const command = created.find((entry) => entry.id === id);
|
||||
if (!command) throw new Error("command missing");
|
||||
const parsed = command.input.parse(input);
|
||||
await command.execute(
|
||||
{
|
||||
capability: {
|
||||
actor: { id: 42, username: "mod", rank: 4 },
|
||||
isSuperAdmin: false,
|
||||
has: () => false,
|
||||
hasAny: () => true,
|
||||
hasAll: () => false,
|
||||
},
|
||||
correlationId: "support-red",
|
||||
ipAddress: "198.51.100.8",
|
||||
},
|
||||
parsed,
|
||||
);
|
||||
expect(execute).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
correlationId: "support-red",
|
||||
expectedActorId: 42,
|
||||
}),
|
||||
operation,
|
||||
parsed,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,161 @@
|
||||
import "server-only";
|
||||
|
||||
import { z } from "zod";
|
||||
import { PERMS } from "@/lib/permission-slugs";
|
||||
import type { HousekeepingCommand } from "../../../foundation/commands/registry";
|
||||
import { anyCapability } from "../../../foundation/contracts";
|
||||
import {
|
||||
type PeopleMutationOperation,
|
||||
type PeopleMutationService,
|
||||
peopleMutationService,
|
||||
} from "../services/mutations";
|
||||
|
||||
export const SUPPORT_COMMAND_IDS = [
|
||||
"people.ticket.reply",
|
||||
"people.ticket.assign",
|
||||
"people.ticket.status",
|
||||
"people.ticket.priority",
|
||||
"people.ticket-template.change",
|
||||
"people.help-ticket.reply",
|
||||
"people.help-ticket.status",
|
||||
"people.help-ticket.unban",
|
||||
] as const;
|
||||
|
||||
type SupportCommandId = (typeof SUPPORT_COMMAND_IDS)[number];
|
||||
const positiveId = z.number().int().positive();
|
||||
const text = (max: number) => z.string().min(1).max(max).regex(/\S/u);
|
||||
const MAX_UNSIGNED_BIGINT = "18446744073709551615";
|
||||
function boundedDecimalPattern(maximum: string): RegExp {
|
||||
const alternatives = [`[1-9]\\d{0,${maximum.length - 2}}`];
|
||||
for (let index = 0; index < maximum.length; index += 1) {
|
||||
const maximumDigit = Number(maximum[index]);
|
||||
const minimumDigit = index === 0 ? 1 : 0;
|
||||
const upperDigit = maximumDigit - 1;
|
||||
if (upperDigit < minimumDigit) continue;
|
||||
const digit =
|
||||
upperDigit === minimumDigit
|
||||
? String(minimumDigit)
|
||||
: `[${minimumDigit}-${upperDigit}]`;
|
||||
alternatives.push(
|
||||
`${maximum.slice(0, index)}${digit}\\d{${maximum.length - index - 1}}`,
|
||||
);
|
||||
}
|
||||
alternatives.push(maximum);
|
||||
return new RegExp(`^(?:${alternatives.join("|")})$`, "u");
|
||||
}
|
||||
const bigintId = z.string().regex(boundedDecimalPattern(MAX_UNSIGNED_BIGINT));
|
||||
|
||||
function command<I>(
|
||||
service: Pick<PeopleMutationService, "execute">,
|
||||
options: {
|
||||
id: SupportCommandId;
|
||||
operation: PeopleMutationOperation;
|
||||
capability: readonly string[];
|
||||
input: z.ZodType<I>;
|
||||
requiresReason?: boolean;
|
||||
},
|
||||
): HousekeepingCommand<I, unknown> {
|
||||
return {
|
||||
id: options.id,
|
||||
owner: "people",
|
||||
risk: "sensitive",
|
||||
capability: anyCapability(...options.capability),
|
||||
input: options.input,
|
||||
requiresReason: options.requiresReason === true,
|
||||
rateLimit: { attempts: 10, windowMs: 60_000 },
|
||||
execute: (context, input) =>
|
||||
service.execute(
|
||||
{
|
||||
correlationId: context.correlationId,
|
||||
expectedActorId: context.capability.actor.id,
|
||||
},
|
||||
options.operation,
|
||||
input,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
export function createSupportCommands(
|
||||
service: Pick<PeopleMutationService, "execute">,
|
||||
) {
|
||||
const ticketEdit = [PERMS.TICKETS_EDIT, PERMS.MOD_TICKETS_EDIT] as const;
|
||||
return [
|
||||
command(service, {
|
||||
id: "people.ticket.reply",
|
||||
operation: "ticket.reply",
|
||||
capability: ticketEdit,
|
||||
input: z.object({ ticketId: positiveId, message: text(5_000) }),
|
||||
}),
|
||||
command(service, {
|
||||
id: "people.ticket.assign",
|
||||
operation: "ticket.assign",
|
||||
capability: ticketEdit,
|
||||
input: z.object({ ticketId: positiveId, assigneeId: positiveId.nullable() }),
|
||||
}),
|
||||
command(service, {
|
||||
id: "people.ticket.status",
|
||||
operation: "ticket.status",
|
||||
capability: ticketEdit,
|
||||
input: z.object({
|
||||
ticketId: positiveId,
|
||||
status: z.enum(["open", "in_progress", "waiting", "closed"]),
|
||||
}),
|
||||
}),
|
||||
command(service, {
|
||||
id: "people.ticket.priority",
|
||||
operation: "ticket.priority",
|
||||
capability: ticketEdit,
|
||||
input: z.object({
|
||||
ticketId: positiveId,
|
||||
priority: z.enum(["low", "normal", "high", "urgent"]),
|
||||
}),
|
||||
}),
|
||||
command(service, {
|
||||
id: "people.ticket-template.change",
|
||||
operation: "ticket-template.change",
|
||||
capability: [PERMS.TICKETS_EDIT],
|
||||
input: z.discriminatedUnion("action", [
|
||||
z.object({
|
||||
action: z.literal("create"),
|
||||
title: text(255),
|
||||
content: text(5_000),
|
||||
category: z.string().max(50).optional(),
|
||||
sortOrder: z.number().int().min(0).max(2_147_483_647).optional(),
|
||||
}),
|
||||
z.object({
|
||||
action: z.literal("update"),
|
||||
id: positiveId,
|
||||
title: text(255).optional(),
|
||||
content: text(5_000).optional(),
|
||||
category: z.string().max(50).optional(),
|
||||
sortOrder: z.number().int().min(0).max(2_147_483_647).optional(),
|
||||
}),
|
||||
z.object({ action: z.literal("delete"), id: positiveId }),
|
||||
]),
|
||||
}),
|
||||
command(service, {
|
||||
id: "people.help-ticket.reply",
|
||||
operation: "help-ticket.reply",
|
||||
capability: ticketEdit,
|
||||
input: z.object({ ticketId: bigintId, content: text(5_000) }),
|
||||
}),
|
||||
command(service, {
|
||||
id: "people.help-ticket.status",
|
||||
operation: "help-ticket.status",
|
||||
capability: ticketEdit,
|
||||
input: z.object({
|
||||
ticketId: bigintId,
|
||||
status: z.enum(["close", "reopen"]),
|
||||
}),
|
||||
}),
|
||||
command(service, {
|
||||
id: "people.help-ticket.unban",
|
||||
operation: "help-ticket.unban",
|
||||
capability: [PERMS.USERS_BAN],
|
||||
input: z.object({ ticketId: bigintId }),
|
||||
requiresReason: true,
|
||||
}),
|
||||
] as const;
|
||||
}
|
||||
|
||||
export const SUPPORT_COMMANDS = createSupportCommands(peopleMutationService);
|
||||
@@ -0,0 +1,244 @@
|
||||
import "server-only";
|
||||
|
||||
import { PERMS } from "@/lib/permission-slugs";
|
||||
import { authorizeHousekeeping } from "../../foundation/authorization";
|
||||
import { satisfiesCapability } from "../../foundation/capability-context";
|
||||
import {
|
||||
anyCapability,
|
||||
type CapabilityRequirement,
|
||||
fail,
|
||||
type HousekeepingCapabilityContext,
|
||||
type HousekeepingInboxSource,
|
||||
type HousekeepingWorkItem,
|
||||
ok,
|
||||
} from "../../foundation/contracts";
|
||||
import { peopleModerationQuery } from "./queries/moderation";
|
||||
import { peopleSupportQuery } from "./queries/support";
|
||||
|
||||
export const PEOPLE_INBOX_SOURCE_IDS = [
|
||||
"people.tickets",
|
||||
"people.help-tickets",
|
||||
"people.cfh",
|
||||
"people.active-bans",
|
||||
] as const;
|
||||
|
||||
export interface PeopleInboxAdapters {
|
||||
tickets(
|
||||
context: HousekeepingCapabilityContext,
|
||||
signal: AbortSignal,
|
||||
): Promise<readonly HousekeepingWorkItem[]>;
|
||||
helpTickets(
|
||||
context: HousekeepingCapabilityContext,
|
||||
signal: AbortSignal,
|
||||
): Promise<readonly HousekeepingWorkItem[]>;
|
||||
cfh(
|
||||
context: HousekeepingCapabilityContext,
|
||||
signal: AbortSignal,
|
||||
): Promise<readonly HousekeepingWorkItem[]>;
|
||||
activeBans(
|
||||
context: HousekeepingCapabilityContext,
|
||||
signal: AbortSignal,
|
||||
): Promise<readonly HousekeepingWorkItem[]>;
|
||||
}
|
||||
|
||||
function safeHref(href: string): boolean {
|
||||
return (
|
||||
href.startsWith("/ase/") &&
|
||||
!href.includes("\\") &&
|
||||
!Array.from(href).some((character) => {
|
||||
const code = character.codePointAt(0) ?? 0;
|
||||
return code < 32 || code === 127;
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
function createSource(
|
||||
id: (typeof PEOPLE_INBOX_SOURCE_IDS)[number],
|
||||
capability: CapabilityRequirement,
|
||||
load: PeopleInboxAdapters[keyof PeopleInboxAdapters],
|
||||
): HousekeepingInboxSource {
|
||||
return {
|
||||
id,
|
||||
owner: "people",
|
||||
capability,
|
||||
async getItems(context, signal) {
|
||||
const authorization = authorizeHousekeeping(context, capability);
|
||||
if (!authorization.ok) return authorization;
|
||||
try {
|
||||
const items = await load(context, signal);
|
||||
return ok(
|
||||
{
|
||||
availability: "available" as const,
|
||||
items: items
|
||||
.filter(
|
||||
(item) =>
|
||||
safeHref(item.href) &&
|
||||
satisfiesCapability(context, item.capability),
|
||||
)
|
||||
.slice(0, 25),
|
||||
},
|
||||
authorization.correlationId,
|
||||
);
|
||||
} catch {
|
||||
return fail(
|
||||
"DEPENDENCY_UNAVAILABLE",
|
||||
"errors.housekeeping.dependencyUnavailable",
|
||||
authorization.correlationId,
|
||||
);
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function createPeopleInboxSources(
|
||||
adapters: PeopleInboxAdapters,
|
||||
): readonly HousekeepingInboxSource[] {
|
||||
return [
|
||||
createSource(
|
||||
"people.tickets",
|
||||
anyCapability(PERMS.TICKETS_VIEW, PERMS.MOD_TICKETS_VIEW),
|
||||
adapters.tickets,
|
||||
),
|
||||
createSource(
|
||||
"people.help-tickets",
|
||||
anyCapability(PERMS.TICKETS_VIEW, PERMS.MOD_TICKETS_VIEW),
|
||||
adapters.helpTickets,
|
||||
),
|
||||
createSource(
|
||||
"people.cfh",
|
||||
anyCapability(PERMS.MODERATION_VIEW, PERMS.MOD_CFH_VIEW),
|
||||
adapters.cfh,
|
||||
),
|
||||
createSource(
|
||||
"people.active-bans",
|
||||
anyCapability(PERMS.BANS_VIEW, PERMS.MOD_BANS_VIEW),
|
||||
adapters.activeBans,
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
function time(occurredAt: string | null) {
|
||||
if (occurredAt === null) return null;
|
||||
const timestamp = Date.parse(occurredAt);
|
||||
if (!Number.isFinite(timestamp)) return null;
|
||||
return {
|
||||
occurredAt,
|
||||
ageMs: Math.max(0, Date.now() - timestamp),
|
||||
freshness:
|
||||
Date.now() - timestamp > 86_400_000
|
||||
? ("stale" as const)
|
||||
: ("fresh" as const),
|
||||
};
|
||||
}
|
||||
|
||||
const productionAdapters: PeopleInboxAdapters = {
|
||||
async tickets(context) {
|
||||
const result = await peopleSupportQuery.run(context, {
|
||||
routeId: "people.support.tickets",
|
||||
list: { pageSize: 25, offset: 0, sort: "updatedAt", order: "desc" },
|
||||
});
|
||||
if (!result.ok || result.data.kind !== "tickets") throw new Error("tickets");
|
||||
const capability = anyCapability(PERMS.TICKETS_VIEW, PERMS.MOD_TICKETS_VIEW);
|
||||
return result.data.page.items.flatMap((ticket) => {
|
||||
const date = time(ticket.updatedAt);
|
||||
return date === null
|
||||
? []
|
||||
: [{
|
||||
sourceId: "people.tickets",
|
||||
itemId: String(ticket.id),
|
||||
deduplicationKey: `${ticket.source}-ticket:${ticket.id}`,
|
||||
domain: "people" as const,
|
||||
capability,
|
||||
severity: ticket.priority === "urgent" ? "critical" as const : "info" as const,
|
||||
priority: ticket.priority === "urgent" ? "critical" as const : "normal" as const,
|
||||
...date,
|
||||
state: ticket.status,
|
||||
titleKey: "pages.housekeeping.items.ticket",
|
||||
context: { subject: ticket.subject },
|
||||
href: ticket.href,
|
||||
actions: [],
|
||||
}];
|
||||
});
|
||||
},
|
||||
async helpTickets(context) {
|
||||
const result = await peopleSupportQuery.run(context, {
|
||||
routeId: "people.support.help-tickets",
|
||||
list: { pageSize: 25, offset: 0, sort: "updatedAt", order: "desc" },
|
||||
});
|
||||
if (!result.ok || result.data.kind !== "help-tickets") throw new Error("help");
|
||||
const capability = anyCapability(PERMS.TICKETS_VIEW, PERMS.MOD_TICKETS_VIEW);
|
||||
return result.data.page.items.flatMap((ticket) => {
|
||||
const date = time(ticket.updatedAt);
|
||||
return date === null ? [] : [{
|
||||
sourceId: "people.help-tickets",
|
||||
itemId: ticket.id,
|
||||
deduplicationKey: `help-ticket:${ticket.id}`,
|
||||
domain: "people" as const,
|
||||
capability,
|
||||
severity: "info" as const,
|
||||
priority: "normal" as const,
|
||||
...date,
|
||||
state: ticket.open ? "open" : "closed",
|
||||
titleKey: "pages.housekeeping.items.helpTicket",
|
||||
context: { title: ticket.title },
|
||||
href: ticket.href,
|
||||
actions: [],
|
||||
}];
|
||||
});
|
||||
},
|
||||
async cfh(context) {
|
||||
const result = await peopleModerationQuery.run(context, {
|
||||
routeId: "people.moderation.cfh",
|
||||
list: { pageSize: 25, offset: 0, sort: "createdAt", order: "desc" },
|
||||
});
|
||||
if (!result.ok || result.data.kind !== "cfh") throw new Error("cfh");
|
||||
const capability = anyCapability(PERMS.MODERATION_VIEW, PERMS.MOD_CFH_VIEW);
|
||||
return result.data.page.items.flatMap((ticket) => {
|
||||
const date = time(ticket.createdAt);
|
||||
return date === null ? [] : [{
|
||||
sourceId: "people.cfh",
|
||||
itemId: String(ticket.id),
|
||||
deduplicationKey: `cfh:${ticket.id}`,
|
||||
domain: "people" as const,
|
||||
capability,
|
||||
severity: "warning" as const,
|
||||
priority: "high" as const,
|
||||
...date,
|
||||
state: String(ticket.state),
|
||||
titleKey: "pages.housekeeping.items.cfh",
|
||||
context: { issue: ticket.issue },
|
||||
href: ticket.href,
|
||||
actions: [],
|
||||
}];
|
||||
});
|
||||
},
|
||||
async activeBans(context) {
|
||||
const result = await peopleModerationQuery.run(context, {
|
||||
routeId: "people.moderation.bans",
|
||||
list: { pageSize: 25, offset: 0, sort: "createdAt", order: "desc" },
|
||||
});
|
||||
if (!result.ok || result.data.kind !== "bans") throw new Error("bans");
|
||||
const capability = anyCapability(PERMS.BANS_VIEW, PERMS.MOD_BANS_VIEW);
|
||||
return result.data.page.items.flatMap((ban) => {
|
||||
const date = time(ban.createdAt);
|
||||
return date === null ? [] : [{
|
||||
sourceId: "people.active-bans",
|
||||
itemId: String(ban.id),
|
||||
deduplicationKey: `ban:${ban.id}`,
|
||||
domain: "people" as const,
|
||||
capability,
|
||||
severity: "warning" as const,
|
||||
priority: "normal" as const,
|
||||
...date,
|
||||
state: "active",
|
||||
titleKey: "pages.housekeeping.items.activeBan",
|
||||
context: { userId: ban.userId, reason: ban.reason },
|
||||
href: `/ase/people/users/${ban.userId}` as const,
|
||||
actions: [],
|
||||
}];
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
export const PEOPLE_INBOX_SOURCES =
|
||||
createPeopleInboxSources(productionAdapters);
|
||||
@@ -3,7 +3,10 @@ import {
|
||||
anyCapability,
|
||||
type HousekeepingDomainManifest,
|
||||
} from "../../foundation/contracts";
|
||||
import { PEOPLE_PRIMARY_ROUTES } from "./routes";
|
||||
import { PEOPLE_INBOX_SOURCES } from "./inbox";
|
||||
import { PEOPLE_ROUTES } from "./routes";
|
||||
import { PEOPLE_SEARCH_PROVIDERS } from "./search";
|
||||
import { PEOPLE_WIDGETS } from "./widgets";
|
||||
|
||||
export const peopleManifest = {
|
||||
id: "people",
|
||||
@@ -35,8 +38,8 @@ export const peopleManifest = {
|
||||
PERMS.MOD_CFH_EDIT,
|
||||
PERMS.MOD_TICKETS_EDIT,
|
||||
),
|
||||
routes: PEOPLE_PRIMARY_ROUTES,
|
||||
searchProviders: [],
|
||||
inboxSources: [],
|
||||
widgets: [],
|
||||
routes: PEOPLE_ROUTES,
|
||||
searchProviders: PEOPLE_SEARCH_PROVIDERS,
|
||||
inboxSources: PEOPLE_INBOX_SOURCES,
|
||||
widgets: PEOPLE_WIDGETS,
|
||||
} satisfies HousekeepingDomainManifest;
|
||||
@@ -154,7 +154,7 @@ export interface PeopleSupportStaff {
|
||||
|
||||
export interface PeopleTicketSummary {
|
||||
readonly source: "cms" | "help";
|
||||
readonly id: number;
|
||||
readonly id: number | string;
|
||||
readonly subject: string;
|
||||
readonly status: string;
|
||||
readonly priority: string;
|
||||
@@ -163,7 +163,7 @@ export interface PeopleTicketSummary {
|
||||
readonly updatedAt: string | null;
|
||||
readonly href:
|
||||
| `/ase/people/support/tickets/${number}`
|
||||
| `/ase/people/support/help-tickets/${number}`;
|
||||
| `/ase/people/support/help-tickets/${string}`;
|
||||
}
|
||||
|
||||
export interface PeopleTicketDeskSummary extends PeopleTicketSummary {
|
||||
@@ -189,22 +189,22 @@ export interface PeopleTicketDetail extends PeopleTicketSummary {
|
||||
}
|
||||
|
||||
export interface PeopleHelpTicketSummary {
|
||||
readonly id: number;
|
||||
readonly id: string;
|
||||
readonly title: string;
|
||||
readonly open: boolean;
|
||||
readonly userId: number | null;
|
||||
readonly username: string | null;
|
||||
readonly updatedAt: string | null;
|
||||
readonly replyCount: number;
|
||||
readonly href: `/ase/people/support/help-tickets/${number}`;
|
||||
readonly href: `/ase/people/support/help-tickets/${string}`;
|
||||
}
|
||||
|
||||
export interface PeopleHelpTicketDetail extends PeopleHelpTicketSummary {
|
||||
readonly categoryId: number | null;
|
||||
readonly categoryId: string | null;
|
||||
readonly categoryName: string | null;
|
||||
readonly content: string;
|
||||
readonly replies: readonly {
|
||||
readonly id: number;
|
||||
readonly id: string;
|
||||
readonly userId: number;
|
||||
readonly username: string | null;
|
||||
readonly content: string;
|
||||
@@ -416,10 +416,17 @@ export function peopleTicketHref(
|
||||
}
|
||||
|
||||
export function peopleHelpTicketHref(
|
||||
id: number,
|
||||
): `/ase/people/support/help-tickets/${number}` {
|
||||
positiveSafeInteger(id);
|
||||
return `/ase/people/support/help-tickets/${id}`;
|
||||
id: string | number | bigint,
|
||||
): `/ase/people/support/help-tickets/${string}` {
|
||||
const text = String(id);
|
||||
if (!/^[1-9]\d{0,19}$/u.test(text)) {
|
||||
throw new Error("invalid People identifier");
|
||||
}
|
||||
const parsed = BigInt(text);
|
||||
if (parsed > 18_446_744_073_709_551_615n) {
|
||||
throw new Error("invalid People identifier");
|
||||
}
|
||||
return `/ase/people/support/help-tickets/${parsed.toString()}`;
|
||||
}
|
||||
|
||||
export function peopleCfhHref(
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
import { PERMS } from "@/lib/permission-slugs";
|
||||
import {
|
||||
createCorrelationId,
|
||||
fail,
|
||||
type HousekeepingCapabilityContext,
|
||||
type HousekeepingResult,
|
||||
} from "../../../foundation/contracts";
|
||||
import type { HousekeepingPageInput } from "../../../route-handlers";
|
||||
import {
|
||||
type PeopleModerationQueryData,
|
||||
peopleModerationQuery,
|
||||
} from "../queries/moderation";
|
||||
import { PeoplePageFrame } from "./page-state";
|
||||
import { PeopleCommandForm } from "./people-command-form";
|
||||
|
||||
interface Props {
|
||||
readonly context: HousekeepingCapabilityContext;
|
||||
readonly result?: HousekeepingResult<PeopleModerationQueryData>;
|
||||
}
|
||||
|
||||
export function PeopleCfhDetailPage({ context, result }: Props) {
|
||||
const canEdit = context.hasAny(PERMS.MODERATION_EDIT, PERMS.MOD_CFH_EDIT);
|
||||
return (
|
||||
<PeoplePageFrame
|
||||
title="Call for help"
|
||||
description="Review the report, resolve it, and apply correlated sanctions."
|
||||
result={result}
|
||||
isEmpty={(data) => data.kind !== "cfh-detail"}
|
||||
>
|
||||
{(data) => data.kind === "cfh-detail" ? (
|
||||
<article className="space-y-4">
|
||||
<h2>CFH #{data.ticket.id}</h2>
|
||||
<p>{data.ticket.issue}</p>
|
||||
<p>Reporter: {data.ticket.senderUsername ?? `#${data.ticket.senderId}`}</p>
|
||||
<p>Reported: {data.ticket.reportedUsername ?? `#${data.ticket.reportedId}`}</p>
|
||||
{canEdit ? (
|
||||
<>
|
||||
<PeopleCommandForm
|
||||
commandId="people.cfh.resolve"
|
||||
buttonLabel="Resolve"
|
||||
input={{ ticketId: data.ticket.id, state: 2 }}
|
||||
/>
|
||||
<PeopleCommandForm
|
||||
commandId="people.cfh.sanction"
|
||||
buttonLabel="Apply sanction"
|
||||
input={{ ticketId: data.ticket.id, userId: data.ticket.reportedId }}
|
||||
fields={[
|
||||
{name: "action", label: "Action", type: "select", options: ["kick", "mute", "alert"].map((value) => ({ value, label: value }))},
|
||||
{name: "duration", label: "Duration", type: "number", min: 0, max: 525600, defaultValue: 0},
|
||||
{name: "message", label: "Message", type: "text", maxLength: 500},
|
||||
]}
|
||||
requiresReason
|
||||
includeReasonInInput
|
||||
/>
|
||||
</>
|
||||
) : null}
|
||||
</article>
|
||||
) : null}
|
||||
</PeoplePageFrame>
|
||||
);
|
||||
}
|
||||
|
||||
export async function renderPeopleCfhDetailPage(input: HousekeepingPageInput) {
|
||||
const id = Number(input.match.params.id);
|
||||
const result = Number.isSafeInteger(id) && id > 0
|
||||
? await peopleModerationQuery.run(input.context, {
|
||||
routeId: "people.moderation.cfh-detail",
|
||||
id,
|
||||
})
|
||||
: fail("VALIDATION", "errors.housekeeping.validation", createCorrelationId());
|
||||
return <PeopleCfhDetailPage context={input.context} result={result} />;
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
import { PERMS } from "@/lib/permission-slugs";
|
||||
import {
|
||||
createCorrelationId,
|
||||
fail,
|
||||
type HousekeepingCapabilityContext,
|
||||
type HousekeepingResult,
|
||||
} from "../../../foundation/contracts";
|
||||
import type { HousekeepingPageInput } from "../../../route-handlers";
|
||||
import { peopleHelpTicketHref } from "../models";
|
||||
import {
|
||||
type PeopleSupportQueryData,
|
||||
peopleSupportQuery,
|
||||
} from "../queries/support";
|
||||
import { PeoplePageFrame } from "./page-state";
|
||||
import { PeopleCommandForm } from "./people-command-form";
|
||||
|
||||
interface Props {
|
||||
readonly context: HousekeepingCapabilityContext;
|
||||
readonly result?: HousekeepingResult<PeopleSupportQueryData>;
|
||||
}
|
||||
|
||||
export function PeopleHelpTicketDetailPage({ context, result }: Props) {
|
||||
const canEdit = context.hasAny(PERMS.TICKETS_EDIT, PERMS.MOD_TICKETS_EDIT);
|
||||
return (
|
||||
<PeoplePageFrame
|
||||
title="Help ticket"
|
||||
description="Review the requester appeal and its complete reply history."
|
||||
result={result}
|
||||
isEmpty={(data) => data.kind !== "help-ticket"}
|
||||
>
|
||||
{(data) => data.kind === "help-ticket" ? (
|
||||
<article className="space-y-4">
|
||||
<header><h2>{data.ticket.title}</h2><p>{data.ticket.content}</p></header>
|
||||
<ul>
|
||||
{data.ticket.replies.map((reply) => (
|
||||
<li key={reply.id}><strong>{reply.username ?? `User #${reply.userId}`}</strong>: {reply.content}</li>
|
||||
))}
|
||||
</ul>
|
||||
{canEdit ? (
|
||||
<>
|
||||
<PeopleCommandForm
|
||||
commandId="people.help-ticket.reply"
|
||||
buttonLabel="Reply"
|
||||
input={{ ticketId: data.ticket.id }}
|
||||
fields={[{ name: "content", label: "Reply", type: "text", required: true, maxLength: 5000 }]}
|
||||
/>
|
||||
<PeopleCommandForm
|
||||
commandId="people.help-ticket.status"
|
||||
buttonLabel={data.ticket.open ? "Close ticket" : "Reopen ticket"}
|
||||
input={{ ticketId: data.ticket.id, status: data.ticket.open ? "close" : "reopen" }}
|
||||
/>
|
||||
</>
|
||||
) : null}
|
||||
{data.ticket.activeBan && context.has(PERMS.USERS_BAN) ? (
|
||||
<PeopleCommandForm
|
||||
commandId="people.help-ticket.unban"
|
||||
buttonLabel="Lift requester bans and close"
|
||||
input={{ ticketId: data.ticket.id }}
|
||||
requiresReason
|
||||
/>
|
||||
) : null}
|
||||
</article>
|
||||
) : null}
|
||||
</PeoplePageFrame>
|
||||
);
|
||||
}
|
||||
|
||||
export async function renderPeopleHelpTicketDetailPage(input: HousekeepingPageInput) {
|
||||
const raw = input.match.params.id ?? "";
|
||||
let id: string | null = null;
|
||||
try {
|
||||
id = peopleHelpTicketHref(raw).split("/").at(-1) ?? null;
|
||||
} catch {
|
||||
id = null;
|
||||
}
|
||||
const result = id
|
||||
? await peopleSupportQuery.run(input.context, {
|
||||
routeId: "people.support.help-ticket-detail",
|
||||
id,
|
||||
})
|
||||
: fail("VALIDATION", "errors.housekeeping.validation", createCorrelationId());
|
||||
return <PeopleHelpTicketDetailPage context={input.context} result={result} />;
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
import { PERMS } from "@/lib/permission-slugs";
|
||||
import {
|
||||
createCorrelationId,
|
||||
fail,
|
||||
type HousekeepingCapabilityContext,
|
||||
type HousekeepingResult,
|
||||
} from "../../../foundation/contracts";
|
||||
import type { HousekeepingPageInput } from "../../../route-handlers";
|
||||
import {
|
||||
type PeopleModerationQueryData,
|
||||
peopleModerationQuery,
|
||||
} from "../queries/moderation";
|
||||
import { PeoplePageFrame, parsePeopleListInput } from "./page-state";
|
||||
import { PeopleCommandForm } from "./people-command-form";
|
||||
|
||||
interface Props {
|
||||
readonly context: HousekeepingCapabilityContext;
|
||||
readonly result?: HousekeepingResult<PeopleModerationQueryData>;
|
||||
}
|
||||
|
||||
function isEmpty(data: PeopleModerationQueryData) {
|
||||
return "page" in data ? data.page.items.length === 0 : false;
|
||||
}
|
||||
|
||||
function QuickAction() {
|
||||
return (
|
||||
<PeopleCommandForm
|
||||
commandId="people.moderation.action"
|
||||
buttonLabel="Run moderation action"
|
||||
input={{}}
|
||||
fields={[
|
||||
{name: "action", label: "Action", type: "select", options: ["kick", "mute", "unmute", "alert", "room-kick", "broadcast"].map((value) => ({ value, label: value }))},
|
||||
{name: "userId", label: "User ID", type: "number", min: 1},
|
||||
{name: "roomId", label: "Room ID", type: "number", min: 1},
|
||||
{name: "duration", label: "Duration", type: "number", min: 0, max: 525600},
|
||||
{name: "message", label: "Message", type: "text", maxLength: 500},
|
||||
{name: "type", label: "Audience", type: "select", options: [{value: "hotel", label: "hotel"}, {value: "staff", label: "staff"}]},
|
||||
]}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function PeopleModerationPage({ context, result }: Props) {
|
||||
const canAct = context.hasAny(PERMS.MODERATION_EDIT, PERMS.MOD_ACTIONS);
|
||||
return (
|
||||
<PeoplePageFrame
|
||||
title="Moderation"
|
||||
description="Review live moderation queues and act with capability-scoped controls."
|
||||
result={result}
|
||||
isEmpty={isEmpty}
|
||||
>
|
||||
{(data) => {
|
||||
if (data.kind === "overview") return (
|
||||
<div className="space-y-4">
|
||||
<dl className="grid gap-3 sm:grid-cols-3">
|
||||
{Object.entries(data.snapshot).map(([label, value]) => (
|
||||
<div key={label} className="rounded border border-[var(--admin-border)] p-3"><dt>{label}</dt><dd>{value}</dd></div>
|
||||
))}
|
||||
</dl>
|
||||
{canAct ? <QuickAction /> : null}
|
||||
</div>
|
||||
);
|
||||
if (data.kind === "cfh") return (
|
||||
<ul>{data.page.items.map((ticket) => (
|
||||
<li key={ticket.id}><a href={ticket.href}>CFH #{ticket.id}</a> · {ticket.issue}</li>
|
||||
))}</ul>
|
||||
);
|
||||
if (data.kind === "bans") return (
|
||||
<div className="space-y-3">
|
||||
{context.has(PERMS.USERS_BAN) ? (
|
||||
<PeopleCommandForm
|
||||
commandId="people.ban.create"
|
||||
buttonLabel="Create ban"
|
||||
input={{}}
|
||||
fields={[
|
||||
{name: "userId", label: "User ID", type: "number", min: 1, required: true},
|
||||
{name: "hours", label: "Hours", type: "number", min: 0, max: 876000, defaultValue: 0},
|
||||
{name: "type", label: "Type", type: "select", options: ["account", "ip", "machine", "super"].map((value) => ({value, label: value}))},
|
||||
]}
|
||||
requiresReason
|
||||
includeReasonInInput
|
||||
/>
|
||||
) : null}
|
||||
<ul>{data.page.items.map((ban) => (
|
||||
<li key={ban.id}>
|
||||
User #{ban.userId}: {ban.reason}
|
||||
{context.has(PERMS.USERS_BAN) ? (
|
||||
<PeopleCommandForm commandId="people.ban.lift" buttonLabel="Lift ban" input={{id: ban.id}} requiresReason />
|
||||
) : null}
|
||||
</li>
|
||||
))}</ul>
|
||||
</div>
|
||||
);
|
||||
if (data.kind === "ip-rules") return <pre>{JSON.stringify({blacklist: data.blacklist, whitelist: data.whitelist}, null, 2)}</pre>;
|
||||
if (data.kind === "vpn") return <ul>{data.settings.map((setting) => <li key={setting.key}>{setting.key}: {setting.value}</li>)}</ul>;
|
||||
if (data.kind === "word-filter") return <ul>{data.page.items.map((entry) => <li key={entry.id}>{entry.word}</li>)}</ul>;
|
||||
return null;
|
||||
}}
|
||||
</PeoplePageFrame>
|
||||
);
|
||||
}
|
||||
|
||||
export async function renderPeopleModerationPage(input: HousekeepingPageInput) {
|
||||
const routeId = input.match.routeId;
|
||||
const queryRoute =
|
||||
routeId === "people.moderation.actions"
|
||||
? "people.moderation.overview"
|
||||
: routeId;
|
||||
const result =
|
||||
queryRoute === "people.moderation.overview" ||
|
||||
queryRoute === "people.moderation.ip" ||
|
||||
queryRoute === "people.moderation.vpn"
|
||||
? await peopleModerationQuery.run(input.context, { routeId: queryRoute })
|
||||
: queryRoute === "people.moderation.cfh" ||
|
||||
queryRoute === "people.moderation.bans" ||
|
||||
queryRoute === "people.moderation.word-filter"
|
||||
? await peopleModerationQuery.run(input.context, {
|
||||
routeId: queryRoute,
|
||||
list: parsePeopleListInput(input.searchParams ?? {}),
|
||||
})
|
||||
: fail("NOT_FOUND", "errors.housekeeping.notFound", createCorrelationId());
|
||||
return <PeopleModerationPage context={input.context} result={result} />;
|
||||
}
|
||||
@@ -11,6 +11,7 @@ vi.mock("@/actions/housekeeping-command", () => ({
|
||||
import {
|
||||
fail,
|
||||
type HousekeepingCapabilityContext,
|
||||
type HousekeepingDomainManifest,
|
||||
type HousekeepingResult,
|
||||
ok,
|
||||
} from "../../../foundation/contracts";
|
||||
@@ -24,6 +25,7 @@ import {
|
||||
PEOPLE_PRIMARY_ROUTE_HANDLERS,
|
||||
PEOPLE_PRIMARY_ROUTE_IDS,
|
||||
} from "../route-handlers";
|
||||
import { PEOPLE_ROUTE_IDS } from "../routes";
|
||||
import { PeopleCommunityPage } from "./community";
|
||||
import { PeopleMultiAccountsPage } from "./multi-accounts";
|
||||
import { parsePeopleListInput } from "./page-state";
|
||||
@@ -263,7 +265,7 @@ describe.each(cases)("People $name page", ({ render, empty, ready }) => {
|
||||
});
|
||||
|
||||
describe("People primary route registration", () => {
|
||||
it("registers exactly the nine real primary routes and handlers", () => {
|
||||
it("keeps the nine primary handlers and registers the complete People catalog", () => {
|
||||
const expected = [
|
||||
"people.users.list",
|
||||
"people.users.edit",
|
||||
@@ -276,7 +278,9 @@ describe("People primary route registration", () => {
|
||||
"people.staff.teams",
|
||||
];
|
||||
expect(PEOPLE_PRIMARY_ROUTE_IDS).toEqual(expected);
|
||||
expect(peopleManifest.routes.map((route) => route.id)).toEqual(expected);
|
||||
expect(peopleManifest.routes.map((route) => route.id)).toEqual(
|
||||
PEOPLE_ROUTE_IDS,
|
||||
);
|
||||
expect(
|
||||
PEOPLE_PRIMARY_ROUTE_HANDLERS.map((handler) => handler.routeId),
|
||||
).toEqual(expected);
|
||||
@@ -284,9 +288,9 @@ describe("People primary route registration", () => {
|
||||
HOUSEKEEPING_ROUTE_HANDLERS.map((handler) => handler.routeId),
|
||||
).toEqual(expect.arrayContaining(expected));
|
||||
expect(
|
||||
HOUSEKEEPING_MANIFESTS.flatMap((manifest) => manifest.routes).map(
|
||||
(route) => route.id,
|
||||
),
|
||||
(
|
||||
HOUSEKEEPING_MANIFESTS as readonly HousekeepingDomainManifest[]
|
||||
).flatMap((manifest) => manifest.routes.map((route) => route.id)),
|
||||
).toEqual(HOUSEKEEPING_ROUTE_HANDLERS.map((handler) => handler.routeId));
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,188 @@
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { PERMS } from "@/lib/permission-slugs";
|
||||
import type { HousekeepingCapabilityContext } from "../../../foundation/contracts";
|
||||
import { ok } from "../../../foundation/contracts";
|
||||
import { PeopleCfhDetailPage } from "./cfh-detail";
|
||||
import { PeopleHelpTicketDetailPage } from "./help-ticket-detail";
|
||||
import { PeopleModerationPage } from "./moderation";
|
||||
import { PeopleSupportPage } from "./support";
|
||||
import { PeopleTicketDetailPage } from "./ticket-detail";
|
||||
|
||||
function context(granted: readonly string[]): HousekeepingCapabilityContext {
|
||||
const permissions = new Set(granted);
|
||||
return {
|
||||
actor: { id: 42, username: "mod", rank: 4 },
|
||||
isSuperAdmin: false,
|
||||
has: (slug) => permissions.has(slug),
|
||||
hasAny: (...slugs) => slugs.some((slug) => permissions.has(slug)),
|
||||
hasAll: (...slugs) => slugs.every((slug) => permissions.has(slug)),
|
||||
};
|
||||
}
|
||||
|
||||
describe("People support/moderation pages", () => {
|
||||
it("renders safe support links and real mid-rank ticket forms", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<PeopleSupportPage
|
||||
context={context([PERMS.MOD_TICKETS_VIEW])}
|
||||
result={ok(
|
||||
{
|
||||
kind: "tickets" as const,
|
||||
page: {
|
||||
items: [{
|
||||
source: "cms" as const,
|
||||
id: 7,
|
||||
subject: "Need help",
|
||||
status: "open",
|
||||
priority: "normal",
|
||||
creatorId: 9,
|
||||
creatorUsername: "visitor",
|
||||
updatedAt: null,
|
||||
href: "/ase/people/support/tickets/7" as const,
|
||||
}],
|
||||
total: 1,
|
||||
pageSize: 20,
|
||||
offset: 0,
|
||||
},
|
||||
},
|
||||
"support",
|
||||
)}
|
||||
/>,
|
||||
);
|
||||
expect(html).toContain("/ase/people/support/tickets/7");
|
||||
expect(html).not.toContain("/admin");
|
||||
expect(html).not.toContain("/mod/");
|
||||
|
||||
const detail = renderToStaticMarkup(
|
||||
<PeopleTicketDetailPage
|
||||
context={context([PERMS.MOD_TICKETS_EDIT])}
|
||||
result={ok(
|
||||
{
|
||||
kind: "ticket" as const,
|
||||
ticket: {
|
||||
source: "cms" as const,
|
||||
id: 7,
|
||||
subject: "Need help",
|
||||
status: "open",
|
||||
priority: "normal",
|
||||
creatorId: 9,
|
||||
creatorUsername: "visitor",
|
||||
updatedAt: null,
|
||||
href: "/ase/people/support/tickets/7" as const,
|
||||
category: "general",
|
||||
assigneeId: null,
|
||||
messages: [],
|
||||
queue: { tickets: 1, helpTickets: 1, cfh: 1, activeBans: 1 },
|
||||
staff: [],
|
||||
},
|
||||
},
|
||||
"detail",
|
||||
)}
|
||||
/>,
|
||||
);
|
||||
expect(detail).toContain('data-housekeeping-command="people.ticket.reply"');
|
||||
expect(detail).toContain('data-housekeeping-command="people.ticket.assign"');
|
||||
});
|
||||
|
||||
it("renders CFH and quick moderation forms with mod.* only", () => {
|
||||
const cfh = renderToStaticMarkup(
|
||||
<PeopleCfhDetailPage
|
||||
context={context([PERMS.MOD_CFH_VIEW, PERMS.MOD_CFH_EDIT])}
|
||||
result={ok(
|
||||
{
|
||||
kind: "cfh-detail" as const,
|
||||
ticket: {
|
||||
id: 4, state: 0, senderId: 2, senderUsername: "sender",
|
||||
reportedId: 3, reportedUsername: "reported", moderatorId: 0,
|
||||
issue: "spam", createdAt: null,
|
||||
href: "/ase/people/moderation/cfh/4" as const,
|
||||
roomId: 0, activeBan: null,
|
||||
},
|
||||
},
|
||||
"cfh",
|
||||
)}
|
||||
/>,
|
||||
);
|
||||
expect(cfh).toContain('data-housekeeping-command="people.cfh.resolve"');
|
||||
expect(cfh).toContain('data-housekeeping-command="people.cfh.sanction"');
|
||||
|
||||
const moderation = renderToStaticMarkup(
|
||||
<PeopleModerationPage
|
||||
context={context([PERMS.MOD_ACTIONS])}
|
||||
result={ok(
|
||||
{
|
||||
kind: "overview" as const,
|
||||
snapshot: {
|
||||
tickets: 1, helpTickets: 1, cfh: 1, activeBans: 1,
|
||||
staffOnline: 1, recentActions: 1,
|
||||
},
|
||||
},
|
||||
"moderation",
|
||||
)}
|
||||
/>,
|
||||
);
|
||||
expect(moderation).toContain(
|
||||
'data-housekeeping-command="people.moderation.action"',
|
||||
);
|
||||
expect(moderation).not.toContain("admin.dashboard");
|
||||
});
|
||||
|
||||
it("wires BIGINT help-ticket reply, reopen, and unban commands", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<PeopleHelpTicketDetailPage
|
||||
context={context([PERMS.MOD_TICKETS_EDIT, PERMS.USERS_BAN])}
|
||||
result={ok(
|
||||
{
|
||||
kind: "help-ticket" as const,
|
||||
ticket: {
|
||||
id: "9007199254740993",
|
||||
title: "Ban appeal",
|
||||
open: false,
|
||||
userId: 7,
|
||||
username: "visitor",
|
||||
updatedAt: "2026-08-29T00:00:00.000Z",
|
||||
replyCount: 1,
|
||||
href: "/ase/people/support/help-tickets/9007199254740993" as const,
|
||||
categoryId: "2",
|
||||
categoryName: "Appeal",
|
||||
content: "Please review",
|
||||
replies: [{
|
||||
id: "9007199254740994",
|
||||
userId: 7,
|
||||
username: "visitor",
|
||||
content: "More context",
|
||||
createdAt: "2026-08-29T00:00:00.000Z",
|
||||
}],
|
||||
queue: { tickets: 1, helpTickets: 1, cfh: 1, activeBans: 1 },
|
||||
staff: [],
|
||||
activeBan: {
|
||||
id: 3,
|
||||
userId: 7,
|
||||
username: "visitor",
|
||||
staffId: 42,
|
||||
staffUsername: "mod",
|
||||
type: "account",
|
||||
reason: "spam",
|
||||
createdAt: "2026-08-29T00:00:00.000Z",
|
||||
expiresAt: 0,
|
||||
active: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
"help-detail",
|
||||
)}
|
||||
/>,
|
||||
);
|
||||
expect(html).toContain('data-housekeeping-command="people.help-ticket.reply"');
|
||||
expect(html).toContain('data-housekeeping-command="people.help-ticket.status"');
|
||||
expect(html).toContain('data-housekeeping-command="people.help-ticket.unban"');
|
||||
expect(html).not.toContain("/admin");
|
||||
expect(html).not.toContain("/mod/");
|
||||
});
|
||||
|
||||
it("renders the loading state before data arrives", () => {
|
||||
expect(
|
||||
renderToStaticMarkup(<PeopleSupportPage context={context([])} />),
|
||||
).toContain('data-housekeeping-state="loading"');
|
||||
});
|
||||
});
|
||||
@@ -121,6 +121,21 @@ export function PeopleStaffPage({ context, result }: PeopleStaffPageProps) {
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
) : data.kind === "moderation-team" ? (
|
||||
<ul className="space-y-2">
|
||||
{data.page.items.map((member) => (
|
||||
<li
|
||||
key={member.id}
|
||||
className="rounded-lg border border-[var(--admin-border)] bg-[var(--admin-surface)] p-4"
|
||||
>
|
||||
<a href={member.href}>{member.username}</a>
|
||||
<p>
|
||||
{member.openCfh} CFH · {member.openTickets} tickets ·{" "}
|
||||
{member.actionCount} actions
|
||||
</p>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
@@ -131,7 +146,9 @@ export function PeopleStaffPage({ context, result }: PeopleStaffPageProps) {
|
||||
export async function renderPeopleStaffPage(input: HousekeepingPageInput) {
|
||||
const routeId = input.match.routeId;
|
||||
const result =
|
||||
routeId === "people.staff.applications" || routeId === "people.staff.teams"
|
||||
routeId === "people.staff.applications" ||
|
||||
routeId === "people.staff.teams" ||
|
||||
routeId === "people.staff.moderation-team"
|
||||
? await peopleStaffQuery.run(input.context, {
|
||||
routeId,
|
||||
list: parsePeopleListInput(input.searchParams ?? {}),
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
import { PERMS } from "@/lib/permission-slugs";
|
||||
import {
|
||||
createCorrelationId,
|
||||
fail,
|
||||
type HousekeepingCapabilityContext,
|
||||
type HousekeepingResult,
|
||||
} from "../../../foundation/contracts";
|
||||
import type { HousekeepingPageInput } from "../../../route-handlers";
|
||||
import {
|
||||
type PeopleSupportQueryData,
|
||||
peopleSupportQuery,
|
||||
} from "../queries/support";
|
||||
import { PeoplePageFrame, parsePeopleListInput } from "./page-state";
|
||||
import { PeopleCommandForm } from "./people-command-form";
|
||||
|
||||
interface Props {
|
||||
readonly context: HousekeepingCapabilityContext;
|
||||
readonly result?: HousekeepingResult<PeopleSupportQueryData>;
|
||||
}
|
||||
|
||||
function empty(data: PeopleSupportQueryData): boolean {
|
||||
return "page" in data ? data.page.items.length === 0 : false;
|
||||
}
|
||||
|
||||
function Queue({ queue }: { readonly queue: { tickets: number; helpTickets: number; cfh: number; activeBans: number } }) {
|
||||
return (
|
||||
<dl className="grid gap-3 sm:grid-cols-4">
|
||||
{Object.entries(queue).map(([label, value]) => (
|
||||
<div key={label} className="rounded border border-[var(--admin-border)] p-3">
|
||||
<dt>{label}</dt><dd>{value}</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
);
|
||||
}
|
||||
|
||||
export function PeopleSupportPage({ context, result }: Props) {
|
||||
return (
|
||||
<PeoplePageFrame
|
||||
title="Support"
|
||||
description="Review ticket queues, assignments, templates, and help requests."
|
||||
result={result}
|
||||
isEmpty={empty}
|
||||
>
|
||||
{(data) => {
|
||||
if (data.kind === "queue") return <Queue queue={data.queue} />;
|
||||
if (data.kind === "ticket-templates") {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{context.has(PERMS.TICKETS_EDIT) ? (
|
||||
<PeopleCommandForm
|
||||
commandId="people.ticket-template.change"
|
||||
buttonLabel="Create template"
|
||||
input={{ action: "create" }}
|
||||
fields={[
|
||||
{ name: "title", label: "Title", type: "text", required: true, maxLength: 255 },
|
||||
{ name: "content", label: "Content", type: "text", required: true, maxLength: 5000 },
|
||||
{ name: "category", label: "Category", type: "text", maxLength: 50, defaultValue: "general" },
|
||||
{ name: "sortOrder", label: "Sort order", type: "number", min: 0, defaultValue: 0 },
|
||||
]}
|
||||
/>
|
||||
) : null}
|
||||
<ul className="space-y-2">
|
||||
{data.page.items.map((template) => (
|
||||
<li key={template.id} className="rounded border border-[var(--admin-border)] p-3">
|
||||
<h2>{template.title}</h2>
|
||||
<p>{template.content}</p>
|
||||
{context.has(PERMS.TICKETS_EDIT) ? (
|
||||
<PeopleCommandForm
|
||||
commandId="people.ticket-template.change"
|
||||
buttonLabel="Delete template"
|
||||
input={{ action: "delete", id: template.id }}
|
||||
/>
|
||||
) : null}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (data.kind === "help-tickets") {
|
||||
return (
|
||||
<ul className="space-y-2">
|
||||
{data.page.items.map((ticket) => (
|
||||
<li key={ticket.id} className="rounded border border-[var(--admin-border)] p-3">
|
||||
<a href={ticket.href}>{ticket.title}</a>
|
||||
<p>{ticket.open ? "Open" : "Closed"} · {ticket.replyCount} replies</p>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
if (data.kind !== "tickets" && data.kind !== "ticket-desk") {
|
||||
return null;
|
||||
}
|
||||
const queue = data.kind === "ticket-desk" ? data.queue : null;
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{queue ? <Queue queue={queue} /> : null}
|
||||
<form method="get" className="flex gap-2">
|
||||
<input name="search" maxLength={100} aria-label="Search tickets" />
|
||||
<button type="submit">Search</button>
|
||||
</form>
|
||||
<ul className="space-y-2">
|
||||
{data.page.items.map((ticket) => (
|
||||
<li key={`${ticket.source}:${ticket.id}`} className="rounded border border-[var(--admin-border)] p-3">
|
||||
<a href={ticket.href}>{ticket.subject}</a>
|
||||
<p>{ticket.status} · {ticket.priority}</p>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
</PeoplePageFrame>
|
||||
);
|
||||
}
|
||||
|
||||
export async function renderPeopleSupportPage(input: HousekeepingPageInput) {
|
||||
const routeId = input.match.routeId;
|
||||
if (
|
||||
routeId !== "people.support.tickets" &&
|
||||
routeId !== "people.support.ticket-desk" &&
|
||||
routeId !== "people.support.ticket-templates" &&
|
||||
routeId !== "people.support.help-tickets"
|
||||
) {
|
||||
return (
|
||||
<PeopleSupportPage
|
||||
context={input.context}
|
||||
result={fail("NOT_FOUND", "errors.housekeeping.notFound", createCorrelationId())}
|
||||
/>
|
||||
);
|
||||
}
|
||||
const result = await peopleSupportQuery.run(input.context, {
|
||||
routeId,
|
||||
list: parsePeopleListInput(input.searchParams ?? {}),
|
||||
});
|
||||
return <PeopleSupportPage context={input.context} result={result} />;
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
import { PERMS } from "@/lib/permission-slugs";
|
||||
import {
|
||||
createCorrelationId,
|
||||
fail,
|
||||
type HousekeepingCapabilityContext,
|
||||
type HousekeepingResult,
|
||||
} from "../../../foundation/contracts";
|
||||
import type { HousekeepingPageInput } from "../../../route-handlers";
|
||||
import {
|
||||
type PeopleSupportQueryData,
|
||||
peopleSupportQuery,
|
||||
} from "../queries/support";
|
||||
import { PeoplePageFrame } from "./page-state";
|
||||
import { PeopleCommandForm } from "./people-command-form";
|
||||
|
||||
interface Props {
|
||||
readonly context: HousekeepingCapabilityContext;
|
||||
readonly result?: HousekeepingResult<PeopleSupportQueryData>;
|
||||
}
|
||||
|
||||
export function PeopleTicketDetailPage({ context, result }: Props) {
|
||||
const canEdit = context.hasAny(PERMS.TICKETS_EDIT, PERMS.MOD_TICKETS_EDIT);
|
||||
return (
|
||||
<PeoplePageFrame
|
||||
title="Ticket"
|
||||
description="Review the conversation and coordinate the support response."
|
||||
result={result}
|
||||
isEmpty={(data) => data.kind !== "ticket"}
|
||||
>
|
||||
{(data) => data.kind === "ticket" ? (
|
||||
<article className="space-y-4">
|
||||
<header><h2>{data.ticket.subject}</h2><p>{data.ticket.status} · {data.ticket.priority}</p></header>
|
||||
<ul className="space-y-2">
|
||||
{data.ticket.messages.map((message) => (
|
||||
<li key={message.id} className="rounded border border-[var(--admin-border)] p-3">
|
||||
<strong>{message.username ?? `User #${message.userId}`}</strong>
|
||||
<p>{message.message}</p>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
{canEdit ? (
|
||||
<div className="grid gap-3 lg:grid-cols-2">
|
||||
<PeopleCommandForm
|
||||
commandId="people.ticket.reply"
|
||||
buttonLabel="Reply"
|
||||
input={{ ticketId: data.ticket.id }}
|
||||
fields={[{ name: "message", label: "Message", type: "text", required: true, maxLength: 5000 }]}
|
||||
/>
|
||||
<PeopleCommandForm
|
||||
commandId="people.ticket.assign"
|
||||
buttonLabel="Assign"
|
||||
input={{ ticketId: data.ticket.id }}
|
||||
fields={[{
|
||||
name: "assigneeId",
|
||||
label: "Assignee",
|
||||
type: "select",
|
||||
options: data.ticket.staff.map((staff) => ({ value: staff.id, label: staff.username })),
|
||||
}]}
|
||||
/>
|
||||
<PeopleCommandForm
|
||||
commandId="people.ticket.status"
|
||||
buttonLabel="Change status"
|
||||
input={{ ticketId: data.ticket.id }}
|
||||
fields={[{
|
||||
name: "status", label: "Status", type: "select",
|
||||
options: ["open", "in_progress", "waiting", "closed"].map((value) => ({ value, label: value })),
|
||||
}]}
|
||||
/>
|
||||
<PeopleCommandForm
|
||||
commandId="people.ticket.priority"
|
||||
buttonLabel="Change priority"
|
||||
input={{ ticketId: data.ticket.id }}
|
||||
fields={[{
|
||||
name: "priority", label: "Priority", type: "select",
|
||||
options: ["low", "normal", "high", "urgent"].map((value) => ({ value, label: value })),
|
||||
}]}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
</article>
|
||||
) : null}
|
||||
</PeoplePageFrame>
|
||||
);
|
||||
}
|
||||
|
||||
export async function renderPeopleTicketDetailPage(input: HousekeepingPageInput) {
|
||||
const id = Number(input.match.params.id);
|
||||
const result =
|
||||
Number.isSafeInteger(id) && id > 0
|
||||
? await peopleSupportQuery.run(input.context, {
|
||||
routeId: "people.support.ticket-detail",
|
||||
id,
|
||||
})
|
||||
: fail("VALIDATION", "errors.housekeeping.validation", createCorrelationId());
|
||||
return <PeopleTicketDetailPage context={input.context} result={result} />;
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { PERMS } from "@/lib/permission-slugs";
|
||||
import type { HousekeepingCapabilityContext } from "../../foundation/contracts";
|
||||
import { anyCapability } from "../../foundation/contracts";
|
||||
import {
|
||||
createPeopleInboxSources,
|
||||
PEOPLE_INBOX_SOURCE_IDS,
|
||||
} from "./inbox";
|
||||
import { peopleManifest } from "./manifest";
|
||||
import {
|
||||
createPeopleSearchProviders,
|
||||
PEOPLE_SEARCH_PROVIDER_IDS,
|
||||
} from "./search";
|
||||
import { createPeopleWidgets } from "./widgets";
|
||||
|
||||
function context(granted: readonly string[]): HousekeepingCapabilityContext {
|
||||
const permissions = new Set(granted);
|
||||
return {
|
||||
actor: { id: 42, username: "operator", rank: 4 },
|
||||
isSuperAdmin: false,
|
||||
has: (slug) => permissions.has(slug),
|
||||
hasAny: (...slugs) => slugs.some((slug) => permissions.has(slug)),
|
||||
hasAll: (...slugs) => slugs.every((slug) => permissions.has(slug)),
|
||||
};
|
||||
}
|
||||
|
||||
describe("People providers", () => {
|
||||
it("declares exact provider IDs and no empty manifest collection", () => {
|
||||
expect(PEOPLE_SEARCH_PROVIDER_IDS).toEqual([
|
||||
"people.users", "people.guilds", "people.tickets",
|
||||
]);
|
||||
expect(PEOPLE_INBOX_SOURCE_IDS).toEqual([
|
||||
"people.tickets", "people.help-tickets", "people.cfh", "people.active-bans",
|
||||
]);
|
||||
expect([
|
||||
peopleManifest.routes,
|
||||
peopleManifest.searchProviders,
|
||||
peopleManifest.inboxSources,
|
||||
peopleManifest.widgets,
|
||||
].every((collection) => collection.length > 0)).toBe(true);
|
||||
});
|
||||
|
||||
it("bounds search at 25 and item-filters capabilities and unsafe links", async () => {
|
||||
const candidates = Array.from({ length: 40 }, (_, index) => ({
|
||||
id: `candidate-${index}`,
|
||||
title: `Candidate ${index}`,
|
||||
href: index === 0
|
||||
? ("https://example.invalid" as const)
|
||||
: (`/ase/people/users/${index + 1}` as const),
|
||||
capability: index === 1
|
||||
? anyCapability(PERMS.USERS_EDIT)
|
||||
: anyCapability(PERMS.MOD_USERS_VIEW),
|
||||
}));
|
||||
const result = await createPeopleSearchProviders({
|
||||
users: async () => candidates,
|
||||
guilds: async () => [],
|
||||
tickets: async () => [],
|
||||
})[0].search(context([PERMS.MOD_USERS_VIEW]), {
|
||||
term: "candidate",
|
||||
limit: 100,
|
||||
});
|
||||
expect(result.ok).toBe(true);
|
||||
if (!result.ok) return;
|
||||
expect(result.data).toHaveLength(25);
|
||||
expect(result.data.every((item) => item.href.startsWith("/ase/"))).toBe(true);
|
||||
expect(result.data.map((item) => item.id)).not.toContain("candidate-1");
|
||||
});
|
||||
|
||||
it("bounds inbox sources and loads the mandatory real queue widget", async () => {
|
||||
const items = Array.from({ length: 40 }, (_, index) => ({
|
||||
sourceId: "people.tickets",
|
||||
itemId: String(index),
|
||||
deduplicationKey: `ticket:${index}`,
|
||||
domain: "people" as const,
|
||||
capability: index === 0
|
||||
? anyCapability(PERMS.TICKETS_EDIT)
|
||||
: anyCapability(PERMS.MOD_TICKETS_VIEW),
|
||||
severity: "info" as const,
|
||||
priority: "normal" as const,
|
||||
ageMs: 0,
|
||||
state: "open",
|
||||
occurredAt: "2026-08-29T00:00:00.000Z",
|
||||
titleKey: "pages.housekeeping.items.ticket",
|
||||
href: index === 1
|
||||
? (`/ase/people/support/tickets/${index + 1}\u0000` as const)
|
||||
: (`/ase/people/support/tickets/${index + 1}` as const),
|
||||
freshness: "fresh" as const,
|
||||
actions: [],
|
||||
}));
|
||||
const inbox = await createPeopleInboxSources({
|
||||
tickets: async () => items,
|
||||
helpTickets: async () => [],
|
||||
cfh: async () => [],
|
||||
activeBans: async () => [],
|
||||
})[0].getItems(
|
||||
context([PERMS.MOD_TICKETS_VIEW]),
|
||||
new AbortController().signal,
|
||||
);
|
||||
expect(inbox.ok).toBe(true);
|
||||
if (inbox.ok) {
|
||||
expect(inbox.data.items).toHaveLength(25);
|
||||
expect(inbox.data.items.map((item) => item.itemId)).not.toContain("0");
|
||||
expect(inbox.data.items.map((item) => item.itemId)).not.toContain("1");
|
||||
}
|
||||
|
||||
const widgets = createPeopleWidgets({
|
||||
queue: async () => ({
|
||||
tickets: 1, helpTickets: 2, cfh: 3, activeBans: 4,
|
||||
}),
|
||||
});
|
||||
expect(widgets[0]).toMatchObject({
|
||||
id: "people.queue",
|
||||
owner: "people",
|
||||
kind: "mandatory",
|
||||
});
|
||||
expect(
|
||||
await widgets[0].load(
|
||||
context([PERMS.MOD_TICKETS_VIEW]),
|
||||
new AbortController().signal,
|
||||
),
|
||||
).toMatchObject({
|
||||
ok: true,
|
||||
data: { tickets: 1, helpTickets: 2, cfh: 0, activeBans: 0 },
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -379,7 +379,7 @@ describe("People production authorization boundary", () => {
|
||||
ok: true,
|
||||
data: {
|
||||
page: {
|
||||
items: [{ id: 12, href: "/ase/people/support/help-tickets/12" }],
|
||||
items: [{ id: "12", href: "/ase/people/support/help-tickets/12" }],
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -427,7 +427,7 @@ describe("People production authorization boundary", () => {
|
||||
});
|
||||
expect(result).toMatchObject({
|
||||
ok: true,
|
||||
data: { page: { items: [{ id: 12, replyCount: 3 }] } },
|
||||
data: { page: { items: [{ id: "12", replyCount: 3 }] } },
|
||||
});
|
||||
|
||||
vi.resetModules();
|
||||
|
||||
@@ -611,14 +611,14 @@ describe("People support query", () => {
|
||||
loadTemplates: async () => ({ rows: [], total: 0 }),
|
||||
loadHelpTickets: async () => ({ rows: [], total: 0 }),
|
||||
loadHelpTicket: async () => ({
|
||||
id: 12,
|
||||
id: "12",
|
||||
title: "Help ticket",
|
||||
open: true,
|
||||
userId: 7,
|
||||
username: "Seven",
|
||||
updatedAt: "2026-08-20T00:00:00.000Z",
|
||||
href: "/ase/people/support/help-tickets/12",
|
||||
categoryId: 2,
|
||||
categoryId: "2",
|
||||
categoryName: "Help",
|
||||
content: "Body",
|
||||
replies: [],
|
||||
@@ -637,7 +637,7 @@ describe("People support query", () => {
|
||||
});
|
||||
const help = await query.run(context([PERMS.TICKETS_VIEW]), {
|
||||
routeId: "people.support.help-ticket-detail",
|
||||
id: 12,
|
||||
id: "12",
|
||||
});
|
||||
expect(desk).toMatchObject({
|
||||
ok: true,
|
||||
@@ -816,7 +816,7 @@ describe("People support query", () => {
|
||||
).toMatchObject({ ok: true, data: { kind: "ticket-templates" } });
|
||||
for (const input of [
|
||||
{ routeId: "people.support.ticket-detail" as const, id: 91 },
|
||||
{ routeId: "people.support.help-ticket-detail" as const, id: 92 },
|
||||
{ routeId: "people.support.help-ticket-detail" as const, id: "92" },
|
||||
]) {
|
||||
expect(
|
||||
await query.run(context([PERMS.TICKETS_VIEW]), input),
|
||||
|
||||
@@ -54,7 +54,7 @@ export interface PeopleSupportAdapters {
|
||||
loadHelpTickets(
|
||||
input: ReturnType<typeof normalizePeopleListInput>,
|
||||
): Promise<SupportRows<PeopleHelpTicketSummary>>;
|
||||
loadHelpTicket(id: number): Promise<PeopleHelpTicketRecord | null>;
|
||||
loadHelpTicket(id: string): Promise<PeopleHelpTicketRecord | null>;
|
||||
loadSupportStaff(): Promise<readonly PeopleSupportStaff[]>;
|
||||
loadActiveBan(userId: number): Promise<PeopleBanSummary | null>;
|
||||
}
|
||||
@@ -74,7 +74,7 @@ export type PeopleSupportQueryInput =
|
||||
}
|
||||
| {
|
||||
readonly routeId: "people.support.help-ticket-detail";
|
||||
readonly id: number;
|
||||
readonly id: string;
|
||||
};
|
||||
|
||||
export type PeopleSupportQueryData =
|
||||
@@ -145,16 +145,42 @@ export function createPeopleSupportQuery(
|
||||
return ok({ kind: "queue" as const, queue }, correlationId);
|
||||
}
|
||||
|
||||
if (
|
||||
input.routeId === "people.support.ticket-detail" ||
|
||||
input.routeId === "people.support.help-ticket-detail"
|
||||
) {
|
||||
if (input.routeId === "people.support.ticket-detail") {
|
||||
const invalid = invalidId(input.id, correlationId);
|
||||
if (invalid !== null) return invalid;
|
||||
const ticket =
|
||||
input.routeId === "people.support.ticket-detail"
|
||||
? await adapters.loadTicket(input.id)
|
||||
: await adapters.loadHelpTicket(input.id);
|
||||
const ticket = await adapters.loadTicket(input.id);
|
||||
if (ticket === null) {
|
||||
return fail(
|
||||
"NOT_FOUND",
|
||||
"errors.housekeeping.notFound",
|
||||
correlationId,
|
||||
);
|
||||
}
|
||||
const [queue, staff] = await Promise.all([
|
||||
adapters.loadQueue(),
|
||||
adapters.loadSupportStaff(),
|
||||
]);
|
||||
const hydrated = { ...ticket, queue, staff };
|
||||
assertPeopleSerializable(hydrated);
|
||||
return ok(
|
||||
{ kind: "ticket" as const, ticket: hydrated },
|
||||
correlationId,
|
||||
);
|
||||
}
|
||||
|
||||
if (input.routeId === "people.support.help-ticket-detail") {
|
||||
let id: string;
|
||||
try {
|
||||
id = peopleHelpTicketHref(input.id).split("/").at(-1) ?? "";
|
||||
} catch {
|
||||
return fail(
|
||||
"VALIDATION",
|
||||
"errors.housekeeping.validation",
|
||||
correlationId,
|
||||
{ id: ["invalid"] },
|
||||
);
|
||||
}
|
||||
const ticket = await adapters.loadHelpTicket(id);
|
||||
if (ticket === null) {
|
||||
return fail(
|
||||
"NOT_FOUND",
|
||||
@@ -162,37 +188,19 @@ export function createPeopleSupportQuery(
|
||||
correlationId,
|
||||
);
|
||||
}
|
||||
const helpTicket =
|
||||
input.routeId === "people.support.help-ticket-detail"
|
||||
? (ticket as PeopleHelpTicketRecord)
|
||||
: null;
|
||||
const [queue, staff, activeBan] = await Promise.all([
|
||||
adapters.loadQueue(),
|
||||
adapters.loadSupportStaff(),
|
||||
helpTicket?.userId !== null && helpTicket?.userId !== undefined
|
||||
? adapters.loadActiveBan(helpTicket.userId)
|
||||
: Promise.resolve(null),
|
||||
ticket.userId === null
|
||||
? Promise.resolve(null)
|
||||
: adapters.loadActiveBan(ticket.userId),
|
||||
]);
|
||||
const hydrated =
|
||||
input.routeId === "people.support.ticket-detail"
|
||||
? { ...ticket, queue, staff }
|
||||
: { ...ticket, queue, staff, activeBan };
|
||||
const hydrated = { ...ticket, queue, staff, activeBan };
|
||||
assertPeopleSerializable(hydrated);
|
||||
return input.routeId === "people.support.ticket-detail"
|
||||
? ok(
|
||||
{
|
||||
kind: "ticket" as const,
|
||||
ticket: hydrated as PeopleTicketDetail,
|
||||
},
|
||||
correlationId,
|
||||
)
|
||||
: ok(
|
||||
{
|
||||
kind: "help-ticket" as const,
|
||||
ticket: hydrated as PeopleHelpTicketDetail,
|
||||
},
|
||||
correlationId,
|
||||
);
|
||||
return ok(
|
||||
{ kind: "help-ticket" as const, ticket: hydrated },
|
||||
correlationId,
|
||||
);
|
||||
}
|
||||
|
||||
const allowedSorts =
|
||||
@@ -309,7 +317,8 @@ const peopleSupportAdapters: PeopleSupportAdapters = {
|
||||
order: input.order,
|
||||
});
|
||||
const rows = result.rows.map((row) => {
|
||||
const id = Number(row.id);
|
||||
const id =
|
||||
row.kind === "cms" ? Number(row.id) : BigInt(String(row.id)).toString();
|
||||
return {
|
||||
source: row.kind,
|
||||
id,
|
||||
@@ -320,7 +329,9 @@ const peopleSupportAdapters: PeopleSupportAdapters = {
|
||||
creatorUsername: row.user === "—" ? null : row.user,
|
||||
updatedAt: row.sortAt === 0 ? null : toPeopleIsoDate(row.sortAt),
|
||||
href:
|
||||
row.kind === "cms" ? peopleTicketHref(id) : peopleHelpTicketHref(id),
|
||||
row.kind === "cms"
|
||||
? peopleTicketHref(id as number)
|
||||
: peopleHelpTicketHref(id),
|
||||
};
|
||||
});
|
||||
return {
|
||||
@@ -527,14 +538,14 @@ const peopleSupportAdapters: PeopleSupportAdapters = {
|
||||
updatedAt: Date | string | null;
|
||||
replyCount: number | bigint;
|
||||
}>(rowsResult).map((row) => ({
|
||||
id: Number(row.id),
|
||||
id: BigInt(String(row.id)).toString(),
|
||||
title: row.title,
|
||||
open: Boolean(row.open),
|
||||
userId: row.userId === null ? null : Number(row.userId),
|
||||
username: row.username,
|
||||
updatedAt: toPeopleIsoDate(row.updatedAt),
|
||||
replyCount: Number(row.replyCount),
|
||||
href: peopleHelpTicketHref(Number(row.id)),
|
||||
href: peopleHelpTicketHref(row.id),
|
||||
}));
|
||||
return {
|
||||
rows,
|
||||
@@ -554,14 +565,14 @@ const peopleSupportAdapters: PeopleSupportAdapters = {
|
||||
FROM website_help_center_tickets t
|
||||
LEFT JOIN users u ON u.id = t.user_id
|
||||
LEFT JOIN website_help_center_categories c ON c.id = t.category_id
|
||||
WHERE t.id = ${id} LIMIT 1
|
||||
WHERE t.id = ${BigInt(id)} LIMIT 1
|
||||
`),
|
||||
db.execute(sql`
|
||||
SELECT r.id, r.user_id AS userId, u.username, r.content,
|
||||
r.created_at AS createdAt
|
||||
FROM website_help_center_ticket_replies r
|
||||
LEFT JOIN users u ON u.id = r.user_id
|
||||
WHERE r.ticket_id = ${id}
|
||||
WHERE r.ticket_id = ${BigInt(id)}
|
||||
ORDER BY r.created_at ASC, r.id ASC
|
||||
LIMIT 500
|
||||
`),
|
||||
@@ -579,17 +590,20 @@ const peopleSupportAdapters: PeopleSupportAdapters = {
|
||||
}>(ticketResult)[0];
|
||||
if (!row) return null;
|
||||
return {
|
||||
id: Number(row.id),
|
||||
id: BigInt(String(row.id)).toString(),
|
||||
title: row.title,
|
||||
content: row.content,
|
||||
open: Boolean(row.open),
|
||||
userId: row.userId === null ? null : Number(row.userId),
|
||||
username: row.username,
|
||||
categoryId: row.categoryId === null ? null : Number(row.categoryId),
|
||||
categoryId:
|
||||
row.categoryId === null
|
||||
? null
|
||||
: BigInt(String(row.categoryId)).toString(),
|
||||
categoryName: row.categoryName,
|
||||
updatedAt: toPeopleIsoDate(row.updatedAt),
|
||||
replyCount: resultRows<unknown>(repliesResult).length,
|
||||
href: peopleHelpTicketHref(Number(row.id)),
|
||||
href: peopleHelpTicketHref(row.id),
|
||||
replies: resultRows<{
|
||||
id: bigint | number;
|
||||
userId: number;
|
||||
@@ -597,7 +611,7 @@ const peopleSupportAdapters: PeopleSupportAdapters = {
|
||||
content: string;
|
||||
createdAt: Date | string | null;
|
||||
}>(repliesResult).map((reply) => ({
|
||||
id: Number(reply.id),
|
||||
id: BigInt(String(reply.id)).toString(),
|
||||
userId: Number(reply.userId),
|
||||
username: reply.username,
|
||||
content: reply.content,
|
||||
|
||||
@@ -1,10 +1,16 @@
|
||||
import type { HousekeepingRouteHandler } from "../../route-handlers";
|
||||
import { renderPeopleCfhDetailPage } from "./pages/cfh-detail";
|
||||
import { renderPeopleCommunityPage } from "./pages/community";
|
||||
import { renderPeopleHelpTicketDetailPage } from "./pages/help-ticket-detail";
|
||||
import { renderPeopleModerationPage } from "./pages/moderation";
|
||||
import { renderPeopleMultiAccountsPage } from "./pages/multi-accounts";
|
||||
import { renderPeopleStaffPage } from "./pages/staff";
|
||||
import { renderPeopleSupportPage } from "./pages/support";
|
||||
import { renderPeopleTicketDetailPage } from "./pages/ticket-detail";
|
||||
import { renderPeopleUserDetailPage } from "./pages/user-detail";
|
||||
import { renderPeopleUserEditPage } from "./pages/user-edit";
|
||||
import { renderPeopleUsersPage } from "./pages/users";
|
||||
import { PEOPLE_ROUTE_IDS } from "./routes";
|
||||
|
||||
export const PEOPLE_PRIMARY_ROUTE_IDS = [
|
||||
"people.users.list",
|
||||
@@ -38,3 +44,34 @@ export const PEOPLE_PRIMARY_ROUTE_HANDLERS: readonly HousekeepingRouteHandler[]
|
||||
Object.freeze({ routeId, render: rendererFor(routeId) }),
|
||||
),
|
||||
);
|
||||
|
||||
function peopleRenderer(
|
||||
routeId: (typeof PEOPLE_ROUTE_IDS)[number],
|
||||
): HousekeepingRouteHandler["render"] {
|
||||
if ((PEOPLE_PRIMARY_ROUTE_IDS as readonly string[]).includes(routeId)) {
|
||||
return rendererFor(routeId as PeoplePrimaryRouteId);
|
||||
}
|
||||
if (routeId === "people.moderation.cfh-detail") {
|
||||
return renderPeopleCfhDetailPage;
|
||||
}
|
||||
if (routeId.startsWith("people.moderation.")) {
|
||||
return renderPeopleModerationPage;
|
||||
}
|
||||
if (routeId === "people.staff.moderation-team") {
|
||||
return renderPeopleStaffPage;
|
||||
}
|
||||
if (routeId === "people.support.ticket-detail") {
|
||||
return renderPeopleTicketDetailPage;
|
||||
}
|
||||
if (routeId === "people.support.help-ticket-detail") {
|
||||
return renderPeopleHelpTicketDetailPage;
|
||||
}
|
||||
return renderPeopleSupportPage;
|
||||
}
|
||||
|
||||
export const PEOPLE_ROUTE_HANDLERS: readonly HousekeepingRouteHandler[] =
|
||||
Object.freeze(
|
||||
PEOPLE_ROUTE_IDS.map((routeId) =>
|
||||
Object.freeze({ routeId, render: peopleRenderer(routeId) }),
|
||||
),
|
||||
);
|
||||
@@ -1,6 +1,9 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { PERMS } from "@/lib/permission-slugs";
|
||||
import { HOUSEKEEPING_MANIFESTS } from "../../manifests";
|
||||
import { peopleMigrationEntries } from "../../migration/people";
|
||||
import { HOUSEKEEPING_ROUTE_HANDLERS } from "../../route-handlers";
|
||||
import { peopleManifest } from "./manifest";
|
||||
import { PEOPLE_ROUTE_IDS, PEOPLE_ROUTES } from "./routes";
|
||||
|
||||
const expectedRoutes = [
|
||||
@@ -166,4 +169,44 @@ describe("PEOPLE_ROUTES", () => {
|
||||
expect(routeTargets).toHaveLength(24);
|
||||
expect(new Set(routeTargets).size).toBe(routeTargets.length);
|
||||
});
|
||||
|
||||
it("registers a real manifest route and handler for every migrated People row", () => {
|
||||
const registeredRouteIds = new Set(
|
||||
peopleManifest.routes.map((route) => route.id),
|
||||
);
|
||||
const registeredHandlerIds = new Set(
|
||||
HOUSEKEEPING_ROUTE_HANDLERS.map((handler) => handler.routeId),
|
||||
);
|
||||
const routeByHref = new Map(
|
||||
PEOPLE_ROUTES.map((route) => [route.href, route.id]),
|
||||
);
|
||||
const unresolved = peopleMigrationEntries
|
||||
.filter((entry) => entry.targetPath !== null)
|
||||
.filter(
|
||||
(entry) =>
|
||||
!routeByHref.has(entry.targetPath as never) ||
|
||||
!registeredRouteIds.has(
|
||||
routeByHref.get(entry.targetPath as never) as never,
|
||||
) ||
|
||||
!registeredHandlerIds.has(
|
||||
routeByHref.get(entry.targetPath as never) as never,
|
||||
),
|
||||
);
|
||||
|
||||
expect(unresolved).toEqual([]);
|
||||
expect(
|
||||
peopleMigrationEntries.filter((entry) =>
|
||||
entry.legacyPath.startsWith("/mod"),
|
||||
),
|
||||
).toHaveLength(13);
|
||||
expect([...registeredRouteIds]).toEqual([...PEOPLE_ROUTE_IDS]);
|
||||
|
||||
const manifestRouteIds = HOUSEKEEPING_MANIFESTS.flatMap((manifest) =>
|
||||
manifest.routes.map((route) => route.id),
|
||||
).sort();
|
||||
const handlerIds = HOUSEKEEPING_ROUTE_HANDLERS.map(
|
||||
(handler) => handler.routeId,
|
||||
).sort();
|
||||
expect(handlerIds).toEqual(manifestRouteIds);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,185 @@
|
||||
import "server-only";
|
||||
|
||||
import { PERMS } from "@/lib/permission-slugs";
|
||||
import { authorizeHousekeeping } from "../../foundation/authorization";
|
||||
import { satisfiesCapability } from "../../foundation/capability-context";
|
||||
import {
|
||||
anyCapability,
|
||||
type CapabilityRequirement,
|
||||
fail,
|
||||
type HousekeepingCapabilityContext,
|
||||
type HousekeepingSearchProvider,
|
||||
ok,
|
||||
} from "../../foundation/contracts";
|
||||
import { peopleCommunityQuery } from "./queries/community";
|
||||
import { peopleSupportQuery } from "./queries/support";
|
||||
import { peopleUsersQuery } from "./queries/users";
|
||||
|
||||
export const PEOPLE_SEARCH_PROVIDER_IDS = [
|
||||
"people.users",
|
||||
"people.guilds",
|
||||
"people.tickets",
|
||||
] as const;
|
||||
|
||||
export interface PeopleSearchCandidate {
|
||||
readonly id: string;
|
||||
readonly title: string;
|
||||
readonly description?: string;
|
||||
readonly href: string;
|
||||
readonly capability: CapabilityRequirement;
|
||||
}
|
||||
|
||||
export interface PeopleSearchAdapters {
|
||||
users(
|
||||
context: HousekeepingCapabilityContext,
|
||||
term: string,
|
||||
limit: number,
|
||||
): Promise<readonly PeopleSearchCandidate[]>;
|
||||
guilds(
|
||||
context: HousekeepingCapabilityContext,
|
||||
term: string,
|
||||
limit: number,
|
||||
): Promise<readonly PeopleSearchCandidate[]>;
|
||||
tickets(
|
||||
context: HousekeepingCapabilityContext,
|
||||
term: string,
|
||||
limit: number,
|
||||
): Promise<readonly PeopleSearchCandidate[]>;
|
||||
}
|
||||
|
||||
function safeHref(href: string): href is `/ase/${string}` {
|
||||
return (
|
||||
href.startsWith("/ase/") &&
|
||||
!href.includes("\\") &&
|
||||
!Array.from(href).some((character) => {
|
||||
const code = character.codePointAt(0) ?? 0;
|
||||
return code < 32 || code === 127;
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
function boundedLimit(limit: number): number {
|
||||
return Number.isFinite(limit) ? Math.min(25, Math.max(1, Math.trunc(limit))) : 25;
|
||||
}
|
||||
|
||||
function createProvider(
|
||||
id: (typeof PEOPLE_SEARCH_PROVIDER_IDS)[number],
|
||||
capability: CapabilityRequirement,
|
||||
load: PeopleSearchAdapters[keyof PeopleSearchAdapters],
|
||||
): HousekeepingSearchProvider {
|
||||
return {
|
||||
id,
|
||||
owner: "people",
|
||||
capability,
|
||||
async search(context, input) {
|
||||
const authorization = authorizeHousekeeping(context, capability);
|
||||
if (!authorization.ok) return authorization;
|
||||
const limit = boundedLimit(input.limit);
|
||||
const term = input.term.normalize("NFC").trim().slice(0, 128);
|
||||
try {
|
||||
const candidates = await load(context, term, limit);
|
||||
return ok(
|
||||
candidates
|
||||
.filter(
|
||||
(item) =>
|
||||
safeHref(item.href) &&
|
||||
satisfiesCapability(context, item.capability),
|
||||
)
|
||||
.slice(0, limit)
|
||||
.map((item) => ({
|
||||
...item,
|
||||
domain: "people" as const,
|
||||
type: "entity" as const,
|
||||
href: item.href as `/ase/${string}`,
|
||||
})),
|
||||
authorization.correlationId,
|
||||
);
|
||||
} catch {
|
||||
return fail(
|
||||
"DEPENDENCY_UNAVAILABLE",
|
||||
"errors.housekeeping.dependencyUnavailable",
|
||||
authorization.correlationId,
|
||||
);
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function createPeopleSearchProviders(
|
||||
adapters: PeopleSearchAdapters,
|
||||
): readonly HousekeepingSearchProvider[] {
|
||||
return [
|
||||
createProvider(
|
||||
"people.users",
|
||||
anyCapability(PERMS.USERS_VIEW, PERMS.MOD_USERS_VIEW),
|
||||
adapters.users,
|
||||
),
|
||||
createProvider(
|
||||
"people.guilds",
|
||||
anyCapability(PERMS.USERS_VIEW),
|
||||
adapters.guilds,
|
||||
),
|
||||
createProvider(
|
||||
"people.tickets",
|
||||
anyCapability(PERMS.TICKETS_VIEW, PERMS.MOD_TICKETS_VIEW),
|
||||
adapters.tickets,
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
const productionAdapters: PeopleSearchAdapters = {
|
||||
async users(context, term, limit) {
|
||||
const result = await peopleUsersQuery.run(context, {
|
||||
routeId: "people.users.list",
|
||||
list: { search: term, pageSize: limit, offset: 0, sort: "username" },
|
||||
});
|
||||
if (!result.ok || result.data.kind !== "users") {
|
||||
if (!result.ok) throw new Error(result.error.code);
|
||||
return [];
|
||||
}
|
||||
return result.data.page.items.map((user) => ({
|
||||
id: `user-${user.id}`,
|
||||
title: user.username,
|
||||
description: `User #${user.id}`,
|
||||
href: user.href,
|
||||
capability: anyCapability(PERMS.USERS_VIEW, PERMS.MOD_USERS_VIEW),
|
||||
}));
|
||||
},
|
||||
async guilds(context, term, limit) {
|
||||
const result = await peopleCommunityQuery.run(context, {
|
||||
routeId: "people.community.guilds",
|
||||
list: { search: term, pageSize: limit, offset: 0, sort: "name" },
|
||||
});
|
||||
if (!result.ok || result.data.kind !== "guilds") {
|
||||
if (!result.ok) throw new Error(result.error.code);
|
||||
return [];
|
||||
}
|
||||
return result.data.page.items.map((guild) => ({
|
||||
id: `guild-${guild.id}`,
|
||||
title: guild.name,
|
||||
description: `${guild.memberCount} members`,
|
||||
href: guild.href,
|
||||
capability: anyCapability(PERMS.USERS_VIEW),
|
||||
}));
|
||||
},
|
||||
async tickets(context, term, limit) {
|
||||
const result = await peopleSupportQuery.run(context, {
|
||||
routeId: "people.support.tickets",
|
||||
list: { search: term, pageSize: limit, offset: 0, sort: "id" },
|
||||
});
|
||||
if (!result.ok || result.data.kind !== "tickets") {
|
||||
if (!result.ok) throw new Error(result.error.code);
|
||||
return [];
|
||||
}
|
||||
return result.data.page.items.map((ticket) => ({
|
||||
id: `${ticket.source}-ticket-${ticket.id}`,
|
||||
title: ticket.subject,
|
||||
description: `${ticket.status} · ${ticket.creatorUsername ?? "unknown"}`,
|
||||
href: ticket.href,
|
||||
capability: anyCapability(PERMS.TICKETS_VIEW, PERMS.MOD_TICKETS_VIEW),
|
||||
}));
|
||||
},
|
||||
};
|
||||
|
||||
export const PEOPLE_SEARCH_PROVIDERS =
|
||||
createPeopleSearchProviders(productionAdapters);
|
||||
@@ -17,23 +17,34 @@ import {
|
||||
Items,
|
||||
Rooms,
|
||||
Sanctions,
|
||||
SupportTickets,
|
||||
User,
|
||||
UsersBadges,
|
||||
UsersCurrency,
|
||||
UsersSettings,
|
||||
WebsiteHelpCenterTicketReplies,
|
||||
WebsiteHelpCenterTickets,
|
||||
WebsiteIpBlacklist,
|
||||
WebsiteIpWhitelist,
|
||||
WebsiteSetting,
|
||||
WebsiteStaffApplications,
|
||||
WebsiteTeams,
|
||||
WebsiteTicket,
|
||||
WebsiteTicketMessage,
|
||||
WebsiteTicketTemplate,
|
||||
WebsiteWordfilter,
|
||||
} from "@/lib/db";
|
||||
import { PERMS } from "@/lib/permission-slugs";
|
||||
import { logAudit } from "@/lib/services/audit";
|
||||
import { reloadWordFilter } from "@/lib/services/moderation";
|
||||
import {
|
||||
executeModerationAction,
|
||||
type ModerationAction,
|
||||
reloadWordFilter,
|
||||
} from "@/lib/services/moderation";
|
||||
import { rcon } from "@/lib/services/rcon";
|
||||
import { siteSettings } from "@/lib/services/site-settings";
|
||||
import { logStaffActivity } from "@/lib/services/staff-activity";
|
||||
import { canonicalTicketId } from "@/lib/services/ticket-replies";
|
||||
import { notify } from "@/lib/services/webhook";
|
||||
import { satisfiesCapability } from "../../../foundation/capability-context";
|
||||
import {
|
||||
@@ -67,7 +78,20 @@ export type PeopleMutationOperation =
|
||||
| "team.change"
|
||||
| "ip.action"
|
||||
| "vpn.configure"
|
||||
| "word-filter.update";
|
||||
| "word-filter.update"
|
||||
| "ticket.reply"
|
||||
| "ticket.assign"
|
||||
| "ticket.status"
|
||||
| "ticket.priority"
|
||||
| "ticket-template.change"
|
||||
| "help-ticket.reply"
|
||||
| "help-ticket.status"
|
||||
| "help-ticket.unban"
|
||||
| "cfh.resolve"
|
||||
| "cfh.sanction"
|
||||
| "ban.create"
|
||||
| "ban.lift"
|
||||
| "moderation.action";
|
||||
|
||||
export interface PeopleMutationSnapshot {
|
||||
readonly before: Readonly<Record<string, unknown>> | null;
|
||||
@@ -121,9 +145,31 @@ class PeopleMutationFailure extends Error {
|
||||
class ConfirmedExternalNoopFailure extends PeopleMutationFailure {}
|
||||
|
||||
function operationCapability(operation: PeopleMutationOperation) {
|
||||
if (operation === "user.ban" || operation === "user.unban") {
|
||||
if (
|
||||
operation === "user.ban" ||
|
||||
operation === "user.unban" ||
|
||||
operation === "ban.create" ||
|
||||
operation === "ban.lift" ||
|
||||
operation === "help-ticket.unban"
|
||||
) {
|
||||
return anyCapability(PERMS.USERS_BAN);
|
||||
}
|
||||
if (
|
||||
operation.startsWith("ticket.") ||
|
||||
operation === "help-ticket.reply" ||
|
||||
operation === "help-ticket.status"
|
||||
) {
|
||||
return anyCapability(PERMS.TICKETS_EDIT, PERMS.MOD_TICKETS_EDIT);
|
||||
}
|
||||
if (operation === "ticket-template.change") {
|
||||
return anyCapability(PERMS.TICKETS_EDIT);
|
||||
}
|
||||
if (operation === "cfh.resolve" || operation === "cfh.sanction") {
|
||||
return anyCapability(PERMS.MODERATION_EDIT, PERMS.MOD_CFH_EDIT);
|
||||
}
|
||||
if (operation === "moderation.action") {
|
||||
return anyCapability(PERMS.MODERATION_EDIT, PERMS.MOD_ACTIONS);
|
||||
}
|
||||
if (operation === "user.reset-password") {
|
||||
return anyCapability(PERMS.USERS_RESET_PASSWORD);
|
||||
}
|
||||
@@ -1755,6 +1801,657 @@ async function executeWordFilterUpdate(
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
type TicketMutationOperation = Extract<
|
||||
PeopleMutationOperation,
|
||||
`ticket.${string}`
|
||||
>;
|
||||
|
||||
async function executeTicketMutation(
|
||||
operation: TicketMutationOperation,
|
||||
input: unknown,
|
||||
context: PeopleMutationContext,
|
||||
): Promise<PeopleMutationSnapshot> {
|
||||
const data = record(input);
|
||||
const ticketId = positiveInteger(data.ticketId);
|
||||
let snapshot!: PeopleMutationSnapshot;
|
||||
await db.transaction(async (tx) => {
|
||||
const [ticket] = await tx
|
||||
.select({
|
||||
id: WebsiteTicket.id,
|
||||
assigneeId: WebsiteTicket.assigneeId,
|
||||
status: WebsiteTicket.status,
|
||||
priority: WebsiteTicket.priority,
|
||||
})
|
||||
.from(WebsiteTicket)
|
||||
.where(eq(WebsiteTicket.id, ticketId))
|
||||
.limit(1);
|
||||
if (!ticket) {
|
||||
throw new PeopleMutationFailure(
|
||||
"NOT_FOUND",
|
||||
"errors.housekeeping.notFound",
|
||||
);
|
||||
}
|
||||
const before = {
|
||||
id: ticket.id,
|
||||
assigneeId: ticket.assigneeId,
|
||||
status: ticket.status,
|
||||
priority: ticket.priority,
|
||||
};
|
||||
const now = new Date();
|
||||
if (operation === "ticket.reply") {
|
||||
const message = normalizedText(data.message, 5_000);
|
||||
await tx.insert(WebsiteTicketMessage).values({
|
||||
ticketId,
|
||||
userId: context.capability.actor.id,
|
||||
message,
|
||||
isStaff: 1,
|
||||
});
|
||||
const assigneeId = ticket.assigneeId ?? context.capability.actor.id;
|
||||
await tx
|
||||
.update(WebsiteTicket)
|
||||
.set({ status: "waiting", assigneeId, updatedAt: now })
|
||||
.where(eq(WebsiteTicket.id, ticketId));
|
||||
snapshot = {
|
||||
before,
|
||||
after: { ...before, assigneeId, status: "waiting" },
|
||||
};
|
||||
} else if (operation === "ticket.assign") {
|
||||
const assigneeId =
|
||||
data.assigneeId === null ? null : positiveInteger(data.assigneeId);
|
||||
const status = assigneeId === null ? "open" : "in_progress";
|
||||
await tx
|
||||
.update(WebsiteTicket)
|
||||
.set({ assigneeId, status, updatedAt: now })
|
||||
.where(eq(WebsiteTicket.id, ticketId));
|
||||
snapshot = { before, after: { ...before, assigneeId, status } };
|
||||
} else if (operation === "ticket.status") {
|
||||
const status = normalizedText(data.status, 32);
|
||||
if (!["open", "in_progress", "waiting", "closed"].includes(status)) {
|
||||
throw new PeopleMutationFailure(
|
||||
"VALIDATION",
|
||||
"errors.housekeeping.validation",
|
||||
);
|
||||
}
|
||||
const assigneeId =
|
||||
status === "in_progress" && ticket.assigneeId === null
|
||||
? context.capability.actor.id
|
||||
: ticket.assigneeId;
|
||||
await tx
|
||||
.update(WebsiteTicket)
|
||||
.set({
|
||||
status,
|
||||
assigneeId,
|
||||
updatedAt: now,
|
||||
...(status === "closed" ? { closedAt: now } : {}),
|
||||
})
|
||||
.where(eq(WebsiteTicket.id, ticketId));
|
||||
snapshot = { before, after: { ...before, assigneeId, status } };
|
||||
} else {
|
||||
const priority = normalizedText(data.priority, 32);
|
||||
if (!["low", "normal", "high", "urgent"].includes(priority)) {
|
||||
throw new PeopleMutationFailure(
|
||||
"VALIDATION",
|
||||
"errors.housekeeping.validation",
|
||||
);
|
||||
}
|
||||
await tx
|
||||
.update(WebsiteTicket)
|
||||
.set({ priority, updatedAt: now })
|
||||
.where(eq(WebsiteTicket.id, ticketId));
|
||||
snapshot = { before, after: { ...before, priority } };
|
||||
}
|
||||
await logAudit(
|
||||
canonicalAuditEntry(
|
||||
context,
|
||||
operation,
|
||||
"WebsiteTicket",
|
||||
ticketId,
|
||||
snapshot,
|
||||
"success",
|
||||
),
|
||||
tx,
|
||||
);
|
||||
});
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
async function executeTicketTemplateChange(
|
||||
input: unknown,
|
||||
context: PeopleMutationContext,
|
||||
): Promise<PeopleMutationSnapshot> {
|
||||
const data = record(input);
|
||||
const action = normalizedText(data.action, 16);
|
||||
let snapshot!: PeopleMutationSnapshot;
|
||||
await db.transaction(async (tx) => {
|
||||
let targetId: number | undefined;
|
||||
if (action === "create") {
|
||||
const values = {
|
||||
title: normalizedText(data.title, 255),
|
||||
content: normalizedText(data.content, 5_000),
|
||||
category: normalizedText(data.category ?? "general", 50),
|
||||
sortOrder:
|
||||
data.sortOrder === undefined ? 0 : nonNegativeInteger(data.sortOrder),
|
||||
};
|
||||
const [result] = await tx.insert(WebsiteTicketTemplate).values(values);
|
||||
targetId = positiveInteger(result.insertId);
|
||||
snapshot = { before: null, after: { id: targetId, ...values } };
|
||||
} else {
|
||||
const id = positiveInteger(data.id);
|
||||
targetId = id;
|
||||
const [existing] = await tx
|
||||
.select({
|
||||
id: WebsiteTicketTemplate.id,
|
||||
title: WebsiteTicketTemplate.title,
|
||||
content: WebsiteTicketTemplate.content,
|
||||
category: WebsiteTicketTemplate.category,
|
||||
sortOrder: WebsiteTicketTemplate.sortOrder,
|
||||
})
|
||||
.from(WebsiteTicketTemplate)
|
||||
.where(eq(WebsiteTicketTemplate.id, id))
|
||||
.limit(1);
|
||||
if (action === "update" && !existing) {
|
||||
throw new PeopleMutationFailure(
|
||||
"NOT_FOUND",
|
||||
"errors.housekeeping.notFound",
|
||||
);
|
||||
}
|
||||
if (action === "delete") {
|
||||
await tx
|
||||
.delete(WebsiteTicketTemplate)
|
||||
.where(eq(WebsiteTicketTemplate.id, id));
|
||||
snapshot = { before: existing ?? null, after: null };
|
||||
} else if (action === "update") {
|
||||
const changes = {
|
||||
...(data.title === undefined
|
||||
? {}
|
||||
: { title: normalizedText(data.title, 255) }),
|
||||
...(data.content === undefined
|
||||
? {}
|
||||
: { content: normalizedText(data.content, 5_000) }),
|
||||
...(data.category === undefined
|
||||
? {}
|
||||
: { category: normalizedText(data.category, 50, false) }),
|
||||
...(data.sortOrder === undefined
|
||||
? {}
|
||||
: { sortOrder: nonNegativeInteger(data.sortOrder) }),
|
||||
};
|
||||
await tx
|
||||
.update(WebsiteTicketTemplate)
|
||||
.set(changes)
|
||||
.where(eq(WebsiteTicketTemplate.id, id));
|
||||
snapshot = { before: existing ?? null, after: { ...existing, ...changes } };
|
||||
} else {
|
||||
throw new PeopleMutationFailure(
|
||||
"VALIDATION",
|
||||
"errors.housekeeping.validation",
|
||||
);
|
||||
}
|
||||
}
|
||||
await logAudit(
|
||||
canonicalAuditEntry(
|
||||
context,
|
||||
"ticket-template.change",
|
||||
"WebsiteTicketTemplate",
|
||||
targetId,
|
||||
snapshot,
|
||||
"success",
|
||||
),
|
||||
tx,
|
||||
);
|
||||
});
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
async function executeHelpTicketMutation(
|
||||
operation: Extract<PeopleMutationOperation, `help-ticket.${string}`>,
|
||||
input: unknown,
|
||||
context: PeopleMutationContext,
|
||||
): Promise<PeopleMutationSnapshot> {
|
||||
const data = record(input);
|
||||
let ticketId: bigint;
|
||||
try {
|
||||
ticketId = canonicalTicketId(data.ticketId as string);
|
||||
} catch {
|
||||
throw new PeopleMutationFailure(
|
||||
"VALIDATION",
|
||||
"errors.housekeeping.validation",
|
||||
);
|
||||
}
|
||||
let snapshot!: PeopleMutationSnapshot;
|
||||
await db.transaction(async (tx) => {
|
||||
const [ticket] = await tx
|
||||
.select({
|
||||
id: WebsiteHelpCenterTickets.id,
|
||||
userId: WebsiteHelpCenterTickets.userId,
|
||||
open: WebsiteHelpCenterTickets.open,
|
||||
title: WebsiteHelpCenterTickets.title,
|
||||
})
|
||||
.from(WebsiteHelpCenterTickets)
|
||||
.where(eq(WebsiteHelpCenterTickets.id, ticketId))
|
||||
.limit(1);
|
||||
if (!ticket) {
|
||||
throw new PeopleMutationFailure(
|
||||
"NOT_FOUND",
|
||||
"errors.housekeeping.notFound",
|
||||
);
|
||||
}
|
||||
const id = ticketId.toString();
|
||||
const before = {
|
||||
id,
|
||||
userId: ticket.userId,
|
||||
open: Boolean(ticket.open),
|
||||
title: ticket.title,
|
||||
};
|
||||
const now = new Date();
|
||||
if (operation === "help-ticket.reply") {
|
||||
await tx.insert(WebsiteHelpCenterTicketReplies).values({
|
||||
ticketId,
|
||||
userId: context.capability.actor.id,
|
||||
content: normalizedText(data.content, 5_000),
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
});
|
||||
await tx
|
||||
.update(WebsiteHelpCenterTickets)
|
||||
.set({ updatedAt: now })
|
||||
.where(eq(WebsiteHelpCenterTickets.id, ticketId));
|
||||
snapshot = { before, after: before };
|
||||
} else if (operation === "help-ticket.status") {
|
||||
const status = normalizedText(data.status, 16);
|
||||
const open =
|
||||
status === "reopen"
|
||||
? true
|
||||
: status === "close"
|
||||
? false
|
||||
: null;
|
||||
if (open === null) {
|
||||
throw new PeopleMutationFailure(
|
||||
"VALIDATION",
|
||||
"errors.housekeeping.validation",
|
||||
);
|
||||
}
|
||||
if (Boolean(ticket.open) === open) {
|
||||
throw new PeopleMutationFailure(
|
||||
"CONFLICT",
|
||||
"errors.housekeeping.conflict",
|
||||
);
|
||||
}
|
||||
await tx
|
||||
.update(WebsiteHelpCenterTickets)
|
||||
.set({ open, updatedAt: now })
|
||||
.where(eq(WebsiteHelpCenterTickets.id, ticketId));
|
||||
snapshot = { before, after: { ...before, open } };
|
||||
} else {
|
||||
if (ticket.userId === null) {
|
||||
throw new PeopleMutationFailure(
|
||||
"CONFLICT",
|
||||
"errors.housekeeping.conflict",
|
||||
);
|
||||
}
|
||||
const deleted = await tx.delete(Ban).where(eq(Ban.userId, ticket.userId));
|
||||
const removed = Number(
|
||||
(deleted as unknown as [{ affectedRows: number }])[0]?.affectedRows ?? 0,
|
||||
);
|
||||
if (ticket.open) {
|
||||
await tx
|
||||
.update(WebsiteHelpCenterTickets)
|
||||
.set({ open: false, updatedAt: now })
|
||||
.where(eq(WebsiteHelpCenterTickets.id, ticketId));
|
||||
}
|
||||
snapshot = {
|
||||
before,
|
||||
after: { ...before, open: false, removedBans: removed },
|
||||
output: { removed, userId: ticket.userId },
|
||||
};
|
||||
}
|
||||
await logAudit(
|
||||
canonicalAuditEntry(
|
||||
context,
|
||||
operation,
|
||||
"WebsiteHelpCenterTickets",
|
||||
auditTargetId(ticketId),
|
||||
snapshot,
|
||||
"success",
|
||||
),
|
||||
tx,
|
||||
);
|
||||
});
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
async function executeCfhResolve(
|
||||
input: unknown,
|
||||
context: PeopleMutationContext,
|
||||
): Promise<PeopleMutationSnapshot> {
|
||||
const data = record(input);
|
||||
const ticketId = positiveInteger(data.ticketId);
|
||||
const state = nonNegativeInteger(data.state);
|
||||
if (state > 3) {
|
||||
throw new PeopleMutationFailure(
|
||||
"VALIDATION",
|
||||
"errors.housekeeping.validation",
|
||||
);
|
||||
}
|
||||
let snapshot!: PeopleMutationSnapshot;
|
||||
await db.transaction(async (tx) => {
|
||||
const [ticket] = await tx
|
||||
.select({
|
||||
id: SupportTickets.id,
|
||||
state: SupportTickets.state,
|
||||
modId: SupportTickets.modId,
|
||||
})
|
||||
.from(SupportTickets)
|
||||
.where(eq(SupportTickets.id, ticketId))
|
||||
.limit(1);
|
||||
if (!ticket) {
|
||||
throw new PeopleMutationFailure(
|
||||
"NOT_FOUND",
|
||||
"errors.housekeeping.notFound",
|
||||
);
|
||||
}
|
||||
snapshot = {
|
||||
before: { id: ticket.id, state: ticket.state, moderatorId: ticket.modId },
|
||||
after: {
|
||||
id: ticket.id,
|
||||
state,
|
||||
moderatorId: context.capability.actor.id,
|
||||
},
|
||||
};
|
||||
await tx
|
||||
.update(SupportTickets)
|
||||
.set({ state, modId: context.capability.actor.id })
|
||||
.where(eq(SupportTickets.id, ticketId));
|
||||
await logAudit(
|
||||
canonicalAuditEntry(
|
||||
context,
|
||||
"cfh.resolve",
|
||||
"support_tickets",
|
||||
ticketId,
|
||||
snapshot,
|
||||
"success",
|
||||
),
|
||||
tx,
|
||||
);
|
||||
});
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
function moderationAction(data: Record<string, unknown>): ModerationAction {
|
||||
const action = normalizedText(data.action, 32);
|
||||
if (action === "kick" || action === "unmute") {
|
||||
return { action, userId: positiveInteger(data.userId) };
|
||||
}
|
||||
if (action === "mute") {
|
||||
const duration = nonNegativeInteger(data.duration);
|
||||
if (duration > 525_600) {
|
||||
throw new PeopleMutationFailure(
|
||||
"VALIDATION",
|
||||
"errors.housekeeping.validation",
|
||||
);
|
||||
}
|
||||
return { action, userId: positiveInteger(data.userId), duration };
|
||||
}
|
||||
if (action === "alert") {
|
||||
return {
|
||||
action,
|
||||
userId: positiveInteger(data.userId),
|
||||
message: normalizedText(data.message, 500),
|
||||
};
|
||||
}
|
||||
if (action === "room-kick") {
|
||||
return { action, roomId: positiveInteger(data.roomId) };
|
||||
}
|
||||
if (action === "broadcast") {
|
||||
const type = normalizedText(data.type, 16);
|
||||
if (type !== "hotel" && type !== "staff") {
|
||||
throw new PeopleMutationFailure(
|
||||
"VALIDATION",
|
||||
"errors.housekeeping.validation",
|
||||
);
|
||||
}
|
||||
return {
|
||||
action,
|
||||
type,
|
||||
message: normalizedText(data.message, 500),
|
||||
};
|
||||
}
|
||||
throw new PeopleMutationFailure(
|
||||
"VALIDATION",
|
||||
"errors.housekeeping.validation",
|
||||
);
|
||||
}
|
||||
|
||||
function actionTarget(input: ModerationAction): {
|
||||
target: string;
|
||||
targetId: number | undefined;
|
||||
} {
|
||||
if ("userId" in input) return { target: "User", targetId: input.userId };
|
||||
if ("roomId" in input) return { target: "Room", targetId: input.roomId };
|
||||
return { target: "broadcast", targetId: undefined };
|
||||
}
|
||||
|
||||
async function deliverModerationAction(input: ModerationAction): Promise<void> {
|
||||
await requireRcon(await executeModerationAction(rcon, input));
|
||||
}
|
||||
|
||||
async function executeModerationMutation(
|
||||
input: unknown,
|
||||
context: PeopleMutationContext,
|
||||
): Promise<PeopleMutationSnapshot> {
|
||||
const action = moderationAction(record(input));
|
||||
const target = actionTarget(action);
|
||||
const snapshot = {
|
||||
before: null,
|
||||
after: { ...action },
|
||||
} satisfies PeopleMutationSnapshot;
|
||||
return runExternalWithAudit(
|
||||
context,
|
||||
"moderation.action",
|
||||
target.target,
|
||||
target.targetId,
|
||||
snapshot,
|
||||
() => deliverModerationAction(action),
|
||||
{ before: null, after: null },
|
||||
);
|
||||
}
|
||||
|
||||
async function executeCfhSanction(
|
||||
input: unknown,
|
||||
context: PeopleMutationContext,
|
||||
): Promise<PeopleMutationSnapshot> {
|
||||
const data = record(input);
|
||||
const ticketId = positiveInteger(data.ticketId);
|
||||
const action = moderationAction({
|
||||
...data,
|
||||
message: data.message ?? data.reason,
|
||||
});
|
||||
const reason = normalizedText(data.reason, 500);
|
||||
let snapshot!: PeopleMutationSnapshot;
|
||||
await db.transaction(async (tx) => {
|
||||
const [ticket] = await tx
|
||||
.select({
|
||||
id: SupportTickets.id,
|
||||
state: SupportTickets.state,
|
||||
modId: SupportTickets.modId,
|
||||
})
|
||||
.from(SupportTickets)
|
||||
.where(eq(SupportTickets.id, ticketId))
|
||||
.limit(1);
|
||||
if (!ticket) {
|
||||
throw new PeopleMutationFailure(
|
||||
"NOT_FOUND",
|
||||
"errors.housekeeping.notFound",
|
||||
);
|
||||
}
|
||||
snapshot = {
|
||||
before: { id: ticket.id, state: ticket.state, moderatorId: ticket.modId },
|
||||
after: {
|
||||
id: ticket.id,
|
||||
state: 2,
|
||||
moderatorId: context.capability.actor.id,
|
||||
sanction: action.action,
|
||||
reason,
|
||||
},
|
||||
};
|
||||
await tx
|
||||
.update(SupportTickets)
|
||||
.set({ state: 2, modId: context.capability.actor.id })
|
||||
.where(eq(SupportTickets.id, ticketId));
|
||||
await logAudit(
|
||||
canonicalAuditEntry(
|
||||
context,
|
||||
"cfh.sanction",
|
||||
"support_tickets",
|
||||
ticketId,
|
||||
snapshot,
|
||||
"intent",
|
||||
),
|
||||
tx,
|
||||
);
|
||||
});
|
||||
return finalizeExternalWithAudit(
|
||||
context,
|
||||
"cfh.sanction",
|
||||
"support_tickets",
|
||||
ticketId,
|
||||
snapshot,
|
||||
() => deliverModerationAction(action),
|
||||
);
|
||||
}
|
||||
|
||||
async function executeBanMutation(
|
||||
operation: "ban.create" | "ban.lift",
|
||||
input: unknown,
|
||||
context: PeopleMutationContext,
|
||||
): Promise<PeopleMutationSnapshot> {
|
||||
const data = record(input);
|
||||
if (operation === "ban.create") {
|
||||
const userId = positiveInteger(data.userId);
|
||||
const hours = nonNegativeInteger(data.hours);
|
||||
const type = normalizedText(data.type, 16);
|
||||
const reason = normalizedText(data.reason, 500);
|
||||
if (
|
||||
hours > 876_000 ||
|
||||
!["account", "ip", "machine", "super"].includes(type)
|
||||
) {
|
||||
throw new PeopleMutationFailure(
|
||||
"VALIDATION",
|
||||
"errors.housekeeping.validation",
|
||||
);
|
||||
}
|
||||
const now = Math.floor(Date.now() / 1_000);
|
||||
const banExpire = hours > 0 ? now + hours * 3_600 : 0;
|
||||
let username: string | null = null;
|
||||
let banId = 0;
|
||||
let snapshot!: PeopleMutationSnapshot;
|
||||
await db.transaction(async (tx) => {
|
||||
const [user] = await tx
|
||||
.select({ username: User.username })
|
||||
.from(User)
|
||||
.where(eq(User.id, userId))
|
||||
.limit(1);
|
||||
username = user?.username ?? null;
|
||||
const [result] = await tx.insert(Ban).values({
|
||||
userId,
|
||||
ip: "",
|
||||
machineId: "",
|
||||
userStaffId: context.capability.actor.id,
|
||||
timestamp: now,
|
||||
banExpire,
|
||||
banReason: reason,
|
||||
type: type as "account" | "ip" | "machine" | "super",
|
||||
cfhTopic: -1,
|
||||
});
|
||||
banId = positiveInteger(result.insertId);
|
||||
snapshot = {
|
||||
before: null,
|
||||
after: {
|
||||
id: banId,
|
||||
userId,
|
||||
type,
|
||||
reason,
|
||||
expiresAt: banExpire,
|
||||
},
|
||||
};
|
||||
await logAudit(
|
||||
canonicalAuditEntry(
|
||||
context,
|
||||
operation,
|
||||
"Ban",
|
||||
banId,
|
||||
snapshot,
|
||||
"intent",
|
||||
),
|
||||
tx,
|
||||
);
|
||||
});
|
||||
return finalizeExternalWithAudit(
|
||||
context,
|
||||
operation,
|
||||
"Ban",
|
||||
banId,
|
||||
snapshot,
|
||||
async () => {
|
||||
let delivered = true;
|
||||
if (username !== null) {
|
||||
delivered = await rcon.disconnectUser(userId, username);
|
||||
}
|
||||
await logStaffActivity({
|
||||
staffId: context.capability.actor.id,
|
||||
action: "user_ban",
|
||||
description: `Banned user #${userId} (${type}, ${
|
||||
hours > 0 ? `${hours}h` : "permanent"
|
||||
}): ${reason}`,
|
||||
targetType: "user",
|
||||
targetId: userId,
|
||||
});
|
||||
await requireRcon(delivered);
|
||||
},
|
||||
);
|
||||
}
|
||||
const id = positiveInteger(data.id);
|
||||
let snapshot!: PeopleMutationSnapshot;
|
||||
await db.transaction(async (tx) => {
|
||||
const [existing] = await tx
|
||||
.select({
|
||||
id: Ban.id,
|
||||
userId: Ban.userId,
|
||||
reason: Ban.banReason,
|
||||
type: Ban.type,
|
||||
})
|
||||
.from(Ban)
|
||||
.where(eq(Ban.id, id))
|
||||
.limit(1);
|
||||
await tx.delete(Ban).where(eq(Ban.id, id));
|
||||
snapshot = { before: existing ?? null, after: null };
|
||||
await logAudit(
|
||||
canonicalAuditEntry(
|
||||
context,
|
||||
operation,
|
||||
"Ban",
|
||||
id,
|
||||
snapshot,
|
||||
"intent",
|
||||
),
|
||||
tx,
|
||||
);
|
||||
});
|
||||
return finalizeExternalWithAudit(
|
||||
context,
|
||||
operation,
|
||||
"Ban",
|
||||
id,
|
||||
snapshot,
|
||||
() =>
|
||||
logStaffActivity({
|
||||
staffId: context.capability.actor.id,
|
||||
action: "ban_lift",
|
||||
description: `Lifted ban #${id}`,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
const productionPeopleMutationAdapter: PeopleMutationAdapter = {
|
||||
async execute(operation, input, context) {
|
||||
if (operation.startsWith("user.")) {
|
||||
@@ -1781,7 +2478,34 @@ const productionPeopleMutationAdapter: PeopleMutationAdapter = {
|
||||
if (operation === "vpn.configure") {
|
||||
return executeVpnConfiguration(input, context);
|
||||
}
|
||||
return executeWordFilterUpdate(input, context);
|
||||
if (operation === "word-filter.update") {
|
||||
return executeWordFilterUpdate(input, context);
|
||||
}
|
||||
if (operation.startsWith("ticket.")) {
|
||||
return executeTicketMutation(operation as TicketMutationOperation, input, context);
|
||||
}
|
||||
if (operation === "ticket-template.change") {
|
||||
return executeTicketTemplateChange(input, context);
|
||||
}
|
||||
if (operation.startsWith("help-ticket.")) {
|
||||
return executeHelpTicketMutation(
|
||||
operation as Extract<PeopleMutationOperation, `help-ticket.${string}`>,
|
||||
input,
|
||||
context,
|
||||
);
|
||||
}
|
||||
if (operation === "cfh.resolve") return executeCfhResolve(input, context);
|
||||
if (operation === "cfh.sanction") return executeCfhSanction(input, context);
|
||||
if (operation === "ban.create" || operation === "ban.lift") {
|
||||
return executeBanMutation(operation, input, context);
|
||||
}
|
||||
if (operation === "moderation.action") {
|
||||
return executeModerationMutation(input, context);
|
||||
}
|
||||
throw new PeopleMutationFailure(
|
||||
"VALIDATION",
|
||||
"errors.housekeeping.validation",
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
import "server-only";
|
||||
|
||||
import { PERMS } from "@/lib/permission-slugs";
|
||||
import { authorizeHousekeeping } from "../../foundation/authorization";
|
||||
import { satisfiesCapability } from "../../foundation/capability-context";
|
||||
import {
|
||||
anyCapability,
|
||||
fail,
|
||||
type HousekeepingCapabilityContext,
|
||||
type HousekeepingWidgetDefinition,
|
||||
ok,
|
||||
} from "../../foundation/contracts";
|
||||
import type { PeopleQueueSnapshot } from "./models";
|
||||
import { peopleSupportQuery } from "./queries/support";
|
||||
|
||||
export interface PeopleWidgetAdapters {
|
||||
queue(
|
||||
context: HousekeepingCapabilityContext,
|
||||
signal: AbortSignal,
|
||||
): Promise<PeopleQueueSnapshot>;
|
||||
}
|
||||
|
||||
const supportQueueCapability = anyCapability(
|
||||
PERMS.TICKETS_VIEW,
|
||||
PERMS.MOD_TICKETS_VIEW,
|
||||
);
|
||||
const cfhQueueCapability = anyCapability(
|
||||
PERMS.MODERATION_VIEW,
|
||||
PERMS.MOD_CFH_VIEW,
|
||||
);
|
||||
const banQueueCapability = anyCapability(
|
||||
PERMS.BANS_VIEW,
|
||||
PERMS.MOD_BANS_VIEW,
|
||||
);
|
||||
const queueCapability = anyCapability(
|
||||
...supportQueueCapability.slugs,
|
||||
...cfhQueueCapability.slugs,
|
||||
...banQueueCapability.slugs,
|
||||
);
|
||||
|
||||
export function createPeopleWidgets(
|
||||
adapters: PeopleWidgetAdapters,
|
||||
): readonly HousekeepingWidgetDefinition[] {
|
||||
return [{
|
||||
id: "people.queue",
|
||||
owner: "people",
|
||||
capability: queueCapability,
|
||||
kind: "mandatory",
|
||||
async load(context, signal) {
|
||||
const authorization = authorizeHousekeeping(context, queueCapability);
|
||||
if (!authorization.ok) return authorization;
|
||||
try {
|
||||
const queue = await adapters.queue(context, signal);
|
||||
return ok(
|
||||
{
|
||||
tickets: satisfiesCapability(context, supportQueueCapability)
|
||||
? queue.tickets
|
||||
: 0,
|
||||
helpTickets: satisfiesCapability(context, supportQueueCapability)
|
||||
? queue.helpTickets
|
||||
: 0,
|
||||
cfh: satisfiesCapability(context, cfhQueueCapability)
|
||||
? queue.cfh
|
||||
: 0,
|
||||
activeBans: satisfiesCapability(context, banQueueCapability)
|
||||
? queue.activeBans
|
||||
: 0,
|
||||
},
|
||||
authorization.correlationId,
|
||||
);
|
||||
} catch {
|
||||
return fail(
|
||||
"DEPENDENCY_UNAVAILABLE",
|
||||
"errors.housekeeping.dependencyUnavailable",
|
||||
authorization.correlationId,
|
||||
);
|
||||
}
|
||||
},
|
||||
}];
|
||||
}
|
||||
|
||||
export const PEOPLE_WIDGETS = createPeopleWidgets({
|
||||
async queue(context) {
|
||||
const result = await peopleSupportQuery.run(context, {
|
||||
routeId: "people.support.queue",
|
||||
});
|
||||
if (!result.ok || result.data.kind !== "queue") throw new Error("queue");
|
||||
return result.data.queue;
|
||||
},
|
||||
});
|
||||
@@ -5,6 +5,8 @@ vi.mock("@/lib/auth", () => ({ invalidateLoginCache: vi.fn() }));
|
||||
vi.mock("@/lib/auth/password", () => ({ hashPassword: vi.fn() }));
|
||||
|
||||
import { COMMUNITY_COMMAND_IDS } from "../../domains/people/commands/community-commands";
|
||||
import { MODERATION_COMMAND_IDS } from "../../domains/people/commands/moderation-commands";
|
||||
import { SUPPORT_COMMAND_IDS } from "../../domains/people/commands/support-commands";
|
||||
import { USER_COMMAND_IDS } from "../../domains/people/commands/user-commands";
|
||||
import { SYSTEM_COMMAND_IDS } from "../../domains/system/commands/system-commands";
|
||||
import { anyCapability, ok } from "../contracts";
|
||||
@@ -72,6 +74,12 @@ describe("housekeeping command bootstrap", () => {
|
||||
expect(
|
||||
COMMUNITY_COMMAND_IDS.map((id) => getHousekeepingCommand(id)?.id),
|
||||
).toEqual(COMMUNITY_COMMAND_IDS);
|
||||
expect(
|
||||
SUPPORT_COMMAND_IDS.map((id) => getHousekeepingCommand(id)?.id),
|
||||
).toEqual(SUPPORT_COMMAND_IDS);
|
||||
expect(
|
||||
MODERATION_COMMAND_IDS.map((id) => getHousekeepingCommand(id)?.id),
|
||||
).toEqual(MODERATION_COMMAND_IDS);
|
||||
expect(() =>
|
||||
registerHousekeepingCommand({
|
||||
id: "system.bootstrap.too-late",
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import "server-only";
|
||||
|
||||
import { COMMUNITY_COMMANDS } from "../../domains/people/commands/community-commands";
|
||||
import { MODERATION_COMMANDS } from "../../domains/people/commands/moderation-commands";
|
||||
import { SUPPORT_COMMANDS } from "../../domains/people/commands/support-commands";
|
||||
import { USER_COMMANDS } from "../../domains/people/commands/user-commands";
|
||||
import { SYSTEM_COMMANDS } from "../../domains/system/commands/system-commands";
|
||||
import type { HousekeepingCommand } from "./registry";
|
||||
@@ -40,6 +42,8 @@ export function registerHousekeepingCommands<
|
||||
const currentHousekeepingCommands = defineHousekeepingCommands(
|
||||
...USER_COMMANDS,
|
||||
...COMMUNITY_COMMANDS,
|
||||
...SUPPORT_COMMANDS,
|
||||
...MODERATION_COMMANDS,
|
||||
...SYSTEM_COMMANDS,
|
||||
);
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ import { join, posix } from "node:path";
|
||||
import { createElement, type ReactElement } from "react";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { PEOPLE_PRIMARY_ROUTE_IDS } from "../domains/people/route-handlers";
|
||||
import { PEOPLE_ROUTE_IDS } from "../domains/people/routes";
|
||||
import { SYSTEM_ROUTE_IDS } from "../domains/system/routes";
|
||||
import { HOUSEKEEPING_MANIFESTS } from "../manifests";
|
||||
import { discoverLegacyPages } from "../migration/discover-legacy-pages";
|
||||
@@ -28,6 +28,14 @@ const approvedRuntimeImports = new Map<string, ReadonlySet<string>>([
|
||||
"src/features/housekeeping/domains/people/commands/user-commands.ts",
|
||||
new Set(["src/features/housekeeping/domains/people/services/mutations"]),
|
||||
],
|
||||
[
|
||||
"src/features/housekeeping/domains/people/commands/support-commands.ts",
|
||||
new Set(["src/features/housekeeping/domains/people/services/mutations"]),
|
||||
],
|
||||
[
|
||||
"src/features/housekeeping/domains/people/commands/moderation-commands.ts",
|
||||
new Set(["src/features/housekeeping/domains/people/services/mutations"]),
|
||||
],
|
||||
[
|
||||
"src/features/housekeeping/domains/people/pages/community.tsx",
|
||||
new Set([
|
||||
@@ -75,6 +83,47 @@ const approvedRuntimeImports = new Map<string, ReadonlySet<string>>([
|
||||
"src/features/housekeeping/domains/people/queries/users",
|
||||
]),
|
||||
],
|
||||
[
|
||||
"src/features/housekeeping/domains/people/pages/support.tsx",
|
||||
new Set([
|
||||
"src/features/housekeeping/domains/people/pages/people-command-form",
|
||||
"src/features/housekeeping/domains/people/pages/page-state",
|
||||
"src/features/housekeeping/domains/people/queries/support",
|
||||
]),
|
||||
],
|
||||
[
|
||||
"src/features/housekeeping/domains/people/pages/ticket-detail.tsx",
|
||||
new Set([
|
||||
"src/features/housekeeping/domains/people/pages/people-command-form",
|
||||
"src/features/housekeeping/domains/people/pages/page-state",
|
||||
"src/features/housekeeping/domains/people/queries/support",
|
||||
]),
|
||||
],
|
||||
[
|
||||
"src/features/housekeeping/domains/people/pages/help-ticket-detail.tsx",
|
||||
new Set([
|
||||
"src/features/housekeeping/domains/people/models",
|
||||
"src/features/housekeeping/domains/people/pages/people-command-form",
|
||||
"src/features/housekeeping/domains/people/pages/page-state",
|
||||
"src/features/housekeeping/domains/people/queries/support",
|
||||
]),
|
||||
],
|
||||
[
|
||||
"src/features/housekeeping/domains/people/pages/moderation.tsx",
|
||||
new Set([
|
||||
"src/features/housekeeping/domains/people/pages/people-command-form",
|
||||
"src/features/housekeeping/domains/people/pages/page-state",
|
||||
"src/features/housekeeping/domains/people/queries/moderation",
|
||||
]),
|
||||
],
|
||||
[
|
||||
"src/features/housekeeping/domains/people/pages/cfh-detail.tsx",
|
||||
new Set([
|
||||
"src/features/housekeeping/domains/people/pages/people-command-form",
|
||||
"src/features/housekeeping/domains/people/pages/page-state",
|
||||
"src/features/housekeeping/domains/people/queries/moderation",
|
||||
]),
|
||||
],
|
||||
[
|
||||
"src/features/housekeeping/domains/people/pages/page-state.tsx",
|
||||
new Set(["src/features/housekeeping/domains/people/models"]),
|
||||
@@ -95,17 +144,50 @@ const approvedRuntimeImports = new Map<string, ReadonlySet<string>>([
|
||||
],
|
||||
[
|
||||
"src/features/housekeeping/domains/people/manifest.ts",
|
||||
new Set(["src/features/housekeeping/domains/people/routes"]),
|
||||
new Set([
|
||||
"src/features/housekeeping/domains/people/inbox",
|
||||
"src/features/housekeeping/domains/people/routes",
|
||||
"src/features/housekeeping/domains/people/search",
|
||||
"src/features/housekeeping/domains/people/widgets",
|
||||
]),
|
||||
],
|
||||
[
|
||||
"src/features/housekeeping/domains/people/search.ts",
|
||||
new Set([
|
||||
"src/features/housekeeping/domains/people/queries/community",
|
||||
"src/features/housekeeping/domains/people/queries/support",
|
||||
"src/features/housekeeping/domains/people/queries/users",
|
||||
]),
|
||||
],
|
||||
[
|
||||
"src/features/housekeeping/domains/people/inbox.ts",
|
||||
new Set([
|
||||
"src/features/housekeeping/domains/people/queries/moderation",
|
||||
"src/features/housekeeping/domains/people/queries/support",
|
||||
]),
|
||||
],
|
||||
[
|
||||
"src/features/housekeeping/domains/people/widgets.ts",
|
||||
new Set([
|
||||
"src/features/housekeeping/domains/people/models",
|
||||
"src/features/housekeeping/domains/people/queries/support",
|
||||
]),
|
||||
],
|
||||
[
|
||||
"src/features/housekeeping/domains/people/route-handlers.ts",
|
||||
new Set([
|
||||
"src/features/housekeeping/domains/people/pages/community",
|
||||
"src/features/housekeeping/domains/people/pages/cfh-detail",
|
||||
"src/features/housekeeping/domains/people/pages/help-ticket-detail",
|
||||
"src/features/housekeeping/domains/people/pages/moderation",
|
||||
"src/features/housekeeping/domains/people/pages/multi-accounts",
|
||||
"src/features/housekeeping/domains/people/pages/staff",
|
||||
"src/features/housekeeping/domains/people/pages/support",
|
||||
"src/features/housekeeping/domains/people/pages/ticket-detail",
|
||||
"src/features/housekeeping/domains/people/pages/user-detail",
|
||||
"src/features/housekeeping/domains/people/pages/user-edit",
|
||||
"src/features/housekeeping/domains/people/pages/users",
|
||||
"src/features/housekeeping/domains/people/routes",
|
||||
]),
|
||||
],
|
||||
[
|
||||
@@ -217,6 +299,8 @@ const approvedRuntimeImports = new Map<string, ReadonlySet<string>>([
|
||||
"src/features/housekeeping/foundation/commands/bootstrap.ts",
|
||||
new Set([
|
||||
"src/features/housekeeping/domains/people/commands/community-commands",
|
||||
"src/features/housekeeping/domains/people/commands/moderation-commands",
|
||||
"src/features/housekeeping/domains/people/commands/support-commands",
|
||||
"src/features/housekeeping/domains/people/commands/user-commands",
|
||||
"src/features/housekeeping/domains/system/commands/system-commands",
|
||||
]),
|
||||
@@ -838,7 +922,7 @@ describe("housekeeping foundation completion contracts", () => {
|
||||
registry.domains
|
||||
.find((domain) => domain.id === "people")
|
||||
?.routes.map((route) => route.id),
|
||||
).toEqual(PEOPLE_PRIMARY_ROUTE_IDS);
|
||||
).toEqual(PEOPLE_ROUTE_IDS);
|
||||
expect(
|
||||
registry.domains
|
||||
.find((domain) => domain.id === "system")
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { PERMS } from "@/lib/permission-slugs";
|
||||
import { PEOPLE_PRIMARY_ROUTES } from "../domains/people/routes";
|
||||
import { PEOPLE_INBOX_SOURCES } from "../domains/people/inbox";
|
||||
import { PEOPLE_ROUTES } from "../domains/people/routes";
|
||||
import { PEOPLE_SEARCH_PROVIDERS } from "../domains/people/search";
|
||||
import { PEOPLE_WIDGETS } from "../domains/people/widgets";
|
||||
import { SYSTEM_ROUTES } from "../domains/system/routes";
|
||||
import { HOUSEKEEPING_MANIFESTS } from "../manifests";
|
||||
import { HOUSEKEEPING_MIGRATION_MATRIX } from "../migration/matrix";
|
||||
@@ -357,14 +360,20 @@ describe("housekeeping registry", () => {
|
||||
});
|
||||
expect(actual.routes).toEqual(
|
||||
expected.id === "people"
|
||||
? PEOPLE_PRIMARY_ROUTES
|
||||
? PEOPLE_ROUTES
|
||||
: expected.id === "system"
|
||||
? SYSTEM_ROUTES
|
||||
: [],
|
||||
);
|
||||
expect(actual.searchProviders).toEqual([]);
|
||||
expect(actual.inboxSources).toEqual([]);
|
||||
expect(actual.widgets).toEqual([]);
|
||||
expect(actual.searchProviders).toEqual(
|
||||
expected.id === "people" ? PEOPLE_SEARCH_PROVIDERS : [],
|
||||
);
|
||||
expect(actual.inboxSources).toEqual(
|
||||
expected.id === "people" ? PEOPLE_INBOX_SOURCES : [],
|
||||
);
|
||||
expect(actual.widgets).toEqual(
|
||||
expected.id === "people" ? PEOPLE_WIDGETS : [],
|
||||
);
|
||||
expect(actual.capability).toEqual({ mode: "any", slugs: expected.slugs });
|
||||
}
|
||||
});
|
||||
@@ -389,7 +398,7 @@ describe("housekeeping registry", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects duplicate provider and widget ids across domain manifests", () => {
|
||||
it("rejects duplicates within each provider kind across domain manifests", () => {
|
||||
expect(() =>
|
||||
createHousekeepingRegistry([
|
||||
{ ...manifest("people"), searchProviders: [searchProvider("shared")] },
|
||||
@@ -425,7 +434,7 @@ describe("housekeeping registry", () => {
|
||||
widgets: [widget("shared", "content")],
|
||||
},
|
||||
]),
|
||||
).toThrow("duplicate widget id: shared");
|
||||
).not.toThrow();
|
||||
});
|
||||
|
||||
it("rejects provider and widget ownership outside their manifest", () => {
|
||||
|
||||
@@ -24,7 +24,9 @@ export function createHousekeepingRegistry(
|
||||
const routeIds = new Set<string>();
|
||||
const routeHrefs = new Set<string>();
|
||||
const routeShapes = new Set<string>();
|
||||
const registryEntryIds = new Set<string>();
|
||||
const searchProviderIds = new Set<string>();
|
||||
const inboxSourceIds = new Set<string>();
|
||||
const widgetIds = new Set<string>();
|
||||
|
||||
for (const manifest of manifests) {
|
||||
if (!approvedDomainIds.has(manifest.id)) {
|
||||
@@ -48,19 +50,19 @@ export function createHousekeepingRegistry(
|
||||
manifest.searchProviders,
|
||||
manifest.id,
|
||||
"search provider",
|
||||
registryEntryIds,
|
||||
searchProviderIds,
|
||||
);
|
||||
validateOwnedRegistryEntries(
|
||||
manifest.inboxSources,
|
||||
manifest.id,
|
||||
"inbox source",
|
||||
registryEntryIds,
|
||||
inboxSourceIds,
|
||||
);
|
||||
validateOwnedRegistryEntries(
|
||||
manifest.widgets,
|
||||
manifest.id,
|
||||
"widget",
|
||||
registryEntryIds,
|
||||
widgetIds,
|
||||
);
|
||||
for (const widget of manifest.widgets) {
|
||||
if (widget.kind !== "mandatory" && widget.kind !== "optional") {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { PEOPLE_PRIMARY_ROUTE_IDS } from "./domains/people/route-handlers";
|
||||
import { PEOPLE_ROUTE_IDS } from "./domains/people/routes";
|
||||
import { SYSTEM_ROUTE_IDS } from "./domains/system/routes";
|
||||
import { createHousekeepingRegistry } from "./foundation/registry";
|
||||
import { HOUSEKEEPING_MANIFESTS } from "./manifests";
|
||||
@@ -18,7 +18,7 @@ describe("housekeeping route handlers", () => {
|
||||
expect(new Set(handlerIds).size).toBe(handlerIds.length);
|
||||
expect([...handlerIds].sort()).toEqual([...routeIds].sort());
|
||||
expect(handlerIds).toEqual([
|
||||
...PEOPLE_PRIMARY_ROUTE_IDS,
|
||||
...PEOPLE_ROUTE_IDS,
|
||||
...SYSTEM_ROUTE_IDS,
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { PEOPLE_PRIMARY_ROUTE_HANDLERS } from "./domains/people/route-handlers";
|
||||
import { PEOPLE_ROUTE_HANDLERS } from "./domains/people/route-handlers";
|
||||
import { SYSTEM_ROUTE_HANDLERS } from "./domains/system/route-handlers";
|
||||
import type { HousekeepingCapabilityContext } from "./foundation/contracts";
|
||||
import type { HousekeepingRouteMatch } from "./foundation/routing/match-route";
|
||||
@@ -18,4 +18,4 @@ export interface HousekeepingRouteHandler {
|
||||
}
|
||||
|
||||
export const HOUSEKEEPING_ROUTE_HANDLERS: readonly HousekeepingRouteHandler[] =
|
||||
Object.freeze([...PEOPLE_PRIMARY_ROUTE_HANDLERS, ...SYSTEM_ROUTE_HANDLERS]);
|
||||
Object.freeze([...PEOPLE_ROUTE_HANDLERS, ...SYSTEM_ROUTE_HANDLERS]);
|
||||
@@ -21,6 +21,53 @@ export interface ModerationResult {
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
export type ModerationAction =
|
||||
| { readonly action: "kick"; readonly userId: number }
|
||||
| {
|
||||
readonly action: "mute";
|
||||
readonly userId: number;
|
||||
readonly duration: number;
|
||||
}
|
||||
| { readonly action: "unmute"; readonly userId: number }
|
||||
| {
|
||||
readonly action: "alert";
|
||||
readonly userId: number;
|
||||
readonly message: string;
|
||||
}
|
||||
| { readonly action: "room-kick"; readonly roomId: number }
|
||||
| {
|
||||
readonly action: "broadcast";
|
||||
readonly message: string;
|
||||
readonly type: "hotel" | "staff";
|
||||
};
|
||||
|
||||
export interface ModerationActionTransport {
|
||||
disconnectUser(userId: number): Promise<boolean>;
|
||||
muteUser(userId: number, duration: number): Promise<boolean>;
|
||||
unmuteUser(userId: number): Promise<boolean>;
|
||||
alertUser(userId: number, message: string): Promise<boolean>;
|
||||
kickAll(roomId: number): Promise<boolean>;
|
||||
hotelAlert(message: string): Promise<boolean>;
|
||||
staffAlert(message: string): Promise<boolean>;
|
||||
}
|
||||
|
||||
// Execute one already-authorized moderation effect and expose delivery truth.
|
||||
export async function executeModerationAction(
|
||||
transport: ModerationActionTransport,
|
||||
input: ModerationAction,
|
||||
): Promise<boolean> {
|
||||
if (input.action === "kick") return transport.disconnectUser(input.userId);
|
||||
if (input.action === "mute")
|
||||
return transport.muteUser(input.userId, input.duration);
|
||||
if (input.action === "unmute") return transport.unmuteUser(input.userId);
|
||||
if (input.action === "alert")
|
||||
return transport.alertUser(input.userId, input.message);
|
||||
if (input.action === "room-kick") return transport.kickAll(input.roomId);
|
||||
return input.type === "hotel"
|
||||
? transport.hotelAlert(input.message)
|
||||
: transport.staffAlert(input.message);
|
||||
}
|
||||
|
||||
const OPENAI_MODERATIONS_URL = "https://api.openai.com/v1/moderations";
|
||||
// Bound the AI call so a slow/hung endpoint can't stall a server action.
|
||||
const OPENAI_TIMEOUT_MS = 5_000;
|
||||
|
||||
@@ -19,6 +19,28 @@ export interface TicketReplyDb {
|
||||
touchTicket(ticketId: bigint, updatedAt: Date): Promise<unknown>;
|
||||
}
|
||||
|
||||
const MAX_UNSIGNED_BIGINT = 18_446_744_073_709_551_615n;
|
||||
|
||||
// Canonicalize a SQL BIGINT identifier without passing through Number.
|
||||
export function canonicalTicketId(value: string | number | bigint): bigint {
|
||||
let parsed: bigint;
|
||||
try {
|
||||
if (
|
||||
typeof value === "number" &&
|
||||
(!Number.isSafeInteger(value) || value <= 0)
|
||||
) {
|
||||
throw new Error("unsafe identifier");
|
||||
}
|
||||
parsed = BigInt(String(value));
|
||||
} catch {
|
||||
throw new Error("invalid ticket identifier");
|
||||
}
|
||||
if (parsed <= 0n || parsed > MAX_UNSIGNED_BIGINT) {
|
||||
throw new Error("invalid ticket identifier");
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
export async function createOwnedTicketReply(
|
||||
db: TicketReplyDb,
|
||||
input: { ticketId: bigint; userId: number; content: string },
|
||||
|
||||
Reference in new issue
Block a user