Skip to content

Commit be01938

Browse files
committed
Merge from phases and products challenge
1 parent 5166c9a commit be01938

27 files changed

+2218
-2
lines changed

.circleci/config.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -76,7 +76,7 @@ workflows:
7676
- test
7777
filters:
7878
branches:
79-
only: [dev, 'feature/db-lock-issue']
79+
only: dev
8080
- deployProd:
8181
requires:
8282
- test

config/default.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,5 +37,6 @@
3737
"jwksUri": "",
3838
"busApiUrl": "http://api.topcoder-dev.com",
3939
"busApiToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJuYW1lIjoicHJvamVjdC1zZXJ2aWNlIiwiaWF0IjoxNTEyNzQ3MDgyLCJleHAiOjE1MjEzODcwODJ9.PHuNcFDaotGAL8RhQXQMdpL8yOKXxjB5DbBIodmt7RE",
40-
"HEALTH_CHECK_URL": "_health"
40+
"HEALTH_CHECK_URL": "_health",
41+
"maxPhaseProductCount": 1
4142
}

src/models/phaseProduct.js

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
2+
3+
module.exports = function definePhaseProduct(sequelize, DataTypes) {
4+
const PhaseProduct = sequelize.define('PhaseProduct', {
5+
id: { type: DataTypes.BIGINT, primaryKey: true, autoIncrement: true },
6+
name: { type: DataTypes.STRING, allowNull: true },
7+
projectId: DataTypes.BIGINT,
8+
directProjectId: DataTypes.BIGINT,
9+
billingAccountId: DataTypes.BIGINT,
10+
// TODO: associate this with product_template
11+
templateId: { type: DataTypes.BIGINT, defaultValue: 0 },
12+
type: { type: DataTypes.STRING, allowNull: true },
13+
estimatedPrice: { type: DataTypes.DOUBLE, defaultValue: 0.0 },
14+
actualPrice: { type: DataTypes.DOUBLE, defaultValue: 0.0 },
15+
details: { type: DataTypes.JSON, defaultValue: '' },
16+
17+
deletedAt: { type: DataTypes.DATE, allowNull: true },
18+
createdAt: { type: DataTypes.DATE, defaultValue: DataTypes.NOW },
19+
updatedAt: { type: DataTypes.DATE, defaultValue: DataTypes.NOW },
20+
deletedBy: { type: DataTypes.INTEGER, allowNull: true },
21+
createdBy: { type: DataTypes.INTEGER, allowNull: false },
22+
updatedBy: { type: DataTypes.INTEGER, allowNull: false },
23+
}, {
24+
tableName: 'phase_products',
25+
paranoid: false,
26+
timestamps: true,
27+
updatedAt: 'updatedAt',
28+
createdAt: 'createdAt',
29+
deletedAt: 'deletedAt',
30+
indexes: [],
31+
classMethods: {
32+
getActivePhaseProducts(phaseId) {
33+
return this.findAll({
34+
where: {
35+
deletedAt: { $eq: null },
36+
phaseId,
37+
},
38+
raw: true,
39+
});
40+
},
41+
},
42+
});
43+
44+
return PhaseProduct;
45+
};

