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 { revalidatePath } from "next/cache";
|
||||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
import { requirePermissionRateLimited } from "@/lib/admin/guard";
|
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 { execute } = vi.hoisted(() => ({ execute: vi.fn() }));
|
||||||
const selectLimit = vi.fn();
|
|
||||||
const insertValues = vi.fn().mockResolvedValue([{ insertId: 1 }]);
|
|
||||||
const deleteWhere = vi.fn().mockResolvedValue([{ affectedRows: 1 }]);
|
|
||||||
return { selectLimit, insertValues, deleteWhere };
|
|
||||||
});
|
|
||||||
|
|
||||||
|
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/admin/guard", () => ({ requirePermissionRateLimited: vi.fn() }));
|
||||||
vi.mock("@/lib/permissions", () => ({ PERMS: { USERS_BAN: "users.ban" } }));
|
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() }));
|
vi.mock("next/cache", () => ({ revalidatePath: vi.fn() }));
|
||||||
|
|
||||||
|
import { createBan, liftBan } from "./admin-bans";
|
||||||
|
|
||||||
const staff = { id: 1, rank: 7, username: "admin" };
|
const staff = { id: 1, rank: 7, username: "admin" };
|
||||||
const fakeForm = (data: Record<string, string>) => ({
|
const fakeForm = (data: Record<string, string>) =>
|
||||||
get: (key: string) => data[key] ?? null,
|
({ get: (key: string) => data[key] ?? null }) as unknown as FormData;
|
||||||
});
|
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
vi.mocked(requirePermissionRateLimited).mockResolvedValue(staff as never);
|
vi.mocked(requirePermissionRateLimited).mockResolvedValue(staff as never);
|
||||||
selectLimit.mockResolvedValue([{ username: "baduser" }]);
|
execute.mockImplementation(async (invocation) => ({
|
||||||
insertValues.mockResolvedValue([{ insertId: 1 }]);
|
ok: true,
|
||||||
deleteWhere.mockResolvedValue([{ affectedRows: 1 }]);
|
data: { before: null, after: {} },
|
||||||
|
correlationId: invocation.correlationId,
|
||||||
|
}));
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("createBan", () => {
|
describe("legacy admin ban wrappers", () => {
|
||||||
it("creates a ban for valid inputs", async () => {
|
it("preserves parsed create input, service delegation, and revalidation", async () => {
|
||||||
await createBan(
|
await createBan(
|
||||||
fakeForm({
|
fakeForm({
|
||||||
userId: "42",
|
userId: "42",
|
||||||
reason: "Spam",
|
reason: "Spam",
|
||||||
hours: "24",
|
hours: "24",
|
||||||
type: "account",
|
type: "account",
|
||||||
}) as unknown as FormData,
|
}),
|
||||||
);
|
);
|
||||||
expect(insertValues).toHaveBeenCalledWith(
|
expect(execute).toHaveBeenCalledWith(
|
||||||
expect.objectContaining({ userId: 42, type: "account" }),
|
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");
|
expect(revalidatePath).toHaveBeenCalledWith("/admin/bans");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("returns early when userId is invalid", async () => {
|
it("returns early when userId is invalid", async () => {
|
||||||
await createBan(
|
await createBan(
|
||||||
fakeForm({
|
fakeForm({ userId: "0", hours: "1", type: "account" }),
|
||||||
userId: "0",
|
|
||||||
hours: "1",
|
|
||||||
type: "account",
|
|
||||||
}) as unknown as FormData,
|
|
||||||
);
|
);
|
||||||
expect(insertValues).not.toHaveBeenCalled();
|
expect(execute).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
});
|
|
||||||
|
|
||||||
describe("liftBan", () => {
|
it("delegates lift by exact ban id and preserves revalidation", async () => {
|
||||||
it("deletes ban and revalidates", async () => {
|
await liftBan(fakeForm({ id: "42" }));
|
||||||
await liftBan(fakeForm({ id: "42" }) as unknown as FormData);
|
expect(execute).toHaveBeenCalledWith(
|
||||||
expect(deleteWhere).toHaveBeenCalled();
|
expect.objectContaining({ expectedActorId: 1, legacy: true }),
|
||||||
|
"ban.lift",
|
||||||
|
{ id: 42 },
|
||||||
|
);
|
||||||
expect(revalidatePath).toHaveBeenCalledWith("/admin/bans");
|
expect(revalidatePath).toHaveBeenCalledWith("/admin/bans");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
+22
-41
@@ -1,12 +1,13 @@
|
|||||||
"use server";
|
"use server";
|
||||||
|
|
||||||
import { eq } from "drizzle-orm";
|
|
||||||
import { revalidatePath } from "next/cache";
|
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 { requirePermissionRateLimited } from "@/lib/admin/guard";
|
||||||
import { Ban, db, User } from "@/lib/db";
|
|
||||||
import { PERMS } from "@/lib/permissions";
|
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([
|
const BAN_TYPES: ReadonlySet<string> = new Set([
|
||||||
"account",
|
"account",
|
||||||
@@ -25,37 +26,17 @@ export async function createBan(formData: FormData): Promise<void> {
|
|||||||
const hours = Number(formData.get("hours"));
|
const hours = Number(formData.get("hours"));
|
||||||
const type = String(formData.get("type"));
|
const type = String(formData.get("type"));
|
||||||
if (!(userId > 0) || !BAN_TYPES.has(type)) return;
|
if (!(userId > 0) || !BAN_TYPES.has(type)) return;
|
||||||
|
const result = await peopleMutationService.execute(
|
||||||
const now = Math.floor(Date.now() / 1000);
|
createPeopleMutationInvocation(staff, createCorrelationId()),
|
||||||
// Emulator convention: banExpire 0 = permanent (not a far-future timestamp).
|
"ban.create",
|
||||||
const banExpire = hours > 0 ? now + Math.floor(hours) * 3600 : 0;
|
{
|
||||||
|
userId,
|
||||||
const [user] = await db
|
reason,
|
||||||
.select({ username: User.username })
|
hours: Number.isFinite(hours) && hours > 0 ? Math.floor(hours) : 0,
|
||||||
.from(User)
|
type,
|
||||||
.where(eq(User.id, userId))
|
},
|
||||||
.limit(1);
|
);
|
||||||
|
if (!result.ok) throw new Error("Could not create ban");
|
||||||
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,
|
|
||||||
});
|
|
||||||
revalidatePath("/admin/bans");
|
revalidatePath("/admin/bans");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -63,12 +44,12 @@ export async function liftBan(formData: FormData): Promise<void> {
|
|||||||
const staff = await requirePermissionRateLimited(PERMS.USERS_BAN);
|
const staff = await requirePermissionRateLimited(PERMS.USERS_BAN);
|
||||||
const id = Number(formData.get("id"));
|
const id = Number(formData.get("id"));
|
||||||
if (id > 0) {
|
if (id > 0) {
|
||||||
await db.delete(Ban).where(eq(Ban.id, id));
|
const result = await peopleMutationService.execute(
|
||||||
await logStaffActivity({
|
createPeopleMutationInvocation(staff, createCorrelationId()),
|
||||||
staffId: staff.id,
|
"ban.lift",
|
||||||
action: "ban_lift",
|
{ id },
|
||||||
description: `Lifted ban #${id}`,
|
);
|
||||||
});
|
if (!result.ok) throw new Error("Could not lift ban");
|
||||||
}
|
}
|
||||||
revalidatePath("/admin/bans");
|
revalidatePath("/admin/bans");
|
||||||
}
|
}
|
||||||
@@ -1,18 +1,15 @@
|
|||||||
"use server";
|
"use server";
|
||||||
|
|
||||||
import { eq } from "drizzle-orm";
|
|
||||||
import { revalidatePath } from "next/cache";
|
import { revalidatePath } from "next/cache";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import {
|
import {
|
||||||
Ban,
|
createPeopleMutationInvocation,
|
||||||
db,
|
peopleMutationService,
|
||||||
WebsiteHelpCenterTicketReplies,
|
} from "@/features/housekeeping/domains/people/services/mutations";
|
||||||
WebsiteHelpCenterTickets,
|
import { createCorrelationId } from "@/features/housekeeping/foundation/contracts";
|
||||||
} from "@/lib/db";
|
|
||||||
import { PERMS } from "@/lib/permissions";
|
import { PERMS } from "@/lib/permissions";
|
||||||
import { adminAction } from "@/lib/safe-action";
|
import { adminAction } from "@/lib/safe-action";
|
||||||
import { ActionError, actionOk } from "@/lib/safe-action-shared";
|
import { ActionError, actionOk } from "@/lib/safe-action-shared";
|
||||||
import { logAudit } from "@/lib/services/audit";
|
|
||||||
|
|
||||||
const ticketIdField = z
|
const ticketIdField = z
|
||||||
.union([z.string(), z.number(), z.bigint()])
|
.union([z.string(), z.number(), z.bigint()])
|
||||||
@@ -37,6 +34,21 @@ function revalidateHelpCenterTicketPaths(ticketId: bigint) {
|
|||||||
revalidatePath(`/help/tickets/${id}`);
|
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(
|
export const liftBanFromHelpTicket = adminAction(
|
||||||
{
|
{
|
||||||
permission: PERMS.USERS_BAN,
|
permission: PERMS.USERS_BAN,
|
||||||
@@ -44,50 +56,23 @@ export const liftBanFromHelpTicket = adminAction(
|
|||||||
},
|
},
|
||||||
async (ctx) => {
|
async (ctx) => {
|
||||||
const ticketId = ctx.data.ticketId;
|
const ticketId = ctx.data.ticketId;
|
||||||
const [ticket] = await db
|
const result = await execute(ctx.session.user, "help-ticket.unban", {
|
||||||
.select({
|
ticketId: ticketId.toString(),
|
||||||
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,
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
if (!result.ok) {
|
||||||
|
throw new ActionError(
|
||||||
|
result.error.code === "CONFLICT"
|
||||||
|
? "Ticket has no requester to unban"
|
||||||
|
: "Ticket not found",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
revalidateHelpCenterTicketPaths(ticketId);
|
revalidateHelpCenterTicketPaths(ticketId);
|
||||||
revalidatePath("/admin/bans");
|
revalidatePath("/admin/bans");
|
||||||
revalidatePath(`/admin/users/show/${ticket.userId}`);
|
const removed = Number(result.data.output?.removed ?? 0);
|
||||||
return actionOk({ removed, userId: ticket.userId });
|
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 },
|
{ permission: HELP_TICKET_EDIT, schema: replyHelpCenterTicketSchema },
|
||||||
async (ctx) => {
|
async (ctx) => {
|
||||||
const ticketId = ctx.data.ticketId;
|
const ticketId = ctx.data.ticketId;
|
||||||
const [ticket] = await db
|
const result = await execute(ctx.session.user, "help-ticket.reply", {
|
||||||
.select({
|
ticketId: ticketId.toString(),
|
||||||
id: WebsiteHelpCenterTickets.id,
|
content: ctx.data.content.trim(),
|
||||||
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),
|
|
||||||
});
|
});
|
||||||
|
if (!result.ok) throw new ActionError("Ticket not found");
|
||||||
|
|
||||||
revalidateHelpCenterTicketPaths(ticketId);
|
revalidateHelpCenterTicketPaths(ticketId);
|
||||||
return actionOk();
|
return actionOk();
|
||||||
@@ -141,32 +97,17 @@ export const closeHelpCenterTicket = adminAction(
|
|||||||
{ permission: HELP_TICKET_EDIT, schema: helpCenterTicketIdSchema },
|
{ permission: HELP_TICKET_EDIT, schema: helpCenterTicketIdSchema },
|
||||||
async (ctx) => {
|
async (ctx) => {
|
||||||
const ticketId = ctx.data.ticketId;
|
const ticketId = ctx.data.ticketId;
|
||||||
const [ticket] = await db
|
const result = await execute(ctx.session.user, "help-ticket.status", {
|
||||||
.select({
|
ticketId: ticketId.toString(),
|
||||||
id: WebsiteHelpCenterTickets.id,
|
status: "close",
|
||||||
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 },
|
|
||||||
});
|
});
|
||||||
|
if (!result.ok) {
|
||||||
|
throw new ActionError(
|
||||||
|
result.error.code === "CONFLICT"
|
||||||
|
? "Ticket is already closed"
|
||||||
|
: "Ticket not found",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
revalidateHelpCenterTicketPaths(ticketId);
|
revalidateHelpCenterTicketPaths(ticketId);
|
||||||
return actionOk();
|
return actionOk();
|
||||||
@@ -177,32 +118,17 @@ export const reopenHelpCenterTicket = adminAction(
|
|||||||
{ permission: HELP_TICKET_EDIT, schema: helpCenterTicketIdSchema },
|
{ permission: HELP_TICKET_EDIT, schema: helpCenterTicketIdSchema },
|
||||||
async (ctx) => {
|
async (ctx) => {
|
||||||
const ticketId = ctx.data.ticketId;
|
const ticketId = ctx.data.ticketId;
|
||||||
const [ticket] = await db
|
const result = await execute(ctx.session.user, "help-ticket.status", {
|
||||||
.select({
|
ticketId: ticketId.toString(),
|
||||||
id: WebsiteHelpCenterTickets.id,
|
status: "reopen",
|
||||||
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 },
|
|
||||||
});
|
});
|
||||||
|
if (!result.ok) {
|
||||||
|
throw new ActionError(
|
||||||
|
result.error.code === "CONFLICT"
|
||||||
|
? "Ticket is already open"
|
||||||
|
: "Ticket not found",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
revalidateHelpCenterTicketPaths(ticketId);
|
revalidateHelpCenterTicketPaths(ticketId);
|
||||||
return actionOk();
|
return actionOk();
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import { eq } from "drizzle-orm";
|
|||||||
import { revalidatePath } from "next/cache";
|
import { revalidatePath } from "next/cache";
|
||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { positiveBigInt } from "@/lib/api";
|
|
||||||
import { auth } from "@/lib/auth";
|
import { auth } from "@/lib/auth";
|
||||||
import {
|
import {
|
||||||
db,
|
db,
|
||||||
@@ -14,7 +13,10 @@ import {
|
|||||||
} from "@/lib/db";
|
} from "@/lib/db";
|
||||||
import { clientIp, rateLimit } from "@/lib/rate-limit";
|
import { clientIp, rateLimit } from "@/lib/rate-limit";
|
||||||
import { moderateOrThrow } from "@/lib/services/moderation";
|
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({
|
const ticketSchema = z.object({
|
||||||
title: z.string().min(1, "Title is required").max(255),
|
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> {
|
export async function createTicket(formData: FormData): Promise<void> {
|
||||||
let outcome: TicketOutcome = "error";
|
let outcome: TicketOutcome = "error";
|
||||||
|
|
||||||
@@ -177,7 +187,7 @@ const replyContentSchema = z.object({
|
|||||||
});
|
});
|
||||||
|
|
||||||
export async function replyHelpTicket(formData: FormData): Promise<void> {
|
export async function replyHelpTicket(formData: FormData): Promise<void> {
|
||||||
const ticketId = positiveBigInt(String(formData.get("ticketId") ?? ""));
|
const ticketId = helpTicketId(formData);
|
||||||
let outcome: TicketDetailOutcome = "error";
|
let outcome: TicketDetailOutcome = "error";
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -284,7 +294,7 @@ export async function replyHelpTicket(formData: FormData): Promise<void> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function closeHelpTicket(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";
|
let outcome: TicketDetailOutcome = "error";
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|||||||
+57
-111
@@ -1,13 +1,14 @@
|
|||||||
"use server";
|
"use server";
|
||||||
|
|
||||||
import { eq } from "drizzle-orm";
|
|
||||||
import { z } from "zod";
|
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 { actionOk, adminAction } from "@/lib/foundation/action";
|
||||||
import { NotFoundError } from "@/lib/foundation/errors";
|
import { NotFoundError } from "@/lib/foundation/errors";
|
||||||
import { PERMS } from "@/lib/permissions";
|
import { PERMS } from "@/lib/permissions";
|
||||||
import { logAudit } from "@/lib/services/audit";
|
|
||||||
import { rcon } from "@/lib/services/rcon";
|
|
||||||
|
|
||||||
// ── CFH Ticket Actions ──────────────────────────────────────────────
|
// ── 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 CFH_PERM = [PERMS.MODERATION_EDIT, PERMS.MOD_CFH_EDIT] as const;
|
||||||
const MOD_ACTION_PERM = [PERMS.MODERATION_EDIT, PERMS.MOD_ACTIONS] 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(
|
export const assignCfhTicket = adminAction(
|
||||||
{ permission: CFH_PERM, schema: cfhIdSchema },
|
{ permission: CFH_PERM, schema: cfhIdSchema },
|
||||||
async (ctx) => {
|
async (ctx) => {
|
||||||
const [ticket] = await db
|
const result = await execute(ctx.session.user, "cfh.resolve", {
|
||||||
.select({ id: SupportTickets.id })
|
ticketId: ctx.data.ticketId,
|
||||||
.from(SupportTickets)
|
state: 1,
|
||||||
.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,
|
|
||||||
});
|
});
|
||||||
|
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();
|
return actionOk();
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -50,30 +54,13 @@ const cfhStateSchema = z.object({
|
|||||||
export const updateCfhState = adminAction(
|
export const updateCfhState = adminAction(
|
||||||
{ permission: CFH_PERM, schema: cfhStateSchema },
|
{ permission: CFH_PERM, schema: cfhStateSchema },
|
||||||
async (ctx) => {
|
async (ctx) => {
|
||||||
const [ticket] = await db
|
const result = await execute(ctx.session.user, "cfh.resolve", ctx.data);
|
||||||
.select({
|
if (!result.ok) {
|
||||||
id: SupportTickets.id,
|
if (result.error.code === "NOT_FOUND") {
|
||||||
state: SupportTickets.state,
|
throw new NotFoundError("SupportTicket", ctx.data.ticketId);
|
||||||
})
|
}
|
||||||
.from(SupportTickets)
|
throw new Error("Could not update support ticket");
|
||||||
.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 },
|
|
||||||
});
|
|
||||||
|
|
||||||
return actionOk();
|
return actionOk();
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -81,18 +68,13 @@ export const updateCfhState = adminAction(
|
|||||||
export const closeCfhTicket = adminAction(
|
export const closeCfhTicket = adminAction(
|
||||||
{ permission: CFH_PERM, schema: cfhIdSchema },
|
{ permission: CFH_PERM, schema: cfhIdSchema },
|
||||||
async (ctx) => {
|
async (ctx) => {
|
||||||
await db
|
const result = await execute(ctx.session.user, "cfh.resolve", {
|
||||||
.update(SupportTickets)
|
ticketId: ctx.data.ticketId,
|
||||||
.set({ state: 2, modId: ctx.session.user.id })
|
state: 2,
|
||||||
.where(eq(SupportTickets.id, ctx.data.ticketId));
|
|
||||||
|
|
||||||
logAudit({
|
|
||||||
userId: ctx.session.user.id,
|
|
||||||
action: "cfh_close",
|
|
||||||
target: "support_tickets",
|
|
||||||
targetId: ctx.data.ticketId,
|
|
||||||
});
|
});
|
||||||
|
if (!result.ok && result.error.code !== "NOT_FOUND") {
|
||||||
|
throw new Error("Could not close support ticket");
|
||||||
|
}
|
||||||
return actionOk();
|
return actionOk();
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -104,15 +86,10 @@ const userIdSchema = z.object({ userId: z.coerce.number().int().positive() });
|
|||||||
export const quickKick = adminAction(
|
export const quickKick = adminAction(
|
||||||
{ permission: MOD_ACTION_PERM, schema: userIdSchema },
|
{ permission: MOD_ACTION_PERM, schema: userIdSchema },
|
||||||
async (ctx) => {
|
async (ctx) => {
|
||||||
await rcon.disconnectUser(ctx.data.userId);
|
await execute(ctx.session.user, "moderation.action", {
|
||||||
|
action: "kick",
|
||||||
logAudit({
|
userId: ctx.data.userId,
|
||||||
userId: ctx.session.user.id,
|
|
||||||
action: "mod_kick",
|
|
||||||
target: "User",
|
|
||||||
targetId: ctx.data.userId,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
return actionOk();
|
return actionOk();
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -125,16 +102,10 @@ const muteSchema = z.object({
|
|||||||
export const quickMute = adminAction(
|
export const quickMute = adminAction(
|
||||||
{ permission: MOD_ACTION_PERM, schema: muteSchema },
|
{ permission: MOD_ACTION_PERM, schema: muteSchema },
|
||||||
async (ctx) => {
|
async (ctx) => {
|
||||||
await rcon.muteUser(ctx.data.userId, ctx.data.duration);
|
await execute(ctx.session.user, "moderation.action", {
|
||||||
|
action: "mute",
|
||||||
logAudit({
|
...ctx.data,
|
||||||
userId: ctx.session.user.id,
|
|
||||||
action: "mod_mute",
|
|
||||||
target: "User",
|
|
||||||
targetId: ctx.data.userId,
|
|
||||||
after: { duration: ctx.data.duration },
|
|
||||||
});
|
});
|
||||||
|
|
||||||
return actionOk();
|
return actionOk();
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -142,15 +113,10 @@ export const quickMute = adminAction(
|
|||||||
export const quickUnmute = adminAction(
|
export const quickUnmute = adminAction(
|
||||||
{ permission: MOD_ACTION_PERM, schema: userIdSchema },
|
{ permission: MOD_ACTION_PERM, schema: userIdSchema },
|
||||||
async (ctx) => {
|
async (ctx) => {
|
||||||
await rcon.unmuteUser(ctx.data.userId);
|
await execute(ctx.session.user, "moderation.action", {
|
||||||
|
action: "unmute",
|
||||||
logAudit({
|
userId: ctx.data.userId,
|
||||||
userId: ctx.session.user.id,
|
|
||||||
action: "mod_unmute",
|
|
||||||
target: "User",
|
|
||||||
targetId: ctx.data.userId,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
return actionOk();
|
return actionOk();
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -163,16 +129,10 @@ const alertSchema = z.object({
|
|||||||
export const quickAlert = adminAction(
|
export const quickAlert = adminAction(
|
||||||
{ permission: MOD_ACTION_PERM, schema: alertSchema },
|
{ permission: MOD_ACTION_PERM, schema: alertSchema },
|
||||||
async (ctx) => {
|
async (ctx) => {
|
||||||
await rcon.alertUser(ctx.data.userId, ctx.data.message);
|
await execute(ctx.session.user, "moderation.action", {
|
||||||
|
action: "alert",
|
||||||
logAudit({
|
...ctx.data,
|
||||||
userId: ctx.session.user.id,
|
|
||||||
action: "mod_alert",
|
|
||||||
target: "User",
|
|
||||||
targetId: ctx.data.userId,
|
|
||||||
after: { message: ctx.data.message },
|
|
||||||
});
|
});
|
||||||
|
|
||||||
return actionOk();
|
return actionOk();
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -182,15 +142,10 @@ const roomIdSchema = z.object({ roomId: z.coerce.number().int().positive() });
|
|||||||
export const quickRoomKick = adminAction(
|
export const quickRoomKick = adminAction(
|
||||||
{ permission: MOD_ACTION_PERM, schema: roomIdSchema },
|
{ permission: MOD_ACTION_PERM, schema: roomIdSchema },
|
||||||
async (ctx) => {
|
async (ctx) => {
|
||||||
await rcon.kickAll(ctx.data.roomId);
|
await execute(ctx.session.user, "moderation.action", {
|
||||||
|
action: "room-kick",
|
||||||
logAudit({
|
roomId: ctx.data.roomId,
|
||||||
userId: ctx.session.user.id,
|
|
||||||
action: "mod_room_kick",
|
|
||||||
target: "Room",
|
|
||||||
targetId: ctx.data.roomId,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
return actionOk();
|
return actionOk();
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -203,19 +158,10 @@ const broadcastSchema = z.object({
|
|||||||
export const broadcastAlert = adminAction(
|
export const broadcastAlert = adminAction(
|
||||||
{ permission: MOD_ACTION_PERM, schema: broadcastSchema },
|
{ permission: MOD_ACTION_PERM, schema: broadcastSchema },
|
||||||
async (ctx) => {
|
async (ctx) => {
|
||||||
if (ctx.data.type === "hotel") {
|
await execute(ctx.session.user, "moderation.action", {
|
||||||
await rcon.hotelAlert(ctx.data.message);
|
action: "broadcast",
|
||||||
} else {
|
...ctx.data,
|
||||||
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 },
|
|
||||||
});
|
});
|
||||||
|
|
||||||
return actionOk();
|
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";
|
"use server";
|
||||||
|
|
||||||
import { eq } from "drizzle-orm";
|
|
||||||
import { z } from "zod";
|
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 { PERMS } from "@/lib/permissions";
|
||||||
import { adminAction } from "@/lib/safe-action";
|
import { adminAction } from "@/lib/safe-action";
|
||||||
import { ActionError, actionOk } from "@/lib/safe-action-shared";
|
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),
|
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(
|
export const createTemplate = adminAction(
|
||||||
{ permission: PERMS.TICKETS_EDIT, schema: templateSchema },
|
{ permission: PERMS.TICKETS_EDIT, schema: templateSchema },
|
||||||
async (ctx) => {
|
async (ctx) => {
|
||||||
const [result] = await db.insert(WebsiteTicketTemplate).values(ctx.data);
|
const snapshot = await execute(ctx.session.user, {
|
||||||
return actionOk({ id: Number(result.insertId) });
|
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 },
|
{ permission: PERMS.TICKETS_EDIT, schema: updateTemplateInput },
|
||||||
async (ctx) => {
|
async (ctx) => {
|
||||||
const { id, ...data } = ctx.data;
|
const { id, ...data } = ctx.data;
|
||||||
const [existing] = await db
|
await execute(ctx.session.user, { action: "update", id, ...data });
|
||||||
.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));
|
|
||||||
return actionOk({ id });
|
return actionOk({ id });
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -51,9 +67,7 @@ const deleteTemplateInput = z.object({
|
|||||||
export const deleteTemplate = adminAction(
|
export const deleteTemplate = adminAction(
|
||||||
{ permission: PERMS.TICKETS_EDIT, schema: deleteTemplateInput },
|
{ permission: PERMS.TICKETS_EDIT, schema: deleteTemplateInput },
|
||||||
async (ctx) => {
|
async (ctx) => {
|
||||||
await db
|
await execute(ctx.session.user, { action: "delete", id: ctx.data.id });
|
||||||
.delete(WebsiteTicketTemplate)
|
|
||||||
.where(eq(WebsiteTicketTemplate.id, ctx.data.id));
|
|
||||||
return actionOk();
|
return actionOk();
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
+30
-133
@@ -1,11 +1,13 @@
|
|||||||
"use server";
|
"use server";
|
||||||
|
|
||||||
import { eq } from "drizzle-orm";
|
import {
|
||||||
import { db, WebsiteTicket, WebsiteTicketMessage } 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 { PERMS } from "@/lib/permissions";
|
||||||
import { adminAction } from "@/lib/safe-action";
|
import { adminAction } from "@/lib/safe-action";
|
||||||
import { ActionError, actionOk } from "@/lib/safe-action-shared";
|
import { ActionError, actionOk } from "@/lib/safe-action-shared";
|
||||||
import { logAudit } from "@/lib/services/audit";
|
|
||||||
import {
|
import {
|
||||||
assignTicketSchema,
|
assignTicketSchema,
|
||||||
replyTicketSchema,
|
replyTicketSchema,
|
||||||
@@ -13,6 +15,27 @@ import {
|
|||||||
updateTicketStatusSchema,
|
updateTicketStatusSchema,
|
||||||
} from "@/lib/validators/ticket";
|
} 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) ──────────────
|
// ── User actions (authenticated, no admin perms needed) ──────────────
|
||||||
|
|
||||||
export const adminReplyTicket = adminAction(
|
export const adminReplyTicket = adminAction(
|
||||||
@@ -21,45 +44,7 @@ export const adminReplyTicket = adminAction(
|
|||||||
schema: replyTicketSchema,
|
schema: replyTicketSchema,
|
||||||
},
|
},
|
||||||
async (ctx) => {
|
async (ctx) => {
|
||||||
const [ticket] = await db
|
await execute(ctx.session.user, "ticket.reply", ctx.data);
|
||||||
.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,
|
|
||||||
});
|
|
||||||
|
|
||||||
return actionOk();
|
return actionOk();
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -70,43 +55,7 @@ export const updateTicketStatus = adminAction(
|
|||||||
schema: updateTicketStatusSchema,
|
schema: updateTicketStatusSchema,
|
||||||
},
|
},
|
||||||
async (ctx) => {
|
async (ctx) => {
|
||||||
const [ticket] = await db
|
await execute(ctx.session.user, "ticket.status", ctx.data);
|
||||||
.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 },
|
|
||||||
});
|
|
||||||
|
|
||||||
return actionOk();
|
return actionOk();
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -117,35 +66,7 @@ export const assignTicket = adminAction(
|
|||||||
schema: assignTicketSchema,
|
schema: assignTicketSchema,
|
||||||
},
|
},
|
||||||
async (ctx) => {
|
async (ctx) => {
|
||||||
const [ticket] = await db
|
await execute(ctx.session.user, "ticket.assign", ctx.data);
|
||||||
.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 },
|
|
||||||
});
|
|
||||||
|
|
||||||
return actionOk();
|
return actionOk();
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -156,31 +77,7 @@ export const updateTicketPriority = adminAction(
|
|||||||
schema: updateTicketPrioritySchema,
|
schema: updateTicketPrioritySchema,
|
||||||
},
|
},
|
||||||
async (ctx) => {
|
async (ctx) => {
|
||||||
const [ticket] = await db
|
await execute(ctx.session.user, "ticket.priority", ctx.data);
|
||||||
.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 },
|
|
||||||
});
|
|
||||||
|
|
||||||
return actionOk();
|
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,
|
anyCapability,
|
||||||
type HousekeepingDomainManifest,
|
type HousekeepingDomainManifest,
|
||||||
} from "../../foundation/contracts";
|
} 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 = {
|
export const peopleManifest = {
|
||||||
id: "people",
|
id: "people",
|
||||||
@@ -35,8 +38,8 @@ export const peopleManifest = {
|
|||||||
PERMS.MOD_CFH_EDIT,
|
PERMS.MOD_CFH_EDIT,
|
||||||
PERMS.MOD_TICKETS_EDIT,
|
PERMS.MOD_TICKETS_EDIT,
|
||||||
),
|
),
|
||||||
routes: PEOPLE_PRIMARY_ROUTES,
|
routes: PEOPLE_ROUTES,
|
||||||
searchProviders: [],
|
searchProviders: PEOPLE_SEARCH_PROVIDERS,
|
||||||
inboxSources: [],
|
inboxSources: PEOPLE_INBOX_SOURCES,
|
||||||
widgets: [],
|
widgets: PEOPLE_WIDGETS,
|
||||||
} satisfies HousekeepingDomainManifest;
|
} satisfies HousekeepingDomainManifest;
|
||||||
@@ -154,7 +154,7 @@ export interface PeopleSupportStaff {
|
|||||||
|
|
||||||
export interface PeopleTicketSummary {
|
export interface PeopleTicketSummary {
|
||||||
readonly source: "cms" | "help";
|
readonly source: "cms" | "help";
|
||||||
readonly id: number;
|
readonly id: number | string;
|
||||||
readonly subject: string;
|
readonly subject: string;
|
||||||
readonly status: string;
|
readonly status: string;
|
||||||
readonly priority: string;
|
readonly priority: string;
|
||||||
@@ -163,7 +163,7 @@ export interface PeopleTicketSummary {
|
|||||||
readonly updatedAt: string | null;
|
readonly updatedAt: string | null;
|
||||||
readonly href:
|
readonly href:
|
||||||
| `/ase/people/support/tickets/${number}`
|
| `/ase/people/support/tickets/${number}`
|
||||||
| `/ase/people/support/help-tickets/${number}`;
|
| `/ase/people/support/help-tickets/${string}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PeopleTicketDeskSummary extends PeopleTicketSummary {
|
export interface PeopleTicketDeskSummary extends PeopleTicketSummary {
|
||||||
@@ -189,22 +189,22 @@ export interface PeopleTicketDetail extends PeopleTicketSummary {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface PeopleHelpTicketSummary {
|
export interface PeopleHelpTicketSummary {
|
||||||
readonly id: number;
|
readonly id: string;
|
||||||
readonly title: string;
|
readonly title: string;
|
||||||
readonly open: boolean;
|
readonly open: boolean;
|
||||||
readonly userId: number | null;
|
readonly userId: number | null;
|
||||||
readonly username: string | null;
|
readonly username: string | null;
|
||||||
readonly updatedAt: string | null;
|
readonly updatedAt: string | null;
|
||||||
readonly replyCount: number;
|
readonly replyCount: number;
|
||||||
readonly href: `/ase/people/support/help-tickets/${number}`;
|
readonly href: `/ase/people/support/help-tickets/${string}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PeopleHelpTicketDetail extends PeopleHelpTicketSummary {
|
export interface PeopleHelpTicketDetail extends PeopleHelpTicketSummary {
|
||||||
readonly categoryId: number | null;
|
readonly categoryId: string | null;
|
||||||
readonly categoryName: string | null;
|
readonly categoryName: string | null;
|
||||||
readonly content: string;
|
readonly content: string;
|
||||||
readonly replies: readonly {
|
readonly replies: readonly {
|
||||||
readonly id: number;
|
readonly id: string;
|
||||||
readonly userId: number;
|
readonly userId: number;
|
||||||
readonly username: string | null;
|
readonly username: string | null;
|
||||||
readonly content: string;
|
readonly content: string;
|
||||||
@@ -416,10 +416,17 @@ export function peopleTicketHref(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function peopleHelpTicketHref(
|
export function peopleHelpTicketHref(
|
||||||
id: number,
|
id: string | number | bigint,
|
||||||
): `/ase/people/support/help-tickets/${number}` {
|
): `/ase/people/support/help-tickets/${string}` {
|
||||||
positiveSafeInteger(id);
|
const text = String(id);
|
||||||
return `/ase/people/support/help-tickets/${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(
|
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 {
|
import {
|
||||||
fail,
|
fail,
|
||||||
type HousekeepingCapabilityContext,
|
type HousekeepingCapabilityContext,
|
||||||
|
type HousekeepingDomainManifest,
|
||||||
type HousekeepingResult,
|
type HousekeepingResult,
|
||||||
ok,
|
ok,
|
||||||
} from "../../../foundation/contracts";
|
} from "../../../foundation/contracts";
|
||||||
@@ -24,6 +25,7 @@ import {
|
|||||||
PEOPLE_PRIMARY_ROUTE_HANDLERS,
|
PEOPLE_PRIMARY_ROUTE_HANDLERS,
|
||||||
PEOPLE_PRIMARY_ROUTE_IDS,
|
PEOPLE_PRIMARY_ROUTE_IDS,
|
||||||
} from "../route-handlers";
|
} from "../route-handlers";
|
||||||
|
import { PEOPLE_ROUTE_IDS } from "../routes";
|
||||||
import { PeopleCommunityPage } from "./community";
|
import { PeopleCommunityPage } from "./community";
|
||||||
import { PeopleMultiAccountsPage } from "./multi-accounts";
|
import { PeopleMultiAccountsPage } from "./multi-accounts";
|
||||||
import { parsePeopleListInput } from "./page-state";
|
import { parsePeopleListInput } from "./page-state";
|
||||||
@@ -263,7 +265,7 @@ describe.each(cases)("People $name page", ({ render, empty, ready }) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("People primary route registration", () => {
|
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 = [
|
const expected = [
|
||||||
"people.users.list",
|
"people.users.list",
|
||||||
"people.users.edit",
|
"people.users.edit",
|
||||||
@@ -276,7 +278,9 @@ describe("People primary route registration", () => {
|
|||||||
"people.staff.teams",
|
"people.staff.teams",
|
||||||
];
|
];
|
||||||
expect(PEOPLE_PRIMARY_ROUTE_IDS).toEqual(expected);
|
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(
|
expect(
|
||||||
PEOPLE_PRIMARY_ROUTE_HANDLERS.map((handler) => handler.routeId),
|
PEOPLE_PRIMARY_ROUTE_HANDLERS.map((handler) => handler.routeId),
|
||||||
).toEqual(expected);
|
).toEqual(expected);
|
||||||
@@ -284,9 +288,9 @@ describe("People primary route registration", () => {
|
|||||||
HOUSEKEEPING_ROUTE_HANDLERS.map((handler) => handler.routeId),
|
HOUSEKEEPING_ROUTE_HANDLERS.map((handler) => handler.routeId),
|
||||||
).toEqual(expect.arrayContaining(expected));
|
).toEqual(expect.arrayContaining(expected));
|
||||||
expect(
|
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));
|
).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>
|
</ul>
|
||||||
</div>
|
</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}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -131,7 +146,9 @@ export function PeopleStaffPage({ context, result }: PeopleStaffPageProps) {
|
|||||||
export async function renderPeopleStaffPage(input: HousekeepingPageInput) {
|
export async function renderPeopleStaffPage(input: HousekeepingPageInput) {
|
||||||
const routeId = input.match.routeId;
|
const routeId = input.match.routeId;
|
||||||
const result =
|
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, {
|
? await peopleStaffQuery.run(input.context, {
|
||||||
routeId,
|
routeId,
|
||||||
list: parsePeopleListInput(input.searchParams ?? {}),
|
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,
|
ok: true,
|
||||||
data: {
|
data: {
|
||||||
page: {
|
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({
|
expect(result).toMatchObject({
|
||||||
ok: true,
|
ok: true,
|
||||||
data: { page: { items: [{ id: 12, replyCount: 3 }] } },
|
data: { page: { items: [{ id: "12", replyCount: 3 }] } },
|
||||||
});
|
});
|
||||||
|
|
||||||
vi.resetModules();
|
vi.resetModules();
|
||||||
|
|||||||
@@ -611,14 +611,14 @@ describe("People support query", () => {
|
|||||||
loadTemplates: async () => ({ rows: [], total: 0 }),
|
loadTemplates: async () => ({ rows: [], total: 0 }),
|
||||||
loadHelpTickets: async () => ({ rows: [], total: 0 }),
|
loadHelpTickets: async () => ({ rows: [], total: 0 }),
|
||||||
loadHelpTicket: async () => ({
|
loadHelpTicket: async () => ({
|
||||||
id: 12,
|
id: "12",
|
||||||
title: "Help ticket",
|
title: "Help ticket",
|
||||||
open: true,
|
open: true,
|
||||||
userId: 7,
|
userId: 7,
|
||||||
username: "Seven",
|
username: "Seven",
|
||||||
updatedAt: "2026-08-20T00:00:00.000Z",
|
updatedAt: "2026-08-20T00:00:00.000Z",
|
||||||
href: "/ase/people/support/help-tickets/12",
|
href: "/ase/people/support/help-tickets/12",
|
||||||
categoryId: 2,
|
categoryId: "2",
|
||||||
categoryName: "Help",
|
categoryName: "Help",
|
||||||
content: "Body",
|
content: "Body",
|
||||||
replies: [],
|
replies: [],
|
||||||
@@ -637,7 +637,7 @@ describe("People support query", () => {
|
|||||||
});
|
});
|
||||||
const help = await query.run(context([PERMS.TICKETS_VIEW]), {
|
const help = await query.run(context([PERMS.TICKETS_VIEW]), {
|
||||||
routeId: "people.support.help-ticket-detail",
|
routeId: "people.support.help-ticket-detail",
|
||||||
id: 12,
|
id: "12",
|
||||||
});
|
});
|
||||||
expect(desk).toMatchObject({
|
expect(desk).toMatchObject({
|
||||||
ok: true,
|
ok: true,
|
||||||
@@ -816,7 +816,7 @@ describe("People support query", () => {
|
|||||||
).toMatchObject({ ok: true, data: { kind: "ticket-templates" } });
|
).toMatchObject({ ok: true, data: { kind: "ticket-templates" } });
|
||||||
for (const input of [
|
for (const input of [
|
||||||
{ routeId: "people.support.ticket-detail" as const, id: 91 },
|
{ 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(
|
expect(
|
||||||
await query.run(context([PERMS.TICKETS_VIEW]), input),
|
await query.run(context([PERMS.TICKETS_VIEW]), input),
|
||||||
|
|||||||
@@ -54,7 +54,7 @@ export interface PeopleSupportAdapters {
|
|||||||
loadHelpTickets(
|
loadHelpTickets(
|
||||||
input: ReturnType<typeof normalizePeopleListInput>,
|
input: ReturnType<typeof normalizePeopleListInput>,
|
||||||
): Promise<SupportRows<PeopleHelpTicketSummary>>;
|
): Promise<SupportRows<PeopleHelpTicketSummary>>;
|
||||||
loadHelpTicket(id: number): Promise<PeopleHelpTicketRecord | null>;
|
loadHelpTicket(id: string): Promise<PeopleHelpTicketRecord | null>;
|
||||||
loadSupportStaff(): Promise<readonly PeopleSupportStaff[]>;
|
loadSupportStaff(): Promise<readonly PeopleSupportStaff[]>;
|
||||||
loadActiveBan(userId: number): Promise<PeopleBanSummary | null>;
|
loadActiveBan(userId: number): Promise<PeopleBanSummary | null>;
|
||||||
}
|
}
|
||||||
@@ -74,7 +74,7 @@ export type PeopleSupportQueryInput =
|
|||||||
}
|
}
|
||||||
| {
|
| {
|
||||||
readonly routeId: "people.support.help-ticket-detail";
|
readonly routeId: "people.support.help-ticket-detail";
|
||||||
readonly id: number;
|
readonly id: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type PeopleSupportQueryData =
|
export type PeopleSupportQueryData =
|
||||||
@@ -145,16 +145,42 @@ export function createPeopleSupportQuery(
|
|||||||
return ok({ kind: "queue" as const, queue }, correlationId);
|
return ok({ kind: "queue" as const, queue }, correlationId);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (
|
if (input.routeId === "people.support.ticket-detail") {
|
||||||
input.routeId === "people.support.ticket-detail" ||
|
|
||||||
input.routeId === "people.support.help-ticket-detail"
|
|
||||||
) {
|
|
||||||
const invalid = invalidId(input.id, correlationId);
|
const invalid = invalidId(input.id, correlationId);
|
||||||
if (invalid !== null) return invalid;
|
if (invalid !== null) return invalid;
|
||||||
const ticket =
|
const ticket = await adapters.loadTicket(input.id);
|
||||||
input.routeId === "people.support.ticket-detail"
|
if (ticket === null) {
|
||||||
? await adapters.loadTicket(input.id)
|
return fail(
|
||||||
: await adapters.loadHelpTicket(input.id);
|
"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) {
|
if (ticket === null) {
|
||||||
return fail(
|
return fail(
|
||||||
"NOT_FOUND",
|
"NOT_FOUND",
|
||||||
@@ -162,37 +188,19 @@ export function createPeopleSupportQuery(
|
|||||||
correlationId,
|
correlationId,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
const helpTicket =
|
|
||||||
input.routeId === "people.support.help-ticket-detail"
|
|
||||||
? (ticket as PeopleHelpTicketRecord)
|
|
||||||
: null;
|
|
||||||
const [queue, staff, activeBan] = await Promise.all([
|
const [queue, staff, activeBan] = await Promise.all([
|
||||||
adapters.loadQueue(),
|
adapters.loadQueue(),
|
||||||
adapters.loadSupportStaff(),
|
adapters.loadSupportStaff(),
|
||||||
helpTicket?.userId !== null && helpTicket?.userId !== undefined
|
ticket.userId === null
|
||||||
? adapters.loadActiveBan(helpTicket.userId)
|
? Promise.resolve(null)
|
||||||
: Promise.resolve(null),
|
: adapters.loadActiveBan(ticket.userId),
|
||||||
]);
|
]);
|
||||||
const hydrated =
|
const hydrated = { ...ticket, queue, staff, activeBan };
|
||||||
input.routeId === "people.support.ticket-detail"
|
|
||||||
? { ...ticket, queue, staff }
|
|
||||||
: { ...ticket, queue, staff, activeBan };
|
|
||||||
assertPeopleSerializable(hydrated);
|
assertPeopleSerializable(hydrated);
|
||||||
return input.routeId === "people.support.ticket-detail"
|
return ok(
|
||||||
? ok(
|
{ kind: "help-ticket" as const, ticket: hydrated },
|
||||||
{
|
correlationId,
|
||||||
kind: "ticket" as const,
|
);
|
||||||
ticket: hydrated as PeopleTicketDetail,
|
|
||||||
},
|
|
||||||
correlationId,
|
|
||||||
)
|
|
||||||
: ok(
|
|
||||||
{
|
|
||||||
kind: "help-ticket" as const,
|
|
||||||
ticket: hydrated as PeopleHelpTicketDetail,
|
|
||||||
},
|
|
||||||
correlationId,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const allowedSorts =
|
const allowedSorts =
|
||||||
@@ -309,7 +317,8 @@ const peopleSupportAdapters: PeopleSupportAdapters = {
|
|||||||
order: input.order,
|
order: input.order,
|
||||||
});
|
});
|
||||||
const rows = result.rows.map((row) => {
|
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 {
|
return {
|
||||||
source: row.kind,
|
source: row.kind,
|
||||||
id,
|
id,
|
||||||
@@ -320,7 +329,9 @@ const peopleSupportAdapters: PeopleSupportAdapters = {
|
|||||||
creatorUsername: row.user === "—" ? null : row.user,
|
creatorUsername: row.user === "—" ? null : row.user,
|
||||||
updatedAt: row.sortAt === 0 ? null : toPeopleIsoDate(row.sortAt),
|
updatedAt: row.sortAt === 0 ? null : toPeopleIsoDate(row.sortAt),
|
||||||
href:
|
href:
|
||||||
row.kind === "cms" ? peopleTicketHref(id) : peopleHelpTicketHref(id),
|
row.kind === "cms"
|
||||||
|
? peopleTicketHref(id as number)
|
||||||
|
: peopleHelpTicketHref(id),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
return {
|
return {
|
||||||
@@ -527,14 +538,14 @@ const peopleSupportAdapters: PeopleSupportAdapters = {
|
|||||||
updatedAt: Date | string | null;
|
updatedAt: Date | string | null;
|
||||||
replyCount: number | bigint;
|
replyCount: number | bigint;
|
||||||
}>(rowsResult).map((row) => ({
|
}>(rowsResult).map((row) => ({
|
||||||
id: Number(row.id),
|
id: BigInt(String(row.id)).toString(),
|
||||||
title: row.title,
|
title: row.title,
|
||||||
open: Boolean(row.open),
|
open: Boolean(row.open),
|
||||||
userId: row.userId === null ? null : Number(row.userId),
|
userId: row.userId === null ? null : Number(row.userId),
|
||||||
username: row.username,
|
username: row.username,
|
||||||
updatedAt: toPeopleIsoDate(row.updatedAt),
|
updatedAt: toPeopleIsoDate(row.updatedAt),
|
||||||
replyCount: Number(row.replyCount),
|
replyCount: Number(row.replyCount),
|
||||||
href: peopleHelpTicketHref(Number(row.id)),
|
href: peopleHelpTicketHref(row.id),
|
||||||
}));
|
}));
|
||||||
return {
|
return {
|
||||||
rows,
|
rows,
|
||||||
@@ -554,14 +565,14 @@ const peopleSupportAdapters: PeopleSupportAdapters = {
|
|||||||
FROM website_help_center_tickets t
|
FROM website_help_center_tickets t
|
||||||
LEFT JOIN users u ON u.id = t.user_id
|
LEFT JOIN users u ON u.id = t.user_id
|
||||||
LEFT JOIN website_help_center_categories c ON c.id = t.category_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`
|
db.execute(sql`
|
||||||
SELECT r.id, r.user_id AS userId, u.username, r.content,
|
SELECT r.id, r.user_id AS userId, u.username, r.content,
|
||||||
r.created_at AS createdAt
|
r.created_at AS createdAt
|
||||||
FROM website_help_center_ticket_replies r
|
FROM website_help_center_ticket_replies r
|
||||||
LEFT JOIN users u ON u.id = r.user_id
|
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
|
ORDER BY r.created_at ASC, r.id ASC
|
||||||
LIMIT 500
|
LIMIT 500
|
||||||
`),
|
`),
|
||||||
@@ -579,17 +590,20 @@ const peopleSupportAdapters: PeopleSupportAdapters = {
|
|||||||
}>(ticketResult)[0];
|
}>(ticketResult)[0];
|
||||||
if (!row) return null;
|
if (!row) return null;
|
||||||
return {
|
return {
|
||||||
id: Number(row.id),
|
id: BigInt(String(row.id)).toString(),
|
||||||
title: row.title,
|
title: row.title,
|
||||||
content: row.content,
|
content: row.content,
|
||||||
open: Boolean(row.open),
|
open: Boolean(row.open),
|
||||||
userId: row.userId === null ? null : Number(row.userId),
|
userId: row.userId === null ? null : Number(row.userId),
|
||||||
username: row.username,
|
username: row.username,
|
||||||
categoryId: row.categoryId === null ? null : Number(row.categoryId),
|
categoryId:
|
||||||
|
row.categoryId === null
|
||||||
|
? null
|
||||||
|
: BigInt(String(row.categoryId)).toString(),
|
||||||
categoryName: row.categoryName,
|
categoryName: row.categoryName,
|
||||||
updatedAt: toPeopleIsoDate(row.updatedAt),
|
updatedAt: toPeopleIsoDate(row.updatedAt),
|
||||||
replyCount: resultRows<unknown>(repliesResult).length,
|
replyCount: resultRows<unknown>(repliesResult).length,
|
||||||
href: peopleHelpTicketHref(Number(row.id)),
|
href: peopleHelpTicketHref(row.id),
|
||||||
replies: resultRows<{
|
replies: resultRows<{
|
||||||
id: bigint | number;
|
id: bigint | number;
|
||||||
userId: number;
|
userId: number;
|
||||||
@@ -597,7 +611,7 @@ const peopleSupportAdapters: PeopleSupportAdapters = {
|
|||||||
content: string;
|
content: string;
|
||||||
createdAt: Date | string | null;
|
createdAt: Date | string | null;
|
||||||
}>(repliesResult).map((reply) => ({
|
}>(repliesResult).map((reply) => ({
|
||||||
id: Number(reply.id),
|
id: BigInt(String(reply.id)).toString(),
|
||||||
userId: Number(reply.userId),
|
userId: Number(reply.userId),
|
||||||
username: reply.username,
|
username: reply.username,
|
||||||
content: reply.content,
|
content: reply.content,
|
||||||
|
|||||||
@@ -1,10 +1,16 @@
|
|||||||
import type { HousekeepingRouteHandler } from "../../route-handlers";
|
import type { HousekeepingRouteHandler } from "../../route-handlers";
|
||||||
|
import { renderPeopleCfhDetailPage } from "./pages/cfh-detail";
|
||||||
import { renderPeopleCommunityPage } from "./pages/community";
|
import { renderPeopleCommunityPage } from "./pages/community";
|
||||||
|
import { renderPeopleHelpTicketDetailPage } from "./pages/help-ticket-detail";
|
||||||
|
import { renderPeopleModerationPage } from "./pages/moderation";
|
||||||
import { renderPeopleMultiAccountsPage } from "./pages/multi-accounts";
|
import { renderPeopleMultiAccountsPage } from "./pages/multi-accounts";
|
||||||
import { renderPeopleStaffPage } from "./pages/staff";
|
import { renderPeopleStaffPage } from "./pages/staff";
|
||||||
|
import { renderPeopleSupportPage } from "./pages/support";
|
||||||
|
import { renderPeopleTicketDetailPage } from "./pages/ticket-detail";
|
||||||
import { renderPeopleUserDetailPage } from "./pages/user-detail";
|
import { renderPeopleUserDetailPage } from "./pages/user-detail";
|
||||||
import { renderPeopleUserEditPage } from "./pages/user-edit";
|
import { renderPeopleUserEditPage } from "./pages/user-edit";
|
||||||
import { renderPeopleUsersPage } from "./pages/users";
|
import { renderPeopleUsersPage } from "./pages/users";
|
||||||
|
import { PEOPLE_ROUTE_IDS } from "./routes";
|
||||||
|
|
||||||
export const PEOPLE_PRIMARY_ROUTE_IDS = [
|
export const PEOPLE_PRIMARY_ROUTE_IDS = [
|
||||||
"people.users.list",
|
"people.users.list",
|
||||||
@@ -38,3 +44,34 @@ export const PEOPLE_PRIMARY_ROUTE_HANDLERS: readonly HousekeepingRouteHandler[]
|
|||||||
Object.freeze({ routeId, render: rendererFor(routeId) }),
|
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 { describe, expect, it } from "vitest";
|
||||||
import { PERMS } from "@/lib/permission-slugs";
|
import { PERMS } from "@/lib/permission-slugs";
|
||||||
|
import { HOUSEKEEPING_MANIFESTS } from "../../manifests";
|
||||||
import { peopleMigrationEntries } from "../../migration/people";
|
import { peopleMigrationEntries } from "../../migration/people";
|
||||||
|
import { HOUSEKEEPING_ROUTE_HANDLERS } from "../../route-handlers";
|
||||||
|
import { peopleManifest } from "./manifest";
|
||||||
import { PEOPLE_ROUTE_IDS, PEOPLE_ROUTES } from "./routes";
|
import { PEOPLE_ROUTE_IDS, PEOPLE_ROUTES } from "./routes";
|
||||||
|
|
||||||
const expectedRoutes = [
|
const expectedRoutes = [
|
||||||
@@ -166,4 +169,44 @@ describe("PEOPLE_ROUTES", () => {
|
|||||||
expect(routeTargets).toHaveLength(24);
|
expect(routeTargets).toHaveLength(24);
|
||||||
expect(new Set(routeTargets).size).toBe(routeTargets.length);
|
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,
|
Items,
|
||||||
Rooms,
|
Rooms,
|
||||||
Sanctions,
|
Sanctions,
|
||||||
|
SupportTickets,
|
||||||
User,
|
User,
|
||||||
UsersBadges,
|
UsersBadges,
|
||||||
UsersCurrency,
|
UsersCurrency,
|
||||||
UsersSettings,
|
UsersSettings,
|
||||||
|
WebsiteHelpCenterTicketReplies,
|
||||||
|
WebsiteHelpCenterTickets,
|
||||||
WebsiteIpBlacklist,
|
WebsiteIpBlacklist,
|
||||||
WebsiteIpWhitelist,
|
WebsiteIpWhitelist,
|
||||||
WebsiteSetting,
|
WebsiteSetting,
|
||||||
WebsiteStaffApplications,
|
WebsiteStaffApplications,
|
||||||
WebsiteTeams,
|
WebsiteTeams,
|
||||||
|
WebsiteTicket,
|
||||||
|
WebsiteTicketMessage,
|
||||||
|
WebsiteTicketTemplate,
|
||||||
WebsiteWordfilter,
|
WebsiteWordfilter,
|
||||||
} from "@/lib/db";
|
} from "@/lib/db";
|
||||||
import { PERMS } from "@/lib/permission-slugs";
|
import { PERMS } from "@/lib/permission-slugs";
|
||||||
import { logAudit } from "@/lib/services/audit";
|
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 { rcon } from "@/lib/services/rcon";
|
||||||
import { siteSettings } from "@/lib/services/site-settings";
|
import { siteSettings } from "@/lib/services/site-settings";
|
||||||
import { logStaffActivity } from "@/lib/services/staff-activity";
|
import { logStaffActivity } from "@/lib/services/staff-activity";
|
||||||
|
import { canonicalTicketId } from "@/lib/services/ticket-replies";
|
||||||
import { notify } from "@/lib/services/webhook";
|
import { notify } from "@/lib/services/webhook";
|
||||||
import { satisfiesCapability } from "../../../foundation/capability-context";
|
import { satisfiesCapability } from "../../../foundation/capability-context";
|
||||||
import {
|
import {
|
||||||
@@ -67,7 +78,20 @@ export type PeopleMutationOperation =
|
|||||||
| "team.change"
|
| "team.change"
|
||||||
| "ip.action"
|
| "ip.action"
|
||||||
| "vpn.configure"
|
| "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 {
|
export interface PeopleMutationSnapshot {
|
||||||
readonly before: Readonly<Record<string, unknown>> | null;
|
readonly before: Readonly<Record<string, unknown>> | null;
|
||||||
@@ -121,9 +145,31 @@ class PeopleMutationFailure extends Error {
|
|||||||
class ConfirmedExternalNoopFailure extends PeopleMutationFailure {}
|
class ConfirmedExternalNoopFailure extends PeopleMutationFailure {}
|
||||||
|
|
||||||
function operationCapability(operation: PeopleMutationOperation) {
|
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);
|
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") {
|
if (operation === "user.reset-password") {
|
||||||
return anyCapability(PERMS.USERS_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 = {
|
const productionPeopleMutationAdapter: PeopleMutationAdapter = {
|
||||||
async execute(operation, input, context) {
|
async execute(operation, input, context) {
|
||||||
if (operation.startsWith("user.")) {
|
if (operation.startsWith("user.")) {
|
||||||
@@ -1781,7 +2478,34 @@ const productionPeopleMutationAdapter: PeopleMutationAdapter = {
|
|||||||
if (operation === "vpn.configure") {
|
if (operation === "vpn.configure") {
|
||||||
return executeVpnConfiguration(input, context);
|
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() }));
|
vi.mock("@/lib/auth/password", () => ({ hashPassword: vi.fn() }));
|
||||||
|
|
||||||
import { COMMUNITY_COMMAND_IDS } from "../../domains/people/commands/community-commands";
|
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 { USER_COMMAND_IDS } from "../../domains/people/commands/user-commands";
|
||||||
import { SYSTEM_COMMAND_IDS } from "../../domains/system/commands/system-commands";
|
import { SYSTEM_COMMAND_IDS } from "../../domains/system/commands/system-commands";
|
||||||
import { anyCapability, ok } from "../contracts";
|
import { anyCapability, ok } from "../contracts";
|
||||||
@@ -72,6 +74,12 @@ describe("housekeeping command bootstrap", () => {
|
|||||||
expect(
|
expect(
|
||||||
COMMUNITY_COMMAND_IDS.map((id) => getHousekeepingCommand(id)?.id),
|
COMMUNITY_COMMAND_IDS.map((id) => getHousekeepingCommand(id)?.id),
|
||||||
).toEqual(COMMUNITY_COMMAND_IDS);
|
).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(() =>
|
expect(() =>
|
||||||
registerHousekeepingCommand({
|
registerHousekeepingCommand({
|
||||||
id: "system.bootstrap.too-late",
|
id: "system.bootstrap.too-late",
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import "server-only";
|
import "server-only";
|
||||||
|
|
||||||
import { COMMUNITY_COMMANDS } from "../../domains/people/commands/community-commands";
|
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 { USER_COMMANDS } from "../../domains/people/commands/user-commands";
|
||||||
import { SYSTEM_COMMANDS } from "../../domains/system/commands/system-commands";
|
import { SYSTEM_COMMANDS } from "../../domains/system/commands/system-commands";
|
||||||
import type { HousekeepingCommand } from "./registry";
|
import type { HousekeepingCommand } from "./registry";
|
||||||
@@ -40,6 +42,8 @@ export function registerHousekeepingCommands<
|
|||||||
const currentHousekeepingCommands = defineHousekeepingCommands(
|
const currentHousekeepingCommands = defineHousekeepingCommands(
|
||||||
...USER_COMMANDS,
|
...USER_COMMANDS,
|
||||||
...COMMUNITY_COMMANDS,
|
...COMMUNITY_COMMANDS,
|
||||||
|
...SUPPORT_COMMANDS,
|
||||||
|
...MODERATION_COMMANDS,
|
||||||
...SYSTEM_COMMANDS,
|
...SYSTEM_COMMANDS,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { join, posix } from "node:path";
|
|||||||
import { createElement, type ReactElement } from "react";
|
import { createElement, type ReactElement } from "react";
|
||||||
import { renderToStaticMarkup } from "react-dom/server";
|
import { renderToStaticMarkup } from "react-dom/server";
|
||||||
import { describe, expect, it } from "vitest";
|
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 { SYSTEM_ROUTE_IDS } from "../domains/system/routes";
|
||||||
import { HOUSEKEEPING_MANIFESTS } from "../manifests";
|
import { HOUSEKEEPING_MANIFESTS } from "../manifests";
|
||||||
import { discoverLegacyPages } from "../migration/discover-legacy-pages";
|
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",
|
"src/features/housekeeping/domains/people/commands/user-commands.ts",
|
||||||
new Set(["src/features/housekeeping/domains/people/services/mutations"]),
|
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",
|
"src/features/housekeeping/domains/people/pages/community.tsx",
|
||||||
new Set([
|
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/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",
|
"src/features/housekeeping/domains/people/pages/page-state.tsx",
|
||||||
new Set(["src/features/housekeeping/domains/people/models"]),
|
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",
|
"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",
|
"src/features/housekeeping/domains/people/route-handlers.ts",
|
||||||
new Set([
|
new Set([
|
||||||
"src/features/housekeeping/domains/people/pages/community",
|
"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/multi-accounts",
|
||||||
"src/features/housekeeping/domains/people/pages/staff",
|
"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-detail",
|
||||||
"src/features/housekeeping/domains/people/pages/user-edit",
|
"src/features/housekeeping/domains/people/pages/user-edit",
|
||||||
"src/features/housekeeping/domains/people/pages/users",
|
"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",
|
"src/features/housekeeping/foundation/commands/bootstrap.ts",
|
||||||
new Set([
|
new Set([
|
||||||
"src/features/housekeeping/domains/people/commands/community-commands",
|
"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/people/commands/user-commands",
|
||||||
"src/features/housekeeping/domains/system/commands/system-commands",
|
"src/features/housekeeping/domains/system/commands/system-commands",
|
||||||
]),
|
]),
|
||||||
@@ -838,7 +922,7 @@ describe("housekeeping foundation completion contracts", () => {
|
|||||||
registry.domains
|
registry.domains
|
||||||
.find((domain) => domain.id === "people")
|
.find((domain) => domain.id === "people")
|
||||||
?.routes.map((route) => route.id),
|
?.routes.map((route) => route.id),
|
||||||
).toEqual(PEOPLE_PRIMARY_ROUTE_IDS);
|
).toEqual(PEOPLE_ROUTE_IDS);
|
||||||
expect(
|
expect(
|
||||||
registry.domains
|
registry.domains
|
||||||
.find((domain) => domain.id === "system")
|
.find((domain) => domain.id === "system")
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import { PERMS } from "@/lib/permission-slugs";
|
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 { SYSTEM_ROUTES } from "../domains/system/routes";
|
||||||
import { HOUSEKEEPING_MANIFESTS } from "../manifests";
|
import { HOUSEKEEPING_MANIFESTS } from "../manifests";
|
||||||
import { HOUSEKEEPING_MIGRATION_MATRIX } from "../migration/matrix";
|
import { HOUSEKEEPING_MIGRATION_MATRIX } from "../migration/matrix";
|
||||||
@@ -357,14 +360,20 @@ describe("housekeeping registry", () => {
|
|||||||
});
|
});
|
||||||
expect(actual.routes).toEqual(
|
expect(actual.routes).toEqual(
|
||||||
expected.id === "people"
|
expected.id === "people"
|
||||||
? PEOPLE_PRIMARY_ROUTES
|
? PEOPLE_ROUTES
|
||||||
: expected.id === "system"
|
: expected.id === "system"
|
||||||
? SYSTEM_ROUTES
|
? SYSTEM_ROUTES
|
||||||
: [],
|
: [],
|
||||||
);
|
);
|
||||||
expect(actual.searchProviders).toEqual([]);
|
expect(actual.searchProviders).toEqual(
|
||||||
expect(actual.inboxSources).toEqual([]);
|
expected.id === "people" ? PEOPLE_SEARCH_PROVIDERS : [],
|
||||||
expect(actual.widgets).toEqual([]);
|
);
|
||||||
|
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 });
|
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(() =>
|
expect(() =>
|
||||||
createHousekeepingRegistry([
|
createHousekeepingRegistry([
|
||||||
{ ...manifest("people"), searchProviders: [searchProvider("shared")] },
|
{ ...manifest("people"), searchProviders: [searchProvider("shared")] },
|
||||||
@@ -425,7 +434,7 @@ describe("housekeeping registry", () => {
|
|||||||
widgets: [widget("shared", "content")],
|
widgets: [widget("shared", "content")],
|
||||||
},
|
},
|
||||||
]),
|
]),
|
||||||
).toThrow("duplicate widget id: shared");
|
).not.toThrow();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("rejects provider and widget ownership outside their manifest", () => {
|
it("rejects provider and widget ownership outside their manifest", () => {
|
||||||
|
|||||||
@@ -24,7 +24,9 @@ export function createHousekeepingRegistry(
|
|||||||
const routeIds = new Set<string>();
|
const routeIds = new Set<string>();
|
||||||
const routeHrefs = new Set<string>();
|
const routeHrefs = new Set<string>();
|
||||||
const routeShapes = 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) {
|
for (const manifest of manifests) {
|
||||||
if (!approvedDomainIds.has(manifest.id)) {
|
if (!approvedDomainIds.has(manifest.id)) {
|
||||||
@@ -48,19 +50,19 @@ export function createHousekeepingRegistry(
|
|||||||
manifest.searchProviders,
|
manifest.searchProviders,
|
||||||
manifest.id,
|
manifest.id,
|
||||||
"search provider",
|
"search provider",
|
||||||
registryEntryIds,
|
searchProviderIds,
|
||||||
);
|
);
|
||||||
validateOwnedRegistryEntries(
|
validateOwnedRegistryEntries(
|
||||||
manifest.inboxSources,
|
manifest.inboxSources,
|
||||||
manifest.id,
|
manifest.id,
|
||||||
"inbox source",
|
"inbox source",
|
||||||
registryEntryIds,
|
inboxSourceIds,
|
||||||
);
|
);
|
||||||
validateOwnedRegistryEntries(
|
validateOwnedRegistryEntries(
|
||||||
manifest.widgets,
|
manifest.widgets,
|
||||||
manifest.id,
|
manifest.id,
|
||||||
"widget",
|
"widget",
|
||||||
registryEntryIds,
|
widgetIds,
|
||||||
);
|
);
|
||||||
for (const widget of manifest.widgets) {
|
for (const widget of manifest.widgets) {
|
||||||
if (widget.kind !== "mandatory" && widget.kind !== "optional") {
|
if (widget.kind !== "mandatory" && widget.kind !== "optional") {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
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 { SYSTEM_ROUTE_IDS } from "./domains/system/routes";
|
||||||
import { createHousekeepingRegistry } from "./foundation/registry";
|
import { createHousekeepingRegistry } from "./foundation/registry";
|
||||||
import { HOUSEKEEPING_MANIFESTS } from "./manifests";
|
import { HOUSEKEEPING_MANIFESTS } from "./manifests";
|
||||||
@@ -18,7 +18,7 @@ describe("housekeeping route handlers", () => {
|
|||||||
expect(new Set(handlerIds).size).toBe(handlerIds.length);
|
expect(new Set(handlerIds).size).toBe(handlerIds.length);
|
||||||
expect([...handlerIds].sort()).toEqual([...routeIds].sort());
|
expect([...handlerIds].sort()).toEqual([...routeIds].sort());
|
||||||
expect(handlerIds).toEqual([
|
expect(handlerIds).toEqual([
|
||||||
...PEOPLE_PRIMARY_ROUTE_IDS,
|
...PEOPLE_ROUTE_IDS,
|
||||||
...SYSTEM_ROUTE_IDS,
|
...SYSTEM_ROUTE_IDS,
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import type { ReactNode } from "react";
|
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 { SYSTEM_ROUTE_HANDLERS } from "./domains/system/route-handlers";
|
||||||
import type { HousekeepingCapabilityContext } from "./foundation/contracts";
|
import type { HousekeepingCapabilityContext } from "./foundation/contracts";
|
||||||
import type { HousekeepingRouteMatch } from "./foundation/routing/match-route";
|
import type { HousekeepingRouteMatch } from "./foundation/routing/match-route";
|
||||||
@@ -18,4 +18,4 @@ export interface HousekeepingRouteHandler {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const HOUSEKEEPING_ROUTE_HANDLERS: readonly 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;
|
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";
|
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.
|
// Bound the AI call so a slow/hung endpoint can't stall a server action.
|
||||||
const OPENAI_TIMEOUT_MS = 5_000;
|
const OPENAI_TIMEOUT_MS = 5_000;
|
||||||
|
|||||||
@@ -19,6 +19,28 @@ export interface TicketReplyDb {
|
|||||||
touchTicket(ticketId: bigint, updatedAt: Date): Promise<unknown>;
|
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(
|
export async function createOwnedTicketReply(
|
||||||
db: TicketReplyDb,
|
db: TicketReplyDb,
|
||||||
input: { ticketId: bigint; userId: number; content: string },
|
input: { ticketId: bigint; userId: number; content: string },
|
||||||
|
|||||||
Reference in new issue
Block a user