From 808d90b304dd9fb33f67fa417400c3818b87d116 Mon Sep 17 00:00:00 2001 From: Vikas Agarwal Date: Wed, 11 Jul 2018 13:01:44 +0530 Subject: [PATCH 01/73] Timeline and Milestone Changes --- .circleci/config.yml | 2 +- README.md | 2 +- config/custom-environment-variables.json | 4 +- config/default.json | 4 +- config/test.json | 4 +- migrations/elasticsearch_sync.js | 7 +- migrations/seedElasticsearchIndex.js | 50 + postman.json | 1196 ++++++++++++- postman_environment.json | 42 + src/constants.js | 13 + src/events/index.js | 11 + src/events/milestones/index.js | 150 ++ src/events/timelines/index.js | 90 + src/models/milestone.js | 39 + src/models/productMilestoneTemplate.js | 30 + src/models/productTemplate.js | 9 + src/models/timeline.js | 36 + src/permissions/index.js | 15 + src/routes/index.js | 29 +- src/routes/milestoneTemplates/create.js | 85 + src/routes/milestoneTemplates/create.spec.js | 282 ++++ src/routes/milestoneTemplates/delete.js | 57 + src/routes/milestoneTemplates/delete.spec.js | 187 +++ src/routes/milestoneTemplates/get.js | 43 + src/routes/milestoneTemplates/get.spec.js | 189 +++ src/routes/milestoneTemplates/list.js | 52 + src/routes/milestoneTemplates/list.spec.js | 217 +++ src/routes/milestoneTemplates/update.js | 121 ++ src/routes/milestoneTemplates/update.spec.js | 428 +++++ src/routes/milestones/create.js | 110 ++ src/routes/milestones/create.spec.js | 606 +++++++ src/routes/milestones/delete.js | 65 + src/routes/milestones/delete.spec.js | 325 ++++ src/routes/milestones/get.js | 48 + src/routes/milestones/get.spec.js | 342 ++++ src/routes/milestones/list.js | 68 + src/routes/milestones/list.spec.js | 324 ++++ src/routes/milestones/update.js | 161 ++ src/routes/milestones/update.spec.js | 981 +++++++++++ src/routes/timelines/create.js | 65 + src/routes/timelines/create.spec.js | 468 ++++++ src/routes/timelines/delete.js | 52 + src/routes/timelines/delete.spec.js | 306 ++++ src/routes/timelines/get.js | 36 + src/routes/timelines/get.spec.js | 304 ++++ src/routes/timelines/list.js | 94 ++ src/routes/timelines/list.spec.js | 397 +++++ src/routes/timelines/update.js | 109 ++ src/routes/timelines/update.spec.js | 626 +++++++ src/tests/seed.js | 127 ++ src/util.js | 98 +- swagger.yaml | 1574 ++++++++++++++---- 52 files changed, 10307 insertions(+), 373 deletions(-) create mode 100644 src/events/milestones/index.js create mode 100644 src/events/timelines/index.js create mode 100644 src/models/milestone.js create mode 100644 src/models/productMilestoneTemplate.js create mode 100644 src/models/timeline.js create mode 100644 src/routes/milestoneTemplates/create.js create mode 100644 src/routes/milestoneTemplates/create.spec.js create mode 100644 src/routes/milestoneTemplates/delete.js create mode 100644 src/routes/milestoneTemplates/delete.spec.js create mode 100644 src/routes/milestoneTemplates/get.js create mode 100644 src/routes/milestoneTemplates/get.spec.js create mode 100644 src/routes/milestoneTemplates/list.js create mode 100644 src/routes/milestoneTemplates/list.spec.js create mode 100644 src/routes/milestoneTemplates/update.js create mode 100644 src/routes/milestoneTemplates/update.spec.js create mode 100644 src/routes/milestones/create.js create mode 100644 src/routes/milestones/create.spec.js create mode 100644 src/routes/milestones/delete.js create mode 100644 src/routes/milestones/delete.spec.js create mode 100644 src/routes/milestones/get.js create mode 100644 src/routes/milestones/get.spec.js create mode 100644 src/routes/milestones/list.js create mode 100644 src/routes/milestones/list.spec.js create mode 100644 src/routes/milestones/update.js create mode 100644 src/routes/milestones/update.spec.js create mode 100644 src/routes/timelines/create.js create mode 100644 src/routes/timelines/create.spec.js create mode 100644 src/routes/timelines/delete.js create mode 100644 src/routes/timelines/delete.spec.js create mode 100644 src/routes/timelines/get.js create mode 100644 src/routes/timelines/get.spec.js create mode 100644 src/routes/timelines/list.js create mode 100644 src/routes/timelines/list.spec.js create mode 100644 src/routes/timelines/update.js create mode 100644 src/routes/timelines/update.spec.js diff --git a/.circleci/config.yml b/.circleci/config.yml index c3db4502..ec0cf2ad 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -76,7 +76,7 @@ workflows: - test filters: branches: - only: ['dev', 'feature/dev-challenges'] + only: ['dev', 'feature/dev-challenges-timeline'] - deployProd: requires: - test diff --git a/README.md b/README.md index e2e5a707..c487aab6 100644 --- a/README.md +++ b/README.md @@ -62,7 +62,7 @@ Authentication is handled via Authorization (Bearer) token header field. Token i ``` eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJyb2xlcyI6WyJhZG1pbmlzdHJhdG9yIl0sImlzcyI6Imh0dHBzOi8vYXBpLnRvcGNvZGVyLWRldi5jb20iLCJoYW5kbGUiOiJwc2hhaDEiLCJleHAiOjI0NjI0OTQ2MTgsInVzZXJJZCI6IjQwMTM1OTc4IiwiaWF0IjoxNDYyNDk0MDE4LCJlbWFpbCI6InBzaGFoMUB0ZXN0LmNvbSIsImp0aSI6ImY0ZTFhNTE0LTg5ODAtNDY0MC04ZWM1LWUzNmUzMWE3ZTg0OSJ9.XuNN7tpMOXvBG1QwWRQROj7NfuUbqhkjwn39Vy4tR5I ``` -It's been signed with the secret 'secret'. This secret should match your entry in config/local.json. You can generate your own token using https://jwt.io +It's been signed with the secret 'secret'. This secret should match your entry in config/local.js. You can generate your own token using https://jwt.io ### Local Deployment Build image: diff --git a/config/custom-environment-variables.json b/config/custom-environment-variables.json index 6b5731a7..3e0b0287 100644 --- a/config/custom-environment-variables.json +++ b/config/custom-environment-variables.json @@ -9,7 +9,9 @@ "host": "PROJECTS_ES_URL", "apiVersion": "2.3", "indexName": "PROJECTS_ES_INDEX_NAME", - "docType": "projectV4" + "docType": "projectV4", + "timelineIndexName": "TIMELINES_ES_INDEX_NAME", + "timelineDocType": "TIMELINES_ES_DOC_TYPE" }, "rabbitmqURL": "RABBITMQ_URL", "pubsubQueueName": "PUBSUB_QUEUE_NAME", diff --git a/config/default.json b/config/default.json index 053f0c35..556a5a10 100644 --- a/config/default.json +++ b/config/default.json @@ -20,7 +20,9 @@ "host": "", "apiVersion": "2.3", "indexName": "projects", - "docType": "projectV4" + "docType": "projectV4", + "timelineIndexName": "timelines", + "timelineDocType": "timelineV4" }, "systemUserClientId": "", "systemUserClientSecret": "", diff --git a/config/test.json b/config/test.json index 26d22a7a..8668be6e 100644 --- a/config/test.json +++ b/config/test.json @@ -7,7 +7,9 @@ "host": "http://localhost:9200", "apiVersion": "2.3", "indexName": "projects_test", - "docType": "projectV4" + "docType": "projectV4", + "timelineIndexName": "timelines_test", + "timelineDocType": "timelineV4" }, "rabbitmqUrl": "amqp://localhost:5672", "dbConfig": { diff --git a/migrations/elasticsearch_sync.js b/migrations/elasticsearch_sync.js index 321f86cb..eac45e5f 100644 --- a/migrations/elasticsearch_sync.js +++ b/migrations/elasticsearch_sync.js @@ -16,6 +16,7 @@ import util from '../src/util'; const ES_PROJECT_INDEX = config.get('elasticsearchConfig.indexName'); const ES_PROJECT_TYPE = config.get('elasticsearchConfig.docType'); +const ES_TIMELINE_INDEX = config.get('elasticsearchConfig.timelineIndexName'); // create new elasticsearch client // the client modifies the config object, so always passed the cloned object @@ -323,10 +324,14 @@ esClient.indices.delete({ ignore: [404], }) .then(() => esClient.indices.create(getRequestBody(ES_PROJECT_INDEX))) +// Re-create timeline index +.then(() => esClient.indices.delete({ index: ES_TIMELINE_INDEX, ignore: [404] })) +.then(() => esClient.indices.create({ index: ES_TIMELINE_INDEX })) .then(() => { console.log('elasticsearch indices synced successfully'); process.exit(); -}).catch((err) => { +}) +.catch((err) => { console.error('elasticsearch indices sync failed', err); process.exit(); }); diff --git a/migrations/seedElasticsearchIndex.js b/migrations/seedElasticsearchIndex.js index 4a10ec48..cf353f8f 100644 --- a/migrations/seedElasticsearchIndex.js +++ b/migrations/seedElasticsearchIndex.js @@ -6,6 +6,7 @@ import config from 'config'; import Promise from 'bluebird'; import models from '../src/models'; import RabbitMQService from '../src/services/rabbitmq'; +import { TIMELINE_REFERENCES } from '../src/constants'; const logger = bunyan.createLogger({ name: 'init-es', level: config.get('logLevel') }); @@ -23,6 +24,19 @@ function getProjectIds() { return []; } +/** + * Retrieve timeline ids from cli if provided + * @return {Array} list of timelineIds + */ +function getTimelineIds() { + let timelineIdArg = _.find(process.argv, a => a.indexOf('timelineIds') > -1); + if (timelineIdArg) { + timelineIdArg = timelineIdArg.split('='); + return timelineIdArg[1].split(',').map(i => parseInt(i, 10)); + } + return []; +} + Promise.coroutine(function* wrapped() { try { const rabbit = new RabbitMQService(logger); @@ -58,12 +72,48 @@ Promise.coroutine(function* wrapped() { logger.info(`Retrieved #${members.length} members`); members = _.groupBy(members, 'projectId'); + // Get timelines + const timelineIds = getTimelineIds(); + const timelineWhereClause = (timelineIds.length > 0) ? { id: { $in: timelineIds } } : {}; + let timelines = yield models.Timeline.findAll({ + where: timelineWhereClause, + include: [{ model: models.Milestone, as: 'milestones' }], + }); + logger.info(`Retrieved #${projects.length} timelines`); + + // Convert to raw json and remove unnecessary fields + timelines = _.map(timelines, (timeline) => { + const entity = _.omit(timeline.toJSON(), ['deletedBy', 'deletedAt']); + entity.milestones = _.map(entity.milestones, milestone => _.omit(milestone, ['deletedBy', 'deletedAt'])); + return entity; + }); + + // Get projectId for each timeline + yield Promise.all( + _.map(timelines, (timeline) => { + if (timeline.reference === TIMELINE_REFERENCES.PROJECT) { + timeline.projectId = timeline.referenceId; + return Promise.resolve(timeline); + } + + return models.ProjectPhase.findById(timeline.referenceId) + .then((phase) => { + timeline.projectId = phase.projectId; + return Promise.resolve(timeline); + }); + }), + ); + const promises = []; _.forEach(projects, (p) => { p.members = members[p.id]; logger.debug(`Processing Project #${p.id}`); promises.push(rabbit.publish('project.initial', p, {})); }); + _.forEach(timelines, (t) => { + logger.debug(`Processing Timeline #${t.id}`); + promises.push(rabbit.publish('timeline.initial', t, {})); + }); Promise.all(promises) .then(() => { logger.info(`Published ${promises.length} msgs`); diff --git a/postman.json b/postman.json index d9cb23a4..048cea72 100644 --- a/postman.json +++ b/postman.json @@ -1,6 +1,6 @@ { "info": { - "_postman_id": "1791b330-5331-4768-a265-f1cb5e6b4492", + "_postman_id": "440ee43d-66ca-4c9b-858d-22db97ea4cea", "name": "tc-project-service", "schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json" }, @@ -2840,7 +2840,7 @@ }, { "name": "issue86 (create project with templateId)", - "description": "", + "description": null, "item": [ { "name": "Create project with templateId", @@ -2928,8 +2928,18 @@ "mode": "raw", "raw": "{\n\t\"param\": {\n\t\t\"targetVersion\": \"v3\",\n\t\t\"defaultProductTemplateId\": 3\n\t}\n}" }, - "url": "{{api-url}}/v4/projects/6/upgrade", - "description": "" + "url": { + "raw": "{{api-url}}/v4/projects/6/upgrade", + "host": [ + "{{api-url}}" + ], + "path": [ + "v4", + "projects", + "6", + "upgrade" + ] + } }, "response": [] }, @@ -2951,8 +2961,18 @@ "mode": "raw", "raw": "{\n\t\"param\": {\n\t\t\"targetVersion\": \"v3\",\n\t\t\"defaultProductTemplateId\": 3\n\t}\n}" }, - "url": "{{api-url}}/v4/projects/7/upgrade", - "description": "" + "url": { + "raw": "{{api-url}}/v4/projects/7/upgrade", + "host": [ + "{{api-url}}" + ], + "path": [ + "v4", + "projects", + "7", + "upgrade" + ] + } }, "response": [] }, @@ -2974,8 +2994,18 @@ "mode": "raw", "raw": "{\n\t\"param\": {\n\t\t\"targetVersion\": \"v3\",\n\t\t\"defaultProductTemplateId\": 3,\n\t\t\"phaseName\": \"Custom phase name\"\n\t}\n}" }, - "url": "{{api-url}}/v4/projects/6/upgrade", - "description": "" + "url": { + "raw": "{{api-url}}/v4/projects/6/upgrade", + "host": [ + "{{api-url}}" + ], + "path": [ + "v4", + "projects", + "6", + "upgrade" + ] + } }, "response": [] }, @@ -2997,12 +3027,1156 @@ "mode": "raw", "raw": "{\n\t\"param\": {\n\t\t\"targetVersion\": \"v3\",\n\t\t\"defaultProductTemplateId\": 3,\n\t\t\"phaseName\": \"Custom phase name\"\n\t}\n}" }, - "url": "{{api-url}}/v4/projects/7/upgrade", - "description": "" + "url": { + "raw": "{{api-url}}/v4/projects/7/upgrade", + "host": [ + "{{api-url}}" + ], + "path": [ + "v4", + "projects", + "7", + "upgrade" + ] + } + }, + "response": [] + } + ] + }, + { + "name": "Timeline", + "description": null, + "item": [ + { + "name": "Create timeline", + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + }, + { + "key": "Authorization", + "value": "Bearer {{jwt-token-connectAdmin-40051336}}" + } + ], + "body": { + "mode": "raw", + "raw": "{\r\n \"param\":{\r\n \"name\":\"new name\",\r\n \"description\":\"new description\",\r\n \"startDate\":\"2018-05-29T00:00:00.000Z\",\r\n \"endDate\": \"2018-05-30T00:00:00.000Z\",\r\n \"reference\": \"project\",\r\n \"referenceId\": 1\r\n }\r\n}" + }, + "url": { + "raw": "{{api-url}}/v4/timelines", + "host": [ + "{{api-url}}" + ], + "path": [ + "v4", + "timelines" + ] + } + }, + "response": [] + }, + { + "name": "Create timeline with invalid data", + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + }, + { + "key": "Authorization", + "value": "Bearer {{jwt-token-connectAdmin-40051336}}" + } + ], + "body": { + "mode": "raw", + "raw": "{\r\n \"param\":{\r\n \"startDate\":\"2018-05-29T00:00:00.000Z\",\r\n \"endDate\": \"2018-05-28T00:00:00.000Z\",\r\n \"reference\": \"invalid\",\r\n \"referenceId\": 0\r\n }\r\n}" + }, + "url": { + "raw": "{{api-url}}/v4/timelines", + "host": [ + "{{api-url}}" + ], + "path": [ + "v4", + "timelines" + ] + } + }, + "response": [] + }, + { + "name": "List timelines", + "request": { + "method": "GET", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + }, + { + "key": "Authorization", + "value": "Bearer {{jwt-token-copilot-40051332}}" + } + ], + "body": { + "mode": "raw", + "raw": "{\r\n \"param\":{\r\n \"name\":\"new name\",\r\n \"key\":\"new key\",\r\n \"category\":\"new category\",\r\n \"scope\":{\r\n \"scope1\":\"scope 1\"\r\n },\r\n \"phases\":{\r\n \"phase1\":\"phase 1\"\r\n }\r\n }\r\n}" + }, + "url": { + "raw": "{{api-url}}/v4/timelines", + "host": [ + "{{api-url}}" + ], + "path": [ + "v4", + "timelines" + ] + } + }, + "response": [] + }, + { + "name": "List timelines (filter by reference)", + "request": { + "method": "GET", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + }, + { + "key": "Authorization", + "value": "Bearer {{jwt-token-copilot-40051332}}" + } + ], + "body": { + "mode": "raw", + "raw": "{\r\n \"param\":{\r\n \"name\":\"new name\",\r\n \"key\":\"new key\",\r\n \"category\":\"new category\",\r\n \"scope\":{\r\n \"scope1\":\"scope 1\"\r\n },\r\n \"phases\":{\r\n \"phase1\":\"phase 1\"\r\n }\r\n }\r\n}" + }, + "url": { + "raw": "{{api-url}}/v4/timelines?filter=reference%3Dproject", + "host": [ + "{{api-url}}" + ], + "path": [ + "v4", + "timelines" + ], + "query": [ + { + "key": "filter", + "value": "reference%3Dproject" + } + ] + } + }, + "response": [] + }, + { + "name": "List timelines (filter by referenceId)", + "request": { + "method": "GET", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + }, + { + "key": "Authorization", + "value": "Bearer {{jwt-token-copilot-40051332}}" + } + ], + "body": { + "mode": "raw", + "raw": "{\r\n \"param\":{\r\n \"name\":\"new name\",\r\n \"key\":\"new key\",\r\n \"category\":\"new category\",\r\n \"scope\":{\r\n \"scope1\":\"scope 1\"\r\n },\r\n \"phases\":{\r\n \"phase1\":\"phase 1\"\r\n }\r\n }\r\n}" + }, + "url": { + "raw": "{{api-url}}/v4/timelines?filter=referenceId%3D1", + "host": [ + "{{api-url}}" + ], + "path": [ + "v4", + "timelines" + ], + "query": [ + { + "key": "filter", + "value": "referenceId%3D1" + } + ] + } + }, + "response": [] + }, + { + "name": "List timelines (filter by reference and referenceId)", + "request": { + "method": "GET", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + }, + { + "key": "Authorization", + "value": "Bearer {{jwt-token-copilot-40051332}}" + } + ], + "body": { + "mode": "raw", + "raw": "{\r\n \"param\":{\r\n \"name\":\"new name\",\r\n \"key\":\"new key\",\r\n \"category\":\"new category\",\r\n \"scope\":{\r\n \"scope1\":\"scope 1\"\r\n },\r\n \"phases\":{\r\n \"phase1\":\"phase 1\"\r\n }\r\n }\r\n}" + }, + "url": { + "raw": "{{api-url}}/v4/timelines?filter=reference%3Dphase%26referenceId%3D1", + "host": [ + "{{api-url}}" + ], + "path": [ + "v4", + "timelines" + ], + "query": [ + { + "key": "filter", + "value": "reference%3Dphase%26referenceId%3D1" + } + ] + } + }, + "response": [] + }, + { + "name": "Get timeline", + "request": { + "method": "GET", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + }, + { + "key": "Authorization", + "value": "Bearer {{jwt-token}}" + } + ], + "body": { + "mode": "raw", + "raw": "{\r\n \"param\":{\r\n \"name\":\"new name\",\r\n \"key\":\"new key\",\r\n \"category\":\"new category\",\r\n \"scope\":{\r\n \"scope1\":\"scope 1\"\r\n },\r\n \"phases\":{\r\n \"phase1\":\"phase 1\"\r\n }\r\n }\r\n}" + }, + "url": { + "raw": "{{api-url}}/v4/timelines/1", + "host": [ + "{{api-url}}" + ], + "path": [ + "v4", + "timelines", + "1" + ] + } + }, + "response": [] + }, + { + "name": "Update timeline", + "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\": \"timeline 1-updated\",\r\n \"description\": \"description-updated\",\r\n \"startDate\": \"2018-05-01T00:00:00.000Z\",\r\n \"endDate\": null,\r\n \"reference\": \"project\",\r\n \"referenceId\": 1\r\n }\r\n}" + }, + "url": { + "raw": "{{api-url}}/v4/timelines/1", + "host": [ + "{{api-url}}" + ], + "path": [ + "v4", + "timelines", + "1" + ] + } + }, + "response": [] + }, + { + "name": "Update timeline (startDate)", + "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\": \"timeline 1-updated\",\r\n \"description\": \"description-updated\",\r\n \"startDate\": \"2018-05-05T00:00:00.000Z\",\r\n \"endDate\": null,\r\n \"reference\": \"project\",\r\n \"referenceId\": 1\r\n }\r\n}" + }, + "url": { + "raw": "{{api-url}}/v4/timelines/1", + "host": [ + "{{api-url}}" + ], + "path": [ + "v4", + "timelines", + "1" + ] + } + }, + "response": [] + }, + { + "name": "Update timeline (endDate)", + "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\": \"timeline 1-updated\",\r\n \"description\": \"description-updated\",\r\n \"startDate\": \"2018-05-04T00:00:00.000Z\",\r\n \"endDate\": \"2018-05-05T00:00:00.000Z\",\r\n \"reference\": \"project\",\r\n \"referenceId\": 1\r\n }\r\n}" + }, + "url": { + "raw": "{{api-url}}/v4/timelines/1", + "host": [ + "{{api-url}}" + ], + "path": [ + "v4", + "timelines", + "1" + ] + } + }, + "response": [] + }, + { + "name": "Delete timeline", + "request": { + "method": "DELETE", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + }, + { + "key": "Authorization", + "value": "Bearer {{jwt-token}}" + } + ], + "body": { + "mode": "raw", + "raw": "" + }, + "url": { + "raw": "{{api-url}}/v4/timelines/4", + "host": [ + "{{api-url}}" + ], + "path": [ + "v4", + "timelines", + "4" + ] + } + }, + "response": [] + } + ] + }, + { + "name": "Milestone", + "description": null, + "item": [ + { + "name": "Create milestone", + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + }, + { + "key": "Authorization", + "value": "Bearer {{jwt-token-member-40051331}}" + } + ], + "body": { + "mode": "raw", + "raw": "{\r\n \"param\":{\r\n \"name\": \"milestone 3\",\r\n \"description\": \"description 3\",\r\n \"duration\": 4,\r\n \"startDate\": \"2018-05-05T00:00:00.000Z\",\r\n \"endDate\": \"2018-05-07T00:00:00.000Z\",\r\n \"completionDate\": \"2018-05-08T00:00:00.000Z\",\r\n \"status\": \"open\",\r\n \"type\": \"type3\",\r\n \"details\": {\r\n \"detail1\": {\r\n \"subDetail1C\": 3\r\n },\r\n \"detail2\": [\r\n 2,\r\n 3,\r\n 4\r\n ]\r\n },\r\n \"order\": 1,\r\n \"plannedText\": \"plannedText 3\",\r\n \"activeText\": \"activeText 3\",\r\n \"completedText\": \"completedText 3\",\r\n \"blockedText\": \"blockedText 3\"\r\n }\r\n}" + }, + "url": { + "raw": "{{api-url}}/v4/timelines/1/milestones", + "host": [ + "{{api-url}}" + ], + "path": [ + "v4", + "timelines", + "1", + "milestones" + ] + } + }, + "response": [] + }, + { + "name": "Create milestone with invalid data", + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + }, + { + "key": "Authorization", + "value": "Bearer {{jwt-token-member-40051331}}" + } + ], + "body": { + "mode": "raw", + "raw": "{\r\n \"param\":{\r\n \"startDate\": \"2018-05-05T00:00:00.000Z\",\r\n \"endDate\": \"2018-05-04T00:00:00.000Z\",\r\n \"completionDate\": \"2018-05-04T00:00:00.000Z\"\r\n }\r\n}" + }, + "url": { + "raw": "{{api-url}}/v4/timelines/1/milestones", + "host": [ + "{{api-url}}" + ], + "path": [ + "v4", + "timelines", + "1", + "milestones" + ] + } + }, + "response": [] + }, + { + "name": "List milestones", + "request": { + "method": "GET", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + }, + { + "key": "Authorization", + "value": "Bearer {{jwt-token-copilot-40051332}}" + } + ], + "body": { + "mode": "raw", + "raw": "{\r\n \"param\":{\r\n \"name\":\"new name\",\r\n \"key\":\"new key\",\r\n \"category\":\"new category\",\r\n \"scope\":{\r\n \"scope1\":\"scope 1\"\r\n },\r\n \"phases\":{\r\n \"phase1\":\"phase 1\"\r\n }\r\n }\r\n}" + }, + "url": { + "raw": "{{api-url}}/v4/timelines/1/milestones", + "host": [ + "{{api-url}}" + ], + "path": [ + "v4", + "timelines", + "1", + "milestones" + ] + } + }, + "response": [] + }, + { + "name": "List milestones (sort)", + "request": { + "method": "GET", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + }, + { + "key": "Authorization", + "value": "Bearer {{jwt-token-copilot-40051332}}" + } + ], + "body": { + "mode": "raw", + "raw": "{\r\n \"param\":{\r\n \"name\":\"new name\",\r\n \"key\":\"new key\",\r\n \"category\":\"new category\",\r\n \"scope\":{\r\n \"scope1\":\"scope 1\"\r\n },\r\n \"phases\":{\r\n \"phase1\":\"phase 1\"\r\n }\r\n }\r\n}" + }, + "url": { + "raw": "{{api-url}}/v4/timelines/1/milestones?sort=order desc", + "host": [ + "{{api-url}}" + ], + "path": [ + "v4", + "timelines", + "1", + "milestones" + ], + "query": [ + { + "key": "sort", + "value": "order desc" + } + ] + } + }, + "response": [] + }, + { + "name": "Get milestone", + "request": { + "method": "GET", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + }, + { + "key": "Authorization", + "value": "Bearer {{jwt-token}}" + } + ], + "body": { + "mode": "raw", + "raw": "{\r\n \"param\":{\r\n \"name\":\"new name\",\r\n \"key\":\"new key\",\r\n \"category\":\"new category\",\r\n \"scope\":{\r\n \"scope1\":\"scope 1\"\r\n },\r\n \"phases\":{\r\n \"phase1\":\"phase 1\"\r\n }\r\n }\r\n}" + }, + "url": { + "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": "{\r\n \"param\":{\r\n \"name\": \"milestone 1-updated\",\r\n \"description\": \"description-updated\",\r\n \"duration\": 3,\r\n \"startDate\": \"2018-05-04T00:00:00.000Z\",\r\n \"endDate\": \"2018-05-06T00:00:00.000Z\",\r\n \"completionDate\": \"2018-05-07T00: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", + "host": [ + "{{api-url}}" + ], + "path": [ + "v4", + "timelines", + "1", + "milestones", + "1" + ] + } + }, + "response": [] + }, + { + "name": "Update milestone (order 1 => 2)", + "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 \"duration\": 3,\r\n \"startDate\": \"2018-05-04T00:00:00.000Z\",\r\n \"endDate\": \"2018-05-06T00:00:00.000Z\",\r\n \"completionDate\": \"2018-05-07T00: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\": 2,\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", + "host": [ + "{{api-url}}" + ], + "path": [ + "v4", + "timelines", + "1", + "milestones", + "1" + ] + } + }, + "response": [] + }, + { + "name": "Update milestone (order 2 => 1)", + "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 \"duration\": 3,\r\n \"startDate\": \"2018-05-04T00:00:00.000Z\",\r\n \"endDate\": \"2018-05-06T00:00:00.000Z\",\r\n \"completionDate\": \"2018-05-07T00: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", + "host": [ + "{{api-url}}" + ], + "path": [ + "v4", + "timelines", + "1", + "milestones", + "1" + ] + } + }, + "response": [] + }, + { + "name": "Update milestone (order 1 => 3)", + "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 \"duration\": 3,\r\n \"startDate\": \"2018-05-04T00:00:00.000Z\",\r\n \"endDate\": \"2018-05-06T00:00:00.000Z\",\r\n \"completionDate\": \"2018-05-07T00: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\": 3,\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", + "host": [ + "{{api-url}}" + ], + "path": [ + "v4", + "timelines", + "1", + "milestones", + "1" + ] + } + }, + "response": [] + }, + { + "name": "Update milestone (order 3 => 1)", + "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 \"duration\": 3,\r\n \"startDate\": \"2018-05-04T00:00:00.000Z\",\r\n \"endDate\": \"2018-05-06T00:00:00.000Z\",\r\n \"completionDate\": \"2018-05-07T00: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", + "host": [ + "{{api-url}}" + ], + "path": [ + "v4", + "timelines", + "1", + "milestones", + "1" + ] + } + }, + "response": [] + }, + { + "name": "Delete milestone", + "request": { + "method": "DELETE", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + }, + { + "key": "Authorization", + "value": "Bearer {{jwt-token}}" + } + ], + "body": { + "mode": "raw", + "raw": "" + }, + "url": { + "raw": "{{api-url}}/v4/timelines/1/milestones/2", + "host": [ + "{{api-url}}" + ], + "path": [ + "v4", + "timelines", + "1", + "milestones", + "2" + ] + } + }, + "response": [] + } + ] + }, + { + "name": "Milestone Template", + "description": null, + "item": [ + { + "name": "Create milestone template", + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + }, + { + "key": "Authorization", + "value": "Bearer {{jwt-token-admin-40051333}}" + } + ], + "body": { + "mode": "raw", + "raw": "{\r\n \"param\":{\r\n \"name\": \"milestoneTemplate 3\",\r\n \"description\": \"description 3\",\r\n \"duration\": 33,\r\n \"type\": \"type3\",\r\n \"order\": 1\r\n }\r\n}" + }, + "url": { + "raw": "{{api-url}}/v4/productTemplates/1/milestones", + "host": [ + "{{api-url}}" + ], + "path": [ + "v4", + "productTemplates", + "1", + "milestones" + ] + } + }, + "response": [] + }, + { + "name": "Create milestone template with invalid data", + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + }, + { + "key": "Authorization", + "value": "Bearer {{jwt-token-admin-40051333}}" + } + ], + "body": { + "mode": "raw", + "raw": "{\r\n \"param\":{\r\n\r\n }\r\n}" + }, + "url": { + "raw": "{{api-url}}/v4/productTemplates/1/milestones", + "host": [ + "{{api-url}}" + ], + "path": [ + "v4", + "productTemplates", + "1", + "milestones" + ] + } + }, + "response": [] + }, + { + "name": "List milestone templates", + "request": { + "method": "GET", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + }, + { + "key": "Authorization", + "value": "Bearer {{jwt-token-copilot-40051332}}" + } + ], + "body": { + "mode": "raw", + "raw": "{\r\n \"param\":{\r\n \"name\":\"new name\",\r\n \"key\":\"new key\",\r\n \"category\":\"new category\",\r\n \"scope\":{\r\n \"scope1\":\"scope 1\"\r\n },\r\n \"phases\":{\r\n \"phase1\":\"phase 1\"\r\n }\r\n }\r\n}" + }, + "url": { + "raw": "{{api-url}}/v4/productTemplates/1/milestones", + "host": [ + "{{api-url}}" + ], + "path": [ + "v4", + "productTemplates", + "1", + "milestones" + ] + } + }, + "response": [] + }, + { + "name": "List milestone templates (sort)", + "request": { + "method": "GET", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + }, + { + "key": "Authorization", + "value": "Bearer {{jwt-token-copilot-40051332}}" + } + ], + "body": { + "mode": "raw", + "raw": "{\r\n \"param\":{\r\n \"name\":\"new name\",\r\n \"key\":\"new key\",\r\n \"category\":\"new category\",\r\n \"scope\":{\r\n \"scope1\":\"scope 1\"\r\n },\r\n \"phases\":{\r\n \"phase1\":\"phase 1\"\r\n }\r\n }\r\n}" + }, + "url": { + "raw": "{{api-url}}/v4/productTemplates/1/milestones?sort=order desc", + "host": [ + "{{api-url}}" + ], + "path": [ + "v4", + "productTemplates", + "1", + "milestones" + ], + "query": [ + { + "key": "sort", + "value": "order desc" + } + ] + } + }, + "response": [] + }, + { + "name": "Get milestone template", + "request": { + "method": "GET", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + }, + { + "key": "Authorization", + "value": "Bearer {{jwt-token}}" + } + ], + "body": { + "mode": "raw", + "raw": "{\r\n \"param\":{\r\n \"name\":\"new name\",\r\n \"key\":\"new key\",\r\n \"category\":\"new category\",\r\n \"scope\":{\r\n \"scope1\":\"scope 1\"\r\n },\r\n \"phases\":{\r\n \"phase1\":\"phase 1\"\r\n }\r\n }\r\n}" + }, + "url": { + "raw": "{{api-url}}/v4/productTemplates/1/milestones/1", + "host": [ + "{{api-url}}" + ], + "path": [ + "v4", + "productTemplates", + "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": "{\r\n \"param\":{\r\n\t\"name\": \"milestoneTemplate 1-updated\",\r\n\t\"description\": \"description 1-updated\",\r\n\t\"duration\": 34,\r\n\t\"type\": \"type1-updated\",\r\n\t\"order\": 1\r\n }\r\n}" + }, + "url": { + "raw": "{{api-url}}/v4/productTemplates/1/milestones/1", + "host": [ + "{{api-url}}" + ], + "path": [ + "v4", + "productTemplates", + "1", + "milestones", + "1" + ] + } + }, + "response": [] + }, + { + "name": "Update milestone (order 1 => 2)", + "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\": \"milestoneTemplate 1-updated\",\r\n \"description\": \"description 1-updated\",\r\n \"duration\": 34,\r\n \"type\": \"type1-updated\",\r\n \"order\": 2\r\n }\r\n}" + }, + "url": { + "raw": "{{api-url}}/v4/productTemplates/1/milestones/1", + "host": [ + "{{api-url}}" + ], + "path": [ + "v4", + "productTemplates", + "1", + "milestones", + "1" + ] + } + }, + "response": [] + }, + { + "name": "Update milestone (order 2 => 1)", + "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\t\"name\": \"milestoneTemplate 1-updated\",\r\n\t\"description\": \"description 1-updated\",\r\n\t\"duration\": 34,\r\n\t\"type\": \"type1-updated\",\r\n\t\"order\": 1\r\n }\r\n}" + }, + "url": { + "raw": "{{api-url}}/v4/productTemplates/1/milestones/1", + "host": [ + "{{api-url}}" + ], + "path": [ + "v4", + "productTemplates", + "1", + "milestones", + "1" + ] + } + }, + "response": [] + }, + { + "name": "Update milestone (order 1 => 3)", + "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\": \"milestoneTemplate 1-updated\",\r\n \"description\": \"description 1-updated\",\r\n \"duration\": 34,\r\n \"type\": \"type1-updated\",\r\n \"order\": 3\r\n }\r\n}" + }, + "url": { + "raw": "{{api-url}}/v4/productTemplates/1/milestones/1", + "host": [ + "{{api-url}}" + ], + "path": [ + "v4", + "productTemplates", + "1", + "milestones", + "1" + ] + } + }, + "response": [] + }, + { + "name": "Update milestone (order 3 => 1)", + "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\": \"milestoneTemplate 1-updated\",\r\n \"description\": \"description 1-updated\",\r\n \"duration\": 34,\r\n \"type\": \"type1-updated\",\r\n \"order\": 1\r\n }\r\n}" + }, + "url": { + "raw": "{{api-url}}/v4/productTemplates/1/milestones/1", + "host": [ + "{{api-url}}" + ], + "path": [ + "v4", + "productTemplates", + "1", + "milestones", + "1" + ] + } + }, + "response": [] + }, + { + "name": "Delete milestone", + "request": { + "method": "DELETE", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + }, + { + "key": "Authorization", + "value": "Bearer {{jwt-token}}" + } + ], + "body": { + "mode": "raw", + "raw": "" + }, + "url": { + "raw": "{{api-url}}/v4/productTemplates/1/milestones/2", + "host": [ + "{{api-url}}" + ], + "path": [ + "v4", + "productTemplates", + "1", + "milestones", + "2" + ] + } }, "response": [] } ] } ] -} +} \ No newline at end of file diff --git a/postman_environment.json b/postman_environment.json index 12fab912..84968c61 100644 --- a/postman_environment.json +++ b/postman_environment.json @@ -15,6 +15,48 @@ "description": "", "type": "text", "enabled": true + }, + { + "key": "jwt-token-admin-40051333", + "value": "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJyb2xlcyI6WyJUb3Bjb2RlciBVc2VyIiwiYWRtaW5pc3RyYXRvciJdLCJpc3MiOiJodHRwczovL2FwaS50b3Bjb2Rlci1kZXYuY29tIiwiaGFuZGxlIjoidGVzdDEiLCJleHAiOjI1NjMwNzY2ODksInVzZXJJZCI6IjQwMDUxMzMzIiwiaWF0IjoxNDYzMDc2MDg5LCJlbWFpbCI6InRlc3RAdG9wY29kZXIuY29tIiwianRpIjoiYjMzYjc3Y2QtYjUyZS00MGZlLTgzN2UtYmViOGUwYWU2YTRhIn0.wKWUe0-SaiFVN-VR_-GwgFlvWaDkSbc8H55ktb9LAVw", + "description": "", + "type": "text", + "enabled": true + }, + { + "key": "jwt-token-member-40051331", + "value": "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJyb2xlcyI6WyJUb3Bjb2RlciBVc2VyIl0sImlzcyI6Imh0dHBzOi8vYXBpLnRvcGNvZGVyLWRldi5jb20iLCJoYW5kbGUiOiJ0ZXN0MSIsImV4cCI6MjU2MzA3NjY4OSwidXNlcklkIjoiNDAwNTEzMzEiLCJpYXQiOjE0NjMwNzYwODksImVtYWlsIjoidGVzdEB0b3Bjb2Rlci5jb20iLCJqdGkiOiJiMzNiNzdjZC1iNTJlLTQwZmUtODM3ZS1iZWI4ZTBhZTZhNGEifQ.pDtRzcGQjgCBD6aLsW-1OFhzmrv5mXhb8YLDWbGAnKo", + "description": "", + "type": "text", + "enabled": true + }, + { + "key": "jwt-token-copilot-40051332", + "value": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlcyI6WyJUb3Bjb2RlciBVc2VyIiwiQ29ubmVjdCBDb3BpbG90Il0sImlzcyI6Imh0dHBzOi8vYXBpLnRvcGNvZGVyLWRldi5jb20iLCJoYW5kbGUiOiJ0ZXN0MSIsImV4cCI6MjU2MzA3NjY4OSwidXNlcklkIjo0MDA1MTMzMiwiZW1haWwiOiJ0ZXN0QHRvcGNvZGVyLmNvbSIsImlhdCI6MTQ3MDYyMDA0NH0.DnX17gBaVF2JTuRai-C2BDSdEjij9da_s4eYcMIjP0c", + "description": "", + "type": "text", + "enabled": true + }, + { + "key": "jwt-token-manager-40051334", + "value": "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJyb2xlcyI6WyJUb3Bjb2RlciBVc2VyIiwiQ29ubmVjdCBNYW5hZ2VyIl0sImlzcyI6Imh0dHBzOi8vYXBpLnRvcGNvZGVyLWRldi5jb20iLCJoYW5kbGUiOiJ0ZXN0MSIsImV4cCI6MjU2MzA3NjY4OSwidXNlcklkIjoiNDAwNTEzMzQiLCJpYXQiOjE0NjMwNzYwODksImVtYWlsIjoidGVzdEB0b3Bjb2Rlci5jb20iLCJqdGkiOiJiMzNiNzdjZC1iNTJlLTQwZmUtODM3ZS1iZWI4ZTBhZTZhNGEifQ.J5VtOEQVph5jfe2Ji-NH7txEDcx_5gthhFeD-MzX9ck", + "description": "", + "type": "text", + "enabled": true + }, + { + "key": "jwt-token-member2-40051335", + "value": "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJyb2xlcyI6WyJUb3Bjb2RlciBVc2VyIl0sImlzcyI6Imh0dHBzOi8vYXBpLnRvcGNvZGVyLWRldi5jb20iLCJoYW5kbGUiOiJtZW1iZXIyIiwiZXhwIjoyNTYzMDc2Njg5LCJ1c2VySWQiOiI0MDA1MTMzNSIsImlhdCI6MTQ2MzA3NjA4OSwiZW1haWwiOiJ0ZXN0QHRvcGNvZGVyLmNvbSIsImp0aSI6ImIzM2I3N2NkLWI1MmUtNDBmZS04MzdlLWJlYjhlMGFlNmE0YSJ9.Mh4bw3wm-cn5Kcf96gLFVlD0kySOqqk4xN3qnreAKL4", + "description": "", + "type": "text", + "enabled": true + }, + { + "key": "jwt-token-connectAdmin-40051336", + "value": "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJyb2xlcyI6WyJDb25uZWN0IEFkbWluIl0sImlzcyI6Imh0dHBzOi8vYXBpLnRvcGNvZGVyLWRldi5jb20iLCJoYW5kbGUiOiJjb25uZWN0X2FkbWluMSIsImV4cCI6MjU2MzA3NjY4OSwidXNlcklkIjoiNDAwNTEzMzYiLCJpYXQiOjE0NjMwNzYwODksImVtYWlsIjoiY29ubmVjdF9hZG1pbjFAdG9wY29kZXIuY29tIiwianRpIjoiYjMzYjc3Y2QtYjUyZS00MGZlLTgzN2UtYmViOGUwYWU2YTRhIn0.nSGfXMl02NZ90ZKLiEKPg75iAjU92mfteaY6xgqkM30", + "description": "", + "type": "text", + "enabled": true } ], "_postman_variable_scope": "environment", diff --git a/src/constants.js b/src/constants.js index 4e24c806..a99469f5 100644 --- a/src/constants.js +++ b/src/constants.js @@ -49,6 +49,14 @@ export const EVENT = { PROJECT_PHASE_PRODUCT_ADDED: 'project.phase.product.added', PROJECT_PHASE_PRODUCT_UPDATED: 'project.phase.product.updated', PROJECT_PHASE_PRODUCT_REMOVED: 'project.phase.product.removed', + + TIMELINE_ADDED: 'timeline.added', + TIMELINE_UPDATED: 'timeline.updated', + TIMELINE_REMOVED: 'timeline.removed', + + MILESTONE_ADDED: 'milestone.added', + MILESTONE_UPDATED: 'milestone.updated', + MILESTONE_REMOVED: 'milestone.removed', }, }; @@ -88,3 +96,8 @@ export const REGEX = { export const TOKEN_SCOPES = { CONNECT_PROJECT_ADMIN: 'all:connect_project', }; + +export const TIMELINE_REFERENCES = { + PROJECT: 'project', + PHASE: 'phase', +}; diff --git a/src/events/index.js b/src/events/index.js index cf6decf8..fac17d8d 100644 --- a/src/events/index.js +++ b/src/events/index.js @@ -9,6 +9,8 @@ import { projectPhaseAddedHandler, projectPhaseRemovedHandler, projectPhaseUpdatedHandler } from './projectPhases'; import { phaseProductAddedHandler, phaseProductRemovedHandler, phaseProductUpdatedHandler } from './phaseProducts'; +import { timelineAddedHandler, timelineUpdatedHandler, timelineRemovedHandler } from './timelines'; +import { milestoneAddedHandler, milestoneUpdatedHandler, milestoneRemovedHandler } from './milestones'; export default { 'project.initial': projectCreatedHandler, @@ -30,4 +32,13 @@ export default { [EVENT.ROUTING_KEY.PROJECT_PHASE_PRODUCT_ADDED]: phaseProductAddedHandler, [EVENT.ROUTING_KEY.PROJECT_PHASE_PRODUCT_REMOVED]: phaseProductRemovedHandler, [EVENT.ROUTING_KEY.PROJECT_PHASE_PRODUCT_UPDATED]: phaseProductUpdatedHandler, + + // Timeline and milestone + 'timeline.initial': timelineAddedHandler, + [EVENT.ROUTING_KEY.TIMELINE_ADDED]: timelineAddedHandler, + [EVENT.ROUTING_KEY.TIMELINE_REMOVED]: timelineRemovedHandler, + [EVENT.ROUTING_KEY.TIMELINE_UPDATED]: timelineUpdatedHandler, + [EVENT.ROUTING_KEY.MILESTONE_ADDED]: milestoneAddedHandler, + [EVENT.ROUTING_KEY.MILESTONE_REMOVED]: milestoneRemovedHandler, + [EVENT.ROUTING_KEY.MILESTONE_UPDATED]: milestoneUpdatedHandler, }; diff --git a/src/events/milestones/index.js b/src/events/milestones/index.js new file mode 100644 index 00000000..3ebd578a --- /dev/null +++ b/src/events/milestones/index.js @@ -0,0 +1,150 @@ +/** + * Event handlers for milestone create, update and delete. + */ +import config from 'config'; +import _ from 'lodash'; +import Promise from 'bluebird'; +import util from '../../util'; + +const ES_TIMELINE_INDEX = config.get('elasticsearchConfig.timelineIndexName'); +const ES_TIMELINE_TYPE = config.get('elasticsearchConfig.timelineDocType'); + +const eClient = util.getElasticSearchClient(); + +/** + * Handler for milestone creation event + * @param {Object} logger logger to log along with trace id + * @param {Object} msg event payload + * @param {Object} channel channel to ack, nack + */ +const milestoneAddedHandler = Promise.coroutine(function* (logger, msg, channel) { // eslint-disable-line func-names + const data = JSON.parse(msg.content.toString()); + try { + const doc = yield eClient.get({ index: ES_TIMELINE_INDEX, type: ES_TIMELINE_TYPE, id: data.timelineId }); + const milestones = _.isArray(doc._source.milestones) ? doc._source.milestones : []; // eslint-disable-line no-underscore-dangle + + // Increase the order of the other milestones in the same timeline, + // which have `order` >= this milestone order + _.each(milestones, (milestone) => { + if (milestone.order >= data.order) { + milestone.order += 1; // eslint-disable-line no-param-reassign + } + }); + + milestones.push(data); + const merged = _.assign(doc._source, { milestones }); // eslint-disable-line no-underscore-dangle + yield eClient.update({ + index: ES_TIMELINE_INDEX, + type: ES_TIMELINE_TYPE, + id: data.timelineId, + body: { doc: merged }, + }); + logger.debug('milestone added to timeline document successfully'); + channel.ack(msg); + } catch (error) { + logger.error(`Error processing event (milestoneId: ${data.id})`, error); + // if the message has been redelivered dont attempt to reprocess it + channel.nack(msg, false, !msg.fields.redelivered); + } +}); + +/** + * Handler for milestone updated event + * @param {Object} logger logger to log along with trace id + * @param {Object} msg event payload + * @param {Object} channel channel to ack, nack + * @returns {undefined} + */ +const milestoneUpdatedHandler = Promise.coroutine(function* (logger, msg, channel) { // eslint-disable-line func-names + const data = JSON.parse(msg.content.toString()); + try { + const doc = yield eClient.get({ index: ES_TIMELINE_INDEX, type: ES_TIMELINE_TYPE, id: data.original.timelineId }); + const milestones = _.map(doc._source.milestones, (single) => { // eslint-disable-line no-underscore-dangle + if (single.id === data.original.id) { + return _.assign(single, data.updated); + } + return single; + }); + + if (data.original.order !== data.updated.order) { + const milestoneWithSameOrder = + _.find(milestones, milestone => milestone.id !== data.updated.id && milestone.order === data.updated.order); + if (milestoneWithSameOrder) { + // Increase the order from M to K: if there is an item with order K, + // orders from M+1 to K should be made M to K-1 + if (data.original.order < data.updated.order) { + _.each(milestones, (single) => { + if (single.id !== data.updated.id + && (data.original.order + 1) <= single.order + && single.order <= data.updated.order) { + single.order -= 1; // eslint-disable-line no-param-reassign + } + }); + } else { + // Decrease the order from M to K: if there is an item with order K, + // orders from K to M-1 should be made K+1 to M + _.each(milestones, (single) => { + if (single.id !== data.updated.id + && data.updated.order <= single.order + && single.order <= (data.original.order - 1)) { + single.order += 1; // eslint-disable-line no-param-reassign + } + }); + } + } + } + + const merged = _.assign(doc._source, { milestones }); // eslint-disable-line no-underscore-dangle + yield eClient.update({ + index: ES_TIMELINE_INDEX, + type: ES_TIMELINE_TYPE, + id: data.original.timelineId, + body: { + doc: merged, + }, + }); + logger.debug('elasticsearch index updated, milestone updated successfully'); + channel.ack(msg); + } catch (error) { + logger.error(`Error processing event (milestoneId: ${data.original.id})`, error); + // if the message has been redelivered dont attempt to reprocess it + channel.nack(msg, false, !msg.fields.redelivered); + } +}); + +/** + * Handler for milestone deleted event + * @param {Object} logger logger to log along with trace id + * @param {Object} msg event payload + * @param {Object} channel channel to ack, nack + * @returns {undefined} + */ +const milestoneRemovedHandler = Promise.coroutine(function* (logger, msg, channel) { // eslint-disable-line func-names + const data = JSON.parse(msg.content.toString()); + try { + const doc = yield eClient.get({ index: ES_TIMELINE_INDEX, type: ES_TIMELINE_TYPE, id: data.timelineId }); + const milestones = _.filter(doc._source.milestones, single => single.id !== data.id); // eslint-disable-line no-underscore-dangle + const merged = _.assign(doc._source, { milestones }); // eslint-disable-line no-underscore-dangle + yield eClient.update({ + index: ES_TIMELINE_INDEX, + type: ES_TIMELINE_TYPE, + id: data.timelineId, + body: { + doc: merged, + }, + }); + logger.debug('milestone removed from timeline document successfully'); + channel.ack(msg); + } catch (error) { + logger.error(`Error processing event (milestoneId: ${data.id})`, error); + // if the message has been redelivered dont attempt to reprocess it + channel.nack(msg, false, !msg.fields.redelivered); + } +}); + + +module.exports = { + milestoneAddedHandler, + milestoneRemovedHandler, + milestoneUpdatedHandler, +}; diff --git a/src/events/timelines/index.js b/src/events/timelines/index.js new file mode 100644 index 00000000..0de36410 --- /dev/null +++ b/src/events/timelines/index.js @@ -0,0 +1,90 @@ +/** + * Event handlers for timeline create, update and delete + */ +import _ from 'lodash'; +import Promise from 'bluebird'; +import config from 'config'; +import util from '../../util'; + +const ES_TIMELINE_INDEX = config.get('elasticsearchConfig.timelineIndexName'); +const ES_TIMELINE_TYPE = config.get('elasticsearchConfig.timelineDocType'); +const eClient = util.getElasticSearchClient(); + +/** + * Handler for timeline creation event + * @param {Object} logger logger to log along with trace id + * @param {Object} msg event payload + * @param {Object} channel channel to ack, nack + */ +const timelineAddedHandler = Promise.coroutine(function* (logger, msg, channel) { // eslint-disable-line func-names + const data = JSON.parse(msg.content.toString()); + try { + // add the record to the index + const result = yield eClient.index({ + index: ES_TIMELINE_INDEX, + type: ES_TIMELINE_TYPE, + id: data.id, + body: data, + }); + logger.debug(`timeline indexed successfully (timelineId: ${data.id})`, result); + channel.ack(msg); + } catch (error) { + logger.error(`Error processing event (timelineId: ${data.id})`, error); + channel.nack(msg, false, !msg.fields.redelivered); + } +}); + +/** + * Handler for timeline updated event + * @param {Object} logger logger to log along with trace id + * @param {Object} msg event payload + * @param {Object} channel channel to ack, nack + */ +const timelineUpdatedHandler = Promise.coroutine(function* (logger, msg, channel) { // eslint-disable-line func-names + const data = JSON.parse(msg.content.toString()); + try { + // first get the existing document and than merge the updated changes and save the new document + const doc = yield eClient.get({ index: ES_TIMELINE_INDEX, type: ES_TIMELINE_TYPE, id: data.original.id }); + const merged = _.merge(doc._source, data.updated); // eslint-disable-line no-underscore-dangle + merged.milestones = data.updated.milestones; + // update the merged document + yield eClient.update({ + index: ES_TIMELINE_INDEX, + type: ES_TIMELINE_TYPE, + id: data.original.id, + body: { + doc: merged, + }, + }); + logger.debug(`timeline updated successfully in elasticsearh index, (timelineId: ${data.original.id})`); + channel.ack(msg); + } catch (error) { + logger.error(`failed to get timeline document, (timelineId: ${data.original.id})`, error); + channel.nack(msg, false, !msg.fields.redelivered); + } +}); + +/** + * Handler for timeline deleted event + * @param {Object} logger logger to log along with trace id + * @param {Object} msg event payload + * @param {Object} channel channel to ack, nack + */ +const timelineRemovedHandler = Promise.coroutine(function* (logger, msg, channel) { // eslint-disable-line func-names + const data = JSON.parse(msg.content.toString()); + try { + yield eClient.delete({ index: ES_TIMELINE_INDEX, type: ES_TIMELINE_TYPE, id: data.id }); + logger.debug(`timeline deleted successfully from elasticsearh index (timelineId: ${data.id})`); + channel.ack(msg); + } catch (error) { + logger.error(`failed to delete timeline document (timelineId: ${data.id})`, error); + channel.nack(msg, false, !msg.fields.redelivered); + } +}); + + +module.exports = { + timelineAddedHandler, + timelineUpdatedHandler, + timelineRemovedHandler, +}; diff --git a/src/models/milestone.js b/src/models/milestone.js new file mode 100644 index 00000000..cb3e0306 --- /dev/null +++ b/src/models/milestone.js @@ -0,0 +1,39 @@ +/* eslint-disable valid-jsdoc */ + +/** + * The Milestone model + */ +module.exports = (sequelize, DataTypes) => { + const Milestone = sequelize.define('Milestone', { + id: { type: DataTypes.BIGINT, primaryKey: true, autoIncrement: true }, + name: { type: DataTypes.STRING(255), allowNull: false }, + description: DataTypes.STRING(255), + duration: { type: DataTypes.INTEGER, allowNull: false }, + startDate: { type: DataTypes.DATE, allowNull: false }, + endDate: DataTypes.DATE, + completionDate: DataTypes.DATE, + status: { type: DataTypes.STRING(45), allowNull: false }, + type: { type: DataTypes.STRING(45), allowNull: false }, + details: DataTypes.JSON, + order: { type: DataTypes.INTEGER, allowNull: false }, + plannedText: { type: DataTypes.STRING(512), allowNull: false }, + activeText: { type: DataTypes.STRING(512), allowNull: false }, + completedText: { type: DataTypes.STRING(512), allowNull: false }, + blockedText: { type: DataTypes.STRING(512), allowNull: false }, + deletedAt: DataTypes.DATE, + createdAt: { type: DataTypes.DATE, defaultValue: DataTypes.NOW }, + updatedAt: { type: DataTypes.DATE, defaultValue: DataTypes.NOW }, + deletedBy: DataTypes.BIGINT, + createdBy: { type: DataTypes.BIGINT, allowNull: false }, + updatedBy: { type: DataTypes.BIGINT, allowNull: false }, + }, { + tableName: 'milestones', + paranoid: true, + timestamps: true, + updatedAt: 'updatedAt', + createdAt: 'createdAt', + deletedAt: 'deletedAt', + }); + + return Milestone; +}; diff --git a/src/models/productMilestoneTemplate.js b/src/models/productMilestoneTemplate.js new file mode 100644 index 00000000..acd40c11 --- /dev/null +++ b/src/models/productMilestoneTemplate.js @@ -0,0 +1,30 @@ +/* eslint-disable valid-jsdoc */ + +/** + * The Product Milestone Template model + */ +module.exports = (sequelize, DataTypes) => { + const ProductMilestoneTemplate = sequelize.define('ProductMilestoneTemplate', { + id: { type: DataTypes.BIGINT, primaryKey: true, autoIncrement: true }, + name: { type: DataTypes.STRING(255), allowNull: false }, + description: DataTypes.STRING(255), + duration: { type: DataTypes.INTEGER, allowNull: false }, + type: { type: DataTypes.STRING(45), allowNull: false }, + order: { type: DataTypes.INTEGER, allowNull: false }, + deletedAt: DataTypes.DATE, + createdAt: { type: DataTypes.DATE, defaultValue: DataTypes.NOW }, + updatedAt: { type: DataTypes.DATE, defaultValue: DataTypes.NOW }, + deletedBy: DataTypes.BIGINT, + createdBy: { type: DataTypes.BIGINT, allowNull: false }, + updatedBy: { type: DataTypes.BIGINT, allowNull: false }, + }, { + tableName: 'product_milestone_templates', + paranoid: true, + timestamps: true, + updatedAt: 'updatedAt', + createdAt: 'createdAt', + deletedAt: 'deletedAt', + }); + + return ProductMilestoneTemplate; +}; diff --git a/src/models/productTemplate.js b/src/models/productTemplate.js index cf1cf2db..1adeff63 100644 --- a/src/models/productTemplate.js +++ b/src/models/productTemplate.js @@ -28,6 +28,15 @@ module.exports = (sequelize, DataTypes) => { updatedAt: 'updatedAt', createdAt: 'createdAt', deletedAt: 'deletedAt', + classMethods: { + associate: (models) => { + ProductTemplate.hasMany(models.ProductMilestoneTemplate, { + as: 'milestones', + foreignKey: 'productTemplateId', + onDelete: 'cascade', + }); + }, + }, }); return ProductTemplate; diff --git a/src/models/timeline.js b/src/models/timeline.js new file mode 100644 index 00000000..5b9d6247 --- /dev/null +++ b/src/models/timeline.js @@ -0,0 +1,36 @@ +/* eslint-disable valid-jsdoc */ + +/** + * The Timeline model + */ +module.exports = (sequelize, DataTypes) => { + const Timeline = sequelize.define('Timeline', { + id: { type: DataTypes.BIGINT, primaryKey: true, autoIncrement: true }, + name: { type: DataTypes.STRING(255), allowNull: false }, + description: DataTypes.STRING(255), + startDate: { type: DataTypes.DATE, allowNull: false }, + endDate: DataTypes.DATE, + reference: { type: DataTypes.STRING(45), allowNull: false }, + referenceId: { type: DataTypes.BIGINT, allowNull: false }, + deletedAt: DataTypes.DATE, + createdAt: { type: DataTypes.DATE, defaultValue: DataTypes.NOW }, + updatedAt: { type: DataTypes.DATE, defaultValue: DataTypes.NOW }, + deletedBy: DataTypes.BIGINT, + createdBy: { type: DataTypes.BIGINT, allowNull: false }, + updatedBy: { type: DataTypes.BIGINT, allowNull: false }, + }, { + tableName: 'timelines', + paranoid: true, + timestamps: true, + updatedAt: 'updatedAt', + createdAt: 'createdAt', + deletedAt: 'deletedAt', + classMethods: { + associate: (models) => { + Timeline.hasMany(models.Milestone, { as: 'milestones', foreignKey: 'timelineId', onDelete: 'cascade' }); + }, + }, + }); + + return Timeline; +}; diff --git a/src/permissions/index.js b/src/permissions/index.js index 30c2c35d..e47435cf 100644 --- a/src/permissions/index.js +++ b/src/permissions/index.js @@ -43,8 +43,23 @@ module.exports = () => { Authorizer.setPolicy('project.updatePhaseProduct', copilotAndAbove); Authorizer.setPolicy('project.deletePhaseProduct', copilotAndAbove); + Authorizer.setPolicy('milestoneTemplate.create', connectManagerOrAdmin); + Authorizer.setPolicy('milestoneTemplate.edit', connectManagerOrAdmin); + Authorizer.setPolicy('milestoneTemplate.delete', connectManagerOrAdmin); + Authorizer.setPolicy('milestoneTemplate.view', true); + Authorizer.setPolicy('projectType.create', projectAdmin); Authorizer.setPolicy('projectType.edit', projectAdmin); Authorizer.setPolicy('projectType.delete', projectAdmin); Authorizer.setPolicy('projectType.view', true); // anyone can view project types + + Authorizer.setPolicy('timeline.create', projectEdit); + Authorizer.setPolicy('timeline.edit', projectEdit); + Authorizer.setPolicy('timeline.delete', projectEdit); + Authorizer.setPolicy('timeline.view', projectView); + + Authorizer.setPolicy('milestone.create', projectEdit); + Authorizer.setPolicy('milestone.edit', projectEdit); + Authorizer.setPolicy('milestone.delete', projectEdit); + Authorizer.setPolicy('milestone.view', projectView); }; diff --git a/src/routes/index.js b/src/routes/index.js index 48d52880..d2ecb93a 100644 --- a/src/routes/index.js +++ b/src/routes/index.js @@ -42,7 +42,7 @@ router.route('/v4/projectTypes/:key') .get(require('./projectTypes/get')); router.all( - RegExp(`\\/${apiVersion}\\/(projects|projectTemplates|productTemplates|projectTypes)(?!\\/health).*`), + RegExp(`\\/${apiVersion}\\/(projects|projectTemplates|productTemplates|projectTypes|timelines)(?!\\/health).*`), jwtAuth()); // Register all the routes @@ -99,6 +99,15 @@ router.route('/v4/productTemplates/:templateId(\\d+)') .patch(require('./productTemplates/update')) .delete(require('./productTemplates/delete')); +router.route('/v4/productTemplates/:productTemplateId(\\d+)/milestones') + .post(require('./milestoneTemplates/create')) + .get(require('./milestoneTemplates/list')); + +router.route('/v4/productTemplates/:productTemplateId(\\d+)/milestones/:milestoneTemplateId(\\d+)') + .get(require('./milestoneTemplates/get')) + .patch(require('./milestoneTemplates/update')) + .delete(require('./milestoneTemplates/delete')); + router.route('/v4/projects/:projectId(\\d+)/phases') .get(require('./phases/list')) .post(require('./phases/create')); @@ -124,6 +133,24 @@ router.route('/v4/projectTypes/:key') .patch(require('./projectTypes/update')) .delete(require('./projectTypes/delete')); +router.route('/v4/timelines') + .post(require('./timelines/create')) + .get(require('./timelines/list')); + +router.route('/v4/timelines/:timelineId(\\d+)') + .get(require('./timelines/get')) + .patch(require('./timelines/update')) + .delete(require('./timelines/delete')); + +router.route('/v4/timelines/:timelineId(\\d+)/milestones') + .post(require('./milestones/create')) + .get(require('./milestones/list')); + +router.route('/v4/timelines/:timelineId(\\d+)/milestones/:milestoneId(\\d+)') + .get(require('./milestones/get')) + .patch(require('./milestones/update')) + .delete(require('./milestones/delete')); + // register error handler router.use((err, req, res, next) => { // eslint-disable-line no-unused-vars // DO NOT REMOVE next arg.. even though eslint diff --git a/src/routes/milestoneTemplates/create.js b/src/routes/milestoneTemplates/create.js new file mode 100644 index 00000000..55ea5c12 --- /dev/null +++ b/src/routes/milestoneTemplates/create.js @@ -0,0 +1,85 @@ +/** + * API to add a milestone template + */ +import validate from 'express-validation'; +import _ from 'lodash'; +import Joi from 'joi'; +import Sequelize from 'sequelize'; +import { middleware as tcMiddleware } from 'tc-core-library-js'; +import util from '../../util'; +import models from '../../models'; + +const permissions = tcMiddleware.permissions; + +const schema = { + params: { + productTemplateId: Joi.number().integer().positive().required(), + }, + body: { + param: Joi.object().keys({ + id: Joi.any().strip(), + name: Joi.string().max(255).required(), + description: Joi.string().max(255), + duration: Joi.number().integer().required(), + type: Joi.string().max(45).required(), + order: Joi.number().integer().required(), + productTemplateId: Joi.any().strip(), + createdAt: Joi.any().strip(), + updatedAt: Joi.any().strip(), + deletedAt: Joi.any().strip(), + createdBy: Joi.any().strip(), + updatedBy: Joi.any().strip(), + deletedBy: Joi.any().strip(), + }).required(), + }, +}; + +module.exports = [ + validate(schema), + permissions('milestoneTemplate.create'), + (req, res, next) => { + const entity = _.assign(req.body.param, { + createdBy: req.authUser.userId, + updatedBy: req.authUser.userId, + productTemplateId: req.params.productTemplateId, + }); + let result; + + return models.sequelize.transaction(tx => + // Find the product template + models.ProductTemplate.findById(req.params.productTemplateId, { transaction: tx }) + .then((productTemplate) => { + // Not found + if (!productTemplate) { + const apiErr = new Error( + `Product template not found for product template id ${req.params.productTemplateId}`); + apiErr.status = 404; + return Promise.reject(apiErr); + } + + // Create the milestone template + return models.ProductMilestoneTemplate.create(entity, { transaction: tx }); + }) + .then((createdEntity) => { + // Omit deletedAt and deletedBy + result = _.omit(createdEntity.toJSON(), 'deletedAt', 'deletedBy'); + + // Increase the order of the other milestone templates in the same product template, + // which have `order` >= this milestone template order + return models.ProductMilestoneTemplate.update({ order: Sequelize.literal('"order" + 1') }, { + where: { + productTemplateId: req.params.productTemplateId, + id: { $ne: result.id }, + order: { $gte: result.order }, + }, + transaction: tx, + }); + }) + .then(() => { + // Write to response + res.status(201).json(util.wrapResponse(req.id, result, 1, 201)); + }) + .catch(next), + ); + }, +]; diff --git a/src/routes/milestoneTemplates/create.spec.js b/src/routes/milestoneTemplates/create.spec.js new file mode 100644 index 00000000..6fe6c128 --- /dev/null +++ b/src/routes/milestoneTemplates/create.spec.js @@ -0,0 +1,282 @@ +/** + * Tests for create.js + */ +import chai from 'chai'; +import request from 'supertest'; +import _ from 'lodash'; +import server from '../../app'; +import testUtil from '../../tests/util'; +import models from '../../models'; + +const should = chai.should(); + +const productTemplates = [ + { + name: 'name 1', + productKey: 'productKey 1', + icon: 'http://example.com/icon1.ico', + brief: 'brief 1', + details: 'details 1', + aliases: { + alias1: { + subAlias1A: 1, + subAlias1B: 2, + }, + alias2: [1, 2, 3], + }, + template: { + template1: { + name: 'template 1', + details: { + anyDetails: 'any details 1', + }, + others: ['others 11', 'others 12'], + }, + template2: { + name: 'template 2', + details: { + anyDetails: 'any details 2', + }, + others: ['others 21', 'others 22'], + }, + }, + createdBy: 1, + updatedBy: 2, + }, + { + name: 'template 2', + productKey: 'productKey 2', + icon: 'http://example.com/icon2.ico', + brief: 'brief 2', + details: 'details 2', + aliases: {}, + template: {}, + createdBy: 3, + updatedBy: 4, + deletedAt: new Date(), + }, +]; +const milestoneTemplates = [ + { + name: 'milestoneTemplate 1', + duration: 3, + type: 'type1', + order: 1, + productTemplateId: 1, + createdBy: 1, + updatedBy: 2, + }, + { + name: 'milestoneTemplate 2', + duration: 4, + type: 'type2', + order: 2, + productTemplateId: 1, + createdBy: 2, + updatedBy: 3, + }, +]; + +describe('CREATE milestone template', () => { + beforeEach(() => testUtil.clearDb() + .then(() => models.ProductTemplate.bulkCreate(productTemplates)) + .then(() => models.ProductMilestoneTemplate.bulkCreate(milestoneTemplates)), + ); + after(testUtil.clearDb); + + describe('POST /productTemplates/{productTemplateId}/milestones', () => { + const body = { + param: { + name: 'milestoneTemplate 3', + description: 'description 3', + duration: 33, + type: 'type3', + order: 1, + }, + }; + + it('should return 403 if user is not authenticated', (done) => { + request(server) + .post('/v4/productTemplates/1/milestones') + .send(body) + .expect(403, done); + }); + + it('should return 403 for member', (done) => { + request(server) + .post('/v4/productTemplates/1/milestones') + .set({ + Authorization: `Bearer ${testUtil.jwts.member}`, + }) + .send(body) + .expect(403, done); + }); + + it('should return 403 for copilot', (done) => { + request(server) + .post('/v4/productTemplates/1/milestones') + .set({ + Authorization: `Bearer ${testUtil.jwts.copilot}`, + }) + .send(body) + .expect(403, done); + }); + + it('should return 404 for non-existed product template', (done) => { + request(server) + .post('/v4/productTemplates/1000/milestones') + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .send(body) + .expect(404, done); + }); + + it('should return 422 if missing name', (done) => { + const invalidBody = { + param: { + name: undefined, + }, + }; + + request(server) + .post('/v4/productTemplates/1/milestones') + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .send(invalidBody) + .expect('Content-Type', /json/) + .expect(422, done); + }); + + it('should return 422 if missing duration', (done) => { + const invalidBody = { + param: { + duration: undefined, + }, + }; + + request(server) + .post('/v4/productTemplates/1/milestones') + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .send(invalidBody) + .expect('Content-Type', /json/) + .expect(422, done); + }); + + it('should return 422 if missing type', (done) => { + const invalidBody = { + param: { + type: undefined, + }, + }; + + request(server) + .post('/v4/productTemplates/1/milestones') + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .send(invalidBody) + .expect('Content-Type', /json/) + .expect(422, done); + }); + + it('should return 422 if missing order', (done) => { + const invalidBody = { + param: { + order: undefined, + }, + }; + + request(server) + .post('/v4/productTemplates/1/milestones') + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .send(invalidBody) + .expect('Content-Type', /json/) + .expect(422, done); + }); + + it('should return 201 for admin', (done) => { + request(server) + .post('/v4/productTemplates/1/milestones') + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .send(body) + .expect('Content-Type', /json/) + .expect(201) + .end((err, res) => { + const resJson = res.body.result.content; + should.exist(resJson.id); + resJson.name.should.be.eql(body.param.name); + resJson.description.should.be.eql(body.param.description); + resJson.duration.should.be.eql(body.param.duration); + resJson.type.should.be.eql(body.param.type); + resJson.order.should.be.eql(body.param.order); + + resJson.createdBy.should.be.eql(40051333); // admin + should.exist(resJson.createdAt); + resJson.updatedBy.should.be.eql(40051333); // admin + should.exist(resJson.updatedAt); + should.not.exist(resJson.deletedBy); + should.not.exist(resJson.deletedAt); + + // Verify 'order' of the other milestones + models.ProductMilestoneTemplate.findAll({ + where: { + productTemplateId: 1, + }, + }) + .then((milestones) => { + _.each(milestones, (milestone) => { + if (milestone.id === 1) { + milestone.order.should.be.eql(1 + 1); + } else if (milestone.id === 2) { + milestone.order.should.be.eql(2 + 1); + } + }); + + done(); + }); + }); + }); + + it('should return 201 for connect manager', (done) => { + request(server) + .post('/v4/productTemplates/1/milestones') + .set({ + Authorization: `Bearer ${testUtil.jwts.manager}`, + }) + .send(body) + .expect('Content-Type', /json/) + .expect(201) + .end((err, res) => { + const resJson = res.body.result.content; + resJson.createdBy.should.be.eql(40051334); // manager + resJson.updatedBy.should.be.eql(40051334); // manager + done(); + }); + }); + + it('should return 201 for connect admin', (done) => { + request(server) + .post('/v4/productTemplates/1/milestones') + .set({ + Authorization: `Bearer ${testUtil.jwts.connectAdmin}`, + }) + .send(body) + .expect('Content-Type', /json/) + .expect(201) + .end((err, res) => { + const resJson = res.body.result.content; + resJson.createdBy.should.be.eql(40051336); // connect admin + resJson.updatedBy.should.be.eql(40051336); // connect admin + done(); + }); + }); + }); +}); diff --git a/src/routes/milestoneTemplates/delete.js b/src/routes/milestoneTemplates/delete.js new file mode 100644 index 00000000..bacb3e36 --- /dev/null +++ b/src/routes/milestoneTemplates/delete.js @@ -0,0 +1,57 @@ +/** + * API to delete a milestone template + */ +import validate from 'express-validation'; +import Joi from 'joi'; +import { middleware as tcMiddleware } from 'tc-core-library-js'; +import models from '../../models'; + +const permissions = tcMiddleware.permissions; + +const schema = { + params: { + productTemplateId: Joi.number().integer().positive().required(), + milestoneTemplateId: Joi.number().integer().positive().required(), + }, +}; + +module.exports = [ + validate(schema), + permissions('milestoneTemplate.delete'), + (req, res, next) => { + const where = { + id: req.params.milestoneTemplateId, + deletedAt: { $eq: null }, + productTemplateId: req.params.productTemplateId, + }; + + return models.sequelize.transaction(tx => + // Update the deletedBy + models.ProductMilestoneTemplate.update({ deletedBy: req.authUser.userId }, { + where, + returning: true, + raw: true, + transaction: tx, + }) + .then((updatedResults) => { + // Not found + if (updatedResults[0] === 0) { + const apiErr = new Error( + `Milestone template not found for milestone template id ${req.params.milestoneTemplateId}`); + apiErr.status = 404; + return Promise.reject(apiErr); + } + + // Soft delete + return models.ProductMilestoneTemplate.destroy({ + where, + transaction: tx, + }); + }) + .then(() => { + res.status(204).end(); + }) + .catch(next), + ); + }, +]; diff --git a/src/routes/milestoneTemplates/delete.spec.js b/src/routes/milestoneTemplates/delete.spec.js new file mode 100644 index 00000000..02fd111c --- /dev/null +++ b/src/routes/milestoneTemplates/delete.spec.js @@ -0,0 +1,187 @@ +/** + * Tests for delete.js + */ +import request from 'supertest'; + +import models from '../../models'; +import server from '../../app'; +import testUtil from '../../tests/util'; + +const productTemplates = [ + { + name: 'name 1', + productKey: 'productKey 1', + icon: 'http://example.com/icon1.ico', + brief: 'brief 1', + details: 'details 1', + aliases: { + alias1: { + subAlias1A: 1, + subAlias1B: 2, + }, + alias2: [1, 2, 3], + }, + template: { + template1: { + name: 'template 1', + details: { + anyDetails: 'any details 1', + }, + others: ['others 11', 'others 12'], + }, + template2: { + name: 'template 2', + details: { + anyDetails: 'any details 2', + }, + others: ['others 21', 'others 22'], + }, + }, + createdBy: 1, + updatedBy: 2, + }, + { + name: 'template 2', + productKey: 'productKey 2', + icon: 'http://example.com/icon2.ico', + brief: 'brief 2', + details: 'details 2', + aliases: {}, + template: {}, + createdBy: 3, + updatedBy: 4, + deletedAt: new Date(), + }, +]; +const milestoneTemplates = [ + { + id: 1, + name: 'milestoneTemplate 1', + duration: 3, + type: 'type1', + order: 1, + productTemplateId: 1, + createdBy: 1, + updatedBy: 2, + }, + { + id: 2, + name: 'milestoneTemplate 2', + duration: 4, + type: 'type2', + order: 2, + productTemplateId: 1, + createdBy: 2, + updatedBy: 3, + deletedAt: new Date(), + }, +]; + +describe('DELETE milestone template', () => { + beforeEach(() => testUtil.clearDb() + .then(() => models.ProductTemplate.bulkCreate(productTemplates)) + .then(() => models.ProductMilestoneTemplate.bulkCreate(milestoneTemplates)), + ); + after(testUtil.clearDb); + + describe('DELETE /productTemplates/{productTemplateId}/milestones/{milestoneTemplateId}', () => { + it('should return 403 if user is not authenticated', (done) => { + request(server) + .delete('/v4/productTemplates/1/milestones/1') + .expect(403, done); + }); + + it('should return 403 for member', (done) => { + request(server) + .delete('/v4/productTemplates/1/milestones/1') + .set({ + Authorization: `Bearer ${testUtil.jwts.member}`, + }) + .expect(403, done); + }); + + it('should return 403 for copilot', (done) => { + request(server) + .delete('/v4/productTemplates/1/milestones/1') + .set({ + Authorization: `Bearer ${testUtil.jwts.copilot}`, + }) + .expect(403, done); + }); + + it('should return 404 for non-existed product template', (done) => { + request(server) + .delete('/v4/productTemplates/1234/milestones/1') + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .expect(404, done); + }); + + it('should return 404 for non-existed milestone template', (done) => { + request(server) + .delete('/v4/productTemplates/1/milestones/444') + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .expect(404, done); + }); + + it('should return 404 for deleted milestone template', (done) => { + request(server) + .delete('/v4/productTemplates/1/milestones/2') + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .expect(404, done); + }); + + it('should return 422 for invalid productTemplateId param', (done) => { + request(server) + .delete('/v4/productTemplates/0/milestones/2') + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .expect(422, done); + }); + + it('should return 422 for invalid milestoneTemplateId param', (done) => { + request(server) + .delete('/v4/productTemplates/1/milestones/0') + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .expect(422, done); + }); + + it('should return 204, for admin, if template was successfully removed', (done) => { + request(server) + .delete('/v4/productTemplates/1/milestones/1') + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .expect(204) + .end(done); + }); + + it('should return 204, for connect admin, if template was successfully removed', (done) => { + request(server) + .delete('/v4/productTemplates/1/milestones/1') + .set({ + Authorization: `Bearer ${testUtil.jwts.connectAdmin}`, + }) + .expect(204) + .end(done); + }); + + it('should return 204, for connect manager, if template was successfully removed', (done) => { + request(server) + .delete('/v4/productTemplates/1/milestones/1') + .set({ + Authorization: `Bearer ${testUtil.jwts.manager}`, + }) + .expect(204) + .end(done); + }); + }); +}); diff --git a/src/routes/milestoneTemplates/get.js b/src/routes/milestoneTemplates/get.js new file mode 100644 index 00000000..1c1fa3f0 --- /dev/null +++ b/src/routes/milestoneTemplates/get.js @@ -0,0 +1,43 @@ +/** + * API to get a milestone template + */ +import validate from 'express-validation'; +import Joi from 'joi'; +import { middleware as tcMiddleware } from 'tc-core-library-js'; +import util from '../../util'; +import models from '../../models'; + +const permissions = tcMiddleware.permissions; + +const schema = { + params: { + productTemplateId: Joi.number().integer().positive().required(), + milestoneTemplateId: Joi.number().integer().positive().required(), + }, +}; + +module.exports = [ + validate(schema), + permissions('milestoneTemplate.view'), + (req, res, next) => models.ProductMilestoneTemplate.findOne({ + where: { + id: req.params.milestoneTemplateId, + productTemplateId: req.params.productTemplateId, + }, + attributes: { exclude: ['deletedAt', 'deletedBy'] }, + raw: true, + }) + .then((milestoneTemplate) => { + // Not found + if (!milestoneTemplate) { + const apiErr = new Error( + `Milestone template not found for milestone template id ${req.params.milestoneTemplateId}`); + apiErr.status = 404; + return Promise.reject(apiErr); + } + + res.json(util.wrapResponse(req.id, milestoneTemplate)); + return Promise.resolve(); + }) + .catch(next), +]; diff --git a/src/routes/milestoneTemplates/get.spec.js b/src/routes/milestoneTemplates/get.spec.js new file mode 100644 index 00000000..c2b144e3 --- /dev/null +++ b/src/routes/milestoneTemplates/get.spec.js @@ -0,0 +1,189 @@ +/** + * Tests for get.js + */ +import chai from 'chai'; +import request from 'supertest'; + +import models from '../../models'; +import server from '../../app'; +import testUtil from '../../tests/util'; + +const should = chai.should(); + +const productTemplates = [ + { + name: 'name 1', + productKey: 'productKey 1', + icon: 'http://example.com/icon1.ico', + brief: 'brief 1', + details: 'details 1', + aliases: { + alias1: { + subAlias1A: 1, + subAlias1B: 2, + }, + alias2: [1, 2, 3], + }, + template: { + template1: { + name: 'template 1', + details: { + anyDetails: 'any details 1', + }, + others: ['others 11', 'others 12'], + }, + template2: { + name: 'template 2', + details: { + anyDetails: 'any details 2', + }, + others: ['others 21', 'others 22'], + }, + }, + createdBy: 1, + updatedBy: 2, + }, + { + name: 'template 2', + productKey: 'productKey 2', + icon: 'http://example.com/icon2.ico', + brief: 'brief 2', + details: 'details 2', + aliases: {}, + template: {}, + createdBy: 3, + updatedBy: 4, + deletedAt: new Date(), + }, +]; +const milestoneTemplates = [ + { + id: 1, + name: 'milestoneTemplate 1', + duration: 3, + type: 'type1', + order: 1, + productTemplateId: 1, + createdBy: 1, + updatedBy: 2, + }, + { + id: 2, + name: 'milestoneTemplate 2', + duration: 4, + type: 'type2', + order: 2, + productTemplateId: 1, + createdBy: 2, + updatedBy: 3, + deletedAt: new Date(), + }, +]; + +describe('GET milestone template', () => { + beforeEach(() => testUtil.clearDb() + .then(() => models.ProductTemplate.bulkCreate(productTemplates)) + .then(() => models.ProductMilestoneTemplate.bulkCreate(milestoneTemplates)), + ); + after(testUtil.clearDb); + + describe('GET /productTemplates/{productTemplateId}/milestones/{milestoneTemplateId}', () => { + it('should return 403 if user is not authenticated', (done) => { + request(server) + .get('/v4/productTemplates/1/milestones/1') + .expect(403, done); + }); + + it('should return 404 for non-existed product template', (done) => { + request(server) + .get('/v4/productTemplates/1234/milestones/1') + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .expect(404, done); + }); + + it('should return 404 for non-existed milestone template', (done) => { + request(server) + .get('/v4/productTemplates/1/milestones/1111') + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .expect(404, done); + }); + + it('should return 404 for deleted milestone template', (done) => { + request(server) + .get('/v4/productTemplates/1/milestones/2') + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .expect(404, done); + }); + + it('should return 200 for admin', (done) => { + request(server) + .get('/v4/productTemplates/1/milestones/1') + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .expect(200) + .end((err, res) => { + const resJson = res.body.result.content; + resJson.id.should.be.eql(milestoneTemplates[0].id); + resJson.name.should.be.eql(milestoneTemplates[0].name); + resJson.duration.should.be.eql(milestoneTemplates[0].duration); + resJson.type.should.be.eql(milestoneTemplates[0].type); + resJson.order.should.be.eql(milestoneTemplates[0].order); + resJson.productTemplateId.should.be.eql(milestoneTemplates[0].productTemplateId); + + resJson.createdBy.should.be.eql(milestoneTemplates[0].createdBy); + should.exist(resJson.createdAt); + resJson.updatedBy.should.be.eql(milestoneTemplates[0].updatedBy); + should.exist(resJson.updatedAt); + should.not.exist(resJson.deletedBy); + should.not.exist(resJson.deletedAt); + + done(); + }); + }); + + it('should return 200 for connect admin', (done) => { + request(server) + .get('/v4/productTemplates/1/milestones/1') + .set({ + Authorization: `Bearer ${testUtil.jwts.connectAdmin}`, + }) + .expect(200) + .end(done); + }); + + it('should return 200 for connect manager', (done) => { + request(server) + .get('/v4/productTemplates/1/milestones/1') + .set({ + Authorization: `Bearer ${testUtil.jwts.manager}`, + }) + .expect(200) + .end(done); + }); + + it('should return 200 for member', (done) => { + request(server) + .get('/v4/productTemplates/1/milestones/1') + .set({ + Authorization: `Bearer ${testUtil.jwts.member}`, + }) + .expect(200, done); + }); + + it('should return 200 for copilot', (done) => { + request(server) + .get('/v4/productTemplates/1/milestones/1') + .set({ + Authorization: `Bearer ${testUtil.jwts.copilot}`, + }) + .expect(200, done); + }); + }); +}); diff --git a/src/routes/milestoneTemplates/list.js b/src/routes/milestoneTemplates/list.js new file mode 100644 index 00000000..40b6ae19 --- /dev/null +++ b/src/routes/milestoneTemplates/list.js @@ -0,0 +1,52 @@ +/** + * API to list all milestone templates + */ +import validate from 'express-validation'; +import Joi from 'joi'; +import _ from 'lodash'; +import { middleware as tcMiddleware } from 'tc-core-library-js'; +import util from '../../util'; +import models from '../../models'; + +const permissions = tcMiddleware.permissions; + +const schema = { + params: { + productTemplateId: Joi.number().integer().positive().required(), + }, +}; + +module.exports = [ + validate(schema), + permissions('milestoneTemplate.view'), + (req, res, next) => { + // Parse the sort query + let sort = req.query.sort ? decodeURIComponent(req.query.sort) : 'order'; + if (sort && sort.indexOf(' ') === -1) { + sort += ' asc'; + } + const sortableProps = [ + 'order asc', 'order desc', + ]; + if (sort && _.indexOf(sortableProps, sort) < 0) { + const apiErr = new Error('Invalid sort criteria'); + apiErr.status = 422; + return next(apiErr); + } + const sortColumnAndOrder = sort.split(' '); + + // Get all milestone templates + return models.ProductMilestoneTemplate.findAll({ + where: { + productTemplateId: req.params.productTemplateId, + }, + order: [sortColumnAndOrder], + attributes: { exclude: ['deletedAt', 'deletedBy'] }, + raw: true, + }) + .then((milestoneTemplates) => { + res.json(util.wrapResponse(req.id, milestoneTemplates)); + }) + .catch(next); + }, +]; diff --git a/src/routes/milestoneTemplates/list.spec.js b/src/routes/milestoneTemplates/list.spec.js new file mode 100644 index 00000000..87fb3228 --- /dev/null +++ b/src/routes/milestoneTemplates/list.spec.js @@ -0,0 +1,217 @@ +/** + * Tests for list.js + */ +import chai from 'chai'; +import request from 'supertest'; + +import models from '../../models'; +import server from '../../app'; +import testUtil from '../../tests/util'; + +const should = chai.should(); + +const productTemplates = [ + { + name: 'name 1', + productKey: 'productKey 1', + icon: 'http://example.com/icon1.ico', + brief: 'brief 1', + details: 'details 1', + aliases: { + alias1: { + subAlias1A: 1, + subAlias1B: 2, + }, + alias2: [1, 2, 3], + }, + template: { + template1: { + name: 'template 1', + details: { + anyDetails: 'any details 1', + }, + others: ['others 11', 'others 12'], + }, + template2: { + name: 'template 2', + details: { + anyDetails: 'any details 2', + }, + others: ['others 21', 'others 22'], + }, + }, + createdBy: 1, + updatedBy: 2, + }, + { + name: 'template 2', + productKey: 'productKey 2', + icon: 'http://example.com/icon2.ico', + brief: 'brief 2', + details: 'details 2', + aliases: {}, + template: {}, + createdBy: 3, + updatedBy: 4, + deletedAt: new Date(), + }, +]; +const milestoneTemplates = [ + { + id: 1, + name: 'milestoneTemplate 1', + duration: 3, + type: 'type1', + order: 1, + productTemplateId: 1, + createdBy: 1, + updatedBy: 2, + }, + { + id: 2, + name: 'milestoneTemplate 2', + duration: 4, + type: 'type2', + order: 2, + productTemplateId: 1, + createdBy: 2, + updatedBy: 3, + }, + { + id: 3, + name: 'milestoneTemplate 3', + duration: 5, + type: 'type3', + order: 3, + productTemplateId: 1, + createdBy: 2, + updatedBy: 3, + deletedAt: new Date(), + }, +]; + +describe('LIST milestone template', () => { + beforeEach(() => testUtil.clearDb() + .then(() => models.ProductTemplate.bulkCreate(productTemplates)) + .then(() => models.ProductMilestoneTemplate.bulkCreate(milestoneTemplates)), + ); + after(testUtil.clearDb); + + describe('GET /productTemplates/{productTemplateId}/milestones', () => { + it('should return 403 if user is not authenticated', (done) => { + request(server) + .get('/v4/productTemplates/1/milestones') + .expect(403, done); + }); + + it('should return 422 for invalid productTemplateId param', (done) => { + request(server) + .get('/v4/productTemplates/0/milestones') + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .expect(422, done); + }); + + it('should return 422 for invalid sort column', (done) => { + request(server) + .get('/v4/productTemplates/1/milestones?sort=id') + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .expect(422, done); + }); + + it('should return 422 for invalid sort order', (done) => { + request(server) + .get('/v4/productTemplates/1/milestones?sort=order%20invalid') + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .expect(422, done); + }); + + it('should return 200 for admin', (done) => { + request(server) + .get('/v4/productTemplates/1/milestones') + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .expect(200) + .end((err, res) => { + const resJson = res.body.result.content; + resJson.should.have.length(2); + resJson[0].id.should.be.eql(milestoneTemplates[0].id); + resJson[0].name.should.be.eql(milestoneTemplates[0].name); + resJson[0].duration.should.be.eql(milestoneTemplates[0].duration); + resJson[0].type.should.be.eql(milestoneTemplates[0].type); + resJson[0].order.should.be.eql(milestoneTemplates[0].order); + resJson[0].productTemplateId.should.be.eql(milestoneTemplates[0].productTemplateId); + + resJson[0].createdBy.should.be.eql(milestoneTemplates[0].createdBy); + should.exist(resJson[0].createdAt); + resJson[0].updatedBy.should.be.eql(milestoneTemplates[0].updatedBy); + should.exist(resJson[0].updatedAt); + should.not.exist(resJson[0].deletedBy); + should.not.exist(resJson[0].deletedAt); + + done(); + }); + }); + + it('should return 200 for connect admin', (done) => { + request(server) + .get('/v4/productTemplates/1/milestones') + .set({ + Authorization: `Bearer ${testUtil.jwts.connectAdmin}`, + }) + .expect(200) + .end(done); + }); + + it('should return 200 for connect manager', (done) => { + request(server) + .get('/v4/productTemplates/1/milestones') + .set({ + Authorization: `Bearer ${testUtil.jwts.manager}`, + }) + .expect(200) + .end(done); + }); + + it('should return 200 for member', (done) => { + request(server) + .get('/v4/productTemplates/1/milestones') + .set({ + Authorization: `Bearer ${testUtil.jwts.member}`, + }) + .expect(200, done); + }); + + it('should return 200 for copilot', (done) => { + request(server) + .get('/v4/productTemplates/1/milestones') + .set({ + Authorization: `Bearer ${testUtil.jwts.copilot}`, + }) + .expect(200, done); + }); + + it('should return 200 with sort desc', (done) => { + request(server) + .get('/v4/productTemplates/1/milestones?sort=order%20desc') + .set({ + Authorization: `Bearer ${testUtil.jwts.copilot}`, + }) + .expect(200) + .end((err, res) => { + const resJson = res.body.result.content; + resJson.should.have.length(2); + resJson[0].id.should.be.eql(2); + resJson[1].id.should.be.eql(1); + + done(); + }); + }); + }); +}); diff --git a/src/routes/milestoneTemplates/update.js b/src/routes/milestoneTemplates/update.js new file mode 100644 index 00000000..65da9e9f --- /dev/null +++ b/src/routes/milestoneTemplates/update.js @@ -0,0 +1,121 @@ +/** + * API to update a milestone template + */ +import validate from 'express-validation'; +import _ from 'lodash'; +import Joi from 'joi'; +import Sequelize from 'sequelize'; +import { middleware as tcMiddleware } from 'tc-core-library-js'; +import util from '../../util'; +import models from '../../models'; + +const permissions = tcMiddleware.permissions; + +const schema = { + params: { + productTemplateId: Joi.number().integer().positive().required(), + milestoneTemplateId: Joi.number().integer().positive().required(), + }, + body: { + param: Joi.object().keys({ + id: Joi.any().strip(), + name: Joi.string().max(255).required(), + description: Joi.string().max(255), + duration: Joi.number().integer().required(), + type: Joi.string().max(45).required(), + order: Joi.number().integer().required(), + productTemplateId: Joi.any().strip(), + createdAt: Joi.any().strip(), + updatedAt: Joi.any().strip(), + deletedAt: Joi.any().strip(), + createdBy: Joi.any().strip(), + updatedBy: Joi.any().strip(), + deletedBy: Joi.any().strip(), + }).required(), + }, +}; + +module.exports = [ + validate(schema), + permissions('milestoneTemplate.edit'), + (req, res, next) => { + const entityToUpdate = _.assign(req.body.param, { + updatedBy: req.authUser.userId, + }); + + let original; + let updated; + + return models.sequelize.transaction(() => + // Get the milestone template + models.ProductMilestoneTemplate.findOne({ + where: { + id: req.params.milestoneTemplateId, + productTemplateId: req.params.productTemplateId, + }, + attributes: { exclude: ['deletedAt', 'deletedBy'] }, + }) + .then((milestoneTemplate) => { + // Not found + if (!milestoneTemplate) { + const apiErr = new Error(`Milestone template not found for template id ${req.params.milestoneTemplateId}`); + apiErr.status = 404; + return Promise.reject(apiErr); + } + + original = _.omit(milestoneTemplate.toJSON(), ['deletedAt', 'deletedBy']); + + // Update + return milestoneTemplate.update(entityToUpdate); + }) + .then((milestoneTemplate) => { + updated = _.omit(milestoneTemplate.toJSON(), ['deletedAt', 'deletedBy']); + + // Update order of the other milestones only if the order was changed + if (original.order === updated.order) { + return Promise.resolve(); + } + + return models.ProductMilestoneTemplate.count({ + where: { + productTemplateId: updated.productTemplateId, + id: { $ne: updated.id }, + order: updated.order, + }, + }) + .then((count) => { + if (count === 0) { + return Promise.resolve(); + } + + // Increase the order from M to K: if there is an item with order K, + // orders from M+1 to K should be made M to K-1 + if (original.order < updated.order) { + return models.ProductMilestoneTemplate.update({ order: Sequelize.literal('"order" - 1') }, { + where: { + productTemplateId: updated.productTemplateId, + id: { $ne: updated.id }, + order: { $between: [original.order + 1, updated.order] }, + }, + }); + } + + // Decrease the order from M to K: if there is an item with order K, + // orders from K to M-1 should be made K+1 to M + return models.ProductMilestoneTemplate.update({ order: Sequelize.literal('"order" + 1') }, { + where: { + productTemplateId: updated.productTemplateId, + id: { $ne: updated.id }, + order: { $between: [updated.order, original.order - 1] }, + }, + }); + }); + }) + .then(() => { + res.json(util.wrapResponse(req.id, updated)); + return Promise.resolve(); + }) + .catch(next), + ); + }, +]; diff --git a/src/routes/milestoneTemplates/update.spec.js b/src/routes/milestoneTemplates/update.spec.js new file mode 100644 index 00000000..297f6ea9 --- /dev/null +++ b/src/routes/milestoneTemplates/update.spec.js @@ -0,0 +1,428 @@ +/** + * Tests for get.js + */ +import chai from 'chai'; +import request from 'supertest'; +import _ from 'lodash'; +import models from '../../models'; +import server from '../../app'; +import testUtil from '../../tests/util'; + +const should = chai.should(); + +const productTemplates = [ + { + name: 'name 1', + productKey: 'productKey 1', + icon: 'http://example.com/icon1.ico', + brief: 'brief 1', + details: 'details 1', + aliases: { + alias1: { + subAlias1A: 1, + subAlias1B: 2, + }, + alias2: [1, 2, 3], + }, + template: { + template1: { + name: 'template 1', + details: { + anyDetails: 'any details 1', + }, + others: ['others 11', 'others 12'], + }, + template2: { + name: 'template 2', + details: { + anyDetails: 'any details 2', + }, + others: ['others 21', 'others 22'], + }, + }, + createdBy: 1, + updatedBy: 2, + }, + { + name: 'template 2', + productKey: 'productKey 2', + icon: 'http://example.com/icon2.ico', + brief: 'brief 2', + details: 'details 2', + aliases: {}, + template: {}, + createdBy: 3, + updatedBy: 4, + deletedAt: new Date(), + }, +]; +const milestoneTemplates = [ + { + id: 1, + name: 'milestoneTemplate 1', + duration: 3, + type: 'type1', + order: 1, + productTemplateId: 1, + createdBy: 1, + updatedBy: 2, + }, + { + id: 2, + name: 'milestoneTemplate 2', + duration: 4, + type: 'type2', + order: 2, + productTemplateId: 1, + createdBy: 2, + updatedBy: 3, + }, + { + id: 3, + name: 'milestoneTemplate 3', + duration: 5, + type: 'type3', + order: 3, + productTemplateId: 1, + createdBy: 2, + updatedBy: 3, + }, + { + id: 4, + name: 'milestoneTemplate 4', + duration: 5, + type: 'type4', + order: 4, + productTemplateId: 1, + createdBy: 2, + updatedBy: 3, + deletedAt: new Date(), + }, +]; + +describe('UPDATE milestone template', () => { + beforeEach(() => testUtil.clearDb() + .then(() => models.ProductTemplate.bulkCreate(productTemplates)) + .then(() => models.ProductMilestoneTemplate.bulkCreate(milestoneTemplates)), + ); + after(testUtil.clearDb); + + describe('PATCH /productTemplates/{productTemplateId}/milestones/{milestoneTemplateId}', () => { + const body = { + param: { + name: 'milestoneTemplate 1-updated', + description: 'description-updated', + duration: 6, + type: 'type1-updated', + order: 5, + }, + }; + + it('should return 403 if user is not authenticated', (done) => { + request(server) + .patch('/v4/productTemplates/1/milestones/1') + .send(body) + .expect(403, done); + }); + + it('should return 403 for member', (done) => { + request(server) + .patch('/v4/productTemplates/1/milestones/1') + .set({ + Authorization: `Bearer ${testUtil.jwts.member}`, + }) + .send(body) + .expect(403, done); + }); + + it('should return 403 for copilot', (done) => { + request(server) + .patch('/v4/productTemplates/1/milestones/1') + .send(body) + .set({ + Authorization: `Bearer ${testUtil.jwts.copilot}`, + }) + .expect(403, done); + }); + + it('should return 422 for missing name', (done) => { + const invalidBody = { + param: { + name: undefined, + }, + }; + + request(server) + .patch('/v4/productTemplates/1/milestones/1') + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .send(invalidBody) + .expect(422, done); + }); + + it('should return 422 for missing type', (done) => { + const invalidBody = { + param: { + type: undefined, + }, + }; + + request(server) + .patch('/v4/productTemplates/1/milestones/1') + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .send(invalidBody) + .expect(422, done); + }); + + it('should return 422 for missing duration', (done) => { + const invalidBody = { + param: { + duration: undefined, + }, + }; + + request(server) + .patch('/v4/productTemplates/1/milestones/1') + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .send(invalidBody) + .expect(422, done); + }); + + it('should return 422 for missing order', (done) => { + const invalidBody = { + param: { + order: undefined, + }, + }; + + request(server) + .patch('/v4/productTemplates/1/milestones/1') + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .send(invalidBody) + .expect(422, done); + }); + + it('should return 404 for non-existed product template', (done) => { + request(server) + .patch('/v4/productTemplates/122/milestones/1') + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .send(body) + .expect(404, done); + }); + + it('should return 404 for non-existed milestone template', (done) => { + request(server) + .patch('/v4/productTemplates/1/milestones/111') + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .send(body) + .expect(404, done); + }); + + it('should return 404 for deleted milestone template', (done) => { + request(server) + .patch('/v4/productTemplates/1/milestones/4') + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .send(body) + .expect(404, done); + }); + + it('should return 200 for admin', (done) => { + request(server) + .patch('/v4/productTemplates/1/milestones/1') + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .send(body) + .expect(200) + .end((err, res) => { + const resJson = res.body.result.content; + resJson.id.should.be.eql(1); + resJson.name.should.be.eql(body.param.name); + resJson.description.should.be.eql(body.param.description); + resJson.duration.should.be.eql(body.param.duration); + resJson.type.should.be.eql(body.param.type); + resJson.order.should.be.eql(body.param.order); + + should.exist(resJson.createdBy); + should.exist(resJson.createdAt); + resJson.updatedBy.should.be.eql(40051333); // admin + should.exist(resJson.updatedAt); + should.not.exist(resJson.deletedBy); + should.not.exist(resJson.deletedAt); + + done(); + }); + }); + + // eslint-disable-next-line func-names + it('should return 200 for admin - order increases and replaces another milestone\'s order', function (done) { + this.timeout(10000); + + request(server) + .patch('/v4/productTemplates/1/milestones/1') + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .send({ param: _.assign({}, body.param, { order: 3 }) }) // 1 to 3 + .expect(200) + .end(() => { + // Milestone 1: order 3 + // Milestone 2: order 2 - 1 = 1 + // Milestone 3: order 3 - 1 = 2 + setTimeout(() => { + models.ProductMilestoneTemplate.findById(1) + .then((milestone) => { + milestone.order.should.be.eql(3); + }) + .then(() => models.ProductMilestoneTemplate.findById(2)) + .then((milestone) => { + milestone.order.should.be.eql(1); + }) + .then(() => models.ProductMilestoneTemplate.findById(3)) + .then((milestone) => { + milestone.order.should.be.eql(2); + + done(); + }); + }, 3000); + }); + }); + + // eslint-disable-next-line func-names + it('should return 200 for admin - order increases and doesnot replace another milestone\'s order', function (done) { + this.timeout(10000); + + request(server) + .patch('/v4/productTemplates/1/milestones/1') + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .send({ param: _.assign({}, body.param, { order: 4 }) }) // 1 to 4 + .expect(200) + .end(() => { + // Milestone 1: order 4 + // Milestone 2: order 2 + // Milestone 3: order 3 + setTimeout(() => { + models.ProductMilestoneTemplate.findById(1) + .then((milestone) => { + milestone.order.should.be.eql(4); + }) + .then(() => models.ProductMilestoneTemplate.findById(2)) + .then((milestone) => { + milestone.order.should.be.eql(2); + }) + .then(() => models.ProductMilestoneTemplate.findById(3)) + .then((milestone) => { + milestone.order.should.be.eql(3); + + done(); + }); + }, 3000); + }); + }); + + // eslint-disable-next-line func-names + it('should return 200 for admin - order decreases and replaces another milestone\'s order', function (done) { + this.timeout(10000); + + request(server) + .patch('/v4/productTemplates/1/milestones/3') + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .send({ param: _.assign({}, body.param, { order: 1 }) }) // 3 to 1 + .expect(200) + .end(() => { + // Milestone 1: order 2 + // Milestone 2: order 3 + // Milestone 3: order 1 + setTimeout(() => { + models.ProductMilestoneTemplate.findById(1) + .then((milestone) => { + milestone.order.should.be.eql(2); + }) + .then(() => models.ProductMilestoneTemplate.findById(2)) + .then((milestone) => { + milestone.order.should.be.eql(3); + }) + .then(() => models.ProductMilestoneTemplate.findById(3)) + .then((milestone) => { + milestone.order.should.be.eql(1); + + done(); + }); + }, 3000); + }); + }); + + // eslint-disable-next-line func-names + it('should return 200 for admin - order decreases and doesnot replace another milestone\'s order', function (done) { + this.timeout(10000); + + request(server) + .patch('/v4/productTemplates/1/milestones/3') + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .send({ param: _.assign({}, body.param, { order: 0 }) }) // 3 to 0 + .expect(200) + .end(() => { + // Milestone 1: order 1 + // Milestone 2: order 2 + // Milestone 3: order 0 + setTimeout(() => { + models.ProductMilestoneTemplate.findById(1) + .then((milestone) => { + milestone.order.should.be.eql(1); + }) + .then(() => models.ProductMilestoneTemplate.findById(2)) + .then((milestone) => { + milestone.order.should.be.eql(2); + }) + .then(() => models.ProductMilestoneTemplate.findById(3)) + .then((milestone) => { + milestone.order.should.be.eql(0); + + done(); + }); + }, 3000); + }); + }); + + it('should return 200 for connect admin', (done) => { + request(server) + .patch('/v4/productTemplates/1/milestones/1') + .set({ + Authorization: `Bearer ${testUtil.jwts.connectAdmin}`, + }) + .send(body) + .expect(200) + .end(done); + }); + + it('should return 200 for connect manager', (done) => { + request(server) + .patch('/v4/productTemplates/1/milestones/1') + .set({ + Authorization: `Bearer ${testUtil.jwts.manager}`, + }) + .send(body) + .expect(200) + .end(done); + }); + }); +}); diff --git a/src/routes/milestones/create.js b/src/routes/milestones/create.js new file mode 100644 index 00000000..f653d685 --- /dev/null +++ b/src/routes/milestones/create.js @@ -0,0 +1,110 @@ +/** + * API to add a milestone + */ +import validate from 'express-validation'; +import _ from 'lodash'; +import Joi from 'joi'; +import { middleware as tcMiddleware } from 'tc-core-library-js'; +import Sequelize from 'sequelize'; +import util from '../../util'; +import models from '../../models'; +import { EVENT } from '../../constants'; + +const permissions = tcMiddleware.permissions; + +const schema = { + params: { + timelineId: Joi.number().integer().positive().required(), + }, + body: { + param: Joi.object().keys({ + id: Joi.any().strip(), + name: Joi.string().max(255).required(), + description: Joi.string().max(255), + duration: Joi.number().integer().required(), + startDate: Joi.date().required(), + endDate: Joi.date().min(Joi.ref('startDate')).allow(null), + completionDate: Joi.date().min(Joi.ref('startDate')).allow(null), + status: Joi.string().max(45).required(), + type: Joi.string().max(45).required(), + details: Joi.object(), + order: Joi.number().integer().required(), + plannedText: Joi.string().max(512).required(), + activeText: Joi.string().max(512).required(), + completedText: Joi.string().max(512).required(), + blockedText: Joi.string().max(512).required(), + createdAt: Joi.any().strip(), + updatedAt: Joi.any().strip(), + deletedAt: Joi.any().strip(), + createdBy: Joi.any().strip(), + updatedBy: Joi.any().strip(), + deletedBy: Joi.any().strip(), + }).required(), + }, +}; + +module.exports = [ + validate(schema), + // Validate and get projectId from the timelineId param, and set to request params + // for checking by the permissions middleware + util.validateTimelineIdParam, + permissions('milestone.create'), + (req, res, next) => { + const entity = _.assign(req.body.param, { + createdBy: req.authUser.userId, + updatedBy: req.authUser.userId, + timelineId: req.params.timelineId, + }); + let result; + + // Validate startDate and endDate to be within the timeline startDate and endDate + let error; + if (req.body.param.startDate < req.timeline.startDate) { + error = 'Milestone startDate must not be before the timeline startDate'; + } else if (req.body.param.endDate && req.timeline.endDate && req.body.param.endDate > req.timeline.endDate) { + error = 'Milestone endDate must not be after the timeline endDate'; + } + if (error) { + const apiErr = new Error(error); + apiErr.status = 422; + return next(apiErr); + } + + return models.sequelize.transaction(tx => + // Save to DB + models.Milestone.create(entity, { transaction: tx }) + .then((createdEntity) => { + // Omit deletedAt, deletedBy + result = _.omit(createdEntity.toJSON(), 'deletedAt', 'deletedBy'); + + // Send event to bus + req.log.debug('Sending event to RabbitMQ bus for milestone %d', result.id); + req.app.services.pubsub.publish(EVENT.ROUTING_KEY.MILESTONE_ADDED, + result, + { correlationId: req.id }, + ); + + // Increase the order of the other milestones in the same timeline, + // which have `order` >= this milestone order + return models.Milestone.update({ order: Sequelize.literal('"order" + 1') }, { + where: { + timelineId: result.timelineId, + id: { $ne: result.id }, + order: { $gte: result.order }, + }, + transaction: tx, + }); + }) + .then(() => { + // Do not send events for the updated milestones here, + // because it will make 'version conflict' error in ES. + // The order of the other milestones need to be updated in the MILESTONE_ADDED event handler + + // Write to the response + res.status(201).json(util.wrapResponse(req.id, result, 1, 201)); + return Promise.resolve(); + }) + .catch(next), + ); + }, +]; diff --git a/src/routes/milestones/create.spec.js b/src/routes/milestones/create.spec.js new file mode 100644 index 00000000..98e72001 --- /dev/null +++ b/src/routes/milestones/create.spec.js @@ -0,0 +1,606 @@ +/** + * Tests for create.js + */ +import chai from 'chai'; +import request from 'supertest'; +import _ from 'lodash'; +import server from '../../app'; +import testUtil from '../../tests/util'; +import models from '../../models'; +import { EVENT } from '../../constants'; + +const should = chai.should(); + +describe('CREATE milestone', () => { + let projectId1; + let projectId2; + + beforeEach((done) => { + testUtil.clearDb() + .then(() => { + models.Project.bulkCreate([ + { + type: 'generic', + billingAccountId: 1, + name: 'test1', + description: 'test project1', + status: 'draft', + details: {}, + createdBy: 1, + updatedBy: 1, + }, + { + type: 'generic', + billingAccountId: 2, + name: 'test2', + description: 'test project2', + status: 'draft', + details: {}, + createdBy: 2, + updatedBy: 2, + deletedAt: '2018-05-15T00:00:00Z', + }, + ], { returning: true }) + .then((projects) => { + projectId1 = projects[0].id; + projectId2 = projects[1].id; + + // Create member + models.ProjectMember.bulkCreate([ + { + userId: 40051332, + projectId: projectId1, + role: 'copilot', + isPrimary: true, + createdBy: 1, + updatedBy: 1, + }, + { + userId: 40051331, + projectId: projectId1, + role: 'customer', + isPrimary: true, + createdBy: 1, + updatedBy: 1, + }, + ]).then(() => + // Create phase + models.ProjectPhase.bulkCreate([ + { + projectId: projectId1, + name: 'test project phase 1', + 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 2', + }, + createdBy: 1, + updatedBy: 1, + }, + { + projectId: projectId2, + name: 'test project phase 2', + status: 'active', + startDate: '2018-05-16T00:00:00Z', + endDate: '2018-05-16T12:00:00Z', + budget: 21.0, + progress: 1.234567, + details: { + message: 'This can be any json 2', + }, + createdBy: 2, + updatedBy: 2, + deletedAt: '2018-05-15T00:00:00Z', + }, + ])) + .then(() => + // Create timelines + models.Timeline.bulkCreate([ + { + name: 'name 1', + description: 'description 1', + startDate: '2018-05-02T00:00:00.000Z', + endDate: '2018-06-12T00:00:00.000Z', + reference: 'project', + referenceId: 1, + createdBy: 1, + updatedBy: 1, + }, + { + name: 'name 2', + description: 'description 2', + startDate: '2018-05-12T00:00:00.000Z', + endDate: '2018-06-13T00:00:00.000Z', + reference: 'phase', + referenceId: 1, + createdBy: 1, + updatedBy: 1, + }, + { + name: 'name 3', + description: 'description 3', + startDate: '2018-05-13T00:00:00.000Z', + endDate: '2018-06-14T00:00:00.000Z', + reference: 'phase', + referenceId: 1, + createdBy: 1, + updatedBy: 1, + deletedAt: '2018-05-14T00:00:00.000Z', + }, + ])) + .then(() => { + // Create milestones + models.Milestone.bulkCreate([ + { + timelineId: 1, + name: 'milestone 1', + duration: 2, + startDate: '2018-05-03T00:00:00.000Z', + status: 'open', + 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, + }, + { + timelineId: 1, + name: 'milestone 2', + duration: 3, + startDate: '2018-05-04T00:00:00.000Z', + status: 'open', + type: 'type2', + order: 2, + plannedText: 'plannedText 2', + activeText: 'activeText 2', + completedText: 'completedText 2', + blockedText: 'blockedText 2', + createdBy: 2, + updatedBy: 3, + }, + { + timelineId: 1, + name: 'milestone 3', + duration: 4, + startDate: '2018-05-04T00:00:00.000Z', + status: 'open', + type: 'type3', + order: 3, + plannedText: 'plannedText 3', + activeText: 'activeText 3', + completedText: 'completedText 3', + blockedText: 'blockedText 3', + createdBy: 3, + updatedBy: 4, + }, + ]) + .then(() => done()); + }); + }); + }); + }); + + after(testUtil.clearDb); + + describe('POST /timelines/{timelineId}/milestones', () => { + const body = { + param: { + name: 'milestone 4', + description: 'description 4', + duration: 4, + startDate: '2018-05-05T00:00:00.000Z', + endDate: '2018-05-07T00:00:00.000Z', + completionDate: '2018-05-08T00:00:00.000Z', + status: 'open', + type: 'type4', + details: { + detail1: { + subDetail1C: 4, + }, + detail2: [ + 3, + 4, + 5, + ], + }, + order: 2, + plannedText: 'plannedText 4', + activeText: 'activeText 4', + completedText: 'completedText 4', + blockedText: 'blockedText 4', + }, + }; + + it('should return 403 if user is not authenticated', (done) => { + request(server) + .post('/v4/timelines/1/milestones') + .send(body) + .expect(403, done); + }); + + it('should return 403 for member who is not in the project', (done) => { + request(server) + .post('/v4/timelines/1/milestones') + .set({ + Authorization: `Bearer ${testUtil.jwts.member2}`, + }) + .send(body) + .expect(403, done); + }); + + it('should return 422 if missing name', (done) => { + const invalidBody = { + param: _.assign({}, body.param, { + name: undefined, + }), + }; + + request(server) + .post('/v4/timelines/1/milestones') + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .send(invalidBody) + .expect('Content-Type', /json/) + .expect(422, done); + }); + + it('should return 422 if missing duration', (done) => { + const invalidBody = { + param: _.assign({}, body.param, { + duration: undefined, + }), + }; + + request(server) + .post('/v4/timelines/1/milestones') + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .send(invalidBody) + .expect('Content-Type', /json/) + .expect(422, done); + }); + + it('should return 422 if missing type', (done) => { + const invalidBody = { + param: _.assign({}, body.param, { + type: undefined, + }), + }; + + request(server) + .post('/v4/timelines/1/milestones') + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .send(invalidBody) + .expect('Content-Type', /json/) + .expect(422, done); + }); + + it('should return 422 if missing order', (done) => { + const invalidBody = { + param: _.assign({}, body.param, { + order: undefined, + }), + }; + + request(server) + .post('/v4/timelines/1/milestones') + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .send(invalidBody) + .expect('Content-Type', /json/) + .expect(422, done); + }); + + it('should return 422 if missing plannedText', (done) => { + const invalidBody = { + param: _.assign({}, body.param, { + plannedText: undefined, + }), + }; + + request(server) + .post('/v4/timelines/1/milestones') + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .send(invalidBody) + .expect('Content-Type', /json/) + .expect(422, done); + }); + + it('should return 422 if missing activeText', (done) => { + const invalidBody = { + param: _.assign({}, body.param, { + activeText: undefined, + }), + }; + + request(server) + .post('/v4/timelines/1/milestones') + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .send(invalidBody) + .expect('Content-Type', /json/) + .expect(422, done); + }); + + it('should return 422 if missing completedText', (done) => { + const invalidBody = { + param: _.assign({}, body.param, { + completedText: undefined, + }), + }; + + request(server) + .post('/v4/timelines/1/milestones') + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .send(invalidBody) + .expect('Content-Type', /json/) + .expect(422, done); + }); + + it('should return 422 if missing blockedText', (done) => { + const invalidBody = { + param: _.assign({}, body.param, { + blockedText: undefined, + }), + }; + + request(server) + .post('/v4/timelines/1/milestones') + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .send(invalidBody) + .expect('Content-Type', /json/) + .expect(422, done); + }); + + it('should return 422 if startDate is after endDate', (done) => { + const invalidBody = { + param: _.assign({}, body.param, { + startDate: '2018-05-29T00:00:00.000Z', + endDate: '2018-05-28T00:00:00.000Z', + }), + }; + + request(server) + .post('/v4/timelines/1/milestones') + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .send(invalidBody) + .expect('Content-Type', /json/) + .expect(422, done); + }); + + it('should return 422 if startDate is after completionDate', (done) => { + const invalidBody = { + param: _.assign({}, body.param, { + startDate: '2018-05-29T00:00:00.000Z', + completionDate: '2018-05-28T00:00:00.000Z', + }), + }; + + request(server) + .post('/v4/timelines/1/milestones') + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .send(invalidBody) + .expect('Content-Type', /json/) + .expect(422, done); + }); + + it('should return 422 if startDate is before the timeline startDate', (done) => { + const invalidBody = { + param: _.assign({}, body.param, { + startDate: '2018-05-01T00:00:00.000Z', + }), + }; + + request(server) + .post('/v4/timelines/1/milestones') + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .send(invalidBody) + .expect('Content-Type', /json/) + .expect(422, done); + }); + + it('should return 422 if endDate is after the timeline endDate', (done) => { + const invalidBody = { + param: _.assign({}, body.param, { + endDate: '2018-06-13T00:00:00.000Z', + }), + }; + + request(server) + .post('/v4/timelines/1/milestones') + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .send(invalidBody) + .expect('Content-Type', /json/) + .expect(422, done); + }); + + it('should return 422 if invalid timelineId param', (done) => { + request(server) + .post('/v4/timelines/0/milestones') + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .send(body) + .expect('Content-Type', /json/) + .expect(422, done); + }); + + it('should return 404 if timeline does not exist', (done) => { + request(server) + .post('/v4/timelines/1000/milestones') + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .send(body) + .expect('Content-Type', /json/) + .expect(404, done); + }); + + it('should return 404 if timeline was deleted', (done) => { + request(server) + .post('/v4/timelines/3/milestones') + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .send(body) + .expect('Content-Type', /json/) + .expect(404, done); + }); + + it('should return 201 for admin', (done) => { + request(server) + .post('/v4/timelines/1/milestones') + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .send(body) + .expect('Content-Type', /json/) + .expect(201) + .end((err, res) => { + const resJson = res.body.result.content; + should.exist(resJson.id); + resJson.name.should.be.eql(body.param.name); + resJson.description.should.be.eql(body.param.description); + resJson.duration.should.be.eql(body.param.duration); + resJson.startDate.should.be.eql(body.param.startDate); + resJson.endDate.should.be.eql(body.param.endDate); + resJson.completionDate.should.be.eql(body.param.completionDate); + resJson.status.should.be.eql(body.param.status); + resJson.type.should.be.eql(body.param.type); + resJson.details.should.be.eql(body.param.details); + resJson.order.should.be.eql(body.param.order); + resJson.plannedText.should.be.eql(body.param.plannedText); + resJson.activeText.should.be.eql(body.param.activeText); + resJson.completedText.should.be.eql(body.param.completedText); + resJson.blockedText.should.be.eql(body.param.blockedText); + + resJson.createdBy.should.be.eql(40051333); // admin + should.exist(resJson.createdAt); + resJson.updatedBy.should.be.eql(40051333); // admin + should.exist(resJson.updatedAt); + should.not.exist(resJson.deletedBy); + should.not.exist(resJson.deletedAt); + + // eslint-disable-next-line no-unused-expressions + server.services.pubsub.publish.calledWith(EVENT.ROUTING_KEY.MILESTONE_ADDED).should.be.true; + + // Verify 'order' of the other milestones + models.Milestone.findAll({ where: { timelineId: 1 } }) + .then((milestones) => { + _.each(milestones, (milestone) => { + if (milestone.id === 1) { + milestone.order.should.be.eql(1); + } else if (milestone.id === 2) { + milestone.order.should.be.eql(2 + 1); + } else if (milestone.id === 3) { + milestone.order.should.be.eql(3 + 1); + } + }); + + done(); + }); + }); + }); + + it('should return 201 for connect manager', (done) => { + request(server) + .post('/v4/timelines/1/milestones') + .set({ + Authorization: `Bearer ${testUtil.jwts.manager}`, + }) + .send(body) + .expect('Content-Type', /json/) + .expect(201) + .end((err, res) => { + const resJson = res.body.result.content; + resJson.createdBy.should.be.eql(40051334); // manager + resJson.updatedBy.should.be.eql(40051334); // manager + done(); + }); + }); + + it('should return 201 for connect admin', (done) => { + request(server) + .post('/v4/timelines/1/milestones') + .set({ + Authorization: `Bearer ${testUtil.jwts.connectAdmin}`, + }) + .send(body) + .expect('Content-Type', /json/) + .expect(201) + .end((err, res) => { + const resJson = res.body.result.content; + resJson.createdBy.should.be.eql(40051336); // connect admin + resJson.updatedBy.should.be.eql(40051336); // connect admin + done(); + }); + }); + + it('should return 201 for copilot', (done) => { + request(server) + .post('/v4/timelines/1/milestones') + .set({ + Authorization: `Bearer ${testUtil.jwts.copilot}`, + }) + .send(body) + .expect('Content-Type', /json/) + .expect(201) + .end((err, res) => { + const resJson = res.body.result.content; + resJson.createdBy.should.be.eql(40051332); // copilot + resJson.updatedBy.should.be.eql(40051332); // copilot + done(); + }); + }); + + it('should return 201 for member', (done) => { + request(server) + .post('/v4/timelines/1/milestones') + .set({ + Authorization: `Bearer ${testUtil.jwts.member}`, + }) + .send(body) + .expect('Content-Type', /json/) + .expect(201) + .end((err, res) => { + const resJson = res.body.result.content; + resJson.createdBy.should.be.eql(40051331); // member + resJson.updatedBy.should.be.eql(40051331); // member + done(); + }); + }); + }); +}); diff --git a/src/routes/milestones/delete.js b/src/routes/milestones/delete.js new file mode 100644 index 00000000..f7074cc0 --- /dev/null +++ b/src/routes/milestones/delete.js @@ -0,0 +1,65 @@ +/** + * API to delete a timeline + */ +import validate from 'express-validation'; +import Joi from 'joi'; +import { middleware as tcMiddleware } from 'tc-core-library-js'; +import models from '../../models'; +import { EVENT } from '../../constants'; +import util from '../../util'; + +const permissions = tcMiddleware.permissions; + +const schema = { + params: { + timelineId: Joi.number().integer().positive().required(), + milestoneId: Joi.number().integer().positive().required(), + }, +}; + +module.exports = [ + validate(schema), + // Validate and get projectId from the timelineId param, and set to request params for + // checking by the permissions middleware + util.validateTimelineIdParam, + permissions('milestone.delete'), + (req, res, next) => { + const where = { + timelineId: req.params.timelineId, + id: req.params.milestoneId, + }; + + return models.sequelize.transaction(tx => + // Find the milestone + models.Milestone.findOne({ + where, + transaction: tx, + }) + .then((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); + } + + // Update the deletedBy, and soft delete + return milestone.update({ deletedBy: req.authUser.userId }, { transaction: tx }) + .then(() => milestone.destroy({ transaction: tx })); + }) + .then((deleted) => { + // Send event to bus + req.log.debug('Sending event to RabbitMQ bus for milestone %d', deleted.id); + req.app.services.pubsub.publish(EVENT.ROUTING_KEY.MILESTONE_REMOVED, + deleted, + { correlationId: req.id }, + ); + + // Write to response + res.status(204).end(); + return Promise.resolve(); + }) + .catch(next), + ); + }, +]; diff --git a/src/routes/milestones/delete.spec.js b/src/routes/milestones/delete.spec.js new file mode 100644 index 00000000..21502333 --- /dev/null +++ b/src/routes/milestones/delete.spec.js @@ -0,0 +1,325 @@ +/** + * Tests for delete.js + */ +import request from 'supertest'; + +import models from '../../models'; +import server from '../../app'; +import testUtil from '../../tests/util'; +import { EVENT } from '../../constants'; + + +describe('DELETE milestone', () => { + beforeEach((done) => { + testUtil.clearDb() + .then(() => { + models.Project.bulkCreate([ + { + type: 'generic', + billingAccountId: 1, + name: 'test1', + description: 'test project1', + status: 'draft', + details: {}, + createdBy: 1, + updatedBy: 1, + }, + { + type: 'generic', + billingAccountId: 2, + name: 'test2', + description: 'test project2', + status: 'draft', + details: {}, + createdBy: 2, + updatedBy: 2, + deletedAt: '2018-05-15T00:00:00Z', + }, + ]) + .then(() => { + // Create member + models.ProjectMember.bulkCreate([ + { + userId: 40051332, + projectId: 1, + role: 'copilot', + isPrimary: true, + createdBy: 1, + updatedBy: 1, + }, + { + userId: 40051331, + projectId: 1, + role: 'customer', + isPrimary: true, + createdBy: 1, + updatedBy: 1, + }, + ]).then(() => + // Create phase + models.ProjectPhase.bulkCreate([ + { + projectId: 1, + name: 'test project phase 1', + 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 2', + }, + createdBy: 1, + updatedBy: 1, + }, + { + projectId: 2, + name: 'test project phase 2', + status: 'active', + startDate: '2018-05-16T00:00:00Z', + endDate: '2018-05-16T12:00:00Z', + budget: 21.0, + progress: 1.234567, + details: { + message: 'This can be any json 2', + }, + createdBy: 2, + updatedBy: 2, + deletedAt: '2018-05-15T00:00:00Z', + }, + ])) + .then(() => + // Create timelines + models.Timeline.bulkCreate([ + { + name: 'name 1', + description: 'description 1', + startDate: '2018-05-11T00:00:00.000Z', + endDate: '2018-05-12T00:00:00.000Z', + reference: 'project', + referenceId: 1, + createdBy: 1, + updatedBy: 1, + }, + { + name: 'name 2', + description: 'description 2', + startDate: '2018-05-12T00:00:00.000Z', + endDate: '2018-05-13T00:00:00.000Z', + reference: 'phase', + referenceId: 1, + createdBy: 1, + updatedBy: 1, + }, + { + name: 'name 3', + description: 'description 3', + startDate: '2018-05-13T00:00:00.000Z', + endDate: '2018-05-14T00:00:00.000Z', + reference: 'phase', + referenceId: 1, + createdBy: 1, + updatedBy: 1, + deletedAt: '2018-05-14T00:00:00.000Z', + }, + ])) + .then(() => { + // Create milestones + models.Milestone.bulkCreate([ + { + timelineId: 1, + name: 'milestone 1', + duration: 2, + startDate: '2018-05-03T00:00:00.000Z', + status: 'open', + 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, + }, + { + timelineId: 1, + name: 'milestone 2', + duration: 3, + startDate: '2018-05-04T00:00:00.000Z', + status: 'open', + type: 'type2', + order: 2, + plannedText: 'plannedText 2', + activeText: 'activeText 2', + completedText: 'completedText 2', + blockedText: 'blockedText 2', + createdBy: 2, + updatedBy: 3, + }, + { + timelineId: 1, + name: 'milestone 3', + duration: 4, + startDate: '2018-05-04T00:00:00.000Z', + status: 'open', + type: 'type3', + order: 3, + plannedText: 'plannedText 3', + activeText: 'activeText 3', + completedText: 'completedText 3', + blockedText: 'blockedText 3', + createdBy: 3, + updatedBy: 4, + deletedBy: 1, + deletedAt: '2018-05-04T00:00:00.000Z', + }, + ]) + .then(() => done()); + }); + }); + }); + }); + + after(testUtil.clearDb); + + describe('DELETE /timelines/{timelineId}/milestones/{milestoneId}', () => { + it('should return 403 if user is not authenticated', (done) => { + request(server) + .delete('/v4/timelines/1/milestones/1') + .expect(403, done); + }); + + it('should return 403 for member who is not in the project', (done) => { + request(server) + .delete('/v4/timelines/1/milestones/1') + .set({ + Authorization: `Bearer ${testUtil.jwts.member2}`, + }) + .expect(403, done); + }); + + it('should return 403 for member who is not in the project (timeline refers to a phase)', (done) => { + request(server) + .delete('/v4/timelines/2/milestones/1') + .set({ + Authorization: `Bearer ${testUtil.jwts.member2}`, + }) + .expect(403, done); + }); + + it('should return 404 for non-existed timeline', (done) => { + request(server) + .delete('/v4/timelines/1234/milestones/1') + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .expect(404, done); + }); + + it('should return 404 for deleted timeline', (done) => { + request(server) + .delete('/v4/timelines/3/milestones/1') + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .expect(404, done); + }); + + it('should return 404 for non-existed milestone', (done) => { + request(server) + .delete('/v4/timelines/1/milestones/100') + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .expect(404, done); + }); + + it('should return 404 for deleted milestone', (done) => { + request(server) + .delete('/v4/timelines/1/milestones/3') + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .expect(404, done); + }); + + it('should return 422 for invalid timelineId param', (done) => { + request(server) + .delete('/v4/timelines/0/milestones/1') + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .expect(422, done); + }); + + it('should return 422 for invalid milestoneId param', (done) => { + request(server) + .delete('/v4/timelines/1/milestones/0') + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .expect(422, done); + }); + + it('should return 204, for admin, if timeline was successfully removed', (done) => { + request(server) + .delete('/v4/timelines/1/milestones/1') + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .expect(204) + .end(() => { + // eslint-disable-next-line no-unused-expressions + server.services.pubsub.publish.calledWith(EVENT.ROUTING_KEY.MILESTONE_REMOVED).should.be.true; + done(); + }); + }); + + it('should return 204, for connect admin, if timeline was successfully removed', (done) => { + request(server) + .delete('/v4/timelines/1/milestones/1') + .set({ + Authorization: `Bearer ${testUtil.jwts.connectAdmin}`, + }) + .expect(204) + .end(done); + }); + + it('should return 204, for connect manager, if timeline was successfully removed', (done) => { + request(server) + .delete('/v4/timelines/1/milestones/1') + .set({ + Authorization: `Bearer ${testUtil.jwts.manager}`, + }) + .expect(204) + .end(done); + }); + + it('should return 204, for copilot, if timeline was successfully removed', (done) => { + request(server) + .delete('/v4/timelines/1/milestones/1') + .set({ + Authorization: `Bearer ${testUtil.jwts.copilot}`, + }) + .expect(204) + .end(done); + }); + + it('should return 204, for member, if timeline was successfully removed', (done) => { + request(server) + .delete('/v4/timelines/1/milestones/1') + .set({ + Authorization: `Bearer ${testUtil.jwts.member}`, + }) + .expect(204) + .end(done); + }); + }); +}); diff --git a/src/routes/milestones/get.js b/src/routes/milestones/get.js new file mode 100644 index 00000000..c35a3e86 --- /dev/null +++ b/src/routes/milestones/get.js @@ -0,0 +1,48 @@ +/** + * API to get a milestone + */ +import validate from 'express-validation'; +import Joi from 'joi'; +import _ from 'lodash'; +import { middleware as tcMiddleware } from 'tc-core-library-js'; +import util from '../../util'; +import models from '../../models'; + +const permissions = tcMiddleware.permissions; + +const schema = { + params: { + timelineId: Joi.number().integer().positive().required(), + milestoneId: Joi.number().integer().positive().required(), + }, +}; + +module.exports = [ + validate(schema), + // Validate and get projectId from the timelineId param, and set to request params for + // checking by the permissions middleware + util.validateTimelineIdParam, + permissions('milestone.view'), + (req, res, next) => { + const where = { + timelineId: req.params.timelineId, + id: req.params.milestoneId, + }; + + // Find the milestone + models.Milestone.findOne({ where }) + .then((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); + } + + // Write to response + res.json(util.wrapResponse(req.id, _.omit(milestone.toJSON(), ['deletedBy', 'deletedAt']))); + return Promise.resolve(); + }) + .catch(next); + }, +]; diff --git a/src/routes/milestones/get.spec.js b/src/routes/milestones/get.spec.js new file mode 100644 index 00000000..919b756d --- /dev/null +++ b/src/routes/milestones/get.spec.js @@ -0,0 +1,342 @@ +/** + * Tests for get.js + */ +import chai from 'chai'; +import request from 'supertest'; + +import models from '../../models'; +import server from '../../app'; +import testUtil from '../../tests/util'; + +const should = chai.should(); + +describe('GET milestone', () => { + before((done) => { + testUtil.clearDb() + .then(() => { + models.Project.bulkCreate([ + { + type: 'generic', + billingAccountId: 1, + name: 'test1', + description: 'test project1', + status: 'draft', + details: {}, + createdBy: 1, + updatedBy: 1, + }, + { + type: 'generic', + billingAccountId: 2, + name: 'test2', + description: 'test project2', + status: 'draft', + details: {}, + createdBy: 2, + updatedBy: 2, + deletedAt: '2018-05-15T00:00:00Z', + }, + ]) + .then(() => { + // Create member + models.ProjectMember.bulkCreate([ + { + userId: 40051332, + projectId: 1, + role: 'copilot', + isPrimary: true, + createdBy: 1, + updatedBy: 1, + }, + { + userId: 40051331, + projectId: 1, + role: 'customer', + isPrimary: true, + createdBy: 1, + updatedBy: 1, + }, + ]) + .then(() => + // Create phase + models.ProjectPhase.bulkCreate([ + { + projectId: 1, + name: 'test project phase 1', + 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 2', + }, + createdBy: 1, + updatedBy: 1, + }, + { + projectId: 2, + name: 'test project phase 2', + status: 'active', + startDate: '2018-05-16T00:00:00Z', + endDate: '2018-05-16T12:00:00Z', + budget: 21.0, + progress: 1.234567, + details: { + message: 'This can be any json 2', + }, + createdBy: 2, + updatedBy: 2, + deletedAt: '2018-05-15T00:00:00Z', + }, + ])) + .then(() => + // Create timelines + models.Timeline.bulkCreate([ + { + name: 'name 1', + description: 'description 1', + startDate: '2018-05-11T00:00:00.000Z', + endDate: '2018-05-12T00:00:00.000Z', + reference: 'project', + referenceId: 1, + createdBy: 1, + updatedBy: 1, + }, + { + name: 'name 2', + description: 'description 2', + startDate: '2018-05-12T00:00:00.000Z', + endDate: '2018-05-13T00:00:00.000Z', + reference: 'phase', + referenceId: 1, + createdBy: 1, + updatedBy: 1, + }, + { + name: 'name 3', + description: 'description 3', + startDate: '2018-05-13T00:00:00.000Z', + endDate: '2018-05-14T00:00:00.000Z', + reference: 'phase', + referenceId: 1, + createdBy: 1, + updatedBy: 1, + deletedAt: '2018-05-14T00:00:00.000Z', + }, + ])) + .then(() => { + // Create milestones + models.Milestone.bulkCreate([ + { + timelineId: 1, + name: 'milestone 1', + duration: 2, + startDate: '2018-05-03T00:00:00.000Z', + status: 'open', + 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, + }, + { + timelineId: 1, + name: 'milestone 2', + duration: 3, + startDate: '2018-05-04T00:00:00.000Z', + status: 'open', + type: 'type2', + order: 2, + plannedText: 'plannedText 2', + activeText: 'activeText 2', + completedText: 'completedText 2', + blockedText: 'blockedText 2', + createdBy: 2, + updatedBy: 3, + }, + { + timelineId: 1, + name: 'milestone 3', + duration: 4, + startDate: '2018-05-04T00:00:00.000Z', + status: 'open', + type: 'type3', + order: 3, + plannedText: 'plannedText 3', + activeText: 'activeText 3', + completedText: 'completedText 3', + blockedText: 'blockedText 3', + createdBy: 3, + updatedBy: 4, + deletedBy: 1, + deletedAt: '2018-05-04T00:00:00.000Z', + }, + ]) + .then(() => done()); + }); + }); + }); + }); + + after(testUtil.clearDb); + + describe('GET /timelines/{timelineId}/milestones/{milestoneId}', () => { + it('should return 403 if user is not authenticated', (done) => { + request(server) + .get('/v4/timelines/1/milestones/1') + .expect(403, done); + }); + + it('should return 403 for member who is not in the project', (done) => { + request(server) + .get('/v4/timelines/1/milestones/1') + .set({ + Authorization: `Bearer ${testUtil.jwts.member2}`, + }) + .expect(403, done); + }); + + it('should return 404 for non-existed timeline', (done) => { + request(server) + .get('/v4/timelines/1234/milestones/1') + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .expect(404, done); + }); + + it('should return 404 for deleted timeline', (done) => { + request(server) + .get('/v4/timelines/3/milestones/1') + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .expect(404, done); + }); + + it('should return 404 for non-existed milestone', (done) => { + request(server) + .get('/v4/timelines/1/milestones/1234') + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .expect(404, done); + }); + + it('should return 404 for deleted milestone', (done) => { + request(server) + .get('/v4/timelines/1/milestones/3') + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .expect(404, done); + }); + + it('should return 422 for invalid timelineId param', (done) => { + request(server) + .get('/v4/timelines/0/milestones/3') + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .expect(422, done); + }); + + it('should return 422 for invalid milestoneId param', (done) => { + request(server) + .get('/v4/timelines/1/milestones/0') + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .expect(422, done); + }); + + it('should return 200 for admin', (done) => { + request(server) + .get('/v4/timelines/1/milestones/1') + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .expect(200) + .end((err, res) => { + const resJson = res.body.result.content; + resJson.id.should.be.eql(1); + resJson.timelineId.should.be.eql(1); + resJson.name.should.be.eql('milestone 1'); + resJson.duration.should.be.eql(2); + resJson.startDate.should.be.eql('2018-05-03T00:00:00.000Z'); + resJson.status.should.be.eql('open'); + resJson.type.should.be.eql('type1'); + resJson.details.should.be.eql({ + detail1: { + subDetail1A: 1, + subDetail1B: 2, + }, + detail2: [1, 2, 3], + }); + resJson.order.should.be.eql(1); + resJson.plannedText.should.be.eql('plannedText 1'); + resJson.activeText.should.be.eql('activeText 1'); + resJson.completedText.should.be.eql('completedText 1'); + resJson.blockedText.should.be.eql('blockedText 1'); + + resJson.createdBy.should.be.eql(1); + should.exist(resJson.createdAt); + resJson.updatedBy.should.be.eql(2); + should.exist(resJson.updatedAt); + should.not.exist(resJson.deletedBy); + should.not.exist(resJson.deletedAt); + + done(); + }); + }); + + it('should return 200 for connect admin', (done) => { + request(server) + .get('/v4/timelines/1/milestones/1') + .set({ + Authorization: `Bearer ${testUtil.jwts.connectAdmin}`, + }) + .expect(200) + .end(done); + }); + + it('should return 200 for connect manager', (done) => { + request(server) + .get('/v4/timelines/1/milestones/1') + .set({ + Authorization: `Bearer ${testUtil.jwts.manager}`, + }) + .expect(200) + .end(done); + }); + + it('should return 200 for member', (done) => { + request(server) + .get('/v4/timelines/1/milestones/1') + .set({ + Authorization: `Bearer ${testUtil.jwts.member}`, + }) + .expect(200, done); + }); + + it('should return 200 for copilot', (done) => { + request(server) + .get('/v4/timelines/1/milestones/1') + .set({ + Authorization: `Bearer ${testUtil.jwts.copilot}`, + }) + .expect(200, done); + }); + }); +}); diff --git a/src/routes/milestones/list.js b/src/routes/milestones/list.js new file mode 100644 index 00000000..6ae2d5c2 --- /dev/null +++ b/src/routes/milestones/list.js @@ -0,0 +1,68 @@ +/** + * API to list all milestones + */ +import validate from 'express-validation'; +import Joi from 'joi'; +import config from 'config'; +import _ from 'lodash'; +import { middleware as tcMiddleware } from 'tc-core-library-js'; +import util from '../../util'; + +const permissions = tcMiddleware.permissions; + +const ES_TIMELINE_INDEX = config.get('elasticsearchConfig.timelineIndexName'); +const ES_TIMELINE_TYPE = config.get('elasticsearchConfig.timelineDocType'); + +const schema = { + params: { + timelineId: Joi.number().integer().positive().required(), + }, +}; + +module.exports = [ + validate(schema), + // Validate and get projectId from the timelineId param, and set to request params for + // checking by the permissions middleware + util.validateTimelineIdParam, + permissions('milestone.view'), + (req, res, next) => { + // Parse the sort query + let sort = req.query.sort ? decodeURIComponent(req.query.sort) : 'order'; + if (sort && sort.indexOf(' ') === -1) { + sort += ' asc'; + } + const sortableProps = [ + 'order asc', 'order desc', + ]; + if (sort && _.indexOf(sortableProps, sort) < 0) { + const apiErr = new Error('Invalid sort criteria'); + apiErr.status = 422; + return next(apiErr); + } + const sortColumnAndOrder = sort.split(' '); + + // Get timeline from ES + return util.getElasticSearchClient().get({ + index: ES_TIMELINE_INDEX, + type: ES_TIMELINE_TYPE, + id: req.params.timelineId, + }) + .then((doc) => { + if (!doc) { + const err = new Error(`Timeline not found for timeline id ${req.params.timelineId}`); + err.status = 404; + throw err; + } + + // Get the milestones + let milestones = _.isArray(doc._source.milestones) ? doc._source.milestones : []; // eslint-disable-line no-underscore-dangle + + // Sort + milestones = _.orderBy(milestones, [sortColumnAndOrder[0]], [sortColumnAndOrder[1]]); + + // Write to response + res.json(util.wrapResponse(req.id, milestones, milestones.length)); + }) + .catch(err => next(err)); + }, +]; diff --git a/src/routes/milestones/list.spec.js b/src/routes/milestones/list.spec.js new file mode 100644 index 00000000..0240ee43 --- /dev/null +++ b/src/routes/milestones/list.spec.js @@ -0,0 +1,324 @@ +/** + * Tests for list.js + */ +import chai from 'chai'; +import request from 'supertest'; +import sleep from 'sleep'; +import config from 'config'; + +import models from '../../models'; +import server from '../../app'; +import testUtil from '../../tests/util'; + +const ES_TIMELINE_INDEX = config.get('elasticsearchConfig.timelineIndexName'); +const ES_TIMELINE_TYPE = config.get('elasticsearchConfig.timelineDocType'); + +// eslint-disable-next-line no-unused-vars +const should = chai.should(); + +const timelines = [ + { + id: 1, + name: 'name 1', + description: 'description 1', + startDate: '2018-05-11T00:00:00.000Z', + endDate: '2018-05-12T00:00:00.000Z', + reference: 'project', + referenceId: 1, + createdBy: 1, + updatedBy: 1, + createdAt: '2018-05-11T00:00:00.000Z', + updatedAt: '2018-05-11T00:00:00.000Z', + }, +]; +const milestones = [ + { + id: 1, + timelineId: 1, + name: 'milestone 1', + duration: 2, + startDate: '2018-05-03T00:00:00.000Z', + endDate: '2018-05-04T00:00:00.000Z', + completionDate: '2018-05-05T00:00:00.000Z', + status: 'open', + 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', + }, + { + id: 2, + timelineId: 1, + name: 'milestone 2', + duration: 3, + startDate: '2018-05-04T00:00:00.000Z', + status: 'open', + type: 'type2', + order: 2, + plannedText: 'plannedText 2', + activeText: 'activeText 2', + completedText: 'completedText 2', + blockedText: 'blockedText 2', + createdBy: 2, + updatedBy: 3, + createdAt: '2018-05-11T00:00:00.000Z', + updatedAt: '2018-05-11T00:00:00.000Z', + }, +]; + +describe('LIST timelines', () => { + before(function beforeHook(done) { + this.timeout(10000); + testUtil.clearDb() + .then(() => { + models.Project.bulkCreate([ + { + type: 'generic', + billingAccountId: 1, + name: 'test1', + description: 'test project1', + status: 'draft', + details: {}, + createdBy: 1, + updatedBy: 1, + }, + { + type: 'generic', + billingAccountId: 2, + name: 'test2', + description: 'test project2', + status: 'draft', + details: {}, + createdBy: 2, + updatedBy: 2, + deletedAt: '2018-05-15T00:00:00Z', + }, + ]) + .then(() => { + // Create member + models.ProjectMember.bulkCreate([ + { + userId: 40051332, + projectId: 1, + role: 'copilot', + isPrimary: true, + createdBy: 1, + updatedBy: 1, + }, + { + userId: 40051331, + projectId: 1, + role: 'customer', + isPrimary: true, + createdBy: 1, + updatedBy: 1, + }, + ]).then(() => + // Create phase + models.ProjectPhase.bulkCreate([ + { + projectId: 1, + name: 'test project phase 1', + 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 2', + }, + createdBy: 1, + updatedBy: 1, + }, + { + projectId: 2, + name: 'test project phase 2', + status: 'active', + startDate: '2018-05-16T00:00:00Z', + endDate: '2018-05-16T12:00:00Z', + budget: 21.0, + progress: 1.234567, + details: { + message: 'This can be any json 2', + }, + createdBy: 2, + updatedBy: 2, + }, + ])) + .then(() => + // Create timelines and milestones + models.Timeline.bulkCreate(timelines) + .then(() => models.Milestone.bulkCreate(milestones))) + .then(() => { + // Index to ES + timelines[0].milestones = milestones; + timelines[0].projectId = 1; + return server.services.es.index({ + index: ES_TIMELINE_INDEX, + type: ES_TIMELINE_TYPE, + id: timelines[0].id, + body: timelines[0], + }) + .then(() => { + // sleep for some time, let elasticsearch indices be settled + sleep.sleep(5); + done(); + }); + }); + }); + }); + }); + + after(testUtil.clearDb); + + describe('GET /timelines/{timelineId}/milestones', () => { + it('should return 403 if user is not authenticated', (done) => { + request(server) + .get('/v4/timelines') + .expect(403, done); + }); + + it('should return 403 for member with no accessible project', (done) => { + request(server) + .get('/v4/timelines/1/milestones') + .set({ + Authorization: `Bearer ${testUtil.jwts.member2}`, + }) + .expect(403, done); + }); + + it('should return 404 for not-existed timeline', (done) => { + request(server) + .get('/v4/timelines/11/milestones') + .set({ + Authorization: `Bearer ${testUtil.jwts.connectAdmin}`, + }) + .expect(404, done); + }); + + it('should return 422 for invalid sort column', (done) => { + request(server) + .get('/v4/timelines/1/milestones?sort=id%20asc') + .set({ + Authorization: `Bearer ${testUtil.jwts.connectAdmin}`, + }) + .expect(422, done); + }); + + it('should return 422 for invalid sort order', (done) => { + request(server) + .get('/v4/timelines/1/milestones?sort=order%20invalid') + .set({ + Authorization: `Bearer ${testUtil.jwts.connectAdmin}`, + }) + .expect(422, done); + }); + + it('should return 200 for admin', (done) => { + request(server) + .get('/v4/timelines/1/milestones') + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .expect(200) + .end((err, res) => { + 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]); + + done(); + }); + }); + + it('should return 200 for connect admin', (done) => { + request(server) + .get('/v4/timelines/1/milestones') + .set({ + Authorization: `Bearer ${testUtil.jwts.connectAdmin}`, + }) + .expect(200) + .end((err, res) => { + const resJson = res.body.result.content; + resJson.should.have.length(2); + + done(); + }); + }); + + it('should return 200 for connect manager', (done) => { + request(server) + .get('/v4/timelines/1/milestones') + .set({ + Authorization: `Bearer ${testUtil.jwts.manager}`, + }) + .expect(200) + .end((err, res) => { + const resJson = res.body.result.content; + resJson.should.have.length(2); + + done(); + }); + }); + + it('should return 200 for member', (done) => { + request(server) + .get('/v4/timelines/1/milestones') + .set({ + Authorization: `Bearer ${testUtil.jwts.member}`, + }) + .end((err, res) => { + const resJson = res.body.result.content; + resJson.should.have.length(2); + + done(); + }); + }); + + it('should return 200 for copilot', (done) => { + request(server) + .get('/v4/timelines/1/milestones') + .set({ + Authorization: `Bearer ${testUtil.jwts.copilot}`, + }) + .end((err, res) => { + const resJson = res.body.result.content; + resJson.should.have.length(2); + + done(); + }); + }); + + it('should return 200 with sort by order desc', (done) => { + request(server) + .get('/v4/timelines/1/milestones?sort=order%20desc') + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .expect(200) + .end((err, res) => { + 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]); + + done(); + }); + }); + }); +}); diff --git a/src/routes/milestones/update.js b/src/routes/milestones/update.js new file mode 100644 index 00000000..5641d290 --- /dev/null +++ b/src/routes/milestones/update.js @@ -0,0 +1,161 @@ +/** + * API to update a milestone + */ +import validate from 'express-validation'; +import _ from 'lodash'; +import Joi from 'joi'; +import Sequelize from 'sequelize'; +import { middleware as tcMiddleware } from 'tc-core-library-js'; +import util from '../../util'; +import { EVENT } from '../../constants'; +import models from '../../models'; + +const permissions = tcMiddleware.permissions; + +const schema = { + params: { + timelineId: Joi.number().integer().positive().required(), + milestoneId: Joi.number().integer().positive().required(), + }, + body: { + param: Joi.object().keys({ + id: Joi.any().strip(), + name: Joi.string().max(255).required(), + description: Joi.string().max(255), + duration: Joi.number().integer().required(), + startDate: Joi.date().required(), + endDate: Joi.date().min(Joi.ref('startDate')).allow(null), + completionDate: Joi.date().min(Joi.ref('startDate')).allow(null), + status: Joi.string().max(45).required(), + type: Joi.string().max(45).required(), + details: Joi.object(), + order: Joi.number().integer().required(), + plannedText: Joi.string().max(512).required(), + activeText: Joi.string().max(512).required(), + completedText: Joi.string().max(512).required(), + blockedText: Joi.string().max(512).required(), + createdAt: Joi.any().strip(), + updatedAt: Joi.any().strip(), + deletedAt: Joi.any().strip(), + createdBy: Joi.any().strip(), + updatedBy: Joi.any().strip(), + deletedBy: Joi.any().strip(), + }).required(), + }, +}; + +module.exports = [ + validate(schema), + // Validate and get projectId from the timelineId param, + // and set to request params for checking by the permissions middleware + util.validateTimelineIdParam, + permissions('milestone.edit'), + (req, res, next) => { + const where = { + timelineId: req.params.timelineId, + id: req.params.milestoneId, + }; + const entityToUpdate = _.assign(req.body.param, { + updatedBy: req.authUser.userId, + timelineId: req.params.timelineId, + }); + + // Validate startDate and endDate to be within the timeline startDate and endDate + let error; + if (req.body.param.startDate < req.timeline.startDate) { + error = 'Milestone startDate must not be before the timeline startDate'; + } else if (req.body.param.endDate && req.timeline.endDate && req.body.param.endDate > req.timeline.endDate) { + error = 'Milestone endDate must not be after the timeline endDate'; + } + if (error) { + const apiErr = new Error(error); + apiErr.status = 422; + return next(apiErr); + } + + let original; + let updated; + + return models.sequelize.transaction(() => + // Find the milestone + models.Milestone.findOne({ where }) + .then((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); + } + + original = _.omit(milestone.toJSON(), ['deletedAt', 'deletedBy']); + + // Merge JSON fields + entityToUpdate.details = util.mergeJsonObjects(milestone.details, entityToUpdate.details); + + // Update + return milestone.update(entityToUpdate); + }) + .then((updatedMilestone) => { + // Omit deletedAt, deletedBy + updated = _.omit(updatedMilestone.toJSON(), 'deletedAt', 'deletedBy'); + + // Update order of the other milestones only if the order was changed + if (original.order === updated.order) { + return Promise.resolve(); + } + + return models.Milestone.count({ + where: { + timelineId: updated.timelineId, + id: { $ne: updated.id }, + order: updated.order, + }, + }) + .then((count) => { + if (count === 0) { + return Promise.resolve(); + } + + // Increase the order from M to K: if there is an item with order K, + // orders from M+1 to K should be made M to K-1 + if (original.order < updated.order) { + return models.Milestone.update({ order: Sequelize.literal('"order" - 1') }, { + where: { + timelineId: updated.timelineId, + id: { $ne: updated.id }, + order: { $between: [original.order + 1, updated.order] }, + }, + }); + } + + // Decrease the order from M to K: if there is an item with order K, + // orders from K to M-1 should be made K+1 to M + return models.Milestone.update({ order: Sequelize.literal('"order" + 1') }, { + where: { + timelineId: updated.timelineId, + id: { $ne: updated.id }, + order: { $between: [updated.order, original.order - 1] }, + }, + }); + }); + }) + .then(() => { + // Send event to bus + req.log.debug('Sending event to RabbitMQ bus for milestone %d', updated.id); + req.app.services.pubsub.publish(EVENT.ROUTING_KEY.MILESTONE_UPDATED, + { original, updated }, + { correlationId: req.id }, + ); + + // Do not send events for the the other milestones (updated order) here, + // because it will make 'version conflict' error in ES. + // The order of the other milestones need to be updated in the MILESTONE_UPDATED event above + + // Write to response + res.json(util.wrapResponse(req.id, updated)); + return Promise.resolve(); + }) + .catch(next), + ); + }, +]; diff --git a/src/routes/milestones/update.spec.js b/src/routes/milestones/update.spec.js new file mode 100644 index 00000000..fca57115 --- /dev/null +++ b/src/routes/milestones/update.spec.js @@ -0,0 +1,981 @@ +/** + * Tests for get.js + */ +import chai from 'chai'; +import request from 'supertest'; +import _ from 'lodash'; +import models from '../../models'; +import server from '../../app'; +import testUtil from '../../tests/util'; +import { EVENT } from '../../constants'; + +const should = chai.should(); + +describe('UPDATE Milestone', () => { + beforeEach((done) => { + testUtil.clearDb() + .then(() => { + models.Project.bulkCreate([ + { + type: 'generic', + billingAccountId: 1, + name: 'test1', + description: 'test project1', + status: 'draft', + details: {}, + createdBy: 1, + updatedBy: 1, + }, + { + type: 'generic', + billingAccountId: 2, + name: 'test2', + description: 'test project2', + status: 'draft', + details: {}, + createdBy: 2, + updatedBy: 2, + deletedAt: '2018-05-15T00:00:00Z', + }, + ]) + .then(() => { + // Create member + models.ProjectMember.bulkCreate([ + { + userId: 40051332, + projectId: 1, + role: 'copilot', + isPrimary: true, + createdBy: 1, + updatedBy: 1, + }, + { + userId: 40051331, + projectId: 1, + role: 'customer', + isPrimary: true, + createdBy: 1, + updatedBy: 1, + }, + ]).then(() => + // Create phase + models.ProjectPhase.bulkCreate([ + { + projectId: 1, + name: 'test project phase 1', + 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 2', + }, + createdBy: 1, + updatedBy: 1, + }, + { + projectId: 2, + name: 'test project phase 2', + status: 'active', + startDate: '2018-05-16T00:00:00Z', + endDate: '2018-05-16T12:00:00Z', + budget: 21.0, + progress: 1.234567, + details: { + message: 'This can be any json 2', + }, + createdBy: 2, + updatedBy: 2, + deletedAt: '2018-05-15T00:00:00Z', + }, + ])) + .then(() => + // Create timelines + models.Timeline.bulkCreate([ + { + name: 'name 1', + description: 'description 1', + startDate: '2018-05-02T00:00:00.000Z', + endDate: '2018-06-12T00:00:00.000Z', + reference: 'project', + referenceId: 1, + createdBy: 1, + updatedBy: 1, + }, + { + name: 'name 2', + description: 'description 2', + startDate: '2018-05-12T00:00:00.000Z', + endDate: '2018-06-13T00:00:00.000Z', + reference: 'phase', + referenceId: 1, + createdBy: 1, + updatedBy: 1, + }, + { + name: 'name 3', + description: 'description 3', + startDate: '2018-05-13T00:00:00.000Z', + endDate: '2018-06-14T00:00:00.000Z', + reference: 'phase', + referenceId: 1, + createdBy: 1, + updatedBy: 1, + deletedAt: '2018-05-14T00:00:00.000Z', + }, + ]).then(() => models.Milestone.bulkCreate([ + { + id: 1, + timelineId: 1, + name: 'Milestone 1', + duration: 2, + startDate: '2018-05-13T00:00:00.000Z', + endDate: '2018-05-14T00:00:00.000Z', + completionDate: '2018-05-15T00:00:00.000Z', + status: 'open', + 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', + }, + { + id: 2, + timelineId: 1, + name: 'Milestone 2', + duration: 3, + startDate: '2018-05-14T00:00:00.000Z', + status: 'open', + type: 'type2', + order: 2, + plannedText: 'plannedText 2', + activeText: 'activeText 2', + completedText: 'completedText 2', + blockedText: 'blockedText 2', + createdBy: 2, + updatedBy: 3, + createdAt: '2018-05-11T00:00:00.000Z', + updatedAt: '2018-05-11T00:00:00.000Z', + }, + { + id: 3, + timelineId: 1, + name: 'Milestone 3', + duration: 3, + startDate: '2018-05-14T00:00:00.000Z', + status: 'open', + type: 'type3', + order: 3, + plannedText: 'plannedText 3', + activeText: 'activeText 3', + completedText: 'completedText 3', + blockedText: 'blockedText 3', + createdBy: 2, + updatedBy: 3, + createdAt: '2018-05-11T00:00:00.000Z', + updatedAt: '2018-05-11T00:00:00.000Z', + }, + { + id: 4, + timelineId: 1, + name: 'Milestone 4', + duration: 3, + startDate: '2018-05-14T00:00:00.000Z', + status: 'open', + type: 'type4', + order: 4, + plannedText: 'plannedText 4', + activeText: 'activeText 4', + completedText: 'completedText 4', + blockedText: 'blockedText 4', + createdBy: 2, + updatedBy: 3, + createdAt: '2018-05-11T00:00:00.000Z', + updatedAt: '2018-05-11T00:00:00.000Z', + }, + { + id: 5, + timelineId: 1, + name: 'Milestone 5', + duration: 3, + startDate: '2018-05-14T00:00:00.000Z', + status: 'open', + type: 'type5', + order: 5, + plannedText: 'plannedText 5', + activeText: 'activeText 5', + completedText: 'completedText 5', + blockedText: 'blockedText 5', + createdBy: 2, + updatedBy: 3, + createdAt: '2018-05-11T00:00:00.000Z', + updatedAt: '2018-05-11T00:00:00.000Z', + deletedAt: '2018-05-11T00:00:00.000Z', + }, + { + id: 6, + timelineId: 2, // Timeline 2 + name: 'Milestone 6', + duration: 3, + startDate: '2018-05-14T00:00:00.000Z', + status: 'open', + type: 'type5', + order: 1, + plannedText: 'plannedText 6', + activeText: 'activeText 6', + completedText: 'completedText 6', + blockedText: 'blockedText 6', + createdBy: 2, + updatedBy: 3, + createdAt: '2018-05-11T00:00:00.000Z', + updatedAt: '2018-05-11T00:00:00.000Z', + }, + ]))) + .then(() => done()); + }); + }); + }); + + after(testUtil.clearDb); + + describe('PATCH /timelines/{timelineId}/milestones/{milestoneId}', () => { + const body = { + param: { + name: 'Milestone 1-updated', + duration: 3, + startDate: '2018-05-14T00:00:00.000Z', + endDate: '2018-05-15T00:00:00.000Z', + completionDate: '2018-05-16T00:00:00.000Z', + description: 'description-updated', + status: 'closed', + type: 'type1-updated', + details: { + detail1: { + subDetail1A: 0, + subDetail1C: 3, + }, + detail2: [4], + detail3: 3, + }, + order: 1, + plannedText: 'plannedText 1-updated', + activeText: 'activeText 1-updated', + completedText: 'completedText 1-updated', + blockedText: 'blockedText 1-updated', + }, + }; + + it('should return 403 if user is not authenticated', (done) => { + request(server) + .patch('/v4/timelines/1/milestones/1') + .send(body) + .expect(403, done); + }); + + it('should return 403 for member who is not in the project', (done) => { + request(server) + .patch('/v4/timelines/1/milestones/1') + .set({ + Authorization: `Bearer ${testUtil.jwts.member2}`, + }) + .send(body) + .expect(403, done); + }); + + it('should return 404 for non-existed timeline', (done) => { + request(server) + .patch('/v4/timelines/1234/milestones/1') + .send(body) + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .expect(404, done); + }); + + it('should return 404 for deleted timeline', (done) => { + request(server) + .patch('/v4/timelines/3/milestones/1') + .send(body) + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .expect(404, done); + }); + + it('should return 404 for non-existed Milestone', (done) => { + request(server) + .patch('/v4/timelines/1/milestones/111') + .send(body) + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .expect(404, done); + }); + + it('should return 404 for deleted Milestone', (done) => { + request(server) + .patch('/v4/timelines/1/milestones/5') + .send(body) + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .expect(404, done); + }); + + it('should return 422 for invalid timelineId param', (done) => { + request(server) + .patch('/v4/timelines/0/milestones/1') + .send(body) + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .expect(422, done); + }); + + it('should return 422 for invalid milestoneId param', (done) => { + request(server) + .patch('/v4/timelines/1/milestones/0') + .send(body) + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .expect(422, done); + }); + + + it('should return 422 if missing name', (done) => { + const invalidBody = { + param: _.assign({}, body.param, { + name: undefined, + }), + }; + + request(server) + .patch('/v4/timelines/1/milestones/1') + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .send(invalidBody) + .expect('Content-Type', /json/) + .expect(422, done); + }); + + it('should return 422 if missing duration', (done) => { + const invalidBody = { + param: _.assign({}, body.param, { + duration: undefined, + }), + }; + + request(server) + .patch('/v4/timelines/1/milestones/1') + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .send(invalidBody) + .expect('Content-Type', /json/) + .expect(422, done); + }); + + it('should return 422 if missing type', (done) => { + const invalidBody = { + param: _.assign({}, body.param, { + type: undefined, + }), + }; + + request(server) + .patch('/v4/timelines/1/milestones/1') + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .send(invalidBody) + .expect('Content-Type', /json/) + .expect(422, done); + }); + + it('should return 422 if missing order', (done) => { + const invalidBody = { + param: _.assign({}, body.param, { + order: undefined, + }), + }; + + request(server) + .patch('/v4/timelines/1/milestones/1') + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .send(invalidBody) + .expect('Content-Type', /json/) + .expect(422, done); + }); + + it('should return 422 if missing plannedText', (done) => { + const invalidBody = { + param: _.assign({}, body.param, { + plannedText: undefined, + }), + }; + + request(server) + .patch('/v4/timelines/1/milestones/1') + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .send(invalidBody) + .expect('Content-Type', /json/) + .expect(422, done); + }); + + it('should return 422 if missing activeText', (done) => { + const invalidBody = { + param: _.assign({}, body.param, { + activeText: undefined, + }), + }; + + request(server) + .patch('/v4/timelines/1/milestones/1') + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .send(invalidBody) + .expect('Content-Type', /json/) + .expect(422, done); + }); + + it('should return 422 if missing completedText', (done) => { + const invalidBody = { + param: _.assign({}, body.param, { + completedText: undefined, + }), + }; + + request(server) + .patch('/v4/timelines/1/milestones/1') + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .send(invalidBody) + .expect('Content-Type', /json/) + .expect(422, done); + }); + + it('should return 422 if missing blockedText', (done) => { + const invalidBody = { + param: _.assign({}, body.param, { + blockedText: undefined, + }), + }; + + request(server) + .patch('/v4/timelines/1/milestones/1') + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .send(invalidBody) + .expect('Content-Type', /json/) + .expect(422, done); + }); + + it('should return 422 if startDate is after endDate', (done) => { + const invalidBody = { + param: _.assign({}, body.param, { + startDate: '2018-05-29T00:00:00.000Z', + endDate: '2018-05-28T00:00:00.000Z', + }), + }; + + request(server) + .patch('/v4/timelines/1/milestones/1') + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .send(invalidBody) + .expect('Content-Type', /json/) + .expect(422, done); + }); + + it('should return 422 if startDate is after completionDate', (done) => { + const invalidBody = { + param: _.assign({}, body.param, { + startDate: '2018-05-29T00:00:00.000Z', + completionDate: '2018-05-28T00:00:00.000Z', + }), + }; + + request(server) + .patch('/v4/timelines/1/milestones/1') + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .send(invalidBody) + .expect('Content-Type', /json/) + .expect(422, done); + }); + + it('should return 422 if startDate is before timeline startDate', (done) => { + const invalidBody = { + param: _.assign({}, body.param, { + startDate: '2018-05-01T00:00:00.000Z', + }), + }; + + request(server) + .patch('/v4/timelines/1/milestones/1') + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .send(invalidBody) + .expect('Content-Type', /json/) + .expect(422, done); + }); + + it('should return 422 if endDate is after timeline endDate', (done) => { + const invalidBody = { + param: _.assign({}, body.param, { + endDate: '2018-07-01T00:00:00.000Z', + }), + }; + + request(server) + .patch('/v4/timelines/1/milestones/1') + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .send(invalidBody) + .expect('Content-Type', /json/) + .expect(422, done); + }); + + it('should return 200 for admin', (done) => { + request(server) + .patch('/v4/timelines/1/milestones/1') + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .send(body) + .expect(200) + .end((err, res) => { + const resJson = res.body.result.content; + should.exist(resJson.id); + resJson.name.should.be.eql(body.param.name); + resJson.description.should.be.eql(body.param.description); + resJson.duration.should.be.eql(body.param.duration); + resJson.startDate.should.be.eql(body.param.startDate); + resJson.endDate.should.be.eql(body.param.endDate); + resJson.completionDate.should.be.eql(body.param.completionDate); + resJson.status.should.be.eql(body.param.status); + resJson.type.should.be.eql(body.param.type); + resJson.details.should.be.eql({ + detail1: { subDetail1A: 0, subDetail1B: 2, subDetail1C: 3 }, + detail2: [4], + detail3: 3, + }); + resJson.order.should.be.eql(body.param.order); + resJson.plannedText.should.be.eql(body.param.plannedText); + resJson.activeText.should.be.eql(body.param.activeText); + resJson.completedText.should.be.eql(body.param.completedText); + resJson.blockedText.should.be.eql(body.param.blockedText); + + should.exist(resJson.createdBy); + should.exist(resJson.createdAt); + resJson.updatedBy.should.be.eql(40051333); // admin + should.exist(resJson.updatedAt); + should.not.exist(resJson.deletedBy); + should.not.exist(resJson.deletedAt); + + // eslint-disable-next-line no-unused-expressions + server.services.pubsub.publish.calledWith(EVENT.ROUTING_KEY.MILESTONE_UPDATED).should.be.true; + + done(); + }); + }); + + // eslint-disable-next-line func-names + it('should return 200 for admin - order increases and replaces another milestone\'s order', function (done) { + this.timeout(10000); + + request(server) + .patch('/v4/timelines/1/milestones/1') + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .send({ param: _.assign({}, body.param, { order: 4 }) }) // 1 to 4 + .expect(200) + .end(() => { + // Milestone 1: order 4 + // Milestone 2: order 2 - 1 = 1 + // Milestone 3: order 3 - 1 = 2 + // Milestone 4: order 4 - 1 = 3 + setTimeout(() => { + models.Milestone.findById(1) + .then((milestone) => { + milestone.order.should.be.eql(4); + }) + .then(() => models.Milestone.findById(2)) + .then((milestone) => { + milestone.order.should.be.eql(1); + }) + .then(() => models.Milestone.findById(3)) + .then((milestone) => { + milestone.order.should.be.eql(2); + }) + .then(() => models.Milestone.findById(4)) + .then((milestone) => { + milestone.order.should.be.eql(3); + + done(); + }); + }, 3000); + }); + }); + + // eslint-disable-next-line func-names + it('should return 200 for admin - order increases and doesnot replace another milestone\'s order', function (done) { + this.timeout(10000); + + request(server) + .patch('/v4/timelines/1/milestones/1') + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .send({ param: _.assign({}, body.param, { order: 5 }) }) // 1 to 5 + .expect(200) + .end(() => { + // Milestone 1: order 5 + // Milestone 2: order 2 + // Milestone 3: order 3 + // Milestone 4: order 4 + setTimeout(() => { + models.Milestone.findById(1) + .then((milestone) => { + milestone.order.should.be.eql(5); + }) + .then(() => models.Milestone.findById(2)) + .then((milestone) => { + milestone.order.should.be.eql(2); + }) + .then(() => models.Milestone.findById(3)) + .then((milestone) => { + milestone.order.should.be.eql(3); + }) + .then(() => models.Milestone.findById(4)) + .then((milestone) => { + milestone.order.should.be.eql(4); + + done(); + }); + }, 3000); + }); + }); + + // eslint-disable-next-line func-names + it('should return 200 for admin - order decreases and replaces another milestone\'s order', function (done) { + this.timeout(10000); + + request(server) + .patch('/v4/timelines/1/milestones/4') + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .send({ param: _.assign({}, body.param, { order: 2 }) }) // 4 to 2 + .expect(200) + .end(() => { + // Milestone 1: order 1 + // Milestone 2: order 3 + // Milestone 3: order 4 + // Milestone 4: order 2 + setTimeout(() => { + models.Milestone.findById(1) + .then((milestone) => { + milestone.order.should.be.eql(1); + }) + .then(() => models.Milestone.findById(2)) + .then((milestone) => { + milestone.order.should.be.eql(3); + }) + .then(() => models.Milestone.findById(3)) + .then((milestone) => { + milestone.order.should.be.eql(4); + }) + .then(() => models.Milestone.findById(4)) + .then((milestone) => { + milestone.order.should.be.eql(2); + + done(); + }); + }, 3000); + }); + }); + + // eslint-disable-next-line func-names + it('should return 200 for admin - order decreases and doesnot replace another milestone\'s order', function (done) { + this.timeout(10000); + + request(server) + .patch('/v4/timelines/1/milestones/4') + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .send({ param: _.assign({}, body.param, { order: 0 }) }) // 4 to 0 + .expect(200) + .end(() => { + // Milestone 1: order 1 + // Milestone 2: order 2 + // Milestone 3: order 3 + // Milestone 4: order 0 + setTimeout(() => { + models.Milestone.findById(1) + .then((milestone) => { + milestone.order.should.be.eql(1); + }) + .then(() => models.Milestone.findById(2)) + .then((milestone) => { + milestone.order.should.be.eql(2); + }) + .then(() => models.Milestone.findById(3)) + .then((milestone) => { + milestone.order.should.be.eql(3); + }) + .then(() => models.Milestone.findById(4)) + .then((milestone) => { + milestone.order.should.be.eql(0); + + done(); + }); + }, 3000); + }); + }); + + // eslint-disable-next-line func-names + it('should return 200 for admin - changing order with only 1 item in list', function (done) { + this.timeout(10000); + + request(server) + .patch('/v4/timelines/2/milestones/6') + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .send({ param: _.assign({}, body.param, { order: 0 }) }) // 1 to 0 + .expect(200) + .end(() => { + // Milestone 6: order 0 + setTimeout(() => { + models.Milestone.findById(6) + .then((milestone) => { + milestone.order.should.be.eql(0); + + done(); + }); + }, 3000); + }); + }); + + // eslint-disable-next-line func-names + it('should return 200 for admin - changing order without changing other milestones\' orders', function (done) { + this.timeout(10000); + + models.Milestone.bulkCreate([ + { + id: 7, + timelineId: 2, // Timeline 2 + name: 'Milestone 7', + duration: 3, + startDate: '2018-05-14T00:00:00.000Z', + status: 'open', + type: 'type7', + order: 3, + plannedText: 'plannedText 7', + activeText: 'activeText 7', + completedText: 'completedText 7', + blockedText: 'blockedText 7', + createdBy: 2, + updatedBy: 3, + createdAt: '2018-05-11T00:00:00.000Z', + updatedAt: '2018-05-11T00:00:00.000Z', + }, + { + id: 8, + timelineId: 2, // Timeline 2 + name: 'Milestone 8', + duration: 3, + startDate: '2018-05-14T00:00:00.000Z', + status: 'open', + type: 'type7', + order: 4, + plannedText: 'plannedText 8', + activeText: 'activeText 8', + completedText: 'completedText 8', + blockedText: 'blockedText 8', + createdBy: 2, + updatedBy: 3, + createdAt: '2018-05-11T00:00:00.000Z', + updatedAt: '2018-05-11T00:00:00.000Z', + }, + ]) + .then(() => { + request(server) + .patch('/v4/timelines/2/milestones/8') + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .send({ param: _.assign({}, body.param, { order: 2 }) }) // 4 to 2 + .expect(200) + .end(() => { + // Milestone 6: order 1 => 1 + // Milestone 7: order 3 => 3 + // Milestone 8: order 4 => 2 + setTimeout(() => { + models.Milestone.findById(6) + .then((milestone) => { + milestone.order.should.be.eql(1); + }) + .then(() => models.Milestone.findById(7)) + .then((milestone) => { + milestone.order.should.be.eql(3); + }) + .then(() => models.Milestone.findById(8)) + .then((milestone) => { + milestone.order.should.be.eql(2); + + done(); + }); + }, 3000); + }); + }); + }); + + // eslint-disable-next-line func-names + it('should return 200 for admin - changing order withchanging other milestones\' orders', function (done) { + this.timeout(10000); + + models.Milestone.bulkCreate([ + { + id: 7, + timelineId: 2, // Timeline 2 + name: 'Milestone 7', + duration: 3, + startDate: '2018-05-14T00:00:00.000Z', + status: 'open', + type: 'type7', + order: 2, + plannedText: 'plannedText 7', + activeText: 'activeText 7', + completedText: 'completedText 7', + blockedText: 'blockedText 7', + createdBy: 2, + updatedBy: 3, + createdAt: '2018-05-11T00:00:00.000Z', + updatedAt: '2018-05-11T00:00:00.000Z', + }, + { + id: 8, + timelineId: 2, // Timeline 2 + name: 'Milestone 8', + duration: 3, + startDate: '2018-05-14T00:00:00.000Z', + status: 'open', + type: 'type7', + order: 4, + plannedText: 'plannedText 8', + activeText: 'activeText 8', + completedText: 'completedText 8', + blockedText: 'blockedText 8', + createdBy: 2, + updatedBy: 3, + createdAt: '2018-05-11T00:00:00.000Z', + updatedAt: '2018-05-11T00:00:00.000Z', + }, + ]) + .then(() => { + request(server) + .patch('/v4/timelines/2/milestones/8') + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .send({ param: _.assign({}, body.param, { order: 2 }) }) // 4 to 2 + .expect(200) + .end(() => { + // Milestone 6: order 1 => 1 + // Milestone 7: order 2 => 3 + // Milestone 8: order 4 => 2 + setTimeout(() => { + models.Milestone.findById(6) + .then((milestone) => { + milestone.order.should.be.eql(1); + }) + .then(() => models.Milestone.findById(7)) + .then((milestone) => { + milestone.order.should.be.eql(3); + }) + .then(() => models.Milestone.findById(8)) + .then((milestone) => { + milestone.order.should.be.eql(2); + + done(); + }); + }, 3000); + }); + }); + }); + + it('should return 200 for connect admin', (done) => { + request(server) + .patch('/v4/timelines/1/milestones/1') + .set({ + Authorization: `Bearer ${testUtil.jwts.connectAdmin}`, + }) + .send(body) + .expect(200) + .end(done); + }); + + it('should return 200 for connect manager', (done) => { + request(server) + .patch('/v4/timelines/1/milestones/1') + .set({ + Authorization: `Bearer ${testUtil.jwts.manager}`, + }) + .send(body) + .expect(200) + .end(done); + }); + + it('should return 200 for copilot', (done) => { + request(server) + .patch('/v4/timelines/1/milestones/1') + .set({ + Authorization: `Bearer ${testUtil.jwts.copilot}`, + }) + .send(body) + .expect(200) + .end(done); + }); + + it('should return 200 for member', (done) => { + request(server) + .patch('/v4/timelines/1/milestones/1') + .set({ + Authorization: `Bearer ${testUtil.jwts.member}`, + }) + .send(body) + .expect(200) + .end(done); + }); + }); +}); diff --git a/src/routes/timelines/create.js b/src/routes/timelines/create.js new file mode 100644 index 00000000..ada7beae --- /dev/null +++ b/src/routes/timelines/create.js @@ -0,0 +1,65 @@ +/** + * API to add a timeline + */ +import validate from 'express-validation'; +import _ from 'lodash'; +import Joi from 'joi'; +import { middleware as tcMiddleware } from 'tc-core-library-js'; +import util from '../../util'; +import models from '../../models'; +import { EVENT, TIMELINE_REFERENCES } from '../../constants'; + +const permissions = tcMiddleware.permissions; + +const schema = { + body: { + param: Joi.object().keys({ + id: Joi.any().strip(), + name: Joi.string().max(255).required(), + description: Joi.string().max(255), + startDate: Joi.date().required(), + endDate: Joi.date().min(Joi.ref('startDate')).allow(null), + reference: Joi.string().valid(_.values(TIMELINE_REFERENCES)).required(), + referenceId: Joi.number().integer().positive().required(), + createdAt: Joi.any().strip(), + updatedAt: Joi.any().strip(), + deletedAt: Joi.any().strip(), + createdBy: Joi.any().strip(), + updatedBy: Joi.any().strip(), + deletedBy: Joi.any().strip(), + }).required(), + }, +}; + +module.exports = [ + validate(schema), + // Validate and get projectId from the timeline request body, and set to request params + // for checking by the permissions middleware + util.validateTimelineRequestBody, + permissions('timeline.create'), + (req, res, next) => { + const entity = _.assign(req.body.param, { + createdBy: req.authUser.userId, + updatedBy: req.authUser.userId, + }); + + // Save to DB + return models.Timeline.create(entity) + .then((createdEntity) => { + // Omit deletedAt, deletedBy + const result = _.omit(createdEntity.toJSON(), 'deletedAt', 'deletedBy'); + + // Send event to bus + req.log.debug('Sending event to RabbitMQ bus for timeline %d', result.id); + req.app.services.pubsub.publish(EVENT.ROUTING_KEY.TIMELINE_ADDED, + _.assign({ projectId: req.params.projectId }, result), + { correlationId: req.id }, + ); + + // Write to the response + res.status(201).json(util.wrapResponse(req.id, result, 1, 201)); + return Promise.resolve(); + }) + .catch(next); + }, +]; diff --git a/src/routes/timelines/create.spec.js b/src/routes/timelines/create.spec.js new file mode 100644 index 00000000..10e3adbe --- /dev/null +++ b/src/routes/timelines/create.spec.js @@ -0,0 +1,468 @@ +/** + * Tests for create.js + */ +import chai from 'chai'; +import request from 'supertest'; +import _ from 'lodash'; +import server from '../../app'; +import testUtil from '../../tests/util'; +import models from '../../models'; +import { EVENT } from '../../constants'; + +const should = chai.should(); + +describe('CREATE timeline', () => { + let projectId1; + let projectId2; + + before((done) => { + testUtil.clearDb() + .then(() => { + models.Project.bulkCreate([ + { + type: 'generic', + billingAccountId: 1, + name: 'test1', + description: 'test project1', + status: 'draft', + details: {}, + createdBy: 1, + updatedBy: 1, + }, + { + type: 'generic', + billingAccountId: 2, + name: 'test2', + description: 'test project2', + status: 'draft', + details: {}, + createdBy: 2, + updatedBy: 2, + deletedAt: '2018-05-15T00:00:00Z', + }, + ], { returning: true }) + .then((projects) => { + projectId1 = projects[0].id; + projectId2 = projects[1].id; + + // Create member + models.ProjectMember.bulkCreate([ + { + userId: 40051332, + projectId: projectId1, + role: 'copilot', + isPrimary: true, + createdBy: 1, + updatedBy: 1, + }, + { + userId: 40051331, + projectId: projectId1, + role: 'customer', + isPrimary: true, + createdBy: 1, + updatedBy: 1, + }, + ]).then(() => + // Create phase + models.ProjectPhase.bulkCreate([ + { + projectId: projectId1, + name: 'test project phase 1', + 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 2', + }, + createdBy: 1, + updatedBy: 1, + }, + { + projectId: projectId2, + name: 'test project phase 2', + status: 'active', + startDate: '2018-05-16T00:00:00Z', + endDate: '2018-05-16T12:00:00Z', + budget: 21.0, + progress: 1.234567, + details: { + message: 'This can be any json 2', + }, + createdBy: 2, + updatedBy: 2, + deletedAt: '2018-05-15T00:00:00Z', + }, + ])) + .then(() => { + done(); + }); + }); + }); + }); + + after(testUtil.clearDb); + + describe('POST /timelines', () => { + const body = { + param: { + name: 'new name', + description: 'new description', + startDate: '2018-05-29T00:00:00.000Z', + endDate: '2018-05-30T00:00:00.000Z', + reference: 'project', + referenceId: 1, + }, + }; + + it('should return 403 if user is not authenticated', (done) => { + request(server) + .post('/v4/timelines') + .send(body) + .expect(403, done); + }); + + it('should return 403 for member who is not in the project', (done) => { + request(server) + .post('/v4/timelines') + .set({ + Authorization: `Bearer ${testUtil.jwts.member2}`, + }) + .send(body) + .expect(403, done); + }); + + it('should return 403 for member who is not in the project (timeline refers to a phase)', (done) => { + const bodyWithPhase = { + param: _.assign({}, body.param, { + reference: 'phase', + referenceId: 1, + }), + }; + + request(server) + .post('/v4/timelines') + .set({ + Authorization: `Bearer ${testUtil.jwts.member2}`, + }) + .send(bodyWithPhase) + .expect(403, done); + }); + + it('should return 422 if missing name', (done) => { + const invalidBody = { + param: _.assign({}, body.param, { + name: undefined, + }), + }; + + request(server) + .post('/v4/timelines') + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .send(invalidBody) + .expect('Content-Type', /json/) + .expect(422, done); + }); + + it('should return 422 if missing startDate', (done) => { + const invalidBody = { + param: _.assign({}, body.param, { + startDate: undefined, + }), + }; + + request(server) + .post('/v4/timelines') + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .send(invalidBody) + .expect('Content-Type', /json/) + .expect(422, done); + }); + + it('should return 422 if startDate is after endDate', (done) => { + const invalidBody = { + param: _.assign({}, body.param, { + startDate: '2018-05-29T00:00:00.000Z', + endDate: '2018-05-28T00:00:00.000Z', + }), + }; + + request(server) + .post('/v4/timelines') + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .send(invalidBody) + .expect('Content-Type', /json/) + .expect(422, done); + }); + + it('should return 422 if missing reference', (done) => { + const invalidBody = { + param: _.assign({}, body.param, { + reference: undefined, + }), + }; + + request(server) + .post('/v4/timelines') + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .send(invalidBody) + .expect('Content-Type', /json/) + .expect(422, done); + }); + + it('should return 422 if missing referenceId', (done) => { + const invalidBody = { + param: _.assign({}, body.param, { + referenceId: undefined, + }), + }; + + request(server) + .post('/v4/timelines') + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .send(invalidBody) + .expect('Content-Type', /json/) + .expect(422, done); + }); + + it('should return 422 if invalid reference', (done) => { + const invalidBody = { + param: _.assign({}, body.param, { + reference: 'invalid', + }), + }; + + request(server) + .post('/v4/timelines') + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .send(invalidBody) + .expect('Content-Type', /json/) + .expect(422, done); + }); + + it('should return 422 if invalid referenceId', (done) => { + const invalidBody = { + param: _.assign({}, body.param, { + referenceId: 0, + }), + }; + + request(server) + .post('/v4/timelines') + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .send(invalidBody) + .expect('Content-Type', /json/) + .expect(422, done); + }); + + it('should return 422 if project does not exist', (done) => { + const invalidBody = { + param: _.assign({}, body.param, { + referenceId: 1110, + }), + }; + + request(server) + .post('/v4/timelines') + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .send(invalidBody) + .expect('Content-Type', /json/) + .expect(422, done); + }); + + it('should return 422 if project was deleted', (done) => { + const invalidBody = { + param: _.assign({}, body.param, { + referenceId: 2, + }), + }; + + request(server) + .post('/v4/timelines') + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .send(invalidBody) + .expect('Content-Type', /json/) + .expect(422, done); + }); + + it('should return 422 if phase does not exist', (done) => { + const invalidBody = { + param: _.assign({}, body.param, { + reference: 'phase', + referenceId: 2222, + }), + }; + + request(server) + .post('/v4/timelines') + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .send(invalidBody) + .expect('Content-Type', /json/) + .expect(422, done); + }); + + it('should return 422 if phase was deleted', (done) => { + const invalidBody = { + param: _.assign({}, body.param, { + reference: 'phase', + referenceId: 2, + }), + }; + + request(server) + .post('/v4/timelines') + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .send(invalidBody) + .expect('Content-Type', /json/) + .expect(422, done); + }); + + it('should return 201 for admin', (done) => { + request(server) + .post('/v4/timelines') + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .send(body) + .expect('Content-Type', /json/) + .expect(201) + .end((err, res) => { + const resJson = res.body.result.content; + should.exist(resJson.id); + resJson.name.should.be.eql(body.param.name); + resJson.description.should.be.eql(body.param.description); + resJson.startDate.should.be.eql(body.param.startDate); + resJson.endDate.should.be.eql(body.param.endDate); + resJson.reference.should.be.eql(body.param.reference); + resJson.referenceId.should.be.eql(body.param.referenceId); + + resJson.createdBy.should.be.eql(40051333); // admin + should.exist(resJson.createdAt); + resJson.updatedBy.should.be.eql(40051333); // admin + should.exist(resJson.updatedAt); + should.not.exist(resJson.deletedBy); + should.not.exist(resJson.deletedAt); + + // eslint-disable-next-line no-unused-expressions + server.services.pubsub.publish.calledWith(EVENT.ROUTING_KEY.TIMELINE_ADDED).should.be.true; + + done(); + }); + }); + + it('should return 201 for connect manager', (done) => { + request(server) + .post('/v4/timelines') + .set({ + Authorization: `Bearer ${testUtil.jwts.manager}`, + }) + .send(body) + .expect('Content-Type', /json/) + .expect(201) + .end((err, res) => { + const resJson = res.body.result.content; + resJson.createdBy.should.be.eql(40051334); // manager + resJson.updatedBy.should.be.eql(40051334); // manager + done(); + }); + }); + + it('should return 201 for connect admin', (done) => { + request(server) + .post('/v4/timelines') + .set({ + Authorization: `Bearer ${testUtil.jwts.connectAdmin}`, + }) + .send(body) + .expect('Content-Type', /json/) + .expect(201) + .end((err, res) => { + const resJson = res.body.result.content; + resJson.createdBy.should.be.eql(40051336); // connect admin + resJson.updatedBy.should.be.eql(40051336); // connect admin + done(); + }); + }); + + it('should return 201 for copilot', (done) => { + request(server) + .post('/v4/timelines') + .set({ + Authorization: `Bearer ${testUtil.jwts.copilot}`, + }) + .send(body) + .expect('Content-Type', /json/) + .expect(201) + .end((err, res) => { + const resJson = res.body.result.content; + resJson.createdBy.should.be.eql(40051332); // copilot + resJson.updatedBy.should.be.eql(40051332); // copilot + done(); + }); + }); + + it('should return 201 for member', (done) => { + request(server) + .post('/v4/timelines') + .set({ + Authorization: `Bearer ${testUtil.jwts.member}`, + }) + .send(body) + .expect('Content-Type', /json/) + .expect(201) + .end((err, res) => { + const resJson = res.body.result.content; + resJson.createdBy.should.be.eql(40051331); // member + resJson.updatedBy.should.be.eql(40051331); // member + done(); + }); + }); + + it('should return 201 for member (timeline refers to a phase)', (done) => { + const bodyWithPhase = _.merge({}, body, { + param: { + reference: 'phase', + referenceId: 1, + }, + }); + request(server) + .post('/v4/timelines') + .set({ + Authorization: `Bearer ${testUtil.jwts.member}`, + }) + .send(bodyWithPhase) + .expect('Content-Type', /json/) + .expect(201) + .end((err, res) => { + const resJson = res.body.result.content; + resJson.createdBy.should.be.eql(40051331); // member + resJson.updatedBy.should.be.eql(40051331); // member + done(); + }); + }); + }); +}); diff --git a/src/routes/timelines/delete.js b/src/routes/timelines/delete.js new file mode 100644 index 00000000..e3d94bb7 --- /dev/null +++ b/src/routes/timelines/delete.js @@ -0,0 +1,52 @@ +/** + * API to delete a timeline + */ +import validate from 'express-validation'; +import Joi from 'joi'; +import _ from 'lodash'; +import { middleware as tcMiddleware } from 'tc-core-library-js'; +import models from '../../models'; +import { EVENT } from '../../constants'; +import util from '../../util'; + +const permissions = tcMiddleware.permissions; + +const schema = { + params: { + timelineId: Joi.number().integer().positive().required(), + }, +}; + +module.exports = [ + validate(schema), + // Validate and get projectId from the timelineId param, and set to request params for + // checking by the permissions middleware + util.validateTimelineIdParam, + permissions('timeline.delete'), + (req, res, next) => { + const timeline = req.timeline; + const deleted = _.omit(timeline.toJSON(), ['deletedAt', 'deletedBy']); + + return models.sequelize.transaction(() => + // Update the deletedBy, then delete + timeline.update({ deletedBy: req.authUser.userId }) + .then(() => timeline.destroy()) + // Cascade delete the milestones + .then(() => models.Milestone.update({ deletedBy: req.authUser.userId }, { where: { timelineId: timeline.id } })) + .then(() => models.Milestone.destroy({ where: { timelineId: timeline.id } })) + .then(() => { + // Send event to bus + req.log.debug('Sending event to RabbitMQ bus for timeline %d', deleted.id); + req.app.services.pubsub.publish(EVENT.ROUTING_KEY.TIMELINE_REMOVED, + deleted, + { correlationId: req.id }, + ); + + // Write to response + res.status(204).end(); + return Promise.resolve(); + }) + .catch(next), + ); + }, +]; diff --git a/src/routes/timelines/delete.spec.js b/src/routes/timelines/delete.spec.js new file mode 100644 index 00000000..76a0fb55 --- /dev/null +++ b/src/routes/timelines/delete.spec.js @@ -0,0 +1,306 @@ +/** + * Tests for delete.js + */ +import request from 'supertest'; +import chai from 'chai'; + +import models from '../../models'; +import server from '../../app'; +import testUtil from '../../tests/util'; +import { EVENT } from '../../constants'; + +const should = chai.should(); // eslint-disable-line no-unused-vars + +describe('DELETE timeline', () => { + beforeEach((done) => { + testUtil.clearDb() + .then(() => { + models.Project.bulkCreate([ + { + type: 'generic', + billingAccountId: 1, + name: 'test1', + description: 'test project1', + status: 'draft', + details: {}, + createdBy: 1, + updatedBy: 1, + }, + { + type: 'generic', + billingAccountId: 2, + name: 'test2', + description: 'test project2', + status: 'draft', + details: {}, + createdBy: 2, + updatedBy: 2, + deletedAt: '2018-05-15T00:00:00Z', + }, + ]) + .then(() => { + // Create member + models.ProjectMember.bulkCreate([ + { + userId: 40051332, + projectId: 1, + role: 'copilot', + isPrimary: true, + createdBy: 1, + updatedBy: 1, + }, + { + userId: 40051331, + projectId: 1, + role: 'customer', + isPrimary: true, + createdBy: 1, + updatedBy: 1, + }, + ]).then(() => + // Create phase + models.ProjectPhase.bulkCreate([ + { + projectId: 1, + name: 'test project phase 1', + 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 2', + }, + createdBy: 1, + updatedBy: 1, + }, + { + projectId: 2, + name: 'test project phase 2', + status: 'active', + startDate: '2018-05-16T00:00:00Z', + endDate: '2018-05-16T12:00:00Z', + budget: 21.0, + progress: 1.234567, + details: { + message: 'This can be any json 2', + }, + createdBy: 2, + updatedBy: 2, + deletedAt: '2018-05-15T00:00:00Z', + }, + ])) + .then(() => + // Create timelines + models.Timeline.bulkCreate([ + { + name: 'name 1', + description: 'description 1', + startDate: '2018-05-11T00:00:00.000Z', + endDate: '2018-05-12T00:00:00.000Z', + reference: 'project', + referenceId: 1, + createdBy: 1, + updatedBy: 1, + }, + { + name: 'name 2', + description: 'description 2', + startDate: '2018-05-12T00:00:00.000Z', + endDate: '2018-05-13T00:00:00.000Z', + reference: 'phase', + referenceId: 1, + createdBy: 1, + updatedBy: 1, + }, + { + name: 'name 3', + description: 'description 3', + startDate: '2018-05-13T00:00:00.000Z', + endDate: '2018-05-14T00:00:00.000Z', + reference: 'phase', + referenceId: 1, + createdBy: 1, + updatedBy: 1, + deletedAt: '2018-05-14T00:00:00.000Z', + }, + ])) + .then(() => + // Create milestones + models.Milestone.bulkCreate([ + { + timelineId: 1, + name: 'milestone 1', + duration: 2, + startDate: '2018-05-03T00:00:00.000Z', + status: 'open', + 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, + }, + { + timelineId: 1, + name: 'milestone 2', + duration: 2, + startDate: '2018-05-03T00:00:00.000Z', + status: 'open', + 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, + }, + ])) + .then(() => done()); + }); + }); + }); + + after(testUtil.clearDb); + + describe('DELETE /timelines/{timelineId}', () => { + it('should return 403 if user is not authenticated', (done) => { + request(server) + .delete('/v4/timelines/1') + .expect(403, done); + }); + + it('should return 403 for member who is not in the project', (done) => { + request(server) + .delete('/v4/timelines/1') + .set({ + Authorization: `Bearer ${testUtil.jwts.member2}`, + }) + .expect(403, done); + }); + + it('should return 403 for member who is not in the project (timeline refers to a phase)', (done) => { + request(server) + .delete('/v4/timelines/2') + .set({ + Authorization: `Bearer ${testUtil.jwts.member2}`, + }) + .expect(403, done); + }); + + it('should return 404 for non-existed timeline', (done) => { + request(server) + .delete('/v4/timelines/1234') + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .expect(404, done); + }); + + it('should return 404 for deleted timeline', (done) => { + request(server) + .delete('/v4/timelines/3') + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .expect(404, done); + }); + + it('should return 422 for invalid param', (done) => { + request(server) + .delete('/v4/timelines/0') + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .expect(422, done); + }); + + // eslint-disable-next-line func-names + it('should return 204, for admin, if timeline was successfully removed', function (done) { + this.timeout(10000); + + models.Milestone.findAll({ where: { timelineId: 1 } }) + .then((results) => { + results.should.have.length(2); + + request(server) + .delete('/v4/timelines/1') + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .expect(204) + .end(() => { + // eslint-disable-next-line no-unused-expressions + server.services.pubsub.publish.calledWith(EVENT.ROUTING_KEY.TIMELINE_REMOVED).should.be.true; + + // Milestones are cascade deleted + setTimeout(() => { + models.Milestone.findAll({ where: { timelineId: 1 } }) + .then((afterResults) => { + afterResults.should.have.length(0); + + done(); + }); + }, 3000); + }); + }); + }); + + it('should return 204, for connect admin, if timeline was successfully removed', (done) => { + request(server) + .delete('/v4/timelines/1') + .set({ + Authorization: `Bearer ${testUtil.jwts.connectAdmin}`, + }) + .expect(204) + .end(done); + }); + + it('should return 204, for connect manager, if timeline was successfully removed', (done) => { + request(server) + .delete('/v4/timelines/1') + .set({ + Authorization: `Bearer ${testUtil.jwts.manager}`, + }) + .expect(204) + .end(done); + }); + + it('should return 204, for copilot, if timeline was successfully removed', (done) => { + request(server) + .delete('/v4/timelines/1') + .set({ + Authorization: `Bearer ${testUtil.jwts.copilot}`, + }) + .expect(204) + .end(done); + }); + + it('should return 204, for member, if timeline was successfully removed', (done) => { + request(server) + .delete('/v4/timelines/1') + .set({ + Authorization: `Bearer ${testUtil.jwts.member}`, + }) + .expect(204) + .end(done); + }); + }); +}); diff --git a/src/routes/timelines/get.js b/src/routes/timelines/get.js new file mode 100644 index 00000000..2e9a03b1 --- /dev/null +++ b/src/routes/timelines/get.js @@ -0,0 +1,36 @@ +/** + * API to get a timeline + */ +import validate from 'express-validation'; +import Joi from 'joi'; +import _ from 'lodash'; +import { middleware as tcMiddleware } from 'tc-core-library-js'; +import util from '../../util'; + +const permissions = tcMiddleware.permissions; + +const schema = { + params: { + timelineId: Joi.number().integer().positive().required(), + }, +}; + +module.exports = [ + validate(schema), + // Validate and get projectId from the timelineId param, and set to request params for + // checking by the permissions middleware + util.validateTimelineIdParam, + permissions('timeline.view'), + (req, res) => { + // Load the milestones + req.timeline.getMilestones() + .then((milestones) => { + const timeline = _.omit(req.timeline.toJSON(), ['deletedAt', 'deletedBy']); + timeline.milestones = + _.map(milestones, milestone => _.omit(milestone.toJSON(), ['deletedAt', 'deletedBy'])); + + // Write to response + res.json(util.wrapResponse(req.id, timeline)); + }); + }, +]; diff --git a/src/routes/timelines/get.spec.js b/src/routes/timelines/get.spec.js new file mode 100644 index 00000000..253013da --- /dev/null +++ b/src/routes/timelines/get.spec.js @@ -0,0 +1,304 @@ +/** + * Tests for get.js + */ +import chai from 'chai'; +import request from 'supertest'; + +import models from '../../models'; +import server from '../../app'; +import testUtil from '../../tests/util'; + +const should = chai.should(); + +const milestones = [ + { + id: 1, + timelineId: 1, + name: 'milestone 1', + duration: 2, + startDate: '2018-05-03T00:00:00.000Z', + endDate: '2018-05-04T00:00:00.000Z', + completionDate: '2018-05-05T00:00:00.000Z', + status: 'open', + 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', + }, + { + id: 2, + timelineId: 1, + name: 'milestone 2', + duration: 3, + startDate: '2018-05-04T00:00:00.000Z', + status: 'open', + type: 'type2', + order: 2, + plannedText: 'plannedText 2', + activeText: 'activeText 2', + completedText: 'completedText 2', + blockedText: 'blockedText 2', + createdBy: 2, + updatedBy: 3, + createdAt: '2018-05-11T00:00:00.000Z', + updatedAt: '2018-05-11T00:00:00.000Z', + }, +]; + +describe('GET timeline', () => { + before((done) => { + testUtil.clearDb() + .then(() => { + models.Project.bulkCreate([ + { + type: 'generic', + billingAccountId: 1, + name: 'test1', + description: 'test project1', + status: 'draft', + details: {}, + createdBy: 1, + updatedBy: 1, + }, + { + type: 'generic', + billingAccountId: 2, + name: 'test2', + description: 'test project2', + status: 'draft', + details: {}, + createdBy: 2, + updatedBy: 2, + deletedAt: '2018-05-15T00:00:00Z', + }, + ]) + .then(() => { + // Create member + models.ProjectMember.bulkCreate([ + { + userId: 40051332, + projectId: 1, + role: 'copilot', + isPrimary: true, + createdBy: 1, + updatedBy: 1, + }, + { + userId: 40051331, + projectId: 1, + role: 'customer', + isPrimary: true, + createdBy: 1, + updatedBy: 1, + }, + ]).then(() => + // Create phase + models.ProjectPhase.bulkCreate([ + { + projectId: 1, + name: 'test project phase 1', + 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 2', + }, + createdBy: 1, + updatedBy: 1, + }, + { + projectId: 2, + name: 'test project phase 2', + status: 'active', + startDate: '2018-05-16T00:00:00Z', + endDate: '2018-05-16T12:00:00Z', + budget: 21.0, + progress: 1.234567, + details: { + message: 'This can be any json 2', + }, + createdBy: 2, + updatedBy: 2, + deletedAt: '2018-05-15T00:00:00Z', + }, + ])) + .then(() => + // Create timelines + models.Timeline.bulkCreate([ + { + name: 'name 1', + description: 'description 1', + startDate: '2018-05-11T00:00:00.000Z', + endDate: '2018-05-12T00:00:00.000Z', + reference: 'project', + referenceId: 1, + createdBy: 1, + updatedBy: 1, + }, + { + name: 'name 2', + description: 'description 2', + startDate: '2018-05-12T00:00:00.000Z', + endDate: '2018-05-13T00:00:00.000Z', + reference: 'phase', + referenceId: 1, + createdBy: 1, + updatedBy: 1, + }, + { + name: 'name 3', + description: 'description 3', + startDate: '2018-05-13T00:00:00.000Z', + endDate: '2018-05-14T00:00:00.000Z', + reference: 'phase', + referenceId: 1, + createdBy: 1, + updatedBy: 1, + deletedAt: '2018-05-14T00:00:00.000Z', + }, + ])) + .then(() => models.Milestone.bulkCreate(milestones)) + .then(() => done()); + }); + }); + }); + + after(testUtil.clearDb); + + describe('GET /timelines/{timelineId}', () => { + it('should return 403 if user is not authenticated', (done) => { + request(server) + .get('/v4/timelines/1') + .expect(403, done); + }); + + it('should return 403 for member who is not in the project', (done) => { + request(server) + .get('/v4/timelines/1') + .set({ + Authorization: `Bearer ${testUtil.jwts.member2}`, + }) + .expect(403, done); + }); + + it('should return 403 for member who is not in the project (timeline refers to a phase)', (done) => { + request(server) + .get('/v4/timelines/2') + .set({ + Authorization: `Bearer ${testUtil.jwts.member2}`, + }) + .expect(403, done); + }); + + it('should return 404 for non-existed timeline', (done) => { + request(server) + .get('/v4/timelines/1234') + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .expect(404, done); + }); + + it('should return 404 for deleted timeline', (done) => { + request(server) + .get('/v4/timelines/3') + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .expect(404, done); + }); + + it('should return 422 for invalid param', (done) => { + request(server) + .get('/v4/timelines/0') + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .expect(422, done); + }); + + it('should return 200 for admin', (done) => { + request(server) + .get('/v4/timelines/1') + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .expect(200) + .end((err, res) => { + const resJson = res.body.result.content; + resJson.id.should.be.eql(1); + resJson.name.should.be.eql('name 1'); + resJson.description.should.be.eql('description 1'); + resJson.startDate.should.be.eql('2018-05-11T00:00:00.000Z'); + resJson.endDate.should.be.eql('2018-05-12T00:00:00.000Z'); + resJson.reference.should.be.eql('project'); + resJson.referenceId.should.be.eql(1); + + resJson.createdBy.should.be.eql(1); + should.exist(resJson.createdAt); + resJson.updatedBy.should.be.eql(1); + should.exist(resJson.updatedAt); + should.not.exist(resJson.deletedBy); + should.not.exist(resJson.deletedAt); + + // Milestones + resJson.milestones.should.have.length(2); + + done(); + }); + }); + + it('should return 200 for connect admin', (done) => { + request(server) + .get('/v4/timelines/1') + .set({ + Authorization: `Bearer ${testUtil.jwts.connectAdmin}`, + }) + .expect(200) + .end(done); + }); + + it('should return 200 for connect manager', (done) => { + request(server) + .get('/v4/timelines/1') + .set({ + Authorization: `Bearer ${testUtil.jwts.manager}`, + }) + .expect(200) + .end(done); + }); + + it('should return 200 for member', (done) => { + request(server) + .get('/v4/timelines/1') + .set({ + Authorization: `Bearer ${testUtil.jwts.member}`, + }) + .expect(200, done); + }); + + it('should return 200 for copilot', (done) => { + request(server) + .get('/v4/timelines/1') + .set({ + Authorization: `Bearer ${testUtil.jwts.copilot}`, + }) + .expect(200, done); + }); + }); +}); diff --git a/src/routes/timelines/list.js b/src/routes/timelines/list.js new file mode 100644 index 00000000..6d3ff14f --- /dev/null +++ b/src/routes/timelines/list.js @@ -0,0 +1,94 @@ +/** + * API to list all timelines + */ +import config from 'config'; +import _ from 'lodash'; +import util from '../../util'; +import models from '../../models'; +import { USER_ROLE, TIMELINE_REFERENCES } from '../../constants'; + +const ES_TIMELINE_INDEX = config.get('elasticsearchConfig.timelineIndexName'); +const ES_TIMELINE_TYPE = config.get('elasticsearchConfig.timelineDocType'); + +/** + * Retrieve timelines from elastic search. + * @param {Array} esTerms the elastic search terms + * @returns {Promise} the promise resolves to the results + */ +function retrieveTimelines(esTerms) { + return new Promise((accept, reject) => { + const es = util.getElasticSearchClient(); + es.search({ + index: ES_TIMELINE_INDEX, + type: ES_TIMELINE_TYPE, + body: { + query: { bool: { must: esTerms } }, + }, + }).then((docs) => { + const rows = _.map(docs.hits.hits, single => _.omit(single._source, ['projectId'])); // eslint-disable-line no-underscore-dangle + accept({ rows, count: docs.hits.total }); + }).catch(reject); + }); +} + + +module.exports = [ + (req, res, next) => { + // Validate the filter + const filter = util.parseQueryFilter(req.query.filter); + if (!util.isValidFilter(filter, ['reference', 'referenceId'])) { + const apiErr = new Error('Only allowed to filter by reference and referenceId'); + apiErr.status = 422; + return next(apiErr); + } + + // Build the elastic search query + const esTerms = []; + if (filter.reference) { + if (!_.includes(TIMELINE_REFERENCES, filter.reference)) { + const apiErr = new Error(`reference filter must be in ${TIMELINE_REFERENCES}`); + apiErr.status = 422; + return next(apiErr); + } + + esTerms.push({ + term: { reference: filter.reference }, + }); + } + if (filter.referenceId) { + if (_.lt(filter.referenceId, 1)) { + const apiErr = new Error('referenceId filter must be a positive integer'); + apiErr.status = 422; + return next(apiErr); + } + + esTerms.push({ + term: { referenceId: filter.referenceId }, + }); + } + + // Admin and topcoder manager can see all timelines + if (util.hasAdminRole(req) || util.hasRole(req, USER_ROLE.MANAGER)) { + return retrieveTimelines(esTerms) + .then(result => res.json(util.wrapResponse(req.id, result.rows, result.count))) + .catch(err => next(err)); + } + + // Get project ids for copilot or member + const getProjectIds = util.hasRole(req, USER_ROLE.COPILOT) ? + models.Project.getProjectIdsForCopilot(req.authUser.userId) : + models.ProjectMember.getProjectIdsForUser(req.authUser.userId); + + return getProjectIds + .then((accessibleProjectIds) => { + // Copilot or member can see his projects + esTerms.push({ + terms: { projectId: accessibleProjectIds }, + }); + + return retrieveTimelines(esTerms); + }) + .then(result => res.json(util.wrapResponse(req.id, result.rows, result.count))) + .catch(err => next(err)); + }, +]; diff --git a/src/routes/timelines/list.spec.js b/src/routes/timelines/list.spec.js new file mode 100644 index 00000000..f903d16c --- /dev/null +++ b/src/routes/timelines/list.spec.js @@ -0,0 +1,397 @@ +/** + * Tests for list.js + */ +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'; +import testUtil from '../../tests/util'; + +const ES_TIMELINE_INDEX = config.get('elasticsearchConfig.timelineIndexName'); +const ES_TIMELINE_TYPE = config.get('elasticsearchConfig.timelineDocType'); + +const should = chai.should(); + +const timelines = [ + { + name: 'name 1', + description: 'description 1', + startDate: '2018-05-11T00:00:00.000Z', + endDate: '2018-05-12T00:00:00.000Z', + reference: 'project', + referenceId: 1, + createdBy: 1, + updatedBy: 1, + }, + { + name: 'name 2', + description: 'description 2', + startDate: '2018-05-12T00:00:00.000Z', + endDate: '2018-05-13T00:00:00.000Z', + reference: 'phase', + referenceId: 1, + createdBy: 1, + updatedBy: 1, + }, + { + name: 'name 3', + description: 'description 3', + startDate: '2018-05-13T00:00:00.000Z', + endDate: '2018-05-14T00:00:00.000Z', + reference: 'phase', + referenceId: 2, + createdBy: 1, + updatedBy: 1, + }, +]; +const milestones = [ + { + id: 1, + timelineId: 1, + name: 'milestone 1', + duration: 2, + startDate: '2018-05-03T00:00:00.000Z', + endDate: '2018-05-04T00:00:00.000Z', + completionDate: '2018-05-05T00:00:00.000Z', + status: 'open', + 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', + }, + { + id: 2, + timelineId: 1, + name: 'milestone 2', + duration: 3, + startDate: '2018-05-04T00:00:00.000Z', + status: 'open', + type: 'type2', + order: 2, + plannedText: 'plannedText 2', + activeText: 'activeText 2', + completedText: 'completedText 2', + blockedText: 'blockedText 2', + createdBy: 2, + updatedBy: 3, + createdAt: '2018-05-11T00:00:00.000Z', + updatedAt: '2018-05-11T00:00:00.000Z', + }, +]; + + +describe('LIST timelines', () => { + before(function beforeHook(done) { + this.timeout(10000); + testUtil.clearDb() + .then(() => { + models.Project.bulkCreate([ + { + type: 'generic', + billingAccountId: 1, + name: 'test1', + description: 'test project1', + status: 'draft', + details: {}, + createdBy: 1, + updatedBy: 1, + }, + { + type: 'generic', + billingAccountId: 2, + name: 'test2', + description: 'test project2', + status: 'draft', + details: {}, + createdBy: 2, + updatedBy: 2, + deletedAt: '2018-05-15T00:00:00Z', + }, + ]) + .then(() => { + // Create member + models.ProjectMember.bulkCreate([ + { + userId: 40051332, + projectId: 1, + role: 'copilot', + isPrimary: true, + createdBy: 1, + updatedBy: 1, + }, + { + userId: 40051331, + projectId: 1, + role: 'customer', + isPrimary: true, + createdBy: 1, + updatedBy: 1, + }, + ]).then(() => + // Create phase + models.ProjectPhase.bulkCreate([ + { + projectId: 1, + name: 'test project phase 1', + 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 2', + }, + createdBy: 1, + updatedBy: 1, + }, + { + projectId: 2, + name: 'test project phase 2', + status: 'active', + startDate: '2018-05-16T00:00:00Z', + endDate: '2018-05-16T12:00:00Z', + budget: 21.0, + progress: 1.234567, + details: { + message: 'This can be any json 2', + }, + createdBy: 2, + updatedBy: 2, + }, + ])) + .then(() => + // Create timelines + models.Timeline.bulkCreate(timelines, { returning: true })) + .then(createdTimelines => + // 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, + }); + })) + .then(() => { + // sleep for some time, let elasticsearch indices be settled + sleep.sleep(5); + done(); + })); + }); + }); + }); + + after(testUtil.clearDb); + + describe('GET /timelines', () => { + it('should return 403 if user is not authenticated', (done) => { + request(server) + .get('/v4/timelines') + .expect(403, done); + }); + + it('should return 422 for invalid filter key', (done) => { + request(server) + .get('/v4/timelines?filter=invalid%3Dproject') + .set({ + Authorization: `Bearer ${testUtil.jwts.connectAdmin}`, + }) + .expect(422) + .end(done); + }); + + it('should return 422 for invalid reference filter', (done) => { + request(server) + .get('/v4/timelines?filter=reference%3Dinvalid') + .set({ + Authorization: `Bearer ${testUtil.jwts.connectAdmin}`, + }) + .expect(422) + .end(done); + }); + + it('should return 422 for invalid referenceId filter', (done) => { + request(server) + .get('/v4/timelines?filter=referenceId%3D0') + .set({ + Authorization: `Bearer ${testUtil.jwts.connectAdmin}`, + }) + .expect(422) + .end(done); + }); + + it('should return 200 for admin', (done) => { + request(server) + .get('/v4/timelines') + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .expect(200) + .end((err, res) => { + const timeline = timelines[0]; + + let resJson = res.body.result.content; + resJson.should.have.length(3); + resJson = _.sortBy(resJson, o => o.id); + resJson[0].id.should.be.eql(1); + resJson[0].name.should.be.eql(timeline.name); + resJson[0].description.should.be.eql(timeline.description); + resJson[0].startDate.should.be.eql(timeline.startDate); + resJson[0].endDate.should.be.eql(timeline.endDate); + resJson[0].reference.should.be.eql(timeline.reference); + resJson[0].referenceId.should.be.eql(timeline.referenceId); + + resJson[0].createdBy.should.be.eql(timeline.createdBy); + should.exist(resJson[0].createdAt); + resJson[0].updatedBy.should.be.eql(timeline.updatedBy); + should.exist(resJson[0].updatedAt); + should.not.exist(resJson[0].deletedBy); + should.not.exist(resJson[0].deletedAt); + + // Milestones + resJson[0].milestones.should.have.length(2); + + done(); + }); + }); + + it('should return 200 for connect admin', (done) => { + request(server) + .get('/v4/timelines') + .set({ + Authorization: `Bearer ${testUtil.jwts.connectAdmin}`, + }) + .expect(200) + .end((err, res) => { + const resJson = res.body.result.content; + resJson.should.have.length(3); + + done(); + }); + }); + + it('should return 200 for connect manager', (done) => { + request(server) + .get('/v4/timelines') + .set({ + Authorization: `Bearer ${testUtil.jwts.manager}`, + }) + .expect(200) + .end((err, res) => { + const resJson = res.body.result.content; + resJson.should.have.length(3); + + done(); + }); + }); + + it('should return 200 for member', (done) => { + request(server) + .get('/v4/timelines') + .set({ + Authorization: `Bearer ${testUtil.jwts.member}`, + }) + .end((err, res) => { + const resJson = res.body.result.content; + resJson.should.have.length(2); + + done(); + }); + }); + + it('should return 200 for copilot', (done) => { + request(server) + .get('/v4/timelines') + .set({ + Authorization: `Bearer ${testUtil.jwts.copilot}`, + }) + .end((err, res) => { + const resJson = res.body.result.content; + resJson.should.have.length(2); + + done(); + }); + }); + + it('should return 200 for member with no accessible project', (done) => { + request(server) + .get('/v4/timelines') + .set({ + Authorization: `Bearer ${testUtil.jwts.member2}`, + }) + .end((err, res) => { + const resJson = res.body.result.content; + resJson.should.have.length(0); // no accessible timelines + + done(); + }); + }); + + it('should return 200 with reference filter', (done) => { + request(server) + .get('/v4/timelines?filter=reference%3Dproject') + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .expect(200) + .end((err, res) => { + const resJson = res.body.result.content; + resJson.should.have.length(1); + + done(); + }); + }); + + it('should return 200 with referenceId filter', (done) => { + request(server) + .get('/v4/timelines?filter=referenceId%3D2') + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .expect(200) + .end((err, res) => { + const resJson = res.body.result.content; + resJson.should.have.length(1); + + done(); + }); + }); + + it('should return 200 with reference and referenceId filter', (done) => { + request(server) + .get('/v4/timelines?filter=reference%3Dproject%26referenceId%3D1') + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .expect(200) + .end((err, res) => { + const resJson = res.body.result.content; + resJson.should.have.length(1); + + done(); + }); + }); + }); +}); diff --git a/src/routes/timelines/update.js b/src/routes/timelines/update.js new file mode 100644 index 00000000..99343de4 --- /dev/null +++ b/src/routes/timelines/update.js @@ -0,0 +1,109 @@ +/** + * API to update a timeline + */ +import validate from 'express-validation'; +import _ from 'lodash'; +import Joi from 'joi'; +import { middleware as tcMiddleware } from 'tc-core-library-js'; +import util from '../../util'; +import { EVENT, TIMELINE_REFERENCES } from '../../constants'; + +const permissions = tcMiddleware.permissions; + +const schema = { + params: { + timelineId: Joi.number().integer().positive().required(), + }, + body: { + param: Joi.object().keys({ + id: Joi.any().strip(), + name: Joi.string().max(255).required(), + description: Joi.string().max(255), + startDate: Joi.date().required(), + endDate: Joi.date().min(Joi.ref('startDate')).allow(null), + reference: Joi.string().valid(_.values(TIMELINE_REFERENCES)).required(), + referenceId: Joi.number().integer().positive().required(), + createdAt: Joi.any().strip(), + updatedAt: Joi.any().strip(), + deletedAt: Joi.any().strip(), + createdBy: Joi.any().strip(), + updatedBy: Joi.any().strip(), + deletedBy: Joi.any().strip(), + }).required(), + }, +}; + +module.exports = [ + validate(schema), + // Validate and get projectId from the timelineId param and request body, + // and set to request params for checking by the permissions middleware + util.validateTimelineIdParam, + util.validateTimelineRequestBody, + permissions('timeline.edit'), + (req, res, next) => { + const entityToUpdate = _.assign(req.body.param, { + updatedBy: req.authUser.userId, + }); + + const timeline = req.timeline; + const original = _.omit(timeline.toJSON(), ['deletedAt', 'deletedBy']); + let updated; + + // Update + return timeline.update(entityToUpdate) + .then((updatedTimeline) => { + // Omit deletedAt, deletedBy + updated = _.omit(updatedTimeline.toJSON(), ['deletedAt', 'deletedBy']); + + // Update milestones startDate and endDate if necessary + if (original.startDate !== updated.startDate || original.endDate !== updated.endDate) { + return updatedTimeline.getMilestones() + .then((milestones) => { + const updateMilestonePromises = _.map(milestones, (_milestone) => { + const milestone = _milestone; + if (original.startDate !== updated.startDate) { + if (milestone.startDate && milestone.startDate < updated.startDate) { + milestone.startDate = updated.startDate; + if (milestone.endDate && milestone.endDate < milestone.startDate) { + milestone.endDate = milestone.startDate; + } + milestone.updatedBy = req.authUser.userId; + } + } + + if (original.endDate !== updated.endDate) { + if (milestone.endDate && updated.endDate && updated.endDate < milestone.endDate) { + milestone.endDate = updated.endDate; + milestone.updatedBy = req.authUser.userId; + } + } + + return milestone.save(); + }); + + return Promise.all(updateMilestonePromises) + .then((updatedMilestones) => { + updated.milestones = + _.map(updatedMilestones, milestone => _.omit(milestone.toJSON(), ['deletedAt', 'deletedBy'])); + return Promise.resolve(); + }); + }); + } + + return Promise.resolve(); + }) + .then(() => { + // Send event to bus + req.log.debug('Sending event to RabbitMQ bus for timeline %d', updated.id); + req.app.services.pubsub.publish(EVENT.ROUTING_KEY.TIMELINE_UPDATED, + { original, updated }, + { correlationId: req.id }, + ); + + // Write to response + res.json(util.wrapResponse(req.id, updated)); + return Promise.resolve(); + }) + .catch(next); + }, +]; diff --git a/src/routes/timelines/update.spec.js b/src/routes/timelines/update.spec.js new file mode 100644 index 00000000..eb887fe3 --- /dev/null +++ b/src/routes/timelines/update.spec.js @@ -0,0 +1,626 @@ +/** + * Tests for get.js + */ +import chai from 'chai'; +import request from 'supertest'; +import _ from 'lodash'; +import models from '../../models'; +import server from '../../app'; +import testUtil from '../../tests/util'; +import { EVENT } from '../../constants'; + +const should = chai.should(); + + +const milestones = [ + { + id: 1, + timelineId: 1, + name: 'milestone 1', + duration: 2, + startDate: '2018-05-13T00:00:00.000Z', + endDate: '2018-05-16T00:00:00.000Z', + completionDate: '2018-05-05T00:00:00.000Z', + status: 'open', + 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', + }, + { + id: 2, + timelineId: 1, + name: 'milestone 2', + duration: 3, + startDate: '2018-05-14T00:00:00.000Z', + status: 'open', + type: 'type2', + order: 2, + plannedText: 'plannedText 2', + activeText: 'activeText 2', + completedText: 'completedText 2', + blockedText: 'blockedText 2', + createdBy: 2, + updatedBy: 3, + createdAt: '2018-05-11T00:00:00.000Z', + updatedAt: '2018-05-11T00:00:00.000Z', + }, +]; + +describe('UPDATE timeline', () => { + beforeEach((done) => { + testUtil.clearDb() + .then(() => { + models.Project.bulkCreate([ + { + type: 'generic', + billingAccountId: 1, + name: 'test1', + description: 'test project1', + status: 'draft', + details: {}, + createdBy: 1, + updatedBy: 1, + }, + { + type: 'generic', + billingAccountId: 2, + name: 'test2', + description: 'test project2', + status: 'draft', + details: {}, + createdBy: 2, + updatedBy: 2, + deletedAt: '2018-05-15T00:00:00Z', + }, + ], { returning: true }) + .then(() => { + // Create member + models.ProjectMember.bulkCreate([ + { + userId: 40051332, + projectId: 1, + role: 'copilot', + isPrimary: true, + createdBy: 1, + updatedBy: 1, + }, + { + userId: 40051331, + projectId: 1, + role: 'customer', + isPrimary: true, + createdBy: 1, + updatedBy: 1, + }, + ]).then(() => + // Create phase + models.ProjectPhase.bulkCreate([ + { + projectId: 1, + name: 'test project phase 1', + 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 2', + }, + createdBy: 1, + updatedBy: 1, + }, + { + projectId: 2, + name: 'test project phase 2', + status: 'active', + startDate: '2018-05-16T00:00:00Z', + endDate: '2018-05-16T12:00:00Z', + budget: 21.0, + progress: 1.234567, + details: { + message: 'This can be any json 2', + }, + createdBy: 2, + updatedBy: 2, + deletedAt: '2018-05-15T00:00:00Z', + }, + ]), { returning: true }) + .then(() => + // Create timelines + models.Timeline.bulkCreate([ + { + name: 'name 1', + description: 'description 1', + startDate: '2018-05-11T00:00:00.000Z', + endDate: '2018-05-20T00:00:00.000Z', + reference: 'project', + referenceId: 1, + createdBy: 1, + updatedBy: 1, + }, + { + name: 'name 2', + description: 'description 2', + startDate: '2018-05-12T00:00:00.000Z', + endDate: '2018-05-13T00:00:00.000Z', + reference: 'phase', + referenceId: 1, + createdBy: 1, + updatedBy: 1, + }, + { + name: 'name 3', + description: 'description 3', + startDate: '2018-05-13T00:00:00.000Z', + endDate: '2018-05-14T00:00:00.000Z', + reference: 'phase', + referenceId: 1, + createdBy: 1, + updatedBy: 1, + deletedAt: '2018-05-14T00:00:00.000Z', + }, + ])) + .then(() => models.Milestone.bulkCreate(milestones)) + .then(() => done()); + }); + }); + }); + + after(testUtil.clearDb); + + describe('PATCH /timelines/{timelineId}', () => { + const body = { + param: { + name: 'new name 1', + description: 'new description 1', + startDate: '2018-06-01T00:00:00.000Z', + endDate: '2018-06-02T00:00:00.000Z', + reference: 'project', + referenceId: 1, + }, + }; + + it('should return 403 if user is not authenticated', (done) => { + request(server) + .patch('/v4/timelines/1') + .send(body) + .expect(403, done); + }); + + it('should return 403 for member who is not in the project', (done) => { + request(server) + .patch('/v4/timelines/1') + .set({ + Authorization: `Bearer ${testUtil.jwts.member2}`, + }) + .send(body) + .expect(403, done); + }); + + it('should return 403 for member who is not in the project (timeline refers to a phase)', (done) => { + request(server) + .patch('/v4/timelines/2') + .send(body) + .set({ + Authorization: `Bearer ${testUtil.jwts.member2}`, + }) + .expect(403, done); + }); + + it('should return 404 for non-existed timeline', (done) => { + request(server) + .patch('/v4/timelines/1234') + .send(body) + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .expect(404, done); + }); + + it('should return 404 for deleted timeline', (done) => { + request(server) + .patch('/v4/timelines/3') + .send(body) + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .expect(404, done); + }); + + it('should return 422 for invalid param', (done) => { + request(server) + .patch('/v4/timelines/0') + .send(body) + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .expect(422, done); + }); + + it('should return 404 for non-existed template', (done) => { + request(server) + .patch('/v4/timelines/1234') + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .send(body) + .expect(404, done); + }); + + it('should return 404 for deleted template', (done) => { + request(server) + .patch('/v4/timelines/3') + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .send(body) + .expect(404, done); + }); + + it('should return 422 if missing name', (done) => { + const invalidBody = { + param: _.assign({}, body.param, { + name: undefined, + }), + }; + + request(server) + .patch('/v4/timelines/1') + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .send(invalidBody) + .expect('Content-Type', /json/) + .expect(422, done); + }); + + it('should return 422 if missing startDate', (done) => { + const invalidBody = { + param: _.assign({}, body.param, { + startDate: undefined, + }), + }; + + request(server) + .patch('/v4/timelines/1') + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .send(invalidBody) + .expect('Content-Type', /json/) + .expect(422, done); + }); + + it('should return 422 if startDate is after endDate', (done) => { + const invalidBody = { + param: _.assign({}, body.param, { + startDate: '2018-05-29T00:00:00.000Z', + endDate: '2018-05-28T00:00:00.000Z', + }), + }; + + request(server) + .patch('/v4/timelines/1') + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .send(invalidBody) + .expect('Content-Type', /json/) + .expect(422, done); + }); + + it('should return 422 if missing reference', (done) => { + const invalidBody = { + param: _.assign({}, body.param, { + reference: undefined, + }), + }; + + request(server) + .patch('/v4/timelines/1') + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .send(invalidBody) + .expect('Content-Type', /json/) + .expect(422, done); + }); + + it('should return 422 if missing referenceId', (done) => { + const invalidBody = { + param: _.assign({}, body.param, { + referenceId: undefined, + }), + }; + + request(server) + .patch('/v4/timelines/1') + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .send(invalidBody) + .expect('Content-Type', /json/) + .expect(422, done); + }); + + it('should return 422 if invalid reference', (done) => { + const invalidBody = { + param: _.assign({}, body.param, { + reference: 'invalid', + }), + }; + + request(server) + .patch('/v4/timelines/1') + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .send(invalidBody) + .expect('Content-Type', /json/) + .expect(422, done); + }); + + it('should return 422 if invalid referenceId', (done) => { + const invalidBody = { + param: _.assign({}, body.param, { + referenceId: 0, + }), + }; + + request(server) + .patch('/v4/timelines/1') + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .send(invalidBody) + .expect('Content-Type', /json/) + .expect(422, done); + }); + + it('should return 422 if project does not exist', (done) => { + const invalidBody = { + param: _.assign({}, body.param, { + referenceId: 1110, + }), + }; + + request(server) + .patch('/v4/timelines/1') + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .send(invalidBody) + .expect('Content-Type', /json/) + .expect(422, done); + }); + + it('should return 422 if project was deleted', (done) => { + const invalidBody = { + param: _.assign({}, body.param, { + referenceId: 2, + }), + }; + + request(server) + .patch('/v4/timelines/1') + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .send(invalidBody) + .expect('Content-Type', /json/) + .expect(422, done); + }); + + it('should return 422 if phase does not exist', (done) => { + const invalidBody = { + param: _.assign({}, body.param, { + reference: 'phase', + referenceId: 2222, + }), + }; + + request(server) + .patch('/v4/timelines/1') + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .send(invalidBody) + .expect('Content-Type', /json/) + .expect(422, done); + }); + + it('should return 422 if phase was deleted', (done) => { + const invalidBody = { + param: _.assign({}, body.param, { + reference: 'phase', + referenceId: 2, + }), + }; + + request(server) + .patch('/v4/timelines/1') + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .send(invalidBody) + .expect('Content-Type', /json/) + .expect(422, done); + }); + + it('should return 200 for admin', (done) => { + request(server) + .patch('/v4/timelines/1') + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .send(body) + .expect(200) + .end((err, res) => { + const resJson = res.body.result.content; + should.exist(resJson.id); + resJson.name.should.be.eql(body.param.name); + resJson.description.should.be.eql(body.param.description); + resJson.startDate.should.be.eql(body.param.startDate); + resJson.endDate.should.be.eql(body.param.endDate); + resJson.reference.should.be.eql(body.param.reference); + resJson.referenceId.should.be.eql(body.param.referenceId); + + resJson.createdBy.should.be.eql(1); + should.exist(resJson.createdAt); + resJson.updatedBy.should.be.eql(40051333); // admin + should.exist(resJson.updatedAt); + should.not.exist(resJson.deletedAt); + should.not.exist(resJson.deletedBy); + + // eslint-disable-next-line no-unused-expressions + server.services.pubsub.publish.calledWith(EVENT.ROUTING_KEY.TIMELINE_UPDATED).should.be.true; + + done(); + }); + }); + + // eslint-disable-next-line func-names + it('should return 200 for admin with changed startDate', function (done) { + this.timeout(10000); + + request(server) + .patch('/v4/timelines/1') + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .send({ + param: _.assign({}, body.param, { + startDate: '2018-05-15T00:00:00.000Z', + endDate: '2018-05-17T00:00:00.000Z', // no affect to milestones + }), + }) + .expect(200) + .end(() => { + setTimeout(() => { + models.Milestone.findById(1) + .then((milestone) => { + milestone.startDate.should.be.eql(new Date('2018-05-15T00:00:00.000Z')); + milestone.endDate.should.be.eql(new Date('2018-05-16T00:00:00.000Z')); + }) + .then(() => models.Milestone.findById(2)) + .then((milestone) => { + milestone.startDate.should.be.eql(new Date('2018-05-15T00:00:00.000Z')); + should.not.exist(milestone.endDate); + + done(); + }); + }, 3000); + }); + }); + + // eslint-disable-next-line func-names + it('should return 200 for admin with changed endDate', function (done) { + this.timeout(10000); + + request(server) + .patch('/v4/timelines/1') + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .send({ + param: _.assign({}, body.param, { + startDate: '2018-05-12T00:00:00.000Z', // no affect to milestones + endDate: '2018-05-15T00:00:00.000Z', + }), + }) + .expect(200) + .end(() => { + setTimeout(() => { + models.Milestone.findById(1) + .then((milestone) => { + milestone.startDate.should.be.eql(new Date('2018-05-13T00:00:00.000Z')); + milestone.endDate.should.be.eql(new Date('2018-05-15T00:00:00.000Z')); + }) + .then(() => models.Milestone.findById(2)) + .then((milestone) => { + milestone.startDate.should.be.eql(new Date('2018-05-14T00:00:00.000Z')); + should.not.exist(milestone.endDate); + + done(); + }); + }, 3000); + }); + }); + + it('should return 200 for connect admin', (done) => { + request(server) + .patch('/v4/timelines/1') + .set({ + Authorization: `Bearer ${testUtil.jwts.connectAdmin}`, + }) + .send(body) + .expect(200) + .end(done); + }); + + it('should return 200 for connect manager', (done) => { + request(server) + .patch('/v4/timelines/1') + .set({ + Authorization: `Bearer ${testUtil.jwts.manager}`, + }) + .send(body) + .expect(200) + .end(done); + }); + + it('should return 200 for copilot', (done) => { + request(server) + .patch('/v4/timelines/1') + .set({ + Authorization: `Bearer ${testUtil.jwts.copilot}`, + }) + .send(body) + .expect(200) + .end(done); + }); + + it('should return 200 for member', (done) => { + request(server) + .patch('/v4/timelines/1') + .set({ + Authorization: `Bearer ${testUtil.jwts.member}`, + }) + .send(body) + .expect(200) + .end(done); + }); + + it('should return 200 if changing reference and referenceId', (done) => { + const newBody = { + param: _.assign({}, body.param, { + reference: 'phase', + referenceId: 1, + }), + }; + + request(server) + .patch('/v4/timelines/1') + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .send(newBody) + .expect(200) + .end(done); + }); + }); +}); diff --git a/src/tests/seed.js b/src/tests/seed.js index 350480c4..3ef8b098 100644 --- a/src/tests/seed.js +++ b/src/tests/seed.js @@ -1,4 +1,5 @@ import models from '../models'; +import { TIMELINE_REFERENCES } from '../constants'; models.sequelize.sync({ force: true }) .then(() => @@ -433,6 +434,132 @@ models.sequelize.sync({ force: true }) createdBy: 1, updatedBy: 2, }, + ], { returning: true })) + // Product milestone templates + .then(productTemplates => models.ProductMilestoneTemplate.bulkCreate([ + { + name: 'milestoneTemplate 1', + duration: 3, + type: 'type1', + order: 1, + productTemplateId: productTemplates[0].id, + createdBy: 1, + updatedBy: 2, + }, + { + name: 'milestoneTemplate 2', + duration: 4, + type: 'type2', + order: 2, + productTemplateId: productTemplates[0].id, + createdBy: 2, + updatedBy: 3, + }, + ])) + // Project phases + .then(() => models.ProjectPhase.bulkCreate([ + { + name: 'phase 1', + projectId: 1, + createdBy: 1, + updatedBy: 2, + }, + { + name: 'phase 2', + projectId: 1, + createdBy: 2, + updatedBy: 3, + }, + ], { returning: true })) + // Timelines + .then(projectPhases => models.Timeline.bulkCreate([ + { + name: 'timeline 1', + startDate: '2018-05-01T00:00:00.000Z', + reference: TIMELINE_REFERENCES.PROJECT, + referenceId: projectPhases[0].projectId, + createdBy: 1, + updatedBy: 2, + }, + { + name: 'timeline 2', + startDate: '2018-05-02T00:00:00.000Z', + reference: TIMELINE_REFERENCES.PHASE, + referenceId: projectPhases[0].id, + createdBy: 2, + updatedBy: 3, + }, + { + name: 'timeline 3', + startDate: '2018-05-03T00:00:00.000Z', + reference: TIMELINE_REFERENCES.PROJECT, + referenceId: projectPhases[0].projectId, + createdBy: 3, + updatedBy: 4, + }, + { + name: 'timeline 4', + startDate: '2018-05-04T00:00:00.000Z', + reference: TIMELINE_REFERENCES.PROJECT, + referenceId: 2, + createdBy: 4, + updatedBy: 5, + }, + ], { returning: true })) + // Milestones + .then(timelines => models.Milestone.bulkCreate([ + { + timelineId: timelines[0].id, + name: 'milestone 1', + duration: 2, + startDate: '2018-05-03T00:00:00.000Z', + status: 'open', + 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, + }, + { + timelineId: timelines[0].id, + name: 'milestone 2', + duration: 3, + startDate: '2018-05-04T00:00:00.000Z', + status: 'open', + type: 'type2', + order: 2, + plannedText: 'plannedText 2', + activeText: 'activeText 2', + completedText: 'completedText 2', + blockedText: 'blockedText 2', + createdBy: 2, + updatedBy: 3, + }, + { + timelineId: timelines[2].id, + name: 'milestone 3', + duration: 4, + startDate: '2018-05-04T00:00:00.000Z', + status: 'open', + type: 'type3', + order: 3, + plannedText: 'plannedText 3', + activeText: 'activeText 3', + completedText: 'completedText 3', + blockedText: 'blockedText 3', + createdBy: 3, + updatedBy: 4, + }, ])) .then(() => models.ProjectType.bulkCreate([ { diff --git a/src/util.js b/src/util.js index add05eb6..843ff371 100644 --- a/src/util.js +++ b/src/util.js @@ -17,7 +17,7 @@ import urlencode from 'urlencode'; import elasticsearch from 'elasticsearch'; import Promise from 'bluebird'; import AWS from 'aws-sdk'; -import { ADMIN_ROLES, TOKEN_SCOPES } from './constants'; +import { ADMIN_ROLES, TOKEN_SCOPES, TIMELINE_REFERENCES } from './constants'; const exec = require('child_process').exec; const models = require('./models').default; @@ -381,6 +381,102 @@ _.assignIn(util, { return source; } }), + + /** + * The middleware to validate and get the projectId specified by the timeline request object, + * and set to the request params. This should be called after the validate() middleware, + * and before the permissions() middleware. + * @param {Object} req the express request instance + * @param {Object} res the express response instance + * @param {Function} next the express next middleware + */ + // eslint-disable-next-line valid-jsdoc + validateTimelineRequestBody: (req, res, next) => { + // The timeline refers to a project + if (req.body.param.reference === TIMELINE_REFERENCES.PROJECT) { + // Set projectId to the params so it can be used in the permission check middleware + req.params.projectId = req.body.param.referenceId; + + // Validate projectId to be existed + return models.Project.findOne({ + where: { + id: req.params.projectId, + deletedAt: { $eq: null }, + }, + }) + .then((project) => { + if (!project) { + const apiErr = new Error(`Project not found for project id ${req.params.projectId}`); + apiErr.status = 422; + return next(apiErr); + } + + return next(); + }); + } + + // The timeline refers to a phase + return models.ProjectPhase.findOne({ + where: { + id: req.body.param.referenceId, + deletedAt: { $eq: null }, + }, + }) + .then((phase) => { + if (!phase) { + const apiErr = new Error(`Phase not found for phase id ${req.body.param.referenceId}`); + apiErr.status = 422; + return next(apiErr); + } + + // Set projectId to the params so it can be used in the permission check middleware + req.params.projectId = req.body.param.referenceId; + return next(); + }); + }, + + /** + * The middleware to validate and get the projectId specified by the timelineId from request + * path parameter, and set to the request params. This should be called after the validate() + * middleware, and before the permissions() middleware. + * @param {Object} req the express request instance + * @param {Object} res the express response instance + * @param {Function} next the express next middleware + */ + // eslint-disable-next-line valid-jsdoc + validateTimelineIdParam: (req, res, next) => { + models.Timeline.findById(req.params.timelineId) + .then((timeline) => { + if (!timeline) { + const apiErr = new Error(`Timeline not found for timeline id ${req.params.timelineId}`); + apiErr.status = 404; + return next(apiErr); + } + + // Set timeline to the request to be used in the next middleware + req.timeline = timeline; + + // The timeline refers to a project + if (timeline.reference === TIMELINE_REFERENCES.PROJECT) { + // Set projectId to the params so it can be used in the permission check middleware + req.params.projectId = timeline.referenceId; + return next(); + } + + // The timeline refers to a phase + return models.ProjectPhase.findOne({ + where: { + id: timeline.referenceId, + deletedAt: { $eq: null }, + }, + }) + .then((phase) => { + // Set projectId to the params so it can be used in the permission check middleware + req.params.projectId = phase.projectId; + return next(); + }); + }); + }, }); export default util; diff --git a/swagger.yaml b/swagger.yaml index 39bad0d3..3fa16cb4 100755 --- a/swagger.yaml +++ b/swagger.yaml @@ -27,7 +27,7 @@ paths: operationId: findProjects security: - Bearer: [] - description: Retreive projects that match the filter + description: Retrieve projects that match the filter responses: '422': description: Invalid input @@ -344,7 +344,7 @@ paths: operationId: findProjectPhases security: - Bearer: [] - description: Retreive all project phases. All users who can edit project can access this endpoint. + description: Retrieve all project phases. All users who can edit project can access this endpoint. parameters: - name: fields required: false @@ -489,7 +489,7 @@ paths: operationId: findPhaseProducts security: - Bearer: [] - description: Retreive all phase products. All users who can edit project can access this endpoint. + description: Retrieve all phase products. All users who can edit project can access this endpoint. responses: '403': description: No permission or wrong token @@ -655,7 +655,7 @@ paths: operationId: findProjectTemplates security: - Bearer: [] - description: Retreive all project templates. All user roles can access this endpoint. + description: Retrieve all project templates. All user roles can access this endpoint. responses: '403': description: No permission or wrong token @@ -781,7 +781,7 @@ paths: operationId: findProductTemplates security: - Bearer: [] - description: Retreive all product templates. All user roles can access this endpoint. + description: Retrieve all product templates. All user roles can access this endpoint. responses: '403': description: No permission or wrong token @@ -907,7 +907,7 @@ paths: operationId: findProjectTypes security: - Bearer: [] - description: Retreive all project types. All user roles can access this endpoint. + description: Retrieve all project types. All user roles can access this endpoint. responses: '403': description: No permission or wrong token @@ -1025,6 +1025,441 @@ paths: description: Project type successfully removed + /timelines: + get: + tags: + - timeline + operationId: findTimelines + security: + - Bearer: [] + description: Retrieve timelines which its projects are accessible by the user. + parameters: + - name: filter + required: false + type: string + in: query + description: | + Url encoded list of supported filters + - reference + - referenceId + responses: + '403': + description: No permission or wrong token + schema: + $ref: "#/definitions/ErrorModel" + '200': + description: A list of timelines + schema: + $ref: "#/definitions/TimelineListResponse" + '422': + description: Invalid input + schema: + $ref: "#/definitions/ErrorModel" + post: + tags: + - timeline + operationId: addTimeline + security: + - Bearer: [] + description: Create a timeline. All users who can edit the project can access this endpoint. + parameters: + - in: body + name: body + required: true + schema: + $ref: '#/definitions/TimelineBodyParam' + responses: + '403': + description: No permission or wrong token + schema: + $ref: "#/definitions/ErrorModel" + '201': + description: Returns the newly created timeline + schema: + $ref: "#/definitions/TimelineResponse" + '422': + description: Invalid input + schema: + $ref: "#/definitions/ErrorModel" + + /timelines/{timelineId}: + get: + tags: + - timeline + description: Retrieve timeline by id. All users who can view the project can access this endpoint. + security: + - Bearer: [] + responses: + '404': + description: Not found + schema: + $ref: "#/definitions/ErrorModel" + '403': + description: No permission or wrong token + schema: + $ref: "#/definitions/ErrorModel" + '422': + description: Invalid input + schema: + $ref: "#/definitions/ErrorModel" + '200': + description: a timeline + schema: + $ref: "#/definitions/TimelineResponse" + parameters: + - $ref: "#/parameters/timelineIdParam" + operationId: getTimeline + + patch: + tags: + - timeline + operationId: updateTimeline + security: + - Bearer: [] + description: Update a timeline. All users who can edit the project can access this endpoint. + responses: + '403': + description: No permission or wrong token + schema: + $ref: "#/definitions/ErrorModel" + '404': + description: Not found + schema: + $ref: "#/definitions/ErrorModel" + '200': + description: Successfully updated timeline. + schema: + $ref: "#/definitions/TimelineResponse" + '422': + description: Invalid input + schema: + $ref: "#/definitions/ErrorModel" + default: + description: error payload + schema: + $ref: '#/definitions/ErrorModel' + parameters: + - $ref: "#/parameters/timelineIdParam" + - name: body + in: body + required: true + schema: + $ref: "#/definitions/TimelineBodyParam" + + delete: + tags: + - timeline + description: Remove an existing timeline. All users who can edit the project can access this endpoint. + security: + - Bearer: [] + parameters: + - $ref: "#/parameters/timelineIdParam" + responses: + '403': + description: No permission or wrong token + schema: + $ref: "#/definitions/ErrorModel" + '404': + description: Not found + schema: + $ref: "#/definitions/ErrorModel" + '422': + description: Invalid input + schema: + $ref: "#/definitions/ErrorModel" + '204': + description: Timeline successfully removed + + /timelines/{timelineId}/milestones: + parameters: + - $ref: "#/parameters/timelineIdParam" + get: + tags: + - milestone + operationId: findMilestones + security: + - Bearer: [] + description: Retrieve all milestones. All users who can view the timeline can access this endpoint. + parameters: + - name: sort + required: false + description: sort by `order`. Default is `order asc` + in: query + type: string + responses: + '403': + description: No permission or wrong token + schema: + $ref: "#/definitions/ErrorModel" + '422': + description: Invalid input + schema: + $ref: "#/definitions/ErrorModel" + '200': + description: A list of milestones + schema: + $ref: "#/definitions/MilestoneListResponse" + post: + tags: + - milestone + operationId: addMilestone + security: + - Bearer: [] + description: Create a milestone. All users who can edit the timeline can access this endpoint. + It also updates the `order` field of all other milestones in the same timeline which have `order` greater than or equal to the `order` specified in the POST body. + parameters: + - in: body + name: body + required: true + schema: + $ref: '#/definitions/MilestoneBodyParam' + responses: + '403': + description: No permission or wrong token + schema: + $ref: "#/definitions/ErrorModel" + '201': + description: Returns the newly created milestone + schema: + $ref: "#/definitions/MilestoneResponse" + '422': + description: Invalid input + schema: + $ref: "#/definitions/ErrorModel" + + /timelines/{timelineId}/milestones/{milestoneId}: + parameters: + - $ref: "#/parameters/timelineIdParam" + - $ref: "#/parameters/milestoneIdParam" + get: + tags: + - milestone + description: Retrieve milestone by id. All users who can view the timeline can access this endpoint. + security: + - Bearer: [] + responses: + '404': + description: Not found + schema: + $ref: "#/definitions/ErrorModel" + '403': + description: No permission or wrong token + schema: + $ref: "#/definitions/ErrorModel" + '422': + description: Invalid input + schema: + $ref: "#/definitions/ErrorModel" + '200': + description: a milestone + schema: + $ref: "#/definitions/MilestoneResponse" + operationId: getMilestone + + patch: + tags: + - milestone + operationId: updateMilestone + security: + - Bearer: [] + description: Update a milestone. All users who can edit the timeline can access this endpoint. + For attributes with JSON object type, it would overwrite the existing fields, or add new if the fields don't exist in the JSON object. + responses: + '403': + description: No permission or wrong token + schema: + $ref: "#/definitions/ErrorModel" + '404': + description: Not found + schema: + $ref: "#/definitions/ErrorModel" + '200': + description: Successfully updated milestone. + schema: + $ref: "#/definitions/MilestoneResponse" + '422': + description: Invalid input + schema: + $ref: "#/definitions/ErrorModel" + default: + description: error payload + schema: + $ref: '#/definitions/ErrorModel' + parameters: + - name: body + in: body + required: true + schema: + $ref: "#/definitions/MilestoneBodyParam" + + delete: + tags: + - milestone + description: Remove an existing milestone. All users who can edit the timeline can access this endpoint. + security: + - Bearer: [] + responses: + '403': + description: No permission or wrong token + schema: + $ref: "#/definitions/ErrorModel" + '404': + description: Not found + schema: + $ref: "#/definitions/ErrorModel" + '422': + description: Invalid input + schema: + $ref: "#/definitions/ErrorModel" + '204': + description: Milestone successfully removed + + + /productTemplates/{productTemplateId}/milestones: + parameters: + - $ref: "#/parameters/productTemplateIdParam" + get: + tags: + - productMilestoneTemplate + operationId: findMilestoneTemplates + security: + - Bearer: [] + description: Retrieve all milestone templates. All user roles can access this endpoint. + parameters: + - name: sort + required: false + description: sort by `order`. Default is `order asc` + in: query + type: string + responses: + '403': + description: No permission or wrong token + schema: + $ref: "#/definitions/ErrorModel" + '422': + description: Invalid input + schema: + $ref: "#/definitions/ErrorModel" + '200': + description: A list of milestone templates + schema: + $ref: "#/definitions/MilestoneTemplateListResponse" + post: + tags: + - productMilestoneTemplate + operationId: addMilestoneTemplate + security: + - Bearer: [] + description: Create a milestone template. Only connect manager, connect admin, and admin can access this endpoint. It also updates the `order` field of all other milestone templates in the same product template which have `order` greater than or equal to the `order` specified in the POST body. + parameters: + - in: body + name: body + required: true + schema: + $ref: '#/definitions/MilestoneTemplateBodyParam' + responses: + '403': + description: No permission or wrong token + schema: + $ref: "#/definitions/ErrorModel" + '201': + description: Returns the newly created milestone template + schema: + $ref: "#/definitions/MilestoneTemplateResponse" + '422': + description: Invalid input + schema: + $ref: "#/definitions/ErrorModel" + + /productTemplates/{productTemplateId}/milestones/{milestoneTemplateId}: + parameters: + - $ref: "#/parameters/productTemplateIdParam" + - $ref: "#/parameters/milestoneTemplateIdParam" + get: + tags: + - productMilestoneTemplate + description: Retrieve milestone template by id. All user roles can access this endpoint. + security: + - Bearer: [] + responses: + '404': + description: Not found + schema: + $ref: "#/definitions/ErrorModel" + '403': + description: No permission or wrong token + schema: + $ref: "#/definitions/ErrorModel" + '422': + description: Invalid input + schema: + $ref: "#/definitions/ErrorModel" + '200': + description: a milestone template + schema: + $ref: "#/definitions/MilestoneTemplateResponse" + operationId: getMilestoneTemplate + + patch: + tags: + - productMilestoneTemplate + operationId: updateMilestoneTemplate + security: + - Bearer: [] + description: Update a milestone template. Only connect manager, connect admin, and admin can access this endpoint. + responses: + '403': + description: No permission or wrong token + schema: + $ref: "#/definitions/ErrorModel" + '404': + description: Not found + schema: + $ref: "#/definitions/ErrorModel" + '200': + description: Successfully updated milestone template. + schema: + $ref: "#/definitions/MilestoneTemplateResponse" + '422': + description: Invalid input + schema: + $ref: "#/definitions/ErrorModel" + default: + description: error payload + schema: + $ref: '#/definitions/ErrorModel' + parameters: + - name: body + in: body + required: true + schema: + $ref: "#/definitions/MilestoneTemplateBodyParam" + + delete: + tags: + - productMilestoneTemplate + description: Remove an existing milestone template. Only connect manager, connect admin, and admin can access this endpoint. + security: + - Bearer: [] + responses: + '403': + description: No permission or wrong token + schema: + $ref: "#/definitions/ErrorModel" + '404': + description: Not found + schema: + $ref: "#/definitions/ErrorModel" + '422': + description: Invalid input + schema: + $ref: "#/definitions/ErrorModel" + '204': + description: Milestone template successfully removed + + + + parameters: @@ -1065,6 +1500,38 @@ parameters: description: project type key required: true type: string + timelineIdParam: + name: timelineId + in: path + description: timeline identifier + required: true + type: integer + format: int64 + minimum: 1 + milestoneIdParam: + name: milestoneId + in: path + description: milestone identifier + required: true + type: integer + format: int64 + minimum: 1 + productTemplateIdParam: + name: productTemplateId + in: path + description: product template identifier + required: true + type: integer + format: int64 + minimum: 1 + milestoneTemplateIdParam: + name: milestoneTemplateId + in: path + description: milestone template identifier + required: true + type: integer + format: int64 + minimum: 1 offsetParam: name: offset description: "number of items to skip. Defaults to 0" @@ -1405,72 +1872,362 @@ definitions: description: member role on specified project enum: ["customer", "manager", "copilot"] - NewProjectMemberBodyParam: + NewProjectMemberBodyParam: + type: object + properties: + param: + $ref: "#/definitions/NewProjectMember" + + UpdateProjectMember: + title: Project Member object + type: object + required: + - role + properties: + isPrimary: + type: boolean + description: primary option + role: + type: string + description: member role on specified project + enum: ["customer", "manager", "copilot"] + + UpdateProjectMemberBodyParam: + type: object + properties: + param: + $ref: "#/definitions/UpdateProjectMember" + + NewProjectAttachment: + title: Project attachment request + type: object + required: + - filePath + - s3Bucket + - title + - contentType + properties: + filePath: + type: string + description: path where file is stored + s3Bucket: + type: string + description: The s3 bucket of attachment + contentType: + type: string + description: Uploaded file content type + title: + type: string + description: Name of the attachment + description: + type: string + description: Optional description for the attached file. + category: + type: string + description: Category of attachment + size: + type: number + format: float + description: The size of attachment + + NewProjectAttachmentBodyParam: + type: object + properties: + param: + $ref: "#/definitions/NewProjectAttachment" + + NewProjectAttachmentResponse: + title: Project attachment object response + type: object + properties: + id: + type: string + description: unique id identifying the request + version: + type: string + result: + type: object + properties: + success: + type: boolean + status: + type: string + description: http status code + content: + $ref: "#/definitions/ProjectAttachment" + + ProjectAttachment: + title: Project attachment + type: object + properties: + id: + type: number + description: unique id for the attachment + size: + type: number + format: float + description: The size of attachment + category: + type: string + description: The category of attachment + contentType: + type: string + description: Uploaded file content type + title: + type: string + description: Name of the attachment + description: + type: string + description: Optional description for the attached file. + downloadUrl: + type: string + description: download link for the attachment. + createdAt: + type: string + description: Datetime (GMT) when task was created + readOnly: true + createdBy: + type: integer + format: int64 + description: READ-ONLY. User who created this task + readOnly: true + updatedAt: + type: string + description: READ-ONLY. Datetime (GMT) when task was updated + readOnly: true + updatedBy: + type: integer + format: int64 + description: READ-ONLY. User that last updated this task + readOnly: true + + ProjectMember: + title: Project Member object + type: object + properties: + id: + type: number + description: unique identifier for record + userId: + type: number + format: int64 + description: user identifier + isPrimary: + type: boolean + description: Flag to indicate this member is primary for specified role + projectId: + type: number + format: int64 + description: project identifier + role: + type: string + description: member role on specified project + enum: ["customer", "manager", "copilot"] + createdAt: + type: string + description: Datetime (GMT) when task was created + readOnly: true + createdBy: + type: integer + format: int64 + description: READ-ONLY. User who created this task + readOnly: true + updatedAt: + type: string + description: READ-ONLY. Datetime (GMT) when task was updated + readOnly: true + updatedBy: + type: integer + format: int64 + description: READ-ONLY. User that last updated this task + readOnly: true + + + + NewProjectMemberResponse: + title: Project member object response + type: object + properties: + id: + type: string + description: unique id identifying the request + version: + type: string + result: + type: object + properties: + success: + type: boolean + status: + type: string + description: http status code + content: + $ref: "#/definitions/ProjectMember" + + UpdateProjectMemberResponse: + title: Project member object response + type: object + properties: + id: + type: string + description: unique id identifying the request + version: + type: string + result: + type: object + properties: + success: + type: boolean + status: + type: string + description: http status code + content: + $ref: "#/definitions/ProjectMember" + + + ProjectResponse: + title: Single project object + type: object + properties: + id: + type: string + description: unique id identifying the request + version: + type: string + result: + type: object + properties: + success: + type: boolean + status: + type: string + description: http status code + content: + $ref: "#/definitions/Project" + + UpdateProjectResponse: + title: response with original and updated project object type: object properties: - param: - $ref: "#/definitions/NewProjectMember" - - UpdateProjectMember: - title: Project Member object - type: object - required: - - role - properties: - isPrimary: - type: boolean - description: primary option - role: - type: string - description: member role on specified project - enum: ["customer", "manager", "copilot"] + id: + type: string + description: unique id identifying the request + version: + type: string + result: + type: object + properties: + success: + type: boolean + status: + type: string + description: http status code + content: + type: object + properties: + original: + $ref: "#/definitions/Project" + updated: + $ref: "#/definitions/Project" - UpdateProjectMemberBodyParam: + ProjectListResponse: + title: List response type: object properties: - param: - $ref: "#/definitions/UpdateProjectMember" + id: + type: string + readOnly: true + description: unique id identifying the request + version: + type: string + result: + type: object + properties: + success: + type: boolean + status: + type: string + description: http status code + metadata: + $ref: "#/definitions/ResponseMetadata" + content: + type: array + items: + $ref: "#/definitions/Project" - NewProjectAttachment: - title: Project attachment request + ProjectTemplateRequest: + title: Project template request object type: object required: - - filePath - - s3Bucket - - title - - contentType + - name + - key + - category + - scope + - phases properties: - filePath: - type: string - description: path where file is stored - s3Bucket: - type: string - description: The s3 bucket of attachment - contentType: - type: string - description: Uploaded file content type - title: + name: type: string - description: Name of the attachment - description: + description: the project template name + key: type: string - description: Optional description for the attached file. + description: the project template key category: type: string - description: Category of attachment - size: - type: number - format: float - description: The size of attachment + description: the project template category + scope: + type: object + description: the project template scope + phases: + type: object + description: the project template phases - NewProjectAttachmentBodyParam: + ProjectTemplateBodyParam: + title: Project template body param type: object + required: + - param properties: param: - $ref: "#/definitions/NewProjectAttachment" + $ref: "#/definitions/ProjectTemplateRequest" - NewProjectAttachmentResponse: - title: Project attachment object response + ProjectTemplate: + title: Project template object + allOf: + - type: object + required: + - id + - createdAt + - createdBy + - updatedAt + - updatedBy + properties: + id: + type: number + format: int64 + description: the id + createdAt: + type: string + description: Datetime (GMT) when object was created + readOnly: true + createdBy: + type: integer + format: int64 + description: READ-ONLY. User who created this object + readOnly: true + updatedAt: + type: string + description: READ-ONLY. Datetime (GMT) when object was updated + readOnly: true + updatedBy: + type: integer + format: int64 + description: READ-ONLY. User that last updated this object + readOnly: true + - $ref: "#/definitions/ProjectTemplateRequest" + + + ProjectTemplateResponse: + title: Single project template response object type: object properties: id: @@ -1486,99 +2243,114 @@ definitions: status: type: string description: http status code + metadata: + $ref: "#/definitions/ResponseMetadata" content: - $ref: "#/definitions/ProjectAttachment" + $ref: "#/definitions/ProjectTemplate" - ProjectAttachment: - title: Project attachment + ProjectTemplateListResponse: + title: Project template list response object type: object properties: id: - type: number - description: unique id for the attachment - size: - type: number - format: float - description: The size of attachment - category: - type: string - description: The category of attachment - contentType: - type: string - description: Uploaded file content type - title: - type: string - description: Name of the attachment - description: - type: string - description: Optional description for the attached file. - downloadUrl: type: string - description: download link for the attachment. - createdAt: - type: string - description: Datetime (GMT) when task was created - readOnly: true - createdBy: - type: integer - format: int64 - description: READ-ONLY. User who created this task readOnly: true - updatedAt: + description: unique id identifying the request + version: type: string - description: READ-ONLY. Datetime (GMT) when task was updated - readOnly: true - updatedBy: - type: integer - format: int64 - description: READ-ONLY. User that last updated this task - readOnly: true - - ProjectMember: - title: Project Member object + result: + type: object + properties: + success: + type: boolean + status: + type: string + description: http status code + metadata: + $ref: "#/definitions/ResponseMetadata" + content: + type: array + items: + $ref: "#/definitions/ProjectTemplate" + + ProductTemplateRequest: + title: Product template request object type: object + required: + - name + - key + - category + - scope + - phases properties: - id: - type: number - description: unique identifier for record - userId: - type: number - format: int64 - description: user identifier - isPrimary: - type: boolean - description: Flag to indicate this member is primary for specified role - projectId: - type: number - format: int64 - description: project identifier - role: + name: type: string - description: member role on specified project - enum: ["customer", "manager", "copilot"] - createdAt: + description: the product template name + productKey: + type: string + description: the product template key + icon: + type: string + description: the product template icon + brief: type: string - description: Datetime (GMT) when task was created - readOnly: true - createdBy: - type: integer - format: int64 - description: READ-ONLY. User who created this task - readOnly: true - updatedAt: + description: the product template brief + details: type: string - description: READ-ONLY. Datetime (GMT) when task was updated - readOnly: true - updatedBy: - type: integer - format: int64 - description: READ-ONLY. User that last updated this task - readOnly: true + description: the product template details + aliases: + type: object + description: the product template aliases + template: + type: object + description: the product template template + ProductTemplateBodyParam: + title: Product template body param + type: object + required: + - param + properties: + param: + $ref: "#/definitions/ProductTemplateRequest" + ProductTemplate: + title: Product template object + allOf: + - type: object + required: + - id + - createdAt + - createdBy + - updatedAt + - updatedBy + properties: + id: + type: number + format: int64 + description: the id + createdAt: + type: string + description: Datetime (GMT) when object was created + readOnly: true + createdBy: + type: integer + format: int64 + description: READ-ONLY. User who created this object + readOnly: true + updatedAt: + type: string + description: READ-ONLY. Datetime (GMT) when object was updated + readOnly: true + updatedBy: + type: integer + format: int64 + description: READ-ONLY. User that last updated this object + readOnly: true + - $ref: "#/definitions/ProductTemplateRequest" - NewProjectMemberResponse: - title: Project member object response + ProjectUpgradeResponse: + title: Project upgrade response object type: object properties: id: @@ -1594,11 +2366,11 @@ definitions: status: type: string description: http status code - content: - $ref: "#/definitions/ProjectMember" + metadata: + $ref: "#/definitions/ResponseMetadata" - UpdateProjectMemberResponse: - title: Project member object response + ProductTemplateResponse: + title: Single product template response object type: object properties: id: @@ -1614,16 +2386,18 @@ definitions: status: type: string description: http status code + metadata: + $ref: "#/definitions/ResponseMetadata" content: - $ref: "#/definitions/ProjectMember" - + $ref: "#/definitions/ProductTemplate" - ProjectResponse: - title: Single project object + ProductTemplateListResponse: + title: Product template list response object type: object properties: id: type: string + readOnly: true description: unique id identifying the request version: type: string @@ -1635,11 +2409,93 @@ definitions: status: type: string description: http status code + metadata: + $ref: "#/definitions/ResponseMetadata" content: - $ref: "#/definitions/Project" + type: array + items: + $ref: "#/definitions/ProductTemplate" - UpdateProjectResponse: - title: response with original and updated project object + ProjectPhaseRequest: + title: Project phase request object + type: object + required: + - name + - status + - startDate + - endDate + properties: + name: + type: string + description: the project phase name + status: + type: string + description: the project phase status + startDate: + type: string + format: date + description: the project phase start date + endDate: + type: string + format: date + description: the project phase end date + budget: + type: number + description: the project phase budget + progress: + type: number + description: the project phase progress + details: + type: object + description: the project phase details + + ProjectPhaseBodyParam: + title: Project phase body param + type: object + required: + - param + properties: + param: + $ref: "#/definitions/ProjectPhaseRequest" + + ProjectPhase: + title: Project phase object + allOf: + - type: object + required: + - id + - createdAt + - createdBy + - updatedAt + - updatedBy + properties: + id: + type: number + format: int64 + description: the id + createdAt: + type: string + description: Datetime (GMT) when object was created + readOnly: true + createdBy: + type: integer + format: int64 + description: READ-ONLY. User who created this object + readOnly: true + updatedAt: + type: string + description: READ-ONLY. Datetime (GMT) when object was updated + readOnly: true + updatedBy: + type: integer + format: int64 + description: READ-ONLY. User that last updated this object + readOnly: true + - $ref: "#/definitions/ProjectPhaseRequest" + + + ProjectPhaseResponse: + title: Single project phase response object type: object properties: id: @@ -1655,16 +2511,13 @@ definitions: status: type: string description: http status code + metadata: + $ref: "#/definitions/ResponseMetadata" content: - type: object - properties: - original: - $ref: "#/definitions/Project" - updated: - $ref: "#/definitions/Project" + $ref: "#/definitions/ProjectPhase" - ProjectListResponse: - title: List response + ProjectPhaseListResponse: + title: Project phase list response object type: object properties: id: @@ -1686,45 +2539,49 @@ definitions: content: type: array items: - $ref: "#/definitions/Project" + $ref: "#/definitions/ProjectPhase" - ProjectTemplateRequest: - title: Project template request object + + PhaseProductRequest: + title: Phase product request object type: object - required: - - name - - key - - category - - scope - - phases properties: name: type: string - description: the project template name - key: - type: string - description: the project template key - category: + description: the phase product name + directProjectId: + type: number + description: the phase product direct project id + billingAccountId: + type: number + description: the phase product billing account Id + templateId: + type: number + description: the phase product template id + type: type: string - description: the project template category - scope: - type: object - description: the project template scope - phases: + description: the phase product type + estimatedPrice: + type: number + description: the phase product estimated price + actualPrice: + type: number + description: the phase product actual price + details: type: object - description: the project template phases + description: the phase product details - ProjectTemplateBodyParam: - title: Project template body param + PhaseProductBodyParam: + title: Phase product body param type: object required: - param properties: param: - $ref: "#/definitions/ProjectTemplateRequest" + $ref: "#/definitions/PhaseProductRequest" - ProjectTemplate: - title: Project template object + PhaseProduct: + title: Phase product object allOf: - type: object required: @@ -1756,11 +2613,11 @@ definitions: format: int64 description: READ-ONLY. User that last updated this object readOnly: true - - $ref: "#/definitions/ProjectTemplateRequest" + - $ref: "#/definitions/PhaseProductRequest" - ProjectTemplateResponse: - title: Single project template response object + PhaseProductResponse: + title: Single phase product response object type: object properties: id: @@ -1779,10 +2636,10 @@ definitions: metadata: $ref: "#/definitions/ResponseMetadata" content: - $ref: "#/definitions/ProjectTemplate" + $ref: "#/definitions/PhaseProduct" - ProjectTemplateListResponse: - title: Project template list response object + PhaseProductListResponse: + title: Phase product list response object type: object properties: id: @@ -1804,64 +2661,64 @@ definitions: content: type: array items: - $ref: "#/definitions/ProjectTemplate" + $ref: "#/definitions/PhaseProduct" - ProductTemplateRequest: - title: Product template request object + + + ProjectTypeRequest: + title: Project type request object type: object required: - - name - - key - - category - - scope - - phases + - displayName properties: - name: - type: string - description: the product template name - productKey: - type: string - description: the product template key - icon: - type: string - description: the product template icon - brief: - type: string - description: the product template brief - details: + displayName: type: string - description: the product template details - aliases: - type: object - description: the product template aliases - template: - type: object - description: the product template template + description: the project type display name - ProductTemplateBodyParam: - title: Product template body param + ProjectTypeBodyParam: + title: Project type body param type: object required: - param properties: param: - $ref: "#/definitions/ProductTemplateRequest" + $ref: "#/definitions/ProjectTypeRequest" - ProductTemplate: - title: Product template object + ProjectTypeCreateRequest: + title: Project type creation request object + type: object + allOf: + - type: object + required: + - key + properties: + key: + type: string + description: the project type key + - $ref: "#/definitions/ProjectTypeRequest" + + ProjectTypeCreateBodyParam: + title: Project type creation body param + type: object + required: + - param + properties: + param: + $ref: "#/definitions/ProjectTypeCreateRequest" + + ProjectType: + title: Project type object allOf: - type: object required: - - id - createdAt - createdBy - updatedAt - updatedBy properties: - id: - type: number - format: int64 - description: the id + key: + type: string + description: the project type key createdAt: type: string description: Datetime (GMT) when object was created @@ -1880,30 +2737,11 @@ definitions: format: int64 description: READ-ONLY. User that last updated this object readOnly: true - - $ref: "#/definitions/ProductTemplateRequest" + - $ref: "#/definitions/ProjectTypeCreateRequest" - ProjectUpgradeResponse: - title: Project upgrade response object - type: object - properties: - id: - type: string - description: unique id identifying the request - version: - type: string - result: - type: object - properties: - success: - type: boolean - status: - type: string - description: http status code - metadata: - $ref: "#/definitions/ResponseMetadata" - ProductTemplateResponse: - title: Single product template response object + ProjectTypeResponse: + title: Single project type response object type: object properties: id: @@ -1922,10 +2760,10 @@ definitions: metadata: $ref: "#/definitions/ResponseMetadata" content: - $ref: "#/definitions/ProductTemplate" + $ref: "#/definitions/ProjectType" - ProductTemplateListResponse: - title: Product template list response object + ProjectTypeListResponse: + title: Project type list response object type: object properties: id: @@ -1947,52 +2785,54 @@ definitions: content: type: array items: - $ref: "#/definitions/ProductTemplate" + $ref: "#/definitions/ProjectType" - ProjectPhaseRequest: - title: Project phase request object + + TimelineRequest: + title: Timeline request object type: object required: - name - - status - startDate - - endDate + - reference + - referenceId properties: name: type: string - description: the project phase name - status: + description: the timeline name + description: type: string - description: the project phase status + description: the timeline description startDate: type: string format: date - description: the project phase start date + description: the timeline start date endDate: type: string format: date - description: the project phase end date - budget: + description: the timeline end date + reference: + type: string + enum: + - project + - phase + description: the timeline reference + referenceId: type: number - description: the project phase budget - progress: - type: number - description: the project phase progress - details: - type: object - description: the project phase details + format: long + description: the timeline reference id (project id or phase id, corresponding to the `reference`) - ProjectPhaseBodyParam: - title: Project phase body param + TimelineBodyParam: + title: Timeline body param type: object required: - param properties: param: - $ref: "#/definitions/ProjectPhaseRequest" + $ref: "#/definitions/TimelineRequest" - ProjectPhase: - title: Project phase object + Timeline: + title: Timeline object allOf: - type: object required: @@ -2024,11 +2864,10 @@ definitions: format: int64 description: READ-ONLY. User that last updated this object readOnly: true - - $ref: "#/definitions/ProjectPhaseRequest" + - $ref: "#/definitions/TimelineRequest" - - ProjectPhaseResponse: - title: Single project phase response object + TimelineResponse: + title: Single timeline response object type: object properties: id: @@ -2047,10 +2886,10 @@ definitions: metadata: $ref: "#/definitions/ResponseMetadata" content: - $ref: "#/definitions/ProjectPhase" + $ref: "#/definitions/Timeline" - ProjectPhaseListResponse: - title: Project phase list response object + TimelineListResponse: + title: Timeline list response object type: object properties: id: @@ -2072,49 +2911,82 @@ definitions: content: type: array items: - $ref: "#/definitions/ProjectPhase" - + $ref: "#/definitions/Timeline" - PhaseProductRequest: - title: Phase product request object + MilestoneRequest: + title: Milestone request object type: object + required: + - name + - duration + - startDate + - status + - type + - order + - plannedText + - activeText + - completedText + - blockedText properties: name: type: string - description: the phase product name - directProjectId: - type: number - description: the phase product direct project id - billingAccountId: - type: number - description: the phase product billing account Id - templateId: + description: the milestone name + description: + type: string + description: the milestone description + duration: type: number - description: the phase product template id + format: integer + description: the milestone duration + startDate: + type: string + format: date + description: the milestone start date + endDate: + type: string + format: date + description: the milestone end date + completionDate: + type: string + format: date + description: the milestone completion date + status: + type: string + description: the milestone status type: type: string - description: the phase product type - estimatedPrice: - type: number - description: the phase product estimated price - actualPrice: - type: number - description: the phase product actual price + description: the milestone type details: type: object - description: the phase product details + description: the milestone details + order: + type: number + format: integer + description: the milestone order + plannedText: + type: string + description: the milestone planned text + activeText: + type: string + description: the milestone active text + completedText: + type: string + description: the milestone completed text + blockedText: + type: string + description: the milestone blocked text - PhaseProductBodyParam: - title: Phase product body param + MilestoneBodyParam: + title: Milestone body param type: object required: - param properties: param: - $ref: "#/definitions/PhaseProductRequest" + $ref: "#/definitions/MilestoneRequest" - PhaseProduct: - title: Phase product object + Milestone: + title: Milestone object allOf: - type: object required: @@ -2146,11 +3018,10 @@ definitions: format: int64 description: READ-ONLY. User that last updated this object readOnly: true - - $ref: "#/definitions/PhaseProductRequest" + - $ref: "#/definitions/MilestoneRequest" - - PhaseProductResponse: - title: Single phase product response object + MilestoneResponse: + title: Single milestone response object type: object properties: id: @@ -2169,10 +3040,10 @@ definitions: metadata: $ref: "#/definitions/ResponseMetadata" content: - $ref: "#/definitions/PhaseProduct" + $ref: "#/definitions/Milestone" - PhaseProductListResponse: - title: Phase product list response object + MilestoneListResponse: + title: Milestone list response object type: object properties: id: @@ -2194,64 +3065,60 @@ definitions: content: type: array items: - $ref: "#/definitions/PhaseProduct" - - - - ProjectTypeRequest: - title: Project type request object - type: object - required: - - displayName - properties: - displayName: - type: string - description: the project type display name + $ref: "#/definitions/Milestone" - ProjectTypeBodyParam: - title: Project type body param + + MilestoneTemplateRequest: + title: Milestone template request object type: object required: - - param + - name + - duration + - type + - order properties: - param: - $ref: "#/definitions/ProjectTypeRequest" - - ProjectTypeCreateRequest: - title: Project type creation request object - type: object - allOf: - - type: object - required: - - key - properties: - key: - type: string - description: the project type key - - $ref: "#/definitions/ProjectTypeRequest" + name: + type: string + description: the milestone template name + description: + type: string + description: the milestone template description + duration: + type: number + format: integer + description: the milestone template duration + type: + type: string + description: the milestone template type + order: + type: number + format: integer + description: the milestone template order - ProjectTypeCreateBodyParam: - title: Project type creation body param + MilestoneTemplateBodyParam: + title: Milestone template body param type: object required: - param properties: param: - $ref: "#/definitions/ProjectTypeCreateRequest" + $ref: "#/definitions/MilestoneTemplateRequest" - ProjectType: - title: Project type object + MilestoneTemplate: + title: Milestone template object allOf: - type: object required: + - id - createdAt - createdBy - updatedAt - updatedBy properties: - key: - type: string - description: the project type key + id: + type: number + format: int64 + description: the id createdAt: type: string description: Datetime (GMT) when object was created @@ -2270,11 +3137,10 @@ definitions: format: int64 description: READ-ONLY. User that last updated this object readOnly: true - - $ref: "#/definitions/ProjectTypeCreateRequest" - + - $ref: "#/definitions/MilestoneTemplateRequest" - ProjectTypeResponse: - title: Single project type response object + MilestoneTemplateResponse: + title: Single milestone template response object type: object properties: id: @@ -2293,10 +3159,10 @@ definitions: metadata: $ref: "#/definitions/ResponseMetadata" content: - $ref: "#/definitions/ProjectType" + $ref: "#/definitions/MilestoneTemplate" - ProjectTypeListResponse: - title: Project type list response object + MilestoneTemplateListResponse: + title: Milestone template list response object type: object properties: id: @@ -2318,4 +3184,4 @@ definitions: content: type: array items: - $ref: "#/definitions/ProjectType" + $ref: "#/definitions/MilestoneTemplate" From 3355613e66c4227dce279b958cb6aee98c47a0a2 Mon Sep 17 00:00:00 2001 From: Vikas Agarwal Date: Wed, 11 Jul 2018 15:43:58 +0530 Subject: [PATCH 02/73] lint fix permission fix with unit tests fix --- src/permissions/index.js | 6 ++--- src/routes/milestoneTemplates/create.spec.js | 27 ++++++++------------ src/routes/milestoneTemplates/delete.spec.js | 19 +++++++------- src/routes/milestoneTemplates/update.spec.js | 21 ++++++++------- 4 files changed, 32 insertions(+), 41 deletions(-) diff --git a/src/permissions/index.js b/src/permissions/index.js index e47435cf..8376d03d 100644 --- a/src/permissions/index.js +++ b/src/permissions/index.js @@ -43,9 +43,9 @@ module.exports = () => { Authorizer.setPolicy('project.updatePhaseProduct', copilotAndAbove); Authorizer.setPolicy('project.deletePhaseProduct', copilotAndAbove); - Authorizer.setPolicy('milestoneTemplate.create', connectManagerOrAdmin); - Authorizer.setPolicy('milestoneTemplate.edit', connectManagerOrAdmin); - Authorizer.setPolicy('milestoneTemplate.delete', connectManagerOrAdmin); + Authorizer.setPolicy('milestoneTemplate.create', projectAdmin); + Authorizer.setPolicy('milestoneTemplate.edit', projectAdmin); + Authorizer.setPolicy('milestoneTemplate.delete', projectAdmin); Authorizer.setPolicy('milestoneTemplate.view', true); Authorizer.setPolicy('projectType.create', projectAdmin); diff --git a/src/routes/milestoneTemplates/create.spec.js b/src/routes/milestoneTemplates/create.spec.js index 6fe6c128..f88a85a7 100644 --- a/src/routes/milestoneTemplates/create.spec.js +++ b/src/routes/milestoneTemplates/create.spec.js @@ -122,6 +122,16 @@ describe('CREATE milestone template', () => { .expect(403, done); }); + it('should return 403 for manager', (done) => { + request(server) + .post('/v4/productTemplates/1/milestones') + .set({ + Authorization: `Bearer ${testUtil.jwts.manager}`, + }) + .send(body) + .expect(403, done); + }); + it('should return 404 for non-existed product template', (done) => { request(server) .post('/v4/productTemplates/1000/milestones') @@ -245,23 +255,6 @@ describe('CREATE milestone template', () => { }); }); - it('should return 201 for connect manager', (done) => { - request(server) - .post('/v4/productTemplates/1/milestones') - .set({ - Authorization: `Bearer ${testUtil.jwts.manager}`, - }) - .send(body) - .expect('Content-Type', /json/) - .expect(201) - .end((err, res) => { - const resJson = res.body.result.content; - resJson.createdBy.should.be.eql(40051334); // manager - resJson.updatedBy.should.be.eql(40051334); // manager - done(); - }); - }); - it('should return 201 for connect admin', (done) => { request(server) .post('/v4/productTemplates/1/milestones') diff --git a/src/routes/milestoneTemplates/delete.spec.js b/src/routes/milestoneTemplates/delete.spec.js index 02fd111c..8290c243 100644 --- a/src/routes/milestoneTemplates/delete.spec.js +++ b/src/routes/milestoneTemplates/delete.spec.js @@ -109,6 +109,15 @@ describe('DELETE milestone template', () => { .expect(403, done); }); + it('should return 403 for manager', (done) => { + request(server) + .delete('/v4/productTemplates/1/milestones/1') + .set({ + Authorization: `Bearer ${testUtil.jwts.manager}`, + }) + .expect(403, done); + }); + it('should return 404 for non-existed product template', (done) => { request(server) .delete('/v4/productTemplates/1234/milestones/1') @@ -173,15 +182,5 @@ describe('DELETE milestone template', () => { .expect(204) .end(done); }); - - it('should return 204, for connect manager, if template was successfully removed', (done) => { - request(server) - .delete('/v4/productTemplates/1/milestones/1') - .set({ - Authorization: `Bearer ${testUtil.jwts.manager}`, - }) - .expect(204) - .end(done); - }); }); }); diff --git a/src/routes/milestoneTemplates/update.spec.js b/src/routes/milestoneTemplates/update.spec.js index 297f6ea9..8eeb6352 100644 --- a/src/routes/milestoneTemplates/update.spec.js +++ b/src/routes/milestoneTemplates/update.spec.js @@ -145,6 +145,16 @@ describe('UPDATE milestone template', () => { .expect(403, done); }); + it('should return 403 for manager', (done) => { + request(server) + .patch('/v4/productTemplates/1/milestones/1') + .send(body) + .set({ + Authorization: `Bearer ${testUtil.jwts.manager}`, + }) + .expect(403, done); + }); + it('should return 422 for missing name', (done) => { const invalidBody = { param: { @@ -413,16 +423,5 @@ describe('UPDATE milestone template', () => { .expect(200) .end(done); }); - - it('should return 200 for connect manager', (done) => { - request(server) - .patch('/v4/productTemplates/1/milestones/1') - .set({ - Authorization: `Bearer ${testUtil.jwts.manager}`, - }) - .send(body) - .expect(200) - .end(done); - }); }); }); From 1c037180f09a32658e085367153917bcc5a59fb0 Mon Sep 17 00:00:00 2001 From: Vikas Agarwal Date: Wed, 11 Jul 2018 15:44:13 +0530 Subject: [PATCH 03/73] made the feature branch deployable --- .circleci/config.yml | 2 +- postman_environment.json | 14 +++++++------- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/.circleci/config.yml b/.circleci/config.yml index ec0cf2ad..fec74ad4 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -76,7 +76,7 @@ workflows: - test filters: branches: - only: ['dev', 'feature/dev-challenges-timeline'] + only: ['dev', 'feature/timeline-milestone'] - deployProd: requires: - test diff --git a/postman_environment.json b/postman_environment.json index 84968c61..d1ccbbd4 100644 --- a/postman_environment.json +++ b/postman_environment.json @@ -11,49 +11,49 @@ }, { "key": "jwt-token", - "value": "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJyb2xlcyI6WyJUb3Bjb2RlciBVc2VyIiwiYWRtaW5pc3RyYXRvciJdLCJpc3MiOiJodHRwczovL2FwaS50b3Bjb2Rlci1kZXYuY29tIiwiaGFuZGxlIjoidGVzdDEiLCJleHAiOjI1NjMwNzY2ODksInVzZXJJZCI6IjQwMDUxMzMzIiwiaWF0IjoxNDYzMDc2MDg5LCJlbWFpbCI6InRlc3RAdG9wY29kZXIuY29tIiwianRpIjoiYjMzYjc3Y2QtYjUyZS00MGZlLTgzN2UtYmViOGUwYWU2YTRhIn0.wKWUe0-SaiFVN-VR_-GwgFlvWaDkSbc8H55ktb9LAVw", + "value": "", "description": "", "type": "text", "enabled": true }, { "key": "jwt-token-admin-40051333", - "value": "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJyb2xlcyI6WyJUb3Bjb2RlciBVc2VyIiwiYWRtaW5pc3RyYXRvciJdLCJpc3MiOiJodHRwczovL2FwaS50b3Bjb2Rlci1kZXYuY29tIiwiaGFuZGxlIjoidGVzdDEiLCJleHAiOjI1NjMwNzY2ODksInVzZXJJZCI6IjQwMDUxMzMzIiwiaWF0IjoxNDYzMDc2MDg5LCJlbWFpbCI6InRlc3RAdG9wY29kZXIuY29tIiwianRpIjoiYjMzYjc3Y2QtYjUyZS00MGZlLTgzN2UtYmViOGUwYWU2YTRhIn0.wKWUe0-SaiFVN-VR_-GwgFlvWaDkSbc8H55ktb9LAVw", + "value": "", "description": "", "type": "text", "enabled": true }, { "key": "jwt-token-member-40051331", - "value": "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJyb2xlcyI6WyJUb3Bjb2RlciBVc2VyIl0sImlzcyI6Imh0dHBzOi8vYXBpLnRvcGNvZGVyLWRldi5jb20iLCJoYW5kbGUiOiJ0ZXN0MSIsImV4cCI6MjU2MzA3NjY4OSwidXNlcklkIjoiNDAwNTEzMzEiLCJpYXQiOjE0NjMwNzYwODksImVtYWlsIjoidGVzdEB0b3Bjb2Rlci5jb20iLCJqdGkiOiJiMzNiNzdjZC1iNTJlLTQwZmUtODM3ZS1iZWI4ZTBhZTZhNGEifQ.pDtRzcGQjgCBD6aLsW-1OFhzmrv5mXhb8YLDWbGAnKo", + "value": "", "description": "", "type": "text", "enabled": true }, { "key": "jwt-token-copilot-40051332", - "value": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlcyI6WyJUb3Bjb2RlciBVc2VyIiwiQ29ubmVjdCBDb3BpbG90Il0sImlzcyI6Imh0dHBzOi8vYXBpLnRvcGNvZGVyLWRldi5jb20iLCJoYW5kbGUiOiJ0ZXN0MSIsImV4cCI6MjU2MzA3NjY4OSwidXNlcklkIjo0MDA1MTMzMiwiZW1haWwiOiJ0ZXN0QHRvcGNvZGVyLmNvbSIsImlhdCI6MTQ3MDYyMDA0NH0.DnX17gBaVF2JTuRai-C2BDSdEjij9da_s4eYcMIjP0c", + "value": "", "description": "", "type": "text", "enabled": true }, { "key": "jwt-token-manager-40051334", - "value": "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJyb2xlcyI6WyJUb3Bjb2RlciBVc2VyIiwiQ29ubmVjdCBNYW5hZ2VyIl0sImlzcyI6Imh0dHBzOi8vYXBpLnRvcGNvZGVyLWRldi5jb20iLCJoYW5kbGUiOiJ0ZXN0MSIsImV4cCI6MjU2MzA3NjY4OSwidXNlcklkIjoiNDAwNTEzMzQiLCJpYXQiOjE0NjMwNzYwODksImVtYWlsIjoidGVzdEB0b3Bjb2Rlci5jb20iLCJqdGkiOiJiMzNiNzdjZC1iNTJlLTQwZmUtODM3ZS1iZWI4ZTBhZTZhNGEifQ.J5VtOEQVph5jfe2Ji-NH7txEDcx_5gthhFeD-MzX9ck", + "value": "", "description": "", "type": "text", "enabled": true }, { "key": "jwt-token-member2-40051335", - "value": "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJyb2xlcyI6WyJUb3Bjb2RlciBVc2VyIl0sImlzcyI6Imh0dHBzOi8vYXBpLnRvcGNvZGVyLWRldi5jb20iLCJoYW5kbGUiOiJtZW1iZXIyIiwiZXhwIjoyNTYzMDc2Njg5LCJ1c2VySWQiOiI0MDA1MTMzNSIsImlhdCI6MTQ2MzA3NjA4OSwiZW1haWwiOiJ0ZXN0QHRvcGNvZGVyLmNvbSIsImp0aSI6ImIzM2I3N2NkLWI1MmUtNDBmZS04MzdlLWJlYjhlMGFlNmE0YSJ9.Mh4bw3wm-cn5Kcf96gLFVlD0kySOqqk4xN3qnreAKL4", + "value": "", "description": "", "type": "text", "enabled": true }, { "key": "jwt-token-connectAdmin-40051336", - "value": "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJyb2xlcyI6WyJDb25uZWN0IEFkbWluIl0sImlzcyI6Imh0dHBzOi8vYXBpLnRvcGNvZGVyLWRldi5jb20iLCJoYW5kbGUiOiJjb25uZWN0X2FkbWluMSIsImV4cCI6MjU2MzA3NjY4OSwidXNlcklkIjoiNDAwNTEzMzYiLCJpYXQiOjE0NjMwNzYwODksImVtYWlsIjoiY29ubmVjdF9hZG1pbjFAdG9wY29kZXIuY29tIiwianRpIjoiYjMzYjc3Y2QtYjUyZS00MGZlLTgzN2UtYmViOGUwYWU2YTRhIn0.nSGfXMl02NZ90ZKLiEKPg75iAjU92mfteaY6xgqkM30", + "value": "", "description": "", "type": "text", "enabled": true From 6808e01b5b5a0f5c2d9ca6a6deda2fd4c1bb25e1 Mon Sep 17 00:00:00 2001 From: Vikas Agarwal Date: Mon, 16 Jul 2018 15:45:17 +0530 Subject: [PATCH 04/73] Added text fields for various states of the milestone template object Fixed PATCH endpoint to allow specifying only fields to be updated --- ..._project_add_templateId_and_new_tables.sql | 4 + src/models/productMilestoneTemplate.js | 4 + src/routes/milestoneTemplates/create.js | 4 + src/routes/milestoneTemplates/create.spec.js | 39 ++-- src/routes/milestoneTemplates/delete.spec.js | 8 + src/routes/milestoneTemplates/get.spec.js | 12 ++ src/routes/milestoneTemplates/list.spec.js | 16 ++ src/routes/milestoneTemplates/update.js | 12 +- src/routes/milestoneTemplates/update.spec.js | 184 ++++++++++++------ 9 files changed, 204 insertions(+), 79 deletions(-) diff --git a/migrations/20180608_project_add_templateId_and_new_tables.sql b/migrations/20180608_project_add_templateId_and_new_tables.sql index b0c463f2..7b4fb7fe 100644 --- a/migrations/20180608_project_add_templateId_and_new_tables.sql +++ b/migrations/20180608_project_add_templateId_and_new_tables.sql @@ -108,6 +108,10 @@ CREATE TABLE product_milestone_templates ( duration integer NOT NULL, type character varying(45) NOT NULL, "order" integer NOT NULL, + "plannedText" character varying(255) NOT NULL, + "activeText" character varying(255) NOT NULL, + "blockedText" character varying(255) NOT NULL, + "completedText" character varying(255) NOT NULL, "deletedAt" timestamp with time zone, "createdAt" timestamp with time zone, "updatedAt" timestamp with time zone, diff --git a/src/models/productMilestoneTemplate.js b/src/models/productMilestoneTemplate.js index acd40c11..2ec0b692 100644 --- a/src/models/productMilestoneTemplate.js +++ b/src/models/productMilestoneTemplate.js @@ -11,6 +11,10 @@ module.exports = (sequelize, DataTypes) => { duration: { type: DataTypes.INTEGER, allowNull: false }, type: { type: DataTypes.STRING(45), allowNull: false }, order: { type: DataTypes.INTEGER, allowNull: false }, + plannedText: { type: DataTypes.STRING(512), allowNull: false }, + activeText: { type: DataTypes.STRING(512), allowNull: false }, + completedText: { type: DataTypes.STRING(512), allowNull: false }, + blockedText: { type: DataTypes.STRING(512), allowNull: false }, deletedAt: DataTypes.DATE, createdAt: { type: DataTypes.DATE, defaultValue: DataTypes.NOW }, updatedAt: { type: DataTypes.DATE, defaultValue: DataTypes.NOW }, diff --git a/src/routes/milestoneTemplates/create.js b/src/routes/milestoneTemplates/create.js index 55ea5c12..a331063b 100644 --- a/src/routes/milestoneTemplates/create.js +++ b/src/routes/milestoneTemplates/create.js @@ -23,6 +23,10 @@ const schema = { duration: Joi.number().integer().required(), type: Joi.string().max(45).required(), order: Joi.number().integer().required(), + plannedText: Joi.string().max(512).required(), + activeText: Joi.string().max(512).required(), + completedText: Joi.string().max(512).required(), + blockedText: Joi.string().max(512).required(), productTemplateId: Joi.any().strip(), createdAt: Joi.any().strip(), updatedAt: Joi.any().strip(), diff --git a/src/routes/milestoneTemplates/create.spec.js b/src/routes/milestoneTemplates/create.spec.js index f88a85a7..b56572a4 100644 --- a/src/routes/milestoneTemplates/create.spec.js +++ b/src/routes/milestoneTemplates/create.spec.js @@ -63,6 +63,10 @@ const milestoneTemplates = [ type: 'type1', order: 1, productTemplateId: 1, + plannedText: 'text to be shown in planned stage', + blockedText: 'text to be shown in blocked stage', + activeText: 'text to be shown in active stage', + completedText: 'text to be shown in completed stage', createdBy: 1, updatedBy: 2, }, @@ -71,6 +75,10 @@ const milestoneTemplates = [ duration: 4, type: 'type2', order: 2, + plannedText: 'text to be shown in planned stage - 2', + blockedText: 'text to be shown in blocked stage - 2', + activeText: 'text to be shown in active stage - 2', + completedText: 'text to be shown in completed stage - 2', productTemplateId: 1, createdBy: 2, updatedBy: 3, @@ -92,6 +100,10 @@ describe('CREATE milestone template', () => { duration: 33, type: 'type3', order: 1, + plannedText: 'text to be shown in planned stage - 3', + blockedText: 'text to be shown in blocked stage - 3', + activeText: 'text to be shown in active stage - 3', + completedText: 'text to be shown in completed stage - 3', }, }; @@ -227,6 +239,10 @@ describe('CREATE milestone template', () => { resJson.duration.should.be.eql(body.param.duration); resJson.type.should.be.eql(body.param.type); resJson.order.should.be.eql(body.param.order); + resJson.plannedText.should.be.eql(body.param.plannedText); + resJson.blockedText.should.be.eql(body.param.blockedText); + resJson.activeText.should.be.eql(body.param.activeText); + resJson.completedText.should.be.eql(body.param.completedText); resJson.createdBy.should.be.eql(40051333); // admin should.exist(resJson.createdAt); @@ -240,18 +256,19 @@ describe('CREATE milestone template', () => { where: { productTemplateId: 1, }, - }) - .then((milestones) => { - _.each(milestones, (milestone) => { - if (milestone.id === 1) { - milestone.order.should.be.eql(1 + 1); - } else if (milestone.id === 2) { - milestone.order.should.be.eql(2 + 1); - } - }); - - done(); + }).then((milestones) => { + _.each(milestones, (milestone) => { + if (milestone.id === 1) { + milestone.order.should.be.eql(1 + 1); + } else if (milestone.id === 2) { + milestone.order.should.be.eql(2 + 1); + } }); + done(); + }).catch((error) => { + console.log(error); + done(); + }); }); }); diff --git a/src/routes/milestoneTemplates/delete.spec.js b/src/routes/milestoneTemplates/delete.spec.js index 8290c243..4ae8864a 100644 --- a/src/routes/milestoneTemplates/delete.spec.js +++ b/src/routes/milestoneTemplates/delete.spec.js @@ -60,6 +60,10 @@ const milestoneTemplates = [ duration: 3, type: 'type1', order: 1, + plannedText: 'text to be shown in planned stage', + blockedText: 'text to be shown in blocked stage', + activeText: 'text to be shown in active stage', + completedText: 'text to be shown in completed stage', productTemplateId: 1, createdBy: 1, updatedBy: 2, @@ -70,6 +74,10 @@ const milestoneTemplates = [ duration: 4, type: 'type2', order: 2, + plannedText: 'text to be shown in planned stage - 2', + blockedText: 'text to be shown in blocked stage - 2', + activeText: 'text to be shown in active stage - 2', + completedText: 'text to be shown in completed stage - 2', productTemplateId: 1, createdBy: 2, updatedBy: 3, diff --git a/src/routes/milestoneTemplates/get.spec.js b/src/routes/milestoneTemplates/get.spec.js index c2b144e3..d9725629 100644 --- a/src/routes/milestoneTemplates/get.spec.js +++ b/src/routes/milestoneTemplates/get.spec.js @@ -63,6 +63,10 @@ const milestoneTemplates = [ duration: 3, type: 'type1', order: 1, + plannedText: 'text to be shown in planned stage', + blockedText: 'text to be shown in blocked stage', + activeText: 'text to be shown in active stage', + completedText: 'text to be shown in completed stage', productTemplateId: 1, createdBy: 1, updatedBy: 2, @@ -73,6 +77,10 @@ const milestoneTemplates = [ duration: 4, type: 'type2', order: 2, + plannedText: 'text to be shown in planned stage - 2', + blockedText: 'text to be shown in blocked stage - 2', + activeText: 'text to be shown in active stage - 2', + completedText: 'text to be shown in completed stage - 2', productTemplateId: 1, createdBy: 2, updatedBy: 3, @@ -135,6 +143,10 @@ describe('GET milestone template', () => { resJson.duration.should.be.eql(milestoneTemplates[0].duration); resJson.type.should.be.eql(milestoneTemplates[0].type); resJson.order.should.be.eql(milestoneTemplates[0].order); + resJson.plannedText.should.be.eql(milestoneTemplates[0].plannedText); + resJson.blockedText.should.be.eql(milestoneTemplates[0].blockedText); + resJson.activeText.should.be.eql(milestoneTemplates[0].activeText); + resJson.completedText.should.be.eql(milestoneTemplates[0].completedText); resJson.productTemplateId.should.be.eql(milestoneTemplates[0].productTemplateId); resJson.createdBy.should.be.eql(milestoneTemplates[0].createdBy); diff --git a/src/routes/milestoneTemplates/list.spec.js b/src/routes/milestoneTemplates/list.spec.js index 87fb3228..fedb32db 100644 --- a/src/routes/milestoneTemplates/list.spec.js +++ b/src/routes/milestoneTemplates/list.spec.js @@ -63,6 +63,10 @@ const milestoneTemplates = [ duration: 3, type: 'type1', order: 1, + plannedText: 'text to be shown in planned stage', + blockedText: 'text to be shown in blocked stage', + activeText: 'text to be shown in active stage', + completedText: 'text to be shown in completed stage', productTemplateId: 1, createdBy: 1, updatedBy: 2, @@ -73,6 +77,10 @@ const milestoneTemplates = [ duration: 4, type: 'type2', order: 2, + plannedText: 'text to be shown in planned stage - 2', + blockedText: 'text to be shown in blocked stage - 2', + activeText: 'text to be shown in active stage - 2', + completedText: 'text to be shown in completed stage - 2', productTemplateId: 1, createdBy: 2, updatedBy: 3, @@ -83,6 +91,10 @@ const milestoneTemplates = [ duration: 5, type: 'type3', order: 3, + plannedText: 'text to be shown in planned stage - 3', + blockedText: 'text to be shown in blocked stage - 3', + activeText: 'text to be shown in active stage - 3', + completedText: 'text to be shown in completed stage - 3', productTemplateId: 1, createdBy: 2, updatedBy: 3, @@ -146,6 +158,10 @@ describe('LIST milestone template', () => { resJson[0].duration.should.be.eql(milestoneTemplates[0].duration); resJson[0].type.should.be.eql(milestoneTemplates[0].type); resJson[0].order.should.be.eql(milestoneTemplates[0].order); + resJson[0].plannedText.should.be.eql(milestoneTemplates[0].plannedText); + resJson[0].blockedText.should.be.eql(milestoneTemplates[0].blockedText); + resJson[0].activeText.should.be.eql(milestoneTemplates[0].activeText); + resJson[0].completedText.should.be.eql(milestoneTemplates[0].completedText); resJson[0].productTemplateId.should.be.eql(milestoneTemplates[0].productTemplateId); resJson[0].createdBy.should.be.eql(milestoneTemplates[0].createdBy); diff --git a/src/routes/milestoneTemplates/update.js b/src/routes/milestoneTemplates/update.js index 65da9e9f..3b3bf408 100644 --- a/src/routes/milestoneTemplates/update.js +++ b/src/routes/milestoneTemplates/update.js @@ -19,11 +19,15 @@ const schema = { body: { param: Joi.object().keys({ id: Joi.any().strip(), - name: Joi.string().max(255).required(), + name: Joi.string().max(255).optional(), description: Joi.string().max(255), - duration: Joi.number().integer().required(), - type: Joi.string().max(45).required(), - order: Joi.number().integer().required(), + duration: Joi.number().integer().optional(), + type: Joi.string().max(45).optional(), + order: Joi.number().integer().optional(), + plannedText: Joi.string().max(512).optional(), + activeText: Joi.string().max(512).optional(), + completedText: Joi.string().max(512).optional(), + blockedText: Joi.string().max(512).optional(), productTemplateId: Joi.any().strip(), createdAt: Joi.any().strip(), updatedAt: Joi.any().strip(), diff --git a/src/routes/milestoneTemplates/update.spec.js b/src/routes/milestoneTemplates/update.spec.js index 8eeb6352..41b7601a 100644 --- a/src/routes/milestoneTemplates/update.spec.js +++ b/src/routes/milestoneTemplates/update.spec.js @@ -63,6 +63,10 @@ const milestoneTemplates = [ duration: 3, type: 'type1', order: 1, + plannedText: 'text to be shown in planned stage', + blockedText: 'text to be shown in blocked stage', + activeText: 'text to be shown in active stage', + completedText: 'text to be shown in completed stage', productTemplateId: 1, createdBy: 1, updatedBy: 2, @@ -73,6 +77,10 @@ const milestoneTemplates = [ duration: 4, type: 'type2', order: 2, + plannedText: 'text to be shown in planned stage - 2', + blockedText: 'text to be shown in blocked stage - 2', + activeText: 'text to be shown in active stage - 2', + completedText: 'text to be shown in completed stage - 2', productTemplateId: 1, createdBy: 2, updatedBy: 3, @@ -83,6 +91,10 @@ const milestoneTemplates = [ duration: 5, type: 'type3', order: 3, + plannedText: 'text to be shown in planned stage - 3', + blockedText: 'text to be shown in blocked stage - 3', + activeText: 'text to be shown in active stage - 3', + completedText: 'text to be shown in completed stage - 3', productTemplateId: 1, createdBy: 2, updatedBy: 3, @@ -93,6 +105,10 @@ const milestoneTemplates = [ duration: 5, type: 'type4', order: 4, + plannedText: 'text to be shown in planned stage - 4', + blockedText: 'text to be shown in blocked stage - 4', + activeText: 'text to be shown in active stage - 4', + completedText: 'text to be shown in completed stage - 4', productTemplateId: 1, createdBy: 2, updatedBy: 3, @@ -115,6 +131,10 @@ describe('UPDATE milestone template', () => { duration: 6, type: 'type1-updated', order: 5, + plannedText: 'text to be shown in planned stage', + blockedText: 'text to be shown in blocked stage', + activeText: 'text to be shown in active stage', + completedText: 'text to be shown in completed stage', }, }; @@ -155,70 +175,6 @@ describe('UPDATE milestone template', () => { .expect(403, done); }); - it('should return 422 for missing name', (done) => { - const invalidBody = { - param: { - name: undefined, - }, - }; - - request(server) - .patch('/v4/productTemplates/1/milestones/1') - .set({ - Authorization: `Bearer ${testUtil.jwts.admin}`, - }) - .send(invalidBody) - .expect(422, done); - }); - - it('should return 422 for missing type', (done) => { - const invalidBody = { - param: { - type: undefined, - }, - }; - - request(server) - .patch('/v4/productTemplates/1/milestones/1') - .set({ - Authorization: `Bearer ${testUtil.jwts.admin}`, - }) - .send(invalidBody) - .expect(422, done); - }); - - it('should return 422 for missing duration', (done) => { - const invalidBody = { - param: { - duration: undefined, - }, - }; - - request(server) - .patch('/v4/productTemplates/1/milestones/1') - .set({ - Authorization: `Bearer ${testUtil.jwts.admin}`, - }) - .send(invalidBody) - .expect(422, done); - }); - - it('should return 422 for missing order', (done) => { - const invalidBody = { - param: { - order: undefined, - }, - }; - - request(server) - .patch('/v4/productTemplates/1/milestones/1') - .set({ - Authorization: `Bearer ${testUtil.jwts.admin}`, - }) - .send(invalidBody) - .expect(422, done); - }); - it('should return 404 for non-existed product template', (done) => { request(server) .patch('/v4/productTemplates/122/milestones/1') @@ -265,6 +221,10 @@ describe('UPDATE milestone template', () => { resJson.duration.should.be.eql(body.param.duration); resJson.type.should.be.eql(body.param.type); resJson.order.should.be.eql(body.param.order); + resJson.plannedText.should.be.eql(body.param.plannedText); + resJson.blockedText.should.be.eql(body.param.blockedText); + resJson.activeText.should.be.eql(body.param.activeText); + resJson.completedText.should.be.eql(body.param.completedText); should.exist(resJson.createdBy); should.exist(resJson.createdAt); @@ -413,6 +373,102 @@ describe('UPDATE milestone template', () => { }); }); + it('should return 200 for missing name', (done) => { + const partialBody = _.cloneDeep(body); + delete partialBody.param.name; + request(server) + .patch('/v4/productTemplates/1/milestones/1') + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .send(partialBody) + .expect(200, done); + }); + + it('should return 200 for missing type', (done) => { + const partialBody = _.cloneDeep(body); + delete partialBody.param.type; + request(server) + .patch('/v4/productTemplates/1/milestones/1') + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .send(partialBody) + .expect(200, done); + }); + + it('should return 200 for missing duration', (done) => { + const partialBody = _.cloneDeep(body); + delete partialBody.param.duration; + request(server) + .patch('/v4/productTemplates/1/milestones/1') + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .send(partialBody) + .expect(200, done); + }); + + it('should return 200 for missing order', (done) => { + const partialBody = _.cloneDeep(body); + delete partialBody.param.order; + request(server) + .patch('/v4/productTemplates/1/milestones/1') + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .send(partialBody) + .expect(200, done); + }); + + it('should return 200 for missing plannedText', (done) => { + const partialBody = _.cloneDeep(body); + delete partialBody.param.plannedText; + request(server) + .patch('/v4/productTemplates/1/milestones/1') + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .send(partialBody) + .expect(200, done); + }); + + it('should return 200 for missing blockedText', (done) => { + const partialBody = _.cloneDeep(body); + delete partialBody.param.blockedText; + request(server) + .patch('/v4/productTemplates/1/milestones/1') + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .send(partialBody) + .expect(200, done); + }); + + it('should return 200 for missing activeText', (done) => { + const partialBody = _.cloneDeep(body); + delete partialBody.param.activeText; + request(server) + .patch('/v4/productTemplates/1/milestones/1') + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .send(partialBody) + .expect(200, done); + }); + + it('should return 200 for missing completedText', (done) => { + const partialBody = _.cloneDeep(body); + delete partialBody.param.completedText; + request(server) + .patch('/v4/productTemplates/1/milestones/1') + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .send(partialBody) + .expect(200, done); + }); + it('should return 200 for connect admin', (done) => { request(server) .patch('/v4/productTemplates/1/milestones/1') From 0700be16c7ca4d09467a4e60d13d7769f08820fb Mon Sep 17 00:00:00 2001 From: Vikas Agarwal Date: Tue, 17 Jul 2018 12:30:46 +0530 Subject: [PATCH 05/73] Added hidden field to the milestone template model --- src/models/milestone.js | 1 + src/models/productMilestoneTemplate.js | 1 + src/routes/milestoneTemplates/create.js | 1 + src/routes/milestoneTemplates/create.spec.js | 19 +++++++++++++++++++ src/routes/milestoneTemplates/update.js | 1 + src/routes/milestoneTemplates/update.spec.js | 13 +++++++++++++ 6 files changed, 36 insertions(+) diff --git a/src/models/milestone.js b/src/models/milestone.js index cb3e0306..a5279571 100644 --- a/src/models/milestone.js +++ b/src/models/milestone.js @@ -20,6 +20,7 @@ module.exports = (sequelize, DataTypes) => { activeText: { type: DataTypes.STRING(512), allowNull: false }, completedText: { type: DataTypes.STRING(512), allowNull: false }, blockedText: { type: DataTypes.STRING(512), allowNull: false }, + hidden: { type: DataTypes.BOOLEAN, defaultValue: false }, deletedAt: DataTypes.DATE, createdAt: { type: DataTypes.DATE, defaultValue: DataTypes.NOW }, updatedAt: { type: DataTypes.DATE, defaultValue: DataTypes.NOW }, diff --git a/src/models/productMilestoneTemplate.js b/src/models/productMilestoneTemplate.js index 2ec0b692..7db76b52 100644 --- a/src/models/productMilestoneTemplate.js +++ b/src/models/productMilestoneTemplate.js @@ -15,6 +15,7 @@ module.exports = (sequelize, DataTypes) => { activeText: { type: DataTypes.STRING(512), allowNull: false }, completedText: { type: DataTypes.STRING(512), allowNull: false }, blockedText: { type: DataTypes.STRING(512), allowNull: false }, + hidden: { type: DataTypes.BOOLEAN, defaultValue: false }, deletedAt: DataTypes.DATE, createdAt: { type: DataTypes.DATE, defaultValue: DataTypes.NOW }, updatedAt: { type: DataTypes.DATE, defaultValue: DataTypes.NOW }, diff --git a/src/routes/milestoneTemplates/create.js b/src/routes/milestoneTemplates/create.js index a331063b..301b539d 100644 --- a/src/routes/milestoneTemplates/create.js +++ b/src/routes/milestoneTemplates/create.js @@ -28,6 +28,7 @@ const schema = { completedText: Joi.string().max(512).required(), blockedText: Joi.string().max(512).required(), productTemplateId: Joi.any().strip(), + hidden: Joi.boolean().optional(), createdAt: Joi.any().strip(), updatedAt: Joi.any().strip(), deletedAt: Joi.any().strip(), diff --git a/src/routes/milestoneTemplates/create.spec.js b/src/routes/milestoneTemplates/create.spec.js index b56572a4..1a04b9db 100644 --- a/src/routes/milestoneTemplates/create.spec.js +++ b/src/routes/milestoneTemplates/create.spec.js @@ -104,6 +104,7 @@ describe('CREATE milestone template', () => { blockedText: 'text to be shown in blocked stage - 3', activeText: 'text to be shown in active stage - 3', completedText: 'text to be shown in completed stage - 3', + hidden: true, }, }; @@ -272,6 +273,24 @@ describe('CREATE milestone template', () => { }); }); + it('should return 201 for admin without optional fields', (done) => { + const minimalBody = _.cloneDeep(body); + delete minimalBody.param.hidden; + request(server) + .post('/v4/productTemplates/1/milestones') + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .send(minimalBody) + .expect('Content-Type', /json/) + .expect(201) + .end((err, res) => { + const resJson = res.body.result.content; + resJson.hidden.should.be.eql(false); // default of hidden field + done(); + }); + }); + it('should return 201 for connect admin', (done) => { request(server) .post('/v4/productTemplates/1/milestones') diff --git a/src/routes/milestoneTemplates/update.js b/src/routes/milestoneTemplates/update.js index 3b3bf408..996b5d6c 100644 --- a/src/routes/milestoneTemplates/update.js +++ b/src/routes/milestoneTemplates/update.js @@ -29,6 +29,7 @@ const schema = { completedText: Joi.string().max(512).optional(), blockedText: Joi.string().max(512).optional(), productTemplateId: Joi.any().strip(), + hidden: Joi.boolean().optional(), createdAt: Joi.any().strip(), updatedAt: Joi.any().strip(), deletedAt: Joi.any().strip(), diff --git a/src/routes/milestoneTemplates/update.spec.js b/src/routes/milestoneTemplates/update.spec.js index 41b7601a..260a04a1 100644 --- a/src/routes/milestoneTemplates/update.spec.js +++ b/src/routes/milestoneTemplates/update.spec.js @@ -135,6 +135,7 @@ describe('UPDATE milestone template', () => { blockedText: 'text to be shown in blocked stage', activeText: 'text to be shown in active stage', completedText: 'text to be shown in completed stage', + hidden: true, }, }; @@ -469,6 +470,18 @@ describe('UPDATE milestone template', () => { .expect(200, done); }); + it('should return 200 for missing hidden field', (done) => { + const partialBody = _.cloneDeep(body); + delete partialBody.param.hidden; + request(server) + .patch('/v4/productTemplates/1/milestones/1') + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .send(partialBody) + .expect(200, done); + }); + it('should return 200 for connect admin', (done) => { request(server) .patch('/v4/productTemplates/1/milestones/1') From 315d42451013eedd640029cbad41e74879a0bc9e Mon Sep 17 00:00:00 2001 From: Vikas Agarwal Date: Tue, 17 Jul 2018 12:54:59 +0530 Subject: [PATCH 06/73] Fixed issue with failing unit tests for milestone create action. It was because we were sending response before transaction commit. Fixed the same for milestone template as well and for update endpoints for milestone and milestone template as well. Made fields in update call to milestone endpoint, optional so that user can specify only sub set of the fields to be updated. --- src/routes/milestoneTemplates/create.js | 14 +- src/routes/milestoneTemplates/update.js | 14 +- src/routes/milestoneTemplates/update.spec.js | 120 ++++---- src/routes/milestones/create.js | 23 +- src/routes/milestones/create.spec.js | 2 + src/routes/milestones/update.js | 55 ++-- src/routes/milestones/update.spec.js | 276 ++++++++----------- 7 files changed, 232 insertions(+), 272 deletions(-) diff --git a/src/routes/milestoneTemplates/create.js b/src/routes/milestoneTemplates/create.js index 301b539d..b207b72a 100644 --- a/src/routes/milestoneTemplates/create.js +++ b/src/routes/milestoneTemplates/create.js @@ -79,12 +79,12 @@ module.exports = [ }, transaction: tx, }); - }) - .then(() => { - // Write to response - res.status(201).json(util.wrapResponse(req.id, result, 1, 201)); - }) - .catch(next), - ); + }), + ) + .then(() => { + // Write to response + res.status(201).json(util.wrapResponse(req.id, result, 1, 201)); + }) + .catch(next); }, ]; diff --git a/src/routes/milestoneTemplates/update.js b/src/routes/milestoneTemplates/update.js index 996b5d6c..8936b72c 100644 --- a/src/routes/milestoneTemplates/update.js +++ b/src/routes/milestoneTemplates/update.js @@ -115,12 +115,12 @@ module.exports = [ }, }); }); - }) - .then(() => { - res.json(util.wrapResponse(req.id, updated)); - return Promise.resolve(); - }) - .catch(next), - ); + }), + ) + .then(() => { + res.json(util.wrapResponse(req.id, updated)); + return Promise.resolve(); + }) + .catch(next); }, ]; diff --git a/src/routes/milestoneTemplates/update.spec.js b/src/routes/milestoneTemplates/update.spec.js index 260a04a1..e653ab2e 100644 --- a/src/routes/milestoneTemplates/update.spec.js +++ b/src/routes/milestoneTemplates/update.spec.js @@ -253,22 +253,20 @@ describe('UPDATE milestone template', () => { // Milestone 1: order 3 // Milestone 2: order 2 - 1 = 1 // Milestone 3: order 3 - 1 = 2 - setTimeout(() => { - models.ProductMilestoneTemplate.findById(1) - .then((milestone) => { - milestone.order.should.be.eql(3); - }) - .then(() => models.ProductMilestoneTemplate.findById(2)) - .then((milestone) => { - milestone.order.should.be.eql(1); - }) - .then(() => models.ProductMilestoneTemplate.findById(3)) - .then((milestone) => { - milestone.order.should.be.eql(2); - - done(); - }); - }, 3000); + models.ProductMilestoneTemplate.findById(1) + .then((milestone) => { + milestone.order.should.be.eql(3); + }) + .then(() => models.ProductMilestoneTemplate.findById(2)) + .then((milestone) => { + milestone.order.should.be.eql(1); + }) + .then(() => models.ProductMilestoneTemplate.findById(3)) + .then((milestone) => { + milestone.order.should.be.eql(2); + + done(); + }); }); }); @@ -287,22 +285,20 @@ describe('UPDATE milestone template', () => { // Milestone 1: order 4 // Milestone 2: order 2 // Milestone 3: order 3 - setTimeout(() => { - models.ProductMilestoneTemplate.findById(1) - .then((milestone) => { - milestone.order.should.be.eql(4); - }) - .then(() => models.ProductMilestoneTemplate.findById(2)) - .then((milestone) => { - milestone.order.should.be.eql(2); - }) - .then(() => models.ProductMilestoneTemplate.findById(3)) - .then((milestone) => { - milestone.order.should.be.eql(3); - - done(); - }); - }, 3000); + models.ProductMilestoneTemplate.findById(1) + .then((milestone) => { + milestone.order.should.be.eql(4); + }) + .then(() => models.ProductMilestoneTemplate.findById(2)) + .then((milestone) => { + milestone.order.should.be.eql(2); + }) + .then(() => models.ProductMilestoneTemplate.findById(3)) + .then((milestone) => { + milestone.order.should.be.eql(3); + + done(); + }); }); }); @@ -321,22 +317,20 @@ describe('UPDATE milestone template', () => { // Milestone 1: order 2 // Milestone 2: order 3 // Milestone 3: order 1 - setTimeout(() => { - models.ProductMilestoneTemplate.findById(1) - .then((milestone) => { - milestone.order.should.be.eql(2); - }) - .then(() => models.ProductMilestoneTemplate.findById(2)) - .then((milestone) => { - milestone.order.should.be.eql(3); - }) - .then(() => models.ProductMilestoneTemplate.findById(3)) - .then((milestone) => { - milestone.order.should.be.eql(1); - - done(); - }); - }, 3000); + models.ProductMilestoneTemplate.findById(1) + .then((milestone) => { + milestone.order.should.be.eql(2); + }) + .then(() => models.ProductMilestoneTemplate.findById(2)) + .then((milestone) => { + milestone.order.should.be.eql(3); + }) + .then(() => models.ProductMilestoneTemplate.findById(3)) + .then((milestone) => { + milestone.order.should.be.eql(1); + + done(); + }); }); }); @@ -355,22 +349,20 @@ describe('UPDATE milestone template', () => { // Milestone 1: order 1 // Milestone 2: order 2 // Milestone 3: order 0 - setTimeout(() => { - models.ProductMilestoneTemplate.findById(1) - .then((milestone) => { - milestone.order.should.be.eql(1); - }) - .then(() => models.ProductMilestoneTemplate.findById(2)) - .then((milestone) => { - milestone.order.should.be.eql(2); - }) - .then(() => models.ProductMilestoneTemplate.findById(3)) - .then((milestone) => { - milestone.order.should.be.eql(0); - - done(); - }); - }, 3000); + models.ProductMilestoneTemplate.findById(1) + .then((milestone) => { + milestone.order.should.be.eql(1); + }) + .then(() => models.ProductMilestoneTemplate.findById(2)) + .then((milestone) => { + milestone.order.should.be.eql(2); + }) + .then(() => models.ProductMilestoneTemplate.findById(3)) + .then((milestone) => { + milestone.order.should.be.eql(0); + + done(); + }); }); }); diff --git a/src/routes/milestones/create.js b/src/routes/milestones/create.js index f653d685..b3b6623f 100644 --- a/src/routes/milestones/create.js +++ b/src/routes/milestones/create.js @@ -33,6 +33,7 @@ const schema = { activeText: Joi.string().max(512).required(), completedText: Joi.string().max(512).required(), blockedText: Joi.string().max(512).required(), + hidden: Joi.boolean().optional(), createdAt: Joi.any().strip(), updatedAt: Joi.any().strip(), deletedAt: Joi.any().strip(), @@ -94,17 +95,17 @@ module.exports = [ }, transaction: tx, }); - }) - .then(() => { - // Do not send events for the updated milestones here, - // because it will make 'version conflict' error in ES. - // The order of the other milestones need to be updated in the MILESTONE_ADDED event handler + }), + ) + .then(() => { + // Do not send events for the updated milestones here, + // because it will make 'version conflict' error in ES. + // The order of the other milestones need to be updated in the MILESTONE_ADDED event handler - // Write to the response - res.status(201).json(util.wrapResponse(req.id, result, 1, 201)); - return Promise.resolve(); - }) - .catch(next), - ); + // Write to the response + res.status(201).json(util.wrapResponse(req.id, result, 1, 201)); + return Promise.resolve(); + }) + .catch(next); }, ]; diff --git a/src/routes/milestones/create.spec.js b/src/routes/milestones/create.spec.js index 98e72001..250e8175 100644 --- a/src/routes/milestones/create.spec.js +++ b/src/routes/milestones/create.spec.js @@ -221,6 +221,7 @@ describe('CREATE milestone', () => { activeText: 'activeText 4', completedText: 'completedText 4', blockedText: 'blockedText 4', + hidden: true, }, }; @@ -506,6 +507,7 @@ describe('CREATE milestone', () => { resJson.activeText.should.be.eql(body.param.activeText); resJson.completedText.should.be.eql(body.param.completedText); resJson.blockedText.should.be.eql(body.param.blockedText); + resJson.hidden.should.be.eql(body.param.hidden); resJson.createdBy.should.be.eql(40051333); // admin should.exist(resJson.createdAt); diff --git a/src/routes/milestones/update.js b/src/routes/milestones/update.js index 5641d290..a5acf5d2 100644 --- a/src/routes/milestones/update.js +++ b/src/routes/milestones/update.js @@ -20,20 +20,21 @@ const schema = { body: { param: Joi.object().keys({ id: Joi.any().strip(), - name: Joi.string().max(255).required(), + name: Joi.string().max(255).optional(), description: Joi.string().max(255), - duration: Joi.number().integer().required(), - startDate: Joi.date().required(), + duration: Joi.number().integer().optional(), + startDate: Joi.date().optional(), endDate: Joi.date().min(Joi.ref('startDate')).allow(null), completionDate: Joi.date().min(Joi.ref('startDate')).allow(null), - status: Joi.string().max(45).required(), - type: Joi.string().max(45).required(), + status: Joi.string().max(45).optional(), + type: Joi.string().max(45).optional(), details: Joi.object(), - order: Joi.number().integer().required(), - plannedText: Joi.string().max(512).required(), - activeText: Joi.string().max(512).required(), - completedText: Joi.string().max(512).required(), - blockedText: Joi.string().max(512).required(), + order: Joi.number().integer().optional(), + plannedText: Joi.string().max(512).optional(), + activeText: Joi.string().max(512).optional(), + completedText: Joi.string().max(512).optional(), + blockedText: Joi.string().max(512).optional(), + hidden: Joi.boolean().optional(), createdAt: Joi.any().strip(), updatedAt: Joi.any().strip(), deletedAt: Joi.any().strip(), @@ -138,24 +139,24 @@ module.exports = [ }, }); }); - }) - .then(() => { - // Send event to bus - req.log.debug('Sending event to RabbitMQ bus for milestone %d', updated.id); - req.app.services.pubsub.publish(EVENT.ROUTING_KEY.MILESTONE_UPDATED, - { original, updated }, - { correlationId: req.id }, - ); + }), + ) + .then(() => { + // Send event to bus + req.log.debug('Sending event to RabbitMQ bus for milestone %d', updated.id); + req.app.services.pubsub.publish(EVENT.ROUTING_KEY.MILESTONE_UPDATED, + { original, updated }, + { correlationId: req.id }, + ); - // Do not send events for the the other milestones (updated order) here, - // because it will make 'version conflict' error in ES. - // The order of the other milestones need to be updated in the MILESTONE_UPDATED event above + // Do not send events for the the other milestones (updated order) here, + // because it will make 'version conflict' error in ES. + // The order of the other milestones need to be updated in the MILESTONE_UPDATED event above - // Write to response - res.json(util.wrapResponse(req.id, updated)); - return Promise.resolve(); - }) - .catch(next), - ); + // Write to response + res.json(util.wrapResponse(req.id, updated)); + return Promise.resolve(); + }) + .catch(next); }, ]; diff --git a/src/routes/milestones/update.spec.js b/src/routes/milestones/update.spec.js index fca57115..1d5edcc1 100644 --- a/src/routes/milestones/update.spec.js +++ b/src/routes/milestones/update.spec.js @@ -275,6 +275,7 @@ describe('UPDATE Milestone', () => { activeText: 'activeText 1-updated', completedText: 'completedText 1-updated', blockedText: 'blockedText 1-updated', + hidden: true, }, }; @@ -355,141 +356,112 @@ describe('UPDATE Milestone', () => { .expect(422, done); }); - - it('should return 422 if missing name', (done) => { - const invalidBody = { - param: _.assign({}, body.param, { - name: undefined, - }), - }; - + it('should return 200 for missing name', (done) => { + const partialBody = _.cloneDeep(body); + delete partialBody.param.name; request(server) .patch('/v4/timelines/1/milestones/1') .set({ Authorization: `Bearer ${testUtil.jwts.admin}`, }) - .send(invalidBody) - .expect('Content-Type', /json/) - .expect(422, done); + .send(partialBody) + .expect(200, done); }); - it('should return 422 if missing duration', (done) => { - const invalidBody = { - param: _.assign({}, body.param, { - duration: undefined, - }), - }; - + it('should return 200 for missing type', (done) => { + const partialBody = _.cloneDeep(body); + delete partialBody.param.type; request(server) .patch('/v4/timelines/1/milestones/1') .set({ Authorization: `Bearer ${testUtil.jwts.admin}`, }) - .send(invalidBody) - .expect('Content-Type', /json/) - .expect(422, done); + .send(partialBody) + .expect(200, done); }); - it('should return 422 if missing type', (done) => { - const invalidBody = { - param: _.assign({}, body.param, { - type: undefined, - }), - }; - + it('should return 200 for missing duration', (done) => { + const partialBody = _.cloneDeep(body); + delete partialBody.param.duration; request(server) .patch('/v4/timelines/1/milestones/1') .set({ Authorization: `Bearer ${testUtil.jwts.admin}`, }) - .send(invalidBody) - .expect('Content-Type', /json/) - .expect(422, done); + .send(partialBody) + .expect(200, done); }); - it('should return 422 if missing order', (done) => { - const invalidBody = { - param: _.assign({}, body.param, { - order: undefined, - }), - }; - + it('should return 200 for missing order', (done) => { + const partialBody = _.cloneDeep(body); + delete partialBody.param.order; request(server) .patch('/v4/timelines/1/milestones/1') .set({ Authorization: `Bearer ${testUtil.jwts.admin}`, }) - .send(invalidBody) - .expect('Content-Type', /json/) - .expect(422, done); + .send(partialBody) + .expect(200, done); }); - it('should return 422 if missing plannedText', (done) => { - const invalidBody = { - param: _.assign({}, body.param, { - plannedText: undefined, - }), - }; - + it('should return 200 for missing plannedText', (done) => { + const partialBody = _.cloneDeep(body); + delete partialBody.param.plannedText; request(server) .patch('/v4/timelines/1/milestones/1') .set({ Authorization: `Bearer ${testUtil.jwts.admin}`, }) - .send(invalidBody) - .expect('Content-Type', /json/) - .expect(422, done); + .send(partialBody) + .expect(200, done); }); - it('should return 422 if missing activeText', (done) => { - const invalidBody = { - param: _.assign({}, body.param, { - activeText: undefined, - }), - }; - + it('should return 200 for missing blockedText', (done) => { + const partialBody = _.cloneDeep(body); + delete partialBody.param.blockedText; request(server) .patch('/v4/timelines/1/milestones/1') .set({ Authorization: `Bearer ${testUtil.jwts.admin}`, }) - .send(invalidBody) - .expect('Content-Type', /json/) - .expect(422, done); + .send(partialBody) + .expect(200, done); }); - it('should return 422 if missing completedText', (done) => { - const invalidBody = { - param: _.assign({}, body.param, { - completedText: undefined, - }), - }; - + it('should return 200 for missing activeText', (done) => { + const partialBody = _.cloneDeep(body); + delete partialBody.param.activeText; request(server) .patch('/v4/timelines/1/milestones/1') .set({ Authorization: `Bearer ${testUtil.jwts.admin}`, }) - .send(invalidBody) - .expect('Content-Type', /json/) - .expect(422, done); + .send(partialBody) + .expect(200, done); }); - it('should return 422 if missing blockedText', (done) => { - const invalidBody = { - param: _.assign({}, body.param, { - blockedText: undefined, - }), - }; + it('should return 200 for missing completedText', (done) => { + const partialBody = _.cloneDeep(body); + delete partialBody.param.completedText; + request(server) + .patch('/v4/timelines/1/milestones/1') + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .send(partialBody) + .expect(200, done); + }); + it('should return 200 for missing hidden field', (done) => { + const partialBody = _.cloneDeep(body); + delete partialBody.param.hidden; request(server) .patch('/v4/timelines/1/milestones/1') .set({ Authorization: `Bearer ${testUtil.jwts.admin}`, }) - .send(invalidBody) - .expect('Content-Type', /json/) - .expect(422, done); + .send(partialBody) + .expect(200, done); }); it('should return 422 if startDate is after endDate', (done) => { @@ -622,26 +594,24 @@ describe('UPDATE Milestone', () => { // Milestone 2: order 2 - 1 = 1 // Milestone 3: order 3 - 1 = 2 // Milestone 4: order 4 - 1 = 3 - setTimeout(() => { - models.Milestone.findById(1) - .then((milestone) => { - milestone.order.should.be.eql(4); - }) - .then(() => models.Milestone.findById(2)) - .then((milestone) => { - milestone.order.should.be.eql(1); - }) - .then(() => models.Milestone.findById(3)) - .then((milestone) => { - milestone.order.should.be.eql(2); - }) - .then(() => models.Milestone.findById(4)) - .then((milestone) => { - milestone.order.should.be.eql(3); + models.Milestone.findById(1) + .then((milestone) => { + milestone.order.should.be.eql(4); + }) + .then(() => models.Milestone.findById(2)) + .then((milestone) => { + milestone.order.should.be.eql(1); + }) + .then(() => models.Milestone.findById(3)) + .then((milestone) => { + milestone.order.should.be.eql(2); + }) + .then(() => models.Milestone.findById(4)) + .then((milestone) => { + milestone.order.should.be.eql(3); - done(); - }); - }, 3000); + done(); + }); }); }); @@ -661,26 +631,24 @@ describe('UPDATE Milestone', () => { // Milestone 2: order 2 // Milestone 3: order 3 // Milestone 4: order 4 - setTimeout(() => { - models.Milestone.findById(1) - .then((milestone) => { - milestone.order.should.be.eql(5); - }) - .then(() => models.Milestone.findById(2)) - .then((milestone) => { - milestone.order.should.be.eql(2); - }) - .then(() => models.Milestone.findById(3)) - .then((milestone) => { - milestone.order.should.be.eql(3); - }) - .then(() => models.Milestone.findById(4)) - .then((milestone) => { - milestone.order.should.be.eql(4); + models.Milestone.findById(1) + .then((milestone) => { + milestone.order.should.be.eql(5); + }) + .then(() => models.Milestone.findById(2)) + .then((milestone) => { + milestone.order.should.be.eql(2); + }) + .then(() => models.Milestone.findById(3)) + .then((milestone) => { + milestone.order.should.be.eql(3); + }) + .then(() => models.Milestone.findById(4)) + .then((milestone) => { + milestone.order.should.be.eql(4); - done(); - }); - }, 3000); + done(); + }); }); }); @@ -700,26 +668,24 @@ describe('UPDATE Milestone', () => { // Milestone 2: order 3 // Milestone 3: order 4 // Milestone 4: order 2 - setTimeout(() => { - models.Milestone.findById(1) - .then((milestone) => { - milestone.order.should.be.eql(1); - }) - .then(() => models.Milestone.findById(2)) - .then((milestone) => { - milestone.order.should.be.eql(3); - }) - .then(() => models.Milestone.findById(3)) - .then((milestone) => { - milestone.order.should.be.eql(4); - }) - .then(() => models.Milestone.findById(4)) - .then((milestone) => { - milestone.order.should.be.eql(2); + models.Milestone.findById(1) + .then((milestone) => { + milestone.order.should.be.eql(1); + }) + .then(() => models.Milestone.findById(2)) + .then((milestone) => { + milestone.order.should.be.eql(3); + }) + .then(() => models.Milestone.findById(3)) + .then((milestone) => { + milestone.order.should.be.eql(4); + }) + .then(() => models.Milestone.findById(4)) + .then((milestone) => { + milestone.order.should.be.eql(2); - done(); - }); - }, 3000); + done(); + }); }); }); @@ -739,26 +705,24 @@ describe('UPDATE Milestone', () => { // Milestone 2: order 2 // Milestone 3: order 3 // Milestone 4: order 0 - setTimeout(() => { - models.Milestone.findById(1) - .then((milestone) => { - milestone.order.should.be.eql(1); - }) - .then(() => models.Milestone.findById(2)) - .then((milestone) => { - milestone.order.should.be.eql(2); - }) - .then(() => models.Milestone.findById(3)) - .then((milestone) => { - milestone.order.should.be.eql(3); - }) - .then(() => models.Milestone.findById(4)) - .then((milestone) => { - milestone.order.should.be.eql(0); + models.Milestone.findById(1) + .then((milestone) => { + milestone.order.should.be.eql(1); + }) + .then(() => models.Milestone.findById(2)) + .then((milestone) => { + milestone.order.should.be.eql(2); + }) + .then(() => models.Milestone.findById(3)) + .then((milestone) => { + milestone.order.should.be.eql(3); + }) + .then(() => models.Milestone.findById(4)) + .then((milestone) => { + milestone.order.should.be.eql(0); - done(); - }); - }, 3000); + done(); + }); }); }); From 4019c1d7fb912861684ecc2a701f3fe8cd515981 Mon Sep 17 00:00:00 2001 From: Vikas Agarwal Date: Tue, 17 Jul 2018 12:56:14 +0530 Subject: [PATCH 07/73] added product as possible value for the reference field in timeline model --- src/constants.js | 1 + 1 file changed, 1 insertion(+) diff --git a/src/constants.js b/src/constants.js index a99469f5..0a62c215 100644 --- a/src/constants.js +++ b/src/constants.js @@ -100,4 +100,5 @@ export const TOKEN_SCOPES = { export const TIMELINE_REFERENCES = { PROJECT: 'project', PHASE: 'phase', + PRODUCT: 'product', }; From 1602c6c53686642b8d08dc440b1a825bb4b60e63 Mon Sep 17 00:00:00 2001 From: architectt1 <40553430+architectt1@users.noreply.github.com> Date: Fri, 20 Jul 2018 07:10:13 -0300 Subject: [PATCH 08/73] Added metadata JSON field. (#115) --- .../20180717_project_types_metadata.sql | 13 +++++ src/models/projectType.js | 1 + src/routes/projectTypes/create.js | 1 + src/routes/projectTypes/create.spec.js | 18 ++++++ src/routes/projectTypes/delete.spec.js | 1 + src/routes/projectTypes/get.spec.js | 2 + src/routes/projectTypes/list.spec.js | 3 + src/routes/projectTypes/update.js | 1 + src/routes/projectTypes/update.spec.js | 55 +++++++++++++++++++ src/routes/projects/create.spec.js | 1 + src/routes/projects/update.spec.js | 1 + 11 files changed, 97 insertions(+) create mode 100644 migrations/20180717_project_types_metadata.sql diff --git a/migrations/20180717_project_types_metadata.sql b/migrations/20180717_project_types_metadata.sql new file mode 100644 index 00000000..f856796f --- /dev/null +++ b/migrations/20180717_project_types_metadata.sql @@ -0,0 +1,13 @@ +-- +-- UPDATE EXISTING TABLES: +-- project_types +-- metadata column: added +-- + +-- +-- project_types +-- + +ALTER TABLE project_types ADD COLUMN "metadata" json; +UPDATE project_types set metadata='{}' where metadata is null; +ALTER TABLE project_types ALTER COLUMN "metadata" SET NOT NULL; diff --git a/src/models/projectType.js b/src/models/projectType.js index acd7b44b..19618698 100644 --- a/src/models/projectType.js +++ b/src/models/projectType.js @@ -10,6 +10,7 @@ module.exports = function definePhaseProduct(sequelize, DataTypes) { aliases: { type: DataTypes.JSON, allowNull: false }, disabled: { type: DataTypes.BOOLEAN, defaultValue: false }, hidden: { type: DataTypes.BOOLEAN, defaultValue: false }, + metadata: { type: DataTypes.JSON, allowNull: false }, deletedAt: { type: DataTypes.DATE, allowNull: true }, createdAt: { type: DataTypes.DATE, defaultValue: DataTypes.NOW }, diff --git a/src/routes/projectTypes/create.js b/src/routes/projectTypes/create.js index 6a4ea058..8e73e1ec 100644 --- a/src/routes/projectTypes/create.js +++ b/src/routes/projectTypes/create.js @@ -21,6 +21,7 @@ const schema = { aliases: Joi.array().required(), disabled: Joi.boolean().optional(), hidden: Joi.boolean().optional(), + metadata: Joi.object().required(), createdAt: Joi.any().strip(), updatedAt: Joi.any().strip(), deletedAt: Joi.any().strip(), diff --git a/src/routes/projectTypes/create.spec.js b/src/routes/projectTypes/create.spec.js index f690bd4d..d2e339bc 100644 --- a/src/routes/projectTypes/create.spec.js +++ b/src/routes/projectTypes/create.spec.js @@ -22,6 +22,7 @@ describe('CREATE project type', () => { aliases: ['key-1', 'key_1'], disabled: false, hidden: false, + metadata: { 'slack-notification-mappings': { color: '#96d957', label: 'Full App' } }, createdBy: 1, updatedBy: 1, })).then(() => Promise.resolve()), @@ -39,6 +40,7 @@ describe('CREATE project type', () => { aliases: ['key-1', 'key_1'], disabled: true, hidden: true, + metadata: { 'slack-notification-mappings': { color: '#96d957', label: 'Full App' } }, }, }; @@ -149,6 +151,20 @@ describe('CREATE project type', () => { .expect(422, done); }); + it('should return 422 for missing metadata', (done) => { + const invalidBody = _.cloneDeep(body); + delete invalidBody.param.metadata; + + request(server) + .post('/v4/projectTypes') + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .send(invalidBody) + .expect('Content-Type', /json/) + .expect(422, done); + }); + it('should return 422 for duplicated key', (done) => { const invalidBody = _.cloneDeep(body); invalidBody.param.key = 'key1'; @@ -182,6 +198,7 @@ describe('CREATE project type', () => { resJson.aliases.should.be.eql(body.param.aliases); resJson.disabled.should.be.eql(body.param.disabled); resJson.hidden.should.be.eql(body.param.hidden); + resJson.metadata.should.be.eql(body.param.metadata); resJson.createdBy.should.be.eql(40051333); // admin should.exist(resJson.createdAt); @@ -213,6 +230,7 @@ describe('CREATE project type', () => { resJson.aliases.should.be.eql(body.param.aliases); resJson.disabled.should.be.eql(body.param.disabled); resJson.hidden.should.be.eql(body.param.hidden); + resJson.metadata.should.be.eql(body.param.metadata); resJson.createdBy.should.be.eql(40051336); // connect admin resJson.updatedBy.should.be.eql(40051336); // connect admin done(); diff --git a/src/routes/projectTypes/delete.spec.js b/src/routes/projectTypes/delete.spec.js index 053bfb1c..496a47a5 100644 --- a/src/routes/projectTypes/delete.spec.js +++ b/src/routes/projectTypes/delete.spec.js @@ -19,6 +19,7 @@ describe('DELETE project type', () => { question: 'question 1', info: 'info 1', aliases: ['key-1', 'key_1'], + metadata: { 'slack-notification-mappings': { color: '#96d957', label: 'Full App' } }, createdBy: 1, updatedBy: 1, })).then(() => Promise.resolve()), diff --git a/src/routes/projectTypes/get.spec.js b/src/routes/projectTypes/get.spec.js index cf12bb88..eb72604f 100644 --- a/src/routes/projectTypes/get.spec.js +++ b/src/routes/projectTypes/get.spec.js @@ -20,6 +20,7 @@ describe('GET project type', () => { aliases: ['key-1', 'key_1'], disabled: true, hidden: true, + metadata: { 'slack-notification-mappings': { color: '#96d957', label: 'Full App' } }, createdBy: 1, updatedBy: 1, }; @@ -71,6 +72,7 @@ describe('GET project type', () => { resJson.aliases.should.be.eql(type.aliases); resJson.disabled.should.be.eql(type.disabled); resJson.hidden.should.be.eql(type.hidden); + resJson.metadata.should.be.eql(type.metadata); resJson.createdBy.should.be.eql(type.createdBy); should.exist(resJson.createdAt); resJson.updatedBy.should.be.eql(type.updatedBy); diff --git a/src/routes/projectTypes/list.spec.js b/src/routes/projectTypes/list.spec.js index 5128a760..991667b5 100644 --- a/src/routes/projectTypes/list.spec.js +++ b/src/routes/projectTypes/list.spec.js @@ -21,6 +21,7 @@ describe('LIST project types', () => { aliases: ['key-1', 'key_1'], disabled: true, hidden: true, + metadata: { 'slack-notification-mappings': { color: '#96d957', label: 'Full App' } }, createdBy: 1, updatedBy: 1, }, @@ -33,6 +34,7 @@ describe('LIST project types', () => { aliases: ['key-2', 'key_2'], disabled: true, hidden: true, + metadata: { 'slack-notification-mappings': { color: '#b47dd6', label: 'Full App 2' } }, createdBy: 1, updatedBy: 1, }, @@ -67,6 +69,7 @@ describe('LIST project types', () => { resJson[0].createdBy.should.be.eql(type.createdBy); resJson[0].disabled.should.be.eql(type.disabled); resJson[0].hidden.should.be.eql(type.hidden); + resJson[0].metadata.should.be.eql(type.metadata); should.exist(resJson[0].createdAt); resJson[0].updatedBy.should.be.eql(type.updatedBy); should.exist(resJson[0].updatedAt); diff --git a/src/routes/projectTypes/update.js b/src/routes/projectTypes/update.js index 3946715e..9975f478 100644 --- a/src/routes/projectTypes/update.js +++ b/src/routes/projectTypes/update.js @@ -24,6 +24,7 @@ const schema = { aliases: Joi.array().optional(), disabled: Joi.boolean().optional(), hidden: Joi.boolean().optional(), + metadata: Joi.object().optional(), createdAt: Joi.any().strip(), updatedAt: Joi.any().strip(), deletedAt: Joi.any().strip(), diff --git a/src/routes/projectTypes/update.spec.js b/src/routes/projectTypes/update.spec.js index 5e809720..0402020c 100644 --- a/src/routes/projectTypes/update.spec.js +++ b/src/routes/projectTypes/update.spec.js @@ -21,6 +21,7 @@ describe('UPDATE project type', () => { aliases: ['key-1', 'key_1'], disabled: false, hidden: false, + metadata: { 'slack-notification-mappings': { color: '#96d957', label: 'Full App' } }, createdBy: 1, updatedBy: 1, }; @@ -42,6 +43,7 @@ describe('UPDATE project type', () => { aliases: ['key-1-updated', 'key_1_updated'], disabled: true, hidden: true, + metadata: { 'slack-notification-mappings': { color: '#b47dd6', label: 'Full App 2' } }, }, }; @@ -113,6 +115,7 @@ describe('UPDATE project type', () => { delete partialBody.param.aliases; delete partialBody.param.disabled; delete partialBody.param.hidden; + delete partialBody.param.metadata; request(server) .patch(`/v4/projectTypes/${key}`) .set({ @@ -130,6 +133,7 @@ describe('UPDATE project type', () => { resJson.aliases.should.be.eql(type.aliases); resJson.disabled.should.be.eql(type.disabled); resJson.hidden.should.be.eql(type.hidden); + resJson.metadata.should.be.eql(type.metadata); resJson.createdBy.should.be.eql(type.createdBy); resJson.createdBy.should.be.eql(type.createdBy); // should not update createdAt resJson.updatedBy.should.be.eql(40051333); // admin @@ -149,6 +153,7 @@ describe('UPDATE project type', () => { delete partialBody.param.aliases; delete partialBody.param.disabled; delete partialBody.param.hidden; + delete partialBody.param.metadata; request(server) .patch(`/v4/projectTypes/${key}`) .set({ @@ -166,6 +171,7 @@ describe('UPDATE project type', () => { resJson.aliases.should.be.eql(type.aliases); resJson.disabled.should.be.eql(type.disabled); resJson.hidden.should.be.eql(type.hidden); + resJson.metadata.should.be.eql(type.metadata); resJson.createdBy.should.be.eql(type.createdBy); resJson.createdBy.should.be.eql(type.createdBy); // should not update createdAt resJson.updatedBy.should.be.eql(40051333); // admin @@ -185,6 +191,7 @@ describe('UPDATE project type', () => { delete partialBody.param.aliases; delete partialBody.param.disabled; delete partialBody.param.hidden; + delete partialBody.param.metadata; request(server) .patch(`/v4/projectTypes/${key}`) .set({ @@ -202,6 +209,7 @@ describe('UPDATE project type', () => { resJson.aliases.should.be.eql(type.aliases); resJson.disabled.should.be.eql(type.disabled); resJson.hidden.should.be.eql(type.hidden); + resJson.metadata.should.be.eql(type.metadata); resJson.createdBy.should.be.eql(type.createdBy); resJson.createdBy.should.be.eql(type.createdBy); // should not update createdAt resJson.updatedBy.should.be.eql(40051333); // admin @@ -221,6 +229,7 @@ describe('UPDATE project type', () => { delete partialBody.param.aliases; delete partialBody.param.disabled; delete partialBody.param.hidden; + delete partialBody.param.metadata; request(server) .patch(`/v4/projectTypes/${key}`) .set({ @@ -238,6 +247,7 @@ describe('UPDATE project type', () => { resJson.aliases.should.be.eql(type.aliases); resJson.disabled.should.be.eql(type.disabled); resJson.hidden.should.be.eql(type.hidden); + resJson.metadata.should.be.eql(type.metadata); resJson.createdBy.should.be.eql(type.createdBy); resJson.createdBy.should.be.eql(type.createdBy); // should not update createdAt resJson.updatedBy.should.be.eql(40051333); // admin @@ -257,6 +267,7 @@ describe('UPDATE project type', () => { delete partialBody.param.displayName; delete partialBody.param.disabled; delete partialBody.param.hidden; + delete partialBody.param.metadata; request(server) .patch(`/v4/projectTypes/${key}`) .set({ @@ -274,6 +285,7 @@ describe('UPDATE project type', () => { resJson.aliases.should.be.eql(partialBody.param.aliases); resJson.disabled.should.be.eql(type.disabled); resJson.hidden.should.be.eql(type.hidden); + resJson.metadata.should.be.eql(type.metadata); resJson.createdBy.should.be.eql(type.createdBy); resJson.createdBy.should.be.eql(type.createdBy); // should not update createdAt resJson.updatedBy.should.be.eql(40051333); // admin @@ -293,6 +305,7 @@ describe('UPDATE project type', () => { delete partialBody.param.displayName; delete partialBody.param.aliases; delete partialBody.param.hidden; + delete partialBody.param.metadata; request(server) .patch(`/v4/projectTypes/${key}`) .set({ @@ -310,6 +323,7 @@ describe('UPDATE project type', () => { resJson.aliases.should.be.eql(type.aliases); resJson.disabled.should.be.eql(partialBody.param.disabled); resJson.hidden.should.be.eql(type.hidden); + resJson.metadata.should.be.eql(type.metadata); resJson.createdBy.should.be.eql(type.createdBy); resJson.createdBy.should.be.eql(type.createdBy); // should not update createdAt resJson.updatedBy.should.be.eql(40051333); // admin @@ -329,6 +343,7 @@ describe('UPDATE project type', () => { delete partialBody.param.displayName; delete partialBody.param.disabled; delete partialBody.param.aliases; + delete partialBody.param.metadata; request(server) .patch(`/v4/projectTypes/${key}`) .set({ @@ -346,6 +361,44 @@ describe('UPDATE project type', () => { resJson.aliases.should.be.eql(type.aliases); resJson.disabled.should.be.eql(type.disabled); resJson.hidden.should.be.eql(partialBody.param.hidden); + resJson.metadata.should.be.eql(type.metadata); + resJson.createdBy.should.be.eql(type.createdBy); // should not update createdAt + resJson.updatedBy.should.be.eql(40051333); // admin + should.exist(resJson.updatedAt); + should.not.exist(resJson.deletedBy); + should.not.exist(resJson.deletedAt); + + done(); + }); + }); + + it('should return 200 for admin metadata updated', (done) => { + const partialBody = _.cloneDeep(body); + delete partialBody.param.icon; + delete partialBody.param.info; + delete partialBody.param.question; + delete partialBody.param.displayName; + delete partialBody.param.disabled; + delete partialBody.param.aliases; + delete partialBody.param.hidden; + request(server) + .patch(`/v4/projectTypes/${key}`) + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .send(partialBody) + .expect(200) + .end((err, res) => { + const resJson = res.body.result.content; + resJson.key.should.be.eql(key); + resJson.displayName.should.be.eql(type.displayName); + resJson.icon.should.be.eql(type.icon); + resJson.info.should.be.eql(type.info); + resJson.question.should.be.eql(type.question); + resJson.aliases.should.be.eql(type.aliases); + resJson.disabled.should.be.eql(type.disabled); + resJson.hidden.should.be.eql(type.hidden); + resJson.metadata.should.be.eql(partialBody.param.metadata); resJson.createdBy.should.be.eql(type.createdBy); // should not update createdAt resJson.updatedBy.should.be.eql(40051333); // admin should.exist(resJson.updatedAt); @@ -374,6 +427,7 @@ describe('UPDATE project type', () => { resJson.aliases.should.be.eql(body.param.aliases); resJson.disabled.should.be.eql(body.param.disabled); resJson.hidden.should.be.eql(body.param.hidden); + resJson.metadata.should.be.eql(body.param.metadata); resJson.createdBy.should.be.eql(type.createdBy); // should not update createdAt resJson.updatedBy.should.be.eql(40051333); // admin should.exist(resJson.updatedAt); @@ -402,6 +456,7 @@ describe('UPDATE project type', () => { resJson.aliases.should.be.eql(body.param.aliases); resJson.disabled.should.be.eql(body.param.disabled); resJson.hidden.should.be.eql(body.param.hidden); + resJson.metadata.should.be.eql(body.param.metadata); resJson.createdBy.should.be.eql(type.createdBy); // should not update createdAt resJson.updatedBy.should.be.eql(40051336); // connect admin done(); diff --git a/src/routes/projects/create.spec.js b/src/routes/projects/create.spec.js index 9de4e1df..5f12ab42 100644 --- a/src/routes/projects/create.spec.js +++ b/src/routes/projects/create.spec.js @@ -26,6 +26,7 @@ describe('Project create', () => { question: 'question 1', info: 'info 1', aliases: ['key-1', 'key_1'], + metadata: {}, createdBy: 1, updatedBy: 1, }, diff --git a/src/routes/projects/update.spec.js b/src/routes/projects/update.spec.js index 8492d883..42abf4f0 100644 --- a/src/routes/projects/update.spec.js +++ b/src/routes/projects/update.spec.js @@ -30,6 +30,7 @@ describe('Project', () => { aliases: ['key-1', 'key_1'], createdBy: 1, updatedBy: 1, + metadata: {}, }, ])) .then(() => done()); From 3ecc88a86d25d9c4c99f23f2036cc7a1fbc8cb52 Mon Sep 17 00:00:00 2001 From: Vikas Agarwal Date: Mon, 23 Jul 2018 14:01:05 +0530 Subject: [PATCH 09/73] =?UTF-8?q?Github=20issue#120,=20Customer=20cannot?= =?UTF-8?q?=20create=20timelines=20and=20milestones=20=E2=80=94=20Added=20?= =?UTF-8?q?logic=20to=20handle=20the=20product=20as=20reference=20for=20ti?= =?UTF-8?q?meline?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/util.js | 24 +++++++++++++++++++++++- 1 file changed, 23 insertions(+), 1 deletion(-) diff --git a/src/util.js b/src/util.js index 843ff371..20067f0f 100644 --- a/src/util.js +++ b/src/util.js @@ -415,6 +415,28 @@ _.assignIn(util, { }); } + // The timeline refers to a project + if (req.body.param.reference === TIMELINE_REFERENCES.PRODUCT) { + // Validate product to be existed + return models.PhaseProduct.findOne({ + where: { + id: req.body.param.referenceId, + deletedAt: { $eq: null }, + }, + }) + .then((product) => { + if (!product) { + const apiErr = new Error(`Product not found for product id ${req.body.param.referenceId}`); + apiErr.status = 422; + return next(apiErr); + } + + // Set projectId to the params so it can be used in the permission check middleware + req.params.projectId = product.projectId; + return next(); + }); + } + // The timeline refers to a phase return models.ProjectPhase.findOne({ where: { @@ -430,7 +452,7 @@ _.assignIn(util, { } // Set projectId to the params so it can be used in the permission check middleware - req.params.projectId = req.body.param.referenceId; + req.params.projectId = phase.projectId; return next(); }); }, From 3454cc423cfef642c87cb4f8c3e47099474d8efa Mon Sep 17 00:00:00 2001 From: Vikas Agarwal Date: Mon, 23 Jul 2018 16:40:23 +0530 Subject: [PATCH 10/73] Making reference and referenceId compulsory for LIST timelines endpoint --- src/routes/timelines/list.js | 7 ++++ src/routes/timelines/list.spec.js | 59 ++++++++----------------------- 2 files changed, 22 insertions(+), 44 deletions(-) diff --git a/src/routes/timelines/list.js b/src/routes/timelines/list.js index 6d3ff14f..661065b2 100644 --- a/src/routes/timelines/list.js +++ b/src/routes/timelines/list.js @@ -42,6 +42,13 @@ module.exports = [ return next(apiErr); } + // Verify required filters are present + if (!filter.reference || !filter.referenceId) { + const apiErr = new Error('Please provide reference and referenceId filter parameters'); + apiErr.status = 422; + return next(apiErr); + } + // Build the elastic search query const esTerms = []; if (filter.reference) { diff --git a/src/routes/timelines/list.spec.js b/src/routes/timelines/list.spec.js index f903d16c..6f1c8227 100644 --- a/src/routes/timelines/list.spec.js +++ b/src/routes/timelines/list.spec.js @@ -224,7 +224,7 @@ describe('LIST timelines', () => { it('should return 422 for invalid reference filter', (done) => { request(server) - .get('/v4/timelines?filter=reference%3Dinvalid') + .get('/v4/timelines?filter=reference%3Dinvalid%26referenceId%3D1') .set({ Authorization: `Bearer ${testUtil.jwts.connectAdmin}`, }) @@ -234,7 +234,7 @@ describe('LIST timelines', () => { it('should return 422 for invalid referenceId filter', (done) => { request(server) - .get('/v4/timelines?filter=referenceId%3D0') + .get('/v4/timelines?filter=reference%3Dinvalid%26referenceId%3D0') .set({ Authorization: `Bearer ${testUtil.jwts.connectAdmin}`, }) @@ -244,7 +244,7 @@ describe('LIST timelines', () => { it('should return 200 for admin', (done) => { request(server) - .get('/v4/timelines') + .get('/v4/timelines?filter=reference%3Dproject%26referenceId%3D1') .set({ Authorization: `Bearer ${testUtil.jwts.admin}`, }) @@ -253,7 +253,7 @@ describe('LIST timelines', () => { const timeline = timelines[0]; let resJson = res.body.result.content; - resJson.should.have.length(3); + resJson.should.have.length(1); resJson = _.sortBy(resJson, o => o.id); resJson[0].id.should.be.eql(1); resJson[0].name.should.be.eql(timeline.name); @@ -279,14 +279,15 @@ describe('LIST timelines', () => { it('should return 200 for connect admin', (done) => { request(server) - .get('/v4/timelines') + .get('/v4/timelines?filter=reference%3Dproject%26referenceId%3D1') .set({ Authorization: `Bearer ${testUtil.jwts.connectAdmin}`, }) .expect(200) .end((err, res) => { const resJson = res.body.result.content; - resJson.should.have.length(3); + console.log(resJson); + resJson.should.have.length(1); done(); }); @@ -294,14 +295,14 @@ describe('LIST timelines', () => { it('should return 200 for connect manager', (done) => { request(server) - .get('/v4/timelines') + .get('/v4/timelines?filter=reference%3Dproject%26referenceId%3D1') .set({ Authorization: `Bearer ${testUtil.jwts.manager}`, }) .expect(200) .end((err, res) => { const resJson = res.body.result.content; - resJson.should.have.length(3); + resJson.should.have.length(1); done(); }); @@ -309,13 +310,13 @@ describe('LIST timelines', () => { it('should return 200 for member', (done) => { request(server) - .get('/v4/timelines') + .get('/v4/timelines?filter=reference%3Dproject%26referenceId%3D1') .set({ Authorization: `Bearer ${testUtil.jwts.member}`, }) .end((err, res) => { const resJson = res.body.result.content; - resJson.should.have.length(2); + resJson.should.have.length(1); done(); }); @@ -323,21 +324,21 @@ describe('LIST timelines', () => { it('should return 200 for copilot', (done) => { request(server) - .get('/v4/timelines') + .get('/v4/timelines?filter=reference%3Dproject%26referenceId%3D1') .set({ Authorization: `Bearer ${testUtil.jwts.copilot}`, }) .end((err, res) => { const resJson = res.body.result.content; - resJson.should.have.length(2); + resJson.should.have.length(1); done(); }); }); - it('should return 200 for member with no accessible project', (done) => { + it('should return 200 for member with not accessible project', (done) => { request(server) - .get('/v4/timelines') + .get('/v4/timelines?filter=reference%3Dproject%26referenceId%3D1') .set({ Authorization: `Bearer ${testUtil.jwts.member2}`, }) @@ -349,36 +350,6 @@ describe('LIST timelines', () => { }); }); - it('should return 200 with reference filter', (done) => { - request(server) - .get('/v4/timelines?filter=reference%3Dproject') - .set({ - Authorization: `Bearer ${testUtil.jwts.admin}`, - }) - .expect(200) - .end((err, res) => { - const resJson = res.body.result.content; - resJson.should.have.length(1); - - done(); - }); - }); - - it('should return 200 with referenceId filter', (done) => { - request(server) - .get('/v4/timelines?filter=referenceId%3D2') - .set({ - Authorization: `Bearer ${testUtil.jwts.admin}`, - }) - .expect(200) - .end((err, res) => { - const resJson = res.body.result.content; - resJson.should.have.length(1); - - done(); - }); - }); - it('should return 200 with reference and referenceId filter', (done) => { request(server) .get('/v4/timelines?filter=reference%3Dproject%26referenceId%3D1') From 94bf57189ea789280166f129ef893472c04901c1 Mon Sep 17 00:00:00 2001 From: Vikas Agarwal Date: Mon, 23 Jul 2018 17:18:41 +0530 Subject: [PATCH 11/73] Github issue#117, Cannot update `endDate` and `completionDate` of milestone. - Should be fixed now --- src/routes/milestones/update.js | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/src/routes/milestones/update.js b/src/routes/milestones/update.js index a5acf5d2..c8b2d9e3 100644 --- a/src/routes/milestones/update.js +++ b/src/routes/milestones/update.js @@ -24,8 +24,8 @@ const schema = { description: Joi.string().max(255), duration: Joi.number().integer().optional(), startDate: Joi.date().optional(), - endDate: Joi.date().min(Joi.ref('startDate')).allow(null), - completionDate: Joi.date().min(Joi.ref('startDate')).allow(null), + endDate: Joi.date().allow(null), + completionDate: Joi.date().allow(null), status: Joi.string().max(45).optional(), type: Joi.string().max(45).optional(), details: Joi.object(), @@ -68,6 +68,12 @@ module.exports = [ } else if (req.body.param.endDate && req.timeline.endDate && req.body.param.endDate > req.timeline.endDate) { error = 'Milestone endDate must not be after the timeline endDate'; } + if (entityToUpdate.endDate && entityToUpdate.endDate < entityToUpdate.startDate) { + error = 'Milestone endDate must not be before startDate'; + } + if (entityToUpdate.completionDate && entityToUpdate.completionDate < entityToUpdate.startDate) { + error = 'Milestone endDate must not be before startDate'; + } if (error) { const apiErr = new Error(error); apiErr.status = 422; From 827b2252947eb2e3a18f5732ebc09dc7d3187963 Mon Sep 17 00:00:00 2001 From: Vikas Agarwal Date: Tue, 24 Jul 2018 14:48:58 +0530 Subject: [PATCH 12/73] =?UTF-8?q?Github=20issue#108,=20Need=20new=20endpoi?= =?UTF-8?q?nt=20for=20creating=20timeline=20and=20milestones=20together=20?= =?UTF-8?q?=E2=80=94=20Implemented=20the=20functionality=20by=20reading=20?= =?UTF-8?q?the=20templateId=20instead=20of=20reading=20the=20milestones=20?= =?UTF-8?q?in=20the=20request=20body.=20This=20would=20make=20things=20con?= =?UTF-8?q?sistent=20with=20the=20way=20we=20are=20handling=20project=20an?= =?UTF-8?q?d=20phases.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/constants.js | 2 + src/routes/timelines/create.js | 94 ++++++++++--- src/routes/timelines/create.spec.js | 207 ++++++++++++++++++++++++---- 3 files changed, 256 insertions(+), 47 deletions(-) diff --git a/src/constants.js b/src/constants.js index 0a62c215..12696193 100644 --- a/src/constants.js +++ b/src/constants.js @@ -11,6 +11,8 @@ export const PROJECT_STATUS = { export const PROJECT_PHASE_STATUS = PROJECT_STATUS; +export const MILESTONE_STATUS = PROJECT_STATUS; + export const PROJECT_MEMBER_ROLE = { MANAGER: 'manager', CUSTOMER: 'customer', diff --git a/src/routes/timelines/create.js b/src/routes/timelines/create.js index ada7beae..2978ac9c 100644 --- a/src/routes/timelines/create.js +++ b/src/routes/timelines/create.js @@ -4,10 +4,11 @@ import validate from 'express-validation'; import _ from 'lodash'; import Joi from 'joi'; +import moment from 'moment'; import { middleware as tcMiddleware } from 'tc-core-library-js'; import util from '../../util'; import models from '../../models'; -import { EVENT, TIMELINE_REFERENCES } from '../../constants'; +import { EVENT, TIMELINE_REFERENCES, MILESTONE_STATUS } from '../../constants'; const permissions = tcMiddleware.permissions; @@ -21,6 +22,7 @@ const schema = { endDate: Joi.date().min(Joi.ref('startDate')).allow(null), reference: Joi.string().valid(_.values(TIMELINE_REFERENCES)).required(), referenceId: Joi.number().integer().positive().required(), + templateId: Joi.number().integer().min(1).optional(), createdAt: Joi.any().strip(), updatedAt: Joi.any().strip(), deletedAt: Joi.any().strip(), @@ -38,28 +40,82 @@ module.exports = [ util.validateTimelineRequestBody, permissions('timeline.create'), (req, res, next) => { - const entity = _.assign(req.body.param, { + const templateId = req.body.param.templateId; + const entity = _.assign({}, req.body.param, { createdBy: req.authUser.userId, updatedBy: req.authUser.userId, }); + delete entity.templateId; + let result; // Save to DB - return models.Timeline.create(entity) - .then((createdEntity) => { - // Omit deletedAt, deletedBy - const result = _.omit(createdEntity.toJSON(), 'deletedAt', 'deletedBy'); - - // Send event to bus - req.log.debug('Sending event to RabbitMQ bus for timeline %d', result.id); - req.app.services.pubsub.publish(EVENT.ROUTING_KEY.TIMELINE_ADDED, - _.assign({ projectId: req.params.projectId }, result), - { correlationId: req.id }, - ); - - // Write to the response - res.status(201).json(util.wrapResponse(req.id, result, 1, 201)); - return Promise.resolve(); - }) - .catch(next); + models.sequelize.transaction(() => { + req.log.debug('Started transaction'); + return models.Timeline.create(entity) + .then((createdEntity) => { + // Omit deletedAt, deletedBy + result = _.omit(createdEntity.toJSON(), 'deletedAt', 'deletedBy'); + req.log.debug('Checking templateId %d for creating milestones', templateId); + if (templateId) { + req.log.debug('Found templateId, finding milestone templates for the template'); + return models.ProductMilestoneTemplate.findAll({ + where: { + productTemplateId: templateId, + deletedAt: { $eq: null }, + }, + }).then((milestoneTemplates) => { + if (milestoneTemplates) { + req.log.debug('%d MilestoneTemplates found', milestoneTemplates.length); + let startDate = moment.utc(new Date(createdEntity.startDate)); + const milestones = _.map(milestoneTemplates, (mt) => { + const endDate = moment.utc(startDate).add(mt.duration - 1, 'days'); + const milestone = { + name: mt.name, + description: mt.description, + type: mt.type, + duration: mt.duration, + order: mt.order, + plannedText: mt.plannedText, + activeText: mt.activeText, + blockedText: mt.blockedText, + completedText: mt.completedText, + hidden: !!mt.hidden, + details: {}, + status: MILESTONE_STATUS.REVIEWED, + startDate: startDate.format(), + endDate: endDate.format(), + createdBy: req.authUser.userId, + updatedBy: req.authUser.userId, + }; + startDate = endDate.add(1, 'days'); + return milestone; + }); + return models.Milestone.bulkCreate(milestones, { returning: true }) + .then((createdMilestones) => { + req.log.debug('Milestones created for timeline with template id %d', templateId); + result.milestones = _.map(createdMilestones, cm => _.omit(cm.toJSON(), 'deletedAt', 'deletedBy')); + }); + } + // no milestone template found for the template + req.log.debug('no milestone template found for the template id %d', templateId); + return Promise.resolve(); + }); + } + return Promise.resolve(); + }) + .catch(next); + }) + .then(() => { + // Send event to bus + req.log.debug('Sending event to RabbitMQ bus for timeline %d', result.id); + req.app.services.pubsub.publish(EVENT.ROUTING_KEY.TIMELINE_ADDED, + _.assign({ projectId: req.params.projectId }, result), + { correlationId: req.id }, + ); + // Write to the response + res.status(201).json(util.wrapResponse(req.id, result, 1, 201)); + return Promise.resolve(); + }) + .catch(next); }, ]; diff --git a/src/routes/timelines/create.spec.js b/src/routes/timelines/create.spec.js index 10e3adbe..ee6fd1cf 100644 --- a/src/routes/timelines/create.spec.js +++ b/src/routes/timelines/create.spec.js @@ -2,15 +2,105 @@ * Tests for create.js */ import chai from 'chai'; +import moment from 'moment'; import request from 'supertest'; import _ from 'lodash'; import server from '../../app'; import testUtil from '../../tests/util'; import models from '../../models'; -import { EVENT } from '../../constants'; +import { EVENT, MILESTONE_STATUS } from '../../constants'; const should = chai.should(); +const testProjects = [ + { + type: 'generic', + billingAccountId: 1, + name: 'test1', + description: 'test project1', + status: 'draft', + details: {}, + createdBy: 1, + updatedBy: 1, + }, + { + type: 'generic', + billingAccountId: 2, + name: 'test2', + description: 'test project2', + status: 'draft', + details: {}, + createdBy: 2, + updatedBy: 2, + deletedAt: '2018-05-15T00:00:00Z', + }, +]; + +const productTemplates = [ + { + name: 'name 1', + productKey: 'productKey 1', + icon: 'http://example.com/icon1.ico', + brief: 'brief 1', + details: 'details 1', + aliases: ['name-1'], + template: { }, + createdBy: 1, + updatedBy: 2, + }, +]; +const milestoneTemplates = [ + { + id: 1, + name: 'milestoneTemplate 1', + description: 'description 1', + duration: 3, + type: 'type1', + order: 1, + plannedText: 'text to be shown in planned stage', + blockedText: 'text to be shown in blocked stage', + activeText: 'text to be shown in active stage', + completedText: 'text to be shown in completed stage', + productTemplateId: 1, + createdBy: 1, + updatedBy: 2, + hidden: false, + }, + { + id: 2, + name: 'milestoneTemplate 2', + description: 'description 2', + duration: 4, + type: 'type2', + order: 2, + plannedText: 'text to be shown in planned stage - 2', + blockedText: 'text to be shown in blocked stage - 2', + activeText: 'text to be shown in active stage - 2', + completedText: 'text to be shown in completed stage - 2', + productTemplateId: 1, + createdBy: 2, + updatedBy: 3, + hidden: false, + }, + { + id: 3, + name: 'milestoneTemplate 3', + description: 'description 3', + duration: 5, + type: 'type3', + order: 3, + plannedText: 'text to be shown in planned stage - 3', + blockedText: 'text to be shown in blocked stage - 3', + activeText: 'text to be shown in active stage - 3', + completedText: 'text to be shown in completed stage - 3', + productTemplateId: 1, + createdBy: 2, + updatedBy: 3, + hidden: false, + deletedAt: new Date(), + }, +]; + describe('CREATE timeline', () => { let projectId1; let projectId2; @@ -18,29 +108,7 @@ describe('CREATE timeline', () => { before((done) => { testUtil.clearDb() .then(() => { - models.Project.bulkCreate([ - { - type: 'generic', - billingAccountId: 1, - name: 'test1', - description: 'test project1', - status: 'draft', - details: {}, - createdBy: 1, - updatedBy: 1, - }, - { - type: 'generic', - billingAccountId: 2, - name: 'test2', - description: 'test project2', - status: 'draft', - details: {}, - createdBy: 2, - updatedBy: 2, - deletedAt: '2018-05-15T00:00:00Z', - }, - ], { returning: true }) + models.Project.bulkCreate(testProjects, { returning: true }) .then((projects) => { projectId1 = projects[0].id; projectId2 = projects[1].id; @@ -95,11 +163,13 @@ describe('CREATE timeline', () => { updatedBy: 2, deletedAt: '2018-05-15T00:00:00Z', }, - ])) - .then(() => { - done(); - }); + ])); }); + }) + .then(() => models.ProductTemplate.bulkCreate(productTemplates)) + .then(() => models.ProductMilestoneTemplate.bulkCreate(milestoneTemplates)) + .then(() => { + done(); }); }); @@ -374,6 +444,87 @@ describe('CREATE timeline', () => { }); }); + it('should return 201 for admin (with milestones)', (done) => { + const withMilestones = _.cloneDeep(body); + withMilestones.param.templateId = 1; + request(server) + .post('/v4/timelines') + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .send(withMilestones) + .expect('Content-Type', /json/) + .expect(201) + .end((err, res) => { + const resJson = res.body.result.content; + should.exist(resJson.id); + resJson.name.should.be.eql(body.param.name); + resJson.description.should.be.eql(body.param.description); + resJson.startDate.should.be.eql(body.param.startDate); + resJson.endDate.should.be.eql(body.param.endDate); + resJson.reference.should.be.eql(body.param.reference); + resJson.referenceId.should.be.eql(body.param.referenceId); + + resJson.createdBy.should.be.eql(40051333); // admin + should.exist(resJson.createdAt); + resJson.updatedBy.should.be.eql(40051333); // admin + should.exist(resJson.updatedAt); + should.not.exist(resJson.deletedBy); + should.not.exist(resJson.deletedAt); + + const milestones = resJson.milestones; + milestones.forEach((milestone, mIdx) => { + should.exist(milestone.id); + let expMilestoneTemplate; + if (mIdx === 0) { + expMilestoneTemplate = _.find(milestoneTemplates, mt => mt.id === 1); + } else if (mIdx === 1) { + expMilestoneTemplate = _.find(milestoneTemplates, mt => mt.id === 2); + } + milestone.name.should.be.eql(expMilestoneTemplate.name); + milestone.description.should.be.eql(expMilestoneTemplate.description); + milestone.duration.should.be.eql(expMilestoneTemplate.duration); + // expected number of days, for starting the milestone, from the timeline start + let expDaysFromTimelineStart = 0; + _.each(milestoneTemplates, (mt, idx) => { + expDaysFromTimelineStart += (idx < mIdx ? mt.duration : 0); + }); + // calculates expected start date of the milestone + const expMilestoneStartDate = moment.utc(resJson.startDate).add(expDaysFromTimelineStart, 'days'); + // milestone created should have the expected start date + expMilestoneStartDate.diff(moment.utc(milestone.startDate), 'days').should.be.eql(0); + // calculates expected end date of the milestone + const expMilestoneEndDate = moment.utc(milestone.startDate).add(expMilestoneTemplate.duration - 1, 'days'); + // milestone created should have the expected end date + expMilestoneEndDate.diff(moment.utc(milestone.endDate), 'days').should.be.eql(0); + // completionDate should not be set yet + should.not.exist(milestone.completionDate); + // status should be reviewed for new milestones + milestone.status.should.be.eql(MILESTONE_STATUS.REVIEWED); + milestone.type.should.be.eql(expMilestoneTemplate.type); + milestone.details.should.be.eql({}); + milestone.order.should.be.eql(expMilestoneTemplate.order); + milestone.plannedText.should.be.eql(expMilestoneTemplate.plannedText); + milestone.activeText.should.be.eql(expMilestoneTemplate.activeText); + milestone.completedText.should.be.eql(expMilestoneTemplate.completedText); + milestone.blockedText.should.be.eql(expMilestoneTemplate.blockedText); + milestone.hidden.should.be.eql(expMilestoneTemplate.hidden); + + milestone.createdBy.should.be.eql(40051333); // admin + should.exist(milestone.createdAt); + milestone.updatedBy.should.be.eql(40051333); // admin + should.exist(milestone.updatedAt); + should.not.exist(milestone.deletedBy); + should.not.exist(milestone.deletedAt); + }); + + // eslint-disable-next-line no-unused-expressions + server.services.pubsub.publish.calledWith(EVENT.ROUTING_KEY.TIMELINE_ADDED).should.be.true; + + done(); + }); + }); + it('should return 201 for connect manager', (done) => { request(server) .post('/v4/timelines') From fe3b54c760cf179b879e979f91a2ab3bfdcd7459 Mon Sep 17 00:00:00 2001 From: Vikas Agarwal Date: Tue, 24 Jul 2018 17:58:03 +0530 Subject: [PATCH 13/73] =?UTF-8?q?Github=20issue#Customer=20cannot=20create?= =?UTF-8?q?=20timelines=20and=20milestones=20=E2=80=94=20Fixed=20permissio?= =?UTF-8?q?n=20logic=20for=20milestones?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/util.js | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/src/util.js b/src/util.js index 20067f0f..02cf4526 100644 --- a/src/util.js +++ b/src/util.js @@ -485,6 +485,28 @@ _.assignIn(util, { return next(); } + // The timeline refers to a project + if (timeline.reference === TIMELINE_REFERENCES.PRODUCT) { + // Validate product to be existed + return models.PhaseProduct.findOne({ + where: { + id: timeline.referenceId, + deletedAt: { $eq: null }, + }, + }) + .then((product) => { + if (!product) { + const apiErr = new Error(`Product not found for product id ${timeline.referenceId}`); + apiErr.status = 422; + return next(apiErr); + } + + // Set projectId to the params so it can be used in the permission check middleware + req.params.projectId = product.projectId; + return next(); + }); + } + // The timeline refers to a phase return models.ProjectPhase.findOne({ where: { From 7fca5a71028f4251edc9ec02326d56cff88b1ac2 Mon Sep 17 00:00:00 2001 From: Vikas Agarwal Date: Thu, 26 Jul 2018 12:16:43 +0530 Subject: [PATCH 14/73] Fixing wrong handling of transactions. HTTP response was being sent before transaction commit which is causing significant extra time for the transaction to complete which in turn causing other operations of the same table to fail because of table locks. --- src/routes/phases/create.js | 32 +++++++++++++++++--------------- src/routes/phases/delete.js | 5 +++-- src/routes/phases/update.js | 6 ++++-- 3 files changed, 24 insertions(+), 19 deletions(-) diff --git a/src/routes/phases/create.js b/src/routes/phases/create.js index f4a7a9b6..bde85c87 100644 --- a/src/routes/phases/create.js +++ b/src/routes/phases/create.js @@ -41,9 +41,9 @@ module.exports = [ updatedBy: req.authUser.userId, }); + let newProjectPhase = null; models.sequelize.transaction(() => { - let newProjectPhase = null; - + req.log.debug('Create Phase - Starting transaction'); return models.Project.findOne({ where: { id: projectId, deletedAt: { $eq: null } }, }).then((existingProject) => { @@ -61,21 +61,23 @@ module.exports = [ newProjectPhase = newProjectPhase.get({ plain: true }); newProjectPhase = _.omit(newProjectPhase, ['deletedAt', 'deletedBy', 'utm']); - - // Send events to buses - req.log.debug('Sending event to RabbitMQ bus for project phase %d', newProjectPhase.id); - req.app.services.pubsub.publish(EVENT.ROUTING_KEY.PROJECT_PHASE_ADDED, - newProjectPhase, - { correlationId: req.id }, - ); - req.log.debug('Sending event to Kafka bus for project phase %d', newProjectPhase.id); - req.app.emit(EVENT.ROUTING_KEY.PROJECT_PHASE_ADDED, { req, created: newProjectPhase }); - - res.status(201).json(util.wrapResponse(req.id, newProjectPhase, 1, 201)); }); - }).catch((err) => { - util.handleError('Error creating project phase', err, req, next); }); + }) + .then(() => { + // Send events to buses + req.log.debug('Sending event to RabbitMQ bus for project phase %d', newProjectPhase.id); + req.app.services.pubsub.publish(EVENT.ROUTING_KEY.PROJECT_PHASE_ADDED, + newProjectPhase, + { correlationId: req.id }, + ); + req.log.debug('Sending event to Kafka bus for project phase %d', newProjectPhase.id); + req.app.emit(EVENT.ROUTING_KEY.PROJECT_PHASE_ADDED, { req, created: newProjectPhase }); + + res.status(201).json(util.wrapResponse(req.id, newProjectPhase, 1, 201)); + }) + .catch((err) => { + util.handleError('Error creating project phase', err, req, next); }); }, diff --git a/src/routes/phases/delete.js b/src/routes/phases/delete.js index 3bc34012..5ba2461d 100644 --- a/src/routes/phases/delete.js +++ b/src/routes/phases/delete.js @@ -34,7 +34,8 @@ module.exports = [ _.extend(existing, { deletedBy: req.authUser.userId, deletedAt: Date.now() }); existing.save().then(accept).catch(reject); } - })).then((deleted) => { + }))) + .then((deleted) => { req.log.debug('deleted project phase', JSON.stringify(deleted, null, 2)); // Send events to buses @@ -46,7 +47,7 @@ module.exports = [ req.app.emit(EVENT.ROUTING_KEY.PROJECT_PHASE_REMOVED, { req, deleted }); res.status(204).json({}); - }).catch(err => next(err))); + }).catch(err => next(err)); }, ]; diff --git a/src/routes/phases/update.js b/src/routes/phases/update.js index 861a459f..389157f2 100644 --- a/src/routes/phases/update.js +++ b/src/routes/phases/update.js @@ -82,7 +82,8 @@ module.exports = [ existing.save().then(accept).catch(reject); } } - })).then((updated) => { + }))) + .then((updated) => { req.log.debug('updated project phase', JSON.stringify(updated, null, 2)); // emit original and updated project phase information @@ -95,6 +96,7 @@ module.exports = [ { req, original: previousValue, updated }); res.json(util.wrapResponse(req.id, updated)); - }).catch(err => next(err))); + }) + .catch(err => next(err)); }, ]; From 46673e8d7ddf86093a13db5dde46fbdc420905d1 Mon Sep 17 00:00:00 2001 From: Vikas Agarwal Date: Thu, 26 Jul 2018 12:19:13 +0530 Subject: [PATCH 15/73] d wrong handling of transaction for delete operation on milestones. Create and Update were fixed previously. --- src/routes/milestones/delete.js | 28 ++++++++++++++-------------- 1 file changed, 14 insertions(+), 14 deletions(-) diff --git a/src/routes/milestones/delete.js b/src/routes/milestones/delete.js index f7074cc0..e59464eb 100644 --- a/src/routes/milestones/delete.js +++ b/src/routes/milestones/delete.js @@ -46,20 +46,20 @@ module.exports = [ // Update the deletedBy, and soft delete return milestone.update({ deletedBy: req.authUser.userId }, { transaction: tx }) .then(() => milestone.destroy({ transaction: tx })); - }) - .then((deleted) => { - // Send event to bus - req.log.debug('Sending event to RabbitMQ bus for milestone %d', deleted.id); - req.app.services.pubsub.publish(EVENT.ROUTING_KEY.MILESTONE_REMOVED, - deleted, - { correlationId: req.id }, - ); + }), + ) + .then((deleted) => { + // Send event to bus + req.log.debug('Sending event to RabbitMQ bus for milestone %d', deleted.id); + req.app.services.pubsub.publish(EVENT.ROUTING_KEY.MILESTONE_REMOVED, + deleted, + { correlationId: req.id }, + ); - // Write to response - res.status(204).end(); - return Promise.resolve(); - }) - .catch(next), - ); + // Write to response + res.status(204).end(); + return Promise.resolve(); + }) + .catch(next); }, ]; From 387aa4b9c3cc0ff6a28ff9086920f85244f72dcb Mon Sep 17 00:00:00 2001 From: Vikas Agarwal Date: Thu, 26 Jul 2018 12:26:48 +0530 Subject: [PATCH 16/73] Fixing wrong handling of transactions in phaseProduct model. --- src/routes/phaseProducts/create.js | 28 +++++++++++++++------------- src/routes/phaseProducts/delete.js | 5 +++-- src/routes/phaseProducts/update.js | 5 +++-- 3 files changed, 21 insertions(+), 17 deletions(-) diff --git a/src/routes/phaseProducts/create.js b/src/routes/phaseProducts/create.js index 47ba2825..10887006 100644 --- a/src/routes/phaseProducts/create.js +++ b/src/routes/phaseProducts/create.js @@ -91,25 +91,27 @@ module.exports = [ err.status = 400; throw err; } - return models.PhaseProduct.create(data); - }) + return models.PhaseProduct.create(data) .then((_newPhaseProduct) => { newPhaseProduct = _.cloneDeep(_newPhaseProduct); req.log.debug('new phase product created (id# %d, name: %s)', newPhaseProduct.id, newPhaseProduct.name); newPhaseProduct = newPhaseProduct.get({ plain: true }); newPhaseProduct = _.omit(newPhaseProduct, ['deletedAt', 'utm']); + }); + })) + .then(() => { + // Send events to buses + req.log.debug('Sending event to RabbitMQ bus for phase product %d', newPhaseProduct.id); + req.app.services.pubsub.publish(EVENT.ROUTING_KEY.PROJECT_PHASE_PRODUCT_ADDED, + newPhaseProduct, + { correlationId: req.id }, + ); + req.log.debug('Sending event to Kafka bus for phase product %d', newPhaseProduct.id); + req.app.emit(EVENT.ROUTING_KEY.PROJECT_PHASE_PRODUCT_ADDED, { req, created: newPhaseProduct }); - // Send events to buses - req.log.debug('Sending event to RabbitMQ bus for phase product %d', newPhaseProduct.id); - req.app.services.pubsub.publish(EVENT.ROUTING_KEY.PROJECT_PHASE_PRODUCT_ADDED, - newPhaseProduct, - { correlationId: req.id }, - ); - req.log.debug('Sending event to Kafka bus for phase product %d', newPhaseProduct.id); - req.app.emit(EVENT.ROUTING_KEY.PROJECT_PHASE_PRODUCT_ADDED, { req, created: newPhaseProduct }); - - res.status(201).json(util.wrapResponse(req.id, newPhaseProduct, 1, 201)); - })).catch((err) => { next(err); }); + res.status(201).json(util.wrapResponse(req.id, newPhaseProduct, 1, 201)); + }) + .catch((err) => { next(err); }); }, ]; diff --git a/src/routes/phaseProducts/delete.js b/src/routes/phaseProducts/delete.js index 2faa6295..2bc50b7f 100644 --- a/src/routes/phaseProducts/delete.js +++ b/src/routes/phaseProducts/delete.js @@ -36,7 +36,8 @@ module.exports = [ _.extend(existing, { deletedBy: req.authUser.userId, deletedAt: Date.now() }); existing.save().then(accept).catch(reject); } - })).then((deleted) => { + }))) + .then((deleted) => { req.log.debug('deleted phase product', JSON.stringify(deleted, null, 2)); // Send events to buses @@ -48,6 +49,6 @@ module.exports = [ req.app.emit(EVENT.ROUTING_KEY.PROJECT_PHASE_PRODUCT_REMOVED, { req, deleted }); res.status(204).json({}); - }).catch(err => next(err))); + }).catch(err => next(err)); }, ]; diff --git a/src/routes/phaseProducts/update.js b/src/routes/phaseProducts/update.js index fa7ac460..e129b316 100644 --- a/src/routes/phaseProducts/update.js +++ b/src/routes/phaseProducts/update.js @@ -62,7 +62,8 @@ module.exports = [ _.extend(existing, updatedProps); existing.save().then(accept).catch(reject); } - })).then((updated) => { + }))) + .then((updated) => { req.log.debug('updated phase product', JSON.stringify(updated, null, 2)); const updatedValue = updated.get({ plain: true }); @@ -77,6 +78,6 @@ module.exports = [ { req, original: previousValue, updated: updatedValue }); res.json(util.wrapResponse(req.id, updated)); - }).catch(err => next(err))); + }).catch(err => next(err)); }, ]; From 89884ac531d443da2d917390ec2fbc4ece722720 Mon Sep 17 00:00:00 2001 From: Vikas Agarwal Date: Thu, 26 Jul 2018 12:29:43 +0530 Subject: [PATCH 17/73] Fixing wrong handling of transactions in timelines model. Create was fixed before and update is not using transactions as of now which is a separate issue to fix. --- src/routes/timelines/delete.js | 28 ++++++++++++++-------------- 1 file changed, 14 insertions(+), 14 deletions(-) diff --git a/src/routes/timelines/delete.js b/src/routes/timelines/delete.js index e3d94bb7..470b8936 100644 --- a/src/routes/timelines/delete.js +++ b/src/routes/timelines/delete.js @@ -33,20 +33,20 @@ module.exports = [ .then(() => timeline.destroy()) // Cascade delete the milestones .then(() => models.Milestone.update({ deletedBy: req.authUser.userId }, { where: { timelineId: timeline.id } })) - .then(() => models.Milestone.destroy({ where: { timelineId: timeline.id } })) - .then(() => { - // Send event to bus - req.log.debug('Sending event to RabbitMQ bus for timeline %d', deleted.id); - req.app.services.pubsub.publish(EVENT.ROUTING_KEY.TIMELINE_REMOVED, - deleted, - { correlationId: req.id }, - ); + .then(() => models.Milestone.destroy({ where: { timelineId: timeline.id } })), + ) + .then(() => { + // Send event to bus + req.log.debug('Sending event to RabbitMQ bus for timeline %d', deleted.id); + req.app.services.pubsub.publish(EVENT.ROUTING_KEY.TIMELINE_REMOVED, + deleted, + { correlationId: req.id }, + ); - // Write to response - res.status(204).end(); - return Promise.resolve(); - }) - .catch(next), - ); + // Write to response + res.status(204).end(); + return Promise.resolve(); + }) + .catch(next); }, ]; From 673c6215dd2fc27cd7e0f12f4548f85d870a0d59 Mon Sep 17 00:00:00 2001 From: Vikas Agarwal Date: Thu, 26 Jul 2018 12:52:18 +0530 Subject: [PATCH 18/73] Fixing wrong handling of transactions in projects model. --- src/routes/projects/create.js | 51 ++++++++++++++++++----------------- src/routes/projects/delete.js | 37 ++++++++++++------------- src/routes/projects/update.js | 6 ++--- 3 files changed, 48 insertions(+), 46 deletions(-) diff --git a/src/routes/projects/create.js b/src/routes/projects/create.js index da3e9e5d..d291a190 100644 --- a/src/routes/projects/create.js +++ b/src/routes/projects/create.js @@ -242,9 +242,10 @@ module.exports = [ if (!project.templateId) { project.version = 'v2'; } + let newProject = null; + let newPhases; models.sequelize.transaction(() => { - let newProject = null; - let newPhases; + req.log.debug('Create Project - Starting transaction'); // Validate the project type return validateProjectType(project.type) // Validate the templates @@ -294,30 +295,30 @@ module.exports = [ return Promise.resolve(); }); // return Promise.resolve(); - }) - .then(() => { - newProject = newProject.get({ plain: true }); - // remove utm details & deletedAt field - newProject = _.omit(newProject, ['deletedAt', 'utm']); - // add an empty attachments array - newProject.attachments = []; - // set phases array - newProject.phases = newPhases; - - req.log.debug('Sending event to RabbitMQ bus for project %d', newProject.id); - req.app.services.pubsub.publish(EVENT.ROUTING_KEY.PROJECT_DRAFT_CREATED, - newProject, - { correlationId: req.id }, - ); - req.log.debug('Sending event to Kafka bus for project %d', newProject.id); - // emit event - req.app.emit(EVENT.ROUTING_KEY.PROJECT_DRAFT_CREATED, { req, project: newProject }); - res.status(201).json(util.wrapResponse(req.id, newProject, 1, 201)); - }) - .catch((err) => { - req.log.error(err.message); - util.handleError('Error creating project', err, req, next); }); + }) + .then(() => { + newProject = newProject.get({ plain: true }); + // remove utm details & deletedAt field + newProject = _.omit(newProject, ['deletedAt', 'utm']); + // add an empty attachments array + newProject.attachments = []; + // set phases array + newProject.phases = newPhases; + + req.log.debug('Sending event to RabbitMQ bus for project %d', newProject.id); + req.app.services.pubsub.publish(EVENT.ROUTING_KEY.PROJECT_DRAFT_CREATED, + newProject, + { correlationId: req.id }, + ); + req.log.debug('Sending event to Kafka bus for project %d', newProject.id); + // emit event + req.app.emit(EVENT.ROUTING_KEY.PROJECT_DRAFT_CREATED, { req, project: newProject }); + res.status(201).json(util.wrapResponse(req.id, newProject, 1, 201)); + }) + .catch((err) => { + req.log.error(err.message); + util.handleError('Error creating project', err, req, next); }); }, ]; diff --git a/src/routes/projects/delete.js b/src/routes/projects/delete.js index a2ea2b19..6b23d486 100644 --- a/src/routes/projects/delete.js +++ b/src/routes/projects/delete.js @@ -22,23 +22,24 @@ module.exports = [ where: { id: projectId }, cascade: true, transaction: t, - }) - .then((count) => { - if (count === 0) { - const err = new Error('Project not found'); - err.status = 404; - next(err); - } else { - req.app.services.pubsub.publish( - EVENT.ROUTING_KEY.PROJECT_DELETED, - { id: projectId }, - { correlationId: req.id }, - ); - // emit event - req.app.emit(EVENT.ROUTING_KEY.PROJECT_DELETED, { req, id: projectId }); - res.status(204).json({}); - } - }) - .catch(err => next(err))); + }), + ) + .then((count) => { + if (count === 0) { + const err = new Error('Project not found'); + err.status = 404; + next(err); + } else { + req.app.services.pubsub.publish( + EVENT.ROUTING_KEY.PROJECT_DELETED, + { id: projectId }, + { correlationId: req.id }, + ); + // emit event + req.app.emit(EVENT.ROUTING_KEY.PROJECT_DELETED, { req, id: projectId }); + res.status(204).json({}); + } + }) + .catch(err => next(err)); }, ]; diff --git a/src/routes/projects/update.js b/src/routes/projects/update.js index fc17b451..157d33ae 100644 --- a/src/routes/projects/update.js +++ b/src/routes/projects/update.js @@ -228,8 +228,8 @@ module.exports = [ } else { accept(); } - })) - .then(() => { + }))) + .then(() => { // transaction has been committed project = project.get({ plain: true }); project = _.omit(project, ['deletedAt']); req.log.debug('updated project', project); @@ -252,7 +252,7 @@ module.exports = [ project.members = req.context.currentProjectMembers; // get attachments return util.getProjectAttachments(req, project.id); - })) + }) .then((attachments) => { // make sure we only send response after transaction is committed project.attachments = attachments; From bd39e239a35f4db26894e0750cc028b72f86ddc0 Mon Sep 17 00:00:00 2001 From: Vikas Agarwal Date: Thu, 26 Jul 2018 15:46:33 +0530 Subject: [PATCH 19/73] Fixing missing timelineId field for milestone --- src/routes/timelines/create.js | 1 + src/routes/timelines/create.spec.js | 1 + 2 files changed, 2 insertions(+) diff --git a/src/routes/timelines/create.js b/src/routes/timelines/create.js index 2978ac9c..4705f0d3 100644 --- a/src/routes/timelines/create.js +++ b/src/routes/timelines/create.js @@ -70,6 +70,7 @@ module.exports = [ const milestones = _.map(milestoneTemplates, (mt) => { const endDate = moment.utc(startDate).add(mt.duration - 1, 'days'); const milestone = { + timelineId: createdEntity.id, name: mt.name, description: mt.description, type: mt.type, diff --git a/src/routes/timelines/create.spec.js b/src/routes/timelines/create.spec.js index ee6fd1cf..c6dc5df9 100644 --- a/src/routes/timelines/create.spec.js +++ b/src/routes/timelines/create.spec.js @@ -481,6 +481,7 @@ describe('CREATE timeline', () => { } else if (mIdx === 1) { expMilestoneTemplate = _.find(milestoneTemplates, mt => mt.id === 2); } + milestone.templateId.should.be.eql(resJson.id); milestone.name.should.be.eql(expMilestoneTemplate.name); milestone.description.should.be.eql(expMilestoneTemplate.description); milestone.duration.should.be.eql(expMilestoneTemplate.duration); From aa61e23730702685504af3481845a49e647c6d7a Mon Sep 17 00:00:00 2001 From: Vikas Agarwal Date: Thu, 26 Jul 2018 16:23:13 +0530 Subject: [PATCH 20/73] fixed unit tests --- src/routes/timelines/create.spec.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/routes/timelines/create.spec.js b/src/routes/timelines/create.spec.js index c6dc5df9..d082bb20 100644 --- a/src/routes/timelines/create.spec.js +++ b/src/routes/timelines/create.spec.js @@ -481,7 +481,7 @@ describe('CREATE timeline', () => { } else if (mIdx === 1) { expMilestoneTemplate = _.find(milestoneTemplates, mt => mt.id === 2); } - milestone.templateId.should.be.eql(resJson.id); + milestone.timelineId.should.be.eql(resJson.id); milestone.name.should.be.eql(expMilestoneTemplate.name); milestone.description.should.be.eql(expMilestoneTemplate.description); milestone.duration.should.be.eql(expMilestoneTemplate.duration); From a2cced237fc6f3519f2389ab59dfa40acc96386d Mon Sep 17 00:00:00 2001 From: Paulo Vitor Magacho da Silva Date: Fri, 27 Jul 2018 21:15:18 -0300 Subject: [PATCH 21/73] Fix for issues in https://github.com/topcoder-platform/tc-project-service/labels/community-task --- migrations/20180727_product_categories.sql | 35 ++ postman.json | 202 ++++++++- src/middlewares/fieldLookupValidation.js | 34 ++ src/middlewares/validateTimeline.js | 166 ++++++++ src/models/productCategory.js | 31 ++ src/models/productTemplate.js | 1 + src/permissions/index.js | 5 + src/routes/index.js | 16 +- src/routes/milestoneTemplates/create.spec.js | 2 + src/routes/milestoneTemplates/delete.spec.js | 2 + src/routes/milestoneTemplates/get.spec.js | 2 + src/routes/milestoneTemplates/list.spec.js | 2 + src/routes/milestoneTemplates/update.spec.js | 2 + src/routes/milestones/create.js | 3 +- src/routes/milestones/delete.js | 4 +- src/routes/milestones/get.js | 3 +- src/routes/milestones/list.js | 3 +- src/routes/milestones/update.js | 3 +- src/routes/productCategories/create.js | 61 +++ src/routes/productCategories/create.spec.js | 222 ++++++++++ src/routes/productCategories/delete.js | 54 +++ src/routes/productCategories/delete.spec.js | 103 +++++ src/routes/productCategories/get.js | 39 ++ src/routes/productCategories/get.spec.js | 129 ++++++ src/routes/productCategories/list.js | 20 + src/routes/productCategories/list.spec.js | 124 ++++++ src/routes/productCategories/update.js | 67 +++ src/routes/productCategories/update.spec.js | 411 +++++++++++++++++++ src/routes/productTemplates/create.js | 3 + src/routes/productTemplates/create.spec.js | 47 +++ src/routes/productTemplates/delete.spec.js | 1 + src/routes/productTemplates/get.spec.js | 2 + src/routes/productTemplates/list.spec.js | 5 +- src/routes/productTemplates/update.js | 3 + src/routes/productTemplates/update.spec.js | 25 ++ src/routes/projectTemplates/create.js | 2 + src/routes/projectTemplates/create.spec.js | 47 ++- src/routes/projectTemplates/update.js | 2 + src/routes/projectTemplates/update.spec.js | 26 +- src/routes/projectUpgrade/create.spec.js | 1 + src/routes/projects/create.spec.js | 3 + src/routes/timelines/create.js | 3 +- src/routes/timelines/create.spec.js | 1 + src/routes/timelines/delete.js | 4 +- src/routes/timelines/get.js | 3 +- src/routes/timelines/list.js | 77 +--- src/routes/timelines/list.spec.js | 9 +- src/routes/timelines/update.js | 5 +- src/tests/seed.js | 5 + src/util.js | 142 +------ swagger.yaml | 263 +++++++++++- 51 files changed, 2190 insertions(+), 235 deletions(-) create mode 100644 migrations/20180727_product_categories.sql create mode 100644 src/middlewares/fieldLookupValidation.js create mode 100644 src/middlewares/validateTimeline.js create mode 100644 src/models/productCategory.js create mode 100644 src/routes/productCategories/create.js create mode 100644 src/routes/productCategories/create.spec.js create mode 100644 src/routes/productCategories/delete.js create mode 100644 src/routes/productCategories/delete.spec.js create mode 100644 src/routes/productCategories/get.js create mode 100644 src/routes/productCategories/get.spec.js create mode 100644 src/routes/productCategories/list.js create mode 100644 src/routes/productCategories/list.spec.js create mode 100644 src/routes/productCategories/update.js create mode 100644 src/routes/productCategories/update.spec.js diff --git a/migrations/20180727_product_categories.sql b/migrations/20180727_product_categories.sql new file mode 100644 index 00000000..e53f912a --- /dev/null +++ b/migrations/20180727_product_categories.sql @@ -0,0 +1,35 @@ +-- UPDATE EXISTING TABLES: +-- product_templates +-- category column: added +-- CREATE NEW TABLE: +-- product_categories + +-- +-- product_categories +-- +CREATE TABLE product_categories ( + key character varying(45) NOT NULL, + "displayName" character varying(255) NOT NULL, + "icon" character varying(255) NOT NULL, + "info" character varying(255) NOT NULL, + "question" character varying(255) NOT NULL, + "aliases" json NOT NULL, + "hidden" boolean DEFAULT false, + "disabled" boolean DEFAULT false, + "deletedAt" timestamp with time zone, + "createdAt" timestamp with time zone, + "updatedAt" timestamp with time zone, + "deletedBy" integer, + "createdBy" integer NOT NULL, + "updatedBy" integer NOT NULL +); + +ALTER TABLE ONLY product_categories + ADD CONSTRAINT product_categories_pkey PRIMARY KEY (key); + +-- +-- product_templates +-- +ALTER TABLE product_templates ADD COLUMN "category" character varying(45); +UPDATE product_templates set category='generic' where category is null; +ALTER TABLE product_templates ALTER COLUMN "generic" SET NOT NULL; diff --git a/postman.json b/postman.json index 048cea72..fda31b5e 100644 --- a/postman.json +++ b/postman.json @@ -2530,7 +2530,7 @@ ], "body": { "mode": "raw", - "raw": "{\r\n \"param\":{\r\n \"name\":\"new name\",\r\n \"productKey\":\"new productKey\",\r\n \"icon\":\"http://example.com/icon-new.ico\",\r\n \"brief\": \"new brief\",\r\n \"details\": \"new details\",\r\n \"aliases\":{\r\n \"alias1\":\"alias 1\"\r\n },\r\n \"template\":{\r\n \"template1\":\"template 1\"\r\n }\r\n }\r\n}" + "raw": "{\r\n \"param\":{\r\n \"name\":\"new name\",\r\n \"productKey\":\"new productKey\",\r\n \"category\":\"generic\",\r\n \"icon\":\"http://example.com/icon-new.ico\",\r\n \"brief\": \"new brief\",\r\n \"details\": \"new details\",\r\n \"aliases\":{\r\n \"alias1\":\"alias 1\"\r\n },\r\n \"template\":{\r\n \"template1\":\"template 1\"\r\n }\r\n }\r\n}" }, "url": { "raw": "{{api-url}}/v4/productTemplates", @@ -2624,7 +2624,7 @@ ], "body": { "mode": "raw", - "raw": "{\r\n \"param\":{\r\n \"name\":\"new name\",\r\n \"productKey\":\"new productKey\",\r\n \"icon\":\"http://example.com/icon-new.ico\",\r\n \"brief\": \"new brief\",\r\n \"details\": \"new details\",\r\n \"aliases\":{\r\n \"alias1\":\"scope 1\",\r\n \"alias2\": [\"a\"]\r\n },\r\n \"template\":{\r\n \"template1\":\"template 1\",\r\n \"template2\": {\r\n \t\"another\": \"another\"\r\n }\r\n }\r\n }\r\n}" + "raw": "{\r\n \"param\":{\r\n \"name\":\"new name\",\r\n \"productKey\":\"new productKey\",\r\n \"category\":\"generic\",\r\n \"icon\":\"http://example.com/icon-new.ico\",\r\n \"brief\": \"new brief\",\r\n \"details\": \"new details\",\r\n \"aliases\":{\r\n \"alias1\":\"scope 1\",\r\n \"alias2\": [\"a\"]\r\n },\r\n \"template\":{\r\n \"template1\":\"template 1\",\r\n \"template2\": {\r\n \t\"another\": \"another\"\r\n }\r\n }\r\n }\r\n}" }, "url": { "raw": "{{api-url}}/v4/productTemplates/1", @@ -2838,6 +2838,202 @@ } ] }, + { + "name": "Product Category", + "description": null, + "item": [ + { + "name": "Create product category", + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + }, + { + "key": "Authorization", + "value": "Bearer {{jwt-token}}" + } + ], + "body": { + "mode": "raw", + "raw": "{\r\n \"param\":{\r\n \"key\": \"generic\",\r\n \"displayName\": \"new displayName\",\r\n \"icon\": \"icon\",\r\n \"question\": \"question\",\r\n \"info\": \"info\",\r\n \"aliases\": [\"key-1\", \"key-2\"]\r\n }\r\n}" + }, + "url": { + "raw": "{{api-url}}/v4/productCategories", + "host": [ + "{{api-url}}" + ], + "path": [ + "v4", + "productCategories" + ] + } + }, + "response": [] + }, + { + "name": "List product categories", + "request": { + "method": "GET", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + }, + { + "key": "Authorization", + "value": "Bearer {{jwt-token}}" + } + ], + "body": { + "mode": "raw", + "raw": "{\r\n \"param\":{\r\n \"name\":\"new name\",\r\n \"key\":\"new key\",\r\n \"category\":\"new category\",\r\n \"scope\":{\r\n \"scope1\":\"scope 1\"\r\n },\r\n \"phases\":{\r\n \"phase1\":\"phase 1\"\r\n }\r\n }\r\n}" + }, + "url": { + "raw": "{{api-url}}/v4/productCategories", + "host": [ + "{{api-url}}" + ], + "path": [ + "v4", + "productCategories" + ] + } + }, + "response": [] + }, + { + "name": "Get product category", + "request": { + "method": "GET", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + }, + { + "key": "Authorization", + "value": "Bearer {{jwt-token}}" + } + ], + "body": { + "mode": "raw", + "raw": "{\r\n \"param\":{\r\n \"name\":\"new name\",\r\n \"key\":\"new key\",\r\n \"category\":\"new category\",\r\n \"scope\":{\r\n \"scope1\":\"scope 1\"\r\n },\r\n \"phases\":{\r\n \"phase1\":\"phase 1\"\r\n }\r\n }\r\n}" + }, + "url": { + "raw": "{{api-url}}/v4/productCategories/generic", + "host": [ + "{{api-url}}" + ], + "path": [ + "v4", + "productCategories", + "generic" + ] + } + }, + "response": [] + }, + { + "name": "Update product category", + "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 \"displayName\": \"Chatbot-updated\"\r\n }\r\n}" + }, + "url": { + "raw": "{{api-url}}/v4/productCategories/generic", + "host": [ + "{{api-url}}" + ], + "path": [ + "v4", + "productCategories", + "generic" + ] + } + }, + "response": [] + }, + { + "name": "Delete product category", + "request": { + "method": "DELETE", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + }, + { + "key": "Authorization", + "value": "Bearer {{jwt-token}}" + } + ], + "body": { + "mode": "raw", + "raw": "{\r\n \"param\":{\r\n \"name\":\"new name\",\r\n \"key\":\"new key\",\r\n \"category\":\"new category\",\r\n \"scope\":{\r\n \"scope1\":\"scope 1\",\r\n \"scope2\": [\"a\"]\r\n },\r\n \"phases\":{\r\n \"phase1\":\"phase 1\",\r\n \"phase2\": {\r\n \t\"another\": \"another\"\r\n }\r\n }\r\n }\r\n}" + }, + "url": { + "raw": "{{api-url}}/v4/productCategories/generic", + "host": [ + "{{api-url}}" + ], + "path": [ + "v4", + "productCategories", + "generic" + ] + } + }, + "response": [] + } + ], + "auth": { + "type": "bearer", + "bearer": [ + { + "key": "token", + "value": "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJyb2xlcyI6WyJhZG1pbmlzdHJhdG9yIl0sImlzcyI6Imh0dHBzOi8vYXBpLnRvcGNvZGVyLWRldi5jb20iLCJoYW5kbGUiOiJwc2hhaDEiLCJleHAiOjI0NjI0OTQ2MTgsInVzZXJJZCI6IjQwMTM1OTc4IiwiaWF0IjoxNDYyNDk0MDE4LCJlbWFpbCI6InBzaGFoMUB0ZXN0LmNvbSIsImp0aSI6ImY0ZTFhNTE0LTg5ODAtNDY0MC04ZWM1LWUzNmUzMWE3ZTg0OSJ9.XuNN7tpMOXvBG1QwWRQROj7NfuUbqhkjwn39Vy4tR5I", + "type": "string" + } + ] + }, + "event": [ + { + "listen": "prerequest", + "script": { + "id": "f0092ef5-e624-4c25-87b2-b6a9e4c81ec8", + "type": "text/javascript", + "exec": [ + "" + ] + } + }, + { + "listen": "test", + "script": { + "id": "9183c429-a5e0-4bf9-96a2-89f4d66e9b0d", + "type": "text/javascript", + "exec": [ + "" + ] + } + } + ] + }, { "name": "issue86 (create project with templateId)", "description": null, @@ -4179,4 +4375,4 @@ ] } ] -} \ No newline at end of file +} diff --git a/src/middlewares/fieldLookupValidation.js b/src/middlewares/fieldLookupValidation.js new file mode 100644 index 00000000..73d07a9c --- /dev/null +++ b/src/middlewares/fieldLookupValidation.js @@ -0,0 +1,34 @@ +import _ from 'lodash'; + +/** + * Constructs a middleware the validates the existence of a record given a path to find in the req. For example, in + * order to check for a ProductCategory being received in "req.body.param.category" you would construct this middleware + * by calling this function with (models.ProductCategory, 'key', 'body.param.category', 'Category'). + * Note that this also works for updates where the value might not be present in the request, in which the case + * the built middleware will continue without errors. + * + * @param {Object} model the mode. + * @param {string} modelKey the model key + * @param {string} path the path to seek the value in the request + * @param {string} errorEntityName the error entity name used to build an error + * @returns {Function} the middleware + */ +export default function (model, modelKey, path, errorEntityName) { + return (req, res, next) => { + const value = _.get(req, path); + if (value) { + model.findOne({ where: { [modelKey]: value } }) + .then((record) => { + if (record) { + next(); + } else { + const err = new Error(`${errorEntityName} not found for key "${value}"`); + err.status = 422; + next(err); + } + }); + } else { + next(); + } + }; +} diff --git a/src/middlewares/validateTimeline.js b/src/middlewares/validateTimeline.js new file mode 100644 index 00000000..60cfbb4c --- /dev/null +++ b/src/middlewares/validateTimeline.js @@ -0,0 +1,166 @@ +import _ from 'lodash'; +import { TIMELINE_REFERENCES } from '../constants'; +import models from '../models'; +import util from '../util'; + +// eslint-disable-next-line valid-jsdoc +/** + * Common validation code for types of timeline references. + * @param {{ reference: string, referenceId: string|number }} sourceObject + * @param {object} req + * @param {boolean} [validateProjectExists] + * @returns {Promise} + */ +async function validateReference(sourceObject, req, validateProjectExists) { + // The source object refers to a project + if (sourceObject.reference === TIMELINE_REFERENCES.PROJECT) { + // Set projectId to the params so it can be used in the permission check middleware + req.params.projectId = sourceObject.referenceId; + + if (validateProjectExists) { + // Validate projectId to be existed + const project = await models.Project.findOne({ + where: { + id: req.params.projectId, + deletedAt: { $eq: null }, + }, + }); + if (!project) { + const apiErr = new Error(`Project not found for project id ${req.params.projectId}`); + apiErr.status = 422; + throw apiErr; + } + } + return; + } + + // The source object refers to a product + if (sourceObject.reference === TIMELINE_REFERENCES.PRODUCT) { + // Validate product to be existed + const product = await models.PhaseProduct.findOne({ + where: { + id: sourceObject.referenceId, + deletedAt: { $eq: null }, + }, + }); + if (!product) { + const apiErr = new Error(`Product not found for product id ${sourceObject.referenceId}`); + apiErr.status = 422; + throw apiErr; + } + + // Set projectId to the params so it can be used in the permission check middleware + req.params.projectId = product.projectId; + return; + } + + // The source object refers to a phase + const phase = await models.ProjectPhase.findOne({ + where: { + id: sourceObject.referenceId, + deletedAt: { $eq: null }, + }, + }); + if (!phase) { + const apiErr = new Error(`Phase not found for phase id ${sourceObject.referenceId}`); + apiErr.status = 422; + throw apiErr; + } + + // Set projectId to the params so it can be used in the permission check middleware + req.params.projectId = phase.projectId; +} + +const validateTimeline = { + + /** + * The middleware to validate and get the projectId specified by the timeline request object, + * and set to the request params. This should be called after the validate() middleware, + * and before the permissions() middleware. + * @param {Object} req the express request instance + * @param {Object} res the express response instance + * @param {Function} next the express next middleware + */ + // eslint-disable-next-line valid-jsdoc + validateTimelineRequestBody: (req, res, next) => { + validateReference(req.body.param, req, true) + .then(next) + .catch(next); + }, + + /** + * The middleware to validate and get the projectId specified by the reference/referenceId pair + * present in the request's query filter and set to the request params. Because of the filter needs + * to be parsed, this can be the first middleware in the stack, and can be placed before the permissions() + * middleware. + * @param {Object} req the express request instance + * @param {Object} res the express response instance + * @param {Function} next the express next middleware + */ + // eslint-disable-next-line valid-jsdoc + validateTimelineQueryFilter: (req, res, next) => { + // Validate the filter + const filter = util.parseQueryFilter(req.query.filter); + + // Save the parsed filter for later + req.params.filter = filter; + + if (!util.isValidFilter(filter, ['reference', 'referenceId'])) { + const apiErr = new Error('Only allowed to filter by reference and referenceId'); + apiErr.status = 422; + return next(apiErr); + } + + // Verify required filters are present + if (!filter.reference || !filter.referenceId) { + const apiErr = new Error('Please provide reference and referenceId filter parameters'); + apiErr.status = 422; + return next(apiErr); + } + + // Verify reference is a valid value + if (!_.includes(TIMELINE_REFERENCES, filter.reference)) { + const apiErr = new Error(`reference filter must be in ${TIMELINE_REFERENCES}`); + apiErr.status = 422; + return next(apiErr); + } + + if (_.lt(filter.referenceId, 1)) { + const apiErr = new Error('referenceId filter must be a positive integer'); + apiErr.status = 422; + return next(apiErr); + } + + return validateReference(filter, req, true) + .then(next) + .catch(next); + }, + + /** + * The middleware to validate and get the projectId specified by the timelineId from request + * path parameter, and set to the request params. This should be called after the validate() + * middleware, and before the permissions() middleware. + * @param {Object} req the express request instance + * @param {Object} res the express response instance + * @param {Function} next the express next middleware + */ + // eslint-disable-next-line valid-jsdoc + validateTimelineIdParam: (req, res, next) => { + models.Timeline.findById(req.params.timelineId) + .then((timeline) => { + if (!timeline) { + const apiErr = new Error(`Timeline not found for timeline id ${req.params.timelineId}`); + apiErr.status = 404; + return next(apiErr); + } + + // Set timeline to the request to be used in the next middleware + req.timeline = timeline; + return validateReference(timeline, req) + .then(next) + .catch(next); + }); + }, +}; + +export default validateTimeline; diff --git a/src/models/productCategory.js b/src/models/productCategory.js new file mode 100644 index 00000000..07f745ef --- /dev/null +++ b/src/models/productCategory.js @@ -0,0 +1,31 @@ +/* eslint-disable valid-jsdoc */ + +/** + * The Product Category model + */ +module.exports = function defineProductCategory(sequelize, DataTypes) { + return sequelize.define('ProductCategory', { + key: { type: DataTypes.STRING(45), primaryKey: true }, + displayName: { type: DataTypes.STRING(255), allowNull: false }, + icon: { type: DataTypes.STRING(255), allowNull: false }, + question: { type: DataTypes.STRING(255), allowNull: false }, + info: { type: DataTypes.STRING(255), allowNull: false }, + aliases: { type: DataTypes.JSON, allowNull: false }, + disabled: { type: DataTypes.BOOLEAN, defaultValue: false }, + hidden: { type: DataTypes.BOOLEAN, defaultValue: false }, + + deletedAt: { type: DataTypes.DATE, allowNull: true }, + createdAt: { type: DataTypes.DATE, defaultValue: DataTypes.NOW }, + updatedAt: { type: DataTypes.DATE, defaultValue: DataTypes.NOW }, + deletedBy: { type: DataTypes.INTEGER, allowNull: true }, + createdBy: { type: DataTypes.INTEGER, allowNull: false }, + updatedBy: { type: DataTypes.INTEGER, allowNull: false }, + }, { + tableName: 'product_categories', + paranoid: true, + timestamps: true, + updatedAt: 'updatedAt', + createdAt: 'createdAt', + deletedAt: 'deletedAt', + }); +}; diff --git a/src/models/productTemplate.js b/src/models/productTemplate.js index 1adeff63..fe955de8 100644 --- a/src/models/productTemplate.js +++ b/src/models/productTemplate.js @@ -8,6 +8,7 @@ module.exports = (sequelize, DataTypes) => { id: { type: DataTypes.BIGINT, primaryKey: true, autoIncrement: true }, name: { type: DataTypes.STRING(255), allowNull: false }, productKey: { type: DataTypes.STRING(45), allowNull: false }, + category: { type: DataTypes.STRING(45), allowNull: false }, icon: { type: DataTypes.STRING(255), allowNull: false }, brief: { type: DataTypes.STRING(45), allowNull: false }, details: { type: DataTypes.STRING(255), allowNull: false }, diff --git a/src/permissions/index.js b/src/permissions/index.js index 8376d03d..8cedefc8 100644 --- a/src/permissions/index.js +++ b/src/permissions/index.js @@ -53,6 +53,11 @@ module.exports = () => { Authorizer.setPolicy('projectType.delete', projectAdmin); Authorizer.setPolicy('projectType.view', true); // anyone can view project types + Authorizer.setPolicy('productCategory.create', projectAdmin); + Authorizer.setPolicy('productCategory.edit', projectAdmin); + Authorizer.setPolicy('productCategory.delete', projectAdmin); + Authorizer.setPolicy('productCategory.view', true); // anyone can view product categories + Authorizer.setPolicy('timeline.create', projectEdit); Authorizer.setPolicy('timeline.edit', projectEdit); Authorizer.setPolicy('timeline.delete', projectEdit); diff --git a/src/routes/index.js b/src/routes/index.js index d2ecb93a..a42b8078 100644 --- a/src/routes/index.js +++ b/src/routes/index.js @@ -41,9 +41,14 @@ router.route('/v4/projectTypes') router.route('/v4/projectTypes/:key') .get(require('./projectTypes/get')); +router.route('/v4/productCategories') + .get(require('./productCategories/list')); +router.route('/v4/productCategories/:key') + .get(require('./productCategories/get')); + router.all( - RegExp(`\\/${apiVersion}\\/(projects|projectTemplates|productTemplates|projectTypes|timelines)(?!\\/health).*`), - jwtAuth()); + RegExp(`\\/${apiVersion}\\/(projects|projectTemplates|productTemplates|productCategories|projectTypes|` + + 'timelines)(?!\\/health).*'), jwtAuth()); // Register all the routes router.route('/v4/projects') @@ -126,6 +131,13 @@ router.route('/v4/projects/:projectId(\\d+)/phases/:phaseId(\\d+)/products/:prod .patch(require('./phaseProducts/update')) .delete(require('./phaseProducts/delete')); +router.route('/v4/productCategories') + .post(require('./productCategories/create')); + +router.route('/v4/productCategories/:key') + .patch(require('./productCategories/update')) + .delete(require('./productCategories/delete')); + router.route('/v4/projectTypes') .post(require('./projectTypes/create')); diff --git a/src/routes/milestoneTemplates/create.spec.js b/src/routes/milestoneTemplates/create.spec.js index 1a04b9db..4be55232 100644 --- a/src/routes/milestoneTemplates/create.spec.js +++ b/src/routes/milestoneTemplates/create.spec.js @@ -14,6 +14,7 @@ const productTemplates = [ { name: 'name 1', productKey: 'productKey 1', + category: 'category', icon: 'http://example.com/icon1.ico', brief: 'brief 1', details: 'details 1', @@ -46,6 +47,7 @@ const productTemplates = [ { name: 'template 2', productKey: 'productKey 2', + category: 'category', icon: 'http://example.com/icon2.ico', brief: 'brief 2', details: 'details 2', diff --git a/src/routes/milestoneTemplates/delete.spec.js b/src/routes/milestoneTemplates/delete.spec.js index 4ae8864a..c5c5ab0a 100644 --- a/src/routes/milestoneTemplates/delete.spec.js +++ b/src/routes/milestoneTemplates/delete.spec.js @@ -11,6 +11,7 @@ const productTemplates = [ { name: 'name 1', productKey: 'productKey 1', + category: 'category', icon: 'http://example.com/icon1.ico', brief: 'brief 1', details: 'details 1', @@ -43,6 +44,7 @@ const productTemplates = [ { name: 'template 2', productKey: 'productKey 2', + category: 'category', icon: 'http://example.com/icon2.ico', brief: 'brief 2', details: 'details 2', diff --git a/src/routes/milestoneTemplates/get.spec.js b/src/routes/milestoneTemplates/get.spec.js index d9725629..958a30a6 100644 --- a/src/routes/milestoneTemplates/get.spec.js +++ b/src/routes/milestoneTemplates/get.spec.js @@ -14,6 +14,7 @@ const productTemplates = [ { name: 'name 1', productKey: 'productKey 1', + category: 'category', icon: 'http://example.com/icon1.ico', brief: 'brief 1', details: 'details 1', @@ -46,6 +47,7 @@ const productTemplates = [ { name: 'template 2', productKey: 'productKey 2', + category: 'category', icon: 'http://example.com/icon2.ico', brief: 'brief 2', details: 'details 2', diff --git a/src/routes/milestoneTemplates/list.spec.js b/src/routes/milestoneTemplates/list.spec.js index fedb32db..086356c3 100644 --- a/src/routes/milestoneTemplates/list.spec.js +++ b/src/routes/milestoneTemplates/list.spec.js @@ -14,6 +14,7 @@ const productTemplates = [ { name: 'name 1', productKey: 'productKey 1', + category: 'category', icon: 'http://example.com/icon1.ico', brief: 'brief 1', details: 'details 1', @@ -46,6 +47,7 @@ const productTemplates = [ { name: 'template 2', productKey: 'productKey 2', + category: 'category', icon: 'http://example.com/icon2.ico', brief: 'brief 2', details: 'details 2', diff --git a/src/routes/milestoneTemplates/update.spec.js b/src/routes/milestoneTemplates/update.spec.js index e653ab2e..59bbcb00 100644 --- a/src/routes/milestoneTemplates/update.spec.js +++ b/src/routes/milestoneTemplates/update.spec.js @@ -14,6 +14,7 @@ const productTemplates = [ { name: 'name 1', productKey: 'productKey 1', + category: 'category', icon: 'http://example.com/icon1.ico', brief: 'brief 1', details: 'details 1', @@ -46,6 +47,7 @@ const productTemplates = [ { name: 'template 2', productKey: 'productKey 2', + category: 'category', icon: 'http://example.com/icon2.ico', brief: 'brief 2', details: 'details 2', diff --git a/src/routes/milestones/create.js b/src/routes/milestones/create.js index b3b6623f..f9420285 100644 --- a/src/routes/milestones/create.js +++ b/src/routes/milestones/create.js @@ -7,6 +7,7 @@ import Joi from 'joi'; import { middleware as tcMiddleware } from 'tc-core-library-js'; import Sequelize from 'sequelize'; import util from '../../util'; +import validateTimeline from '../../middlewares/validateTimeline'; import models from '../../models'; import { EVENT } from '../../constants'; @@ -48,7 +49,7 @@ module.exports = [ validate(schema), // Validate and get projectId from the timelineId param, and set to request params // for checking by the permissions middleware - util.validateTimelineIdParam, + validateTimeline.validateTimelineIdParam, permissions('milestone.create'), (req, res, next) => { const entity = _.assign(req.body.param, { diff --git a/src/routes/milestones/delete.js b/src/routes/milestones/delete.js index e59464eb..50377c90 100644 --- a/src/routes/milestones/delete.js +++ b/src/routes/milestones/delete.js @@ -6,7 +6,7 @@ import Joi from 'joi'; import { middleware as tcMiddleware } from 'tc-core-library-js'; import models from '../../models'; import { EVENT } from '../../constants'; -import util from '../../util'; +import validateTimeline from '../../middlewares/validateTimeline'; const permissions = tcMiddleware.permissions; @@ -21,7 +21,7 @@ module.exports = [ validate(schema), // Validate and get projectId from the timelineId param, and set to request params for // checking by the permissions middleware - util.validateTimelineIdParam, + validateTimeline.validateTimelineIdParam, permissions('milestone.delete'), (req, res, next) => { const where = { diff --git a/src/routes/milestones/get.js b/src/routes/milestones/get.js index c35a3e86..a4731321 100644 --- a/src/routes/milestones/get.js +++ b/src/routes/milestones/get.js @@ -6,6 +6,7 @@ import Joi from 'joi'; import _ from 'lodash'; import { middleware as tcMiddleware } from 'tc-core-library-js'; import util from '../../util'; +import validateTimeline from '../../middlewares/validateTimeline'; import models from '../../models'; const permissions = tcMiddleware.permissions; @@ -21,7 +22,7 @@ module.exports = [ validate(schema), // Validate and get projectId from the timelineId param, and set to request params for // checking by the permissions middleware - util.validateTimelineIdParam, + validateTimeline.validateTimelineIdParam, permissions('milestone.view'), (req, res, next) => { const where = { diff --git a/src/routes/milestones/list.js b/src/routes/milestones/list.js index 6ae2d5c2..16152f50 100644 --- a/src/routes/milestones/list.js +++ b/src/routes/milestones/list.js @@ -7,6 +7,7 @@ import config from 'config'; import _ from 'lodash'; import { middleware as tcMiddleware } from 'tc-core-library-js'; import util from '../../util'; +import validateTimeline from '../../middlewares/validateTimeline'; const permissions = tcMiddleware.permissions; @@ -23,7 +24,7 @@ module.exports = [ validate(schema), // Validate and get projectId from the timelineId param, and set to request params for // checking by the permissions middleware - util.validateTimelineIdParam, + validateTimeline.validateTimelineIdParam, permissions('milestone.view'), (req, res, next) => { // Parse the sort query diff --git a/src/routes/milestones/update.js b/src/routes/milestones/update.js index c8b2d9e3..9f4e19b9 100644 --- a/src/routes/milestones/update.js +++ b/src/routes/milestones/update.js @@ -7,6 +7,7 @@ import Joi from 'joi'; import Sequelize from 'sequelize'; import { middleware as tcMiddleware } from 'tc-core-library-js'; import util from '../../util'; +import validateTimeline from '../../middlewares/validateTimeline'; import { EVENT } from '../../constants'; import models from '../../models'; @@ -49,7 +50,7 @@ module.exports = [ validate(schema), // Validate and get projectId from the timelineId param, // and set to request params for checking by the permissions middleware - util.validateTimelineIdParam, + validateTimeline.validateTimelineIdParam, permissions('milestone.edit'), (req, res, next) => { const where = { diff --git a/src/routes/productCategories/create.js b/src/routes/productCategories/create.js new file mode 100644 index 00000000..e80bc596 --- /dev/null +++ b/src/routes/productCategories/create.js @@ -0,0 +1,61 @@ +/** + * API to add a product category + */ +import validate from 'express-validation'; +import _ from 'lodash'; +import Joi from 'joi'; +import { middleware as tcMiddleware } from 'tc-core-library-js'; +import util from '../../util'; +import models from '../../models'; + +const permissions = tcMiddleware.permissions; + +const schema = { + body: { + param: Joi.object().keys({ + key: Joi.string().max(45).required(), + displayName: Joi.string().max(255).required(), + icon: Joi.string().max(255).required(), + question: Joi.string().max(255).required(), + info: Joi.string().max(255).required(), + aliases: Joi.array().required(), + disabled: Joi.boolean().optional(), + hidden: Joi.boolean().optional(), + createdAt: Joi.any().strip(), + updatedAt: Joi.any().strip(), + deletedAt: Joi.any().strip(), + createdBy: Joi.any().strip(), + updatedBy: Joi.any().strip(), + deletedBy: Joi.any().strip(), + }).required(), + }, +}; + +module.exports = [ + validate(schema), + permissions('productCategory.create'), + (req, res, next) => { + const entity = _.assign(req.body.param, { + createdBy: req.authUser.userId, + updatedBy: req.authUser.userId, + }); + + // Check if duplicated key + return models.ProductCategory.findById(req.body.param.key) + .then((existing) => { + if (existing) { + const apiErr = new Error(`Product category already exists for key ${req.params.key}`); + apiErr.status = 422; + return Promise.reject(apiErr); + } + + // Create + return models.ProductCategory.create(entity); + }).then((createdEntity) => { + // Omit deletedAt, deletedBy + res.status(201).json(util.wrapResponse( + req.id, _.omit(createdEntity.toJSON(), 'deletedAt', 'deletedBy'), 1, 201)); + }) + .catch(next); + }, +]; diff --git a/src/routes/productCategories/create.spec.js b/src/routes/productCategories/create.spec.js new file mode 100644 index 00000000..7b8f0089 --- /dev/null +++ b/src/routes/productCategories/create.spec.js @@ -0,0 +1,222 @@ +/** + * Tests for create.js + */ +import _ from 'lodash'; +import chai from 'chai'; +import request from 'supertest'; + +import server from '../../app'; +import testUtil from '../../tests/util'; +import models from '../../models'; + +const should = chai.should(); + +describe('CREATE product category', () => { + beforeEach(() => testUtil.clearDb() + .then(() => models.ProductCategory.create({ + key: 'key1', + displayName: 'displayName 1', + icon: 'http://example.com/icon1.ico', + question: 'question 1', + info: 'info 1', + aliases: ['key-1', 'key_1'], + disabled: false, + hidden: false, + createdBy: 1, + updatedBy: 1, + })).then(() => Promise.resolve()), + ); + after(testUtil.clearDb); + + describe('POST /productCategories', () => { + const body = { + param: { + key: 'app_dev', + displayName: 'Application Development', + icon: 'prod-cat-app-icon', + info: 'Application Development Info', + question: 'What kind of devlopment you need?', + aliases: ['key-1', 'key_1'], + disabled: true, + hidden: true, + }, + }; + + it('should return 403 if user is not authenticated', (done) => { + request(server) + .post('/v4/productCategories') + .send(body) + .expect(403, done); + }); + + it('should return 403 for member', (done) => { + request(server) + .post('/v4/productCategories') + .set({ + Authorization: `Bearer ${testUtil.jwts.member}`, + }) + .send(body) + .expect(403, done); + }); + + it('should return 403 for copilot', (done) => { + request(server) + .post('/v4/productCategories') + .set({ + Authorization: `Bearer ${testUtil.jwts.copilot}`, + }) + .send(body) + .expect(403, done); + }); + + it('should return 403 for manager', (done) => { + request(server) + .post('/v4/productCategories') + .set({ + Authorization: `Bearer ${testUtil.jwts.manager}`, + }) + .send(body) + .expect(403, done); + }); + + it('should return 422 for missing key', (done) => { + const invalidBody = _.cloneDeep(body); + delete invalidBody.param.key; + + request(server) + .post('/v4/productCategories') + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .send(invalidBody) + .expect('Content-Type', /json/) + .expect(422, done); + }); + + it('should return 422 for missing displayName', (done) => { + const invalidBody = _.cloneDeep(body); + delete invalidBody.param.displayName; + + request(server) + .post('/v4/productCategories') + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .send(invalidBody) + .expect('Content-Type', /json/) + .expect(422, done); + }); + + it('should return 422 for missing icon', (done) => { + const invalidBody = _.cloneDeep(body); + delete invalidBody.param.icon; + + request(server) + .post('/v4/productCategories') + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .send(invalidBody) + .expect('Content-Type', /json/) + .expect(422, done); + }); + + it('should return 422 for missing question', (done) => { + const invalidBody = _.cloneDeep(body); + delete invalidBody.param.question; + + request(server) + .post('/v4/productCategories') + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .send(invalidBody) + .expect('Content-Type', /json/) + .expect(422, done); + }); + + it('should return 422 for missing info', (done) => { + const invalidBody = _.cloneDeep(body); + delete invalidBody.param.info; + + request(server) + .post('/v4/productCategories') + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .send(invalidBody) + .expect('Content-Type', /json/) + .expect(422, done); + }); + + it('should return 422 for duplicated key', (done) => { + const invalidBody = _.cloneDeep(body); + invalidBody.param.key = 'key1'; + + request(server) + .post('/v4/productCategories') + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .send(invalidBody) + .expect('Content-Type', /json/) + .expect(422, done); + }); + + it('should return 201 for admin', (done) => { + request(server) + .post('/v4/productCategories') + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .send(body) + .expect('Content-Type', /json/) + .expect(201) + .end((err, res) => { + const resJson = res.body.result.content; + resJson.key.should.be.eql(body.param.key); + resJson.displayName.should.be.eql(body.param.displayName); + resJson.icon.should.be.eql(body.param.icon); + resJson.info.should.be.eql(body.param.info); + resJson.question.should.be.eql(body.param.question); + resJson.aliases.should.be.eql(body.param.aliases); + resJson.disabled.should.be.eql(body.param.disabled); + resJson.hidden.should.be.eql(body.param.hidden); + + resJson.createdBy.should.be.eql(40051333); // admin + should.exist(resJson.createdAt); + resJson.updatedBy.should.be.eql(40051333); // admin + should.exist(resJson.updatedAt); + should.not.exist(resJson.deletedBy); + should.not.exist(resJson.deletedAt); + + done(); + }); + }); + + it('should return 201 for connect admin', (done) => { + request(server) + .post('/v4/productCategories') + .set({ + Authorization: `Bearer ${testUtil.jwts.connectAdmin}`, + }) + .send(body) + .expect('Content-Type', /json/) + .expect(201) + .end((err, res) => { + const resJson = res.body.result.content; + resJson.key.should.be.eql(body.param.key); + resJson.displayName.should.be.eql(body.param.displayName); + resJson.icon.should.be.eql(body.param.icon); + resJson.info.should.be.eql(body.param.info); + resJson.question.should.be.eql(body.param.question); + resJson.aliases.should.be.eql(body.param.aliases); + resJson.disabled.should.be.eql(body.param.disabled); + resJson.hidden.should.be.eql(body.param.hidden); + resJson.createdBy.should.be.eql(40051336); // connect admin + resJson.updatedBy.should.be.eql(40051336); // connect admin + done(); + }); + }); + }); +}); diff --git a/src/routes/productCategories/delete.js b/src/routes/productCategories/delete.js new file mode 100644 index 00000000..b12170fb --- /dev/null +++ b/src/routes/productCategories/delete.js @@ -0,0 +1,54 @@ +/** + * API to delete a product category + */ +import validate from 'express-validation'; +import Joi from 'joi'; +import { middleware as tcMiddleware } from 'tc-core-library-js'; +import models from '../../models'; + +const permissions = tcMiddleware.permissions; + +const schema = { + params: { + key: Joi.string().max(45).required(), + }, +}; + +module.exports = [ + validate(schema), + permissions('productCategory.delete'), + (req, res, next) => { + const where = { + deletedAt: { $eq: null }, + key: req.params.key, + }; + + return models.sequelize.transaction(tx => + // Update the deletedBy + models.ProductCategory.update({ deletedBy: req.authUser.userId }, { + where, + returning: true, + raw: true, + transaction: tx, + }) + .then((updatedResults) => { + // Not found + if (updatedResults[0] === 0) { + const apiErr = new Error(`Product category not found for key ${req.params.key}`); + apiErr.status = 404; + return Promise.reject(apiErr); + } + + // Soft delete + return models.ProductCategory.destroy({ + where, + transaction: tx, + }); + }) + .then(() => { + res.status(204).end(); + }) + .catch(next), + ); + }, +]; diff --git a/src/routes/productCategories/delete.spec.js b/src/routes/productCategories/delete.spec.js new file mode 100644 index 00000000..4fe89a64 --- /dev/null +++ b/src/routes/productCategories/delete.spec.js @@ -0,0 +1,103 @@ +/** + * Tests for delete.js + */ +import request from 'supertest'; + +import models from '../../models'; +import server from '../../app'; +import testUtil from '../../tests/util'; + + +describe('DELETE product category', () => { + const key = 'key1'; + + beforeEach(() => testUtil.clearDb() + .then(() => models.ProductCategory.create({ + key: 'key1', + displayName: 'displayName 1', + icon: 'http://example.com/icon1.ico', + question: 'question 1', + info: 'info 1', + aliases: ['key-1', 'key_1'], + createdBy: 1, + updatedBy: 1, + })).then(() => Promise.resolve()), + ); + after(testUtil.clearDb); + + describe('DELETE /productCategories/{key}', () => { + it('should return 403 if user is not authenticated', (done) => { + request(server) + .delete(`/v4/productCategories/${key}`) + .expect(403, done); + }); + + it('should return 403 for member', (done) => { + request(server) + .delete(`/v4/productCategories/${key}`) + .set({ + Authorization: `Bearer ${testUtil.jwts.member}`, + }) + .expect(403, done); + }); + + it('should return 403 for copilot', (done) => { + request(server) + .delete(`/v4/productCategories/${key}`) + .set({ + Authorization: `Bearer ${testUtil.jwts.copilot}`, + }) + .expect(403, done); + }); + + it('should return 403 for manager', (done) => { + request(server) + .delete(`/v4/productCategories/${key}`) + .set({ + Authorization: `Bearer ${testUtil.jwts.manager}`, + }) + .expect(403, done); + }); + + it('should return 404 for non-existed product category', (done) => { + request(server) + .delete('/v4/productCategories/not_existed') + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .expect(404, done); + }); + + it('should return 404 for deleted product category', (done) => { + models.ProductCategory.destroy({ where: { key } }) + .then(() => { + request(server) + .delete(`/v4/productCategories/${key}`) + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .expect(404, done); + }); + }); + + it('should return 204, for admin, if the product category was successfully removed', (done) => { + request(server) + .delete(`/v4/productCategories/${key}`) + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .expect(204) + .end(done); + }); + + it('should return 204, for connect admin, if the product category was successfully removed', (done) => { + request(server) + .delete(`/v4/productCategories/${key}`) + .set({ + Authorization: `Bearer ${testUtil.jwts.connectAdmin}`, + }) + .expect(204) + .end(done); + }); + }); +}); diff --git a/src/routes/productCategories/get.js b/src/routes/productCategories/get.js new file mode 100644 index 00000000..f113859b --- /dev/null +++ b/src/routes/productCategories/get.js @@ -0,0 +1,39 @@ +/** + * API to get a product category + */ +import validate from 'express-validation'; +import Joi from 'joi'; +import { middleware as tcMiddleware } from 'tc-core-library-js'; +import util from '../../util'; +import models from '../../models'; + +const permissions = tcMiddleware.permissions; + +const schema = { + params: { + key: Joi.string().max(45).required(), + }, +}; + +module.exports = [ + validate(schema), + permissions('productCategory.view'), + (req, res, next) => models.ProductCategory.findOne({ + where: { + key: req.params.key, + }, + attributes: { exclude: ['deletedAt', 'deletedBy'] }, + }) + .then((productCategory) => { + // Not found + if (!productCategory) { + const apiErr = new Error(`Product category not found for key ${req.params.key}`); + apiErr.status = 404; + return Promise.reject(apiErr); + } + + res.json(util.wrapResponse(req.id, productCategory)); + return Promise.resolve(); + }) + .catch(next), +]; diff --git a/src/routes/productCategories/get.spec.js b/src/routes/productCategories/get.spec.js new file mode 100644 index 00000000..9d06dbf4 --- /dev/null +++ b/src/routes/productCategories/get.spec.js @@ -0,0 +1,129 @@ +/** + * Tests for get.js + */ +import chai from 'chai'; +import request from 'supertest'; + +import models from '../../models'; +import server from '../../app'; +import testUtil from '../../tests/util'; + +const should = chai.should(); + +describe('GET product category', () => { + const productCategory = { + key: 'key1', + displayName: 'displayName 1', + icon: 'http://example.com/icon1.ico', + question: 'question 1', + info: 'info 1', + aliases: ['key-1', 'key_1'], + disabled: true, + hidden: true, + createdBy: 1, + updatedBy: 1, + }; + + const key = productCategory.key; + + beforeEach(() => testUtil.clearDb() + .then(() => models.ProductCategory.create(productCategory)) + .then(() => Promise.resolve()), + ); + after(testUtil.clearDb); + + describe('GET /productCategories/{key}', () => { + it('should return 404 for non-existed product category', (done) => { + request(server) + .get('/v4/productCategories/1234') + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .expect(404, done); + }); + + it('should return 404 for deleted product category', (done) => { + models.ProductCategory.destroy({ where: { key } }) + .then(() => { + request(server) + .get(`/v4/productCategories/${key}`) + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .expect(404, done); + }); + }); + + it('should return 200 for admin', (done) => { + request(server) + .get(`/v4/productCategories/${key}`) + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .expect(200) + .end((err, res) => { + const resJson = res.body.result.content; + resJson.key.should.be.eql(productCategory.key); + resJson.displayName.should.be.eql(productCategory.displayName); + resJson.icon.should.be.eql(productCategory.icon); + resJson.info.should.be.eql(productCategory.info); + resJson.question.should.be.eql(productCategory.question); + resJson.aliases.should.be.eql(productCategory.aliases); + resJson.disabled.should.be.eql(productCategory.disabled); + resJson.hidden.should.be.eql(productCategory.hidden); + resJson.createdBy.should.be.eql(productCategory.createdBy); + should.exist(resJson.createdAt); + resJson.updatedBy.should.be.eql(productCategory.updatedBy); + should.exist(resJson.updatedAt); + should.not.exist(resJson.deletedBy); + should.not.exist(resJson.deletedAt); + + done(); + }); + }); + + it('should return 200 even if user is not authenticated', (done) => { + request(server) + .get(`/v4/productCategories/${key}`) + .expect(200, done); + }); + + it('should return 200 for connect admin', (done) => { + request(server) + .get(`/v4/productCategories/${key}`) + .set({ + Authorization: `Bearer ${testUtil.jwts.connectAdmin}`, + }) + .expect(200) + .end(done); + }); + + it('should return 200 for connect manager', (done) => { + request(server) + .get(`/v4/productCategories/${key}`) + .set({ + Authorization: `Bearer ${testUtil.jwts.manager}`, + }) + .expect(200) + .end(done); + }); + + it('should return 200 for member', (done) => { + request(server) + .get(`/v4/productCategories/${key}`) + .set({ + Authorization: `Bearer ${testUtil.jwts.member}`, + }) + .expect(200, done); + }); + + it('should return 200 for copilot', (done) => { + request(server) + .get(`/v4/productCategories/${key}`) + .set({ + Authorization: `Bearer ${testUtil.jwts.copilot}`, + }) + .expect(200, done); + }); + }); +}); diff --git a/src/routes/productCategories/list.js b/src/routes/productCategories/list.js new file mode 100644 index 00000000..abc2a9e7 --- /dev/null +++ b/src/routes/productCategories/list.js @@ -0,0 +1,20 @@ +/** + * API to list all product categories + */ +import { middleware as tcMiddleware } from 'tc-core-library-js'; +import util from '../../util'; +import models from '../../models'; + +const permissions = tcMiddleware.permissions; + +module.exports = [ + permissions('productCategory.view'), + (req, res, next) => models.ProductCategory.findAll({ + attributes: { exclude: ['deletedAt', 'deletedBy'] }, + raw: true, + }) + .then((productCategories) => { + res.json(util.wrapResponse(req.id, productCategories)); + }) + .catch(next), +]; diff --git a/src/routes/productCategories/list.spec.js b/src/routes/productCategories/list.spec.js new file mode 100644 index 00000000..e0e56ec7 --- /dev/null +++ b/src/routes/productCategories/list.spec.js @@ -0,0 +1,124 @@ +/** + * Tests for list.js + */ +import chai from 'chai'; +import request from 'supertest'; + +import models from '../../models'; +import server from '../../app'; +import testUtil from '../../tests/util'; + +const should = chai.should(); + +describe('LIST product categories', () => { + const productCategories = [ + { + key: 'key1', + displayName: 'displayName 1', + icon: 'http://example.com/icon1.ico', + question: 'question 1', + info: 'info 1', + aliases: ['key-1', 'key_1'], + disabled: true, + hidden: true, + createdBy: 1, + updatedBy: 1, + }, + { + key: 'key2', + displayName: 'displayName 2', + icon: 'http://example.com/icon2.ico', + question: 'question 2', + info: 'info 2', + aliases: ['key-2', 'key_2'], + disabled: true, + hidden: true, + createdBy: 1, + updatedBy: 1, + }, + ]; + + beforeEach(() => testUtil.clearDb() + .then(() => models.ProductCategory.create(productCategories[0])) + .then(() => models.ProductCategory.create(productCategories[1])) + .then(() => Promise.resolve()), + ); + after(testUtil.clearDb); + + describe('GET /productCategories', () => { + it('should return 200 for admin', (done) => { + request(server) + .get('/v4/productCategories') + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .expect(200) + .end((err, res) => { + const type = productCategories[0]; + + const resJson = res.body.result.content; + resJson.should.have.length(2); + resJson[0].key.should.be.eql(type.key); + resJson[0].displayName.should.be.eql(type.displayName); + resJson[0].icon.should.be.eql(type.icon); + resJson[0].info.should.be.eql(type.info); + resJson[0].question.should.be.eql(type.question); + resJson[0].aliases.should.be.eql(type.aliases); + resJson[0].createdBy.should.be.eql(type.createdBy); + resJson[0].disabled.should.be.eql(type.disabled); + resJson[0].hidden.should.be.eql(type.hidden); + should.exist(resJson[0].createdAt); + resJson[0].updatedBy.should.be.eql(type.updatedBy); + should.exist(resJson[0].updatedAt); + should.not.exist(resJson[0].deletedBy); + should.not.exist(resJson[0].deletedAt); + + done(); + }); + }); + + it('should return 200 even if user is not authenticated', (done) => { + request(server) + .get('/v4/productCategories') + .expect(200, done); + }); + + it('should return 200 for connect admin', (done) => { + request(server) + .get('/v4/productCategories') + .set({ + Authorization: `Bearer ${testUtil.jwts.connectAdmin}`, + }) + .expect(200) + .end(done); + }); + + it('should return 200 for connect manager', (done) => { + request(server) + .get('/v4/productCategories') + .set({ + Authorization: `Bearer ${testUtil.jwts.manager}`, + }) + .expect(200) + .end(done); + }); + + it('should return 200 for member', (done) => { + request(server) + .get('/v4/productCategories') + .set({ + Authorization: `Bearer ${testUtil.jwts.member}`, + }) + .expect(200, done); + }); + + it('should return 200 for copilot', (done) => { + request(server) + .get('/v4/productCategories') + .set({ + Authorization: `Bearer ${testUtil.jwts.copilot}`, + }) + .expect(200, done); + }); + }); +}); diff --git a/src/routes/productCategories/update.js b/src/routes/productCategories/update.js new file mode 100644 index 00000000..68958966 --- /dev/null +++ b/src/routes/productCategories/update.js @@ -0,0 +1,67 @@ +/** + * API to update a product category + */ +import validate from 'express-validation'; +import _ from 'lodash'; +import Joi from 'joi'; +import { middleware as tcMiddleware } from 'tc-core-library-js'; +import util from '../../util'; +import models from '../../models'; + +const permissions = tcMiddleware.permissions; + +const schema = { + params: { + key: Joi.string().max(45).required(), + }, + body: { + param: Joi.object().keys({ + key: Joi.any().strip(), + displayName: Joi.string().max(255).optional(), + icon: Joi.string().max(255).optional(), + question: Joi.string().max(255).optional(), + info: Joi.string().max(255).optional(), + aliases: Joi.array().optional(), + disabled: Joi.boolean().optional(), + hidden: Joi.boolean().optional(), + createdAt: Joi.any().strip(), + updatedAt: Joi.any().strip(), + deletedAt: Joi.any().strip(), + createdBy: Joi.any().strip(), + updatedBy: Joi.any().strip(), + deletedBy: Joi.any().strip(), + }).required(), + }, +}; + +module.exports = [ + validate(schema), + permissions('productCategory.edit'), + (req, res, next) => { + const entityToUpdate = _.assign(req.body.param, { + updatedBy: req.authUser.userId, + }); + + return models.ProductCategory.findOne({ + where: { + key: req.params.key, + }, + attributes: { exclude: ['deletedAt', 'deletedBy'] }, + }) + .then((productCategory) => { + // Not found + if (!productCategory) { + const apiErr = new Error(`Product category not found for key ${req.params.key}`); + apiErr.status = 404; + return Promise.reject(apiErr); + } + + return productCategory.update(entityToUpdate); + }) + .then((productCategory) => { + res.json(util.wrapResponse(req.id, productCategory)); + return Promise.resolve(); + }) + .catch(next); + }, +]; diff --git a/src/routes/productCategories/update.spec.js b/src/routes/productCategories/update.spec.js new file mode 100644 index 00000000..7b877553 --- /dev/null +++ b/src/routes/productCategories/update.spec.js @@ -0,0 +1,411 @@ +/** + * Tests for get.js + */ +import _ from 'lodash'; +import chai from 'chai'; +import request from 'supertest'; + +import models from '../../models'; +import server from '../../app'; +import testUtil from '../../tests/util'; + +const should = chai.should(); + +describe('UPDATE product category', () => { + const productCategory = { + key: 'key1', + displayName: 'displayName 1', + icon: 'http://example.com/icon1.ico', + question: 'question 1', + info: 'info 1', + aliases: ['key-1', 'key_1'], + disabled: false, + hidden: false, + createdBy: 1, + updatedBy: 1, + }; + const key = productCategory.key; + + beforeEach(() => testUtil.clearDb() + .then(() => models.ProductCategory.create(productCategory)) + .then(() => Promise.resolve()), + ); + after(testUtil.clearDb); + + describe('PATCH /productCategories/{key}', () => { + const body = { + param: { + displayName: 'displayName 1 - update', + icon: 'http://example.com/icon1.ico - update', + question: 'question 1 - update', + info: 'info 1 - update', + aliases: ['key-1-updated', 'key_1_updated'], + disabled: true, + hidden: true, + }, + }; + + it('should return 403 if user is not authenticated', (done) => { + request(server) + .patch(`/v4/productCategories/${key}`) + .send(body) + .expect(403, done); + }); + + it('should return 403 for member', (done) => { + request(server) + .patch(`/v4/productCategories/${key}`) + .set({ + Authorization: `Bearer ${testUtil.jwts.member}`, + }) + .send(body) + .expect(403, done); + }); + + it('should return 403 for copilot', (done) => { + request(server) + .patch(`/v4/productCategories/${key}`) + .send(body) + .set({ + Authorization: `Bearer ${testUtil.jwts.copilot}`, + }) + .expect(403, done); + }); + + it('should return 403 for manager', (done) => { + request(server) + .patch(`/v4/productCategories/${key}`) + .send(body) + .set({ + Authorization: `Bearer ${testUtil.jwts.manager}`, + }) + .expect(403, done); + }); + + it('should return 404 for non-existed product category', (done) => { + request(server) + .patch('/v4/productCategories/1234') + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .send(body) + .expect(404, done); + }); + + it('should return 404 for deleted product category', (done) => { + models.ProductCategory.destroy({ where: { key } }) + .then(() => { + request(server) + .patch(`/v4/productCategories/${key}`) + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .send(body) + .expect(404, done); + }); + }); + + it('should return 200 for admin displayName updated', (done) => { + const partialBody = _.cloneDeep(body); + delete partialBody.param.icon; + delete partialBody.param.info; + delete partialBody.param.question; + delete partialBody.param.aliases; + delete partialBody.param.disabled; + delete partialBody.param.hidden; + request(server) + .patch(`/v4/productCategories/${key}`) + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .send(partialBody) + .expect(200) + .end((err, res) => { + const resJson = res.body.result.content; + resJson.key.should.be.eql(key); + resJson.displayName.should.be.eql(partialBody.param.displayName); + resJson.icon.should.be.eql(productCategory.icon); + resJson.info.should.be.eql(productCategory.info); + resJson.question.should.be.eql(productCategory.question); + resJson.aliases.should.be.eql(productCategory.aliases); + resJson.disabled.should.be.eql(productCategory.disabled); + resJson.hidden.should.be.eql(productCategory.hidden); + resJson.createdBy.should.be.eql(productCategory.createdBy); + resJson.createdBy.should.be.eql(productCategory.createdBy); // should not update createdAt + resJson.updatedBy.should.be.eql(40051333); // admin + should.exist(resJson.updatedAt); + should.not.exist(resJson.deletedBy); + should.not.exist(resJson.deletedAt); + + done(); + }); + }); + + it('should return 200 for admin icon updated', (done) => { + const partialBody = _.cloneDeep(body); + delete partialBody.param.info; + delete partialBody.param.displayName; + delete partialBody.param.question; + delete partialBody.param.aliases; + delete partialBody.param.disabled; + delete partialBody.param.hidden; + request(server) + .patch(`/v4/productCategories/${key}`) + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .send(partialBody) + .expect(200) + .end((err, res) => { + const resJson = res.body.result.content; + resJson.key.should.be.eql(key); + resJson.displayName.should.be.eql(productCategory.displayName); + resJson.icon.should.be.eql(partialBody.param.icon); + resJson.info.should.be.eql(productCategory.info); + resJson.question.should.be.eql(productCategory.question); + resJson.aliases.should.be.eql(productCategory.aliases); + resJson.disabled.should.be.eql(productCategory.disabled); + resJson.hidden.should.be.eql(productCategory.hidden); + resJson.createdBy.should.be.eql(productCategory.createdBy); + resJson.createdBy.should.be.eql(productCategory.createdBy); // should not update createdAt + resJson.updatedBy.should.be.eql(40051333); // admin + should.exist(resJson.updatedAt); + should.not.exist(resJson.deletedBy); + should.not.exist(resJson.deletedAt); + + done(); + }); + }); + + it('should return 200 for admin info updated', (done) => { + const partialBody = _.cloneDeep(body); + delete partialBody.param.icon; + delete partialBody.param.displayName; + delete partialBody.param.question; + delete partialBody.param.aliases; + delete partialBody.param.disabled; + delete partialBody.param.hidden; + request(server) + .patch(`/v4/productCategories/${key}`) + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .send(partialBody) + .expect(200) + .end((err, res) => { + const resJson = res.body.result.content; + resJson.key.should.be.eql(key); + resJson.displayName.should.be.eql(productCategory.displayName); + resJson.icon.should.be.eql(productCategory.icon); + resJson.info.should.be.eql(partialBody.param.info); + resJson.question.should.be.eql(productCategory.question); + resJson.aliases.should.be.eql(productCategory.aliases); + resJson.disabled.should.be.eql(productCategory.disabled); + resJson.hidden.should.be.eql(productCategory.hidden); + resJson.createdBy.should.be.eql(productCategory.createdBy); + resJson.createdBy.should.be.eql(productCategory.createdBy); // should not update createdAt + resJson.updatedBy.should.be.eql(40051333); // admin + should.exist(resJson.updatedAt); + should.not.exist(resJson.deletedBy); + should.not.exist(resJson.deletedAt); + + done(); + }); + }); + + it('should return 200 for admin question updated', (done) => { + const partialBody = _.cloneDeep(body); + delete partialBody.param.icon; + delete partialBody.param.info; + delete partialBody.param.displayName; + delete partialBody.param.aliases; + delete partialBody.param.disabled; + delete partialBody.param.hidden; + request(server) + .patch(`/v4/productCategories/${key}`) + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .send(partialBody) + .expect(200) + .end((err, res) => { + const resJson = res.body.result.content; + resJson.key.should.be.eql(key); + resJson.displayName.should.be.eql(productCategory.displayName); + resJson.icon.should.be.eql(productCategory.icon); + resJson.info.should.be.eql(productCategory.info); + resJson.question.should.be.eql(partialBody.param.question); + resJson.aliases.should.be.eql(productCategory.aliases); + resJson.disabled.should.be.eql(productCategory.disabled); + resJson.hidden.should.be.eql(productCategory.hidden); + resJson.createdBy.should.be.eql(productCategory.createdBy); + resJson.createdBy.should.be.eql(productCategory.createdBy); // should not update createdAt + resJson.updatedBy.should.be.eql(40051333); // admin + should.exist(resJson.updatedAt); + should.not.exist(resJson.deletedBy); + should.not.exist(resJson.deletedAt); + + done(); + }); + }); + + it('should return 200 for admin aliases updated', (done) => { + const partialBody = _.cloneDeep(body); + delete partialBody.param.icon; + delete partialBody.param.info; + delete partialBody.param.question; + delete partialBody.param.displayName; + delete partialBody.param.disabled; + delete partialBody.param.hidden; + request(server) + .patch(`/v4/productCategories/${key}`) + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .send(partialBody) + .expect(200) + .end((err, res) => { + const resJson = res.body.result.content; + resJson.key.should.be.eql(key); + resJson.displayName.should.be.eql(productCategory.displayName); + resJson.icon.should.be.eql(productCategory.icon); + resJson.info.should.be.eql(productCategory.info); + resJson.question.should.be.eql(productCategory.question); + resJson.aliases.should.be.eql(partialBody.param.aliases); + resJson.disabled.should.be.eql(productCategory.disabled); + resJson.hidden.should.be.eql(productCategory.hidden); + resJson.createdBy.should.be.eql(productCategory.createdBy); + resJson.createdBy.should.be.eql(productCategory.createdBy); // should not update createdAt + resJson.updatedBy.should.be.eql(40051333); // admin + should.exist(resJson.updatedAt); + should.not.exist(resJson.deletedBy); + should.not.exist(resJson.deletedAt); + + done(); + }); + }); + + it('should return 200 for admin disabled updated', (done) => { + const partialBody = _.cloneDeep(body); + delete partialBody.param.icon; + delete partialBody.param.info; + delete partialBody.param.question; + delete partialBody.param.displayName; + delete partialBody.param.aliases; + delete partialBody.param.hidden; + request(server) + .patch(`/v4/productCategories/${key}`) + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .send(partialBody) + .expect(200) + .end((err, res) => { + const resJson = res.body.result.content; + resJson.key.should.be.eql(key); + resJson.displayName.should.be.eql(productCategory.displayName); + resJson.icon.should.be.eql(productCategory.icon); + resJson.info.should.be.eql(productCategory.info); + resJson.question.should.be.eql(productCategory.question); + resJson.aliases.should.be.eql(productCategory.aliases); + resJson.disabled.should.be.eql(partialBody.param.disabled); + resJson.hidden.should.be.eql(productCategory.hidden); + resJson.createdBy.should.be.eql(productCategory.createdBy); + resJson.createdBy.should.be.eql(productCategory.createdBy); // should not update createdAt + resJson.updatedBy.should.be.eql(40051333); // admin + should.exist(resJson.updatedAt); + should.not.exist(resJson.deletedBy); + should.not.exist(resJson.deletedAt); + + done(); + }); + }); + + it('should return 200 for admin hidden updated', (done) => { + const partialBody = _.cloneDeep(body); + delete partialBody.param.icon; + delete partialBody.param.info; + delete partialBody.param.question; + delete partialBody.param.displayName; + delete partialBody.param.disabled; + delete partialBody.param.aliases; + request(server) + .patch(`/v4/productCategories/${key}`) + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .send(partialBody) + .expect(200) + .end((err, res) => { + const resJson = res.body.result.content; + resJson.key.should.be.eql(key); + resJson.displayName.should.be.eql(productCategory.displayName); + resJson.icon.should.be.eql(productCategory.icon); + resJson.info.should.be.eql(productCategory.info); + resJson.question.should.be.eql(productCategory.question); + resJson.aliases.should.be.eql(productCategory.aliases); + resJson.disabled.should.be.eql(productCategory.disabled); + resJson.hidden.should.be.eql(partialBody.param.hidden); + resJson.createdBy.should.be.eql(productCategory.createdBy); // should not update createdAt + resJson.updatedBy.should.be.eql(40051333); // admin + should.exist(resJson.updatedAt); + should.not.exist(resJson.deletedBy); + should.not.exist(resJson.deletedAt); + + done(); + }); + }); + + it('should return 200 for admin all fields updated', (done) => { + request(server) + .patch(`/v4/productCategories/${key}`) + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .send(body) + .expect(200) + .end((err, res) => { + const resJson = res.body.result.content; + resJson.key.should.be.eql(key); + resJson.displayName.should.be.eql(body.param.displayName); + resJson.icon.should.be.eql(body.param.icon); + resJson.info.should.be.eql(body.param.info); + resJson.question.should.be.eql(body.param.question); + resJson.aliases.should.be.eql(body.param.aliases); + resJson.disabled.should.be.eql(body.param.disabled); + resJson.hidden.should.be.eql(body.param.hidden); + resJson.createdBy.should.be.eql(productCategory.createdBy); // should not update createdAt + resJson.updatedBy.should.be.eql(40051333); // admin + should.exist(resJson.updatedAt); + should.not.exist(resJson.deletedBy); + should.not.exist(resJson.deletedAt); + + done(); + }); + }); + + it('should return 200 for connect admin', (done) => { + request(server) + .patch(`/v4/productCategories/${key}`) + .set({ + Authorization: `Bearer ${testUtil.jwts.connectAdmin}`, + }) + .send(body) + .expect(200) + .end((err, res) => { + const resJson = res.body.result.content; + resJson.key.should.be.eql(key); + resJson.displayName.should.be.eql(body.param.displayName); + resJson.icon.should.be.eql(body.param.icon); + resJson.info.should.be.eql(body.param.info); + resJson.question.should.be.eql(body.param.question); + resJson.aliases.should.be.eql(body.param.aliases); + resJson.disabled.should.be.eql(body.param.disabled); + resJson.hidden.should.be.eql(body.param.hidden); + resJson.createdBy.should.be.eql(productCategory.createdBy); // should not update createdAt + resJson.updatedBy.should.be.eql(40051336); // connect admin + done(); + }); + }); + }); +}); diff --git a/src/routes/productTemplates/create.js b/src/routes/productTemplates/create.js index f0ab51b0..8f47ec4f 100644 --- a/src/routes/productTemplates/create.js +++ b/src/routes/productTemplates/create.js @@ -5,6 +5,7 @@ import validate from 'express-validation'; import _ from 'lodash'; import Joi from 'joi'; import { middleware as tcMiddleware } from 'tc-core-library-js'; +import fieldLookupValidation from '../../middlewares/fieldLookupValidation'; import util from '../../util'; import models from '../../models'; @@ -14,6 +15,7 @@ const schema = { body: { param: Joi.object().keys({ id: Joi.any().strip(), + category: Joi.string().max(45).required(), name: Joi.string().max(255).required(), productKey: Joi.string().max(45).required(), icon: Joi.string().max(255).required(), @@ -36,6 +38,7 @@ const schema = { module.exports = [ validate(schema), permissions('productTemplate.create'), + fieldLookupValidation(models.ProductCategory, 'key', 'body.param.category', 'Category'), (req, res, next) => { const entity = _.assign(req.body.param, { createdBy: req.authUser.userId, diff --git a/src/routes/productTemplates/create.spec.js b/src/routes/productTemplates/create.spec.js index c17fbc6c..846e429f 100644 --- a/src/routes/productTemplates/create.spec.js +++ b/src/routes/productTemplates/create.spec.js @@ -1,20 +1,40 @@ /** * Tests for create.js */ +import _ from 'lodash'; import chai from 'chai'; import request from 'supertest'; import server from '../../app'; import testUtil from '../../tests/util'; +import models from '../../models'; const should = chai.should(); describe('CREATE product template', () => { + before((done) => { + testUtil.clearDb() + .then(() => models.ProductCategory.bulkCreate([ + { + key: 'generic', + displayName: 'Generic', + icon: 'http://example.com/icon1.ico', + question: 'question 1', + info: 'info 1', + aliases: ['key-1', 'key_1'], + createdBy: 1, + updatedBy: 1, + }, + ])) + .then(() => done()); + }); + describe('POST /productTemplates', () => { const body = { param: { name: 'name 1', productKey: 'productKey 1', + category: 'generic', icon: 'http://example.com/icon1.ico', brief: 'brief 1', details: 'details 1', @@ -95,6 +115,32 @@ describe('CREATE product template', () => { .expect(422, done); }); + it('should return 422 if product category is missing', (done) => { + const invalidBody = _.cloneDeep(body); + invalidBody.param.category = null; + request(server) + .post('/v4/productTemplates') + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .send(invalidBody) + .expect('Content-Type', /json/) + .expect(422, done); + }); + + it('should return 422 if product category does not exist', (done) => { + const invalidBody = _.cloneDeep(body); + invalidBody.param.category = 'not_exist'; + request(server) + .post('/v4/productTemplates') + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .send(invalidBody) + .expect('Content-Type', /json/) + .expect(422, done); + }); + it('should return 201 for admin', (done) => { request(server) .post('/v4/productTemplates') @@ -109,6 +155,7 @@ describe('CREATE product template', () => { should.exist(resJson.id); resJson.name.should.be.eql(body.param.name); resJson.productKey.should.be.eql(body.param.productKey); + resJson.category.should.be.eql(body.param.category); resJson.icon.should.be.eql(body.param.icon); resJson.brief.should.be.eql(body.param.brief); resJson.details.should.be.eql(body.param.details); diff --git a/src/routes/productTemplates/delete.spec.js b/src/routes/productTemplates/delete.spec.js index 3c79c12a..e76e9c67 100644 --- a/src/routes/productTemplates/delete.spec.js +++ b/src/routes/productTemplates/delete.spec.js @@ -15,6 +15,7 @@ describe('DELETE product template', () => { .then(() => models.ProductTemplate.create({ name: 'name 1', productKey: 'productKey 1', + category: 'generic', icon: 'http://example.com/icon1.ico', brief: 'brief 1', details: 'details 1', diff --git a/src/routes/productTemplates/get.spec.js b/src/routes/productTemplates/get.spec.js index 185128c5..c690efca 100644 --- a/src/routes/productTemplates/get.spec.js +++ b/src/routes/productTemplates/get.spec.js @@ -14,6 +14,7 @@ describe('GET product template', () => { const template = { name: 'name 1', productKey: 'productKey 1', + category: 'generic', icon: 'http://example.com/icon1.ico', brief: 'brief 1', details: 'details 1', @@ -89,6 +90,7 @@ describe('GET product template', () => { resJson.id.should.be.eql(templateId); resJson.name.should.be.eql(template.name); resJson.productKey.should.be.eql(template.productKey); + resJson.category.should.be.eql(template.category); resJson.icon.should.be.eql(template.icon); resJson.brief.should.be.eql(template.brief); resJson.details.should.be.eql(template.details); diff --git a/src/routes/productTemplates/list.spec.js b/src/routes/productTemplates/list.spec.js index 81a4453c..b0e8c56f 100644 --- a/src/routes/productTemplates/list.spec.js +++ b/src/routes/productTemplates/list.spec.js @@ -14,11 +14,12 @@ import testUtil from '../../tests/util'; const validateProductTemplates = (count, resJson, expectedTemplates) => { resJson.should.have.length(count); resJson.forEach((pt, idx) => { - pt.should.have.all.keys('id', 'name', 'productKey', 'icon', 'brief', 'details', 'aliases', + pt.should.have.all.keys('id', 'name', 'productKey', 'category', 'icon', 'brief', 'details', 'aliases', 'template', 'disabled', 'hidden', 'createdBy', 'createdAt', 'updatedBy', 'updatedAt'); pt.should.not.have.all.keys('deletedAt', 'deletedBy'); pt.name.should.be.eql(expectedTemplates[idx].name); pt.productKey.should.be.eql(expectedTemplates[idx].productKey); + pt.category.should.be.eql(expectedTemplates[idx].category); pt.icon.should.be.eql(expectedTemplates[idx].icon); pt.brief.should.be.eql(expectedTemplates[idx].brief); pt.details.should.be.eql(expectedTemplates[idx].details); @@ -36,6 +37,7 @@ describe('LIST product templates', () => { { name: 'name 1', productKey: 'productKey-1', + category: 'generic', icon: 'http://example.com/icon1.ico', brief: 'brief 1', details: 'details 1', @@ -70,6 +72,7 @@ describe('LIST product templates', () => { { name: 'template 2', productKey: 'productKey-2', + category: 'concrete', icon: 'http://example.com/icon2.ico', brief: 'brief 2', details: 'details 2', diff --git a/src/routes/productTemplates/update.js b/src/routes/productTemplates/update.js index eb559fa2..82095299 100644 --- a/src/routes/productTemplates/update.js +++ b/src/routes/productTemplates/update.js @@ -5,6 +5,7 @@ import validate from 'express-validation'; import _ from 'lodash'; import Joi from 'joi'; import { middleware as tcMiddleware } from 'tc-core-library-js'; +import fieldLookupValidation from '../../middlewares/fieldLookupValidation'; import util from '../../util'; import models from '../../models'; @@ -19,6 +20,7 @@ const schema = { id: Joi.any().strip(), name: Joi.string().max(255), productKey: Joi.string().max(45), + category: Joi.string().max(45), icon: Joi.string().max(255), brief: Joi.string().max(45), details: Joi.string().max(255), @@ -39,6 +41,7 @@ const schema = { module.exports = [ validate(schema), permissions('productTemplate.edit'), + fieldLookupValidation(models.ProductCategory, 'key', 'body.param.category', 'Category'), (req, res, next) => { const entityToUpdate = _.assign(req.body.param, { updatedBy: req.authUser.userId, diff --git a/src/routes/productTemplates/update.spec.js b/src/routes/productTemplates/update.spec.js index 8b01afe1..80667f0f 100644 --- a/src/routes/productTemplates/update.spec.js +++ b/src/routes/productTemplates/update.spec.js @@ -14,6 +14,7 @@ describe('UPDATE product template', () => { const template = { name: 'name 1', productKey: 'productKey 1', + category: 'generic', icon: 'http://example.com/icon1.ico', brief: 'brief 1', details: 'details 1', @@ -49,6 +50,28 @@ describe('UPDATE product template', () => { let templateId; beforeEach(() => testUtil.clearDb() + .then(() => models.ProductCategory.bulkCreate([ + { + key: 'generic', + displayName: 'Generic', + icon: 'http://example.com/icon1.ico', + question: 'question 1', + info: 'info 1', + aliases: ['key-1', 'key_1'], + createdBy: 1, + updatedBy: 1, + }, + { + key: 'concrete', + displayName: 'Concrete', + icon: 'http://example.com/icon1.ico', + question: 'question 2', + info: 'info 2', + aliases: ['key-2', 'key_2'], + createdBy: 1, + updatedBy: 1, + }, + ])) .then(() => models.ProductTemplate.create(template)) .then((createdTemplate) => { templateId = createdTemplate.id; @@ -62,6 +85,7 @@ describe('UPDATE product template', () => { param: { name: 'template 1 - update', productKey: 'productKey 1 - update', + category: 'concrete', icon: 'http://example.com/icon1-update.ico', brief: 'brief 1 - update', details: 'details 1 - update', @@ -183,6 +207,7 @@ describe('UPDATE product template', () => { resJson.id.should.be.eql(templateId); resJson.name.should.be.eql(body.param.name); resJson.productKey.should.be.eql(body.param.productKey); + resJson.category.should.be.eql(body.param.category); resJson.icon.should.be.eql(body.param.icon); resJson.brief.should.be.eql(body.param.brief); resJson.details.should.be.eql(body.param.details); diff --git a/src/routes/projectTemplates/create.js b/src/routes/projectTemplates/create.js index cdae740e..12f7c52e 100644 --- a/src/routes/projectTemplates/create.js +++ b/src/routes/projectTemplates/create.js @@ -5,6 +5,7 @@ import validate from 'express-validation'; import _ from 'lodash'; import Joi from 'joi'; import { middleware as tcMiddleware } from 'tc-core-library-js'; +import fieldLookupValidation from '../../middlewares/fieldLookupValidation'; import util from '../../util'; import models from '../../models'; @@ -38,6 +39,7 @@ const schema = { module.exports = [ validate(schema), permissions('projectTemplate.create'), + fieldLookupValidation(models.ProjectType, 'key', 'body.param.category', 'Category'), (req, res, next) => { const entity = _.assign(req.body.param, { createdBy: req.authUser.userId, diff --git a/src/routes/projectTemplates/create.spec.js b/src/routes/projectTemplates/create.spec.js index 261b4ffa..7dbb0d2b 100644 --- a/src/routes/projectTemplates/create.spec.js +++ b/src/routes/projectTemplates/create.spec.js @@ -2,20 +2,39 @@ * Tests for create.js */ import chai from 'chai'; +import _ from 'lodash'; import request from 'supertest'; import server from '../../app'; +import models from '../../models'; import testUtil from '../../tests/util'; const should = chai.should(); describe('CREATE project template', () => { + before((done) => { + testUtil.clearDb() + .then(() => models.ProjectType.bulkCreate([ + { + key: 'generic', + displayName: 'Generic', + icon: 'http://example.com/icon1.ico', + question: 'question 1', + info: 'info 1', + aliases: ['key-1', 'key_1'], + createdBy: 1, + updatedBy: 1, + }, + ])) + .then(() => done()); + }); + describe('POST /projectTemplates', () => { const body = { param: { name: 'template 1', key: 'key 1', - category: 'category 1', + category: 'generic', icon: 'http://example.com/icon1.ico', question: 'question 1', info: 'info 1', @@ -103,6 +122,32 @@ describe('CREATE project template', () => { .expect(422, done); }); + it('should return 422 if project type is missing', (done) => { + const invalidBody = _.cloneDeep(body); + invalidBody.param.type = null; + request(server) + .post('/v4/projectTemplates') + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .send(invalidBody) + .expect('Content-Type', /json/) + .expect(422, done); + }); + + it('should return 422 if project type does not exist', (done) => { + const invalidBody = _.cloneDeep(body); + invalidBody.param.type = 'not_exist'; + request(server) + .post('/v4/projectTemplates') + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .send(invalidBody) + .expect('Content-Type', /json/) + .expect(422, done); + }); + it('should return 201 for admin', (done) => { request(server) .post('/v4/projectTemplates') diff --git a/src/routes/projectTemplates/update.js b/src/routes/projectTemplates/update.js index 7a9f27e5..61e01c99 100644 --- a/src/routes/projectTemplates/update.js +++ b/src/routes/projectTemplates/update.js @@ -5,6 +5,7 @@ import validate from 'express-validation'; import _ from 'lodash'; import Joi from 'joi'; import { middleware as tcMiddleware } from 'tc-core-library-js'; +import fieldLookupValidation from '../../middlewares/fieldLookupValidation'; import util from '../../util'; import models from '../../models'; @@ -41,6 +42,7 @@ const schema = { module.exports = [ validate(schema), permissions('projectTemplate.edit'), + fieldLookupValidation(models.ProjectType, 'key', 'body.param.category', 'Category'), (req, res, next) => { const entityToUpdate = _.assign(req.body.param, { updatedBy: req.authUser.userId, diff --git a/src/routes/projectTemplates/update.spec.js b/src/routes/projectTemplates/update.spec.js index 632cfa95..1a533cdf 100644 --- a/src/routes/projectTemplates/update.spec.js +++ b/src/routes/projectTemplates/update.spec.js @@ -14,7 +14,7 @@ describe('UPDATE project template', () => { const template = { name: 'template 1', key: 'key 1', - category: 'category 1', + category: 'generic', icon: 'http://example.com/icon1.ico', question: 'question 1', info: 'info 1', @@ -51,6 +51,28 @@ describe('UPDATE project template', () => { let templateId; beforeEach(() => testUtil.clearDb() + .then(() => models.ProjectType.bulkCreate([ + { + key: 'generic', + displayName: 'Generic', + icon: 'http://example.com/icon1.ico', + question: 'question 1', + info: 'info 1', + aliases: ['key-1', 'key_1'], + createdBy: 1, + updatedBy: 1, + }, + { + key: 'concrete', + displayName: 'Concrete', + icon: 'http://example.com/icon1.ico', + question: 'question 2', + info: 'info 2', + aliases: ['key-2', 'key_2'], + createdBy: 1, + updatedBy: 1, + }, + ])) .then(() => models.ProjectTemplate.create(template)) .then((createdTemplate) => { templateId = createdTemplate.id; @@ -64,7 +86,7 @@ describe('UPDATE project template', () => { param: { name: 'template 1 - update', key: 'key 1 - update', - category: 'category 1 - update', + category: 'concrete', scope: { scope1: { subScope1A: 11, diff --git a/src/routes/projectUpgrade/create.spec.js b/src/routes/projectUpgrade/create.spec.js index 381ef2b7..853f508a 100644 --- a/src/routes/projectUpgrade/create.spec.js +++ b/src/routes/projectUpgrade/create.spec.js @@ -92,6 +92,7 @@ describe('Project upgrade', () => { ].map(specific => models.ProductTemplate.create(Object.assign({ name: 'name 1', productKey: 'a product key', + category: 'category', icon: 'http://example.com/icon1.ico', brief: 'brief 1', details: 'details 1', diff --git a/src/routes/projects/create.spec.js b/src/routes/projects/create.spec.js index 9de4e1df..cf63ffc0 100644 --- a/src/routes/projects/create.spec.js +++ b/src/routes/projects/create.spec.js @@ -35,6 +35,7 @@ describe('Project create', () => { id: 21, name: 'template 1', productKey: 'productKey-1', + category: 'generic', icon: 'http://example.com/icon2.ico', brief: 'brief 1', details: 'details 1', @@ -47,6 +48,7 @@ describe('Project create', () => { id: 22, name: 'template 2', productKey: 'productKey-2', + category: 'generic', icon: 'http://example.com/icon2.ico', brief: 'brief 2', details: 'details 2', @@ -59,6 +61,7 @@ describe('Project create', () => { id: 23, name: 'template 3', productKey: 'productKey-3', + category: 'generic', icon: 'http://example.com/icon3.ico', brief: 'brief 3', details: 'details 3', diff --git a/src/routes/timelines/create.js b/src/routes/timelines/create.js index 4705f0d3..9f832861 100644 --- a/src/routes/timelines/create.js +++ b/src/routes/timelines/create.js @@ -7,6 +7,7 @@ import Joi from 'joi'; import moment from 'moment'; import { middleware as tcMiddleware } from 'tc-core-library-js'; import util from '../../util'; +import validateTimeline from '../../middlewares/validateTimeline'; import models from '../../models'; import { EVENT, TIMELINE_REFERENCES, MILESTONE_STATUS } from '../../constants'; @@ -37,7 +38,7 @@ module.exports = [ validate(schema), // Validate and get projectId from the timeline request body, and set to request params // for checking by the permissions middleware - util.validateTimelineRequestBody, + validateTimeline.validateTimelineRequestBody, permissions('timeline.create'), (req, res, next) => { const templateId = req.body.param.templateId; diff --git a/src/routes/timelines/create.spec.js b/src/routes/timelines/create.spec.js index d082bb20..c35d4661 100644 --- a/src/routes/timelines/create.spec.js +++ b/src/routes/timelines/create.spec.js @@ -40,6 +40,7 @@ const productTemplates = [ { name: 'name 1', productKey: 'productKey 1', + category: 'generic', icon: 'http://example.com/icon1.ico', brief: 'brief 1', details: 'details 1', diff --git a/src/routes/timelines/delete.js b/src/routes/timelines/delete.js index 470b8936..911291ec 100644 --- a/src/routes/timelines/delete.js +++ b/src/routes/timelines/delete.js @@ -7,7 +7,7 @@ import _ from 'lodash'; import { middleware as tcMiddleware } from 'tc-core-library-js'; import models from '../../models'; import { EVENT } from '../../constants'; -import util from '../../util'; +import validateTimeline from '../../middlewares/validateTimeline'; const permissions = tcMiddleware.permissions; @@ -21,7 +21,7 @@ module.exports = [ validate(schema), // Validate and get projectId from the timelineId param, and set to request params for // checking by the permissions middleware - util.validateTimelineIdParam, + validateTimeline.validateTimelineIdParam, permissions('timeline.delete'), (req, res, next) => { const timeline = req.timeline; diff --git a/src/routes/timelines/get.js b/src/routes/timelines/get.js index 2e9a03b1..c02ff4be 100644 --- a/src/routes/timelines/get.js +++ b/src/routes/timelines/get.js @@ -6,6 +6,7 @@ import Joi from 'joi'; import _ from 'lodash'; import { middleware as tcMiddleware } from 'tc-core-library-js'; import util from '../../util'; +import validateTimeline from '../../middlewares/validateTimeline'; const permissions = tcMiddleware.permissions; @@ -19,7 +20,7 @@ module.exports = [ validate(schema), // Validate and get projectId from the timelineId param, and set to request params for // checking by the permissions middleware - util.validateTimelineIdParam, + validateTimeline.validateTimelineIdParam, permissions('timeline.view'), (req, res) => { // Load the milestones diff --git a/src/routes/timelines/list.js b/src/routes/timelines/list.js index 661065b2..cf7777e8 100644 --- a/src/routes/timelines/list.js +++ b/src/routes/timelines/list.js @@ -3,9 +3,9 @@ */ import config from 'config'; import _ from 'lodash'; +import { middleware as tcMiddleware } from 'tc-core-library-js'; import util from '../../util'; -import models from '../../models'; -import { USER_ROLE, TIMELINE_REFERENCES } from '../../constants'; +import validateTimeline from '../../middlewares/validateTimeline'; const ES_TIMELINE_INDEX = config.get('elasticsearchConfig.timelineIndexName'); const ES_TIMELINE_TYPE = config.get('elasticsearchConfig.timelineDocType'); @@ -31,70 +31,25 @@ function retrieveTimelines(esTerms) { }); } +const permissions = tcMiddleware.permissions; module.exports = [ + // Validate and get projectId from the reference/referenceId pair, and set to request params for + // checking by the permissions middleware + validateTimeline.validateTimelineQueryFilter, + permissions('timeline.view'), (req, res, next) => { - // Validate the filter - const filter = util.parseQueryFilter(req.query.filter); - if (!util.isValidFilter(filter, ['reference', 'referenceId'])) { - const apiErr = new Error('Only allowed to filter by reference and referenceId'); - apiErr.status = 422; - return next(apiErr); - } - - // Verify required filters are present - if (!filter.reference || !filter.referenceId) { - const apiErr = new Error('Please provide reference and referenceId filter parameters'); - apiErr.status = 422; - return next(apiErr); - } + const filter = req.params.filter; // Build the elastic search query - const esTerms = []; - if (filter.reference) { - if (!_.includes(TIMELINE_REFERENCES, filter.reference)) { - const apiErr = new Error(`reference filter must be in ${TIMELINE_REFERENCES}`); - apiErr.status = 422; - return next(apiErr); - } - - esTerms.push({ - term: { reference: filter.reference }, - }); - } - if (filter.referenceId) { - if (_.lt(filter.referenceId, 1)) { - const apiErr = new Error('referenceId filter must be a positive integer'); - apiErr.status = 422; - return next(apiErr); - } - - esTerms.push({ - term: { referenceId: filter.referenceId }, - }); - } - - // Admin and topcoder manager can see all timelines - if (util.hasAdminRole(req) || util.hasRole(req, USER_ROLE.MANAGER)) { - return retrieveTimelines(esTerms) - .then(result => res.json(util.wrapResponse(req.id, result.rows, result.count))) - .catch(err => next(err)); - } - - // Get project ids for copilot or member - const getProjectIds = util.hasRole(req, USER_ROLE.COPILOT) ? - models.Project.getProjectIdsForCopilot(req.authUser.userId) : - models.ProjectMember.getProjectIdsForUser(req.authUser.userId); - - return getProjectIds - .then((accessibleProjectIds) => { - // Copilot or member can see his projects - esTerms.push({ - terms: { projectId: accessibleProjectIds }, - }); - - return retrieveTimelines(esTerms); - }) + const esTerms = [{ + term: { reference: filter.reference }, + }, { + term: { referenceId: filter.referenceId }, + }]; + + // Retrieve timelines, as we know the user has access for the provided reference/referenceId part + return retrieveTimelines(esTerms) .then(result => res.json(util.wrapResponse(req.id, result.rows, result.count))) .catch(err => next(err)); }, diff --git a/src/routes/timelines/list.spec.js b/src/routes/timelines/list.spec.js index 6f1c8227..03f2ce15 100644 --- a/src/routes/timelines/list.spec.js +++ b/src/routes/timelines/list.spec.js @@ -336,18 +336,13 @@ describe('LIST timelines', () => { }); }); - it('should return 200 for member with not accessible project', (done) => { + it('should return 403 for member with not accessible project', (done) => { request(server) .get('/v4/timelines?filter=reference%3Dproject%26referenceId%3D1') .set({ Authorization: `Bearer ${testUtil.jwts.member2}`, }) - .end((err, res) => { - const resJson = res.body.result.content; - resJson.should.have.length(0); // no accessible timelines - - done(); - }); + .expect(403, done); }); it('should return 200 with reference and referenceId filter', (done) => { diff --git a/src/routes/timelines/update.js b/src/routes/timelines/update.js index 99343de4..3ad5d710 100644 --- a/src/routes/timelines/update.js +++ b/src/routes/timelines/update.js @@ -6,6 +6,7 @@ import _ from 'lodash'; import Joi from 'joi'; import { middleware as tcMiddleware } from 'tc-core-library-js'; import util from '../../util'; +import validateTimeline from '../../middlewares/validateTimeline'; import { EVENT, TIMELINE_REFERENCES } from '../../constants'; const permissions = tcMiddleware.permissions; @@ -37,8 +38,8 @@ module.exports = [ validate(schema), // Validate and get projectId from the timelineId param and request body, // and set to request params for checking by the permissions middleware - util.validateTimelineIdParam, - util.validateTimelineRequestBody, + validateTimeline.validateTimelineIdParam, + validateTimeline.validateTimelineRequestBody, permissions('timeline.edit'), (req, res, next) => { const entityToUpdate = _.assign(req.body.param, { diff --git a/src/tests/seed.js b/src/tests/seed.js index 3ef8b098..312f53c0 100644 --- a/src/tests/seed.js +++ b/src/tests/seed.js @@ -311,6 +311,7 @@ models.sequelize.sync({ force: true }) { name: 'name 1', productKey: 'productKey 1', + category: 'category', icon: 'http://example.com/icon1.ico', question: 'question 1', info: 'info 1', @@ -345,6 +346,7 @@ models.sequelize.sync({ force: true }) { name: 'template 2', productKey: 'productKey 2', + category: 'category', icon: 'http://example.com/icon1.ico', question: 'question 1', info: 'info 1', @@ -358,6 +360,7 @@ models.sequelize.sync({ force: true }) { name: 'Generic work', productKey: 'generic_work', + category: 'category', icon: 'http://example.com/icon1.ico', question: 'question 1', info: 'info 1', @@ -385,6 +388,7 @@ models.sequelize.sync({ force: true }) { name: 'Website product', productKey: 'website_development', + category: 'category', icon: 'http://example.com/icon1.ico', question: 'question 1', info: 'info 1', @@ -412,6 +416,7 @@ models.sequelize.sync({ force: true }) { name: 'Application product', productKey: 'application_development', + category: 'category', icon: 'http://example.com/icon1.ico', brief: 'brief 1', details: 'details 1', diff --git a/src/util.js b/src/util.js index 02cf4526..add05eb6 100644 --- a/src/util.js +++ b/src/util.js @@ -17,7 +17,7 @@ import urlencode from 'urlencode'; import elasticsearch from 'elasticsearch'; import Promise from 'bluebird'; import AWS from 'aws-sdk'; -import { ADMIN_ROLES, TOKEN_SCOPES, TIMELINE_REFERENCES } from './constants'; +import { ADMIN_ROLES, TOKEN_SCOPES } from './constants'; const exec = require('child_process').exec; const models = require('./models').default; @@ -381,146 +381,6 @@ _.assignIn(util, { return source; } }), - - /** - * The middleware to validate and get the projectId specified by the timeline request object, - * and set to the request params. This should be called after the validate() middleware, - * and before the permissions() middleware. - * @param {Object} req the express request instance - * @param {Object} res the express response instance - * @param {Function} next the express next middleware - */ - // eslint-disable-next-line valid-jsdoc - validateTimelineRequestBody: (req, res, next) => { - // The timeline refers to a project - if (req.body.param.reference === TIMELINE_REFERENCES.PROJECT) { - // Set projectId to the params so it can be used in the permission check middleware - req.params.projectId = req.body.param.referenceId; - - // Validate projectId to be existed - return models.Project.findOne({ - where: { - id: req.params.projectId, - deletedAt: { $eq: null }, - }, - }) - .then((project) => { - if (!project) { - const apiErr = new Error(`Project not found for project id ${req.params.projectId}`); - apiErr.status = 422; - return next(apiErr); - } - - return next(); - }); - } - - // The timeline refers to a project - if (req.body.param.reference === TIMELINE_REFERENCES.PRODUCT) { - // Validate product to be existed - return models.PhaseProduct.findOne({ - where: { - id: req.body.param.referenceId, - deletedAt: { $eq: null }, - }, - }) - .then((product) => { - if (!product) { - const apiErr = new Error(`Product not found for product id ${req.body.param.referenceId}`); - apiErr.status = 422; - return next(apiErr); - } - - // Set projectId to the params so it can be used in the permission check middleware - req.params.projectId = product.projectId; - return next(); - }); - } - - // The timeline refers to a phase - return models.ProjectPhase.findOne({ - where: { - id: req.body.param.referenceId, - deletedAt: { $eq: null }, - }, - }) - .then((phase) => { - if (!phase) { - const apiErr = new Error(`Phase not found for phase id ${req.body.param.referenceId}`); - apiErr.status = 422; - return next(apiErr); - } - - // Set projectId to the params so it can be used in the permission check middleware - req.params.projectId = phase.projectId; - return next(); - }); - }, - - /** - * The middleware to validate and get the projectId specified by the timelineId from request - * path parameter, and set to the request params. This should be called after the validate() - * middleware, and before the permissions() middleware. - * @param {Object} req the express request instance - * @param {Object} res the express response instance - * @param {Function} next the express next middleware - */ - // eslint-disable-next-line valid-jsdoc - validateTimelineIdParam: (req, res, next) => { - models.Timeline.findById(req.params.timelineId) - .then((timeline) => { - if (!timeline) { - const apiErr = new Error(`Timeline not found for timeline id ${req.params.timelineId}`); - apiErr.status = 404; - return next(apiErr); - } - - // Set timeline to the request to be used in the next middleware - req.timeline = timeline; - - // The timeline refers to a project - if (timeline.reference === TIMELINE_REFERENCES.PROJECT) { - // Set projectId to the params so it can be used in the permission check middleware - req.params.projectId = timeline.referenceId; - return next(); - } - - // The timeline refers to a project - if (timeline.reference === TIMELINE_REFERENCES.PRODUCT) { - // Validate product to be existed - return models.PhaseProduct.findOne({ - where: { - id: timeline.referenceId, - deletedAt: { $eq: null }, - }, - }) - .then((product) => { - if (!product) { - const apiErr = new Error(`Product not found for product id ${timeline.referenceId}`); - apiErr.status = 422; - return next(apiErr); - } - - // Set projectId to the params so it can be used in the permission check middleware - req.params.projectId = product.projectId; - return next(); - }); - } - - // The timeline refers to a phase - return models.ProjectPhase.findOne({ - where: { - id: timeline.referenceId, - deletedAt: { $eq: null }, - }, - }) - .then((phase) => { - // Set projectId to the params so it can be used in the permission check middleware - req.params.projectId = phase.projectId; - return next(); - }); - }); - }, }); export default util; diff --git a/swagger.yaml b/swagger.yaml index 3fa16cb4..3d42d55f 100755 --- a/swagger.yaml +++ b/swagger.yaml @@ -393,7 +393,7 @@ paths: description: Invalid input schema: $ref: "#/definitions/ErrorModel" - + /projects/{projectId}/phases/{phaseId}: parameters: - $ref: "#/parameters/projectIdParam" @@ -900,6 +900,129 @@ paths: description: Product template successfully removed + /productCategories: + get: + tags: + - productCategory + operationId: findProductCategories + security: + - Bearer: [] + description: Retrieve all product categories. All user roles can access this endpoint. + responses: + '403': + description: No permission or wrong token + schema: + $ref: "#/definitions/ErrorModel" + '200': + description: A list of product categories + schema: + $ref: "#/definitions/ProductCategoryListResponse" + post: + tags: + - productCategory + operationId: addProductCategory + security: + - Bearer: [] + description: Create a product category. Only admin or connect admin can access this endpoint. + parameters: + - in: body + name: body + required: true + schema: + $ref: '#/definitions/ProductCategoryCreateBodyParam' + responses: + '403': + description: No permission or wrong token + schema: + $ref: "#/definitions/ErrorModel" + '201': + description: Returns the newly created product category + schema: + $ref: "#/definitions/ProductCategoryResponse" + '422': + description: Invalid input + schema: + $ref: "#/definitions/ErrorModel" + + /productCategories/{key}: + get: + tags: + - productCategory + description: Retrieve product category by id. All user roles can access this endpoint. + security: + - Bearer: [] + responses: + '404': + description: Not found + schema: + $ref: "#/definitions/ErrorModel" + '403': + description: No permission or wrong token + schema: + $ref: "#/definitions/ErrorModel" + '200': + description: a product category + schema: + $ref: "#/definitions/ProductCategoryResponse" + parameters: + - $ref: "#/parameters/keyParam" + operationId: getProductCategory + patch: + tags: + - productCategory + operationId: updateProductCategory + security: + - Bearer: [] + description: Update a product category. Only admin or connect admin can access this endpoint. + responses: + '403': + description: No permission or wrong token + schema: + $ref: "#/definitions/ErrorModel" + '404': + description: Not found + schema: + $ref: "#/definitions/ErrorModel" + '200': + description: Successfully updated product category. + schema: + $ref: "#/definitions/ProductCategoryResponse" + '422': + description: Invalid input + schema: + $ref: "#/definitions/ErrorModel" + default: + description: error payload + schema: + $ref: '#/definitions/ErrorModel' + parameters: + - $ref: "#/parameters/keyParam" + - name: body + in: body + required: true + schema: + $ref: "#/definitions/ProductCategoryBodyParam" + delete: + tags: + - productCategory + description: Remove an existing product category. Only admin or connect admin can access this endpoint. + security: + - Bearer: [] + parameters: + - $ref: "#/parameters/keyParam" + responses: + '403': + description: No permission or wrong token + schema: + $ref: "#/definitions/ErrorModel" + '404': + description: If product category is not found + schema: + $ref: "#/definitions/ErrorModel" + '204': + description: Product category successfully removed + + /projectTypes: get: tags: @@ -2272,7 +2395,7 @@ definitions: type: array items: $ref: "#/definitions/ProjectTemplate" - + ProductTemplateRequest: title: Product template request object type: object @@ -2289,6 +2412,9 @@ definitions: productKey: type: string description: the product template key + category: + type: string + description: the product template product category icon: type: string description: the product template icon @@ -2324,6 +2450,7 @@ definitions: - createdBy - updatedAt - updatedBy + - category properties: id: type: number @@ -2347,6 +2474,9 @@ definitions: format: int64 description: READ-ONLY. User that last updated this object readOnly: true + category: + type: string + description: The product category of the product template - $ref: "#/definitions/ProductTemplateRequest" ProjectUpgradeResponse: @@ -2662,9 +2792,132 @@ definitions: type: array items: $ref: "#/definitions/PhaseProduct" - - - + + + + ProductCategoryRequest: + title: Product category request object + type: object + required: + - displayName + properties: + displayName: + type: string + description: the product category display name + + ProductCategoryBodyParam: + title: Product category body param + type: object + required: + - param + properties: + param: + $ref: "#/definitions/ProductCategoryRequest" + + ProductCategoryCreateRequest: + title: Product category creation request object + type: object + allOf: + - type: object + required: + - key + properties: + key: + type: string + description: the product category key + - $ref: "#/definitions/ProductCategoryRequest" + + ProductCategoryCreateBodyParam: + title: Product category creation body param + type: object + required: + - param + properties: + param: + $ref: "#/definitions/ProductCategoryCreateRequest" + + ProductCategory: + title: Product category object + allOf: + - type: object + required: + - createdAt + - createdBy + - updatedAt + - updatedBy + properties: + key: + type: string + description: the product category key + createdAt: + type: string + description: Datetime (GMT) when object was created + readOnly: true + createdBy: + type: integer + format: int64 + description: READ-ONLY. User who created this object + readOnly: true + updatedAt: + type: string + description: READ-ONLY. Datetime (GMT) when object was updated + readOnly: true + updatedBy: + type: integer + format: int64 + description: READ-ONLY. User that last updated this object + readOnly: true + - $ref: "#/definitions/ProductCategoryCreateRequest" + + + ProductCategoryResponse: + title: Single product category response object + type: object + properties: + id: + type: string + description: unique id identifying the request + version: + type: string + result: + type: object + properties: + success: + type: boolean + status: + type: string + description: http status code + metadata: + $ref: "#/definitions/ResponseMetadata" + content: + $ref: "#/definitions/ProductCategory" + + ProductCategoryListResponse: + title: Product category list response object + type: object + properties: + id: + type: string + readOnly: true + description: unique id identifying the request + version: + type: string + result: + type: object + properties: + success: + type: boolean + status: + type: string + description: http status code + metadata: + $ref: "#/definitions/ResponseMetadata" + content: + type: array + items: + $ref: "#/definitions/ProductCategory" + + ProjectTypeRequest: title: Project type request object type: object From 14ec56e6be4b344252fde31bf10bfa70efcc6a8b Mon Sep 17 00:00:00 2001 From: Gian Franco Zabarino Date: Fri, 27 Jul 2018 23:03:59 -0300 Subject: [PATCH 22/73] Replaced project type checking by lookup middleware. --- src/routes/projects/create.js | 29 +++-------------------------- 1 file changed, 3 insertions(+), 26 deletions(-) diff --git a/src/routes/projects/create.js b/src/routes/projects/create.js index d291a190..f086bc7a 100644 --- a/src/routes/projects/create.js +++ b/src/routes/projects/create.js @@ -7,6 +7,7 @@ import config from 'config'; import models from '../../models'; import { PROJECT_MEMBER_ROLE, PROJECT_STATUS, PROJECT_PHASE_STATUS, USER_ROLE, EVENT, REGEX } from '../../constants'; +import fieldLookupValidation from '../../middlewares/fieldLookupValidation'; import util from '../../util'; import directProject from '../../services/directProject'; @@ -178,30 +179,11 @@ function validateAndFetchTemplates(templateId) { }); } -/** - * Validates the project type being one from the allowed ones. - * - * @param {String} type key of the project type to be used - * @returns {Promise} promise which resolves to a project type if it is valid, rejects otherwise with 422 error - */ -function validateProjectType(type) { - return models.ProjectType.findOne({ where: { key: type } }) - .then((projectType) => { - if (!projectType) { - // Not found - const apiErr = new Error(`Project type not found for key ${type}`); - apiErr.status = 422; - return Promise.reject(apiErr); - } - - return Promise.resolve(projectType); - }); -} - module.exports = [ // handles request validations validate(createProjectValdiations), permissions('project.create'), + fieldLookupValidation(models.ProjectType, 'key', 'body.param.type', 'Project type'), /** * POST projects/ * Create a project if the user has access @@ -246,13 +228,8 @@ module.exports = [ let newPhases; models.sequelize.transaction(() => { req.log.debug('Create Project - Starting transaction'); - // Validate the project type - return validateProjectType(project.type) // Validate the templates - .then((projectType) => { - req.log.debug(`Project type ${projectType.key} validated successfully`); - return validateAndFetchTemplates(project.templateId); - }) + return validateAndFetchTemplates(project.templateId) // Create project and phases .then(({ projectTemplate, productTemplates }) => { req.log.debug('Creating project, phase and products'); From 520f37368fac4ad7099880e5d336d8097151d208 Mon Sep 17 00:00:00 2001 From: Gian Franco Zabarino Date: Fri, 27 Jul 2018 23:32:04 -0300 Subject: [PATCH 23/73] - Added projectUrl to all connect bus events. - Added connectProjectsUrl config var to test.json, as it's needed for running tests. --- config/test.json | 1 + src/events/busApi.js | 27 ++++++++++++++++++++++++++- 2 files changed, 27 insertions(+), 1 deletion(-) diff --git a/config/test.json b/config/test.json index 8668be6e..ab652133 100644 --- a/config/test.json +++ b/config/test.json @@ -12,6 +12,7 @@ "timelineDocType": "timelineV4" }, "rabbitmqUrl": "amqp://localhost:5672", + "connectProjectsUrl": "https://local.topcoder-dev.com/projects/", "dbConfig": { "masterUrl": "postgres://coder:mysecretpassword@localhost:5432/projectsdb_test", "maxPoolSize": 50, diff --git a/src/events/busApi.js b/src/events/busApi.js index afb1b432..67be99b8 100644 --- a/src/events/busApi.js +++ b/src/events/busApi.js @@ -1,5 +1,5 @@ import _ from 'lodash'; -import 'config'; +import config from 'config'; import { EVENT, BUS_API_EVENT, PROJECT_STATUS, PROJECT_MEMBER_ROLE } from '../constants'; import { createEvent } from '../services/busApi'; import models from '../models'; @@ -17,6 +17,16 @@ const mapEventTypes = { [PROJECT_STATUS.ACTIVE]: BUS_API_EVENT.PROJECT_ACTIVE, }; +/** + * Builds the connect project url for the given project id. + * + * @param {string|number} projectId the project id + * @returns {string} the connect project url + */ +function connectProjectUrl(projectId) { + return `${config.get('connectProjectsUrl')}${projectId}`; +} + module.exports = (app, logger) => { /** * PROJECT_DRAFT_CREATED @@ -28,6 +38,7 @@ module.exports = (app, logger) => { createEvent(BUS_API_EVENT.PROJECT_CREATED, { projectId: project.id, projectName: project.name, + projectUrl: connectProjectUrl(project.id), userId: req.authUser.userId, initiatorUserId: req.authUser.userId, }, logger); @@ -44,6 +55,7 @@ module.exports = (app, logger) => { createEvent(mapEventTypes[updated.status], { projectId: updated.id, projectName: updated.name, + projectUrl: connectProjectUrl(updated.id), userId: req.authUser.userId, initiatorUserId: req.authUser.userId, }, logger); @@ -55,6 +67,7 @@ module.exports = (app, logger) => { createEvent(BUS_API_EVENT.PROJECT_SPECIFICATION_MODIFIED, { projectId: updated.id, projectName: updated.name, + projectUrl: connectProjectUrl(updated.id), userId: req.authUser.userId, initiatorUserId: req.authUser.userId, }, logger); @@ -63,6 +76,7 @@ module.exports = (app, logger) => { createEvent(BUS_API_EVENT.PROJECT_LINK_CREATED, { projectId: updated.id, projectName: updated.name, + projectUrl: connectProjectUrl(updated.id), userId: req.authUser.userId, initiatorUserId: req.authUser.userId, }, logger); @@ -96,6 +110,7 @@ module.exports = (app, logger) => { createEvent(eventType, { projectId, projectName: project.name, + projectUrl: connectProjectUrl(projectId), userId: member.userId, initiatorUserId: req.authUser.userId, }, logger); @@ -124,6 +139,7 @@ module.exports = (app, logger) => { createEvent(eventType, { projectId, projectName: project.name, + projectUrl: connectProjectUrl(projectId), userId: member.userId, initiatorUserId: req.authUser.userId, }, logger); @@ -147,6 +163,7 @@ module.exports = (app, logger) => { createEvent(BUS_API_EVENT.MEMBER_ASSIGNED_AS_OWNER, { projectId, projectName: project.name, + projectUrl: connectProjectUrl(projectId), userId: updated.userId, initiatorUserId: req.authUser.userId, }, logger); @@ -170,6 +187,7 @@ module.exports = (app, logger) => { createEvent(BUS_API_EVENT.PROJECT_FILE_UPLOADED, { projectId, projectName: project.name, + projectUrl: connectProjectUrl(projectId), fileName: attachment.filePath.replace(/^.*[\\\/]/, ''), // eslint-disable-line userId: req.authUser.userId, initiatorUserId: req.authUser.userId, @@ -192,6 +210,7 @@ module.exports = (app, logger) => { createEvent(BUS_API_EVENT.PROJECT_PLAN_MODIFIED, { projectId, projectName: project.name, + projectUrl: connectProjectUrl(projectId), userId: req.authUser.userId, initiatorUserId: req.authUser.userId, }, logger); @@ -213,6 +232,7 @@ module.exports = (app, logger) => { createEvent(BUS_API_EVENT.PROJECT_PLAN_MODIFIED, { projectId, projectName: project.name, + projectUrl: connectProjectUrl(projectId), userId: req.authUser.userId, initiatorUserId: req.authUser.userId, }, logger); @@ -234,6 +254,7 @@ module.exports = (app, logger) => { createEvent(BUS_API_EVENT.PROJECT_PLAN_MODIFIED, { projectId, projectName: project.name, + projectUrl: connectProjectUrl(projectId), userId: req.authUser.userId, initiatorUserId: req.authUser.userId, }, logger); @@ -255,6 +276,7 @@ module.exports = (app, logger) => { createEvent(BUS_API_EVENT.PROJECT_PLAN_MODIFIED, { projectId, projectName: project.name, + projectUrl: connectProjectUrl(projectId), userId: req.authUser.userId, initiatorUserId: req.authUser.userId, phase: created, @@ -277,6 +299,7 @@ module.exports = (app, logger) => { createEvent(BUS_API_EVENT.PROJECT_PLAN_MODIFIED, { projectId, projectName: project.name, + projectUrl: connectProjectUrl(projectId), userId: req.authUser.userId, initiatorUserId: req.authUser.userId, }, logger); @@ -302,6 +325,7 @@ module.exports = (app, logger) => { createEvent(BUS_API_EVENT.PROJECT_PRODUCT_SPECIFICATION_MODIFIED, { projectId, projectName: project.name, + projectUrl: connectProjectUrl(projectId), userId: req.authUser.userId, initiatorUserId: req.authUser.userId, }, logger); @@ -314,6 +338,7 @@ module.exports = (app, logger) => { createEvent(BUS_API_EVENT.PROJECT_PLAN_MODIFIED, { projectId, projectName: project.name, + projectUrl: connectProjectUrl(projectId), userId: req.authUser.userId, initiatorUserId: req.authUser.userId, }, logger); From 5b5215a730d695b57c5e4fe04fa55497b1d727b4 Mon Sep 17 00:00:00 2001 From: Gian Franco Zabarino Date: Fri, 27 Jul 2018 23:50:02 -0300 Subject: [PATCH 24/73] - Added fileUrl when sending a PROJECT_FILE_UPLOADED event to the bus api. - Added connectProjectsUrl config var to test.json, as it's needed for running tests. --- config/test.json | 1 + src/events/busApi.js | 14 +++++++++++++- 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/config/test.json b/config/test.json index 8668be6e..ab652133 100644 --- a/config/test.json +++ b/config/test.json @@ -12,6 +12,7 @@ "timelineDocType": "timelineV4" }, "rabbitmqUrl": "amqp://localhost:5672", + "connectProjectsUrl": "https://local.topcoder-dev.com/projects/", "dbConfig": { "masterUrl": "postgres://coder:mysecretpassword@localhost:5432/projectsdb_test", "maxPoolSize": 50, diff --git a/src/events/busApi.js b/src/events/busApi.js index afb1b432..8a9eb621 100644 --- a/src/events/busApi.js +++ b/src/events/busApi.js @@ -1,5 +1,5 @@ import _ from 'lodash'; -import 'config'; +import config from 'config'; import { EVENT, BUS_API_EVENT, PROJECT_STATUS, PROJECT_MEMBER_ROLE } from '../constants'; import { createEvent } from '../services/busApi'; import models from '../models'; @@ -17,6 +17,17 @@ const mapEventTypes = { [PROJECT_STATUS.ACTIVE]: BUS_API_EVENT.PROJECT_ACTIVE, }; +/** + * Builds the connect project attachment url for the given project and attachment ids. + * + * @param {string|number} projectId the project id + * @param {string|number} attachmentId the attachment id + * @returns {string} the connect project attachment url + */ +function connectProjectAttachmentUrl(projectId, attachmentId) { + return `${config.get('connectProjectsUrl')}${projectId}/attachments/${attachmentId}`; +} + module.exports = (app, logger) => { /** * PROJECT_DRAFT_CREATED @@ -171,6 +182,7 @@ module.exports = (app, logger) => { projectId, projectName: project.name, fileName: attachment.filePath.replace(/^.*[\\\/]/, ''), // eslint-disable-line + fileUrl: connectProjectAttachmentUrl(projectId, attachment.id), userId: req.authUser.userId, initiatorUserId: req.authUser.userId, }, logger); From 2887ef8d39c70a7b9af05a7130f8232e30d9b397 Mon Sep 17 00:00:00 2001 From: Gian Franco Zabarino Date: Sat, 28 Jul 2018 00:31:28 -0300 Subject: [PATCH 25/73] When creating a project from a project template, define phases' startDate, endDate and duration. --- src/routes/projects/create.js | 15 ++++++++++----- src/routes/projects/create.spec.js | 7 +++++++ 2 files changed, 17 insertions(+), 5 deletions(-) diff --git a/src/routes/projects/create.js b/src/routes/projects/create.js index d291a190..c8fb6ce0 100644 --- a/src/routes/projects/create.js +++ b/src/routes/projects/create.js @@ -4,6 +4,7 @@ import validate from 'express-validation'; import _ from 'lodash'; import Joi from 'joi'; import config from 'config'; +import moment from 'moment'; import models from '../../models'; import { PROJECT_MEMBER_ROLE, PROJECT_STATUS, PROJECT_PHASE_STATUS, USER_ROLE, EVENT, REGEX } from '../../constants'; @@ -90,12 +91,16 @@ function createProjectAndPhases(req, project, projectTemplate, productTemplates) productTemplates.forEach((pt) => { productTemplateMap[pt.id] = pt; }); - return Promise.all(_.map(phases, (phase, phaseIdx) => + return Promise.all(_.map(phases, (phase, phaseIdx) => { + const duration = _.get(phase, 'duration', 1); + const startDate = moment.utc(); // Create phase - models.ProjectPhase.create({ + return models.ProjectPhase.create({ projectId: newProject.id, name: _.get(phase, 'name', `Stage ${phaseIdx}`), - duration: _.get(phase, 'duration', 0), + duration, + startDate: startDate.format(), + endDate: moment.utc(startDate).add(duration - 1, 'days').format(), status: _.get(phase, 'status', PROJECT_PHASE_STATUS.DRAFT), budget: _.get(phase, 'budget', 0), updatedBy: req.authUser.userId, @@ -121,8 +126,8 @@ function createProjectAndPhases(req, project, projectTemplate, productTemplates) result.newPhases.push(newPhaseJson); return Promise.resolve(); }); - }), - )); + }); + })); }).then(() => Promise.resolve(result)); } diff --git a/src/routes/projects/create.spec.js b/src/routes/projects/create.spec.js index cf63ffc0..82b33b6e 100644 --- a/src/routes/projects/create.spec.js +++ b/src/routes/projects/create.spec.js @@ -1,6 +1,7 @@ /* eslint-disable no-unused-expressions */ import _ from 'lodash'; import chai from 'chai'; +import moment from 'moment'; import sinon from 'sinon'; import request from 'supertest'; @@ -85,6 +86,7 @@ describe('Project create', () => { phases: { phase1: { name: 'phase 1', + duration: 5, products: [ { id: 21, @@ -116,6 +118,7 @@ describe('Project create', () => { 1: { name: 'Design Stage', status: 'open', + duration: 10, details: { description: 'detailed description', }, @@ -130,6 +133,7 @@ describe('Project create', () => { 2: { name: 'Development Stage', status: 'open', + duration: 20, products: [ { id: 23, @@ -440,6 +444,9 @@ describe('Project create', () => { const phases = _.sortBy(resJson.phases, p => p.name); phases[0].name.should.be.eql('Design Stage'); phases[0].status.should.be.eql('open'); + phases[0].startDate.should.be.a('string'); + phases[0].duration.should.be.eql(10); + new Date(phases[0].endDate).should.be.eql(moment.utc(phases[0].startDate).add(9, 'days').toDate()); expect(phases[0].details).to.be.empty; phases[0].products.should.have.lengthOf(1); phases[0].products[0].name.should.be.eql('product 1'); From de0d52e0b0ba5467b810c4c13914d8a6160f2b86 Mon Sep 17 00:00:00 2001 From: Gian Franco Zabarino Date: Sat, 28 Jul 2018 01:34:38 -0300 Subject: [PATCH 26/73] Changes after comments on #138. --- src/routes/projects/create.js | 3 ++- src/routes/projects/create.spec.js | 7 ++++++- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/src/routes/projects/create.js b/src/routes/projects/create.js index c8fb6ce0..b45f639c 100644 --- a/src/routes/projects/create.js +++ b/src/routes/projects/create.js @@ -93,7 +93,8 @@ function createProjectAndPhases(req, project, projectTemplate, productTemplates) }); return Promise.all(_.map(phases, (phase, phaseIdx) => { const duration = _.get(phase, 'duration', 1); - const startDate = moment.utc(); + const startDate = moment.utc().hours(0).minutes(0).seconds(0) + .milliseconds(0); // Create phase return models.ProjectPhase.create({ projectId: newProject.id, diff --git a/src/routes/projects/create.spec.js b/src/routes/projects/create.spec.js index 82b33b6e..0b13f1be 100644 --- a/src/routes/projects/create.spec.js +++ b/src/routes/projects/create.spec.js @@ -446,7 +446,12 @@ describe('Project create', () => { phases[0].status.should.be.eql('open'); phases[0].startDate.should.be.a('string'); phases[0].duration.should.be.eql(10); - new Date(phases[0].endDate).should.be.eql(moment.utc(phases[0].startDate).add(9, 'days').toDate()); + const startDate = moment.utc(phases[0].startDate); + startDate.hours().should.be.eql(0); + startDate.minutes().should.be.eql(0); + startDate.seconds().should.be.eql(0); + startDate.milliseconds().should.be.eql(0); + new Date(phases[0].endDate).should.be.eql(startDate.add(9, 'days').toDate()); expect(phases[0].details).to.be.empty; phases[0].products.should.have.lengthOf(1); phases[0].products[0].name.should.be.eql('product 1'); From 84b9fed9095271556fed35584abca706f5d023fb Mon Sep 17 00:00:00 2001 From: Gian Franco Zabarino Date: Sat, 28 Jul 2018 01:40:57 -0300 Subject: [PATCH 27/73] Changes after comments on #137. --- config/development.json | 3 ++- config/production.json | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/config/development.json b/config/development.json index 7e8ce29d..12dc197a 100644 --- a/config/development.json +++ b/config/development.json @@ -1,5 +1,6 @@ { "pubsubQueueName": "dev.project.service", "pubsubExchangeName": "dev.projects", - "attachmentsS3Bucket": "topcoder-dev-media" + "attachmentsS3Bucket": "topcoder-dev-media", + "connectProjectsUrl": "https://connect.topcoder-dev.com/projects/" } diff --git a/config/production.json b/config/production.json index 93d6df88..2d3343bd 100644 --- a/config/production.json +++ b/config/production.json @@ -1,3 +1,4 @@ { - "authDomain": "topcoder.com" + "authDomain": "topcoder.com", + "connectProjectsUrl": "https://connect.topcoder.com/projects/", } From 4457b871d409074e1d4ee75cce3811a946636a2d Mon Sep 17 00:00:00 2001 From: Gian Franco Zabarino Date: Sat, 28 Jul 2018 02:03:06 -0300 Subject: [PATCH 28/73] When updating a timeline, update correctly their milestones' startDate and endDate. --- src/routes/timelines/update.js | 29 ++++++++++++----------------- src/routes/timelines/update.spec.js | 17 +++++++++-------- 2 files changed, 21 insertions(+), 25 deletions(-) diff --git a/src/routes/timelines/update.js b/src/routes/timelines/update.js index 3ad5d710..5cbfad5d 100644 --- a/src/routes/timelines/update.js +++ b/src/routes/timelines/update.js @@ -3,6 +3,7 @@ */ import validate from 'express-validation'; import _ from 'lodash'; +import moment from 'moment'; import Joi from 'joi'; import { middleware as tcMiddleware } from 'tc-core-library-js'; import util from '../../util'; @@ -60,27 +61,21 @@ module.exports = [ if (original.startDate !== updated.startDate || original.endDate !== updated.endDate) { return updatedTimeline.getMilestones() .then((milestones) => { - const updateMilestonePromises = _.map(milestones, (_milestone) => { + let startDate = updated.startDate; + const updateMilestonePromises = _.chain(milestones).sortBy('order').map((_milestone) => { const milestone = _milestone; - if (original.startDate !== updated.startDate) { - if (milestone.startDate && milestone.startDate < updated.startDate) { - milestone.startDate = updated.startDate; - if (milestone.endDate && milestone.endDate < milestone.startDate) { - milestone.endDate = milestone.startDate; - } - milestone.updatedBy = req.authUser.userId; - } + if (milestone.startDate.getTime() !== startDate.getTime()) { + milestone.startDate = startDate; + milestone.updatedBy = req.authUser.userId; } - - if (original.endDate !== updated.endDate) { - if (milestone.endDate && updated.endDate && updated.endDate < milestone.endDate) { - milestone.endDate = updated.endDate; - milestone.updatedBy = req.authUser.userId; - } + const endDate = moment.utc(milestone.startDate).add(milestone.duration - 1, 'days').toDate(); + if (!milestone.endDate || endDate.getTime() !== milestone.endDate.getTime()) { + milestone.endDate = endDate; + milestone.updatedBy = req.authUser.userId; } - + startDate = moment.utc(milestone.endDate).add(1, 'days').toDate(); return milestone.save(); - }); + }).value(); return Promise.all(updateMilestonePromises) .then((updatedMilestones) => { diff --git a/src/routes/timelines/update.spec.js b/src/routes/timelines/update.spec.js index eb887fe3..cf00398c 100644 --- a/src/routes/timelines/update.spec.js +++ b/src/routes/timelines/update.spec.js @@ -518,11 +518,11 @@ describe('UPDATE timeline', () => { }) .then(() => models.Milestone.findById(2)) .then((milestone) => { - milestone.startDate.should.be.eql(new Date('2018-05-15T00:00:00.000Z')); - should.not.exist(milestone.endDate); - + milestone.startDate.should.be.eql(new Date('2018-05-17T00:00:00.000Z')); + milestone.endDate.should.be.eql(new Date('2018-05-19T00:00:00.000Z')); done(); - }); + }) + .catch(done); }, 3000); }); }); @@ -547,16 +547,17 @@ describe('UPDATE timeline', () => { setTimeout(() => { models.Milestone.findById(1) .then((milestone) => { - milestone.startDate.should.be.eql(new Date('2018-05-13T00:00:00.000Z')); - milestone.endDate.should.be.eql(new Date('2018-05-15T00:00:00.000Z')); + milestone.startDate.should.be.eql(new Date('2018-05-12T00:00:00.000Z')); + milestone.endDate.should.be.eql(new Date('2018-05-13T00:00:00.000Z')); }) .then(() => models.Milestone.findById(2)) .then((milestone) => { milestone.startDate.should.be.eql(new Date('2018-05-14T00:00:00.000Z')); - should.not.exist(milestone.endDate); + milestone.endDate.should.be.eql(new Date('2018-05-16T00:00:00.000Z')); done(); - }); + }) + .catch(done); }, 3000); }); }); From 52ee6f6c55584ac5197cc1c3d41ce79304058c26 Mon Sep 17 00:00:00 2001 From: morehappiness Date: Sat, 28 Jul 2018 23:18:04 +0800 Subject: [PATCH 29/73] fix Issue #128 --- src/models/phaseProduct.js | 2 +- src/models/project.js | 2 + src/models/projectAttachment.js | 3 +- src/models/projectHistory.js | 2 +- src/models/projectMember.js | 1 + src/models/projectPhase.js | 2 +- src/routes/attachments/delete.js | 7 +- src/routes/attachments/delete.spec.js | 28 +++- src/routes/milestoneTemplates/delete.js | 37 ++-- src/routes/milestoneTemplates/delete.spec.js | 31 +++- src/routes/milestones/delete.spec.js | 167 +++++++++++-------- src/routes/phaseProducts/delete.js | 14 +- src/routes/phaseProducts/delete.spec.js | 31 +++- src/routes/phases/delete.js | 11 +- src/routes/phases/delete.spec.js | 29 +++- src/routes/productCategories/delete.js | 33 +--- src/routes/productCategories/delete.spec.js | 31 +++- src/routes/productTemplates/delete.js | 34 +--- src/routes/productTemplates/delete.spec.js | 31 +++- src/routes/projectMembers/delete.js | 5 +- src/routes/projectMembers/delete.spec.js | 42 +++-- src/routes/projectTemplates/delete.js | 34 +--- src/routes/projectTemplates/delete.spec.js | 31 +++- src/routes/projectTypes/delete.js | 33 +--- src/routes/projectTypes/delete.spec.js | 31 +++- src/routes/projects/delete.js | 48 +++--- src/routes/projects/delete.spec.js | 156 +++++++++-------- src/routes/timelines/delete.spec.js | 59 +++++-- 28 files changed, 574 insertions(+), 361 deletions(-) diff --git a/src/models/phaseProduct.js b/src/models/phaseProduct.js index 4ec1ea90..04ec131e 100644 --- a/src/models/phaseProduct.js +++ b/src/models/phaseProduct.js @@ -22,7 +22,7 @@ module.exports = function definePhaseProduct(sequelize, DataTypes) { updatedBy: { type: DataTypes.INTEGER, allowNull: false }, }, { tableName: 'phase_products', - paranoid: false, + paranoid: true, timestamps: true, updatedAt: 'updatedAt', createdAt: 'createdAt', diff --git a/src/models/project.js b/src/models/project.js index f103a1fa..6bb6b66f 100644 --- a/src/models/project.js +++ b/src/models/project.js @@ -38,11 +38,13 @@ module.exports = function defineProject(sequelize, DataTypes) { deletedAt: { type: DataTypes.DATE, allowNull: true }, createdAt: { type: DataTypes.DATE, defaultValue: DataTypes.NOW }, updatedAt: { type: DataTypes.DATE, defaultValue: DataTypes.NOW }, + deletedBy: DataTypes.BIGINT, createdBy: { type: DataTypes.INTEGER, allowNull: false }, updatedBy: { type: DataTypes.INTEGER, allowNull: false }, version: { type: DataTypes.STRING(3), allowNull: false, defaultValue: 'v3' }, }, { tableName: 'projects', + paranoid: true, timestamps: true, updatedAt: 'updatedAt', createdAt: 'createdAt', diff --git a/src/models/projectAttachment.js b/src/models/projectAttachment.js index 8fab5508..9e917b25 100644 --- a/src/models/projectAttachment.js +++ b/src/models/projectAttachment.js @@ -12,11 +12,12 @@ module.exports = function defineProjectAttachment(sequelize, DataTypes) { deletedAt: { type: DataTypes.DATE, allowNull: true }, createdAt: { type: DataTypes.DATE, defaultValue: DataTypes.NOW }, updatedAt: { type: DataTypes.DATE, defaultValue: DataTypes.NOW }, + deletedBy: DataTypes.BIGINT, createdBy: { type: DataTypes.INTEGER, allowNull: false }, updatedBy: { type: DataTypes.INTEGER, allowNull: false }, }, { tableName: 'project_attachments', - paranoid: false, + paranoid: true, timestamps: true, updatedAt: 'updatedAt', createdAt: 'createdAt', diff --git a/src/models/projectHistory.js b/src/models/projectHistory.js index 23bdbe93..af169583 100644 --- a/src/models/projectHistory.js +++ b/src/models/projectHistory.js @@ -11,7 +11,7 @@ module.exports = function defineProjectHistory(sequelize, DataTypes) { updatedBy: { type: DataTypes.INTEGER, allowNull: false }, }, { tableName: 'project_history', - paranoid: false, + paranoid: true, timestamps: true, updatedAt: 'updatedAt', createdAt: 'createdAt', diff --git a/src/models/projectMember.js b/src/models/projectMember.js index 2ab4c94b..1003c017 100644 --- a/src/models/projectMember.js +++ b/src/models/projectMember.js @@ -17,6 +17,7 @@ module.exports = function defineProjectMember(sequelize, DataTypes) { deletedAt: { type: DataTypes.DATE, allowNull: true }, createdAt: { type: DataTypes.DATE, defaultValue: DataTypes.NOW }, updatedAt: { type: DataTypes.DATE, defaultValue: DataTypes.NOW }, + deletedBy: DataTypes.BIGINT, createdBy: { type: DataTypes.INTEGER, allowNull: false }, updatedBy: { type: DataTypes.INTEGER, allowNull: false }, }, { diff --git a/src/models/projectPhase.js b/src/models/projectPhase.js index bcfe827a..75b8e3dd 100644 --- a/src/models/projectPhase.js +++ b/src/models/projectPhase.js @@ -23,7 +23,7 @@ module.exports = function defineProjectPhase(sequelize, DataTypes) { updatedBy: { type: DataTypes.INTEGER, allowNull: false }, }, { tableName: 'project_phases', - paranoid: false, + paranoid: true, timestamps: true, updatedAt: 'updatedAt', createdAt: 'createdAt', diff --git a/src/routes/attachments/delete.js b/src/routes/attachments/delete.js index 3401c06b..f5292b88 100644 --- a/src/routes/attachments/delete.js +++ b/src/routes/attachments/delete.js @@ -37,8 +37,9 @@ module.exports = [ return Promise.reject(err); } attachment = _attachment; - return _attachment.destroy(); - }) + return _attachment.update({ deletedBy: req.authUser.userId }) + .then(() => _attachment.destroy()); + })) .then((_attachment) => { if (process.env.NODE_ENV !== 'development') { return fileService.deleteFile(req, _attachment.filePath); @@ -56,6 +57,6 @@ module.exports = [ req.app.emit(EVENT.ROUTING_KEY.PROJECT_ATTACHMENT_REMOVED, { req, pattachment }); res.status(204).json({}); }) - .catch(err => next(err))); + .catch(err => next(err)); }, ]; diff --git a/src/routes/attachments/delete.spec.js b/src/routes/attachments/delete.spec.js index 87727788..76f223d1 100644 --- a/src/routes/attachments/delete.spec.js +++ b/src/routes/attachments/delete.spec.js @@ -7,6 +7,7 @@ import models from '../../models'; import util from '../../util'; import server from '../../app'; import testUtil from '../../tests/util'; +import chai from 'chai'; describe('Project Attachments delete', () => { @@ -113,8 +114,31 @@ describe('Project Attachments delete', () => { if (err) { done(err); } else { - deleteSpy.should.have.been.calledOnce; - done(); + setTimeout(() => + models.ProjectAttachment.findOne({ + where: { + projectId: project1.id, + id: attachment.id, + }, + paranoid: false, + }) + .then((res) => { + if (!res) { + throw new Error('Should found the entity'); + } else { + deleteSpy.should.have.been.calledOnce; + + chai.assert.isNotNull(res.deletedAt); + chai.assert.isNotNull(res.deletedBy); + + request(server) + .get(`/v4/projects/${project1.id}/attachments/${attachment.id}`) + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .expect(404, done); + } + }), 500); } }); }); diff --git a/src/routes/milestoneTemplates/delete.js b/src/routes/milestoneTemplates/delete.js index bacb3e36..43d3fd57 100644 --- a/src/routes/milestoneTemplates/delete.js +++ b/src/routes/milestoneTemplates/delete.js @@ -25,33 +25,24 @@ module.exports = [ productTemplateId: req.params.productTemplateId, }; - return models.sequelize.transaction(tx => - // Update the deletedBy - models.ProductMilestoneTemplate.update({ deletedBy: req.authUser.userId }, { + return models.sequelize.transaction(() => + // soft delete the record + models.ProductMilestoneTemplate.findOne({ where, - returning: true, - raw: true, - transaction: tx, + }).then((existing) => { + if (!existing) { + // handle 404 + const err = new Error( + `Milestone template not found for milestone template id ${req.params.milestoneTemplateId}`); + err.status = 404; + return Promise.reject(err); + } + return existing.update({ deletedBy: req.authUser.userId }); }) - .then((updatedResults) => { - // Not found - if (updatedResults[0] === 0) { - const apiErr = new Error( - `Milestone template not found for milestone template id ${req.params.milestoneTemplateId}`); - apiErr.status = 404; - return Promise.reject(apiErr); - } - - // Soft delete - return models.ProductMilestoneTemplate.destroy({ - where, - transaction: tx, - }); - }) + .then(entity => entity.destroy())) .then(() => { res.status(204).end(); }) - .catch(next), - ); + .catch(next); }, ]; diff --git a/src/routes/milestoneTemplates/delete.spec.js b/src/routes/milestoneTemplates/delete.spec.js index c5c5ab0a..67d99cd0 100644 --- a/src/routes/milestoneTemplates/delete.spec.js +++ b/src/routes/milestoneTemplates/delete.spec.js @@ -6,7 +6,34 @@ import request from 'supertest'; import models from '../../models'; import server from '../../app'; import testUtil from '../../tests/util'; +import chai from 'chai'; +const expectAfterDelete = (productTemplateId, id, err, next) => { + if (err) throw err; + setTimeout(() => + models.ProductMilestoneTemplate.findOne({ + where: { + id, + productTemplateId, + }, + paranoid: false, + }) + .then((res) => { + if (!res) { + throw new Error('Should found the entity'); + } else { + chai.assert.isNotNull(res.deletedAt); + chai.assert.isNotNull(res.deletedBy); + + request(server) + .get(`/v4/productTemplates/${productTemplateId}/milestones/${id}`) + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .expect(404, next); + } + }), 500); +}; const productTemplates = [ { name: 'name 1', @@ -180,7 +207,7 @@ describe('DELETE milestone template', () => { Authorization: `Bearer ${testUtil.jwts.admin}`, }) .expect(204) - .end(done); + .end(err => expectAfterDelete(1, 1, err, done)); }); it('should return 204, for connect admin, if template was successfully removed', (done) => { @@ -190,7 +217,7 @@ describe('DELETE milestone template', () => { Authorization: `Bearer ${testUtil.jwts.connectAdmin}`, }) .expect(204) - .end(done); + .end(err => expectAfterDelete(1, 1, err, done)); }); }); }); diff --git a/src/routes/milestones/delete.spec.js b/src/routes/milestones/delete.spec.js index 21502333..477c2bb3 100644 --- a/src/routes/milestones/delete.spec.js +++ b/src/routes/milestones/delete.spec.js @@ -7,8 +7,35 @@ import models from '../../models'; import server from '../../app'; import testUtil from '../../tests/util'; import { EVENT } from '../../constants'; +import chai from 'chai'; +const expectAfterDelete = (timelineId, id, err, next) => { + if (err) throw err; + models.Milestone.findOne({ + where: { + timelineId, + id, + }, + paranoid: false, + }) + .then((res) => { + if (!res) { + throw new Error('Should found the entity'); + } else { + chai.assert.isNotNull(res.deletedAt); + chai.assert.isNotNull(res.deletedBy); + + request(server) + .get(`/v4/timelines/${timelineId}/milestones/${id}`) + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .expect(404, next); + } + }); +}; + describe('DELETE milestone', () => { beforeEach((done) => { testUtil.clearDb() @@ -57,72 +84,72 @@ describe('DELETE milestone', () => { }, ]).then(() => // Create phase - models.ProjectPhase.bulkCreate([ - { - projectId: 1, - name: 'test project phase 1', - 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 2', - }, - createdBy: 1, - updatedBy: 1, - }, - { - projectId: 2, - name: 'test project phase 2', - status: 'active', - startDate: '2018-05-16T00:00:00Z', - endDate: '2018-05-16T12:00:00Z', - budget: 21.0, - progress: 1.234567, - details: { - message: 'This can be any json 2', - }, - createdBy: 2, - updatedBy: 2, - deletedAt: '2018-05-15T00:00:00Z', - }, - ])) + models.ProjectPhase.bulkCreate([ + { + projectId: 1, + name: 'test project phase 1', + 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 2', + }, + createdBy: 1, + updatedBy: 1, + }, + { + projectId: 2, + name: 'test project phase 2', + status: 'active', + startDate: '2018-05-16T00:00:00Z', + endDate: '2018-05-16T12:00:00Z', + budget: 21.0, + progress: 1.234567, + details: { + message: 'This can be any json 2', + }, + createdBy: 2, + updatedBy: 2, + deletedAt: '2018-05-15T00:00:00Z', + }, + ])) .then(() => // Create timelines - models.Timeline.bulkCreate([ - { - name: 'name 1', - description: 'description 1', - startDate: '2018-05-11T00:00:00.000Z', - endDate: '2018-05-12T00:00:00.000Z', - reference: 'project', - referenceId: 1, - createdBy: 1, - updatedBy: 1, - }, - { - name: 'name 2', - description: 'description 2', - startDate: '2018-05-12T00:00:00.000Z', - endDate: '2018-05-13T00:00:00.000Z', - reference: 'phase', - referenceId: 1, - createdBy: 1, - updatedBy: 1, - }, - { - name: 'name 3', - description: 'description 3', - startDate: '2018-05-13T00:00:00.000Z', - endDate: '2018-05-14T00:00:00.000Z', - reference: 'phase', - referenceId: 1, - createdBy: 1, - updatedBy: 1, - deletedAt: '2018-05-14T00:00:00.000Z', - }, - ])) + models.Timeline.bulkCreate([ + { + name: 'name 1', + description: 'description 1', + startDate: '2018-05-11T00:00:00.000Z', + endDate: '2018-05-12T00:00:00.000Z', + reference: 'project', + referenceId: 1, + createdBy: 1, + updatedBy: 1, + }, + { + name: 'name 2', + description: 'description 2', + startDate: '2018-05-12T00:00:00.000Z', + endDate: '2018-05-13T00:00:00.000Z', + reference: 'phase', + referenceId: 1, + createdBy: 1, + updatedBy: 1, + }, + { + name: 'name 3', + description: 'description 3', + startDate: '2018-05-13T00:00:00.000Z', + endDate: '2018-05-14T00:00:00.000Z', + reference: 'phase', + referenceId: 1, + createdBy: 1, + updatedBy: 1, + deletedAt: '2018-05-14T00:00:00.000Z', + }, + ])) .then(() => { // Create milestones models.Milestone.bulkCreate([ @@ -275,11 +302,11 @@ describe('DELETE milestone', () => { Authorization: `Bearer ${testUtil.jwts.admin}`, }) .expect(204) - .end(() => { + .end(err => expectAfterDelete(1, 1, err, () => { // eslint-disable-next-line no-unused-expressions server.services.pubsub.publish.calledWith(EVENT.ROUTING_KEY.MILESTONE_REMOVED).should.be.true; done(); - }); + })); }); it('should return 204, for connect admin, if timeline was successfully removed', (done) => { @@ -289,7 +316,7 @@ describe('DELETE milestone', () => { Authorization: `Bearer ${testUtil.jwts.connectAdmin}`, }) .expect(204) - .end(done); + .end(err => expectAfterDelete(1, 1, err, done)); }); it('should return 204, for connect manager, if timeline was successfully removed', (done) => { @@ -299,7 +326,7 @@ describe('DELETE milestone', () => { Authorization: `Bearer ${testUtil.jwts.manager}`, }) .expect(204) - .end(done); + .end(err => expectAfterDelete(1, 1, err, done)); }); it('should return 204, for copilot, if timeline was successfully removed', (done) => { @@ -309,7 +336,7 @@ describe('DELETE milestone', () => { Authorization: `Bearer ${testUtil.jwts.copilot}`, }) .expect(204) - .end(done); + .end(err => expectAfterDelete(1, 1, err, done)); }); it('should return 204, for member, if timeline was successfully removed', (done) => { @@ -319,7 +346,7 @@ describe('DELETE milestone', () => { Authorization: `Bearer ${testUtil.jwts.member}`, }) .expect(204) - .end(done); + .end(err => expectAfterDelete(1, 1, err, done)); }); }); }); diff --git a/src/routes/phaseProducts/delete.js b/src/routes/phaseProducts/delete.js index 2bc50b7f..b8efbb39 100644 --- a/src/routes/phaseProducts/delete.js +++ b/src/routes/phaseProducts/delete.js @@ -25,18 +25,17 @@ module.exports = [ phaseId, deletedAt: { $eq: null }, }, - }).then(existing => new Promise((accept, reject) => { + }).then((existing) => { if (!existing) { // handle 404 const err = new Error('No active phase product found for project id ' + `${projectId}, phase id ${phaseId} and product id ${productId}`); err.status = 404; - reject(err); - } else { - _.extend(existing, { deletedBy: req.authUser.userId, deletedAt: Date.now() }); - existing.save().then(accept).catch(reject); + return Promise.reject(err); } - }))) + return existing.update({ deletedBy: req.authUser.userId }); + }) + .then(entity => entity.destroy())) .then((deleted) => { req.log.debug('deleted phase product', JSON.stringify(deleted, null, 2)); @@ -49,6 +48,7 @@ module.exports = [ req.app.emit(EVENT.ROUTING_KEY.PROJECT_PHASE_PRODUCT_REMOVED, { req, deleted }); res.status(204).json({}); - }).catch(err => next(err)); + }) + .catch(err => next(err)); }, ]; diff --git a/src/routes/phaseProducts/delete.spec.js b/src/routes/phaseProducts/delete.spec.js index 9bf234b4..7901475c 100644 --- a/src/routes/phaseProducts/delete.spec.js +++ b/src/routes/phaseProducts/delete.spec.js @@ -4,7 +4,35 @@ import request from 'supertest'; import server from '../../app'; import models from '../../models'; import testUtil from '../../tests/util'; +import chai from 'chai'; +const expectAfterDelete = (projectId, phaseId, id, err, next) => { + if (err) throw err; + setTimeout(() => + models.PhaseProduct.findOne({ + where: { + id, + projectId, + phaseId, + }, + paranoid: false, + }) + .then((res) => { + if (!res) { + throw new Error('Should found the entity'); + } else { + chai.assert.isNotNull(res.deletedAt); + chai.assert.isNotNull(res.deletedBy); + + request(server) + .get(`/v4/projects/${projectId}/phases/${phaseId}/products/${id}`) + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .expect(404, next); + } + }), 500); +}; const body = { name: 'test phase product', type: 'product1', @@ -156,7 +184,8 @@ describe('Phase Products', () => { .set({ Authorization: `Bearer ${testUtil.jwts.copilot}`, }) - .expect(204, done); + .expect(204) + .end(err => expectAfterDelete(projectId, phaseId, productId, err, done)); }); }); }); diff --git a/src/routes/phases/delete.js b/src/routes/phases/delete.js index 5ba2461d..afa440e0 100644 --- a/src/routes/phases/delete.js +++ b/src/routes/phases/delete.js @@ -23,18 +23,17 @@ module.exports = [ projectId, deletedAt: { $eq: null }, }, - }).then(existing => new Promise((accept, reject) => { + }).then((existing) => { if (!existing) { // handle 404 const err = new Error('no active project phase found for project id ' + `${projectId} and phase id ${phaseId}`); err.status = 404; - reject(err); - } else { - _.extend(existing, { deletedBy: req.authUser.userId, deletedAt: Date.now() }); - existing.save().then(accept).catch(reject); + return Promise.reject(err); } - }))) + return existing.update({ deletedBy: req.authUser.userId }); + }) + .then(entity => entity.destroy())) .then((deleted) => { req.log.debug('deleted project phase', JSON.stringify(deleted, null, 2)); diff --git a/src/routes/phases/delete.spec.js b/src/routes/phases/delete.spec.js index 43a56b13..ca93208b 100644 --- a/src/routes/phases/delete.spec.js +++ b/src/routes/phases/delete.spec.js @@ -4,7 +4,34 @@ import request from 'supertest'; import server from '../../app'; import models from '../../models'; import testUtil from '../../tests/util'; +import chai from 'chai'; +const expectAfterDelete = (projectId, id, err, next) => { + if (err) throw err; + setTimeout(() => + models.ProjectPhase.findOne({ + where: { + id, + projectId, + }, + paranoid: false, + }) + .then((res) => { + if (!res) { + throw new Error('Should found the entity'); + } else { + chai.assert.isNotNull(res.deletedAt); + chai.assert.isNotNull(res.deletedBy); + + request(server) + .get(`/v4/projects/${projectId}/phases/${id}`) + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .expect(404, next); + } + }), 500); +}; const body = { name: 'test project phase', status: 'active', @@ -130,7 +157,7 @@ describe('Project Phases', () => { .set({ Authorization: `Bearer ${testUtil.jwts.copilot}`, }) - .expect(204, done); + .end(err => expectAfterDelete(projectId, phaseId, err, done)); }); }); }); diff --git a/src/routes/productCategories/delete.js b/src/routes/productCategories/delete.js index b12170fb..89f575bd 100644 --- a/src/routes/productCategories/delete.js +++ b/src/routes/productCategories/delete.js @@ -17,38 +17,21 @@ const schema = { module.exports = [ validate(schema), permissions('productCategory.delete'), - (req, res, next) => { - const where = { - deletedAt: { $eq: null }, - key: req.params.key, - }; - - return models.sequelize.transaction(tx => - // Update the deletedBy - models.ProductCategory.update({ deletedBy: req.authUser.userId }, { - where, - returning: true, - raw: true, - transaction: tx, - }) - .then((updatedResults) => { - // Not found - if (updatedResults[0] === 0) { + (req, res, next) => + models.sequelize.transaction(() => + models.ProductCategory.findById(req.params.key) + .then((entity) => { + if (!entity) { const apiErr = new Error(`Product category not found for key ${req.params.key}`); apiErr.status = 404; return Promise.reject(apiErr); } - - // Soft delete - return models.ProductCategory.destroy({ - where, - transaction: tx, - }); + // Update the deletedBy, then delete + return entity.update({ deletedBy: req.authUser.userId }); }) + .then(entity => entity.destroy())) .then(() => { res.status(204).end(); }) .catch(next), - ); - }, ]; diff --git a/src/routes/productCategories/delete.spec.js b/src/routes/productCategories/delete.spec.js index 4fe89a64..dc33a92f 100644 --- a/src/routes/productCategories/delete.spec.js +++ b/src/routes/productCategories/delete.spec.js @@ -2,12 +2,39 @@ * Tests for delete.js */ import request from 'supertest'; +import chai from 'chai'; import models from '../../models'; import server from '../../app'; import testUtil from '../../tests/util'; +const expectAfterDelete = (key, err, next) => { + if (err) throw err; + setTimeout(() => + models.ProductCategory.findOne({ + where: { + key, + }, + paranoid: false, + }) + .then((res) => { + if (!res) { + throw new Error('Should found the entity'); + } else { + chai.assert.isNotNull(res.deletedAt); + chai.assert.isNotNull(res.deletedBy); + + request(server) + .get(`/v4/productCategories/${key}`) + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .expect(404, next); + } + }), 500); +}; + describe('DELETE product category', () => { const key = 'key1'; @@ -87,7 +114,7 @@ describe('DELETE product category', () => { Authorization: `Bearer ${testUtil.jwts.admin}`, }) .expect(204) - .end(done); + .end(err => expectAfterDelete(key, err, done)); }); it('should return 204, for connect admin, if the product category was successfully removed', (done) => { @@ -97,7 +124,7 @@ describe('DELETE product category', () => { Authorization: `Bearer ${testUtil.jwts.connectAdmin}`, }) .expect(204) - .end(done); + .end(err => expectAfterDelete(key, err, done)); }); }); }); diff --git a/src/routes/productTemplates/delete.js b/src/routes/productTemplates/delete.js index 81c65b6b..03ce1d3b 100644 --- a/src/routes/productTemplates/delete.js +++ b/src/routes/productTemplates/delete.js @@ -17,39 +17,21 @@ const schema = { module.exports = [ validate(schema), permissions('productTemplate.delete'), - (req, res, next) => { - const where = { - deletedAt: { $eq: null }, - id: req.params.templateId, - }; - - return models.sequelize.transaction(tx => - // Update the deletedBy - models.ProductTemplate.update({ deletedBy: req.authUser.userId }, { - where, - returning: true, - raw: true, - transaction: tx, - }) - .then((updatedResults) => { - // Not found - if (updatedResults[0] === 0) { + (req, res, next) => + models.sequelize.transaction(() => + models.ProductTemplate.findById(req.params.templateId) + .then((entity) => { + if (!entity) { const apiErr = new Error(`Product template not found for template id ${req.params.templateId}`); apiErr.status = 404; return Promise.reject(apiErr); } - - // Soft delete - return models.ProductTemplate.destroy({ - where, - transaction: tx, - raw: true, - }); + // Update the deletedBy, then delete + return entity.update({ deletedBy: req.authUser.userId }); }) + .then(entity => entity.destroy())) .then(() => { res.status(204).end(); }) .catch(next), - ); - }, ]; diff --git a/src/routes/productTemplates/delete.spec.js b/src/routes/productTemplates/delete.spec.js index e76e9c67..0f39eb15 100644 --- a/src/routes/productTemplates/delete.spec.js +++ b/src/routes/productTemplates/delete.spec.js @@ -2,11 +2,38 @@ * Tests for delete.js */ import request from 'supertest'; +import chai from 'chai'; import models from '../../models'; import server from '../../app'; import testUtil from '../../tests/util'; +const expectAfterDelete = (id, err, next) => { + if (err) throw err; + setTimeout(() => + models.ProductTemplate.findOne({ + where: { + id, + }, + paranoid: false, + }) + .then((res) => { + if (!res) { + throw new Error('Should found the entity'); + } else { + chai.assert.isNotNull(res.deletedAt); + chai.assert.isNotNull(res.deletedBy); + + request(server) + .get(`/v4/productTemplates/${id}`) + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .expect(404, next); + } + }), 500); +}; + describe('DELETE product template', () => { let templateId; @@ -107,7 +134,7 @@ describe('DELETE product template', () => { Authorization: `Bearer ${testUtil.jwts.admin}`, }) .expect(204) - .end(done); + .end(err => expectAfterDelete(templateId, err, done)); }); it('should return 204, for connect admin, if template was successfully removed', (done) => { @@ -117,7 +144,7 @@ describe('DELETE product template', () => { Authorization: `Bearer ${testUtil.jwts.connectAdmin}`, }) .expect(204) - .end(done); + .end(err => expectAfterDelete(templateId, err, done)); }); }); }); diff --git a/src/routes/projectMembers/delete.js b/src/routes/projectMembers/delete.js index d0f83ff3..43e1f037 100644 --- a/src/routes/projectMembers/delete.js +++ b/src/routes/projectMembers/delete.js @@ -24,12 +24,13 @@ module.exports = [ }) .then((member) => { if (!member) { - const err = new Error('Record not found'); + const err = new Error(`Project member not found for member id ${req.params.id}`); err.status = 404; return Promise.reject(err); } - return member.destroy({ logging: console.log }); // eslint-disable-line no-console + return member.update({ deletedBy: req.authUser.userId }); // eslint-disable-line no-console }) + .then(member => member.destroy({ logging: console.log })) .then(member => member.save()) // if primary co-pilot is removed promote the next co-pilot to primary #43 .then(member => new Promise((accept, reject) => { diff --git a/src/routes/projectMembers/delete.spec.js b/src/routes/projectMembers/delete.spec.js index e600742d..3667bcff 100644 --- a/src/routes/projectMembers/delete.spec.js +++ b/src/routes/projectMembers/delete.spec.js @@ -11,6 +11,24 @@ import testUtil from '../../tests/util'; const should = chai.should(); +const expectAfterDelete = (projectId, id, err, next) => { + if (err) throw err; + setTimeout(() => + models.ProjectMember.findOne({ + where: { + id, + projectId, + }, + paranoid: false, + }) + .then((res) => { + if (!res) { + throw new Error('Should found the entity'); + } else { + next(); + } + }), 500); +}; describe('Project members delete', () => { let project1; let member1; @@ -109,9 +127,7 @@ describe('Project members delete', () => { }) .expect(204) .end((err) => { - if (err) { - done(err); - } else { + expectAfterDelete(project1.id, member1.id, err, () => { const removedMember = { projectId: project1.id, userId: 40051332, @@ -121,7 +137,7 @@ describe('Project members delete', () => { server.services.pubsub.publish.calledWith('project.member.removed', sinon.match(removedMember)).should.be.true; done(); - } + }); // models.ProjectMember // .count({where: { projectId: project1.id, deletedAt: { $eq: null } }}) @@ -170,9 +186,7 @@ describe('Project members delete', () => { }) .expect(204) .end((err) => { - if (err) { - done(err); - } else { + expectAfterDelete(project1.id, member1.id, err, () => { const removedMember = { projectId: project1.id, userId: 40051332, @@ -201,7 +215,7 @@ describe('Project members delete', () => { plain.userId.should.equal(40051331); done(); }); - } + }); }); }); }); @@ -230,9 +244,7 @@ describe('Project members delete', () => { }) .expect(204) .end((err) => { - if (err) { - done(err); - } else { + expectAfterDelete(project1.id, member2.id, err, () => { const removedMember = { projectId: project1.id, userId: 40051334, @@ -243,7 +255,7 @@ describe('Project members delete', () => { sinon.match(removedMember)).should.be.true; postSpy.should.have.been.calledOnce; done(); - } + }); }); }); @@ -279,9 +291,7 @@ describe('Project members delete', () => { }) .expect(204) .end((err) => { - if (err) { - done(err); - } else { + expectAfterDelete(project1.id, member2.id, err, () => { const removedMember = { projectId: project1.id, userId: 40051334, @@ -292,7 +302,7 @@ describe('Project members delete', () => { sinon.match(removedMember)).should.be.true; postSpy.should.not.have.been.calledOnce; done(); - } + }); }); }); }); diff --git a/src/routes/projectTemplates/delete.js b/src/routes/projectTemplates/delete.js index 4db9a855..b3f353b6 100644 --- a/src/routes/projectTemplates/delete.js +++ b/src/routes/projectTemplates/delete.js @@ -17,39 +17,21 @@ const schema = { module.exports = [ validate(schema), permissions('projectTemplate.delete'), - (req, res, next) => { - const where = { - deletedAt: { $eq: null }, - id: req.params.templateId, - }; - - return models.sequelize.transaction(tx => - // Update the deletedBy - models.ProjectTemplate.update({ deletedBy: req.authUser.userId }, { - where, - returning: true, - raw: true, - transaction: tx, - }) - .then((updatedResults) => { - // Not found - if (updatedResults[0] === 0) { + (req, res, next) => + models.sequelize.transaction(() => + models.ProjectTemplate.findById(req.params.templateId) + .then((entity) => { + if (!entity) { const apiErr = new Error(`Project template not found for template id ${req.params.templateId}`); apiErr.status = 404; return Promise.reject(apiErr); } - - // Soft delete - return models.ProjectTemplate.destroy({ - where, - transaction: tx, - raw: true, - }); + // Update the deletedBy, then delete + return entity.update({ deletedBy: req.authUser.userId }); }) + .then(entity => entity.destroy())) .then(() => { res.status(204).end(); }) .catch(next), - ); - }, ]; diff --git a/src/routes/projectTemplates/delete.spec.js b/src/routes/projectTemplates/delete.spec.js index f82d61ba..a475ee5d 100644 --- a/src/routes/projectTemplates/delete.spec.js +++ b/src/routes/projectTemplates/delete.spec.js @@ -2,11 +2,37 @@ * Tests for delete.js */ import request from 'supertest'; +import chai from 'chai'; import models from '../../models'; import server from '../../app'; import testUtil from '../../tests/util'; +const expectAfterDelete = (id, err, next) => { + if (err) throw err; + setTimeout(() => + models.ProjectTemplate.findOne({ + where: { + id, + }, + paranoid: false, + }) + .then((res) => { + if (!res) { + throw new Error('Should found the entity'); + } else { + chai.assert.isNotNull(res.deletedAt); + chai.assert.isNotNull(res.deletedBy); + + request(server) + .get(`/v4/projectTemplates/${id}`) + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .expect(404, next); + } + }), 500); +}; describe('DELETE project template', () => { let templateId; @@ -113,8 +139,7 @@ describe('DELETE project template', () => { .set({ Authorization: `Bearer ${testUtil.jwts.admin}`, }) - .expect(204) - .end(done); + .end(err => expectAfterDelete(templateId, err, done)); }); it('should return 204, for connect admin, if template was successfully removed', (done) => { @@ -124,7 +149,7 @@ describe('DELETE project template', () => { Authorization: `Bearer ${testUtil.jwts.connectAdmin}`, }) .expect(204) - .end(done); + .end(err => expectAfterDelete(templateId, err, done)); }); }); }); diff --git a/src/routes/projectTypes/delete.js b/src/routes/projectTypes/delete.js index 7592641c..2f5e2f07 100644 --- a/src/routes/projectTypes/delete.js +++ b/src/routes/projectTypes/delete.js @@ -17,38 +17,21 @@ const schema = { module.exports = [ validate(schema), permissions('projectType.delete'), - (req, res, next) => { - const where = { - deletedAt: { $eq: null }, - key: req.params.key, - }; - - return models.sequelize.transaction(tx => - // Update the deletedBy - models.ProjectType.update({ deletedBy: req.authUser.userId }, { - where, - returning: true, - raw: true, - transaction: tx, - }) - .then((updatedResults) => { - // Not found - if (updatedResults[0] === 0) { + (req, res, next) => + models.sequelize.transaction(() => + models.ProjectType.findById(req.params.key) + .then((entity) => { + if (!entity) { const apiErr = new Error(`Project type not found for key ${req.params.key}`); apiErr.status = 404; return Promise.reject(apiErr); } - - // Soft delete - return models.ProjectType.destroy({ - where, - transaction: tx, - }); + // Update the deletedBy, then delete + return entity.update({ deletedBy: req.authUser.userId }); }) + .then(entity => entity.destroy())) .then(() => { res.status(204).end(); }) .catch(next), - ); - }, ]; diff --git a/src/routes/projectTypes/delete.spec.js b/src/routes/projectTypes/delete.spec.js index 053bfb1c..5a063187 100644 --- a/src/routes/projectTypes/delete.spec.js +++ b/src/routes/projectTypes/delete.spec.js @@ -2,11 +2,36 @@ * Tests for delete.js */ import request from 'supertest'; - +import chai from 'chai'; import models from '../../models'; import server from '../../app'; import testUtil from '../../tests/util'; +const expectAfterDelete = (key, err, next) => { + if (err) throw err; + setTimeout(() => + models.ProjectType.findOne({ + where: { + key, + }, + paranoid: false, + }) + .then((res) => { + if (!res) { + throw new Error('Should found the entity'); + } else { + chai.assert.isNotNull(res.deletedAt); + chai.assert.isNotNull(res.deletedBy); + + request(server) + .get(`/v4/projectTypes/${key}`) + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .expect(404, next); + } + }), 500); +}; describe('DELETE project type', () => { const key = 'key1'; @@ -87,7 +112,7 @@ describe('DELETE project type', () => { Authorization: `Bearer ${testUtil.jwts.admin}`, }) .expect(204) - .end(done); + .end(err => expectAfterDelete(key, err, done)); }); it('should return 204, for connect admin, if type was successfully removed', (done) => { @@ -97,7 +122,7 @@ describe('DELETE project type', () => { Authorization: `Bearer ${testUtil.jwts.connectAdmin}`, }) .expect(204) - .end(done); + .end(err => expectAfterDelete(key, err, done)); }); }); }); diff --git a/src/routes/projects/delete.js b/src/routes/projects/delete.js index 6b23d486..915d91d9 100644 --- a/src/routes/projects/delete.js +++ b/src/routes/projects/delete.js @@ -16,30 +16,28 @@ module.exports = [ (req, res, next) => { const projectId = _.parseInt(req.params.projectId); - models.sequelize.transaction(t => - // soft delete the record - models.Project.destroy({ - where: { id: projectId }, - cascade: true, - transaction: t, - }), - ) - .then((count) => { - if (count === 0) { - const err = new Error('Project not found'); - err.status = 404; - next(err); - } else { - req.app.services.pubsub.publish( - EVENT.ROUTING_KEY.PROJECT_DELETED, - { id: projectId }, - { correlationId: req.id }, - ); - // emit event - req.app.emit(EVENT.ROUTING_KEY.PROJECT_DELETED, { req, id: projectId }); - res.status(204).json({}); - } - }) - .catch(err => next(err)); + models.sequelize.transaction(() => + models.Project.findById(req.params.projectId) + .then((entity) => { + if (!entity) { + const apiErr = new Error(`Project template not found for template id ${projectId}`); + apiErr.status = 404; + return Promise.reject(apiErr); + } + // Update the deletedBy, then delete + return entity.update({ deletedBy: req.authUser.userId }); + }) + .then(project => project.destroy({ cascade: true }))) + .then(() => { + req.app.services.pubsub.publish( + EVENT.ROUTING_KEY.PROJECT_DELETED, + { id: projectId }, + { correlationId: req.id }, + ); + // emit event + req.app.emit(EVENT.ROUTING_KEY.PROJECT_DELETED, { req, id: projectId }); + res.status(204).json({}); + }) + .catch(err => next(err)); }, ]; diff --git a/src/routes/projects/delete.spec.js b/src/routes/projects/delete.spec.js index 8fee3330..4d1e76e6 100644 --- a/src/routes/projects/delete.spec.js +++ b/src/routes/projects/delete.spec.js @@ -4,70 +4,97 @@ import request from 'supertest'; import models from '../../models'; import server from '../../app'; import testUtil from '../../tests/util'; +import chai from 'chai'; +const expectAfterDelete = (id, err, next) => { + if (err) throw err; + setTimeout(() => + models.Project.findOne({ + where: { + id, + }, + paranoid: false, + }) + .then((res) => { + if (!res) { + throw new Error('Should found the entity'); + } else { + server.services.pubsub.publish.calledWith('project.deleted').should.be.true; + chai.assert.isNotNull(res.deletedAt); + chai.assert.isNotNull(res.deletedBy); + + request(server) + .get(`/v4/projects/${id}`) + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .expect(404, next); + } + }), 500); +}; describe('Project delete test', () => { let project1; beforeEach((done) => { testUtil.clearDb() - .then(() => { - models.Project.create({ - type: 'generic', - directProjectId: 1, - billingAccountId: 1, - name: 'test1', - description: 'test project1', - status: 'draft', - details: {}, - createdBy: 1, - updatedBy: 1, - }).then((p) => { - project1 = p; - // create members - const promises = [ - // owner - models.ProjectMember.create({ - userId: 40051331, - projectId: project1.id, - role: 'customer', - isPrimary: true, - createdBy: 1, - updatedBy: 1, - }), - // manager - models.ProjectMember.create({ - userId: 40051334, - projectId: project1.id, - role: 'manager', - isPrimary: true, - createdBy: 1, - updatedBy: 1, - }), - // copilot - models.ProjectMember.create({ - userId: 40051332, - projectId: project1.id, - role: 'copilot', - isPrimary: true, - createdBy: 1, - updatedBy: 1, - }), - // team member - models.ProjectMember.create({ - userId: 40051335, - projectId: project1.id, - role: 'customer', - isPrimary: false, - createdBy: 1, - updatedBy: 1, - }), - ]; - Promise.all(promises) + .then(() => { + models.Project.create({ + type: 'generic', + directProjectId: 1, + billingAccountId: 1, + name: 'test1', + description: 'test project1', + status: 'draft', + details: {}, + createdBy: 1, + updatedBy: 1, + }).then((p) => { + project1 = p; + // create members + const promises = [ + // owner + models.ProjectMember.create({ + userId: 40051331, + projectId: project1.id, + role: 'customer', + isPrimary: true, + createdBy: 1, + updatedBy: 1, + }), + // manager + models.ProjectMember.create({ + userId: 40051334, + projectId: project1.id, + role: 'manager', + isPrimary: true, + createdBy: 1, + updatedBy: 1, + }), + // copilot + models.ProjectMember.create({ + userId: 40051332, + projectId: project1.id, + role: 'copilot', + isPrimary: true, + createdBy: 1, + updatedBy: 1, + }), + // team member + models.ProjectMember.create({ + userId: 40051335, + projectId: project1.id, + role: 'customer', + isPrimary: false, + createdBy: 1, + updatedBy: 1, + }), + ]; + Promise.all(promises) .then(() => { done(); }); - }); }); + }); }); after((done) => { @@ -92,12 +119,7 @@ describe('Project delete test', () => { }) .expect(204) .end((err) => { - if (err) { - done(err); - } else { - server.services.pubsub.publish.calledWith('project.deleted').should.be.true; - done(); - } + expectAfterDelete(project1.id, err, done); }); }); @@ -109,12 +131,7 @@ describe('Project delete test', () => { }) .expect(204) .end((err) => { - if (err) { - done(err); - } else { - server.services.pubsub.publish.calledWith('project.deleted').should.be.true; - done(); - } + expectAfterDelete(project1.id, err, done); }); }); @@ -126,12 +143,7 @@ describe('Project delete test', () => { }) .expect(204) .end((err) => { - if (err) { - done(err); - } else { - server.services.pubsub.publish.calledWith('project.deleted').should.be.true; - done(); - } + expectAfterDelete(project1.id, err, done); }); }); }); diff --git a/src/routes/timelines/delete.spec.js b/src/routes/timelines/delete.spec.js index 76a0fb55..44ad6f07 100644 --- a/src/routes/timelines/delete.spec.js +++ b/src/routes/timelines/delete.spec.js @@ -11,6 +11,32 @@ import { EVENT } from '../../constants'; const should = chai.should(); // eslint-disable-line no-unused-vars +const expectAfterDelete = (id, err, next) => { + if (err) throw err; + setTimeout(() => + models.Timeline.findOne({ + where: { + id, + }, + paranoid: false, + }) + .then((res) => { + if (!res) { + throw new Error('Should found the entity'); + } else { + chai.assert.isNotNull(res.deletedAt); + chai.assert.isNotNull(res.deletedBy); + + request(server) + .get(`/v4/timelines/${id}`) + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .expect(404, next); + } + }), 500); +}; + describe('DELETE timeline', () => { beforeEach((done) => { testUtil.clearDb() @@ -180,6 +206,7 @@ describe('DELETE timeline', () => { after(testUtil.clearDb); + describe('DELETE /timelines/{timelineId}', () => { it('should return 403 if user is not authenticated', (done) => { request(server) @@ -246,19 +273,21 @@ describe('DELETE timeline', () => { Authorization: `Bearer ${testUtil.jwts.admin}`, }) .expect(204) - .end(() => { - // eslint-disable-next-line no-unused-expressions - server.services.pubsub.publish.calledWith(EVENT.ROUTING_KEY.TIMELINE_REMOVED).should.be.true; + .end((err) => { + expectAfterDelete(1, err, () => { + // eslint-disable-next-line no-unused-expressions + server.services.pubsub.publish.calledWith(EVENT.ROUTING_KEY.TIMELINE_REMOVED).should.be.true; - // Milestones are cascade deleted - setTimeout(() => { - models.Milestone.findAll({ where: { timelineId: 1 } }) - .then((afterResults) => { - afterResults.should.have.length(0); + // Milestones are cascade deleted + setTimeout(() => { + models.Milestone.findAll({ where: { timelineId: 1 } }) + .then((afterResults) => { + afterResults.should.have.length(0); - done(); - }); - }, 3000); + done(); + }); + }, 3000); + }); }); }); }); @@ -270,7 +299,7 @@ describe('DELETE timeline', () => { Authorization: `Bearer ${testUtil.jwts.connectAdmin}`, }) .expect(204) - .end(done); + .end(err => expectAfterDelete(1, err, done)); }); it('should return 204, for connect manager, if timeline was successfully removed', (done) => { @@ -280,7 +309,7 @@ describe('DELETE timeline', () => { Authorization: `Bearer ${testUtil.jwts.manager}`, }) .expect(204) - .end(done); + .end(err => expectAfterDelete(1, err, done)); }); it('should return 204, for copilot, if timeline was successfully removed', (done) => { @@ -290,7 +319,7 @@ describe('DELETE timeline', () => { Authorization: `Bearer ${testUtil.jwts.copilot}`, }) .expect(204) - .end(done); + .end(err => expectAfterDelete(1, err, done)); }); it('should return 204, for member, if timeline was successfully removed', (done) => { @@ -300,7 +329,7 @@ describe('DELETE timeline', () => { Authorization: `Bearer ${testUtil.jwts.member}`, }) .expect(204) - .end(done); + .end(err => expectAfterDelete(1, err, done)); }); }); }); From b2ca8706192963a655045f8a6a0ae4386b293eba Mon Sep 17 00:00:00 2001 From: Thiyagu GK Date: Sun, 29 Jul 2018 01:16:11 +0530 Subject: [PATCH 30/73] Fix for issue#133 --- postman.json | 1120 ++++++++++++++++++++------------------------- src/tests/seed.js | 64 +++ 2 files changed, 569 insertions(+), 615 deletions(-) diff --git a/postman.json b/postman.json index fda31b5e..fe2a6ee1 100644 --- a/postman.json +++ b/postman.json @@ -1,14 +1,268 @@ { "info": { - "_postman_id": "440ee43d-66ca-4c9b-858d-22db97ea4cea", "name": "tc-project-service", + "_postman_id": "63bb8939-b1c0-0c3c-ad9d-68e63063eda7", + "description": "", "schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json" }, "item": [ { "name": "Project Attachments", - "description": null, "item": [ + { + "name": "bookmarks", + "item": [ + { + "name": " Create project without bookmarks", + "request": { + "method": "POST", + "header": [ + { + "key": "Authorization", + "value": "Bearer {{jwt-token}}" + }, + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"param\": {\n \"type\": \"generic\",\n \"description\": \"test project\",\n \"details\": {},\n \"billingAccountId\": 123,\n \"name\": \"test project1\"\n }\n}" + }, + "url": { + "raw": "{{api-url}}/v4/projects", + "host": [ + "{{api-url}}" + ], + "path": [ + "v4", + "projects" + ] + } + }, + "response": [] + }, + { + "name": " Create project with valid bookmarks", + "request": { + "method": "POST", + "header": [ + { + "key": "Authorization", + "value": "Bearer {{jwt-token}}" + }, + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"param\": {\n \"type\": \"generic\",\n \"description\": \"test project\",\n \"details\": {},\n \"bookmarks\":[{\n \"title\":\"title1\",\n \"address\":\"address1\"\n },{\n \"title\":\"title2\",\n \"address\":\"address2\"\n }],\n \"billingAccountId\": 123,\n \"name\": \"test project1\"\n }\n}" + }, + "url": { + "raw": "{{api-url}}/v4/projects", + "host": [ + "{{api-url}}" + ], + "path": [ + "v4", + "projects" + ] + } + }, + "response": [] + }, + { + "name": " Create project with invalid bookmarks", + "request": { + "method": "POST", + "header": [ + { + "key": "Authorization", + "value": "Bearer {{jwt-token}}" + }, + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"param\": {\n \"type\": \"generic\",\n \"description\": \"test project\",\n \"details\": {},\n \"bookmarks\":[{\n \"title\":\"title1\",\n \"invalid\":3,\n \"address\":\"address1\"\n },{\n \"title\":\"title2\",\n \"address\":\"address2\"\n }],\n \"billingAccountId\": 123,\n \"name\": \"test project1\"\n }\n}" + }, + "url": { + "raw": "{{api-url}}/v4/projects", + "host": [ + "{{api-url}}" + ], + "path": [ + "v4", + "projects" + ] + } + }, + "response": [] + }, + { + "name": "get project", + "request": { + "method": "GET", + "header": [ + { + "key": "Authorization", + "value": "Bearer {{jwt-token}}" + }, + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"param\": {\n \"billingAccountId\": 9999, \n \"name\": \"new project name\"\n }\n}" + }, + "url": { + "raw": "{{api-url}}/v4/projects/2", + "host": [ + "{{api-url}}" + ], + "path": [ + "v4", + "projects", + "2" + ] + } + }, + "response": [] + }, + { + "name": "Update project with bookmarks", + "request": { + "method": "PATCH", + "header": [ + { + "key": "Authorization", + "value": "Bearer {{jwt-token}}" + }, + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"param\": {\n \"billingAccountId\": 9999, \n \"name\": \"new project name\",\n \"bookmarks\":[{\n \"title\":\"title1\",\n \"address\":\"address1\"\n },{\n \"title\":\"title2\",\n \"address\":\"address2\"\n }]\n }\n}" + }, + "url": { + "raw": "{{api-url}}/v4/projects/2", + "host": [ + "{{api-url}}" + ], + "path": [ + "v4", + "projects", + "2" + ] + } + }, + "response": [] + }, + { + "name": "Delete project all bookmarks null", + "request": { + "method": "PATCH", + "header": [ + { + "key": "Authorization", + "value": "Bearer {{jwt-token}}" + }, + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"param\": {\n \"billingAccountId\": 9999, \n \"name\": \"new project name2\",\n \"bookmarks\":null\n }\n}" + }, + "url": { + "raw": "{{api-url}}/v4/projects/2", + "host": [ + "{{api-url}}" + ], + "path": [ + "v4", + "projects", + "2" + ] + } + }, + "response": [] + }, + { + "name": "Update project with invalid bookmarks", + "request": { + "method": "PATCH", + "header": [ + { + "key": "Authorization", + "value": "Bearer {{jwt-token}}" + }, + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"param\": {\n \"billingAccountId\": 9999, \n \"name\": \"new project name2\",\n \"bookmarks\":3\n }\n}" + }, + "url": { + "raw": "{{api-url}}/v4/projects/2", + "host": [ + "{{api-url}}" + ], + "path": [ + "v4", + "projects", + "2" + ] + } + }, + "response": [] + }, + { + "name": "get projects with admin token", + "request": { + "method": "GET", + "header": [ + { + "key": "Authorization", + "value": "Bearer {{jwt-token}}" + } + ], + "body": { + "mode": "raw", + "raw": "" + }, + "url": { + "raw": "{{api-url}}/v4/projects", + "host": [ + "{{api-url}}" + ], + "path": [ + "v4", + "projects" + ] + } + }, + "response": [] + } + ], + "_postman_isSubFolder": true + }, { "name": "Upload attachment", "request": { @@ -115,9 +369,76 @@ } ] }, + { + "name": "Project With TemplateId issue", + "description": "", + "item": [ + { + "name": "Create project with templateId (not existed)", + "request": { + "method": "POST", + "header": [ + { + "key": "Authorization", + "value": "Bearer {{jwt-token}}" + }, + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n\t\"param\": {\n\t\t\"name\": \"test project with templateId\",\n\t\t\"description\": \"Hello I am a test project with templateId\",\n\t\t\"type\": \"generic\",\n\t\t\"templateId\": 3000\n\t}\n}" + }, + "url": { + "raw": "{{api-url}}/v4/projects", + "host": [ + "{{api-url}}" + ], + "path": [ + "v4", + "projects" + ] + } + }, + "response": [] + }, + { + "name": "Create project with templateId", + "request": { + "method": "POST", + "header": [ + { + "key": "Authorization", + "value": "Bearer {{jwt-token}}" + }, + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"param\": {\n \"name\": \"test project with templateId\",\n \"description\": \"Hello I am a test project with templateId\",\n \"type\": \"generic\",\n \"templateId\": 3\n }\n}" + }, + "url": { + "raw": "{{api-url}}/v4/projects", + "host": [ + "{{api-url}}" + ], + "path": [ + "v4", + "projects" + ] + } + }, + "response": [] + } + ] + }, { "name": "Project Members", - "description": null, "item": [ { "name": "Create project member with no payload", @@ -169,7 +490,7 @@ ], "body": { "mode": "raw", - "raw": "{\n\t\"role\": \"copilot\"\n}" + "raw": "{\n\"param\":{\n\t\"role\": \"copilot\"\n}\n}" }, "url": { "raw": "{{api-url}}/v4/projects/1/members", @@ -342,16 +663,16 @@ "raw": "{\n\t\"param\": {\n\t\t\"role\": \"copilot\",\n\t\t\"isPrimary\": true\n\t}\n}" }, "url": { - "raw": "{{api-url}}/v4/projects/7/members/16", + "raw": "{{api-url}}/v4/projects/1/members/1", "host": [ "{{api-url}}" ], "path": [ "v4", "projects", - "7", + "1", "members", - "16" + "1" ] }, "description": "Update a project's member." @@ -377,22 +698,56 @@ "raw": "{\n\t\"param\": {\n\t\t\"role\": \"copilot\",\n\t\t\"isPrimary\": false\n\t}\n}" }, "url": { - "raw": "{{api-url}}/v4/projects/7/members/16", + "raw": "{{api-url}}/v4/projects/1/members/1", "host": [ "{{api-url}}" ], "path": [ "v4", "projects", - "7", + "1", "members", - "16" + "1" ] }, "description": "Update a project's member." }, "response": [] }, + { + "name": "wrong role", + "request": { + "method": "PATCH", + "header": [ + { + "key": "Authorization", + "value": "Bearer {{jwt-token}}" + }, + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": " {\n \"param\": {\n \"role\": \"wrong\"\n }\n } " + }, + "url": { + "raw": "{{api-url}}/v4/projects/3/members/5", + "host": [ + "{{api-url}}" + ], + "path": [ + "v4", + "projects", + "3", + "members", + "5" + ] + } + }, + "response": [] + }, { "name": "Delete project member", "request": { @@ -412,21 +767,55 @@ "raw": "" }, "url": { - "raw": "{{api-url}}/v4/projects/7/members/15", + "raw": "{{api-url}}/v4/projects/3/members/5", "host": [ "{{api-url}}" ], "path": [ "v4", "projects", - "7", + "3", "members", - "15" + "5" ] }, "description": "Delete a project's member" }, "response": [] + }, + { + "name": "editing project member roles & primary option", + "request": { + "method": "PATCH", + "header": [ + { + "key": "Authorization", + "value": "Bearer {{jwt-token}}" + }, + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": " {\n \"param\": {\n \"role\": \"manager\",\n \"isPrimary\": true\n }\n } " + }, + "url": { + "raw": "{{api-url}}/v4/projects/1/members/2", + "host": [ + "{{api-url}}" + ], + "path": [ + "v4", + "projects", + "1", + "members", + "2" + ] + } + }, + "response": [] } ] }, @@ -762,6 +1151,33 @@ }, "response": [] }, + { + "name": "get projects 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", + "host": [ + "{{api-url}}" + ], + "path": [ + "v4", + "projects" + ] + } + }, + "response": [] + }, { "name": "DELETE project by id", "request": { @@ -810,14 +1226,14 @@ "raw": "{\n \"param\": {\n \"name\": \"project name updated\"\n }\n}" }, "url": { - "raw": "{{api-url}}/v4/projects/13", + "raw": "{{api-url}}/v4/projects/1", "host": [ "{{api-url}}" ], "path": [ "v4", "projects", - "13" + "1" ] }, "description": "Update the project name. Name should be updated successfully." @@ -909,14 +1325,14 @@ "raw": "{\n \"param\": {\n \"status\": \"in_review\"\n }\n}" }, "url": { - "raw": "{{api-url}}/v4/projects/7", + "raw": "{{api-url}}/v4/projects/1", "host": [ "{{api-url}}" ], "path": [ "v4", "projects", - "7" + "1" ] }, "description": "Update the project status." @@ -1117,404 +1533,45 @@ "1" ] }, - "description": "Move a project out of cancel state. Only admin and manager is allowed to do so." - }, - "response": [] - }, - { - "name": "Move project out of cancel state 403", - "request": { - "method": "PATCH", - "header": [ - { - "key": "Authorization", - "value": "Bearer {{jwt-token}}" - }, - { - "key": "Content-Type", - "value": "application/json" - } - ], - "body": { - "mode": "raw", - "raw": "{\n\t\"param\": {\n\t\t\"status\": \"active\"\n\t}\n}" - }, - "url": { - "raw": "{{api-url}}/v4/projects/1", - "host": [ - "{{api-url}}" - ], - "path": [ - "v4", - "projects", - "1" - ] - }, - "description": "Move a project out of cancel state. Only admin and manager is allowed to do so." - }, - "response": [] - }, - { - "name": "Update project details", - "request": { - "method": "PATCH", - "header": [ - { - "key": "Authorization", - "value": "Bearer {{jwt-token}}" - }, - { - "key": "Content-Type", - "value": "application/json" - } - ], - "body": { - "mode": "raw", - "raw": "{\n \"param\": {\n \"details\": {\n \"summary\": \"project name updated\"\n }\n }\n}" - }, - "url": { - "raw": "{{api-url}}/v4/projects/8", - "host": [ - "{{api-url}}" - ], - "path": [ - "v4", - "projects", - "8" - ] - }, - "description": "Update the project details. This should fire specification modified event" - }, - "response": [] - }, - { - "name": "Update project bookmarks", - "request": { - "method": "PATCH", - "header": [ - { - "key": "Authorization", - "value": "Bearer {{jwt-token}}" - }, - { - "key": "Content-Type", - "value": "application/json" - } - ], - "body": { - "mode": "raw", - "raw": "{\n \"param\": {\n \"bookmarks\": [\n {\n \"title\": \"test\",\n \"address\": \"http://topcoder.com\"\n }\n \n ]\n }\n}" - }, - "url": { - "raw": "{{api-url}}/v4/projects/8", - "host": [ - "{{api-url}}" - ], - "path": [ - "v4", - "projects", - "8" - ] - }, - "description": "Update the project bookmarks. This should fire project link created event" - }, - "response": [] - } - ] - }, - { - "name": "bookmarks", - "description": null, - "item": [ - { - "name": " Create project without bookmarks", - "request": { - "method": "POST", - "header": [ - { - "key": "Authorization", - "value": "Bearer {{jwt-token}}" - }, - { - "key": "Content-Type", - "value": "application/json" - } - ], - "body": { - "mode": "raw", - "raw": "{\n \"param\": {\n \"type\": \"generic\",\n \"description\": \"test project\",\n \"details\": {},\n \"billingAccountId\": 123,\n \"name\": \"test project1\"\n }\n}" - }, - "url": { - "raw": "{{api-url}}/v4/projects", - "host": [ - "{{api-url}}" - ], - "path": [ - "v4", - "projects" - ] - } - }, - "response": [] - }, - { - "name": " Create project with valid bookmarks", - "request": { - "method": "POST", - "header": [ - { - "key": "Authorization", - "value": "Bearer {{jwt-token}}" - }, - { - "key": "Content-Type", - "value": "application/json" - } - ], - "body": { - "mode": "raw", - "raw": "{\n \"param\": {\n \"type\": \"generic\",\n \"description\": \"test project\",\n \"details\": {},\n \"bookmarks\":[{\n \"title\":\"title1\",\n \"address\":\"address1\"\n },{\n \"title\":\"title2\",\n \"address\":\"address2\"\n }],\n \"billingAccountId\": 123,\n \"name\": \"test project1\"\n }\n}" - }, - "url": { - "raw": "{{api-url}}/v4/projects", - "host": [ - "{{api-url}}" - ], - "path": [ - "v4", - "projects" - ] - } - }, - "response": [] - }, - { - "name": " Create project with invalid bookmarks", - "request": { - "method": "POST", - "header": [ - { - "key": "Authorization", - "value": "Bearer {{jwt-token}}" - }, - { - "key": "Content-Type", - "value": "application/json" - } - ], - "body": { - "mode": "raw", - "raw": "{\n \"param\": {\n \"type\": \"generic\",\n \"description\": \"test project\",\n \"details\": {},\n \"bookmarks\":[{\n \"title\":\"title1\",\n \"invalid\":3,\n \"address\":\"address1\"\n },{\n \"title\":\"title2\",\n \"address\":\"address2\"\n }],\n \"billingAccountId\": 123,\n \"name\": \"test project1\"\n }\n}" - }, - "url": { - "raw": "{{api-url}}/v4/projects", - "host": [ - "{{api-url}}" - ], - "path": [ - "v4", - "projects" - ] - } - }, - "response": [] - }, - { - "name": "get project", - "request": { - "method": "GET", - "header": [ - { - "key": "Authorization", - "value": "Bearer {{jwt-token}}" - }, - { - "key": "Content-Type", - "value": "application/json" - } - ], - "body": { - "mode": "raw", - "raw": "{\n \"param\": {\n \"billingAccountId\": 9999, \n \"name\": \"new project name\"\n }\n}" - }, - "url": { - "raw": "{{api-url}}/v4/projects/2", - "host": [ - "{{api-url}}" - ], - "path": [ - "v4", - "projects", - "2" - ] - } - }, - "response": [] - }, - { - "name": "Update project with bookmarks", - "request": { - "method": "PATCH", - "header": [ - { - "key": "Authorization", - "value": "Bearer {{jwt-token}}" - }, - { - "key": "Content-Type", - "value": "application/json" - } - ], - "body": { - "mode": "raw", - "raw": "{\n \"param\": {\n \"billingAccountId\": 9999, \n \"name\": \"new project name\",\n \"bookmarks\":[{\n \"title\":\"title1\",\n \"address\":\"address1\"\n },{\n \"title\":\"title2\",\n \"address\":\"address2\"\n }]\n }\n}" - }, - "url": { - "raw": "{{api-url}}/v4/projects/2", - "host": [ - "{{api-url}}" - ], - "path": [ - "v4", - "projects", - "2" - ] - } - }, - "response": [] - }, - { - "name": "Delete project all bookmarks null", - "request": { - "method": "PATCH", - "header": [ - { - "key": "Authorization", - "value": "Bearer {{jwt-token}}" - }, - { - "key": "Content-Type", - "value": "application/json" - } - ], - "body": { - "mode": "raw", - "raw": "{\n \"param\": {\n \"billingAccountId\": 9999, \n \"name\": \"new project name2\",\n \"bookmarks\":null\n }\n}" - }, - "url": { - "raw": "{{api-url}}/v4/projects/2", - "host": [ - "{{api-url}}" - ], - "path": [ - "v4", - "projects", - "2" - ] - } - }, - "response": [] - }, - { - "name": "Update project with invalid bookmarks", - "request": { - "method": "PATCH", - "header": [ - { - "key": "Authorization", - "value": "Bearer {{jwt-token}}" - }, - { - "key": "Content-Type", - "value": "application/json" - } - ], - "body": { - "mode": "raw", - "raw": "{\n \"param\": {\n \"billingAccountId\": 9999, \n \"name\": \"new project name2\",\n \"bookmarks\":3\n }\n}" - }, - "url": { - "raw": "{{api-url}}/v4/projects/2", - "host": [ - "{{api-url}}" - ], - "path": [ - "v4", - "projects", - "2" - ] - } - }, - "response": [] - }, - { - "name": "get projects with admin token", - "request": { - "method": "GET", - "header": [ - { - "key": "Authorization", - "value": "Bearer {{jwt-token}}" - } - ], - "body": { - "mode": "raw", - "raw": "" - }, - "url": { - "raw": "{{api-url}}/v4/projects", - "host": [ - "{{api-url}}" - ], - "path": [ - "v4", - "projects" - ] - } - }, - "response": [] - } - ] - }, - { - "name": "issue1", - "description": null, - "item": [ + "description": "Move a project out of cancel state. Only admin and manager is allowed to do so." + }, + "response": [] + }, { - "name": "get projects with copilot token", + "name": "Move project out of cancel state 403", "request": { - "method": "GET", + "method": "PATCH", "header": [ { "key": "Authorization", "value": "Bearer {{jwt-token}}" + }, + { + "key": "Content-Type", + "value": "application/json" } ], "body": { "mode": "raw", - "raw": "" + "raw": "{\n\t\"param\": {\n\t\t\"status\": \"active\"\n\t}\n}" }, "url": { - "raw": "{{api-url}}/v4/projects", + "raw": "{{api-url}}/v4/projects/1", "host": [ "{{api-url}}" ], "path": [ "v4", - "projects" + "projects", + "1" ] - } + }, + "description": "Move a project out of cancel state. Only admin and manager is allowed to do so." }, "response": [] - } - ] - }, - { - "name": "issue10", - "description": null, - "item": [ + }, { - "name": "wrong role", + "name": "Update project details", "request": { "method": "PATCH", "header": [ @@ -1529,26 +1586,25 @@ ], "body": { "mode": "raw", - "raw": " {\n \"param\": {\n \"role\": \"wrong\"\n }\n } " + "raw": "{\n \"param\": {\n \"details\": {\n \"summary\": \"project name updated\"\n }\n }\n}" }, "url": { - "raw": "{{api-url}}/v4/projects/3/members/5", + "raw": "{{api-url}}/v4/projects/8", "host": [ "{{api-url}}" ], "path": [ "v4", "projects", - "3", - "members", - "5" + "8" ] - } + }, + "description": "Update the project details. This should fire specification modified event" }, "response": [] }, { - "name": "editing project member roles & primary option", + "name": "Update project bookmarks", "request": { "method": "PATCH", "header": [ @@ -1563,30 +1619,23 @@ ], "body": { "mode": "raw", - "raw": " {\n \"param\": {\n \"role\": \"manager\",\n \"isPrimary\": true\n }\n } " + "raw": "{\n \"param\": {\n \"bookmarks\": [\n {\n \"title\": \"test\",\n \"address\": \"http://topcoder.com\"\n }\n \n ]\n }\n}" }, "url": { - "raw": "{{api-url}}/v4/projects/1/members/1", + "raw": "{{api-url}}/v4/projects/8", "host": [ "{{api-url}}" ], "path": [ "v4", "projects", - "1", - "members", - "1" + "8" ] - } + }, + "description": "Update the project bookmarks. This should fire project link created event" }, "response": [] - } - ] - }, - { - "name": "issue5", - "description": null, - "item": [ + }, { "name": "launch a project by topcoder managers ", "request": { @@ -1686,8 +1735,7 @@ ] }, { - "name": "issue8", - "description": null, + "name": "EventHandling and Integration with Direct Project API", "item": [ { "name": "mock direct projects", @@ -1708,12 +1756,13 @@ "raw": " {\n \"param\": {\n \"role\": \"copilot\",\n \"isPrimary\": true\n }\n } " }, "url": { - "raw": "https://localhost:8443/v3/direct/projects", + "raw": "https://api.topcoder-dev.com/v3/direct/projects", "protocol": "https", "host": [ - "localhost" + "api", + "topcoder-dev", + "com" ], - "port": "8443", "path": [ "v3", "direct", @@ -1920,11 +1969,32 @@ }, "response": [] } + ], + "event": [ + { + "listen": "prerequest", + "script": { + "id": "ef96ac6a-0fc0-4a64-a4fe-5390e17afe67", + "type": "text/javascript", + "exec": [ + "" + ] + } + }, + { + "listen": "test", + "script": { + "id": "12f9d794-0872-4058-aafa-77b89e72025b", + "type": "text/javascript", + "exec": [ + "" + ] + } + } ] }, { "name": "Project Phase", - "description": null, "item": [ { "name": "Create Phase", @@ -2176,7 +2246,6 @@ }, { "name": "Phase Products", - "description": null, "item": [ { "name": "Create Phase Product", @@ -2348,7 +2417,6 @@ }, { "name": "Project Templates", - "description": null, "item": [ { "name": "Create project template", @@ -2366,7 +2434,7 @@ ], "body": { "mode": "raw", - "raw": "{\r\n \"param\":{\r\n \"name\":\"new name\",\r\n \"key\":\"new key\",\r\n \"category\":\"new category\",\r\n \"scope\":{\r\n \"scope1\":\"scope 1\"\r\n },\r\n \"phases\":{\r\n \"phase1\":\"phase 1\"\r\n }\r\n }\r\n}" + "raw": "{\r\n \"param\":{\r\n \"name\":\"new name\",\r\n \"key\":\"new key\",\r\n \"category\":\"app\",\r\n \"icon\": \"http://example.com/icon1.ico\",\r\n \"question\": \"question 1\",\r\n \"info\": \"info 1\",\r\n \"aliases\": [\"key-1\", \"key_1\"],\r\n \"scope\":{\r\n \"scope1\":\"scope 1\"\r\n },\r\n \"phases\":{\r\n \"phase1\":\"phase 1\"\r\n }\r\n }\r\n}" }, "url": { "raw": "{{api-url}}/v4/projectTemplates", @@ -2460,7 +2528,7 @@ ], "body": { "mode": "raw", - "raw": "{\r\n \"param\":{\r\n \"name\":\"new name\",\r\n \"key\":\"new key\",\r\n \"category\":\"new category\",\r\n \"scope\":{\r\n \"scope1\":\"scope 1\",\r\n \"scope2\": [\"a\"]\r\n },\r\n \"phases\":{\r\n \"phase1\":\"phase 1\",\r\n \"phase2\": {\r\n \t\"another\": \"another\"\r\n }\r\n }\r\n }\r\n}" + "raw": "{\r\n \"param\":{\r\n \"name\":\"new name\",\r\n \"key\":\"new key\",\r\n \"category\":\"app\",\r\n \"scope\":{\r\n \"scope1\":\"scope 1\",\r\n \"scope2\": [\"a\"]\r\n },\r\n \"phases\":{\r\n \"phase1\":\"phase 1\",\r\n \"phase2\": {\r\n \t\"another\": \"another\"\r\n }\r\n }\r\n }\r\n}" }, "url": { "raw": "{{api-url}}/v4/projectTemplates/1", @@ -2495,14 +2563,14 @@ "raw": "{\r\n \"param\":{\r\n \"name\":\"new name\",\r\n \"key\":\"new key\",\r\n \"category\":\"new category\",\r\n \"scope\":{\r\n \"scope1\":\"scope 1\",\r\n \"scope2\": [\"a\"]\r\n },\r\n \"phases\":{\r\n \"phase1\":\"phase 1\",\r\n \"phase2\": {\r\n \t\"another\": \"another\"\r\n }\r\n }\r\n }\r\n}" }, "url": { - "raw": "{{api-url}}/v4/projectTemplates/1", + "raw": "{{api-url}}/v4/projectTemplates/2", "host": [ "{{api-url}}" ], "path": [ "v4", "projectTemplates", - "1" + "2" ] } }, @@ -2512,7 +2580,6 @@ }, { "name": "Product Templates", - "description": null, "item": [ { "name": "Create product template", @@ -2530,7 +2597,7 @@ ], "body": { "mode": "raw", - "raw": "{\r\n \"param\":{\r\n \"name\":\"new name\",\r\n \"productKey\":\"new productKey\",\r\n \"category\":\"generic\",\r\n \"icon\":\"http://example.com/icon-new.ico\",\r\n \"brief\": \"new brief\",\r\n \"details\": \"new details\",\r\n \"aliases\":{\r\n \"alias1\":\"alias 1\"\r\n },\r\n \"template\":{\r\n \"template1\":\"template 1\"\r\n }\r\n }\r\n}" + "raw": "{\r\n \"param\": {\r\n \"name\": \"name 1\",\r\n \"productKey\": \"productKey 1\",\r\n \"category\": \"key1\",\r\n \"icon\": \"http://example.com/icon1.ico\",\r\n \"brief\": \"brief 1\",\r\n \"details\": \"details 1\",\r\n \"aliases\": [\"product key 1\", \"product_key_1\"],\r\n \"template\": {\r\n \"template1\": {\r\n \"name\": \"template 1\",\r\n \"details\": {\r\n \"anyDetails\": \"any details 1\"\r\n },\r\n \"others\": [\"others 11\", \"others 12\"]\r\n },\r\n \"template2\": {\r\n \"name\": \"template 2\",\r\n \"details\": {\r\n \"anyDetails\": \"any details 2\"\r\n },\r\n \"others\": [\"others 21\", \"others 22\"]\r\n }\r\n }\r\n }\r\n }" }, "url": { "raw": "{{api-url}}/v4/productTemplates", @@ -2595,14 +2662,14 @@ "raw": "{\r\n \"param\":{\r\n \"name\":\"new name\",\r\n \"key\":\"new key\",\r\n \"category\":\"new category\",\r\n \"scope\":{\r\n \"scope1\":\"scope 1\"\r\n },\r\n \"phases\":{\r\n \"phase1\":\"phase 1\"\r\n }\r\n }\r\n}" }, "url": { - "raw": "{{api-url}}/v4/productTemplates/1", + "raw": "{{api-url}}/v4/productTemplates/3", "host": [ "{{api-url}}" ], "path": [ "v4", "productTemplates", - "1" + "3" ] } }, @@ -2624,7 +2691,7 @@ ], "body": { "mode": "raw", - "raw": "{\r\n \"param\":{\r\n \"name\":\"new name\",\r\n \"productKey\":\"new productKey\",\r\n \"category\":\"generic\",\r\n \"icon\":\"http://example.com/icon-new.ico\",\r\n \"brief\": \"new brief\",\r\n \"details\": \"new details\",\r\n \"aliases\":{\r\n \"alias1\":\"scope 1\",\r\n \"alias2\": [\"a\"]\r\n },\r\n \"template\":{\r\n \"template1\":\"template 1\",\r\n \"template2\": {\r\n \t\"another\": \"another\"\r\n }\r\n }\r\n }\r\n}" + "raw": "{\r\n \"param\":{\r\n \"name\":\"new name\",\r\n \"productKey\":\"new productKey\",\r\n \"category\":\"key1\",\r\n \"icon\":\"http://example.com/icon-new.ico\",\r\n \"brief\": \"new brief\",\r\n \"details\": \"new details\",\r\n \"aliases\":{\r\n \"alias1\":\"scope 1\",\r\n \"alias2\": [\"a\"]\r\n },\r\n \"template\":{\r\n \"template1\":\"template 1\",\r\n \"template2\": {\r\n \t\"another\": \"another\"\r\n }\r\n }\r\n }\r\n}" }, "url": { "raw": "{{api-url}}/v4/productTemplates/1", @@ -2656,7 +2723,7 @@ ], "body": { "mode": "raw", - "raw": "{\r\n \"param\":{\r\n \"name\":\"new name\",\r\n \"key\":\"new key\",\r\n \"category\":\"new category\",\r\n \"scope\":{\r\n \"scope1\":\"scope 1\",\r\n \"scope2\": [\"a\"]\r\n },\r\n \"phases\":{\r\n \"phase1\":\"phase 1\",\r\n \"phase2\": {\r\n \t\"another\": \"another\"\r\n }\r\n }\r\n }\r\n}" + "raw": "" }, "url": { "raw": "{{api-url}}/v4/productTemplates/1", @@ -2676,7 +2743,6 @@ }, { "name": "Project Type", - "description": null, "item": [ { "name": "Create project type", @@ -2694,7 +2760,7 @@ ], "body": { "mode": "raw", - "raw": "{\r\n \"param\":{\r\n \"key\": \"new key\",\r\n \"displayName\": \"new displayName\"\r\n }\r\n}" + "raw": "{\r\n \"param\":{\r\n \"key\": \"new key\",\r\n \"displayName\": \"new displayName\",\r\n \"icon\": \"http://example.com/icon4.ico\",\r\n \t\"question\": \"question 4\",\r\n \t\"info\": \"info 4\",\r\n \t\"aliases\": [\"key-41\", \"key_42\"]\r\n }\r\n}" }, "url": { "raw": "{{api-url}}/v4/projectTypes", @@ -2820,7 +2886,7 @@ ], "body": { "mode": "raw", - "raw": "{\r\n \"param\":{\r\n \"name\":\"new name\",\r\n \"key\":\"new key\",\r\n \"category\":\"new category\",\r\n \"scope\":{\r\n \"scope1\":\"scope 1\",\r\n \"scope2\": [\"a\"]\r\n },\r\n \"phases\":{\r\n \"phase1\":\"phase 1\",\r\n \"phase2\": {\r\n \t\"another\": \"another\"\r\n }\r\n }\r\n }\r\n}" + "raw": "" }, "url": { "raw": "{{api-url}}/v4/projectTypes/chatbot", @@ -2840,7 +2906,6 @@ }, { "name": "Product Category", - "description": null, "item": [ { "name": "Create product category", @@ -3034,74 +3099,6 @@ } ] }, - { - "name": "issue86 (create project with templateId)", - "description": null, - "item": [ - { - "name": "Create project with templateId", - "request": { - "method": "POST", - "header": [ - { - "key": "Authorization", - "value": "Bearer {{jwt-token}}" - }, - { - "key": "Content-Type", - "value": "application/json" - } - ], - "body": { - "mode": "raw", - "raw": "{\n \"param\": {\n \"name\": \"test project with templateId\",\n \"description\": \"Hello I am a test project with templateId\",\n \"type\": \"generic\",\n \"templateId\": 3\n }\n}" - }, - "url": { - "raw": "{{api-url}}/v4/projects", - "host": [ - "{{api-url}}" - ], - "path": [ - "v4", - "projects" - ] - } - }, - "response": [] - }, - { - "name": "Create project with templateId (not existed)", - "request": { - "method": "POST", - "header": [ - { - "key": "Authorization", - "value": "Bearer {{jwt-token}}" - }, - { - "key": "Content-Type", - "value": "application/json" - } - ], - "body": { - "mode": "raw", - "raw": "{\n\t\"param\": {\n\t\t\"name\": \"test project with templateId\",\n\t\t\"description\": \"Hello I am a test project with templateId\",\n\t\t\"type\": \"generic\",\n\t\t\"templateId\": 3000\n\t}\n}" - }, - "url": { - "raw": "{{api-url}}/v4/projects", - "host": [ - "{{api-url}}" - ], - "path": [ - "v4", - "projects" - ] - } - }, - "response": [] - } - ] - }, { "name": "Project upgrade", "description": "Request to migrate projects.", @@ -3242,7 +3239,6 @@ }, { "name": "Timeline", - "description": null, "item": [ { "name": "Create timeline", @@ -3306,111 +3302,6 @@ }, "response": [] }, - { - "name": "List timelines", - "request": { - "method": "GET", - "header": [ - { - "key": "Content-Type", - "value": "application/json" - }, - { - "key": "Authorization", - "value": "Bearer {{jwt-token-copilot-40051332}}" - } - ], - "body": { - "mode": "raw", - "raw": "{\r\n \"param\":{\r\n \"name\":\"new name\",\r\n \"key\":\"new key\",\r\n \"category\":\"new category\",\r\n \"scope\":{\r\n \"scope1\":\"scope 1\"\r\n },\r\n \"phases\":{\r\n \"phase1\":\"phase 1\"\r\n }\r\n }\r\n}" - }, - "url": { - "raw": "{{api-url}}/v4/timelines", - "host": [ - "{{api-url}}" - ], - "path": [ - "v4", - "timelines" - ] - } - }, - "response": [] - }, - { - "name": "List timelines (filter by reference)", - "request": { - "method": "GET", - "header": [ - { - "key": "Content-Type", - "value": "application/json" - }, - { - "key": "Authorization", - "value": "Bearer {{jwt-token-copilot-40051332}}" - } - ], - "body": { - "mode": "raw", - "raw": "{\r\n \"param\":{\r\n \"name\":\"new name\",\r\n \"key\":\"new key\",\r\n \"category\":\"new category\",\r\n \"scope\":{\r\n \"scope1\":\"scope 1\"\r\n },\r\n \"phases\":{\r\n \"phase1\":\"phase 1\"\r\n }\r\n }\r\n}" - }, - "url": { - "raw": "{{api-url}}/v4/timelines?filter=reference%3Dproject", - "host": [ - "{{api-url}}" - ], - "path": [ - "v4", - "timelines" - ], - "query": [ - { - "key": "filter", - "value": "reference%3Dproject" - } - ] - } - }, - "response": [] - }, - { - "name": "List timelines (filter by referenceId)", - "request": { - "method": "GET", - "header": [ - { - "key": "Content-Type", - "value": "application/json" - }, - { - "key": "Authorization", - "value": "Bearer {{jwt-token-copilot-40051332}}" - } - ], - "body": { - "mode": "raw", - "raw": "{\r\n \"param\":{\r\n \"name\":\"new name\",\r\n \"key\":\"new key\",\r\n \"category\":\"new category\",\r\n \"scope\":{\r\n \"scope1\":\"scope 1\"\r\n },\r\n \"phases\":{\r\n \"phase1\":\"phase 1\"\r\n }\r\n }\r\n}" - }, - "url": { - "raw": "{{api-url}}/v4/timelines?filter=referenceId%3D1", - "host": [ - "{{api-url}}" - ], - "path": [ - "v4", - "timelines" - ], - "query": [ - { - "key": "filter", - "value": "referenceId%3D1" - } - ] - } - }, - "response": [] - }, { "name": "List timelines (filter by reference and referenceId)", "request": { @@ -3441,7 +3332,8 @@ "query": [ { "key": "filter", - "value": "reference%3Dphase%26referenceId%3D1" + "value": "reference%3Dphase%26referenceId%3D1", + "equals": true } ] } @@ -3612,7 +3504,6 @@ }, { "name": "Milestone", - "description": null, "item": [ { "name": "Create milestone", @@ -3994,7 +3885,6 @@ }, { "name": "Milestone Template", - "description": null, "item": [ { "name": "Create milestone template", @@ -4012,7 +3902,7 @@ ], "body": { "mode": "raw", - "raw": "{\r\n \"param\":{\r\n \"name\": \"milestoneTemplate 3\",\r\n \"description\": \"description 3\",\r\n \"duration\": 33,\r\n \"type\": \"type3\",\r\n \"order\": 1\r\n }\r\n}" + "raw": "{\r\n \"param\":{\r\n \"name\": \"milestoneTemplate 3\",\r\n \"description\": \"description 3\",\r\n \"duration\": 33,\r\n \"type\": \"type3\",\r\n \"order\": 1,\r\n \"activeText\": \"activeText 1\",\r\n \"completedText\": \"completedText 1\",\r\n \"blockedText\": \"blockedText 1\",\r\n \"plannedText\": \"planned Text 1\"\r\n\r\n }\r\n}" }, "url": { "raw": "{{api-url}}/v4/productTemplates/1/milestones", @@ -4375,4 +4265,4 @@ ] } ] -} +} \ No newline at end of file diff --git a/src/tests/seed.js b/src/tests/seed.js index 312f53c0..9b51e77c 100644 --- a/src/tests/seed.js +++ b/src/tests/seed.js @@ -448,6 +448,10 @@ models.sequelize.sync({ force: true }) type: 'type1', order: 1, productTemplateId: productTemplates[0].id, + activeText: 'activeText 1', + completedText: 'completedText 1', + blockedText: 'blockedText 1', + plannedText: 'planned Text 1', createdBy: 1, updatedBy: 2, }, @@ -457,6 +461,10 @@ models.sequelize.sync({ force: true }) type: 'type2', order: 2, productTemplateId: productTemplates[0].id, + activeText: 'activeText 2', + completedText: 'completedText 2', + blockedText: 'blockedText 2', + plannedText: 'planned Text 2', createdBy: 2, updatedBy: 3, }, @@ -572,50 +580,106 @@ models.sequelize.sync({ force: true }) displayName: 'Application development', createdBy: 1, updatedBy: 2, + icon: 'http://example.com/icon1.ico', + question: 'question 1', + info: 'info 1', + aliases: ['key-11', 'key_12'], }, { key: 'generic', displayName: 'Generic', createdBy: 1, updatedBy: 2, + icon: 'http://example.com/icon2.ico', + question: 'question 2', + info: 'info 2', + aliases: ['key-21', 'key_22'], }, { key: 'visual_prototype', displayName: 'Visual Prototype', createdBy: 1, updatedBy: 2, + icon: 'http://example.com/icon3.ico', + question: 'question 3', + info: 'info 1', + aliases: ['key-31', 'key_32'], }, { key: 'visual_design', displayName: 'Visual Design', createdBy: 1, updatedBy: 2, + icon: 'http://example.com/icon4.ico', + question: 'question 4', + info: 'info 4', + aliases: ['key-41', 'key_42'], }, { key: 'website', displayName: 'Website', createdBy: 1, updatedBy: 2, + icon: 'http://example.com/icon5.ico', + question: 'question 5', + info: 'info 5', + aliases: ['key-51', 'key_52'], }, { key: 'app', displayName: 'Application', createdBy: 1, updatedBy: 2, + icon: 'http://example.com/icon6.ico', + question: 'question 6', + info: 'info 6', + aliases: ['key-61', 'key_62'], }, { key: 'quality_assurance', displayName: 'Quality Assurance', createdBy: 1, updatedBy: 2, + icon: 'http://example.com/icon7.ico', + question: 'question 7', + info: 'info 7', + aliases: ['key-71', 'key_72'], }, { key: 'chatbot', displayName: 'Chatbot', createdBy: 1, updatedBy: 2, + icon: 'http://example.com/icon8.ico', + question: 'question 8', + info: 'info 8', + aliases: ['key-81', 'key_82'], }, ])) + .then(() => models.ProductCategory.bulkCreate([ + { + key: 'key1', + displayName: 'displayName 1', + icon: 'http://example.com/icon1.ico', + question: 'question 1', + info: 'info 1', + aliases: ['key-11', 'key_12'], + disabled: false, + hidden: false, + createdBy: 1, + updatedBy: 1, + }, + { + key: 'key2', + displayName: 'displayName 2', + icon: 'http://example.com/icon2.ico', + question: 'question 2', + info: 'info 2', + aliases: ['key-21', 'key_22'], + createdBy: 1, + updatedBy: 1, + } + ])) .then(() => { process.exit(0); }) From b9fc2dbb98696710c8d6024f65770c1b0f9c4a5b Mon Sep 17 00:00:00 2001 From: Gian Franco Zabarino Date: Sat, 28 Jul 2018 19:17:48 -0300 Subject: [PATCH 31/73] - Updating a milestone's completionDate/duration should update the dates of subsequent milestones. - Fixed bug where the transaction wasn't being included into wrapped queries. --- src/routes/milestones/update.js | 90 ++++++++++++++++++------- src/routes/milestones/update.spec.js | 98 +++++++++++++++++++++------- 2 files changed, 141 insertions(+), 47 deletions(-) diff --git a/src/routes/milestones/update.js b/src/routes/milestones/update.js index 9f4e19b9..85542b53 100644 --- a/src/routes/milestones/update.js +++ b/src/routes/milestones/update.js @@ -3,6 +3,7 @@ */ import validate from 'express-validation'; import _ from 'lodash'; +import moment from 'moment'; import Joi from 'joi'; import Sequelize from 'sequelize'; import { middleware as tcMiddleware } from 'tc-core-library-js'; @@ -13,6 +14,37 @@ import models from '../../models'; const permissions = tcMiddleware.permissions; +/** + * Cascades endDate/completionDate changes to all milestones with a greater order than the given one. + * @param {Object} updatedMilestone the milestone that was updated + * @param {Object} transaction the wrapping transaction + * @returns {Promise} a promise + */ +async function updateComingMilestones(updatedMilestone, transaction) { + const comingMilestones = _.sortBy(await models.Milestone.findAll({ + where: { + timelineId: updatedMilestone.timelineId, + order: { $gt: updatedMilestone.order }, + }, + transaction, + }), 'order'); + let startDate = moment.utc(updatedMilestone.completionDate + ? updatedMilestone.completionDate + : updatedMilestone.endDate).add(1, 'days').toDate(); + const promises = _.map(comingMilestones, (_milestone) => { + const milestone = _milestone; + if (milestone.startDate.getTime() !== startDate.getTime()) { + milestone.startDate = startDate; + milestone.endDate = moment.utc(startDate).add(milestone.duration - 1, 'days').toDate(); + } + startDate = moment.utc(milestone.completionDate + ? milestone.completionDate + : milestone.endDate).add(1, 'days').toDate(); + return milestone.save({ transaction }); + }); + await Promise.all(promises); +} + const schema = { params: { timelineId: Joi.number().integer().positive().required(), @@ -23,7 +55,7 @@ const schema = { id: Joi.any().strip(), name: Joi.string().max(255).optional(), description: Joi.string().max(255), - duration: Joi.number().integer().optional(), + duration: Joi.number().integer().min(1).optional(), startDate: Joi.date().optional(), endDate: Joi.date().allow(null), completionDate: Joi.date().allow(null), @@ -62,29 +94,10 @@ module.exports = [ timelineId: req.params.timelineId, }); - // Validate startDate and endDate to be within the timeline startDate and endDate - let error; - if (req.body.param.startDate < req.timeline.startDate) { - error = 'Milestone startDate must not be before the timeline startDate'; - } else if (req.body.param.endDate && req.timeline.endDate && req.body.param.endDate > req.timeline.endDate) { - error = 'Milestone endDate must not be after the timeline endDate'; - } - if (entityToUpdate.endDate && entityToUpdate.endDate < entityToUpdate.startDate) { - error = 'Milestone endDate must not be before startDate'; - } - if (entityToUpdate.completionDate && entityToUpdate.completionDate < entityToUpdate.startDate) { - error = 'Milestone endDate must not be before startDate'; - } - if (error) { - const apiErr = new Error(error); - apiErr.status = 422; - return next(apiErr); - } - let original; let updated; - return models.sequelize.transaction(() => + return models.sequelize.transaction(transaction => // Find the milestone models.Milestone.findOne({ where }) .then((milestone) => { @@ -94,14 +107,34 @@ module.exports = [ apiErr.status = 404; return Promise.reject(apiErr); } + // if any of these keys was provided and is different from what's in the database, error + if (['startDate', 'endDate'] + .some(key => entityToUpdate[key] && ( + !milestone[key] || + (milestone[key] && entityToUpdate[key].getTime() !== milestone[key].getTime()) + ))) { + const apiErr = new Error('Updating a milestone startDate or endDate is not allowed'); + apiErr.status = 422; + 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.'); + apiErr.status = 422; + return Promise.reject(apiErr); + } original = _.omit(milestone.toJSON(), ['deletedAt', 'deletedBy']); // Merge JSON fields entityToUpdate.details = util.mergeJsonObjects(milestone.details, entityToUpdate.details); + if (entityToUpdate.duration && entityToUpdate.duration !== milestone.duration) { + entityToUpdate.endDate = moment.utc(milestone.startDate).add(entityToUpdate.duration - 1, 'days').toDate(); + } + // Update - return milestone.update(entityToUpdate); + return milestone.update(entityToUpdate, { transaction }); }) .then((updatedMilestone) => { // Omit deletedAt, deletedBy @@ -118,6 +151,7 @@ module.exports = [ id: { $ne: updated.id }, order: updated.order, }, + transaction, }) .then((count) => { if (count === 0) { @@ -133,6 +167,7 @@ module.exports = [ id: { $ne: updated.id }, order: { $between: [original.order + 1, updated.order] }, }, + transaction, }); } @@ -144,8 +179,19 @@ module.exports = [ id: { $ne: updated.id }, order: { $between: [updated.order, original.order - 1] }, }, + transaction, }); }); + }) + .then(() => { + // Update dates of the other milestones only if the completionDate nor the duration changed + if (((!original.completionDate && !updated.completionDate) || + (original.completionDate && updated.completionDate && + original.completionDate.getTime() === updated.completionDate.getTime())) && + original.duration === updated.duration) { + return Promise.resolve(); + } + return updateComingMilestones(updated, transaction); }), ) .then(() => { diff --git a/src/routes/milestones/update.spec.js b/src/routes/milestones/update.spec.js index 1d5edcc1..99fbc516 100644 --- a/src/routes/milestones/update.spec.js +++ b/src/routes/milestones/update.spec.js @@ -256,8 +256,6 @@ describe('UPDATE Milestone', () => { param: { name: 'Milestone 1-updated', duration: 3, - startDate: '2018-05-14T00:00:00.000Z', - endDate: '2018-05-15T00:00:00.000Z', completionDate: '2018-05-16T00:00:00.000Z', description: 'description-updated', status: 'closed', @@ -482,28 +480,10 @@ describe('UPDATE Milestone', () => { .expect(422, done); }); - it('should return 422 if startDate is after completionDate', (done) => { + it('should return 422 if startDate is different than the original startDate', (done) => { const invalidBody = { param: _.assign({}, body.param, { - startDate: '2018-05-29T00:00:00.000Z', - completionDate: '2018-05-28T00:00:00.000Z', - }), - }; - - request(server) - .patch('/v4/timelines/1/milestones/1') - .set({ - Authorization: `Bearer ${testUtil.jwts.admin}`, - }) - .send(invalidBody) - .expect('Content-Type', /json/) - .expect(422, done); - }); - - it('should return 422 if startDate is before timeline startDate', (done) => { - const invalidBody = { - param: _.assign({}, body.param, { - startDate: '2018-05-01T00:00:00.000Z', + startDate: '2018-07-01T00:00:00.000Z', }), }; @@ -517,7 +497,7 @@ describe('UPDATE Milestone', () => { .expect(422, done); }); - it('should return 422 if endDate is after timeline endDate', (done) => { + it('should return 422 if endDate is different than the original endDate', (done) => { const invalidBody = { param: _.assign({}, body.param, { endDate: '2018-07-01T00:00:00.000Z', @@ -548,8 +528,6 @@ describe('UPDATE Milestone', () => { resJson.name.should.be.eql(body.param.name); resJson.description.should.be.eql(body.param.description); resJson.duration.should.be.eql(body.param.duration); - resJson.startDate.should.be.eql(body.param.startDate); - resJson.endDate.should.be.eql(body.param.endDate); resJson.completionDate.should.be.eql(body.param.completionDate); resJson.status.should.be.eql(body.param.status); resJson.type.should.be.eql(body.param.type); @@ -898,6 +876,76 @@ describe('UPDATE Milestone', () => { }); }); + it('should return 200 for admin - changing completionDate will cascade changes to coming ' + + // eslint-disable-next-line func-names + 'milestones', function (done) { + this.timeout(10000); + + request(server) + .patch('/v4/timelines/1/milestones/2') + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .send({ param: _.assign({}, body.param, { + completionDate: '2018-05-18T00:00:00.000Z', order: undefined, duration: undefined, + }) }) + .expect(200) + .end(() => { + // Milestone 3: startDate: '2018-05-14T00:00:00.000Z' to '2018-05-19T00:00:00.000Z' + // endDate: null to '2018-05-21T00:00:00.000Z' + // Milestone 4: startDate: '2018-05-14T00:00:00.000Z' to '2018-05-22T00:00:00.000Z' + // endDate: null to '2018-05-24T00:00:00.000Z' + setTimeout(() => { + models.Milestone.findById(3) + .then((milestone) => { + milestone.startDate.should.be.eql(new Date('2018-05-19T00:00:00.000Z')); + milestone.endDate.should.be.eql(new Date('2018-05-21T00:00:00.000Z')); + return models.Milestone.findById(4); + }) + .then((milestone) => { + milestone.startDate.should.be.eql(new Date('2018-05-22T00:00:00.000Z')); + milestone.endDate.should.be.eql(new Date('2018-05-24T00:00:00.000Z')); + done(); + }) + .catch(done); + }, 3000); + }); + }); + + it('should return 200 for admin - changing duration will cascade changes to coming ' + + // eslint-disable-next-line func-names + 'milestones', function (done) { + this.timeout(10000); + + request(server) + .patch('/v4/timelines/1/milestones/2') + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .send({ param: _.assign({}, body.param, { duration: 5, order: undefined, completionDate: undefined }) }) + .expect(200) + .end(() => { + // Milestone 3: startDate: '2018-05-14T00:00:00.000Z' to '2018-05-19T00:00:00.000Z' + // endDate: null to '2018-05-21T00:00:00.000Z' + // Milestone 4: startDate: '2018-05-14T00:00:00.000Z' to '2018-05-22T00:00:00.000Z' + // endDate: null to '2018-05-24T00:00:00.000Z' + setTimeout(() => { + models.Milestone.findById(3) + .then((milestone) => { + milestone.startDate.should.be.eql(new Date('2018-05-19T00:00:00.000Z')); + milestone.endDate.should.be.eql(new Date('2018-05-21T00:00:00.000Z')); + return models.Milestone.findById(4); + }) + .then((milestone) => { + milestone.startDate.should.be.eql(new Date('2018-05-22T00:00:00.000Z')); + milestone.endDate.should.be.eql(new Date('2018-05-24T00:00:00.000Z')); + done(); + }) + .catch(done); + }, 3000); + }); + }); + it('should return 200 for connect admin', (done) => { request(server) .patch('/v4/timelines/1/milestones/1') From 127a47024fd863b6e70130ccc307112c2eb83f0b Mon Sep 17 00:00:00 2001 From: mhikeo Date: Sun, 29 Jul 2018 12:13:48 +0800 Subject: [PATCH 32/73] Fix for Issue #129 --- postman.json | 102 +++++++++ src/permissions/index.js | 1 + src/routes/index.js | 3 + src/routes/milestoneTemplates/clone.js | 91 ++++++++ src/routes/milestoneTemplates/clone.spec.js | 234 ++++++++++++++++++++ 5 files changed, 431 insertions(+) create mode 100644 src/routes/milestoneTemplates/clone.js create mode 100644 src/routes/milestoneTemplates/clone.spec.js diff --git a/postman.json b/postman.json index fda31b5e..145003ff 100644 --- a/postman.json +++ b/postman.json @@ -4062,6 +4062,108 @@ }, "response": [] }, + { + "name": "Clone milestone template", + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + }, + { + "key": "Authorization", + "value": "Bearer {{jwt-token-admin-40051333}}" + } + ], + "body": { + "mode": "raw", + "raw": "{\r\n \"param\":{\r\n \"sourceTemplateId\": 1\r\n }\r\n}" + }, + "url": { + "raw": "{{api-url}}/v4/productTemplates/2/milestones/clone", + "host": [ + "{{api-url}}" + ], + "path": [ + "v4", + "productTemplates", + "2", + "milestones", + "clone" + ] + } + }, + "response": [] + }, + { + "name": "Clone milestone template with invalid product template id", + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + }, + { + "key": "Authorization", + "value": "Bearer {{jwt-token-admin-40051333}}" + } + ], + "body": { + "mode": "raw", + "raw": "{\r\n \"param\":{\r\n \"sourceTemplateId\": 1\r\n }\r\n}" + }, + "url": { + "raw": "{{api-url}}/v4/productTemplates/5/milestones/clone", + "host": [ + "{{api-url}}" + ], + "path": [ + "v4", + "productTemplates", + "5", + "milestones", + "clone" + ] + } + }, + "response": [] + }, + { + "name": "Clone milestone template with invalid source product template id", + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + }, + { + "key": "Authorization", + "value": "Bearer {{jwt-token-admin-40051333}}" + } + ], + "body": { + "mode": "raw", + "raw": "{\r\n \"param\":{\r\n \"sourceTemplateId\": 6\r\n }\r\n}" + }, + "url": { + "raw": "{{api-url}}/v4/productTemplates/2/milestones/clone", + "host": [ + "{{api-url}}" + ], + "path": [ + "v4", + "productTemplates", + "2", + "milestones", + "clone" + ] + } + }, + "response": [] + }, { "name": "List milestone templates", "request": { diff --git a/src/permissions/index.js b/src/permissions/index.js index 8cedefc8..f9b3668c 100644 --- a/src/permissions/index.js +++ b/src/permissions/index.js @@ -43,6 +43,7 @@ module.exports = () => { Authorizer.setPolicy('project.updatePhaseProduct', copilotAndAbove); Authorizer.setPolicy('project.deletePhaseProduct', copilotAndAbove); + Authorizer.setPolicy('milestoneTemplate.clone', projectAdmin); Authorizer.setPolicy('milestoneTemplate.create', projectAdmin); Authorizer.setPolicy('milestoneTemplate.edit', projectAdmin); Authorizer.setPolicy('milestoneTemplate.delete', projectAdmin); diff --git a/src/routes/index.js b/src/routes/index.js index a42b8078..8e4f034f 100644 --- a/src/routes/index.js +++ b/src/routes/index.js @@ -108,6 +108,9 @@ router.route('/v4/productTemplates/:productTemplateId(\\d+)/milestones') .post(require('./milestoneTemplates/create')) .get(require('./milestoneTemplates/list')); +router.route('/v4/productTemplates/:productTemplateId(\\d+)/milestones/clone') + .post(require('./milestoneTemplates/clone')); + router.route('/v4/productTemplates/:productTemplateId(\\d+)/milestones/:milestoneTemplateId(\\d+)') .get(require('./milestoneTemplates/get')) .patch(require('./milestoneTemplates/update')) diff --git a/src/routes/milestoneTemplates/clone.js b/src/routes/milestoneTemplates/clone.js new file mode 100644 index 00000000..efd16bb0 --- /dev/null +++ b/src/routes/milestoneTemplates/clone.js @@ -0,0 +1,91 @@ +/** + * API to clone a milestone template + */ +import validate from 'express-validation'; +import _ from 'lodash'; +import Joi from 'joi'; +import Sequelize from 'sequelize'; +import { middleware as tcMiddleware } from 'tc-core-library-js'; +import util from '../../util'; +import models from '../../models'; + +const permissions = tcMiddleware.permissions; + +const schema = { + params: { + productTemplateId: Joi.number().integer().positive().required(), + }, + body: { + param: Joi.object().keys({ + sourceTemplateId: Joi.number().integer().positive().required(), + }).required(), + }, +}; + +module.exports = [ + validate(schema), + permissions('milestoneTemplate.clone'), + (req, res, next) => { + let result; + + return models.sequelize.transaction(tx => + // Find the product template + models.ProductTemplate.findAll({ where: { id: [req.params.productTemplateId, req.body.param.sourceTemplateId] }, transaction: tx }) + .then((productTemplates) => { + // Not found + if (!productTemplates) { + const apiErr = new Error( + `Product template not found for product template ids ${req.params.productTemplateId} ${req.body.param.sourceTemplateId}`); + apiErr.status = 404; + return Promise.reject(apiErr); + } + + const targetProductTemplate = _.find(productTemplates, template => { return template.id == req.params.productTemplateId }); + const sourceProductTemplate = _.find(productTemplates, template => { return template.id == req.body.param.sourceTemplateId }); + + // Not found + if (!targetProductTemplate) { + const apiErr = new Error( + `Product template not found for product template id ${req.params.productTemplateId}`); + apiErr.status = 404; + return Promise.reject(apiErr); + } + + // Not found + if (!sourceProductTemplate) { + const apiErr = new Error( + `Product template not found for source product template id ${req.body.param.sourceTemplateId}`); + apiErr.status = 404; + return Promise.reject(apiErr); + } + + return models.ProductMilestoneTemplate.findAll({ + where: { + productTemplateId: req.body.param.sourceTemplateId, + }, + attributes: { exclude: ['id', 'deletedAt', 'createdAt', 'updatedAt', 'deletedBy'] }, + raw: true, + }) + .then((milestoneTemplatesToClone) => { + let newMilestoneTemplates = _.cloneDeep(milestoneTemplatesToClone) + _.each(newMilestoneTemplates, milestone => { + milestone.productTemplateId = req.params.productTemplateId + milestone.createdBy = req.authUser.userId + milestone.updatedBy = req.authUser.userId + }) + return models.ProductMilestoneTemplate.bulkCreate(newMilestoneTemplates, { transaction: tx }); + }) + }) + .then((createdEntities) => { + // Omit deletedAt and deletedBy + result = _.map(createdEntities, entity => _.omit(entity, 'deletedAt', 'deletedBy')) + return result + }), + ) + .then(() => { + // Write to response + res.status(201).json(util.wrapResponse(req.id, result, 1, 201)); + }) + .catch(next); + }, +]; diff --git a/src/routes/milestoneTemplates/clone.spec.js b/src/routes/milestoneTemplates/clone.spec.js new file mode 100644 index 00000000..c602bea1 --- /dev/null +++ b/src/routes/milestoneTemplates/clone.spec.js @@ -0,0 +1,234 @@ +/** + * Tests for create.js + */ +import chai from 'chai'; +import request from 'supertest'; +import _ from 'lodash'; +import server from '../../app'; +import testUtil from '../../tests/util'; +import models from '../../models'; + +const should = chai.should(); + +const productTemplates = [ + { + name: 'name 1', + productKey: 'productKey 1', + category: 'category', + icon: 'http://example.com/icon1.ico', + brief: 'brief 1', + details: 'details 1', + aliases: { + alias1: { + subAlias1A: 1, + subAlias1B: 2, + }, + alias2: [1, 2, 3], + }, + template: { + template1: { + name: 'template 1', + details: { + anyDetails: 'any details 1', + }, + others: ['others 11', 'others 12'], + }, + template2: { + name: 'template 2', + details: { + anyDetails: 'any details 2', + }, + others: ['others 21', 'others 22'], + }, + }, + createdBy: 1, + updatedBy: 2, + }, + { + name: 'template 2', + productKey: 'productKey 2', + category: 'category', + icon: 'http://example.com/icon2.ico', + brief: 'brief 2', + details: 'details 2', + aliases: {}, + template: {}, + createdBy: 3, + updatedBy: 4, + deletedAt: new Date(), + }, +]; +const milestoneTemplates = [ + { + name: 'milestoneTemplate 1', + duration: 3, + type: 'type1', + order: 1, + productTemplateId: 1, + plannedText: 'text to be shown in planned stage', + blockedText: 'text to be shown in blocked stage', + activeText: 'text to be shown in active stage', + completedText: 'text to be shown in completed stage', + createdBy: 1, + updatedBy: 2, + }, + { + name: 'milestoneTemplate 2', + duration: 4, + type: 'type2', + order: 2, + plannedText: 'text to be shown in planned stage - 2', + blockedText: 'text to be shown in blocked stage - 2', + activeText: 'text to be shown in active stage - 2', + completedText: 'text to be shown in completed stage - 2', + productTemplateId: 1, + createdBy: 2, + updatedBy: 3, + }, +]; + +describe('CLONE milestone template', () => { + beforeEach(() => testUtil.clearDb() + .then(() => models.ProductTemplate.bulkCreate(productTemplates)) + .then(() => models.ProductMilestoneTemplate.bulkCreate(milestoneTemplates)), + ); + after(testUtil.clearDb); + + describe('POST /productTemplates/{productTemplateId}/milestones/clone', () => { + const body = { + param: { + sourceTemplateId: 1, + }, + }; + + it('should return 403 if user is not authenticated/clone', (done) => { + request(server) + .post('/v4/productTemplates/2/milestones') + .send(body) + .expect(403, done); + }); + + it('should return 403 for member', (done) => { + request(server) + .post('/v4/productTemplates/2/milestones/clone') + .set({ + Authorization: `Bearer ${testUtil.jwts.member}`, + }) + .send(body) + .expect(403, done); + }); + + it('should return 403 for copilot', (done) => { + request(server) + .post('/v4/productTemplates/2/milestones/clone') + .set({ + Authorization: `Bearer ${testUtil.jwts.copilot}`, + }) + .send(body) + .expect(403, done); + }); + + it('should return 403 for manager', (done) => { + request(server) + .post('/v4/productTemplates/2/milestones/clone') + .set({ + Authorization: `Bearer ${testUtil.jwts.manager}`, + }) + .send(body) + .expect(403, done); + }); + + it('should return 404 for non-existent product template', (done) => { + request(server) + .post('/v4/productTemplates/1000/milestones/clone') + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .send(body) + .expect(404, done); + }); + + it('should return 404 for non-existent source product template', (done) => { + const invalidBody = { + param: { + sourceTemplateId: 99, + }, + }; + + request(server) + .post('/v4/productTemplates/2/milestones/clone') + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .send(invalidBody) + .expect(404, done); + }); + + it('should return 422 if missing sourceTemplateId', (done) => { + const invalidBody = { + param: { + sourceTemplateId: undefined, + }, + }; + + request(server) + .post('/v4/productTemplates/2/milestones/clone') + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .send(invalidBody) + .expect('Content-Type', /json/) + .expect(422, done); + }); + + it('should return 201 for admin', (done) => { + request(server) + .post('/v4/productTemplates/2/milestones/clone') + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .send(body) + .expect('Content-Type', /json/) + .expect(201) + .end((err, res) => { + const resJson = res.body.result.content; + should.exist(resJson.id); + resJson.name.should.be.eql(body.param.name); + resJson.description.should.be.eql(body.param.description); + resJson.duration.should.be.eql(body.param.duration); + resJson.type.should.be.eql(body.param.type); + resJson.order.should.be.eql(body.param.order); + resJson.plannedText.should.be.eql(body.param.plannedText); + resJson.blockedText.should.be.eql(body.param.blockedText); + resJson.activeText.should.be.eql(body.param.activeText); + resJson.completedText.should.be.eql(body.param.completedText); + + resJson.createdBy.should.be.eql(40051333); // admin + should.exist(resJson.createdAt); + resJson.updatedBy.should.be.eql(40051333); // admin + should.exist(resJson.updatedAt); + should.not.exist(resJson.deletedBy); + should.not.exist(resJson.deletedAt); + + done(); + }); + }); + + it('should return 201 for connect admin', (done) => { + request(server) + .post('/v4/productTemplates/2/milestones') + .set({ + Authorization: `Bearer ${testUtil.jwts.connectAdmin}`, + }) + .send(body) + .expect('Content-Type', /json/) + .expect(201) + .end((err, res) => { + const resJson = res.body.result.content; + resJson.createdBy.should.be.eql(40051336); // connect admin + resJson.updatedBy.should.be.eql(40051336); // connect admin + done(); + }); + }); + }); +}); From b1e51c672c681004053137b733bdd94e96a4593d Mon Sep 17 00:00:00 2001 From: mhikeo Date: Sun, 29 Jul 2018 12:57:54 +0800 Subject: [PATCH 33/73] Fix lint and update swagger #129 --- src/routes/milestoneTemplates/clone.js | 29 ++++++------- src/routes/milestoneTemplates/clone.spec.js | 1 - swagger.yaml | 46 +++++++++++++++++++++ 3 files changed, 61 insertions(+), 15 deletions(-) diff --git a/src/routes/milestoneTemplates/clone.js b/src/routes/milestoneTemplates/clone.js index efd16bb0..0d21ba46 100644 --- a/src/routes/milestoneTemplates/clone.js +++ b/src/routes/milestoneTemplates/clone.js @@ -4,7 +4,6 @@ import validate from 'express-validation'; import _ from 'lodash'; import Joi from 'joi'; -import Sequelize from 'sequelize'; import { middleware as tcMiddleware } from 'tc-core-library-js'; import util from '../../util'; import models from '../../models'; @@ -30,18 +29,20 @@ module.exports = [ return models.sequelize.transaction(tx => // Find the product template - models.ProductTemplate.findAll({ where: { id: [req.params.productTemplateId, req.body.param.sourceTemplateId] }, transaction: tx }) + models.ProductTemplate.findAll({ where: { id: [req.params.productTemplateId, req.body.param.sourceTemplateId] }, + transaction: tx }) .then((productTemplates) => { // Not found if (!productTemplates) { const apiErr = new Error( - `Product template not found for product template ids ${req.params.productTemplateId} ${req.body.param.sourceTemplateId}`); + `Product template not found for product template ids ${req.params.productTemplateId} + ${req.body.param.sourceTemplateId}`); apiErr.status = 404; return Promise.reject(apiErr); } - const targetProductTemplate = _.find(productTemplates, template => { return template.id == req.params.productTemplateId }); - const sourceProductTemplate = _.find(productTemplates, template => { return template.id == req.body.param.sourceTemplateId }); + const targetProductTemplate = _.find(productTemplates, ['id', req.params.productTemplateId]); + const sourceProductTemplate = _.find(productTemplates, ['id', req.body.param.sourceTemplateId]); // Not found if (!targetProductTemplate) { @@ -67,19 +68,19 @@ module.exports = [ raw: true, }) .then((milestoneTemplatesToClone) => { - let newMilestoneTemplates = _.cloneDeep(milestoneTemplatesToClone) - _.each(newMilestoneTemplates, milestone => { - milestone.productTemplateId = req.params.productTemplateId - milestone.createdBy = req.authUser.userId - milestone.updatedBy = req.authUser.userId - }) + const newMilestoneTemplates = _.cloneDeep(milestoneTemplatesToClone); + _.each(newMilestoneTemplates, (milestone) => { + milestone.productTemplateId = req.params.productTemplateId; // eslint-disable-line no-param-reassign + milestone.createdBy = req.authUser.userId; // eslint-disable-line no-param-reassign + milestone.updatedBy = req.authUser.userId; // eslint-disable-line no-param-reassign + }); return models.ProductMilestoneTemplate.bulkCreate(newMilestoneTemplates, { transaction: tx }); - }) + }); }) .then((createdEntities) => { // Omit deletedAt and deletedBy - result = _.map(createdEntities, entity => _.omit(entity, 'deletedAt', 'deletedBy')) - return result + result = _.map(createdEntities, entity => _.omit(entity, 'deletedAt', 'deletedBy')); + return result; }), ) .then(() => { diff --git a/src/routes/milestoneTemplates/clone.spec.js b/src/routes/milestoneTemplates/clone.spec.js index c602bea1..e0e8b2fd 100644 --- a/src/routes/milestoneTemplates/clone.spec.js +++ b/src/routes/milestoneTemplates/clone.spec.js @@ -3,7 +3,6 @@ */ import chai from 'chai'; import request from 'supertest'; -import _ from 'lodash'; import server from '../../app'; import testUtil from '../../tests/util'; import models from '../../models'; diff --git a/swagger.yaml b/swagger.yaml index 3d42d55f..d2799dee 100755 --- a/swagger.yaml +++ b/swagger.yaml @@ -1494,6 +1494,41 @@ paths: schema: $ref: "#/definitions/ErrorModel" + /productTemplates/{productTemplateId}/milestones/clone: + parameters: + - $ref: "#/parameters/productTemplateIdParam" + post: + tags: + - productMilestoneTemplate + operationId: cloneMilestoneTemplate + security: + - Bearer: [] + description: Clone milestone templates from one product template to the other. Only connect manager, connect admin, and admin can access this endpoint. + parameters: + - in: body + name: body + required: true + schema: + $ref: '#/definitions/MilestoneCloneTemplateRequest' + responses: + '201': + description: Returns the list of cloned milestone templates + schema: + $ref: "#/definitions/MilestoneTemplateListResponse" + '403': + description: No permission or wrong token + schema: + $ref: "#/definitions/ErrorModel" + '404': + description: Not found + schema: + $ref: "#/definitions/ErrorModel" + '422': + description: Invalid input + schema: + $ref: "#/definitions/ErrorModel" + + /productTemplates/{productTemplateId}/milestones/{milestoneTemplateId}: parameters: - $ref: "#/parameters/productTemplateIdParam" @@ -3357,6 +3392,17 @@ definitions: param: $ref: "#/definitions/MilestoneTemplateRequest" + MilestoneCloneTemplateRequest: + title: Milestone clone template request object + type: object + required: + - sourceTemplateId + properties: + sourceTemplateId: + type: number + format: integer + description: the product template id where to clone the milestone templates from + MilestoneTemplate: title: Milestone template object allOf: From 4c4d7feb8a513d32b17c52787eeb35709e55da7c Mon Sep 17 00:00:00 2001 From: Developer Date: Sun, 29 Jul 2018 16:59:11 +0800 Subject: [PATCH 34/73] Fix unit test #129 --- src/routes/milestoneTemplates/clone.js | 16 +++-- src/routes/milestoneTemplates/clone.spec.js | 78 +++++++++++++-------- 2 files changed, 61 insertions(+), 33 deletions(-) diff --git a/src/routes/milestoneTemplates/clone.js b/src/routes/milestoneTemplates/clone.js index 0d21ba46..c7fd1bf3 100644 --- a/src/routes/milestoneTemplates/clone.js +++ b/src/routes/milestoneTemplates/clone.js @@ -77,10 +77,18 @@ module.exports = [ return models.ProductMilestoneTemplate.bulkCreate(newMilestoneTemplates, { transaction: tx }); }); }) - .then((createdEntities) => { - // Omit deletedAt and deletedBy - result = _.map(createdEntities, entity => _.omit(entity, 'deletedAt', 'deletedBy')); - return result; + .then(() => { // eslint-disable-line arrow-body-style + return models.ProductMilestoneTemplate.findAll({ + where: { + productTemplateId: req.params.productTemplateId, + }, + attributes: { exclude: ['deletedAt', 'deletedBy'] }, + raw: true, + }) + .then((clonedMilestoneTemplates) => { + result = clonedMilestoneTemplates; + return result; + }); }), ) .then(() => { diff --git a/src/routes/milestoneTemplates/clone.spec.js b/src/routes/milestoneTemplates/clone.spec.js index e0e8b2fd..2f008e8d 100644 --- a/src/routes/milestoneTemplates/clone.spec.js +++ b/src/routes/milestoneTemplates/clone.spec.js @@ -44,17 +44,37 @@ const productTemplates = [ updatedBy: 2, }, { - name: 'template 2', + name: 'name 2', productKey: 'productKey 2', category: 'category', - icon: 'http://example.com/icon2.ico', + icon: 'http://example.com/icon1.ico', brief: 'brief 2', details: 'details 2', - aliases: {}, - template: {}, - createdBy: 3, - updatedBy: 4, - deletedAt: new Date(), + aliases: { + alias1: { + subAlias1A: 1, + subAlias1B: 2, + }, + alias2: [1, 2, 3], + }, + template: { + template1: { + name: 'template 1', + details: { + anyDetails: 'any details 1', + }, + others: ['others 11', 'others 12'], + }, + template2: { + name: 'template 2', + details: { + anyDetails: 'any details 2', + }, + others: ['others 21', 'others 22'], + }, + }, + createdBy: 1, + updatedBy: 2, }, ]; const milestoneTemplates = [ @@ -187,27 +207,27 @@ describe('CLONE milestone template', () => { Authorization: `Bearer ${testUtil.jwts.admin}`, }) .send(body) - .expect('Content-Type', /json/) .expect(201) .end((err, res) => { const resJson = res.body.result.content; - should.exist(resJson.id); - resJson.name.should.be.eql(body.param.name); - resJson.description.should.be.eql(body.param.description); - resJson.duration.should.be.eql(body.param.duration); - resJson.type.should.be.eql(body.param.type); - resJson.order.should.be.eql(body.param.order); - resJson.plannedText.should.be.eql(body.param.plannedText); - resJson.blockedText.should.be.eql(body.param.blockedText); - resJson.activeText.should.be.eql(body.param.activeText); - resJson.completedText.should.be.eql(body.param.completedText); - - resJson.createdBy.should.be.eql(40051333); // admin - should.exist(resJson.createdAt); - resJson.updatedBy.should.be.eql(40051333); // admin - should.exist(resJson.updatedAt); - should.not.exist(resJson.deletedBy); - should.not.exist(resJson.deletedAt); + resJson.should.have.length(2); + should.not.equal(resJson[0].id, null); + resJson[0].name.should.be.eql(milestoneTemplates[0].name); + resJson[0].duration.should.be.eql(milestoneTemplates[0].duration); + resJson[0].type.should.be.eql(milestoneTemplates[0].type); + resJson[0].order.should.be.eql(milestoneTemplates[0].order); + resJson[0].plannedText.should.be.eql(milestoneTemplates[0].plannedText); + resJson[0].blockedText.should.be.eql(milestoneTemplates[0].blockedText); + resJson[0].activeText.should.be.eql(milestoneTemplates[0].activeText); + resJson[0].completedText.should.be.eql(milestoneTemplates[0].completedText); + resJson[0].productTemplateId.should.be.eql(2); + + resJson[0].createdBy.should.be.eql(40051333); // admin + should.exist(resJson[0].createdAt); + resJson[0].updatedBy.should.be.eql(40051333); // admin + should.exist(resJson[0].updatedAt); + should.not.exist(resJson[0].deletedBy); + should.not.exist(resJson[0].deletedAt); done(); }); @@ -215,17 +235,17 @@ describe('CLONE milestone template', () => { it('should return 201 for connect admin', (done) => { request(server) - .post('/v4/productTemplates/2/milestones') + .post('/v4/productTemplates/2/milestones/clone') .set({ Authorization: `Bearer ${testUtil.jwts.connectAdmin}`, }) .send(body) - .expect('Content-Type', /json/) .expect(201) .end((err, res) => { const resJson = res.body.result.content; - resJson.createdBy.should.be.eql(40051336); // connect admin - resJson.updatedBy.should.be.eql(40051336); // connect admin + resJson.should.have.length(2); + resJson[0].createdBy.should.be.eql(40051336); // connect admin + resJson[0].updatedBy.should.be.eql(40051336); // connect admin done(); }); }); From 0a504f4675e246f6c61698d43030d4e4cbc38d20 Mon Sep 17 00:00:00 2001 From: Gian Franco Zabarino Date: Sun, 29 Jul 2018 12:22:15 -0300 Subject: [PATCH 35/73] Moved back to implicit transactions. --- src/routes/milestones/update.js | 47 +++++++++++++++------------------ 1 file changed, 22 insertions(+), 25 deletions(-) diff --git a/src/routes/milestones/update.js b/src/routes/milestones/update.js index 85542b53..4c3df09c 100644 --- a/src/routes/milestones/update.js +++ b/src/routes/milestones/update.js @@ -17,32 +17,32 @@ const permissions = tcMiddleware.permissions; /** * Cascades endDate/completionDate changes to all milestones with a greater order than the given one. * @param {Object} updatedMilestone the milestone that was updated - * @param {Object} transaction the wrapping transaction * @returns {Promise} a promise */ -async function updateComingMilestones(updatedMilestone, transaction) { - const comingMilestones = _.sortBy(await models.Milestone.findAll({ +function updateComingMilestones(updatedMilestone) { + return models.Milestone.findAll({ where: { timelineId: updatedMilestone.timelineId, order: { $gt: updatedMilestone.order }, }, - transaction, - }), 'order'); - let startDate = moment.utc(updatedMilestone.completionDate - ? updatedMilestone.completionDate - : updatedMilestone.endDate).add(1, 'days').toDate(); - const promises = _.map(comingMilestones, (_milestone) => { - const milestone = _milestone; - if (milestone.startDate.getTime() !== startDate.getTime()) { - milestone.startDate = startDate; - milestone.endDate = moment.utc(startDate).add(milestone.duration - 1, 'days').toDate(); - } - startDate = moment.utc(milestone.completionDate - ? milestone.completionDate - : milestone.endDate).add(1, 'days').toDate(); - return milestone.save({ transaction }); + }).then((affectedMilestones) => { + const comingMilestones = _.sortBy(affectedMilestones, 'order'); + let startDate = moment.utc(updatedMilestone.completionDate + ? updatedMilestone.completionDate + : updatedMilestone.endDate).add(1, 'days').toDate(); + const promises = _.map(comingMilestones, (_milestone) => { + const milestone = _milestone; + if (milestone.startDate.getTime() !== startDate.getTime()) { + milestone.startDate = startDate; + milestone.endDate = moment.utc(startDate).add(milestone.duration - 1, 'days').toDate(); + } + startDate = moment.utc(milestone.completionDate + ? milestone.completionDate + : milestone.endDate).add(1, 'days').toDate(); + return milestone.save(); + }); + return Promise.all(promises); }); - await Promise.all(promises); } const schema = { @@ -97,7 +97,7 @@ module.exports = [ let original; let updated; - return models.sequelize.transaction(transaction => + return models.sequelize.transaction(() => // Find the milestone models.Milestone.findOne({ where }) .then((milestone) => { @@ -134,7 +134,7 @@ module.exports = [ } // Update - return milestone.update(entityToUpdate, { transaction }); + return milestone.update(entityToUpdate); }) .then((updatedMilestone) => { // Omit deletedAt, deletedBy @@ -151,7 +151,6 @@ module.exports = [ id: { $ne: updated.id }, order: updated.order, }, - transaction, }) .then((count) => { if (count === 0) { @@ -167,7 +166,6 @@ module.exports = [ id: { $ne: updated.id }, order: { $between: [original.order + 1, updated.order] }, }, - transaction, }); } @@ -179,7 +177,6 @@ module.exports = [ id: { $ne: updated.id }, order: { $between: [updated.order, original.order - 1] }, }, - transaction, }); }); }) @@ -191,7 +188,7 @@ module.exports = [ original.duration === updated.duration) { return Promise.resolve(); } - return updateComingMilestones(updated, transaction); + return updateComingMilestones(updated); }), ) .then(() => { From 35cfcacd02b483e021e439be180672ed8fcf745c Mon Sep 17 00:00:00 2001 From: narekcat Date: Sun, 29 Jul 2018 19:23:29 +0400 Subject: [PATCH 36/73] Changed GET /v4/projects endpoint in a way that it does not return attachments, when not requested. --- src/routes/projects/list.js | 6 ++- src/routes/projects/list.spec.js | 76 ++++++++++++++++++++++++++++++++ src/util.js | 4 ++ 3 files changed, 85 insertions(+), 1 deletion(-) diff --git a/src/routes/projects/list.js b/src/routes/projects/list.js index 21871a82..af53cbd4 100755 --- a/src/routes/projects/list.js +++ b/src/routes/projects/list.js @@ -134,7 +134,10 @@ const parseElasticSearchCriteria = (criteria, fields, order) => { const phaseFields = _.get(fields, 'project_phases_products'); sourceInclude = sourceInclude.concat(_.map(phaseFields, single => `phases.products.${single}`)); } - sourceInclude = sourceInclude.concat(_.map(PROJECT_ATTACHMENT_ATTRIBUTES, single => `attachments.${single}`)); + if (_.get(fields, 'attachments', null)) { + const phaseFields = _.get(fields, 'attachments'); + sourceInclude = sourceInclude.concat(_.map(phaseFields, single => `attachments.${single}`)); + } if (sourceInclude) { searchCriteria._sourceInclude = sourceInclude; // eslint-disable-line no-underscore-dangle @@ -248,6 +251,7 @@ const retrieveProjects = (req, criteria, sort, ffields) => { project_members: PROJECT_MEMBER_ATTRIBUTES, project_phases: PROJECT_PHASE_ATTRIBUTES, project_phases_products: PROJECT_PHASE_PRODUCTS_ATTRIBUTES, + attachments: PROJECT_ATTACHMENT_ATTRIBUTES, }); // make sure project.id is part of fields if (_.indexOf(fields.projects, 'id') < 0) { diff --git a/src/routes/projects/list.spec.js b/src/routes/projects/list.spec.js index cdcfa1fd..918e2695 100644 --- a/src/routes/projects/list.spec.js +++ b/src/routes/projects/list.spec.js @@ -306,6 +306,82 @@ describe('LIST Project', () => { }); }); + it('should return the project for administrator with field description, billingAccountId and attachments', (done) => { + request(server) + .get('/v4/projects/?fields=description%2CbillingAccountId%2Cattachments&sort=id%20asc') + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .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(3); + resJson[0].should.have.property('attachments'); + resJson[0].should.have.property('description'); + resJson[0].should.have.property('billingAccountId'); + done(); + } + }); + }); + + it('should return the project for administrator with field description and billingAccountId', (done) => { + request(server) + .get('/v4/projects/?fields=description%2CbillingAccountId&sort=id%20asc') + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .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(3); + resJson[0].should.not.have.property('attachments'); + resJson[0].should.have.property('description'); + resJson[0].should.have.property('billingAccountId'); + done(); + } + }); + }); + + it('should return the project for administrator with all field', (done) => { + request(server) + .get('/v4/projects/?sort=id%20asc') + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .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(3); + resJson[0].should.have.property('id'); + resJson[0].should.have.property('type'); + resJson[0].should.have.property('billingAccountId'); + resJson[0].should.have.property('description'); + resJson[0].should.have.property('status'); + resJson[0].should.have.property('details'); + resJson[0].should.have.property('createdBy'); + resJson[0].should.have.property('updatedBy'); + resJson[0].should.have.property('members'); + resJson[0].should.have.property('attachments'); + done(); + } + }); + }); + it('should return all projects that match when filtering by name', (done) => { request(server) .get('/v4/projects/?filter=keyword%3Dtest') diff --git a/src/util.js b/src/util.js index add05eb6..79cecbc3 100644 --- a/src/util.js +++ b/src/util.js @@ -154,6 +154,10 @@ _.assignIn(util, { if (fields.project_members.length === 0 && _.indexOf(queryFields, 'members') > -1) { fields.project_members = allowedFields.project_members; } + // remove attachments if not requested + if (fields.attachments && _.indexOf(queryFields, 'attachments') === -1) { + fields.attachments = null; + } } return fields; }, From 6565aca2baacd9d6ae932242705349e86c36313a Mon Sep 17 00:00:00 2001 From: narekcat Date: Sun, 29 Jul 2018 19:33:26 +0400 Subject: [PATCH 37/73] Fix lint errors. --- src/routes/milestoneTemplates/create.spec.js | 3 +- src/routes/projects/list.spec.js | 45 ++++++++++---------- src/routes/timelines/list.spec.js | 1 - 3 files changed, 24 insertions(+), 25 deletions(-) diff --git a/src/routes/milestoneTemplates/create.spec.js b/src/routes/milestoneTemplates/create.spec.js index 4be55232..c821a5fe 100644 --- a/src/routes/milestoneTemplates/create.spec.js +++ b/src/routes/milestoneTemplates/create.spec.js @@ -269,8 +269,7 @@ describe('CREATE milestone template', () => { }); done(); }).catch((error) => { - console.log(error); - done(); + done(error); }); }); }); diff --git a/src/routes/projects/list.spec.js b/src/routes/projects/list.spec.js index 918e2695..1658faae 100644 --- a/src/routes/projects/list.spec.js +++ b/src/routes/projects/list.spec.js @@ -306,28 +306,29 @@ describe('LIST Project', () => { }); }); - it('should return the project for administrator with field description, billingAccountId and attachments', (done) => { - request(server) - .get('/v4/projects/?fields=description%2CbillingAccountId%2Cattachments&sort=id%20asc') - .set({ - Authorization: `Bearer ${testUtil.jwts.admin}`, - }) - .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(3); - resJson[0].should.have.property('attachments'); - resJson[0].should.have.property('description'); - resJson[0].should.have.property('billingAccountId'); - done(); - } - }); - }); + it('should return the project for administrator with field description, billingAccountId and attachments', + (done) => { + request(server) + .get('/v4/projects/?fields=description%2CbillingAccountId%2Cattachments&sort=id%20asc') + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .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(3); + resJson[0].should.have.property('attachments'); + resJson[0].should.have.property('description'); + resJson[0].should.have.property('billingAccountId'); + done(); + } + }); + }); it('should return the project for administrator with field description and billingAccountId', (done) => { request(server) diff --git a/src/routes/timelines/list.spec.js b/src/routes/timelines/list.spec.js index 03f2ce15..877141de 100644 --- a/src/routes/timelines/list.spec.js +++ b/src/routes/timelines/list.spec.js @@ -286,7 +286,6 @@ describe('LIST timelines', () => { .expect(200) .end((err, res) => { const resJson = res.body.result.content; - console.log(resJson); resJson.should.have.length(1); done(); From 4492fdff75797001d0bc1b1c5a0e522414ae197c Mon Sep 17 00:00:00 2001 From: narekcat Date: Mon, 30 Jul 2018 12:02:37 +0400 Subject: [PATCH 38/73] Fixed copy paste magic and added new test for attachments field. --- src/routes/projects/list.js | 4 ++-- src/routes/projects/list.spec.js | 9 +++++++++ 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/src/routes/projects/list.js b/src/routes/projects/list.js index af53cbd4..483cff4d 100755 --- a/src/routes/projects/list.js +++ b/src/routes/projects/list.js @@ -135,8 +135,8 @@ const parseElasticSearchCriteria = (criteria, fields, order) => { sourceInclude = sourceInclude.concat(_.map(phaseFields, single => `phases.products.${single}`)); } if (_.get(fields, 'attachments', null)) { - const phaseFields = _.get(fields, 'attachments'); - sourceInclude = sourceInclude.concat(_.map(phaseFields, single => `attachments.${single}`)); + const attachmentFields = _.get(fields, 'attachments'); + sourceInclude = sourceInclude.concat(_.map(attachmentFields, single => `attachments.${single}`)); } if (sourceInclude) { diff --git a/src/routes/projects/list.spec.js b/src/routes/projects/list.spec.js index 1658faae..b69fcf6f 100644 --- a/src/routes/projects/list.spec.js +++ b/src/routes/projects/list.spec.js @@ -323,6 +323,15 @@ describe('LIST Project', () => { should.exist(resJson); resJson.should.have.lengthOf(3); resJson[0].should.have.property('attachments'); + resJson[0].attachments.should.have.lengthOf(1); + resJson[0].attachments[0].should.have.property('id'); + resJson[0].attachments[0].should.have.property('projectId'); + resJson[0].attachments[0].should.have.property('title'); + resJson[0].attachments[0].should.have.property('description'); + resJson[0].attachments[0].should.have.property('filePath'); + resJson[0].attachments[0].should.have.property('contentType'); + resJson[0].attachments[0].should.have.property('createdBy'); + resJson[0].attachments[0].should.have.property('updatedBy'); resJson[0].should.have.property('description'); resJson[0].should.have.property('billingAccountId'); done(); From c1cf7790e876a91fd604172a170bfc5080abd229 Mon Sep 17 00:00:00 2001 From: Vikas Agarwal Date: Mon, 30 Jul 2018 15:28:10 +0530 Subject: [PATCH 39/73] fixed typo --- migrations/20180727_product_categories.sql | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/migrations/20180727_product_categories.sql b/migrations/20180727_product_categories.sql index e53f912a..56bc6022 100644 --- a/migrations/20180727_product_categories.sql +++ b/migrations/20180727_product_categories.sql @@ -32,4 +32,4 @@ ALTER TABLE ONLY product_categories -- ALTER TABLE product_templates ADD COLUMN "category" character varying(45); UPDATE product_templates set category='generic' where category is null; -ALTER TABLE product_templates ALTER COLUMN "generic" SET NOT NULL; +ALTER TABLE product_templates ALTER COLUMN "category" SET NOT NULL; From 55ada2a7cab8ea5a8bbd676be23149cf76df30f4 Mon Sep 17 00:00:00 2001 From: Gian Franco Zabarino Date: Mon, 30 Jul 2018 20:51:15 -0300 Subject: [PATCH 40/73] - Fixed issue where identical data were being treated as different and then triggering milestones' updates. - Do not trigger milestones' updates if the timeline endDate changed. - Added comments to milestones' updates when a timeline gets updated. --- src/routes/timelines/update.js | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/src/routes/timelines/update.js b/src/routes/timelines/update.js index 5cbfad5d..e91783e4 100644 --- a/src/routes/timelines/update.js +++ b/src/routes/timelines/update.js @@ -57,22 +57,31 @@ module.exports = [ // Omit deletedAt, deletedBy updated = _.omit(updatedTimeline.toJSON(), ['deletedAt', 'deletedBy']); - // Update milestones startDate and endDate if necessary - if (original.startDate !== updated.startDate || original.endDate !== updated.endDate) { + // Update milestones startDate and endDate if necessary, if the timeline startDate changed + if (original.startDate.getTime() !== updated.startDate.getTime()) { return updatedTimeline.getMilestones() .then((milestones) => { let startDate = updated.startDate; + + // Process milestones in order const updateMilestonePromises = _.chain(milestones).sortBy('order').map((_milestone) => { const milestone = _milestone; + + // Update if the iterating startDate is different than the saved one if (milestone.startDate.getTime() !== startDate.getTime()) { milestone.startDate = startDate; milestone.updatedBy = req.authUser.userId; } + + // Make sure the endDate is the correct, i.e. for duration = 1 it should be equal to the start date, + // for duration = 2 it should be equal to the next day and so on... const endDate = moment.utc(milestone.startDate).add(milestone.duration - 1, 'days').toDate(); if (!milestone.endDate || endDate.getTime() !== milestone.endDate.getTime()) { milestone.endDate = endDate; milestone.updatedBy = req.authUser.userId; } + + // Next iterated milestone should have as startDate this milestone's endDate plus one day startDate = moment.utc(milestone.endDate).add(1, 'days').toDate(); return milestone.save(); }).value(); From 737c0e6aca3043d43d6fe480ae106d3ae670ca81 Mon Sep 17 00:00:00 2001 From: Gian Franco Zabarino Date: Mon, 30 Jul 2018 21:28:13 -0300 Subject: [PATCH 41/73] - Removed setTimeout calls. - startDate and endDate are now forbidden from being present in the payload. - Removed explicit check against startDate and endDate parameters, since now they are forbidden. --- src/routes/milestones/update.js | 14 +- src/routes/milestones/update.spec.js | 196 +++++++++++---------------- 2 files changed, 80 insertions(+), 130 deletions(-) diff --git a/src/routes/milestones/update.js b/src/routes/milestones/update.js index 4c3df09c..41872634 100644 --- a/src/routes/milestones/update.js +++ b/src/routes/milestones/update.js @@ -56,8 +56,8 @@ const schema = { name: Joi.string().max(255).optional(), description: Joi.string().max(255), duration: Joi.number().integer().min(1).optional(), - startDate: Joi.date().optional(), - endDate: Joi.date().allow(null), + startDate: Joi.any().forbidden(), + endDate: Joi.any().forbidden(), completionDate: Joi.date().allow(null), status: Joi.string().max(45).optional(), type: Joi.string().max(45).optional(), @@ -107,16 +107,6 @@ module.exports = [ apiErr.status = 404; return Promise.reject(apiErr); } - // if any of these keys was provided and is different from what's in the database, error - if (['startDate', 'endDate'] - .some(key => entityToUpdate[key] && ( - !milestone[key] || - (milestone[key] && entityToUpdate[key].getTime() !== milestone[key].getTime()) - ))) { - const apiErr = new Error('Updating a milestone startDate or endDate is not allowed'); - apiErr.status = 422; - 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.'); diff --git a/src/routes/milestones/update.spec.js b/src/routes/milestones/update.spec.js index 99fbc516..1c066efe 100644 --- a/src/routes/milestones/update.spec.js +++ b/src/routes/milestones/update.spec.js @@ -462,56 +462,23 @@ describe('UPDATE Milestone', () => { .expect(200, done); }); - it('should return 422 if startDate is after endDate', (done) => { - const invalidBody = { - param: _.assign({}, body.param, { - startDate: '2018-05-29T00:00:00.000Z', - endDate: '2018-05-28T00:00:00.000Z', - }), - }; - - request(server) - .patch('/v4/timelines/1/milestones/1') - .set({ - Authorization: `Bearer ${testUtil.jwts.admin}`, - }) - .send(invalidBody) - .expect('Content-Type', /json/) - .expect(422, done); - }); - - it('should return 422 if startDate is different than the original startDate', (done) => { - const invalidBody = { - param: _.assign({}, body.param, { - startDate: '2018-07-01T00:00:00.000Z', - }), - }; - - request(server) - .patch('/v4/timelines/1/milestones/1') - .set({ - Authorization: `Bearer ${testUtil.jwts.admin}`, - }) - .send(invalidBody) - .expect('Content-Type', /json/) - .expect(422, done); - }); - - it('should return 422 if endDate is different than the original endDate', (done) => { - const invalidBody = { - param: _.assign({}, body.param, { - endDate: '2018-07-01T00:00:00.000Z', - }), - }; - - request(server) - .patch('/v4/timelines/1/milestones/1') - .set({ - Authorization: `Bearer ${testUtil.jwts.admin}`, - }) - .send(invalidBody) - .expect('Content-Type', /json/) - .expect(422, done); + ['startDate', 'endDate'].forEach((field) => { + it(`should return 422 if ${field} is present in the payload`, (done) => { + const invalidBody = { + param: _.assign({}, body.param, { + [field]: '2018-07-01T00:00:00.000Z', + }), + }; + + request(server) + .patch('/v4/timelines/1/milestones/1') + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .send(invalidBody) + .expect('Content-Type', /json/) + .expect(422, done); + }); }); it('should return 200 for admin', (done) => { @@ -717,14 +684,13 @@ describe('UPDATE Milestone', () => { .expect(200) .end(() => { // Milestone 6: order 0 - setTimeout(() => { - models.Milestone.findById(6) - .then((milestone) => { - milestone.order.should.be.eql(0); - - done(); - }); - }, 3000); + models.Milestone.findById(6) + .then((milestone) => { + milestone.order.should.be.eql(0); + + done(); + }) + .catch(done); }); }); @@ -782,22 +748,21 @@ describe('UPDATE Milestone', () => { // Milestone 6: order 1 => 1 // Milestone 7: order 3 => 3 // Milestone 8: order 4 => 2 - setTimeout(() => { - models.Milestone.findById(6) - .then((milestone) => { - milestone.order.should.be.eql(1); - }) - .then(() => models.Milestone.findById(7)) - .then((milestone) => { - milestone.order.should.be.eql(3); - }) - .then(() => models.Milestone.findById(8)) - .then((milestone) => { - milestone.order.should.be.eql(2); - - done(); - }); - }, 3000); + models.Milestone.findById(6) + .then((milestone) => { + milestone.order.should.be.eql(1); + }) + .then(() => models.Milestone.findById(7)) + .then((milestone) => { + milestone.order.should.be.eql(3); + }) + .then(() => models.Milestone.findById(8)) + .then((milestone) => { + milestone.order.should.be.eql(2); + + done(); + }) + .catch(done); }); }); }); @@ -856,22 +821,21 @@ describe('UPDATE Milestone', () => { // Milestone 6: order 1 => 1 // Milestone 7: order 2 => 3 // Milestone 8: order 4 => 2 - setTimeout(() => { - models.Milestone.findById(6) - .then((milestone) => { - milestone.order.should.be.eql(1); - }) - .then(() => models.Milestone.findById(7)) - .then((milestone) => { - milestone.order.should.be.eql(3); - }) - .then(() => models.Milestone.findById(8)) - .then((milestone) => { - milestone.order.should.be.eql(2); - - done(); - }); - }, 3000); + models.Milestone.findById(6) + .then((milestone) => { + milestone.order.should.be.eql(1); + }) + .then(() => models.Milestone.findById(7)) + .then((milestone) => { + milestone.order.should.be.eql(3); + }) + .then(() => models.Milestone.findById(8)) + .then((milestone) => { + milestone.order.should.be.eql(2); + + done(); + }) + .catch(done); }); }); }); @@ -895,20 +859,18 @@ describe('UPDATE Milestone', () => { // endDate: null to '2018-05-21T00:00:00.000Z' // Milestone 4: startDate: '2018-05-14T00:00:00.000Z' to '2018-05-22T00:00:00.000Z' // endDate: null to '2018-05-24T00:00:00.000Z' - setTimeout(() => { - models.Milestone.findById(3) - .then((milestone) => { - milestone.startDate.should.be.eql(new Date('2018-05-19T00:00:00.000Z')); - milestone.endDate.should.be.eql(new Date('2018-05-21T00:00:00.000Z')); - return models.Milestone.findById(4); - }) - .then((milestone) => { - milestone.startDate.should.be.eql(new Date('2018-05-22T00:00:00.000Z')); - milestone.endDate.should.be.eql(new Date('2018-05-24T00:00:00.000Z')); - done(); - }) - .catch(done); - }, 3000); + models.Milestone.findById(3) + .then((milestone) => { + milestone.startDate.should.be.eql(new Date('2018-05-19T00:00:00.000Z')); + milestone.endDate.should.be.eql(new Date('2018-05-21T00:00:00.000Z')); + return models.Milestone.findById(4); + }) + .then((milestone) => { + milestone.startDate.should.be.eql(new Date('2018-05-22T00:00:00.000Z')); + milestone.endDate.should.be.eql(new Date('2018-05-24T00:00:00.000Z')); + done(); + }) + .catch(done); }); }); @@ -929,20 +891,18 @@ describe('UPDATE Milestone', () => { // endDate: null to '2018-05-21T00:00:00.000Z' // Milestone 4: startDate: '2018-05-14T00:00:00.000Z' to '2018-05-22T00:00:00.000Z' // endDate: null to '2018-05-24T00:00:00.000Z' - setTimeout(() => { - models.Milestone.findById(3) - .then((milestone) => { - milestone.startDate.should.be.eql(new Date('2018-05-19T00:00:00.000Z')); - milestone.endDate.should.be.eql(new Date('2018-05-21T00:00:00.000Z')); - return models.Milestone.findById(4); - }) - .then((milestone) => { - milestone.startDate.should.be.eql(new Date('2018-05-22T00:00:00.000Z')); - milestone.endDate.should.be.eql(new Date('2018-05-24T00:00:00.000Z')); - done(); - }) - .catch(done); - }, 3000); + models.Milestone.findById(3) + .then((milestone) => { + milestone.startDate.should.be.eql(new Date('2018-05-19T00:00:00.000Z')); + milestone.endDate.should.be.eql(new Date('2018-05-21T00:00:00.000Z')); + return models.Milestone.findById(4); + }) + .then((milestone) => { + milestone.startDate.should.be.eql(new Date('2018-05-22T00:00:00.000Z')); + milestone.endDate.should.be.eql(new Date('2018-05-24T00:00:00.000Z')); + done(); + }) + .catch(done); }); }); From 61cdff3025f43533b6d7327588bcc66f4c4ed44e Mon Sep 17 00:00:00 2001 From: Gian Franco Zabarino Date: Mon, 30 Jul 2018 21:49:03 -0300 Subject: [PATCH 42/73] Made code more readable. --- src/routes/milestones/update.js | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/src/routes/milestones/update.js b/src/routes/milestones/update.js index 41872634..03534061 100644 --- a/src/routes/milestones/update.js +++ b/src/routes/milestones/update.js @@ -171,14 +171,11 @@ module.exports = [ }); }) .then(() => { - // Update dates of the other milestones only if the completionDate nor the duration changed - if (((!original.completionDate && !updated.completionDate) || - (original.completionDate && updated.completionDate && - original.completionDate.getTime() === updated.completionDate.getTime())) && - original.duration === updated.duration) { - return Promise.resolve(); + // Update dates of the other milestones only if the completionDate or the duration changed + if (!_.isEqual(original.completionDate, updated.completionDate) || original.duration !== updated.duration) { + return updateComingMilestones(updated); } - return updateComingMilestones(updated); + return Promise.resolve(); }), ) .then(() => { From 377b0706b4c0da305db9398250c98ff171d702b5 Mon Sep 17 00:00:00 2001 From: Gian Franco Zabarino Date: Mon, 30 Jul 2018 23:25:14 -0300 Subject: [PATCH 43/73] - When updating a milestone, set updatedBy to updated records. - Handled scenario where the milestone's endDate was null and the startDate didn't change. Now the endDate gets updated. - When the last timeline's (ordered by order) endDate changes, the timeline gets set that endDate as its endDate. Added unit tests about this. - Updated Swagger documentation. - Removed startDate/endDate fields from Postman's milestone's PATCH requests, since now they are not accepted by the server. --- postman.json | 10 ++-- src/routes/milestones/update.js | 35 +++++++++++-- src/routes/milestones/update.spec.js | 66 ++++++++++++++++++++++++ swagger.yaml | 75 +++++++++++++++++++++++++--- 4 files changed, 170 insertions(+), 16 deletions(-) diff --git a/postman.json b/postman.json index 145003ff..a3336ae5 100644 --- a/postman.json +++ b/postman.json @@ -3802,7 +3802,7 @@ ], "body": { "mode": "raw", - "raw": "{\r\n \"param\":{\r\n \"name\": \"milestone 1-updated\",\r\n \"description\": \"description-updated\",\r\n \"duration\": 3,\r\n \"startDate\": \"2018-05-04T00:00:00.000Z\",\r\n \"endDate\": \"2018-05-06T00:00:00.000Z\",\r\n \"completionDate\": \"2018-05-07T00: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 \"duration\": 3,\r\n \"completionDate\": \"2018-05-07T00: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", @@ -3836,7 +3836,7 @@ ], "body": { "mode": "raw", - "raw": "{\r\n \"param\":{\r\n \"name\": \"milestone 1-updated\",\r\n \"description\": \"description-updated\",\r\n \"duration\": 3,\r\n \"startDate\": \"2018-05-04T00:00:00.000Z\",\r\n \"endDate\": \"2018-05-06T00:00:00.000Z\",\r\n \"completionDate\": \"2018-05-07T00: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\": 2,\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 \"duration\": 3,\r\n \"completionDate\": \"2018-05-07T00: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\": 2,\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", @@ -3870,7 +3870,7 @@ ], "body": { "mode": "raw", - "raw": "{\r\n \"param\":{\r\n \"name\": \"milestone 1-updated\",\r\n \"description\": \"description-updated\",\r\n \"duration\": 3,\r\n \"startDate\": \"2018-05-04T00:00:00.000Z\",\r\n \"endDate\": \"2018-05-06T00:00:00.000Z\",\r\n \"completionDate\": \"2018-05-07T00: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 \"duration\": 3,\r\n \"completionDate\": \"2018-05-07T00: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", @@ -3904,7 +3904,7 @@ ], "body": { "mode": "raw", - "raw": "{\r\n \"param\":{\r\n \"name\": \"milestone 1-updated\",\r\n \"description\": \"description-updated\",\r\n \"duration\": 3,\r\n \"startDate\": \"2018-05-04T00:00:00.000Z\",\r\n \"endDate\": \"2018-05-06T00:00:00.000Z\",\r\n \"completionDate\": \"2018-05-07T00: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\": 3,\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 \"duration\": 3,\r\n \"completionDate\": \"2018-05-07T00: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\": 3,\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", @@ -3938,7 +3938,7 @@ ], "body": { "mode": "raw", - "raw": "{\r\n \"param\":{\r\n \"name\": \"milestone 1-updated\",\r\n \"description\": \"description-updated\",\r\n \"duration\": 3,\r\n \"startDate\": \"2018-05-04T00:00:00.000Z\",\r\n \"endDate\": \"2018-05-06T00:00:00.000Z\",\r\n \"completionDate\": \"2018-05-07T00: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 \"duration\": 3,\r\n \"completionDate\": \"2018-05-07T00: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", diff --git a/src/routes/milestones/update.js b/src/routes/milestones/update.js index 03534061..9cb2c698 100644 --- a/src/routes/milestones/update.js +++ b/src/routes/milestones/update.js @@ -17,7 +17,9 @@ const permissions = tcMiddleware.permissions; /** * Cascades endDate/completionDate changes to all milestones with a greater order than the given one. * @param {Object} updatedMilestone the milestone that was updated - * @returns {Promise} a promise + * @returns {Promise} a promise that resolves to the last found milestone. If no milestone exists with an + * order greater than the passed updatedMilestone, the promise will resolve to the passed + * updatedMilestone */ function updateComingMilestones(updatedMilestone) { return models.Milestone.findAll({ @@ -32,16 +34,29 @@ function updateComingMilestones(updatedMilestone) { : updatedMilestone.endDate).add(1, 'days').toDate(); const promises = _.map(comingMilestones, (_milestone) => { const milestone = _milestone; - if (milestone.startDate.getTime() !== startDate.getTime()) { + + // Update the milestone startDate if different than the iterated startDate + if (!_.isEqual(milestone.startDate, startDate)) { milestone.startDate = startDate; - milestone.endDate = moment.utc(startDate).add(milestone.duration - 1, 'days').toDate(); + milestone.updatedBy = updatedMilestone.updatedBy; + } + + // Calculate the endDate, and update it if different + const endDate = moment.utc(startDate).add(milestone.duration - 1, 'days').toDate(); + if (!_.isEqual(milestone.endDate, endDate)) { + milestone.endDate = endDate; + milestone.updatedBy = updatedMilestone.updatedBy; } + + // Set the next startDate value to the next day after completionDate if present or the endDate startDate = moment.utc(milestone.completionDate ? milestone.completionDate : milestone.endDate).add(1, 'days').toDate(); return milestone.save(); }); - return Promise.all(promises); + + // Resolve promise to the last updated milestone, or to the passed in updatedMilestone + return Promise.all(promises).then(updatedMilestones => updatedMilestones.pop() || updatedMilestone); }); } @@ -94,6 +109,8 @@ module.exports = [ timelineId: req.params.timelineId, }); + const timeline = req.timeline; + let original; let updated; @@ -173,7 +190,15 @@ module.exports = [ .then(() => { // Update dates of the other milestones only if the completionDate or the duration changed if (!_.isEqual(original.completionDate, updated.completionDate) || original.duration !== updated.duration) { - return updateComingMilestones(updated); + return updateComingMilestones(updated) + .then((lastTimelineMilestone) => { + if (!_.isEqual(lastTimelineMilestone.endDate, timeline.endDate)) { + timeline.endDate = lastTimelineMilestone.endDate; + timeline.updatedBy = lastTimelineMilestone.updatedBy; + return timeline.save(); + } + return Promise.resolve(); + }); } return Promise.resolve(); }), diff --git a/src/routes/milestones/update.spec.js b/src/routes/milestones/update.spec.js index 1c066efe..b3fde3fe 100644 --- a/src/routes/milestones/update.spec.js +++ b/src/routes/milestones/update.spec.js @@ -874,6 +874,40 @@ describe('UPDATE Milestone', () => { }); }); + it('should return 200 for admin - changing completionDate will change the timeline\'s ' + + // eslint-disable-next-line func-names + 'endDate', function (done) { + this.timeout(10000); + + request(server) + .patch('/v4/timelines/1/milestones/2') + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .send({ param: _.assign({}, body.param, { + completionDate: '2018-05-18T00:00:00.000Z', order: undefined, duration: undefined, + }) }) + .expect(200) + .end(() => { + // Milestone 3: startDate: '2018-05-14T00:00:00.000Z' to '2018-05-19T00:00:00.000Z' + // endDate: null to '2018-05-21T00:00:00.000Z' + // Milestone 4: startDate: '2018-05-14T00:00:00.000Z' to '2018-05-22T00:00:00.000Z' + // BELOW will be the new timeline's endDate + // endDate: null to '2018-05-24T00:00:00.000Z' + models.Timeline.findById(1) + .then((timeline) => { + // timeline start shouldn't change + timeline.startDate.should.be.eql(new Date('2018-05-02T00:00:00.000Z')); + + // timeline end should change + timeline.endDate.should.be.eql(new Date('2018-05-24T00:00:00.000Z')); + + done(); + }) + .catch(done); + }); + }); + it('should return 200 for admin - changing duration will cascade changes to coming ' + // eslint-disable-next-line func-names 'milestones', function (done) { @@ -906,6 +940,38 @@ describe('UPDATE Milestone', () => { }); }); + it('should return 200 for admin - changing duration will change the timeline\'s ' + + // eslint-disable-next-line func-names + 'endDate', function (done) { + this.timeout(10000); + + request(server) + .patch('/v4/timelines/1/milestones/2') + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .send({ param: _.assign({}, body.param, { duration: 5, order: undefined, completionDate: undefined }) }) + .expect(200) + .end(() => { + // Milestone 3: startDate: '2018-05-14T00:00:00.000Z' to '2018-05-19T00:00:00.000Z' + // endDate: null to '2018-05-21T00:00:00.000Z' + // Milestone 4: startDate: '2018-05-14T00:00:00.000Z' to '2018-05-22T00:00:00.000Z' + // BELOW will be the new timeline's endDate + // endDate: null to '2018-05-24T00:00:00.000Z' + models.Timeline.findById(1) + .then((timeline) => { + // timeline start shouldn't change + timeline.startDate.should.be.eql(new Date('2018-05-02T00:00:00.000Z')); + + // timeline end should change + timeline.endDate.should.be.eql(new Date('2018-05-24T00:00:00.000Z')); + + done(); + }) + .catch(done); + }); + }); + it('should return 200 for connect admin', (done) => { request(server) .patch('/v4/timelines/1/milestones/1') diff --git a/swagger.yaml b/swagger.yaml index d2799dee..b95bbe94 100755 --- a/swagger.yaml +++ b/swagger.yaml @@ -1335,7 +1335,7 @@ paths: name: body required: true schema: - $ref: '#/definitions/MilestoneBodyParam' + $ref: '#/definitions/MilestonePostBodyParam' responses: '403': description: No permission or wrong token @@ -1413,7 +1413,7 @@ paths: in: body required: true schema: - $ref: "#/definitions/MilestoneBodyParam" + $ref: "#/definitions/MilestonePatchBodyParam" delete: tags: @@ -3201,7 +3201,7 @@ definitions: items: $ref: "#/definitions/Timeline" - MilestoneRequest: + MilestonePostRequest: title: Milestone request object type: object required: @@ -3264,14 +3264,77 @@ definitions: type: string description: the milestone blocked text - MilestoneBodyParam: + MilestonePatchRequest: + title: Milestone request object + type: object + required: + - name + - duration + - status + - type + - order + - plannedText + - activeText + - completedText + - blockedText + properties: + name: + type: string + description: the milestone name + description: + type: string + description: the milestone description + duration: + type: number + format: integer + description: the milestone duration + completionDate: + type: string + format: date + description: the milestone completion date + status: + type: string + description: the milestone status + type: + type: string + description: the milestone type + details: + type: object + description: the milestone details + order: + type: number + format: integer + description: the milestone order + plannedText: + type: string + description: the milestone planned text + activeText: + type: string + description: the milestone active text + completedText: + type: string + description: the milestone completed text + blockedText: + type: string + description: the milestone blocked text + + MilestonePostBodyParam: + title: Milestone body param + type: object + required: + - param + properties: + param: + $ref: "#/definitions/MilestonePostRequest" + + MilestonePatchBodyParam: title: Milestone body param type: object required: - param properties: param: - $ref: "#/definitions/MilestoneRequest" + $ref: "#/definitions/MilestonePatchRequest" Milestone: title: Milestone object @@ -3306,7 +3369,7 @@ definitions: format: int64 description: READ-ONLY. User that last updated this object readOnly: true - - $ref: "#/definitions/MilestoneRequest" + - $ref: "#/definitions/MilestonePostRequest" MilestoneResponse: title: Single milestone response object From afee68eaf2c40f69c6d1849279e60e49ed5d3428 Mon Sep 17 00:00:00 2001 From: Vikas Agarwal Date: Tue, 31 Jul 2018 17:53:01 +0530 Subject: [PATCH 44/73] lint fix --- src/tests/seed.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/tests/seed.js b/src/tests/seed.js index 9b51e77c..ea2ea5fb 100644 --- a/src/tests/seed.js +++ b/src/tests/seed.js @@ -678,7 +678,7 @@ models.sequelize.sync({ force: true }) aliases: ['key-21', 'key_22'], createdBy: 1, updatedBy: 1, - } + }, ])) .then(() => { process.exit(0); From 69a166ca5a71d9d078198a698cd6b7dd89cae91e Mon Sep 17 00:00:00 2001 From: Vikas Agarwal Date: Tue, 31 Jul 2018 17:54:19 +0530 Subject: [PATCH 45/73] Marking next milestone as active when a milestone is marked as complete Also, makes one thing, from status and completionDate, optional to mark a milestone complete --- src/routes/milestones/update.js | 36 ++++++++++++++++++++++++++++----- 1 file changed, 31 insertions(+), 5 deletions(-) diff --git a/src/routes/milestones/update.js b/src/routes/milestones/update.js index 9cb2c698..0564aea1 100644 --- a/src/routes/milestones/update.js +++ b/src/routes/milestones/update.js @@ -9,19 +9,20 @@ import Sequelize from 'sequelize'; import { middleware as tcMiddleware } from 'tc-core-library-js'; import util from '../../util'; import validateTimeline from '../../middlewares/validateTimeline'; -import { EVENT } from '../../constants'; +import { EVENT, MILESTONE_STATUS } from '../../constants'; import models from '../../models'; const permissions = tcMiddleware.permissions; /** * Cascades endDate/completionDate changes to all milestones with a greater order than the given one. + * @param {Object} originalMilestone the original milestone that was updated * @param {Object} updatedMilestone the milestone that was updated * @returns {Promise} a promise that resolves to the last found milestone. If no milestone exists with an * order greater than the passed updatedMilestone, the promise will resolve to the passed * updatedMilestone */ -function updateComingMilestones(updatedMilestone) { +function updateComingMilestones(originalMilestone, updatedMilestone) { return models.Milestone.findAll({ where: { timelineId: updatedMilestone.timelineId, @@ -32,7 +33,7 @@ function updateComingMilestones(updatedMilestone) { let startDate = moment.utc(updatedMilestone.completionDate ? updatedMilestone.completionDate : updatedMilestone.endDate).add(1, 'days').toDate(); - const promises = _.map(comingMilestones, (_milestone) => { + const promises = _.map(comingMilestones, (_milestone, idx) => { const milestone = _milestone; // Update the milestone startDate if different than the iterated startDate @@ -48,6 +49,12 @@ function updateComingMilestones(updatedMilestone) { milestone.updatedBy = updatedMilestone.updatedBy; } + // if completionDate is alerted, update status of the next milestone to the current one + if (!_.isEqual(originalMilestone.completionDate, updatedMilestone.completionDate) && idx === 0) { + // activate next milestone + milestone.status = MILESTONE_STATUS.ACTIVE; + } + // Set the next startDate value to the next day after completionDate if present or the endDate startDate = moment.utc(milestone.completionDate ? milestone.completionDate @@ -132,14 +139,33 @@ module.exports = [ } original = _.omit(milestone.toJSON(), ['deletedAt', 'deletedBy']); + const durationChanged = entityToUpdate.duration && entityToUpdate.duration !== milestone.duration; + const statusChanged = entityToUpdate.status && entityToUpdate.status !== milestone.status; + const completionDateChanged = entityToUpdate.completionDate + && !_.isEqual(milestone.completionDate, entityToUpdate.completionDate); // Merge JSON fields entityToUpdate.details = util.mergeJsonObjects(milestone.details, entityToUpdate.details); - if (entityToUpdate.duration && entityToUpdate.duration !== milestone.duration) { + if (durationChanged) { entityToUpdate.endDate = moment.utc(milestone.startDate).add(entityToUpdate.duration - 1, 'days').toDate(); } + // if status has changed + if (statusChanged) { + // if status has changed to be completed, set the compeltionDate if not provided + if (entityToUpdate.status === MILESTONE_STATUS.COMPLETED) { + const today = moment.utc().hours(0).minutes(0).seconds(0) + .milliseconds(0); + entityToUpdate.completionDate = entityToUpdate.completionDate ? entityToUpdate.completionDate : today; + } + } + + // if completionDate has changed + if (!statusChanged && completionDateChanged) { + entityToUpdate.status = MILESTONE_STATUS.COMPLETED; + } + // Update return milestone.update(entityToUpdate); }) @@ -190,7 +216,7 @@ module.exports = [ .then(() => { // Update dates of the other milestones only if the completionDate or the duration changed if (!_.isEqual(original.completionDate, updated.completionDate) || original.duration !== updated.duration) { - return updateComingMilestones(updated) + return updateComingMilestones(original, updated) .then((lastTimelineMilestone) => { if (!_.isEqual(lastTimelineMilestone.endDate, timeline.endDate)) { timeline.endDate = lastTimelineMilestone.endDate; From 860997dc074ad752c87b4bfc1d5960b3466a2454 Mon Sep 17 00:00:00 2001 From: Vikas Agarwal Date: Wed, 1 Aug 2018 11:18:18 +0530 Subject: [PATCH 46/73] lint fixes --- src/routes/attachments/delete.spec.js | 2 +- src/routes/milestoneTemplates/delete.spec.js | 2 +- src/routes/milestones/delete.spec.js | 2 +- src/routes/phaseProducts/delete.spec.js | 2 +- src/routes/projects/delete.spec.js | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/routes/attachments/delete.spec.js b/src/routes/attachments/delete.spec.js index 76f223d1..850b8ca1 100644 --- a/src/routes/attachments/delete.spec.js +++ b/src/routes/attachments/delete.spec.js @@ -2,12 +2,12 @@ import _ from 'lodash'; import sinon from 'sinon'; import request from 'supertest'; +import chai from 'chai'; import models from '../../models'; import util from '../../util'; import server from '../../app'; import testUtil from '../../tests/util'; -import chai from 'chai'; describe('Project Attachments delete', () => { diff --git a/src/routes/milestoneTemplates/delete.spec.js b/src/routes/milestoneTemplates/delete.spec.js index 67d99cd0..63091b36 100644 --- a/src/routes/milestoneTemplates/delete.spec.js +++ b/src/routes/milestoneTemplates/delete.spec.js @@ -2,11 +2,11 @@ * Tests for delete.js */ import request from 'supertest'; +import chai from 'chai'; import models from '../../models'; import server from '../../app'; import testUtil from '../../tests/util'; -import chai from 'chai'; const expectAfterDelete = (productTemplateId, id, err, next) => { if (err) throw err; diff --git a/src/routes/milestones/delete.spec.js b/src/routes/milestones/delete.spec.js index 477c2bb3..a82294e9 100644 --- a/src/routes/milestones/delete.spec.js +++ b/src/routes/milestones/delete.spec.js @@ -2,12 +2,12 @@ * Tests for delete.js */ import request from 'supertest'; +import chai from 'chai'; import models from '../../models'; import server from '../../app'; import testUtil from '../../tests/util'; import { EVENT } from '../../constants'; -import chai from 'chai'; const expectAfterDelete = (timelineId, id, err, next) => { diff --git a/src/routes/phaseProducts/delete.spec.js b/src/routes/phaseProducts/delete.spec.js index 7901475c..5f958f4d 100644 --- a/src/routes/phaseProducts/delete.spec.js +++ b/src/routes/phaseProducts/delete.spec.js @@ -1,10 +1,10 @@ /* 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'; -import chai from 'chai'; const expectAfterDelete = (projectId, phaseId, id, err, next) => { if (err) throw err; diff --git a/src/routes/projects/delete.spec.js b/src/routes/projects/delete.spec.js index 4d1e76e6..852d8fb4 100644 --- a/src/routes/projects/delete.spec.js +++ b/src/routes/projects/delete.spec.js @@ -1,10 +1,10 @@ /* eslint-disable no-unused-expressions */ import request from 'supertest'; +import chai from 'chai'; import models from '../../models'; import server from '../../app'; import testUtil from '../../tests/util'; -import chai from 'chai'; const expectAfterDelete = (id, err, next) => { if (err) throw err; From 7a3ab9ce480cedfa0719fe39f35bd30df9d958be Mon Sep 17 00:00:00 2001 From: Vikas Agarwal Date: Wed, 1 Aug 2018 11:18:42 +0530 Subject: [PATCH 47/73] lint fixes --- src/routes/phases/delete.spec.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/routes/phases/delete.spec.js b/src/routes/phases/delete.spec.js index ca93208b..1b3ace93 100644 --- a/src/routes/phases/delete.spec.js +++ b/src/routes/phases/delete.spec.js @@ -1,10 +1,10 @@ /* 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'; -import chai from 'chai'; const expectAfterDelete = (projectId, id, err, next) => { if (err) throw err; From 7589a86c0d4f9f6e71fe06d5c74825935f8af59f Mon Sep 17 00:00:00 2001 From: Vikas Agarwal Date: Wed, 1 Aug 2018 11:19:03 +0530 Subject: [PATCH 48/73] SQL migration script for soft delete --- .../20180801_deletedBy_soft_deleted.sql | 24 +++++++++++++++++++ 1 file changed, 24 insertions(+) create mode 100644 migrations/20180801_deletedBy_soft_deleted.sql diff --git a/migrations/20180801_deletedBy_soft_deleted.sql b/migrations/20180801_deletedBy_soft_deleted.sql new file mode 100644 index 00000000..95bde3e4 --- /dev/null +++ b/migrations/20180801_deletedBy_soft_deleted.sql @@ -0,0 +1,24 @@ +-- +-- UPDATE EXISTING TABLES: +-- projects +-- deletedBy column: added +-- project_attachments +-- deletedBy column: added +-- project_members +-- deletedBy column: added +-- + +-- +-- projects +-- +ALTER TABLE projects ADD COLUMN "deletedBy" bigint; + +-- +-- project_attachments +-- +ALTER TABLE project_attachments ADD COLUMN "deletedBy" bigint; + +-- +-- project_members +-- +ALTER TABLE project_members ADD COLUMN "deletedBy" bigint; \ No newline at end of file From 9cd1630a4352f73dbe551e0ffdb51d9c9fc3af3b Mon Sep 17 00:00:00 2001 From: Vikas Agarwal Date: Wed, 1 Aug 2018 12:12:45 +0530 Subject: [PATCH 49/73] Adding ability to fetch the products along with each phase to remove unnecessary calls --- src/routes/phases/list.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/routes/phases/list.js b/src/routes/phases/list.js index 6644a365..455f7b14 100644 --- a/src/routes/phases/list.js +++ b/src/routes/phases/list.js @@ -51,7 +51,7 @@ module.exports = [ // Parse the fields string to determine what fields are to be returned let fields = req.query.fields ? req.query.fields.split(',') : PHASE_ATTRIBUTES; - fields = _.intersection(fields, PHASE_ATTRIBUTES); + fields = _.intersection(fields, [PHASE_ATTRIBUTES, 'products']); if (_.indexOf(fields, 'id') < 0) { fields.push('id'); } From 7407724dba76ad040d033b7f71ffaa48154a75dd Mon Sep 17 00:00:00 2001 From: Vikas Agarwal Date: Wed, 1 Aug 2018 14:33:18 +0530 Subject: [PATCH 50/73] Fixed bug with returning default fields in phase list endpoint --- src/routes/phases/list.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/routes/phases/list.js b/src/routes/phases/list.js index 455f7b14..c2fffd8a 100644 --- a/src/routes/phases/list.js +++ b/src/routes/phases/list.js @@ -51,7 +51,7 @@ module.exports = [ // Parse the fields string to determine what fields are to be returned let fields = req.query.fields ? req.query.fields.split(',') : PHASE_ATTRIBUTES; - fields = _.intersection(fields, [PHASE_ATTRIBUTES, 'products']); + fields = _.intersection(fields, [...PHASE_ATTRIBUTES, 'products']); if (_.indexOf(fields, 'id') < 0) { fields.push('id'); } From b6ff1c8102ec73e7aa997f0ad6445f510756e8dd Mon Sep 17 00:00:00 2001 From: Vikas Agarwal Date: Wed, 1 Aug 2018 15:22:25 +0530 Subject: [PATCH 51/73] Fixing bug about hidden milestones. --- src/routes/milestones/update.js | 1 + 1 file changed, 1 insertion(+) diff --git a/src/routes/milestones/update.js b/src/routes/milestones/update.js index 0564aea1..2b60019a 100644 --- a/src/routes/milestones/update.js +++ b/src/routes/milestones/update.js @@ -27,6 +27,7 @@ function updateComingMilestones(originalMilestone, updatedMilestone) { where: { timelineId: updatedMilestone.timelineId, order: { $gt: updatedMilestone.order }, + hidden: false, }, }).then((affectedMilestones) => { const comingMilestones = _.sortBy(affectedMilestones, 'order'); From 43ae1fc6b15147c5423ea91ba7833020adc9ab9a Mon Sep 17 00:00:00 2001 From: Vikas Agarwal Date: Fri, 3 Aug 2018 12:53:18 +0530 Subject: [PATCH 52/73] Github issue#156, Update dates of hidden milestones as well when cascading the changes in a milestone - Should be fixed now --- src/routes/milestones/update.js | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/src/routes/milestones/update.js b/src/routes/milestones/update.js index 2b60019a..4032b6e7 100644 --- a/src/routes/milestones/update.js +++ b/src/routes/milestones/update.js @@ -23,18 +23,20 @@ const permissions = tcMiddleware.permissions; * updatedMilestone */ function updateComingMilestones(originalMilestone, updatedMilestone) { + // flag to indicate if the milestone in picture, is updated for completionDate field or not + const completionDateChanged = !_.isEqual(originalMilestone.completionDate, updatedMilestone.completionDate); return models.Milestone.findAll({ where: { timelineId: updatedMilestone.timelineId, order: { $gt: updatedMilestone.order }, - hidden: false, }, }).then((affectedMilestones) => { const comingMilestones = _.sortBy(affectedMilestones, 'order'); let startDate = moment.utc(updatedMilestone.completionDate ? updatedMilestone.completionDate : updatedMilestone.endDate).add(1, 'days').toDate(); - const promises = _.map(comingMilestones, (_milestone, idx) => { + let firstMilestoneFound = false; + const promises = _.map(comingMilestones, (_milestone) => { const milestone = _milestone; // Update the milestone startDate if different than the iterated startDate @@ -50,10 +52,11 @@ function updateComingMilestones(originalMilestone, updatedMilestone) { milestone.updatedBy = updatedMilestone.updatedBy; } - // if completionDate is alerted, update status of the next milestone to the current one - if (!_.isEqual(originalMilestone.completionDate, updatedMilestone.completionDate) && idx === 0) { + // if completionDate is alerted, update status of the first non hidden milestone after the current one + if (!firstMilestoneFound && completionDateChanged && !milestone.hidden) { // activate next milestone milestone.status = MILESTONE_STATUS.ACTIVE; + firstMilestoneFound = true; } // Set the next startDate value to the next day after completionDate if present or the endDate From c2da5b00eb9bd75647940ae8dbb87959b6b6078c Mon Sep 17 00:00:00 2001 From: Vikas Agarwal Date: Tue, 7 Aug 2018 16:21:14 +0530 Subject: [PATCH 53/73] Setting startDate when activating a milstone. --- src/routes/milestones/update.js | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/routes/milestones/update.js b/src/routes/milestones/update.js index 4032b6e7..ee280216 100644 --- a/src/routes/milestones/update.js +++ b/src/routes/milestones/update.js @@ -147,6 +147,8 @@ module.exports = [ const statusChanged = entityToUpdate.status && entityToUpdate.status !== milestone.status; const completionDateChanged = entityToUpdate.completionDate && !_.isEqual(milestone.completionDate, entityToUpdate.completionDate); + const today = moment.utc().hours(0).minutes(0).seconds(0) + .milliseconds(0); // Merge JSON fields entityToUpdate.details = util.mergeJsonObjects(milestone.details, entityToUpdate.details); @@ -159,10 +161,12 @@ module.exports = [ if (statusChanged) { // if status has changed to be completed, set the compeltionDate if not provided if (entityToUpdate.status === MILESTONE_STATUS.COMPLETED) { - const today = moment.utc().hours(0).minutes(0).seconds(0) - .milliseconds(0); entityToUpdate.completionDate = entityToUpdate.completionDate ? entityToUpdate.completionDate : today; } + // if status has changed to be active, set the startDate to today + if (entityToUpdate.status === MILESTONE_STATUS.ACTIVE) { + entityToUpdate.startDate = today; + } } // if completionDate has changed From 264f21e96e6f1e09fcc5ea75e053d1d141e1291d Mon Sep 17 00:00:00 2001 From: Vikas Agarwal Date: Thu, 9 Aug 2018 17:00:43 +0530 Subject: [PATCH 54/73] Trying to add actualStartDate. --- src/models/milestone.js | 1 + src/routes/milestones/create.js | 1 + src/routes/milestones/update.js | 9 ++++++++- src/routes/milestones/update.spec.js | 5 ++++- 4 files changed, 14 insertions(+), 2 deletions(-) diff --git a/src/models/milestone.js b/src/models/milestone.js index a5279571..0f4bc4ec 100644 --- a/src/models/milestone.js +++ b/src/models/milestone.js @@ -10,6 +10,7 @@ module.exports = (sequelize, DataTypes) => { description: DataTypes.STRING(255), duration: { type: DataTypes.INTEGER, allowNull: false }, startDate: { type: DataTypes.DATE, allowNull: false }, + actualStartDate: DataTypes.DATE, endDate: DataTypes.DATE, completionDate: DataTypes.DATE, status: { type: DataTypes.STRING(45), allowNull: false }, diff --git a/src/routes/milestones/create.js b/src/routes/milestones/create.js index f9420285..e0643d38 100644 --- a/src/routes/milestones/create.js +++ b/src/routes/milestones/create.js @@ -24,6 +24,7 @@ const schema = { description: Joi.string().max(255), duration: Joi.number().integer().required(), startDate: Joi.date().required(), + actualStartDate: Joi.date().allow(null), endDate: Joi.date().min(Joi.ref('startDate')).allow(null), completionDate: Joi.date().min(Joi.ref('startDate')).allow(null), status: Joi.string().max(45).required(), diff --git a/src/routes/milestones/update.js b/src/routes/milestones/update.js index ee280216..9412e4dc 100644 --- a/src/routes/milestones/update.js +++ b/src/routes/milestones/update.js @@ -25,6 +25,8 @@ const permissions = tcMiddleware.permissions; function updateComingMilestones(originalMilestone, updatedMilestone) { // flag to indicate if the milestone in picture, is updated for completionDate field or not const completionDateChanged = !_.isEqual(originalMilestone.completionDate, updatedMilestone.completionDate); + const today = moment.utc().hours(0).minutes(0).seconds(0) + .milliseconds(0); return models.Milestone.findAll({ where: { timelineId: updatedMilestone.timelineId, @@ -56,6 +58,7 @@ function updateComingMilestones(originalMilestone, updatedMilestone) { if (!firstMilestoneFound && completionDateChanged && !milestone.hidden) { // activate next milestone milestone.status = MILESTONE_STATUS.ACTIVE; + milestone.actualStartDate = today; firstMilestoneFound = true; } @@ -83,6 +86,7 @@ const schema = { description: Joi.string().max(255), duration: Joi.number().integer().min(1).optional(), startDate: Joi.any().forbidden(), + actualStartDate: Joi.date().allow(null), endDate: Joi.any().forbidden(), completionDate: Joi.date().allow(null), status: Joi.string().max(45).optional(), @@ -165,7 +169,10 @@ module.exports = [ } // if status has changed to be active, set the startDate to today if (entityToUpdate.status === MILESTONE_STATUS.ACTIVE) { - entityToUpdate.startDate = today; + // NOTE: not updating startDate as activating a milestone should not update the scheduled start date + // entityToUpdate.startDate = today; + // should update actual start date + entityToUpdate.actualStartDate = today; } } diff --git a/src/routes/milestones/update.spec.js b/src/routes/milestones/update.spec.js index b3fde3fe..e0da82a2 100644 --- a/src/routes/milestones/update.spec.js +++ b/src/routes/milestones/update.spec.js @@ -7,7 +7,7 @@ import _ from 'lodash'; import models from '../../models'; import server from '../../app'; import testUtil from '../../tests/util'; -import { EVENT } from '../../constants'; +import { EVENT, MILESTONE_STATUS } from '../../constants'; const should = chai.should(); @@ -862,11 +862,14 @@ describe('UPDATE Milestone', () => { models.Milestone.findById(3) .then((milestone) => { milestone.startDate.should.be.eql(new Date('2018-05-19T00:00:00.000Z')); + milestone.actualStartDate.should.be.eql(new Date('2018-05-19T00:00:00.000Z')); milestone.endDate.should.be.eql(new Date('2018-05-21T00:00:00.000Z')); + milestone.status.should.be.eql(MILESTONE_STATUS.ACTIVE); return models.Milestone.findById(4); }) .then((milestone) => { milestone.startDate.should.be.eql(new Date('2018-05-22T00:00:00.000Z')); + should.not.exist(milestone.actualStartDate); milestone.endDate.should.be.eql(new Date('2018-05-24T00:00:00.000Z')); done(); }) From 587762262e2d8ef385a37630365a9e4d052bd680 Mon Sep 17 00:00:00 2001 From: Vikas Agarwal Date: Thu, 9 Aug 2018 17:02:09 +0530 Subject: [PATCH 55/73] =?UTF-8?q?Github=20issue#160,=20/v4/projects/6976/p?= =?UTF-8?q?hases=3Ffields=3D...=20doesn't=20accept=20encoded=20query=20par?= =?UTF-8?q?ams=20=E2=80=94=20Should=20be=20fixed?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/routes/phases/list.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/routes/phases/list.js b/src/routes/phases/list.js index c2fffd8a..55d5ba63 100644 --- a/src/routes/phases/list.js +++ b/src/routes/phases/list.js @@ -20,6 +20,8 @@ module.exports = [ (req, res, next) => { const projectId = _.parseInt(req.params.projectId); + // Parse the fields string to determine what fields are to be returned + let fields = 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'; @@ -49,8 +51,6 @@ module.exports = [ // Sort phases = _.sortBy(phases, [sortColumnAndOrder[0]], [sortColumnAndOrder[1]]); - // Parse the fields string to determine what fields are to be returned - let fields = req.query.fields ? req.query.fields.split(',') : PHASE_ATTRIBUTES; fields = _.intersection(fields, [...PHASE_ATTRIBUTES, 'products']); if (_.indexOf(fields, 'id') < 0) { fields.push('id'); From 72fe6f6c869ee82be2fd69b8aeeb029d72c7eaa0 Mon Sep 17 00:00:00 2001 From: Vikas Agarwal Date: Thu, 9 Aug 2018 17:46:31 +0530 Subject: [PATCH 56/73] trying to fix unit tests for actual start date --- src/routes/milestones/update.spec.js | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/routes/milestones/update.spec.js b/src/routes/milestones/update.spec.js index e0da82a2..e1e096a0 100644 --- a/src/routes/milestones/update.spec.js +++ b/src/routes/milestones/update.spec.js @@ -3,6 +3,7 @@ */ import chai from 'chai'; import request from 'supertest'; +import moment from 'moment'; import _ from 'lodash'; import models from '../../models'; import server from '../../app'; @@ -862,7 +863,10 @@ describe('UPDATE Milestone', () => { models.Milestone.findById(3) .then((milestone) => { milestone.startDate.should.be.eql(new Date('2018-05-19T00:00:00.000Z')); - milestone.actualStartDate.should.be.eql(new Date('2018-05-19T00:00:00.000Z')); + const today = moment.utc(); + should.exist(milestone.actualStartDate); + moment().utc(milestone.actualStartDate).diff(today, 'days').should.be.eql(0); + // milestone.actualStartDate.should.be.eql(today); milestone.endDate.should.be.eql(new Date('2018-05-21T00:00:00.000Z')); milestone.status.should.be.eql(MILESTONE_STATUS.ACTIVE); return models.Milestone.findById(4); From 9ada35f866e592e944669695ec5ce2dbecd1bba8 Mon Sep 17 00:00:00 2001 From: Vikas Agarwal Date: Thu, 9 Aug 2018 19:50:43 +0530 Subject: [PATCH 57/73] SQL script changes for recent backend changes --- .../20180608_project_add_templateId_and_new_tables.sql | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/migrations/20180608_project_add_templateId_and_new_tables.sql b/migrations/20180608_project_add_templateId_and_new_tables.sql index 7b4fb7fe..7107ae02 100644 --- a/migrations/20180608_project_add_templateId_and_new_tables.sql +++ b/migrations/20180608_project_add_templateId_and_new_tables.sql @@ -35,6 +35,7 @@ CREATE TABLE milestones ( duration integer NOT NULL, "startDate" timestamp with time zone NOT NULL, "endDate" timestamp with time zone, + "actualStartDate" timestamp with time zone, "completionDate" timestamp with time zone, status character varying(45) NOT NULL, type character varying(45) NOT NULL, @@ -108,10 +109,10 @@ CREATE TABLE product_milestone_templates ( duration integer NOT NULL, type character varying(45) NOT NULL, "order" integer NOT NULL, - "plannedText" character varying(255) NOT NULL, - "activeText" character varying(255) NOT NULL, - "blockedText" character varying(255) NOT NULL, - "completedText" character varying(255) NOT NULL, + "plannedText" character varying(512) NOT NULL, + "activeText" character varying(512) NOT NULL, + "blockedText" character varying(512) NOT NULL, + "completedText" character varying(512) NOT NULL, "deletedAt" timestamp with time zone, "createdAt" timestamp with time zone, "updatedAt" timestamp with time zone, From 7c003e3385b99b82cd3fc25e97b0eb9ccfed0344 Mon Sep 17 00:00:00 2001 From: Vikas Agarwal Date: Thu, 9 Aug 2018 19:51:35 +0530 Subject: [PATCH 58/73] trying to fix mismatch in database and ES index for milestone updates when they are updated via cascaded changes --- src/events/milestones/index.js | 10 ++++++++++ src/routes/milestones/update.js | 24 ++++++++++++++++-------- 2 files changed, 26 insertions(+), 8 deletions(-) diff --git a/src/events/milestones/index.js b/src/events/milestones/index.js index 3ebd578a..1ba301b9 100644 --- a/src/events/milestones/index.js +++ b/src/events/milestones/index.js @@ -66,6 +66,16 @@ const milestoneUpdatedHandler = Promise.coroutine(function* (logger, msg, channe return single; }); + if (data.cascadedUpdates && data.cascadedUpdates.milestones && data.cascadedUpdates.milestones.length > 0) { + const otherUpdatedMilestones = data.cascadedUpdates.milestones; + _.each(milestones, (m) => { + const updatedMilestone = _.find(otherUpdatedMilestones, oum => oum.id === m.id); + if (updatedMilestone) { + _.assign(m, updatedMilestone); + } + }); + } + if (data.original.order !== data.updated.order) { const milestoneWithSameOrder = _.find(milestones, milestone => milestone.id !== data.updated.id && milestone.order === data.updated.order); diff --git a/src/routes/milestones/update.js b/src/routes/milestones/update.js index 9412e4dc..c292e279 100644 --- a/src/routes/milestones/update.js +++ b/src/routes/milestones/update.js @@ -69,8 +69,11 @@ function updateComingMilestones(originalMilestone, updatedMilestone) { return milestone.save(); }); - // Resolve promise to the last updated milestone, or to the passed in updatedMilestone - return Promise.all(promises).then(updatedMilestones => updatedMilestones.pop() || updatedMilestone); + // Resolve promise with all original and updated milestones + return Promise.all(promises).then(updatedMilestones => ({ + originals: affectedMilestones, + updated: updatedMilestones, + })); }); } @@ -232,23 +235,28 @@ module.exports = [ // Update dates of the other milestones only if the completionDate or the duration changed if (!_.isEqual(original.completionDate, updated.completionDate) || original.duration !== updated.duration) { return updateComingMilestones(original, updated) - .then((lastTimelineMilestone) => { + .then(({ originalMilestones, updatedMilestones }) => { + const lastTimelineMilestone = _.last(updatedMilestones); if (!_.isEqual(lastTimelineMilestone.endDate, timeline.endDate)) { timeline.endDate = lastTimelineMilestone.endDate; timeline.updatedBy = lastTimelineMilestone.updatedBy; - return timeline.save(); + return timeline.save().then(() => ({ originalMilestones, updatedMilestones })); } - return Promise.resolve(); + return Promise.resolve({ originalMilestones, updatedMilestones }); }); } - return Promise.resolve(); + return Promise.resolve({}); }), ) - .then(() => { + .then(({ originalMilestones, updatedMilestones }) => { + const cascadedMilestones = _.map(originalMilestones, om => ({ + original: om, updated: _.find(updatedMilestones, um => um.id === om.id), + })); + const cascadedUpdates = { milestones: cascadedMilestones }; // Send event to bus req.log.debug('Sending event to RabbitMQ bus for milestone %d', updated.id); req.app.services.pubsub.publish(EVENT.ROUTING_KEY.MILESTONE_UPDATED, - { original, updated }, + { original, updated, cascadedUpdates }, { correlationId: req.id }, ); From 2dcd55688458f980d7d5ea7ede7d2a59e2c277b7 Mon Sep 17 00:00:00 2001 From: Vikas Agarwal Date: Fri, 10 Aug 2018 12:08:00 +0530 Subject: [PATCH 59/73] fixed typos --- src/routes/milestones/update.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/routes/milestones/update.js b/src/routes/milestones/update.js index c292e279..c1a6fb98 100644 --- a/src/routes/milestones/update.js +++ b/src/routes/milestones/update.js @@ -71,8 +71,8 @@ function updateComingMilestones(originalMilestone, updatedMilestone) { // Resolve promise with all original and updated milestones return Promise.all(promises).then(updatedMilestones => ({ - originals: affectedMilestones, - updated: updatedMilestones, + originalMilestones: affectedMilestones, + updatedMilestones, })); }); } From 8f0f3f0e87e3a4dcdda7091971a6314887d6a506 Mon Sep 17 00:00:00 2001 From: Vikas Agarwal Date: Fri, 10 Aug 2018 12:19:21 +0530 Subject: [PATCH 60/73] Fixed unit tests Removed code to recalculate the order of the milestones for ES indexing, now it should be carrying those changes to the ES via cascaded updates --- src/events/milestones/index.js | 54 ++++++++++++++++----------------- src/routes/milestones/update.js | 4 ++- 2 files changed, 30 insertions(+), 28 deletions(-) diff --git a/src/events/milestones/index.js b/src/events/milestones/index.js index 1ba301b9..29cb57da 100644 --- a/src/events/milestones/index.js +++ b/src/events/milestones/index.js @@ -76,33 +76,33 @@ const milestoneUpdatedHandler = Promise.coroutine(function* (logger, msg, channe }); } - if (data.original.order !== data.updated.order) { - const milestoneWithSameOrder = - _.find(milestones, milestone => milestone.id !== data.updated.id && milestone.order === data.updated.order); - if (milestoneWithSameOrder) { - // Increase the order from M to K: if there is an item with order K, - // orders from M+1 to K should be made M to K-1 - if (data.original.order < data.updated.order) { - _.each(milestones, (single) => { - if (single.id !== data.updated.id - && (data.original.order + 1) <= single.order - && single.order <= data.updated.order) { - single.order -= 1; // eslint-disable-line no-param-reassign - } - }); - } else { - // Decrease the order from M to K: if there is an item with order K, - // orders from K to M-1 should be made K+1 to M - _.each(milestones, (single) => { - if (single.id !== data.updated.id - && data.updated.order <= single.order - && single.order <= (data.original.order - 1)) { - single.order += 1; // eslint-disable-line no-param-reassign - } - }); - } - } - } + // if (data.original.order !== data.updated.order) { + // const milestoneWithSameOrder = + // _.find(milestones, milestone => milestone.id !== data.updated.id && milestone.order === data.updated.order); + // if (milestoneWithSameOrder) { + // // Increase the order from M to K: if there is an item with order K, + // // orders from M+1 to K should be made M to K-1 + // if (data.original.order < data.updated.order) { + // _.each(milestones, (single) => { + // if (single.id !== data.updated.id + // && (data.original.order + 1) <= single.order + // && single.order <= data.updated.order) { + // single.order -= 1; // eslint-disable-line no-param-reassign + // } + // }); + // } else { + // // Decrease the order from M to K: if there is an item with order K, + // // orders from K to M-1 should be made K+1 to M + // _.each(milestones, (single) => { + // if (single.id !== data.updated.id + // && data.updated.order <= single.order + // && single.order <= (data.original.order - 1)) { + // single.order += 1; // eslint-disable-line no-param-reassign + // } + // }); + // } + // } + // } const merged = _.assign(doc._source, { milestones }); // eslint-disable-line no-underscore-dangle yield eClient.update({ diff --git a/src/routes/milestones/update.js b/src/routes/milestones/update.js index c1a6fb98..b8182748 100644 --- a/src/routes/milestones/update.js +++ b/src/routes/milestones/update.js @@ -236,7 +236,9 @@ module.exports = [ if (!_.isEqual(original.completionDate, updated.completionDate) || original.duration !== updated.duration) { return updateComingMilestones(original, updated) .then(({ originalMilestones, updatedMilestones }) => { - const lastTimelineMilestone = _.last(updatedMilestones); + // finds the last milestone updated + // if no milestone is updated by updateComingMilestones method, it means the current milestone is the last one + const lastTimelineMilestone = updatedMilestones.length ? _.last(updatedMilestones) : updated; if (!_.isEqual(lastTimelineMilestone.endDate, timeline.endDate)) { timeline.endDate = lastTimelineMilestone.endDate; timeline.updatedBy = lastTimelineMilestone.updatedBy; From df36fccb56c24d57e360e0192cc228f4374be54c Mon Sep 17 00:00:00 2001 From: Vikas Agarwal Date: Fri, 10 Aug 2018 14:25:15 +0530 Subject: [PATCH 61/73] Debug statements --- src/events/milestones/index.js | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/events/milestones/index.js b/src/events/milestones/index.js index 29cb57da..8da0438a 100644 --- a/src/events/milestones/index.js +++ b/src/events/milestones/index.js @@ -66,6 +66,7 @@ const milestoneUpdatedHandler = Promise.coroutine(function* (logger, msg, channe return single; }); + console.log(data.cascadedUpdates); if (data.cascadedUpdates && data.cascadedUpdates.milestones && data.cascadedUpdates.milestones.length > 0) { const otherUpdatedMilestones = data.cascadedUpdates.milestones; _.each(milestones, (m) => { @@ -75,6 +76,7 @@ const milestoneUpdatedHandler = Promise.coroutine(function* (logger, msg, channe } }); } + console.log(milestones); // if (data.original.order !== data.updated.order) { // const milestoneWithSameOrder = From 3b788503dd5e7cd82bbff90928ee22691adcd6bb Mon Sep 17 00:00:00 2001 From: Vikas Agarwal Date: Fri, 10 Aug 2018 15:36:14 +0530 Subject: [PATCH 62/73] Fixed issue with handling cascaded updates --- src/events/milestones/index.js | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/events/milestones/index.js b/src/events/milestones/index.js index 8da0438a..914ea44f 100644 --- a/src/events/milestones/index.js +++ b/src/events/milestones/index.js @@ -66,11 +66,12 @@ const milestoneUpdatedHandler = Promise.coroutine(function* (logger, msg, channe return single; }); - console.log(data.cascadedUpdates); if (data.cascadedUpdates && data.cascadedUpdates.milestones && data.cascadedUpdates.milestones.length > 0) { const otherUpdatedMilestones = data.cascadedUpdates.milestones; _.each(milestones, (m) => { - const updatedMilestone = _.find(otherUpdatedMilestones, oum => oum.id === m.id); + // finds the updated milestone from the cascaded updates + const updatedMilestone = _.find(otherUpdatedMilestones, oum => oum.updated && oum.updated.id === m.id); + logger.debug('updatedMilestone=>', updatedMilestone); if (updatedMilestone) { _.assign(m, updatedMilestone); } From 056ca89f2beef2cdf7ef39f8f67e6b42cb32dbde Mon Sep 17 00:00:00 2001 From: Vikas Agarwal Date: Fri, 10 Aug 2018 15:55:40 +0530 Subject: [PATCH 63/73] One more fix for syncing ES and db --- src/events/milestones/index.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/events/milestones/index.js b/src/events/milestones/index.js index 914ea44f..37db927d 100644 --- a/src/events/milestones/index.js +++ b/src/events/milestones/index.js @@ -70,8 +70,8 @@ const milestoneUpdatedHandler = Promise.coroutine(function* (logger, msg, channe const otherUpdatedMilestones = data.cascadedUpdates.milestones; _.each(milestones, (m) => { // finds the updated milestone from the cascaded updates - const updatedMilestone = _.find(otherUpdatedMilestones, oum => oum.updated && oum.updated.id === m.id); - logger.debug('updatedMilestone=>', updatedMilestone); + const updatedMilestoneData = _.find(otherUpdatedMilestones, oum => oum.updated && oum.updated.id === m.id); + logger.debug('updatedMilestone=>', updatedMilestoneData.updated); if (updatedMilestone) { _.assign(m, updatedMilestone); } From 1b6cf9c0c1501ce846ce78c74ba4434aa09b9b30 Mon Sep 17 00:00:00 2001 From: Vikas Agarwal Date: Fri, 10 Aug 2018 15:58:53 +0530 Subject: [PATCH 64/73] lint fix --- src/events/milestones/index.js | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/events/milestones/index.js b/src/events/milestones/index.js index 37db927d..280969a6 100644 --- a/src/events/milestones/index.js +++ b/src/events/milestones/index.js @@ -71,9 +71,9 @@ const milestoneUpdatedHandler = Promise.coroutine(function* (logger, msg, channe _.each(milestones, (m) => { // finds the updated milestone from the cascaded updates const updatedMilestoneData = _.find(otherUpdatedMilestones, oum => oum.updated && oum.updated.id === m.id); - logger.debug('updatedMilestone=>', updatedMilestoneData.updated); - if (updatedMilestone) { - _.assign(m, updatedMilestone); + logger.debug('updatedMilestone=>', updatedMilestoneData); + if (updatedMilestoneData && updatedMilestoneData.updated) { + _.assign(m, updatedMilestoneData.updated); } }); } From e2e0d49d2820014a743bef580a0ecccfe5e6376e Mon Sep 17 00:00:00 2001 From: Vikas Agarwal Date: Fri, 10 Aug 2018 16:58:06 +0530 Subject: [PATCH 65/73] removed debug statement --- src/events/milestones/index.js | 1 - 1 file changed, 1 deletion(-) diff --git a/src/events/milestones/index.js b/src/events/milestones/index.js index 280969a6..71fd0d6b 100644 --- a/src/events/milestones/index.js +++ b/src/events/milestones/index.js @@ -77,7 +77,6 @@ const milestoneUpdatedHandler = Promise.coroutine(function* (logger, msg, channe } }); } - console.log(milestones); // if (data.original.order !== data.updated.order) { // const milestoneWithSameOrder = From 14dc571ae3b93ca80342946d91edb41bf8b947e6 Mon Sep 17 00:00:00 2001 From: Vikas Agarwal Date: Mon, 13 Aug 2018 13:05:16 +0530 Subject: [PATCH 66/73] Fixed logic for updating upcoming milestones when actual start date of milestone is changed. --- src/routes/milestones/update.js | 48 +++++++++++++++++++++------------ 1 file changed, 31 insertions(+), 17 deletions(-) diff --git a/src/routes/milestones/update.js b/src/routes/milestones/update.js index b8182748..c0b27a35 100644 --- a/src/routes/milestones/update.js +++ b/src/routes/milestones/update.js @@ -16,27 +16,32 @@ const permissions = tcMiddleware.permissions; /** * Cascades endDate/completionDate changes to all milestones with a greater order than the given one. - * @param {Object} originalMilestone the original milestone that was updated - * @param {Object} updatedMilestone the milestone that was updated + * @param {Object} origMilestone the original milestone that was updated + * @param {Object} updMilestone the milestone that was updated * @returns {Promise} a promise that resolves to the last found milestone. If no milestone exists with an - * order greater than the passed updatedMilestone, the promise will resolve to the passed - * updatedMilestone + * order greater than the passed updMilestone, the promise will resolve to the passed + * updMilestone */ -function updateComingMilestones(originalMilestone, updatedMilestone) { +function updateComingMilestones(origMilestone, updMilestone) { // flag to indicate if the milestone in picture, is updated for completionDate field or not - const completionDateChanged = !_.isEqual(originalMilestone.completionDate, updatedMilestone.completionDate); + const completionDateChanged = !_.isEqual(origMilestone.completionDate, updMilestone.completionDate); const today = moment.utc().hours(0).minutes(0).seconds(0) .milliseconds(0); + // updated milestone's start date, pefers actual start date over scheduled start date + const updMSStartDate = updMilestone.actualStartDate ? updMilestone.actualStartDate : updMilestone.startDate; + // calculates schedule end date for the milestone based on start date and duration + let updMilestoneEndDate = moment.utc(updMSStartDate).add(updMilestone.duration - 1, 'days').toDate(); + // if the milestone, in context, is completed, overrides the end date to the completion date + updMilestoneEndDate = updMilestone.completionDate ? updMilestone.completionDate : updMilestoneEndDate; return models.Milestone.findAll({ where: { - timelineId: updatedMilestone.timelineId, - order: { $gt: updatedMilestone.order }, + timelineId: updMilestone.timelineId, + order: { $gt: updMilestone.order }, }, }).then((affectedMilestones) => { const comingMilestones = _.sortBy(affectedMilestones, 'order'); - let startDate = moment.utc(updatedMilestone.completionDate - ? updatedMilestone.completionDate - : updatedMilestone.endDate).add(1, 'days').toDate(); + // calculates the schedule start date for the next milestone + let startDate = moment.utc(updMilestoneEndDate).add(1, 'days').toDate(); let firstMilestoneFound = false; const promises = _.map(comingMilestones, (_milestone) => { const milestone = _milestone; @@ -44,14 +49,14 @@ function updateComingMilestones(originalMilestone, updatedMilestone) { // Update the milestone startDate if different than the iterated startDate if (!_.isEqual(milestone.startDate, startDate)) { milestone.startDate = startDate; - milestone.updatedBy = updatedMilestone.updatedBy; + milestone.updatedBy = updMilestone.updatedBy; } // Calculate the endDate, and update it if different const endDate = moment.utc(startDate).add(milestone.duration - 1, 'days').toDate(); if (!_.isEqual(milestone.endDate, endDate)) { milestone.endDate = endDate; - milestone.updatedBy = updatedMilestone.updatedBy; + milestone.updatedBy = updMilestone.updatedBy; } // if completionDate is alerted, update status of the first non hidden milestone after the current one @@ -160,10 +165,7 @@ module.exports = [ // Merge JSON fields entityToUpdate.details = util.mergeJsonObjects(milestone.details, entityToUpdate.details); - if (durationChanged) { - entityToUpdate.endDate = moment.utc(milestone.startDate).add(entityToUpdate.duration - 1, 'days').toDate(); - } - + let actualStartDateCanged = false; // if status has changed if (statusChanged) { // if status has changed to be completed, set the compeltionDate if not provided @@ -176,9 +178,21 @@ module.exports = [ // entityToUpdate.startDate = today; // should update actual start date entityToUpdate.actualStartDate = today; + actualStartDateCanged = true; } } + // Updates the end date of the milestone if: + // 1. if duration of the milestone is udpated, update its end date + // OR + // 2. if actual start date is updated, updating the end date of the activated milestone because + // early or late start of milestone, we are essentially changing the end schedule of the milestone + if (durationChanged || actualStartDateCanged) { + const updatedStartDate = actualStartDateCanged ? entityToUpdate.actualStartDate : milestone.startDate; + const updatedDuration = _.get(entityToUpdate, 'duration', milestone.duration); + entityToUpdate.endDate = moment.utc(updatedStartDate).add(updatedDuration - 1, 'days').toDate(); + } + // if completionDate has changed if (!statusChanged && completionDateChanged) { entityToUpdate.status = MILESTONE_STATUS.COMPLETED; From 63abc6e0608ecf1380b58e468320633f160f1e36 Mon Sep 17 00:00:00 2001 From: Vikas Agarwal Date: Mon, 13 Aug 2018 15:15:13 +0530 Subject: [PATCH 67/73] Fixed logic to trigger cascade updates when a milestone is marked as active earlier or later than its scheduled start --- src/routes/milestones/update.js | 8 +++- src/routes/milestones/update.spec.js | 57 +++++++++++++++++++++++++++- 2 files changed, 62 insertions(+), 3 deletions(-) diff --git a/src/routes/milestones/update.js b/src/routes/milestones/update.js index c0b27a35..24230354 100644 --- a/src/routes/milestones/update.js +++ b/src/routes/milestones/update.js @@ -246,8 +246,12 @@ module.exports = [ }); }) .then(() => { - // Update dates of the other milestones only if the completionDate or the duration changed - if (!_.isEqual(original.completionDate, updated.completionDate) || original.duration !== updated.duration) { + // we need to recalculate change in fields because we update some fields before making actual update + const needToCascade = !_.isEqual(original.completionDate, updated.completionDate) // completion date changed + || original.duration !== updated.duration // duration changed + || original.actualStartDate !== updated.actualStartDate; // actual start date updated + // Update dates of the other milestones only if cascade updates needed + if (needToCascade) { return updateComingMilestones(original, updated) .then(({ originalMilestones, updatedMilestones }) => { // finds the last milestone updated diff --git a/src/routes/milestones/update.spec.js b/src/routes/milestones/update.spec.js index e1e096a0..d6b77bb9 100644 --- a/src/routes/milestones/update.spec.js +++ b/src/routes/milestones/update.spec.js @@ -841,10 +841,66 @@ describe('UPDATE Milestone', () => { }); }); + it('should return 200 for admin - marking milestone active later will cascade changes to coming ' + + // eslint-disable-next-line func-names + 'milestones', function (done) { + this.timeout(10000); + const today = moment.utc().hours(0).minutes(0).seconds(0) + .milliseconds(0); + + request(server) + .patch('/v4/timelines/1/milestones/2') + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .send({ param: { status: MILESTONE_STATUS.ACTIVE } }) + .expect(200) + .end(() => { + // Milestone 2: startDate: '2018-05-14T00:00:00.000Z' to '2018-05-14T00:00:00.000Z' + // actualStartDate: null to today + // endDate: null to today + 2 (2 = duration - 1) + // Milestone 3: startDate: '2018-05-14T00:00:00.000Z' to today + 3 + // endDate: null to today + 5 (5 = 3 + duration - 1) + // Milestone 4: startDate: '2018-05-14T00:00:00.000Z' to today + 6 + // endDate: null to today + 8 (2 = 6 + duration - 1) + models.Milestone.findById(2) + .then((milestone) => { + should.exist(milestone.actualStartDate); + moment.utc(milestone.actualStartDate).diff(today, 'days').should.be.eql(0); + // start date of the updated milestone should not change + milestone.startDate.should.be.eql(new Date('2018-05-14T00:00:00.000Z')); + today.add('days', milestone.duration - 1); + // end date of the updated milestone should change, as delayed start caused scheduled to be delayed + moment.utc(milestone.endDate).diff(today, 'days').should.be.eql(0); + milestone.status.should.be.eql(MILESTONE_STATUS.ACTIVE); + return models.Milestone.findById(3); + }) + .then((milestone) => { + today.add('days', 1); // should have start date next to previous one's end date + moment.utc(milestone.startDate).diff(today, 'days').should.be.eql(0); + should.not.exist(milestone.actualStartDate); + today.add('days', milestone.duration - 1); + moment.utc(milestone.endDate).diff(today, 'days').should.be.eql(0); + return models.Milestone.findById(4); + }) + .then((milestone) => { + today.add('days', 1); // should have start date next to previous one's end date + moment.utc(milestone.startDate).diff(today, 'days').should.be.eql(0); + should.not.exist(milestone.actualStartDate); + today.add('days', milestone.duration - 1); + moment.utc(milestone.endDate).diff(today, 'days').should.be.eql(0); + done(); + }) + .catch(done); + }); + }); + it('should return 200 for admin - changing completionDate will cascade changes to coming ' + // eslint-disable-next-line func-names 'milestones', function (done) { this.timeout(10000); + const today = moment.utc().hours(0).minutes(0).seconds(0) + .milliseconds(0); request(server) .patch('/v4/timelines/1/milestones/2') @@ -863,7 +919,6 @@ describe('UPDATE Milestone', () => { models.Milestone.findById(3) .then((milestone) => { milestone.startDate.should.be.eql(new Date('2018-05-19T00:00:00.000Z')); - const today = moment.utc(); should.exist(milestone.actualStartDate); moment().utc(milestone.actualStartDate).diff(today, 'days').should.be.eql(0); // milestone.actualStartDate.should.be.eql(today); From 90d69bc1e84ce196cc0448327d2d2c1ec4352651 Mon Sep 17 00:00:00 2001 From: Vikas Agarwal Date: Mon, 13 Aug 2018 15:29:01 +0530 Subject: [PATCH 68/73] ordering milestone templates before creating milestones based on them --- src/routes/timelines/create.js | 1 + 1 file changed, 1 insertion(+) diff --git a/src/routes/timelines/create.js b/src/routes/timelines/create.js index 9f832861..f60e2509 100644 --- a/src/routes/timelines/create.js +++ b/src/routes/timelines/create.js @@ -64,6 +64,7 @@ module.exports = [ productTemplateId: templateId, deletedAt: { $eq: null }, }, + order: [['order', 'asc']], }).then((milestoneTemplates) => { if (milestoneTemplates) { req.log.debug('%d MilestoneTemplates found', milestoneTemplates.length); From 488c5ac76997082fbf6206135614417a7dbe7a43 Mon Sep 17 00:00:00 2001 From: Vikas Agarwal Date: Mon, 13 Aug 2018 16:10:05 +0530 Subject: [PATCH 69/73] Removed in built validation and added explicit validation on startDate and endDate so that we can pass them without each other --- src/routes/phases/create.js | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/routes/phases/create.js b/src/routes/phases/create.js index bde85c87..bb0cb8c8 100644 --- a/src/routes/phases/create.js +++ b/src/routes/phases/create.js @@ -14,7 +14,7 @@ const addProjectPhaseValidations = { param: Joi.object().keys({ name: Joi.string().required(), status: Joi.string().required(), - startDate: Joi.date().max(Joi.ref('endDate')).optional(), + startDate: Joi.date().optional(), endDate: Joi.date().optional(), duration: Joi.number().min(0).optional(), budget: Joi.number().min(0).optional(), @@ -52,6 +52,11 @@ module.exports = [ err.status = 404; throw err; } + if (data.startDate !== null && data.endDate !== null && data.startDate > data.endDate) { + const err = new Error('startDate must not be after endDate.'); + err.status = 400; + throw err; + } return models.ProjectPhase .create(data) .then((_newProjectPhase) => { From dcfc62b1593cbb4b095ecf4fc1690e727a78aea6 Mon Sep 17 00:00:00 2001 From: Vikas Agarwal Date: Mon, 13 Aug 2018 16:28:45 +0530 Subject: [PATCH 70/73] fixing unit test --- src/routes/phases/create.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/routes/phases/create.js b/src/routes/phases/create.js index bb0cb8c8..3c5c2f83 100644 --- a/src/routes/phases/create.js +++ b/src/routes/phases/create.js @@ -54,7 +54,7 @@ module.exports = [ } if (data.startDate !== null && data.endDate !== null && data.startDate > data.endDate) { const err = new Error('startDate must not be after endDate.'); - err.status = 400; + err.status = 422; throw err; } return models.ProjectPhase From 5b1cdd8dae6618b98cfd1d33e183c75327478bc2 Mon Sep 17 00:00:00 2001 From: Vikas Agarwal Date: Mon, 13 Aug 2018 18:04:41 +0530 Subject: [PATCH 71/73] trying to handle hidden milestones while creating them with new timeline --- src/routes/timelines/create.js | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/routes/timelines/create.js b/src/routes/timelines/create.js index f60e2509..7aa53502 100644 --- a/src/routes/timelines/create.js +++ b/src/routes/timelines/create.js @@ -90,7 +90,9 @@ module.exports = [ createdBy: req.authUser.userId, updatedBy: req.authUser.userId, }; - startDate = endDate.add(1, 'days'); + if (!mt.hidden) { + startDate = endDate.add(1, 'days'); + } return milestone; }); return models.Milestone.bulkCreate(milestones, { returning: true }) From a02eefbe1719747348b11f1255fc2103d813df2a Mon Sep 17 00:00:00 2001 From: Vikas Agarwal Date: Tue, 14 Aug 2018 12:00:09 +0530 Subject: [PATCH 72/73] Removing foreign key validation for milestone templates so that it can be decoupled in upcoming releases from products --- migrations/20180608_project_add_templateId_and_new_tables.sql | 4 ---- 1 file changed, 4 deletions(-) diff --git a/migrations/20180608_project_add_templateId_and_new_tables.sql b/migrations/20180608_project_add_templateId_and_new_tables.sql index 7107ae02..5b1ede10 100644 --- a/migrations/20180608_project_add_templateId_and_new_tables.sql +++ b/migrations/20180608_project_add_templateId_and_new_tables.sql @@ -318,9 +318,5 @@ ALTER TABLE ONLY milestones ALTER TABLE ONLY phase_products ADD CONSTRAINT "phase_products_phaseId_fkey" FOREIGN KEY ("phaseId") REFERENCES project_phases(id) ON UPDATE CASCADE ON DELETE SET NULL; - -ALTER TABLE ONLY product_milestone_templates - ADD CONSTRAINT "product_milestone_templates_productTemplateId_fkey" FOREIGN KEY ("productTemplateId") REFERENCES product_templates(id) ON UPDATE CASCADE ON DELETE CASCADE; - ALTER TABLE ONLY project_phases ADD CONSTRAINT "project_phases_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES projects(id) ON UPDATE CASCADE ON DELETE SET NULL; From 0191f1f4c1fa19cbeadcdc2ddd6100f1a550cc91 Mon Sep 17 00:00:00 2001 From: Vikas Agarwal Date: Tue, 14 Aug 2018 12:07:50 +0530 Subject: [PATCH 73/73] unit tests fix --- src/routes/projectTemplates/create.spec.js | 1 + src/routes/projectTemplates/update.spec.js | 2 ++ 2 files changed, 3 insertions(+) diff --git a/src/routes/projectTemplates/create.spec.js b/src/routes/projectTemplates/create.spec.js index 7dbb0d2b..c18123cb 100644 --- a/src/routes/projectTemplates/create.spec.js +++ b/src/routes/projectTemplates/create.spec.js @@ -22,6 +22,7 @@ describe('CREATE project template', () => { question: 'question 1', info: 'info 1', aliases: ['key-1', 'key_1'], + metadata: {}, createdBy: 1, updatedBy: 1, }, diff --git a/src/routes/projectTemplates/update.spec.js b/src/routes/projectTemplates/update.spec.js index 1a533cdf..514df753 100644 --- a/src/routes/projectTemplates/update.spec.js +++ b/src/routes/projectTemplates/update.spec.js @@ -59,6 +59,7 @@ describe('UPDATE project template', () => { question: 'question 1', info: 'info 1', aliases: ['key-1', 'key_1'], + metadata: {}, createdBy: 1, updatedBy: 1, }, @@ -69,6 +70,7 @@ describe('UPDATE project template', () => { question: 'question 2', info: 'info 2', aliases: ['key-2', 'key_2'], + metadata: {}, createdBy: 1, updatedBy: 1, },