Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
d9bef24
PM-3717 - group challenge events by skill event type
vas3a Feb 10, 2026
c31eade
update query for fetching challenges in skill details
vas3a Feb 10, 2026
693ddee
fix: reduced margin bottom of certification item
hentrymartin Feb 10, 2026
03f87d2
fix: reduced margin bottom of certification item
hentrymartin Feb 10, 2026
c1f9ebb
fix: reduced margin bottom of certification item
hentrymartin Feb 10, 2026
4c8babf
fix: reduced margin bottom of certification item
hentrymartin Feb 10, 2026
6b341d0
Merge pull request #65 from topcoder-platform/PM-3717_verified-skill-…
vas3a Feb 10, 2026
7f5523a
Merge pull request #66 from topcoder-platform/pm-3733_1
hentrymartin Feb 10, 2026
08959ab
PM-3734 #time 5h implemented challenges stats by track section
hentrymartin Feb 10, 2026
fde64b7
PM-3734 #time 15m debug logs
hentrymartin Feb 10, 2026
91238f7
Convert 2nd place & 3rd placements to wins
vas3a Feb 11, 2026
d3d7edb
Merge pull request #67 from topcoder-platform/PM-3717_verified-skill-…
vas3a Feb 11, 2026
9fd80dc
PM-3717 #time 1h Debug and fix db connection for engagements prisma c…
vas3a Feb 11, 2026
7aed2cb
Merge pull request #68 from topcoder-platform/PM-3717_verified-skill-…
vas3a Feb 11, 2026
28aae51
Merge branch 'develop' into pm-3734
hentrymartin Feb 11, 2026
d7412e1
PM-3734 #time 30m testing the implementation
hentrymartin Feb 11, 2026
993b455
PM-3734 #time 30m testing the implementation
hentrymartin Feb 11, 2026
9ba1668
PM-3734 #time 15m get challenge resources with submitter role
hentrymartin Feb 11, 2026
8e32d2f
PM-3734 #time 30m changed styles of topcoder stats
hentrymartin Feb 11, 2026
2d1d3a4
PM-3734 #time 15m changed styles of stats section
hentrymartin Feb 11, 2026
a044ab5
PM-3734 #time 15m changed styles of stats section
hentrymartin Feb 11, 2026
2bbaaf2
removed debug logs
hentrymartin Feb 11, 2026
ead041d
Merge pull request #69 from topcoder-platform/pm-3734
hentrymartin Feb 11, 2026
eac2859
Hide email addresses to copilots unless they share a challenge with a…
jmgasper Feb 11, 2026
49d4905
Merge branch 'develop' of github.com:topcoder-platform/member-api-v6 …
jmgasper Feb 11, 2026
21aa616
Ensure that we restrict copilots to only challenges they are a copilo…
jmgasper Feb 11, 2026
7e60b55
PM-3825 #time 3h make sure we properly restrict visibility over user …
vas3a Feb 12, 2026
3036456
Merge pull request #70 from topcoder-platform/PM-3825_ensure-profile-…
vas3a Feb 12, 2026
67568ef
PM-3826 #time 30m exclude private personalization trait from api resp…
vas3a Feb 13, 2026
f19aa1e
Merge pull request #73 from topcoder-platform/PM-3825_ensure-profile-…
vas3a Feb 13, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .circleci/config.yml
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@ workflows:
branches:
only:
- develop
- pm-3734

# Production builds are exectuted only on tagged commits to the
# master branch.
Expand Down
4 changes: 2 additions & 2 deletions app-constants.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
* App constants
*/
const ADMIN_ROLES = ['administrator', 'admin']
const PROFILE_DOWNLOAD_ROLES = ['project manager', 'Talent Manager']
const SENSITIVE_DATA_ROLES = [...ADMIN_ROLES, 'Talent Manager']
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[⚠️ maintainability]
The change from PROFILE_DOWNLOAD_ROLES to SENSITIVE_DATA_ROLES suggests a broader scope for this constant. Ensure that all usages of PROFILE_DOWNLOAD_ROLES are reviewed to confirm they align with the new intent of handling sensitive data access.

