Skip to content

Commit fc4dda0

Browse files
committed
winning submission from challenge 30089708 - Topcoder Project Service - Milestones pause/resume
1 parent 40628db commit fc4dda0

13 files changed

+1312
-15
lines changed
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
--
2+
-- Create table status history
3+
--
4+
5+
CREATE TABLE status_history (
6+
id bigint,
7+
"reference" character varying(45) NOT NULL,
8+
"referenceId" bigint NOT NULL,
9+
"status" character varying(45) NOT NULL,
10+
"comment" text,
11+
"createdAt" timestamp with time zone,
12+
"updatedAt" timestamp with time zone,
13+
"createdBy" integer NOT NULL,
14+
"updatedBy" integer NOT NULL
15+
);
16+
17+
CREATE SEQUENCE status_history_id_seq
18+
START WITH 1
19+
INCREMENT BY 1
20+
NO MINVALUE
21+
NO MAXVALUE
22+
CACHE 1;
23+
24+
ALTER SEQUENCE status_history_id_seq OWNED BY status_history.id;
25+
26+
ALTER TABLE ONLY status_history ALTER COLUMN id SET DEFAULT nextval('status_history_id_seq'::regclass);
27+
28+
ALTER TABLE ONLY status_history
29+
ADD CONSTRAINT status_history_pkey PRIMARY KEY (id);

postman.json

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4253,6 +4253,78 @@
42534253
}
42544254
},
42554255
"response": []
4256+
},
4257+
{
4258+
"name": "Pause Milestone",
4259+
"request": {
4260+
"method": "PATCH",
4261+
"header": [
4262+
{
4263+
"key": "Content-Type",
4264+
"value": "application/json"
4265+
},
4266+
{
4267+
"key": "Authorization",
4268+
"value": "Bearer {{jwt-token}}"
4269+
}
4270+
],
4271+
"body": {
4272+
"mode": "raw",
4273+
"raw": "{\r\n \"param\":{\r\n \"comment\": \"Comment\"\r\n\t}\r\n}"
4274+
},
4275+
"url": {
4276+
"raw": "{{api-url}}/v4/timelines/1/milestones/2/status/pause",
4277+
"host": [
4278+
"{{api-url}}"
4279+
],
4280+
"path": [
4281+
"v4",
4282+
"timelines",
4283+
"1",
4284+
"milestones",
4285+
"2",
4286+
"status",
4287+
"pause"
4288+
]
4289+
}
4290+
},
4291+
"response": []
4292+
},
4293+
{
4294+
"name": "Resume Milestone",
4295+
"request": {
4296+
"method": "PATCH",
4297+
"header": [
4298+
{
4299+
"key": "Content-Type",
4300+
"value": "application/json"
4301+
},
4302+
{
4303+
"key": "Authorization",
4304+
"value": "Bearer {{jwt-token}}"
4305+
}
4306+
],
4307+
"body": {
4308+
"mode": "raw",
4309+
"raw": "{\r\n \"param\":{\r\n \"comment\": \"Comment\"\r\n\t}\r\n}"
4310+
},
4311+
"url": {
4312+
"raw": "{{api-url}}/v4/timelines/1/milestones/2/status/resume",
4313+
"host": [
4314+
"{{api-url}}"
4315+
],
4316+
"path": [
4317+
"v4",
4318+
"timelines",
4319+
"1",
4320+
"milestones",
4321+
"2",
4322+
"status",
4323+
"resume"
4324+
]
4325+
}
4326+
},
4327+
"response": []
42564328
}
42574329
]
42584330
},

