From 1cdf22ce5c1eb61fef99fa2a802faa80abfae8d6 Mon Sep 17 00:00:00 2001 From: himaniraghav3 Date: Tue, 11 Aug 2026 14:48:09 +0530 Subject: [PATCH 1/3] PM-5460 Include Preferred Roles in Profile Completion Calculation --- .circleci/config.yml | 2 +- docs/swagger.yaml | 12 ++++++--- src/services/MemberService.js | 50 +++++++++++++++++++++++++++-------- 3 files changed, 48 insertions(+), 16 deletions(-) diff --git a/.circleci/config.yml b/.circleci/config.yml index 4379e6e..40d8c2c 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -67,7 +67,7 @@ workflows: branches: only: - develop - - PM-4482 + - PM-5460 # Production builds are exectuted only on tagged commits to the # master branch. diff --git a/docs/swagger.yaml b/docs/swagger.yaml index 79bcc8f..379e0e3 100644 --- a/docs/swagger.yaml +++ b/docs/swagger.yaml @@ -2259,11 +2259,12 @@ definitions: properties: skills: type: boolean - gigAvailability: + engagementAvailability: type: boolean - bio: + preferredRoles: type: boolean - profilePicture: + description: True when the member has selected at least one preferred role. + bio: type: boolean workHistory: type: boolean @@ -2274,7 +2275,10 @@ definitions: skillsLastUpdateDate: type: date-time description: 'ISO-8601 formatted date times (YYYY-MM-DDTHH:mm:ss.sssZ)' - gigAvailabilityLastUpdateDate: + engagementAvailabilityLastUpdateDate: + type: date-time + description: 'ISO-8601 formatted date times (YYYY-MM-DDTHH:mm:ss.sssZ)' + preferredRolesLastUpdateDate: type: date-time description: 'ISO-8601 formatted date times (YYYY-MM-DDTHH:mm:ss.sssZ)' workHistoryLastUpdateDate: diff --git a/src/services/MemberService.js b/src/services/MemberService.js index 83910b8..e4bac2d 100644 --- a/src/services/MemberService.js +++ b/src/services/MemberService.js @@ -452,6 +452,7 @@ async function getProfileCompleteness (currentUser, handle, query) { // data.verified = false data.skills = false data.engagementAvailability = false + data.preferredRoles = false data.bio = false data.workHistory = false data.education = false @@ -461,6 +462,7 @@ async function getProfileCompleteness (currentUser, handle, query) { data.skillsLastUpdateDate = undefined data.engagementAvailabilityLastUpdateDate = undefined + data.preferredRolesLastUpdateDate = undefined data.workHistoryLastUpdateDate = undefined data.educationLastUpdateDate = undefined data.locationLastUpdateDate = undefined @@ -480,17 +482,40 @@ async function getProfileCompleteness (currentUser, handle, query) { data.workHistoryLastUpdateDate = new Date(item.updatedAt).toISOString() } - if (item.traitId === 'personalization' && item.traits.data.length > 0 && !data.engagementAvailability) { - const openToWorkTrait = item.traits.data.find(r => Object.keys(r).includes('openToWork')) || {} - const openToWorkData = openToWorkTrait.openToWork - - if (openToWorkData && ( - !openToWorkData.availability || - (openToWorkData.preferredRoles && openToWorkData.preferredRoles.length) - )) { - completeItems += 1 - data.engagementAvailability = true - data.engagementAvailabilityLastUpdateDate = new Date(item.updatedAt).toISOString() + if (item.traitId === 'personalization' && item.traits.data.length > 0) { + if (!data.engagementAvailability) { + const openToWorkTrait = item.traits.data.find(r => Object.keys(r).includes('openToWork')) || {} + const openToWorkData = openToWorkTrait.openToWork + + if (openToWorkData && ( + !openToWorkData.availability || + (openToWorkData.preferredRoles && openToWorkData.preferredRoles.length) + )) { + completeItems += 1 + data.engagementAvailability = true + data.engagementAvailabilityLastUpdateDate = new Date(item.updatedAt).toISOString() + } + } + + // Prefer top-level preferredRoles (current model); fall back to legacy openToWork.preferredRoles + if (!data.preferredRoles) { + const personalizationEntry = item.traits.data.find(r => + Object.prototype.hasOwnProperty.call(r, 'preferredRoles') || + Object.prototype.hasOwnProperty.call(r, 'openToWork') + ) || item.traits.data[0] || {} + + let preferredRoles + if (Object.prototype.hasOwnProperty.call(personalizationEntry, 'preferredRoles')) { + preferredRoles = personalizationEntry.preferredRoles + } else { + preferredRoles = personalizationEntry.openToWork && personalizationEntry.openToWork.preferredRoles + } + + if (Array.isArray(preferredRoles) && preferredRoles.length > 0) { + completeItems += 1 + data.preferredRoles = true + data.preferredRolesLastUpdateDate = new Date(item.updatedAt).toISOString() + } } } }) @@ -504,6 +529,9 @@ async function getProfileCompleteness (currentUser, handle, query) { if (!data.engagementAvailability) { showToast.push('engagementAvailability') } + if (!data.preferredRoles) { + showToast.push('preferredRoles') + } // TODO: Do we use the short bio or the "description" field of the member object? if (member.description && !data.bio) { From 7c91c31b92eb63bf9447601317ef92a166303f1c Mon Sep 17 00:00:00 2001 From: jmgasper Date: Wed, 12 Aug 2026 14:22:14 +1000 Subject: [PATCH 2/3] Remove deprecated Kafka topics --- .github/workflows/trivy.yaml | 22 +++++-- ReadMe.md | 5 +- app-constants.ts | 9 +-- src/services/MemberService.ts | 7 --- src/services/MemberTraitService.ts | 53 +---------------- src/services/SearchService.ts | 47 +++++++++++---- test/unit/SearchService.test.js | 96 ++++++++++++++++++++++++++++++ 7 files changed, 152 insertions(+), 87 deletions(-) diff --git a/.github/workflows/trivy.yaml b/.github/workflows/trivy.yaml index 9cbcf52..ae9f983 100644 --- a/.github/workflows/trivy.yaml +++ b/.github/workflows/trivy.yaml @@ -1,34 +1,44 @@ name: Trivy Scanner -permissions: - contents: read - security-events: write on: push: branches: - main + - master - dev + - develop pull_request: + workflow_dispatch: + +permissions: + actions: read + contents: read + security-events: write + jobs: trivy-scan: - name: Use Trivy + name: Trivy SAST and SCA runs-on: ubuntu-24.04 steps: - name: Checkout code uses: actions/checkout@v4 - name: Run Trivy scanner in repo mode - uses: aquasecurity/trivy-action@0.35.0 + uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25 # v0.36.0 with: + version: "v0.73.0" scan-type: "fs" + scan-ref: "." ignore-unfixed: true format: "sarif" output: "trivy-results.sarif" severity: "CRITICAL,HIGH,UNKNOWN" + limit-severities-for-sarif: true scanners: vuln,secret,misconfig,license github-pat: ${{ secrets.GITHUB_TOKEN }} - name: Upload Trivy scan results to GitHub Security tab - uses: github/codeql-action/upload-sarif@v3 + if: always() + uses: github/codeql-action/upload-sarif@v4 with: sarif_file: "trivy-results.sarif" diff --git a/ReadMe.md b/ReadMe.md index e888b88..2a524f3 100644 --- a/ReadMe.md +++ b/ReadMe.md @@ -9,8 +9,9 @@ - PostgreSQL - Docker and Docker Compose -The service does not consume Kafka messages. It continues to publish events through -the Bus API wrapper, so the existing Bus API and Auth0 configuration remains unchanged. +The service does not consume Kafka messages. It uses the Bus API wrapper only for +email-change verification events; profile and trait mutations do not publish events. +The existing Bus API and Auth0 configuration remains required for email verification. ## Install, Build, and Run diff --git a/app-constants.ts b/app-constants.ts index 29b7d9a..eba3e69 100644 --- a/app-constants.ts +++ b/app-constants.ts @@ -11,14 +11,7 @@ const EVENT_ORIGINATOR = 'topcoder-member-api' const EVENT_MIME_TYPE = 'application/json' const TOPICS = { - MemberCreated: 'member.action.profile.create', - MemberUpdated: 'member.action.profile.update', - EmailChanged: 'member.action.email.profile.emailchange.verification', - MemberTraitCreated: 'member.action.profile.trait.create', - MemberTraitUpdated: 'member.action.profile.trait.update', - MemberTraitDeleted: 'member.action.profile.trait.delete', - MemberSkillsCreated: 'member.action.profile.skills.create', - MemberSkillsUpdated: 'member.action.profile.skills.update' + EmailChanged: 'member.action.email.profile.emailchange.verification' } const MAMBO_GET_REWARDS_ALLOWED_FIELDS = [ diff --git a/src/services/MemberService.ts b/src/services/MemberService.ts index f0dc0d9..1640472 100644 --- a/src/services/MemberService.ts +++ b/src/services/MemberService.ts @@ -905,8 +905,6 @@ async function updateMember (currentUser, handle, query, data) { // convert prisma data to response format prismaHelper.convertMember(result) - // send data to event bus - await helper.postBusEvent(constants.TOPICS.MemberUpdated, result) if (emailChanged) { // send email verification to old email await helper.postBusEvent(constants.TOPICS.EmailChanged, { @@ -1078,7 +1076,6 @@ async function updateHandle (currentUser, handle, query, data) { } prismaHelper.convertMember(updatedMember) - await helper.postBusEvent(constants.TOPICS.MemberUpdated, updatedMember) return cleanMember(currentUser, updatedMember, selectFields) } @@ -1142,7 +1139,6 @@ async function verifyEmail (currentUser, handle, query) { data: _.omit(member, ['maxRating', 'phones']) }) prismaHelper.convertMember(result) - await helper.postBusEvent(constants.TOPICS.MemberUpdated, result) return { emailChangeCompleted, verifiedEmail } } @@ -1221,8 +1217,6 @@ async function uploadPhoto (currentUser, handle, files) { } }) prismaHelper.convertMember(result) - // post bus event - await helper.postBusEvent(constants.TOPICS.MemberUpdated, result) return { photoURL } } @@ -1343,7 +1337,6 @@ async function deleteMember (currentUser, handle, data) { } prismaHelper.convertMember(updatedMember) - await helper.postBusEvent(constants.TOPICS.MemberUpdated, updatedMember) return { handle: deletedHandle, diff --git a/src/services/MemberTraitService.ts b/src/services/MemberTraitService.ts index 526cb86..de9b3ee 100644 --- a/src/services/MemberTraitService.ts +++ b/src/services/MemberTraitService.ts @@ -9,7 +9,6 @@ const moment = require('moment') const helper = require('../common/helper') const logger = require('../common/logger') const errors = require('../common/errors') -const constants = require('../../app-constants') const prisma = require('../common/prisma').getClient() const prismaManager = require('../common/prisma') const skillsPrisma = prismaManager.getSkillsClient() @@ -479,22 +478,6 @@ async function createTraits (currentUser, handle, data) { data: prismaData }) } - // send data to event bus - for (let item of data) { - const trait = { ...item } - trait.userId = helper.bigIntToNumber(member.userId) - trait.createdBy = Number(currentUser.userId || config.TC_WEBSERVICE_USERID) - if (trait.traits) { - trait.traits = { 'traitId': trait.traitId, 'data': trait.traits.data } - } else { - trait.traits = { 'traitId': trait.traitId, 'data': [] } - } - // convert date time - trait.createdAt = new Date().getTime() - // post bus event - await helper.postBusEvent(constants.TOPICS.MemberTraitCreated, trait) - } - // merge result existingTraits = _.concat(existingTraits, data) @@ -575,24 +558,6 @@ async function updateTraits (currentUser, handle, data) { await prisma.memberTraits.create({ data: createData }) } - // post bus events: created for new traits, updated for existing ones - const existingIds = new Set((existingTraits || []).map(t => t.traitId)) - for (let r of result) { - if (!existingIds.has(r.traitId)) { - const trait = { ...r } - trait.userId = helper.bigIntToNumber(member.userId) - trait.createdBy = Number(currentUser.userId || config.TC_WEBSERVICE_USERID) - if (trait.traits) { - trait.traits = { traitId: trait.traitId, data: trait.traits.data } - } else { - trait.traits = { traitId: trait.traitId, data: [] } - } - trait.createdAt = new Date().getTime() - await helper.postBusEvent(constants.TOPICS.MemberTraitCreated, trait) - } else { - await helper.postBusEvent(constants.TOPICS.MemberTraitUpdated, r) - } - } return result } @@ -632,26 +597,10 @@ async function removeTraits (currentUser, handle, query) { }))) }) } - // remove existingTraits data - const memberProfileTraitIds = [] - _.forEach(existingTraits, t => { - if (!traitIds || _.includes(traitIds, t.traitId)) { - memberProfileTraitIds.push(t.traitId) - } - }) - + // remove deleted traits from the data used to recalculate skill-score deductions existingTraits = _.filter(existingTraits, t => !traitIds.includes(t.traitId)) await updateSkillScoreDeduction(currentUser, member, existingTraits) - // post bus event - if (memberProfileTraitIds.length > 0) { - await helper.postBusEvent(constants.TOPICS.MemberTraitDeleted, { - userId: helper.bigIntToNumber(member.userId), - memberProfileTraitIds, - updatedAt: new Date(), - updatedBy: currentUser.userId || currentUser.sub - }) - } } removeTraits.schema = { diff --git a/src/services/SearchService.ts b/src/services/SearchService.ts index 3d6815d..d7b9b55 100644 --- a/src/services/SearchService.ts +++ b/src/services/SearchService.ts @@ -64,33 +64,56 @@ const MEMBER_SELECT_EXCLUDED_FIELDS = ['addresses', 'maxRating', 'skills', 'stat const BULK_IDENTIFIER_MAX_LENGTH = 256 const BULK_EMAIL_REGEX = /^[+_A-Za-z0-9-]+(\.[+_A-Za-z0-9-]+)*@[A-Za-z0-9-]+(\.[A-Za-z0-9]+)*(\.[A-Za-z]{2,}$)/ const BULK_HANDLE_REGEX = /^[-A-Za-z0-9_.`{}[\]]+$/ +const MAX_USER_ID = BigInt('9223372036854775807') +const USER_ID_QUERY_ITEM_SCHEMA = Joi.alternatives().try( + Joi.string().pattern(/^\d+$/).custom((value, helpers) => ( + BigInt(value) <= MAX_USER_ID ? value : helpers.error('string.userIdRange') + )).messages({ + 'string.userIdRange': '{{#label}} must not exceed the maximum 64-bit user ID' + }), + Joi.number().integer().min(0).strict() +) /** * Accept arrays parsed by `qs` as well as the JSON-array query format documented * by this API. Joi 14 coerced JSON array strings automatically, while Joi 18 * no longer coerces them. + * @param {Object} [itemSchema] optional Joi schema used to validate and normalize items * @returns {Object} Joi schema for a query-string array */ -function queryArraySchema () { +function queryArraySchema (itemSchema = null) { + const itemArraySchema = itemSchema && Joi.array().items(itemSchema) + return Joi.any().custom((value, helpers) => { - if (_.isArray(value)) { - return value - } + let parsed = value - if (_.isString(value)) { + if (_.isArray(value)) { + parsed = value + } else if (_.isString(value)) { try { - const parsed = JSON.parse(value) - if (_.isArray(parsed)) { - return parsed - } + parsed = JSON.parse(value) } catch (err) { // Fall through to the same validation error as a non-array value. } } - return helpers.error('array.base') + if (!_.isArray(parsed)) { + return helpers.error('array.base') + } + + if (!itemSchema) { + return parsed + } + + const validation = itemArraySchema.validate(parsed, { abortEarly: false }) + if (validation.error) { + return helpers.error('queryArray.items', { details: validation.error.message }) + } + + return validation.value }).messages({ - 'array.base': '{{#label}} must be an array' + 'array.base': '{{#label}} must be an array', + 'queryArray.items': '{{#label}} contains invalid values: {{#details}}' }) } @@ -442,7 +465,7 @@ searchMembers.schema = { handles: queryArraySchema(), email: Joi.string(), userId: Joi.number(), - userIds: queryArraySchema(), + userIds: queryArraySchema(USER_ID_QUERY_ITEM_SCHEMA), term: Joi.string(), fields: Joi.string(), includeStats: Joi.string(), diff --git a/test/unit/SearchService.test.js b/test/unit/SearchService.test.js index ffc2e59..167be5e 100644 --- a/test/unit/SearchService.test.js +++ b/test/unit/SearchService.test.js @@ -54,6 +54,102 @@ describe('search service unit tests', () => { } }) + it('searchMembers should accept qs decimal-string userIds without losing precision', async () => { + const prisma = prismaManager.getClient() + const originalMemberCount = prisma.member.count + let memberFilter + + try { + prisma.member.count = async (filter) => { + memberFilter = filter + return 0 + } + + await service.searchMembers( + { isMachine: true }, + { + userIds: ['100000013', '9223372036854775807'], + fields: 'handle,userId' + } + ) + + memberFilter.where.AND.should.deep.equal([ + { userId: { in: ['100000013', '9223372036854775807'] } } + ]) + } finally { + prisma.member.count = originalMemberCount + } + }) + + it('searchMembers should preserve an empty userIds array', async () => { + const prisma = prismaManager.getClient() + const originalMemberCount = prisma.member.count + let memberFilter + + try { + prisma.member.count = async (filter) => { + memberFilter = filter + return 0 + } + + await service.searchMembers( + { isMachine: true }, + { + userIds: [], + fields: 'handle,userId' + } + ) + + memberFilter.where.AND.should.deep.equal([]) + } finally { + prisma.member.count = originalMemberCount + } + }) + + it('searchMembers should reject invalid userIds before querying Prisma', async () => { + const prisma = prismaManager.getClient() + const originalMemberCount = prisma.member.count + let memberCountCalled = false + let validationError + + try { + prisma.member.count = async () => { + memberCountCalled = true + return 0 + } + + for (const userIds of [ + ['TCConnCopilot'], + [Number.MAX_SAFE_INTEGER + 1], + ['9223372036854775808'], + [''] + ]) { + validationError = undefined + + try { + await service.searchMembers( + { isMachine: true }, + { + userIds, + fields: 'handle,userId' + } + ) + } catch (err) { + validationError = err + } + + should.exist(validationError) + validationError.isJoi.should.equal(true) + validationError.details[0].path.should.deep.equal(['query', 'userIds']) + validationError.details[0].type.should.equal('queryArray.items') + } + + memberCountCalled.should.equal(false) + } finally { + prisma.member.count = originalMemberCount + } + }) + it('searchMembers should skip stats and skills hydration for explicit field-limited lookups', async () => { const prisma = prismaManager.getClient() const skillsPrisma = prismaManager.getSkillsClient() From 83e5c0ff3d62b41f68bce4b2853e7143deb7eacd Mon Sep 17 00:00:00 2001 From: himaniraghav3 Date: Wed, 12 Aug 2026 10:13:26 +0530 Subject: [PATCH 3/3] PM-5460 Fix preferred roles check --- src/services/MemberService.ts | 59 +++++++++++++++++++++-------------- 1 file changed, 36 insertions(+), 23 deletions(-) diff --git a/src/services/MemberService.ts b/src/services/MemberService.ts index f47c2a3..d3fc265 100644 --- a/src/services/MemberService.ts +++ b/src/services/MemberService.ts @@ -81,6 +81,34 @@ function isEngagementAvailabilityComplete (member, openToWorkData) { return hasAvailability || hasLegacyPreferredRoles } +/** + * Determine whether the member has selected at least one preferred role. + * Prefers the top-level personalization.preferredRoles field (current model) + * and falls back to legacy openToWork.preferredRoles when the top-level key + * is absent. + * @param {Array} personalizationData personalization trait data entries + * @returns {Boolean} true when at least one preferred role is selected + */ +function hasPreferredRolesSelected (personalizationData) { + if (!Array.isArray(personalizationData) || personalizationData.length === 0) { + return false + } + + const personalizationEntry = personalizationData.find(r => + Object.prototype.hasOwnProperty.call(r, 'preferredRoles') || + Object.prototype.hasOwnProperty.call(r, 'openToWork') + ) || personalizationData[0] || {} + + let preferredRoles + if (Object.prototype.hasOwnProperty.call(personalizationEntry, 'preferredRoles')) { + preferredRoles = personalizationEntry.preferredRoles + } else { + preferredRoles = personalizationEntry.openToWork && personalizationEntry.openToWork.preferredRoles + } + + return Array.isArray(preferredRoles) && preferredRoles.length > 0 +} + /** * Resolve compact memberStats track/type UUIDs before deriving current * maxRating labels for member profile responses. @@ -652,32 +680,17 @@ async function getProfileCompleteness (currentUser, handle, query) { const openToWorkTrait = item.traits.data.find(r => Object.keys(r).includes('openToWork')) || {} const openToWorkData = openToWorkTrait.openToWork - if (isEngagementAvailabilityComplete(member, openToWorkData)) { - completeItems += 1 - data.engagementAvailability = true - data.engagementAvailabilityLastUpdateDate = new Date(item.updatedAt).toISOString() - } - - // Prefer top-level preferredRoles (current model); fall back to legacy openToWork.preferredRoles - if (!data.preferredRoles) { - const personalizationEntry = item.traits.data.find(r => - Object.prototype.hasOwnProperty.call(r, 'preferredRoles') || - Object.prototype.hasOwnProperty.call(r, 'openToWork') - ) || item.traits.data[0] || {} - - let preferredRoles - if (Object.prototype.hasOwnProperty.call(personalizationEntry, 'preferredRoles')) { - preferredRoles = personalizationEntry.preferredRoles - } else { - preferredRoles = personalizationEntry.openToWork && personalizationEntry.openToWork.preferredRoles - } - - if (Array.isArray(preferredRoles) && preferredRoles.length > 0) { + if (isEngagementAvailabilityComplete(member, openToWorkData)) { completeItems += 1 - data.preferredRoles = true - data.preferredRolesLastUpdateDate = new Date(item.updatedAt).toISOString() + data.engagementAvailability = true + data.engagementAvailabilityLastUpdateDate = new Date(item.updatedAt).toISOString() } } + + if (!data.preferredRoles && hasPreferredRolesSelected(item.traits.data)) { + completeItems += 1 + data.preferredRoles = true + data.preferredRolesLastUpdateDate = new Date(item.updatedAt).toISOString() } } })