feat(housekeeping): complete people moderation parity

This commit is contained in:
Simo committed 2026-08-29 22:38:50 +02:00
1 parent 3d385d1869
commit 29fe22297b
41 files changed
+3465 -580

No files matched your search

+34 -49
View File
@@ -1,84 +1,69 @@
// @ts-nocheck
import { revalidatePath } from "next/cache";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { requirePermissionRateLimited } from "@/lib/admin/guard";
import { rcon } from "@/lib/services/rcon";
import { createBan, liftBan } from "./admin-bans";
const { selectLimit, insertValues, deleteWhere } = vi.hoisted(() => {
const selectLimit = vi.fn();
const insertValues = vi.fn().mockResolvedValue([{ insertId: 1 }]);
const deleteWhere = vi.fn().mockResolvedValue([{ affectedRows: 1 }]);
return { selectLimit, insertValues, deleteWhere };
});
const { execute } = vi.hoisted(() => ({ execute: vi.fn() }));
vi.mock("@/features/housekeeping/domains/people/services/mutations", () => ({
createPeopleMutationInvocation: vi.fn((staff, correlationId) => ({
expectedActorId: staff.id,
correlationId,
legacy: true,
})),
peopleMutationService: { execute },
}));
vi.mock("@/lib/admin/guard", () => ({ requirePermissionRateLimited: vi.fn() }));
vi.mock("@/lib/permissions", () => ({ PERMS: { USERS_BAN: "users.ban" } }));
vi.mock("@/lib/db", () => ({
db: {
select: vi.fn(() => ({
from: vi.fn(() => ({
where: vi.fn(() => ({
limit: selectLimit,
})),
})),
})),
insert: vi.fn(() => ({ values: insertValues })),
delete: vi.fn(() => ({ where: deleteWhere })),
},
Ban: { id: "id", userId: "userId" },
User: { id: "id", username: "username" },
}));
vi.mock("@/lib/services/rcon", () => ({ rcon: { disconnectUser: vi.fn() } }));
vi.mock("@/lib/services/staff-activity", () => ({ logStaffActivity: vi.fn() }));
vi.mock("next/cache", () => ({ revalidatePath: vi.fn() }));
import { createBan, liftBan } from "./admin-bans";
const staff = { id: 1, rank: 7, username: "admin" };
const fakeForm = (data: Record<string, string>) => ({
get: (key: string) => data[key] ?? null,
});
const fakeForm = (data: Record<string, string>) =>
({ get: (key: string) => data[key] ?? null }) as unknown as FormData;
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(requirePermissionRateLimited).mockResolvedValue(staff as never);
selectLimit.mockResolvedValue([{ username: "baduser" }]);
insertValues.mockResolvedValue([{ insertId: 1 }]);
deleteWhere.mockResolvedValue([{ affectedRows: 1 }]);
execute.mockImplementation(async (invocation) => ({
ok: true,
data: { before: null, after: {} },
correlationId: invocation.correlationId,
}));
});
describe("createBan", () => {
it("creates a ban for valid inputs", async () => {
describe("legacy admin ban wrappers", () => {
it("preserves parsed create input, service delegation, and revalidation", async () => {
await createBan(
fakeForm({
userId: "42",
reason: "Spam",
hours: "24",
type: "account",
}) as unknown as FormData,
}),
);
expect(insertValues).toHaveBeenCalledWith(
expect.objectContaining({ userId: 42, type: "account" }),
expect(execute).toHaveBeenCalledWith(
expect.objectContaining({ expectedActorId: 1, legacy: true }),
"ban.create",
{ userId: 42, reason: "Spam", hours: 24, type: "account" },
);
expect(rcon.disconnectUser).toHaveBeenCalledWith(42, "baduser");
expect(revalidatePath).toHaveBeenCalledWith("/admin/bans");
});
it("returns early when userId is invalid", async () => {
await createBan(
fakeForm({
userId: "0",
hours: "1",
type: "account",
}) as unknown as FormData,
fakeForm({ userId: "0", hours: "1", type: "account" }),
);
expect(insertValues).not.toHaveBeenCalled();
expect(execute).not.toHaveBeenCalled();
});
});
describe("liftBan", () => {
it("deletes ban and revalidates", async () => {
await liftBan(fakeForm({ id: "42" }) as unknown as FormData);
expect(deleteWhere).toHaveBeenCalled();
it("delegates lift by exact ban id and preserves revalidation", async () => {
await liftBan(fakeForm({ id: "42" }));
expect(execute).toHaveBeenCalledWith(
expect.objectContaining({ expectedActorId: 1, legacy: true }),
"ban.lift",
{ id: 42 },
);
expect(revalidatePath).toHaveBeenCalledWith("/admin/bans");
});
});
+22 -41
View File
@@ -1,12 +1,13 @@
"use server";
import { eq } from "drizzle-orm";
import { revalidatePath } from "next/cache";
import {
createPeopleMutationInvocation,
peopleMutationService,
} from "@/features/housekeeping/domains/people/services/mutations";
import { createCorrelationId } from "@/features/housekeeping/foundation/contracts";
import { requirePermissionRateLimited } from "@/lib/admin/guard";
import { Ban, db, User } from "@/lib/db";
import { PERMS } from "@/lib/permissions";
import { rcon } from "@/lib/services/rcon";
import { logStaffActivity } from "@/lib/services/staff-activity";
const BAN_TYPES: ReadonlySet<string> = new Set([
"account",
@@ -25,37 +26,17 @@ export async function createBan(formData: FormData): Promise<void> {
const hours = Number(formData.get("hours"));
const type = String(formData.get("type"));
if (!(userId > 0) || !BAN_TYPES.has(type)) return;
const now = Math.floor(Date.now() / 1000);
// Emulator convention: banExpire 0 = permanent (not a far-future timestamp).
const banExpire = hours > 0 ? now + Math.floor(hours) * 3600 : 0;
const [user] = await db
.select({ username: User.username })
.from(User)
.where(eq(User.id, userId))
.limit(1);
await db.insert(Ban).values({
userId,
ip: "",
machineId: "",
userStaffId: staff.id,
timestamp: now,
banExpire,
banReason: reason,
type: type as "account" | "ip" | "machine" | "super",
cfhTopic: -1,
});
if (user) await rcon.disconnectUser(userId, user.username);
await logStaffActivity({
staffId: staff.id,
action: "user_ban",
description: `Banned user #${userId} (${type}, ${hours > 0 ? `${hours}h` : "permanent"}): ${reason}`,
targetType: "user",
targetId: userId,
});
const result = await peopleMutationService.execute(
createPeopleMutationInvocation(staff, createCorrelationId()),
"ban.create",
{
userId,
reason,
hours: Number.isFinite(hours) && hours > 0 ? Math.floor(hours) : 0,
type,
},
);
if (!result.ok) throw new Error("Could not create ban");
revalidatePath("/admin/bans");
}
@@ -63,12 +44,12 @@ export async function liftBan(formData: FormData): Promise<void> {
const staff = await requirePermissionRateLimited(PERMS.USERS_BAN);
const id = Number(formData.get("id"));
if (id > 0) {
await db.delete(Ban).where(eq(Ban.id, id));
await logStaffActivity({
staffId: staff.id,
action: "ban_lift",
description: `Lifted ban #${id}`,
});
const result = await peopleMutationService.execute(
createPeopleMutationInvocation(staff, createCorrelationId()),
"ban.lift",
{ id },
);
if (!result.ok) throw new Error("Could not lift ban");
}
revalidatePath("/admin/bans");
}
+56 -130
View File
@@ -1,18 +1,15 @@
"use server";
import { eq } from "drizzle-orm";
import { revalidatePath } from "next/cache";
import { z } from "zod";
import {
Ban,
db,
WebsiteHelpCenterTicketReplies,
WebsiteHelpCenterTickets,
} from "@/lib/db";
createPeopleMutationInvocation,
peopleMutationService,
} from "@/features/housekeeping/domains/people/services/mutations";
import { createCorrelationId } from "@/features/housekeeping/foundation/contracts";
import { PERMS } from "@/lib/permissions";
import { adminAction } from "@/lib/safe-action";
import { ActionError, actionOk } from "@/lib/safe-action-shared";
import { logAudit } from "@/lib/services/audit";
const ticketIdField = z
.union([z.string(), z.number(), z.bigint()])
@@ -37,6 +34,21 @@ function revalidateHelpCenterTicketPaths(ticketId: bigint) {
revalidatePath(`/help/tickets/${id}`);
}
async function execute(
staff: { readonly id: number },
operation:
| "help-ticket.reply"
| "help-ticket.status"
| "help-ticket.unban",
input: unknown,
) {
return peopleMutationService.execute(
createPeopleMutationInvocation(staff, createCorrelationId()),
operation,
input,
);
}
export const liftBanFromHelpTicket = adminAction(
{
permission: PERMS.USERS_BAN,
@@ -44,50 +56,23 @@ export const liftBanFromHelpTicket = adminAction(
},
async (ctx) => {
const ticketId = ctx.data.ticketId;
const [ticket] = await db
.select({
id: WebsiteHelpCenterTickets.id,
userId: WebsiteHelpCenterTickets.userId,
open: WebsiteHelpCenterTickets.open,
title: WebsiteHelpCenterTickets.title,
})
.from(WebsiteHelpCenterTickets)
.where(eq(WebsiteHelpCenterTickets.id, ticketId))
.limit(1);
if (!ticket) throw new ActionError("Ticket not found");
if (ticket.userId == null) {
throw new ActionError("Ticket has no requester to unban");
}
const result = await db.delete(Ban).where(eq(Ban.userId, ticket.userId));
const removed = Number(
(result as unknown as [{ affectedRows: number }])[0]?.affectedRows ?? 0,
);
const now = new Date();
if (ticket.open) {
await db
.update(WebsiteHelpCenterTickets)
.set({ open: false, updatedAt: now })
.where(eq(WebsiteHelpCenterTickets.id, ticketId));
}
logAudit({
userId: ctx.session.user.id,
action: "unban_via_help_ticket",
target: "User",
targetId: ticket.userId,
after: {
ticketId: String(ticketId),
removedBans: removed,
title: ticket.title,
},
const result = await execute(ctx.session.user, "help-ticket.unban", {
ticketId: ticketId.toString(),
});
if (!result.ok) {
throw new ActionError(
result.error.code === "CONFLICT"
? "Ticket has no requester to unban"
: "Ticket not found",
);
}
revalidateHelpCenterTicketPaths(ticketId);
revalidatePath("/admin/bans");
revalidatePath(`/admin/users/show/${ticket.userId}`);
return actionOk({ removed, userId: ticket.userId });
const removed = Number(result.data.output?.removed ?? 0);
const userId = Number(result.data.output?.userId);
revalidatePath(`/admin/users/show/${userId}`);
return actionOk({ removed, userId });
},
);
@@ -97,40 +82,11 @@ export const replyHelpCenterTicket = adminAction(
{ permission: HELP_TICKET_EDIT, schema: replyHelpCenterTicketSchema },
async (ctx) => {
const ticketId = ctx.data.ticketId;
const [ticket] = await db
.select({
id: WebsiteHelpCenterTickets.id,
open: WebsiteHelpCenterTickets.open,
})
.from(WebsiteHelpCenterTickets)
.where(eq(WebsiteHelpCenterTickets.id, ticketId))
.limit(1);
if (!ticket) throw new ActionError("Ticket not found");
const now = new Date();
const staffId = Number(ctx.session.user.id);
await db.transaction(async (tx) => {
await tx.insert(WebsiteHelpCenterTicketReplies).values({
ticketId,
userId: staffId,
content: ctx.data.content.trim(),
createdAt: now,
updatedAt: now,
});
await tx
.update(WebsiteHelpCenterTickets)
.set({ updatedAt: now })
.where(eq(WebsiteHelpCenterTickets.id, ticketId));
});
logAudit({
userId: staffId,
action: "help_center_ticket_reply",
target: "WebsiteHelpCenterTickets",
targetId: Number(ticketId),
const result = await execute(ctx.session.user, "help-ticket.reply", {
ticketId: ticketId.toString(),
content: ctx.data.content.trim(),
});
if (!result.ok) throw new ActionError("Ticket not found");
revalidateHelpCenterTicketPaths(ticketId);
return actionOk();
@@ -141,32 +97,17 @@ export const closeHelpCenterTicket = adminAction(
{ permission: HELP_TICKET_EDIT, schema: helpCenterTicketIdSchema },
async (ctx) => {
const ticketId = ctx.data.ticketId;
const [ticket] = await db
.select({
id: WebsiteHelpCenterTickets.id,
open: WebsiteHelpCenterTickets.open,
})
.from(WebsiteHelpCenterTickets)
.where(eq(WebsiteHelpCenterTickets.id, ticketId))
.limit(1);
if (!ticket) throw new ActionError("Ticket not found");
if (!ticket.open) throw new ActionError("Ticket is already closed");
const now = new Date();
await db
.update(WebsiteHelpCenterTickets)
.set({ open: false, updatedAt: now })
.where(eq(WebsiteHelpCenterTickets.id, ticketId));
logAudit({
userId: Number(ctx.session.user.id),
action: "help_center_ticket_close",
target: "WebsiteHelpCenterTickets",
targetId: Number(ticketId),
before: { open: true },
after: { open: false },
const result = await execute(ctx.session.user, "help-ticket.status", {
ticketId: ticketId.toString(),
status: "close",
});
if (!result.ok) {
throw new ActionError(
result.error.code === "CONFLICT"
? "Ticket is already closed"
: "Ticket not found",
);
}
revalidateHelpCenterTicketPaths(ticketId);
return actionOk();
@@ -177,32 +118,17 @@ export const reopenHelpCenterTicket = adminAction(
{ permission: HELP_TICKET_EDIT, schema: helpCenterTicketIdSchema },
async (ctx) => {
const ticketId = ctx.data.ticketId;
const [ticket] = await db
.select({
id: WebsiteHelpCenterTickets.id,
open: WebsiteHelpCenterTickets.open,
})
.from(WebsiteHelpCenterTickets)
.where(eq(WebsiteHelpCenterTickets.id, ticketId))
.limit(1);
if (!ticket) throw new ActionError("Ticket not found");
if (ticket.open) throw new ActionError("Ticket is already open");
const now = new Date();
await db
.update(WebsiteHelpCenterTickets)
.set({ open: true, updatedAt: now })
.where(eq(WebsiteHelpCenterTickets.id, ticketId));
logAudit({
userId: Number(ctx.session.user.id),
action: "help_center_ticket_reopen",
target: "WebsiteHelpCenterTickets",
targetId: Number(ticketId),
before: { open: false },
after: { open: true },
const result = await execute(ctx.session.user, "help-ticket.status", {
ticketId: ticketId.toString(),
status: "reopen",
});
if (!result.ok) {
throw new ActionError(
result.error.code === "CONFLICT"
? "Ticket is already open"
: "Ticket not found",
);
}
revalidateHelpCenterTicketPaths(ticketId);
return actionOk();
+14 -4
View File
@@ -4,7 +4,6 @@ import { eq } from "drizzle-orm";
import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation";
import { z } from "zod";
import { positiveBigInt } from "@/lib/api";
import { auth } from "@/lib/auth";
import {
db,
@@ -14,7 +13,10 @@ import {
} from "@/lib/db";
import { clientIp, rateLimit } from "@/lib/rate-limit";
import { moderateOrThrow } from "@/lib/services/moderation";
import { createOwnedTicketReply } from "@/lib/services/ticket-replies";
import {
canonicalTicketId,
createOwnedTicketReply,
} from "@/lib/services/ticket-replies";
const ticketSchema = z.object({
title: z.string().min(1, "Title is required").max(255),
@@ -64,6 +66,14 @@ function isNextRedirect(e: unknown): boolean {
);
}
function helpTicketId(formData: FormData): bigint | null {
try {
return canonicalTicketId(String(formData.get("ticketId") ?? ""));
} catch {
return null;
}
}
export async function createTicket(formData: FormData): Promise<void> {
let outcome: TicketOutcome = "error";
@@ -177,7 +187,7 @@ const replyContentSchema = z.object({
});
export async function replyHelpTicket(formData: FormData): Promise<void> {
const ticketId = positiveBigInt(String(formData.get("ticketId") ?? ""));
const ticketId = helpTicketId(formData);
let outcome: TicketDetailOutcome = "error";
try {
@@ -284,7 +294,7 @@ export async function replyHelpTicket(formData: FormData): Promise<void> {
}
export async function closeHelpTicket(formData: FormData): Promise<void> {
const ticketId = positiveBigInt(String(formData.get("ticketId") ?? ""));
const ticketId = helpTicketId(formData);
let outcome: TicketDetailOutcome = "error";
try {
+57 -111
View File
@@ -1,13 +1,14 @@
"use server";
import { eq } from "drizzle-orm";
import { z } from "zod";
import { db, SupportTickets } from "@/lib/db";
import {
createPeopleMutationInvocation,
peopleMutationService,
} from "@/features/housekeeping/domains/people/services/mutations";
import { createCorrelationId } from "@/features/housekeeping/foundation/contracts";
import { actionOk, adminAction } from "@/lib/foundation/action";
import { NotFoundError } from "@/lib/foundation/errors";
import { PERMS } from "@/lib/permissions";
import { logAudit } from "@/lib/services/audit";
import { rcon } from "@/lib/services/rcon";
// ── CFH Ticket Actions ──────────────────────────────────────────────
@@ -16,28 +17,31 @@ const cfhIdSchema = z.object({ ticketId: z.coerce.number().int().positive() });
const CFH_PERM = [PERMS.MODERATION_EDIT, PERMS.MOD_CFH_EDIT] as const;
const MOD_ACTION_PERM = [PERMS.MODERATION_EDIT, PERMS.MOD_ACTIONS] as const;
async function execute(
staff: { readonly id: number },
operation: "cfh.resolve" | "moderation.action",
input: unknown,
) {
return peopleMutationService.execute(
createPeopleMutationInvocation(staff, createCorrelationId()),
operation,
input,
);
}
export const assignCfhTicket = adminAction(
{ permission: CFH_PERM, schema: cfhIdSchema },
async (ctx) => {
const [ticket] = await db
.select({ id: SupportTickets.id })
.from(SupportTickets)
.where(eq(SupportTickets.id, ctx.data.ticketId))
.limit(1);
if (!ticket) throw new NotFoundError("SupportTicket", ctx.data.ticketId);
await db
.update(SupportTickets)
.set({ modId: ctx.session.user.id, state: 1 })
.where(eq(SupportTickets.id, ctx.data.ticketId));
logAudit({
userId: ctx.session.user.id,
action: "cfh_assign",
target: "support_tickets",
targetId: ctx.data.ticketId,
const result = await execute(ctx.session.user, "cfh.resolve", {
ticketId: ctx.data.ticketId,
state: 1,
});
if (!result.ok) {
if (result.error.code === "NOT_FOUND") {
throw new NotFoundError("SupportTicket", ctx.data.ticketId);
}
throw new Error("Could not assign support ticket");
}
return actionOk();
},
);
@@ -50,30 +54,13 @@ const cfhStateSchema = z.object({
export const updateCfhState = adminAction(
{ permission: CFH_PERM, schema: cfhStateSchema },
async (ctx) => {
const [ticket] = await db
.select({
id: SupportTickets.id,
state: SupportTickets.state,
})
.from(SupportTickets)
.where(eq(SupportTickets.id, ctx.data.ticketId))
.limit(1);
if (!ticket) throw new NotFoundError("SupportTicket", ctx.data.ticketId);
await db
.update(SupportTickets)
.set({ state: ctx.data.state, modId: ctx.session.user.id })
.where(eq(SupportTickets.id, ctx.data.ticketId));
logAudit({
userId: ctx.session.user.id,
action: "cfh_state_change",
target: "support_tickets",
targetId: ctx.data.ticketId,
before: { state: ticket.state },
after: { state: ctx.data.state },
});
const result = await execute(ctx.session.user, "cfh.resolve", ctx.data);
if (!result.ok) {
if (result.error.code === "NOT_FOUND") {
throw new NotFoundError("SupportTicket", ctx.data.ticketId);
}
throw new Error("Could not update support ticket");
}
return actionOk();
},
);
@@ -81,18 +68,13 @@ export const updateCfhState = adminAction(
export const closeCfhTicket = adminAction(
{ permission: CFH_PERM, schema: cfhIdSchema },
async (ctx) => {
await db
.update(SupportTickets)
.set({ state: 2, modId: ctx.session.user.id })
.where(eq(SupportTickets.id, ctx.data.ticketId));
logAudit({
userId: ctx.session.user.id,
action: "cfh_close",
target: "support_tickets",
targetId: ctx.data.ticketId,
const result = await execute(ctx.session.user, "cfh.resolve", {
ticketId: ctx.data.ticketId,
state: 2,
});
if (!result.ok && result.error.code !== "NOT_FOUND") {
throw new Error("Could not close support ticket");
}
return actionOk();
},
);
@@ -104,15 +86,10 @@ const userIdSchema = z.object({ userId: z.coerce.number().int().positive() });
export const quickKick = adminAction(
{ permission: MOD_ACTION_PERM, schema: userIdSchema },
async (ctx) => {
await rcon.disconnectUser(ctx.data.userId);
logAudit({
userId: ctx.session.user.id,
action: "mod_kick",
target: "User",
targetId: ctx.data.userId,
await execute(ctx.session.user, "moderation.action", {
action: "kick",
userId: ctx.data.userId,
});
return actionOk();
},
);
@@ -125,16 +102,10 @@ const muteSchema = z.object({
export const quickMute = adminAction(
{ permission: MOD_ACTION_PERM, schema: muteSchema },
async (ctx) => {
await rcon.muteUser(ctx.data.userId, ctx.data.duration);
logAudit({
userId: ctx.session.user.id,
action: "mod_mute",
target: "User",
targetId: ctx.data.userId,
after: { duration: ctx.data.duration },
await execute(ctx.session.user, "moderation.action", {
action: "mute",
...ctx.data,
});
return actionOk();
},
);
@@ -142,15 +113,10 @@ export const quickMute = adminAction(
export const quickUnmute = adminAction(
{ permission: MOD_ACTION_PERM, schema: userIdSchema },
async (ctx) => {
await rcon.unmuteUser(ctx.data.userId);
logAudit({
userId: ctx.session.user.id,
action: "mod_unmute",
target: "User",
targetId: ctx.data.userId,
await execute(ctx.session.user, "moderation.action", {
action: "unmute",
userId: ctx.data.userId,
});
return actionOk();
},
);
@@ -163,16 +129,10 @@ const alertSchema = z.object({
export const quickAlert = adminAction(
{ permission: MOD_ACTION_PERM, schema: alertSchema },
async (ctx) => {
await rcon.alertUser(ctx.data.userId, ctx.data.message);
logAudit({
userId: ctx.session.user.id,
action: "mod_alert",
target: "User",
targetId: ctx.data.userId,
after: { message: ctx.data.message },
await execute(ctx.session.user, "moderation.action", {
action: "alert",
...ctx.data,
});
return actionOk();
},
);
@@ -182,15 +142,10 @@ const roomIdSchema = z.object({ roomId: z.coerce.number().int().positive() });
export const quickRoomKick = adminAction(
{ permission: MOD_ACTION_PERM, schema: roomIdSchema },
async (ctx) => {
await rcon.kickAll(ctx.data.roomId);
logAudit({
userId: ctx.session.user.id,
action: "mod_room_kick",
target: "Room",
targetId: ctx.data.roomId,
await execute(ctx.session.user, "moderation.action", {
action: "room-kick",
roomId: ctx.data.roomId,
});
return actionOk();
},
);
@@ -203,19 +158,10 @@ const broadcastSchema = z.object({
export const broadcastAlert = adminAction(
{ permission: MOD_ACTION_PERM, schema: broadcastSchema },
async (ctx) => {
if (ctx.data.type === "hotel") {
await rcon.hotelAlert(ctx.data.message);
} else {
await rcon.staffAlert(ctx.data.message);
}
logAudit({
userId: ctx.session.user.id,
action: `mod_broadcast_${ctx.data.type}`,
target: "broadcast",
after: { message: ctx.data.message },
await execute(ctx.session.user, "moderation.action", {
action: "broadcast",
...ctx.data,
});
return actionOk();
},
);
@@ -0,0 +1,216 @@
import { revalidatePath } from "next/cache";
import { beforeEach, describe, expect, it, vi } from "vitest";
const { execute, registrations, staff } = vi.hoisted(() => ({
execute: vi.fn(),
registrations: [] as Array<{ permission: string | readonly string[] }>,
staff: { id: 42, rank: 4, username: "moderator" },
}));
function wrapper(
options: { permission: string | readonly string[] },
handler: (context: { data: unknown; session: { user: typeof staff } }) => unknown,
) {
registrations.push(options);
return (data: unknown) => handler({ data, session: { user: staff } });
}
vi.mock("@/features/housekeeping/domains/people/services/mutations", () => ({
createPeopleMutationInvocation: vi.fn((actor, correlationId) => ({
expectedActorId: actor.id,
correlationId,
legacy: true,
})),
peopleMutationService: { execute },
}));
vi.mock("@/lib/safe-action", () => ({ adminAction: wrapper }));
vi.mock("@/lib/safe-action-shared", () => ({
ActionError: class ActionError extends Error {},
actionOk: (data?: unknown) => ({ ok: true, data: data ?? {} }),
}));
vi.mock("@/lib/foundation/action", () => ({
adminAction: wrapper,
actionOk: (data?: unknown) => ({ ok: true, data: data ?? {} }),
}));
vi.mock("@/lib/permissions", () => ({
PERMS: {
TICKETS_EDIT: "admin.tickets.edit",
MOD_TICKETS_EDIT: "mod.tickets.edit",
USERS_BAN: "admin.users.ban",
MODERATION_EDIT: "admin.moderation.edit",
MOD_CFH_EDIT: "mod.cfh.edit",
MOD_ACTIONS: "mod.actions",
},
}));
vi.mock("next/cache", () => ({ revalidatePath: vi.fn() }));
import {
closeHelpCenterTicket,
liftBanFromHelpTicket,
reopenHelpCenterTicket,
replyHelpCenterTicket,
} from "./admin-help-tickets";
import {
assignCfhTicket,
broadcastAlert,
closeCfhTicket,
quickAlert,
quickKick,
quickMute,
quickRoomKick,
quickUnmute,
updateCfhState,
} from "./moderation";
import {
createTemplate,
deleteTemplate,
updateTemplate,
} from "./ticket-templates";
import {
adminReplyTicket,
assignTicket,
updateTicketPriority,
updateTicketStatus,
} from "./tickets";
type LegacyAction = (input: unknown) => Promise<unknown>;
const call = (action: unknown, input: unknown) =>
(action as LegacyAction)(input);
beforeEach(() => {
vi.clearAllMocks();
execute.mockImplementation(async (invocation, operation) => ({
ok: true,
data: {
before: null,
after: operation === "ticket-template.change" ? { id: "88" } : {},
output:
operation === "help-ticket.unban"
? { removed: 2, userId: 7 }
: undefined,
},
correlationId: invocation.correlationId,
}));
});
describe("legacy People support and moderation wrappers", () => {
it("keeps mid-rank ACL alternatives without an admin.dashboard dependency", () => {
const permissions = registrations.flatMap((entry) =>
typeof entry.permission === "string"
? [entry.permission]
: entry.permission,
);
expect(permissions).toEqual(
expect.arrayContaining([
"admin.tickets.edit",
"mod.tickets.edit",
"admin.moderation.edit",
"mod.cfh.edit",
"mod.actions",
]),
);
expect(permissions).not.toContain("admin.dashboard");
});
it("delegates tickets and templates with their established result shapes", async () => {
await expect(
call(adminReplyTicket, { ticketId: 7, message: "Handled" }),
).resolves.toEqual({ ok: true, data: {} });
await call(assignTicket, { ticketId: 7, assigneeId: 42 });
await call(updateTicketStatus, { ticketId: 7, status: "closed" });
await call(updateTicketPriority, { ticketId: 7, priority: "urgent" });
await expect(
call(createTemplate, {
title: "Greeting",
content: "Hello",
category: "general",
sortOrder: 0,
}),
).resolves.toEqual({ ok: true, data: { id: 88 } });
await expect(
call(updateTemplate, { id: 88, title: "Updated" }),
).resolves.toEqual({ ok: true, data: { id: 88 } });
await expect(call(deleteTemplate, { id: 88 })).resolves.toEqual({
ok: true,
data: {},
});
expect(execute.mock.calls.map((entry) => entry[1])).toEqual([
"ticket.reply",
"ticket.assign",
"ticket.status",
"ticket.priority",
"ticket-template.change",
"ticket-template.change",
"ticket-template.change",
]);
});
it("preserves BIGINT help-ticket IDs, outputs, and every legacy refresh", async () => {
const ticketId = 9_007_199_254_740_993n;
await call(replyHelpCenterTicket, { ticketId, content: " Handled " });
await call(closeHelpCenterTicket, { ticketId });
await call(reopenHelpCenterTicket, { ticketId });
await expect(
call(liftBanFromHelpTicket, { ticketId }),
).resolves.toEqual({ ok: true, data: { removed: 2, userId: 7 } });
expect(execute.mock.calls.map((entry) => entry[2])).toEqual([
{ ticketId: "9007199254740993", content: "Handled" },
{ ticketId: "9007199254740993", status: "close" },
{ ticketId: "9007199254740993", status: "reopen" },
{ ticketId: "9007199254740993" },
]);
expect(revalidatePath).toHaveBeenCalledWith("/admin/help-tickets");
expect(revalidatePath).toHaveBeenCalledWith(
"/admin/help-tickets/9007199254740993",
);
expect(revalidatePath).toHaveBeenCalledWith(
"/mod/help-tickets/9007199254740993",
);
expect(revalidatePath).toHaveBeenCalledWith(
"/help/tickets/9007199254740993",
);
expect(revalidatePath).toHaveBeenCalledWith("/admin/bans");
expect(revalidatePath).toHaveBeenCalledWith("/admin/users/show/7");
});
it("delegates every CFH and moderation transport action", async () => {
await call(assignCfhTicket, { ticketId: 9 });
await call(updateCfhState, { ticketId: 9, state: 3 });
await call(closeCfhTicket, { ticketId: 9 });
await call(quickKick, { userId: 7 });
await call(quickMute, { userId: 7, duration: 60 });
await call(quickUnmute, { userId: 7 });
await call(quickAlert, { userId: 7, message: "Stop" });
await call(quickRoomKick, { roomId: 12 });
await call(broadcastAlert, { message: "Notice", type: "staff" });
expect(execute.mock.calls.map((entry) => entry[1])).toEqual([
"cfh.resolve",
"cfh.resolve",
"cfh.resolve",
"moderation.action",
"moderation.action",
"moderation.action",
"moderation.action",
"moderation.action",
"moderation.action",
]);
expect(execute.mock.calls.map((entry) => entry[0].expectedActorId)).toEqual(
Array(9).fill(42),
);
});
it("keeps close-CFH missing rows as a successful legacy no-op", async () => {
execute.mockResolvedValueOnce({
ok: false,
error: { code: "NOT_FOUND", messageKey: "errors.housekeeping.notFound" },
correlationId: "missing-cfh",
});
await expect(call(closeCfhTicket, { ticketId: 404 })).resolves.toEqual({
ok: true,
data: {},
});
});
});
+31 -17
View File
@@ -1,8 +1,11 @@
"use server";
import { eq } from "drizzle-orm";
import { z } from "zod";
import { db, WebsiteTicketTemplate } from "@/lib/db";
import {
createPeopleMutationInvocation,
peopleMutationService,
} from "@/features/housekeeping/domains/people/services/mutations";
import { createCorrelationId } from "@/features/housekeeping/foundation/contracts";
import { PERMS } from "@/lib/permissions";
import { adminAction } from "@/lib/safe-action";
import { ActionError, actionOk } from "@/lib/safe-action-shared";
@@ -14,11 +17,33 @@ const templateSchema = z.object({
sortOrder: z.coerce.number().int().min(0).default(0),
});
async function execute(
staff: { readonly id: number },
input: unknown,
) {
const result = await peopleMutationService.execute(
createPeopleMutationInvocation(staff, createCorrelationId()),
"ticket-template.change",
input,
);
if (!result.ok) {
throw new ActionError(
result.error.code === "NOT_FOUND"
? "Template not found"
: "Template update failed",
);
}
return result.data;
}
export const createTemplate = adminAction(
{ permission: PERMS.TICKETS_EDIT, schema: templateSchema },
async (ctx) => {
const [result] = await db.insert(WebsiteTicketTemplate).values(ctx.data);
return actionOk({ id: Number(result.insertId) });
const snapshot = await execute(ctx.session.user, {
action: "create",
...ctx.data,
});
return actionOk({ id: Number(snapshot.after?.id) });
},
);
@@ -30,16 +55,7 @@ export const updateTemplate = adminAction(
{ permission: PERMS.TICKETS_EDIT, schema: updateTemplateInput },
async (ctx) => {
const { id, ...data } = ctx.data;
const [existing] = await db
.select({ id: WebsiteTicketTemplate.id })
.from(WebsiteTicketTemplate)
.where(eq(WebsiteTicketTemplate.id, id))
.limit(1);
if (!existing) throw new ActionError("Template not found");
await db
.update(WebsiteTicketTemplate)
.set(data)
.where(eq(WebsiteTicketTemplate.id, id));
await execute(ctx.session.user, { action: "update", id, ...data });
return actionOk({ id });
},
);
@@ -51,9 +67,7 @@ const deleteTemplateInput = z.object({
export const deleteTemplate = adminAction(
{ permission: PERMS.TICKETS_EDIT, schema: deleteTemplateInput },
async (ctx) => {
await db
.delete(WebsiteTicketTemplate)
.where(eq(WebsiteTicketTemplate.id, ctx.data.id));
await execute(ctx.session.user, { action: "delete", id: ctx.data.id });
return actionOk();
},
);
+30 -133
View File
@@ -1,11 +1,13 @@
"use server";
import { eq } from "drizzle-orm";
import { db, WebsiteTicket, WebsiteTicketMessage } from "@/lib/db";
import {
createPeopleMutationInvocation,
peopleMutationService,
} from "@/features/housekeeping/domains/people/services/mutations";
import { createCorrelationId } from "@/features/housekeeping/foundation/contracts";
import { PERMS } from "@/lib/permissions";
import { adminAction } from "@/lib/safe-action";
import { ActionError, actionOk } from "@/lib/safe-action-shared";
import { logAudit } from "@/lib/services/audit";
import {
assignTicketSchema,
replyTicketSchema,
@@ -13,6 +15,27 @@ import {
updateTicketStatusSchema,
} from "@/lib/validators/ticket";
async function execute(
staff: { readonly id: number },
operation:
| "ticket.reply"
| "ticket.assign"
| "ticket.status"
| "ticket.priority",
input: unknown,
) {
const result = await peopleMutationService.execute(
createPeopleMutationInvocation(staff, createCorrelationId()),
operation,
input,
);
if (!result.ok) {
throw new ActionError(
result.error.code === "NOT_FOUND" ? "Ticket not found" : "Ticket update failed",
);
}
}
// ── User actions (authenticated, no admin perms needed) ──────────────
export const adminReplyTicket = adminAction(
@@ -21,45 +44,7 @@ export const adminReplyTicket = adminAction(
schema: replyTicketSchema,
},
async (ctx) => {
const [ticket] = await db
.select({
id: WebsiteTicket.id,
assigneeId: WebsiteTicket.assigneeId,
})
.from(WebsiteTicket)
.where(eq(WebsiteTicket.id, ctx.data.ticketId))
.limit(1);
if (!ticket) throw new ActionError("Ticket not found");
await db.insert(WebsiteTicketMessage).values({
ticketId: ctx.data.ticketId,
userId: ctx.session.user.id,
message: ctx.data.message,
isStaff: 1,
});
// Auto-assign if not assigned yet
const updates: Partial<typeof WebsiteTicket.$inferInsert> = {
status: "waiting",
updatedAt: new Date(),
};
if (!ticket.assigneeId) {
updates.assigneeId = ctx.session.user.id;
}
await db
.update(WebsiteTicket)
.set(updates)
.where(eq(WebsiteTicket.id, ctx.data.ticketId));
logAudit({
userId: ctx.session.user.id,
action: "ticket_reply",
target: "WebsiteTicket",
targetId: ctx.data.ticketId,
});
await execute(ctx.session.user, "ticket.reply", ctx.data);
return actionOk();
},
);
@@ -70,43 +55,7 @@ export const updateTicketStatus = adminAction(
schema: updateTicketStatusSchema,
},
async (ctx) => {
const [ticket] = await db
.select({
id: WebsiteTicket.id,
assigneeId: WebsiteTicket.assigneeId,
status: WebsiteTicket.status,
})
.from(WebsiteTicket)
.where(eq(WebsiteTicket.id, ctx.data.ticketId))
.limit(1);
if (!ticket) throw new ActionError("Ticket not found");
const data: Partial<typeof WebsiteTicket.$inferInsert> = {
status: ctx.data.status,
updatedAt: new Date(),
};
if (ctx.data.status === "closed") {
data.closedAt = new Date();
}
if (ctx.data.status === "in_progress" && !ticket.assigneeId) {
data.assigneeId = ctx.session.user.id;
}
await db
.update(WebsiteTicket)
.set(data)
.where(eq(WebsiteTicket.id, ctx.data.ticketId));
logAudit({
userId: ctx.session.user.id,
action: "ticket_status_change",
target: "WebsiteTicket",
targetId: ctx.data.ticketId,
before: { status: ticket.status },
after: { status: ctx.data.status },
});
await execute(ctx.session.user, "ticket.status", ctx.data);
return actionOk();
},
);
@@ -117,35 +66,7 @@ export const assignTicket = adminAction(
schema: assignTicketSchema,
},
async (ctx) => {
const [ticket] = await db
.select({
id: WebsiteTicket.id,
assigneeId: WebsiteTicket.assigneeId,
})
.from(WebsiteTicket)
.where(eq(WebsiteTicket.id, ctx.data.ticketId))
.limit(1);
if (!ticket) throw new ActionError("Ticket not found");
await db
.update(WebsiteTicket)
.set({
assigneeId: ctx.data.assigneeId,
status: ctx.data.assigneeId ? "in_progress" : "open",
updatedAt: new Date(),
})
.where(eq(WebsiteTicket.id, ctx.data.ticketId));
logAudit({
userId: ctx.session.user.id,
action: "ticket_assign",
target: "WebsiteTicket",
targetId: ctx.data.ticketId,
before: { assigneeId: ticket.assigneeId },
after: { assigneeId: ctx.data.assigneeId },
});
await execute(ctx.session.user, "ticket.assign", ctx.data);
return actionOk();
},
);
@@ -156,31 +77,7 @@ export const updateTicketPriority = adminAction(
schema: updateTicketPrioritySchema,
},
async (ctx) => {
const [ticket] = await db
.select({
id: WebsiteTicket.id,
priority: WebsiteTicket.priority,
})
.from(WebsiteTicket)
.where(eq(WebsiteTicket.id, ctx.data.ticketId))
.limit(1);
if (!ticket) throw new ActionError("Ticket not found");
await db
.update(WebsiteTicket)
.set({ priority: ctx.data.priority, updatedAt: new Date() })
.where(eq(WebsiteTicket.id, ctx.data.ticketId));
logAudit({
userId: ctx.session.user.id,
action: "ticket_priority_change",
target: "WebsiteTicket",
targetId: ctx.data.ticketId,
before: { priority: ticket.priority },
after: { priority: ctx.data.priority },
});
await execute(ctx.session.user, "ticket.priority", ctx.data);
return actionOk();
},
);