diff --git a/.circleci/config.yml b/.circleci/config.yml index c3db4502..fec74ad4 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -76,7 +76,7 @@ workflows: - test filters: branches: - only: ['dev', 'feature/dev-challenges'] + only: ['dev', 'feature/timeline-milestone'] - deployProd: requires: - test diff --git a/README.md b/README.md index e2e5a707..c487aab6 100644 --- a/README.md +++ b/README.md @@ -62,7 +62,7 @@ Authentication is handled via Authorization (Bearer) token header field. Token i ``` eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJyb2xlcyI6WyJhZG1pbmlzdHJhdG9yIl0sImlzcyI6Imh0dHBzOi8vYXBpLnRvcGNvZGVyLWRldi5jb20iLCJoYW5kbGUiOiJwc2hhaDEiLCJleHAiOjI0NjI0OTQ2MTgsInVzZXJJZCI6IjQwMTM1OTc4IiwiaWF0IjoxNDYyNDk0MDE4LCJlbWFpbCI6InBzaGFoMUB0ZXN0LmNvbSIsImp0aSI6ImY0ZTFhNTE0LTg5ODAtNDY0MC04ZWM1LWUzNmUzMWE3ZTg0OSJ9.XuNN7tpMOXvBG1QwWRQROj7NfuUbqhkjwn39Vy4tR5I ``` -It's been signed with the secret 'secret'. This secret should match your entry in config/local.json. You can generate your own token using https://jwt.io +It's been signed with the secret 'secret'. This secret should match your entry in config/local.js. You can generate your own token using https://jwt.io ### Local Deployment Build image: diff --git a/config/custom-environment-variables.json b/config/custom-environment-variables.json index 6b5731a7..3e0b0287 100644 --- a/config/custom-environment-variables.json +++ b/config/custom-environment-variables.json @@ -9,7 +9,9 @@ "host": "PROJECTS_ES_URL", "apiVersion": "2.3", "indexName": "PROJECTS_ES_INDEX_NAME", - "docType": "projectV4" + "docType": "projectV4", + "timelineIndexName": "TIMELINES_ES_INDEX_NAME", + "timelineDocType": "TIMELINES_ES_DOC_TYPE" }, "rabbitmqURL": "RABBITMQ_URL", "pubsubQueueName": "PUBSUB_QUEUE_NAME", diff --git a/config/default.json b/config/default.json index 053f0c35..556a5a10 100644 --- a/config/default.json +++ b/config/default.json @@ -20,7 +20,9 @@ "host": "", "apiVersion": "2.3", "indexName": "projects", - "docType": "projectV4" + "docType": "projectV4", + "timelineIndexName": "timelines", + "timelineDocType": "timelineV4" }, "systemUserClientId": "", "systemUserClientSecret": "", diff --git a/config/development.json b/config/development.json index 7e8ce29d..12dc197a 100644 --- a/config/development.json +++ b/config/development.json @@ -1,5 +1,6 @@ { "pubsubQueueName": "dev.project.service", "pubsubExchangeName": "dev.projects", - "attachmentsS3Bucket": "topcoder-dev-media" + "attachmentsS3Bucket": "topcoder-dev-media", + "connectProjectsUrl": "https://connect.topcoder-dev.com/projects/" } diff --git a/config/production.json b/config/production.json index 93d6df88..2d3343bd 100644 --- a/config/production.json +++ b/config/production.json @@ -1,3 +1,4 @@ { - "authDomain": "topcoder.com" + "authDomain": "topcoder.com", + "connectProjectsUrl": "https://connect.topcoder.com/projects/", } diff --git a/config/test.json b/config/test.json index 26d22a7a..ab652133 100644 --- a/config/test.json +++ b/config/test.json @@ -7,9 +7,12 @@ "host": "http://localhost:9200", "apiVersion": "2.3", "indexName": "projects_test", - "docType": "projectV4" + "docType": "projectV4", + "timelineIndexName": "timelines_test", + "timelineDocType": "timelineV4" }, "rabbitmqUrl": "amqp://localhost:5672", + "connectProjectsUrl": "https://local.topcoder-dev.com/projects/", "dbConfig": { "masterUrl": "postgres://coder:mysecretpassword@localhost:5432/projectsdb_test", "maxPoolSize": 50, diff --git a/migrations/20180608_project_add_templateId_and_new_tables.sql b/migrations/20180608_project_add_templateId_and_new_tables.sql index b0c463f2..5b1ede10 100644 --- a/migrations/20180608_project_add_templateId_and_new_tables.sql +++ b/migrations/20180608_project_add_templateId_and_new_tables.sql @@ -35,6 +35,7 @@ CREATE TABLE milestones ( duration integer NOT NULL, "startDate" timestamp with time zone NOT NULL, "endDate" timestamp with time zone, + "actualStartDate" timestamp with time zone, "completionDate" timestamp with time zone, status character varying(45) NOT NULL, type character varying(45) NOT NULL, @@ -108,6 +109,10 @@ CREATE TABLE product_milestone_templates ( duration integer NOT NULL, type character varying(45) NOT NULL, "order" integer NOT NULL, + "plannedText" character varying(512) NOT NULL, + "activeText" character varying(512) NOT NULL, + "blockedText" character varying(512) NOT NULL, + "completedText" character varying(512) NOT NULL, "deletedAt" timestamp with time zone, "createdAt" timestamp with time zone, "updatedAt" timestamp with time zone, @@ -313,9 +318,5 @@ ALTER TABLE ONLY milestones ALTER TABLE ONLY phase_products ADD CONSTRAINT "phase_products_phaseId_fkey" FOREIGN KEY ("phaseId") REFERENCES project_phases(id) ON UPDATE CASCADE ON DELETE SET NULL; - -ALTER TABLE ONLY product_milestone_templates - ADD CONSTRAINT "product_milestone_templates_productTemplateId_fkey" FOREIGN KEY ("productTemplateId") REFERENCES product_templates(id) ON UPDATE CASCADE ON DELETE CASCADE; - ALTER TABLE ONLY project_phases ADD CONSTRAINT "project_phases_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES projects(id) ON UPDATE CASCADE ON DELETE SET NULL; diff --git a/migrations/20180717_project_types_metadata.sql b/migrations/20180717_project_types_metadata.sql new file mode 100644 index 00000000..f856796f --- /dev/null +++ b/migrations/20180717_project_types_metadata.sql @@ -0,0 +1,13 @@ +-- +-- UPDATE EXISTING TABLES: +-- project_types +-- metadata column: added +-- + +-- +-- project_types +-- + +ALTER TABLE project_types ADD COLUMN "metadata" json; +UPDATE project_types set metadata='{}' where metadata is null; +ALTER TABLE project_types ALTER COLUMN "metadata" SET NOT NULL; diff --git a/migrations/20180727_product_categories.sql b/migrations/20180727_product_categories.sql new file mode 100644 index 00000000..56bc6022 --- /dev/null +++ b/migrations/20180727_product_categories.sql @@ -0,0 +1,35 @@ +-- UPDATE EXISTING TABLES: +-- product_templates +-- category column: added +-- CREATE NEW TABLE: +-- product_categories + +-- +-- product_categories +-- +CREATE TABLE product_categories ( + key character varying(45) NOT NULL, + "displayName" character varying(255) NOT NULL, + "icon" character varying(255) NOT NULL, + "info" character varying(255) NOT NULL, + "question" character varying(255) NOT NULL, + "aliases" json NOT NULL, + "hidden" boolean DEFAULT false, + "disabled" boolean DEFAULT false, + "deletedAt" timestamp with time zone, + "createdAt" timestamp with time zone, + "updatedAt" timestamp with time zone, + "deletedBy" integer, + "createdBy" integer NOT NULL, + "updatedBy" integer NOT NULL +); + +ALTER TABLE ONLY product_categories + ADD CONSTRAINT product_categories_pkey PRIMARY KEY (key); + +-- +-- product_templates +-- +ALTER TABLE product_templates ADD COLUMN "category" character varying(45); +UPDATE product_templates set category='generic' where category is null; +ALTER TABLE product_templates ALTER COLUMN "category" SET NOT NULL; diff --git a/migrations/20180801_deletedBy_soft_deleted.sql b/migrations/20180801_deletedBy_soft_deleted.sql new file mode 100644 index 00000000..95bde3e4 --- /dev/null +++ b/migrations/20180801_deletedBy_soft_deleted.sql @@ -0,0 +1,24 @@ +-- +-- UPDATE EXISTING TABLES: +-- projects +-- deletedBy column: added +-- project_attachments +-- deletedBy column: added +-- project_members +-- deletedBy column: added +-- + +-- +-- projects +-- +ALTER TABLE projects ADD COLUMN "deletedBy" bigint; + +-- +-- project_attachments +-- +ALTER TABLE project_attachments ADD COLUMN "deletedBy" bigint; + +-- +-- project_members +-- +ALTER TABLE project_members ADD COLUMN "deletedBy" bigint; \ No newline at end of file diff --git a/migrations/elasticsearch_sync.js b/migrations/elasticsearch_sync.js index 321f86cb..eac45e5f 100644 --- a/migrations/elasticsearch_sync.js +++ b/migrations/elasticsearch_sync.js @@ -16,6 +16,7 @@ import util from '../src/util'; const ES_PROJECT_INDEX = config.get('elasticsearchConfig.indexName'); const ES_PROJECT_TYPE = config.get('elasticsearchConfig.docType'); +const ES_TIMELINE_INDEX = config.get('elasticsearchConfig.timelineIndexName'); // create new elasticsearch client // the client modifies the config object, so always passed the cloned object @@ -323,10 +324,14 @@ esClient.indices.delete({ ignore: [404], }) .then(() => esClient.indices.create(getRequestBody(ES_PROJECT_INDEX))) +// Re-create timeline index +.then(() => esClient.indices.delete({ index: ES_TIMELINE_INDEX, ignore: [404] })) +.then(() => esClient.indices.create({ index: ES_TIMELINE_INDEX })) .then(() => { console.log('elasticsearch indices synced successfully'); process.exit(); -}).catch((err) => { +}) +.catch((err) => { console.error('elasticsearch indices sync failed', err); process.exit(); }); diff --git a/migrations/seedElasticsearchIndex.js b/migrations/seedElasticsearchIndex.js index 4a10ec48..cf353f8f 100644 --- a/migrations/seedElasticsearchIndex.js +++ b/migrations/seedElasticsearchIndex.js @@ -6,6 +6,7 @@ import config from 'config'; import Promise from 'bluebird'; import models from '../src/models'; import RabbitMQService from '../src/services/rabbitmq'; +import { TIMELINE_REFERENCES } from '../src/constants'; const logger = bunyan.createLogger({ name: 'init-es', level: config.get('logLevel') }); @@ -23,6 +24,19 @@ function getProjectIds() { return []; } +/** + * Retrieve timeline ids from cli if provided + * @return {Array} list of timelineIds + */ +function getTimelineIds() { + let timelineIdArg = _.find(process.argv, a => a.indexOf('timelineIds') > -1); + if (timelineIdArg) { + timelineIdArg = timelineIdArg.split('='); + return timelineIdArg[1].split(',').map(i => parseInt(i, 10)); + } + return []; +} + Promise.coroutine(function* wrapped() { try { const rabbit = new RabbitMQService(logger); @@ -58,12 +72,48 @@ Promise.coroutine(function* wrapped() { logger.info(`Retrieved #${members.length} members`); members = _.groupBy(members, 'projectId'); + // Get timelines + const timelineIds = getTimelineIds(); + const timelineWhereClause = (timelineIds.length > 0) ? { id: { $in: timelineIds } } : {}; + let timelines = yield models.Timeline.findAll({ + where: timelineWhereClause, + include: [{ model: models.Milestone, as: 'milestones' }], + }); + logger.info(`Retrieved #${projects.length} timelines`); + + // Convert to raw json and remove unnecessary fields + timelines = _.map(timelines, (timeline) => { + const entity = _.omit(timeline.toJSON(), ['deletedBy', 'deletedAt']); + entity.milestones = _.map(entity.milestones, milestone => _.omit(milestone, ['deletedBy', 'deletedAt'])); + return entity; + }); + + // Get projectId for each timeline + yield Promise.all( + _.map(timelines, (timeline) => { + if (timeline.reference === TIMELINE_REFERENCES.PROJECT) { + timeline.projectId = timeline.referenceId; + return Promise.resolve(timeline); + } + + return models.ProjectPhase.findById(timeline.referenceId) + .then((phase) => { + timeline.projectId = phase.projectId; + return Promise.resolve(timeline); + }); + }), + ); + const promises = []; _.forEach(projects, (p) => { p.members = members[p.id]; logger.debug(`Processing Project #${p.id}`); promises.push(rabbit.publish('project.initial', p, {})); }); + _.forEach(timelines, (t) => { + logger.debug(`Processing Timeline #${t.id}`); + promises.push(rabbit.publish('timeline.initial', t, {})); + }); Promise.all(promises) .then(() => { logger.info(`Published ${promises.length} msgs`); diff --git a/postman.json b/postman.json index d9cb23a4..ed8a690f 100644 --- a/postman.json +++ b/postman.json @@ -1,14 +1,268 @@ { "info": { - "_postman_id": "1791b330-5331-4768-a265-f1cb5e6b4492", "name": "tc-project-service", + "_postman_id": "63bb8939-b1c0-0c3c-ad9d-68e63063eda7", + "description": "", "schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json" }, "item": [ { "name": "Project Attachments", - "description": null, "item": [ + { + "name": "bookmarks", + "item": [ + { + "name": " Create project without bookmarks", + "request": { + "method": "POST", + "header": [ + { + "key": "Authorization", + "value": "Bearer {{jwt-token}}" + }, + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"param\": {\n \"type\": \"generic\",\n \"description\": \"test project\",\n \"details\": {},\n \"billingAccountId\": 123,\n \"name\": \"test project1\"\n }\n}" + }, + "url": { + "raw": "{{api-url}}/v4/projects", + "host": [ + "{{api-url}}" + ], + "path": [ + "v4", + "projects" + ] + } + }, + "response": [] + }, + { + "name": " Create project with valid bookmarks", + "request": { + "method": "POST", + "header": [ + { + "key": "Authorization", + "value": "Bearer {{jwt-token}}" + }, + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"param\": {\n \"type\": \"generic\",\n \"description\": \"test project\",\n \"details\": {},\n \"bookmarks\":[{\n \"title\":\"title1\",\n \"address\":\"address1\"\n },{\n \"title\":\"title2\",\n \"address\":\"address2\"\n }],\n \"billingAccountId\": 123,\n \"name\": \"test project1\"\n }\n}" + }, + "url": { + "raw": "{{api-url}}/v4/projects", + "host": [ + "{{api-url}}" + ], + "path": [ + "v4", + "projects" + ] + } + }, + "response": [] + }, + { + "name": " Create project with invalid bookmarks", + "request": { + "method": "POST", + "header": [ + { + "key": "Authorization", + "value": "Bearer {{jwt-token}}" + }, + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"param\": {\n \"type\": \"generic\",\n \"description\": \"test project\",\n \"details\": {},\n \"bookmarks\":[{\n \"title\":\"title1\",\n \"invalid\":3,\n \"address\":\"address1\"\n },{\n \"title\":\"title2\",\n \"address\":\"address2\"\n }],\n \"billingAccountId\": 123,\n \"name\": \"test project1\"\n }\n}" + }, + "url": { + "raw": "{{api-url}}/v4/projects", + "host": [ + "{{api-url}}" + ], + "path": [ + "v4", + "projects" + ] + } + }, + "response": [] + }, + { + "name": "get project", + "request": { + "method": "GET", + "header": [ + { + "key": "Authorization", + "value": "Bearer {{jwt-token}}" + }, + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"param\": {\n \"billingAccountId\": 9999, \n \"name\": \"new project name\"\n }\n}" + }, + "url": { + "raw": "{{api-url}}/v4/projects/2", + "host": [ + "{{api-url}}" + ], + "path": [ + "v4", + "projects", + "2" + ] + } + }, + "response": [] + }, + { + "name": "Update project with bookmarks", + "request": { + "method": "PATCH", + "header": [ + { + "key": "Authorization", + "value": "Bearer {{jwt-token}}" + }, + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"param\": {\n \"billingAccountId\": 9999, \n \"name\": \"new project name\",\n \"bookmarks\":[{\n \"title\":\"title1\",\n \"address\":\"address1\"\n },{\n \"title\":\"title2\",\n \"address\":\"address2\"\n }]\n }\n}" + }, + "url": { + "raw": "{{api-url}}/v4/projects/2", + "host": [ + "{{api-url}}" + ], + "path": [ + "v4", + "projects", + "2" + ] + } + }, + "response": [] + }, + { + "name": "Delete project all bookmarks null", + "request": { + "method": "PATCH", + "header": [ + { + "key": "Authorization", + "value": "Bearer {{jwt-token}}" + }, + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"param\": {\n \"billingAccountId\": 9999, \n \"name\": \"new project name2\",\n \"bookmarks\":null\n }\n}" + }, + "url": { + "raw": "{{api-url}}/v4/projects/2", + "host": [ + "{{api-url}}" + ], + "path": [ + "v4", + "projects", + "2" + ] + } + }, + "response": [] + }, + { + "name": "Update project with invalid bookmarks", + "request": { + "method": "PATCH", + "header": [ + { + "key": "Authorization", + "value": "Bearer {{jwt-token}}" + }, + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"param\": {\n \"billingAccountId\": 9999, \n \"name\": \"new project name2\",\n \"bookmarks\":3\n }\n}" + }, + "url": { + "raw": "{{api-url}}/v4/projects/2", + "host": [ + "{{api-url}}" + ], + "path": [ + "v4", + "projects", + "2" + ] + } + }, + "response": [] + }, + { + "name": "get projects with admin token", + "request": { + "method": "GET", + "header": [ + { + "key": "Authorization", + "value": "Bearer {{jwt-token}}" + } + ], + "body": { + "mode": "raw", + "raw": "" + }, + "url": { + "raw": "{{api-url}}/v4/projects", + "host": [ + "{{api-url}}" + ], + "path": [ + "v4", + "projects" + ] + } + }, + "response": [] + } + ], + "_postman_isSubFolder": true + }, { "name": "Upload attachment", "request": { @@ -115,9 +369,76 @@ } ] }, + { + "name": "Project With TemplateId issue", + "description": "", + "item": [ + { + "name": "Create project with templateId (not existed)", + "request": { + "method": "POST", + "header": [ + { + "key": "Authorization", + "value": "Bearer {{jwt-token}}" + }, + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n\t\"param\": {\n\t\t\"name\": \"test project with templateId\",\n\t\t\"description\": \"Hello I am a test project with templateId\",\n\t\t\"type\": \"generic\",\n\t\t\"templateId\": 3000\n\t}\n}" + }, + "url": { + "raw": "{{api-url}}/v4/projects", + "host": [ + "{{api-url}}" + ], + "path": [ + "v4", + "projects" + ] + } + }, + "response": [] + }, + { + "name": "Create project with templateId", + "request": { + "method": "POST", + "header": [ + { + "key": "Authorization", + "value": "Bearer {{jwt-token}}" + }, + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"param\": {\n \"name\": \"test project with templateId\",\n \"description\": \"Hello I am a test project with templateId\",\n \"type\": \"generic\",\n \"templateId\": 3\n }\n}" + }, + "url": { + "raw": "{{api-url}}/v4/projects", + "host": [ + "{{api-url}}" + ], + "path": [ + "v4", + "projects" + ] + } + }, + "response": [] + } + ] + }, { "name": "Project Members", - "description": null, "item": [ { "name": "Create project member with no payload", @@ -169,7 +490,7 @@ ], "body": { "mode": "raw", - "raw": "{\n\t\"role\": \"copilot\"\n}" + "raw": "{\n\"param\":{\n\t\"role\": \"copilot\"\n}\n}" }, "url": { "raw": "{{api-url}}/v4/projects/1/members", @@ -342,16 +663,16 @@ "raw": "{\n\t\"param\": {\n\t\t\"role\": \"copilot\",\n\t\t\"isPrimary\": true\n\t}\n}" }, "url": { - "raw": "{{api-url}}/v4/projects/7/members/16", + "raw": "{{api-url}}/v4/projects/1/members/1", "host": [ "{{api-url}}" ], "path": [ "v4", "projects", - "7", + "1", "members", - "16" + "1" ] }, "description": "Update a project's member." @@ -377,22 +698,56 @@ "raw": "{\n\t\"param\": {\n\t\t\"role\": \"copilot\",\n\t\t\"isPrimary\": false\n\t}\n}" }, "url": { - "raw": "{{api-url}}/v4/projects/7/members/16", + "raw": "{{api-url}}/v4/projects/1/members/1", "host": [ "{{api-url}}" ], "path": [ "v4", "projects", - "7", + "1", "members", - "16" + "1" ] }, "description": "Update a project's member." }, "response": [] }, + { + "name": "wrong role", + "request": { + "method": "PATCH", + "header": [ + { + "key": "Authorization", + "value": "Bearer {{jwt-token}}" + }, + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": " {\n \"param\": {\n \"role\": \"wrong\"\n }\n } " + }, + "url": { + "raw": "{{api-url}}/v4/projects/3/members/5", + "host": [ + "{{api-url}}" + ], + "path": [ + "v4", + "projects", + "3", + "members", + "5" + ] + } + }, + "response": [] + }, { "name": "Delete project member", "request": { @@ -412,21 +767,55 @@ "raw": "" }, "url": { - "raw": "{{api-url}}/v4/projects/7/members/15", + "raw": "{{api-url}}/v4/projects/3/members/5", "host": [ "{{api-url}}" ], "path": [ "v4", "projects", - "7", + "3", "members", - "15" + "5" ] }, "description": "Delete a project's member" }, "response": [] + }, + { + "name": "editing project member roles & primary option", + "request": { + "method": "PATCH", + "header": [ + { + "key": "Authorization", + "value": "Bearer {{jwt-token}}" + }, + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": " {\n \"param\": {\n \"role\": \"manager\",\n \"isPrimary\": true\n }\n } " + }, + "url": { + "raw": "{{api-url}}/v4/projects/1/members/2", + "host": [ + "{{api-url}}" + ], + "path": [ + "v4", + "projects", + "1", + "members", + "2" + ] + } + }, + "response": [] } ] }, @@ -762,6 +1151,33 @@ }, "response": [] }, + { + "name": "get projects with copilot token", + "request": { + "method": "GET", + "header": [ + { + "key": "Authorization", + "value": "Bearer {{jwt-token-copilot-40051332}}" + } + ], + "body": { + "mode": "raw", + "raw": "" + }, + "url": { + "raw": "{{api-url}}/v4/projects", + "host": [ + "{{api-url}}" + ], + "path": [ + "v4", + "projects" + ] + } + }, + "response": [] + }, { "name": "DELETE project by id", "request": { @@ -810,14 +1226,14 @@ "raw": "{\n \"param\": {\n \"name\": \"project name updated\"\n }\n}" }, "url": { - "raw": "{{api-url}}/v4/projects/13", + "raw": "{{api-url}}/v4/projects/1", "host": [ "{{api-url}}" ], "path": [ "v4", "projects", - "13" + "1" ] }, "description": "Update the project name. Name should be updated successfully." @@ -909,14 +1325,14 @@ "raw": "{\n \"param\": {\n \"status\": \"in_review\"\n }\n}" }, "url": { - "raw": "{{api-url}}/v4/projects/7", + "raw": "{{api-url}}/v4/projects/1", "host": [ "{{api-url}}" ], "path": [ "v4", "projects", - "7" + "1" ] }, "description": "Update the project status." @@ -1219,17 +1635,11 @@ "description": "Update the project bookmarks. This should fire project link created event" }, "response": [] - } - ] - }, - { - "name": "bookmarks", - "description": null, - "item": [ + }, { - "name": " Create project without bookmarks", + "name": "launch a project by topcoder managers ", "request": { - "method": "POST", + "method": "PATCH", "header": [ { "key": "Authorization", @@ -1242,25 +1652,26 @@ ], "body": { "mode": "raw", - "raw": "{\n \"param\": {\n \"type\": \"generic\",\n \"description\": \"test project\",\n \"details\": {},\n \"billingAccountId\": 123,\n \"name\": \"test project1\"\n }\n}" + "raw": "{\n \n \"param\":{\n \"name\": \"updatedProject name\",\n \"status\": \"active\"\n }\n}" }, "url": { - "raw": "{{api-url}}/v4/projects", + "raw": "{{api-url}}/v4/projects/1", "host": [ "{{api-url}}" ], "path": [ "v4", - "projects" + "projects", + "1" ] } }, "response": [] }, { - "name": " Create project with valid bookmarks", + "name": "launch a project by member", "request": { - "method": "POST", + "method": "PATCH", "header": [ { "key": "Authorization", @@ -1273,25 +1684,26 @@ ], "body": { "mode": "raw", - "raw": "{\n \"param\": {\n \"type\": \"generic\",\n \"description\": \"test project\",\n \"details\": {},\n \"bookmarks\":[{\n \"title\":\"title1\",\n \"address\":\"address1\"\n },{\n \"title\":\"title2\",\n \"address\":\"address2\"\n }],\n \"billingAccountId\": 123,\n \"name\": \"test project1\"\n }\n}" + "raw": "{\n \n \"param\":{\n \"name\": \"updatedProject name\",\n \"status\": \"active\"\n }\n}" }, "url": { - "raw": "{{api-url}}/v4/projects", + "raw": "{{api-url}}/v4/projects/1", "host": [ "{{api-url}}" ], "path": [ "v4", - "projects" + "projects", + "1" ] } }, "response": [] }, { - "name": " Create project with invalid bookmarks", + "name": "launch a project by copilot", "request": { - "method": "POST", + "method": "PATCH", "header": [ { "key": "Authorization", @@ -1304,23 +1716,29 @@ ], "body": { "mode": "raw", - "raw": "{\n \"param\": {\n \"type\": \"generic\",\n \"description\": \"test project\",\n \"details\": {},\n \"bookmarks\":[{\n \"title\":\"title1\",\n \"invalid\":3,\n \"address\":\"address1\"\n },{\n \"title\":\"title2\",\n \"address\":\"address2\"\n }],\n \"billingAccountId\": 123,\n \"name\": \"test project1\"\n }\n}" + "raw": "{\n \n \"param\":{\n \"name\": \"updatedProject name\",\n \"status\": \"active\"\n }\n}" }, "url": { - "raw": "{{api-url}}/v4/projects", + "raw": "{{api-url}}/v4/projects/1", "host": [ "{{api-url}}" ], "path": [ "v4", - "projects" + "projects", + "1" ] } }, "response": [] - }, + } + ] + }, + { + "name": "EventHandling and Integration with Direct Project API", + "item": [ { - "name": "get project", + "name": "mock direct projects", "request": { "method": "GET", "header": [ @@ -1335,26 +1753,29 @@ ], "body": { "mode": "raw", - "raw": "{\n \"param\": {\n \"billingAccountId\": 9999, \n \"name\": \"new project name\"\n }\n}" + "raw": " {\n \"param\": {\n \"role\": \"copilot\",\n \"isPrimary\": true\n }\n } " }, "url": { - "raw": "{{api-url}}/v4/projects/2", + "raw": "https://api.topcoder-dev.com/v3/direct/projects", + "protocol": "https", "host": [ - "{{api-url}}" + "api", + "topcoder-dev", + "com" ], "path": [ - "v4", - "projects", - "2" + "v3", + "direct", + "projects" ] } }, "response": [] }, { - "name": "Update project with bookmarks", + "name": " Create direct project when a new project is successfully created", "request": { - "method": "PATCH", + "method": "POST", "header": [ { "key": "Authorization", @@ -1367,26 +1788,25 @@ ], "body": { "mode": "raw", - "raw": "{\n \"param\": {\n \"billingAccountId\": 9999, \n \"name\": \"new project name\",\n \"bookmarks\":[{\n \"title\":\"title1\",\n \"address\":\"address1\"\n },{\n \"title\":\"title2\",\n \"address\":\"address2\"\n }]\n }\n}" + "raw": "{\n \"param\": {\n \"type\": \"generic\",\n \"description\": \"test project\",\n \"details\": {},\n \"billingAccountId\": 123,\n \"name\": \"test project1\"\n }\n}" }, "url": { - "raw": "{{api-url}}/v4/projects/2", + "raw": "{{api-url}}/v4/projects", "host": [ "{{api-url}}" ], "path": [ "v4", - "projects", - "2" + "projects" ] } }, "response": [] }, { - "name": "Delete project all bookmarks null", + "name": "Response error from direct project service", "request": { - "method": "PATCH", + "method": "POST", "header": [ { "key": "Authorization", @@ -1399,26 +1819,27 @@ ], "body": { "mode": "raw", - "raw": "{\n \"param\": {\n \"billingAccountId\": 9999, \n \"name\": \"new project name2\",\n \"bookmarks\":null\n }\n}" + "raw": "{\n \"param\": {\n \"userId\": 2, \n \"role\": \"copilot\"\n }\n}" }, "url": { - "raw": "{{api-url}}/v4/projects/2", + "raw": "{{api-url}}/v4/projects/1/members", "host": [ "{{api-url}}" ], "path": [ "v4", "projects", - "2" + "1", + "members" ] } }, "response": [] }, { - "name": "Update project with invalid bookmarks", + "name": " Add co-pilot when a co-pilot is added to a project", "request": { - "method": "PATCH", + "method": "POST", "header": [ { "key": "Authorization", @@ -1431,92 +1852,93 @@ ], "body": { "mode": "raw", - "raw": "{\n \"param\": {\n \"billingAccountId\": 9999, \n \"name\": \"new project name2\",\n \"bookmarks\":3\n }\n}" + "raw": "{\n \"param\": {\n \"userId\": 2, \n \"role\": \"copilot\"\n }\n}" }, "url": { - "raw": "{{api-url}}/v4/projects/2", + "raw": "{{api-url}}/v4/projects/2/members", "host": [ "{{api-url}}" ], "path": [ "v4", "projects", - "2" + "2", + "members" ] } }, "response": [] }, { - "name": "get projects with admin token", + "name": "remove copilot from direct project when editing project member role", "request": { - "method": "GET", + "method": "PATCH", "header": [ { "key": "Authorization", "value": "Bearer {{jwt-token}}" + }, + { + "key": "Content-Type", + "value": "application/json" } ], "body": { "mode": "raw", - "raw": "" + "raw": " {\n \"param\": {\n \"role\": \"customer\",\n \"isPrimary\": true\n }\n } " }, "url": { - "raw": "{{api-url}}/v4/projects", + "raw": "{{api-url}}/v4/projects/2/members/4", "host": [ "{{api-url}}" ], "path": [ "v4", - "projects" + "projects", + "2", + "members", + "4" ] } }, "response": [] - } - ] - }, - { - "name": "issue1", - "description": null, - "item": [ + }, { - "name": "get projects with copilot token", + "name": " Sync billing account id with direct", "request": { - "method": "GET", + "method": "PATCH", "header": [ { "key": "Authorization", "value": "Bearer {{jwt-token}}" + }, + { + "key": "Content-Type", + "value": "application/json" } ], "body": { "mode": "raw", - "raw": "" + "raw": "{\n \"param\": {\n \"billingAccountId\": 9999, \n \"name\": \"new project name\"\n }\n}" }, "url": { - "raw": "{{api-url}}/v4/projects", + "raw": "{{api-url}}/v4/projects/2", "host": [ "{{api-url}}" ], "path": [ "v4", - "projects" + "projects", + "2" ] } }, "response": [] - } - ] - }, - { - "name": "issue10", - "description": null, - "item": [ + }, { - "name": "wrong role", + "name": "Delete co-pilot when a co-pilot is removed from a project", "request": { - "method": "PATCH", + "method": "DELETE", "header": [ { "key": "Authorization", @@ -1529,28 +1951,55 @@ ], "body": { "mode": "raw", - "raw": " {\n \"param\": {\n \"role\": \"wrong\"\n }\n } " + "raw": "" }, "url": { - "raw": "{{api-url}}/v4/projects/3/members/5", + "raw": "{{api-url}}/v4/projects/2/members/4", "host": [ "{{api-url}}" ], "path": [ "v4", "projects", - "3", + "2", "members", - "5" + "4" ] } }, "response": [] + } + ], + "event": [ + { + "listen": "prerequest", + "script": { + "id": "ef96ac6a-0fc0-4a64-a4fe-5390e17afe67", + "type": "text/javascript", + "exec": [ + "" + ] + } }, { - "name": "editing project member roles & primary option", + "listen": "test", + "script": { + "id": "12f9d794-0872-4058-aafa-77b89e72025b", + "type": "text/javascript", + "exec": [ + "" + ] + } + } + ] + }, + { + "name": "Project Phase", + "item": [ + { + "name": "Create Phase", "request": { - "method": "PATCH", + "method": "POST", "header": [ { "key": "Authorization", @@ -1563,10 +2012,10 @@ ], "body": { "mode": "raw", - "raw": " {\n \"param\": {\n \"role\": \"manager\",\n \"isPrimary\": true\n }\n } " + "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/members/1", + "raw": "{{api-url}}/v4/projects/1/phases", "host": [ "{{api-url}}" ], @@ -1574,23 +2023,16 @@ "v4", "projects", "1", - "members", - "1" + "phases" ] } }, "response": [] - } - ] - }, - { - "name": "issue5", - "description": null, - "item": [ + }, { - "name": "launch a project by topcoder managers ", + "name": "List Phase", "request": { - "method": "PATCH", + "method": "GET", "header": [ { "key": "Authorization", @@ -1603,26 +2045,27 @@ ], "body": { "mode": "raw", - "raw": "{\n \n \"param\":{\n \"name\": \"updatedProject name\",\n \"status\": \"active\"\n }\n}" + "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", + "raw": "{{api-url}}/v4/projects/1/phases", "host": [ "{{api-url}}" ], "path": [ "v4", "projects", - "1" + "1", + "phases" ] } }, "response": [] }, { - "name": "launch a project by member", + "name": "List Phase with fields", "request": { - "method": "PATCH", + "method": "GET", "header": [ { "key": "Authorization", @@ -1635,26 +2078,33 @@ ], "body": { "mode": "raw", - "raw": "{\n \n \"param\":{\n \"name\": \"updatedProject name\",\n \"status\": \"active\"\n }\n}" + "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", + "raw": "{{api-url}}/v4/projects/1/phases?fields=status,name,budget", "host": [ "{{api-url}}" ], "path": [ "v4", "projects", - "1" + "1", + "phases" + ], + "query": [ + { + "key": "fields", + "value": "status,name,budget" + } ] } }, "response": [] }, { - "name": "launch a project by copilot", + "name": "List Phase with sort", "request": { - "method": "PATCH", + "method": "GET", "header": [ { "key": "Authorization", @@ -1667,30 +2117,31 @@ ], "body": { "mode": "raw", - "raw": "{\n \n \"param\":{\n \"name\": \"updatedProject name\",\n \"status\": \"active\"\n }\n}" + "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", + "raw": "{{api-url}}/v4/projects/1/phases?sort=status desc", "host": [ "{{api-url}}" ], "path": [ "v4", "projects", - "1" + "1", + "phases" + ], + "query": [ + { + "key": "sort", + "value": "status desc" + } ] } }, "response": [] - } - ] - }, - { - "name": "issue8", - "description": null, - "item": [ + }, { - "name": "mock direct projects", + "name": "Get Phase", "request": { "method": "GET", "header": [ @@ -1705,28 +2156,28 @@ ], "body": { "mode": "raw", - "raw": " {\n \"param\": {\n \"role\": \"copilot\",\n \"isPrimary\": true\n }\n } " + "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": "https://localhost:8443/v3/direct/projects", - "protocol": "https", + "raw": "{{api-url}}/v4/projects/1/phases/1", "host": [ - "localhost" + "{{api-url}}" ], - "port": "8443", "path": [ - "v3", - "direct", - "projects" + "v4", + "projects", + "1", + "phases", + "1" ] } }, "response": [] }, { - "name": " Create direct project when a new project is successfully created", + "name": "Update Phase", "request": { - "method": "POST", + "method": "PATCH", "header": [ { "key": "Authorization", @@ -1739,25 +2190,28 @@ ], "body": { "mode": "raw", - "raw": "{\n \"param\": {\n \"type\": \"generic\",\n \"description\": \"test project\",\n \"details\": {},\n \"billingAccountId\": 123,\n \"name\": \"test project1\"\n }\n}" + "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", + "raw": "{{api-url}}/v4/projects/1/phases/1", "host": [ "{{api-url}}" ], "path": [ "v4", - "projects" + "projects", + "1", + "phases", + "1" ] } }, "response": [] }, { - "name": "Response error from direct project service", + "name": "Delete Phase", "request": { - "method": "POST", + "method": "DELETE", "header": [ { "key": "Authorization", @@ -1770,10 +2224,10 @@ ], "body": { "mode": "raw", - "raw": "{\n \"param\": {\n \"userId\": 2, \n \"role\": \"copilot\"\n }\n}" + "raw": "" }, "url": { - "raw": "{{api-url}}/v4/projects/1/members", + "raw": "{{api-url}}/v4/projects/1/phases/3", "host": [ "{{api-url}}" ], @@ -1781,14 +2235,20 @@ "v4", "projects", "1", - "members" + "phases", + "3" ] } }, "response": [] - }, + } + ] + }, + { + "name": "Phase Products", + "item": [ { - "name": " Add co-pilot when a co-pilot is added to a project", + "name": "Create Phase Product", "request": { "method": "POST", "header": [ @@ -1803,93 +2263,949 @@ ], "body": { "mode": "raw", - "raw": "{\n \"param\": {\n \"userId\": 2, \n \"role\": \"copilot\"\n }\n}" + "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/2/members", + "raw": "{{api-url}}/v4/projects/1/phases/1/products", "host": [ "{{api-url}}" ], "path": [ "v4", "projects", - "2", - "members" + "1", + "phases", + "1", + "products" ] } }, "response": [] }, { - "name": "remove copilot from direct project when editing project member role", + "name": "List Phase Products", "request": { - "method": "PATCH", + "method": "GET", "header": [ { "key": "Authorization", "value": "Bearer {{jwt-token}}" - }, - { - "key": "Content-Type", - "value": "application/json" } ], "body": { "mode": "raw", - "raw": " {\n \"param\": {\n \"role\": \"customer\",\n \"isPrimary\": true\n }\n } " + "raw": "" }, "url": { - "raw": "{{api-url}}/v4/projects/2/members/4", + "raw": "{{api-url}}/v4/projects/1/phases/1/products", "host": [ "{{api-url}}" ], "path": [ "v4", "projects", - "2", - "members", - "4" + "1", + "phases", + "1", + "products" ] } }, "response": [] }, { - "name": " Sync billing account id with direct", + "name": "Get Phase Product", "request": { - "method": "PATCH", + "method": "GET", "header": [ { "key": "Authorization", "value": "Bearer {{jwt-token}}" - }, - { - "key": "Content-Type", - "value": "application/json" } ], "body": { "mode": "raw", - "raw": "{\n \"param\": {\n \"billingAccountId\": 9999, \n \"name\": \"new project name\"\n }\n}" + "raw": "" }, "url": { - "raw": "{{api-url}}/v4/projects/2", + "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", + "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\":\"app\",\r\n \"icon\": \"http://example.com/icon1.ico\",\r\n \"question\": \"question 1\",\r\n \"info\": \"info 1\",\r\n \"aliases\": [\"key-1\", \"key_1\"],\r\n \"scope\":{\r\n \"scope1\":\"scope 1\"\r\n },\r\n \"phases\":{\r\n \"phase1\":\"phase 1\"\r\n }\r\n }\r\n}" + }, + "url": { + "raw": "{{api-url}}/v4/projectTemplates", + "host": [ + "{{api-url}}" + ], + "path": [ + "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": { + "raw": "{{api-url}}/v4/projectTemplates", + "host": [ + "{{api-url}}" + ], + "path": [ + "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": { + "raw": "{{api-url}}/v4/projectTemplates/1", + "host": [ + "{{api-url}}" + ], + "path": [ + "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\":\"app\",\r\n \"scope\":{\r\n \"scope1\":\"scope 1\",\r\n \"scope2\": [\"a\"]\r\n },\r\n \"phases\":{\r\n \"phase1\":\"phase 1\",\r\n \"phase2\": {\r\n \t\"another\": \"another\"\r\n }\r\n }\r\n }\r\n}" + }, + "url": { + "raw": "{{api-url}}/v4/projectTemplates/1", + "host": [ + "{{api-url}}" + ], + "path": [ + "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": { + "raw": "{{api-url}}/v4/projectTemplates/2", + "host": [ + "{{api-url}}" + ], + "path": [ + "v4", + "projectTemplates", "2" ] } }, "response": [] + } + ] + }, + { + "name": "Product Templates", + "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\": \"name 1\",\r\n \"productKey\": \"productKey 1\",\r\n \"category\": \"key1\",\r\n \"icon\": \"http://example.com/icon1.ico\",\r\n \"brief\": \"brief 1\",\r\n \"details\": \"details 1\",\r\n \"aliases\": [\"product key 1\", \"product_key_1\"],\r\n \"template\": {\r\n \"template1\": {\r\n \"name\": \"template 1\",\r\n \"details\": {\r\n \"anyDetails\": \"any details 1\"\r\n },\r\n \"others\": [\"others 11\", \"others 12\"]\r\n },\r\n \"template2\": {\r\n \"name\": \"template 2\",\r\n \"details\": {\r\n \"anyDetails\": \"any details 2\"\r\n },\r\n \"others\": [\"others 21\", \"others 22\"]\r\n }\r\n }\r\n }\r\n }" + }, + "url": { + "raw": "{{api-url}}/v4/productTemplates", + "host": [ + "{{api-url}}" + ], + "path": [ + "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": { + "raw": "{{api-url}}/v4/productTemplates", + "host": [ + "{{api-url}}" + ], + "path": [ + "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": { + "raw": "{{api-url}}/v4/productTemplates/3", + "host": [ + "{{api-url}}" + ], + "path": [ + "v4", + "productTemplates", + "3" + ] + } + }, + "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 \"category\":\"key1\",\r\n \"icon\":\"http://example.com/icon-new.ico\",\r\n \"brief\": \"new brief\",\r\n \"details\": \"new details\",\r\n \"aliases\":{\r\n \"alias1\":\"scope 1\",\r\n \"alias2\": [\"a\"]\r\n },\r\n \"template\":{\r\n \"template1\":\"template 1\",\r\n \"template2\": {\r\n \t\"another\": \"another\"\r\n }\r\n }\r\n }\r\n}" + }, + "url": { + "raw": "{{api-url}}/v4/productTemplates/1", + "host": [ + "{{api-url}}" + ], + "path": [ + "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": "" + }, + "url": { + "raw": "{{api-url}}/v4/productTemplates/1", + "host": [ + "{{api-url}}" + ], + "path": [ + "v4", + "productTemplates", + "1" + ] + } + }, + "response": [] + } + ] + }, + { + "name": "Project Type", + "item": [ + { + "name": "Create project type", + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + }, + { + "key": "Authorization", + "value": "Bearer {{jwt-token}}" + } + ], + "body": { + "mode": "raw", + "raw": "{\r\n \"param\":{\r\n \"key\": \"new key\",\r\n \"displayName\": \"new displayName\",\r\n \"icon\": \"http://example.com/icon4.ico\",\r\n \t\"question\": \"question 4\",\r\n \t\"info\": \"info 4\",\r\n \t\"aliases\": [\"key-41\", \"key_42\"]\r\n }\r\n}" + }, + "url": { + "raw": "{{api-url}}/v4/projectTypes", + "host": [ + "{{api-url}}" + ], + "path": [ + "v4", + "projectTypes" + ] + } + }, + "response": [] + }, + { + "name": "List project types", + "request": { + "method": "GET", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + }, + { + "key": "Authorization", + "value": "Bearer {{jwt-token}}" + } + ], + "body": { + "mode": "raw", + "raw": "{\r\n \"param\":{\r\n \"name\":\"new name\",\r\n \"key\":\"new key\",\r\n \"category\":\"new category\",\r\n \"scope\":{\r\n \"scope1\":\"scope 1\"\r\n },\r\n \"phases\":{\r\n \"phase1\":\"phase 1\"\r\n }\r\n }\r\n}" + }, + "url": { + "raw": "{{api-url}}/v4/projectTypes", + "host": [ + "{{api-url}}" + ], + "path": [ + "v4", + "projectTypes" + ] + } + }, + "response": [] + }, + { + "name": "Get project type", + "request": { + "method": "GET", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + }, + { + "key": "Authorization", + "value": "Bearer {{jwt-token}}" + } + ], + "body": { + "mode": "raw", + "raw": "{\r\n \"param\":{\r\n \"name\":\"new name\",\r\n \"key\":\"new key\",\r\n \"category\":\"new category\",\r\n \"scope\":{\r\n \"scope1\":\"scope 1\"\r\n },\r\n \"phases\":{\r\n \"phase1\":\"phase 1\"\r\n }\r\n }\r\n}" + }, + "url": { + "raw": "{{api-url}}/v4/projectTypes/generic", + "host": [ + "{{api-url}}" + ], + "path": [ + "v4", + "projectTypes", + "generic" + ] + } + }, + "response": [] + }, + { + "name": "Update project type", + "request": { + "method": "PATCH", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + }, + { + "key": "Authorization", + "value": "Bearer {{jwt-token}}" + } + ], + "body": { + "mode": "raw", + "raw": "{\r\n \"param\":{\r\n \"displayName\": \"Chatbot-updated\"\r\n }\r\n}" + }, + "url": { + "raw": "{{api-url}}/v4/projectTypes/chatbot", + "host": [ + "{{api-url}}" + ], + "path": [ + "v4", + "projectTypes", + "chatbot" + ] + } + }, + "response": [] + }, + { + "name": "Delete project type", + "request": { + "method": "DELETE", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + }, + { + "key": "Authorization", + "value": "Bearer {{jwt-token}}" + } + ], + "body": { + "mode": "raw", + "raw": "" + }, + "url": { + "raw": "{{api-url}}/v4/projectTypes/chatbot", + "host": [ + "{{api-url}}" + ], + "path": [ + "v4", + "projectTypes", + "chatbot" + ] + } + }, + "response": [] + } + ] + }, + { + "name": "Product Category", + "item": [ + { + "name": "Create product category", + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + }, + { + "key": "Authorization", + "value": "Bearer {{jwt-token}}" + } + ], + "body": { + "mode": "raw", + "raw": "{\r\n \"param\":{\r\n \"key\": \"generic\",\r\n \"displayName\": \"new displayName\",\r\n \"icon\": \"icon\",\r\n \"question\": \"question\",\r\n \"info\": \"info\",\r\n \"aliases\": [\"key-1\", \"key-2\"]\r\n }\r\n}" + }, + "url": { + "raw": "{{api-url}}/v4/productCategories", + "host": [ + "{{api-url}}" + ], + "path": [ + "v4", + "productCategories" + ] + } + }, + "response": [] + }, + { + "name": "List product categories", + "request": { + "method": "GET", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + }, + { + "key": "Authorization", + "value": "Bearer {{jwt-token}}" + } + ], + "body": { + "mode": "raw", + "raw": "{\r\n \"param\":{\r\n \"name\":\"new name\",\r\n \"key\":\"new key\",\r\n \"category\":\"new category\",\r\n \"scope\":{\r\n \"scope1\":\"scope 1\"\r\n },\r\n \"phases\":{\r\n \"phase1\":\"phase 1\"\r\n }\r\n }\r\n}" + }, + "url": { + "raw": "{{api-url}}/v4/productCategories", + "host": [ + "{{api-url}}" + ], + "path": [ + "v4", + "productCategories" + ] + } + }, + "response": [] + }, + { + "name": "Get product category", + "request": { + "method": "GET", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + }, + { + "key": "Authorization", + "value": "Bearer {{jwt-token}}" + } + ], + "body": { + "mode": "raw", + "raw": "{\r\n \"param\":{\r\n \"name\":\"new name\",\r\n \"key\":\"new key\",\r\n \"category\":\"new category\",\r\n \"scope\":{\r\n \"scope1\":\"scope 1\"\r\n },\r\n \"phases\":{\r\n \"phase1\":\"phase 1\"\r\n }\r\n }\r\n}" + }, + "url": { + "raw": "{{api-url}}/v4/productCategories/generic", + "host": [ + "{{api-url}}" + ], + "path": [ + "v4", + "productCategories", + "generic" + ] + } + }, + "response": [] + }, + { + "name": "Update product category", + "request": { + "method": "PATCH", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + }, + { + "key": "Authorization", + "value": "Bearer {{jwt-token}}" + } + ], + "body": { + "mode": "raw", + "raw": "{\r\n \"param\":{\r\n \"displayName\": \"Chatbot-updated\"\r\n }\r\n}" + }, + "url": { + "raw": "{{api-url}}/v4/productCategories/generic", + "host": [ + "{{api-url}}" + ], + "path": [ + "v4", + "productCategories", + "generic" + ] + } + }, + "response": [] + }, + { + "name": "Delete product category", + "request": { + "method": "DELETE", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + }, + { + "key": "Authorization", + "value": "Bearer {{jwt-token}}" + } + ], + "body": { + "mode": "raw", + "raw": "{\r\n \"param\":{\r\n \"name\":\"new name\",\r\n \"key\":\"new key\",\r\n \"category\":\"new category\",\r\n \"scope\":{\r\n \"scope1\":\"scope 1\",\r\n \"scope2\": [\"a\"]\r\n },\r\n \"phases\":{\r\n \"phase1\":\"phase 1\",\r\n \"phase2\": {\r\n \t\"another\": \"another\"\r\n }\r\n }\r\n }\r\n}" + }, + "url": { + "raw": "{{api-url}}/v4/productCategories/generic", + "host": [ + "{{api-url}}" + ], + "path": [ + "v4", + "productCategories", + "generic" + ] + } + }, + "response": [] + } + ], + "auth": { + "type": "bearer", + "bearer": [ + { + "key": "token", + "value": "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJyb2xlcyI6WyJhZG1pbmlzdHJhdG9yIl0sImlzcyI6Imh0dHBzOi8vYXBpLnRvcGNvZGVyLWRldi5jb20iLCJoYW5kbGUiOiJwc2hhaDEiLCJleHAiOjI0NjI0OTQ2MTgsInVzZXJJZCI6IjQwMTM1OTc4IiwiaWF0IjoxNDYyNDk0MDE4LCJlbWFpbCI6InBzaGFoMUB0ZXN0LmNvbSIsImp0aSI6ImY0ZTFhNTE0LTg5ODAtNDY0MC04ZWM1LWUzNmUzMWE3ZTg0OSJ9.XuNN7tpMOXvBG1QwWRQROj7NfuUbqhkjwn39Vy4tR5I", + "type": "string" + } + ] + }, + "event": [ + { + "listen": "prerequest", + "script": { + "id": "f0092ef5-e624-4c25-87b2-b6a9e4c81ec8", + "type": "text/javascript", + "exec": [ + "" + ] + } + }, + { + "listen": "test", + "script": { + "id": "9183c429-a5e0-4bf9-96a2-89f4d66e9b0d", + "type": "text/javascript", + "exec": [ + "" + ] + } + } + ] + }, + { + "name": "Project upgrade", + "description": "Request to migrate projects.", + "item": [ + { + "name": "Migrate project", + "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\"targetVersion\": \"v3\",\n\t\t\"defaultProductTemplateId\": 3\n\t}\n}" + }, + "url": { + "raw": "{{api-url}}/v4/projects/6/upgrade", + "host": [ + "{{api-url}}" + ], + "path": [ + "v4", + "projects", + "6", + "upgrade" + ] + } + }, + "response": [] + }, + { + "name": "Migrate project (completed)", + "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\"targetVersion\": \"v3\",\n\t\t\"defaultProductTemplateId\": 3\n\t}\n}" + }, + "url": { + "raw": "{{api-url}}/v4/projects/7/upgrade", + "host": [ + "{{api-url}}" + ], + "path": [ + "v4", + "projects", + "7", + "upgrade" + ] + } + }, + "response": [] + }, + { + "name": "Migrate project with phase name", + "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\"targetVersion\": \"v3\",\n\t\t\"defaultProductTemplateId\": 3,\n\t\t\"phaseName\": \"Custom phase name\"\n\t}\n}" + }, + "url": { + "raw": "{{api-url}}/v4/projects/6/upgrade", + "host": [ + "{{api-url}}" + ], + "path": [ + "v4", + "projects", + "6", + "upgrade" + ] + } + }, + "response": [] }, { - "name": "Delete co-pilot when a co-pilot is removed from a project", + "name": "Migrate project with phase name (completed)", "request": { - "method": "DELETE", + "method": "POST", "header": [ { "key": "Authorization", @@ -1902,19 +3218,18 @@ ], "body": { "mode": "raw", - "raw": "" + "raw": "{\n\t\"param\": {\n\t\t\"targetVersion\": \"v3\",\n\t\t\"defaultProductTemplateId\": 3,\n\t\t\"phaseName\": \"Custom phase name\"\n\t}\n}" }, "url": { - "raw": "{{api-url}}/v4/projects/2/members/4", + "raw": "{{api-url}}/v4/projects/7/upgrade", "host": [ "{{api-url}}" ], "path": [ "v4", "projects", - "2", - "members", - "4" + "7", + "upgrade" ] } }, @@ -1923,108 +3238,102 @@ ] }, { - "name": "Project Phase", - "description": null, + "name": "Timeline", "item": [ { - "name": "Create Phase", + "name": "Create timeline", "request": { "method": "POST", "header": [ - { - "key": "Authorization", - "value": "Bearer {{jwt-token}}" - }, { "key": "Content-Type", "value": "application/json" + }, + { + "key": "Authorization", + "value": "Bearer {{jwt-token-connectAdmin-40051336}}" } ], "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}" + "raw": "{\r\n \"param\":{\r\n \"name\":\"new name\",\r\n \"description\":\"new description\",\r\n \"startDate\":\"2018-05-29T00:00:00.000Z\",\r\n \"endDate\": \"2018-05-30T00:00:00.000Z\",\r\n \"reference\": \"project\",\r\n \"referenceId\": 1\r\n }\r\n}" }, "url": { - "raw": "{{api-url}}/v4/projects/1/phases", + "raw": "{{api-url}}/v4/timelines", "host": [ "{{api-url}}" ], "path": [ "v4", - "projects", - "1", - "phases" + "timelines" ] } }, "response": [] }, { - "name": "List Phase", + "name": "Create timeline with invalid data", "request": { - "method": "GET", + "method": "POST", "header": [ - { - "key": "Authorization", - "value": "Bearer {{jwt-token}}" - }, { "key": "Content-Type", "value": "application/json" + }, + { + "key": "Authorization", + "value": "Bearer {{jwt-token-connectAdmin-40051336}}" } ], "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}" + "raw": "{\r\n \"param\":{\r\n \"startDate\":\"2018-05-29T00:00:00.000Z\",\r\n \"endDate\": \"2018-05-28T00:00:00.000Z\",\r\n \"reference\": \"invalid\",\r\n \"referenceId\": 0\r\n }\r\n}" }, "url": { - "raw": "{{api-url}}/v4/projects/1/phases", + "raw": "{{api-url}}/v4/timelines", "host": [ "{{api-url}}" ], "path": [ "v4", - "projects", - "1", - "phases" + "timelines" ] } }, "response": [] }, { - "name": "List Phase with fields", + "name": "List timelines (filter by reference and referenceId)", "request": { "method": "GET", "header": [ - { - "key": "Authorization", - "value": "Bearer {{jwt-token}}" - }, { "key": "Content-Type", "value": "application/json" + }, + { + "key": "Authorization", + "value": "Bearer {{jwt-token-copilot-40051332}}" } ], "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}" + "raw": "{\r\n \"param\":{\r\n \"name\":\"new name\",\r\n \"key\":\"new key\",\r\n \"category\":\"new category\",\r\n \"scope\":{\r\n \"scope1\":\"scope 1\"\r\n },\r\n \"phases\":{\r\n \"phase1\":\"phase 1\"\r\n }\r\n }\r\n}" }, "url": { - "raw": "{{api-url}}/v4/projects/1/phases?fields=status,name,budget", + "raw": "{{api-url}}/v4/timelines?filter=reference%3Dphase%26referenceId%3D1", "host": [ "{{api-url}}" ], "path": [ "v4", - "projects", - "1", - "phases" + "timelines" ], "query": [ { - "key": "fields", - "value": "status,name,budget" + "key": "filter", + "value": "reference%3Dphase%26referenceId%3D1", + "equals": true } ] } @@ -2032,72 +3341,63 @@ "response": [] }, { - "name": "List Phase with sort", + "name": "Get timeline", "request": { "method": "GET", "header": [ - { - "key": "Authorization", - "value": "Bearer {{jwt-token}}" - }, { "key": "Content-Type", "value": "application/json" + }, + { + "key": "Authorization", + "value": "Bearer {{jwt-token}}" } ], "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}" + "raw": "{\r\n \"param\":{\r\n \"name\":\"new name\",\r\n \"key\":\"new key\",\r\n \"category\":\"new category\",\r\n \"scope\":{\r\n \"scope1\":\"scope 1\"\r\n },\r\n \"phases\":{\r\n \"phase1\":\"phase 1\"\r\n }\r\n }\r\n}" }, "url": { - "raw": "{{api-url}}/v4/projects/1/phases?sort=status desc", + "raw": "{{api-url}}/v4/timelines/1", "host": [ "{{api-url}}" ], "path": [ "v4", - "projects", - "1", - "phases" - ], - "query": [ - { - "key": "sort", - "value": "status desc" - } + "timelines", + "1" ] } }, "response": [] }, { - "name": "Get Phase", + "name": "Update timeline", "request": { - "method": "GET", + "method": "PATCH", "header": [ - { - "key": "Authorization", - "value": "Bearer {{jwt-token}}" - }, { "key": "Content-Type", "value": "application/json" + }, + { + "key": "Authorization", + "value": "Bearer {{jwt-token}}" } ], "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}" + "raw": "{\r\n \"param\":{\r\n \"name\": \"timeline 1-updated\",\r\n \"description\": \"description-updated\",\r\n \"startDate\": \"2018-05-01T00:00:00.000Z\",\r\n \"endDate\": null,\r\n \"reference\": \"project\",\r\n \"referenceId\": 1\r\n }\r\n}" }, "url": { - "raw": "{{api-url}}/v4/projects/1/phases/1", + "raw": "{{api-url}}/v4/timelines/1", "host": [ "{{api-url}}" ], "path": [ "v4", - "projects", - "1", - "phases", + "timelines", "1" ] } @@ -2105,33 +3405,63 @@ "response": [] }, { - "name": "Update Phase", + "name": "Update timeline (startDate)", "request": { "method": "PATCH", "header": [ + { + "key": "Content-Type", + "value": "application/json" + }, { "key": "Authorization", "value": "Bearer {{jwt-token}}" - }, + } + ], + "body": { + "mode": "raw", + "raw": "{\r\n \"param\":{\r\n \"name\": \"timeline 1-updated\",\r\n \"description\": \"description-updated\",\r\n \"startDate\": \"2018-05-05T00:00:00.000Z\",\r\n \"endDate\": null,\r\n \"reference\": \"project\",\r\n \"referenceId\": 1\r\n }\r\n}" + }, + "url": { + "raw": "{{api-url}}/v4/timelines/1", + "host": [ + "{{api-url}}" + ], + "path": [ + "v4", + "timelines", + "1" + ] + } + }, + "response": [] + }, + { + "name": "Update timeline (endDate)", + "request": { + "method": "PATCH", + "header": [ { "key": "Content-Type", "value": "application/json" + }, + { + "key": "Authorization", + "value": "Bearer {{jwt-token}}" } ], "body": { "mode": "raw", - "raw": "{\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}" + "raw": "{\r\n \"param\":{\r\n \"name\": \"timeline 1-updated\",\r\n \"description\": \"description-updated\",\r\n \"startDate\": \"2018-05-04T00:00:00.000Z\",\r\n \"endDate\": \"2018-05-05T00:00:00.000Z\",\r\n \"reference\": \"project\",\r\n \"referenceId\": 1\r\n }\r\n}" }, "url": { - "raw": "{{api-url}}/v4/projects/1/phases/1", + "raw": "{{api-url}}/v4/timelines/1", "host": [ "{{api-url}}" ], "path": [ "v4", - "projects", - "1", - "phases", + "timelines", "1" ] } @@ -2139,17 +3469,17 @@ "response": [] }, { - "name": "Delete Phase", + "name": "Delete timeline", "request": { "method": "DELETE", "header": [ - { - "key": "Authorization", - "value": "Bearer {{jwt-token}}" - }, { "key": "Content-Type", "value": "application/json" + }, + { + "key": "Authorization", + "value": "Bearer {{jwt-token}}" } ], "body": { @@ -2157,16 +3487,14 @@ "raw": "" }, "url": { - "raw": "{{api-url}}/v4/projects/1/phases/3", + "raw": "{{api-url}}/v4/timelines/4", "host": [ "{{api-url}}" ], "path": [ "v4", - "projects", - "1", - "phases", - "3" + "timelines", + "4" ] } }, @@ -2175,137 +3503,174 @@ ] }, { - "name": "Phase Products", - "description": null, + "name": "Milestone", "item": [ { - "name": "Create Phase Product", + "name": "Create milestone", "request": { "method": "POST", "header": [ { - "key": "Authorization", - "value": "Bearer {{jwt-token}}" + "key": "Content-Type", + "value": "application/json" }, + { + "key": "Authorization", + "value": "Bearer {{jwt-token-member-40051331}}" + } + ], + "body": { + "mode": "raw", + "raw": "{\r\n \"param\":{\r\n \"name\": \"milestone 3\",\r\n \"description\": \"description 3\",\r\n \"duration\": 4,\r\n \"startDate\": \"2018-05-05T00:00:00.000Z\",\r\n \"endDate\": \"2018-05-07T00:00:00.000Z\",\r\n \"completionDate\": \"2018-05-08T00:00:00.000Z\",\r\n \"status\": \"open\",\r\n \"type\": \"type3\",\r\n \"details\": {\r\n \"detail1\": {\r\n \"subDetail1C\": 3\r\n },\r\n \"detail2\": [\r\n 2,\r\n 3,\r\n 4\r\n ]\r\n },\r\n \"order\": 1,\r\n \"plannedText\": \"plannedText 3\",\r\n \"activeText\": \"activeText 3\",\r\n \"completedText\": \"completedText 3\",\r\n \"blockedText\": \"blockedText 3\"\r\n }\r\n}" + }, + "url": { + "raw": "{{api-url}}/v4/timelines/1/milestones", + "host": [ + "{{api-url}}" + ], + "path": [ + "v4", + "timelines", + "1", + "milestones" + ] + } + }, + "response": [] + }, + { + "name": "Create milestone with invalid data", + "request": { + "method": "POST", + "header": [ { "key": "Content-Type", "value": "application/json" + }, + { + "key": "Authorization", + "value": "Bearer {{jwt-token-member-40051331}}" } ], "body": { "mode": "raw", - "raw": "{\n\t\"param\": {\n\t\t\"name\": \"test phase product\",\n\t\t\"type\": \"type 1\",\n\t\t\"estimatedPrice\": 10\n\t}\n}" + "raw": "{\r\n \"param\":{\r\n \"startDate\": \"2018-05-05T00:00:00.000Z\",\r\n \"endDate\": \"2018-05-04T00:00:00.000Z\",\r\n \"completionDate\": \"2018-05-04T00:00:00.000Z\"\r\n }\r\n}" }, "url": { - "raw": "{{api-url}}/v4/projects/1/phases/1/products", + "raw": "{{api-url}}/v4/timelines/1/milestones", "host": [ "{{api-url}}" ], "path": [ "v4", - "projects", - "1", - "phases", + "timelines", "1", - "products" + "milestones" ] } }, "response": [] }, { - "name": "List Phase Products", + "name": "List milestones", "request": { "method": "GET", "header": [ + { + "key": "Content-Type", + "value": "application/json" + }, { "key": "Authorization", - "value": "Bearer {{jwt-token}}" + "value": "Bearer {{jwt-token-copilot-40051332}}" } ], "body": { "mode": "raw", - "raw": "" + "raw": "{\r\n \"param\":{\r\n \"name\":\"new name\",\r\n \"key\":\"new key\",\r\n \"category\":\"new category\",\r\n \"scope\":{\r\n \"scope1\":\"scope 1\"\r\n },\r\n \"phases\":{\r\n \"phase1\":\"phase 1\"\r\n }\r\n }\r\n}" }, "url": { - "raw": "{{api-url}}/v4/projects/1/phases/1/products", + "raw": "{{api-url}}/v4/timelines/1/milestones", "host": [ "{{api-url}}" ], "path": [ "v4", - "projects", - "1", - "phases", + "timelines", "1", - "products" + "milestones" ] } }, "response": [] }, { - "name": "Get Phase Product", + "name": "List milestones (sort)", "request": { "method": "GET", "header": [ + { + "key": "Content-Type", + "value": "application/json" + }, { "key": "Authorization", - "value": "Bearer {{jwt-token}}" + "value": "Bearer {{jwt-token-copilot-40051332}}" } ], "body": { "mode": "raw", - "raw": "" + "raw": "{\r\n \"param\":{\r\n \"name\":\"new name\",\r\n \"key\":\"new key\",\r\n \"category\":\"new category\",\r\n \"scope\":{\r\n \"scope1\":\"scope 1\"\r\n },\r\n \"phases\":{\r\n \"phase1\":\"phase 1\"\r\n }\r\n }\r\n}" }, "url": { - "raw": "{{api-url}}/v4/projects/1/phases/1/products/1", + "raw": "{{api-url}}/v4/timelines/1/milestones?sort=order desc", "host": [ "{{api-url}}" ], "path": [ "v4", - "projects", - "1", - "phases", + "timelines", "1", - "products", - "1" + "milestones" + ], + "query": [ + { + "key": "sort", + "value": "order desc" + } ] } }, "response": [] }, { - "name": "Update Phase Product", + "name": "Get milestone", "request": { - "method": "PATCH", + "method": "GET", "header": [ - { - "key": "Authorization", - "value": "Bearer {{jwt-token}}" - }, { "key": "Content-Type", "value": "application/json" + }, + { + "key": "Authorization", + "value": "Bearer {{jwt-token}}" } ], "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}" + "raw": "{\r\n \"param\":{\r\n \"name\":\"new name\",\r\n \"key\":\"new key\",\r\n \"category\":\"new category\",\r\n \"scope\":{\r\n \"scope1\":\"scope 1\"\r\n },\r\n \"phases\":{\r\n \"phase1\":\"phase 1\"\r\n }\r\n }\r\n}" }, "url": { - "raw": "{{api-url}}/v4/projects/1/phases/1/products/1", + "raw": "{{api-url}}/v4/timelines/1/milestones/1", "host": [ "{{api-url}}" ], "path": [ "v4", - "projects", - "1", - "phases", + "timelines", "1", - "products", + "milestones", "1" ] } @@ -2313,10 +3678,14 @@ "response": [] }, { - "name": "Delete Phase Product", + "name": "Update milestone", "request": { - "method": "DELETE", + "method": "PATCH", "header": [ + { + "key": "Content-Type", + "value": "application/json" + }, { "key": "Authorization", "value": "Bearer {{jwt-token}}" @@ -2324,36 +3693,28 @@ ], "body": { "mode": "raw", - "raw": "" + "raw": "{\r\n \"param\":{\r\n \"name\": \"milestone 1-updated\",\r\n \"description\": \"description-updated\",\r\n \"duration\": 3,\r\n \"completionDate\": \"2018-05-07T00:00:00.000Z\",\r\n \"status\": \"closed\",\r\n \"type\": \"type2\",\r\n \"details\": {\r\n \"detail1\": {\r\n \"subDetail1C\": 3\r\n },\r\n \"detail2\": [\r\n 4\r\n ]\r\n },\r\n \"order\": 1,\r\n \"plannedText\": \"plannedText 1-updated\",\r\n \"activeText\": \"activeText 1-updated\",\r\n \"completedText\": \"completedText 1-updated\",\r\n \"blockedText\": \"blockedText 1-updated\"\r\n }\r\n}" }, "url": { - "raw": "{{api-url}}/v4/projects/1/phases/1/products/1", + "raw": "{{api-url}}/v4/timelines/1/milestones/1", "host": [ "{{api-url}}" ], "path": [ "v4", - "projects", + "timelines", "1", - "phases", - "1", - "products", + "milestones", "1" ] - } - }, - "response": [] - } - ] - }, - { - "name": "Project Templates", - "description": null, - "item": [ + } + }, + "response": [] + }, { - "name": "Create project template", + "name": "Update milestone (order 1 => 2)", "request": { - "method": "POST", + "method": "PATCH", "header": [ { "key": "Content-Type", @@ -2366,25 +3727,28 @@ ], "body": { "mode": "raw", - "raw": "{\r\n \"param\":{\r\n \"name\":\"new name\",\r\n \"key\":\"new key\",\r\n \"category\":\"new category\",\r\n \"scope\":{\r\n \"scope1\":\"scope 1\"\r\n },\r\n \"phases\":{\r\n \"phase1\":\"phase 1\"\r\n }\r\n }\r\n}" + "raw": "{\r\n \"param\":{\r\n \"name\": \"milestone 1-updated\",\r\n \"description\": \"description-updated\",\r\n \"duration\": 3,\r\n \"completionDate\": \"2018-05-07T00:00:00.000Z\",\r\n \"status\": \"closed\",\r\n \"type\": \"type2\",\r\n \"details\": {\r\n \"detail1\": {\r\n \"subDetail1C\": 3\r\n },\r\n \"detail2\": [\r\n 4\r\n ]\r\n },\r\n \"order\": 2,\r\n \"plannedText\": \"plannedText 1-updated\",\r\n \"activeText\": \"activeText 1-updated\",\r\n \"completedText\": \"completedText 1-updated\",\r\n \"blockedText\": \"blockedText 1-updated\"\r\n }\r\n}" }, "url": { - "raw": "{{api-url}}/v4/projectTemplates", + "raw": "{{api-url}}/v4/timelines/1/milestones/1", "host": [ "{{api-url}}" ], "path": [ "v4", - "projectTemplates" + "timelines", + "1", + "milestones", + "1" ] } }, "response": [] }, { - "name": "List project templates", + "name": "Update milestone (order 2 => 1)", "request": { - "method": "GET", + "method": "PATCH", "header": [ { "key": "Content-Type", @@ -2397,25 +3761,28 @@ ], "body": { "mode": "raw", - "raw": "{\r\n \"param\":{\r\n \"name\":\"new name\",\r\n \"key\":\"new key\",\r\n \"category\":\"new category\",\r\n \"scope\":{\r\n \"scope1\":\"scope 1\"\r\n },\r\n \"phases\":{\r\n \"phase1\":\"phase 1\"\r\n }\r\n }\r\n}" + "raw": "{\r\n \"param\":{\r\n \"name\": \"milestone 1-updated\",\r\n \"description\": \"description-updated\",\r\n \"duration\": 3,\r\n \"completionDate\": \"2018-05-07T00:00:00.000Z\",\r\n \"status\": \"closed\",\r\n \"type\": \"type2\",\r\n \"details\": {\r\n \"detail1\": {\r\n \"subDetail1C\": 3\r\n },\r\n \"detail2\": [\r\n 4\r\n ]\r\n },\r\n \"order\": 1,\r\n \"plannedText\": \"plannedText 1-updated\",\r\n \"activeText\": \"activeText 1-updated\",\r\n \"completedText\": \"completedText 1-updated\",\r\n \"blockedText\": \"blockedText 1-updated\"\r\n }\r\n}" }, "url": { - "raw": "{{api-url}}/v4/projectTemplates", + "raw": "{{api-url}}/v4/timelines/1/milestones/1", "host": [ "{{api-url}}" ], "path": [ "v4", - "projectTemplates" + "timelines", + "1", + "milestones", + "1" ] } }, "response": [] }, { - "name": "Get project template", + "name": "Update milestone (order 1 => 3)", "request": { - "method": "GET", + "method": "PATCH", "header": [ { "key": "Content-Type", @@ -2428,16 +3795,18 @@ ], "body": { "mode": "raw", - "raw": "{\r\n \"param\":{\r\n \"name\":\"new name\",\r\n \"key\":\"new key\",\r\n \"category\":\"new category\",\r\n \"scope\":{\r\n \"scope1\":\"scope 1\"\r\n },\r\n \"phases\":{\r\n \"phase1\":\"phase 1\"\r\n }\r\n }\r\n}" + "raw": "{\r\n \"param\":{\r\n \"name\": \"milestone 1-updated\",\r\n \"description\": \"description-updated\",\r\n \"duration\": 3,\r\n \"completionDate\": \"2018-05-07T00:00:00.000Z\",\r\n \"status\": \"closed\",\r\n \"type\": \"type2\",\r\n \"details\": {\r\n \"detail1\": {\r\n \"subDetail1C\": 3\r\n },\r\n \"detail2\": [\r\n 4\r\n ]\r\n },\r\n \"order\": 3,\r\n \"plannedText\": \"plannedText 1-updated\",\r\n \"activeText\": \"activeText 1-updated\",\r\n \"completedText\": \"completedText 1-updated\",\r\n \"blockedText\": \"blockedText 1-updated\"\r\n }\r\n}" }, "url": { - "raw": "{{api-url}}/v4/projectTemplates/1", + "raw": "{{api-url}}/v4/timelines/1/milestones/1", "host": [ "{{api-url}}" ], "path": [ "v4", - "projectTemplates", + "timelines", + "1", + "milestones", "1" ] } @@ -2445,7 +3814,7 @@ "response": [] }, { - "name": "Update project template", + "name": "Update milestone (order 3 => 1)", "request": { "method": "PATCH", "header": [ @@ -2460,16 +3829,18 @@ ], "body": { "mode": "raw", - "raw": "{\r\n \"param\":{\r\n \"name\":\"new name\",\r\n \"key\":\"new key\",\r\n \"category\":\"new category\",\r\n \"scope\":{\r\n \"scope1\":\"scope 1\",\r\n \"scope2\": [\"a\"]\r\n },\r\n \"phases\":{\r\n \"phase1\":\"phase 1\",\r\n \"phase2\": {\r\n \t\"another\": \"another\"\r\n }\r\n }\r\n }\r\n}" + "raw": "{\r\n \"param\":{\r\n \"name\": \"milestone 1-updated\",\r\n \"description\": \"description-updated\",\r\n \"duration\": 3,\r\n \"completionDate\": \"2018-05-07T00:00:00.000Z\",\r\n \"status\": \"closed\",\r\n \"type\": \"type2\",\r\n \"details\": {\r\n \"detail1\": {\r\n \"subDetail1C\": 3\r\n },\r\n \"detail2\": [\r\n 4\r\n ]\r\n },\r\n \"order\": 1,\r\n \"plannedText\": \"plannedText 1-updated\",\r\n \"activeText\": \"activeText 1-updated\",\r\n \"completedText\": \"completedText 1-updated\",\r\n \"blockedText\": \"blockedText 1-updated\"\r\n }\r\n}" }, "url": { - "raw": "{{api-url}}/v4/projectTemplates/1", + "raw": "{{api-url}}/v4/timelines/1/milestones/1", "host": [ "{{api-url}}" ], "path": [ "v4", - "projectTemplates", + "timelines", + "1", + "milestones", "1" ] } @@ -2477,7 +3848,7 @@ "response": [] }, { - "name": "Delete project template", + "name": "Delete milestone", "request": { "method": "DELETE", "header": [ @@ -2492,17 +3863,19 @@ ], "body": { "mode": "raw", - "raw": "{\r\n \"param\":{\r\n \"name\":\"new name\",\r\n \"key\":\"new key\",\r\n \"category\":\"new category\",\r\n \"scope\":{\r\n \"scope1\":\"scope 1\",\r\n \"scope2\": [\"a\"]\r\n },\r\n \"phases\":{\r\n \"phase1\":\"phase 1\",\r\n \"phase2\": {\r\n \t\"another\": \"another\"\r\n }\r\n }\r\n }\r\n}" + "raw": "" }, "url": { - "raw": "{{api-url}}/v4/projectTemplates/1", + "raw": "{{api-url}}/v4/timelines/1/milestones/2", "host": [ "{{api-url}}" ], "path": [ "v4", - "projectTemplates", - "1" + "timelines", + "1", + "milestones", + "2" ] } }, @@ -2511,11 +3884,10 @@ ] }, { - "name": "Product Templates", - "description": null, + "name": "Milestone Template", "item": [ { - "name": "Create product template", + "name": "Create milestone template", "request": { "method": "POST", "header": [ @@ -2525,30 +3897,32 @@ }, { "key": "Authorization", - "value": "Bearer {{jwt-token}}" + "value": "Bearer {{jwt-token-admin-40051333}}" } ], "body": { "mode": "raw", - "raw": "{\r\n \"param\":{\r\n \"name\":\"new name\",\r\n \"productKey\":\"new productKey\",\r\n \"icon\":\"http://example.com/icon-new.ico\",\r\n \"brief\": \"new brief\",\r\n \"details\": \"new details\",\r\n \"aliases\":{\r\n \"alias1\":\"alias 1\"\r\n },\r\n \"template\":{\r\n \"template1\":\"template 1\"\r\n }\r\n }\r\n}" + "raw": "{\r\n \"param\":{\r\n \"name\": \"milestoneTemplate 3\",\r\n \"description\": \"description 3\",\r\n \"duration\": 33,\r\n \"type\": \"type3\",\r\n \"order\": 1,\r\n \"activeText\": \"activeText 1\",\r\n \"completedText\": \"completedText 1\",\r\n \"blockedText\": \"blockedText 1\",\r\n \"plannedText\": \"planned Text 1\"\r\n\r\n }\r\n}" }, "url": { - "raw": "{{api-url}}/v4/productTemplates", + "raw": "{{api-url}}/v4/productTemplates/1/milestones", "host": [ "{{api-url}}" ], "path": [ "v4", - "productTemplates" + "productTemplates", + "1", + "milestones" ] } }, "response": [] }, { - "name": "List product templates", + "name": "Create milestone template with invalid data", "request": { - "method": "GET", + "method": "POST", "header": [ { "key": "Content-Type", @@ -2556,30 +3930,32 @@ }, { "key": "Authorization", - "value": "Bearer {{jwt-token}}" + "value": "Bearer {{jwt-token-admin-40051333}}" } ], "body": { "mode": "raw", - "raw": "{\r\n \"param\":{\r\n \"name\":\"new name\",\r\n \"key\":\"new key\",\r\n \"category\":\"new category\",\r\n \"scope\":{\r\n \"scope1\":\"scope 1\"\r\n },\r\n \"phases\":{\r\n \"phase1\":\"phase 1\"\r\n }\r\n }\r\n}" + "raw": "{\r\n \"param\":{\r\n\r\n }\r\n}" }, "url": { - "raw": "{{api-url}}/v4/productTemplates", + "raw": "{{api-url}}/v4/productTemplates/1/milestones", "host": [ "{{api-url}}" ], "path": [ "v4", - "productTemplates" + "productTemplates", + "1", + "milestones" ] } }, "response": [] }, { - "name": "Get product template", + "name": "Clone milestone template", "request": { - "method": "GET", + "method": "POST", "header": [ { "key": "Content-Type", @@ -2587,31 +3963,33 @@ }, { "key": "Authorization", - "value": "Bearer {{jwt-token}}" + "value": "Bearer {{jwt-token-admin-40051333}}" } ], "body": { "mode": "raw", - "raw": "{\r\n \"param\":{\r\n \"name\":\"new name\",\r\n \"key\":\"new key\",\r\n \"category\":\"new category\",\r\n \"scope\":{\r\n \"scope1\":\"scope 1\"\r\n },\r\n \"phases\":{\r\n \"phase1\":\"phase 1\"\r\n }\r\n }\r\n}" + "raw": "{\r\n \"param\":{\r\n \"sourceTemplateId\": 1\r\n }\r\n}" }, "url": { - "raw": "{{api-url}}/v4/productTemplates/1", + "raw": "{{api-url}}/v4/productTemplates/2/milestones/clone", "host": [ "{{api-url}}" ], "path": [ "v4", "productTemplates", - "1" + "2", + "milestones", + "clone" ] } }, "response": [] }, { - "name": "Update product template", + "name": "Clone milestone template with invalid product template id", "request": { - "method": "PATCH", + "method": "POST", "header": [ { "key": "Content-Type", @@ -2619,31 +3997,33 @@ }, { "key": "Authorization", - "value": "Bearer {{jwt-token}}" + "value": "Bearer {{jwt-token-admin-40051333}}" } ], "body": { "mode": "raw", - "raw": "{\r\n \"param\":{\r\n \"name\":\"new name\",\r\n \"productKey\":\"new productKey\",\r\n \"icon\":\"http://example.com/icon-new.ico\",\r\n \"brief\": \"new brief\",\r\n \"details\": \"new details\",\r\n \"aliases\":{\r\n \"alias1\":\"scope 1\",\r\n \"alias2\": [\"a\"]\r\n },\r\n \"template\":{\r\n \"template1\":\"template 1\",\r\n \"template2\": {\r\n \t\"another\": \"another\"\r\n }\r\n }\r\n }\r\n}" + "raw": "{\r\n \"param\":{\r\n \"sourceTemplateId\": 1\r\n }\r\n}" }, "url": { - "raw": "{{api-url}}/v4/productTemplates/1", + "raw": "{{api-url}}/v4/productTemplates/5/milestones/clone", "host": [ "{{api-url}}" ], "path": [ "v4", "productTemplates", - "1" + "5", + "milestones", + "clone" ] } }, "response": [] }, { - "name": "Delete product template", + "name": "Clone milestone template with invalid source product template id", "request": { - "method": "DELETE", + "method": "POST", "header": [ { "key": "Content-Type", @@ -2651,37 +4031,33 @@ }, { "key": "Authorization", - "value": "Bearer {{jwt-token}}" + "value": "Bearer {{jwt-token-admin-40051333}}" } ], "body": { "mode": "raw", - "raw": "{\r\n \"param\":{\r\n \"name\":\"new name\",\r\n \"key\":\"new key\",\r\n \"category\":\"new category\",\r\n \"scope\":{\r\n \"scope1\":\"scope 1\",\r\n \"scope2\": [\"a\"]\r\n },\r\n \"phases\":{\r\n \"phase1\":\"phase 1\",\r\n \"phase2\": {\r\n \t\"another\": \"another\"\r\n }\r\n }\r\n }\r\n}" + "raw": "{\r\n \"param\":{\r\n \"sourceTemplateId\": 6\r\n }\r\n}" }, "url": { - "raw": "{{api-url}}/v4/productTemplates/1", + "raw": "{{api-url}}/v4/productTemplates/2/milestones/clone", "host": [ "{{api-url}}" ], "path": [ "v4", "productTemplates", - "1" + "2", + "milestones", + "clone" ] } }, "response": [] - } - ] - }, - { - "name": "Project Type", - "description": null, - "item": [ + }, { - "name": "Create project type", + "name": "List milestone templates", "request": { - "method": "POST", + "method": "GET", "header": [ { "key": "Content-Type", @@ -2689,28 +4065,30 @@ }, { "key": "Authorization", - "value": "Bearer {{jwt-token}}" + "value": "Bearer {{jwt-token-copilot-40051332}}" } ], "body": { "mode": "raw", - "raw": "{\r\n \"param\":{\r\n \"key\": \"new key\",\r\n \"displayName\": \"new displayName\"\r\n }\r\n}" + "raw": "{\r\n \"param\":{\r\n \"name\":\"new name\",\r\n \"key\":\"new key\",\r\n \"category\":\"new category\",\r\n \"scope\":{\r\n \"scope1\":\"scope 1\"\r\n },\r\n \"phases\":{\r\n \"phase1\":\"phase 1\"\r\n }\r\n }\r\n}" }, "url": { - "raw": "{{api-url}}/v4/projectTypes", + "raw": "{{api-url}}/v4/productTemplates/1/milestones", "host": [ "{{api-url}}" ], "path": [ "v4", - "projectTypes" + "productTemplates", + "1", + "milestones" ] } }, "response": [] }, { - "name": "List project types", + "name": "List milestone templates (sort)", "request": { "method": "GET", "header": [ @@ -2720,7 +4098,7 @@ }, { "key": "Authorization", - "value": "Bearer {{jwt-token}}" + "value": "Bearer {{jwt-token-copilot-40051332}}" } ], "body": { @@ -2728,20 +4106,28 @@ "raw": "{\r\n \"param\":{\r\n \"name\":\"new name\",\r\n \"key\":\"new key\",\r\n \"category\":\"new category\",\r\n \"scope\":{\r\n \"scope1\":\"scope 1\"\r\n },\r\n \"phases\":{\r\n \"phase1\":\"phase 1\"\r\n }\r\n }\r\n}" }, "url": { - "raw": "{{api-url}}/v4/projectTypes", + "raw": "{{api-url}}/v4/productTemplates/1/milestones?sort=order desc", "host": [ "{{api-url}}" ], "path": [ "v4", - "projectTypes" + "productTemplates", + "1", + "milestones" + ], + "query": [ + { + "key": "sort", + "value": "order desc" + } ] } }, "response": [] }, { - "name": "Get project type", + "name": "Get milestone template", "request": { "method": "GET", "header": [ @@ -2759,21 +4145,23 @@ "raw": "{\r\n \"param\":{\r\n \"name\":\"new name\",\r\n \"key\":\"new key\",\r\n \"category\":\"new category\",\r\n \"scope\":{\r\n \"scope1\":\"scope 1\"\r\n },\r\n \"phases\":{\r\n \"phase1\":\"phase 1\"\r\n }\r\n }\r\n}" }, "url": { - "raw": "{{api-url}}/v4/projectTypes/generic", + "raw": "{{api-url}}/v4/productTemplates/1/milestones/1", "host": [ "{{api-url}}" ], "path": [ "v4", - "projectTypes", - "generic" + "productTemplates", + "1", + "milestones", + "1" ] } }, "response": [] }, { - "name": "Update project type", + "name": "Update milestone", "request": { "method": "PATCH", "header": [ @@ -2788,26 +4176,28 @@ ], "body": { "mode": "raw", - "raw": "{\r\n \"param\":{\r\n \"displayName\": \"Chatbot-updated\"\r\n }\r\n}" + "raw": "{\r\n \"param\":{\r\n\t\"name\": \"milestoneTemplate 1-updated\",\r\n\t\"description\": \"description 1-updated\",\r\n\t\"duration\": 34,\r\n\t\"type\": \"type1-updated\",\r\n\t\"order\": 1\r\n }\r\n}" }, "url": { - "raw": "{{api-url}}/v4/projectTypes/chatbot", + "raw": "{{api-url}}/v4/productTemplates/1/milestones/1", "host": [ "{{api-url}}" ], "path": [ "v4", - "projectTypes", - "chatbot" + "productTemplates", + "1", + "milestones", + "1" ] } }, "response": [] }, { - "name": "Delete project type", + "name": "Update milestone (order 1 => 2)", "request": { - "method": "DELETE", + "method": "PATCH", "header": [ { "key": "Content-Type", @@ -2820,189 +4210,161 @@ ], "body": { "mode": "raw", - "raw": "{\r\n \"param\":{\r\n \"name\":\"new name\",\r\n \"key\":\"new key\",\r\n \"category\":\"new category\",\r\n \"scope\":{\r\n \"scope1\":\"scope 1\",\r\n \"scope2\": [\"a\"]\r\n },\r\n \"phases\":{\r\n \"phase1\":\"phase 1\",\r\n \"phase2\": {\r\n \t\"another\": \"another\"\r\n }\r\n }\r\n }\r\n}" + "raw": "{\r\n \"param\":{\r\n \"name\": \"milestoneTemplate 1-updated\",\r\n \"description\": \"description 1-updated\",\r\n \"duration\": 34,\r\n \"type\": \"type1-updated\",\r\n \"order\": 2\r\n }\r\n}" }, "url": { - "raw": "{{api-url}}/v4/projectTypes/chatbot", + "raw": "{{api-url}}/v4/productTemplates/1/milestones/1", "host": [ "{{api-url}}" ], "path": [ "v4", - "projectTypes", - "chatbot" + "productTemplates", + "1", + "milestones", + "1" ] } }, "response": [] - } - ] - }, - { - "name": "issue86 (create project with templateId)", - "description": "", - "item": [ + }, { - "name": "Create project with templateId", + "name": "Update milestone (order 2 => 1)", "request": { - "method": "POST", + "method": "PATCH", "header": [ - { - "key": "Authorization", - "value": "Bearer {{jwt-token}}" - }, { "key": "Content-Type", "value": "application/json" + }, + { + "key": "Authorization", + "value": "Bearer {{jwt-token}}" } ], "body": { "mode": "raw", - "raw": "{\n \"param\": {\n \"name\": \"test project with templateId\",\n \"description\": \"Hello I am a test project with templateId\",\n \"type\": \"generic\",\n \"templateId\": 3\n }\n}" + "raw": "{\r\n \"param\":{\r\n\t\"name\": \"milestoneTemplate 1-updated\",\r\n\t\"description\": \"description 1-updated\",\r\n\t\"duration\": 34,\r\n\t\"type\": \"type1-updated\",\r\n\t\"order\": 1\r\n }\r\n}" }, "url": { - "raw": "{{api-url}}/v4/projects", + "raw": "{{api-url}}/v4/productTemplates/1/milestones/1", "host": [ "{{api-url}}" ], "path": [ "v4", - "projects" + "productTemplates", + "1", + "milestones", + "1" ] } }, "response": [] }, { - "name": "Create project with templateId (not existed)", + "name": "Update milestone (order 1 => 3)", "request": { - "method": "POST", + "method": "PATCH", "header": [ - { - "key": "Authorization", - "value": "Bearer {{jwt-token}}" - }, { "key": "Content-Type", "value": "application/json" + }, + { + "key": "Authorization", + "value": "Bearer {{jwt-token}}" } ], "body": { "mode": "raw", - "raw": "{\n\t\"param\": {\n\t\t\"name\": \"test project with templateId\",\n\t\t\"description\": \"Hello I am a test project with templateId\",\n\t\t\"type\": \"generic\",\n\t\t\"templateId\": 3000\n\t}\n}" + "raw": "{\r\n \"param\":{\r\n \"name\": \"milestoneTemplate 1-updated\",\r\n \"description\": \"description 1-updated\",\r\n \"duration\": 34,\r\n \"type\": \"type1-updated\",\r\n \"order\": 3\r\n }\r\n}" }, "url": { - "raw": "{{api-url}}/v4/projects", + "raw": "{{api-url}}/v4/productTemplates/1/milestones/1", "host": [ "{{api-url}}" ], "path": [ "v4", - "projects" + "productTemplates", + "1", + "milestones", + "1" ] } }, "response": [] - } - ] - }, - { - "name": "Project upgrade", - "description": "Request to migrate projects.", - "item": [ + }, { - "name": "Migrate project", + "name": "Update milestone (order 3 => 1)", "request": { - "method": "POST", + "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\"targetVersion\": \"v3\",\n\t\t\"defaultProductTemplateId\": 3\n\t}\n}" - }, - "url": "{{api-url}}/v4/projects/6/upgrade", - "description": "" - }, - "response": [] - }, - { - "name": "Migrate project (completed)", - "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\"targetVersion\": \"v3\",\n\t\t\"defaultProductTemplateId\": 3\n\t}\n}" + "raw": "{\r\n \"param\":{\r\n \"name\": \"milestoneTemplate 1-updated\",\r\n \"description\": \"description 1-updated\",\r\n \"duration\": 34,\r\n \"type\": \"type1-updated\",\r\n \"order\": 1\r\n }\r\n}" }, - "url": "{{api-url}}/v4/projects/7/upgrade", - "description": "" + "url": { + "raw": "{{api-url}}/v4/productTemplates/1/milestones/1", + "host": [ + "{{api-url}}" + ], + "path": [ + "v4", + "productTemplates", + "1", + "milestones", + "1" + ] + } }, "response": [] }, { - "name": "Migrate project with phase name", + "name": "Delete milestone", "request": { - "method": "POST", + "method": "DELETE", "header": [ - { - "key": "Authorization", - "value": "Bearer {{jwt-token}}" - }, { "key": "Content-Type", "value": "application/json" - } - ], - "body": { - "mode": "raw", - "raw": "{\n\t\"param\": {\n\t\t\"targetVersion\": \"v3\",\n\t\t\"defaultProductTemplateId\": 3,\n\t\t\"phaseName\": \"Custom phase name\"\n\t}\n}" - }, - "url": "{{api-url}}/v4/projects/6/upgrade", - "description": "" - }, - "response": [] - }, - { - "name": "Migrate project with phase name (completed)", - "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\"targetVersion\": \"v3\",\n\t\t\"defaultProductTemplateId\": 3,\n\t\t\"phaseName\": \"Custom phase name\"\n\t}\n}" + "raw": "" }, - "url": "{{api-url}}/v4/projects/7/upgrade", - "description": "" + "url": { + "raw": "{{api-url}}/v4/productTemplates/1/milestones/2", + "host": [ + "{{api-url}}" + ], + "path": [ + "v4", + "productTemplates", + "1", + "milestones", + "2" + ] + } }, "response": [] } ] } ] -} +} \ No newline at end of file diff --git a/postman_environment.json b/postman_environment.json index 12fab912..d1ccbbd4 100644 --- a/postman_environment.json +++ b/postman_environment.json @@ -11,7 +11,49 @@ }, { "key": "jwt-token", - "value": "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJyb2xlcyI6WyJUb3Bjb2RlciBVc2VyIiwiYWRtaW5pc3RyYXRvciJdLCJpc3MiOiJodHRwczovL2FwaS50b3Bjb2Rlci1kZXYuY29tIiwiaGFuZGxlIjoidGVzdDEiLCJleHAiOjI1NjMwNzY2ODksInVzZXJJZCI6IjQwMDUxMzMzIiwiaWF0IjoxNDYzMDc2MDg5LCJlbWFpbCI6InRlc3RAdG9wY29kZXIuY29tIiwianRpIjoiYjMzYjc3Y2QtYjUyZS00MGZlLTgzN2UtYmViOGUwYWU2YTRhIn0.wKWUe0-SaiFVN-VR_-GwgFlvWaDkSbc8H55ktb9LAVw", + "value": "", + "description": "", + "type": "text", + "enabled": true + }, + { + "key": "jwt-token-admin-40051333", + "value": "", + "description": "", + "type": "text", + "enabled": true + }, + { + "key": "jwt-token-member-40051331", + "value": "", + "description": "", + "type": "text", + "enabled": true + }, + { + "key": "jwt-token-copilot-40051332", + "value": "", + "description": "", + "type": "text", + "enabled": true + }, + { + "key": "jwt-token-manager-40051334", + "value": "", + "description": "", + "type": "text", + "enabled": true + }, + { + "key": "jwt-token-member2-40051335", + "value": "", + "description": "", + "type": "text", + "enabled": true + }, + { + "key": "jwt-token-connectAdmin-40051336", + "value": "", "description": "", "type": "text", "enabled": true diff --git a/src/constants.js b/src/constants.js index 4e24c806..12696193 100644 --- a/src/constants.js +++ b/src/constants.js @@ -11,6 +11,8 @@ export const PROJECT_STATUS = { export const PROJECT_PHASE_STATUS = PROJECT_STATUS; +export const MILESTONE_STATUS = PROJECT_STATUS; + export const PROJECT_MEMBER_ROLE = { MANAGER: 'manager', CUSTOMER: 'customer', @@ -49,6 +51,14 @@ export const EVENT = { PROJECT_PHASE_PRODUCT_ADDED: 'project.phase.product.added', PROJECT_PHASE_PRODUCT_UPDATED: 'project.phase.product.updated', PROJECT_PHASE_PRODUCT_REMOVED: 'project.phase.product.removed', + + TIMELINE_ADDED: 'timeline.added', + TIMELINE_UPDATED: 'timeline.updated', + TIMELINE_REMOVED: 'timeline.removed', + + MILESTONE_ADDED: 'milestone.added', + MILESTONE_UPDATED: 'milestone.updated', + MILESTONE_REMOVED: 'milestone.removed', }, }; @@ -88,3 +98,9 @@ export const REGEX = { export const TOKEN_SCOPES = { CONNECT_PROJECT_ADMIN: 'all:connect_project', }; + +export const TIMELINE_REFERENCES = { + PROJECT: 'project', + PHASE: 'phase', + PRODUCT: 'product', +}; diff --git a/src/events/busApi.js b/src/events/busApi.js index afb1b432..9608de1c 100644 --- a/src/events/busApi.js +++ b/src/events/busApi.js @@ -1,5 +1,5 @@ import _ from 'lodash'; -import 'config'; +import config from 'config'; import { EVENT, BUS_API_EVENT, PROJECT_STATUS, PROJECT_MEMBER_ROLE } from '../constants'; import { createEvent } from '../services/busApi'; import models from '../models'; @@ -17,6 +17,27 @@ const mapEventTypes = { [PROJECT_STATUS.ACTIVE]: BUS_API_EVENT.PROJECT_ACTIVE, }; +/** + * Builds the connect project attachment url for the given project and attachment ids. + * + * @param {string|number} projectId the project id + * @param {string|number} attachmentId the attachment id + * @returns {string} the connect project attachment url + */ +function connectProjectAttachmentUrl(projectId, attachmentId) { + return `${config.get('connectProjectsUrl')}${projectId}/attachments/${attachmentId}`; +} + +/** + * Builds the connect project url for the given project id. + * + * @param {string|number} projectId the project id + * @returns {string} the connect project url + */ +function connectProjectUrl(projectId) { + return `${config.get('connectProjectsUrl')}${projectId}`; +} + module.exports = (app, logger) => { /** * PROJECT_DRAFT_CREATED @@ -28,6 +49,7 @@ module.exports = (app, logger) => { createEvent(BUS_API_EVENT.PROJECT_CREATED, { projectId: project.id, projectName: project.name, + projectUrl: connectProjectUrl(project.id), userId: req.authUser.userId, initiatorUserId: req.authUser.userId, }, logger); @@ -44,6 +66,7 @@ module.exports = (app, logger) => { createEvent(mapEventTypes[updated.status], { projectId: updated.id, projectName: updated.name, + projectUrl: connectProjectUrl(updated.id), userId: req.authUser.userId, initiatorUserId: req.authUser.userId, }, logger); @@ -55,6 +78,7 @@ module.exports = (app, logger) => { createEvent(BUS_API_EVENT.PROJECT_SPECIFICATION_MODIFIED, { projectId: updated.id, projectName: updated.name, + projectUrl: connectProjectUrl(updated.id), userId: req.authUser.userId, initiatorUserId: req.authUser.userId, }, logger); @@ -63,6 +87,7 @@ module.exports = (app, logger) => { createEvent(BUS_API_EVENT.PROJECT_LINK_CREATED, { projectId: updated.id, projectName: updated.name, + projectUrl: connectProjectUrl(updated.id), userId: req.authUser.userId, initiatorUserId: req.authUser.userId, }, logger); @@ -96,6 +121,7 @@ module.exports = (app, logger) => { createEvent(eventType, { projectId, projectName: project.name, + projectUrl: connectProjectUrl(projectId), userId: member.userId, initiatorUserId: req.authUser.userId, }, logger); @@ -124,6 +150,7 @@ module.exports = (app, logger) => { createEvent(eventType, { projectId, projectName: project.name, + projectUrl: connectProjectUrl(projectId), userId: member.userId, initiatorUserId: req.authUser.userId, }, logger); @@ -147,6 +174,7 @@ module.exports = (app, logger) => { createEvent(BUS_API_EVENT.MEMBER_ASSIGNED_AS_OWNER, { projectId, projectName: project.name, + projectUrl: connectProjectUrl(projectId), userId: updated.userId, initiatorUserId: req.authUser.userId, }, logger); @@ -170,7 +198,9 @@ module.exports = (app, logger) => { createEvent(BUS_API_EVENT.PROJECT_FILE_UPLOADED, { projectId, projectName: project.name, + projectUrl: connectProjectUrl(projectId), fileName: attachment.filePath.replace(/^.*[\\\/]/, ''), // eslint-disable-line + fileUrl: connectProjectAttachmentUrl(projectId, attachment.id), userId: req.authUser.userId, initiatorUserId: req.authUser.userId, }, logger); @@ -192,6 +222,7 @@ module.exports = (app, logger) => { createEvent(BUS_API_EVENT.PROJECT_PLAN_MODIFIED, { projectId, projectName: project.name, + projectUrl: connectProjectUrl(projectId), userId: req.authUser.userId, initiatorUserId: req.authUser.userId, }, logger); @@ -213,6 +244,7 @@ module.exports = (app, logger) => { createEvent(BUS_API_EVENT.PROJECT_PLAN_MODIFIED, { projectId, projectName: project.name, + projectUrl: connectProjectUrl(projectId), userId: req.authUser.userId, initiatorUserId: req.authUser.userId, }, logger); @@ -234,6 +266,7 @@ module.exports = (app, logger) => { createEvent(BUS_API_EVENT.PROJECT_PLAN_MODIFIED, { projectId, projectName: project.name, + projectUrl: connectProjectUrl(projectId), userId: req.authUser.userId, initiatorUserId: req.authUser.userId, }, logger); @@ -255,6 +288,7 @@ module.exports = (app, logger) => { createEvent(BUS_API_EVENT.PROJECT_PLAN_MODIFIED, { projectId, projectName: project.name, + projectUrl: connectProjectUrl(projectId), userId: req.authUser.userId, initiatorUserId: req.authUser.userId, phase: created, @@ -277,6 +311,7 @@ module.exports = (app, logger) => { createEvent(BUS_API_EVENT.PROJECT_PLAN_MODIFIED, { projectId, projectName: project.name, + projectUrl: connectProjectUrl(projectId), userId: req.authUser.userId, initiatorUserId: req.authUser.userId, }, logger); @@ -302,6 +337,7 @@ module.exports = (app, logger) => { createEvent(BUS_API_EVENT.PROJECT_PRODUCT_SPECIFICATION_MODIFIED, { projectId, projectName: project.name, + projectUrl: connectProjectUrl(projectId), userId: req.authUser.userId, initiatorUserId: req.authUser.userId, }, logger); @@ -314,6 +350,7 @@ module.exports = (app, logger) => { createEvent(BUS_API_EVENT.PROJECT_PLAN_MODIFIED, { projectId, projectName: project.name, + projectUrl: connectProjectUrl(projectId), userId: req.authUser.userId, initiatorUserId: req.authUser.userId, }, logger); diff --git a/src/events/index.js b/src/events/index.js index cf6decf8..fac17d8d 100644 --- a/src/events/index.js +++ b/src/events/index.js @@ -9,6 +9,8 @@ import { projectPhaseAddedHandler, projectPhaseRemovedHandler, projectPhaseUpdatedHandler } from './projectPhases'; import { phaseProductAddedHandler, phaseProductRemovedHandler, phaseProductUpdatedHandler } from './phaseProducts'; +import { timelineAddedHandler, timelineUpdatedHandler, timelineRemovedHandler } from './timelines'; +import { milestoneAddedHandler, milestoneUpdatedHandler, milestoneRemovedHandler } from './milestones'; export default { 'project.initial': projectCreatedHandler, @@ -30,4 +32,13 @@ export default { [EVENT.ROUTING_KEY.PROJECT_PHASE_PRODUCT_ADDED]: phaseProductAddedHandler, [EVENT.ROUTING_KEY.PROJECT_PHASE_PRODUCT_REMOVED]: phaseProductRemovedHandler, [EVENT.ROUTING_KEY.PROJECT_PHASE_PRODUCT_UPDATED]: phaseProductUpdatedHandler, + + // Timeline and milestone + 'timeline.initial': timelineAddedHandler, + [EVENT.ROUTING_KEY.TIMELINE_ADDED]: timelineAddedHandler, + [EVENT.ROUTING_KEY.TIMELINE_REMOVED]: timelineRemovedHandler, + [EVENT.ROUTING_KEY.TIMELINE_UPDATED]: timelineUpdatedHandler, + [EVENT.ROUTING_KEY.MILESTONE_ADDED]: milestoneAddedHandler, + [EVENT.ROUTING_KEY.MILESTONE_REMOVED]: milestoneRemovedHandler, + [EVENT.ROUTING_KEY.MILESTONE_UPDATED]: milestoneUpdatedHandler, }; diff --git a/src/events/milestones/index.js b/src/events/milestones/index.js new file mode 100644 index 00000000..71fd0d6b --- /dev/null +++ b/src/events/milestones/index.js @@ -0,0 +1,162 @@ +/** + * Event handlers for milestone create, update and delete. + */ +import config from 'config'; +import _ from 'lodash'; +import Promise from 'bluebird'; +import util from '../../util'; + +const ES_TIMELINE_INDEX = config.get('elasticsearchConfig.timelineIndexName'); +const ES_TIMELINE_TYPE = config.get('elasticsearchConfig.timelineDocType'); + +const eClient = util.getElasticSearchClient(); + +/** + * Handler for milestone creation event + * @param {Object} logger logger to log along with trace id + * @param {Object} msg event payload + * @param {Object} channel channel to ack, nack + */ +const milestoneAddedHandler = Promise.coroutine(function* (logger, msg, channel) { // eslint-disable-line func-names + const data = JSON.parse(msg.content.toString()); + try { + const doc = yield eClient.get({ index: ES_TIMELINE_INDEX, type: ES_TIMELINE_TYPE, id: data.timelineId }); + const milestones = _.isArray(doc._source.milestones) ? doc._source.milestones : []; // eslint-disable-line no-underscore-dangle + + // Increase the order of the other milestones in the same timeline, + // which have `order` >= this milestone order + _.each(milestones, (milestone) => { + if (milestone.order >= data.order) { + milestone.order += 1; // eslint-disable-line no-param-reassign + } + }); + + milestones.push(data); + const merged = _.assign(doc._source, { milestones }); // eslint-disable-line no-underscore-dangle + yield eClient.update({ + index: ES_TIMELINE_INDEX, + type: ES_TIMELINE_TYPE, + id: data.timelineId, + body: { doc: merged }, + }); + logger.debug('milestone added to timeline document successfully'); + channel.ack(msg); + } catch (error) { + logger.error(`Error processing event (milestoneId: ${data.id})`, error); + // if the message has been redelivered dont attempt to reprocess it + channel.nack(msg, false, !msg.fields.redelivered); + } +}); + +/** + * Handler for milestone updated event + * @param {Object} logger logger to log along with trace id + * @param {Object} msg event payload + * @param {Object} channel channel to ack, nack + * @returns {undefined} + */ +const milestoneUpdatedHandler = Promise.coroutine(function* (logger, msg, channel) { // eslint-disable-line func-names + const data = JSON.parse(msg.content.toString()); + try { + const doc = yield eClient.get({ index: ES_TIMELINE_INDEX, type: ES_TIMELINE_TYPE, id: data.original.timelineId }); + const milestones = _.map(doc._source.milestones, (single) => { // eslint-disable-line no-underscore-dangle + if (single.id === data.original.id) { + return _.assign(single, data.updated); + } + return single; + }); + + if (data.cascadedUpdates && data.cascadedUpdates.milestones && data.cascadedUpdates.milestones.length > 0) { + const otherUpdatedMilestones = data.cascadedUpdates.milestones; + _.each(milestones, (m) => { + // finds the updated milestone from the cascaded updates + const updatedMilestoneData = _.find(otherUpdatedMilestones, oum => oum.updated && oum.updated.id === m.id); + logger.debug('updatedMilestone=>', updatedMilestoneData); + if (updatedMilestoneData && updatedMilestoneData.updated) { + _.assign(m, updatedMilestoneData.updated); + } + }); + } + + // if (data.original.order !== data.updated.order) { + // const milestoneWithSameOrder = + // _.find(milestones, milestone => milestone.id !== data.updated.id && milestone.order === data.updated.order); + // if (milestoneWithSameOrder) { + // // Increase the order from M to K: if there is an item with order K, + // // orders from M+1 to K should be made M to K-1 + // if (data.original.order < data.updated.order) { + // _.each(milestones, (single) => { + // if (single.id !== data.updated.id + // && (data.original.order + 1) <= single.order + // && single.order <= data.updated.order) { + // single.order -= 1; // eslint-disable-line no-param-reassign + // } + // }); + // } else { + // // Decrease the order from M to K: if there is an item with order K, + // // orders from K to M-1 should be made K+1 to M + // _.each(milestones, (single) => { + // if (single.id !== data.updated.id + // && data.updated.order <= single.order + // && single.order <= (data.original.order - 1)) { + // single.order += 1; // eslint-disable-line no-param-reassign + // } + // }); + // } + // } + // } + + const merged = _.assign(doc._source, { milestones }); // eslint-disable-line no-underscore-dangle + yield eClient.update({ + index: ES_TIMELINE_INDEX, + type: ES_TIMELINE_TYPE, + id: data.original.timelineId, + body: { + doc: merged, + }, + }); + logger.debug('elasticsearch index updated, milestone updated successfully'); + channel.ack(msg); + } catch (error) { + logger.error(`Error processing event (milestoneId: ${data.original.id})`, error); + // if the message has been redelivered dont attempt to reprocess it + channel.nack(msg, false, !msg.fields.redelivered); + } +}); + +/** + * Handler for milestone deleted event + * @param {Object} logger logger to log along with trace id + * @param {Object} msg event payload + * @param {Object} channel channel to ack, nack + * @returns {undefined} + */ +const milestoneRemovedHandler = Promise.coroutine(function* (logger, msg, channel) { // eslint-disable-line func-names + const data = JSON.parse(msg.content.toString()); + try { + const doc = yield eClient.get({ index: ES_TIMELINE_INDEX, type: ES_TIMELINE_TYPE, id: data.timelineId }); + const milestones = _.filter(doc._source.milestones, single => single.id !== data.id); // eslint-disable-line no-underscore-dangle + const merged = _.assign(doc._source, { milestones }); // eslint-disable-line no-underscore-dangle + yield eClient.update({ + index: ES_TIMELINE_INDEX, + type: ES_TIMELINE_TYPE, + id: data.timelineId, + body: { + doc: merged, + }, + }); + logger.debug('milestone removed from timeline document successfully'); + channel.ack(msg); + } catch (error) { + logger.error(`Error processing event (milestoneId: ${data.id})`, error); + // if the message has been redelivered dont attempt to reprocess it + channel.nack(msg, false, !msg.fields.redelivered); + } +}); + + +module.exports = { + milestoneAddedHandler, + milestoneRemovedHandler, + milestoneUpdatedHandler, +}; diff --git a/src/events/timelines/index.js b/src/events/timelines/index.js new file mode 100644 index 00000000..0de36410 --- /dev/null +++ b/src/events/timelines/index.js @@ -0,0 +1,90 @@ +/** + * Event handlers for timeline create, update and delete + */ +import _ from 'lodash'; +import Promise from 'bluebird'; +import config from 'config'; +import util from '../../util'; + +const ES_TIMELINE_INDEX = config.get('elasticsearchConfig.timelineIndexName'); +const ES_TIMELINE_TYPE = config.get('elasticsearchConfig.timelineDocType'); +const eClient = util.getElasticSearchClient(); + +/** + * Handler for timeline creation event + * @param {Object} logger logger to log along with trace id + * @param {Object} msg event payload + * @param {Object} channel channel to ack, nack + */ +const timelineAddedHandler = Promise.coroutine(function* (logger, msg, channel) { // eslint-disable-line func-names + const data = JSON.parse(msg.content.toString()); + try { + // add the record to the index + const result = yield eClient.index({ + index: ES_TIMELINE_INDEX, + type: ES_TIMELINE_TYPE, + id: data.id, + body: data, + }); + logger.debug(`timeline indexed successfully (timelineId: ${data.id})`, result); + channel.ack(msg); + } catch (error) { + logger.error(`Error processing event (timelineId: ${data.id})`, error); + channel.nack(msg, false, !msg.fields.redelivered); + } +}); + +/** + * Handler for timeline updated event + * @param {Object} logger logger to log along with trace id + * @param {Object} msg event payload + * @param {Object} channel channel to ack, nack + */ +const timelineUpdatedHandler = Promise.coroutine(function* (logger, msg, channel) { // eslint-disable-line func-names + const data = JSON.parse(msg.content.toString()); + try { + // first get the existing document and than merge the updated changes and save the new document + const doc = yield eClient.get({ index: ES_TIMELINE_INDEX, type: ES_TIMELINE_TYPE, id: data.original.id }); + const merged = _.merge(doc._source, data.updated); // eslint-disable-line no-underscore-dangle + merged.milestones = data.updated.milestones; + // update the merged document + yield eClient.update({ + index: ES_TIMELINE_INDEX, + type: ES_TIMELINE_TYPE, + id: data.original.id, + body: { + doc: merged, + }, + }); + logger.debug(`timeline updated successfully in elasticsearh index, (timelineId: ${data.original.id})`); + channel.ack(msg); + } catch (error) { + logger.error(`failed to get timeline document, (timelineId: ${data.original.id})`, error); + channel.nack(msg, false, !msg.fields.redelivered); + } +}); + +/** + * Handler for timeline deleted event + * @param {Object} logger logger to log along with trace id + * @param {Object} msg event payload + * @param {Object} channel channel to ack, nack + */ +const timelineRemovedHandler = Promise.coroutine(function* (logger, msg, channel) { // eslint-disable-line func-names + const data = JSON.parse(msg.content.toString()); + try { + yield eClient.delete({ index: ES_TIMELINE_INDEX, type: ES_TIMELINE_TYPE, id: data.id }); + logger.debug(`timeline deleted successfully from elasticsearh index (timelineId: ${data.id})`); + channel.ack(msg); + } catch (error) { + logger.error(`failed to delete timeline document (timelineId: ${data.id})`, error); + channel.nack(msg, false, !msg.fields.redelivered); + } +}); + + +module.exports = { + timelineAddedHandler, + timelineUpdatedHandler, + timelineRemovedHandler, +}; diff --git a/src/middlewares/fieldLookupValidation.js b/src/middlewares/fieldLookupValidation.js new file mode 100644 index 00000000..73d07a9c --- /dev/null +++ b/src/middlewares/fieldLookupValidation.js @@ -0,0 +1,34 @@ +import _ from 'lodash'; + +/** + * Constructs a middleware the validates the existence of a record given a path to find in the req. For example, in + * order to check for a ProductCategory being received in "req.body.param.category" you would construct this middleware + * by calling this function with (models.ProductCategory, 'key', 'body.param.category', 'Category'). + * Note that this also works for updates where the value might not be present in the request, in which the case + * the built middleware will continue without errors. + * + * @param {Object} model the mode. + * @param {string} modelKey the model key + * @param {string} path the path to seek the value in the request + * @param {string} errorEntityName the error entity name used to build an error + * @returns {Function} the middleware + */ +export default function (model, modelKey, path, errorEntityName) { + return (req, res, next) => { + const value = _.get(req, path); + if (value) { + model.findOne({ where: { [modelKey]: value } }) + .then((record) => { + if (record) { + next(); + } else { + const err = new Error(`${errorEntityName} not found for key "${value}"`); + err.status = 422; + next(err); + } + }); + } else { + next(); + } + }; +} diff --git a/src/middlewares/validateTimeline.js b/src/middlewares/validateTimeline.js new file mode 100644 index 00000000..60cfbb4c --- /dev/null +++ b/src/middlewares/validateTimeline.js @@ -0,0 +1,166 @@ +import _ from 'lodash'; +import { TIMELINE_REFERENCES } from '../constants'; +import models from '../models'; +import util from '../util'; + +// eslint-disable-next-line valid-jsdoc +/** + * Common validation code for types of timeline references. + * @param {{ reference: string, referenceId: string|number }} sourceObject + * @param {object} req + * @param {boolean} [validateProjectExists] + * @returns {Promise} + */ +async function validateReference(sourceObject, req, validateProjectExists) { + // The source object refers to a project + if (sourceObject.reference === TIMELINE_REFERENCES.PROJECT) { + // Set projectId to the params so it can be used in the permission check middleware + req.params.projectId = sourceObject.referenceId; + + if (validateProjectExists) { + // Validate projectId to be existed + const project = await models.Project.findOne({ + where: { + id: req.params.projectId, + deletedAt: { $eq: null }, + }, + }); + if (!project) { + const apiErr = new Error(`Project not found for project id ${req.params.projectId}`); + apiErr.status = 422; + throw apiErr; + } + } + return; + } + + // The source object refers to a product + if (sourceObject.reference === TIMELINE_REFERENCES.PRODUCT) { + // Validate product to be existed + const product = await models.PhaseProduct.findOne({ + where: { + id: sourceObject.referenceId, + deletedAt: { $eq: null }, + }, + }); + if (!product) { + const apiErr = new Error(`Product not found for product id ${sourceObject.referenceId}`); + apiErr.status = 422; + throw apiErr; + } + + // Set projectId to the params so it can be used in the permission check middleware + req.params.projectId = product.projectId; + return; + } + + // The source object refers to a phase + const phase = await models.ProjectPhase.findOne({ + where: { + id: sourceObject.referenceId, + deletedAt: { $eq: null }, + }, + }); + if (!phase) { + const apiErr = new Error(`Phase not found for phase id ${sourceObject.referenceId}`); + apiErr.status = 422; + throw apiErr; + } + + // Set projectId to the params so it can be used in the permission check middleware + req.params.projectId = phase.projectId; +} + +const validateTimeline = { + + /** + * The middleware to validate and get the projectId specified by the timeline request object, + * and set to the request params. This should be called after the validate() middleware, + * and before the permissions() middleware. + * @param {Object} req the express request instance + * @param {Object} res the express response instance + * @param {Function} next the express next middleware + */ + // eslint-disable-next-line valid-jsdoc + validateTimelineRequestBody: (req, res, next) => { + validateReference(req.body.param, req, true) + .then(next) + .catch(next); + }, + + /** + * The middleware to validate and get the projectId specified by the reference/referenceId pair + * present in the request's query filter and set to the request params. Because of the filter needs + * to be parsed, this can be the first middleware in the stack, and can be placed before the permissions() + * middleware. + * @param {Object} req the express request instance + * @param {Object} res the express response instance + * @param {Function} next the express next middleware + */ + // eslint-disable-next-line valid-jsdoc + validateTimelineQueryFilter: (req, res, next) => { + // Validate the filter + const filter = util.parseQueryFilter(req.query.filter); + + // Save the parsed filter for later + req.params.filter = filter; + + if (!util.isValidFilter(filter, ['reference', 'referenceId'])) { + const apiErr = new Error('Only allowed to filter by reference and referenceId'); + apiErr.status = 422; + return next(apiErr); + } + + // Verify required filters are present + if (!filter.reference || !filter.referenceId) { + const apiErr = new Error('Please provide reference and referenceId filter parameters'); + apiErr.status = 422; + return next(apiErr); + } + + // Verify reference is a valid value + if (!_.includes(TIMELINE_REFERENCES, filter.reference)) { + const apiErr = new Error(`reference filter must be in ${TIMELINE_REFERENCES}`); + apiErr.status = 422; + return next(apiErr); + } + + if (_.lt(filter.referenceId, 1)) { + const apiErr = new Error('referenceId filter must be a positive integer'); + apiErr.status = 422; + return next(apiErr); + } + + return validateReference(filter, req, true) + .then(next) + .catch(next); + }, + + /** + * The middleware to validate and get the projectId specified by the timelineId from request + * path parameter, and set to the request params. This should be called after the validate() + * middleware, and before the permissions() middleware. + * @param {Object} req the express request instance + * @param {Object} res the express response instance + * @param {Function} next the express next middleware + */ + // eslint-disable-next-line valid-jsdoc + validateTimelineIdParam: (req, res, next) => { + models.Timeline.findById(req.params.timelineId) + .then((timeline) => { + if (!timeline) { + const apiErr = new Error(`Timeline not found for timeline id ${req.params.timelineId}`); + apiErr.status = 404; + return next(apiErr); + } + + // Set timeline to the request to be used in the next middleware + req.timeline = timeline; + return validateReference(timeline, req) + .then(next) + .catch(next); + }); + }, +}; + +export default validateTimeline; diff --git a/src/models/milestone.js b/src/models/milestone.js new file mode 100644 index 00000000..0f4bc4ec --- /dev/null +++ b/src/models/milestone.js @@ -0,0 +1,41 @@ +/* eslint-disable valid-jsdoc */ + +/** + * The Milestone model + */ +module.exports = (sequelize, DataTypes) => { + const Milestone = sequelize.define('Milestone', { + id: { type: DataTypes.BIGINT, primaryKey: true, autoIncrement: true }, + name: { type: DataTypes.STRING(255), allowNull: false }, + description: DataTypes.STRING(255), + duration: { type: DataTypes.INTEGER, allowNull: false }, + startDate: { type: DataTypes.DATE, allowNull: false }, + actualStartDate: DataTypes.DATE, + endDate: DataTypes.DATE, + completionDate: DataTypes.DATE, + status: { type: DataTypes.STRING(45), allowNull: false }, + type: { type: DataTypes.STRING(45), allowNull: false }, + details: DataTypes.JSON, + order: { type: DataTypes.INTEGER, allowNull: false }, + plannedText: { type: DataTypes.STRING(512), allowNull: false }, + activeText: { type: DataTypes.STRING(512), allowNull: false }, + completedText: { type: DataTypes.STRING(512), allowNull: false }, + blockedText: { type: DataTypes.STRING(512), allowNull: false }, + hidden: { type: DataTypes.BOOLEAN, defaultValue: false }, + deletedAt: DataTypes.DATE, + createdAt: { type: DataTypes.DATE, defaultValue: DataTypes.NOW }, + updatedAt: { type: DataTypes.DATE, defaultValue: DataTypes.NOW }, + deletedBy: DataTypes.BIGINT, + createdBy: { type: DataTypes.BIGINT, allowNull: false }, + updatedBy: { type: DataTypes.BIGINT, allowNull: false }, + }, { + tableName: 'milestones', + paranoid: true, + timestamps: true, + updatedAt: 'updatedAt', + createdAt: 'createdAt', + deletedAt: 'deletedAt', + }); + + return Milestone; +}; diff --git a/src/models/phaseProduct.js b/src/models/phaseProduct.js index 4ec1ea90..04ec131e 100644 --- a/src/models/phaseProduct.js +++ b/src/models/phaseProduct.js @@ -22,7 +22,7 @@ module.exports = function definePhaseProduct(sequelize, DataTypes) { updatedBy: { type: DataTypes.INTEGER, allowNull: false }, }, { tableName: 'phase_products', - paranoid: false, + paranoid: true, timestamps: true, updatedAt: 'updatedAt', createdAt: 'createdAt', diff --git a/src/models/productCategory.js b/src/models/productCategory.js new file mode 100644 index 00000000..07f745ef --- /dev/null +++ b/src/models/productCategory.js @@ -0,0 +1,31 @@ +/* eslint-disable valid-jsdoc */ + +/** + * The Product Category model + */ +module.exports = function defineProductCategory(sequelize, DataTypes) { + return sequelize.define('ProductCategory', { + key: { type: DataTypes.STRING(45), primaryKey: true }, + displayName: { type: DataTypes.STRING(255), allowNull: false }, + icon: { type: DataTypes.STRING(255), allowNull: false }, + question: { type: DataTypes.STRING(255), allowNull: false }, + info: { type: DataTypes.STRING(255), allowNull: false }, + aliases: { type: DataTypes.JSON, allowNull: false }, + disabled: { type: DataTypes.BOOLEAN, defaultValue: false }, + hidden: { type: DataTypes.BOOLEAN, defaultValue: false }, + + deletedAt: { type: DataTypes.DATE, allowNull: true }, + createdAt: { type: DataTypes.DATE, defaultValue: DataTypes.NOW }, + updatedAt: { type: DataTypes.DATE, defaultValue: DataTypes.NOW }, + deletedBy: { type: DataTypes.INTEGER, allowNull: true }, + createdBy: { type: DataTypes.INTEGER, allowNull: false }, + updatedBy: { type: DataTypes.INTEGER, allowNull: false }, + }, { + tableName: 'product_categories', + paranoid: true, + timestamps: true, + updatedAt: 'updatedAt', + createdAt: 'createdAt', + deletedAt: 'deletedAt', + }); +}; diff --git a/src/models/productMilestoneTemplate.js b/src/models/productMilestoneTemplate.js new file mode 100644 index 00000000..7db76b52 --- /dev/null +++ b/src/models/productMilestoneTemplate.js @@ -0,0 +1,35 @@ +/* eslint-disable valid-jsdoc */ + +/** + * The Product Milestone Template model + */ +module.exports = (sequelize, DataTypes) => { + const ProductMilestoneTemplate = sequelize.define('ProductMilestoneTemplate', { + id: { type: DataTypes.BIGINT, primaryKey: true, autoIncrement: true }, + name: { type: DataTypes.STRING(255), allowNull: false }, + description: DataTypes.STRING(255), + duration: { type: DataTypes.INTEGER, allowNull: false }, + type: { type: DataTypes.STRING(45), allowNull: false }, + order: { type: DataTypes.INTEGER, allowNull: false }, + plannedText: { type: DataTypes.STRING(512), allowNull: false }, + activeText: { type: DataTypes.STRING(512), allowNull: false }, + completedText: { type: DataTypes.STRING(512), allowNull: false }, + blockedText: { type: DataTypes.STRING(512), allowNull: false }, + hidden: { type: DataTypes.BOOLEAN, defaultValue: false }, + deletedAt: DataTypes.DATE, + createdAt: { type: DataTypes.DATE, defaultValue: DataTypes.NOW }, + updatedAt: { type: DataTypes.DATE, defaultValue: DataTypes.NOW }, + deletedBy: DataTypes.BIGINT, + createdBy: { type: DataTypes.BIGINT, allowNull: false }, + updatedBy: { type: DataTypes.BIGINT, allowNull: false }, + }, { + tableName: 'product_milestone_templates', + paranoid: true, + timestamps: true, + updatedAt: 'updatedAt', + createdAt: 'createdAt', + deletedAt: 'deletedAt', + }); + + return ProductMilestoneTemplate; +}; diff --git a/src/models/productTemplate.js b/src/models/productTemplate.js index cf1cf2db..fe955de8 100644 --- a/src/models/productTemplate.js +++ b/src/models/productTemplate.js @@ -8,6 +8,7 @@ module.exports = (sequelize, DataTypes) => { id: { type: DataTypes.BIGINT, primaryKey: true, autoIncrement: true }, name: { type: DataTypes.STRING(255), allowNull: false }, productKey: { type: DataTypes.STRING(45), allowNull: false }, + category: { type: DataTypes.STRING(45), allowNull: false }, icon: { type: DataTypes.STRING(255), allowNull: false }, brief: { type: DataTypes.STRING(45), allowNull: false }, details: { type: DataTypes.STRING(255), allowNull: false }, @@ -28,6 +29,15 @@ module.exports = (sequelize, DataTypes) => { updatedAt: 'updatedAt', createdAt: 'createdAt', deletedAt: 'deletedAt', + classMethods: { + associate: (models) => { + ProductTemplate.hasMany(models.ProductMilestoneTemplate, { + as: 'milestones', + foreignKey: 'productTemplateId', + onDelete: 'cascade', + }); + }, + }, }); return ProductTemplate; diff --git a/src/models/project.js b/src/models/project.js index f103a1fa..6bb6b66f 100644 --- a/src/models/project.js +++ b/src/models/project.js @@ -38,11 +38,13 @@ module.exports = function defineProject(sequelize, DataTypes) { deletedAt: { type: DataTypes.DATE, allowNull: true }, createdAt: { type: DataTypes.DATE, defaultValue: DataTypes.NOW }, updatedAt: { type: DataTypes.DATE, defaultValue: DataTypes.NOW }, + deletedBy: DataTypes.BIGINT, createdBy: { type: DataTypes.INTEGER, allowNull: false }, updatedBy: { type: DataTypes.INTEGER, allowNull: false }, version: { type: DataTypes.STRING(3), allowNull: false, defaultValue: 'v3' }, }, { tableName: 'projects', + paranoid: true, timestamps: true, updatedAt: 'updatedAt', createdAt: 'createdAt', diff --git a/src/models/projectAttachment.js b/src/models/projectAttachment.js index 8fab5508..9e917b25 100644 --- a/src/models/projectAttachment.js +++ b/src/models/projectAttachment.js @@ -12,11 +12,12 @@ module.exports = function defineProjectAttachment(sequelize, DataTypes) { deletedAt: { type: DataTypes.DATE, allowNull: true }, createdAt: { type: DataTypes.DATE, defaultValue: DataTypes.NOW }, updatedAt: { type: DataTypes.DATE, defaultValue: DataTypes.NOW }, + deletedBy: DataTypes.BIGINT, createdBy: { type: DataTypes.INTEGER, allowNull: false }, updatedBy: { type: DataTypes.INTEGER, allowNull: false }, }, { tableName: 'project_attachments', - paranoid: false, + paranoid: true, timestamps: true, updatedAt: 'updatedAt', createdAt: 'createdAt', diff --git a/src/models/projectHistory.js b/src/models/projectHistory.js index 23bdbe93..af169583 100644 --- a/src/models/projectHistory.js +++ b/src/models/projectHistory.js @@ -11,7 +11,7 @@ module.exports = function defineProjectHistory(sequelize, DataTypes) { updatedBy: { type: DataTypes.INTEGER, allowNull: false }, }, { tableName: 'project_history', - paranoid: false, + paranoid: true, timestamps: true, updatedAt: 'updatedAt', createdAt: 'createdAt', diff --git a/src/models/projectMember.js b/src/models/projectMember.js index 2ab4c94b..1003c017 100644 --- a/src/models/projectMember.js +++ b/src/models/projectMember.js @@ -17,6 +17,7 @@ module.exports = function defineProjectMember(sequelize, DataTypes) { deletedAt: { type: DataTypes.DATE, allowNull: true }, createdAt: { type: DataTypes.DATE, defaultValue: DataTypes.NOW }, updatedAt: { type: DataTypes.DATE, defaultValue: DataTypes.NOW }, + deletedBy: DataTypes.BIGINT, createdBy: { type: DataTypes.INTEGER, allowNull: false }, updatedBy: { type: DataTypes.INTEGER, allowNull: false }, }, { diff --git a/src/models/projectPhase.js b/src/models/projectPhase.js index bcfe827a..75b8e3dd 100644 --- a/src/models/projectPhase.js +++ b/src/models/projectPhase.js @@ -23,7 +23,7 @@ module.exports = function defineProjectPhase(sequelize, DataTypes) { updatedBy: { type: DataTypes.INTEGER, allowNull: false }, }, { tableName: 'project_phases', - paranoid: false, + paranoid: true, timestamps: true, updatedAt: 'updatedAt', createdAt: 'createdAt', diff --git a/src/models/projectType.js b/src/models/projectType.js index acd7b44b..19618698 100644 --- a/src/models/projectType.js +++ b/src/models/projectType.js @@ -10,6 +10,7 @@ module.exports = function definePhaseProduct(sequelize, DataTypes) { aliases: { type: DataTypes.JSON, allowNull: false }, disabled: { type: DataTypes.BOOLEAN, defaultValue: false }, hidden: { type: DataTypes.BOOLEAN, defaultValue: false }, + metadata: { type: DataTypes.JSON, allowNull: false }, deletedAt: { type: DataTypes.DATE, allowNull: true }, createdAt: { type: DataTypes.DATE, defaultValue: DataTypes.NOW }, diff --git a/src/models/timeline.js b/src/models/timeline.js new file mode 100644 index 00000000..5b9d6247 --- /dev/null +++ b/src/models/timeline.js @@ -0,0 +1,36 @@ +/* eslint-disable valid-jsdoc */ + +/** + * The Timeline model + */ +module.exports = (sequelize, DataTypes) => { + const Timeline = sequelize.define('Timeline', { + id: { type: DataTypes.BIGINT, primaryKey: true, autoIncrement: true }, + name: { type: DataTypes.STRING(255), allowNull: false }, + description: DataTypes.STRING(255), + startDate: { type: DataTypes.DATE, allowNull: false }, + endDate: DataTypes.DATE, + reference: { type: DataTypes.STRING(45), allowNull: false }, + referenceId: { type: DataTypes.BIGINT, allowNull: false }, + deletedAt: DataTypes.DATE, + createdAt: { type: DataTypes.DATE, defaultValue: DataTypes.NOW }, + updatedAt: { type: DataTypes.DATE, defaultValue: DataTypes.NOW }, + deletedBy: DataTypes.BIGINT, + createdBy: { type: DataTypes.BIGINT, allowNull: false }, + updatedBy: { type: DataTypes.BIGINT, allowNull: false }, + }, { + tableName: 'timelines', + paranoid: true, + timestamps: true, + updatedAt: 'updatedAt', + createdAt: 'createdAt', + deletedAt: 'deletedAt', + classMethods: { + associate: (models) => { + Timeline.hasMany(models.Milestone, { as: 'milestones', foreignKey: 'timelineId', onDelete: 'cascade' }); + }, + }, + }); + + return Timeline; +}; diff --git a/src/permissions/index.js b/src/permissions/index.js index 30c2c35d..f9b3668c 100644 --- a/src/permissions/index.js +++ b/src/permissions/index.js @@ -43,8 +43,29 @@ module.exports = () => { Authorizer.setPolicy('project.updatePhaseProduct', copilotAndAbove); Authorizer.setPolicy('project.deletePhaseProduct', copilotAndAbove); + Authorizer.setPolicy('milestoneTemplate.clone', projectAdmin); + Authorizer.setPolicy('milestoneTemplate.create', projectAdmin); + Authorizer.setPolicy('milestoneTemplate.edit', projectAdmin); + Authorizer.setPolicy('milestoneTemplate.delete', projectAdmin); + Authorizer.setPolicy('milestoneTemplate.view', true); + Authorizer.setPolicy('projectType.create', projectAdmin); Authorizer.setPolicy('projectType.edit', projectAdmin); Authorizer.setPolicy('projectType.delete', projectAdmin); Authorizer.setPolicy('projectType.view', true); // anyone can view project types + + Authorizer.setPolicy('productCategory.create', projectAdmin); + Authorizer.setPolicy('productCategory.edit', projectAdmin); + Authorizer.setPolicy('productCategory.delete', projectAdmin); + Authorizer.setPolicy('productCategory.view', true); // anyone can view product categories + + Authorizer.setPolicy('timeline.create', projectEdit); + Authorizer.setPolicy('timeline.edit', projectEdit); + Authorizer.setPolicy('timeline.delete', projectEdit); + Authorizer.setPolicy('timeline.view', projectView); + + Authorizer.setPolicy('milestone.create', projectEdit); + Authorizer.setPolicy('milestone.edit', projectEdit); + Authorizer.setPolicy('milestone.delete', projectEdit); + Authorizer.setPolicy('milestone.view', projectView); }; diff --git a/src/routes/attachments/delete.js b/src/routes/attachments/delete.js index 3401c06b..f5292b88 100644 --- a/src/routes/attachments/delete.js +++ b/src/routes/attachments/delete.js @@ -37,8 +37,9 @@ module.exports = [ return Promise.reject(err); } attachment = _attachment; - return _attachment.destroy(); - }) + return _attachment.update({ deletedBy: req.authUser.userId }) + .then(() => _attachment.destroy()); + })) .then((_attachment) => { if (process.env.NODE_ENV !== 'development') { return fileService.deleteFile(req, _attachment.filePath); @@ -56,6 +57,6 @@ module.exports = [ req.app.emit(EVENT.ROUTING_KEY.PROJECT_ATTACHMENT_REMOVED, { req, pattachment }); res.status(204).json({}); }) - .catch(err => next(err))); + .catch(err => next(err)); }, ]; diff --git a/src/routes/attachments/delete.spec.js b/src/routes/attachments/delete.spec.js index 87727788..850b8ca1 100644 --- a/src/routes/attachments/delete.spec.js +++ b/src/routes/attachments/delete.spec.js @@ -2,6 +2,7 @@ import _ from 'lodash'; import sinon from 'sinon'; import request from 'supertest'; +import chai from 'chai'; import models from '../../models'; import util from '../../util'; @@ -113,8 +114,31 @@ describe('Project Attachments delete', () => { if (err) { done(err); } else { - deleteSpy.should.have.been.calledOnce; - done(); + setTimeout(() => + models.ProjectAttachment.findOne({ + where: { + projectId: project1.id, + id: attachment.id, + }, + paranoid: false, + }) + .then((res) => { + if (!res) { + throw new Error('Should found the entity'); + } else { + deleteSpy.should.have.been.calledOnce; + + chai.assert.isNotNull(res.deletedAt); + chai.assert.isNotNull(res.deletedBy); + + request(server) + .get(`/v4/projects/${project1.id}/attachments/${attachment.id}`) + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .expect(404, done); + } + }), 500); } }); }); diff --git a/src/routes/index.js b/src/routes/index.js index 48d52880..8e4f034f 100644 --- a/src/routes/index.js +++ b/src/routes/index.js @@ -41,9 +41,14 @@ router.route('/v4/projectTypes') router.route('/v4/projectTypes/:key') .get(require('./projectTypes/get')); +router.route('/v4/productCategories') + .get(require('./productCategories/list')); +router.route('/v4/productCategories/:key') + .get(require('./productCategories/get')); + router.all( - RegExp(`\\/${apiVersion}\\/(projects|projectTemplates|productTemplates|projectTypes)(?!\\/health).*`), - jwtAuth()); + RegExp(`\\/${apiVersion}\\/(projects|projectTemplates|productTemplates|productCategories|projectTypes|` + + 'timelines)(?!\\/health).*'), jwtAuth()); // Register all the routes router.route('/v4/projects') @@ -99,6 +104,18 @@ router.route('/v4/productTemplates/:templateId(\\d+)') .patch(require('./productTemplates/update')) .delete(require('./productTemplates/delete')); +router.route('/v4/productTemplates/:productTemplateId(\\d+)/milestones') + .post(require('./milestoneTemplates/create')) + .get(require('./milestoneTemplates/list')); + +router.route('/v4/productTemplates/:productTemplateId(\\d+)/milestones/clone') + .post(require('./milestoneTemplates/clone')); + +router.route('/v4/productTemplates/:productTemplateId(\\d+)/milestones/:milestoneTemplateId(\\d+)') + .get(require('./milestoneTemplates/get')) + .patch(require('./milestoneTemplates/update')) + .delete(require('./milestoneTemplates/delete')); + router.route('/v4/projects/:projectId(\\d+)/phases') .get(require('./phases/list')) .post(require('./phases/create')); @@ -117,6 +134,13 @@ router.route('/v4/projects/:projectId(\\d+)/phases/:phaseId(\\d+)/products/:prod .patch(require('./phaseProducts/update')) .delete(require('./phaseProducts/delete')); +router.route('/v4/productCategories') + .post(require('./productCategories/create')); + +router.route('/v4/productCategories/:key') + .patch(require('./productCategories/update')) + .delete(require('./productCategories/delete')); + router.route('/v4/projectTypes') .post(require('./projectTypes/create')); @@ -124,6 +148,24 @@ router.route('/v4/projectTypes/:key') .patch(require('./projectTypes/update')) .delete(require('./projectTypes/delete')); +router.route('/v4/timelines') + .post(require('./timelines/create')) + .get(require('./timelines/list')); + +router.route('/v4/timelines/:timelineId(\\d+)') + .get(require('./timelines/get')) + .patch(require('./timelines/update')) + .delete(require('./timelines/delete')); + +router.route('/v4/timelines/:timelineId(\\d+)/milestones') + .post(require('./milestones/create')) + .get(require('./milestones/list')); + +router.route('/v4/timelines/:timelineId(\\d+)/milestones/:milestoneId(\\d+)') + .get(require('./milestones/get')) + .patch(require('./milestones/update')) + .delete(require('./milestones/delete')); + // register error handler router.use((err, req, res, next) => { // eslint-disable-line no-unused-vars // DO NOT REMOVE next arg.. even though eslint diff --git a/src/routes/milestoneTemplates/clone.js b/src/routes/milestoneTemplates/clone.js new file mode 100644 index 00000000..c7fd1bf3 --- /dev/null +++ b/src/routes/milestoneTemplates/clone.js @@ -0,0 +1,100 @@ +/** + * API to clone a milestone 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: { + productTemplateId: Joi.number().integer().positive().required(), + }, + body: { + param: Joi.object().keys({ + sourceTemplateId: Joi.number().integer().positive().required(), + }).required(), + }, +}; + +module.exports = [ + validate(schema), + permissions('milestoneTemplate.clone'), + (req, res, next) => { + let result; + + return models.sequelize.transaction(tx => + // Find the product template + models.ProductTemplate.findAll({ where: { id: [req.params.productTemplateId, req.body.param.sourceTemplateId] }, + transaction: tx }) + .then((productTemplates) => { + // Not found + if (!productTemplates) { + const apiErr = new Error( + `Product template not found for product template ids ${req.params.productTemplateId} + ${req.body.param.sourceTemplateId}`); + apiErr.status = 404; + return Promise.reject(apiErr); + } + + const targetProductTemplate = _.find(productTemplates, ['id', req.params.productTemplateId]); + const sourceProductTemplate = _.find(productTemplates, ['id', req.body.param.sourceTemplateId]); + + // Not found + if (!targetProductTemplate) { + const apiErr = new Error( + `Product template not found for product template id ${req.params.productTemplateId}`); + apiErr.status = 404; + return Promise.reject(apiErr); + } + + // Not found + if (!sourceProductTemplate) { + const apiErr = new Error( + `Product template not found for source product template id ${req.body.param.sourceTemplateId}`); + apiErr.status = 404; + return Promise.reject(apiErr); + } + + return models.ProductMilestoneTemplate.findAll({ + where: { + productTemplateId: req.body.param.sourceTemplateId, + }, + attributes: { exclude: ['id', 'deletedAt', 'createdAt', 'updatedAt', 'deletedBy'] }, + raw: true, + }) + .then((milestoneTemplatesToClone) => { + const newMilestoneTemplates = _.cloneDeep(milestoneTemplatesToClone); + _.each(newMilestoneTemplates, (milestone) => { + milestone.productTemplateId = req.params.productTemplateId; // eslint-disable-line no-param-reassign + milestone.createdBy = req.authUser.userId; // eslint-disable-line no-param-reassign + milestone.updatedBy = req.authUser.userId; // eslint-disable-line no-param-reassign + }); + return models.ProductMilestoneTemplate.bulkCreate(newMilestoneTemplates, { transaction: tx }); + }); + }) + .then(() => { // eslint-disable-line arrow-body-style + return models.ProductMilestoneTemplate.findAll({ + where: { + productTemplateId: req.params.productTemplateId, + }, + attributes: { exclude: ['deletedAt', 'deletedBy'] }, + raw: true, + }) + .then((clonedMilestoneTemplates) => { + result = clonedMilestoneTemplates; + return result; + }); + }), + ) + .then(() => { + // Write to response + res.status(201).json(util.wrapResponse(req.id, result, 1, 201)); + }) + .catch(next); + }, +]; diff --git a/src/routes/milestoneTemplates/clone.spec.js b/src/routes/milestoneTemplates/clone.spec.js new file mode 100644 index 00000000..2f008e8d --- /dev/null +++ b/src/routes/milestoneTemplates/clone.spec.js @@ -0,0 +1,253 @@ +/** + * Tests for create.js + */ +import chai from 'chai'; +import request from 'supertest'; +import server from '../../app'; +import testUtil from '../../tests/util'; +import models from '../../models'; + +const should = chai.should(); + +const productTemplates = [ + { + name: 'name 1', + productKey: 'productKey 1', + category: 'category', + icon: 'http://example.com/icon1.ico', + brief: 'brief 1', + details: 'details 1', + aliases: { + alias1: { + subAlias1A: 1, + subAlias1B: 2, + }, + alias2: [1, 2, 3], + }, + template: { + template1: { + name: 'template 1', + details: { + anyDetails: 'any details 1', + }, + others: ['others 11', 'others 12'], + }, + template2: { + name: 'template 2', + details: { + anyDetails: 'any details 2', + }, + others: ['others 21', 'others 22'], + }, + }, + createdBy: 1, + updatedBy: 2, + }, + { + name: 'name 2', + productKey: 'productKey 2', + category: 'category', + icon: 'http://example.com/icon1.ico', + brief: 'brief 2', + details: 'details 2', + aliases: { + alias1: { + subAlias1A: 1, + subAlias1B: 2, + }, + alias2: [1, 2, 3], + }, + template: { + template1: { + name: 'template 1', + details: { + anyDetails: 'any details 1', + }, + others: ['others 11', 'others 12'], + }, + template2: { + name: 'template 2', + details: { + anyDetails: 'any details 2', + }, + others: ['others 21', 'others 22'], + }, + }, + createdBy: 1, + updatedBy: 2, + }, +]; +const milestoneTemplates = [ + { + name: 'milestoneTemplate 1', + duration: 3, + type: 'type1', + order: 1, + productTemplateId: 1, + plannedText: 'text to be shown in planned stage', + blockedText: 'text to be shown in blocked stage', + activeText: 'text to be shown in active stage', + completedText: 'text to be shown in completed stage', + createdBy: 1, + updatedBy: 2, + }, + { + name: 'milestoneTemplate 2', + duration: 4, + type: 'type2', + order: 2, + plannedText: 'text to be shown in planned stage - 2', + blockedText: 'text to be shown in blocked stage - 2', + activeText: 'text to be shown in active stage - 2', + completedText: 'text to be shown in completed stage - 2', + productTemplateId: 1, + createdBy: 2, + updatedBy: 3, + }, +]; + +describe('CLONE milestone template', () => { + beforeEach(() => testUtil.clearDb() + .then(() => models.ProductTemplate.bulkCreate(productTemplates)) + .then(() => models.ProductMilestoneTemplate.bulkCreate(milestoneTemplates)), + ); + after(testUtil.clearDb); + + describe('POST /productTemplates/{productTemplateId}/milestones/clone', () => { + const body = { + param: { + sourceTemplateId: 1, + }, + }; + + it('should return 403 if user is not authenticated/clone', (done) => { + request(server) + .post('/v4/productTemplates/2/milestones') + .send(body) + .expect(403, done); + }); + + it('should return 403 for member', (done) => { + request(server) + .post('/v4/productTemplates/2/milestones/clone') + .set({ + Authorization: `Bearer ${testUtil.jwts.member}`, + }) + .send(body) + .expect(403, done); + }); + + it('should return 403 for copilot', (done) => { + request(server) + .post('/v4/productTemplates/2/milestones/clone') + .set({ + Authorization: `Bearer ${testUtil.jwts.copilot}`, + }) + .send(body) + .expect(403, done); + }); + + it('should return 403 for manager', (done) => { + request(server) + .post('/v4/productTemplates/2/milestones/clone') + .set({ + Authorization: `Bearer ${testUtil.jwts.manager}`, + }) + .send(body) + .expect(403, done); + }); + + it('should return 404 for non-existent product template', (done) => { + request(server) + .post('/v4/productTemplates/1000/milestones/clone') + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .send(body) + .expect(404, done); + }); + + it('should return 404 for non-existent source product template', (done) => { + const invalidBody = { + param: { + sourceTemplateId: 99, + }, + }; + + request(server) + .post('/v4/productTemplates/2/milestones/clone') + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .send(invalidBody) + .expect(404, done); + }); + + it('should return 422 if missing sourceTemplateId', (done) => { + const invalidBody = { + param: { + sourceTemplateId: undefined, + }, + }; + + request(server) + .post('/v4/productTemplates/2/milestones/clone') + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .send(invalidBody) + .expect('Content-Type', /json/) + .expect(422, done); + }); + + it('should return 201 for admin', (done) => { + request(server) + .post('/v4/productTemplates/2/milestones/clone') + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .send(body) + .expect(201) + .end((err, res) => { + const resJson = res.body.result.content; + resJson.should.have.length(2); + should.not.equal(resJson[0].id, null); + resJson[0].name.should.be.eql(milestoneTemplates[0].name); + resJson[0].duration.should.be.eql(milestoneTemplates[0].duration); + resJson[0].type.should.be.eql(milestoneTemplates[0].type); + resJson[0].order.should.be.eql(milestoneTemplates[0].order); + resJson[0].plannedText.should.be.eql(milestoneTemplates[0].plannedText); + resJson[0].blockedText.should.be.eql(milestoneTemplates[0].blockedText); + resJson[0].activeText.should.be.eql(milestoneTemplates[0].activeText); + resJson[0].completedText.should.be.eql(milestoneTemplates[0].completedText); + resJson[0].productTemplateId.should.be.eql(2); + + resJson[0].createdBy.should.be.eql(40051333); // admin + should.exist(resJson[0].createdAt); + resJson[0].updatedBy.should.be.eql(40051333); // admin + should.exist(resJson[0].updatedAt); + should.not.exist(resJson[0].deletedBy); + should.not.exist(resJson[0].deletedAt); + + done(); + }); + }); + + it('should return 201 for connect admin', (done) => { + request(server) + .post('/v4/productTemplates/2/milestones/clone') + .set({ + Authorization: `Bearer ${testUtil.jwts.connectAdmin}`, + }) + .send(body) + .expect(201) + .end((err, res) => { + const resJson = res.body.result.content; + resJson.should.have.length(2); + resJson[0].createdBy.should.be.eql(40051336); // connect admin + resJson[0].updatedBy.should.be.eql(40051336); // connect admin + done(); + }); + }); + }); +}); diff --git a/src/routes/milestoneTemplates/create.js b/src/routes/milestoneTemplates/create.js new file mode 100644 index 00000000..b207b72a --- /dev/null +++ b/src/routes/milestoneTemplates/create.js @@ -0,0 +1,90 @@ +/** + * API to add a milestone template + */ +import validate from 'express-validation'; +import _ from 'lodash'; +import Joi from 'joi'; +import Sequelize from 'sequelize'; +import { middleware as tcMiddleware } from 'tc-core-library-js'; +import util from '../../util'; +import models from '../../models'; + +const permissions = tcMiddleware.permissions; + +const schema = { + params: { + productTemplateId: Joi.number().integer().positive().required(), + }, + body: { + param: Joi.object().keys({ + id: Joi.any().strip(), + name: Joi.string().max(255).required(), + description: Joi.string().max(255), + duration: Joi.number().integer().required(), + type: Joi.string().max(45).required(), + order: Joi.number().integer().required(), + plannedText: Joi.string().max(512).required(), + activeText: Joi.string().max(512).required(), + completedText: Joi.string().max(512).required(), + blockedText: Joi.string().max(512).required(), + productTemplateId: Joi.any().strip(), + hidden: Joi.boolean().optional(), + createdAt: Joi.any().strip(), + updatedAt: Joi.any().strip(), + deletedAt: Joi.any().strip(), + createdBy: Joi.any().strip(), + updatedBy: Joi.any().strip(), + deletedBy: Joi.any().strip(), + }).required(), + }, +}; + +module.exports = [ + validate(schema), + permissions('milestoneTemplate.create'), + (req, res, next) => { + const entity = _.assign(req.body.param, { + createdBy: req.authUser.userId, + updatedBy: req.authUser.userId, + productTemplateId: req.params.productTemplateId, + }); + let result; + + return models.sequelize.transaction(tx => + // Find the product template + models.ProductTemplate.findById(req.params.productTemplateId, { transaction: tx }) + .then((productTemplate) => { + // Not found + if (!productTemplate) { + const apiErr = new Error( + `Product template not found for product template id ${req.params.productTemplateId}`); + apiErr.status = 404; + return Promise.reject(apiErr); + } + + // Create the milestone template + return models.ProductMilestoneTemplate.create(entity, { transaction: tx }); + }) + .then((createdEntity) => { + // Omit deletedAt and deletedBy + result = _.omit(createdEntity.toJSON(), 'deletedAt', 'deletedBy'); + + // Increase the order of the other milestone templates in the same product template, + // which have `order` >= this milestone template order + return models.ProductMilestoneTemplate.update({ order: Sequelize.literal('"order" + 1') }, { + where: { + productTemplateId: req.params.productTemplateId, + id: { $ne: result.id }, + order: { $gte: result.order }, + }, + transaction: tx, + }); + }), + ) + .then(() => { + // Write to response + res.status(201).json(util.wrapResponse(req.id, result, 1, 201)); + }) + .catch(next); + }, +]; diff --git a/src/routes/milestoneTemplates/create.spec.js b/src/routes/milestoneTemplates/create.spec.js new file mode 100644 index 00000000..c821a5fe --- /dev/null +++ b/src/routes/milestoneTemplates/create.spec.js @@ -0,0 +1,312 @@ +/** + * Tests for create.js + */ +import chai from 'chai'; +import request from 'supertest'; +import _ from 'lodash'; +import server from '../../app'; +import testUtil from '../../tests/util'; +import models from '../../models'; + +const should = chai.should(); + +const productTemplates = [ + { + name: 'name 1', + productKey: 'productKey 1', + category: 'category', + icon: 'http://example.com/icon1.ico', + brief: 'brief 1', + details: 'details 1', + aliases: { + alias1: { + subAlias1A: 1, + subAlias1B: 2, + }, + alias2: [1, 2, 3], + }, + template: { + template1: { + name: 'template 1', + details: { + anyDetails: 'any details 1', + }, + others: ['others 11', 'others 12'], + }, + template2: { + name: 'template 2', + details: { + anyDetails: 'any details 2', + }, + others: ['others 21', 'others 22'], + }, + }, + createdBy: 1, + updatedBy: 2, + }, + { + name: 'template 2', + productKey: 'productKey 2', + category: 'category', + icon: 'http://example.com/icon2.ico', + brief: 'brief 2', + details: 'details 2', + aliases: {}, + template: {}, + createdBy: 3, + updatedBy: 4, + deletedAt: new Date(), + }, +]; +const milestoneTemplates = [ + { + name: 'milestoneTemplate 1', + duration: 3, + type: 'type1', + order: 1, + productTemplateId: 1, + plannedText: 'text to be shown in planned stage', + blockedText: 'text to be shown in blocked stage', + activeText: 'text to be shown in active stage', + completedText: 'text to be shown in completed stage', + createdBy: 1, + updatedBy: 2, + }, + { + name: 'milestoneTemplate 2', + duration: 4, + type: 'type2', + order: 2, + plannedText: 'text to be shown in planned stage - 2', + blockedText: 'text to be shown in blocked stage - 2', + activeText: 'text to be shown in active stage - 2', + completedText: 'text to be shown in completed stage - 2', + productTemplateId: 1, + createdBy: 2, + updatedBy: 3, + }, +]; + +describe('CREATE milestone template', () => { + beforeEach(() => testUtil.clearDb() + .then(() => models.ProductTemplate.bulkCreate(productTemplates)) + .then(() => models.ProductMilestoneTemplate.bulkCreate(milestoneTemplates)), + ); + after(testUtil.clearDb); + + describe('POST /productTemplates/{productTemplateId}/milestones', () => { + const body = { + param: { + name: 'milestoneTemplate 3', + description: 'description 3', + duration: 33, + type: 'type3', + order: 1, + plannedText: 'text to be shown in planned stage - 3', + blockedText: 'text to be shown in blocked stage - 3', + activeText: 'text to be shown in active stage - 3', + completedText: 'text to be shown in completed stage - 3', + hidden: true, + }, + }; + + it('should return 403 if user is not authenticated', (done) => { + request(server) + .post('/v4/productTemplates/1/milestones') + .send(body) + .expect(403, done); + }); + + it('should return 403 for member', (done) => { + request(server) + .post('/v4/productTemplates/1/milestones') + .set({ + Authorization: `Bearer ${testUtil.jwts.member}`, + }) + .send(body) + .expect(403, done); + }); + + it('should return 403 for copilot', (done) => { + request(server) + .post('/v4/productTemplates/1/milestones') + .set({ + Authorization: `Bearer ${testUtil.jwts.copilot}`, + }) + .send(body) + .expect(403, done); + }); + + it('should return 403 for manager', (done) => { + request(server) + .post('/v4/productTemplates/1/milestones') + .set({ + Authorization: `Bearer ${testUtil.jwts.manager}`, + }) + .send(body) + .expect(403, done); + }); + + it('should return 404 for non-existed product template', (done) => { + request(server) + .post('/v4/productTemplates/1000/milestones') + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .send(body) + .expect(404, done); + }); + + it('should return 422 if missing name', (done) => { + const invalidBody = { + param: { + name: undefined, + }, + }; + + request(server) + .post('/v4/productTemplates/1/milestones') + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .send(invalidBody) + .expect('Content-Type', /json/) + .expect(422, done); + }); + + it('should return 422 if missing duration', (done) => { + const invalidBody = { + param: { + duration: undefined, + }, + }; + + request(server) + .post('/v4/productTemplates/1/milestones') + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .send(invalidBody) + .expect('Content-Type', /json/) + .expect(422, done); + }); + + it('should return 422 if missing type', (done) => { + const invalidBody = { + param: { + type: undefined, + }, + }; + + request(server) + .post('/v4/productTemplates/1/milestones') + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .send(invalidBody) + .expect('Content-Type', /json/) + .expect(422, done); + }); + + it('should return 422 if missing order', (done) => { + const invalidBody = { + param: { + order: undefined, + }, + }; + + request(server) + .post('/v4/productTemplates/1/milestones') + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .send(invalidBody) + .expect('Content-Type', /json/) + .expect(422, done); + }); + + it('should return 201 for admin', (done) => { + request(server) + .post('/v4/productTemplates/1/milestones') + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .send(body) + .expect('Content-Type', /json/) + .expect(201) + .end((err, res) => { + const resJson = res.body.result.content; + should.exist(resJson.id); + resJson.name.should.be.eql(body.param.name); + resJson.description.should.be.eql(body.param.description); + resJson.duration.should.be.eql(body.param.duration); + resJson.type.should.be.eql(body.param.type); + resJson.order.should.be.eql(body.param.order); + resJson.plannedText.should.be.eql(body.param.plannedText); + resJson.blockedText.should.be.eql(body.param.blockedText); + resJson.activeText.should.be.eql(body.param.activeText); + resJson.completedText.should.be.eql(body.param.completedText); + + resJson.createdBy.should.be.eql(40051333); // admin + should.exist(resJson.createdAt); + resJson.updatedBy.should.be.eql(40051333); // admin + should.exist(resJson.updatedAt); + should.not.exist(resJson.deletedBy); + should.not.exist(resJson.deletedAt); + + // Verify 'order' of the other milestones + models.ProductMilestoneTemplate.findAll({ + where: { + productTemplateId: 1, + }, + }).then((milestones) => { + _.each(milestones, (milestone) => { + if (milestone.id === 1) { + milestone.order.should.be.eql(1 + 1); + } else if (milestone.id === 2) { + milestone.order.should.be.eql(2 + 1); + } + }); + done(); + }).catch((error) => { + done(error); + }); + }); + }); + + it('should return 201 for admin without optional fields', (done) => { + const minimalBody = _.cloneDeep(body); + delete minimalBody.param.hidden; + request(server) + .post('/v4/productTemplates/1/milestones') + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .send(minimalBody) + .expect('Content-Type', /json/) + .expect(201) + .end((err, res) => { + const resJson = res.body.result.content; + resJson.hidden.should.be.eql(false); // default of hidden field + done(); + }); + }); + + it('should return 201 for connect admin', (done) => { + request(server) + .post('/v4/productTemplates/1/milestones') + .set({ + Authorization: `Bearer ${testUtil.jwts.connectAdmin}`, + }) + .send(body) + .expect('Content-Type', /json/) + .expect(201) + .end((err, res) => { + const resJson = res.body.result.content; + resJson.createdBy.should.be.eql(40051336); // connect admin + resJson.updatedBy.should.be.eql(40051336); // connect admin + done(); + }); + }); + }); +}); diff --git a/src/routes/milestoneTemplates/delete.js b/src/routes/milestoneTemplates/delete.js new file mode 100644 index 00000000..43d3fd57 --- /dev/null +++ b/src/routes/milestoneTemplates/delete.js @@ -0,0 +1,48 @@ +/** + * API to delete a milestone template + */ +import validate from 'express-validation'; +import Joi from 'joi'; +import { middleware as tcMiddleware } from 'tc-core-library-js'; +import models from '../../models'; + +const permissions = tcMiddleware.permissions; + +const schema = { + params: { + productTemplateId: Joi.number().integer().positive().required(), + milestoneTemplateId: Joi.number().integer().positive().required(), + }, +}; + +module.exports = [ + validate(schema), + permissions('milestoneTemplate.delete'), + (req, res, next) => { + const where = { + id: req.params.milestoneTemplateId, + deletedAt: { $eq: null }, + productTemplateId: req.params.productTemplateId, + }; + + return models.sequelize.transaction(() => + // soft delete the record + models.ProductMilestoneTemplate.findOne({ + where, + }).then((existing) => { + if (!existing) { + // handle 404 + const err = new Error( + `Milestone template not found for milestone template id ${req.params.milestoneTemplateId}`); + err.status = 404; + return Promise.reject(err); + } + return existing.update({ deletedBy: req.authUser.userId }); + }) + .then(entity => entity.destroy())) + .then(() => { + res.status(204).end(); + }) + .catch(next); + }, +]; diff --git a/src/routes/milestoneTemplates/delete.spec.js b/src/routes/milestoneTemplates/delete.spec.js new file mode 100644 index 00000000..63091b36 --- /dev/null +++ b/src/routes/milestoneTemplates/delete.spec.js @@ -0,0 +1,223 @@ +/** + * Tests for delete.js + */ +import request from 'supertest'; +import chai from 'chai'; + +import models from '../../models'; +import server from '../../app'; +import testUtil from '../../tests/util'; + +const expectAfterDelete = (productTemplateId, id, err, next) => { + if (err) throw err; + setTimeout(() => + models.ProductMilestoneTemplate.findOne({ + where: { + id, + productTemplateId, + }, + paranoid: false, + }) + .then((res) => { + if (!res) { + throw new Error('Should found the entity'); + } else { + chai.assert.isNotNull(res.deletedAt); + chai.assert.isNotNull(res.deletedBy); + + request(server) + .get(`/v4/productTemplates/${productTemplateId}/milestones/${id}`) + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .expect(404, next); + } + }), 500); +}; +const productTemplates = [ + { + name: 'name 1', + productKey: 'productKey 1', + category: 'category', + icon: 'http://example.com/icon1.ico', + brief: 'brief 1', + details: 'details 1', + aliases: { + alias1: { + subAlias1A: 1, + subAlias1B: 2, + }, + alias2: [1, 2, 3], + }, + template: { + template1: { + name: 'template 1', + details: { + anyDetails: 'any details 1', + }, + others: ['others 11', 'others 12'], + }, + template2: { + name: 'template 2', + details: { + anyDetails: 'any details 2', + }, + others: ['others 21', 'others 22'], + }, + }, + createdBy: 1, + updatedBy: 2, + }, + { + name: 'template 2', + productKey: 'productKey 2', + category: 'category', + icon: 'http://example.com/icon2.ico', + brief: 'brief 2', + details: 'details 2', + aliases: {}, + template: {}, + createdBy: 3, + updatedBy: 4, + deletedAt: new Date(), + }, +]; +const milestoneTemplates = [ + { + id: 1, + name: 'milestoneTemplate 1', + duration: 3, + type: 'type1', + order: 1, + plannedText: 'text to be shown in planned stage', + blockedText: 'text to be shown in blocked stage', + activeText: 'text to be shown in active stage', + completedText: 'text to be shown in completed stage', + productTemplateId: 1, + createdBy: 1, + updatedBy: 2, + }, + { + id: 2, + name: 'milestoneTemplate 2', + duration: 4, + type: 'type2', + order: 2, + plannedText: 'text to be shown in planned stage - 2', + blockedText: 'text to be shown in blocked stage - 2', + activeText: 'text to be shown in active stage - 2', + completedText: 'text to be shown in completed stage - 2', + productTemplateId: 1, + createdBy: 2, + updatedBy: 3, + deletedAt: new Date(), + }, +]; + +describe('DELETE milestone template', () => { + beforeEach(() => testUtil.clearDb() + .then(() => models.ProductTemplate.bulkCreate(productTemplates)) + .then(() => models.ProductMilestoneTemplate.bulkCreate(milestoneTemplates)), + ); + after(testUtil.clearDb); + + describe('DELETE /productTemplates/{productTemplateId}/milestones/{milestoneTemplateId}', () => { + it('should return 403 if user is not authenticated', (done) => { + request(server) + .delete('/v4/productTemplates/1/milestones/1') + .expect(403, done); + }); + + it('should return 403 for member', (done) => { + request(server) + .delete('/v4/productTemplates/1/milestones/1') + .set({ + Authorization: `Bearer ${testUtil.jwts.member}`, + }) + .expect(403, done); + }); + + it('should return 403 for copilot', (done) => { + request(server) + .delete('/v4/productTemplates/1/milestones/1') + .set({ + Authorization: `Bearer ${testUtil.jwts.copilot}`, + }) + .expect(403, done); + }); + + it('should return 403 for manager', (done) => { + request(server) + .delete('/v4/productTemplates/1/milestones/1') + .set({ + Authorization: `Bearer ${testUtil.jwts.manager}`, + }) + .expect(403, done); + }); + + it('should return 404 for non-existed product template', (done) => { + request(server) + .delete('/v4/productTemplates/1234/milestones/1') + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .expect(404, done); + }); + + it('should return 404 for non-existed milestone template', (done) => { + request(server) + .delete('/v4/productTemplates/1/milestones/444') + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .expect(404, done); + }); + + it('should return 404 for deleted milestone template', (done) => { + request(server) + .delete('/v4/productTemplates/1/milestones/2') + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .expect(404, done); + }); + + it('should return 422 for invalid productTemplateId param', (done) => { + request(server) + .delete('/v4/productTemplates/0/milestones/2') + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .expect(422, done); + }); + + it('should return 422 for invalid milestoneTemplateId param', (done) => { + request(server) + .delete('/v4/productTemplates/1/milestones/0') + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .expect(422, done); + }); + + it('should return 204, for admin, if template was successfully removed', (done) => { + request(server) + .delete('/v4/productTemplates/1/milestones/1') + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .expect(204) + .end(err => expectAfterDelete(1, 1, err, done)); + }); + + it('should return 204, for connect admin, if template was successfully removed', (done) => { + request(server) + .delete('/v4/productTemplates/1/milestones/1') + .set({ + Authorization: `Bearer ${testUtil.jwts.connectAdmin}`, + }) + .expect(204) + .end(err => expectAfterDelete(1, 1, err, done)); + }); + }); +}); diff --git a/src/routes/milestoneTemplates/get.js b/src/routes/milestoneTemplates/get.js new file mode 100644 index 00000000..1c1fa3f0 --- /dev/null +++ b/src/routes/milestoneTemplates/get.js @@ -0,0 +1,43 @@ +/** + * API to get a milestone template + */ +import validate from 'express-validation'; +import Joi from 'joi'; +import { middleware as tcMiddleware } from 'tc-core-library-js'; +import util from '../../util'; +import models from '../../models'; + +const permissions = tcMiddleware.permissions; + +const schema = { + params: { + productTemplateId: Joi.number().integer().positive().required(), + milestoneTemplateId: Joi.number().integer().positive().required(), + }, +}; + +module.exports = [ + validate(schema), + permissions('milestoneTemplate.view'), + (req, res, next) => models.ProductMilestoneTemplate.findOne({ + where: { + id: req.params.milestoneTemplateId, + productTemplateId: req.params.productTemplateId, + }, + attributes: { exclude: ['deletedAt', 'deletedBy'] }, + raw: true, + }) + .then((milestoneTemplate) => { + // Not found + if (!milestoneTemplate) { + const apiErr = new Error( + `Milestone template not found for milestone template id ${req.params.milestoneTemplateId}`); + apiErr.status = 404; + return Promise.reject(apiErr); + } + + res.json(util.wrapResponse(req.id, milestoneTemplate)); + return Promise.resolve(); + }) + .catch(next), +]; diff --git a/src/routes/milestoneTemplates/get.spec.js b/src/routes/milestoneTemplates/get.spec.js new file mode 100644 index 00000000..958a30a6 --- /dev/null +++ b/src/routes/milestoneTemplates/get.spec.js @@ -0,0 +1,203 @@ +/** + * Tests for get.js + */ +import chai from 'chai'; +import request from 'supertest'; + +import models from '../../models'; +import server from '../../app'; +import testUtil from '../../tests/util'; + +const should = chai.should(); + +const productTemplates = [ + { + name: 'name 1', + productKey: 'productKey 1', + category: 'category', + icon: 'http://example.com/icon1.ico', + brief: 'brief 1', + details: 'details 1', + aliases: { + alias1: { + subAlias1A: 1, + subAlias1B: 2, + }, + alias2: [1, 2, 3], + }, + template: { + template1: { + name: 'template 1', + details: { + anyDetails: 'any details 1', + }, + others: ['others 11', 'others 12'], + }, + template2: { + name: 'template 2', + details: { + anyDetails: 'any details 2', + }, + others: ['others 21', 'others 22'], + }, + }, + createdBy: 1, + updatedBy: 2, + }, + { + name: 'template 2', + productKey: 'productKey 2', + category: 'category', + icon: 'http://example.com/icon2.ico', + brief: 'brief 2', + details: 'details 2', + aliases: {}, + template: {}, + createdBy: 3, + updatedBy: 4, + deletedAt: new Date(), + }, +]; +const milestoneTemplates = [ + { + id: 1, + name: 'milestoneTemplate 1', + duration: 3, + type: 'type1', + order: 1, + plannedText: 'text to be shown in planned stage', + blockedText: 'text to be shown in blocked stage', + activeText: 'text to be shown in active stage', + completedText: 'text to be shown in completed stage', + productTemplateId: 1, + createdBy: 1, + updatedBy: 2, + }, + { + id: 2, + name: 'milestoneTemplate 2', + duration: 4, + type: 'type2', + order: 2, + plannedText: 'text to be shown in planned stage - 2', + blockedText: 'text to be shown in blocked stage - 2', + activeText: 'text to be shown in active stage - 2', + completedText: 'text to be shown in completed stage - 2', + productTemplateId: 1, + createdBy: 2, + updatedBy: 3, + deletedAt: new Date(), + }, +]; + +describe('GET milestone template', () => { + beforeEach(() => testUtil.clearDb() + .then(() => models.ProductTemplate.bulkCreate(productTemplates)) + .then(() => models.ProductMilestoneTemplate.bulkCreate(milestoneTemplates)), + ); + after(testUtil.clearDb); + + describe('GET /productTemplates/{productTemplateId}/milestones/{milestoneTemplateId}', () => { + it('should return 403 if user is not authenticated', (done) => { + request(server) + .get('/v4/productTemplates/1/milestones/1') + .expect(403, done); + }); + + it('should return 404 for non-existed product template', (done) => { + request(server) + .get('/v4/productTemplates/1234/milestones/1') + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .expect(404, done); + }); + + it('should return 404 for non-existed milestone template', (done) => { + request(server) + .get('/v4/productTemplates/1/milestones/1111') + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .expect(404, done); + }); + + it('should return 404 for deleted milestone template', (done) => { + request(server) + .get('/v4/productTemplates/1/milestones/2') + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .expect(404, done); + }); + + it('should return 200 for admin', (done) => { + request(server) + .get('/v4/productTemplates/1/milestones/1') + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .expect(200) + .end((err, res) => { + const resJson = res.body.result.content; + resJson.id.should.be.eql(milestoneTemplates[0].id); + resJson.name.should.be.eql(milestoneTemplates[0].name); + resJson.duration.should.be.eql(milestoneTemplates[0].duration); + resJson.type.should.be.eql(milestoneTemplates[0].type); + resJson.order.should.be.eql(milestoneTemplates[0].order); + resJson.plannedText.should.be.eql(milestoneTemplates[0].plannedText); + resJson.blockedText.should.be.eql(milestoneTemplates[0].blockedText); + resJson.activeText.should.be.eql(milestoneTemplates[0].activeText); + resJson.completedText.should.be.eql(milestoneTemplates[0].completedText); + resJson.productTemplateId.should.be.eql(milestoneTemplates[0].productTemplateId); + + resJson.createdBy.should.be.eql(milestoneTemplates[0].createdBy); + should.exist(resJson.createdAt); + resJson.updatedBy.should.be.eql(milestoneTemplates[0].updatedBy); + should.exist(resJson.updatedAt); + should.not.exist(resJson.deletedBy); + should.not.exist(resJson.deletedAt); + + done(); + }); + }); + + it('should return 200 for connect admin', (done) => { + request(server) + .get('/v4/productTemplates/1/milestones/1') + .set({ + Authorization: `Bearer ${testUtil.jwts.connectAdmin}`, + }) + .expect(200) + .end(done); + }); + + it('should return 200 for connect manager', (done) => { + request(server) + .get('/v4/productTemplates/1/milestones/1') + .set({ + Authorization: `Bearer ${testUtil.jwts.manager}`, + }) + .expect(200) + .end(done); + }); + + it('should return 200 for member', (done) => { + request(server) + .get('/v4/productTemplates/1/milestones/1') + .set({ + Authorization: `Bearer ${testUtil.jwts.member}`, + }) + .expect(200, done); + }); + + it('should return 200 for copilot', (done) => { + request(server) + .get('/v4/productTemplates/1/milestones/1') + .set({ + Authorization: `Bearer ${testUtil.jwts.copilot}`, + }) + .expect(200, done); + }); + }); +}); diff --git a/src/routes/milestoneTemplates/list.js b/src/routes/milestoneTemplates/list.js new file mode 100644 index 00000000..40b6ae19 --- /dev/null +++ b/src/routes/milestoneTemplates/list.js @@ -0,0 +1,52 @@ +/** + * API to list all milestone templates + */ +import validate from 'express-validation'; +import Joi from 'joi'; +import _ from 'lodash'; +import { middleware as tcMiddleware } from 'tc-core-library-js'; +import util from '../../util'; +import models from '../../models'; + +const permissions = tcMiddleware.permissions; + +const schema = { + params: { + productTemplateId: Joi.number().integer().positive().required(), + }, +}; + +module.exports = [ + validate(schema), + permissions('milestoneTemplate.view'), + (req, res, next) => { + // Parse the sort query + let sort = req.query.sort ? decodeURIComponent(req.query.sort) : 'order'; + if (sort && sort.indexOf(' ') === -1) { + sort += ' asc'; + } + const sortableProps = [ + 'order asc', 'order desc', + ]; + if (sort && _.indexOf(sortableProps, sort) < 0) { + const apiErr = new Error('Invalid sort criteria'); + apiErr.status = 422; + return next(apiErr); + } + const sortColumnAndOrder = sort.split(' '); + + // Get all milestone templates + return models.ProductMilestoneTemplate.findAll({ + where: { + productTemplateId: req.params.productTemplateId, + }, + order: [sortColumnAndOrder], + attributes: { exclude: ['deletedAt', 'deletedBy'] }, + raw: true, + }) + .then((milestoneTemplates) => { + res.json(util.wrapResponse(req.id, milestoneTemplates)); + }) + .catch(next); + }, +]; diff --git a/src/routes/milestoneTemplates/list.spec.js b/src/routes/milestoneTemplates/list.spec.js new file mode 100644 index 00000000..086356c3 --- /dev/null +++ b/src/routes/milestoneTemplates/list.spec.js @@ -0,0 +1,235 @@ +/** + * Tests for list.js + */ +import chai from 'chai'; +import request from 'supertest'; + +import models from '../../models'; +import server from '../../app'; +import testUtil from '../../tests/util'; + +const should = chai.should(); + +const productTemplates = [ + { + name: 'name 1', + productKey: 'productKey 1', + category: 'category', + icon: 'http://example.com/icon1.ico', + brief: 'brief 1', + details: 'details 1', + aliases: { + alias1: { + subAlias1A: 1, + subAlias1B: 2, + }, + alias2: [1, 2, 3], + }, + template: { + template1: { + name: 'template 1', + details: { + anyDetails: 'any details 1', + }, + others: ['others 11', 'others 12'], + }, + template2: { + name: 'template 2', + details: { + anyDetails: 'any details 2', + }, + others: ['others 21', 'others 22'], + }, + }, + createdBy: 1, + updatedBy: 2, + }, + { + name: 'template 2', + productKey: 'productKey 2', + category: 'category', + icon: 'http://example.com/icon2.ico', + brief: 'brief 2', + details: 'details 2', + aliases: {}, + template: {}, + createdBy: 3, + updatedBy: 4, + deletedAt: new Date(), + }, +]; +const milestoneTemplates = [ + { + id: 1, + name: 'milestoneTemplate 1', + duration: 3, + type: 'type1', + order: 1, + plannedText: 'text to be shown in planned stage', + blockedText: 'text to be shown in blocked stage', + activeText: 'text to be shown in active stage', + completedText: 'text to be shown in completed stage', + productTemplateId: 1, + createdBy: 1, + updatedBy: 2, + }, + { + id: 2, + name: 'milestoneTemplate 2', + duration: 4, + type: 'type2', + order: 2, + plannedText: 'text to be shown in planned stage - 2', + blockedText: 'text to be shown in blocked stage - 2', + activeText: 'text to be shown in active stage - 2', + completedText: 'text to be shown in completed stage - 2', + productTemplateId: 1, + createdBy: 2, + updatedBy: 3, + }, + { + id: 3, + name: 'milestoneTemplate 3', + duration: 5, + type: 'type3', + order: 3, + plannedText: 'text to be shown in planned stage - 3', + blockedText: 'text to be shown in blocked stage - 3', + activeText: 'text to be shown in active stage - 3', + completedText: 'text to be shown in completed stage - 3', + productTemplateId: 1, + createdBy: 2, + updatedBy: 3, + deletedAt: new Date(), + }, +]; + +describe('LIST milestone template', () => { + beforeEach(() => testUtil.clearDb() + .then(() => models.ProductTemplate.bulkCreate(productTemplates)) + .then(() => models.ProductMilestoneTemplate.bulkCreate(milestoneTemplates)), + ); + after(testUtil.clearDb); + + describe('GET /productTemplates/{productTemplateId}/milestones', () => { + it('should return 403 if user is not authenticated', (done) => { + request(server) + .get('/v4/productTemplates/1/milestones') + .expect(403, done); + }); + + it('should return 422 for invalid productTemplateId param', (done) => { + request(server) + .get('/v4/productTemplates/0/milestones') + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .expect(422, done); + }); + + it('should return 422 for invalid sort column', (done) => { + request(server) + .get('/v4/productTemplates/1/milestones?sort=id') + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .expect(422, done); + }); + + it('should return 422 for invalid sort order', (done) => { + request(server) + .get('/v4/productTemplates/1/milestones?sort=order%20invalid') + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .expect(422, done); + }); + + it('should return 200 for admin', (done) => { + request(server) + .get('/v4/productTemplates/1/milestones') + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .expect(200) + .end((err, res) => { + const resJson = res.body.result.content; + resJson.should.have.length(2); + resJson[0].id.should.be.eql(milestoneTemplates[0].id); + resJson[0].name.should.be.eql(milestoneTemplates[0].name); + resJson[0].duration.should.be.eql(milestoneTemplates[0].duration); + resJson[0].type.should.be.eql(milestoneTemplates[0].type); + resJson[0].order.should.be.eql(milestoneTemplates[0].order); + resJson[0].plannedText.should.be.eql(milestoneTemplates[0].plannedText); + resJson[0].blockedText.should.be.eql(milestoneTemplates[0].blockedText); + resJson[0].activeText.should.be.eql(milestoneTemplates[0].activeText); + resJson[0].completedText.should.be.eql(milestoneTemplates[0].completedText); + resJson[0].productTemplateId.should.be.eql(milestoneTemplates[0].productTemplateId); + + resJson[0].createdBy.should.be.eql(milestoneTemplates[0].createdBy); + should.exist(resJson[0].createdAt); + resJson[0].updatedBy.should.be.eql(milestoneTemplates[0].updatedBy); + should.exist(resJson[0].updatedAt); + should.not.exist(resJson[0].deletedBy); + should.not.exist(resJson[0].deletedAt); + + done(); + }); + }); + + it('should return 200 for connect admin', (done) => { + request(server) + .get('/v4/productTemplates/1/milestones') + .set({ + Authorization: `Bearer ${testUtil.jwts.connectAdmin}`, + }) + .expect(200) + .end(done); + }); + + it('should return 200 for connect manager', (done) => { + request(server) + .get('/v4/productTemplates/1/milestones') + .set({ + Authorization: `Bearer ${testUtil.jwts.manager}`, + }) + .expect(200) + .end(done); + }); + + it('should return 200 for member', (done) => { + request(server) + .get('/v4/productTemplates/1/milestones') + .set({ + Authorization: `Bearer ${testUtil.jwts.member}`, + }) + .expect(200, done); + }); + + it('should return 200 for copilot', (done) => { + request(server) + .get('/v4/productTemplates/1/milestones') + .set({ + Authorization: `Bearer ${testUtil.jwts.copilot}`, + }) + .expect(200, done); + }); + + it('should return 200 with sort desc', (done) => { + request(server) + .get('/v4/productTemplates/1/milestones?sort=order%20desc') + .set({ + Authorization: `Bearer ${testUtil.jwts.copilot}`, + }) + .expect(200) + .end((err, res) => { + const resJson = res.body.result.content; + resJson.should.have.length(2); + resJson[0].id.should.be.eql(2); + resJson[1].id.should.be.eql(1); + + done(); + }); + }); + }); +}); diff --git a/src/routes/milestoneTemplates/update.js b/src/routes/milestoneTemplates/update.js new file mode 100644 index 00000000..8936b72c --- /dev/null +++ b/src/routes/milestoneTemplates/update.js @@ -0,0 +1,126 @@ +/** + * API to update a milestone template + */ +import validate from 'express-validation'; +import _ from 'lodash'; +import Joi from 'joi'; +import Sequelize from 'sequelize'; +import { middleware as tcMiddleware } from 'tc-core-library-js'; +import util from '../../util'; +import models from '../../models'; + +const permissions = tcMiddleware.permissions; + +const schema = { + params: { + productTemplateId: Joi.number().integer().positive().required(), + milestoneTemplateId: Joi.number().integer().positive().required(), + }, + body: { + param: Joi.object().keys({ + id: Joi.any().strip(), + name: Joi.string().max(255).optional(), + description: Joi.string().max(255), + duration: Joi.number().integer().optional(), + type: Joi.string().max(45).optional(), + order: Joi.number().integer().optional(), + plannedText: Joi.string().max(512).optional(), + activeText: Joi.string().max(512).optional(), + completedText: Joi.string().max(512).optional(), + blockedText: Joi.string().max(512).optional(), + productTemplateId: Joi.any().strip(), + hidden: Joi.boolean().optional(), + createdAt: Joi.any().strip(), + updatedAt: Joi.any().strip(), + deletedAt: Joi.any().strip(), + createdBy: Joi.any().strip(), + updatedBy: Joi.any().strip(), + deletedBy: Joi.any().strip(), + }).required(), + }, +}; + +module.exports = [ + validate(schema), + permissions('milestoneTemplate.edit'), + (req, res, next) => { + const entityToUpdate = _.assign(req.body.param, { + updatedBy: req.authUser.userId, + }); + + let original; + let updated; + + return models.sequelize.transaction(() => + // Get the milestone template + models.ProductMilestoneTemplate.findOne({ + where: { + id: req.params.milestoneTemplateId, + productTemplateId: req.params.productTemplateId, + }, + attributes: { exclude: ['deletedAt', 'deletedBy'] }, + }) + .then((milestoneTemplate) => { + // Not found + if (!milestoneTemplate) { + const apiErr = new Error(`Milestone template not found for template id ${req.params.milestoneTemplateId}`); + apiErr.status = 404; + return Promise.reject(apiErr); + } + + original = _.omit(milestoneTemplate.toJSON(), ['deletedAt', 'deletedBy']); + + // Update + return milestoneTemplate.update(entityToUpdate); + }) + .then((milestoneTemplate) => { + updated = _.omit(milestoneTemplate.toJSON(), ['deletedAt', 'deletedBy']); + + // Update order of the other milestones only if the order was changed + if (original.order === updated.order) { + return Promise.resolve(); + } + + return models.ProductMilestoneTemplate.count({ + where: { + productTemplateId: updated.productTemplateId, + id: { $ne: updated.id }, + order: updated.order, + }, + }) + .then((count) => { + if (count === 0) { + return Promise.resolve(); + } + + // Increase the order from M to K: if there is an item with order K, + // orders from M+1 to K should be made M to K-1 + if (original.order < updated.order) { + return models.ProductMilestoneTemplate.update({ order: Sequelize.literal('"order" - 1') }, { + where: { + productTemplateId: updated.productTemplateId, + id: { $ne: updated.id }, + order: { $between: [original.order + 1, updated.order] }, + }, + }); + } + + // Decrease the order from M to K: if there is an item with order K, + // orders from K to M-1 should be made K+1 to M + return models.ProductMilestoneTemplate.update({ order: Sequelize.literal('"order" + 1') }, { + where: { + productTemplateId: updated.productTemplateId, + id: { $ne: updated.id }, + order: { $between: [updated.order, original.order - 1] }, + }, + }); + }); + }), + ) + .then(() => { + res.json(util.wrapResponse(req.id, updated)); + return Promise.resolve(); + }) + .catch(next); + }, +]; diff --git a/src/routes/milestoneTemplates/update.spec.js b/src/routes/milestoneTemplates/update.spec.js new file mode 100644 index 00000000..59bbcb00 --- /dev/null +++ b/src/routes/milestoneTemplates/update.spec.js @@ -0,0 +1,490 @@ +/** + * Tests for get.js + */ +import chai from 'chai'; +import request from 'supertest'; +import _ from 'lodash'; +import models from '../../models'; +import server from '../../app'; +import testUtil from '../../tests/util'; + +const should = chai.should(); + +const productTemplates = [ + { + name: 'name 1', + productKey: 'productKey 1', + category: 'category', + icon: 'http://example.com/icon1.ico', + brief: 'brief 1', + details: 'details 1', + aliases: { + alias1: { + subAlias1A: 1, + subAlias1B: 2, + }, + alias2: [1, 2, 3], + }, + template: { + template1: { + name: 'template 1', + details: { + anyDetails: 'any details 1', + }, + others: ['others 11', 'others 12'], + }, + template2: { + name: 'template 2', + details: { + anyDetails: 'any details 2', + }, + others: ['others 21', 'others 22'], + }, + }, + createdBy: 1, + updatedBy: 2, + }, + { + name: 'template 2', + productKey: 'productKey 2', + category: 'category', + icon: 'http://example.com/icon2.ico', + brief: 'brief 2', + details: 'details 2', + aliases: {}, + template: {}, + createdBy: 3, + updatedBy: 4, + deletedAt: new Date(), + }, +]; +const milestoneTemplates = [ + { + id: 1, + name: 'milestoneTemplate 1', + duration: 3, + type: 'type1', + order: 1, + plannedText: 'text to be shown in planned stage', + blockedText: 'text to be shown in blocked stage', + activeText: 'text to be shown in active stage', + completedText: 'text to be shown in completed stage', + productTemplateId: 1, + createdBy: 1, + updatedBy: 2, + }, + { + id: 2, + name: 'milestoneTemplate 2', + duration: 4, + type: 'type2', + order: 2, + plannedText: 'text to be shown in planned stage - 2', + blockedText: 'text to be shown in blocked stage - 2', + activeText: 'text to be shown in active stage - 2', + completedText: 'text to be shown in completed stage - 2', + productTemplateId: 1, + createdBy: 2, + updatedBy: 3, + }, + { + id: 3, + name: 'milestoneTemplate 3', + duration: 5, + type: 'type3', + order: 3, + plannedText: 'text to be shown in planned stage - 3', + blockedText: 'text to be shown in blocked stage - 3', + activeText: 'text to be shown in active stage - 3', + completedText: 'text to be shown in completed stage - 3', + productTemplateId: 1, + createdBy: 2, + updatedBy: 3, + }, + { + id: 4, + name: 'milestoneTemplate 4', + duration: 5, + type: 'type4', + order: 4, + plannedText: 'text to be shown in planned stage - 4', + blockedText: 'text to be shown in blocked stage - 4', + activeText: 'text to be shown in active stage - 4', + completedText: 'text to be shown in completed stage - 4', + productTemplateId: 1, + createdBy: 2, + updatedBy: 3, + deletedAt: new Date(), + }, +]; + +describe('UPDATE milestone template', () => { + beforeEach(() => testUtil.clearDb() + .then(() => models.ProductTemplate.bulkCreate(productTemplates)) + .then(() => models.ProductMilestoneTemplate.bulkCreate(milestoneTemplates)), + ); + after(testUtil.clearDb); + + describe('PATCH /productTemplates/{productTemplateId}/milestones/{milestoneTemplateId}', () => { + const body = { + param: { + name: 'milestoneTemplate 1-updated', + description: 'description-updated', + duration: 6, + type: 'type1-updated', + order: 5, + plannedText: 'text to be shown in planned stage', + blockedText: 'text to be shown in blocked stage', + activeText: 'text to be shown in active stage', + completedText: 'text to be shown in completed stage', + hidden: true, + }, + }; + + it('should return 403 if user is not authenticated', (done) => { + request(server) + .patch('/v4/productTemplates/1/milestones/1') + .send(body) + .expect(403, done); + }); + + it('should return 403 for member', (done) => { + request(server) + .patch('/v4/productTemplates/1/milestones/1') + .set({ + Authorization: `Bearer ${testUtil.jwts.member}`, + }) + .send(body) + .expect(403, done); + }); + + it('should return 403 for copilot', (done) => { + request(server) + .patch('/v4/productTemplates/1/milestones/1') + .send(body) + .set({ + Authorization: `Bearer ${testUtil.jwts.copilot}`, + }) + .expect(403, done); + }); + + it('should return 403 for manager', (done) => { + request(server) + .patch('/v4/productTemplates/1/milestones/1') + .send(body) + .set({ + Authorization: `Bearer ${testUtil.jwts.manager}`, + }) + .expect(403, done); + }); + + it('should return 404 for non-existed product template', (done) => { + request(server) + .patch('/v4/productTemplates/122/milestones/1') + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .send(body) + .expect(404, done); + }); + + it('should return 404 for non-existed milestone template', (done) => { + request(server) + .patch('/v4/productTemplates/1/milestones/111') + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .send(body) + .expect(404, done); + }); + + it('should return 404 for deleted milestone template', (done) => { + request(server) + .patch('/v4/productTemplates/1/milestones/4') + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .send(body) + .expect(404, done); + }); + + it('should return 200 for admin', (done) => { + request(server) + .patch('/v4/productTemplates/1/milestones/1') + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .send(body) + .expect(200) + .end((err, res) => { + const resJson = res.body.result.content; + resJson.id.should.be.eql(1); + resJson.name.should.be.eql(body.param.name); + resJson.description.should.be.eql(body.param.description); + resJson.duration.should.be.eql(body.param.duration); + resJson.type.should.be.eql(body.param.type); + resJson.order.should.be.eql(body.param.order); + resJson.plannedText.should.be.eql(body.param.plannedText); + resJson.blockedText.should.be.eql(body.param.blockedText); + resJson.activeText.should.be.eql(body.param.activeText); + resJson.completedText.should.be.eql(body.param.completedText); + + should.exist(resJson.createdBy); + should.exist(resJson.createdAt); + resJson.updatedBy.should.be.eql(40051333); // admin + should.exist(resJson.updatedAt); + should.not.exist(resJson.deletedBy); + should.not.exist(resJson.deletedAt); + + done(); + }); + }); + + // eslint-disable-next-line func-names + it('should return 200 for admin - order increases and replaces another milestone\'s order', function (done) { + this.timeout(10000); + + request(server) + .patch('/v4/productTemplates/1/milestones/1') + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .send({ param: _.assign({}, body.param, { order: 3 }) }) // 1 to 3 + .expect(200) + .end(() => { + // Milestone 1: order 3 + // Milestone 2: order 2 - 1 = 1 + // Milestone 3: order 3 - 1 = 2 + models.ProductMilestoneTemplate.findById(1) + .then((milestone) => { + milestone.order.should.be.eql(3); + }) + .then(() => models.ProductMilestoneTemplate.findById(2)) + .then((milestone) => { + milestone.order.should.be.eql(1); + }) + .then(() => models.ProductMilestoneTemplate.findById(3)) + .then((milestone) => { + milestone.order.should.be.eql(2); + + done(); + }); + }); + }); + + // eslint-disable-next-line func-names + it('should return 200 for admin - order increases and doesnot replace another milestone\'s order', function (done) { + this.timeout(10000); + + request(server) + .patch('/v4/productTemplates/1/milestones/1') + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .send({ param: _.assign({}, body.param, { order: 4 }) }) // 1 to 4 + .expect(200) + .end(() => { + // Milestone 1: order 4 + // Milestone 2: order 2 + // Milestone 3: order 3 + models.ProductMilestoneTemplate.findById(1) + .then((milestone) => { + milestone.order.should.be.eql(4); + }) + .then(() => models.ProductMilestoneTemplate.findById(2)) + .then((milestone) => { + milestone.order.should.be.eql(2); + }) + .then(() => models.ProductMilestoneTemplate.findById(3)) + .then((milestone) => { + milestone.order.should.be.eql(3); + + done(); + }); + }); + }); + + // eslint-disable-next-line func-names + it('should return 200 for admin - order decreases and replaces another milestone\'s order', function (done) { + this.timeout(10000); + + request(server) + .patch('/v4/productTemplates/1/milestones/3') + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .send({ param: _.assign({}, body.param, { order: 1 }) }) // 3 to 1 + .expect(200) + .end(() => { + // Milestone 1: order 2 + // Milestone 2: order 3 + // Milestone 3: order 1 + models.ProductMilestoneTemplate.findById(1) + .then((milestone) => { + milestone.order.should.be.eql(2); + }) + .then(() => models.ProductMilestoneTemplate.findById(2)) + .then((milestone) => { + milestone.order.should.be.eql(3); + }) + .then(() => models.ProductMilestoneTemplate.findById(3)) + .then((milestone) => { + milestone.order.should.be.eql(1); + + done(); + }); + }); + }); + + // eslint-disable-next-line func-names + it('should return 200 for admin - order decreases and doesnot replace another milestone\'s order', function (done) { + this.timeout(10000); + + request(server) + .patch('/v4/productTemplates/1/milestones/3') + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .send({ param: _.assign({}, body.param, { order: 0 }) }) // 3 to 0 + .expect(200) + .end(() => { + // Milestone 1: order 1 + // Milestone 2: order 2 + // Milestone 3: order 0 + models.ProductMilestoneTemplate.findById(1) + .then((milestone) => { + milestone.order.should.be.eql(1); + }) + .then(() => models.ProductMilestoneTemplate.findById(2)) + .then((milestone) => { + milestone.order.should.be.eql(2); + }) + .then(() => models.ProductMilestoneTemplate.findById(3)) + .then((milestone) => { + milestone.order.should.be.eql(0); + + done(); + }); + }); + }); + + it('should return 200 for missing name', (done) => { + const partialBody = _.cloneDeep(body); + delete partialBody.param.name; + request(server) + .patch('/v4/productTemplates/1/milestones/1') + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .send(partialBody) + .expect(200, done); + }); + + it('should return 200 for missing type', (done) => { + const partialBody = _.cloneDeep(body); + delete partialBody.param.type; + request(server) + .patch('/v4/productTemplates/1/milestones/1') + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .send(partialBody) + .expect(200, done); + }); + + it('should return 200 for missing duration', (done) => { + const partialBody = _.cloneDeep(body); + delete partialBody.param.duration; + request(server) + .patch('/v4/productTemplates/1/milestones/1') + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .send(partialBody) + .expect(200, done); + }); + + it('should return 200 for missing order', (done) => { + const partialBody = _.cloneDeep(body); + delete partialBody.param.order; + request(server) + .patch('/v4/productTemplates/1/milestones/1') + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .send(partialBody) + .expect(200, done); + }); + + it('should return 200 for missing plannedText', (done) => { + const partialBody = _.cloneDeep(body); + delete partialBody.param.plannedText; + request(server) + .patch('/v4/productTemplates/1/milestones/1') + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .send(partialBody) + .expect(200, done); + }); + + it('should return 200 for missing blockedText', (done) => { + const partialBody = _.cloneDeep(body); + delete partialBody.param.blockedText; + request(server) + .patch('/v4/productTemplates/1/milestones/1') + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .send(partialBody) + .expect(200, done); + }); + + it('should return 200 for missing activeText', (done) => { + const partialBody = _.cloneDeep(body); + delete partialBody.param.activeText; + request(server) + .patch('/v4/productTemplates/1/milestones/1') + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .send(partialBody) + .expect(200, done); + }); + + it('should return 200 for missing completedText', (done) => { + const partialBody = _.cloneDeep(body); + delete partialBody.param.completedText; + request(server) + .patch('/v4/productTemplates/1/milestones/1') + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .send(partialBody) + .expect(200, done); + }); + + it('should return 200 for missing hidden field', (done) => { + const partialBody = _.cloneDeep(body); + delete partialBody.param.hidden; + request(server) + .patch('/v4/productTemplates/1/milestones/1') + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .send(partialBody) + .expect(200, done); + }); + + it('should return 200 for connect admin', (done) => { + request(server) + .patch('/v4/productTemplates/1/milestones/1') + .set({ + Authorization: `Bearer ${testUtil.jwts.connectAdmin}`, + }) + .send(body) + .expect(200) + .end(done); + }); + }); +}); diff --git a/src/routes/milestones/create.js b/src/routes/milestones/create.js new file mode 100644 index 00000000..e0643d38 --- /dev/null +++ b/src/routes/milestones/create.js @@ -0,0 +1,113 @@ +/** + * API to add a milestone + */ +import validate from 'express-validation'; +import _ from 'lodash'; +import Joi from 'joi'; +import { middleware as tcMiddleware } from 'tc-core-library-js'; +import Sequelize from 'sequelize'; +import util from '../../util'; +import validateTimeline from '../../middlewares/validateTimeline'; +import models from '../../models'; +import { EVENT } from '../../constants'; + +const permissions = tcMiddleware.permissions; + +const schema = { + params: { + timelineId: Joi.number().integer().positive().required(), + }, + body: { + param: Joi.object().keys({ + id: Joi.any().strip(), + name: Joi.string().max(255).required(), + description: Joi.string().max(255), + duration: Joi.number().integer().required(), + startDate: Joi.date().required(), + actualStartDate: Joi.date().allow(null), + endDate: Joi.date().min(Joi.ref('startDate')).allow(null), + completionDate: Joi.date().min(Joi.ref('startDate')).allow(null), + status: Joi.string().max(45).required(), + type: Joi.string().max(45).required(), + details: Joi.object(), + order: Joi.number().integer().required(), + plannedText: Joi.string().max(512).required(), + activeText: Joi.string().max(512).required(), + completedText: Joi.string().max(512).required(), + blockedText: Joi.string().max(512).required(), + hidden: Joi.boolean().optional(), + createdAt: Joi.any().strip(), + updatedAt: Joi.any().strip(), + deletedAt: Joi.any().strip(), + createdBy: Joi.any().strip(), + updatedBy: Joi.any().strip(), + deletedBy: Joi.any().strip(), + }).required(), + }, +}; + +module.exports = [ + validate(schema), + // Validate and get projectId from the timelineId param, and set to request params + // for checking by the permissions middleware + validateTimeline.validateTimelineIdParam, + permissions('milestone.create'), + (req, res, next) => { + const entity = _.assign(req.body.param, { + createdBy: req.authUser.userId, + updatedBy: req.authUser.userId, + timelineId: req.params.timelineId, + }); + let result; + + // Validate startDate and endDate to be within the timeline startDate and endDate + let error; + if (req.body.param.startDate < req.timeline.startDate) { + error = 'Milestone startDate must not be before the timeline startDate'; + } else if (req.body.param.endDate && req.timeline.endDate && req.body.param.endDate > req.timeline.endDate) { + error = 'Milestone endDate must not be after the timeline endDate'; + } + if (error) { + const apiErr = new Error(error); + apiErr.status = 422; + return next(apiErr); + } + + return models.sequelize.transaction(tx => + // Save to DB + models.Milestone.create(entity, { transaction: tx }) + .then((createdEntity) => { + // Omit deletedAt, deletedBy + result = _.omit(createdEntity.toJSON(), 'deletedAt', 'deletedBy'); + + // Send event to bus + req.log.debug('Sending event to RabbitMQ bus for milestone %d', result.id); + req.app.services.pubsub.publish(EVENT.ROUTING_KEY.MILESTONE_ADDED, + result, + { correlationId: req.id }, + ); + + // Increase the order of the other milestones in the same timeline, + // which have `order` >= this milestone order + return models.Milestone.update({ order: Sequelize.literal('"order" + 1') }, { + where: { + timelineId: result.timelineId, + id: { $ne: result.id }, + order: { $gte: result.order }, + }, + transaction: tx, + }); + }), + ) + .then(() => { + // Do not send events for the updated milestones here, + // because it will make 'version conflict' error in ES. + // The order of the other milestones need to be updated in the MILESTONE_ADDED event handler + + // Write to the response + res.status(201).json(util.wrapResponse(req.id, result, 1, 201)); + return Promise.resolve(); + }) + .catch(next); + }, +]; diff --git a/src/routes/milestones/create.spec.js b/src/routes/milestones/create.spec.js new file mode 100644 index 00000000..250e8175 --- /dev/null +++ b/src/routes/milestones/create.spec.js @@ -0,0 +1,608 @@ +/** + * Tests for create.js + */ +import chai from 'chai'; +import request from 'supertest'; +import _ from 'lodash'; +import server from '../../app'; +import testUtil from '../../tests/util'; +import models from '../../models'; +import { EVENT } from '../../constants'; + +const should = chai.should(); + +describe('CREATE milestone', () => { + let projectId1; + let projectId2; + + beforeEach((done) => { + testUtil.clearDb() + .then(() => { + models.Project.bulkCreate([ + { + type: 'generic', + billingAccountId: 1, + name: 'test1', + description: 'test project1', + status: 'draft', + details: {}, + createdBy: 1, + updatedBy: 1, + }, + { + type: 'generic', + billingAccountId: 2, + name: 'test2', + description: 'test project2', + status: 'draft', + details: {}, + createdBy: 2, + updatedBy: 2, + deletedAt: '2018-05-15T00:00:00Z', + }, + ], { returning: true }) + .then((projects) => { + projectId1 = projects[0].id; + projectId2 = projects[1].id; + + // Create member + models.ProjectMember.bulkCreate([ + { + userId: 40051332, + projectId: projectId1, + role: 'copilot', + isPrimary: true, + createdBy: 1, + updatedBy: 1, + }, + { + userId: 40051331, + projectId: projectId1, + role: 'customer', + isPrimary: true, + createdBy: 1, + updatedBy: 1, + }, + ]).then(() => + // Create phase + models.ProjectPhase.bulkCreate([ + { + projectId: projectId1, + name: 'test project phase 1', + status: 'active', + startDate: '2018-05-15T00:00:00Z', + endDate: '2018-05-15T12:00:00Z', + budget: 20.0, + progress: 1.23456, + details: { + message: 'This can be any json 2', + }, + createdBy: 1, + updatedBy: 1, + }, + { + projectId: projectId2, + name: 'test project phase 2', + status: 'active', + startDate: '2018-05-16T00:00:00Z', + endDate: '2018-05-16T12:00:00Z', + budget: 21.0, + progress: 1.234567, + details: { + message: 'This can be any json 2', + }, + createdBy: 2, + updatedBy: 2, + deletedAt: '2018-05-15T00:00:00Z', + }, + ])) + .then(() => + // Create timelines + models.Timeline.bulkCreate([ + { + name: 'name 1', + description: 'description 1', + startDate: '2018-05-02T00:00:00.000Z', + endDate: '2018-06-12T00:00:00.000Z', + reference: 'project', + referenceId: 1, + createdBy: 1, + updatedBy: 1, + }, + { + name: 'name 2', + description: 'description 2', + startDate: '2018-05-12T00:00:00.000Z', + endDate: '2018-06-13T00:00:00.000Z', + reference: 'phase', + referenceId: 1, + createdBy: 1, + updatedBy: 1, + }, + { + name: 'name 3', + description: 'description 3', + startDate: '2018-05-13T00:00:00.000Z', + endDate: '2018-06-14T00:00:00.000Z', + reference: 'phase', + referenceId: 1, + createdBy: 1, + updatedBy: 1, + deletedAt: '2018-05-14T00:00:00.000Z', + }, + ])) + .then(() => { + // Create milestones + models.Milestone.bulkCreate([ + { + timelineId: 1, + name: 'milestone 1', + duration: 2, + startDate: '2018-05-03T00:00:00.000Z', + status: 'open', + type: 'type1', + details: { + detail1: { + subDetail1A: 1, + subDetail1B: 2, + }, + detail2: [1, 2, 3], + }, + order: 1, + plannedText: 'plannedText 1', + activeText: 'activeText 1', + completedText: 'completedText 1', + blockedText: 'blockedText 1', + createdBy: 1, + updatedBy: 2, + }, + { + timelineId: 1, + name: 'milestone 2', + duration: 3, + startDate: '2018-05-04T00:00:00.000Z', + status: 'open', + type: 'type2', + order: 2, + plannedText: 'plannedText 2', + activeText: 'activeText 2', + completedText: 'completedText 2', + blockedText: 'blockedText 2', + createdBy: 2, + updatedBy: 3, + }, + { + timelineId: 1, + name: 'milestone 3', + duration: 4, + startDate: '2018-05-04T00:00:00.000Z', + status: 'open', + type: 'type3', + order: 3, + plannedText: 'plannedText 3', + activeText: 'activeText 3', + completedText: 'completedText 3', + blockedText: 'blockedText 3', + createdBy: 3, + updatedBy: 4, + }, + ]) + .then(() => done()); + }); + }); + }); + }); + + after(testUtil.clearDb); + + describe('POST /timelines/{timelineId}/milestones', () => { + const body = { + param: { + name: 'milestone 4', + description: 'description 4', + duration: 4, + startDate: '2018-05-05T00:00:00.000Z', + endDate: '2018-05-07T00:00:00.000Z', + completionDate: '2018-05-08T00:00:00.000Z', + status: 'open', + type: 'type4', + details: { + detail1: { + subDetail1C: 4, + }, + detail2: [ + 3, + 4, + 5, + ], + }, + order: 2, + plannedText: 'plannedText 4', + activeText: 'activeText 4', + completedText: 'completedText 4', + blockedText: 'blockedText 4', + hidden: true, + }, + }; + + it('should return 403 if user is not authenticated', (done) => { + request(server) + .post('/v4/timelines/1/milestones') + .send(body) + .expect(403, done); + }); + + it('should return 403 for member who is not in the project', (done) => { + request(server) + .post('/v4/timelines/1/milestones') + .set({ + Authorization: `Bearer ${testUtil.jwts.member2}`, + }) + .send(body) + .expect(403, done); + }); + + it('should return 422 if missing name', (done) => { + const invalidBody = { + param: _.assign({}, body.param, { + name: undefined, + }), + }; + + request(server) + .post('/v4/timelines/1/milestones') + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .send(invalidBody) + .expect('Content-Type', /json/) + .expect(422, done); + }); + + it('should return 422 if missing duration', (done) => { + const invalidBody = { + param: _.assign({}, body.param, { + duration: undefined, + }), + }; + + request(server) + .post('/v4/timelines/1/milestones') + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .send(invalidBody) + .expect('Content-Type', /json/) + .expect(422, done); + }); + + it('should return 422 if missing type', (done) => { + const invalidBody = { + param: _.assign({}, body.param, { + type: undefined, + }), + }; + + request(server) + .post('/v4/timelines/1/milestones') + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .send(invalidBody) + .expect('Content-Type', /json/) + .expect(422, done); + }); + + it('should return 422 if missing order', (done) => { + const invalidBody = { + param: _.assign({}, body.param, { + order: undefined, + }), + }; + + request(server) + .post('/v4/timelines/1/milestones') + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .send(invalidBody) + .expect('Content-Type', /json/) + .expect(422, done); + }); + + it('should return 422 if missing plannedText', (done) => { + const invalidBody = { + param: _.assign({}, body.param, { + plannedText: undefined, + }), + }; + + request(server) + .post('/v4/timelines/1/milestones') + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .send(invalidBody) + .expect('Content-Type', /json/) + .expect(422, done); + }); + + it('should return 422 if missing activeText', (done) => { + const invalidBody = { + param: _.assign({}, body.param, { + activeText: undefined, + }), + }; + + request(server) + .post('/v4/timelines/1/milestones') + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .send(invalidBody) + .expect('Content-Type', /json/) + .expect(422, done); + }); + + it('should return 422 if missing completedText', (done) => { + const invalidBody = { + param: _.assign({}, body.param, { + completedText: undefined, + }), + }; + + request(server) + .post('/v4/timelines/1/milestones') + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .send(invalidBody) + .expect('Content-Type', /json/) + .expect(422, done); + }); + + it('should return 422 if missing blockedText', (done) => { + const invalidBody = { + param: _.assign({}, body.param, { + blockedText: undefined, + }), + }; + + request(server) + .post('/v4/timelines/1/milestones') + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .send(invalidBody) + .expect('Content-Type', /json/) + .expect(422, done); + }); + + it('should return 422 if startDate is after endDate', (done) => { + const invalidBody = { + param: _.assign({}, body.param, { + startDate: '2018-05-29T00:00:00.000Z', + endDate: '2018-05-28T00:00:00.000Z', + }), + }; + + request(server) + .post('/v4/timelines/1/milestones') + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .send(invalidBody) + .expect('Content-Type', /json/) + .expect(422, done); + }); + + it('should return 422 if startDate is after completionDate', (done) => { + const invalidBody = { + param: _.assign({}, body.param, { + startDate: '2018-05-29T00:00:00.000Z', + completionDate: '2018-05-28T00:00:00.000Z', + }), + }; + + request(server) + .post('/v4/timelines/1/milestones') + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .send(invalidBody) + .expect('Content-Type', /json/) + .expect(422, done); + }); + + it('should return 422 if startDate is before the timeline startDate', (done) => { + const invalidBody = { + param: _.assign({}, body.param, { + startDate: '2018-05-01T00:00:00.000Z', + }), + }; + + request(server) + .post('/v4/timelines/1/milestones') + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .send(invalidBody) + .expect('Content-Type', /json/) + .expect(422, done); + }); + + it('should return 422 if endDate is after the timeline endDate', (done) => { + const invalidBody = { + param: _.assign({}, body.param, { + endDate: '2018-06-13T00:00:00.000Z', + }), + }; + + request(server) + .post('/v4/timelines/1/milestones') + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .send(invalidBody) + .expect('Content-Type', /json/) + .expect(422, done); + }); + + it('should return 422 if invalid timelineId param', (done) => { + request(server) + .post('/v4/timelines/0/milestones') + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .send(body) + .expect('Content-Type', /json/) + .expect(422, done); + }); + + it('should return 404 if timeline does not exist', (done) => { + request(server) + .post('/v4/timelines/1000/milestones') + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .send(body) + .expect('Content-Type', /json/) + .expect(404, done); + }); + + it('should return 404 if timeline was deleted', (done) => { + request(server) + .post('/v4/timelines/3/milestones') + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .send(body) + .expect('Content-Type', /json/) + .expect(404, done); + }); + + it('should return 201 for admin', (done) => { + request(server) + .post('/v4/timelines/1/milestones') + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .send(body) + .expect('Content-Type', /json/) + .expect(201) + .end((err, res) => { + const resJson = res.body.result.content; + should.exist(resJson.id); + resJson.name.should.be.eql(body.param.name); + resJson.description.should.be.eql(body.param.description); + resJson.duration.should.be.eql(body.param.duration); + resJson.startDate.should.be.eql(body.param.startDate); + resJson.endDate.should.be.eql(body.param.endDate); + resJson.completionDate.should.be.eql(body.param.completionDate); + resJson.status.should.be.eql(body.param.status); + resJson.type.should.be.eql(body.param.type); + resJson.details.should.be.eql(body.param.details); + resJson.order.should.be.eql(body.param.order); + resJson.plannedText.should.be.eql(body.param.plannedText); + resJson.activeText.should.be.eql(body.param.activeText); + resJson.completedText.should.be.eql(body.param.completedText); + resJson.blockedText.should.be.eql(body.param.blockedText); + resJson.hidden.should.be.eql(body.param.hidden); + + resJson.createdBy.should.be.eql(40051333); // admin + should.exist(resJson.createdAt); + resJson.updatedBy.should.be.eql(40051333); // admin + should.exist(resJson.updatedAt); + should.not.exist(resJson.deletedBy); + should.not.exist(resJson.deletedAt); + + // eslint-disable-next-line no-unused-expressions + server.services.pubsub.publish.calledWith(EVENT.ROUTING_KEY.MILESTONE_ADDED).should.be.true; + + // Verify 'order' of the other milestones + models.Milestone.findAll({ where: { timelineId: 1 } }) + .then((milestones) => { + _.each(milestones, (milestone) => { + if (milestone.id === 1) { + milestone.order.should.be.eql(1); + } else if (milestone.id === 2) { + milestone.order.should.be.eql(2 + 1); + } else if (milestone.id === 3) { + milestone.order.should.be.eql(3 + 1); + } + }); + + done(); + }); + }); + }); + + it('should return 201 for connect manager', (done) => { + request(server) + .post('/v4/timelines/1/milestones') + .set({ + Authorization: `Bearer ${testUtil.jwts.manager}`, + }) + .send(body) + .expect('Content-Type', /json/) + .expect(201) + .end((err, res) => { + const resJson = res.body.result.content; + resJson.createdBy.should.be.eql(40051334); // manager + resJson.updatedBy.should.be.eql(40051334); // manager + done(); + }); + }); + + it('should return 201 for connect admin', (done) => { + request(server) + .post('/v4/timelines/1/milestones') + .set({ + Authorization: `Bearer ${testUtil.jwts.connectAdmin}`, + }) + .send(body) + .expect('Content-Type', /json/) + .expect(201) + .end((err, res) => { + const resJson = res.body.result.content; + resJson.createdBy.should.be.eql(40051336); // connect admin + resJson.updatedBy.should.be.eql(40051336); // connect admin + done(); + }); + }); + + it('should return 201 for copilot', (done) => { + request(server) + .post('/v4/timelines/1/milestones') + .set({ + Authorization: `Bearer ${testUtil.jwts.copilot}`, + }) + .send(body) + .expect('Content-Type', /json/) + .expect(201) + .end((err, res) => { + const resJson = res.body.result.content; + resJson.createdBy.should.be.eql(40051332); // copilot + resJson.updatedBy.should.be.eql(40051332); // copilot + done(); + }); + }); + + it('should return 201 for member', (done) => { + request(server) + .post('/v4/timelines/1/milestones') + .set({ + Authorization: `Bearer ${testUtil.jwts.member}`, + }) + .send(body) + .expect('Content-Type', /json/) + .expect(201) + .end((err, res) => { + const resJson = res.body.result.content; + resJson.createdBy.should.be.eql(40051331); // member + resJson.updatedBy.should.be.eql(40051331); // member + done(); + }); + }); + }); +}); diff --git a/src/routes/milestones/delete.js b/src/routes/milestones/delete.js new file mode 100644 index 00000000..50377c90 --- /dev/null +++ b/src/routes/milestones/delete.js @@ -0,0 +1,65 @@ +/** + * API to delete a timeline + */ +import validate from 'express-validation'; +import Joi from 'joi'; +import { middleware as tcMiddleware } from 'tc-core-library-js'; +import models from '../../models'; +import { EVENT } from '../../constants'; +import validateTimeline from '../../middlewares/validateTimeline'; + +const permissions = tcMiddleware.permissions; + +const schema = { + params: { + timelineId: Joi.number().integer().positive().required(), + milestoneId: Joi.number().integer().positive().required(), + }, +}; + +module.exports = [ + validate(schema), + // Validate and get projectId from the timelineId param, and set to request params for + // checking by the permissions middleware + validateTimeline.validateTimelineIdParam, + permissions('milestone.delete'), + (req, res, next) => { + const where = { + timelineId: req.params.timelineId, + id: req.params.milestoneId, + }; + + return models.sequelize.transaction(tx => + // Find the milestone + models.Milestone.findOne({ + where, + transaction: tx, + }) + .then((milestone) => { + // Not found + if (!milestone) { + const apiErr = new Error(`Milestone not found for milestone id ${req.params.milestoneId}`); + apiErr.status = 404; + return Promise.reject(apiErr); + } + + // Update the deletedBy, and soft delete + return milestone.update({ deletedBy: req.authUser.userId }, { transaction: tx }) + .then(() => milestone.destroy({ transaction: tx })); + }), + ) + .then((deleted) => { + // Send event to bus + req.log.debug('Sending event to RabbitMQ bus for milestone %d', deleted.id); + req.app.services.pubsub.publish(EVENT.ROUTING_KEY.MILESTONE_REMOVED, + deleted, + { correlationId: req.id }, + ); + + // Write to response + res.status(204).end(); + return Promise.resolve(); + }) + .catch(next); + }, +]; diff --git a/src/routes/milestones/delete.spec.js b/src/routes/milestones/delete.spec.js new file mode 100644 index 00000000..a82294e9 --- /dev/null +++ b/src/routes/milestones/delete.spec.js @@ -0,0 +1,352 @@ +/** + * Tests for delete.js + */ +import request from 'supertest'; +import chai from 'chai'; + +import models from '../../models'; +import server from '../../app'; +import testUtil from '../../tests/util'; +import { EVENT } from '../../constants'; + + +const expectAfterDelete = (timelineId, id, err, next) => { + if (err) throw err; + models.Milestone.findOne({ + where: { + timelineId, + id, + }, + paranoid: false, + }) + .then((res) => { + if (!res) { + throw new Error('Should found the entity'); + } else { + chai.assert.isNotNull(res.deletedAt); + chai.assert.isNotNull(res.deletedBy); + + request(server) + .get(`/v4/timelines/${timelineId}/milestones/${id}`) + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .expect(404, next); + } + }); +}; + +describe('DELETE milestone', () => { + beforeEach((done) => { + testUtil.clearDb() + .then(() => { + models.Project.bulkCreate([ + { + type: 'generic', + billingAccountId: 1, + name: 'test1', + description: 'test project1', + status: 'draft', + details: {}, + createdBy: 1, + updatedBy: 1, + }, + { + type: 'generic', + billingAccountId: 2, + name: 'test2', + description: 'test project2', + status: 'draft', + details: {}, + createdBy: 2, + updatedBy: 2, + deletedAt: '2018-05-15T00:00:00Z', + }, + ]) + .then(() => { + // Create member + models.ProjectMember.bulkCreate([ + { + userId: 40051332, + projectId: 1, + role: 'copilot', + isPrimary: true, + createdBy: 1, + updatedBy: 1, + }, + { + userId: 40051331, + projectId: 1, + role: 'customer', + isPrimary: true, + createdBy: 1, + updatedBy: 1, + }, + ]).then(() => + // Create phase + models.ProjectPhase.bulkCreate([ + { + projectId: 1, + name: 'test project phase 1', + status: 'active', + startDate: '2018-05-15T00:00:00Z', + endDate: '2018-05-15T12:00:00Z', + budget: 20.0, + progress: 1.23456, + details: { + message: 'This can be any json 2', + }, + createdBy: 1, + updatedBy: 1, + }, + { + projectId: 2, + name: 'test project phase 2', + status: 'active', + startDate: '2018-05-16T00:00:00Z', + endDate: '2018-05-16T12:00:00Z', + budget: 21.0, + progress: 1.234567, + details: { + message: 'This can be any json 2', + }, + createdBy: 2, + updatedBy: 2, + deletedAt: '2018-05-15T00:00:00Z', + }, + ])) + .then(() => + // Create timelines + models.Timeline.bulkCreate([ + { + name: 'name 1', + description: 'description 1', + startDate: '2018-05-11T00:00:00.000Z', + endDate: '2018-05-12T00:00:00.000Z', + reference: 'project', + referenceId: 1, + createdBy: 1, + updatedBy: 1, + }, + { + name: 'name 2', + description: 'description 2', + startDate: '2018-05-12T00:00:00.000Z', + endDate: '2018-05-13T00:00:00.000Z', + reference: 'phase', + referenceId: 1, + createdBy: 1, + updatedBy: 1, + }, + { + name: 'name 3', + description: 'description 3', + startDate: '2018-05-13T00:00:00.000Z', + endDate: '2018-05-14T00:00:00.000Z', + reference: 'phase', + referenceId: 1, + createdBy: 1, + updatedBy: 1, + deletedAt: '2018-05-14T00:00:00.000Z', + }, + ])) + .then(() => { + // Create milestones + models.Milestone.bulkCreate([ + { + timelineId: 1, + name: 'milestone 1', + duration: 2, + startDate: '2018-05-03T00:00:00.000Z', + status: 'open', + type: 'type1', + details: { + detail1: { + subDetail1A: 1, + subDetail1B: 2, + }, + detail2: [1, 2, 3], + }, + order: 1, + plannedText: 'plannedText 1', + activeText: 'activeText 1', + completedText: 'completedText 1', + blockedText: 'blockedText 1', + createdBy: 1, + updatedBy: 2, + }, + { + timelineId: 1, + name: 'milestone 2', + duration: 3, + startDate: '2018-05-04T00:00:00.000Z', + status: 'open', + type: 'type2', + order: 2, + plannedText: 'plannedText 2', + activeText: 'activeText 2', + completedText: 'completedText 2', + blockedText: 'blockedText 2', + createdBy: 2, + updatedBy: 3, + }, + { + timelineId: 1, + name: 'milestone 3', + duration: 4, + startDate: '2018-05-04T00:00:00.000Z', + status: 'open', + type: 'type3', + order: 3, + plannedText: 'plannedText 3', + activeText: 'activeText 3', + completedText: 'completedText 3', + blockedText: 'blockedText 3', + createdBy: 3, + updatedBy: 4, + deletedBy: 1, + deletedAt: '2018-05-04T00:00:00.000Z', + }, + ]) + .then(() => done()); + }); + }); + }); + }); + + after(testUtil.clearDb); + + describe('DELETE /timelines/{timelineId}/milestones/{milestoneId}', () => { + it('should return 403 if user is not authenticated', (done) => { + request(server) + .delete('/v4/timelines/1/milestones/1') + .expect(403, done); + }); + + it('should return 403 for member who is not in the project', (done) => { + request(server) + .delete('/v4/timelines/1/milestones/1') + .set({ + Authorization: `Bearer ${testUtil.jwts.member2}`, + }) + .expect(403, done); + }); + + it('should return 403 for member who is not in the project (timeline refers to a phase)', (done) => { + request(server) + .delete('/v4/timelines/2/milestones/1') + .set({ + Authorization: `Bearer ${testUtil.jwts.member2}`, + }) + .expect(403, done); + }); + + it('should return 404 for non-existed timeline', (done) => { + request(server) + .delete('/v4/timelines/1234/milestones/1') + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .expect(404, done); + }); + + it('should return 404 for deleted timeline', (done) => { + request(server) + .delete('/v4/timelines/3/milestones/1') + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .expect(404, done); + }); + + it('should return 404 for non-existed milestone', (done) => { + request(server) + .delete('/v4/timelines/1/milestones/100') + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .expect(404, done); + }); + + it('should return 404 for deleted milestone', (done) => { + request(server) + .delete('/v4/timelines/1/milestones/3') + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .expect(404, done); + }); + + it('should return 422 for invalid timelineId param', (done) => { + request(server) + .delete('/v4/timelines/0/milestones/1') + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .expect(422, done); + }); + + it('should return 422 for invalid milestoneId param', (done) => { + request(server) + .delete('/v4/timelines/1/milestones/0') + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .expect(422, done); + }); + + it('should return 204, for admin, if timeline was successfully removed', (done) => { + request(server) + .delete('/v4/timelines/1/milestones/1') + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .expect(204) + .end(err => expectAfterDelete(1, 1, err, () => { + // eslint-disable-next-line no-unused-expressions + server.services.pubsub.publish.calledWith(EVENT.ROUTING_KEY.MILESTONE_REMOVED).should.be.true; + done(); + })); + }); + + it('should return 204, for connect admin, if timeline was successfully removed', (done) => { + request(server) + .delete('/v4/timelines/1/milestones/1') + .set({ + Authorization: `Bearer ${testUtil.jwts.connectAdmin}`, + }) + .expect(204) + .end(err => expectAfterDelete(1, 1, err, done)); + }); + + it('should return 204, for connect manager, if timeline was successfully removed', (done) => { + request(server) + .delete('/v4/timelines/1/milestones/1') + .set({ + Authorization: `Bearer ${testUtil.jwts.manager}`, + }) + .expect(204) + .end(err => expectAfterDelete(1, 1, err, done)); + }); + + it('should return 204, for copilot, if timeline was successfully removed', (done) => { + request(server) + .delete('/v4/timelines/1/milestones/1') + .set({ + Authorization: `Bearer ${testUtil.jwts.copilot}`, + }) + .expect(204) + .end(err => expectAfterDelete(1, 1, err, done)); + }); + + it('should return 204, for member, if timeline was successfully removed', (done) => { + request(server) + .delete('/v4/timelines/1/milestones/1') + .set({ + Authorization: `Bearer ${testUtil.jwts.member}`, + }) + .expect(204) + .end(err => expectAfterDelete(1, 1, err, done)); + }); + }); +}); diff --git a/src/routes/milestones/get.js b/src/routes/milestones/get.js new file mode 100644 index 00000000..a4731321 --- /dev/null +++ b/src/routes/milestones/get.js @@ -0,0 +1,49 @@ +/** + * API to get a milestone + */ +import validate from 'express-validation'; +import Joi from 'joi'; +import _ from 'lodash'; +import { middleware as tcMiddleware } from 'tc-core-library-js'; +import util from '../../util'; +import validateTimeline from '../../middlewares/validateTimeline'; +import models from '../../models'; + +const permissions = tcMiddleware.permissions; + +const schema = { + params: { + timelineId: Joi.number().integer().positive().required(), + milestoneId: Joi.number().integer().positive().required(), + }, +}; + +module.exports = [ + validate(schema), + // Validate and get projectId from the timelineId param, and set to request params for + // checking by the permissions middleware + validateTimeline.validateTimelineIdParam, + permissions('milestone.view'), + (req, res, next) => { + const where = { + timelineId: req.params.timelineId, + id: req.params.milestoneId, + }; + + // Find the milestone + models.Milestone.findOne({ where }) + .then((milestone) => { + // Not found + if (!milestone) { + const apiErr = new Error(`Milestone not found for milestone id ${req.params.milestoneId}`); + apiErr.status = 404; + return Promise.reject(apiErr); + } + + // Write to response + res.json(util.wrapResponse(req.id, _.omit(milestone.toJSON(), ['deletedBy', 'deletedAt']))); + return Promise.resolve(); + }) + .catch(next); + }, +]; diff --git a/src/routes/milestones/get.spec.js b/src/routes/milestones/get.spec.js new file mode 100644 index 00000000..919b756d --- /dev/null +++ b/src/routes/milestones/get.spec.js @@ -0,0 +1,342 @@ +/** + * Tests for get.js + */ +import chai from 'chai'; +import request from 'supertest'; + +import models from '../../models'; +import server from '../../app'; +import testUtil from '../../tests/util'; + +const should = chai.should(); + +describe('GET milestone', () => { + before((done) => { + testUtil.clearDb() + .then(() => { + models.Project.bulkCreate([ + { + type: 'generic', + billingAccountId: 1, + name: 'test1', + description: 'test project1', + status: 'draft', + details: {}, + createdBy: 1, + updatedBy: 1, + }, + { + type: 'generic', + billingAccountId: 2, + name: 'test2', + description: 'test project2', + status: 'draft', + details: {}, + createdBy: 2, + updatedBy: 2, + deletedAt: '2018-05-15T00:00:00Z', + }, + ]) + .then(() => { + // Create member + models.ProjectMember.bulkCreate([ + { + userId: 40051332, + projectId: 1, + role: 'copilot', + isPrimary: true, + createdBy: 1, + updatedBy: 1, + }, + { + userId: 40051331, + projectId: 1, + role: 'customer', + isPrimary: true, + createdBy: 1, + updatedBy: 1, + }, + ]) + .then(() => + // Create phase + models.ProjectPhase.bulkCreate([ + { + projectId: 1, + name: 'test project phase 1', + status: 'active', + startDate: '2018-05-15T00:00:00Z', + endDate: '2018-05-15T12:00:00Z', + budget: 20.0, + progress: 1.23456, + details: { + message: 'This can be any json 2', + }, + createdBy: 1, + updatedBy: 1, + }, + { + projectId: 2, + name: 'test project phase 2', + status: 'active', + startDate: '2018-05-16T00:00:00Z', + endDate: '2018-05-16T12:00:00Z', + budget: 21.0, + progress: 1.234567, + details: { + message: 'This can be any json 2', + }, + createdBy: 2, + updatedBy: 2, + deletedAt: '2018-05-15T00:00:00Z', + }, + ])) + .then(() => + // Create timelines + models.Timeline.bulkCreate([ + { + name: 'name 1', + description: 'description 1', + startDate: '2018-05-11T00:00:00.000Z', + endDate: '2018-05-12T00:00:00.000Z', + reference: 'project', + referenceId: 1, + createdBy: 1, + updatedBy: 1, + }, + { + name: 'name 2', + description: 'description 2', + startDate: '2018-05-12T00:00:00.000Z', + endDate: '2018-05-13T00:00:00.000Z', + reference: 'phase', + referenceId: 1, + createdBy: 1, + updatedBy: 1, + }, + { + name: 'name 3', + description: 'description 3', + startDate: '2018-05-13T00:00:00.000Z', + endDate: '2018-05-14T00:00:00.000Z', + reference: 'phase', + referenceId: 1, + createdBy: 1, + updatedBy: 1, + deletedAt: '2018-05-14T00:00:00.000Z', + }, + ])) + .then(() => { + // Create milestones + models.Milestone.bulkCreate([ + { + timelineId: 1, + name: 'milestone 1', + duration: 2, + startDate: '2018-05-03T00:00:00.000Z', + status: 'open', + type: 'type1', + details: { + detail1: { + subDetail1A: 1, + subDetail1B: 2, + }, + detail2: [1, 2, 3], + }, + order: 1, + plannedText: 'plannedText 1', + activeText: 'activeText 1', + completedText: 'completedText 1', + blockedText: 'blockedText 1', + createdBy: 1, + updatedBy: 2, + }, + { + timelineId: 1, + name: 'milestone 2', + duration: 3, + startDate: '2018-05-04T00:00:00.000Z', + status: 'open', + type: 'type2', + order: 2, + plannedText: 'plannedText 2', + activeText: 'activeText 2', + completedText: 'completedText 2', + blockedText: 'blockedText 2', + createdBy: 2, + updatedBy: 3, + }, + { + timelineId: 1, + name: 'milestone 3', + duration: 4, + startDate: '2018-05-04T00:00:00.000Z', + status: 'open', + type: 'type3', + order: 3, + plannedText: 'plannedText 3', + activeText: 'activeText 3', + completedText: 'completedText 3', + blockedText: 'blockedText 3', + createdBy: 3, + updatedBy: 4, + deletedBy: 1, + deletedAt: '2018-05-04T00:00:00.000Z', + }, + ]) + .then(() => done()); + }); + }); + }); + }); + + after(testUtil.clearDb); + + describe('GET /timelines/{timelineId}/milestones/{milestoneId}', () => { + it('should return 403 if user is not authenticated', (done) => { + request(server) + .get('/v4/timelines/1/milestones/1') + .expect(403, done); + }); + + it('should return 403 for member who is not in the project', (done) => { + request(server) + .get('/v4/timelines/1/milestones/1') + .set({ + Authorization: `Bearer ${testUtil.jwts.member2}`, + }) + .expect(403, done); + }); + + it('should return 404 for non-existed timeline', (done) => { + request(server) + .get('/v4/timelines/1234/milestones/1') + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .expect(404, done); + }); + + it('should return 404 for deleted timeline', (done) => { + request(server) + .get('/v4/timelines/3/milestones/1') + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .expect(404, done); + }); + + it('should return 404 for non-existed milestone', (done) => { + request(server) + .get('/v4/timelines/1/milestones/1234') + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .expect(404, done); + }); + + it('should return 404 for deleted milestone', (done) => { + request(server) + .get('/v4/timelines/1/milestones/3') + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .expect(404, done); + }); + + it('should return 422 for invalid timelineId param', (done) => { + request(server) + .get('/v4/timelines/0/milestones/3') + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .expect(422, done); + }); + + it('should return 422 for invalid milestoneId param', (done) => { + request(server) + .get('/v4/timelines/1/milestones/0') + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .expect(422, done); + }); + + it('should return 200 for admin', (done) => { + request(server) + .get('/v4/timelines/1/milestones/1') + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .expect(200) + .end((err, res) => { + const resJson = res.body.result.content; + resJson.id.should.be.eql(1); + resJson.timelineId.should.be.eql(1); + resJson.name.should.be.eql('milestone 1'); + resJson.duration.should.be.eql(2); + resJson.startDate.should.be.eql('2018-05-03T00:00:00.000Z'); + resJson.status.should.be.eql('open'); + resJson.type.should.be.eql('type1'); + resJson.details.should.be.eql({ + detail1: { + subDetail1A: 1, + subDetail1B: 2, + }, + detail2: [1, 2, 3], + }); + resJson.order.should.be.eql(1); + resJson.plannedText.should.be.eql('plannedText 1'); + resJson.activeText.should.be.eql('activeText 1'); + resJson.completedText.should.be.eql('completedText 1'); + resJson.blockedText.should.be.eql('blockedText 1'); + + resJson.createdBy.should.be.eql(1); + should.exist(resJson.createdAt); + resJson.updatedBy.should.be.eql(2); + should.exist(resJson.updatedAt); + should.not.exist(resJson.deletedBy); + should.not.exist(resJson.deletedAt); + + done(); + }); + }); + + it('should return 200 for connect admin', (done) => { + request(server) + .get('/v4/timelines/1/milestones/1') + .set({ + Authorization: `Bearer ${testUtil.jwts.connectAdmin}`, + }) + .expect(200) + .end(done); + }); + + it('should return 200 for connect manager', (done) => { + request(server) + .get('/v4/timelines/1/milestones/1') + .set({ + Authorization: `Bearer ${testUtil.jwts.manager}`, + }) + .expect(200) + .end(done); + }); + + it('should return 200 for member', (done) => { + request(server) + .get('/v4/timelines/1/milestones/1') + .set({ + Authorization: `Bearer ${testUtil.jwts.member}`, + }) + .expect(200, done); + }); + + it('should return 200 for copilot', (done) => { + request(server) + .get('/v4/timelines/1/milestones/1') + .set({ + Authorization: `Bearer ${testUtil.jwts.copilot}`, + }) + .expect(200, done); + }); + }); +}); diff --git a/src/routes/milestones/list.js b/src/routes/milestones/list.js new file mode 100644 index 00000000..16152f50 --- /dev/null +++ b/src/routes/milestones/list.js @@ -0,0 +1,69 @@ +/** + * API to list all milestones + */ +import validate from 'express-validation'; +import Joi from 'joi'; +import config from 'config'; +import _ from 'lodash'; +import { middleware as tcMiddleware } from 'tc-core-library-js'; +import util from '../../util'; +import validateTimeline from '../../middlewares/validateTimeline'; + +const permissions = tcMiddleware.permissions; + +const ES_TIMELINE_INDEX = config.get('elasticsearchConfig.timelineIndexName'); +const ES_TIMELINE_TYPE = config.get('elasticsearchConfig.timelineDocType'); + +const schema = { + params: { + timelineId: Joi.number().integer().positive().required(), + }, +}; + +module.exports = [ + validate(schema), + // Validate and get projectId from the timelineId param, and set to request params for + // checking by the permissions middleware + validateTimeline.validateTimelineIdParam, + permissions('milestone.view'), + (req, res, next) => { + // Parse the sort query + let sort = req.query.sort ? decodeURIComponent(req.query.sort) : 'order'; + if (sort && sort.indexOf(' ') === -1) { + sort += ' asc'; + } + const sortableProps = [ + 'order asc', 'order desc', + ]; + if (sort && _.indexOf(sortableProps, sort) < 0) { + const apiErr = new Error('Invalid sort criteria'); + apiErr.status = 422; + return next(apiErr); + } + const sortColumnAndOrder = sort.split(' '); + + // Get timeline from ES + return util.getElasticSearchClient().get({ + index: ES_TIMELINE_INDEX, + type: ES_TIMELINE_TYPE, + id: req.params.timelineId, + }) + .then((doc) => { + if (!doc) { + const err = new Error(`Timeline not found for timeline id ${req.params.timelineId}`); + err.status = 404; + throw err; + } + + // Get the milestones + let milestones = _.isArray(doc._source.milestones) ? doc._source.milestones : []; // eslint-disable-line no-underscore-dangle + + // Sort + milestones = _.orderBy(milestones, [sortColumnAndOrder[0]], [sortColumnAndOrder[1]]); + + // Write to response + res.json(util.wrapResponse(req.id, milestones, milestones.length)); + }) + .catch(err => next(err)); + }, +]; diff --git a/src/routes/milestones/list.spec.js b/src/routes/milestones/list.spec.js new file mode 100644 index 00000000..0240ee43 --- /dev/null +++ b/src/routes/milestones/list.spec.js @@ -0,0 +1,324 @@ +/** + * Tests for list.js + */ +import chai from 'chai'; +import request from 'supertest'; +import sleep from 'sleep'; +import config from 'config'; + +import models from '../../models'; +import server from '../../app'; +import testUtil from '../../tests/util'; + +const ES_TIMELINE_INDEX = config.get('elasticsearchConfig.timelineIndexName'); +const ES_TIMELINE_TYPE = config.get('elasticsearchConfig.timelineDocType'); + +// eslint-disable-next-line no-unused-vars +const should = chai.should(); + +const timelines = [ + { + id: 1, + name: 'name 1', + description: 'description 1', + startDate: '2018-05-11T00:00:00.000Z', + endDate: '2018-05-12T00:00:00.000Z', + reference: 'project', + referenceId: 1, + createdBy: 1, + updatedBy: 1, + createdAt: '2018-05-11T00:00:00.000Z', + updatedAt: '2018-05-11T00:00:00.000Z', + }, +]; +const milestones = [ + { + id: 1, + timelineId: 1, + name: 'milestone 1', + duration: 2, + startDate: '2018-05-03T00:00:00.000Z', + endDate: '2018-05-04T00:00:00.000Z', + completionDate: '2018-05-05T00:00:00.000Z', + status: 'open', + type: 'type1', + details: { + detail1: { + subDetail1A: 1, + subDetail1B: 2, + }, + detail2: [1, 2, 3], + }, + order: 1, + plannedText: 'plannedText 1', + activeText: 'activeText 1', + completedText: 'completedText 1', + blockedText: 'blockedText 1', + createdBy: 1, + updatedBy: 2, + createdAt: '2018-05-11T00:00:00.000Z', + updatedAt: '2018-05-11T00:00:00.000Z', + }, + { + id: 2, + timelineId: 1, + name: 'milestone 2', + duration: 3, + startDate: '2018-05-04T00:00:00.000Z', + status: 'open', + type: 'type2', + order: 2, + plannedText: 'plannedText 2', + activeText: 'activeText 2', + completedText: 'completedText 2', + blockedText: 'blockedText 2', + createdBy: 2, + updatedBy: 3, + createdAt: '2018-05-11T00:00:00.000Z', + updatedAt: '2018-05-11T00:00:00.000Z', + }, +]; + +describe('LIST timelines', () => { + before(function beforeHook(done) { + this.timeout(10000); + testUtil.clearDb() + .then(() => { + models.Project.bulkCreate([ + { + type: 'generic', + billingAccountId: 1, + name: 'test1', + description: 'test project1', + status: 'draft', + details: {}, + createdBy: 1, + updatedBy: 1, + }, + { + type: 'generic', + billingAccountId: 2, + name: 'test2', + description: 'test project2', + status: 'draft', + details: {}, + createdBy: 2, + updatedBy: 2, + deletedAt: '2018-05-15T00:00:00Z', + }, + ]) + .then(() => { + // Create member + models.ProjectMember.bulkCreate([ + { + userId: 40051332, + projectId: 1, + role: 'copilot', + isPrimary: true, + createdBy: 1, + updatedBy: 1, + }, + { + userId: 40051331, + projectId: 1, + role: 'customer', + isPrimary: true, + createdBy: 1, + updatedBy: 1, + }, + ]).then(() => + // Create phase + models.ProjectPhase.bulkCreate([ + { + projectId: 1, + name: 'test project phase 1', + status: 'active', + startDate: '2018-05-15T00:00:00Z', + endDate: '2018-05-15T12:00:00Z', + budget: 20.0, + progress: 1.23456, + details: { + message: 'This can be any json 2', + }, + createdBy: 1, + updatedBy: 1, + }, + { + projectId: 2, + name: 'test project phase 2', + status: 'active', + startDate: '2018-05-16T00:00:00Z', + endDate: '2018-05-16T12:00:00Z', + budget: 21.0, + progress: 1.234567, + details: { + message: 'This can be any json 2', + }, + createdBy: 2, + updatedBy: 2, + }, + ])) + .then(() => + // Create timelines and milestones + models.Timeline.bulkCreate(timelines) + .then(() => models.Milestone.bulkCreate(milestones))) + .then(() => { + // Index to ES + timelines[0].milestones = milestones; + timelines[0].projectId = 1; + return server.services.es.index({ + index: ES_TIMELINE_INDEX, + type: ES_TIMELINE_TYPE, + id: timelines[0].id, + body: timelines[0], + }) + .then(() => { + // sleep for some time, let elasticsearch indices be settled + sleep.sleep(5); + done(); + }); + }); + }); + }); + }); + + after(testUtil.clearDb); + + describe('GET /timelines/{timelineId}/milestones', () => { + it('should return 403 if user is not authenticated', (done) => { + request(server) + .get('/v4/timelines') + .expect(403, done); + }); + + it('should return 403 for member with no accessible project', (done) => { + request(server) + .get('/v4/timelines/1/milestones') + .set({ + Authorization: `Bearer ${testUtil.jwts.member2}`, + }) + .expect(403, done); + }); + + it('should return 404 for not-existed timeline', (done) => { + request(server) + .get('/v4/timelines/11/milestones') + .set({ + Authorization: `Bearer ${testUtil.jwts.connectAdmin}`, + }) + .expect(404, done); + }); + + it('should return 422 for invalid sort column', (done) => { + request(server) + .get('/v4/timelines/1/milestones?sort=id%20asc') + .set({ + Authorization: `Bearer ${testUtil.jwts.connectAdmin}`, + }) + .expect(422, done); + }); + + it('should return 422 for invalid sort order', (done) => { + request(server) + .get('/v4/timelines/1/milestones?sort=order%20invalid') + .set({ + Authorization: `Bearer ${testUtil.jwts.connectAdmin}`, + }) + .expect(422, done); + }); + + it('should return 200 for admin', (done) => { + request(server) + .get('/v4/timelines/1/milestones') + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .expect(200) + .end((err, res) => { + const resJson = res.body.result.content; + resJson.should.have.length(2); + + resJson[0].should.be.eql(milestones[0]); + resJson[1].should.be.eql(milestones[1]); + + done(); + }); + }); + + it('should return 200 for connect admin', (done) => { + request(server) + .get('/v4/timelines/1/milestones') + .set({ + Authorization: `Bearer ${testUtil.jwts.connectAdmin}`, + }) + .expect(200) + .end((err, res) => { + const resJson = res.body.result.content; + resJson.should.have.length(2); + + done(); + }); + }); + + it('should return 200 for connect manager', (done) => { + request(server) + .get('/v4/timelines/1/milestones') + .set({ + Authorization: `Bearer ${testUtil.jwts.manager}`, + }) + .expect(200) + .end((err, res) => { + const resJson = res.body.result.content; + resJson.should.have.length(2); + + done(); + }); + }); + + it('should return 200 for member', (done) => { + request(server) + .get('/v4/timelines/1/milestones') + .set({ + Authorization: `Bearer ${testUtil.jwts.member}`, + }) + .end((err, res) => { + const resJson = res.body.result.content; + resJson.should.have.length(2); + + done(); + }); + }); + + it('should return 200 for copilot', (done) => { + request(server) + .get('/v4/timelines/1/milestones') + .set({ + Authorization: `Bearer ${testUtil.jwts.copilot}`, + }) + .end((err, res) => { + const resJson = res.body.result.content; + resJson.should.have.length(2); + + done(); + }); + }); + + it('should return 200 with sort by order desc', (done) => { + request(server) + .get('/v4/timelines/1/milestones?sort=order%20desc') + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .expect(200) + .end((err, res) => { + const resJson = res.body.result.content; + resJson.should.have.length(2); + + resJson[0].should.be.eql(milestones[1]); + resJson[1].should.be.eql(milestones[0]); + + done(); + }); + }); + }); +}); diff --git a/src/routes/milestones/update.js b/src/routes/milestones/update.js new file mode 100644 index 00000000..24230354 --- /dev/null +++ b/src/routes/milestones/update.js @@ -0,0 +1,293 @@ +/** + * API to update a milestone + */ +import validate from 'express-validation'; +import _ from 'lodash'; +import moment from 'moment'; +import Joi from 'joi'; +import Sequelize from 'sequelize'; +import { middleware as tcMiddleware } from 'tc-core-library-js'; +import util from '../../util'; +import validateTimeline from '../../middlewares/validateTimeline'; +import { EVENT, MILESTONE_STATUS } from '../../constants'; +import models from '../../models'; + +const permissions = tcMiddleware.permissions; + +/** + * Cascades endDate/completionDate changes to all milestones with a greater order than the given one. + * @param {Object} origMilestone the original milestone that was updated + * @param {Object} updMilestone the milestone that was updated + * @returns {Promise} a promise that resolves to the last found milestone. If no milestone exists with an + * order greater than the passed updMilestone, the promise will resolve to the passed + * updMilestone + */ +function updateComingMilestones(origMilestone, updMilestone) { + // flag to indicate if the milestone in picture, is updated for completionDate field or not + const completionDateChanged = !_.isEqual(origMilestone.completionDate, updMilestone.completionDate); + const today = moment.utc().hours(0).minutes(0).seconds(0) + .milliseconds(0); + // updated milestone's start date, pefers actual start date over scheduled start date + const updMSStartDate = updMilestone.actualStartDate ? updMilestone.actualStartDate : updMilestone.startDate; + // calculates schedule end date for the milestone based on start date and duration + let updMilestoneEndDate = moment.utc(updMSStartDate).add(updMilestone.duration - 1, 'days').toDate(); + // if the milestone, in context, is completed, overrides the end date to the completion date + updMilestoneEndDate = updMilestone.completionDate ? updMilestone.completionDate : updMilestoneEndDate; + return models.Milestone.findAll({ + where: { + timelineId: updMilestone.timelineId, + order: { $gt: updMilestone.order }, + }, + }).then((affectedMilestones) => { + const comingMilestones = _.sortBy(affectedMilestones, 'order'); + // calculates the schedule start date for the next milestone + let startDate = moment.utc(updMilestoneEndDate).add(1, 'days').toDate(); + let firstMilestoneFound = false; + const promises = _.map(comingMilestones, (_milestone) => { + const milestone = _milestone; + + // Update the milestone startDate if different than the iterated startDate + if (!_.isEqual(milestone.startDate, startDate)) { + milestone.startDate = startDate; + milestone.updatedBy = updMilestone.updatedBy; + } + + // Calculate the endDate, and update it if different + const endDate = moment.utc(startDate).add(milestone.duration - 1, 'days').toDate(); + if (!_.isEqual(milestone.endDate, endDate)) { + milestone.endDate = endDate; + milestone.updatedBy = updMilestone.updatedBy; + } + + // if completionDate is alerted, update status of the first non hidden milestone after the current one + if (!firstMilestoneFound && completionDateChanged && !milestone.hidden) { + // activate next milestone + milestone.status = MILESTONE_STATUS.ACTIVE; + milestone.actualStartDate = today; + firstMilestoneFound = true; + } + + // Set the next startDate value to the next day after completionDate if present or the endDate + startDate = moment.utc(milestone.completionDate + ? milestone.completionDate + : milestone.endDate).add(1, 'days').toDate(); + return milestone.save(); + }); + + // Resolve promise with all original and updated milestones + return Promise.all(promises).then(updatedMilestones => ({ + originalMilestones: affectedMilestones, + updatedMilestones, + })); + }); +} + +const schema = { + params: { + timelineId: Joi.number().integer().positive().required(), + milestoneId: Joi.number().integer().positive().required(), + }, + body: { + param: Joi.object().keys({ + id: Joi.any().strip(), + name: Joi.string().max(255).optional(), + description: Joi.string().max(255), + duration: Joi.number().integer().min(1).optional(), + startDate: Joi.any().forbidden(), + actualStartDate: Joi.date().allow(null), + endDate: Joi.any().forbidden(), + completionDate: Joi.date().allow(null), + status: Joi.string().max(45).optional(), + type: Joi.string().max(45).optional(), + details: Joi.object(), + order: Joi.number().integer().optional(), + plannedText: Joi.string().max(512).optional(), + activeText: Joi.string().max(512).optional(), + completedText: Joi.string().max(512).optional(), + blockedText: Joi.string().max(512).optional(), + hidden: Joi.boolean().optional(), + createdAt: Joi.any().strip(), + updatedAt: Joi.any().strip(), + deletedAt: Joi.any().strip(), + createdBy: Joi.any().strip(), + updatedBy: Joi.any().strip(), + deletedBy: Joi.any().strip(), + }).required(), + }, +}; + +module.exports = [ + validate(schema), + // Validate and get projectId from the timelineId param, + // and set to request params for checking by the permissions middleware + validateTimeline.validateTimelineIdParam, + permissions('milestone.edit'), + (req, res, next) => { + const where = { + timelineId: req.params.timelineId, + id: req.params.milestoneId, + }; + const entityToUpdate = _.assign(req.body.param, { + updatedBy: req.authUser.userId, + timelineId: req.params.timelineId, + }); + + const timeline = req.timeline; + + let original; + let updated; + + return models.sequelize.transaction(() => + // Find the milestone + models.Milestone.findOne({ where }) + .then((milestone) => { + // Not found + if (!milestone) { + const apiErr = new Error(`Milestone not found for milestone id ${req.params.milestoneId}`); + apiErr.status = 404; + return Promise.reject(apiErr); + } + + if (entityToUpdate.completionDate && entityToUpdate.completionDate < milestone.startDate) { + const apiErr = new Error('The milestone completionDate should be greater or equal than the startDate.'); + apiErr.status = 422; + return Promise.reject(apiErr); + } + + original = _.omit(milestone.toJSON(), ['deletedAt', 'deletedBy']); + const durationChanged = entityToUpdate.duration && entityToUpdate.duration !== milestone.duration; + const statusChanged = entityToUpdate.status && entityToUpdate.status !== milestone.status; + const completionDateChanged = entityToUpdate.completionDate + && !_.isEqual(milestone.completionDate, entityToUpdate.completionDate); + const today = moment.utc().hours(0).minutes(0).seconds(0) + .milliseconds(0); + + // Merge JSON fields + entityToUpdate.details = util.mergeJsonObjects(milestone.details, entityToUpdate.details); + + let actualStartDateCanged = false; + // if status has changed + if (statusChanged) { + // if status has changed to be completed, set the compeltionDate if not provided + if (entityToUpdate.status === MILESTONE_STATUS.COMPLETED) { + entityToUpdate.completionDate = entityToUpdate.completionDate ? entityToUpdate.completionDate : today; + } + // if status has changed to be active, set the startDate to today + if (entityToUpdate.status === MILESTONE_STATUS.ACTIVE) { + // NOTE: not updating startDate as activating a milestone should not update the scheduled start date + // entityToUpdate.startDate = today; + // should update actual start date + entityToUpdate.actualStartDate = today; + actualStartDateCanged = true; + } + } + + // Updates the end date of the milestone if: + // 1. if duration of the milestone is udpated, update its end date + // OR + // 2. if actual start date is updated, updating the end date of the activated milestone because + // early or late start of milestone, we are essentially changing the end schedule of the milestone + if (durationChanged || actualStartDateCanged) { + const updatedStartDate = actualStartDateCanged ? entityToUpdate.actualStartDate : milestone.startDate; + const updatedDuration = _.get(entityToUpdate, 'duration', milestone.duration); + entityToUpdate.endDate = moment.utc(updatedStartDate).add(updatedDuration - 1, 'days').toDate(); + } + + // if completionDate has changed + if (!statusChanged && completionDateChanged) { + entityToUpdate.status = MILESTONE_STATUS.COMPLETED; + } + + // Update + return milestone.update(entityToUpdate); + }) + .then((updatedMilestone) => { + // Omit deletedAt, deletedBy + updated = _.omit(updatedMilestone.toJSON(), 'deletedAt', 'deletedBy'); + + // Update order of the other milestones only if the order was changed + if (original.order === updated.order) { + return Promise.resolve(); + } + + return models.Milestone.count({ + where: { + timelineId: updated.timelineId, + id: { $ne: updated.id }, + order: updated.order, + }, + }) + .then((count) => { + if (count === 0) { + return Promise.resolve(); + } + + // Increase the order from M to K: if there is an item with order K, + // orders from M+1 to K should be made M to K-1 + if (original.order < updated.order) { + return models.Milestone.update({ order: Sequelize.literal('"order" - 1') }, { + where: { + timelineId: updated.timelineId, + id: { $ne: updated.id }, + order: { $between: [original.order + 1, updated.order] }, + }, + }); + } + + // Decrease the order from M to K: if there is an item with order K, + // orders from K to M-1 should be made K+1 to M + return models.Milestone.update({ order: Sequelize.literal('"order" + 1') }, { + where: { + timelineId: updated.timelineId, + id: { $ne: updated.id }, + order: { $between: [updated.order, original.order - 1] }, + }, + }); + }); + }) + .then(() => { + // we need to recalculate change in fields because we update some fields before making actual update + const needToCascade = !_.isEqual(original.completionDate, updated.completionDate) // completion date changed + || original.duration !== updated.duration // duration changed + || original.actualStartDate !== updated.actualStartDate; // actual start date updated + // Update dates of the other milestones only if cascade updates needed + if (needToCascade) { + return updateComingMilestones(original, updated) + .then(({ originalMilestones, updatedMilestones }) => { + // finds the last milestone updated + // if no milestone is updated by updateComingMilestones method, it means the current milestone is the last one + const lastTimelineMilestone = updatedMilestones.length ? _.last(updatedMilestones) : updated; + if (!_.isEqual(lastTimelineMilestone.endDate, timeline.endDate)) { + timeline.endDate = lastTimelineMilestone.endDate; + timeline.updatedBy = lastTimelineMilestone.updatedBy; + return timeline.save().then(() => ({ originalMilestones, updatedMilestones })); + } + return Promise.resolve({ originalMilestones, updatedMilestones }); + }); + } + return Promise.resolve({}); + }), + ) + .then(({ originalMilestones, updatedMilestones }) => { + const cascadedMilestones = _.map(originalMilestones, om => ({ + original: om, updated: _.find(updatedMilestones, um => um.id === om.id), + })); + const cascadedUpdates = { milestones: cascadedMilestones }; + // Send event to bus + req.log.debug('Sending event to RabbitMQ bus for milestone %d', updated.id); + req.app.services.pubsub.publish(EVENT.ROUTING_KEY.MILESTONE_UPDATED, + { original, updated, cascadedUpdates }, + { correlationId: req.id }, + ); + + // Do not send events for the the other milestones (updated order) here, + // because it will make 'version conflict' error in ES. + // The order of the other milestones need to be updated in the MILESTONE_UPDATED event above + + // Write to response + res.json(util.wrapResponse(req.id, updated)); + return Promise.resolve(); + }) + .catch(next); + }, +]; diff --git a/src/routes/milestones/update.spec.js b/src/routes/milestones/update.spec.js new file mode 100644 index 00000000..d6b77bb9 --- /dev/null +++ b/src/routes/milestones/update.spec.js @@ -0,0 +1,1081 @@ +/** + * Tests for get.js + */ +import chai from 'chai'; +import request from 'supertest'; +import moment from 'moment'; +import _ from 'lodash'; +import models from '../../models'; +import server from '../../app'; +import testUtil from '../../tests/util'; +import { EVENT, MILESTONE_STATUS } from '../../constants'; + +const should = chai.should(); + +describe('UPDATE Milestone', () => { + beforeEach((done) => { + testUtil.clearDb() + .then(() => { + models.Project.bulkCreate([ + { + type: 'generic', + billingAccountId: 1, + name: 'test1', + description: 'test project1', + status: 'draft', + details: {}, + createdBy: 1, + updatedBy: 1, + }, + { + type: 'generic', + billingAccountId: 2, + name: 'test2', + description: 'test project2', + status: 'draft', + details: {}, + createdBy: 2, + updatedBy: 2, + deletedAt: '2018-05-15T00:00:00Z', + }, + ]) + .then(() => { + // Create member + models.ProjectMember.bulkCreate([ + { + userId: 40051332, + projectId: 1, + role: 'copilot', + isPrimary: true, + createdBy: 1, + updatedBy: 1, + }, + { + userId: 40051331, + projectId: 1, + role: 'customer', + isPrimary: true, + createdBy: 1, + updatedBy: 1, + }, + ]).then(() => + // Create phase + models.ProjectPhase.bulkCreate([ + { + projectId: 1, + name: 'test project phase 1', + status: 'active', + startDate: '2018-05-15T00:00:00Z', + endDate: '2018-05-15T12:00:00Z', + budget: 20.0, + progress: 1.23456, + details: { + message: 'This can be any json 2', + }, + createdBy: 1, + updatedBy: 1, + }, + { + projectId: 2, + name: 'test project phase 2', + status: 'active', + startDate: '2018-05-16T00:00:00Z', + endDate: '2018-05-16T12:00:00Z', + budget: 21.0, + progress: 1.234567, + details: { + message: 'This can be any json 2', + }, + createdBy: 2, + updatedBy: 2, + deletedAt: '2018-05-15T00:00:00Z', + }, + ])) + .then(() => + // Create timelines + models.Timeline.bulkCreate([ + { + name: 'name 1', + description: 'description 1', + startDate: '2018-05-02T00:00:00.000Z', + endDate: '2018-06-12T00:00:00.000Z', + reference: 'project', + referenceId: 1, + createdBy: 1, + updatedBy: 1, + }, + { + name: 'name 2', + description: 'description 2', + startDate: '2018-05-12T00:00:00.000Z', + endDate: '2018-06-13T00:00:00.000Z', + reference: 'phase', + referenceId: 1, + createdBy: 1, + updatedBy: 1, + }, + { + name: 'name 3', + description: 'description 3', + startDate: '2018-05-13T00:00:00.000Z', + endDate: '2018-06-14T00:00:00.000Z', + reference: 'phase', + referenceId: 1, + createdBy: 1, + updatedBy: 1, + deletedAt: '2018-05-14T00:00:00.000Z', + }, + ]).then(() => models.Milestone.bulkCreate([ + { + id: 1, + timelineId: 1, + name: 'Milestone 1', + duration: 2, + startDate: '2018-05-13T00:00:00.000Z', + endDate: '2018-05-14T00:00:00.000Z', + completionDate: '2018-05-15T00:00:00.000Z', + status: 'open', + type: 'type1', + details: { + detail1: { + subDetail1A: 1, + subDetail1B: 2, + }, + detail2: [1, 2, 3], + }, + order: 1, + plannedText: 'plannedText 1', + activeText: 'activeText 1', + completedText: 'completedText 1', + blockedText: 'blockedText 1', + createdBy: 1, + updatedBy: 2, + createdAt: '2018-05-11T00:00:00.000Z', + updatedAt: '2018-05-11T00:00:00.000Z', + }, + { + id: 2, + timelineId: 1, + name: 'Milestone 2', + duration: 3, + startDate: '2018-05-14T00:00:00.000Z', + status: 'open', + type: 'type2', + order: 2, + plannedText: 'plannedText 2', + activeText: 'activeText 2', + completedText: 'completedText 2', + blockedText: 'blockedText 2', + createdBy: 2, + updatedBy: 3, + createdAt: '2018-05-11T00:00:00.000Z', + updatedAt: '2018-05-11T00:00:00.000Z', + }, + { + id: 3, + timelineId: 1, + name: 'Milestone 3', + duration: 3, + startDate: '2018-05-14T00:00:00.000Z', + status: 'open', + type: 'type3', + order: 3, + plannedText: 'plannedText 3', + activeText: 'activeText 3', + completedText: 'completedText 3', + blockedText: 'blockedText 3', + createdBy: 2, + updatedBy: 3, + createdAt: '2018-05-11T00:00:00.000Z', + updatedAt: '2018-05-11T00:00:00.000Z', + }, + { + id: 4, + timelineId: 1, + name: 'Milestone 4', + duration: 3, + startDate: '2018-05-14T00:00:00.000Z', + status: 'open', + type: 'type4', + order: 4, + plannedText: 'plannedText 4', + activeText: 'activeText 4', + completedText: 'completedText 4', + blockedText: 'blockedText 4', + createdBy: 2, + updatedBy: 3, + createdAt: '2018-05-11T00:00:00.000Z', + updatedAt: '2018-05-11T00:00:00.000Z', + }, + { + id: 5, + timelineId: 1, + name: 'Milestone 5', + duration: 3, + startDate: '2018-05-14T00:00:00.000Z', + status: 'open', + type: 'type5', + order: 5, + plannedText: 'plannedText 5', + activeText: 'activeText 5', + completedText: 'completedText 5', + blockedText: 'blockedText 5', + createdBy: 2, + updatedBy: 3, + createdAt: '2018-05-11T00:00:00.000Z', + updatedAt: '2018-05-11T00:00:00.000Z', + deletedAt: '2018-05-11T00:00:00.000Z', + }, + { + id: 6, + timelineId: 2, // Timeline 2 + name: 'Milestone 6', + duration: 3, + startDate: '2018-05-14T00:00:00.000Z', + status: 'open', + type: 'type5', + order: 1, + plannedText: 'plannedText 6', + activeText: 'activeText 6', + completedText: 'completedText 6', + blockedText: 'blockedText 6', + createdBy: 2, + updatedBy: 3, + createdAt: '2018-05-11T00:00:00.000Z', + updatedAt: '2018-05-11T00:00:00.000Z', + }, + ]))) + .then(() => done()); + }); + }); + }); + + after(testUtil.clearDb); + + describe('PATCH /timelines/{timelineId}/milestones/{milestoneId}', () => { + const body = { + param: { + name: 'Milestone 1-updated', + duration: 3, + completionDate: '2018-05-16T00:00:00.000Z', + description: 'description-updated', + status: 'closed', + type: 'type1-updated', + details: { + detail1: { + subDetail1A: 0, + subDetail1C: 3, + }, + detail2: [4], + detail3: 3, + }, + order: 1, + plannedText: 'plannedText 1-updated', + activeText: 'activeText 1-updated', + completedText: 'completedText 1-updated', + blockedText: 'blockedText 1-updated', + hidden: true, + }, + }; + + it('should return 403 if user is not authenticated', (done) => { + request(server) + .patch('/v4/timelines/1/milestones/1') + .send(body) + .expect(403, done); + }); + + it('should return 403 for member who is not in the project', (done) => { + request(server) + .patch('/v4/timelines/1/milestones/1') + .set({ + Authorization: `Bearer ${testUtil.jwts.member2}`, + }) + .send(body) + .expect(403, done); + }); + + it('should return 404 for non-existed timeline', (done) => { + request(server) + .patch('/v4/timelines/1234/milestones/1') + .send(body) + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .expect(404, done); + }); + + it('should return 404 for deleted timeline', (done) => { + request(server) + .patch('/v4/timelines/3/milestones/1') + .send(body) + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .expect(404, done); + }); + + it('should return 404 for non-existed Milestone', (done) => { + request(server) + .patch('/v4/timelines/1/milestones/111') + .send(body) + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .expect(404, done); + }); + + it('should return 404 for deleted Milestone', (done) => { + request(server) + .patch('/v4/timelines/1/milestones/5') + .send(body) + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .expect(404, done); + }); + + it('should return 422 for invalid timelineId param', (done) => { + request(server) + .patch('/v4/timelines/0/milestones/1') + .send(body) + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .expect(422, done); + }); + + it('should return 422 for invalid milestoneId param', (done) => { + request(server) + .patch('/v4/timelines/1/milestones/0') + .send(body) + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .expect(422, done); + }); + + it('should return 200 for missing name', (done) => { + const partialBody = _.cloneDeep(body); + delete partialBody.param.name; + request(server) + .patch('/v4/timelines/1/milestones/1') + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .send(partialBody) + .expect(200, done); + }); + + it('should return 200 for missing type', (done) => { + const partialBody = _.cloneDeep(body); + delete partialBody.param.type; + request(server) + .patch('/v4/timelines/1/milestones/1') + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .send(partialBody) + .expect(200, done); + }); + + it('should return 200 for missing duration', (done) => { + const partialBody = _.cloneDeep(body); + delete partialBody.param.duration; + request(server) + .patch('/v4/timelines/1/milestones/1') + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .send(partialBody) + .expect(200, done); + }); + + it('should return 200 for missing order', (done) => { + const partialBody = _.cloneDeep(body); + delete partialBody.param.order; + request(server) + .patch('/v4/timelines/1/milestones/1') + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .send(partialBody) + .expect(200, done); + }); + + it('should return 200 for missing plannedText', (done) => { + const partialBody = _.cloneDeep(body); + delete partialBody.param.plannedText; + request(server) + .patch('/v4/timelines/1/milestones/1') + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .send(partialBody) + .expect(200, done); + }); + + it('should return 200 for missing blockedText', (done) => { + const partialBody = _.cloneDeep(body); + delete partialBody.param.blockedText; + request(server) + .patch('/v4/timelines/1/milestones/1') + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .send(partialBody) + .expect(200, done); + }); + + it('should return 200 for missing activeText', (done) => { + const partialBody = _.cloneDeep(body); + delete partialBody.param.activeText; + request(server) + .patch('/v4/timelines/1/milestones/1') + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .send(partialBody) + .expect(200, done); + }); + + it('should return 200 for missing completedText', (done) => { + const partialBody = _.cloneDeep(body); + delete partialBody.param.completedText; + request(server) + .patch('/v4/timelines/1/milestones/1') + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .send(partialBody) + .expect(200, done); + }); + + it('should return 200 for missing hidden field', (done) => { + const partialBody = _.cloneDeep(body); + delete partialBody.param.hidden; + request(server) + .patch('/v4/timelines/1/milestones/1') + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .send(partialBody) + .expect(200, done); + }); + + ['startDate', 'endDate'].forEach((field) => { + it(`should return 422 if ${field} is present in the payload`, (done) => { + const invalidBody = { + param: _.assign({}, body.param, { + [field]: '2018-07-01T00:00:00.000Z', + }), + }; + + request(server) + .patch('/v4/timelines/1/milestones/1') + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .send(invalidBody) + .expect('Content-Type', /json/) + .expect(422, done); + }); + }); + + it('should return 200 for admin', (done) => { + request(server) + .patch('/v4/timelines/1/milestones/1') + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .send(body) + .expect(200) + .end((err, res) => { + const resJson = res.body.result.content; + should.exist(resJson.id); + resJson.name.should.be.eql(body.param.name); + resJson.description.should.be.eql(body.param.description); + resJson.duration.should.be.eql(body.param.duration); + resJson.completionDate.should.be.eql(body.param.completionDate); + resJson.status.should.be.eql(body.param.status); + resJson.type.should.be.eql(body.param.type); + resJson.details.should.be.eql({ + detail1: { subDetail1A: 0, subDetail1B: 2, subDetail1C: 3 }, + detail2: [4], + detail3: 3, + }); + resJson.order.should.be.eql(body.param.order); + resJson.plannedText.should.be.eql(body.param.plannedText); + resJson.activeText.should.be.eql(body.param.activeText); + resJson.completedText.should.be.eql(body.param.completedText); + resJson.blockedText.should.be.eql(body.param.blockedText); + + should.exist(resJson.createdBy); + should.exist(resJson.createdAt); + resJson.updatedBy.should.be.eql(40051333); // admin + should.exist(resJson.updatedAt); + should.not.exist(resJson.deletedBy); + should.not.exist(resJson.deletedAt); + + // eslint-disable-next-line no-unused-expressions + server.services.pubsub.publish.calledWith(EVENT.ROUTING_KEY.MILESTONE_UPDATED).should.be.true; + + done(); + }); + }); + + // eslint-disable-next-line func-names + it('should return 200 for admin - order increases and replaces another milestone\'s order', function (done) { + this.timeout(10000); + + request(server) + .patch('/v4/timelines/1/milestones/1') + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .send({ param: _.assign({}, body.param, { order: 4 }) }) // 1 to 4 + .expect(200) + .end(() => { + // Milestone 1: order 4 + // Milestone 2: order 2 - 1 = 1 + // Milestone 3: order 3 - 1 = 2 + // Milestone 4: order 4 - 1 = 3 + models.Milestone.findById(1) + .then((milestone) => { + milestone.order.should.be.eql(4); + }) + .then(() => models.Milestone.findById(2)) + .then((milestone) => { + milestone.order.should.be.eql(1); + }) + .then(() => models.Milestone.findById(3)) + .then((milestone) => { + milestone.order.should.be.eql(2); + }) + .then(() => models.Milestone.findById(4)) + .then((milestone) => { + milestone.order.should.be.eql(3); + + done(); + }); + }); + }); + + // eslint-disable-next-line func-names + it('should return 200 for admin - order increases and doesnot replace another milestone\'s order', function (done) { + this.timeout(10000); + + request(server) + .patch('/v4/timelines/1/milestones/1') + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .send({ param: _.assign({}, body.param, { order: 5 }) }) // 1 to 5 + .expect(200) + .end(() => { + // Milestone 1: order 5 + // Milestone 2: order 2 + // Milestone 3: order 3 + // Milestone 4: order 4 + models.Milestone.findById(1) + .then((milestone) => { + milestone.order.should.be.eql(5); + }) + .then(() => models.Milestone.findById(2)) + .then((milestone) => { + milestone.order.should.be.eql(2); + }) + .then(() => models.Milestone.findById(3)) + .then((milestone) => { + milestone.order.should.be.eql(3); + }) + .then(() => models.Milestone.findById(4)) + .then((milestone) => { + milestone.order.should.be.eql(4); + + done(); + }); + }); + }); + + // eslint-disable-next-line func-names + it('should return 200 for admin - order decreases and replaces another milestone\'s order', function (done) { + this.timeout(10000); + + request(server) + .patch('/v4/timelines/1/milestones/4') + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .send({ param: _.assign({}, body.param, { order: 2 }) }) // 4 to 2 + .expect(200) + .end(() => { + // Milestone 1: order 1 + // Milestone 2: order 3 + // Milestone 3: order 4 + // Milestone 4: order 2 + models.Milestone.findById(1) + .then((milestone) => { + milestone.order.should.be.eql(1); + }) + .then(() => models.Milestone.findById(2)) + .then((milestone) => { + milestone.order.should.be.eql(3); + }) + .then(() => models.Milestone.findById(3)) + .then((milestone) => { + milestone.order.should.be.eql(4); + }) + .then(() => models.Milestone.findById(4)) + .then((milestone) => { + milestone.order.should.be.eql(2); + + done(); + }); + }); + }); + + // eslint-disable-next-line func-names + it('should return 200 for admin - order decreases and doesnot replace another milestone\'s order', function (done) { + this.timeout(10000); + + request(server) + .patch('/v4/timelines/1/milestones/4') + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .send({ param: _.assign({}, body.param, { order: 0 }) }) // 4 to 0 + .expect(200) + .end(() => { + // Milestone 1: order 1 + // Milestone 2: order 2 + // Milestone 3: order 3 + // Milestone 4: order 0 + models.Milestone.findById(1) + .then((milestone) => { + milestone.order.should.be.eql(1); + }) + .then(() => models.Milestone.findById(2)) + .then((milestone) => { + milestone.order.should.be.eql(2); + }) + .then(() => models.Milestone.findById(3)) + .then((milestone) => { + milestone.order.should.be.eql(3); + }) + .then(() => models.Milestone.findById(4)) + .then((milestone) => { + milestone.order.should.be.eql(0); + + done(); + }); + }); + }); + + // eslint-disable-next-line func-names + it('should return 200 for admin - changing order with only 1 item in list', function (done) { + this.timeout(10000); + + request(server) + .patch('/v4/timelines/2/milestones/6') + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .send({ param: _.assign({}, body.param, { order: 0 }) }) // 1 to 0 + .expect(200) + .end(() => { + // Milestone 6: order 0 + models.Milestone.findById(6) + .then((milestone) => { + milestone.order.should.be.eql(0); + + done(); + }) + .catch(done); + }); + }); + + // eslint-disable-next-line func-names + it('should return 200 for admin - changing order without changing other milestones\' orders', function (done) { + this.timeout(10000); + + models.Milestone.bulkCreate([ + { + id: 7, + timelineId: 2, // Timeline 2 + name: 'Milestone 7', + duration: 3, + startDate: '2018-05-14T00:00:00.000Z', + status: 'open', + type: 'type7', + order: 3, + plannedText: 'plannedText 7', + activeText: 'activeText 7', + completedText: 'completedText 7', + blockedText: 'blockedText 7', + createdBy: 2, + updatedBy: 3, + createdAt: '2018-05-11T00:00:00.000Z', + updatedAt: '2018-05-11T00:00:00.000Z', + }, + { + id: 8, + timelineId: 2, // Timeline 2 + name: 'Milestone 8', + duration: 3, + startDate: '2018-05-14T00:00:00.000Z', + status: 'open', + type: 'type7', + order: 4, + plannedText: 'plannedText 8', + activeText: 'activeText 8', + completedText: 'completedText 8', + blockedText: 'blockedText 8', + createdBy: 2, + updatedBy: 3, + createdAt: '2018-05-11T00:00:00.000Z', + updatedAt: '2018-05-11T00:00:00.000Z', + }, + ]) + .then(() => { + request(server) + .patch('/v4/timelines/2/milestones/8') + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .send({ param: _.assign({}, body.param, { order: 2 }) }) // 4 to 2 + .expect(200) + .end(() => { + // Milestone 6: order 1 => 1 + // Milestone 7: order 3 => 3 + // Milestone 8: order 4 => 2 + models.Milestone.findById(6) + .then((milestone) => { + milestone.order.should.be.eql(1); + }) + .then(() => models.Milestone.findById(7)) + .then((milestone) => { + milestone.order.should.be.eql(3); + }) + .then(() => models.Milestone.findById(8)) + .then((milestone) => { + milestone.order.should.be.eql(2); + + done(); + }) + .catch(done); + }); + }); + }); + + // eslint-disable-next-line func-names + it('should return 200 for admin - changing order withchanging other milestones\' orders', function (done) { + this.timeout(10000); + + models.Milestone.bulkCreate([ + { + id: 7, + timelineId: 2, // Timeline 2 + name: 'Milestone 7', + duration: 3, + startDate: '2018-05-14T00:00:00.000Z', + status: 'open', + type: 'type7', + order: 2, + plannedText: 'plannedText 7', + activeText: 'activeText 7', + completedText: 'completedText 7', + blockedText: 'blockedText 7', + createdBy: 2, + updatedBy: 3, + createdAt: '2018-05-11T00:00:00.000Z', + updatedAt: '2018-05-11T00:00:00.000Z', + }, + { + id: 8, + timelineId: 2, // Timeline 2 + name: 'Milestone 8', + duration: 3, + startDate: '2018-05-14T00:00:00.000Z', + status: 'open', + type: 'type7', + order: 4, + plannedText: 'plannedText 8', + activeText: 'activeText 8', + completedText: 'completedText 8', + blockedText: 'blockedText 8', + createdBy: 2, + updatedBy: 3, + createdAt: '2018-05-11T00:00:00.000Z', + updatedAt: '2018-05-11T00:00:00.000Z', + }, + ]) + .then(() => { + request(server) + .patch('/v4/timelines/2/milestones/8') + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .send({ param: _.assign({}, body.param, { order: 2 }) }) // 4 to 2 + .expect(200) + .end(() => { + // Milestone 6: order 1 => 1 + // Milestone 7: order 2 => 3 + // Milestone 8: order 4 => 2 + models.Milestone.findById(6) + .then((milestone) => { + milestone.order.should.be.eql(1); + }) + .then(() => models.Milestone.findById(7)) + .then((milestone) => { + milestone.order.should.be.eql(3); + }) + .then(() => models.Milestone.findById(8)) + .then((milestone) => { + milestone.order.should.be.eql(2); + + done(); + }) + .catch(done); + }); + }); + }); + + it('should return 200 for admin - marking milestone active later will cascade changes to coming ' + + // eslint-disable-next-line func-names + 'milestones', function (done) { + this.timeout(10000); + const today = moment.utc().hours(0).minutes(0).seconds(0) + .milliseconds(0); + + request(server) + .patch('/v4/timelines/1/milestones/2') + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .send({ param: { status: MILESTONE_STATUS.ACTIVE } }) + .expect(200) + .end(() => { + // Milestone 2: startDate: '2018-05-14T00:00:00.000Z' to '2018-05-14T00:00:00.000Z' + // actualStartDate: null to today + // endDate: null to today + 2 (2 = duration - 1) + // Milestone 3: startDate: '2018-05-14T00:00:00.000Z' to today + 3 + // endDate: null to today + 5 (5 = 3 + duration - 1) + // Milestone 4: startDate: '2018-05-14T00:00:00.000Z' to today + 6 + // endDate: null to today + 8 (2 = 6 + duration - 1) + models.Milestone.findById(2) + .then((milestone) => { + should.exist(milestone.actualStartDate); + moment.utc(milestone.actualStartDate).diff(today, 'days').should.be.eql(0); + // start date of the updated milestone should not change + milestone.startDate.should.be.eql(new Date('2018-05-14T00:00:00.000Z')); + today.add('days', milestone.duration - 1); + // end date of the updated milestone should change, as delayed start caused scheduled to be delayed + moment.utc(milestone.endDate).diff(today, 'days').should.be.eql(0); + milestone.status.should.be.eql(MILESTONE_STATUS.ACTIVE); + return models.Milestone.findById(3); + }) + .then((milestone) => { + today.add('days', 1); // should have start date next to previous one's end date + moment.utc(milestone.startDate).diff(today, 'days').should.be.eql(0); + should.not.exist(milestone.actualStartDate); + today.add('days', milestone.duration - 1); + moment.utc(milestone.endDate).diff(today, 'days').should.be.eql(0); + return models.Milestone.findById(4); + }) + .then((milestone) => { + today.add('days', 1); // should have start date next to previous one's end date + moment.utc(milestone.startDate).diff(today, 'days').should.be.eql(0); + should.not.exist(milestone.actualStartDate); + today.add('days', milestone.duration - 1); + moment.utc(milestone.endDate).diff(today, 'days').should.be.eql(0); + done(); + }) + .catch(done); + }); + }); + + it('should return 200 for admin - changing completionDate will cascade changes to coming ' + + // eslint-disable-next-line func-names + 'milestones', function (done) { + this.timeout(10000); + const today = moment.utc().hours(0).minutes(0).seconds(0) + .milliseconds(0); + + request(server) + .patch('/v4/timelines/1/milestones/2') + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .send({ param: _.assign({}, body.param, { + completionDate: '2018-05-18T00:00:00.000Z', order: undefined, duration: undefined, + }) }) + .expect(200) + .end(() => { + // Milestone 3: startDate: '2018-05-14T00:00:00.000Z' to '2018-05-19T00:00:00.000Z' + // endDate: null to '2018-05-21T00:00:00.000Z' + // Milestone 4: startDate: '2018-05-14T00:00:00.000Z' to '2018-05-22T00:00:00.000Z' + // endDate: null to '2018-05-24T00:00:00.000Z' + models.Milestone.findById(3) + .then((milestone) => { + milestone.startDate.should.be.eql(new Date('2018-05-19T00:00:00.000Z')); + should.exist(milestone.actualStartDate); + moment().utc(milestone.actualStartDate).diff(today, 'days').should.be.eql(0); + // milestone.actualStartDate.should.be.eql(today); + milestone.endDate.should.be.eql(new Date('2018-05-21T00:00:00.000Z')); + milestone.status.should.be.eql(MILESTONE_STATUS.ACTIVE); + return models.Milestone.findById(4); + }) + .then((milestone) => { + milestone.startDate.should.be.eql(new Date('2018-05-22T00:00:00.000Z')); + should.not.exist(milestone.actualStartDate); + milestone.endDate.should.be.eql(new Date('2018-05-24T00:00:00.000Z')); + done(); + }) + .catch(done); + }); + }); + + it('should return 200 for admin - changing completionDate will change the timeline\'s ' + + // eslint-disable-next-line func-names + 'endDate', function (done) { + this.timeout(10000); + + request(server) + .patch('/v4/timelines/1/milestones/2') + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .send({ param: _.assign({}, body.param, { + completionDate: '2018-05-18T00:00:00.000Z', order: undefined, duration: undefined, + }) }) + .expect(200) + .end(() => { + // Milestone 3: startDate: '2018-05-14T00:00:00.000Z' to '2018-05-19T00:00:00.000Z' + // endDate: null to '2018-05-21T00:00:00.000Z' + // Milestone 4: startDate: '2018-05-14T00:00:00.000Z' to '2018-05-22T00:00:00.000Z' + // BELOW will be the new timeline's endDate + // endDate: null to '2018-05-24T00:00:00.000Z' + models.Timeline.findById(1) + .then((timeline) => { + // timeline start shouldn't change + timeline.startDate.should.be.eql(new Date('2018-05-02T00:00:00.000Z')); + + // timeline end should change + timeline.endDate.should.be.eql(new Date('2018-05-24T00:00:00.000Z')); + + done(); + }) + .catch(done); + }); + }); + + it('should return 200 for admin - changing duration will cascade changes to coming ' + + // eslint-disable-next-line func-names + 'milestones', function (done) { + this.timeout(10000); + + request(server) + .patch('/v4/timelines/1/milestones/2') + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .send({ param: _.assign({}, body.param, { duration: 5, order: undefined, completionDate: undefined }) }) + .expect(200) + .end(() => { + // Milestone 3: startDate: '2018-05-14T00:00:00.000Z' to '2018-05-19T00:00:00.000Z' + // endDate: null to '2018-05-21T00:00:00.000Z' + // Milestone 4: startDate: '2018-05-14T00:00:00.000Z' to '2018-05-22T00:00:00.000Z' + // endDate: null to '2018-05-24T00:00:00.000Z' + models.Milestone.findById(3) + .then((milestone) => { + milestone.startDate.should.be.eql(new Date('2018-05-19T00:00:00.000Z')); + milestone.endDate.should.be.eql(new Date('2018-05-21T00:00:00.000Z')); + return models.Milestone.findById(4); + }) + .then((milestone) => { + milestone.startDate.should.be.eql(new Date('2018-05-22T00:00:00.000Z')); + milestone.endDate.should.be.eql(new Date('2018-05-24T00:00:00.000Z')); + done(); + }) + .catch(done); + }); + }); + + it('should return 200 for admin - changing duration will change the timeline\'s ' + + // eslint-disable-next-line func-names + 'endDate', function (done) { + this.timeout(10000); + + request(server) + .patch('/v4/timelines/1/milestones/2') + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .send({ param: _.assign({}, body.param, { duration: 5, order: undefined, completionDate: undefined }) }) + .expect(200) + .end(() => { + // Milestone 3: startDate: '2018-05-14T00:00:00.000Z' to '2018-05-19T00:00:00.000Z' + // endDate: null to '2018-05-21T00:00:00.000Z' + // Milestone 4: startDate: '2018-05-14T00:00:00.000Z' to '2018-05-22T00:00:00.000Z' + // BELOW will be the new timeline's endDate + // endDate: null to '2018-05-24T00:00:00.000Z' + models.Timeline.findById(1) + .then((timeline) => { + // timeline start shouldn't change + timeline.startDate.should.be.eql(new Date('2018-05-02T00:00:00.000Z')); + + // timeline end should change + timeline.endDate.should.be.eql(new Date('2018-05-24T00:00:00.000Z')); + + done(); + }) + .catch(done); + }); + }); + + it('should return 200 for connect admin', (done) => { + request(server) + .patch('/v4/timelines/1/milestones/1') + .set({ + Authorization: `Bearer ${testUtil.jwts.connectAdmin}`, + }) + .send(body) + .expect(200) + .end(done); + }); + + it('should return 200 for connect manager', (done) => { + request(server) + .patch('/v4/timelines/1/milestones/1') + .set({ + Authorization: `Bearer ${testUtil.jwts.manager}`, + }) + .send(body) + .expect(200) + .end(done); + }); + + it('should return 200 for copilot', (done) => { + request(server) + .patch('/v4/timelines/1/milestones/1') + .set({ + Authorization: `Bearer ${testUtil.jwts.copilot}`, + }) + .send(body) + .expect(200) + .end(done); + }); + + it('should return 200 for member', (done) => { + request(server) + .patch('/v4/timelines/1/milestones/1') + .set({ + Authorization: `Bearer ${testUtil.jwts.member}`, + }) + .send(body) + .expect(200) + .end(done); + }); + }); +}); diff --git a/src/routes/phaseProducts/create.js b/src/routes/phaseProducts/create.js index 47ba2825..10887006 100644 --- a/src/routes/phaseProducts/create.js +++ b/src/routes/phaseProducts/create.js @@ -91,25 +91,27 @@ module.exports = [ err.status = 400; throw err; } - return models.PhaseProduct.create(data); - }) + return models.PhaseProduct.create(data) .then((_newPhaseProduct) => { newPhaseProduct = _.cloneDeep(_newPhaseProduct); req.log.debug('new phase product created (id# %d, name: %s)', newPhaseProduct.id, newPhaseProduct.name); newPhaseProduct = newPhaseProduct.get({ plain: true }); newPhaseProduct = _.omit(newPhaseProduct, ['deletedAt', 'utm']); + }); + })) + .then(() => { + // Send events to buses + req.log.debug('Sending event to RabbitMQ bus for phase product %d', newPhaseProduct.id); + req.app.services.pubsub.publish(EVENT.ROUTING_KEY.PROJECT_PHASE_PRODUCT_ADDED, + newPhaseProduct, + { correlationId: req.id }, + ); + req.log.debug('Sending event to Kafka bus for phase product %d', newPhaseProduct.id); + req.app.emit(EVENT.ROUTING_KEY.PROJECT_PHASE_PRODUCT_ADDED, { req, created: newPhaseProduct }); - // Send events to buses - req.log.debug('Sending event to RabbitMQ bus for phase product %d', newPhaseProduct.id); - req.app.services.pubsub.publish(EVENT.ROUTING_KEY.PROJECT_PHASE_PRODUCT_ADDED, - newPhaseProduct, - { correlationId: req.id }, - ); - req.log.debug('Sending event to Kafka bus for phase product %d', newPhaseProduct.id); - req.app.emit(EVENT.ROUTING_KEY.PROJECT_PHASE_PRODUCT_ADDED, { req, created: newPhaseProduct }); - - res.status(201).json(util.wrapResponse(req.id, newPhaseProduct, 1, 201)); - })).catch((err) => { next(err); }); + res.status(201).json(util.wrapResponse(req.id, newPhaseProduct, 1, 201)); + }) + .catch((err) => { next(err); }); }, ]; diff --git a/src/routes/phaseProducts/delete.js b/src/routes/phaseProducts/delete.js index 2faa6295..b8efbb39 100644 --- a/src/routes/phaseProducts/delete.js +++ b/src/routes/phaseProducts/delete.js @@ -25,18 +25,18 @@ module.exports = [ phaseId, deletedAt: { $eq: null }, }, - }).then(existing => new Promise((accept, reject) => { + }).then((existing) => { if (!existing) { // handle 404 const err = new Error('No active phase product found for project id ' + `${projectId}, phase id ${phaseId} and product id ${productId}`); err.status = 404; - reject(err); - } else { - _.extend(existing, { deletedBy: req.authUser.userId, deletedAt: Date.now() }); - existing.save().then(accept).catch(reject); + return Promise.reject(err); } - })).then((deleted) => { + return existing.update({ deletedBy: req.authUser.userId }); + }) + .then(entity => entity.destroy())) + .then((deleted) => { req.log.debug('deleted phase product', JSON.stringify(deleted, null, 2)); // Send events to buses @@ -48,6 +48,7 @@ module.exports = [ req.app.emit(EVENT.ROUTING_KEY.PROJECT_PHASE_PRODUCT_REMOVED, { req, deleted }); res.status(204).json({}); - }).catch(err => next(err))); + }) + .catch(err => next(err)); }, ]; diff --git a/src/routes/phaseProducts/delete.spec.js b/src/routes/phaseProducts/delete.spec.js index 9bf234b4..5f958f4d 100644 --- a/src/routes/phaseProducts/delete.spec.js +++ b/src/routes/phaseProducts/delete.spec.js @@ -1,10 +1,38 @@ /* eslint-disable no-unused-expressions */ import _ from 'lodash'; import request from 'supertest'; +import chai from 'chai'; import server from '../../app'; import models from '../../models'; import testUtil from '../../tests/util'; +const expectAfterDelete = (projectId, phaseId, id, err, next) => { + if (err) throw err; + setTimeout(() => + models.PhaseProduct.findOne({ + where: { + id, + projectId, + phaseId, + }, + paranoid: false, + }) + .then((res) => { + if (!res) { + throw new Error('Should found the entity'); + } else { + chai.assert.isNotNull(res.deletedAt); + chai.assert.isNotNull(res.deletedBy); + + request(server) + .get(`/v4/projects/${projectId}/phases/${phaseId}/products/${id}`) + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .expect(404, next); + } + }), 500); +}; const body = { name: 'test phase product', type: 'product1', @@ -156,7 +184,8 @@ describe('Phase Products', () => { .set({ Authorization: `Bearer ${testUtil.jwts.copilot}`, }) - .expect(204, done); + .expect(204) + .end(err => expectAfterDelete(projectId, phaseId, productId, err, done)); }); }); }); diff --git a/src/routes/phaseProducts/update.js b/src/routes/phaseProducts/update.js index fa7ac460..e129b316 100644 --- a/src/routes/phaseProducts/update.js +++ b/src/routes/phaseProducts/update.js @@ -62,7 +62,8 @@ module.exports = [ _.extend(existing, updatedProps); existing.save().then(accept).catch(reject); } - })).then((updated) => { + }))) + .then((updated) => { req.log.debug('updated phase product', JSON.stringify(updated, null, 2)); const updatedValue = updated.get({ plain: true }); @@ -77,6 +78,6 @@ module.exports = [ { req, original: previousValue, updated: updatedValue }); res.json(util.wrapResponse(req.id, updated)); - }).catch(err => next(err))); + }).catch(err => next(err)); }, ]; diff --git a/src/routes/phases/create.js b/src/routes/phases/create.js index f4a7a9b6..3c5c2f83 100644 --- a/src/routes/phases/create.js +++ b/src/routes/phases/create.js @@ -14,7 +14,7 @@ const addProjectPhaseValidations = { param: Joi.object().keys({ name: Joi.string().required(), status: Joi.string().required(), - startDate: Joi.date().max(Joi.ref('endDate')).optional(), + startDate: Joi.date().optional(), endDate: Joi.date().optional(), duration: Joi.number().min(0).optional(), budget: Joi.number().min(0).optional(), @@ -41,9 +41,9 @@ module.exports = [ updatedBy: req.authUser.userId, }); + let newProjectPhase = null; models.sequelize.transaction(() => { - let newProjectPhase = null; - + req.log.debug('Create Phase - Starting transaction'); return models.Project.findOne({ where: { id: projectId, deletedAt: { $eq: null } }, }).then((existingProject) => { @@ -52,6 +52,11 @@ module.exports = [ err.status = 404; throw err; } + if (data.startDate !== null && data.endDate !== null && data.startDate > data.endDate) { + const err = new Error('startDate must not be after endDate.'); + err.status = 422; + throw err; + } return models.ProjectPhase .create(data) .then((_newProjectPhase) => { @@ -61,21 +66,23 @@ module.exports = [ newProjectPhase = newProjectPhase.get({ plain: true }); newProjectPhase = _.omit(newProjectPhase, ['deletedAt', 'deletedBy', 'utm']); - - // Send events to buses - req.log.debug('Sending event to RabbitMQ bus for project phase %d', newProjectPhase.id); - req.app.services.pubsub.publish(EVENT.ROUTING_KEY.PROJECT_PHASE_ADDED, - newProjectPhase, - { correlationId: req.id }, - ); - req.log.debug('Sending event to Kafka bus for project phase %d', newProjectPhase.id); - req.app.emit(EVENT.ROUTING_KEY.PROJECT_PHASE_ADDED, { req, created: newProjectPhase }); - - res.status(201).json(util.wrapResponse(req.id, newProjectPhase, 1, 201)); }); - }).catch((err) => { - util.handleError('Error creating project phase', err, req, next); }); + }) + .then(() => { + // Send events to buses + req.log.debug('Sending event to RabbitMQ bus for project phase %d', newProjectPhase.id); + req.app.services.pubsub.publish(EVENT.ROUTING_KEY.PROJECT_PHASE_ADDED, + newProjectPhase, + { correlationId: req.id }, + ); + req.log.debug('Sending event to Kafka bus for project phase %d', newProjectPhase.id); + req.app.emit(EVENT.ROUTING_KEY.PROJECT_PHASE_ADDED, { req, created: newProjectPhase }); + + res.status(201).json(util.wrapResponse(req.id, newProjectPhase, 1, 201)); + }) + .catch((err) => { + util.handleError('Error creating project phase', err, req, next); }); }, diff --git a/src/routes/phases/delete.js b/src/routes/phases/delete.js index 3bc34012..afa440e0 100644 --- a/src/routes/phases/delete.js +++ b/src/routes/phases/delete.js @@ -23,18 +23,18 @@ module.exports = [ projectId, deletedAt: { $eq: null }, }, - }).then(existing => new Promise((accept, reject) => { + }).then((existing) => { if (!existing) { // handle 404 const err = new Error('no active project phase found for project id ' + `${projectId} and phase id ${phaseId}`); err.status = 404; - reject(err); - } else { - _.extend(existing, { deletedBy: req.authUser.userId, deletedAt: Date.now() }); - existing.save().then(accept).catch(reject); + return Promise.reject(err); } - })).then((deleted) => { + return existing.update({ deletedBy: req.authUser.userId }); + }) + .then(entity => entity.destroy())) + .then((deleted) => { req.log.debug('deleted project phase', JSON.stringify(deleted, null, 2)); // Send events to buses @@ -46,7 +46,7 @@ module.exports = [ req.app.emit(EVENT.ROUTING_KEY.PROJECT_PHASE_REMOVED, { req, deleted }); res.status(204).json({}); - }).catch(err => next(err))); + }).catch(err => next(err)); }, ]; diff --git a/src/routes/phases/delete.spec.js b/src/routes/phases/delete.spec.js index 43a56b13..1b3ace93 100644 --- a/src/routes/phases/delete.spec.js +++ b/src/routes/phases/delete.spec.js @@ -1,10 +1,37 @@ /* eslint-disable no-unused-expressions */ import _ from 'lodash'; import request from 'supertest'; +import chai from 'chai'; import server from '../../app'; import models from '../../models'; import testUtil from '../../tests/util'; +const expectAfterDelete = (projectId, id, err, next) => { + if (err) throw err; + setTimeout(() => + models.ProjectPhase.findOne({ + where: { + id, + projectId, + }, + paranoid: false, + }) + .then((res) => { + if (!res) { + throw new Error('Should found the entity'); + } else { + chai.assert.isNotNull(res.deletedAt); + chai.assert.isNotNull(res.deletedBy); + + request(server) + .get(`/v4/projects/${projectId}/phases/${id}`) + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .expect(404, next); + } + }), 500); +}; const body = { name: 'test project phase', status: 'active', @@ -130,7 +157,7 @@ describe('Project Phases', () => { .set({ Authorization: `Bearer ${testUtil.jwts.copilot}`, }) - .expect(204, done); + .end(err => expectAfterDelete(projectId, phaseId, err, done)); }); }); }); diff --git a/src/routes/phases/list.js b/src/routes/phases/list.js index 6644a365..55d5ba63 100644 --- a/src/routes/phases/list.js +++ b/src/routes/phases/list.js @@ -20,6 +20,8 @@ module.exports = [ (req, res, next) => { const projectId = _.parseInt(req.params.projectId); + // Parse the fields string to determine what fields are to be returned + let fields = req.query.fields ? decodeURIComponent(req.query.fields).split(',') : PHASE_ATTRIBUTES; let sort = req.query.sort ? decodeURIComponent(req.query.sort) : 'startDate'; if (sort && sort.indexOf(' ') === -1) { sort += ' asc'; @@ -49,9 +51,7 @@ module.exports = [ // Sort phases = _.sortBy(phases, [sortColumnAndOrder[0]], [sortColumnAndOrder[1]]); - // Parse the fields string to determine what fields are to be returned - let fields = req.query.fields ? req.query.fields.split(',') : PHASE_ATTRIBUTES; - fields = _.intersection(fields, PHASE_ATTRIBUTES); + fields = _.intersection(fields, [...PHASE_ATTRIBUTES, 'products']); if (_.indexOf(fields, 'id') < 0) { fields.push('id'); } diff --git a/src/routes/phases/update.js b/src/routes/phases/update.js index 861a459f..389157f2 100644 --- a/src/routes/phases/update.js +++ b/src/routes/phases/update.js @@ -82,7 +82,8 @@ module.exports = [ existing.save().then(accept).catch(reject); } } - })).then((updated) => { + }))) + .then((updated) => { req.log.debug('updated project phase', JSON.stringify(updated, null, 2)); // emit original and updated project phase information @@ -95,6 +96,7 @@ module.exports = [ { req, original: previousValue, updated }); res.json(util.wrapResponse(req.id, updated)); - }).catch(err => next(err))); + }) + .catch(err => next(err)); }, ]; diff --git a/src/routes/productCategories/create.js b/src/routes/productCategories/create.js new file mode 100644 index 00000000..e80bc596 --- /dev/null +++ b/src/routes/productCategories/create.js @@ -0,0 +1,61 @@ +/** + * API to add a product category + */ +import validate from 'express-validation'; +import _ from 'lodash'; +import Joi from 'joi'; +import { middleware as tcMiddleware } from 'tc-core-library-js'; +import util from '../../util'; +import models from '../../models'; + +const permissions = tcMiddleware.permissions; + +const schema = { + body: { + param: Joi.object().keys({ + key: Joi.string().max(45).required(), + displayName: Joi.string().max(255).required(), + icon: Joi.string().max(255).required(), + question: Joi.string().max(255).required(), + info: Joi.string().max(255).required(), + aliases: Joi.array().required(), + disabled: Joi.boolean().optional(), + hidden: Joi.boolean().optional(), + createdAt: Joi.any().strip(), + updatedAt: Joi.any().strip(), + deletedAt: Joi.any().strip(), + createdBy: Joi.any().strip(), + updatedBy: Joi.any().strip(), + deletedBy: Joi.any().strip(), + }).required(), + }, +}; + +module.exports = [ + validate(schema), + permissions('productCategory.create'), + (req, res, next) => { + const entity = _.assign(req.body.param, { + createdBy: req.authUser.userId, + updatedBy: req.authUser.userId, + }); + + // Check if duplicated key + return models.ProductCategory.findById(req.body.param.key) + .then((existing) => { + if (existing) { + const apiErr = new Error(`Product category already exists for key ${req.params.key}`); + apiErr.status = 422; + return Promise.reject(apiErr); + } + + // Create + return models.ProductCategory.create(entity); + }).then((createdEntity) => { + // Omit deletedAt, deletedBy + res.status(201).json(util.wrapResponse( + req.id, _.omit(createdEntity.toJSON(), 'deletedAt', 'deletedBy'), 1, 201)); + }) + .catch(next); + }, +]; diff --git a/src/routes/productCategories/create.spec.js b/src/routes/productCategories/create.spec.js new file mode 100644 index 00000000..7b8f0089 --- /dev/null +++ b/src/routes/productCategories/create.spec.js @@ -0,0 +1,222 @@ +/** + * Tests for create.js + */ +import _ from 'lodash'; +import chai from 'chai'; +import request from 'supertest'; + +import server from '../../app'; +import testUtil from '../../tests/util'; +import models from '../../models'; + +const should = chai.should(); + +describe('CREATE product category', () => { + beforeEach(() => testUtil.clearDb() + .then(() => models.ProductCategory.create({ + key: 'key1', + displayName: 'displayName 1', + icon: 'http://example.com/icon1.ico', + question: 'question 1', + info: 'info 1', + aliases: ['key-1', 'key_1'], + disabled: false, + hidden: false, + createdBy: 1, + updatedBy: 1, + })).then(() => Promise.resolve()), + ); + after(testUtil.clearDb); + + describe('POST /productCategories', () => { + const body = { + param: { + key: 'app_dev', + displayName: 'Application Development', + icon: 'prod-cat-app-icon', + info: 'Application Development Info', + question: 'What kind of devlopment you need?', + aliases: ['key-1', 'key_1'], + disabled: true, + hidden: true, + }, + }; + + it('should return 403 if user is not authenticated', (done) => { + request(server) + .post('/v4/productCategories') + .send(body) + .expect(403, done); + }); + + it('should return 403 for member', (done) => { + request(server) + .post('/v4/productCategories') + .set({ + Authorization: `Bearer ${testUtil.jwts.member}`, + }) + .send(body) + .expect(403, done); + }); + + it('should return 403 for copilot', (done) => { + request(server) + .post('/v4/productCategories') + .set({ + Authorization: `Bearer ${testUtil.jwts.copilot}`, + }) + .send(body) + .expect(403, done); + }); + + it('should return 403 for manager', (done) => { + request(server) + .post('/v4/productCategories') + .set({ + Authorization: `Bearer ${testUtil.jwts.manager}`, + }) + .send(body) + .expect(403, done); + }); + + it('should return 422 for missing key', (done) => { + const invalidBody = _.cloneDeep(body); + delete invalidBody.param.key; + + request(server) + .post('/v4/productCategories') + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .send(invalidBody) + .expect('Content-Type', /json/) + .expect(422, done); + }); + + it('should return 422 for missing displayName', (done) => { + const invalidBody = _.cloneDeep(body); + delete invalidBody.param.displayName; + + request(server) + .post('/v4/productCategories') + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .send(invalidBody) + .expect('Content-Type', /json/) + .expect(422, done); + }); + + it('should return 422 for missing icon', (done) => { + const invalidBody = _.cloneDeep(body); + delete invalidBody.param.icon; + + request(server) + .post('/v4/productCategories') + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .send(invalidBody) + .expect('Content-Type', /json/) + .expect(422, done); + }); + + it('should return 422 for missing question', (done) => { + const invalidBody = _.cloneDeep(body); + delete invalidBody.param.question; + + request(server) + .post('/v4/productCategories') + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .send(invalidBody) + .expect('Content-Type', /json/) + .expect(422, done); + }); + + it('should return 422 for missing info', (done) => { + const invalidBody = _.cloneDeep(body); + delete invalidBody.param.info; + + request(server) + .post('/v4/productCategories') + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .send(invalidBody) + .expect('Content-Type', /json/) + .expect(422, done); + }); + + it('should return 422 for duplicated key', (done) => { + const invalidBody = _.cloneDeep(body); + invalidBody.param.key = 'key1'; + + request(server) + .post('/v4/productCategories') + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .send(invalidBody) + .expect('Content-Type', /json/) + .expect(422, done); + }); + + it('should return 201 for admin', (done) => { + request(server) + .post('/v4/productCategories') + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .send(body) + .expect('Content-Type', /json/) + .expect(201) + .end((err, res) => { + const resJson = res.body.result.content; + resJson.key.should.be.eql(body.param.key); + resJson.displayName.should.be.eql(body.param.displayName); + resJson.icon.should.be.eql(body.param.icon); + resJson.info.should.be.eql(body.param.info); + resJson.question.should.be.eql(body.param.question); + resJson.aliases.should.be.eql(body.param.aliases); + resJson.disabled.should.be.eql(body.param.disabled); + resJson.hidden.should.be.eql(body.param.hidden); + + resJson.createdBy.should.be.eql(40051333); // admin + should.exist(resJson.createdAt); + resJson.updatedBy.should.be.eql(40051333); // admin + should.exist(resJson.updatedAt); + should.not.exist(resJson.deletedBy); + should.not.exist(resJson.deletedAt); + + done(); + }); + }); + + it('should return 201 for connect admin', (done) => { + request(server) + .post('/v4/productCategories') + .set({ + Authorization: `Bearer ${testUtil.jwts.connectAdmin}`, + }) + .send(body) + .expect('Content-Type', /json/) + .expect(201) + .end((err, res) => { + const resJson = res.body.result.content; + resJson.key.should.be.eql(body.param.key); + resJson.displayName.should.be.eql(body.param.displayName); + resJson.icon.should.be.eql(body.param.icon); + resJson.info.should.be.eql(body.param.info); + resJson.question.should.be.eql(body.param.question); + resJson.aliases.should.be.eql(body.param.aliases); + resJson.disabled.should.be.eql(body.param.disabled); + resJson.hidden.should.be.eql(body.param.hidden); + resJson.createdBy.should.be.eql(40051336); // connect admin + resJson.updatedBy.should.be.eql(40051336); // connect admin + done(); + }); + }); + }); +}); diff --git a/src/routes/productCategories/delete.js b/src/routes/productCategories/delete.js new file mode 100644 index 00000000..89f575bd --- /dev/null +++ b/src/routes/productCategories/delete.js @@ -0,0 +1,37 @@ +/** + * API to delete a product category + */ +import validate from 'express-validation'; +import Joi from 'joi'; +import { middleware as tcMiddleware } from 'tc-core-library-js'; +import models from '../../models'; + +const permissions = tcMiddleware.permissions; + +const schema = { + params: { + key: Joi.string().max(45).required(), + }, +}; + +module.exports = [ + validate(schema), + permissions('productCategory.delete'), + (req, res, next) => + models.sequelize.transaction(() => + models.ProductCategory.findById(req.params.key) + .then((entity) => { + if (!entity) { + const apiErr = new Error(`Product category not found for key ${req.params.key}`); + apiErr.status = 404; + return Promise.reject(apiErr); + } + // Update the deletedBy, then delete + return entity.update({ deletedBy: req.authUser.userId }); + }) + .then(entity => entity.destroy())) + .then(() => { + res.status(204).end(); + }) + .catch(next), +]; diff --git a/src/routes/productCategories/delete.spec.js b/src/routes/productCategories/delete.spec.js new file mode 100644 index 00000000..dc33a92f --- /dev/null +++ b/src/routes/productCategories/delete.spec.js @@ -0,0 +1,130 @@ +/** + * Tests for delete.js + */ +import request from 'supertest'; +import chai from 'chai'; + +import models from '../../models'; +import server from '../../app'; +import testUtil from '../../tests/util'; + + +const expectAfterDelete = (key, err, next) => { + if (err) throw err; + setTimeout(() => + models.ProductCategory.findOne({ + where: { + key, + }, + paranoid: false, + }) + .then((res) => { + if (!res) { + throw new Error('Should found the entity'); + } else { + chai.assert.isNotNull(res.deletedAt); + chai.assert.isNotNull(res.deletedBy); + + request(server) + .get(`/v4/productCategories/${key}`) + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .expect(404, next); + } + }), 500); +}; + +describe('DELETE product category', () => { + const key = 'key1'; + + beforeEach(() => testUtil.clearDb() + .then(() => models.ProductCategory.create({ + key: 'key1', + displayName: 'displayName 1', + icon: 'http://example.com/icon1.ico', + question: 'question 1', + info: 'info 1', + aliases: ['key-1', 'key_1'], + createdBy: 1, + updatedBy: 1, + })).then(() => Promise.resolve()), + ); + after(testUtil.clearDb); + + describe('DELETE /productCategories/{key}', () => { + it('should return 403 if user is not authenticated', (done) => { + request(server) + .delete(`/v4/productCategories/${key}`) + .expect(403, done); + }); + + it('should return 403 for member', (done) => { + request(server) + .delete(`/v4/productCategories/${key}`) + .set({ + Authorization: `Bearer ${testUtil.jwts.member}`, + }) + .expect(403, done); + }); + + it('should return 403 for copilot', (done) => { + request(server) + .delete(`/v4/productCategories/${key}`) + .set({ + Authorization: `Bearer ${testUtil.jwts.copilot}`, + }) + .expect(403, done); + }); + + it('should return 403 for manager', (done) => { + request(server) + .delete(`/v4/productCategories/${key}`) + .set({ + Authorization: `Bearer ${testUtil.jwts.manager}`, + }) + .expect(403, done); + }); + + it('should return 404 for non-existed product category', (done) => { + request(server) + .delete('/v4/productCategories/not_existed') + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .expect(404, done); + }); + + it('should return 404 for deleted product category', (done) => { + models.ProductCategory.destroy({ where: { key } }) + .then(() => { + request(server) + .delete(`/v4/productCategories/${key}`) + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .expect(404, done); + }); + }); + + it('should return 204, for admin, if the product category was successfully removed', (done) => { + request(server) + .delete(`/v4/productCategories/${key}`) + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .expect(204) + .end(err => expectAfterDelete(key, err, done)); + }); + + it('should return 204, for connect admin, if the product category was successfully removed', (done) => { + request(server) + .delete(`/v4/productCategories/${key}`) + .set({ + Authorization: `Bearer ${testUtil.jwts.connectAdmin}`, + }) + .expect(204) + .end(err => expectAfterDelete(key, err, done)); + }); + }); +}); diff --git a/src/routes/productCategories/get.js b/src/routes/productCategories/get.js new file mode 100644 index 00000000..f113859b --- /dev/null +++ b/src/routes/productCategories/get.js @@ -0,0 +1,39 @@ +/** + * API to get a product category + */ +import validate from 'express-validation'; +import Joi from 'joi'; +import { middleware as tcMiddleware } from 'tc-core-library-js'; +import util from '../../util'; +import models from '../../models'; + +const permissions = tcMiddleware.permissions; + +const schema = { + params: { + key: Joi.string().max(45).required(), + }, +}; + +module.exports = [ + validate(schema), + permissions('productCategory.view'), + (req, res, next) => models.ProductCategory.findOne({ + where: { + key: req.params.key, + }, + attributes: { exclude: ['deletedAt', 'deletedBy'] }, + }) + .then((productCategory) => { + // Not found + if (!productCategory) { + const apiErr = new Error(`Product category not found for key ${req.params.key}`); + apiErr.status = 404; + return Promise.reject(apiErr); + } + + res.json(util.wrapResponse(req.id, productCategory)); + return Promise.resolve(); + }) + .catch(next), +]; diff --git a/src/routes/productCategories/get.spec.js b/src/routes/productCategories/get.spec.js new file mode 100644 index 00000000..9d06dbf4 --- /dev/null +++ b/src/routes/productCategories/get.spec.js @@ -0,0 +1,129 @@ +/** + * Tests for get.js + */ +import chai from 'chai'; +import request from 'supertest'; + +import models from '../../models'; +import server from '../../app'; +import testUtil from '../../tests/util'; + +const should = chai.should(); + +describe('GET product category', () => { + const productCategory = { + key: 'key1', + displayName: 'displayName 1', + icon: 'http://example.com/icon1.ico', + question: 'question 1', + info: 'info 1', + aliases: ['key-1', 'key_1'], + disabled: true, + hidden: true, + createdBy: 1, + updatedBy: 1, + }; + + const key = productCategory.key; + + beforeEach(() => testUtil.clearDb() + .then(() => models.ProductCategory.create(productCategory)) + .then(() => Promise.resolve()), + ); + after(testUtil.clearDb); + + describe('GET /productCategories/{key}', () => { + it('should return 404 for non-existed product category', (done) => { + request(server) + .get('/v4/productCategories/1234') + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .expect(404, done); + }); + + it('should return 404 for deleted product category', (done) => { + models.ProductCategory.destroy({ where: { key } }) + .then(() => { + request(server) + .get(`/v4/productCategories/${key}`) + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .expect(404, done); + }); + }); + + it('should return 200 for admin', (done) => { + request(server) + .get(`/v4/productCategories/${key}`) + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .expect(200) + .end((err, res) => { + const resJson = res.body.result.content; + resJson.key.should.be.eql(productCategory.key); + resJson.displayName.should.be.eql(productCategory.displayName); + resJson.icon.should.be.eql(productCategory.icon); + resJson.info.should.be.eql(productCategory.info); + resJson.question.should.be.eql(productCategory.question); + resJson.aliases.should.be.eql(productCategory.aliases); + resJson.disabled.should.be.eql(productCategory.disabled); + resJson.hidden.should.be.eql(productCategory.hidden); + resJson.createdBy.should.be.eql(productCategory.createdBy); + should.exist(resJson.createdAt); + resJson.updatedBy.should.be.eql(productCategory.updatedBy); + should.exist(resJson.updatedAt); + should.not.exist(resJson.deletedBy); + should.not.exist(resJson.deletedAt); + + done(); + }); + }); + + it('should return 200 even if user is not authenticated', (done) => { + request(server) + .get(`/v4/productCategories/${key}`) + .expect(200, done); + }); + + it('should return 200 for connect admin', (done) => { + request(server) + .get(`/v4/productCategories/${key}`) + .set({ + Authorization: `Bearer ${testUtil.jwts.connectAdmin}`, + }) + .expect(200) + .end(done); + }); + + it('should return 200 for connect manager', (done) => { + request(server) + .get(`/v4/productCategories/${key}`) + .set({ + Authorization: `Bearer ${testUtil.jwts.manager}`, + }) + .expect(200) + .end(done); + }); + + it('should return 200 for member', (done) => { + request(server) + .get(`/v4/productCategories/${key}`) + .set({ + Authorization: `Bearer ${testUtil.jwts.member}`, + }) + .expect(200, done); + }); + + it('should return 200 for copilot', (done) => { + request(server) + .get(`/v4/productCategories/${key}`) + .set({ + Authorization: `Bearer ${testUtil.jwts.copilot}`, + }) + .expect(200, done); + }); + }); +}); diff --git a/src/routes/productCategories/list.js b/src/routes/productCategories/list.js new file mode 100644 index 00000000..abc2a9e7 --- /dev/null +++ b/src/routes/productCategories/list.js @@ -0,0 +1,20 @@ +/** + * API to list all product categories + */ +import { middleware as tcMiddleware } from 'tc-core-library-js'; +import util from '../../util'; +import models from '../../models'; + +const permissions = tcMiddleware.permissions; + +module.exports = [ + permissions('productCategory.view'), + (req, res, next) => models.ProductCategory.findAll({ + attributes: { exclude: ['deletedAt', 'deletedBy'] }, + raw: true, + }) + .then((productCategories) => { + res.json(util.wrapResponse(req.id, productCategories)); + }) + .catch(next), +]; diff --git a/src/routes/productCategories/list.spec.js b/src/routes/productCategories/list.spec.js new file mode 100644 index 00000000..e0e56ec7 --- /dev/null +++ b/src/routes/productCategories/list.spec.js @@ -0,0 +1,124 @@ +/** + * Tests for list.js + */ +import chai from 'chai'; +import request from 'supertest'; + +import models from '../../models'; +import server from '../../app'; +import testUtil from '../../tests/util'; + +const should = chai.should(); + +describe('LIST product categories', () => { + const productCategories = [ + { + key: 'key1', + displayName: 'displayName 1', + icon: 'http://example.com/icon1.ico', + question: 'question 1', + info: 'info 1', + aliases: ['key-1', 'key_1'], + disabled: true, + hidden: true, + createdBy: 1, + updatedBy: 1, + }, + { + key: 'key2', + displayName: 'displayName 2', + icon: 'http://example.com/icon2.ico', + question: 'question 2', + info: 'info 2', + aliases: ['key-2', 'key_2'], + disabled: true, + hidden: true, + createdBy: 1, + updatedBy: 1, + }, + ]; + + beforeEach(() => testUtil.clearDb() + .then(() => models.ProductCategory.create(productCategories[0])) + .then(() => models.ProductCategory.create(productCategories[1])) + .then(() => Promise.resolve()), + ); + after(testUtil.clearDb); + + describe('GET /productCategories', () => { + it('should return 200 for admin', (done) => { + request(server) + .get('/v4/productCategories') + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .expect(200) + .end((err, res) => { + const type = productCategories[0]; + + const resJson = res.body.result.content; + resJson.should.have.length(2); + resJson[0].key.should.be.eql(type.key); + resJson[0].displayName.should.be.eql(type.displayName); + resJson[0].icon.should.be.eql(type.icon); + resJson[0].info.should.be.eql(type.info); + resJson[0].question.should.be.eql(type.question); + resJson[0].aliases.should.be.eql(type.aliases); + resJson[0].createdBy.should.be.eql(type.createdBy); + resJson[0].disabled.should.be.eql(type.disabled); + resJson[0].hidden.should.be.eql(type.hidden); + should.exist(resJson[0].createdAt); + resJson[0].updatedBy.should.be.eql(type.updatedBy); + should.exist(resJson[0].updatedAt); + should.not.exist(resJson[0].deletedBy); + should.not.exist(resJson[0].deletedAt); + + done(); + }); + }); + + it('should return 200 even if user is not authenticated', (done) => { + request(server) + .get('/v4/productCategories') + .expect(200, done); + }); + + it('should return 200 for connect admin', (done) => { + request(server) + .get('/v4/productCategories') + .set({ + Authorization: `Bearer ${testUtil.jwts.connectAdmin}`, + }) + .expect(200) + .end(done); + }); + + it('should return 200 for connect manager', (done) => { + request(server) + .get('/v4/productCategories') + .set({ + Authorization: `Bearer ${testUtil.jwts.manager}`, + }) + .expect(200) + .end(done); + }); + + it('should return 200 for member', (done) => { + request(server) + .get('/v4/productCategories') + .set({ + Authorization: `Bearer ${testUtil.jwts.member}`, + }) + .expect(200, done); + }); + + it('should return 200 for copilot', (done) => { + request(server) + .get('/v4/productCategories') + .set({ + Authorization: `Bearer ${testUtil.jwts.copilot}`, + }) + .expect(200, done); + }); + }); +}); diff --git a/src/routes/productCategories/update.js b/src/routes/productCategories/update.js new file mode 100644 index 00000000..68958966 --- /dev/null +++ b/src/routes/productCategories/update.js @@ -0,0 +1,67 @@ +/** + * API to update a product category + */ +import validate from 'express-validation'; +import _ from 'lodash'; +import Joi from 'joi'; +import { middleware as tcMiddleware } from 'tc-core-library-js'; +import util from '../../util'; +import models from '../../models'; + +const permissions = tcMiddleware.permissions; + +const schema = { + params: { + key: Joi.string().max(45).required(), + }, + body: { + param: Joi.object().keys({ + key: Joi.any().strip(), + displayName: Joi.string().max(255).optional(), + icon: Joi.string().max(255).optional(), + question: Joi.string().max(255).optional(), + info: Joi.string().max(255).optional(), + aliases: Joi.array().optional(), + disabled: Joi.boolean().optional(), + hidden: Joi.boolean().optional(), + createdAt: Joi.any().strip(), + updatedAt: Joi.any().strip(), + deletedAt: Joi.any().strip(), + createdBy: Joi.any().strip(), + updatedBy: Joi.any().strip(), + deletedBy: Joi.any().strip(), + }).required(), + }, +}; + +module.exports = [ + validate(schema), + permissions('productCategory.edit'), + (req, res, next) => { + const entityToUpdate = _.assign(req.body.param, { + updatedBy: req.authUser.userId, + }); + + return models.ProductCategory.findOne({ + where: { + key: req.params.key, + }, + attributes: { exclude: ['deletedAt', 'deletedBy'] }, + }) + .then((productCategory) => { + // Not found + if (!productCategory) { + const apiErr = new Error(`Product category not found for key ${req.params.key}`); + apiErr.status = 404; + return Promise.reject(apiErr); + } + + return productCategory.update(entityToUpdate); + }) + .then((productCategory) => { + res.json(util.wrapResponse(req.id, productCategory)); + return Promise.resolve(); + }) + .catch(next); + }, +]; diff --git a/src/routes/productCategories/update.spec.js b/src/routes/productCategories/update.spec.js new file mode 100644 index 00000000..7b877553 --- /dev/null +++ b/src/routes/productCategories/update.spec.js @@ -0,0 +1,411 @@ +/** + * Tests for get.js + */ +import _ from 'lodash'; +import chai from 'chai'; +import request from 'supertest'; + +import models from '../../models'; +import server from '../../app'; +import testUtil from '../../tests/util'; + +const should = chai.should(); + +describe('UPDATE product category', () => { + const productCategory = { + key: 'key1', + displayName: 'displayName 1', + icon: 'http://example.com/icon1.ico', + question: 'question 1', + info: 'info 1', + aliases: ['key-1', 'key_1'], + disabled: false, + hidden: false, + createdBy: 1, + updatedBy: 1, + }; + const key = productCategory.key; + + beforeEach(() => testUtil.clearDb() + .then(() => models.ProductCategory.create(productCategory)) + .then(() => Promise.resolve()), + ); + after(testUtil.clearDb); + + describe('PATCH /productCategories/{key}', () => { + const body = { + param: { + displayName: 'displayName 1 - update', + icon: 'http://example.com/icon1.ico - update', + question: 'question 1 - update', + info: 'info 1 - update', + aliases: ['key-1-updated', 'key_1_updated'], + disabled: true, + hidden: true, + }, + }; + + it('should return 403 if user is not authenticated', (done) => { + request(server) + .patch(`/v4/productCategories/${key}`) + .send(body) + .expect(403, done); + }); + + it('should return 403 for member', (done) => { + request(server) + .patch(`/v4/productCategories/${key}`) + .set({ + Authorization: `Bearer ${testUtil.jwts.member}`, + }) + .send(body) + .expect(403, done); + }); + + it('should return 403 for copilot', (done) => { + request(server) + .patch(`/v4/productCategories/${key}`) + .send(body) + .set({ + Authorization: `Bearer ${testUtil.jwts.copilot}`, + }) + .expect(403, done); + }); + + it('should return 403 for manager', (done) => { + request(server) + .patch(`/v4/productCategories/${key}`) + .send(body) + .set({ + Authorization: `Bearer ${testUtil.jwts.manager}`, + }) + .expect(403, done); + }); + + it('should return 404 for non-existed product category', (done) => { + request(server) + .patch('/v4/productCategories/1234') + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .send(body) + .expect(404, done); + }); + + it('should return 404 for deleted product category', (done) => { + models.ProductCategory.destroy({ where: { key } }) + .then(() => { + request(server) + .patch(`/v4/productCategories/${key}`) + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .send(body) + .expect(404, done); + }); + }); + + it('should return 200 for admin displayName updated', (done) => { + const partialBody = _.cloneDeep(body); + delete partialBody.param.icon; + delete partialBody.param.info; + delete partialBody.param.question; + delete partialBody.param.aliases; + delete partialBody.param.disabled; + delete partialBody.param.hidden; + request(server) + .patch(`/v4/productCategories/${key}`) + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .send(partialBody) + .expect(200) + .end((err, res) => { + const resJson = res.body.result.content; + resJson.key.should.be.eql(key); + resJson.displayName.should.be.eql(partialBody.param.displayName); + resJson.icon.should.be.eql(productCategory.icon); + resJson.info.should.be.eql(productCategory.info); + resJson.question.should.be.eql(productCategory.question); + resJson.aliases.should.be.eql(productCategory.aliases); + resJson.disabled.should.be.eql(productCategory.disabled); + resJson.hidden.should.be.eql(productCategory.hidden); + resJson.createdBy.should.be.eql(productCategory.createdBy); + resJson.createdBy.should.be.eql(productCategory.createdBy); // should not update createdAt + resJson.updatedBy.should.be.eql(40051333); // admin + should.exist(resJson.updatedAt); + should.not.exist(resJson.deletedBy); + should.not.exist(resJson.deletedAt); + + done(); + }); + }); + + it('should return 200 for admin icon updated', (done) => { + const partialBody = _.cloneDeep(body); + delete partialBody.param.info; + delete partialBody.param.displayName; + delete partialBody.param.question; + delete partialBody.param.aliases; + delete partialBody.param.disabled; + delete partialBody.param.hidden; + request(server) + .patch(`/v4/productCategories/${key}`) + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .send(partialBody) + .expect(200) + .end((err, res) => { + const resJson = res.body.result.content; + resJson.key.should.be.eql(key); + resJson.displayName.should.be.eql(productCategory.displayName); + resJson.icon.should.be.eql(partialBody.param.icon); + resJson.info.should.be.eql(productCategory.info); + resJson.question.should.be.eql(productCategory.question); + resJson.aliases.should.be.eql(productCategory.aliases); + resJson.disabled.should.be.eql(productCategory.disabled); + resJson.hidden.should.be.eql(productCategory.hidden); + resJson.createdBy.should.be.eql(productCategory.createdBy); + resJson.createdBy.should.be.eql(productCategory.createdBy); // should not update createdAt + resJson.updatedBy.should.be.eql(40051333); // admin + should.exist(resJson.updatedAt); + should.not.exist(resJson.deletedBy); + should.not.exist(resJson.deletedAt); + + done(); + }); + }); + + it('should return 200 for admin info updated', (done) => { + const partialBody = _.cloneDeep(body); + delete partialBody.param.icon; + delete partialBody.param.displayName; + delete partialBody.param.question; + delete partialBody.param.aliases; + delete partialBody.param.disabled; + delete partialBody.param.hidden; + request(server) + .patch(`/v4/productCategories/${key}`) + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .send(partialBody) + .expect(200) + .end((err, res) => { + const resJson = res.body.result.content; + resJson.key.should.be.eql(key); + resJson.displayName.should.be.eql(productCategory.displayName); + resJson.icon.should.be.eql(productCategory.icon); + resJson.info.should.be.eql(partialBody.param.info); + resJson.question.should.be.eql(productCategory.question); + resJson.aliases.should.be.eql(productCategory.aliases); + resJson.disabled.should.be.eql(productCategory.disabled); + resJson.hidden.should.be.eql(productCategory.hidden); + resJson.createdBy.should.be.eql(productCategory.createdBy); + resJson.createdBy.should.be.eql(productCategory.createdBy); // should not update createdAt + resJson.updatedBy.should.be.eql(40051333); // admin + should.exist(resJson.updatedAt); + should.not.exist(resJson.deletedBy); + should.not.exist(resJson.deletedAt); + + done(); + }); + }); + + it('should return 200 for admin question updated', (done) => { + const partialBody = _.cloneDeep(body); + delete partialBody.param.icon; + delete partialBody.param.info; + delete partialBody.param.displayName; + delete partialBody.param.aliases; + delete partialBody.param.disabled; + delete partialBody.param.hidden; + request(server) + .patch(`/v4/productCategories/${key}`) + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .send(partialBody) + .expect(200) + .end((err, res) => { + const resJson = res.body.result.content; + resJson.key.should.be.eql(key); + resJson.displayName.should.be.eql(productCategory.displayName); + resJson.icon.should.be.eql(productCategory.icon); + resJson.info.should.be.eql(productCategory.info); + resJson.question.should.be.eql(partialBody.param.question); + resJson.aliases.should.be.eql(productCategory.aliases); + resJson.disabled.should.be.eql(productCategory.disabled); + resJson.hidden.should.be.eql(productCategory.hidden); + resJson.createdBy.should.be.eql(productCategory.createdBy); + resJson.createdBy.should.be.eql(productCategory.createdBy); // should not update createdAt + resJson.updatedBy.should.be.eql(40051333); // admin + should.exist(resJson.updatedAt); + should.not.exist(resJson.deletedBy); + should.not.exist(resJson.deletedAt); + + done(); + }); + }); + + it('should return 200 for admin aliases updated', (done) => { + const partialBody = _.cloneDeep(body); + delete partialBody.param.icon; + delete partialBody.param.info; + delete partialBody.param.question; + delete partialBody.param.displayName; + delete partialBody.param.disabled; + delete partialBody.param.hidden; + request(server) + .patch(`/v4/productCategories/${key}`) + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .send(partialBody) + .expect(200) + .end((err, res) => { + const resJson = res.body.result.content; + resJson.key.should.be.eql(key); + resJson.displayName.should.be.eql(productCategory.displayName); + resJson.icon.should.be.eql(productCategory.icon); + resJson.info.should.be.eql(productCategory.info); + resJson.question.should.be.eql(productCategory.question); + resJson.aliases.should.be.eql(partialBody.param.aliases); + resJson.disabled.should.be.eql(productCategory.disabled); + resJson.hidden.should.be.eql(productCategory.hidden); + resJson.createdBy.should.be.eql(productCategory.createdBy); + resJson.createdBy.should.be.eql(productCategory.createdBy); // should not update createdAt + resJson.updatedBy.should.be.eql(40051333); // admin + should.exist(resJson.updatedAt); + should.not.exist(resJson.deletedBy); + should.not.exist(resJson.deletedAt); + + done(); + }); + }); + + it('should return 200 for admin disabled updated', (done) => { + const partialBody = _.cloneDeep(body); + delete partialBody.param.icon; + delete partialBody.param.info; + delete partialBody.param.question; + delete partialBody.param.displayName; + delete partialBody.param.aliases; + delete partialBody.param.hidden; + request(server) + .patch(`/v4/productCategories/${key}`) + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .send(partialBody) + .expect(200) + .end((err, res) => { + const resJson = res.body.result.content; + resJson.key.should.be.eql(key); + resJson.displayName.should.be.eql(productCategory.displayName); + resJson.icon.should.be.eql(productCategory.icon); + resJson.info.should.be.eql(productCategory.info); + resJson.question.should.be.eql(productCategory.question); + resJson.aliases.should.be.eql(productCategory.aliases); + resJson.disabled.should.be.eql(partialBody.param.disabled); + resJson.hidden.should.be.eql(productCategory.hidden); + resJson.createdBy.should.be.eql(productCategory.createdBy); + resJson.createdBy.should.be.eql(productCategory.createdBy); // should not update createdAt + resJson.updatedBy.should.be.eql(40051333); // admin + should.exist(resJson.updatedAt); + should.not.exist(resJson.deletedBy); + should.not.exist(resJson.deletedAt); + + done(); + }); + }); + + it('should return 200 for admin hidden updated', (done) => { + const partialBody = _.cloneDeep(body); + delete partialBody.param.icon; + delete partialBody.param.info; + delete partialBody.param.question; + delete partialBody.param.displayName; + delete partialBody.param.disabled; + delete partialBody.param.aliases; + request(server) + .patch(`/v4/productCategories/${key}`) + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .send(partialBody) + .expect(200) + .end((err, res) => { + const resJson = res.body.result.content; + resJson.key.should.be.eql(key); + resJson.displayName.should.be.eql(productCategory.displayName); + resJson.icon.should.be.eql(productCategory.icon); + resJson.info.should.be.eql(productCategory.info); + resJson.question.should.be.eql(productCategory.question); + resJson.aliases.should.be.eql(productCategory.aliases); + resJson.disabled.should.be.eql(productCategory.disabled); + resJson.hidden.should.be.eql(partialBody.param.hidden); + resJson.createdBy.should.be.eql(productCategory.createdBy); // should not update createdAt + resJson.updatedBy.should.be.eql(40051333); // admin + should.exist(resJson.updatedAt); + should.not.exist(resJson.deletedBy); + should.not.exist(resJson.deletedAt); + + done(); + }); + }); + + it('should return 200 for admin all fields updated', (done) => { + request(server) + .patch(`/v4/productCategories/${key}`) + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .send(body) + .expect(200) + .end((err, res) => { + const resJson = res.body.result.content; + resJson.key.should.be.eql(key); + resJson.displayName.should.be.eql(body.param.displayName); + resJson.icon.should.be.eql(body.param.icon); + resJson.info.should.be.eql(body.param.info); + resJson.question.should.be.eql(body.param.question); + resJson.aliases.should.be.eql(body.param.aliases); + resJson.disabled.should.be.eql(body.param.disabled); + resJson.hidden.should.be.eql(body.param.hidden); + resJson.createdBy.should.be.eql(productCategory.createdBy); // should not update createdAt + resJson.updatedBy.should.be.eql(40051333); // admin + should.exist(resJson.updatedAt); + should.not.exist(resJson.deletedBy); + should.not.exist(resJson.deletedAt); + + done(); + }); + }); + + it('should return 200 for connect admin', (done) => { + request(server) + .patch(`/v4/productCategories/${key}`) + .set({ + Authorization: `Bearer ${testUtil.jwts.connectAdmin}`, + }) + .send(body) + .expect(200) + .end((err, res) => { + const resJson = res.body.result.content; + resJson.key.should.be.eql(key); + resJson.displayName.should.be.eql(body.param.displayName); + resJson.icon.should.be.eql(body.param.icon); + resJson.info.should.be.eql(body.param.info); + resJson.question.should.be.eql(body.param.question); + resJson.aliases.should.be.eql(body.param.aliases); + resJson.disabled.should.be.eql(body.param.disabled); + resJson.hidden.should.be.eql(body.param.hidden); + resJson.createdBy.should.be.eql(productCategory.createdBy); // should not update createdAt + resJson.updatedBy.should.be.eql(40051336); // connect admin + done(); + }); + }); + }); +}); diff --git a/src/routes/productTemplates/create.js b/src/routes/productTemplates/create.js index f0ab51b0..8f47ec4f 100644 --- a/src/routes/productTemplates/create.js +++ b/src/routes/productTemplates/create.js @@ -5,6 +5,7 @@ import validate from 'express-validation'; import _ from 'lodash'; import Joi from 'joi'; import { middleware as tcMiddleware } from 'tc-core-library-js'; +import fieldLookupValidation from '../../middlewares/fieldLookupValidation'; import util from '../../util'; import models from '../../models'; @@ -14,6 +15,7 @@ const schema = { body: { param: Joi.object().keys({ id: Joi.any().strip(), + category: Joi.string().max(45).required(), name: Joi.string().max(255).required(), productKey: Joi.string().max(45).required(), icon: Joi.string().max(255).required(), @@ -36,6 +38,7 @@ const schema = { module.exports = [ validate(schema), permissions('productTemplate.create'), + fieldLookupValidation(models.ProductCategory, 'key', 'body.param.category', 'Category'), (req, res, next) => { const entity = _.assign(req.body.param, { createdBy: req.authUser.userId, diff --git a/src/routes/productTemplates/create.spec.js b/src/routes/productTemplates/create.spec.js index c17fbc6c..846e429f 100644 --- a/src/routes/productTemplates/create.spec.js +++ b/src/routes/productTemplates/create.spec.js @@ -1,20 +1,40 @@ /** * Tests for create.js */ +import _ from 'lodash'; import chai from 'chai'; import request from 'supertest'; import server from '../../app'; import testUtil from '../../tests/util'; +import models from '../../models'; const should = chai.should(); describe('CREATE product template', () => { + before((done) => { + testUtil.clearDb() + .then(() => models.ProductCategory.bulkCreate([ + { + key: 'generic', + displayName: 'Generic', + icon: 'http://example.com/icon1.ico', + question: 'question 1', + info: 'info 1', + aliases: ['key-1', 'key_1'], + createdBy: 1, + updatedBy: 1, + }, + ])) + .then(() => done()); + }); + describe('POST /productTemplates', () => { const body = { param: { name: 'name 1', productKey: 'productKey 1', + category: 'generic', icon: 'http://example.com/icon1.ico', brief: 'brief 1', details: 'details 1', @@ -95,6 +115,32 @@ describe('CREATE product template', () => { .expect(422, done); }); + it('should return 422 if product category is missing', (done) => { + const invalidBody = _.cloneDeep(body); + invalidBody.param.category = null; + request(server) + .post('/v4/productTemplates') + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .send(invalidBody) + .expect('Content-Type', /json/) + .expect(422, done); + }); + + it('should return 422 if product category does not exist', (done) => { + const invalidBody = _.cloneDeep(body); + invalidBody.param.category = 'not_exist'; + request(server) + .post('/v4/productTemplates') + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .send(invalidBody) + .expect('Content-Type', /json/) + .expect(422, done); + }); + it('should return 201 for admin', (done) => { request(server) .post('/v4/productTemplates') @@ -109,6 +155,7 @@ describe('CREATE product template', () => { should.exist(resJson.id); resJson.name.should.be.eql(body.param.name); resJson.productKey.should.be.eql(body.param.productKey); + resJson.category.should.be.eql(body.param.category); resJson.icon.should.be.eql(body.param.icon); resJson.brief.should.be.eql(body.param.brief); resJson.details.should.be.eql(body.param.details); diff --git a/src/routes/productTemplates/delete.js b/src/routes/productTemplates/delete.js index 81c65b6b..03ce1d3b 100644 --- a/src/routes/productTemplates/delete.js +++ b/src/routes/productTemplates/delete.js @@ -17,39 +17,21 @@ const schema = { module.exports = [ validate(schema), permissions('productTemplate.delete'), - (req, res, next) => { - const where = { - deletedAt: { $eq: null }, - id: req.params.templateId, - }; - - return models.sequelize.transaction(tx => - // Update the deletedBy - models.ProductTemplate.update({ deletedBy: req.authUser.userId }, { - where, - returning: true, - raw: true, - transaction: tx, - }) - .then((updatedResults) => { - // Not found - if (updatedResults[0] === 0) { + (req, res, next) => + models.sequelize.transaction(() => + models.ProductTemplate.findById(req.params.templateId) + .then((entity) => { + if (!entity) { const apiErr = new Error(`Product template not found for template id ${req.params.templateId}`); apiErr.status = 404; return Promise.reject(apiErr); } - - // Soft delete - return models.ProductTemplate.destroy({ - where, - transaction: tx, - raw: true, - }); + // Update the deletedBy, then delete + return entity.update({ deletedBy: req.authUser.userId }); }) + .then(entity => entity.destroy())) .then(() => { res.status(204).end(); }) .catch(next), - ); - }, ]; diff --git a/src/routes/productTemplates/delete.spec.js b/src/routes/productTemplates/delete.spec.js index 3c79c12a..0f39eb15 100644 --- a/src/routes/productTemplates/delete.spec.js +++ b/src/routes/productTemplates/delete.spec.js @@ -2,11 +2,38 @@ * Tests for delete.js */ import request from 'supertest'; +import chai from 'chai'; import models from '../../models'; import server from '../../app'; import testUtil from '../../tests/util'; +const expectAfterDelete = (id, err, next) => { + if (err) throw err; + setTimeout(() => + models.ProductTemplate.findOne({ + where: { + id, + }, + paranoid: false, + }) + .then((res) => { + if (!res) { + throw new Error('Should found the entity'); + } else { + chai.assert.isNotNull(res.deletedAt); + chai.assert.isNotNull(res.deletedBy); + + request(server) + .get(`/v4/productTemplates/${id}`) + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .expect(404, next); + } + }), 500); +}; + describe('DELETE product template', () => { let templateId; @@ -15,6 +42,7 @@ describe('DELETE product template', () => { .then(() => models.ProductTemplate.create({ name: 'name 1', productKey: 'productKey 1', + category: 'generic', icon: 'http://example.com/icon1.ico', brief: 'brief 1', details: 'details 1', @@ -106,7 +134,7 @@ describe('DELETE product template', () => { Authorization: `Bearer ${testUtil.jwts.admin}`, }) .expect(204) - .end(done); + .end(err => expectAfterDelete(templateId, err, done)); }); it('should return 204, for connect admin, if template was successfully removed', (done) => { @@ -116,7 +144,7 @@ describe('DELETE product template', () => { Authorization: `Bearer ${testUtil.jwts.connectAdmin}`, }) .expect(204) - .end(done); + .end(err => expectAfterDelete(templateId, err, done)); }); }); }); diff --git a/src/routes/productTemplates/get.spec.js b/src/routes/productTemplates/get.spec.js index 185128c5..c690efca 100644 --- a/src/routes/productTemplates/get.spec.js +++ b/src/routes/productTemplates/get.spec.js @@ -14,6 +14,7 @@ describe('GET product template', () => { const template = { name: 'name 1', productKey: 'productKey 1', + category: 'generic', icon: 'http://example.com/icon1.ico', brief: 'brief 1', details: 'details 1', @@ -89,6 +90,7 @@ describe('GET product template', () => { resJson.id.should.be.eql(templateId); resJson.name.should.be.eql(template.name); resJson.productKey.should.be.eql(template.productKey); + resJson.category.should.be.eql(template.category); resJson.icon.should.be.eql(template.icon); resJson.brief.should.be.eql(template.brief); resJson.details.should.be.eql(template.details); diff --git a/src/routes/productTemplates/list.spec.js b/src/routes/productTemplates/list.spec.js index 81a4453c..b0e8c56f 100644 --- a/src/routes/productTemplates/list.spec.js +++ b/src/routes/productTemplates/list.spec.js @@ -14,11 +14,12 @@ import testUtil from '../../tests/util'; const validateProductTemplates = (count, resJson, expectedTemplates) => { resJson.should.have.length(count); resJson.forEach((pt, idx) => { - pt.should.have.all.keys('id', 'name', 'productKey', 'icon', 'brief', 'details', 'aliases', + pt.should.have.all.keys('id', 'name', 'productKey', 'category', 'icon', 'brief', 'details', 'aliases', 'template', 'disabled', 'hidden', 'createdBy', 'createdAt', 'updatedBy', 'updatedAt'); pt.should.not.have.all.keys('deletedAt', 'deletedBy'); pt.name.should.be.eql(expectedTemplates[idx].name); pt.productKey.should.be.eql(expectedTemplates[idx].productKey); + pt.category.should.be.eql(expectedTemplates[idx].category); pt.icon.should.be.eql(expectedTemplates[idx].icon); pt.brief.should.be.eql(expectedTemplates[idx].brief); pt.details.should.be.eql(expectedTemplates[idx].details); @@ -36,6 +37,7 @@ describe('LIST product templates', () => { { name: 'name 1', productKey: 'productKey-1', + category: 'generic', icon: 'http://example.com/icon1.ico', brief: 'brief 1', details: 'details 1', @@ -70,6 +72,7 @@ describe('LIST product templates', () => { { name: 'template 2', productKey: 'productKey-2', + category: 'concrete', icon: 'http://example.com/icon2.ico', brief: 'brief 2', details: 'details 2', diff --git a/src/routes/productTemplates/update.js b/src/routes/productTemplates/update.js index eb559fa2..82095299 100644 --- a/src/routes/productTemplates/update.js +++ b/src/routes/productTemplates/update.js @@ -5,6 +5,7 @@ import validate from 'express-validation'; import _ from 'lodash'; import Joi from 'joi'; import { middleware as tcMiddleware } from 'tc-core-library-js'; +import fieldLookupValidation from '../../middlewares/fieldLookupValidation'; import util from '../../util'; import models from '../../models'; @@ -19,6 +20,7 @@ const schema = { id: Joi.any().strip(), name: Joi.string().max(255), productKey: Joi.string().max(45), + category: Joi.string().max(45), icon: Joi.string().max(255), brief: Joi.string().max(45), details: Joi.string().max(255), @@ -39,6 +41,7 @@ const schema = { module.exports = [ validate(schema), permissions('productTemplate.edit'), + fieldLookupValidation(models.ProductCategory, 'key', 'body.param.category', 'Category'), (req, res, next) => { const entityToUpdate = _.assign(req.body.param, { updatedBy: req.authUser.userId, diff --git a/src/routes/productTemplates/update.spec.js b/src/routes/productTemplates/update.spec.js index 8b01afe1..80667f0f 100644 --- a/src/routes/productTemplates/update.spec.js +++ b/src/routes/productTemplates/update.spec.js @@ -14,6 +14,7 @@ describe('UPDATE product template', () => { const template = { name: 'name 1', productKey: 'productKey 1', + category: 'generic', icon: 'http://example.com/icon1.ico', brief: 'brief 1', details: 'details 1', @@ -49,6 +50,28 @@ describe('UPDATE product template', () => { let templateId; beforeEach(() => testUtil.clearDb() + .then(() => models.ProductCategory.bulkCreate([ + { + key: 'generic', + displayName: 'Generic', + icon: 'http://example.com/icon1.ico', + question: 'question 1', + info: 'info 1', + aliases: ['key-1', 'key_1'], + createdBy: 1, + updatedBy: 1, + }, + { + key: 'concrete', + displayName: 'Concrete', + icon: 'http://example.com/icon1.ico', + question: 'question 2', + info: 'info 2', + aliases: ['key-2', 'key_2'], + createdBy: 1, + updatedBy: 1, + }, + ])) .then(() => models.ProductTemplate.create(template)) .then((createdTemplate) => { templateId = createdTemplate.id; @@ -62,6 +85,7 @@ describe('UPDATE product template', () => { param: { name: 'template 1 - update', productKey: 'productKey 1 - update', + category: 'concrete', icon: 'http://example.com/icon1-update.ico', brief: 'brief 1 - update', details: 'details 1 - update', @@ -183,6 +207,7 @@ describe('UPDATE product template', () => { resJson.id.should.be.eql(templateId); resJson.name.should.be.eql(body.param.name); resJson.productKey.should.be.eql(body.param.productKey); + resJson.category.should.be.eql(body.param.category); resJson.icon.should.be.eql(body.param.icon); resJson.brief.should.be.eql(body.param.brief); resJson.details.should.be.eql(body.param.details); diff --git a/src/routes/projectMembers/delete.js b/src/routes/projectMembers/delete.js index d0f83ff3..43e1f037 100644 --- a/src/routes/projectMembers/delete.js +++ b/src/routes/projectMembers/delete.js @@ -24,12 +24,13 @@ module.exports = [ }) .then((member) => { if (!member) { - const err = new Error('Record not found'); + const err = new Error(`Project member not found for member id ${req.params.id}`); err.status = 404; return Promise.reject(err); } - return member.destroy({ logging: console.log }); // eslint-disable-line no-console + return member.update({ deletedBy: req.authUser.userId }); // eslint-disable-line no-console }) + .then(member => member.destroy({ logging: console.log })) .then(member => member.save()) // if primary co-pilot is removed promote the next co-pilot to primary #43 .then(member => new Promise((accept, reject) => { diff --git a/src/routes/projectMembers/delete.spec.js b/src/routes/projectMembers/delete.spec.js index e600742d..3667bcff 100644 --- a/src/routes/projectMembers/delete.spec.js +++ b/src/routes/projectMembers/delete.spec.js @@ -11,6 +11,24 @@ import testUtil from '../../tests/util'; const should = chai.should(); +const expectAfterDelete = (projectId, id, err, next) => { + if (err) throw err; + setTimeout(() => + models.ProjectMember.findOne({ + where: { + id, + projectId, + }, + paranoid: false, + }) + .then((res) => { + if (!res) { + throw new Error('Should found the entity'); + } else { + next(); + } + }), 500); +}; describe('Project members delete', () => { let project1; let member1; @@ -109,9 +127,7 @@ describe('Project members delete', () => { }) .expect(204) .end((err) => { - if (err) { - done(err); - } else { + expectAfterDelete(project1.id, member1.id, err, () => { const removedMember = { projectId: project1.id, userId: 40051332, @@ -121,7 +137,7 @@ describe('Project members delete', () => { server.services.pubsub.publish.calledWith('project.member.removed', sinon.match(removedMember)).should.be.true; done(); - } + }); // models.ProjectMember // .count({where: { projectId: project1.id, deletedAt: { $eq: null } }}) @@ -170,9 +186,7 @@ describe('Project members delete', () => { }) .expect(204) .end((err) => { - if (err) { - done(err); - } else { + expectAfterDelete(project1.id, member1.id, err, () => { const removedMember = { projectId: project1.id, userId: 40051332, @@ -201,7 +215,7 @@ describe('Project members delete', () => { plain.userId.should.equal(40051331); done(); }); - } + }); }); }); }); @@ -230,9 +244,7 @@ describe('Project members delete', () => { }) .expect(204) .end((err) => { - if (err) { - done(err); - } else { + expectAfterDelete(project1.id, member2.id, err, () => { const removedMember = { projectId: project1.id, userId: 40051334, @@ -243,7 +255,7 @@ describe('Project members delete', () => { sinon.match(removedMember)).should.be.true; postSpy.should.have.been.calledOnce; done(); - } + }); }); }); @@ -279,9 +291,7 @@ describe('Project members delete', () => { }) .expect(204) .end((err) => { - if (err) { - done(err); - } else { + expectAfterDelete(project1.id, member2.id, err, () => { const removedMember = { projectId: project1.id, userId: 40051334, @@ -292,7 +302,7 @@ describe('Project members delete', () => { sinon.match(removedMember)).should.be.true; postSpy.should.not.have.been.calledOnce; done(); - } + }); }); }); }); diff --git a/src/routes/projectTemplates/create.js b/src/routes/projectTemplates/create.js index cdae740e..12f7c52e 100644 --- a/src/routes/projectTemplates/create.js +++ b/src/routes/projectTemplates/create.js @@ -5,6 +5,7 @@ import validate from 'express-validation'; import _ from 'lodash'; import Joi from 'joi'; import { middleware as tcMiddleware } from 'tc-core-library-js'; +import fieldLookupValidation from '../../middlewares/fieldLookupValidation'; import util from '../../util'; import models from '../../models'; @@ -38,6 +39,7 @@ const schema = { module.exports = [ validate(schema), permissions('projectTemplate.create'), + fieldLookupValidation(models.ProjectType, 'key', 'body.param.category', 'Category'), (req, res, next) => { const entity = _.assign(req.body.param, { createdBy: req.authUser.userId, diff --git a/src/routes/projectTemplates/create.spec.js b/src/routes/projectTemplates/create.spec.js index 261b4ffa..c18123cb 100644 --- a/src/routes/projectTemplates/create.spec.js +++ b/src/routes/projectTemplates/create.spec.js @@ -2,20 +2,40 @@ * Tests for create.js */ import chai from 'chai'; +import _ from 'lodash'; import request from 'supertest'; import server from '../../app'; +import models from '../../models'; import testUtil from '../../tests/util'; const should = chai.should(); describe('CREATE project template', () => { + before((done) => { + testUtil.clearDb() + .then(() => models.ProjectType.bulkCreate([ + { + key: 'generic', + displayName: 'Generic', + icon: 'http://example.com/icon1.ico', + question: 'question 1', + info: 'info 1', + aliases: ['key-1', 'key_1'], + metadata: {}, + createdBy: 1, + updatedBy: 1, + }, + ])) + .then(() => done()); + }); + describe('POST /projectTemplates', () => { const body = { param: { name: 'template 1', key: 'key 1', - category: 'category 1', + category: 'generic', icon: 'http://example.com/icon1.ico', question: 'question 1', info: 'info 1', @@ -103,6 +123,32 @@ describe('CREATE project template', () => { .expect(422, done); }); + it('should return 422 if project type is missing', (done) => { + const invalidBody = _.cloneDeep(body); + invalidBody.param.type = null; + request(server) + .post('/v4/projectTemplates') + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .send(invalidBody) + .expect('Content-Type', /json/) + .expect(422, done); + }); + + it('should return 422 if project type does not exist', (done) => { + const invalidBody = _.cloneDeep(body); + invalidBody.param.type = 'not_exist'; + request(server) + .post('/v4/projectTemplates') + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .send(invalidBody) + .expect('Content-Type', /json/) + .expect(422, done); + }); + it('should return 201 for admin', (done) => { request(server) .post('/v4/projectTemplates') diff --git a/src/routes/projectTemplates/delete.js b/src/routes/projectTemplates/delete.js index 4db9a855..b3f353b6 100644 --- a/src/routes/projectTemplates/delete.js +++ b/src/routes/projectTemplates/delete.js @@ -17,39 +17,21 @@ const schema = { module.exports = [ validate(schema), permissions('projectTemplate.delete'), - (req, res, next) => { - const where = { - deletedAt: { $eq: null }, - id: req.params.templateId, - }; - - return models.sequelize.transaction(tx => - // Update the deletedBy - models.ProjectTemplate.update({ deletedBy: req.authUser.userId }, { - where, - returning: true, - raw: true, - transaction: tx, - }) - .then((updatedResults) => { - // Not found - if (updatedResults[0] === 0) { + (req, res, next) => + models.sequelize.transaction(() => + models.ProjectTemplate.findById(req.params.templateId) + .then((entity) => { + if (!entity) { const apiErr = new Error(`Project template not found for template id ${req.params.templateId}`); apiErr.status = 404; return Promise.reject(apiErr); } - - // Soft delete - return models.ProjectTemplate.destroy({ - where, - transaction: tx, - raw: true, - }); + // Update the deletedBy, then delete + return entity.update({ deletedBy: req.authUser.userId }); }) + .then(entity => entity.destroy())) .then(() => { res.status(204).end(); }) .catch(next), - ); - }, ]; diff --git a/src/routes/projectTemplates/delete.spec.js b/src/routes/projectTemplates/delete.spec.js index f82d61ba..a475ee5d 100644 --- a/src/routes/projectTemplates/delete.spec.js +++ b/src/routes/projectTemplates/delete.spec.js @@ -2,11 +2,37 @@ * Tests for delete.js */ import request from 'supertest'; +import chai from 'chai'; import models from '../../models'; import server from '../../app'; import testUtil from '../../tests/util'; +const expectAfterDelete = (id, err, next) => { + if (err) throw err; + setTimeout(() => + models.ProjectTemplate.findOne({ + where: { + id, + }, + paranoid: false, + }) + .then((res) => { + if (!res) { + throw new Error('Should found the entity'); + } else { + chai.assert.isNotNull(res.deletedAt); + chai.assert.isNotNull(res.deletedBy); + + request(server) + .get(`/v4/projectTemplates/${id}`) + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .expect(404, next); + } + }), 500); +}; describe('DELETE project template', () => { let templateId; @@ -113,8 +139,7 @@ describe('DELETE project template', () => { .set({ Authorization: `Bearer ${testUtil.jwts.admin}`, }) - .expect(204) - .end(done); + .end(err => expectAfterDelete(templateId, err, done)); }); it('should return 204, for connect admin, if template was successfully removed', (done) => { @@ -124,7 +149,7 @@ describe('DELETE project template', () => { Authorization: `Bearer ${testUtil.jwts.connectAdmin}`, }) .expect(204) - .end(done); + .end(err => expectAfterDelete(templateId, err, done)); }); }); }); diff --git a/src/routes/projectTemplates/update.js b/src/routes/projectTemplates/update.js index 7a9f27e5..61e01c99 100644 --- a/src/routes/projectTemplates/update.js +++ b/src/routes/projectTemplates/update.js @@ -5,6 +5,7 @@ import validate from 'express-validation'; import _ from 'lodash'; import Joi from 'joi'; import { middleware as tcMiddleware } from 'tc-core-library-js'; +import fieldLookupValidation from '../../middlewares/fieldLookupValidation'; import util from '../../util'; import models from '../../models'; @@ -41,6 +42,7 @@ const schema = { module.exports = [ validate(schema), permissions('projectTemplate.edit'), + fieldLookupValidation(models.ProjectType, 'key', 'body.param.category', 'Category'), (req, res, next) => { const entityToUpdate = _.assign(req.body.param, { updatedBy: req.authUser.userId, diff --git a/src/routes/projectTemplates/update.spec.js b/src/routes/projectTemplates/update.spec.js index 632cfa95..514df753 100644 --- a/src/routes/projectTemplates/update.spec.js +++ b/src/routes/projectTemplates/update.spec.js @@ -14,7 +14,7 @@ describe('UPDATE project template', () => { const template = { name: 'template 1', key: 'key 1', - category: 'category 1', + category: 'generic', icon: 'http://example.com/icon1.ico', question: 'question 1', info: 'info 1', @@ -51,6 +51,30 @@ describe('UPDATE project template', () => { let templateId; beforeEach(() => testUtil.clearDb() + .then(() => models.ProjectType.bulkCreate([ + { + key: 'generic', + displayName: 'Generic', + icon: 'http://example.com/icon1.ico', + question: 'question 1', + info: 'info 1', + aliases: ['key-1', 'key_1'], + metadata: {}, + createdBy: 1, + updatedBy: 1, + }, + { + key: 'concrete', + displayName: 'Concrete', + icon: 'http://example.com/icon1.ico', + question: 'question 2', + info: 'info 2', + aliases: ['key-2', 'key_2'], + metadata: {}, + createdBy: 1, + updatedBy: 1, + }, + ])) .then(() => models.ProjectTemplate.create(template)) .then((createdTemplate) => { templateId = createdTemplate.id; @@ -64,7 +88,7 @@ describe('UPDATE project template', () => { param: { name: 'template 1 - update', key: 'key 1 - update', - category: 'category 1 - update', + category: 'concrete', scope: { scope1: { subScope1A: 11, diff --git a/src/routes/projectTypes/create.js b/src/routes/projectTypes/create.js index 6a4ea058..8e73e1ec 100644 --- a/src/routes/projectTypes/create.js +++ b/src/routes/projectTypes/create.js @@ -21,6 +21,7 @@ const schema = { aliases: Joi.array().required(), disabled: Joi.boolean().optional(), hidden: Joi.boolean().optional(), + metadata: Joi.object().required(), createdAt: Joi.any().strip(), updatedAt: Joi.any().strip(), deletedAt: Joi.any().strip(), diff --git a/src/routes/projectTypes/create.spec.js b/src/routes/projectTypes/create.spec.js index f690bd4d..d2e339bc 100644 --- a/src/routes/projectTypes/create.spec.js +++ b/src/routes/projectTypes/create.spec.js @@ -22,6 +22,7 @@ describe('CREATE project type', () => { aliases: ['key-1', 'key_1'], disabled: false, hidden: false, + metadata: { 'slack-notification-mappings': { color: '#96d957', label: 'Full App' } }, createdBy: 1, updatedBy: 1, })).then(() => Promise.resolve()), @@ -39,6 +40,7 @@ describe('CREATE project type', () => { aliases: ['key-1', 'key_1'], disabled: true, hidden: true, + metadata: { 'slack-notification-mappings': { color: '#96d957', label: 'Full App' } }, }, }; @@ -149,6 +151,20 @@ describe('CREATE project type', () => { .expect(422, done); }); + it('should return 422 for missing metadata', (done) => { + const invalidBody = _.cloneDeep(body); + delete invalidBody.param.metadata; + + request(server) + .post('/v4/projectTypes') + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .send(invalidBody) + .expect('Content-Type', /json/) + .expect(422, done); + }); + it('should return 422 for duplicated key', (done) => { const invalidBody = _.cloneDeep(body); invalidBody.param.key = 'key1'; @@ -182,6 +198,7 @@ describe('CREATE project type', () => { resJson.aliases.should.be.eql(body.param.aliases); resJson.disabled.should.be.eql(body.param.disabled); resJson.hidden.should.be.eql(body.param.hidden); + resJson.metadata.should.be.eql(body.param.metadata); resJson.createdBy.should.be.eql(40051333); // admin should.exist(resJson.createdAt); @@ -213,6 +230,7 @@ describe('CREATE project type', () => { resJson.aliases.should.be.eql(body.param.aliases); resJson.disabled.should.be.eql(body.param.disabled); resJson.hidden.should.be.eql(body.param.hidden); + resJson.metadata.should.be.eql(body.param.metadata); resJson.createdBy.should.be.eql(40051336); // connect admin resJson.updatedBy.should.be.eql(40051336); // connect admin done(); diff --git a/src/routes/projectTypes/delete.js b/src/routes/projectTypes/delete.js index 7592641c..2f5e2f07 100644 --- a/src/routes/projectTypes/delete.js +++ b/src/routes/projectTypes/delete.js @@ -17,38 +17,21 @@ const schema = { module.exports = [ validate(schema), permissions('projectType.delete'), - (req, res, next) => { - const where = { - deletedAt: { $eq: null }, - key: req.params.key, - }; - - return models.sequelize.transaction(tx => - // Update the deletedBy - models.ProjectType.update({ deletedBy: req.authUser.userId }, { - where, - returning: true, - raw: true, - transaction: tx, - }) - .then((updatedResults) => { - // Not found - if (updatedResults[0] === 0) { + (req, res, next) => + models.sequelize.transaction(() => + models.ProjectType.findById(req.params.key) + .then((entity) => { + if (!entity) { const apiErr = new Error(`Project type not found for key ${req.params.key}`); apiErr.status = 404; return Promise.reject(apiErr); } - - // Soft delete - return models.ProjectType.destroy({ - where, - transaction: tx, - }); + // Update the deletedBy, then delete + return entity.update({ deletedBy: req.authUser.userId }); }) + .then(entity => entity.destroy())) .then(() => { res.status(204).end(); }) .catch(next), - ); - }, ]; diff --git a/src/routes/projectTypes/delete.spec.js b/src/routes/projectTypes/delete.spec.js index 053bfb1c..26b35994 100644 --- a/src/routes/projectTypes/delete.spec.js +++ b/src/routes/projectTypes/delete.spec.js @@ -2,11 +2,36 @@ * Tests for delete.js */ import request from 'supertest'; - +import chai from 'chai'; import models from '../../models'; import server from '../../app'; import testUtil from '../../tests/util'; +const expectAfterDelete = (key, err, next) => { + if (err) throw err; + setTimeout(() => + models.ProjectType.findOne({ + where: { + key, + }, + paranoid: false, + }) + .then((res) => { + if (!res) { + throw new Error('Should found the entity'); + } else { + chai.assert.isNotNull(res.deletedAt); + chai.assert.isNotNull(res.deletedBy); + + request(server) + .get(`/v4/projectTypes/${key}`) + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .expect(404, next); + } + }), 500); +}; describe('DELETE project type', () => { const key = 'key1'; @@ -19,6 +44,7 @@ describe('DELETE project type', () => { question: 'question 1', info: 'info 1', aliases: ['key-1', 'key_1'], + metadata: { 'slack-notification-mappings': { color: '#96d957', label: 'Full App' } }, createdBy: 1, updatedBy: 1, })).then(() => Promise.resolve()), @@ -87,7 +113,7 @@ describe('DELETE project type', () => { Authorization: `Bearer ${testUtil.jwts.admin}`, }) .expect(204) - .end(done); + .end(err => expectAfterDelete(key, err, done)); }); it('should return 204, for connect admin, if type was successfully removed', (done) => { @@ -97,7 +123,7 @@ describe('DELETE project type', () => { Authorization: `Bearer ${testUtil.jwts.connectAdmin}`, }) .expect(204) - .end(done); + .end(err => expectAfterDelete(key, err, done)); }); }); }); diff --git a/src/routes/projectTypes/get.spec.js b/src/routes/projectTypes/get.spec.js index cf12bb88..eb72604f 100644 --- a/src/routes/projectTypes/get.spec.js +++ b/src/routes/projectTypes/get.spec.js @@ -20,6 +20,7 @@ describe('GET project type', () => { aliases: ['key-1', 'key_1'], disabled: true, hidden: true, + metadata: { 'slack-notification-mappings': { color: '#96d957', label: 'Full App' } }, createdBy: 1, updatedBy: 1, }; @@ -71,6 +72,7 @@ describe('GET project type', () => { resJson.aliases.should.be.eql(type.aliases); resJson.disabled.should.be.eql(type.disabled); resJson.hidden.should.be.eql(type.hidden); + resJson.metadata.should.be.eql(type.metadata); resJson.createdBy.should.be.eql(type.createdBy); should.exist(resJson.createdAt); resJson.updatedBy.should.be.eql(type.updatedBy); diff --git a/src/routes/projectTypes/list.spec.js b/src/routes/projectTypes/list.spec.js index 5128a760..991667b5 100644 --- a/src/routes/projectTypes/list.spec.js +++ b/src/routes/projectTypes/list.spec.js @@ -21,6 +21,7 @@ describe('LIST project types', () => { aliases: ['key-1', 'key_1'], disabled: true, hidden: true, + metadata: { 'slack-notification-mappings': { color: '#96d957', label: 'Full App' } }, createdBy: 1, updatedBy: 1, }, @@ -33,6 +34,7 @@ describe('LIST project types', () => { aliases: ['key-2', 'key_2'], disabled: true, hidden: true, + metadata: { 'slack-notification-mappings': { color: '#b47dd6', label: 'Full App 2' } }, createdBy: 1, updatedBy: 1, }, @@ -67,6 +69,7 @@ describe('LIST project types', () => { resJson[0].createdBy.should.be.eql(type.createdBy); resJson[0].disabled.should.be.eql(type.disabled); resJson[0].hidden.should.be.eql(type.hidden); + resJson[0].metadata.should.be.eql(type.metadata); should.exist(resJson[0].createdAt); resJson[0].updatedBy.should.be.eql(type.updatedBy); should.exist(resJson[0].updatedAt); diff --git a/src/routes/projectTypes/update.js b/src/routes/projectTypes/update.js index 3946715e..9975f478 100644 --- a/src/routes/projectTypes/update.js +++ b/src/routes/projectTypes/update.js @@ -24,6 +24,7 @@ const schema = { aliases: Joi.array().optional(), disabled: Joi.boolean().optional(), hidden: Joi.boolean().optional(), + metadata: Joi.object().optional(), createdAt: Joi.any().strip(), updatedAt: Joi.any().strip(), deletedAt: Joi.any().strip(), diff --git a/src/routes/projectTypes/update.spec.js b/src/routes/projectTypes/update.spec.js index 5e809720..0402020c 100644 --- a/src/routes/projectTypes/update.spec.js +++ b/src/routes/projectTypes/update.spec.js @@ -21,6 +21,7 @@ describe('UPDATE project type', () => { aliases: ['key-1', 'key_1'], disabled: false, hidden: false, + metadata: { 'slack-notification-mappings': { color: '#96d957', label: 'Full App' } }, createdBy: 1, updatedBy: 1, }; @@ -42,6 +43,7 @@ describe('UPDATE project type', () => { aliases: ['key-1-updated', 'key_1_updated'], disabled: true, hidden: true, + metadata: { 'slack-notification-mappings': { color: '#b47dd6', label: 'Full App 2' } }, }, }; @@ -113,6 +115,7 @@ describe('UPDATE project type', () => { delete partialBody.param.aliases; delete partialBody.param.disabled; delete partialBody.param.hidden; + delete partialBody.param.metadata; request(server) .patch(`/v4/projectTypes/${key}`) .set({ @@ -130,6 +133,7 @@ describe('UPDATE project type', () => { resJson.aliases.should.be.eql(type.aliases); resJson.disabled.should.be.eql(type.disabled); resJson.hidden.should.be.eql(type.hidden); + resJson.metadata.should.be.eql(type.metadata); resJson.createdBy.should.be.eql(type.createdBy); resJson.createdBy.should.be.eql(type.createdBy); // should not update createdAt resJson.updatedBy.should.be.eql(40051333); // admin @@ -149,6 +153,7 @@ describe('UPDATE project type', () => { delete partialBody.param.aliases; delete partialBody.param.disabled; delete partialBody.param.hidden; + delete partialBody.param.metadata; request(server) .patch(`/v4/projectTypes/${key}`) .set({ @@ -166,6 +171,7 @@ describe('UPDATE project type', () => { resJson.aliases.should.be.eql(type.aliases); resJson.disabled.should.be.eql(type.disabled); resJson.hidden.should.be.eql(type.hidden); + resJson.metadata.should.be.eql(type.metadata); resJson.createdBy.should.be.eql(type.createdBy); resJson.createdBy.should.be.eql(type.createdBy); // should not update createdAt resJson.updatedBy.should.be.eql(40051333); // admin @@ -185,6 +191,7 @@ describe('UPDATE project type', () => { delete partialBody.param.aliases; delete partialBody.param.disabled; delete partialBody.param.hidden; + delete partialBody.param.metadata; request(server) .patch(`/v4/projectTypes/${key}`) .set({ @@ -202,6 +209,7 @@ describe('UPDATE project type', () => { resJson.aliases.should.be.eql(type.aliases); resJson.disabled.should.be.eql(type.disabled); resJson.hidden.should.be.eql(type.hidden); + resJson.metadata.should.be.eql(type.metadata); resJson.createdBy.should.be.eql(type.createdBy); resJson.createdBy.should.be.eql(type.createdBy); // should not update createdAt resJson.updatedBy.should.be.eql(40051333); // admin @@ -221,6 +229,7 @@ describe('UPDATE project type', () => { delete partialBody.param.aliases; delete partialBody.param.disabled; delete partialBody.param.hidden; + delete partialBody.param.metadata; request(server) .patch(`/v4/projectTypes/${key}`) .set({ @@ -238,6 +247,7 @@ describe('UPDATE project type', () => { resJson.aliases.should.be.eql(type.aliases); resJson.disabled.should.be.eql(type.disabled); resJson.hidden.should.be.eql(type.hidden); + resJson.metadata.should.be.eql(type.metadata); resJson.createdBy.should.be.eql(type.createdBy); resJson.createdBy.should.be.eql(type.createdBy); // should not update createdAt resJson.updatedBy.should.be.eql(40051333); // admin @@ -257,6 +267,7 @@ describe('UPDATE project type', () => { delete partialBody.param.displayName; delete partialBody.param.disabled; delete partialBody.param.hidden; + delete partialBody.param.metadata; request(server) .patch(`/v4/projectTypes/${key}`) .set({ @@ -274,6 +285,7 @@ describe('UPDATE project type', () => { resJson.aliases.should.be.eql(partialBody.param.aliases); resJson.disabled.should.be.eql(type.disabled); resJson.hidden.should.be.eql(type.hidden); + resJson.metadata.should.be.eql(type.metadata); resJson.createdBy.should.be.eql(type.createdBy); resJson.createdBy.should.be.eql(type.createdBy); // should not update createdAt resJson.updatedBy.should.be.eql(40051333); // admin @@ -293,6 +305,7 @@ describe('UPDATE project type', () => { delete partialBody.param.displayName; delete partialBody.param.aliases; delete partialBody.param.hidden; + delete partialBody.param.metadata; request(server) .patch(`/v4/projectTypes/${key}`) .set({ @@ -310,6 +323,7 @@ describe('UPDATE project type', () => { resJson.aliases.should.be.eql(type.aliases); resJson.disabled.should.be.eql(partialBody.param.disabled); resJson.hidden.should.be.eql(type.hidden); + resJson.metadata.should.be.eql(type.metadata); resJson.createdBy.should.be.eql(type.createdBy); resJson.createdBy.should.be.eql(type.createdBy); // should not update createdAt resJson.updatedBy.should.be.eql(40051333); // admin @@ -329,6 +343,7 @@ describe('UPDATE project type', () => { delete partialBody.param.displayName; delete partialBody.param.disabled; delete partialBody.param.aliases; + delete partialBody.param.metadata; request(server) .patch(`/v4/projectTypes/${key}`) .set({ @@ -346,6 +361,44 @@ describe('UPDATE project type', () => { resJson.aliases.should.be.eql(type.aliases); resJson.disabled.should.be.eql(type.disabled); resJson.hidden.should.be.eql(partialBody.param.hidden); + resJson.metadata.should.be.eql(type.metadata); + resJson.createdBy.should.be.eql(type.createdBy); // should not update createdAt + resJson.updatedBy.should.be.eql(40051333); // admin + should.exist(resJson.updatedAt); + should.not.exist(resJson.deletedBy); + should.not.exist(resJson.deletedAt); + + done(); + }); + }); + + it('should return 200 for admin metadata updated', (done) => { + const partialBody = _.cloneDeep(body); + delete partialBody.param.icon; + delete partialBody.param.info; + delete partialBody.param.question; + delete partialBody.param.displayName; + delete partialBody.param.disabled; + delete partialBody.param.aliases; + delete partialBody.param.hidden; + request(server) + .patch(`/v4/projectTypes/${key}`) + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .send(partialBody) + .expect(200) + .end((err, res) => { + const resJson = res.body.result.content; + resJson.key.should.be.eql(key); + resJson.displayName.should.be.eql(type.displayName); + resJson.icon.should.be.eql(type.icon); + resJson.info.should.be.eql(type.info); + resJson.question.should.be.eql(type.question); + resJson.aliases.should.be.eql(type.aliases); + resJson.disabled.should.be.eql(type.disabled); + resJson.hidden.should.be.eql(type.hidden); + resJson.metadata.should.be.eql(partialBody.param.metadata); resJson.createdBy.should.be.eql(type.createdBy); // should not update createdAt resJson.updatedBy.should.be.eql(40051333); // admin should.exist(resJson.updatedAt); @@ -374,6 +427,7 @@ describe('UPDATE project type', () => { resJson.aliases.should.be.eql(body.param.aliases); resJson.disabled.should.be.eql(body.param.disabled); resJson.hidden.should.be.eql(body.param.hidden); + resJson.metadata.should.be.eql(body.param.metadata); resJson.createdBy.should.be.eql(type.createdBy); // should not update createdAt resJson.updatedBy.should.be.eql(40051333); // admin should.exist(resJson.updatedAt); @@ -402,6 +456,7 @@ describe('UPDATE project type', () => { resJson.aliases.should.be.eql(body.param.aliases); resJson.disabled.should.be.eql(body.param.disabled); resJson.hidden.should.be.eql(body.param.hidden); + resJson.metadata.should.be.eql(body.param.metadata); resJson.createdBy.should.be.eql(type.createdBy); // should not update createdAt resJson.updatedBy.should.be.eql(40051336); // connect admin done(); diff --git a/src/routes/projectUpgrade/create.spec.js b/src/routes/projectUpgrade/create.spec.js index 381ef2b7..853f508a 100644 --- a/src/routes/projectUpgrade/create.spec.js +++ b/src/routes/projectUpgrade/create.spec.js @@ -92,6 +92,7 @@ describe('Project upgrade', () => { ].map(specific => models.ProductTemplate.create(Object.assign({ name: 'name 1', productKey: 'a product key', + category: 'category', icon: 'http://example.com/icon1.ico', brief: 'brief 1', details: 'details 1', diff --git a/src/routes/projects/create.js b/src/routes/projects/create.js index da3e9e5d..c603b820 100644 --- a/src/routes/projects/create.js +++ b/src/routes/projects/create.js @@ -4,9 +4,11 @@ import validate from 'express-validation'; import _ from 'lodash'; import Joi from 'joi'; import config from 'config'; +import moment from 'moment'; import models from '../../models'; import { PROJECT_MEMBER_ROLE, PROJECT_STATUS, PROJECT_PHASE_STATUS, USER_ROLE, EVENT, REGEX } from '../../constants'; +import fieldLookupValidation from '../../middlewares/fieldLookupValidation'; import util from '../../util'; import directProject from '../../services/directProject'; @@ -90,12 +92,17 @@ function createProjectAndPhases(req, project, projectTemplate, productTemplates) productTemplates.forEach((pt) => { productTemplateMap[pt.id] = pt; }); - return Promise.all(_.map(phases, (phase, phaseIdx) => + return Promise.all(_.map(phases, (phase, phaseIdx) => { + const duration = _.get(phase, 'duration', 1); + const startDate = moment.utc().hours(0).minutes(0).seconds(0) + .milliseconds(0); // Create phase - models.ProjectPhase.create({ + return models.ProjectPhase.create({ projectId: newProject.id, name: _.get(phase, 'name', `Stage ${phaseIdx}`), - duration: _.get(phase, 'duration', 0), + duration, + startDate: startDate.format(), + endDate: moment.utc(startDate).add(duration - 1, 'days').format(), status: _.get(phase, 'status', PROJECT_PHASE_STATUS.DRAFT), budget: _.get(phase, 'budget', 0), updatedBy: req.authUser.userId, @@ -121,8 +128,8 @@ function createProjectAndPhases(req, project, projectTemplate, productTemplates) result.newPhases.push(newPhaseJson); return Promise.resolve(); }); - }), - )); + }); + })); }).then(() => Promise.resolve(result)); } @@ -178,30 +185,11 @@ function validateAndFetchTemplates(templateId) { }); } -/** - * Validates the project type being one from the allowed ones. - * - * @param {String} type key of the project type to be used - * @returns {Promise} promise which resolves to a project type if it is valid, rejects otherwise with 422 error - */ -function validateProjectType(type) { - return models.ProjectType.findOne({ where: { key: type } }) - .then((projectType) => { - if (!projectType) { - // Not found - const apiErr = new Error(`Project type not found for key ${type}`); - apiErr.status = 422; - return Promise.reject(apiErr); - } - - return Promise.resolve(projectType); - }); -} - module.exports = [ // handles request validations validate(createProjectValdiations), permissions('project.create'), + fieldLookupValidation(models.ProjectType, 'key', 'body.param.type', 'Project type'), /** * POST projects/ * Create a project if the user has access @@ -242,16 +230,12 @@ module.exports = [ if (!project.templateId) { project.version = 'v2'; } + let newProject = null; + let newPhases; models.sequelize.transaction(() => { - let newProject = null; - let newPhases; - // Validate the project type - return validateProjectType(project.type) + req.log.debug('Create Project - Starting transaction'); // Validate the templates - .then((projectType) => { - req.log.debug(`Project type ${projectType.key} validated successfully`); - return validateAndFetchTemplates(project.templateId); - }) + return validateAndFetchTemplates(project.templateId) // Create project and phases .then(({ projectTemplate, productTemplates }) => { req.log.debug('Creating project, phase and products'); @@ -294,30 +278,30 @@ module.exports = [ return Promise.resolve(); }); // return Promise.resolve(); - }) - .then(() => { - newProject = newProject.get({ plain: true }); - // remove utm details & deletedAt field - newProject = _.omit(newProject, ['deletedAt', 'utm']); - // add an empty attachments array - newProject.attachments = []; - // set phases array - newProject.phases = newPhases; - - req.log.debug('Sending event to RabbitMQ bus for project %d', newProject.id); - req.app.services.pubsub.publish(EVENT.ROUTING_KEY.PROJECT_DRAFT_CREATED, - newProject, - { correlationId: req.id }, - ); - req.log.debug('Sending event to Kafka bus for project %d', newProject.id); - // emit event - req.app.emit(EVENT.ROUTING_KEY.PROJECT_DRAFT_CREATED, { req, project: newProject }); - res.status(201).json(util.wrapResponse(req.id, newProject, 1, 201)); - }) - .catch((err) => { - req.log.error(err.message); - util.handleError('Error creating project', err, req, next); }); + }) + .then(() => { + newProject = newProject.get({ plain: true }); + // remove utm details & deletedAt field + newProject = _.omit(newProject, ['deletedAt', 'utm']); + // add an empty attachments array + newProject.attachments = []; + // set phases array + newProject.phases = newPhases; + + req.log.debug('Sending event to RabbitMQ bus for project %d', newProject.id); + req.app.services.pubsub.publish(EVENT.ROUTING_KEY.PROJECT_DRAFT_CREATED, + newProject, + { correlationId: req.id }, + ); + req.log.debug('Sending event to Kafka bus for project %d', newProject.id); + // emit event + req.app.emit(EVENT.ROUTING_KEY.PROJECT_DRAFT_CREATED, { req, project: newProject }); + res.status(201).json(util.wrapResponse(req.id, newProject, 1, 201)); + }) + .catch((err) => { + req.log.error(err.message); + util.handleError('Error creating project', err, req, next); }); }, ]; diff --git a/src/routes/projects/create.spec.js b/src/routes/projects/create.spec.js index 9de4e1df..7637cefe 100644 --- a/src/routes/projects/create.spec.js +++ b/src/routes/projects/create.spec.js @@ -1,6 +1,7 @@ /* eslint-disable no-unused-expressions */ import _ from 'lodash'; import chai from 'chai'; +import moment from 'moment'; import sinon from 'sinon'; import request from 'supertest'; @@ -26,6 +27,7 @@ describe('Project create', () => { question: 'question 1', info: 'info 1', aliases: ['key-1', 'key_1'], + metadata: {}, createdBy: 1, updatedBy: 1, }, @@ -35,6 +37,7 @@ describe('Project create', () => { id: 21, name: 'template 1', productKey: 'productKey-1', + category: 'generic', icon: 'http://example.com/icon2.ico', brief: 'brief 1', details: 'details 1', @@ -47,6 +50,7 @@ describe('Project create', () => { id: 22, name: 'template 2', productKey: 'productKey-2', + category: 'generic', icon: 'http://example.com/icon2.ico', brief: 'brief 2', details: 'details 2', @@ -59,6 +63,7 @@ describe('Project create', () => { id: 23, name: 'template 3', productKey: 'productKey-3', + category: 'generic', icon: 'http://example.com/icon3.ico', brief: 'brief 3', details: 'details 3', @@ -82,6 +87,7 @@ describe('Project create', () => { phases: { phase1: { name: 'phase 1', + duration: 5, products: [ { id: 21, @@ -113,6 +119,7 @@ describe('Project create', () => { 1: { name: 'Design Stage', status: 'open', + duration: 10, details: { description: 'detailed description', }, @@ -127,6 +134,7 @@ describe('Project create', () => { 2: { name: 'Development Stage', status: 'open', + duration: 20, products: [ { id: 23, @@ -437,6 +445,14 @@ describe('Project create', () => { const phases = _.sortBy(resJson.phases, p => p.name); phases[0].name.should.be.eql('Design Stage'); phases[0].status.should.be.eql('open'); + phases[0].startDate.should.be.a('string'); + phases[0].duration.should.be.eql(10); + const startDate = moment.utc(phases[0].startDate); + startDate.hours().should.be.eql(0); + startDate.minutes().should.be.eql(0); + startDate.seconds().should.be.eql(0); + startDate.milliseconds().should.be.eql(0); + new Date(phases[0].endDate).should.be.eql(startDate.add(9, 'days').toDate()); expect(phases[0].details).to.be.empty; phases[0].products.should.have.lengthOf(1); phases[0].products[0].name.should.be.eql('product 1'); diff --git a/src/routes/projects/delete.js b/src/routes/projects/delete.js index a2ea2b19..915d91d9 100644 --- a/src/routes/projects/delete.js +++ b/src/routes/projects/delete.js @@ -16,29 +16,28 @@ module.exports = [ (req, res, next) => { const projectId = _.parseInt(req.params.projectId); - models.sequelize.transaction(t => - // soft delete the record - models.Project.destroy({ - where: { id: projectId }, - cascade: true, - transaction: t, - }) - .then((count) => { - if (count === 0) { - const err = new Error('Project not found'); - err.status = 404; - next(err); - } else { - req.app.services.pubsub.publish( - EVENT.ROUTING_KEY.PROJECT_DELETED, - { id: projectId }, - { correlationId: req.id }, - ); - // emit event - req.app.emit(EVENT.ROUTING_KEY.PROJECT_DELETED, { req, id: projectId }); - res.status(204).json({}); + models.sequelize.transaction(() => + models.Project.findById(req.params.projectId) + .then((entity) => { + if (!entity) { + const apiErr = new Error(`Project template not found for template id ${projectId}`); + apiErr.status = 404; + return Promise.reject(apiErr); } + // Update the deletedBy, then delete + return entity.update({ deletedBy: req.authUser.userId }); }) - .catch(err => next(err))); + .then(project => project.destroy({ cascade: true }))) + .then(() => { + req.app.services.pubsub.publish( + EVENT.ROUTING_KEY.PROJECT_DELETED, + { id: projectId }, + { correlationId: req.id }, + ); + // emit event + req.app.emit(EVENT.ROUTING_KEY.PROJECT_DELETED, { req, id: projectId }); + res.status(204).json({}); + }) + .catch(err => next(err)); }, ]; diff --git a/src/routes/projects/delete.spec.js b/src/routes/projects/delete.spec.js index 8fee3330..852d8fb4 100644 --- a/src/routes/projects/delete.spec.js +++ b/src/routes/projects/delete.spec.js @@ -1,73 +1,100 @@ /* eslint-disable no-unused-expressions */ import request from 'supertest'; +import chai from 'chai'; import models from '../../models'; import server from '../../app'; import testUtil from '../../tests/util'; +const expectAfterDelete = (id, err, next) => { + if (err) throw err; + setTimeout(() => + models.Project.findOne({ + where: { + id, + }, + paranoid: false, + }) + .then((res) => { + if (!res) { + throw new Error('Should found the entity'); + } else { + server.services.pubsub.publish.calledWith('project.deleted').should.be.true; + chai.assert.isNotNull(res.deletedAt); + chai.assert.isNotNull(res.deletedBy); + + request(server) + .get(`/v4/projects/${id}`) + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .expect(404, next); + } + }), 500); +}; describe('Project delete test', () => { let project1; beforeEach((done) => { testUtil.clearDb() - .then(() => { - models.Project.create({ - type: 'generic', - directProjectId: 1, - billingAccountId: 1, - name: 'test1', - description: 'test project1', - status: 'draft', - details: {}, - createdBy: 1, - updatedBy: 1, - }).then((p) => { - project1 = p; - // create members - const promises = [ - // owner - models.ProjectMember.create({ - userId: 40051331, - projectId: project1.id, - role: 'customer', - isPrimary: true, - createdBy: 1, - updatedBy: 1, - }), - // manager - models.ProjectMember.create({ - userId: 40051334, - projectId: project1.id, - role: 'manager', - isPrimary: true, - createdBy: 1, - updatedBy: 1, - }), - // copilot - models.ProjectMember.create({ - userId: 40051332, - projectId: project1.id, - role: 'copilot', - isPrimary: true, - createdBy: 1, - updatedBy: 1, - }), - // team member - models.ProjectMember.create({ - userId: 40051335, - projectId: project1.id, - role: 'customer', - isPrimary: false, - createdBy: 1, - updatedBy: 1, - }), - ]; - Promise.all(promises) + .then(() => { + models.Project.create({ + type: 'generic', + directProjectId: 1, + billingAccountId: 1, + name: 'test1', + description: 'test project1', + status: 'draft', + details: {}, + createdBy: 1, + updatedBy: 1, + }).then((p) => { + project1 = p; + // create members + const promises = [ + // owner + models.ProjectMember.create({ + userId: 40051331, + projectId: project1.id, + role: 'customer', + isPrimary: true, + createdBy: 1, + updatedBy: 1, + }), + // manager + models.ProjectMember.create({ + userId: 40051334, + projectId: project1.id, + role: 'manager', + isPrimary: true, + createdBy: 1, + updatedBy: 1, + }), + // copilot + models.ProjectMember.create({ + userId: 40051332, + projectId: project1.id, + role: 'copilot', + isPrimary: true, + createdBy: 1, + updatedBy: 1, + }), + // team member + models.ProjectMember.create({ + userId: 40051335, + projectId: project1.id, + role: 'customer', + isPrimary: false, + createdBy: 1, + updatedBy: 1, + }), + ]; + Promise.all(promises) .then(() => { done(); }); - }); }); + }); }); after((done) => { @@ -92,12 +119,7 @@ describe('Project delete test', () => { }) .expect(204) .end((err) => { - if (err) { - done(err); - } else { - server.services.pubsub.publish.calledWith('project.deleted').should.be.true; - done(); - } + expectAfterDelete(project1.id, err, done); }); }); @@ -109,12 +131,7 @@ describe('Project delete test', () => { }) .expect(204) .end((err) => { - if (err) { - done(err); - } else { - server.services.pubsub.publish.calledWith('project.deleted').should.be.true; - done(); - } + expectAfterDelete(project1.id, err, done); }); }); @@ -126,12 +143,7 @@ describe('Project delete test', () => { }) .expect(204) .end((err) => { - if (err) { - done(err); - } else { - server.services.pubsub.publish.calledWith('project.deleted').should.be.true; - done(); - } + expectAfterDelete(project1.id, err, done); }); }); }); diff --git a/src/routes/projects/list.js b/src/routes/projects/list.js index 21871a82..483cff4d 100755 --- a/src/routes/projects/list.js +++ b/src/routes/projects/list.js @@ -134,7 +134,10 @@ const parseElasticSearchCriteria = (criteria, fields, order) => { const phaseFields = _.get(fields, 'project_phases_products'); sourceInclude = sourceInclude.concat(_.map(phaseFields, single => `phases.products.${single}`)); } - sourceInclude = sourceInclude.concat(_.map(PROJECT_ATTACHMENT_ATTRIBUTES, single => `attachments.${single}`)); + if (_.get(fields, 'attachments', null)) { + const attachmentFields = _.get(fields, 'attachments'); + sourceInclude = sourceInclude.concat(_.map(attachmentFields, single => `attachments.${single}`)); + } if (sourceInclude) { searchCriteria._sourceInclude = sourceInclude; // eslint-disable-line no-underscore-dangle @@ -248,6 +251,7 @@ const retrieveProjects = (req, criteria, sort, ffields) => { project_members: PROJECT_MEMBER_ATTRIBUTES, project_phases: PROJECT_PHASE_ATTRIBUTES, project_phases_products: PROJECT_PHASE_PRODUCTS_ATTRIBUTES, + attachments: PROJECT_ATTACHMENT_ATTRIBUTES, }); // make sure project.id is part of fields if (_.indexOf(fields.projects, 'id') < 0) { diff --git a/src/routes/projects/list.spec.js b/src/routes/projects/list.spec.js index cdcfa1fd..b69fcf6f 100644 --- a/src/routes/projects/list.spec.js +++ b/src/routes/projects/list.spec.js @@ -306,6 +306,92 @@ describe('LIST Project', () => { }); }); + it('should return the project for administrator with field description, billingAccountId and attachments', + (done) => { + request(server) + .get('/v4/projects/?fields=description%2CbillingAccountId%2Cattachments&sort=id%20asc') + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .expect('Content-Type', /json/) + .expect(200) + .end((err, res) => { + if (err) { + done(err); + } else { + const resJson = res.body.result.content; + should.exist(resJson); + resJson.should.have.lengthOf(3); + resJson[0].should.have.property('attachments'); + resJson[0].attachments.should.have.lengthOf(1); + resJson[0].attachments[0].should.have.property('id'); + resJson[0].attachments[0].should.have.property('projectId'); + resJson[0].attachments[0].should.have.property('title'); + resJson[0].attachments[0].should.have.property('description'); + resJson[0].attachments[0].should.have.property('filePath'); + resJson[0].attachments[0].should.have.property('contentType'); + resJson[0].attachments[0].should.have.property('createdBy'); + resJson[0].attachments[0].should.have.property('updatedBy'); + resJson[0].should.have.property('description'); + resJson[0].should.have.property('billingAccountId'); + done(); + } + }); + }); + + it('should return the project for administrator with field description and billingAccountId', (done) => { + request(server) + .get('/v4/projects/?fields=description%2CbillingAccountId&sort=id%20asc') + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .expect('Content-Type', /json/) + .expect(200) + .end((err, res) => { + if (err) { + done(err); + } else { + const resJson = res.body.result.content; + should.exist(resJson); + resJson.should.have.lengthOf(3); + resJson[0].should.not.have.property('attachments'); + resJson[0].should.have.property('description'); + resJson[0].should.have.property('billingAccountId'); + done(); + } + }); + }); + + it('should return the project for administrator with all field', (done) => { + request(server) + .get('/v4/projects/?sort=id%20asc') + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .expect('Content-Type', /json/) + .expect(200) + .end((err, res) => { + if (err) { + done(err); + } else { + const resJson = res.body.result.content; + should.exist(resJson); + resJson.should.have.lengthOf(3); + resJson[0].should.have.property('id'); + resJson[0].should.have.property('type'); + resJson[0].should.have.property('billingAccountId'); + resJson[0].should.have.property('description'); + resJson[0].should.have.property('status'); + resJson[0].should.have.property('details'); + resJson[0].should.have.property('createdBy'); + resJson[0].should.have.property('updatedBy'); + resJson[0].should.have.property('members'); + resJson[0].should.have.property('attachments'); + done(); + } + }); + }); + it('should return all projects that match when filtering by name', (done) => { request(server) .get('/v4/projects/?filter=keyword%3Dtest') diff --git a/src/routes/projects/update.js b/src/routes/projects/update.js index fc17b451..157d33ae 100644 --- a/src/routes/projects/update.js +++ b/src/routes/projects/update.js @@ -228,8 +228,8 @@ module.exports = [ } else { accept(); } - })) - .then(() => { + }))) + .then(() => { // transaction has been committed project = project.get({ plain: true }); project = _.omit(project, ['deletedAt']); req.log.debug('updated project', project); @@ -252,7 +252,7 @@ module.exports = [ project.members = req.context.currentProjectMembers; // get attachments return util.getProjectAttachments(req, project.id); - })) + }) .then((attachments) => { // make sure we only send response after transaction is committed project.attachments = attachments; diff --git a/src/routes/projects/update.spec.js b/src/routes/projects/update.spec.js index 8492d883..42abf4f0 100644 --- a/src/routes/projects/update.spec.js +++ b/src/routes/projects/update.spec.js @@ -30,6 +30,7 @@ describe('Project', () => { aliases: ['key-1', 'key_1'], createdBy: 1, updatedBy: 1, + metadata: {}, }, ])) .then(() => done()); diff --git a/src/routes/timelines/create.js b/src/routes/timelines/create.js new file mode 100644 index 00000000..7aa53502 --- /dev/null +++ b/src/routes/timelines/create.js @@ -0,0 +1,126 @@ +/** + * API to add a timeline + */ +import validate from 'express-validation'; +import _ from 'lodash'; +import Joi from 'joi'; +import moment from 'moment'; +import { middleware as tcMiddleware } from 'tc-core-library-js'; +import util from '../../util'; +import validateTimeline from '../../middlewares/validateTimeline'; +import models from '../../models'; +import { EVENT, TIMELINE_REFERENCES, MILESTONE_STATUS } from '../../constants'; + +const permissions = tcMiddleware.permissions; + +const schema = { + body: { + param: Joi.object().keys({ + id: Joi.any().strip(), + name: Joi.string().max(255).required(), + description: Joi.string().max(255), + startDate: Joi.date().required(), + endDate: Joi.date().min(Joi.ref('startDate')).allow(null), + reference: Joi.string().valid(_.values(TIMELINE_REFERENCES)).required(), + referenceId: Joi.number().integer().positive().required(), + templateId: Joi.number().integer().min(1).optional(), + createdAt: Joi.any().strip(), + updatedAt: Joi.any().strip(), + deletedAt: Joi.any().strip(), + createdBy: Joi.any().strip(), + updatedBy: Joi.any().strip(), + deletedBy: Joi.any().strip(), + }).required(), + }, +}; + +module.exports = [ + validate(schema), + // Validate and get projectId from the timeline request body, and set to request params + // for checking by the permissions middleware + validateTimeline.validateTimelineRequestBody, + permissions('timeline.create'), + (req, res, next) => { + const templateId = req.body.param.templateId; + const entity = _.assign({}, req.body.param, { + createdBy: req.authUser.userId, + updatedBy: req.authUser.userId, + }); + delete entity.templateId; + + let result; + // Save to DB + models.sequelize.transaction(() => { + req.log.debug('Started transaction'); + return models.Timeline.create(entity) + .then((createdEntity) => { + // Omit deletedAt, deletedBy + result = _.omit(createdEntity.toJSON(), 'deletedAt', 'deletedBy'); + req.log.debug('Checking templateId %d for creating milestones', templateId); + if (templateId) { + req.log.debug('Found templateId, finding milestone templates for the template'); + return models.ProductMilestoneTemplate.findAll({ + where: { + productTemplateId: templateId, + deletedAt: { $eq: null }, + }, + order: [['order', 'asc']], + }).then((milestoneTemplates) => { + if (milestoneTemplates) { + req.log.debug('%d MilestoneTemplates found', milestoneTemplates.length); + let startDate = moment.utc(new Date(createdEntity.startDate)); + const milestones = _.map(milestoneTemplates, (mt) => { + const endDate = moment.utc(startDate).add(mt.duration - 1, 'days'); + const milestone = { + timelineId: createdEntity.id, + name: mt.name, + description: mt.description, + type: mt.type, + duration: mt.duration, + order: mt.order, + plannedText: mt.plannedText, + activeText: mt.activeText, + blockedText: mt.blockedText, + completedText: mt.completedText, + hidden: !!mt.hidden, + details: {}, + status: MILESTONE_STATUS.REVIEWED, + startDate: startDate.format(), + endDate: endDate.format(), + createdBy: req.authUser.userId, + updatedBy: req.authUser.userId, + }; + if (!mt.hidden) { + startDate = endDate.add(1, 'days'); + } + return milestone; + }); + return models.Milestone.bulkCreate(milestones, { returning: true }) + .then((createdMilestones) => { + req.log.debug('Milestones created for timeline with template id %d', templateId); + result.milestones = _.map(createdMilestones, cm => _.omit(cm.toJSON(), 'deletedAt', 'deletedBy')); + }); + } + // no milestone template found for the template + req.log.debug('no milestone template found for the template id %d', templateId); + return Promise.resolve(); + }); + } + return Promise.resolve(); + }) + .catch(next); + }) + .then(() => { + // Send event to bus + req.log.debug('Sending event to RabbitMQ bus for timeline %d', result.id); + req.app.services.pubsub.publish(EVENT.ROUTING_KEY.TIMELINE_ADDED, + _.assign({ projectId: req.params.projectId }, result), + { correlationId: req.id }, + ); + // Write to the response + res.status(201).json(util.wrapResponse(req.id, result, 1, 201)); + return Promise.resolve(); + }) + .catch(next); + }, +]; diff --git a/src/routes/timelines/create.spec.js b/src/routes/timelines/create.spec.js new file mode 100644 index 00000000..c35d4661 --- /dev/null +++ b/src/routes/timelines/create.spec.js @@ -0,0 +1,621 @@ +/** + * Tests for create.js + */ +import chai from 'chai'; +import moment from 'moment'; +import request from 'supertest'; +import _ from 'lodash'; +import server from '../../app'; +import testUtil from '../../tests/util'; +import models from '../../models'; +import { EVENT, MILESTONE_STATUS } from '../../constants'; + +const should = chai.should(); + +const testProjects = [ + { + type: 'generic', + billingAccountId: 1, + name: 'test1', + description: 'test project1', + status: 'draft', + details: {}, + createdBy: 1, + updatedBy: 1, + }, + { + type: 'generic', + billingAccountId: 2, + name: 'test2', + description: 'test project2', + status: 'draft', + details: {}, + createdBy: 2, + updatedBy: 2, + deletedAt: '2018-05-15T00:00:00Z', + }, +]; + +const productTemplates = [ + { + name: 'name 1', + productKey: 'productKey 1', + category: 'generic', + icon: 'http://example.com/icon1.ico', + brief: 'brief 1', + details: 'details 1', + aliases: ['name-1'], + template: { }, + createdBy: 1, + updatedBy: 2, + }, +]; +const milestoneTemplates = [ + { + id: 1, + name: 'milestoneTemplate 1', + description: 'description 1', + duration: 3, + type: 'type1', + order: 1, + plannedText: 'text to be shown in planned stage', + blockedText: 'text to be shown in blocked stage', + activeText: 'text to be shown in active stage', + completedText: 'text to be shown in completed stage', + productTemplateId: 1, + createdBy: 1, + updatedBy: 2, + hidden: false, + }, + { + id: 2, + name: 'milestoneTemplate 2', + description: 'description 2', + duration: 4, + type: 'type2', + order: 2, + plannedText: 'text to be shown in planned stage - 2', + blockedText: 'text to be shown in blocked stage - 2', + activeText: 'text to be shown in active stage - 2', + completedText: 'text to be shown in completed stage - 2', + productTemplateId: 1, + createdBy: 2, + updatedBy: 3, + hidden: false, + }, + { + id: 3, + name: 'milestoneTemplate 3', + description: 'description 3', + duration: 5, + type: 'type3', + order: 3, + plannedText: 'text to be shown in planned stage - 3', + blockedText: 'text to be shown in blocked stage - 3', + activeText: 'text to be shown in active stage - 3', + completedText: 'text to be shown in completed stage - 3', + productTemplateId: 1, + createdBy: 2, + updatedBy: 3, + hidden: false, + deletedAt: new Date(), + }, +]; + +describe('CREATE timeline', () => { + let projectId1; + let projectId2; + + before((done) => { + testUtil.clearDb() + .then(() => { + models.Project.bulkCreate(testProjects, { returning: true }) + .then((projects) => { + projectId1 = projects[0].id; + projectId2 = projects[1].id; + + // Create member + models.ProjectMember.bulkCreate([ + { + userId: 40051332, + projectId: projectId1, + role: 'copilot', + isPrimary: true, + createdBy: 1, + updatedBy: 1, + }, + { + userId: 40051331, + projectId: projectId1, + role: 'customer', + isPrimary: true, + createdBy: 1, + updatedBy: 1, + }, + ]).then(() => + // Create phase + models.ProjectPhase.bulkCreate([ + { + projectId: projectId1, + name: 'test project phase 1', + status: 'active', + startDate: '2018-05-15T00:00:00Z', + endDate: '2018-05-15T12:00:00Z', + budget: 20.0, + progress: 1.23456, + details: { + message: 'This can be any json 2', + }, + createdBy: 1, + updatedBy: 1, + }, + { + projectId: projectId2, + name: 'test project phase 2', + status: 'active', + startDate: '2018-05-16T00:00:00Z', + endDate: '2018-05-16T12:00:00Z', + budget: 21.0, + progress: 1.234567, + details: { + message: 'This can be any json 2', + }, + createdBy: 2, + updatedBy: 2, + deletedAt: '2018-05-15T00:00:00Z', + }, + ])); + }); + }) + .then(() => models.ProductTemplate.bulkCreate(productTemplates)) + .then(() => models.ProductMilestoneTemplate.bulkCreate(milestoneTemplates)) + .then(() => { + done(); + }); + }); + + after(testUtil.clearDb); + + describe('POST /timelines', () => { + const body = { + param: { + name: 'new name', + description: 'new description', + startDate: '2018-05-29T00:00:00.000Z', + endDate: '2018-05-30T00:00:00.000Z', + reference: 'project', + referenceId: 1, + }, + }; + + it('should return 403 if user is not authenticated', (done) => { + request(server) + .post('/v4/timelines') + .send(body) + .expect(403, done); + }); + + it('should return 403 for member who is not in the project', (done) => { + request(server) + .post('/v4/timelines') + .set({ + Authorization: `Bearer ${testUtil.jwts.member2}`, + }) + .send(body) + .expect(403, done); + }); + + it('should return 403 for member who is not in the project (timeline refers to a phase)', (done) => { + const bodyWithPhase = { + param: _.assign({}, body.param, { + reference: 'phase', + referenceId: 1, + }), + }; + + request(server) + .post('/v4/timelines') + .set({ + Authorization: `Bearer ${testUtil.jwts.member2}`, + }) + .send(bodyWithPhase) + .expect(403, done); + }); + + it('should return 422 if missing name', (done) => { + const invalidBody = { + param: _.assign({}, body.param, { + name: undefined, + }), + }; + + request(server) + .post('/v4/timelines') + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .send(invalidBody) + .expect('Content-Type', /json/) + .expect(422, done); + }); + + it('should return 422 if missing startDate', (done) => { + const invalidBody = { + param: _.assign({}, body.param, { + startDate: undefined, + }), + }; + + request(server) + .post('/v4/timelines') + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .send(invalidBody) + .expect('Content-Type', /json/) + .expect(422, done); + }); + + it('should return 422 if startDate is after endDate', (done) => { + const invalidBody = { + param: _.assign({}, body.param, { + startDate: '2018-05-29T00:00:00.000Z', + endDate: '2018-05-28T00:00:00.000Z', + }), + }; + + request(server) + .post('/v4/timelines') + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .send(invalidBody) + .expect('Content-Type', /json/) + .expect(422, done); + }); + + it('should return 422 if missing reference', (done) => { + const invalidBody = { + param: _.assign({}, body.param, { + reference: undefined, + }), + }; + + request(server) + .post('/v4/timelines') + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .send(invalidBody) + .expect('Content-Type', /json/) + .expect(422, done); + }); + + it('should return 422 if missing referenceId', (done) => { + const invalidBody = { + param: _.assign({}, body.param, { + referenceId: undefined, + }), + }; + + request(server) + .post('/v4/timelines') + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .send(invalidBody) + .expect('Content-Type', /json/) + .expect(422, done); + }); + + it('should return 422 if invalid reference', (done) => { + const invalidBody = { + param: _.assign({}, body.param, { + reference: 'invalid', + }), + }; + + request(server) + .post('/v4/timelines') + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .send(invalidBody) + .expect('Content-Type', /json/) + .expect(422, done); + }); + + it('should return 422 if invalid referenceId', (done) => { + const invalidBody = { + param: _.assign({}, body.param, { + referenceId: 0, + }), + }; + + request(server) + .post('/v4/timelines') + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .send(invalidBody) + .expect('Content-Type', /json/) + .expect(422, done); + }); + + it('should return 422 if project does not exist', (done) => { + const invalidBody = { + param: _.assign({}, body.param, { + referenceId: 1110, + }), + }; + + request(server) + .post('/v4/timelines') + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .send(invalidBody) + .expect('Content-Type', /json/) + .expect(422, done); + }); + + it('should return 422 if project was deleted', (done) => { + const invalidBody = { + param: _.assign({}, body.param, { + referenceId: 2, + }), + }; + + request(server) + .post('/v4/timelines') + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .send(invalidBody) + .expect('Content-Type', /json/) + .expect(422, done); + }); + + it('should return 422 if phase does not exist', (done) => { + const invalidBody = { + param: _.assign({}, body.param, { + reference: 'phase', + referenceId: 2222, + }), + }; + + request(server) + .post('/v4/timelines') + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .send(invalidBody) + .expect('Content-Type', /json/) + .expect(422, done); + }); + + it('should return 422 if phase was deleted', (done) => { + const invalidBody = { + param: _.assign({}, body.param, { + reference: 'phase', + referenceId: 2, + }), + }; + + request(server) + .post('/v4/timelines') + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .send(invalidBody) + .expect('Content-Type', /json/) + .expect(422, done); + }); + + it('should return 201 for admin', (done) => { + request(server) + .post('/v4/timelines') + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .send(body) + .expect('Content-Type', /json/) + .expect(201) + .end((err, res) => { + const resJson = res.body.result.content; + should.exist(resJson.id); + resJson.name.should.be.eql(body.param.name); + resJson.description.should.be.eql(body.param.description); + resJson.startDate.should.be.eql(body.param.startDate); + resJson.endDate.should.be.eql(body.param.endDate); + resJson.reference.should.be.eql(body.param.reference); + resJson.referenceId.should.be.eql(body.param.referenceId); + + resJson.createdBy.should.be.eql(40051333); // admin + should.exist(resJson.createdAt); + resJson.updatedBy.should.be.eql(40051333); // admin + should.exist(resJson.updatedAt); + should.not.exist(resJson.deletedBy); + should.not.exist(resJson.deletedAt); + + // eslint-disable-next-line no-unused-expressions + server.services.pubsub.publish.calledWith(EVENT.ROUTING_KEY.TIMELINE_ADDED).should.be.true; + + done(); + }); + }); + + it('should return 201 for admin (with milestones)', (done) => { + const withMilestones = _.cloneDeep(body); + withMilestones.param.templateId = 1; + request(server) + .post('/v4/timelines') + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .send(withMilestones) + .expect('Content-Type', /json/) + .expect(201) + .end((err, res) => { + const resJson = res.body.result.content; + should.exist(resJson.id); + resJson.name.should.be.eql(body.param.name); + resJson.description.should.be.eql(body.param.description); + resJson.startDate.should.be.eql(body.param.startDate); + resJson.endDate.should.be.eql(body.param.endDate); + resJson.reference.should.be.eql(body.param.reference); + resJson.referenceId.should.be.eql(body.param.referenceId); + + resJson.createdBy.should.be.eql(40051333); // admin + should.exist(resJson.createdAt); + resJson.updatedBy.should.be.eql(40051333); // admin + should.exist(resJson.updatedAt); + should.not.exist(resJson.deletedBy); + should.not.exist(resJson.deletedAt); + + const milestones = resJson.milestones; + milestones.forEach((milestone, mIdx) => { + should.exist(milestone.id); + let expMilestoneTemplate; + if (mIdx === 0) { + expMilestoneTemplate = _.find(milestoneTemplates, mt => mt.id === 1); + } else if (mIdx === 1) { + expMilestoneTemplate = _.find(milestoneTemplates, mt => mt.id === 2); + } + milestone.timelineId.should.be.eql(resJson.id); + milestone.name.should.be.eql(expMilestoneTemplate.name); + milestone.description.should.be.eql(expMilestoneTemplate.description); + milestone.duration.should.be.eql(expMilestoneTemplate.duration); + // expected number of days, for starting the milestone, from the timeline start + let expDaysFromTimelineStart = 0; + _.each(milestoneTemplates, (mt, idx) => { + expDaysFromTimelineStart += (idx < mIdx ? mt.duration : 0); + }); + // calculates expected start date of the milestone + const expMilestoneStartDate = moment.utc(resJson.startDate).add(expDaysFromTimelineStart, 'days'); + // milestone created should have the expected start date + expMilestoneStartDate.diff(moment.utc(milestone.startDate), 'days').should.be.eql(0); + // calculates expected end date of the milestone + const expMilestoneEndDate = moment.utc(milestone.startDate).add(expMilestoneTemplate.duration - 1, 'days'); + // milestone created should have the expected end date + expMilestoneEndDate.diff(moment.utc(milestone.endDate), 'days').should.be.eql(0); + // completionDate should not be set yet + should.not.exist(milestone.completionDate); + // status should be reviewed for new milestones + milestone.status.should.be.eql(MILESTONE_STATUS.REVIEWED); + milestone.type.should.be.eql(expMilestoneTemplate.type); + milestone.details.should.be.eql({}); + milestone.order.should.be.eql(expMilestoneTemplate.order); + milestone.plannedText.should.be.eql(expMilestoneTemplate.plannedText); + milestone.activeText.should.be.eql(expMilestoneTemplate.activeText); + milestone.completedText.should.be.eql(expMilestoneTemplate.completedText); + milestone.blockedText.should.be.eql(expMilestoneTemplate.blockedText); + milestone.hidden.should.be.eql(expMilestoneTemplate.hidden); + + milestone.createdBy.should.be.eql(40051333); // admin + should.exist(milestone.createdAt); + milestone.updatedBy.should.be.eql(40051333); // admin + should.exist(milestone.updatedAt); + should.not.exist(milestone.deletedBy); + should.not.exist(milestone.deletedAt); + }); + + // eslint-disable-next-line no-unused-expressions + server.services.pubsub.publish.calledWith(EVENT.ROUTING_KEY.TIMELINE_ADDED).should.be.true; + + done(); + }); + }); + + it('should return 201 for connect manager', (done) => { + request(server) + .post('/v4/timelines') + .set({ + Authorization: `Bearer ${testUtil.jwts.manager}`, + }) + .send(body) + .expect('Content-Type', /json/) + .expect(201) + .end((err, res) => { + const resJson = res.body.result.content; + resJson.createdBy.should.be.eql(40051334); // manager + resJson.updatedBy.should.be.eql(40051334); // manager + done(); + }); + }); + + it('should return 201 for connect admin', (done) => { + request(server) + .post('/v4/timelines') + .set({ + Authorization: `Bearer ${testUtil.jwts.connectAdmin}`, + }) + .send(body) + .expect('Content-Type', /json/) + .expect(201) + .end((err, res) => { + const resJson = res.body.result.content; + resJson.createdBy.should.be.eql(40051336); // connect admin + resJson.updatedBy.should.be.eql(40051336); // connect admin + done(); + }); + }); + + it('should return 201 for copilot', (done) => { + request(server) + .post('/v4/timelines') + .set({ + Authorization: `Bearer ${testUtil.jwts.copilot}`, + }) + .send(body) + .expect('Content-Type', /json/) + .expect(201) + .end((err, res) => { + const resJson = res.body.result.content; + resJson.createdBy.should.be.eql(40051332); // copilot + resJson.updatedBy.should.be.eql(40051332); // copilot + done(); + }); + }); + + it('should return 201 for member', (done) => { + request(server) + .post('/v4/timelines') + .set({ + Authorization: `Bearer ${testUtil.jwts.member}`, + }) + .send(body) + .expect('Content-Type', /json/) + .expect(201) + .end((err, res) => { + const resJson = res.body.result.content; + resJson.createdBy.should.be.eql(40051331); // member + resJson.updatedBy.should.be.eql(40051331); // member + done(); + }); + }); + + it('should return 201 for member (timeline refers to a phase)', (done) => { + const bodyWithPhase = _.merge({}, body, { + param: { + reference: 'phase', + referenceId: 1, + }, + }); + request(server) + .post('/v4/timelines') + .set({ + Authorization: `Bearer ${testUtil.jwts.member}`, + }) + .send(bodyWithPhase) + .expect('Content-Type', /json/) + .expect(201) + .end((err, res) => { + const resJson = res.body.result.content; + resJson.createdBy.should.be.eql(40051331); // member + resJson.updatedBy.should.be.eql(40051331); // member + done(); + }); + }); + }); +}); diff --git a/src/routes/timelines/delete.js b/src/routes/timelines/delete.js new file mode 100644 index 00000000..911291ec --- /dev/null +++ b/src/routes/timelines/delete.js @@ -0,0 +1,52 @@ +/** + * API to delete a timeline + */ +import validate from 'express-validation'; +import Joi from 'joi'; +import _ from 'lodash'; +import { middleware as tcMiddleware } from 'tc-core-library-js'; +import models from '../../models'; +import { EVENT } from '../../constants'; +import validateTimeline from '../../middlewares/validateTimeline'; + +const permissions = tcMiddleware.permissions; + +const schema = { + params: { + timelineId: Joi.number().integer().positive().required(), + }, +}; + +module.exports = [ + validate(schema), + // Validate and get projectId from the timelineId param, and set to request params for + // checking by the permissions middleware + validateTimeline.validateTimelineIdParam, + permissions('timeline.delete'), + (req, res, next) => { + const timeline = req.timeline; + const deleted = _.omit(timeline.toJSON(), ['deletedAt', 'deletedBy']); + + return models.sequelize.transaction(() => + // Update the deletedBy, then delete + timeline.update({ deletedBy: req.authUser.userId }) + .then(() => timeline.destroy()) + // Cascade delete the milestones + .then(() => models.Milestone.update({ deletedBy: req.authUser.userId }, { where: { timelineId: timeline.id } })) + .then(() => models.Milestone.destroy({ where: { timelineId: timeline.id } })), + ) + .then(() => { + // Send event to bus + req.log.debug('Sending event to RabbitMQ bus for timeline %d', deleted.id); + req.app.services.pubsub.publish(EVENT.ROUTING_KEY.TIMELINE_REMOVED, + deleted, + { correlationId: req.id }, + ); + + // Write to response + res.status(204).end(); + return Promise.resolve(); + }) + .catch(next); + }, +]; diff --git a/src/routes/timelines/delete.spec.js b/src/routes/timelines/delete.spec.js new file mode 100644 index 00000000..44ad6f07 --- /dev/null +++ b/src/routes/timelines/delete.spec.js @@ -0,0 +1,335 @@ +/** + * Tests for delete.js + */ +import request from 'supertest'; +import chai from 'chai'; + +import models from '../../models'; +import server from '../../app'; +import testUtil from '../../tests/util'; +import { EVENT } from '../../constants'; + +const should = chai.should(); // eslint-disable-line no-unused-vars + +const expectAfterDelete = (id, err, next) => { + if (err) throw err; + setTimeout(() => + models.Timeline.findOne({ + where: { + id, + }, + paranoid: false, + }) + .then((res) => { + if (!res) { + throw new Error('Should found the entity'); + } else { + chai.assert.isNotNull(res.deletedAt); + chai.assert.isNotNull(res.deletedBy); + + request(server) + .get(`/v4/timelines/${id}`) + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .expect(404, next); + } + }), 500); +}; + +describe('DELETE timeline', () => { + beforeEach((done) => { + testUtil.clearDb() + .then(() => { + models.Project.bulkCreate([ + { + type: 'generic', + billingAccountId: 1, + name: 'test1', + description: 'test project1', + status: 'draft', + details: {}, + createdBy: 1, + updatedBy: 1, + }, + { + type: 'generic', + billingAccountId: 2, + name: 'test2', + description: 'test project2', + status: 'draft', + details: {}, + createdBy: 2, + updatedBy: 2, + deletedAt: '2018-05-15T00:00:00Z', + }, + ]) + .then(() => { + // Create member + models.ProjectMember.bulkCreate([ + { + userId: 40051332, + projectId: 1, + role: 'copilot', + isPrimary: true, + createdBy: 1, + updatedBy: 1, + }, + { + userId: 40051331, + projectId: 1, + role: 'customer', + isPrimary: true, + createdBy: 1, + updatedBy: 1, + }, + ]).then(() => + // Create phase + models.ProjectPhase.bulkCreate([ + { + projectId: 1, + name: 'test project phase 1', + status: 'active', + startDate: '2018-05-15T00:00:00Z', + endDate: '2018-05-15T12:00:00Z', + budget: 20.0, + progress: 1.23456, + details: { + message: 'This can be any json 2', + }, + createdBy: 1, + updatedBy: 1, + }, + { + projectId: 2, + name: 'test project phase 2', + status: 'active', + startDate: '2018-05-16T00:00:00Z', + endDate: '2018-05-16T12:00:00Z', + budget: 21.0, + progress: 1.234567, + details: { + message: 'This can be any json 2', + }, + createdBy: 2, + updatedBy: 2, + deletedAt: '2018-05-15T00:00:00Z', + }, + ])) + .then(() => + // Create timelines + models.Timeline.bulkCreate([ + { + name: 'name 1', + description: 'description 1', + startDate: '2018-05-11T00:00:00.000Z', + endDate: '2018-05-12T00:00:00.000Z', + reference: 'project', + referenceId: 1, + createdBy: 1, + updatedBy: 1, + }, + { + name: 'name 2', + description: 'description 2', + startDate: '2018-05-12T00:00:00.000Z', + endDate: '2018-05-13T00:00:00.000Z', + reference: 'phase', + referenceId: 1, + createdBy: 1, + updatedBy: 1, + }, + { + name: 'name 3', + description: 'description 3', + startDate: '2018-05-13T00:00:00.000Z', + endDate: '2018-05-14T00:00:00.000Z', + reference: 'phase', + referenceId: 1, + createdBy: 1, + updatedBy: 1, + deletedAt: '2018-05-14T00:00:00.000Z', + }, + ])) + .then(() => + // Create milestones + models.Milestone.bulkCreate([ + { + timelineId: 1, + name: 'milestone 1', + duration: 2, + startDate: '2018-05-03T00:00:00.000Z', + status: 'open', + type: 'type1', + details: { + detail1: { + subDetail1A: 1, + subDetail1B: 2, + }, + detail2: [1, 2, 3], + }, + order: 1, + plannedText: 'plannedText 1', + activeText: 'activeText 1', + completedText: 'completedText 1', + blockedText: 'blockedText 1', + createdBy: 1, + updatedBy: 2, + }, + { + timelineId: 1, + name: 'milestone 2', + duration: 2, + startDate: '2018-05-03T00:00:00.000Z', + status: 'open', + type: 'type1', + details: { + detail1: { + subDetail1A: 1, + subDetail1B: 2, + }, + detail2: [1, 2, 3], + }, + order: 1, + plannedText: 'plannedText 1', + activeText: 'activeText 1', + completedText: 'completedText 1', + blockedText: 'blockedText 1', + createdBy: 1, + updatedBy: 2, + }, + ])) + .then(() => done()); + }); + }); + }); + + after(testUtil.clearDb); + + + describe('DELETE /timelines/{timelineId}', () => { + it('should return 403 if user is not authenticated', (done) => { + request(server) + .delete('/v4/timelines/1') + .expect(403, done); + }); + + it('should return 403 for member who is not in the project', (done) => { + request(server) + .delete('/v4/timelines/1') + .set({ + Authorization: `Bearer ${testUtil.jwts.member2}`, + }) + .expect(403, done); + }); + + it('should return 403 for member who is not in the project (timeline refers to a phase)', (done) => { + request(server) + .delete('/v4/timelines/2') + .set({ + Authorization: `Bearer ${testUtil.jwts.member2}`, + }) + .expect(403, done); + }); + + it('should return 404 for non-existed timeline', (done) => { + request(server) + .delete('/v4/timelines/1234') + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .expect(404, done); + }); + + it('should return 404 for deleted timeline', (done) => { + request(server) + .delete('/v4/timelines/3') + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .expect(404, done); + }); + + it('should return 422 for invalid param', (done) => { + request(server) + .delete('/v4/timelines/0') + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .expect(422, done); + }); + + // eslint-disable-next-line func-names + it('should return 204, for admin, if timeline was successfully removed', function (done) { + this.timeout(10000); + + models.Milestone.findAll({ where: { timelineId: 1 } }) + .then((results) => { + results.should.have.length(2); + + request(server) + .delete('/v4/timelines/1') + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .expect(204) + .end((err) => { + expectAfterDelete(1, err, () => { + // eslint-disable-next-line no-unused-expressions + server.services.pubsub.publish.calledWith(EVENT.ROUTING_KEY.TIMELINE_REMOVED).should.be.true; + + // Milestones are cascade deleted + setTimeout(() => { + models.Milestone.findAll({ where: { timelineId: 1 } }) + .then((afterResults) => { + afterResults.should.have.length(0); + + done(); + }); + }, 3000); + }); + }); + }); + }); + + it('should return 204, for connect admin, if timeline was successfully removed', (done) => { + request(server) + .delete('/v4/timelines/1') + .set({ + Authorization: `Bearer ${testUtil.jwts.connectAdmin}`, + }) + .expect(204) + .end(err => expectAfterDelete(1, err, done)); + }); + + it('should return 204, for connect manager, if timeline was successfully removed', (done) => { + request(server) + .delete('/v4/timelines/1') + .set({ + Authorization: `Bearer ${testUtil.jwts.manager}`, + }) + .expect(204) + .end(err => expectAfterDelete(1, err, done)); + }); + + it('should return 204, for copilot, if timeline was successfully removed', (done) => { + request(server) + .delete('/v4/timelines/1') + .set({ + Authorization: `Bearer ${testUtil.jwts.copilot}`, + }) + .expect(204) + .end(err => expectAfterDelete(1, err, done)); + }); + + it('should return 204, for member, if timeline was successfully removed', (done) => { + request(server) + .delete('/v4/timelines/1') + .set({ + Authorization: `Bearer ${testUtil.jwts.member}`, + }) + .expect(204) + .end(err => expectAfterDelete(1, err, done)); + }); + }); +}); diff --git a/src/routes/timelines/get.js b/src/routes/timelines/get.js new file mode 100644 index 00000000..c02ff4be --- /dev/null +++ b/src/routes/timelines/get.js @@ -0,0 +1,37 @@ +/** + * API to get a timeline + */ +import validate from 'express-validation'; +import Joi from 'joi'; +import _ from 'lodash'; +import { middleware as tcMiddleware } from 'tc-core-library-js'; +import util from '../../util'; +import validateTimeline from '../../middlewares/validateTimeline'; + +const permissions = tcMiddleware.permissions; + +const schema = { + params: { + timelineId: Joi.number().integer().positive().required(), + }, +}; + +module.exports = [ + validate(schema), + // Validate and get projectId from the timelineId param, and set to request params for + // checking by the permissions middleware + validateTimeline.validateTimelineIdParam, + permissions('timeline.view'), + (req, res) => { + // Load the milestones + req.timeline.getMilestones() + .then((milestones) => { + const timeline = _.omit(req.timeline.toJSON(), ['deletedAt', 'deletedBy']); + timeline.milestones = + _.map(milestones, milestone => _.omit(milestone.toJSON(), ['deletedAt', 'deletedBy'])); + + // Write to response + res.json(util.wrapResponse(req.id, timeline)); + }); + }, +]; diff --git a/src/routes/timelines/get.spec.js b/src/routes/timelines/get.spec.js new file mode 100644 index 00000000..253013da --- /dev/null +++ b/src/routes/timelines/get.spec.js @@ -0,0 +1,304 @@ +/** + * Tests for get.js + */ +import chai from 'chai'; +import request from 'supertest'; + +import models from '../../models'; +import server from '../../app'; +import testUtil from '../../tests/util'; + +const should = chai.should(); + +const milestones = [ + { + id: 1, + timelineId: 1, + name: 'milestone 1', + duration: 2, + startDate: '2018-05-03T00:00:00.000Z', + endDate: '2018-05-04T00:00:00.000Z', + completionDate: '2018-05-05T00:00:00.000Z', + status: 'open', + type: 'type1', + details: { + detail1: { + subDetail1A: 1, + subDetail1B: 2, + }, + detail2: [1, 2, 3], + }, + order: 1, + plannedText: 'plannedText 1', + activeText: 'activeText 1', + completedText: 'completedText 1', + blockedText: 'blockedText 1', + createdBy: 1, + updatedBy: 2, + createdAt: '2018-05-11T00:00:00.000Z', + updatedAt: '2018-05-11T00:00:00.000Z', + }, + { + id: 2, + timelineId: 1, + name: 'milestone 2', + duration: 3, + startDate: '2018-05-04T00:00:00.000Z', + status: 'open', + type: 'type2', + order: 2, + plannedText: 'plannedText 2', + activeText: 'activeText 2', + completedText: 'completedText 2', + blockedText: 'blockedText 2', + createdBy: 2, + updatedBy: 3, + createdAt: '2018-05-11T00:00:00.000Z', + updatedAt: '2018-05-11T00:00:00.000Z', + }, +]; + +describe('GET timeline', () => { + before((done) => { + testUtil.clearDb() + .then(() => { + models.Project.bulkCreate([ + { + type: 'generic', + billingAccountId: 1, + name: 'test1', + description: 'test project1', + status: 'draft', + details: {}, + createdBy: 1, + updatedBy: 1, + }, + { + type: 'generic', + billingAccountId: 2, + name: 'test2', + description: 'test project2', + status: 'draft', + details: {}, + createdBy: 2, + updatedBy: 2, + deletedAt: '2018-05-15T00:00:00Z', + }, + ]) + .then(() => { + // Create member + models.ProjectMember.bulkCreate([ + { + userId: 40051332, + projectId: 1, + role: 'copilot', + isPrimary: true, + createdBy: 1, + updatedBy: 1, + }, + { + userId: 40051331, + projectId: 1, + role: 'customer', + isPrimary: true, + createdBy: 1, + updatedBy: 1, + }, + ]).then(() => + // Create phase + models.ProjectPhase.bulkCreate([ + { + projectId: 1, + name: 'test project phase 1', + status: 'active', + startDate: '2018-05-15T00:00:00Z', + endDate: '2018-05-15T12:00:00Z', + budget: 20.0, + progress: 1.23456, + details: { + message: 'This can be any json 2', + }, + createdBy: 1, + updatedBy: 1, + }, + { + projectId: 2, + name: 'test project phase 2', + status: 'active', + startDate: '2018-05-16T00:00:00Z', + endDate: '2018-05-16T12:00:00Z', + budget: 21.0, + progress: 1.234567, + details: { + message: 'This can be any json 2', + }, + createdBy: 2, + updatedBy: 2, + deletedAt: '2018-05-15T00:00:00Z', + }, + ])) + .then(() => + // Create timelines + models.Timeline.bulkCreate([ + { + name: 'name 1', + description: 'description 1', + startDate: '2018-05-11T00:00:00.000Z', + endDate: '2018-05-12T00:00:00.000Z', + reference: 'project', + referenceId: 1, + createdBy: 1, + updatedBy: 1, + }, + { + name: 'name 2', + description: 'description 2', + startDate: '2018-05-12T00:00:00.000Z', + endDate: '2018-05-13T00:00:00.000Z', + reference: 'phase', + referenceId: 1, + createdBy: 1, + updatedBy: 1, + }, + { + name: 'name 3', + description: 'description 3', + startDate: '2018-05-13T00:00:00.000Z', + endDate: '2018-05-14T00:00:00.000Z', + reference: 'phase', + referenceId: 1, + createdBy: 1, + updatedBy: 1, + deletedAt: '2018-05-14T00:00:00.000Z', + }, + ])) + .then(() => models.Milestone.bulkCreate(milestones)) + .then(() => done()); + }); + }); + }); + + after(testUtil.clearDb); + + describe('GET /timelines/{timelineId}', () => { + it('should return 403 if user is not authenticated', (done) => { + request(server) + .get('/v4/timelines/1') + .expect(403, done); + }); + + it('should return 403 for member who is not in the project', (done) => { + request(server) + .get('/v4/timelines/1') + .set({ + Authorization: `Bearer ${testUtil.jwts.member2}`, + }) + .expect(403, done); + }); + + it('should return 403 for member who is not in the project (timeline refers to a phase)', (done) => { + request(server) + .get('/v4/timelines/2') + .set({ + Authorization: `Bearer ${testUtil.jwts.member2}`, + }) + .expect(403, done); + }); + + it('should return 404 for non-existed timeline', (done) => { + request(server) + .get('/v4/timelines/1234') + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .expect(404, done); + }); + + it('should return 404 for deleted timeline', (done) => { + request(server) + .get('/v4/timelines/3') + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .expect(404, done); + }); + + it('should return 422 for invalid param', (done) => { + request(server) + .get('/v4/timelines/0') + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .expect(422, done); + }); + + it('should return 200 for admin', (done) => { + request(server) + .get('/v4/timelines/1') + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .expect(200) + .end((err, res) => { + const resJson = res.body.result.content; + resJson.id.should.be.eql(1); + resJson.name.should.be.eql('name 1'); + resJson.description.should.be.eql('description 1'); + resJson.startDate.should.be.eql('2018-05-11T00:00:00.000Z'); + resJson.endDate.should.be.eql('2018-05-12T00:00:00.000Z'); + resJson.reference.should.be.eql('project'); + resJson.referenceId.should.be.eql(1); + + resJson.createdBy.should.be.eql(1); + should.exist(resJson.createdAt); + resJson.updatedBy.should.be.eql(1); + should.exist(resJson.updatedAt); + should.not.exist(resJson.deletedBy); + should.not.exist(resJson.deletedAt); + + // Milestones + resJson.milestones.should.have.length(2); + + done(); + }); + }); + + it('should return 200 for connect admin', (done) => { + request(server) + .get('/v4/timelines/1') + .set({ + Authorization: `Bearer ${testUtil.jwts.connectAdmin}`, + }) + .expect(200) + .end(done); + }); + + it('should return 200 for connect manager', (done) => { + request(server) + .get('/v4/timelines/1') + .set({ + Authorization: `Bearer ${testUtil.jwts.manager}`, + }) + .expect(200) + .end(done); + }); + + it('should return 200 for member', (done) => { + request(server) + .get('/v4/timelines/1') + .set({ + Authorization: `Bearer ${testUtil.jwts.member}`, + }) + .expect(200, done); + }); + + it('should return 200 for copilot', (done) => { + request(server) + .get('/v4/timelines/1') + .set({ + Authorization: `Bearer ${testUtil.jwts.copilot}`, + }) + .expect(200, done); + }); + }); +}); diff --git a/src/routes/timelines/list.js b/src/routes/timelines/list.js new file mode 100644 index 00000000..cf7777e8 --- /dev/null +++ b/src/routes/timelines/list.js @@ -0,0 +1,56 @@ +/** + * API to list all timelines + */ +import config from 'config'; +import _ from 'lodash'; +import { middleware as tcMiddleware } from 'tc-core-library-js'; +import util from '../../util'; +import validateTimeline from '../../middlewares/validateTimeline'; + +const ES_TIMELINE_INDEX = config.get('elasticsearchConfig.timelineIndexName'); +const ES_TIMELINE_TYPE = config.get('elasticsearchConfig.timelineDocType'); + +/** + * Retrieve timelines from elastic search. + * @param {Array} esTerms the elastic search terms + * @returns {Promise} the promise resolves to the results + */ +function retrieveTimelines(esTerms) { + return new Promise((accept, reject) => { + const es = util.getElasticSearchClient(); + es.search({ + index: ES_TIMELINE_INDEX, + type: ES_TIMELINE_TYPE, + body: { + query: { bool: { must: esTerms } }, + }, + }).then((docs) => { + const rows = _.map(docs.hits.hits, single => _.omit(single._source, ['projectId'])); // eslint-disable-line no-underscore-dangle + accept({ rows, count: docs.hits.total }); + }).catch(reject); + }); +} + +const permissions = tcMiddleware.permissions; + +module.exports = [ + // Validate and get projectId from the reference/referenceId pair, and set to request params for + // checking by the permissions middleware + validateTimeline.validateTimelineQueryFilter, + permissions('timeline.view'), + (req, res, next) => { + const filter = req.params.filter; + + // Build the elastic search query + const esTerms = [{ + term: { reference: filter.reference }, + }, { + term: { referenceId: filter.referenceId }, + }]; + + // Retrieve timelines, as we know the user has access for the provided reference/referenceId part + return retrieveTimelines(esTerms) + .then(result => res.json(util.wrapResponse(req.id, result.rows, result.count))) + .catch(err => next(err)); + }, +]; diff --git a/src/routes/timelines/list.spec.js b/src/routes/timelines/list.spec.js new file mode 100644 index 00000000..877141de --- /dev/null +++ b/src/routes/timelines/list.spec.js @@ -0,0 +1,362 @@ +/** + * Tests for list.js + */ +import chai from 'chai'; +import request from 'supertest'; +import sleep from 'sleep'; +import config from 'config'; +import _ from 'lodash'; + +import models from '../../models'; +import server from '../../app'; +import testUtil from '../../tests/util'; + +const ES_TIMELINE_INDEX = config.get('elasticsearchConfig.timelineIndexName'); +const ES_TIMELINE_TYPE = config.get('elasticsearchConfig.timelineDocType'); + +const should = chai.should(); + +const timelines = [ + { + name: 'name 1', + description: 'description 1', + startDate: '2018-05-11T00:00:00.000Z', + endDate: '2018-05-12T00:00:00.000Z', + reference: 'project', + referenceId: 1, + createdBy: 1, + updatedBy: 1, + }, + { + name: 'name 2', + description: 'description 2', + startDate: '2018-05-12T00:00:00.000Z', + endDate: '2018-05-13T00:00:00.000Z', + reference: 'phase', + referenceId: 1, + createdBy: 1, + updatedBy: 1, + }, + { + name: 'name 3', + description: 'description 3', + startDate: '2018-05-13T00:00:00.000Z', + endDate: '2018-05-14T00:00:00.000Z', + reference: 'phase', + referenceId: 2, + createdBy: 1, + updatedBy: 1, + }, +]; +const milestones = [ + { + id: 1, + timelineId: 1, + name: 'milestone 1', + duration: 2, + startDate: '2018-05-03T00:00:00.000Z', + endDate: '2018-05-04T00:00:00.000Z', + completionDate: '2018-05-05T00:00:00.000Z', + status: 'open', + type: 'type1', + details: { + detail1: { + subDetail1A: 1, + subDetail1B: 2, + }, + detail2: [1, 2, 3], + }, + order: 1, + plannedText: 'plannedText 1', + activeText: 'activeText 1', + completedText: 'completedText 1', + blockedText: 'blockedText 1', + createdBy: 1, + updatedBy: 2, + createdAt: '2018-05-11T00:00:00.000Z', + updatedAt: '2018-05-11T00:00:00.000Z', + }, + { + id: 2, + timelineId: 1, + name: 'milestone 2', + duration: 3, + startDate: '2018-05-04T00:00:00.000Z', + status: 'open', + type: 'type2', + order: 2, + plannedText: 'plannedText 2', + activeText: 'activeText 2', + completedText: 'completedText 2', + blockedText: 'blockedText 2', + createdBy: 2, + updatedBy: 3, + createdAt: '2018-05-11T00:00:00.000Z', + updatedAt: '2018-05-11T00:00:00.000Z', + }, +]; + + +describe('LIST timelines', () => { + before(function beforeHook(done) { + this.timeout(10000); + testUtil.clearDb() + .then(() => { + models.Project.bulkCreate([ + { + type: 'generic', + billingAccountId: 1, + name: 'test1', + description: 'test project1', + status: 'draft', + details: {}, + createdBy: 1, + updatedBy: 1, + }, + { + type: 'generic', + billingAccountId: 2, + name: 'test2', + description: 'test project2', + status: 'draft', + details: {}, + createdBy: 2, + updatedBy: 2, + deletedAt: '2018-05-15T00:00:00Z', + }, + ]) + .then(() => { + // Create member + models.ProjectMember.bulkCreate([ + { + userId: 40051332, + projectId: 1, + role: 'copilot', + isPrimary: true, + createdBy: 1, + updatedBy: 1, + }, + { + userId: 40051331, + projectId: 1, + role: 'customer', + isPrimary: true, + createdBy: 1, + updatedBy: 1, + }, + ]).then(() => + // Create phase + models.ProjectPhase.bulkCreate([ + { + projectId: 1, + name: 'test project phase 1', + status: 'active', + startDate: '2018-05-15T00:00:00Z', + endDate: '2018-05-15T12:00:00Z', + budget: 20.0, + progress: 1.23456, + details: { + message: 'This can be any json 2', + }, + createdBy: 1, + updatedBy: 1, + }, + { + projectId: 2, + name: 'test project phase 2', + status: 'active', + startDate: '2018-05-16T00:00:00Z', + endDate: '2018-05-16T12:00:00Z', + budget: 21.0, + progress: 1.234567, + details: { + message: 'This can be any json 2', + }, + createdBy: 2, + updatedBy: 2, + }, + ])) + .then(() => + // Create timelines + models.Timeline.bulkCreate(timelines, { returning: true })) + .then(createdTimelines => + // Index to ES + Promise.all(_.map(createdTimelines, (createdTimeline) => { + const timelineJson = _.omit(createdTimeline.toJSON(), 'deletedAt', 'deletedBy'); + timelineJson.projectId = createdTimeline.id !== 3 ? 1 : 2; + if (timelineJson.id === 1) { + timelineJson.milestones = milestones; + } + return server.services.es.index({ + index: ES_TIMELINE_INDEX, + type: ES_TIMELINE_TYPE, + id: timelineJson.id, + body: timelineJson, + }); + })) + .then(() => { + // sleep for some time, let elasticsearch indices be settled + sleep.sleep(5); + done(); + })); + }); + }); + }); + + after(testUtil.clearDb); + + describe('GET /timelines', () => { + it('should return 403 if user is not authenticated', (done) => { + request(server) + .get('/v4/timelines') + .expect(403, done); + }); + + it('should return 422 for invalid filter key', (done) => { + request(server) + .get('/v4/timelines?filter=invalid%3Dproject') + .set({ + Authorization: `Bearer ${testUtil.jwts.connectAdmin}`, + }) + .expect(422) + .end(done); + }); + + it('should return 422 for invalid reference filter', (done) => { + request(server) + .get('/v4/timelines?filter=reference%3Dinvalid%26referenceId%3D1') + .set({ + Authorization: `Bearer ${testUtil.jwts.connectAdmin}`, + }) + .expect(422) + .end(done); + }); + + it('should return 422 for invalid referenceId filter', (done) => { + request(server) + .get('/v4/timelines?filter=reference%3Dinvalid%26referenceId%3D0') + .set({ + Authorization: `Bearer ${testUtil.jwts.connectAdmin}`, + }) + .expect(422) + .end(done); + }); + + it('should return 200 for admin', (done) => { + request(server) + .get('/v4/timelines?filter=reference%3Dproject%26referenceId%3D1') + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .expect(200) + .end((err, res) => { + const timeline = timelines[0]; + + let resJson = res.body.result.content; + resJson.should.have.length(1); + resJson = _.sortBy(resJson, o => o.id); + resJson[0].id.should.be.eql(1); + resJson[0].name.should.be.eql(timeline.name); + resJson[0].description.should.be.eql(timeline.description); + resJson[0].startDate.should.be.eql(timeline.startDate); + resJson[0].endDate.should.be.eql(timeline.endDate); + resJson[0].reference.should.be.eql(timeline.reference); + resJson[0].referenceId.should.be.eql(timeline.referenceId); + + resJson[0].createdBy.should.be.eql(timeline.createdBy); + should.exist(resJson[0].createdAt); + resJson[0].updatedBy.should.be.eql(timeline.updatedBy); + should.exist(resJson[0].updatedAt); + should.not.exist(resJson[0].deletedBy); + should.not.exist(resJson[0].deletedAt); + + // Milestones + resJson[0].milestones.should.have.length(2); + + done(); + }); + }); + + it('should return 200 for connect admin', (done) => { + request(server) + .get('/v4/timelines?filter=reference%3Dproject%26referenceId%3D1') + .set({ + Authorization: `Bearer ${testUtil.jwts.connectAdmin}`, + }) + .expect(200) + .end((err, res) => { + const resJson = res.body.result.content; + resJson.should.have.length(1); + + done(); + }); + }); + + it('should return 200 for connect manager', (done) => { + request(server) + .get('/v4/timelines?filter=reference%3Dproject%26referenceId%3D1') + .set({ + Authorization: `Bearer ${testUtil.jwts.manager}`, + }) + .expect(200) + .end((err, res) => { + const resJson = res.body.result.content; + resJson.should.have.length(1); + + done(); + }); + }); + + it('should return 200 for member', (done) => { + request(server) + .get('/v4/timelines?filter=reference%3Dproject%26referenceId%3D1') + .set({ + Authorization: `Bearer ${testUtil.jwts.member}`, + }) + .end((err, res) => { + const resJson = res.body.result.content; + resJson.should.have.length(1); + + done(); + }); + }); + + it('should return 200 for copilot', (done) => { + request(server) + .get('/v4/timelines?filter=reference%3Dproject%26referenceId%3D1') + .set({ + Authorization: `Bearer ${testUtil.jwts.copilot}`, + }) + .end((err, res) => { + const resJson = res.body.result.content; + resJson.should.have.length(1); + + done(); + }); + }); + + it('should return 403 for member with not accessible project', (done) => { + request(server) + .get('/v4/timelines?filter=reference%3Dproject%26referenceId%3D1') + .set({ + Authorization: `Bearer ${testUtil.jwts.member2}`, + }) + .expect(403, done); + }); + + it('should return 200 with reference and referenceId filter', (done) => { + request(server) + .get('/v4/timelines?filter=reference%3Dproject%26referenceId%3D1') + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .expect(200) + .end((err, res) => { + const resJson = res.body.result.content; + resJson.should.have.length(1); + + done(); + }); + }); + }); +}); diff --git a/src/routes/timelines/update.js b/src/routes/timelines/update.js new file mode 100644 index 00000000..e91783e4 --- /dev/null +++ b/src/routes/timelines/update.js @@ -0,0 +1,114 @@ +/** + * API to update a timeline + */ +import validate from 'express-validation'; +import _ from 'lodash'; +import moment from 'moment'; +import Joi from 'joi'; +import { middleware as tcMiddleware } from 'tc-core-library-js'; +import util from '../../util'; +import validateTimeline from '../../middlewares/validateTimeline'; +import { EVENT, TIMELINE_REFERENCES } from '../../constants'; + +const permissions = tcMiddleware.permissions; + +const schema = { + params: { + timelineId: Joi.number().integer().positive().required(), + }, + body: { + param: Joi.object().keys({ + id: Joi.any().strip(), + name: Joi.string().max(255).required(), + description: Joi.string().max(255), + startDate: Joi.date().required(), + endDate: Joi.date().min(Joi.ref('startDate')).allow(null), + reference: Joi.string().valid(_.values(TIMELINE_REFERENCES)).required(), + referenceId: Joi.number().integer().positive().required(), + createdAt: Joi.any().strip(), + updatedAt: Joi.any().strip(), + deletedAt: Joi.any().strip(), + createdBy: Joi.any().strip(), + updatedBy: Joi.any().strip(), + deletedBy: Joi.any().strip(), + }).required(), + }, +}; + +module.exports = [ + validate(schema), + // Validate and get projectId from the timelineId param and request body, + // and set to request params for checking by the permissions middleware + validateTimeline.validateTimelineIdParam, + validateTimeline.validateTimelineRequestBody, + permissions('timeline.edit'), + (req, res, next) => { + const entityToUpdate = _.assign(req.body.param, { + updatedBy: req.authUser.userId, + }); + + const timeline = req.timeline; + const original = _.omit(timeline.toJSON(), ['deletedAt', 'deletedBy']); + let updated; + + // Update + return timeline.update(entityToUpdate) + .then((updatedTimeline) => { + // Omit deletedAt, deletedBy + updated = _.omit(updatedTimeline.toJSON(), ['deletedAt', 'deletedBy']); + + // Update milestones startDate and endDate if necessary, if the timeline startDate changed + if (original.startDate.getTime() !== updated.startDate.getTime()) { + return updatedTimeline.getMilestones() + .then((milestones) => { + let startDate = updated.startDate; + + // Process milestones in order + const updateMilestonePromises = _.chain(milestones).sortBy('order').map((_milestone) => { + const milestone = _milestone; + + // Update if the iterating startDate is different than the saved one + if (milestone.startDate.getTime() !== startDate.getTime()) { + milestone.startDate = startDate; + milestone.updatedBy = req.authUser.userId; + } + + // Make sure the endDate is the correct, i.e. for duration = 1 it should be equal to the start date, + // for duration = 2 it should be equal to the next day and so on... + const endDate = moment.utc(milestone.startDate).add(milestone.duration - 1, 'days').toDate(); + if (!milestone.endDate || endDate.getTime() !== milestone.endDate.getTime()) { + milestone.endDate = endDate; + milestone.updatedBy = req.authUser.userId; + } + + // Next iterated milestone should have as startDate this milestone's endDate plus one day + startDate = moment.utc(milestone.endDate).add(1, 'days').toDate(); + return milestone.save(); + }).value(); + + return Promise.all(updateMilestonePromises) + .then((updatedMilestones) => { + updated.milestones = + _.map(updatedMilestones, milestone => _.omit(milestone.toJSON(), ['deletedAt', 'deletedBy'])); + return Promise.resolve(); + }); + }); + } + + return Promise.resolve(); + }) + .then(() => { + // Send event to bus + req.log.debug('Sending event to RabbitMQ bus for timeline %d', updated.id); + req.app.services.pubsub.publish(EVENT.ROUTING_KEY.TIMELINE_UPDATED, + { original, updated }, + { correlationId: req.id }, + ); + + // Write to response + res.json(util.wrapResponse(req.id, updated)); + return Promise.resolve(); + }) + .catch(next); + }, +]; diff --git a/src/routes/timelines/update.spec.js b/src/routes/timelines/update.spec.js new file mode 100644 index 00000000..cf00398c --- /dev/null +++ b/src/routes/timelines/update.spec.js @@ -0,0 +1,627 @@ +/** + * Tests for get.js + */ +import chai from 'chai'; +import request from 'supertest'; +import _ from 'lodash'; +import models from '../../models'; +import server from '../../app'; +import testUtil from '../../tests/util'; +import { EVENT } from '../../constants'; + +const should = chai.should(); + + +const milestones = [ + { + id: 1, + timelineId: 1, + name: 'milestone 1', + duration: 2, + startDate: '2018-05-13T00:00:00.000Z', + endDate: '2018-05-16T00:00:00.000Z', + completionDate: '2018-05-05T00:00:00.000Z', + status: 'open', + type: 'type1', + details: { + detail1: { + subDetail1A: 1, + subDetail1B: 2, + }, + detail2: [1, 2, 3], + }, + order: 1, + plannedText: 'plannedText 1', + activeText: 'activeText 1', + completedText: 'completedText 1', + blockedText: 'blockedText 1', + createdBy: 1, + updatedBy: 2, + createdAt: '2018-05-11T00:00:00.000Z', + updatedAt: '2018-05-11T00:00:00.000Z', + }, + { + id: 2, + timelineId: 1, + name: 'milestone 2', + duration: 3, + startDate: '2018-05-14T00:00:00.000Z', + status: 'open', + type: 'type2', + order: 2, + plannedText: 'plannedText 2', + activeText: 'activeText 2', + completedText: 'completedText 2', + blockedText: 'blockedText 2', + createdBy: 2, + updatedBy: 3, + createdAt: '2018-05-11T00:00:00.000Z', + updatedAt: '2018-05-11T00:00:00.000Z', + }, +]; + +describe('UPDATE timeline', () => { + beforeEach((done) => { + testUtil.clearDb() + .then(() => { + models.Project.bulkCreate([ + { + type: 'generic', + billingAccountId: 1, + name: 'test1', + description: 'test project1', + status: 'draft', + details: {}, + createdBy: 1, + updatedBy: 1, + }, + { + type: 'generic', + billingAccountId: 2, + name: 'test2', + description: 'test project2', + status: 'draft', + details: {}, + createdBy: 2, + updatedBy: 2, + deletedAt: '2018-05-15T00:00:00Z', + }, + ], { returning: true }) + .then(() => { + // Create member + models.ProjectMember.bulkCreate([ + { + userId: 40051332, + projectId: 1, + role: 'copilot', + isPrimary: true, + createdBy: 1, + updatedBy: 1, + }, + { + userId: 40051331, + projectId: 1, + role: 'customer', + isPrimary: true, + createdBy: 1, + updatedBy: 1, + }, + ]).then(() => + // Create phase + models.ProjectPhase.bulkCreate([ + { + projectId: 1, + name: 'test project phase 1', + status: 'active', + startDate: '2018-05-15T00:00:00Z', + endDate: '2018-05-15T12:00:00Z', + budget: 20.0, + progress: 1.23456, + details: { + message: 'This can be any json 2', + }, + createdBy: 1, + updatedBy: 1, + }, + { + projectId: 2, + name: 'test project phase 2', + status: 'active', + startDate: '2018-05-16T00:00:00Z', + endDate: '2018-05-16T12:00:00Z', + budget: 21.0, + progress: 1.234567, + details: { + message: 'This can be any json 2', + }, + createdBy: 2, + updatedBy: 2, + deletedAt: '2018-05-15T00:00:00Z', + }, + ]), { returning: true }) + .then(() => + // Create timelines + models.Timeline.bulkCreate([ + { + name: 'name 1', + description: 'description 1', + startDate: '2018-05-11T00:00:00.000Z', + endDate: '2018-05-20T00:00:00.000Z', + reference: 'project', + referenceId: 1, + createdBy: 1, + updatedBy: 1, + }, + { + name: 'name 2', + description: 'description 2', + startDate: '2018-05-12T00:00:00.000Z', + endDate: '2018-05-13T00:00:00.000Z', + reference: 'phase', + referenceId: 1, + createdBy: 1, + updatedBy: 1, + }, + { + name: 'name 3', + description: 'description 3', + startDate: '2018-05-13T00:00:00.000Z', + endDate: '2018-05-14T00:00:00.000Z', + reference: 'phase', + referenceId: 1, + createdBy: 1, + updatedBy: 1, + deletedAt: '2018-05-14T00:00:00.000Z', + }, + ])) + .then(() => models.Milestone.bulkCreate(milestones)) + .then(() => done()); + }); + }); + }); + + after(testUtil.clearDb); + + describe('PATCH /timelines/{timelineId}', () => { + const body = { + param: { + name: 'new name 1', + description: 'new description 1', + startDate: '2018-06-01T00:00:00.000Z', + endDate: '2018-06-02T00:00:00.000Z', + reference: 'project', + referenceId: 1, + }, + }; + + it('should return 403 if user is not authenticated', (done) => { + request(server) + .patch('/v4/timelines/1') + .send(body) + .expect(403, done); + }); + + it('should return 403 for member who is not in the project', (done) => { + request(server) + .patch('/v4/timelines/1') + .set({ + Authorization: `Bearer ${testUtil.jwts.member2}`, + }) + .send(body) + .expect(403, done); + }); + + it('should return 403 for member who is not in the project (timeline refers to a phase)', (done) => { + request(server) + .patch('/v4/timelines/2') + .send(body) + .set({ + Authorization: `Bearer ${testUtil.jwts.member2}`, + }) + .expect(403, done); + }); + + it('should return 404 for non-existed timeline', (done) => { + request(server) + .patch('/v4/timelines/1234') + .send(body) + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .expect(404, done); + }); + + it('should return 404 for deleted timeline', (done) => { + request(server) + .patch('/v4/timelines/3') + .send(body) + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .expect(404, done); + }); + + it('should return 422 for invalid param', (done) => { + request(server) + .patch('/v4/timelines/0') + .send(body) + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .expect(422, done); + }); + + it('should return 404 for non-existed template', (done) => { + request(server) + .patch('/v4/timelines/1234') + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .send(body) + .expect(404, done); + }); + + it('should return 404 for deleted template', (done) => { + request(server) + .patch('/v4/timelines/3') + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .send(body) + .expect(404, done); + }); + + it('should return 422 if missing name', (done) => { + const invalidBody = { + param: _.assign({}, body.param, { + name: undefined, + }), + }; + + request(server) + .patch('/v4/timelines/1') + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .send(invalidBody) + .expect('Content-Type', /json/) + .expect(422, done); + }); + + it('should return 422 if missing startDate', (done) => { + const invalidBody = { + param: _.assign({}, body.param, { + startDate: undefined, + }), + }; + + request(server) + .patch('/v4/timelines/1') + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .send(invalidBody) + .expect('Content-Type', /json/) + .expect(422, done); + }); + + it('should return 422 if startDate is after endDate', (done) => { + const invalidBody = { + param: _.assign({}, body.param, { + startDate: '2018-05-29T00:00:00.000Z', + endDate: '2018-05-28T00:00:00.000Z', + }), + }; + + request(server) + .patch('/v4/timelines/1') + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .send(invalidBody) + .expect('Content-Type', /json/) + .expect(422, done); + }); + + it('should return 422 if missing reference', (done) => { + const invalidBody = { + param: _.assign({}, body.param, { + reference: undefined, + }), + }; + + request(server) + .patch('/v4/timelines/1') + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .send(invalidBody) + .expect('Content-Type', /json/) + .expect(422, done); + }); + + it('should return 422 if missing referenceId', (done) => { + const invalidBody = { + param: _.assign({}, body.param, { + referenceId: undefined, + }), + }; + + request(server) + .patch('/v4/timelines/1') + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .send(invalidBody) + .expect('Content-Type', /json/) + .expect(422, done); + }); + + it('should return 422 if invalid reference', (done) => { + const invalidBody = { + param: _.assign({}, body.param, { + reference: 'invalid', + }), + }; + + request(server) + .patch('/v4/timelines/1') + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .send(invalidBody) + .expect('Content-Type', /json/) + .expect(422, done); + }); + + it('should return 422 if invalid referenceId', (done) => { + const invalidBody = { + param: _.assign({}, body.param, { + referenceId: 0, + }), + }; + + request(server) + .patch('/v4/timelines/1') + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .send(invalidBody) + .expect('Content-Type', /json/) + .expect(422, done); + }); + + it('should return 422 if project does not exist', (done) => { + const invalidBody = { + param: _.assign({}, body.param, { + referenceId: 1110, + }), + }; + + request(server) + .patch('/v4/timelines/1') + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .send(invalidBody) + .expect('Content-Type', /json/) + .expect(422, done); + }); + + it('should return 422 if project was deleted', (done) => { + const invalidBody = { + param: _.assign({}, body.param, { + referenceId: 2, + }), + }; + + request(server) + .patch('/v4/timelines/1') + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .send(invalidBody) + .expect('Content-Type', /json/) + .expect(422, done); + }); + + it('should return 422 if phase does not exist', (done) => { + const invalidBody = { + param: _.assign({}, body.param, { + reference: 'phase', + referenceId: 2222, + }), + }; + + request(server) + .patch('/v4/timelines/1') + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .send(invalidBody) + .expect('Content-Type', /json/) + .expect(422, done); + }); + + it('should return 422 if phase was deleted', (done) => { + const invalidBody = { + param: _.assign({}, body.param, { + reference: 'phase', + referenceId: 2, + }), + }; + + request(server) + .patch('/v4/timelines/1') + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .send(invalidBody) + .expect('Content-Type', /json/) + .expect(422, done); + }); + + it('should return 200 for admin', (done) => { + request(server) + .patch('/v4/timelines/1') + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .send(body) + .expect(200) + .end((err, res) => { + const resJson = res.body.result.content; + should.exist(resJson.id); + resJson.name.should.be.eql(body.param.name); + resJson.description.should.be.eql(body.param.description); + resJson.startDate.should.be.eql(body.param.startDate); + resJson.endDate.should.be.eql(body.param.endDate); + resJson.reference.should.be.eql(body.param.reference); + resJson.referenceId.should.be.eql(body.param.referenceId); + + resJson.createdBy.should.be.eql(1); + should.exist(resJson.createdAt); + resJson.updatedBy.should.be.eql(40051333); // admin + should.exist(resJson.updatedAt); + should.not.exist(resJson.deletedAt); + should.not.exist(resJson.deletedBy); + + // eslint-disable-next-line no-unused-expressions + server.services.pubsub.publish.calledWith(EVENT.ROUTING_KEY.TIMELINE_UPDATED).should.be.true; + + done(); + }); + }); + + // eslint-disable-next-line func-names + it('should return 200 for admin with changed startDate', function (done) { + this.timeout(10000); + + request(server) + .patch('/v4/timelines/1') + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .send({ + param: _.assign({}, body.param, { + startDate: '2018-05-15T00:00:00.000Z', + endDate: '2018-05-17T00:00:00.000Z', // no affect to milestones + }), + }) + .expect(200) + .end(() => { + setTimeout(() => { + models.Milestone.findById(1) + .then((milestone) => { + milestone.startDate.should.be.eql(new Date('2018-05-15T00:00:00.000Z')); + milestone.endDate.should.be.eql(new Date('2018-05-16T00:00:00.000Z')); + }) + .then(() => models.Milestone.findById(2)) + .then((milestone) => { + milestone.startDate.should.be.eql(new Date('2018-05-17T00:00:00.000Z')); + milestone.endDate.should.be.eql(new Date('2018-05-19T00:00:00.000Z')); + done(); + }) + .catch(done); + }, 3000); + }); + }); + + // eslint-disable-next-line func-names + it('should return 200 for admin with changed endDate', function (done) { + this.timeout(10000); + + request(server) + .patch('/v4/timelines/1') + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .send({ + param: _.assign({}, body.param, { + startDate: '2018-05-12T00:00:00.000Z', // no affect to milestones + endDate: '2018-05-15T00:00:00.000Z', + }), + }) + .expect(200) + .end(() => { + setTimeout(() => { + models.Milestone.findById(1) + .then((milestone) => { + milestone.startDate.should.be.eql(new Date('2018-05-12T00:00:00.000Z')); + milestone.endDate.should.be.eql(new Date('2018-05-13T00:00:00.000Z')); + }) + .then(() => models.Milestone.findById(2)) + .then((milestone) => { + milestone.startDate.should.be.eql(new Date('2018-05-14T00:00:00.000Z')); + milestone.endDate.should.be.eql(new Date('2018-05-16T00:00:00.000Z')); + + done(); + }) + .catch(done); + }, 3000); + }); + }); + + it('should return 200 for connect admin', (done) => { + request(server) + .patch('/v4/timelines/1') + .set({ + Authorization: `Bearer ${testUtil.jwts.connectAdmin}`, + }) + .send(body) + .expect(200) + .end(done); + }); + + it('should return 200 for connect manager', (done) => { + request(server) + .patch('/v4/timelines/1') + .set({ + Authorization: `Bearer ${testUtil.jwts.manager}`, + }) + .send(body) + .expect(200) + .end(done); + }); + + it('should return 200 for copilot', (done) => { + request(server) + .patch('/v4/timelines/1') + .set({ + Authorization: `Bearer ${testUtil.jwts.copilot}`, + }) + .send(body) + .expect(200) + .end(done); + }); + + it('should return 200 for member', (done) => { + request(server) + .patch('/v4/timelines/1') + .set({ + Authorization: `Bearer ${testUtil.jwts.member}`, + }) + .send(body) + .expect(200) + .end(done); + }); + + it('should return 200 if changing reference and referenceId', (done) => { + const newBody = { + param: _.assign({}, body.param, { + reference: 'phase', + referenceId: 1, + }), + }; + + request(server) + .patch('/v4/timelines/1') + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .send(newBody) + .expect(200) + .end(done); + }); + }); +}); diff --git a/src/tests/seed.js b/src/tests/seed.js index 350480c4..ea2ea5fb 100644 --- a/src/tests/seed.js +++ b/src/tests/seed.js @@ -1,4 +1,5 @@ import models from '../models'; +import { TIMELINE_REFERENCES } from '../constants'; models.sequelize.sync({ force: true }) .then(() => @@ -310,6 +311,7 @@ models.sequelize.sync({ force: true }) { name: 'name 1', productKey: 'productKey 1', + category: 'category', icon: 'http://example.com/icon1.ico', question: 'question 1', info: 'info 1', @@ -344,6 +346,7 @@ models.sequelize.sync({ force: true }) { name: 'template 2', productKey: 'productKey 2', + category: 'category', icon: 'http://example.com/icon1.ico', question: 'question 1', info: 'info 1', @@ -357,6 +360,7 @@ models.sequelize.sync({ force: true }) { name: 'Generic work', productKey: 'generic_work', + category: 'category', icon: 'http://example.com/icon1.ico', question: 'question 1', info: 'info 1', @@ -384,6 +388,7 @@ models.sequelize.sync({ force: true }) { name: 'Website product', productKey: 'website_development', + category: 'category', icon: 'http://example.com/icon1.ico', question: 'question 1', info: 'info 1', @@ -411,6 +416,7 @@ models.sequelize.sync({ force: true }) { name: 'Application product', productKey: 'application_development', + category: 'category', icon: 'http://example.com/icon1.ico', brief: 'brief 1', details: 'details 1', @@ -433,6 +439,140 @@ models.sequelize.sync({ force: true }) createdBy: 1, updatedBy: 2, }, + ], { returning: true })) + // Product milestone templates + .then(productTemplates => models.ProductMilestoneTemplate.bulkCreate([ + { + name: 'milestoneTemplate 1', + duration: 3, + type: 'type1', + order: 1, + productTemplateId: productTemplates[0].id, + activeText: 'activeText 1', + completedText: 'completedText 1', + blockedText: 'blockedText 1', + plannedText: 'planned Text 1', + createdBy: 1, + updatedBy: 2, + }, + { + name: 'milestoneTemplate 2', + duration: 4, + type: 'type2', + order: 2, + productTemplateId: productTemplates[0].id, + activeText: 'activeText 2', + completedText: 'completedText 2', + blockedText: 'blockedText 2', + plannedText: 'planned Text 2', + createdBy: 2, + updatedBy: 3, + }, + ])) + // Project phases + .then(() => models.ProjectPhase.bulkCreate([ + { + name: 'phase 1', + projectId: 1, + createdBy: 1, + updatedBy: 2, + }, + { + name: 'phase 2', + projectId: 1, + createdBy: 2, + updatedBy: 3, + }, + ], { returning: true })) + // Timelines + .then(projectPhases => models.Timeline.bulkCreate([ + { + name: 'timeline 1', + startDate: '2018-05-01T00:00:00.000Z', + reference: TIMELINE_REFERENCES.PROJECT, + referenceId: projectPhases[0].projectId, + createdBy: 1, + updatedBy: 2, + }, + { + name: 'timeline 2', + startDate: '2018-05-02T00:00:00.000Z', + reference: TIMELINE_REFERENCES.PHASE, + referenceId: projectPhases[0].id, + createdBy: 2, + updatedBy: 3, + }, + { + name: 'timeline 3', + startDate: '2018-05-03T00:00:00.000Z', + reference: TIMELINE_REFERENCES.PROJECT, + referenceId: projectPhases[0].projectId, + createdBy: 3, + updatedBy: 4, + }, + { + name: 'timeline 4', + startDate: '2018-05-04T00:00:00.000Z', + reference: TIMELINE_REFERENCES.PROJECT, + referenceId: 2, + createdBy: 4, + updatedBy: 5, + }, + ], { returning: true })) + // Milestones + .then(timelines => models.Milestone.bulkCreate([ + { + timelineId: timelines[0].id, + name: 'milestone 1', + duration: 2, + startDate: '2018-05-03T00:00:00.000Z', + status: 'open', + type: 'type1', + details: { + detail1: { + subDetail1A: 1, + subDetail1B: 2, + }, + detail2: [1, 2, 3], + }, + order: 1, + plannedText: 'plannedText 1', + activeText: 'activeText 1', + completedText: 'completedText 1', + blockedText: 'blockedText 1', + createdBy: 1, + updatedBy: 2, + }, + { + timelineId: timelines[0].id, + name: 'milestone 2', + duration: 3, + startDate: '2018-05-04T00:00:00.000Z', + status: 'open', + type: 'type2', + order: 2, + plannedText: 'plannedText 2', + activeText: 'activeText 2', + completedText: 'completedText 2', + blockedText: 'blockedText 2', + createdBy: 2, + updatedBy: 3, + }, + { + timelineId: timelines[2].id, + name: 'milestone 3', + duration: 4, + startDate: '2018-05-04T00:00:00.000Z', + status: 'open', + type: 'type3', + order: 3, + plannedText: 'plannedText 3', + activeText: 'activeText 3', + completedText: 'completedText 3', + blockedText: 'blockedText 3', + createdBy: 3, + updatedBy: 4, + }, ])) .then(() => models.ProjectType.bulkCreate([ { @@ -440,48 +580,104 @@ models.sequelize.sync({ force: true }) displayName: 'Application development', createdBy: 1, updatedBy: 2, + icon: 'http://example.com/icon1.ico', + question: 'question 1', + info: 'info 1', + aliases: ['key-11', 'key_12'], }, { key: 'generic', displayName: 'Generic', createdBy: 1, updatedBy: 2, + icon: 'http://example.com/icon2.ico', + question: 'question 2', + info: 'info 2', + aliases: ['key-21', 'key_22'], }, { key: 'visual_prototype', displayName: 'Visual Prototype', createdBy: 1, updatedBy: 2, + icon: 'http://example.com/icon3.ico', + question: 'question 3', + info: 'info 1', + aliases: ['key-31', 'key_32'], }, { key: 'visual_design', displayName: 'Visual Design', createdBy: 1, updatedBy: 2, + icon: 'http://example.com/icon4.ico', + question: 'question 4', + info: 'info 4', + aliases: ['key-41', 'key_42'], }, { key: 'website', displayName: 'Website', createdBy: 1, updatedBy: 2, + icon: 'http://example.com/icon5.ico', + question: 'question 5', + info: 'info 5', + aliases: ['key-51', 'key_52'], }, { key: 'app', displayName: 'Application', createdBy: 1, updatedBy: 2, + icon: 'http://example.com/icon6.ico', + question: 'question 6', + info: 'info 6', + aliases: ['key-61', 'key_62'], }, { key: 'quality_assurance', displayName: 'Quality Assurance', createdBy: 1, updatedBy: 2, + icon: 'http://example.com/icon7.ico', + question: 'question 7', + info: 'info 7', + aliases: ['key-71', 'key_72'], }, { key: 'chatbot', displayName: 'Chatbot', createdBy: 1, updatedBy: 2, + icon: 'http://example.com/icon8.ico', + question: 'question 8', + info: 'info 8', + aliases: ['key-81', 'key_82'], + }, + ])) + .then(() => models.ProductCategory.bulkCreate([ + { + key: 'key1', + displayName: 'displayName 1', + icon: 'http://example.com/icon1.ico', + question: 'question 1', + info: 'info 1', + aliases: ['key-11', 'key_12'], + disabled: false, + hidden: false, + createdBy: 1, + updatedBy: 1, + }, + { + key: 'key2', + displayName: 'displayName 2', + icon: 'http://example.com/icon2.ico', + question: 'question 2', + info: 'info 2', + aliases: ['key-21', 'key_22'], + createdBy: 1, + updatedBy: 1, }, ])) .then(() => { diff --git a/src/util.js b/src/util.js index add05eb6..79cecbc3 100644 --- a/src/util.js +++ b/src/util.js @@ -154,6 +154,10 @@ _.assignIn(util, { if (fields.project_members.length === 0 && _.indexOf(queryFields, 'members') > -1) { fields.project_members = allowedFields.project_members; } + // remove attachments if not requested + if (fields.attachments && _.indexOf(queryFields, 'attachments') === -1) { + fields.attachments = null; + } } return fields; }, diff --git a/swagger.yaml b/swagger.yaml index 39bad0d3..b95bbe94 100755 --- a/swagger.yaml +++ b/swagger.yaml @@ -27,7 +27,7 @@ paths: operationId: findProjects security: - Bearer: [] - description: Retreive projects that match the filter + description: Retrieve projects that match the filter responses: '422': description: Invalid input @@ -344,7 +344,7 @@ paths: operationId: findProjectPhases security: - Bearer: [] - description: Retreive all project phases. All users who can edit project can access this endpoint. + description: Retrieve all project phases. All users who can edit project can access this endpoint. parameters: - name: fields required: false @@ -393,7 +393,7 @@ paths: description: Invalid input schema: $ref: "#/definitions/ErrorModel" - + /projects/{projectId}/phases/{phaseId}: parameters: - $ref: "#/parameters/projectIdParam" @@ -489,7 +489,7 @@ paths: operationId: findPhaseProducts security: - Bearer: [] - description: Retreive all phase products. All users who can edit project can access this endpoint. + description: Retrieve all phase products. All users who can edit project can access this endpoint. responses: '403': description: No permission or wrong token @@ -655,7 +655,7 @@ paths: operationId: findProjectTemplates security: - Bearer: [] - description: Retreive all project templates. All user roles can access this endpoint. + description: Retrieve all project templates. All user roles can access this endpoint. responses: '403': description: No permission or wrong token @@ -781,7 +781,7 @@ paths: operationId: findProductTemplates security: - Bearer: [] - description: Retreive all product templates. All user roles can access this endpoint. + description: Retrieve all product templates. All user roles can access this endpoint. responses: '403': description: No permission or wrong token @@ -900,6 +900,129 @@ paths: description: Product template successfully removed + /productCategories: + get: + tags: + - productCategory + operationId: findProductCategories + security: + - Bearer: [] + description: Retrieve all product categories. All user roles can access this endpoint. + responses: + '403': + description: No permission or wrong token + schema: + $ref: "#/definitions/ErrorModel" + '200': + description: A list of product categories + schema: + $ref: "#/definitions/ProductCategoryListResponse" + post: + tags: + - productCategory + operationId: addProductCategory + security: + - Bearer: [] + description: Create a product category. Only admin or connect admin can access this endpoint. + parameters: + - in: body + name: body + required: true + schema: + $ref: '#/definitions/ProductCategoryCreateBodyParam' + responses: + '403': + description: No permission or wrong token + schema: + $ref: "#/definitions/ErrorModel" + '201': + description: Returns the newly created product category + schema: + $ref: "#/definitions/ProductCategoryResponse" + '422': + description: Invalid input + schema: + $ref: "#/definitions/ErrorModel" + + /productCategories/{key}: + get: + tags: + - productCategory + description: Retrieve product category by id. All user roles can access this endpoint. + security: + - Bearer: [] + responses: + '404': + description: Not found + schema: + $ref: "#/definitions/ErrorModel" + '403': + description: No permission or wrong token + schema: + $ref: "#/definitions/ErrorModel" + '200': + description: a product category + schema: + $ref: "#/definitions/ProductCategoryResponse" + parameters: + - $ref: "#/parameters/keyParam" + operationId: getProductCategory + patch: + tags: + - productCategory + operationId: updateProductCategory + security: + - Bearer: [] + description: Update a product category. Only admin or connect admin can access this endpoint. + responses: + '403': + description: No permission or wrong token + schema: + $ref: "#/definitions/ErrorModel" + '404': + description: Not found + schema: + $ref: "#/definitions/ErrorModel" + '200': + description: Successfully updated product category. + schema: + $ref: "#/definitions/ProductCategoryResponse" + '422': + description: Invalid input + schema: + $ref: "#/definitions/ErrorModel" + default: + description: error payload + schema: + $ref: '#/definitions/ErrorModel' + parameters: + - $ref: "#/parameters/keyParam" + - name: body + in: body + required: true + schema: + $ref: "#/definitions/ProductCategoryBodyParam" + delete: + tags: + - productCategory + description: Remove an existing product category. Only admin or connect admin can access this endpoint. + security: + - Bearer: [] + parameters: + - $ref: "#/parameters/keyParam" + responses: + '403': + description: No permission or wrong token + schema: + $ref: "#/definitions/ErrorModel" + '404': + description: If product category is not found + schema: + $ref: "#/definitions/ErrorModel" + '204': + description: Product category successfully removed + + /projectTypes: get: tags: @@ -907,7 +1030,7 @@ paths: operationId: findProjectTypes security: - Bearer: [] - description: Retreive all project types. All user roles can access this endpoint. + description: Retrieve all project types. All user roles can access this endpoint. responses: '403': description: No permission or wrong token @@ -1025,80 +1148,582 @@ paths: description: Project type successfully removed + /timelines: + get: + tags: + - timeline + operationId: findTimelines + security: + - Bearer: [] + description: Retrieve timelines which its projects are accessible by the user. + parameters: + - name: filter + required: false + type: string + in: query + description: | + Url encoded list of supported filters + - reference + - referenceId + responses: + '403': + description: No permission or wrong token + schema: + $ref: "#/definitions/ErrorModel" + '200': + description: A list of timelines + schema: + $ref: "#/definitions/TimelineListResponse" + '422': + description: Invalid input + schema: + $ref: "#/definitions/ErrorModel" + post: + tags: + - timeline + operationId: addTimeline + security: + - Bearer: [] + description: Create a timeline. All users who can edit the project can access this endpoint. + parameters: + - in: body + name: body + required: true + schema: + $ref: '#/definitions/TimelineBodyParam' + responses: + '403': + description: No permission or wrong token + schema: + $ref: "#/definitions/ErrorModel" + '201': + description: Returns the newly created timeline + schema: + $ref: "#/definitions/TimelineResponse" + '422': + description: Invalid input + schema: + $ref: "#/definitions/ErrorModel" + /timelines/{timelineId}: + get: + tags: + - timeline + description: Retrieve timeline by id. All users who can view the project can access this endpoint. + security: + - Bearer: [] + responses: + '404': + description: Not found + schema: + $ref: "#/definitions/ErrorModel" + '403': + description: No permission or wrong token + schema: + $ref: "#/definitions/ErrorModel" + '422': + description: Invalid input + schema: + $ref: "#/definitions/ErrorModel" + '200': + description: a timeline + schema: + $ref: "#/definitions/TimelineResponse" + parameters: + - $ref: "#/parameters/timelineIdParam" + operationId: getTimeline -parameters: - projectIdParam: - name: projectId - in: path - description: project identifier - 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 - keyParam: - name: key - in: path - description: project type key - required: true - type: string - offsetParam: - name: offset - description: "number of items to skip. Defaults to 0" - in: query - required: false - type: integer - format: int32 - limitParam: - name: limit - description: "max records to return. Defaults to 20" - in: query - required: false - type: integer - format: int32 - -definitions: - ResponseMetadata: - title: Metadata object for a response - type: object - properties: - totalCount: - type: integer - format: int64 - description: Total count of the objects + patch: + tags: + - timeline + operationId: updateTimeline + security: + - Bearer: [] + description: Update a timeline. All users who can edit the project can access this endpoint. + responses: + '403': + description: No permission or wrong token + schema: + $ref: "#/definitions/ErrorModel" + '404': + description: Not found + schema: + $ref: "#/definitions/ErrorModel" + '200': + description: Successfully updated timeline. + schema: + $ref: "#/definitions/TimelineResponse" + '422': + description: Invalid input + schema: + $ref: "#/definitions/ErrorModel" + default: + description: error payload + schema: + $ref: '#/definitions/ErrorModel' + parameters: + - $ref: "#/parameters/timelineIdParam" + - name: body + in: body + required: true + schema: + $ref: "#/definitions/TimelineBodyParam" - ErrorModel: - type: object - properties: - id: - type: string - description: unique id identifying the request - version: - type: string - result: + delete: + tags: + - timeline + description: Remove an existing timeline. All users who can edit the project can access this endpoint. + security: + - Bearer: [] + parameters: + - $ref: "#/parameters/timelineIdParam" + responses: + '403': + description: No permission or wrong token + schema: + $ref: "#/definitions/ErrorModel" + '404': + description: Not found + schema: + $ref: "#/definitions/ErrorModel" + '422': + description: Invalid input + schema: + $ref: "#/definitions/ErrorModel" + '204': + description: Timeline successfully removed + + /timelines/{timelineId}/milestones: + parameters: + - $ref: "#/parameters/timelineIdParam" + get: + tags: + - milestone + operationId: findMilestones + security: + - Bearer: [] + description: Retrieve all milestones. All users who can view the timeline can access this endpoint. + parameters: + - name: sort + required: false + description: sort by `order`. Default is `order asc` + in: query + type: string + responses: + '403': + description: No permission or wrong token + schema: + $ref: "#/definitions/ErrorModel" + '422': + description: Invalid input + schema: + $ref: "#/definitions/ErrorModel" + '200': + description: A list of milestones + schema: + $ref: "#/definitions/MilestoneListResponse" + post: + tags: + - milestone + operationId: addMilestone + security: + - Bearer: [] + description: Create a milestone. All users who can edit the timeline can access this endpoint. + It also updates the `order` field of all other milestones in the same timeline which have `order` greater than or equal to the `order` specified in the POST body. + parameters: + - in: body + name: body + required: true + schema: + $ref: '#/definitions/MilestonePostBodyParam' + responses: + '403': + description: No permission or wrong token + schema: + $ref: "#/definitions/ErrorModel" + '201': + description: Returns the newly created milestone + schema: + $ref: "#/definitions/MilestoneResponse" + '422': + description: Invalid input + schema: + $ref: "#/definitions/ErrorModel" + + /timelines/{timelineId}/milestones/{milestoneId}: + parameters: + - $ref: "#/parameters/timelineIdParam" + - $ref: "#/parameters/milestoneIdParam" + get: + tags: + - milestone + description: Retrieve milestone by id. All users who can view the timeline can access this endpoint. + security: + - Bearer: [] + responses: + '404': + description: Not found + schema: + $ref: "#/definitions/ErrorModel" + '403': + description: No permission or wrong token + schema: + $ref: "#/definitions/ErrorModel" + '422': + description: Invalid input + schema: + $ref: "#/definitions/ErrorModel" + '200': + description: a milestone + schema: + $ref: "#/definitions/MilestoneResponse" + operationId: getMilestone + + patch: + tags: + - milestone + operationId: updateMilestone + security: + - Bearer: [] + description: Update a milestone. All users who can edit the timeline can access this endpoint. + For attributes with JSON object type, it would overwrite the existing fields, or add new if the fields don't exist in the JSON object. + responses: + '403': + description: No permission or wrong token + schema: + $ref: "#/definitions/ErrorModel" + '404': + description: Not found + schema: + $ref: "#/definitions/ErrorModel" + '200': + description: Successfully updated milestone. + schema: + $ref: "#/definitions/MilestoneResponse" + '422': + description: Invalid input + schema: + $ref: "#/definitions/ErrorModel" + default: + description: error payload + schema: + $ref: '#/definitions/ErrorModel' + parameters: + - name: body + in: body + required: true + schema: + $ref: "#/definitions/MilestonePatchBodyParam" + + delete: + tags: + - milestone + description: Remove an existing milestone. All users who can edit the timeline can access this endpoint. + security: + - Bearer: [] + responses: + '403': + description: No permission or wrong token + schema: + $ref: "#/definitions/ErrorModel" + '404': + description: Not found + schema: + $ref: "#/definitions/ErrorModel" + '422': + description: Invalid input + schema: + $ref: "#/definitions/ErrorModel" + '204': + description: Milestone successfully removed + + + /productTemplates/{productTemplateId}/milestones: + parameters: + - $ref: "#/parameters/productTemplateIdParam" + get: + tags: + - productMilestoneTemplate + operationId: findMilestoneTemplates + security: + - Bearer: [] + description: Retrieve all milestone templates. All user roles can access this endpoint. + parameters: + - name: sort + required: false + description: sort by `order`. Default is `order asc` + in: query + type: string + responses: + '403': + description: No permission or wrong token + schema: + $ref: "#/definitions/ErrorModel" + '422': + description: Invalid input + schema: + $ref: "#/definitions/ErrorModel" + '200': + description: A list of milestone templates + schema: + $ref: "#/definitions/MilestoneTemplateListResponse" + post: + tags: + - productMilestoneTemplate + operationId: addMilestoneTemplate + security: + - Bearer: [] + description: Create a milestone template. Only connect manager, connect admin, and admin can access this endpoint. It also updates the `order` field of all other milestone templates in the same product template which have `order` greater than or equal to the `order` specified in the POST body. + parameters: + - in: body + name: body + required: true + schema: + $ref: '#/definitions/MilestoneTemplateBodyParam' + responses: + '403': + description: No permission or wrong token + schema: + $ref: "#/definitions/ErrorModel" + '201': + description: Returns the newly created milestone template + schema: + $ref: "#/definitions/MilestoneTemplateResponse" + '422': + description: Invalid input + schema: + $ref: "#/definitions/ErrorModel" + + /productTemplates/{productTemplateId}/milestones/clone: + parameters: + - $ref: "#/parameters/productTemplateIdParam" + post: + tags: + - productMilestoneTemplate + operationId: cloneMilestoneTemplate + security: + - Bearer: [] + description: Clone milestone templates from one product template to the other. Only connect manager, connect admin, and admin can access this endpoint. + parameters: + - in: body + name: body + required: true + schema: + $ref: '#/definitions/MilestoneCloneTemplateRequest' + responses: + '201': + description: Returns the list of cloned milestone templates + schema: + $ref: "#/definitions/MilestoneTemplateListResponse" + '403': + description: No permission or wrong token + schema: + $ref: "#/definitions/ErrorModel" + '404': + description: Not found + schema: + $ref: "#/definitions/ErrorModel" + '422': + description: Invalid input + schema: + $ref: "#/definitions/ErrorModel" + + + /productTemplates/{productTemplateId}/milestones/{milestoneTemplateId}: + parameters: + - $ref: "#/parameters/productTemplateIdParam" + - $ref: "#/parameters/milestoneTemplateIdParam" + get: + tags: + - productMilestoneTemplate + description: Retrieve milestone template by id. All user roles can access this endpoint. + security: + - Bearer: [] + responses: + '404': + description: Not found + schema: + $ref: "#/definitions/ErrorModel" + '403': + description: No permission or wrong token + schema: + $ref: "#/definitions/ErrorModel" + '422': + description: Invalid input + schema: + $ref: "#/definitions/ErrorModel" + '200': + description: a milestone template + schema: + $ref: "#/definitions/MilestoneTemplateResponse" + operationId: getMilestoneTemplate + + patch: + tags: + - productMilestoneTemplate + operationId: updateMilestoneTemplate + security: + - Bearer: [] + description: Update a milestone template. Only connect manager, connect admin, and admin can access this endpoint. + responses: + '403': + description: No permission or wrong token + schema: + $ref: "#/definitions/ErrorModel" + '404': + description: Not found + schema: + $ref: "#/definitions/ErrorModel" + '200': + description: Successfully updated milestone template. + schema: + $ref: "#/definitions/MilestoneTemplateResponse" + '422': + description: Invalid input + schema: + $ref: "#/definitions/ErrorModel" + default: + description: error payload + schema: + $ref: '#/definitions/ErrorModel' + parameters: + - name: body + in: body + required: true + schema: + $ref: "#/definitions/MilestoneTemplateBodyParam" + + delete: + tags: + - productMilestoneTemplate + description: Remove an existing milestone template. Only connect manager, connect admin, and admin can access this endpoint. + security: + - Bearer: [] + responses: + '403': + description: No permission or wrong token + schema: + $ref: "#/definitions/ErrorModel" + '404': + description: Not found + schema: + $ref: "#/definitions/ErrorModel" + '422': + description: Invalid input + schema: + $ref: "#/definitions/ErrorModel" + '204': + description: Milestone template successfully removed + + + + + + +parameters: + projectIdParam: + name: projectId + in: path + description: project identifier + 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 + keyParam: + name: key + in: path + description: project type key + required: true + type: string + timelineIdParam: + name: timelineId + in: path + description: timeline identifier + required: true + type: integer + format: int64 + minimum: 1 + milestoneIdParam: + name: milestoneId + in: path + description: milestone identifier + required: true + type: integer + format: int64 + minimum: 1 + productTemplateIdParam: + name: productTemplateId + in: path + description: product template identifier + required: true + type: integer + format: int64 + minimum: 1 + milestoneTemplateIdParam: + name: milestoneTemplateId + in: path + description: milestone template identifier + required: true + type: integer + format: int64 + minimum: 1 + offsetParam: + name: offset + description: "number of items to skip. Defaults to 0" + in: query + required: false + type: integer + format: int32 + limitParam: + name: limit + description: "max records to return. Defaults to 20" + in: query + required: false + type: integer + format: int32 + +definitions: + ResponseMetadata: + title: Metadata object for a response + type: object + properties: + totalCount: + type: integer + format: int64 + description: Total count of the objects + + ErrorModel: + type: object + properties: + id: + type: string + description: unique id identifying the request + version: + type: string + result: type: object properties: success: @@ -1346,131 +1971,551 @@ definitions: description: READ-ONLY. User that last updated this task readOnly: true - ProjectDetails: - description: Project details + ProjectDetails: + description: Project details + type: object + properties: + summary: + type: string + description: text summary of the project + TBD_usageDescription: + type: string + description: a description of how the app will be used + TBD_features: + type: object + properties: + id: + type: integer + title: + type: string + description: + type: string + isCustom: + type: boolean + + ProjectUpgrade: + title: Project Upgrade object + type: object + required: + - targetVersion + - defaultProductTemplateId + properties: + targetVersion: + type: string + description: Version identifier + defaultProductTemplateId: + type: number + format: int64 + description: Default product template id, used when the associated project template is not found, or there's no matching phase with the project's product id + phaseName: + type: string + description: This value will be used instead of the product template's name for the created ProjectPhase + + NewProjectMember: + title: Project Member object + type: object + required: + - userId + - role + properties: + userId: + type: number + format: int64 + description: user identifier + isPrimary: + type: boolean + description: Flag to indicate this member is primary for specified role + role: + type: string + description: member role on specified project + enum: ["customer", "manager", "copilot"] + + NewProjectMemberBodyParam: + type: object + properties: + param: + $ref: "#/definitions/NewProjectMember" + + UpdateProjectMember: + title: Project Member object + type: object + required: + - role + properties: + isPrimary: + type: boolean + description: primary option + role: + type: string + description: member role on specified project + enum: ["customer", "manager", "copilot"] + + UpdateProjectMemberBodyParam: + type: object + properties: + param: + $ref: "#/definitions/UpdateProjectMember" + + NewProjectAttachment: + title: Project attachment request + type: object + required: + - filePath + - s3Bucket + - title + - contentType + properties: + filePath: + type: string + description: path where file is stored + s3Bucket: + type: string + description: The s3 bucket of attachment + contentType: + type: string + description: Uploaded file content type + title: + type: string + description: Name of the attachment + description: + type: string + description: Optional description for the attached file. + category: + type: string + description: Category of attachment + size: + type: number + format: float + description: The size of attachment + + NewProjectAttachmentBodyParam: + type: object + properties: + param: + $ref: "#/definitions/NewProjectAttachment" + + NewProjectAttachmentResponse: + title: Project attachment object response + type: object + properties: + id: + type: string + description: unique id identifying the request + version: + type: string + result: + type: object + properties: + success: + type: boolean + status: + type: string + description: http status code + content: + $ref: "#/definitions/ProjectAttachment" + + ProjectAttachment: + title: Project attachment + type: object + properties: + id: + type: number + description: unique id for the attachment + size: + type: number + format: float + description: The size of attachment + category: + type: string + description: The category of attachment + contentType: + type: string + description: Uploaded file content type + title: + type: string + description: Name of the attachment + description: + type: string + description: Optional description for the attached file. + downloadUrl: + type: string + description: download link for the attachment. + createdAt: + type: string + description: Datetime (GMT) when task was created + readOnly: true + createdBy: + type: integer + format: int64 + description: READ-ONLY. User who created this task + readOnly: true + updatedAt: + type: string + description: READ-ONLY. Datetime (GMT) when task was updated + readOnly: true + updatedBy: + type: integer + format: int64 + description: READ-ONLY. User that last updated this task + readOnly: true + + ProjectMember: + title: Project Member object + type: object + properties: + id: + type: number + description: unique identifier for record + userId: + type: number + format: int64 + description: user identifier + isPrimary: + type: boolean + description: Flag to indicate this member is primary for specified role + projectId: + type: number + format: int64 + description: project identifier + role: + type: string + description: member role on specified project + enum: ["customer", "manager", "copilot"] + createdAt: + type: string + description: Datetime (GMT) when task was created + readOnly: true + createdBy: + type: integer + format: int64 + description: READ-ONLY. User who created this task + readOnly: true + updatedAt: + type: string + description: READ-ONLY. Datetime (GMT) when task was updated + readOnly: true + updatedBy: + type: integer + format: int64 + description: READ-ONLY. User that last updated this task + readOnly: true + + + + NewProjectMemberResponse: + title: Project member object response type: object properties: - summary: + id: type: string - description: text summary of the project - TBD_usageDescription: + description: unique id identifying the request + version: type: string - description: a description of how the app will be used - TBD_features: + result: type: object properties: - id: - type: integer - title: + success: + type: boolean + status: type: string - description: + 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 - isCustom: + 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" - ProjectUpgrade: - title: Project Upgrade object + UpdateProjectResponse: + title: response with original and updated project object type: object - required: - - targetVersion - - defaultProductTemplateId properties: - targetVersion: + id: type: string - description: Version identifier - defaultProductTemplateId: - type: number - format: int64 - description: Default product template id, used when the associated project template is not found, or there's no matching phase with the project's product id - phaseName: + description: unique id identifying the request + version: type: string - description: This value will be used instead of the product template's name for the created ProjectPhase + 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" - NewProjectMember: - title: Project Member object + 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: - - userId - - role + - name + - key + - category + - scope + - phases properties: - userId: - type: number - format: int64 - description: user identifier - isPrimary: - type: boolean - description: Flag to indicate this member is primary for specified role - role: + name: type: string - description: member role on specified project - enum: ["customer", "manager", "copilot"] + description: the project template name + key: + type: string + description: the project template key + category: + type: string + description: the project template category + scope: + type: object + description: the project template scope + phases: + type: object + description: the project template phases - NewProjectMemberBodyParam: + ProjectTemplateBodyParam: + title: Project template body param type: object + required: + - param properties: param: - $ref: "#/definitions/NewProjectMember" + $ref: "#/definitions/ProjectTemplateRequest" - UpdateProjectMember: - title: Project Member object - type: object - required: - - role - properties: - isPrimary: - type: boolean - description: primary option - role: - type: string - description: member role on specified project - enum: ["customer", "manager", "copilot"] + 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: + 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/ProjectTemplate" - UpdateProjectMemberBodyParam: + ProjectTemplateListResponse: + title: Project template list response object type: object properties: - param: - $ref: "#/definitions/UpdateProjectMember" + id: + type: string + readOnly: true + description: unique id identifying the request + version: + type: string + result: + type: object + properties: + success: + type: boolean + status: + type: string + description: http status code + metadata: + $ref: "#/definitions/ResponseMetadata" + content: + type: array + items: + $ref: "#/definitions/ProjectTemplate" - NewProjectAttachment: - title: Project attachment request + ProductTemplateRequest: + title: Product template request object type: object required: - - filePath - - s3Bucket - - title - - contentType + - name + - key + - category + - scope + - phases properties: - filePath: + name: type: string - description: path where file is stored - s3Bucket: + description: the product template name + productKey: type: string - description: The s3 bucket of attachment - contentType: + description: the product template key + category: type: string - description: Uploaded file content type - title: + description: the product template product category + icon: type: string - description: Name of the attachment - description: + description: the product template icon + brief: type: string - description: Optional description for the attached file. - category: + description: the product template brief + details: type: string - description: Category of attachment - size: - type: number - format: float - description: The size of attachment + description: the product template details + aliases: + type: object + description: the product template aliases + template: + type: object + description: the product template template - NewProjectAttachmentBodyParam: + ProductTemplateBodyParam: + title: Product template body param type: object + required: + - param properties: param: - $ref: "#/definitions/NewProjectAttachment" + $ref: "#/definitions/ProductTemplateRequest" - NewProjectAttachmentResponse: - title: Project attachment object response + ProductTemplate: + title: Product template object + allOf: + - type: object + required: + - id + - createdAt + - createdBy + - updatedAt + - updatedBy + - category + 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 + category: + type: string + description: The product category of the product template + - $ref: "#/definitions/ProductTemplateRequest" + + ProjectUpgradeResponse: + title: Project upgrade response object type: object properties: id: @@ -1486,99 +2531,136 @@ definitions: status: type: string description: http status code - content: - $ref: "#/definitions/ProjectAttachment" + metadata: + $ref: "#/definitions/ResponseMetadata" - ProjectAttachment: - title: Project attachment + ProductTemplateResponse: + title: Single product template response object type: object properties: id: - type: number - description: unique id for the attachment - size: - type: number - format: float - description: The size of attachment - category: type: string - description: The category of attachment - contentType: - type: string - description: Uploaded file content type - title: - type: string - description: Name of the attachment - description: - type: string - description: Optional description for the attached file. - downloadUrl: + description: unique id identifying the request + version: type: string - description: download link for the attachment. - createdAt: + result: + type: object + properties: + success: + type: boolean + status: + type: string + description: http status code + metadata: + $ref: "#/definitions/ResponseMetadata" + content: + $ref: "#/definitions/ProductTemplate" + + ProductTemplateListResponse: + title: Product template list response object + type: object + properties: + id: type: string - description: Datetime (GMT) when task was created - readOnly: true - createdBy: - type: integer - format: int64 - description: READ-ONLY. User who created this task readOnly: true - updatedAt: + description: unique id identifying the request + version: type: string - description: READ-ONLY. Datetime (GMT) when task was updated - readOnly: true - updatedBy: - type: integer - format: int64 - description: READ-ONLY. User that last updated this task - readOnly: true + result: + type: object + properties: + success: + type: boolean + status: + type: string + description: http status code + metadata: + $ref: "#/definitions/ResponseMetadata" + content: + type: array + items: + $ref: "#/definitions/ProductTemplate" - ProjectMember: - title: Project Member object + ProjectPhaseRequest: + title: Project phase request object type: object + required: + - name + - status + - startDate + - endDate properties: - id: - type: number - description: unique identifier for record - userId: - type: number - format: int64 - description: user identifier - isPrimary: - type: boolean - description: Flag to indicate this member is primary for specified role - projectId: - type: number - format: int64 - description: project identifier - role: + name: type: string - description: member role on specified project - enum: ["customer", "manager", "copilot"] - createdAt: + description: the project phase name + status: 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 project phase status + startDate: 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 + format: date + description: the project phase start date + endDate: + type: string + format: date + description: the project phase end date + budget: + type: number + description: the project phase budget + progress: + type: number + description: the project phase progress + details: + type: object + description: the project phase details + + ProjectPhaseBodyParam: + title: Project phase body param + type: object + required: + - param + properties: + param: + $ref: "#/definitions/ProjectPhaseRequest" + ProjectPhase: + title: Project phase object + allOf: + - type: object + required: + - id + - createdAt + - createdBy + - updatedAt + - updatedBy + properties: + id: + type: number + format: int64 + description: the id + createdAt: + type: string + description: Datetime (GMT) when object was created + readOnly: true + createdBy: + type: integer + format: int64 + description: READ-ONLY. User who created this object + readOnly: true + updatedAt: + type: string + description: READ-ONLY. Datetime (GMT) when object was updated + readOnly: true + updatedBy: + type: integer + format: int64 + description: READ-ONLY. User that last updated this object + readOnly: true + - $ref: "#/definitions/ProjectPhaseRequest" - NewProjectMemberResponse: - title: Project member object response + ProjectPhaseResponse: + title: Single project phase response object type: object properties: id: @@ -1594,15 +2676,18 @@ definitions: status: type: string description: http status code + metadata: + $ref: "#/definitions/ResponseMetadata" content: - $ref: "#/definitions/ProjectMember" + $ref: "#/definitions/ProjectPhase" - UpdateProjectMemberResponse: - title: Project member object response + ProjectPhaseListResponse: + title: Project phase list response object type: object properties: id: type: string + readOnly: true description: unique id identifying the request version: type: string @@ -1614,32 +2699,90 @@ definitions: status: type: string description: http status code + metadata: + $ref: "#/definitions/ResponseMetadata" content: - $ref: "#/definitions/ProjectMember" + type: array + items: + $ref: "#/definitions/ProjectPhase" - ProjectResponse: - title: Single project object + PhaseProductRequest: + title: Phase product request object type: object properties: - id: + name: type: string - description: unique id identifying the request - version: + 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 - result: + 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: - success: - type: boolean - status: + id: + type: number + format: int64 + description: the id + createdAt: type: string - description: http status code - content: - $ref: "#/definitions/Project" + 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" - UpdateProjectResponse: - title: response with original and updated project object + + PhaseProductResponse: + title: Single phase product response object type: object properties: id: @@ -1655,16 +2798,13 @@ definitions: status: type: string description: http status code + metadata: + $ref: "#/definitions/ResponseMetadata" content: - type: object - properties: - original: - $ref: "#/definitions/Project" - updated: - $ref: "#/definitions/Project" + $ref: "#/definitions/PhaseProduct" - ProjectListResponse: - title: List response + PhaseProductListResponse: + title: Phase product list response object type: object properties: id: @@ -1686,58 +2826,64 @@ definitions: content: type: array items: - $ref: "#/definitions/Project" + $ref: "#/definitions/PhaseProduct" - ProjectTemplateRequest: - title: Project template request object + + + ProductCategoryRequest: + title: Product category request object type: object required: - - name - - key - - category - - scope - - phases + - displayName properties: - name: - type: string - description: the project template name - key: - type: string - description: the project template key - category: + displayName: type: string - description: the project template category - scope: - type: object - description: the project template scope - phases: - type: object - description: the project template phases + description: the product category display name - ProjectTemplateBodyParam: - title: Project template body param + ProductCategoryBodyParam: + title: Product category body param type: object required: - param properties: param: - $ref: "#/definitions/ProjectTemplateRequest" + $ref: "#/definitions/ProductCategoryRequest" - ProjectTemplate: - title: Project template object + ProductCategoryCreateRequest: + title: Product category creation request object + type: object + allOf: + - type: object + required: + - key + properties: + key: + type: string + description: the product category key + - $ref: "#/definitions/ProductCategoryRequest" + + ProductCategoryCreateBodyParam: + title: Product category creation body param + type: object + required: + - param + properties: + param: + $ref: "#/definitions/ProductCategoryCreateRequest" + + ProductCategory: + title: Product category object allOf: - type: object required: - - id - createdAt - createdBy - updatedAt - updatedBy properties: - id: - type: number - format: int64 - description: the id + key: + type: string + description: the product category key createdAt: type: string description: Datetime (GMT) when object was created @@ -1756,11 +2902,11 @@ definitions: format: int64 description: READ-ONLY. User that last updated this object readOnly: true - - $ref: "#/definitions/ProjectTemplateRequest" + - $ref: "#/definitions/ProductCategoryCreateRequest" - ProjectTemplateResponse: - title: Single project template response object + ProductCategoryResponse: + title: Single product category response object type: object properties: id: @@ -1779,10 +2925,10 @@ definitions: metadata: $ref: "#/definitions/ResponseMetadata" content: - $ref: "#/definitions/ProjectTemplate" + $ref: "#/definitions/ProductCategory" - ProjectTemplateListResponse: - title: Project template list response object + ProductCategoryListResponse: + title: Product category list response object type: object properties: id: @@ -1804,64 +2950,63 @@ definitions: 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: the product template name - productKey: - type: string - description: the product template key - icon: - type: string - description: the product template icon - brief: - type: string - description: the product template brief - details: + $ref: "#/definitions/ProductCategory" + + + ProjectTypeRequest: + title: Project type request object + type: object + required: + - displayName + properties: + displayName: type: string - description: the product template details - aliases: - type: object - description: the product template aliases - template: - type: object - description: the product template template + description: the project type display name - ProductTemplateBodyParam: - title: Product template body param + ProjectTypeBodyParam: + title: Project type body param type: object required: - param properties: param: - $ref: "#/definitions/ProductTemplateRequest" + $ref: "#/definitions/ProjectTypeRequest" - ProductTemplate: - title: Product template object + ProjectTypeCreateRequest: + title: Project type creation request object + type: object + allOf: + - type: object + required: + - key + properties: + key: + type: string + description: the project type key + - $ref: "#/definitions/ProjectTypeRequest" + + ProjectTypeCreateBodyParam: + title: Project type creation body param + type: object + required: + - param + properties: + param: + $ref: "#/definitions/ProjectTypeCreateRequest" + + ProjectType: + title: Project type object allOf: - type: object required: - - id - createdAt - createdBy - updatedAt - updatedBy properties: - id: - type: number - format: int64 - description: the id + key: + type: string + description: the project type key createdAt: type: string description: Datetime (GMT) when object was created @@ -1880,30 +3025,11 @@ definitions: format: int64 description: READ-ONLY. User that last updated this object readOnly: true - - $ref: "#/definitions/ProductTemplateRequest" + - $ref: "#/definitions/ProjectTypeCreateRequest" - ProjectUpgradeResponse: - title: Project upgrade response object - type: object - properties: - id: - type: string - description: unique id identifying the request - version: - type: string - result: - type: object - properties: - success: - type: boolean - status: - type: string - description: http status code - metadata: - $ref: "#/definitions/ResponseMetadata" - ProductTemplateResponse: - title: Single product template response object + ProjectTypeResponse: + title: Single project type response object type: object properties: id: @@ -1922,10 +3048,10 @@ definitions: metadata: $ref: "#/definitions/ResponseMetadata" content: - $ref: "#/definitions/ProductTemplate" + $ref: "#/definitions/ProjectType" - ProductTemplateListResponse: - title: Product template list response object + ProjectTypeListResponse: + title: Project type list response object type: object properties: id: @@ -1947,52 +3073,54 @@ definitions: content: type: array items: - $ref: "#/definitions/ProductTemplate" + $ref: "#/definitions/ProjectType" - ProjectPhaseRequest: - title: Project phase request object + + TimelineRequest: + title: Timeline request object type: object required: - name - - status - startDate - - endDate + - reference + - referenceId properties: name: type: string - description: the project phase name - status: + description: the timeline name + description: type: string - description: the project phase status + description: the timeline description startDate: type: string format: date - description: the project phase start date + description: the timeline start date endDate: type: string format: date - description: the project phase end date - budget: + description: the timeline end date + reference: + type: string + enum: + - project + - phase + description: the timeline reference + referenceId: type: number - description: the project phase budget - progress: - type: number - description: the project phase progress - details: - type: object - description: the project phase details + format: long + description: the timeline reference id (project id or phase id, corresponding to the `reference`) - ProjectPhaseBodyParam: - title: Project phase body param + TimelineBodyParam: + title: Timeline body param type: object required: - param properties: param: - $ref: "#/definitions/ProjectPhaseRequest" + $ref: "#/definitions/TimelineRequest" - ProjectPhase: - title: Project phase object + Timeline: + title: Timeline object allOf: - type: object required: @@ -2024,11 +3152,10 @@ definitions: format: int64 description: READ-ONLY. User that last updated this object readOnly: true - - $ref: "#/definitions/ProjectPhaseRequest" + - $ref: "#/definitions/TimelineRequest" - - ProjectPhaseResponse: - title: Single project phase response object + TimelineResponse: + title: Single timeline response object type: object properties: id: @@ -2047,10 +3174,10 @@ definitions: metadata: $ref: "#/definitions/ResponseMetadata" content: - $ref: "#/definitions/ProjectPhase" + $ref: "#/definitions/Timeline" - ProjectPhaseListResponse: - title: Project phase list response object + TimelineListResponse: + title: Timeline list response object type: object properties: id: @@ -2072,49 +3199,145 @@ definitions: content: type: array items: - $ref: "#/definitions/ProjectPhase" - + $ref: "#/definitions/Timeline" - PhaseProductRequest: - title: Phase product request object + MilestonePostRequest: + title: Milestone request object type: object + required: + - name + - duration + - startDate + - status + - type + - order + - plannedText + - activeText + - completedText + - blockedText properties: name: type: string - description: the phase product name - directProjectId: - type: number - description: the phase product direct project id - billingAccountId: - type: number - description: the phase product billing account Id - templateId: + description: the milestone name + description: + type: string + description: the milestone description + duration: type: number - description: the phase product template id + format: integer + description: the milestone duration + startDate: + type: string + format: date + description: the milestone start date + endDate: + type: string + format: date + description: the milestone end date + completionDate: + type: string + format: date + description: the milestone completion date + status: + type: string + description: the milestone status type: type: string - description: the phase product type - estimatedPrice: + description: the milestone type + details: + type: object + description: the milestone details + order: type: number - description: the phase product estimated price - actualPrice: + format: integer + description: the milestone order + plannedText: + type: string + description: the milestone planned text + activeText: + type: string + description: the milestone active text + completedText: + type: string + description: the milestone completed text + blockedText: + type: string + description: the milestone blocked text + + MilestonePatchRequest: + title: Milestone request object + type: object + required: + - name + - duration + - status + - type + - order + - plannedText + - activeText + - completedText + - blockedText + properties: + name: + type: string + description: the milestone name + description: + type: string + description: the milestone description + duration: type: number - description: the phase product actual price + format: integer + description: the milestone duration + completionDate: + type: string + format: date + description: the milestone completion date + status: + type: string + description: the milestone status + type: + type: string + description: the milestone type details: type: object - description: the phase product details + description: the milestone details + order: + type: number + format: integer + description: the milestone order + plannedText: + type: string + description: the milestone planned text + activeText: + type: string + description: the milestone active text + completedText: + type: string + description: the milestone completed text + blockedText: + type: string + description: the milestone blocked text - PhaseProductBodyParam: - title: Phase product body param + MilestonePostBodyParam: + title: Milestone body param type: object required: - param properties: param: - $ref: "#/definitions/PhaseProductRequest" + $ref: "#/definitions/MilestonePostRequest" - PhaseProduct: - title: Phase product object + MilestonePatchBodyParam: + title: Milestone body param + type: object + required: + - param + properties: + param: + $ref: "#/definitions/MilestonePatchRequest" + + Milestone: + title: Milestone object allOf: - type: object required: @@ -2146,11 +3369,10 @@ definitions: format: int64 description: READ-ONLY. User that last updated this object readOnly: true - - $ref: "#/definitions/PhaseProductRequest" + - $ref: "#/definitions/MilestonePostRequest" - - PhaseProductResponse: - title: Single phase product response object + MilestoneResponse: + title: Single milestone response object type: object properties: id: @@ -2169,10 +3391,10 @@ definitions: metadata: $ref: "#/definitions/ResponseMetadata" content: - $ref: "#/definitions/PhaseProduct" + $ref: "#/definitions/Milestone" - PhaseProductListResponse: - title: Phase product list response object + MilestoneListResponse: + title: Milestone list response object type: object properties: id: @@ -2194,64 +3416,71 @@ definitions: content: type: array items: - $ref: "#/definitions/PhaseProduct" - - - - ProjectTypeRequest: - title: Project type request object + $ref: "#/definitions/Milestone" + + + MilestoneTemplateRequest: + title: Milestone template request object type: object required: - - displayName + - name + - duration + - type + - order properties: - displayName: + name: type: string - description: the project type display name + description: the milestone template name + description: + type: string + description: the milestone template description + duration: + type: number + format: integer + description: the milestone template duration + type: + type: string + description: the milestone template type + order: + type: number + format: integer + description: the milestone template order - ProjectTypeBodyParam: - title: Project type body param + MilestoneTemplateBodyParam: + title: Milestone template body param type: object required: - param properties: param: - $ref: "#/definitions/ProjectTypeRequest" - - ProjectTypeCreateRequest: - title: Project type creation request object - type: object - allOf: - - type: object - required: - - key - properties: - key: - type: string - description: the project type key - - $ref: "#/definitions/ProjectTypeRequest" + $ref: "#/definitions/MilestoneTemplateRequest" - ProjectTypeCreateBodyParam: - title: Project type creation body param + MilestoneCloneTemplateRequest: + title: Milestone clone template request object type: object required: - - param + - sourceTemplateId properties: - param: - $ref: "#/definitions/ProjectTypeCreateRequest" + sourceTemplateId: + type: number + format: integer + description: the product template id where to clone the milestone templates from - ProjectType: - title: Project type object + MilestoneTemplate: + title: Milestone template object allOf: - type: object required: + - id - createdAt - createdBy - updatedAt - updatedBy properties: - key: - type: string - description: the project type key + id: + type: number + format: int64 + description: the id createdAt: type: string description: Datetime (GMT) when object was created @@ -2270,11 +3499,10 @@ definitions: format: int64 description: READ-ONLY. User that last updated this object readOnly: true - - $ref: "#/definitions/ProjectTypeCreateRequest" - + - $ref: "#/definitions/MilestoneTemplateRequest" - ProjectTypeResponse: - title: Single project type response object + MilestoneTemplateResponse: + title: Single milestone template response object type: object properties: id: @@ -2293,10 +3521,10 @@ definitions: metadata: $ref: "#/definitions/ResponseMetadata" content: - $ref: "#/definitions/ProjectType" + $ref: "#/definitions/MilestoneTemplate" - ProjectTypeListResponse: - title: Project type list response object + MilestoneTemplateListResponse: + title: Milestone template list response object type: object properties: id: @@ -2318,4 +3546,4 @@ definitions: content: type: array items: - $ref: "#/definitions/ProjectType" + $ref: "#/definitions/MilestoneTemplate"