diff --git a/config/default.json b/config/default.json index 367d8ca8..3111ddce 100644 --- a/config/default.json +++ b/config/default.json @@ -59,5 +59,6 @@ "accountsAppUrl": "https://accounts.topcoder-dev.com", "MAX_REVISION_NUMBER": 100, "UNIQUE_GMAIL_VALIDATION": false, - "SSO_REFCODES": "[]" + "SSO_REFCODES": "[]", + "VALID_STATUSES_BEFORE_PAUSED": "[\"active\"]" } diff --git a/migrations/20190502_status_history_create.sql b/migrations/20190502_status_history_create.sql new file mode 100644 index 00000000..cac38750 --- /dev/null +++ b/migrations/20190502_status_history_create.sql @@ -0,0 +1,29 @@ +-- +-- Create table status history +-- + +CREATE TABLE status_history ( + id bigint, + "reference" character varying(45) NOT NULL, + "referenceId" bigint NOT NULL, + "status" character varying(45) NOT NULL, + "comment" text, + "createdAt" timestamp with time zone, + "updatedAt" timestamp with time zone, + "createdBy" integer NOT NULL, + "updatedBy" integer NOT NULL +); + +CREATE SEQUENCE status_history_id_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + +ALTER SEQUENCE status_history_id_seq OWNED BY status_history.id; + +ALTER TABLE ONLY status_history ALTER COLUMN id SET DEFAULT nextval('status_history_id_seq'::regclass); + +ALTER TABLE ONLY status_history + ADD CONSTRAINT status_history_pkey PRIMARY KEY (id); \ No newline at end of file diff --git a/postman.json b/postman.json index 3e17f076..17ee5a26 100644 --- a/postman.json +++ b/postman.json @@ -1,6 +1,6 @@ { "info": { - "_postman_id": "57206894-511c-4ffb-94bb-e50d2dd416fb", + "_postman_id": "efae2a9b-b869-4965-b889-3278870b29ea", "name": "tc-project-service", "schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json" }, @@ -118,10 +118,6 @@ "value": "application/json" } ], - "body": { - "mode": "raw", - "raw": "" - }, "url": { "raw": "{{api-url}}/v4/projects/2", "host": [ @@ -242,10 +238,6 @@ "value": "Bearer {{jwt-token}}" } ], - "body": { - "mode": "raw", - "raw": "" - }, "url": { "raw": "{{api-url}}/v4/projects", "host": [ @@ -958,10 +950,6 @@ "value": "Bearer {{jwt-token}}" } ], - "body": { - "mode": "raw", - "raw": "" - }, "url": { "raw": "{{api-url}}/v4/projects/7", "host": [ @@ -987,10 +975,6 @@ "value": "Bearer {{jwt-token}}" } ], - "body": { - "mode": "raw", - "raw": "" - }, "url": { "raw": "{{api-url}}/v4/projects/1?fields=id,name,description,members.id,members.projectId", "host": [ @@ -1022,10 +1006,6 @@ "value": "Bearer {{jwt-token}}" } ], - "body": { - "mode": "raw", - "raw": "" - }, "url": { "raw": "{{api-url}}/v4/projects/db", "host": [ @@ -1051,10 +1031,6 @@ "value": "Bearer {{jwt-token}}" } ], - "body": { - "mode": "raw", - "raw": "" - }, "url": { "raw": "{{api-url}}/v4/projects/db?limit=1&offset=1", "host": [ @@ -1090,10 +1066,6 @@ "value": "Bearer {{jwt-token}}" } ], - "body": { - "mode": "raw", - "raw": "" - }, "url": { "raw": "{{api-url}}/v4/projects/db?filter=type%3Dgeneric", "host": [ @@ -1125,10 +1097,6 @@ "value": "Bearer {{jwt-token}}" } ], - "body": { - "mode": "raw", - "raw": "" - }, "url": { "raw": "{{api-url}}/v4/projects/db?sort=type%20desc", "host": [ @@ -1160,10 +1128,6 @@ "value": "Bearer {{jwt-token}}" } ], - "body": { - "mode": "raw", - "raw": "" - }, "url": { "raw": "{{api-url}}/v4/projects/db?fields=id,name,description", "host": [ @@ -1195,10 +1159,6 @@ "value": "Bearer {{jwt-token-copilot-40051332}}" } ], - "body": { - "mode": "raw", - "raw": "" - }, "url": { "raw": "{{api-url}}/v4/projects/db", "host": [ @@ -1223,10 +1183,6 @@ "value": "Bearer {{jwt-token}}" } ], - "body": { - "mode": "raw", - "raw": "" - }, "url": { "raw": "{{api-url}}/v4/projects", "host": [ @@ -1251,10 +1207,6 @@ "value": "Bearer {{jwt-token}}" } ], - "body": { - "mode": "raw", - "raw": "" - }, "url": { "raw": "{{api-url}}/v4/projects?limit=1&offset=1", "host": [ @@ -1289,10 +1241,6 @@ "value": "Bearer {{jwt-token}}" } ], - "body": { - "mode": "raw", - "raw": "" - }, "url": { "raw": "{{api-url}}/v4/projects?filter=type%3Dgeneric", "host": [ @@ -1323,10 +1271,6 @@ "value": "Bearer {{jwt-token}}" } ], - "body": { - "mode": "raw", - "raw": "" - }, "url": { "raw": "{{api-url}}/v4/projects?sort=type%20desc", "host": [ @@ -1357,10 +1301,6 @@ "value": "Bearer {{jwt-token}}" } ], - "body": { - "mode": "raw", - "raw": "" - }, "url": { "raw": "{{api-url}}/v4/projects?fields=id,name,description", "host": [ @@ -1391,10 +1331,6 @@ "value": "Bearer {{jwt-token-copilot-40051332}}" } ], - "body": { - "mode": "raw", - "raw": "" - }, "url": { "raw": "{{api-url}}/v4/projects", "host": [ @@ -1982,10 +1918,6 @@ "value": "application/json" } ], - "body": { - "mode": "raw", - "raw": "" - }, "url": { "raw": "https://api.topcoder-dev.com/v3/direct/projects", "protocol": "https", @@ -2340,10 +2272,6 @@ "value": "application/json" } ], - "body": { - "mode": "raw", - "raw": "" - }, "url": { "raw": "{{api-url}}/v4/projects/1/phases/db", "host": [ @@ -2374,10 +2302,6 @@ "value": "application/json" } ], - "body": { - "mode": "raw", - "raw": "" - }, "url": { "raw": "{{api-url}}/v4/projects/1/phases/db?fields=status,name,budget", "host": [ @@ -2414,10 +2338,6 @@ "value": "application/json" } ], - "body": { - "mode": "raw", - "raw": "" - }, "url": { "raw": "{{api-url}}/v4/projects/1/phases/db?sort=status desc", "host": [ @@ -2454,10 +2374,6 @@ "value": "application/json" } ], - "body": { - "mode": "raw", - "raw": "" - }, "url": { "raw": "{{api-url}}/v4/projects/1/phases/db?sort=order desc", "host": [ @@ -2494,10 +2410,6 @@ "value": "application/json" } ], - "body": { - "mode": "raw", - "raw": "" - }, "url": { "raw": "{{api-url}}/v4/projects/1/phases", "host": [ @@ -2527,10 +2439,6 @@ "value": "application/json" } ], - "body": { - "mode": "raw", - "raw": "" - }, "url": { "raw": "{{api-url}}/v4/projects/1/phases?fields=status,name,budget", "host": [ @@ -2566,10 +2474,6 @@ "value": "application/json" } ], - "body": { - "mode": "raw", - "raw": "" - }, "url": { "raw": "{{api-url}}/v4/projects/1/phases?sort=status desc", "host": [ @@ -2605,10 +2509,6 @@ "value": "application/json" } ], - "body": { - "mode": "raw", - "raw": "" - }, "url": { "raw": "{{api-url}}/v4/projects/1/phases?sort=order desc", "host": [ @@ -2644,10 +2544,6 @@ "value": "application/json" } ], - "body": { - "mode": "raw", - "raw": "" - }, "url": { "raw": "{{api-url}}/v4/projects/1/phases/1", "host": [ @@ -2816,10 +2712,6 @@ "value": "Bearer {{jwt-token}}" } ], - "body": { - "mode": "raw", - "raw": "" - }, "url": { "raw": "{{api-url}}/v4/projects/1/phases/1/products/db", "host": [ @@ -2848,10 +2740,6 @@ "value": "Bearer {{jwt-token}}" } ], - "body": { - "mode": "raw", - "raw": "" - }, "url": { "raw": "{{api-url}}/v4/projects/1/phases/1/products", "host": [ @@ -2879,10 +2767,6 @@ "value": "Bearer {{jwt-token}}" } ], - "body": { - "mode": "raw", - "raw": "" - }, "url": { "raw": "{{api-url}}/v4/projects/1/phases/1/products/1", "host": [ @@ -3153,10 +3037,6 @@ "value": "Bearer {{jwt-token}}" } ], - "body": { - "mode": "raw", - "raw": "" - }, "url": { "raw": "{{api-url}}/v4/projects/metadata/projectTemplates", "host": [ @@ -3186,10 +3066,6 @@ "value": "Bearer {{jwt-token}}" } ], - "body": { - "mode": "raw", - "raw": "" - }, "url": { "raw": "{{api-url}}/v4/projects/metadata/projectTemplates/1", "host": [ @@ -3598,10 +3474,6 @@ "value": "Bearer {{jwt-token}}" } ], - "body": { - "mode": "raw", - "raw": "" - }, "url": { "raw": "{{api-url}}/v4/projects/metadata/productTemplates", "host": [ @@ -3631,10 +3503,6 @@ "value": "Bearer {{jwt-token}}" } ], - "body": { - "mode": "raw", - "raw": "" - }, "url": { "raw": "{{api-url}}/v4/projects/metadata/productTemplates/3", "host": [ @@ -3882,10 +3750,6 @@ "value": "Bearer {{jwt-token}}" } ], - "body": { - "mode": "raw", - "raw": "" - }, "url": { "raw": "{{api-url}}/v4/projects/metadata/projectTypes", "host": [ @@ -3915,10 +3779,6 @@ "value": "Bearer {{jwt-token}}" } ], - "body": { - "mode": "raw", - "raw": "" - }, "url": { "raw": "{{api-url}}/v4/projects/metadata/projectTypes/generic", "host": [ @@ -4055,10 +3915,6 @@ "value": "Bearer {{jwt-token}}" } ], - "body": { - "mode": "raw", - "raw": "" - }, "url": { "raw": "{{api-url}}/v4/projects/metadata/productCategories", "host": [ @@ -4088,10 +3944,6 @@ "value": "Bearer {{jwt-token}}" } ], - "body": { - "mode": "raw", - "raw": "" - }, "url": { "raw": "{{api-url}}/v4/projects/metadata/productCategories/generic", "host": [ @@ -4458,10 +4310,6 @@ "value": "Bearer {{jwt-token-copilot-40051332}}" } ], - "body": { - "mode": "raw", - "raw": "" - }, "url": { "raw": "{{api-url}}/v4/timelines?filter=reference%3Dphase%26referenceId%3D1", "host": [ @@ -4495,10 +4343,6 @@ "value": "Bearer {{jwt-token}}" } ], - "body": { - "mode": "raw", - "raw": "" - }, "url": { "raw": "{{api-url}}/v4/timelines/1", "host": [ @@ -4726,10 +4570,6 @@ "value": "Bearer {{jwt-token-copilot-40051332}}" } ], - "body": { - "mode": "raw", - "raw": "" - }, "url": { "raw": "{{api-url}}/v4/timelines/1/milestones", "host": [ @@ -4759,10 +4599,6 @@ "value": "Bearer {{jwt-token-copilot-40051332}}" } ], - "body": { - "mode": "raw", - "raw": "" - }, "url": { "raw": "{{api-url}}/v4/timelines/1/milestones?sort=order desc", "host": [ @@ -4798,9 +4634,39 @@ "value": "Bearer {{jwt-token}}" } ], + "url": { + "raw": "{{api-url}}/v4/timelines/1/milestones/1", + "host": [ + "{{api-url}}" + ], + "path": [ + "v4", + "timelines", + "1", + "milestones", + "1" + ] + } + }, + "response": [] + }, + { + "name": "Update milestone", + "request": { + "method": "PATCH", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + }, + { + "key": "Authorization", + "value": "Bearer {{jwt-token}}" + } + ], "body": { "mode": "raw", - "raw": "" + "raw": "{\r\n \"param\":{\r\n \"name\": \"milestone 1-updated\",\r\n \"description\": \"description-updated\",\r\n \"duration\": 3,\r\n \"completionDate\": \"2018-09-28T00:00:00.000Z\",\r\n \"status\": \"closed\",\r\n \"type\": \"type2\",\r\n \"details\": {\r\n \"detail1\": {\r\n \"subDetail1C\": 3\r\n },\r\n \"detail2\": [\r\n 4\r\n ]\r\n },\r\n \"order\": 1,\r\n \"plannedText\": \"plannedText 1-updated\",\r\n \"activeText\": \"activeText 1-updated\",\r\n \"completedText\": \"completedText 1-updated\",\r\n \"blockedText\": \"blockedText 1-updated\"\r\n }\r\n}" }, "url": { "raw": "{{api-url}}/v4/timelines/1/milestones/1", @@ -4819,7 +4685,7 @@ "response": [] }, { - "name": "Update milestone", + "name": "Update milestone - paused", "request": { "method": "PATCH", "header": [ @@ -4834,7 +4700,41 @@ ], "body": { "mode": "raw", - "raw": "{\r\n \"param\":{\r\n \"name\": \"milestone 1-updated\",\r\n \"description\": \"description-updated\",\r\n \"duration\": 3,\r\n \"completionDate\": \"2018-09-28T00:00:00.000Z\",\r\n \"status\": \"closed\",\r\n \"type\": \"type2\",\r\n \"details\": {\r\n \"detail1\": {\r\n \"subDetail1C\": 3\r\n },\r\n \"detail2\": [\r\n 4\r\n ]\r\n },\r\n \"order\": 1,\r\n \"plannedText\": \"plannedText 1-updated\",\r\n \"activeText\": \"activeText 1-updated\",\r\n \"completedText\": \"completedText 1-updated\",\r\n \"blockedText\": \"blockedText 1-updated\"\r\n }\r\n}" + "raw": "{\r\n \"param\":{\r\n \"name\": \"milestone 1-updated\",\r\n \"description\": \"description-updated\",\r\n \"status\": \"paused\",\r\n \"details\": {\r\n \"detail1\": {\r\n \"subDetail1C\": 3\r\n },\r\n \"detail2\": [\r\n 4\r\n ]\r\n },\r\n \"statusComment\": \"milestone paused\"\r\n }\r\n}" + }, + "url": { + "raw": "{{api-url}}/v4/timelines/1/milestones/1", + "host": [ + "{{api-url}}" + ], + "path": [ + "v4", + "timelines", + "1", + "milestones", + "1" + ] + } + }, + "response": [] + }, + { + "name": "Update milestone - resume", + "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\": \"milestone 1-updated\",\r\n \"description\": \"description-updated\",\r\n \"status\": \"resume\",\r\n \"details\": {\r\n \"detail1\": {\r\n \"subDetail1C\": 3\r\n },\r\n \"detail2\": [\r\n 4\r\n ]\r\n },\r\n \"statusComment\": \"milestone resume\"\r\n }\r\n}" }, "url": { "raw": "{{api-url}}/v4/timelines/1/milestones/1", @@ -5310,10 +5210,6 @@ "value": "Bearer {{jwt-token-copilot-40051332}}" } ], - "body": { - "mode": "raw", - "raw": "" - }, "url": { "raw": "{{api-url}}/v4/timelines/metadata/milestoneTemplates", "host": [ @@ -5343,10 +5239,6 @@ "value": "Bearer {{jwt-token-copilot-40051332}}" } ], - "body": { - "mode": "raw", - "raw": "" - }, "url": { "raw": "{{api-url}}/v4/timelines/metadata/milestoneTemplates?filter=reference%3DproductTemplate%26referenceId%3D1", "host": [ @@ -5382,10 +5274,6 @@ "value": "Bearer {{jwt-token-copilot-40051332}}" } ], - "body": { - "mode": "raw", - "raw": "" - }, "url": { "raw": "{{api-url}}/v4/timelines/metadata/milestoneTemplates?filter=reference%3DproductTemplate%26referenceId%3D1&sort=order desc", "host": [ @@ -5425,10 +5313,6 @@ "value": "Bearer {{jwt-token}}" } ], - "body": { - "mode": "raw", - "raw": "" - }, "url": { "raw": "{{api-url}}/v4/timelines/metadata/milestoneTemplates/1", "host": [ @@ -5709,10 +5593,6 @@ "type": "text" } ], - "body": { - "mode": "raw", - "raw": "" - }, "url": { "raw": "{{api-url}}/v4/projects/metadata", "host": [ @@ -5742,10 +5622,6 @@ }, "method": "GET", "header": [], - "body": { - "mode": "raw", - "raw": "" - }, "url": { "raw": "{{api-url}}/v4/projects/metadata?includeAllReferred=true", "host": [ @@ -5786,10 +5662,6 @@ }, "method": "GET", "header": [], - "body": { - "mode": "raw", - "raw": "" - }, "url": { "raw": "{{api-url}}/v4/projects/metadata/form/dev/versions", "host": [ @@ -5822,10 +5694,6 @@ }, "method": "GET", "header": [], - "body": { - "mode": "raw", - "raw": "" - }, "url": { "raw": "{{api-url}}/v4/projects/metadata/form/dev/versions/1", "host": [ @@ -5859,10 +5727,6 @@ }, "method": "GET", "header": [], - "body": { - "mode": "raw", - "raw": "" - }, "url": { "raw": "{{api-url}}/v4/projects/metadata/form/dev", "host": [ @@ -6030,10 +5894,6 @@ }, "method": "GET", "header": [], - "body": { - "mode": "raw", - "raw": "" - }, "url": { "raw": "{{api-url}}/v4/projects/metadata/form/dev/versions/1/revisions", "host": [ @@ -6068,10 +5928,6 @@ }, "method": "GET", "header": [], - "body": { - "mode": "raw", - "raw": "" - }, "url": { "raw": "{{api-url}}/v4/projects/metadata/form/dev/versions/1/revisions/1", "host": [ @@ -6248,10 +6104,6 @@ }, "method": "GET", "header": [], - "body": { - "mode": "raw", - "raw": "" - }, "url": { "raw": "{{api-url}}/v4/projects/metadata/priceConfig/dev/versions", "host": [ @@ -6284,10 +6136,6 @@ }, "method": "GET", "header": [], - "body": { - "mode": "raw", - "raw": "" - }, "url": { "raw": "{{api-url}}/v4/projects/metadata/priceConfig/dev/versions/1", "host": [ @@ -6321,10 +6169,6 @@ }, "method": "GET", "header": [], - "body": { - "mode": "raw", - "raw": "" - }, "url": { "raw": "{{api-url}}/v4/projects/metadata/priceConfig/dev", "host": [ @@ -6514,10 +6358,6 @@ }, "method": "GET", "header": [], - "body": { - "mode": "raw", - "raw": "" - }, "url": { "raw": "{{api-url}}/v4/projects/metadata/priceConfig/dev/versions/3/revisions", "host": [ @@ -6552,10 +6392,6 @@ }, "method": "GET", "header": [], - "body": { - "mode": "raw", - "raw": "" - }, "url": { "raw": "{{api-url}}/v4/projects/metadata/priceConfig/dev/versions/1/revisions/1", "host": [ @@ -6732,10 +6568,6 @@ }, "method": "GET", "header": [], - "body": { - "mode": "raw", - "raw": "" - }, "url": { "raw": "{{api-url}}/v4/projects/metadata/planConfig/dev/versions", "host": [ @@ -6768,10 +6600,6 @@ }, "method": "GET", "header": [], - "body": { - "mode": "raw", - "raw": "" - }, "url": { "raw": "{{api-url}}/v4/projects/metadata/planConfig/dev/versions/3", "host": [ @@ -6805,10 +6633,6 @@ }, "method": "GET", "header": [], - "body": { - "mode": "raw", - "raw": "" - }, "url": { "raw": "{{api-url}}/v4/projects/metadata/planConfig/dev", "host": [ @@ -6976,10 +6800,6 @@ }, "method": "GET", "header": [], - "body": { - "mode": "raw", - "raw": "" - }, "url": { "raw": "{{api-url}}/v4/projects/metadata/planConfig/dev/versions/1/revisions", "host": [ @@ -7014,10 +6834,6 @@ }, "method": "GET", "header": [], - "body": { - "mode": "raw", - "raw": "" - }, "url": { "raw": "{{api-url}}/v4/projects/metadata/planConfig/dev/versions/1/revisions/1", "host": [ diff --git a/src/constants.js b/src/constants.js index d2b7ee41..f0ac0f21 100644 --- a/src/constants.js +++ b/src/constants.js @@ -131,6 +131,8 @@ export const BUS_API_EVENT = { MILESTONE_TRANSITION_ACTIVE: 'connect.action.timeline.milestone.transition.active', // When milestone is marked as completed MILESTONE_TRANSITION_COMPLETED: 'connect.action.timeline.milestone.transition.completed', + // When milestone is marked as paused + MILESTONE_TRANSITION_PAUSED: 'connect.action.timeline.milestone.transition.paused', // When milestone is waiting for customers's input MILESTONE_WAITING_CUSTOMER: 'connect.action.timeline.milestone.waiting.customer', @@ -163,6 +165,10 @@ export const TIMELINE_REFERENCES = { PRODUCT: 'product', }; +export const STATUS_HISTORY_REFERENCES = { + MILESTONE: 'milestone', +}; + export const MILESTONE_TEMPLATE_REFERENCES = { PRODUCT_TEMPLATE: 'productTemplate', }; diff --git a/src/events/busApi.js b/src/events/busApi.js index 6895cccb..da63bfc0 100644 --- a/src/events/busApi.js +++ b/src/events/busApi.js @@ -529,6 +529,8 @@ module.exports = (app, logger) => { event = BUS_API_EVENT.MILESTONE_TRANSITION_COMPLETED; } else if (updated.status === MILESTONE_STATUS.ACTIVE) { event = BUS_API_EVENT.MILESTONE_TRANSITION_ACTIVE; + } else if (updated.status === MILESTONE_STATUS.PAUSED) { + event = BUS_API_EVENT.MILESTONE_TRANSITION_PAUSED; } if (event) { @@ -592,12 +594,24 @@ module.exports = (app, logger) => { .catch(err => null); // eslint-disable-line no-unused-vars }); - /** + /** * MILESTONE_UPDATED. */ - // eslint-disable-next-line no-unused-vars - app.on(EVENT.ROUTING_KEY.MILESTONE_UPDATED, ({ req, original, updated, cascadedUpdates }) => { - logger.debug(`receive MILESTONE_UPDATED event for milestone ${original.id}`); + + /** + * Handlers for updated milestones which sends events to Kafka + * + * @param {String} eventName event name which causes calling this method + * @param {Object} params params + * @param {Object} params.req request object + * @param {Object} params.original original milestone object + * @param {Object} params.updated updated milestone object + * @param {Object} params.cascadedUpdates milestones updated cascaded + * + * @return {undefined} + */ + function handleMilestoneUpdated(eventName, { req, original, updated, cascadedUpdates }) { + logger.debug(`receive ${eventName} event for milestone ${original.id}`); const projectId = _.parseInt(req.params.projectId); const timeline = _.omit(req.timeline.toJSON(), 'deletedAt', 'deletedBy'); @@ -640,7 +654,9 @@ module.exports = (app, logger) => { } }); }).catch(err => null); // eslint-disable-line no-unused-vars - }); + } + + app.on(EVENT.ROUTING_KEY.MILESTONE_UPDATED, handleMilestoneUpdated.bind(null, EVENT.ROUTING_KEY.MILESTONE_UPDATED)); /** * MILESTONE_REMOVED. diff --git a/src/models/milestone.js b/src/models/milestone.js index 76246a52..ee79c860 100644 --- a/src/models/milestone.js +++ b/src/models/milestone.js @@ -1,6 +1,54 @@ +import _ from 'lodash'; import moment from 'moment'; +import models from '../models'; +import { STATUS_HISTORY_REFERENCES } from '../constants'; /* eslint-disable valid-jsdoc */ +/** + * Populate and map milestone model with statusHistory + * NOTE that this function mutates milestone + * + * @param {Array|Object} milestone one milestone or list of milestones + * @param {Object} options options which has been used to call main method + * + * @returns {Promise} promise + */ +const populateWithStatusHistory = async (milestone, options) => { + // depend on this option `milestone` is a sequlize ORM object or plain JS object + const isRaw = !!_.get(options, 'raw'); + const getMilestoneId = m => ( + isRaw ? m.id : m.dataValues.id + ); + const formatMilestone = statusHistory => ( + isRaw ? { statusHistory } : { dataValues: { statusHistory } } + ); + if (Array.isArray(milestone)) { + const allStatusHistory = await models.StatusHistory.findAll({ + where: { + referenceId: { $in: milestone.map(getMilestoneId) }, + reference: 'milestone', + }, + order: [['createdAt', 'desc']], + raw: true, + }); + + return milestone.map((m, index) => { + const statusHistory = _.filter(allStatusHistory, { referenceId: getMilestoneId(m) }); + return _.merge(milestone[index], formatMilestone(statusHistory)); + }); + } + + const statusHistory = await models.StatusHistory.findAll({ + where: { + referenceId: getMilestoneId(milestone), + reference: 'milestone', + }, + order: [['createdAt', 'desc']], + raw: true, + }); + return _.merge(milestone, formatMilestone(statusHistory)); +}; + /** * The Milestone model */ @@ -82,6 +130,54 @@ module.exports = (sequelize, DataTypes) => { }); }, }, + hooks: { + afterCreate: (milestone, options) => models.StatusHistory.create({ + reference: STATUS_HISTORY_REFERENCES.MILESTONE, + referenceId: milestone.id, + status: milestone.status, + comment: null, + createdBy: milestone.createdBy, + updatedBy: milestone.updatedBy, + }, { + transaction: options.transaction, + }).then(() => populateWithStatusHistory(milestone, options)), + + afterBulkCreate: (milestones, options) => { + const listStatusHistory = milestones.map(({ dataValues }) => ({ + reference: STATUS_HISTORY_REFERENCES.MILESTONE, + referenceId: dataValues.id, + status: dataValues.status, + comment: null, + createdBy: dataValues.createdBy, + updatedBy: dataValues.updatedBy, + })); + + return models.StatusHistory.bulkCreate(listStatusHistory, { + transaction: options.transaction, + }).then(() => populateWithStatusHistory(milestones, options)); + }, + + afterUpdate: (milestone, options) => { + if (milestone.changed().includes('status')) { + return models.StatusHistory.create({ + reference: STATUS_HISTORY_REFERENCES.MILESTONE, + referenceId: milestone.id, + status: milestone.status, + comment: options.comment || null, + createdBy: milestone.createdBy, + updatedBy: milestone.updatedBy, + }, { + transaction: options.transaction, + }).then(() => populateWithStatusHistory(milestone)); + } + return populateWithStatusHistory(milestone, options); + }, + + afterFind: (milestone, options) => { + if (!milestone) return Promise.resolve(); + return populateWithStatusHistory(milestone, options); + }, + }, }); return Milestone; diff --git a/src/models/statusHistory.js b/src/models/statusHistory.js new file mode 100644 index 00000000..b73d3650 --- /dev/null +++ b/src/models/statusHistory.js @@ -0,0 +1,35 @@ +/* eslint-disable valid-jsdoc */ + +import _ from 'lodash'; +import { MILESTONE_STATUS } from '../constants'; + +module.exports = function defineStatusHistory(sequelize, DataTypes) { + const StatusHistory = sequelize.define('StatusHistory', { + id: { type: DataTypes.BIGINT, primaryKey: true, autoIncrement: true }, + reference: { type: DataTypes.STRING, allowNull: false }, + referenceId: { type: DataTypes.BIGINT, allowNull: false }, + status: { + type: DataTypes.STRING, + allowNull: false, + validate: { + isIn: [_.values(MILESTONE_STATUS)], + }, + }, + comment: DataTypes.TEXT, + createdBy: { type: DataTypes.INTEGER, allowNull: false }, + createdAt: { type: DataTypes.DATE, defaultValue: DataTypes.NOW }, + updatedBy: { type: DataTypes.INTEGER, allowNull: false }, + updatedAt: { type: DataTypes.DATE, defaultValue: DataTypes.NOW }, + }, { + tableName: 'status_history', + paranoid: false, + timestamps: true, + updatedAt: 'updatedAt', + createdAt: 'createdAt', + deletedAt: 'deletedAt', + indexes: [], + classMethods: {}, + }); + + return StatusHistory; +}; diff --git a/src/routes/milestones/create.spec.js b/src/routes/milestones/create.spec.js index ab1424ca..ec04afda 100644 --- a/src/routes/milestones/create.spec.js +++ b/src/routes/milestones/create.spec.js @@ -142,11 +142,12 @@ describe('CREATE milestone', () => { // Create milestones models.Milestone.bulkCreate([ { + id: 11, timelineId: 1, name: 'milestone 1', duration: 2, startDate: '2018-05-03T00:00:00.000Z', - status: 'open', + status: 'draft', type: 'type1', details: { detail1: { @@ -164,11 +165,12 @@ describe('CREATE milestone', () => { updatedBy: 2, }, { + id: 12, timelineId: 1, name: 'milestone 2', duration: 3, startDate: '2018-05-04T00:00:00.000Z', - status: 'open', + status: 'draft', type: 'type2', order: 2, plannedText: 'plannedText 2', @@ -179,11 +181,12 @@ describe('CREATE milestone', () => { updatedBy: 3, }, { + id: 13, timelineId: 1, name: 'milestone 3', duration: 4, startDate: '2018-05-04T00:00:00.000Z', - status: 'open', + status: 'draft', type: 'type3', order: 3, plannedText: 'plannedText 3', @@ -211,7 +214,7 @@ describe('CREATE milestone', () => { startDate: '2018-05-05T00:00:00.000Z', endDate: '2018-05-07T00:00:00.000Z', completionDate: '2018-05-08T00:00:00.000Z', - status: 'open', + status: 'draft', type: 'type4', details: { detail1: { @@ -523,6 +526,15 @@ describe('CREATE milestone', () => { should.not.exist(resJson.deletedBy); should.not.exist(resJson.deletedAt); + // validate statusHistory + should.exist(resJson.statusHistory); + resJson.statusHistory.should.be.an('array'); + resJson.statusHistory.length.should.be.eql(1); + resJson.statusHistory.forEach((statusHistory) => { + statusHistory.reference.should.be.eql('milestone'); + statusHistory.referenceId.should.be.eql(resJson.id); + }); + // eslint-disable-next-line no-unused-expressions server.services.pubsub.publish.calledWith(EVENT.ROUTING_KEY.MILESTONE_ADDED).should.be.true; @@ -530,11 +542,11 @@ describe('CREATE milestone', () => { models.Milestone.findAll({ where: { timelineId: 1 } }) .then((milestones) => { _.each(milestones, (milestone) => { - if (milestone.id === 1) { + if (milestone.id === 11) { milestone.order.should.be.eql(1); - } else if (milestone.id === 2) { + } else if (milestone.id === 12) { milestone.order.should.be.eql(2 + 1); - } else if (milestone.id === 3) { + } else if (milestone.id === 13) { milestone.order.should.be.eql(3 + 1); } }); diff --git a/src/routes/milestones/delete.spec.js b/src/routes/milestones/delete.spec.js index c756b7b0..5b237d1d 100644 --- a/src/routes/milestones/delete.spec.js +++ b/src/routes/milestones/delete.spec.js @@ -160,6 +160,7 @@ describe('DELETE milestone', () => { // Create milestones models.Milestone.bulkCreate([ { + id: 1, timelineId: 1, name: 'milestone 1', duration: 2, @@ -182,6 +183,7 @@ describe('DELETE milestone', () => { updatedBy: 2, }, { + id: 2, timelineId: 1, name: 'milestone 2', duration: 3, @@ -197,6 +199,7 @@ describe('DELETE milestone', () => { updatedBy: 3, }, { + id: 3, timelineId: 1, name: 'milestone 3', duration: 4, diff --git a/src/routes/milestones/get.spec.js b/src/routes/milestones/get.spec.js index fb0451d1..5ae3adf9 100644 --- a/src/routes/milestones/get.spec.js +++ b/src/routes/milestones/get.spec.js @@ -133,6 +133,7 @@ describe('GET milestone', () => { // Create milestones models.Milestone.bulkCreate([ { + id: 1, timelineId: 1, name: 'milestone 1', duration: 2, @@ -155,6 +156,7 @@ describe('GET milestone', () => { updatedBy: 2, }, { + id: 2, timelineId: 1, name: 'milestone 2', duration: 3, @@ -170,6 +172,7 @@ describe('GET milestone', () => { updatedBy: 3, }, { + id: 3, timelineId: 1, name: 'milestone 3', duration: 4, @@ -301,6 +304,15 @@ describe('GET milestone', () => { should.not.exist(resJson.deletedBy); should.not.exist(resJson.deletedAt); + // validate statusHistory + should.exist(resJson.statusHistory); + resJson.statusHistory.should.be.an('array'); + resJson.statusHistory.length.should.be.eql(1); + resJson.statusHistory.forEach((statusHistory) => { + statusHistory.reference.should.be.eql('milestone'); + statusHistory.referenceId.should.be.eql(resJson.id); + }); + done(); }); }); diff --git a/src/routes/milestones/list.spec.js b/src/routes/milestones/list.spec.js index 21c07336..5ab96537 100644 --- a/src/routes/milestones/list.spec.js +++ b/src/routes/milestones/list.spec.js @@ -5,6 +5,7 @@ import chai from 'chai'; import request from 'supertest'; import sleep from 'sleep'; import config from 'config'; +import _ from 'lodash'; import models from '../../models'; import server from '../../app'; @@ -50,6 +51,7 @@ const milestones = [ detail2: [1, 2, 3], }, order: 1, + hidden: false, plannedText: 'plannedText 1', activeText: 'activeText 1', completedText: 'completedText 1', @@ -68,6 +70,7 @@ const milestones = [ status: 'open', type: 'type2', order: 2, + hidden: false, plannedText: 'plannedText 2', activeText: 'activeText 2', completedText: 'completedText 2', @@ -79,7 +82,7 @@ const milestones = [ }, ]; -describe('LIST timelines', () => { +describe('LIST milestones', () => { before(function beforeHook(done) { this.timeout(10000); testUtil.clearDb() @@ -162,13 +165,12 @@ describe('LIST timelines', () => { updatedBy: 2, }, ])) - .then(() => - // Create timelines and milestones - models.Timeline.bulkCreate(timelines) - .then(() => models.Milestone.bulkCreate(milestones))) - .then(() => { + // Create timelines and milestones + .then(() => models.Timeline.bulkCreate(timelines)) + .then(() => models.Milestone.bulkCreate(milestones)) + .then((createdMilestones) => { // Index to ES - timelines[0].milestones = milestones; + timelines[0].milestones = _.map(createdMilestones, cm => _.omit(cm.toJSON(), 'deletedAt', 'deletedBy')); timelines[0].projectId = 1; return server.services.es.index({ index: ES_TIMELINE_INDEX, @@ -242,8 +244,18 @@ describe('LIST timelines', () => { const resJson = res.body.result.content; resJson.should.have.length(2); - resJson[0].should.be.eql(milestones[0]); - resJson[1].should.be.eql(milestones[1]); + resJson.forEach((milestone, index) => { + milestone.statusHistory.should.be.an('array'); + milestone.statusHistory.length.should.be.eql(1); + milestone.statusHistory.forEach((statusHistory) => { + statusHistory.reference.should.be.eql('milestone'); + statusHistory.referenceId.should.be.eql(milestone.id); + }); + + const m = _.omit(milestone, ['statusHistory']); + + m.should.be.eql(milestones[index]); + }); done(); }); @@ -318,8 +330,10 @@ describe('LIST timelines', () => { const resJson = res.body.result.content; resJson.should.have.length(2); - resJson[0].should.be.eql(milestones[1]); - resJson[1].should.be.eql(milestones[0]); + const m1 = _.omit(resJson[0], ['statusHistory']); + const m2 = _.omit(resJson[1], ['statusHistory']); + m1.should.be.eql(milestones[1]); + m2.should.be.eql(milestones[0]); done(); }); diff --git a/src/routes/milestones/update.js b/src/routes/milestones/update.js index 98a5cdf1..4b68e72d 100644 --- a/src/routes/milestones/update.js +++ b/src/routes/milestones/update.js @@ -4,6 +4,7 @@ import validate from 'express-validation'; import _ from 'lodash'; import moment from 'moment'; +import config from 'config'; import Joi from 'joi'; import Sequelize from 'sequelize'; import { middleware as tcMiddleware } from 'tc-core-library-js'; @@ -111,6 +112,7 @@ const schema = { completedText: Joi.string().max(512).optional(), blockedText: Joi.string().max(512).optional(), hidden: Joi.boolean().optional(), + statusComment: Joi.string().when('status', { is: 'paused', then: Joi.required(), otherwise: Joi.optional() }), createdAt: Joi.any().strip(), updatedAt: Joi.any().strip(), deletedAt: Joi.any().strip(), @@ -146,13 +148,42 @@ module.exports = [ return models.sequelize.transaction(() => // Find the milestone models.Milestone.findOne({ where }) - .then((milestone) => { + .then(async (milestone) => { // Not found if (!milestone) { const apiErr = new Error(`Milestone not found for milestone id ${req.params.milestoneId}`); apiErr.status = 404; return Promise.reject(apiErr); } + const validStatuses = JSON.parse(config.get('VALID_STATUSES_BEFORE_PAUSED')); + if (entityToUpdate.status === MILESTONE_STATUS.PAUSED && !validStatuses.includes(milestone.status)) { + const validStatutesStr = validStatuses.join(', '); + const apiErr = new Error(`Milestone can only be paused from the next statuses: ${validStatutesStr}`); + apiErr.status = 422; + return Promise.reject(apiErr); + } + + if (entityToUpdate.status === 'resume') { + if (milestone.status !== MILESTONE_STATUS.PAUSED) { + const apiErr = new Error('Milestone status isn\'t paused'); + apiErr.status = 422; + return Promise.reject(apiErr); + } + const statusHistory = await models.StatusHistory.findAll({ + where: { referenceId: milestone.id }, + order: [['createdAt', 'desc'], ['id', 'desc']], + attributes: ['status', 'id'], + limit: 2, + raw: true, + }); + if (statusHistory.length === 2) { + entityToUpdate.status = statusHistory[1].status; + } else { + const apiErr = new Error('No previous status is found'); + apiErr.status = 500; + return Promise.reject(apiErr); + } + } if (entityToUpdate.completionDate && entityToUpdate.completionDate < milestone.startDate) { const apiErr = new Error('The milestone completionDate should be greater or equal than the startDate.'); @@ -207,7 +238,7 @@ module.exports = [ } // Update - return milestone.update(entityToUpdate); + return milestone.update(entityToUpdate, { comment: entityToUpdate.statusComment }); }) .then((updatedMilestone) => { // Omit deletedAt, deletedBy diff --git a/src/routes/milestones/update.spec.js b/src/routes/milestones/update.spec.js index 9a694e73..382d2eab 100644 --- a/src/routes/milestones/update.spec.js +++ b/src/routes/milestones/update.spec.js @@ -141,7 +141,7 @@ describe('UPDATE Milestone', () => { startDate: '2018-05-13T00:00:00.000Z', endDate: '2018-05-14T00:00:00.000Z', completionDate: '2018-05-15T00:00:00.000Z', - status: 'open', + status: 'active', type: 'type1', details: { detail1: { @@ -166,7 +166,7 @@ describe('UPDATE Milestone', () => { name: 'Milestone 2', duration: 3, startDate: '2018-05-14T00:00:00.000Z', - status: 'open', + status: 'reviewed', type: 'type2', order: 2, plannedText: 'plannedText 2', @@ -184,7 +184,7 @@ describe('UPDATE Milestone', () => { name: 'Milestone 3', duration: 3, startDate: '2018-05-14T00:00:00.000Z', - status: 'open', + status: 'active', type: 'type3', order: 3, plannedText: 'plannedText 3', @@ -202,7 +202,7 @@ describe('UPDATE Milestone', () => { name: 'Milestone 4', duration: 3, startDate: '2018-05-14T00:00:00.000Z', - status: 'open', + status: 'active', type: 'type4', order: 4, plannedText: 'plannedText 4', @@ -220,7 +220,7 @@ describe('UPDATE Milestone', () => { name: 'Milestone 5', duration: 3, startDate: '2018-05-14T00:00:00.000Z', - status: 'open', + status: 'active', type: 'type5', order: 5, plannedText: 'plannedText 5', @@ -239,7 +239,7 @@ describe('UPDATE Milestone', () => { name: 'Milestone 6', duration: 3, startDate: '2018-05-14T00:00:00.000Z', - status: 'open', + status: 'active', type: 'type5', order: 1, plannedText: 'plannedText 6', @@ -252,7 +252,7 @@ describe('UPDATE Milestone', () => { updatedAt: '2018-05-11T00:00:00.000Z', }, ]))) - .then(() => done()); + .then(() => done()); }); }); }); @@ -266,7 +266,7 @@ describe('UPDATE Milestone', () => { duration: 3, completionDate: '2018-05-16T00:00:00.000Z', description: 'description-updated', - status: 'closed', + status: 'draft', type: 'type1-updated', details: { detail1: { @@ -524,6 +524,15 @@ describe('UPDATE Milestone', () => { should.not.exist(resJson.deletedBy); should.not.exist(resJson.deletedAt); + // validate statusHistory + should.exist(resJson.statusHistory); + resJson.statusHistory.should.be.an('array'); + resJson.statusHistory.length.should.be.eql(2); + resJson.statusHistory.forEach((statusHistory) => { + statusHistory.reference.should.be.eql('milestone'); + statusHistory.referenceId.should.be.eql(resJson.id); + }); + // eslint-disable-next-line no-unused-expressions server.services.pubsub.publish.calledWith(EVENT.ROUTING_KEY.MILESTONE_UPDATED).should.be.true; @@ -713,7 +722,7 @@ describe('UPDATE Milestone', () => { name: 'Milestone 7', duration: 3, startDate: '2018-05-14T00:00:00.000Z', - status: 'open', + status: 'active', type: 'type7', order: 3, plannedText: 'plannedText 7', @@ -731,7 +740,7 @@ describe('UPDATE Milestone', () => { name: 'Milestone 8', duration: 3, startDate: '2018-05-14T00:00:00.000Z', - status: 'open', + status: 'active', type: 'type7', order: 4, plannedText: 'plannedText 8', @@ -786,7 +795,7 @@ describe('UPDATE Milestone', () => { name: 'Milestone 7', duration: 3, startDate: '2018-05-14T00:00:00.000Z', - status: 'open', + status: 'active', type: 'type7', order: 2, plannedText: 'plannedText 7', @@ -804,7 +813,7 @@ describe('UPDATE Milestone', () => { name: 'Milestone 8', duration: 3, startDate: '2018-05-14T00:00:00.000Z', - status: 'open', + status: 'active', type: 'type7', order: 4, plannedText: 'plannedText 8', @@ -1085,6 +1094,146 @@ describe('UPDATE Milestone', () => { .end(done); }); + it('should return 422 if try to pause and statusComment is missed', (done) => { + const newBody = _.cloneDeep(body); + newBody.param.status = 'paused'; + request(server) + .patch('/v4/timelines/1/milestones/1') + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .send(newBody) + .expect(422, done); + }); + + it('should return 422 if try to pause not active milestone', (done) => { + const newBody = _.cloneDeep(body); + newBody.param.status = 'paused'; + newBody.param.statusComment = 'milestone paused'; + request(server) + .patch('/v4/timelines/1/milestones/2') + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .send(newBody) + .expect(422, done); + }); + + it('should return 200 if try to pause and should have one status history created', (done) => { + const newBody = _.cloneDeep(body); + newBody.param.status = 'paused'; + newBody.param.statusComment = 'milestone paused'; + request(server) + .patch('/v4/timelines/1/milestones/1') + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .send(newBody) + .expect(200) + .end((err) => { + if (err) { + done(err); + } else { + models.Milestone.findById(1).then((milestone) => { + milestone.status.should.be.eql('paused'); + return models.StatusHistory.findAll({ + where: { + reference: 'milestone', + referenceId: milestone.id, + status: milestone.status, + comment: 'milestone paused', + }, + paranoid: false, + }).then((statusHistories) => { + statusHistories.length.should.be.eql(1); + done(); + }); + }); + } + }); + }); + + it('should return 422 if try to resume not paused milestone', (done) => { + const newBody = _.cloneDeep(body); + newBody.param.status = 'resume'; + request(server) + .patch('/v4/timelines/1/milestones/2') + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .send(newBody) + .expect(422, done); + }); + + it('should return 200 if try to resume then status should update to last status and ' + + 'should have one status history created', (done) => { + const newBody = _.cloneDeep(body); + newBody.param.status = 'resume'; + newBody.param.statusComment = 'new comment'; + models.Milestone.bulkCreate([ + { + id: 7, + timelineId: 1, + name: 'Milestone 1 [paused]', + duration: 2, + startDate: '2018-05-13T00:00:00.000Z', + endDate: '2018-05-14T00:00:00.000Z', + completionDate: '2018-05-16T00:00:00.000Z', + status: 'active', + type: 'type1', + details: { + detail1: { + subDetail1A: 1, + subDetail1B: 2, + }, + detail2: [1, 2, 3], + }, + order: 1, + plannedText: 'plannedText 1', + activeText: 'activeText 1', + completedText: 'completedText 1', + blockedText: 'blockedText 1', + createdBy: 1, + updatedBy: 2, + createdAt: '2018-05-11T00:00:00.000Z', + updatedAt: '2018-05-11T00:00:00.000Z', + }, + ]).then(() => models.Milestone.findById(7) + // pause milestone before resume + .then(milestone => milestone.update(_.assign({}, milestone.toJSON(), { status: 'paused' }))), + ).then(() => { + request(server) + .patch('/v4/timelines/1/milestones/7') + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .send(newBody) + .expect(200) + .end((err) => { + if (err) { + done(err); + } else { + models.Milestone.findById(7).then((milestone) => { + milestone.status.should.be.eql('active'); + + return models.StatusHistory.findAll({ + where: { + reference: 'milestone', + referenceId: milestone.id, + status: 'active', + comment: 'new comment', + }, + paranoid: false, + }).then((statusHistories) => { + statusHistories.length.should.be.eql(1); + done(); + }).catch(done); + }).catch(done); + } + }); + }); + }); + describe('Bus api', () => { let createEventSpy; const sandbox = sinon.sandbox.create(); diff --git a/src/routes/timelines/create.js b/src/routes/timelines/create.js index 44ec10be..a37b7adb 100644 --- a/src/routes/timelines/create.js +++ b/src/routes/timelines/create.js @@ -51,9 +51,9 @@ module.exports = [ let result; // Save to DB - models.sequelize.transaction(() => { + models.sequelize.transaction((tx) => { req.log.debug('Started transaction'); - return models.Timeline.create(entity) + return models.Timeline.create(entity, { transaction: tx }) .then((createdEntity) => { // Omit deletedAt, deletedBy result = _.omit(createdEntity.toJSON(), 'deletedAt', 'deletedBy'); @@ -97,7 +97,7 @@ module.exports = [ } return milestone; }); - return models.Milestone.bulkCreate(milestones, { returning: true }) + return models.Milestone.bulkCreate(milestones, { returning: true, transaction: tx }) .then((createdMilestones) => { req.log.debug('Milestones created for timeline with template id %d', templateId); result.milestones = _.map(createdMilestones, cm => _.omit(cm.toJSON(), 'deletedAt', 'deletedBy')); diff --git a/src/routes/timelines/create.spec.js b/src/routes/timelines/create.spec.js index 20c88ed8..c6ab1bc8 100644 --- a/src/routes/timelines/create.spec.js +++ b/src/routes/timelines/create.spec.js @@ -529,6 +529,15 @@ describe('CREATE timeline', () => { should.exist(milestone.updatedAt); should.not.exist(milestone.deletedBy); should.not.exist(milestone.deletedAt); + + // validate statusHistory + should.exist(milestone.statusHistory); + milestone.statusHistory.should.be.an('array'); + milestone.statusHistory.length.should.be.eql(1); + milestone.statusHistory.forEach((statusHistory) => { + statusHistory.reference.should.be.eql('milestone'); + statusHistory.referenceId.should.be.eql(milestone.id); + }); }); // eslint-disable-next-line no-unused-expressions diff --git a/src/routes/timelines/delete.spec.js b/src/routes/timelines/delete.spec.js index 68c505b2..f609397e 100644 --- a/src/routes/timelines/delete.spec.js +++ b/src/routes/timelines/delete.spec.js @@ -159,6 +159,7 @@ describe('DELETE timeline', () => { // Create milestones models.Milestone.bulkCreate([ { + id: 1, timelineId: 1, name: 'milestone 1', duration: 2, @@ -181,6 +182,7 @@ describe('DELETE timeline', () => { updatedBy: 2, }, { + id: 2, timelineId: 1, name: 'milestone 2', duration: 2, diff --git a/src/routes/timelines/get.spec.js b/src/routes/timelines/get.spec.js index 2331295c..da22d117 100644 --- a/src/routes/timelines/get.spec.js +++ b/src/routes/timelines/get.spec.js @@ -262,6 +262,16 @@ describe('GET timeline', () => { // Milestones resJson.milestones.should.have.length(2); + resJson.milestones.forEach((milestone) => { + // validate statusHistory + should.exist(milestone.statusHistory); + milestone.statusHistory.should.be.an('array'); + milestone.statusHistory.length.should.be.eql(1); + milestone.statusHistory.forEach((statusHistory) => { + statusHistory.reference.should.be.eql('milestone'); + statusHistory.referenceId.should.be.eql(milestone.id); + }); + }); done(); }); diff --git a/src/routes/timelines/list.spec.js b/src/routes/timelines/list.spec.js index 2c1395c8..9da02b3b 100644 --- a/src/routes/timelines/list.spec.js +++ b/src/routes/timelines/list.spec.js @@ -182,22 +182,31 @@ describe('LIST timelines', () => { ])) .then(() => // Create timelines - models.Timeline.bulkCreate(timelines, { returning: true })) - .then(createdTimelines => + models.Timeline.bulkCreate(timelines, { returning: true }) + .then(createdTimelines => ( + // create milestones after timelines + models.Milestone.bulkCreate(milestones)) + .then(createdMilestones => [createdTimelines, createdMilestones]), + ), + ).then(([createdTimelines, createdMilestones]) => // Index to ES - Promise.all(_.map(createdTimelines, (createdTimeline) => { - const timelineJson = _.omit(createdTimeline.toJSON(), 'deletedAt', 'deletedBy'); - timelineJson.projectId = createdTimeline.id !== 3 ? 1 : 2; - if (timelineJson.id === 1) { - timelineJson.milestones = milestones; - } - return server.services.es.index({ - index: ES_TIMELINE_INDEX, - type: ES_TIMELINE_TYPE, - id: timelineJson.id, - body: timelineJson, - }); - })) + Promise.all(_.map(createdTimelines, (createdTimeline) => { + const timelineJson = _.omit(createdTimeline.toJSON(), 'deletedAt', 'deletedBy'); + timelineJson.projectId = createdTimeline.id !== 3 ? 1 : 2; + if (timelineJson.id === 1) { + timelineJson.milestones = _.map( + createdMilestones, + cm => _.omit(cm.toJSON(), 'deletedAt', 'deletedBy'), + ); + } + + return server.services.es.index({ + index: ES_TIMELINE_INDEX, + type: ES_TIMELINE_TYPE, + id: timelineJson.id, + body: timelineJson, + }); + })) .then(() => { // sleep for some time, let elasticsearch indices be settled sleep.sleep(5); @@ -276,6 +285,16 @@ describe('LIST timelines', () => { // Milestones resJson[0].milestones.should.have.length(2); + resJson[0].milestones.forEach((milestone) => { + // validate statusHistory + should.exist(milestone.statusHistory); + milestone.statusHistory.should.be.an('array'); + milestone.statusHistory.length.should.be.eql(1); + milestone.statusHistory.forEach((statusHistory) => { + statusHistory.reference.should.be.eql('milestone'); + statusHistory.referenceId.should.be.eql(milestone.id); + }); + }); done(); }); diff --git a/src/routes/timelines/update.spec.js b/src/routes/timelines/update.spec.js index 72fffcb3..d0726cf1 100644 --- a/src/routes/timelines/update.spec.js +++ b/src/routes/timelines/update.spec.js @@ -492,6 +492,19 @@ describe('UPDATE timeline', () => { should.not.exist(resJson.deletedAt); should.not.exist(resJson.deletedBy); + // Milestones + resJson.milestones.should.have.length(2); + resJson.milestones.forEach((milestone) => { + // validate statusHistory + should.exist(milestone.statusHistory); + milestone.statusHistory.should.be.an('array'); + milestone.statusHistory.length.should.be.eql(1); + milestone.statusHistory.forEach((statusHistory) => { + statusHistory.reference.should.be.eql('milestone'); + statusHistory.referenceId.should.be.eql(milestone.id); + }); + }); + // eslint-disable-next-line no-unused-expressions server.services.pubsub.publish.calledWith(EVENT.ROUTING_KEY.TIMELINE_UPDATED).should.be.true; diff --git a/swagger.yaml b/swagger.yaml index 27d458c1..54d651ec 100644 --- a/swagger.yaml +++ b/swagger.yaml @@ -3173,7 +3173,7 @@ definitions: - buildingBlockKey properties: conditions: - type: string + type: string price: type: number format: float @@ -3186,7 +3186,7 @@ definitions: metadata: type: object buildingBlockKey: - type: string + type: string type: type: string description: project type @@ -4834,6 +4834,9 @@ definitions: blockedText: type: string description: the milestone blocked text + statusComment: + type: string + description: the milestone status history comment MilestonePostBodyParam: title: Milestone body param type: object @@ -4856,6 +4859,7 @@ definitions: - type: object required: - id + - statusHistory - createdAt - createdBy - updatedAt @@ -4865,6 +4869,8 @@ definitions: type: number format: int64 description: the id + statusHistory: + $ref: '#/definitions/StatusHistory' createdAt: type: string description: Datetime (GMT) when object was created @@ -5521,3 +5527,46 @@ definitions: config: description: config json type: object + StatusHistory: + title: Status history object + type: object + required: + - id + - status + - reference + - referenceId + - comment + properties: + id: + type: string + description: the id + status: + type: string + description: the status + reference: + type: string + description: the referenced model + referenceId: + type: string + description: the referenced id + comment: + type: string + description: the comment + 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