From 0fa636439a2add5febf63b136d21db07d996f044 Mon Sep 17 00:00:00 2001 From: Z8MB1E Date: Tue, 1 Sep 2026 17:48:40 -0400 Subject: [PATCH] feat(helpdesk): add ticket controls actions and coverage Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- src/app/(frontend)/helpdesk/actions.ts | 51 +++++- src/utils/event-log/eventTypes.ts | 3 + tests/int/tickets.int.spec.ts | 207 +++++++++++++++++++++++++ 3 files changed, 260 insertions(+), 1 deletion(-) diff --git a/src/app/(frontend)/helpdesk/actions.ts b/src/app/(frontend)/helpdesk/actions.ts index 71377ba..7ac3543 100644 --- a/src/app/(frontend)/helpdesk/actions.ts +++ b/src/app/(frontend)/helpdesk/actions.ts @@ -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 { success: boolean; @@ -141,6 +147,35 @@ export async function createTicket(input: { } } +export async function editTicket(input: TicketEditInput): Promise> { + 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> { + 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 { 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}`, diff --git a/src/utils/event-log/eventTypes.ts b/src/utils/event-log/eventTypes.ts index fbce9a3..59e155d 100644 --- a/src/utils/event-log/eventTypes.ts +++ b/src/utils/event-log/eventTypes.ts @@ -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", diff --git a/tests/int/tickets.int.spec.ts b/tests/int/tickets.int.spec.ts index 1bc992c..bd12d0c 100644 --- a/tests/int/tickets.int.spec.ts +++ b/tests/int/tickets.int.spec.ts @@ -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", + ); + }); });