From 444955b459e3f6642add3d4131dea906e6f2ec7d Mon Sep 17 00:00:00 2001 From: jmgasper Date: Wed, 22 Apr 2026 09:54:40 +1000 Subject: [PATCH] Better handling of > 500 submissions --- __tests__/shared/services/submissions.js | 107 ++++++++++++++++++ .../shared/utils/mm-review-summations.test.js | 57 ++++++++++ .../containers/challenge-detail/index.jsx | 31 ++++- src/shared/services/submissions.js | 96 +++++++++++++++- src/shared/utils/mm-review-summations.js | 19 +++- 5 files changed, 297 insertions(+), 13 deletions(-) create mode 100644 __tests__/shared/services/submissions.js diff --git a/__tests__/shared/services/submissions.js b/__tests__/shared/services/submissions.js new file mode 100644 index 000000000..d05820bfa --- /dev/null +++ b/__tests__/shared/services/submissions.js @@ -0,0 +1,107 @@ +/* eslint-env jest */ +import { config } from 'topcoder-react-utils'; +import { getChallengeSubmissions } from '../../../src/shared/services/submissions'; + +const baseUrl = `${config.API.V6}/submissions`; + +describe('submissions service', () => { + const originalFetch = global.fetch; + const originalHeaders = global.Headers; + + beforeAll(() => { + if (!global.Headers) { + global.Headers = class HeadersMock { + constructor() { + this.values = {}; + } + + set(key, value) { + this.values[key] = value; + } + + get(key) { + return this.values[key]; + } + }; + } + }); + + afterAll(() => { + global.fetch = originalFetch; + global.Headers = originalHeaders; + }); + + beforeEach(() => { + global.fetch = jest.fn(); + }); + + it('loads every submissions page reported by metadata', async () => { + global.fetch + .mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve({ + data: [{ id: 'submission-page-1' }], + meta: { + page: 1, + perPage: 100, + totalPages: 2, + totalItems: 101, + }, + }), + }) + .mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve({ + data: [{ id: 'submission-page-2' }], + meta: { + page: 2, + perPage: 100, + totalPages: 2, + totalItems: 101, + }, + }), + }); + + const result = await getChallengeSubmissions('token-v3', 'challenge-id'); + + expect(global.fetch).toHaveBeenCalledTimes(2); + expect(global.fetch).toHaveBeenNthCalledWith( + 1, + `${baseUrl}?challengeId=challenge-id&perPage=500&page=1`, + expect.objectContaining({ method: 'GET' }), + ); + expect(global.fetch).toHaveBeenNthCalledWith( + 2, + `${baseUrl}?challengeId=challenge-id&perPage=500&page=2`, + expect.objectContaining({ method: 'GET' }), + ); + expect(result.data).toEqual([ + { id: 'submission-page-1' }, + { id: 'submission-page-2' }, + ]); + expect(result.meta).toEqual(expect.objectContaining({ + page: 2, + perPage: 500, + totalItems: 2, + totalPages: 2, + })); + }); + + it('uses the short-page heuristic when total pages are absent', async () => { + global.fetch.mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve({ + data: [{ id: 'submission-only-page' }], + meta: { + page: 1, + perPage: 100, + }, + }), + }); + + const result = await getChallengeSubmissions(null, 'challenge-id'); + + expect(global.fetch).toHaveBeenCalledTimes(1); + expect(result.data).toEqual([{ id: 'submission-only-page' }]); + }); +}); diff --git a/__tests__/shared/utils/mm-review-summations.test.js b/__tests__/shared/utils/mm-review-summations.test.js index a6d721896..02c4524c9 100644 --- a/__tests__/shared/utils/mm-review-summations.test.js +++ b/__tests__/shared/utils/mm-review-summations.test.js @@ -125,4 +125,61 @@ describe('buildMmSubmissionData', () => { }), ]); }); + + it('uses v6 submitter fields and submittedDate for imported raw submissions', () => { + const rawSubmissions = [ + { + createdAt: '2026-04-09T05:00:55.279Z', + createdBy: 'historical-mm-importer', + finalScore: '7186.79', + id: 'submission-imported', + isLatest: true, + memberId: '16064986', + submittedDate: '2006-05-16T10:31:42.790Z', + submitterHandle: 'ctrucza', + submitterMaxRating: 1228, + }, + ]; + + const result = buildMmSubmissionData([], rawSubmissions); + + expect(result).toHaveLength(1); + expect(result[0]).toEqual(expect.objectContaining({ + member: 'ctrucza', + memberId: '16064986', + rating: 1228, + })); + expect(result[0].submissions).toEqual([ + expect.objectContaining({ + finalScore: 7186.79, + submissionId: 'submission-imported', + submissionTime: '2006-05-16T10:31:42.790Z', + }), + ]); + }); + + it('uses reviewedDate before import createdAt for review summation times', () => { + const reviewSummations = [ + { + aggregateScore: 7186.79, + createdAt: '2026-04-21T02:55:21.255Z', + id: 'summation-imported', + isFinal: true, + reviewedDate: '2006-05-16T10:31:42.790Z', + submissionId: 'submission-reviewed', + submitterHandle: 'ctrucza', + submitterId: '16064986', + }, + ]; + + const result = buildMmSubmissionData(reviewSummations); + + expect(result[0].submissions).toEqual([ + expect.objectContaining({ + finalScore: 7186.79, + submissionId: 'submission-reviewed', + submissionTime: '2006-05-16T10:31:42.790Z', + }), + ]); + }); }); diff --git a/src/shared/containers/challenge-detail/index.jsx b/src/shared/containers/challenge-detail/index.jsx index 9bd40228c..698e70fe7 100644 --- a/src/shared/containers/challenge-detail/index.jsx +++ b/src/shared/containers/challenge-detail/index.jsx @@ -54,7 +54,10 @@ import MetaTags from 'components/MetaTags'; import { decodeToken } from '@topcoder-platform/tc-auth-lib'; import { actions, errors, services } from 'topcoder-react-lib'; import { getService } from 'services/contentful'; -import { getSubmissionArtifacts as getSubmissionArtifactsService } from 'services/submissions'; +import { + getChallengeSubmissions as getChallengeSubmissionsService, + getSubmissionArtifacts as getSubmissionArtifactsService, +} from 'services/submissions'; import getReviewSummationsService from 'services/reviewSummations'; import { buildMmSubmissionData, buildStatisticsData } from 'utils/mm-review-summations'; import { appendUtmParamsToUrl } from 'utils/utm'; @@ -931,6 +934,9 @@ function extractArrayFromStateSlice(slice, challengeId) { return slice; } if (slice && Array.isArray(slice.data)) { + if (slice.challengeId && _.toString(slice.challengeId) !== _.toString(challengeId)) { + return []; + } return slice.data; } const key = challengeId ? String(challengeId) : null; @@ -1159,8 +1165,10 @@ function mapStateToProps(state, props) { ? challenge.submissions : (_.get(challenge, 'submissions.data') || []); let mmSubmissions = extractArrayFromStateSlice(state.challenge.mmSubmissions, challengeId); - if (reviewSummations.length || rawChallengeSubmissions.length) { + if (reviewSummations.length) { mmSubmissions = buildMmSubmissionData(reviewSummations, rawChallengeSubmissions); + } else if (!mmSubmissions.length && rawChallengeSubmissions.length) { + mmSubmissions = buildMmSubmissionData([], rawChallengeSubmissions); } const { auth } = state; let statisticsData = extractArrayFromStateSlice(state.challenge.statisticsData, challengeId); @@ -1513,9 +1521,17 @@ const mapDispatchToProps = (dispatch) => { }); } - getReviewSummationsService(tokenV3, challengeIdStr) - .then(({ data }) => { + const challengeSubmissionsPromise = includeMmSubmissions + ? getChallengeSubmissionsService(tokenV3, challengeIdStr) + : Promise.resolve({ data: [] }); + + Promise.all([ + getReviewSummationsService(tokenV3, challengeIdStr), + challengeSubmissionsPromise, + ]) + .then(([{ data }, { data: rawSubmissions }]) => { const reviewSummations = Array.isArray(data) ? data : []; + const rawChallengeSubmissions = Array.isArray(rawSubmissions) ? rawSubmissions : []; dispatch({ type: 'CHALLENGE/GET_REVIEW_SUMMATIONS_DONE', @@ -1523,12 +1539,15 @@ const mapDispatchToProps = (dispatch) => { meta: { challengeId: challengeIdStr }, }); if (includeMmSubmissions) { - const mmSubmissions = buildMmSubmissionData(reviewSummations); + const mmSubmissions = buildMmSubmissionData(reviewSummations, rawChallengeSubmissions); dispatch({ type: 'CHALLENGE/GET_MM_SUBMISSIONS_DONE', payload: { challengeId: challengeIdStr, - submissions: mmSubmissions, + submissions: { + challengeId: challengeIdStr, + data: mmSubmissions, + }, }, }); } diff --git a/src/shared/services/submissions.js b/src/shared/services/submissions.js index aa6351baf..4ed829d44 100644 --- a/src/shared/services/submissions.js +++ b/src/shared/services/submissions.js @@ -1,17 +1,107 @@ import { config } from 'topcoder-react-utils'; -const v5ApiUrl = config.API.V6; +const v6ApiUrl = config.API.V6; +const DEFAULT_PER_PAGE = 500; -export const downloadSubmissions = (tokenV3, submissionId, artifactId) => fetch(`${v5ApiUrl}/submissions/${submissionId}/artifacts/${artifactId}/download`, { +export const downloadSubmissions = (tokenV3, submissionId, artifactId) => fetch(`${v6ApiUrl}/submissions/${submissionId}/artifacts/${artifactId}/download`, { method: 'GET', headers: new Headers({ Authorization: `Bearer ${tokenV3}`, }), }).then(res => res.blob()); -export const getSubmissionArtifacts = (tokenV3, submissionId) => fetch(`${v5ApiUrl}/submissions/${submissionId}/artifacts`, { +export const getSubmissionArtifacts = (tokenV3, submissionId) => fetch(`${v6ApiUrl}/submissions/${submissionId}/artifacts`, { method: 'GET', headers: new Headers({ Authorization: `Bearer ${tokenV3}`, }), }).then(res => res.json()); + +function getHeaders(tokenV3) { + const headers = new Headers(); + if (tokenV3) { + headers.set('Authorization', `Bearer ${tokenV3}`); + } + return headers; +} + +async function fetchChallengeSubmissionsPage({ + tokenV3, + challengeId, + page, + perPage, + aggregated, + meta, +}) { + const url = `${v6ApiUrl}/submissions?challengeId=${encodeURIComponent(challengeId)}&perPage=${perPage}&page=${page}`; + const response = await fetch(url, { + method: 'GET', + headers: getHeaders(tokenV3), + }); + + if (!response.ok) { + const error = new Error(`Failed to fetch submissions: ${response.status} ${response.statusText}`); + error.status = response.status; + throw error; + } + + const payload = await response.json(); + const data = payload.data || []; + const combined = [...aggregated, ...data]; + const latestMeta = payload.meta || meta; + const totalPages = payload.meta && (payload.meta.totalPages || payload.meta.total_pages); + const reachedEnd = !data.length + || (totalPages ? page >= totalPages : data.length < perPage); + + if (reachedEnd) { + return { + data: combined, + meta: latestMeta, + }; + } + + return fetchChallengeSubmissionsPage({ + tokenV3, + challengeId, + page: page + 1, + perPage, + aggregated: combined, + meta: latestMeta, + }); +} + +/** + * Fetches every submission page for a challenge from the v6 submissions API. + * + * The challenge details service only embeds the first page of submissions, so + * Marathon Match views use this helper when they need complete member attempt + * history. + * + * @param {String} tokenV3 Topcoder auth token v3 used for private challenge access. + * @param {String|Number} challengeId Challenge identifier used by the submissions API. + * @param {Object} options Optional pagination settings. + * @param {Number} options.perPage Number of records requested per API page. + * @return {Promise<{data: Array, meta: Object}>} Aggregated submissions and + * final response metadata. + * @throws {Error} Throws when any submissions API page returns a non-2xx status. + */ +export async function getChallengeSubmissions(tokenV3, challengeId, options = {}) { + const { perPage = DEFAULT_PER_PAGE } = options; + const { data, meta } = await fetchChallengeSubmissionsPage({ + tokenV3, + challengeId, + page: 1, + perPage, + aggregated: [], + meta: null, + }); + + return { + data, + meta: { + ...(meta || {}), + totalItems: data.length, + perPage, + }, + }; +} diff --git a/src/shared/utils/mm-review-summations.js b/src/shared/utils/mm-review-summations.js index 27a468220..e29b476af 100644 --- a/src/shared/utils/mm-review-summations.js +++ b/src/shared/utils/mm-review-summations.js @@ -13,9 +13,9 @@ function normalizeScoreValue(score) { function getSummationTimestamp(summation) { const candidates = [ + _.get(summation, 'reviewedDate'), _.get(summation, 'createdAt'), _.get(summation, 'created'), - _.get(summation, 'reviewedDate'), _.get(summation, 'updatedAt'), ]; return _.find(candidates, value => !!value) || null; @@ -80,6 +80,7 @@ function getSummationRating(summation) { function getSubmissionHandle(submission) { const handle = _.get(submission, 'registrant.memberHandle') || _.get(submission, 'memberHandle') + || _.get(submission, 'submitterHandle') || _.get(submission, 'createdBy'); if (!handle || !_.isString(handle) || !handle.trim()) { @@ -90,21 +91,31 @@ function getSubmissionHandle(submission) { } function getSubmissionMemberId(submission) { - const memberId = _.get(submission, 'memberId', _.get(submission, 'registrant.memberId')); + const memberId = _.get( + submission, + 'memberId', + _.get(submission, 'registrant.memberId', _.get(submission, 'submitterId')), + ); return _.isNil(memberId) ? null : _.toString(memberId); } function getSubmissionRating(submission) { - const rating = _.get(submission, 'rating', _.get(submission, 'registrant.rating')); + let rating = _.get(submission, 'rating'); + if (_.isNil(rating)) { + rating = _.get(submission, 'registrant.rating'); + } + if (_.isNil(rating)) { + rating = _.get(submission, 'submitterMaxRating'); + } return _.isNil(rating) ? null : rating; } function getSubmissionTimestamp(submission) { const candidates = [ _.get(submission, 'submissionTime'), + _.get(submission, 'submittedDate'), _.get(submission, 'created'), _.get(submission, 'createdAt'), - _.get(submission, 'submittedDate'), _.get(submission, 'reviewedDate'), _.get(submission, 'updated'), _.get(submission, 'updatedAt'),