Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion src/apps/support/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
1 change: 1 addition & 0 deletions src/apps/support/src/lib/models/support.models.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ export interface SupportTicketSummary {
status: SupportTicketStatus
openedAt: string
closedAt?: string
closedByUserId?: string
updatedAt: string
latestActivityAt: string
responseCount: number
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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(<TicketDetailPage />)

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(<TicketDetailPage />)

expect(screen.getByText('legacy-staff-1'))
.toBeTruthy()
})

it('preserves freshly revalidated assignees when marking a ticket read completes', async () => {
const markReadRequest = createDeferred<void>()
mockedMarkRead.mockReturnValue(markReadRequest.promise)
Expand Down Expand Up @@ -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,
Expand All @@ -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,
Expand All @@ -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)
})
})
19 changes: 19 additions & 0 deletions src/apps/support/src/pages/ticket-details/TicketDetailPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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}`
Expand Down Expand Up @@ -234,6 +237,21 @@ export const TicketDetailPage: FC = () => {
Closed
{' '}
{formatSupportDate(data.closedAt)}
{data.closedByUserId && (
<>
{' '}
by
{' '}
<span>
{closedByAssignee ? (
<MemberHandle
color={closedByAssignee.handleColor}
handle={closedByAssignee.handle}
/>
) : data.closedByUserId}
</span>
</>
)}
</p>
)}
</div>
Expand All @@ -247,6 +265,7 @@ export const TicketDetailPage: FC = () => {
size='md'
/>
<Button
disabled={updatingAssignment || !assignedToCurrentUser}
label='Close support ticket'
onClick={() => setConfirmClose(true)}
primary
Expand Down
Loading