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'
/>