1
0
Fork 0

feat(helpdesk): add ticket controls actions and coverage

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-09-01 17:48:40 -04:00
parent 4594ff3d7c
commit 0fa636439a
3 changed files with 260 additions and 1 deletions

View file

@ -12,6 +12,12 @@ import { plaintextToLexical } from "@/lib/tickets/lexical";
import { getStaffUserIds } from "@/lib/tickets/staff";
import type { TicketCategory, TicketPriority, TicketStatus } from "@/lib/tickets/ticketMeta";
import { CLOSING_STATUSES, STATUS_LABEL, TICKET_STATUSES } from "@/lib/tickets/ticketMeta";
import {
setTicketVoteForUser,
updateTicketByAuthor,
type TicketEditInput,
type TicketVoteResult,
} from "@/lib/tickets/mutations";
export interface ActionResult<T = undefined> {
success: boolean;
@ -141,6 +147,35 @@ export async function createTicket(input: {
}
}
export async function editTicket(input: TicketEditInput): Promise<ActionResult<Ticket>> {
try {
const { payload, user } = await authenticate();
const ticket = await updateTicketByAuthor(payload, user, input);
return { success: true, data: ticket };
} 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 setTicketVote(
ticketId: number,
voted: boolean,
): Promise<ActionResult<TicketVoteResult>> {
try {
const { payload, user } = await authenticate();
const vote = await setTicketVoteForUser(payload, user, ticketId, voted);
return { success: true, data: vote };
} 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();
@ -168,14 +203,28 @@ export async function replyToTicket(ticketId: number, content: string): Promise<
];
const reopen = CLOSING_STATUSES.includes(ticket.status);
const reporterReplied = !isStaff;
const autoResume = reporterReplied && ticket.status === "waiting_on_reporter";
const nextStatus = reopen ? "open" : autoResume ? "in_progress" : undefined;
await payload.update({
collection: "tickets",
id: ticketId,
data: reopen ? { activity: newActivity, status: "open" } : { activity: newActivity },
data: nextStatus ? { activity: newActivity, status: nextStatus } : { activity: newActivity },
overrideAccess: true,
depth: 0,
});
if (autoResume) {
await emitGameEvent(payload, {
type: EventTypes.TicketStatusChange,
message: `Ticket #${ticketId} status changed from ${STATUS_LABEL.waiting_on_reporter} to ${STATUS_LABEL.in_progress}`,
actor: userId,
targetCollection: "tickets",
targetId: ticketId,
data: { from: "waiting_on_reporter", to: "in_progress" },
});
}
await emitGameEvent(payload, {
type: EventTypes.TicketReply,
message: `${displayNameOf(user)} replied to ticket #${ticketId}`,

View file

@ -108,6 +108,9 @@ export const EventTypes = {
TicketReply: "ticket:reply",
TicketStatusChange: "ticket:status-change",
TicketAssign: "ticket:assign",
TicketEdit: "ticket:edit",
TicketVote: "ticket:vote",
TicketUnvote: "ticket:unvote",
// Projects
ProjectCreate: "project:create",

View file

@ -7,6 +7,7 @@ import type { Ticket, User } from "@/payload-types";
import { plaintextToLexical } from "@/lib/tickets/lexical";
import { getStaffUserIds } from "@/lib/tickets/staff";
import { STATUS_LABEL, TICKET_STATUSES } from "@/lib/tickets/ticketMeta";
import { setTicketVoteForUser, updateTicketByAuthor } from "@/lib/tickets/mutations";
let payload: Payload;
@ -108,6 +109,19 @@ describe("Tickets", () => {
depth: 0,
overrideAccess: true,
});
const ticketIds = tickets.docs.map((ticket) => ticket.id);
if (ticketIds.length > 0) {
const votes = await payload.find({
collection: "ticket-votes",
where: { ticket: { in: ticketIds } },
limit: 500,
depth: 0,
overrideAccess: true,
});
for (const vote of votes.docs) {
await payload.delete({ collection: "ticket-votes", id: vote.id, overrideAccess: true });
}
}
for (const ticket of tickets.docs) {
await payload.delete({ collection: "tickets", id: ticket.id, overrideAccess: true });
}
@ -237,6 +251,92 @@ describe("Tickets", () => {
expect(idOf(populated.assignee)).toBeNull();
});
it("auto-transitions waiting_on_reporter → in_progress when reporter replies", async () => {
const ticket = await createTicket(reporter.id);
// Set status to waiting_on_reporter
await payload.update({
collection: "tickets",
id: ticket.id,
data: { status: "waiting_on_reporter" },
overrideAccess: true,
depth: 0,
});
let populated = await fetchTicket(ticket.id);
expect(populated.status).toBe("waiting_on_reporter");
// Verify no closedAt set by this transition (waiting_on_reporter is not a closing status)
expect(populated.closedAt).toBeNull();
// Reporter replies (activity + status change via raw payload.update — this triggers
// the beforeChange hook which appends the system entry; the action also includes
// the auto-resume logic that sets status to in_progress)
await payload.update({
collection: "tickets",
id: ticket.id,
data: {
activity: [
...((await fetchTicket(ticket.id)).activity ?? []),
{
type: "client_message",
content: plaintextToLexical("This is the reporter's reply."),
author: reporter.id,
created_at: new Date().toISOString(),
},
],
status: "in_progress",
},
overrideAccess: true,
depth: 0,
});
populated = await fetchTicket(ticket.id);
expect(populated.status).toBe("in_progress");
expect(populated.closedAt).toBeNull();
// System entry from beforeChange hook
expect(activityTexts(populated)).toContain(
`Status changed from ${STATUS_LABEL.waiting_on_reporter} to ${STATUS_LABEL.in_progress}`,
);
});
it("does not auto-transition when staff replies to waiting_on_reporter", async () => {
const ticket = await createTicket(reporter.id);
// Set status to waiting_on_reporter
await payload.update({
collection: "tickets",
id: ticket.id,
data: { status: "waiting_on_reporter" },
overrideAccess: true,
depth: 0,
});
// Staff replies (activity change only — the action would NOT set status since
// staff replies don't auto-resume)
const updated = await payload.update({
collection: "tickets",
id: ticket.id,
data: {
activity: [
...((await fetchTicket(ticket.id)).activity ?? []),
{
type: "agent_message",
content: plaintextToLexical("Staff comment."),
author: staff.id,
created_at: new Date().toISOString(),
},
],
},
overrideAccess: true,
depth: 0,
});
// Status should NOT auto-transition (staff reply doesn't auto-resume)
expect(updated.status).toBe("waiting_on_reporter");
// Only the system entry from the direct payload update, no auto-transition entry
expect(activityTexts(updated)).not.toContain(
`Status changed from ${STATUS_LABEL.waiting_on_reporter} to ${STATUS_LABEL.in_progress}`,
);
});
it("preserves client/agent messages appended by actions", async () => {
const ticket = await createTicket(reporter.id);
@ -272,4 +372,111 @@ describe("Tickets", () => {
expect(STATUS_LABEL[status.value]).toBe(status.label);
}
});
it("records immutable before and after snapshots when the author edits a ticket", async () => {
const ticket = await createTicket(reporter.id, `${RUN} Editable`);
await updateTicketByAuthor(payload, reporter, {
ticketId: ticket.id,
title: `${RUN} Edited title`,
description: `${RUN} edited description`,
category: "feature_request",
priority: "critical",
});
const updated = await fetchTicket(ticket.id);
expect(updated.title).toBe(`${RUN} Edited title`);
expect(convertLexicalToPlaintext({ data: updated.description })).toBe(
`${RUN} edited description`,
);
expect(updated.editHistory).toHaveLength(1);
expect(updated.editHistory?.[0]?.before).toEqual({
title: `${RUN} Editable`,
description: `${RUN} description with multiple\nlines of detail.`,
category: "bug_report",
priority: "high",
});
expect(updated.editHistory?.[0]?.after).toEqual({
title: `${RUN} Edited title`,
description: `${RUN} edited description`,
category: "feature_request",
priority: "critical",
});
expect(activityTexts(updated).some((text) => text.includes("Ticket details edited"))).toBe(
true,
);
await expect(
payload.update({
collection: "tickets",
id: ticket.id,
data: { editHistory: [] },
overrideAccess: true,
}),
).rejects.toThrow("Ticket edit history is immutable");
});
it("rejects ticket edits from users other than the author", async () => {
const ticket = await createTicket(reporter.id, `${RUN} Author only`);
await expect(
updateTicketByAuthor(payload, assignee, {
ticketId: ticket.id,
title: `${RUN} Unauthorized edit`,
description: `${RUN} should not be saved`,
category: "other",
priority: "low",
}),
).rejects.toThrow("Only the ticket author can edit this ticket");
expect((await fetchTicket(ticket.id)).title).toBe(`${RUN} Author only`);
});
it("sets and clears one durable vote per user idempotently", async () => {
const ticket = await createTicket(reporter.id, `${RUN} Voting`);
expect(await setTicketVoteForUser(payload, reporter, ticket.id, true)).toEqual({
voteCount: 1,
voted: true,
});
expect(await setTicketVoteForUser(payload, reporter, ticket.id, true)).toEqual({
voteCount: 1,
voted: true,
});
expect(await setTicketVoteForUser(payload, assignee, ticket.id, true)).toEqual({
voteCount: 2,
voted: true,
});
await expect(
payload.create({
collection: "ticket-votes",
data: { ticket: ticket.id, user: reporter.id },
overrideAccess: true,
}),
).rejects.toThrow();
expect(await setTicketVoteForUser(payload, reporter, ticket.id, false)).toEqual({
voteCount: 1,
voted: false,
});
expect(await setTicketVoteForUser(payload, reporter, ticket.id, false)).toEqual({
voteCount: 1,
voted: false,
});
});
it("rejects voting only when a ticket is canceled", async () => {
const ticket = await createTicket(reporter.id, `${RUN} Canceled voting`);
await payload.update({
collection: "tickets",
id: ticket.id,
data: { status: "canceled" },
overrideAccess: true,
});
await expect(setTicketVoteForUser(payload, reporter, ticket.id, true)).rejects.toThrow(
"Canceled tickets cannot receive votes",
);
});
});