feat(helpdesk): add ticket server actions
Add createTicket, replyToTicket, updateTicketStatus, and assignTicket server actions with notification and event-log emission. Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
This commit is contained in:
parent
ff986bf4ec
commit
40f23e8774
1 changed files with 350 additions and 0 deletions
350
src/app/(frontend)/helpdesk/actions.ts
Normal file
350
src/app/(frontend)/helpdesk/actions.ts
Normal file
|
|
@ -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<T = undefined> {
|
||||
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<Ticket["description"]> {
|
||||
return plaintextToLexical(text) as unknown as NonNullable<Ticket["description"]>;
|
||||
}
|
||||
|
||||
async function getTicketOrThrow(
|
||||
payload: Awaited<ReturnType<typeof getPayload>>,
|
||||
ticketId: number,
|
||||
): Promise<Ticket> {
|
||||
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<ActionResult<Ticket>> {
|
||||
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<ActionResult> {
|
||||
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<Ticket["activity"]>[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<ActionResult> {
|
||||
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<number>();
|
||||
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<ActionResult> {
|
||||
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 };
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue