diff --git a/src/apps/opportunities/README.md b/src/apps/opportunities/README.md index 60695776b..36c8e3f2f 100644 --- a/src/apps/opportunities/README.md +++ b/src/apps/opportunities/README.md @@ -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. @@ -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 diff --git a/src/apps/opportunities/src/components/SubmissionArtifactsModal.spec.tsx b/src/apps/opportunities/src/components/SubmissionArtifactsModal.spec.tsx index 5e1036595..812f5f55f 100644 --- a/src/apps/opportunities/src/components/SubmissionArtifactsModal.spec.tsx +++ b/src/apps/opportunities/src/components/SubmissionArtifactsModal.spec.tsx @@ -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( + new Map() }}> + + , + ) + 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( new Map() }}> diff --git a/src/apps/opportunities/src/components/SubmissionArtifactsModal.tsx b/src/apps/opportunities/src/components/SubmissionArtifactsModal.tsx index 731b15421..6a3fa0ed6 100644 --- a/src/apps/opportunities/src/components/SubmissionArtifactsModal.tsx +++ b/src/apps/opportunities/src/components/SubmissionArtifactsModal.tsx @@ -13,6 +13,7 @@ import { import styles from './SubmissionArtifactsModal.module.scss' interface SubmissionArtifactsModalProps { + allowInternalArtifacts?: boolean onClose: () => void open: boolean submissionId?: string @@ -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. @@ -78,7 +80,7 @@ export const SubmissionArtifactsModal: FC = props const [downloadingArtifactId, setDownloadingArtifactId] = useState() const response: SWRResponse = 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 }, @@ -106,6 +108,12 @@ export const SubmissionArtifactsModal: FC = 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 =
@@ -116,7 +124,7 @@ export const SubmissionArtifactsModal: FC = props ) - } else if (!response.data?.length) { + } else if (!artifacts.length) { content =

No submission artifacts are available.

} else { content = ( @@ -129,7 +137,7 @@ export const SubmissionArtifactsModal: FC = props - {response.data.map(artifactId => ( + {artifacts.map(artifactId => ( {artifactId} diff --git a/src/apps/opportunities/src/components/SubmissionHistoryModal.spec.tsx b/src/apps/opportunities/src/components/SubmissionHistoryModal.spec.tsx index d6b77b774..c9cae7dd4 100644 --- a/src/apps/opportunities/src/components/SubmissionHistoryModal.spec.tsx +++ b/src/apps/opportunities/src/components/SubmissionHistoryModal.spec.tsx @@ -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( + new Map() }}> + + , + ) + 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( new Map() }}> diff --git a/src/apps/opportunities/src/components/SubmissionHistoryModal.tsx b/src/apps/opportunities/src/components/SubmissionHistoryModal.tsx index 7dbd198fb..5da92c36b 100644 --- a/src/apps/opportunities/src/components/SubmissionHistoryModal.tsx +++ b/src/apps/opportunities/src/components/SubmissionHistoryModal.tsx @@ -22,6 +22,7 @@ interface SubmissionHistoryModalProps { challengeId: string isMarathonMatch?: boolean onClose: () => void + onOpenArtifacts?: (submissionId: string) => void open: boolean reviewSummations?: ChallengeReviewSummation[] showFinalScores?: boolean @@ -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. */ @@ -144,6 +145,7 @@ export const SubmissionHistoryModal: FC = props => Submission Date {props.isMarathonMatch && Provisional Score} Final Score + {props.onOpenArtifacts && Artifacts} @@ -170,6 +172,17 @@ export const SubmissionHistoryModal: FC = props => ) : formatMarathonScore(scores.finalScore, 'N/A')} + {props.onOpenArtifacts && ( + + + + )} ) })} diff --git a/src/apps/opportunities/src/pages/ChallengeDetailsPage.flows.spec.tsx b/src/apps/opportunities/src/pages/ChallengeDetailsPage.flows.spec.tsx index 515306f18..a876ebc71 100644 --- a/src/apps/opportunities/src/pages/ChallengeDetailsPage.flows.spec.tsx +++ b/src/apps/opportunities/src/pages/ChallengeDetailsPage.flows.spec.tsx @@ -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' } @@ -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, }] diff --git a/src/apps/opportunities/src/pages/ChallengeDetailsPage.tsx b/src/apps/opportunities/src/pages/ChallengeDetailsPage.tsx index 631d1b852..71bbf0040 100644 --- a/src/apps/opportunities/src/pages/ChallengeDetailsPage.tsx +++ b/src/apps/opportunities/src/pages/ChallengeDetailsPage.tsx @@ -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)} @@ -884,6 +886,8 @@ interface ChallengeTabContentProps { canCreateForumAnnouncements: boolean canDeleteForumTopics: boolean challenge: ChallengeOpportunity + canManageArtifacts: boolean + isRegistered: boolean memberId?: string onCloseSubmission: () => void onContactSupport: () => void @@ -914,7 +918,14 @@ const ChallengeTabContent: FC = props => { const isDesign = catalogName(props.challenge.track) .toLowerCase() === 'design' return props.memberId || isDesign - ? + ? ( + + ) : } @@ -937,8 +948,11 @@ const ChallengeTabContent: FC = props => { return ( interface SubmissionsTabProps { challenge: ChallengeOpportunity + canManageArtifacts?: boolean + isRegistered?: boolean memberId?: string mine?: boolean onDeleted?: () => Promise @@ -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. */ @@ -1268,6 +1284,32 @@ const SubmissionsTab: FC = 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 || [ @@ -1650,12 +1692,12 @@ const SubmissionsTab: FC = props => {