From 2e5bd08b6108f915d5a7ca296389b409e7e1e69c Mon Sep 17 00:00:00 2001 From: Justin Gasper Date: Fri, 6 Feb 2026 09:40:04 +1100 Subject: [PATCH 01/27] Better challenge access checks for RS-518 --- src/services/ChallengeService.js | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/services/ChallengeService.js b/src/services/ChallengeService.js index 61af425..44848ea 100644 --- a/src/services/ChallengeService.js +++ b/src/services/ChallengeService.js @@ -482,8 +482,11 @@ async function searchChallengesViaMemberAccess({ }) { const chunkSize = Number(process.env.SEARCH_MEMBER_CHUNK_SIZE || 500); const memberChallengeIdStart = Date.now(); - const memberChallengeIdRows = - await prisma.$queryRaw`SELECT DISTINCT r."challengeId" FROM resources."Resource" r WHERE r."memberId" = ${requestedMemberId} AND r."challengeId" IS NOT NULL`; + const memberChallengeIdRows = await prisma.memberChallengeAccess.findMany({ + where: { memberId: requestedMemberId }, + select: { challengeId: true }, + distinct: ["challengeId"], + }); const memberChallengeIds = memberChallengeIdRows .map((row) => row.challengeId) .filter((id) => !_.isNil(id)); From 41b4c68e1f4918c46811706ef7c87eef5c051629 Mon Sep 17 00:00:00 2001 From: Justin Gasper Date: Fri, 13 Feb 2026 11:02:05 +1100 Subject: [PATCH 02/27] Cors issue when using local platform-ui --- app.js | 10 +--------- 1 file changed, 1 insertion(+), 9 deletions(-) diff --git a/app.js b/app.js index 4ac3927..e666553 100644 --- a/app.js +++ b/app.js @@ -70,15 +70,7 @@ app.use( app.use( cors({ - origin: (origin, callback) => { - if (!origin) { - console.log("No origin - probably curl or server to server request"); - // disable cors if service to service request - callback(null, false); - } else { - callback(null, '*') - } - }, + origin: "*", exposedHeaders: [ "X-Prev-Page", "X-Next-Page", From d1161be7f9783f5a7d2b574f405fd5cde31c3c65 Mon Sep 17 00:00:00 2001 From: Justin Gasper Date: Thu, 19 Feb 2026 16:45:47 +1100 Subject: [PATCH 03/27] Additional fields for timeline template service. --- docs/swagger.yaml | 12 +++++ src/services/TimelineTemplateService.js | 24 ++++++++-- test/unit/TimelineTemplateService.test.js | 56 +++++++++++++++++++++++ 3 files changed, 88 insertions(+), 4 deletions(-) diff --git a/docs/swagger.yaml b/docs/swagger.yaml index 3ab3bdc..276d0d3 100644 --- a/docs/swagger.yaml +++ b/docs/swagger.yaml @@ -3597,6 +3597,18 @@ definitions: type: string description: The timeline template id. format: UUID + typeId: + type: string + description: The challenge type id mapped to this timeline template row. + format: UUID + trackId: + type: string + description: The challenge track id mapped to this timeline template row. + format: UUID + isDefault: + type: boolean + description: Indicates this timeline template is the default for the mapped type/track. + default: false - $ref: "#/definitions/TimelineTemplateData" required: - id diff --git a/src/services/TimelineTemplateService.js b/src/services/TimelineTemplateService.js index 447c6ac..86d1f68 100644 --- a/src/services/TimelineTemplateService.js +++ b/src/services/TimelineTemplateService.js @@ -26,12 +26,28 @@ async function searchTimelineTemplates(criteria) { const perPage = criteria.perPage || 50; let items = await prisma.timelineTemplate.findMany({ where: searchFilter, - include: { phases: true }, + include: { phases: true, challengeTimelineTemplates: true }, }); - items = _.map(items, (r) => _.omit(r, constants.auditFields)); - items.forEach((item) => { - item.phases = _.map(item.phases, (p) => _.omit(p, constants.auditFields)); + items = _.flatMap(items, (template) => { + const phases = _.map(template.phases, (phase) => _.omit(phase, constants.auditFields)); + const baseTemplate = { + ..._.omit(template, [...constants.auditFields, "phases", "challengeTimelineTemplates"]), + phases, + }; + const challengeTimelineTemplates = template.challengeTimelineTemplates || []; + + if (!challengeTimelineTemplates.length) { + return [baseTemplate]; + } + + return _.map(challengeTimelineTemplates, (challengeTimelineTemplate) => ({ + ...baseTemplate, + isDefault: challengeTimelineTemplate.isDefault === true, + trackId: challengeTimelineTemplate.trackId, + typeId: challengeTimelineTemplate.typeId, + })); }); + const total = items.length; const result = items.slice((page - 1) * perPage, page * perPage); diff --git a/test/unit/TimelineTemplateService.test.js b/test/unit/TimelineTemplateService.test.js index 390bce1..37f9641 100644 --- a/test/unit/TimelineTemplateService.test.js +++ b/test/unit/TimelineTemplateService.test.js @@ -207,6 +207,62 @@ describe('timeline template service unit tests', () => { should.equal(result.result[0].phases[0].defaultDuration, 123) }) + it('search timeline templates includes challenge timeline mapping data', async () => { + const challengeTypeId = uuid() + const challengeTrackId = uuid() + const challengeTimelineTemplateId = uuid() + + await prisma.challengeType.create({ + data: { + id: challengeTypeId, + name: `type-${new Date().getTime()}`, + description: 'desc', + isActive: true, + abbreviation: `tp-${new Date().getTime()}`, + createdBy: authUser.userId, + updatedBy: authUser.userId + } + }) + + await prisma.challengeTrack.create({ + data: { + id: challengeTrackId, + name: `track-${new Date().getTime()}`, + description: 'desc', + isActive: true, + abbreviation: `tr-${new Date().getTime()}`, + createdBy: authUser.userId, + updatedBy: authUser.userId + } + }) + + try { + await prisma.challengeTimelineTemplate.create({ + data: { + id: challengeTimelineTemplateId, + isDefault: true, + timelineTemplateId: id, + trackId: challengeTrackId, + typeId: challengeTypeId, + createdBy: authUser.userId, + updatedBy: authUser.userId + } + }) + + const result = await service.searchTimelineTemplates({ page: 1, perPage: 10, name }) + should.equal(result.total, 1) + should.equal(result.result.length, 1) + should.equal(result.result[0].id, id) + should.equal(result.result[0].isDefault, true) + should.equal(result.result[0].trackId, challengeTrackId) + should.equal(result.result[0].typeId, challengeTypeId) + } finally { + await prisma.challengeTimelineTemplate.deleteMany({ where: { id: challengeTimelineTemplateId } }) + await prisma.challengeType.deleteMany({ where: { id: challengeTypeId } }) + await prisma.challengeTrack.deleteMany({ where: { id: challengeTrackId } }) + } + }) + it('search timeline templates successfully 2', async () => { const result = await service.searchTimelineTemplates({ name: 'xyzxyz123' }) should.equal(result.total, 0) From 6d392684c0464e5ec603857931c2829a6405cda4 Mon Sep 17 00:00:00 2001 From: himaniraghav3 Date: Fri, 20 Feb 2026 12:34:31 +0530 Subject: [PATCH 04/27] PM-3351 Send notification on manual phase change [initial commit] --- app-constants.js | 8 +++ config/default.js | 1 + src/common/helper.js | 75 ++++++++++++++++++++++++ src/services/ChallengePhaseService.js | 82 +++++++++++++++++++++++++++ 4 files changed, 166 insertions(+) diff --git a/app-constants.js b/app-constants.js index 0c6f361..5fe401c 100644 --- a/app-constants.js +++ b/app-constants.js @@ -151,6 +151,14 @@ const PhaseFact = { UNRECOGNIZED: -1 } +exports.PhaseChangeNotificationSettings = { + PHASE_CHANGE: { + sendgridTemplateId: process.env.PHASE_CHANGE_SENDGRID_TEMPLATE_ID, + cc: [], + }, +}; + + const auditFields = [ 'createdAt', 'createdBy', 'updatedAt', 'updatedBy' ] diff --git a/config/default.js b/config/default.js index cc0da6e..ec71ecf 100644 --- a/config/default.js +++ b/config/default.js @@ -133,4 +133,5 @@ module.exports = { RESOURCES_DB_SCHEMA: process.env.RESOURCES_DB_SCHEMA || "resources", REVIEW_DB_SCHEMA: process.env.REVIEW_DB_SCHEMA || "reviews", CHALLENGE_SERVICE_PRISMA_TIMEOUT: process.env.CHALLENGE_SERVICE_PRISMA_TIMEOUT ? parseInt(process.env.CHALLENGE_SERVICE_PRISMA_TIMEOUT, 10) : 10000, + CHALLENGE_URL: process.env.CHALLENGE_URL || 'https://www.topcoder-dev.com/challenges' }; diff --git a/src/common/helper.js b/src/common/helper.js index a7453fb..7420218 100644 --- a/src/common/helper.js +++ b/src/common/helper.js @@ -1638,6 +1638,79 @@ async function sendSelfServiceNotification(type, recipients, data) { } } +/** + * Build payload for phase change email notification + * @param {String} challenge Id + * @param {String} challenge name + * @param {String} challenge phase name + * @param {String} operation to be performed on the phase - open | close | reopen + */ +function buildPhaseChangeEmailData({ challengeId, challengeName, phaseName, operation, at }) { + const isOpen = operation === 'open' || operation === 'reopen'; + const isClose = operation === 'close'; + + return { + challengeURL: `${config.CHALLENGE_URL}/${challengeId}`, + challengeName, + phaseOpen: isOpen ? phaseName : null, + phaseOpenDate: isOpen ? at : null, + phaseClose: isClose ? phaseName : null, + phaseCloseDate: isClose ? at : null, + }; +} + + +/** + * Send phase change notification + * @param {String} type the notification type + * @param {Array} recipients the array of recipients in { userId || email || handle } format + * @param {Object} data the data + */ +async function sendPhaseChangeNotification(type, recipients, data) { + try { + const settings = constants.PhaseChangeNotificationSettings?.[type]; + + if (!settings) { + logger.debug(`sendPhaseChangeNotification: unknown type ${type}`); + return; + } + + if (!settings.sendgridTemplateId) { + logger.debug( + `sendPhaseChangeNotification: sendgridTemplateId not configured for type ${type}` + ); + return; + } + const safeRecipients = Array.isArray(recipients) ? recipients.filter(Boolean) : []; + + if (!safeRecipients.length) { + logger.debug(`sendPhaseChangeNotification: no recipients for type ${type}`); + return; + } + + await postBusEvent(constants.Topics.Notifications, { + notifications: [ + { + serviceId: 'email', + type, + details: { + from: config.EMAIL_FROM, + recipients: [...safeRecipients], + cc: [...(settings.cc || [])], + data: { + ...data, + }, + sendgridTemplateId: settings.sendgridTemplateId, + version: 'v3', + }, + }, + ], + }); + } catch (e) { + logger.debug(`Failed to post notification ${type}: ${e.message}`); + } +} + /** * Submit a request to zendesk * @param {Object} request the request @@ -1756,6 +1829,8 @@ module.exports = { setToInternalCache, flushInternalCache, removeNullProperties, + buildPhaseChangeEmailData, + sendPhaseChangeNotification }; logger.buildService(module.exports); diff --git a/src/services/ChallengePhaseService.js b/src/services/ChallengePhaseService.js index 507eb2f..61e0cde 100644 --- a/src/services/ChallengePhaseService.js +++ b/src/services/ChallengePhaseService.js @@ -813,9 +813,91 @@ async function partiallyUpdateChallengePhase(currentUser, challengeId, id, data) _.assignIn({ id: result.id }, data) ); await postChallengeUpdatedNotification(challengeId); + + // send notification logic + try { + const shouldNotifyClose = Boolean(isClosingPhase); + const shouldNotifyOpen = Boolean(isOpeningPhase); // includes reopen + + if (!shouldNotifyClose && !shouldNotifyOpen) { + return _.omit(result, constants.auditFields); + } + + // Single template - single type + const notificationType = "PHASE_CHANGE"; + + const operation = shouldNotifyClose + ? "close" + : (isReopeningPhase ? "reopen" : "open"); + + const at = shouldNotifyClose + ? (result.actualEndDate || new Date().toISOString()) + : (result.actualStartDate || new Date().toISOString()); + + // fetch challenge name + const challenge = await prisma.challenge.findUnique({ + where: { id: challengeId }, + select: { name: true }, + }); + + const challengeName = challenge?.name; + + // build recipients + const resources = await helper.getChallengeResources(challengeId); + + const seen = new Set(); + const recipients = []; + + for (const r of resources || []) { + const userId = r?.memberId ? String(r.memberId).trim() : null; + const handle = r?.memberHandle ? String(r.memberHandle).trim() : null; + + let key = null; + let rec = null; + + if (userId) { + key = `userId:${userId}`; + rec = { userId }; + } else if (handle) { + const norm = handle.toLowerCase(); + key = `handle:${norm}`; + rec = { handle: norm }; + } + + if (!key || seen.has(key)) continue; + seen.add(key); + recipients.push(rec); + } + + if (!recipients.length) { + logger.debug( + `phase change notification skipped: no recipients for challenge ${challengeId}` + ); + return _.omit(result, constants.auditFields); + } + + // build payload that matches the SendGrid HTML template + const phaseName = result.name || data.name || challengePhase.name; + + const payload = helper.buildPhaseChangeEmailData({ + challengeId, + challengeName, + phaseName, + operation, + at, + }); + + await sendPhaseChangeNotification(notificationType, recipients, payload); + } catch (e) { + logger.debug( + `phase change notification failed for challenge ${challengeId}, phase ${id}: ${e.message}` + ); + } + return _.omit(result, constants.auditFields); } + partiallyUpdateChallengePhase.schema = { currentUser: Joi.any(), challengeId: Joi.id(), From f7ee6df0c80d9759ad7cef81dd86544901e517ed Mon Sep 17 00:00:00 2001 From: himaniraghav3 Date: Fri, 20 Feb 2026 12:35:37 +0530 Subject: [PATCH 05/27] deploy PM-3351 --- .circleci/config.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.circleci/config.yml b/.circleci/config.yml index 9d4b135..3c0c8c6 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -91,7 +91,7 @@ workflows: only: - develop - security - - PM-3327 + - PM-3351 - "build-qa": context: org-global From 23c3a606fa870dff02dcc418143b9bac2176a81b Mon Sep 17 00:00:00 2001 From: himaniraghav3 Date: Fri, 20 Feb 2026 12:55:29 +0530 Subject: [PATCH 06/27] Add sendgrid appvar to config --- app-constants.js | 2 +- config/default.js | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/app-constants.js b/app-constants.js index 5fe401c..bc7e075 100644 --- a/app-constants.js +++ b/app-constants.js @@ -153,7 +153,7 @@ const PhaseFact = { exports.PhaseChangeNotificationSettings = { PHASE_CHANGE: { - sendgridTemplateId: process.env.PHASE_CHANGE_SENDGRID_TEMPLATE_ID, + sendgridTemplateId: config.PHASE_CHANGE_SENDGRID_TEMPLATE_ID, cc: [], }, }; diff --git a/config/default.js b/config/default.js index ec71ecf..93e524f 100644 --- a/config/default.js +++ b/config/default.js @@ -133,5 +133,6 @@ module.exports = { RESOURCES_DB_SCHEMA: process.env.RESOURCES_DB_SCHEMA || "resources", REVIEW_DB_SCHEMA: process.env.REVIEW_DB_SCHEMA || "reviews", CHALLENGE_SERVICE_PRISMA_TIMEOUT: process.env.CHALLENGE_SERVICE_PRISMA_TIMEOUT ? parseInt(process.env.CHALLENGE_SERVICE_PRISMA_TIMEOUT, 10) : 10000, - CHALLENGE_URL: process.env.CHALLENGE_URL || 'https://www.topcoder-dev.com/challenges' + CHALLENGE_URL: process.env.CHALLENGE_URL || 'https://www.topcoder-dev.com/challenges' , + PHASE_CHANGE_SENDGRID_TEMPLATE_ID: process.env.PHASE_CHANGE_SENDGRID_TEMPLATE_ID || "", }; From c17d651c1159b5e3a7040cea3eb8f3204f8e9356 Mon Sep 17 00:00:00 2001 From: himaniraghav3 Date: Fri, 20 Feb 2026 13:36:56 +0530 Subject: [PATCH 07/27] Fix typo --- src/services/ChallengePhaseService.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/services/ChallengePhaseService.js b/src/services/ChallengePhaseService.js index 61e0cde..b3789d2 100644 --- a/src/services/ChallengePhaseService.js +++ b/src/services/ChallengePhaseService.js @@ -887,7 +887,7 @@ async function partiallyUpdateChallengePhase(currentUser, challengeId, id, data) at, }); - await sendPhaseChangeNotification(notificationType, recipients, payload); + await helper.sendPhaseChangeNotification(notificationType, recipients, payload); } catch (e) { logger.debug( `phase change notification failed for challenge ${challengeId}, phase ${id}: ${e.message}` From 95fd2e6dfd4f7ed71af6c6fb4d2b9a14e4a58409 Mon Sep 17 00:00:00 2001 From: himaniraghav3 Date: Fri, 20 Feb 2026 14:11:14 +0530 Subject: [PATCH 08/27] Fix typo --- app-constants.js | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/app-constants.js b/app-constants.js index bc7e075..5c8d576 100644 --- a/app-constants.js +++ b/app-constants.js @@ -151,7 +151,7 @@ const PhaseFact = { UNRECOGNIZED: -1 } -exports.PhaseChangeNotificationSettings = { +const PhaseChangeNotificationSettings = { PHASE_CHANGE: { sendgridTemplateId: config.PHASE_CHANGE_SENDGRID_TEMPLATE_ID, cc: [], @@ -176,4 +176,5 @@ module.exports = { SelfServiceNotificationSettings, PhaseFact, auditFields, + PhaseChangeNotificationSettings, }; From 57c5063aebedbee9c0fa86d4a3beb514f57af58d Mon Sep 17 00:00:00 2001 From: himaniraghav3 Date: Fri, 20 Feb 2026 14:24:00 +0530 Subject: [PATCH 09/27] Add logging --- src/common/helper.js | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/src/common/helper.js b/src/common/helper.js index 7420218..47fc1dd 100644 --- a/src/common/helper.js +++ b/src/common/helper.js @@ -1688,6 +1688,21 @@ async function sendPhaseChangeNotification(type, recipients, data) { return; } + logger.debug( + `sendPhaseChangeNotification: preparing email`, + { + type, + recipientsCount: safeRecipients.length, + recipients: safeRecipients, + } + ); + + logger.debug( + `sendPhaseChangeNotification: payload`, + data + ); + + await postBusEvent(constants.Topics.Notifications, { notifications: [ { From 4ecb6bf6c2726195c7782101d39c97de64d2b39f Mon Sep 17 00:00:00 2001 From: himaniraghav3 Date: Fri, 20 Feb 2026 14:42:14 +0530 Subject: [PATCH 10/27] Fix recipient emails and add logging --- src/services/ChallengePhaseService.js | 35 ++++++++++----------------- 1 file changed, 13 insertions(+), 22 deletions(-) diff --git a/src/services/ChallengePhaseService.js b/src/services/ChallengePhaseService.js index b3789d2..411f745 100644 --- a/src/services/ChallengePhaseService.js +++ b/src/services/ChallengePhaseService.js @@ -845,29 +845,20 @@ async function partiallyUpdateChallengePhase(currentUser, challengeId, id, data) // build recipients const resources = await helper.getChallengeResources(challengeId); - const seen = new Set(); - const recipients = []; - - for (const r of resources || []) { - const userId = r?.memberId ? String(r.memberId).trim() : null; - const handle = r?.memberHandle ? String(r.memberHandle).trim() : null; - - let key = null; - let rec = null; - - if (userId) { - key = `userId:${userId}`; - rec = { userId }; - } else if (handle) { - const norm = handle.toLowerCase(); - key = `handle:${norm}`; - rec = { handle: norm }; - } + const recipients = Array.from( + new Set( + (resources || []) + .map(r => r?.email || r?.memberEmail) + .filter(Boolean) + .map(e => String(e).trim().toLowerCase()) + ) + ); - if (!key || seen.has(key)) continue; - seen.add(key); - recipients.push(rec); - } + logger.debug(`phase change: resolved emails`, { + challengeId, + emailsCount: recipientEmails.length, + emails: recipientEmails, + }); if (!recipients.length) { logger.debug( From e814a6d7cda1a1d0e6bb452397dcbe0da1339972 Mon Sep 17 00:00:00 2001 From: himaniraghav3 Date: Fri, 20 Feb 2026 14:52:36 +0530 Subject: [PATCH 11/27] fix typo --- src/services/ChallengePhaseService.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/services/ChallengePhaseService.js b/src/services/ChallengePhaseService.js index 411f745..fc36eb2 100644 --- a/src/services/ChallengePhaseService.js +++ b/src/services/ChallengePhaseService.js @@ -856,8 +856,8 @@ async function partiallyUpdateChallengePhase(currentUser, challengeId, id, data) logger.debug(`phase change: resolved emails`, { challengeId, - emailsCount: recipientEmails.length, - emails: recipientEmails, + emailsCount: recipients.length, + emails: recipients, }); if (!recipients.length) { From b39f1048be0eb177a435d2a6dfaaa46065eadeb6 Mon Sep 17 00:00:00 2001 From: himaniraghav3 Date: Fri, 20 Feb 2026 15:04:00 +0530 Subject: [PATCH 12/27] Enable challenge phase update topic --- app-constants.js | 1 - 1 file changed, 1 deletion(-) diff --git a/app-constants.js b/app-constants.js index 5c8d576..bce6ef8 100644 --- a/app-constants.js +++ b/app-constants.js @@ -96,7 +96,6 @@ const DisabledTopics = [ Topics.ChallengeTimelineTemplateCreated, Topics.ChallengeTimelineTemplateUpdated, Topics.ChallengeTimelineTemplateDeleted, - Topics.ChallengePhaseUpdated, Topics.ChallengePhaseDeleted, Topics.DefaultChallengeReviewerCreated, Topics.DefaultChallengeReviewerUpdated, From b1812d720f14b85878d8ca6330e98d2d16b190e4 Mon Sep 17 00:00:00 2001 From: himaniraghav3 Date: Fri, 20 Feb 2026 15:21:53 +0530 Subject: [PATCH 13/27] always return updated phase --- src/services/ChallengePhaseService.js | 96 +++++++++++++-------------- 1 file changed, 47 insertions(+), 49 deletions(-) diff --git a/src/services/ChallengePhaseService.js b/src/services/ChallengePhaseService.js index fc36eb2..48bc8a1 100644 --- a/src/services/ChallengePhaseService.js +++ b/src/services/ChallengePhaseService.js @@ -819,66 +819,64 @@ async function partiallyUpdateChallengePhase(currentUser, challengeId, id, data) const shouldNotifyClose = Boolean(isClosingPhase); const shouldNotifyOpen = Boolean(isOpeningPhase); // includes reopen - if (!shouldNotifyClose && !shouldNotifyOpen) { - return _.omit(result, constants.auditFields); - } - - // Single template - single type - const notificationType = "PHASE_CHANGE"; + if (shouldNotifyClose && shouldNotifyOpen) { + // Single template - single type + const notificationType = "PHASE_CHANGE"; - const operation = shouldNotifyClose - ? "close" - : (isReopeningPhase ? "reopen" : "open"); + const operation = shouldNotifyClose + ? "close" + : (isReopeningPhase ? "reopen" : "open"); - const at = shouldNotifyClose - ? (result.actualEndDate || new Date().toISOString()) - : (result.actualStartDate || new Date().toISOString()); + const at = shouldNotifyClose + ? (result.actualEndDate || new Date().toISOString()) + : (result.actualStartDate || new Date().toISOString()); - // fetch challenge name - const challenge = await prisma.challenge.findUnique({ - where: { id: challengeId }, - select: { name: true }, - }); + // fetch challenge name + const challenge = await prisma.challenge.findUnique({ + where: { id: challengeId }, + select: { name: true }, + }); - const challengeName = challenge?.name; + const challengeName = challenge?.name; - // build recipients - const resources = await helper.getChallengeResources(challengeId); + // build recipients + const resources = await helper.getChallengeResources(challengeId); - const recipients = Array.from( - new Set( - (resources || []) - .map(r => r?.email || r?.memberEmail) - .filter(Boolean) - .map(e => String(e).trim().toLowerCase()) - ) - ); + const recipients = Array.from( + new Set( + (resources || []) + .map(r => r?.email || r?.memberEmail) + .filter(Boolean) + .map(e => String(e).trim().toLowerCase()) + ) + ); - logger.debug(`phase change: resolved emails`, { - challengeId, - emailsCount: recipients.length, - emails: recipients, - }); + logger.debug(`phase change: resolved emails`, { + challengeId, + emailsCount: recipients.length, + emails: recipients, + }); - if (!recipients.length) { - logger.debug( - `phase change notification skipped: no recipients for challenge ${challengeId}` - ); - return _.omit(result, constants.auditFields); - } + if (!recipients.length) { + logger.debug( + `phase change notification skipped: no recipients for challenge ${challengeId}` + ); + return _.omit(result, constants.auditFields); + } - // build payload that matches the SendGrid HTML template - const phaseName = result.name || data.name || challengePhase.name; + // build payload that matches the SendGrid HTML template + const phaseName = result.name || data.name || challengePhase.name; - const payload = helper.buildPhaseChangeEmailData({ - challengeId, - challengeName, - phaseName, - operation, - at, - }); + const payload = helper.buildPhaseChangeEmailData({ + challengeId, + challengeName, + phaseName, + operation, + at, + }); - await helper.sendPhaseChangeNotification(notificationType, recipients, payload); + await helper.sendPhaseChangeNotification(notificationType, recipients, payload); + } } catch (e) { logger.debug( `phase change notification failed for challenge ${challengeId}, phase ${id}: ${e.message}` From 1bd719a68879b27ddf929a64b85a9b4d8921a66d Mon Sep 17 00:00:00 2001 From: himaniraghav3 Date: Fri, 20 Feb 2026 15:28:07 +0530 Subject: [PATCH 14/27] AI feedback --- src/services/ChallengePhaseService.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/services/ChallengePhaseService.js b/src/services/ChallengePhaseService.js index 48bc8a1..530f63f 100644 --- a/src/services/ChallengePhaseService.js +++ b/src/services/ChallengePhaseService.js @@ -819,7 +819,7 @@ async function partiallyUpdateChallengePhase(currentUser, challengeId, id, data) const shouldNotifyClose = Boolean(isClosingPhase); const shouldNotifyOpen = Boolean(isOpeningPhase); // includes reopen - if (shouldNotifyClose && shouldNotifyOpen) { + if (shouldNotifyClose || shouldNotifyOpen) { // Single template - single type const notificationType = "PHASE_CHANGE"; From b235f717a1ae447db5a3cad53990d26248bfbd48 Mon Sep 17 00:00:00 2001 From: himaniraghav3 Date: Fri, 20 Feb 2026 15:39:32 +0530 Subject: [PATCH 15/27] Change topic --- src/common/helper.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/common/helper.js b/src/common/helper.js index 47fc1dd..1374155 100644 --- a/src/common/helper.js +++ b/src/common/helper.js @@ -1614,7 +1614,7 @@ async function getStandSkills(ids) { */ async function sendSelfServiceNotification(type, recipients, data) { try { - await postBusEvent(constants.Topics.Notifications, { + await postBusEvent(constants.Topics.ChallengePhaseUpdated, { notifications: [ { serviceId: "email", From eaca715af427714c507cd4360b79d3f76f0c17b1 Mon Sep 17 00:00:00 2001 From: himaniraghav3 Date: Fri, 20 Feb 2026 15:53:00 +0530 Subject: [PATCH 16/27] Add valid topic and test --- src/common/helper.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/common/helper.js b/src/common/helper.js index 1374155..7574c3d 100644 --- a/src/common/helper.js +++ b/src/common/helper.js @@ -1614,7 +1614,7 @@ async function getStandSkills(ids) { */ async function sendSelfServiceNotification(type, recipients, data) { try { - await postBusEvent(constants.Topics.ChallengePhaseUpdated, { + await postBusEvent('external.action.email', { notifications: [ { serviceId: "email", From aa809538d07d22e3ffad2982c6084ffe021e0cd6 Mon Sep 17 00:00:00 2001 From: himaniraghav3 Date: Fri, 20 Feb 2026 16:02:03 +0530 Subject: [PATCH 17/27] Wrong method updated --- src/common/helper.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/common/helper.js b/src/common/helper.js index 7574c3d..33db8fb 100644 --- a/src/common/helper.js +++ b/src/common/helper.js @@ -1614,7 +1614,7 @@ async function getStandSkills(ids) { */ async function sendSelfServiceNotification(type, recipients, data) { try { - await postBusEvent('external.action.email', { + await postBusEvent(constants.Topics.Notifications, { notifications: [ { serviceId: "email", @@ -1703,7 +1703,7 @@ async function sendPhaseChangeNotification(type, recipients, data) { ); - await postBusEvent(constants.Topics.Notifications, { + await postBusEvent('external.action.email', { notifications: [ { serviceId: 'email', From 629b0b28d20f3245f828b8ee1fc9ea9ac70c6ffc Mon Sep 17 00:00:00 2001 From: himaniraghav3 Date: Fri, 20 Feb 2026 16:10:03 +0530 Subject: [PATCH 18/27] Keep the invalid topic disabled --- app-constants.js | 1 + 1 file changed, 1 insertion(+) diff --git a/app-constants.js b/app-constants.js index bce6ef8..5c8d576 100644 --- a/app-constants.js +++ b/app-constants.js @@ -96,6 +96,7 @@ const DisabledTopics = [ Topics.ChallengeTimelineTemplateCreated, Topics.ChallengeTimelineTemplateUpdated, Topics.ChallengeTimelineTemplateDeleted, + Topics.ChallengePhaseUpdated, Topics.ChallengePhaseDeleted, Topics.DefaultChallengeReviewerCreated, Topics.DefaultChallengeReviewerUpdated, From 979eb82d8013048fb13c0284cc28329f78ee78f4 Mon Sep 17 00:00:00 2001 From: himaniraghav3 Date: Fri, 20 Feb 2026 16:41:16 +0530 Subject: [PATCH 19/27] Match autopilot bus payload --- src/common/helper.js | 31 ++++++++++++++----------------- 1 file changed, 14 insertions(+), 17 deletions(-) diff --git a/src/common/helper.js b/src/common/helper.js index 33db8fb..992995c 100644 --- a/src/common/helper.js +++ b/src/common/helper.js @@ -1703,23 +1703,20 @@ async function sendPhaseChangeNotification(type, recipients, data) { ); - await postBusEvent('external.action.email', { - notifications: [ - { - serviceId: 'email', - type, - details: { - from: config.EMAIL_FROM, - recipients: [...safeRecipients], - cc: [...(settings.cc || [])], - data: { - ...data, - }, - sendgridTemplateId: settings.sendgridTemplateId, - version: 'v3', - }, - }, - ], + await postBusEvent('external.action.email', + { + from: config.EMAIL_FROM, + replyTo: config.EMAIL_FROM, + recipients: safeRecipients, + data: data, + sendgrid_template_id: settings.sendgridTemplateId, + version: 'v3', + }, + ); + + logger.debug(`sendPhaseChangeNotification: published`, { + type, + recipientsCount: safeRecipients.length, }); } catch (e) { logger.debug(`Failed to post notification ${type}: ${e.message}`); From 867eec448d9b7d5aced9f9d00f98b4130d2809bd Mon Sep 17 00:00:00 2001 From: himaniraghav3 Date: Fri, 20 Feb 2026 16:53:22 +0530 Subject: [PATCH 20/27] cleanup logs --- src/common/helper.js | 20 -------------------- src/services/ChallengePhaseService.js | 6 ------ 2 files changed, 26 deletions(-) diff --git a/src/common/helper.js b/src/common/helper.js index 992995c..aed437b 100644 --- a/src/common/helper.js +++ b/src/common/helper.js @@ -1688,21 +1688,6 @@ async function sendPhaseChangeNotification(type, recipients, data) { return; } - logger.debug( - `sendPhaseChangeNotification: preparing email`, - { - type, - recipientsCount: safeRecipients.length, - recipients: safeRecipients, - } - ); - - logger.debug( - `sendPhaseChangeNotification: payload`, - data - ); - - await postBusEvent('external.action.email', { from: config.EMAIL_FROM, @@ -1713,11 +1698,6 @@ async function sendPhaseChangeNotification(type, recipients, data) { version: 'v3', }, ); - - logger.debug(`sendPhaseChangeNotification: published`, { - type, - recipientsCount: safeRecipients.length, - }); } catch (e) { logger.debug(`Failed to post notification ${type}: ${e.message}`); } diff --git a/src/services/ChallengePhaseService.js b/src/services/ChallengePhaseService.js index 530f63f..4c6c4da 100644 --- a/src/services/ChallengePhaseService.js +++ b/src/services/ChallengePhaseService.js @@ -851,12 +851,6 @@ async function partiallyUpdateChallengePhase(currentUser, challengeId, id, data) ) ); - logger.debug(`phase change: resolved emails`, { - challengeId, - emailsCount: recipients.length, - emails: recipients, - }); - if (!recipients.length) { logger.debug( `phase change notification skipped: no recipients for challenge ${challengeId}` From 1c0edd7104d7d08101716505de7ec75dda5f58de Mon Sep 17 00:00:00 2001 From: himaniraghav3 Date: Fri, 20 Feb 2026 17:02:02 +0530 Subject: [PATCH 21/27] Fix JSdocs --- src/common/helper.js | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/common/helper.js b/src/common/helper.js index aed437b..d9e3ef9 100644 --- a/src/common/helper.js +++ b/src/common/helper.js @@ -1644,6 +1644,7 @@ async function sendSelfServiceNotification(type, recipients, data) { * @param {String} challenge name * @param {String} challenge phase name * @param {String} operation to be performed on the phase - open | close | reopen + * @param {String|Date} at - The date/time when the phase opened/closed */ function buildPhaseChangeEmailData({ challengeId, challengeName, phaseName, operation, at }) { const isOpen = operation === 'open' || operation === 'reopen'; @@ -1663,7 +1664,7 @@ function buildPhaseChangeEmailData({ challengeId, challengeName, phaseName, oper /** * Send phase change notification * @param {String} type the notification type - * @param {Array} recipients the array of recipients in { userId || email || handle } format + * @param {Array} recipients the array of recipients emails * @param {Object} data the data */ async function sendPhaseChangeNotification(type, recipients, data) { From 0fc71dd001116c59b196b8116e098aafbd3737c2 Mon Sep 17 00:00:00 2001 From: Justin Gasper Date: Sat, 21 Feb 2026 16:49:04 +1100 Subject: [PATCH 22/27] Fixes for new work app in platform-ui --- src/common/challenge-helper.js | 1 - src/common/helper.js | 40 ++++++++++++++++++++++++++++------ 2 files changed, 33 insertions(+), 8 deletions(-) diff --git a/src/common/challenge-helper.js b/src/common/challenge-helper.js index c2b6d81..b3a2047 100644 --- a/src/common/challenge-helper.js +++ b/src/common/challenge-helper.js @@ -91,7 +91,6 @@ class ChallengeHelper { promises.push( (async () => { const group = await helper.getGroupById(g); - console.log("group", group); if (!group) { throw new errors.BadRequestError("The groups provided are invalid " + g); } diff --git a/src/common/helper.js b/src/common/helper.js index a7453fb..404db69 100644 --- a/src/common/helper.js +++ b/src/common/helper.js @@ -1428,22 +1428,48 @@ function sumOfPrizes(prizes) { } /** - * Get group by id - * @param {String} groupId the group id + * Get group by id, with oldId fallback for backward compatibility. + * @param {String} groupId the group id or oldId * @returns {Promise} the group */ async function getGroupById(groupId) { + const normalizedGroupId = _.toString(groupId || "").trim(); + if (!normalizedGroupId) { + return; + } + const token = await m2mHelper.getM2MToken(); + const requestHeaders = { Authorization: `Bearer ${token}` }; try { - const result = await axios.get(`${config.GROUPS_API_URL}/${groupId}`, { - headers: { Authorization: `Bearer ${token}` }, + const result = await axios.get(`${config.GROUPS_API_URL}/${encodeURIComponent(normalizedGroupId)}`, { + headers: requestHeaders, }); return result.data; } catch (err) { - if (err.response.status === HttpStatus.NOT_FOUND) { - return; + const status = _.get(err, "response.status"); + if (status !== HttpStatus.NOT_FOUND) { + throw err; + } + } + + try { + const result = await axios.get(config.GROUPS_API_URL, { + headers: requestHeaders, + params: { + page: 1, + perPage: 1, + oldId: normalizedGroupId, + }, + }); + const groups = _.get(result, "data", []); + if (groups.length > 0) { + return groups[0]; + } + } catch (err) { + const status = _.get(err, "response.status"); + if (status !== HttpStatus.NOT_FOUND) { + throw err; } - throw err; } } From 48aac9085e5c204645501ee87917aed1845a82ea Mon Sep 17 00:00:00 2001 From: Justin Gasper Date: Sun, 22 Feb 2026 16:41:27 +1100 Subject: [PATCH 23/27] PM-1839: add funChallenge flag to challenge API What was broken\nMarathon tournament fun challenges had no explicit API flag, so downstream apps could not reliably distinguish leaderboard-only challenges from prize-paying challenges.\n\nRoot cause\nThe Challenge schema and API payload validation/sanitization did not include a dedicated fun challenge boolean.\n\nWhat was changed\nAdded funChallenge boolean (default false) to Prisma schemas and migration, wired it through create/update validation, Prisma mapping, sanitize output, and Swagger docs.\n\nAny added/updated tests\nUpdated ChallengeService unit test data/assertions to cover funChallenge persistence in create and update flows. --- data-migration/prisma/schema.prisma | 1 + docs/swagger.yaml | 4 ++++ .../20260222100000_add_fun_challenge_flag/migration.sql | 2 ++ prisma/schema.prisma | 3 ++- src/common/prisma-helper.js | 1 + src/services/ChallengeService.js | 3 +++ test/unit/ChallengeService.test.js | 3 +++ 7 files changed, 16 insertions(+), 1 deletion(-) create mode 100644 prisma/migrations/20260222100000_add_fun_challenge_flag/migration.sql diff --git a/data-migration/prisma/schema.prisma b/data-migration/prisma/schema.prisma index 1b94c0a..e01ca4f 100644 --- a/data-migration/prisma/schema.prisma +++ b/data-migration/prisma/schema.prisma @@ -74,6 +74,7 @@ model Challenge { currentPhaseNames String[] // current phase names wiproAllowed Boolean @default(false) + funChallenge Boolean @default(false) // simple arrays for tags and groups (PostgreSQL native array type) tags String[] diff --git a/docs/swagger.yaml b/docs/swagger.yaml index 276d0d3..434463d 100644 --- a/docs/swagger.yaml +++ b/docs/swagger.yaml @@ -2538,6 +2538,10 @@ definitions: type: boolean default: false description: Flag to indicate whether Wipro employees can join challenge + funChallenge: + type: boolean + default: false + description: Flag to indicate this challenge contributes to leaderboard scoring without individual prize payouts descriptionFormat: type: string default: markup diff --git a/prisma/migrations/20260222100000_add_fun_challenge_flag/migration.sql b/prisma/migrations/20260222100000_add_fun_challenge_flag/migration.sql new file mode 100644 index 0000000..57bc492 --- /dev/null +++ b/prisma/migrations/20260222100000_add_fun_challenge_flag/migration.sql @@ -0,0 +1,2 @@ +ALTER TABLE "Challenge" +ADD COLUMN "funChallenge" BOOLEAN NOT NULL DEFAULT false; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 036da57..735f523 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -92,6 +92,7 @@ model Challenge { currentPhaseNames String[] // current phase names wiproAllowed Boolean @default(false) + funChallenge Boolean @default(false) // simple arrays for tags and groups (PostgreSQL native array type) tags String[] @@ -732,4 +733,4 @@ model TimelineTemplatePhase { @@index([timelineTemplateId]) @@index([timelineTemplateId, phaseId]) -} \ No newline at end of file +} diff --git a/src/common/prisma-helper.js b/src/common/prisma-helper.js index 8f4898b..e20a8b2 100644 --- a/src/common/prisma-helper.js +++ b/src/common/prisma-helper.js @@ -95,6 +95,7 @@ function convertChallengeSchemaToPrisma(currentUser, challenge) { "groups", "legacyId", "wiproAllowed", + "funChallenge", "numOfRegistrants", "numOfSubmissions", "numOfCheckpointSubmissions", diff --git a/src/services/ChallengeService.js b/src/services/ChallengeService.js index 44848ea..7ed0499 100644 --- a/src/services/ChallengeService.js +++ b/src/services/ChallengeService.js @@ -1865,6 +1865,7 @@ createChallenge.schema = { privateDescription: Joi.string(), descriptionFormat: Joi.string(), wiproAllowed: Joi.boolean().optional(), + funChallenge: Joi.boolean().optional(), challengeSource: Joi.string(), numOfRegistrants: Joi.number().integer().min(0).optional(), numOfSubmissions: Joi.number().integer().min(0).optional(), @@ -3143,6 +3144,7 @@ updateChallenge.schema = { privateDescription: Joi.string().allow("").optional(), descriptionFormat: Joi.string().optional(), wiproAllowed: Joi.boolean().optional(), + funChallenge: Joi.boolean().optional(), challengeSource: Joi.string().optional(), numOfRegistrants: Joi.number().integer().min(0).optional(), numOfSubmissions: Joi.number().integer().min(0).optional(), @@ -3635,6 +3637,7 @@ function sanitizeChallenge(challenge) { "skills", "reviewers", "wiproAllowed", + "funChallenge", "numOfRegistrants", "numOfSubmissions", "numOfCheckpointSubmissions", diff --git a/test/unit/ChallengeService.test.js b/test/unit/ChallengeService.test.js index 722d4c0..09de12b 100644 --- a/test/unit/ChallengeService.test.js +++ b/test/unit/ChallengeService.test.js @@ -96,6 +96,7 @@ describe('challenge service unit tests', () => { description: 'Prisma Test Challenge', privateDescription: 'Prisma Test Challenge', descriptionFormat: 'html', + funChallenge: true, metadata: [ { name: 'meta-name', @@ -200,6 +201,7 @@ describe('challenge service unit tests', () => { should.equal(result.legacyId, testChallengeData.legacyId) should.equal(result.forumId, testChallengeData.forumId) should.equal(result.status, testChallengeData.status) + should.equal(result.funChallenge, testChallengeData.funChallenge) should.equal(result.createdBy, 'testuser') should.exist(result.startDate) should.exist(result.created) @@ -861,6 +863,7 @@ describe('challenge service unit tests', () => { should.equal(result.legacyId, challengeData.legacyId) should.equal(result.forumId, challengeData.forumId) should.equal(result.status, challengeData.status) + should.equal(result.funChallenge, challengeData.funChallenge) should.equal(!result.attachments || result.attachments.length === 0, true) should.equal(result.createdBy, 'testuser') should.equal(result.updatedBy, '22838965') From c35a6d1edd0ab4d53164c24235950c99bca51b6d Mon Sep 17 00:00:00 2001 From: Justin Gasper Date: Sun, 22 Feb 2026 21:01:09 +1100 Subject: [PATCH 24/27] PM-2205: normalize legacy challenge track aliases What was broken:\n- POST /v6/challenge-tracks rejected payloads that used legacy track values (DEVELOP and QA), returning 400 responses instead of creating/updating tracks.\n\nRoot cause:\n- The ChallengeTrack enum was standardized to DEVELOPMENT/QUALITY_ASSURANCE, but request validation and persistence paths did not normalize legacy values still used by clients.\n\nWhat was changed:\n- Added legacy alias normalization in ChallengeTrackService: DEVELOP -> DEVELOPMENT and QA -> QUALITY_ASSURANCE.\n- Applied normalization for search, create, full update, and partial update flows.\n- Updated Joi validation to accept both canonical enum values and legacy aliases (uppercased input).\n\nAny added/updated tests:\n- Added test/unit/ChallengeTrackService.test.js with focused unit tests for create/search/partial update alias behavior using stubbed Prisma methods. --- src/services/ChallengeTrackService.js | 41 +++++++- test/unit/ChallengeTrackService.test.js | 118 ++++++++++++++++++++++++ 2 files changed, 154 insertions(+), 5 deletions(-) create mode 100644 test/unit/ChallengeTrackService.test.js diff --git a/src/services/ChallengeTrackService.js b/src/services/ChallengeTrackService.js index 6c82529..6f03104 100644 --- a/src/services/ChallengeTrackService.js +++ b/src/services/ChallengeTrackService.js @@ -11,6 +11,30 @@ const constants = require("../../app-constants"); const { getClient, ChallengeTrackEnum } = require("../common/prisma"); const prisma = getClient(); +// Backward compatible aliases kept for payloads still using legacy track enum values. +const legacyTrackAliases = { + DEVELOP: ChallengeTrackEnum.DEVELOPMENT, + QA: ChallengeTrackEnum.QUALITY_ASSURANCE, +}; + +const supportedTrackValues = _.uniq([ + ..._.values(ChallengeTrackEnum), + ...Object.keys(legacyTrackAliases), +]); + +/** + * Normalize legacy track aliases to current enum values. + * @param {String} track raw track value + * @returns {String|null|undefined} normalized track value + */ +function normalizeTrackValue(track) { + if (_.isNil(track)) { + return track; + } + const normalized = _.toUpper(_.trim(track)); + return legacyTrackAliases[normalized] || normalized; +} + /** * Search challenge types * @param {Object} criteria the search criteria @@ -60,7 +84,7 @@ function getSearchFilter(criteria) { ret.legacyId = { equals: criteria.legacyId }; } if (!_.isEmpty(criteria.track)) { - ret.track = { equals: criteria.track }; + ret.track = { equals: normalizeTrackValue(criteria.track) }; } return ret; } @@ -74,7 +98,7 @@ searchChallengeTracks.schema = { isActive: Joi.boolean(), abbreviation: Joi.string(), legacyId: Joi.number().integer().positive(), - track: Joi.string().valid(..._.values(ChallengeTrackEnum)), + track: Joi.string().uppercase().valid(...supportedTrackValues), }), }; @@ -117,9 +141,11 @@ async function checkTrackAbrv(abbreviation) { async function createChallengeTrack(authUser, type) { await checkTrackName(type.name); await checkTrackAbrv(type.abbreviation); + const normalizedTrack = normalizeTrackValue(type.track); let ret = await prisma.challengeTrack.create({ data: { ...type, + track: normalizedTrack, createdBy: authUser.userId, updatedBy: authUser.userId, }, @@ -140,7 +166,7 @@ createChallengeTrack.schema = { isActive: Joi.boolean().required(), abbreviation: Joi.string().required(), legacyId: Joi.number().integer().positive(), - track: Joi.string().valid(..._.values(ChallengeTrackEnum)), + track: Joi.string().uppercase().valid(...supportedTrackValues), }) .required(), }; @@ -186,6 +212,8 @@ async function fullyUpdateChallengeTrack(authUser, id, data) { } if (_.isUndefined(data.track)) { data.track = null; + } else { + data.track = normalizeTrackValue(data.track); } data.updatedBy = authUser.userId; let ret = await prisma.challengeTrack.update({ @@ -209,7 +237,7 @@ fullyUpdateChallengeTrack.schema = { isActive: Joi.boolean().required(), abbreviation: Joi.string().required(), legacyId: Joi.number().integer().positive(), - track: Joi.string().valid(..._.values(ChallengeTrackEnum)), + track: Joi.string().uppercase().valid(...supportedTrackValues), }) .required(), }; @@ -229,6 +257,9 @@ async function partiallyUpdateChallengeTrack(authUser, id, data) { if (data.abbreviation && type.abbreviation.toLowerCase() !== data.abbreviation.toLowerCase()) { await checkTrackAbrv(data.abbreviation); } + if (!_.isUndefined(data.track)) { + data.track = normalizeTrackValue(data.track); + } data.updatedBy = authUser.userId; let ret = await prisma.challengeTrack.update({ where: { id }, @@ -251,7 +282,7 @@ partiallyUpdateChallengeTrack.schema = { isActive: Joi.boolean(), abbreviation: Joi.string(), legacyId: Joi.number().integer().positive(), - track: Joi.string().valid(..._.values(ChallengeTrackEnum)), + track: Joi.string().uppercase().valid(...supportedTrackValues), }) .required(), }; diff --git a/test/unit/ChallengeTrackService.test.js b/test/unit/ChallengeTrackService.test.js new file mode 100644 index 0000000..cebd4ec --- /dev/null +++ b/test/unit/ChallengeTrackService.test.js @@ -0,0 +1,118 @@ +/* + * Unit tests of challenge track service + */ + +require('../../app-bootstrap') +const { v4: uuid } = require('uuid') +const chai = require('chai') + +const service = require('../../src/services/ChallengeTrackService') +const prisma = require('../../src/common/prisma').getClient() + +const should = chai.should() + +describe('challenge track service unit tests', () => { + let originalFindMany + let originalCreate + let originalFindUnique + let originalUpdate + + beforeEach(() => { + originalFindMany = prisma.challengeTrack.findMany + originalCreate = prisma.challengeTrack.create + originalFindUnique = prisma.challengeTrack.findUnique + originalUpdate = prisma.challengeTrack.update + }) + + afterEach(() => { + prisma.challengeTrack.findMany = originalFindMany + prisma.challengeTrack.create = originalCreate + prisma.challengeTrack.findUnique = originalFindUnique + prisma.challengeTrack.update = originalUpdate + }) + + it('create challenge track - accepts DEVELOP alias', async () => { + let createdPayload + prisma.challengeTrack.findMany = async () => [] + prisma.challengeTrack.create = async ({ data }) => { + createdPayload = data + return { + id: uuid(), + name: data.name, + description: data.description || null, + isActive: data.isActive, + abbreviation: data.abbreviation, + legacyId: data.legacyId || null, + track: data.track, + createdAt: new Date(), + createdBy: data.createdBy, + updatedAt: new Date(), + updatedBy: data.updatedBy + } + } + + const result = await service.createChallengeTrack({ userId: 'test-user' }, { + name: `track-${Date.now()}`, + isActive: true, + abbreviation: `abbr-${Date.now()}`, + track: 'DEVELOP' + }) + + should.equal(createdPayload.track, 'DEVELOPMENT') + should.equal(result.track, 'DEVELOPMENT') + }) + + it('search challenge tracks - accepts QA alias', async () => { + let receivedFilter + prisma.challengeTrack.findMany = async ({ where }) => { + receivedFilter = where + return [] + } + + const result = await service.searchChallengeTracks({ + page: 1, + perPage: 10, + track: 'QA' + }) + + should.equal(result.total, 0) + should.equal(receivedFilter.track.equals, 'QUALITY_ASSURANCE') + }) + + it('partially update challenge track - accepts QA alias', async () => { + let updatedPayload + const id = uuid() + prisma.challengeTrack.findUnique = async () => ({ + id, + name: 'Design', + description: null, + isActive: true, + abbreviation: 'DS', + legacyId: null, + track: 'DESIGN', + createdAt: new Date(), + createdBy: 'seed-user', + updatedAt: new Date(), + updatedBy: 'seed-user' + }) + prisma.challengeTrack.findMany = async () => [] + prisma.challengeTrack.update = async ({ data }) => { + updatedPayload = data + return { + id, + ...data, + createdAt: new Date(), + createdBy: 'seed-user', + updatedAt: new Date(), + updatedBy: data.updatedBy + } + } + + const result = await service.partiallyUpdateChallengeTrack({ userId: 'test-user' }, id, { + track: 'QA' + }) + + should.equal(updatedPayload.track, 'QUALITY_ASSURANCE') + should.equal(result.track, 'QUALITY_ASSURANCE') + }) +}) From 001601ccca36e79c5e26330b3ed7fc61df9c5465 Mon Sep 17 00:00:00 2001 From: Justin Gasper Date: Mon, 23 Feb 2026 16:23:16 +1100 Subject: [PATCH 25/27] Better handling of old directProjectId (PM-3999) --- src/services/ChallengeService.js | 13 +++++++++++-- test/unit/ChallengeService.test.js | 31 +++++++++++++++++++++++++++--- 2 files changed, 39 insertions(+), 5 deletions(-) diff --git a/src/services/ChallengeService.js b/src/services/ChallengeService.js index 7ed0499..619e375 100644 --- a/src/services/ChallengeService.js +++ b/src/services/ChallengeService.js @@ -1519,8 +1519,17 @@ async function createChallenge(currentUser, challenge, userToken) { logger.debug(`createChallenge: fetching project details ${buildLogContext()}`); const { directProjectId } = await projectHelper.getProject(projectId, currentUser); + let normalizedDirectProjectId = directProjectId; + if (!_.isNil(directProjectId)) { + normalizedDirectProjectId = _.toNumber(directProjectId); + if (!Number.isInteger(normalizedDirectProjectId)) { + throw new errors.BadRequestError( + `Project with id: ${projectId} has invalid directProjectId: ${directProjectId}` + ); + } + } logger.debug( - `createChallenge: fetched project details (directProjectId=${directProjectId}) ${buildLogContext()}` + `createChallenge: fetched project details (directProjectId=${normalizedDirectProjectId}) ${buildLogContext()}` ); logger.debug(`createChallenge: fetching billing information ${buildLogContext()}`); const { billingAccountId, markup } = await projectHelper.getProjectBillingInformation( @@ -1532,7 +1541,7 @@ async function createChallenge(currentUser, challenge, userToken) { }, markup=${markup}) ${buildLogContext()}` ); - _.set(challenge, "legacy.directProjectId", directProjectId); + _.set(challenge, "legacy.directProjectId", normalizedDirectProjectId); // Ensure billingAccountId is a string or null to match Prisma schema if (billingAccountId !== null && billingAccountId !== undefined) { _.set(challenge, "billing.billingAccountId", String(billingAccountId)); diff --git a/test/unit/ChallengeService.test.js b/test/unit/ChallengeService.test.js index 09de12b..caf376b 100644 --- a/test/unit/ChallengeService.test.js +++ b/test/unit/ChallengeService.test.js @@ -14,6 +14,7 @@ const chai = require('chai') const constants = require('../../app-constants') const service = require('../../src/services/ChallengeService') const helper = require('../../src/common/helper') +const projectHelper = require('../../src/common/project-helper') const testHelper = require('../testHelper') const { getClient, ChallengeStatusEnum, PrizeSetTypeEnum } = require('../../src/common/prisma') const { getReviewClient } = require('../../src/common/review-prisma') @@ -154,9 +155,16 @@ describe('challenge service unit tests', () => { }) after(async () => { - await prisma.challenge.deleteMany({ - where: {id} - }) + const idsToDelete = _.compact([id, id2]) + if (idsToDelete.length > 0) { + await prisma.challenge.deleteMany({ + where: { + id: { + in: idsToDelete + } + } + }) + } await testHelper.clearData() }) @@ -209,6 +217,23 @@ describe('challenge service unit tests', () => { should.equal(result.numOfRegistrants, 0) }) + it('create challenge successfully when project directProjectId is a numeric string', async () => { + const challengeData = _.cloneDeep(testChallengeData) + const originalGetProject = projectHelper.getProject + projectHelper.getProject = async () => ({ directProjectId: '33541' }) + try { + const result = await service.createChallenge( + { isMachine: true, sub: 'sub', userId: 'testuser' }, + challengeData, + config.M2M_FULL_ACCESS_TOKEN + ) + id2 = result.id + should.equal(_.get(result, 'legacy.directProjectId'), 33541) + } finally { + projectHelper.getProject = originalGetProject + } + }) + it('create challenge - type not found', async () => { const challengeData = _.clone(testChallengeData) challengeData.typeId = notFoundId From 4d2ae6903567174f584966ff054d153f16091bdf Mon Sep 17 00:00:00 2001 From: Justin Gasper Date: Wed, 25 Feb 2026 11:54:28 +1100 Subject: [PATCH 26/27] Better challenge status sorting for new work app --- src/services/ChallengeService.js | 81 ++++++++++++++++++++ test/unit/ChallengeService.test.js | 117 +++++++++++++++++++++++++++++ 2 files changed, 198 insertions(+) diff --git a/src/services/ChallengeService.js b/src/services/ChallengeService.js index 619e375..db8c42b 100644 --- a/src/services/ChallengeService.js +++ b/src/services/ChallengeService.js @@ -47,6 +47,37 @@ const allowedSortByValues = _.uniq([ ...Object.keys(sortByAliases), ]); +function normalizeStatusSortValue(statusValue) { + if (_.isNil(statusValue)) { + return null; + } + + const normalizedStatus = _.toString(statusValue).trim().toUpperCase(); + if (!normalizedStatus) { + return null; + } + + return normalizedStatus; +} + +function compareStatusSortValues(aStatusValue, bStatusValue) { + const normalizedA = normalizeStatusSortValue(aStatusValue); + const normalizedB = normalizeStatusSortValue(bStatusValue); + + if (normalizedA === normalizedB) { + return _.toString(aStatusValue).localeCompare(_.toString(bStatusValue)); + } + + if (_.isNil(normalizedA)) { + return 1; + } + if (_.isNil(normalizedB)) { + return -1; + } + + return normalizedA.localeCompare(normalizedB); +} + // Minimal domain adapter for PhaseAdvancer to fetch phase-specific facts. // For now this returns an empty factResponses array which makes the // PhaseAdvancer default to conservative behavior when such facts are needed. @@ -530,6 +561,9 @@ async function searchChallengesViaMemberAccess({ if (aValue === bValue) { return 0; } + if (sortByProp === "status") { + return compareStatusSortValues(aValue, bValue); + } if (_.isNil(aValue)) { return 1; } @@ -1277,6 +1311,53 @@ async function searchChallenges(currentUser, criteria) { challengeInclude, markTiming, })); + } else if (sortByProp === "status") { + const summaryStart = Date.now(); + const summaryRecords = await prisma.challenge.findMany({ + where: prismaFilter.where, + select: { + id: true, + status: true, + }, + }); + markTiming("statusSortSummaryScan", { + durationMs: Date.now() - summaryStart, + candidateCount: summaryRecords.length, + }); + + const sortDirection = sortOrderProp === "asc" ? 1 : -1; + summaryRecords.sort( + (a, b) => compareStatusSortValues(a.status, b.status) * sortDirection + ); + + total = summaryRecords.length; + const offset = (page - 1) * perPage; + const pageSummaries = summaryRecords.slice(offset, offset + perPage); + const pageIds = pageSummaries.map((summary) => summary.id); + if (pageIds.length === 0) { + challenges = []; + } else { + const fetchWhere = _.cloneDeep(prismaFilter.where); + fetchWhere.AND = [...(fetchWhere.AND || []), { id: { in: pageIds } }]; + + const findManyStart = Date.now(); + const fetchedChallenges = await prisma.challenge.findMany({ + where: fetchWhere, + include: challengeInclude, + }); + markTiming("statusSortFetch", { + durationMs: Date.now() - findManyStart, + resultCount: fetchedChallenges.length, + }); + + const challengesById = new Map(); + fetchedChallenges.forEach((challenge) => { + challengesById.set(challenge.id, challenge); + }); + challenges = pageIds + .map((challengeId) => challengesById.get(challengeId)) + .filter((challenge) => !!challenge); + } } else { const countStart = Date.now(); total = await prisma.challenge.count({ ...prismaFilter }); diff --git a/test/unit/ChallengeService.test.js b/test/unit/ChallengeService.test.js index caf376b..3bdcca6 100644 --- a/test/unit/ChallengeService.test.js +++ b/test/unit/ChallengeService.test.js @@ -422,6 +422,123 @@ describe('challenge service unit tests', () => { should.equal(result.numOfSubmissions, 0) should.equal(result.numOfRegistrants, 0) }) + + it('search challenges sorts status alphabetically for member and non-member searches', async () => { + const statusChallenges = [ + { + id: uuid(), + name: `Status Sort Cancelled ${Date.now()}`, + status: ChallengeStatusEnum.CANCELLED_CLIENT_REQUEST + }, + { + id: uuid(), + name: `Status Sort New ${Date.now()}`, + status: ChallengeStatusEnum.NEW + }, + { + id: uuid(), + name: `Status Sort Active ${Date.now()}`, + status: ChallengeStatusEnum.ACTIVE + }, + { + id: uuid(), + name: `Status Sort Completed ${Date.now()}`, + status: ChallengeStatusEnum.COMPLETED + } + ] + const statusChallengeIds = statusChallenges.map(challengeRow => challengeRow.id) + const originalMemberChallengeAccessFindMany = prisma.memberChallengeAccess.findMany + + try { + await Promise.all(statusChallenges.map(challengeRow => prisma.challenge.create({ + data: { + id: challengeRow.id, + name: challengeRow.name, + description: 'status-sort', + privateDescription: 'status-sort', + challengeSource: 'Topcoder', + descriptionFormat: 'html', + timelineTemplate: { connect: { id: data.timelineTemplate.id } }, + type: { connect: { id: data.challenge.typeId } }, + track: { connect: { id: data.challenge.trackId } }, + tags: [], + groups: [], + status: challengeRow.status, + createdBy: 'testuser', + updatedBy: 'testuser' + } + }))) + + prisma.memberChallengeAccess.findMany = async () => + statusChallenges.map(challengeRow => ({ challengeId: challengeRow.id })) + + const ascRes = await service.searchChallenges({ isMachine: true }, { + memberId: 'status-sort-member', + sortBy: 'status', + sortOrder: 'asc', + page: 1, + perPage: 10 + }) + should.deepEqual(_.map(ascRes.result, 'status'), [ + ChallengeStatusEnum.ACTIVE, + ChallengeStatusEnum.CANCELLED_CLIENT_REQUEST, + ChallengeStatusEnum.COMPLETED, + ChallengeStatusEnum.NEW + ]) + + const ascResNoMember = await service.searchChallenges({ isMachine: true }, { + ids: statusChallengeIds, + sortBy: 'status', + sortOrder: 'asc', + page: 1, + perPage: 10 + }) + should.deepEqual(_.map(ascResNoMember.result, 'status'), [ + ChallengeStatusEnum.ACTIVE, + ChallengeStatusEnum.CANCELLED_CLIENT_REQUEST, + ChallengeStatusEnum.COMPLETED, + ChallengeStatusEnum.NEW + ]) + + const descRes = await service.searchChallenges({ isMachine: true }, { + memberId: 'status-sort-member', + sortBy: 'status', + sortOrder: 'desc', + page: 1, + perPage: 10 + }) + should.deepEqual(_.map(descRes.result, 'status'), [ + ChallengeStatusEnum.NEW, + ChallengeStatusEnum.COMPLETED, + ChallengeStatusEnum.CANCELLED_CLIENT_REQUEST, + ChallengeStatusEnum.ACTIVE, + ]) + + const descResNoMember = await service.searchChallenges({ isMachine: true }, { + ids: statusChallengeIds, + sortBy: 'status', + sortOrder: 'desc', + page: 1, + perPage: 10 + }) + should.deepEqual(_.map(descResNoMember.result, 'status'), [ + ChallengeStatusEnum.NEW, + ChallengeStatusEnum.COMPLETED, + ChallengeStatusEnum.CANCELLED_CLIENT_REQUEST, + ChallengeStatusEnum.ACTIVE + ]) + } finally { + prisma.memberChallengeAccess.findMany = originalMemberChallengeAccessFindMany + await prisma.challenge.deleteMany({ + where: { + id: { + in: statusChallengeIds + } + } + }) + } + }) + it('search challenges successfully 1', async () => { const res = await service.searchChallenges({ isMachine: true }, { page: 1, From 52f43181ede313929dfa0a9916c12661d1605429 Mon Sep 17 00:00:00 2001 From: Vasilica Olariu Date: Fri, 27 Feb 2026 14:09:07 +0200 Subject: [PATCH 27/27] Add index on challenge term --- .../20260227120000_add_challenge_term_index/migration.sql | 2 ++ prisma/schema.prisma | 2 ++ 2 files changed, 4 insertions(+) create mode 100644 prisma/migrations/20260227120000_add_challenge_term_index/migration.sql diff --git a/prisma/migrations/20260227120000_add_challenge_term_index/migration.sql b/prisma/migrations/20260227120000_add_challenge_term_index/migration.sql new file mode 100644 index 0000000..2d7465d --- /dev/null +++ b/prisma/migrations/20260227120000_add_challenge_term_index/migration.sql @@ -0,0 +1,2 @@ +-- CreateIndex +CREATE INDEX "ChallengeTerm_challengeId_idx" ON "ChallengeTerm"("challengeId"); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 735f523..e24bbb6 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -376,6 +376,8 @@ model ChallengeTerm { createdBy String updatedAt DateTime @updatedAt updatedBy String + + @@index([challengeId]) } //////////////////////////////////////////