From be01938841eea0e625a8d64e99ef34c94adeb2f0 Mon Sep 17 00:00:00 2001 From: Paulo Vitor Magacho Date: Thu, 24 May 2018 09:12:18 -0300 Subject: [PATCH 1/3] Merge from phases and products challenge --- .circleci/config.yml | 2 +- config/default.json | 3 +- src/models/phaseProduct.js | 45 ++++++ src/models/project.js | 1 + src/models/projectPhase.js | 105 +++++++++++++ src/permissions/index.js | 6 + src/routes/index.js | 17 ++ src/routes/phaseProducts/create.js | 102 ++++++++++++ src/routes/phaseProducts/create.spec.js | 185 ++++++++++++++++++++++ src/routes/phaseProducts/delete.js | 43 ++++++ src/routes/phaseProducts/delete.spec.js | 129 ++++++++++++++++ src/routes/phaseProducts/get.js | 36 +++++ src/routes/phaseProducts/get.spec.js | 147 ++++++++++++++++++ src/routes/phaseProducts/list.js | 41 +++++ src/routes/phaseProducts/list.spec.js | 129 ++++++++++++++++ src/routes/phaseProducts/update.js | 63 ++++++++ src/routes/phaseProducts/update.spec.js | 178 +++++++++++++++++++++ src/routes/phases/create.js | 69 +++++++++ src/routes/phases/create.spec.js | 196 ++++++++++++++++++++++++ src/routes/phases/delete.js | 42 +++++ src/routes/phases/delete.spec.js | 103 +++++++++++++ src/routes/phases/get.js | 31 ++++ src/routes/phases/get.spec.js | 121 +++++++++++++++ src/routes/phases/list.js | 74 +++++++++ src/routes/phases/list.spec.js | 102 ++++++++++++ src/routes/phases/update.js | 83 ++++++++++ src/routes/phases/update.spec.js | 167 ++++++++++++++++++++ 27 files changed, 2218 insertions(+), 2 deletions(-) create mode 100644 src/models/phaseProduct.js create mode 100644 src/models/projectPhase.js create mode 100644 src/routes/phaseProducts/create.js create mode 100644 src/routes/phaseProducts/create.spec.js create mode 100644 src/routes/phaseProducts/delete.js create mode 100644 src/routes/phaseProducts/delete.spec.js create mode 100644 src/routes/phaseProducts/get.js create mode 100644 src/routes/phaseProducts/get.spec.js create mode 100644 src/routes/phaseProducts/list.js create mode 100644 src/routes/phaseProducts/list.spec.js create mode 100644 src/routes/phaseProducts/update.js create mode 100644 src/routes/phaseProducts/update.spec.js create mode 100644 src/routes/phases/create.js create mode 100644 src/routes/phases/create.spec.js create mode 100644 src/routes/phases/delete.js create mode 100644 src/routes/phases/delete.spec.js create mode 100644 src/routes/phases/get.js create mode 100644 src/routes/phases/get.spec.js create mode 100644 src/routes/phases/list.js create mode 100644 src/routes/phases/list.spec.js create mode 100644 src/routes/phases/update.js create mode 100644 src/routes/phases/update.spec.js diff --git a/.circleci/config.yml b/.circleci/config.yml index cfc1f527..4fea1158 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -76,7 +76,7 @@ workflows: - test filters: branches: - only: [dev, 'feature/db-lock-issue'] + only: dev - deployProd: requires: - test diff --git a/config/default.json b/config/default.json index 2358f4a0..9cd04951 100644 --- a/config/default.json +++ b/config/default.json @@ -37,5 +37,6 @@ "jwksUri": "", "busApiUrl": "http://api.topcoder-dev.com", "busApiToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJuYW1lIjoicHJvamVjdC1zZXJ2aWNlIiwiaWF0IjoxNTEyNzQ3MDgyLCJleHAiOjE1MjEzODcwODJ9.PHuNcFDaotGAL8RhQXQMdpL8yOKXxjB5DbBIodmt7RE", - "HEALTH_CHECK_URL": "_health" + "HEALTH_CHECK_URL": "_health", + "maxPhaseProductCount": 1 } diff --git a/src/models/phaseProduct.js b/src/models/phaseProduct.js new file mode 100644 index 00000000..da7a5bc0 --- /dev/null +++ b/src/models/phaseProduct.js @@ -0,0 +1,45 @@ + + +module.exports = function definePhaseProduct(sequelize, DataTypes) { + const PhaseProduct = sequelize.define('PhaseProduct', { + id: { type: DataTypes.BIGINT, primaryKey: true, autoIncrement: true }, + name: { type: DataTypes.STRING, allowNull: true }, + projectId: DataTypes.BIGINT, + directProjectId: DataTypes.BIGINT, + billingAccountId: DataTypes.BIGINT, + // TODO: associate this with product_template + templateId: { type: DataTypes.BIGINT, defaultValue: 0 }, + type: { type: DataTypes.STRING, allowNull: true }, + estimatedPrice: { type: DataTypes.DOUBLE, defaultValue: 0.0 }, + actualPrice: { type: DataTypes.DOUBLE, defaultValue: 0.0 }, + details: { type: DataTypes.JSON, defaultValue: '' }, + + 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: 'phase_products', + paranoid: false, + timestamps: true, + updatedAt: 'updatedAt', + createdAt: 'createdAt', + deletedAt: 'deletedAt', + indexes: [], + classMethods: { + getActivePhaseProducts(phaseId) { + return this.findAll({ + where: { + deletedAt: { $eq: null }, + phaseId, + }, + raw: true, + }); + }, + }, + }); + + return PhaseProduct; +}; diff --git a/src/models/project.js b/src/models/project.js index 0c832e5f..ae6724ce 100644 --- a/src/models/project.js +++ b/src/models/project.js @@ -92,6 +92,7 @@ module.exports = function defineProject(sequelize, DataTypes) { associate: (models) => { Project.hasMany(models.ProjectMember, { as: 'members', foreignKey: 'projectId' }); Project.hasMany(models.ProjectAttachment, { as: 'attachments', foreignKey: 'projectId' }); + Project.hasMany(models.ProjectPhase, { as: 'phases', foreignKey: 'projectId' }); }, /** diff --git a/src/models/projectPhase.js b/src/models/projectPhase.js new file mode 100644 index 00000000..b855f44d --- /dev/null +++ b/src/models/projectPhase.js @@ -0,0 +1,105 @@ +/* eslint-disable valid-jsdoc */ + +import _ from 'lodash'; + +module.exports = function defineProjectPhase(sequelize, DataTypes) { + const ProjectPhase = sequelize.define('ProjectPhase', { + id: { type: DataTypes.BIGINT, primaryKey: true, autoIncrement: true }, + name: { type: DataTypes.STRING, allowNull: true }, + status: { type: DataTypes.STRING, allowNull: true }, + startDate: { type: DataTypes.DATE, allowNull: true }, + endDate: { type: DataTypes.DATE, allowNull: true }, + budget: { type: DataTypes.DOUBLE, defaultValue: 0.0 }, + progress: { type: DataTypes.DOUBLE, defaultValue: 0.0 }, + details: { type: DataTypes.JSON, defaultValue: '' }, + + 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_phases', + paranoid: false, + timestamps: true, + updatedAt: 'updatedAt', + createdAt: 'createdAt', + deletedAt: 'deletedAt', + indexes: [], + classMethods: { + getActiveProjectPhases(projectId) { + return this.findAll({ + where: { + deletedAt: { $eq: null }, + projectId, + }, + raw: true, + }); + }, + associate: (models) => { + ProjectPhase.hasMany(models.PhaseProduct, { as: 'products', foreignKey: 'phaseId' }); + }, + /** + * Search name or status + * @param parameters the parameters + * - filters: the filters contains keyword + * - order: the order + * - limit: the limit + * - offset: the offset + * - attributes: the attributes to get + * @param log the request log + * @return the result rows and count + */ + searchText(parameters, log) { + // special handling for keyword filter + let query = '1=1 '; + if (_.has(parameters.filters, 'id')) { + if (_.isObject(parameters.filters.id)) { + if (parameters.filters.id.$in.length === 0) { + parameters.filters.id.$in.push(-1); + } + query += `AND id IN (${parameters.filters.id.$in}) `; + } else if (_.isString(parameters.filters.id) || _.isNumber(parameters.filters.id)) { + query += `AND id = ${parameters.filters.id} `; + } + } + if (_.has(parameters.filters, 'status')) { + const statusFilter = parameters.filters.status; + if (_.isObject(statusFilter)) { + const statuses = statusFilter.$in.join("','"); + query += `AND status IN ('${statuses}') `; + } else if (_.isString(statusFilter)) { + query += `AND status ='${statusFilter}'`; + } + } + if (_.has(parameters.filters, 'name')) { + query += `AND name like '%${parameters.filters.name}%' `; + } + + const attributesStr = `"${parameters.attributes.join('","')}"`; + const orderStr = `"${parameters.order[0][0]}" ${parameters.order[0][1]}`; + + // select count of project_phases + return sequelize.query(`SELECT COUNT(1) FROM project_phases WHERE ${query}`, + { type: sequelize.QueryTypes.SELECT, + logging: (str) => { log.debug(str); }, + raw: true, + }) + .then((fcount) => { + const count = fcount[0].count; + // select project attributes + return sequelize.query(`SELECT ${attributesStr} FROM project_phases WHERE ${query} ORDER BY ` + + ` ${orderStr} LIMIT ${parameters.limit} OFFSET ${parameters.offset}`, + { type: sequelize.QueryTypes.SELECT, + logging: (str) => { log.debug(str); }, + raw: true, + }) + .then(phases => ({ rows: phases, count })); + }); + }, + }, + }); + + return ProjectPhase; +}; diff --git a/src/permissions/index.js b/src/permissions/index.js index e0797b3c..3fc2a2a5 100644 --- a/src/permissions/index.js +++ b/src/permissions/index.js @@ -23,4 +23,10 @@ module.exports = () => { Authorizer.setPolicy('project.downloadAttachment', projectView); Authorizer.setPolicy('project.updateMember', projectEdit); Authorizer.setPolicy('project.admin', projectAdmin); + Authorizer.setPolicy('project.addProjectPhase', projectEdit); + Authorizer.setPolicy('project.updateProjectPhase', projectEdit); + Authorizer.setPolicy('project.deleteProjectPhase', projectEdit); + Authorizer.setPolicy('project.addPhaseProduct', projectEdit); + Authorizer.setPolicy('project.updatePhaseProduct', projectEdit); + Authorizer.setPolicy('project.deletePhaseProduct', projectEdit); }; diff --git a/src/routes/index.js b/src/routes/index.js index 47a51502..4c7291a5 100644 --- a/src/routes/index.js +++ b/src/routes/index.js @@ -64,6 +64,23 @@ router.route('/v4/projects/:projectId(\\d+)/attachments/:id(\\d+)') .patch(require('./attachments/update')) .delete(require('./attachments/delete')); +router.route('/v4/projects/:projectId(\\d+)/phases') + .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')); + +router.route('/v4/projects/:projectId(\\d+)/phases/:phaseId(\\d+)/products') + .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')); // register error handler router.use((err, req, res, next) => { // eslint-disable-line no-unused-vars diff --git a/src/routes/phaseProducts/create.js b/src/routes/phaseProducts/create.js new file mode 100644 index 00000000..5a67ef01 --- /dev/null +++ b/src/routes/phaseProducts/create.js @@ -0,0 +1,102 @@ + +import validate from 'express-validation'; +import _ from 'lodash'; +import config from 'config'; +import Joi from 'joi'; + +import models from '../../models'; +import util from '../../util'; + +const permissions = require('tc-core-library-js').middleware.permissions; + +const addPhaseProductValidations = { + body: { + param: Joi.object().keys({ + name: Joi.string().required(), + type: Joi.string().required(), + templateId: Joi.number().optional(), + estimatedPrice: Joi.number().positive().optional(), + actualPrice: Joi.number().positive().optional(), + details: Joi.any().optional(), + }).required(), + }, +}; + +module.exports = [ + // validate request payload + validate(addPhaseProductValidations), + // check permission + permissions('project.addPhaseProduct'), + // do the real work + (req, res, next) => { + const projectId = _.parseInt(req.params.projectId); + const phaseId = _.parseInt(req.params.phaseId); + + const data = req.body.param; + // default values + _.assign(data, { + createdBy: req.authUser.userId, + updatedBy: req.authUser.userId, + }); + + let newPhaseProduct = null; + models.sequelize.transaction(() => models.Project.findOne({ + where: { id: projectId, deletedAt: { $eq: null } }, + raw: true, + }).then((existingProject) => { + // make sure project exists + if (!existingProject) { + const err = new Error(`project not found for project id ${projectId}`); + err.status = 404; + throw err; + } + _.assign(data, { + projectId, + directProjectId: existingProject.directProjectId, + billingAccountId: existingProject.billingAccountId, + }); + + return models.ProjectPhase.findOne({ + where: { id: phaseId, projectId, deletedAt: { $eq: null } }, + raw: true, + }); + }).then((existingPhase) => { + // make sure phase exists + if (!existingPhase) { + const err = new Error(`project phase not found for project id ${projectId}` + + ` and phase id ${phaseId}`); + err.status = 404; + throw err; + } + _.assign(data, { + phaseId, + }); + + return models.PhaseProduct.count({ + where: { + projectId, + phaseId, + deletedAt: { $eq: null }, + }, + raw: true, + }); + }).then((productCount) => { + // make sure number of products of per phase <= max value + if (productCount >= config.maxPhaseProductCount) { + const err = new Error('the number of products per phase cannot exceed ' + + `${config.maxPhaseProductCount}`); + err.status = 400; + throw err; + } + return models.PhaseProduct.create(data); + }) + .then((_newPhaseProduct) => { + newPhaseProduct = _.cloneDeep(_newPhaseProduct); + req.log.debug('new phase product created (id# %d, name: %s)', + newPhaseProduct.id, newPhaseProduct.name); + newPhaseProduct = newPhaseProduct.get({ plain: true }); + newPhaseProduct = _.omit(newPhaseProduct, ['deletedAt', 'utm']); + res.status(201).json(util.wrapResponse(req.id, newPhaseProduct, 1, 201)); + })).catch((err) => { next(err); }); + }, +]; diff --git a/src/routes/phaseProducts/create.spec.js b/src/routes/phaseProducts/create.spec.js new file mode 100644 index 00000000..95a6d1ca --- /dev/null +++ b/src/routes/phaseProducts/create.spec.js @@ -0,0 +1,185 @@ +/* eslint-disable no-unused-expressions */ +import _ from 'lodash'; +import chai from 'chai'; +import request from 'supertest'; +import server from '../../app'; +import models from '../../models'; +import testUtil from '../../tests/util'; + +const should = chai.should(); + +const body = { + name: 'test phase product', + type: 'product1', + estimatedPrice: 20.0, + actualPrice: 1.23456, + details: { + message: 'This can be any json', + }, +}; + +describe('Phase Products', () => { + let projectId; + let phaseId; + before((done) => { + // mocks + testUtil.clearDb() + .then(() => { + models.Project.create({ + type: 'generic', + billingAccountId: 1, + name: 'test1', + description: 'test project1', + status: 'draft', + details: {}, + createdBy: 1, + updatedBy: 1, + }).then((p) => { + projectId = p.id; + // create members + models.ProjectMember.create({ + userId: 40051332, + projectId, + role: 'copilot', + isPrimary: true, + createdBy: 1, + updatedBy: 1, + }).then(() => { + models.ProjectPhase.create({ + name: 'test project phase', + status: 'active', + startDate: '2018-05-15T00:00:00Z', + endDate: '2018-05-15T12:00:00Z', + budget: 20.0, + progress: 1.23456, + details: { + message: 'This can be any json', + }, + createdBy: 1, + updatedBy: 1, + projectId, + }).then((phase) => { + phaseId = phase.id; + done(); + }); + }); + }); + }); + }); + + after((done) => { + testUtil.clearDb(done); + }); + + describe('POST /projects/{projectId}/phases/{phaseId}/products', () => { + it('should return 403 if user does not have permissions', (done) => { + request(server) + .post(`/v4/projects/${projectId}/phases/${phaseId}/products`) + .set({ + Authorization: `Bearer ${testUtil.jwts.member}`, + }) + .send({ param: body }) + .expect('Content-Type', /json/) + .expect(403, done); + }); + + it('should return 422 when name not provided', (done) => { + const reqBody = _.cloneDeep(body); + delete reqBody.name; + request(server) + .post(`/v4/projects/${projectId}/phases/${phaseId}/products`) + .set({ + Authorization: `Bearer ${testUtil.jwts.copilot}`, + }) + .send({ param: reqBody }) + .expect('Content-Type', /json/) + .expect(422, done); + }); + + it('should return 422 when type not provided', (done) => { + const reqBody = _.cloneDeep(body); + delete reqBody.type; + request(server) + .post(`/v4/projects/${projectId}/phases/${phaseId}/products`) + .set({ + Authorization: `Bearer ${testUtil.jwts.copilot}`, + }) + .send({ param: reqBody }) + .expect('Content-Type', /json/) + .expect(422, done); + }); + + it('should return 422 when estimatedPrice is negative', (done) => { + const reqBody = _.cloneDeep(body); + reqBody.estimatedPrice = -20; + request(server) + .post(`/v4/projects/${projectId}/phases/${phaseId}/products`) + .set({ + Authorization: `Bearer ${testUtil.jwts.copilot}`, + }) + .send({ param: reqBody }) + .expect('Content-Type', /json/) + .expect(422, done); + }); + + it('should return 422 when actualPrice is negative', (done) => { + const reqBody = _.cloneDeep(body); + reqBody.actualPrice = -20; + request(server) + .post(`/v4/projects/${projectId}/phases/${phaseId}/products`) + .set({ + Authorization: `Bearer ${testUtil.jwts.copilot}`, + }) + .send({ param: reqBody }) + .expect('Content-Type', /json/) + .expect(422, done); + }); + + it('should return 404 when project is not found', (done) => { + request(server) + .post(`/v4/projects/99999/phases/${phaseId}/products`) + .set({ + Authorization: `Bearer ${testUtil.jwts.manager}`, + }) + .send({ param: body }) + .expect('Content-Type', /json/) + .expect(404, done); + }); + + it('should return 404 when project phase is not found', (done) => { + request(server) + .post(`/v4/projects/${projectId}/phases/99999/products`) + .set({ + Authorization: `Bearer ${testUtil.jwts.manager}`, + }) + .send({ param: body }) + .expect('Content-Type', /json/) + .expect(404, done); + }); + + it('should return 201 if payload is valid', (done) => { + request(server) + .post(`/v4/projects/${projectId}/phases/${phaseId}/products`) + .set({ + Authorization: `Bearer ${testUtil.jwts.copilot}`, + }) + .send({ param: body }) + .expect('Content-Type', /json/) + .expect(201) + .end((err, res) => { + if (err) { + done(err); + } else { + const resJson = res.body.result.content; + should.exist(resJson); + resJson.name.should.be.eql(body.name); + resJson.type.should.be.eql(body.type); + resJson.estimatedPrice.should.be.eql(body.estimatedPrice); + resJson.actualPrice.should.be.eql(body.actualPrice); + resJson.details.should.be.eql(body.details); + done(); + } + }); + }); + }); +}); diff --git a/src/routes/phaseProducts/delete.js b/src/routes/phaseProducts/delete.js new file mode 100644 index 00000000..a7e5e8ee --- /dev/null +++ b/src/routes/phaseProducts/delete.js @@ -0,0 +1,43 @@ + + +import _ from 'lodash'; +import { middleware as tcMiddleware } from 'tc-core-library-js'; +import models from '../../models'; + +const permissions = tcMiddleware.permissions; + +module.exports = [ + // check permission + permissions('project.deletePhaseProduct'), + + (req, res, next) => { + const projectId = _.parseInt(req.params.projectId); + const phaseId = _.parseInt(req.params.phaseId); + const productId = _.parseInt(req.params.productId); + + models.sequelize.transaction(() => + // soft delete the record + models.PhaseProduct.findOne({ + where: { + id: productId, + projectId, + phaseId, + deletedAt: { $eq: null }, + }, + }).then(existing => new Promise((accept, reject) => { + if (!existing) { + // handle 404 + const err = new Error('No active phase product found for project id ' + + `${projectId}, phase id ${phaseId} and product id ${productId}`); + err.status = 404; + reject(err); + } else { + _.extend(existing, { deletedBy: req.authUser.userId, deletedAt: Date.now() }); + existing.save().then(accept).catch(reject); + } + })).then((deleted) => { + req.log.debug('deleted phase product', JSON.stringify(deleted, null, 2)); + res.status(204).json({}); + }).catch(err => next(err))); + }, +]; diff --git a/src/routes/phaseProducts/delete.spec.js b/src/routes/phaseProducts/delete.spec.js new file mode 100644 index 00000000..2908bee9 --- /dev/null +++ b/src/routes/phaseProducts/delete.spec.js @@ -0,0 +1,129 @@ +/* eslint-disable no-unused-expressions */ +import _ from 'lodash'; +import request from 'supertest'; +import server from '../../app'; +import models from '../../models'; +import testUtil from '../../tests/util'; + +const body = { + name: 'test phase product', + type: 'product1', + estimatedPrice: 20.0, + actualPrice: 1.23456, + details: { + message: 'This can be any json', + }, + createdBy: 1, + updatedBy: 1, +}; + +describe('Phase Products', () => { + let projectId; + let phaseId; + let productId; + before((done) => { + // mocks + testUtil.clearDb() + .then(() => { + models.Project.create({ + type: 'generic', + billingAccountId: 1, + name: 'test1', + description: 'test project1', + status: 'draft', + details: {}, + createdBy: 1, + updatedBy: 1, + }).then((p) => { + projectId = p.id; + // create members + models.ProjectMember.create({ + userId: 40051332, + projectId, + role: 'copilot', + isPrimary: true, + createdBy: 1, + updatedBy: 1, + }).then(() => { + models.ProjectPhase.create({ + name: 'test project phase', + status: 'active', + startDate: '2018-05-15T00:00:00Z', + endDate: '2018-05-15T12:00:00Z', + budget: 20.0, + progress: 1.23456, + details: { + message: 'This can be any json', + }, + createdBy: 1, + updatedBy: 1, + projectId, + }).then((phase) => { + phaseId = phase.id; + _.assign(body, { phaseId, projectId }); + + models.PhaseProduct.create(body).then((product) => { + productId = product.id; + done(); + }); + }); + }); + }); + }); + }); + + after((done) => { + testUtil.clearDb(done); + }); + + describe('DELETE /projects/{id}/phases/{phaseId}/products/{productId}', () => { + it('should return 403 when user have no permission', (done) => { + request(server) + .delete(`/v4/projects/${projectId}/phases/${phaseId}/products/${productId}`) + .set({ + Authorization: `Bearer ${testUtil.jwts.member}`, + }) + .expect('Content-Type', /json/) + .expect(403, done); + }); + + it('should return 404 when no project with specific projectId', (done) => { + request(server) + .delete(`/v4/projects/999/phases/${phaseId}/products/${productId}`) + .set({ + Authorization: `Bearer ${testUtil.jwts.manager}`, + }) + .expect('Content-Type', /json/) + .expect(404, done); + }); + + it('should return 404 when no phase with specific phaseId', (done) => { + request(server) + .delete(`/v4/projects/${projectId}/phases/99999/products/${productId}`) + .set({ + Authorization: `Bearer ${testUtil.jwts.manager}`, + }) + .expect('Content-Type', /json/) + .expect(404, done); + }); + + it('should return 404 when no product with specific productId', (done) => { + request(server) + .delete(`/v4/projects/${projectId}/phases/${phaseId}/products/99999`) + .set({ + Authorization: `Bearer ${testUtil.jwts.manager}`, + }) + .expect('Content-Type', /json/) + .expect(404, done); + }); + + it('should return 204 when user have project permission', (done) => { + request(server) + .delete(`/v4/projects/${projectId}/phases/${phaseId}/products/${productId}`) + .set({ + Authorization: `Bearer ${testUtil.jwts.copilot}`, + }) + .expect(204, done); + }); + }); +}); diff --git a/src/routes/phaseProducts/get.js b/src/routes/phaseProducts/get.js new file mode 100644 index 00000000..30aa2ed4 --- /dev/null +++ b/src/routes/phaseProducts/get.js @@ -0,0 +1,36 @@ + +import _ from 'lodash'; + +import models from '../../models'; +import util from '../../util'; + +const permissions = require('tc-core-library-js').middleware.permissions; + +module.exports = [ + // check permission + permissions('project.view'), + + (req, res, next) => { + const projectId = _.parseInt(req.params.projectId); + const phaseId = _.parseInt(req.params.phaseId); + const productId = _.parseInt(req.params.productId); + + return models.PhaseProduct.findOne({ + where: { + id: productId, + projectId, + phaseId, + }, + }).then((product) => { + if (!product) { + // handle 404 + const err = new Error('phase product not found for project id ' + + `${projectId}, phase id ${phaseId} and product id ${productId}`); + err.status = 404; + throw err; + } else { + res.json(util.wrapResponse(req.id, product)); + } + }).catch(err => next(err)); + }, +]; diff --git a/src/routes/phaseProducts/get.spec.js b/src/routes/phaseProducts/get.spec.js new file mode 100644 index 00000000..b2b65b2a --- /dev/null +++ b/src/routes/phaseProducts/get.spec.js @@ -0,0 +1,147 @@ +/* eslint-disable no-unused-expressions */ +import _ from 'lodash'; +import chai from 'chai'; +import request from 'supertest'; +import server from '../../app'; +import models from '../../models'; +import testUtil from '../../tests/util'; + +const should = chai.should(); + +const body = { + name: 'test phase product', + type: 'product1', + estimatedPrice: 20.0, + actualPrice: 1.23456, + details: { + message: 'This can be any json', + }, + createdBy: 1, + updatedBy: 1, +}; + +describe('Phase Products', () => { + let projectId; + let phaseId; + let productId; + before((done) => { + // mocks + testUtil.clearDb() + .then(() => { + models.Project.create({ + type: 'generic', + billingAccountId: 1, + name: 'test1', + description: 'test project1', + status: 'draft', + details: {}, + createdBy: 1, + updatedBy: 1, + }).then((p) => { + projectId = p.id; + // create members + models.ProjectMember.create({ + userId: 40051332, + projectId, + role: 'copilot', + isPrimary: true, + createdBy: 1, + updatedBy: 1, + }).then(() => { + models.ProjectPhase.create({ + name: 'test project phase', + status: 'active', + startDate: '2018-05-15T00:00:00Z', + endDate: '2018-05-15T12:00:00Z', + budget: 20.0, + progress: 1.23456, + details: { + message: 'This can be any json', + }, + createdBy: 1, + updatedBy: 1, + projectId, + }).then((phase) => { + phaseId = phase.id; + _.assign(body, { phaseId, projectId }); + + models.PhaseProduct.create(body).then((product) => { + productId = product.id; + done(); + }); + }); + }); + }); + }); + }); + + after((done) => { + testUtil.clearDb(done); + }); + + describe('GET /projects/{id}/phases/{phaseId}/products/{productId}', () => { + it('should return 403 when user have no permission', (done) => { + request(server) + .get(`/v4/projects/${projectId}/phases/${phaseId}/products/${productId}`) + .set({ + Authorization: `Bearer ${testUtil.jwts.member}`, + }) + .expect('Content-Type', /json/) + .expect(403, done); + }); + + it('should return 404 when no project with specific projectId', (done) => { + request(server) + .get(`/v4/projects/999/phases/${phaseId}/products/${productId}`) + .set({ + Authorization: `Bearer ${testUtil.jwts.manager}`, + }) + .expect('Content-Type', /json/) + .expect(404, done); + }); + + it('should return 404 when no phase with specific phaseId', (done) => { + request(server) + .get(`/v4/projects/${projectId}/phases/99999/products/${productId}`) + .set({ + Authorization: `Bearer ${testUtil.jwts.manager}`, + }) + .expect('Content-Type', /json/) + .expect(404, done); + }); + + it('should return 404 when no product with specific productId', (done) => { + request(server) + .get(`/v4/projects/${projectId}/phases/${phaseId}/products/99999`) + .set({ + Authorization: `Bearer ${testUtil.jwts.manager}`, + }) + .expect('Content-Type', /json/) + .expect(404, done); + }); + + it('should return 1 phase when user have project permission', (done) => { + request(server) + .get(`/v4/projects/${projectId}/phases/${phaseId}/products/${productId}`) + .set({ + Authorization: `Bearer ${testUtil.jwts.copilot}`, + }) + .expect('Content-Type', /json/) + .expect(200) + .end((err, res) => { + if (err) { + done(err); + } else { + const resJson = res.body.result.content; + should.exist(resJson); + resJson.name.should.be.eql(body.name); + resJson.type.should.be.eql(body.type); + resJson.estimatedPrice.should.be.eql(body.estimatedPrice); + resJson.actualPrice.should.be.eql(body.actualPrice); + resJson.details.should.be.eql(body.details); + done(); + } + }); + }); + }); +}); diff --git a/src/routes/phaseProducts/list.js b/src/routes/phaseProducts/list.js new file mode 100644 index 00000000..2abb3076 --- /dev/null +++ b/src/routes/phaseProducts/list.js @@ -0,0 +1,41 @@ + +import _ from 'lodash'; + +import models from '../../models'; +import util from '../../util'; + +const permissions = require('tc-core-library-js').middleware.permissions; + +module.exports = [ + // check permission + permissions('project.view'), + + (req, res, next) => { + const projectId = _.parseInt(req.params.projectId); + const phaseId = _.parseInt(req.params.phaseId); + + return models.ProjectPhase.findOne({ + where: { id: phaseId, projectId, deletedAt: { $eq: null } }, + }).then((existingPhase) => { + if (!existingPhase) { + const err = new Error(`active project phase not found for project id ${projectId}` + + ` and phase id ${phaseId}`); + err.status = 404; + throw err; + } + return models.PhaseProduct.findAll({ + where: { + projectId, + phaseId, + deletedAt: { $eq: null }, + }, + }); + }).then((products) => { + if (!products) { + res.json(util.wrapResponse(req.id, [], 0)); + } else { + res.json(util.wrapResponse(req.id, products, products.length)); + } + }).catch(err => next(err)); + }, +]; diff --git a/src/routes/phaseProducts/list.spec.js b/src/routes/phaseProducts/list.spec.js new file mode 100644 index 00000000..92d0b0dc --- /dev/null +++ b/src/routes/phaseProducts/list.spec.js @@ -0,0 +1,129 @@ +/* eslint-disable no-unused-expressions */ +import _ from 'lodash'; +import request from 'supertest'; +import server from '../../app'; +import models from '../../models'; +import testUtil from '../../tests/util'; + +const body = { + name: 'test phase product', + type: 'product1', + estimatedPrice: 20.0, + actualPrice: 1.23456, + details: { + message: 'This can be any json', + }, + createdBy: 1, + updatedBy: 1, +}; + +describe('Phase Products', () => { + let projectId; + let phaseId; + before((done) => { + // mocks + testUtil.clearDb() + .then(() => { + models.Project.create({ + type: 'generic', + billingAccountId: 1, + name: 'test1', + description: 'test project1', + status: 'draft', + details: {}, + createdBy: 1, + updatedBy: 1, + }).then((p) => { + projectId = p.id; + // create members + models.ProjectMember.create({ + userId: 40051332, + projectId, + role: 'copilot', + isPrimary: true, + createdBy: 1, + updatedBy: 1, + }).then(() => { + models.ProjectPhase.create({ + name: 'test project phase', + status: 'active', + startDate: '2018-05-15T00:00:00Z', + endDate: '2018-05-15T12:00:00Z', + budget: 20.0, + progress: 1.23456, + details: { + message: 'This can be any json', + }, + createdBy: 1, + updatedBy: 1, + projectId, + }).then((phase) => { + phaseId = phase.id; + _.assign(body, { phaseId, projectId }); + + models.PhaseProduct.create(body).then(() => done()); + }); + }); + }); + }); + }); + + after((done) => { + testUtil.clearDb(done); + }); + + describe('GET /projects/{id}/phases/{phaseId}/products', () => { + it('should return 403 when user have no permission', (done) => { + request(server) + .get(`/v4/projects/${projectId}/phases/${phaseId}/products`) + .set({ + Authorization: `Bearer ${testUtil.jwts.member}`, + }) + .send({ param: body }) + .expect('Content-Type', /json/) + .expect(403, done); + }); + + it('should return 404 when no project with specific projectId', (done) => { + request(server) + .get(`/v4/projects/999/phases/${phaseId}/products`) + .set({ + Authorization: `Bearer ${testUtil.jwts.manager}`, + }) + .send({ param: body }) + .expect('Content-Type', /json/) + .expect(404, done); + }); + + it('should return 404 when no phase with specific phaseId', (done) => { + request(server) + .get(`/v4/projects/${projectId}/phases/99999/products`) + .set({ + Authorization: `Bearer ${testUtil.jwts.manager}`, + }) + .send({ param: body }) + .expect('Content-Type', /json/) + .expect(404, done); + }); + + it('should return 1 phase when user have project permission', (done) => { + request(server) + .get(`/v4/projects/${projectId}/phases/${phaseId}/products`) + .set({ + Authorization: `Bearer ${testUtil.jwts.copilot}`, + }) + .send({ param: body }) + .expect('Content-Type', /json/) + .expect(200) + .end((err, res) => { + if (err) { + done(err); + } else { + const resJson = res.body.result.content; + resJson.should.have.lengthOf(1); + done(); + } + }); + }); + }); +}); diff --git a/src/routes/phaseProducts/update.js b/src/routes/phaseProducts/update.js new file mode 100644 index 00000000..2d42f618 --- /dev/null +++ b/src/routes/phaseProducts/update.js @@ -0,0 +1,63 @@ + +import validate from 'express-validation'; +import _ from 'lodash'; +import Joi from 'joi'; +import { middleware as tcMiddleware } from 'tc-core-library-js'; +import models from '../../models'; +import util from '../../util'; + + +const permissions = tcMiddleware.permissions; + +const updatePhaseProductValidation = { + body: { + param: Joi.object().keys({ + name: Joi.string().optional(), + type: Joi.string().optional(), + templateId: Joi.number().optional(), + estimatedPrice: Joi.number().positive().optional(), + actualPrice: Joi.number().positive().optional(), + details: Joi.any().optional(), + }).required(), + }, +}; + + +module.exports = [ + // validate request payload + validate(updatePhaseProductValidation), + // check permission + permissions('project.updatePhaseProduct'), + + (req, res, next) => { + const projectId = _.parseInt(req.params.projectId); + const phaseId = _.parseInt(req.params.phaseId); + const productId = _.parseInt(req.params.productId); + + const updatedProps = req.body.param; + updatedProps.updatedBy = req.authUser.userId; + + models.sequelize.transaction(() => models.PhaseProduct.findOne({ + where: { + id: productId, + projectId, + phaseId, + deletedAt: { $eq: null }, + }, + }).then(existing => new Promise((accept, reject) => { + if (!existing) { + // handle 404 + const err = new Error('No active phase product found for project id ' + + `${projectId}, phase id ${phaseId} and product id ${productId}`); + err.status = 404; + reject(err); + } else { + _.extend(existing, updatedProps); + existing.save().then(accept).catch(reject); + } + })).then((updated) => { + req.log.debug('updated phase product', JSON.stringify(updated, null, 2)); + res.json(util.wrapResponse(req.id, updated)); + }).catch(err => next(err))); + }, +]; diff --git a/src/routes/phaseProducts/update.spec.js b/src/routes/phaseProducts/update.spec.js new file mode 100644 index 00000000..1a8b8a21 --- /dev/null +++ b/src/routes/phaseProducts/update.spec.js @@ -0,0 +1,178 @@ +/* eslint-disable no-unused-expressions */ +import _ from 'lodash'; +import chai from 'chai'; +import request from 'supertest'; +import server from '../../app'; +import models from '../../models'; +import testUtil from '../../tests/util'; + +const should = chai.should(); + +const body = { + name: 'test phase product', + type: 'product1', + estimatedPrice: 20.0, + actualPrice: 1.23456, + details: { + message: 'This can be any json', + }, + createdBy: 1, + updatedBy: 1, +}; + +const updateBody = { + name: 'test phase product xxx', + type: 'product2', + estimatedPrice: 123456.789, + actualPrice: 9.8765432, + details: { + message: 'This is another json', + }, +}; + +describe('Phase Products', () => { + let projectId; + let phaseId; + let productId; + before((done) => { + // mocks + testUtil.clearDb() + .then(() => { + models.Project.create({ + type: 'generic', + billingAccountId: 1, + name: 'test1', + description: 'test project1', + status: 'draft', + details: {}, + createdBy: 1, + updatedBy: 1, + }).then((p) => { + projectId = p.id; + // create members + models.ProjectMember.create({ + userId: 40051332, + projectId, + role: 'copilot', + isPrimary: true, + createdBy: 1, + updatedBy: 1, + }).then(() => { + models.ProjectPhase.create({ + name: 'test project phase', + status: 'active', + startDate: '2018-05-15T00:00:00Z', + endDate: '2018-05-15T12:00:00Z', + budget: 20.0, + progress: 1.23456, + details: { + message: 'This can be any json', + }, + createdBy: 1, + updatedBy: 1, + projectId, + }).then((phase) => { + phaseId = phase.id; + _.assign(body, { phaseId, projectId }); + + models.PhaseProduct.create(body).then((product) => { + productId = product.id; + done(); + }); + }); + }); + }); + }); + }); + + after((done) => { + testUtil.clearDb(done); + }); + + describe('PATCH /projects/{id}/phases/{phaseId}/products/{productId}', () => { + it('should return 403 when user have no permission', (done) => { + request(server) + .patch(`/v4/projects/${projectId}/phases/${phaseId}/products/${productId}`) + .set({ + Authorization: `Bearer ${testUtil.jwts.member}`, + }) + .send({ param: updateBody }) + .expect('Content-Type', /json/) + .expect(403, done); + }); + + it('should return 404 when no project with specific projectId', (done) => { + request(server) + .patch(`/v4/projects/999/phases/${phaseId}/products/${productId}`) + .set({ + Authorization: `Bearer ${testUtil.jwts.manager}`, + }) + .send({ param: updateBody }) + .expect('Content-Type', /json/) + .expect(404, done); + }); + + it('should return 404 when no phase with specific phaseId', (done) => { + request(server) + .patch(`/v4/projects/${projectId}/phases/99999/products/${productId}`) + .set({ + Authorization: `Bearer ${testUtil.jwts.manager}`, + }) + .send({ param: updateBody }) + .expect('Content-Type', /json/) + .expect(404, done); + }); + + it('should return 404 when no product with specific productId', (done) => { + request(server) + .patch(`/v4/projects/${projectId}/phases/${phaseId}/products/99999`) + .set({ + Authorization: `Bearer ${testUtil.jwts.manager}`, + }) + .send({ param: updateBody }) + .expect('Content-Type', /json/) + .expect(404, done); + }); + + it('should return 422 when parameters are invalid', (done) => { + request(server) + .patch(`/v4/projects/${projectId}/phases/${phaseId}/products/99999`) + .set({ + Authorization: `Bearer ${testUtil.jwts.manager}`, + }) + .send({ + param: { + estimatedPrice: -15, + }, + }) + .expect('Content-Type', /json/) + .expect(422, done); + }); + + + it('should return updated product when user have permission and parameters are valid', (done) => { + request(server) + .patch(`/v4/projects/${projectId}/phases/${phaseId}/products/${productId}`) + .set({ + Authorization: `Bearer ${testUtil.jwts.copilot}`, + }) + .send({ param: updateBody }) + .expect('Content-Type', /json/) + .expect(200) + .end((err, res) => { + if (err) { + done(err); + } else { + const resJson = res.body.result.content; + should.exist(resJson); + resJson.name.should.be.eql(updateBody.name); + resJson.type.should.be.eql(updateBody.type); + resJson.estimatedPrice.should.be.eql(updateBody.estimatedPrice); + resJson.actualPrice.should.be.eql(updateBody.actualPrice); + resJson.details.should.be.eql(updateBody.details); + done(); + } + }); + }); + }); +}); diff --git a/src/routes/phases/create.js b/src/routes/phases/create.js new file mode 100644 index 00000000..6a81df61 --- /dev/null +++ b/src/routes/phases/create.js @@ -0,0 +1,69 @@ +import validate from 'express-validation'; +import _ from 'lodash'; +import Joi from 'joi'; + +import models from '../../models'; +import util from '../../util'; + +const permissions = require('tc-core-library-js').middleware.permissions; + + +const addProjectPhaseValidations = { + body: { + param: Joi.object().keys({ + name: Joi.string().required(), + status: Joi.string().required(), + startDate: Joi.date().max(Joi.ref('endDate')).required(), + endDate: Joi.date().required(), + budget: Joi.number().positive().optional(), + progress: Joi.number().positive().optional(), + details: Joi.any().optional(), + }).required(), + }, +}; + +module.exports = [ + // validate request payload + validate(addProjectPhaseValidations), + // check permission + permissions('project.addProjectPhase'), + // do the real work + (req, res, next) => { + const data = req.body.param; + // default values + const projectId = _.parseInt(req.params.projectId); + _.assign(data, { + projectId, + createdBy: req.authUser.userId, + updatedBy: req.authUser.userId, + }); + + models.sequelize.transaction(() => { + let newProjectPhase = null; + + models.Project.findOne({ + where: { id: projectId, deletedAt: { $eq: null } }, + }).then((existingProject) => { + if (!existingProject) { + const err = new Error(`active project not found for project id ${projectId}`); + err.status = 404; + throw err; + } + models.ProjectPhase + .create(data) + .then((_newProjectPhase) => { + newProjectPhase = _.cloneDeep(_newProjectPhase); + req.log.debug('new project phase created (id# %d, name: %s)', + newProjectPhase.id, newProjectPhase.name); + + newProjectPhase = newProjectPhase.get({ plain: true }); + newProjectPhase = _.omit(newProjectPhase, ['deletedAt', 'deletedBy', 'utm']); + res.status(201).json(util.wrapResponse(req.id, newProjectPhase, 1, 201)); + }); + }).catch((err) => { + util.handleError('Error creating project phase', err, req, next); + }); + }); + }, + +]; diff --git a/src/routes/phases/create.spec.js b/src/routes/phases/create.spec.js new file mode 100644 index 00000000..9a1fb5ce --- /dev/null +++ b/src/routes/phases/create.spec.js @@ -0,0 +1,196 @@ +/* eslint-disable no-unused-expressions */ +import _ from 'lodash'; +import chai from 'chai'; +import request from 'supertest'; +import server from '../../app'; +import models from '../../models'; +import testUtil from '../../tests/util'; + +const should = chai.should(); + +const body = { + name: 'test project phase', + status: 'active', + startDate: '2018-05-15T00:00:00Z', + endDate: '2018-05-15T12:00:00Z', + budget: 20.0, + progress: 1.23456, + details: { + message: 'This can be any json', + }, +}; + +describe('Project Phases', () => { + let projectId; + before((done) => { + // mocks + testUtil.clearDb() + .then(() => { + models.Project.create({ + type: 'generic', + billingAccountId: 1, + name: 'test1', + description: 'test project1', + status: 'draft', + details: {}, + createdBy: 1, + updatedBy: 1, + }).then((p) => { + projectId = p.id; + // create members + models.ProjectMember.create({ + userId: 40051332, + projectId, + role: 'copilot', + isPrimary: true, + createdBy: 1, + updatedBy: 1, + }).then(() => done()); + }); + }); + }); + + after((done) => { + testUtil.clearDb(done); + }); + + describe('POST /projects/{id}/phases/', () => { + it('should return 403 if user does not have permissions', (done) => { + request(server) + .post(`/v4/projects/${projectId}/phases/`) + .set({ + Authorization: `Bearer ${testUtil.jwts.member}`, + }) + .send({ param: body }) + .expect('Content-Type', /json/) + .expect(403, done); + }); + + it('should return 422 when name not provided', (done) => { + const reqBody = _.cloneDeep(body); + delete reqBody.name; + request(server) + .post(`/v4/projects/${projectId}/phases/`) + .set({ + Authorization: `Bearer ${testUtil.jwts.copilot}`, + }) + .send({ param: reqBody }) + .expect('Content-Type', /json/) + .expect(422, done); + }); + + it('should return 422 when status not provided', (done) => { + const reqBody = _.cloneDeep(body); + delete reqBody.status; + request(server) + .post(`/v4/projects/${projectId}/phases/`) + .set({ + Authorization: `Bearer ${testUtil.jwts.copilot}`, + }) + .send({ param: reqBody }) + .expect('Content-Type', /json/) + .expect(422, done); + }); + + it('should return 422 when startDate not provided', (done) => { + const reqBody = _.cloneDeep(body); + delete reqBody.startDate; + request(server) + .post(`/v4/projects/${projectId}/phases/`) + .set({ + Authorization: `Bearer ${testUtil.jwts.copilot}`, + }) + .send({ param: reqBody }) + .expect('Content-Type', /json/) + .expect(422, done); + }); + + it('should return 422 when endDate not provided', (done) => { + const reqBody = _.cloneDeep(body); + delete reqBody.endDate; + request(server) + .post(`/v4/projects/${projectId}/phases/`) + .set({ + Authorization: `Bearer ${testUtil.jwts.copilot}`, + }) + .send({ param: reqBody }) + .expect('Content-Type', /json/) + .expect(422, done); + }); + + it('should return 422 when startDate > endDate', (done) => { + const reqBody = _.cloneDeep(body); + reqBody.startDate = '2018-05-16T12:00:00'; + request(server) + .post(`/v4/projects/${projectId}/phases/`) + .set({ + Authorization: `Bearer ${testUtil.jwts.copilot}`, + }) + .send({ param: reqBody }) + .expect('Content-Type', /json/) + .expect(422, done); + }); + + it('should return 422 when budget is negative', (done) => { + const reqBody = _.cloneDeep(body); + reqBody.budget = -20; + request(server) + .post(`/v4/projects/${projectId}/phases/`) + .set({ + Authorization: `Bearer ${testUtil.jwts.copilot}`, + }) + .send({ param: reqBody }) + .expect('Content-Type', /json/) + .expect(422, done); + }); + + it('should return 422 when progress is negative', (done) => { + const reqBody = _.cloneDeep(body); + reqBody.progress = -20; + request(server) + .post(`/v4/projects/${projectId}/phases/`) + .set({ + Authorization: `Bearer ${testUtil.jwts.copilot}`, + }) + .send({ param: reqBody }) + .expect('Content-Type', /json/) + .expect(422, done); + }); + + it('should return 404 when project is not found', (done) => { + request(server) + .post('/v4/projects/99999/phases/') + .set({ + Authorization: `Bearer ${testUtil.jwts.manager}`, + }) + .send({ param: body }) + .expect('Content-Type', /json/) + .expect(404, done); + }); + + it('should return 201 if payload is valid', (done) => { + request(server) + .post(`/v4/projects/${projectId}/phases/`) + .set({ + Authorization: `Bearer ${testUtil.jwts.copilot}`, + }) + .send({ param: body }) + .expect('Content-Type', /json/) + .expect(201) + .end((err, res) => { + if (err) { + done(err); + } else { + const resJson = res.body.result.content; + should.exist(resJson); + resJson.name.should.be.eql(body.name); + resJson.status.should.be.eql(body.status); + resJson.budget.should.be.eql(body.budget); + resJson.progress.should.be.eql(body.progress); + resJson.details.should.be.eql(body.details); + done(); + } + }); + }); + }); +}); diff --git a/src/routes/phases/delete.js b/src/routes/phases/delete.js new file mode 100644 index 00000000..c8979ca2 --- /dev/null +++ b/src/routes/phases/delete.js @@ -0,0 +1,42 @@ + + +import _ from 'lodash'; +import { middleware as tcMiddleware } from 'tc-core-library-js'; +import models from '../../models'; + +const permissions = tcMiddleware.permissions; + +module.exports = [ + // check permission + permissions('project.deleteProjectPhase'), + + (req, res, next) => { + const projectId = _.parseInt(req.params.projectId); + const phaseId = _.parseInt(req.params.phaseId); + + models.sequelize.transaction(() => + // soft delete the record + models.ProjectPhase.findOne({ + where: { + id: phaseId, + projectId, + deletedAt: { $eq: null }, + }, + }).then(existing => new Promise((accept, reject) => { + if (!existing) { + // handle 404 + const err = new Error('no active project phase found for project id ' + + `${projectId} and phase id ${phaseId}`); + err.status = 404; + reject(err); + } else { + _.extend(existing, { deletedBy: req.authUser.userId, deletedAt: Date.now() }); + existing.save().then(accept).catch(reject); + } + })).then((deleted) => { + req.log.debug('deleted project phase', JSON.stringify(deleted, null, 2)); + res.status(204).json({}); + }).catch(err => next(err))); + }, +]; + diff --git a/src/routes/phases/delete.spec.js b/src/routes/phases/delete.spec.js new file mode 100644 index 00000000..cb1ea251 --- /dev/null +++ b/src/routes/phases/delete.spec.js @@ -0,0 +1,103 @@ +/* eslint-disable no-unused-expressions */ +import _ from 'lodash'; +import request from 'supertest'; +import server from '../../app'; +import models from '../../models'; +import testUtil from '../../tests/util'; + +const body = { + name: 'test project phase', + status: 'active', + startDate: '2018-05-15T00:00:00Z', + endDate: '2018-05-15T12:00:00Z', + budget: 20.0, + progress: 1.23456, + details: { + message: 'This can be any json', + }, + createdBy: 1, + updatedBy: 1, +}; + +describe('Project Phases', () => { + let projectId; + let phaseId; + before((done) => { + // mocks + testUtil.clearDb() + .then(() => { + models.Project.create({ + type: 'generic', + billingAccountId: 1, + name: 'test1', + description: 'test project1', + status: 'draft', + details: {}, + createdBy: 1, + updatedBy: 1, + }).then((p) => { + projectId = p.id; + // create members + models.ProjectMember.create({ + userId: 40051332, + projectId, + role: 'copilot', + isPrimary: true, + createdBy: 1, + updatedBy: 1, + }).then(() => { + _.assign(body, { projectId }); + models.ProjectPhase.create(body).then((phase) => { + phaseId = phase.id; + done(); + }); + }); + }); + }); + }); + + after((done) => { + testUtil.clearDb(done); + }); + + describe('DELETE /projects/{projectId}/phases/{phaseId}', () => { + it('should return 403 when user have no permission', (done) => { + request(server) + .delete(`/v4/projects/${projectId}/phases/${phaseId}`) + .set({ + Authorization: `Bearer ${testUtil.jwts.member}`, + }) + .expect('Content-Type', /json/) + .expect(403, done); + }); + + it('should return 404 when no project with specific projectId', (done) => { + request(server) + .delete(`/v4/projects/999/phases/${phaseId}`) + .set({ + Authorization: `Bearer ${testUtil.jwts.manager}`, + }) + .expect('Content-Type', /json/) + .expect(404, done); + }); + + it('should return 404 when no phase with specific phaseId', (done) => { + request(server) + .delete(`/v4/projects/${projectId}/phases/999`) + .set({ + Authorization: `Bearer ${testUtil.jwts.manager}`, + }) + .expect('Content-Type', /json/) + .expect(404, done); + }); + + it('should return 204 when user have project permission', (done) => { + request(server) + .delete(`/v4/projects/${projectId}/phases/${phaseId}`) + .set({ + Authorization: `Bearer ${testUtil.jwts.copilot}`, + }) + .expect(204, done); + }); + }); +}); diff --git a/src/routes/phases/get.js b/src/routes/phases/get.js new file mode 100644 index 00000000..cee2c3f2 --- /dev/null +++ b/src/routes/phases/get.js @@ -0,0 +1,31 @@ + +import _ from 'lodash'; +import { middleware as tcMiddleware } from 'tc-core-library-js'; +import util from '../../util'; +import models from '../../models'; + +const permissions = tcMiddleware.permissions; + +module.exports = [ + permissions('project.view'), + (req, res, next) => { + const projectId = _.parseInt(req.params.projectId); + const phaseId = _.parseInt(req.params.phaseId); + return models.ProjectPhase + .findOne({ + where: { id: phaseId, projectId }, + raw: true, + }) + .then((phase) => { + if (!phase) { + // handle 404 + const err = new Error('project phase not found for project id ' + + `${projectId} and phase id ${phaseId}`); + err.status = 404; + throw err; + } + res.json(util.wrapResponse(req.id, phase)); + }) + .catch(err => next(err)); + }, +]; diff --git a/src/routes/phases/get.spec.js b/src/routes/phases/get.spec.js new file mode 100644 index 00000000..8a384e38 --- /dev/null +++ b/src/routes/phases/get.spec.js @@ -0,0 +1,121 @@ +/* eslint-disable no-unused-expressions */ +import _ from 'lodash'; +import chai from 'chai'; +import request from 'supertest'; +import server from '../../app'; +import models from '../../models'; +import testUtil from '../../tests/util'; + +const should = chai.should(); + +const body = { + name: 'test project phase', + status: 'active', + startDate: '2018-05-15T00:00:00Z', + endDate: '2018-05-15T12:00:00Z', + budget: 20.0, + progress: 1.23456, + details: { + message: 'This can be any json', + }, + createdBy: 1, + updatedBy: 1, +}; + +describe('Project Phases', () => { + let projectId; + let phaseId; + before((done) => { + // mocks + testUtil.clearDb() + .then(() => { + models.Project.create({ + type: 'generic', + billingAccountId: 1, + name: 'test1', + description: 'test project1', + status: 'draft', + details: {}, + createdBy: 1, + updatedBy: 1, + }).then((p) => { + projectId = p.id; + // create members + models.ProjectMember.create({ + userId: 40051332, + projectId, + role: 'copilot', + isPrimary: true, + createdBy: 1, + updatedBy: 1, + }).then(() => { + _.assign(body, { projectId }); + models.ProjectPhase.create(body).then((phase) => { + phaseId = phase.id; + done(); + }); + }); + }); + }); + }); + + after((done) => { + testUtil.clearDb(done); + }); + + describe('GET /projects/{projectId}/phases/{phaseId}', () => { + it('should return 403 when user have no permission', (done) => { + request(server) + .get(`/v4/projects/${projectId}/phases/${phaseId}`) + .set({ + Authorization: `Bearer ${testUtil.jwts.member}`, + }) + .expect('Content-Type', /json/) + .expect(403, done); + }); + + it('should return 404 when no project with specific projectId', (done) => { + request(server) + .get(`/v4/projects/999/phases/${phaseId}`) + .set({ + Authorization: `Bearer ${testUtil.jwts.manager}`, + }) + .expect('Content-Type', /json/) + .expect(404, done); + }); + + it('should return 404 when no phase with specific phaseId', (done) => { + request(server) + .get(`/v4/projects/${projectId}/phases/999`) + .set({ + Authorization: `Bearer ${testUtil.jwts.manager}`, + }) + .expect('Content-Type', /json/) + .expect(404, done); + }); + + it('should return 1 phase when user have project permission', (done) => { + request(server) + .get(`/v4/projects/${projectId}/phases/${phaseId}`) + .set({ + Authorization: `Bearer ${testUtil.jwts.copilot}`, + }) + .expect('Content-Type', /json/) + .expect(200) + .end((err, res) => { + if (err) { + done(err); + } else { + const resJson = res.body.result.content; + should.exist(resJson); + resJson.name.should.be.eql('test project phase'); + resJson.status.should.be.eql('active'); + resJson.budget.should.be.eql(20.0); + resJson.progress.should.be.eql(1.23456); + resJson.details.should.be.eql({ message: 'This can be any json' }); + done(); + } + }); + }); + }); +}); diff --git a/src/routes/phases/list.js b/src/routes/phases/list.js new file mode 100644 index 00000000..bfec53d8 --- /dev/null +++ b/src/routes/phases/list.js @@ -0,0 +1,74 @@ + +import _ from 'lodash'; +import { middleware as tcMiddleware } from 'tc-core-library-js'; +import util from '../../util'; +import models from '../../models'; + +const PHASE_ATTRIBUTES = _.without(_.keys(models.ProjectPhase.rawAttributes), + 'utm', +); + +const permissions = tcMiddleware.permissions; + +const retrieveProjectPhases = (req, criteria, sort, ffields) => { + // order by + const order = sort ? [sort.split(' ')] : [['createdAt', 'asc']]; + let fields = ffields ? ffields.split(',') : PHASE_ATTRIBUTES; + // parse the fields string to determine what fields are to be returned + fields = _.intersection(fields, PHASE_ATTRIBUTES); + if (_.indexOf(fields, 'id') < 0) fields.push('id'); + + return models.ProjectPhase.searchText({ + filters: criteria.filters, + order, + limit: criteria.limit, + offset: criteria.offset, + attributes: fields, + }, req.log); +}; + +module.exports = [ + permissions('project.view'), + (req, res, next) => { + const projectId = _.parseInt(req.params.projectId); + + const filters = util.parseQueryFilter(req.query.filter); + let sort = req.query.sort ? decodeURIComponent(req.query.sort) : 'createdAt'; + if (sort && sort.indexOf(' ') === -1) { + sort += ' asc'; + } + const sortableProps = [ + 'createdAt', 'createdAt asc', 'createdAt desc', + 'updatedAt', 'updatedAt asc', 'updatedAt desc', + 'id', 'id asc', 'id desc', + 'status', 'status asc', 'status desc', + 'name', 'name asc', 'name desc', + 'budget', 'budget asc', 'budget desc', + 'progress', 'progress asc', 'progress desc', + ]; + if (!util.isValidFilter(filters, ['id', 'status', 'type', 'name', 'status', 'budget', 'progress']) || + (sort && _.indexOf(sortableProps, sort) < 0)) { + return util.handleError('Invalid filters or sort', null, req, next); + } + + const criteria = { + filters, + limit: Math.min(req.query.limit || 20, 20), + offset: req.query.offset || 0, + }; + + criteria.filters.projectId = projectId; + + return models.Project.findOne({ + where: { id: projectId, deletedAt: { $eq: null } }, + }).then((existingProject) => { + if (!existingProject) { + const err = new Error(`active project not found for project id ${projectId}`); + err.status = 404; + throw err; + } + return retrieveProjectPhases(req, criteria, sort, req.query.fields); + }).then(result => res.json(util.wrapResponse(req.id, result.rows, result.count))) + .catch(err => next(err)); + }, +]; diff --git a/src/routes/phases/list.spec.js b/src/routes/phases/list.spec.js new file mode 100644 index 00000000..2136a87e --- /dev/null +++ b/src/routes/phases/list.spec.js @@ -0,0 +1,102 @@ +/* eslint-disable no-unused-expressions */ +import _ from 'lodash'; +import request from 'supertest'; +import server from '../../app'; +import models from '../../models'; +import testUtil from '../../tests/util'; + +const body = { + name: 'test project phase', + status: 'active', + startDate: '2018-05-15T00:00:00Z', + endDate: '2018-05-15T12:00:00Z', + budget: 20.0, + progress: 1.23456, + details: { + message: 'This can be any json', + }, + createdBy: 1, + updatedBy: 1, +}; + +describe('Project Phases', () => { + let projectId; + before((done) => { + // mocks + testUtil.clearDb() + .then(() => { + models.Project.create({ + type: 'generic', + billingAccountId: 1, + name: 'test1', + description: 'test project1', + status: 'draft', + details: {}, + createdBy: 1, + updatedBy: 1, + }).then((p) => { + projectId = p.id; + // create members + models.ProjectMember.create({ + userId: 40051332, + projectId, + role: 'copilot', + isPrimary: true, + createdBy: 1, + updatedBy: 1, + }).then(() => { + _.assign(body, { projectId }); + models.ProjectPhase.create(body).then(() => done()); + }); + }); + }); + }); + + after((done) => { + testUtil.clearDb(done); + }); + + describe('GET /projects/{id}/phases/', () => { + it('should return 403 when user have no permission', (done) => { + request(server) + .get(`/v4/projects/${projectId}/phases/`) + .set({ + Authorization: `Bearer ${testUtil.jwts.member}`, + }) + .send({ param: body }) + .expect('Content-Type', /json/) + .expect(403, done); + }); + + it('should return 404 when no project with specific projectId', (done) => { + request(server) + .get('/v4/projects/999/phases/') + .set({ + Authorization: `Bearer ${testUtil.jwts.manager}`, + }) + .send({ param: body }) + .expect('Content-Type', /json/) + .expect(404, done); + }); + + it('should return 1 phase when user have project permission', (done) => { + request(server) + .get(`/v4/projects/${projectId}/phases/`) + .set({ + Authorization: `Bearer ${testUtil.jwts.copilot}`, + }) + .send({ param: body }) + .expect('Content-Type', /json/) + .expect(200) + .end((err, res) => { + if (err) { + done(err); + } else { + const resJson = res.body.result.content; + resJson.should.have.lengthOf(1); + done(); + } + }); + }); + }); +}); diff --git a/src/routes/phases/update.js b/src/routes/phases/update.js new file mode 100644 index 00000000..543e0fac --- /dev/null +++ b/src/routes/phases/update.js @@ -0,0 +1,83 @@ + +import validate from 'express-validation'; +import _ from 'lodash'; +import Joi from 'joi'; +import { middleware as tcMiddleware } from 'tc-core-library-js'; +import models from '../../models'; +import util from '../../util'; + + +const permissions = tcMiddleware.permissions; + +const updateProjectPhaseValidation = { + body: { + param: Joi.object().keys({ + name: Joi.string().optional(), + status: Joi.string().optional(), + startDate: Joi.date().optional(), + endDate: Joi.date().optional(), + budget: Joi.number().positive().optional(), + progress: Joi.number().positive().optional(), + details: Joi.any().optional(), + }).required(), + }, +}; + + +module.exports = [ + // validate request payload + validate(updateProjectPhaseValidation), + // check permission + permissions('project.updateProjectPhase'), + + (req, res, next) => { + const projectId = _.parseInt(req.params.projectId); + const phaseId = _.parseInt(req.params.phaseId); + + const updatedProps = req.body.param; + updatedProps.updatedBy = req.authUser.userId; + + models.sequelize.transaction(() => models.ProjectPhase.findOne({ + where: { + id: phaseId, + projectId, + deletedAt: { $eq: null }, + }, + }).then(existing => new Promise((accept, reject) => { + if (!existing) { + // handle 404 + const err = new Error('No active project phase found for project id ' + + `${projectId} and phase id ${phaseId}`); + err.status = 404; + reject(err); + } else { + // make sure startDate < endDate + let startDate; + let endDate; + if (updatedProps.startDate) { + startDate = new Date(updatedProps.startDate); + } else { + startDate = new Date(existing.startDate); + } + + if (updatedProps.endDate) { + endDate = new Date(updatedProps.endDate); + } else { + endDate = new Date(existing.endDate); + } + + if (startDate >= endDate) { + const err = new Error('startDate must be before endDate.'); + err.status = 400; + reject(err); + } else { + _.extend(existing, updatedProps); + existing.save().then(accept).catch(reject); + } + } + })).then((updated) => { + req.log.debug('updated project phase', JSON.stringify(updated, null, 2)); + res.json(util.wrapResponse(req.id, updated)); + }).catch(err => next(err))); + }, +]; diff --git a/src/routes/phases/update.spec.js b/src/routes/phases/update.spec.js new file mode 100644 index 00000000..a129255e --- /dev/null +++ b/src/routes/phases/update.spec.js @@ -0,0 +1,167 @@ +/* eslint-disable no-unused-expressions */ +import _ from 'lodash'; +import chai from 'chai'; +import request from 'supertest'; +import server from '../../app'; +import models from '../../models'; +import testUtil from '../../tests/util'; + +const should = chai.should(); + +const body = { + name: 'test project phase', + status: 'active', + startDate: '2018-05-15T00:00:00Z', + endDate: '2018-05-15T12:00:00Z', + budget: 20.0, + progress: 1.23456, + details: { + message: 'This can be any json', + }, + createdBy: 1, + updatedBy: 1, +}; + +const updateBody = { + name: 'test project phase xxx', + status: 'inactive', + startDate: '2018-05-11T00:00:00Z', + endDate: '2018-05-12T12:00:00Z', + budget: 123456.789, + progress: 9.8765432, + details: { + message: 'This is another json', + }, +}; + +describe('Project Phases', () => { + let projectId; + let phaseId; + before((done) => { + // mocks + testUtil.clearDb() + .then(() => { + models.Project.create({ + type: 'generic', + billingAccountId: 1, + name: 'test1', + description: 'test project1', + status: 'draft', + details: {}, + createdBy: 1, + updatedBy: 1, + }).then((p) => { + projectId = p.id; + // create members + models.ProjectMember.create({ + userId: 40051332, + projectId, + role: 'copilot', + isPrimary: true, + createdBy: 1, + updatedBy: 1, + }).then(() => { + _.assign(body, { projectId }); + models.ProjectPhase.create(body).then((phase) => { + phaseId = phase.id; + done(); + }); + }); + }); + }); + }); + + after((done) => { + testUtil.clearDb(done); + }); + + describe('PATCH /projects/{projectId}/phases/{phaseId}', () => { + it('should return 403 when user have no permission', (done) => { + request(server) + .patch(`/v4/projects/${projectId}/phases/${phaseId}`) + .set({ + Authorization: `Bearer ${testUtil.jwts.member}`, + }) + .send({ param: updateBody }) + .expect('Content-Type', /json/) + .expect(403, done); + }); + + it('should return 404 when no project with specific projectId', (done) => { + request(server) + .patch(`/v4/projects/999/phases/${phaseId}`) + .set({ + Authorization: `Bearer ${testUtil.jwts.manager}`, + }) + .send({ param: updateBody }) + .expect('Content-Type', /json/) + .expect(404, done); + }); + + it('should return 404 when no phase with specific phaseId', (done) => { + request(server) + .patch(`/v4/projects/${projectId}/phases/999`) + .set({ + Authorization: `Bearer ${testUtil.jwts.manager}`, + }) + .send({ param: updateBody }) + .expect('Content-Type', /json/) + .expect(404, done); + }); + + it('should return 422 when parameters are invalid', (done) => { + request(server) + .patch(`/v4/projects/${projectId}/phases/${phaseId}`) + .set({ + Authorization: `Bearer ${testUtil.jwts.manager}`, + }) + .send({ + param: { + progress: -15, + }, + }) + .expect('Content-Type', /json/) + .expect(422, done); + }); + + it('should return 400 when startDate >= endDate', (done) => { + request(server) + .patch(`/v4/projects/${projectId}/phases/${phaseId}`) + .set({ + Authorization: `Bearer ${testUtil.jwts.manager}`, + }) + .send({ + param: { + endDate: '2018-05-13T00:00:00Z', + }, + }) + .expect('Content-Type', /json/) + .expect(400, done); + }); + + it('should return updated phase when user have permission and parameters are valid', (done) => { + request(server) + .patch(`/v4/projects/${projectId}/phases/${phaseId}`) + .set({ + Authorization: `Bearer ${testUtil.jwts.copilot}`, + }) + .send({ param: updateBody }) + .expect('Content-Type', /json/) + .expect(200) + .end((err, res) => { + if (err) { + done(err); + } else { + const resJson = res.body.result.content; + should.exist(resJson); + resJson.name.should.be.eql(updateBody.name); + resJson.status.should.be.eql(updateBody.status); + resJson.budget.should.be.eql(updateBody.budget); + resJson.progress.should.be.eql(updateBody.progress); + resJson.details.should.be.eql(updateBody.details); + done(); + } + }); + }); + }); +}); From fb0fc89e23c743b36ec2be0c7b2f18e7d5beee91 Mon Sep 17 00:00:00 2001 From: Paulo Vitor Magacho Date: Thu, 24 May 2018 09:15:08 -0300 Subject: [PATCH 2/3] merge from phases and products elasticsearch challenge --- config/default.json | 2 +- local/mock-services/authMiddleware.js | 2 +- local/mock-services/server.js | 5 +- local/mock-services/services.json | 150 ++ migrations/elasticsearch_sync.js | 4 + migrations/seedElasticsearchIndex.js | 28 +- postman.json | 1083 ++++++++++- postman_environment.json | 22 + src/constants.js | 16 + src/events/busApi.js | 217 ++- src/events/index.js | 13 + src/events/phaseProducts/index.js | 129 ++ src/events/projectPhases/index.js | 110 ++ src/models/phaseProduct.js | 2 +- src/models/projectPhase.js | 2 +- src/routes/phaseProducts/create.js | 23 +- src/routes/phaseProducts/delete.js | 14 +- src/routes/phaseProducts/list.js | 56 +- src/routes/phaseProducts/list.spec.js | 98 +- src/routes/phaseProducts/update.js | 17 + src/routes/phases/create.js | 11 + src/routes/phases/delete.js | 12 +- src/routes/phases/list.js | 85 +- src/routes/phases/list.spec.js | 67 +- src/routes/phases/update.js | 19 +- swagger.yaml | 2424 +++++++++++++++---------- 26 files changed, 3422 insertions(+), 1189 deletions(-) create mode 100644 postman_environment.json create mode 100644 src/events/phaseProducts/index.js create mode 100644 src/events/projectPhases/index.js diff --git a/config/default.json b/config/default.json index 9cd04951..35cb7d30 100644 --- a/config/default.json +++ b/config/default.json @@ -35,7 +35,7 @@ "analyticsKey": "", "validIssuers": "[\"https:\/\/topcoder-newauth.auth0.com\/\",\"https:\/\/api.topcoder-dev.com\"]", "jwksUri": "", - "busApiUrl": "http://api.topcoder-dev.com", + "busApiUrl": "http://api.topcoder-dev.com/v5", "busApiToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJuYW1lIjoicHJvamVjdC1zZXJ2aWNlIiwiaWF0IjoxNTEyNzQ3MDgyLCJleHAiOjE1MjEzODcwODJ9.PHuNcFDaotGAL8RhQXQMdpL8yOKXxjB5DbBIodmt7RE", "HEALTH_CHECK_URL": "_health", "maxPhaseProductCount": 1 diff --git a/local/mock-services/authMiddleware.js b/local/mock-services/authMiddleware.js index f7939a67..9cf0387c 100644 --- a/local/mock-services/authMiddleware.js +++ b/local/mock-services/authMiddleware.js @@ -1,5 +1,5 @@ module.exports = function def(req, res, next) { - if (req.method === 'POST' && req.url === '/authorizations/') { + if (req.method === 'POST' && (req.url === '/authorizations/' || req.url === '/authorizations')) { const resp = { id: '1', result: { diff --git a/local/mock-services/server.js b/local/mock-services/server.js index 0a5ca5fd..029df5b7 100644 --- a/local/mock-services/server.js +++ b/local/mock-services/server.js @@ -23,9 +23,10 @@ server.use(authMiddleware); // add additional search route for project members server.get('/v3/members/_search', (req, res) => { const fields = _.isString(req.query.fields) ? req.query.fields.split(',') : []; - const filter = _.isString(req.query.query) ? req.query.query.split(' OR ') : []; + const filter = _.isString(req.query.query) ? + req.query.query.replace('%2520', ' ').replace('%20', ' ').split(' OR ') : []; const criteria = _.map(filter, (single) => { - const ret = { }; + const ret = {}; const splitted = single.split(':'); // if the result can be parsed successfully const parsed = jsprim.parseInteger(splitted[1], { allowTrailing: true, trimWhitespace: true }); diff --git a/local/mock-services/services.json b/local/mock-services/services.json index e391a9b9..87ef7863 100644 --- a/local/mock-services/services.json +++ b/local/mock-services/services.json @@ -99,6 +99,156 @@ } }, "version": "v3" + }, + { + "id": "test_customer1", + "result": { + "success": true, + "status": 200, + "metadata": null, + "content": { + "maxRating": { + "rating": 1114, + "track": "DATA_SCIENCE", + "subTrack": "SRM" + }, + "createdBy": "40011578", + "updatedBy": "40011578", + "userId": 40051331, + "firstName": "Firstname", + "lastName": "Lastname", + "quote": "It is a mistake to think you can solve any major problems just with potatoes.", + "description": null, + "otherLangName": null, + "handle": "test_customer1", + "handleLower": "test_customer1", + "status": "ACTIVE", + "email": "test_customer1@email.com", + "addresses": [ + { + "streetAddr1": "100 Main Street", + "streetAddr2": "", + "city": "Chicago", + "zip": "60601", + "stateCode": "IL", + "type": "HOME", + "updatedAt": null, + "createdAt": null, + "createdBy": null, + "updatedBy": null + } + ], + "homeCountryCode": "USA", + "competitionCountryCode": "USA", + "photoURL": null, + "tracks": [ + "DEVELOP" + ], + "updatedAt": "2015-12-02T14:00Z", + "createdAt": "2014-04-10T10:55Z" + } + }, + "version": "v3" + }, + { + "id": "test_copilot1", + "result": { + "success": true, + "status": 200, + "metadata": null, + "content": { + "maxRating": { + "rating": 1114, + "track": "DATA_SCIENCE", + "subTrack": "SRM" + }, + "createdBy": "40011578", + "updatedBy": "40011578", + "userId": 40051332, + "firstName": "Firstname", + "lastName": "Lastname", + "quote": "It is a mistake to think you can solve any major problems just with potatoes.", + "description": null, + "otherLangName": null, + "handle": "test_copilot1", + "handleLower": "test_copilot1", + "status": "ACTIVE", + "email": "test_copilot1@email.com", + "addresses": [ + { + "streetAddr1": "100 Main Street", + "streetAddr2": "", + "city": "Chicago", + "zip": "60601", + "stateCode": "IL", + "type": "HOME", + "updatedAt": null, + "createdAt": null, + "createdBy": null, + "updatedBy": null + } + ], + "homeCountryCode": "USA", + "competitionCountryCode": "USA", + "photoURL": null, + "tracks": [ + "DEVELOP" + ], + "updatedAt": "2015-12-02T14:00Z", + "createdAt": "2014-04-10T10:55Z" + } + }, + "version": "v3" + }, + { + "id": "test_manager1", + "result": { + "success": true, + "status": 200, + "metadata": null, + "content": { + "maxRating": { + "rating": 1114, + "track": "DATA_SCIENCE", + "subTrack": "SRM" + }, + "createdBy": "40011578", + "updatedBy": "40011578", + "userId": 40051333, + "firstName": "Firstname", + "lastName": "Lastname", + "quote": "It is a mistake to think you can solve any major problems just with potatoes.", + "description": null, + "otherLangName": null, + "handle": "test_manager1", + "handleLower": "test_manager1", + "status": "ACTIVE", + "email": "test_manager1@email.com", + "addresses": [ + { + "streetAddr1": "100 Main Street", + "streetAddr2": "", + "city": "Chicago", + "zip": "60601", + "stateCode": "IL", + "type": "HOME", + "updatedAt": null, + "createdAt": null, + "createdBy": null, + "updatedBy": null + } + ], + "homeCountryCode": "USA", + "competitionCountryCode": "USA", + "photoURL": null, + "tracks": [ + "DEVELOP" + ], + "updatedAt": "2015-12-02T14:00Z", + "createdAt": "2014-04-10T10:55Z" + } + }, + "version": "v3" } ] } diff --git a/migrations/elasticsearch_sync.js b/migrations/elasticsearch_sync.js index 1c9e5713..321f86cb 100644 --- a/migrations/elasticsearch_sync.js +++ b/migrations/elasticsearch_sync.js @@ -293,6 +293,10 @@ function getRequestBody(indexName) { }, }, }, + phases: { + type: 'nested', + dynamic: true, + }, }, }; switch (indexName) { diff --git a/migrations/seedElasticsearchIndex.js b/migrations/seedElasticsearchIndex.js index 7752efeb..4a10ec48 100644 --- a/migrations/seedElasticsearchIndex.js +++ b/migrations/seedElasticsearchIndex.js @@ -33,15 +33,21 @@ Promise.coroutine(function* wrapped() { config.get('pubsubQueueName'), ); - const projectIds = getProjectIds(); const projectWhereClause = (projectIds.length > 0) ? { id: { $in: projectIds } } : { deletedAt: { $eq: null } }; - const projects = yield models.Project.findAll({ + let projects = yield models.Project.findAll({ where: projectWhereClause, - raw: true, + include: [{ + model: models.ProjectPhase, + as: 'phases', + include: [{ model: models.PhaseProduct, as: 'products' }], + }], }); logger.info(`Retrieved #${projects.length} projects`); + // Convert to raw json + projects = _.map(projects, project => project.toJSON()); + const memberWhereClause = (projectIds.length > 0) ? { projectId: { $in: projectIds } } : { deletedAt: { $eq: null } }; @@ -59,14 +65,14 @@ Promise.coroutine(function* wrapped() { promises.push(rabbit.publish('project.initial', p, {})); }); Promise.all(promises) - .then(() => { - logger.info(`Published ${promises.length} msgs`); - process.exit(); - }) - .catch((err) => { - logger.error(err); - process.exit(); - }); + .then(() => { + logger.info(`Published ${promises.length} msgs`); + process.exit(); + }) + .catch((err) => { + logger.error(err); + process.exit(); + }); } catch (err) { logger.error(err); process.exit(); diff --git a/postman.json b/postman.json index 807c4f87..11ebff26 100644 --- a/postman.json +++ b/postman.json @@ -1,13 +1,13 @@ { "info": { + "_postman_id": "0d2b00c1-bd90-40ab-ba13-e730e4ddfcf4", "name": "tc-project-service ", - "_postman_id": "8f323d9c-63bd-5f2c-87f1-1e99083786f3", - "description": "", - "schema": "https://schema.getpostman.com/json/collection/v2.0.0/collection.json" + "schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json" }, "item": [ { "name": "Project Attachments", + "description": null, "item": [ { "name": "Upload attachment", @@ -27,7 +27,18 @@ "mode": "raw", "raw": "{\n\t\"param\": {\n\t\t\"title\": \"first attachment submission\",\n\t\t\"filePath\": \"asdjshdasdas/asdsadj/asdasd.png\",\n\t\t\"s3Bucket\": \"topcoder-project-service\",\n\t\t\"contentType\": \"application/png\"\n\t}\n}" }, - "url": "{{api-url}}/v4/projects/7/attachments", + "url": { + "raw": "{{api-url}}/v4/projects/7/attachments", + "host": [ + "{{api-url}}" + ], + "path": [ + "v4", + "projects", + "7", + "attachments" + ] + }, "description": "Create an project attachment" }, "response": [] @@ -50,7 +61,19 @@ "mode": "raw", "raw": "{\n\t\"param\": {\n\t\t\"title\": \"first attachment submission updated\",\n\t\t\"description\": \"updated project attachment\"\n\t}\n}" }, - "url": "{{api-url}}/v4/projects/7/attachments/2", + "url": { + "raw": "{{api-url}}/v4/projects/7/attachments/2", + "host": [ + "{{api-url}}" + ], + "path": [ + "v4", + "projects", + "7", + "attachments", + "2" + ] + }, "description": "Update project attachment" }, "response": [] @@ -73,7 +96,19 @@ "mode": "raw", "raw": "" }, - "url": "{{api-url}}/v4/projects/7/attachments/2", + "url": { + "raw": "{{api-url}}/v4/projects/7/attachments/2", + "host": [ + "{{api-url}}" + ], + "path": [ + "v4", + "projects", + "7", + "attachments", + "2" + ] + }, "description": "Delete a project attachment" }, "response": [] @@ -82,6 +117,7 @@ }, { "name": "Project Members", + "description": null, "item": [ { "name": "Create project member with no payload", @@ -101,7 +137,18 @@ "mode": "raw", "raw": "" }, - "url": "{{api-url}}/v4/projects/1/members", + "url": { + "raw": "{{api-url}}/v4/projects/1/members", + "host": [ + "{{api-url}}" + ], + "path": [ + "v4", + "projects", + "1", + "members" + ] + }, "description": "Request payload is mandatory while creating project. If no request payload is specified this should result in 422 status code." }, "response": [] @@ -124,7 +171,18 @@ "mode": "raw", "raw": "{\n\t\"role\": \"copilot\"\n}" }, - "url": "{{api-url}}/v4/projects/1/members", + "url": { + "raw": "{{api-url}}/v4/projects/1/members", + "host": [ + "{{api-url}}" + ], + "path": [ + "v4", + "projects", + "1", + "members" + ] + }, "description": "Certain fields are mandatory while creating project. If invalid fields are specified this should result in 422 status code." }, "response": [] @@ -147,7 +205,18 @@ "mode": "raw", "raw": "{\n\t\"param\": {\n\t\t\"role\": \"copilot\",\n\t\t\"userId\": 40051331,\n\t\t\"isPrimary\": true\n\t}\n}" }, - "url": "{{api-url}}/v4/projects/7/members", + "url": { + "raw": "{{api-url}}/v4/projects/7/members", + "host": [ + "{{api-url}}" + ], + "path": [ + "v4", + "projects", + "7", + "members" + ] + }, "description": "If the request payload is valid, than project member should be created." }, "response": [] @@ -170,7 +239,18 @@ "mode": "raw", "raw": "{\n\t\"param\": {\n\t\t\"role\": \"copilot\",\n\t\t\"userId\": 40051331,\n\t\t\"isPrimary\": true\n\t}\n}" }, - "url": "{{api-url}}/v4/projects/1/members", + "url": { + "raw": "{{api-url}}/v4/projects/1/members", + "host": [ + "{{api-url}}" + ], + "path": [ + "v4", + "projects", + "1", + "members" + ] + }, "description": "If the request payload is valid and user is already registered with the specified role than this should result in 400." }, "response": [] @@ -193,7 +273,18 @@ "mode": "raw", "raw": "{\n\t\"param\": {\n\t\t\"role\": \"manager\",\n\t\t\"userId\": 40051330,\n\t\t\"isPrimary\": true\n\t}\n}" }, - "url": "{{api-url}}/v4/projects/7/members", + "url": { + "raw": "{{api-url}}/v4/projects/7/members", + "host": [ + "{{api-url}}" + ], + "path": [ + "v4", + "projects", + "7", + "members" + ] + }, "description": "If the request payload is valid, than project manager should be added. This should sync with the direct project is project is associated with direct project." }, "response": [] @@ -216,7 +307,18 @@ "mode": "raw", "raw": "{\n\t\"param\": {\n\t\t\"role\": \"customer\",\n\t\t\"userId\": 40051332,\n\t\t\"isPrimary\": true\n\t}\n}" }, - "url": "{{api-url}}/v4/projects/7/members", + "url": { + "raw": "{{api-url}}/v4/projects/7/members", + "host": [ + "{{api-url}}" + ], + "path": [ + "v4", + "projects", + "7", + "members" + ] + }, "description": "If the request payload is valid, than project customer should be added. This should sync with the direct project is project is associated with direct project." }, "response": [] @@ -239,7 +341,19 @@ "mode": "raw", "raw": "{\n\t\"param\": {\n\t\t\"role\": \"copilot\",\n\t\t\"isPrimary\": true\n\t}\n}" }, - "url": "{{api-url}}/v4/projects/7/members/16", + "url": { + "raw": "{{api-url}}/v4/projects/7/members/16", + "host": [ + "{{api-url}}" + ], + "path": [ + "v4", + "projects", + "7", + "members", + "16" + ] + }, "description": "Update a project's member." }, "response": [] @@ -262,7 +376,19 @@ "mode": "raw", "raw": "{\n\t\"param\": {\n\t\t\"role\": \"copilot\",\n\t\t\"isPrimary\": false\n\t}\n}" }, - "url": "{{api-url}}/v4/projects/7/members/16", + "url": { + "raw": "{{api-url}}/v4/projects/7/members/16", + "host": [ + "{{api-url}}" + ], + "path": [ + "v4", + "projects", + "7", + "members", + "16" + ] + }, "description": "Update a project's member." }, "response": [] @@ -285,7 +411,19 @@ "mode": "raw", "raw": "" }, - "url": "{{api-url}}/v4/projects/7/members/15", + "url": { + "raw": "{{api-url}}/v4/projects/7/members/15", + "host": [ + "{{api-url}}" + ], + "path": [ + "v4", + "projects", + "7", + "members", + "15" + ] + }, "description": "Delete a project's member" }, "response": [] @@ -314,7 +452,16 @@ "mode": "raw", "raw": "{\n\t\n}" }, - "url": "{{api-url}}/v4/projects", + "url": { + "raw": "{{api-url}}/v4/projects", + "host": [ + "{{api-url}}" + ], + "path": [ + "v4", + "projects" + ] + }, "description": "Request body is mandatory while creating project. If invalid request body is supplied this should return 422 status code." }, "response": [] @@ -337,7 +484,16 @@ "mode": "raw", "raw": "{\n\t\"param\": {\n\t}\n}" }, - "url": "{{api-url}}/v4/projects", + "url": { + "raw": "{{api-url}}/v4/projects", + "host": [ + "{{api-url}}" + ], + "path": [ + "v4", + "projects" + ] + }, "description": "Certain fields are mandatory while creating project. If invalid request body is supplied this should return 422 status code." }, "response": [] @@ -360,7 +516,16 @@ "mode": "raw", "raw": "{\n\t\"param\": {\n\t\t\"name\": \"test project\",\n\t\t\"description\": \"Hello I am a test project\",\n\t\t\"type\": \"generic\"\n\t}\n}" }, - "url": "{{api-url}}/v4/projects", + "url": { + "raw": "{{api-url}}/v4/projects", + "host": [ + "{{api-url}}" + ], + "path": [ + "v4", + "projects" + ] + }, "description": "Valid request body. Project should be created successfully." }, "response": [] @@ -379,7 +544,17 @@ "mode": "raw", "raw": "" }, - "url": "{{api-url}}/v4/projects/7", + "url": { + "raw": "{{api-url}}/v4/projects/7", + "host": [ + "{{api-url}}" + ], + "path": [ + "v4", + "projects", + "7" + ] + }, "description": "Get a project by id. project members and attachments should also be returned." }, "response": [] @@ -433,7 +608,16 @@ "mode": "raw", "raw": "" }, - "url": "{{api-url}}/v4/projects", + "url": { + "raw": "{{api-url}}/v4/projects", + "host": [ + "{{api-url}}" + ], + "path": [ + "v4", + "projects" + ] + }, "description": "List all the project with no filter. Default sort and limits are applied." }, "response": [] @@ -592,7 +776,17 @@ "mode": "raw", "raw": "" }, - "url": "{{api-url}}/v4/projects/3", + "url": { + "raw": "{{api-url}}/v4/projects/3", + "host": [ + "{{api-url}}" + ], + "path": [ + "v4", + "projects", + "3" + ] + }, "description": "Delete a project by id" }, "response": [] @@ -615,7 +809,17 @@ "mode": "raw", "raw": "{\n \"param\": {\n \"name\": \"project name updated\"\n }\n}" }, - "url": "{{api-url}}/v4/projects/13", + "url": { + "raw": "{{api-url}}/v4/projects/13", + "host": [ + "{{api-url}}" + ], + "path": [ + "v4", + "projects", + "13" + ] + }, "description": "Update the project name. Name should be updated successfully." }, "response": [] @@ -638,7 +842,17 @@ "mode": "raw", "raw": "{\n\t\"param\": {\n\t\t\"name\": \"project name updated\"\n\t}\n}" }, - "url": "{{api-url}}/v4/projects/2", + "url": { + "raw": "{{api-url}}/v4/projects/2", + "host": [ + "{{api-url}}" + ], + "path": [ + "v4", + "projects", + "2" + ] + }, "description": "Update the project name. If user don't have permission to the project than it should return 403." }, "response": [] @@ -661,7 +875,17 @@ "mode": "raw", "raw": "{\n\t\"param\": {\n\t\t\"name\": \"project name updated\"\n\t}\n}" }, - "url": "{{api-url}}/v4/projects/10", + "url": { + "raw": "{{api-url}}/v4/projects/10", + "host": [ + "{{api-url}}" + ], + "path": [ + "v4", + "projects", + "10" + ] + }, "description": "Update the project name. If project is not found than this result in 404 status code." }, "response": [] @@ -684,7 +908,17 @@ "mode": "raw", "raw": "{\n \"param\": {\n \"status\": \"in_review\"\n }\n}" }, - "url": "{{api-url}}/v4/projects/7", + "url": { + "raw": "{{api-url}}/v4/projects/7", + "host": [ + "{{api-url}}" + ], + "path": [ + "v4", + "projects", + "7" + ] + }, "description": "Update the project status." }, "response": [] @@ -707,7 +941,17 @@ "mode": "raw", "raw": "{\n \"param\": {\n \"status\": \"reviewed\"\n }\n}" }, - "url": "{{api-url}}/v4/projects/7", + "url": { + "raw": "{{api-url}}/v4/projects/7", + "host": [ + "{{api-url}}" + ], + "path": [ + "v4", + "projects", + "7" + ] + }, "description": "Update the project status." }, "response": [] @@ -730,7 +974,17 @@ "mode": "raw", "raw": "{\n \"param\": {\n \"status\": \"paused\"\n }\n}" }, - "url": "{{api-url}}/v4/projects/7", + "url": { + "raw": "{{api-url}}/v4/projects/7", + "host": [ + "{{api-url}}" + ], + "path": [ + "v4", + "projects", + "7" + ] + }, "description": "Update the project status." }, "response": [] @@ -753,7 +1007,17 @@ "mode": "raw", "raw": "{\n \"param\": {\n \"status\": \"cancelled\",\n \"cancelReason\": \"price/cost\"\n }\n}" }, - "url": "{{api-url}}/v4/projects/7", + "url": { + "raw": "{{api-url}}/v4/projects/7", + "host": [ + "{{api-url}}" + ], + "path": [ + "v4", + "projects", + "7" + ] + }, "description": "Update the project status. While cancelling the project `cancelReason` is mandatory." }, "response": [] @@ -776,7 +1040,17 @@ "mode": "raw", "raw": "{\n\t\"param\": {\n\t\t\"status\": \"cancelled\"\n\t}\n}" }, - "url": "{{api-url}}/v4/projects/1", + "url": { + "raw": "{{api-url}}/v4/projects/1", + "host": [ + "{{api-url}}" + ], + "path": [ + "v4", + "projects", + "1" + ] + }, "description": "Update the project status. While cancelling the project `cancelReason` is mandatory. If no `cancelReason` is supplied this should result in 422 status code." }, "response": [] @@ -799,7 +1073,17 @@ "mode": "raw", "raw": "{\n \"param\": {\n \"status\": \"completed\"\n }\n}" }, - "url": "{{api-url}}/v4/projects/7", + "url": { + "raw": "{{api-url}}/v4/projects/7", + "host": [ + "{{api-url}}" + ], + "path": [ + "v4", + "projects", + "7" + ] + }, "description": "Update the project status." }, "response": [] @@ -822,7 +1106,17 @@ "mode": "raw", "raw": "{\n\t\"param\": {\n\t\t\"status\": \"active\"\n\t}\n}" }, - "url": "{{api-url}}/v4/projects/1", + "url": { + "raw": "{{api-url}}/v4/projects/1", + "host": [ + "{{api-url}}" + ], + "path": [ + "v4", + "projects", + "1" + ] + }, "description": "Move a project out of cancel state. Only admin and manager is allowed to do so." }, "response": [] @@ -845,9 +1139,19 @@ "mode": "raw", "raw": "{\n\t\"param\": {\n\t\t\"status\": \"active\"\n\t}\n}" }, - "url": "{{api-url}}/v4/projects/1", - "description": "Move a project out of cancel state. Only admin and manager is allowed to do so." - }, + "url": { + "raw": "{{api-url}}/v4/projects/1", + "host": [ + "{{api-url}}" + ], + "path": [ + "v4", + "projects", + "1" + ] + }, + "description": "Move a project out of cancel state. Only admin and manager is allowed to do so." + }, "response": [] }, { @@ -868,7 +1172,17 @@ "mode": "raw", "raw": "{\n \"param\": {\n \"details\": {\n \"summary\": \"project name updated\"\n }\n }\n}" }, - "url": "{{api-url}}/v4/projects/8", + "url": { + "raw": "{{api-url}}/v4/projects/8", + "host": [ + "{{api-url}}" + ], + "path": [ + "v4", + "projects", + "8" + ] + }, "description": "Update the project details. This should fire specification modified event" }, "response": [] @@ -891,7 +1205,17 @@ "mode": "raw", "raw": "{\n \"param\": {\n \"bookmarks\": [\n {\n \"title\": \"test\",\n \"address\": \"http://topcoder.com\"\n }\n \n ]\n }\n}" }, - "url": "{{api-url}}/v4/projects/8", + "url": { + "raw": "{{api-url}}/v4/projects/8", + "host": [ + "{{api-url}}" + ], + "path": [ + "v4", + "projects", + "8" + ] + }, "description": "Update the project bookmarks. This should fire project link created event" }, "response": [] @@ -900,6 +1224,7 @@ }, { "name": "bookmarks", + "description": null, "item": [ { "name": " Create project without bookmarks", @@ -919,7 +1244,16 @@ "mode": "raw", "raw": "{\n \"param\": {\n \"type\": \"generic\",\n \"description\": \"test project\",\n \"details\": {},\n \"billingAccountId\": 123,\n \"name\": \"test project1\"\n }\n}" }, - "url": "{{api-url}}/v4/projects" + "url": { + "raw": "{{api-url}}/v4/projects", + "host": [ + "{{api-url}}" + ], + "path": [ + "v4", + "projects" + ] + } }, "response": [] }, @@ -941,7 +1275,16 @@ "mode": "raw", "raw": "{\n \"param\": {\n \"type\": \"generic\",\n \"description\": \"test project\",\n \"details\": {},\n \"bookmarks\":[{\n \"title\":\"title1\",\n \"address\":\"address1\"\n },{\n \"title\":\"title2\",\n \"address\":\"address2\"\n }],\n \"billingAccountId\": 123,\n \"name\": \"test project1\"\n }\n}" }, - "url": "{{api-url}}/v4/projects" + "url": { + "raw": "{{api-url}}/v4/projects", + "host": [ + "{{api-url}}" + ], + "path": [ + "v4", + "projects" + ] + } }, "response": [] }, @@ -963,7 +1306,16 @@ "mode": "raw", "raw": "{\n \"param\": {\n \"type\": \"generic\",\n \"description\": \"test project\",\n \"details\": {},\n \"bookmarks\":[{\n \"title\":\"title1\",\n \"invalid\":3,\n \"address\":\"address1\"\n },{\n \"title\":\"title2\",\n \"address\":\"address2\"\n }],\n \"billingAccountId\": 123,\n \"name\": \"test project1\"\n }\n}" }, - "url": "{{api-url}}/v4/projects" + "url": { + "raw": "{{api-url}}/v4/projects", + "host": [ + "{{api-url}}" + ], + "path": [ + "v4", + "projects" + ] + } }, "response": [] }, @@ -985,7 +1337,17 @@ "mode": "raw", "raw": "{\n \"param\": {\n \"billingAccountId\": 9999, \n \"name\": \"new project name\"\n }\n}" }, - "url": "{{api-url}}/v4/projects/2" + "url": { + "raw": "{{api-url}}/v4/projects/2", + "host": [ + "{{api-url}}" + ], + "path": [ + "v4", + "projects", + "2" + ] + } }, "response": [] }, @@ -1007,7 +1369,17 @@ "mode": "raw", "raw": "{\n \"param\": {\n \"billingAccountId\": 9999, \n \"name\": \"new project name\",\n \"bookmarks\":[{\n \"title\":\"title1\",\n \"address\":\"address1\"\n },{\n \"title\":\"title2\",\n \"address\":\"address2\"\n }]\n }\n}" }, - "url": "{{api-url}}/v4/projects/2" + "url": { + "raw": "{{api-url}}/v4/projects/2", + "host": [ + "{{api-url}}" + ], + "path": [ + "v4", + "projects", + "2" + ] + } }, "response": [] }, @@ -1029,7 +1401,17 @@ "mode": "raw", "raw": "{\n \"param\": {\n \"billingAccountId\": 9999, \n \"name\": \"new project name2\",\n \"bookmarks\":null\n }\n}" }, - "url": "{{api-url}}/v4/projects/2" + "url": { + "raw": "{{api-url}}/v4/projects/2", + "host": [ + "{{api-url}}" + ], + "path": [ + "v4", + "projects", + "2" + ] + } }, "response": [] }, @@ -1051,7 +1433,17 @@ "mode": "raw", "raw": "{\n \"param\": {\n \"billingAccountId\": 9999, \n \"name\": \"new project name2\",\n \"bookmarks\":3\n }\n}" }, - "url": "{{api-url}}/v4/projects/2" + "url": { + "raw": "{{api-url}}/v4/projects/2", + "host": [ + "{{api-url}}" + ], + "path": [ + "v4", + "projects", + "2" + ] + } }, "response": [] }, @@ -1069,7 +1461,16 @@ "mode": "raw", "raw": "" }, - "url": "{{api-url}}/v4/projects" + "url": { + "raw": "{{api-url}}/v4/projects", + "host": [ + "{{api-url}}" + ], + "path": [ + "v4", + "projects" + ] + } }, "response": [] } @@ -1077,6 +1478,7 @@ }, { "name": "issue1", + "description": null, "item": [ { "name": "get projects with copilot token", @@ -1092,7 +1494,16 @@ "mode": "raw", "raw": "" }, - "url": "{{api-url}}/v4/projects" + "url": { + "raw": "{{api-url}}/v4/projects", + "host": [ + "{{api-url}}" + ], + "path": [ + "v4", + "projects" + ] + } }, "response": [] } @@ -1100,6 +1511,7 @@ }, { "name": "issue10", + "description": null, "item": [ { "name": "wrong role", @@ -1119,7 +1531,19 @@ "mode": "raw", "raw": " {\n \"param\": {\n \"role\": \"wrong\"\n }\n } " }, - "url": "{{api-url}}/v4/projects/3/members/5" + "url": { + "raw": "{{api-url}}/v4/projects/3/members/5", + "host": [ + "{{api-url}}" + ], + "path": [ + "v4", + "projects", + "3", + "members", + "5" + ] + } }, "response": [] }, @@ -1141,7 +1565,19 @@ "mode": "raw", "raw": " {\n \"param\": {\n \"role\": \"manager\",\n \"isPrimary\": true\n }\n } " }, - "url": "{{api-url}}/v4/projects/1/members/1" + "url": { + "raw": "{{api-url}}/v4/projects/1/members/1", + "host": [ + "{{api-url}}" + ], + "path": [ + "v4", + "projects", + "1", + "members", + "1" + ] + } }, "response": [] } @@ -1149,6 +1585,7 @@ }, { "name": "issue5", + "description": null, "item": [ { "name": "launch a project by topcoder managers ", @@ -1168,7 +1605,17 @@ "mode": "raw", "raw": "{\n \n \"param\":{\n \"name\": \"updatedProject name\",\n \"status\": \"active\"\n }\n}" }, - "url": "{{api-url}}/v4/projects/1" + "url": { + "raw": "{{api-url}}/v4/projects/1", + "host": [ + "{{api-url}}" + ], + "path": [ + "v4", + "projects", + "1" + ] + } }, "response": [] }, @@ -1190,7 +1637,17 @@ "mode": "raw", "raw": "{\n \n \"param\":{\n \"name\": \"updatedProject name\",\n \"status\": \"active\"\n }\n}" }, - "url": "{{api-url}}/v4/projects/1" + "url": { + "raw": "{{api-url}}/v4/projects/1", + "host": [ + "{{api-url}}" + ], + "path": [ + "v4", + "projects", + "1" + ] + } }, "response": [] }, @@ -1212,7 +1669,17 @@ "mode": "raw", "raw": "{\n \n \"param\":{\n \"name\": \"updatedProject name\",\n \"status\": \"active\"\n }\n}" }, - "url": "{{api-url}}/v4/projects/1" + "url": { + "raw": "{{api-url}}/v4/projects/1", + "host": [ + "{{api-url}}" + ], + "path": [ + "v4", + "projects", + "1" + ] + } }, "response": [] } @@ -1220,6 +1687,7 @@ }, { "name": "issue8", + "description": null, "item": [ { "name": "mock direct projects", @@ -1239,7 +1707,19 @@ "mode": "raw", "raw": " {\n \"param\": {\n \"role\": \"copilot\",\n \"isPrimary\": true\n }\n } " }, - "url": "https://localhost:8443/v3/direct/projects" + "url": { + "raw": "https://localhost:8443/v3/direct/projects", + "protocol": "https", + "host": [ + "localhost" + ], + "port": "8443", + "path": [ + "v3", + "direct", + "projects" + ] + } }, "response": [] }, @@ -1261,7 +1741,16 @@ "mode": "raw", "raw": "{\n \"param\": {\n \"type\": \"generic\",\n \"description\": \"test project\",\n \"details\": {},\n \"billingAccountId\": 123,\n \"name\": \"test project1\"\n }\n}" }, - "url": "{{api-url}}/v4/projects" + "url": { + "raw": "{{api-url}}/v4/projects", + "host": [ + "{{api-url}}" + ], + "path": [ + "v4", + "projects" + ] + } }, "response": [] }, @@ -1283,7 +1772,18 @@ "mode": "raw", "raw": "{\n \"param\": {\n \"userId\": 2, \n \"role\": \"copilot\"\n }\n}" }, - "url": "{{api-url}}/v4/projects/1/members" + "url": { + "raw": "{{api-url}}/v4/projects/1/members", + "host": [ + "{{api-url}}" + ], + "path": [ + "v4", + "projects", + "1", + "members" + ] + } }, "response": [] }, @@ -1305,7 +1805,18 @@ "mode": "raw", "raw": "{\n \"param\": {\n \"userId\": 2, \n \"role\": \"copilot\"\n }\n}" }, - "url": "{{api-url}}/v4/projects/2/members" + "url": { + "raw": "{{api-url}}/v4/projects/2/members", + "host": [ + "{{api-url}}" + ], + "path": [ + "v4", + "projects", + "2", + "members" + ] + } }, "response": [] }, @@ -1327,7 +1838,19 @@ "mode": "raw", "raw": " {\n \"param\": {\n \"role\": \"customer\",\n \"isPrimary\": true\n }\n } " }, - "url": "{{api-url}}/v4/projects/2/members/4" + "url": { + "raw": "{{api-url}}/v4/projects/2/members/4", + "host": [ + "{{api-url}}" + ], + "path": [ + "v4", + "projects", + "2", + "members", + "4" + ] + } }, "response": [] }, @@ -1349,7 +1872,17 @@ "mode": "raw", "raw": "{\n \"param\": {\n \"billingAccountId\": 9999, \n \"name\": \"new project name\"\n }\n}" }, - "url": "{{api-url}}/v4/projects/2" + "url": { + "raw": "{{api-url}}/v4/projects/2", + "host": [ + "{{api-url}}" + ], + "path": [ + "v4", + "projects", + "2" + ] + } }, "response": [] }, @@ -1371,7 +1904,443 @@ "mode": "raw", "raw": "" }, - "url": "{{api-url}}/v4/projects/2/members/4" + "url": { + "raw": "{{api-url}}/v4/projects/2/members/4", + "host": [ + "{{api-url}}" + ], + "path": [ + "v4", + "projects", + "2", + "members", + "4" + ] + } + }, + "response": [] + } + ] + }, + { + "name": "Project Phase", + "description": null, + "item": [ + { + "name": "Create Phase", + "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 phase\",\n\t\t\"status\": \"active\",\n\t\t\"startDate\": \"2018-05-15T00:00:00\",\n\t\t\"endDate\": \"2018-05-16T00:00:00\",\n\t\t\"budget\": 20,\n\t\t\"details\": {\n\t\t\t\"aDetails\": \"a details\"\n\t\t}\n\t}\n}" + }, + "url": { + "raw": "{{api-url}}/v4/projects/1/phases", + "host": [ + "{{api-url}}" + ], + "path": [ + "v4", + "projects", + "1", + "phases" + ] + } + }, + "response": [] + }, + { + "name": "List Phase", + "request": { + "method": "GET", + "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 phase\",\n\t\t\"status\": \"active\",\n\t\t\"startDate\": \"2018-05-15T00:00:00\",\n\t\t\"endDate\": \"2018-05-16T00:00:00\",\n\t\t\"budget\": 20\n\t}\n}" + }, + "url": { + "raw": "{{api-url}}/v4/projects/1/phases", + "host": [ + "{{api-url}}" + ], + "path": [ + "v4", + "projects", + "1", + "phases" + ] + } + }, + "response": [] + }, + { + "name": "List Phase with fields", + "request": { + "method": "GET", + "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 phase\",\n\t\t\"status\": \"active\",\n\t\t\"startDate\": \"2018-05-15T00:00:00\",\n\t\t\"endDate\": \"2018-05-16T00:00:00\",\n\t\t\"budget\": 20\n\t}\n}" + }, + "url": { + "raw": "{{api-url}}/v4/projects/1/phases?fields=status,name,budget", + "host": [ + "{{api-url}}" + ], + "path": [ + "v4", + "projects", + "1", + "phases" + ], + "query": [ + { + "key": "fields", + "value": "status,name,budget" + } + ] + } + }, + "response": [] + }, + { + "name": "List Phase with sort", + "request": { + "method": "GET", + "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 phase\",\n\t\t\"status\": \"active\",\n\t\t\"startDate\": \"2018-05-15T00:00:00\",\n\t\t\"endDate\": \"2018-05-16T00:00:00\",\n\t\t\"budget\": 20\n\t}\n}" + }, + "url": { + "raw": "{{api-url}}/v4/projects/1/phases?sort=status desc", + "host": [ + "{{api-url}}" + ], + "path": [ + "v4", + "projects", + "1", + "phases" + ], + "query": [ + { + "key": "sort", + "value": "status desc" + } + ] + } + }, + "response": [] + }, + { + "name": "Get Phase", + "request": { + "method": "GET", + "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 phase\",\n\t\t\"status\": \"active\",\n\t\t\"startDate\": \"2018-05-15T00:00:00\",\n\t\t\"endDate\": \"2018-05-16T00:00:00\",\n\t\t\"budget\": 20\n\t}\n}" + }, + "url": { + "raw": "{{api-url}}/v4/projects/1/phases/1", + "host": [ + "{{api-url}}" + ], + "path": [ + "v4", + "projects", + "1", + "phases", + "1" + ] + } + }, + "response": [] + }, + { + "name": "Update Phase", + "request": { + "method": "PATCH", + "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 phase xxx\",\n\t\t\"status\": \"inactive\",\n\t\t\"startDate\": \"2018-05-14T00:00:00\",\n\t\t\"endDate\": \"2018-05-15T00:00:00\",\n\t\t\"budget\": 30,\n\t\t\"progress\": 15,\n\t\t\"details\": {\n\t\t\t\"message\": \"phase details\"\n\t\t}\n\t}\n}" + }, + "url": { + "raw": "{{api-url}}/v4/projects/1/phases/1", + "host": [ + "{{api-url}}" + ], + "path": [ + "v4", + "projects", + "1", + "phases", + "1" + ] + } + }, + "response": [] + }, + { + "name": "Delete Phase", + "request": { + "method": "DELETE", + "header": [ + { + "key": "Authorization", + "value": "Bearer {{jwt-token}}" + }, + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "" + }, + "url": { + "raw": "{{api-url}}/v4/projects/1/phases/3", + "host": [ + "{{api-url}}" + ], + "path": [ + "v4", + "projects", + "1", + "phases", + "3" + ] + } + }, + "response": [] + } + ] + }, + { + "name": "Phase Products", + "description": null, + "item": [ + { + "name": "Create Phase Product", + "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 phase product\",\n\t\t\"type\": \"type 1\",\n\t\t\"estimatedPrice\": 10\n\t}\n}" + }, + "url": { + "raw": "{{api-url}}/v4/projects/1/phases/1/products", + "host": [ + "{{api-url}}" + ], + "path": [ + "v4", + "projects", + "1", + "phases", + "1", + "products" + ] + } + }, + "response": [] + }, + { + "name": "List Phase Products", + "request": { + "method": "GET", + "header": [ + { + "key": "Authorization", + "value": "Bearer {{jwt-token}}" + } + ], + "body": { + "mode": "raw", + "raw": "" + }, + "url": { + "raw": "{{api-url}}/v4/projects/1/phases/1/products", + "host": [ + "{{api-url}}" + ], + "path": [ + "v4", + "projects", + "1", + "phases", + "1", + "products" + ] + } + }, + "response": [] + }, + { + "name": "Get Phase Product", + "request": { + "method": "GET", + "header": [ + { + "key": "Authorization", + "value": "Bearer {{jwt-token}}" + } + ], + "body": { + "mode": "raw", + "raw": "" + }, + "url": { + "raw": "{{api-url}}/v4/projects/1/phases/1/products/1", + "host": [ + "{{api-url}}" + ], + "path": [ + "v4", + "projects", + "1", + "phases", + "1", + "products", + "1" + ] + } + }, + "response": [] + }, + { + "name": "Update Phase Product", + "request": { + "method": "PATCH", + "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 phase product xxx\",\n\t\t\"type\": \"type 2\",\n\t\t\"templateId\": 10,\n\t\t\"estimatedPrice\": 1.234567,\n\t\t\"actualPrice\": 2.34567,\n\t\t\"details\": {\n\t\t\t\"message\": \"this is a JSON type. You can use any json\"\n\t\t}\n\t}\n}" + }, + "url": { + "raw": "{{api-url}}/v4/projects/1/phases/1/products/1", + "host": [ + "{{api-url}}" + ], + "path": [ + "v4", + "projects", + "1", + "phases", + "1", + "products", + "1" + ] + } + }, + "response": [] + }, + { + "name": "Delete Phase Product", + "request": { + "method": "DELETE", + "header": [ + { + "key": "Authorization", + "value": "Bearer {{jwt-token}}" + } + ], + "body": { + "mode": "raw", + "raw": "" + }, + "url": { + "raw": "{{api-url}}/v4/projects/1/phases/1/products/1", + "host": [ + "{{api-url}}" + ], + "path": [ + "v4", + "projects", + "1", + "phases", + "1", + "products", + "1" + ] + } }, "response": [] } diff --git a/postman_environment.json b/postman_environment.json new file mode 100644 index 00000000..4f8f54be --- /dev/null +++ b/postman_environment.json @@ -0,0 +1,22 @@ +{ + "id": "1d4b6c34-6da6-8651-3372-9c6d4d09cc8c", + "name": "project service", + "values": [ + { + "enabled": true, + "key": "api-url", + "value": "http://localhost:3000", + "type": "text" + }, + { + "enabled": true, + "key": "jwt-token", + "value": "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJyb2xlcyI6WyJhZG1pbmlzdHJhdG9yIl0sImlzcyI6Imh0dHBzOi8vYXBpLnRvcGNvZGVyLWRldi5jb20iLCJoYW5kbGUiOiJwc2hhaDEiLCJleHAiOjI0NjI0OTQ2MTgsInVzZXJJZCI6IjQwMTM1OTc4IiwiaWF0IjoxNDYyNDk0MDE4LCJlbWFpbCI6InBzaGFoMUB0ZXN0LmNvbSIsImp0aSI6ImY0ZTFhNTE0LTg5ODAtNDY0MC04ZWM1LWUzNmUzMWE3ZTg0OSJ9.XuNN7tpMOXvBG1QwWRQROj7NfuUbqhkjwn39Vy4tR5I", + "type": "text" + } + ], + "timestamp": 1526351351170, + "_postman_variable_scope": "environment", + "_postman_exported_at": "2018-05-15T14:19:14.630Z", + "_postman_exported_using": "Postman/5.5.2" +} \ No newline at end of file diff --git a/src/constants.js b/src/constants.js index e642eb66..0f2a46e0 100644 --- a/src/constants.js +++ b/src/constants.js @@ -50,6 +50,14 @@ export const EVENT = { PROJECT_DRAFT_CREATED: 'project.draft-created', PROJECT_UPDATED: 'project.updated', PROJECT_DELETED: 'project.deleted', + + PROJECT_PHASE_ADDED: 'project.phase.added', + PROJECT_PHASE_UPDATED: 'project.phase.updated', + PROJECT_PHASE_REMOVED: 'project.phase.removed', + + PROJECT_PHASE_PRODUCT_ADDED: 'project.phase.product.added', + PROJECT_PHASE_PRODUCT_UPDATED: 'project.phase.product.updated', + PROJECT_PHASE_PRODUCT_REMOVED: 'project.phase.product.removed', }, }; @@ -72,6 +80,14 @@ export const BUS_API_EVENT = { PROJECT_LINK_CREATED: 'notifications.connect.project.linkCreated', PROJECT_FILE_UPLOADED: 'notifications.connect.project.fileUploaded', PROJECT_SPECIFICATION_MODIFIED: 'notifications.connect.project.specificationModified', + + // When phase is added/updated/deleted from the project, + // When product is added/deleted from a phase + // When product is updated on any field other than specification + PROJECT_PLAN_MODIFIED: 'notifications.connect.project.planModified', + + // When specification of a product is modified + PROJECT_PRODUCT_SPECIFICATION_MODIFIED: 'notifications.connect.project.productSpecificationModified', }; export const REGEX = { diff --git a/src/events/busApi.js b/src/events/busApi.js index ac5f7b45..ae693f02 100644 --- a/src/events/busApi.js +++ b/src/events/busApi.js @@ -92,14 +92,14 @@ module.exports = (app, logger) => { models.Project.findOne({ where: { id: projectId }, }) - .then((project) => { - createEvent(eventType, { - projectId, - projectName: project.name, - userId: member.userId, - initiatorUserId: req.authUser.userId, - }, logger); - }).catch(err => null); // eslint-disable-line no-unused-vars + .then((project) => { + createEvent(eventType, { + projectId, + projectName: project.name, + userId: member.userId, + initiatorUserId: req.authUser.userId, + }, logger); + }).catch(err => null); // eslint-disable-line no-unused-vars }); /** @@ -119,16 +119,16 @@ module.exports = (app, logger) => { models.Project.findOne({ where: { id: projectId }, }) - .then((project) => { - if (project) { - createEvent(eventType, { - projectId, - projectName: project.name, - userId: member.userId, - initiatorUserId: req.authUser.userId, - }, logger); - } - }).catch(err => null); // eslint-disable-line no-unused-vars + .then((project) => { + if (project) { + createEvent(eventType, { + projectId, + projectName: project.name, + userId: member.userId, + initiatorUserId: req.authUser.userId, + }, logger); + } + }).catch(err => null); // eslint-disable-line no-unused-vars }); /** @@ -142,16 +142,16 @@ module.exports = (app, logger) => { models.Project.findOne({ where: { id: projectId }, }) - .then((project) => { - if (project) { - createEvent(BUS_API_EVENT.MEMBER_ASSIGNED_AS_OWNER, { - projectId, - projectName: project.name, - userId: updated.userId, - initiatorUserId: req.authUser.userId, - }, logger); - } - }).catch(err => null); // eslint-disable-line no-unused-vars + .then((project) => { + if (project) { + createEvent(BUS_API_EVENT.MEMBER_ASSIGNED_AS_OWNER, { + projectId, + projectName: project.name, + userId: updated.userId, + initiatorUserId: req.authUser.userId, + }, logger); + } + }).catch(err => null); // eslint-disable-line no-unused-vars } }); @@ -166,14 +166,157 @@ module.exports = (app, logger) => { models.Project.findOne({ where: { id: projectId }, }) - .then((project) => { - createEvent(BUS_API_EVENT.PROJECT_FILE_UPLOADED, { - projectId, - projectName: project.name, - fileName: attachment.filePath.replace(/^.*[\\\/]/, ''), // eslint-disable-line - userId: req.authUser.userId, - initiatorUserId: req.authUser.userId, - }, logger); - }).catch(err => null); // eslint-disable-line no-unused-vars + .then((project) => { + createEvent(BUS_API_EVENT.PROJECT_FILE_UPLOADED, { + projectId, + projectName: project.name, + fileName: attachment.filePath.replace(/^.*[\\\/]/, ''), // eslint-disable-line + userId: req.authUser.userId, + initiatorUserId: req.authUser.userId, + }, logger); + }).catch(err => null); // eslint-disable-line no-unused-vars + }); + + /** + * PROJECT_PHASE_ADDED + */ + app.on(EVENT.ROUTING_KEY.PROJECT_PHASE_ADDED, ({ req, created }) => { // eslint-disable-line no-unused-vars + logger.debug('receive PROJECT_PHASE_ADDED event'); + + const projectId = _.parseInt(req.params.projectId); + + models.Project.findOne({ + where: { id: projectId }, + }) + .then((project) => { + createEvent(BUS_API_EVENT.PROJECT_PLAN_MODIFIED, { + projectId, + projectName: project.name, + userId: req.authUser.userId, + initiatorUserId: req.authUser.userId, + }, logger); + }).catch(err => null); // eslint-disable-line no-unused-vars + }); + + /** + * PROJECT_PHASE_REMOVED + */ + app.on(EVENT.ROUTING_KEY.PROJECT_PHASE_REMOVED, ({ req, deleted }) => { // eslint-disable-line no-unused-vars + logger.debug('receive PROJECT_PHASE_REMOVED event'); + + const projectId = _.parseInt(req.params.projectId); + + models.Project.findOne({ + where: { id: projectId }, + }) + .then((project) => { + createEvent(BUS_API_EVENT.PROJECT_PLAN_MODIFIED, { + projectId, + projectName: project.name, + userId: req.authUser.userId, + initiatorUserId: req.authUser.userId, + }, logger); + }).catch(err => null); // eslint-disable-line no-unused-vars + }); + + /** + * PROJECT_PHASE_UPDATED + */ + app.on(EVENT.ROUTING_KEY.PROJECT_PHASE_UPDATED, ({ req, original, updated }) => { // eslint-disable-line no-unused-vars + logger.debug('receive PROJECT_PHASE_UPDATED event'); + + const projectId = _.parseInt(req.params.projectId); + + models.Project.findOne({ + where: { id: projectId }, + }) + .then((project) => { + createEvent(BUS_API_EVENT.PROJECT_PLAN_MODIFIED, { + projectId, + projectName: project.name, + userId: req.authUser.userId, + initiatorUserId: req.authUser.userId, + }, logger); + }).catch(err => null); // eslint-disable-line no-unused-vars + }); + + /** + * PROJECT_PHASE_PRODUCT_ADDED + */ + app.on(EVENT.ROUTING_KEY.PROJECT_PHASE_PRODUCT_ADDED, ({ req, created }) => { // eslint-disable-line no-unused-vars + logger.debug('receive PROJECT_PHASE_PRODUCT_ADDED event'); + + const projectId = _.parseInt(req.params.projectId); + + models.Project.findOne({ + where: { id: projectId }, + }) + .then((project) => { + createEvent(BUS_API_EVENT.PROJECT_PLAN_MODIFIED, { + projectId, + projectName: project.name, + userId: req.authUser.userId, + initiatorUserId: req.authUser.userId, + }, logger); + }).catch(err => null); // eslint-disable-line no-unused-vars + }); + + /** + * PROJECT_PHASE_PRODUCT_REMOVED + */ + app.on(EVENT.ROUTING_KEY.PROJECT_PHASE_PRODUCT_REMOVED, ({ req, deleted }) => { // eslint-disable-line no-unused-vars + logger.debug('receive PROJECT_PHASE_PRODUCT_REMOVED event'); + + const projectId = _.parseInt(req.params.projectId); + + models.Project.findOne({ + where: { id: projectId }, + }) + .then((project) => { + createEvent(BUS_API_EVENT.PROJECT_PLAN_MODIFIED, { + projectId, + projectName: project.name, + userId: req.authUser.userId, + initiatorUserId: req.authUser.userId, + }, logger); + }).catch(err => null); // eslint-disable-line no-unused-vars + }); + + /** + * PROJECT_PHASE_PRODUCT_UPDATED + */ + app.on(EVENT.ROUTING_KEY.PROJECT_PHASE_PRODUCT_UPDATED, ({ req, original, updated }) => { // eslint-disable-line no-unused-vars + logger.debug('receive PROJECT_PHASE_PRODUCT_UPDATED event'); + + const projectId = _.parseInt(req.params.projectId); + + models.Project.findOne({ + where: { id: projectId }, + }) + .then((project) => { + // Spec changes + if (!_.isEqual(original.details, updated.details)) { + logger.debug(`Spec changed for product id ${updated.id}`); + + createEvent(BUS_API_EVENT.PROJECT_PRODUCT_SPECIFICATION_MODIFIED, { + projectId, + projectName: project.name, + userId: req.authUser.userId, + initiatorUserId: req.authUser.userId, + }, logger); + } + + // Other fields change + const originalWithouDetails = _.omit(original, 'details'); + const updatedWithouDetails = _.omit(updated, 'details'); + if (!_.isEqual(originalWithouDetails.details, updatedWithouDetails.details)) { + createEvent(BUS_API_EVENT.PROJECT_PLAN_MODIFIED, { + projectId, + projectName: project.name, + userId: req.authUser.userId, + initiatorUserId: req.authUser.userId, + }, logger); + } + }).catch(err => null); // eslint-disable-line no-unused-vars }); }; diff --git a/src/events/index.js b/src/events/index.js index a8ac3096..cf6decf8 100644 --- a/src/events/index.js +++ b/src/events/index.js @@ -5,6 +5,10 @@ import { projectMemberAddedHandler, projectMemberRemovedHandler, projectMemberUpdatedHandler } from './projectMembers'; import { projectAttachmentAddedHandler, projectAttachmentRemovedHandler, projectAttachmentUpdatedHandler } from './projectAttachments'; +import { projectPhaseAddedHandler, projectPhaseRemovedHandler, + projectPhaseUpdatedHandler } from './projectPhases'; +import { phaseProductAddedHandler, phaseProductRemovedHandler, + phaseProductUpdatedHandler } from './phaseProducts'; export default { 'project.initial': projectCreatedHandler, @@ -17,4 +21,13 @@ export default { [EVENT.ROUTING_KEY.PROJECT_ATTACHMENT_ADDED]: projectAttachmentAddedHandler, [EVENT.ROUTING_KEY.PROJECT_ATTACHMENT_REMOVED]: projectAttachmentRemovedHandler, [EVENT.ROUTING_KEY.PROJECT_ATTACHMENT_UPDATED]: projectAttachmentUpdatedHandler, + [EVENT.ROUTING_KEY.PROJECT_PHASE_ADDED]: projectPhaseAddedHandler, + [EVENT.ROUTING_KEY.PROJECT_PHASE_REMOVED]: projectPhaseRemovedHandler, + [EVENT.ROUTING_KEY.PROJECT_PHASE_UPDATED]: projectPhaseUpdatedHandler, + [EVENT.ROUTING_KEY.PROJECT_PHASE_ADDED]: projectPhaseAddedHandler, + [EVENT.ROUTING_KEY.PROJECT_PHASE_REMOVED]: projectPhaseRemovedHandler, + [EVENT.ROUTING_KEY.PROJECT_PHASE_UPDATED]: projectPhaseUpdatedHandler, + [EVENT.ROUTING_KEY.PROJECT_PHASE_PRODUCT_ADDED]: phaseProductAddedHandler, + [EVENT.ROUTING_KEY.PROJECT_PHASE_PRODUCT_REMOVED]: phaseProductRemovedHandler, + [EVENT.ROUTING_KEY.PROJECT_PHASE_PRODUCT_UPDATED]: phaseProductUpdatedHandler, }; diff --git a/src/events/phaseProducts/index.js b/src/events/phaseProducts/index.js new file mode 100644 index 00000000..b6b6c063 --- /dev/null +++ b/src/events/phaseProducts/index.js @@ -0,0 +1,129 @@ +/** + * Event handlers for phase product create, update and delete. + * Current functionality just updates the elasticsearch indexes. + */ + +import config from 'config'; +import _ from 'lodash'; +import Promise from 'bluebird'; +import util from '../../util'; + +const ES_PROJECT_INDEX = config.get('elasticsearchConfig.indexName'); +const ES_PROJECT_TYPE = config.get('elasticsearchConfig.docType'); + +const eClient = util.getElasticSearchClient(); + +/** + * Handler for phase product creation event + * @param {Object} logger logger to log along with trace id + * @param {Object} msg event payload + * @param {Object} channel channel to ack, nack + * @returns {undefined} + */ +const phaseProductAddedHandler = Promise.coroutine(function* (logger, msg, channel) { // eslint-disable-line func-names + try { + const data = JSON.parse(msg.content.toString()); + const doc = yield eClient.get({ index: ES_PROJECT_INDEX, type: ES_PROJECT_TYPE, id: data.projectId }); + const phases = _.isArray(doc._source.phases) ? doc._source.phases : []; // eslint-disable-line no-underscore-dangle + + _.each(phases, (phase) => { + if (phase.id === data.phaseId) { + phase.products = _.isArray(phase.products) ? phase.products : []; // eslint-disable-line no-param-reassign + phase.products.push(_.omit(data, ['deletedAt', 'deletedBy'])); + } + }); + + const merged = _.assign(doc._source, { phases }); // eslint-disable-line no-underscore-dangle + yield eClient.update({ index: ES_PROJECT_INDEX, type: ES_PROJECT_TYPE, id: data.projectId, body: { doc: merged } }); + logger.debug('phase product added to project document successfully'); + channel.ack(msg); + } catch (error) { + logger.error('Error handling project.phase.added event', error); + // if the message has been redelivered dont attempt to reprocess it + channel.nack(msg, false, !msg.fields.redelivered); + } +}); + +/** + * Handler for phase product updated event + * @param {Object} logger logger to log along with trace id + * @param {Object} msg event payload + * @param {Object} channel channel to ack, nack + * @returns {undefined} + */ +const phaseProductUpdatedHandler = Promise.coroutine(function* (logger, msg, channel) { // eslint-disable-line func-names + try { + const data = JSON.parse(msg.content.toString()); + const doc = yield eClient.get({ index: ES_PROJECT_INDEX, type: ES_PROJECT_TYPE, id: data.original.projectId }); + const phases = _.map(doc._source.phases, (phase) => { // eslint-disable-line no-underscore-dangle + if (phase.id === data.original.phaseId) { + phase.products = _.map(phase.products, (product) => { // eslint-disable-line no-param-reassign + if (product.id === data.original.id) { + return _.assign(product, _.omit(data.updated, ['deletedAt', 'deletedBy'])); + } + return product; + }); + } + return phase; + }); + const merged = _.assign(doc._source, { phases }); // eslint-disable-line no-underscore-dangle + yield eClient.update({ + index: ES_PROJECT_INDEX, + type: ES_PROJECT_TYPE, + id: data.original.projectId, + body: { + doc: merged, + }, + }); + logger.debug('elasticsearch index updated, phase product updated successfully'); + channel.ack(msg); + } catch (error) { + logger.error('Error handling project.phase.updated event', error); + // if the message has been redelivered dont attempt to reprocess it + channel.nack(msg, false, !msg.fields.redelivered); + } +}); + +/** + * Handler for phase product deleted event + * @param {Object} logger logger to log along with trace id + * @param {Object} msg event payload + * @param {Object} channel channel to ack, nack + * @returns {undefined} + */ +const phaseProductRemovedHandler = Promise.coroutine(function* (logger, msg, channel) { // eslint-disable-line func-names + try { + const data = JSON.parse(msg.content.toString()); + const doc = yield eClient.get({ index: ES_PROJECT_INDEX, type: ES_PROJECT_TYPE, id: data.projectId }); + const phases = _.map(doc._source.phases, (phase) => { // eslint-disable-line no-underscore-dangle + if (phase.id === data.phaseId) { + phase.products = _.filter(phase.products, product => product.id !== data.id); // eslint-disable-line no-param-reassign + } + return phase; + }); + + const merged = _.assign(doc._source, { phases }); // eslint-disable-line no-underscore-dangle + + yield eClient.update({ + index: ES_PROJECT_INDEX, + type: ES_PROJECT_TYPE, + id: data.projectId, + body: { + doc: merged, + }, + }); + logger.debug('phase product removed from project document successfully'); + channel.ack(msg); + } catch (error) { + logger.error('Error fetching project document from elasticsearch', error); + // if the message has been redelivered dont attempt to reprocess it + channel.nack(msg, false, !msg.fields.redelivered); + } +}); + + +module.exports = { + phaseProductAddedHandler, + phaseProductRemovedHandler, + phaseProductUpdatedHandler, +}; diff --git a/src/events/projectPhases/index.js b/src/events/projectPhases/index.js new file mode 100644 index 00000000..7543bdae --- /dev/null +++ b/src/events/projectPhases/index.js @@ -0,0 +1,110 @@ +/** + * Event handlers for project phase create, update and delete. + * Current functionality just updates the elasticsearch indexes. + */ + +import config from 'config'; +import _ from 'lodash'; +import Promise from 'bluebird'; +import util from '../../util'; + +const ES_PROJECT_INDEX = config.get('elasticsearchConfig.indexName'); +const ES_PROJECT_TYPE = config.get('elasticsearchConfig.docType'); + +const eClient = util.getElasticSearchClient(); + +/** + * Handler for project phase creation event + * @param {Object} logger logger to log along with trace id + * @param {Object} msg event payload + * @param {Object} channel channel to ack, nack + * @returns {undefined} + */ +const projectPhaseAddedHandler = Promise.coroutine(function* (logger, msg, channel) { // eslint-disable-line func-names + try { + const data = JSON.parse(msg.content.toString()); + const doc = yield eClient.get({ index: ES_PROJECT_INDEX, type: ES_PROJECT_TYPE, id: data.projectId }); + const phases = _.isArray(doc._source.phases) ? doc._source.phases : []; // eslint-disable-line no-underscore-dangle + phases.push(_.omit(data, ['deletedAt', 'deletedBy'])); + const merged = _.assign(doc._source, { phases }); // eslint-disable-line no-underscore-dangle + yield eClient.update({ index: ES_PROJECT_INDEX, type: ES_PROJECT_TYPE, id: data.projectId, body: { doc: merged } }); + logger.debug('project phase added to project document successfully'); + channel.ack(msg); + } catch (error) { + logger.error('Error handling project.phase.added event', error); + // if the message has been redelivered dont attempt to reprocess it + channel.nack(msg, false, !msg.fields.redelivered); + } +}); + +/** + * Handler for project phase updated event + * @param {Object} logger logger to log along with trace id + * @param {Object} msg event payload + * @param {Object} channel channel to ack, nack + * @returns {undefined} + */ +const projectPhaseUpdatedHandler = Promise.coroutine(function* (logger, msg, channel) { // eslint-disable-line func-names + try { + const data = JSON.parse(msg.content.toString()); + const doc = yield eClient.get({ index: ES_PROJECT_INDEX, type: ES_PROJECT_TYPE, id: data.original.projectId }); + const phases = _.map(doc._source.phases, (single) => { // eslint-disable-line no-underscore-dangle + if (single.id === data.original.id) { + return _.assign(single, _.omit(data.updated, ['deletedAt', 'deletedBy'])); + } + return single; + }); + const merged = _.assign(doc._source, { phases }); // eslint-disable-line no-underscore-dangle + yield eClient.update({ + index: ES_PROJECT_INDEX, + type: ES_PROJECT_TYPE, + id: data.original.projectId, + body: { + doc: merged, + }, + }); + logger.debug('elasticsearch index updated, project phase updated successfully'); + channel.ack(msg); + } catch (error) { + logger.error('Error handling project.phase.updated event', error); + // if the message has been redelivered dont attempt to reprocess it + channel.nack(msg, false, !msg.fields.redelivered); + } +}); + +/** + * Handler for project phase deleted event + * @param {Object} logger logger to log along with trace id + * @param {Object} msg event payload + * @param {Object} channel channel to ack, nack + * @returns {undefined} + */ +const projectPhaseRemovedHandler = Promise.coroutine(function* (logger, msg, channel) { // eslint-disable-line func-names + try { + const data = JSON.parse(msg.content.toString()); + const doc = yield eClient.get({ index: ES_PROJECT_INDEX, type: ES_PROJECT_TYPE, id: data.projectId }); + const phases = _.filter(doc._source.phases, single => single.id !== data.id); // eslint-disable-line no-underscore-dangle + const merged = _.assign(doc._source, { phases }); // eslint-disable-line no-underscore-dangle + yield eClient.update({ + index: ES_PROJECT_INDEX, + type: ES_PROJECT_TYPE, + id: data.projectId, + body: { + doc: merged, + }, + }); + logger.debug('project phase removed from project document successfully'); + channel.ack(msg); + } catch (error) { + logger.error('Error fetching project document from elasticsearch', error); + // if the message has been redelivered dont attempt to reprocess it + channel.nack(msg, false, !msg.fields.redelivered); + } +}); + + +module.exports = { + projectPhaseAddedHandler, + projectPhaseRemovedHandler, + projectPhaseUpdatedHandler, +}; diff --git a/src/models/phaseProduct.js b/src/models/phaseProduct.js index da7a5bc0..4ec1ea90 100644 --- a/src/models/phaseProduct.js +++ b/src/models/phaseProduct.js @@ -12,7 +12,7 @@ module.exports = function definePhaseProduct(sequelize, DataTypes) { type: { type: DataTypes.STRING, allowNull: true }, estimatedPrice: { type: DataTypes.DOUBLE, defaultValue: 0.0 }, actualPrice: { type: DataTypes.DOUBLE, defaultValue: 0.0 }, - details: { type: DataTypes.JSON, defaultValue: '' }, + details: { type: DataTypes.JSON, defaultValue: {} }, deletedAt: { type: DataTypes.DATE, allowNull: true }, createdAt: { type: DataTypes.DATE, defaultValue: DataTypes.NOW }, diff --git a/src/models/projectPhase.js b/src/models/projectPhase.js index b855f44d..3d21ec1c 100644 --- a/src/models/projectPhase.js +++ b/src/models/projectPhase.js @@ -11,7 +11,7 @@ module.exports = function defineProjectPhase(sequelize, DataTypes) { endDate: { type: DataTypes.DATE, allowNull: true }, budget: { type: DataTypes.DOUBLE, defaultValue: 0.0 }, progress: { type: DataTypes.DOUBLE, defaultValue: 0.0 }, - details: { type: DataTypes.JSON, defaultValue: '' }, + details: { type: DataTypes.JSON, defaultValue: {} }, deletedAt: { type: DataTypes.DATE, allowNull: true }, createdAt: { type: DataTypes.DATE, defaultValue: DataTypes.NOW }, diff --git a/src/routes/phaseProducts/create.js b/src/routes/phaseProducts/create.js index 5a67ef01..670e89d1 100644 --- a/src/routes/phaseProducts/create.js +++ b/src/routes/phaseProducts/create.js @@ -6,6 +6,7 @@ import Joi from 'joi'; import models from '../../models'; import util from '../../util'; +import { EVENT } from '../../constants'; const permissions = require('tc-core-library-js').middleware.permissions; @@ -44,7 +45,7 @@ module.exports = [ where: { id: projectId, deletedAt: { $eq: null } }, raw: true, }).then((existingProject) => { - // make sure project exists + // make sure project exists if (!existingProject) { const err = new Error(`project not found for project id ${projectId}`); err.status = 404; @@ -61,10 +62,10 @@ module.exports = [ raw: true, }); }).then((existingPhase) => { - // make sure phase exists + // make sure phase exists if (!existingPhase) { const err = new Error(`project phase not found for project id ${projectId}` + - ` and phase id ${phaseId}`); + ` and phase id ${phaseId}`); err.status = 404; throw err; } @@ -81,10 +82,10 @@ module.exports = [ raw: true, }); }).then((productCount) => { - // make sure number of products of per phase <= max value + // make sure number of products of per phase <= max value if (productCount >= config.maxPhaseProductCount) { const err = new Error('the number of products per phase cannot exceed ' + - `${config.maxPhaseProductCount}`); + `${config.maxPhaseProductCount}`); err.status = 400; throw err; } @@ -93,9 +94,19 @@ module.exports = [ .then((_newPhaseProduct) => { newPhaseProduct = _.cloneDeep(_newPhaseProduct); req.log.debug('new phase product created (id# %d, name: %s)', - newPhaseProduct.id, newPhaseProduct.name); + newPhaseProduct.id, newPhaseProduct.name); newPhaseProduct = newPhaseProduct.get({ plain: true }); newPhaseProduct = _.omit(newPhaseProduct, ['deletedAt', 'utm']); + + // Send events to buses + req.log.debug('Sending event to RabbitMQ bus for phase product %d', newPhaseProduct.id); + req.app.services.pubsub.publish(EVENT.ROUTING_KEY.PROJECT_PHASE_PRODUCT_ADDED, + newPhaseProduct, + { correlationId: req.id }, + ); + req.log.debug('Sending event to Kafka bus for phase product %d', newPhaseProduct.id); + req.app.emit(EVENT.ROUTING_KEY.PROJECT_PHASE_PRODUCT_ADDED, { req, created: newPhaseProduct }); + res.status(201).json(util.wrapResponse(req.id, newPhaseProduct, 1, 201)); })).catch((err) => { next(err); }); }, diff --git a/src/routes/phaseProducts/delete.js b/src/routes/phaseProducts/delete.js index a7e5e8ee..2faa6295 100644 --- a/src/routes/phaseProducts/delete.js +++ b/src/routes/phaseProducts/delete.js @@ -3,6 +3,7 @@ import _ from 'lodash'; import { middleware as tcMiddleware } from 'tc-core-library-js'; import models from '../../models'; +import { EVENT } from '../../constants'; const permissions = tcMiddleware.permissions; @@ -26,9 +27,9 @@ module.exports = [ }, }).then(existing => new Promise((accept, reject) => { if (!existing) { - // handle 404 + // handle 404 const err = new Error('No active phase product found for project id ' + - `${projectId}, phase id ${phaseId} and product id ${productId}`); + `${projectId}, phase id ${phaseId} and product id ${productId}`); err.status = 404; reject(err); } else { @@ -37,6 +38,15 @@ module.exports = [ } })).then((deleted) => { req.log.debug('deleted phase product', JSON.stringify(deleted, null, 2)); + + // Send events to buses + req.app.services.pubsub.publish( + EVENT.ROUTING_KEY.PROJECT_PHASE_PRODUCT_REMOVED, + deleted, + { correlationId: req.id }, + ); + req.app.emit(EVENT.ROUTING_KEY.PROJECT_PHASE_PRODUCT_REMOVED, { req, deleted }); + res.status(204).json({}); }).catch(err => next(err))); }, diff --git a/src/routes/phaseProducts/list.js b/src/routes/phaseProducts/list.js index 2abb3076..5899c425 100644 --- a/src/routes/phaseProducts/list.js +++ b/src/routes/phaseProducts/list.js @@ -1,9 +1,13 @@ import _ from 'lodash'; - -import models from '../../models'; +import config from 'config'; import util from '../../util'; +const ES_PROJECT_INDEX = config.get('elasticsearchConfig.indexName'); +const ES_PROJECT_TYPE = config.get('elasticsearchConfig.docType'); + +const eClient = util.getElasticSearchClient(); + const permissions = require('tc-core-library-js').middleware.permissions; module.exports = [ @@ -14,28 +18,32 @@ module.exports = [ const projectId = _.parseInt(req.params.projectId); const phaseId = _.parseInt(req.params.phaseId); - return models.ProjectPhase.findOne({ - where: { id: phaseId, projectId, deletedAt: { $eq: null } }, - }).then((existingPhase) => { - if (!existingPhase) { - const err = new Error(`active project phase not found for project id ${projectId}` + - ` and phase id ${phaseId}`); - err.status = 404; - throw err; - } - return models.PhaseProduct.findAll({ - where: { - projectId, - phaseId, - deletedAt: { $eq: null }, - }, - }); - }).then((products) => { - if (!products) { - res.json(util.wrapResponse(req.id, [], 0)); - } else { + // Get project from ES + eClient.get({ index: ES_PROJECT_INDEX, type: ES_PROJECT_TYPE, id: req.params.projectId }) + .then((doc) => { + if (!doc) { + const err = new Error(`active project not found for project id ${projectId}`); + err.status = 404; + throw err; + } + + // Get the phases + let phases = _.isArray(doc._source.phases) ? doc._source.phases : []; // eslint-disable-line no-underscore-dangle + + // Get the phase by id + phases = _.filter(phases, { id: phaseId }); + if (phases.length <= 0) { + const err = new Error(`active project phase not found for phase id ${phaseId}`); + err.status = 404; + throw err; + } + + // Get the products + let products = phases[0].products; + products = _.isArray(products) ? products : []; // eslint-disable-line no-underscore-dangle + res.json(util.wrapResponse(req.id, products, products.length)); - } - }).catch(err => next(err)); + }) + .catch(err => next(err)); }, ]; diff --git a/src/routes/phaseProducts/list.spec.js b/src/routes/phaseProducts/list.spec.js index 92d0b0dc..5f91dece 100644 --- a/src/routes/phaseProducts/list.spec.js +++ b/src/routes/phaseProducts/list.spec.js @@ -1,10 +1,18 @@ /* eslint-disable no-unused-expressions */ import _ from 'lodash'; import request from 'supertest'; +import sleep from 'sleep'; +import chai from 'chai'; +import config from 'config'; import server from '../../app'; import models from '../../models'; import testUtil from '../../tests/util'; +const ES_PROJECT_INDEX = config.get('elasticsearchConfig.indexName'); +const ES_PROJECT_TYPE = config.get('elasticsearchConfig.docType'); + +const should = chai.should(); + const body = { name: 'test phase product', type: 'product1', @@ -20,52 +28,71 @@ const body = { describe('Phase Products', () => { let projectId; let phaseId; - before((done) => { + let project; + before(function beforeHook(done) { + this.timeout(10000); // mocks testUtil.clearDb() - .then(() => { - models.Project.create({ - type: 'generic', - billingAccountId: 1, - name: 'test1', - description: 'test project1', - status: 'draft', - details: {}, + .then(() => { + models.Project.create({ + type: 'generic', + billingAccountId: 1, + name: 'test1', + description: 'test project1', + status: 'draft', + details: {}, + createdBy: 1, + updatedBy: 1, + }).then((p) => { + projectId = p.id; + project = p.toJSON(); + // create members + models.ProjectMember.create({ + userId: 40051332, + projectId, + role: 'copilot', + isPrimary: true, createdBy: 1, updatedBy: 1, - }).then((p) => { - projectId = p.id; - // create members - models.ProjectMember.create({ - userId: 40051332, - projectId, - role: 'copilot', - isPrimary: true, + }).then(() => { + models.ProjectPhase.create({ + name: 'test project phase', + status: 'active', + startDate: '2018-05-15T00:00:00Z', + endDate: '2018-05-15T12:00:00Z', + budget: 20.0, + progress: 1.23456, + details: { + message: 'This can be any json', + }, createdBy: 1, updatedBy: 1, - }).then(() => { - models.ProjectPhase.create({ - name: 'test project phase', - status: 'active', - startDate: '2018-05-15T00:00:00Z', - endDate: '2018-05-15T12:00:00Z', - budget: 20.0, - progress: 1.23456, - details: { - message: 'This can be any json', - }, - createdBy: 1, - updatedBy: 1, - projectId, - }).then((phase) => { - phaseId = phase.id; - _.assign(body, { phaseId, projectId }); + projectId, + }).then((phase) => { + phaseId = phase.id; + _.assign(body, { phaseId, projectId }); + + project.phases = [phase.toJSON()]; + + models.PhaseProduct.create(body).then((product) => { + project.phases[0].products = [product.toJSON()]; - models.PhaseProduct.create(body).then(() => done()); + // Index to ES + return server.services.es.index({ + index: ES_PROJECT_INDEX, + type: ES_PROJECT_TYPE, + id: projectId, + body: project, + }).then(() => { + // sleep for some time, let elasticsearch indices be settled + sleep.sleep(5); + done(); + }); }); }); }); }); + }); }); after((done) => { @@ -120,6 +147,7 @@ describe('Phase Products', () => { done(err); } else { const resJson = res.body.result.content; + should.exist(resJson); resJson.should.have.lengthOf(1); done(); } diff --git a/src/routes/phaseProducts/update.js b/src/routes/phaseProducts/update.js index 2d42f618..9d335f2a 100644 --- a/src/routes/phaseProducts/update.js +++ b/src/routes/phaseProducts/update.js @@ -5,6 +5,7 @@ import Joi from 'joi'; import { middleware as tcMiddleware } from 'tc-core-library-js'; import models from '../../models'; import util from '../../util'; +import { EVENT } from '../../constants'; const permissions = tcMiddleware.permissions; @@ -37,6 +38,8 @@ module.exports = [ const updatedProps = req.body.param; updatedProps.updatedBy = req.authUser.userId; + let previousValue; + models.sequelize.transaction(() => models.PhaseProduct.findOne({ where: { id: productId, @@ -52,11 +55,25 @@ module.exports = [ err.status = 404; reject(err); } else { + previousValue = _.clone(existing.get({ plain: true })); + _.extend(existing, updatedProps); existing.save().then(accept).catch(reject); } })).then((updated) => { req.log.debug('updated phase product', JSON.stringify(updated, null, 2)); + + const updatedValue = updated.get({ plain: true }); + + // emit original and updated project phase information + req.app.services.pubsub.publish( + EVENT.ROUTING_KEY.PROJECT_PHASE_PRODUCT_UPDATED, + { original: previousValue, updated: updatedValue }, + { correlationId: req.id }, + ); + req.app.emit(EVENT.ROUTING_KEY.PROJECT_PHASE_PRODUCT_UPDATED, + { req, original: previousValue, updated: updatedValue }); + res.json(util.wrapResponse(req.id, updated)); }).catch(err => next(err))); }, diff --git a/src/routes/phases/create.js b/src/routes/phases/create.js index 6a81df61..1a5828c7 100644 --- a/src/routes/phases/create.js +++ b/src/routes/phases/create.js @@ -4,6 +4,7 @@ import Joi from 'joi'; import models from '../../models'; import util from '../../util'; +import { EVENT } from '../../constants'; const permissions = require('tc-core-library-js').middleware.permissions; @@ -58,6 +59,16 @@ module.exports = [ newProjectPhase = newProjectPhase.get({ plain: true }); newProjectPhase = _.omit(newProjectPhase, ['deletedAt', 'deletedBy', 'utm']); + + // Send events to buses + req.log.debug('Sending event to RabbitMQ bus for project phase %d', newProjectPhase.id); + req.app.services.pubsub.publish(EVENT.ROUTING_KEY.PROJECT_PHASE_ADDED, + newProjectPhase, + { correlationId: req.id }, + ); + req.log.debug('Sending event to Kafka bus for project phase %d', newProjectPhase.id); + req.app.emit(EVENT.ROUTING_KEY.PROJECT_PHASE_ADDED, { req, created: newProjectPhase }); + res.status(201).json(util.wrapResponse(req.id, newProjectPhase, 1, 201)); }); }).catch((err) => { diff --git a/src/routes/phases/delete.js b/src/routes/phases/delete.js index c8979ca2..3bc34012 100644 --- a/src/routes/phases/delete.js +++ b/src/routes/phases/delete.js @@ -3,6 +3,7 @@ import _ from 'lodash'; import { middleware as tcMiddleware } from 'tc-core-library-js'; import models from '../../models'; +import { EVENT } from '../../constants'; const permissions = tcMiddleware.permissions; @@ -26,7 +27,7 @@ module.exports = [ if (!existing) { // handle 404 const err = new Error('no active project phase found for project id ' + - `${projectId} and phase id ${phaseId}`); + `${projectId} and phase id ${phaseId}`); err.status = 404; reject(err); } else { @@ -35,6 +36,15 @@ module.exports = [ } })).then((deleted) => { req.log.debug('deleted project phase', JSON.stringify(deleted, null, 2)); + + // Send events to buses + req.app.services.pubsub.publish( + EVENT.ROUTING_KEY.PROJECT_PHASE_REMOVED, + deleted, + { correlationId: req.id }, + ); + req.app.emit(EVENT.ROUTING_KEY.PROJECT_PHASE_REMOVED, { req, deleted }); + res.status(204).json({}); }).catch(err => next(err))); }, diff --git a/src/routes/phases/list.js b/src/routes/phases/list.js index bfec53d8..6644a365 100644 --- a/src/routes/phases/list.js +++ b/src/routes/phases/list.js @@ -1,74 +1,65 @@ import _ from 'lodash'; +import config from 'config'; import { middleware as tcMiddleware } from 'tc-core-library-js'; import util from '../../util'; import models from '../../models'; -const PHASE_ATTRIBUTES = _.without(_.keys(models.ProjectPhase.rawAttributes), - 'utm', -); +const ES_PROJECT_INDEX = config.get('elasticsearchConfig.indexName'); +const ES_PROJECT_TYPE = config.get('elasticsearchConfig.docType'); -const permissions = tcMiddleware.permissions; +const eClient = util.getElasticSearchClient(); + +const PHASE_ATTRIBUTES = _.keys(models.ProjectPhase.rawAttributes); -const retrieveProjectPhases = (req, criteria, sort, ffields) => { - // order by - const order = sort ? [sort.split(' ')] : [['createdAt', 'asc']]; - let fields = ffields ? ffields.split(',') : PHASE_ATTRIBUTES; - // parse the fields string to determine what fields are to be returned - fields = _.intersection(fields, PHASE_ATTRIBUTES); - if (_.indexOf(fields, 'id') < 0) fields.push('id'); +const permissions = tcMiddleware.permissions; - return models.ProjectPhase.searchText({ - filters: criteria.filters, - order, - limit: criteria.limit, - offset: criteria.offset, - attributes: fields, - }, req.log); -}; module.exports = [ permissions('project.view'), (req, res, next) => { const projectId = _.parseInt(req.params.projectId); - const filters = util.parseQueryFilter(req.query.filter); - let sort = req.query.sort ? decodeURIComponent(req.query.sort) : 'createdAt'; + let sort = req.query.sort ? decodeURIComponent(req.query.sort) : 'startDate'; if (sort && sort.indexOf(' ') === -1) { sort += ' asc'; } const sortableProps = [ - 'createdAt', 'createdAt asc', 'createdAt desc', - 'updatedAt', 'updatedAt asc', 'updatedAt desc', - 'id', 'id asc', 'id desc', - 'status', 'status asc', 'status desc', - 'name', 'name asc', 'name desc', - 'budget', 'budget asc', 'budget desc', - 'progress', 'progress asc', 'progress desc', + 'startDate asc', 'startDate desc', + 'endDate asc', 'endDate desc', + 'status asc', 'status desc', ]; - if (!util.isValidFilter(filters, ['id', 'status', 'type', 'name', 'status', 'budget', 'progress']) || - (sort && _.indexOf(sortableProps, sort) < 0)) { - return util.handleError('Invalid filters or sort', null, req, next); + if (sort && _.indexOf(sortableProps, sort) < 0) { + return util.handleError('Invalid sort criteria', null, req, next); } + const sortColumnAndOrder = sort.split(' '); + + // Get project from ES + return eClient.get({ index: ES_PROJECT_INDEX, type: ES_PROJECT_TYPE, id: req.params.projectId }) + .then((doc) => { + if (!doc) { + const err = new Error(`active project not found for project id ${projectId}`); + err.status = 404; + throw err; + } + + // Get the phases + let phases = _.isArray(doc._source.phases) ? doc._source.phases : []; // eslint-disable-line no-underscore-dangle + + // Sort + phases = _.sortBy(phases, [sortColumnAndOrder[0]], [sortColumnAndOrder[1]]); - const criteria = { - filters, - limit: Math.min(req.query.limit || 20, 20), - offset: req.query.offset || 0, - }; + // Parse the fields string to determine what fields are to be returned + let fields = req.query.fields ? req.query.fields.split(',') : PHASE_ATTRIBUTES; + fields = _.intersection(fields, PHASE_ATTRIBUTES); + if (_.indexOf(fields, 'id') < 0) { + fields.push('id'); + } - criteria.filters.projectId = projectId; + phases = _.map(phases, phase => _.pick(phase, fields)); - return models.Project.findOne({ - where: { id: projectId, deletedAt: { $eq: null } }, - }).then((existingProject) => { - if (!existingProject) { - const err = new Error(`active project not found for project id ${projectId}`); - err.status = 404; - throw err; - } - return retrieveProjectPhases(req, criteria, sort, req.query.fields); - }).then(result => res.json(util.wrapResponse(req.id, result.rows, result.count))) + res.json(util.wrapResponse(req.id, phases, phases.length)); + }) .catch(err => next(err)); }, ]; diff --git a/src/routes/phases/list.spec.js b/src/routes/phases/list.spec.js index 2136a87e..74761ad2 100644 --- a/src/routes/phases/list.spec.js +++ b/src/routes/phases/list.spec.js @@ -1,10 +1,18 @@ /* eslint-disable no-unused-expressions */ import _ from 'lodash'; import request from 'supertest'; +import config from 'config'; +import sleep from 'sleep'; +import chai from 'chai'; import server from '../../app'; import models from '../../models'; import testUtil from '../../tests/util'; +const ES_PROJECT_INDEX = config.get('elasticsearchConfig.indexName'); +const ES_PROJECT_TYPE = config.get('elasticsearchConfig.docType'); + +const should = chai.should(); + const body = { name: 'test project phase', status: 'active', @@ -21,35 +29,51 @@ const body = { describe('Project Phases', () => { let projectId; - before((done) => { + let project; + before(function beforeHook(done) { + this.timeout(10000); // mocks testUtil.clearDb() - .then(() => { - models.Project.create({ - type: 'generic', - billingAccountId: 1, - name: 'test1', - description: 'test project1', - status: 'draft', - details: {}, + .then(() => { + models.Project.create({ + type: 'generic', + billingAccountId: 1, + name: 'test1', + description: 'test project1', + status: 'draft', + details: {}, + createdBy: 1, + updatedBy: 1, + }).then((p) => { + projectId = p.id; + project = p.toJSON(); + // create members + models.ProjectMember.create({ + userId: 40051332, + projectId, + role: 'copilot', + isPrimary: true, createdBy: 1, updatedBy: 1, - }).then((p) => { - projectId = p.id; - // create members - models.ProjectMember.create({ - userId: 40051332, - projectId, - role: 'copilot', - isPrimary: true, - createdBy: 1, - updatedBy: 1, + }).then(() => { + _.assign(body, { projectId }); + return models.ProjectPhase.create(body); + }).then((phase) => { + // Index to ES + project.phases = [phase]; + return server.services.es.index({ + index: ES_PROJECT_INDEX, + type: ES_PROJECT_TYPE, + id: projectId, + body: project, }).then(() => { - _.assign(body, { projectId }); - models.ProjectPhase.create(body).then(() => done()); + // sleep for some time, let elasticsearch indices be settled + sleep.sleep(5); + done(); }); }); }); + }); }); after((done) => { @@ -93,6 +117,7 @@ describe('Project Phases', () => { done(err); } else { const resJson = res.body.result.content; + should.exist(resJson); resJson.should.have.lengthOf(1); done(); } diff --git a/src/routes/phases/update.js b/src/routes/phases/update.js index 543e0fac..96b589d3 100644 --- a/src/routes/phases/update.js +++ b/src/routes/phases/update.js @@ -5,6 +5,7 @@ import Joi from 'joi'; import { middleware as tcMiddleware } from 'tc-core-library-js'; import models from '../../models'; import util from '../../util'; +import { EVENT } from '../../constants'; const permissions = tcMiddleware.permissions; @@ -37,6 +38,8 @@ module.exports = [ const updatedProps = req.body.param; updatedProps.updatedBy = req.authUser.userId; + let previousValue; + models.sequelize.transaction(() => models.ProjectPhase.findOne({ where: { id: phaseId, @@ -45,12 +48,14 @@ module.exports = [ }, }).then(existing => new Promise((accept, reject) => { if (!existing) { - // handle 404 + // handle 404 const err = new Error('No active project phase found for project id ' + - `${projectId} and phase id ${phaseId}`); + `${projectId} and phase id ${phaseId}`); err.status = 404; reject(err); } else { + previousValue = _.clone(existing.get({ plain: true })); + // make sure startDate < endDate let startDate; let endDate; @@ -77,6 +82,16 @@ module.exports = [ } })).then((updated) => { req.log.debug('updated project phase', JSON.stringify(updated, null, 2)); + + // emit original and updated project phase information + req.app.services.pubsub.publish( + EVENT.ROUTING_KEY.PROJECT_PHASE_UPDATED, + { original: previousValue, updated }, + { correlationId: req.id }, + ); + req.app.emit(EVENT.ROUTING_KEY.PROJECT_PHASE_UPDATED, + { req, original: previousValue, updated }); + res.json(util.wrapResponse(req.id, updated)); }).catch(err => next(err))); }, diff --git a/swagger.yaml b/swagger.yaml index f195828f..f8178324 100755 --- a/swagger.yaml +++ b/swagger.yaml @@ -1,940 +1,1484 @@ ---- -swagger: "2.0" -info: - version: "v4" - title: "Projects API" -# during production,should point to your server machine -host: localhost:3000 -basePath: "/v4" -# during production, should use https -schemes: -- "http" -produces: -- application/json -consumes: -- application/json - -securityDefinitions: - Bearer: - type: apiKey - name: Authorization - in: header - -paths: - /projects: - get: - tags: - - project - operationId: findProjects - security: - - Bearer: [] - description: Retreive projects that match the filter - responses: - '422': - description: Invalid input - schema: - $ref: "#/definitions/ErrorModel" - '403': - description: No permission or wrong token - schema: - $ref: "#/definitions/ErrorModel" - '200': - description: A list of projects - schema: - $ref: "#/definitions/ProjectListResponse" - parameters: - - $ref: "#/parameters/offsetParam" - - $ref: "#/parameters/limitParam" - - name: filter - required: true - type: string - in: query - description: | - Url encoded list of Supported filters - - id - - status - - type - - memberOnly - - keyword - - name: sort - required: false - description: | - sort projects by status, name, type, createdAt, updatedAt. Default is createdAt asc - in: query - type: string - post: - operationId: addProject - security: - - Bearer: [] - description: Create a project - parameters: - - in: body - name: body - required: true - schema: - $ref: '#/definitions/NewProjectBodyParam' - responses: - '403': - description: No permission or wrong token - schema: - $ref: "#/definitions/ErrorModel" - '201': - description: Returns the newly created project - schema: - $ref: "#/definitions/ProjectResponse" - '422': - description: Invalid input - schema: - $ref: "#/definitions/ErrorModel" - - /projects/{projectId}: - get: - description: Retrieve project by id - 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 - schema: - $ref: "#/definitions/ProjectResponse" - parameters: - - $ref: "#/parameters/projectIdParam" - - name: fields - required: false - type: string - in: query - description: | - Comma separated list of project fields to return. - Can also specify project_members, attachments to get project members and project attachments. - Sub fields of project members and project attachments are also allowed. - operationId: getProject - - patch: - operationId: updateProject - security: - - Bearer: [] - description: Update a project that user has access to. Managers and admin are able to pull out a project from cancelled state. - 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. Returns original and updated project object - schema: - $ref: "#/definitions/UpdateProjectResponse" - '422': - description: Invalid input - schema: - $ref: "#/definitions/ErrorModel" - default: - description: error payload - schema: - $ref: '#/definitions/ErrorModel' - parameters: - - $ref: "#/parameters/projectIdParam" - - name: body - in: body - required: true - description: Only specify those properties that needs to be updated. `cancelReason` is mandatory if status is cancelled - schema: - $ref: "#/definitions/ProjectBodyParam" - - delete: - description: remove an existing project - security: - - Bearer: [] - parameters: - - $ref: "#/parameters/projectIdParam" - 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 successfully removed - - /projects/{projectId}/attachments: - post: - description: add a new project attachment - security: - - Bearer: [] - parameters: - - $ref: "#/parameters/projectIdParam" - - in: body - name: body - required: true - schema: - $ref: '#/definitions/NewProjectAttachmentBodyParam' - responses: - '403': - description: No permission or wrong token - schema: - $ref: "#/definitions/ErrorModel" - '201': - description: Returns the newly created project attachment - schema: - $ref: "#/definitions/NewProjectAttachmentResponse" - '422': - description: Invalid input - schema: - $ref: "#/definitions/ErrorModel" - - /projects/{projectId}/attachments/{id}: - patch: - description: Update an existing attachment - security: - - Bearer: [] - parameters: - - $ref: "#/parameters/projectIdParam" - - in: path - name: id - required: true - description: The id of attachment to update - type: integer - - in: body - name: body - required: true - description: Specify only those properties that needs to be updated - schema: - $ref: '#/definitions/NewProjectAttachmentBodyParam' - responses: - '403': - description: No permission or wrong token - schema: - $ref: "#/definitions/ErrorModel" - '201': - description: Returns the newly created project - schema: - $ref: "#/definitions/NewProjectAttachmentResponse" - '404': - description: If project attachment is not found - schema: - $ref: "#/definitions/ErrorModel" - '422': - description: Invalid input - schema: - $ref: "#/definitions/ErrorModel" - delete: - description: remove an existing attachment - security: - - Bearer: [] - parameters: - - $ref: "#/parameters/projectIdParam" - - in: path - name: id - required: true - description: The id of attachment to delete - type: integer - responses: - '403': - description: No permission or wrong token - schema: - $ref: "#/definitions/ErrorModel" - '404': - description: If attachment is not found - schema: - $ref: "#/definitions/ErrorModel" - '204': - description: Attachment successfully removed - - /projects/{projectId}/members: - post: - description: add a new project member - security: - - Bearer: [] - parameters: - - $ref: "#/parameters/projectIdParam" - - in: body - name: body - required: true - schema: - $ref: '#/definitions/NewProjectMemberBodyParam' - responses: - '403': - description: No permission or wrong token - schema: - $ref: "#/definitions/ErrorModel" - '201': - description: Returns the newly created project - schema: - $ref: "#/definitions/NewProjectMemberResponse" - '422': - description: Invalid input - schema: - $ref: "#/definitions/ErrorModel" - - /projects/{projectId}/members/{id}: - delete: - description: Delete a project member - security: - - Bearer: [] - parameters: - - $ref: "#/parameters/projectIdParam" - - in: path - name: id - required: true - type: integer - - responses: - '403': - description: No permission or wrong token - schema: - $ref: "#/definitions/ErrorModel" - '204': - description: Member successfully removed - patch: - security: - - Bearer: [] - description: Support editing project member roles & primary option. - 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 member. Returns entire project member object - schema: - $ref: "#/definitions/UpdateProjectMemberResponse" - '422': - description: Invalid input - schema: - $ref: "#/definitions/ErrorModel" - default: - description: error payload - schema: - $ref: '#/definitions/ErrorModel' - parameters: - - $ref: "#/parameters/projectIdParam" - - in: path - name: id - required: true - type: integer - - name: body - in: body - required: true - schema: - $ref: "#/definitions/UpdateProjectMemberBodyParam" - - -parameters: - projectIdParam: - name: projectId - in: path - description: project identifier - required: true - type: integer - format: int64 - offsetParam: - name: offset - description: "number of items to skip. Defaults to 0" - in: query - required: false - type: integer - format: int32 - limitParam: - name: limit - description: "max records to return. Defaults to 20" - in: query - required: false - type: integer - format: int32 - -definitions: - ResponseMetadata: - title: Metadata object for a response - type: object - properties: - totalCount: - type: integer - format: int64 - description: Total count of the objects - - ErrorModel: - type: object - properties: - id: - type: string - description: unique id identifying the request - version: - type: string - result: - type: object - properties: - success: - type: boolean - status: - description: http status code - type: integer - format: int32 - debug: - type: object - content: - type: object - - ProjectBookMark: - title: Project bookmark - type: object - properties: - title: - type: string - address: - type: string - - ProjectBodyParam: - type: object - properties: - param: - $ref: "#/definitions/Project" - - NewProject: - type: object - required: - - name - - description - - type - properties: - name: - type: string - description: project name (required) - description: - type: string - description: Project description - billingAccountId: - type: number - format: long - description: the customer billing account id - estimatedPrice: - type: number - format: float - description: The estimated price of the project - terms: - type: array - items: - type: number - format: integer - external: - type: object - description: READ-ONLY, OPTIONAL. Refernce to external task/issue. - properties: - id: - type: string - description: Identifier for external reference - type: - type: string - description: external source type - enum: [ "github", "jira", "asana", "other"] - data: - type: string - description: "300 Char length text blob for customer provided data" - type: - type: string - description: project type - enum: ["generic", "visual_design", "visual_prototype", "app_dev"] - bookmarks: - type: array - items: - $ref: "#/definitions/ProjectBookMark" - challengeEligibility: - description: List of eligibility criteria (one entry per role) - type: array - items: - $ref: "#/definitions/ChallengeEligibility" - details: - $ref: "#/definitions/ProjectDetails" - utm: - description: READ-ONLY. Used for tracking - type: object - properties: - campaign: - type: string - medium: - type: string - source: - type: string - - - NewProjectBodyParam: - type: object - properties: - param: - $ref: "#/definitions/NewProject" - - ChallengeEligibility: - description: Object describing who is eligible to work on this task - type: object - properties: - role: - type: string - enum: ["submitter", "reviewer", "copilot"] - users: - type: array - items: - type: integer - format: int64 - groups: - type: array - items: - type: integer - format: int64 - - - Project: - type: object - properties: - id: - description: unique identifier - type: integer - format: int64 - directProjectId: - description: unique identifier in direct - type: integer - format: int64 - billingAccountId: - type: integer - format: int64 - description: The customer billing account id - utm: - description: READ-ONLY. Used for tracking - type: object - properties: - campaign: - type: string - medium: - type: string - source: - type: string - estimatedPrice: - type: number - format: float - description: The estimated price of the project - actualPrice: - type: number - format: float - description: The actual price of the project - terms: - type: array - items: - type: number - format: integer - name: - type: string - description: project name - description: - type: string - description: Project description - - external: - type: object - description: READ-ONLY, OPTIONAL. Refernce to external task/issue. - properties: - id: - type: string - description: Identifier for external reference - type: - type: string - description: external source type - enum: [ "github", "jira", "asana", "other"] - data: - type: string - description: "300 Char length text blob for customer provided data" - type: - type: string - description: project type - enum: ["app_dev", "generic", "visual_prototype", "visual_design"] - status: - type: string - description: current state of the task - enum: ["draft", "in_review", "reviewed", "active", "paused", "cancelled", "completed"] - cancelReason: - type: string - description: If a project is cancelled, define the reason of cancellation - challengeEligibility: - description: List of eligibility criteria (one entry per role) - type: array - items: - $ref: "#/definitions/ChallengeEligibility" - bookmarks: - type: array - items: - $ref: "#/definitions/ProjectBookMark" - members: - description: | - READ-ONLY. List of project members. - Use project member api to add/remove members - type: array - items: - $ref: "#/definitions/ProjectMember" - attachments: - description: | - READ-ONLY. List of project attachmens. - Use project attachment api to add/remove attachments - type: array - items: - $ref: "#/definitions/ProjectAttachment" - details: - $ref: "#/definitions/ProjectDetails" - - createdAt: - type: string - description: Datetime (GMT) when task was created - readOnly: true - createdBy: - type: integer - format: int64 - description: READ-ONLY. User who created this task - readOnly: true - updatedAt: - type: string - description: READ-ONLY. Datetime (GMT) when task was updated - readOnly: true - updatedBy: - type: integer - format: int64 - description: READ-ONLY. User that last updated this task - readOnly: true - - ProjectDetails: - description: Project details - type: object - properties: - summary: - type: string - description: text summary of the project - TBD_usageDescription: - type: string - description: a description of how the app will be used - TBD_features: - type: object - properties: - id: - type: integer - title: - type: string - description: - type: string - isCustom: - type: boolean - - - NewProjectMember: - title: Project Member object - type: object - required: - - userId - - role - properties: - userId: - type: number - format: int64 - description: user identifier - isPrimary: - type: boolean - description: Flag to indicate this member is primary for specified role - role: - type: string - description: member role on specified project - enum: ["customer", "manager", "copilot"] - - NewProjectMemberBodyParam: - type: object - properties: - param: - $ref: "#/definitions/NewProjectMember" - - UpdateProjectMember: - title: Project Member object - type: object - required: - - role - properties: - isPrimary: - type: boolean - description: primary option - role: - type: string - description: member role on specified project - enum: ["customer", "manager", "copilot"] - - UpdateProjectMemberBodyParam: - type: object - properties: - param: - $ref: "#/definitions/UpdateProjectMember" - - NewProjectAttachment: - title: Project attachment request - type: object - required: - - filePath - - s3Bucket - - title - - contentType - properties: - filePath: - type: string - description: path where file is stored - s3Bucket: - type: string - description: The s3 bucket of attachment - contentType: - type: string - description: Uploaded file content type - title: - type: string - description: Name of the attachment - description: - type: string - description: Optional description for the attached file. - category: - type: string - description: Category of attachment - size: - type: number - format: float - description: The size of attachment - - NewProjectAttachmentBodyParam: - type: object - properties: - param: - $ref: "#/definitions/NewProjectAttachment" - - NewProjectAttachmentResponse: - title: Project attachment object response - 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 - content: - $ref: "#/definitions/ProjectAttachment" - - ProjectAttachment: - title: Project attachment - type: object - properties: - id: - type: number - description: unique id for the attachment - size: - type: number - format: float - description: The size of attachment - category: - type: string - description: The category of attachment - contentType: - type: string - description: Uploaded file content type - title: - type: string - description: Name of the attachment - description: - type: string - description: Optional description for the attached file. - downloadUrl: - type: string - description: download link for the attachment. - createdAt: - type: string - description: Datetime (GMT) when task was created - readOnly: true - createdBy: - type: integer - format: int64 - description: READ-ONLY. User who created this task - readOnly: true - updatedAt: - type: string - description: READ-ONLY. Datetime (GMT) when task was updated - readOnly: true - updatedBy: - type: integer - format: int64 - description: READ-ONLY. User that last updated this task - readOnly: true - - ProjectMember: - title: Project Member object - type: object - properties: - id: - type: number - description: unique identifier for record - userId: - type: number - format: int64 - description: user identifier - isPrimary: - type: boolean - description: Flag to indicate this member is primary for specified role - projectId: - type: number - format: int64 - description: project identifier - role: - type: string - description: member role on specified project - enum: ["customer", "manager", "copilot"] - createdAt: - type: string - description: Datetime (GMT) when task was created - readOnly: true - createdBy: - type: integer - format: int64 - description: READ-ONLY. User who created this task - readOnly: true - updatedAt: - type: string - description: READ-ONLY. Datetime (GMT) when task was updated - readOnly: true - updatedBy: - type: integer - format: int64 - description: READ-ONLY. User that last updated this task - readOnly: true - - - - NewProjectMemberResponse: - title: Project member object response - 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 - content: - $ref: "#/definitions/ProjectMember" - - UpdateProjectMemberResponse: - title: Project member object response - 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 - content: - $ref: "#/definitions/ProjectMember" - - - ProjectResponse: - title: Single project 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 - content: - $ref: "#/definitions/Project" - - UpdateProjectResponse: - title: response with original and updated project 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 - content: - type: object - properties: - original: - $ref: "#/definitions/Project" - updated: - $ref: "#/definitions/Project" - - ProjectListResponse: - title: List response - 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/Project" +--- +swagger: "2.0" +info: + version: "v4" + title: "Projects API" +# during production,should point to your server machine +host: localhost:3000 +basePath: "/v4" +# during production, should use https +schemes: +- "http" +produces: +- application/json +consumes: +- application/json + +securityDefinitions: + Bearer: + type: apiKey + name: Authorization + in: header + +paths: + /projects: + get: + tags: + - project + operationId: findProjects + security: + - Bearer: [] + description: Retreive projects that match the filter + responses: + '422': + description: Invalid input + schema: + $ref: "#/definitions/ErrorModel" + '403': + description: No permission or wrong token + schema: + $ref: "#/definitions/ErrorModel" + '200': + description: A list of projects + schema: + $ref: "#/definitions/ProjectListResponse" + parameters: + - $ref: "#/parameters/offsetParam" + - $ref: "#/parameters/limitParam" + - name: filter + required: true + type: string + in: query + description: | + Url encoded list of Supported filters + - id + - status + - type + - memberOnly + - keyword + - name: sort + required: false + description: | + sort projects by status, name, type, createdAt, updatedAt. Default is createdAt asc + in: query + type: string + post: + operationId: addProject + security: + - Bearer: [] + description: Create a project + parameters: + - in: body + name: body + required: true + schema: + $ref: '#/definitions/NewProjectBodyParam' + responses: + '403': + description: No permission or wrong token + schema: + $ref: "#/definitions/ErrorModel" + '201': + description: Returns the newly created project + schema: + $ref: "#/definitions/ProjectResponse" + '422': + description: Invalid input + schema: + $ref: "#/definitions/ErrorModel" + + /projects/{projectId}: + get: + description: Retrieve project by id + 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 + schema: + $ref: "#/definitions/ProjectResponse" + parameters: + - $ref: "#/parameters/projectIdParam" + - name: fields + required: false + type: string + in: query + description: | + Comma separated list of project fields to return. + Can also specify project_members, attachments to get project members and project attachments. + Sub fields of project members and project attachments are also allowed. + operationId: getProject + + patch: + operationId: updateProject + security: + - Bearer: [] + description: Update a project that user has access to. Managers and admin are able to pull out a project from cancelled state. + 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. Returns original and updated project object + schema: + $ref: "#/definitions/UpdateProjectResponse" + '422': + description: Invalid input + schema: + $ref: "#/definitions/ErrorModel" + default: + description: error payload + schema: + $ref: '#/definitions/ErrorModel' + parameters: + - $ref: "#/parameters/projectIdParam" + - name: body + in: body + required: true + description: Only specify those properties that needs to be updated. `cancelReason` is mandatory if status is cancelled + schema: + $ref: "#/definitions/ProjectBodyParam" + + delete: + description: remove an existing project + security: + - Bearer: [] + parameters: + - $ref: "#/parameters/projectIdParam" + 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 successfully removed + + /projects/{projectId}/attachments: + post: + description: add a new project attachment + security: + - Bearer: [] + parameters: + - $ref: "#/parameters/projectIdParam" + - in: body + name: body + required: true + schema: + $ref: '#/definitions/NewProjectAttachmentBodyParam' + responses: + '403': + description: No permission or wrong token + schema: + $ref: "#/definitions/ErrorModel" + '201': + description: Returns the newly created project attachment + schema: + $ref: "#/definitions/NewProjectAttachmentResponse" + '422': + description: Invalid input + schema: + $ref: "#/definitions/ErrorModel" + + /projects/{projectId}/attachments/{id}: + patch: + description: Update an existing attachment + security: + - Bearer: [] + parameters: + - $ref: "#/parameters/projectIdParam" + - in: path + name: id + required: true + description: The id of attachment to update + type: integer + - in: body + name: body + required: true + description: Specify only those properties that needs to be updated + schema: + $ref: '#/definitions/NewProjectAttachmentBodyParam' + responses: + '403': + description: No permission or wrong token + schema: + $ref: "#/definitions/ErrorModel" + '201': + description: Returns the newly created project + schema: + $ref: "#/definitions/NewProjectAttachmentResponse" + '404': + description: If project attachment is not found + schema: + $ref: "#/definitions/ErrorModel" + '422': + description: Invalid input + schema: + $ref: "#/definitions/ErrorModel" + delete: + description: remove an existing attachment + security: + - Bearer: [] + parameters: + - $ref: "#/parameters/projectIdParam" + - in: path + name: id + required: true + description: The id of attachment to delete + type: integer + responses: + '403': + description: No permission or wrong token + schema: + $ref: "#/definitions/ErrorModel" + '404': + description: If attachment is not found + schema: + $ref: "#/definitions/ErrorModel" + '204': + description: Attachment successfully removed + + /projects/{projectId}/members: + post: + description: add a new project member + security: + - Bearer: [] + parameters: + - $ref: "#/parameters/projectIdParam" + - in: body + name: body + required: true + schema: + $ref: '#/definitions/NewProjectMemberBodyParam' + responses: + '403': + description: No permission or wrong token + schema: + $ref: "#/definitions/ErrorModel" + '201': + description: Returns the newly created project + schema: + $ref: "#/definitions/NewProjectMemberResponse" + '422': + description: Invalid input + schema: + $ref: "#/definitions/ErrorModel" + + /projects/{projectId}/members/{id}: + delete: + description: Delete a project member + security: + - Bearer: [] + parameters: + - $ref: "#/parameters/projectIdParam" + - in: path + name: id + required: true + type: integer + + responses: + '403': + description: No permission or wrong token + schema: + $ref: "#/definitions/ErrorModel" + '204': + description: Member successfully removed + patch: + security: + - Bearer: [] + description: Support editing project member roles & primary option. + 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 member. Returns entire project member object + schema: + $ref: "#/definitions/UpdateProjectMemberResponse" + '422': + description: Invalid input + schema: + $ref: "#/definitions/ErrorModel" + default: + description: error payload + schema: + $ref: '#/definitions/ErrorModel' + parameters: + - $ref: "#/parameters/projectIdParam" + - in: path + name: id + required: true + type: integer + - name: body + in: body + required: true + schema: + $ref: "#/definitions/UpdateProjectMemberBodyParam" + + + + /projects/{projectId}/phases: + parameters: + - $ref: "#/parameters/projectIdParam" + get: + tags: + - phase + operationId: findProjectPhases + security: + - Bearer: [] + description: Retreive all project phases. All users who can edit project can access this endpoint. + parameters: + - name: fields + required: false + type: string + in: query + description: | + Comma separated list of project phase fields to return. + - name: sort + required: false + description: | + sort project phases by startDate, endDate, status. Default is startDate asc + in: query + type: string + responses: + '403': + description: No permission or wrong token + schema: + $ref: "#/definitions/ErrorModel" + '200': + description: A list of project phases + schema: + $ref: "#/definitions/ProjectPhaseListResponse" + post: + tags: + - phase + operationId: addProjectPhase + security: + - Bearer: [] + description: Create a project phase + parameters: + - in: body + name: body + required: true + schema: + $ref: '#/definitions/ProjectPhaseBodyParam' + responses: + '403': + description: No permission or wrong token + schema: + $ref: "#/definitions/ErrorModel" + '201': + description: Returns the newly created project phase + schema: + $ref: "#/definitions/ProjectPhaseResponse" + '422': + description: Invalid input + schema: + $ref: "#/definitions/ErrorModel" + + /projects/{projectId}/phases/{phaseId}: + parameters: + - $ref: "#/parameters/projectIdParam" + - $ref: "#/parameters/phaseIdParam" + get: + tags: + - phase + description: Retrieve project phase by id. All users who can edit project 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 phase + schema: + $ref: "#/definitions/ProjectPhaseResponse" + parameters: + - $ref: "#/parameters/phaseIdParam" + operationId: getProjectPhase + + patch: + tags: + - phase + operationId: updateProjectPhase + security: + - Bearer: [] + description: Update a project phase. All users who can edit project 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 phase. + schema: + $ref: "#/definitions/ProjectPhaseResponse" + '422': + description: Invalid input + schema: + $ref: "#/definitions/ErrorModel" + default: + description: error payload + schema: + $ref: '#/definitions/ErrorModel' + parameters: + - $ref: "#/parameters/phaseIdParam" + - name: body + in: body + required: true + schema: + $ref: "#/definitions/ProjectPhaseBodyParam" + + delete: + tags: + - phase + description: Remove an existing project phase. All users who can edit project can access this endpoint. + security: + - Bearer: [] + parameters: + - $ref: "#/parameters/phaseIdParam" + 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 phase successfully removed + + + + /projects/{projectId}/phases/{phaseId}/products: + parameters: + - $ref: "#/parameters/projectIdParam" + - $ref: "#/parameters/phaseIdParam" + get: + tags: + - phase product + operationId: findPhaseProducts + security: + - Bearer: [] + description: Retreive all phase products. All users who can edit project can access this endpoint. + responses: + '403': + description: No permission or wrong token + schema: + $ref: "#/definitions/ErrorModel" + '200': + description: A list of phase products + schema: + $ref: "#/definitions/PhaseProductListResponse" + post: + tags: + - phase product + operationId: addPhaseProduct + security: + - Bearer: [] + description: Create a phase product + parameters: + - in: body + name: body + required: true + schema: + $ref: '#/definitions/PhaseProductBodyParam' + responses: + '403': + description: No permission or wrong token + schema: + $ref: "#/definitions/ErrorModel" + '201': + description: Returns the newly created phase product + schema: + $ref: "#/definitions/PhaseProductResponse" + '422': + description: Invalid input + schema: + $ref: "#/definitions/ErrorModel" + + /projects/{projectId}/phases/{phaseId}/products/{productId}: + parameters: + - $ref: "#/parameters/projectIdParam" + - $ref: "#/parameters/phaseIdParam" + - $ref: "#/parameters/productIdParam" + get: + tags: + - phase product + description: Retrieve phase product by id. All users who can edit project 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 phase product + schema: + $ref: "#/definitions/PhaseProductResponse" + parameters: + - $ref: "#/parameters/phaseIdParam" + operationId: getPhaseProduct + + patch: + tags: + - phase product + operationId: updatePhaseProduct + security: + - Bearer: [] + description: Update a phase product. All users who can edit project 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 phase product. + schema: + $ref: "#/definitions/PhaseProductResponse" + '422': + description: Invalid input + schema: + $ref: "#/definitions/ErrorModel" + default: + description: error payload + schema: + $ref: '#/definitions/ErrorModel' + parameters: + - $ref: "#/parameters/phaseIdParam" + - name: body + in: body + required: true + schema: + $ref: "#/definitions/PhaseProductBodyParam" + + delete: + tags: + - phase product + description: Remove an existing phase product. All users who can edit project can access this endpoint. + security: + - Bearer: [] + parameters: + - $ref: "#/parameters/phaseIdParam" + 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 phase successfully removed + + +parameters: + projectIdParam: + name: projectId + in: path + description: project identifier + required: true + type: integer + format: int64 + phaseIdParam: + name: phaseId + in: path + description: project phase identifier + required: true + type: integer + format: int64 + minimum: 1 + productIdParam: + name: productId + in: path + description: project phase product identifier + required: true + type: integer + format: int64 + minimum: 1 + offsetParam: + name: offset + description: "number of items to skip. Defaults to 0" + in: query + required: false + type: integer + format: int32 + limitParam: + name: limit + description: "max records to return. Defaults to 20" + in: query + required: false + type: integer + format: int32 + +definitions: + ResponseMetadata: + title: Metadata object for a response + type: object + properties: + totalCount: + type: integer + format: int64 + description: Total count of the objects + + ErrorModel: + type: object + properties: + id: + type: string + description: unique id identifying the request + version: + type: string + result: + type: object + properties: + success: + type: boolean + status: + description: http status code + type: integer + format: int32 + debug: + type: object + content: + type: object + + ProjectBookMark: + title: Project bookmark + type: object + properties: + title: + type: string + address: + type: string + + ProjectBodyParam: + type: object + properties: + param: + $ref: "#/definitions/Project" + + NewProject: + type: object + required: + - name + - description + - type + properties: + name: + type: string + description: project name (required) + description: + type: string + description: Project description + billingAccountId: + type: number + format: long + description: the customer billing account id + estimatedPrice: + type: number + format: float + description: The estimated price of the project + terms: + type: array + items: + type: number + format: integer + external: + type: object + description: READ-ONLY, OPTIONAL. Refernce to external task/issue. + properties: + id: + type: string + description: Identifier for external reference + type: + type: string + description: external source type + enum: [ "github", "jira", "asana", "other"] + data: + type: string + description: "300 Char length text blob for customer provided data" + type: + type: string + description: project type + enum: ["generic", "visual_design", "visual_prototype", "app_dev"] + bookmarks: + type: array + items: + $ref: "#/definitions/ProjectBookMark" + challengeEligibility: + description: List of eligibility criteria (one entry per role) + type: array + items: + $ref: "#/definitions/ChallengeEligibility" + details: + $ref: "#/definitions/ProjectDetails" + utm: + description: READ-ONLY. Used for tracking + type: object + properties: + campaign: + type: string + medium: + type: string + source: + type: string + + + NewProjectBodyParam: + type: object + properties: + param: + $ref: "#/definitions/NewProject" + + ChallengeEligibility: + description: Object describing who is eligible to work on this task + type: object + properties: + role: + type: string + enum: ["submitter", "reviewer", "copilot"] + users: + type: array + items: + type: integer + format: int64 + groups: + type: array + items: + type: integer + format: int64 + + + Project: + type: object + properties: + id: + description: unique identifier + type: integer + format: int64 + directProjectId: + description: unique identifier in direct + type: integer + format: int64 + billingAccountId: + type: integer + format: int64 + description: The customer billing account id + utm: + description: READ-ONLY. Used for tracking + type: object + properties: + campaign: + type: string + medium: + type: string + source: + type: string + estimatedPrice: + type: number + format: float + description: The estimated price of the project + actualPrice: + type: number + format: float + description: The actual price of the project + terms: + type: array + items: + type: number + format: integer + name: + type: string + description: project name + description: + type: string + description: Project description + + external: + type: object + description: READ-ONLY, OPTIONAL. Refernce to external task/issue. + properties: + id: + type: string + description: Identifier for external reference + type: + type: string + description: external source type + enum: [ "github", "jira", "asana", "other"] + data: + type: string + description: "300 Char length text blob for customer provided data" + type: + type: string + description: project type + enum: ["app_dev", "generic", "visual_prototype", "visual_design"] + status: + type: string + description: current state of the task + enum: ["draft", "in_review", "reviewed", "active", "paused", "cancelled", "completed"] + cancelReason: + type: string + description: If a project is cancelled, define the reason of cancellation + challengeEligibility: + description: List of eligibility criteria (one entry per role) + type: array + items: + $ref: "#/definitions/ChallengeEligibility" + bookmarks: + type: array + items: + $ref: "#/definitions/ProjectBookMark" + members: + description: | + READ-ONLY. List of project members. + Use project member api to add/remove members + type: array + items: + $ref: "#/definitions/ProjectMember" + attachments: + description: | + READ-ONLY. List of project attachmens. + Use project attachment api to add/remove attachments + type: array + items: + $ref: "#/definitions/ProjectAttachment" + details: + $ref: "#/definitions/ProjectDetails" + + createdAt: + type: string + description: Datetime (GMT) when task was created + readOnly: true + createdBy: + type: integer + format: int64 + description: READ-ONLY. User who created this task + readOnly: true + updatedAt: + type: string + description: READ-ONLY. Datetime (GMT) when task was updated + readOnly: true + updatedBy: + type: integer + format: int64 + description: READ-ONLY. User that last updated this task + readOnly: true + + ProjectDetails: + description: Project details + type: object + properties: + summary: + type: string + description: text summary of the project + TBD_usageDescription: + type: string + description: a description of how the app will be used + TBD_features: + type: object + properties: + id: + type: integer + title: + type: string + description: + type: string + isCustom: + type: boolean + + + NewProjectMember: + title: Project Member object + type: object + required: + - userId + - role + properties: + userId: + type: number + format: int64 + description: user identifier + isPrimary: + type: boolean + description: Flag to indicate this member is primary for specified role + role: + type: string + description: member role on specified project + enum: ["customer", "manager", "copilot"] + + NewProjectMemberBodyParam: + type: object + properties: + param: + $ref: "#/definitions/NewProjectMember" + + UpdateProjectMember: + title: Project Member object + type: object + required: + - role + properties: + isPrimary: + type: boolean + description: primary option + role: + type: string + description: member role on specified project + enum: ["customer", "manager", "copilot"] + + UpdateProjectMemberBodyParam: + type: object + properties: + param: + $ref: "#/definitions/UpdateProjectMember" + + NewProjectAttachment: + title: Project attachment request + type: object + required: + - filePath + - s3Bucket + - title + - contentType + properties: + filePath: + type: string + description: path where file is stored + s3Bucket: + type: string + description: The s3 bucket of attachment + contentType: + type: string + description: Uploaded file content type + title: + type: string + description: Name of the attachment + description: + type: string + description: Optional description for the attached file. + category: + type: string + description: Category of attachment + size: + type: number + format: float + description: The size of attachment + + NewProjectAttachmentBodyParam: + type: object + properties: + param: + $ref: "#/definitions/NewProjectAttachment" + + NewProjectAttachmentResponse: + title: Project attachment object response + 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 + content: + $ref: "#/definitions/ProjectAttachment" + + ProjectAttachment: + title: Project attachment + type: object + properties: + id: + type: number + description: unique id for the attachment + size: + type: number + format: float + description: The size of attachment + category: + type: string + description: The category of attachment + contentType: + type: string + description: Uploaded file content type + title: + type: string + description: Name of the attachment + description: + type: string + description: Optional description for the attached file. + downloadUrl: + type: string + description: download link for the attachment. + createdAt: + type: string + description: Datetime (GMT) when task was created + readOnly: true + createdBy: + type: integer + format: int64 + description: READ-ONLY. User who created this task + readOnly: true + updatedAt: + type: string + description: READ-ONLY. Datetime (GMT) when task was updated + readOnly: true + updatedBy: + type: integer + format: int64 + description: READ-ONLY. User that last updated this task + readOnly: true + + ProjectMember: + title: Project Member object + type: object + properties: + id: + type: number + description: unique identifier for record + userId: + type: number + format: int64 + description: user identifier + isPrimary: + type: boolean + description: Flag to indicate this member is primary for specified role + projectId: + type: number + format: int64 + description: project identifier + role: + type: string + description: member role on specified project + enum: ["customer", "manager", "copilot"] + createdAt: + type: string + description: Datetime (GMT) when task was created + readOnly: true + createdBy: + type: integer + format: int64 + description: READ-ONLY. User who created this task + readOnly: true + updatedAt: + type: string + description: READ-ONLY. Datetime (GMT) when task was updated + readOnly: true + updatedBy: + type: integer + format: int64 + description: READ-ONLY. User that last updated this task + readOnly: true + + + + NewProjectMemberResponse: + title: Project member object response + 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 + content: + $ref: "#/definitions/ProjectMember" + + UpdateProjectMemberResponse: + title: Project member object response + 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 + content: + $ref: "#/definitions/ProjectMember" + + + ProjectResponse: + title: Single project 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 + content: + $ref: "#/definitions/Project" + + UpdateProjectResponse: + title: response with original and updated project 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 + content: + type: object + properties: + original: + $ref: "#/definitions/Project" + updated: + $ref: "#/definitions/Project" + + ProjectListResponse: + title: List response + 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/Project" + + + + + ProjectPhaseRequest: + title: Project phase request object + type: object + required: + - name + - status + - startDate + - endDate + properties: + name: + type: string + description: the project phase name + status: + type: string + description: the project phase status + startDate: + type: string + format: date + description: the project phase start date + endDate: + type: string + format: date + description: the project phase end date + budget: + type: number + description: the project phase budget + progress: + type: number + description: the project phase progress + details: + type: object + description: the project phase details + + ProjectPhaseBodyParam: + title: Project phase body param + type: object + required: + - param + properties: + param: + $ref: "#/definitions/ProjectPhaseRequest" + + ProjectPhase: + title: Project phase object + allOf: + - type: object + required: + - id + - createdAt + - createdBy + - updatedAt + - updatedBy + properties: + id: + type: number + format: int64 + description: the id + 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/ProjectPhaseRequest" + + + ProjectPhaseResponse: + title: Single project phase 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/ProjectPhase" + + ProjectPhaseListResponse: + title: Project phase 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/ProjectPhase" + + + PhaseProductRequest: + title: Phase product request object + type: object + properties: + name: + type: string + description: the phase product name + directProjectId: + type: number + description: the phase product direct project id + billingAccountId: + type: number + description: the phase product billing account Id + templateId: + type: number + description: the phase product template id + type: + type: string + description: the phase product type + estimatedPrice: + type: number + description: the phase product estimated price + actualPrice: + type: number + description: the phase product actual price + details: + type: object + description: the phase product details + + PhaseProductBodyParam: + title: Phase product body param + type: object + required: + - param + properties: + param: + $ref: "#/definitions/PhaseProductRequest" + + PhaseProduct: + title: Phase product object + allOf: + - type: object + required: + - id + - createdAt + - createdBy + - updatedAt + - updatedBy + properties: + id: + type: number + format: int64 + description: the id + 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/PhaseProductRequest" + + + PhaseProductResponse: + title: Single phase product 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/PhaseProduct" + + PhaseProductListResponse: + title: Phase product 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/PhaseProduct" + \ No newline at end of file From 1e65c939eb4428cd7fa89d81bdecae39715a99e9 Mon Sep 17 00:00:00 2001 From: Paulo Vitor Magacho Date: Sat, 26 May 2018 08:13:49 -0300 Subject: [PATCH 3/3] merge from project and product templates --- .circleci/config.yml | 2 +- README.md | 2 +- postman.json | 242 ++++++++- postman_environment.json | 23 + src/models/productTemplate.js | 32 ++ src/models/projectTemplate.js | 30 ++ src/permissions/connectManagerOrAdmin.ops.js | 18 + src/permissions/index.js | 11 + src/routes/index.js | 35 +- src/routes/productTemplates/create.js | 51 ++ src/routes/productTemplates/create.spec.js | 157 ++++++ src/routes/productTemplates/delete.js | 55 ++ src/routes/productTemplates/delete.spec.js | 129 +++++ src/routes/productTemplates/get.js | 41 ++ src/routes/productTemplates/get.spec.js | 153 ++++++ src/routes/productTemplates/list.js | 23 + src/routes/productTemplates/list.spec.js | 148 ++++++ src/routes/productTemplates/update.js | 72 +++ src/routes/productTemplates/update.spec.js | 244 +++++++++ src/routes/projectTemplates/create.js | 49 ++ src/routes/projectTemplates/create.spec.js | 153 ++++++ src/routes/projectTemplates/delete.js | 55 ++ src/routes/projectTemplates/delete.spec.js | 127 +++++ src/routes/projectTemplates/get.js | 41 ++ src/routes/projectTemplates/get.spec.js | 148 ++++++ src/routes/projectTemplates/list.js | 23 + src/routes/projectTemplates/list.spec.js | 141 ++++++ src/routes/projectTemplates/update.js | 70 +++ src/routes/projectTemplates/update.spec.js | 237 +++++++++ src/tests/seed.js | 296 +++++++---- src/util.js | 80 +-- swagger.yaml | 500 +++++++++++++++++++ 32 files changed, 3237 insertions(+), 151 deletions(-) create mode 100644 postman_environment.json create mode 100644 src/models/productTemplate.js create mode 100644 src/models/projectTemplate.js create mode 100644 src/permissions/connectManagerOrAdmin.ops.js create mode 100644 src/routes/productTemplates/create.js create mode 100644 src/routes/productTemplates/create.spec.js create mode 100644 src/routes/productTemplates/delete.js create mode 100644 src/routes/productTemplates/delete.spec.js create mode 100644 src/routes/productTemplates/get.js create mode 100644 src/routes/productTemplates/get.spec.js create mode 100644 src/routes/productTemplates/list.js create mode 100644 src/routes/productTemplates/list.spec.js create mode 100644 src/routes/productTemplates/update.js create mode 100644 src/routes/productTemplates/update.spec.js create mode 100644 src/routes/projectTemplates/create.js create mode 100644 src/routes/projectTemplates/create.spec.js create mode 100644 src/routes/projectTemplates/delete.js create mode 100644 src/routes/projectTemplates/delete.spec.js create mode 100644 src/routes/projectTemplates/get.js create mode 100644 src/routes/projectTemplates/get.spec.js create mode 100644 src/routes/projectTemplates/list.js create mode 100644 src/routes/projectTemplates/list.spec.js create mode 100644 src/routes/projectTemplates/update.js create mode 100644 src/routes/projectTemplates/update.spec.js diff --git a/.circleci/config.yml b/.circleci/config.yml index cfc1f527..4fea1158 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -76,7 +76,7 @@ workflows: - test filters: branches: - only: [dev, 'feature/db-lock-issue'] + only: dev - deployProd: requires: - test diff --git a/README.md b/README.md index 2a18f10c..e2e5a707 100644 --- a/README.md +++ b/README.md @@ -71,7 +71,7 @@ Run image: `docker run -p 3000:3000 -i -t -e DB_HOST=172.17.0.1 tc_projects_services` You may replace 172.17.0.1 with your docker0 IP. -You can paste **swagger.yaml** to [swagger editor](http://editor.swagger.io/) or import **postman.json** to verify endpoints. +You can paste **swagger.yaml** to [swagger editor](http://editor.swagger.io/) or import **postman.json** and **postman_environment.json** to verify endpoints. #### Deploying without docker If you don't want to use docker to deploy to localhost. You can simply run `npm run start` from root of project. This should start the server on default port `3000`. diff --git a/postman.json b/postman.json index 807c4f87..314c5d4b 100644 --- a/postman.json +++ b/postman.json @@ -1,13 +1,13 @@ { "info": { + "_postman_id": "469f0f40-b34a-40f9-b89d-bd7fde996676", "name": "tc-project-service ", - "_postman_id": "8f323d9c-63bd-5f2c-87f1-1e99083786f3", - "description": "", "schema": "https://schema.getpostman.com/json/collection/v2.0.0/collection.json" }, "item": [ { "name": "Project Attachments", + "description": null, "item": [ { "name": "Upload attachment", @@ -82,6 +82,7 @@ }, { "name": "Project Members", + "description": null, "item": [ { "name": "Create project member with no payload", @@ -900,6 +901,7 @@ }, { "name": "bookmarks", + "description": null, "item": [ { "name": " Create project without bookmarks", @@ -1077,6 +1079,7 @@ }, { "name": "issue1", + "description": null, "item": [ { "name": "get projects with copilot token", @@ -1100,6 +1103,7 @@ }, { "name": "issue10", + "description": null, "item": [ { "name": "wrong role", @@ -1149,6 +1153,7 @@ }, { "name": "issue5", + "description": null, "item": [ { "name": "launch a project by topcoder managers ", @@ -1220,6 +1225,7 @@ }, { "name": "issue8", + "description": null, "item": [ { "name": "mock direct projects", @@ -1376,6 +1382,238 @@ "response": [] } ] + }, + { + "name": "Project Templates", + "description": "", + "item": [ + { + "name": "Create project template", + "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 \"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" + }, + "response": [] + }, + { + "name": "List project templates", + "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": "{{api-url}}/v4/projectTemplates" + }, + "response": [] + }, + { + "name": "Get project template", + "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": "{{api-url}}/v4/projectTemplates/1" + }, + "response": [] + }, + { + "name": "Update project template", + "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 \"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" + }, + "response": [] + }, + { + "name": "Delete project template", + "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": "{{api-url}}/v4/projectTemplates/1" + }, + "response": [] + } + ] + }, + { + "name": "Product Templates", + "description": "", + "item": [ + { + "name": "Create product template", + "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 \"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" + }, + "response": [] + }, + { + "name": "List product templates", + "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": "{{api-url}}/v4/productTemplates" + }, + "response": [] + }, + { + "name": "Get product template", + "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": "{{api-url}}/v4/productTemplates/1" + }, + "response": [] + }, + { + "name": "Update product template", + "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 \"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" + }, + "response": [] + }, + { + "name": "Delete product template", + "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": "{{api-url}}/v4/productTemplates/1" + }, + "response": [] + } + ] } ] } \ No newline at end of file diff --git a/postman_environment.json b/postman_environment.json new file mode 100644 index 00000000..12fab912 --- /dev/null +++ b/postman_environment.json @@ -0,0 +1,23 @@ +{ + "id": "e6b30b4b-1388-4622-8314-bc49ba1d752b", + "name": "tc-project-service", + "values": [ + { + "key": "api-url", + "value": "http://localhost:3000", + "description": "", + "type": "text", + "enabled": true + }, + { + "key": "jwt-token", + "value": "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJyb2xlcyI6WyJUb3Bjb2RlciBVc2VyIiwiYWRtaW5pc3RyYXRvciJdLCJpc3MiOiJodHRwczovL2FwaS50b3Bjb2Rlci1kZXYuY29tIiwiaGFuZGxlIjoidGVzdDEiLCJleHAiOjI1NjMwNzY2ODksInVzZXJJZCI6IjQwMDUxMzMzIiwiaWF0IjoxNDYzMDc2MDg5LCJlbWFpbCI6InRlc3RAdG9wY29kZXIuY29tIiwianRpIjoiYjMzYjc3Y2QtYjUyZS00MGZlLTgzN2UtYmViOGUwYWU2YTRhIn0.wKWUe0-SaiFVN-VR_-GwgFlvWaDkSbc8H55ktb9LAVw", + "description": "", + "type": "text", + "enabled": true + } + ], + "_postman_variable_scope": "environment", + "_postman_exported_at": "2018-05-18T18:54:18.167Z", + "_postman_exported_using": "Postman/6.0.10" +} \ No newline at end of file diff --git a/src/models/productTemplate.js b/src/models/productTemplate.js new file mode 100644 index 00000000..72d7bc30 --- /dev/null +++ b/src/models/productTemplate.js @@ -0,0 +1,32 @@ +/* eslint-disable valid-jsdoc */ + +/** + * The Product Template model + */ +module.exports = (sequelize, DataTypes) => { + const ProductTemplate = sequelize.define('ProductTemplate', { + id: { type: DataTypes.BIGINT, primaryKey: true, autoIncrement: true }, + name: { type: DataTypes.STRING(255), allowNull: false }, + productKey: { type: DataTypes.STRING(45), allowNull: false }, + icon: { type: DataTypes.STRING(255), allowNull: false }, + brief: { type: DataTypes.STRING(45), allowNull: false }, + details: { type: DataTypes.STRING(255), allowNull: false }, + aliases: { type: DataTypes.JSON, allowNull: false }, + template: { type: DataTypes.JSON, allowNull: false }, + deletedAt: DataTypes.DATE, + createdAt: { type: DataTypes.DATE, defaultValue: DataTypes.NOW }, + updatedAt: { type: DataTypes.DATE, defaultValue: DataTypes.NOW }, + deletedBy: DataTypes.BIGINT, + createdBy: { type: DataTypes.BIGINT, allowNull: false }, + updatedBy: { type: DataTypes.BIGINT, allowNull: false }, + }, { + tableName: 'product_templates', + paranoid: true, + timestamps: true, + updatedAt: 'updatedAt', + createdAt: 'createdAt', + deletedAt: 'deletedAt', + }); + + return ProductTemplate; +}; diff --git a/src/models/projectTemplate.js b/src/models/projectTemplate.js new file mode 100644 index 00000000..206fa9e0 --- /dev/null +++ b/src/models/projectTemplate.js @@ -0,0 +1,30 @@ +/* eslint-disable valid-jsdoc */ + +/** + * The Project Template model + */ +module.exports = (sequelize, DataTypes) => { + const ProjectTemplate = sequelize.define('ProjectTemplate', { + id: { type: DataTypes.BIGINT, primaryKey: true, autoIncrement: true }, + name: { type: DataTypes.STRING(255), allowNull: false }, + key: { type: DataTypes.STRING(45), allowNull: false }, + category: { type: DataTypes.STRING(45), allowNull: false }, + scope: { type: DataTypes.JSON, allowNull: false }, + phases: { type: DataTypes.JSON, allowNull: false }, + deletedAt: DataTypes.DATE, + createdAt: { type: DataTypes.DATE, defaultValue: DataTypes.NOW }, + updatedAt: { type: DataTypes.DATE, defaultValue: DataTypes.NOW }, + deletedBy: DataTypes.BIGINT, + createdBy: { type: DataTypes.BIGINT, allowNull: false }, + updatedBy: { type: DataTypes.BIGINT, allowNull: false }, + }, { + tableName: 'project_templates', + paranoid: true, + timestamps: true, + updatedAt: 'updatedAt', + createdAt: 'createdAt', + deletedAt: 'deletedAt', + }); + + return ProjectTemplate; +}; diff --git a/src/permissions/connectManagerOrAdmin.ops.js b/src/permissions/connectManagerOrAdmin.ops.js new file mode 100644 index 00000000..0a5fb15a --- /dev/null +++ b/src/permissions/connectManagerOrAdmin.ops.js @@ -0,0 +1,18 @@ +import util from '../util'; +import { MANAGER_ROLES } from '../constants'; + + +/** + * Only Connect Manager, Connect Admin, and administrator are allowed to perform the operations + * @param {Object} req the express request instance + * @return {Promise} returns a promise + */ +module.exports = req => new Promise((resolve, reject) => { + const hasAccess = util.hasRoles(req, MANAGER_ROLES); + + if (!hasAccess) { + return reject(new Error('You do not have permissions to perform this action')); + } + + return resolve(true); +}); diff --git a/src/permissions/index.js b/src/permissions/index.js index e0797b3c..54365072 100644 --- a/src/permissions/index.js +++ b/src/permissions/index.js @@ -6,6 +6,7 @@ const projectEdit = require('./project.edit'); const projectDelete = require('./project.delete'); const projectMemberDelete = require('./projectMember.delete'); const projectAdmin = require('./admin.ops'); +const connectManagerOrAdmin = require('./connectManagerOrAdmin.ops'); module.exports = () => { Authorizer.setDeniedStatusCode(403); @@ -23,4 +24,14 @@ module.exports = () => { Authorizer.setPolicy('project.downloadAttachment', projectView); Authorizer.setPolicy('project.updateMember', projectEdit); Authorizer.setPolicy('project.admin', projectAdmin); + + Authorizer.setPolicy('projectTemplate.create', connectManagerOrAdmin); + Authorizer.setPolicy('projectTemplate.edit', connectManagerOrAdmin); + Authorizer.setPolicy('projectTemplate.delete', connectManagerOrAdmin); + Authorizer.setPolicy('projectTemplate.view', true); + + Authorizer.setPolicy('productTemplate.create', connectManagerOrAdmin); + Authorizer.setPolicy('productTemplate.edit', connectManagerOrAdmin); + Authorizer.setPolicy('productTemplate.delete', connectManagerOrAdmin); + Authorizer.setPolicy('productTemplate.view', true); }; diff --git a/src/routes/index.js b/src/routes/index.js index 47a51502..35abd8ba 100644 --- a/src/routes/index.js +++ b/src/routes/index.js @@ -26,7 +26,9 @@ router.get(`/${apiVersion}/projects/health`, (req, res) => { // All project service endpoints need authentication const jwtAuth = require('tc-core-library-js').middleware.jwtAuthenticator; -router.all(RegExp(`\\/${apiVersion}\\/projects(?!\\/health).*`), jwtAuth()); +router.all( + RegExp(`\\/${apiVersion}\\/(projects|projectTemplates|productTemplates)(?!\\/health).*`), + jwtAuth()); // Register all the routes router.route('/v4/projects') @@ -51,19 +53,34 @@ router.route('/v4/projects/:projectId(\\d+)') .delete(require('./projects/delete')); router.route('/v4/projects/:projectId(\\d+)/members') - .post(require('./projectMembers/create')); + .post(require('./projectMembers/create')); router.route('/v4/projects/:projectId(\\d+)/members/:id(\\d+)') - .delete(require('./projectMembers/delete')) - .patch(require('./projectMembers/update')); + .delete(require('./projectMembers/delete')) + .patch(require('./projectMembers/update')); router.route('/v4/projects/:projectId(\\d+)/attachments') - .post(require('./attachments/create')); + .post(require('./attachments/create')); router.route('/v4/projects/:projectId(\\d+)/attachments/:id(\\d+)') - .get(require('./attachments/download')) - .patch(require('./attachments/update')) - .delete(require('./attachments/delete')); - + .get(require('./attachments/download')) + .patch(require('./attachments/update')) + .delete(require('./attachments/delete')); + +router.route('/v4/projectTemplates') + .post(require('./projectTemplates/create')) + .get(require('./projectTemplates/list')); +router.route('/v4/projectTemplates/:templateId(\\d+)') + .get(require('./projectTemplates/get')) + .patch(require('./projectTemplates/update')) + .delete(require('./projectTemplates/delete')); + +router.route('/v4/productTemplates') + .post(require('./productTemplates/create')) + .get(require('./productTemplates/list')); +router.route('/v4/productTemplates/:templateId(\\d+)') + .get(require('./productTemplates/get')) + .patch(require('./productTemplates/update')) + .delete(require('./productTemplates/delete')); // register error handler router.use((err, req, res, next) => { // eslint-disable-line no-unused-vars diff --git a/src/routes/productTemplates/create.js b/src/routes/productTemplates/create.js new file mode 100644 index 00000000..b00363e3 --- /dev/null +++ b/src/routes/productTemplates/create.js @@ -0,0 +1,51 @@ +/** + * API to add a product template + */ +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({ + id: Joi.any().strip(), + name: Joi.string().max(255).required(), + productKey: Joi.string().max(45).required(), + icon: Joi.string().max(255).required(), + brief: Joi.string().max(45).required(), + details: Joi.string().max(255).required(), + aliases: Joi.object().required(), + template: Joi.object().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('productTemplate.create'), + (req, res, next) => { + const entity = _.assign(req.body.param, { + createdBy: req.authUser.userId, + updatedBy: req.authUser.userId, + }); + + return models.ProductTemplate.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/productTemplates/create.spec.js b/src/routes/productTemplates/create.spec.js new file mode 100644 index 00000000..8476a5ef --- /dev/null +++ b/src/routes/productTemplates/create.spec.js @@ -0,0 +1,157 @@ +/** + * Tests for create.js + */ +import chai from 'chai'; +import request from 'supertest'; + +import server from '../../app'; +import testUtil from '../../tests/util'; + +const should = chai.should(); + +describe('CREATE product template', () => { + describe('POST /productTemplates', () => { + const body = { + param: { + name: 'name 1', + productKey: 'productKey 1', + icon: 'http://example.com/icon1.ico', + brief: 'brief 1', + details: 'details 1', + aliases: { + alias1: { + subAlias1A: 1, + subAlias1B: 2, + }, + alias2: [1, 2, 3], + }, + template: { + template1: { + name: 'template 1', + details: { + anyDetails: 'any details 1', + }, + others: ['others 11', 'others 12'], + }, + template2: { + name: 'template 2', + details: { + anyDetails: 'any details 2', + }, + others: ['others 21', 'others 22'], + }, + }, + }, + }; + + it('should return 403 if user is not authenticated', (done) => { + request(server) + .post('/v4/productTemplates') + .send(body) + .expect(403, done); + }); + + it('should return 403 for member', (done) => { + request(server) + .post('/v4/productTemplates') + .set({ + Authorization: `Bearer ${testUtil.jwts.member}`, + }) + .send(body) + .expect(403, done); + }); + + it('should return 403 for copilot', (done) => { + request(server) + .post('/v4/productTemplates') + .set({ + Authorization: `Bearer ${testUtil.jwts.copilot}`, + }) + .send(body) + .expect(403, done); + }); + + it('should return 422 if validations dont pass', (done) => { + const invalidBody = { + param: { + aliases: 'a', + template: 1, + }, + }; + + request(server) + .post('/v4/productTemplates') + .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/productTemplates') + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .send(body) + .expect('Content-Type', /json/) + .expect(201) + .end((err, res) => { + const resJson = res.body.result.content; + should.exist(resJson.id); + resJson.name.should.be.eql(body.param.name); + resJson.productKey.should.be.eql(body.param.productKey); + resJson.icon.should.be.eql(body.param.icon); + resJson.brief.should.be.eql(body.param.brief); + resJson.details.should.be.eql(body.param.details); + resJson.aliases.should.be.eql(body.param.aliases); + resJson.template.should.be.eql(body.param.template); + + 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 manager', (done) => { + request(server) + .post('/v4/productTemplates') + .set({ + Authorization: `Bearer ${testUtil.jwts.manager}`, + }) + .send(body) + .expect('Content-Type', /json/) + .expect(201) + .end((err, res) => { + const resJson = res.body.result.content; + resJson.createdBy.should.be.eql(40051334); // manager + resJson.updatedBy.should.be.eql(40051334); // manager + done(); + }); + }); + + it('should return 201 for connect admin', (done) => { + request(server) + .post('/v4/productTemplates') + .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/productTemplates/delete.js b/src/routes/productTemplates/delete.js new file mode 100644 index 00000000..81c65b6b --- /dev/null +++ b/src/routes/productTemplates/delete.js @@ -0,0 +1,55 @@ +/** + * API to delete a product template + */ +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: { + templateId: Joi.number().integer().positive().required(), + }, +}; + +module.exports = [ + validate(schema), + permissions('productTemplate.delete'), + (req, res, next) => { + const where = { + deletedAt: { $eq: null }, + id: req.params.templateId, + }; + + return models.sequelize.transaction(tx => + // Update the deletedBy + models.ProductTemplate.update({ deletedBy: req.authUser.userId }, { + where, + returning: true, + raw: true, + transaction: tx, + }) + .then((updatedResults) => { + // Not found + if (updatedResults[0] === 0) { + const apiErr = new Error(`Product template not found for template id ${req.params.templateId}`); + apiErr.status = 404; + return Promise.reject(apiErr); + } + + // Soft delete + return models.ProductTemplate.destroy({ + where, + transaction: tx, + raw: true, + }); + }) + .then(() => { + res.status(204).end(); + }) + .catch(next), + ); + }, +]; diff --git a/src/routes/productTemplates/delete.spec.js b/src/routes/productTemplates/delete.spec.js new file mode 100644 index 00000000..058fea4c --- /dev/null +++ b/src/routes/productTemplates/delete.spec.js @@ -0,0 +1,129 @@ +/** + * Tests for delete.js + */ +import request from 'supertest'; + +import models from '../../models'; +import server from '../../app'; +import testUtil from '../../tests/util'; + + +describe('DELETE product template', () => { + let templateId; + + beforeEach(() => testUtil.clearDb() + .then(() => models.ProductTemplate.create({ + name: 'name 1', + productKey: 'productKey 1', + icon: 'http://example.com/icon1.ico', + brief: 'brief 1', + details: 'details 1', + aliases: { + alias1: { + subAlias1A: 1, + subAlias1B: 2, + }, + alias2: [1, 2, 3], + }, + template: { + template1: { + name: 'template 1', + details: { + anyDetails: 'any details 1', + }, + others: ['others 11', 'others 12'], + }, + template2: { + name: 'template 2', + details: { + anyDetails: 'any details 2', + }, + others: ['others 21', 'others 22'], + }, + }, + createdBy: 1, + updatedBy: 2, + })).then((template) => { + templateId = template.id; + return Promise.resolve(); + }), + ); + after(testUtil.clearDb); + + describe('DELETE /productTemplates/{templateId}', () => { + it('should return 403 if user is not authenticated', (done) => { + request(server) + .delete(`/v4/productTemplates/${templateId}`) + .expect(403, done); + }); + + it('should return 403 for member', (done) => { + request(server) + .delete(`/v4/productTemplates/${templateId}`) + .set({ + Authorization: `Bearer ${testUtil.jwts.member}`, + }) + .expect(403, done); + }); + + it('should return 403 for copilot', (done) => { + request(server) + .delete(`/v4/productTemplates/${templateId}`) + .set({ + Authorization: `Bearer ${testUtil.jwts.copilot}`, + }) + .expect(403, done); + }); + + it('should return 404 for non-existed template', (done) => { + request(server) + .delete('/v4/productTemplates/1234') + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .expect(404, done); + }); + + it('should return 404 for deleted template', (done) => { + models.ProductTemplate.destroy({ where: { id: templateId } }) + .then(() => { + request(server) + .delete(`/v4/productTemplates/${templateId}`) + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .expect(404, done); + }); + }); + + it('should return 204, for admin, if template was successfully removed', (done) => { + request(server) + .delete(`/v4/productTemplates/${templateId}`) + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .expect(204) + .end(done); + }); + + it('should return 204, for connect admin, if template was successfully removed', (done) => { + request(server) + .delete(`/v4/productTemplates/${templateId}`) + .set({ + Authorization: `Bearer ${testUtil.jwts.connectAdmin}`, + }) + .expect(204) + .end(done); + }); + + it('should return 204, for connect manager, if template was successfully removed', (done) => { + request(server) + .delete(`/v4/productTemplates/${templateId}`) + .set({ + Authorization: `Bearer ${testUtil.jwts.manager}`, + }) + .expect(204) + .end(done); + }); + }); +}); diff --git a/src/routes/productTemplates/get.js b/src/routes/productTemplates/get.js new file mode 100644 index 00000000..e660f979 --- /dev/null +++ b/src/routes/productTemplates/get.js @@ -0,0 +1,41 @@ +/** + * API to get a product template + */ +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: { + templateId: Joi.number().integer().positive().required(), + }, +}; + +module.exports = [ + validate(schema), + permissions('productTemplate.view'), + (req, res, next) => models.ProductTemplate.findOne({ + where: { + deletedAt: { $eq: null }, + id: req.params.templateId, + }, + attributes: { exclude: ['deletedAt', 'deletedBy'] }, + raw: true, + }) + .then((productTemplate) => { + // Not found + if (!productTemplate) { + const apiErr = new Error(`Product template not found for product id ${req.params.templateId}`); + apiErr.status = 404; + return Promise.reject(apiErr); + } + + res.json(util.wrapResponse(req.id, productTemplate)); + return Promise.resolve(); + }) + .catch(next), +]; diff --git a/src/routes/productTemplates/get.spec.js b/src/routes/productTemplates/get.spec.js new file mode 100644 index 00000000..0fbdf37e --- /dev/null +++ b/src/routes/productTemplates/get.spec.js @@ -0,0 +1,153 @@ +/** + * 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 product template', () => { + const template = { + name: 'name 1', + productKey: 'productKey 1', + icon: 'http://example.com/icon1.ico', + brief: 'brief 1', + details: 'details 1', + aliases: { + alias1: { + subAlias1A: 1, + subAlias1B: 2, + }, + alias2: [1, 2, 3], + }, + template: { + template1: { + name: 'template 1', + details: { + anyDetails: 'any details 1', + }, + others: ['others 11', 'others 12'], + }, + template2: { + name: 'template 2', + details: { + anyDetails: 'any details 2', + }, + others: ['others 21', 'others 22'], + }, + }, + createdBy: 1, + updatedBy: 2, + }; + + let templateId; + + beforeEach(() => testUtil.clearDb() + .then(() => models.ProductTemplate.create(template)) + .then((createdTemplate) => { + templateId = createdTemplate.id; + return Promise.resolve(); + }), + ); + after(testUtil.clearDb); + + describe('GET /productTemplates/{templateId}', () => { + it('should return 403 if user is not authenticated', (done) => { + request(server) + .get(`/v4/productTemplates/${templateId}`) + .expect(403, done); + }); + + it('should return 404 for non-existed template', (done) => { + request(server) + .get('/v4/productTemplates/1234') + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .expect(404, done); + }); + + it('should return 404 for deleted template', (done) => { + models.ProductTemplate.destroy({ where: { id: templateId } }) + .then(() => { + request(server) + .get(`/v4/productTemplates/${templateId}`) + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .expect(404, done); + }); + }); + + it('should return 200 for admin', (done) => { + request(server) + .get(`/v4/productTemplates/${templateId}`) + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .expect(200) + .end((err, res) => { + const resJson = res.body.result.content; + resJson.id.should.be.eql(templateId); + resJson.name.should.be.eql(template.name); + resJson.productKey.should.be.eql(template.productKey); + resJson.icon.should.be.eql(template.icon); + resJson.brief.should.be.eql(template.brief); + resJson.details.should.be.eql(template.details); + resJson.aliases.should.be.eql(template.aliases); + resJson.template.should.be.eql(template.template); + + resJson.createdBy.should.be.eql(template.createdBy); + should.exist(resJson.createdAt); + resJson.updatedBy.should.be.eql(template.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/productTemplates/${templateId}`) + .set({ + Authorization: `Bearer ${testUtil.jwts.connectAdmin}`, + }) + .expect(200) + .end(done); + }); + + it('should return 200 for connect manager', (done) => { + request(server) + .get(`/v4/productTemplates/${templateId}`) + .set({ + Authorization: `Bearer ${testUtil.jwts.manager}`, + }) + .expect(200) + .end(done); + }); + + it('should return 200 for member', (done) => { + request(server) + .get(`/v4/productTemplates/${templateId}`) + .set({ + Authorization: `Bearer ${testUtil.jwts.member}`, + }) + .expect(200, done); + }); + + it('should return 200 for copilot', (done) => { + request(server) + .get(`/v4/productTemplates/${templateId}`) + .set({ + Authorization: `Bearer ${testUtil.jwts.copilot}`, + }) + .expect(200, done); + }); + }); +}); diff --git a/src/routes/productTemplates/list.js b/src/routes/productTemplates/list.js new file mode 100644 index 00000000..11a9e276 --- /dev/null +++ b/src/routes/productTemplates/list.js @@ -0,0 +1,23 @@ +/** + * API to list all product templates + */ +import { middleware as tcMiddleware } from 'tc-core-library-js'; +import util from '../../util'; +import models from '../../models'; + +const permissions = tcMiddleware.permissions; + +module.exports = [ + permissions('productTemplate.view'), + (req, res, next) => models.ProductTemplate.findAll({ + where: { + deletedAt: { $eq: null }, + }, + attributes: { exclude: ['deletedAt', 'deletedBy'] }, + raw: true, + }) + .then((productTemplates) => { + res.json(util.wrapResponse(req.id, productTemplates)); + }) + .catch(next), +]; diff --git a/src/routes/productTemplates/list.spec.js b/src/routes/productTemplates/list.spec.js new file mode 100644 index 00000000..e487d777 --- /dev/null +++ b/src/routes/productTemplates/list.spec.js @@ -0,0 +1,148 @@ +/** + * 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 product templates', () => { + const templates = [ + { + name: 'name 1', + productKey: 'productKey 1', + icon: 'http://example.com/icon1.ico', + brief: 'brief 1', + details: 'details 1', + aliases: { + alias1: { + subAlias1A: 1, + subAlias1B: 2, + }, + alias2: [1, 2, 3], + }, + template: { + template1: { + name: 'template 1', + details: { + anyDetails: 'any details 1', + }, + others: ['others 11', 'others 12'], + }, + template2: { + name: 'template 2', + details: { + anyDetails: 'any details 2', + }, + others: ['others 21', 'others 22'], + }, + }, + createdBy: 1, + updatedBy: 2, + }, + { + name: 'template 2', + productKey: 'productKey 2', + icon: 'http://example.com/icon2.ico', + brief: 'brief 2', + details: 'details 2', + aliases: {}, + template: {}, + createdBy: 3, + updatedBy: 4, + }, + ]; + + let templateId; + + beforeEach(() => testUtil.clearDb() + .then(() => models.ProductTemplate.create(templates[0])) + .then((createdTemplate) => { + templateId = createdTemplate.id; + return models.ProductTemplate.create(templates[1]); + }).then(() => Promise.resolve()), + ); + after(testUtil.clearDb); + + describe('GET /productTemplates', () => { + it('should return 403 if user is not authenticated', (done) => { + request(server) + .get('/v4/productTemplates') + .expect(403, done); + }); + + it('should return 200 for admin', (done) => { + request(server) + .get('/v4/productTemplates') + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .expect(200) + .end((err, res) => { + const template = templates[0]; + + const resJson = res.body.result.content; + resJson.should.have.length(2); + resJson[0].id.should.be.eql(templateId); + resJson[0].name.should.be.eql(template.name); + resJson[0].productKey.should.be.eql(template.productKey); + resJson[0].icon.should.be.eql(template.icon); + resJson[0].brief.should.be.eql(template.brief); + resJson[0].details.should.be.eql(template.details); + resJson[0].aliases.should.be.eql(template.aliases); + resJson[0].template.should.be.eql(template.template); + + resJson[0].createdBy.should.be.eql(template.createdBy); + should.exist(resJson[0].createdAt); + resJson[0].updatedBy.should.be.eql(template.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/productTemplates') + .set({ + Authorization: `Bearer ${testUtil.jwts.connectAdmin}`, + }) + .expect(200) + .end(done); + }); + + it('should return 200 for connect manager', (done) => { + request(server) + .get('/v4/productTemplates') + .set({ + Authorization: `Bearer ${testUtil.jwts.manager}`, + }) + .expect(200) + .end(done); + }); + + it('should return 200 for member', (done) => { + request(server) + .get('/v4/productTemplates') + .set({ + Authorization: `Bearer ${testUtil.jwts.member}`, + }) + .expect(200, done); + }); + + it('should return 200 for copilot', (done) => { + request(server) + .get('/v4/productTemplates') + .set({ + Authorization: `Bearer ${testUtil.jwts.copilot}`, + }) + .expect(200, done); + }); + }); +}); diff --git a/src/routes/productTemplates/update.js b/src/routes/productTemplates/update.js new file mode 100644 index 00000000..c5ebf633 --- /dev/null +++ b/src/routes/productTemplates/update.js @@ -0,0 +1,72 @@ +/** + * API to update a product template + */ +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: { + templateId: Joi.number().integer().positive().required(), + }, + body: { + param: Joi.object().keys({ + id: Joi.any().strip(), + name: Joi.string().max(255).required(), + productKey: Joi.string().max(45).required(), + icon: Joi.string().max(255).required(), + brief: Joi.string().max(45).required(), + details: Joi.string().max(255).required(), + aliases: Joi.object().required(), + template: Joi.object().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('productTemplate.edit'), + (req, res, next) => { + const entityToUpdate = _.assign(req.body.param, { + updatedBy: req.authUser.userId, + }); + + return models.ProductTemplate.findOne({ + where: { + deletedAt: { $eq: null }, + id: req.params.templateId, + }, + attributes: { exclude: ['deletedAt', 'deletedBy'] }, + }) + .then((productTemplate) => { + // Not found + if (!productTemplate) { + const apiErr = new Error(`Product template not found for template id ${req.params.templateId}`); + apiErr.status = 404; + return Promise.reject(apiErr); + } + + // Merge JSON fields + entityToUpdate.aliases = util.mergeJsonObjects(productTemplate.aliases, entityToUpdate.aliases); + entityToUpdate.template = util.mergeJsonObjects(productTemplate.template, entityToUpdate.template); + + return productTemplate.update(entityToUpdate); + }) + .then((productTemplate) => { + res.json(util.wrapResponse(req.id, productTemplate)); + return Promise.resolve(); + }) + .catch(next); + }, +]; diff --git a/src/routes/productTemplates/update.spec.js b/src/routes/productTemplates/update.spec.js new file mode 100644 index 00000000..0d5c5e0e --- /dev/null +++ b/src/routes/productTemplates/update.spec.js @@ -0,0 +1,244 @@ +/** + * 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 product template', () => { + const template = { + name: 'name 1', + productKey: 'productKey 1', + icon: 'http://example.com/icon1.ico', + brief: 'brief 1', + details: 'details 1', + aliases: { + alias1: { + subAlias1A: 1, + subAlias1B: 2, + }, + alias2: [1, 2, 3], + }, + template: { + template1: { + name: 'template 1', + details: { + anyDetails: 'any details 1', + }, + others: ['others 11', 'others 12'], + }, + template2: { + name: 'template 2', + details: { + anyDetails: 'any details 2', + }, + others: ['others 21', 'others 22'], + }, + }, + createdBy: 1, + updatedBy: 2, + }; + + let templateId; + + beforeEach(() => testUtil.clearDb() + .then(() => models.ProductTemplate.create(template)) + .then((createdTemplate) => { + templateId = createdTemplate.id; + return Promise.resolve(); + }), + ); + after(testUtil.clearDb); + + describe('PATCH /productTemplates/{templateId}', () => { + const body = { + param: { + name: 'template 1 - update', + productKey: 'productKey 1 - update', + icon: 'http://example.com/icon1-update.ico', + brief: 'brief 1 - update', + details: 'details 1 - update', + aliases: { + alias1: { + subAlias1A: 11, + subAlias1C: 'new', + }, + alias2: [4], + alias3: 'new', + }, + template: { + template1: { + name: 'template 1 - update', + details: { + anyDetails: 'any details 1 - update', + newDetails: 'new', + }, + others: ['others new'], + }, + template3: { + name: 'template 3', + details: { + anyDetails: 'any details 3', + }, + others: ['others 31', 'others 32'], + }, + }, + }, + }; + + it('should return 403 if user is not authenticated', (done) => { + request(server) + .patch(`/v4/productTemplates/${templateId}`) + .send(body) + .expect(403, done); + }); + + it('should return 403 for member', (done) => { + request(server) + .patch(`/v4/productTemplates/${templateId}`) + .set({ + Authorization: `Bearer ${testUtil.jwts.member}`, + }) + .send(body) + .expect(403, done); + }); + + it('should return 403 for copilot', (done) => { + request(server) + .patch(`/v4/productTemplates/${templateId}`) + .send(body) + .set({ + Authorization: `Bearer ${testUtil.jwts.copilot}`, + }) + .expect(403, done); + }); + + it('should return 422 for invalid request', (done) => { + const invalidBody = { + param: { + aliases: 'a', + template: 1, + }, + }; + + request(server) + .patch(`/v4/productTemplates/${templateId}`) + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .send(invalidBody) + .expect(422, done); + }); + + it('should return 404 for non-existed template', (done) => { + request(server) + .patch('/v4/productTemplates/1234') + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .send(body) + .expect(404, done); + }); + + it('should return 404 for deleted template', (done) => { + models.ProductTemplate.destroy({ where: { id: templateId } }) + .then(() => { + request(server) + .patch(`/v4/productTemplates/${templateId}`) + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .send(body) + .expect(404, done); + }); + }); + + it('should return 200 for admin', (done) => { + request(server) + .patch(`/v4/productTemplates/${templateId}`) + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .send(body) + .expect(200) + .end((err, res) => { + const resJson = res.body.result.content; + resJson.id.should.be.eql(templateId); + resJson.name.should.be.eql(body.param.name); + resJson.productKey.should.be.eql(body.param.productKey); + resJson.icon.should.be.eql(body.param.icon); + resJson.brief.should.be.eql(body.param.brief); + resJson.details.should.be.eql(body.param.details); + + resJson.aliases.should.be.eql({ + alias1: { + subAlias1A: 11, + subAlias1B: 2, + subAlias1C: 'new', + }, + alias2: [4], + alias3: 'new', + }); + resJson.template.should.be.eql({ + template1: { + name: 'template 1 - update', + details: { + anyDetails: 'any details 1 - update', + newDetails: 'new', + }, + others: ['others new'], + }, + template2: { + name: 'template 2', + details: { + anyDetails: 'any details 2', + }, + others: ['others 21', 'others 22'], + }, + template3: { + name: 'template 3', + details: { + anyDetails: 'any details 3', + }, + others: ['others 31', 'others 32'], + }, + }); + resJson.createdBy.should.be.eql(template.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/productTemplates/${templateId}`) + .set({ + Authorization: `Bearer ${testUtil.jwts.connectAdmin}`, + }) + .send(body) + .expect(200) + .end(done); + }); + + it('should return 200 for connect manager', (done) => { + request(server) + .patch(`/v4/productTemplates/${templateId}`) + .set({ + Authorization: `Bearer ${testUtil.jwts.manager}`, + }) + .send(body) + .expect(200) + .end(done); + }); + }); +}); diff --git a/src/routes/projectTemplates/create.js b/src/routes/projectTemplates/create.js new file mode 100644 index 00000000..4c19fc0f --- /dev/null +++ b/src/routes/projectTemplates/create.js @@ -0,0 +1,49 @@ +/** + * API to add a project template + */ +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({ + id: Joi.any().strip(), + name: Joi.string().max(255).required(), + key: Joi.string().max(45).required(), + category: Joi.string().max(45).required(), + scope: Joi.object().required(), + phases: Joi.object().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('projectTemplate.create'), + (req, res, next) => { + const entity = _.assign(req.body.param, { + createdBy: req.authUser.userId, + updatedBy: req.authUser.userId, + }); + + return models.ProjectTemplate.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/projectTemplates/create.spec.js b/src/routes/projectTemplates/create.spec.js new file mode 100644 index 00000000..afb46113 --- /dev/null +++ b/src/routes/projectTemplates/create.spec.js @@ -0,0 +1,153 @@ +/** + * Tests for create.js + */ +import chai from 'chai'; +import request from 'supertest'; + +import server from '../../app'; +import testUtil from '../../tests/util'; + +const should = chai.should(); + +describe('CREATE project template', () => { + describe('POST /projectTemplates', () => { + const body = { + param: { + name: 'template 1', + key: 'key 1', + category: 'category 1', + scope: { + scope1: { + subScope1A: 1, + subScope1B: 2, + }, + scope2: [1, 2, 3], + }, + phases: { + phase1: { + name: 'phase 1', + details: { + anyDetails: 'any details 1', + }, + others: ['others 11', 'others 12'], + }, + phase2: { + name: 'phase 2', + details: { + anyDetails: 'any details 2', + }, + others: ['others 21', 'others 22'], + }, + }, + }, + }; + + it('should return 403 if user is not authenticated', (done) => { + request(server) + .post('/v4/projectTemplates') + .send(body) + .expect(403, done); + }); + + it('should return 403 for member', (done) => { + request(server) + .post('/v4/projectTemplates') + .set({ + Authorization: `Bearer ${testUtil.jwts.member}`, + }) + .send(body) + .expect(403, done); + }); + + it('should return 403 for copilot', (done) => { + request(server) + .post('/v4/projectTemplates') + .set({ + Authorization: `Bearer ${testUtil.jwts.copilot}`, + }) + .send(body) + .expect(403, done); + }); + + it('should return 422 if validations dont pass', (done) => { + const invalidBody = { + param: { + scope: 'a', + phases: 1, + }, + }; + + request(server) + .post('/v4/projectTemplates') + .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/projectTemplates') + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .send(body) + .expect('Content-Type', /json/) + .expect(201) + .end((err, res) => { + const resJson = res.body.result.content; + should.exist(resJson.id); + resJson.name.should.be.eql(body.param.name); + resJson.key.should.be.eql(body.param.key); + resJson.category.should.be.eql(body.param.category); + resJson.scope.should.be.eql(body.param.scope); + resJson.phases.should.be.eql(body.param.phases); + + 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 manager', (done) => { + request(server) + .post('/v4/projectTemplates') + .set({ + Authorization: `Bearer ${testUtil.jwts.manager}`, + }) + .send(body) + .expect('Content-Type', /json/) + .expect(201) + .end((err, res) => { + const resJson = res.body.result.content; + resJson.createdBy.should.be.eql(40051334); // manager + resJson.updatedBy.should.be.eql(40051334); // manager + done(); + }); + }); + + it('should return 201 for connect admin', (done) => { + request(server) + .post('/v4/projectTemplates') + .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/projectTemplates/delete.js b/src/routes/projectTemplates/delete.js new file mode 100644 index 00000000..4db9a855 --- /dev/null +++ b/src/routes/projectTemplates/delete.js @@ -0,0 +1,55 @@ +/** + * API to delete a project template + */ +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: { + templateId: Joi.number().integer().positive().required(), + }, +}; + +module.exports = [ + validate(schema), + permissions('projectTemplate.delete'), + (req, res, next) => { + const where = { + deletedAt: { $eq: null }, + id: req.params.templateId, + }; + + return models.sequelize.transaction(tx => + // Update the deletedBy + models.ProjectTemplate.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 template not found for template id ${req.params.templateId}`); + apiErr.status = 404; + return Promise.reject(apiErr); + } + + // Soft delete + return models.ProjectTemplate.destroy({ + where, + transaction: tx, + raw: true, + }); + }) + .then(() => { + res.status(204).end(); + }) + .catch(next), + ); + }, +]; diff --git a/src/routes/projectTemplates/delete.spec.js b/src/routes/projectTemplates/delete.spec.js new file mode 100644 index 00000000..27973d7e --- /dev/null +++ b/src/routes/projectTemplates/delete.spec.js @@ -0,0 +1,127 @@ +/** + * Tests for delete.js + */ +import request from 'supertest'; + +import models from '../../models'; +import server from '../../app'; +import testUtil from '../../tests/util'; + + +describe('DELETE project template', () => { + let templateId; + + beforeEach(() => testUtil.clearDb() + .then(() => models.ProjectTemplate.create({ + name: 'template 1', + key: 'key 1', + category: 'category 1', + scope: { + scope1: { + subScope1A: 1, + subScope1B: 2, + }, + scope2: [1, 2, 3], + }, + phases: { + phase1: { + name: 'phase 1', + details: { + anyDetails: 'any details 1', + }, + others: ['others 11', 'others 12'], + }, + phase2: { + name: 'phase 2', + details: { + anyDetails: 'any details 2', + }, + others: ['others 21', 'others 22'], + }, + }, + createdBy: 1, + updatedBy: 1, + })).then((template) => { + templateId = template.id; + return Promise.resolve(); + }), + ); + after(testUtil.clearDb); + + describe('DELETE /projectTemplates/{templateId}', () => { + it('should return 403 if user is not authenticated', (done) => { + request(server) + .delete(`/v4/projectTemplates/${templateId}`) + .expect(403, done); + }); + + it('should return 403 for member', (done) => { + request(server) + .delete(`/v4/projectTemplates/${templateId}`) + .set({ + Authorization: `Bearer ${testUtil.jwts.member}`, + }) + .expect(403, done); + }); + + it('should return 403 for copilot', (done) => { + request(server) + .delete(`/v4/projectTemplates/${templateId}`) + .set({ + Authorization: `Bearer ${testUtil.jwts.copilot}`, + }) + .expect(403, done); + }); + + it('should return 404 for non-existed template', (done) => { + request(server) + .delete('/v4/projectTemplates/1234') + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .expect(404, done); + }); + + it('should return 404 for deleted template', (done) => { + models.ProjectTemplate.destroy({ where: { id: templateId } }) + .then(() => { + request(server) + .delete(`/v4/projectTemplates/${templateId}`) + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .expect(404, done); + }); + }); + + it('should return 204, for admin, if template was successfully removed', (done) => { + request(server) + .delete(`/v4/projectTemplates/${templateId}`) + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .expect(204) + .end(done); + }); + + it('should return 204, for connect admin, if template was successfully removed', (done) => { + request(server) + .delete(`/v4/projectTemplates/${templateId}`) + .set({ + Authorization: `Bearer ${testUtil.jwts.connectAdmin}`, + }) + .expect(204) + .end(done); + }); + + it('should return 204, for connect manager, if template was successfully removed', (done) => { + request(server) + .delete(`/v4/projectTemplates/${templateId}`) + .set({ + Authorization: `Bearer ${testUtil.jwts.manager}`, + }) + .expect(204) + .end(done); + }); + }); +}); diff --git a/src/routes/projectTemplates/get.js b/src/routes/projectTemplates/get.js new file mode 100644 index 00000000..e81c0939 --- /dev/null +++ b/src/routes/projectTemplates/get.js @@ -0,0 +1,41 @@ +/** + * API to get a project template + */ +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: { + templateId: Joi.number().integer().positive().required(), + }, +}; + +module.exports = [ + validate(schema), + permissions('projectTemplate.view'), + (req, res, next) => models.ProjectTemplate.findOne({ + where: { + deletedAt: { $eq: null }, + id: req.params.templateId, + }, + attributes: { exclude: ['deletedAt', 'deletedBy'] }, + raw: true, + }) + .then((projectTemplate) => { + // Not found + if (!projectTemplate) { + const apiErr = new Error(`Project template not found for project id ${req.params.templateId}`); + apiErr.status = 404; + return Promise.reject(apiErr); + } + + res.json(util.wrapResponse(req.id, projectTemplate)); + return Promise.resolve(); + }) + .catch(next), +]; diff --git a/src/routes/projectTemplates/get.spec.js b/src/routes/projectTemplates/get.spec.js new file mode 100644 index 00000000..4c9b2ccf --- /dev/null +++ b/src/routes/projectTemplates/get.spec.js @@ -0,0 +1,148 @@ +/** + * 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 template', () => { + const template = { + name: 'template 1', + key: 'key 1', + category: 'category 1', + scope: { + scope1: { + subScope1A: 1, + subScope1B: 2, + }, + scope2: [1, 2, 3], + }, + phases: { + phase1: { + name: 'phase 1', + details: { + anyDetails: 'any details 1', + }, + others: ['others 11', 'others 12'], + }, + phase2: { + name: 'phase 2', + details: { + anyDetails: 'any details 2', + }, + others: ['others 21', 'others 22'], + }, + }, + createdBy: 1, + updatedBy: 1, + }; + + let templateId; + + beforeEach(() => testUtil.clearDb() + .then(() => models.ProjectTemplate.create(template)) + .then((createdTemplate) => { + templateId = createdTemplate.id; + return Promise.resolve(); + }), + ); + after(testUtil.clearDb); + + describe('GET /projectTemplates/{templateId}', () => { + it('should return 403 if user is not authenticated', (done) => { + request(server) + .get(`/v4/projectTemplates/${templateId}`) + .expect(403, done); + }); + + it('should return 404 for non-existed template', (done) => { + request(server) + .get('/v4/projectTemplates/1234') + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .expect(404, done); + }); + + it('should return 404 for deleted template', (done) => { + models.ProjectTemplate.destroy({ where: { id: templateId } }) + .then(() => { + request(server) + .get(`/v4/projectTemplates/${templateId}`) + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .expect(404, done); + }); + }); + + it('should return 200 for admin', (done) => { + request(server) + .get(`/v4/projectTemplates/${templateId}`) + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .expect(200) + .end((err, res) => { + const resJson = res.body.result.content; + resJson.id.should.be.eql(templateId); + resJson.name.should.be.eql(template.name); + resJson.key.should.be.eql(template.key); + resJson.category.should.be.eql(template.category); + resJson.scope.should.be.eql(template.scope); + resJson.phases.should.be.eql(template.phases); + resJson.createdBy.should.be.eql(template.createdBy); + should.exist(resJson.createdAt); + resJson.updatedBy.should.be.eql(template.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/projectTemplates/${templateId}`) + .set({ + Authorization: `Bearer ${testUtil.jwts.connectAdmin}`, + }) + .expect(200) + .end(done); + }); + + it('should return 200 for connect manager', (done) => { + request(server) + .get(`/v4/projectTemplates/${templateId}`) + .set({ + Authorization: `Bearer ${testUtil.jwts.manager}`, + }) + .expect(200) + .end(done); + }); + + it('should return 200 for member', (done) => { + request(server) + .get(`/v4/projectTemplates/${templateId}`) + .set({ + Authorization: `Bearer ${testUtil.jwts.member}`, + }) + .expect(200, done); + }); + + it('should return 200 for copilot', (done) => { + request(server) + .get(`/v4/projectTemplates/${templateId}`) + .set({ + Authorization: `Bearer ${testUtil.jwts.copilot}`, + }) + .expect(200, done); + }); + }); +}); diff --git a/src/routes/projectTemplates/list.js b/src/routes/projectTemplates/list.js new file mode 100644 index 00000000..3e83f2e4 --- /dev/null +++ b/src/routes/projectTemplates/list.js @@ -0,0 +1,23 @@ +/** + * API to list all project templates + */ +import { middleware as tcMiddleware } from 'tc-core-library-js'; +import util from '../../util'; +import models from '../../models'; + +const permissions = tcMiddleware.permissions; + +module.exports = [ + permissions('projectTemplate.view'), + (req, res, next) => models.ProjectTemplate.findAll({ + where: { + deletedAt: { $eq: null }, + }, + attributes: { exclude: ['deletedAt', 'deletedBy'] }, + raw: true, + }) + .then((projectTemplates) => { + res.json(util.wrapResponse(req.id, projectTemplates)); + }) + .catch(next), +]; diff --git a/src/routes/projectTemplates/list.spec.js b/src/routes/projectTemplates/list.spec.js new file mode 100644 index 00000000..b68fc28d --- /dev/null +++ b/src/routes/projectTemplates/list.spec.js @@ -0,0 +1,141 @@ +/** + * 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 templates', () => { + const templates = [ + { + name: 'template 1', + key: 'key 1', + category: 'category 1', + scope: { + scope1: { + subScope1A: 1, + subScope1B: 2, + }, + scope2: [1, 2, 3], + }, + phases: { + phase1: { + name: 'phase 1', + details: { + anyDetails: 'any details 1', + }, + others: ['others 11', 'others 12'], + }, + phase2: { + name: 'phase 2', + details: { + anyDetails: 'any details 2', + }, + others: ['others 21', 'others 22'], + }, + }, + createdBy: 1, + updatedBy: 1, + }, + { + name: 'template 2', + key: 'key 2', + category: 'category 2', + scope: {}, + phases: {}, + createdBy: 1, + updatedBy: 2, + }, + ]; + + let templateId; + + beforeEach(() => testUtil.clearDb() + .then(() => models.ProjectTemplate.create(templates[0])) + .then((createdTemplate) => { + templateId = createdTemplate.id; + return models.ProjectTemplate.create(templates[1]); + }).then(() => Promise.resolve()), + ); + after(testUtil.clearDb); + + describe('GET /projectTemplates', () => { + it('should return 403 if user is not authenticated', (done) => { + request(server) + .get('/v4/projectTemplates') + .expect(403, done); + }); + + it('should return 200 for admin', (done) => { + request(server) + .get('/v4/projectTemplates') + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .expect(200) + .end((err, res) => { + const template = templates[0]; + + const resJson = res.body.result.content; + resJson.should.have.length(2); + resJson[0].id.should.be.eql(templateId); + resJson[0].name.should.be.eql(template.name); + resJson[0].key.should.be.eql(template.key); + resJson[0].category.should.be.eql(template.category); + resJson[0].scope.should.be.eql(template.scope); + resJson[0].phases.should.be.eql(template.phases); + resJson[0].createdBy.should.be.eql(template.createdBy); + should.exist(resJson[0].createdAt); + resJson[0].updatedBy.should.be.eql(template.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/projectTemplates') + .set({ + Authorization: `Bearer ${testUtil.jwts.connectAdmin}`, + }) + .expect(200) + .end(done); + }); + + it('should return 200 for connect manager', (done) => { + request(server) + .get('/v4/projectTemplates') + .set({ + Authorization: `Bearer ${testUtil.jwts.manager}`, + }) + .expect(200) + .end(done); + }); + + it('should return 200 for member', (done) => { + request(server) + .get('/v4/projectTemplates') + .set({ + Authorization: `Bearer ${testUtil.jwts.member}`, + }) + .expect(200, done); + }); + + it('should return 200 for copilot', (done) => { + request(server) + .get('/v4/projectTemplates') + .set({ + Authorization: `Bearer ${testUtil.jwts.copilot}`, + }) + .expect(200, done); + }); + }); +}); diff --git a/src/routes/projectTemplates/update.js b/src/routes/projectTemplates/update.js new file mode 100644 index 00000000..8b88c84a --- /dev/null +++ b/src/routes/projectTemplates/update.js @@ -0,0 +1,70 @@ +/** + * API to update a project template + */ +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: { + templateId: Joi.number().integer().positive().required(), + }, + body: { + param: Joi.object().keys({ + id: Joi.any().strip(), + name: Joi.string().max(255).required(), + key: Joi.string().max(45).required(), + category: Joi.string().max(45).required(), + scope: Joi.object().required(), + phases: Joi.object().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('projectTemplate.edit'), + (req, res, next) => { + const entityToUpdate = _.assign(req.body.param, { + updatedBy: req.authUser.userId, + }); + + return models.ProjectTemplate.findOne({ + where: { + deletedAt: { $eq: null }, + id: req.params.templateId, + }, + attributes: { exclude: ['deletedAt', 'deletedBy'] }, + }) + .then((projectTemplate) => { + // Not found + if (!projectTemplate) { + const apiErr = new Error(`Project template not found for template id ${req.params.templateId}`); + apiErr.status = 404; + return Promise.reject(apiErr); + } + + // Merge JSON fields + entityToUpdate.scope = util.mergeJsonObjects(projectTemplate.scope, entityToUpdate.scope); + entityToUpdate.phases = util.mergeJsonObjects(projectTemplate.phases, entityToUpdate.phases); + + return projectTemplate.update(entityToUpdate); + }) + .then((projectTemplate) => { + res.json(util.wrapResponse(req.id, projectTemplate)); + return Promise.resolve(); + }) + .catch(next); + }, +]; diff --git a/src/routes/projectTemplates/update.spec.js b/src/routes/projectTemplates/update.spec.js new file mode 100644 index 00000000..dd286a77 --- /dev/null +++ b/src/routes/projectTemplates/update.spec.js @@ -0,0 +1,237 @@ +/** + * 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 template', () => { + const template = { + name: 'template 1', + key: 'key 1', + category: 'category 1', + scope: { + scope1: { + subScope1A: 1, + subScope1B: 2, + }, + scope2: [1, 2, 3], + }, + phases: { + phase1: { + name: 'phase 1', + details: { + anyDetails: 'any details 1', + }, + others: ['others 11', 'others 12'], + }, + phase2: { + name: 'phase 2', + details: { + anyDetails: 'any details 2', + }, + others: ['others 21', 'others 22'], + }, + }, + createdBy: 1, + updatedBy: 1, + }; + + let templateId; + + beforeEach(() => testUtil.clearDb() + .then(() => models.ProjectTemplate.create(template)) + .then((createdTemplate) => { + templateId = createdTemplate.id; + return Promise.resolve(); + }), + ); + after(testUtil.clearDb); + + describe('PATCH /projectTemplates/{templateId}', () => { + const body = { + param: { + name: 'template 1 - update', + key: 'key 1 - update', + category: 'category 1 - update', + scope: { + scope1: { + subScope1A: 11, + subScope1C: 'new', + }, + scope2: [4], + scope3: 'new', + }, + phases: { + phase1: { + name: 'phase 1 - update', + details: { + anyDetails: 'any details 1 - update', + newDetails: 'new', + }, + others: ['others new'], + }, + phase3: { + name: 'phase 3', + details: { + anyDetails: 'any details 3', + }, + others: ['others 31', 'others 32'], + }, + }, + }, + }; + + it('should return 403 if user is not authenticated', (done) => { + request(server) + .patch(`/v4/projectTemplates/${templateId}`) + .send(body) + .expect(403, done); + }); + + it('should return 403 for member', (done) => { + request(server) + .patch(`/v4/projectTemplates/${templateId}`) + .set({ + Authorization: `Bearer ${testUtil.jwts.member}`, + }) + .send(body) + .expect(403, done); + }); + + it('should return 403 for copilot', (done) => { + request(server) + .patch(`/v4/projectTemplates/${templateId}`) + .send(body) + .set({ + Authorization: `Bearer ${testUtil.jwts.copilot}`, + }) + .expect(403, done); + }); + + it('should return 422 for invalid request', (done) => { + const invalidBody = { + param: { + scope: 'a', + phases: 1, + }, + }; + + request(server) + .patch(`/v4/projectTemplates/${templateId}`) + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .send(invalidBody) + .expect(422, done); + }); + + it('should return 404 for non-existed template', (done) => { + request(server) + .patch('/v4/projectTemplates/1234') + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .send(body) + .expect(404, done); + }); + + it('should return 404 for deleted template', (done) => { + models.ProjectTemplate.destroy({ where: { id: templateId } }) + .then(() => { + request(server) + .patch(`/v4/projectTemplates/${templateId}`) + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .send(body) + .expect(404, done); + }); + }); + + it('should return 200 for admin', (done) => { + request(server) + .patch(`/v4/projectTemplates/${templateId}`) + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .send(body) + .expect(200) + .end((err, res) => { + const resJson = res.body.result.content; + resJson.id.should.be.eql(templateId); + resJson.name.should.be.eql(body.param.name); + resJson.key.should.be.eql(body.param.key); + resJson.category.should.be.eql(body.param.category); + resJson.scope.should.be.eql({ + scope1: { + subScope1A: 11, + subScope1B: 2, + subScope1C: 'new', + }, + scope2: [4], + scope3: 'new', + }); + resJson.phases.should.be.eql({ + phase1: { + name: 'phase 1 - update', + details: { + anyDetails: 'any details 1 - update', + newDetails: 'new', + }, + others: ['others new'], + }, + phase2: { + name: 'phase 2', + details: { + anyDetails: 'any details 2', + }, + others: ['others 21', 'others 22'], + }, + phase3: { + name: 'phase 3', + details: { + anyDetails: 'any details 3', + }, + others: ['others 31', 'others 32'], + }, + }); + resJson.createdBy.should.be.eql(template.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/projectTemplates/${templateId}`) + .set({ + Authorization: `Bearer ${testUtil.jwts.connectAdmin}`, + }) + .send(body) + .expect(200) + .end(done); + }); + + it('should return 200 for connect manager', (done) => { + request(server) + .patch(`/v4/projectTemplates/${templateId}`) + .set({ + Authorization: `Bearer ${testUtil.jwts.manager}`, + }) + .send(body) + .expect(200) + .end(done); + }); + }); +}); diff --git a/src/tests/seed.js b/src/tests/seed.js index a1f53c84..bbd0aa08 100644 --- a/src/tests/seed.js +++ b/src/tests/seed.js @@ -1,108 +1,194 @@ import models from '../models'; models.sequelize.sync({ force: true }) - .then(() => - models.Project.bulkCreate([{ - type: 'generic', - directProjectId: 9999999, - billingAccountId: 1, - name: 'test1', - description: 'test project1', - status: 'active', - details: {}, - createdBy: 1, - updatedBy: 1, - }, { - type: 'visual_design', - directProjectId: 1, - billingAccountId: 2, - name: 'test2', - description: 'test project2', - status: 'draft', - details: {}, - createdBy: 1, - updatedBy: 1, - }, { - type: 'visual_design', - billingAccountId: 3, - name: 'test2', - description: 'completed project without copilot', - status: 'completed', - details: {}, - createdBy: 1, - updatedBy: 1, - }, { - type: 'generic', - billingAccountId: 4, - name: 'test2', - description: 'draft project without copilot', - status: 'draft', - details: {}, - createdBy: 1, - updatedBy: 1, - }, { - type: 'generic', - billingAccountId: 5, - name: 'test2', - description: 'active project without copilot', - status: 'active', - details: {}, - createdBy: 1, - updatedBy: 1, - }])) - .then(() => models.Project.findAll()) - .then((projects) => { - const project1 = projects[0]; - const project2 = projects[1]; - const operations = []; - operations.push(models.ProjectMember.bulkCreate([{ - userId: 40051331, - projectId: project1.id, - role: 'customer', - isPrimary: false, - createdBy: 1, - updatedBy: 1, - }, { - userId: 40051332, - projectId: project1.id, - role: 'copilot', - isPrimary: false, - createdBy: 1, - updatedBy: 1, - }, { - userId: 40051333, - projectId: project1.id, - role: 'manager', - isPrimary: true, - createdBy: 1, - updatedBy: 1, - }, { - userId: 40051332, - projectId: project2.id, - role: 'copilot', - isPrimary: false, - createdBy: 1, - updatedBy: 1, - }, { - userId: 40051331, - projectId: projects[2].id, - role: 'customer', - isPrimary: false, - createdBy: 1, - updatedBy: 1, - }])); - operations.push(models.ProjectAttachment.create({ - title: 'Spec', - projectId: project1.id, - description: 'specification', - filePath: 'projects/1/spec.pdf', - contentType: 'application/pdf', - createdBy: 1, - updatedBy: 1, - })); - return Promise.all(operations); - }) - .then(() => { - process.exit(0); - }) - .catch(() => process.exit(1)); + .then(() => + models.Project.bulkCreate([{ + type: 'generic', + directProjectId: 9999999, + billingAccountId: 1, + name: 'test1', + description: 'test project1', + status: 'active', + details: {}, + createdBy: 1, + updatedBy: 1, + }, { + type: 'visual_design', + directProjectId: 1, + billingAccountId: 2, + name: 'test2', + description: 'test project2', + status: 'draft', + details: {}, + createdBy: 1, + updatedBy: 1, + }, { + type: 'visual_design', + billingAccountId: 3, + name: 'test2', + description: 'completed project without copilot', + status: 'completed', + details: {}, + createdBy: 1, + updatedBy: 1, + }, { + type: 'generic', + billingAccountId: 4, + name: 'test2', + description: 'draft project without copilot', + status: 'draft', + details: {}, + createdBy: 1, + updatedBy: 1, + }, { + type: 'generic', + billingAccountId: 5, + name: 'test2', + description: 'active project without copilot', + status: 'active', + details: {}, + createdBy: 1, + updatedBy: 1, + }])) + .then(() => models.Project.findAll()) + .then((projects) => { + const project1 = projects[0]; + const project2 = projects[1]; + const operations = []; + operations.push(models.ProjectMember.bulkCreate([{ + userId: 40051331, + projectId: project1.id, + role: 'customer', + isPrimary: false, + createdBy: 1, + updatedBy: 1, + }, { + userId: 40051332, + projectId: project1.id, + role: 'copilot', + isPrimary: false, + createdBy: 1, + updatedBy: 1, + }, { + userId: 40051333, + projectId: project1.id, + role: 'manager', + isPrimary: true, + createdBy: 1, + updatedBy: 1, + }, { + userId: 40051332, + projectId: project2.id, + role: 'copilot', + isPrimary: false, + createdBy: 1, + updatedBy: 1, + }, { + userId: 40051331, + projectId: projects[2].id, + role: 'customer', + isPrimary: false, + createdBy: 1, + updatedBy: 1, + }])); + operations.push(models.ProjectAttachment.create({ + title: 'Spec', + projectId: project1.id, + description: 'specification', + filePath: 'projects/1/spec.pdf', + contentType: 'application/pdf', + createdBy: 1, + updatedBy: 1, + })); + return Promise.all(operations); + }) + .then(() => models.ProjectTemplate.bulkCreate([ + { + name: 'template 1', + key: 'key 1', + category: 'category 1', + scope: { + scope1: { + subScope1A: 1, + subScope1B: 2, + }, + scope2: [1, 2, 3], + }, + phases: { + phase1: { + name: 'phase 1', + details: { + anyDetails: 'any details 1', + }, + others: ['others 11', 'others 12'], + }, + phase2: { + name: 'phase 2', + details: { + anyDetails: 'any details 2', + }, + others: ['others 21', 'others 22'], + }, + }, + createdBy: 1, + updatedBy: 1, + }, + { + name: 'template 2', + key: 'key 2', + category: 'category 2', + scope: {}, + phases: {}, + createdBy: 1, + updatedBy: 2, + }, + ])) + .then(() => models.ProductTemplate.bulkCreate([ + { + name: 'name 1', + productKey: 'productKey 1', + icon: 'http://example.com/icon1.ico', + brief: 'brief 1', + details: 'details 1', + aliases: { + alias1: { + subAlias1A: 1, + subAlias1B: 2, + }, + alias2: [1, 2, 3], + }, + template: { + template1: { + name: 'template 1', + details: { + anyDetails: 'any details 1', + }, + others: ['others 11', 'others 12'], + }, + template2: { + name: 'template 2', + details: { + anyDetails: 'any details 2', + }, + others: ['others 21', 'others 22'], + }, + }, + createdBy: 1, + updatedBy: 2, + }, + { + name: 'template 2', + productKey: 'productKey 2', + icon: 'http://example.com/icon2.ico', + brief: 'brief 2', + details: 'details 2', + aliases: {}, + template: {}, + createdBy: 3, + updatedBy: 4, + }, + ])) + .then(() => { + process.exit(0); + }) + .catch(() => process.exit(1)); diff --git a/src/util.js b/src/util.js index 86386add..efbcf3e1 100644 --- a/src/util.js +++ b/src/util.js @@ -209,30 +209,30 @@ _.assignIn(util, { getProjectAttachments: (req, projectId) => { let attachments = []; return models.ProjectAttachment.getActiveProjectAttachments(projectId) - .then((_attachments) => { - // if attachments were requested - if (attachments) { - attachments = _attachments; - } else { - return attachments; - } - // TODO consider using redis to cache attachments urls - const promises = []; - _.each(attachments, (a) => { - promises.push(util.getFileDownloadUrl(req, a.filePath)); - }); - return Promise.all(promises); - }) - .then((result) => { - // result is an array of 'tuples' => [[path, url], [path,url]] - // convert it to a map for easy lookup - const urls = _.fromPairs(result); - _.each(attachments, (at) => { - const a = at; - a.downloadUrl = urls[a.filePath]; - }); + .then((_attachments) => { + // if attachments were requested + if (attachments) { + attachments = _attachments; + } else { return attachments; + } + // TODO consider using redis to cache attachments urls + const promises = []; + _.each(attachments, (a) => { + promises.push(util.getFileDownloadUrl(req, a.filePath)); + }); + return Promise.all(promises); + }) + .then((result) => { + // result is an array of 'tuples' => [[path, url], [path,url]] + // convert it to a map for easy lookup + const urls = _.fromPairs(result); + _.each(attachments, (at) => { + const a = at; + a.downloadUrl = urls[a.filePath]; }); + return attachments; + }); }, getSystemUserToken: (logger, id = 'system') => { @@ -245,19 +245,19 @@ _.assignIn(util, { timeout: 4000, headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, }, - ) + ) .then(res => res.data.result.content.token); }, - /** - * Fetches the topcoder user details using the given JWT token. - * - * @param {Number} userId id of the user to be fetched - * @param {String} jwtToken JWT token of the admin user or JWT token of the user to be fecthed - * @param {Object} logger logger to be used for logging purposes - * - * @return {Promise} promise which resolves to the user's information - */ + /** + * Fetches the topcoder user details using the given JWT token. + * + * @param {Number} userId id of the user to be fetched + * @param {String} jwtToken JWT token of the admin user or JWT token of the user to be fecthed + * @param {Object} logger logger to be used for logging purposes + * + * @return {Promise} promise which resolves to the user's information + */ getTopcoderUser: (userId, jwtToken, logger) => { const httpClient = util.getHttpClient({ id: `userService_${userId}`, log: logger }); httpClient.defaults.timeout = 3000; @@ -266,7 +266,7 @@ _.assignIn(util, { httpClient.defaults.headers.common.Authorization = `Bearer ${jwtToken}`; return httpClient.get(`${config.identityServiceEndpoint}users/${userId}`).then((response) => { if (response.data && response.data.result - && response.data.result.status === 200 && response.data.result.content) { + && response.data.result.status === 200 && response.data.result.content) { return response.data.result.content; } return null; @@ -338,6 +338,20 @@ _.assignIn(util, { return Promise.reject(err); } }), + + /** + * Merge two JSON objects. For array fields, the target will be replaced by source. + * @param {Object} targetObj the target object + * @param {Object} sourceObj the source object + * @returns {Object} the merged object + */ + // eslint-disable-next-line consistent-return + mergeJsonObjects: (targetObj, sourceObj) => _.mergeWith(targetObj, sourceObj, (target, source) => { + // Overwrite the array + if (_.isArray(source)) { + return source; + } + }), }); export default util; diff --git a/swagger.yaml b/swagger.yaml index f195828f..14a15ee1 100755 --- a/swagger.yaml +++ b/swagger.yaml @@ -336,6 +336,256 @@ paths: schema: $ref: "#/definitions/UpdateProjectMemberBodyParam" + /projectTemplates: + get: + tags: + - projectTemplate + operationId: findProjectTemplates + security: + - Bearer: [] + description: Retreive all project templates. 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 templates + schema: + $ref: "#/definitions/ProjectTemplateListResponse" + post: + tags: + - projectTemplate + operationId: addProjectTemplate + security: + - Bearer: [] + description: Create a project template + parameters: + - in: body + name: body + required: true + schema: + $ref: '#/definitions/ProjectTemplateBodyParam' + responses: + '403': + description: No permission or wrong token + schema: + $ref: "#/definitions/ErrorModel" + '201': + description: Returns the newly created project template + schema: + $ref: "#/definitions/ProjectTemplateResponse" + '422': + description: Invalid input + schema: + $ref: "#/definitions/ErrorModel" + + /projectTemplates/{templateId}: + get: + tags: + - projectTemplate + description: Retrieve project template 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 template + schema: + $ref: "#/definitions/ProjectTemplateResponse" + parameters: + - $ref: "#/parameters/templateIdParam" + operationId: getProjectTemplate + + patch: + tags: + - projectTemplate + operationId: updateProjectTemplate + security: + - Bearer: [] + description: Update a project template. Only connect manager, connect admin, and admin can access this endpoint. + For attributes with JSON object type, it would overwrite the existing fields, or add new if the fields don't exist in the JSON object. + 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 template. + schema: + $ref: "#/definitions/ProjectTemplateResponse" + '422': + description: Invalid input + schema: + $ref: "#/definitions/ErrorModel" + default: + description: error payload + schema: + $ref: '#/definitions/ErrorModel' + parameters: + - $ref: "#/parameters/templateIdParam" + - name: body + in: body + required: true + schema: + $ref: "#/definitions/ProjectTemplateBodyParam" + + delete: + tags: + - projectTemplate + description: Remove an existing project template. Only connect manager, connect admin, and admin can access this endpoint. + security: + - Bearer: [] + parameters: + - $ref: "#/parameters/templateIdParam" + 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 template successfully removed + + + /productTemplates: + get: + tags: + - productTemplate + operationId: findProductTemplates + security: + - Bearer: [] + description: Retreive all product templates. All user roles can access this endpoint. + responses: + '403': + description: No permission or wrong token + schema: + $ref: "#/definitions/ErrorModel" + '200': + description: A list of product templates + schema: + $ref: "#/definitions/ProductTemplateListResponse" + post: + tags: + - productTemplate + operationId: addProductTemplate + security: + - Bearer: [] + description: Create a product template + parameters: + - in: body + name: body + required: true + schema: + $ref: '#/definitions/ProductTemplateBodyParam' + responses: + '403': + description: No permission or wrong token + schema: + $ref: "#/definitions/ErrorModel" + '201': + description: Returns the newly created product template + schema: + $ref: "#/definitions/ProductTemplateResponse" + '422': + description: Invalid input + schema: + $ref: "#/definitions/ErrorModel" + + /productTemplates/{templateId}: + get: + tags: + - productTemplate + description: Retrieve product template 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 product template + schema: + $ref: "#/definitions/ProductTemplateResponse" + parameters: + - $ref: "#/parameters/templateIdParam" + operationId: getProductTemplate + + patch: + tags: + - productTemplate + operationId: updateProductTemplate + security: + - Bearer: [] + description: Update a product template. Only connect manager, connect admin, and admin can access this endpoint. + For attributes with JSON object type, it would overwrite the existing fields, or add new if the fields don't exist in the JSON object. + responses: + '403': + description: No permission or wrong token + schema: + $ref: "#/definitions/ErrorModel" + '404': + description: Not found + schema: + $ref: "#/definitions/ErrorModel" + '200': + description: Successfully updated product template. + schema: + $ref: "#/definitions/ProductTemplateResponse" + '422': + description: Invalid input + schema: + $ref: "#/definitions/ErrorModel" + default: + description: error payload + schema: + $ref: '#/definitions/ErrorModel' + parameters: + - $ref: "#/parameters/templateIdParam" + - name: body + in: body + required: true + schema: + $ref: "#/definitions/ProductTemplateBodyParam" + + delete: + tags: + - productTemplate + description: Remove an existing product template. Only connect manager, connect admin, and admin can access this endpoint. + security: + - Bearer: [] + parameters: + - $ref: "#/parameters/templateIdParam" + responses: + '403': + description: No permission or wrong token + schema: + $ref: "#/definitions/ErrorModel" + '404': + description: If product is not found + schema: + $ref: "#/definitions/ErrorModel" + '204': + description: Product template successfully removed parameters: projectIdParam: @@ -345,6 +595,14 @@ parameters: required: true type: integer format: int64 + templateIdParam: + name: templateId + in: path + description: template identifier + required: true + type: integer + format: int64 + minimum: 1 offsetParam: name: offset description: "number of items to skip. Defaults to 0" @@ -938,3 +1196,245 @@ definitions: type: array items: $ref: "#/definitions/Project" + + ProjectTemplateRequest: + title: Project template request object + type: object + required: + - name + - key + - category + - scope + - phases + properties: + name: + type: string + description: the project template name + key: + type: string + description: the project template key + category: + type: string + description: the project template category + scope: + type: object + description: the project template scope + phases: + type: object + description: the project template phases + + ProjectTemplateBodyParam: + title: Project template body param + type: object + required: + - param + properties: + param: + $ref: "#/definitions/ProjectTemplateRequest" + + ProjectTemplate: + title: Project template object + allOf: + - type: object + required: + - id + - createdAt + - createdBy + - updatedAt + - updatedBy + properties: + id: + type: number + format: int64 + description: the id + 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/ProjectTemplateRequest" + + + ProjectTemplateResponse: + title: Single project template 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/ProjectTemplate" + + ProjectTemplateListResponse: + title: Project template 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/ProjectTemplate" + + ProductTemplateRequest: + title: Product template request object + type: object + required: + - name + - key + - category + - scope + - phases + properties: + name: + type: string + description: the product template name + productKey: + type: string + description: the product template key + icon: + type: string + description: the product template icon + brief: + type: string + description: the product template brief + details: + type: string + description: the product template details + aliases: + type: object + description: the product template aliases + template: + type: object + description: the product template template + + ProductTemplateBodyParam: + title: Product template body param + type: object + required: + - param + properties: + param: + $ref: "#/definitions/ProductTemplateRequest" + + ProductTemplate: + title: Product template object + allOf: + - type: object + required: + - id + - createdAt + - createdBy + - updatedAt + - updatedBy + properties: + id: + type: number + format: int64 + description: the id + 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/ProductTemplateRequest" + + + ProductTemplateResponse: + title: Single product template 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/ProductTemplate" + + ProductTemplateListResponse: + title: Product template 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/ProductTemplate" \ No newline at end of file