From d1c284d6b07b3338b0988464d0d0ceebec434785 Mon Sep 17 00:00:00 2001 From: ngoctay Date: Thu, 31 May 2018 10:49:31 +0700 Subject: [PATCH 1/5] #87 - Added /projectTypes endpoints (code, unit tests, Postman updates) --- postman.json | 288 +++++++++++++++++++++++-- src/models/project.js | 3 - src/models/projectType.js | 24 +++ src/permissions/index.js | 5 + src/routes/index.js | 31 ++- src/routes/projectTypes/create.js | 55 +++++ src/routes/projectTypes/create.spec.js | 163 ++++++++++++++ src/routes/projectTypes/delete.js | 54 +++++ src/routes/projectTypes/delete.spec.js | 99 +++++++++ src/routes/projectTypes/get.js | 39 ++++ src/routes/projectTypes/get.spec.js | 117 ++++++++++ src/routes/projectTypes/list.js | 20 ++ src/routes/projectTypes/list.spec.js | 106 +++++++++ src/routes/projectTypes/update.js | 61 ++++++ src/routes/projectTypes/update.spec.js | 145 +++++++++++++ src/routes/projects/create.js | 147 +++++++------ src/routes/projects/create.spec.js | 38 +++- src/routes/projects/update.js | 39 +++- src/routes/projects/update.spec.js | 59 +++-- src/tests/seed.js | 89 +++++++- 20 files changed, 1459 insertions(+), 123 deletions(-) create mode 100644 src/models/projectType.js create mode 100644 src/routes/projectTypes/create.js create mode 100644 src/routes/projectTypes/create.spec.js create mode 100644 src/routes/projectTypes/delete.js create mode 100644 src/routes/projectTypes/delete.spec.js create mode 100644 src/routes/projectTypes/get.js create mode 100644 src/routes/projectTypes/get.spec.js create mode 100644 src/routes/projectTypes/list.js create mode 100644 src/routes/projectTypes/list.spec.js create mode 100644 src/routes/projectTypes/update.js create mode 100644 src/routes/projectTypes/update.spec.js diff --git a/postman.json b/postman.json index 4447684b..5eb84b82 100644 --- a/postman.json +++ b/postman.json @@ -1,7 +1,7 @@ { "info": { - "_postman_id": "0d2b00c1-bd90-40ab-ba13-e730e4ddfcf4", - "name": "tc-project-service ", + "_postman_id": "1791b330-5331-4768-a265-f1cb5e6b4492", + "name": "tc-project-service", "schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json" }, "item": [ @@ -2348,7 +2348,7 @@ }, { "name": "Project Templates", - "description": "", + "description": null, "item": [ { "name": "Create project template", @@ -2368,7 +2368,16 @@ "mode": "raw", "raw": "{\r\n \"param\":{\r\n \"name\":\"new name\",\r\n \"key\":\"new key\",\r\n \"category\":\"new category\",\r\n \"scope\":{\r\n \"scope1\":\"scope 1\"\r\n },\r\n \"phases\":{\r\n \"phase1\":\"phase 1\"\r\n }\r\n }\r\n}" }, - "url": "{{api-url}}/v4/projectTemplates" + "url": { + "raw": "{{api-url}}/v4/projectTemplates", + "host": [ + "{{api-url}}" + ], + "path": [ + "v4", + "projectTemplates" + ] + } }, "response": [] }, @@ -2390,7 +2399,16 @@ "mode": "raw", "raw": "{\r\n \"param\":{\r\n \"name\":\"new name\",\r\n \"key\":\"new key\",\r\n \"category\":\"new category\",\r\n \"scope\":{\r\n \"scope1\":\"scope 1\"\r\n },\r\n \"phases\":{\r\n \"phase1\":\"phase 1\"\r\n }\r\n }\r\n}" }, - "url": "{{api-url}}/v4/projectTemplates" + "url": { + "raw": "{{api-url}}/v4/projectTemplates", + "host": [ + "{{api-url}}" + ], + "path": [ + "v4", + "projectTemplates" + ] + } }, "response": [] }, @@ -2412,7 +2430,17 @@ "mode": "raw", "raw": "{\r\n \"param\":{\r\n \"name\":\"new name\",\r\n \"key\":\"new key\",\r\n \"category\":\"new category\",\r\n \"scope\":{\r\n \"scope1\":\"scope 1\"\r\n },\r\n \"phases\":{\r\n \"phase1\":\"phase 1\"\r\n }\r\n }\r\n}" }, - "url": "{{api-url}}/v4/projectTemplates/1" + "url": { + "raw": "{{api-url}}/v4/projectTemplates/1", + "host": [ + "{{api-url}}" + ], + "path": [ + "v4", + "projectTemplates", + "1" + ] + } }, "response": [] }, @@ -2434,7 +2462,17 @@ "mode": "raw", "raw": "{\r\n \"param\":{\r\n \"name\":\"new name\",\r\n \"key\":\"new key\",\r\n \"category\":\"new category\",\r\n \"scope\":{\r\n \"scope1\":\"scope 1\",\r\n \"scope2\": [\"a\"]\r\n },\r\n \"phases\":{\r\n \"phase1\":\"phase 1\",\r\n \"phase2\": {\r\n \t\"another\": \"another\"\r\n }\r\n }\r\n }\r\n}" }, - "url": "{{api-url}}/v4/projectTemplates/1" + "url": { + "raw": "{{api-url}}/v4/projectTemplates/1", + "host": [ + "{{api-url}}" + ], + "path": [ + "v4", + "projectTemplates", + "1" + ] + } }, "response": [] }, @@ -2456,7 +2494,17 @@ "mode": "raw", "raw": "{\r\n \"param\":{\r\n \"name\":\"new name\",\r\n \"key\":\"new key\",\r\n \"category\":\"new category\",\r\n \"scope\":{\r\n \"scope1\":\"scope 1\",\r\n \"scope2\": [\"a\"]\r\n },\r\n \"phases\":{\r\n \"phase1\":\"phase 1\",\r\n \"phase2\": {\r\n \t\"another\": \"another\"\r\n }\r\n }\r\n }\r\n}" }, - "url": "{{api-url}}/v4/projectTemplates/1" + "url": { + "raw": "{{api-url}}/v4/projectTemplates/1", + "host": [ + "{{api-url}}" + ], + "path": [ + "v4", + "projectTemplates", + "1" + ] + } }, "response": [] } @@ -2464,7 +2512,7 @@ }, { "name": "Product Templates", - "description": "", + "description": null, "item": [ { "name": "Create product template", @@ -2484,7 +2532,16 @@ "mode": "raw", "raw": "{\r\n \"param\":{\r\n \"name\":\"new name\",\r\n \"productKey\":\"new productKey\",\r\n \"icon\":\"http://example.com/icon-new.ico\",\r\n \"brief\": \"new brief\",\r\n \"details\": \"new details\",\r\n \"aliases\":{\r\n \"alias1\":\"alias 1\"\r\n },\r\n \"template\":{\r\n \"template1\":\"template 1\"\r\n }\r\n }\r\n}" }, - "url": "{{api-url}}/v4/productTemplates" + "url": { + "raw": "{{api-url}}/v4/productTemplates", + "host": [ + "{{api-url}}" + ], + "path": [ + "v4", + "productTemplates" + ] + } }, "response": [] }, @@ -2506,7 +2563,16 @@ "mode": "raw", "raw": "{\r\n \"param\":{\r\n \"name\":\"new name\",\r\n \"key\":\"new key\",\r\n \"category\":\"new category\",\r\n \"scope\":{\r\n \"scope1\":\"scope 1\"\r\n },\r\n \"phases\":{\r\n \"phase1\":\"phase 1\"\r\n }\r\n }\r\n}" }, - "url": "{{api-url}}/v4/productTemplates" + "url": { + "raw": "{{api-url}}/v4/productTemplates", + "host": [ + "{{api-url}}" + ], + "path": [ + "v4", + "productTemplates" + ] + } }, "response": [] }, @@ -2528,7 +2594,17 @@ "mode": "raw", "raw": "{\r\n \"param\":{\r\n \"name\":\"new name\",\r\n \"key\":\"new key\",\r\n \"category\":\"new category\",\r\n \"scope\":{\r\n \"scope1\":\"scope 1\"\r\n },\r\n \"phases\":{\r\n \"phase1\":\"phase 1\"\r\n }\r\n }\r\n}" }, - "url": "{{api-url}}/v4/productTemplates/1" + "url": { + "raw": "{{api-url}}/v4/productTemplates/1", + "host": [ + "{{api-url}}" + ], + "path": [ + "v4", + "productTemplates", + "1" + ] + } }, "response": [] }, @@ -2550,7 +2626,17 @@ "mode": "raw", "raw": "{\r\n \"param\":{\r\n \"name\":\"new name\",\r\n \"productKey\":\"new productKey\",\r\n \"icon\":\"http://example.com/icon-new.ico\",\r\n \"brief\": \"new brief\",\r\n \"details\": \"new details\",\r\n \"aliases\":{\r\n \"alias1\":\"scope 1\",\r\n \"alias2\": [\"a\"]\r\n },\r\n \"template\":{\r\n \"template1\":\"template 1\",\r\n \"template2\": {\r\n \t\"another\": \"another\"\r\n }\r\n }\r\n }\r\n}" }, - "url": "{{api-url}}/v4/productTemplates/1" + "url": { + "raw": "{{api-url}}/v4/productTemplates/1", + "host": [ + "{{api-url}}" + ], + "path": [ + "v4", + "productTemplates", + "1" + ] + } }, "response": [] }, @@ -2572,7 +2658,181 @@ "mode": "raw", "raw": "{\r\n \"param\":{\r\n \"name\":\"new name\",\r\n \"key\":\"new key\",\r\n \"category\":\"new category\",\r\n \"scope\":{\r\n \"scope1\":\"scope 1\",\r\n \"scope2\": [\"a\"]\r\n },\r\n \"phases\":{\r\n \"phase1\":\"phase 1\",\r\n \"phase2\": {\r\n \t\"another\": \"another\"\r\n }\r\n }\r\n }\r\n}" }, - "url": "{{api-url}}/v4/productTemplates/1" + "url": { + "raw": "{{api-url}}/v4/productTemplates/1", + "host": [ + "{{api-url}}" + ], + "path": [ + "v4", + "productTemplates", + "1" + ] + } + }, + "response": [] + } + ] + }, + { + "name": "Project Type", + "description": null, + "item": [ + { + "name": "Create project type", + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + }, + { + "key": "Authorization", + "value": "Bearer {{jwt-token}}" + } + ], + "body": { + "mode": "raw", + "raw": "{\r\n \"param\":{\r\n \"key\": \"new key\",\r\n \"displayName\": \"new displayName\"\r\n }\r\n}" + }, + "url": { + "raw": "{{api-url}}/v4/projectTypes", + "host": [ + "{{api-url}}" + ], + "path": [ + "v4", + "projectTypes" + ] + } + }, + "response": [] + }, + { + "name": "List project types", + "request": { + "method": "GET", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + }, + { + "key": "Authorization", + "value": "Bearer {{jwt-token}}" + } + ], + "body": { + "mode": "raw", + "raw": "{\r\n \"param\":{\r\n \"name\":\"new name\",\r\n \"key\":\"new key\",\r\n \"category\":\"new category\",\r\n \"scope\":{\r\n \"scope1\":\"scope 1\"\r\n },\r\n \"phases\":{\r\n \"phase1\":\"phase 1\"\r\n }\r\n }\r\n}" + }, + "url": { + "raw": "{{api-url}}/v4/projectTypes", + "host": [ + "{{api-url}}" + ], + "path": [ + "v4", + "projectTypes" + ] + } + }, + "response": [] + }, + { + "name": "Get project type", + "request": { + "method": "GET", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + }, + { + "key": "Authorization", + "value": "Bearer {{jwt-token}}" + } + ], + "body": { + "mode": "raw", + "raw": "{\r\n \"param\":{\r\n \"name\":\"new name\",\r\n \"key\":\"new key\",\r\n \"category\":\"new category\",\r\n \"scope\":{\r\n \"scope1\":\"scope 1\"\r\n },\r\n \"phases\":{\r\n \"phase1\":\"phase 1\"\r\n }\r\n }\r\n}" + }, + "url": { + "raw": "{{api-url}}/v4/projectTypes/generic", + "host": [ + "{{api-url}}" + ], + "path": [ + "v4", + "projectTypes", + "generic" + ] + } + }, + "response": [] + }, + { + "name": "Update project type", + "request": { + "method": "PATCH", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + }, + { + "key": "Authorization", + "value": "Bearer {{jwt-token}}" + } + ], + "body": { + "mode": "raw", + "raw": "{\r\n \"param\":{\r\n \"displayName\": \"Chatbot-updated\"\r\n }\r\n}" + }, + "url": { + "raw": "{{api-url}}/v4/projectTypes/chatbot", + "host": [ + "{{api-url}}" + ], + "path": [ + "v4", + "projectTypes", + "chatbot" + ] + } + }, + "response": [] + }, + { + "name": "Delete project type", + "request": { + "method": "DELETE", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + }, + { + "key": "Authorization", + "value": "Bearer {{jwt-token}}" + } + ], + "body": { + "mode": "raw", + "raw": "{\r\n \"param\":{\r\n \"name\":\"new name\",\r\n \"key\":\"new key\",\r\n \"category\":\"new category\",\r\n \"scope\":{\r\n \"scope1\":\"scope 1\",\r\n \"scope2\": [\"a\"]\r\n },\r\n \"phases\":{\r\n \"phase1\":\"phase 1\",\r\n \"phase2\": {\r\n \t\"another\": \"another\"\r\n }\r\n }\r\n }\r\n}" + }, + "url": { + "raw": "{{api-url}}/v4/projectTypes/chatbot", + "host": [ + "{{api-url}}" + ], + "path": [ + "v4", + "projectTypes", + "chatbot" + ] + } }, "response": [] } diff --git a/src/models/project.js b/src/models/project.js index efdcca5f..27b3a8e6 100644 --- a/src/models/project.js +++ b/src/models/project.js @@ -23,9 +23,6 @@ module.exports = function defineProject(sequelize, DataTypes) { type: { type: DataTypes.STRING, allowNull: false, - validate: { - isIn: [_.values(PROJECT_TYPE)], - }, }, status: { type: DataTypes.STRING, diff --git a/src/models/projectType.js b/src/models/projectType.js new file mode 100644 index 00000000..ff8163ff --- /dev/null +++ b/src/models/projectType.js @@ -0,0 +1,24 @@ + + +module.exports = function definePhaseProduct(sequelize, DataTypes) { + const ProjectType = sequelize.define('ProjectType', { + key: { type: DataTypes.STRING(45), primaryKey: true }, + displayName: { type: DataTypes.STRING(255), allowNull: false }, + + deletedAt: { type: DataTypes.DATE, allowNull: true }, + createdAt: { type: DataTypes.DATE, defaultValue: DataTypes.NOW }, + updatedAt: { type: DataTypes.DATE, defaultValue: DataTypes.NOW }, + deletedBy: { type: DataTypes.INTEGER, allowNull: true }, + createdBy: { type: DataTypes.INTEGER, allowNull: false }, + updatedBy: { type: DataTypes.INTEGER, allowNull: false }, + }, { + tableName: 'project_types', + paranoid: true, + timestamps: true, + updatedAt: 'updatedAt', + createdAt: 'createdAt', + deletedAt: 'deletedAt', + }); + + return ProjectType; +}; diff --git a/src/permissions/index.js b/src/permissions/index.js index ea1adf72..6ea7a418 100644 --- a/src/permissions/index.js +++ b/src/permissions/index.js @@ -41,4 +41,9 @@ module.exports = () => { Authorizer.setPolicy('project.addPhaseProduct', projectEdit); Authorizer.setPolicy('project.updatePhaseProduct', projectEdit); Authorizer.setPolicy('project.deletePhaseProduct', projectEdit); + + Authorizer.setPolicy('projectType.create', projectAdmin); + Authorizer.setPolicy('projectType.edit', projectAdmin); + Authorizer.setPolicy('projectType.delete', projectAdmin); + Authorizer.setPolicy('projectType.view', true); // anyone can view project types }; diff --git a/src/routes/index.js b/src/routes/index.js index 63250efe..b427b0c2 100644 --- a/src/routes/index.js +++ b/src/routes/index.js @@ -27,7 +27,7 @@ router.get(`/${apiVersion}/projects/health`, (req, res) => { const jwtAuth = require('tc-core-library-js').middleware.jwtAuthenticator; router.all( - RegExp(`\\/${apiVersion}\\/(projects|projectTemplates|productTemplates)(?!\\/health).*`), + RegExp(`\\/${apiVersion}\\/(projects|projectTemplates|productTemplates|projectTypes)(?!\\/health).*`), jwtAuth()); // Register all the routes @@ -86,22 +86,31 @@ router.route('/v4/productTemplates/:templateId(\\d+)') .delete(require('./productTemplates/delete')); router.route('/v4/projects/:projectId(\\d+)/phases') - .get(require('./phases/list')) - .post(require('./phases/create')); + .get(require('./phases/list')) + .post(require('./phases/create')); router.route('/v4/projects/:projectId(\\d+)/phases/:phaseId(\\d+)') - .get(require('./phases/get')) - .patch(require('./phases/update')) - .delete(require('./phases/delete')); + .get(require('./phases/get')) + .patch(require('./phases/update')) + .delete(require('./phases/delete')); router.route('/v4/projects/:projectId(\\d+)/phases/:phaseId(\\d+)/products') - .get(require('./phaseProducts/list')) - .post(require('./phaseProducts/create')); + .get(require('./phaseProducts/list')) + .post(require('./phaseProducts/create')); router.route('/v4/projects/:projectId(\\d+)/phases/:phaseId(\\d+)/products/:productId(\\d+)') - .get(require('./phaseProducts/get')) - .patch(require('./phaseProducts/update')) - .delete(require('./phaseProducts/delete')); + .get(require('./phaseProducts/get')) + .patch(require('./phaseProducts/update')) + .delete(require('./phaseProducts/delete')); + +router.route('/v4/projectTypes') + .post(require('./projectTypes/create')) + .get(require('./projectTypes/list')); + +router.route('/v4/projectTypes/:key') + .get(require('./projectTypes/get')) + .patch(require('./projectTypes/update')) + .delete(require('./projectTypes/delete')); // register error handler router.use((err, req, res, next) => { // eslint-disable-line no-unused-vars diff --git a/src/routes/projectTypes/create.js b/src/routes/projectTypes/create.js new file mode 100644 index 00000000..3cbcf579 --- /dev/null +++ b/src/routes/projectTypes/create.js @@ -0,0 +1,55 @@ +/** + * API to add a project type + */ +import validate from 'express-validation'; +import _ from 'lodash'; +import Joi from 'joi'; +import { middleware as tcMiddleware } from 'tc-core-library-js'; +import util from '../../util'; +import models from '../../models'; + +const permissions = tcMiddleware.permissions; + +const schema = { + body: { + param: Joi.object().keys({ + key: Joi.string().max(45).required(), + displayName: Joi.string().max(255).required(), + createdAt: Joi.any().strip(), + updatedAt: Joi.any().strip(), + deletedAt: Joi.any().strip(), + createdBy: Joi.any().strip(), + updatedBy: Joi.any().strip(), + deletedBy: Joi.any().strip(), + }).required(), + }, +}; + +module.exports = [ + validate(schema), + permissions('projectType.create'), + (req, res, next) => { + const entity = _.assign(req.body.param, { + createdBy: req.authUser.userId, + updatedBy: req.authUser.userId, + }); + + // Check if duplicated key + return models.ProjectType.findById(req.body.param.key) + .then((existing) => { + if (existing) { + const apiErr = new Error(`Project type already exists for key ${req.params.key}`); + apiErr.status = 422; + return Promise.reject(apiErr); + } + + // Create + return models.ProjectType.create(entity); + }).then((createdEntity) => { + // Omit deletedAt, deletedBy + res.status(201).json(util.wrapResponse( + req.id, _.omit(createdEntity.toJSON(), 'deletedAt', 'deletedBy'), 1, 201)); + }) + .catch(next); + }, +]; diff --git a/src/routes/projectTypes/create.spec.js b/src/routes/projectTypes/create.spec.js new file mode 100644 index 00000000..69b4b391 --- /dev/null +++ b/src/routes/projectTypes/create.spec.js @@ -0,0 +1,163 @@ +/** + * Tests for create.js + */ +import chai from 'chai'; +import request from 'supertest'; + +import server from '../../app'; +import testUtil from '../../tests/util'; +import models from '../../models'; + +const should = chai.should(); + +describe('CREATE project type', () => { + beforeEach(() => testUtil.clearDb() + .then(() => models.ProjectType.create({ + key: 'key1', + displayName: 'displayName 1', + createdBy: 1, + updatedBy: 1, + })).then(() => Promise.resolve()), + ); + after(testUtil.clearDb); + + describe('POST /projectTypes', () => { + const body = { + param: { + key: 'app_dev', + displayName: 'Application Development', + }, + }; + + it('should return 403 if user is not authenticated', (done) => { + request(server) + .post('/v4/projectTypes') + .send(body) + .expect(403, done); + }); + + it('should return 403 for member', (done) => { + request(server) + .post('/v4/projectTypes') + .set({ + Authorization: `Bearer ${testUtil.jwts.member}`, + }) + .send(body) + .expect(403, done); + }); + + it('should return 403 for copilot', (done) => { + request(server) + .post('/v4/projectTypes') + .set({ + Authorization: `Bearer ${testUtil.jwts.copilot}`, + }) + .send(body) + .expect(403, done); + }); + + it('should return 403 for manager', (done) => { + request(server) + .post('/v4/projectTypes') + .set({ + Authorization: `Bearer ${testUtil.jwts.manager}`, + }) + .send(body) + .expect(403, done); + }); + + it('should return 422 for missing key', (done) => { + const invalidBody = { + param: { + displayName: 'displayName', + }, + }; + + request(server) + .post('/v4/projectTypes') + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .send(invalidBody) + .expect('Content-Type', /json/) + .expect(422, done); + }); + + it('should return 422 for missing displayName', (done) => { + const invalidBody = { + param: { + key: 'key', + }, + }; + + request(server) + .post('/v4/projectTypes') + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .send(invalidBody) + .expect('Content-Type', /json/) + .expect(422, done); + }); + + it('should return 422 for duplicated key', (done) => { + const invalidBody = { + param: { + key: 'key1', + displayName: 'displayName', + }, + }; + + request(server) + .post('/v4/projectTypes') + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .send(invalidBody) + .expect('Content-Type', /json/) + .expect(422, done); + }); + + it('should return 201 for admin', (done) => { + request(server) + .post('/v4/projectTypes') + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .send(body) + .expect('Content-Type', /json/) + .expect(201) + .end((err, res) => { + const resJson = res.body.result.content; + resJson.key.should.be.eql(body.param.key); + resJson.displayName.should.be.eql(body.param.displayName); + + resJson.createdBy.should.be.eql(40051333); // admin + should.exist(resJson.createdAt); + resJson.updatedBy.should.be.eql(40051333); // admin + should.exist(resJson.updatedAt); + should.not.exist(resJson.deletedBy); + should.not.exist(resJson.deletedAt); + + done(); + }); + }); + + it('should return 201 for connect admin', (done) => { + request(server) + .post('/v4/projectTypes') + .set({ + Authorization: `Bearer ${testUtil.jwts.connectAdmin}`, + }) + .send(body) + .expect('Content-Type', /json/) + .expect(201) + .end((err, res) => { + const resJson = res.body.result.content; + resJson.createdBy.should.be.eql(40051336); // connect admin + resJson.updatedBy.should.be.eql(40051336); // connect admin + done(); + }); + }); + }); +}); diff --git a/src/routes/projectTypes/delete.js b/src/routes/projectTypes/delete.js new file mode 100644 index 00000000..7592641c --- /dev/null +++ b/src/routes/projectTypes/delete.js @@ -0,0 +1,54 @@ +/** + * API to delete a project type + */ +import validate from 'express-validation'; +import Joi from 'joi'; +import { middleware as tcMiddleware } from 'tc-core-library-js'; +import models from '../../models'; + +const permissions = tcMiddleware.permissions; + +const schema = { + params: { + key: Joi.string().max(45).required(), + }, +}; + +module.exports = [ + validate(schema), + permissions('projectType.delete'), + (req, res, next) => { + const where = { + deletedAt: { $eq: null }, + key: req.params.key, + }; + + return models.sequelize.transaction(tx => + // Update the deletedBy + models.ProjectType.update({ deletedBy: req.authUser.userId }, { + where, + returning: true, + raw: true, + transaction: tx, + }) + .then((updatedResults) => { + // Not found + if (updatedResults[0] === 0) { + const apiErr = new Error(`Project type not found for key ${req.params.key}`); + apiErr.status = 404; + return Promise.reject(apiErr); + } + + // Soft delete + return models.ProjectType.destroy({ + where, + transaction: tx, + }); + }) + .then(() => { + res.status(204).end(); + }) + .catch(next), + ); + }, +]; diff --git a/src/routes/projectTypes/delete.spec.js b/src/routes/projectTypes/delete.spec.js new file mode 100644 index 00000000..4d38f666 --- /dev/null +++ b/src/routes/projectTypes/delete.spec.js @@ -0,0 +1,99 @@ +/** + * Tests for delete.js + */ +import request from 'supertest'; + +import models from '../../models'; +import server from '../../app'; +import testUtil from '../../tests/util'; + + +describe('DELETE project type', () => { + const key = 'key1'; + + beforeEach(() => testUtil.clearDb() + .then(() => models.ProjectType.create({ + key: 'key1', + displayName: 'displayName 1', + createdBy: 1, + updatedBy: 1, + })).then(() => Promise.resolve()), + ); + after(testUtil.clearDb); + + describe('DELETE /projectTypes/{key}', () => { + it('should return 403 if user is not authenticated', (done) => { + request(server) + .delete(`/v4/projectTypes/${key}`) + .expect(403, done); + }); + + it('should return 403 for member', (done) => { + request(server) + .delete(`/v4/projectTypes/${key}`) + .set({ + Authorization: `Bearer ${testUtil.jwts.member}`, + }) + .expect(403, done); + }); + + it('should return 403 for copilot', (done) => { + request(server) + .delete(`/v4/projectTypes/${key}`) + .set({ + Authorization: `Bearer ${testUtil.jwts.copilot}`, + }) + .expect(403, done); + }); + + it('should return 403 for manager', (done) => { + request(server) + .delete(`/v4/projectTypes/${key}`) + .set({ + Authorization: `Bearer ${testUtil.jwts.manager}`, + }) + .expect(403, done); + }); + + it('should return 404 for non-existed type', (done) => { + request(server) + .delete('/v4/projectTypes/not_existed') + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .expect(404, done); + }); + + it('should return 404 for deleted type', (done) => { + models.ProjectType.destroy({ where: { key } }) + .then(() => { + request(server) + .delete(`/v4/projectTypes/${key}`) + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .expect(404, done); + }); + }); + + it('should return 204, for admin, if type was successfully removed', (done) => { + request(server) + .delete(`/v4/projectTypes/${key}`) + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .expect(204) + .end(done); + }); + + it('should return 204, for connect admin, if type was successfully removed', (done) => { + request(server) + .delete(`/v4/projectTypes/${key}`) + .set({ + Authorization: `Bearer ${testUtil.jwts.connectAdmin}`, + }) + .expect(204) + .end(done); + }); + }); +}); diff --git a/src/routes/projectTypes/get.js b/src/routes/projectTypes/get.js new file mode 100644 index 00000000..f7eb0b95 --- /dev/null +++ b/src/routes/projectTypes/get.js @@ -0,0 +1,39 @@ +/** + * API to get a project type + */ +import validate from 'express-validation'; +import Joi from 'joi'; +import { middleware as tcMiddleware } from 'tc-core-library-js'; +import util from '../../util'; +import models from '../../models'; + +const permissions = tcMiddleware.permissions; + +const schema = { + params: { + key: Joi.string().max(45).required(), + }, +}; + +module.exports = [ + validate(schema), + permissions('projectType.view'), + (req, res, next) => models.ProjectType.findOne({ + where: { + key: req.params.key, + }, + attributes: { exclude: ['deletedAt', 'deletedBy'] }, + }) + .then((projectType) => { + // Not found + if (!projectType) { + const apiErr = new Error(`Project type not found for key ${req.params.key}`); + apiErr.status = 404; + return Promise.reject(apiErr); + } + + res.json(util.wrapResponse(req.id, projectType)); + return Promise.resolve(); + }) + .catch(next), +]; diff --git a/src/routes/projectTypes/get.spec.js b/src/routes/projectTypes/get.spec.js new file mode 100644 index 00000000..f85e61af --- /dev/null +++ b/src/routes/projectTypes/get.spec.js @@ -0,0 +1,117 @@ +/** + * Tests for get.js + */ +import chai from 'chai'; +import request from 'supertest'; + +import models from '../../models'; +import server from '../../app'; +import testUtil from '../../tests/util'; + +const should = chai.should(); + +describe('GET project type', () => { + const type = { + key: 'key1', + displayName: 'displayName 1', + createdBy: 1, + updatedBy: 1, + }; + + const key = type.key; + + beforeEach(() => testUtil.clearDb() + .then(() => models.ProjectType.create(type)) + .then(() => Promise.resolve()), + ); + after(testUtil.clearDb); + + describe('GET /projectTypes/{key}', () => { + it('should return 403 if user is not authenticated', (done) => { + request(server) + .get(`/v4/projectTypes/${key}`) + .expect(403, done); + }); + + it('should return 404 for non-existed type', (done) => { + request(server) + .get('/v4/projectTypes/1234') + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .expect(404, done); + }); + + it('should return 404 for deleted type', (done) => { + models.ProjectType.destroy({ where: { key } }) + .then(() => { + request(server) + .get(`/v4/projectTypes/${key}`) + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .expect(404, done); + }); + }); + + it('should return 200 for admin', (done) => { + request(server) + .get(`/v4/projectTypes/${key}`) + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .expect(200) + .end((err, res) => { + const resJson = res.body.result.content; + resJson.key.should.be.eql(type.key); + resJson.displayName.should.be.eql(type.displayName); + resJson.createdBy.should.be.eql(type.createdBy); + should.exist(resJson.createdAt); + resJson.updatedBy.should.be.eql(type.updatedBy); + should.exist(resJson.updatedAt); + should.not.exist(resJson.deletedBy); + should.not.exist(resJson.deletedAt); + + done(); + }); + }); + + it('should return 200 for connect admin', (done) => { + request(server) + .get(`/v4/projectTypes/${key}`) + .set({ + Authorization: `Bearer ${testUtil.jwts.connectAdmin}`, + }) + .expect(200) + .end(done); + }); + + it('should return 200 for connect manager', (done) => { + request(server) + .get(`/v4/projectTypes/${key}`) + .set({ + Authorization: `Bearer ${testUtil.jwts.manager}`, + }) + .expect(200) + .end(done); + }); + + it('should return 200 for member', (done) => { + request(server) + .get(`/v4/projectTypes/${key}`) + .set({ + Authorization: `Bearer ${testUtil.jwts.member}`, + }) + .expect(200, done); + }); + + it('should return 200 for copilot', (done) => { + request(server) + .get(`/v4/projectTypes/${key}`) + .set({ + Authorization: `Bearer ${testUtil.jwts.copilot}`, + }) + .expect(200, done); + }); + }); +}); diff --git a/src/routes/projectTypes/list.js b/src/routes/projectTypes/list.js new file mode 100644 index 00000000..56bc2059 --- /dev/null +++ b/src/routes/projectTypes/list.js @@ -0,0 +1,20 @@ +/** + * API to list all project types + */ +import { middleware as tcMiddleware } from 'tc-core-library-js'; +import util from '../../util'; +import models from '../../models'; + +const permissions = tcMiddleware.permissions; + +module.exports = [ + permissions('projectType.view'), + (req, res, next) => models.ProjectType.findAll({ + attributes: { exclude: ['deletedAt', 'deletedBy'] }, + raw: true, + }) + .then((projectTypes) => { + res.json(util.wrapResponse(req.id, projectTypes)); + }) + .catch(next), +]; diff --git a/src/routes/projectTypes/list.spec.js b/src/routes/projectTypes/list.spec.js new file mode 100644 index 00000000..94497692 --- /dev/null +++ b/src/routes/projectTypes/list.spec.js @@ -0,0 +1,106 @@ +/** + * Tests for list.js + */ +import chai from 'chai'; +import request from 'supertest'; + +import models from '../../models'; +import server from '../../app'; +import testUtil from '../../tests/util'; + +const should = chai.should(); + +describe('LIST project types', () => { + const types = [ + { + key: 'key1', + displayName: 'displayName 1', + createdBy: 1, + updatedBy: 1, + }, + { + key: 'key2', + displayName: 'displayName 1', + createdBy: 1, + updatedBy: 1, + }, + ]; + + beforeEach(() => testUtil.clearDb() + .then(() => models.ProjectType.create(types[0])) + .then(() => models.ProjectType.create(types[1])) + .then(() => Promise.resolve()), + ); + after(testUtil.clearDb); + + describe('GET /projectTypes', () => { + it('should return 403 if user is not authenticated', (done) => { + request(server) + .get('/v4/projectTypes') + .expect(403, done); + }); + + it('should return 200 for admin', (done) => { + request(server) + .get('/v4/projectTypes') + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .expect(200) + .end((err, res) => { + const type = types[0]; + + const resJson = res.body.result.content; + resJson.should.have.length(2); + resJson[0].key.should.be.eql(type.key); + resJson[0].displayName.should.be.eql(type.displayName); + resJson[0].createdBy.should.be.eql(type.createdBy); + should.exist(resJson[0].createdAt); + resJson[0].updatedBy.should.be.eql(type.updatedBy); + should.exist(resJson[0].updatedAt); + should.not.exist(resJson[0].deletedBy); + should.not.exist(resJson[0].deletedAt); + + done(); + }); + }); + + it('should return 200 for connect admin', (done) => { + request(server) + .get('/v4/projectTypes') + .set({ + Authorization: `Bearer ${testUtil.jwts.connectAdmin}`, + }) + .expect(200) + .end(done); + }); + + it('should return 200 for connect manager', (done) => { + request(server) + .get('/v4/projectTypes') + .set({ + Authorization: `Bearer ${testUtil.jwts.manager}`, + }) + .expect(200) + .end(done); + }); + + it('should return 200 for member', (done) => { + request(server) + .get('/v4/projectTypes') + .set({ + Authorization: `Bearer ${testUtil.jwts.member}`, + }) + .expect(200, done); + }); + + it('should return 200 for copilot', (done) => { + request(server) + .get('/v4/projectTypes') + .set({ + Authorization: `Bearer ${testUtil.jwts.copilot}`, + }) + .expect(200, done); + }); + }); +}); diff --git a/src/routes/projectTypes/update.js b/src/routes/projectTypes/update.js new file mode 100644 index 00000000..4a0aa26b --- /dev/null +++ b/src/routes/projectTypes/update.js @@ -0,0 +1,61 @@ +/** + * API to update a project type + */ +import validate from 'express-validation'; +import _ from 'lodash'; +import Joi from 'joi'; +import { middleware as tcMiddleware } from 'tc-core-library-js'; +import util from '../../util'; +import models from '../../models'; + +const permissions = tcMiddleware.permissions; + +const schema = { + params: { + key: Joi.string().max(45).required(), + }, + body: { + param: Joi.object().keys({ + key: Joi.any().strip(), + displayName: Joi.string().max(255).required(), + createdAt: Joi.any().strip(), + updatedAt: Joi.any().strip(), + deletedAt: Joi.any().strip(), + createdBy: Joi.any().strip(), + updatedBy: Joi.any().strip(), + deletedBy: Joi.any().strip(), + }).required(), + }, +}; + +module.exports = [ + validate(schema), + permissions('projectType.edit'), + (req, res, next) => { + const entityToUpdate = _.assign(req.body.param, { + updatedBy: req.authUser.userId, + }); + + return models.ProjectType.findOne({ + where: { + key: req.params.key, + }, + attributes: { exclude: ['deletedAt', 'deletedBy'] }, + }) + .then((projectType) => { + // Not found + if (!projectType) { + const apiErr = new Error(`Project type not found for key ${req.params.key}`); + apiErr.status = 404; + return Promise.reject(apiErr); + } + + return projectType.update(entityToUpdate); + }) + .then((projectType) => { + res.json(util.wrapResponse(req.id, projectType)); + return Promise.resolve(); + }) + .catch(next); + }, +]; diff --git a/src/routes/projectTypes/update.spec.js b/src/routes/projectTypes/update.spec.js new file mode 100644 index 00000000..ce3fcc79 --- /dev/null +++ b/src/routes/projectTypes/update.spec.js @@ -0,0 +1,145 @@ +/** + * Tests for get.js + */ +import chai from 'chai'; +import request from 'supertest'; + +import models from '../../models'; +import server from '../../app'; +import testUtil from '../../tests/util'; + +const should = chai.should(); + +describe('UPDATE project type', () => { + const type = { + key: 'key1', + displayName: 'displayName 1', + createdBy: 1, + updatedBy: 1, + }; + const key = type.key; + + beforeEach(() => testUtil.clearDb() + .then(() => models.ProjectType.create(type)) + .then(() => Promise.resolve()), + ); + after(testUtil.clearDb); + + describe('PATCH /projectTypes/{key}', () => { + const body = { + param: { + displayName: 'displayName 1 - update', + }, + }; + + it('should return 403 if user is not authenticated', (done) => { + request(server) + .patch(`/v4/projectTypes/${key}`) + .send(body) + .expect(403, done); + }); + + it('should return 403 for member', (done) => { + request(server) + .patch(`/v4/projectTypes/${key}`) + .set({ + Authorization: `Bearer ${testUtil.jwts.member}`, + }) + .send(body) + .expect(403, done); + }); + + it('should return 403 for copilot', (done) => { + request(server) + .patch(`/v4/projectTypes/${key}`) + .send(body) + .set({ + Authorization: `Bearer ${testUtil.jwts.copilot}`, + }) + .expect(403, done); + }); + + it('should return 403 for manager', (done) => { + request(server) + .patch(`/v4/projectTypes/${key}`) + .send(body) + .set({ + Authorization: `Bearer ${testUtil.jwts.manager}`, + }) + .expect(403, done); + }); + + it('should return 422 for missing displayName', (done) => { + const invalidBody = { + param: { + displayName: null, + }, + }; + + request(server) + .patch(`/v4/projectTypes/${key}`) + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .send(invalidBody) + .expect(422, done); + }); + + it('should return 404 for non-existed type', (done) => { + request(server) + .patch('/v4/projectTypes/1234') + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .send(body) + .expect(404, done); + }); + + it('should return 404 for deleted type', (done) => { + models.ProjectType.destroy({ where: { key } }) + .then(() => { + request(server) + .patch(`/v4/projectTypes/${key}`) + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .send(body) + .expect(404, done); + }); + }); + + it('should return 200 for admin', (done) => { + request(server) + .patch(`/v4/projectTypes/${key}`) + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .send(body) + .expect(200) + .end((err, res) => { + const resJson = res.body.result.content; + resJson.key.should.be.eql(key); + resJson.displayName.should.be.eql(body.param.displayName); + resJson.createdBy.should.be.eql(type.createdBy); + should.exist(resJson.createdAt); + resJson.updatedBy.should.be.eql(40051333); // admin + should.exist(resJson.updatedAt); + should.not.exist(resJson.deletedBy); + should.not.exist(resJson.deletedAt); + + done(); + }); + }); + + it('should return 200 for connect admin', (done) => { + request(server) + .patch(`/v4/projectTypes/${key}`) + .set({ + Authorization: `Bearer ${testUtil.jwts.connectAdmin}`, + }) + .send(body) + .expect(200) + .end(done); + }); + }); +}); diff --git a/src/routes/projects/create.js b/src/routes/projects/create.js index 06e93405..48ccfce9 100644 --- a/src/routes/projects/create.js +++ b/src/routes/projects/create.js @@ -44,8 +44,7 @@ const createProjectValdiations = { type: Joi.any().valid('github', 'jira', 'asana', 'other'), data: Joi.string().max(300), // TODO - restrict length }).allow(null), - // TODO - add more types - type: Joi.any().valid(_.values(PROJECT_TYPE)).required(), + type: Joi.string().max(45).required(), details: Joi.any(), challengeEligibility: Joi.array().items(Joi.object().keys({ role: Joi.string().valid('submitter', 'reviewer', 'copilot'), @@ -70,8 +69,8 @@ module.exports = [ 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]) - ? PROJECT_MEMBER_ROLE.MANAGER - : PROJECT_MEMBER_ROLE.CUSTOMER; + ? PROJECT_MEMBER_ROLE.MANAGER + : PROJECT_MEMBER_ROLE.CUSTOMER; // set defaults _.defaults(project, { description: '', @@ -100,69 +99,85 @@ module.exports = [ }); models.sequelize.transaction(() => { let newProject = null; - return models.Project - .create(project, { - include: [{ - model: models.ProjectMember, - as: 'members', - }], - }) - .then((_newProject) => { - newProject = _newProject; - req.log.debug('new project created (id# %d, name: %s)', - newProject.id, newProject.name); - // create direct project with name and description - const body = { - projectName: newProject.name, - projectDescription: newProject.description, - }; - // billingAccountId is optional field - if (newProject.billingAccountId) { - body.billingAccountId = newProject.billingAccountId; - } - req.log.debug('creating project history for project %d', newProject.id); - // add to project history - models.ProjectHistory.create({ - projectId: _newProject.id, - status: PROJECT_STATUS.DRAFT, - cancelReason: null, - updatedBy: req.authUser.userId, - }).then(() => req.log.debug('project history created for project %d', newProject.id)) + // Validate the project type + return models.ProjectType.findOne({ + where: { + key: project.type, + } + }) + .then((projectType) => { + if (!projectType) { + // Not found + const apiErr = new Error(`Project type not found for key ${project.type}`); + apiErr.status = 422; + return Promise.reject(apiErr); + } + + // Create project + return models.Project + .create(project, { + include: [{ + model: models.ProjectMember, + as: 'members', + }], + }); + }) + .then((_newProject) => { + newProject = _newProject; + req.log.debug('new project created (id# %d, name: %s)', + newProject.id, newProject.name); + // create direct project with name and description + const body = { + projectName: newProject.name, + projectDescription: newProject.description, + }; + // billingAccountId is optional field + if (newProject.billingAccountId) { + body.billingAccountId = newProject.billingAccountId; + } + req.log.debug('creating project history for project %d', newProject.id); + // add to project history + models.ProjectHistory.create({ + projectId: _newProject.id, + status: PROJECT_STATUS.DRAFT, + cancelReason: null, + updatedBy: req.authUser.userId, + }).then(() => req.log.debug('project history created for project %d', newProject.id)) .catch(() => req.log.error('project history failed for project %d', newProject.id)); - req.log.debug('creating direct project for project %d', newProject.id); - return directProject.createDirectProject(req, body) - .then((resp) => { - newProject.directProjectId = resp.data.result.content.projectId; - return newProject.save(); - }) - .then(() => newProject.reload(newProject.id)) - .catch((err) => { - // log the error and continue - req.log.error('Error creating direct project'); - req.log.error(err); - return Promise.resolve(); - }); - // return Promise.resolve(); - }) - .then(() => { - newProject = newProject.get({ plain: true }); - // remove utm details & deletedAt field - newProject = _.omit(newProject, ['deletedAt', 'utm']); - // add an empty attachments array - newProject.attachments = []; - req.log.debug('Sending event to RabbitMQ bus for project %d', newProject.id); - req.app.services.pubsub.publish(EVENT.ROUTING_KEY.PROJECT_DRAFT_CREATED, - newProject, - { correlationId: req.id }, - ); - req.log.debug('Sending event to Kafka bus for project %d', newProject.id); - // emit event - req.app.emit(EVENT.ROUTING_KEY.PROJECT_DRAFT_CREATED, { req, project: newProject }); - res.status(201).json(util.wrapResponse(req.id, newProject, 1, 201)); - }) - .catch((err) => { - util.handleError('Error creating project', err, req, next); - }); + req.log.debug('creating direct project for project %d', newProject.id); + return directProject.createDirectProject(req, body) + .then((resp) => { + newProject.directProjectId = resp.data.result.content.projectId; + return newProject.save(); + }) + .then(() => newProject.reload(newProject.id)) + .catch((err) => { + // log the error and continue + req.log.error('Error creating direct project'); + req.log.error(err); + return Promise.resolve(); + }); + // return Promise.resolve(); + }) + .then(() => { + newProject = newProject.get({ plain: true }); + // remove utm details & deletedAt field + newProject = _.omit(newProject, ['deletedAt', 'utm']); + // add an empty attachments array + newProject.attachments = []; + req.log.debug('Sending event to RabbitMQ bus for project %d', newProject.id); + req.app.services.pubsub.publish(EVENT.ROUTING_KEY.PROJECT_DRAFT_CREATED, + newProject, + { correlationId: req.id }, + ); + req.log.debug('Sending event to Kafka bus for project %d', newProject.id); + // emit event + req.app.emit(EVENT.ROUTING_KEY.PROJECT_DRAFT_CREATED, { req, project: newProject }); + res.status(201).json(util.wrapResponse(req.id, newProject, 1, 201)); + }) + .catch((err) => { + util.handleError('Error creating project', err, req, next); + }); }); }, ]; diff --git a/src/routes/projects/create.spec.js b/src/routes/projects/create.spec.js index 9e9cb0dc..98a190f9 100644 --- a/src/routes/projects/create.spec.js +++ b/src/routes/projects/create.spec.js @@ -8,6 +8,7 @@ import util from '../../util'; import server from '../../app'; import testUtil from '../../tests/util'; import RabbitMQService from '../../services/rabbitmq'; +import models from '../../models'; const should = chai.should(); @@ -16,7 +17,16 @@ sinon.stub(RabbitMQService.prototype, 'publish', () => {}); describe('Project create', () => { before((done) => { - testUtil.clearDb(done); + testUtil.clearDb() + .then(() => models.ProjectType.bulkCreate([ + { + key: 'generic', + displayName: 'Generic', + createdBy: 1, + updatedBy: 1, + } + ])) + .then(() => done()); }); after((done) => { @@ -66,6 +76,32 @@ describe('Project create', () => { .expect(422, done); }); + it('should return 422 if project type is missing', (done) => { + const invalidBody = _.cloneDeep(body); + invalidBody.param.type = null; + request(server) + .post('/v4/projects') + .set({ + Authorization: `Bearer ${testUtil.jwts.member}`, + }) + .send(invalidBody) + .expect('Content-Type', /json/) + .expect(422, done); + }); + + it('should return 422 if project type does not exist', (done) => { + const invalidBody = _.cloneDeep(body); + invalidBody.param.type = 'not_exist'; + request(server) + .post('/v4/projects') + .set({ + Authorization: `Bearer ${testUtil.jwts.member}`, + }) + .send(invalidBody) + .expect('Content-Type', /json/) + .expect(422, done); + }); + it('should return 201 if error to create direct project', (done) => { const mockHttpClient = _.merge(testUtil.mockHttpClient, { post: () => Promise.reject(new Error('error message')), diff --git a/src/routes/projects/update.js b/src/routes/projects/update.js index b02fc55d..d0efccd6 100644 --- a/src/routes/projects/update.js +++ b/src/routes/projects/update.js @@ -60,7 +60,7 @@ const updateProjectValdiations = { title: Joi.string(), address: Joi.string().regex(REGEX.URL), })).optional().allow(null), - type: Joi.any().valid(_.values(PROJECT_TYPE)), + type: Joi.string().max(45), details: Joi.any(), memers: Joi.any(), createdBy: Joi.any(), @@ -91,15 +91,15 @@ const validateUpdates = (existingProject, updatedProps, req) => { break; default: break; - // disabling this check for now. - // case PROJECT_STATUS.DRAFT: - // if (_.get(updatedProject, 'status', '') === 'active') { - // // attempting to launch the project make sure certain - // // properties are set - // if (!updatedProject.billingAccountId && !existingProject.billingAccountId) { - // errors.push('\'billingAccountId\' must be set before activating the project') - // } - // } + // disabling this check for now. + // case PROJECT_STATUS.DRAFT: + // if (_.get(updatedProject, 'status', '') === 'active') { + // // attempting to launch the project make sure certain + // // properties are set + // if (!updatedProject.billingAccountId && !existingProject.billingAccountId) { + // errors.push('\'billingAccountId\' must be set before activating the project') + // } + // } } if (_.has(updatedProps, 'directProjectId') && !util.hasRoles(req, [USER_ROLE.MANAGER, USER_ROLE.TOPCODER_ADMIN])) { @@ -113,6 +113,25 @@ module.exports = [ // handles request validations validate(updateProjectValdiations), permissions('project.edit'), + /** + * Validate project type to be existed. + */ + (req, res, next) => { + if (req.body.param.type) { + models.ProjectType.findOne({ where: { key: req.body.param.type } }) + .then((projectType) => { + if (projectType) { + next(); + } else { + const err = new Error(`Project type not found for key ${req.body.param.type}`); + err.status = 422; + next(err); + } + }) + } else { + next(); + } + }, /** * POST projects/ * Create a project if the user has access diff --git a/src/routes/projects/update.spec.js b/src/routes/projects/update.spec.js index 520d93db..e7b9abf0 100644 --- a/src/routes/projects/update.spec.js +++ b/src/routes/projects/update.spec.js @@ -19,7 +19,16 @@ describe('Project', () => { let project2; let project3; beforeEach((done) => { - testUtil.clearDb(done); + testUtil.clearDb() + .then(() => models.ProjectType.bulkCreate([ + { + key: 'generic', + displayName: 'Generic', + createdBy: 1, + updatedBy: 1, + } + ])) + .then(() => done()); }); after((done) => { @@ -319,6 +328,22 @@ describe('Project', () => { }); }); + it('should return 422 if project type does not exist', (done) => { + const mbody = { + param: { + type: 'not_exist' + }, + }; + request(server) + .patch(`/v4/projects/${project1.id}`) + .set({ + Authorization: `Bearer ${testUtil.jwts.copilot}`, + }) + .send(mbody) + .expect('Content-Type', /json/) + .expect(422, done); + }); + it('should return 200 and project history should be updated for cancelled project', (done) => { const mbody = { param: { @@ -369,10 +394,10 @@ describe('Project', () => { models.Project.update({ status: PROJECT_STATUS.CANCELLED, }, { - where: { - id: project1.id, - }, - }) + where: { + id: project1.id, + }, + }) .then(() => { const mbody = { param: { @@ -422,10 +447,10 @@ describe('Project', () => { models.Project.update({ status: PROJECT_STATUS.CANCELLED, }, { - where: { - id: project1.id, - }, - }) + where: { + id: project1.id, + }, + }) .then(() => { const mbody = { param: { @@ -475,10 +500,10 @@ describe('Project', () => { models.Project.update({ status: PROJECT_STATUS.CANCELLED, }, { - where: { - id: project1.id, - }, - }) + where: { + id: project1.id, + }, + }) .then(() => { const mbody = { param: { @@ -729,10 +754,10 @@ describe('Project', () => { models.Project.update({ status: PROJECT_STATUS.CANCELLED, }, { - where: { - id: project1.id, - }, - }) + where: { + id: project1.id, + }, + }) .then(() => { const mbody = { param: { diff --git a/src/tests/seed.js b/src/tests/seed.js index bbd0aa08..2a66734f 100644 --- a/src/tests/seed.js +++ b/src/tests/seed.js @@ -107,6 +107,10 @@ models.sequelize.sync({ force: true }) name: 'template 1', key: 'key 1', category: 'category 1', + icon: 'http://example.com/icon1.ico', + question: 'question 1', + info: 'info 1', + aliases: [], scope: { scope1: { subScope1A: 1, @@ -137,8 +141,38 @@ models.sequelize.sync({ force: true }) name: 'template 2', key: 'key 2', category: 'category 2', + icon: 'http://example.com/icon1.ico', + info: 'info 2', + aliases: [], scope: {}, phases: {}, + question: 'question 2', + createdBy: 1, + updatedBy: 2, + }, + { + name: 'template 3', + key: 'key 3', + category: 'category 3', + icon: 'http://example.com/icon3.ico', + question: 'question 3', + info: 'info 3', + aliases: [], + scope: {}, + phases: { + 1: { + name: 'Design Stage', + products: [ + { id: 21, productKey: 'visual_design_prod' }, + ], + }, + 2: { + name: 'Development Stage', + products: [ + { id: 23, productKey: 'website_development' }, + ], + }, + }, createdBy: 1, updatedBy: 2, }, @@ -188,7 +222,60 @@ models.sequelize.sync({ force: true }) updatedBy: 4, }, ])) + .then(() => models.ProjectType.bulkCreate([ + { + key: 'app_dev', + displayName: 'Application development', + createdBy: 1, + updatedBy: 2, + }, + { + key: 'generic', + displayName: 'Generic', + createdBy: 1, + updatedBy: 2, + }, + { + key: 'visual_prototype', + displayName: 'Visual Prototype', + createdBy: 1, + updatedBy: 2, + }, + { + key: 'visual_design', + displayName: 'Visual Design', + createdBy: 1, + updatedBy: 2, + }, + { + key: 'website', + displayName: 'Website', + createdBy: 1, + updatedBy: 2, + }, + { + key: 'app', + displayName: 'Application', + createdBy: 1, + updatedBy: 2, + }, + { + key: 'quality_assurance', + displayName: 'Quality Assurance', + createdBy: 1, + updatedBy: 2, + }, + { + key: 'chatbot', + displayName: 'Chatbot', + createdBy: 1, + updatedBy: 2, + }, + ])) .then(() => { process.exit(0); }) - .catch(() => process.exit(1)); + .catch((err) => { + console.log(err); // eslint-disable-line no-console + process.exit(1); + }); From 7c10f7ef631d14aa76d1aad0c0850f6e898606e6 Mon Sep 17 00:00:00 2001 From: ngoctay Date: Fri, 1 Jun 2018 02:51:46 +0700 Subject: [PATCH 2/5] #86 - code, unit tests, updated Postman and Swagger #87 - updated Swagger --- config/default.json | 8 +- package-lock.json | 190 +++++++++++++++++++- postman.json | 68 ++++++++ src/constants.js | 11 -- src/models/project.js | 3 +- src/routes/projects/create.js | 93 ++++++++-- src/routes/projects/create.spec.js | 170 +++++++++++++++++- src/routes/projects/update.js | 6 +- src/routes/projects/update.spec.js | 37 ++-- src/tests/seed.js | 24 ++- swagger.yaml | 269 ++++++++++++++++++++++++++++- 11 files changed, 816 insertions(+), 63 deletions(-) diff --git a/config/default.json b/config/default.json index 05b3dfe5..857a6add 100644 --- a/config/default.json +++ b/config/default.json @@ -39,9 +39,9 @@ "busApiToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJuYW1lIjoicHJvamVjdC1zZXJ2aWNlIiwiaWF0IjoxNTEyNzQ3MDgyLCJleHAiOjE1MjEzODcwODJ9.PHuNcFDaotGAL8RhQXQMdpL8yOKXxjB5DbBIodmt7RE", "HEALTH_CHECK_URL": "_health", "maxPhaseProductCount": 1, - "AUTH0_CLIENT_ID": "", - "AUTH0_CLIENT_SECRET": "", - "AUTH0_AUDIENCE": "", - "AUTH0_URL": "", + "AUTH0_CLIENT_ID": "5fctfjaLJHdvM04kSrCcC8yn0I4t1JTd", + "AUTH0_CLIENT_SECRET": "GhvDENIrYXo-d8xQ10fxm9k7XSVg491vlpvolXyWNBmeBdhsA5BAq2mH4cAAYS0x", + "AUTH0_AUDIENCE": "https://www.topcoder.com", + "AUTH0_URL": "https://topcoder-newauth.auth0.com/oauth/token", "TOKEN_CACHE_TIME": "" } diff --git a/package-lock.json b/package-lock.json index 0eedd948..59e653b9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -388,6 +388,84 @@ "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", "integrity": "sha1-x57Zf380y48robyXkLzDZkdLS3k=" }, + "auth0-js": { + "version": "9.6.0", + "resolved": "https://registry.npmjs.org/auth0-js/-/auth0-js-9.6.0.tgz", + "integrity": "sha1-2a4wFIBzZtO0ecKtGKNTfz4Mlpk=", + "requires": { + "base64-js": "1.2.1", + "idtoken-verifier": "1.2.0", + "js-cookie": "2.2.0", + "qs": "6.5.1", + "superagent": "3.8.3", + "url-join": "1.1.0", + "winchan": "0.2.0" + }, + "dependencies": { + "debug": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.1.0.tgz", + "integrity": "sha512-OX8XqP7/1a9cqkxYw2yXss15f26NKWBpDXQd0/uK/KPqdQhxbPa994hnzjcE2VqQpDslf55723cKPUOGSmMY3g==", + "requires": { + "ms": "2.0.0" + } + }, + "formidable": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/formidable/-/formidable-1.2.1.tgz", + "integrity": "sha512-Fs9VRguL0gqGHkXS5GQiMCr1VhZBxz0JnJs4JmMp/2jL18Fmbzvv7vOFRU+U8TBkHEE/CX1qDXzJplVULgsLeg==" + }, + "isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha1-u5NdSFgsuhaMBoNJV6VKPgcSTxE=" + }, + "process-nextick-args": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.0.tgz", + "integrity": "sha512-MtEC1TqN0EU5nephaJ4rAtThHtC86dNN9qCuEhtshvpVBkAW5ZO7BASN9REnF9eoXGcRub+pFuKEpOHE+HbEMw==" + }, + "readable-stream": { + "version": "2.3.6", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.6.tgz", + "integrity": "sha512-tQtKA9WIAhBF3+VLAseyMqZeBjW0AHJoxOtYqSUZNJxauErmLbVm2FW1y+J/YA9dUrAC39ITejlZWhVIwawkKw==", + "requires": { + "core-util-is": "1.0.2", + "inherits": "2.0.3", + "isarray": "1.0.0", + "process-nextick-args": "2.0.0", + "safe-buffer": "5.1.1", + "string_decoder": "1.1.1", + "util-deprecate": "1.0.2" + } + }, + "string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "requires": { + "safe-buffer": "5.1.1" + } + }, + "superagent": { + "version": "3.8.3", + "resolved": "https://registry.npmjs.org/superagent/-/superagent-3.8.3.tgz", + "integrity": "sha512-GLQtLMCoEIK4eDv6OGtkOoSMt3D+oq0y3dsxMuYuDvaNUvuT8eFBuLmfR0iYYzHC1e8hpzC6ZsxbuP6DIalMFA==", + "requires": { + "component-emitter": "1.2.1", + "cookiejar": "2.1.1", + "debug": "3.1.0", + "extend": "3.0.1", + "form-data": "2.3.1", + "formidable": "1.2.1", + "methods": "1.1.2", + "mime": "1.4.1", + "qs": "6.5.1", + "readable-stream": "2.3.6" + } + } + } + }, "aws-sdk": { "version": "2.143.0", "resolved": "https://registry.npmjs.org/aws-sdk/-/aws-sdk-2.143.0.tgz", @@ -2247,6 +2325,11 @@ "resolved": "https://registry.npmjs.org/crypto-browserify/-/crypto-browserify-1.0.9.tgz", "integrity": "sha1-zFRJaF37hesRyYKKzHy4erW7/MA=" }, + "crypto-js": { + "version": "3.1.9-1", + "resolved": "https://registry.npmjs.org/crypto-js/-/crypto-js-3.1.9-1.tgz", + "integrity": "sha1-/aGedh/Ad+Af+/3G6f38WeiAbNg=" + }, "crypto-random-string": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/crypto-random-string/-/crypto-random-string-1.0.0.tgz", @@ -3949,6 +4032,82 @@ "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.19.tgz", "integrity": "sha512-oTZqweIP51xaGPI4uPa56/Pri/480R+mo7SeU+YETByQNhDG55ycFyNLIgta9vXhILrxXDmF7ZGhqZIcuN0gJQ==" }, + "idtoken-verifier": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/idtoken-verifier/-/idtoken-verifier-1.2.0.tgz", + "integrity": "sha512-8jmmFHwdPz8L73zGNAXHHOV9yXNC+Z0TUBN5rafpoaFaLFltlIFr1JkQa3FYAETP23eSsulVw0sBiwrE8jqbUg==", + "requires": { + "base64-js": "1.2.1", + "crypto-js": "3.1.9-1", + "jsbn": "0.1.1", + "superagent": "3.8.3", + "url-join": "1.1.0" + }, + "dependencies": { + "debug": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.1.0.tgz", + "integrity": "sha512-OX8XqP7/1a9cqkxYw2yXss15f26NKWBpDXQd0/uK/KPqdQhxbPa994hnzjcE2VqQpDslf55723cKPUOGSmMY3g==", + "requires": { + "ms": "2.0.0" + } + }, + "formidable": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/formidable/-/formidable-1.2.1.tgz", + "integrity": "sha512-Fs9VRguL0gqGHkXS5GQiMCr1VhZBxz0JnJs4JmMp/2jL18Fmbzvv7vOFRU+U8TBkHEE/CX1qDXzJplVULgsLeg==" + }, + "isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha1-u5NdSFgsuhaMBoNJV6VKPgcSTxE=" + }, + "process-nextick-args": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.0.tgz", + "integrity": "sha512-MtEC1TqN0EU5nephaJ4rAtThHtC86dNN9qCuEhtshvpVBkAW5ZO7BASN9REnF9eoXGcRub+pFuKEpOHE+HbEMw==" + }, + "readable-stream": { + "version": "2.3.6", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.6.tgz", + "integrity": "sha512-tQtKA9WIAhBF3+VLAseyMqZeBjW0AHJoxOtYqSUZNJxauErmLbVm2FW1y+J/YA9dUrAC39ITejlZWhVIwawkKw==", + "requires": { + "core-util-is": "1.0.2", + "inherits": "2.0.3", + "isarray": "1.0.0", + "process-nextick-args": "2.0.0", + "safe-buffer": "5.1.1", + "string_decoder": "1.1.1", + "util-deprecate": "1.0.2" + } + }, + "string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "requires": { + "safe-buffer": "5.1.1" + } + }, + "superagent": { + "version": "3.8.3", + "resolved": "https://registry.npmjs.org/superagent/-/superagent-3.8.3.tgz", + "integrity": "sha512-GLQtLMCoEIK4eDv6OGtkOoSMt3D+oq0y3dsxMuYuDvaNUvuT8eFBuLmfR0iYYzHC1e8hpzC6ZsxbuP6DIalMFA==", + "requires": { + "component-emitter": "1.2.1", + "cookiejar": "2.1.1", + "debug": "3.1.0", + "extend": "3.0.1", + "form-data": "2.3.1", + "formidable": "1.2.1", + "methods": "1.1.2", + "mime": "1.4.1", + "qs": "6.5.1", + "readable-stream": "2.3.6" + } + } + } + }, "ieee754": { "version": "1.1.8", "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.1.8.tgz", @@ -4563,6 +4722,11 @@ "nopt": "3.0.6" } }, + "js-cookie": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/js-cookie/-/js-cookie-2.2.0.tgz", + "integrity": "sha1-Gywnmm7s44ChIWi5JIUmWzWx7/s=" + }, "js-string-escape": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/js-string-escape/-/js-string-escape-1.0.1.tgz", @@ -4587,8 +4751,7 @@ "jsbn": { "version": "0.1.1", "resolved": "https://registry.npmjs.org/jsbn/-/jsbn-0.1.1.tgz", - "integrity": "sha1-peZUwuWi3rXyAdls77yoDA7y9RM=", - "optional": true + "integrity": "sha1-peZUwuWi3rXyAdls77yoDA7y9RM=" }, "jsesc": { "version": "1.3.0", @@ -6991,11 +7154,6 @@ "resolved": "https://registry.npmjs.org/stream-consume/-/stream-consume-0.1.0.tgz", "integrity": "sha1-pB6tGm1ggc63n2WwYZAbbY89HQ8=" }, - "string_decoder": { - "version": "0.10.31", - "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-0.10.31.tgz", - "integrity": "sha1-YuIDvEF2bGwoyfyEMB2rHFMQ+pQ=" - }, "string-width": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/string-width/-/string-width-1.0.2.tgz", @@ -7007,6 +7165,11 @@ "strip-ansi": "3.0.1" } }, + "string_decoder": { + "version": "0.10.31", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-0.10.31.tgz", + "integrity": "sha1-YuIDvEF2bGwoyfyEMB2rHFMQ+pQ=" + }, "stringstream": { "version": "0.0.5", "resolved": "https://registry.npmjs.org/stringstream/-/stringstream-0.0.5.tgz", @@ -7221,8 +7384,9 @@ } }, "tc-core-library-js": { - "version": "github:appirio-tech/tc-core-library-js#4346c62b2c08d8f1d6b7a7642ef4c0c011c3f732", + "version": "github:appirio-tech/tc-core-library-js#df1f5c1a5578d3d1e475bfb4a7413d9dec25525a", "requires": { + "auth0-js": "9.6.0", "axios": "0.12.0", "bunyan": "1.8.12", "config": "1.27.0", @@ -7621,6 +7785,11 @@ "querystring": "0.2.0" } }, + "url-join": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/url-join/-/url-join-1.1.0.tgz", + "integrity": "sha1-dBxsL0WWxIMNZxhGCSDQySIC3Hg=" + }, "url-parse-lax": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/url-parse-lax/-/url-parse-lax-1.0.0.tgz", @@ -7838,6 +8007,11 @@ "string-width": "1.0.2" } }, + "winchan": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/winchan/-/winchan-0.2.0.tgz", + "integrity": "sha1-OGMCjn+XSw2hQS8oQXukJJcqvZQ=" + }, "window-size": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/window-size/-/window-size-0.1.0.tgz", diff --git a/postman.json b/postman.json index 5eb84b82..1a07fb9f 100644 --- a/postman.json +++ b/postman.json @@ -2837,6 +2837,74 @@ "response": [] } ] + }, + { + "name": "issue86 (create project with projectTemplateId)", + "description": "", + "item": [ + { + "name": "Create project with projectTemplateId", + "request": { + "method": "POST", + "header": [ + { + "key": "Authorization", + "value": "Bearer {{jwt-token}}" + }, + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n\t\"param\": {\n\t\t\"name\": \"test project with projectTemplateId\",\n\t\t\"description\": \"Hello I am a test project with projectTemplateId\",\n\t\t\"type\": \"generic\",\n\t\t\"projectTemplateId\": 3\n\t}\n}" + }, + "url": { + "raw": "{{api-url}}/v4/projects", + "host": [ + "{{api-url}}" + ], + "path": [ + "v4", + "projects" + ] + } + }, + "response": [] + }, + { + "name": "Create project with projectTemplateId (not existed)", + "request": { + "method": "POST", + "header": [ + { + "key": "Authorization", + "value": "Bearer {{jwt-token}}" + }, + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n\t\"param\": {\n\t\t\"name\": \"test project with projectTemplateId\",\n\t\t\"description\": \"Hello I am a test project with projectTemplateId\",\n\t\t\"type\": \"generic\",\n\t\t\"projectTemplateId\": 3000\n\t}\n}" + }, + "url": { + "raw": "{{api-url}}/v4/projects", + "host": [ + "{{api-url}}" + ], + "path": [ + "v4", + "projects" + ] + } + }, + "response": [] + } + ] } ] } \ No newline at end of file diff --git a/src/constants.js b/src/constants.js index 85aac5f3..ed5f4ba6 100644 --- a/src/constants.js +++ b/src/constants.js @@ -1,15 +1,4 @@ -export const PROJECT_TYPE = { - APP_DEV: 'app_dev', - GENERIC: 'generic', - VISUAL_PROTOTYPE: 'visual_prototype', - VISUAL_DESIGN: 'visual_design', - WEBSITE: 'website', - APP: 'app', - QUALITY_ASSURANCE: 'quality_assurance', - CHATBOT: 'chatbot', -}; - export const PROJECT_STATUS = { DRAFT: 'draft', IN_REVIEW: 'in_review', diff --git a/src/models/project.js b/src/models/project.js index 27b3a8e6..41da06ea 100644 --- a/src/models/project.js +++ b/src/models/project.js @@ -1,7 +1,7 @@ /* eslint-disable valid-jsdoc */ import _ from 'lodash'; -import { PROJECT_TYPE, PROJECT_STATUS, PROJECT_MEMBER_ROLE } from '../constants'; +import { PROJECT_STATUS, PROJECT_MEMBER_ROLE } from '../constants'; module.exports = function defineProject(sequelize, DataTypes) { const Project = sequelize.define('Project', { @@ -36,6 +36,7 @@ module.exports = function defineProject(sequelize, DataTypes) { cancelReason: DataTypes.STRING, version: DataTypes.STRING(15), templateId: DataTypes.BIGINT, + projectTemplateId: DataTypes.BIGINT, deletedAt: { type: DataTypes.DATE, allowNull: true }, createdAt: { type: DataTypes.DATE, defaultValue: DataTypes.NOW }, updatedAt: { type: DataTypes.DATE, defaultValue: DataTypes.NOW }, diff --git a/src/routes/projects/create.js b/src/routes/projects/create.js index 48ccfce9..a6547b07 100644 --- a/src/routes/projects/create.js +++ b/src/routes/projects/create.js @@ -3,9 +3,10 @@ import validate from 'express-validation'; import _ from 'lodash'; import Joi from 'joi'; +import config from 'config'; import models from '../../models'; -import { PROJECT_TYPE, PROJECT_MEMBER_ROLE, PROJECT_STATUS, USER_ROLE, EVENT, REGEX } from '../../constants'; +import { PROJECT_MEMBER_ROLE, PROJECT_STATUS, USER_ROLE, EVENT, REGEX } from '../../constants'; import util from '../../util'; import directProject from '../../services/directProject'; @@ -52,6 +53,7 @@ const createProjectValdiations = { groups: Joi.array().items(Joi.number().positive()), })).allow(null), templateId: Joi.number().positive(), + projectTemplateId: Joi.number().integer().positive(), version: Joi.string(), }).required(), }, @@ -81,7 +83,7 @@ module.exports = [ external: null, utm: null, }); - traverse(project).forEach(function (x) { + traverse(project).forEach(function (x) { // eslint-disable-line func-names if (this.isLeaf && typeof x === 'string') this.update(req.sanitize(x)); }); // override values @@ -99,12 +101,9 @@ module.exports = [ }); models.sequelize.transaction(() => { let newProject = null; + let projectTemplate; // Validate the project type - return models.ProjectType.findOne({ - where: { - key: project.type, - } - }) + return models.ProjectType.findOne({ where: { key: project.type } }) .then((projectType) => { if (!projectType) { // Not found @@ -113,15 +112,35 @@ module.exports = [ return Promise.reject(apiErr); } + return Promise.resolve(); + }) + // Validate the projectTemplateId + .then(() => { + if (project.projectTemplateId) { + return models.ProjectTemplate.findById(project.projectTemplateId) + .then((existingProjectTemplate) => { + if (!existingProjectTemplate) { + // Not found + const apiErr = new Error(`Project template not found for id ${project.projectTemplateId}`); + apiErr.status = 422; + return Promise.reject(apiErr); + } + + projectTemplate = existingProjectTemplate; + return Promise.resolve(); + }); + } + return Promise.resolve(); + }) + .then(() => // Create project - return models.Project + models.Project .create(project, { include: [{ model: models.ProjectMember, as: 'members', }], - }); - }) + })) .then((_newProject) => { newProject = _newProject; req.log.debug('new project created (id# %d, name: %s)', @@ -165,6 +184,59 @@ module.exports = [ newProject = _.omit(newProject, ['deletedAt', 'utm']); // add an empty attachments array newProject.attachments = []; + // add an empty phases array + newProject.phases = []; + + // Create phases and products + if (!projectTemplate) { + return Promise.resolve(); + } + + const phases = _.values(projectTemplate.phases); + return Promise.all(_.map(phases, phase => + // Create phase + models.ProjectPhase.create( + _.assign( + _.omit(phase, 'products'), + { + projectId: newProject.id, + updatedBy: req.authUser.userId, + createdBy: req.authUser.userId, + }, + ), + ) + .then((newPhase) => { + // Make sure number of products of per phase <= max value + const productCount = _.isArray(phase.products) ? phase.products.length : 0; + if (productCount > config.maxPhaseProductCount) { + const err = new Error('the number of products per phase cannot exceed ' + + `${config.maxPhaseProductCount}`); + err.status = 422; + throw err; + } + + // Create products + return models.PhaseProduct.bulkCreate(_.map(phase.products, product => + // productKey is just used for the JSON to be more human readable + // id need to map to templateId + _.assign(_.omit(product, ['id', 'productKey']), { + phaseId: newPhase.id, + projectId: newProject.id, + templateId: product.id, + updatedBy: req.authUser.userId, + createdBy: req.authUser.userId, + })), { returning: true }) + .then((products) => { + // Add phases and products to the project JSON, so they can be stored to ES later + const newPhaseJson = _.omit(newPhase.toJSON(), ['deletedAt', 'deletedBy']); + newPhaseJson.products = _.map(products, product => + _.omit(product.toJSON(), ['deletedAt', 'deletedBy'])); + newProject.phases.push(newPhaseJson); + return Promise.resolve(); + }); + }))); + }) + .then(() => { req.log.debug('Sending event to RabbitMQ bus for project %d', newProject.id); req.app.services.pubsub.publish(EVENT.ROUTING_KEY.PROJECT_DRAFT_CREATED, newProject, @@ -176,6 +248,7 @@ module.exports = [ res.status(201).json(util.wrapResponse(req.id, newProject, 1, 201)); }) .catch((err) => { + req.log.error(err.message); util.handleError('Error creating project', err, req, next); }); }); diff --git a/src/routes/projects/create.spec.js b/src/routes/projects/create.spec.js index 98a190f9..d6dfa5f1 100644 --- a/src/routes/projects/create.spec.js +++ b/src/routes/projects/create.spec.js @@ -12,8 +12,8 @@ import models from '../../models'; const should = chai.should(); -sinon.stub(RabbitMQService.prototype, 'init', () => {}); -sinon.stub(RabbitMQService.prototype, 'publish', () => {}); +sinon.stub(RabbitMQService.prototype, 'init', () => { }); +sinon.stub(RabbitMQService.prototype, 'publish', () => { }); describe('Project create', () => { before((done) => { @@ -24,7 +24,86 @@ describe('Project create', () => { displayName: 'Generic', createdBy: 1, updatedBy: 1, - } + }, + ])) + .then(() => models.ProjectTemplate.bulkCreate([ + { + id: 1, + name: 'template 1', + key: 'key 1', + category: 'category 1', + icon: 'http://example.com/icon1.ico', + question: 'question 1', + info: 'info 1', + aliases: [], + scope: {}, + phases: { + phase1: { + name: 'phase 1', + products: [ + { + id: 21, + name: 'product 1', + productKey: 'visual_design_prod1', + }, + { + id: 22, + name: 'product 2', + productKey: 'visual_design_prod2', + }, + ], + }, + }, + createdBy: 1, + updatedBy: 1, + }, + { + id: 3, + name: 'template 3', + key: 'key 3', + category: 'category 3', + icon: 'http://example.com/icon3.ico', + question: 'question 3', + info: 'info 3', + aliases: [], + scope: {}, + phases: { + 1: { + name: 'Design Stage', + status: 'open', + details: { + description: 'detailed description', + }, + products: [ + { + id: 21, + name: 'product 1', + productKey: 'visual_design_prod', + }, + ], + }, + 2: { + name: 'Development Stage', + status: 'open', + products: [ + { + id: 23, + name: 'product 2', + details: { + subDetails: 'subDetails 2', + }, + productKey: 'website_development', + }, + ], + }, + 3: { + name: 'QA Stage', + status: 'open', + }, + }, + createdBy: 1, + updatedBy: 2, + }, ])) .then(() => done()); }); @@ -102,6 +181,32 @@ describe('Project create', () => { .expect(422, done); }); + it('should return 422 if projectTemplateId does not exist', (done) => { + const invalidBody = _.cloneDeep(body); + invalidBody.param.projectTemplateId = 3000; + request(server) + .post('/v4/projects') + .set({ + Authorization: `Bearer ${testUtil.jwts.member}`, + }) + .send(invalidBody) + .expect('Content-Type', /json/) + .expect(422, done); + }); + + it('should return 422 if phaseProduct count exceeds max value', (done) => { + const invalidBody = _.cloneDeep(body); + invalidBody.param.projectTemplateId = 1; + request(server) + .post('/v4/projects') + .set({ + Authorization: `Bearer ${testUtil.jwts.member}`, + }) + .send(invalidBody) + .expect('Content-Type', /json/) + .expect(422, done); + }); + it('should return 201 if error to create direct project', (done) => { const mockHttpClient = _.merge(testUtil.mockHttpClient, { post: () => Promise.reject(new Error('error message')), @@ -178,5 +283,64 @@ describe('Project create', () => { } }); }); + + it('should return 201 if valid user and data (with projectTemplateId)', (done) => { + const mockHttpClient = _.merge(testUtil.mockHttpClient, { + post: () => Promise.resolve({ + status: 200, + data: { + id: 'requesterId', + version: 'v3', + result: { + success: true, + status: 200, + content: { + projectId: 128, + }, + }, + }, + }), + }); + sandbox.stub(util, 'getHttpClient', () => mockHttpClient); + request(server) + .post('/v4/projects') + .set({ + Authorization: `Bearer ${testUtil.jwts.member}`, + }) + .send(_.merge({ param: { projectTemplateId: 3 } }, body)) + .expect('Content-Type', /json/) + .expect(201) + .end((err, res) => { + if (err) { + done(err); + } else { + const resJson = res.body.result.content; + should.exist(resJson); + should.exist(resJson.billingAccountId); + should.exist(resJson.name); + resJson.directProjectId.should.be.eql(128); + resJson.status.should.be.eql('draft'); + resJson.type.should.be.eql(body.param.type); + resJson.members.should.have.lengthOf(1); + resJson.members[0].role.should.be.eql('customer'); + resJson.members[0].userId.should.be.eql(40051331); + resJson.members[0].projectId.should.be.eql(resJson.id); + resJson.members[0].isPrimary.should.be.truthy; + resJson.bookmarks.should.have.lengthOf(1); + resJson.bookmarks[0].title.should.be.eql('title1'); + resJson.bookmarks[0].address.should.be.eql('http://www.address.com'); + resJson.phases.should.have.lengthOf(3); + const phases = _.sortBy(resJson.phases, p => p.name); + phases[0].name.should.be.eql('Design Stage'); + phases[0].status.should.be.eql('open'); + phases[0].details.should.be.eql({ description: 'detailed description' }); + phases[0].products.should.have.lengthOf(1); + phases[0].products[0].name.should.be.eql('product 1'); + phases[0].products[0].templateId.should.be.eql(21); + server.services.pubsub.publish.calledWith('project.draft-created').should.be.true; + done(); + } + }); + }); }); }); diff --git a/src/routes/projects/update.js b/src/routes/projects/update.js index d0efccd6..8a9f4058 100644 --- a/src/routes/projects/update.js +++ b/src/routes/projects/update.js @@ -6,7 +6,6 @@ import { } from 'tc-core-library-js'; import models from '../../models'; import { - PROJECT_TYPE, PROJECT_STATUS, PROJECT_MEMBER_ROLE, EVENT, @@ -63,6 +62,7 @@ const updateProjectValdiations = { type: Joi.string().max(45), details: Joi.any(), memers: Joi.any(), + projectTemplateId: Joi.any().strip(), // ignore the project template id createdBy: Joi.any(), createdAt: Joi.any(), updatedBy: Joi.any(), @@ -127,7 +127,7 @@ module.exports = [ err.status = 422; next(err); } - }) + }); } else { next(); } @@ -142,7 +142,7 @@ module.exports = [ const projectId = _.parseInt(req.params.projectId); // prune any fields that cannot be updated directly updatedProps = _.omit(updatedProps, ['createdBy', 'createdAt', 'updatedBy', 'updatedAt', 'id']); - traverse(updatedProps).forEach(function (x) { + traverse(updatedProps).forEach(function (x) { // eslint-disable-line func-names if (x && this.isLeaf && typeof x === 'string') this.update(req.sanitize(x)); }); let previousValue; diff --git a/src/routes/projects/update.spec.js b/src/routes/projects/update.spec.js index e7b9abf0..4671e0c7 100644 --- a/src/routes/projects/update.spec.js +++ b/src/routes/projects/update.spec.js @@ -26,7 +26,7 @@ describe('Project', () => { displayName: 'Generic', createdBy: 1, updatedBy: 1, - } + }, ])) .then(() => done()); }); @@ -38,6 +38,7 @@ describe('Project', () => { const body = { param: { name: 'updatedProject name', + type: 'generic', }, }; let sandbox; @@ -331,7 +332,7 @@ describe('Project', () => { it('should return 422 if project type does not exist', (done) => { const mbody = { param: { - type: 'not_exist' + type: 'not_exist', }, }; request(server) @@ -394,10 +395,10 @@ describe('Project', () => { models.Project.update({ status: PROJECT_STATUS.CANCELLED, }, { - where: { - id: project1.id, - }, - }) + where: { + id: project1.id, + }, + }) .then(() => { const mbody = { param: { @@ -447,10 +448,10 @@ describe('Project', () => { models.Project.update({ status: PROJECT_STATUS.CANCELLED, }, { - where: { - id: project1.id, - }, - }) + where: { + id: project1.id, + }, + }) .then(() => { const mbody = { param: { @@ -500,10 +501,10 @@ describe('Project', () => { models.Project.update({ status: PROJECT_STATUS.CANCELLED, }, { - where: { - id: project1.id, - }, - }) + where: { + id: project1.id, + }, + }) .then(() => { const mbody = { param: { @@ -754,10 +755,10 @@ describe('Project', () => { models.Project.update({ status: PROJECT_STATUS.CANCELLED, }, { - where: { - id: project1.id, - }, - }) + where: { + id: project1.id, + }, + }) .then(() => { const mbody = { param: { diff --git a/src/tests/seed.js b/src/tests/seed.js index 2a66734f..a745ead0 100644 --- a/src/tests/seed.js +++ b/src/tests/seed.js @@ -162,16 +162,36 @@ models.sequelize.sync({ force: true }) phases: { 1: { name: 'Design Stage', + status: 'open', + details: { + description: 'detailed description', + }, products: [ - { id: 21, productKey: 'visual_design_prod' }, + { + id: 21, + name: 'product 1', + productKey: 'visual_design_prod', + }, ], }, 2: { name: 'Development Stage', + status: 'open', products: [ - { id: 23, productKey: 'website_development' }, + { + id: 23, + name: 'product 2', + details: { + subDetails: 'subDetails 2', + }, + productKey: 'website_development', + }, ], }, + 3: { + name: 'QA Stage', + status: 'open', + }, }, createdBy: 1, updatedBy: 2, diff --git a/swagger.yaml b/swagger.yaml index 6df120e1..7efa7613 100755 --- a/swagger.yaml +++ b/swagger.yaml @@ -862,6 +862,133 @@ paths: description: Product template successfully removed + /projectTypes: + get: + tags: + - projectType + operationId: findProjectTypes + security: + - Bearer: [] + description: Retreive all project types. All user roles can access this endpoint. + responses: + '403': + description: No permission or wrong token + schema: + $ref: "#/definitions/ErrorModel" + '200': + description: A list of project types + schema: + $ref: "#/definitions/ProjectTypeListResponse" + post: + tags: + - projectType + operationId: addProjectType + security: + - Bearer: [] + description: Create a project type. Only admin or connect admin can access this endpoint. + parameters: + - in: body + name: body + required: true + schema: + $ref: '#/definitions/ProjectTypeCreateBodyParam' + responses: + '403': + description: No permission or wrong token + schema: + $ref: "#/definitions/ErrorModel" + '201': + description: Returns the newly created project type + schema: + $ref: "#/definitions/ProjectTypeResponse" + '422': + description: Invalid input + schema: + $ref: "#/definitions/ErrorModel" + + /projectTypes/{key}: + get: + tags: + - projectType + description: Retrieve project type by id. All user roles can access this endpoint. + security: + - Bearer: [] + responses: + '404': + description: Not found + schema: + $ref: "#/definitions/ErrorModel" + '403': + description: No permission or wrong token + schema: + $ref: "#/definitions/ErrorModel" + '200': + description: a project type + schema: + $ref: "#/definitions/ProjectTypeResponse" + parameters: + - $ref: "#/parameters/keyParam" + operationId: getProjectType + + patch: + tags: + - projectType + operationId: updateProjectType + security: + - Bearer: [] + description: Update a project type. Only admin or connect admin can access this endpoint. + responses: + '403': + description: No permission or wrong token + schema: + $ref: "#/definitions/ErrorModel" + '404': + description: Not found + schema: + $ref: "#/definitions/ErrorModel" + '200': + description: Successfully updated project type. + schema: + $ref: "#/definitions/ProjectTypeResponse" + '422': + description: Invalid input + schema: + $ref: "#/definitions/ErrorModel" + default: + description: error payload + schema: + $ref: '#/definitions/ErrorModel' + parameters: + - $ref: "#/parameters/keyParam" + - name: body + in: body + required: true + schema: + $ref: "#/definitions/ProjectTypeBodyParam" + + delete: + tags: + - projectType + description: Remove an existing project type. Only admin or connect admin can access this endpoint. + security: + - Bearer: [] + parameters: + - $ref: "#/parameters/keyParam" + responses: + '403': + description: No permission or wrong token + schema: + $ref: "#/definitions/ErrorModel" + '404': + description: If project is not found + schema: + $ref: "#/definitions/ErrorModel" + '204': + description: Project type successfully removed + + + + parameters: projectIdParam: name: projectId @@ -894,6 +1021,12 @@ parameters: type: integer format: int64 minimum: 1 + keyParam: + name: key + in: path + description: project type key + required: true + type: string offsetParam: name: offset description: "number of items to skip. Defaults to 0" @@ -999,7 +1132,6 @@ definitions: type: type: string description: project type - enum: ["generic", "visual_design", "visual_prototype", "app_dev"] bookmarks: type: array items: @@ -1021,6 +1153,10 @@ definitions: type: string source: type: string + projectTemplateId: + description: the project template identifier + type: number + format: long NewProjectBodyParam: @@ -1110,7 +1246,6 @@ definitions: type: type: string description: project type - enum: ["app_dev", "generic", "visual_prototype", "visual_design"] status: type: string description: current state of the task @@ -1143,6 +1278,10 @@ definitions: $ref: "#/definitions/ProjectAttachment" details: $ref: "#/definitions/ProjectDetails" + projectTemplateId: + description: the project template identifier + type: number + format: long createdAt: type: string @@ -1975,4 +2114,128 @@ definitions: content: type: array items: - $ref: "#/definitions/PhaseProduct" \ No newline at end of file + $ref: "#/definitions/PhaseProduct" + + + + ProjectTypeRequest: + title: Project type request object + type: object + required: + - displayName + properties: + displayName: + type: string + description: the project type display name + + ProjectTypeBodyParam: + title: Project type body param + type: object + required: + - param + properties: + param: + $ref: "#/definitions/ProjectTypeRequest" + + ProjectTypeCreateRequest: + title: Project type creation request object + type: object + allOf: + - type: object + required: + - key + properties: + key: + type: string + description: the project type key + - $ref: "#/definitions/ProjectTypeRequest" + + ProjectTypeCreateBodyParam: + title: Project type creation body param + type: object + required: + - param + properties: + param: + $ref: "#/definitions/ProjectTypeCreateRequest" + + ProjectType: + title: Project type object + allOf: + - type: object + required: + - createdAt + - createdBy + - updatedAt + - updatedBy + properties: + key: + type: string + description: the project type key + createdAt: + type: string + description: Datetime (GMT) when object was created + readOnly: true + createdBy: + type: integer + format: int64 + description: READ-ONLY. User who created this object + readOnly: true + updatedAt: + type: string + description: READ-ONLY. Datetime (GMT) when object was updated + readOnly: true + updatedBy: + type: integer + format: int64 + description: READ-ONLY. User that last updated this object + readOnly: true + - $ref: "#/definitions/ProjectTypeCreateRequest" + + + ProjectTypeResponse: + title: Single project type response object + type: object + properties: + id: + type: string + description: unique id identifying the request + version: + type: string + result: + type: object + properties: + success: + type: boolean + status: + type: string + description: http status code + metadata: + $ref: "#/definitions/ResponseMetadata" + content: + $ref: "#/definitions/ProjectType" + + ProjectTypeListResponse: + title: Project type list response object + type: object + properties: + id: + type: string + readOnly: true + description: unique id identifying the request + version: + type: string + result: + type: object + properties: + success: + type: boolean + status: + type: string + description: http status code + metadata: + $ref: "#/definitions/ResponseMetadata" + content: + type: array + items: + $ref: "#/definitions/ProjectType" \ No newline at end of file From 5a8ab3ce46cd5bd93fbfb79992afa55561712601 Mon Sep 17 00:00:00 2001 From: ngoctay Date: Mon, 4 Jun 2018 16:18:36 +0700 Subject: [PATCH 3/5] Additiona fix for #86 #87 according to reviewer comments --- config/default.json | 8 +-- postman.json | 10 +-- src/models/project.js | 1 - src/routes/projects/create.js | 102 +++++++++++++++-------------- src/routes/projects/create.spec.js | 10 +-- src/routes/projects/update.js | 2 +- swagger.yaml | 4 +- 7 files changed, 69 insertions(+), 68 deletions(-) diff --git a/config/default.json b/config/default.json index 857a6add..05b3dfe5 100644 --- a/config/default.json +++ b/config/default.json @@ -39,9 +39,9 @@ "busApiToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJuYW1lIjoicHJvamVjdC1zZXJ2aWNlIiwiaWF0IjoxNTEyNzQ3MDgyLCJleHAiOjE1MjEzODcwODJ9.PHuNcFDaotGAL8RhQXQMdpL8yOKXxjB5DbBIodmt7RE", "HEALTH_CHECK_URL": "_health", "maxPhaseProductCount": 1, - "AUTH0_CLIENT_ID": "5fctfjaLJHdvM04kSrCcC8yn0I4t1JTd", - "AUTH0_CLIENT_SECRET": "GhvDENIrYXo-d8xQ10fxm9k7XSVg491vlpvolXyWNBmeBdhsA5BAq2mH4cAAYS0x", - "AUTH0_AUDIENCE": "https://www.topcoder.com", - "AUTH0_URL": "https://topcoder-newauth.auth0.com/oauth/token", + "AUTH0_CLIENT_ID": "", + "AUTH0_CLIENT_SECRET": "", + "AUTH0_AUDIENCE": "", + "AUTH0_URL": "", "TOKEN_CACHE_TIME": "" } diff --git a/postman.json b/postman.json index 1a07fb9f..1d64ee62 100644 --- a/postman.json +++ b/postman.json @@ -2839,11 +2839,11 @@ ] }, { - "name": "issue86 (create project with projectTemplateId)", + "name": "issue86 (create project with templateId)", "description": "", "item": [ { - "name": "Create project with projectTemplateId", + "name": "Create project with templateId", "request": { "method": "POST", "header": [ @@ -2858,7 +2858,7 @@ ], "body": { "mode": "raw", - "raw": "{\n\t\"param\": {\n\t\t\"name\": \"test project with projectTemplateId\",\n\t\t\"description\": \"Hello I am a test project with projectTemplateId\",\n\t\t\"type\": \"generic\",\n\t\t\"projectTemplateId\": 3\n\t}\n}" + "raw": "{\n \"param\": {\n \"name\": \"test project with templateId\",\n \"description\": \"Hello I am a test project with templateId\",\n \"type\": \"generic\",\n \"templateId\": 3\n }\n}" }, "url": { "raw": "{{api-url}}/v4/projects", @@ -2874,7 +2874,7 @@ "response": [] }, { - "name": "Create project with projectTemplateId (not existed)", + "name": "Create project with templateId (not existed)", "request": { "method": "POST", "header": [ @@ -2889,7 +2889,7 @@ ], "body": { "mode": "raw", - "raw": "{\n\t\"param\": {\n\t\t\"name\": \"test project with projectTemplateId\",\n\t\t\"description\": \"Hello I am a test project with projectTemplateId\",\n\t\t\"type\": \"generic\",\n\t\t\"projectTemplateId\": 3000\n\t}\n}" + "raw": "{\n\t\"param\": {\n\t\t\"name\": \"test project with templateId\",\n\t\t\"description\": \"Hello I am a test project with templateId\",\n\t\t\"type\": \"generic\",\n\t\t\"templateId\": 3000\n\t}\n}" }, "url": { "raw": "{{api-url}}/v4/projects", diff --git a/src/models/project.js b/src/models/project.js index 41da06ea..319d0048 100644 --- a/src/models/project.js +++ b/src/models/project.js @@ -36,7 +36,6 @@ module.exports = function defineProject(sequelize, DataTypes) { cancelReason: DataTypes.STRING, version: DataTypes.STRING(15), templateId: DataTypes.BIGINT, - projectTemplateId: DataTypes.BIGINT, deletedAt: { type: DataTypes.DATE, allowNull: true }, createdAt: { type: DataTypes.DATE, defaultValue: DataTypes.NOW }, updatedAt: { type: DataTypes.DATE, defaultValue: DataTypes.NOW }, diff --git a/src/routes/projects/create.js b/src/routes/projects/create.js index a6547b07..b27fb4c4 100644 --- a/src/routes/projects/create.js +++ b/src/routes/projects/create.js @@ -52,8 +52,7 @@ const createProjectValdiations = { users: Joi.array().items(Joi.number().positive()), groups: Joi.array().items(Joi.number().positive()), })).allow(null), - templateId: Joi.number().positive(), - projectTemplateId: Joi.number().integer().positive(), + templateId: Joi.number().integer().positive(), version: Joi.string(), }).required(), }, @@ -102,6 +101,7 @@ module.exports = [ models.sequelize.transaction(() => { let newProject = null; let projectTemplate; + const newPhases = []; // Validate the project type return models.ProjectType.findOne({ where: { key: project.type } }) .then((projectType) => { @@ -114,14 +114,14 @@ module.exports = [ return Promise.resolve(); }) - // Validate the projectTemplateId + // Validate the templateId .then(() => { - if (project.projectTemplateId) { - return models.ProjectTemplate.findById(project.projectTemplateId) + if (project.templateId) { + return models.ProjectTemplate.findById(project.templateId) .then((existingProjectTemplate) => { if (!existingProjectTemplate) { // Not found - const apiErr = new Error(`Project template not found for id ${project.projectTemplateId}`); + const apiErr = new Error(`Project template not found for id ${project.templateId}`); apiErr.status = 422; return Promise.reject(apiErr); } @@ -143,51 +143,9 @@ module.exports = [ })) .then((_newProject) => { newProject = _newProject; - req.log.debug('new project created (id# %d, name: %s)', - newProject.id, newProject.name); - // create direct project with name and description - const body = { - projectName: newProject.name, - projectDescription: newProject.description, - }; - // billingAccountId is optional field - if (newProject.billingAccountId) { - body.billingAccountId = newProject.billingAccountId; - } - req.log.debug('creating project history for project %d', newProject.id); - // add to project history - models.ProjectHistory.create({ - projectId: _newProject.id, - status: PROJECT_STATUS.DRAFT, - cancelReason: null, - updatedBy: req.authUser.userId, - }).then(() => req.log.debug('project history created for project %d', newProject.id)) - .catch(() => req.log.error('project history failed for project %d', newProject.id)); - req.log.debug('creating direct project for project %d', newProject.id); - return directProject.createDirectProject(req, body) - .then((resp) => { - newProject.directProjectId = resp.data.result.content.projectId; - return newProject.save(); - }) - .then(() => newProject.reload(newProject.id)) - .catch((err) => { - // log the error and continue - req.log.error('Error creating direct project'); - req.log.error(err); - return Promise.resolve(); - }); - // return Promise.resolve(); - }) - .then(() => { - newProject = newProject.get({ plain: true }); - // remove utm details & deletedAt field - newProject = _.omit(newProject, ['deletedAt', 'utm']); - // add an empty attachments array - newProject.attachments = []; - // add an empty phases array - newProject.phases = []; // Create phases and products + // This needs to be done before creating direct project if (!projectTemplate) { return Promise.resolve(); } @@ -231,12 +189,56 @@ module.exports = [ const newPhaseJson = _.omit(newPhase.toJSON(), ['deletedAt', 'deletedBy']); newPhaseJson.products = _.map(products, product => _.omit(product.toJSON(), ['deletedAt', 'deletedBy'])); - newProject.phases.push(newPhaseJson); + newPhases.push(newPhaseJson); return Promise.resolve(); }); }))); }) .then(() => { + req.log.debug('new project created (id# %d, name: %s)', + newProject.id, newProject.name); + // create direct project with name and description + const body = { + projectName: newProject.name, + projectDescription: newProject.description, + }; + // billingAccountId is optional field + if (newProject.billingAccountId) { + body.billingAccountId = newProject.billingAccountId; + } + req.log.debug('creating project history for project %d', newProject.id); + // add to project history + models.ProjectHistory.create({ + projectId: newProject.id, + status: PROJECT_STATUS.DRAFT, + cancelReason: null, + updatedBy: req.authUser.userId, + }).then(() => req.log.debug('project history created for project %d', newProject.id)) + .catch(() => req.log.error('project history failed for project %d', newProject.id)); + req.log.debug('creating direct project for project %d', newProject.id); + return directProject.createDirectProject(req, body) + .then((resp) => { + newProject.directProjectId = resp.data.result.content.projectId; + return newProject.save(); + }) + .then(() => newProject.reload(newProject.id)) + .catch((err) => { + // log the error and continue + req.log.error('Error creating direct project'); + req.log.error(err); + return Promise.resolve(); + }); + // return Promise.resolve(); + }) + .then(() => { + newProject = newProject.get({ plain: true }); + // remove utm details & deletedAt field + newProject = _.omit(newProject, ['deletedAt', 'utm']); + // add an empty attachments array + newProject.attachments = []; + // set phases array + newProject.phases = newPhases; + req.log.debug('Sending event to RabbitMQ bus for project %d', newProject.id); req.app.services.pubsub.publish(EVENT.ROUTING_KEY.PROJECT_DRAFT_CREATED, newProject, diff --git a/src/routes/projects/create.spec.js b/src/routes/projects/create.spec.js index d6dfa5f1..c8f594dc 100644 --- a/src/routes/projects/create.spec.js +++ b/src/routes/projects/create.spec.js @@ -181,9 +181,9 @@ describe('Project create', () => { .expect(422, done); }); - it('should return 422 if projectTemplateId does not exist', (done) => { + it('should return 422 if templateId does not exist', (done) => { const invalidBody = _.cloneDeep(body); - invalidBody.param.projectTemplateId = 3000; + invalidBody.param.templateId = 3000; request(server) .post('/v4/projects') .set({ @@ -196,7 +196,7 @@ describe('Project create', () => { it('should return 422 if phaseProduct count exceeds max value', (done) => { const invalidBody = _.cloneDeep(body); - invalidBody.param.projectTemplateId = 1; + invalidBody.param.templateId = 1; request(server) .post('/v4/projects') .set({ @@ -284,7 +284,7 @@ describe('Project create', () => { }); }); - it('should return 201 if valid user and data (with projectTemplateId)', (done) => { + it('should return 201 if valid user and data (with templateId)', (done) => { const mockHttpClient = _.merge(testUtil.mockHttpClient, { post: () => Promise.resolve({ status: 200, @@ -307,7 +307,7 @@ describe('Project create', () => { .set({ Authorization: `Bearer ${testUtil.jwts.member}`, }) - .send(_.merge({ param: { projectTemplateId: 3 } }, body)) + .send(_.merge({ param: { templateId: 3 } }, body)) .expect('Content-Type', /json/) .expect(201) .end((err, res) => { diff --git a/src/routes/projects/update.js b/src/routes/projects/update.js index 8a9f4058..fc17b451 100644 --- a/src/routes/projects/update.js +++ b/src/routes/projects/update.js @@ -62,7 +62,7 @@ const updateProjectValdiations = { type: Joi.string().max(45), details: Joi.any(), memers: Joi.any(), - projectTemplateId: Joi.any().strip(), // ignore the project template id + templateId: Joi.any().strip(), // ignore the template id createdBy: Joi.any(), createdAt: Joi.any(), updatedBy: Joi.any(), diff --git a/swagger.yaml b/swagger.yaml index 7efa7613..000f7285 100755 --- a/swagger.yaml +++ b/swagger.yaml @@ -1153,7 +1153,7 @@ definitions: type: string source: type: string - projectTemplateId: + templateId: description: the project template identifier type: number format: long @@ -1278,7 +1278,7 @@ definitions: $ref: "#/definitions/ProjectAttachment" details: $ref: "#/definitions/ProjectDetails" - projectTemplateId: + templateId: description: the project template identifier type: number format: long From 4203102174bca522090cfa9c43571b67ecb3f2a2 Mon Sep 17 00:00:00 2001 From: ngoctay Date: Mon, 4 Jun 2018 23:11:55 +0700 Subject: [PATCH 4/5] #86 #87 - wrap a single method which creates phases and products --- src/routes/projects/create.js | 117 +++++++++++++++++++--------------- 1 file changed, 65 insertions(+), 52 deletions(-) diff --git a/src/routes/projects/create.js b/src/routes/projects/create.js index b27fb4c4..6d7e67c3 100644 --- a/src/routes/projects/create.js +++ b/src/routes/projects/create.js @@ -58,6 +58,66 @@ const createProjectValdiations = { }, }; +/** + * Create project phases and products. This needs to be done before creating direct project. + * @param {Object} req the request + * @param {Object} project the project + * @param {Object} projectTemplate the project template + * @returns {Promise} the promise that resolves to the created phases + */ +function createPhases(req, project, projectTemplate) { + const newPhases = []; + + if (!projectTemplate) { + return Promise.resolve(newPhases); + } + + const phases = _.values(projectTemplate.phases); + return Promise.all(_.map(phases, phase => + // Create phase + models.ProjectPhase.create( + _.assign( + _.omit(phase, 'products'), + { + projectId: project.id, + updatedBy: req.authUser.userId, + createdBy: req.authUser.userId, + }, + ), + ) + .then((newPhase) => { + // Make sure number of products of per phase <= max value + const productCount = _.isArray(phase.products) ? phase.products.length : 0; + if (productCount > config.maxPhaseProductCount) { + const err = new Error('the number of products per phase cannot exceed ' + + `${config.maxPhaseProductCount}`); + err.status = 422; + throw err; + } + + // Create products + return models.PhaseProduct.bulkCreate(_.map(phase.products, product => + // productKey is just used for the JSON to be more human readable + // id need to map to templateId + _.assign(_.omit(product, ['id', 'productKey']), { + phaseId: newPhase.id, + projectId: project.id, + templateId: product.id, + updatedBy: req.authUser.userId, + createdBy: req.authUser.userId, + })), { returning: true }) + .then((products) => { + // Add phases and products to the project JSON, so they can be stored to ES later + const newPhaseJson = _.omit(newPhase.toJSON(), ['deletedAt', 'deletedBy']); + newPhaseJson.products = _.map(products, product => + _.omit(product.toJSON(), ['deletedAt', 'deletedBy'])); + newPhases.push(newPhaseJson); + return Promise.resolve(); + }); + }))) + .then(() => Promise.resolve(newPhases)); +} + module.exports = [ // handles request validations validate(createProjectValdiations), @@ -101,7 +161,7 @@ module.exports = [ models.sequelize.transaction(() => { let newProject = null; let projectTemplate; - const newPhases = []; + let newPhases; // Validate the project type return models.ProjectType.findOne({ where: { key: project.type } }) .then((projectType) => { @@ -143,58 +203,11 @@ module.exports = [ })) .then((_newProject) => { newProject = _newProject; - - // Create phases and products - // This needs to be done before creating direct project - if (!projectTemplate) { - return Promise.resolve(); - } - - const phases = _.values(projectTemplate.phases); - return Promise.all(_.map(phases, phase => - // Create phase - models.ProjectPhase.create( - _.assign( - _.omit(phase, 'products'), - { - projectId: newProject.id, - updatedBy: req.authUser.userId, - createdBy: req.authUser.userId, - }, - ), - ) - .then((newPhase) => { - // Make sure number of products of per phase <= max value - const productCount = _.isArray(phase.products) ? phase.products.length : 0; - if (productCount > config.maxPhaseProductCount) { - const err = new Error('the number of products per phase cannot exceed ' + - `${config.maxPhaseProductCount}`); - err.status = 422; - throw err; - } - - // Create products - return models.PhaseProduct.bulkCreate(_.map(phase.products, product => - // productKey is just used for the JSON to be more human readable - // id need to map to templateId - _.assign(_.omit(product, ['id', 'productKey']), { - phaseId: newPhase.id, - projectId: newProject.id, - templateId: product.id, - updatedBy: req.authUser.userId, - createdBy: req.authUser.userId, - })), { returning: true }) - .then((products) => { - // Add phases and products to the project JSON, so they can be stored to ES later - const newPhaseJson = _.omit(newPhase.toJSON(), ['deletedAt', 'deletedBy']); - newPhaseJson.products = _.map(products, product => - _.omit(product.toJSON(), ['deletedAt', 'deletedBy'])); - newPhases.push(newPhaseJson); - return Promise.resolve(); - }); - }))); + return createPhases(req, newProject, projectTemplate); }) - .then(() => { + .then((phases) => { + newPhases = phases; + req.log.debug('new project created (id# %d, name: %s)', newProject.id, newProject.name); // create direct project with name and description From 2fc571c1c7c856062e7d7be45ab8eda79da56d5f Mon Sep 17 00:00:00 2001 From: ngoctay Date: Wed, 6 Jun 2018 09:03:30 +0700 Subject: [PATCH 5/5] Wrap creating project, phases, and products in a single method --- src/routes/projects/create.js | 134 +++++++++++++++++----------------- 1 file changed, 69 insertions(+), 65 deletions(-) diff --git a/src/routes/projects/create.js b/src/routes/projects/create.js index 6d7e67c3..96e830ec 100644 --- a/src/routes/projects/create.js +++ b/src/routes/projects/create.js @@ -59,63 +59,77 @@ const createProjectValdiations = { }; /** - * Create project phases and products. This needs to be done before creating direct project. + * Create the project, project phases and products. This needs to be done before creating direct project. * @param {Object} req the request * @param {Object} project the project * @param {Object} projectTemplate the project template - * @returns {Promise} the promise that resolves to the created phases + * @returns {Promise} the promise that resolves to the created project and phases */ -function createPhases(req, project, projectTemplate) { - const newPhases = []; +function createProjectAndPhases(req, project, projectTemplate) { + const result = { + newProject: null, + newPhases: [], + }; - if (!projectTemplate) { - return Promise.resolve(newPhases); - } + // Create project + return models.Project.create(project, { + include: [{ + model: models.ProjectMember, + as: 'members', + }], + }) + .then((newProject) => { + result.newProject = newProject; - const phases = _.values(projectTemplate.phases); - return Promise.all(_.map(phases, phase => - // Create phase - models.ProjectPhase.create( - _.assign( - _.omit(phase, 'products'), - { - projectId: project.id, - updatedBy: req.authUser.userId, - createdBy: req.authUser.userId, - }, - ), - ) - .then((newPhase) => { - // Make sure number of products of per phase <= max value - const productCount = _.isArray(phase.products) ? phase.products.length : 0; - if (productCount > config.maxPhaseProductCount) { - const err = new Error('the number of products per phase cannot exceed ' + - `${config.maxPhaseProductCount}`); - err.status = 422; - throw err; - } + if (!projectTemplate) { + return Promise.resolve(result); + } - // Create products - return models.PhaseProduct.bulkCreate(_.map(phase.products, product => - // productKey is just used for the JSON to be more human readable - // id need to map to templateId - _.assign(_.omit(product, ['id', 'productKey']), { - phaseId: newPhase.id, - projectId: project.id, - templateId: product.id, - updatedBy: req.authUser.userId, - createdBy: req.authUser.userId, - })), { returning: true }) - .then((products) => { - // Add phases and products to the project JSON, so they can be stored to ES later - const newPhaseJson = _.omit(newPhase.toJSON(), ['deletedAt', 'deletedBy']); - newPhaseJson.products = _.map(products, product => - _.omit(product.toJSON(), ['deletedAt', 'deletedBy'])); - newPhases.push(newPhaseJson); - return Promise.resolve(); - }); - }))) - .then(() => Promise.resolve(newPhases)); + const phases = _.values(projectTemplate.phases); + return Promise.all(_.map(phases, phase => + // Create phase + models.ProjectPhase.create( + _.assign( + _.omit(phase, 'products'), + { + projectId: project.id, + updatedBy: req.authUser.userId, + createdBy: req.authUser.userId, + }, + ), + ) + .then((newPhase) => { + // Make sure number of products of per phase <= max value + const productCount = _.isArray(phase.products) ? phase.products.length : 0; + if (productCount > config.maxPhaseProductCount) { + const err = new Error('the number of products per phase cannot exceed ' + + `${config.maxPhaseProductCount}`); + err.status = 422; + throw err; + } + + // Create products + return models.PhaseProduct.bulkCreate(_.map(phase.products, product => + // productKey is just used for the JSON to be more human readable + // id need to map to templateId + _.assign(_.omit(product, ['id', 'productKey']), { + phaseId: newPhase.id, + projectId: project.id, + templateId: product.id, + updatedBy: req.authUser.userId, + createdBy: req.authUser.userId, + })), { returning: true }) + .then((products) => { + // Add phases and products to the project JSON, so they can be stored to ES later + const newPhaseJson = _.omit(newPhase.toJSON(), ['deletedAt', 'deletedBy']); + newPhaseJson.products = _.map(products, product => + _.omit(product.toJSON(), ['deletedAt', 'deletedBy'])); + result.newPhases.push(newPhaseJson); + return Promise.resolve(); + }); + }))); + }) + .then(() => Promise.resolve(result)); } module.exports = [ @@ -192,21 +206,11 @@ module.exports = [ } return Promise.resolve(); }) - .then(() => - // Create project - models.Project - .create(project, { - include: [{ - model: models.ProjectMember, - as: 'members', - }], - })) - .then((_newProject) => { - newProject = _newProject; - return createPhases(req, newProject, projectTemplate); - }) - .then((phases) => { - newPhases = phases; + // Create project and phases + .then(() => createProjectAndPhases(req, project, projectTemplate)) + .then((createdProjectAndPhases) => { + newProject = createdProjectAndPhases.newProject; + newPhases = createdProjectAndPhases.newPhases; req.log.debug('new project created (id# %d, name: %s)', newProject.id, newProject.name);