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
10 changes: 10 additions & 0 deletions src/apps/opportunities/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -484,6 +484,14 @@ views refresh attempts and their embedded scorer summaries so completed results
appear without a page reload. Their actions include the clean submission, scorer
artifacts, and submission history, while the single page-level button owns the
Review App handoff.
Artifact controls in All Submissions, My Submissions, and submission history
use the challenge's exact `COMPLETED` status for Marathon Matches. Before completion
(including cancelled challenges), contestants can list and download only their own
regular artifacts. After completion, registered contestants can download regular
and internal provisional/system artifacts for their own and other members' attempts.
Scores and phase end dates never unlock artifacts. Existing admin/copilot access
is retained. The shared dialog filters internal entries when permission changes,
and Review API independently authorizes both listing and every download.
The Marathon Match My Submissions table reserves enough width for the complete
submission timestamp and keeps its date heading and sort icon on one line,
aligned with the dates beneath it. Score columns remain right aligned.
Expand All @@ -495,6 +503,8 @@ compact close action only at that breakpoint. History requests include the
selected member ID;
Review API returns every attempt to that member and authorized challenge staff,
while ordinary viewers receive only the selected entrant's latest attempt.
Registered contestants of a completed Marathon Match can also inspect every
historical attempt to download its released scorer artifacts.
Design submissions can be deleted only while Submission or Checkpoint
Submission is open. Successful deletion updates both the challenge and member
submission counts as well as the current list. Replacing a Design submission
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,24 @@ describe('SubmissionArtifactsModal', () => {
.mockImplementation(() => undefined)
})

it.each([false, true])('filters internal artifact controls when access is %s', async allowInternalArtifacts => {
mockedGetArtifacts.mockResolvedValue(['regular-results', 'provisional-internal', 'system-INTERNAL'])
render(
<SWRConfig value={{ provider: () => new Map() }}>
<SubmissionArtifactsModal
allowInternalArtifacts={allowInternalArtifacts}
onClose={jest.fn()}
open
submissionId='submission-id'
/>
</SWRConfig>,
)
expect(await screen.findByRole('button', { name: 'Download artifact regular-results' }))
.toBeInTheDocument()
expect(screen.queryAllByRole('button', { name: /Download artifact .*internal/i }))
.toHaveLength(allowInternalArtifacts ? 2 : 0)
})

