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:
parent
4594ff3d7c
commit
0fa636439a
3 changed files with 260 additions and 1 deletions
|
|
@ -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}`,
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in a new issue