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