it('loads and downloads the selected scorer artifact through Review API', async () => {
render(
<SWRConfig value={{ dedupingInterval: 0, provider: () => new Map() }}>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import {
import styles from './SubmissionArtifactsModal.module.scss'

interface SubmissionArtifactsModalProps {
allowInternalArtifacts?: boolean
onClose: () => void
open: boolean
submissionId?: string
Expand Down Expand Up @@ -70,6 +71,7 @@ function saveArtifact(blob: Blob, filename: string): void {
/**
* Lists and downloads scorer-generated files for one authored submission.
*
* Internal files are displayed only with explicit completion/management permission.
* @param props selected submission, visibility, and close callback.
* @returns artifact dialog with loading, error, empty, and download states.
* @throws Does not throw; request failures remain visible in the dialog.
Expand All @@ -78,7 +80,7 @@ export const SubmissionArtifactsModal: FC<SubmissionArtifactsModalProps> = props
const [downloadingArtifactId, setDownloadingArtifactId] = useState<string>()
const response: SWRResponse<string[], Error> = useSWR(
props.open && props.submissionId
? ['opportunities:submission-artifacts', props.submissionId]
? ['opportunities:submission-artifacts', props.submissionId, !!props.allowInternalArtifacts]
: undefined,
() => getChallengeSubmissionArtifacts(props.submissionId as string),
{ revalidateOnFocus: false, shouldRetryOnError: false },
Expand Down Expand Up @@ -106,6 +108,12 @@ export const SubmissionArtifactsModal: FC<SubmissionArtifactsModalProps> = props
}
}

// Also filter cached responses when completion or viewer permissions change.
const artifacts = (response.data ?? []).filter(artifactId => (
props.allowInternalArtifacts || !artifactId.toLowerCase()
.includes('internal')
))

let content
if (response.isValidating && !response.data) {
content = <div className={styles.loading}><LoadingSpinner /></div>
Expand All @@ -116,7 +124,7 @@ export const SubmissionArtifactsModal: FC<SubmissionArtifactsModalProps> = props
<button onClick={() => response.mutate()} type='button'>Try again</button>
</div>
)
} else if (!response.data?.length) {
} else if (!artifacts.length) {
content = <p className={styles.message}>No submission artifacts are available.</p>
} else {
content = (
Expand All @@ -129,7 +137,7 @@ export const SubmissionArtifactsModal: FC<SubmissionArtifactsModalProps> = props
</tr>
</thead>
<tbody>
{response.data.map(artifactId => (
{artifacts.map(artifactId => (
<tr key={artifactId}>
<td><span title={artifactId}>{artifactId}</span></td>
<td>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,32 @@ describe('SubmissionHistoryModal', () => {
.toHaveBeenCalledTimes(2)
})

it.each([false, true])('gates historical artifact actions with authorization %s', async allowed => {
const onOpenArtifacts = jest.fn()
render(
<SWRConfig value={{ dedupingInterval: 0, provider: () => new Map() }}>
<SubmissionHistoryModal
challengeId='challenge'
isMarathonMatch
onClose={jest.fn()}
onOpenArtifacts={allowed ? onOpenArtifacts : undefined}
open
submission={{ id: 'submission-two', memberId: '123' }}
/>
</SWRConfig>,
)
await screen.findByText('submission-one')
const action = screen.queryByRole('button', { name: 'Download submission artifacts submission-one' })
if (allowed) {
fireEvent.click(action as HTMLElement)
expect(onOpenArtifacts)
.toHaveBeenCalledWith('submission-one')
} else {
expect(action)
.not.toBeInTheDocument()
}
})

it('replaces the non-Marathon status column with the final score', async () => {
render(
<SWRConfig value={{ dedupingInterval: 0, provider: () => new Map() }}>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ interface SubmissionHistoryModalProps {
challengeId: string
isMarathonMatch?: boolean
onClose: () => void
onOpenArtifacts?: (submissionId: string) => void
open: boolean
reviewSummations?: ChallengeReviewSummation[]
showFinalScores?: boolean
Expand Down Expand Up @@ -81,7 +82,7 @@ function submissionHandle(submission?: ChallengeSubmission): string | undefined
* navigating away from Opportunities to Review App. Review API may limit an
* ordinary viewer to the latest attempt.
*
* @param props selected submission, challenge context, visibility, and close callback.
* @param props selected submission, challenge context, visibility, and authorized artifact/close callbacks.
* @returns modal with history rows or a loading, error, or empty state.
* @throws Does not throw; request failures render a retryable modal state.
*/
Expand Down Expand Up @@ -144,6 +145,7 @@ export const SubmissionHistoryModal: FC<SubmissionHistoryModalProps> = props =>
<th>Submission Date</th>
{props.isMarathonMatch && <th>Provisional Score</th>}
<th>Final Score</th>
{props.onOpenArtifacts && <th>Artifacts</th>}
</tr>
</thead>
<tbody>
Expand All @@ -170,6 +172,17 @@ export const SubmissionHistoryModal: FC<SubmissionHistoryModalProps> = props =>
)
: formatMarathonScore(scores.finalScore, 'N/A')}
</td>
{props.onOpenArtifacts && (
<td data-mobile-label='Artifacts' data-mobile-order='5'>
<button
aria-label={`Download submission artifacts ${submission.id}`}
onClick={() => props.onOpenArtifacts?.(submission.id)}
type='button'
>
Artifacts
</button>
</td>
)}
</tr>
)
})}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1878,6 +1878,31 @@ describe('ChallengeDetailsPage member flows', () => {
.not.toBeInTheDocument()
})

