From ed355898880fa9a34b6f221eebe2a8cbba710351 Mon Sep 17 00:00:00 2001 From: Maksym Mykhailenko Date: Fri, 3 May 2019 18:43:55 +0700 Subject: [PATCH] winning submission from challenge 30089841 - Topcoder Project Service - Create DB endpoints --- postman.json | 391 ++++++++++++++++++++++- src/models/phaseProduct.js | 21 +- src/models/projectPhase.js | 72 ++--- src/routes/index.js | 6 + src/routes/phaseProducts/list-db.js | 45 +++ src/routes/phaseProducts/list-db.spec.js | 188 +++++++++++ src/routes/phases/list-db.js | 62 ++++ src/routes/phases/list-db.spec.js | 159 +++++++++ swagger.yaml | 125 +++++++- 9 files changed, 1012 insertions(+), 57 deletions(-) create mode 100644 src/routes/phaseProducts/list-db.js create mode 100644 src/routes/phaseProducts/list-db.spec.js create mode 100644 src/routes/phases/list-db.js create mode 100644 src/routes/phases/list-db.spec.js diff --git a/postman.json b/postman.json index b4b0a1b6..3e17f076 100644 --- a/postman.json +++ b/postman.json @@ -1,6 +1,6 @@ { "info": { - "_postman_id": "d9ea7b0f-1d2c-4d48-a693-fe7b51b1e2ea", + "_postman_id": "57206894-511c-4ffb-94bb-e50d2dd416fb", "name": "tc-project-service", "schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json" }, @@ -1012,6 +1012,207 @@ }, "response": [] }, + { + "name": "List projects DB", + "request": { + "method": "GET", + "header": [ + { + "key": "Authorization", + "value": "Bearer {{jwt-token}}" + } + ], + "body": { + "mode": "raw", + "raw": "" + }, + "url": { + "raw": "{{api-url}}/v4/projects/db", + "host": [ + "{{api-url}}" + ], + "path": [ + "v4", + "projects", + "db" + ] + }, + "description": "List all the project with no filter. Default sort and limits are applied." + }, + "response": [] + }, + { + "name": "List projects DB with limit and offset", + "request": { + "method": "GET", + "header": [ + { + "key": "Authorization", + "value": "Bearer {{jwt-token}}" + } + ], + "body": { + "mode": "raw", + "raw": "" + }, + "url": { + "raw": "{{api-url}}/v4/projects/db?limit=1&offset=1", + "host": [ + "{{api-url}}" + ], + "path": [ + "v4", + "projects", + "db" + ], + "query": [ + { + "key": "limit", + "value": "1" + }, + { + "key": "offset", + "value": "1" + } + ] + }, + "description": "List all the project with no filter. Limit of 1 and offset of 1 is applied" + }, + "response": [] + }, + { + "name": "List projects DB with filters applied", + "request": { + "method": "GET", + "header": [ + { + "key": "Authorization", + "value": "Bearer {{jwt-token}}" + } + ], + "body": { + "mode": "raw", + "raw": "" + }, + "url": { + "raw": "{{api-url}}/v4/projects/db?filter=type%3Dgeneric", + "host": [ + "{{api-url}}" + ], + "path": [ + "v4", + "projects", + "db" + ], + "query": [ + { + "key": "filter", + "value": "type%3Dgeneric" + } + ] + }, + "description": "List all the project with filters applied. The filter string should be url encoded. Default limit and offset is applicable" + }, + "response": [] + }, + { + "name": "List projects DB with sort applied", + "request": { + "method": "GET", + "header": [ + { + "key": "Authorization", + "value": "Bearer {{jwt-token}}" + } + ], + "body": { + "mode": "raw", + "raw": "" + }, + "url": { + "raw": "{{api-url}}/v4/projects/db?sort=type%20desc", + "host": [ + "{{api-url}}" + ], + "path": [ + "v4", + "projects", + "db" + ], + "query": [ + { + "key": "sort", + "value": "type%20desc" + } + ] + }, + "description": "List all the project with custom sort and no filter. Default sort and limits are applied. The sort string has to be url encoded. Sort is of type `key asc|desc`" + }, + "response": [] + }, + { + "name": "List projects DB and return specific fields", + "request": { + "method": "GET", + "header": [ + { + "key": "Authorization", + "value": "Bearer {{jwt-token}}" + } + ], + "body": { + "mode": "raw", + "raw": "" + }, + "url": { + "raw": "{{api-url}}/v4/projects/db?fields=id,name,description", + "host": [ + "{{api-url}}" + ], + "path": [ + "v4", + "projects", + "db" + ], + "query": [ + { + "key": "fields", + "value": "id,name,description" + } + ] + }, + "description": "List all the project with no filter. Default sort and limits are applied. The fields to return is specified as comma separated list. Only those fields should be returned." + }, + "response": [] + }, + { + "name": "List projects DB with copilot token", + "request": { + "method": "GET", + "header": [ + { + "key": "Authorization", + "value": "Bearer {{jwt-token-copilot-40051332}}" + } + ], + "body": { + "mode": "raw", + "raw": "" + }, + "url": { + "raw": "{{api-url}}/v4/projects/db", + "host": [ + "{{api-url}}" + ], + "path": [ + "v4", + "projects", + "db" + ] + } + }, + "response": [] + }, { "name": "List projects", "request": { @@ -1181,7 +1382,7 @@ "response": [] }, { - "name": "get projects with copilot token", + "name": "List projects with copilot token", "request": { "method": "GET", "header": [ @@ -2125,6 +2326,160 @@ }, "response": [] }, + { + "name": "List Phase DB", + "request": { + "method": "GET", + "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/db", + "host": [ + "{{api-url}}" + ], + "path": [ + "v4", + "projects", + "1", + "phases", + "db" + ] + } + }, + "response": [] + }, + { + "name": "List Phase DB with fields", + "request": { + "method": "GET", + "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/db?fields=status,name,budget", + "host": [ + "{{api-url}}" + ], + "path": [ + "v4", + "projects", + "1", + "phases", + "db" + ], + "query": [ + { + "key": "fields", + "value": "status,name,budget" + } + ] + } + }, + "response": [] + }, + { + "name": "List Phase DB with sort", + "request": { + "method": "GET", + "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/db?sort=status desc", + "host": [ + "{{api-url}}" + ], + "path": [ + "v4", + "projects", + "1", + "phases", + "db" + ], + "query": [ + { + "key": "sort", + "value": "status desc" + } + ] + } + }, + "response": [] + }, + { + "name": "List Phase DB with sort by order", + "request": { + "method": "GET", + "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/db?sort=order desc", + "host": [ + "{{api-url}}" + ], + "path": [ + "v4", + "projects", + "1", + "phases", + "db" + ], + "query": [ + { + "key": "sort", + "value": "order desc" + } + ] + } + }, + "response": [] + }, { "name": "List Phase", "request": { @@ -2451,6 +2806,38 @@ }, "response": [] }, + { + "name": "List Phase DB 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/db", + "host": [ + "{{api-url}}" + ], + "path": [ + "v4", + "projects", + "1", + "phases", + "1", + "products", + "db" + ] + } + }, + "response": [] + }, { "name": "List Phase Products", "request": { diff --git a/src/models/phaseProduct.js b/src/models/phaseProduct.js index 04ec131e..04f97479 100644 --- a/src/models/phaseProduct.js +++ b/src/models/phaseProduct.js @@ -1,5 +1,3 @@ - - module.exports = function definePhaseProduct(sequelize, DataTypes) { const PhaseProduct = sequelize.define('PhaseProduct', { id: { type: DataTypes.BIGINT, primaryKey: true, autoIncrement: true }, @@ -38,6 +36,25 @@ module.exports = function definePhaseProduct(sequelize, DataTypes) { raw: true, }); }, + /** + * Search Phase Products + * @param {Object} parameters the replacements for sequelize + * - projectId id of the project + * - phaseId id of phase + * @param {Object} log the request log + * @return {Object} the result rows and count + */ + async search(parameters = {}, log) { + const whereQuery = 'phase_products."projectId"= :projectId AND phase_products."phaseId" = :phaseId'; + const dbQuery = `SELECT * FROM phase_products WHERE ${whereQuery}`; + return sequelize.query(dbQuery, + { type: sequelize.QueryTypes.SELECT, + replacements: parameters, + logging: (str) => { log.debug(str); }, + raw: true, + }) + .then(phases => ({ rows: phases, count: phases.length })); + }, }, }); diff --git a/src/models/projectPhase.js b/src/models/projectPhase.js index 35649382..651c9c70 100644 --- a/src/models/projectPhase.js +++ b/src/models/projectPhase.js @@ -1,5 +1,3 @@ -/* eslint-disable valid-jsdoc */ - import _ from 'lodash'; module.exports = function defineProjectPhase(sequelize, DataTypes) { @@ -44,62 +42,32 @@ module.exports = function defineProjectPhase(sequelize, DataTypes) { 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 + * Search project phases + * @param {Object} parameters the parameters + * - sortField: the field that will be references when sorting + * - sortType: ASC or DESC + * - fields: the fields to retrieved + * - projectId: the id of project + * @param {Object} log the request log + * @return {Object} 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}%' `; + async search(parameters = {}, log) { + let fieldsStr = _.map(parameters.fields, field => `project_phases."${field}"`); + fieldsStr = `${fieldsStr.join(',')}`; + const replacements = { + projectId: parameters.projectId, + }; + let dbQuery = `SELECT ${fieldsStr} FROM project_phases WHERE project_phases."projectId" = :projectId`; + if (_.has(parameters, 'sortField') && _.has(parameters, 'sortType')) { + dbQuery = `${dbQuery} ORDER BY project_phases."${parameters.sortField}" ${parameters.sortType}`; } - - 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}`, + return sequelize.query(dbQuery, { type: sequelize.QueryTypes.SELECT, logging: (str) => { log.debug(str); }, + replacements, 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 })); - }); + .then(phases => ({ rows: phases, count: phases.length })); }, }, }); diff --git a/src/routes/index.js b/src/routes/index.js index 4e8176af..c6f711ef 100644 --- a/src/routes/index.js +++ b/src/routes/index.js @@ -130,6 +130,9 @@ router.route('/v4/projects/:projectId(\\d+)/phases') .get(require('./phases/list')) .post(require('./phases/create')); +router.route('/v4/projects/:projectId(\\d+)/phases/db') + .get(require('./phases/list-db')); + router.route('/v4/projects/:projectId(\\d+)/phases/:phaseId(\\d+)') .get(require('./phases/get')) .patch(require('./phases/update')) @@ -139,6 +142,9 @@ 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/db') + .get(require('./phaseProducts/list-db')); + router.route('/v4/projects/:projectId(\\d+)/phases/:phaseId(\\d+)/products/:productId(\\d+)') .get(require('./phaseProducts/get')) .patch(require('./phaseProducts/update')) diff --git a/src/routes/phaseProducts/list-db.js b/src/routes/phaseProducts/list-db.js new file mode 100644 index 00000000..d0eab2f5 --- /dev/null +++ b/src/routes/phaseProducts/list-db.js @@ -0,0 +1,45 @@ +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'), + async (req, res, next) => { + const projectId = _.parseInt(req.params.projectId); + const phaseId = _.parseInt(req.params.phaseId); + + // check if the project and phase are exist + try { + const countProject = await models.Project.count({ where: { id: projectId } }); + if (countProject === 0) { + const apiErr = new Error(`active project not found for project id ${projectId}`); + apiErr.status = 404; + throw apiErr; + } + + const countPhase = await models.ProjectPhase.count({ where: { id: phaseId } }); + if (countPhase === 0) { + const apiErr = new Error(`active project phase not found for id ${phaseId}`); + apiErr.status = 404; + throw apiErr; + } + } catch (err) { + return next(err); + } + + const parameters = { + projectId, + phaseId, + }; + + try { + const { rows, count } = await models.PhaseProduct.search(parameters, req.log); + return res.json(util.wrapResponse(req.id, rows, count)); + } catch (err) { + return next(err); + } + }, +]; diff --git a/src/routes/phaseProducts/list-db.spec.js b/src/routes/phaseProducts/list-db.spec.js new file mode 100644 index 00000000..eb32119c --- /dev/null +++ b/src/routes/phaseProducts/list-db.spec.js @@ -0,0 +1,188 @@ +/* eslint-disable no-unused-expressions */ +import _ from 'lodash'; +import request from 'supertest'; +import chai from 'chai'; +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 project; + const memberUser = { + handle: testUtil.getDecodedToken(testUtil.jwts.member).handle, + userId: testUtil.getDecodedToken(testUtil.jwts.member).userId, + firstName: 'fname', + lastName: 'lName', + email: 'some@abc.com', + }; + const copilotUser = { + handle: testUtil.getDecodedToken(testUtil.jwts.copilot).handle, + userId: testUtil.getDecodedToken(testUtil.jwts.copilot).userId, + firstName: 'fname', + lastName: 'lName', + email: 'some@abc.com', + }; + 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: {}, + createdBy: 1, + updatedBy: 1, + lastActivityAt: 1, + lastActivityUserId: '1', + }).then((p) => { + projectId = p.id; + project = p.toJSON(); + // create members + models.ProjectMember.bulkCreate([{ + id: 1, + userId: copilotUser.userId, + projectId, + role: 'copilot', + isPrimary: false, + createdBy: 1, + updatedBy: 1, + }, { + id: 2, + userId: memberUser.userId, + projectId, + role: 'customer', + isPrimary: true, + createdBy: 1, + updatedBy: 1, + }]).then(() => { + 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 }); + project.lastActivityAt = 1; + project.phases = [phase.toJSON()]; + + models.PhaseProduct.create(body).then((product) => { + project.phases[0].products = [product.toJSON()]; + project.lastActivityAt = 1; + done(); + }); + }); + }); + }); + }); + }); + + after((done) => { + testUtil.clearDb(done); + }); + + describe('GET /projects/{id}/phases/{phaseId}/products/db', () => { + it('should return 403 when user have no permission (non team member)', (done) => { + request(server) + .get(`/v4/projects/${projectId}/phases/${phaseId}/products/db`) + .set({ + Authorization: `Bearer ${testUtil.jwts.member2}`, + }) + .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/db`) + .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/db`) + .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 (customer)', (done) => { + request(server) + .get(`/v4/projects/${projectId}/phases/${phaseId}/products/db`) + .set({ + Authorization: `Bearer ${testUtil.jwts.member}`, + }) + .send({ param: body }) + .expect('Content-Type', /json/) + .expect(200) + .end((err, res) => { + if (err) { + done(err); + } else { + const resJson = res.body.result.content; + should.exist(resJson); + resJson.should.have.lengthOf(1); + done(); + } + }); + }); + + it('should return 1 phase when user have project permission (copilot)', (done) => { + request(server) + .get(`/v4/projects/${projectId}/phases/${phaseId}/products/db`) + .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; + should.exist(resJson); + resJson.should.have.lengthOf(1); + done(); + } + }); + }); + }); +}); diff --git a/src/routes/phases/list-db.js b/src/routes/phases/list-db.js new file mode 100644 index 00000000..60337cb1 --- /dev/null +++ b/src/routes/phases/list-db.js @@ -0,0 +1,62 @@ +import _ from 'lodash'; +import { middleware as tcMiddleware } from 'tc-core-library-js'; +import util from '../../util'; +import models from '../../models'; + +const PHASE_ATTRIBUTES = _.keys(models.ProjectPhase.rawAttributes); +const permissions = tcMiddleware.permissions; + +module.exports = [ + permissions('project.view'), + async (req, res, next) => { + const projectId = _.parseInt(req.params.projectId); + // check if the project is exist + try { + const count = await models.Project.count({ where: { id: projectId } }); + if (count === 0) { + const apiErr = new Error(`active project not found for project id ${projectId}`); + apiErr.status = 404; + throw apiErr; + } + } catch (err) { + return next(err); + } + + // Parse the fields string to determine what fields are to be returned + const rawFields = req.query.fields ? decodeURIComponent(req.query.fields).split(',') : PHASE_ATTRIBUTES; + let sort = req.query.sort ? decodeURIComponent(req.query.sort) : 'startDate'; + if (sort && sort.indexOf(' ') === -1) { + sort += ' asc'; + } + const sortableProps = [ + 'startDate asc', 'startDate desc', + 'endDate asc', 'endDate desc', + 'status asc', 'status desc', + 'order asc', 'order desc', + ]; + if (sort && _.indexOf(sortableProps, sort) < 0) { + return util.handleError('Invalid sort criteria', null, req, next); + } + + const sortParameters = sort.split(' '); + + const fields = _.union( + _.intersection(rawFields, [...PHASE_ATTRIBUTES, 'products']), + ['id'], // required fields + ); + + const parameters = { + projectId, + sortField: sortParameters[0], + sortType: sortParameters[1], + fields, + }; + + try { + const { rows, count } = await models.ProjectPhase.search(parameters, req.log); + return res.json(util.wrapResponse(req.id, rows, count)); + } catch (err) { + return next(err); + } + }, +]; diff --git a/src/routes/phases/list-db.spec.js b/src/routes/phases/list-db.spec.js new file mode 100644 index 00000000..568f2815 --- /dev/null +++ b/src/routes/phases/list-db.spec.js @@ -0,0 +1,159 @@ +/* eslint-disable no-unused-expressions */ +import _ from 'lodash'; +import request from 'supertest'; +import chai from 'chai'; +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 project; + const memberUser = { + handle: testUtil.getDecodedToken(testUtil.jwts.member).handle, + userId: testUtil.getDecodedToken(testUtil.jwts.member).userId, + firstName: 'fname', + lastName: 'lName', + email: 'some@abc.com', + }; + const copilotUser = { + handle: testUtil.getDecodedToken(testUtil.jwts.copilot).handle, + userId: testUtil.getDecodedToken(testUtil.jwts.copilot).userId, + firstName: 'fname', + lastName: 'lName', + email: 'some@abc.com', + }; + 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: {}, + createdBy: 1, + updatedBy: 1, + lastActivityAt: 1, + lastActivityUserId: '1', + }).then((p) => { + projectId = p.id; + project = p.toJSON(); + // create members + models.ProjectMember.bulkCreate([{ + id: 1, + userId: copilotUser.userId, + projectId, + role: 'copilot', + isPrimary: false, + createdBy: 1, + updatedBy: 1, + }, { + id: 2, + userId: memberUser.userId, + projectId, + role: 'customer', + isPrimary: true, + createdBy: 1, + updatedBy: 1, + }]).then(() => { + _.assign(body, { projectId }); + return models.ProjectPhase.create(body); + }).then((phase) => { + project.lastActivityAt = 1; + project.phases = [phase]; + done(); + }); + }); + }); + }); + + after((done) => { + testUtil.clearDb(done); + }); + + describe('GET /projects/{id}/phases/db', () => { + it('should return 403 when user have no permission (non team member)', (done) => { + request(server) + .get(`/v4/projects/${projectId}/phases/db`) + .set({ + Authorization: `Bearer ${testUtil.jwts.member2}`, + }) + .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/db') + .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 (customer)', (done) => { + request(server) + .get(`/v4/projects/${projectId}/phases/db`) + .set({ + Authorization: `Bearer ${testUtil.jwts.member}`, + }) + .send({ param: body }) + .expect('Content-Type', /json/) + .expect(200) + .end((err, res) => { + if (err) { + done(err); + } else { + const resJson = res.body.result.content; + should.exist(resJson); + resJson.should.have.lengthOf(1); + done(); + } + }); + }); + + it('should return 1 phase when user have project permission (copilot)', (done) => { + request(server) + .get(`/v4/projects/${projectId}/phases/db`) + .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; + should.exist(resJson); + resJson.should.have.lengthOf(1); + done(); + } + }); + }); + }); +}); diff --git a/swagger.yaml b/swagger.yaml index a74dbb94..a8e5aa18 100644 --- a/swagger.yaml +++ b/swagger.yaml @@ -16,7 +16,53 @@ securityDefinitions: name: Authorization in: header paths: - /projects: + '/projects/db': + get: + tags: + - project + operationId: findProjectsDB + security: + - Bearer: [] + description: Retrieve projects that match the filter directly from database + responses: + '200': + description: A list of projects + schema: + $ref: '#/definitions/ProjectListResponse' + '403': + description: No permission or wrong token + schema: + $ref: '#/definitions/ErrorModel' + '422': + description: Invalid input + schema: + $ref: '#/definitions/ErrorModel' + 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 + - code + - customer + - manager + - name: sort + required: false + description: > + sort projects by status, name, type, createdAt, updatedAt. Default + is createdAt asc + in: query + type: string + '/projects': get: tags: - project @@ -63,6 +109,8 @@ paths: in: query type: string post: + tags: + - project operationId: addProject security: - Bearer: [] @@ -88,6 +136,8 @@ paths: $ref: '#/definitions/ErrorModel' '/projects/{projectId}': get: + tags: + - project description: Retrieve project by id security: - Bearer: [] @@ -120,6 +170,8 @@ paths: allowed. operationId: getProject patch: + tags: + - project operationId: updateProject security: - Bearer: [] @@ -160,6 +212,8 @@ paths: schema: $ref: '#/definitions/ProjectBodyParam' delete: + tags: + - project description: remove an existing project security: - Bearer: [] @@ -178,6 +232,8 @@ paths: $ref: '#/definitions/ErrorModel' '/projects/{projectId}/attachments': post: + tags: + - project description: add a new project attachment security: - Bearer: [] @@ -203,6 +259,8 @@ paths: $ref: '#/definitions/ErrorModel' '/projects/{projectId}/attachments/{id}': patch: + tags: + - project description: Update an existing attachment security: - Bearer: [] @@ -237,6 +295,8 @@ paths: schema: $ref: '#/definitions/ErrorModel' delete: + tags: + - project description: remove an existing attachment security: - Bearer: [] @@ -260,6 +320,8 @@ paths: $ref: '#/definitions/ErrorModel' '/projects/{projectId}/members': post: + tags: + - project description: add a new project member security: - Bearer: [] @@ -285,6 +347,8 @@ paths: $ref: '#/definitions/ErrorModel' '/projects/{projectId}/members/{id}': delete: + tags: + - project description: Delete a project member security: - Bearer: [] @@ -302,6 +366,8 @@ paths: schema: $ref: '#/definitions/ErrorModel' patch: + tags: + - project security: - Bearer: [] description: Support editing project member roles & primary option. @@ -339,6 +405,41 @@ paths: required: true schema: $ref: '#/definitions/UpdateProjectMemberBodyParam' + '/projects/{projectId}/phases/db': + parameters: + - $ref: '#/parameters/projectIdParam' + get: + tags: + - phase + operationId: findProjectPhasesDB + security: + - Bearer: [] + description: >- + Retrieve all project phases directly from database. 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, order. Default is + startDate asc + in: query + type: string + responses: + '200': + description: A list of project phases + schema: + $ref: '#/definitions/ProjectPhaseListResponse' + '403': + description: No permission or wrong token + schema: + $ref: '#/definitions/ErrorModel' '/projects/{projectId}/phases': parameters: - $ref: '#/parameters/projectIdParam' @@ -503,6 +604,28 @@ paths: description: If project is not found schema: $ref: '#/definitions/ErrorModel' + '/projects/{projectId}/phases/{phaseId}/products/db': + parameters: + - $ref: '#/parameters/projectIdParam' + - $ref: '#/parameters/phaseIdParam' + get: + tags: + - phase product + operationId: findPhaseProductsDB + security: + - Bearer: [] + description: >- + Retrieve all phase products directly from database. All users who can edit project can access + this endpoint. + responses: + '200': + description: A list of phase products + schema: + $ref: '#/definitions/PhaseProductListResponse' + '403': + description: No permission or wrong token + schema: + $ref: '#/definitions/ErrorModel' '/projects/{projectId}/phases/{phaseId}/products': parameters: - $ref: '#/parameters/projectIdParam'