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
6 changes: 3 additions & 3 deletions src/common/challenge-helper.js
Original file line number Diff line number Diff line change
Expand Up @@ -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) => {
Expand Down
70 changes: 59 additions & 11 deletions src/common/phase-helper.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {};
Expand Down Expand Up @@ -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";
Expand All @@ -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;
Expand Down Expand Up @@ -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) {
Expand Down
4 changes: 1 addition & 3 deletions src/common/prisma-helper.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 =
Expand Down
106 changes: 105 additions & 1 deletion src/services/ChallengeService.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down Expand Up @@ -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<String>} challengeIds challenge identifiers to count submissions for
* @returns {Promise<Map<String, { numOfSubmissions: Number, numOfCheckpointSubmissions: Number }>>}
* 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<Object>} challenges challenge records being returned by the API
* @returns {Promise<void>}
*/
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;
Expand Down Expand Up @@ -1977,6 +2068,8 @@ async function searchChallenges(currentUser, criteria) {
});
}

await applyLatestSubmissionCounts(challenges);

challenges.forEach((challenge) => {
prismaHelper.convertModelToResponse(challenge);
});
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -5297,6 +5399,8 @@ module.exports = {
shouldSkipChallengeApprovalFlow,
syncChallengeBillingAccountLock,
validateChallengeActivationBillingAccount,
getLatestSubmissionCountsByChallenge,
applyLatestSubmissionCounts,
},
searchChallenges,
createChallenge,
Expand Down
78 changes: 78 additions & 0 deletions test/unit/ChallengeService.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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;

Expand Down
Loading
Loading