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 { getStaffUserIds } from "@/lib/tickets/staff";
|
||||||
import type { TicketCategory, TicketPriority, TicketStatus } from "@/lib/tickets/ticketMeta";
|
import type { TicketCategory, TicketPriority, TicketStatus } from "@/lib/tickets/ticketMeta";
|
||||||
import { CLOSING_STATUSES, STATUS_LABEL, TICKET_STATUSES } 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> {
|
export interface ActionResult<T = undefined> {
|
||||||
success: boolean;
|
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> {
|
export async function replyToTicket(ticketId: number, content: string): Promise<ActionResult> {
|
||||||
try {
|
try {
|
||||||
const { payload, user } = await authenticate();
|
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 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({
|
await payload.update({
|
||||||
collection: "tickets",
|
collection: "tickets",
|
||||||
id: ticketId,
|
id: ticketId,
|
||||||
data: reopen ? { activity: newActivity, status: "open" } : { activity: newActivity },
|
data: nextStatus ? { activity: newActivity, status: nextStatus } : { activity: newActivity },
|
||||||
overrideAccess: true,
|
overrideAccess: true,
|
||||||
depth: 0,
|
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, {
|
await emitGameEvent(payload, {
|
||||||
type: EventTypes.TicketReply,
|
type: EventTypes.TicketReply,
|
||||||
message: `${displayNameOf(user)} replied to ticket #${ticketId}`,
|
message: `${displayNameOf(user)} replied to ticket #${ticketId}`,
|
||||||
|
|
|
||||||
|
|
@ -108,6 +108,9 @@ export const EventTypes = {
|
||||||
TicketReply: "ticket:reply",
|
TicketReply: "ticket:reply",
|
||||||
TicketStatusChange: "ticket:status-change",
|
TicketStatusChange: "ticket:status-change",
|
||||||
TicketAssign: "ticket:assign",
|
TicketAssign: "ticket:assign",
|
||||||
|
TicketEdit: "ticket:edit",
|
||||||
|
TicketVote: "ticket:vote",
|
||||||
|
TicketUnvote: "ticket:unvote",
|
||||||
|
|
||||||
// Projects
|
// Projects
|
||||||
ProjectCreate: "project:create",
|
ProjectCreate: "project:create",
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ import type { Ticket, User } from "@/payload-types";
|
||||||
import { plaintextToLexical } from "@/lib/tickets/lexical";
|
import { plaintextToLexical } from "@/lib/tickets/lexical";
|
||||||
import { getStaffUserIds } from "@/lib/tickets/staff";
|
import { getStaffUserIds } from "@/lib/tickets/staff";
|
||||||
import { STATUS_LABEL, TICKET_STATUSES } from "@/lib/tickets/ticketMeta";
|
import { STATUS_LABEL, TICKET_STATUSES } from "@/lib/tickets/ticketMeta";
|
||||||
|
import { setTicketVoteForUser, updateTicketByAuthor } from "@/lib/tickets/mutations";
|
||||||
|
|
||||||
let payload: Payload;
|
let payload: Payload;
|
||||||
|
|
||||||
|
|
@ -108,6 +109,19 @@ describe("Tickets", () => {
|
||||||
depth: 0,
|
depth: 0,
|
||||||
overrideAccess: true,
|
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) {
|
for (const ticket of tickets.docs) {
|
||||||
await payload.delete({ collection: "tickets", id: ticket.id, overrideAccess: true });
|
await payload.delete({ collection: "tickets", id: ticket.id, overrideAccess: true });
|
||||||
}
|
}
|
||||||
|
|
@ -237,6 +251,92 @@ describe("Tickets", () => {
|
||||||
expect(idOf(populated.assignee)).toBeNull();
|
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 () => {
|
it("preserves client/agent messages appended by actions", async () => {
|
||||||
const ticket = await createTicket(reporter.id);
|
const ticket = await createTicket(reporter.id);
|
||||||
|
|
||||||
|
|
@ -272,4 +372,111 @@ describe("Tickets", () => {
|
||||||
expect(STATUS_LABEL[status.value]).toBe(status.label);
|
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