Files
EpicNext-Cms/src/actions/tickets.ts
T
openhands 6a67fb6e83
CI / check (push) Successful in 26s
CI / release (push) Skipped
CI / deploy (push) Successful in 59s
refactor: remove additional dead exports and unused actions
Remove buildFontUrl, measureText, uncached, invalidateCache, fetchJsonWithFlareSolver, upsertPermission, deletePermission, bulkImportPermissions, clearAllPermissions, bulkDeletePermissions, bulkDeletePhotos, userReplyTicket, closeTicketByUser. Update staff-smoke-contract test for bulkDeletePhotos removal.
2026-08-07 19:19:05 +02:00

212 lines
5.0 KiB
TypeScript

"use server";
import { eq } from "drizzle-orm";
import { db, WebsiteTicket, WebsiteTicketMessage } from "@/lib/db";
import { PERMS } from "@/lib/permissions";
import { adminAction, authAction } from "@/lib/safe-action";
import { ActionError, actionOk } from "@/lib/safe-action-shared";
import { logAudit } from "@/lib/services/audit";
import { notify } from "@/lib/services/webhook";
import {
assignTicketSchema,
createTicketSchema,
replyTicketSchema,
updateTicketPrioritySchema,
updateTicketStatusSchema,
} from "@/lib/validators/ticket";
// ── User actions (authenticated, no admin perms needed) ──────────────
export const createTicket = authAction(
{
schema: createTicketSchema,
rateLimitKey: "ticket-create",
rateLimitMax: 5,
rateLimitWindowMs: 60_000,
},
async (ctx) => {
const now = new Date();
const [result] = await db.insert(WebsiteTicket).values({
subject: ctx.data.subject,
category: ctx.data.category,
creatorId: ctx.session.user.id,
updatedAt: now,
});
const ticketId = Number(result.insertId);
// Create the first message
await db.insert(WebsiteTicketMessage).values({
ticketId,
userId: ctx.session.user.id,
message: ctx.data.message,
isStaff: 0,
});
notify({
action: "ticket_create",
actor: ctx.session.user.username,
target: `#${ticketId} - ${ctx.data.subject}`,
details: `Category: ${ctx.data.category}`,
});
return actionOk({ id: ticketId });
},
);
export const adminReplyTicket = adminAction(
{
permission: [PERMS.TICKETS_EDIT, PERMS.MOD_TICKETS_EDIT],
schema: replyTicketSchema,
},
async (ctx) => {
const [ticket] = await db
.select()
.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();
},
);
export const updateTicketStatus = adminAction(
{
permission: [PERMS.TICKETS_EDIT, PERMS.MOD_TICKETS_EDIT],
schema: updateTicketStatusSchema,
},
async (ctx) => {
const [ticket] = await db
.select()
.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();
},
);
export const assignTicket = adminAction(
{
permission: [PERMS.TICKETS_EDIT, PERMS.MOD_TICKETS_EDIT],
schema: assignTicketSchema,
},
async (ctx) => {
const [ticket] = await db
.select()
.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();
},
);
export const updateTicketPriority = adminAction(
{
permission: [PERMS.TICKETS_EDIT, PERMS.MOD_TICKETS_EDIT],
schema: updateTicketPrioritySchema,
},
async (ctx) => {
const [ticket] = await db
.select()
.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();
},
);