From 3baab7acd6505aa3117146ce6efaab1df4866859 Mon Sep 17 00:00:00 2001 From: Justin Gasper Date: Wed, 12 Aug 2026 16:52:02 +1000 Subject: [PATCH] PM-5858: Require assignment before ticket closure What was broken Support Team members could close an unassigned ticket, and the closed-ticket detail did not identify who closed it. Root cause The close control checked only Support Team membership, and the UI contract omitted the closer ID already stored by the API. What was changed Disable closure unless the current Support Team user is assigned, accept the optional closer ID, and display the matching assignee handle with a stored-ID fallback. Any added/updated tests Updated TicketDetailPage coverage for unassigned and assigned closure access, closer-handle display, and legacy closer-ID fallback. --- src/apps/support/README.md | 3 +- .../support/src/lib/models/support.models.ts | 1 + .../ticket-details/TicketDetailPage.spec.tsx | 55 ++++++++++++++++++- .../pages/ticket-details/TicketDetailPage.tsx | 19 +++++++ 4 files changed, 75 insertions(+), 3 deletions(-) diff --git a/src/apps/support/README.md b/src/apps/support/README.md index eaad0a17d..ead5236ab 100644 --- a/src/apps/support/README.md +++ b/src/apps/support/README.md @@ -3,7 +3,8 @@ The Support subapp lets authenticated Topcoder members open requests, follow their status, and reply to the Topcoder Support Team. Users with the exact `Topcoder Support Team` role can see all tickets, assign themselves, search -closed tickets, reply to tickets assigned to them, and close resolved requests. +closed tickets, and reply to or close resolved requests assigned to them. +Closed ticket details identify the Support Team user who closed the request. ## Routes diff --git a/src/apps/support/src/lib/models/support.models.ts b/src/apps/support/src/lib/models/support.models.ts index 5c346295f..f0c94d64f 100644 --- a/src/apps/support/src/lib/models/support.models.ts +++ b/src/apps/support/src/lib/models/support.models.ts @@ -33,6 +33,7 @@ export interface SupportTicketSummary { status: SupportTicketStatus openedAt: string closedAt?: string + closedByUserId?: string updatedAt: string latestActivityAt: string responseCount: number diff --git a/src/apps/support/src/pages/ticket-details/TicketDetailPage.spec.tsx b/src/apps/support/src/pages/ticket-details/TicketDetailPage.spec.tsx index 66b321d20..9e69ac6a5 100644 --- a/src/apps/support/src/pages/ticket-details/TicketDetailPage.spec.tsx +++ b/src/apps/support/src/pages/ticket-details/TicketDetailPage.spec.tsx @@ -196,6 +196,49 @@ describe('TicketDetailPage reply access', () => { .toBeTruthy() }) + it('identifies the support staff member who closed the ticket', () => { + mockUseSWR.mockReturnValue({ + data: { + ...closedTicket, + assignees: [{ + assignedAt: '2026-08-07T00:30:00.000Z', + handle: 'support-agent', + userId: '99999', + }], + closedByUserId: '99999', + }, + error: undefined, + isValidating: false, + mutate: mockMutate, + }) + + render() + + expect(screen.getByText((_content, element) => ( + element?.tagName === 'P' + && element.textContent?.includes('Closed') === true + && element.textContent?.includes('by support-agent') === true + ))) + .toBeTruthy() + }) + + it('falls back to the stored closer user ID when no assignee snapshot matches', () => { + mockUseSWR.mockReturnValue({ + data: { + ...closedTicket, + closedByUserId: 'legacy-staff-1', + }, + error: undefined, + isValidating: false, + mutate: mockMutate, + }) + + render() + + expect(screen.getByText('legacy-staff-1')) + .toBeTruthy() + }) + it('preserves freshly revalidated assignees when marking a ticket read completes', async () => { const markReadRequest = createDeferred() mockedMarkRead.mockReturnValue(markReadRequest.promise) @@ -242,7 +285,7 @@ describe('TicketDetailPage reply access', () => { .toBeUndefined() }) - it('requires non-owner support staff to assign an open ticket before replying', () => { + it('requires non-owner support staff to assign an open ticket before replying or closing it', () => { mockProfile = { roles: ['Topcoder Support Team'], userId: 99999, @@ -264,9 +307,13 @@ describe('TicketDetailPage reply access', () => { .toBeNull() expect(screen.getByText('Assign this ticket to yourself before replying.')) .toBeTruthy() + expect((screen.getByRole('button', { + name: 'Close support ticket', + }) as HTMLButtonElement).disabled) + .toBe(true) }) - it('lets assigned support staff reply to an open ticket', () => { + it('lets assigned support staff reply to and close an open ticket', () => { mockProfile = { roles: ['Topcoder Support Team'], userId: 99999, @@ -293,5 +340,9 @@ describe('TicketDetailPage reply access', () => { .toBeTruthy() expect(screen.queryByText('Assign this ticket to yourself before replying.')) .toBeNull() + expect((screen.getByRole('button', { + name: 'Close support ticket', + }) as HTMLButtonElement).disabled) + .toBe(false) }) }) diff --git a/src/apps/support/src/pages/ticket-details/TicketDetailPage.tsx b/src/apps/support/src/pages/ticket-details/TicketDetailPage.tsx index 4df066db3..8ac4bfeb5 100644 --- a/src/apps/support/src/pages/ticket-details/TicketDetailPage.tsx +++ b/src/apps/support/src/pages/ticket-details/TicketDetailPage.tsx @@ -106,6 +106,9 @@ export const TicketDetailPage: FC = () => { assignee => String(assignee.userId) === currentUserId, )) const closed = data.status === 'CLOSED' + const closedByAssignee = data.closedByUserId + ? data.assignees.find(assignee => String(assignee.userId) === String(data.closedByUserId)) + : undefined const ticketOwner = Boolean(currentUserId && String(data.memberUserId) === currentUserId) const canReply = ticketOwner || (!closed && (!supportTeam || assignedToCurrentUser)) const replyContext = `${data.id}-reply-${replyRevision}` @@ -234,6 +237,21 @@ export const TicketDetailPage: FC = () => { Closed {' '} {formatSupportDate(data.closedAt)} + {data.closedByUserId && ( + <> + {' '} + by + {' '} + + {closedByAssignee ? ( + + ) : data.closedByUserId} + + + )}

)} @@ -247,6 +265,7 @@ export const TicketDetailPage: FC = () => { size='md' />