diff --git a/.circleci/config.yml b/.circleci/config.yml index 0d7d4ee..e7df356 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -67,6 +67,7 @@ workflows: branches: only: - develop + - pm-3734 # Production builds are exectuted only on tagged commits to the # master branch. diff --git a/app-constants.js b/app-constants.js index 62aa3f7..29b7d9a 100644 --- a/app-constants.js +++ b/app-constants.js @@ -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'] 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'] @@ -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, diff --git a/config/default.js b/config/default.js index 304b582..238c766 100644 --- a/config/default.js +++ b/config/default.js @@ -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')) { + communicationSecureFields.unshift('email') +} + module.exports = { LOG_LEVEL: process.env.LOG_LEVEL || 'debug', PORT: process.env.PORT || 3000, @@ -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 diff --git a/src/common/copilotEmailAccess.js b/src/common/copilotEmailAccess.js new file mode 100644 index 0000000..928c7cd --- /dev/null +++ b/src/common/copilotEmailAccess.js @@ -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) { + 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({ + 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) { + 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 +} diff --git a/src/common/helper.js b/src/common/helper.js index e8ae9cb..97efe54 100644 --- a/src/common/helper.js +++ b/src/common/helper.js @@ -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()) + return authUser.roles.some(r => allowedRolesLower.includes(r.toLowerCase())) } /** @@ -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)) { return true } // Member can download their own profile @@ -647,7 +638,7 @@ module.exports = { hasAdminRole, hasAutocompleteRole, hasSearchByEmailRole, - hasProfileDownloadableRole, + hasSensitiveDataRole, getMemberByHandle, uploadPhotoToS3, postBusEvent, diff --git a/src/common/prisma.js b/src/common/prisma.js index 291838d..c74443b 100644 --- a/src/common/prisma.js +++ b/src/common/prisma.js @@ -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, @@ -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; @@ -44,6 +55,17 @@ const clientOptions = { ], }; +const createPgAdapter = (dbUrl) => { + const schema = extractSchemaFromUrl(dbUrl); + const args = [{ connectionString: dbUrl }]; + + if (schema) { + args.push({ schema }); + } + + return new PrismaPg(...args); +}; + let membersClient; const getMembersClient = () => { if (!membersClient) { @@ -97,7 +119,6 @@ const getAcademyClient = () => { }; let engagementsClient; -let engagementsPool; const getEngagementsClient = () => { if (!engagementsClient) { if (!engagementsDbUrl) { @@ -105,13 +126,10 @@ const getEngagementsClient = () => { "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), }); } return engagementsClient; diff --git a/src/common/prismaHelper.js b/src/common/prismaHelper.js index 1eed63e..a3b14da 100644 --- a/src/common/prismaHelper.js +++ b/src/common/prismaHelper.js @@ -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, })) } diff --git a/src/common/profileTemplate.js b/src/common/profileTemplate.js index e269998..56b19f9 100644 --- a/src/common/profileTemplate.js +++ b/src/common/profileTemplate.js @@ -142,12 +142,15 @@ const styles = StyleSheet.create({ // Topcoder Activity activityItem: { fontSize: 10, - marginBottom: 5, + marginBottom: 2, color: '#000000' }, activityLabel: { fontWeight: 'bold' }, + activityValue: { + fontWeight: 'normal' + }, // Education/Experience items itemTitle: { fontSize: 11, @@ -202,13 +205,12 @@ const styles = StyleSheet.create({ // Certifications & Courses certificationItem: { fontSize: 10, - marginBottom: 8, + marginBottom: 2, lineHeight: 1.5, color: '#000000' }, courseItem: { fontSize: 10, - marginTop: 10, marginBottom: 8, lineHeight: 1.5, color: '#000000' @@ -490,9 +492,33 @@ function buildProfileTemplate (pdfData) { } // Topcoder Activity Section - if (topcoderActivity.specialRole || topcoderActivity.achievements) { + const hasStatsByTrack = topcoderActivity.statsByTrack && topcoderActivity.statsByTrack.length > 0 + if (topcoderActivity.specialRole || topcoderActivity.achievements || hasStatsByTrack) { const activityContent = [createSectionHeader('TOPCODER ACTIVITY')] + // Member stats by track first (Development: wins, submissions, challenges; Competitive Programming: rating, wins, competitions) + if (hasStatsByTrack) { + const statsItems = topcoderActivity.statsByTrack.map((stat, index) => { + const isCompetitiveProgramming = stat.trackName === 'Competitive Programming' + const valueText = isCompetitiveProgramming + ? `${stat.rating ?? 0} rating, ${stat.wins ?? 0} wins, ${stat.competitions ?? 0} competitions` + : `${stat.wins ?? 0} wins, ${stat.submissions ?? 0} submissions, ${stat.challenges ?? 0} challenges` + return React.createElement( + Text, + { key: `stats-track-${index}`, style: styles.activityItem }, + React.createElement(Text, { style: styles.activityLabel }, `${stat.trackName}: `), + React.createElement(Text, { style: styles.activityValue }, valueText) + ) + }) + activityContent.push( + React.createElement( + View, + { key: 'stats-by-track-wrapper', style: { marginBottom: 15 } }, + ...statsItems + ) + ) + } + if (topcoderActivity.specialRole) { activityContent.push( React.createElement( diff --git a/src/services/MemberService.js b/src/services/MemberService.js index d6a1651..3ffb5d3 100644 --- a/src/services/MemberService.js +++ b/src/services/MemberService.js @@ -13,6 +13,7 @@ const errors = require('../common/errors') const constants = require('../../app-constants') const mailchimp = require('../common/mailchimp') const hubspot = require('../common/hubspot') +const copilotEmailAccess = require('../common/copilotEmailAccess') const memberTraitService = require('./MemberTraitService') const mime = require('mime-types') const fileType = require('file-type') @@ -32,6 +33,7 @@ const academyPrisma = prismaManager.getAcademyClient() const resourcesPrisma = prismaManager.getResourcesClient() const engagementsPrisma = prismaManager.getEngagementsClient() const profilePDFService = require('./ProfilePDFService') +const StatisticsService = require('./StatisticsService') const request = require('request') const cityTimezones = require('city-timezones') const moment = require('moment-timezone') @@ -97,19 +99,19 @@ function omitMemberAttributes (currentUser, mb) { let res = _.omit(mb, INTERNAL_MEMBER_FIELDS) // remove identifiable info fields if user is not admin, not M2M and not member himself const canManageMember = helper.canManageMember(currentUser, mb) - const hasAutocompleteRole = helper.hasAutocompleteRole(currentUser) - const isAdminOrM2M = currentUser && (currentUser.isMachine || helper.hasAdminRole(currentUser)) + const hasSensitiveDataRole = helper.hasSensitiveDataRole(currentUser) + const isM2M = currentUser && currentUser.isMachine const isSelf = currentUser && currentUser.handle && mb.handleLower && currentUser.handle.trim().toLowerCase() === mb.handleLower.trim().toLowerCase() - const canSeeIdentityVerified = isAdminOrM2M || hasAutocompleteRole || isSelf - const canSeeRecentActivity = isAdminOrM2M || hasAutocompleteRole || isSelf + const canSeeIdentityVerified = isM2M || hasSensitiveDataRole || isSelf + const canSeeRecentActivity = isM2M || hasSensitiveDataRole || isSelf if (!canManageMember) { res = _.omit(res, config.MEMBER_SECURE_FIELDS) res = helper.secureMemberAddressData(res) res = helper.truncateLastName(res) } - if (!canManageMember && !hasAutocompleteRole) { + if (!canManageMember && !hasSensitiveDataRole) { res = _.omit(res, config.COMMUNICATION_SECURE_FIELDS) if (res.phones) { delete res.phones @@ -124,6 +126,11 @@ function omitMemberAttributes (currentUser, mb) { delete res.recentActivity } + // Remove availableForGigs if user doesn't have permission + if (!canManageMember && !hasSensitiveDataRole && res.availableForGigs !== undefined) { + delete res.availableForGigs + } + return res } @@ -251,16 +258,16 @@ async function getMemberData (handle, query, allowedFields = MEMBER_FIELDS) { */ async function getMember (currentUser, handle, query) { // Check if user has permission to see phones - // Phones are visible to: self, admin, M2M, or users with autocomplete roles (Talent Manager, etc.) - const hasAutocompleteRole = helper.hasAutocompleteRole(currentUser) - const isAdminOrM2M = currentUser && (currentUser.isMachine || helper.hasAdminRole(currentUser)) + // Phones are visible to: self, users with sensitive data roles (Talent Manager, admin) and M2M + const hasSensitiveDataRole = helper.hasSensitiveDataRole(currentUser) + const isM2M = currentUser && currentUser.isMachine const isSelf = currentUser && currentUser.handle && currentUser.handle.trim().toLowerCase() === handle.trim().toLowerCase() - const canSeePhones = isAdminOrM2M || hasAutocompleteRole || isSelf - const canSeeRecentActivity = isAdminOrM2M || hasAutocompleteRole || isSelf + const canSeePhones = isM2M || hasSensitiveDataRole || isSelf + const canSeeRecentActivity = isM2M || hasSensitiveDataRole || isSelf // Identity verified field has same access control as phones - const canSeeIdentityVerified = isAdminOrM2M || hasAutocompleteRole || isSelf + const canSeeIdentityVerified = isM2M || hasSensitiveDataRole || isSelf const allowedFields = canSeePhones ? [...MEMBER_FIELDS, 'phones'] : MEMBER_FIELDS const threeMonthsAgo = new Date() @@ -328,7 +335,20 @@ async function getMember (currentUser, handle, query) { selectFields.push('identityVerified') } // clean member fields according to current user - return cleanMember(currentUser, member, selectFields) + const response = cleanMember(currentUser, member, selectFields) + + // Copilots can only see member email when they share at least one challenge resource. + if (response.email !== undefined) { + const canAccessMemberEmail = await copilotEmailAccess.canCopilotAccessMemberEmail( + currentUser, + member.userId + ) + if (!canAccessMemberEmail) { + delete response.email + } + } + + return response } getMember.schema = { @@ -1495,6 +1515,122 @@ async function getMemberRoles (userId) { } } +/** Track enum to display name (for standard tracks: wins, submissions, challenges) */ +const TRACK_DISPLAY_NAMES = { + DEVELOPMENT: 'Development', + DESIGN: 'Design', + DATA_SCIENCE: 'Competitive Programming', + QUALITY_ASSURANCE: 'Quality Assurance' +} + +/** + * Fetch member stats by challenge track for PDF: wins and submissions from ChallengeWinner, + * registrations (challenges count) from resources schema, grouped by track. + * @param {Number} userId member userId + * @param {Object} challengesPrisma challenges Prisma client + * @param {Object} resourcesPrisma resources Prisma client + * @returns {Promise>} + */ +async function fetchMemberStatsByTrack (userId, challengesPrisma, resourcesPrisma) { + const trackMap = {} // track enum -> { wins, submissions, challenges } or { rating, wins, competitions } for DATA_SCIENCE + + try { + const numUserId = typeof userId === 'bigint' ? helper.bigIntToNumber(userId) : userId + + // 1) ChallengeWinner: wins (and submissions if same table) by track + const winners = await challengesPrisma.ChallengeWinner.findMany({ + where: { userId: numUserId }, + include: { + challenge: { + include: { track: true } + } + } + }) + + for (const w of winners) { + const trackEnum = w.challenge?.track?.track + if (!trackEnum) continue + if (!trackMap[trackEnum]) { + const isDataScience = trackEnum === 'DATA_SCIENCE' + trackMap[trackEnum] = isDataScience + ? { wins: 0, competitions: 0, rating: undefined } + : { wins: 0, submissions: 0, challenges: 0 } + } + const row = trackMap[trackEnum] + if (row.wins !== undefined) row.wins += 1 + if (row.submissions !== undefined) row.submissions += 1 + } + + // 2) Resources: registrations (distinct challenges) by track + const memberIdStr = String(userId) + const resources = await resourcesPrisma.resource.findMany({ + where: { + memberId: memberIdStr, + resourceRole: { + nameLower: 'submitter' + } + }, + select: { challengeId: true } + }) + const challengeIds = [...new Set(resources.map(r => r.challengeId).filter(Boolean))] + if (challengeIds.length > 0) { + const challenges = await challengesPrisma.Challenge.findMany({ + where: { id: { in: challengeIds } }, + include: { track: true } + }) + const challengeIdToTrack = {} + for (const c of challenges) { + const trackEnum = c.track?.track + if (trackEnum) challengeIdToTrack[c.id] = trackEnum + } + const challengesPerTrack = {} + for (const cid of challengeIds) { + const trackEnum = challengeIdToTrack[cid] + if (!trackEnum) continue + if (!challengesPerTrack[trackEnum]) challengesPerTrack[trackEnum] = 0 + challengesPerTrack[trackEnum] += 1 + } + for (const [trackEnum, count] of Object.entries(challengesPerTrack)) { + if (!trackMap[trackEnum]) { + const isDataScience = trackEnum === 'DATA_SCIENCE' + trackMap[trackEnum] = isDataScience + ? { wins: 0, competitions: count, rating: undefined } + : { wins: 0, submissions: 0, challenges: count } + } else { + if (trackMap[trackEnum].challenges !== undefined) trackMap[trackEnum].challenges = count + if (trackMap[trackEnum].competitions !== undefined) trackMap[trackEnum].competitions = count + } + } + } + + const statsByTrack = [] + for (const [trackEnum, counts] of Object.entries(trackMap)) { + const trackName = TRACK_DISPLAY_NAMES[trackEnum] || trackEnum + const hasAny = Object.values(counts).some(v => typeof v === 'number' && v > 0) + if (!hasAny && (counts.rating == null || counts.rating === 0)) continue + if (trackEnum === 'DATA_SCIENCE') { + statsByTrack.push({ + trackName, + rating: counts.rating ?? 0, + wins: counts.wins ?? 0, + competitions: counts.competitions ?? 0 + }) + } else { + statsByTrack.push({ + trackName, + wins: counts.wins ?? 0, + submissions: counts.submissions ?? 0, + challenges: counts.challenges ?? 0 + }) + } + } + return statsByTrack + } catch (err) { + logger.warn(`fetchMemberStatsByTrack failed for user ${userId}: ${err.message}`) + return [] + } +} + /** * Aggregate all data needed for PDF generation * @param {Object} currentUser the user who performs operation @@ -1592,6 +1728,36 @@ async function aggregatePDFData (currentUser, handle) { // Fetch gamification achievements const achievements = await fetchGamificationAchievements(userId) + // Fetch member stats by track (wins, submissions, challenges from ChallengeWinner + resources) + let statsByTrack = [] + try { + statsByTrack = await fetchMemberStatsByTrack(userId, challengesPrisma, resourcesPrisma) + } catch (err) { + logger.warn(`aggregatePDFData: statsByTrack failed for ${handle}: ${err.message}`) + } + + // Merge Competitive Programming rating from stats endpoint (same source as GET /members/:handle/stats) + try { + const statsResult = await StatisticsService.getMemberStats(currentUser, handle, {}) + const statsResponse = Array.isArray(statsResult) && statsResult.length > 0 ? statsResult[0] : null + if (statsResponse && statsResponse.DATA_SCIENCE) { + const ds = statsResponse.DATA_SCIENCE + const rating = (ds.SRM && ds.SRM.rank && ds.SRM.rank.rating != null) + ? ds.SRM.rank.rating + : (ds.MARATHON_MATCH && ds.MARATHON_MATCH.rank && ds.MARATHON_MATCH.rank.rating != null) + ? ds.MARATHON_MATCH.rank.rating + : 0 + const cpEntry = statsByTrack.find(e => e.trackName === 'Competitive Programming') + if (cpEntry) { + cpEntry.rating = rating + } else if (rating > 0) { + statsByTrack.push({ trackName: 'Competitive Programming', rating, wins: 0, competitions: 0 }) + } + } + } catch (err) { + logger.warn(`aggregatePDFData: getMemberStats for rating failed for ${handle}: ${err.message}`) + } + // Fetch certifications and courses const { certifications, courses } = await fetchCertificationsAndCourses(userId) @@ -1661,7 +1827,8 @@ async function aggregatePDFData (currentUser, handle) { // Topcoder activity topcoderActivity: { specialRole: specialRoles.length > 0 ? `Topcoder Special Role: ${specialRoles.join(', ')}` : null, - achievements: achievements + achievements: achievements, + statsByTrack }, // Certifications and courses certifications, @@ -1735,6 +1902,9 @@ async function getMemberSkill (currentUser, handle, skillId) { select: { createdAt: true, sourceId: true, + skillEventType : { + select: { name: true } + }, sourceType: { select: { name: true } } @@ -1756,62 +1926,31 @@ async function getMemberSkill (currentUser, handle, skillId) { if (skill.activity) { const fetchPromises = [] - // Prepare challenge fetch – group by resource role, last 3 per role by endDate + // Prepare challenge fetch const challengeSources = _.get(skill, 'activity.challenge.sources', []) if (challengeSources.length > 0) { - const challengeIds = challengeSources - + const winMap = {challenge_2nd_place: 'challenge_win', challenge_3rd_place: 'challenge_win'}; + const challengeIds = _.uniqBy(challengeSources, 'sourceId').map(s => s.sourceId) + const roleMap = new Map() + challengeSources.forEach(source => { + if (!roleMap.has(source.sourceId)) { + roleMap.set(source.sourceId, new Set()) + } + roleMap.get(source.sourceId).add(winMap[source.skillEventType.name] ?? source.skillEventType.name); + }) + fetchPromises.push( - Promise.all([ - resourcesPrisma.resource.findMany({ - where: { - memberId: String(member.userId), - challengeId: { in: challengeIds } - }, - select: { challengeId: true, resourceRole: { select: { name: true } } } - }), - // Get challenge details (including endDate for ordering) from challenges DB - challengesPrisma.Challenge.findMany({ - where: { id: { in: challengeIds } }, - select: { - id: true, - name: true, - endDate: true, - taskIsTask: true, - winners: { - where: { userId: helper.bigIntToNumber(member.userId) }, - select: { userId: true } - } - } - }) - ]).then(([resources, dbChallenges]) => { - const roleMap = new Map() - resources.forEach(resource => { - if (!roleMap.has(resource.challengeId)) { - roleMap.set(resource.challengeId, new Set()) - } - roleMap.get(resource.challengeId).add(resource.resourceRole.name) - }) + challengesPrisma.Challenge.findMany({ + where: { id: { in: challengeIds } }, + select: { id: true, name: true } + }).then(dbChallenges => { const challengeMap = new Map(dbChallenges.map(c => [c.id, c])) - const winnerSet = new Set( - dbChallenges - .filter(c => c.winners && c.winners.length > 0) - .map(c => c.id) - ) - // Group challenges by role const groups = {} for (const challengeId of challengeIds) { const challenge = challengeMap.get(challengeId) if (challenge) { - const roles = roleMap.get(challengeId) - const roleNames = roles && roles.size - ? Array.from(roles).map(role => ( - role === 'Submitter' && winnerSet.has(challengeId) - ? 'Winner' - : role - )) - : [challenge?.taskIsTask ? 'Task' : 'Unknown'] + const roleNames = roleMap.get(challengeId) roleNames.forEach(roleName => { if (!groups[roleName]) groups[roleName] = [] @@ -1819,7 +1958,6 @@ async function getMemberSkill (currentUser, handle, skillId) { }) } } - // For each role: sort by endDate desc, keep last 3, include total count skill.activity.challenge = Object.fromEntries( Object.entries(groups).map(([role, challenges]) => { @@ -1843,7 +1981,7 @@ async function getMemberSkill (currentUser, handle, skillId) { // Prepare certification fetch const certificationSources = _.get(skill, 'activity.certification.sources', []) if (certificationSources.length > 0) { - const certificationIds = certificationSources.filter(Boolean) + const certificationIds = _.uniqBy(certificationSources, 'sourceId').map(s => s.sourceId).filter(Boolean) if (certificationIds.length > 0) { fetchPromises.push( academyPrisma.CertificationEnrollments.findMany({ @@ -1877,7 +2015,7 @@ async function getMemberSkill (currentUser, handle, skillId) { // Prepare course fetch const courseSources = _.get(skill, 'activity.course.sources', []) if (courseSources.length > 0) { - const courseIds = courseSources.filter(Boolean) + const courseIds = _.uniqBy(courseSources, 'sourceId').map(s => s.sourceId).filter(Boolean) if (courseIds.length > 0) { fetchPromises.push( academyPrisma.FccCertificationProgresses.findMany({ @@ -1910,7 +2048,7 @@ async function getMemberSkill (currentUser, handle, skillId) { // Prepare engagement fetch const engagementSources = _.get(skill, 'activity.engagement.sources', []) if (engagementSources.length > 0) { - const engagementIds = engagementSources.filter(Boolean) + const engagementIds = _.uniqBy(engagementSources, 'sourceId').map(s => s.sourceId).filter(Boolean) if (engagementIds.length > 0) { fetchPromises.push( engagementsPrisma.EngagementAssignment.findMany({ diff --git a/src/services/MemberTraitService.js b/src/services/MemberTraitService.js index 699d54e..c4028f3 100644 --- a/src/services/MemberTraitService.js +++ b/src/services/MemberTraitService.js @@ -203,13 +203,13 @@ async function getTraits (currentUser, handle, query) { const traitIds = helper.parseCommaSeparatedString(query.traitIds, TRAIT_IDS) || TRAIT_IDS const fields = helper.parseCommaSeparatedString(query.fields, TRAIT_FIELDS) || TRAIT_FIELDS - const hasAutocompleteRole = helper.hasAutocompleteRole(currentUser) - const isAdminOrM2M = currentUser && (currentUser.isMachine || helper.hasAdminRole(currentUser)) + const hasSensitiveDataRole = helper.hasSensitiveDataRole(currentUser) + const isM2M = currentUser && currentUser.isMachine const isSelf = currentUser && currentUser.handle && currentUser.handle.trim().toLowerCase() === handle.trim().toLowerCase() // can read private personalisation info on a member - const canReadPrivate = isAdminOrM2M || hasAutocompleteRole || isSelf + const canReadPrivate = isM2M || hasSensitiveDataRole || isSelf const personalizationFilter = canReadPrivate ? {} diff --git a/src/services/SearchService.js b/src/services/SearchService.js index 44e0178..6e029c7 100644 --- a/src/services/SearchService.js +++ b/src/services/SearchService.js @@ -10,6 +10,7 @@ const config = require('config') const helper = require('../common/helper') const logger = require('../common/logger') const errors = require('../common/errors') +const copilotEmailAccess = require('../common/copilotEmailAccess') const prismaHelper = require('../common/prismaHelper') const prismaManager = require('../common/prisma') const prisma = prismaManager.getClient() @@ -127,13 +128,41 @@ function omitMemberAttributes (currentUser, query, allowedValues) { if (!currentUser || (!currentUser.isMachine && !helper.hasAdminRole(currentUser))) { fields = _.without(fields, ...config.MEMBER_SECURE_FIELDS) } - // If the current user does not have an autocompleterole, remove the communication fields - if (!currentUser || (!currentUser.isMachine && !helper.hasAutocompleteRole(currentUser))) { + // If the current user does not have sensitive data role (Admin or Talent Manager), remove the communication fields + if (!currentUser || (!currentUser.isMachine && !helper.hasSensitiveDataRole(currentUser))) { fields = _.without(fields, ...config.COMMUNICATION_SECURE_FIELDS) } return fields } +function prepareFieldsForCopilotEmailFiltering (currentUser, requestedFields) { + const shouldLimitCopilotEmailAccess = copilotEmailAccess.shouldLimitCopilotEmailAccess(currentUser) + const includesEmail = _.includes(requestedFields, 'email') + const includesUserId = _.includes(requestedFields, 'userId') + const shouldAddUserIdForFiltering = shouldLimitCopilotEmailAccess && includesEmail && !includesUserId + + return { + fieldsForQuery: shouldAddUserIdForFiltering + ? [...requestedFields, 'userId'] + : requestedFields, + removeUserIdAfterFiltering: shouldAddUserIdForFiltering + } +} + +async function applyCopilotEmailFiltering (currentUser, records, removeUserIdAfterFiltering) { + if (!Array.isArray(records) || records.length === 0) { + return records + } + + await copilotEmailAccess.stripUnauthorizedCopilotEmails(currentUser, records) + + if (removeUserIdAfterFiltering) { + return _.map(records, record => _.omit(record, 'userId')) + } + + return records +} + function parseCsvLine (line) { const fields = [] let field = '' @@ -279,7 +308,25 @@ function normalizeBulkIdentifiers (rawIdentifiers) { * @returns {Object} the search result */ async function searchMembers (currentUser, query) { - const fields = omitMemberAttributes(currentUser, query, MEMBER_FIELDS) + const requestedFields = omitMemberAttributes(currentUser, query, MEMBER_FIELDS) + const preparedFields = prepareFieldsForCopilotEmailFiltering(currentUser, requestedFields) + const shouldLimitCopilotEmailAccess = copilotEmailAccess.shouldLimitCopilotEmailAccess(currentUser) + const isEmailLookup = query.email != null && query.email.length > 0 + + let fieldsForQuery = [...preparedFields.fieldsForQuery] + let removeUserIdAfterFiltering = preparedFields.removeUserIdAfterFiltering + let removeEmailAfterFiltering = false + + if (shouldLimitCopilotEmailAccess && isEmailLookup) { + if (!_.includes(fieldsForQuery, 'email')) { + fieldsForQuery.push('email') + removeEmailAfterFiltering = true + } + if (!_.includes(fieldsForQuery, 'userId')) { + fieldsForQuery.push('userId') + removeUserIdAfterFiltering = true + } + } const logContext = _.omitBy({ handle: query.handle, @@ -321,7 +368,7 @@ async function searchMembers (currentUser, query) { restrictStatus: !(canBypassStatusRestriction || isExplicitMemberLookup) }) logger.debug(`searchMembers: prisma filter ${stringifyForLog(prismaFilter)}`) - const searchData = await fillMembers(prismaFilter, query, fields) + const searchData = await fillMembers(prismaFilter, query, fieldsForQuery) // secure address data const canManageMember = currentUser && (currentUser.isMachine || helper.hasAdminRole(currentUser)) @@ -330,6 +377,23 @@ async function searchMembers (currentUser, query) { searchData.result = _.map(searchData.result, res => helper.truncateLastName(res)) } + searchData.result = await applyCopilotEmailFiltering( + currentUser, + searchData.result, + removeUserIdAfterFiltering + ) + + if (shouldLimitCopilotEmailAccess && isEmailLookup) { + searchData.result = searchData.result.filter(result => ( + result.email !== undefined && result.email !== null + )) + searchData.total = searchData.result.length + } + + if (removeEmailAfterFiltering) { + searchData.result = _.map(searchData.result, result => _.omit(result, 'email')) + } + logger.debug(`searchMembers: returning total=${searchData.total} resultCount=${_.size(searchData.result)} page=${searchData.page} perPage=${searchData.perPage}`) return searchData @@ -711,14 +775,18 @@ const searchMembersBySkills = async (currentUser, query) => { // NOTE, we remove stats only because it's too much data at the current time for the talent search app // We can add stats back in at some point in the future if we want to expand the information shown on the // talent search app. - const fields = omitMemberAttributes(currentUser, query, _.without(MEMBER_FIELDS, 'stats')) + const requestedFields = omitMemberAttributes(currentUser, query, _.without(MEMBER_FIELDS, 'stats')) + const { + fieldsForQuery, + removeUserIdAfterFiltering + } = prepareFieldsForCopilotEmailFiltering(currentUser, requestedFields) // build search member filter. Make sure member has every skill id in skillIds const memberIds = await searchMemberIdWithSkillIds(skillIds) const prismaFilter = { where: { userId: { in: memberIds } } } // build result - let response = await fillMembers(prismaFilter, query, fields, true) + let response = await fillMembers(prismaFilter, query, fieldsForQuery, true) // secure address data const canManageMember = currentUser && (currentUser.isMachine || helper.hasAdminRole(currentUser)) @@ -727,6 +795,12 @@ const searchMembersBySkills = async (currentUser, query) => { response.result = _.map(response.result, res => helper.truncateLastName(res)) } + response.result = await applyCopilotEmailFiltering( + currentUser, + response.result, + removeUserIdAfterFiltering + ) + return response } catch (e) { logger.error('ERROR WHEN SEARCHING') @@ -823,6 +897,8 @@ async function bulkSearch (currentUser, data) { } }) + await copilotEmailAccess.stripUnauthorizedCopilotEmails(currentUser, members) + const membersByHandle = new Map() const membersByEmail = new Map() for (const member of members) { @@ -885,7 +961,11 @@ bulkSearch.schema = { * @returns {Object} the autocomplete result */ async function autocomplete (currentUser, query) { - const fields = omitMemberAttributes(currentUser, query, MEMBER_AUTOCOMPLETE_FIELDS) + const requestedFields = omitMemberAttributes(currentUser, query, MEMBER_AUTOCOMPLETE_FIELDS) + const { + fieldsForQuery, + removeUserIdAfterFiltering + } = prepareFieldsForCopilotEmailFiltering(currentUser, requestedFields) if (!query.term || query.term.length === 0) { return { total: 0, page: query.page, perPage: query.perPage, result: [] } @@ -902,7 +982,7 @@ async function autocomplete (currentUser, query) { return { total: 0, page: query.page, perPage: query.perPage, result: [] } } const selectFields = {} - _.forEach(fields, f => { + _.forEach(fieldsForQuery, f => { selectFields[f] = true }) @@ -914,13 +994,15 @@ async function autocomplete (currentUser, query) { orderBy: { handle: query.sortOrder } }) records = _.map(records, item => { - const t = _.pick(item, fields) + const t = _.pick(item, fieldsForQuery) if (t.userId) { t.userId = helper.bigIntToNumber(t.userId) } return t }) + records = await applyCopilotEmailFiltering(currentUser, records, removeUserIdAfterFiltering) + return { total, page: query.page, perPage: query.perPage, result: records } } diff --git a/test/unit/MemberService.test.js b/test/unit/MemberService.test.js index e158303..a9feb8e 100644 --- a/test/unit/MemberService.test.js +++ b/test/unit/MemberService.test.js @@ -89,6 +89,22 @@ describe('member service unit tests', () => { // should.not.exist(result.addresses) }) + it('get member should not expose email for regular users when communication fields are misconfigured', async () => { + const originalCommunicationSecureFields = [...config.COMMUNICATION_SECURE_FIELDS] + config.COMMUNICATION_SECURE_FIELDS = ['loginCount', 'lastLoginDate'] + + try { + const result = await service.getMember({ handle: 'test', roles: ['role'] }, member1.handle, { + fields: 'userId,firstName,lastName,email,loginCount,lastLoginDate' + }) + should.equal(result.userId, member1.userId) + should.equal(result.firstName, member1.firstName) + should.not.exist(result.email) + } finally { + config.COMMUNICATION_SECURE_FIELDS = originalCommunicationSecureFields + } + }) + it('get member - not found', async () => { try { await service.getMember({ isMachine: true }, 'other', {})