diff --git a/src/apps/opportunities/README.md b/src/apps/opportunities/README.md index 7df1dbe75..e7d18e5ad 100644 --- a/src/apps/opportunities/README.md +++ b/src/apps/opportunities/README.md @@ -384,7 +384,10 @@ tab, and that tab exists only when Work Manager enables its challenge metadata. Review API submissions and Marathon Match review summations own provisional and final scores. Active My Submissions pages periodically revalidate so an asynchronous AI decision score appears without requiring the member to reload -the page. A transient background failure retains the last successful submission +the page. AI-reviewed challenges expand the newest submission's workflow details +by default; each row can reveal the reviewer, completion date, threshold-derived +result, and a score deep-link to Review App, polling while a run is pending or +has not yet been created. A transient background failure retains the last successful submission page, retries twice with a delay, and revalidates when the member returns to the tab; initial failures still expose the explicit retry action. Optional score requests do not enter an automatic retry loop. Final diff --git a/src/apps/opportunities/src/components/SubmissionAiReviewDetails.module.scss b/src/apps/opportunities/src/components/SubmissionAiReviewDetails.module.scss new file mode 100644 index 000000000..e781faac9 --- /dev/null +++ b/src/apps/opportunities/src/components/SubmissionAiReviewDetails.module.scss @@ -0,0 +1,121 @@ +.tableWrap { + background: #f5f7fa; + overflow-x: auto; + padding: 12px 16px 16px; + + table { + border-collapse: collapse; + min-width: 640px; + table-layout: fixed; + width: 100%; + } + + th, + td { + border-bottom: 1px solid #c6c6c6; + font-size: 14px; + line-height: 20px; + padding: 12px 16px; + text-align: left; + } + + th { + font-weight: 700; + } + + th:first-child { + width: 46%; + } + + th:nth-child(2) { + width: 26%; + } + + a, + button { + color: #007d79; + } +} + +.requestState { + align-items: center; + background: #f5f7fa; + color: #525252; + display: flex; + font-size: 14px; + gap: 12px; + margin: 0; + min-height: 52px; + padding: 12px 16px; + + button { + background: transparent; + border: 0; + color: #007d79; + cursor: pointer; + font: inherit; + font-weight: 700; + padding: 0; + } +} + +.result { + align-items: center; + display: inline-flex; + font-size: 12px; + font-weight: 700; + gap: 4px; + + &::before { + align-items: center; + border: 1px solid currentColor; + border-radius: 50%; + content: ''; + display: inline-flex; + height: 14px; + justify-content: center; + width: 14px; + } +} + +.passed { + color: #198038; + + &::before { + content: '✓'; + } +} + +.failed { + color: #da1e28; + + &::before { + content: '−'; + } +} + +.pending, +.status { + color: #525252; +} + +@media (max-width: 767px) { + .tableWrap { + padding: 8px; + + table { + min-width: 560px; + } + } +} + +@media (max-width: 620px) { + .tableWrap { + overflow-x: visible; + + table { + min-width: 0; + width: 100%; + } + } +} diff --git a/src/apps/opportunities/src/components/SubmissionAiReviewDetails.tsx b/src/apps/opportunities/src/components/SubmissionAiReviewDetails.tsx new file mode 100644 index 000000000..9655d4a95 --- /dev/null +++ b/src/apps/opportunities/src/components/SubmissionAiReviewDetails.tsx @@ -0,0 +1,201 @@ +/* eslint-disable react/jsx-no-bind */ +import { FC } from 'react' +import useSWR, { SWRResponse } from 'swr' + +import { ChallengeSubmissionAiWorkflowRun } from '../models' +import { getChallengeSubmissionAiWorkflowRuns } from '../services' +import { submissionAiReviewAppUrl } from '../utils' + +import styles from './SubmissionAiReviewDetails.module.scss' + +interface SubmissionAiReviewDetailsProps { + challengeId: string + id: string + submissionId: string +} + +interface WorkflowRunResult { + kind: 'failed' | 'passed' | 'pending' | 'status' + label: string +} + +const PENDING_STATUSES = new Set(['INIT', 'QUEUED', 'DISPATCHED', 'IN_PROGRESS']) +const TERMINAL_STATUSES = new Set(['CANCELLED', 'COMPLETED', 'FAILED', 'FAILURE', 'SUCCESS', 'TIMEOUT']) +const WORKFLOW_RUN_REFRESH_INTERVAL_MS = 10_000 + +/** + * Normalizes an optional Review API workflow status for comparisons and display. + * + * @param run workflow run returned by Review API. + * @returns trimmed uppercase status, or an empty string when omitted. + * @throws Does not throw. + */ +function workflowRunStatus(run: ChallengeSubmissionAiWorkflowRun): string { + return (run.status ?? '').trim() + .toUpperCase() +} + +/** + * Converts one Review API workflow run into its member-facing result. + * + * Successful runs use the configured minimum passing score, matching the legacy + * submission-management experience. Other lifecycle values remain explicit. + * + * @param run workflow run returned by Review API. + * @returns uppercase result label and visual kind. + * @throws Does not throw. + */ +export function submissionAiWorkflowRunResult( + run: ChallengeSubmissionAiWorkflowRun, +): WorkflowRunResult { + const status = workflowRunStatus(run) + if (PENDING_STATUSES.has(status)) return { kind: 'pending', label: 'PENDING' } + if (status === 'SUCCESS') { + const score = Number(run.score) + const minimumPassingScore = Number(run.workflow?.scorecard?.minimumPassingScore ?? 0) + const passed = Number.isFinite(score) + && Number.isFinite(minimumPassingScore) + && score >= minimumPassingScore + return { kind: passed ? 'passed' : 'failed', label: passed ? 'PASSED' : 'FAILED' } + } + + if (status === 'FAILED' || status === 'FAILURE' || status === 'TIMEOUT') { + return { kind: 'failed', label: 'FAILED' } + } + + return { kind: 'status', label: status.replace(/_/g, ' ') || 'UNKNOWN' } +} + +/** + * Formats a workflow completion timestamp for the Opportunities locale. + * + * @param value ISO timestamp returned by Review API. + * @returns localized date/time or a dash when missing/invalid. + * @throws Does not throw. + */ +function workflowReviewDate(value: string | undefined): string { + if (!value) return '-' + const date = new Date(value) + if (Number.isNaN(date.getTime())) return '-' + return date.toLocaleString('en-US', { + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + month: 'short', + year: 'numeric', + }) +} + +/** + * Determines whether an unfinished workflow run should keep the details fresh. + * + * @param runs latest workflow records, when loaded. + * @returns polling interval while any run is non-terminal, otherwise zero. + * @throws Does not throw. + */ +function workflowRunRefreshInterval( + runs: ChallengeSubmissionAiWorkflowRun[] | undefined, +): number { + return !runs?.length || runs.some(run => !TERMINAL_STATUSES.has(workflowRunStatus(run))) + ? WORKFLOW_RUN_REFRESH_INTERVAL_MS + : 0 +} + +/** + * Displays workflow-run details for an expanded member submission row. + * + * The component owns its authenticated request so one failed submission renders + * an inline retry state without producing a page-wide toast. + * + * @param props challenge/submission identifiers and the controlled panel id. + * @returns workflow details table or a compact request state. + * @throws Does not throw; request failures are rendered inline. + */ +export const SubmissionAiReviewDetails: FC = props => { + const response: SWRResponse = useSWR( + ['opportunities:submission-ai-workflow-runs', props.submissionId], + () => getChallengeSubmissionAiWorkflowRuns(props.submissionId), + { + refreshInterval: workflowRunRefreshInterval, + revalidateOnFocus: false, + shouldRetryOnError: false, + }, + ) + + /** Retries only this submission's workflow-run request. */ + const retry = (): void => { + response.mutate() + } + + if (response.error && response.data === undefined) { + return ( + + ) + } + + if (!response.data) { + return

Loading AI review details…

+ } + + if (!response.data.length) { + return

No AI review details are available yet.

+ } + + return ( +
+ + + + + + + + + + + {response.data.map(run => { + const successful = workflowRunStatus(run) === 'SUCCESS' + const workflowId = run.workflowId ?? run.workflow?.id + const result = submissionAiWorkflowRunResult(run) + const score = successful && run.score !== null && run.score !== undefined + ? String(run.score) + : '-' + return ( + + + + + + + ) + })} + +
AI ReviewerReview DateScoreResult
+ {run.workflow?.name ?? 'AI review workflow'} + + {successful ? workflowReviewDate(run.completedAt) : '-'} + + {successful && workflowId ? ( + + {score} + + ) : score} + + + {result.label} + +
+
+ ) +} diff --git a/src/apps/opportunities/src/models/opportunity.models.ts b/src/apps/opportunities/src/models/opportunity.models.ts index 2ca387037..9a8715f92 100644 --- a/src/apps/opportunities/src/models/opportunity.models.ts +++ b/src/apps/opportunities/src/models/opportunity.models.ts @@ -114,6 +114,11 @@ export interface ChallengeAiReviewConfig { mode: ChallengeAiReviewMode } +/** Challenge API reviewer assignment used to identify AI-reviewed challenges. */ +export interface ChallengeReviewer { + aiWorkflowId?: string +} + export interface ChallengeOpportunity { attachments?: ChallengeAttachment[] currentPhase?: ChallengePhase @@ -139,6 +144,7 @@ export interface ChallengeOpportunity { prizeSets?: ChallengePrizeSet[] projectId?: string registrationEndDate?: string + reviewers?: ChallengeReviewer[] skills?: OpportunitySkill[] startDate?: string status?: string @@ -406,6 +412,23 @@ export interface ChallengeSubmission { virusScan?: boolean } +/** Review API workflow-run projection displayed beneath a member submission. */ +export interface ChallengeSubmissionAiWorkflowRun { + completedAt?: string + id: string + score?: number | string | null + status?: string + submissionId?: string + workflow?: { + id?: string + name?: string + scorecard?: { + minimumPassingScore?: number | string | null + } + } + workflowId?: string +} + /** Submission categories accepted by the v6 Review API upload endpoint. */ export type ChallengeSubmissionType = | 'CONTEST_SUBMISSION' diff --git a/src/apps/opportunities/src/pages/ChallengeDetailsPage.flows.spec.tsx b/src/apps/opportunities/src/pages/ChallengeDetailsPage.flows.spec.tsx index 66f25a7cc..66a0b56e6 100644 --- a/src/apps/opportunities/src/pages/ChallengeDetailsPage.flows.spec.tsx +++ b/src/apps/opportunities/src/pages/ChallengeDetailsPage.flows.spec.tsx @@ -19,6 +19,7 @@ import { import { toast } from 'react-toastify' import { ChallengeDetailsPage } from './ChallengeDetailsPage' +import { submissionAiWorkflowRunResult } from '../components/SubmissionAiReviewDetails' const mockUseSWR = jest.fn() const mockDeleteSubmission = jest.fn() @@ -36,6 +37,8 @@ let mockRegistration: { id: string } | undefined let mockTabAccessLoading: boolean let mockRegistrationRemoved: boolean let mockChallenge: Record +let mockAiWorkflowRuns: Record[]> +let mockAiWorkflowRunErrors: Record let mockMemberProfiles: Record[] let mockMemberResource: { id: string; roleName?: string } | undefined let mockMySubmissionCount: number | undefined @@ -227,6 +230,7 @@ jest.mock('../services', () => ({ getChallengeProjectResults: jest.fn(), getChallengeRegistration: jest.fn(), getChallengeReviewSummations: jest.fn(), + getChallengeSubmissionAiWorkflowRuns: jest.fn(), getChallengeSubmissionDownloadUrl: (...args: unknown[]) => mockGetSubmissionDownloadUrl(...args), getChallengeSubmissionPreviews: jest.fn(), getChallengeSubmissions: jest.fn(), @@ -335,6 +339,12 @@ jest.mock('../utils', () => ({ ))) || additionalScores.some(score => Number.isFinite(Number(score))) }, + submissionAiReviewAppUrl: ( + challengeId: string, + submissionId: string, + workflowId: string, + ): string => `https://review.topcoder-dev.com/active-challenges/${challengeId}` + + `/reviews/${submissionId}?workflowId=${workflowId}`, winnerFinalScore: ( winner: { placement?: number; userId?: string }, projectResults: Array<{ finalScore?: number; placement?: number; userId?: string }>, @@ -435,6 +445,8 @@ describe('ChallengeDetailsPage member flows', () => { track: 'Development', type: 'Challenge', } + mockAiWorkflowRuns = {} + mockAiWorkflowRunErrors = {} mockUnregister.mockResolvedValue(undefined) mockRegister.mockResolvedValue({ id: 'new-resource-id', memberId: 123 }) mockDeleteSubmission.mockResolvedValue(undefined) @@ -482,6 +494,14 @@ describe('ChallengeDetailsPage member flows', () => { } } + if (Array.isArray(key) && key[0] === 'opportunities:submission-ai-workflow-runs') { + const submissionId = String(key[1]) + return { + ...swrResponse(mockAiWorkflowRuns[submissionId] ?? []), + error: mockAiWorkflowRunErrors[submissionId], + } + } + if (Array.isArray(key) && key[0] === 'opportunities:my-submission-count') { return { ...swrResponse(mockMySubmissionCount), @@ -1372,6 +1392,135 @@ describe('ChallengeDetailsPage member flows', () => { .toHaveAttribute('data-mobile-label', 'Score') }) + it('expands the first AI workflow result and toggles each submission independently', () => { + mockProfile = { handle: 'coder', userId: 123 } + mockRegistration = { id: 'resource-id' } + mockChallenge = { + ...mockChallenge, + reviewers: [{ aiWorkflowId: 'configured-workflow' }], + status: 'ACTIVE', + } + mockSubmissions = [ + { + aiDecisionScore: 92, + createdAt: '2026-09-11T06:50:00.000Z', + id: 'newest-submission', + status: 'ACTIVE', + type: 'CONTEST_SUBMISSION', + }, + { + aiDecisionScore: 60, + createdAt: '2026-09-10T06:50:00.000Z', + id: 'older-submission', + status: 'ACTIVE', + type: 'CONTEST_SUBMISSION', + }, + ] + mockAiWorkflowRuns = { + 'newest-submission': [{ + completedAt: '2026-09-11T06:56:55.997Z', + id: 'run-newest', + score: 92, + status: 'SUCCESS', + workflow: { + id: 'nested-workflow-id', + name: '[AWS:Claude-Haiku-4.5] - Submission Requirements Workflow', + scorecard: { minimumPassingScore: 75 }, + }, + workflowId: 'returned-top-level-workflow-id', + }], + 'older-submission': [{ + completedAt: '2026-09-10T06:56:55.997Z', + id: 'run-older', + score: 60, + status: 'SUCCESS', + workflow: { + name: 'Older AI workflow', + scorecard: { minimumPassingScore: 75 }, + }, + workflowId: 'older-workflow-id', + }], + } + + renderPage() + fireEvent.click(screen.getByRole('tab', { name: 'My Submissions' })) + + const newestToggle = screen.getByRole('button', { + name: 'Collapse AI review details for submission newest-submission', + }) + expect(newestToggle) + .toHaveAttribute('aria-expanded', 'true') + expect(screen.getByRole('button', { + name: 'Expand AI review details for submission older-submission', + })) + .toHaveAttribute('aria-expanded', 'false') + const newestDetails = screen.getByRole('table', { + name: 'AI review details for submission newest-submission', + }) + const detailHeaders = ['AI Reviewer', 'Review Date', 'Score', 'Result'] + detailHeaders.forEach(header => { + expect(within(newestDetails) + .getByRole('columnheader', { name: header })) + .toBeInTheDocument() + }) + expect(within(newestDetails) + .getByText('PASSED')) + .toBeInTheDocument() + expect(within(newestDetails) + .getByRole('link', { name: '92' })) + .toHaveAttribute( + 'href', + 'https://review.topcoder-dev.com/active-challenges/challenge-id/reviews/newest-submission' + + '?workflowId=returned-top-level-workflow-id', + ) + const workflowRequest = mockUseSWR.mock.calls.find(([key]) => ( + Array.isArray(key) + && key[0] === 'opportunities:submission-ai-workflow-runs' + && key[1] === 'newest-submission' + )) + const refreshInterval = workflowRequest?.[2]?.refreshInterval as ( + runs: Array<{ status?: string }> + ) => number + expect(refreshInterval([])) + .toBe(10000) + expect(refreshInterval([{ status: 'SUCCESS' }])) + .toBe(0) + + fireEvent.click(screen.getByRole('button', { + name: 'Expand AI review details for submission older-submission', + })) + + expect(screen.getByRole('table', { + name: 'AI review details for submission newest-submission', + })) + .toBeInTheDocument() + const olderDetails = screen.getByRole('table', { + name: 'AI review details for submission older-submission', + }) + expect(within(olderDetails) + .getByText('FAILED')) + .toBeInTheDocument() + + fireEvent.click(screen.getByRole('button', { + name: 'Collapse AI review details for submission older-submission', + })) + expect(screen.queryByRole('table', { + name: 'AI review details for submission older-submission', + })) + .not.toBeInTheDocument() + expect(screen.getByRole('table', { + name: 'AI review details for submission newest-submission', + })) + .toBeInTheDocument() + }) + + it('handles missing AI workflow status without crashing', () => { + expect(submissionAiWorkflowRunResult({ id: 'run-without-status' })) + .toEqual({ kind: 'status', label: 'UNKNOWN' }) + expect(submissionAiWorkflowRunResult({ id: 'legacy-failure', status: 'FAILED' })) + .toEqual({ kind: 'failed', label: 'FAILED' }) + }) + it('renders and deletes the compact Design My Submissions actions', async () => { mockProfile = { handle: 'coder', userId: 123 } mockRegistration = { id: 'resource-id' } diff --git a/src/apps/opportunities/src/pages/ChallengeDetailsPage.module.scss b/src/apps/opportunities/src/pages/ChallengeDetailsPage.module.scss index f0e705399..57e1092f0 100644 --- a/src/apps/opportunities/src/pages/ChallengeDetailsPage.module.scss +++ b/src/apps/opportunities/src/pages/ChallengeDetailsPage.module.scss @@ -543,6 +543,17 @@ } } +.expandedChevron { + transform: rotate(180deg); +} + +.aiReviewDetailsRow { + > td { + height: auto; + padding: 0; + } +} + .reviewAppActionIcon { background: currentColor; display: inline-block; @@ -1248,6 +1259,21 @@ font-weight: 700; } } + + .aiReviewDetailsRow { + border-bottom: 0; + margin-top: -12px; + padding-bottom: 0; + + > td { + display: block; + padding: 0; + + &::before { + content: none; + } + } + } } .ratedRegistrantTable, diff --git a/src/apps/opportunities/src/pages/ChallengeDetailsPage.tsx b/src/apps/opportunities/src/pages/ChallengeDetailsPage.tsx index dbdf8eb56..7e5dd8825 100644 --- a/src/apps/opportunities/src/pages/ChallengeDetailsPage.tsx +++ b/src/apps/opportunities/src/pages/ChallengeDetailsPage.tsx @@ -1,6 +1,7 @@ /* eslint-disable no-use-before-define, react/jsx-no-bind */ import { FC, + Fragment, KeyboardEvent, ReactNode, SyntheticEvent, @@ -44,6 +45,7 @@ import { SubmissionArtifactsModal, SubmissionHistoryModal, } from '../components' +import { SubmissionAiReviewDetails } from '../components/SubmissionAiReviewDetails' import { challengeCatalogKey, ChallengePlacementPrize, @@ -1234,12 +1236,23 @@ const SubmissionsTab: FC = props => { const [sortOrder, setSortOrder] = useState<'asc' | 'desc'>('desc') const [artifactsSubmissionId, setArtifactsSubmissionId] = useState() const [historySubmission, setHistorySubmission] = useState() + const [aiSubmissionExpansionOverrides, setAiSubmissionExpansionOverrides] + = useState>({}) const [deletingSubmissionId, setDeletingSubmissionId] = useState() const [downloadingSubmissionId, setDownloadingSubmissionId] = useState() const trackKey = challengeCatalogKey(props.challenge.track) const isDesign = trackKey === 'design' const isQa = trackKey === 'qualityassurance' const isMarathonMatch = isMarathonMatchChallenge(props.challenge) + const hasAiWorkflow = !isMarathonMatch && ( + props.challenge.reviewers?.some(reviewer => !!reviewer.aiWorkflowId?.trim()) === true + || [ + ...(props.challenge.currentPhaseNames ?? []), + ...(props.challenge.phases ?? []).map(phase => phase.name), + ].some(name => name.replace(/[^a-z0-9]/gi, '') + .toLowerCase() + .includes('aireview')) + ) const refreshActiveMySubmissions = !!props.mine && props.challenge.status?.toUpperCase() === 'ACTIVE' const privateDesignSubmissions = isDesign @@ -1326,6 +1339,21 @@ const SubmissionsTab: FC = props => { props.challenge, submissions, ) + /** + * Toggles one submission's AI details without changing other expanded rows. + * + * @param submissionId selected Review API submission identifier. + * @param expanded current disclosure state for the selected row. + * @returns void after updating the controlled disclosure state. + * @throws Does not throw. + */ + const toggleAiSubmission = (submissionId: string, expanded: boolean): void => { + setAiSubmissionExpansionOverrides(current => ({ + ...current, + [submissionId]: !expanded, + })) + } + /** * Opens an authorized clean-storage download without exposing private URLs in list data. * @@ -1514,141 +1542,181 @@ const SubmissionsTab: FC = props => { : '' const designDeletionAllowed = isDesign && challengeAllowsDesignSubmissionDeletion(props.challenge, submission) + const aiDetailsId = `submission-ai-review-${submission.id}` + const aiDetailsExpanded = hasAiWorkflow && ( + aiSubmissionExpansionOverrides[submission.id] + ?? submission.id === submissions[0]?.id + ) return ( - - - {submission.id} - - {!isMarathonMatch && ( - - {submissionTypeLabel(submission.type)} + + + + {submission.id} - )} - - {formatTimestamp(submission.submittedDate ?? submission.createdAt)} - - {isMarathonMatch ? ( - <> - - {progress.process ?? '—'} + {!isMarathonMatch && ( + + {submissionTypeLabel(submission.type)} - - - {progress.status ?? '—'} - - - -
- - - - - {progress.progress === undefined - ? '—' - : `${Math.round(progress.progress)}%`} + )} + + {formatTimestamp(submission.submittedDate ?? submission.createdAt)} + + {isMarathonMatch ? ( + <> + + {progress.process ?? '—'} + + + + {progress.status ?? '—'} -
- - - {formatMarathonScore(scores.finalScore, '-')} - - - {formatMarathonScore(scores.provisionalScore, 'N/A')} - - - ) : !isDesign && !isQa ? ( - <> - - - {submissionStatusLabel(submission, props.challenge)} - - - - {formatMarathonScore( - scores.finalScore ?? scores.provisionalScore, - '-', - )} - - - ) : undefined} - -
- {submission.isFileSubmission !== false && ( - - )} - {isMarathonMatch && ( - - )} - {isDesign && ( - + )} + {isMarathonMatch && ( + + )} + {isDesign && ( + - )} - {!isMarathonMatch && ( - - - )} - {!isDesign && !isQa && ( - - )} -
- - + onClick={() => removeSubmission(submission)} + title={designDeletionAllowed + ? 'Delete' + : 'Submission deletion is closed'} + type='button' + > +
) })} diff --git a/src/apps/opportunities/src/services/opportunities.service.spec.ts b/src/apps/opportunities/src/services/opportunities.service.spec.ts index a7c7c9d25..a57a6b90c 100644 --- a/src/apps/opportunities/src/services/opportunities.service.spec.ts +++ b/src/apps/opportunities/src/services/opportunities.service.spec.ts @@ -19,6 +19,7 @@ import { getChallengeMemberResource, getChallengeProjectResults, getChallengeReviewSummations, + getChallengeSubmissionAiWorkflowRuns, getChallengeSubmissionHistory, getChallengeSubmissionArtifacts, getChallengeSubmissionPreviews, @@ -1420,6 +1421,24 @@ describe('opportunities service normalization', () => { ) }) + it('loads AI workflow runs for the encoded member submission', async () => { + const get = xhrGetAsync as jest.MockedFunction + const runs = [{ + id: 'workflow-run', + status: 'SUCCESS', + submissionId: 'submission/id', + workflowId: 'workflow-id', + }] + get.mockResolvedValueOnce(runs) + + await expect(getChallengeSubmissionAiWorkflowRuns('submission/id')) + .resolves.toEqual(runs) + expect(get) + .toHaveBeenLastCalledWith( + 'https://api.example/v6/workflows/runs?submissionId=submission%2Fid', + ) + }) + it('loads supported Review API artifact envelopes and downloads an encoded artifact', async () => { const get = xhrGetAsync as jest.MockedFunction const getBlob = xhrGetBlobAsync as jest.MockedFunction diff --git a/src/apps/opportunities/src/services/opportunities.service.ts b/src/apps/opportunities/src/services/opportunities.service.ts index b9d9d8a18..d8d407403 100644 --- a/src/apps/opportunities/src/services/opportunities.service.ts +++ b/src/apps/opportunities/src/services/opportunities.service.ts @@ -26,6 +26,7 @@ import { ChallengeResourceRole, ChallengeReviewSummation, ChallengeSubmission, + ChallengeSubmissionAiWorkflowRun, ChallengeSubmissionType, ChallengeTerm, CopilotOpportunity, @@ -1627,6 +1628,23 @@ export async function getChallengeSubmissions( return normalizeSubmissionPage(response, page, perPage) } +/** + * Loads the AI workflow runs associated with one member submission. + * + * Opportunities uses these records for the expandable My Submissions review table. + * + * @param submissionId Review API submission identifier. + * @returns workflow runs in Review API order. + * @throws Propagates Review API, authorization, and network errors. + */ +export async function getChallengeSubmissionAiWorkflowRuns( + submissionId: string, +): Promise { + return xhrGetAsync( + `${V6_URL}/workflows/runs?submissionId=${encodeURIComponent(submissionId)}`, + ) +} + /** * Requests the authorized short-lived URL for a submission download action. * diff --git a/src/apps/opportunities/src/utils/challenge-detail.utils.spec.ts b/src/apps/opportunities/src/utils/challenge-detail.utils.spec.ts index 9b9d839d9..c147fdb59 100644 --- a/src/apps/opportunities/src/utils/challenge-detail.utils.spec.ts +++ b/src/apps/opportunities/src/utils/challenge-detail.utils.spec.ts @@ -9,6 +9,7 @@ import { challengeSubmissionMode, challengeSubmissionLimit, memberProfileUrl, + submissionAiReviewAppUrl, } from './challenge-detail.utils' jest.mock('~/config', () => ({ @@ -48,6 +49,17 @@ describe('challenge detail utilities', () => { .toBe('https://review.example/active-challenges/challenge-id/challenge-details') }) + it('builds encoded Review App links for one AI workflow run', () => { + expect(submissionAiReviewAppUrl( + 'challenge with/slash', + 'submission with/slash', + 'workflow with/slash', + 'https://review.example/', + )) + .toBe('https://review.example/active-challenges/challenge%20with%2Fslash' + + '/reviews/submission%20with%2Fslash?workflowId=workflow%20with%2Fslash') + }) + it('builds encoded links on the configured Profiles app host', () => { expect(memberProfileUrl('handle with/slash')) .toBe('https://profiles.topcoder-dev.com/handle%20with%2Fslash') diff --git a/src/apps/opportunities/src/utils/challenge-detail.utils.ts b/src/apps/opportunities/src/utils/challenge-detail.utils.ts index 869ef8af1..09e82626d 100644 --- a/src/apps/opportunities/src/utils/challenge-detail.utils.ts +++ b/src/apps/opportunities/src/utils/challenge-detail.utils.ts @@ -34,6 +34,29 @@ export function challengeReviewAppUrl( + `/active-challenges/${encodeURIComponent(challengeId)}/challenge-details` } +/** + * Builds the Review App destination for one AI workflow run and submission. + * + * @param challengeId Challenge API UUID. + * @param submissionId Review API submission identifier. + * @param workflowId workflow identifier returned at the top level of the run. + * @param reviewAppUrl configured Review App origin, optionally overridden by tests. + * @returns absolute, safely encoded workflow-review URL. + * @throws Does not throw. + */ +export function submissionAiReviewAppUrl( + challengeId: string, + submissionId: string, + workflowId: string, + reviewAppUrl: string = EnvironmentConfig.REVIEW_APP_URL + ?? `https://review.${EnvironmentConfig.TC_DOMAIN}`, +): string { + return `${reviewAppUrl.replace(/\/+$/, '')}` + + `/active-challenges/${encodeURIComponent(challengeId)}` + + `/reviews/${encodeURIComponent(submissionId)}` + + `?workflowId=${encodeURIComponent(workflowId)}` +} + /** * Builds a member profile URL on the environment-specific Profiles app. *