diff --git a/docs/Project API.postman_collection.json b/docs/Project API.postman_collection.json index 2fa603b5..d86f73b0 100644 --- a/docs/Project API.postman_collection.json +++ b/docs/Project API.postman_collection.json @@ -1,6 +1,6 @@ { "info": { - "_postman_id": "52f34e21-5b0b-4eb0-99fa-cbd1ac7f215a", + "_postman_id": "6418ac6e-a797-4e30-b4d3-a1dd0cdead22", "name": "Project API", "schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json" }, @@ -4834,7 +4834,7 @@ "exec": [ "pm.test(\"Status code is 201\", function () {", " pm.response.to.have.status(201);", - " pm.environment.set(\"phaseId\", pm.response.json().id);", + " pm.environment.set(\"phaseId-2\", pm.response.json().id);", "});" ], "type": "text/javascript" @@ -4880,7 +4880,7 @@ "exec": [ "pm.test(\"Status code is 201\", function () {", " pm.response.to.have.status(201);", - " pm.environment.set(\"phaseId\", pm.response.json().id);", + " pm.environment.set(\"phaseId-3\", pm.response.json().id);", "});" ], "type": "text/javascript" @@ -4901,7 +4901,7 @@ ], "body": { "mode": "raw", - "raw": "{\n\t\"name\": \"test project phase\",\n\t\"status\": \"active\",\n\t\"startDate\": \"2018-05-15T00:00:00\",\n\t\"endDate\": \"2018-05-16T00:00:00\",\n\t\"budget\": 20,\n\t\"details\": {\n\t\t\"aDetails\": \"a details\"\n\t},\n\t\"order\": 1,\n\t\"productTemplateId\": {{productTemplateId}}\n}" + "raw": "{\n\t\"name\": \"test project phase\",\n\t\"status\": \"active\",\n\t\"startDate\": \"2018-05-15T00:00:00\",\n\t\"endDate\": \"2018-05-16T00:00:00\",\n\t\"budget\": 20,\n\t\"details\": {\n\t\t\"aDetails\": \"a details\"\n\t},\n\t\"order\": 1,\n\t\"productTemplateId\": 2\n}" }, "url": { "raw": "{{api-url}}/projects/{{projectId}}/phases", @@ -4926,7 +4926,7 @@ "exec": [ "pm.test(\"Status code is 201\", function () {", " pm.response.to.have.status(201);", - " pm.environment.set(\"phaseId\", pm.response.json().id);", + " pm.environment.set(\"phaseId-4\", pm.response.json().id);", "});" ], "type": "text/javascript" @@ -5355,6 +5355,38 @@ } }, "response": [] + }, + { + "name": "Bulk Delete Phase", + "request": { + "method": "DELETE", + "header": [ + { + "key": "Authorization", + "value": "Bearer {{jwt-token}}" + }, + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\r\n \"phaseIds\": [\r\n {{phaseId-2}},\r\n {{phaseId-3}},\r\n {{phaseId-4}}\r\n ]\r\n}" + }, + "url": { + "raw": "{{api-url}}/projects/{{projectId}}/phases", + "host": [ + "{{api-url}}" + ], + "path": [ + "projects", + "{{projectId}}", + "phases" + ] + } + }, + "response": [] } ] }, diff --git a/src/routes/index.js b/src/routes/index.js index 9e142073..522527f6 100644 --- a/src/routes/index.js +++ b/src/routes/index.js @@ -167,7 +167,8 @@ router.route('/v5/projects/metadata/productTemplates/:templateId(\\d+)') router.route('/v5/projects/:projectId(\\d+)/phases') .get(require('./phases/list')) - .post(require('./phases/create')); + .post(require('./phases/create')) + .delete(require('./phases/bulkDelete')); router.route('/v5/projects/:projectId(\\d+)/phases/:phaseId(\\d+)') .get(require('./phases/get')) diff --git a/src/routes/phaseMembers/update.js b/src/routes/phaseMembers/update.js index 26f75077..92eb2e4f 100644 --- a/src/routes/phaseMembers/update.js +++ b/src/routes/phaseMembers/update.js @@ -51,7 +51,7 @@ module.exports = [ req.log.debug('updated phase members', JSON.stringify(newPhaseMembers, null, 2)); const updatedPhase = _.cloneDeep(phase); // emit event - if (_.intersectionBy(phaseMembers, updatedPhaseMembers, 'id').length !== updatedPhaseMembers.length) { + if (!_.isEqual(_.sortBy(phaseMembers, 'id'), _.sortBy(updatedPhaseMembers, 'id'))) { util.sendResourceToKafkaBus( req, EVENT.ROUTING_KEY.PROJECT_PHASE_UPDATED, diff --git a/src/routes/phases/bulkDelete.js b/src/routes/phases/bulkDelete.js new file mode 100644 index 00000000..e02ce1c3 --- /dev/null +++ b/src/routes/phases/bulkDelete.js @@ -0,0 +1,79 @@ +import validate from 'express-validation'; +import _ from 'lodash'; +import { middleware as tcMiddleware } from 'tc-core-library-js'; +import Joi from 'joi'; +import models from '../../models'; +import util from '../../util'; +import { EVENT, RESOURCES } from '../../constants'; + +const permissions = tcMiddleware.permissions; + +const bulkDeletePhaseValidation = { + body: Joi.object().keys({ + phaseIds: Joi.array().items(Joi.number().integer()).required(), + }).required(), +}; + +module.exports = [ + // validate request payload + validate(bulkDeletePhaseValidation), + // check permission + permissions('project.deleteProjectPhase'), + + (req, res, next) => { + const data = req.body; + const projectId = _.parseInt(req.params.projectId); + + models.sequelize.transaction(transaction => + // soft delete the record + models.ProjectPhase.findAll({ + where: { + id: data.phaseIds, + projectId, + deletedAt: { $eq: null }, + }, + raw: true, + transaction, + }).then((phases) => { + const notFoundPhases = _.differenceWith(data.phaseIds, phases, (a, b) => a === b.id); + if (!_.isEmpty(notFoundPhases)) { + // handle 404 + const err = new Error('no active project phase found for project id ' + + `${projectId} and phase ids ${notFoundPhases}`); + err.status = 404; + return Promise.reject(err); + } + return models.ProjectPhase.update({ deletedBy: req.authUser.userId }, { + where: { + id: data.phaseIds, + projectId, + }, + transaction, + }).then(() => + models.ProjectPhase.destroy({ + where: { + id: data.phaseIds, + projectId, + }, + transaction, + }), + ); + })) + .then((deletedCount) => { + const result = { + id: data.phaseIds, + projectId, + }; + req.log.debug('deleted project phases', JSON.stringify(result, null, 2)); + if (deletedCount > 0) { + util.sendResourceToKafkaBus( + req, + EVENT.ROUTING_KEY.PROJECT_PHASE_REMOVED, + RESOURCES.PHASE, + result, + ); + } + res.status(204).json({}); + }).catch(err => next(err)); + }, +]; diff --git a/src/routes/phases/bulkDelete.spec.js b/src/routes/phases/bulkDelete.spec.js new file mode 100644 index 00000000..670b19d8 --- /dev/null +++ b/src/routes/phases/bulkDelete.spec.js @@ -0,0 +1,284 @@ +/* eslint-disable no-unused-expressions */ +import _ from 'lodash'; +import request from 'supertest'; +import sinon from 'sinon'; +import chai from 'chai'; +import server from '../../app'; +import models from '../../models'; +import testUtil from '../../tests/util'; +import busApi from '../../services/busApi'; +import { + BUS_API_EVENT, + RESOURCES, + CONNECT_NOTIFICATION_EVENT, +} from '../../constants'; + +const should = chai.should(); // eslint-disable-line no-unused-vars + +const expectAfterDelete = (projectId, id, err, next) => { + if (err) throw err; + setTimeout(() => + models.ProjectPhase.findOne({ + where: { + id, + projectId, + }, + paranoid: false, + }) + .then((res) => { + if (!res) { + throw new Error('Should found the entity'); + } else { + chai.assert.isNotNull(res.deletedAt); + chai.assert.isNotNull(res.deletedBy); + } + next(); + }), 500); +}; +const body = { + name: 'test project phase', + status: 'active', + startDate: '2018-05-15T00:00:00Z', + endDate: '2018-05-15T12:00:00Z', + budget: 20.0, + progress: 1.23456, + details: { + message: 'This can be any json', + }, + createdBy: 1, + updatedBy: 1, +}; + +describe('Project Phases', () => { + let projectId; + let phaseId; + let projectName; + const memberUser = { + handle: testUtil.getDecodedToken(testUtil.jwts.member).handle, + userId: testUtil.getDecodedToken(testUtil.jwts.member).userId, + firstName: 'fname', + lastName: 'lName', + email: 'some@abc.com', + }; + const copilotUser = { + handle: testUtil.getDecodedToken(testUtil.jwts.copilot).handle, + userId: testUtil.getDecodedToken(testUtil.jwts.copilot).userId, + firstName: 'fname', + lastName: 'lName', + email: 'some@abc.com', + }; + const project = { + type: 'generic', + billingAccountId: 1, + name: 'test1', + description: 'test project1', + status: 'draft', + details: {}, + createdBy: 1, + updatedBy: 1, + lastActivityAt: 1, + lastActivityUserId: '1', + }; + beforeEach((done) => { + // mocks + testUtil.clearDb() + .then(() => { + models.Project.create(project).then((p) => { + projectId = p.id; + projectName = p.name; + // create members + models.ProjectMember.bulkCreate([{ + id: 1, + userId: copilotUser.userId, + projectId, + role: 'copilot', + isPrimary: false, + createdBy: 1, + updatedBy: 1, + }, { + id: 2, + userId: memberUser.userId, + projectId, + role: 'customer', + isPrimary: true, + createdBy: 1, + updatedBy: 1, + }]).then(() => { + _.assign(body, { projectId }); + models.ProjectPhase.create(body).then((phase) => { + phaseId = phase.id; + done(); + }); + }); + }); + }); + }); + + describe('DELETE /projects/{projectId}/phases', () => { + it('should return 403 if user does not have permissions (non team member)', (done) => { + request(server) + .delete(`/v5/projects/${projectId}/phases`) + .set({ + Authorization: `Bearer ${testUtil.jwts.member2}`, + }) + .send({ phaseIds: [phaseId] }) + .expect('Content-Type', /json/) + .expect(403, done); + }); + + it('should return 403 if user does not have permissions (customer)', (done) => { + request(server) + .delete(`/v5/projects/${projectId}/phases`) + .set({ + Authorization: `Bearer ${testUtil.jwts.member}`, + }) + .send({ phaseIds: [phaseId] }) + .expect('Content-Type', /json/) + .expect(403, done); + }); + + it('should return 404 when no project with specific projectId', (done) => { + request(server) + .delete('/v5/projects/999/phases') + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .send({ phaseIds: [phaseId] }) + .expect('Content-Type', /json/) + .expect(404, done); + }); + + it('should return 404 when no phase with specific phaseId', (done) => { + request(server) + .delete(`/v5/projects/${projectId}/phases`) + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .send({ phaseIds: [phaseId, 987] }) + .expect('Content-Type', /json/) + .expect(404, done); + }); + + it('should return 204 when user have project permission', (done) => { + request(server) + .delete(`/v5/projects/${projectId}/phases`) + .set({ + Authorization: `Bearer ${testUtil.jwts.copilot}`, + }) + .send({ phaseIds: [phaseId] }) + .expect(204) + .end(err => expectAfterDelete(projectId, phaseId, err, done)); + }); + + it('should return 204 if requested by admin', (done) => { + request(server) + .delete(`/v5/projects/${projectId}/phases`) + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .send({ phaseIds: [phaseId] }) + .expect(204) + .end(done); + }); + + it('should return 204 if requested by manager which is a member', (done) => { + models.ProjectMember.create({ + id: 3, + userId: testUtil.userIds.manager, + projectId, + role: 'manager', + isPrimary: false, + createdBy: 1, + updatedBy: 1, + }).then(() => { + request(server) + .delete(`/v5/projects/${projectId}/phases`) + .set({ + Authorization: `Bearer ${testUtil.jwts.manager}`, + }) + .send({ phaseIds: [phaseId] }) + .expect(204) + .end(done); + }); + }); + + it('should return 403 if requested by manager which is not a member', (done) => { + request(server) + .delete(`/v5/projects/${projectId}/phases`) + .set({ + Authorization: `Bearer ${testUtil.jwts.manager}`, + }) + .send({ phaseIds: [phaseId] }) + .expect(403) + .end(done); + }); + + it('should return 403 if requested by non-member copilot', (done) => { + models.ProjectMember.destroy({ + where: { userId: testUtil.userIds.copilot, projectId }, + }).then(() => { + request(server) + .delete(`/v5/projects/${projectId}/phases`) + .set({ + Authorization: `Bearer ${testUtil.jwts.copilot}`, + }) + .send({ phaseIds: [phaseId] }) + .expect(403) + .end(done); + }); + }); + + describe('Bus api', () => { + let createEventSpy; + const sandbox = sinon.sandbox.create(); + + before((done) => { + // Wait for 500ms in order to wait for createEvent calls from previous tests to complete + testUtil.wait(done); + }); + + beforeEach(() => { + createEventSpy = sandbox.spy(busApi, 'createEvent'); + }); + + afterEach(() => { + sandbox.restore(); + }); + + it('should send correct BUS API messages when phase removed', (done) => { + request(server) + .delete(`/v5/projects/${projectId}/phases`) + .set({ + Authorization: `Bearer ${testUtil.jwts.copilot}`, + }) + .send({ phaseIds: [phaseId] }) + .expect(204) + .end((err) => { + if (err) { + done(err); + } else { + testUtil.wait(() => { + createEventSpy.callCount.should.be.eql(2); + + createEventSpy.calledWith(BUS_API_EVENT.PROJECT_PHASE_DELETED, sinon.match({ + resource: RESOURCES.PHASE, + id: [phaseId], + })).should.be.true; + + // Check Notification Service events + createEventSpy.calledWith(CONNECT_NOTIFICATION_EVENT.PROJECT_PLAN_UPDATED, sinon.match({ + projectId, + projectName, + projectUrl: `https://local.topcoder-dev.com/projects/${projectId}`, + userId: 40051332, + initiatorUserId: 40051332, + })).should.be.true; + + done(); + }); + } + }); + }); + }); + }); +});