it.each([
['ACTIVE', false],
['COMPLETED', true],
['CANCELLED_FAILED_REVIEW', false],
['CANCELLED', false],
])('gates other contestants artifact actions for %s challenges', (status, allowed) => {
mockProfile = { handle: 'coder', userId: 123 }
mockRegistration = { id: 'resource-id' }
mockChallenge = { ...mockChallenge, status, type: 'Marathon Match' }
mockSubmissions = [{ finalScore: 100, id: 'submission-other', memberId: '456' }]

renderPage()
fireEvent.click(screen.getByRole('tab', { name: /^Submissions/ }))
const action = screen.queryByRole('button', { name: 'Download submission artifacts submission-other' })
if (allowed) {
expect(action)
.toBeInTheDocument()
fireEvent.click(action as HTMLElement)
expect(screen.getByText('Artifacts modal submission-other'))
.toBeInTheDocument()
} else {
expect(action).not.toBeInTheDocument()
}
})

it('renders Marathon Match testing progress and both score phases without rounding', () => {
mockProfile = { handle: 'coder', userId: 123 }
mockRegistration = { id: 'resource-id' }
Expand All @@ -1886,6 +1911,7 @@ describe('ChallengeDetailsPage member flows', () => {
createdAt: '2026-06-03T09:30:00.000Z',
finalScore: 99.31399426811394,
id: 'submission-1',
memberId: '123',
provisionalScore: 99.08838088531581,
}]

Expand Down
63 changes: 59 additions & 4 deletions src/apps/opportunities/src/pages/ChallengeDetailsPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -823,7 +823,9 @@ export const ChallengeDetailsPage: FC = () => {
activeTab={activeTab}
canCreateForumAnnouncements={isAdministrator || isChallengeCopilot}
canDeleteForumTopics={isAdministrator}
canManageArtifacts={isAdministrator || isChallengeCopilot}
challenge={challenge}
isRegistered={isRegistered}
memberId={memberId}
onCloseSubmission={closeSubmission}
onContactSupport={() => setIssueOpen(true)}
Expand Down Expand Up @@ -884,6 +886,8 @@ interface ChallengeTabContentProps {
canCreateForumAnnouncements: boolean
canDeleteForumTopics: boolean
challenge: ChallengeOpportunity
canManageArtifacts: boolean
isRegistered: boolean
memberId?: string
onCloseSubmission: () => void
onContactSupport: () => void
Expand Down Expand Up @@ -914,7 +918,14 @@ const ChallengeTabContent: FC<ChallengeTabContentProps> = props => {
const isDesign = catalogName(props.challenge.track)
.toLowerCase() === 'design'
return props.memberId || isDesign
? <SubmissionsTab challenge={props.challenge} viewerMemberId={props.memberId} />
? (
<SubmissionsTab
canManageArtifacts={props.canManageArtifacts}
challenge={props.challenge}
isRegistered={props.isRegistered}
viewerMemberId={props.memberId}
/>
)
: <SignInTab subject='submissions' />
}

Expand All @@ -937,8 +948,11 @@ const ChallengeTabContent: FC<ChallengeTabContentProps> = props => {

return (
<SubmissionsTab
canManageArtifacts={props.canManageArtifacts}
challenge={props.challenge}
isRegistered={props.isRegistered}
memberId={props.memberId}
viewerMemberId={props.memberId}
mine
onDeleted={props.onDeleted}
onStartSubmission={props.onStartSubmission}
Expand Down Expand Up @@ -1240,6 +1254,8 @@ const RegistrantsTab: FC<{ challenge: ChallengeOpportunity; revision: number }>

interface SubmissionsTabProps {
challenge: ChallengeOpportunity
canManageArtifacts?: boolean
isRegistered?: boolean
memberId?: string
mine?: boolean
onDeleted?: () => Promise<void>
Expand All @@ -1250,7 +1266,7 @@ interface SubmissionsTabProps {
/**
* Loads and paginates submissions only after a submission tab is selected.
*
* @param props challenge, member scope, viewer identity, My Submissions flag, and submission callbacks.
* @param props challenge, registration/management rights, viewer identity, My Submissions flag, and callbacks.
* @returns submission table/gallery, lifecycle-aware empty state, or request state.
* @throws Does not throw; request failures render a retry action.
*/
Expand All @@ -1268,6 +1284,32 @@ const SubmissionsTab: FC<SubmissionsTabProps> = props => {
const isDesign = trackKey === 'design'
const isQa = trackKey === 'qualityassurance'
const isMarathonMatch = isMarathonMatchChallenge(props.challenge)
const completedMarathon = isMarathonMatch && props.challenge.status?.toUpperCase() === 'COMPLETED'
const allowInternalArtifacts = !!props.canManageArtifacts || completedMarathon

/**
* Gates scorer artifact controls using ownership and completed MM participation.
* @param submission Candidate row; Review API rechecks access on each request.
* @returns Whether this viewer should see the artifact action.
* @throws Does not throw.
*/
const canViewArtifacts = (submission: ChallengeSubmission): boolean => isMarathonMatch && (
!!props.canManageArtifacts
|| (!!props.viewerMemberId && challengeSubmissionMemberId(submission) === props.viewerMemberId)
|| (completedMarathon && !!props.isRegistered)
)

/**
* Opens scorer artifacts from either the latest row or a history entry.
* @param submissionId Authorized submission selected by the viewer.
* @returns void after closing history and opening the artifact dialog.
* @throws Does not throw; the dialog reports API authorization errors.
*/
const openArtifacts = (submissionId: string): void => {
setHistorySubmission(undefined)
setArtifactsSubmissionId(submissionId)
}

const hasAiWorkflow = !isMarathonMatch && (
props.challenge.reviewers?.some(reviewer => !!reviewer.aiWorkflowId?.trim()) === true
|| [
Expand Down Expand Up @@ -1650,12 +1692,12 @@ const SubmissionsTab: FC<SubmissionsTabProps> = props => {
<IconOutline.DownloadIcon aria-hidden='true' />
</button>
)}
{isMarathonMatch && (
{canViewArtifacts(submission) && (
<button
aria-label={
`Download submission artifacts ${submission.id}`
}
onClick={() => setArtifactsSubmissionId(submission.id)}
onClick={() => openArtifacts(submission.id)}
title='Download submission artifacts'
type='button'
>
Expand Down Expand Up @@ -1839,6 +1881,17 @@ const SubmissionsTab: FC<SubmissionsTabProps> = props => {
</td>
)}
<td data-mobile-label='Action'>
{canViewArtifacts(submission) && (
<button
aria-label={`Download submission artifacts ${submission.id}`}
className={styles.historyLink}
onClick={() => openArtifacts(submission.id)}
type='button'
>
<IconOutline.FolderDownloadIcon aria-hidden='true' width={20} />
Artifacts
</button>
)}
<button
className={styles.historyLink}
onClick={() => setHistorySubmission(submission)}
Expand All @@ -1862,6 +1915,7 @@ const SubmissionsTab: FC<SubmissionsTabProps> = props => {
)}
<div className={styles.tablePagination}>{pagination}</div>
<SubmissionArtifactsModal
allowInternalArtifacts={allowInternalArtifacts}
onClose={() => setArtifactsSubmissionId(undefined)}
open={!!artifactsSubmissionId}
submissionId={artifactsSubmissionId}
Expand All @@ -1870,6 +1924,7 @@ const SubmissionsTab: FC<SubmissionsTabProps> = props => {
challengeId={props.challenge.id}
isMarathonMatch={isMarathonMatch}
onClose={() => setHistorySubmission(undefined)}
onOpenArtifacts={historySubmission && canViewArtifacts(historySubmission) ? openArtifacts : undefined}
open={!!historySubmission}
reviewSummations={scoreResponse.data}
showFinalScores={props.mine || showAllSubmissionFinalScores}
Expand Down
Loading
Loading