src/models/project.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,7 @@ module.exports = function defineProject(sequelize, DataTypes) {
9292
associate: (models) => {
9393
Project.hasMany(models.ProjectMember, { as: 'members', foreignKey: 'projectId' });
9494
Project.hasMany(models.ProjectAttachment, { as: 'attachments', foreignKey: 'projectId' });
95+
Project.hasMany(models.ProjectPhase, { as: 'phases', foreignKey: 'projectId' });
9596
},
9697

9798
/**

src/models/projectPhase.js

Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
/* eslint-disable valid-jsdoc */
2+
3+
import _ from 'lodash';
4+
5+
module.exports = function defineProjectPhase(sequelize, DataTypes) {
6+
const ProjectPhase = sequelize.define('ProjectPhase', {
7+
id: { type: DataTypes.BIGINT, primaryKey: true, autoIncrement: true },
8+
name: { type: DataTypes.STRING, allowNull: true },
9+
status: { type: DataTypes.STRING, allowNull: true },
10+
startDate: { type: DataTypes.DATE, allowNull: true },
11+
endDate: { type: DataTypes.DATE, allowNull: true },
12+
budget: { type: DataTypes.DOUBLE, defaultValue: 0.0 },
13+
progress: { type: DataTypes.DOUBLE, defaultValue: 0.0 },
14+
details: { type: DataTypes.JSON, defaultValue: '' },
15+
16+
deletedAt: { type: DataTypes.DATE, allowNull: true },
17+
createdAt: { type: DataTypes.DATE, defaultValue: DataTypes.NOW },
18+
updatedAt: { type: DataTypes.DATE, defaultValue: DataTypes.NOW },
19+
deletedBy: { type: DataTypes.INTEGER, allowNull: true },
20+
createdBy: { type: DataTypes.INTEGER, allowNull: false },
21+
updatedBy: { type: DataTypes.INTEGER, allowNull: false },
22+
}, {
23+
tableName: 'project_phases',
24+
paranoid: false,
25+
timestamps: true,
26+
updatedAt: 'updatedAt',
27+
createdAt: 'createdAt',
28+
deletedAt: 'deletedAt',
29+
indexes: [],
30+
classMethods: {
31+
getActiveProjectPhases(projectId) {
32+
return this.findAll({
33+
where: {
34+
deletedAt: { $eq: null },
35+
projectId,
36+
},
37+
raw: true,
38+
});
39+
},
40+
associate: (models) => {
41+
ProjectPhase.hasMany(models.PhaseProduct, { as: 'products', foreignKey: 'phaseId' });
42+
},
43+
/**
44+
* Search name or status
45+
* @param parameters the parameters
46+
* - filters: the filters contains keyword
47+
* - order: the order
48+
* - limit: the limit
49+
* - offset: the offset
50+
* - attributes: the attributes to get
51+
* @param log the request log
52+
* @return the result rows and count
53+
*/
54+
searchText(parameters, log) {
55+
// special handling for keyword filter
56+
let query = '1=1 ';
57+
if (_.has(parameters.filters, 'id')) {
58+
if (_.isObject(parameters.filters.id)) {
59+
if (parameters.filters.id.$in.length === 0) {
60+
parameters.filters.id.$in.push(-1);
61+
}
62+
query += `AND id IN (${parameters.filters.id.$in}) `;
63+
} else if (_.isString(parameters.filters.id) || _.isNumber(parameters.filters.id)) {
64+
query += `AND id = ${parameters.filters.id} `;
65+
}
66+
}
67+
if (_.has(parameters.filters, 'status')) {
68+
const statusFilter = parameters.filters.status;
69+
if (_.isObject(statusFilter)) {
70+
const statuses = statusFilter.$in.join("','");
71+
query += `AND status IN ('${statuses}') `;
72+
} else if (_.isString(statusFilter)) {
73+
query += `AND status ='${statusFilter}'`;
74+
}
75+
}
76+
if (_.has(parameters.filters, 'name')) {
77+
query += `AND name like '%${parameters.filters.name}%' `;
78+
}
79+
80+
const attributesStr = `"${parameters.attributes.join('","')}"`;
81+
const orderStr = `"${parameters.order[0][0]}" ${parameters.order[0][1]}`;
82+
83+
// select count of project_phases
84+
return sequelize.query(`SELECT COUNT(1) FROM project_phases WHERE ${query}`,
85+
{ type: sequelize.QueryTypes.SELECT,
86+
logging: (str) => { log.debug(str); },
87+
raw: true,
88+
})
89+
.then((fcount) => {
90+
const count = fcount[0].count;
91+
// select project attributes
92+
return sequelize.query(`SELECT ${attributesStr} FROM project_phases WHERE ${query} ORDER BY ` +
93+
` ${orderStr} LIMIT ${parameters.limit} OFFSET ${parameters.offset}`,
94+
{ type: sequelize.QueryTypes.SELECT,
95+
logging: (str) => { log.debug(str); },
96+
raw: true,
97+
})
98+
.then(phases => ({ rows: phases, count }));
99+
});
100+
},
101+
},
102+
});
103+
104+
return ProjectPhase;
105+
};

src/permissions/index.js

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,4 +23,10 @@ module.exports = () => {
2323
Authorizer.setPolicy('project.downloadAttachment', projectView);
2424
Authorizer.setPolicy('project.updateMember', projectEdit);
2525
Authorizer.setPolicy('project.admin', projectAdmin);
26+
Authorizer.setPolicy('project.addProjectPhase', projectEdit);
27+
Authorizer.setPolicy('project.updateProjectPhase', projectEdit);
28+
Authorizer.setPolicy('project.deleteProjectPhase', projectEdit);
29+
Authorizer.setPolicy('project.addPhaseProduct', projectEdit);
30+
Authorizer.setPolicy('project.updatePhaseProduct', projectEdit);
31+
Authorizer.setPolicy('project.deletePhaseProduct', projectEdit);
2632
};

src/routes/index.js

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,23 @@ router.route('/v4/projects/:projectId(\\d+)/attachments/:id(\\d+)')
6464
.patch(require('./attachments/update'))
6565
.delete(require('./attachments/delete'));
6666

67+
router.route('/v4/projects/:projectId(\\d+)/phases')
68+
.get(require('./phases/list'))
69+
.post(require('./phases/create'));
70+
71+
router.route('/v4/projects/:projectId(\\d+)/phases/:phaseId(\\d+)')
72+
.get(require('./phases/get'))
73+
.patch(require('./phases/update'))
74+
.delete(require('./phases/delete'));
75+
76+
router.route('/v4/projects/:projectId(\\d+)/phases/:phaseId(\\d+)/products')
77+
.get(require('./phaseProducts/list'))
78+
.post(require('./phaseProducts/create'));
79+
80+
router.route('/v4/projects/:projectId(\\d+)/phases/:phaseId(\\d+)/products/:productId(\\d+)')
81+
.get(require('./phaseProducts/get'))
82+
.patch(require('./phaseProducts/update'))
83+
.delete(require('./phaseProducts/delete'));
6784

6885
// register error handler
6986
router.use((err, req, res, next) => { // eslint-disable-line no-unused-vars

src/routes/phaseProducts/create.js

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
2+
import validate from 'express-validation';
3+
import _ from 'lodash';
4+
import config from 'config';
5+
import Joi from 'joi';
6+
7+
import models from '../../models';
8+
import util from '../../util';
9+
10+
const permissions = require('tc-core-library-js').middleware.permissions;
11+
12+
const addPhaseProductValidations = {
13+
body: {
14+
param: Joi.object().keys({
15+
name: Joi.string().required(),
16+
type: Joi.string().required(),
17+
templateId: Joi.number().optional(),
18+
estimatedPrice: Joi.number().positive().optional(),
19+
actualPrice: Joi.number().positive().optional(),
20+
details: Joi.any().optional(),
21+
}).required(),
22+
},
23+
};
24+
25+
module.exports = [
26+
// validate request payload
27+
validate(addPhaseProductValidations),
28+
// check permission
29+
permissions('project.addPhaseProduct'),
30+
// do the real work
31+
(req, res, next) => {
32+
const projectId = _.parseInt(req.params.projectId);
33+
const phaseId = _.parseInt(req.params.phaseId);
34+
35+
const data = req.body.param;
36+
// default values
37+
_.assign(data, {
38+
createdBy: req.authUser.userId,
39+
updatedBy: req.authUser.userId,
40+
});
41+
42+
let newPhaseProduct = null;
43+
models.sequelize.transaction(() => models.Project.findOne({
44+
where: { id: projectId, deletedAt: { $eq: null } },
45+
raw: true,
46+
}).then((existingProject) => {
47+
// make sure project exists
48+
if (!existingProject) {
49+
const err = new Error(`project not found for project id ${projectId}`);
50+
err.status = 404;
51+
throw err;
52+
}
53+
_.assign(data, {
54+
projectId,
55+
directProjectId: existingProject.directProjectId,
56+
billingAccountId: existingProject.billingAccountId,
57+
});
58+
59+
return models.ProjectPhase.findOne({
60+
where: { id: phaseId, projectId, deletedAt: { $eq: null } },
61+
raw: true,
62+
});
63+
}).then((existingPhase) => {
64+
// make sure phase exists
65+
if (!existingPhase) {
66+
const err = new Error(`project phase not found for project id ${projectId}` +
67+
` and phase id ${phaseId}`);
68+
err.status = 404;
69+
throw err;
70+
}
71+
_.assign(data, {
72+
phaseId,
73+
});
74+
75+
return models.PhaseProduct.count({
76+
where: {
77+
projectId,
78+
phaseId,
79+
deletedAt: { $eq: null },
80+
},
81+
raw: true,
82+
});
83+
}).then((productCount) => {
84+
// make sure number of products of per phase <= max value
85+
if (productCount >= config.maxPhaseProductCount) {
86+
const err = new Error('the number of products per phase cannot exceed ' +
87+
`${config.maxPhaseProductCount}`);
88+
err.status = 400;
89+
throw err;
90+
}
91+
return models.PhaseProduct.create(data);
92+
})
93+
.then((_newPhaseProduct) => {
94+
newPhaseProduct = _.cloneDeep(_newPhaseProduct);
95+
req.log.debug('new phase product created (id# %d, name: %s)',
96+
newPhaseProduct.id, newPhaseProduct.name);
97+
newPhaseProduct = newPhaseProduct.get({ plain: true });
98+
newPhaseProduct = _.omit(newPhaseProduct, ['deletedAt', 'utm']);
99+
res.status(201).json(util.wrapResponse(req.id, newPhaseProduct, 1, 201));
100+
})).catch((err) => { next(err); });
101+
},
102+
];

0 commit comments

Comments
 (0)