diff --git a/app.js b/app.js index 56521d9a..bc37cd0a 100644 --- a/app.js +++ b/app.js @@ -75,8 +75,8 @@ app.use((err, req, res, next) => { } if (err.response) { - // extract error message from V3 API - errorResponse.message = _.get(err, 'response.body.result.content') + // extract error message from V3/V5 API + errorResponse.message = _.get(err, 'response.body.result.content') || _.get(err, 'response.body.message') } if (_.isUndefined(errorResponse.message)) { diff --git a/config/default.js b/config/default.js index 3c88f71e..06b08c4f 100644 --- a/config/default.js +++ b/config/default.js @@ -39,6 +39,10 @@ module.exports = { TOPCODER_SKILL_PROVIDER_ID: process.env.TOPCODER_SKILL_PROVIDER_ID || '9cc0795a-6e12-4c84-9744-15858dba1861', TOPCODER_USERS_API: process.env.TOPCODER_USERS_API || 'https://api.topcoder-dev.com/v3/users', + // the api to find topcoder members + TOPCODER_MEMBERS_API: process.env.TOPCODER_MEMBERS_API || 'https://api.topcoder-dev.com/v3/members', + // rate limit of requests to user api + MAX_PARALLEL_REQUEST_TOPCODER_USERS_API: process.env.MAX_PARALLEL_REQUEST_TOPCODER_USERS_API || 100, // PostgreSQL database url. DATABASE_URL: process.env.DATABASE_URL || 'postgres://postgres:postgres@localhost:5432/postgres', diff --git a/docs/Topcoder-bookings-api.postman_collection.json b/docs/Topcoder-bookings-api.postman_collection.json index a13896de..a3cc1b66 100644 --- a/docs/Topcoder-bookings-api.postman_collection.json +++ b/docs/Topcoder-bookings-api.postman_collection.json @@ -4482,7 +4482,52 @@ } }, "response": [] - } + }, + { + "name": "POST /taas-teams/:id/members", + "request": { + "method": "POST", + "header": [ + { + "key": "Authorization", + "type": "text", + "value": "Bearer {{token_administrator}}" + }, + { + "key": "Content-Type", + "type": "text", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"handles\": [\n \"tester1234\",\n \"non-existing\"\n ],\n \"emails\": [\n \"non-existing@domain.com\",\n \"email@domain.com\"\n ]\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{URL}}/taas-teams/:id/members", + "host": [ + "{{URL}}" + ], + "path": [ + "taas-teams", + ":id", + "members" + ], + "variable": [ + { + "key": "id", + "value": "16705" + } + ] + } + }, + "response": [] + } ] }, { diff --git a/docs/swagger.yaml b/docs/swagger.yaml index b28b57d1..4c042bab 100644 --- a/docs/swagger.yaml +++ b/docs/swagger.yaml @@ -1523,6 +1523,63 @@ paths: application/json: schema: $ref: '#/components/schemas/Error' + /taas-teams/{id}/members: + post: + tags: + - Teams + description: | + Add members to a team by handle or email. + security: + - bearerAuth: [] + parameters: + - in: path + name: id + required: true + schema: + type: integer + description: The team/project id. + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/AddMembersRequestBody' + responses: + '200': + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/AddMembersResponseBody' + '400': + description: Bad request + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + '401': + description: Not authenticated + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + '403': + description: Not authorized + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + '404': + description: Not Found + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + '500': + description: Internal Server Error + content: + application/json: + schema: + $ref: '#/components/schemas/Error' /taas-teams/skills: get: tags: @@ -2419,6 +2476,55 @@ components: type: object example: {"projectName": "TaaS Project Name", "projectId": 12345, "reportText": "I have issue with ..."} description: "Arbitrary data to feed the specified template" + AddMembersRequestBody: + properties: + handles: + type: array + description: "The handles." + items: + type: string + description: "the handle of a member" + example: topcoder321 + emails: + type: array + description: "The emails." + items: + type: string + description: "the email of a member" + example: 'xxx@xxx.com' + AddMembersResponseBody: + properties: + success: + type: array + description: "The handles." + items: + type: object + example: {"createdAt": "2021-02-18T19:58:50.610Z", "createdBy": -101, "email": "email@domain.com", "handle": "Scud", "id": 14155, "photoURL": "https://topcoder-dev-media.s3.amazonaws.com/member/profile/Scud-1450982908556.png", "role": "customer", "timeZone": null, "updatedAt": "2021-02-18T19:58:50.611Z", "updatedBy": -101, "userId": 1800091, "workingHourEnd": null, "workingHourStart": null} + failed: + type: array + description: "The emails." + items: + oneOf: + - type: object + properties: + error: + type: string + description: the error message + example: "User doesn't exist" + handle: + type: string + description: "the handle of a member" + example: topcoder321 + - type: object + properties: + error: + type: string + description: the error message + example: "User is already added" + email: + type: string + description: "the email of a member" + example: 'xxx@xxx.com' Error: required: - message diff --git a/package-lock.json b/package-lock.json index ec16c9c6..97150812 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1324,6 +1324,11 @@ "type-is": "~1.6.17" } }, + "bottleneck": { + "version": "2.19.5", + "resolved": "https://registry.npmjs.org/bottleneck/-/bottleneck-2.19.5.tgz", + "integrity": "sha512-VHiNCbI1lKdl44tGrhNfU3lup0Tj/ZBMJB5/2ZbNXRCPuRCO7ed2mgcK4r17y+KB2EfuYuRaVlwNbAeaWGSpbw==" + }, "boxen": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/boxen/-/boxen-4.2.0.tgz", diff --git a/package.json b/package.json index 39747993..23621ca5 100644 --- a/package.json +++ b/package.json @@ -34,6 +34,7 @@ "@elastic/elasticsearch": "^7.9.1", "@topcoder-platform/topcoder-bus-api-wrapper": "github:topcoder-platform/tc-bus-api-wrapper", "aws-sdk": "^2.787.0", + "bottleneck": "^2.19.5", "config": "^3.3.2", "cors": "^2.8.5", "date-fns": "^2.16.1", diff --git a/src/common/helper.js b/src/common/helper.js index 5ac0b6dd..10fc5e19 100644 --- a/src/common/helper.js +++ b/src/common/helper.js @@ -5,6 +5,7 @@ const fs = require('fs') const querystring = require('querystring') const Confirm = require('prompt-confirm') +const Bottleneck = require('bottleneck') const AWS = require('aws-sdk') const config = require('config') const HttpStatus = require('http-status-codes') @@ -968,6 +969,89 @@ async function checkIsMemberOfProject (userId, projectId) { } } +/** + * Find topcoder members by handles. + * + * @param {Array} handles the array of handles + * @returns {Array} the member details + */ +async function getMemberDetailsByHandles (handles) { + if (!handles.length) { + return [] + } + const token = await getM2MToken() + const res = await request + .get(`${config.TOPCODER_MEMBERS_API}/_search`) + .query({ + query: _.map(handles, handle => `handleLower:${handle.toLowerCase()}`).join(' OR '), + fields: 'userId,handle,firstName,lastName,email' + }) + .set('Authorization', `Bearer ${token}`) + .set('Accept', 'application/json') + localLogger.debug({ context: 'getMemberDetailsByHandles', message: `response body: ${JSON.stringify(res.body)}` }) + return _.get(res.body, 'result.content') +} + +/** + * Find topcoder members by email. + * + * @param {String} token the auth token + * @param {String} email the email + * @returns {Array} the member details + */ +async function _getMemberDetailsByEmail (token, email) { + const res = await request + .get(config.TOPCODER_USERS_API) + .query({ + filter: `email=${email}`, + fields: 'handle,id,email' + }) + .set('Authorization', `Bearer ${token}`) + .set('Accept', 'application/json') + localLogger.debug({ context: '_getMemberDetailsByEmail', message: `response body: ${JSON.stringify(res.body)}` }) + return _.get(res.body, 'result.content') +} + +/** + * Find topcoder members by emails. + * Maximum concurrent requests is limited by MAX_PARALLEL_REQUEST_TOPCODER_USERS_API. + * + * @param {Array} emails the array of emails + * @returns {Array} the member details + */ +async function getMemberDetailsByEmails (emails) { + const token = await getM2MToken() + const limiter = new Bottleneck({ maxConcurrent: config.MAX_PARALLEL_REQUEST_TOPCODER_USERS_API }) + const membersArray = await Promise.all(emails.map(email => limiter.schedule(() => _getMemberDetailsByEmail(token, email) + .catch(() => { + localLogger.error({ context: 'getMemberDetailsByEmails', message: `email: ${email} user not found` }) + return [] + }) + ))) + return _.flatten(membersArray) +} + +/** + * Add a member to a project. + * + * @param {Number} projectId project id + * @param {Object} data the userId and the role of the member + * @param {Object} criteria the filtering criteria + * @returns {Object} the member created + */ +async function createProjectMember (projectId, data, criteria) { + const m2mToken = await getM2MToken() + const { body: member } = await request + .post(`${config.TC_API}/projects/${projectId}/members`) + .set('Authorization', `Bearer ${m2mToken}`) + .set('Content-Type', 'application/json') + .set('Accept', 'application/json') + .query(criteria) + .send(data) + localLogger.debug({ context: 'createProjectMember', message: `response body: ${JSON.stringify(member)}` }) + return member +} + module.exports = { getParamFromCliArgs, promptUser, @@ -1002,5 +1086,8 @@ module.exports = { ensureJobById, ensureUserById, getAuditM2Muser, - checkIsMemberOfProject + checkIsMemberOfProject, + getMemberDetailsByHandles, + getMemberDetailsByEmails, + createProjectMember } diff --git a/src/controllers/TeamController.js b/src/controllers/TeamController.js index c20bdf5d..c60698a2 100644 --- a/src/controllers/TeamController.js +++ b/src/controllers/TeamController.js @@ -44,9 +44,19 @@ async function sendEmail (req, res) { res.status(HttpStatus.NO_CONTENT).end() } +/** + * Add members to a team. + * @param req the request + * @param res the response + */ +async function addMembers (req, res) { + res.send(await service.addMembers(req.authUser, req.params.id, req.body)) +} + module.exports = { searchTeams, getTeam, getTeamJob, - sendEmail + sendEmail, + addMembers } diff --git a/src/routes/TeamRoutes.js b/src/routes/TeamRoutes.js index b4c8b0d2..0fd06402 100644 --- a/src/routes/TeamRoutes.js +++ b/src/routes/TeamRoutes.js @@ -43,5 +43,13 @@ module.exports = { auth: 'jwt', scopes: [constants.Scopes.READ_TAAS_TEAM] } + }, + '/taas-teams/:id/members': { + post: { + controller: 'TeamController', + method: 'addMembers', + auth: 'jwt', + scopes: [constants.Scopes.READ_TAAS_TEAM] + } } } diff --git a/src/services/TeamService.js b/src/services/TeamService.js index 803066e3..8a91a78f 100644 --- a/src/services/TeamService.js +++ b/src/services/TeamService.js @@ -320,7 +320,7 @@ async function sendEmail (currentUser, data) { data: { handle: currentUser.handle, subject: template.subjectTemplate(data.data), - message: template.messageTemplate(data.data), + message: template.messageTemplate(data.data) }, sendgrid_template_id: template.sendgridTemplateId, version: 'v3', @@ -336,9 +336,98 @@ sendEmail.schema = Joi.object().keys({ }).required() }).required() +/** + * Add a member to a team as customer. + * + * @param {Number} projectId project id + * @param {String} userId user id + * @returns {Object} the member added + */ +async function _addMemberToProjectAsCustomer (projectId, userId) { + try { + const member = await helper.createProjectMember( + projectId, + { userId: userId, role: 'customer' }, + { fields: 'id,userId,role,createdAt,updatedAt,createdBy,updatedBy,handle,photoURL,workingHourStart,workingHourEnd,timeZone,email' } + ) + return member + } catch (err) { + err.message = _.get(err, 'response.body.message') || err.message + if (err.message && err.message.includes('User already registered')) { + throw new Error('User is already added') + } + logger.error({ + component: 'TeamService', + context: '_addMemberToProjectAsCustomer', + message: err.message + }) + throw err + } +} + +/** + * Add members to a team by handle or email. + * @param {Object} currentUser the user who perform this operation + * @param {String} id the team id + * @param {Object} data the object including members with handle/email to be added + * @returns {Object} the success/failed added members + */ +async function addMembers (currentUser, id, data) { + await helper.getProjectById(currentUser, id) // check whether the user can access the project + const result = { + success: [], + failed: [] + } + const membersByHandle = await helper.getMemberDetailsByHandles(data.handles) + .then(members => { + return _.groupBy(members, 'handle') + }) + const membersByEmail = await helper.getMemberDetailsByEmails(data.emails) + .then(members => { + return _.groupBy(members, 'email') + }) + await Promise.all([ + Promise.all(data.handles.map(handle => { + if (!membersByHandle[handle]) { + result.failed.push({ error: 'User doesn\'t exist', handle }) + return + } + return _addMemberToProjectAsCustomer(id, membersByHandle[handle][0].userId) + .then(member => { + result.success.push(({ ...member, handle })) + }).catch(err => { + result.failed.push({ error: err.message, handle }) + }) + })), + Promise.all(data.emails.map(email => { + if (!membersByEmail[email]) { + result.failed.push({ error: 'User doesn\'t exist', email }) + return + } + return _addMemberToProjectAsCustomer(id, membersByEmail[email][0].id) + .then(member => { + result.success.push(({ ...member, email })) + }).catch(err => { + result.failed.push({ error: err.message, email }) + }) + })) + ]) + return result +} + +addMembers.schema = Joi.object().keys({ + currentUser: Joi.object().required(), + id: Joi.number().integer().required(), + data: Joi.object().keys({ + handles: Joi.array().items(Joi.string()), + emails: Joi.array().items(Joi.string().email()) + }).or('handles', 'emails').required() +}).required() + module.exports = { searchTeams, getTeam, getTeamJob, - sendEmail + sendEmail, + addMembers }