diff --git a/README.md b/README.md index c9efc4e7..2b4a6450 100644 --- a/README.md +++ b/README.md @@ -5,6 +5,7 @@ Microservice to manage CRUD operations for all things Projects. ### Note : Steps mentioned below are best to our capability as guide for local deployment, however, we expect from contributor, being a developer, to resolve run-time issues (e.g. OS and node version issues etc), if any. ### Local Development + * We use docker-compose for running dependencies locally. Instructions for Docker compose setup - https://docs.docker.com/compose/install/ * Nodejs 8.9.4 - consider using [nvm](https://github.com/creationix/nvm) or equivalent to manage your node version * Install [libpg](https://www.npmjs.com/package/pg-native) diff --git a/postman.json b/postman.json index 999689af..8c74a585 100644 --- a/postman.json +++ b/postman.json @@ -1,6 +1,6 @@ { "info": { - "_postman_id": "97085cd7-b298-4f1c-9629-24af14ff5f13", + "_postman_id": "4fc2b7cf-404a-4fd7-b6d2-4828a3994859", "name": "tc-project-service", "schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json" }, @@ -1041,6 +1041,31 @@ }, "response": [] }, + { + "name": "Invite with userIds and emails - both success and failed", + "request": { + "url": "{{api-url}}/v4/projects/1/members/invite", + "method": "POST", + "header": [ + { + "key": "Authorization", + "value": "Bearer {{jwt-token-manager-40051334}}", + "description": "" + }, + { + "key": "Content-Type", + "value": "application/json", + "description": "" + } + ], + "body": { + "mode": "raw", + "raw": "{\n\t\"param\": {\n\t\t\"userIds\": [40051331, 40051334],\n\t\t\"emails\": [\"divyalife526@gmail.com\"],\n\t\t\"role\": \"manager\"\n\t}\n}" + }, + "description": "" + }, + "response": [] + }, { "name": "Update invite status with userId", "request": { @@ -1452,7 +1477,109 @@ "response": [] }, { - "name": "List projects with filters applied", + "name": "List projects with filters - type (exact)", + "request": { + "method": "GET", + "header": [ + { + "key": "Authorization", + "value": "Bearer {{jwt-token}}" + } + ], + "body": { + "mode": "raw", + "raw": "" + }, + "url": { + "raw": "{{api-url}}/v4/projects?filter=type%3Dapp", + "host": [ + "{{api-url}}" + ], + "path": [ + "v4", + "projects" + ], + "query": [ + { + "key": "filter", + "value": "type%3Dapp" + } + ] + }, + "description": "List all the project with filters applied. The filter string should be url encoded. Default limit and offset is applicable" + }, + "response": [] + }, + { + "name": "List projects with filters - id (exact)", + "request": { + "method": "GET", + "header": [ + { + "key": "Authorization", + "value": "Bearer {{jwt-token}}" + } + ], + "body": { + "mode": "raw", + "raw": "" + }, + "url": { + "raw": "{{api-url}}/v4/projects?filter=id%3D1", + "host": [ + "{{api-url}}" + ], + "path": [ + "v4", + "projects" + ], + "query": [ + { + "key": "filter", + "value": "id%3D1" + } + ] + }, + "description": "List all the project with filters applied. The filter string should be url encoded. Default limit and offset is applicable" + }, + "response": [] + }, + { + "name": "List projects with filters - name, code, customer, manager", + "request": { + "method": "GET", + "header": [ + { + "key": "Authorization", + "value": "Bearer {{jwt-token}}" + } + ], + "body": { + "mode": "raw", + "raw": "" + }, + "url": { + "raw": "{{api-url}}/v4/projects?filter=id%3D1*%26name%3Dtes*%26code=test*%26customer%3DDiya*%26manager=first*", + "host": [ + "{{api-url}}" + ], + "path": [ + "v4", + "projects" + ], + "query": [ + { + "key": "filter", + "value": "id%3D1*%26name%3Dtes*%26code=test*%26customer%3DDiya*%26manager=first*" + } + ] + }, + "description": "List all the project with filters applied. The filter string should be url encoded. Default limit and offset is applicable" + }, + "response": [] + }, + { + "name": "List projects with filters - code", "request": { "method": "GET", "header": [ @@ -1466,7 +1593,7 @@ "raw": "" }, "url": { - "raw": "{{api-url}}/v4/projects?filter=type%3Dgeneric", + "raw": "{{api-url}}/v4/projects?filter=code%3Dtest*", "host": [ "{{api-url}}" ], @@ -1477,7 +1604,7 @@ "query": [ { "key": "filter", - "value": "type%3Dgeneric" + "value": "code%3Dtest*" } ] }, @@ -5395,4 +5522,4 @@ ] } ] -} \ No newline at end of file +} diff --git a/src/constants.js b/src/constants.js index 032e1e8d..583cfce1 100644 --- a/src/constants.js +++ b/src/constants.js @@ -18,6 +18,7 @@ export const PROJECT_MEMBER_ROLE = { OBSERVER: 'observer', CUSTOMER: 'customer', COPILOT: 'copilot', + ACCOUNT_MANAGER: 'account_manager', }; export const PROJECT_MEMBER_MANAGER_ROLES = [PROJECT_MEMBER_ROLE.MANAGER, PROJECT_MEMBER_ROLE.OBSERVER]; @@ -25,13 +26,20 @@ export const PROJECT_MEMBER_MANAGER_ROLES = [PROJECT_MEMBER_ROLE.MANAGER, PROJEC export const USER_ROLE = { TOPCODER_ADMIN: 'administrator', MANAGER: 'Connect Manager', + TOPCODER_ACCOUNT_MANAGER: 'Connect Account Manager', COPILOT: 'Connect Copilot', CONNECT_ADMIN: 'Connect Admin', + COPILOT_MANAGER: 'Connect Copilot Manager', }; export const ADMIN_ROLES = [USER_ROLE.CONNECT_ADMIN, USER_ROLE.TOPCODER_ADMIN]; -export const MANAGER_ROLES = [...ADMIN_ROLES, USER_ROLE.MANAGER]; +export const MANAGER_ROLES = [ + ...ADMIN_ROLES, + USER_ROLE.MANAGER, + USER_ROLE.TOPCODER_ACCOUNT_MANAGER, + USER_ROLE.COPILOT_MANAGER, +]; export const EVENT = { ROUTING_KEY: { @@ -130,7 +138,10 @@ export const BUS_API_EVENT = { // Project Member Invites PROJECT_MEMBER_INVITE_CREATED: 'notifications.connect.project.member.invite.created', + PROJECT_MEMBER_INVITE_REQUESTED: 'notifications.connect.project.member.invite.requested', PROJECT_MEMBER_INVITE_UPDATED: 'notifications.connect.project.member.invite.updated', + PROJECT_MEMBER_INVITE_APPROVED: 'notifications.connect.project.member.invite.approved', + PROJECT_MEMBER_INVITE_REJECTED: 'notifications.connect.project.member.invite.rejected', PROJECT_MEMBER_EMAIL_INVITE_CREATED: 'connect.action.email.project.member.invite.created', }; @@ -156,5 +167,8 @@ export const INVITE_STATUS = { PENDING: 'pending', ACCEPTED: 'accepted', REFUSED: 'refused', + REQUESTED: 'requested', + REQUEST_REJECTED: 'request_rejected', + REQUEST_APPROVED: 'request_approved', CANCELED: 'canceled', }; diff --git a/src/events/busApi.js b/src/events/busApi.js index 141ee3e4..b1b69dc2 100644 --- a/src/events/busApi.js +++ b/src/events/busApi.js @@ -1,6 +1,7 @@ import _ from 'lodash'; import config from 'config'; -import { EVENT, BUS_API_EVENT, PROJECT_STATUS, PROJECT_PHASE_STATUS, PROJECT_MEMBER_ROLE, MILESTONE_STATUS } +import { EVENT, BUS_API_EVENT, PROJECT_STATUS, PROJECT_PHASE_STATUS, PROJECT_MEMBER_ROLE, MILESTONE_STATUS, + INVITE_STATUS } from '../constants'; import { createEvent } from '../services/busApi'; import models from '../models'; @@ -696,30 +697,66 @@ module.exports = (app, logger) => { } }); - app.on(EVENT.ROUTING_KEY.PROJECT_MEMBER_INVITE_CREATED, ({ req, userId, email }) => { + app.on(EVENT.ROUTING_KEY.PROJECT_MEMBER_INVITE_CREATED, ({ req, userId, email, status, role }) => { logger.debug('receive PROJECT_MEMBER_INVITE_CREATED event'); const projectId = _.parseInt(req.params.projectId); - // send event to bus api - createEvent(BUS_API_EVENT.PROJECT_MEMBER_INVITE_CREATED, { - projectId, - userId, - email, - initiatorUserId: req.authUser.userId, - }, logger); + if (status === INVITE_STATUS.REQUESTED) { + createEvent(BUS_API_EVENT.PROJECT_MEMBER_INVITE_REQUESTED, { + projectId, + userId, + email, + role, + initiatorUserId: req.authUser.userId, + }, logger); + } else { + // send event to bus api + createEvent(BUS_API_EVENT.PROJECT_MEMBER_INVITE_CREATED, { + projectId, + userId, + email, + role, + initiatorUserId: req.authUser.userId, + }, logger); + } }); - app.on(EVENT.ROUTING_KEY.PROJECT_MEMBER_INVITE_UPDATED, ({ req, userId, email, status }) => { + app.on(EVENT.ROUTING_KEY.PROJECT_MEMBER_INVITE_UPDATED, ({ req, userId, email, status, role, createdBy }) => { logger.debug('receive PROJECT_MEMBER_INVITE_UPDATED event'); const projectId = _.parseInt(req.params.projectId); - // send event to bus api - createEvent(BUS_API_EVENT.PROJECT_MEMBER_INVITE_UPDATED, { - projectId, - userId, - email, - status, - initiatorUserId: req.authUser.userId, - }, logger); + if (status === INVITE_STATUS.REQUEST_APPROVED) { + // send event to bus api + createEvent(BUS_API_EVENT.PROJECT_MEMBER_INVITE_APPROVED, { + projectId, + userId, + originator: createdBy, + email, + role, + status, + initiatorUserId: req.authUser.userId, + }, logger); + } else if (status === INVITE_STATUS.REQUEST_REJECTED) { + // send event to bus api + createEvent(BUS_API_EVENT.PROJECT_MEMBER_INVITE_REJECTED, { + projectId, + userId, + originator: createdBy, + email, + role, + status, + initiatorUserId: req.authUser.userId, + }, logger); + } else { + // send event to bus api + createEvent(BUS_API_EVENT.PROJECT_MEMBER_INVITE_UPDATED, { + projectId, + userId, + email, + role, + status, + initiatorUserId: req.authUser.userId, + }, logger); + } }); }; diff --git a/src/models/projectMemberInvite.js b/src/models/projectMemberInvite.js index ac33ee2d..ae27d0cb 100644 --- a/src/models/projectMemberInvite.js +++ b/src/models/projectMemberInvite.js @@ -55,6 +55,15 @@ module.exports = function defineProjectMemberInvite(sequelize, DataTypes) { raw: true, }); }, + getPendingAndReguestedInvitesForProject(projectId) { + return this.findAll({ + where: { + projectId, + status: { $in: [INVITE_STATUS.PENDING, INVITE_STATUS.REQUESTED] }, + }, + raw: true, + }); + }, getPendingInviteByEmailOrUserId(projectId, email, userId) { const where = { projectId, status: INVITE_STATUS.PENDING }; @@ -69,6 +78,16 @@ module.exports = function defineProjectMemberInvite(sequelize, DataTypes) { where, }); }, + getRequestedInvite(projectId, userId) { + const where = { projectId, status: INVITE_STATUS.REQUESTED }; + + if (userId) { + _.assign(where, { userId }); + } + return this.findOne({ + where, + }); + }, getProjectInvitesForUser(email, userId) { const where = { status: INVITE_STATUS.PENDING }; diff --git a/src/permissions/project.edit.js b/src/permissions/project.edit.js index 760e672e..a9ab5d51 100644 --- a/src/permissions/project.edit.js +++ b/src/permissions/project.edit.js @@ -3,7 +3,7 @@ import _ from 'lodash'; import util from '../util'; import models from '../models'; -import { USER_ROLE } from '../constants'; +import { MANAGER_ROLES } from '../constants'; /** * Super admin, Topcoder Managers are allowed to edit any project @@ -20,7 +20,7 @@ module.exports = freq => new Promise((resolve, reject) => { req.context.currentProjectMembers = members; // check if auth user has acecss to this project const hasAccess = util.hasAdminRole(req) - || util.hasRole(req, USER_ROLE.MANAGER) + || util.hasRoles(req, MANAGER_ROLES) || !_.isUndefined(_.find(members, m => m.userId === req.authUser.userId)); if (!hasAccess) { diff --git a/src/permissions/project.view.js b/src/permissions/project.view.js index b82cd1dc..61e4ebed 100644 --- a/src/permissions/project.view.js +++ b/src/permissions/project.view.js @@ -2,7 +2,7 @@ import _ from 'lodash'; import util from '../util'; import models from '../models'; -import { USER_ROLE, PROJECT_STATUS, PROJECT_MEMBER_ROLE } from '../constants'; +import { USER_ROLE, PROJECT_STATUS, PROJECT_MEMBER_ROLE, MANAGER_ROLES } from '../constants'; /** * Super admin, Topcoder Managers are allowed to view any projects @@ -21,7 +21,7 @@ module.exports = freq => new Promise((resolve, reject) => { req.context.currentProjectMembers = members; // check if auth user has acecss to this project const hasAccess = util.hasAdminRole(req) - || util.hasRole(req, USER_ROLE.MANAGER) + || util.hasRoles(req, MANAGER_ROLES) || !_.isUndefined(_.find(members, m => m.userId === currentUserId)); // if user is co-pilot and the project doesn't have any copilots then diff --git a/src/routes/projectMemberInvites/create.js b/src/routes/projectMemberInvites/create.js index 3214c493..4839ba45 100644 --- a/src/routes/projectMemberInvites/create.js +++ b/src/routes/projectMemberInvites/create.js @@ -8,7 +8,7 @@ import { middleware as tcMiddleware } from 'tc-core-library-js'; import models from '../../models'; import util from '../../util'; import { PROJECT_MEMBER_ROLE, PROJECT_MEMBER_MANAGER_ROLES, - MANAGER_ROLES, INVITE_STATUS, EVENT, BUS_API_EVENT } from '../../constants'; + MANAGER_ROLES, INVITE_STATUS, EVENT, BUS_API_EVENT, USER_ROLE } from '../../constants'; import { createEvent } from '../../services/busApi'; @@ -35,12 +35,12 @@ const addMemberValidations = { * @param {Object} invite invite to process * @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 * * @returns {Promise} list of promises */ -const buildCreateInvitePromises = (req, invite, invites, data) => { +const buildCreateInvitePromises = (req, invite, invites, data, failed) => { const invitePromises = []; - if (invite.userIds) { // remove invites for users that are invited already _.remove(invite.userIds, u => _.some(invites, i => i.userId === u)); @@ -91,15 +91,15 @@ const buildCreateInvitePromises = (req, invite, invites, data) => { invitePromises.push(models.ProjectMemberInvite.create(dataNew)); }); - - return Promise.resolve(invitePromises); + return invitePromises; }).catch((error) => { req.log.error(error); - return Promise.reject(invitePromises); + _.forEach(invite.emails, email => failed.push(_.assign({}, { email, message: error.statusText }))); + return invitePromises; }); } - return Promise.resolve(invitePromises); + return invitePromises; }; const sendInviteEmail = (req, projectId, invite) => { @@ -157,6 +157,7 @@ module.exports = [ validate(addMemberValidations), permissions('projectMemberInvite.create'), (req, res, next) => { + let failed = []; const invite = req.body.param; if (!invite.userIds && !invite.emails) { @@ -192,12 +193,11 @@ module.exports = [ 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 err = new Error(`Emails can only be used for ${PROJECT_MEMBER_ROLE.CUSTOMER}`); - err.status = 400; - return next(err); + 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()); } @@ -209,14 +209,14 @@ module.exports = [ const [userId, roles] = data; req.log.debug(roles); - if (!util.hasIntersection(MANAGER_ROLES, roles)) { + if (roles && !util.hasIntersection(MANAGER_ROLES, roles)) { forbidUserList.push(userId); } }); if (forbidUserList.length > 0) { - const err = new Error(`${forbidUserList.join()} cannot be added with a Manager role to the project`); - err.status = 403; - return next(err); + 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) @@ -224,42 +224,48 @@ module.exports = [ const data = { projectId, role: invite.role, - status: INVITE_STATUS.PENDING, + // 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, }; - return buildCreateInvitePromises(req, invite, invites, data) - .then((invitePromises) => { - if (invitePromises.length === 0) { - return []; - } - - req.log.debug('Creating invites'); - return models.sequelize.Promise.all(invitePromises) - .then((values) => { - values.forEach((v) => { - req.app.emit(EVENT.ROUTING_KEY.PROJECT_MEMBER_INVITE_CREATED, { - req, - userId: v.userId, - email: v.email, - }); - 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) { - sendInviteEmail(req, projectId, v); - } - }); - return values; - }); // models.sequelize.Promise.all - }); // buildCreateInvitePromises + req.log.debug('Creating invites'); + return models.sequelize.Promise.all(buildCreateInvitePromises(req, invite, invites, data, failed)) + .then((values) => { + values.forEach((v) => { + req.app.emit(EVENT.ROUTING_KEY.PROJECT_MEMBER_INVITE_CREATED, { + req, + userId: v.userId, + email: v.email, + status: v.status, + role: v.role, + }); + 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; + }); // models.sequelize.Promise.all }); // models.ProjectMemberInvite.getPendingInvitesForProject }) - .then(values => res.status(201).json(util.wrapResponse(req.id, values, null, 201))) + .then((values) => { + const success = _.assign({}, { success: values }); + if (failed.length) { + res.status(403).json(util.wrapResponse(req.id, _.assign({}, success, { failed }), null, 403)); + } else { + res.status(201).json(util.wrapResponse(req.id, success, null, 201)); + } + }) .catch(err => next(err)); }, ]; diff --git a/src/routes/projectMemberInvites/create.spec.js b/src/routes/projectMemberInvites/create.spec.js index e648a06f..74f6be9c 100644 --- a/src/routes/projectMemberInvites/create.spec.js +++ b/src/routes/projectMemberInvites/create.spec.js @@ -42,6 +42,15 @@ describe('Project Member Invite create', () => { createdBy: 1, updatedBy: 1, }); + + models.ProjectMember.create({ + userId: 40051334, + projectId: project1.id, + role: 'manager', + isPrimary: true, + createdBy: 1, + updatedBy: 1, + }); }).then(() => models.Project.create({ type: 'generic', @@ -87,6 +96,7 @@ describe('Project Member Invite create', () => { sinon.stub(server.services.pubsub, 'init', () => {}); sinon.stub(server.services.pubsub, 'publish', () => {}); // by default mock lookupUserEmails return nothing so all the cases are not broken + sandbox.stub(util, 'getUserRoles', () => Promise.resolve([])); sandbox.stub(util, 'lookupUserEmails', () => Promise.resolve([])); sandbox.stub(util, 'getMemberDetailsByUserIds', () => Promise.resolve([{ userId: 40051333, @@ -246,9 +256,11 @@ describe('Project Member Invite create', () => { result: { success: true, status: 200, - content: [{ - roleName: USER_ROLE.COPILOT, - }], + content: { + success: [{ + roleName: USER_ROLE.COPILOT, + }], + }, }, }, }), @@ -271,7 +283,7 @@ describe('Project Member Invite create', () => { if (err) { done(err); } else { - const resJson = res.body.result.content[0]; + const resJson = res.body.result.content.success[0]; should.exist(resJson); resJson.role.should.equal('customer'); resJson.projectId.should.equal(project2.id); @@ -292,9 +304,11 @@ describe('Project Member Invite create', () => { result: { success: true, status: 200, - content: [{ - roleName: USER_ROLE.COPILOT, - }], + content: { + success: [{ + roleName: USER_ROLE.COPILOT, + }], + }, }, }, }), @@ -322,7 +336,7 @@ describe('Project Member Invite create', () => { if (err) { done(err); } else { - const resJson = res.body.result.content[0]; + const resJson = res.body.result.content.success[0]; should.exist(resJson); resJson.role.should.equal('customer'); resJson.projectId.should.equal(project2.id); @@ -344,9 +358,11 @@ describe('Project Member Invite create', () => { result: { success: true, status: 200, - content: [{ - roleName: USER_ROLE.COPILOT, - }], + content: { + success: [{ + roleName: USER_ROLE.COPILOT, + }], + }, }, }, }), @@ -369,7 +385,7 @@ describe('Project Member Invite create', () => { if (err) { done(err); } else { - const resJson = res.body.result.content[0]; + const resJson = res.body.result.content.success[0]; should.exist(resJson); resJson.role.should.equal('customer'); resJson.projectId.should.equal(project2.id); @@ -390,9 +406,11 @@ describe('Project Member Invite create', () => { result: { success: true, status: 200, - content: [{ - roleName: USER_ROLE.COPILOT, - }], + content: { + success: [{ + roleName: USER_ROLE.COPILOT, + }], + }, }, }, }), @@ -415,7 +433,7 @@ describe('Project Member Invite create', () => { if (err) { done(err); } else { - const resJson = res.body.result.content; + const resJson = res.body.result.content.success; should.exist(resJson); resJson.length.should.equal(0); server.services.pubsub.publish.neverCalledWith('project.member.invite.created').should.be.true; @@ -484,16 +502,18 @@ describe('Project Member Invite create', () => { it('should return 201 if try to create manager with MANAGER_ROLES', (done) => { const mockHttpClient = _.merge(testUtil.mockHttpClient, { get: () => Promise.resolve({ - status: 200, + status: 403, data: { id: 'requesterId', version: 'v3', result: { success: true, - status: 200, - content: [{ - roleName: USER_ROLE.MANAGER, - }], + status: 403, + content: { + failed: [{ + message: 'cannot be added with a Manager role to the project', + }], + }, }, }, }), @@ -511,16 +531,57 @@ describe('Project Member Invite create', () => { }, }) .expect('Content-Type', /json/) + .expect(403) + .end((err, res) => { + const failed = res.body.result.content.failed[0]; + should.exist(failed); + failed.message.should.equal('cannot be added with a Manager role to the project'); + done(); + }); + }); + + 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(`/v4/projects/${project1.id}/members/invite`) + .set({ + Authorization: `Bearer ${testUtil.jwts.manager}`, + }) + .send({ + param: { + userIds: [40051331], + role: 'copilot', + }, + }) + .expect('Content-Type', /json/) .expect(201) .end((err, res) => { if (err) { done(err); } else { - const resJson = res.body.result.content[0]; + const resJson = res.body.result.content.success[0]; should.exist(resJson); - resJson.role.should.equal('manager'); + resJson.role.should.equal('copilot'); resJson.projectId.should.equal(project1.id); - resJson.userId.should.equal(40152855); + resJson.userId.should.equal(40051331); server.services.pubsub.publish.calledWith('project.member.invite.created').should.be.true; done(); } diff --git a/src/routes/projectMemberInvites/update.js b/src/routes/projectMemberInvites/update.js index 300ba0ad..a5ba6b5a 100644 --- a/src/routes/projectMemberInvites/update.js +++ b/src/routes/projectMemberInvites/update.js @@ -4,7 +4,7 @@ import Joi from 'joi'; import { middleware as tcMiddleware } from 'tc-core-library-js'; import models from '../../models'; import util from '../../util'; -import { PROJECT_MEMBER_ROLE, MANAGER_ROLES, INVITE_STATUS, EVENT } from '../../constants'; +import { PROJECT_MEMBER_ROLE, MANAGER_ROLES, INVITE_STATUS, EVENT, USER_ROLE } from '../../constants'; /** * API to update invite member to project. @@ -44,13 +44,17 @@ module.exports = [ } let invite; + let requestedInvite; return models.ProjectMemberInvite.getPendingInviteByEmailOrUserId( projectId, putInvite.email, putInvite.userId, ).then((_invite) => { invite = _invite; - if (!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( @@ -60,14 +64,20 @@ module.exports = [ return next(err); } + invite = invite || requestedInvite; + req.log.debug('Chekcing user permission for updating invite'); let error = null; - if (putInvite.status === INVITE_STATUS.CANCELED) { + if (invite.status === INVITE_STATUS.REQUESTED && + !util.hasRoles(req, [USER_ROLE.CONNECT_ADMIN, USER_ROLE.COPILOT_MANAGER])) { + 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)) { + } 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])) { error = 'Project members can only update invites for themselves'; } @@ -88,6 +98,8 @@ module.exports = [ userId: updatedInvite.userId, email: updatedInvite.email, status: updatedInvite.status, + role: updatedInvite.role, + createdBy: updatedInvite.createdBy, }); req.app.services.pubsub.publish(EVENT.ROUTING_KEY.PROJECT_MEMBER_INVITE_UPDATED, updatedInvite, { correlationId: req.id, @@ -95,7 +107,8 @@ module.exports = [ req.log.debug('Adding user to project'); // add user to project if accept invite - if (updatedInvite.status === INVITE_STATUS.ACCEPTED) { + if (updatedInvite.status === INVITE_STATUS.ACCEPTED || + updatedInvite.status === INVITE_STATUS.REQUEST_APPROVED) { return models.ProjectMember.getActiveProjectMembers(projectId) .then((members) => { req.context = req.context || {}; @@ -103,7 +116,7 @@ module.exports = [ const member = { projectId, role: updatedInvite.role, - userId: req.authUser.userId, + userId: updatedInvite.userId, createdBy: req.authUser.userId, updatedBy: req.authUser.userId, }; diff --git a/src/routes/projectMemberInvites/update.spec.js b/src/routes/projectMemberInvites/update.spec.js index a137e57e..0383291b 100644 --- a/src/routes/projectMemberInvites/update.spec.js +++ b/src/routes/projectMemberInvites/update.spec.js @@ -16,6 +16,7 @@ describe('Project member invite update', () => { let project1; let invite1; let invite2; + let invite3; beforeEach((done) => { testUtil.clearDb() @@ -61,7 +62,7 @@ describe('Project member invite update', () => { }); models.ProjectMemberInvite.create({ projectId: project1.id, - userId: 40051332, + userId: 40051334, email: null, role: PROJECT_MEMBER_ROLE.MANAGER, status: INVITE_STATUS.PENDING, @@ -73,7 +74,22 @@ describe('Project member invite update', () => { invite2 = in2.get({ plain: true, }); - done(); + 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(); + }); }); }); }); @@ -243,6 +259,52 @@ describe('Project member invite update', () => { }); }); + 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(`/v4/projects/${project1.id}/members/invite`) + .set({ + Authorization: `Bearer ${testUtil.jwts.copilot}`, + }) + .send({ + param: { + userId: invite3.userId, + status: INVITE_STATUS.ACCEPTED, + }, + }) + .expect('Content-Type', /json/) + .expect(403) + .end((err, res) => { + if (err) { + done(err); + } else { + const resJson = res.body.result.content; + should.exist(resJson); + res.body.result.status.should.equal(403); + const errorMessage = _.get(resJson, 'message', ''); + sinon.assert.match(errorMessage, 'Requested invites can only be updated by Copilot manager'); + done(); + } + }); + }); + + describe('Bus api', () => { let createEventSpy; diff --git a/src/routes/projectMembers/create.js b/src/routes/projectMembers/create.js index 96c34a85..f3a1fc0c 100644 --- a/src/routes/projectMembers/create.js +++ b/src/routes/projectMembers/create.js @@ -1,9 +1,9 @@ - - import _ from 'lodash'; +import Joi from 'joi'; +import validate from 'express-validation'; import { middleware as tcMiddleware } from 'tc-core-library-js'; import util from '../../util'; -import { USER_ROLE, PROJECT_MEMBER_ROLE, MANAGER_ROLES, INVITE_STATUS } from '../../constants'; +import { INVITE_STATUS, MANAGER_ROLES, PROJECT_MEMBER_ROLE, USER_ROLE } from '../../constants'; import models from '../../models'; /** @@ -13,14 +13,49 @@ import models from '../../models'; */ const permissions = tcMiddleware.permissions; +const createProjectMemberValidations = { + body: { + param: Joi.object() + .keys({ + role: Joi.any() + .valid(PROJECT_MEMBER_ROLE.MANAGER, PROJECT_MEMBER_ROLE.ACCOUNT_MANAGER, PROJECT_MEMBER_ROLE.COPILOT), + }), + }, +}; + module.exports = [ // handles request validations + validate(createProjectMemberValidations), permissions('project.addMember'), (req, res, next) => { let targetRole; - if (util.hasRoles(req, [USER_ROLE.MANAGER])) { + if (_.get(req, 'body.param.role')) { + targetRole = _.get(req, 'body.param.role'); + + if (PROJECT_MEMBER_ROLE.MANAGER === targetRole && + !util.hasRoles(req, [USER_ROLE.MANAGER])) { + const err = new Error(`Only manager is able to join as ${targetRole}`); + err.status = 401; + return next(err); + } + + if (PROJECT_MEMBER_ROLE.ACCOUNT_MANAGER === targetRole && + !util.hasRoles(req, [USER_ROLE.MANAGER, USER_ROLE.TOPCODER_ACCOUNT_MANAGER])) { + const err = new Error(`Only manager or account manager is able to join as ${targetRole}`); + err.status = 401; + return next(err); + } + + if (targetRole === PROJECT_MEMBER_ROLE.COPILOT && !util.hasRoles(req, [USER_ROLE.COPILOT])) { + const err = new Error(`Only copilot is able to join as ${targetRole}`); + err.status = 401; + return next(err); + } + } else if (util.hasRoles(req, [USER_ROLE.MANAGER, USER_ROLE.CONNECT_ADMIN])) { targetRole = PROJECT_MEMBER_ROLE.MANAGER; - } else if (util.hasRoles(req, [USER_ROLE.COPILOT])) { + } else if (util.hasRoles(req, [USER_ROLE.TOPCODER_ACCOUNT_MANAGER])) { + targetRole = PROJECT_MEMBER_ROLE.ACCOUNT_MANAGER; + } else if (util.hasRoles(req, [USER_ROLE.COPILOT, USER_ROLE.CONNECT_ADMIN])) { targetRole = PROJECT_MEMBER_ROLE.COPILOT; } else { const err = new Error('Only copilot or manager is able to call this endpoint'); @@ -60,13 +95,17 @@ module.exports = [ .then((_invite) => { invite = _invite; if (!invite) { - return res.status(201).json(util.wrapResponse(req.id, newMember, 1, 201)); + return res.status(201) + .json(util.wrapResponse(req.id, newMember, 1, 201)); } return invite.update({ status: INVITE_STATUS.ACCEPTED, - }).then(() => res.status(201).json(util.wrapResponse(req.id, newMember, 1, 201))); + }) + .then(() => res.status(201) + .json(util.wrapResponse(req.id, newMember, 1, 201))); }); }); - }).catch(err => next(err)); + }) + .catch(err => next(err)); }, ]; diff --git a/src/routes/projectMembers/update.js b/src/routes/projectMembers/update.js index 97efadb1..7001133f 100644 --- a/src/routes/projectMembers/update.js +++ b/src/routes/projectMembers/update.js @@ -17,7 +17,7 @@ const updateProjectMemberValdiations = { param: Joi.object().keys({ isPrimary: Joi.boolean(), role: Joi.any().valid(PROJECT_MEMBER_ROLE.CUSTOMER, PROJECT_MEMBER_ROLE.MANAGER, - PROJECT_MEMBER_ROLE.COPILOT, PROJECT_MEMBER_ROLE.OBSERVER).required(), + PROJECT_MEMBER_ROLE.ACCOUNT_MANAGER, PROJECT_MEMBER_ROLE.COPILOT, PROJECT_MEMBER_ROLE.OBSERVER).required(), }), }, }; diff --git a/src/routes/projectTemplates/update.js b/src/routes/projectTemplates/update.js index acc7e741..10d55d70 100644 --- a/src/routes/projectTemplates/update.js +++ b/src/routes/projectTemplates/update.js @@ -64,7 +64,11 @@ module.exports = [ } // Merge JSON fields - entityToUpdate.scope = util.mergeJsonObjects(projectTemplate.scope, entityToUpdate.scope, ['priceConfig']); + entityToUpdate.scope = util.mergeJsonObjects( + projectTemplate.scope, + entityToUpdate.scope, + ['priceConfig', 'addonPriceConfig', 'preparedConditions', 'buildingBlocks'], + ); entityToUpdate.phases = util.mergeJsonObjects(projectTemplate.phases, entityToUpdate.phases); // removes null phase templates entityToUpdate.phases = _.omitBy(entityToUpdate.phases, _.isNull); diff --git a/src/routes/projects/create.js b/src/routes/projects/create.js index 2600203b..61a9e299 100644 --- a/src/routes/projects/create.js +++ b/src/routes/projects/create.js @@ -7,7 +7,8 @@ import config from 'config'; import moment from 'moment'; import models from '../../models'; -import { PROJECT_MEMBER_ROLE, PROJECT_STATUS, PROJECT_PHASE_STATUS, USER_ROLE, EVENT, REGEX } from '../../constants'; +import { PROJECT_MEMBER_ROLE, MANAGER_ROLES, PROJECT_STATUS, PROJECT_PHASE_STATUS, + EVENT, REGEX } from '../../constants'; import fieldLookupValidation from '../../middlewares/fieldLookupValidation'; import util from '../../util'; import directProject from '../../services/directProject'; @@ -197,7 +198,7 @@ module.exports = [ (req, res, next) => { const project = req.body.param; // by default connect admin and managers joins projects as manager - const userRole = util.hasRoles(req, [USER_ROLE.CONNECT_ADMIN, USER_ROLE.MANAGER]) + const userRole = util.hasRoles(req, MANAGER_ROLES) ? PROJECT_MEMBER_ROLE.MANAGER : PROJECT_MEMBER_ROLE.CUSTOMER; // set defaults diff --git a/src/routes/projects/get.js b/src/routes/projects/get.js index a6f8cabe..8942237d 100644 --- a/src/routes/projects/get.js +++ b/src/routes/projects/get.js @@ -64,7 +64,7 @@ module.exports = [ if (attachments) { project.attachments = attachments; } - return models.ProjectMemberInvite.getPendingInvitesForProject(projectId); + return models.ProjectMemberInvite.getPendingAndReguestedInvitesForProject(projectId); }) .then((invites) => { project.invites = invites; diff --git a/src/routes/projects/list-db.js b/src/routes/projects/list-db.js index a8ed05d2..3e10736f 100644 --- a/src/routes/projects/list-db.js +++ b/src/routes/projects/list-db.js @@ -1,7 +1,7 @@ import _ from 'lodash'; import Promise from 'bluebird'; import models from '../../models'; -import { USER_ROLE } from '../../constants'; +import { USER_ROLE, MANAGER_ROLES } from '../../constants'; import util from '../../util'; /** @@ -125,7 +125,7 @@ module.exports = [ if (!memberOnly && (util.hasAdminRole(req) - || util.hasRole(req, USER_ROLE.MANAGER))) { + || util.hasRoles(req, MANAGER_ROLES))) { // admins & topcoder managers can see all projects return retrieveProjects(req, criteria, sort, req.query.fields) .then(result => res.json(util.wrapResponse(req.id, result.rows, result.count))) diff --git a/src/routes/projects/list.js b/src/routes/projects/list.js index 1e490f57..d9be9f66 100755 --- a/src/routes/projects/list.js +++ b/src/routes/projects/list.js @@ -5,7 +5,7 @@ import _ from 'lodash'; import config from 'config'; import models from '../../models'; -import { USER_ROLE } from '../../constants'; +import { USER_ROLE, MANAGER_ROLES } from '../../constants'; import util from '../../util'; const ES_PROJECT_INDEX = config.get('elasticsearchConfig.indexName'); @@ -102,6 +102,87 @@ const buildEsFullTextQuery = (keyword, matchType, singleFieldName) => { }; }; +/** + * Build ES query search request body based on value, keyword, matchType and fieldName + * + * @param {String} value the value to build request body for + * @param {String} keyword the keyword to query + * @param {String} matchType wildcard match or exact match + * @param {Array} fieldName the fieldName + * @return {Object} search request body that can be passed to .search api call + */ +const buildEsQueryWithFilter = (value, keyword, matchType, fieldName) => { + let should = []; + if (value !== 'details' && value !== 'customer' && value !== 'manager') { + should = _.concat(should, { + query_string: { + query: keyword, + analyze_wildcard: (matchType === MATCH_TYPE_WILDCARD), + fields: fieldName, + }, + }); + } + + if (value === 'details') { + should = _.concat(should, { + nested: { + path: 'details', + query: { + nested: { + path: 'details.utm', + query: { + query_string: { + query: keyword, + analyze_wildcard: (matchType === MATCH_TYPE_WILDCARD), + fields: fieldName, + }, + }, + }, + }, + }, + }); + } + + if (value === 'customer' || value === 'manager') { + should = _.concat(should, { + nested: { + path: 'members', + query: { + bool: { + must: [ + { match: { 'members.role': value } }, + { + query_string: { + query: keyword, + analyze_wildcard: (matchType === MATCH_TYPE_WILDCARD), + fields: fieldName, + }, + }, + ], + }, + }, + }, + }); + } + + return should; +}; + +/** + * Prepare search request body based on wildcard query + * + * @param {String} value the value to build request body for + * @param {String} keyword the keyword to query + * @param {Array} fieldName the fieldName + * @return {Object} search request body that can be passed to .search api call + */ +const setFilter = (value, keyword, fieldName) => { + if (keyword.indexOf('*') > -1) { + return buildEsQueryWithFilter(value, keyword, MATCH_TYPE_WILDCARD, fieldName); + } + return buildEsQueryWithFilter(value, keyword, MATCH_TYPE_EXACT_PHRASE, fieldName); +}; + /** * Parse the ES search criteria and prepare search request body * @@ -152,6 +233,7 @@ const parseElasticSearchCriteria = (criteria, fields, order) => { } // prepare the elasticsearch filter criteria const boolQuery = []; + let mustQuery = []; let fullTextQuery; if (_.has(criteria, 'filters.id.$in')) { boolQuery.push({ @@ -159,6 +241,32 @@ const parseElasticSearchCriteria = (criteria, fields, order) => { values: criteria.filters.id.$in, }, }); + } else if (_.has(criteria, 'filters.id')) { + boolQuery.push({ + term: { + id: criteria.filters.id, + }, + }); + } + + if (_.has(criteria, 'filters.name')) { + mustQuery = _.concat(mustQuery, setFilter('name', criteria.filters.name, ['name'])); + } + + if (_.has(criteria, 'filters.code')) { + mustQuery = _.concat(mustQuery, setFilter('details', criteria.filters.code, ['details.utm.code'])); + } + + if (_.has(criteria, 'filters.customer')) { + mustQuery = _.concat(mustQuery, setFilter('customer', + criteria.filters.customer, + ['members.firstName', 'members.lastName'])); + } + + if (_.has(criteria, 'filters.manager')) { + mustQuery = _.concat(mustQuery, setFilter('manager', + criteria.filters.manager, + ['members.firstName', 'members.lastName'])); } if (_.has(criteria, 'filters.status.$in')) { @@ -222,7 +330,7 @@ const parseElasticSearchCriteria = (criteria, fields, order) => { if (!keyword) { // Not a specific field search nor an exact phrase search, do a wildcard match - keyword = escapeEsKeyword(criteria.filters.keyword); + keyword = criteria.filters.keyword; matchType = MATCH_TYPE_WILDCARD; } @@ -234,6 +342,12 @@ const parseElasticSearchCriteria = (criteria, fields, order) => { filter: boolQuery, }; } + + if (mustQuery.length > 0) { + body.query.bool = _.merge(body.query.bool, { + must: mustQuery, + }); + } if (fullTextQuery) { body.query = _.merge(body.query, fullTextQuery); if (body.query.bool) { @@ -241,10 +355,9 @@ const parseElasticSearchCriteria = (criteria, fields, order) => { } } - if (fullTextQuery || boolQuery.length > 0) { + if (fullTextQuery || boolQuery.length > 0 || mustQuery.length > 0) { searchCriteria.body = body; } - return searchCriteria; }; @@ -267,8 +380,7 @@ const retrieveProjects = (req, criteria, sort, ffields) => { fields.projects.push('id'); } - const searchCriteria = parseElasticSearchCriteria(criteria, fields, order); - + const searchCriteria = parseElasticSearchCriteria(criteria, fields, order) || {}; return new Promise((accept, reject) => { const es = util.getElasticSearchClient(); es.search(searchCriteria).then((docs) => { @@ -300,7 +412,8 @@ module.exports = [ 'name', 'name asc', 'name desc', 'type', 'type asc', 'type desc', ]; - if (!util.isValidFilter(filters, ['id', 'status', 'type', 'memberOnly', 'keyword']) || + if (!util.isValidFilter(filters, + ['id', 'status', 'memberOnly', 'keyword', 'type', 'name', 'code', 'customer', 'manager']) || (sort && _.indexOf(sortableProps, sort) < 0)) { return util.handleError('Invalid filters or sort', null, req, next); } @@ -317,7 +430,7 @@ module.exports = [ if (!memberOnly && (util.hasAdminRole(req) - || util.hasRole(req, USER_ROLE.MANAGER))) { + || util.hasRoles(req, MANAGER_ROLES))) { // admins & topcoder managers can see all projects return retrieveProjects(req, criteria, sort, req.query.fields) .then(result => res.json(util.wrapResponse(req.id, result.rows, result.count))) diff --git a/src/routes/projects/list.spec.js b/src/routes/projects/list.spec.js index ef1bb7c6..dfecec90 100644 --- a/src/routes/projects/list.spec.js +++ b/src/routes/projects/list.spec.js @@ -37,6 +37,8 @@ const data = [ userId: 40051331, projectId: 1, role: 'customer', + firstName: 'Firstname', + lastName: 'Lastname', handle: 'test_tourist_handle', isPrimary: true, createdBy: 1, @@ -101,6 +103,19 @@ const data = [ updatedBy: 1, lastActivityAt: 3, lastActivityUserId: '1', + members: [{ + id: 5, + userId: 40051334, + projectId: 2, + role: 'manager', + firstName: 'first', + lastName: 'last', + handle: 'manager_handle', + isPrimary: true, + createdBy: 1, + updatedBy: 1, + }, + ], }, ]; @@ -193,7 +208,18 @@ describe('LIST Project', () => { lastActivityUserId: '1', }).then((p) => { project3 = p; - return Promise.resolve(); + // create members + return models.ProjectMember.create({ + userId: 40051334, + projectId: project3.id, + role: 'manager', + firstName: 'first', + lastName: 'last', + handle: 'manager_handle', + isPrimary: true, + createdBy: 1, + updatedBy: 1, + }); }); return Promise.all([p1, p2, p3]).then(() => { @@ -487,6 +513,160 @@ describe('LIST Project', () => { }); }); + it('should return project that match when filtering by id (exact)', (done) => { + request(server) + .get('/v4/projects/?filter=id%3D1') + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .expect('Content-Type', /json/) + .expect(200) + .end((err, res) => { + if (err) { + done(err); + } else { + const resJson = res.body.result.content; + should.exist(resJson); + resJson.should.have.lengthOf(1); + resJson[0].id.should.equal(1); + resJson[0].name.should.equal('test1'); + done(); + } + }); + }); + + it('should return project that match when filtering by name', (done) => { + request(server) + .get('/v4/projects/?filter=name%3Dtest1') + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .expect('Content-Type', /json/) + .expect(200) + .end((err, res) => { + if (err) { + done(err); + } else { + const resJson = res.body.result.content; + should.exist(resJson); + resJson.should.have.lengthOf(1); + resJson[0].name.should.equal('test1'); + done(); + } + }); + }); + + it('should return project that match when filtering by name\'s substring', (done) => { + request(server) + .get('/v4/projects/?filter=name%3D*st1') + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .expect('Content-Type', /json/) + .expect(200) + .end((err, res) => { + if (err) { + done(err); + } else { + const resJson = res.body.result.content; + should.exist(resJson); + resJson.should.have.lengthOf(1); + resJson[0].name.should.equal('test1'); + done(); + } + }); + }); + + it('should return all projects that match when filtering by details code', (done) => { + request(server) + .get('/v4/projects/?filter=code%3Dcode1') + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .expect('Content-Type', /json/) + .expect(200) + .end((err, res) => { + if (err) { + done(err); + } else { + const resJson = res.body.result.content; + should.exist(resJson); + resJson.should.have.lengthOf(1); + resJson[0].name.should.equal('test1'); + resJson[0].details.utm.code.should.equal('code1'); + done(); + } + }); + }); + + it('should return all projects that match when filtering by details code\'s substring', (done) => { + request(server) + .get('/v4/projects/?filter=code%3D*de1') + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .expect('Content-Type', /json/) + .expect(200) + .end((err, res) => { + if (err) { + done(err); + } else { + const resJson = res.body.result.content; + should.exist(resJson); + resJson.should.have.lengthOf(1); + resJson[0].name.should.equal('test1'); + resJson[0].details.utm.code.should.equal('code1'); + done(); + } + }); + }); + + it('should return all projects that match when filtering by customer', (done) => { + request(server) + .get('/v4/projects/?filter=customer%3Dfirst*') + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .expect('Content-Type', /json/) + .expect(200) + .end((err, res) => { + if (err) { + done(err); + } else { + const resJson = res.body.result.content; + should.exist(resJson); + resJson.should.have.lengthOf(1); + resJson[0].name.should.equal('test1'); + resJson[0].members.should.have.deep.property('[0].role', 'customer'); + resJson[0].members[0].userId.should.equal(40051331); + done(); + } + }); + }); + + it('should return all projects that match when filtering by manager', (done) => { + request(server) + .get('/v4/projects/?filter=manager%3D*ast') + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .expect('Content-Type', /json/) + .expect(200) + .end((err, res) => { + if (err) { + done(err); + } else { + const resJson = res.body.result.content; + should.exist(resJson); + resJson.should.have.lengthOf(1); + resJson[0].name.should.equal('test3'); + resJson[0].members.should.have.deep.property('[0].role', 'manager'); + resJson[0].members[0].userId.should.equal(40051334); + done(); + } + }); + }); + it('should return list of projects ordered ascending by lastActivityAt when sort column is "lastActivityAt"', (done) => { request(server) .get('/v4/projects/?sort=lastActivityAt') diff --git a/src/util.js b/src/util.js index 62ef15d6..5231249b 100644 --- a/src/util.js +++ b/src/util.js @@ -180,7 +180,7 @@ _.assignIn(util, { } return val; }); - if (queryFilter.id) { + if (queryFilter.id && queryFilter.id.$in) { queryFilter.id.$in = _.map(queryFilter.id.$in, _.parseInt); } return queryFilter; diff --git a/swagger.yaml b/swagger.yaml old mode 100755 new mode 100644 index 2d7e7c8b..fcd02b60 --- a/swagger.yaml +++ b/swagger.yaml @@ -55,6 +55,10 @@ paths: - type - memberOnly - keyword + - name + - code + - customer + - manager - name: sort required: false description: | @@ -4067,6 +4071,16 @@ definitions: description: READ-ONLY. User that last updated this task readOnly: true + ProjectMemberInviteSuccessAndFailure: + type: object + properties: + success: + $ref: "#/definitions/ProjectMemberInvite" + failed: + type: array + items: + type: object + AddProjectMemberInvitesRequest: title: Add project member invites request object type: object @@ -4129,4 +4143,4 @@ definitions: metadata: $ref: "#/definitions/ResponseMetadata" content: - $ref: "#/definitions/ProjectMemberInvite" + $ref: "#/definitions/ProjectMemberInviteSuccessAndFailure"