@@ -31,6 +31,7 @@ const CHALLENGE_TRACK_NAME = 'DEVELOPMENT'
3131const CHALLENGE_TYPE_NAMES = [ TYPE_NAMES . CHALLENGE , TYPE_NAMES . CODE ]
3232const RERATE_ACTOR = 'rerate-member-stats'
3333const COMPLETED_CHALLENGE_STATUS = 'COMPLETED'
34+ const CHALLENGE_WINNER_RATING_TYPES = [ 'PLACEMENT' ]
3435
3536function isBigIntValue ( value ) {
3637 return Object . prototype . toString . call ( value ) === '[object BigInt]'
@@ -351,9 +352,11 @@ function findHistorySeedIndexForChallenge (historyRows, challengeEntry) {
351352}
352353
353354function normalizeScore ( row ) {
354- const finalScore = Number ( row . finalScore )
355- if ( Number . isFinite ( finalScore ) ) {
356- return finalScore
355+ if ( row . finalScore !== null && row . finalScore !== undefined ) {
356+ const finalScore = Number ( row . finalScore )
357+ if ( Number . isFinite ( finalScore ) ) {
358+ return finalScore
359+ }
357360 }
358361
359362 const placement = Number ( row . placement )
@@ -391,6 +394,145 @@ function isParticipantEligibleForRating (row) {
391394 return false
392395}
393396
397+ /**
398+ * Convert a member user id into the numeric shape stored on ChallengeWinner.
399+ * @param {BigInt|string|number } userId member identifier
400+ * @returns {number|string } numeric user id when safe, otherwise the string form
401+ */
402+ function toChallengeWinnerUserId ( userId ) {
403+ const numericUserId = Number ( userId )
404+ return Number . isSafeInteger ( numericUserId ) ? numericUserId : String ( userId )
405+ }
406+
407+ /**
408+ * Convert one ChallengeWinner row into the challengeResult-like row consumed by
409+ * the Development rating replay. Winner rows only provide placement, so the
410+ * existing placement score fallback drives the rating calculation.
411+ * @param {Object } row ChallengeWinner row
412+ * @returns {Object|null } review-row-compatible participant data
413+ */
414+ function toChallengeWinnerParticipantRow ( row ) {
415+ if ( ! row || row . userId === null || row . userId === undefined || ! row . challengeId ) {
416+ return null
417+ }
418+
419+ return {
420+ challengeId : String ( row . challengeId ) ,
421+ userId : String ( row . userId ) ,
422+ placement : row . placement ,
423+ passedReview : true ,
424+ validSubmission : true ,
425+ createdAt : row . createdAt
426+ }
427+ }
428+
429+ /**
430+ * Build the duplicate key used when merging challengeResult and ChallengeWinner
431+ * participant rows for the same member and challenge.
432+ * @param {Object } row participant row
433+ * @returns {string } duplicate key
434+ */
435+ function buildParticipantRowKey ( row ) {
436+ return `${ String ( row && row . challengeId ) } ::${ String ( row && row . userId ) } `
437+ }
438+
439+ /**
440+ * Merge review-api participant rows with ChallengeWinner placement rows. Review
441+ * rows are kept when both sources exist because they may carry final score and
442+ * source rating fields that are more precise than placement-only winners.
443+ * @param {Array<Object> } reviewRows challengeResult rows
444+ * @param {Array<Object> } winnerRows ChallengeWinner rows
445+ * @returns {Array<Object> } merged participant rows
446+ */
447+ function mergeChallengeWinnerParticipantRows ( reviewRows , winnerRows ) {
448+ const mergedRows = ( reviewRows || [ ] ) . slice ( )
449+ const existingKeys = new Set ( mergedRows . map ( buildParticipantRowKey ) )
450+
451+ ; ( winnerRows || [ ] ) . forEach ( ( winnerRow ) => {
452+ const participantRow = toChallengeWinnerParticipantRow ( winnerRow )
453+ if ( ! participantRow ) {
454+ return
455+ }
456+
457+ const key = buildParticipantRowKey ( participantRow )
458+ if ( existingKeys . has ( key ) ) {
459+ return
460+ }
461+
462+ existingKeys . add ( key )
463+ mergedRows . push ( participantRow )
464+ } )
465+
466+ return mergedRows
467+ }
468+
469+ /**
470+ * Load ChallengeWinner rows for a target member. These rows let winner-only
471+ * members enter the Development rating timeline even when review-api never
472+ * wrote a challengeResult row for the completed challenge.
473+ * @param {Object } challengeClient challenge Prisma client
474+ * @param {BigInt|string|number } userId member identifier
475+ * @returns {Promise<Array<Object>> } ChallengeWinner rows for rating replay
476+ */
477+ async function fetchChallengeWinnerRowsForUser ( challengeClient , userId ) {
478+ if ( ! challengeClient || ! challengeClient . ChallengeWinner ||
479+ typeof challengeClient . ChallengeWinner . findMany !== 'function' ) {
480+ return [ ]
481+ }
482+
483+ return challengeClient . ChallengeWinner . findMany ( {
484+ where : {
485+ userId : toChallengeWinnerUserId ( userId ) ,
486+ type : {
487+ in : CHALLENGE_WINNER_RATING_TYPES
488+ }
489+ } ,
490+ select : {
491+ challengeId : true ,
492+ userId : true ,
493+ placement : true ,
494+ createdAt : true
495+ }
496+ } )
497+ }
498+
499+ /**
500+ * Load ChallengeWinner participant rows for one challenge. The rows are merged
501+ * with review-api participants so placement winners without challengeResult
502+ * records still affect and receive Development rating updates.
503+ * @param {Object } challengeClient challenge Prisma client
504+ * @param {Object|string|number } challengeRef challenge id or history entry
505+ * @returns {Promise<Array<Object>> } ChallengeWinner rows for the challenge
506+ */
507+ async function fetchChallengeWinnerRowsForChallenge ( challengeClient , challengeRef ) {
508+ if ( ! challengeClient || ! challengeClient . ChallengeWinner ||
509+ typeof challengeClient . ChallengeWinner . findMany !== 'function' ) {
510+ return [ ]
511+ }
512+
513+ const challengeIds = buildChallengeIdCandidates ( challengeRef )
514+ if ( challengeIds . length === 0 ) {
515+ return [ ]
516+ }
517+
518+ return challengeClient . ChallengeWinner . findMany ( {
519+ where : {
520+ challengeId : {
521+ in : challengeIds
522+ } ,
523+ type : {
524+ in : CHALLENGE_WINNER_RATING_TYPES
525+ }
526+ } ,
527+ select : {
528+ challengeId : true ,
529+ userId : true ,
530+ placement : true ,
531+ createdAt : true
532+ }
533+ } )
534+ }
535+
394536async function fetchReviewResultsForUser ( reviewDbClient , userId ) {
395537 const challengeResultRelation = await resolveChallengeResultRelation ( reviewDbClient )
396538 const result = await reviewDbClient . query (
@@ -406,7 +548,7 @@ async function fetchReviewResultsForUser (reviewDbClient, userId) {
406548 return result . rows
407549}
408550
409- async function fetchParticipantsForChallenge ( reviewDbClient , challengeRef ) {
551+ async function fetchParticipantsForChallenge ( reviewDbClient , challengeRef , challengeClient ) {
410552 const challengeIds = buildChallengeIdCandidates ( challengeRef )
411553 if ( challengeIds . length === 0 ) {
412554 return [ ]
@@ -424,7 +566,8 @@ async function fetchParticipantsForChallenge (reviewDbClient, challengeRef) {
424566 challengeIds
425567 )
426568
427- return result . rows
569+ const winnerRows = await fetchChallengeWinnerRowsForChallenge ( challengeClient , challengeRef )
570+ return mergeChallengeWinnerParticipantRows ( result . rows , winnerRows )
428571}
429572
430573/**
@@ -951,8 +1094,12 @@ async function rerateDevTrack (membersClient, challengeClient, reviewDbClient, u
9511094 }
9521095
9531096 const normalizedUserId = toBigIntUserId ( userId )
954- const reviewRows = await fetchReviewResultsForUser ( reviewDbClient , normalizedUserId )
955- if ( reviewRows . length === 0 ) {
1097+ const [ reviewRows , winnerRows ] = await Promise . all ( [
1098+ fetchReviewResultsForUser ( reviewDbClient , normalizedUserId ) ,
1099+ fetchChallengeWinnerRowsForUser ( challengeClient , normalizedUserId )
1100+ ] )
1101+ const participantRowsForUser = mergeChallengeWinnerParticipantRows ( reviewRows , winnerRows )
1102+ if ( participantRowsForUser . length === 0 ) {
9561103 return {
9571104 challengesProcessed : 0 ,
9581105 ratingsUpdated : 0
@@ -961,10 +1108,10 @@ async function rerateDevTrack (membersClient, challengeClient, reviewDbClient, u
9611108
9621109 const challengeMetadataById = await fetchChallengeMetadataMap (
9631110 challengeClient ,
964- Array . from ( new Set ( reviewRows . map ( ( row ) => String ( row . challengeId ) ) ) )
1111+ Array . from ( new Set ( participantRowsForUser . map ( ( row ) => String ( row . challengeId ) ) ) )
9651112 )
9661113
967- const targetHistory = buildTargetHistory ( reviewRows , challengeMetadataById , {
1114+ const targetHistory = buildTargetHistory ( participantRowsForUser , challengeMetadataById , {
9681115 skipLegacyReviewIds : options . skipLegacyReviewIds === true ,
9691116 useLegacySourceRatings : options . useLegacySourceRatings === true
9701117 } )
@@ -1062,7 +1209,7 @@ async function rerateDevTrack (membersClient, challengeClient, reviewDbClient, u
10621209 continue
10631210 }
10641211
1065- const participantRows = ( await fetchParticipantsForChallenge ( reviewDbClient , historyEntry ) )
1212+ const participantRows = ( await fetchParticipantsForChallenge ( reviewDbClient , historyEntry , challengeClient ) )
10661213 . filter ( ( row ) => isParticipantEligibleForRating ( row ) )
10671214 if ( participantRows . length === 0 ) {
10681215 continue
0 commit comments