From aa39c278c9eb1d5bda961c5bce8ef1189fb2a2b9 Mon Sep 17 00:00:00 2001 From: ngoctay Date: Thu, 7 Jun 2018 00:07:25 +0700 Subject: [PATCH 1/3] =?UTF-8?q?-=20Merge=20the=20code=20with=20"topcoder?= =?UTF-8?q?=20Connect=20-=20Timeline=20and=20Milestone=20REST=20API"=20cha?= =?UTF-8?q?llenge=20(30065695)=20-=20(Review)=20Milestone:=20startDate,=20?= =?UTF-8?q?status=20should=20be=20required=20fields=20-=20(Review)=20When?= =?UTF-8?q?=20deleting=20a=20timeline,=20it=20should=20be=20cascaded=20to?= =?UTF-8?q?=20it's=20milestone=20as=20well=20-=20(Review)=20Milestone=20co?= =?UTF-8?q?mpletionDate=20must=20not=20be=20before=20startDate=20-=20(Revi?= =?UTF-8?q?ew)=20Postman:=20DELETE=20should=20not=20have=20request=20body?= =?UTF-8?q?=20-=20(Review)=20Postman:=20add=20at=20least=201=20negative=20?= =?UTF-8?q?test=20case=20for=20the=20POST=20end=20points=20-=20(Paulo)=20D?= =?UTF-8?q?on=E2=80=99t=20allow=20startDate=20and=20endDate=20of=20milesto?= =?UTF-8?q?nes=20out=20of=20bound=20of=20it=E2=80=99s=20parent=20timeline.?= =?UTF-8?q?=20=20=20If=20startDate=20or=20endDate=20for=20timeline=20is=20?= =?UTF-8?q?updated,=20children=20must=20validated=20and=20changed=20accord?= =?UTF-8?q?ingly=20-=20(Paulo)=20GET=20/timelines,=20/timelines/{id}=20sho?= =?UTF-8?q?uld=20return=20the=20associated=20milestones=20-=20(Paulo)=20Up?= =?UTF-8?q?date=20milestone=20order=20for=20PATCH=20endpoint?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 2 +- config/custom-environment-variables.json | 4 +- config/default.json | 13 +- 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 | 791 +++++++++ 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 ++++++++++++++---- 51 files changed, 10120 insertions(+), 377 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/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..29a87f18 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": "", @@ -36,13 +38,12 @@ "validIssuers": "[\"https:\/\/topcoder-newauth.auth0.com\/\",\"https:\/\/api.topcoder-dev.com\"]", "jwksUri": "", "busApiUrl": "http://api.topcoder-dev.com/v5", - "messageApiUrl": "http://api.topcoder-dev.com/v5", "busApiToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJuYW1lIjoicHJvamVjdC1zZXJ2aWNlIiwiaWF0IjoxNTEyNzQ3MDgyLCJleHAiOjE1MjEzODcwODJ9.PHuNcFDaotGAL8RhQXQMdpL8yOKXxjB5DbBIodmt7RE", "HEALTH_CHECK_URL": "_health", "maxPhaseProductCount": 1, - "AUTH0_CLIENT_ID": "", - "AUTH0_CLIENT_SECRET": "", - "AUTH0_AUDIENCE": "", - "AUTH0_URL": "", + "AUTH0_CLIENT_ID": "5fctfjaLJHdvM04kSrCcC8yn0I4t1JTd", + "AUTH0_CLIENT_SECRET": "GhvDENIrYXo-d8xQ10fxm9k7XSVg491vlpvolXyWNBmeBdhsA5BAq2mH4cAAYS0x", + "AUTH0_AUDIENCE": "https://www.topcoder.com", + "AUTH0_URL": "https://topcoder-newauth.auth0.com/oauth/token", "TOKEN_CACHE_TIME": "" } 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 ed5f4ba6..1b48b8bd 100644 --- a/src/constants.js +++ b/src/constants.js @@ -47,6 +47,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', }, }; @@ -86,3 +94,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 72d7bc30..2671d98e 100644 --- a/src/models/productTemplate.js +++ b/src/models/productTemplate.js @@ -26,6 +26,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 6ea7a418..f0d3af2a 100644 --- a/src/permissions/index.js +++ b/src/permissions/index.js @@ -35,6 +35,11 @@ module.exports = () => { Authorizer.setPolicy('productTemplate.delete', connectManagerOrAdmin); Authorizer.setPolicy('productTemplate.view', true); + Authorizer.setPolicy('milestoneTemplate.create', connectManagerOrAdmin); + Authorizer.setPolicy('milestoneTemplate.edit', connectManagerOrAdmin); + Authorizer.setPolicy('milestoneTemplate.delete', connectManagerOrAdmin); + Authorizer.setPolicy('milestoneTemplate.view', true); + Authorizer.setPolicy('project.addProjectPhase', projectEdit); Authorizer.setPolicy('project.updateProjectPhase', projectEdit); Authorizer.setPolicy('project.deleteProjectPhase', projectEdit); @@ -46,4 +51,14 @@ module.exports = () => { 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 289f47c6..d9b46645 100644 --- a/src/routes/index.js +++ b/src/routes/index.js @@ -27,7 +27,7 @@ router.get(`/${apiVersion}/projects/health`, (req, res) => { const jwtAuth = require('tc-core-library-js').middleware.jwtAuthenticator; router.all( - RegExp(`\\/${apiVersion}\\/(projects|projectTemplates|productTemplates|projectTypes)(?!\\/health).*`), + RegExp(`\\/${apiVersion}\\/(projects|projectTemplates|productTemplates|projectTypes|timelines)(?!\\/health).*`), jwtAuth()); // Register all the routes @@ -88,6 +88,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')); @@ -115,6 +124,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..f120dc25 --- /dev/null +++ b/src/routes/milestones/update.spec.js @@ -0,0 +1,791 @@ +/** + * 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', + }, + ]))) + .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 3 + // Milestone 3: order 4 + // 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); + }); + }); + + 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 da0e243e73640ae0b94113b24794a8fd3a547a8c Mon Sep 17 00:00:00 2001 From: ngoctay Date: Thu, 7 Jun 2018 00:11:11 +0700 Subject: [PATCH 2/3] Removed secrets in default.json --- config/default.json | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/config/default.json b/config/default.json index 29a87f18..556a5a10 100644 --- a/config/default.json +++ b/config/default.json @@ -38,12 +38,13 @@ "validIssuers": "[\"https:\/\/topcoder-newauth.auth0.com\/\",\"https:\/\/api.topcoder-dev.com\"]", "jwksUri": "", "busApiUrl": "http://api.topcoder-dev.com/v5", + "messageApiUrl": "http://api.topcoder-dev.com/v5", "busApiToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJuYW1lIjoicHJvamVjdC1zZXJ2aWNlIiwiaWF0IjoxNTEyNzQ3MDgyLCJleHAiOjE1MjEzODcwODJ9.PHuNcFDaotGAL8RhQXQMdpL8yOKXxjB5DbBIodmt7RE", "HEALTH_CHECK_URL": "_health", "maxPhaseProductCount": 1, - "AUTH0_CLIENT_ID": "5fctfjaLJHdvM04kSrCcC8yn0I4t1JTd", - "AUTH0_CLIENT_SECRET": "GhvDENIrYXo-d8xQ10fxm9k7XSVg491vlpvolXyWNBmeBdhsA5BAq2mH4cAAYS0x", - "AUTH0_AUDIENCE": "https://www.topcoder.com", - "AUTH0_URL": "https://topcoder-newauth.auth0.com/oauth/token", + "AUTH0_CLIENT_ID": "", + "AUTH0_CLIENT_SECRET": "", + "AUTH0_AUDIENCE": "", + "AUTH0_URL": "", "TOKEN_CACHE_TIME": "" } From 157bb75db6516bc5da4af69f1a677ce444e02511 Mon Sep 17 00:00:00 2001 From: ngoctay Date: Fri, 8 Jun 2018 09:06:54 +0700 Subject: [PATCH 3/3] Added more tests for updating milestone order --- src/routes/milestones/update.spec.js | 194 ++++++++++++++++++++++++++- 1 file changed, 192 insertions(+), 2 deletions(-) diff --git a/src/routes/milestones/update.spec.js b/src/routes/milestones/update.spec.js index f120dc25..fca57115 100644 --- a/src/routes/milestones/update.spec.js +++ b/src/routes/milestones/update.spec.js @@ -225,6 +225,24 @@ describe('UPDATE Milestone', () => { 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()); }); @@ -718,8 +736,8 @@ describe('UPDATE Milestone', () => { .expect(200) .end(() => { // Milestone 1: order 1 - // Milestone 2: order 3 - // Milestone 3: order 4 + // Milestone 2: order 2 + // Milestone 3: order 3 // Milestone 4: order 0 setTimeout(() => { models.Milestone.findById(1) @@ -744,6 +762,178 @@ describe('UPDATE Milestone', () => { }); }); + // 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')