diff --git a/src/common/challenge-helper.js b/src/common/challenge-helper.js index 896f689..abe318e 100644 --- a/src/common/challenge-helper.js +++ b/src/common/challenge-helper.js @@ -806,9 +806,9 @@ class ChallengeHelper { enrichChallengeForResponse(challenge, track, type, options = {}) { if (challenge.phases && challenge.phases.length > 0) { const registrationPhase = _.find(challenge.phases, (p) => p.name === "Registration"); - const submissionPhase = - _.find(challenge.phases, (p) => p.name === SUBMISSION_PHASE_PRIORITY[0]) || - _.find(challenge.phases, (p) => p.name === SUBMISSION_PHASE_PRIORITY[1]); + const submissionPhase = _.find(challenge.phases, (p) => + _.includes(SUBMISSION_PHASE_PRIORITY, p.name) + ); // select last started open phase as current phase _.forEach(challenge.phases, (p) => { diff --git a/src/common/phase-helper.js b/src/common/phase-helper.js index 0cb8c65..0274604 100644 --- a/src/common/phase-helper.js +++ b/src/common/phase-helper.js @@ -10,6 +10,61 @@ const prisma = require("../common/prisma").getClient(); const SUBMISSION_PHASE_PRIORITY = ["Topgear Submission", "Topcoder Submission", "Submission"]; +/** + * Apply an explicit scheduled end date to a phase and update its duration. + * + * @param {Object} phase challenge phase being recalculated + * @param {Date|String} scheduledEndDate scheduled end date supplied by the update payload + * @returns {Boolean} true when the scheduled end date was applied + * @throws {BadRequestError} when the supplied end date is invalid or not after the phase start + */ +function applyScheduledEndDate(phase, scheduledEndDate) { + if (_.isNil(scheduledEndDate)) { + return false; + } + + const scheduledStart = moment(phase.scheduledStartDate); + const scheduledEnd = moment(scheduledEndDate); + + if (!scheduledEnd.isValid()) { + throw new errors.BadRequestError( + `scheduledEndDate: ${scheduledEndDate} should be a valid date` + ); + } + + if (!scheduledEnd.isAfter(scheduledStart)) { + throw new errors.BadRequestError( + `scheduledEndDate: ${scheduledEndDate} should be after scheduledStartDate: ${phase.scheduledStartDate}` + ); + } + + phase.scheduledEndDate = scheduledEnd.toDate().toISOString(); + phase.duration = scheduledEnd.diff(scheduledStart, "seconds"); + return true; +} + +/** + * Recalculate a phase's scheduled end date from either explicit input or duration. + * + * @param {Object} phase challenge phase being recalculated + * @returns {undefined} mutates the provided phase + * @throws {BadRequestError} when an explicit scheduled end date is invalid + */ +function recalculateScheduledEndDate(phase) { + if (!_.isNil(phase.actualEndDate)) { + return; + } + + if (applyScheduledEndDate(phase, phase.requestedScheduledEndDate)) { + return; + } + + phase.scheduledEndDate = moment(phase.scheduledStartDate) + .add(phase.duration, "seconds") + .toDate() + .toISOString(); +} + class ChallengePhaseHelper { phaseDefinitionMap = {}; timelineTemplateMap = {}; @@ -141,6 +196,7 @@ class ChallengePhaseHelper { ...phase, predecessor: resolvedPredecessor, description: phaseDefinition.description, + requestedScheduledEndDate: _.get(newPhase, "scheduledEndDate"), }; if (updatedPhase.name === "Post-Mortem") { updatedPhase.predecessor = "a93544bc-c165-4af4-b55e-18f3593b457a"; @@ -166,10 +222,7 @@ class ChallengePhaseHelper { } else if (_.isNil(phase.actualStartDate)) { updatedPhase.scheduledStartDate = moment(scheduledStartDate).toDate().toISOString(); } - updatedPhase.scheduledEndDate = moment(updatedPhase.scheduledStartDate) - .add(updatedPhase.duration, "seconds") - .toDate() - .toISOString(); + recalculateScheduledEndDate(updatedPhase); } if (_.isNil(phase.actualEndDate) && !_.isNil(newPhase) && !_.isNil(newPhase.constraints)) { updatedPhase.constraints = newPhase.constraints; @@ -221,14 +274,9 @@ class ChallengePhaseHelper { } else if (_.isNil(phase.actualStartDate)) { phase.scheduledStartDate = predecessorPhase.scheduledEndDate; } - if (_.isNil(phase.actualEndDate)) { - phase.scheduledEndDate = moment(phase.scheduledStartDate) - .add(phase.duration, "seconds") - .toDate() - .toISOString(); - } + recalculateScheduledEndDate(phase); } - return updatedPhases; + return _.map(updatedPhases, (phase) => _.omit(phase, "requestedScheduledEndDate")); } handlePhasesAfterCancelling(phases) { diff --git a/src/common/prisma-helper.js b/src/common/prisma-helper.js index f658f1d..14e5327 100644 --- a/src/common/prisma-helper.js +++ b/src/common/prisma-helper.js @@ -31,9 +31,7 @@ function convertChallengePhaseSchema(challenge, result, auditFields) { result.currentPhaseNames = _.map(_.filter(phases, (p) => p.isOpen === true), "name"); const registrationPhase = _.find(phases, (p) => p.name === "Registration"); - const submissionPhase = - _.find(phases, (p) => p.name === SUBMISSION_PHASE_PRIORITY[0]) || - _.find(phases, (p) => p.name === SUBMISSION_PHASE_PRIORITY[1]); + const submissionPhase = _.find(phases, (p) => _.includes(SUBMISSION_PHASE_PRIORITY, p.name)); if (registrationPhase) { result.registrationStartDate = diff --git a/src/services/ChallengeService.js b/src/services/ChallengeService.js index 59347ad..f741e08 100644 --- a/src/services/ChallengeService.js +++ b/src/services/ChallengeService.js @@ -63,6 +63,7 @@ const CHALLENGE_APPROVAL_ACTION_STATUSES = new Set([ ]); const DEFAULT_ESTIMATED_SUBMISSIONS_COUNT = 2; +const CHECKPOINT_SUBMISSION_TYPE = "CHECKPOINT_SUBMISSION"; // Provide aliases for friendlier sortBy query params const sortByAliases = { @@ -96,6 +97,96 @@ function isCancelledChallengeStatus(status) { return CANCELLED_CHALLENGE_STATUSES.has(status); } +/** + * Loads submission counters for challenge responses from the review submission table. + * + * Community app badges show the number of members with submissions, not the + * number of attempts, so repeated uploads by the same member are counted once. + * + * @param {Array} challengeIds challenge identifiers to count submissions for + * @returns {Promise>} + * counts keyed by challenge id + * @throws {Error} when the review database query fails + */ +async function getLatestSubmissionCountsByChallenge(challengeIds) { + const ids = _.uniq( + (challengeIds || []) + .map((challengeId) => _.toString(challengeId).trim()) + .filter((challengeId) => !!challengeId), + ); + const countsByChallenge = new Map(); + + if (!ids.length || !config.REVIEW_DB_URL) { + return countsByChallenge; + } + + const reviewSchema = _.toString(config.REVIEW_DB_SCHEMA || "").trim(); + const submissionTable = reviewSchema + ? Prisma.raw(`"${reviewSchema.replace(/"/g, '""')}"."submission"`) + : Prisma.raw('"submission"'); + const reviewClient = getReviewClient(); + + const rows = await reviewClient.$queryRaw` + SELECT + "challengeId", + COUNT(DISTINCT CASE + WHEN "type"::text = ${CHECKPOINT_SUBMISSION_TYPE} THEN "memberId" + END)::int AS "numOfCheckpointSubmissions", + COUNT(DISTINCT CASE + WHEN "type"::text <> ${CHECKPOINT_SUBMISSION_TYPE} THEN "memberId" + END)::int AS "numOfSubmissions" + FROM ${submissionTable} + WHERE "challengeId" IN (${Prisma.join(ids)}) + AND "memberId" IS NOT NULL + GROUP BY "challengeId" + `; + + rows.forEach((row) => { + countsByChallenge.set(_.toString(row.challengeId), { + numOfSubmissions: Number(row.numOfSubmissions || 0), + numOfCheckpointSubmissions: Number(row.numOfCheckpointSubmissions || 0), + }); + }); + + return countsByChallenge; +} + +/** + * Applies latest-member submission counts to challenge records before response conversion. + * + * If the review query succeeds, challenges without submission rows are reset to + * zero so stale stored counters are not shown. If the query cannot run, callers + * keep the stored counters and log a warning instead of failing challenge reads. + * + * @param {Array} challenges challenge records being returned by the API + * @returns {Promise} + */ +async function applyLatestSubmissionCounts(challenges) { + const records = (challenges || []).filter((challenge) => challenge && challenge.id); + if (!records.length || !config.REVIEW_DB_URL) { + return; + } + + let countsByChallenge; + try { + countsByChallenge = await getLatestSubmissionCountsByChallenge( + records.map((challenge) => challenge.id), + ); + } catch (err) { + logger.warn(`Failed to load latest submission counts: ${err.message}`); + return; + } + + records.forEach((challenge) => { + const counts = countsByChallenge.get(_.toString(challenge.id)) || { + numOfSubmissions: 0, + numOfCheckpointSubmissions: 0, + }; + challenge.numOfSubmissions = counts.numOfSubmissions; + challenge.numOfCheckpointSubmissions = counts.numOfCheckpointSubmissions; + }); +} + function normalizeStatusSortValue(statusValue) { if (_.isNil(statusValue)) { return null; @@ -1977,6 +2068,8 @@ async function searchChallenges(currentUser, criteria) { }); } + await applyLatestSubmissionCounts(challenges); + challenges.forEach((challenge) => { prismaHelper.convertModelToResponse(challenge); }); @@ -2754,6 +2847,8 @@ async function getChallenge(currentUser, id, checkIfExists) { _.unset(challenge, "payments"); } + await applyLatestSubmissionCounts([challenge]); + prismaHelper.convertModelToResponse(challenge); // Enrich skills data with full details from standardized skills API @@ -4252,6 +4347,7 @@ updateChallenge.schema = { isOpen: Joi.boolean(), actualEndDate: Joi.date().allow(null), scheduledStartDate: Joi.date().allow(null), + scheduledEndDate: Joi.date().allow(null), constraints: Joi.array() .items( Joi.object() @@ -4774,7 +4870,13 @@ function sanitizeChallenge(challenge) { } if (challenge.phases) { sanitized.phases = _.map(challenge.phases, (phase) => - _.pick(phase, ["phaseId", "duration", "scheduledStartDate", "constraints"]), + _.pick(phase, [ + "phaseId", + "duration", + "scheduledStartDate", + "scheduledEndDate", + "constraints", + ]), ); } if (challenge.prizeSets) { @@ -5297,6 +5399,8 @@ module.exports = { shouldSkipChallengeApprovalFlow, syncChallengeBillingAccountLock, validateChallengeActivationBillingAccount, + getLatestSubmissionCountsByChallenge, + applyLatestSubmissionCounts, }, searchChallenges, createChallenge, diff --git a/test/unit/ChallengeService.test.js b/test/unit/ChallengeService.test.js index f3a899c..a7c23c3 100644 --- a/test/unit/ChallengeService.test.js +++ b/test/unit/ChallengeService.test.js @@ -24,6 +24,7 @@ const { getReviewClient } = require("../../src/common/review-prisma"); const prisma = getClient(); const reviewSchema = config.get("REVIEW_DB_SCHEMA"); const reviewTableName = `"${reviewSchema}"."review"`; +const submissionTableName = `"${reviewSchema}"."submission"`; const should = chai.should(); let reviewClient; @@ -78,6 +79,31 @@ describe("challenge service unit tests", () => { ADD COLUMN IF NOT EXISTS "scorecardId" varchar(255) `); await reviewClient.$executeRawUnsafe(`DELETE FROM ${reviewTableName}`); + await reviewClient.$executeRawUnsafe(` + CREATE TABLE IF NOT EXISTS ${submissionTableName} ( + "id" varchar(64) PRIMARY KEY, + "challengeId" varchar(255), + "memberId" varchar(255), + "type" varchar(64), + "status" varchar(64), + "submittedDate" timestamp, + "createdAt" timestamp DEFAULT now(), + "updatedAt" timestamp DEFAULT now() + ) + `); + await reviewClient.$executeRawUnsafe(` + ALTER TABLE ${submissionTableName} + ADD COLUMN IF NOT EXISTS "challengeId" varchar(255) + `); + await reviewClient.$executeRawUnsafe(` + ALTER TABLE ${submissionTableName} + ADD COLUMN IF NOT EXISTS "memberId" varchar(255) + `); + await reviewClient.$executeRawUnsafe(` + ALTER TABLE ${submissionTableName} + ADD COLUMN IF NOT EXISTS "type" varchar(64) + `); + await reviewClient.$executeRawUnsafe(`DELETE FROM ${submissionTableName}`); testChallengeData = { typeId: data.challenge.typeId, @@ -622,6 +648,58 @@ describe("challenge service unit tests", () => { should.equal(result.numOfRegistrants, 0); }); + it("returns latest-member submission counters for challenge detail and listing", async () => { + const challengeId = data.challenge.id; + await prisma.challenge.update({ + where: { id: challengeId }, + data: { + numOfSubmissions: 5, + numOfCheckpointSubmissions: 2, + }, + }); + + await reviewClient.$executeRawUnsafe(` + INSERT INTO ${submissionTableName} + ("id", "challengeId", "memberId", "type", "status", "submittedDate") + VALUES + ('pm4831a1', '${challengeId}', 'member-1', 'CONTEST_SUBMISSION', 'ACTIVE', '2026-01-01T00:00:00Z'), + ('pm4831a2', '${challengeId}', 'member-1', 'CONTEST_SUBMISSION', 'ACTIVE', '2026-01-02T00:00:00Z'), + ('pm4831b1', '${challengeId}', 'member-2', 'CONTEST_SUBMISSION', 'ACTIVE', '2026-01-03T00:00:00Z'), + ('pm4831c1', '${challengeId}', 'member-3', 'CONTEST_SUBMISSION', 'ACTIVE', '2026-01-04T00:00:00Z'), + ('pm4831d1', '${challengeId}', 'member-4', 'CHECKPOINT_SUBMISSION', 'ACTIVE', '2026-01-05T00:00:00Z'), + ('pm4831d2', '${challengeId}', 'member-4', 'CHECKPOINT_SUBMISSION', 'ACTIVE', '2026-01-06T00:00:00Z') + `); + + try { + const detail = await service.getChallenge({ isMachine: true }, challengeId); + should.equal(detail.numOfSubmissions, 3); + should.equal(detail.numOfCheckpointSubmissions, 1); + + const listing = await service.searchChallenges( + { isMachine: true }, + { + id: challengeId, + page: 1, + perPage: 10, + }, + ); + should.equal(listing.result.length, 1); + should.equal(listing.result[0].numOfSubmissions, 3); + should.equal(listing.result[0].numOfCheckpointSubmissions, 1); + } finally { + await reviewClient.$executeRawUnsafe( + `DELETE FROM ${submissionTableName} WHERE "challengeId" = '${challengeId}'`, + ); + await prisma.challenge.update({ + where: { id: challengeId }, + data: { + numOfSubmissions: 0, + numOfCheckpointSubmissions: 0, + }, + }); + } + }); + it("get challenge preserves billing for project write users", async () => { const originalUserHasProjectWriteAccess = helper.userHasProjectWriteAccess; diff --git a/test/unit/challenge-helper.test.js b/test/unit/challenge-helper.test.js new file mode 100644 index 0000000..4f508a3 --- /dev/null +++ b/test/unit/challenge-helper.test.js @@ -0,0 +1,33 @@ +const chai = require('chai') + +const challengeHelper = require('../../src/common/challenge-helper') + +chai.should() + +describe('challenge response helper', () => { + it('enriches submission dates from the standard Submission phase', () => { + const submissionStartDate = '2026-05-22T08:00:00.000Z' + const submissionEndDate = '2026-05-27T08:00:00.000Z' + const challenge = { + phases: [ + { + name: 'Registration', + phaseId: 'registration-phase', + scheduledEndDate: '2026-05-27T08:00:00.000Z', + scheduledStartDate: '2026-05-22T08:00:00.000Z' + }, + { + name: 'Submission', + phaseId: 'submission-phase', + scheduledEndDate: submissionEndDate, + scheduledStartDate: submissionStartDate + } + ] + } + + challengeHelper.enrichChallengeForResponse(challenge) + + challenge.submissionStartDate.should.equal(submissionStartDate) + challenge.submissionEndDate.should.equal(submissionEndDate) + }) +}) diff --git a/test/unit/phase-helper.test.js b/test/unit/phase-helper.test.js new file mode 100644 index 0000000..dc3e84c --- /dev/null +++ b/test/unit/phase-helper.test.js @@ -0,0 +1,166 @@ +const chai = require('chai') + +const phaseHelper = require('../../src/common/phase-helper') + +chai.should() + +describe('phase helper unit tests', () => { + const originalGetTemplateAndTemplateMap = phaseHelper.getTemplateAndTemplateMap + const originalGetPhaseDefinitionsAndMap = phaseHelper.getPhaseDefinitionsAndMap + + afterEach(() => { + phaseHelper.getTemplateAndTemplateMap = originalGetTemplateAndTemplateMap + phaseHelper.getPhaseDefinitionsAndMap = originalGetPhaseDefinitionsAndMap + }) + + /** + * Install phase/template lookup stubs for schedule recalculation tests. + * + * @param {Array} phaseDefinitions phase records returned by phase lookup. + * @param {Array} templatePhases template phase records returned by timeline lookup. + * @returns {undefined} mutates the shared helper singleton for the current test. + */ + function stubPhaseLookups (phaseDefinitions, templatePhases) { + const phaseDefinitionMap = new Map(phaseDefinitions.map((phase) => [phase.id, phase])) + const timelineTemplateMap = new Map(templatePhases.map((phase) => [phase.phaseId, phase])) + + phaseHelper.getPhaseDefinitionsAndMap = async () => ({ + phaseDefinitionMap, + phaseDefinitions + }) + phaseHelper.getTemplateAndTemplateMap = async () => ({ + timelineTemplateMap, + timelineTempate: templatePhases + }) + } + + it('uses scheduled end dates from update payload when recalculating phases', async () => { + const registrationPhaseId = 'registration-phase' + const submissionPhaseId = 'submission-phase' + const staleDuration = 24 * 60 * 60 + const registrationStartDate = '2026-05-26T05:14:00.000Z' + const registrationEndDate = '2026-05-29T05:14:00.000Z' + const submissionEndDate = '2026-06-02T05:14:00.000Z' + + stubPhaseLookups( + [ + { id: registrationPhaseId, name: 'Registration', description: 'Registration phase' }, + { id: submissionPhaseId, name: 'Submission', description: 'Submission phase' } + ], + [ + { phaseId: registrationPhaseId, defaultDuration: staleDuration }, + { + phaseId: submissionPhaseId, + predecessor: registrationPhaseId, + defaultDuration: staleDuration + } + ] + ) + + const updatedPhases = await phaseHelper.populatePhasesForChallengeUpdate( + [ + { + duration: staleDuration, + name: 'Registration', + phaseId: registrationPhaseId, + scheduledStartDate: registrationStartDate, + scheduledEndDate: '2026-05-27T05:14:00.000Z' + }, + { + duration: staleDuration, + name: 'Submission', + phaseId: submissionPhaseId, + predecessor: registrationPhaseId, + scheduledStartDate: '2026-05-27T05:14:00.000Z', + scheduledEndDate: '2026-05-28T05:14:00.000Z' + } + ], + [ + { + duration: staleDuration, + phaseId: registrationPhaseId, + scheduledEndDate: registrationEndDate, + scheduledStartDate: registrationStartDate + }, + { + duration: staleDuration, + phaseId: submissionPhaseId, + scheduledEndDate: submissionEndDate + } + ], + 'timeline-template-id', + false + ) + + updatedPhases[0].scheduledEndDate.should.equal(registrationEndDate) + updatedPhases[0].duration.should.equal(3 * 24 * 60 * 60) + updatedPhases[1].scheduledStartDate.should.equal(registrationEndDate) + updatedPhases[1].scheduledEndDate.should.equal(submissionEndDate) + updatedPhases[1].duration.should.equal(4 * 24 * 60 * 60) + }) + + it('uses scheduled end dates from update payload for MM phases', async () => { + const registrationPhaseId = 'mm-registration-phase' + const submissionPhaseId = 'mm-submission-phase' + const staleDuration = 12 * 60 * 60 + const registrationStartDate = '2026-06-01T00:00:00.000Z' + const registrationEndDate = '2026-06-03T00:00:00.000Z' + const submissionEndDate = '2026-06-06T00:00:00.000Z' + + stubPhaseLookups( + [ + { id: registrationPhaseId, name: 'MM Registration', description: 'MM Registration phase' }, + { id: submissionPhaseId, name: 'MM Submission', description: 'MM Submission phase' } + ], + [ + { phaseId: registrationPhaseId, defaultDuration: staleDuration }, + { + phaseId: submissionPhaseId, + predecessor: registrationPhaseId, + defaultDuration: staleDuration + } + ] + ) + + const updatedPhases = await phaseHelper.populatePhasesForChallengeUpdate( + [ + { + duration: staleDuration, + name: 'MM Registration', + phaseId: registrationPhaseId, + scheduledStartDate: registrationStartDate, + scheduledEndDate: '2026-06-01T12:00:00.000Z' + }, + { + duration: staleDuration, + name: 'MM Submission', + phaseId: submissionPhaseId, + predecessor: registrationPhaseId, + scheduledStartDate: '2026-06-01T12:00:00.000Z', + scheduledEndDate: '2026-06-02T00:00:00.000Z' + } + ], + [ + { + duration: staleDuration, + phaseId: registrationPhaseId, + scheduledEndDate: registrationEndDate, + scheduledStartDate: registrationStartDate + }, + { + duration: staleDuration, + phaseId: submissionPhaseId, + scheduledEndDate: submissionEndDate + } + ], + 'timeline-template-id', + false + ) + + updatedPhases[0].scheduledEndDate.should.equal(registrationEndDate) + updatedPhases[0].duration.should.equal(2 * 24 * 60 * 60) + updatedPhases[1].scheduledStartDate.should.equal(registrationEndDate) + updatedPhases[1].scheduledEndDate.should.equal(submissionEndDate) + updatedPhases[1].duration.should.equal(3 * 24 * 60 * 60) + }) +}) diff --git a/test/unit/prisma-helper.test.js b/test/unit/prisma-helper.test.js new file mode 100644 index 0000000..9773096 --- /dev/null +++ b/test/unit/prisma-helper.test.js @@ -0,0 +1,40 @@ +const chai = require('chai') + +const prismaHelper = require('../../src/common/prisma-helper') + +chai.should() + +describe('prisma helper unit tests', () => { + it('derives submission dates from the standard Submission phase', () => { + const result = {} + const submissionStartDate = '2026-05-22T08:00:00.000Z' + const submissionEndDate = '2026-05-27T08:00:00.000Z' + + prismaHelper.convertChallengePhaseSchema( + { + phases: [ + { + name: 'Registration', + phaseId: 'registration-phase', + scheduledEndDate: '2026-05-27T08:00:00.000Z', + scheduledStartDate: '2026-05-22T08:00:00.000Z' + }, + { + name: 'Submission', + phaseId: 'submission-phase', + scheduledEndDate: submissionEndDate, + scheduledStartDate: submissionStartDate + } + ] + }, + result, + { + createdBy: 'test-user', + updatedBy: 'test-user' + } + ) + + result.submissionStartDate.should.equal(submissionStartDate) + result.submissionEndDate.should.equal(submissionEndDate) + }) +})