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