diff --git a/README.md b/README.md index c8356d59..e2790ac4 100644 --- a/README.md +++ b/README.md @@ -76,6 +76,8 @@ Microservice to manage CRUD operations for all things Projects. *NOTE: This will first clear all the indices and than recreate them. So use with caution.* * Run + + **NOTE** If you use `config/m2m.local.js` config, you should set M2M environment variables before running the next command. ```bash npm run start:dev ``` @@ -127,6 +129,9 @@ eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJyb2xlcyI6WyJhZG1pbmlzdHJhdG9yIl0sImlzcyI It's been signed with the secret 'secret'. This secret should match your entry in config/local.js. You can generate your own token using https://jwt.io ### Local Deployment + +**NOTE: This part of README may contain inconsistencies and requires update. Don't follow it unless you know how to properly make configuration for these steps. It's not needed for regular development process.** + Build image: `docker build -t tc_projects_services .` Run image: diff --git a/config/m2m.local.js b/config/m2m.local.js index 9aef91f2..8fb26130 100644 --- a/config/m2m.local.js +++ b/config/m2m.local.js @@ -5,7 +5,7 @@ if (process.env.NODE_ENV === 'test') { config = require('./test.json'); } else { config = { - identityServiceEndpoint: "https://api.topcoder-dev.com/", + identityServiceEndpoint: "https://api.topcoder-dev.com/v3/", authSecret: 'secret', authDomain: 'topcoder-dev.com', logLevel: 'debug', diff --git a/local/seed/index.js b/local/seed/index.js index c13edcce..a98fcc1c 100644 --- a/local/seed/index.js +++ b/local/seed/index.js @@ -10,4 +10,4 @@ async function seed() { await seedProjects(targetUrl, token); } -seed(); +seed().then(() => process.exit()); diff --git a/local/seed/projects.json b/local/seed/projects.json index 8d4fb65f..9fb184af 100644 --- a/local/seed/projects.json +++ b/local/seed/projects.json @@ -174,5 +174,91 @@ "status": "cancelled", "cancelReason": "Test cancel" } + }, + { + "param": { + "name": "Reviewed project with copilot invited", + "details": { + "utm": { + "code": "" + }, + "appDefinition": { + "primaryTarget": "phone", + "goal": { + "value": "Nothing" + }, + "users": { + "value": "No one" + }, + "notes": "" + }, + "hideDiscussions": true + }, + "description": "Hello this is a sample description... This requires at least 160 characters. I'm trying to satisfy this condition. But I could n't if I don't type this unnecessary message", + "templateId": 3, + "type": "website", + "status": "reviewed", + "invites": [{ + "param": { + "userIds": [40152855], + "role": "copilot" + }}] + } + }, + { + "param": { + "name": "Reviewed project with copilot as a member with copilot role", + "details": { + "utm": { + "code": "" + }, + "appDefinition": { + "primaryTarget": "phone", + "goal": { + "value": "Nothing" + }, + "users": { + "value": "No one" + }, + "notes": "" + }, + "hideDiscussions": true + }, + "description": "Hello this is a sample description... This requires at least 160 characters. I'm trying to satisfy this condition. But I could n't if I don't type this unnecessary message", + "templateId": 3, + "type": "website", + "status": "reviewed", + "invites": [{ + "param": { + "userIds": [40152855], + "role": "copilot" + }}], + "acceptInvitation": true + } + }, + { + "param": { + "name": "Reviewed project when copilot is not a member and not invited", + "details": { + "utm": { + "code": "" + }, + "appDefinition": { + "primaryTarget": "phone", + "goal": { + "value": "Nothing" + }, + "users": { + "value": "No one" + }, + "notes": "" + }, + "hideDiscussions": true + }, + "description": "Hello this is a sample description... This requires at least 160 characters. I'm trying to satisfy this condition. But I could n't if I don't type this unnecessary message", + "templateId": 3, + "type": "website", + "status": "reviewed" + } } ] diff --git a/local/seed/seedProjects.js b/local/seed/seedProjects.js index 36cc3d79..e1d50417 100644 --- a/local/seed/seedProjects.js +++ b/local/seed/seedProjects.js @@ -1,9 +1,13 @@ +import util from '../../src/tests/util'; + const axios = require('axios'); const Promise = require('bluebird'); const _ = require('lodash'); - const projects = require('./projects.json'); +// we make delay after requests which has to be indexed in ES asynchronous +const ES_INDEX_DELAY = 3000; + /** * Create projects and update their statuses. */ @@ -11,26 +15,74 @@ module.exports = (targetUrl, token) => { let projectPromises; const projectsUrl = `${targetUrl}projects`; - const headers = { + const adminHeaders = { 'Content-Type': 'application/json', Authorization: `Bearer ${token}`, }; + const connectAdminHeaders = { + 'Content-Type': 'application/json', + Authorization: `Bearer ${util.jwts.connectAdmin}`, + }; + console.log('Creating projects'); projectPromises = projects.map((project, i) => { const status = _.get(project, 'param.status'); const cancelReason = _.get(project, 'param.cancelReason'); + const invites = _.cloneDeep(_.get(project, 'param.invites')); + const acceptInvitation = _.get(project, 'param.acceptInvitation'); + delete project.param.status; delete project.param.cancelReason; + delete project.param.invites; + delete project.param.acceptInvitation; return axios - .post(projectsUrl, project, { headers }) + .post(projectsUrl, project, { headers: adminHeaders }) .catch((err) => { console.log(`Failed to create project ${i}: ${err.message}`); }) - .then((response) => { + .then(async (response) => { const projectId = _.get(response, 'data.result.content.id'); + // updating status + if (status !== _.get(response, 'data.result.content.status')) { + console.log(`Project #${projectId}: Wait a bit to give time ES to index before updating status...`); + await Promise.delay(ES_INDEX_DELAY); + await updateProjectStatus(projectId, { status, cancelReason }, targetUrl, adminHeaders).catch((ex) => { + console.error(`Project #${projectId}: Failed to update project status: ${ex.message}`); + }); + } + + // creating invitations + if (Array.isArray(invites)) { + let promises = [] + invites.forEach(invite => { + promises.push(createProjectMemberInvite(projectId, invite, targetUrl, connectAdminHeaders)) + }) + + // accepting invitations + console.log(`Project #${projectId}: Wait a bit to give time ES to index before creating invitation...`); + await Promise.delay(ES_INDEX_DELAY); + const responses = await Promise.all(promises) + if (acceptInvitation) { + let acceptInvitationPromises = [] + responses.forEach(response => { + const userId = _.get(response, 'data.result.content.success[0].userId') + acceptInvitationPromises.push(updateProjectMemberInvite(projectId, { + param: { + userId, + status: 'accepted' + } + }, targetUrl, connectAdminHeaders)) + }) + + console.log(`Project #${projectId}: Wait a bit to give time ES to index before accepting invitation...`); + await Promise.delay(ES_INDEX_DELAY); + await Promise.all(acceptInvitationPromises) + } + } + return { projectId, status, @@ -40,16 +92,6 @@ module.exports = (targetUrl, token) => { }); return Promise.all(projectPromises) - .then((createdProjects) => { - console.log('Updating statuses'); - return Promise.all( - createdProjects.map(({ projectId, status, cancelReason }) => - updateProjectStatus(projectId, { status, cancelReason }, targetUrl, headers).catch((ex) => { - console.log(`Failed to update project status of project with id ${projectId}: ${ex.message}`); - }), - ), - ); - }) .then(() => console.log('Done project seed.')) .catch(ex => console.error(ex)); }; @@ -72,3 +114,24 @@ function updateProjectStatus(project, updateParams, targetUrl, headers) { }, ); } + +function createProjectMemberInvite(projectId, params, targetUrl, headers) { + const projectMemberInviteUrl = `${targetUrl}projects/${projectId}/members/invite`; + + return axios + .post(projectMemberInviteUrl, params, { headers }) + .catch((err) => { + console.log(`Failed to create project member invites ${projectId}: ${err.message}`); + }) +} + +function updateProjectMemberInvite(projectId, params, targetUrl, headers) { + const updateProjectMemberInviteUrl = `${targetUrl}projects/${projectId}/members/invite`; + + return axios + .put(updateProjectMemberInviteUrl, params, { headers }) + .catch((err) => { + console.log(`Failed to update project member invites ${projectId}: ${err.message}`); + }) +} + diff --git a/src/models/project.js b/src/models/project.js index 5339b5b4..946289f2 100644 --- a/src/models/project.js +++ b/src/models/project.js @@ -1,7 +1,7 @@ /* eslint-disable valid-jsdoc */ import _ from 'lodash'; -import { PROJECT_STATUS, PROJECT_MEMBER_ROLE } from '../constants'; +import { PROJECT_STATUS } from '../constants'; module.exports = function defineProject(sequelize, DataTypes) { const Project = sequelize.define('Project', { @@ -61,28 +61,6 @@ module.exports = function defineProject(sequelize, DataTypes) { { fields: ['directProjectId'] }, ], classMethods: { - /* - * @Co-pilots should be able to view projects any of the following conditions are met: - * a. they are registered active project members on the project - * b. any project that is in 'reviewed' state AND does not yet have a co-pilot assigned - * @param userId the id of user - */ - getProjectIdsForCopilot(userId) { - return this.findAll({ - where: { - $or: [ - ['EXISTS(SELECT * FROM "project_members" WHERE "deletedAt" ' + - 'IS NULL AND "projectId" = "Project".id AND "userId" = ? )', userId], - ['"Project".status=? AND NOT EXISTS(SELECT * FROM "project_members" WHERE ' + - ' "deletedAt" IS NULL AND "projectId" = "Project".id AND "role" = ? )', - PROJECT_STATUS.REVIEWED, PROJECT_MEMBER_ROLE.COPILOT], - ], - }, - attributes: ['id'], - raw: true, - }) - .then(res => _.map(res, 'id')); - }, /** * Get direct project id * @param id the id of project diff --git a/src/permissions/project.view.js b/src/permissions/project.view.js index 61e4ebed..e14049ea 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, MANAGER_ROLES } from '../constants'; +import { MANAGER_ROLES } from '../constants'; /** * Super admin, Topcoder Managers are allowed to view any projects @@ -24,48 +24,11 @@ module.exports = freq => new Promise((resolve, reject) => { || 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 - // user can access the project - if (!hasAccess && util.hasRole(req, USER_ROLE.COPILOT)) { - return models.Project.getProjectIdsForCopilot(currentUserId) - .then((ids) => { - req.context.accessibleProjectIds = ids; - return Promise.resolve(_.indexOf(ids, projectId) > -1); - }); - } return Promise.resolve(hasAccess); }) .then((hasAccess) => { if (!hasAccess) { - let errorMessage = 'You do not have permissions to perform this action'; - // customize error message for copilots - if (util.hasRole(freq, USER_ROLE.COPILOT)) { - if (_.findIndex(freq.context.currentProjectMembers, m => m.role === PROJECT_MEMBER_ROLE.COPILOT) >= 0) { - errorMessage = 'Copilot: Project is already claimed by another copilot'; - return Promise.resolve(errorMessage); - } - return models.Project - .find({ - where: { id: projectId }, - attributes: ['status'], - raw: true, - }) - .then((project) => { - if (!project || [PROJECT_STATUS.DRAFT, PROJECT_STATUS.IN_REVIEW].indexOf(project.status) >= 0) { - errorMessage = 'Copilot: Project is not yet available to copilots'; - } else { - // project status is 'active' or higher so it's not available to copilots - errorMessage = 'Copilot: Project has already started'; - } - return Promise.resolve(errorMessage); - }); - } - // user is not an admin nor is a registered project member - return Promise.resolve(errorMessage); - } - return Promise.resolve(null); - }).then((errorMessage) => { - if (errorMessage) { + const errorMessage = 'You do not have permissions to perform this action'; // user is not an admin nor is a registered project member return reject(new Error(errorMessage)); } diff --git a/src/routes/projectMemberInvites/create.spec.js b/src/routes/projectMemberInvites/create.spec.js index 74f6be9c..e9701f99 100644 --- a/src/routes/projectMemberInvites/create.spec.js +++ b/src/routes/projectMemberInvites/create.spec.js @@ -65,18 +65,27 @@ describe('Project Member Invite create', () => { lastActivityUserId: '1', }).then((p2) => { project2 = p2; - models.ProjectMemberInvite.create({ - projectId: project1.id, - userId: 40051335, - email: null, - role: PROJECT_MEMBER_ROLE.MANAGER, - status: INVITE_STATUS.PENDING, + models.ProjectMember.create({ + userId: 40051332, + projectId: project2.id, + role: 'copilot', + isPrimary: true, createdBy: 1, updatedBy: 1, - createdAt: '2016-06-30 00:33:07+00', - updatedAt: '2016-06-30 00:33:07+00', }).then(() => { - done(); + models.ProjectMemberInvite.create({ + projectId: project1.id, + userId: 40051335, + 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(() => { + done(); + }); }); })); }); diff --git a/src/routes/projectMembers/create.spec.js b/src/routes/projectMembers/create.spec.js index f55ad90a..f6c85633 100644 --- a/src/routes/projectMembers/create.spec.js +++ b/src/routes/projectMembers/create.spec.js @@ -60,7 +60,7 @@ describe('Project Members create', () => { .expect(403, done); }); - it('should return 201 and then 400 if user is already registered', (done) => { + it('should return 201 when invited then accepted and then 404 if user is already as a member', (done) => { const mockHttpClient = _.merge(testUtil.mockHttpClient, { get: () => Promise.resolve({ status: 200, @@ -79,9 +79,15 @@ describe('Project Members create', () => { }); sandbox.stub(util, 'getHttpClient', () => mockHttpClient); request(server) - .post(`/v4/projects/${project1.id}/members/`) + .post(`/v4/projects/${project1.id}/members/invite`) .set({ - Authorization: `Bearer ${testUtil.jwts.copilot}`, + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .send({ + param: { + userIds: [40051332], + role: 'copilot', + }, }) .expect('Content-Type', /json/) .expect(201) @@ -89,26 +95,59 @@ describe('Project Members create', () => { if (err) { done(err); } else { - const resJson = res.body.result.content; + const resJson = res.body.result.content.success[0]; should.exist(resJson); resJson.role.should.equal('copilot'); resJson.projectId.should.equal(project1.id); resJson.userId.should.equal(40051332); - server.services.pubsub.publish.calledWith('project.member.added').should.be.true; + server.services.pubsub.publish.calledWith('project.member.invite.created').should.be.true; request(server) - .post(`/v4/projects/${project1.id}/members/`) + .put(`/v4/projects/${project1.id}/members/invite`) .set({ - Authorization: `Bearer ${testUtil.jwts.copilot}`, + Authorization: `Bearer ${testUtil.jwts.connectAdmin}`, + }) + .send({ + param: { + userId: 40051332, + status: 'accepted', + }, }) .expect('Content-Type', /json/) - .expect(400) + .expect(200) .end((err2, res2) => { if (err2) { - done(err); + done(err2); } else { - res2.body.result.status.should.equal(400); - done(); + const resJson2 = res2.body.result.content; + should.exist(resJson2); + resJson2.role.should.equal('copilot'); + resJson2.projectId.should.equal(project1.id); + resJson2.userId.should.equal(40051332); + server.services.pubsub.publish.calledWith('project.member.invite.updated').should.be.true; + server.services.pubsub.publish.calledWith('project.member.added').should.be.true; + + request(server) + .put(`/v4/projects/${project1.id}/members/invite`) + .set({ + Authorization: `Bearer ${testUtil.jwts.connectAdmin}`, + }) + .send({ + param: { + userId: 40051332, + status: 'accepted', + }, + }) + .expect('Content-Type', /json/) + .expect(404) + .end((err3, res3) => { + if (err3) { + done(err3); + } else { + res3.body.result.status.should.equal(404); + done(); + } + }); } }); } @@ -238,28 +277,53 @@ describe('Project Members create', () => { it('sends single BUS_API_EVENT.PROJECT_TEAM_UPDATED message when copilot added', (done) => { request(server) - .post(`/v4/projects/${project1.id}/members/`) + .post(`/v4/projects/${project1.id}/members/invite`) .set({ - Authorization: `Bearer ${testUtil.jwts.copilot}`, + Authorization: `Bearer ${testUtil.jwts.admin}`, }) .send({ + param: { + userIds: [40051332], + role: 'copilot', + }, }) .expect(201) .end((err) => { if (err) { done(err); } else { - testUtil.wait(() => { - createEventSpy.calledTwice.should.be.true; - createEventSpy.firstCall.calledWith(BUS_API_EVENT.MEMBER_JOINED_COPILOT); - createEventSpy.secondCall.calledWith(BUS_API_EVENT.PROJECT_TEAM_UPDATED, sinon.match({ - projectId: project1.id, - projectName: project1.name, - projectUrl: `https://local.topcoder-dev.com/projects/${project1.id}`, + request(server) + .put(`/v4/projects/${project1.id}/members/invite`) + .set({ + Authorization: `Bearer ${testUtil.jwts.connectAdmin}`, + }) + .send({ + param: { userId: 40051332, - initiatorUserId: 40051332, - })).should.be.true; - done(); + status: 'accepted', + }, + }) + .expect('Content-Type', /json/) + .expect(200) + .end((err2) => { + if (err2) { + done(err2); + } else { + testUtil.wait(() => { + createEventSpy.callCount.should.equal(4); + createEventSpy.firstCall.calledWith(BUS_API_EVENT.PROJECT_MEMBER_INVITE_REQUESTED); + createEventSpy.secondCall.calledWith(BUS_API_EVENT.PROJECT_MEMBER_INVITE_UPDATED); + createEventSpy.thirdCall.calledWith(BUS_API_EVENT.MEMBER_JOINED_COPILOT); + createEventSpy.lastCall.calledWith(BUS_API_EVENT.PROJECT_TEAM_UPDATED, sinon.match({ + projectId: project1.id, + projectName: project1.name, + projectUrl: `https://local.topcoder-dev.com/projects/${project1.id}`, + userId: 40051336, + initiatorUserId: 40051336, + })).should.be.true; + done(); + }); + } }); } }); diff --git a/src/routes/projects/list-db.js b/src/routes/projects/list-db.js index 3e10736f..c01b138f 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, MANAGER_ROLES } from '../../constants'; +import { MANAGER_ROLES } from '../../constants'; import util from '../../util'; /** @@ -131,13 +131,10 @@ module.exports = [ .then(result => res.json(util.wrapResponse(req.id, result.rows, result.count))) .catch(err => next(err)); } - // If user requested projects where he/she is a member or - // if they are not a copilot then return projects that they are members in. - // Copilots can view projects that they are members in or they have - // - const getProjectIds = !memberOnly && util.hasRole(req, USER_ROLE.COPILOT) ? - models.Project.getProjectIdsForCopilot(req.authUser.userId) : - models.ProjectMember.getProjectIdsForUser(req.authUser.userId); + + // regular users can only see projects they are members of (or invited, handled bellow) + const getProjectIds = models.ProjectMember.getProjectIdsForUser(req.authUser.userId); + return getProjectIds .then((accessibleProjectIds) => { let allowedProjectIds = accessibleProjectIds; diff --git a/src/routes/projects/list-db.spec.js b/src/routes/projects/list-db.spec.js index 08f4d14c..f7022e51 100644 --- a/src/routes/projects/list-db.spec.js +++ b/src/routes/projects/list-db.spec.js @@ -184,8 +184,7 @@ describe('LIST Project db', () => { }); }); - it('should return the project when project that is in reviewed state AND does not yet' + - 'have a co-pilot assigned', (done) => { + it('should return the project when project that is in reviewed state in which the copilot is its member or has been invited', (done) => { request(server) .get('/v4/projects/db/') .set({ @@ -198,9 +197,9 @@ describe('LIST Project db', () => { done(err); } else { const resJson = res.body.result.content; - res.body.result.metadata.totalCount.should.equal(3); + res.body.result.metadata.totalCount.should.equal(2); should.exist(resJson); - resJson.should.have.lengthOf(3); + resJson.should.have.lengthOf(2); done(); } }); diff --git a/src/routes/projects/list.js b/src/routes/projects/list.js index d9be9f66..73cd3f5d 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, MANAGER_ROLES } from '../../constants'; +import { MANAGER_ROLES } from '../../constants'; import util from '../../util'; const ES_PROJECT_INDEX = config.get('elasticsearchConfig.indexName'); @@ -436,13 +436,9 @@ module.exports = [ .then(result => res.json(util.wrapResponse(req.id, result.rows, result.count))) .catch(err => next(err)); } - // If user requested projects where he/she is a member or - // if they are not a copilot then return projects that they are members in. - // Copilots can view projects that they are members in or they have - // - const getProjectIds = !memberOnly && util.hasRole(req, USER_ROLE.COPILOT) ? - models.Project.getProjectIdsForCopilot(req.authUser.userId) : - models.ProjectMember.getProjectIdsForUser(req.authUser.userId); + + // regular users can only see projects they are members of (or invited, handled bellow) + const getProjectIds = models.ProjectMember.getProjectIdsForUser(req.authUser.userId); return getProjectIds .then((accessibleProjectIds) => { diff --git a/src/routes/projects/list.spec.js b/src/routes/projects/list.spec.js index dfecec90..3edc9f4c 100644 --- a/src/routes/projects/list.spec.js +++ b/src/routes/projects/list.spec.js @@ -303,8 +303,7 @@ describe('LIST Project', () => { }); }); - it('should return the project when project that is in reviewed state AND does not yet ' + - 'have a co-pilot assigned', (done) => { + it('should return the project when project that is in reviewed state in which the copilot is its member or has been invited', (done) => { request(server) .get('/v4/projects') .set({ @@ -317,9 +316,9 @@ describe('LIST Project', () => { done(err); } else { const resJson = res.body.result.content; - res.body.result.metadata.totalCount.should.equal(3); + res.body.result.metadata.totalCount.should.equal(2); should.exist(resJson); - resJson.should.have.lengthOf(3); + resJson.should.have.lengthOf(2); done(); } });