1
0
Fork 0

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:
Jason Fraley 2026-08-16 18:12:36 -04:00
parent ff986bf4ec
commit 40f23e8774

View 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 };
}
}