src/constants.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -131,6 +131,8 @@ export const BUS_API_EVENT = {
131131
MILESTONE_TRANSITION_ACTIVE: 'connect.action.timeline.milestone.transition.active',
132132
// When milestone is marked as completed
133133
MILESTONE_TRANSITION_COMPLETED: 'connect.action.timeline.milestone.transition.completed',
134+
// When milestone is marked as paused
135+
MILESTONE_TRANSITION_PAUSED: 'connect.action.timeline.milestone.transition.paused',
134136
// When milestone is waiting for customers's input
135137
MILESTONE_WAITING_CUSTOMER: 'connect.action.timeline.milestone.waiting.customer',
136138

src/models/milestone.js

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import moment from 'moment';
2+
import models from '../models';
23
/* eslint-disable valid-jsdoc */
34

45
/**
@@ -82,6 +83,35 @@ module.exports = (sequelize, DataTypes) => {
8283
});
8384
},
8485
},
86+
hooks: {
87+
afterCreate: (milestone, options) => models.StatusHistory.create({
88+
reference: 'milestone',
89+
referenceId: milestone.id,
90+
status: milestone.status,
91+
comment: null,
92+
createdBy: milestone.createdBy,
93+
updatedBy: milestone.updatedBy,
94+
},
95+
{
96+
transaction: options.transaction,
97+
}),
98+
afterUpdate: (milestone, options) => {
99+
if (milestone.changed().includes('status')) {
100+
return models.StatusHistory.create({
101+
reference: 'milestone',
102+
referenceId: milestone.id,
103+
status: milestone.status,
104+
comment: options.comment || null,
105+
createdBy: milestone.createdBy,
106+
updatedBy: milestone.updatedBy,
107+
},
108+
{
109+
transaction: options.transaction,
110+
});
111+
}
112+
return Promise.resolve();
113+
},
114+
},
85115
});
86116

87117
return Milestone;

src/models/statusHistory.js

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
/* eslint-disable valid-jsdoc */
2+
3+
import _ from 'lodash';
4+
import { MILESTONE_STATUS } from '../constants';
5+
6+
module.exports = function defineStatusHistory(sequelize, DataTypes) {
7+
const StatusHistory = sequelize.define('StatusHistory', {
8+
id: { type: DataTypes.BIGINT, primaryKey: true, autoIncrement: true },
9+
reference: { type: DataTypes.STRING, allowNull: false },
10+
referenceId: { type: DataTypes.STRING, allowNull: false },
11+
status: {
12+
type: DataTypes.STRING,
13+
allowNull: false,
14+
validate: {
15+
isIn: [_.values(MILESTONE_STATUS)],
16+
},
17+
},
18+
comment: DataTypes.TEXT,
19+
createdBy: { type: DataTypes.INTEGER, allowNull: false },
20+
createdAt: { type: DataTypes.DATE, defaultValue: DataTypes.NOW },
21+
updatedBy: { type: DataTypes.INTEGER, allowNull: false },
22+
updatedAt: { type: DataTypes.DATE, defaultValue: DataTypes.NOW },
23+
}, {
24+
tableName: 'status_history',
25+
paranoid: false,
26+
timestamps: true,
27+
updatedAt: 'updatedAt',
28+
createdAt: 'createdAt',
29+
deletedAt: 'deletedAt',
30+
indexes: [],
31+
classMethods: {},
32+
});
33+
34+
return StatusHistory;
35+
};

src/routes/index.js

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -176,6 +176,12 @@ router.route('/v4/timelines/:timelineId(\\d+)/milestones/:milestoneId(\\d+)')
176176
.patch(require('./milestones/update'))
177177
.delete(require('./milestones/delete'));
178178

179+
router.route('/v4/timelines/:timelineId(\\d+)/milestones/:milestoneId(\\d+)/status/pause')
180+
.patch(require('./milestones/status.pause'));
181+
182+
router.route('/v4/timelines/:timelineId(\\d+)/milestones/:milestoneId(\\d+)/status/resume')
183+
.patch(require('./milestones/status.resume'));
184+
179185
router.route('/v4/timelines/metadata/milestoneTemplates')
180186
.post(require('./milestoneTemplates/create'))
181187
.get(require('./milestoneTemplates/list'));

src/routes/milestones/create.spec.js

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -146,7 +146,7 @@ describe('CREATE milestone', () => {
146146
name: 'milestone 1',
147147
duration: 2,
148148
startDate: '2018-05-03T00:00:00.000Z',
149-
status: 'open',
149+
status: 'draft',
150150
type: 'type1',
151151
details: {
152152
detail1: {
@@ -168,7 +168,7 @@ describe('CREATE milestone', () => {
168168
name: 'milestone 2',
169169
duration: 3,
170170
startDate: '2018-05-04T00:00:00.000Z',
171-
status: 'open',
171+
status: 'draft',
172172
type: 'type2',
173173
order: 2,
174174
plannedText: 'plannedText 2',
@@ -183,7 +183,7 @@ describe('CREATE milestone', () => {
183183
name: 'milestone 3',
184184
duration: 4,
185185
startDate: '2018-05-04T00:00:00.000Z',
186-
status: 'open',
186+
status: 'draft',
187187
type: 'type3',
188188
order: 3,
189189
plannedText: 'plannedText 3',
@@ -211,7 +211,7 @@ describe('CREATE milestone', () => {
211211
startDate: '2018-05-05T00:00:00.000Z',
212212
endDate: '2018-05-07T00:00:00.000Z',
213213
completionDate: '2018-05-08T00:00:00.000Z',
214-
status: 'open',
214+
status: 'draft',
215215
type: 'type4',
216216
details: {
217217
detail1: {

src/routes/milestones/status.pause.js

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
import validate from 'express-validation';
2+
import _ from 'lodash';
3+
import Joi from 'joi';
4+
import { middleware as tcMiddleware } from 'tc-core-library-js';
5+
import util from '../../util';
6+
import validateTimeline from '../../middlewares/validateTimeline';
7+
import { MILESTONE_STATUS, BUS_API_EVENT } from '../../constants';
8+
import models from '../../models';
9+
10+
const permissions = tcMiddleware.permissions;
11+
12+
13+
const schema = {
14+
params: {
15+
timelineId: Joi.number().integer().positive().required(),
16+
milestoneId: Joi.number().integer().positive().required(),
17+
},
18+
body: {
19+
param: Joi.object().keys({
20+
comment: Joi.string().max(512).required(),
21+
}).required(),
22+
},
23+
};
24+
25+
module.exports = [
26+
validate(schema),
27+
// Validate and get projectId from the timelineId param,
28+
// and set to request params for checking by the permissions middleware
29+
validateTimeline.validateTimelineIdParam,
30+
permissions('milestone.edit'),
31+
(req, res, next) => {
32+
const where = {
33+
timelineId: req.params.timelineId,
34+
id: req.params.milestoneId,
35+
};
36+
37+
const entityToUpdate = {
38+
updatedBy: req.authUser.userId,
39+
};
40+
const comment = req.body.param.comment;
41+
42+
let original;
43+
let updated;
44+
45+
return models.sequelize.transaction(transaction =>
46+
// Find the milestone
47+
models.Milestone.findOne({ where })
48+
.then((milestone) => {
49+
// Not found
50+
if (!milestone) {
51+
const apiErr = new Error(`Milestone not found for milestone id ${req.params.milestoneId}`);
52+
apiErr.status = 404;
53+
return Promise.reject(apiErr);
54+
}
55+
56+
// status already on pause
57+
if (milestone.status === MILESTONE_STATUS.PAUSED) {
58+
const apiErr = new Error('Milestone status already paused');
59+
apiErr.status = 422;
60+
return Promise.reject(apiErr);
61+
}
62+
63+
original = _.omit(milestone.toJSON(), ['deletedAt', 'deletedBy']);
64+
65+
entityToUpdate.status = MILESTONE_STATUS.PAUSED;
66+
entityToUpdate.id = milestone.id;
67+
68+
// Update
69+
return milestone.update(entityToUpdate, { comment, transaction });
70+
})
71+
.then((updatedMilestone) => {
72+
updated = _.omit(updatedMilestone.toJSON(), 'deletedAt', 'deletedBy');
73+
}),
74+
)
75+
.then(() => {
76+
// Send event to bus
77+
req.log.debug('Sending event to RabbitMQ bus for milestone %d', updated.id);
78+
req.app.services.pubsub.publish(BUS_API_EVENT.MILESTONE_TRANSITION_PAUSED,
79+
{ original, updated },
80+
{ correlationId: req.id },
81+
);
82+
83+
req.app.emit(BUS_API_EVENT.MILESTONE_TRANSITION_PAUSED,
84+
{ req, original, updated });
85+
86+
res.json(util.wrapResponse(req.id));
87+
return Promise.resolve(true);
88+
})
89+
.catch(next);
90+
},
91+
];

0 commit comments

Comments
 (0)