From e8268b585d45183f0258b4b06b9f08c7ef9f4676 Mon Sep 17 00:00:00 2001 From: wherearemyglasses Date: Wed, 29 Jan 2020 21:45:05 +0800 Subject: [PATCH] Submission For Topcoder Project Service - Update Invite Endpoints - part 1 --- docs/Project API.postman_collection.json | 250 ++++++++++-- docs/swagger.yaml | 112 ++++-- local/mock-services/server.js | 13 +- src/models/projectMemberInvite.js | 39 ++ src/permissions/index.js | 7 +- src/permissions/projectMemberInvite.view.js | 36 ++ src/routes/index.js | 12 +- src/routes/projectMemberInvites/create.js | 255 ++++++------ .../projectMemberInvites/create.spec.js | 219 +++++----- src/routes/projectMemberInvites/get.js | 65 +-- src/routes/projectMemberInvites/get.spec.js | 237 ++++++++--- src/routes/projectMemberInvites/list.js | 58 ++- src/routes/projectMemberInvites/list.spec.js | 259 ++++++++++++ src/routes/projectMemberInvites/update.js | 76 ++-- .../projectMemberInvites/update.spec.js | 374 +++++++++++------- src/util.js | 29 ++ 16 files changed, 1449 insertions(+), 592 deletions(-) create mode 100644 src/permissions/projectMemberInvite.view.js create mode 100644 src/routes/projectMemberInvites/list.spec.js diff --git a/docs/Project API.postman_collection.json b/docs/Project API.postman_collection.json index cda37b99..fc3df567 100644 --- a/docs/Project API.postman_collection.json +++ b/docs/Project API.postman_collection.json @@ -1,6 +1,6 @@ { "info": { - "_postman_id": "2e0a1b99-3cb9-4c77-a562-7e6fe4956358", + "_postman_id": "47adb133-9da4-4cc6-aa73-c7e7a2eb675e", "name": "Project API", "schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json" }, @@ -1033,6 +1033,41 @@ { "name": "Project Members Invites", "item": [ + { + "name": "List project member invite", + "protocolProfileBehavior": { + "disableBodyPruning": true + }, + "request": { + "method": "GET", + "header": [ + { + "key": "Authorization", + "value": "Bearer {{jwt-token}}" + }, + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "" + }, + "url": { + "raw": "{{api-url}}/projects/{{projectId}}/invites", + "host": [ + "{{api-url}}" + ], + "path": [ + "projects", + "{{projectId}}", + "invites" + ] + } + }, + "response": [] + }, { "name": "Create project member with no payload", "request": { @@ -1044,23 +1079,29 @@ }, { "key": "Content-Type", - "value": "application/json" + "name": "Content-Type", + "value": "application/json", + "type": "text" } ], "body": { "mode": "raw", - "raw": "{\n}" + "raw": "{\n}", + "options": { + "raw": { + "language": "json" + } + } }, "url": { - "raw": "{{api-url}}/projects/{{projectId}}/members/invite", + "raw": "{{api-url}}/projects/{{projectId}}/invites", "host": [ "{{api-url}}" ], "path": [ "projects", "{{projectId}}", - "members", - "invite" + "invites" ] }, "description": "Request payload is mandatory while creating project. If no request payload is specified this should result in 400 status code." @@ -1078,29 +1119,128 @@ }, { "key": "Content-Type", - "value": "application/json" + "name": "Content-Type", + "value": "application/json", + "type": "text" } ], "body": { "mode": "raw", - "raw": "{\n\t\"role\": \"customer\",\n\t\"emails\": [\"test@topcoder.com\"]\n}" + "raw": "{\n\t\"role\": \"customer\",\n\t\"emails\": [\"test@topcoder.com\"]\n}", + "options": { + "raw": { + "language": "json" + } + } }, "url": { - "raw": "{{api-url}}/projects/{{projectId}}/members/invite", + "raw": "{{api-url}}/projects/{{projectId}}/invites", "host": [ "{{api-url}}" ], "path": [ "projects", "{{projectId}}", - "members", - "invite" + "invites" ] }, "description": "If the request payload is valid, than project customer should be added. This should sync with the direct project is project is associated with direct project." }, "response": [] }, + { + "name": "Create member invites with handles", + "event": [ + { + "listen": "test", + "script": { + "id": "3835313a-bb42-487a-b17e-4d687535d7e5", + "exec": [ + "pm.test(\"Status code is 201\", function () {", + " pm.response.to.have.status(201);", + " pm.environment.set(\"inviteId\", pm.response.json().success[0].id);", + "});" + ], + "type": "text/javascript" + } + } + ], + "request": { + "method": "POST", + "header": [ + { + "key": "Authorization", + "value": "Bearer {{jwt-token}}" + }, + { + "key": "Content-Type", + "name": "Content-Type", + "value": "application/json", + "type": "text" + } + ], + "body": { + "mode": "raw", + "raw": "{\n\t\"role\": \"copilot\",\n\t\"handles\": [\"test_copilot1\"]\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{api-url}}/projects/{{projectId}}/invites", + "host": [ + "{{api-url}}" + ], + "path": [ + "projects", + "{{projectId}}", + "invites" + ] + } + }, + "response": [] + }, + { + "name": "Create member invites with wrong roles", + "request": { + "method": "POST", + "header": [ + { + "key": "Authorization", + "value": "Bearer {{jwt-token}}" + }, + { + "key": "Content-Type", + "name": "Content-Type", + "value": "application/json", + "type": "text" + } + ], + "body": { + "mode": "raw", + "raw": "{\n\t\"role\": \"manager\",\n\t\"handles\": [\"test_copilot1\", \"test_user1\"]\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{api-url}}/projects/{{projectId}}/invites", + "host": [ + "{{api-url}}" + ], + "path": [ + "projects", + "{{projectId}}", + "invites" + ] + } + }, + "response": [] + }, { "name": "Get project member invite", "protocolProfileBehavior": { @@ -1123,15 +1263,15 @@ "raw": "" }, "url": { - "raw": "{{api-url}}/projects/{{projectId}}/members/invite", + "raw": "{{api-url}}/projects/{{projectId}}/invites/{{inviteId}}", "host": [ "{{api-url}}" ], "path": [ "projects", "{{projectId}}", - "members", - "invite" + "invites", + "{{inviteId}}" ] }, "description": "Update a project's member." @@ -1141,31 +1281,38 @@ { "name": "Update project member invite", "request": { - "method": "PUT", + "method": "PATCH", "header": [ { "key": "Authorization", - "value": "Bearer {{jwt-token}}" + "value": "Bearer {{jwt-token-copilot-40051332}}" }, { "key": "Content-Type", - "value": "application/json" + "name": "Content-Type", + "value": "application/json", + "type": "text" } ], "body": { "mode": "raw", - "raw": "{\n\t\"status\": \"accepted\",\n\t\"email\": \"test@topcoder.com\"\n}" + "raw": "{\n\t\"status\": \"accepted\"\n}", + "options": { + "raw": { + "language": "json" + } + } }, "url": { - "raw": "{{api-url}}/projects/{{projectId}}/members/invite", + "raw": "{{api-url}}/projects/{{projectId}}/invites/{{inviteId}}", "host": [ "{{api-url}}" ], "path": [ "projects", "{{projectId}}", - "members", - "invite" + "invites", + "{{inviteId}}" ] }, "description": "Update a project's member." @@ -1173,9 +1320,9 @@ "response": [] }, { - "name": "wrong status", + "name": "Update project member invite - wrong status", "request": { - "method": "PUT", + "method": "PATCH", "header": [ { "key": "Authorization", @@ -1183,23 +1330,70 @@ }, { "key": "Content-Type", - "value": "application/json" + "name": "Content-Type", + "value": "application/json", + "type": "text" } ], "body": { "mode": "raw", - "raw": " {\n\t\"status\": \"wrong\"\n } " + "raw": " {\n\t\"status\": \"wrong\"\n } ", + "options": { + "raw": { + "language": "json" + } + } }, "url": { - "raw": "{{api-url}}/projects/{{projectId}}/members/invite", + "raw": "{{api-url}}/projects/{{projectId}}/invites/{{inviteId}}", "host": [ "{{api-url}}" ], "path": [ "projects", "{{projectId}}", - "members", - "invite" + "invites", + "{{inviteId}}" + ] + } + }, + "response": [] + }, + { + "name": "Update project member invite - wrong user", + "request": { + "method": "PATCH", + "header": [ + { + "key": "Authorization", + "value": "Bearer {{jwt-token-member2-40051335}}" + }, + { + "key": "Content-Type", + "name": "Content-Type", + "value": "application/json", + "type": "text" + } + ], + "body": { + "mode": "raw", + "raw": "{\n\t\"status\": \"accepted\"\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{api-url}}/projects/{{projectId}}/invites/{{inviteId}}", + "host": [ + "{{api-url}}" + ], + "path": [ + "projects", + "{{projectId}}", + "invites", + "{{inviteId}}" ] } }, diff --git a/docs/swagger.yaml b/docs/swagger.yaml index 31e834a9..f9c1ca8e 100644 --- a/docs/swagger.yaml +++ b/docs/swagger.yaml @@ -3518,31 +3518,26 @@ paths: description: Internal Server Error schema: $ref: '#/definitions/ErrorModel' - '/projects/{projectId}/members/invite': + '/projects/{projectId}/invites': get: tags: - project member invite - operationId: getCurrentUserInvite + operationId: listProjectInvites security: - Bearer: [] - description: Retrieve the invite for current user. + description: >- + If user can "view" this project, he/she can get all invitations. + Otherwise user can only see his/her own invitation in this project. + If user has no invitation in this project or this project doesn't exist, an empty array will be returned. parameters: - $ref: '#/parameters/projectIdParam' responses: '200': description: The invite for current user schema: - $ref: '#/definitions/ProjectMemberInviteSuccessAndFailure' - '400': - description: Bad request - schema: - $ref: '#/definitions/ErrorModel' - '401': - description: Unauthorized - schema: - $ref: '#/definitions/ErrorModel' - '404': - description: Invite not found + $ref: '#/definitions/ProjectMemberInviteListResult' + '403': + description: Forbidden schema: $ref: '#/definitions/ErrorModel' '500': @@ -3566,27 +3561,54 @@ paths: schema: $ref: '#/definitions/AddProjectMemberInvitesRequest' responses: - '200': - description: Returns the newly created invite + '201': + description: Created schema: $ref: '#/definitions/ProjectMemberInviteSuccessAndFailure' '400': description: Bad request schema: $ref: '#/definitions/ErrorModel' - '401': - description: Unauthorized + '403': + description: Forbidden + schema: + $ref: '#/definitions/ErrorModel' + '500': + description: Internal Server Error schema: $ref: '#/definitions/ErrorModel' + '/projects/{projectId}/invites/{inviteId}': + get: + tags: + - project member invite + operationId: getProjectMemberInvite + security: + - Bearer: [] + description: >- + Get an invite. Users who can "view" this project can see this invitation. + User got invited by this inviteId can also see this invitation. + If project/invitation doesn't exist, or this invitation is not for logged-in user, it will return 404 response. + parameters: + - $ref: '#/parameters/projectIdParam' + - $ref: '#/parameters/inviteIdParam' + responses: + '200': + description: Returns the newly updated invite + schema: + $ref: '#/definitions/ProjectMemberInvite' '403': description: Forbidden schema: $ref: '#/definitions/ErrorModel' + '404': + description: Not Found + schema: + $ref: '#/definitions/ErrorModel' '500': description: Internal Server Error schema: $ref: '#/definitions/ErrorModel' - put: + patch: tags: - project member invite operationId: updateProjectMemberInvite @@ -3597,6 +3619,7 @@ paths: restriction will be applied based on role to be updated. parameters: - $ref: '#/parameters/projectIdParam' + - $ref: '#/parameters/inviteIdParam' - in: body name: body required: true @@ -3606,19 +3629,19 @@ paths: '200': description: Returns the newly updated invite schema: - $ref: '#/definitions/ProjectMemberInviteSuccessAndFailure' + $ref: '#/definitions/ProjectMemberInvite' '400': description: Bad request schema: $ref: '#/definitions/ErrorModel' - '401': - description: Unauthorized - schema: - $ref: '#/definitions/ErrorModel' '403': description: Forbidden schema: $ref: '#/definitions/ErrorModel' + '404': + description: Not Found + schema: + $ref: '#/definitions/ErrorModel' '500': description: Internal Server Error schema: @@ -4786,6 +4809,13 @@ parameters: required: true type: integer format: int64 + inviteIdParam: + name: inviteId + in: path + description: project member invite identifier + required: true + type: integer + format: int64 definitions: ErrorModel: type: object @@ -6148,26 +6178,41 @@ definitions: type: object properties: success: - $ref: '#/definitions/ProjectMemberInvite' + type: array + items: + $ref: '#/definitions/ProjectMemberInvite' failed: type: array items: type: object + properties: + email: + description: invitation user email(Optional) + type: string + handle: + description: invitation user handle(Optional) + type: string + message: + description: create invitation error message + type: string + ProjectMemberInviteListResult: + type: array + items: + $ref: '#/definitions/ProjectMemberInvite' AddProjectMemberInvitesRequest: title: Add project member invites request object type: object properties: - userIds: - description: 'The user Id list, could not present with emails' + handles: + description: 'The user handle list, could not present with emails' type: array items: - type: integer - format: int64 + type: string emails: type: array items: type: string - description: 'The user email list, could not present with userIds' + description: 'The user email list, could not present with handles' role: description: The target role in the project type: string @@ -6179,13 +6224,6 @@ definitions: title: Update project member invite request object type: object properties: - userId: - type: integer - format: int64 - description: 'The user Id, could not present with email' - email: - type: string - description: 'The user email, could not present with userId' status: description: The invite status type: string diff --git a/local/mock-services/server.js b/local/mock-services/server.js index f43fd1c7..04c34b12 100644 --- a/local/mock-services/server.js +++ b/local/mock-services/server.js @@ -25,7 +25,7 @@ server.use(authMiddleware); server.get('/v3/members/_search', (req, res) => { const fields = _.isString(req.query.fields) ? req.query.fields.split(',') : []; const filter = _.isString(req.query.query) ? - req.query.query.replace('%2520', ' ').replace('%20', ' ').split(' OR ') : []; + req.query.query.replace(/%2520/g, ' ').replace(/%20/g, ' ').split(' OR ') : []; const criteria = _.map(filter, (single) => { const ret = {}; const splitted = single.split(':'); @@ -45,6 +45,7 @@ server.get('/v3/members/_search', (req, res) => { }); const userIds = _.map(criteria, 'userId'); const handles = _.map(criteria, 'handle'); + const handleLowers = _.map(criteria, 'handleLower'); const cloned = _.cloneDeep(members); const response = { id: 'res1', @@ -66,6 +67,12 @@ server.get('/v3/members/_search', (req, res) => { found = _.pick(found, fields); } return found; + } else if (_.indexOf(handleLowers, single.result.content.handleLower) > -1) { + let found = single.result.content; + if (fields.length > 0) { + found = _.pick(found, fields); + } + return found; } return null; }).filter(_.identity); @@ -76,7 +83,7 @@ server.get('/v3/members/_search', (req, res) => { // add filter route for project members server.get('/users', (req, res) => { - const filter = req.query.filter.replace('%2520', ' ').replace('%20', ' ').replace('%3D', ' '); + const filter = req.query.filter.replace(/%2520/g, ' ').replace(/%20/g, ' ').replace('%3D', ' '); const allEmails = filter.split('=')[1]; const emails = allEmails.split('OR'); const cloned = _.cloneDeep(members); @@ -97,7 +104,7 @@ server.get('/users', (req, res) => { // add additional search route for project members server.get('/roles', (req, res) => { const filter = _.isString(req.query.filter) ? - req.query.filter.replace('%2520', ' ').replace('%20', ' ').split('=') : []; + req.query.filter.replace(/%2520/g, ' ').replace(/%20/g, ' ').split('=') : []; const cloned = _.cloneDeep(roles); const response = { id: 'res1', diff --git a/src/models/projectMemberInvite.js b/src/models/projectMemberInvite.js index bacee6cd..3215089e 100644 --- a/src/models/projectMemberInvite.js +++ b/src/models/projectMemberInvite.js @@ -63,6 +63,45 @@ module.exports = function defineProjectMemberInvite(sequelize, DataTypes) { raw: true, }); + ProjectMemberInvite.getPendingOrRequestedProjectInvitesForUser = (projectId, email, userId) => { + const where = { + projectId, + status: { $in: [INVITE_STATUS.PENDING, INVITE_STATUS.REQUESTED] }, + }; + + if (email && userId) { + _.assign(where, { $or: [{ email: { $eq: email } }, { userId: { $eq: userId } }] }); + } else if (email) { + _.assign(where, { email }); + } else if (userId) { + _.assign(where, { userId }); + } + return ProjectMemberInvite.findAll({ + where, + raw: true, + }); + }; + + ProjectMemberInvite.getPendingInviteByIdForUser = (projectId, inviteId, email, userId) => { + const where = { + projectId, + id: inviteId, + status: INVITE_STATUS.PENDING, + }; + + if (email && userId) { + _.assign(where, { $or: [{ email: { $eq: email } }, { userId: { $eq: userId } }] }); + } else if (email) { + _.assign(where, { email }); + } else if (userId) { + _.assign(where, { userId }); + } + return ProjectMemberInvite.findOne({ + where, + raw: true, + }); + }; + ProjectMemberInvite.getPendingInviteByEmailOrUserId = (projectId, email, userId) => { const where = { projectId, status: INVITE_STATUS.PENDING }; diff --git a/src/permissions/index.js b/src/permissions/index.js index f9c6fc96..f8f46329 100644 --- a/src/permissions/index.js +++ b/src/permissions/index.js @@ -12,6 +12,7 @@ const connectManagerOrAdmin = require('./connectManagerOrAdmin.ops'); const copilotAndAbove = require('./copilotAndAbove'); const workManagementPermissions = require('./workManagementForTemplate'); const projectSettingEdit = require('./projectSetting.edit'); +const projectMemberInviteView = require('./projectMemberInvite.view'); module.exports = () => { Authorizer.setDeniedStatusCode(403); @@ -85,9 +86,9 @@ module.exports = () => { Authorizer.setPolicy('metadata.list', true); // anyone can view all metadata Authorizer.setPolicy('projectMemberInvite.create', projectView); - Authorizer.setPolicy('projectMemberInvite.put', true); - Authorizer.setPolicy('projectMemberInvite.get', true); - Authorizer.setPolicy('projectMemberInvite.list', projectView); + Authorizer.setPolicy('projectMemberInvite.edit', true); + Authorizer.setPolicy('projectMemberInvite.get', projectMemberInviteView); + Authorizer.setPolicy('projectMemberInvite.list', projectMemberInviteView); Authorizer.setPolicy('form.create', projectAdmin); Authorizer.setPolicy('form.edit', projectAdmin); diff --git a/src/permissions/projectMemberInvite.view.js b/src/permissions/projectMemberInvite.view.js new file mode 100644 index 00000000..138555cf --- /dev/null +++ b/src/permissions/projectMemberInvite.view.js @@ -0,0 +1,36 @@ + +import _ from 'lodash'; +import util from '../util'; +import models from '../models'; +import { MANAGER_ROLES } from '../constants'; + +/** + * Check user can view project member invite or not. + * Users who can view the project can see all invites. Logged-in user can only see invitations + * for himself/herself. + * @param {Object} freq the express request instance + * @return {Promise} Returns a promise + */ +module.exports = freq => new Promise((resolve) => { + const req = freq; + const projectId = _.parseInt(freq.params.projectId); + const currentUserId = freq.authUser.userId; + let hasAccess; + return models.ProjectMember.getActiveProjectMembers(projectId) + .then((members) => { + req.context = req.context || {}; + // check if auth user has acecss to this project + hasAccess = util.hasAdminRole(req) + || util.hasRoles(req, MANAGER_ROLES) + || !_.isUndefined(_.find(members, m => m.userId === currentUserId)); + if (hasAccess) { + // if user can "view" the project, he/she can see all invites + // save this info into request. + req.context.inviteType = 'all'; + } else { + // user can only see invitations for himself/herself in this project + req.context.inviteType = 'list'; + } + return resolve(true); + }); +}); diff --git a/src/routes/index.js b/src/routes/index.js index 275ce470..6dbd65d6 100644 --- a/src/routes/index.js +++ b/src/routes/index.js @@ -229,13 +229,13 @@ router.route('/v5/timelines/metadata/milestoneTemplates/:milestoneTemplateId(\\d .patch(require('./milestoneTemplates/update')) .delete(require('./milestoneTemplates/delete')); -router.route('/v5/projects/:projectId(\\d+)/members/invite') - .post(require('./projectMemberInvites/create')) - .put(require('./projectMemberInvites/update')) - .get(require('./projectMemberInvites/get')); +router.route('/v5/projects/:projectId(\\d+)/invites') + .get(require('./projectMemberInvites/list')) + .post(require('./projectMemberInvites/create')); -router.route('/v5/projects/:projectId(\\d+)/members/invites') - .get(require('./projectMemberInvites/list')); +router.route('/v5/projects/:projectId(\\d+)/invites/:inviteId(\\d+)') + .patch(require('./projectMemberInvites/update')) + .get(require('./projectMemberInvites/get')); router.route('/v5/projects/metadata/orgConfig') .post(require('./orgConfig/create')); diff --git a/src/routes/projectMemberInvites/create.js b/src/routes/projectMemberInvites/create.js index bdd35484..fd46b376 100644 --- a/src/routes/projectMemberInvites/create.js +++ b/src/routes/projectMemberInvites/create.js @@ -20,7 +20,7 @@ const permissions = tcMiddleware.permissions; const addMemberValidations = { body: Joi.object().keys({ - userIds: Joi.array().items(Joi.number()).optional().min(1), + handles: Joi.array().items(Joi.string()).optional().min(1), emails: Joi.array().items(Joi.string().email()).optional().min(1), role: Joi.any().valid(_.values(PROJECT_MEMBER_ROLE)).required(), }).required(), @@ -50,34 +50,44 @@ const compareEmail = (email1, email2, options = { UNIQUE_GMAIL_VALIDATION: false return _.toLower(email1) === _.toLower(email2); }; +/** + * Get user handle by user id from user list. Used to generate error messages below. + * You need to make sure user with specific userId exists in users. + * @param {Number} userId user id + * @param {Array} users user list + * @returns {String} user handle + */ +const getUserHandleById = (userId, users) => _.find(users, { userId }).handle; + /** * Helper method to build promises for creating new invites in DB * * @param {Object} req express request object - * @param {Object} invite invite to process + * @param {Array} inviteEmails invite.emails + * @param {Array} inviteUserIds filtered invite.userIds * @param {Array} invites existent invites from DB * @param {Object} data template for new invites to be put in DB * @param {Array} failed failed invites error message * @param {Array} members already members of the project - * + * @param {Array} inviteUsers users retrieved by invite.handles * @returns {Promise} list of promises */ -const buildCreateInvitePromises = (req, invite, invites, data, failed, members) => { +const buildCreateInvitePromises = (req, inviteEmails, inviteUserIds, invites, data, failed, members, inviteUsers) => { const invitePromises = []; - if (invite.userIds) { + if (inviteUserIds) { // remove invites for users that are invited already const errMessageForAlreadyInvitedUsers = 'User with such handle is already invited to this project.'; - _.remove(invite.userIds, u => _.some(invites, (i) => { + _.remove(inviteUserIds, u => _.some(invites, (i) => { const isPresent = i.userId === u; if (isPresent) { failed.push(_.assign({}, { - userId: u, + handle: getUserHandleById(u, inviteUsers), message: errMessageForAlreadyInvitedUsers, })); } return isPresent; })); - invite.userIds.forEach((userId) => { + inviteUserIds.forEach((userId) => { const dataNew = _.clone(data); dataNew.userId = userId; @@ -86,10 +96,10 @@ const buildCreateInvitePromises = (req, invite, invites, data, failed, members) }); } - if (invite.emails) { + if (inviteEmails) { // if for some emails there are already existent users, we will invite them by userId, // to avoid sending them registration email - return util.lookupMultipleUserEmails(req, invite.emails, MAX_PARALLEL_REQUEST_QTY) + return util.lookupMultipleUserEmails(req, inviteEmails, MAX_PARALLEL_REQUEST_QTY) .then((existentUsers) => { // existent user we will invite by userId and email const existentUsersWithNumberId = existentUsers.map((user) => { @@ -100,7 +110,7 @@ const buildCreateInvitePromises = (req, invite, invites, data, failed, members) return userWithNumberId; }); // non-existent users we will invite them by email only - const nonExistentUserEmails = invite.emails.filter(inviteEmail => + const nonExistentUserEmails = inviteEmails.filter(inviteEmail => !_.find(existentUsers, existentUser => compareEmail(existentUser.email, inviteEmail, { UNIQUE_GMAIL_VALIDATION: false })), ); @@ -166,7 +176,7 @@ const buildCreateInvitePromises = (req, invite, invites, data, failed, members) return invitePromises; }).catch((error) => { req.log.error(error); - _.forEach(invite.emails, email => failed.push(_.assign({}, { email, message: error.statusText }))); + _.forEach(inviteEmails, email => failed.push(_.assign({}, { email, message: error.statusText }))); return invitePromises; }); } @@ -234,8 +244,8 @@ module.exports = [ // let us request user fields during creating, probably this should be move to GET by ID endpoint instead const fields = req.query.fields ? req.query.fields.split(',') : null; - if (!invite.userIds && !invite.emails) { - const err = new Error('Either userIds or emails are required'); + if (!invite.handles && !invite.emails) { + const err = new Error('Either handles or emails are required'); err.status = 400; return next(err); } @@ -246,118 +256,133 @@ module.exports = [ return next(err); } - const members = req.context.currentProjectMembers; - const projectId = _.parseInt(req.params.projectId); + // get member details by handles first + return util.getMemberDetailsByHandles(invite.handles, req.log, req.id).then((inviteUsers) => { + const members = req.context.currentProjectMembers; + const projectId = _.parseInt(req.params.projectId); + // check user handle exists in returned result + const errorMessageHandleNotExist = 'User with such handle does not exist'; + if (!!invite.handles && invite.handles.length > 0) { + const existentHandles = _.map(inviteUsers, 'handle'); + failed = _.concat(failed, _.map(_.difference(invite.handles, existentHandles), handle => _.assign({}, { + handle, + message: errorMessageHandleNotExist, + }))); + } - const promises = []; - const errorMessageForAlreadyMemberUser = 'User with such handle is already a member of the team.'; - if (invite.userIds) { - // remove members already in the team - _.remove(invite.userIds, u => _.some(members, (m) => { - const isPresent = m.userId === u; - if (isPresent) { - failed.push(_.assign({}, { - userId: m.userId, - message: errorMessageForAlreadyMemberUser, - })); - } - return isPresent; - })); + let inviteUserIds = _.map(inviteUsers, 'userId'); + const promises = []; + const errorMessageForAlreadyMemberUser = 'User with such handle is already a member of the team.'; + + if (inviteUserIds) { + // remove members already in the team + _.remove(inviteUserIds, u => _.some(members, (m) => { + const isPresent = m.userId === u; + if (isPresent) { + failed.push(_.assign({}, { + handle: getUserHandleById(m.userId, inviteUsers), + message: errorMessageForAlreadyMemberUser, + })); + } + return isPresent; + })); // permission: // user has to have constants.MANAGER_ROLES role // to be invited as PROJECT_MEMBER_ROLE.MANAGER - if (_.includes(PROJECT_MEMBER_MANAGER_ROLES, invite.role)) { - _.forEach(invite.userIds, (userId) => { - req.log.info(userId); - promises.push(util.getUserRoles(userId, req.log, req.id)); - }); + if (_.includes(PROJECT_MEMBER_MANAGER_ROLES, invite.role)) { + _.forEach(inviteUserIds, (userId) => { + req.log.info(userId); + promises.push(util.getUserRoles(userId, req.log, req.id)); + }); + } } - } - if (invite.emails) { + if (invite.emails) { // email invites can only be used for CUSTOMER role - if (invite.role !== PROJECT_MEMBER_ROLE.CUSTOMER) { // eslint-disable-line no-lonely-if - const message = `Emails can only be used for ${PROJECT_MEMBER_ROLE.CUSTOMER}`; - failed = _.concat(failed, _.map(invite.emails, email => _.assign({}, { email, message }))); - delete invite.emails; + if (invite.role !== PROJECT_MEMBER_ROLE.CUSTOMER) { // eslint-disable-line no-lonely-if + const message = `Emails can only be used for ${PROJECT_MEMBER_ROLE.CUSTOMER}`; + failed = _.concat(failed, _.map(invite.emails, email => _.assign({}, { email, message }))); + delete invite.emails; + } } - } - if (promises.length === 0) { - promises.push(Promise.resolve()); - } - return Promise.all(promises).then((rolesList) => { - if (!!invite.userIds && _.includes(PROJECT_MEMBER_MANAGER_ROLES, invite.role)) { - req.log.debug('Checking if userId is allowed as manager'); - const forbidUserList = []; - _.zip(invite.userIds, rolesList).forEach((data) => { - const [userId, roles] = data; - req.log.debug(roles); + if (promises.length === 0) { + promises.push(Promise.resolve()); + } + return Promise.all(promises).then((rolesList) => { + if (!!inviteUserIds && _.includes(PROJECT_MEMBER_MANAGER_ROLES, invite.role)) { + req.log.debug('Checking if userId is allowed as manager'); + const forbidUserList = []; + _.zip(inviteUserIds, rolesList).forEach((data) => { + const [userId, roles] = data; + req.log.debug(roles); - if (roles && !util.hasIntersection(MANAGER_ROLES, roles)) { - forbidUserList.push(userId); + if (roles && !util.hasIntersection(MANAGER_ROLES, roles)) { + forbidUserList.push(userId); + } + }); + if (forbidUserList.length > 0) { + const message = 'cannot be added with a Manager role to the project'; + failed = _.concat(failed, _.map(forbidUserList, + id => _.assign({}, { handle: getUserHandleById(id, inviteUsers), message }))); + inviteUserIds = _.filter(inviteUserIds, userId => !_.includes(forbidUserList, userId)); } - }); - if (forbidUserList.length > 0) { - const message = 'cannot be added with a Manager role to the project'; - failed = _.concat(failed, _.map(forbidUserList, id => _.assign({}, { userId: id, message }))); - invite.userIds = _.filter(invite.userIds, userId => !_.includes(forbidUserList, userId)); } - } - return models.ProjectMemberInvite.getPendingInvitesForProject(projectId) - .then((invites) => { - const data = { - projectId, - role: invite.role, - // invite directly if user is admin or copilot manager - status: (invite.role !== PROJECT_MEMBER_ROLE.COPILOT || - util.hasRoles(req, [USER_ROLE.CONNECT_ADMIN, USER_ROLE.COPILOT_MANAGER])) - ? INVITE_STATUS.PENDING - : INVITE_STATUS.REQUESTED, - createdBy: req.authUser.userId, - updatedBy: req.authUser.userId, - }; - - req.log.debug('Creating invites'); - return models.Sequelize.Promise.all(buildCreateInvitePromises(req, invite, invites, data, failed, members)) - .then((values) => { - values.forEach((v) => { - // emit the event - util.sendResourceToKafkaBus( - req, - EVENT.ROUTING_KEY.PROJECT_MEMBER_INVITE_CREATED, - RESOURCES.PROJECT_MEMBER_INVITE, - v.toJSON()); + return models.ProjectMemberInvite.getPendingInvitesForProject(projectId) + .then((invites) => { + const data = { + projectId, + role: invite.role, + // invite directly if user is admin or copilot manager + status: (invite.role !== PROJECT_MEMBER_ROLE.COPILOT || + util.hasRoles(req, [USER_ROLE.CONNECT_ADMIN, USER_ROLE.COPILOT_MANAGER])) + ? INVITE_STATUS.PENDING + : INVITE_STATUS.REQUESTED, + createdBy: req.authUser.userId, + updatedBy: req.authUser.userId, + }; + req.log.debug('Creating invites'); + return models.Sequelize.Promise.all(buildCreateInvitePromises( + req, invite.emails, inviteUserIds, invites, data, failed, members, inviteUsers)) + .then((values) => { + values.forEach((v) => { + // emit the event + util.sendResourceToKafkaBus( + req, + EVENT.ROUTING_KEY.PROJECT_MEMBER_INVITE_CREATED, + RESOURCES.PROJECT_MEMBER_INVITE, + v.toJSON()); - req.app.services.pubsub.publish( - EVENT.ROUTING_KEY.PROJECT_MEMBER_INVITE_CREATED, - v, - { correlationId: req.id }, - ); - // send email invite (async) - if (v.email && !v.userId && v.status === INVITE_STATUS.PENDING) { - sendInviteEmail(req, projectId, v); - } - }); - return values.map(value => value.get({ plain: true })); - }); // models.sequelize.Promise.all - }); // models.ProjectMemberInvite.getPendingInvitesForProject - }) - .then(values => ( - // populate successful invites with user details if required - util.getObjectsWithMemberDetails(values, fields, req) - .catch((err) => { - req.log.error('Cannot get user details for invites.'); - req.log.debug('Error during getting user details for invites', err); - }) - )) - .then((values) => { - const success = _.assign({}, { success: values }); - if (failed.length) { - res.status(403).json(_.assign({}, util.maskInviteEmails('$.success[?(@.email)]', success, req), { failed })); - } else { - res.status(201).json(util.maskInviteEmails('$.success[?(@.email)]', success, req)); - } - }) - .catch(err => next(err)); + req.app.services.pubsub.publish( + EVENT.ROUTING_KEY.PROJECT_MEMBER_INVITE_CREATED, + v, + { correlationId: req.id }, + ); + // send email invite (async) + if (v.email && !v.userId && v.status === INVITE_STATUS.PENDING) { + sendInviteEmail(req, projectId, v); + } + }); + return values.map(value => value.get({ plain: true })); + }); // models.sequelize.Promise.all + }); // models.ProjectMemberInvite.getPendingInvitesForProject + }) + .then(values => ( + // populate successful invites with user details if required + util.getObjectsWithMemberDetails(values, fields, req) + .catch((err) => { + req.log.error('Cannot get user details for invites.'); + req.log.debug('Error during getting user details for invites', err); + }) + )) + .then((values) => { + const success = _.assign({}, { success: values }); + if (failed.length) { + res.status(403).json(_.assign({}, + util.maskInviteEmails('$.success[?(@.email)]', success, req), { failed })); + } else { + res.status(201).json(util.maskInviteEmails('$.success[?(@.email)]', success, req)); + } + }); + }).catch(err => next(err)); }, ]; diff --git a/src/routes/projectMemberInvites/create.spec.js b/src/routes/projectMemberInvites/create.spec.js index ac77d1cc..43e297ad 100644 --- a/src/routes/projectMemberInvites/create.spec.js +++ b/src/routes/projectMemberInvites/create.spec.js @@ -154,7 +154,7 @@ describe('Project Member Invite create', () => { testUtil.clearDb(done); }); - describe('POST /projects/{id}/members/invite', () => { + describe('POST /projects/{id}/invites', () => { let sandbox; beforeEach(() => { sandbox = sinon.sandbox.create(); @@ -171,6 +171,46 @@ describe('Project Member Invite create', () => { firstName: 'Admin', lastName: 'User', }])); + // mock getMemberDetailsByHandles function. + sandbox.stub(util, 'getMemberDetailsByHandles', (handles) => { + if (_.isNil(handles) || _.isEmpty(handles)) { + return Promise.resolve([]); + } + let userHandles = [{ + userId: 40011578, + handle: 'magrathean', + }, { + userId: 40011579, + handle: 'test_user1', + }, { + userId: 40011578, + handle: 'test_user2', + }, { + userId: 40051331, + handle: 'test_customer1', + }, { + userId: 40051332, + handle: 'test_copilot1', + }, { + userId: 40051333, + handle: 'test_manager1', + }, { + userId: 40051334, + handle: 'test_manager2', + }, { + userId: 40051335, + handle: 'test_manager3', + }, { + userId: 40051336, + handle: 'test_manager4', + }, { + userId: 40135978, + handle: 'test_admin1', + }]; + userHandles = _.each(userHandles, u => _.extend(u, { firstName: 'Connect', lastName: 'User' })); + + return Promise.resolve(_.filter(userHandles, u => handles.indexOf(u.handle) >= 0)); + }); }); afterEach(() => { sandbox.restore(); @@ -179,12 +219,12 @@ describe('Project Member Invite create', () => { it('should return 201 if userIds and emails are presented the same time', (done) => { request(server) - .post(`/v5/projects/${project1.id}/members/invite`) + .post(`/v5/projects/${project1.id}/invites`) .set({ Authorization: `Bearer ${testUtil.jwts.admin}`, }) .send({ - userIds: [40051331], + handles: ['test_customer1'], emails: ['hello@world.com'], role: 'customer', }) @@ -205,10 +245,10 @@ describe('Project Member Invite create', () => { }); }); - it('should return 400 if neither userIds or email is presented', + it('should return 400 if neither handles or email is presented', (done) => { request(server) - .post(`/v5/projects/${project1.id}/members/invite`) + .post(`/v5/projects/${project1.id}/invites`) .set({ Authorization: `Bearer ${testUtil.jwts.admin}`, }) @@ -222,7 +262,7 @@ describe('Project Member Invite create', () => { done(err); } else { const errorMessage = _.get(res.body, 'message', ''); - sinon.assert.match(errorMessage, /.*Either userIds or emails are required/); + sinon.assert.match(errorMessage, /.*Either handles or emails are required/); done(); } }); @@ -247,12 +287,12 @@ describe('Project Member Invite create', () => { }); sandbox.stub(util, 'getHttpClient', () => mockHttpClient); request(server) - .post(`/v5/projects/${project2.id}/members/invite`) + .post(`/v5/projects/${project2.id}/invites`) .set({ Authorization: `Bearer ${testUtil.jwts.copilot}`, }) .send({ - userIds: [40152855], + handles: ['test_user1'], role: 'copilot', }) .expect('Content-Type', /json/) @@ -287,12 +327,12 @@ describe('Project Member Invite create', () => { }); sandbox.stub(util, 'getHttpClient', () => mockHttpClient); request(server) - .post(`/v5/projects/${project2.id}/members/invite`) + .post(`/v5/projects/${project2.id}/invites`) .set({ Authorization: `Bearer ${testUtil.jwts.copilot}`, }) .send({ - userIds: [40152855], + handles: ['test_user1'], role: 'copilot', }) .expect('Content-Type', /json/) @@ -329,7 +369,7 @@ describe('Project Member Invite create', () => { }); sandbox.stub(util, 'getHttpClient', () => mockHttpClient); request(server) - .post(`/v5/projects/${project2.id}/members/invite`) + .post(`/v5/projects/${project2.id}/invites`) .set({ Authorization: `Bearer ${testUtil.jwts.copilot}`, }) @@ -382,7 +422,7 @@ describe('Project Member Invite create', () => { email: 'hello@world.com', }])); request(server) - .post(`/v5/projects/${project2.id}/members/invite`) + .post(`/v5/projects/${project2.id}/invites`) .set({ Authorization: `Bearer ${testUtil.jwts.copilot}`, }) @@ -422,6 +462,7 @@ describe('Project Member Invite create', () => { status: 200, content: { success: [{ + userId: 40152855, roleName: USER_ROLE.COPILOT, }], }, @@ -431,12 +472,12 @@ describe('Project Member Invite create', () => { }); sandbox.stub(util, 'getHttpClient', () => mockHttpClient); request(server) - .post(`/v5/projects/${project2.id}/members/invite`) + .post(`/v5/projects/${project2.id}/invites`) .set({ Authorization: `Bearer ${testUtil.jwts.copilot}`, }) .send({ - userIds: [40152855], + handles: ['test_customer1'], role: 'customer', }) .expect('Content-Type', /json/) @@ -449,32 +490,21 @@ describe('Project Member Invite create', () => { should.exist(resJson); resJson.role.should.equal('customer'); resJson.projectId.should.equal(project2.id); - resJson.userId.should.equal(40152855); + resJson.userId.should.equal(40051331); server.services.pubsub.publish.calledWith('project.member.invite.created').should.be.true; done(); } }); }); - it('should return 403 and failed list when trying add already team member by userId', (done) => { - const mockHttpClient = _.merge(testUtil.mockHttpClient, { - get: () => Promise.resolve({ - status: 200, - data: { - success: [{ - roleName: USER_ROLE.COPILOT, - }], - }, - }), - }); - sandbox.stub(util, 'getHttpClient', () => mockHttpClient); + it('should return 403 and failed list when trying add already team member by handle', (done) => { request(server) - .post(`/v5/projects/${project1.id}/members/invite`) + .post(`/v5/projects/${project1.id}/invites`) .set({ Authorization: `Bearer ${testUtil.jwts.copilot}`, }) .send({ - userIds: [40158431], + handles: ['test_copilot1'], role: 'customer', }) .expect('Content-Type', /json/) @@ -485,7 +515,7 @@ describe('Project Member Invite create', () => { } else { const resJson = res.body.failed; should.exist(resJson); - resJson[0].userId.should.equal(40158431); + resJson[0].handle.should.equal('test_copilot1'); resJson[0].message.should.equal('User with such handle is already a member of the team.'); resJson.length.should.equal(1); server.services.pubsub.publish.neverCalledWith('project.member.invite.created').should.be.true; @@ -500,6 +530,7 @@ describe('Project Member Invite create', () => { status: 200, data: { success: [{ + userId: 40158431, roleName: USER_ROLE.COPILOT, }], }, @@ -512,7 +543,7 @@ describe('Project Member Invite create', () => { email: 'romit.choudhary@rivigo.com', }])); request(server) - .post(`/v5/projects/${project1.id}/members/invite`) + .post(`/v5/projects/${project1.id}/invites`) .set({ Authorization: `Bearer ${testUtil.jwts.copilot}`, }) @@ -537,33 +568,14 @@ describe('Project Member Invite create', () => { }); }); - it('should return 403 and failed list when trying add already invited member by userId', (done) => { - const mockHttpClient = _.merge(testUtil.mockHttpClient, { - get: () => Promise.resolve({ - status: 200, - data: { - id: 'requesterId', - version: 'v3', - result: { - success: true, - status: 200, - content: { - success: [{ - roleName: USER_ROLE.COPILOT, - }], - }, - }, - }, - }), - }); - sandbox.stub(util, 'getHttpClient', () => mockHttpClient); + it('should return 403 and failed list when trying add already invited member by handle', (done) => { request(server) - .post(`/v5/projects/${project1.id}/members/invite`) + .post(`/v5/projects/${project1.id}/invites`) .set({ Authorization: `Bearer ${testUtil.jwts.copilot}`, }) .send({ - userIds: [40051335], + handles: ['test_manager3'], role: 'customer', }) .expect('Content-Type', /json/) @@ -575,7 +587,7 @@ describe('Project Member Invite create', () => { const resJson = res.body.failed; should.exist(resJson); resJson.length.should.equal(1); - resJson[0].userId.should.equal(40051335); + resJson[0].handle.should.equal('test_manager3'); resJson[0].message.should.equal('User with such handle is already invited to this project.'); server.services.pubsub.publish.neverCalledWith('project.member.invite.created').should.be.true; done(); @@ -614,12 +626,12 @@ describe('Project Member Invite create', () => { }); sandbox.stub(util, 'getHttpClient', () => mockHttpClient); request(server) - .post(`/v5/projects/${project1.id}/members/invite`) + .post(`/v5/projects/${project1.id}/invites`) .set({ Authorization: `Bearer ${testUtil.jwts.copilot}`, }) .send({ - userIds: [40152855], + handles: ['test_user'], role: 'manager', }) .expect('Content-Type', /json/) @@ -637,16 +649,43 @@ describe('Project Member Invite create', () => { }); }); + it('should return 201 if try to create invitation with non-existent handle', (done) => { + util.getUserRoles.restore(); + sandbox.stub(util, 'getUserRoles', () => Promise.resolve([USER_ROLE.MANAGER])); + request(server) + .post(`/v5/projects/${project1.id}/invites`) + .set({ + Authorization: `Bearer ${testUtil.jwts.manager}`, + }) + .send({ + handles: ['invalid_handle'], + role: 'customer', + }) + .expect('Content-Type', /json/) + .expect(403) + .end((err, res) => { + if (err) { + done(err); + } else { + const resJson = res.body.failed[0]; + should.exist(resJson); + resJson.handle.should.equal('invalid_handle'); + resJson.message.should.equal('User with such handle does not exist'); + done(); + } + }); + }); + it('should return 201 if try to create manager with MANAGER_ROLES', (done) => { util.getUserRoles.restore(); sandbox.stub(util, 'getUserRoles', () => Promise.resolve([USER_ROLE.MANAGER])); request(server) - .post(`/v5/projects/${project1.id}/members/invite`) + .post(`/v5/projects/${project1.id}/invites`) .set({ Authorization: `Bearer ${testUtil.jwts.manager}`, }) .send({ - userIds: [40152855], + handles: ['test_manager4'], role: 'manager', }) .expect('Content-Type', /json/) @@ -656,7 +695,7 @@ describe('Project Member Invite create', () => { should.exist(resJson); resJson.role.should.equal('manager'); resJson.projectId.should.equal(project1.id); - resJson.userId.should.equal(40152855); + resJson.userId.should.equal(40051336); server.services.pubsub.publish.calledWith('project.member.invite.created').should.be.true; done(); }); @@ -666,12 +705,12 @@ describe('Project Member Invite create', () => { util.getUserRoles.restore(); sandbox.stub(util, 'getUserRoles', () => Promise.resolve([USER_ROLE.MANAGER])); request(server) - .post(`/v5/projects/${project1.id}/members/invite`) + .post(`/v5/projects/${project1.id}/invites`) .set({ Authorization: `Bearer ${testUtil.jwts.manager}`, }) .send({ - userIds: [40152855], + handles: ['test_manager4'], role: 'account_manager', }) .expect('Content-Type', /json/) @@ -681,7 +720,7 @@ describe('Project Member Invite create', () => { should.exist(resJson); resJson.role.should.equal('account_manager'); resJson.projectId.should.equal(project1.id); - resJson.userId.should.equal(40152855); + resJson.userId.should.equal(40051336); server.services.pubsub.publish.calledWith('project.member.invite.created').should.be.true; done(); }); @@ -691,12 +730,12 @@ describe('Project Member Invite create', () => { util.getUserRoles.restore(); sandbox.stub(util, 'getUserRoles', () => Promise.resolve(['Topcoder User'])); request(server) - .post(`/v5/projects/${project1.id}/members/invite`) + .post(`/v5/projects/${project1.id}/invites`) .set({ Authorization: `Bearer ${testUtil.jwts.manager}`, }) .send({ - userIds: [40152855], + handles: ['test_customer1'], role: 'account_manager', }) .expect('Content-Type', /json/) @@ -715,32 +754,13 @@ describe('Project Member Invite create', () => { }); it('should return 201 if try to create customer with COPILOT', (done) => { - const mockHttpClient = _.merge(testUtil.mockHttpClient, { - get: () => Promise.resolve({ - status: 200, - data: { - id: 'requesterId', - version: 'v3', - result: { - success: true, - status: 200, - content: { - success: [{ - roleName: USER_ROLE.COPILOT, - }], - }, - }, - }, - }), - }); - sandbox.stub(util, 'getHttpClient', () => mockHttpClient); request(server) - .post(`/v5/projects/${project1.id}/members/invite`) + .post(`/v5/projects/${project1.id}/invites`) .set({ Authorization: `Bearer ${testUtil.jwts.manager}`, }) .send({ - userIds: [40051331], + handles: ['test_customer1'], role: 'copilot', }) .expect('Content-Type', /json/) @@ -762,7 +782,7 @@ describe('Project Member Invite create', () => { it('should return 403 and failed list when trying add already invited member by lowercase email', (done) => { request(server) - .post(`/v5/projects/${project1.id}/members/invite`) + .post(`/v5/projects/${project1.id}/invites`) .set({ Authorization: `Bearer ${testUtil.jwts.copilot}`, }) @@ -788,7 +808,7 @@ describe('Project Member Invite create', () => { it('should return 403 and failed list when trying add already invited member by uppercase email', (done) => { request(server) - .post(`/v5/projects/${project1.id}/members/invite`) + .post(`/v5/projects/${project1.id}/invites`) .set({ Authorization: `Bearer ${testUtil.jwts.copilot}`, }) @@ -815,7 +835,7 @@ describe('Project Member Invite create', () => { xit('should return 403 and failed list when trying add already invited member by gmail email with dot', (done) => { request(server) - .post(`/v5/projects/${project1.id}/members/invite`) + .post(`/v5/projects/${project1.id}/invites`) .set({ Authorization: `Bearer ${testUtil.jwts.copilot}`, }) @@ -841,7 +861,7 @@ describe('Project Member Invite create', () => { xit('should return 403 and failed list when trying add already invited member by gmail email without dot', (done) => { request(server) - .post(`/v5/projects/${project1.id}/members/invite`) + .post(`/v5/projects/${project1.id}/invites`) .set({ Authorization: `Bearer ${testUtil.jwts.copilot}`, }) @@ -876,23 +896,14 @@ describe('Project Member Invite create', () => { createEventSpy = sandbox.spy(busApi, 'createEvent'); }); - it('should send correct BUS API messages when invite added by userId', (done) => { - const mockHttpClient = _.merge(testUtil.mockHttpClient, { - get: () => Promise.resolve({ - status: 200, - data: [{ - roleName: USER_ROLE.MANAGER, - }], - }), - }); - sandbox.stub(util, 'getHttpClient', () => mockHttpClient); + it('should send correct BUS API messages when invite added by handle', (done) => { request(server) - .post(`/v5/projects/${project1.id}/members/invite`) + .post(`/v5/projects/${project1.id}/invites`) .set({ Authorization: `Bearer ${testUtil.jwts.manager}`, }) .send({ - userIds: [3], + handles: ['test_user2'], role: PROJECT_MEMBER_ROLE.CUSTOMER, }) .expect(201) @@ -906,14 +917,14 @@ describe('Project Member Invite create', () => { createEventSpy.calledWith(BUS_API_EVENT.PROJECT_MEMBER_INVITE_CREATED, sinon.match({ resource: RESOURCES.PROJECT_MEMBER_INVITE, projectId: project1.id, - userId: 3, + userId: 40011578, email: null, })).should.be.true; // Check Notification Service events createEventSpy.calledWith(CONNECT_NOTIFICATION_EVENT.PROJECT_MEMBER_INVITE_CREATED, sinon.match({ projectId: project1.id, - userId: 3, + userId: 40011578, email: null, isSSO: false, })).should.be.true; @@ -935,7 +946,7 @@ describe('Project Member Invite create', () => { }); sandbox.stub(util, 'getHttpClient', () => mockHttpClient); request(server) - .post(`/v5/projects/${project1.id}/members/invite`) + .post(`/v5/projects/${project1.id}/invites`) .set({ Authorization: `Bearer ${testUtil.jwts.manager}`, }) diff --git a/src/routes/projectMemberInvites/get.js b/src/routes/projectMemberInvites/get.js index 2cc9fb1d..db373368 100644 --- a/src/routes/projectMemberInvites/get.js +++ b/src/routes/projectMemberInvites/get.js @@ -24,11 +24,12 @@ module.exports = [ permissions('projectMemberInvite.get'), (req, res, next) => { const projectId = _.parseInt(req.params.projectId); + const inviteId = _.parseInt(req.params.inviteId); const currentUserId = req.authUser.userId; const email = req.authUser.email; const fields = req.query.fields ? req.query.fields.split(',') : null; - util.fetchByIdFromES('invites', { + const esSearchParam = { query: { nested: { path: 'invites', @@ -39,16 +40,7 @@ module.exports = [ bool: { must: [ { term: { 'invites.projectId': projectId } }, - { - bool: { - should: [ - { term: { 'invites.email': email } }, - { term: { 'invites.userId': currentUserId } }, - ], - minimum_number_should_match: 1, - }, - }, - + { term: { 'invites.id': inviteId } }, ], }, }, @@ -57,22 +49,47 @@ module.exports = [ inner_hits: {}, }, }, - }) - .then((data) => { + }; + + if (req.context.inviteType === 'list') { + // user can only his/her own invite with specific id + esSearchParam.query.nested.query.filtered.filter.bool.must.push({ + bool: { + should: [ + { term: { 'invites.email': email } }, + { term: { 'invites.userId': currentUserId } }, + ], + minimum_number_should_match: 1, + }, + }); + } + util.fetchByIdFromES('invites', esSearchParam).then((data) => { if (data.length === 0) { req.log.debug('No project member invite found in ES'); - return models.ProjectMemberInvite.getPendingInviteByEmailOrUserId(projectId, email, currentUserId) - .then((invite) => { - if (!invite) { - // check there is an existing invite for the user with status PENDING - // handle 404 - const err = new Error('invite not found for project id ' + - `${projectId}, userId ${currentUserId}, email ${email}`); - err.status = 404; - throw err; + let getInvitePromise; + if (req.context.inviteType === 'all') { + getInvitePromise = models.ProjectMemberInvite.getPendingInviteByIdForUser(projectId, inviteId); + } else { + getInvitePromise = models.ProjectMemberInvite.getPendingInviteByIdForUser( + projectId, inviteId, email, currentUserId); + } + return getInvitePromise.then((invite) => { + if (!invite) { + // check there is an existing invite for the user with status PENDING + // handle 404 + let errMsg; + if (req.context.inviteType === 'all') { + errMsg = `invite not found for project id ${projectId}, inviteId ${inviteId}`; + } else { + errMsg = `invite not found for project id ${projectId}, inviteId ${inviteId}, ` + + `userId ${currentUserId} and email ${email}`; } - return invite; - }); + const err = new Error(errMsg); + err.status = 404; + throw err; + } + return invite; + }); } req.log.debug('project member found in ES'); return data[0].inner_hits.invites.hits.hits[0]._source; // eslint-disable-line no-underscore-dangle diff --git a/src/routes/projectMemberInvites/get.spec.js b/src/routes/projectMemberInvites/get.spec.js index 5b04ae5b..6c16a9cf 100644 --- a/src/routes/projectMemberInvites/get.spec.js +++ b/src/routes/projectMemberInvites/get.spec.js @@ -9,91 +9,168 @@ import { INVITE_STATUS } from '../../constants'; const should = chai.should(); -describe('GET Project', () => { +describe('GET Project Member Invite', () => { let project1; let project2; before((done) => { - testUtil.clearDb() - .then(() => { - const p1 = models.Project.create({ - type: 'generic', - billingAccountId: 1, - name: 'test1', - description: 'test project1', - status: 'draft', - details: {}, + // clear ES and db + testUtil.clearES().then(() => { + testUtil.clearDb() + .then(() => { + const p1 = models.Project.create({ + type: 'generic', + billingAccountId: 1, + name: 'test1', + description: 'test project1', + status: 'draft', + details: {}, + createdBy: 1, + updatedBy: 1, + lastActivityAt: 1, + lastActivityUserId: '1', + }).then((p) => { + project1 = p; + // create members + const pm1 = models.ProjectMember.create({ + userId: testUtil.userIds.admin, + projectId: project1.id, + role: 'copilot', + isPrimary: true, createdBy: 1, updatedBy: 1, - lastActivityAt: 1, - lastActivityUserId: '1', - }).then((p) => { - project1 = p; - // create members - const pm1 = models.ProjectMember.create({ - userId: 40051333, - projectId: project1.id, - role: 'copilot', - isPrimary: true, - createdBy: 1, - updatedBy: 1, - }); - // create invite - const invite1 = models.ProjectMemberInvite.create({ - userId: 40051331, - email: null, - projectId: project1.id, - role: 'customer', - createdBy: 1, - updatedBy: 1, - status: INVITE_STATUS.PENDING, - }); - return Promise.all([pm1, invite1]); }); + // create invite + const invite1 = models.ProjectMemberInvite.create({ + id: 1, + userId: testUtil.userIds.member, + email: null, + projectId: project1.id, + role: 'customer', + createdBy: 1, + updatedBy: 1, + status: INVITE_STATUS.PENDING, + }); + + const invite2 = models.ProjectMemberInvite.create({ + id: 2, + userId: testUtil.userIds.copilot, + email: null, + projectId: project1.id, + role: 'copilot', + createdBy: 1, + updatedBy: 1, + status: INVITE_STATUS.PENDING, + }); + + return Promise.all([pm1, invite1, invite2]); + }); + + const p2 = models.Project.create({ + type: 'visual_design', + billingAccountId: 1, + name: 'test2', + description: 'test project2', + status: 'draft', + details: {}, + createdBy: 1, + updatedBy: 1, + lastActivityAt: 1, + lastActivityUserId: '1', + }).then((p) => { + project2 = p; - const p2 = models.Project.create({ - type: 'visual_design', - billingAccountId: 1, - name: 'test2', - description: 'test project2', - status: 'draft', - details: {}, + // create invite 3 + const invite3 = models.ProjectMemberInvite.create({ + id: 3, + userId: null, + email: 'test@topcoder.com', + projectId: project2.id, + role: 'customer', createdBy: 1, updatedBy: 1, - lastActivityAt: 1, - lastActivityUserId: '1', - }).then((p) => { - project2 = p; + status: INVITE_STATUS.PENDING, }); - return Promise.all([p1, p2]) - .then(() => done()); + + const invite4 = models.ProjectMemberInvite.create({ + id: 4, + userId: testUtil.userIds.member2, + email: null, + projectId: project2.id, + role: 'customer', + createdBy: 1, + updatedBy: 1, + status: INVITE_STATUS.ACCEPTED, + }); + + return Promise.all([invite3, invite4]); }); + return Promise.all([p1, p2]) + .then(() => done()); + }); + }); }); after((done) => { testUtil.clearDb(done); }); - describe('GET /projects/{id}/members/invite', () => { + describe('GET /projects/{projectId}/invites/{inviteId}', () => { it('should return 403 if user is not authenticated', (done) => { request(server) - .get(`/v5/projects/${project2.id}/members/invite`) + .get(`/v5/projects/${project2.id}/invites/1`) .expect(403, done); }); it('should return 404 if requested project doesn\'t exist', (done) => { request(server) - .get('/v5/projects/14343323/members/invite') + .get('/v5/projects/14343323/invites/1') .set({ Authorization: `Bearer ${testUtil.jwts.admin}`, }) .expect(404, done); }); - it('should return the invite if user is invited to this project', (done) => { + it('should return 404 if requested invitation doesn\'t exist', (done) => { request(server) - .get(`/v5/projects/${project1.id}/members/invite`) + .get(`/v5/projects/${project1.id}/invites/12345678`) + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .expect(404, done); + }); + + it('should return 404 if requested invitation and project doesn\'t match', (done) => { + request(server) + .get(`/v5/projects/${project1.id}/invites/3`) + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .expect(404, done); + }); + + it('should return 404 if user can\'t view project and this invitation is not for this user', (done) => { + request(server) + .get(`/v5/projects/${project1.id}/invites/1`) + .set({ + Authorization: `Bearer ${testUtil.jwts.copilot}`, + }) + .expect(404, done); + }); + + it('should return 404 if invitation is not in pending or requested status', (done) => { + request(server) + .get(`/v5/projects/${project2.id}/invites/4`) + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .expect(404, done); + }); + + it('should return the invite if user can view the project', (done) => { + request(server) + .get(`/v5/projects/${project1.id}/invites/1`) .set({ - Authorization: `Bearer ${testUtil.jwts.member}`, + Authorization: `Bearer ${testUtil.jwts.admin}`, }) .expect('Content-Type', /json/) .expect(200) @@ -104,24 +181,58 @@ describe('GET Project', () => { const resJson = res.body; should.exist(resJson); should.exist(resJson.projectId); - resJson.userId.should.be.eql(40051331); + resJson.id.should.be.eql(1); + resJson.userId.should.be.eql(testUtil.userIds.member); resJson.status.should.be.eql(INVITE_STATUS.PENDING); done(); } }); }); - it('should return 404 if user is not invited to this project', (done) => { + it('should return the invite if this invitation is for logged-in user', (done) => { request(server) - .get(`/v5/projects/${project2.id}/members/invite`) - .set({ - Authorization: `Bearer ${testUtil.jwts.member}`, - }) - .expect('Content-Type', /json/) - .expect(404) - .end(() => { + .get(`/v5/projects/${project1.id}/invites/2`) + .set({ + Authorization: `Bearer ${testUtil.jwts.copilot}`, + }) + .expect('Content-Type', /json/) + .expect(200) + .end((err, res) => { + if (err) { + done(err); + } else { + const resJson = res.body; + should.exist(resJson); + should.exist(resJson.projectId); + resJson.id.should.be.eql(2); + resJson.userId.should.be.eql(testUtil.userIds.copilot); + resJson.status.should.be.eql(INVITE_STATUS.PENDING); done(); - }); + } + }); + }); + + it('should return the invite if user get invitation by email', (done) => { + request(server) + .get(`/v5/projects/${project2.id}/invites/3`) + .set({ + Authorization: `Bearer ${testUtil.jwts.member}`, + }) + .expect('Content-Type', /json/) + .expect(200) + .end((err, res) => { + if (err) { + done(err); + } else { + const resJson = res.body; + should.exist(resJson); + should.exist(resJson.projectId); + resJson.id.should.be.eql(3); + resJson.email.should.be.eql('test@topcoder.com'); + resJson.status.should.be.eql(INVITE_STATUS.PENDING); + done(); + } + }); }); }); }); diff --git a/src/routes/projectMemberInvites/list.js b/src/routes/projectMemberInvites/list.js index 03edbe48..fe889857 100644 --- a/src/routes/projectMemberInvites/list.js +++ b/src/routes/projectMemberInvites/list.js @@ -24,9 +24,11 @@ module.exports = [ permissions('projectMemberInvite.list'), (req, res, next) => { const projectId = _.parseInt(req.params.projectId); + const currentUserId = req.authUser.userId; + const email = req.authUser.email; const fields = req.query.fields ? req.query.fields.split(',') : null; - util.fetchByIdFromES('invites', { + const esSearchParam = { query: { nested: { path: 'invites', @@ -52,22 +54,42 @@ module.exports = [ }, }, }, - }) - .then((data) => { - if (data.length === 0) { - req.log.debug('No project member invites found in ES'); - return models.ProjectMemberInvite.getPendingAndReguestedInvitesForProject(projectId); - } - req.log.debug('project member found in ES'); - return data[0].inner_hits.invites.hits.hits.map(hit => hit._source); // eslint-disable-line no-underscore-dangle - }).then(invites => ( - util.getObjectsWithMemberDetails(invites, fields, req) - .catch((err) => { - req.log.error('Cannot get user details for invites.'); - req.log.debug('Error during getting user details for invites.', err); - }) - )) - .then(invites => res.json(util.maskInviteEmails('$[*].email', invites, req))) - .catch(next); + }; + if (req.context.inviteType === 'list') { + // user has no "view" project permission + // try to search from es, add search by user id or email + esSearchParam.query.nested.query.filtered.filter.bool.must.push({ + bool: { + should: [ + { term: { 'invites.email': email } }, + { term: { 'invites.userId': currentUserId } }, + ], + minimum_number_should_match: 1, + }, + }); + } + util.fetchByIdFromES('invites', esSearchParam) + .then((data) => { + if (data.length === 0) { + req.log.debug('No project member invites found in ES'); + // if user has "view" project permission, get all invites + if (req.context.inviteType === 'all') { + return models.ProjectMemberInvite.getPendingOrRequestedProjectInvitesForUser(projectId); + } + // get invitation only for user + return models.ProjectMemberInvite.getPendingOrRequestedProjectInvitesForUser( + projectId, email, currentUserId); + } + req.log.debug('project member found in ES'); + return data[0].inner_hits.invites.hits.hits.map(hit => hit._source); // eslint-disable-line no-underscore-dangle + }).then(invites => ( + util.getObjectsWithMemberDetails(invites, fields, req) + .catch((err) => { + req.log.error('Cannot get user details for invites.'); + req.log.debug('Error during getting user details for invites.', err); + }) + )) + .then(invites => res.json(util.maskInviteEmails('$[*].email', invites, req))) + .catch(next); }, ]; diff --git a/src/routes/projectMemberInvites/list.spec.js b/src/routes/projectMemberInvites/list.spec.js new file mode 100644 index 00000000..3e9d8624 --- /dev/null +++ b/src/routes/projectMemberInvites/list.spec.js @@ -0,0 +1,259 @@ +/* eslint-disable no-unused-expressions */ +import _ from 'lodash'; +import chai from 'chai'; +import request from 'supertest'; + +import models from '../../models'; +import server from '../../app'; +import testUtil from '../../tests/util'; +import { INVITE_STATUS } from '../../constants'; + +const should = chai.should(); + +describe('GET Project Member Invites', () => { + let project1; + let project2; + before((done) => { + // clear ES and db + testUtil.clearES().then(() => { + testUtil.clearDb() + .then(() => { + const p1 = models.Project.create({ + type: 'generic', + billingAccountId: 1, + name: 'test1', + description: 'test project1', + status: 'draft', + details: {}, + createdBy: 1, + updatedBy: 1, + lastActivityAt: 1, + lastActivityUserId: '1', + }).then((p) => { + project1 = p; + // create members + const pm1 = models.ProjectMember.create({ + userId: testUtil.userIds.admin, + projectId: project1.id, + role: 'copilot', + isPrimary: true, + createdBy: 1, + updatedBy: 1, + }); + // create invite + const invite1 = models.ProjectMemberInvite.create({ + id: 1, + userId: testUtil.userIds.member, + email: null, + projectId: project1.id, + role: 'customer', + createdBy: 1, + updatedBy: 1, + status: INVITE_STATUS.PENDING, + }); + + const invite2 = models.ProjectMemberInvite.create({ + id: 2, + userId: testUtil.userIds.copilot, + email: null, + projectId: project1.id, + role: 'copilot', + createdBy: 1, + updatedBy: 1, + status: INVITE_STATUS.PENDING, + }); + + return Promise.all([pm1, invite1, invite2]); + }); + + const p2 = models.Project.create({ + type: 'visual_design', + billingAccountId: 1, + name: 'test2', + description: 'test project2', + status: 'draft', + details: {}, + createdBy: 1, + updatedBy: 1, + lastActivityAt: 1, + lastActivityUserId: '1', + }).then((p) => { + project2 = p; + + // create invite 3 + const invite3 = models.ProjectMemberInvite.create({ + id: 3, + userId: null, + email: 'test@topcoder.com', + projectId: project2.id, + role: 'customer', + createdBy: 1, + updatedBy: 1, + status: INVITE_STATUS.PENDING, + }); + + const invite4 = models.ProjectMemberInvite.create({ + id: 4, + userId: testUtil.userIds.member2, + email: null, + projectId: project2.id, + role: 'customer', + createdBy: 1, + updatedBy: 1, + status: INVITE_STATUS.ACCEPTED, + }); + + return Promise.all([invite3, invite4]); + }); + return Promise.all([p1, p2]) + .then(() => done()); + }); + }); + }); + + after((done) => { + testUtil.clearDb(done); + }); + + describe('GET /projects/{projectId}/invites', () => { + it('should return 403 if user is not authenticated', (done) => { + request(server) + .get(`/v5/projects/${project2.id}/invites`) + .expect(403, done); + }); + + it('should return empty result if requested project doesn\'t exist', (done) => { + request(server) + .get('/v5/projects/14343323/invites') + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .expect(200) + .end((err, res) => { + if (err) { + done(err); + } else { + const resJson = res.body; + should.exist(resJson); + resJson.should.be.an('array'); + resJson.length.should.be.eql(0); + done(); + } + }); + }); + + it('should return all invitation if user can view the project', (done) => { + request(server) + .get(`/v5/projects/${project1.id}/invites`) + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .expect('Content-Type', /json/) + .expect(200) + .end((err, res) => { + if (err) { + done(err); + } else { + const resJson = res.body; + should.exist(resJson); + resJson.should.be.an('array'); + resJson.length.should.be.eql(2); + // check invitations + _.filter(resJson, inv => inv.id === 1).length.should.be.eql(1); + _.filter(resJson, inv => inv.id === 2).length.should.be.eql(1); + done(); + } + }); + }); + + it('should return only pending/requested invitation if user can view the project', (done) => { + request(server) + .get(`/v5/projects/${project2.id}/invites`) + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .expect('Content-Type', /json/) + .expect(200) + .end((err, res) => { + if (err) { + done(err); + } else { + const resJson = res.body; + should.exist(resJson); + resJson.should.be.an('array'); + resJson.length.should.be.eql(1); + // check invitations + _.filter(resJson, inv => inv.id === 3).length.should.be.eql(1); + done(); + } + }); + }); + + it('should return only his/her invitation for logged-in user', (done) => { + request(server) + .get(`/v5/projects/${project1.id}/invites`) + .set({ + Authorization: `Bearer ${testUtil.jwts.copilot}`, + }) + .expect('Content-Type', /json/) + .expect(200) + .end((err, res) => { + if (err) { + done(err); + } else { + const resJson = res.body; + should.exist(resJson); + resJson.should.be.an('array'); + resJson.length.should.be.eql(1); + // check invitations + _.filter(resJson, inv => inv.id === 2).length.should.be.eql(1); + done(); + } + }); + }); + + it('should return empty result for logged-in user has no invitation', (done) => { + request(server) + .get(`/v5/projects/${project1.id}/invites`) + .set({ + Authorization: `Bearer ${testUtil.jwts.member2}`, + }) + .expect('Content-Type', /json/) + .expect(200) + .end((err, res) => { + if (err) { + done(err); + } else { + const resJson = res.body; + should.exist(resJson); + resJson.should.be.an('array'); + resJson.length.should.be.eql(0); + done(); + } + }); + }); + + it('should return the invite if user get invitation by email', (done) => { + request(server) + .get(`/v5/projects/${project2.id}/invites`) + .set({ + Authorization: `Bearer ${testUtil.jwts.member}`, + }) + .expect('Content-Type', /json/) + .expect(200) + .end((err, res) => { + if (err) { + done(err); + } else { + const resJson = res.body; + should.exist(resJson); + resJson.should.be.an('array'); + resJson.length.should.be.eql(1); + // check invitations + _.filter(resJson, inv => inv.id === 3).length.should.be.eql(1); + done(); + } + }); + }); + }); +}); diff --git a/src/routes/projectMemberInvites/update.js b/src/routes/projectMemberInvites/update.js index 5b779e82..8f4c1577 100644 --- a/src/routes/projectMemberInvites/update.js +++ b/src/routes/projectMemberInvites/update.js @@ -15,10 +15,6 @@ const permissions = tcMiddleware.permissions; const updateMemberValidations = { body: Joi.object() .keys({ - userId: Joi.number().optional(), - email: Joi.string() - .email() - .optional(), status: Joi.any() .valid(_.values(INVITE_STATUS)) .required(), @@ -29,53 +25,45 @@ const updateMemberValidations = { module.exports = [ // handles request validations validate(updateMemberValidations), - permissions('projectMemberInvite.put'), + permissions('projectMemberInvite.edit'), (req, res, next) => { - const putInvite = req.body; + const newStatus = req.body.status; const projectId = _.parseInt(req.params.projectId); + const inviteId = _.parseInt(req.params.inviteId); + const email = req.authUser.email; + const currentUserId = req.authUser.userId; - // userId or email should be provided - if (!putInvite.userId && !putInvite.email) { - const err = new Error('userId or email should be provided'); - err.status = 400; - return next(err); - } + // check user has admin role or manager role. + const adminAccess = util.hasRoles(req, [USER_ROLE.CONNECT_ADMIN, USER_ROLE.COPILOT_MANAGER]); + const managerAccess = util.hasRoles(req, MANAGER_ROLES); - let invite; - let requestedInvite; - return models.ProjectMemberInvite.getPendingInviteByEmailOrUserId( - projectId, - putInvite.email, - putInvite.userId, - ).then((_invite) => { - invite = _invite; - }).then(() => models.ProjectMemberInvite.getRequestedInvite(projectId, putInvite.userId)) - .then((_requestedInvite) => { - requestedInvite = _requestedInvite; - if (!invite && !requestedInvite) { - // check there is an existing invite for the user with status PENDING - // handle 404 - const err = new Error( - `invite not found for project id ${projectId}, email ${putInvite.email} and userId ${putInvite.userId}`, + // get invite by id and project id + return models.ProjectMemberInvite.findOne({ + where: { + projectId, + id: inviteId, + status: { $in: [INVITE_STATUS.PENDING, INVITE_STATUS.REQUESTED] }, + }, + }).then((invite) => { + // if invite doesn't exist, return 404 + if (!invite) { + const err = new Error(`invite not found for project id ${projectId}, inviteId ${inviteId},` + + ` email ${email} and userId ${currentUserId}`, ); err.status = 404; return next(err); } - - invite = invite || requestedInvite; - + // check this invitation is for logged-in user or not + const ownInvite = (!!invite && (invite.userId === currentUserId || invite.email === email)); + // check permission req.log.debug('Chekcing user permission for updating invite'); let error = null; - if (invite.status === INVITE_STATUS.REQUESTED && - !util.hasRoles(req, [USER_ROLE.CONNECT_ADMIN, USER_ROLE.COPILOT_MANAGER])) { + if (invite.status === INVITE_STATUS.REQUESTED && !adminAccess) { error = 'Requested invites can only be updated by Copilot manager'; - } else if (putInvite.status === INVITE_STATUS.CANCELED) { - if (!util.hasRoles(req, MANAGER_ROLES) && invite.role !== PROJECT_MEMBER_ROLE.CUSTOMER) { - error = `Project members can cancel invites only for ${PROJECT_MEMBER_ROLE.CUSTOMER}`; - } - } else if (((!!putInvite.userId && putInvite.userId !== req.authUser.userId) || - (!!putInvite.email && putInvite.email !== req.authUser.email)) && - !util.hasRoles(req, [USER_ROLE.CONNECT_ADMIN, USER_ROLE.COPILOT_MANAGER])) { + } else if (newStatus === INVITE_STATUS.CANCELED && + (!managerAccess && invite.role !== PROJECT_MEMBER_ROLE.CUSTOMER)) { + error = `Project members can cancel invites only for ${PROJECT_MEMBER_ROLE.CUSTOMER}`; + } else if (!adminAccess && !ownInvite) { error = 'Project members can only update invites for themselves'; } @@ -88,7 +76,7 @@ module.exports = [ req.log.debug('Updating invite status'); return invite .update({ - status: putInvite.status, + status: newStatus, }) .then((updatedInvite) => { // emit the event @@ -105,15 +93,15 @@ module.exports = [ req.log.debug('Adding user to project'); // add user to project if accept invite if (updatedInvite.status === INVITE_STATUS.ACCEPTED || - updatedInvite.status === INVITE_STATUS.REQUEST_APPROVED) { + updatedInvite.status === INVITE_STATUS.REQUEST_APPROVED) { return models.ProjectMember.getActiveProjectMembers(projectId) .then((members) => { req.context = req.context || {}; req.context.currentProjectMembers = members; let userId = updatedInvite.userId; // if the requesting user is updating his/her own invite - if (!userId && req.authUser.email === updatedInvite.email) { - userId = req.authUser.userId; + if (!userId && email === updatedInvite.email) { + userId = currentUserId; } // if we are not able to identify the user yet, it must be something wrong and we should not create // project member diff --git a/src/routes/projectMemberInvites/update.spec.js b/src/routes/projectMemberInvites/update.spec.js index 49e53e3c..e685e646 100644 --- a/src/routes/projectMemberInvites/update.spec.js +++ b/src/routes/projectMemberInvites/update.spec.js @@ -11,7 +11,6 @@ import busApi from '../../services/busApi'; import { BUS_API_EVENT, RESOURCES, - USER_ROLE, PROJECT_MEMBER_ROLE, INVITE_STATUS, CONNECT_NOTIFICATION_EVENT, @@ -21,14 +20,12 @@ const should = chai.should(); describe('Project member invite update', () => { let project1; - let invite1; - let invite2; - let invite3; + let project2; beforeEach((done) => { testUtil.clearDb() .then(() => { - models.Project.create({ + const p1 = models.Project.create({ type: 'generic', directProjectId: 1, billingAccountId: 1, @@ -43,8 +40,8 @@ describe('Project member invite update', () => { }).then((p) => { project1 = p; // create members - models.ProjectMember.create({ - userId: 40051334, + const pm1 = models.ProjectMember.create({ + userId: testUtil.userIds.manager, projectId: project1.id, role: 'manager', isPrimary: false, @@ -52,55 +49,119 @@ describe('Project member invite update', () => { updatedBy: 1, createdAt: '2016-06-30 00:33:07+00', updatedAt: '2016-06-30 00:33:07+00', - }).then(() => { - models.ProjectMemberInvite.create({ - projectId: project1.id, - userId: 40051331, - email: null, - role: PROJECT_MEMBER_ROLE.CUSTOMER, - status: INVITE_STATUS.PENDING, - createdBy: 1, - updatedBy: 1, - createdAt: '2016-06-30 00:33:07+00', - updatedAt: '2016-06-30 00:33:07+00', - }).then((in1) => { - invite1 = in1.get({ - plain: true, - }); - models.ProjectMemberInvite.create({ - projectId: project1.id, - userId: 40051334, - email: null, - role: PROJECT_MEMBER_ROLE.MANAGER, - status: INVITE_STATUS.PENDING, - createdBy: 1, - updatedBy: 1, - createdAt: '2016-06-30 00:33:07+00', - updatedAt: '2016-06-30 00:33:07+00', - }).then((in2) => { - invite2 = in2.get({ - plain: true, - }); - models.ProjectMemberInvite.create({ - projectId: project1.id, - userId: 40051332, - email: null, - role: PROJECT_MEMBER_ROLE.COPILOT, - status: INVITE_STATUS.REQUESTED, - createdBy: 1, - updatedBy: 1, - createdAt: '2016-06-30 00:33:07+00', - updatedAt: '2016-06-30 00:33:07+00', - }).then((in3) => { - invite3 = in3.get({ - plain: true, - }); - done(); - }); - }); - }); }); + + const invite1 = models.ProjectMemberInvite.create({ + id: 1, + projectId: project1.id, + userId: testUtil.userIds.member, + email: null, + role: PROJECT_MEMBER_ROLE.CUSTOMER, + status: INVITE_STATUS.PENDING, + createdBy: 1, + updatedBy: 1, + createdAt: '2016-06-30 00:33:07+00', + updatedAt: '2016-06-30 00:33:07+00', + }); + + const invite2 = models.ProjectMemberInvite.create({ + id: 2, + projectId: project1.id, + userId: testUtil.userIds.copilot, + email: null, + role: PROJECT_MEMBER_ROLE.COPILOT, + status: INVITE_STATUS.REQUESTED, + createdBy: 1, + updatedBy: 1, + createdAt: '2016-06-30 00:33:07+00', + updatedAt: '2016-06-30 00:33:07+00', + }); + + const invite3 = models.ProjectMemberInvite.create({ + id: 3, + projectId: project1.id, + userId: testUtil.userIds.manager, + email: null, + role: PROJECT_MEMBER_ROLE.MANAGER, + status: INVITE_STATUS.PENDING, + createdBy: 1, + updatedBy: 1, + createdAt: '2016-06-30 00:33:07+00', + updatedAt: '2016-06-30 00:33:07+00', + }); + + return Promise.all([pm1, invite1, invite2, invite3]); }); + + const p2 = models.Project.create({ + type: 'generic', + directProjectId: 1, + billingAccountId: 1, + name: 'test2', + description: 'test project2', + status: 'draft', + details: {}, + createdBy: 1, + updatedBy: 1, + lastActivityAt: 1, + lastActivityUserId: '1', + }).then((p) => { + project2 = p; + // create members + const pm = models.ProjectMember.create({ + userId: testUtil.userIds.manager, + projectId: project2.id, + role: 'manager', + isPrimary: false, + createdBy: 1, + updatedBy: 1, + createdAt: '2016-06-30 00:33:07+00', + updatedAt: '2016-06-30 00:33:07+00', + }); + + const invite4 = models.ProjectMemberInvite.create({ + id: 4, + projectId: project2.id, + userId: testUtil.userIds.member, + email: null, + role: PROJECT_MEMBER_ROLE.CUSTOMER, + status: INVITE_STATUS.PENDING, + createdBy: 1, + updatedBy: 1, + createdAt: '2016-06-30 00:33:07+00', + updatedAt: '2016-06-30 00:33:07+00', + }); + + const invite5 = models.ProjectMemberInvite.create({ + id: 5, + projectId: project2.id, + userId: null, + email: 'romit.choudhary@rivigo.com', + role: PROJECT_MEMBER_ROLE.CUSTOMER, + status: INVITE_STATUS.PENDING, + createdBy: 1, + updatedBy: 1, + createdAt: '2016-06-30 00:33:07+00', + updatedAt: '2016-06-30 00:33:07+00', + }); + + const invite6 = models.ProjectMemberInvite.create({ + id: 6, + projectId: project2.id, + userId: testUtil.userIds.copilot, + email: null, + role: PROJECT_MEMBER_ROLE.COPILOT, + status: INVITE_STATUS.ACCEPTED, + createdBy: 1, + updatedBy: 1, + createdAt: '2016-06-30 00:33:07+00', + updatedAt: '2016-06-30 00:33:07+00', + }); + + return Promise.all([pm, invite4, invite5, invite6]); + }); + + Promise.all([p1, p2]).then(() => done()); }); }); @@ -108,7 +169,7 @@ describe('Project member invite update', () => { testUtil.clearDb(done); }); - describe('PUT /projects/{id}/members/invite', () => { + describe('PUT /projects/{id}/invites', () => { const body = { status: 'accepted', }; @@ -123,19 +184,18 @@ describe('Project member invite update', () => { it('should return 403 if user does not have permissions', (done) => { request(server) - .patch(`/v5/projects/${project1.id}/members/invite`) + .patch(`/v5/projects/${project1.id}/invites/1`) .send(body) .expect(403, done); }); - it('should return 404 if user has no invite', (done) => { + it('should return 404 if invitation id and project id doesn\'t match', (done) => { request(server) - .put(`/v5/projects/${project1.id}/members/invite`) + .patch(`/v5/projects/${project1.id}/invites/5`) .set({ - Authorization: `Bearer ${testUtil.jwts.copilot}`, + Authorization: `Bearer ${testUtil.jwts.admin}`, }) .send({ - userId: 123, status: INVITE_STATUS.CANCELED, }) .expect('Content-Type', /json/) @@ -145,9 +205,9 @@ describe('Project member invite update', () => { }); }); - it('should return 400 no userId or email is presented', (done) => { + it('should return 404 if project id doesn\'t exist', (done) => { request(server) - .put(`/v5/projects/${project1.id}/members/invite`) + .patch('/v5/projects/99999/invites/1') .set({ Authorization: `Bearer ${testUtil.jwts.admin}`, }) @@ -155,45 +215,51 @@ describe('Project member invite update', () => { status: INVITE_STATUS.CANCELED, }) .expect('Content-Type', /json/) - .expect(400) - .end((err, res) => { - if (err) { - done(err); - } else { - const resJson = res.body; - should.exist(resJson); - const errorMessage = _.get(resJson, 'message', ''); - sinon.assert.match(errorMessage, /.*userId or email should be provided/); - done(); - } + .expect(404) + .end(() => { + done(); + }); + }); + + it('should return 404 if invitation id doesn\'t exist', (done) => { + request(server) + .patch(`/v5/projects/${project1.id}/invites/99999`) + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .send({ + status: INVITE_STATUS.CANCELED, + }) + .expect('Content-Type', /json/) + .expect(404) + .end(() => { + done(); + }); + }); + + it('should return 404 if invitation status is not pending or requested', (done) => { + request(server) + .patch(`/v5/projects/${project2.id}/invites/6`) + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .send({ + status: INVITE_STATUS.CANCELED, + }) + .expect('Content-Type', /json/) + .expect(404) + .end(() => { + done(); }); }); it('should return 403 if try to update MANAGER role invite with copilot', (done) => { - const mockHttpClient = _.merge(testUtil.mockHttpClient, { - get: () => Promise.resolve({ - status: 200, - data: { - id: 'requesterId', - version: 'v3', - result: { - success: true, - status: 200, - content: [{ - roleName: USER_ROLE.COPILOT, - }], - }, - }, - }), - }); - sandbox.stub(util, 'getHttpClient', () => mockHttpClient); request(server) - .put(`/v5/projects/${project1.id}/members/invite`) + .patch(`/v5/projects/${project1.id}/invites/3`) .set({ Authorization: `Bearer ${testUtil.jwts.copilot}`, }) .send({ - userId: invite2.userId, status: INVITE_STATUS.CANCELED, }) .expect('Content-Type', /json/) @@ -212,30 +278,12 @@ describe('Project member invite update', () => { }); it('should return 403 if try to update others invite with CUSTOMER', (done) => { - const mockHttpClient = _.merge(testUtil.mockHttpClient, { - get: () => Promise.resolve({ - status: 200, - data: { - id: 'requesterId', - version: 'v3', - result: { - success: true, - status: 200, - content: [{ - roleName: USER_ROLE.CUSTOMER, - }], - }, - }, - }), - }); - sandbox.stub(util, 'getHttpClient', () => mockHttpClient); request(server) - .put(`/v5/projects/${project1.id}/members/invite`) + .patch(`/v5/projects/${project1.id}/invites/1`) .set({ - Authorization: `Bearer ${testUtil.jwts.member2}`, + Authorization: `Bearer ${testUtil.jwts.copilot}`, }) .send({ - userId: invite2.userId, status: INVITE_STATUS.CANCELED, }) .expect('Content-Type', /json/) @@ -247,37 +295,19 @@ describe('Project member invite update', () => { const resJson = res.body; should.exist(resJson); const errorMessage = _.get(resJson, 'message', ''); - sinon.assert.match(errorMessage, /.*Project members can cancel invites only for customer/); + sinon.assert.match(errorMessage, /.*Project members can only update invites for themselves/); done(); } }); }); it('should return 403 if try to update COPILOT role invite with copilot', (done) => { - const mockHttpClient = _.merge(testUtil.mockHttpClient, { - get: () => Promise.resolve({ - status: 200, - data: { - id: 'requesterId', - version: 'v3', - result: { - success: true, - status: 200, - content: [{ - roleName: USER_ROLE.COPILOT, - }], - }, - }, - }), - }); - sandbox.stub(util, 'getHttpClient', () => mockHttpClient); request(server) - .put(`/v5/projects/${project1.id}/members/invite`) + .patch(`/v5/projects/${project1.id}/invites/2`) .set({ Authorization: `Bearer ${testUtil.jwts.copilot}`, }) .send({ - userId: invite3.userId, status: INVITE_STATUS.ACCEPTED, }) .expect('Content-Type', /json/) @@ -295,6 +325,61 @@ describe('Project member invite update', () => { }); }); + it('should return 200 if member accepts his/her invitation', (done) => { + request(server) + .patch(`/v5/projects/${project1.id}/invites/1`) + .set({ + Authorization: `Bearer ${testUtil.jwts.member}`, + }) + .send({ + status: INVITE_STATUS.ACCEPTED, + }) + .expect('Content-Type', /json/) + .expect(200) + .end(() => done()); + }); + + it('should return 200 if admin accepts his/her invitation', (done) => { + request(server) + .patch(`/v5/projects/${project1.id}/invites/1`) + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .send({ + status: INVITE_STATUS.ACCEPTED, + }) + .expect('Content-Type', /json/) + .expect(200) + .end(() => done()); + }); + + it('should return 200 if copilot accepts his/her invitation', (done) => { + request(server) + .patch(`/v5/projects/${project1.id}/invites/2`) + .set({ + Authorization: `Bearer ${testUtil.jwts.copilot}`, + }) + .send({ + status: INVITE_STATUS.ACCEPTED, + }) + .expect('Content-Type', /json/) + .expect(200) + .end(() => done()); + }); + + it('should return 200 if user accept invitation by email', (done) => { + request(server) + .patch(`/v5/projects/${project1.id}/invites/5`) + .set({ + Authorization: `Bearer ${testUtil.jwts.romit}`, + }) + .send({ + status: INVITE_STATUS.ACCEPTED, + }) + .expect('Content-Type', /json/) + .expect(200) + .end(() => done()); + }); describe('Bus api', () => { let createEventSpy; @@ -317,12 +402,11 @@ describe('Project member invite update', () => { }); sandbox.stub(util, 'getHttpClient', () => mockHttpClient); request(server) - .put(`/v5/projects/${project1.id}/members/invite`) + .patch(`/v5/projects/${project1.id}/invites/1`) .set({ Authorization: `Bearer ${testUtil.jwts.member}`, }) .send({ - userId: invite1.userId, status: INVITE_STATUS.ACCEPTED, }) .expect('Content-Type', /json/) @@ -334,13 +418,11 @@ describe('Project member invite update', () => { testUtil.wait(() => { createEventSpy.callCount.should.be.eql(5); - /* - Events for accepted invite - */ + // Events for accepted invite createEventSpy.calledWith(BUS_API_EVENT.PROJECT_MEMBER_INVITE_UPDATED, sinon.match({ resource: RESOURCES.PROJECT_MEMBER_INVITE, projectId: project1.id, - userId: invite1.userId, + userId: testUtil.userIds.member, status: INVITE_STATUS.ACCEPTED, email: null, })).should.be.true; @@ -348,33 +430,31 @@ describe('Project member invite update', () => { // Check Notification Service events createEventSpy.calledWith(CONNECT_NOTIFICATION_EVENT.PROJECT_MEMBER_INVITE_UPDATED, sinon.match({ projectId: project1.id, - userId: invite1.userId, + userId: testUtil.userIds.member, status: INVITE_STATUS.ACCEPTED, email: null, isSSO: false, })).should.be.true; - /* - Events for created member (after invite acceptance) - */ + // Events for created member (after invite acceptance) createEventSpy.calledWith(BUS_API_EVENT.PROJECT_MEMBER_ADDED, sinon.match({ resource: RESOURCES.PROJECT_MEMBER, projectId: project1.id, - userId: invite1.userId, + userId: testUtil.userIds.member, })).should.be.true; // Check Notification Service events createEventSpy.calledWith(CONNECT_NOTIFICATION_EVENT.MEMBER_JOINED, sinon.match({ projectId: project1.id, projectName: project1.name, - userId: invite1.userId, - initiatorUserId: 40051331, + userId: testUtil.userIds.member, + initiatorUserId: testUtil.userIds.member, })).should.be.true; createEventSpy.calledWith(CONNECT_NOTIFICATION_EVENT.PROJECT_TEAM_UPDATED, sinon.match({ projectId: project1.id, projectName: project1.name, - userId: invite1.userId, - initiatorUserId: 40051331, + userId: testUtil.userIds.member, + initiatorUserId: testUtil.userIds.member, })).should.be.true; done(); diff --git a/src/util.js b/src/util.js index a593751e..3af16049 100644 --- a/src/util.js +++ b/src/util.js @@ -522,6 +522,35 @@ _.assignIn(util, { } }), + /** + * Retrieve member details from user handles + */ + getMemberDetailsByHandles: Promise.coroutine(function* (handles, logger, requestId) { // eslint-disable-line func-names + if (_.isNil(handles) || (_.isArray(handles) && handles.length <= 0)) { + return Promise.resolve([]); + } + try { + const token = yield this.getM2MToken(); + const httpClient = this.getHttpClient({ id: requestId, log: logger }); + if (logger) { + logger.trace(handles); + } + const handleArr = _.map(handles, h => `handleLower:${h.toLowerCase()}`); + return httpClient.get(`${config.memberServiceEndpoint}/_search`, { + params: { + query: `${handleArr.join(urlencode(' OR ', 'utf8'))}`, + fields: 'userId,handle,firstName,lastName,email', + }, + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${token}`, + }, + }).then(res => _.get(res, 'data.result.content', null)); + } catch (err) { + return Promise.reject(err); + } + }), + /** * maksEmail *