const SEARCH_BY_EMAIL_ROLES = ADMIN_ROLES.concat('tgadmin', 'copilot', 'Project Manager', 'Talent Manager')
const AUTOCOMPLETE_ROLES = ['copilot', 'administrator', 'admin', 'Connect Copilot', 'Connect Account Manager', 'Connect Admin', 'Account Executive', 'Talent Manager', 'Project Manager']

Expand Down Expand Up @@ -36,7 +36,7 @@ const PHONE_REGEX = /^\+[1-9]\d{1,14}$/

module.exports = {
ADMIN_ROLES,
PROFILE_DOWNLOAD_ROLES,
SENSITIVE_DATA_ROLES,
SEARCH_BY_EMAIL_ROLES,
AUTOCOMPLETE_ROLES,
EVENT_ORIGINATOR,
Expand Down
16 changes: 13 additions & 3 deletions config/default.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,18 @@
* The configuration file.
*/

const communicationSecureFields = (
process.env.COMMUNICATION_SECURE_FIELDS
? process.env.COMMUNICATION_SECURE_FIELDS.split(',')
: ['email', 'loginCount', 'lastLoginDate']
)
.map(field => field.trim())
.filter(field => field.length > 0)

if (!communicationSecureFields.includes('email')) {
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[⚠️ performance]
The logic to ensure 'email' is included in communicationSecureFields is correct, but it might be more efficient to handle this within the initial array creation. Consider adding 'email' directly to the default array and then deduplicating after processing the environment variable, if necessary.

communicationSecureFields.unshift('email')
}

module.exports = {
LOG_LEVEL: process.env.LOG_LEVEL || 'debug',
PORT: process.env.PORT || 3000,
Expand Down Expand Up @@ -76,9 +88,7 @@ module.exports = {

// Member identifiable info fields, copilots, admins, or M2M can get these fields
// Anyone in the constants.AUTOCOMPLETE_ROLES will have access to these fields
COMMUNICATION_SECURE_FIELDS: process.env.COMMUNICATION_SECURE_FIELDS
? process.env.COMMUNICATION_SECURE_FIELDS.split(',')
: ['email', 'loginCount', 'lastLoginDate'],
COMMUNICATION_SECURE_FIELDS: communicationSecureFields,

// Member identifiable info traits that are public, anyone can get these fields
MEMBER_PUBLIC_TRAITS: process.env.MEMBER_PUBLIC_TRAITS
Expand Down
150 changes: 150 additions & 0 deletions src/common/copilotEmailAccess.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
const _ = require('lodash')
const helper = require('./helper')
const logger = require('./logger')
const prismaManager = require('./prisma')

const resourcesPrisma = prismaManager.getResourcesClient()
const COPILOT_RESOURCE_ROLE_NAME_LOWER = 'copilot'

function hasCopilotRole (currentUser) {
if (!currentUser || !currentUser.roles) {
return false
}

return helper.checkIfExists(['copilot'], currentUser.roles)
}

function shouldLimitCopilotEmailAccess (currentUser) {
if (!currentUser || currentUser.isMachine) {
return false
}

if (helper.hasAdminRole(currentUser)) {
return false
}

return hasCopilotRole(currentUser)
}

function normalizeUserId (userId) {
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[⚠️ correctness]
The normalizeUserId function returns null for undefined or null inputs, which is fine. However, consider handling cases where userId is an empty string or a non-numeric string more explicitly, as these could lead to unexpected behavior if not intended.

if (userId === null || userId === undefined) {
return null
}

const asNumber = Number(userId)
if (!Number.isNaN(asNumber) && Number.isFinite(asNumber)) {
return String(asNumber)
}

const asString = String(userId).trim()
return asString.length > 0 ? asString : null
}

async function getCopilotAccessibleMemberIdSet (currentUser, memberUserIds) {
const copilotUserId = normalizeUserId(currentUser && currentUser.userId)
const accessibleMemberIds = new Set()

if (!copilotUserId) {
return accessibleMemberIds
}

accessibleMemberIds.add(copilotUserId)

const targetMemberIds = _.uniq(
(memberUserIds || [])
.map(id => normalizeUserId(id))
.filter(Boolean)
)

if (targetMemberIds.length === 0) {
return accessibleMemberIds
}

try {
const copilotResources = await resourcesPrisma.resource.findMany({
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[⚠️ performance]
Consider adding a timeout or limit to the findMany query to prevent potential performance issues if the dataset is large. This can help mitigate risks of long-running queries.

where: {
memberId: copilotUserId,
resourceRole: { nameLower: COPILOT_RESOURCE_ROLE_NAME_LOWER }
},
select: { challengeId: true }
})
const challengeIds = _.uniq(
copilotResources
.map(resource => resource.challengeId)
.filter(challengeId => challengeId !== null && challengeId !== undefined)
)

if (challengeIds.length === 0) {
return accessibleMemberIds
}

const sharedResources = await resourcesPrisma.resource.findMany({
where: {
memberId: { in: targetMemberIds },
challengeId: { in: challengeIds }
},
select: { memberId: true }
})

_.forEach(sharedResources, resource => {
const memberId = normalizeUserId(resource.memberId)
if (memberId) {
accessibleMemberIds.add(memberId)
}
})
} catch (error) {
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[⚠️ maintainability]
The catch block logs an error but does not rethrow or handle it further. Consider whether the function should fail silently or if additional error handling is needed to inform the caller of the failure.

logger.error(
`Unable to compute copilot email visibility for userId=${copilotUserId}: ${error.message}`
)
}

return accessibleMemberIds
}

async function canCopilotAccessMemberEmail (currentUser, memberUserId) {
if (!shouldLimitCopilotEmailAccess(currentUser)) {
return true
}

const normalizedMemberUserId = normalizeUserId(memberUserId)
if (!normalizedMemberUserId) {
return false
}

const accessibleMemberIds = await getCopilotAccessibleMemberIdSet(currentUser, [normalizedMemberUserId])
return accessibleMemberIds.has(normalizedMemberUserId)
}

async function stripUnauthorizedCopilotEmails (currentUser, members) {
if (!shouldLimitCopilotEmailAccess(currentUser) || !Array.isArray(members) || members.length === 0) {
return members
}

const membersWithEmail = members.filter(member => (
member && member.email !== undefined && member.email !== null
))

if (membersWithEmail.length === 0) {
return members
}

const accessibleMemberIds = await getCopilotAccessibleMemberIdSet(
currentUser,
membersWithEmail.map(member => member.userId)
)

_.forEach(membersWithEmail, member => {
const memberUserId = normalizeUserId(member.userId)
if (!memberUserId || !accessibleMemberIds.has(memberUserId)) {
delete member.email
}
})

return members
}

module.exports = {
shouldLimitCopilotEmailAccess,
canCopilotAccessMemberEmail,
stripUnauthorizedCopilotEmails
}
27 changes: 9 additions & 18 deletions src/common/helper.js
Original file line number Diff line number Diff line change
Expand Up @@ -150,22 +150,17 @@ function hasAutocompleteRole (authUser) {
}

/**
* Check if the user has a role which can download profile
* Check if the user has a role that grants access to sensitive member data
* @param {Object} authUser the user
* @returns {Boolean} whether the user has PM role
* @returns {Boolean} whether the user has any "sensitive data" roles
*/
function hasProfileDownloadableRole (authUser) {
function hasSensitiveDataRole (authUser) {
if (!authUser || !authUser.roles) {
return false
}
for (let i = 0; i < authUser.roles.length; i += 1) {
for (let j = 0; j < constants.PROFILE_DOWNLOAD_ROLES.length; j += 1) {
if (authUser.roles[i].toLowerCase() === constants.PROFILE_DOWNLOAD_ROLES[j].toLowerCase()) {
return true
}
}
}
return false

const allowedRolesLower = constants.SENSITIVE_DATA_ROLES.map(r => r.toLowerCase())
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[⚠️ correctness]
The use of map and some for role checking is more efficient and readable than the previous nested loop approach. However, ensure that constants.SENSITIVE_DATA_ROLES is always defined and an array to prevent runtime errors.

return authUser.roles.some(r => allowedRolesLower.includes(r.toLowerCase()))
}

/**
Expand Down Expand Up @@ -372,12 +367,8 @@ function canDownloadProfile (currentUser, member) {
if (currentUser.isMachine) {
return true
}
// Admin can download
if (hasAdminRole(currentUser)) {
return true
}
// PM can download
if (hasProfileDownloadableRole(currentUser)) {
// Admin or Talent Manager can download
if (hasSensitiveDataRole(currentUser)) {
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[⚠️ maintainability]
The comment mentions 'Admin or Talent Manager', but the function hasSensitiveDataRole checks against constants.SENSITIVE_DATA_ROLES. Ensure that constants.SENSITIVE_DATA_ROLES accurately reflects the roles intended by this comment to avoid potential misunderstandings.

return true
}
// Member can download their own profile
Expand Down Expand Up @@ -647,7 +638,7 @@ module.exports = {
hasAdminRole,
hasAutocompleteRole,
hasSearchByEmailRole,
hasProfileDownloadableRole,
hasSensitiveDataRole,
getMemberByHandle,
uploadPhotoToS3,
postBusEvent,
Expand Down
34 changes: 26 additions & 8 deletions src/common/prisma.js
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
const { PrismaPg } = require("@prisma/adapter-pg");
// Use the package-scoped generated clients to avoid cross-package overrides in the monorepo
const {
PrismaClient: MembersPrismaClient,
Expand All @@ -22,8 +23,18 @@ const {
const {
PrismaClient: EngagementsPrismaClient,
} = require("@topcoder/engagements-api-v6/packages/engagements-prisma-client");
const { PrismaPg } = require("@prisma/adapter-pg");
const { Pool } = require("pg");

const extractSchemaFromUrl = (dbUrl) => {
if (!dbUrl) {
return null;
}
try {
const url = new URL(dbUrl);
return url.searchParams.get("schema");
} catch (error) {
return null;
}
};

const skillsDbUrl = process.env.SKILLS_DB_URL;
const challengesDbUrl = process.env.CHALLENGES_DB_URL;
Expand All @@ -44,6 +55,17 @@ const clientOptions = {
],
};

const createPgAdapter = (dbUrl) => {
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[⚠️ correctness]
The createPgAdapter function currently does not handle invalid URLs gracefully. Consider adding validation to ensure dbUrl is a valid URL before attempting to parse it. This will prevent potential runtime errors if an invalid URL is passed.

const schema = extractSchemaFromUrl(dbUrl);
const args = [{ connectionString: dbUrl }];

if (schema) {
args.push({ schema });
}

return new PrismaPg(...args);
};

let membersClient;
const getMembersClient = () => {
if (!membersClient) {
Expand Down Expand Up @@ -97,21 +119,17 @@ const getAcademyClient = () => {
};

let engagementsClient;
let engagementsPool;
const getEngagementsClient = () => {
if (!engagementsClient) {
if (!engagementsDbUrl) {
throw new Error(
"ENGAGEMENTS_DB_URL must be set for engagements Prisma client",
);
}
if (!engagementsPool) {
engagementsPool = new Pool({ connectionString: engagementsDbUrl });
}
const adapter = new PrismaPg(engagementsPool);

engagementsClient = new EngagementsPrismaClient({
...clientOptions,
adapter,
adapter: createPgAdapter(engagementsDbUrl),
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[⚠️ correctness]
The createPgAdapter function is used to create the adapter for the EngagementsPrismaClient. Ensure that the extractSchemaFromUrl function correctly extracts the schema from the URL and that the schema exists in the database. If the schema does not exist, it could lead to runtime errors.

});
}
return engagementsClient;
Expand Down
3 changes: 2 additions & 1 deletion src/common/prismaHelper.js
Original file line number Diff line number Diff line change
Expand Up @@ -111,8 +111,9 @@ function buildMemberSkills (skillList) {
const events = _.orderBy(first.skill.skillEvents || [], 'createdAt', 'desc')
const grouped = _.groupBy(events, 'sourceType.name')
ret.lastUsedDate = events[0].createdAt

ret.activity = _.mapValues(grouped, (v, k) => ({
sources: _.uniqBy(v, 'sourceId').map(s => s.sourceId)
sources: v,
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[❗❗ correctness]
The change from _.uniqBy(v, 'sourceId').map(s => s.sourceId) to v means that the sources array will now contain full event objects instead of just unique sourceIds. Ensure that this change is intentional and that the consuming code can handle the new structure.

}))
}

Expand Down
Loading
Loading