diff --git a/src/app/(frontend)/helpdesk/actions.ts b/src/app/(frontend)/helpdesk/actions.ts new file mode 100644 index 0000000..ac9dec4 --- /dev/null +++ b/src/app/(frontend)/helpdesk/actions.ts @@ -0,0 +1,350 @@ +"use server"; + +import config from "@payload-config"; +import { getPayload } from "payload"; +import type { Ticket } from "@/payload-types"; +import hasRoles from "@/utils/access-control/hasRoles"; +import { emitGameEvent } from "@/utils/event-log/emit"; +import { EventTypes } from "@/utils/event-log/eventTypes"; +import { notifyUser } from "@/lib/notifications"; +import { plaintextToLexical } from "@/lib/tickets/lexical"; +import { getStaffUserIds } from "@/lib/tickets/staff"; +import { + CLOSING_STATUSES, + STATUS_LABEL, + TICKET_STATUSES, +} from "@/lib/tickets/ticketMeta"; +import type { TicketCategory, TicketPriority, TicketStatus } from "@/lib/tickets/ticketMeta"; + +export interface ActionResult { + success: boolean; + error?: string; + data?: T; +} + +async function authenticate() { + const payloadConfig = await config; + const payload = await getPayload({ config: payloadConfig }); + const { headers } = await import("next/headers"); + const hdrs = await headers(); + const { user } = await payload.auth({ headers: hdrs, canSetHeaders: false }); + + if (!user) { + throw new Error("Unauthorized"); + } + + return { payload, user }; +} + +function displayNameOf(user: { payloadDisplayName?: string | null; username?: string | null }): string { + return user.payloadDisplayName || user.username || "A member"; +} + +function reporterIdOf(ticket: Ticket): number | null { + return typeof ticket.reporter === "object" && ticket.reporter + ? (ticket.reporter as { id: number }).id + : (ticket.reporter as number | null); +} + +function assigneeIdOf(ticket: Ticket): number | null { + return typeof ticket.assignee === "object" && ticket.assignee + ? (ticket.assignee as { id: number }).id + : (ticket.assignee as number | null); +} + +function lexicalFor(text: string): NonNullable { + return plaintextToLexical(text) as unknown as NonNullable; +} + +async function getTicketOrThrow( + payload: Awaited>, + ticketId: number, +): Promise { + const ticket = (await payload + .findByID({ collection: "tickets", id: ticketId, depth: 0, overrideAccess: true }) + .catch(() => null)) as unknown as Ticket | null; + if (!ticket) throw new Error("Ticket not found."); + return ticket; +} + +export async function createTicket(input: { + title: string; + description: string; + category?: TicketCategory; + priority?: TicketPriority; +}): Promise> { + try { + const { payload, user } = await authenticate(); + const userId = user.id as number; + + const title = input.title.trim(); + if (!title) throw new Error("Title is required."); + if (title.length > 200) throw new Error("Title must be 200 characters or fewer."); + + const description = input.description.trim(); + if (!description) throw new Error("Description is required."); + + const category = input.category ?? "support_request"; + const priority = input.priority ?? "medium"; + + const createdTicket = (await payload.create({ + collection: "tickets", + data: { + title, + description: lexicalFor(description), + category, + priority, + status: "open", + reporter: userId, + activity: [ + { + type: "system", + content: lexicalFor("Ticket created"), + created_at: new Date().toISOString(), + }, + ], + }, + overrideAccess: true, + depth: 0, + })) as unknown as Ticket; + + await emitGameEvent(payload, { + type: EventTypes.TicketCreate, + message: `Ticket #${createdTicket.id} created: ${title}`, + actor: userId, + targetCollection: "tickets", + targetId: createdTicket.id, + data: { category, priority }, + }); + + const reporterName = displayNameOf(user); + const staffIds = await getStaffUserIds(payload); + for (const staffId of staffIds) { + if (staffId === userId) continue; + await notifyUser(payload, { + userId: staffId, + type: "ticket:created", + title: "New ticket", + message: `${title} — ${reporterName}`, + link: `/helpdesk/${createdTicket.id}`, + }); + } + + return { success: true, data: createdTicket }; + } catch (error) { + const message = error instanceof Error ? error.message : "Unknown error"; + return message === "Unauthorized" + ? { success: false, error: "You must be logged in." } + : { success: false, error: message }; + } +} + +export async function replyToTicket(ticketId: number, content: string): Promise { + try { + const { payload, user } = await authenticate(); + const userId = user.id as number; + const ticket = await getTicketOrThrow(payload, ticketId); + + const isStaff = hasRoles(["admin", "developer"], user); + const reporterId = reporterIdOf(ticket); + if (!isStaff && reporterId !== userId) { + throw new Error("You don't have access to this ticket."); + } + + const trimmed = content.trim(); + if (!trimmed) throw new Error("Message is required."); + + const messageType = isStaff ? "agent_message" : "client_message"; + const newActivity: NonNullable[number][] = [ + ...(ticket.activity ?? []), + { + type: messageType, + content: lexicalFor(trimmed), + author: userId, + created_at: new Date().toISOString(), + }, + ]; + + const reopen = CLOSING_STATUSES.includes(ticket.status); + await payload.update({ + collection: "tickets", + id: ticketId, + data: reopen + ? { activity: newActivity, status: "open" } + : { activity: newActivity }, + overrideAccess: true, + depth: 0, + }); + + await emitGameEvent(payload, { + type: EventTypes.TicketReply, + message: `${displayNameOf(user)} replied to ticket #${ticketId}`, + actor: userId, + targetCollection: "tickets", + targetId: ticketId, + }); + + const replyMessage = `"${ticket.title}" — you have a new reply.`; + if (isStaff) { + if (reporterId != null && reporterId !== userId) { + await notifyUser(payload, { + userId: reporterId, + type: "ticket:reply", + title: "New reply", + message: replyMessage, + link: `/helpdesk/${ticketId}`, + }); + } + } else { + const assigneeId = assigneeIdOf(ticket); + if (assigneeId != null && assigneeId !== userId) { + await notifyUser(payload, { + userId: assigneeId, + type: "ticket:reply", + title: "New reply", + message: replyMessage, + link: `/helpdesk/${ticketId}`, + }); + } else { + const staffIds = await getStaffUserIds(payload); + for (const staffId of staffIds) { + if (staffId === userId) continue; + await notifyUser(payload, { + userId: staffId, + type: "ticket:reply", + title: "New reply", + message: replyMessage, + link: `/helpdesk/${ticketId}`, + }); + } + } + } + + return { success: true }; + } catch (error) { + const message = error instanceof Error ? error.message : "Unknown error"; + return message === "Unauthorized" + ? { success: false, error: "You must be logged in." } + : { success: false, error: message }; + } +} + +export async function updateTicketStatus(ticketId: number, status: TicketStatus): Promise { + try { + const { payload, user } = await authenticate(); + const userId = user.id as number; + + if (!TICKET_STATUSES.some((s) => s.value === status)) { + throw new Error("Invalid status."); + } + + const ticket = await getTicketOrThrow(payload, ticketId); + if (status === ticket.status) return { success: true }; + + const isStaff = hasRoles(["admin", "developer"], user); + const reporterId = reporterIdOf(ticket); + if (!isStaff) { + const reporterCancel = + reporterId === userId && + status === "canceled" && + !CLOSING_STATUSES.includes(ticket.status); + if (!reporterCancel) throw new Error("Only staff can change ticket status."); + } + + await payload.update({ + collection: "tickets", + id: ticketId, + data: { status }, + overrideAccess: true, + depth: 0, + }); + + await emitGameEvent(payload, { + type: EventTypes.TicketStatusChange, + message: `Ticket #${ticketId} status changed to ${STATUS_LABEL[status]}`, + actor: userId, + targetCollection: "tickets", + targetId: ticketId, + data: { from: ticket.status, to: status }, + }); + + const recipients = new Set(); + if (reporterId != null) recipients.add(reporterId); + const assigneeId = assigneeIdOf(ticket); + if (assigneeId != null) recipients.add(assigneeId); + recipients.delete(userId); + + for (const recipientId of recipients) { + await notifyUser(payload, { + userId: recipientId, + type: "ticket:status", + title: "Ticket updated", + message: `Status changed to ${STATUS_LABEL[status]} on "${ticket.title}".`, + link: `/helpdesk/${ticketId}`, + }); + } + + return { success: true }; + } catch (error) { + const message = error instanceof Error ? error.message : "Unknown error"; + return message === "Unauthorized" + ? { success: false, error: "You must be logged in." } + : { success: false, error: message }; + } +} + +export async function assignTicket(ticketId: number, assigneeId: number | null): Promise { + try { + const { payload, user } = await authenticate(); + const userId = user.id as number; + + if (!hasRoles(["admin", "developer"], user)) { + throw new Error("Only staff can assign tickets."); + } + + const ticket = await getTicketOrThrow(payload, ticketId); + + if (assigneeId != null) { + const assignee = (await payload + .findByID({ collection: "users", id: assigneeId, depth: 0, overrideAccess: true }) + .catch(() => null)) as unknown as { id: number } | null; + if (!assignee) throw new Error("Assignee not found."); + } + + if (assigneeId === assigneeIdOf(ticket)) return { success: true }; + + await payload.update({ + collection: "tickets", + id: ticketId, + data: { assignee: assigneeId ?? null }, + overrideAccess: true, + depth: 0, + }); + + await emitGameEvent(payload, { + type: EventTypes.TicketAssign, + message: `Ticket #${ticketId} assigned`, + actor: userId, + targetCollection: "tickets", + targetId: ticketId, + data: { assignee: assigneeId }, + }); + + if (assigneeId != null && assigneeId !== userId) { + await notifyUser(payload, { + userId: assigneeId, + type: "ticket:assigned", + title: "Ticket assigned", + message: `You were assigned "${ticket.title}".`, + link: `/helpdesk/${ticketId}`, + }); + } + + return { success: true }; + } catch (error) { + const message = error instanceof Error ? error.message : "Unknown error"; + return message === "Unauthorized" + ? { success: false, error: "You must be logged in." } + : { success: false, error: message }; + } +}