diff --git a/README.md b/README.md index 2a18f10c..e2e5a707 100644 --- a/README.md +++ b/README.md @@ -71,7 +71,7 @@ Run image: `docker run -p 3000:3000 -i -t -e DB_HOST=172.17.0.1 tc_projects_services` You may replace 172.17.0.1 with your docker0 IP. -You can paste **swagger.yaml** to [swagger editor](http://editor.swagger.io/) or import **postman.json** to verify endpoints. +You can paste **swagger.yaml** to [swagger editor](http://editor.swagger.io/) or import **postman.json** and **postman_environment.json** to verify endpoints. #### Deploying without docker If you don't want to use docker to deploy to localhost. You can simply run `npm run start` from root of project. This should start the server on default port `3000`. diff --git a/config/default.json b/config/default.json index 1db936d4..05b3dfe5 100644 --- a/config/default.json +++ b/config/default.json @@ -33,8 +33,12 @@ }, "analyticsKey": "", "VALID_ISSUERS": "[\"https:\/\/topcoder-newauth.auth0.com\/\",\"https:\/\/api.topcoder-dev.com\"]", - "busApiUrl": "http://api.topcoder-dev.com", + "validIssuers": "[\"https:\/\/topcoder-newauth.auth0.com\/\",\"https:\/\/api.topcoder-dev.com\"]", + "jwksUri": "", + "busApiUrl": "http://api.topcoder-dev.com/v5", + "busApiToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJuYW1lIjoicHJvamVjdC1zZXJ2aWNlIiwiaWF0IjoxNTEyNzQ3MDgyLCJleHAiOjE1MjEzODcwODJ9.PHuNcFDaotGAL8RhQXQMdpL8yOKXxjB5DbBIodmt7RE", "HEALTH_CHECK_URL": "_health", + "maxPhaseProductCount": 1, "AUTH0_CLIENT_ID": "", "AUTH0_CLIENT_SECRET": "", "AUTH0_AUDIENCE": "", diff --git a/local/mock-services/authMiddleware.js b/local/mock-services/authMiddleware.js index f7939a67..9cf0387c 100644 --- a/local/mock-services/authMiddleware.js +++ b/local/mock-services/authMiddleware.js @@ -1,5 +1,5 @@ module.exports = function def(req, res, next) { - if (req.method === 'POST' && req.url === '/authorizations/') { + if (req.method === 'POST' && (req.url === '/authorizations/' || req.url === '/authorizations')) { const resp = { id: '1', result: { diff --git a/local/mock-services/server.js b/local/mock-services/server.js index 0a5ca5fd..029df5b7 100644 --- a/local/mock-services/server.js +++ b/local/mock-services/server.js @@ -23,9 +23,10 @@ server.use(authMiddleware); // add additional search route for project members server.get('/v3/members/_search', (req, res) => { const fields = _.isString(req.query.fields) ? req.query.fields.split(',') : []; - const filter = _.isString(req.query.query) ? req.query.query.split(' OR ') : []; + const filter = _.isString(req.query.query) ? + req.query.query.replace('%2520', ' ').replace('%20', ' ').split(' OR ') : []; const criteria = _.map(filter, (single) => { - const ret = { }; + const ret = {}; const splitted = single.split(':'); // if the result can be parsed successfully const parsed = jsprim.parseInteger(splitted[1], { allowTrailing: true, trimWhitespace: true }); diff --git a/local/mock-services/services.json b/local/mock-services/services.json index e391a9b9..87ef7863 100644 --- a/local/mock-services/services.json +++ b/local/mock-services/services.json @@ -99,6 +99,156 @@ } }, "version": "v3" + }, + { + "id": "test_customer1", + "result": { + "success": true, + "status": 200, + "metadata": null, + "content": { + "maxRating": { + "rating": 1114, + "track": "DATA_SCIENCE", + "subTrack": "SRM" + }, + "createdBy": "40011578", + "updatedBy": "40011578", + "userId": 40051331, + "firstName": "Firstname", + "lastName": "Lastname", + "quote": "It is a mistake to think you can solve any major problems just with potatoes.", + "description": null, + "otherLangName": null, + "handle": "test_customer1", + "handleLower": "test_customer1", + "status": "ACTIVE", + "email": "test_customer1@email.com", + "addresses": [ + { + "streetAddr1": "100 Main Street", + "streetAddr2": "", + "city": "Chicago", + "zip": "60601", + "stateCode": "IL", + "type": "HOME", + "updatedAt": null, + "createdAt": null, + "createdBy": null, + "updatedBy": null + } + ], + "homeCountryCode": "USA", + "competitionCountryCode": "USA", + "photoURL": null, + "tracks": [ + "DEVELOP" + ], + "updatedAt": "2015-12-02T14:00Z", + "createdAt": "2014-04-10T10:55Z" + } + }, + "version": "v3" + }, + { + "id": "test_copilot1", + "result": { + "success": true, + "status": 200, + "metadata": null, + "content": { + "maxRating": { + "rating": 1114, + "track": "DATA_SCIENCE", + "subTrack": "SRM" + }, + "createdBy": "40011578", + "updatedBy": "40011578", + "userId": 40051332, + "firstName": "Firstname", + "lastName": "Lastname", + "quote": "It is a mistake to think you can solve any major problems just with potatoes.", + "description": null, + "otherLangName": null, + "handle": "test_copilot1", + "handleLower": "test_copilot1", + "status": "ACTIVE", + "email": "test_copilot1@email.com", + "addresses": [ + { + "streetAddr1": "100 Main Street", + "streetAddr2": "", + "city": "Chicago", + "zip": "60601", + "stateCode": "IL", + "type": "HOME", + "updatedAt": null, + "createdAt": null, + "createdBy": null, + "updatedBy": null + } + ], + "homeCountryCode": "USA", + "competitionCountryCode": "USA", + "photoURL": null, + "tracks": [ + "DEVELOP" + ], + "updatedAt": "2015-12-02T14:00Z", + "createdAt": "2014-04-10T10:55Z" + } + }, + "version": "v3" + }, + { + "id": "test_manager1", + "result": { + "success": true, + "status": 200, + "metadata": null, + "content": { + "maxRating": { + "rating": 1114, + "track": "DATA_SCIENCE", + "subTrack": "SRM" + }, + "createdBy": "40011578", + "updatedBy": "40011578", + "userId": 40051333, + "firstName": "Firstname", + "lastName": "Lastname", + "quote": "It is a mistake to think you can solve any major problems just with potatoes.", + "description": null, + "otherLangName": null, + "handle": "test_manager1", + "handleLower": "test_manager1", + "status": "ACTIVE", + "email": "test_manager1@email.com", + "addresses": [ + { + "streetAddr1": "100 Main Street", + "streetAddr2": "", + "city": "Chicago", + "zip": "60601", + "stateCode": "IL", + "type": "HOME", + "updatedAt": null, + "createdAt": null, + "createdBy": null, + "updatedBy": null + } + ], + "homeCountryCode": "USA", + "competitionCountryCode": "USA", + "photoURL": null, + "tracks": [ + "DEVELOP" + ], + "updatedAt": "2015-12-02T14:00Z", + "createdAt": "2014-04-10T10:55Z" + } + }, + "version": "v3" } ] } diff --git a/migrations/elasticsearch_sync.js b/migrations/elasticsearch_sync.js index 1c9e5713..321f86cb 100644 --- a/migrations/elasticsearch_sync.js +++ b/migrations/elasticsearch_sync.js @@ -293,6 +293,10 @@ function getRequestBody(indexName) { }, }, }, + phases: { + type: 'nested', + dynamic: true, + }, }, }; switch (indexName) { diff --git a/migrations/seedElasticsearchIndex.js b/migrations/seedElasticsearchIndex.js index 7752efeb..4a10ec48 100644 --- a/migrations/seedElasticsearchIndex.js +++ b/migrations/seedElasticsearchIndex.js @@ -33,15 +33,21 @@ Promise.coroutine(function* wrapped() { config.get('pubsubQueueName'), ); - const projectIds = getProjectIds(); const projectWhereClause = (projectIds.length > 0) ? { id: { $in: projectIds } } : { deletedAt: { $eq: null } }; - const projects = yield models.Project.findAll({ + let projects = yield models.Project.findAll({ where: projectWhereClause, - raw: true, + include: [{ + model: models.ProjectPhase, + as: 'phases', + include: [{ model: models.PhaseProduct, as: 'products' }], + }], }); logger.info(`Retrieved #${projects.length} projects`); + // Convert to raw json + projects = _.map(projects, project => project.toJSON()); + const memberWhereClause = (projectIds.length > 0) ? { projectId: { $in: projectIds } } : { deletedAt: { $eq: null } }; @@ -59,14 +65,14 @@ Promise.coroutine(function* wrapped() { promises.push(rabbit.publish('project.initial', p, {})); }); Promise.all(promises) - .then(() => { - logger.info(`Published ${promises.length} msgs`); - process.exit(); - }) - .catch((err) => { - logger.error(err); - process.exit(); - }); + .then(() => { + logger.info(`Published ${promises.length} msgs`); + process.exit(); + }) + .catch((err) => { + logger.error(err); + process.exit(); + }); } catch (err) { logger.error(err); process.exit(); diff --git a/postman.json b/postman.json index 807c4f87..4447684b 100644 --- a/postman.json +++ b/postman.json @@ -1,13 +1,13 @@ { "info": { + "_postman_id": "0d2b00c1-bd90-40ab-ba13-e730e4ddfcf4", "name": "tc-project-service ", - "_postman_id": "8f323d9c-63bd-5f2c-87f1-1e99083786f3", - "description": "", - "schema": "https://schema.getpostman.com/json/collection/v2.0.0/collection.json" + "schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json" }, "item": [ { "name": "Project Attachments", + "description": null, "item": [ { "name": "Upload attachment", @@ -27,7 +27,18 @@ "mode": "raw", "raw": "{\n\t\"param\": {\n\t\t\"title\": \"first attachment submission\",\n\t\t\"filePath\": \"asdjshdasdas/asdsadj/asdasd.png\",\n\t\t\"s3Bucket\": \"topcoder-project-service\",\n\t\t\"contentType\": \"application/png\"\n\t}\n}" }, - "url": "{{api-url}}/v4/projects/7/attachments", + "url": { + "raw": "{{api-url}}/v4/projects/7/attachments", + "host": [ + "{{api-url}}" + ], + "path": [ + "v4", + "projects", + "7", + "attachments" + ] + }, "description": "Create an project attachment" }, "response": [] @@ -50,7 +61,19 @@ "mode": "raw", "raw": "{\n\t\"param\": {\n\t\t\"title\": \"first attachment submission updated\",\n\t\t\"description\": \"updated project attachment\"\n\t}\n}" }, - "url": "{{api-url}}/v4/projects/7/attachments/2", + "url": { + "raw": "{{api-url}}/v4/projects/7/attachments/2", + "host": [ + "{{api-url}}" + ], + "path": [ + "v4", + "projects", + "7", + "attachments", + "2" + ] + }, "description": "Update project attachment" }, "response": [] @@ -73,7 +96,19 @@ "mode": "raw", "raw": "" }, - "url": "{{api-url}}/v4/projects/7/attachments/2", + "url": { + "raw": "{{api-url}}/v4/projects/7/attachments/2", + "host": [ + "{{api-url}}" + ], + "path": [ + "v4", + "projects", + "7", + "attachments", + "2" + ] + }, "description": "Delete a project attachment" }, "response": [] @@ -82,6 +117,7 @@ }, { "name": "Project Members", + "description": null, "item": [ { "name": "Create project member with no payload", @@ -101,7 +137,18 @@ "mode": "raw", "raw": "" }, - "url": "{{api-url}}/v4/projects/1/members", + "url": { + "raw": "{{api-url}}/v4/projects/1/members", + "host": [ + "{{api-url}}" + ], + "path": [ + "v4", + "projects", + "1", + "members" + ] + }, "description": "Request payload is mandatory while creating project. If no request payload is specified this should result in 422 status code." }, "response": [] @@ -124,7 +171,18 @@ "mode": "raw", "raw": "{\n\t\"role\": \"copilot\"\n}" }, - "url": "{{api-url}}/v4/projects/1/members", + "url": { + "raw": "{{api-url}}/v4/projects/1/members", + "host": [ + "{{api-url}}" + ], + "path": [ + "v4", + "projects", + "1", + "members" + ] + }, "description": "Certain fields are mandatory while creating project. If invalid fields are specified this should result in 422 status code." }, "response": [] @@ -147,7 +205,18 @@ "mode": "raw", "raw": "{\n\t\"param\": {\n\t\t\"role\": \"copilot\",\n\t\t\"userId\": 40051331,\n\t\t\"isPrimary\": true\n\t}\n}" }, - "url": "{{api-url}}/v4/projects/7/members", + "url": { + "raw": "{{api-url}}/v4/projects/7/members", + "host": [ + "{{api-url}}" + ], + "path": [ + "v4", + "projects", + "7", + "members" + ] + }, "description": "If the request payload is valid, than project member should be created." }, "response": [] @@ -170,7 +239,18 @@ "mode": "raw", "raw": "{\n\t\"param\": {\n\t\t\"role\": \"copilot\",\n\t\t\"userId\": 40051331,\n\t\t\"isPrimary\": true\n\t}\n}" }, - "url": "{{api-url}}/v4/projects/1/members", + "url": { + "raw": "{{api-url}}/v4/projects/1/members", + "host": [ + "{{api-url}}" + ], + "path": [ + "v4", + "projects", + "1", + "members" + ] + }, "description": "If the request payload is valid and user is already registered with the specified role than this should result in 400." }, "response": [] @@ -193,7 +273,18 @@ "mode": "raw", "raw": "{\n\t\"param\": {\n\t\t\"role\": \"manager\",\n\t\t\"userId\": 40051330,\n\t\t\"isPrimary\": true\n\t}\n}" }, - "url": "{{api-url}}/v4/projects/7/members", + "url": { + "raw": "{{api-url}}/v4/projects/7/members", + "host": [ + "{{api-url}}" + ], + "path": [ + "v4", + "projects", + "7", + "members" + ] + }, "description": "If the request payload is valid, than project manager should be added. This should sync with the direct project is project is associated with direct project." }, "response": [] @@ -216,7 +307,18 @@ "mode": "raw", "raw": "{\n\t\"param\": {\n\t\t\"role\": \"customer\",\n\t\t\"userId\": 40051332,\n\t\t\"isPrimary\": true\n\t}\n}" }, - "url": "{{api-url}}/v4/projects/7/members", + "url": { + "raw": "{{api-url}}/v4/projects/7/members", + "host": [ + "{{api-url}}" + ], + "path": [ + "v4", + "projects", + "7", + "members" + ] + }, "description": "If the request payload is valid, than project customer should be added. This should sync with the direct project is project is associated with direct project." }, "response": [] @@ -239,7 +341,19 @@ "mode": "raw", "raw": "{\n\t\"param\": {\n\t\t\"role\": \"copilot\",\n\t\t\"isPrimary\": true\n\t}\n}" }, - "url": "{{api-url}}/v4/projects/7/members/16", + "url": { + "raw": "{{api-url}}/v4/projects/7/members/16", + "host": [ + "{{api-url}}" + ], + "path": [ + "v4", + "projects", + "7", + "members", + "16" + ] + }, "description": "Update a project's member." }, "response": [] @@ -262,7 +376,19 @@ "mode": "raw", "raw": "{\n\t\"param\": {\n\t\t\"role\": \"copilot\",\n\t\t\"isPrimary\": false\n\t}\n}" }, - "url": "{{api-url}}/v4/projects/7/members/16", + "url": { + "raw": "{{api-url}}/v4/projects/7/members/16", + "host": [ + "{{api-url}}" + ], + "path": [ + "v4", + "projects", + "7", + "members", + "16" + ] + }, "description": "Update a project's member." }, "response": [] @@ -285,7 +411,19 @@ "mode": "raw", "raw": "" }, - "url": "{{api-url}}/v4/projects/7/members/15", + "url": { + "raw": "{{api-url}}/v4/projects/7/members/15", + "host": [ + "{{api-url}}" + ], + "path": [ + "v4", + "projects", + "7", + "members", + "15" + ] + }, "description": "Delete a project's member" }, "response": [] @@ -314,7 +452,16 @@ "mode": "raw", "raw": "{\n\t\n}" }, - "url": "{{api-url}}/v4/projects", + "url": { + "raw": "{{api-url}}/v4/projects", + "host": [ + "{{api-url}}" + ], + "path": [ + "v4", + "projects" + ] + }, "description": "Request body is mandatory while creating project. If invalid request body is supplied this should return 422 status code." }, "response": [] @@ -337,7 +484,16 @@ "mode": "raw", "raw": "{\n\t\"param\": {\n\t}\n}" }, - "url": "{{api-url}}/v4/projects", + "url": { + "raw": "{{api-url}}/v4/projects", + "host": [ + "{{api-url}}" + ], + "path": [ + "v4", + "projects" + ] + }, "description": "Certain fields are mandatory while creating project. If invalid request body is supplied this should return 422 status code." }, "response": [] @@ -360,7 +516,16 @@ "mode": "raw", "raw": "{\n\t\"param\": {\n\t\t\"name\": \"test project\",\n\t\t\"description\": \"Hello I am a test project\",\n\t\t\"type\": \"generic\"\n\t}\n}" }, - "url": "{{api-url}}/v4/projects", + "url": { + "raw": "{{api-url}}/v4/projects", + "host": [ + "{{api-url}}" + ], + "path": [ + "v4", + "projects" + ] + }, "description": "Valid request body. Project should be created successfully." }, "response": [] @@ -379,7 +544,17 @@ "mode": "raw", "raw": "" }, - "url": "{{api-url}}/v4/projects/7", + "url": { + "raw": "{{api-url}}/v4/projects/7", + "host": [ + "{{api-url}}" + ], + "path": [ + "v4", + "projects", + "7" + ] + }, "description": "Get a project by id. project members and attachments should also be returned." }, "response": [] @@ -433,7 +608,16 @@ "mode": "raw", "raw": "" }, - "url": "{{api-url}}/v4/projects", + "url": { + "raw": "{{api-url}}/v4/projects", + "host": [ + "{{api-url}}" + ], + "path": [ + "v4", + "projects" + ] + }, "description": "List all the project with no filter. Default sort and limits are applied." }, "response": [] @@ -592,7 +776,17 @@ "mode": "raw", "raw": "" }, - "url": "{{api-url}}/v4/projects/3", + "url": { + "raw": "{{api-url}}/v4/projects/3", + "host": [ + "{{api-url}}" + ], + "path": [ + "v4", + "projects", + "3" + ] + }, "description": "Delete a project by id" }, "response": [] @@ -615,7 +809,17 @@ "mode": "raw", "raw": "{\n \"param\": {\n \"name\": \"project name updated\"\n }\n}" }, - "url": "{{api-url}}/v4/projects/13", + "url": { + "raw": "{{api-url}}/v4/projects/13", + "host": [ + "{{api-url}}" + ], + "path": [ + "v4", + "projects", + "13" + ] + }, "description": "Update the project name. Name should be updated successfully." }, "response": [] @@ -638,7 +842,17 @@ "mode": "raw", "raw": "{\n\t\"param\": {\n\t\t\"name\": \"project name updated\"\n\t}\n}" }, - "url": "{{api-url}}/v4/projects/2", + "url": { + "raw": "{{api-url}}/v4/projects/2", + "host": [ + "{{api-url}}" + ], + "path": [ + "v4", + "projects", + "2" + ] + }, "description": "Update the project name. If user don't have permission to the project than it should return 403." }, "response": [] @@ -661,7 +875,17 @@ "mode": "raw", "raw": "{\n\t\"param\": {\n\t\t\"name\": \"project name updated\"\n\t}\n}" }, - "url": "{{api-url}}/v4/projects/10", + "url": { + "raw": "{{api-url}}/v4/projects/10", + "host": [ + "{{api-url}}" + ], + "path": [ + "v4", + "projects", + "10" + ] + }, "description": "Update the project name. If project is not found than this result in 404 status code." }, "response": [] @@ -684,7 +908,17 @@ "mode": "raw", "raw": "{\n \"param\": {\n \"status\": \"in_review\"\n }\n}" }, - "url": "{{api-url}}/v4/projects/7", + "url": { + "raw": "{{api-url}}/v4/projects/7", + "host": [ + "{{api-url}}" + ], + "path": [ + "v4", + "projects", + "7" + ] + }, "description": "Update the project status." }, "response": [] @@ -707,7 +941,17 @@ "mode": "raw", "raw": "{\n \"param\": {\n \"status\": \"reviewed\"\n }\n}" }, - "url": "{{api-url}}/v4/projects/7", + "url": { + "raw": "{{api-url}}/v4/projects/7", + "host": [ + "{{api-url}}" + ], + "path": [ + "v4", + "projects", + "7" + ] + }, "description": "Update the project status." }, "response": [] @@ -730,7 +974,17 @@ "mode": "raw", "raw": "{\n \"param\": {\n \"status\": \"paused\"\n }\n}" }, - "url": "{{api-url}}/v4/projects/7", + "url": { + "raw": "{{api-url}}/v4/projects/7", + "host": [ + "{{api-url}}" + ], + "path": [ + "v4", + "projects", + "7" + ] + }, "description": "Update the project status." }, "response": [] @@ -753,7 +1007,17 @@ "mode": "raw", "raw": "{\n \"param\": {\n \"status\": \"cancelled\",\n \"cancelReason\": \"price/cost\"\n }\n}" }, - "url": "{{api-url}}/v4/projects/7", + "url": { + "raw": "{{api-url}}/v4/projects/7", + "host": [ + "{{api-url}}" + ], + "path": [ + "v4", + "projects", + "7" + ] + }, "description": "Update the project status. While cancelling the project `cancelReason` is mandatory." }, "response": [] @@ -776,7 +1040,17 @@ "mode": "raw", "raw": "{\n\t\"param\": {\n\t\t\"status\": \"cancelled\"\n\t}\n}" }, - "url": "{{api-url}}/v4/projects/1", + "url": { + "raw": "{{api-url}}/v4/projects/1", + "host": [ + "{{api-url}}" + ], + "path": [ + "v4", + "projects", + "1" + ] + }, "description": "Update the project status. While cancelling the project `cancelReason` is mandatory. If no `cancelReason` is supplied this should result in 422 status code." }, "response": [] @@ -799,7 +1073,17 @@ "mode": "raw", "raw": "{\n \"param\": {\n \"status\": \"completed\"\n }\n}" }, - "url": "{{api-url}}/v4/projects/7", + "url": { + "raw": "{{api-url}}/v4/projects/7", + "host": [ + "{{api-url}}" + ], + "path": [ + "v4", + "projects", + "7" + ] + }, "description": "Update the project status." }, "response": [] @@ -822,7 +1106,17 @@ "mode": "raw", "raw": "{\n\t\"param\": {\n\t\t\"status\": \"active\"\n\t}\n}" }, - "url": "{{api-url}}/v4/projects/1", + "url": { + "raw": "{{api-url}}/v4/projects/1", + "host": [ + "{{api-url}}" + ], + "path": [ + "v4", + "projects", + "1" + ] + }, "description": "Move a project out of cancel state. Only admin and manager is allowed to do so." }, "response": [] @@ -845,9 +1139,19 @@ "mode": "raw", "raw": "{\n\t\"param\": {\n\t\t\"status\": \"active\"\n\t}\n}" }, - "url": "{{api-url}}/v4/projects/1", - "description": "Move a project out of cancel state. Only admin and manager is allowed to do so." - }, + "url": { + "raw": "{{api-url}}/v4/projects/1", + "host": [ + "{{api-url}}" + ], + "path": [ + "v4", + "projects", + "1" + ] + }, + "description": "Move a project out of cancel state. Only admin and manager is allowed to do so." + }, "response": [] }, { @@ -868,7 +1172,17 @@ "mode": "raw", "raw": "{\n \"param\": {\n \"details\": {\n \"summary\": \"project name updated\"\n }\n }\n}" }, - "url": "{{api-url}}/v4/projects/8", + "url": { + "raw": "{{api-url}}/v4/projects/8", + "host": [ + "{{api-url}}" + ], + "path": [ + "v4", + "projects", + "8" + ] + }, "description": "Update the project details. This should fire specification modified event" }, "response": [] @@ -891,7 +1205,17 @@ "mode": "raw", "raw": "{\n \"param\": {\n \"bookmarks\": [\n {\n \"title\": \"test\",\n \"address\": \"http://topcoder.com\"\n }\n \n ]\n }\n}" }, - "url": "{{api-url}}/v4/projects/8", + "url": { + "raw": "{{api-url}}/v4/projects/8", + "host": [ + "{{api-url}}" + ], + "path": [ + "v4", + "projects", + "8" + ] + }, "description": "Update the project bookmarks. This should fire project link created event" }, "response": [] @@ -900,6 +1224,7 @@ }, { "name": "bookmarks", + "description": null, "item": [ { "name": " Create project without bookmarks", @@ -919,7 +1244,16 @@ "mode": "raw", "raw": "{\n \"param\": {\n \"type\": \"generic\",\n \"description\": \"test project\",\n \"details\": {},\n \"billingAccountId\": 123,\n \"name\": \"test project1\"\n }\n}" }, - "url": "{{api-url}}/v4/projects" + "url": { + "raw": "{{api-url}}/v4/projects", + "host": [ + "{{api-url}}" + ], + "path": [ + "v4", + "projects" + ] + } }, "response": [] }, @@ -941,7 +1275,16 @@ "mode": "raw", "raw": "{\n \"param\": {\n \"type\": \"generic\",\n \"description\": \"test project\",\n \"details\": {},\n \"bookmarks\":[{\n \"title\":\"title1\",\n \"address\":\"address1\"\n },{\n \"title\":\"title2\",\n \"address\":\"address2\"\n }],\n \"billingAccountId\": 123,\n \"name\": \"test project1\"\n }\n}" }, - "url": "{{api-url}}/v4/projects" + "url": { + "raw": "{{api-url}}/v4/projects", + "host": [ + "{{api-url}}" + ], + "path": [ + "v4", + "projects" + ] + } }, "response": [] }, @@ -963,7 +1306,16 @@ "mode": "raw", "raw": "{\n \"param\": {\n \"type\": \"generic\",\n \"description\": \"test project\",\n \"details\": {},\n \"bookmarks\":[{\n \"title\":\"title1\",\n \"invalid\":3,\n \"address\":\"address1\"\n },{\n \"title\":\"title2\",\n \"address\":\"address2\"\n }],\n \"billingAccountId\": 123,\n \"name\": \"test project1\"\n }\n}" }, - "url": "{{api-url}}/v4/projects" + "url": { + "raw": "{{api-url}}/v4/projects", + "host": [ + "{{api-url}}" + ], + "path": [ + "v4", + "projects" + ] + } }, "response": [] }, @@ -985,7 +1337,17 @@ "mode": "raw", "raw": "{\n \"param\": {\n \"billingAccountId\": 9999, \n \"name\": \"new project name\"\n }\n}" }, - "url": "{{api-url}}/v4/projects/2" + "url": { + "raw": "{{api-url}}/v4/projects/2", + "host": [ + "{{api-url}}" + ], + "path": [ + "v4", + "projects", + "2" + ] + } }, "response": [] }, @@ -1007,7 +1369,17 @@ "mode": "raw", "raw": "{\n \"param\": {\n \"billingAccountId\": 9999, \n \"name\": \"new project name\",\n \"bookmarks\":[{\n \"title\":\"title1\",\n \"address\":\"address1\"\n },{\n \"title\":\"title2\",\n \"address\":\"address2\"\n }]\n }\n}" }, - "url": "{{api-url}}/v4/projects/2" + "url": { + "raw": "{{api-url}}/v4/projects/2", + "host": [ + "{{api-url}}" + ], + "path": [ + "v4", + "projects", + "2" + ] + } }, "response": [] }, @@ -1029,7 +1401,17 @@ "mode": "raw", "raw": "{\n \"param\": {\n \"billingAccountId\": 9999, \n \"name\": \"new project name2\",\n \"bookmarks\":null\n }\n}" }, - "url": "{{api-url}}/v4/projects/2" + "url": { + "raw": "{{api-url}}/v4/projects/2", + "host": [ + "{{api-url}}" + ], + "path": [ + "v4", + "projects", + "2" + ] + } }, "response": [] }, @@ -1051,7 +1433,17 @@ "mode": "raw", "raw": "{\n \"param\": {\n \"billingAccountId\": 9999, \n \"name\": \"new project name2\",\n \"bookmarks\":3\n }\n}" }, - "url": "{{api-url}}/v4/projects/2" + "url": { + "raw": "{{api-url}}/v4/projects/2", + "host": [ + "{{api-url}}" + ], + "path": [ + "v4", + "projects", + "2" + ] + } }, "response": [] }, @@ -1069,7 +1461,16 @@ "mode": "raw", "raw": "" }, - "url": "{{api-url}}/v4/projects" + "url": { + "raw": "{{api-url}}/v4/projects", + "host": [ + "{{api-url}}" + ], + "path": [ + "v4", + "projects" + ] + } }, "response": [] } @@ -1077,6 +1478,7 @@ }, { "name": "issue1", + "description": null, "item": [ { "name": "get projects with copilot token", @@ -1092,7 +1494,16 @@ "mode": "raw", "raw": "" }, - "url": "{{api-url}}/v4/projects" + "url": { + "raw": "{{api-url}}/v4/projects", + "host": [ + "{{api-url}}" + ], + "path": [ + "v4", + "projects" + ] + } }, "response": [] } @@ -1100,6 +1511,7 @@ }, { "name": "issue10", + "description": null, "item": [ { "name": "wrong role", @@ -1119,7 +1531,19 @@ "mode": "raw", "raw": " {\n \"param\": {\n \"role\": \"wrong\"\n }\n } " }, - "url": "{{api-url}}/v4/projects/3/members/5" + "url": { + "raw": "{{api-url}}/v4/projects/3/members/5", + "host": [ + "{{api-url}}" + ], + "path": [ + "v4", + "projects", + "3", + "members", + "5" + ] + } }, "response": [] }, @@ -1141,7 +1565,19 @@ "mode": "raw", "raw": " {\n \"param\": {\n \"role\": \"manager\",\n \"isPrimary\": true\n }\n } " }, - "url": "{{api-url}}/v4/projects/1/members/1" + "url": { + "raw": "{{api-url}}/v4/projects/1/members/1", + "host": [ + "{{api-url}}" + ], + "path": [ + "v4", + "projects", + "1", + "members", + "1" + ] + } }, "response": [] } @@ -1149,6 +1585,7 @@ }, { "name": "issue5", + "description": null, "item": [ { "name": "launch a project by topcoder managers ", @@ -1168,7 +1605,17 @@ "mode": "raw", "raw": "{\n \n \"param\":{\n \"name\": \"updatedProject name\",\n \"status\": \"active\"\n }\n}" }, - "url": "{{api-url}}/v4/projects/1" + "url": { + "raw": "{{api-url}}/v4/projects/1", + "host": [ + "{{api-url}}" + ], + "path": [ + "v4", + "projects", + "1" + ] + } }, "response": [] }, @@ -1190,7 +1637,17 @@ "mode": "raw", "raw": "{\n \n \"param\":{\n \"name\": \"updatedProject name\",\n \"status\": \"active\"\n }\n}" }, - "url": "{{api-url}}/v4/projects/1" + "url": { + "raw": "{{api-url}}/v4/projects/1", + "host": [ + "{{api-url}}" + ], + "path": [ + "v4", + "projects", + "1" + ] + } }, "response": [] }, @@ -1212,7 +1669,17 @@ "mode": "raw", "raw": "{\n \n \"param\":{\n \"name\": \"updatedProject name\",\n \"status\": \"active\"\n }\n}" }, - "url": "{{api-url}}/v4/projects/1" + "url": { + "raw": "{{api-url}}/v4/projects/1", + "host": [ + "{{api-url}}" + ], + "path": [ + "v4", + "projects", + "1" + ] + } }, "response": [] } @@ -1220,6 +1687,7 @@ }, { "name": "issue8", + "description": null, "item": [ { "name": "mock direct projects", @@ -1239,7 +1707,19 @@ "mode": "raw", "raw": " {\n \"param\": {\n \"role\": \"copilot\",\n \"isPrimary\": true\n }\n } " }, - "url": "https://localhost:8443/v3/direct/projects" + "url": { + "raw": "https://localhost:8443/v3/direct/projects", + "protocol": "https", + "host": [ + "localhost" + ], + "port": "8443", + "path": [ + "v3", + "direct", + "projects" + ] + } }, "response": [] }, @@ -1261,7 +1741,16 @@ "mode": "raw", "raw": "{\n \"param\": {\n \"type\": \"generic\",\n \"description\": \"test project\",\n \"details\": {},\n \"billingAccountId\": 123,\n \"name\": \"test project1\"\n }\n}" }, - "url": "{{api-url}}/v4/projects" + "url": { + "raw": "{{api-url}}/v4/projects", + "host": [ + "{{api-url}}" + ], + "path": [ + "v4", + "projects" + ] + } }, "response": [] }, @@ -1283,7 +1772,18 @@ "mode": "raw", "raw": "{\n \"param\": {\n \"userId\": 2, \n \"role\": \"copilot\"\n }\n}" }, - "url": "{{api-url}}/v4/projects/1/members" + "url": { + "raw": "{{api-url}}/v4/projects/1/members", + "host": [ + "{{api-url}}" + ], + "path": [ + "v4", + "projects", + "1", + "members" + ] + } }, "response": [] }, @@ -1305,7 +1805,18 @@ "mode": "raw", "raw": "{\n \"param\": {\n \"userId\": 2, \n \"role\": \"copilot\"\n }\n}" }, - "url": "{{api-url}}/v4/projects/2/members" + "url": { + "raw": "{{api-url}}/v4/projects/2/members", + "host": [ + "{{api-url}}" + ], + "path": [ + "v4", + "projects", + "2", + "members" + ] + } }, "response": [] }, @@ -1327,7 +1838,19 @@ "mode": "raw", "raw": " {\n \"param\": {\n \"role\": \"customer\",\n \"isPrimary\": true\n }\n } " }, - "url": "{{api-url}}/v4/projects/2/members/4" + "url": { + "raw": "{{api-url}}/v4/projects/2/members/4", + "host": [ + "{{api-url}}" + ], + "path": [ + "v4", + "projects", + "2", + "members", + "4" + ] + } }, "response": [] }, @@ -1349,7 +1872,17 @@ "mode": "raw", "raw": "{\n \"param\": {\n \"billingAccountId\": 9999, \n \"name\": \"new project name\"\n }\n}" }, - "url": "{{api-url}}/v4/projects/2" + "url": { + "raw": "{{api-url}}/v4/projects/2", + "host": [ + "{{api-url}}" + ], + "path": [ + "v4", + "projects", + "2" + ] + } }, "response": [] }, @@ -1371,7 +1904,675 @@ "mode": "raw", "raw": "" }, - "url": "{{api-url}}/v4/projects/2/members/4" + "url": { + "raw": "{{api-url}}/v4/projects/2/members/4", + "host": [ + "{{api-url}}" + ], + "path": [ + "v4", + "projects", + "2", + "members", + "4" + ] + } + }, + "response": [] + } + ] + }, + { + "name": "Project Phase", + "description": null, + "item": [ + { + "name": "Create Phase", + "request": { + "method": "POST", + "header": [ + { + "key": "Authorization", + "value": "Bearer {{jwt-token}}" + }, + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n\t\"param\": {\n\t\t\"name\": \"test project phase\",\n\t\t\"status\": \"active\",\n\t\t\"startDate\": \"2018-05-15T00:00:00\",\n\t\t\"endDate\": \"2018-05-16T00:00:00\",\n\t\t\"budget\": 20,\n\t\t\"details\": {\n\t\t\t\"aDetails\": \"a details\"\n\t\t}\n\t}\n}" + }, + "url": { + "raw": "{{api-url}}/v4/projects/1/phases", + "host": [ + "{{api-url}}" + ], + "path": [ + "v4", + "projects", + "1", + "phases" + ] + } + }, + "response": [] + }, + { + "name": "List Phase", + "request": { + "method": "GET", + "header": [ + { + "key": "Authorization", + "value": "Bearer {{jwt-token}}" + }, + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n\t\"param\": {\n\t\t\"name\": \"test project phase\",\n\t\t\"status\": \"active\",\n\t\t\"startDate\": \"2018-05-15T00:00:00\",\n\t\t\"endDate\": \"2018-05-16T00:00:00\",\n\t\t\"budget\": 20\n\t}\n}" + }, + "url": { + "raw": "{{api-url}}/v4/projects/1/phases", + "host": [ + "{{api-url}}" + ], + "path": [ + "v4", + "projects", + "1", + "phases" + ] + } + }, + "response": [] + }, + { + "name": "List Phase with fields", + "request": { + "method": "GET", + "header": [ + { + "key": "Authorization", + "value": "Bearer {{jwt-token}}" + }, + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n\t\"param\": {\n\t\t\"name\": \"test project phase\",\n\t\t\"status\": \"active\",\n\t\t\"startDate\": \"2018-05-15T00:00:00\",\n\t\t\"endDate\": \"2018-05-16T00:00:00\",\n\t\t\"budget\": 20\n\t}\n}" + }, + "url": { + "raw": "{{api-url}}/v4/projects/1/phases?fields=status,name,budget", + "host": [ + "{{api-url}}" + ], + "path": [ + "v4", + "projects", + "1", + "phases" + ], + "query": [ + { + "key": "fields", + "value": "status,name,budget" + } + ] + } + }, + "response": [] + }, + { + "name": "List Phase with sort", + "request": { + "method": "GET", + "header": [ + { + "key": "Authorization", + "value": "Bearer {{jwt-token}}" + }, + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n\t\"param\": {\n\t\t\"name\": \"test project phase\",\n\t\t\"status\": \"active\",\n\t\t\"startDate\": \"2018-05-15T00:00:00\",\n\t\t\"endDate\": \"2018-05-16T00:00:00\",\n\t\t\"budget\": 20\n\t}\n}" + }, + "url": { + "raw": "{{api-url}}/v4/projects/1/phases?sort=status desc", + "host": [ + "{{api-url}}" + ], + "path": [ + "v4", + "projects", + "1", + "phases" + ], + "query": [ + { + "key": "sort", + "value": "status desc" + } + ] + } + }, + "response": [] + }, + { + "name": "Get Phase", + "request": { + "method": "GET", + "header": [ + { + "key": "Authorization", + "value": "Bearer {{jwt-token}}" + }, + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n\t\"param\": {\n\t\t\"name\": \"test project phase\",\n\t\t\"status\": \"active\",\n\t\t\"startDate\": \"2018-05-15T00:00:00\",\n\t\t\"endDate\": \"2018-05-16T00:00:00\",\n\t\t\"budget\": 20\n\t}\n}" + }, + "url": { + "raw": "{{api-url}}/v4/projects/1/phases/1", + "host": [ + "{{api-url}}" + ], + "path": [ + "v4", + "projects", + "1", + "phases", + "1" + ] + } + }, + "response": [] + }, + { + "name": "Update Phase", + "request": { + "method": "PATCH", + "header": [ + { + "key": "Authorization", + "value": "Bearer {{jwt-token}}" + }, + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n\t\"param\": {\n\t\t\"name\": \"test project phase xxx\",\n\t\t\"status\": \"inactive\",\n\t\t\"startDate\": \"2018-05-14T00:00:00\",\n\t\t\"endDate\": \"2018-05-15T00:00:00\",\n\t\t\"budget\": 30,\n\t\t\"progress\": 15,\n\t\t\"details\": {\n\t\t\t\"message\": \"phase details\"\n\t\t}\n\t}\n}" + }, + "url": { + "raw": "{{api-url}}/v4/projects/1/phases/1", + "host": [ + "{{api-url}}" + ], + "path": [ + "v4", + "projects", + "1", + "phases", + "1" + ] + } + }, + "response": [] + }, + { + "name": "Delete Phase", + "request": { + "method": "DELETE", + "header": [ + { + "key": "Authorization", + "value": "Bearer {{jwt-token}}" + }, + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "" + }, + "url": { + "raw": "{{api-url}}/v4/projects/1/phases/3", + "host": [ + "{{api-url}}" + ], + "path": [ + "v4", + "projects", + "1", + "phases", + "3" + ] + } + }, + "response": [] + } + ] + }, + { + "name": "Phase Products", + "description": null, + "item": [ + { + "name": "Create Phase Product", + "request": { + "method": "POST", + "header": [ + { + "key": "Authorization", + "value": "Bearer {{jwt-token}}" + }, + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n\t\"param\": {\n\t\t\"name\": \"test phase product\",\n\t\t\"type\": \"type 1\",\n\t\t\"estimatedPrice\": 10\n\t}\n}" + }, + "url": { + "raw": "{{api-url}}/v4/projects/1/phases/1/products", + "host": [ + "{{api-url}}" + ], + "path": [ + "v4", + "projects", + "1", + "phases", + "1", + "products" + ] + } + }, + "response": [] + }, + { + "name": "List Phase Products", + "request": { + "method": "GET", + "header": [ + { + "key": "Authorization", + "value": "Bearer {{jwt-token}}" + } + ], + "body": { + "mode": "raw", + "raw": "" + }, + "url": { + "raw": "{{api-url}}/v4/projects/1/phases/1/products", + "host": [ + "{{api-url}}" + ], + "path": [ + "v4", + "projects", + "1", + "phases", + "1", + "products" + ] + } + }, + "response": [] + }, + { + "name": "Get Phase Product", + "request": { + "method": "GET", + "header": [ + { + "key": "Authorization", + "value": "Bearer {{jwt-token}}" + } + ], + "body": { + "mode": "raw", + "raw": "" + }, + "url": { + "raw": "{{api-url}}/v4/projects/1/phases/1/products/1", + "host": [ + "{{api-url}}" + ], + "path": [ + "v4", + "projects", + "1", + "phases", + "1", + "products", + "1" + ] + } + }, + "response": [] + }, + { + "name": "Update Phase Product", + "request": { + "method": "PATCH", + "header": [ + { + "key": "Authorization", + "value": "Bearer {{jwt-token}}" + }, + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n\t\"param\": {\n\t\t\"name\": \"test phase product xxx\",\n\t\t\"type\": \"type 2\",\n\t\t\"templateId\": 10,\n\t\t\"estimatedPrice\": 1.234567,\n\t\t\"actualPrice\": 2.34567,\n\t\t\"details\": {\n\t\t\t\"message\": \"this is a JSON type. You can use any json\"\n\t\t}\n\t}\n}" + }, + "url": { + "raw": "{{api-url}}/v4/projects/1/phases/1/products/1", + "host": [ + "{{api-url}}" + ], + "path": [ + "v4", + "projects", + "1", + "phases", + "1", + "products", + "1" + ] + } + }, + "response": [] + }, + { + "name": "Delete Phase Product", + "request": { + "method": "DELETE", + "header": [ + { + "key": "Authorization", + "value": "Bearer {{jwt-token}}" + } + ], + "body": { + "mode": "raw", + "raw": "" + }, + "url": { + "raw": "{{api-url}}/v4/projects/1/phases/1/products/1", + "host": [ + "{{api-url}}" + ], + "path": [ + "v4", + "projects", + "1", + "phases", + "1", + "products", + "1" + ] + } + }, + "response": [] + } + ] + }, + { + "name": "Project Templates", + "description": "", + "item": [ + { + "name": "Create project template", + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + }, + { + "key": "Authorization", + "value": "Bearer {{jwt-token}}" + } + ], + "body": { + "mode": "raw", + "raw": "{\r\n \"param\":{\r\n \"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": "{{api-url}}/v4/projectTemplates" + }, + "response": [] + }, + { + "name": "List project templates", + "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": "{{api-url}}/v4/projectTemplates" + }, + "response": [] + }, + { + "name": "Get project 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": "{{api-url}}/v4/projectTemplates/1" + }, + "response": [] + }, + { + "name": "Update project template", + "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\":\"new name\",\r\n \"key\":\"new key\",\r\n \"category\":\"new category\",\r\n \"scope\":{\r\n \"scope1\":\"scope 1\",\r\n \"scope2\": [\"a\"]\r\n },\r\n \"phases\":{\r\n \"phase1\":\"phase 1\",\r\n \"phase2\": {\r\n \t\"another\": \"another\"\r\n }\r\n }\r\n }\r\n}" + }, + "url": "{{api-url}}/v4/projectTemplates/1" + }, + "response": [] + }, + { + "name": "Delete project template", + "request": { + "method": "DELETE", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + }, + { + "key": "Authorization", + "value": "Bearer {{jwt-token}}" + } + ], + "body": { + "mode": "raw", + "raw": "{\r\n \"param\":{\r\n \"name\":\"new name\",\r\n \"key\":\"new key\",\r\n \"category\":\"new category\",\r\n \"scope\":{\r\n \"scope1\":\"scope 1\",\r\n \"scope2\": [\"a\"]\r\n },\r\n \"phases\":{\r\n \"phase1\":\"phase 1\",\r\n \"phase2\": {\r\n \t\"another\": \"another\"\r\n }\r\n }\r\n }\r\n}" + }, + "url": "{{api-url}}/v4/projectTemplates/1" + }, + "response": [] + } + ] + }, + { + "name": "Product Templates", + "description": "", + "item": [ + { + "name": "Create product template", + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + }, + { + "key": "Authorization", + "value": "Bearer {{jwt-token}}" + } + ], + "body": { + "mode": "raw", + "raw": "{\r\n \"param\":{\r\n \"name\":\"new name\",\r\n \"productKey\":\"new productKey\",\r\n \"icon\":\"http://example.com/icon-new.ico\",\r\n \"brief\": \"new brief\",\r\n \"details\": \"new details\",\r\n \"aliases\":{\r\n \"alias1\":\"alias 1\"\r\n },\r\n \"template\":{\r\n \"template1\":\"template 1\"\r\n }\r\n }\r\n}" + }, + "url": "{{api-url}}/v4/productTemplates" + }, + "response": [] + }, + { + "name": "List product templates", + "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": "{{api-url}}/v4/productTemplates" + }, + "response": [] + }, + { + "name": "Get product 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": "{{api-url}}/v4/productTemplates/1" + }, + "response": [] + }, + { + "name": "Update product template", + "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\":\"new name\",\r\n \"productKey\":\"new productKey\",\r\n \"icon\":\"http://example.com/icon-new.ico\",\r\n \"brief\": \"new brief\",\r\n \"details\": \"new details\",\r\n \"aliases\":{\r\n \"alias1\":\"scope 1\",\r\n \"alias2\": [\"a\"]\r\n },\r\n \"template\":{\r\n \"template1\":\"template 1\",\r\n \"template2\": {\r\n \t\"another\": \"another\"\r\n }\r\n }\r\n }\r\n}" + }, + "url": "{{api-url}}/v4/productTemplates/1" + }, + "response": [] + }, + { + "name": "Delete product template", + "request": { + "method": "DELETE", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + }, + { + "key": "Authorization", + "value": "Bearer {{jwt-token}}" + } + ], + "body": { + "mode": "raw", + "raw": "{\r\n \"param\":{\r\n \"name\":\"new name\",\r\n \"key\":\"new key\",\r\n \"category\":\"new category\",\r\n \"scope\":{\r\n \"scope1\":\"scope 1\",\r\n \"scope2\": [\"a\"]\r\n },\r\n \"phases\":{\r\n \"phase1\":\"phase 1\",\r\n \"phase2\": {\r\n \t\"another\": \"another\"\r\n }\r\n }\r\n }\r\n}" + }, + "url": "{{api-url}}/v4/productTemplates/1" }, "response": [] } diff --git a/postman_environment.json b/postman_environment.json new file mode 100644 index 00000000..12fab912 --- /dev/null +++ b/postman_environment.json @@ -0,0 +1,23 @@ +{ + "id": "e6b30b4b-1388-4622-8314-bc49ba1d752b", + "name": "tc-project-service", + "values": [ + { + "key": "api-url", + "value": "http://localhost:3000", + "description": "", + "type": "text", + "enabled": true + }, + { + "key": "jwt-token", + "value": "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJyb2xlcyI6WyJUb3Bjb2RlciBVc2VyIiwiYWRtaW5pc3RyYXRvciJdLCJpc3MiOiJodHRwczovL2FwaS50b3Bjb2Rlci1kZXYuY29tIiwiaGFuZGxlIjoidGVzdDEiLCJleHAiOjI1NjMwNzY2ODksInVzZXJJZCI6IjQwMDUxMzMzIiwiaWF0IjoxNDYzMDc2MDg5LCJlbWFpbCI6InRlc3RAdG9wY29kZXIuY29tIiwianRpIjoiYjMzYjc3Y2QtYjUyZS00MGZlLTgzN2UtYmViOGUwYWU2YTRhIn0.wKWUe0-SaiFVN-VR_-GwgFlvWaDkSbc8H55ktb9LAVw", + "description": "", + "type": "text", + "enabled": true + } + ], + "_postman_variable_scope": "environment", + "_postman_exported_at": "2018-05-18T18:54:18.167Z", + "_postman_exported_using": "Postman/6.0.10" +} \ No newline at end of file diff --git a/src/constants.js b/src/constants.js index 39b0b398..85aac5f3 100644 --- a/src/constants.js +++ b/src/constants.js @@ -50,6 +50,14 @@ export const EVENT = { PROJECT_DRAFT_CREATED: 'project.draft-created', PROJECT_UPDATED: 'project.updated', PROJECT_DELETED: 'project.deleted', + + PROJECT_PHASE_ADDED: 'project.phase.added', + PROJECT_PHASE_UPDATED: 'project.phase.updated', + PROJECT_PHASE_REMOVED: 'project.phase.removed', + + PROJECT_PHASE_PRODUCT_ADDED: 'project.phase.product.added', + PROJECT_PHASE_PRODUCT_UPDATED: 'project.phase.product.updated', + PROJECT_PHASE_PRODUCT_REMOVED: 'project.phase.product.removed', }, }; @@ -72,6 +80,14 @@ export const BUS_API_EVENT = { PROJECT_LINK_CREATED: 'notifications.connect.project.linkCreated', PROJECT_FILE_UPLOADED: 'notifications.connect.project.fileUploaded', PROJECT_SPECIFICATION_MODIFIED: 'notifications.connect.project.specificationModified', + + // When phase is added/updated/deleted from the project, + // When product is added/deleted from a phase + // When product is updated on any field other than specification + PROJECT_PLAN_MODIFIED: 'notifications.connect.project.planModified', + + // When specification of a product is modified + PROJECT_PRODUCT_SPECIFICATION_MODIFIED: 'notifications.connect.project.productSpecificationModified', }; export const REGEX = { diff --git a/src/events/busApi.js b/src/events/busApi.js index ac5f7b45..ae693f02 100644 --- a/src/events/busApi.js +++ b/src/events/busApi.js @@ -92,14 +92,14 @@ module.exports = (app, logger) => { models.Project.findOne({ where: { id: projectId }, }) - .then((project) => { - createEvent(eventType, { - projectId, - projectName: project.name, - userId: member.userId, - initiatorUserId: req.authUser.userId, - }, logger); - }).catch(err => null); // eslint-disable-line no-unused-vars + .then((project) => { + createEvent(eventType, { + projectId, + projectName: project.name, + userId: member.userId, + initiatorUserId: req.authUser.userId, + }, logger); + }).catch(err => null); // eslint-disable-line no-unused-vars }); /** @@ -119,16 +119,16 @@ module.exports = (app, logger) => { models.Project.findOne({ where: { id: projectId }, }) - .then((project) => { - if (project) { - createEvent(eventType, { - projectId, - projectName: project.name, - userId: member.userId, - initiatorUserId: req.authUser.userId, - }, logger); - } - }).catch(err => null); // eslint-disable-line no-unused-vars + .then((project) => { + if (project) { + createEvent(eventType, { + projectId, + projectName: project.name, + userId: member.userId, + initiatorUserId: req.authUser.userId, + }, logger); + } + }).catch(err => null); // eslint-disable-line no-unused-vars }); /** @@ -142,16 +142,16 @@ module.exports = (app, logger) => { models.Project.findOne({ where: { id: projectId }, }) - .then((project) => { - if (project) { - createEvent(BUS_API_EVENT.MEMBER_ASSIGNED_AS_OWNER, { - projectId, - projectName: project.name, - userId: updated.userId, - initiatorUserId: req.authUser.userId, - }, logger); - } - }).catch(err => null); // eslint-disable-line no-unused-vars + .then((project) => { + if (project) { + createEvent(BUS_API_EVENT.MEMBER_ASSIGNED_AS_OWNER, { + projectId, + projectName: project.name, + userId: updated.userId, + initiatorUserId: req.authUser.userId, + }, logger); + } + }).catch(err => null); // eslint-disable-line no-unused-vars } }); @@ -166,14 +166,157 @@ module.exports = (app, logger) => { models.Project.findOne({ where: { id: projectId }, }) - .then((project) => { - createEvent(BUS_API_EVENT.PROJECT_FILE_UPLOADED, { - projectId, - projectName: project.name, - fileName: attachment.filePath.replace(/^.*[\\\/]/, ''), // eslint-disable-line - userId: req.authUser.userId, - initiatorUserId: req.authUser.userId, - }, logger); - }).catch(err => null); // eslint-disable-line no-unused-vars + .then((project) => { + createEvent(BUS_API_EVENT.PROJECT_FILE_UPLOADED, { + projectId, + projectName: project.name, + fileName: attachment.filePath.replace(/^.*[\\\/]/, ''), // eslint-disable-line + userId: req.authUser.userId, + initiatorUserId: req.authUser.userId, + }, logger); + }).catch(err => null); // eslint-disable-line no-unused-vars + }); + + /** + * PROJECT_PHASE_ADDED + */ + app.on(EVENT.ROUTING_KEY.PROJECT_PHASE_ADDED, ({ req, created }) => { // eslint-disable-line no-unused-vars + logger.debug('receive PROJECT_PHASE_ADDED event'); + + const projectId = _.parseInt(req.params.projectId); + + models.Project.findOne({ + where: { id: projectId }, + }) + .then((project) => { + createEvent(BUS_API_EVENT.PROJECT_PLAN_MODIFIED, { + projectId, + projectName: project.name, + userId: req.authUser.userId, + initiatorUserId: req.authUser.userId, + }, logger); + }).catch(err => null); // eslint-disable-line no-unused-vars + }); + + /** + * PROJECT_PHASE_REMOVED + */ + app.on(EVENT.ROUTING_KEY.PROJECT_PHASE_REMOVED, ({ req, deleted }) => { // eslint-disable-line no-unused-vars + logger.debug('receive PROJECT_PHASE_REMOVED event'); + + const projectId = _.parseInt(req.params.projectId); + + models.Project.findOne({ + where: { id: projectId }, + }) + .then((project) => { + createEvent(BUS_API_EVENT.PROJECT_PLAN_MODIFIED, { + projectId, + projectName: project.name, + userId: req.authUser.userId, + initiatorUserId: req.authUser.userId, + }, logger); + }).catch(err => null); // eslint-disable-line no-unused-vars + }); + + /** + * PROJECT_PHASE_UPDATED + */ + app.on(EVENT.ROUTING_KEY.PROJECT_PHASE_UPDATED, ({ req, original, updated }) => { // eslint-disable-line no-unused-vars + logger.debug('receive PROJECT_PHASE_UPDATED event'); + + const projectId = _.parseInt(req.params.projectId); + + models.Project.findOne({ + where: { id: projectId }, + }) + .then((project) => { + createEvent(BUS_API_EVENT.PROJECT_PLAN_MODIFIED, { + projectId, + projectName: project.name, + userId: req.authUser.userId, + initiatorUserId: req.authUser.userId, + }, logger); + }).catch(err => null); // eslint-disable-line no-unused-vars + }); + + /** + * PROJECT_PHASE_PRODUCT_ADDED + */ + app.on(EVENT.ROUTING_KEY.PROJECT_PHASE_PRODUCT_ADDED, ({ req, created }) => { // eslint-disable-line no-unused-vars + logger.debug('receive PROJECT_PHASE_PRODUCT_ADDED event'); + + const projectId = _.parseInt(req.params.projectId); + + models.Project.findOne({ + where: { id: projectId }, + }) + .then((project) => { + createEvent(BUS_API_EVENT.PROJECT_PLAN_MODIFIED, { + projectId, + projectName: project.name, + userId: req.authUser.userId, + initiatorUserId: req.authUser.userId, + }, logger); + }).catch(err => null); // eslint-disable-line no-unused-vars + }); + + /** + * PROJECT_PHASE_PRODUCT_REMOVED + */ + app.on(EVENT.ROUTING_KEY.PROJECT_PHASE_PRODUCT_REMOVED, ({ req, deleted }) => { // eslint-disable-line no-unused-vars + logger.debug('receive PROJECT_PHASE_PRODUCT_REMOVED event'); + + const projectId = _.parseInt(req.params.projectId); + + models.Project.findOne({ + where: { id: projectId }, + }) + .then((project) => { + createEvent(BUS_API_EVENT.PROJECT_PLAN_MODIFIED, { + projectId, + projectName: project.name, + userId: req.authUser.userId, + initiatorUserId: req.authUser.userId, + }, logger); + }).catch(err => null); // eslint-disable-line no-unused-vars + }); + + /** + * PROJECT_PHASE_PRODUCT_UPDATED + */ + app.on(EVENT.ROUTING_KEY.PROJECT_PHASE_PRODUCT_UPDATED, ({ req, original, updated }) => { // eslint-disable-line no-unused-vars + logger.debug('receive PROJECT_PHASE_PRODUCT_UPDATED event'); + + const projectId = _.parseInt(req.params.projectId); + + models.Project.findOne({ + where: { id: projectId }, + }) + .then((project) => { + // Spec changes + if (!_.isEqual(original.details, updated.details)) { + logger.debug(`Spec changed for product id ${updated.id}`); + + createEvent(BUS_API_EVENT.PROJECT_PRODUCT_SPECIFICATION_MODIFIED, { + projectId, + projectName: project.name, + userId: req.authUser.userId, + initiatorUserId: req.authUser.userId, + }, logger); + } + + // Other fields change + const originalWithouDetails = _.omit(original, 'details'); + const updatedWithouDetails = _.omit(updated, 'details'); + if (!_.isEqual(originalWithouDetails.details, updatedWithouDetails.details)) { + createEvent(BUS_API_EVENT.PROJECT_PLAN_MODIFIED, { + projectId, + projectName: project.name, + userId: req.authUser.userId, + initiatorUserId: req.authUser.userId, + }, logger); + } + }).catch(err => null); // eslint-disable-line no-unused-vars }); }; diff --git a/src/events/index.js b/src/events/index.js index a8ac3096..cf6decf8 100644 --- a/src/events/index.js +++ b/src/events/index.js @@ -5,6 +5,10 @@ import { projectMemberAddedHandler, projectMemberRemovedHandler, projectMemberUpdatedHandler } from './projectMembers'; import { projectAttachmentAddedHandler, projectAttachmentRemovedHandler, projectAttachmentUpdatedHandler } from './projectAttachments'; +import { projectPhaseAddedHandler, projectPhaseRemovedHandler, + projectPhaseUpdatedHandler } from './projectPhases'; +import { phaseProductAddedHandler, phaseProductRemovedHandler, + phaseProductUpdatedHandler } from './phaseProducts'; export default { 'project.initial': projectCreatedHandler, @@ -17,4 +21,13 @@ export default { [EVENT.ROUTING_KEY.PROJECT_ATTACHMENT_ADDED]: projectAttachmentAddedHandler, [EVENT.ROUTING_KEY.PROJECT_ATTACHMENT_REMOVED]: projectAttachmentRemovedHandler, [EVENT.ROUTING_KEY.PROJECT_ATTACHMENT_UPDATED]: projectAttachmentUpdatedHandler, + [EVENT.ROUTING_KEY.PROJECT_PHASE_ADDED]: projectPhaseAddedHandler, + [EVENT.ROUTING_KEY.PROJECT_PHASE_REMOVED]: projectPhaseRemovedHandler, + [EVENT.ROUTING_KEY.PROJECT_PHASE_UPDATED]: projectPhaseUpdatedHandler, + [EVENT.ROUTING_KEY.PROJECT_PHASE_ADDED]: projectPhaseAddedHandler, + [EVENT.ROUTING_KEY.PROJECT_PHASE_REMOVED]: projectPhaseRemovedHandler, + [EVENT.ROUTING_KEY.PROJECT_PHASE_UPDATED]: projectPhaseUpdatedHandler, + [EVENT.ROUTING_KEY.PROJECT_PHASE_PRODUCT_ADDED]: phaseProductAddedHandler, + [EVENT.ROUTING_KEY.PROJECT_PHASE_PRODUCT_REMOVED]: phaseProductRemovedHandler, + [EVENT.ROUTING_KEY.PROJECT_PHASE_PRODUCT_UPDATED]: phaseProductUpdatedHandler, }; diff --git a/src/events/phaseProducts/index.js b/src/events/phaseProducts/index.js new file mode 100644 index 00000000..b6b6c063 --- /dev/null +++ b/src/events/phaseProducts/index.js @@ -0,0 +1,129 @@ +/** + * Event handlers for phase product create, update and delete. + * Current functionality just updates the elasticsearch indexes. + */ + +import config from 'config'; +import _ from 'lodash'; +import Promise from 'bluebird'; +import util from '../../util'; + +const ES_PROJECT_INDEX = config.get('elasticsearchConfig.indexName'); +const ES_PROJECT_TYPE = config.get('elasticsearchConfig.docType'); + +const eClient = util.getElasticSearchClient(); + +/** + * Handler for phase product creation 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 phaseProductAddedHandler = Promise.coroutine(function* (logger, msg, channel) { // eslint-disable-line func-names + try { + const data = JSON.parse(msg.content.toString()); + const doc = yield eClient.get({ index: ES_PROJECT_INDEX, type: ES_PROJECT_TYPE, id: data.projectId }); + const phases = _.isArray(doc._source.phases) ? doc._source.phases : []; // eslint-disable-line no-underscore-dangle + + _.each(phases, (phase) => { + if (phase.id === data.phaseId) { + phase.products = _.isArray(phase.products) ? phase.products : []; // eslint-disable-line no-param-reassign + phase.products.push(_.omit(data, ['deletedAt', 'deletedBy'])); + } + }); + + const merged = _.assign(doc._source, { phases }); // eslint-disable-line no-underscore-dangle + yield eClient.update({ index: ES_PROJECT_INDEX, type: ES_PROJECT_TYPE, id: data.projectId, body: { doc: merged } }); + logger.debug('phase product added to project document successfully'); + channel.ack(msg); + } catch (error) { + logger.error('Error handling project.phase.added event', error); + // if the message has been redelivered dont attempt to reprocess it + channel.nack(msg, false, !msg.fields.redelivered); + } +}); + +/** + * Handler for phase product 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 phaseProductUpdatedHandler = Promise.coroutine(function* (logger, msg, channel) { // eslint-disable-line func-names + try { + const data = JSON.parse(msg.content.toString()); + const doc = yield eClient.get({ index: ES_PROJECT_INDEX, type: ES_PROJECT_TYPE, id: data.original.projectId }); + const phases = _.map(doc._source.phases, (phase) => { // eslint-disable-line no-underscore-dangle + if (phase.id === data.original.phaseId) { + phase.products = _.map(phase.products, (product) => { // eslint-disable-line no-param-reassign + if (product.id === data.original.id) { + return _.assign(product, _.omit(data.updated, ['deletedAt', 'deletedBy'])); + } + return product; + }); + } + return phase; + }); + const merged = _.assign(doc._source, { phases }); // eslint-disable-line no-underscore-dangle + yield eClient.update({ + index: ES_PROJECT_INDEX, + type: ES_PROJECT_TYPE, + id: data.original.projectId, + body: { + doc: merged, + }, + }); + logger.debug('elasticsearch index updated, phase product updated successfully'); + channel.ack(msg); + } catch (error) { + logger.error('Error handling project.phase.updated event', error); + // if the message has been redelivered dont attempt to reprocess it + channel.nack(msg, false, !msg.fields.redelivered); + } +}); + +/** + * Handler for phase product 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 phaseProductRemovedHandler = Promise.coroutine(function* (logger, msg, channel) { // eslint-disable-line func-names + try { + const data = JSON.parse(msg.content.toString()); + const doc = yield eClient.get({ index: ES_PROJECT_INDEX, type: ES_PROJECT_TYPE, id: data.projectId }); + const phases = _.map(doc._source.phases, (phase) => { // eslint-disable-line no-underscore-dangle + if (phase.id === data.phaseId) { + phase.products = _.filter(phase.products, product => product.id !== data.id); // eslint-disable-line no-param-reassign + } + return phase; + }); + + const merged = _.assign(doc._source, { phases }); // eslint-disable-line no-underscore-dangle + + yield eClient.update({ + index: ES_PROJECT_INDEX, + type: ES_PROJECT_TYPE, + id: data.projectId, + body: { + doc: merged, + }, + }); + logger.debug('phase product removed from project document successfully'); + channel.ack(msg); + } catch (error) { + logger.error('Error fetching project document from elasticsearch', error); + // if the message has been redelivered dont attempt to reprocess it + channel.nack(msg, false, !msg.fields.redelivered); + } +}); + + +module.exports = { + phaseProductAddedHandler, + phaseProductRemovedHandler, + phaseProductUpdatedHandler, +}; diff --git a/src/events/projectPhases/index.js b/src/events/projectPhases/index.js new file mode 100644 index 00000000..7543bdae --- /dev/null +++ b/src/events/projectPhases/index.js @@ -0,0 +1,110 @@ +/** + * Event handlers for project phase create, update and delete. + * Current functionality just updates the elasticsearch indexes. + */ + +import config from 'config'; +import _ from 'lodash'; +import Promise from 'bluebird'; +import util from '../../util'; + +const ES_PROJECT_INDEX = config.get('elasticsearchConfig.indexName'); +const ES_PROJECT_TYPE = config.get('elasticsearchConfig.docType'); + +const eClient = util.getElasticSearchClient(); + +/** + * Handler for project phase creation 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 projectPhaseAddedHandler = Promise.coroutine(function* (logger, msg, channel) { // eslint-disable-line func-names + try { + const data = JSON.parse(msg.content.toString()); + const doc = yield eClient.get({ index: ES_PROJECT_INDEX, type: ES_PROJECT_TYPE, id: data.projectId }); + const phases = _.isArray(doc._source.phases) ? doc._source.phases : []; // eslint-disable-line no-underscore-dangle + phases.push(_.omit(data, ['deletedAt', 'deletedBy'])); + const merged = _.assign(doc._source, { phases }); // eslint-disable-line no-underscore-dangle + yield eClient.update({ index: ES_PROJECT_INDEX, type: ES_PROJECT_TYPE, id: data.projectId, body: { doc: merged } }); + logger.debug('project phase added to project document successfully'); + channel.ack(msg); + } catch (error) { + logger.error('Error handling project.phase.added event', error); + // if the message has been redelivered dont attempt to reprocess it + channel.nack(msg, false, !msg.fields.redelivered); + } +}); + +/** + * Handler for project phase 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 projectPhaseUpdatedHandler = Promise.coroutine(function* (logger, msg, channel) { // eslint-disable-line func-names + try { + const data = JSON.parse(msg.content.toString()); + const doc = yield eClient.get({ index: ES_PROJECT_INDEX, type: ES_PROJECT_TYPE, id: data.original.projectId }); + const phases = _.map(doc._source.phases, (single) => { // eslint-disable-line no-underscore-dangle + if (single.id === data.original.id) { + return _.assign(single, _.omit(data.updated, ['deletedAt', 'deletedBy'])); + } + return single; + }); + const merged = _.assign(doc._source, { phases }); // eslint-disable-line no-underscore-dangle + yield eClient.update({ + index: ES_PROJECT_INDEX, + type: ES_PROJECT_TYPE, + id: data.original.projectId, + body: { + doc: merged, + }, + }); + logger.debug('elasticsearch index updated, project phase updated successfully'); + channel.ack(msg); + } catch (error) { + logger.error('Error handling project.phase.updated event', error); + // if the message has been redelivered dont attempt to reprocess it + channel.nack(msg, false, !msg.fields.redelivered); + } +}); + +/** + * Handler for project phase 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 projectPhaseRemovedHandler = Promise.coroutine(function* (logger, msg, channel) { // eslint-disable-line func-names + try { + const data = JSON.parse(msg.content.toString()); + const doc = yield eClient.get({ index: ES_PROJECT_INDEX, type: ES_PROJECT_TYPE, id: data.projectId }); + const phases = _.filter(doc._source.phases, single => single.id !== data.id); // eslint-disable-line no-underscore-dangle + const merged = _.assign(doc._source, { phases }); // eslint-disable-line no-underscore-dangle + yield eClient.update({ + index: ES_PROJECT_INDEX, + type: ES_PROJECT_TYPE, + id: data.projectId, + body: { + doc: merged, + }, + }); + logger.debug('project phase removed from project document successfully'); + channel.ack(msg); + } catch (error) { + logger.error('Error fetching project document from elasticsearch', error); + // if the message has been redelivered dont attempt to reprocess it + channel.nack(msg, false, !msg.fields.redelivered); + } +}); + + +module.exports = { + projectPhaseAddedHandler, + projectPhaseRemovedHandler, + projectPhaseUpdatedHandler, +}; diff --git a/src/models/phaseProduct.js b/src/models/phaseProduct.js new file mode 100644 index 00000000..4ec1ea90 --- /dev/null +++ b/src/models/phaseProduct.js @@ -0,0 +1,45 @@ + + +module.exports = function definePhaseProduct(sequelize, DataTypes) { + const PhaseProduct = sequelize.define('PhaseProduct', { + id: { type: DataTypes.BIGINT, primaryKey: true, autoIncrement: true }, + name: { type: DataTypes.STRING, allowNull: true }, + projectId: DataTypes.BIGINT, + directProjectId: DataTypes.BIGINT, + billingAccountId: DataTypes.BIGINT, + // TODO: associate this with product_template + templateId: { type: DataTypes.BIGINT, defaultValue: 0 }, + type: { type: DataTypes.STRING, allowNull: true }, + estimatedPrice: { type: DataTypes.DOUBLE, defaultValue: 0.0 }, + actualPrice: { type: DataTypes.DOUBLE, defaultValue: 0.0 }, + details: { type: DataTypes.JSON, defaultValue: {} }, + + deletedAt: { type: DataTypes.DATE, allowNull: true }, + createdAt: { type: DataTypes.DATE, defaultValue: DataTypes.NOW }, + updatedAt: { type: DataTypes.DATE, defaultValue: DataTypes.NOW }, + deletedBy: { type: DataTypes.INTEGER, allowNull: true }, + createdBy: { type: DataTypes.INTEGER, allowNull: false }, + updatedBy: { type: DataTypes.INTEGER, allowNull: false }, + }, { + tableName: 'phase_products', + paranoid: false, + timestamps: true, + updatedAt: 'updatedAt', + createdAt: 'createdAt', + deletedAt: 'deletedAt', + indexes: [], + classMethods: { + getActivePhaseProducts(phaseId) { + return this.findAll({ + where: { + deletedAt: { $eq: null }, + phaseId, + }, + raw: true, + }); + }, + }, + }); + + return PhaseProduct; +}; diff --git a/src/models/productTemplate.js b/src/models/productTemplate.js new file mode 100644 index 00000000..72d7bc30 --- /dev/null +++ b/src/models/productTemplate.js @@ -0,0 +1,32 @@ +/* eslint-disable valid-jsdoc */ + +/** + * The Product Template model + */ +module.exports = (sequelize, DataTypes) => { + const ProductTemplate = sequelize.define('ProductTemplate', { + id: { type: DataTypes.BIGINT, primaryKey: true, autoIncrement: true }, + name: { type: DataTypes.STRING(255), allowNull: false }, + productKey: { type: DataTypes.STRING(45), allowNull: false }, + icon: { type: DataTypes.STRING(255), allowNull: false }, + brief: { type: DataTypes.STRING(45), allowNull: false }, + details: { type: DataTypes.STRING(255), allowNull: false }, + aliases: { type: DataTypes.JSON, allowNull: false }, + template: { type: DataTypes.JSON, 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_templates', + paranoid: true, + timestamps: true, + updatedAt: 'updatedAt', + createdAt: 'createdAt', + deletedAt: 'deletedAt', + }); + + return ProductTemplate; +}; diff --git a/src/models/project.js b/src/models/project.js index 0c832e5f..ae6724ce 100644 --- a/src/models/project.js +++ b/src/models/project.js @@ -92,6 +92,7 @@ module.exports = function defineProject(sequelize, DataTypes) { associate: (models) => { Project.hasMany(models.ProjectMember, { as: 'members', foreignKey: 'projectId' }); Project.hasMany(models.ProjectAttachment, { as: 'attachments', foreignKey: 'projectId' }); + Project.hasMany(models.ProjectPhase, { as: 'phases', foreignKey: 'projectId' }); }, /** diff --git a/src/models/projectPhase.js b/src/models/projectPhase.js new file mode 100644 index 00000000..3d21ec1c --- /dev/null +++ b/src/models/projectPhase.js @@ -0,0 +1,105 @@ +/* eslint-disable valid-jsdoc */ + +import _ from 'lodash'; + +module.exports = function defineProjectPhase(sequelize, DataTypes) { + const ProjectPhase = sequelize.define('ProjectPhase', { + id: { type: DataTypes.BIGINT, primaryKey: true, autoIncrement: true }, + name: { type: DataTypes.STRING, allowNull: true }, + status: { type: DataTypes.STRING, allowNull: true }, + startDate: { type: DataTypes.DATE, allowNull: true }, + endDate: { type: DataTypes.DATE, allowNull: true }, + budget: { type: DataTypes.DOUBLE, defaultValue: 0.0 }, + progress: { type: DataTypes.DOUBLE, defaultValue: 0.0 }, + details: { type: DataTypes.JSON, defaultValue: {} }, + + deletedAt: { type: DataTypes.DATE, allowNull: true }, + createdAt: { type: DataTypes.DATE, defaultValue: DataTypes.NOW }, + updatedAt: { type: DataTypes.DATE, defaultValue: DataTypes.NOW }, + deletedBy: { type: DataTypes.INTEGER, allowNull: true }, + createdBy: { type: DataTypes.INTEGER, allowNull: false }, + updatedBy: { type: DataTypes.INTEGER, allowNull: false }, + }, { + tableName: 'project_phases', + paranoid: false, + timestamps: true, + updatedAt: 'updatedAt', + createdAt: 'createdAt', + deletedAt: 'deletedAt', + indexes: [], + classMethods: { + getActiveProjectPhases(projectId) { + return this.findAll({ + where: { + deletedAt: { $eq: null }, + projectId, + }, + raw: true, + }); + }, + associate: (models) => { + ProjectPhase.hasMany(models.PhaseProduct, { as: 'products', foreignKey: 'phaseId' }); + }, + /** + * Search name or status + * @param parameters the parameters + * - filters: the filters contains keyword + * - order: the order + * - limit: the limit + * - offset: the offset + * - attributes: the attributes to get + * @param log the request log + * @return the result rows and count + */ + searchText(parameters, log) { + // special handling for keyword filter + let query = '1=1 '; + if (_.has(parameters.filters, 'id')) { + if (_.isObject(parameters.filters.id)) { + if (parameters.filters.id.$in.length === 0) { + parameters.filters.id.$in.push(-1); + } + query += `AND id IN (${parameters.filters.id.$in}) `; + } else if (_.isString(parameters.filters.id) || _.isNumber(parameters.filters.id)) { + query += `AND id = ${parameters.filters.id} `; + } + } + if (_.has(parameters.filters, 'status')) { + const statusFilter = parameters.filters.status; + if (_.isObject(statusFilter)) { + const statuses = statusFilter.$in.join("','"); + query += `AND status IN ('${statuses}') `; + } else if (_.isString(statusFilter)) { + query += `AND status ='${statusFilter}'`; + } + } + if (_.has(parameters.filters, 'name')) { + query += `AND name like '%${parameters.filters.name}%' `; + } + + const attributesStr = `"${parameters.attributes.join('","')}"`; + const orderStr = `"${parameters.order[0][0]}" ${parameters.order[0][1]}`; + + // select count of project_phases + return sequelize.query(`SELECT COUNT(1) FROM project_phases WHERE ${query}`, + { type: sequelize.QueryTypes.SELECT, + logging: (str) => { log.debug(str); }, + raw: true, + }) + .then((fcount) => { + const count = fcount[0].count; + // select project attributes + return sequelize.query(`SELECT ${attributesStr} FROM project_phases WHERE ${query} ORDER BY ` + + ` ${orderStr} LIMIT ${parameters.limit} OFFSET ${parameters.offset}`, + { type: sequelize.QueryTypes.SELECT, + logging: (str) => { log.debug(str); }, + raw: true, + }) + .then(phases => ({ rows: phases, count })); + }); + }, + }, + }); + + return ProjectPhase; +}; diff --git a/src/models/projectTemplate.js b/src/models/projectTemplate.js new file mode 100644 index 00000000..206fa9e0 --- /dev/null +++ b/src/models/projectTemplate.js @@ -0,0 +1,30 @@ +/* eslint-disable valid-jsdoc */ + +/** + * The Project Template model + */ +module.exports = (sequelize, DataTypes) => { + const ProjectTemplate = sequelize.define('ProjectTemplate', { + id: { type: DataTypes.BIGINT, primaryKey: true, autoIncrement: true }, + name: { type: DataTypes.STRING(255), allowNull: false }, + key: { type: DataTypes.STRING(45), allowNull: false }, + category: { type: DataTypes.STRING(45), allowNull: false }, + scope: { type: DataTypes.JSON, allowNull: false }, + phases: { type: DataTypes.JSON, 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: 'project_templates', + paranoid: true, + timestamps: true, + updatedAt: 'updatedAt', + createdAt: 'createdAt', + deletedAt: 'deletedAt', + }); + + return ProjectTemplate; +}; diff --git a/src/permissions/connectManagerOrAdmin.ops.js b/src/permissions/connectManagerOrAdmin.ops.js new file mode 100644 index 00000000..0a5fb15a --- /dev/null +++ b/src/permissions/connectManagerOrAdmin.ops.js @@ -0,0 +1,18 @@ +import util from '../util'; +import { MANAGER_ROLES } from '../constants'; + + +/** + * Only Connect Manager, Connect Admin, and administrator are allowed to perform the operations + * @param {Object} req the express request instance + * @return {Promise} returns a promise + */ +module.exports = req => new Promise((resolve, reject) => { + const hasAccess = util.hasRoles(req, MANAGER_ROLES); + + if (!hasAccess) { + return reject(new Error('You do not have permissions to perform this action')); + } + + return resolve(true); +}); diff --git a/src/permissions/index.js b/src/permissions/index.js index e0797b3c..ea1adf72 100644 --- a/src/permissions/index.js +++ b/src/permissions/index.js @@ -6,6 +6,7 @@ const projectEdit = require('./project.edit'); const projectDelete = require('./project.delete'); const projectMemberDelete = require('./projectMember.delete'); const projectAdmin = require('./admin.ops'); +const connectManagerOrAdmin = require('./connectManagerOrAdmin.ops'); module.exports = () => { Authorizer.setDeniedStatusCode(403); @@ -23,4 +24,21 @@ module.exports = () => { Authorizer.setPolicy('project.downloadAttachment', projectView); Authorizer.setPolicy('project.updateMember', projectEdit); Authorizer.setPolicy('project.admin', projectAdmin); + + Authorizer.setPolicy('projectTemplate.create', connectManagerOrAdmin); + Authorizer.setPolicy('projectTemplate.edit', connectManagerOrAdmin); + Authorizer.setPolicy('projectTemplate.delete', connectManagerOrAdmin); + Authorizer.setPolicy('projectTemplate.view', true); + + Authorizer.setPolicy('productTemplate.create', connectManagerOrAdmin); + Authorizer.setPolicy('productTemplate.edit', connectManagerOrAdmin); + Authorizer.setPolicy('productTemplate.delete', connectManagerOrAdmin); + Authorizer.setPolicy('productTemplate.view', true); + + Authorizer.setPolicy('project.addProjectPhase', projectEdit); + Authorizer.setPolicy('project.updateProjectPhase', projectEdit); + Authorizer.setPolicy('project.deleteProjectPhase', projectEdit); + Authorizer.setPolicy('project.addPhaseProduct', projectEdit); + Authorizer.setPolicy('project.updatePhaseProduct', projectEdit); + Authorizer.setPolicy('project.deletePhaseProduct', projectEdit); }; diff --git a/src/routes/index.js b/src/routes/index.js index 47a51502..63250efe 100644 --- a/src/routes/index.js +++ b/src/routes/index.js @@ -26,7 +26,9 @@ router.get(`/${apiVersion}/projects/health`, (req, res) => { // All project service endpoints need authentication const jwtAuth = require('tc-core-library-js').middleware.jwtAuthenticator; -router.all(RegExp(`\\/${apiVersion}\\/projects(?!\\/health).*`), jwtAuth()); +router.all( + RegExp(`\\/${apiVersion}\\/(projects|projectTemplates|productTemplates)(?!\\/health).*`), + jwtAuth()); // Register all the routes router.route('/v4/projects') @@ -51,19 +53,55 @@ router.route('/v4/projects/:projectId(\\d+)') .delete(require('./projects/delete')); router.route('/v4/projects/:projectId(\\d+)/members') - .post(require('./projectMembers/create')); + .post(require('./projectMembers/create')); router.route('/v4/projects/:projectId(\\d+)/members/:id(\\d+)') - .delete(require('./projectMembers/delete')) - .patch(require('./projectMembers/update')); + .delete(require('./projectMembers/delete')) + .patch(require('./projectMembers/update')); router.route('/v4/projects/:projectId(\\d+)/attachments') - .post(require('./attachments/create')); -router.route('/v4/projects/:projectId(\\d+)/attachments/:id(\\d+)') - .get(require('./attachments/download')) - .patch(require('./attachments/update')) - .delete(require('./attachments/delete')); + .post(require('./attachments/create')); +router.route('/v4/projects/:projectId(\\d+)/attachments/:id(\\d+)') + .get(require('./attachments/download')) + .patch(require('./attachments/update')) + .delete(require('./attachments/delete')); + +router.route('/v4/projectTemplates') + .post(require('./projectTemplates/create')) + .get(require('./projectTemplates/list')); + +router.route('/v4/projectTemplates/:templateId(\\d+)') + .get(require('./projectTemplates/get')) + .patch(require('./projectTemplates/update')) + .delete(require('./projectTemplates/delete')); + +router.route('/v4/productTemplates') + .post(require('./productTemplates/create')) + .get(require('./productTemplates/list')); + +router.route('/v4/productTemplates/:templateId(\\d+)') + .get(require('./productTemplates/get')) + .patch(require('./productTemplates/update')) + .delete(require('./productTemplates/delete')); + +router.route('/v4/projects/:projectId(\\d+)/phases') + .get(require('./phases/list')) + .post(require('./phases/create')); + +router.route('/v4/projects/:projectId(\\d+)/phases/:phaseId(\\d+)') + .get(require('./phases/get')) + .patch(require('./phases/update')) + .delete(require('./phases/delete')); + +router.route('/v4/projects/:projectId(\\d+)/phases/:phaseId(\\d+)/products') + .get(require('./phaseProducts/list')) + .post(require('./phaseProducts/create')); + +router.route('/v4/projects/:projectId(\\d+)/phases/:phaseId(\\d+)/products/:productId(\\d+)') + .get(require('./phaseProducts/get')) + .patch(require('./phaseProducts/update')) + .delete(require('./phaseProducts/delete')); // register error handler router.use((err, req, res, next) => { // eslint-disable-line no-unused-vars diff --git a/src/routes/phaseProducts/create.js b/src/routes/phaseProducts/create.js new file mode 100644 index 00000000..670e89d1 --- /dev/null +++ b/src/routes/phaseProducts/create.js @@ -0,0 +1,113 @@ + +import validate from 'express-validation'; +import _ from 'lodash'; +import config from 'config'; +import Joi from 'joi'; + +import models from '../../models'; +import util from '../../util'; +import { EVENT } from '../../constants'; + +const permissions = require('tc-core-library-js').middleware.permissions; + +const addPhaseProductValidations = { + body: { + param: Joi.object().keys({ + name: Joi.string().required(), + type: Joi.string().required(), + templateId: Joi.number().optional(), + estimatedPrice: Joi.number().positive().optional(), + actualPrice: Joi.number().positive().optional(), + details: Joi.any().optional(), + }).required(), + }, +}; + +module.exports = [ + // validate request payload + validate(addPhaseProductValidations), + // check permission + permissions('project.addPhaseProduct'), + // do the real work + (req, res, next) => { + const projectId = _.parseInt(req.params.projectId); + const phaseId = _.parseInt(req.params.phaseId); + + const data = req.body.param; + // default values + _.assign(data, { + createdBy: req.authUser.userId, + updatedBy: req.authUser.userId, + }); + + let newPhaseProduct = null; + models.sequelize.transaction(() => models.Project.findOne({ + where: { id: projectId, deletedAt: { $eq: null } }, + raw: true, + }).then((existingProject) => { + // make sure project exists + if (!existingProject) { + const err = new Error(`project not found for project id ${projectId}`); + err.status = 404; + throw err; + } + _.assign(data, { + projectId, + directProjectId: existingProject.directProjectId, + billingAccountId: existingProject.billingAccountId, + }); + + return models.ProjectPhase.findOne({ + where: { id: phaseId, projectId, deletedAt: { $eq: null } }, + raw: true, + }); + }).then((existingPhase) => { + // make sure phase exists + if (!existingPhase) { + const err = new Error(`project phase not found for project id ${projectId}` + + ` and phase id ${phaseId}`); + err.status = 404; + throw err; + } + _.assign(data, { + phaseId, + }); + + return models.PhaseProduct.count({ + where: { + projectId, + phaseId, + deletedAt: { $eq: null }, + }, + raw: true, + }); + }).then((productCount) => { + // make sure number of products of per phase <= max value + if (productCount >= config.maxPhaseProductCount) { + const err = new Error('the number of products per phase cannot exceed ' + + `${config.maxPhaseProductCount}`); + err.status = 400; + throw err; + } + return models.PhaseProduct.create(data); + }) + .then((_newPhaseProduct) => { + newPhaseProduct = _.cloneDeep(_newPhaseProduct); + req.log.debug('new phase product created (id# %d, name: %s)', + newPhaseProduct.id, newPhaseProduct.name); + newPhaseProduct = newPhaseProduct.get({ plain: true }); + newPhaseProduct = _.omit(newPhaseProduct, ['deletedAt', 'utm']); + + // Send events to buses + req.log.debug('Sending event to RabbitMQ bus for phase product %d', newPhaseProduct.id); + req.app.services.pubsub.publish(EVENT.ROUTING_KEY.PROJECT_PHASE_PRODUCT_ADDED, + newPhaseProduct, + { correlationId: req.id }, + ); + req.log.debug('Sending event to Kafka bus for phase product %d', newPhaseProduct.id); + req.app.emit(EVENT.ROUTING_KEY.PROJECT_PHASE_PRODUCT_ADDED, { req, created: newPhaseProduct }); + + res.status(201).json(util.wrapResponse(req.id, newPhaseProduct, 1, 201)); + })).catch((err) => { next(err); }); + }, +]; diff --git a/src/routes/phaseProducts/create.spec.js b/src/routes/phaseProducts/create.spec.js new file mode 100644 index 00000000..95a6d1ca --- /dev/null +++ b/src/routes/phaseProducts/create.spec.js @@ -0,0 +1,185 @@ +/* eslint-disable no-unused-expressions */ +import _ from 'lodash'; +import chai from 'chai'; +import request from 'supertest'; +import server from '../../app'; +import models from '../../models'; +import testUtil from '../../tests/util'; + +const should = chai.should(); + +const body = { + name: 'test phase product', + type: 'product1', + estimatedPrice: 20.0, + actualPrice: 1.23456, + details: { + message: 'This can be any json', + }, +}; + +describe('Phase Products', () => { + let projectId; + let phaseId; + before((done) => { + // mocks + testUtil.clearDb() + .then(() => { + models.Project.create({ + type: 'generic', + billingAccountId: 1, + name: 'test1', + description: 'test project1', + status: 'draft', + details: {}, + createdBy: 1, + updatedBy: 1, + }).then((p) => { + projectId = p.id; + // create members + models.ProjectMember.create({ + userId: 40051332, + projectId, + role: 'copilot', + isPrimary: true, + createdBy: 1, + updatedBy: 1, + }).then(() => { + models.ProjectPhase.create({ + name: 'test project phase', + 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', + }, + createdBy: 1, + updatedBy: 1, + projectId, + }).then((phase) => { + phaseId = phase.id; + done(); + }); + }); + }); + }); + }); + + after((done) => { + testUtil.clearDb(done); + }); + + describe('POST /projects/{projectId}/phases/{phaseId}/products', () => { + it('should return 403 if user does not have permissions', (done) => { + request(server) + .post(`/v4/projects/${projectId}/phases/${phaseId}/products`) + .set({ + Authorization: `Bearer ${testUtil.jwts.member}`, + }) + .send({ param: body }) + .expect('Content-Type', /json/) + .expect(403, done); + }); + + it('should return 422 when name not provided', (done) => { + const reqBody = _.cloneDeep(body); + delete reqBody.name; + request(server) + .post(`/v4/projects/${projectId}/phases/${phaseId}/products`) + .set({ + Authorization: `Bearer ${testUtil.jwts.copilot}`, + }) + .send({ param: reqBody }) + .expect('Content-Type', /json/) + .expect(422, done); + }); + + it('should return 422 when type not provided', (done) => { + const reqBody = _.cloneDeep(body); + delete reqBody.type; + request(server) + .post(`/v4/projects/${projectId}/phases/${phaseId}/products`) + .set({ + Authorization: `Bearer ${testUtil.jwts.copilot}`, + }) + .send({ param: reqBody }) + .expect('Content-Type', /json/) + .expect(422, done); + }); + + it('should return 422 when estimatedPrice is negative', (done) => { + const reqBody = _.cloneDeep(body); + reqBody.estimatedPrice = -20; + request(server) + .post(`/v4/projects/${projectId}/phases/${phaseId}/products`) + .set({ + Authorization: `Bearer ${testUtil.jwts.copilot}`, + }) + .send({ param: reqBody }) + .expect('Content-Type', /json/) + .expect(422, done); + }); + + it('should return 422 when actualPrice is negative', (done) => { + const reqBody = _.cloneDeep(body); + reqBody.actualPrice = -20; + request(server) + .post(`/v4/projects/${projectId}/phases/${phaseId}/products`) + .set({ + Authorization: `Bearer ${testUtil.jwts.copilot}`, + }) + .send({ param: reqBody }) + .expect('Content-Type', /json/) + .expect(422, done); + }); + + it('should return 404 when project is not found', (done) => { + request(server) + .post(`/v4/projects/99999/phases/${phaseId}/products`) + .set({ + Authorization: `Bearer ${testUtil.jwts.manager}`, + }) + .send({ param: body }) + .expect('Content-Type', /json/) + .expect(404, done); + }); + + it('should return 404 when project phase is not found', (done) => { + request(server) + .post(`/v4/projects/${projectId}/phases/99999/products`) + .set({ + Authorization: `Bearer ${testUtil.jwts.manager}`, + }) + .send({ param: body }) + .expect('Content-Type', /json/) + .expect(404, done); + }); + + it('should return 201 if payload is valid', (done) => { + request(server) + .post(`/v4/projects/${projectId}/phases/${phaseId}/products`) + .set({ + Authorization: `Bearer ${testUtil.jwts.copilot}`, + }) + .send({ param: body }) + .expect('Content-Type', /json/) + .expect(201) + .end((err, res) => { + if (err) { + done(err); + } else { + const resJson = res.body.result.content; + should.exist(resJson); + resJson.name.should.be.eql(body.name); + resJson.type.should.be.eql(body.type); + resJson.estimatedPrice.should.be.eql(body.estimatedPrice); + resJson.actualPrice.should.be.eql(body.actualPrice); + resJson.details.should.be.eql(body.details); + done(); + } + }); + }); + }); +}); diff --git a/src/routes/phaseProducts/delete.js b/src/routes/phaseProducts/delete.js new file mode 100644 index 00000000..2faa6295 --- /dev/null +++ b/src/routes/phaseProducts/delete.js @@ -0,0 +1,53 @@ + + +import _ from 'lodash'; +import { middleware as tcMiddleware } from 'tc-core-library-js'; +import models from '../../models'; +import { EVENT } from '../../constants'; + +const permissions = tcMiddleware.permissions; + +module.exports = [ + // check permission + permissions('project.deletePhaseProduct'), + + (req, res, next) => { + const projectId = _.parseInt(req.params.projectId); + const phaseId = _.parseInt(req.params.phaseId); + const productId = _.parseInt(req.params.productId); + + models.sequelize.transaction(() => + // soft delete the record + models.PhaseProduct.findOne({ + where: { + id: productId, + projectId, + phaseId, + deletedAt: { $eq: null }, + }, + }).then(existing => new Promise((accept, reject) => { + if (!existing) { + // handle 404 + const err = new Error('No active phase product found for project id ' + + `${projectId}, phase id ${phaseId} and product id ${productId}`); + err.status = 404; + reject(err); + } else { + _.extend(existing, { deletedBy: req.authUser.userId, deletedAt: Date.now() }); + existing.save().then(accept).catch(reject); + } + })).then((deleted) => { + req.log.debug('deleted phase product', JSON.stringify(deleted, null, 2)); + + // Send events to buses + req.app.services.pubsub.publish( + EVENT.ROUTING_KEY.PROJECT_PHASE_PRODUCT_REMOVED, + deleted, + { correlationId: req.id }, + ); + req.app.emit(EVENT.ROUTING_KEY.PROJECT_PHASE_PRODUCT_REMOVED, { req, deleted }); + + res.status(204).json({}); + }).catch(err => next(err))); + }, +]; diff --git a/src/routes/phaseProducts/delete.spec.js b/src/routes/phaseProducts/delete.spec.js new file mode 100644 index 00000000..2908bee9 --- /dev/null +++ b/src/routes/phaseProducts/delete.spec.js @@ -0,0 +1,129 @@ +/* eslint-disable no-unused-expressions */ +import _ from 'lodash'; +import request from 'supertest'; +import server from '../../app'; +import models from '../../models'; +import testUtil from '../../tests/util'; + +const body = { + name: 'test phase product', + type: 'product1', + estimatedPrice: 20.0, + actualPrice: 1.23456, + details: { + message: 'This can be any json', + }, + createdBy: 1, + updatedBy: 1, +}; + +describe('Phase Products', () => { + let projectId; + let phaseId; + let productId; + before((done) => { + // mocks + testUtil.clearDb() + .then(() => { + models.Project.create({ + type: 'generic', + billingAccountId: 1, + name: 'test1', + description: 'test project1', + status: 'draft', + details: {}, + createdBy: 1, + updatedBy: 1, + }).then((p) => { + projectId = p.id; + // create members + models.ProjectMember.create({ + userId: 40051332, + projectId, + role: 'copilot', + isPrimary: true, + createdBy: 1, + updatedBy: 1, + }).then(() => { + models.ProjectPhase.create({ + name: 'test project phase', + 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', + }, + createdBy: 1, + updatedBy: 1, + projectId, + }).then((phase) => { + phaseId = phase.id; + _.assign(body, { phaseId, projectId }); + + models.PhaseProduct.create(body).then((product) => { + productId = product.id; + done(); + }); + }); + }); + }); + }); + }); + + after((done) => { + testUtil.clearDb(done); + }); + + describe('DELETE /projects/{id}/phases/{phaseId}/products/{productId}', () => { + it('should return 403 when user have no permission', (done) => { + request(server) + .delete(`/v4/projects/${projectId}/phases/${phaseId}/products/${productId}`) + .set({ + Authorization: `Bearer ${testUtil.jwts.member}`, + }) + .expect('Content-Type', /json/) + .expect(403, done); + }); + + it('should return 404 when no project with specific projectId', (done) => { + request(server) + .delete(`/v4/projects/999/phases/${phaseId}/products/${productId}`) + .set({ + Authorization: `Bearer ${testUtil.jwts.manager}`, + }) + .expect('Content-Type', /json/) + .expect(404, done); + }); + + it('should return 404 when no phase with specific phaseId', (done) => { + request(server) + .delete(`/v4/projects/${projectId}/phases/99999/products/${productId}`) + .set({ + Authorization: `Bearer ${testUtil.jwts.manager}`, + }) + .expect('Content-Type', /json/) + .expect(404, done); + }); + + it('should return 404 when no product with specific productId', (done) => { + request(server) + .delete(`/v4/projects/${projectId}/phases/${phaseId}/products/99999`) + .set({ + Authorization: `Bearer ${testUtil.jwts.manager}`, + }) + .expect('Content-Type', /json/) + .expect(404, done); + }); + + it('should return 204 when user have project permission', (done) => { + request(server) + .delete(`/v4/projects/${projectId}/phases/${phaseId}/products/${productId}`) + .set({ + Authorization: `Bearer ${testUtil.jwts.copilot}`, + }) + .expect(204, done); + }); + }); +}); diff --git a/src/routes/phaseProducts/get.js b/src/routes/phaseProducts/get.js new file mode 100644 index 00000000..30aa2ed4 --- /dev/null +++ b/src/routes/phaseProducts/get.js @@ -0,0 +1,36 @@ + +import _ from 'lodash'; + +import models from '../../models'; +import util from '../../util'; + +const permissions = require('tc-core-library-js').middleware.permissions; + +module.exports = [ + // check permission + permissions('project.view'), + + (req, res, next) => { + const projectId = _.parseInt(req.params.projectId); + const phaseId = _.parseInt(req.params.phaseId); + const productId = _.parseInt(req.params.productId); + + return models.PhaseProduct.findOne({ + where: { + id: productId, + projectId, + phaseId, + }, + }).then((product) => { + if (!product) { + // handle 404 + const err = new Error('phase product not found for project id ' + + `${projectId}, phase id ${phaseId} and product id ${productId}`); + err.status = 404; + throw err; + } else { + res.json(util.wrapResponse(req.id, product)); + } + }).catch(err => next(err)); + }, +]; diff --git a/src/routes/phaseProducts/get.spec.js b/src/routes/phaseProducts/get.spec.js new file mode 100644 index 00000000..b2b65b2a --- /dev/null +++ b/src/routes/phaseProducts/get.spec.js @@ -0,0 +1,147 @@ +/* eslint-disable no-unused-expressions */ +import _ from 'lodash'; +import chai from 'chai'; +import request from 'supertest'; +import server from '../../app'; +import models from '../../models'; +import testUtil from '../../tests/util'; + +const should = chai.should(); + +const body = { + name: 'test phase product', + type: 'product1', + estimatedPrice: 20.0, + actualPrice: 1.23456, + details: { + message: 'This can be any json', + }, + createdBy: 1, + updatedBy: 1, +}; + +describe('Phase Products', () => { + let projectId; + let phaseId; + let productId; + before((done) => { + // mocks + testUtil.clearDb() + .then(() => { + models.Project.create({ + type: 'generic', + billingAccountId: 1, + name: 'test1', + description: 'test project1', + status: 'draft', + details: {}, + createdBy: 1, + updatedBy: 1, + }).then((p) => { + projectId = p.id; + // create members + models.ProjectMember.create({ + userId: 40051332, + projectId, + role: 'copilot', + isPrimary: true, + createdBy: 1, + updatedBy: 1, + }).then(() => { + models.ProjectPhase.create({ + name: 'test project phase', + 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', + }, + createdBy: 1, + updatedBy: 1, + projectId, + }).then((phase) => { + phaseId = phase.id; + _.assign(body, { phaseId, projectId }); + + models.PhaseProduct.create(body).then((product) => { + productId = product.id; + done(); + }); + }); + }); + }); + }); + }); + + after((done) => { + testUtil.clearDb(done); + }); + + describe('GET /projects/{id}/phases/{phaseId}/products/{productId}', () => { + it('should return 403 when user have no permission', (done) => { + request(server) + .get(`/v4/projects/${projectId}/phases/${phaseId}/products/${productId}`) + .set({ + Authorization: `Bearer ${testUtil.jwts.member}`, + }) + .expect('Content-Type', /json/) + .expect(403, done); + }); + + it('should return 404 when no project with specific projectId', (done) => { + request(server) + .get(`/v4/projects/999/phases/${phaseId}/products/${productId}`) + .set({ + Authorization: `Bearer ${testUtil.jwts.manager}`, + }) + .expect('Content-Type', /json/) + .expect(404, done); + }); + + it('should return 404 when no phase with specific phaseId', (done) => { + request(server) + .get(`/v4/projects/${projectId}/phases/99999/products/${productId}`) + .set({ + Authorization: `Bearer ${testUtil.jwts.manager}`, + }) + .expect('Content-Type', /json/) + .expect(404, done); + }); + + it('should return 404 when no product with specific productId', (done) => { + request(server) + .get(`/v4/projects/${projectId}/phases/${phaseId}/products/99999`) + .set({ + Authorization: `Bearer ${testUtil.jwts.manager}`, + }) + .expect('Content-Type', /json/) + .expect(404, done); + }); + + it('should return 1 phase when user have project permission', (done) => { + request(server) + .get(`/v4/projects/${projectId}/phases/${phaseId}/products/${productId}`) + .set({ + Authorization: `Bearer ${testUtil.jwts.copilot}`, + }) + .expect('Content-Type', /json/) + .expect(200) + .end((err, res) => { + if (err) { + done(err); + } else { + const resJson = res.body.result.content; + should.exist(resJson); + resJson.name.should.be.eql(body.name); + resJson.type.should.be.eql(body.type); + resJson.estimatedPrice.should.be.eql(body.estimatedPrice); + resJson.actualPrice.should.be.eql(body.actualPrice); + resJson.details.should.be.eql(body.details); + done(); + } + }); + }); + }); +}); diff --git a/src/routes/phaseProducts/list.js b/src/routes/phaseProducts/list.js new file mode 100644 index 00000000..5899c425 --- /dev/null +++ b/src/routes/phaseProducts/list.js @@ -0,0 +1,49 @@ + +import _ from 'lodash'; +import config from 'config'; +import util from '../../util'; + +const ES_PROJECT_INDEX = config.get('elasticsearchConfig.indexName'); +const ES_PROJECT_TYPE = config.get('elasticsearchConfig.docType'); + +const eClient = util.getElasticSearchClient(); + +const permissions = require('tc-core-library-js').middleware.permissions; + +module.exports = [ + // check permission + permissions('project.view'), + + (req, res, next) => { + const projectId = _.parseInt(req.params.projectId); + const phaseId = _.parseInt(req.params.phaseId); + + // Get project from ES + eClient.get({ index: ES_PROJECT_INDEX, type: ES_PROJECT_TYPE, id: req.params.projectId }) + .then((doc) => { + if (!doc) { + const err = new Error(`active project not found for project id ${projectId}`); + err.status = 404; + throw err; + } + + // Get the phases + let phases = _.isArray(doc._source.phases) ? doc._source.phases : []; // eslint-disable-line no-underscore-dangle + + // Get the phase by id + phases = _.filter(phases, { id: phaseId }); + if (phases.length <= 0) { + const err = new Error(`active project phase not found for phase id ${phaseId}`); + err.status = 404; + throw err; + } + + // Get the products + let products = phases[0].products; + products = _.isArray(products) ? products : []; // eslint-disable-line no-underscore-dangle + + res.json(util.wrapResponse(req.id, products, products.length)); + }) + .catch(err => next(err)); + }, +]; diff --git a/src/routes/phaseProducts/list.spec.js b/src/routes/phaseProducts/list.spec.js new file mode 100644 index 00000000..5f91dece --- /dev/null +++ b/src/routes/phaseProducts/list.spec.js @@ -0,0 +1,157 @@ +/* eslint-disable no-unused-expressions */ +import _ from 'lodash'; +import request from 'supertest'; +import sleep from 'sleep'; +import chai from 'chai'; +import config from 'config'; +import server from '../../app'; +import models from '../../models'; +import testUtil from '../../tests/util'; + +const ES_PROJECT_INDEX = config.get('elasticsearchConfig.indexName'); +const ES_PROJECT_TYPE = config.get('elasticsearchConfig.docType'); + +const should = chai.should(); + +const body = { + name: 'test phase product', + type: 'product1', + estimatedPrice: 20.0, + actualPrice: 1.23456, + details: { + message: 'This can be any json', + }, + createdBy: 1, + updatedBy: 1, +}; + +describe('Phase Products', () => { + let projectId; + let phaseId; + let project; + before(function beforeHook(done) { + this.timeout(10000); + // mocks + testUtil.clearDb() + .then(() => { + models.Project.create({ + type: 'generic', + billingAccountId: 1, + name: 'test1', + description: 'test project1', + status: 'draft', + details: {}, + createdBy: 1, + updatedBy: 1, + }).then((p) => { + projectId = p.id; + project = p.toJSON(); + // create members + models.ProjectMember.create({ + userId: 40051332, + projectId, + role: 'copilot', + isPrimary: true, + createdBy: 1, + updatedBy: 1, + }).then(() => { + models.ProjectPhase.create({ + name: 'test project phase', + 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', + }, + createdBy: 1, + updatedBy: 1, + projectId, + }).then((phase) => { + phaseId = phase.id; + _.assign(body, { phaseId, projectId }); + + project.phases = [phase.toJSON()]; + + models.PhaseProduct.create(body).then((product) => { + project.phases[0].products = [product.toJSON()]; + + // Index to ES + return server.services.es.index({ + index: ES_PROJECT_INDEX, + type: ES_PROJECT_TYPE, + id: projectId, + body: project, + }).then(() => { + // sleep for some time, let elasticsearch indices be settled + sleep.sleep(5); + done(); + }); + }); + }); + }); + }); + }); + }); + + after((done) => { + testUtil.clearDb(done); + }); + + describe('GET /projects/{id}/phases/{phaseId}/products', () => { + it('should return 403 when user have no permission', (done) => { + request(server) + .get(`/v4/projects/${projectId}/phases/${phaseId}/products`) + .set({ + Authorization: `Bearer ${testUtil.jwts.member}`, + }) + .send({ param: body }) + .expect('Content-Type', /json/) + .expect(403, done); + }); + + it('should return 404 when no project with specific projectId', (done) => { + request(server) + .get(`/v4/projects/999/phases/${phaseId}/products`) + .set({ + Authorization: `Bearer ${testUtil.jwts.manager}`, + }) + .send({ param: body }) + .expect('Content-Type', /json/) + .expect(404, done); + }); + + it('should return 404 when no phase with specific phaseId', (done) => { + request(server) + .get(`/v4/projects/${projectId}/phases/99999/products`) + .set({ + Authorization: `Bearer ${testUtil.jwts.manager}`, + }) + .send({ param: body }) + .expect('Content-Type', /json/) + .expect(404, done); + }); + + it('should return 1 phase when user have project permission', (done) => { + request(server) + .get(`/v4/projects/${projectId}/phases/${phaseId}/products`) + .set({ + Authorization: `Bearer ${testUtil.jwts.copilot}`, + }) + .send({ param: body }) + .expect('Content-Type', /json/) + .expect(200) + .end((err, res) => { + if (err) { + done(err); + } else { + const resJson = res.body.result.content; + should.exist(resJson); + resJson.should.have.lengthOf(1); + done(); + } + }); + }); + }); +}); diff --git a/src/routes/phaseProducts/update.js b/src/routes/phaseProducts/update.js new file mode 100644 index 00000000..9d335f2a --- /dev/null +++ b/src/routes/phaseProducts/update.js @@ -0,0 +1,80 @@ + +import validate from 'express-validation'; +import _ from 'lodash'; +import Joi from 'joi'; +import { middleware as tcMiddleware } from 'tc-core-library-js'; +import models from '../../models'; +import util from '../../util'; +import { EVENT } from '../../constants'; + + +const permissions = tcMiddleware.permissions; + +const updatePhaseProductValidation = { + body: { + param: Joi.object().keys({ + name: Joi.string().optional(), + type: Joi.string().optional(), + templateId: Joi.number().optional(), + estimatedPrice: Joi.number().positive().optional(), + actualPrice: Joi.number().positive().optional(), + details: Joi.any().optional(), + }).required(), + }, +}; + + +module.exports = [ + // validate request payload + validate(updatePhaseProductValidation), + // check permission + permissions('project.updatePhaseProduct'), + + (req, res, next) => { + const projectId = _.parseInt(req.params.projectId); + const phaseId = _.parseInt(req.params.phaseId); + const productId = _.parseInt(req.params.productId); + + const updatedProps = req.body.param; + updatedProps.updatedBy = req.authUser.userId; + + let previousValue; + + models.sequelize.transaction(() => models.PhaseProduct.findOne({ + where: { + id: productId, + projectId, + phaseId, + deletedAt: { $eq: null }, + }, + }).then(existing => new Promise((accept, reject) => { + if (!existing) { + // handle 404 + const err = new Error('No active phase product found for project id ' + + `${projectId}, phase id ${phaseId} and product id ${productId}`); + err.status = 404; + reject(err); + } else { + previousValue = _.clone(existing.get({ plain: true })); + + _.extend(existing, updatedProps); + existing.save().then(accept).catch(reject); + } + })).then((updated) => { + req.log.debug('updated phase product', JSON.stringify(updated, null, 2)); + + const updatedValue = updated.get({ plain: true }); + + // emit original and updated project phase information + req.app.services.pubsub.publish( + EVENT.ROUTING_KEY.PROJECT_PHASE_PRODUCT_UPDATED, + { original: previousValue, updated: updatedValue }, + { correlationId: req.id }, + ); + req.app.emit(EVENT.ROUTING_KEY.PROJECT_PHASE_PRODUCT_UPDATED, + { req, original: previousValue, updated: updatedValue }); + + res.json(util.wrapResponse(req.id, updated)); + }).catch(err => next(err))); + }, +]; diff --git a/src/routes/phaseProducts/update.spec.js b/src/routes/phaseProducts/update.spec.js new file mode 100644 index 00000000..1a8b8a21 --- /dev/null +++ b/src/routes/phaseProducts/update.spec.js @@ -0,0 +1,178 @@ +/* eslint-disable no-unused-expressions */ +import _ from 'lodash'; +import chai from 'chai'; +import request from 'supertest'; +import server from '../../app'; +import models from '../../models'; +import testUtil from '../../tests/util'; + +const should = chai.should(); + +const body = { + name: 'test phase product', + type: 'product1', + estimatedPrice: 20.0, + actualPrice: 1.23456, + details: { + message: 'This can be any json', + }, + createdBy: 1, + updatedBy: 1, +}; + +const updateBody = { + name: 'test phase product xxx', + type: 'product2', + estimatedPrice: 123456.789, + actualPrice: 9.8765432, + details: { + message: 'This is another json', + }, +}; + +describe('Phase Products', () => { + let projectId; + let phaseId; + let productId; + before((done) => { + // mocks + testUtil.clearDb() + .then(() => { + models.Project.create({ + type: 'generic', + billingAccountId: 1, + name: 'test1', + description: 'test project1', + status: 'draft', + details: {}, + createdBy: 1, + updatedBy: 1, + }).then((p) => { + projectId = p.id; + // create members + models.ProjectMember.create({ + userId: 40051332, + projectId, + role: 'copilot', + isPrimary: true, + createdBy: 1, + updatedBy: 1, + }).then(() => { + models.ProjectPhase.create({ + name: 'test project phase', + 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', + }, + createdBy: 1, + updatedBy: 1, + projectId, + }).then((phase) => { + phaseId = phase.id; + _.assign(body, { phaseId, projectId }); + + models.PhaseProduct.create(body).then((product) => { + productId = product.id; + done(); + }); + }); + }); + }); + }); + }); + + after((done) => { + testUtil.clearDb(done); + }); + + describe('PATCH /projects/{id}/phases/{phaseId}/products/{productId}', () => { + it('should return 403 when user have no permission', (done) => { + request(server) + .patch(`/v4/projects/${projectId}/phases/${phaseId}/products/${productId}`) + .set({ + Authorization: `Bearer ${testUtil.jwts.member}`, + }) + .send({ param: updateBody }) + .expect('Content-Type', /json/) + .expect(403, done); + }); + + it('should return 404 when no project with specific projectId', (done) => { + request(server) + .patch(`/v4/projects/999/phases/${phaseId}/products/${productId}`) + .set({ + Authorization: `Bearer ${testUtil.jwts.manager}`, + }) + .send({ param: updateBody }) + .expect('Content-Type', /json/) + .expect(404, done); + }); + + it('should return 404 when no phase with specific phaseId', (done) => { + request(server) + .patch(`/v4/projects/${projectId}/phases/99999/products/${productId}`) + .set({ + Authorization: `Bearer ${testUtil.jwts.manager}`, + }) + .send({ param: updateBody }) + .expect('Content-Type', /json/) + .expect(404, done); + }); + + it('should return 404 when no product with specific productId', (done) => { + request(server) + .patch(`/v4/projects/${projectId}/phases/${phaseId}/products/99999`) + .set({ + Authorization: `Bearer ${testUtil.jwts.manager}`, + }) + .send({ param: updateBody }) + .expect('Content-Type', /json/) + .expect(404, done); + }); + + it('should return 422 when parameters are invalid', (done) => { + request(server) + .patch(`/v4/projects/${projectId}/phases/${phaseId}/products/99999`) + .set({ + Authorization: `Bearer ${testUtil.jwts.manager}`, + }) + .send({ + param: { + estimatedPrice: -15, + }, + }) + .expect('Content-Type', /json/) + .expect(422, done); + }); + + + it('should return updated product when user have permission and parameters are valid', (done) => { + request(server) + .patch(`/v4/projects/${projectId}/phases/${phaseId}/products/${productId}`) + .set({ + Authorization: `Bearer ${testUtil.jwts.copilot}`, + }) + .send({ param: updateBody }) + .expect('Content-Type', /json/) + .expect(200) + .end((err, res) => { + if (err) { + done(err); + } else { + const resJson = res.body.result.content; + should.exist(resJson); + resJson.name.should.be.eql(updateBody.name); + resJson.type.should.be.eql(updateBody.type); + resJson.estimatedPrice.should.be.eql(updateBody.estimatedPrice); + resJson.actualPrice.should.be.eql(updateBody.actualPrice); + resJson.details.should.be.eql(updateBody.details); + done(); + } + }); + }); + }); +}); diff --git a/src/routes/phases/create.js b/src/routes/phases/create.js new file mode 100644 index 00000000..1a5828c7 --- /dev/null +++ b/src/routes/phases/create.js @@ -0,0 +1,80 @@ +import validate from 'express-validation'; +import _ from 'lodash'; +import Joi from 'joi'; + +import models from '../../models'; +import util from '../../util'; +import { EVENT } from '../../constants'; + +const permissions = require('tc-core-library-js').middleware.permissions; + + +const addProjectPhaseValidations = { + body: { + param: Joi.object().keys({ + name: Joi.string().required(), + status: Joi.string().required(), + startDate: Joi.date().max(Joi.ref('endDate')).required(), + endDate: Joi.date().required(), + budget: Joi.number().positive().optional(), + progress: Joi.number().positive().optional(), + details: Joi.any().optional(), + }).required(), + }, +}; + +module.exports = [ + // validate request payload + validate(addProjectPhaseValidations), + // check permission + permissions('project.addProjectPhase'), + // do the real work + (req, res, next) => { + const data = req.body.param; + // default values + const projectId = _.parseInt(req.params.projectId); + _.assign(data, { + projectId, + createdBy: req.authUser.userId, + updatedBy: req.authUser.userId, + }); + + models.sequelize.transaction(() => { + let newProjectPhase = null; + + models.Project.findOne({ + where: { id: projectId, deletedAt: { $eq: null } }, + }).then((existingProject) => { + if (!existingProject) { + const err = new Error(`active project not found for project id ${projectId}`); + err.status = 404; + throw err; + } + models.ProjectPhase + .create(data) + .then((_newProjectPhase) => { + newProjectPhase = _.cloneDeep(_newProjectPhase); + req.log.debug('new project phase created (id# %d, name: %s)', + newProjectPhase.id, newProjectPhase.name); + + newProjectPhase = newProjectPhase.get({ plain: true }); + newProjectPhase = _.omit(newProjectPhase, ['deletedAt', 'deletedBy', 'utm']); + + // Send events to buses + req.log.debug('Sending event to RabbitMQ bus for project phase %d', newProjectPhase.id); + req.app.services.pubsub.publish(EVENT.ROUTING_KEY.PROJECT_PHASE_ADDED, + newProjectPhase, + { correlationId: req.id }, + ); + req.log.debug('Sending event to Kafka bus for project phase %d', newProjectPhase.id); + req.app.emit(EVENT.ROUTING_KEY.PROJECT_PHASE_ADDED, { req, created: newProjectPhase }); + + res.status(201).json(util.wrapResponse(req.id, newProjectPhase, 1, 201)); + }); + }).catch((err) => { + util.handleError('Error creating project phase', err, req, next); + }); + }); + }, + +]; diff --git a/src/routes/phases/create.spec.js b/src/routes/phases/create.spec.js new file mode 100644 index 00000000..9a1fb5ce --- /dev/null +++ b/src/routes/phases/create.spec.js @@ -0,0 +1,196 @@ +/* eslint-disable no-unused-expressions */ +import _ from 'lodash'; +import chai from 'chai'; +import request from 'supertest'; +import server from '../../app'; +import models from '../../models'; +import testUtil from '../../tests/util'; + +const should = chai.should(); + +const body = { + name: 'test project phase', + 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', + }, +}; + +describe('Project Phases', () => { + let projectId; + before((done) => { + // mocks + testUtil.clearDb() + .then(() => { + models.Project.create({ + type: 'generic', + billingAccountId: 1, + name: 'test1', + description: 'test project1', + status: 'draft', + details: {}, + createdBy: 1, + updatedBy: 1, + }).then((p) => { + projectId = p.id; + // create members + models.ProjectMember.create({ + userId: 40051332, + projectId, + role: 'copilot', + isPrimary: true, + createdBy: 1, + updatedBy: 1, + }).then(() => done()); + }); + }); + }); + + after((done) => { + testUtil.clearDb(done); + }); + + describe('POST /projects/{id}/phases/', () => { + it('should return 403 if user does not have permissions', (done) => { + request(server) + .post(`/v4/projects/${projectId}/phases/`) + .set({ + Authorization: `Bearer ${testUtil.jwts.member}`, + }) + .send({ param: body }) + .expect('Content-Type', /json/) + .expect(403, done); + }); + + it('should return 422 when name not provided', (done) => { + const reqBody = _.cloneDeep(body); + delete reqBody.name; + request(server) + .post(`/v4/projects/${projectId}/phases/`) + .set({ + Authorization: `Bearer ${testUtil.jwts.copilot}`, + }) + .send({ param: reqBody }) + .expect('Content-Type', /json/) + .expect(422, done); + }); + + it('should return 422 when status not provided', (done) => { + const reqBody = _.cloneDeep(body); + delete reqBody.status; + request(server) + .post(`/v4/projects/${projectId}/phases/`) + .set({ + Authorization: `Bearer ${testUtil.jwts.copilot}`, + }) + .send({ param: reqBody }) + .expect('Content-Type', /json/) + .expect(422, done); + }); + + it('should return 422 when startDate not provided', (done) => { + const reqBody = _.cloneDeep(body); + delete reqBody.startDate; + request(server) + .post(`/v4/projects/${projectId}/phases/`) + .set({ + Authorization: `Bearer ${testUtil.jwts.copilot}`, + }) + .send({ param: reqBody }) + .expect('Content-Type', /json/) + .expect(422, done); + }); + + it('should return 422 when endDate not provided', (done) => { + const reqBody = _.cloneDeep(body); + delete reqBody.endDate; + request(server) + .post(`/v4/projects/${projectId}/phases/`) + .set({ + Authorization: `Bearer ${testUtil.jwts.copilot}`, + }) + .send({ param: reqBody }) + .expect('Content-Type', /json/) + .expect(422, done); + }); + + it('should return 422 when startDate > endDate', (done) => { + const reqBody = _.cloneDeep(body); + reqBody.startDate = '2018-05-16T12:00:00'; + request(server) + .post(`/v4/projects/${projectId}/phases/`) + .set({ + Authorization: `Bearer ${testUtil.jwts.copilot}`, + }) + .send({ param: reqBody }) + .expect('Content-Type', /json/) + .expect(422, done); + }); + + it('should return 422 when budget is negative', (done) => { + const reqBody = _.cloneDeep(body); + reqBody.budget = -20; + request(server) + .post(`/v4/projects/${projectId}/phases/`) + .set({ + Authorization: `Bearer ${testUtil.jwts.copilot}`, + }) + .send({ param: reqBody }) + .expect('Content-Type', /json/) + .expect(422, done); + }); + + it('should return 422 when progress is negative', (done) => { + const reqBody = _.cloneDeep(body); + reqBody.progress = -20; + request(server) + .post(`/v4/projects/${projectId}/phases/`) + .set({ + Authorization: `Bearer ${testUtil.jwts.copilot}`, + }) + .send({ param: reqBody }) + .expect('Content-Type', /json/) + .expect(422, done); + }); + + it('should return 404 when project is not found', (done) => { + request(server) + .post('/v4/projects/99999/phases/') + .set({ + Authorization: `Bearer ${testUtil.jwts.manager}`, + }) + .send({ param: body }) + .expect('Content-Type', /json/) + .expect(404, done); + }); + + it('should return 201 if payload is valid', (done) => { + request(server) + .post(`/v4/projects/${projectId}/phases/`) + .set({ + Authorization: `Bearer ${testUtil.jwts.copilot}`, + }) + .send({ param: body }) + .expect('Content-Type', /json/) + .expect(201) + .end((err, res) => { + if (err) { + done(err); + } else { + const resJson = res.body.result.content; + should.exist(resJson); + resJson.name.should.be.eql(body.name); + resJson.status.should.be.eql(body.status); + resJson.budget.should.be.eql(body.budget); + resJson.progress.should.be.eql(body.progress); + resJson.details.should.be.eql(body.details); + done(); + } + }); + }); + }); +}); diff --git a/src/routes/phases/delete.js b/src/routes/phases/delete.js new file mode 100644 index 00000000..3bc34012 --- /dev/null +++ b/src/routes/phases/delete.js @@ -0,0 +1,52 @@ + + +import _ from 'lodash'; +import { middleware as tcMiddleware } from 'tc-core-library-js'; +import models from '../../models'; +import { EVENT } from '../../constants'; + +const permissions = tcMiddleware.permissions; + +module.exports = [ + // check permission + permissions('project.deleteProjectPhase'), + + (req, res, next) => { + const projectId = _.parseInt(req.params.projectId); + const phaseId = _.parseInt(req.params.phaseId); + + models.sequelize.transaction(() => + // soft delete the record + models.ProjectPhase.findOne({ + where: { + id: phaseId, + projectId, + deletedAt: { $eq: null }, + }, + }).then(existing => new Promise((accept, reject) => { + if (!existing) { + // handle 404 + const err = new Error('no active project phase found for project id ' + + `${projectId} and phase id ${phaseId}`); + err.status = 404; + reject(err); + } else { + _.extend(existing, { deletedBy: req.authUser.userId, deletedAt: Date.now() }); + existing.save().then(accept).catch(reject); + } + })).then((deleted) => { + req.log.debug('deleted project phase', JSON.stringify(deleted, null, 2)); + + // Send events to buses + req.app.services.pubsub.publish( + EVENT.ROUTING_KEY.PROJECT_PHASE_REMOVED, + deleted, + { correlationId: req.id }, + ); + req.app.emit(EVENT.ROUTING_KEY.PROJECT_PHASE_REMOVED, { req, deleted }); + + res.status(204).json({}); + }).catch(err => next(err))); + }, +]; + diff --git a/src/routes/phases/delete.spec.js b/src/routes/phases/delete.spec.js new file mode 100644 index 00000000..cb1ea251 --- /dev/null +++ b/src/routes/phases/delete.spec.js @@ -0,0 +1,103 @@ +/* eslint-disable no-unused-expressions */ +import _ from 'lodash'; +import request from 'supertest'; +import server from '../../app'; +import models from '../../models'; +import testUtil from '../../tests/util'; + +const body = { + name: 'test project phase', + 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', + }, + createdBy: 1, + updatedBy: 1, +}; + +describe('Project Phases', () => { + let projectId; + let phaseId; + before((done) => { + // mocks + testUtil.clearDb() + .then(() => { + models.Project.create({ + type: 'generic', + billingAccountId: 1, + name: 'test1', + description: 'test project1', + status: 'draft', + details: {}, + createdBy: 1, + updatedBy: 1, + }).then((p) => { + projectId = p.id; + // create members + models.ProjectMember.create({ + userId: 40051332, + projectId, + role: 'copilot', + isPrimary: true, + createdBy: 1, + updatedBy: 1, + }).then(() => { + _.assign(body, { projectId }); + models.ProjectPhase.create(body).then((phase) => { + phaseId = phase.id; + done(); + }); + }); + }); + }); + }); + + after((done) => { + testUtil.clearDb(done); + }); + + describe('DELETE /projects/{projectId}/phases/{phaseId}', () => { + it('should return 403 when user have no permission', (done) => { + request(server) + .delete(`/v4/projects/${projectId}/phases/${phaseId}`) + .set({ + Authorization: `Bearer ${testUtil.jwts.member}`, + }) + .expect('Content-Type', /json/) + .expect(403, done); + }); + + it('should return 404 when no project with specific projectId', (done) => { + request(server) + .delete(`/v4/projects/999/phases/${phaseId}`) + .set({ + Authorization: `Bearer ${testUtil.jwts.manager}`, + }) + .expect('Content-Type', /json/) + .expect(404, done); + }); + + it('should return 404 when no phase with specific phaseId', (done) => { + request(server) + .delete(`/v4/projects/${projectId}/phases/999`) + .set({ + Authorization: `Bearer ${testUtil.jwts.manager}`, + }) + .expect('Content-Type', /json/) + .expect(404, done); + }); + + it('should return 204 when user have project permission', (done) => { + request(server) + .delete(`/v4/projects/${projectId}/phases/${phaseId}`) + .set({ + Authorization: `Bearer ${testUtil.jwts.copilot}`, + }) + .expect(204, done); + }); + }); +}); diff --git a/src/routes/phases/get.js b/src/routes/phases/get.js new file mode 100644 index 00000000..cee2c3f2 --- /dev/null +++ b/src/routes/phases/get.js @@ -0,0 +1,31 @@ + +import _ from 'lodash'; +import { middleware as tcMiddleware } from 'tc-core-library-js'; +import util from '../../util'; +import models from '../../models'; + +const permissions = tcMiddleware.permissions; + +module.exports = [ + permissions('project.view'), + (req, res, next) => { + const projectId = _.parseInt(req.params.projectId); + const phaseId = _.parseInt(req.params.phaseId); + return models.ProjectPhase + .findOne({ + where: { id: phaseId, projectId }, + raw: true, + }) + .then((phase) => { + if (!phase) { + // handle 404 + const err = new Error('project phase not found for project id ' + + `${projectId} and phase id ${phaseId}`); + err.status = 404; + throw err; + } + res.json(util.wrapResponse(req.id, phase)); + }) + .catch(err => next(err)); + }, +]; diff --git a/src/routes/phases/get.spec.js b/src/routes/phases/get.spec.js new file mode 100644 index 00000000..8a384e38 --- /dev/null +++ b/src/routes/phases/get.spec.js @@ -0,0 +1,121 @@ +/* eslint-disable no-unused-expressions */ +import _ from 'lodash'; +import chai from 'chai'; +import request from 'supertest'; +import server from '../../app'; +import models from '../../models'; +import testUtil from '../../tests/util'; + +const should = chai.should(); + +const body = { + name: 'test project phase', + 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', + }, + createdBy: 1, + updatedBy: 1, +}; + +describe('Project Phases', () => { + let projectId; + let phaseId; + before((done) => { + // mocks + testUtil.clearDb() + .then(() => { + models.Project.create({ + type: 'generic', + billingAccountId: 1, + name: 'test1', + description: 'test project1', + status: 'draft', + details: {}, + createdBy: 1, + updatedBy: 1, + }).then((p) => { + projectId = p.id; + // create members + models.ProjectMember.create({ + userId: 40051332, + projectId, + role: 'copilot', + isPrimary: true, + createdBy: 1, + updatedBy: 1, + }).then(() => { + _.assign(body, { projectId }); + models.ProjectPhase.create(body).then((phase) => { + phaseId = phase.id; + done(); + }); + }); + }); + }); + }); + + after((done) => { + testUtil.clearDb(done); + }); + + describe('GET /projects/{projectId}/phases/{phaseId}', () => { + it('should return 403 when user have no permission', (done) => { + request(server) + .get(`/v4/projects/${projectId}/phases/${phaseId}`) + .set({ + Authorization: `Bearer ${testUtil.jwts.member}`, + }) + .expect('Content-Type', /json/) + .expect(403, done); + }); + + it('should return 404 when no project with specific projectId', (done) => { + request(server) + .get(`/v4/projects/999/phases/${phaseId}`) + .set({ + Authorization: `Bearer ${testUtil.jwts.manager}`, + }) + .expect('Content-Type', /json/) + .expect(404, done); + }); + + it('should return 404 when no phase with specific phaseId', (done) => { + request(server) + .get(`/v4/projects/${projectId}/phases/999`) + .set({ + Authorization: `Bearer ${testUtil.jwts.manager}`, + }) + .expect('Content-Type', /json/) + .expect(404, done); + }); + + it('should return 1 phase when user have project permission', (done) => { + request(server) + .get(`/v4/projects/${projectId}/phases/${phaseId}`) + .set({ + Authorization: `Bearer ${testUtil.jwts.copilot}`, + }) + .expect('Content-Type', /json/) + .expect(200) + .end((err, res) => { + if (err) { + done(err); + } else { + const resJson = res.body.result.content; + should.exist(resJson); + resJson.name.should.be.eql('test project phase'); + resJson.status.should.be.eql('active'); + resJson.budget.should.be.eql(20.0); + resJson.progress.should.be.eql(1.23456); + resJson.details.should.be.eql({ message: 'This can be any json' }); + done(); + } + }); + }); + }); +}); diff --git a/src/routes/phases/list.js b/src/routes/phases/list.js new file mode 100644 index 00000000..6644a365 --- /dev/null +++ b/src/routes/phases/list.js @@ -0,0 +1,65 @@ + +import _ from 'lodash'; +import config from 'config'; +import { middleware as tcMiddleware } from 'tc-core-library-js'; +import util from '../../util'; +import models from '../../models'; + +const ES_PROJECT_INDEX = config.get('elasticsearchConfig.indexName'); +const ES_PROJECT_TYPE = config.get('elasticsearchConfig.docType'); + +const eClient = util.getElasticSearchClient(); + +const PHASE_ATTRIBUTES = _.keys(models.ProjectPhase.rawAttributes); + +const permissions = tcMiddleware.permissions; + + +module.exports = [ + permissions('project.view'), + (req, res, next) => { + const projectId = _.parseInt(req.params.projectId); + + let sort = req.query.sort ? decodeURIComponent(req.query.sort) : 'startDate'; + if (sort && sort.indexOf(' ') === -1) { + sort += ' asc'; + } + const sortableProps = [ + 'startDate asc', 'startDate desc', + 'endDate asc', 'endDate desc', + 'status asc', 'status desc', + ]; + if (sort && _.indexOf(sortableProps, sort) < 0) { + return util.handleError('Invalid sort criteria', null, req, next); + } + const sortColumnAndOrder = sort.split(' '); + + // Get project from ES + return eClient.get({ index: ES_PROJECT_INDEX, type: ES_PROJECT_TYPE, id: req.params.projectId }) + .then((doc) => { + if (!doc) { + const err = new Error(`active project not found for project id ${projectId}`); + err.status = 404; + throw err; + } + + // Get the phases + let phases = _.isArray(doc._source.phases) ? doc._source.phases : []; // eslint-disable-line no-underscore-dangle + + // Sort + phases = _.sortBy(phases, [sortColumnAndOrder[0]], [sortColumnAndOrder[1]]); + + // Parse the fields string to determine what fields are to be returned + let fields = req.query.fields ? req.query.fields.split(',') : PHASE_ATTRIBUTES; + fields = _.intersection(fields, PHASE_ATTRIBUTES); + if (_.indexOf(fields, 'id') < 0) { + fields.push('id'); + } + + phases = _.map(phases, phase => _.pick(phase, fields)); + + res.json(util.wrapResponse(req.id, phases, phases.length)); + }) + .catch(err => next(err)); + }, +]; diff --git a/src/routes/phases/list.spec.js b/src/routes/phases/list.spec.js new file mode 100644 index 00000000..74761ad2 --- /dev/null +++ b/src/routes/phases/list.spec.js @@ -0,0 +1,127 @@ +/* eslint-disable no-unused-expressions */ +import _ from 'lodash'; +import request from 'supertest'; +import config from 'config'; +import sleep from 'sleep'; +import chai from 'chai'; +import server from '../../app'; +import models from '../../models'; +import testUtil from '../../tests/util'; + +const ES_PROJECT_INDEX = config.get('elasticsearchConfig.indexName'); +const ES_PROJECT_TYPE = config.get('elasticsearchConfig.docType'); + +const should = chai.should(); + +const body = { + name: 'test project phase', + 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', + }, + createdBy: 1, + updatedBy: 1, +}; + +describe('Project Phases', () => { + let projectId; + let project; + before(function beforeHook(done) { + this.timeout(10000); + // mocks + testUtil.clearDb() + .then(() => { + models.Project.create({ + type: 'generic', + billingAccountId: 1, + name: 'test1', + description: 'test project1', + status: 'draft', + details: {}, + createdBy: 1, + updatedBy: 1, + }).then((p) => { + projectId = p.id; + project = p.toJSON(); + // create members + models.ProjectMember.create({ + userId: 40051332, + projectId, + role: 'copilot', + isPrimary: true, + createdBy: 1, + updatedBy: 1, + }).then(() => { + _.assign(body, { projectId }); + return models.ProjectPhase.create(body); + }).then((phase) => { + // Index to ES + project.phases = [phase]; + return server.services.es.index({ + index: ES_PROJECT_INDEX, + type: ES_PROJECT_TYPE, + id: projectId, + body: project, + }).then(() => { + // sleep for some time, let elasticsearch indices be settled + sleep.sleep(5); + done(); + }); + }); + }); + }); + }); + + after((done) => { + testUtil.clearDb(done); + }); + + describe('GET /projects/{id}/phases/', () => { + it('should return 403 when user have no permission', (done) => { + request(server) + .get(`/v4/projects/${projectId}/phases/`) + .set({ + Authorization: `Bearer ${testUtil.jwts.member}`, + }) + .send({ param: body }) + .expect('Content-Type', /json/) + .expect(403, done); + }); + + it('should return 404 when no project with specific projectId', (done) => { + request(server) + .get('/v4/projects/999/phases/') + .set({ + Authorization: `Bearer ${testUtil.jwts.manager}`, + }) + .send({ param: body }) + .expect('Content-Type', /json/) + .expect(404, done); + }); + + it('should return 1 phase when user have project permission', (done) => { + request(server) + .get(`/v4/projects/${projectId}/phases/`) + .set({ + Authorization: `Bearer ${testUtil.jwts.copilot}`, + }) + .send({ param: body }) + .expect('Content-Type', /json/) + .expect(200) + .end((err, res) => { + if (err) { + done(err); + } else { + const resJson = res.body.result.content; + should.exist(resJson); + resJson.should.have.lengthOf(1); + done(); + } + }); + }); + }); +}); diff --git a/src/routes/phases/update.js b/src/routes/phases/update.js new file mode 100644 index 00000000..96b589d3 --- /dev/null +++ b/src/routes/phases/update.js @@ -0,0 +1,98 @@ + +import validate from 'express-validation'; +import _ from 'lodash'; +import Joi from 'joi'; +import { middleware as tcMiddleware } from 'tc-core-library-js'; +import models from '../../models'; +import util from '../../util'; +import { EVENT } from '../../constants'; + + +const permissions = tcMiddleware.permissions; + +const updateProjectPhaseValidation = { + body: { + param: Joi.object().keys({ + name: Joi.string().optional(), + status: Joi.string().optional(), + startDate: Joi.date().optional(), + endDate: Joi.date().optional(), + budget: Joi.number().positive().optional(), + progress: Joi.number().positive().optional(), + details: Joi.any().optional(), + }).required(), + }, +}; + + +module.exports = [ + // validate request payload + validate(updateProjectPhaseValidation), + // check permission + permissions('project.updateProjectPhase'), + + (req, res, next) => { + const projectId = _.parseInt(req.params.projectId); + const phaseId = _.parseInt(req.params.phaseId); + + const updatedProps = req.body.param; + updatedProps.updatedBy = req.authUser.userId; + + let previousValue; + + models.sequelize.transaction(() => models.ProjectPhase.findOne({ + where: { + id: phaseId, + projectId, + deletedAt: { $eq: null }, + }, + }).then(existing => new Promise((accept, reject) => { + if (!existing) { + // handle 404 + const err = new Error('No active project phase found for project id ' + + `${projectId} and phase id ${phaseId}`); + err.status = 404; + reject(err); + } else { + previousValue = _.clone(existing.get({ plain: true })); + + // make sure startDate < endDate + let startDate; + let endDate; + if (updatedProps.startDate) { + startDate = new Date(updatedProps.startDate); + } else { + startDate = new Date(existing.startDate); + } + + if (updatedProps.endDate) { + endDate = new Date(updatedProps.endDate); + } else { + endDate = new Date(existing.endDate); + } + + if (startDate >= endDate) { + const err = new Error('startDate must be before endDate.'); + err.status = 400; + reject(err); + } else { + _.extend(existing, updatedProps); + existing.save().then(accept).catch(reject); + } + } + })).then((updated) => { + req.log.debug('updated project phase', JSON.stringify(updated, null, 2)); + + // emit original and updated project phase information + req.app.services.pubsub.publish( + EVENT.ROUTING_KEY.PROJECT_PHASE_UPDATED, + { original: previousValue, updated }, + { correlationId: req.id }, + ); + req.app.emit(EVENT.ROUTING_KEY.PROJECT_PHASE_UPDATED, + { req, original: previousValue, updated }); + + res.json(util.wrapResponse(req.id, updated)); + }).catch(err => next(err))); + }, +]; diff --git a/src/routes/phases/update.spec.js b/src/routes/phases/update.spec.js new file mode 100644 index 00000000..a129255e --- /dev/null +++ b/src/routes/phases/update.spec.js @@ -0,0 +1,167 @@ +/* eslint-disable no-unused-expressions */ +import _ from 'lodash'; +import chai from 'chai'; +import request from 'supertest'; +import server from '../../app'; +import models from '../../models'; +import testUtil from '../../tests/util'; + +const should = chai.should(); + +const body = { + name: 'test project phase', + 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', + }, + createdBy: 1, + updatedBy: 1, +}; + +const updateBody = { + name: 'test project phase xxx', + status: 'inactive', + startDate: '2018-05-11T00:00:00Z', + endDate: '2018-05-12T12:00:00Z', + budget: 123456.789, + progress: 9.8765432, + details: { + message: 'This is another json', + }, +}; + +describe('Project Phases', () => { + let projectId; + let phaseId; + before((done) => { + // mocks + testUtil.clearDb() + .then(() => { + models.Project.create({ + type: 'generic', + billingAccountId: 1, + name: 'test1', + description: 'test project1', + status: 'draft', + details: {}, + createdBy: 1, + updatedBy: 1, + }).then((p) => { + projectId = p.id; + // create members + models.ProjectMember.create({ + userId: 40051332, + projectId, + role: 'copilot', + isPrimary: true, + createdBy: 1, + updatedBy: 1, + }).then(() => { + _.assign(body, { projectId }); + models.ProjectPhase.create(body).then((phase) => { + phaseId = phase.id; + done(); + }); + }); + }); + }); + }); + + after((done) => { + testUtil.clearDb(done); + }); + + describe('PATCH /projects/{projectId}/phases/{phaseId}', () => { + it('should return 403 when user have no permission', (done) => { + request(server) + .patch(`/v4/projects/${projectId}/phases/${phaseId}`) + .set({ + Authorization: `Bearer ${testUtil.jwts.member}`, + }) + .send({ param: updateBody }) + .expect('Content-Type', /json/) + .expect(403, done); + }); + + it('should return 404 when no project with specific projectId', (done) => { + request(server) + .patch(`/v4/projects/999/phases/${phaseId}`) + .set({ + Authorization: `Bearer ${testUtil.jwts.manager}`, + }) + .send({ param: updateBody }) + .expect('Content-Type', /json/) + .expect(404, done); + }); + + it('should return 404 when no phase with specific phaseId', (done) => { + request(server) + .patch(`/v4/projects/${projectId}/phases/999`) + .set({ + Authorization: `Bearer ${testUtil.jwts.manager}`, + }) + .send({ param: updateBody }) + .expect('Content-Type', /json/) + .expect(404, done); + }); + + it('should return 422 when parameters are invalid', (done) => { + request(server) + .patch(`/v4/projects/${projectId}/phases/${phaseId}`) + .set({ + Authorization: `Bearer ${testUtil.jwts.manager}`, + }) + .send({ + param: { + progress: -15, + }, + }) + .expect('Content-Type', /json/) + .expect(422, done); + }); + + it('should return 400 when startDate >= endDate', (done) => { + request(server) + .patch(`/v4/projects/${projectId}/phases/${phaseId}`) + .set({ + Authorization: `Bearer ${testUtil.jwts.manager}`, + }) + .send({ + param: { + endDate: '2018-05-13T00:00:00Z', + }, + }) + .expect('Content-Type', /json/) + .expect(400, done); + }); + + it('should return updated phase when user have permission and parameters are valid', (done) => { + request(server) + .patch(`/v4/projects/${projectId}/phases/${phaseId}`) + .set({ + Authorization: `Bearer ${testUtil.jwts.copilot}`, + }) + .send({ param: updateBody }) + .expect('Content-Type', /json/) + .expect(200) + .end((err, res) => { + if (err) { + done(err); + } else { + const resJson = res.body.result.content; + should.exist(resJson); + resJson.name.should.be.eql(updateBody.name); + resJson.status.should.be.eql(updateBody.status); + resJson.budget.should.be.eql(updateBody.budget); + resJson.progress.should.be.eql(updateBody.progress); + resJson.details.should.be.eql(updateBody.details); + done(); + } + }); + }); + }); +}); diff --git a/src/routes/productTemplates/create.js b/src/routes/productTemplates/create.js new file mode 100644 index 00000000..b00363e3 --- /dev/null +++ b/src/routes/productTemplates/create.js @@ -0,0 +1,51 @@ +/** + * API to add a product template + */ +import validate from 'express-validation'; +import _ from 'lodash'; +import Joi from 'joi'; +import { middleware as tcMiddleware } from 'tc-core-library-js'; +import util from '../../util'; +import models from '../../models'; + +const permissions = tcMiddleware.permissions; + +const schema = { + body: { + param: Joi.object().keys({ + id: Joi.any().strip(), + name: Joi.string().max(255).required(), + productKey: Joi.string().max(45).required(), + icon: Joi.string().max(255).required(), + brief: Joi.string().max(45).required(), + details: Joi.string().max(255).required(), + aliases: Joi.object().required(), + template: Joi.object().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), + permissions('productTemplate.create'), + (req, res, next) => { + const entity = _.assign(req.body.param, { + createdBy: req.authUser.userId, + updatedBy: req.authUser.userId, + }); + + return models.ProductTemplate.create(entity) + .then((createdEntity) => { + // Omit deletedAt, deletedBy + res.status(201).json(util.wrapResponse( + req.id, _.omit(createdEntity.toJSON(), 'deletedAt', 'deletedBy'), 1, 201)); + }) + .catch(next); + }, +]; diff --git a/src/routes/productTemplates/create.spec.js b/src/routes/productTemplates/create.spec.js new file mode 100644 index 00000000..8476a5ef --- /dev/null +++ b/src/routes/productTemplates/create.spec.js @@ -0,0 +1,157 @@ +/** + * Tests for create.js + */ +import chai from 'chai'; +import request from 'supertest'; + +import server from '../../app'; +import testUtil from '../../tests/util'; + +const should = chai.should(); + +describe('CREATE product template', () => { + describe('POST /productTemplates', () => { + const body = { + param: { + 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'], + }, + }, + }, + }; + + it('should return 403 if user is not authenticated', (done) => { + request(server) + .post('/v4/productTemplates') + .send(body) + .expect(403, done); + }); + + it('should return 403 for member', (done) => { + request(server) + .post('/v4/productTemplates') + .set({ + Authorization: `Bearer ${testUtil.jwts.member}`, + }) + .send(body) + .expect(403, done); + }); + + it('should return 403 for copilot', (done) => { + request(server) + .post('/v4/productTemplates') + .set({ + Authorization: `Bearer ${testUtil.jwts.copilot}`, + }) + .send(body) + .expect(403, done); + }); + + it('should return 422 if validations dont pass', (done) => { + const invalidBody = { + param: { + aliases: 'a', + template: 1, + }, + }; + + request(server) + .post('/v4/productTemplates') + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .send(invalidBody) + .expect('Content-Type', /json/) + .expect(422, done); + }); + + it('should return 201 for admin', (done) => { + request(server) + .post('/v4/productTemplates') + .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.productKey.should.be.eql(body.param.productKey); + resJson.icon.should.be.eql(body.param.icon); + resJson.brief.should.be.eql(body.param.brief); + resJson.details.should.be.eql(body.param.details); + resJson.aliases.should.be.eql(body.param.aliases); + resJson.template.should.be.eql(body.param.template); + + resJson.createdBy.should.be.eql(40051333); // admin + should.exist(resJson.createdAt); + resJson.updatedBy.should.be.eql(40051333); // admin + should.exist(resJson.updatedAt); + should.not.exist(resJson.deletedBy); + should.not.exist(resJson.deletedAt); + + done(); + }); + }); + + it('should return 201 for connect manager', (done) => { + request(server) + .post('/v4/productTemplates') + .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') + .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/productTemplates/delete.js b/src/routes/productTemplates/delete.js new file mode 100644 index 00000000..81c65b6b --- /dev/null +++ b/src/routes/productTemplates/delete.js @@ -0,0 +1,55 @@ +/** + * API to delete a product 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: { + templateId: Joi.number().integer().positive().required(), + }, +}; + +module.exports = [ + validate(schema), + permissions('productTemplate.delete'), + (req, res, next) => { + const where = { + deletedAt: { $eq: null }, + id: req.params.templateId, + }; + + return models.sequelize.transaction(tx => + // Update the deletedBy + models.ProductTemplate.update({ deletedBy: req.authUser.userId }, { + where, + returning: true, + raw: true, + transaction: tx, + }) + .then((updatedResults) => { + // Not found + if (updatedResults[0] === 0) { + const apiErr = new Error(`Product template not found for template id ${req.params.templateId}`); + apiErr.status = 404; + return Promise.reject(apiErr); + } + + // Soft delete + return models.ProductTemplate.destroy({ + where, + transaction: tx, + raw: true, + }); + }) + .then(() => { + res.status(204).end(); + }) + .catch(next), + ); + }, +]; diff --git a/src/routes/productTemplates/delete.spec.js b/src/routes/productTemplates/delete.spec.js new file mode 100644 index 00000000..058fea4c --- /dev/null +++ b/src/routes/productTemplates/delete.spec.js @@ -0,0 +1,129 @@ +/** + * Tests for delete.js + */ +import request from 'supertest'; + +import models from '../../models'; +import server from '../../app'; +import testUtil from '../../tests/util'; + + +describe('DELETE product template', () => { + let templateId; + + beforeEach(() => testUtil.clearDb() + .then(() => models.ProductTemplate.create({ + 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, + })).then((template) => { + templateId = template.id; + return Promise.resolve(); + }), + ); + after(testUtil.clearDb); + + describe('DELETE /productTemplates/{templateId}', () => { + it('should return 403 if user is not authenticated', (done) => { + request(server) + .delete(`/v4/productTemplates/${templateId}`) + .expect(403, done); + }); + + it('should return 403 for member', (done) => { + request(server) + .delete(`/v4/productTemplates/${templateId}`) + .set({ + Authorization: `Bearer ${testUtil.jwts.member}`, + }) + .expect(403, done); + }); + + it('should return 403 for copilot', (done) => { + request(server) + .delete(`/v4/productTemplates/${templateId}`) + .set({ + Authorization: `Bearer ${testUtil.jwts.copilot}`, + }) + .expect(403, done); + }); + + it('should return 404 for non-existed template', (done) => { + request(server) + .delete('/v4/productTemplates/1234') + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .expect(404, done); + }); + + it('should return 404 for deleted template', (done) => { + models.ProductTemplate.destroy({ where: { id: templateId } }) + .then(() => { + request(server) + .delete(`/v4/productTemplates/${templateId}`) + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .expect(404, done); + }); + }); + + it('should return 204, for admin, if template was successfully removed', (done) => { + request(server) + .delete(`/v4/productTemplates/${templateId}`) + .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/${templateId}`) + .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/${templateId}`) + .set({ + Authorization: `Bearer ${testUtil.jwts.manager}`, + }) + .expect(204) + .end(done); + }); + }); +}); diff --git a/src/routes/productTemplates/get.js b/src/routes/productTemplates/get.js new file mode 100644 index 00000000..e660f979 --- /dev/null +++ b/src/routes/productTemplates/get.js @@ -0,0 +1,41 @@ +/** + * API to get a product 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: { + templateId: Joi.number().integer().positive().required(), + }, +}; + +module.exports = [ + validate(schema), + permissions('productTemplate.view'), + (req, res, next) => models.ProductTemplate.findOne({ + where: { + deletedAt: { $eq: null }, + id: req.params.templateId, + }, + attributes: { exclude: ['deletedAt', 'deletedBy'] }, + raw: true, + }) + .then((productTemplate) => { + // Not found + if (!productTemplate) { + const apiErr = new Error(`Product template not found for product id ${req.params.templateId}`); + apiErr.status = 404; + return Promise.reject(apiErr); + } + + res.json(util.wrapResponse(req.id, productTemplate)); + return Promise.resolve(); + }) + .catch(next), +]; diff --git a/src/routes/productTemplates/get.spec.js b/src/routes/productTemplates/get.spec.js new file mode 100644 index 00000000..0fbdf37e --- /dev/null +++ b/src/routes/productTemplates/get.spec.js @@ -0,0 +1,153 @@ +/** + * Tests for get.js + */ +import chai from 'chai'; +import request from 'supertest'; + +import models from '../../models'; +import server from '../../app'; +import testUtil from '../../tests/util'; + +const should = chai.should(); + +describe('GET product template', () => { + const template = { + 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, + }; + + let templateId; + + beforeEach(() => testUtil.clearDb() + .then(() => models.ProductTemplate.create(template)) + .then((createdTemplate) => { + templateId = createdTemplate.id; + return Promise.resolve(); + }), + ); + after(testUtil.clearDb); + + describe('GET /productTemplates/{templateId}', () => { + it('should return 403 if user is not authenticated', (done) => { + request(server) + .get(`/v4/productTemplates/${templateId}`) + .expect(403, done); + }); + + it('should return 404 for non-existed template', (done) => { + request(server) + .get('/v4/productTemplates/1234') + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .expect(404, done); + }); + + it('should return 404 for deleted template', (done) => { + models.ProductTemplate.destroy({ where: { id: templateId } }) + .then(() => { + request(server) + .get(`/v4/productTemplates/${templateId}`) + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .expect(404, done); + }); + }); + + it('should return 200 for admin', (done) => { + request(server) + .get(`/v4/productTemplates/${templateId}`) + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .expect(200) + .end((err, res) => { + const resJson = res.body.result.content; + resJson.id.should.be.eql(templateId); + resJson.name.should.be.eql(template.name); + resJson.productKey.should.be.eql(template.productKey); + resJson.icon.should.be.eql(template.icon); + resJson.brief.should.be.eql(template.brief); + resJson.details.should.be.eql(template.details); + resJson.aliases.should.be.eql(template.aliases); + resJson.template.should.be.eql(template.template); + + resJson.createdBy.should.be.eql(template.createdBy); + should.exist(resJson.createdAt); + resJson.updatedBy.should.be.eql(template.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/${templateId}`) + .set({ + Authorization: `Bearer ${testUtil.jwts.connectAdmin}`, + }) + .expect(200) + .end(done); + }); + + it('should return 200 for connect manager', (done) => { + request(server) + .get(`/v4/productTemplates/${templateId}`) + .set({ + Authorization: `Bearer ${testUtil.jwts.manager}`, + }) + .expect(200) + .end(done); + }); + + it('should return 200 for member', (done) => { + request(server) + .get(`/v4/productTemplates/${templateId}`) + .set({ + Authorization: `Bearer ${testUtil.jwts.member}`, + }) + .expect(200, done); + }); + + it('should return 200 for copilot', (done) => { + request(server) + .get(`/v4/productTemplates/${templateId}`) + .set({ + Authorization: `Bearer ${testUtil.jwts.copilot}`, + }) + .expect(200, done); + }); + }); +}); diff --git a/src/routes/productTemplates/list.js b/src/routes/productTemplates/list.js new file mode 100644 index 00000000..11a9e276 --- /dev/null +++ b/src/routes/productTemplates/list.js @@ -0,0 +1,23 @@ +/** + * API to list all product templates + */ +import { middleware as tcMiddleware } from 'tc-core-library-js'; +import util from '../../util'; +import models from '../../models'; + +const permissions = tcMiddleware.permissions; + +module.exports = [ + permissions('productTemplate.view'), + (req, res, next) => models.ProductTemplate.findAll({ + where: { + deletedAt: { $eq: null }, + }, + attributes: { exclude: ['deletedAt', 'deletedBy'] }, + raw: true, + }) + .then((productTemplates) => { + res.json(util.wrapResponse(req.id, productTemplates)); + }) + .catch(next), +]; diff --git a/src/routes/productTemplates/list.spec.js b/src/routes/productTemplates/list.spec.js new file mode 100644 index 00000000..e487d777 --- /dev/null +++ b/src/routes/productTemplates/list.spec.js @@ -0,0 +1,148 @@ +/** + * Tests for list.js + */ +import chai from 'chai'; +import request from 'supertest'; + +import models from '../../models'; +import server from '../../app'; +import testUtil from '../../tests/util'; + +const should = chai.should(); + +describe('LIST product templates', () => { + const templates = [ + { + 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, + }, + ]; + + let templateId; + + beforeEach(() => testUtil.clearDb() + .then(() => models.ProductTemplate.create(templates[0])) + .then((createdTemplate) => { + templateId = createdTemplate.id; + return models.ProductTemplate.create(templates[1]); + }).then(() => Promise.resolve()), + ); + after(testUtil.clearDb); + + describe('GET /productTemplates', () => { + it('should return 403 if user is not authenticated', (done) => { + request(server) + .get('/v4/productTemplates') + .expect(403, done); + }); + + it('should return 200 for admin', (done) => { + request(server) + .get('/v4/productTemplates') + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .expect(200) + .end((err, res) => { + const template = templates[0]; + + const resJson = res.body.result.content; + resJson.should.have.length(2); + resJson[0].id.should.be.eql(templateId); + resJson[0].name.should.be.eql(template.name); + resJson[0].productKey.should.be.eql(template.productKey); + resJson[0].icon.should.be.eql(template.icon); + resJson[0].brief.should.be.eql(template.brief); + resJson[0].details.should.be.eql(template.details); + resJson[0].aliases.should.be.eql(template.aliases); + resJson[0].template.should.be.eql(template.template); + + resJson[0].createdBy.should.be.eql(template.createdBy); + should.exist(resJson[0].createdAt); + resJson[0].updatedBy.should.be.eql(template.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') + .set({ + Authorization: `Bearer ${testUtil.jwts.connectAdmin}`, + }) + .expect(200) + .end(done); + }); + + it('should return 200 for connect manager', (done) => { + request(server) + .get('/v4/productTemplates') + .set({ + Authorization: `Bearer ${testUtil.jwts.manager}`, + }) + .expect(200) + .end(done); + }); + + it('should return 200 for member', (done) => { + request(server) + .get('/v4/productTemplates') + .set({ + Authorization: `Bearer ${testUtil.jwts.member}`, + }) + .expect(200, done); + }); + + it('should return 200 for copilot', (done) => { + request(server) + .get('/v4/productTemplates') + .set({ + Authorization: `Bearer ${testUtil.jwts.copilot}`, + }) + .expect(200, done); + }); + }); +}); diff --git a/src/routes/productTemplates/update.js b/src/routes/productTemplates/update.js new file mode 100644 index 00000000..c5ebf633 --- /dev/null +++ b/src/routes/productTemplates/update.js @@ -0,0 +1,72 @@ +/** + * API to update a product template + */ +import validate from 'express-validation'; +import _ from 'lodash'; +import Joi from 'joi'; +import { middleware as tcMiddleware } from 'tc-core-library-js'; +import util from '../../util'; +import models from '../../models'; + +const permissions = tcMiddleware.permissions; + +const schema = { + params: { + templateId: Joi.number().integer().positive().required(), + }, + body: { + param: Joi.object().keys({ + id: Joi.any().strip(), + name: Joi.string().max(255).required(), + productKey: Joi.string().max(45).required(), + icon: Joi.string().max(255).required(), + brief: Joi.string().max(45).required(), + details: Joi.string().max(255).required(), + aliases: Joi.object().required(), + template: Joi.object().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), + permissions('productTemplate.edit'), + (req, res, next) => { + const entityToUpdate = _.assign(req.body.param, { + updatedBy: req.authUser.userId, + }); + + return models.ProductTemplate.findOne({ + where: { + deletedAt: { $eq: null }, + id: req.params.templateId, + }, + attributes: { exclude: ['deletedAt', 'deletedBy'] }, + }) + .then((productTemplate) => { + // Not found + if (!productTemplate) { + const apiErr = new Error(`Product template not found for template id ${req.params.templateId}`); + apiErr.status = 404; + return Promise.reject(apiErr); + } + + // Merge JSON fields + entityToUpdate.aliases = util.mergeJsonObjects(productTemplate.aliases, entityToUpdate.aliases); + entityToUpdate.template = util.mergeJsonObjects(productTemplate.template, entityToUpdate.template); + + return productTemplate.update(entityToUpdate); + }) + .then((productTemplate) => { + res.json(util.wrapResponse(req.id, productTemplate)); + return Promise.resolve(); + }) + .catch(next); + }, +]; diff --git a/src/routes/productTemplates/update.spec.js b/src/routes/productTemplates/update.spec.js new file mode 100644 index 00000000..0d5c5e0e --- /dev/null +++ b/src/routes/productTemplates/update.spec.js @@ -0,0 +1,244 @@ +/** + * 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('UPDATE product template', () => { + const template = { + 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, + }; + + let templateId; + + beforeEach(() => testUtil.clearDb() + .then(() => models.ProductTemplate.create(template)) + .then((createdTemplate) => { + templateId = createdTemplate.id; + return Promise.resolve(); + }), + ); + after(testUtil.clearDb); + + describe('PATCH /productTemplates/{templateId}', () => { + const body = { + param: { + name: 'template 1 - update', + productKey: 'productKey 1 - update', + icon: 'http://example.com/icon1-update.ico', + brief: 'brief 1 - update', + details: 'details 1 - update', + aliases: { + alias1: { + subAlias1A: 11, + subAlias1C: 'new', + }, + alias2: [4], + alias3: 'new', + }, + template: { + template1: { + name: 'template 1 - update', + details: { + anyDetails: 'any details 1 - update', + newDetails: 'new', + }, + others: ['others new'], + }, + template3: { + name: 'template 3', + details: { + anyDetails: 'any details 3', + }, + others: ['others 31', 'others 32'], + }, + }, + }, + }; + + it('should return 403 if user is not authenticated', (done) => { + request(server) + .patch(`/v4/productTemplates/${templateId}`) + .send(body) + .expect(403, done); + }); + + it('should return 403 for member', (done) => { + request(server) + .patch(`/v4/productTemplates/${templateId}`) + .set({ + Authorization: `Bearer ${testUtil.jwts.member}`, + }) + .send(body) + .expect(403, done); + }); + + it('should return 403 for copilot', (done) => { + request(server) + .patch(`/v4/productTemplates/${templateId}`) + .send(body) + .set({ + Authorization: `Bearer ${testUtil.jwts.copilot}`, + }) + .expect(403, done); + }); + + it('should return 422 for invalid request', (done) => { + const invalidBody = { + param: { + aliases: 'a', + template: 1, + }, + }; + + request(server) + .patch(`/v4/productTemplates/${templateId}`) + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .send(invalidBody) + .expect(422, done); + }); + + it('should return 404 for non-existed template', (done) => { + request(server) + .patch('/v4/productTemplates/1234') + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .send(body) + .expect(404, done); + }); + + it('should return 404 for deleted template', (done) => { + models.ProductTemplate.destroy({ where: { id: templateId } }) + .then(() => { + request(server) + .patch(`/v4/productTemplates/${templateId}`) + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .send(body) + .expect(404, done); + }); + }); + + it('should return 200 for admin', (done) => { + request(server) + .patch(`/v4/productTemplates/${templateId}`) + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .send(body) + .expect(200) + .end((err, res) => { + const resJson = res.body.result.content; + resJson.id.should.be.eql(templateId); + resJson.name.should.be.eql(body.param.name); + resJson.productKey.should.be.eql(body.param.productKey); + resJson.icon.should.be.eql(body.param.icon); + resJson.brief.should.be.eql(body.param.brief); + resJson.details.should.be.eql(body.param.details); + + resJson.aliases.should.be.eql({ + alias1: { + subAlias1A: 11, + subAlias1B: 2, + subAlias1C: 'new', + }, + alias2: [4], + alias3: 'new', + }); + resJson.template.should.be.eql({ + template1: { + name: 'template 1 - update', + details: { + anyDetails: 'any details 1 - update', + newDetails: 'new', + }, + others: ['others new'], + }, + template2: { + name: 'template 2', + details: { + anyDetails: 'any details 2', + }, + others: ['others 21', 'others 22'], + }, + template3: { + name: 'template 3', + details: { + anyDetails: 'any details 3', + }, + others: ['others 31', 'others 32'], + }, + }); + resJson.createdBy.should.be.eql(template.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(); + }); + }); + + it('should return 200 for connect admin', (done) => { + request(server) + .patch(`/v4/productTemplates/${templateId}`) + .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/${templateId}`) + .set({ + Authorization: `Bearer ${testUtil.jwts.manager}`, + }) + .send(body) + .expect(200) + .end(done); + }); + }); +}); diff --git a/src/routes/projectTemplates/create.js b/src/routes/projectTemplates/create.js new file mode 100644 index 00000000..4c19fc0f --- /dev/null +++ b/src/routes/projectTemplates/create.js @@ -0,0 +1,49 @@ +/** + * API to add a project template + */ +import validate from 'express-validation'; +import _ from 'lodash'; +import Joi from 'joi'; +import { middleware as tcMiddleware } from 'tc-core-library-js'; +import util from '../../util'; +import models from '../../models'; + +const permissions = tcMiddleware.permissions; + +const schema = { + body: { + param: Joi.object().keys({ + id: Joi.any().strip(), + name: Joi.string().max(255).required(), + key: Joi.string().max(45).required(), + category: Joi.string().max(45).required(), + scope: Joi.object().required(), + phases: Joi.object().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), + permissions('projectTemplate.create'), + (req, res, next) => { + const entity = _.assign(req.body.param, { + createdBy: req.authUser.userId, + updatedBy: req.authUser.userId, + }); + + return models.ProjectTemplate.create(entity) + .then((createdEntity) => { + // Omit deletedAt, deletedBy + res.status(201).json(util.wrapResponse( + req.id, _.omit(createdEntity.toJSON(), 'deletedAt', 'deletedBy'), 1, 201)); + }) + .catch(next); + }, +]; diff --git a/src/routes/projectTemplates/create.spec.js b/src/routes/projectTemplates/create.spec.js new file mode 100644 index 00000000..afb46113 --- /dev/null +++ b/src/routes/projectTemplates/create.spec.js @@ -0,0 +1,153 @@ +/** + * Tests for create.js + */ +import chai from 'chai'; +import request from 'supertest'; + +import server from '../../app'; +import testUtil from '../../tests/util'; + +const should = chai.should(); + +describe('CREATE project template', () => { + describe('POST /projectTemplates', () => { + const body = { + param: { + name: 'template 1', + key: 'key 1', + category: 'category 1', + scope: { + scope1: { + subScope1A: 1, + subScope1B: 2, + }, + scope2: [1, 2, 3], + }, + phases: { + phase1: { + name: 'phase 1', + details: { + anyDetails: 'any details 1', + }, + others: ['others 11', 'others 12'], + }, + phase2: { + name: 'phase 2', + details: { + anyDetails: 'any details 2', + }, + others: ['others 21', 'others 22'], + }, + }, + }, + }; + + it('should return 403 if user is not authenticated', (done) => { + request(server) + .post('/v4/projectTemplates') + .send(body) + .expect(403, done); + }); + + it('should return 403 for member', (done) => { + request(server) + .post('/v4/projectTemplates') + .set({ + Authorization: `Bearer ${testUtil.jwts.member}`, + }) + .send(body) + .expect(403, done); + }); + + it('should return 403 for copilot', (done) => { + request(server) + .post('/v4/projectTemplates') + .set({ + Authorization: `Bearer ${testUtil.jwts.copilot}`, + }) + .send(body) + .expect(403, done); + }); + + it('should return 422 if validations dont pass', (done) => { + const invalidBody = { + param: { + scope: 'a', + phases: 1, + }, + }; + + request(server) + .post('/v4/projectTemplates') + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .send(invalidBody) + .expect('Content-Type', /json/) + .expect(422, done); + }); + + it('should return 201 for admin', (done) => { + request(server) + .post('/v4/projectTemplates') + .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.key.should.be.eql(body.param.key); + resJson.category.should.be.eql(body.param.category); + resJson.scope.should.be.eql(body.param.scope); + resJson.phases.should.be.eql(body.param.phases); + + resJson.createdBy.should.be.eql(40051333); // admin + should.exist(resJson.createdAt); + resJson.updatedBy.should.be.eql(40051333); // admin + should.exist(resJson.updatedAt); + should.not.exist(resJson.deletedBy); + should.not.exist(resJson.deletedAt); + + done(); + }); + }); + + it('should return 201 for connect manager', (done) => { + request(server) + .post('/v4/projectTemplates') + .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/projectTemplates') + .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/projectTemplates/delete.js b/src/routes/projectTemplates/delete.js new file mode 100644 index 00000000..4db9a855 --- /dev/null +++ b/src/routes/projectTemplates/delete.js @@ -0,0 +1,55 @@ +/** + * API to delete a project 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: { + templateId: Joi.number().integer().positive().required(), + }, +}; + +module.exports = [ + validate(schema), + permissions('projectTemplate.delete'), + (req, res, next) => { + const where = { + deletedAt: { $eq: null }, + id: req.params.templateId, + }; + + return models.sequelize.transaction(tx => + // Update the deletedBy + models.ProjectTemplate.update({ deletedBy: req.authUser.userId }, { + where, + returning: true, + raw: true, + transaction: tx, + }) + .then((updatedResults) => { + // Not found + if (updatedResults[0] === 0) { + const apiErr = new Error(`Project template not found for template id ${req.params.templateId}`); + apiErr.status = 404; + return Promise.reject(apiErr); + } + + // Soft delete + return models.ProjectTemplate.destroy({ + where, + transaction: tx, + raw: true, + }); + }) + .then(() => { + res.status(204).end(); + }) + .catch(next), + ); + }, +]; diff --git a/src/routes/projectTemplates/delete.spec.js b/src/routes/projectTemplates/delete.spec.js new file mode 100644 index 00000000..27973d7e --- /dev/null +++ b/src/routes/projectTemplates/delete.spec.js @@ -0,0 +1,127 @@ +/** + * Tests for delete.js + */ +import request from 'supertest'; + +import models from '../../models'; +import server from '../../app'; +import testUtil from '../../tests/util'; + + +describe('DELETE project template', () => { + let templateId; + + beforeEach(() => testUtil.clearDb() + .then(() => models.ProjectTemplate.create({ + name: 'template 1', + key: 'key 1', + category: 'category 1', + scope: { + scope1: { + subScope1A: 1, + subScope1B: 2, + }, + scope2: [1, 2, 3], + }, + phases: { + phase1: { + name: 'phase 1', + details: { + anyDetails: 'any details 1', + }, + others: ['others 11', 'others 12'], + }, + phase2: { + name: 'phase 2', + details: { + anyDetails: 'any details 2', + }, + others: ['others 21', 'others 22'], + }, + }, + createdBy: 1, + updatedBy: 1, + })).then((template) => { + templateId = template.id; + return Promise.resolve(); + }), + ); + after(testUtil.clearDb); + + describe('DELETE /projectTemplates/{templateId}', () => { + it('should return 403 if user is not authenticated', (done) => { + request(server) + .delete(`/v4/projectTemplates/${templateId}`) + .expect(403, done); + }); + + it('should return 403 for member', (done) => { + request(server) + .delete(`/v4/projectTemplates/${templateId}`) + .set({ + Authorization: `Bearer ${testUtil.jwts.member}`, + }) + .expect(403, done); + }); + + it('should return 403 for copilot', (done) => { + request(server) + .delete(`/v4/projectTemplates/${templateId}`) + .set({ + Authorization: `Bearer ${testUtil.jwts.copilot}`, + }) + .expect(403, done); + }); + + it('should return 404 for non-existed template', (done) => { + request(server) + .delete('/v4/projectTemplates/1234') + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .expect(404, done); + }); + + it('should return 404 for deleted template', (done) => { + models.ProjectTemplate.destroy({ where: { id: templateId } }) + .then(() => { + request(server) + .delete(`/v4/projectTemplates/${templateId}`) + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .expect(404, done); + }); + }); + + it('should return 204, for admin, if template was successfully removed', (done) => { + request(server) + .delete(`/v4/projectTemplates/${templateId}`) + .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/projectTemplates/${templateId}`) + .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/projectTemplates/${templateId}`) + .set({ + Authorization: `Bearer ${testUtil.jwts.manager}`, + }) + .expect(204) + .end(done); + }); + }); +}); diff --git a/src/routes/projectTemplates/get.js b/src/routes/projectTemplates/get.js new file mode 100644 index 00000000..e81c0939 --- /dev/null +++ b/src/routes/projectTemplates/get.js @@ -0,0 +1,41 @@ +/** + * API to get a project 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: { + templateId: Joi.number().integer().positive().required(), + }, +}; + +module.exports = [ + validate(schema), + permissions('projectTemplate.view'), + (req, res, next) => models.ProjectTemplate.findOne({ + where: { + deletedAt: { $eq: null }, + id: req.params.templateId, + }, + attributes: { exclude: ['deletedAt', 'deletedBy'] }, + raw: true, + }) + .then((projectTemplate) => { + // Not found + if (!projectTemplate) { + const apiErr = new Error(`Project template not found for project id ${req.params.templateId}`); + apiErr.status = 404; + return Promise.reject(apiErr); + } + + res.json(util.wrapResponse(req.id, projectTemplate)); + return Promise.resolve(); + }) + .catch(next), +]; diff --git a/src/routes/projectTemplates/get.spec.js b/src/routes/projectTemplates/get.spec.js new file mode 100644 index 00000000..4c9b2ccf --- /dev/null +++ b/src/routes/projectTemplates/get.spec.js @@ -0,0 +1,148 @@ +/** + * 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 project template', () => { + const template = { + name: 'template 1', + key: 'key 1', + category: 'category 1', + scope: { + scope1: { + subScope1A: 1, + subScope1B: 2, + }, + scope2: [1, 2, 3], + }, + phases: { + phase1: { + name: 'phase 1', + details: { + anyDetails: 'any details 1', + }, + others: ['others 11', 'others 12'], + }, + phase2: { + name: 'phase 2', + details: { + anyDetails: 'any details 2', + }, + others: ['others 21', 'others 22'], + }, + }, + createdBy: 1, + updatedBy: 1, + }; + + let templateId; + + beforeEach(() => testUtil.clearDb() + .then(() => models.ProjectTemplate.create(template)) + .then((createdTemplate) => { + templateId = createdTemplate.id; + return Promise.resolve(); + }), + ); + after(testUtil.clearDb); + + describe('GET /projectTemplates/{templateId}', () => { + it('should return 403 if user is not authenticated', (done) => { + request(server) + .get(`/v4/projectTemplates/${templateId}`) + .expect(403, done); + }); + + it('should return 404 for non-existed template', (done) => { + request(server) + .get('/v4/projectTemplates/1234') + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .expect(404, done); + }); + + it('should return 404 for deleted template', (done) => { + models.ProjectTemplate.destroy({ where: { id: templateId } }) + .then(() => { + request(server) + .get(`/v4/projectTemplates/${templateId}`) + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .expect(404, done); + }); + }); + + it('should return 200 for admin', (done) => { + request(server) + .get(`/v4/projectTemplates/${templateId}`) + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .expect(200) + .end((err, res) => { + const resJson = res.body.result.content; + resJson.id.should.be.eql(templateId); + resJson.name.should.be.eql(template.name); + resJson.key.should.be.eql(template.key); + resJson.category.should.be.eql(template.category); + resJson.scope.should.be.eql(template.scope); + resJson.phases.should.be.eql(template.phases); + resJson.createdBy.should.be.eql(template.createdBy); + should.exist(resJson.createdAt); + resJson.updatedBy.should.be.eql(template.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/projectTemplates/${templateId}`) + .set({ + Authorization: `Bearer ${testUtil.jwts.connectAdmin}`, + }) + .expect(200) + .end(done); + }); + + it('should return 200 for connect manager', (done) => { + request(server) + .get(`/v4/projectTemplates/${templateId}`) + .set({ + Authorization: `Bearer ${testUtil.jwts.manager}`, + }) + .expect(200) + .end(done); + }); + + it('should return 200 for member', (done) => { + request(server) + .get(`/v4/projectTemplates/${templateId}`) + .set({ + Authorization: `Bearer ${testUtil.jwts.member}`, + }) + .expect(200, done); + }); + + it('should return 200 for copilot', (done) => { + request(server) + .get(`/v4/projectTemplates/${templateId}`) + .set({ + Authorization: `Bearer ${testUtil.jwts.copilot}`, + }) + .expect(200, done); + }); + }); +}); diff --git a/src/routes/projectTemplates/list.js b/src/routes/projectTemplates/list.js new file mode 100644 index 00000000..3e83f2e4 --- /dev/null +++ b/src/routes/projectTemplates/list.js @@ -0,0 +1,23 @@ +/** + * API to list all project templates + */ +import { middleware as tcMiddleware } from 'tc-core-library-js'; +import util from '../../util'; +import models from '../../models'; + +const permissions = tcMiddleware.permissions; + +module.exports = [ + permissions('projectTemplate.view'), + (req, res, next) => models.ProjectTemplate.findAll({ + where: { + deletedAt: { $eq: null }, + }, + attributes: { exclude: ['deletedAt', 'deletedBy'] }, + raw: true, + }) + .then((projectTemplates) => { + res.json(util.wrapResponse(req.id, projectTemplates)); + }) + .catch(next), +]; diff --git a/src/routes/projectTemplates/list.spec.js b/src/routes/projectTemplates/list.spec.js new file mode 100644 index 00000000..b68fc28d --- /dev/null +++ b/src/routes/projectTemplates/list.spec.js @@ -0,0 +1,141 @@ +/** + * Tests for list.js + */ +import chai from 'chai'; +import request from 'supertest'; + +import models from '../../models'; +import server from '../../app'; +import testUtil from '../../tests/util'; + +const should = chai.should(); + +describe('LIST project templates', () => { + const templates = [ + { + name: 'template 1', + key: 'key 1', + category: 'category 1', + scope: { + scope1: { + subScope1A: 1, + subScope1B: 2, + }, + scope2: [1, 2, 3], + }, + phases: { + phase1: { + name: 'phase 1', + details: { + anyDetails: 'any details 1', + }, + others: ['others 11', 'others 12'], + }, + phase2: { + name: 'phase 2', + details: { + anyDetails: 'any details 2', + }, + others: ['others 21', 'others 22'], + }, + }, + createdBy: 1, + updatedBy: 1, + }, + { + name: 'template 2', + key: 'key 2', + category: 'category 2', + scope: {}, + phases: {}, + createdBy: 1, + updatedBy: 2, + }, + ]; + + let templateId; + + beforeEach(() => testUtil.clearDb() + .then(() => models.ProjectTemplate.create(templates[0])) + .then((createdTemplate) => { + templateId = createdTemplate.id; + return models.ProjectTemplate.create(templates[1]); + }).then(() => Promise.resolve()), + ); + after(testUtil.clearDb); + + describe('GET /projectTemplates', () => { + it('should return 403 if user is not authenticated', (done) => { + request(server) + .get('/v4/projectTemplates') + .expect(403, done); + }); + + it('should return 200 for admin', (done) => { + request(server) + .get('/v4/projectTemplates') + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .expect(200) + .end((err, res) => { + const template = templates[0]; + + const resJson = res.body.result.content; + resJson.should.have.length(2); + resJson[0].id.should.be.eql(templateId); + resJson[0].name.should.be.eql(template.name); + resJson[0].key.should.be.eql(template.key); + resJson[0].category.should.be.eql(template.category); + resJson[0].scope.should.be.eql(template.scope); + resJson[0].phases.should.be.eql(template.phases); + resJson[0].createdBy.should.be.eql(template.createdBy); + should.exist(resJson[0].createdAt); + resJson[0].updatedBy.should.be.eql(template.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/projectTemplates') + .set({ + Authorization: `Bearer ${testUtil.jwts.connectAdmin}`, + }) + .expect(200) + .end(done); + }); + + it('should return 200 for connect manager', (done) => { + request(server) + .get('/v4/projectTemplates') + .set({ + Authorization: `Bearer ${testUtil.jwts.manager}`, + }) + .expect(200) + .end(done); + }); + + it('should return 200 for member', (done) => { + request(server) + .get('/v4/projectTemplates') + .set({ + Authorization: `Bearer ${testUtil.jwts.member}`, + }) + .expect(200, done); + }); + + it('should return 200 for copilot', (done) => { + request(server) + .get('/v4/projectTemplates') + .set({ + Authorization: `Bearer ${testUtil.jwts.copilot}`, + }) + .expect(200, done); + }); + }); +}); diff --git a/src/routes/projectTemplates/update.js b/src/routes/projectTemplates/update.js new file mode 100644 index 00000000..8b88c84a --- /dev/null +++ b/src/routes/projectTemplates/update.js @@ -0,0 +1,70 @@ +/** + * API to update a project template + */ +import validate from 'express-validation'; +import _ from 'lodash'; +import Joi from 'joi'; +import { middleware as tcMiddleware } from 'tc-core-library-js'; +import util from '../../util'; +import models from '../../models'; + +const permissions = tcMiddleware.permissions; + +const schema = { + params: { + templateId: Joi.number().integer().positive().required(), + }, + body: { + param: Joi.object().keys({ + id: Joi.any().strip(), + name: Joi.string().max(255).required(), + key: Joi.string().max(45).required(), + category: Joi.string().max(45).required(), + scope: Joi.object().required(), + phases: Joi.object().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), + permissions('projectTemplate.edit'), + (req, res, next) => { + const entityToUpdate = _.assign(req.body.param, { + updatedBy: req.authUser.userId, + }); + + return models.ProjectTemplate.findOne({ + where: { + deletedAt: { $eq: null }, + id: req.params.templateId, + }, + attributes: { exclude: ['deletedAt', 'deletedBy'] }, + }) + .then((projectTemplate) => { + // Not found + if (!projectTemplate) { + const apiErr = new Error(`Project template not found for template id ${req.params.templateId}`); + apiErr.status = 404; + return Promise.reject(apiErr); + } + + // Merge JSON fields + entityToUpdate.scope = util.mergeJsonObjects(projectTemplate.scope, entityToUpdate.scope); + entityToUpdate.phases = util.mergeJsonObjects(projectTemplate.phases, entityToUpdate.phases); + + return projectTemplate.update(entityToUpdate); + }) + .then((projectTemplate) => { + res.json(util.wrapResponse(req.id, projectTemplate)); + return Promise.resolve(); + }) + .catch(next); + }, +]; diff --git a/src/routes/projectTemplates/update.spec.js b/src/routes/projectTemplates/update.spec.js new file mode 100644 index 00000000..dd286a77 --- /dev/null +++ b/src/routes/projectTemplates/update.spec.js @@ -0,0 +1,237 @@ +/** + * 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('UPDATE project template', () => { + const template = { + name: 'template 1', + key: 'key 1', + category: 'category 1', + scope: { + scope1: { + subScope1A: 1, + subScope1B: 2, + }, + scope2: [1, 2, 3], + }, + phases: { + phase1: { + name: 'phase 1', + details: { + anyDetails: 'any details 1', + }, + others: ['others 11', 'others 12'], + }, + phase2: { + name: 'phase 2', + details: { + anyDetails: 'any details 2', + }, + others: ['others 21', 'others 22'], + }, + }, + createdBy: 1, + updatedBy: 1, + }; + + let templateId; + + beforeEach(() => testUtil.clearDb() + .then(() => models.ProjectTemplate.create(template)) + .then((createdTemplate) => { + templateId = createdTemplate.id; + return Promise.resolve(); + }), + ); + after(testUtil.clearDb); + + describe('PATCH /projectTemplates/{templateId}', () => { + const body = { + param: { + name: 'template 1 - update', + key: 'key 1 - update', + category: 'category 1 - update', + scope: { + scope1: { + subScope1A: 11, + subScope1C: 'new', + }, + scope2: [4], + scope3: 'new', + }, + phases: { + phase1: { + name: 'phase 1 - update', + details: { + anyDetails: 'any details 1 - update', + newDetails: 'new', + }, + others: ['others new'], + }, + phase3: { + name: 'phase 3', + details: { + anyDetails: 'any details 3', + }, + others: ['others 31', 'others 32'], + }, + }, + }, + }; + + it('should return 403 if user is not authenticated', (done) => { + request(server) + .patch(`/v4/projectTemplates/${templateId}`) + .send(body) + .expect(403, done); + }); + + it('should return 403 for member', (done) => { + request(server) + .patch(`/v4/projectTemplates/${templateId}`) + .set({ + Authorization: `Bearer ${testUtil.jwts.member}`, + }) + .send(body) + .expect(403, done); + }); + + it('should return 403 for copilot', (done) => { + request(server) + .patch(`/v4/projectTemplates/${templateId}`) + .send(body) + .set({ + Authorization: `Bearer ${testUtil.jwts.copilot}`, + }) + .expect(403, done); + }); + + it('should return 422 for invalid request', (done) => { + const invalidBody = { + param: { + scope: 'a', + phases: 1, + }, + }; + + request(server) + .patch(`/v4/projectTemplates/${templateId}`) + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .send(invalidBody) + .expect(422, done); + }); + + it('should return 404 for non-existed template', (done) => { + request(server) + .patch('/v4/projectTemplates/1234') + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .send(body) + .expect(404, done); + }); + + it('should return 404 for deleted template', (done) => { + models.ProjectTemplate.destroy({ where: { id: templateId } }) + .then(() => { + request(server) + .patch(`/v4/projectTemplates/${templateId}`) + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .send(body) + .expect(404, done); + }); + }); + + it('should return 200 for admin', (done) => { + request(server) + .patch(`/v4/projectTemplates/${templateId}`) + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .send(body) + .expect(200) + .end((err, res) => { + const resJson = res.body.result.content; + resJson.id.should.be.eql(templateId); + resJson.name.should.be.eql(body.param.name); + resJson.key.should.be.eql(body.param.key); + resJson.category.should.be.eql(body.param.category); + resJson.scope.should.be.eql({ + scope1: { + subScope1A: 11, + subScope1B: 2, + subScope1C: 'new', + }, + scope2: [4], + scope3: 'new', + }); + resJson.phases.should.be.eql({ + phase1: { + name: 'phase 1 - update', + details: { + anyDetails: 'any details 1 - update', + newDetails: 'new', + }, + others: ['others new'], + }, + phase2: { + name: 'phase 2', + details: { + anyDetails: 'any details 2', + }, + others: ['others 21', 'others 22'], + }, + phase3: { + name: 'phase 3', + details: { + anyDetails: 'any details 3', + }, + others: ['others 31', 'others 32'], + }, + }); + resJson.createdBy.should.be.eql(template.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(); + }); + }); + + it('should return 200 for connect admin', (done) => { + request(server) + .patch(`/v4/projectTemplates/${templateId}`) + .set({ + Authorization: `Bearer ${testUtil.jwts.connectAdmin}`, + }) + .send(body) + .expect(200) + .end(done); + }); + + it('should return 200 for connect manager', (done) => { + request(server) + .patch(`/v4/projectTemplates/${templateId}`) + .set({ + Authorization: `Bearer ${testUtil.jwts.manager}`, + }) + .send(body) + .expect(200) + .end(done); + }); + }); +}); diff --git a/src/tests/seed.js b/src/tests/seed.js index a1f53c84..bbd0aa08 100644 --- a/src/tests/seed.js +++ b/src/tests/seed.js @@ -1,108 +1,194 @@ import models from '../models'; models.sequelize.sync({ force: true }) - .then(() => - models.Project.bulkCreate([{ - type: 'generic', - directProjectId: 9999999, - billingAccountId: 1, - name: 'test1', - description: 'test project1', - status: 'active', - details: {}, - createdBy: 1, - updatedBy: 1, - }, { - type: 'visual_design', - directProjectId: 1, - billingAccountId: 2, - name: 'test2', - description: 'test project2', - status: 'draft', - details: {}, - createdBy: 1, - updatedBy: 1, - }, { - type: 'visual_design', - billingAccountId: 3, - name: 'test2', - description: 'completed project without copilot', - status: 'completed', - details: {}, - createdBy: 1, - updatedBy: 1, - }, { - type: 'generic', - billingAccountId: 4, - name: 'test2', - description: 'draft project without copilot', - status: 'draft', - details: {}, - createdBy: 1, - updatedBy: 1, - }, { - type: 'generic', - billingAccountId: 5, - name: 'test2', - description: 'active project without copilot', - status: 'active', - details: {}, - createdBy: 1, - updatedBy: 1, - }])) - .then(() => models.Project.findAll()) - .then((projects) => { - const project1 = projects[0]; - const project2 = projects[1]; - const operations = []; - operations.push(models.ProjectMember.bulkCreate([{ - userId: 40051331, - projectId: project1.id, - role: 'customer', - isPrimary: false, - createdBy: 1, - updatedBy: 1, - }, { - userId: 40051332, - projectId: project1.id, - role: 'copilot', - isPrimary: false, - createdBy: 1, - updatedBy: 1, - }, { - userId: 40051333, - projectId: project1.id, - role: 'manager', - isPrimary: true, - createdBy: 1, - updatedBy: 1, - }, { - userId: 40051332, - projectId: project2.id, - role: 'copilot', - isPrimary: false, - createdBy: 1, - updatedBy: 1, - }, { - userId: 40051331, - projectId: projects[2].id, - role: 'customer', - isPrimary: false, - createdBy: 1, - updatedBy: 1, - }])); - operations.push(models.ProjectAttachment.create({ - title: 'Spec', - projectId: project1.id, - description: 'specification', - filePath: 'projects/1/spec.pdf', - contentType: 'application/pdf', - createdBy: 1, - updatedBy: 1, - })); - return Promise.all(operations); - }) - .then(() => { - process.exit(0); - }) - .catch(() => process.exit(1)); + .then(() => + models.Project.bulkCreate([{ + type: 'generic', + directProjectId: 9999999, + billingAccountId: 1, + name: 'test1', + description: 'test project1', + status: 'active', + details: {}, + createdBy: 1, + updatedBy: 1, + }, { + type: 'visual_design', + directProjectId: 1, + billingAccountId: 2, + name: 'test2', + description: 'test project2', + status: 'draft', + details: {}, + createdBy: 1, + updatedBy: 1, + }, { + type: 'visual_design', + billingAccountId: 3, + name: 'test2', + description: 'completed project without copilot', + status: 'completed', + details: {}, + createdBy: 1, + updatedBy: 1, + }, { + type: 'generic', + billingAccountId: 4, + name: 'test2', + description: 'draft project without copilot', + status: 'draft', + details: {}, + createdBy: 1, + updatedBy: 1, + }, { + type: 'generic', + billingAccountId: 5, + name: 'test2', + description: 'active project without copilot', + status: 'active', + details: {}, + createdBy: 1, + updatedBy: 1, + }])) + .then(() => models.Project.findAll()) + .then((projects) => { + const project1 = projects[0]; + const project2 = projects[1]; + const operations = []; + operations.push(models.ProjectMember.bulkCreate([{ + userId: 40051331, + projectId: project1.id, + role: 'customer', + isPrimary: false, + createdBy: 1, + updatedBy: 1, + }, { + userId: 40051332, + projectId: project1.id, + role: 'copilot', + isPrimary: false, + createdBy: 1, + updatedBy: 1, + }, { + userId: 40051333, + projectId: project1.id, + role: 'manager', + isPrimary: true, + createdBy: 1, + updatedBy: 1, + }, { + userId: 40051332, + projectId: project2.id, + role: 'copilot', + isPrimary: false, + createdBy: 1, + updatedBy: 1, + }, { + userId: 40051331, + projectId: projects[2].id, + role: 'customer', + isPrimary: false, + createdBy: 1, + updatedBy: 1, + }])); + operations.push(models.ProjectAttachment.create({ + title: 'Spec', + projectId: project1.id, + description: 'specification', + filePath: 'projects/1/spec.pdf', + contentType: 'application/pdf', + createdBy: 1, + updatedBy: 1, + })); + return Promise.all(operations); + }) + .then(() => models.ProjectTemplate.bulkCreate([ + { + name: 'template 1', + key: 'key 1', + category: 'category 1', + scope: { + scope1: { + subScope1A: 1, + subScope1B: 2, + }, + scope2: [1, 2, 3], + }, + phases: { + phase1: { + name: 'phase 1', + details: { + anyDetails: 'any details 1', + }, + others: ['others 11', 'others 12'], + }, + phase2: { + name: 'phase 2', + details: { + anyDetails: 'any details 2', + }, + others: ['others 21', 'others 22'], + }, + }, + createdBy: 1, + updatedBy: 1, + }, + { + name: 'template 2', + key: 'key 2', + category: 'category 2', + scope: {}, + phases: {}, + createdBy: 1, + updatedBy: 2, + }, + ])) + .then(() => models.ProductTemplate.bulkCreate([ + { + 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, + }, + ])) + .then(() => { + process.exit(0); + }) + .catch(() => process.exit(1)); diff --git a/src/util.js b/src/util.js index 0afbdfb0..5a468d6e 100644 --- a/src/util.js +++ b/src/util.js @@ -227,30 +227,30 @@ _.assignIn(util, { getProjectAttachments: (req, projectId) => { let attachments = []; return models.ProjectAttachment.getActiveProjectAttachments(projectId) - .then((_attachments) => { - // if attachments were requested - if (attachments) { - attachments = _attachments; - } else { - return attachments; - } - // TODO consider using redis to cache attachments urls - const promises = []; - _.each(attachments, (a) => { - promises.push(util.getFileDownloadUrl(req, a.filePath)); - }); - return Promise.all(promises); - }) - .then((result) => { - // result is an array of 'tuples' => [[path, url], [path,url]] - // convert it to a map for easy lookup - const urls = _.fromPairs(result); - _.each(attachments, (at) => { - const a = at; - a.downloadUrl = urls[a.filePath]; - }); + .then((_attachments) => { + // if attachments were requested + if (attachments) { + attachments = _attachments; + } else { return attachments; + } + // TODO consider using redis to cache attachments urls + const promises = []; + _.each(attachments, (a) => { + promises.push(util.getFileDownloadUrl(req, a.filePath)); + }); + return Promise.all(promises); + }) + .then((result) => { + // result is an array of 'tuples' => [[path, url], [path,url]] + // convert it to a map for easy lookup + const urls = _.fromPairs(result); + _.each(attachments, (at) => { + const a = at; + a.downloadUrl = urls[a.filePath]; }); + return attachments; + }); }, getSystemUserToken: (logger, id = 'system') => { @@ -263,19 +263,19 @@ _.assignIn(util, { timeout: 4000, headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, }, - ) + ) .then(res => res.data.result.content.token); }, - /** - * Fetches the topcoder user details using the given JWT token. - * - * @param {Number} userId id of the user to be fetched - * @param {String} jwtToken JWT token of the admin user or JWT token of the user to be fecthed - * @param {Object} logger logger to be used for logging purposes - * - * @return {Promise} promise which resolves to the user's information - */ + /** + * Fetches the topcoder user details using the given JWT token. + * + * @param {Number} userId id of the user to be fetched + * @param {String} jwtToken JWT token of the admin user or JWT token of the user to be fecthed + * @param {Object} logger logger to be used for logging purposes + * + * @return {Promise} promise which resolves to the user's information + */ getTopcoderUser: (userId, jwtToken, logger) => { const httpClient = util.getHttpClient({ id: `userService_${userId}`, log: logger }); httpClient.defaults.timeout = 3000; @@ -284,7 +284,7 @@ _.assignIn(util, { httpClient.defaults.headers.common.Authorization = `Bearer ${jwtToken}`; return httpClient.get(`${config.identityServiceEndpoint}users/${userId}`).then((response) => { if (response.data && response.data.result - && response.data.result.status === 200 && response.data.result.content) { + && response.data.result.status === 200 && response.data.result.content) { return response.data.result.content; } return null; @@ -356,6 +356,20 @@ _.assignIn(util, { return Promise.reject(err); } }), + + /** + * Merge two JSON objects. For array fields, the target will be replaced by source. + * @param {Object} targetObj the target object + * @param {Object} sourceObj the source object + * @returns {Object} the merged object + */ + // eslint-disable-next-line consistent-return + mergeJsonObjects: (targetObj, sourceObj) => _.mergeWith(targetObj, sourceObj, (target, source) => { + // Overwrite the array + if (_.isArray(source)) { + return source; + } + }), }); export default util; diff --git a/swagger.yaml b/swagger.yaml index f195828f..6df120e1 100755 --- a/swagger.yaml +++ b/swagger.yaml @@ -1,4 +1,3 @@ ---- swagger: "2.0" info: version: "v4" @@ -336,6 +335,532 @@ paths: schema: $ref: "#/definitions/UpdateProjectMemberBodyParam" + /projects/{projectId}/phases: + parameters: + - $ref: "#/parameters/projectIdParam" + get: + tags: + - phase + operationId: findProjectPhases + security: + - Bearer: [] + description: Retreive all project phases. All users who can edit project can access this endpoint. + parameters: + - name: fields + required: false + type: string + in: query + description: | + Comma separated list of project phase fields to return. + - name: sort + required: false + description: | + sort project phases by startDate, endDate, status. Default is startDate asc + in: query + type: string + responses: + '403': + description: No permission or wrong token + schema: + $ref: "#/definitions/ErrorModel" + '200': + description: A list of project phases + schema: + $ref: "#/definitions/ProjectPhaseListResponse" + post: + tags: + - phase + operationId: addProjectPhase + security: + - Bearer: [] + description: Create a project phase + parameters: + - in: body + name: body + required: true + schema: + $ref: '#/definitions/ProjectPhaseBodyParam' + responses: + '403': + description: No permission or wrong token + schema: + $ref: "#/definitions/ErrorModel" + '201': + description: Returns the newly created project phase + schema: + $ref: "#/definitions/ProjectPhaseResponse" + '422': + description: Invalid input + schema: + $ref: "#/definitions/ErrorModel" + + /projects/{projectId}/phases/{phaseId}: + parameters: + - $ref: "#/parameters/projectIdParam" + - $ref: "#/parameters/phaseIdParam" + get: + tags: + - phase + description: Retrieve project phase by id. All users who can edit 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" + '200': + description: a project phase + schema: + $ref: "#/definitions/ProjectPhaseResponse" + parameters: + - $ref: "#/parameters/phaseIdParam" + operationId: getProjectPhase + + patch: + tags: + - phase + operationId: updateProjectPhase + security: + - Bearer: [] + description: Update a project phase. All users who can edit 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 project phase. + schema: + $ref: "#/definitions/ProjectPhaseResponse" + '422': + description: Invalid input + schema: + $ref: "#/definitions/ErrorModel" + default: + description: error payload + schema: + $ref: '#/definitions/ErrorModel' + parameters: + - $ref: "#/parameters/phaseIdParam" + - name: body + in: body + required: true + schema: + $ref: "#/definitions/ProjectPhaseBodyParam" + + delete: + tags: + - phase + description: Remove an existing project phase. All users who can edit project can access this endpoint. + security: + - Bearer: [] + parameters: + - $ref: "#/parameters/phaseIdParam" + responses: + '403': + description: No permission or wrong token + schema: + $ref: "#/definitions/ErrorModel" + '404': + description: If project is not found + schema: + $ref: "#/definitions/ErrorModel" + '204': + description: Project phase successfully removed + + + + /projects/{projectId}/phases/{phaseId}/products: + parameters: + - $ref: "#/parameters/projectIdParam" + - $ref: "#/parameters/phaseIdParam" + get: + tags: + - phase product + operationId: findPhaseProducts + security: + - Bearer: [] + description: Retreive all phase products. All users who can edit project can access this endpoint. + responses: + '403': + description: No permission or wrong token + schema: + $ref: "#/definitions/ErrorModel" + '200': + description: A list of phase products + schema: + $ref: "#/definitions/PhaseProductListResponse" + post: + tags: + - phase product + operationId: addPhaseProduct + security: + - Bearer: [] + description: Create a phase product + parameters: + - in: body + name: body + required: true + schema: + $ref: '#/definitions/PhaseProductBodyParam' + responses: + '403': + description: No permission or wrong token + schema: + $ref: "#/definitions/ErrorModel" + '201': + description: Returns the newly created phase product + schema: + $ref: "#/definitions/PhaseProductResponse" + '422': + description: Invalid input + schema: + $ref: "#/definitions/ErrorModel" + + /projects/{projectId}/phases/{phaseId}/products/{productId}: + parameters: + - $ref: "#/parameters/projectIdParam" + - $ref: "#/parameters/phaseIdParam" + - $ref: "#/parameters/productIdParam" + get: + tags: + - phase product + description: Retrieve phase product by id. All users who can edit 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" + '200': + description: a phase product + schema: + $ref: "#/definitions/PhaseProductResponse" + parameters: + - $ref: "#/parameters/phaseIdParam" + operationId: getPhaseProduct + + patch: + tags: + - phase product + operationId: updatePhaseProduct + security: + - Bearer: [] + description: Update a phase product. All users who can edit 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 phase product. + schema: + $ref: "#/definitions/PhaseProductResponse" + '422': + description: Invalid input + schema: + $ref: "#/definitions/ErrorModel" + default: + description: error payload + schema: + $ref: '#/definitions/ErrorModel' + parameters: + - $ref: "#/parameters/phaseIdParam" + - name: body + in: body + required: true + schema: + $ref: "#/definitions/PhaseProductBodyParam" + + delete: + tags: + - phase product + description: Remove an existing phase product. All users who can edit project can access this endpoint. + security: + - Bearer: [] + parameters: + - $ref: "#/parameters/phaseIdParam" + responses: + '403': + description: No permission or wrong token + schema: + $ref: "#/definitions/ErrorModel" + '404': + description: If project is not found + schema: + $ref: "#/definitions/ErrorModel" + '204': + description: Project phase successfully removed + + /projectTemplates: + get: + tags: + - projectTemplate + operationId: findProjectTemplates + security: + - Bearer: [] + description: Retreive all project templates. All user roles can access this endpoint. + responses: + '403': + description: No permission or wrong token + schema: + $ref: "#/definitions/ErrorModel" + '200': + description: A list of project templates + schema: + $ref: "#/definitions/ProjectTemplateListResponse" + post: + tags: + - projectTemplate + operationId: addProjectTemplate + security: + - Bearer: [] + description: Create a project template + parameters: + - in: body + name: body + required: true + schema: + $ref: '#/definitions/ProjectTemplateBodyParam' + responses: + '403': + description: No permission or wrong token + schema: + $ref: "#/definitions/ErrorModel" + '201': + description: Returns the newly created project template + schema: + $ref: "#/definitions/ProjectTemplateResponse" + '422': + description: Invalid input + schema: + $ref: "#/definitions/ErrorModel" + + /projectTemplates/{templateId}: + get: + tags: + - projectTemplate + description: Retrieve project 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" + '200': + description: a project template + schema: + $ref: "#/definitions/ProjectTemplateResponse" + parameters: + - $ref: "#/parameters/templateIdParam" + operationId: getProjectTemplate + + patch: + tags: + - projectTemplate + operationId: updateProjectTemplate + security: + - Bearer: [] + description: Update a project template. Only connect manager, connect admin, and admin 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 project template. + schema: + $ref: "#/definitions/ProjectTemplateResponse" + '422': + description: Invalid input + schema: + $ref: "#/definitions/ErrorModel" + default: + description: error payload + schema: + $ref: '#/definitions/ErrorModel' + parameters: + - $ref: "#/parameters/templateIdParam" + - name: body + in: body + required: true + schema: + $ref: "#/definitions/ProjectTemplateBodyParam" + + delete: + tags: + - projectTemplate + description: Remove an existing project template. Only connect manager, connect admin, and admin can access this endpoint. + security: + - Bearer: [] + parameters: + - $ref: "#/parameters/templateIdParam" + responses: + '403': + description: No permission or wrong token + schema: + $ref: "#/definitions/ErrorModel" + '404': + description: If project is not found + schema: + $ref: "#/definitions/ErrorModel" + '204': + description: Project template successfully removed + + + /productTemplates: + get: + tags: + - productTemplate + operationId: findProductTemplates + security: + - Bearer: [] + description: Retreive all product templates. All user roles can access this endpoint. + responses: + '403': + description: No permission or wrong token + schema: + $ref: "#/definitions/ErrorModel" + '200': + description: A list of product templates + schema: + $ref: "#/definitions/ProductTemplateListResponse" + post: + tags: + - productTemplate + operationId: addProductTemplate + security: + - Bearer: [] + description: Create a product template + parameters: + - in: body + name: body + required: true + schema: + $ref: '#/definitions/ProductTemplateBodyParam' + responses: + '403': + description: No permission or wrong token + schema: + $ref: "#/definitions/ErrorModel" + '201': + description: Returns the newly created product template + schema: + $ref: "#/definitions/ProductTemplateResponse" + '422': + description: Invalid input + schema: + $ref: "#/definitions/ErrorModel" + + /productTemplates/{templateId}: + get: + tags: + - productTemplate + description: Retrieve product 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" + '200': + description: a product template + schema: + $ref: "#/definitions/ProductTemplateResponse" + parameters: + - $ref: "#/parameters/templateIdParam" + operationId: getProductTemplate + + patch: + tags: + - productTemplate + operationId: updateProductTemplate + security: + - Bearer: [] + description: Update a product template. Only connect manager, connect admin, and admin 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 product template. + schema: + $ref: "#/definitions/ProductTemplateResponse" + '422': + description: Invalid input + schema: + $ref: "#/definitions/ErrorModel" + default: + description: error payload + schema: + $ref: '#/definitions/ErrorModel' + parameters: + - $ref: "#/parameters/templateIdParam" + - name: body + in: body + required: true + schema: + $ref: "#/definitions/ProductTemplateBodyParam" + + delete: + tags: + - productTemplate + description: Remove an existing product template. Only connect manager, connect admin, and admin can access this endpoint. + security: + - Bearer: [] + parameters: + - $ref: "#/parameters/templateIdParam" + responses: + '403': + description: No permission or wrong token + schema: + $ref: "#/definitions/ErrorModel" + '404': + description: If product is not found + schema: + $ref: "#/definitions/ErrorModel" + '204': + description: Product template successfully removed + parameters: projectIdParam: @@ -345,6 +870,30 @@ parameters: required: true type: integer format: int64 + phaseIdParam: + name: phaseId + in: path + description: project phase identifier + required: true + type: integer + format: int64 + minimum: 1 + productIdParam: + name: productId + in: path + description: project phase product identifier + required: true + type: integer + format: int64 + minimum: 1 + templateIdParam: + name: templateId + in: path + description: template identifier + required: true + type: integer + format: int64 + minimum: 1 offsetParam: name: offset description: "number of items to skip. Defaults to 0" @@ -702,26 +1251,316 @@ definitions: description: Uploaded file content type title: type: string - description: Name of the attachment - description: + 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: + 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" + + ProjectListResponse: + title: List response + type: object + properties: + id: + type: string + readOnly: true + description: unique id identifying the request + version: + type: string + result: + type: object + properties: + success: + type: boolean + status: + type: string + description: http status code + metadata: + $ref: "#/definitions/ResponseMetadata" + content: + type: array + items: + $ref: "#/definitions/Project" + + ProjectTemplateRequest: + title: Project template request object + type: object + required: + - name + - key + - category + - scope + - phases + properties: + name: + type: string + 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: @@ -737,99 +1576,115 @@ 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: + readOnly: true + description: unique id identifying the request + version: type: string - description: Uploaded file content type - title: + 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: + name: type: string - description: Name of the attachment - description: + description: the product template name + productKey: type: string - description: Optional description for the attached file. - downloadUrl: + description: the product template key + icon: type: string - description: download link for the attachment. - createdAt: + 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 - ProjectMember: - title: Project Member object + ProductTemplateBodyParam: + title: Product template body param type: object + required: + - param 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 + 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 + ProductTemplateResponse: + title: Single product template response object type: object properties: id: @@ -845,15 +1700,18 @@ definitions: status: type: string description: http status code + metadata: + $ref: "#/definitions/ResponseMetadata" content: - $ref: "#/definitions/ProjectMember" + $ref: "#/definitions/ProductTemplate" - UpdateProjectMemberResponse: - title: Project member object response + ProductTemplateListResponse: + title: Product template list response object type: object properties: id: type: string + readOnly: true description: unique id identifying the request version: type: string @@ -865,12 +1723,93 @@ definitions: status: type: string description: http status code + metadata: + $ref: "#/definitions/ResponseMetadata" content: - $ref: "#/definitions/ProjectMember" + type: array + items: + $ref: "#/definitions/ProductTemplate" + + 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" - ProjectResponse: - title: Single project object + 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: @@ -886,15 +1825,18 @@ definitions: status: type: string description: http status code + metadata: + $ref: "#/definitions/ResponseMetadata" content: - $ref: "#/definitions/Project" + $ref: "#/definitions/ProjectPhase" - UpdateProjectResponse: - title: response with original and updated project object + ProjectPhaseListResponse: + title: Project phase list response object type: object properties: id: type: string + readOnly: true description: unique id identifying the request version: type: string @@ -906,16 +1848,112 @@ definitions: status: type: string description: http status code + metadata: + $ref: "#/definitions/ResponseMetadata" content: - type: object - properties: - original: - $ref: "#/definitions/Project" - updated: - $ref: "#/definitions/Project" + type: array + items: + $ref: "#/definitions/ProjectPhase" - ProjectListResponse: - title: List response + + PhaseProductRequest: + title: Phase product request object + type: object + 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: + type: number + description: the phase product template id + 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 + details: + type: object + description: the phase product details + + PhaseProductBodyParam: + title: Phase product body param + type: object + required: + - param + properties: + param: + $ref: "#/definitions/PhaseProductRequest" + + PhaseProduct: + title: Phase product 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/PhaseProductRequest" + + + PhaseProductResponse: + title: Single phase product response object + type: object + properties: + id: + type: string + description: unique id identifying the request + version: + type: string + result: + type: object + properties: + success: + type: boolean + status: + type: string + description: http status code + metadata: + $ref: "#/definitions/ResponseMetadata" + content: + $ref: "#/definitions/PhaseProduct" + + PhaseProductListResponse: + title: Phase product list response object type: object properties: id: @@ -937,4 +1975,4 @@ definitions: content: type: array items: - $ref: "#/definitions/Project" + $ref: "#/definitions/PhaseProduct" \ No newline at end of file