Skip to content

Commit fc4cb48

Browse files
committed
PM-5761: Count every design submission concept
What was broken Challenge detail and listing responses counted multiple Design concepts from one member as a single submission, so the Community App displayed an incorrect submissions total. Root cause Challenge API recomputed both final and checkpoint counters with the submitting member ID as the distinct identity for every challenge track. What was changed Use the submission ID as the distinct counter identity for Design challenges while preserving member-based counting for all other tracks. Apply the rule to both checkpoint and non-checkpoint submissions. Any added/updated tests Added regression coverage for multiple final and checkpoint Design concepts from one member across challenge detail and listing responses. Preserved the existing Development-track deduplication coverage.
1 parent 202759e commit fc4cb48

2 files changed

Lines changed: 76 additions & 6 deletions

File tree

src/services/ChallengeService.ts

Lines changed: 27 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -100,20 +100,27 @@ function isCancelledChallengeStatus(status) {
100100
/**
101101
* Loads submission counters for challenge responses from the review submission table.
102102
*
103-
* Community app badges show the number of members with submissions, not the
104-
* number of attempts, so repeated uploads by the same member are counted once.
103+
* Community app badges normally show the number of members with submissions,
104+
* so repeated uploads by the same member are counted once. Design challenges
105+
* count every submission because each upload can be a unique concept.
105106
*
106107
* @param {Array<String>} challengeIds challenge identifiers to count submissions for
108+
* @param {Array<String>} designChallengeIds Design challenge identifiers that count every upload
107109
* @returns {Promise<Map<String, { numOfSubmissions: Number, numOfCheckpointSubmissions: Number }>>}
108110
* counts keyed by challenge id
109111
* @throws {Error} when the review database query fails
110112
*/
111-
async function getLatestSubmissionCountsByChallenge(challengeIds) {
113+
async function getLatestSubmissionCountsByChallenge(challengeIds, designChallengeIds = []) {
112114
const ids = _.uniq(
113115
(challengeIds || [])
114116
.map((challengeId) => _.toString(challengeId).trim())
115117
.filter((challengeId) => !!challengeId),
116118
);
119+
const designIds = _.uniq(
120+
(designChallengeIds || [])
121+
.map((challengeId) => _.toString(challengeId).trim())
122+
.filter((challengeId) => !!challengeId),
123+
);
117124
const countsByChallenge = new Map();
118125

119126
if (!ids.length || !config.REVIEW_DB_URL) {
@@ -124,16 +131,22 @@ async function getLatestSubmissionCountsByChallenge(challengeIds) {
124131
const submissionTable = reviewSchema
125132
? Prisma.raw(`"${reviewSchema.replace(/"/g, '""')}"."submission"`)
126133
: Prisma.raw('"submission"');
134+
const submissionIdentity = designIds.length
135+
? Prisma.sql`CASE
136+
WHEN "challengeId" IN (${Prisma.join(designIds)}) THEN "id"
137+
ELSE "memberId"
138+
END`
139+
: Prisma.sql`"memberId"`;
127140
const reviewClient = getReviewClient();
128141

129142
const rows = await reviewClient.$queryRaw`
130143
SELECT
131144
"challengeId",
132145
COUNT(DISTINCT CASE
133-
WHEN "type"::text = ${CHECKPOINT_SUBMISSION_TYPE} THEN "memberId"
146+
WHEN "type"::text = ${CHECKPOINT_SUBMISSION_TYPE} THEN ${submissionIdentity}
134147
END)::int AS "numOfCheckpointSubmissions",
135148
COUNT(DISTINCT CASE
136-
WHEN "type"::text <> ${CHECKPOINT_SUBMISSION_TYPE} THEN "memberId"
149+
WHEN "type"::text <> ${CHECKPOINT_SUBMISSION_TYPE} THEN ${submissionIdentity}
137150
END)::int AS "numOfSubmissions"
138151
FROM ${submissionTable}
139152
WHERE "challengeId" IN (${Prisma.join(ids)})
@@ -152,7 +165,11 @@ async function getLatestSubmissionCountsByChallenge(challengeIds) {
152165
}
153166

154167
/**
155-
* Applies latest-member submission counts to challenge records before response conversion.
168+
* Applies submission counts to challenge records before response conversion.
169+
*
170+
* Design challenges count every submission as a separate concept. Other tracks
171+
* count distinct submitting members so replacement attempts do not inflate the
172+
* displayed total.
156173
*
157174
* If the review query succeeds, challenges without submission rows are reset to
158175
* zero so stale stored counters are not shown. If the query cannot run, callers
@@ -169,8 +186,12 @@ async function applyLatestSubmissionCounts(challenges) {
169186

170187
let countsByChallenge;
171188
try {
189+
const designChallengeIds = records
190+
.filter((challenge) => phaseHelper.isDesignTrack(challenge.track))
191+
.map((challenge) => challenge.id);
172192
countsByChallenge = await getLatestSubmissionCountsByChallenge(
173193
records.map((challenge) => challenge.id),
194+
designChallengeIds,
174195
);
175196
} catch (err) {
176197
logger.warn(`Failed to load latest submission counts: ${err.message}`);

test/unit/ChallengeService.test.js

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -704,6 +704,55 @@ describe("challenge service unit tests", () => {
704704
}
705705
});
706706

707+
it("counts every Design submission as a separate concept", async () => {
708+
const challengeId = data.challenge.id;
709+
const originalTrack = data.challengeTrack.track;
710+
await prisma.challengeTrack.update({
711+
where: { id: data.challenge.trackId },
712+
data: { track: "DESIGN" },
713+
});
714+
715+
try {
716+
await reviewClient.$executeRawUnsafe(`
717+
INSERT INTO ${submissionTableName}
718+
("id", "challengeId", "memberId", "type", "status", "submittedDate")
719+
VALUES
720+
('pm5761a1', '${challengeId}', 'member-1', 'CONTEST_SUBMISSION', 'ACTIVE', '2026-01-01T00:00:00Z'),
721+
('pm5761a2', '${challengeId}', 'member-1', 'CONTEST_SUBMISSION', 'ACTIVE', '2026-01-02T00:00:00Z'),
722+
('pm5761a3', '${challengeId}', 'member-1', 'CONTEST_SUBMISSION', 'ACTIVE', '2026-01-03T00:00:00Z'),
723+
('pm5761c1', '${challengeId}', 'member-1', 'CHECKPOINT_SUBMISSION', 'ACTIVE', '2026-01-04T00:00:00Z'),
724+
('pm5761c2', '${challengeId}', 'member-1', 'CHECKPOINT_SUBMISSION', 'ACTIVE', '2026-01-05T00:00:00Z')
725+
`);
726+
727+
const detail = await service.getChallenge({ isMachine: true }, challengeId);
728+
should.equal(detail.numOfSubmissions, 3);
729+
should.equal(detail.numOfCheckpointSubmissions, 2);
730+
731+
const listing = await service.searchChallenges(
732+
{ isMachine: true },
733+
{
734+
id: challengeId,
735+
page: 1,
736+
perPage: 10,
737+
},
738+
);
739+
should.equal(listing.result.length, 1);
740+
should.equal(listing.result[0].numOfSubmissions, 3);
741+
should.equal(listing.result[0].numOfCheckpointSubmissions, 2);
742+
} finally {
743+
try {
744+
await reviewClient.$executeRawUnsafe(
745+
`DELETE FROM ${submissionTableName} WHERE "challengeId" = '${challengeId}'`,
746+
);
747+
} finally {
748+
await prisma.challengeTrack.update({
749+
where: { id: data.challenge.trackId },
750+
data: { track: originalTrack },
751+
});
752+
}
753+
}
754+
});
755+
707756
it("get challenge preserves billing for project write users", async () => {
708757
const originalUserHasProjectWriteAccess = helper.userHasProjectWriteAccess;
709758

0 commit comments

Comments
 (0)