diff --git a/.circleci/config.yml b/.circleci/config.yml index 2a7a0e8..02e01d8 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -2,7 +2,7 @@ version: 2 python_env: &python_env docker: - image: circleci/python:2.7-stretch-browsers - + install_awscli: &install_awscli name: "Install awscli" command: | @@ -14,7 +14,7 @@ install_deploysuite: &install_deploysuite cp ./../buildscript/master_deploy.sh . cp ./../buildscript/buildenv.sh . cp ./../buildscript/awsconfiguration.sh . - + # Instructions of deployment deploy_steps: &deploy_steps - checkout @@ -25,14 +25,14 @@ deploy_steps: &deploy_steps - setup_remote_docker - run: docker build -t ${APPNAME}:latest . - deploy: - name: "Running Masterscript - deploy tc-project-service " + name: "Running Masterscript - deploy tc-project-service " command: | - ./awsconfiguration.sh $DEPLOY_ENV + ./awsconfiguration.sh $DEPLOY_ENV source awsenvconf ./buildenv.sh -e $DEPLOY_ENV -b ${LOGICAL_ENV}-${APPNAME}-deployvar source buildenvvar ./master_deploy.sh -d ECS -e $DEPLOY_ENV -t latest -s ${LOGICAL_ENV}-global-appvar,${LOGICAL_ENV}-${APPNAME}-appvar -i ${APPNAME} - + jobs: test: docker: @@ -42,10 +42,11 @@ jobs: - POSTGRES_USER: circle_test - POSTGRES_DB: circle_test - image: elasticsearch:2.3 + - image: rabbitmq:3-management environment: DEPLOY_ENV: "DEV" LOGICAL_ENV: "dev" - APPNAME: "projects-api" + APPNAME: "projects-api" steps: - checkout - run: @@ -53,10 +54,10 @@ jobs: command: | sudo apt update sudo apt install curl - sudo apt install python-pip + sudo apt install python-pip - run: *install_awscli - run: *install_deploysuite - - setup_remote_docker + - setup_remote_docker - restore_cache: key: test-node-modules-{{ checksum "package.json" }} - run: npm install @@ -66,12 +67,12 @@ jobs: - node_modules - run: npm run lint - run: - name: "Running Masterscript - deploy tc-project-service " + name: "Running Masterscript - deploy tc-project-service " command: | - ./awsconfiguration.sh $DEPLOY_ENV + ./awsconfiguration.sh $DEPLOY_ENV source awsenvconf ./buildenv.sh -e $DEPLOY_ENV -b ${LOGICAL_ENV}-${APPNAME}-testvar - source buildenvvar + source buildenvvar npm run test rm -f buildenvvar - run: npm run build @@ -79,13 +80,13 @@ jobs: root: . paths: - dist - + deployProd: <<: *python_env environment: DEPLOY_ENV: "PROD" LOGICAL_ENV: "prod" - APPNAME: "projects-api" + APPNAME: "projects-api" steps: *deploy_steps deployDev: @@ -93,9 +94,9 @@ jobs: environment: DEPLOY_ENV: "DEV" LOGICAL_ENV: "dev" - APPNAME: "projects-api" - steps: *deploy_steps - + APPNAME: "projects-api" + steps: *deploy_steps + workflows: version: 2 build: diff --git a/.eslintrc b/.eslintrc index b0115e3..00dbe70 100644 --- a/.eslintrc +++ b/.eslintrc @@ -8,7 +8,7 @@ "mocha": true }, "rules": { - "import/no-extraneous-dependencies": ["error", {"devDependencies": ["**/*.test.js", "**/*.spec.js", "**/serviceMocks.js"]}], + "import/no-extraneous-dependencies": ["error", {"devDependencies": ["**/*.test.js", "**/*.spec.js", "src/tests/*.js"]}], "max-len": ["error", { "ignoreComments": true, "code": 120 }], "valid-jsdoc": ["error", { "requireReturn": true, diff --git a/config/custom-environment-variables.json b/config/custom-environment-variables.json index 4889667..733bbbc 100644 --- a/config/custom-environment-variables.json +++ b/config/custom-environment-variables.json @@ -52,5 +52,17 @@ "accountsAppUrl": "ACCOUNTS_APP_URL", "inviteEmailSubject": "INVITE_EMAIL_SUBJECT", "inviteEmailSectionTitle": "INVITE_EMAIL_SECTION_TITLE", - "pageSize": "PAGE_SIZE" + "pageSize": "PAGE_SIZE", + "SSO_REFCODES": "SSO_REFCODES", + "lookerConfig": { + "BASE_URL": "LOOKER_API_BASE_URL", + "CLIENT_ID": "LOOKER_API_CLIENT_ID", + "CLIENT_SECRET": "LOOKER_API_CLIENT_SECRET", + "TOKEN": "TOKEN", + "USE_MOCK": "LOOKER_API_ENABLE_MOCK", + "QUERIES": { + "REG_STATS": "LOOKER_API_REG_STATS_QUERY_ID", + "BUDGET": "LOOKER_API_BUDGET_QUERY_ID" + } + } } diff --git a/config/default.json b/config/default.json index 4b62d37..83446f6 100644 --- a/config/default.json +++ b/config/default.json @@ -56,5 +56,18 @@ "accountsAppUrl": "https://accounts.topcoder-dev.com", "MAX_REVISION_NUMBER": 100, "UNIQUE_GMAIL_VALIDATION": false, - "pageSize": 20 + "pageSize": 20, + "VALID_STATUSES_BEFORE_PAUSED": "[\"active\"]", + "SSO_REFCODES": "[]", + "lookerConfig": { + "BASE_URL": "", + "CLIENT_ID": "", + "CLIENT_SECRET": "", + "TOKEN": "TOKEN", + "USE_MOCK": "true", + "QUERIES": { + "REG_STATS": 1234, + "BUDGET": 123 + } + } } diff --git a/docs/Project API.postman_collection.json b/docs/Project API.postman_collection.json index cb1e0c8..0c48429 100644 --- a/docs/Project API.postman_collection.json +++ b/docs/Project API.postman_collection.json @@ -1,6 +1,6 @@ { "info": { - "_postman_id": "90ff8f9b-5661-4642-8b8b-84aa4b581706", + "_postman_id": "dd9afefd-0b47-4483-a4e5-33ca395791a3", "name": "Project API", "schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json" }, @@ -244,6 +244,7 @@ "response": [] } ], + "protocolProfileBehavior": {}, "_postman_isSubFolder": true }, { @@ -435,7 +436,8 @@ }, "response": [] } - ] + ], + "protocolProfileBehavior": {} }, { "name": "Project With TemplateId issue", @@ -515,7 +517,8 @@ }, "response": [] } - ] + ], + "protocolProfileBehavior": {} }, { "name": "Project Members", @@ -1024,7 +1027,8 @@ }, "response": [] } - ] + ], + "protocolProfileBehavior": {} }, { "name": "Project Members Invites", @@ -1223,7 +1227,8 @@ ] } } - ] + ], + "protocolProfileBehavior": {} }, { "name": "Projects", @@ -2157,15 +2162,16 @@ "response": [] } ], - "description": "Requests for all things projects." + "description": "Requests for all things projects.", + "protocolProfileBehavior": {} }, { - "name": "EventHandling and Integration with Direct Project API", + "name": "Workstream", "item": [ { - "name": "mock direct projects", + "name": "Create workstream without payload", "request": { - "method": "GET", + "method": "POST", "header": [ { "key": "Authorization", @@ -2176,25 +2182,42 @@ "value": "application/json" } ], + "body": { + "mode": "raw", + "raw": "{\n\t\n}" + }, "url": { - "raw": "https://api.topcoder-dev.com/v3/direct/projects", - "protocol": "https", + "raw": "{{api-url}}/projects/{{projectId}}/workstreams", "host": [ - "api", - "topcoder-dev", - "com" + "{{api-url}}" ], "path": [ - "v3", - "direct", - "projects" + "projects", + "{{projectId}}", + "workstreams" ] - } + }, + "description": "Request body is mandatory while creating project. If invalid request body is supplied this should return 422 status code." }, "response": [] }, { - "name": " Create direct project when a new project is successfully created", + "name": "Create workstream with valid values", + "event": [ + { + "listen": "test", + "script": { + "id": "15506f7a-77d3-46cb-9b37-41015ffbfdbc", + "exec": [ + "pm.test(\"Status code is 201\", function () {", + " pm.response.to.have.status(201);", + " pm.environment.set(\"workStreamId\", pm.response.json().id);", + "});" + ], + "type": "text/javascript" + } + } + ], "request": { "method": "POST", "header": [ @@ -2209,28 +2232,31 @@ ], "body": { "mode": "raw", - "raw": "{\n \"type\": \"generic\",\n \"description\": \"test project\",\n \"details\": {},\n \"billingAccountId\": 123,\n \"name\": \"test project1\"\n }" + "raw": "{\n\t\t\"name\": \"test project\",\n\t\t\"type\": \"generic\",\n\t\t\"status\": \"active\"\n}" }, "url": { - "raw": "{{api-url}}/projects", + "raw": "{{api-url}}/projects/{{projectId}}/workstreams", "host": [ "{{api-url}}" ], "path": [ - "projects" + "projects", + "{{projectId}}", + "workstreams" ] - } + }, + "description": "Valid request body. Project should be created successfully." }, "response": [] }, { - "name": "Response error from direct project service", + "name": "Create workstream by inactive user", "request": { "method": "POST", "header": [ { "key": "Authorization", - "value": "Bearer {{jwt-token}}" + "value": "Bearer userId_{{inactive-userId}}" }, { "key": "Content-Type", @@ -2239,122 +2265,108 @@ ], "body": { "mode": "raw", - "raw": "{\n\n \"role\": \"copilot\"\n}" + "raw": "{\n\t\t\"name\": \"test project\",\n\t\t\"description\": \"Hello I am a test project\",\n\t\t\"type\": \"generic\"\n}" }, "url": { - "raw": "{{api-url}}/projects/{{projectId}}/members", + "raw": "{{api-url}}/projects/{{projectId}}/workstreams", "host": [ "{{api-url}}" ], "path": [ "projects", "{{projectId}}", - "members" + "workstreams" ] - } + }, + "description": "Valid request body. Project should be created successfully." }, "response": [] }, { - "name": " Add co-pilot when a co-pilot is added to a project", + "name": "Get workstream by id", "request": { - "method": "POST", + "method": "GET", "header": [ { "key": "Authorization", "value": "Bearer {{jwt-token}}" - }, - { - "key": "Content-Type", - "value": "application/json" } ], - "body": { - "mode": "raw", - "raw": "{\n\t\"role\": \"copilot\"\n}" - }, "url": { - "raw": "{{api-url}}/projects/{{projectId}}/members", + "raw": "{{api-url}}/projects/{{projectId}}/workstreams/{{workStreamId}}", "host": [ "{{api-url}}" ], "path": [ "projects", "{{projectId}}", - "members" + "workstreams", + "{{workStreamId}}" ] - } + }, + "description": "Get a project by id. project members and attachments should also be returned." }, "response": [] }, { - "name": "remove copilot from direct project when editing project member role", + "name": "List workstreams", "request": { - "method": "PATCH", + "method": "GET", "header": [ { "key": "Authorization", "value": "Bearer {{jwt-token}}" - }, - { - "key": "Content-Type", - "value": "application/json" } ], - "body": { - "mode": "raw", - "raw": " {\n \"role\": \"customer\",\n \"isPrimary\": true\n } " - }, "url": { - "raw": "{{api-url}}/projects/{{projectId}}/members/{{memberId}}", + "raw": "{{api-url}}/projects/{{projectId}}/workstreams", "host": [ "{{api-url}}" ], "path": [ "projects", "{{projectId}}", - "members", - "{{memberId}}" + "workstreams" ] - } + }, + "description": "List all the project with no filter. Default sort and limits are applied." }, "response": [] }, { - "name": " Sync billing account id with direct", + "name": "DELETE workstream by id", "request": { - "method": "PATCH", + "method": "DELETE", "header": [ { "key": "Authorization", "value": "Bearer {{jwt-token}}" - }, - { - "key": "Content-Type", - "value": "application/json" } ], "body": { "mode": "raw", - "raw": "{\n \"billingAccountId\": 9999, \n \"name\": \"new project name\"\n }" + "raw": "" }, "url": { - "raw": "{{api-url}}/projects/{{projectId}}", + "raw": "{{api-url}}/projects/{{projectId}}/workstreams/{{workStreamId}}", "host": [ "{{api-url}}" ], "path": [ "projects", - "{{projectId}}" + "{{projectId}}", + "workstreams", + "{{workStreamId}}" ] - } + }, + "description": "Delete a project by id" }, "response": [] }, { - "name": "Delete co-pilot when a co-pilot is removed from a project", + "name": "Update workstream", "request": { - "method": "DELETE", + "method": "PATCH", "header": [ { "key": "Authorization", @@ -2367,67 +2379,33 @@ ], "body": { "mode": "raw", - "raw": "" + "raw": "{\n\t\t\"name\": \"test project - updated\"\n}" }, "url": { - "raw": "{{api-url}}/projects/{{projectId}}/members/{{memberId}}", + "raw": "{{api-url}}/projects/{{projectId}}/workstreams/{{workStreamId}}", "host": [ "{{api-url}}" ], "path": [ "projects", "{{projectId}}", - "members", - "{{memberId}}" + "workstreams", + "{{workStreamId}}" ] - } + }, + "description": "Update the project name. Name should be updated successfully." }, "response": [] } ], - "event": [ - { - "listen": "prerequest", - "script": { - "id": "ef96ac6a-0fc0-4a64-a4fe-5390e17afe67", - "type": "text/javascript", - "exec": [ - "" - ] - } - }, - { - "listen": "test", - "script": { - "id": "12f9d794-0872-4058-aafa-77b89e72025b", - "type": "text/javascript", - "exec": [ - "" - ] - } - } - ] + "description": "Requests for all things projects.", + "protocolProfileBehavior": {} }, { - "name": "Project Phase", + "name": "Work", "item": [ { - "name": "Create Phase", - "event": [ - { - "listen": "test", - "script": { - "id": "7050133a-b934-4faf-8101-d2e80b5c0710", - "exec": [ - "pm.test(\"Status code is 201\", function () {", - " pm.response.to.have.status(201);", - " pm.environment.set(\"phaseId\", pm.response.json().id);", - "});" - ], - "type": "text/javascript" - } - } - ], + "name": "Create work without payload", "request": { "method": "POST", "header": [ @@ -2442,33 +2420,36 @@ ], "body": { "mode": "raw", - "raw": "{\n\t\"name\": \"test project phase\",\n\t\"status\": \"active\",\n\t\"startDate\": \"2018-05-15T00:00:00\",\n\t\"endDate\": \"2018-05-16T00:00:00\",\n\t\"budget\": 20,\n\t\"details\": {\n\t\t\"aDetails\": \"a details\"\n\t}\n}" + "raw": "{\n\t\n}" }, "url": { - "raw": "{{api-url}}/projects/{{projectId}}/phases", + "raw": "{{api-url}}/projects/{{projectId}}/workstreams/{{workStreamId}}/works", "host": [ "{{api-url}}" ], "path": [ "projects", "{{projectId}}", - "phases" + "workstreams", + "{{workStreamId}}", + "works" ] - } + }, + "description": "Request body is mandatory while creating project. If invalid request body is supplied this should return 422 status code." }, "response": [] }, { - "name": "Create Phase with order", + "name": "Create work with valid values", "event": [ { "listen": "test", "script": { - "id": "2f771afe-7b4e-4260-b04d-324e880eb61b", + "id": "34d06fac-76f6-47b8-9b70-6e9c558e2bf1", "exec": [ "pm.test(\"Status code is 201\", function () {", " pm.response.to.have.status(201);", - " pm.environment.set(\"phaseId\", pm.response.json().id);", + " pm.environment.set(\"workId\", pm.response.json().id);", "});" ], "type": "text/javascript" @@ -2489,45 +2470,33 @@ ], "body": { "mode": "raw", - "raw": "{\n\t\"name\": \"test project phase\",\n\t\"status\": \"active\",\n\t\"startDate\": \"2018-05-15T00:00:00\",\n\t\"endDate\": \"2018-05-16T00:00:00\",\n\t\"budget\": 20,\n\t\"details\": {\n\t\t\"aDetails\": \"a details\"\n\t},\n\t\"order\": 1\n}" + "raw": "{\n\t\t\"name\": \"test project phase\",\n\t\t\"status\": \"active\",\n\t\t\"startDate\": \"2018-05-16T00:00:00\",\n\t\t\"endDate\": \"2018-05-17T00:00:00\",\n\t\t\"order\": 1,\n\t\t\"budget\": 20,\n\t\t\"details\": {\n\t\t\t\"aDetails\": \"a details\"\n\t\t}\n\t}" }, "url": { - "raw": "{{api-url}}/projects/{{projectId}}/phases", + "raw": "{{api-url}}/projects/{{projectId}}/workstreams/{{workStreamId}}/works", "host": [ "{{api-url}}" ], "path": [ "projects", "{{projectId}}", - "phases" + "workstreams", + "{{workStreamId}}", + "works" ] - } + }, + "description": "Valid request body. Project should be created successfully." }, "response": [] }, { - "name": "Create Phase with productTemplateId", - "event": [ - { - "listen": "test", - "script": { - "id": "8415ad98-b3f6-4330-88b6-e1830da2e4f9", - "exec": [ - "pm.test(\"Status code is 201\", function () {", - " pm.response.to.have.status(201);", - " pm.environment.set(\"phaseId\", pm.response.json().id);", - "});" - ], - "type": "text/javascript" - } - } - ], + "name": "Create work - 403", "request": { "method": "POST", "header": [ { "key": "Authorization", - "value": "Bearer {{jwt-token}}" + "value": "Bearer {{jwt-token-member-40051331}}" }, { "key": "Content-Type", @@ -2536,216 +2505,215 @@ ], "body": { "mode": "raw", - "raw": "{\n\t\"name\": \"test project phase\",\n\t\"status\": \"active\",\n\t\"startDate\": \"2018-05-15T00:00:00\",\n\t\"endDate\": \"2018-05-16T00:00:00\",\n\t\"budget\": 20,\n\t\"details\": {\n\t\t\"aDetails\": \"a details\"\n\t},\n\t\"order\": 1,\n\t\"productTemplateId\": {{productTemplateId}}\n}" + "raw": "{\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\"order\": 2,\n\t\t\"budget\": 20,\n\t\t\"details\": {\n\t\t\t\"aDetails\": \"a details\"\n\t\t}\n\t}" }, "url": { - "raw": "{{api-url}}/projects/{{projectId}}/phases", + "raw": "{{api-url}}/projects/{{projectId}}/workstreams/{{workStreamId}}/works", "host": [ "{{api-url}}" ], "path": [ "projects", "{{projectId}}", - "phases" + "workstreams", + "{{workStreamId}}", + "works" ] - } + }, + "description": "Valid request body. Project should be created successfully." }, "response": [] }, { - "name": "List Phase", + "name": "Create work by inactive user", "request": { - "method": "GET", + "method": "POST", "header": [ { "key": "Authorization", - "value": "Bearer {{jwt-token}}" + "value": "Bearer userId_{{inactive-userId}}" }, { "key": "Content-Type", "value": "application/json" } ], + "body": { + "mode": "raw", + "raw": "{\n\t\t\"name\": \"test project\",\n\t\t\"description\": \"Hello I am a test project\",\n\t\t\"type\": \"generic\"\n}" + }, "url": { - "raw": "{{api-url}}/projects/{{projectId}}/phases", + "raw": "{{api-url}}/projects/{{projectId}}/workstreams/{{workStreamId}}/works", "host": [ "{{api-url}}" ], "path": [ "projects", "{{projectId}}", - "phases" + "workstreams", + "{{workStreamId}}", + "works" ] - } + }, + "description": "Valid request body. Project should be created successfully." }, "response": [] }, { - "name": "List Phase with fields", + "name": "Get work by id", "request": { "method": "GET", "header": [ { "key": "Authorization", "value": "Bearer {{jwt-token}}" - }, - { - "key": "Content-Type", - "value": "application/json" } ], "url": { - "raw": "{{api-url}}/projects/{{projectId}}/phases?fields=status,name,budget", + "raw": "{{api-url}}/projects/{{projectId}}/workstreams/{{workStreamId}}/works/{{workId}}", "host": [ "{{api-url}}" ], "path": [ "projects", "{{projectId}}", - "phases" - ], - "query": [ - { - "key": "fields", - "value": "status,name,budget" - } + "workstreams", + "{{workStreamId}}", + "works", + "{{workId}}" ] - } + }, + "description": "Get a project by id. project members and attachments should also be returned." }, "response": [] }, { - "name": "List Phase with sort", + "name": "List works", "request": { "method": "GET", "header": [ { "key": "Authorization", "value": "Bearer {{jwt-token}}" - }, - { - "key": "Content-Type", - "value": "application/json" } ], "url": { - "raw": "{{api-url}}/projects/{{projectId}}/phases?sort=status desc", + "raw": "{{api-url}}/projects/{{projectId}}/workstreams/{{workStreamId}}/works", "host": [ "{{api-url}}" ], "path": [ "projects", "{{projectId}}", - "phases" - ], - "query": [ - { - "key": "sort", - "value": "status desc" - } + "workstreams", + "{{workStreamId}}", + "works" ] - } + }, + "description": "List all the project with no filter. Default sort and limits are applied." }, "response": [] }, { - "name": "List Phase with sort by order", + "name": "List works - sort", "request": { "method": "GET", "header": [ { "key": "Authorization", "value": "Bearer {{jwt-token}}" - }, - { - "key": "Content-Type", - "value": "application/json" } ], "url": { - "raw": "{{api-url}}/projects/{{projectId}}/phases?sort=order desc", + "raw": "{{api-url}}/projects/{{projectId}}/workstreams/{{workStreamId}}/works?sort=startDate desc", "host": [ "{{api-url}}" ], "path": [ "projects", "{{projectId}}", - "phases" + "workstreams", + "{{workStreamId}}", + "works" ], "query": [ { "key": "sort", - "value": "order desc" + "value": "startDate desc" } ] - } + }, + "description": "List all the project with no filter. Default sort and limits are applied." }, "response": [] }, { - "name": "Get Phase", + "name": "List works - fields", "request": { "method": "GET", "header": [ { "key": "Authorization", "value": "Bearer {{jwt-token}}" - }, - { - "key": "Content-Type", - "value": "application/json" } ], "url": { - "raw": "{{api-url}}/projects/{{projectId}}/phases/{{phaseId}}", + "raw": "{{api-url}}/projects/{{projectId}}/workstreams/{{workStreamId}}/works?fields=status,name,budget", "host": [ "{{api-url}}" ], "path": [ "projects", "{{projectId}}", - "phases", - "{{phaseId}}" - ] - } + "workstreams", + "{{workStreamId}}", + "works" + ], + "query": [ + { + "key": "fields", + "value": "status,name,budget" + } + ] + }, + "description": "List all the project with no filter. Default sort and limits are applied." }, "response": [] }, { - "name": "Update Phase", + "name": "DELETE work by id", "request": { - "method": "PATCH", + "method": "DELETE", "header": [ { "key": "Authorization", "value": "Bearer {{jwt-token}}" - }, - { - "key": "Content-Type", - "value": "application/json" } ], "body": { "mode": "raw", - "raw": "{\n\t\"name\": \"test project phase xxx\",\n\t\"status\": \"inactive\",\n\t\"startDate\": \"2018-05-14T00:00:00\",\n\t\"endDate\": \"2018-05-15T00:00:00\",\n\t\"budget\": 30,\n\t\"progress\": 15,\n\t\"details\": {\n\t\t\"message\": \"phase details\"\n\t}\n}" + "raw": "" }, "url": { - "raw": "{{api-url}}/projects/{{projectId}}/phases/{{phaseId}}", + "raw": "{{api-url}}/projects/{{projectId}}/workstreams/{{workStreamId}}/works/{{workId}}", "host": [ "{{api-url}}" ], "path": [ "projects", "{{projectId}}", - "phases", - "{{phaseId}}" + "workstreams", + "{{workStreamId}}", + "works", + "{{workId}}" ] - } + }, + "description": "Delete a project by id" }, "response": [] }, { - "name": "Update Phase with order", + "name": "Update work", "request": { "method": "PATCH", "header": [ @@ -2760,31 +2728,34 @@ ], "body": { "mode": "raw", - "raw": "{\n\t\"name\": \"test project phase xxx\",\n\t\"status\": \"inactive\",\n\t\"startDate\": \"2018-05-14T00:00:00\",\n\t\"endDate\": \"2018-05-15T00:00:00\",\n\t\"budget\": 30,\n\t\"progress\": 15,\n\t\"details\": {\n\t\t\"message\": \"phase details\"\n\t},\n\t\"order\": 1\n}" + "raw": "{\n\t\t\"name\": \"test project phase 11\",\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\": 24,\n\t\t\"order\": 2\n}" }, "url": { - "raw": "{{api-url}}/projects/{{projectId}}/phases/{{phaseId}}", + "raw": "{{api-url}}/projects/{{projectId}}/workstreams/{{workStreamId}}/works/{{workId}}", "host": [ "{{api-url}}" ], "path": [ "projects", "{{projectId}}", - "phases", - "{{phaseId}}" + "workstreams", + "{{workStreamId}}", + "works", + "{{workId}}" ] - } + }, + "description": "Update the project name. Name should be updated successfully." }, "response": [] }, { - "name": "Delete Phase", + "name": "Update work 403", "request": { - "method": "DELETE", + "method": "PATCH", "header": [ { "key": "Authorization", - "value": "Bearer {{jwt-token}}" + "value": "Bearer {{jwt-token-copilot-40051332}}" }, { "key": "Content-Type", @@ -2793,42 +2764,80 @@ ], "body": { "mode": "raw", - "raw": "" + "raw": "{\n\t\t\"name\": \"test project - updated\",\n\t\t\"type\": \"generic\",\n\t\t\"status\": \"active\"\n}" }, "url": { - "raw": "{{api-url}}/projects/{{projectId}}/phases/{{phaseId}}", + "raw": "{{api-url}}/projects/{{projectId}}/workstreams/{{workStreamId}}", "host": [ "{{api-url}}" ], "path": [ "projects", "{{projectId}}", - "phases", - "{{phaseId}}" + "workstreams", + "{{workStreamId}}" ] - } + }, + "description": "Update the project name. If user don't have permission to the project than it should return 403." }, "response": [] } - ] + ], + "description": "Requests for all things projects.", + "protocolProfileBehavior": {} }, { - "name": "Phase Products", + "name": "Work Item", "item": [ { - "name": "Create Phase Product", + "name": "Create work item without payload", + "request": { + "method": "POST", + "header": [ + { + "key": "Authorization", + "value": "Bearer {{jwt-token}}" + }, + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n\t\n}" + }, + "url": { + "raw": "{{api-url}}/projects/workstreams/{{workStreamId}}/works/{{workId}}/workitems", + "host": [ + "{{api-url}}" + ], + "path": [ + "projects", + "workstreams", + "{{workStreamId}}", + "works", + "{{workId}}", + "workitems" + ] + }, + "description": "Request body is mandatory while creating project. If invalid request body is supplied this should return 422 status code." + }, + "response": [] + }, + { + "name": "Create work item with valid values", "event": [ { "listen": "test", "script": { - "id": "77f089b3-cbe6-4fb4-b54f-2a52d138a050", + "type": "text/javascript", "exec": [ "pm.test(\"Status code is 201\", function () {", " pm.response.to.have.status(201);", - " pm.environment.set(\"phaseProductId\", pm.response.json().id);", + " pm.environment.set(\"itemId\", pm.response.json().id);", "});" - ], - "type": "text/javascript" + ] } } ], @@ -2846,26 +2855,66 @@ ], "body": { "mode": "raw", - "raw": "{\n\t\"name\": \"test phase product\",\n\t\"type\": \"type 1\",\n\t\"estimatedPrice\": 10\n}" + "raw": "{\n\t\t\"name\": \"work item - phase product\",\n\t\t\"type\": \"type 1\",\n\t\t\"estimatedPrice\": 12\n}" }, "url": { - "raw": "{{api-url}}/projects/{{projectId}}/phases/{{phaseId}}/products", + "raw": "{{api-url}}/projects/{{projectId}}/workstreams/{{workStreamId}}/works/{{workId}}/workitems", "host": [ "{{api-url}}" ], "path": [ "projects", "{{projectId}}", - "phases", - "{{phaseId}}", - "products" + "workstreams", + "{{workStreamId}}", + "works", + "{{workId}}", + "workitems" ] - } + }, + "description": "Valid request body. Project should be created successfully." }, "response": [] }, { - "name": "List Phase Products", + "name": "Create work item by inactive user", + "request": { + "method": "POST", + "header": [ + { + "key": "Authorization", + "value": "Bearer userId_{{inactive-userId}}" + }, + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n\t\t\"name\": \"test project\",\n\t\t\"description\": \"Hello I am a test project\",\n\t\t\"type\": \"generic\"\n}" + }, + "url": { + "raw": "{{api-url}}/projects/{{projectId}}/workstreams/{{workStreamId}}/works/{{workId}}/workitems", + "host": [ + "{{api-url}}" + ], + "path": [ + "projects", + "{{projectId}}", + "workstreams", + "{{workStreamId}}", + "works", + "{{workId}}", + "workitems" + ] + }, + "description": "Valid request body. Project should be created successfully." + }, + "response": [] + }, + { + "name": "Get work item by id", "request": { "method": "GET", "header": [ @@ -2875,23 +2924,27 @@ } ], "url": { - "raw": "{{api-url}}/projects/{{projectId}}/phases/{{phaseId}}/products", + "raw": "{{api-url}}/projects/{{projectId}}/workstreams/{{workStreamId}}/works/{{workId}}/workitems/{{itemId}}", "host": [ "{{api-url}}" ], "path": [ "projects", "{{projectId}}", - "phases", - "{{phaseId}}", - "products" + "workstreams", + "{{workStreamId}}", + "works", + "{{workId}}", + "workitems", + "{{itemId}}" ] - } + }, + "description": "Get a project by id. project members and attachments should also be returned." }, "response": [] }, { - "name": "Get Phase Product", + "name": "List work items", "request": { "method": "GET", "header": [ @@ -2901,151 +2954,147 @@ } ], "url": { - "raw": "{{api-url}}/projects/{{projectId}}/phases/{{phaseId}}/products/{{phaseProductId}}", + "raw": "{{api-url}}/projects/{{projectId}}/workstreams/{{workStreamId}}/works/{{workId}}/workitems", "host": [ "{{api-url}}" ], "path": [ "projects", "{{projectId}}", - "phases", - "{{phaseId}}", - "products", - "{{phaseProductId}}" + "workstreams", + "{{workStreamId}}", + "works", + "{{workId}}", + "workitems" ] - } + }, + "description": "List all the project with no filter. Default sort and limits are applied." }, "response": [] }, { - "name": "Update Phase Product", + "name": "DELETE work item by id", "request": { - "method": "PATCH", + "method": "DELETE", "header": [ { "key": "Authorization", "value": "Bearer {{jwt-token}}" - }, - { - "key": "Content-Type", - "value": "application/json" } ], "body": { "mode": "raw", - "raw": "{\n\t\"name\": \"test phase product xxx\",\n\t\"type\": \"type 2\",\n\t\"templateId\": 10,\n\t\"estimatedPrice\": 1.234567,\n\t\"actualPrice\": 2.34567,\n\t\"details\": {\n\t\t\"message\": \"this is a JSON type. You can use any json\"\n\t}\n}" + "raw": "" }, "url": { - "raw": "{{api-url}}/projects/{{projectId}}/phases/{{phaseId}}/products/{{phaseProductId}}", + "raw": "{{api-url}}/projects/{{projectId}}/workstreams/{{workStreamId}}/works/{{workId}}/workitems/{{itemId}}", "host": [ "{{api-url}}" ], "path": [ "projects", "{{projectId}}", - "phases", - "{{phaseId}}", - "products", - "{{phaseProductId}}" + "workstreams", + "{{workStreamId}}", + "works", + "{{workId}}", + "workitems", + "{{itemId}}" ] - } + }, + "description": "Delete a project by id" }, "response": [] }, { - "name": "Delete Phase Product", + "name": "Update work item", "request": { - "method": "DELETE", + "method": "PATCH", "header": [ { "key": "Authorization", "value": "Bearer {{jwt-token}}" + }, + { + "key": "Content-Type", + "value": "application/json" } ], "body": { "mode": "raw", - "raw": "" + "raw": "{\n\t\t\"name\": \"work item - updated\"\n}" }, "url": { - "raw": "{{api-url}}/projects/{{projectId}}/phases/{{phaseId}}/products/{{phaseProductId}}", + "raw": "{{api-url}}/projects/{{projectId}}/workstreams/{{workStreamId}}/works/{{workId}}/workitems/{{itemId}}", "host": [ "{{api-url}}" ], "path": [ "projects", "{{projectId}}", - "phases", - "{{phaseId}}", - "products", - "{{phaseProductId}}" + "workstreams", + "{{workStreamId}}", + "works", + "{{workId}}", + "workitems", + "{{itemId}}" ] - } + }, + "description": "Update the project name. Name should be updated successfully." }, "response": [] } - ] + ], + "description": "Requests for all things projects.", + "protocolProfileBehavior": {} }, { - "name": "Project Templates", + "name": "Work Management Permission", "item": [ { - "name": "Create project template", - "event": [ - { - "listen": "test", - "script": { - "id": "2f79c07b-8076-4715-abf7-1d6903df444f", - "exec": [ - "pm.test(\"Status code is 201\", function () {", - " pm.response.to.have.status(201);", - " pm.environment.set(\"projectTemplateId\", pm.response.json().id);", - "});" - ], - "type": "text/javascript" - } - } - ], + "name": "Create work management permission without payload", "request": { "method": "POST", "header": [ - { - "key": "Content-Type", - "value": "application/json" - }, { "key": "Authorization", "value": "Bearer {{jwt-token}}" + }, + { + "key": "Content-Type", + "value": "application/json" } ], "body": { "mode": "raw", - "raw": "{\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 }" + "raw": "{\n\t\n}" }, "url": { - "raw": "{{api-url}}/projects/metadata/projectTemplates", + "raw": "{{api-url}}/projects/metadata/workManagementPermission", "host": [ "{{api-url}}" ], "path": [ "projects", "metadata", - "projectTemplates" + "workManagementPermission" ] - } + }, + "description": "Request body is mandatory while creating project. If invalid request body is supplied this should return 422 status code." }, "response": [] }, { - "name": "Create project template with form, priceConfig, planConfig", + "name": "Create work management permission with valid values 1", "event": [ { "listen": "test", "script": { - "id": "4c442ea3-0834-4a30-8044-a4e94fd4ea2d", + "id": "305f3d68-6e9f-4c4d-b031-7487986a93e2", "exec": [ "pm.test(\"Status code is 201\", function () {", " pm.response.to.have.status(201);", - " pm.environment.set(\"projectTemplateId\", pm.response.json().id);", + " pm.environment.set(\"workManagementPermissionId\", pm.response.json().id);", "});" ], "type": "text/javascript" @@ -3055,44 +3104,45 @@ "request": { "method": "POST", "header": [ - { - "key": "Content-Type", - "value": "application/json" - }, { "key": "Authorization", "value": "Bearer {{jwt-token}}" + }, + { + "key": "Content-Type", + "value": "application/json" } ], "body": { "mode": "raw", - "raw": "{\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 \"form\": {\r\n \t\"key\": \"dev\",\r\n \t\"version\": 1\r\n },\r\n \"priceConfig\": {\r\n \t\"key\": \"dev\",\r\n \t\"version\": 1\r\n },\r\n \"planConfig\": {\r\n \t\"key\": \"dev\",\r\n \t\"version\": 1\r\n }\r\n }" + "raw": "{\n \"policy\": \"work.new.create\",\n \"permission\": {\n \"allowRule\": {\n \"projectRoles\": [\"customer\", \"copilot\"],\n \"topcoderRoles\": [\"Connect Manager\", \"Connect Admin\", \"administrator\"]\n },\n \"denyRule\": { \"projectRoles\": [\"copilot\"] }\n },\n \"projectTemplateId\": 1\n}" }, "url": { - "raw": "{{api-url}}/projects/metadata/projectTemplates", + "raw": "{{api-url}}/projects/metadata/workManagementPermission", "host": [ "{{api-url}}" ], "path": [ "projects", "metadata", - "projectTemplates" + "workManagementPermission" ] - } + }, + "description": "Valid request body. Project should be created successfully." }, "response": [] }, { - "name": "Create project template with only form key", + "name": "Create work management permission with valid values 2", "event": [ { "listen": "test", "script": { - "id": "7d0ae3ca-fe2d-40eb-b5c8-9b03955babec", + "id": "305f3d68-6e9f-4c4d-b031-7487986a93e2", "exec": [ "pm.test(\"Status code is 201\", function () {", " pm.response.to.have.status(201);", - " pm.environment.set(\"projectTemplateId\", pm.response.json().id);", + " pm.environment.set(\"workManagementPermissionId\", pm.response.json().id);", "});" ], "type": "text/javascript" @@ -3102,265 +3152,250 @@ "request": { "method": "POST", "header": [ - { - "key": "Content-Type", - "value": "application/json" - }, { "key": "Authorization", "value": "Bearer {{jwt-token}}" + }, + { + "key": "Content-Type", + "value": "application/json" } ], "body": { "mode": "raw", - "raw": "{\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 \"form\": {\r\n \t\"key\": \"dev\"\r\n }\r\n }" + "raw": "{\n \"policy\": \"work.new.create\",\n \"permission\": {\n \"allowRule\": {\n \"projectRoles\": [\"customer\", \"copilot\"],\n \"topcoderRoles\": [\"Connect Manager\", \"Connect Admin\", \"administrator\"]\n },\n \"denyRule\": { \"projectRoles\": [\"copilot\"] }\n },\n \"projectTemplateId\": 2\n}" }, "url": { - "raw": "{{api-url}}/projects/metadata/projectTemplates", + "raw": "{{api-url}}/projects/metadata/workManagementPermission", "host": [ "{{api-url}}" ], "path": [ "projects", "metadata", - "projectTemplates" + "workManagementPermission" ] - } + }, + "description": "Valid request body. Project should be created successfully." }, "response": [] }, { - "name": "Create project template with wrong form key", + "name": "Create work management permission by inactive user", "request": { "method": "POST", "header": [ { - "key": "Content-Type", - "value": "application/json" + "key": "Authorization", + "value": "Bearer userId_{{inactive-userId}}" }, { - "key": "Authorization", - "value": "Bearer {{jwt-token}}" + "key": "Content-Type", + "value": "application/json" } ], "body": { "mode": "raw", - "raw": "{\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 \"form\": {\r\n \t\"key\": \"wrong-key\"\r\n }\r\n }" + "raw": "{\n \"policy\": \"work.create\",\n \"permission\": {\n \"allowRule\": {\n \"projectRoles\": [\"customer\", \"copilot\"],\n \"topcoderRoles\": [\"Connect Manager\", \"Connect Admin\", \"administrator\"]\n },\n \"denyRule\": { \"projectRoles\": [\"copilot\"] }\n },\n \"projectTemplateId\": 1\n}" }, "url": { - "raw": "{{api-url}}/projects/metadata/projectTemplates", + "raw": "{{api-url}}/projects/metadata/workManagementPermission", "host": [ "{{api-url}}" ], "path": [ "projects", "metadata", - "projectTemplates" + "workManagementPermission" ] - } + }, + "description": "Valid request body. Project should be created successfully." }, "response": [] }, { - "name": "Create project template with wrong model version", + "name": "Get work management permission by id", "request": { - "method": "POST", + "method": "GET", "header": [ - { - "key": "Content-Type", - "value": "application/json" - }, { "key": "Authorization", "value": "Bearer {{jwt-token}}" } ], - "body": { - "mode": "raw", - "raw": "{\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 \"form\": {\r\n \t\"key\": \"dev\",\r\n \t\"version\": 1123\r\n },\r\n \"priceConfig\": {\r\n \t\"key\": \"dev\",\r\n \t\"version\": 1123\r\n },\r\n \"planConfig\": {\r\n \t\"key\": \"dev\",\r\n \t\"version\": 1123\r\n }\r\n }" - }, "url": { - "raw": "{{api-url}}/projects/metadata/projectTemplates", + "raw": "{{api-url}}/projects/metadata/workManagementPermission/{{workManagementPermissionId}}", "host": [ "{{api-url}}" ], "path": [ "projects", "metadata", - "projectTemplates" + "workManagementPermission", + "{{workManagementPermissionId}}" ] - } + }, + "description": "Get a project by id. project members and attachments should also be returned." }, "response": [] }, { - "name": "List project templates", + "name": "List work management permissions - filter required", "request": { "method": "GET", "header": [ - { - "key": "Content-Type", - "value": "application/json" - }, { "key": "Authorization", "value": "Bearer {{jwt-token}}" } ], "url": { - "raw": "{{api-url}}/projects/metadata/projectTemplates", + "raw": "{{api-url}}/projects/metadata/workManagementPermission", "host": [ "{{api-url}}" ], "path": [ "projects", "metadata", - "projectTemplates" + "workManagementPermission" ] - } + }, + "description": "List all the project with no filter. Default sort and limits are applied." }, "response": [] }, { - "name": "Get project template", + "name": "List work management permissions - missing filter", "request": { "method": "GET", "header": [ - { - "key": "Content-Type", - "value": "application/json" - }, { "key": "Authorization", "value": "Bearer {{jwt-token}}" } ], "url": { - "raw": "{{api-url}}/projects/metadata/projectTemplates/{{projectTemplateId}}", + "raw": "{{api-url}}/projects/metadata/workManagementPermission?filter=template", "host": [ "{{api-url}}" ], "path": [ "projects", "metadata", - "projectTemplates", - "{{projectTemplateId}}" + "workManagementPermission" + ], + "query": [ + { + "key": "filter", + "value": "template" + } ] - } + }, + "description": "List all the project with no filter. Default sort and limits are applied." }, "response": [] }, { - "name": "Upgrade a project template with from, priceConfig, planConfig", + "name": "List work management permissions - invalid filter", "request": { - "method": "POST", + "method": "GET", "header": [ - { - "key": "Content-Type", - "value": "application/json" - }, { "key": "Authorization", "value": "Bearer {{jwt-token}}" } ], - "body": { - "mode": "raw", - "raw": "{\r\n \"form\": {\r\n \t\"key\": \"dev\",\t\r\n \t \"version\": 2\r\n },\r\n \"priceConfig\": {\r\n \t\"key\": \"dev\",\t\r\n \t \"version\": 1\r\n },\r\n \"planConfig\": {\r\n \t\"key\": \"qa\",\t\r\n \t \"version\": 2\t\r\n }\r\n }" - }, "url": { - "raw": "{{api-url}}/projects/metadata/projectTemplates/{{projectTemplateId}}/upgrade", + "raw": "{{api-url}}/projects/metadata/workManagementPermission?filter=invalid%3D2%26projectTemplateId%3D1", "host": [ "{{api-url}}" ], "path": [ "projects", "metadata", - "projectTemplates", - "{{projectTemplateId}}", - "upgrade" + "workManagementPermission" + ], + "query": [ + { + "key": "filter", + "value": "invalid%3D2%26projectTemplateId%3D1" + } ] - } + }, + "description": "List all the project with no filter. Default sort and limits are applied." }, "response": [] }, { - "name": "Upgrade a project template with wrong model version", + "name": "List work management permissions", "request": { - "method": "POST", + "method": "GET", "header": [ - { - "key": "Content-Type", - "value": "application/json" - }, { "key": "Authorization", "value": "Bearer {{jwt-token}}" } ], - "body": { - "mode": "raw", - "raw": "{\r\n \"form\": {\r\n \t\"key\": \"dev\",\t\r\n \t \"version\": 1234\r\n },\r\n \"priceConfig\": {\r\n \t\"key\": \"dev\",\t\r\n \t \"version\": 1234\r\n },\r\n \"planConfig\": {\r\n \t\"key\": \"dev\",\t\r\n \t \"version\": 1234\r\n }\r\n }" - }, "url": { - "raw": "{{api-url}}/projects/metadata/projectTemplates/{{projectTemplateId}}/upgrade", + "raw": "{{api-url}}/projects/metadata/workManagementPermission?filter=projectTemplateId%3D1", "host": [ "{{api-url}}" ], "path": [ "projects", "metadata", - "projectTemplates", - "{{projectTemplateId}}", - "upgrade" + "workManagementPermission" + ], + "query": [ + { + "key": "filter", + "value": "projectTemplateId%3D1" + } ] - } + }, + "description": "List all the project with no filter. Default sort and limits are applied." }, "response": [] }, { - "name": "Upgrade a project template without define form, priceConfig, planConfig", + "name": "Update work management permission", "request": { - "method": "POST", + "method": "PATCH", "header": [ - { - "key": "Content-Type", - "value": "application/json" - }, { "key": "Authorization", "value": "Bearer {{jwt-token}}" + }, + { + "key": "Content-Type", + "value": "application/json" } ], "body": { "mode": "raw", - "raw": "{\r\n}" + "raw": "{\n \"policy\": \"work.new.delete\",\n \"permission\": {\n \"allowRule\": {\n \"projectRoles\": [\"customer\", \"copilot\"],\n \"topcoderRoles\": [\"Connect Manager\", \"Connect Admin\", \"administrator\"]\n },\n \"denyRule\": { \"projectRoles\": [\"copilot\"] }\n },\n \"projectTemplateId\": 1\n}" }, "url": { - "raw": "{{api-url}}/projects/metadata/projectTemplates/{{projectTemplateId}}/upgrade", + "raw": "{{api-url}}/projects/metadata/workManagementPermission/{{workManagementPermissionId}}", "host": [ "{{api-url}}" ], "path": [ "projects", "metadata", - "projectTemplates", - "{{projectTemplateId}}", - "upgrade" + "workManagementPermission", + "{{workManagementPermissionId}}" ] - } + }, + "description": "Update the project name. Name should be updated successfully." }, "response": [] }, { - "name": "Update project template", + "name": "Delete work management permission by id", "request": { - "method": "PATCH", + "method": "DELETE", "header": [ - { - "key": "Content-Type", - "value": "application/json" - }, { "key": "Authorization", "value": "Bearer {{jwt-token}}" @@ -3368,882 +3403,769 @@ ], "body": { "mode": "raw", - "raw": "{\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 }" + "raw": "" }, "url": { - "raw": "{{api-url}}/projects/metadata/projectTemplates/{{projectTemplateId}}", + "raw": "{{api-url}}/projects/metadata/workManagementPermission/{{workManagementPermissionId}}", "host": [ "{{api-url}}" ], "path": [ "projects", "metadata", - "projectTemplates", - "{{projectTemplateId}}" + "workManagementPermission", + "{{workManagementPermissionId}}" ] - } + }, + "description": "Delete a project by id" }, "response": [] - }, + } + ], + "description": "Requests for all things projects.", + "protocolProfileBehavior": {} + }, + { + "name": "Permissions", + "item": [ { - "name": "Update project template with define form, priceConfig, planConfig", + "name": "Get permissions - 404", "request": { - "method": "PATCH", + "method": "GET", "header": [ - { - "key": "Content-Type", - "value": "application/json" - }, { "key": "Authorization", "value": "Bearer {{jwt-token}}" } ], - "body": { - "mode": "raw", - "raw": "{\r\n \"name\":\"new name\",\r\n \"key\":\"new key\",\r\n \"category\":\"app\",\r\n \"form\": {\r\n \"key\": \"dev\",\r\n \"version\": 1\r\n },\r\n \"priceConfig\": {\r\n \"key\": \"dev\",\r\n \"version\": 1\r\n },\r\n \"planConfig\": {\r\n \"key\": \"dev\",\r\n \"version\": 1\r\n }\r\n }" - }, "url": { - "raw": "{{api-url}}/projects/metadata/projectTemplates/{{projectTemplateId}}", + "raw": "{{api-url}}/projects/9999/permissions", "host": [ "{{api-url}}" ], "path": [ "projects", - "metadata", - "projectTemplates", - "{{projectTemplateId}}" + "9999", + "permissions" ] - } + }, + "description": "List all the project with no filter. Default sort and limits are applied." }, "response": [] }, { - "name": "Update project template with wrong form, priceConfig, planConfig", + "name": "Create project invite for customer with valid values", "request": { - "method": "PATCH", + "method": "POST", "header": [ - { - "key": "Content-Type", - "value": "application/json" - }, { "key": "Authorization", "value": "Bearer {{jwt-token}}" + }, + { + "key": "Content-Type", + "value": "application/json" } ], "body": { "mode": "raw", - "raw": "{\r\n \"name\":\"new name\",\r\n \"key\":\"new key\",\r\n \"category\":\"app\",\r\n\t\"form\": {\r\n \"key\": \"dev\",\r\n \"version\": 1123\r\n },\r\n \"priceConfig\": {\r\n \"key\": \"dev\",\r\n \"version\": 1123\r\n },\r\n \"planConfig\": {\r\n \"key\": \"dev\",\r\n \"version\": 1123\r\n }\r\n }" + "raw": "{\n\t\t\"userIds\": [40051331],\n\t\t\"role\": \"customer\"\n}" }, "url": { - "raw": "{{api-url}}/projects/metadata/projectTemplates/{{projectTemplateId}}", + "raw": "{{api-url}}/projects/{{projectId}}/members/invite", "host": [ "{{api-url}}" ], "path": [ "projects", - "metadata", - "projectTemplates", - "{{projectTemplateId}}" + "{{projectId}}", + "members", + "invite" ] - } + }, + "description": "If the request payload is valid, than project customer should be added. This should sync with the direct project is project is associated with direct project." }, "response": [] }, { - "name": "Delete project template", + "name": "Update project invite to accept invite", "request": { - "method": "DELETE", + "method": "PUT", "header": [ { - "key": "Content-Type", - "value": "application/json" + "key": "Authorization", + "value": "Bearer {{jwt-token-member-40051331}}" }, { - "key": "Authorization", - "value": "Bearer {{jwt-token}}" + "key": "Content-Type", + "value": "application/json" } ], "body": { "mode": "raw", - "raw": "{\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 }" + "raw": "{\n\t\t\"userId\": 40051331,\n\t\t\"status\": \"accepted\"\n}" }, "url": { - "raw": "{{api-url}}/projects/metadata/projectTemplates/{{projectTemplateId}}", + "raw": "{{api-url}}/projects/{{projectId}}/members/invite", "host": [ "{{api-url}}" ], "path": [ "projects", - "metadata", - "projectTemplates", - "{{projectTemplateId}}" + "{{projectId}}", + "members", + "invite" ] - } + }, + "description": "If the request payload is valid, than project customer should be added. This should sync with the direct project is project is associated with direct project." }, "response": [] - } - ] - }, - { - "name": "Product Templates", - "item": [ + }, { - "name": "Create product template", - "event": [ - { - "listen": "test", - "script": { - "id": "b5aaf185-6026-4b58-b9b8-56616109cd5a", - "exec": [ - "pm.test(\"Status code is 201\", function () {", - " pm.response.to.have.status(201);", - " pm.environment.set(\"productTemplateId\", pm.response.json().id);", - "});" - ], - "type": "text/javascript" - } - } - ], + "name": "Get permissions", "request": { - "method": "POST", + "method": "GET", "header": [ - { - "key": "Content-Type", - "value": "application/json" - }, { "key": "Authorization", "value": "Bearer {{jwt-token}}" } ], - "body": { - "mode": "raw", - "raw": "{\r\n \"name\": \"name 1\",\r\n \"productKey\": \"productKey 1\",\r\n \"category\": \"{{productCategoryId}}\",\r\n \"subCategory\": \"app\",\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 }" - }, "url": { - "raw": "{{api-url}}/projects/metadata/productTemplates", + "raw": "{{api-url}}/projects/{{projectId}}/permissions", "host": [ "{{api-url}}" ], "path": [ "projects", - "metadata", - "productTemplates" + "{{projectId}}", + "permissions" ] - } + }, + "description": "List all the project with no filter. Default sort and limits are applied." }, "response": [] }, { - "name": "Create product template with form", - "event": [ - { - "listen": "test", - "script": { - "id": "d5a2af2e-97d2-415c-a533-1d52dd4003c7", - "exec": [ - "pm.test(\"Status code is 201\", function () {", - " pm.response.to.have.status(201);", - " pm.environment.set(\"productTemplateId\", pm.response.json().id);", - "});" - ], - "type": "text/javascript" - } - } - ], + "name": "Get permissions - manager", "request": { - "method": "POST", + "method": "GET", "header": [ - { - "key": "Content-Type", - "value": "application/json" - }, { "key": "Authorization", - "value": "Bearer {{jwt-token}}" + "value": "Bearer {{jwt-token-manager-40051334}}" } ], - "body": { - "mode": "raw", - "raw": "{\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 \"form\": {\r\n\t\t\"key\": \"dev\",\r\n\t\t\"version\": 1\r\n\t}\r\n }" - }, "url": { - "raw": "{{api-url}}/projects/metadata/productTemplates", + "raw": "{{api-url}}/projects/{{projectId}}/permissions", "host": [ "{{api-url}}" ], "path": [ "projects", - "metadata", - "productTemplates" + "{{projectId}}", + "permissions" ] - } + }, + "description": "List all the project with no filter. Default sort and limits are applied." }, "response": [] - }, - { - "name": "Create product template with wrong form key", + } + ], + "event": [ + { + "listen": "prerequest", + "script": { + "id": "5197d1ec-429c-4a6f-9e9c-3ec3cd6f292a", + "type": "text/javascript", + "exec": [ + "" + ] + } + }, + { + "listen": "test", + "script": { + "id": "cc0cbbf1-54d1-481f-b8fa-a6dc4c80e993", + "type": "text/javascript", + "exec": [ + "" + ] + } + } + ], + "protocolProfileBehavior": {} + }, + { + "name": "WorkManagementForTemplate", + "item": [ + { + "name": "Create workstream with valid values", + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test(\"Status code is 201\", function () {", + " pm.response.to.have.status(201);", + " pm.environment.set(\"workStreamId1\", pm.response.json().id);", + "});" + ] + } + } + ], "request": { "method": "POST", "header": [ - { - "key": "Content-Type", - "value": "application/json" - }, { "key": "Authorization", "value": "Bearer {{jwt-token}}" + }, + { + "key": "Content-Type", + "value": "application/json" } ], "body": { "mode": "raw", - "raw": "{\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 \"form\": {\r\n\t\t\"key\": \"wrong-key\"\r\n\t}\r\n }" + "raw": "{\n\t\t\"name\": \"test project\",\n\t\t\"type\": \"generic\",\n\t\t\"status\": \"active\"\n}" }, "url": { - "raw": "{{api-url}}/projects/metadata/productTemplates", + "raw": "{{api-url}}/projects/{{projectId}}/workstreams", "host": [ "{{api-url}}" ], "path": [ "projects", - "metadata", - "productTemplates" + "{{projectId}}", + "workstreams" ] - } + }, + "description": "Valid request body. Project should be created successfully." }, "response": [] }, { - "name": "Create product template with wrong model version", + "name": "Create project invite for customer with valid values", "request": { "method": "POST", "header": [ - { - "key": "Content-Type", - "value": "application/json" - }, { "key": "Authorization", "value": "Bearer {{jwt-token}}" + }, + { + "key": "Content-Type", + "value": "application/json" } ], "body": { "mode": "raw", - "raw": "{\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 \"form\": {\r\n\t\t\"key\": \"dev\",\r\n\t\t\"version\": 1123\r\n\t}\r\n }" + "raw": "{\n\t\t\"userIds\": [40051331],\n\t\t\"role\": \"customer\"\n}" }, "url": { - "raw": "{{api-url}}/projects/metadata/productTemplates", + "raw": "{{api-url}}/projects/{{projectId}}/members/invite", "host": [ "{{api-url}}" ], "path": [ "projects", - "metadata", - "productTemplates" + "{{projectId}}", + "members", + "invite" ] - } + }, + "description": "If the request payload is valid, than project customer should be added. This should sync with the direct project is project is associated with direct project." }, "response": [] }, { - "name": "List product templates", + "name": "Update project invite to accept invite", "request": { - "method": "GET", + "method": "PUT", "header": [ { - "key": "Content-Type", - "value": "application/json" + "key": "Authorization", + "value": "Bearer {{jwt-token-member-40051331}}" }, { - "key": "Authorization", - "value": "Bearer {{jwt-token}}" + "key": "Content-Type", + "value": "application/json" } ], + "body": { + "mode": "raw", + "raw": "{\n\t\t\"userId\": 40051331,\n\t\t\"status\": \"accepted\"\n}" + }, "url": { - "raw": "{{api-url}}/projects/metadata/productTemplates", + "raw": "{{api-url}}/projects/{{projectId}}/members/invite", "host": [ "{{api-url}}" ], "path": [ "projects", - "metadata", - "productTemplates" + "{{projectId}}", + "members", + "invite" ] - } + }, + "description": "If the request payload is valid, than project customer should be added. This should sync with the direct project is project is associated with direct project." }, "response": [] }, { - "name": "Get product template", + "name": "Update work stream - no match allowRule", "request": { - "method": "GET", + "method": "PATCH", "header": [ { - "key": "Content-Type", - "value": "application/json" + "key": "Authorization", + "value": "Bearer {{jwt-token-copilot-40051332}}" }, { - "key": "Authorization", - "value": "Bearer {{jwt-token}}" + "key": "Content-Type", + "value": "application/json" } ], + "body": { + "mode": "raw", + "raw": "{\n\t\t\"name\": \"work item - updated\"\n}" + }, "url": { - "raw": "{{api-url}}/projects/metadata/productTemplates/{{productTemplateId}}", + "raw": "{{api-url}}/projects/{{projectId}}/workstreams/{{workStreamId1}}", "host": [ "{{api-url}}" ], "path": [ "projects", - "metadata", - "productTemplates", - "{{productTemplateId}}" + "{{projectId}}", + "workstreams", + "{{workStreamId1}}" ] - } + }, + "description": "Update the project name. Name should be updated successfully." }, "response": [] }, { - "name": "Update product template", + "name": "Update work stream - allow access", "request": { "method": "PATCH", "header": [ { - "key": "Content-Type", - "value": "application/json" + "key": "Authorization", + "value": "Bearer {{jwt-token-connectAdmin-40051336}}" }, { - "key": "Authorization", - "value": "Bearer {{jwt-token}}" + "key": "Content-Type", + "value": "application/json" } ], "body": { "mode": "raw", - "raw": "{\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 }" + "raw": "{\n\t\t\"name\": \"work item - updated\"\n}" }, "url": { - "raw": "{{api-url}}/projects/metadata/productTemplates/{{productTemplateId}}", + "raw": "{{api-url}}/projects/{{projectId}}/workstreams/{{workStreamId1}}", "host": [ "{{api-url}}" ], "path": [ "projects", - "metadata", - "productTemplates", - "{{productTemplateId}}" + "{{projectId}}", + "workstreams", + "{{workStreamId1}}" ] - } + }, + "description": "Update the project name. Name should be updated successfully." }, "response": [] }, { - "name": "Delete product template", + "name": "Update work stream - allow access with ProjectRoles", "request": { - "method": "DELETE", + "method": "PATCH", "header": [ { - "key": "Content-Type", - "value": "application/json" + "key": "Authorization", + "value": "Bearer {{jwt-token-member-40051331}}" }, { - "key": "Authorization", - "value": "Bearer {{jwt-token}}" + "key": "Content-Type", + "value": "application/json" } ], "body": { "mode": "raw", - "raw": "" + "raw": "{\n\t\t\"name\": \"work item - updated\"\n}" }, "url": { - "raw": "{{api-url}}/projects/metadata/productTemplates/{{productTemplateId}}", + "raw": "{{api-url}}/projects/{{projectId}}/workstreams/{{workStreamId1}}", "host": [ "{{api-url}}" ], "path": [ "projects", - "metadata", - "productTemplates", - "{{productTemplateId}}" + "{{projectId}}", + "workstreams", + "{{workStreamId1}}" ] - } + }, + "description": "Update the project name. Name should be updated successfully." }, "response": [] }, { - "name": "Upgrade a product template with form", + "name": "Create workstream with valid values - Project 2", + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test(\"Status code is 201\", function () {", + " pm.response.to.have.status(201);", + " pm.environment.set(\"workStreamId2\", pm.response.json().id);", + "});" + ] + } + } + ], "request": { "method": "POST", "header": [ { - "key": "Content-Type", - "value": "application/json", - "type": "text" + "key": "Authorization", + "value": "Bearer {{jwt-token}}" }, { - "key": "Authorization", - "value": "Bearer {{jwt-token}}", - "type": "text" + "key": "Content-Type", + "value": "application/json" } ], "body": { "mode": "raw", - "raw": "{\r\n \"form\": {\r\n \t\"key\": \"dev\",\t\r\n \t\"version\": 2\r\n }\r\n }" + "raw": "{\n\t\t\"name\": \"test project\",\n\t\t\"type\": \"generic\",\n\t\t\"status\": \"active\"\n}" }, "url": { - "raw": "{{api-url}}/projects/metadata/productTemplates/{{productTemplateId}}/upgrade", + "raw": "{{api-url}}/projects/2/workstreams", "host": [ "{{api-url}}" ], "path": [ "projects", - "metadata", - "productTemplates", - "{{productTemplateId}}", - "upgrade" + "2", + "workstreams" ] - } + }, + "description": "Valid request body. Project should be created successfully." }, "response": [] }, { - "name": "Upgrade a product template with wrong model version", + "name": "Update work item - allow access", "request": { - "method": "POST", + "method": "PATCH", "header": [ { - "key": "Content-Type", - "value": "application/json", - "type": "text" + "key": "Authorization", + "value": "Bearer {{jwt-token-manager-40051334}}" }, { - "key": "Authorization", - "value": "Bearer {{jwt-token}}", - "type": "text" + "key": "Content-Type", + "value": "application/json" } ], "body": { "mode": "raw", - "raw": "{\r\n \"form\": {\r\n \t\"key\": \"dev\",\t\r\n \t\"version\": 1234\r\n }\r\n }" + "raw": "{\n\t\t\"name\": \"test project - updated\"\n}" }, "url": { - "raw": "{{api-url}}/projects/metadata/productTemplates/{{productTemplateId}}/upgrade", + "raw": "{{api-url}}/projects/2/workstreams/{{workStreamId2}}", "host": [ "{{api-url}}" ], "path": [ "projects", - "metadata", - "productTemplates", - "{{productTemplateId}}", - "upgrade" + "2", + "workstreams", + "{{workStreamId2}}" ] - } + }, + "description": "Update the project name. If user don't have permission to the project than it should return 403." }, "response": [] }, { - "name": "Upgrade a product template without define form", + "name": "Update workstream - deny access", "request": { - "method": "POST", + "method": "PATCH", "header": [ { - "key": "Content-Type", - "value": "application/json", - "type": "text" + "key": "Authorization", + "value": "Bearer {{jwt-token-copilot-40051332}}" }, { - "key": "Authorization", - "value": "Bearer {{jwt-token}}", - "type": "text" + "key": "Content-Type", + "value": "application/json" } ], "body": { "mode": "raw", - "raw": "{\r\n \r\n}" + "raw": "{\n\t\t\"name\": \"work item - updated\"\n}" }, "url": { - "raw": "{{api-url}}/projects/metadata/productTemplates/{{productTemplateId}}/upgrade", + "raw": "{{api-url}}/projects/2/workstreams/{{workStreamId2}}", "host": [ "{{api-url}}" ], "path": [ "projects", - "metadata", - "productTemplates", - "{{productTemplateId}}", - "upgrade" + "2", + "workstreams", + "{{workStreamId2}}" ] - } + }, + "description": "Update the project name. Name should be updated successfully." }, "response": [] } - ] + ], + "description": "Requests for all things projects.", + "protocolProfileBehavior": {} }, { - "name": "Project Type", + "name": "EventHandling and Integration with Direct Project API", "item": [ { - "name": "Create project type", - "event": [ - { - "listen": "test", - "script": { - "id": "fbc45946-a3f2-433a-8ec5-0af82b69d2bd", - "exec": [ - "pm.test(\"Status code is 201\", function () {", - " pm.response.to.have.status(201);", - " pm.environment.set(\"projectTypeId\", pm.response.json().key);", - "});" - ], - "type": "text/javascript" - } - } - ], + "name": "mock direct projects", "request": { - "method": "POST", + "method": "GET", "header": [ - { - "key": "Content-Type", - "value": "application/json" - }, { "key": "Authorization", "value": "Bearer {{jwt-token}}" - } - ], - "body": { - "mode": "raw", - "raw": "{\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 \t\"metadata\": {}\r\n }" - }, - "url": { - "raw": "{{api-url}}/projects/metadata/projectTypes", - "host": [ - "{{api-url}}" - ], - "path": [ - "projects", - "metadata", - "projectTypes" - ] - } - }, - "response": [] - }, - { - "name": "List project types", - "request": { - "method": "GET", - "header": [ + }, { "key": "Content-Type", "value": "application/json" - }, - { - "key": "Authorization", - "value": "Bearer {{jwt-token}}" } ], "url": { - "raw": "{{api-url}}/projects/metadata/projectTypes", + "raw": "https://api.topcoder-dev.com/v3/direct/projects", + "protocol": "https", "host": [ - "{{api-url}}" + "api", + "topcoder-dev", + "com" ], "path": [ - "projects", - "metadata", - "projectTypes" + "v3", + "direct", + "projects" ] } }, "response": [] }, { - "name": "Get project type", + "name": " Create direct project when a new project is successfully created", "request": { - "method": "GET", + "method": "POST", "header": [ - { - "key": "Content-Type", - "value": "application/json" - }, { "key": "Authorization", "value": "Bearer {{jwt-token}}" - } - ], - "url": { - "raw": "{{api-url}}/projects/metadata/projectTypes/{{projectTypeId}}", - "host": [ - "{{api-url}}" - ], - "path": [ - "projects", - "metadata", - "projectTypes", - "{{projectTypeId}}" - ] - } - }, - "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 \"displayName\": \"Chatbot-updated\"\r\n }" + "raw": "{\n \"type\": \"generic\",\n \"description\": \"test project\",\n \"details\": {},\n \"billingAccountId\": 123,\n \"name\": \"test project1\"\n }" }, "url": { - "raw": "{{api-url}}/projects/metadata/projectTypes/{{projectTypeId}}", + "raw": "{{api-url}}/projects", "host": [ "{{api-url}}" ], "path": [ - "projects", - "metadata", - "projectTypes", - "{{projectTypeId}}" + "projects" ] } }, "response": [] }, { - "name": "Delete project type", + "name": "Response error from direct project service", "request": { - "method": "DELETE", + "method": "POST", "header": [ - { - "key": "Content-Type", - "value": "application/json" - }, { "key": "Authorization", "value": "Bearer {{jwt-token}}" + }, + { + "key": "Content-Type", + "value": "application/json" } ], "body": { "mode": "raw", - "raw": "" + "raw": "{\n\n \"role\": \"copilot\"\n}" }, "url": { - "raw": "{{api-url}}/projects/metadata/projectTypes/{{projectTypeId}}", + "raw": "{{api-url}}/projects/{{projectId}}/members", "host": [ "{{api-url}}" ], "path": [ "projects", - "metadata", - "projectTypes", - "{{projectTypeId}}" + "{{projectId}}", + "members" ] } }, "response": [] - } - ] - }, - { - "name": "Product Category", - "item": [ + }, { - "name": "Create product category", - "event": [ - { - "listen": "test", - "script": { - "id": "06156797-ceb2-4f8c-9448-5c453adb7b7a", - "exec": [ - "pm.test(\"Status code is 201\", function () {", - " pm.response.to.have.status(201);", - " pm.environment.set(\"productCategoryId\", pm.response.json().key);", - "});" - ], - "type": "text/javascript" - } - } - ], + "name": " Add co-pilot when a co-pilot is added to a project", "request": { "method": "POST", "header": [ - { - "key": "Content-Type", - "value": "application/json" - }, { "key": "Authorization", "value": "Bearer {{jwt-token}}" + }, + { + "key": "Content-Type", + "value": "application/json" } ], "body": { "mode": "raw", - "raw": "{\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 }" + "raw": "{\n\t\"role\": \"copilot\"\n}" }, "url": { - "raw": "{{api-url}}/projects/metadata/productCategories", + "raw": "{{api-url}}/projects/{{projectId}}/members", "host": [ "{{api-url}}" ], "path": [ "projects", - "metadata", - "productCategories" + "{{projectId}}", + "members" ] } }, "response": [] }, { - "name": "List product categories", + "name": "remove copilot from direct project when editing project member role", "request": { - "method": "GET", + "method": "PATCH", "header": [ - { - "key": "Content-Type", - "value": "application/json" - }, { "key": "Authorization", "value": "Bearer {{jwt-token}}" - } - ], - "url": { - "raw": "{{api-url}}/projects/metadata/productCategories", - "host": [ - "{{api-url}}" - ], - "path": [ - "projects", - "metadata", - "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": " {\n \"role\": \"customer\",\n \"isPrimary\": true\n } " + }, "url": { - "raw": "{{api-url}}/projects/metadata/productCategories/{{productCategoryId}}", + "raw": "{{api-url}}/projects/{{projectId}}/members/{{memberId}}", "host": [ "{{api-url}}" ], "path": [ "projects", - "metadata", - "productCategories", - "{{productCategoryId}}" + "{{projectId}}", + "members", + "{{memberId}}" ] } }, "response": [] }, { - "name": "Update product category", + "name": " Sync billing account id with direct", "request": { "method": "PATCH", "header": [ - { - "key": "Content-Type", - "value": "application/json" - }, { "key": "Authorization", "value": "Bearer {{jwt-token}}" + }, + { + "key": "Content-Type", + "value": "application/json" } ], "body": { "mode": "raw", - "raw": "{\r\n \"displayName\": \"Chatbot-updated\"\r\n }" + "raw": "{\n \"billingAccountId\": 9999, \n \"name\": \"new project name\"\n }" }, "url": { - "raw": "{{api-url}}/projects/metadata/productCategories/{{productCategoryId}}", + "raw": "{{api-url}}/projects/{{projectId}}", "host": [ "{{api-url}}" ], "path": [ "projects", - "metadata", - "productCategories", - "{{productCategoryId}}" + "{{projectId}}" ] } }, "response": [] }, { - "name": "Delete product category", + "name": "Delete co-pilot when a co-pilot is removed from a project", "request": { "method": "DELETE", "header": [ - { - "key": "Content-Type", - "value": "application/json" - }, { "key": "Authorization", "value": "Bearer {{jwt-token}}" + }, + { + "key": "Content-Type", + "value": "application/json" } ], "body": { "mode": "raw", - "raw": "{\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 }" + "raw": "" }, "url": { - "raw": "{{api-url}}/projects/metadata/productCategories/{{productCategoryId}}", + "raw": "{{api-url}}/projects/{{projectId}}/members/{{memberId}}", "host": [ "{{api-url}}" ], "path": [ "projects", - "metadata", - "productCategories", - "{{productCategoryId}}" + "{{projectId}}", + "members", + "{{memberId}}" ] } }, "response": [] } ], - "auth": { - "type": "bearer", - "bearer": [ - { - "key": "token", - "value": "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJyb2xlcyI6WyJhZG1pbmlzdHJhdG9yIl0sImlzcyI6Imh0dHBzOi8vYXBpLnRvcGNvZGVyLWRldi5jb20iLCJoYW5kbGUiOiJwc2hhaDEiLCJleHAiOjI0NjI0OTQ2MTgsInVzZXJJZCI6IjQwMTM1OTc4IiwiaWF0IjoxNDYyNDk0MDE4LCJlbWFpbCI6InBzaGFoMUB0ZXN0LmNvbSIsImp0aSI6ImY0ZTFhNTE0LTg5ODAtNDY0MC04ZWM1LWUzNmUzMWE3ZTg0OSJ9.XuNN7tpMOXvBG1QwWRQROj7NfuUbqhkjwn39Vy4tR5I", - "type": "string" - } - ] - }, "event": [ { "listen": "prerequest", "script": { - "id": "f0092ef5-e624-4c25-87b2-b6a9e4c81ec8", + "id": "ef96ac6a-0fc0-4a64-a4fe-5390e17afe67", "type": "text/javascript", "exec": [ "" @@ -4253,25 +4175,41 @@ { "listen": "test", "script": { - "id": "9183c429-a5e0-4bf9-96a2-89f4d66e9b0d", + "id": "12f9d794-0872-4058-aafa-77b89e72025b", "type": "text/javascript", "exec": [ "" ] } } - ] + ], + "protocolProfileBehavior": {} }, { - "name": "Project upgrade", + "name": "Project Phase", "item": [ { - "name": "Migrate project", - "request": { - "method": "POST", - "header": [ - { - "key": "Authorization", + "name": "Create Phase", + "event": [ + { + "listen": "test", + "script": { + "id": "7050133a-b934-4faf-8101-d2e80b5c0710", + "exec": [ + "pm.test(\"Status code is 201\", function () {", + " pm.response.to.have.status(201);", + " pm.environment.set(\"phaseId\", pm.response.json().id);", + "});" + ], + "type": "text/javascript" + } + } + ], + "request": { + "method": "POST", + "header": [ + { + "key": "Authorization", "value": "Bearer {{jwt-token}}" }, { @@ -4281,24 +4219,39 @@ ], "body": { "mode": "raw", - "raw": "{\n\t\t\"targetVersion\": \"v3\",\n\t\t\"defaultProductTemplateId\": 3\n\t}" + "raw": "{\n\t\"name\": \"test project phase\",\n\t\"status\": \"active\",\n\t\"startDate\": \"2018-05-15T00:00:00\",\n\t\"endDate\": \"2018-05-16T00:00:00\",\n\t\"budget\": 20,\n\t\"details\": {\n\t\t\"aDetails\": \"a details\"\n\t}\n}" }, "url": { - "raw": "{{api-url}}/projects/{{projectId}}/upgrade", + "raw": "{{api-url}}/projects/{{projectId}}/phases", "host": [ "{{api-url}}" ], "path": [ "projects", "{{projectId}}", - "upgrade" + "phases" ] } }, "response": [] }, { - "name": "Migrate project (completed)", + "name": "Create Phase with order", + "event": [ + { + "listen": "test", + "script": { + "id": "2f771afe-7b4e-4260-b04d-324e880eb61b", + "exec": [ + "pm.test(\"Status code is 201\", function () {", + " pm.response.to.have.status(201);", + " pm.environment.set(\"phaseId\", pm.response.json().id);", + "});" + ], + "type": "text/javascript" + } + } + ], "request": { "method": "POST", "header": [ @@ -4313,24 +4266,39 @@ ], "body": { "mode": "raw", - "raw": "{\n\t\t\"targetVersion\": \"v3\",\n\t\t\"defaultProductTemplateId\": 3\n\t}" + "raw": "{\n\t\"name\": \"test project phase\",\n\t\"status\": \"active\",\n\t\"startDate\": \"2018-05-15T00:00:00\",\n\t\"endDate\": \"2018-05-16T00:00:00\",\n\t\"budget\": 20,\n\t\"details\": {\n\t\t\"aDetails\": \"a details\"\n\t},\n\t\"order\": 1\n}" }, "url": { - "raw": "{{api-url}}/projects/{{projectId}}/upgrade", + "raw": "{{api-url}}/projects/{{projectId}}/phases", "host": [ "{{api-url}}" ], "path": [ "projects", "{{projectId}}", - "upgrade" + "phases" ] } }, "response": [] }, { - "name": "Migrate project with phase name", + "name": "Create Phase with productTemplateId", + "event": [ + { + "listen": "test", + "script": { + "id": "8415ad98-b3f6-4330-88b6-e1830da2e4f9", + "exec": [ + "pm.test(\"Status code is 201\", function () {", + " pm.response.to.have.status(201);", + " pm.environment.set(\"phaseId\", pm.response.json().id);", + "});" + ], + "type": "text/javascript" + } + } + ], "request": { "method": "POST", "header": [ @@ -4345,26 +4313,26 @@ ], "body": { "mode": "raw", - "raw": "{\n\t\t\"targetVersion\": \"v3\",\n\t\t\"defaultProductTemplateId\": 3,\n\t\t\"phaseName\": \"Custom phase name\"\n\t}" + "raw": "{\n\t\"name\": \"test project phase\",\n\t\"status\": \"active\",\n\t\"startDate\": \"2018-05-15T00:00:00\",\n\t\"endDate\": \"2018-05-16T00:00:00\",\n\t\"budget\": 20,\n\t\"details\": {\n\t\t\"aDetails\": \"a details\"\n\t},\n\t\"order\": 1,\n\t\"productTemplateId\": {{productTemplateId}}\n}" }, "url": { - "raw": "{{api-url}}/projects/{{projectId}}/upgrade", + "raw": "{{api-url}}/projects/{{projectId}}/phases", "host": [ "{{api-url}}" ], "path": [ "projects", "{{projectId}}", - "upgrade" + "phases" ] } }, "response": [] }, { - "name": "Migrate project with phase name (completed)", + "name": "List Phase", "request": { - "method": "POST", + "method": "GET", "header": [ { "key": "Authorization", @@ -4375,186 +4343,116 @@ "value": "application/json" } ], - "body": { - "mode": "raw", - "raw": "{\n\t\t\"targetVersion\": \"v3\",\n\t\t\"defaultProductTemplateId\": 3,\n\t\t\"phaseName\": \"Custom phase name\"\n\t}" - }, "url": { - "raw": "{{api-url}}/projects/{{projectId}}/upgrade", + "raw": "{{api-url}}/projects/{{projectId}}/phases", "host": [ "{{api-url}}" ], "path": [ "projects", "{{projectId}}", - "upgrade" + "phases" ] } }, "response": [] - } - ], - "description": "Request to migrate projects." - }, - { - "name": "Timeline", - "item": [ + }, { - "name": "Create timeline", - "event": [ - { - "listen": "test", - "script": { - "id": "c066e7d4-537f-406e-a768-ec4bf73a2634", - "exec": [ - "pm.test(\"Status code is 201\", function () {", - " pm.response.to.have.status(201);", - " pm.environment.set(\"timelineId\", pm.response.json().id);", - "});" - ], - "type": "text/javascript" - } - } - ], + "name": "List Phase with fields", "request": { - "method": "POST", + "method": "GET", "header": [ - { - "key": "Content-Type", - "value": "application/json" - }, { "key": "Authorization", - "value": "Bearer {{jwt-token-connectAdmin-40051336}}" - } - ], - "body": { - "mode": "raw", - "raw": "{\r\n\t\"name\":\"new name\",\r\n\t\"description\":\"new description\",\r\n\t\"startDate\":\"2018-05-29T00:00:00.000Z\",\r\n\t\"endDate\": \"2018-05-30T00:00:00.000Z\",\r\n\t\"reference\": \"project\",\r\n\t\"referenceId\": {{projectId}}\r\n}" - }, - "url": { - "raw": "{{api-url}}/timelines", - "host": [ - "{{api-url}}" - ], - "path": [ - "timelines" - ] - } - }, - "response": [] - }, - { - "name": "Create timeline with templateId", - "event": [ - { - "listen": "test", - "script": { - "id": "ee729ed9-0072-4821-9141-3615ff66f728", - "exec": [ - "pm.test(\"Status code is 201\", function () {", - " pm.response.to.have.status(201);", - " pm.environment.set(\"timelineId\", pm.response.json().id);", - "});" - ], - "type": "text/javascript" - } - } - ], - "request": { - "method": "POST", - "header": [ + "value": "Bearer {{jwt-token}}" + }, { "key": "Content-Type", "value": "application/json" - }, - { - "key": "Authorization", - "value": "Bearer {{jwt-token-connectAdmin-40051336}}" } ], - "body": { - "mode": "raw", - "raw": "{\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 \"templateId\": 1\r\n}" - }, "url": { - "raw": "{{api-url}}/timelines", + "raw": "{{api-url}}/projects/{{projectId}}/phases?fields=status,name,budget", "host": [ "{{api-url}}" ], "path": [ - "timelines" + "projects", + "{{projectId}}", + "phases" + ], + "query": [ + { + "key": "fields", + "value": "status,name,budget" + } ] } }, "response": [] }, { - "name": "Create timeline with invalid data", + "name": "List Phase with sort", "request": { - "method": "POST", + "method": "GET", "header": [ { - "key": "Content-Type", - "value": "application/json" + "key": "Authorization", + "value": "Bearer {{jwt-token}}" }, { - "key": "Authorization", - "value": "Bearer {{jwt-token-connectAdmin-40051336}}" + "key": "Content-Type", + "value": "application/json" } ], - "body": { - "mode": "raw", - "raw": "{\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}" - }, "url": { - "raw": "{{api-url}}/timelines", + "raw": "{{api-url}}/projects/{{projectId}}/phases?sort=status desc", "host": [ "{{api-url}}" ], "path": [ - "timelines" + "projects", + "{{projectId}}", + "phases" + ], + "query": [ + { + "key": "sort", + "value": "status desc" + } ] } }, "response": [] }, { - "name": "List timelines (filter by reference and referenceId)", + "name": "List Phase with sort by order", "request": { "method": "GET", "header": [ - { - "key": "Content-Type", - "value": "application/json" - }, { "key": "Authorization", - "value": "Bearer {{jwt-token-copilot-40051332}}", - "disabled": true + "value": "Bearer {{jwt-token}}" }, { - "key": "Authorization", - "value": "Bearer {{jwt-token}}", - "type": "text" + "key": "Content-Type", + "value": "application/json" } ], "url": { - "raw": "{{api-url}}/timelines?reference=project&referenceId={{projectId}}", + "raw": "{{api-url}}/projects/{{projectId}}/phases?sort=order desc", "host": [ "{{api-url}}" ], "path": [ - "timelines" + "projects", + "{{projectId}}", + "phases" ], "query": [ { - "key": "reference", - "value": "project" - }, - { - "key": "referenceId", - "value": "{{projectId}}" + "key": "sort", + "value": "order desc" } ] } @@ -4562,137 +4460,112 @@ "response": [] }, { - "name": "Get timeline", + "name": "Get Phase", "request": { "method": "GET", "header": [ - { - "key": "Content-Type", - "value": "application/json" - }, { "key": "Authorization", "value": "Bearer {{jwt-token}}" + }, + { + "key": "Content-Type", + "value": "application/json" } ], "url": { - "raw": "{{api-url}}/timelines/{{timelineId}}", + "raw": "{{api-url}}/projects/{{projectId}}/phases/{{phaseId}}", "host": [ "{{api-url}}" ], "path": [ - "timelines", - "{{timelineId}}" + "projects", + "{{projectId}}", + "phases", + "{{phaseId}}" ] } }, "response": [] }, { - "name": "Update timeline", + "name": "Update Phase", "request": { "method": "PATCH", "header": [ - { - "key": "Content-Type", - "value": "application/json" - }, { "key": "Authorization", "value": "Bearer {{jwt-token}}" - } - ], - "body": { - "mode": "raw", - "raw": "{\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\": {{projectId}}\r\n}" - }, - "url": { - "raw": "{{api-url}}/timelines/{{timelineId}}", - "host": [ - "{{api-url}}" - ], - "path": [ - "timelines", - "{{timelineId}}" - ] - } - }, - "response": [] - }, - { - "name": "Update timeline (startDate)", - "request": { - "method": "PATCH", - "header": [ + }, { "key": "Content-Type", "value": "application/json" - }, - { - "key": "Authorization", - "value": "Bearer {{jwt-token}}" } ], "body": { "mode": "raw", - "raw": "{\r\n \"name\": \"timeline 1-updated\",\r\n \"description\": \"description-updated\",\r\n \"startDate\": \"2018-05-05T00:00:00.000Z\",\r\n \"reference\": \"project\",\r\n \"referenceId\": 1\r\n}" + "raw": "{\n\t\"name\": \"test project phase xxx\",\n\t\"status\": \"inactive\",\n\t\"startDate\": \"2018-05-14T00:00:00\",\n\t\"endDate\": \"2018-05-15T00:00:00\",\n\t\"budget\": 30,\n\t\"progress\": 15,\n\t\"details\": {\n\t\t\"message\": \"phase details\"\n\t}\n}" }, "url": { - "raw": "{{api-url}}/timelines/{{timelineId}}", + "raw": "{{api-url}}/projects/{{projectId}}/phases/{{phaseId}}", "host": [ "{{api-url}}" ], "path": [ - "timelines", - "{{timelineId}}" + "projects", + "{{projectId}}", + "phases", + "{{phaseId}}" ] } }, "response": [] }, { - "name": "Update timeline (endDate)", + "name": "Update Phase with order", "request": { "method": "PATCH", "header": [ - { - "key": "Content-Type", - "value": "application/json" - }, { "key": "Authorization", "value": "Bearer {{jwt-token}}" + }, + { + "key": "Content-Type", + "value": "application/json" } ], "body": { "mode": "raw", - "raw": "{\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}" + "raw": "{\n\t\"name\": \"test project phase xxx\",\n\t\"status\": \"inactive\",\n\t\"startDate\": \"2018-05-14T00:00:00\",\n\t\"endDate\": \"2018-05-15T00:00:00\",\n\t\"budget\": 30,\n\t\"progress\": 15,\n\t\"details\": {\n\t\t\"message\": \"phase details\"\n\t},\n\t\"order\": 1\n}" }, "url": { - "raw": "{{api-url}}/timelines/{{timelineId}}", + "raw": "{{api-url}}/projects/{{projectId}}/phases/{{phaseId}}", "host": [ "{{api-url}}" ], "path": [ - "timelines", - "{{timelineId}}" + "projects", + "{{projectId}}", + "phases", + "{{phaseId}}" ] } }, "response": [] }, { - "name": "Delete timeline", + "name": "Delete Phase", "request": { "method": "DELETE", "header": [ - { - "key": "Content-Type", - "value": "application/json" - }, { "key": "Authorization", "value": "Bearer {{jwt-token}}" + }, + { + "key": "Content-Type", + "value": "application/json" } ], "body": { @@ -4700,34 +4573,37 @@ "raw": "" }, "url": { - "raw": "{{api-url}}/timelines/{{timelineId}}", + "raw": "{{api-url}}/projects/{{projectId}}/phases/{{phaseId}}", "host": [ "{{api-url}}" ], "path": [ - "timelines", - "{{timelineId}}" + "projects", + "{{projectId}}", + "phases", + "{{phaseId}}" ] } }, "response": [] } - ] + ], + "protocolProfileBehavior": {} }, { - "name": "Milestone", + "name": "Phase Products", "item": [ { - "name": "Create milestone", + "name": "Create Phase Product", "event": [ { "listen": "test", "script": { - "id": "8fd1d5e9-8e6e-4cd7-9010-b855308be069", + "id": "77f089b3-cbe6-4fb4-b54f-2a52d138a050", "exec": [ "pm.test(\"Status code is 201\", function () {", " pm.response.to.have.status(201);", - " pm.environment.set(\"milestoneId\", pm.response.json().id);", + " pm.environment.set(\"phaseProductId\", pm.response.json().id);", "});" ], "type": "text/javascript" @@ -4738,160 +4614,178 @@ "method": "POST", "header": [ { - "key": "Content-Type", - "value": "application/json" + "key": "Authorization", + "value": "Bearer {{jwt-token}}" }, { - "key": "Authorization", - "value": "Bearer {{jwt-token-admin-40051333}}" + "key": "Content-Type", + "value": "application/json" } ], "body": { "mode": "raw", - "raw": "{\r\n \"name\": \"milestone 3\",\r\n \"description\": \"description 3\",\r\n \"duration\": 4,\r\n \"startDate\": \"2018-05-29T00:00:00.000Z\",\r\n \"endDate\": \"2018-05-30T00:00:00.000Z\",\r\n \"completionDate\": \"2018-05-31T00: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}" + "raw": "{\n\t\"name\": \"test phase product\",\n\t\"type\": \"type 1\",\n\t\"estimatedPrice\": 10\n}" }, "url": { - "raw": "{{api-url}}/timelines/{{timelineId}}/milestones", + "raw": "{{api-url}}/projects/{{projectId}}/phases/{{phaseId}}/products", "host": [ "{{api-url}}" ], "path": [ - "timelines", - "{{timelineId}}", - "milestones" + "projects", + "{{projectId}}", + "phases", + "{{phaseId}}", + "products" ] } }, "response": [] }, { - "name": "Create milestone with invalid data", + "name": "List Phase Products", "request": { - "method": "POST", + "method": "GET", "header": [ - { - "key": "Content-Type", - "value": "application/json" - }, { "key": "Authorization", - "value": "Bearer {{jwt-token-member-40051331}}" + "value": "Bearer {{jwt-token}}" } ], - "body": { - "mode": "raw", - "raw": "{\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}" - }, "url": { - "raw": "{{api-url}}/timelines/{{timelineId}}/milestones", + "raw": "{{api-url}}/projects/{{projectId}}/phases/{{phaseId}}/products", "host": [ "{{api-url}}" ], "path": [ - "timelines", - "{{timelineId}}", - "milestones" + "projects", + "{{projectId}}", + "phases", + "{{phaseId}}", + "products" ] } }, "response": [] }, { - "name": "List milestones", + "name": "Get Phase Product", "request": { "method": "GET", "header": [ - { - "key": "Content-Type", - "value": "application/json" - }, { "key": "Authorization", - "value": "Bearer {{jwt-token}}", - "type": "text" + "value": "Bearer {{jwt-token}}" } ], "url": { - "raw": "{{api-url}}/timelines/{{timelineId}}/milestones", + "raw": "{{api-url}}/projects/{{projectId}}/phases/{{phaseId}}/products/{{phaseProductId}}", "host": [ "{{api-url}}" ], "path": [ - "timelines", - "{{timelineId}}", - "milestones" + "projects", + "{{projectId}}", + "phases", + "{{phaseId}}", + "products", + "{{phaseProductId}}" ] } }, "response": [] }, { - "name": "List milestones (sort)", + "name": "Update Phase Product", "request": { - "method": "GET", + "method": "PATCH", "header": [ { - "key": "Content-Type", - "value": "application/json" + "key": "Authorization", + "value": "Bearer {{jwt-token}}" }, { - "key": "Authorization", - "value": "Bearer {{jwt-token-copilot-40051332}}" + "key": "Content-Type", + "value": "application/json" } ], + "body": { + "mode": "raw", + "raw": "{\n\t\"name\": \"test phase product xxx\",\n\t\"type\": \"type 2\",\n\t\"templateId\": 10,\n\t\"estimatedPrice\": 1.234567,\n\t\"actualPrice\": 2.34567,\n\t\"details\": {\n\t\t\"message\": \"this is a JSON type. You can use any json\"\n\t}\n}" + }, "url": { - "raw": "{{api-url}}/timelines/{{timelineId}}/milestones?sort=order desc", + "raw": "{{api-url}}/projects/{{projectId}}/phases/{{phaseId}}/products/{{phaseProductId}}", "host": [ "{{api-url}}" ], "path": [ - "timelines", - "{{timelineId}}", - "milestones" - ], - "query": [ - { - "key": "sort", - "value": "order desc" - } + "projects", + "{{projectId}}", + "phases", + "{{phaseId}}", + "products", + "{{phaseProductId}}" ] } }, "response": [] }, { - "name": "Get milestone", + "name": "Delete Phase Product", "request": { - "method": "GET", + "method": "DELETE", "header": [ - { - "key": "Content-Type", - "value": "application/json" - }, { "key": "Authorization", "value": "Bearer {{jwt-token}}" } ], + "body": { + "mode": "raw", + "raw": "" + }, "url": { - "raw": "{{api-url}}/timelines/{{timelineId}}/milestones/{{milestoneId}}", + "raw": "{{api-url}}/projects/{{projectId}}/phases/{{phaseId}}/products/{{phaseProductId}}", "host": [ "{{api-url}}" ], "path": [ - "timelines", - "{{timelineId}}", - "milestones", - "{{milestoneId}}" + "projects", + "{{projectId}}", + "phases", + "{{phaseId}}", + "products", + "{{phaseProductId}}" ] } }, "response": [] - }, + } + ], + "protocolProfileBehavior": {} + }, + { + "name": "Project Templates", + "item": [ { - "name": "Update milestone", + "name": "Create project template", + "event": [ + { + "listen": "test", + "script": { + "id": "2f79c07b-8076-4715-abf7-1d6903df444f", + "exec": [ + "pm.test(\"Status code is 201\", function () {", + " pm.response.to.have.status(201);", + " pm.environment.set(\"projectTemplateId\", pm.response.json().id);", + "});" + ], + "type": "text/javascript" + } + } + ], "request": { - "method": "PATCH", + "method": "POST", "header": [ { "key": "Content-Type", @@ -4904,60 +4798,88 @@ ], "body": { "mode": "raw", - "raw": "{\r\n \"name\": \"milestone 1-updated\",\r\n \"description\": \"description-updated\",\r\n \"duration\": 3,\r\n \"completionDate\": \"2018-09-28T00:00:00.000Z\",\r\n \"status\": \"closed\",\r\n \"type\": \"type2\",\r\n \"details\": {\r\n \"detail1\": {\r\n \"subDetail1C\": 3\r\n },\r\n \"detail2\": [\r\n 4\r\n ]\r\n },\r\n \"order\": 1,\r\n \"plannedText\": \"plannedText 1-updated\",\r\n \"activeText\": \"activeText 1-updated\",\r\n \"completedText\": \"completedText 1-updated\",\r\n \"blockedText\": \"blockedText 1-updated\"\r\n}" + "raw": "{\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 }" }, "url": { - "raw": "{{api-url}}/timelines/{{timelineId}}/milestones/{{milestoneId}}", + "raw": "{{api-url}}/projects/metadata/projectTemplates", "host": [ "{{api-url}}" ], "path": [ - "timelines", - "{{timelineId}}", - "milestones", - "{{milestoneId}}" + "projects", + "metadata", + "projectTemplates" ] } }, "response": [] }, { - "name": "Update milestone (active)", - "request": { - "method": "PATCH", - "header": [ - { - "key": "Content-Type", - "value": "application/json" - }, - { - "key": "Authorization", - "value": "Bearer {{jwt-token}}" - } + "name": "Create project template with form, priceConfig, planConfig", + "event": [ + { + "listen": "test", + "script": { + "id": "4c442ea3-0834-4a30-8044-a4e94fd4ea2d", + "exec": [ + "pm.test(\"Status code is 201\", function () {", + " pm.response.to.have.status(201);", + " pm.environment.set(\"projectTemplateId\", pm.response.json().id);", + "});" + ], + "type": "text/javascript" + } + } + ], + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + }, + { + "key": "Authorization", + "value": "Bearer {{jwt-token}}" + } ], "body": { "mode": "raw", - "raw": "{\r\n \"name\": \"milestone 2-updated\",\r\n \"description\": \"description-updated\",\r\n \"duration\": 3,\r\n \"completionDate\": \"2018-10-28T00:00:00.000Z\",\r\n \"status\": \"active\",\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}" + "raw": "{\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 \"form\": {\r\n \t\"key\": \"dev\",\r\n \t\"version\": 1\r\n },\r\n \"priceConfig\": {\r\n \t\"key\": \"dev\",\r\n \t\"version\": 1\r\n },\r\n \"planConfig\": {\r\n \t\"key\": \"dev\",\r\n \t\"version\": 1\r\n }\r\n }" }, "url": { - "raw": "{{api-url}}/timelines/{{timelineId}}/milestones/{{milestoneId}}", + "raw": "{{api-url}}/projects/metadata/projectTemplates", "host": [ "{{api-url}}" ], "path": [ - "timelines", - "{{timelineId}}", - "milestones", - "{{milestoneId}}" + "projects", + "metadata", + "projectTemplates" ] } }, "response": [] }, { - "name": "Update milestone (completed)", + "name": "Create project template with only form key", + "event": [ + { + "listen": "test", + "script": { + "id": "7d0ae3ca-fe2d-40eb-b5c8-9b03955babec", + "exec": [ + "pm.test(\"Status code is 201\", function () {", + " pm.response.to.have.status(201);", + " pm.environment.set(\"projectTemplateId\", pm.response.json().id);", + "});" + ], + "type": "text/javascript" + } + } + ], "request": { - "method": "PATCH", + "method": "POST", "header": [ { "key": "Content-Type", @@ -4970,27 +4892,26 @@ ], "body": { "mode": "raw", - "raw": "{\r\n \"name\": \"milestone 2-updated\",\r\n \"description\": \"description-updated\",\r\n \"duration\": 3,\r\n \"completionDate\": \"2018-10-28T00:00:00.000Z\",\r\n \"status\": \"completed\",\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}" + "raw": "{\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 \"form\": {\r\n \t\"key\": \"dev\"\r\n }\r\n }" }, "url": { - "raw": "{{api-url}}/timelines/{{timelineId}}/milestones/{{milestoneId}}", + "raw": "{{api-url}}/projects/metadata/projectTemplates", "host": [ "{{api-url}}" ], "path": [ - "timelines", - "{{timelineId}}", - "milestones", - "{{milestoneId}}" + "projects", + "metadata", + "projectTemplates" ] } }, "response": [] }, { - "name": "Update milestone (order 1 => 2)", + "name": "Create project template with wrong form key", "request": { - "method": "PATCH", + "method": "POST", "header": [ { "key": "Content-Type", @@ -5003,27 +4924,26 @@ ], "body": { "mode": "raw", - "raw": "{\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}" + "raw": "{\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 \"form\": {\r\n \t\"key\": \"wrong-key\"\r\n }\r\n }" }, "url": { - "raw": "{{api-url}}/timelines/{{timelineId}}/milestones/{{milestoneId}}", + "raw": "{{api-url}}/projects/metadata/projectTemplates", "host": [ "{{api-url}}" ], "path": [ - "timelines", - "{{timelineId}}", - "milestones", - "{{milestoneId}}" + "projects", + "metadata", + "projectTemplates" ] } }, "response": [] }, { - "name": "Update milestone (order 2 => 1)", + "name": "Create project template with wrong model version", "request": { - "method": "PATCH", + "method": "POST", "header": [ { "key": "Content-Type", @@ -5036,27 +4956,26 @@ ], "body": { "mode": "raw", - "raw": "{\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}" + "raw": "{\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 \"form\": {\r\n \t\"key\": \"dev\",\r\n \t\"version\": 1123\r\n },\r\n \"priceConfig\": {\r\n \t\"key\": \"dev\",\r\n \t\"version\": 1123\r\n },\r\n \"planConfig\": {\r\n \t\"key\": \"dev\",\r\n \t\"version\": 1123\r\n }\r\n }" }, "url": { - "raw": "{{api-url}}/timelines/{{timelineId}}/milestones/{{milestoneId}}", + "raw": "{{api-url}}/projects/metadata/projectTemplates", "host": [ "{{api-url}}" ], "path": [ - "timelines", - "{{timelineId}}", - "milestones", - "{{milestoneId}}" + "projects", + "metadata", + "projectTemplates" ] } }, "response": [] }, { - "name": "Update milestone (order 1 => 3)", + "name": "List project templates", "request": { - "method": "PATCH", + "method": "GET", "header": [ { "key": "Content-Type", @@ -5067,29 +4986,24 @@ "value": "Bearer {{jwt-token}}" } ], - "body": { - "mode": "raw", - "raw": "{\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}" - }, "url": { - "raw": "{{api-url}}/timelines/{{timelineId}}/milestones/{{milestoneId}}", + "raw": "{{api-url}}/projects/metadata/projectTemplates", "host": [ "{{api-url}}" ], "path": [ - "timelines", - "{{timelineId}}", - "milestones", - "{{milestoneId}}" + "projects", + "metadata", + "projectTemplates" ] } }, "response": [] }, { - "name": "Update milestone (order 3 => 1)", + "name": "Get project template", "request": { - "method": "PATCH", + "method": "GET", "header": [ { "key": "Content-Type", @@ -5100,29 +5014,25 @@ "value": "Bearer {{jwt-token}}" } ], - "body": { - "mode": "raw", - "raw": "{\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}" - }, "url": { - "raw": "{{api-url}}/timelines/{{timelineId}}/milestones/{{milestoneId}}", + "raw": "{{api-url}}/projects/metadata/projectTemplates/{{projectTemplateId}}", "host": [ "{{api-url}}" ], "path": [ - "timelines", - "{{timelineId}}", - "milestones", - "{{milestoneId}}" + "projects", + "metadata", + "projectTemplates", + "{{projectTemplateId}}" ] } }, "response": [] }, { - "name": "Delete milestone", + "name": "Upgrade a project template with from, priceConfig, planConfig", "request": { - "method": "DELETE", + "method": "POST", "header": [ { "key": "Content-Type", @@ -5135,45 +5045,26 @@ ], "body": { "mode": "raw", - "raw": "" + "raw": "{\r\n \"form\": {\r\n \t\"key\": \"dev\",\t\r\n \t \"version\": 2\r\n },\r\n \"priceConfig\": {\r\n \t\"key\": \"dev\",\t\r\n \t \"version\": 1\r\n },\r\n \"planConfig\": {\r\n \t\"key\": \"qa\",\t\r\n \t \"version\": 2\t\r\n }\r\n }" }, "url": { - "raw": "{{api-url}}/timelines/{{timelineId}}/milestones/{{milestoneId}}", + "raw": "{{api-url}}/projects/metadata/projectTemplates/{{projectTemplateId}}/upgrade", "host": [ "{{api-url}}" ], "path": [ - "timelines", - "{{timelineId}}", - "milestones", - "{{milestoneId}}" + "projects", + "metadata", + "projectTemplates", + "{{projectTemplateId}}", + "upgrade" ] } }, "response": [] - } - ] - }, - { - "name": "Milestone Template", - "item": [ + }, { - "name": "Create milestone template", - "event": [ - { - "listen": "test", - "script": { - "id": "3dbf8b29-2498-4b05-93de-14d809ccc285", - "exec": [ - "pm.test(\"Status code is 201\", function () {", - " pm.response.to.have.status(201);", - " pm.environment.set(\"milestoneTemplateId\", pm.response.json().id);", - "});" - ], - "type": "text/javascript" - } - } - ], + "name": "Upgrade a project template with wrong model version", "request": { "method": "POST", "header": [ @@ -5183,29 +5074,31 @@ }, { "key": "Authorization", - "value": "Bearer {{jwt-token-admin-40051333}}" + "value": "Bearer {{jwt-token}}" } ], "body": { "mode": "raw", - "raw": "{\r\n \"name\": \"milestoneTemplate 1\",\r\n \"description\": \"description 1\",\r\n \"duration\": 11,\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\t\"reference\": \"productTemplate\",\r\n\t\"referenceId\": {{productTemplateId}},\r\n\t\"metadata\": {}\r\n}" + "raw": "{\r\n \"form\": {\r\n \t\"key\": \"dev\",\t\r\n \t \"version\": 1234\r\n },\r\n \"priceConfig\": {\r\n \t\"key\": \"dev\",\t\r\n \t \"version\": 1234\r\n },\r\n \"planConfig\": {\r\n \t\"key\": \"dev\",\t\r\n \t \"version\": 1234\r\n }\r\n }" }, "url": { - "raw": "{{api-url}}/timelines/metadata/milestoneTemplates", + "raw": "{{api-url}}/projects/metadata/projectTemplates/{{projectTemplateId}}/upgrade", "host": [ "{{api-url}}" ], "path": [ - "timelines", + "projects", "metadata", - "milestoneTemplates" + "projectTemplates", + "{{projectTemplateId}}", + "upgrade" ] } }, "response": [] }, { - "name": "Create milestone template with invalid referenceId", + "name": "Upgrade a project template without define form, priceConfig, planConfig", "request": { "method": "POST", "header": [ @@ -5215,31 +5108,33 @@ }, { "key": "Authorization", - "value": "Bearer {{jwt-token-admin-40051333}}" + "value": "Bearer {{jwt-token}}" } ], "body": { "mode": "raw", - "raw": "{\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\t\"reference\": \"productTemplate\",\r\n\t\"referenceId\": 1000,\r\n\t\"metadata\": {}\r\n}" + "raw": "{\r\n}" }, "url": { - "raw": "{{api-url}}/timelines/metadata/milestoneTemplates", + "raw": "{{api-url}}/projects/metadata/projectTemplates/{{projectTemplateId}}/upgrade", "host": [ "{{api-url}}" ], "path": [ - "timelines", + "projects", "metadata", - "milestoneTemplates" + "projectTemplates", + "{{projectTemplateId}}", + "upgrade" ] } }, "response": [] }, { - "name": "Create milestone template with invalid data", + "name": "Update project template", "request": { - "method": "POST", + "method": "PATCH", "header": [ { "key": "Content-Type", @@ -5247,31 +5142,32 @@ }, { "key": "Authorization", - "value": "Bearer {{jwt-token-admin-40051333}}" + "value": "Bearer {{jwt-token}}" } ], "body": { "mode": "raw", - "raw": "{\r\n}" + "raw": "{\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 }" }, "url": { - "raw": "{{api-url}}/timelines/metadata/milestoneTemplates", + "raw": "{{api-url}}/projects/metadata/projectTemplates/{{projectTemplateId}}", "host": [ "{{api-url}}" ], "path": [ - "timelines", + "projects", "metadata", - "milestoneTemplates" + "projectTemplates", + "{{projectTemplateId}}" ] } }, "response": [] }, { - "name": "Clone milestone template", + "name": "Update project template with define form, priceConfig, planConfig", "request": { - "method": "POST", + "method": "PATCH", "header": [ { "key": "Content-Type", @@ -5279,32 +5175,32 @@ }, { "key": "Authorization", - "value": "Bearer {{jwt-token-admin-40051333}}" + "value": "Bearer {{jwt-token}}" } ], "body": { "mode": "raw", - "raw": "{\r\n \"sourceReference\": \"productTemplate\",\r\n \"sourceReferenceId\": 1,\r\n \"reference\": \"productTemplate\",\r\n \"referenceId\": 2\r\n}" + "raw": "{\r\n \"name\":\"new name\",\r\n \"key\":\"new key\",\r\n \"category\":\"app\",\r\n \"form\": {\r\n \"key\": \"dev\",\r\n \"version\": 1\r\n },\r\n \"priceConfig\": {\r\n \"key\": \"dev\",\r\n \"version\": 1\r\n },\r\n \"planConfig\": {\r\n \"key\": \"dev\",\r\n \"version\": 1\r\n }\r\n }" }, "url": { - "raw": "{{api-url}}/timelines/metadata/milestoneTemplates/clone", + "raw": "{{api-url}}/projects/metadata/projectTemplates/{{projectTemplateId}}", "host": [ "{{api-url}}" ], "path": [ - "timelines", + "projects", "metadata", - "milestoneTemplates", - "clone" + "projectTemplates", + "{{projectTemplateId}}" ] } }, "response": [] }, { - "name": "Clone milestone template with invalid referenceId", + "name": "Update project template with wrong form, priceConfig, planConfig", "request": { - "method": "POST", + "method": "PATCH", "header": [ { "key": "Content-Type", @@ -5312,32 +5208,32 @@ }, { "key": "Authorization", - "value": "Bearer {{jwt-token-admin-40051333}}" + "value": "Bearer {{jwt-token}}" } ], "body": { "mode": "raw", - "raw": "{\r\n \"sourceReference\": \"productTemplate\",\r\n \"sourceReferenceId\": 1,\r\n \"reference\": \"productTemplate\",\r\n \"referenceId\": 2000\r\n}" + "raw": "{\r\n \"name\":\"new name\",\r\n \"key\":\"new key\",\r\n \"category\":\"app\",\r\n\t\"form\": {\r\n \"key\": \"dev\",\r\n \"version\": 1123\r\n },\r\n \"priceConfig\": {\r\n \"key\": \"dev\",\r\n \"version\": 1123\r\n },\r\n \"planConfig\": {\r\n \"key\": \"dev\",\r\n \"version\": 1123\r\n }\r\n }" }, "url": { - "raw": "{{api-url}}/timelines/metadata/milestoneTemplates/clone", + "raw": "{{api-url}}/projects/metadata/projectTemplates/{{projectTemplateId}}", "host": [ "{{api-url}}" ], "path": [ - "timelines", + "projects", "metadata", - "milestoneTemplates", - "clone" + "projectTemplates", + "{{projectTemplateId}}" ] } }, "response": [] }, { - "name": "Clone milestone template with invalid sourceReferenceId", + "name": "Delete project template", "request": { - "method": "POST", + "method": "DELETE", "header": [ { "key": "Content-Type", @@ -5345,32 +5241,53 @@ }, { "key": "Authorization", - "value": "Bearer {{jwt-token-admin-40051333}}" + "value": "Bearer {{jwt-token}}" } ], "body": { "mode": "raw", - "raw": "{\r\n \"sourceReference\": \"productTemplate\",\r\n \"sourceReferenceId\": 1000,\r\n \"reference\": \"productTemplate\",\r\n \"referenceId\": 2\r\n}" + "raw": "{\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 }" }, "url": { - "raw": "{{api-url}}/timelines/metadata/milestoneTemplates/clone", + "raw": "{{api-url}}/projects/metadata/projectTemplates/{{projectTemplateId}}", "host": [ "{{api-url}}" ], "path": [ - "timelines", + "projects", "metadata", - "milestoneTemplates", - "clone" + "projectTemplates", + "{{projectTemplateId}}" ] } }, "response": [] - }, + } + ], + "protocolProfileBehavior": {} + }, + { + "name": "Product Templates", + "item": [ { - "name": "List milestone templates", + "name": "Create product template", + "event": [ + { + "listen": "test", + "script": { + "id": "b5aaf185-6026-4b58-b9b8-56616109cd5a", + "exec": [ + "pm.test(\"Status code is 201\", function () {", + " pm.response.to.have.status(201);", + " pm.environment.set(\"productTemplateId\", pm.response.json().id);", + "});" + ], + "type": "text/javascript" + } + } + ], "request": { - "method": "GET", + "method": "POST", "header": [ { "key": "Content-Type", @@ -5378,27 +5295,46 @@ }, { "key": "Authorization", - "value": "Bearer {{jwt-token-copilot-40051332}}" + "value": "Bearer {{jwt-token}}" } ], + "body": { + "mode": "raw", + "raw": "{\r\n \"name\": \"name 1\",\r\n \"productKey\": \"productKey 1\",\r\n \"category\": \"{{productCategoryId}}\",\r\n \"subCategory\": \"app\",\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 }" + }, "url": { - "raw": "{{api-url}}/timelines/metadata/milestoneTemplates", + "raw": "{{api-url}}/projects/metadata/productTemplates", "host": [ "{{api-url}}" ], "path": [ - "timelines", + "projects", "metadata", - "milestoneTemplates" + "productTemplates" ] } }, "response": [] }, { - "name": "List milestone templates (filter)", + "name": "Create product template with form", + "event": [ + { + "listen": "test", + "script": { + "id": "d5a2af2e-97d2-415c-a533-1d52dd4003c7", + "exec": [ + "pm.test(\"Status code is 201\", function () {", + " pm.response.to.have.status(201);", + " pm.environment.set(\"productTemplateId\", pm.response.json().id);", + "});" + ], + "type": "text/javascript" + } + } + ], "request": { - "method": "GET", + "method": "POST", "header": [ { "key": "Content-Type", @@ -5406,37 +5342,31 @@ }, { "key": "Authorization", - "value": "Bearer {{jwt-token-copilot-40051332}}" + "value": "Bearer {{jwt-token}}" } ], + "body": { + "mode": "raw", + "raw": "{\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 \"form\": {\r\n\t\t\"key\": \"dev\",\r\n\t\t\"version\": 1\r\n\t}\r\n }" + }, "url": { - "raw": "{{api-url}}/timelines/metadata/milestoneTemplates?reference=productTemplate&referenceId={{productTemplateId}}", + "raw": "{{api-url}}/projects/metadata/productTemplates", "host": [ "{{api-url}}" ], "path": [ - "timelines", + "projects", "metadata", - "milestoneTemplates" - ], - "query": [ - { - "key": "reference", - "value": "productTemplate" - }, - { - "key": "referenceId", - "value": "{{productTemplateId}}" - } + "productTemplates" ] } }, "response": [] }, { - "name": "List milestone templates (sort)", + "name": "Create product template with wrong form key", "request": { - "method": "GET", + "method": "POST", "header": [ { "key": "Content-Type", @@ -5444,41 +5374,31 @@ }, { "key": "Authorization", - "value": "Bearer {{jwt-token-copilot-40051332}}" + "value": "Bearer {{jwt-token}}" } ], + "body": { + "mode": "raw", + "raw": "{\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 \"form\": {\r\n\t\t\"key\": \"wrong-key\"\r\n\t}\r\n }" + }, "url": { - "raw": "{{api-url}}/timelines/metadata/milestoneTemplates?reference=productTemplate&referenceId={{productTemplateId}}&sort=order desc", + "raw": "{{api-url}}/projects/metadata/productTemplates", "host": [ "{{api-url}}" ], "path": [ - "timelines", + "projects", "metadata", - "milestoneTemplates" - ], - "query": [ - { - "key": "reference", - "value": "productTemplate" - }, - { - "key": "referenceId", - "value": "{{productTemplateId}}" - }, - { - "key": "sort", - "value": "order desc" - } + "productTemplates" ] } }, "response": [] }, { - "name": "Get milestone template", + "name": "Create product template with wrong model version", "request": { - "method": "GET", + "method": "POST", "header": [ { "key": "Content-Type", @@ -5489,25 +5409,28 @@ "value": "Bearer {{jwt-token}}" } ], + "body": { + "mode": "raw", + "raw": "{\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 \"form\": {\r\n\t\t\"key\": \"dev\",\r\n\t\t\"version\": 1123\r\n\t}\r\n }" + }, "url": { - "raw": "{{api-url}}/timelines/metadata/milestoneTemplates/{{milestoneTemplateId}}", + "raw": "{{api-url}}/projects/metadata/productTemplates", "host": [ "{{api-url}}" ], "path": [ - "timelines", + "projects", "metadata", - "milestoneTemplates", - "{{milestoneTemplateId}}" + "productTemplates" ] } }, "response": [] }, { - "name": "Update milestone", + "name": "List product templates", "request": { - "method": "PATCH", + "method": "GET", "header": [ { "key": "Content-Type", @@ -5518,29 +5441,24 @@ "value": "Bearer {{jwt-token}}" } ], - "body": { - "mode": "raw", - "raw": "{\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\t\"reference\": \"productTemplate\",\r\n\t\"referenceId\": 1\r\n}" - }, "url": { - "raw": "{{api-url}}/timelines/metadata/milestoneTemplates/{{milestoneTemplateId}}", + "raw": "{{api-url}}/projects/metadata/productTemplates", "host": [ "{{api-url}}" ], "path": [ - "timelines", + "projects", "metadata", - "milestoneTemplates", - "{{milestoneTemplateId}}" + "productTemplates" ] } }, "response": [] }, { - "name": "Update milestone (order 1 => 2)", + "name": "Get product template", "request": { - "method": "PATCH", + "method": "GET", "header": [ { "key": "Content-Type", @@ -5551,27 +5469,23 @@ "value": "Bearer {{jwt-token}}" } ], - "body": { - "mode": "raw", - "raw": "{\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 \"reference\": \"productTemplate\",\r\n \"referenceId\": {{productTemplateId}}\r\n}" - }, "url": { - "raw": "{{api-url}}/timelines/metadata/milestoneTemplates/{{milestoneTemplateId}}", + "raw": "{{api-url}}/projects/metadata/productTemplates/{{productTemplateId}}", "host": [ "{{api-url}}" ], "path": [ - "timelines", + "projects", "metadata", - "milestoneTemplates", - "{{milestoneTemplateId}}" + "productTemplates", + "{{productTemplateId}}" ] } }, "response": [] }, { - "name": "Update milestone (order 2 => 1)", + "name": "Update product template", "request": { "method": "PATCH", "header": [ @@ -5586,27 +5500,27 @@ ], "body": { "mode": "raw", - "raw": "{\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\t\"reference\": \"productTemplate\",\r\n\t\"referenceId\": 1\r\n}" + "raw": "{\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 }" }, "url": { - "raw": "{{api-url}}/timelines/metadata/milestoneTemplates/{{milestoneTemplateId}}", + "raw": "{{api-url}}/projects/metadata/productTemplates/{{productTemplateId}}", "host": [ "{{api-url}}" ], "path": [ - "timelines", + "projects", "metadata", - "milestoneTemplates", - "{{milestoneTemplateId}}" + "productTemplates", + "{{productTemplateId}}" ] } }, "response": [] }, { - "name": "Update milestone (order 1 => 3)", + "name": "Delete product template", "request": { - "method": "PATCH", + "method": "DELETE", "header": [ { "key": "Content-Type", @@ -5619,305 +5533,324 @@ ], "body": { "mode": "raw", - "raw": "{\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\t\"reference\": \"productTemplate\",\r\n\t\"referenceId\": 1\r\n}" + "raw": "" }, "url": { - "raw": "{{api-url}}/timelines/metadata/milestoneTemplates/{{milestoneTemplateId}}", + "raw": "{{api-url}}/projects/metadata/productTemplates/{{productTemplateId}}", "host": [ "{{api-url}}" ], "path": [ - "timelines", + "projects", "metadata", - "milestoneTemplates", - "{{milestoneTemplateId}}" + "productTemplates", + "{{productTemplateId}}" ] } }, "response": [] }, { - "name": "Update milestone (order 3 => 1)", + "name": "Upgrade a product template with form", "request": { - "method": "PATCH", + "method": "POST", "header": [ { "key": "Content-Type", - "value": "application/json" + "value": "application/json", + "type": "text" }, { "key": "Authorization", - "value": "Bearer {{jwt-token}}" + "value": "Bearer {{jwt-token}}", + "type": "text" } ], "body": { "mode": "raw", - "raw": "{\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\t\"reference\": \"productTemplate\",\r\n\t\"referenceId\": 1\r\n}" + "raw": "{\r\n \"form\": {\r\n \t\"key\": \"dev\",\t\r\n \t\"version\": 2\r\n }\r\n }" }, "url": { - "raw": "{{api-url}}/timelines/metadata/milestoneTemplates/{{milestoneTemplateId}}", + "raw": "{{api-url}}/projects/metadata/productTemplates/{{productTemplateId}}/upgrade", "host": [ "{{api-url}}" ], "path": [ - "timelines", + "projects", "metadata", - "milestoneTemplates", - "{{milestoneTemplateId}}" + "productTemplates", + "{{productTemplateId}}", + "upgrade" ] } }, "response": [] }, { - "name": "Update milestone with metadata", + "name": "Upgrade a product template with wrong model version", "request": { - "method": "PATCH", + "method": "POST", "header": [ { "key": "Content-Type", - "value": "application/json" + "value": "application/json", + "type": "text" }, { "key": "Authorization", - "value": "Bearer {{jwt-token}}" + "value": "Bearer {{jwt-token}}", + "type": "text" } ], "body": { "mode": "raw", - "raw": "{\r\n\t\"name\": \"milestoneTemplate 5-updated\",\r\n\t\"description\": \"description 5-updated\",\r\n\t\"duration\": 34,\r\n\t\"type\": \"type5-updated\",\r\n\t\"order\": 5,\r\n\t\"reference\": \"productTemplate\",\r\n\t\"referenceId\": 1,\r\n\t\"metadata\": {\r\n \"metadata1\": {\r\n \"name\": \"metadata 1 - update\",\r\n \"details\": {\r\n \"anyDetails\": \"any details 1 - update\",\r\n \"newDetails\": \"new\"\r\n },\r\n \"others\": [\"others new\"]\r\n },\r\n \"metadata3\": {\r\n \"name\": \"metadata 3\",\r\n \"details\": {\r\n \"anyDetails\": \"any details 3\"\r\n },\r\n \"others\": [\"others 31\", \"others 32\"]\r\n }\r\n }\r\n}" + "raw": "{\r\n \"form\": {\r\n \t\"key\": \"dev\",\t\r\n \t\"version\": 1234\r\n }\r\n }" }, "url": { - "raw": "{{api-url}}/timelines/metadata/milestoneTemplates/{{milestoneTemplateId}}", + "raw": "{{api-url}}/projects/metadata/productTemplates/{{productTemplateId}}/upgrade", "host": [ "{{api-url}}" ], "path": [ - "timelines", + "projects", "metadata", - "milestoneTemplates", - "{{milestoneTemplateId}}" + "productTemplates", + "{{productTemplateId}}", + "upgrade" ] } }, "response": [] }, { - "name": "Delete milestone", + "name": "Upgrade a product template without define form", "request": { - "method": "DELETE", + "method": "POST", "header": [ { "key": "Content-Type", - "value": "application/json" + "value": "application/json", + "type": "text" }, { "key": "Authorization", - "value": "Bearer {{jwt-token}}" + "value": "Bearer {{jwt-token}}", + "type": "text" } ], "body": { "mode": "raw", - "raw": "" + "raw": "{\r\n \r\n}" }, "url": { - "raw": "{{api-url}}/timelines/metadata/milestoneTemplates/{{milestoneTemplateId}}", + "raw": "{{api-url}}/projects/metadata/productTemplates/{{productTemplateId}}/upgrade", "host": [ "{{api-url}}" ], "path": [ - "timelines", + "projects", "metadata", - "milestoneTemplates", - "{{milestoneTemplateId}}" + "productTemplates", + "{{productTemplateId}}", + "upgrade" ] } }, "response": [] } - ] + ], + "protocolProfileBehavior": {} }, { - "name": "Metadata", + "name": "Project Type", "item": [ { - "name": "Get all metadata", - "request": { - "auth": { - "type": "bearer", - "bearer": [ - { - "key": "token", - "value": "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJyb2xlcyI6WyJUb3Bjb2RlciBVc2VyIiwiYWRtaW5pc3RyYXRvciJdLCJpc3MiOiJodHRwczovL2FwaS50b3Bjb2Rlci1kZXYuY29tIiwiaGFuZGxlIjoidGVzdDEiLCJleHAiOjI1NjMwNzY2ODksInVzZXJJZCI6IjQwMDUxMzMzIiwiaWF0IjoxNDYzMDc2MDg5LCJlbWFpbCI6InRlc3RAdG9wY29kZXIuY29tIiwianRpIjoiYjMzYjc3Y2QtYjUyZS00MGZlLTgzN2UtYmViOGUwYWU2YTRhIn0.wKWUe0-SaiFVN-VR_-GwgFlvWaDkSbc8H55ktb9LAVw", - "type": "string" - } - ] - }, - "method": "GET", + "name": "Create project type", + "event": [ + { + "listen": "test", + "script": { + "id": "fbc45946-a3f2-433a-8ec5-0af82b69d2bd", + "exec": [ + "pm.test(\"Status code is 201\", function () {", + " pm.response.to.have.status(201);", + " pm.environment.set(\"projectTypeId\", pm.response.json().key);", + "});" + ], + "type": "text/javascript" + } + } + ], + "request": { + "method": "POST", "header": [ { - "key": "", - "value": "", - "type": "text" + "key": "Content-Type", + "value": "application/json" + }, + { + "key": "Authorization", + "value": "Bearer {{jwt-token}}" } ], + "body": { + "mode": "raw", + "raw": "{\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 \t\"metadata\": {}\r\n }" + }, "url": { - "raw": "{{api-url}}/projects/metadata", + "raw": "{{api-url}}/projects/metadata/projectTypes", "host": [ "{{api-url}}" ], "path": [ "projects", - "metadata" + "metadata", + "projectTypes" ] } }, "response": [] }, { - "name": "Get all metadata with includeAllVersion", + "name": "List project types", "request": { - "auth": { - "type": "bearer", - "bearer": [ - { - "key": "token", - "value": "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJyb2xlcyI6WyJUb3Bjb2RlciBVc2VyIiwiYWRtaW5pc3RyYXRvciJdLCJpc3MiOiJodHRwczovL2FwaS50b3Bjb2Rlci1kZXYuY29tIiwiaGFuZGxlIjoidGVzdDEiLCJleHAiOjI1NjMwNzY2ODksInVzZXJJZCI6IjQwMDUxMzMzIiwiaWF0IjoxNDYzMDc2MDg5LCJlbWFpbCI6InRlc3RAdG9wY29kZXIuY29tIiwianRpIjoiYjMzYjc3Y2QtYjUyZS00MGZlLTgzN2UtYmViOGUwYWU2YTRhIn0.wKWUe0-SaiFVN-VR_-GwgFlvWaDkSbc8H55ktb9LAVw", - "type": "string" - } - ] - }, "method": "GET", - "header": [], + "header": [ + { + "key": "Content-Type", + "value": "application/json" + }, + { + "key": "Authorization", + "value": "Bearer {{jwt-token}}" + } + ], "url": { - "raw": "{{api-url}}/projects/metadata?includeAllReferred=true", + "raw": "{{api-url}}/projects/metadata/projectTypes", "host": [ "{{api-url}}" ], "path": [ "projects", - "metadata" - ], - "query": [ - { - "key": "includeAllReferred", - "value": "true" - } + "metadata", + "projectTypes" ] } }, "response": [] - } - ] - }, - { - "name": "Form Version", - "item": [ + }, { - "name": "List forms", + "name": "Get project type", "request": { - "auth": { - "type": "bearer", - "bearer": [ - { - "key": "token", - "value": "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJyb2xlcyI6WyJUb3Bjb2RlciBVc2VyIiwiYWRtaW5pc3RyYXRvciJdLCJpc3MiOiJodHRwczovL2FwaS50b3Bjb2Rlci1kZXYuY29tIiwiaGFuZGxlIjoidGVzdDEiLCJleHAiOjI1NjMwNzY2ODksInVzZXJJZCI6IjQwMDUxMzMzIiwiaWF0IjoxNDYzMDc2MDg5LCJlbWFpbCI6InRlc3RAdG9wY29kZXIuY29tIiwianRpIjoiYjMzYjc3Y2QtYjUyZS00MGZlLTgzN2UtYmViOGUwYWU2YTRhIn0.wKWUe0-SaiFVN-VR_-GwgFlvWaDkSbc8H55ktb9LAVw", - "type": "string" - } - ] - }, "method": "GET", - "header": [], + "header": [ + { + "key": "Content-Type", + "value": "application/json" + }, + { + "key": "Authorization", + "value": "Bearer {{jwt-token}}" + } + ], "url": { - "raw": "{{api-url}}/projects/metadata/form/{{formKey}}/versions", + "raw": "{{api-url}}/projects/metadata/projectTypes/{{projectTypeId}}", "host": [ "{{api-url}}" ], "path": [ "projects", "metadata", - "form", - "{{formKey}}", - "versions" + "projectTypes", + "{{projectTypeId}}" ] } }, "response": [] }, { - "name": "Get a particular version", + "name": "Update project type", "request": { - "auth": { - "type": "bearer", - "bearer": [ - { - "key": "token", - "value": "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJyb2xlcyI6WyJUb3Bjb2RlciBVc2VyIiwiYWRtaW5pc3RyYXRvciJdLCJpc3MiOiJodHRwczovL2FwaS50b3Bjb2Rlci1kZXYuY29tIiwiaGFuZGxlIjoidGVzdDEiLCJleHAiOjI1NjMwNzY2ODksInVzZXJJZCI6IjQwMDUxMzMzIiwiaWF0IjoxNDYzMDc2MDg5LCJlbWFpbCI6InRlc3RAdG9wY29kZXIuY29tIiwianRpIjoiYjMzYjc3Y2QtYjUyZS00MGZlLTgzN2UtYmViOGUwYWU2YTRhIn0.wKWUe0-SaiFVN-VR_-GwgFlvWaDkSbc8H55ktb9LAVw", - "type": "string" - } - ] + "method": "PATCH", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + }, + { + "key": "Authorization", + "value": "Bearer {{jwt-token}}" + } + ], + "body": { + "mode": "raw", + "raw": "{\r\n \"displayName\": \"Chatbot-updated\"\r\n }" }, - "method": "GET", - "header": [], "url": { - "raw": "{{api-url}}/projects/metadata/form/{{formKey}}/versions/{{formVersion}}", + "raw": "{{api-url}}/projects/metadata/projectTypes/{{projectTypeId}}", "host": [ "{{api-url}}" ], "path": [ "projects", "metadata", - "form", - "{{formKey}}", - "versions", - "{{formVersion}}" + "projectTypes", + "{{projectTypeId}}" ] } }, "response": [] }, { - "name": "Get latest version", + "name": "Delete project type", "request": { - "auth": { - "type": "bearer", - "bearer": [ - { - "key": "token", - "value": "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJyb2xlcyI6WyJUb3Bjb2RlciBVc2VyIiwiYWRtaW5pc3RyYXRvciJdLCJpc3MiOiJodHRwczovL2FwaS50b3Bjb2Rlci1kZXYuY29tIiwiaGFuZGxlIjoidGVzdDEiLCJleHAiOjI1NjMwNzY2ODksInVzZXJJZCI6IjQwMDUxMzMzIiwiaWF0IjoxNDYzMDc2MDg5LCJlbWFpbCI6InRlc3RAdG9wY29kZXIuY29tIiwianRpIjoiYjMzYjc3Y2QtYjUyZS00MGZlLTgzN2UtYmViOGUwYWU2YTRhIn0.wKWUe0-SaiFVN-VR_-GwgFlvWaDkSbc8H55ktb9LAVw", - "type": "string" - } - ] + "method": "DELETE", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + }, + { + "key": "Authorization", + "value": "Bearer {{jwt-token}}" + } + ], + "body": { + "mode": "raw", + "raw": "" }, - "method": "GET", - "header": [], "url": { - "raw": "{{api-url}}/projects/metadata/form/{{formKey}}", + "raw": "{{api-url}}/projects/metadata/projectTypes/{{projectTypeId}}", "host": [ "{{api-url}}" ], "path": [ "projects", "metadata", - "form", - "{{formKey}}" + "projectTypes", + "{{projectTypeId}}" ] } }, "response": [] - }, + } + ], + "protocolProfileBehavior": {} + }, + { + "name": "Product Category", + "item": [ { - "name": "Create form", + "name": "Create product category", "event": [ { "listen": "test", "script": { - "id": "94f6be66-34cc-40c8-80c2-b27dd93ed527", + "id": "06156797-ceb2-4f8c-9448-5c453adb7b7a", "exec": [ "pm.test(\"Status code is 201\", function () {", " pm.response.to.have.status(201);", - " pm.environment.set(\"formKey\", pm.response.json().key);", - " pm.environment.set(\"formVersion\", pm.response.json().version);", + " pm.environment.set(\"productCategoryId\", pm.response.json().key);", "});" ], "type": "text/javascript" @@ -5925,461 +5858,387 @@ } ], "request": { - "auth": { - "type": "bearer", - "bearer": [ - { - "key": "token", - "value": "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJyb2xlcyI6WyJUb3Bjb2RlciBVc2VyIiwiYWRtaW5pc3RyYXRvciJdLCJpc3MiOiJodHRwczovL2FwaS50b3Bjb2Rlci1kZXYuY29tIiwiaGFuZGxlIjoidGVzdDEiLCJleHAiOjI1NjMwNzY2ODksInVzZXJJZCI6IjQwMDUxMzMzIiwiaWF0IjoxNDYzMDc2MDg5LCJlbWFpbCI6InRlc3RAdG9wY29kZXIuY29tIiwianRpIjoiYjMzYjc3Y2QtYjUyZS00MGZlLTgzN2UtYmViOGUwYWU2YTRhIn0.wKWUe0-SaiFVN-VR_-GwgFlvWaDkSbc8H55ktb9LAVw", - "type": "string" - } - ] - }, "method": "POST", "header": [ { "key": "Content-Type", - "name": "Content-Type", - "type": "text", "value": "application/json" + }, + { + "key": "Authorization", + "value": "Bearer {{jwt-token}}" } ], "body": { "mode": "raw", - "raw": "{\r\n \t\"config\": {\r\n \t\t\"hello\": \"test\"\r\n \t}\r\n }" + "raw": "{\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 }" }, "url": { - "raw": "{{api-url}}/projects/metadata/form/dev/versions", + "raw": "{{api-url}}/projects/metadata/productCategories", "host": [ "{{api-url}}" ], "path": [ "projects", "metadata", - "form", - "dev", - "versions" + "productCategories" ] } }, "response": [] }, { - "name": "Update form", + "name": "List product categories", "request": { - "auth": { - "type": "bearer", - "bearer": [ - { - "key": "token", - "value": "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJyb2xlcyI6WyJUb3Bjb2RlciBVc2VyIiwiYWRtaW5pc3RyYXRvciJdLCJpc3MiOiJodHRwczovL2FwaS50b3Bjb2Rlci1kZXYuY29tIiwiaGFuZGxlIjoidGVzdDEiLCJleHAiOjI1NjMwNzY2ODksInVzZXJJZCI6IjQwMDUxMzMzIiwiaWF0IjoxNDYzMDc2MDg5LCJlbWFpbCI6InRlc3RAdG9wY29kZXIuY29tIiwianRpIjoiYjMzYjc3Y2QtYjUyZS00MGZlLTgzN2UtYmViOGUwYWU2YTRhIn0.wKWUe0-SaiFVN-VR_-GwgFlvWaDkSbc8H55ktb9LAVw", - "type": "string" - } - ] - }, - "method": "PATCH", + "method": "GET", "header": [ { "key": "Content-Type", - "name": "Content-Type", - "type": "text", "value": "application/json" + }, + { + "key": "Authorization", + "value": "Bearer {{jwt-token}}" } ], - "body": { - "mode": "raw", - "raw": "{\r\n \t\"config\": {\r\n \t\t\"hello\": \"test111\"\r\n \t}\r\n }" - }, "url": { - "raw": "{{api-url}}/projects/metadata/form/{{formKey}}/versions/{{formVersion}}", + "raw": "{{api-url}}/projects/metadata/productCategories", "host": [ "{{api-url}}" ], "path": [ "projects", "metadata", - "form", - "{{formKey}}", - "versions", - "{{formVersion}}" + "productCategories" ] } }, "response": [] }, { - "name": "Delete form", + "name": "Get product category", "request": { - "auth": { - "type": "bearer", - "bearer": [ - { - "key": "token", - "value": "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJyb2xlcyI6WyJUb3Bjb2RlciBVc2VyIiwiYWRtaW5pc3RyYXRvciJdLCJpc3MiOiJodHRwczovL2FwaS50b3Bjb2Rlci1kZXYuY29tIiwiaGFuZGxlIjoidGVzdDEiLCJleHAiOjI1NjMwNzY2ODksInVzZXJJZCI6IjQwMDUxMzMzIiwiaWF0IjoxNDYzMDc2MDg5LCJlbWFpbCI6InRlc3RAdG9wY29kZXIuY29tIiwianRpIjoiYjMzYjc3Y2QtYjUyZS00MGZlLTgzN2UtYmViOGUwYWU2YTRhIn0.wKWUe0-SaiFVN-VR_-GwgFlvWaDkSbc8H55ktb9LAVw", - "type": "string" - } - ] - }, - "method": "DELETE", + "method": "GET", "header": [ { "key": "Content-Type", - "name": "Content-Type", - "type": "text", "value": "application/json" + }, + { + "key": "Authorization", + "value": "Bearer {{jwt-token}}" } ], - "body": { - "mode": "raw", - "raw": "" - }, "url": { - "raw": "{{api-url}}/projects/metadata/form/{{formKey}}/versions/{{formVersion}}", + "raw": "{{api-url}}/projects/metadata/productCategories/{{productCategoryId}}", "host": [ "{{api-url}}" ], "path": [ "projects", "metadata", - "form", - "{{formKey}}", - "versions", - "{{formVersion}}" + "productCategories", + "{{productCategoryId}}" ] } }, "response": [] - } - ] - }, - { - "name": "Form Revision", - "item": [ + }, { - "name": "List all revision for version", + "name": "Update product category", "request": { - "auth": { - "type": "bearer", - "bearer": [ - { - "key": "token", - "value": "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJyb2xlcyI6WyJUb3Bjb2RlciBVc2VyIiwiYWRtaW5pc3RyYXRvciJdLCJpc3MiOiJodHRwczovL2FwaS50b3Bjb2Rlci1kZXYuY29tIiwiaGFuZGxlIjoidGVzdDEiLCJleHAiOjI1NjMwNzY2ODksInVzZXJJZCI6IjQwMDUxMzMzIiwiaWF0IjoxNDYzMDc2MDg5LCJlbWFpbCI6InRlc3RAdG9wY29kZXIuY29tIiwianRpIjoiYjMzYjc3Y2QtYjUyZS00MGZlLTgzN2UtYmViOGUwYWU2YTRhIn0.wKWUe0-SaiFVN-VR_-GwgFlvWaDkSbc8H55ktb9LAVw", - "type": "string" - } - ] + "method": "PATCH", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + }, + { + "key": "Authorization", + "value": "Bearer {{jwt-token}}" + } + ], + "body": { + "mode": "raw", + "raw": "{\r\n \"displayName\": \"Chatbot-updated\"\r\n }" }, - "method": "GET", - "header": [], "url": { - "raw": "{{api-url}}/projects/metadata/form/{{formKey}}/versions/{{formVersion}}/revisions", + "raw": "{{api-url}}/projects/metadata/productCategories/{{productCategoryId}}", "host": [ "{{api-url}}" ], "path": [ "projects", "metadata", - "form", - "{{formKey}}", - "versions", - "{{formVersion}}", - "revisions" + "productCategories", + "{{productCategoryId}}" ] } }, "response": [] }, { - "name": "Get a particular revision", + "name": "Delete product category", "request": { - "auth": { - "type": "bearer", - "bearer": [ - { - "key": "token", - "value": "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJyb2xlcyI6WyJUb3Bjb2RlciBVc2VyIiwiYWRtaW5pc3RyYXRvciJdLCJpc3MiOiJodHRwczovL2FwaS50b3Bjb2Rlci1kZXYuY29tIiwiaGFuZGxlIjoidGVzdDEiLCJleHAiOjI1NjMwNzY2ODksInVzZXJJZCI6IjQwMDUxMzMzIiwiaWF0IjoxNDYzMDc2MDg5LCJlbWFpbCI6InRlc3RAdG9wY29kZXIuY29tIiwianRpIjoiYjMzYjc3Y2QtYjUyZS00MGZlLTgzN2UtYmViOGUwYWU2YTRhIn0.wKWUe0-SaiFVN-VR_-GwgFlvWaDkSbc8H55ktb9LAVw", - "type": "string" - } - ] + "method": "DELETE", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + }, + { + "key": "Authorization", + "value": "Bearer {{jwt-token}}" + } + ], + "body": { + "mode": "raw", + "raw": "{\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 }" }, - "method": "GET", - "header": [], "url": { - "raw": "{{api-url}}/projects/metadata/form/{{formKey}}/versions/{{formVersion}}/revisions/{{formRevision}}", + "raw": "{{api-url}}/projects/metadata/productCategories/{{productCategoryId}}", "host": [ "{{api-url}}" ], "path": [ "projects", "metadata", - "form", - "{{formKey}}", - "versions", - "{{formVersion}}", - "revisions", - "{{formRevision}}" + "productCategories", + "{{productCategoryId}}" ] } }, "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": [ + "" + ] + } }, { - "name": "Create form", - "event": [ - { - "listen": "test", - "script": { - "id": "dbe5ec9f-022c-4ec5-b58c-d19c15430b61", - "exec": [ - "pm.test(\"Status code is 201\", function () {", - " pm.response.to.have.status(201);", - " pm.environment.set(\"formRevision\", pm.response.json().revision);", - "});" - ], - "type": "text/javascript" - } - } - ], + "listen": "test", + "script": { + "id": "9183c429-a5e0-4bf9-96a2-89f4d66e9b0d", + "type": "text/javascript", + "exec": [ + "" + ] + } + } + ], + "protocolProfileBehavior": {} + }, + { + "name": "Project upgrade", + "item": [ + { + "name": "Migrate project", "request": { - "auth": { - "type": "bearer", - "bearer": [ - { - "key": "token", - "value": "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJyb2xlcyI6WyJUb3Bjb2RlciBVc2VyIiwiYWRtaW5pc3RyYXRvciJdLCJpc3MiOiJodHRwczovL2FwaS50b3Bjb2Rlci1kZXYuY29tIiwiaGFuZGxlIjoidGVzdDEiLCJleHAiOjI1NjMwNzY2ODksInVzZXJJZCI6IjQwMDUxMzMzIiwiaWF0IjoxNDYzMDc2MDg5LCJlbWFpbCI6InRlc3RAdG9wY29kZXIuY29tIiwianRpIjoiYjMzYjc3Y2QtYjUyZS00MGZlLTgzN2UtYmViOGUwYWU2YTRhIn0.wKWUe0-SaiFVN-VR_-GwgFlvWaDkSbc8H55ktb9LAVw", - "type": "string" - } - ] - }, "method": "POST", "header": [ + { + "key": "Authorization", + "value": "Bearer {{jwt-token}}" + }, { "key": "Content-Type", - "name": "Content-Type", - "type": "text", "value": "application/json" } ], "body": { "mode": "raw", - "raw": "{\r\n \t\"config\": {\r\n \t\t\"hello\": \"test\"\r\n \t}\r\n }" + "raw": "{\n\t\t\"targetVersion\": \"v3\",\n\t\t\"defaultProductTemplateId\": 3\n\t}" }, "url": { - "raw": "{{api-url}}/projects/metadata/form/{{formKey}}/versions/{{formVersion}}/revisions", + "raw": "{{api-url}}/projects/{{projectId}}/upgrade", "host": [ "{{api-url}}" ], "path": [ "projects", - "metadata", - "form", - "{{formKey}}", - "versions", - "{{formVersion}}", - "revisions" + "{{projectId}}", + "upgrade" ] } }, "response": [] }, { - "name": "Create for no exist key", + "name": "Migrate project (completed)", "request": { - "auth": { - "type": "bearer", - "bearer": [ - { - "key": "token", - "value": "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJyb2xlcyI6WyJUb3Bjb2RlciBVc2VyIiwiYWRtaW5pc3RyYXRvciJdLCJpc3MiOiJodHRwczovL2FwaS50b3Bjb2Rlci1kZXYuY29tIiwiaGFuZGxlIjoidGVzdDEiLCJleHAiOjI1NjMwNzY2ODksInVzZXJJZCI6IjQwMDUxMzMzIiwiaWF0IjoxNDYzMDc2MDg5LCJlbWFpbCI6InRlc3RAdG9wY29kZXIuY29tIiwianRpIjoiYjMzYjc3Y2QtYjUyZS00MGZlLTgzN2UtYmViOGUwYWU2YTRhIn0.wKWUe0-SaiFVN-VR_-GwgFlvWaDkSbc8H55ktb9LAVw", - "type": "string" - } - ] - }, "method": "POST", "header": [ + { + "key": "Authorization", + "value": "Bearer {{jwt-token}}" + }, { "key": "Content-Type", - "name": "Content-Type", - "value": "application/json", - "type": "text" + "value": "application/json" } ], "body": { "mode": "raw", - "raw": "{\r\n \t\"config\": {\r\n \t\t\"hello\": \"test\"\r\n \t}\r\n }" + "raw": "{\n\t\t\"targetVersion\": \"v3\",\n\t\t\"defaultProductTemplateId\": 3\n\t}" }, "url": { - "raw": "{{api-url}}/projects/metadata/form/no-exist-2222key36/versions/1/revisions", + "raw": "{{api-url}}/projects/{{projectId}}/upgrade", "host": [ "{{api-url}}" ], "path": [ "projects", - "metadata", - "form", - "no-exist-2222key36", - "versions", - "1", - "revisions" + "{{projectId}}", + "upgrade" ] } }, "response": [] }, { - "name": "Delete revision", + "name": "Migrate project with phase name", "request": { - "auth": { - "type": "bearer", - "bearer": [ - { - "key": "token", - "value": "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJyb2xlcyI6WyJUb3Bjb2RlciBVc2VyIiwiYWRtaW5pc3RyYXRvciJdLCJpc3MiOiJodHRwczovL2FwaS50b3Bjb2Rlci1kZXYuY29tIiwiaGFuZGxlIjoidGVzdDEiLCJleHAiOjI1NjMwNzY2ODksInVzZXJJZCI6IjQwMDUxMzMzIiwiaWF0IjoxNDYzMDc2MDg5LCJlbWFpbCI6InRlc3RAdG9wY29kZXIuY29tIiwianRpIjoiYjMzYjc3Y2QtYjUyZS00MGZlLTgzN2UtYmViOGUwYWU2YTRhIn0.wKWUe0-SaiFVN-VR_-GwgFlvWaDkSbc8H55ktb9LAVw", - "type": "string" - } - ] - }, - "method": "DELETE", + "method": "POST", "header": [ + { + "key": "Authorization", + "value": "Bearer {{jwt-token}}" + }, { "key": "Content-Type", - "name": "Content-Type", - "type": "text", "value": "application/json" } ], "body": { "mode": "raw", - "raw": "" + "raw": "{\n\t\t\"targetVersion\": \"v3\",\n\t\t\"defaultProductTemplateId\": 3,\n\t\t\"phaseName\": \"Custom phase name\"\n\t}" }, "url": { - "raw": "{{api-url}}/projects/metadata/form/{{formKey}}/versions/{{formVersion}}/revisions/{{formRevision}}", + "raw": "{{api-url}}/projects/{{projectId}}/upgrade", "host": [ "{{api-url}}" ], "path": [ "projects", - "metadata", - "form", - "{{formKey}}", - "versions", - "{{formVersion}}", - "revisions", - "{{formRevision}}" - ] - } - }, - "response": [] - } - ] - }, - { - "name": "Price Config Version", - "item": [ - { - "name": "List price configs", - "request": { - "auth": { - "type": "bearer", - "bearer": [ - { - "key": "token", - "value": "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJyb2xlcyI6WyJUb3Bjb2RlciBVc2VyIiwiYWRtaW5pc3RyYXRvciJdLCJpc3MiOiJodHRwczovL2FwaS50b3Bjb2Rlci1kZXYuY29tIiwiaGFuZGxlIjoidGVzdDEiLCJleHAiOjI1NjMwNzY2ODksInVzZXJJZCI6IjQwMDUxMzMzIiwiaWF0IjoxNDYzMDc2MDg5LCJlbWFpbCI6InRlc3RAdG9wY29kZXIuY29tIiwianRpIjoiYjMzYjc3Y2QtYjUyZS00MGZlLTgzN2UtYmViOGUwYWU2YTRhIn0.wKWUe0-SaiFVN-VR_-GwgFlvWaDkSbc8H55ktb9LAVw", - "type": "string" - } - ] - }, - "method": "GET", - "header": [], - "url": { - "raw": "{{api-url}}/projects/metadata/priceConfig/dev/versions", - "host": [ - "{{api-url}}" - ], - "path": [ - "projects", - "metadata", - "priceConfig", - "dev", - "versions" + "{{projectId}}", + "upgrade" ] } }, "response": [] }, { - "name": "Get a particular version", + "name": "Migrate project with phase name (completed)", "request": { - "auth": { - "type": "bearer", - "bearer": [ - { - "key": "token", - "value": "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJyb2xlcyI6WyJUb3Bjb2RlciBVc2VyIiwiYWRtaW5pc3RyYXRvciJdLCJpc3MiOiJodHRwczovL2FwaS50b3Bjb2Rlci1kZXYuY29tIiwiaGFuZGxlIjoidGVzdDEiLCJleHAiOjI1NjMwNzY2ODksInVzZXJJZCI6IjQwMDUxMzMzIiwiaWF0IjoxNDYzMDc2MDg5LCJlbWFpbCI6InRlc3RAdG9wY29kZXIuY29tIiwianRpIjoiYjMzYjc3Y2QtYjUyZS00MGZlLTgzN2UtYmViOGUwYWU2YTRhIn0.wKWUe0-SaiFVN-VR_-GwgFlvWaDkSbc8H55ktb9LAVw", - "type": "string" - } - ] + "method": "POST", + "header": [ + { + "key": "Authorization", + "value": "Bearer {{jwt-token}}" + }, + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n\t\t\"targetVersion\": \"v3\",\n\t\t\"defaultProductTemplateId\": 3,\n\t\t\"phaseName\": \"Custom phase name\"\n\t}" }, - "method": "GET", - "header": [], "url": { - "raw": "{{api-url}}/projects/metadata/priceConfig/{{priceKey}}/versions/{{priceVersion}}", + "raw": "{{api-url}}/projects/{{projectId}}/upgrade", "host": [ "{{api-url}}" ], "path": [ "projects", - "metadata", - "priceConfig", - "{{priceKey}}", - "versions", - "{{priceVersion}}" + "{{projectId}}", + "upgrade" ] } }, "response": [] - }, + } + ], + "description": "Request to migrate projects.", + "protocolProfileBehavior": {} + }, + { + "name": "Timeline", + "item": [ { - "name": "Get latest version", + "name": "Create timeline", + "event": [ + { + "listen": "test", + "script": { + "id": "c066e7d4-537f-406e-a768-ec4bf73a2634", + "exec": [ + "pm.test(\"Status code is 201\", function () {", + " pm.response.to.have.status(201);", + " pm.environment.set(\"timelineId\", pm.response.json().id);", + "});" + ], + "type": "text/javascript" + } + } + ], "request": { - "auth": { - "type": "bearer", - "bearer": [ - { - "key": "token", - "value": "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJyb2xlcyI6WyJUb3Bjb2RlciBVc2VyIiwiYWRtaW5pc3RyYXRvciJdLCJpc3MiOiJodHRwczovL2FwaS50b3Bjb2Rlci1kZXYuY29tIiwiaGFuZGxlIjoidGVzdDEiLCJleHAiOjI1NjMwNzY2ODksInVzZXJJZCI6IjQwMDUxMzMzIiwiaWF0IjoxNDYzMDc2MDg5LCJlbWFpbCI6InRlc3RAdG9wY29kZXIuY29tIiwianRpIjoiYjMzYjc3Y2QtYjUyZS00MGZlLTgzN2UtYmViOGUwYWU2YTRhIn0.wKWUe0-SaiFVN-VR_-GwgFlvWaDkSbc8H55ktb9LAVw", - "type": "string" - } - ] + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + }, + { + "key": "Authorization", + "value": "Bearer {{jwt-token-connectAdmin-40051336}}" + } + ], + "body": { + "mode": "raw", + "raw": "{\r\n\t\"name\":\"new name\",\r\n\t\"description\":\"new description\",\r\n\t\"startDate\":\"2018-05-29T00:00:00.000Z\",\r\n\t\"endDate\": \"2018-05-30T00:00:00.000Z\",\r\n\t\"reference\": \"project\",\r\n\t\"referenceId\": {{projectId}}\r\n}" }, - "method": "GET", - "header": [], "url": { - "raw": "{{api-url}}/projects/metadata/priceConfig/{{priceKey}}", + "raw": "{{api-url}}/timelines", "host": [ "{{api-url}}" ], "path": [ - "projects", - "metadata", - "priceConfig", - "{{priceKey}}" + "timelines" ] } }, "response": [] }, { - "name": "Create priceConfig", + "name": "Create timeline with templateId", "event": [ { "listen": "test", "script": { - "id": "e440c87c-49ff-4443-b9bf-b44d4e9a480f", + "id": "ee729ed9-0072-4821-9141-3615ff66f728", "exec": [ "pm.test(\"Status code is 201\", function () {", " pm.response.to.have.status(201);", - " pm.environment.set(\"priceKey\", pm.response.json().key);", - " pm.environment.set(\"priceVersion\", pm.response.json().version);", + " pm.environment.set(\"timelineId\", pm.response.json().id);", "});" ], "type": "text/javascript" @@ -6387,810 +6246,3731 @@ } ], "request": { - "auth": { - "type": "bearer", - "bearer": [ - { - "key": "token", - "value": "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJyb2xlcyI6WyJUb3Bjb2RlciBVc2VyIiwiYWRtaW5pc3RyYXRvciJdLCJpc3MiOiJodHRwczovL2FwaS50b3Bjb2Rlci1kZXYuY29tIiwiaGFuZGxlIjoidGVzdDEiLCJleHAiOjI1NjMwNzY2ODksInVzZXJJZCI6IjQwMDUxMzMzIiwiaWF0IjoxNDYzMDc2MDg5LCJlbWFpbCI6InRlc3RAdG9wY29kZXIuY29tIiwianRpIjoiYjMzYjc3Y2QtYjUyZS00MGZlLTgzN2UtYmViOGUwYWU2YTRhIn0.wKWUe0-SaiFVN-VR_-GwgFlvWaDkSbc8H55ktb9LAVw", - "type": "string" - } - ] - }, "method": "POST", "header": [ { "key": "Content-Type", - "name": "Content-Type", - "type": "text", "value": "application/json" + }, + { + "key": "Authorization", + "value": "Bearer {{jwt-token-connectAdmin-40051336}}" } ], "body": { "mode": "raw", - "raw": "{\r\n \t\"config\": {\r\n \t\t\"hello\": \"test\"\r\n \t}\r\n }" + "raw": "{\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 \"templateId\": 1\r\n}" }, "url": { - "raw": "{{api-url}}/projects/metadata/priceConfig/dev/versions", + "raw": "{{api-url}}/timelines", "host": [ "{{api-url}}" ], "path": [ - "projects", - "metadata", - "priceConfig", - "dev", - "versions" + "timelines" ] } }, "response": [] }, { - "name": "Update priceConfig", + "name": "Create timeline with invalid data", "request": { - "auth": { - "type": "bearer", - "bearer": [ - { - "key": "token", - "value": "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJyb2xlcyI6WyJUb3Bjb2RlciBVc2VyIiwiYWRtaW5pc3RyYXRvciJdLCJpc3MiOiJodHRwczovL2FwaS50b3Bjb2Rlci1kZXYuY29tIiwiaGFuZGxlIjoidGVzdDEiLCJleHAiOjI1NjMwNzY2ODksInVzZXJJZCI6IjQwMDUxMzMzIiwiaWF0IjoxNDYzMDc2MDg5LCJlbWFpbCI6InRlc3RAdG9wY29kZXIuY29tIiwianRpIjoiYjMzYjc3Y2QtYjUyZS00MGZlLTgzN2UtYmViOGUwYWU2YTRhIn0.wKWUe0-SaiFVN-VR_-GwgFlvWaDkSbc8H55ktb9LAVw", - "type": "string" - } - ] - }, - "method": "PATCH", + "method": "POST", "header": [ { "key": "Content-Type", - "name": "Content-Type", - "type": "text", "value": "application/json" + }, + { + "key": "Authorization", + "value": "Bearer {{jwt-token-connectAdmin-40051336}}" } ], "body": { "mode": "raw", - "raw": "{\r\n \t\"config\": {\r\n \t\t\"hello\": \"test111\"\r\n \t}\r\n }" + "raw": "{\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}" }, "url": { - "raw": "{{api-url}}/projects/metadata/priceConfig/{{priceKey}}/versions/{{priceVersion}}", + "raw": "{{api-url}}/timelines", "host": [ "{{api-url}}" ], "path": [ - "projects", - "metadata", - "priceConfig", - "{{priceKey}}", - "versions", - "{{priceVersion}}" + "timelines" ] } }, "response": [] }, { - "name": "Delete priceConfig", + "name": "List timelines (filter by reference and referenceId)", "request": { - "auth": { - "type": "bearer", - "bearer": [ - { - "key": "token", - "value": "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJyb2xlcyI6WyJUb3Bjb2RlciBVc2VyIiwiYWRtaW5pc3RyYXRvciJdLCJpc3MiOiJodHRwczovL2FwaS50b3Bjb2Rlci1kZXYuY29tIiwiaGFuZGxlIjoidGVzdDEiLCJleHAiOjI1NjMwNzY2ODksInVzZXJJZCI6IjQwMDUxMzMzIiwiaWF0IjoxNDYzMDc2MDg5LCJlbWFpbCI6InRlc3RAdG9wY29kZXIuY29tIiwianRpIjoiYjMzYjc3Y2QtYjUyZS00MGZlLTgzN2UtYmViOGUwYWU2YTRhIn0.wKWUe0-SaiFVN-VR_-GwgFlvWaDkSbc8H55ktb9LAVw", - "type": "string" - } - ] - }, - "method": "DELETE", + "method": "GET", "header": [ { "key": "Content-Type", - "name": "Content-Type", - "type": "text", "value": "application/json" + }, + { + "key": "Authorization", + "value": "Bearer {{jwt-token-copilot-40051332}}", + "disabled": true + }, + { + "key": "Authorization", + "value": "Bearer {{jwt-token}}", + "type": "text" } ], - "body": { - "mode": "raw", - "raw": "" - }, "url": { - "raw": "{{api-url}}/projects/metadata/priceConfig/{{priceKey}}/versions/{{priceVersion}}", + "raw": "{{api-url}}/timelines?reference=project&referenceId={{projectId}}", "host": [ "{{api-url}}" ], "path": [ - "projects", - "metadata", - "priceConfig", - "{{priceKey}}", - "versions", - "{{priceVersion}}" + "timelines" + ], + "query": [ + { + "key": "reference", + "value": "project" + }, + { + "key": "referenceId", + "value": "{{projectId}}" + } ] } }, "response": [] - } - ], - "event": [ - { - "listen": "prerequest", - "script": { - "id": "59182724-4332-4d76-90ea-f7520a7b1be9", - "type": "text/javascript", - "exec": [ - "" - ] - } }, { - "listen": "test", - "script": { - "id": "abc13dca-e8a4-4995-970f-00e5889a5f2d", - "type": "text/javascript", - "exec": [ + "name": "Get timeline", + "request": { + "method": "GET", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + }, + { + "key": "Authorization", + "value": "Bearer {{jwt-token}}" + } + ], + "url": { + "raw": "{{api-url}}/timelines/{{timelineId}}", + "host": [ + "{{api-url}}" + ], + "path": [ + "timelines", + "{{timelineId}}" + ] + } + }, + "response": [] + }, + { + "name": "Get timeline from DB", + "request": { + "method": "GET", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + }, + { + "key": "Authorization", + "value": "Bearer {{jwt-token}}" + } + ], + "url": { + "raw": "{{api-url}}/timelines/{{timelineId}}", + "host": [ + "{{api-url}}" + ], + "path": [ + "timelines", + "{{timelineId}}" + ] + } + }, + "response": [] + }, + { + "name": "Update timeline", + "request": { + "method": "PATCH", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + }, + { + "key": "Authorization", + "value": "Bearer {{jwt-token}}" + } + ], + "body": { + "mode": "raw", + "raw": "{\r\n \"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\": {{projectId}}\r\n}" + }, + "url": { + "raw": "{{api-url}}/timelines/{{timelineId}}", + "host": [ + "{{api-url}}" + ], + "path": [ + "timelines", + "{{timelineId}}" + ] + } + }, + "response": [] + }, + { + "name": "Update timeline (startDate)", + "request": { + "method": "PATCH", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + }, + { + "key": "Authorization", + "value": "Bearer {{jwt-token}}" + } + ], + "body": { + "mode": "raw", + "raw": "{\r\n \"name\": \"timeline 1-updated\",\r\n \"description\": \"description-updated\",\r\n \"startDate\": \"2018-05-05T00:00:00.000Z\",\r\n \"reference\": \"project\",\r\n \"referenceId\": 1\r\n}" + }, + "url": { + "raw": "{{api-url}}/timelines/{{timelineId}}", + "host": [ + "{{api-url}}" + ], + "path": [ + "timelines", + "{{timelineId}}" + ] + } + }, + "response": [] + }, + { + "name": "Update timeline (endDate)", + "request": { + "method": "PATCH", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + }, + { + "key": "Authorization", + "value": "Bearer {{jwt-token}}" + } + ], + "body": { + "mode": "raw", + "raw": "{\r\n \"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}" + }, + "url": { + "raw": "{{api-url}}/timelines/{{timelineId}}", + "host": [ + "{{api-url}}" + ], + "path": [ + "timelines", + "{{timelineId}}" + ] + } + }, + "response": [] + }, + { + "name": "Delete timeline", + "request": { + "method": "DELETE", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + }, + { + "key": "Authorization", + "value": "Bearer {{jwt-token}}" + } + ], + "body": { + "mode": "raw", + "raw": "" + }, + "url": { + "raw": "{{api-url}}/timelines/{{timelineId}}", + "host": [ + "{{api-url}}" + ], + "path": [ + "timelines", + "{{timelineId}}" + ] + } + }, + "response": [] + } + ], + "protocolProfileBehavior": {} + }, + { + "name": "Milestone", + "item": [ + { + "name": "Create milestone", + "event": [ + { + "listen": "test", + "script": { + "id": "8fd1d5e9-8e6e-4cd7-9010-b855308be069", + "exec": [ + "pm.test(\"Status code is 201\", function () {", + " pm.response.to.have.status(201);", + " pm.environment.set(\"milestoneId\", pm.response.json().id);", + "});" + ], + "type": "text/javascript" + } + } + ], + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + }, + { + "key": "Authorization", + "value": "Bearer {{jwt-token-admin-40051333}}" + } + ], + "body": { + "mode": "raw", + "raw": "{\r\n \"name\": \"milestone 3\",\r\n \"description\": \"description 3\",\r\n \"duration\": 4,\r\n \"startDate\": \"2018-05-29T00:00:00.000Z\",\r\n \"endDate\": \"2018-05-30T00:00:00.000Z\",\r\n \"completionDate\": \"2018-05-31T00: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}" + }, + "url": { + "raw": "{{api-url}}/timelines/{{timelineId}}/milestones", + "host": [ + "{{api-url}}" + ], + "path": [ + "timelines", + "{{timelineId}}", + "milestones" + ] + } + }, + "response": [] + }, + { + "name": "Create milestone with invalid data", + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + }, + { + "key": "Authorization", + "value": "Bearer {{jwt-token-member-40051331}}" + } + ], + "body": { + "mode": "raw", + "raw": "{\r\n \"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}" + }, + "url": { + "raw": "{{api-url}}/timelines/{{timelineId}}/milestones", + "host": [ + "{{api-url}}" + ], + "path": [ + "timelines", + "{{timelineId}}", + "milestones" + ] + } + }, + "response": [] + }, + { + "name": "List milestones", + "request": { + "method": "GET", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + }, + { + "key": "Authorization", + "value": "Bearer {{jwt-token}}", + "type": "text" + } + ], + "url": { + "raw": "{{api-url}}/timelines/{{timelineId}}/milestones", + "host": [ + "{{api-url}}" + ], + "path": [ + "timelines", + "{{timelineId}}", + "milestones" + ] + } + }, + "response": [] + }, + { + "name": "List milestones (sort)", + "request": { + "method": "GET", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + }, + { + "key": "Authorization", + "value": "Bearer {{jwt-token-copilot-40051332}}" + } + ], + "url": { + "raw": "{{api-url}}/timelines/{{timelineId}}/milestones?sort=order desc", + "host": [ + "{{api-url}}" + ], + "path": [ + "timelines", + "{{timelineId}}", + "milestones" + ], + "query": [ + { + "key": "sort", + "value": "order desc" + } + ] + } + }, + "response": [] + }, + { + "name": "Get milestone", + "request": { + "method": "GET", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + }, + { + "key": "Authorization", + "value": "Bearer {{jwt-token}}" + } + ], + "url": { + "raw": "{{api-url}}/timelines/{{timelineId}}/milestones/{{milestoneId}}", + "host": [ + "{{api-url}}" + ], + "path": [ + "timelines", + "{{timelineId}}", + "milestones", + "{{milestoneId}}" + ] + } + }, + "response": [] + }, + { + "name": "Update milestone", + "request": { + "method": "PATCH", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + }, + { + "key": "Authorization", + "value": "Bearer {{jwt-token}}" + } + ], + "body": { + "mode": "raw", + "raw": "{\r\n \"name\": \"milestone 1-updated\",\r\n \"description\": \"description-updated\",\r\n \"duration\": 3,\r\n \"completionDate\": \"2018-09-28T00:00:00.000Z\",\r\n \"status\": \"closed\",\r\n \"type\": \"type2\",\r\n \"details\": {\r\n \"detail1\": {\r\n \"subDetail1C\": 3\r\n },\r\n \"detail2\": [\r\n 4\r\n ]\r\n },\r\n \"order\": 1,\r\n \"plannedText\": \"plannedText 1-updated\",\r\n \"activeText\": \"activeText 1-updated\",\r\n \"completedText\": \"completedText 1-updated\",\r\n \"blockedText\": \"blockedText 1-updated\"\r\n}" + }, + "url": { + "raw": "{{api-url}}/timelines/{{timelineId}}/milestones/{{milestoneId}}", + "host": [ + "{{api-url}}" + ], + "path": [ + "timelines", + "{{timelineId}}", + "milestones", + "{{milestoneId}}" + ] + } + }, + "response": [] + }, + { + "name": "Update milestone - paused", + "request": { + "method": "PATCH", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + }, + { + "key": "Authorization", + "value": "Bearer {{jwt-token}}" + } + ], + "body": { + "mode": "raw", + "raw": "{\r\n \"name\": \"milestone 1-updated\",\r\n \"description\": \"description-updated\",\r\n \"status\": \"paused\",\r\n \"details\": {\r\n \"detail1\": {\r\n \"subDetail1C\": 3\r\n },\r\n \"detail2\": [\r\n 4\r\n ]\r\n },\r\n \"statusComment\": \"milestone paused\"\r\n}" + }, + "url": { + "raw": "{{api-url}}/timelines/{{timelineId}}/milestones/{{milestoneId}}", + "host": [ + "{{api-url}}" + ], + "path": [ + "timelines", + "{{timelineId}}", + "milestones", + "{{milestoneId}}" + ] + } + }, + "response": [] + }, + { + "name": "Update milestone - resume", + "request": { + "method": "PATCH", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + }, + { + "key": "Authorization", + "value": "Bearer {{jwt-token}}" + } + ], + "body": { + "mode": "raw", + "raw": "{\r\n \"name\": \"milestone 1-updated\",\r\n \"description\": \"description-updated\",\r\n \"status\": \"resume\",\r\n \"details\": {\r\n \"detail1\": {\r\n \"subDetail1C\": 3\r\n },\r\n \"detail2\": [\r\n 4\r\n ]\r\n },\r\n \"statusComment\": \"milestone resume\"\r\n}" + }, + "url": { + "raw": "{{api-url}}/timelines/{{timelineId}}/milestones/{{milestoneId}}", + "host": [ + "{{api-url}}" + ], + "path": [ + "timelines", + "{{timelineId}}", + "milestones", + "{{milestoneId}}" + ] + } + }, + "response": [] + }, + { + "name": "Update milestone (active)", + "request": { + "method": "PATCH", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + }, + { + "key": "Authorization", + "value": "Bearer {{jwt-token}}" + } + ], + "body": { + "mode": "raw", + "raw": "{\r\n \"name\": \"milestone 2-updated\",\r\n \"description\": \"description-updated\",\r\n \"duration\": 3,\r\n \"completionDate\": \"2018-10-28T00:00:00.000Z\",\r\n \"status\": \"active\",\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}" + }, + "url": { + "raw": "{{api-url}}/timelines/{{timelineId}}/milestones/{{milestoneId}}", + "host": [ + "{{api-url}}" + ], + "path": [ + "timelines", + "{{timelineId}}", + "milestones", + "{{milestoneId}}" + ] + } + }, + "response": [] + }, + { + "name": "Update milestone (completed)", + "request": { + "method": "PATCH", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + }, + { + "key": "Authorization", + "value": "Bearer {{jwt-token}}" + } + ], + "body": { + "mode": "raw", + "raw": "{\r\n \"name\": \"milestone 2-updated\",\r\n \"description\": \"description-updated\",\r\n \"duration\": 3,\r\n \"completionDate\": \"2018-10-28T00:00:00.000Z\",\r\n \"status\": \"completed\",\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}" + }, + "url": { + "raw": "{{api-url}}/timelines/{{timelineId}}/milestones/{{milestoneId}}", + "host": [ + "{{api-url}}" + ], + "path": [ + "timelines", + "{{timelineId}}", + "milestones", + "{{milestoneId}}" + ] + } + }, + "response": [] + }, + { + "name": "Update milestone (order 1 => 2)", + "request": { + "method": "PATCH", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + }, + { + "key": "Authorization", + "value": "Bearer {{jwt-token}}" + } + ], + "body": { + "mode": "raw", + "raw": "{\r\n \"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}" + }, + "url": { + "raw": "{{api-url}}/timelines/{{timelineId}}/milestones/{{milestoneId}}", + "host": [ + "{{api-url}}" + ], + "path": [ + "timelines", + "{{timelineId}}", + "milestones", + "{{milestoneId}}" + ] + } + }, + "response": [] + }, + { + "name": "Update milestone (order 2 => 1)", + "request": { + "method": "PATCH", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + }, + { + "key": "Authorization", + "value": "Bearer {{jwt-token}}" + } + ], + "body": { + "mode": "raw", + "raw": "{\r\n \"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}" + }, + "url": { + "raw": "{{api-url}}/timelines/{{timelineId}}/milestones/{{milestoneId}}", + "host": [ + "{{api-url}}" + ], + "path": [ + "timelines", + "{{timelineId}}", + "milestones", + "{{milestoneId}}" + ] + } + }, + "response": [] + }, + { + "name": "Update milestone (order 1 => 3)", + "request": { + "method": "PATCH", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + }, + { + "key": "Authorization", + "value": "Bearer {{jwt-token}}" + } + ], + "body": { + "mode": "raw", + "raw": "{\r\n \"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}" + }, + "url": { + "raw": "{{api-url}}/timelines/{{timelineId}}/milestones/{{milestoneId}}", + "host": [ + "{{api-url}}" + ], + "path": [ + "timelines", + "{{timelineId}}", + "milestones", + "{{milestoneId}}" + ] + } + }, + "response": [] + }, + { + "name": "Update milestone (order 3 => 1)", + "request": { + "method": "PATCH", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + }, + { + "key": "Authorization", + "value": "Bearer {{jwt-token}}" + } + ], + "body": { + "mode": "raw", + "raw": "{\r\n \"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}" + }, + "url": { + "raw": "{{api-url}}/timelines/{{timelineId}}/milestones/{{milestoneId}}", + "host": [ + "{{api-url}}" + ], + "path": [ + "timelines", + "{{timelineId}}", + "milestones", + "{{milestoneId}}" + ] + } + }, + "response": [] + }, + { + "name": "Delete milestone", + "request": { + "method": "DELETE", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + }, + { + "key": "Authorization", + "value": "Bearer {{jwt-token}}" + } + ], + "body": { + "mode": "raw", + "raw": "" + }, + "url": { + "raw": "{{api-url}}/timelines/{{timelineId}}/milestones/{{milestoneId}}", + "host": [ + "{{api-url}}" + ], + "path": [ + "timelines", + "{{timelineId}}", + "milestones", + "{{milestoneId}}" + ] + } + }, + "response": [] + } + ], + "protocolProfileBehavior": {} + }, + { + "name": "Milestone Template", + "item": [ + { + "name": "Create milestone template", + "event": [ + { + "listen": "test", + "script": { + "id": "3dbf8b29-2498-4b05-93de-14d809ccc285", + "exec": [ + "pm.test(\"Status code is 201\", function () {", + " pm.response.to.have.status(201);", + " pm.environment.set(\"milestoneTemplateId\", pm.response.json().id);", + "});" + ], + "type": "text/javascript" + } + } + ], + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + }, + { + "key": "Authorization", + "value": "Bearer {{jwt-token-admin-40051333}}" + } + ], + "body": { + "mode": "raw", + "raw": "{\r\n \"name\": \"milestoneTemplate 1\",\r\n \"description\": \"description 1\",\r\n \"duration\": 11,\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\t\"reference\": \"productTemplate\",\r\n\t\"referenceId\": {{productTemplateId}},\r\n\t\"metadata\": {}\r\n}" + }, + "url": { + "raw": "{{api-url}}/timelines/metadata/milestoneTemplates", + "host": [ + "{{api-url}}" + ], + "path": [ + "timelines", + "metadata", + "milestoneTemplates" + ] + } + }, + "response": [] + }, + { + "name": "Create milestone template with invalid referenceId", + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + }, + { + "key": "Authorization", + "value": "Bearer {{jwt-token-admin-40051333}}" + } + ], + "body": { + "mode": "raw", + "raw": "{\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\t\"reference\": \"productTemplate\",\r\n\t\"referenceId\": 1000,\r\n\t\"metadata\": {}\r\n}" + }, + "url": { + "raw": "{{api-url}}/timelines/metadata/milestoneTemplates", + "host": [ + "{{api-url}}" + ], + "path": [ + "timelines", + "metadata", + "milestoneTemplates" + ] + } + }, + "response": [] + }, + { + "name": "Create milestone template with invalid data", + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + }, + { + "key": "Authorization", + "value": "Bearer {{jwt-token-admin-40051333}}" + } + ], + "body": { + "mode": "raw", + "raw": "{\r\n}" + }, + "url": { + "raw": "{{api-url}}/timelines/metadata/milestoneTemplates", + "host": [ + "{{api-url}}" + ], + "path": [ + "timelines", + "metadata", + "milestoneTemplates" + ] + } + }, + "response": [] + }, + { + "name": "Clone milestone template", + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + }, + { + "key": "Authorization", + "value": "Bearer {{jwt-token-admin-40051333}}" + } + ], + "body": { + "mode": "raw", + "raw": "{\r\n \"sourceReference\": \"productTemplate\",\r\n \"sourceReferenceId\": 1,\r\n \"reference\": \"productTemplate\",\r\n \"referenceId\": 2\r\n}" + }, + "url": { + "raw": "{{api-url}}/timelines/metadata/milestoneTemplates/clone", + "host": [ + "{{api-url}}" + ], + "path": [ + "timelines", + "metadata", + "milestoneTemplates", + "clone" + ] + } + }, + "response": [] + }, + { + "name": "Clone milestone template with invalid referenceId", + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + }, + { + "key": "Authorization", + "value": "Bearer {{jwt-token-admin-40051333}}" + } + ], + "body": { + "mode": "raw", + "raw": "{\r\n \"sourceReference\": \"productTemplate\",\r\n \"sourceReferenceId\": 1,\r\n \"reference\": \"productTemplate\",\r\n \"referenceId\": 2000\r\n}" + }, + "url": { + "raw": "{{api-url}}/timelines/metadata/milestoneTemplates/clone", + "host": [ + "{{api-url}}" + ], + "path": [ + "timelines", + "metadata", + "milestoneTemplates", + "clone" + ] + } + }, + "response": [] + }, + { + "name": "Clone milestone template with invalid sourceReferenceId", + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + }, + { + "key": "Authorization", + "value": "Bearer {{jwt-token-admin-40051333}}" + } + ], + "body": { + "mode": "raw", + "raw": "{\r\n \"sourceReference\": \"productTemplate\",\r\n \"sourceReferenceId\": 1000,\r\n \"reference\": \"productTemplate\",\r\n \"referenceId\": 2\r\n}" + }, + "url": { + "raw": "{{api-url}}/timelines/metadata/milestoneTemplates/clone", + "host": [ + "{{api-url}}" + ], + "path": [ + "timelines", + "metadata", + "milestoneTemplates", + "clone" + ] + } + }, + "response": [] + }, + { + "name": "List milestone templates", + "request": { + "method": "GET", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + }, + { + "key": "Authorization", + "value": "Bearer {{jwt-token-copilot-40051332}}" + } + ], + "url": { + "raw": "{{api-url}}/timelines/metadata/milestoneTemplates", + "host": [ + "{{api-url}}" + ], + "path": [ + "timelines", + "metadata", + "milestoneTemplates" + ] + } + }, + "response": [] + }, + { + "name": "List milestone templates (filter)", + "request": { + "method": "GET", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + }, + { + "key": "Authorization", + "value": "Bearer {{jwt-token-copilot-40051332}}" + } + ], + "url": { + "raw": "{{api-url}}/timelines/metadata/milestoneTemplates?reference=productTemplate&referenceId={{productTemplateId}}", + "host": [ + "{{api-url}}" + ], + "path": [ + "timelines", + "metadata", + "milestoneTemplates" + ], + "query": [ + { + "key": "reference", + "value": "productTemplate" + }, + { + "key": "referenceId", + "value": "{{productTemplateId}}" + } + ] + } + }, + "response": [] + }, + { + "name": "List milestone templates (sort)", + "request": { + "method": "GET", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + }, + { + "key": "Authorization", + "value": "Bearer {{jwt-token-copilot-40051332}}" + } + ], + "url": { + "raw": "{{api-url}}/timelines/metadata/milestoneTemplates?reference=productTemplate&referenceId={{productTemplateId}}&sort=order desc", + "host": [ + "{{api-url}}" + ], + "path": [ + "timelines", + "metadata", + "milestoneTemplates" + ], + "query": [ + { + "key": "reference", + "value": "productTemplate" + }, + { + "key": "referenceId", + "value": "{{productTemplateId}}" + }, + { + "key": "sort", + "value": "order desc" + } + ] + } + }, + "response": [] + }, + { + "name": "Get milestone template", + "request": { + "method": "GET", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + }, + { + "key": "Authorization", + "value": "Bearer {{jwt-token}}" + } + ], + "url": { + "raw": "{{api-url}}/timelines/metadata/milestoneTemplates/{{milestoneTemplateId}}", + "host": [ + "{{api-url}}" + ], + "path": [ + "timelines", + "metadata", + "milestoneTemplates", + "{{milestoneTemplateId}}" + ] + } + }, + "response": [] + }, + { + "name": "Update milestone", + "request": { + "method": "PATCH", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + }, + { + "key": "Authorization", + "value": "Bearer {{jwt-token}}" + } + ], + "body": { + "mode": "raw", + "raw": "{\r\n\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\t\"reference\": \"productTemplate\",\r\n\t\"referenceId\": 1\r\n}" + }, + "url": { + "raw": "{{api-url}}/timelines/metadata/milestoneTemplates/{{milestoneTemplateId}}", + "host": [ + "{{api-url}}" + ], + "path": [ + "timelines", + "metadata", + "milestoneTemplates", + "{{milestoneTemplateId}}" + ] + } + }, + "response": [] + }, + { + "name": "Update milestone (order 1 => 2)", + "request": { + "method": "PATCH", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + }, + { + "key": "Authorization", + "value": "Bearer {{jwt-token}}" + } + ], + "body": { + "mode": "raw", + "raw": "{\r\n \"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 \"reference\": \"productTemplate\",\r\n \"referenceId\": {{productTemplateId}}\r\n}" + }, + "url": { + "raw": "{{api-url}}/timelines/metadata/milestoneTemplates/{{milestoneTemplateId}}", + "host": [ + "{{api-url}}" + ], + "path": [ + "timelines", + "metadata", + "milestoneTemplates", + "{{milestoneTemplateId}}" + ] + } + }, + "response": [] + }, + { + "name": "Update milestone (order 2 => 1)", + "request": { + "method": "PATCH", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + }, + { + "key": "Authorization", + "value": "Bearer {{jwt-token}}" + } + ], + "body": { + "mode": "raw", + "raw": "{\r\n\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\t\"reference\": \"productTemplate\",\r\n\t\"referenceId\": 1\r\n}" + }, + "url": { + "raw": "{{api-url}}/timelines/metadata/milestoneTemplates/{{milestoneTemplateId}}", + "host": [ + "{{api-url}}" + ], + "path": [ + "timelines", + "metadata", + "milestoneTemplates", + "{{milestoneTemplateId}}" + ] + } + }, + "response": [] + }, + { + "name": "Update milestone (order 1 => 3)", + "request": { + "method": "PATCH", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + }, + { + "key": "Authorization", + "value": "Bearer {{jwt-token}}" + } + ], + "body": { + "mode": "raw", + "raw": "{\r\n \"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\t\"reference\": \"productTemplate\",\r\n\t\"referenceId\": 1\r\n}" + }, + "url": { + "raw": "{{api-url}}/timelines/metadata/milestoneTemplates/{{milestoneTemplateId}}", + "host": [ + "{{api-url}}" + ], + "path": [ + "timelines", + "metadata", + "milestoneTemplates", + "{{milestoneTemplateId}}" + ] + } + }, + "response": [] + }, + { + "name": "Update milestone (order 3 => 1)", + "request": { + "method": "PATCH", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + }, + { + "key": "Authorization", + "value": "Bearer {{jwt-token}}" + } + ], + "body": { + "mode": "raw", + "raw": "{\r\n \"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\t\"reference\": \"productTemplate\",\r\n\t\"referenceId\": 1\r\n}" + }, + "url": { + "raw": "{{api-url}}/timelines/metadata/milestoneTemplates/{{milestoneTemplateId}}", + "host": [ + "{{api-url}}" + ], + "path": [ + "timelines", + "metadata", + "milestoneTemplates", + "{{milestoneTemplateId}}" + ] + } + }, + "response": [] + }, + { + "name": "Update milestone with metadata", + "request": { + "method": "PATCH", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + }, + { + "key": "Authorization", + "value": "Bearer {{jwt-token}}" + } + ], + "body": { + "mode": "raw", + "raw": "{\r\n\t\"name\": \"milestoneTemplate 5-updated\",\r\n\t\"description\": \"description 5-updated\",\r\n\t\"duration\": 34,\r\n\t\"type\": \"type5-updated\",\r\n\t\"order\": 5,\r\n\t\"reference\": \"productTemplate\",\r\n\t\"referenceId\": 1,\r\n\t\"metadata\": {\r\n \"metadata1\": {\r\n \"name\": \"metadata 1 - update\",\r\n \"details\": {\r\n \"anyDetails\": \"any details 1 - update\",\r\n \"newDetails\": \"new\"\r\n },\r\n \"others\": [\"others new\"]\r\n },\r\n \"metadata3\": {\r\n \"name\": \"metadata 3\",\r\n \"details\": {\r\n \"anyDetails\": \"any details 3\"\r\n },\r\n \"others\": [\"others 31\", \"others 32\"]\r\n }\r\n }\r\n}" + }, + "url": { + "raw": "{{api-url}}/timelines/metadata/milestoneTemplates/{{milestoneTemplateId}}", + "host": [ + "{{api-url}}" + ], + "path": [ + "timelines", + "metadata", + "milestoneTemplates", + "{{milestoneTemplateId}}" + ] + } + }, + "response": [] + }, + { + "name": "Delete milestone", + "request": { + "method": "DELETE", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + }, + { + "key": "Authorization", + "value": "Bearer {{jwt-token}}" + } + ], + "body": { + "mode": "raw", + "raw": "" + }, + "url": { + "raw": "{{api-url}}/timelines/metadata/milestoneTemplates/{{milestoneTemplateId}}", + "host": [ + "{{api-url}}" + ], + "path": [ + "timelines", + "metadata", + "milestoneTemplates", + "{{milestoneTemplateId}}" + ] + } + }, + "response": [] + } + ], + "protocolProfileBehavior": {} + }, + { + "name": "Metadata", + "item": [ + { + "name": "Get all metadata", + "request": { + "auth": { + "type": "bearer", + "bearer": [ + { + "key": "token", + "value": "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJyb2xlcyI6WyJUb3Bjb2RlciBVc2VyIiwiYWRtaW5pc3RyYXRvciJdLCJpc3MiOiJodHRwczovL2FwaS50b3Bjb2Rlci1kZXYuY29tIiwiaGFuZGxlIjoidGVzdDEiLCJleHAiOjI1NjMwNzY2ODksInVzZXJJZCI6IjQwMDUxMzMzIiwiaWF0IjoxNDYzMDc2MDg5LCJlbWFpbCI6InRlc3RAdG9wY29kZXIuY29tIiwianRpIjoiYjMzYjc3Y2QtYjUyZS00MGZlLTgzN2UtYmViOGUwYWU2YTRhIn0.wKWUe0-SaiFVN-VR_-GwgFlvWaDkSbc8H55ktb9LAVw", + "type": "string" + } + ] + }, + "method": "GET", + "header": [ + { + "key": "", + "value": "", + "type": "text" + } + ], + "url": { + "raw": "{{api-url}}/projects/metadata", + "host": [ + "{{api-url}}" + ], + "path": [ + "projects", + "metadata" + ] + } + }, + "response": [] + }, + { + "name": "Get all metadata with includeAllVersion", + "request": { + "auth": { + "type": "bearer", + "bearer": [ + { + "key": "token", + "value": "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJyb2xlcyI6WyJUb3Bjb2RlciBVc2VyIiwiYWRtaW5pc3RyYXRvciJdLCJpc3MiOiJodHRwczovL2FwaS50b3Bjb2Rlci1kZXYuY29tIiwiaGFuZGxlIjoidGVzdDEiLCJleHAiOjI1NjMwNzY2ODksInVzZXJJZCI6IjQwMDUxMzMzIiwiaWF0IjoxNDYzMDc2MDg5LCJlbWFpbCI6InRlc3RAdG9wY29kZXIuY29tIiwianRpIjoiYjMzYjc3Y2QtYjUyZS00MGZlLTgzN2UtYmViOGUwYWU2YTRhIn0.wKWUe0-SaiFVN-VR_-GwgFlvWaDkSbc8H55ktb9LAVw", + "type": "string" + } + ] + }, + "method": "GET", + "header": [], + "url": { + "raw": "{{api-url}}/projects/metadata?includeAllReferred=true", + "host": [ + "{{api-url}}" + ], + "path": [ + "projects", + "metadata" + ], + "query": [ + { + "key": "includeAllReferred", + "value": "true" + } + ] + } + }, + "response": [] + } + ], + "protocolProfileBehavior": {} + }, + { + "name": "Form Version", + "item": [ + { + "name": "List forms", + "request": { + "auth": { + "type": "bearer", + "bearer": [ + { + "key": "token", + "value": "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJyb2xlcyI6WyJUb3Bjb2RlciBVc2VyIiwiYWRtaW5pc3RyYXRvciJdLCJpc3MiOiJodHRwczovL2FwaS50b3Bjb2Rlci1kZXYuY29tIiwiaGFuZGxlIjoidGVzdDEiLCJleHAiOjI1NjMwNzY2ODksInVzZXJJZCI6IjQwMDUxMzMzIiwiaWF0IjoxNDYzMDc2MDg5LCJlbWFpbCI6InRlc3RAdG9wY29kZXIuY29tIiwianRpIjoiYjMzYjc3Y2QtYjUyZS00MGZlLTgzN2UtYmViOGUwYWU2YTRhIn0.wKWUe0-SaiFVN-VR_-GwgFlvWaDkSbc8H55ktb9LAVw", + "type": "string" + } + ] + }, + "method": "GET", + "header": [], + "url": { + "raw": "{{api-url}}/projects/metadata/form/{{formKey}}/versions", + "host": [ + "{{api-url}}" + ], + "path": [ + "projects", + "metadata", + "form", + "{{formKey}}", + "versions" + ] + } + }, + "response": [] + }, + { + "name": "Get a particular version", + "request": { + "auth": { + "type": "bearer", + "bearer": [ + { + "key": "token", + "value": "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJyb2xlcyI6WyJUb3Bjb2RlciBVc2VyIiwiYWRtaW5pc3RyYXRvciJdLCJpc3MiOiJodHRwczovL2FwaS50b3Bjb2Rlci1kZXYuY29tIiwiaGFuZGxlIjoidGVzdDEiLCJleHAiOjI1NjMwNzY2ODksInVzZXJJZCI6IjQwMDUxMzMzIiwiaWF0IjoxNDYzMDc2MDg5LCJlbWFpbCI6InRlc3RAdG9wY29kZXIuY29tIiwianRpIjoiYjMzYjc3Y2QtYjUyZS00MGZlLTgzN2UtYmViOGUwYWU2YTRhIn0.wKWUe0-SaiFVN-VR_-GwgFlvWaDkSbc8H55ktb9LAVw", + "type": "string" + } + ] + }, + "method": "GET", + "header": [], + "url": { + "raw": "{{api-url}}/projects/metadata/form/{{formKey}}/versions/{{formVersion}}", + "host": [ + "{{api-url}}" + ], + "path": [ + "projects", + "metadata", + "form", + "{{formKey}}", + "versions", + "{{formVersion}}" + ] + } + }, + "response": [] + }, + { + "name": "Get latest version", + "request": { + "auth": { + "type": "bearer", + "bearer": [ + { + "key": "token", + "value": "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJyb2xlcyI6WyJUb3Bjb2RlciBVc2VyIiwiYWRtaW5pc3RyYXRvciJdLCJpc3MiOiJodHRwczovL2FwaS50b3Bjb2Rlci1kZXYuY29tIiwiaGFuZGxlIjoidGVzdDEiLCJleHAiOjI1NjMwNzY2ODksInVzZXJJZCI6IjQwMDUxMzMzIiwiaWF0IjoxNDYzMDc2MDg5LCJlbWFpbCI6InRlc3RAdG9wY29kZXIuY29tIiwianRpIjoiYjMzYjc3Y2QtYjUyZS00MGZlLTgzN2UtYmViOGUwYWU2YTRhIn0.wKWUe0-SaiFVN-VR_-GwgFlvWaDkSbc8H55ktb9LAVw", + "type": "string" + } + ] + }, + "method": "GET", + "header": [], + "url": { + "raw": "{{api-url}}/projects/metadata/form/{{formKey}}", + "host": [ + "{{api-url}}" + ], + "path": [ + "projects", + "metadata", + "form", + "{{formKey}}" + ] + } + }, + "response": [] + }, + { + "name": "Create form", + "event": [ + { + "listen": "test", + "script": { + "id": "94f6be66-34cc-40c8-80c2-b27dd93ed527", + "exec": [ + "pm.test(\"Status code is 201\", function () {", + " pm.response.to.have.status(201);", + " pm.environment.set(\"formKey\", pm.response.json().key);", + " pm.environment.set(\"formVersion\", pm.response.json().version);", + "});" + ], + "type": "text/javascript" + } + } + ], + "request": { + "auth": { + "type": "bearer", + "bearer": [ + { + "key": "token", + "value": "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJyb2xlcyI6WyJUb3Bjb2RlciBVc2VyIiwiYWRtaW5pc3RyYXRvciJdLCJpc3MiOiJodHRwczovL2FwaS50b3Bjb2Rlci1kZXYuY29tIiwiaGFuZGxlIjoidGVzdDEiLCJleHAiOjI1NjMwNzY2ODksInVzZXJJZCI6IjQwMDUxMzMzIiwiaWF0IjoxNDYzMDc2MDg5LCJlbWFpbCI6InRlc3RAdG9wY29kZXIuY29tIiwianRpIjoiYjMzYjc3Y2QtYjUyZS00MGZlLTgzN2UtYmViOGUwYWU2YTRhIn0.wKWUe0-SaiFVN-VR_-GwgFlvWaDkSbc8H55ktb9LAVw", + "type": "string" + } + ] + }, + "method": "POST", + "header": [ + { + "key": "Content-Type", + "name": "Content-Type", + "type": "text", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\r\n \t\"config\": {\r\n \t\t\"hello\": \"test\"\r\n \t}\r\n }" + }, + "url": { + "raw": "{{api-url}}/projects/metadata/form/dev/versions", + "host": [ + "{{api-url}}" + ], + "path": [ + "projects", + "metadata", + "form", + "dev", + "versions" + ] + } + }, + "response": [] + }, + { + "name": "Update form", + "request": { + "auth": { + "type": "bearer", + "bearer": [ + { + "key": "token", + "value": "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJyb2xlcyI6WyJUb3Bjb2RlciBVc2VyIiwiYWRtaW5pc3RyYXRvciJdLCJpc3MiOiJodHRwczovL2FwaS50b3Bjb2Rlci1kZXYuY29tIiwiaGFuZGxlIjoidGVzdDEiLCJleHAiOjI1NjMwNzY2ODksInVzZXJJZCI6IjQwMDUxMzMzIiwiaWF0IjoxNDYzMDc2MDg5LCJlbWFpbCI6InRlc3RAdG9wY29kZXIuY29tIiwianRpIjoiYjMzYjc3Y2QtYjUyZS00MGZlLTgzN2UtYmViOGUwYWU2YTRhIn0.wKWUe0-SaiFVN-VR_-GwgFlvWaDkSbc8H55ktb9LAVw", + "type": "string" + } + ] + }, + "method": "PATCH", + "header": [ + { + "key": "Content-Type", + "name": "Content-Type", + "type": "text", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\r\n \t\"config\": {\r\n \t\t\"hello\": \"test111\"\r\n \t}\r\n }" + }, + "url": { + "raw": "{{api-url}}/projects/metadata/form/{{formKey}}/versions/{{formVersion}}", + "host": [ + "{{api-url}}" + ], + "path": [ + "projects", + "metadata", + "form", + "{{formKey}}", + "versions", + "{{formVersion}}" + ] + } + }, + "response": [] + }, + { + "name": "Delete form", + "request": { + "auth": { + "type": "bearer", + "bearer": [ + { + "key": "token", + "value": "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJyb2xlcyI6WyJUb3Bjb2RlciBVc2VyIiwiYWRtaW5pc3RyYXRvciJdLCJpc3MiOiJodHRwczovL2FwaS50b3Bjb2Rlci1kZXYuY29tIiwiaGFuZGxlIjoidGVzdDEiLCJleHAiOjI1NjMwNzY2ODksInVzZXJJZCI6IjQwMDUxMzMzIiwiaWF0IjoxNDYzMDc2MDg5LCJlbWFpbCI6InRlc3RAdG9wY29kZXIuY29tIiwianRpIjoiYjMzYjc3Y2QtYjUyZS00MGZlLTgzN2UtYmViOGUwYWU2YTRhIn0.wKWUe0-SaiFVN-VR_-GwgFlvWaDkSbc8H55ktb9LAVw", + "type": "string" + } + ] + }, + "method": "DELETE", + "header": [ + { + "key": "Content-Type", + "name": "Content-Type", + "type": "text", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "" + }, + "url": { + "raw": "{{api-url}}/projects/metadata/form/{{formKey}}/versions/{{formVersion}}", + "host": [ + "{{api-url}}" + ], + "path": [ + "projects", + "metadata", + "form", + "{{formKey}}", + "versions", + "{{formVersion}}" + ] + } + }, + "response": [] + } + ], + "protocolProfileBehavior": {} + }, + { + "name": "Form Revision", + "item": [ + { + "name": "List all revision for version", + "request": { + "auth": { + "type": "bearer", + "bearer": [ + { + "key": "token", + "value": "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJyb2xlcyI6WyJUb3Bjb2RlciBVc2VyIiwiYWRtaW5pc3RyYXRvciJdLCJpc3MiOiJodHRwczovL2FwaS50b3Bjb2Rlci1kZXYuY29tIiwiaGFuZGxlIjoidGVzdDEiLCJleHAiOjI1NjMwNzY2ODksInVzZXJJZCI6IjQwMDUxMzMzIiwiaWF0IjoxNDYzMDc2MDg5LCJlbWFpbCI6InRlc3RAdG9wY29kZXIuY29tIiwianRpIjoiYjMzYjc3Y2QtYjUyZS00MGZlLTgzN2UtYmViOGUwYWU2YTRhIn0.wKWUe0-SaiFVN-VR_-GwgFlvWaDkSbc8H55ktb9LAVw", + "type": "string" + } + ] + }, + "method": "GET", + "header": [], + "url": { + "raw": "{{api-url}}/projects/metadata/form/{{formKey}}/versions/{{formVersion}}/revisions", + "host": [ + "{{api-url}}" + ], + "path": [ + "projects", + "metadata", + "form", + "{{formKey}}", + "versions", + "{{formVersion}}", + "revisions" + ] + } + }, + "response": [] + }, + { + "name": "Get a particular revision", + "request": { + "auth": { + "type": "bearer", + "bearer": [ + { + "key": "token", + "value": "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJyb2xlcyI6WyJUb3Bjb2RlciBVc2VyIiwiYWRtaW5pc3RyYXRvciJdLCJpc3MiOiJodHRwczovL2FwaS50b3Bjb2Rlci1kZXYuY29tIiwiaGFuZGxlIjoidGVzdDEiLCJleHAiOjI1NjMwNzY2ODksInVzZXJJZCI6IjQwMDUxMzMzIiwiaWF0IjoxNDYzMDc2MDg5LCJlbWFpbCI6InRlc3RAdG9wY29kZXIuY29tIiwianRpIjoiYjMzYjc3Y2QtYjUyZS00MGZlLTgzN2UtYmViOGUwYWU2YTRhIn0.wKWUe0-SaiFVN-VR_-GwgFlvWaDkSbc8H55ktb9LAVw", + "type": "string" + } + ] + }, + "method": "GET", + "header": [], + "url": { + "raw": "{{api-url}}/projects/metadata/form/{{formKey}}/versions/{{formVersion}}/revisions/{{formRevision}}", + "host": [ + "{{api-url}}" + ], + "path": [ + "projects", + "metadata", + "form", + "{{formKey}}", + "versions", + "{{formVersion}}", + "revisions", + "{{formRevision}}" + ] + } + }, + "response": [] + }, + { + "name": "Create form", + "event": [ + { + "listen": "test", + "script": { + "id": "dbe5ec9f-022c-4ec5-b58c-d19c15430b61", + "exec": [ + "pm.test(\"Status code is 201\", function () {", + " pm.response.to.have.status(201);", + " pm.environment.set(\"formRevision\", pm.response.json().revision);", + "});" + ], + "type": "text/javascript" + } + } + ], + "request": { + "auth": { + "type": "bearer", + "bearer": [ + { + "key": "token", + "value": "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJyb2xlcyI6WyJUb3Bjb2RlciBVc2VyIiwiYWRtaW5pc3RyYXRvciJdLCJpc3MiOiJodHRwczovL2FwaS50b3Bjb2Rlci1kZXYuY29tIiwiaGFuZGxlIjoidGVzdDEiLCJleHAiOjI1NjMwNzY2ODksInVzZXJJZCI6IjQwMDUxMzMzIiwiaWF0IjoxNDYzMDc2MDg5LCJlbWFpbCI6InRlc3RAdG9wY29kZXIuY29tIiwianRpIjoiYjMzYjc3Y2QtYjUyZS00MGZlLTgzN2UtYmViOGUwYWU2YTRhIn0.wKWUe0-SaiFVN-VR_-GwgFlvWaDkSbc8H55ktb9LAVw", + "type": "string" + } + ] + }, + "method": "POST", + "header": [ + { + "key": "Content-Type", + "name": "Content-Type", + "type": "text", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\r\n \t\"config\": {\r\n \t\t\"hello\": \"test\"\r\n \t}\r\n }" + }, + "url": { + "raw": "{{api-url}}/projects/metadata/form/{{formKey}}/versions/{{formVersion}}/revisions", + "host": [ + "{{api-url}}" + ], + "path": [ + "projects", + "metadata", + "form", + "{{formKey}}", + "versions", + "{{formVersion}}", + "revisions" + ] + } + }, + "response": [] + }, + { + "name": "Create for no exist key", + "request": { + "auth": { + "type": "bearer", + "bearer": [ + { + "key": "token", + "value": "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJyb2xlcyI6WyJUb3Bjb2RlciBVc2VyIiwiYWRtaW5pc3RyYXRvciJdLCJpc3MiOiJodHRwczovL2FwaS50b3Bjb2Rlci1kZXYuY29tIiwiaGFuZGxlIjoidGVzdDEiLCJleHAiOjI1NjMwNzY2ODksInVzZXJJZCI6IjQwMDUxMzMzIiwiaWF0IjoxNDYzMDc2MDg5LCJlbWFpbCI6InRlc3RAdG9wY29kZXIuY29tIiwianRpIjoiYjMzYjc3Y2QtYjUyZS00MGZlLTgzN2UtYmViOGUwYWU2YTRhIn0.wKWUe0-SaiFVN-VR_-GwgFlvWaDkSbc8H55ktb9LAVw", + "type": "string" + } + ] + }, + "method": "POST", + "header": [ + { + "key": "Content-Type", + "name": "Content-Type", + "value": "application/json", + "type": "text" + } + ], + "body": { + "mode": "raw", + "raw": "{\r\n \t\"config\": {\r\n \t\t\"hello\": \"test\"\r\n \t}\r\n }" + }, + "url": { + "raw": "{{api-url}}/projects/metadata/form/no-exist-2222key36/versions/1/revisions", + "host": [ + "{{api-url}}" + ], + "path": [ + "projects", + "metadata", + "form", + "no-exist-2222key36", + "versions", + "1", + "revisions" + ] + } + }, + "response": [] + }, + { + "name": "Delete revision", + "request": { + "auth": { + "type": "bearer", + "bearer": [ + { + "key": "token", + "value": "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJyb2xlcyI6WyJUb3Bjb2RlciBVc2VyIiwiYWRtaW5pc3RyYXRvciJdLCJpc3MiOiJodHRwczovL2FwaS50b3Bjb2Rlci1kZXYuY29tIiwiaGFuZGxlIjoidGVzdDEiLCJleHAiOjI1NjMwNzY2ODksInVzZXJJZCI6IjQwMDUxMzMzIiwiaWF0IjoxNDYzMDc2MDg5LCJlbWFpbCI6InRlc3RAdG9wY29kZXIuY29tIiwianRpIjoiYjMzYjc3Y2QtYjUyZS00MGZlLTgzN2UtYmViOGUwYWU2YTRhIn0.wKWUe0-SaiFVN-VR_-GwgFlvWaDkSbc8H55ktb9LAVw", + "type": "string" + } + ] + }, + "method": "DELETE", + "header": [ + { + "key": "Content-Type", + "name": "Content-Type", + "type": "text", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "" + }, + "url": { + "raw": "{{api-url}}/projects/metadata/form/{{formKey}}/versions/{{formVersion}}/revisions/{{formRevision}}", + "host": [ + "{{api-url}}" + ], + "path": [ + "projects", + "metadata", + "form", + "{{formKey}}", + "versions", + "{{formVersion}}", + "revisions", + "{{formRevision}}" + ] + } + }, + "response": [] + } + ], + "protocolProfileBehavior": {} + }, + { + "name": "Price Config Version", + "item": [ + { + "name": "List price configs", + "request": { + "auth": { + "type": "bearer", + "bearer": [ + { + "key": "token", + "value": "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJyb2xlcyI6WyJUb3Bjb2RlciBVc2VyIiwiYWRtaW5pc3RyYXRvciJdLCJpc3MiOiJodHRwczovL2FwaS50b3Bjb2Rlci1kZXYuY29tIiwiaGFuZGxlIjoidGVzdDEiLCJleHAiOjI1NjMwNzY2ODksInVzZXJJZCI6IjQwMDUxMzMzIiwiaWF0IjoxNDYzMDc2MDg5LCJlbWFpbCI6InRlc3RAdG9wY29kZXIuY29tIiwianRpIjoiYjMzYjc3Y2QtYjUyZS00MGZlLTgzN2UtYmViOGUwYWU2YTRhIn0.wKWUe0-SaiFVN-VR_-GwgFlvWaDkSbc8H55ktb9LAVw", + "type": "string" + } + ] + }, + "method": "GET", + "header": [], + "url": { + "raw": "{{api-url}}/projects/metadata/priceConfig/dev/versions", + "host": [ + "{{api-url}}" + ], + "path": [ + "projects", + "metadata", + "priceConfig", + "dev", + "versions" + ] + } + }, + "response": [] + }, + { + "name": "Get a particular version", + "request": { + "auth": { + "type": "bearer", + "bearer": [ + { + "key": "token", + "value": "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJyb2xlcyI6WyJUb3Bjb2RlciBVc2VyIiwiYWRtaW5pc3RyYXRvciJdLCJpc3MiOiJodHRwczovL2FwaS50b3Bjb2Rlci1kZXYuY29tIiwiaGFuZGxlIjoidGVzdDEiLCJleHAiOjI1NjMwNzY2ODksInVzZXJJZCI6IjQwMDUxMzMzIiwiaWF0IjoxNDYzMDc2MDg5LCJlbWFpbCI6InRlc3RAdG9wY29kZXIuY29tIiwianRpIjoiYjMzYjc3Y2QtYjUyZS00MGZlLTgzN2UtYmViOGUwYWU2YTRhIn0.wKWUe0-SaiFVN-VR_-GwgFlvWaDkSbc8H55ktb9LAVw", + "type": "string" + } + ] + }, + "method": "GET", + "header": [], + "url": { + "raw": "{{api-url}}/projects/metadata/priceConfig/{{priceKey}}/versions/{{priceVersion}}", + "host": [ + "{{api-url}}" + ], + "path": [ + "projects", + "metadata", + "priceConfig", + "{{priceKey}}", + "versions", + "{{priceVersion}}" + ] + } + }, + "response": [] + }, + { + "name": "Get latest version", + "request": { + "auth": { + "type": "bearer", + "bearer": [ + { + "key": "token", + "value": "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJyb2xlcyI6WyJUb3Bjb2RlciBVc2VyIiwiYWRtaW5pc3RyYXRvciJdLCJpc3MiOiJodHRwczovL2FwaS50b3Bjb2Rlci1kZXYuY29tIiwiaGFuZGxlIjoidGVzdDEiLCJleHAiOjI1NjMwNzY2ODksInVzZXJJZCI6IjQwMDUxMzMzIiwiaWF0IjoxNDYzMDc2MDg5LCJlbWFpbCI6InRlc3RAdG9wY29kZXIuY29tIiwianRpIjoiYjMzYjc3Y2QtYjUyZS00MGZlLTgzN2UtYmViOGUwYWU2YTRhIn0.wKWUe0-SaiFVN-VR_-GwgFlvWaDkSbc8H55ktb9LAVw", + "type": "string" + } + ] + }, + "method": "GET", + "header": [], + "url": { + "raw": "{{api-url}}/projects/metadata/priceConfig/{{priceKey}}", + "host": [ + "{{api-url}}" + ], + "path": [ + "projects", + "metadata", + "priceConfig", + "{{priceKey}}" + ] + } + }, + "response": [] + }, + { + "name": "Create priceConfig", + "event": [ + { + "listen": "test", + "script": { + "id": "e440c87c-49ff-4443-b9bf-b44d4e9a480f", + "exec": [ + "pm.test(\"Status code is 201\", function () {", + " pm.response.to.have.status(201);", + " pm.environment.set(\"priceKey\", pm.response.json().key);", + " pm.environment.set(\"priceVersion\", pm.response.json().version);", + "});" + ], + "type": "text/javascript" + } + } + ], + "request": { + "auth": { + "type": "bearer", + "bearer": [ + { + "key": "token", + "value": "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJyb2xlcyI6WyJUb3Bjb2RlciBVc2VyIiwiYWRtaW5pc3RyYXRvciJdLCJpc3MiOiJodHRwczovL2FwaS50b3Bjb2Rlci1kZXYuY29tIiwiaGFuZGxlIjoidGVzdDEiLCJleHAiOjI1NjMwNzY2ODksInVzZXJJZCI6IjQwMDUxMzMzIiwiaWF0IjoxNDYzMDc2MDg5LCJlbWFpbCI6InRlc3RAdG9wY29kZXIuY29tIiwianRpIjoiYjMzYjc3Y2QtYjUyZS00MGZlLTgzN2UtYmViOGUwYWU2YTRhIn0.wKWUe0-SaiFVN-VR_-GwgFlvWaDkSbc8H55ktb9LAVw", + "type": "string" + } + ] + }, + "method": "POST", + "header": [ + { + "key": "Content-Type", + "name": "Content-Type", + "type": "text", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\r\n \t\"config\": {\r\n \t\t\"hello\": \"test\"\r\n \t}\r\n }" + }, + "url": { + "raw": "{{api-url}}/projects/metadata/priceConfig/dev/versions", + "host": [ + "{{api-url}}" + ], + "path": [ + "projects", + "metadata", + "priceConfig", + "dev", + "versions" + ] + } + }, + "response": [] + }, + { + "name": "Update priceConfig", + "request": { + "auth": { + "type": "bearer", + "bearer": [ + { + "key": "token", + "value": "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJyb2xlcyI6WyJUb3Bjb2RlciBVc2VyIiwiYWRtaW5pc3RyYXRvciJdLCJpc3MiOiJodHRwczovL2FwaS50b3Bjb2Rlci1kZXYuY29tIiwiaGFuZGxlIjoidGVzdDEiLCJleHAiOjI1NjMwNzY2ODksInVzZXJJZCI6IjQwMDUxMzMzIiwiaWF0IjoxNDYzMDc2MDg5LCJlbWFpbCI6InRlc3RAdG9wY29kZXIuY29tIiwianRpIjoiYjMzYjc3Y2QtYjUyZS00MGZlLTgzN2UtYmViOGUwYWU2YTRhIn0.wKWUe0-SaiFVN-VR_-GwgFlvWaDkSbc8H55ktb9LAVw", + "type": "string" + } + ] + }, + "method": "PATCH", + "header": [ + { + "key": "Content-Type", + "name": "Content-Type", + "type": "text", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\r\n \t\"config\": {\r\n \t\t\"hello\": \"test111\"\r\n \t}\r\n }" + }, + "url": { + "raw": "{{api-url}}/projects/metadata/priceConfig/{{priceKey}}/versions/{{priceVersion}}", + "host": [ + "{{api-url}}" + ], + "path": [ + "projects", + "metadata", + "priceConfig", + "{{priceKey}}", + "versions", + "{{priceVersion}}" + ] + } + }, + "response": [] + }, + { + "name": "Delete priceConfig", + "request": { + "auth": { + "type": "bearer", + "bearer": [ + { + "key": "token", + "value": "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJyb2xlcyI6WyJUb3Bjb2RlciBVc2VyIiwiYWRtaW5pc3RyYXRvciJdLCJpc3MiOiJodHRwczovL2FwaS50b3Bjb2Rlci1kZXYuY29tIiwiaGFuZGxlIjoidGVzdDEiLCJleHAiOjI1NjMwNzY2ODksInVzZXJJZCI6IjQwMDUxMzMzIiwiaWF0IjoxNDYzMDc2MDg5LCJlbWFpbCI6InRlc3RAdG9wY29kZXIuY29tIiwianRpIjoiYjMzYjc3Y2QtYjUyZS00MGZlLTgzN2UtYmViOGUwYWU2YTRhIn0.wKWUe0-SaiFVN-VR_-GwgFlvWaDkSbc8H55ktb9LAVw", + "type": "string" + } + ] + }, + "method": "DELETE", + "header": [ + { + "key": "Content-Type", + "name": "Content-Type", + "type": "text", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "" + }, + "url": { + "raw": "{{api-url}}/projects/metadata/priceConfig/{{priceKey}}/versions/{{priceVersion}}", + "host": [ + "{{api-url}}" + ], + "path": [ + "projects", + "metadata", + "priceConfig", + "{{priceKey}}", + "versions", + "{{priceVersion}}" + ] + } + }, + "response": [] + } + ], + "event": [ + { + "listen": "prerequest", + "script": { + "id": "59182724-4332-4d76-90ea-f7520a7b1be9", + "type": "text/javascript", + "exec": [ + "" + ] + } + }, + { + "listen": "test", + "script": { + "id": "abc13dca-e8a4-4995-970f-00e5889a5f2d", + "type": "text/javascript", + "exec": [ "" ] } } - ] + ], + "protocolProfileBehavior": {} + }, + { + "name": "Price Config Revision", + "item": [ + { + "name": "List all revision for version", + "request": { + "auth": { + "type": "bearer", + "bearer": [ + { + "key": "token", + "value": "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJyb2xlcyI6WyJUb3Bjb2RlciBVc2VyIiwiYWRtaW5pc3RyYXRvciJdLCJpc3MiOiJodHRwczovL2FwaS50b3Bjb2Rlci1kZXYuY29tIiwiaGFuZGxlIjoidGVzdDEiLCJleHAiOjI1NjMwNzY2ODksInVzZXJJZCI6IjQwMDUxMzMzIiwiaWF0IjoxNDYzMDc2MDg5LCJlbWFpbCI6InRlc3RAdG9wY29kZXIuY29tIiwianRpIjoiYjMzYjc3Y2QtYjUyZS00MGZlLTgzN2UtYmViOGUwYWU2YTRhIn0.wKWUe0-SaiFVN-VR_-GwgFlvWaDkSbc8H55ktb9LAVw", + "type": "string" + } + ] + }, + "method": "GET", + "header": [], + "url": { + "raw": "{{api-url}}/projects/metadata/priceConfig/dev/versions/3/revisions", + "host": [ + "{{api-url}}" + ], + "path": [ + "projects", + "metadata", + "priceConfig", + "dev", + "versions", + "3", + "revisions" + ] + } + }, + "response": [] + }, + { + "name": "Get a particular revision", + "request": { + "auth": { + "type": "bearer", + "bearer": [ + { + "key": "token", + "value": "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJyb2xlcyI6WyJUb3Bjb2RlciBVc2VyIiwiYWRtaW5pc3RyYXRvciJdLCJpc3MiOiJodHRwczovL2FwaS50b3Bjb2Rlci1kZXYuY29tIiwiaGFuZGxlIjoidGVzdDEiLCJleHAiOjI1NjMwNzY2ODksInVzZXJJZCI6IjQwMDUxMzMzIiwiaWF0IjoxNDYzMDc2MDg5LCJlbWFpbCI6InRlc3RAdG9wY29kZXIuY29tIiwianRpIjoiYjMzYjc3Y2QtYjUyZS00MGZlLTgzN2UtYmViOGUwYWU2YTRhIn0.wKWUe0-SaiFVN-VR_-GwgFlvWaDkSbc8H55ktb9LAVw", + "type": "string" + } + ] + }, + "method": "GET", + "header": [], + "url": { + "raw": "{{api-url}}/projects/metadata/priceConfig/{{priceKey}}/versions/{{priceVersion}}/revisions/{{priceRevision}}", + "host": [ + "{{api-url}}" + ], + "path": [ + "projects", + "metadata", + "priceConfig", + "{{priceKey}}", + "versions", + "{{priceVersion}}", + "revisions", + "{{priceRevision}}" + ] + } + }, + "response": [] + }, + { + "name": "Create price config", + "event": [ + { + "listen": "test", + "script": { + "id": "d53ed608-b21c-4d6f-bb68-c2beda1d631d", + "exec": [ + "pm.test(\"Status code is 201\", function () {", + " pm.response.to.have.status(201);", + " pm.environment.set(\"priceRevision\", pm.response.json().revision);", + "});" + ], + "type": "text/javascript" + } + } + ], + "request": { + "auth": { + "type": "bearer", + "bearer": [ + { + "key": "token", + "value": "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJyb2xlcyI6WyJUb3Bjb2RlciBVc2VyIiwiYWRtaW5pc3RyYXRvciJdLCJpc3MiOiJodHRwczovL2FwaS50b3Bjb2Rlci1kZXYuY29tIiwiaGFuZGxlIjoidGVzdDEiLCJleHAiOjI1NjMwNzY2ODksInVzZXJJZCI6IjQwMDUxMzMzIiwiaWF0IjoxNDYzMDc2MDg5LCJlbWFpbCI6InRlc3RAdG9wY29kZXIuY29tIiwianRpIjoiYjMzYjc3Y2QtYjUyZS00MGZlLTgzN2UtYmViOGUwYWU2YTRhIn0.wKWUe0-SaiFVN-VR_-GwgFlvWaDkSbc8H55ktb9LAVw", + "type": "string" + } + ] + }, + "method": "POST", + "header": [ + { + "key": "Content-Type", + "name": "Content-Type", + "value": "application/json", + "type": "text" + } + ], + "body": { + "mode": "raw", + "raw": "{\r\n \t\"config\": {\r\n \t\t\"hello\": \"test\"\r\n \t}\r\n }" + }, + "url": { + "raw": "{{api-url}}/projects/metadata/priceConfig/{{priceKey}}/versions/{{priceVersion}}/revisions", + "host": [ + "{{api-url}}" + ], + "path": [ + "projects", + "metadata", + "priceConfig", + "{{priceKey}}", + "versions", + "{{priceVersion}}", + "revisions" + ] + } + }, + "response": [] + }, + { + "name": "Create for no exist key", + "request": { + "auth": { + "type": "bearer", + "bearer": [ + { + "key": "token", + "value": "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJyb2xlcyI6WyJUb3Bjb2RlciBVc2VyIiwiYWRtaW5pc3RyYXRvciJdLCJpc3MiOiJodHRwczovL2FwaS50b3Bjb2Rlci1kZXYuY29tIiwiaGFuZGxlIjoidGVzdDEiLCJleHAiOjI1NjMwNzY2ODksInVzZXJJZCI6IjQwMDUxMzMzIiwiaWF0IjoxNDYzMDc2MDg5LCJlbWFpbCI6InRlc3RAdG9wY29kZXIuY29tIiwianRpIjoiYjMzYjc3Y2QtYjUyZS00MGZlLTgzN2UtYmViOGUwYWU2YTRhIn0.wKWUe0-SaiFVN-VR_-GwgFlvWaDkSbc8H55ktb9LAVw", + "type": "string" + } + ] + }, + "method": "POST", + "header": [ + { + "key": "Content-Type", + "name": "Content-Type", + "type": "text", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\r\n \t\"config\": {\r\n \t\t\"hello\": \"test\"\r\n \t}\r\n }" + }, + "url": { + "raw": "{{api-url}}/projects/metadata/priceConfig/no-exist-key/versions/1/revisions", + "host": [ + "{{api-url}}" + ], + "path": [ + "projects", + "metadata", + "priceConfig", + "no-exist-key", + "versions", + "1", + "revisions" + ] + } + }, + "response": [] + }, + { + "name": "Delete revision", + "request": { + "auth": { + "type": "bearer", + "bearer": [ + { + "key": "token", + "value": "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJyb2xlcyI6WyJUb3Bjb2RlciBVc2VyIiwiYWRtaW5pc3RyYXRvciJdLCJpc3MiOiJodHRwczovL2FwaS50b3Bjb2Rlci1kZXYuY29tIiwiaGFuZGxlIjoidGVzdDEiLCJleHAiOjI1NjMwNzY2ODksInVzZXJJZCI6IjQwMDUxMzMzIiwiaWF0IjoxNDYzMDc2MDg5LCJlbWFpbCI6InRlc3RAdG9wY29kZXIuY29tIiwianRpIjoiYjMzYjc3Y2QtYjUyZS00MGZlLTgzN2UtYmViOGUwYWU2YTRhIn0.wKWUe0-SaiFVN-VR_-GwgFlvWaDkSbc8H55ktb9LAVw", + "type": "string" + } + ] + }, + "method": "DELETE", + "header": [ + { + "key": "Content-Type", + "name": "Content-Type", + "type": "text", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "" + }, + "url": { + "raw": "{{api-url}}/projects/metadata/priceConfig/{{priceKey}}/versions/{{priceVersion}}/revisions/{{priceRevision}}", + "host": [ + "{{api-url}}" + ], + "path": [ + "projects", + "metadata", + "priceConfig", + "{{priceKey}}", + "versions", + "{{priceVersion}}", + "revisions", + "{{priceRevision}}" + ] + } + }, + "response": [] + } + ], + "protocolProfileBehavior": {} + }, + { + "name": "Plan Config Version", + "item": [ + { + "name": "List plan configs", + "request": { + "auth": { + "type": "bearer", + "bearer": [ + { + "key": "token", + "value": "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJyb2xlcyI6WyJUb3Bjb2RlciBVc2VyIiwiYWRtaW5pc3RyYXRvciJdLCJpc3MiOiJodHRwczovL2FwaS50b3Bjb2Rlci1kZXYuY29tIiwiaGFuZGxlIjoidGVzdDEiLCJleHAiOjI1NjMwNzY2ODksInVzZXJJZCI6IjQwMDUxMzMzIiwiaWF0IjoxNDYzMDc2MDg5LCJlbWFpbCI6InRlc3RAdG9wY29kZXIuY29tIiwianRpIjoiYjMzYjc3Y2QtYjUyZS00MGZlLTgzN2UtYmViOGUwYWU2YTRhIn0.wKWUe0-SaiFVN-VR_-GwgFlvWaDkSbc8H55ktb9LAVw", + "type": "string" + } + ] + }, + "method": "GET", + "header": [], + "url": { + "raw": "{{api-url}}/projects/metadata/planConfig/dev/versions", + "host": [ + "{{api-url}}" + ], + "path": [ + "projects", + "metadata", + "planConfig", + "dev", + "versions" + ] + } + }, + "response": [] + }, + { + "name": "Get a particular version", + "request": { + "auth": { + "type": "bearer", + "bearer": [ + { + "key": "token", + "value": "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJyb2xlcyI6WyJUb3Bjb2RlciBVc2VyIiwiYWRtaW5pc3RyYXRvciJdLCJpc3MiOiJodHRwczovL2FwaS50b3Bjb2Rlci1kZXYuY29tIiwiaGFuZGxlIjoidGVzdDEiLCJleHAiOjI1NjMwNzY2ODksInVzZXJJZCI6IjQwMDUxMzMzIiwiaWF0IjoxNDYzMDc2MDg5LCJlbWFpbCI6InRlc3RAdG9wY29kZXIuY29tIiwianRpIjoiYjMzYjc3Y2QtYjUyZS00MGZlLTgzN2UtYmViOGUwYWU2YTRhIn0.wKWUe0-SaiFVN-VR_-GwgFlvWaDkSbc8H55ktb9LAVw", + "type": "string" + } + ] + }, + "method": "GET", + "header": [], + "url": { + "raw": "{{api-url}}/projects/metadata/planConfig/{{planKey}}/versions/{{planVersion}}", + "host": [ + "{{api-url}}" + ], + "path": [ + "projects", + "metadata", + "planConfig", + "{{planKey}}", + "versions", + "{{planVersion}}" + ] + } + }, + "response": [] + }, + { + "name": "Get latest version", + "request": { + "auth": { + "type": "bearer", + "bearer": [ + { + "key": "token", + "value": "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJyb2xlcyI6WyJUb3Bjb2RlciBVc2VyIiwiYWRtaW5pc3RyYXRvciJdLCJpc3MiOiJodHRwczovL2FwaS50b3Bjb2Rlci1kZXYuY29tIiwiaGFuZGxlIjoidGVzdDEiLCJleHAiOjI1NjMwNzY2ODksInVzZXJJZCI6IjQwMDUxMzMzIiwiaWF0IjoxNDYzMDc2MDg5LCJlbWFpbCI6InRlc3RAdG9wY29kZXIuY29tIiwianRpIjoiYjMzYjc3Y2QtYjUyZS00MGZlLTgzN2UtYmViOGUwYWU2YTRhIn0.wKWUe0-SaiFVN-VR_-GwgFlvWaDkSbc8H55ktb9LAVw", + "type": "string" + } + ] + }, + "method": "GET", + "header": [], + "url": { + "raw": "{{api-url}}/projects/metadata/planConfig/{{planKey}}", + "host": [ + "{{api-url}}" + ], + "path": [ + "projects", + "metadata", + "planConfig", + "{{planKey}}" + ] + } + }, + "response": [] + }, + { + "name": "Create plan config", + "event": [ + { + "listen": "test", + "script": { + "id": "97bc350a-0c4f-46a6-a315-a62b203b3ad2", + "exec": [ + "pm.test(\"Status code is 201\", function () {", + " pm.response.to.have.status(201);", + " pm.environment.set(\"planKey\", pm.response.json().key);", + " pm.environment.set(\"planVersion\", pm.response.json().version);", + "});" + ], + "type": "text/javascript" + } + } + ], + "request": { + "auth": { + "type": "bearer", + "bearer": [ + { + "key": "token", + "value": "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJyb2xlcyI6WyJUb3Bjb2RlciBVc2VyIiwiYWRtaW5pc3RyYXRvciJdLCJpc3MiOiJodHRwczovL2FwaS50b3Bjb2Rlci1kZXYuY29tIiwiaGFuZGxlIjoidGVzdDEiLCJleHAiOjI1NjMwNzY2ODksInVzZXJJZCI6IjQwMDUxMzMzIiwiaWF0IjoxNDYzMDc2MDg5LCJlbWFpbCI6InRlc3RAdG9wY29kZXIuY29tIiwianRpIjoiYjMzYjc3Y2QtYjUyZS00MGZlLTgzN2UtYmViOGUwYWU2YTRhIn0.wKWUe0-SaiFVN-VR_-GwgFlvWaDkSbc8H55ktb9LAVw", + "type": "string" + } + ] + }, + "method": "POST", + "header": [ + { + "key": "Content-Type", + "name": "Content-Type", + "type": "text", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\r\n \t\"config\": {\r\n \t\t\"hello\": \"test\"\r\n \t}\r\n }" + }, + "url": { + "raw": "{{api-url}}/projects/metadata/planConfig/dev/versions", + "host": [ + "{{api-url}}" + ], + "path": [ + "projects", + "metadata", + "planConfig", + "dev", + "versions" + ] + } + }, + "response": [] + }, + { + "name": "Update plan config", + "request": { + "auth": { + "type": "bearer", + "bearer": [ + { + "key": "token", + "value": "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJyb2xlcyI6WyJUb3Bjb2RlciBVc2VyIiwiYWRtaW5pc3RyYXRvciJdLCJpc3MiOiJodHRwczovL2FwaS50b3Bjb2Rlci1kZXYuY29tIiwiaGFuZGxlIjoidGVzdDEiLCJleHAiOjI1NjMwNzY2ODksInVzZXJJZCI6IjQwMDUxMzMzIiwiaWF0IjoxNDYzMDc2MDg5LCJlbWFpbCI6InRlc3RAdG9wY29kZXIuY29tIiwianRpIjoiYjMzYjc3Y2QtYjUyZS00MGZlLTgzN2UtYmViOGUwYWU2YTRhIn0.wKWUe0-SaiFVN-VR_-GwgFlvWaDkSbc8H55ktb9LAVw", + "type": "string" + } + ] + }, + "method": "PATCH", + "header": [ + { + "key": "Content-Type", + "name": "Content-Type", + "type": "text", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\r\n \t\"config\": {\r\n \t\t\"hello\": \"test111\"\r\n \t}\r\n }" + }, + "url": { + "raw": "{{api-url}}/projects/metadata/planConfig/{{planKey}}/versions/{{planVersion}}", + "host": [ + "{{api-url}}" + ], + "path": [ + "projects", + "metadata", + "planConfig", + "{{planKey}}", + "versions", + "{{planVersion}}" + ] + } + }, + "response": [] + }, + { + "name": "Delete plan config", + "request": { + "auth": { + "type": "bearer", + "bearer": [ + { + "key": "token", + "value": "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJyb2xlcyI6WyJUb3Bjb2RlciBVc2VyIiwiYWRtaW5pc3RyYXRvciJdLCJpc3MiOiJodHRwczovL2FwaS50b3Bjb2Rlci1kZXYuY29tIiwiaGFuZGxlIjoidGVzdDEiLCJleHAiOjI1NjMwNzY2ODksInVzZXJJZCI6IjQwMDUxMzMzIiwiaWF0IjoxNDYzMDc2MDg5LCJlbWFpbCI6InRlc3RAdG9wY29kZXIuY29tIiwianRpIjoiYjMzYjc3Y2QtYjUyZS00MGZlLTgzN2UtYmViOGUwYWU2YTRhIn0.wKWUe0-SaiFVN-VR_-GwgFlvWaDkSbc8H55ktb9LAVw", + "type": "string" + } + ] + }, + "method": "DELETE", + "header": [ + { + "key": "Content-Type", + "name": "Content-Type", + "type": "text", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "" + }, + "url": { + "raw": "{{api-url}}/projects/metadata/planConfig/{{planKey}}/versions/{{planVersion}}", + "host": [ + "{{api-url}}" + ], + "path": [ + "projects", + "metadata", + "planConfig", + "{{planKey}}", + "versions", + "{{planVersion}}" + ] + } + }, + "response": [] + } + ], + "protocolProfileBehavior": {} + }, + { + "name": "Plan Config Revision", + "item": [ + { + "name": "List all revision for version", + "request": { + "auth": { + "type": "bearer", + "bearer": [ + { + "key": "token", + "value": "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJyb2xlcyI6WyJUb3Bjb2RlciBVc2VyIiwiYWRtaW5pc3RyYXRvciJdLCJpc3MiOiJodHRwczovL2FwaS50b3Bjb2Rlci1kZXYuY29tIiwiaGFuZGxlIjoidGVzdDEiLCJleHAiOjI1NjMwNzY2ODksInVzZXJJZCI6IjQwMDUxMzMzIiwiaWF0IjoxNDYzMDc2MDg5LCJlbWFpbCI6InRlc3RAdG9wY29kZXIuY29tIiwianRpIjoiYjMzYjc3Y2QtYjUyZS00MGZlLTgzN2UtYmViOGUwYWU2YTRhIn0.wKWUe0-SaiFVN-VR_-GwgFlvWaDkSbc8H55ktb9LAVw", + "type": "string" + } + ] + }, + "method": "GET", + "header": [], + "url": { + "raw": "{{api-url}}/projects/metadata/planConfig/{{planKey}}/versions/{{planVersion}}/revisions", + "host": [ + "{{api-url}}" + ], + "path": [ + "projects", + "metadata", + "planConfig", + "{{planKey}}", + "versions", + "{{planVersion}}", + "revisions" + ] + } + }, + "response": [] + }, + { + "name": "Get a particular revision", + "request": { + "auth": { + "type": "bearer", + "bearer": [ + { + "key": "token", + "value": "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJyb2xlcyI6WyJUb3Bjb2RlciBVc2VyIiwiYWRtaW5pc3RyYXRvciJdLCJpc3MiOiJodHRwczovL2FwaS50b3Bjb2Rlci1kZXYuY29tIiwiaGFuZGxlIjoidGVzdDEiLCJleHAiOjI1NjMwNzY2ODksInVzZXJJZCI6IjQwMDUxMzMzIiwiaWF0IjoxNDYzMDc2MDg5LCJlbWFpbCI6InRlc3RAdG9wY29kZXIuY29tIiwianRpIjoiYjMzYjc3Y2QtYjUyZS00MGZlLTgzN2UtYmViOGUwYWU2YTRhIn0.wKWUe0-SaiFVN-VR_-GwgFlvWaDkSbc8H55ktb9LAVw", + "type": "string" + } + ] + }, + "method": "GET", + "header": [], + "url": { + "raw": "{{api-url}}/projects/metadata/planConfig/{{planKey}}/versions/{{planVersion}}/revisions/{{planRevision}}", + "host": [ + "{{api-url}}" + ], + "path": [ + "projects", + "metadata", + "planConfig", + "{{planKey}}", + "versions", + "{{planVersion}}", + "revisions", + "{{planRevision}}" + ] + } + }, + "response": [] + }, + { + "name": "Create plan config", + "event": [ + { + "listen": "test", + "script": { + "id": "a5373f1f-4beb-46f9-8538-10c938c204ba", + "exec": [ + "pm.test(\"Status code is 201\", function () {", + " pm.response.to.have.status(201);", + " pm.environment.set(\"planRevision\", pm.response.json().revision);", + "});" + ], + "type": "text/javascript" + } + } + ], + "request": { + "auth": { + "type": "bearer", + "bearer": [ + { + "key": "token", + "value": "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJyb2xlcyI6WyJUb3Bjb2RlciBVc2VyIiwiYWRtaW5pc3RyYXRvciJdLCJpc3MiOiJodHRwczovL2FwaS50b3Bjb2Rlci1kZXYuY29tIiwiaGFuZGxlIjoidGVzdDEiLCJleHAiOjI1NjMwNzY2ODksInVzZXJJZCI6IjQwMDUxMzMzIiwiaWF0IjoxNDYzMDc2MDg5LCJlbWFpbCI6InRlc3RAdG9wY29kZXIuY29tIiwianRpIjoiYjMzYjc3Y2QtYjUyZS00MGZlLTgzN2UtYmViOGUwYWU2YTRhIn0.wKWUe0-SaiFVN-VR_-GwgFlvWaDkSbc8H55ktb9LAVw", + "type": "string" + } + ] + }, + "method": "POST", + "header": [ + { + "key": "Content-Type", + "name": "Content-Type", + "value": "application/json", + "type": "text" + } + ], + "body": { + "mode": "raw", + "raw": "{\r\n \t\"config\": {\r\n \t\t\"hello\": \"test\"\r\n \t}\r\n }" + }, + "url": { + "raw": "{{api-url}}/projects/metadata/planConfig/{{planKey}}/versions/{{planVersion}}/revisions", + "host": [ + "{{api-url}}" + ], + "path": [ + "projects", + "metadata", + "planConfig", + "{{planKey}}", + "versions", + "{{planVersion}}", + "revisions" + ] + } + }, + "response": [] + }, + { + "name": "Create for no exist key", + "request": { + "auth": { + "type": "bearer", + "bearer": [ + { + "key": "token", + "value": "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJyb2xlcyI6WyJUb3Bjb2RlciBVc2VyIiwiYWRtaW5pc3RyYXRvciJdLCJpc3MiOiJodHRwczovL2FwaS50b3Bjb2Rlci1kZXYuY29tIiwiaGFuZGxlIjoidGVzdDEiLCJleHAiOjI1NjMwNzY2ODksInVzZXJJZCI6IjQwMDUxMzMzIiwiaWF0IjoxNDYzMDc2MDg5LCJlbWFpbCI6InRlc3RAdG9wY29kZXIuY29tIiwianRpIjoiYjMzYjc3Y2QtYjUyZS00MGZlLTgzN2UtYmViOGUwYWU2YTRhIn0.wKWUe0-SaiFVN-VR_-GwgFlvWaDkSbc8H55ktb9LAVw", + "type": "string" + } + ] + }, + "method": "POST", + "header": [ + { + "key": "Content-Type", + "name": "Content-Type", + "type": "text", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\r\n \t\"config\": {\r\n \t\t\"hello\": \"test\"\r\n \t}\r\n }" + }, + "url": { + "raw": "{{api-url}}/projects/metadata/planConfig/no-exist-key/versions/1/revisions", + "host": [ + "{{api-url}}" + ], + "path": [ + "projects", + "metadata", + "planConfig", + "no-exist-key", + "versions", + "1", + "revisions" + ] + } + }, + "response": [] + }, + { + "name": "Delete revision", + "request": { + "auth": { + "type": "bearer", + "bearer": [ + { + "key": "token", + "value": "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJyb2xlcyI6WyJUb3Bjb2RlciBVc2VyIiwiYWRtaW5pc3RyYXRvciJdLCJpc3MiOiJodHRwczovL2FwaS50b3Bjb2Rlci1kZXYuY29tIiwiaGFuZGxlIjoidGVzdDEiLCJleHAiOjI1NjMwNzY2ODksInVzZXJJZCI6IjQwMDUxMzMzIiwiaWF0IjoxNDYzMDc2MDg5LCJlbWFpbCI6InRlc3RAdG9wY29kZXIuY29tIiwianRpIjoiYjMzYjc3Y2QtYjUyZS00MGZlLTgzN2UtYmViOGUwYWU2YTRhIn0.wKWUe0-SaiFVN-VR_-GwgFlvWaDkSbc8H55ktb9LAVw", + "type": "string" + } + ] + }, + "method": "DELETE", + "header": [ + { + "key": "Content-Type", + "name": "Content-Type", + "type": "text", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "" + }, + "url": { + "raw": "{{api-url}}/projects/metadata/planConfig/{{planKey}}/versions/{{planVersion}}/revisions/{{planRevision}}", + "host": [ + "{{api-url}}" + ], + "path": [ + "projects", + "metadata", + "planConfig", + "{{planKey}}", + "versions", + "{{planVersion}}", + "revisions", + "{{planRevision}}" + ] + } + }, + "response": [] + } + ], + "protocolProfileBehavior": {} + }, + { + "name": "Project Reports", + "item": [ + { + "name": "summary", + "item": [ + { + "name": "get report by admin", + "request": { + "method": "GET", + "header": [ + { + "key": "Authorization", + "value": "Bearer {{jwt-token-admin-40051333}}", + "type": "text" + } + ], + "url": { + "raw": "{{api-url}}/projects/{{projectId}}/reports?reportName=summary", + "host": [ + "{{api-url}}" + ], + "path": [ + "projects", + "{{projectId}}", + "reports" + ], + "query": [ + { + "key": "reportName", + "value": "summary" + } + ] + } + }, + "response": [] + }, + { + "name": "get report by member", + "request": { + "method": "GET", + "header": [ + { + "key": "Authorization", + "value": "Bearer {{jwt-token-member2-40051335}}", + "type": "text" + } + ], + "url": { + "raw": "{{api-url}}/projects/{{projectId}}/reports?reportName=summary", + "host": [ + "{{api-url}}" + ], + "path": [ + "projects", + "{{projectId}}", + "reports" + ], + "query": [ + { + "key": "reportName", + "value": "summary" + } + ] + } + }, + "response": [] + }, + { + "name": "get report with invalid project id", + "request": { + "method": "GET", + "header": [ + { + "key": "Authorization", + "type": "text", + "value": "Bearer {{jwt-token-admin-40051333}}" + } + ], + "url": { + "raw": "{{api-url}}/projects/123456/reports?reportName=summary", + "host": [ + "{{api-url}}" + ], + "path": [ + "projects", + "123456", + "reports" + ], + "query": [ + { + "key": "reportName", + "value": "summary" + } + ] + } + }, + "response": [] + }, + { + "name": "get report with invalid report name", + "request": { + "method": "GET", + "header": [ + { + "key": "Authorization", + "type": "text", + "value": "Bearer {{jwt-token-admin-40051333}}" + } + ], + "url": { + "raw": "{{api-url}}/projects/{{projectId}}/reports?reportName=summary123", + "host": [ + "{{api-url}}" + ], + "path": [ + "projects", + "{{projectId}}", + "reports" + ], + "query": [ + { + "key": "reportName", + "value": "summary123" + } + ] + } + }, + "response": [] + } + ], + "protocolProfileBehavior": {}, + "_postman_isSubFolder": true + }, + { + "name": "projectBudget", + "item": [ + { + "name": "get report by admin", + "request": { + "method": "GET", + "header": [ + { + "key": "Authorization", + "type": "text", + "value": "Bearer {{jwt-token-admin-40051333}}" + } + ], + "url": { + "raw": "{{api-url}}/projects/{{projectId}}/reports?reportName=projectBudget", + "host": [ + "{{api-url}}" + ], + "path": [ + "projects", + "{{projectId}}", + "reports" + ], + "query": [ + { + "key": "reportName", + "value": "projectBudget" + } + ] + } + }, + "response": [] + } + ], + "protocolProfileBehavior": {}, + "_postman_isSubFolder": true + } + ], + "protocolProfileBehavior": {} }, { - "name": "Price Config Revision", + "name": "Project Setting", "item": [ { - "name": "List all revision for version", + "name": "Create project setting - double", + "event": [ + { + "listen": "test", + "script": { + "id": "7350de08-5111-44f8-8a4c-3d0c48bcd8d4", + "exec": [ + "pm.test(\"Status code is 201\", function () {", + " pm.response.to.have.status(201);", + " pm.environment.set(\"settingId\", pm.response.json().id);", + "})" + ], + "type": "text/javascript" + } + } + ], "request": { - "auth": { - "type": "bearer", - "bearer": [ - { - "key": "token", - "value": "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJyb2xlcyI6WyJUb3Bjb2RlciBVc2VyIiwiYWRtaW5pc3RyYXRvciJdLCJpc3MiOiJodHRwczovL2FwaS50b3Bjb2Rlci1kZXYuY29tIiwiaGFuZGxlIjoidGVzdDEiLCJleHAiOjI1NjMwNzY2ODksInVzZXJJZCI6IjQwMDUxMzMzIiwiaWF0IjoxNDYzMDc2MDg5LCJlbWFpbCI6InRlc3RAdG9wY29kZXIuY29tIiwianRpIjoiYjMzYjc3Y2QtYjUyZS00MGZlLTgzN2UtYmViOGUwYWU2YTRhIn0.wKWUe0-SaiFVN-VR_-GwgFlvWaDkSbc8H55ktb9LAVw", - "type": "string" - } + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + }, + { + "key": "Authorization", + "value": "Bearer {{jwt-token-admin-40051333}}" + } + ], + "body": { + "mode": "raw", + "raw": "{\r\n \"key\": \"markup_topcoder_service\",\r\n \"value\": \"1000\",\r\n \"valueType\": \"double\",\r\n \"projectId\": 1,\r\n\t\"writePermission\": {\r\n\t \t\"allowRule\": {\r\n\t \t\"projectRoles\": [\"account_manager\"],\r\n\t \t\"topcoderRoles\": [\"administrator\", \"Connect Admin\"]\r\n\t },\r\n\t \t\"denyRule\": {\r\n\t \t\"topcoderRoles\": [\"Connect Copilot Manager\"]\r\n\t }\r\n\t },\r\n\t\"readPermission\": {\r\n\t \t\"projectRoles\": [\"manager\"],\r\n\t \"topcoderRoles\": [\"administrator\", \"Connect Admin\", \"Connect Account Manager\"]\r\n\t },\r\n\t\"metadata\": {}\r\n}" + }, + "url": { + "raw": "{{api-url}}/projects/{{projectId}}/settings", + "host": [ + "{{api-url}}" + ], + "path": [ + "projects", + "{{projectId}}", + "settings" ] + } + }, + "response": [] + }, + { + "name": "Create project setting - percentage", + "event": [ + { + "listen": "test", + "script": { + "id": "bf3aa19f-517c-4103-9250-82d7847e7477", + "exec": [ + "" + ], + "type": "text/javascript" + } + } + ], + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + }, + { + "key": "Authorization", + "value": "Bearer {{jwt-token-admin-40051333}}" + } + ], + "body": { + "mode": "raw", + "raw": "{\r\n \"key\": \"markup_fee\",\r\n \"value\": \"18.88\",\r\n \"valueType\": \"percentage\",\r\n\t\"writePermission\": {\r\n\t \t\"allowRule\": {\r\n\t \t\"projectRoles\": [\"account_manager\"]\r\n\t },\r\n\t \t\"denyRule\": {\r\n\t \t\"topcoderRoles\": [\"Connect Copilot Manager\"]\r\n\t }\r\n\t },\r\n\t\"readPermission\": {\r\n\t \"topcoderRoles\": [\"Connect Copilot\"]\r\n\t },\r\n\t\"metadata\": {}\r\n}" }, - "method": "GET", - "header": [], "url": { - "raw": "{{api-url}}/projects/metadata/priceConfig/dev/versions/3/revisions", + "raw": "{{api-url}}/projects/{{projectId}}/settings", "host": [ "{{api-url}}" ], "path": [ "projects", - "metadata", - "priceConfig", - "dev", - "versions", - "3", - "revisions" + "{{projectId}}", + "settings" ] } }, "response": [] }, { - "name": "Get a particular revision", + "name": "Create project setting - for project = 2", + "event": [ + { + "listen": "test", + "script": { + "id": "7350de08-5111-44f8-8a4c-3d0c48bcd8d4", + "exec": [ + "" + ], + "type": "text/javascript" + } + } + ], "request": { - "auth": { - "type": "bearer", - "bearer": [ - { - "key": "token", - "value": "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJyb2xlcyI6WyJUb3Bjb2RlciBVc2VyIiwiYWRtaW5pc3RyYXRvciJdLCJpc3MiOiJodHRwczovL2FwaS50b3Bjb2Rlci1kZXYuY29tIiwiaGFuZGxlIjoidGVzdDEiLCJleHAiOjI1NjMwNzY2ODksInVzZXJJZCI6IjQwMDUxMzMzIiwiaWF0IjoxNDYzMDc2MDg5LCJlbWFpbCI6InRlc3RAdG9wY29kZXIuY29tIiwianRpIjoiYjMzYjc3Y2QtYjUyZS00MGZlLTgzN2UtYmViOGUwYWU2YTRhIn0.wKWUe0-SaiFVN-VR_-GwgFlvWaDkSbc8H55ktb9LAVw", - "type": "string" - } + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + }, + { + "key": "Authorization", + "value": "Bearer {{jwt-token-admin-40051333}}" + } + ], + "body": { + "mode": "raw", + "raw": "{\r\n \"key\": \"markup_topcoder_service\",\r\n \"value\": \"2222\",\r\n \"valueType\": \"double\",\r\n\t\"writePermission\": {\r\n\t \t\"allowRule\": {\r\n\t \t\"projectRoles\": [\"account_manager\"],\r\n\t \t\"topcoderRoles\": [\"administrator\", \"Connect Admin\"]\r\n\t },\r\n\t \t\"denyRule\": {\r\n\t \t\"topcoderRoles\": [\"Connect Copilot Manager\"]\r\n\t }\r\n\t },\r\n\t\"readPermission\": {\r\n\t \t\"projectRoles\": [\"manager\"],\r\n\t \"topcoderRoles\": [\"administrator\", \"Connect Admin\", \"Connect Account Manager\"]\r\n\t },\r\n\t\"metadata\": {}\r\n}" + }, + "url": { + "raw": "{{api-url}}/projects/2/settings", + "host": [ + "{{api-url}}" + ], + "path": [ + "projects", + "2", + "settings" + ] + } + }, + "response": [] + }, + { + "name": "Create project setting - another estimation type", + "event": [ + { + "listen": "test", + "script": { + "id": "7350de08-5111-44f8-8a4c-3d0c48bcd8d4", + "exec": [ + "" + ], + "type": "text/javascript" + } + } + ], + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + }, + { + "key": "Authorization", + "value": "Bearer {{jwt-token}}" + } + ], + "body": { + "mode": "raw", + "raw": "{\r\n \"key\": \"markup_reference_program\",\r\n \"value\": \"17800\",\r\n \"valueType\": \"double\",\r\n\t\"writePermission\": {\r\n\t \t\"allowRule\": {\r\n\t \t\"projectRoles\": [\"account_manager\"],\r\n\t \t\"topcoderRoles\": [\"administrator\", \"Connect Admin\"]\r\n\t },\r\n\t \t\"denyRule\": {\r\n\t \t\"topcoderRoles\": [\"Connect Copilot Manager\"]\r\n\t }\r\n\t },\r\n\t\"readPermission\": {\r\n\t \t\"projectRoles\": [\"manager\", \"copilot\"],\r\n\t \"topcoderRoles\": [\"administrator\", \"Connect Admin\", \"Connect Account Manager\"]\r\n\t },\r\n\t\"metadata\": {}\r\n}" + }, + "url": { + "raw": "{{api-url}}/projects/{{projectId}}/settings", + "host": [ + "{{api-url}}" + ], + "path": [ + "projects", + "{{projectId}}", + "settings" ] + } + }, + "response": [] + }, + { + "name": "Create project setting with non estimation type", + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + }, + { + "key": "Authorization", + "value": "Bearer {{jwt-token}}" + } + ], + "body": { + "mode": "raw", + "raw": "{\r\n \"key\": \"markup_non_estimation\",\r\n \"value\": \"8765\",\r\n \"valueType\": \"string\",\r\n\t\"writePermission\": {\r\n\t \t\"allowRule\": {\r\n\t \t\"projectRoles\": [\"account_manager\"],\r\n\t \t\"topcoderRoles\": [\"administrator\", \"Connect Admin\"]\r\n\t },\r\n\t \t\"denyRule\": {\r\n\t \t\"topcoderRoles\": [\"Connect Copilot Manager\"]\r\n\t }\r\n\t },\r\n\t \"readPermission\": {\r\n\t \t\"projectRoles\": [\"manager\"],\r\n\t \"topcoderRoles\": [\"administrator\", \"Connect Admin\", \"Connect Account Manager\"]\r\n\t },\r\n\t\"metadata\": {}\r\n}" }, - "method": "GET", - "header": [], "url": { - "raw": "{{api-url}}/projects/metadata/priceConfig/{{priceKey}}/versions/{{priceVersion}}/revisions/{{priceRevision}}", + "raw": "{{api-url}}/projects/{{projectId}}/settings", "host": [ "{{api-url}}" ], "path": [ "projects", - "metadata", - "priceConfig", - "{{priceKey}}", - "versions", - "{{priceVersion}}", - "revisions", - "{{priceRevision}}" + "{{projectId}}", + "settings" ] } }, "response": [] }, { - "name": "Create price config", + "name": "Create project setting - duplicate key", "event": [ { "listen": "test", "script": { - "id": "d53ed608-b21c-4d6f-bb68-c2beda1d631d", + "id": "7350de08-5111-44f8-8a4c-3d0c48bcd8d4", "exec": [ - "pm.test(\"Status code is 201\", function () {", - " pm.response.to.have.status(201);", - " pm.environment.set(\"priceRevision\", pm.response.json().revision);", - "});" + "" ], "type": "text/javascript" } } ], "request": { - "auth": { - "type": "bearer", - "bearer": [ - { - "key": "token", - "value": "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJyb2xlcyI6WyJUb3Bjb2RlciBVc2VyIiwiYWRtaW5pc3RyYXRvciJdLCJpc3MiOiJodHRwczovL2FwaS50b3Bjb2Rlci1kZXYuY29tIiwiaGFuZGxlIjoidGVzdDEiLCJleHAiOjI1NjMwNzY2ODksInVzZXJJZCI6IjQwMDUxMzMzIiwiaWF0IjoxNDYzMDc2MDg5LCJlbWFpbCI6InRlc3RAdG9wY29kZXIuY29tIiwianRpIjoiYjMzYjc3Y2QtYjUyZS00MGZlLTgzN2UtYmViOGUwYWU2YTRhIn0.wKWUe0-SaiFVN-VR_-GwgFlvWaDkSbc8H55ktb9LAVw", - "type": "string" - } - ] - }, "method": "POST", "header": [ { "key": "Content-Type", - "name": "Content-Type", - "value": "application/json", - "type": "text" + "value": "application/json" + }, + { + "key": "Authorization", + "value": "Bearer {{jwt-token-admin-40051333}}" } ], "body": { "mode": "raw", - "raw": "{\r\n \t\"config\": {\r\n \t\t\"hello\": \"test\"\r\n \t}\r\n }" + "raw": "{\r\n \"key\": \"markup_topcoder_service\",\r\n \"value\": \"1000\",\r\n \"valueType\": \"double\",\r\n\t\"writePermission\": {\r\n\t \t\"allowRule\": {\r\n\t \t\"projectRoles\": [\"account_manager\"],\r\n\t \t\"topcoderRoles\": [\"administrator\", \"Connect Admin\"]\r\n\t },\r\n\t \t\"denyRule\": {\r\n\t \t\"topcoderRoles\": [\"Connect Copilot Manager\"]\r\n\t }\r\n\t },\r\n\t\"readPermission\": {\r\n\t \t\"projectRoles\": [\"manager\"],\r\n\t \"topcoderRoles\": [\"administrator\", \"Connect Admin\", \"Connect Account Manager\"]\r\n\t },\r\n\t\"metadata\": {}\r\n}" }, "url": { - "raw": "{{api-url}}/projects/metadata/priceConfig/{{priceKey}}/versions/{{priceVersion}}/revisions", + "raw": "{{api-url}}/projects/{{projectId}}/settings", "host": [ "{{api-url}}" ], "path": [ "projects", - "metadata", - "priceConfig", - "{{priceKey}}", - "versions", - "{{priceVersion}}", - "revisions" + "{{projectId}}", + "settings" ] } }, "response": [] }, { - "name": "Create for no exist key", + "name": "Create project setting with invalid valueType", "request": { - "auth": { - "type": "bearer", - "bearer": [ - { - "key": "token", - "value": "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJyb2xlcyI6WyJUb3Bjb2RlciBVc2VyIiwiYWRtaW5pc3RyYXRvciJdLCJpc3MiOiJodHRwczovL2FwaS50b3Bjb2Rlci1kZXYuY29tIiwiaGFuZGxlIjoidGVzdDEiLCJleHAiOjI1NjMwNzY2ODksInVzZXJJZCI6IjQwMDUxMzMzIiwiaWF0IjoxNDYzMDc2MDg5LCJlbWFpbCI6InRlc3RAdG9wY29kZXIuY29tIiwianRpIjoiYjMzYjc3Y2QtYjUyZS00MGZlLTgzN2UtYmViOGUwYWU2YTRhIn0.wKWUe0-SaiFVN-VR_-GwgFlvWaDkSbc8H55ktb9LAVw", - "type": "string" - } - ] - }, "method": "POST", "header": [ { "key": "Content-Type", - "name": "Content-Type", - "type": "text", "value": "application/json" + }, + { + "key": "Authorization", + "value": "Bearer {{jwt-token-admin-40051333}}" } ], "body": { "mode": "raw", - "raw": "{\r\n \t\"config\": {\r\n \t\t\"hello\": \"test\"\r\n \t}\r\n }" + "raw": "{\r\n \"key\": \"markup_topcoder_service\",\r\n \"value\": \"1000\",\r\n \"valueType\": \"int1\",\r\n\t\"writePermission\": {\r\n\t \t\"allowRule\": {\r\n\t \t\"projectRoles\": [\"account_manager\"],\r\n\t \t\"topcoderRoles\": [\"administrator\", \"Connect Admin\"]\r\n\t },\r\n\t \t\"denyRule\": {\r\n\t \t\"topcoderRoles\": [\"Connect Copilot Manager\"]\r\n\t }\r\n\t },\r\n\t\"readPermission\": {\r\n\t \t\"projectRoles\": [\"manager\"],\r\n\t \"topcoderRoles\": [\"administrator\", \"Connect Admin\", \"Connect Account Manager\"]\r\n\t },\r\n\t\"metadata\": {}\r\n}" }, "url": { - "raw": "{{api-url}}/projects/metadata/priceConfig/no-exist-key/versions/1/revisions", + "raw": "{{api-url}}/projects/{{projectId}}/settings", "host": [ "{{api-url}}" ], "path": [ "projects", - "metadata", - "priceConfig", - "no-exist-key", - "versions", - "1", - "revisions" + "{{projectId}}", + "settings" ] } }, "response": [] }, { - "name": "Delete revision", + "name": "Create project setting with invalid percentage value", "request": { - "auth": { - "type": "bearer", - "bearer": [ - { - "key": "token", - "value": "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJyb2xlcyI6WyJUb3Bjb2RlciBVc2VyIiwiYWRtaW5pc3RyYXRvciJdLCJpc3MiOiJodHRwczovL2FwaS50b3Bjb2Rlci1kZXYuY29tIiwiaGFuZGxlIjoidGVzdDEiLCJleHAiOjI1NjMwNzY2ODksInVzZXJJZCI6IjQwMDUxMzMzIiwiaWF0IjoxNDYzMDc2MDg5LCJlbWFpbCI6InRlc3RAdG9wY29kZXIuY29tIiwianRpIjoiYjMzYjc3Y2QtYjUyZS00MGZlLTgzN2UtYmViOGUwYWU2YTRhIn0.wKWUe0-SaiFVN-VR_-GwgFlvWaDkSbc8H55ktb9LAVw", - "type": "string" - } - ] - }, - "method": "DELETE", + "method": "POST", "header": [ { "key": "Content-Type", - "name": "Content-Type", - "type": "text", "value": "application/json" + }, + { + "key": "Authorization", + "value": "Bearer {{jwt-token-admin-40051333}}" } ], "body": { "mode": "raw", - "raw": "" + "raw": "{\r\n \"key\": \"markup_community\",\r\n \"value\": \"200\",\r\n \"valueType\": \"percentage\",\r\n\t\"writePermission\": {\r\n\t \t\"allowRule\": {\r\n\t \t\"projectRoles\": [\"account_manager\"],\r\n\t \t\"topcoderRoles\": [\"administrator\", \"Connect Admin\"]\r\n\t },\r\n\t \t\"denyRule\": {\r\n\t \t\"topcoderRoles\": [\"Connect Copilot Manager\"]\r\n\t }\r\n\t },\r\n\t\"readPermission\": {\r\n\t \t\"projectRoles\": [\"manager\"],\r\n\t \"topcoderRoles\": [\"administrator\", \"Connect Admin\", \"Connect Account Manager\"]\r\n\t },\r\n\t\"metadata\": {}\r\n}" }, "url": { - "raw": "{{api-url}}/projects/metadata/priceConfig/{{priceKey}}/versions/{{priceVersion}}/revisions/{{priceRevision}}", + "raw": "{{api-url}}/projects/{{projectId}}/settings", "host": [ "{{api-url}}" ], "path": [ "projects", - "metadata", - "priceConfig", - "{{priceKey}}", - "versions", - "{{priceVersion}}", - "revisions", - "{{priceRevision}}" + "{{projectId}}", + "settings" ] } }, "response": [] - } - ] - }, - { - "name": "Plan Config Version", - "item": [ + }, { - "name": "List plan configs", + "name": "Create project setting with missing readPermission", "request": { - "auth": { - "type": "bearer", - "bearer": [ - { - "key": "token", - "value": "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJyb2xlcyI6WyJUb3Bjb2RlciBVc2VyIiwiYWRtaW5pc3RyYXRvciJdLCJpc3MiOiJodHRwczovL2FwaS50b3Bjb2Rlci1kZXYuY29tIiwiaGFuZGxlIjoidGVzdDEiLCJleHAiOjI1NjMwNzY2ODksInVzZXJJZCI6IjQwMDUxMzMzIiwiaWF0IjoxNDYzMDc2MDg5LCJlbWFpbCI6InRlc3RAdG9wY29kZXIuY29tIiwianRpIjoiYjMzYjc3Y2QtYjUyZS00MGZlLTgzN2UtYmViOGUwYWU2YTRhIn0.wKWUe0-SaiFVN-VR_-GwgFlvWaDkSbc8H55ktb9LAVw", - "type": "string" - } - ] + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + }, + { + "key": "Authorization", + "value": "Bearer {{jwt-token-admin-40051333}}" + } + ], + "body": { + "mode": "raw", + "raw": "{\r\n \"key\": \"markup_topcoder_service\",\r\n \"value\": \"1000\",\r\n \"valueType\": \"int\",\r\n \"projectId\": 1,\r\n\t\"writePermission\": {\r\n\t \t\"allowRule\": {\r\n\t \t\"projectRoles\": [\"account_manager\"],\r\n\t \t\"topcoderRoles\": [\"administrator\", \"Connect Admin\"]\r\n\t },\r\n\t \t\"denyRule\": {\r\n\t \t\"topcoderRoles\": [\"Connect Copilot Manager\"]\r\n\t }\r\n\t },\r\n\t\"metadata\": {}\r\n}" }, - "method": "GET", - "header": [], "url": { - "raw": "{{api-url}}/projects/metadata/planConfig/dev/versions", + "raw": "{{api-url}}/projects/{{projectId}}/settings", "host": [ "{{api-url}}" ], "path": [ "projects", - "metadata", - "planConfig", - "dev", - "versions" + "{{projectId}}", + "settings" ] } }, "response": [] }, { - "name": "Get a particular version", + "name": "Create project setting with empty body", "request": { - "auth": { - "type": "bearer", - "bearer": [ - { - "key": "token", - "value": "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJyb2xlcyI6WyJUb3Bjb2RlciBVc2VyIiwiYWRtaW5pc3RyYXRvciJdLCJpc3MiOiJodHRwczovL2FwaS50b3Bjb2Rlci1kZXYuY29tIiwiaGFuZGxlIjoidGVzdDEiLCJleHAiOjI1NjMwNzY2ODksInVzZXJJZCI6IjQwMDUxMzMzIiwiaWF0IjoxNDYzMDc2MDg5LCJlbWFpbCI6InRlc3RAdG9wY29kZXIuY29tIiwianRpIjoiYjMzYjc3Y2QtYjUyZS00MGZlLTgzN2UtYmViOGUwYWU2YTRhIn0.wKWUe0-SaiFVN-VR_-GwgFlvWaDkSbc8H55ktb9LAVw", - "type": "string" - } - ] + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + }, + { + "key": "Authorization", + "value": "Bearer {{jwt-token-admin-40051333}}" + } + ], + "body": { + "mode": "raw", + "raw": "{\r\n}" }, - "method": "GET", - "header": [], "url": { - "raw": "{{api-url}}/projects/metadata/planConfig/{{planKey}}/versions/{{planVersion}}", + "raw": "{{api-url}}/projects/{{projectId}}/settings", "host": [ "{{api-url}}" ], "path": [ "projects", - "metadata", - "planConfig", - "{{planKey}}", - "versions", - "{{planVersion}}" + "{{projectId}}", + "settings" ] } }, "response": [] }, { - "name": "Get latest version", + "name": "Create project setting - not permitted", + "event": [ + { + "listen": "test", + "script": { + "id": "7350de08-5111-44f8-8a4c-3d0c48bcd8d4", + "exec": [ + "" + ], + "type": "text/javascript" + } + } + ], "request": { - "auth": { - "type": "bearer", - "bearer": [ - { - "key": "token", - "value": "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJyb2xlcyI6WyJUb3Bjb2RlciBVc2VyIiwiYWRtaW5pc3RyYXRvciJdLCJpc3MiOiJodHRwczovL2FwaS50b3Bjb2Rlci1kZXYuY29tIiwiaGFuZGxlIjoidGVzdDEiLCJleHAiOjI1NjMwNzY2ODksInVzZXJJZCI6IjQwMDUxMzMzIiwiaWF0IjoxNDYzMDc2MDg5LCJlbWFpbCI6InRlc3RAdG9wY29kZXIuY29tIiwianRpIjoiYjMzYjc3Y2QtYjUyZS00MGZlLTgzN2UtYmViOGUwYWU2YTRhIn0.wKWUe0-SaiFVN-VR_-GwgFlvWaDkSbc8H55ktb9LAVw", - "type": "string" - } - ] + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + }, + { + "key": "Authorization", + "value": "Bearer {{jwt-token-member-40051331}}" + } + ], + "body": { + "mode": "raw", + "raw": "{\r\n \"key\": \"markup_topcoder_service\",\r\n \"value\": \"1000\",\r\n \"valueType\": \"double\",\r\n\t\"writePermission\": {\r\n\t \t\"allowRule\": {\r\n\t \t\"projectRoles\": [\"account_manager\"],\r\n\t \t\"topcoderRoles\": [\"administrator\", \"Connect Admin\"]\r\n\t },\r\n\t \t\"denyRule\": {\r\n\t \t\"topcoderRoles\": [\"Connect Copilot Manager\"]\r\n\t }\r\n\t },\r\n\t\"readPermission\": {\r\n\t \t\"projectRoles\": [\"manager\"],\r\n\t \"topcoderRoles\": [\"administrator\", \"Connect Admin\", \"Connect Account Manager\"]\r\n\t },\r\n\t\"metadata\": {}\r\n}" }, - "method": "GET", - "header": [], "url": { - "raw": "{{api-url}}/projects/metadata/planConfig/{{planKey}}", + "raw": "{{api-url}}/projects/{{projectId}}/settings", "host": [ "{{api-url}}" ], "path": [ "projects", - "metadata", - "planConfig", - "{{planKey}}" + "{{projectId}}", + "settings" ] } }, "response": [] }, { - "name": "Create plan config", - "event": [ - { - "listen": "test", - "script": { - "id": "97bc350a-0c4f-46a6-a315-a62b203b3ad2", - "exec": [ - "pm.test(\"Status code is 201\", function () {", - " pm.response.to.have.status(201);", - " pm.environment.set(\"planKey\", pm.response.json().key);", - " pm.environment.set(\"planVersion\", pm.response.json().version);", - "});" - ], - "type": "text/javascript" - } - } - ], + "name": "List project setting", "request": { - "auth": { - "type": "bearer", - "bearer": [ - { - "key": "token", - "value": "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJyb2xlcyI6WyJUb3Bjb2RlciBVc2VyIiwiYWRtaW5pc3RyYXRvciJdLCJpc3MiOiJodHRwczovL2FwaS50b3Bjb2Rlci1kZXYuY29tIiwiaGFuZGxlIjoidGVzdDEiLCJleHAiOjI1NjMwNzY2ODksInVzZXJJZCI6IjQwMDUxMzMzIiwiaWF0IjoxNDYzMDc2MDg5LCJlbWFpbCI6InRlc3RAdG9wY29kZXIuY29tIiwianRpIjoiYjMzYjc3Y2QtYjUyZS00MGZlLTgzN2UtYmViOGUwYWU2YTRhIn0.wKWUe0-SaiFVN-VR_-GwgFlvWaDkSbc8H55ktb9LAVw", - "type": "string" - } + "method": "GET", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + }, + { + "key": "Authorization", + "value": "Bearer {{jwt-token}}" + } + ], + "url": { + "raw": "{{api-url}}/projects/{{projectId}}/settings", + "host": [ + "{{api-url}}" + ], + "path": [ + "projects", + "{{projectId}}", + "settings" ] - }, - "method": "POST", + } + }, + "response": [] + }, + { + "name": "List project setting - 403", + "request": { + "method": "GET", "header": [ { "key": "Content-Type", - "name": "Content-Type", - "type": "text", "value": "application/json" + }, + { + "key": "Authorization", + "value": "Bearer {{jwt-token-copilot-40051332}}" } ], - "body": { - "mode": "raw", - "raw": "{\r\n \t\"config\": {\r\n \t\t\"hello\": \"test\"\r\n \t}\r\n }" - }, "url": { - "raw": "{{api-url}}/projects/metadata/planConfig/dev/versions", + "raw": "{{api-url}}/projects/{{projectId}}/settings", "host": [ "{{api-url}}" ], "path": [ "projects", - "metadata", - "planConfig", - "dev", - "versions" + "{{projectId}}", + "settings" ] } }, "response": [] }, { - "name": "Update plan config", + "name": "List project setting - manager", "request": { - "auth": { - "type": "bearer", - "bearer": [ - { - "key": "token", - "value": "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJyb2xlcyI6WyJUb3Bjb2RlciBVc2VyIiwiYWRtaW5pc3RyYXRvciJdLCJpc3MiOiJodHRwczovL2FwaS50b3Bjb2Rlci1kZXYuY29tIiwiaGFuZGxlIjoidGVzdDEiLCJleHAiOjI1NjMwNzY2ODksInVzZXJJZCI6IjQwMDUxMzMzIiwiaWF0IjoxNDYzMDc2MDg5LCJlbWFpbCI6InRlc3RAdG9wY29kZXIuY29tIiwianRpIjoiYjMzYjc3Y2QtYjUyZS00MGZlLTgzN2UtYmViOGUwYWU2YTRhIn0.wKWUe0-SaiFVN-VR_-GwgFlvWaDkSbc8H55ktb9LAVw", - "type": "string" - } - ] - }, - "method": "PATCH", + "method": "GET", "header": [ { "key": "Content-Type", - "name": "Content-Type", - "type": "text", "value": "application/json" + }, + { + "key": "Authorization", + "value": "Bearer {{jwt-token-manager-40051334}}" } ], - "body": { - "mode": "raw", - "raw": "{\r\n \t\"config\": {\r\n \t\t\"hello\": \"test111\"\r\n \t}\r\n }" - }, "url": { - "raw": "{{api-url}}/projects/metadata/planConfig/{{planKey}}/versions/{{planVersion}}", + "raw": "{{api-url}}/projects/{{projectId}}/settings", "host": [ "{{api-url}}" ], "path": [ "projects", - "metadata", - "planConfig", - "{{planKey}}", - "versions", - "{{planVersion}}" + "{{projectId}}", + "settings" ] } }, "response": [] }, { - "name": "Delete plan config", + "name": "Update project setting - (failed) change key", "request": { - "auth": { - "type": "bearer", - "bearer": [ - { - "key": "token", - "value": "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJyb2xlcyI6WyJUb3Bjb2RlciBVc2VyIiwiYWRtaW5pc3RyYXRvciJdLCJpc3MiOiJodHRwczovL2FwaS50b3Bjb2Rlci1kZXYuY29tIiwiaGFuZGxlIjoidGVzdDEiLCJleHAiOjI1NjMwNzY2ODksInVzZXJJZCI6IjQwMDUxMzMzIiwiaWF0IjoxNDYzMDc2MDg5LCJlbWFpbCI6InRlc3RAdG9wY29kZXIuY29tIiwianRpIjoiYjMzYjc3Y2QtYjUyZS00MGZlLTgzN2UtYmViOGUwYWU2YTRhIn0.wKWUe0-SaiFVN-VR_-GwgFlvWaDkSbc8H55ktb9LAVw", - "type": "string" - } - ] - }, - "method": "DELETE", + "method": "PATCH", "header": [ { "key": "Content-Type", - "name": "Content-Type", - "type": "text", "value": "application/json" + }, + { + "key": "Authorization", + "value": "Bearer {{jwt-token-admin-40051333}}" } ], "body": { "mode": "raw", - "raw": "" + "raw": "{\r\n \"key\": \"markup_community\",\r\n\t\"readPermission\": {\r\n\t \t\"projectRoles\": [\"manager\"],\r\n\t \"topcoderRoles\": [\"Connect Manager\"]\r\n\t }\r\n}" }, "url": { - "raw": "{{api-url}}/projects/metadata/planConfig/{{planKey}}/versions/{{planVersion}}", + "raw": "{{api-url}}/projects/{{projectId}}/settings/{{settingId}}", "host": [ "{{api-url}}" ], "path": [ "projects", - "metadata", - "planConfig", - "{{planKey}}", - "versions", - "{{planVersion}}" + "{{projectId}}", + "settings", + "{{settingId}}" ] } }, "response": [] - } - ] - }, - { - "name": "Plan Config Revision", - "item": [ + }, { - "name": "List all revision for version", + "name": "Update project setting - change double to percentage", "request": { - "auth": { - "type": "bearer", - "bearer": [ - { - "key": "token", - "value": "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJyb2xlcyI6WyJUb3Bjb2RlciBVc2VyIiwiYWRtaW5pc3RyYXRvciJdLCJpc3MiOiJodHRwczovL2FwaS50b3Bjb2Rlci1kZXYuY29tIiwiaGFuZGxlIjoidGVzdDEiLCJleHAiOjI1NjMwNzY2ODksInVzZXJJZCI6IjQwMDUxMzMzIiwiaWF0IjoxNDYzMDc2MDg5LCJlbWFpbCI6InRlc3RAdG9wY29kZXIuY29tIiwianRpIjoiYjMzYjc3Y2QtYjUyZS00MGZlLTgzN2UtYmViOGUwYWU2YTRhIn0.wKWUe0-SaiFVN-VR_-GwgFlvWaDkSbc8H55ktb9LAVw", - "type": "string" - } - ] + "method": "PATCH", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + }, + { + "key": "Authorization", + "value": "Bearer {{jwt-token-admin-40051333}}" + } + ], + "body": { + "mode": "raw", + "raw": "{\r\n \"value\": \"35.60\",\r\n \"valueType\": \"percentage\"\r\n}" }, - "method": "GET", - "header": [], "url": { - "raw": "{{api-url}}/projects/metadata/planConfig/{{planKey}}/versions/{{planVersion}}/revisions", + "raw": "{{api-url}}/projects/{{projectId}}/settings/{{settingId}}", "host": [ "{{api-url}}" ], "path": [ "projects", - "metadata", - "planConfig", - "{{planKey}}", - "versions", - "{{planVersion}}", - "revisions" + "{{projectId}}", + "settings", + "{{settingId}}" ] } }, "response": [] }, { - "name": "Get a particular revision", + "name": "Update project setting - non-existent project", "request": { - "auth": { - "type": "bearer", - "bearer": [ - { - "key": "token", - "value": "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJyb2xlcyI6WyJUb3Bjb2RlciBVc2VyIiwiYWRtaW5pc3RyYXRvciJdLCJpc3MiOiJodHRwczovL2FwaS50b3Bjb2Rlci1kZXYuY29tIiwiaGFuZGxlIjoidGVzdDEiLCJleHAiOjI1NjMwNzY2ODksInVzZXJJZCI6IjQwMDUxMzMzIiwiaWF0IjoxNDYzMDc2MDg5LCJlbWFpbCI6InRlc3RAdG9wY29kZXIuY29tIiwianRpIjoiYjMzYjc3Y2QtYjUyZS00MGZlLTgzN2UtYmViOGUwYWU2YTRhIn0.wKWUe0-SaiFVN-VR_-GwgFlvWaDkSbc8H55ktb9LAVw", - "type": "string" - } - ] + "method": "PATCH", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + }, + { + "key": "Authorization", + "value": "Bearer {{jwt-token-admin-40051333}}" + } + ], + "body": { + "mode": "raw", + "raw": "{\r\n \"value\": \"30\",\r\n \"valueType\": \"percentage\"\r\n}" }, - "method": "GET", - "header": [], "url": { - "raw": "{{api-url}}/projects/metadata/planConfig/{{planKey}}/versions/{{planVersion}}/revisions/{{planRevision}}", + "raw": "{{api-url}}/projects/9999/settings/{{settingId}}", "host": [ "{{api-url}}" ], "path": [ "projects", - "metadata", - "planConfig", - "{{planKey}}", - "versions", - "{{planVersion}}", - "revisions", - "{{planRevision}}" + "9999", + "settings", + "{{settingId}}" ] } }, "response": [] }, { - "name": "Create plan config", - "event": [ - { - "listen": "test", - "script": { - "id": "a5373f1f-4beb-46f9-8538-10c938c204ba", - "exec": [ - "pm.test(\"Status code is 201\", function () {", - " pm.response.to.have.status(201);", - " pm.environment.set(\"planRevision\", pm.response.json().revision);", - "});" - ], - "type": "text/javascript" - } - } - ], + "name": "Update project setting - non-existent project setting", "request": { - "auth": { - "type": "bearer", - "bearer": [ - { - "key": "token", - "value": "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJyb2xlcyI6WyJUb3Bjb2RlciBVc2VyIiwiYWRtaW5pc3RyYXRvciJdLCJpc3MiOiJodHRwczovL2FwaS50b3Bjb2Rlci1kZXYuY29tIiwiaGFuZGxlIjoidGVzdDEiLCJleHAiOjI1NjMwNzY2ODksInVzZXJJZCI6IjQwMDUxMzMzIiwiaWF0IjoxNDYzMDc2MDg5LCJlbWFpbCI6InRlc3RAdG9wY29kZXIuY29tIiwianRpIjoiYjMzYjc3Y2QtYjUyZS00MGZlLTgzN2UtYmViOGUwYWU2YTRhIn0.wKWUe0-SaiFVN-VR_-GwgFlvWaDkSbc8H55ktb9LAVw", - "type": "string" - } - ] - }, - "method": "POST", + "method": "PATCH", "header": [ { "key": "Content-Type", - "name": "Content-Type", - "value": "application/json", - "type": "text" + "value": "application/json" + }, + { + "key": "Authorization", + "value": "Bearer {{jwt-token-admin-40051333}}" } ], "body": { "mode": "raw", - "raw": "{\r\n \t\"config\": {\r\n \t\t\"hello\": \"test\"\r\n \t}\r\n }" + "raw": "{\r\n \"value\": \"30\",\r\n \"valueType\": \"percentage\"\r\n}" }, "url": { - "raw": "{{api-url}}/projects/metadata/planConfig/{{planKey}}/versions/{{planVersion}}/revisions", + "raw": "{{api-url}}/projects/{{projectId}}/settings/9999", "host": [ "{{api-url}}" ], "path": [ "projects", - "metadata", - "planConfig", - "{{planKey}}", - "versions", - "{{planVersion}}", - "revisions" + "{{projectId}}", + "settings", + "9999" ] } }, "response": [] }, { - "name": "Create for no exist key", + "name": "Update project setting - change readPermission", "request": { - "auth": { - "type": "bearer", - "bearer": [ - { - "key": "token", - "value": "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJyb2xlcyI6WyJUb3Bjb2RlciBVc2VyIiwiYWRtaW5pc3RyYXRvciJdLCJpc3MiOiJodHRwczovL2FwaS50b3Bjb2Rlci1kZXYuY29tIiwiaGFuZGxlIjoidGVzdDEiLCJleHAiOjI1NjMwNzY2ODksInVzZXJJZCI6IjQwMDUxMzMzIiwiaWF0IjoxNDYzMDc2MDg5LCJlbWFpbCI6InRlc3RAdG9wY29kZXIuY29tIiwianRpIjoiYjMzYjc3Y2QtYjUyZS00MGZlLTgzN2UtYmViOGUwYWU2YTRhIn0.wKWUe0-SaiFVN-VR_-GwgFlvWaDkSbc8H55ktb9LAVw", - "type": "string" - } - ] - }, - "method": "POST", + "method": "PATCH", "header": [ { "key": "Content-Type", - "name": "Content-Type", - "type": "text", "value": "application/json" + }, + { + "key": "Authorization", + "value": "Bearer {{jwt-token-admin-40051333}}" } ], "body": { "mode": "raw", - "raw": "{\r\n \t\"config\": {\r\n \t\t\"hello\": \"test\"\r\n \t}\r\n }" + "raw": "{\r\n\t\"readPermission\": {\r\n\t\t\"projectRoles\": [\"manager\"],\r\n\t\t\"topcoderRoles\": [\"Connect Manager\"]\r\n\t}\r\n}" }, "url": { - "raw": "{{api-url}}/projects/metadata/planConfig/no-exist-key/versions/1/revisions", + "raw": "{{api-url}}/projects/{{projectId}}/settings/{{settingId}}", "host": [ "{{api-url}}" ], "path": [ "projects", - "metadata", - "planConfig", - "no-exist-key", - "versions", - "1", - "revisions" + "{{projectId}}", + "settings", + "{{settingId}}" ] } }, "response": [] }, { - "name": "Delete revision", + "name": "Delete project setting", "request": { - "auth": { - "type": "bearer", - "bearer": [ - { - "key": "token", - "value": "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJyb2xlcyI6WyJUb3Bjb2RlciBVc2VyIiwiYWRtaW5pc3RyYXRvciJdLCJpc3MiOiJodHRwczovL2FwaS50b3Bjb2Rlci1kZXYuY29tIiwiaGFuZGxlIjoidGVzdDEiLCJleHAiOjI1NjMwNzY2ODksInVzZXJJZCI6IjQwMDUxMzMzIiwiaWF0IjoxNDYzMDc2MDg5LCJlbWFpbCI6InRlc3RAdG9wY29kZXIuY29tIiwianRpIjoiYjMzYjc3Y2QtYjUyZS00MGZlLTgzN2UtYmViOGUwYWU2YTRhIn0.wKWUe0-SaiFVN-VR_-GwgFlvWaDkSbc8H55ktb9LAVw", - "type": "string" - } - ] - }, "method": "DELETE", "header": [ { "key": "Content-Type", - "name": "Content-Type", - "type": "text", "value": "application/json" + }, + { + "key": "Authorization", + "value": "Bearer {{jwt-token}}" } ], "body": { @@ -7198,25 +9978,23 @@ "raw": "" }, "url": { - "raw": "{{api-url}}/projects/metadata/planConfig/{{planKey}}/versions/{{planVersion}}/revisions/{{planRevision}}", + "raw": "{{api-url}}/projects/{{projectId}}/settings/{{settingId}}", "host": [ "{{api-url}}" ], "path": [ "projects", - "metadata", - "planConfig", - "{{planKey}}", - "versions", - "{{planVersion}}", - "revisions", - "{{planRevision}}" + "{{projectId}}", + "settings", + "{{settingId}}" ] } }, "response": [] } - ] + ], + "protocolProfileBehavior": {} } - ] -} + ], + "protocolProfileBehavior": {} +} \ No newline at end of file diff --git a/docs/swagger.yaml b/docs/swagger.yaml index b653e9d..4912bf8 100644 --- a/docs/swagger.yaml +++ b/docs/swagger.yaml @@ -804,6 +804,726 @@ paths: description: Internal Server Error schema: $ref: '#/definitions/ErrorModel' + '/projects/{projectId}/workstreams': + parameters: + - $ref: '#/parameters/projectIdParam' + get: + tags: + - workstream + operationId: findWorkStreams + security: + - Bearer: [] + description: >- + Retrieve all project workstreams. + responses: + '200': + description: A list of project work streams + schema: + type: array + items: + $ref: '#/definitions/WorkStream' + '401': + description: Unauthorized + schema: + $ref: '#/definitions/ErrorModel' + '403': + description: Forbidden + schema: + $ref: '#/definitions/ErrorModel' + '404': + description: If project is not found + schema: + $ref: '#/definitions/ErrorModel' + '500': + description: Internal Server Error + schema: + $ref: '#/definitions/ErrorModel' + post: + tags: + - workstream + operationId: addWorkStream + security: + - Bearer: [] + description: >- + Create a work stream. + parameters: + - in: body + name: body + required: true + schema: + type: object + allOf: + - $ref: '#/definitions/WorkStreamRequest' + responses: + '200': + description: Returns the newly created project work stream + schema: + $ref: '#/definitions/WorkStream' + '400': + description: Bad request + schema: + $ref: '#/definitions/ErrorModel' + '401': + description: Unauthorized + schema: + $ref: '#/definitions/ErrorModel' + '403': + description: Forbidden + schema: + $ref: '#/definitions/ErrorModel' + '404': + description: If project is not found + schema: + $ref: '#/definitions/ErrorModel' + '500': + description: Internal Server Error + schema: + $ref: '#/definitions/ErrorModel' + '/projects/{projectId}/workstreams/{workStreamId}': + parameters: + - $ref: '#/parameters/projectIdParam' + - $ref: '#/parameters/workStreamIdParam' + get: + tags: + - workstream + description: >- + Retrieve work stream by id. + security: + - Bearer: [] + responses: + '200': + description: a project work stream + schema: + $ref: '#/definitions/WorkStream' + '401': + description: Unauthorized + schema: + $ref: '#/definitions/ErrorModel' + '403': + description: Forbidden + schema: + $ref: '#/definitions/ErrorModel' + '404': + description: Not found + schema: + $ref: '#/definitions/ErrorModel' + '500': + description: Internal Server Error + schema: + $ref: '#/definitions/ErrorModel' + parameters: + - $ref: '#/parameters/workStreamIdParam' + operationId: getWorkStream + patch: + tags: + - workstream + operationId: updateWorkStream + security: + - Bearer: [] + description: >- + Update a project work stream. + responses: + '200': + description: Successfully updated project work stream. + schema: + $ref: '#/definitions/WorkStream' + '400': + description: Bad request + schema: + $ref: '#/definitions/ErrorModel' + '401': + description: Unauthorized + schema: + $ref: '#/definitions/ErrorModel' + '403': + description: Forbidden + schema: + $ref: '#/definitions/ErrorModel' + '404': + description: Not found + schema: + $ref: '#/definitions/ErrorModel' + '500': + description: Internal Server Error + schema: + $ref: '#/definitions/ErrorModel' + parameters: + - name: body + in: body + required: true + schema: + $ref: '#/definitions/WorkStreamRequest' + delete: + tags: + - workstream + description: >- + Remove an existing project work stream. + security: + - Bearer: [] + responses: + '204': + description: Project work stream successfully removed + '401': + description: Unauthorized + schema: + $ref: '#/definitions/ErrorModel' + '403': + description: Forbidden + schema: + $ref: '#/definitions/ErrorModel' + '404': + description: If project is not found + schema: + $ref: '#/definitions/ErrorModel' + '500': + description: Internal Server Error + schema: + $ref: '#/definitions/ErrorModel' + '/projects/{projectId}/workstreams/{workStreamId}/works': + parameters: + - $ref: '#/parameters/projectIdParam' + - $ref: '#/parameters/workStreamIdParam' + get: + tags: + - work + operationId: findWorks + security: + - Bearer: [] + description: >- + Retrieve all works for given project and workstream. + parameters: + - name: fields + required: false + type: string + in: query + description: | + Comma separated list of project phase fields to return. + - name: sort + required: false + description: > + sort project phases by startDate, endDate, status, order. Default is + startDate asc + in: query + type: string + responses: + '200': + description: A list of project works + schema: + type: array + items: + $ref: '#/definitions/ProjectPhase' + '401': + description: Unauthorized + schema: + $ref: '#/definitions/ErrorModel' + '403': + description: Forbidden + schema: + $ref: '#/definitions/ErrorModel' + '404': + description: If project or workstream is not found + schema: + $ref: '#/definitions/ErrorModel' + '500': + description: Internal Server Error + schema: + $ref: '#/definitions/ErrorModel' + post: + tags: + - work + operationId: addWork + security: + - Bearer: [] + description: >- + Create a work + parameters: + - in: body + name: body + required: true + schema: + type: object + allOf: + - $ref: '#/definitions/ProjectPhaseRequest' + responses: + '200': + description: Returns the newly created project work + schema: + $ref: '#/definitions/ProjectPhase' + '400': + description: Bad request + schema: + $ref: '#/definitions/ErrorModel' + '401': + description: Unauthorized + schema: + $ref: '#/definitions/ErrorModel' + '403': + description: Forbidden + schema: + $ref: '#/definitions/ErrorModel' + '404': + description: If project or workstream is not found + schema: + $ref: '#/definitions/ErrorModel' + '500': + description: Internal Server Error + schema: + $ref: '#/definitions/ErrorModel' + '/projects/{projectId}/workstreams/{workStreamId}/works/{phaseId}': + parameters: + - $ref: '#/parameters/projectIdParam' + - $ref: '#/parameters/phaseIdParam' + - $ref: '#/parameters/workStreamIdParam' + get: + tags: + - work + description: >- + Retrieve work by id. + security: + - Bearer: [] + responses: + '200': + description: a project work + schema: + $ref: '#/definitions/ProjectPhase' + '401': + description: Unauthorized + schema: + $ref: '#/definitions/ErrorModel' + '403': + description: Forbidden + schema: + $ref: '#/definitions/ErrorModel' + '404': + description: Not found + schema: + $ref: '#/definitions/ErrorModel' + '500': + description: Internal Server Error + schema: + $ref: '#/definitions/ErrorModel' + parameters: + - $ref: '#/parameters/phaseIdParam' + operationId: getWork + patch: + tags: + - work + operationId: updateWork + security: + - Bearer: [] + description: >- + Update work for given project and workstream. + responses: + '200': + description: Successfully updated project work. + schema: + $ref: '#/definitions/ProjectPhase' + '400': + description: Bad request + schema: + $ref: '#/definitions/ErrorModel' + '401': + description: Unauthorized + schema: + $ref: '#/definitions/ErrorModel' + '403': + description: Forbidden + schema: + $ref: '#/definitions/ErrorModel' + '404': + description: Not found + schema: + $ref: '#/definitions/ErrorModel' + '500': + description: Internal Server Error + schema: + $ref: '#/definitions/ErrorModel' + parameters: + - $ref: '#/parameters/phaseIdParam' + - name: body + in: body + required: true + schema: + $ref: '#/definitions/ProjectPhaseRequest' + delete: + tags: + - work + description: >- + Remove an existing work by id for given project and workstream. + security: + - Bearer: [] + parameters: + - $ref: '#/parameters/phaseIdParam' + responses: + '204': + description: Work successfully removed + '401': + description: Unauthorized + schema: + $ref: '#/definitions/ErrorModel' + '403': + description: Forbidden + schema: + $ref: '#/definitions/ErrorModel' + '404': + description: If project or workstream is not found + schema: + $ref: '#/definitions/ErrorModel' + '500': + description: Internal Server Error + schema: + $ref: '#/definitions/ErrorModel' + '/projects/{projectId}/workstreams/{workStreamId}/works/{phaseId}/workitems': + parameters: + - $ref: '#/parameters/projectIdParam' + - $ref: '#/parameters/phaseIdParam' + - $ref: '#/parameters/workStreamIdParam' + get: + tags: + - work item + operationId: findWorkItems + security: + - Bearer: [] + description: >- + Retrieve all work items for given project, workstream and phase. + responses: + '200': + description: A list of work items + schema: + type: array + items: + $ref: '#/definitions/PhaseProduct' + '401': + description: Unauthorized + schema: + $ref: '#/definitions/ErrorModel' + '403': + description: Forbidden + schema: + $ref: '#/definitions/ErrorModel' + '404': + description: If project, workstream or phase is not found + schema: + $ref: '#/definitions/ErrorModel' + '500': + description: Internal Server Error + schema: + $ref: '#/definitions/ErrorModel' + post: + tags: + - work item + operationId: addWorkItem + security: + - Bearer: [] + description: Create a work item for given project, workstream and phase. + parameters: + - in: body + name: body + required: true + schema: + $ref: '#/definitions/PhaseProductRequest' + responses: + '200': + description: Returns the newly created work item + schema: + $ref: '#/definitions/PhaseProduct' + '400': + description: Bad request + schema: + $ref: '#/definitions/ErrorModel' + '401': + description: Unauthorized + schema: + $ref: '#/definitions/ErrorModel' + '403': + description: Forbidden + schema: + $ref: '#/definitions/ErrorModel' + '404': + description: If project, workstream or phase is not found + schema: + $ref: '#/definitions/ErrorModel' + '500': + description: Internal Server Error + schema: + $ref: '#/definitions/ErrorModel' + '/projects/{projectId}/workstreams/{workStreamId}/works/{phaseId}/workitems/{productId}': + parameters: + - $ref: '#/parameters/projectIdParam' + - $ref: '#/parameters/phaseIdParam' + - $ref: '#/parameters/workStreamIdParam' + - $ref: '#/parameters/productIdParam' + get: + tags: + - work item + description: >- + Retrieve work item by id for given project, workstream and phase. + security: + - Bearer: [] + responses: + '200': + description: a work item + schema: + $ref: '#/definitions/PhaseProduct' + '401': + description: Unauthorized + schema: + $ref: '#/definitions/ErrorModel' + '403': + description: Forbidden + schema: + $ref: '#/definitions/ErrorModel' + '404': + description: Not found + schema: + $ref: '#/definitions/ErrorModel' + '500': + description: Internal Server Error + schema: + $ref: '#/definitions/ErrorModel' + parameters: + - $ref: '#/parameters/phaseIdParam' + operationId: getWorkItem + patch: + tags: + - work item + operationId: updateWorkItem + security: + - Bearer: [] + description: >- + Update a work item for given project, workstream and phase. + responses: + '200': + description: Successfully updated work item. + schema: + $ref: '#/definitions/PhaseProduct' + '400': + description: Bad request + schema: + $ref: '#/definitions/ErrorModel' + '401': + description: Unauthorized + schema: + $ref: '#/definitions/ErrorModel' + '403': + description: Forbidden + schema: + $ref: '#/definitions/ErrorModel' + '404': + description: Not found + schema: + $ref: '#/definitions/ErrorModel' + '500': + description: Internal Server Error + schema: + $ref: '#/definitions/ErrorModel' + parameters: + - $ref: '#/parameters/phaseIdParam' + - name: body + in: body + required: true + schema: + $ref: '#/definitions/PhaseProductRequest' + delete: + tags: + - work item + description: >- + Remove an existing work item by id for given project, workstream and phase. + security: + - Bearer: [] + parameters: + - $ref: '#/parameters/phaseIdParam' + responses: + '204': + description: Work item successfully removed + '401': + description: Unauthorized + schema: + $ref: '#/definitions/ErrorModel' + '403': + description: Forbidden + schema: + $ref: '#/definitions/ErrorModel' + '404': + description: If project, workstream or phase is not found + schema: + $ref: '#/definitions/ErrorModel' + '500': + description: Internal Server Error + schema: + $ref: '#/definitions/ErrorModel' + '/projects/{projectId}/settings': + parameters: + - $ref: '#/parameters/projectIdParam' + get: + tags: + - project settings + operationId: findProjectSettings + security: + - Bearer: [] + description: >- + Retrieve all project settings. Only users with readPermission can get the setting + responses: + '200': + description: A list of project phases + schema: + type: array + items: + $ref: '#/definitions/ProjectSetting' + '401': + description: Unauthorized + schema: + $ref: '#/definitions/ErrorModel' + '403': + description: Forbidden + schema: + $ref: '#/definitions/ErrorModel' + '500': + description: Internal Server Error + schema: + $ref: '#/definitions/ErrorModel' + post: + tags: + - project settings + operationId: addProjectSetting + security: + - Bearer: [] + description: >- + Create a project setting and create project estimation items based on estimation type. + parameters: + - in: body + name: body + required: true + schema: + type: object + allOf: + - $ref: '#/definitions/ProjectSettingRequest' + responses: + '200': + description: Returns the newly created project phase + schema: + $ref: '#/definitions/ProjectPhase' + '400': + description: Bad request + schema: + $ref: '#/definitions/ErrorModel' + '401': + description: Unauthorized + schema: + $ref: '#/definitions/ErrorModel' + '403': + description: Forbidden + schema: + $ref: '#/definitions/ErrorModel' + '500': + description: Internal Server Error + schema: + $ref: '#/definitions/ErrorModel' + '/projects/{projectId}/settings/{settingId}': + parameters: + - $ref: '#/parameters/projectIdParam' + - $ref: '#/parameters/settingIdParam' + patch: + tags: + - project settings + operationId: updateProjectSetting + security: + - Bearer: [] + description: >- + Update a project setting. All user with write permission can edit the setting. + responses: + '200': + description: Successfully updated project setting. + schema: + $ref: '#/definitions/ProjectSetting' + '400': + description: Bad request + schema: + $ref: '#/definitions/ErrorModel' + '401': + description: Unauthorized + schema: + $ref: '#/definitions/ErrorModel' + '403': + description: Forbidden + schema: + $ref: '#/definitions/ErrorModel' + '404': + description: Not found + schema: + $ref: '#/definitions/ErrorModel' + '500': + description: Internal Server Error + schema: + $ref: '#/definitions/ErrorModel' + parameters: + - name: body + in: body + required: true + schema: + $ref: '#/definitions/ProjectSettingRequest' + delete: + tags: + - project settings + description: >- + Remove an existing project setting. All users who are connect managers and admins + access this endpoint. + security: + - Bearer: [] + parameters: + - $ref: '#/parameters/settingIdParam' + responses: + '204': + description: Project setting successfully removed + '401': + description: Unauthorized + schema: + $ref: '#/definitions/ErrorModel' + '403': + description: Forbidden + schema: + $ref: '#/definitions/ErrorModel' + '404': + description: If project is not found + schema: + $ref: '#/definitions/ErrorModel' + '500': + description: Internal Server Error + schema: + $ref: '#/definitions/ErrorModel' + '/projects/{projectId}/estimations/{estimationId}/items': + get: + tags: + - Project Estimation Items + security: + - Bearer: [] + description: get project estimation items + parameters: + - $ref: '#/parameters/projectIdParam' + - $ref: '#/parameters/projectEstimationIdParam' + responses: + '200': + description: List of project estimation items + schema: + type: array + items: + $ref: '#/definitions/ProjectEstimationItem' + '401': + description: Unauthorized + schema: + $ref: '#/definitions/ErrorModel' + '403': + description: Forbidden + schema: + $ref: '#/definitions/ErrorModel' + '404': + description: Model not found + schema: + $ref: '#/definitions/ErrorModel' + '500': + description: Invalid server state or unknown error + schema: + $ref: '#/definitions/ErrorModel' '/projects/{projectId}/phases/{phaseId}/products': parameters: - $ref: '#/parameters/projectIdParam' @@ -1960,7 +2680,216 @@ paths: '400': description: Bad request schema: - $ref: '#/definitions/ErrorModel' + $ref: '#/definitions/ErrorModel' + '401': + description: Unauthorized + schema: + $ref: '#/definitions/ErrorModel' + '403': + description: Forbidden + schema: + $ref: '#/definitions/ErrorModel' + '404': + description: If organization config is not found + schema: + $ref: '#/definitions/ErrorModel' + '500': + description: Internal Server Error + schema: + $ref: '#/definitions/ErrorModel' + /projects/metadata/workManagementPermission: + get: + tags: + - workManagementPermission + operationId: findWorkManagementPermissions + security: + - Bearer: [] + description: >- + Retrieve all work management permissions. Only admin or connect admin can access + this endpoint. + parameters: + - name: filter + required: true + type: string + in: query + description: | + Url encoded list of Supported filters + - projectTemplateId (required) + responses: + '200': + description: A list of work management permissions + schema: + type: array + items: + $ref: '#/definitions/WorkManagementPermission' + '401': + description: Unauthorized + schema: + $ref: '#/definitions/ErrorModel' + '403': + description: Forbidden + schema: + $ref: '#/definitions/ErrorModel' + '500': + description: Internal Server Error + schema: + $ref: '#/definitions/ErrorModel' + post: + tags: + - workManagementPermission + operationId: addWorkManagementPermission + security: + - Bearer: [] + description: >- + Create a work management permission. Only admin or connect admin can access + this endpoint. + parameters: + - in: body + name: body + required: true + schema: + $ref: '#/definitions/WorkManagementPermissionCreateRequest' + responses: + '200': + description: Returns the newly created work management permission + schema: + $ref: '#/definitions/WorkManagementPermission' + '400': + description: Bad request + schema: + $ref: '#/definitions/ErrorModel' + '401': + description: Unauthorized + schema: + $ref: '#/definitions/ErrorModel' + '403': + description: Forbidden + schema: + $ref: '#/definitions/ErrorModel' + '500': + description: Internal Server Error + schema: + $ref: '#/definitions/ErrorModel' + '/projects/metadata/workManagementPermission/{id}': + get: + tags: + - workManagementPermission + description: Retrieve work management permission by id. Only admin or connect admin can access + this endpoint. + security: + - Bearer: [] + responses: + '200': + description: a project type + schema: + $ref: '#/definitions/WorkManagementPermission' + '401': + description: Unauthorized + schema: + $ref: '#/definitions/ErrorModel' + '403': + description: Forbidden + schema: + $ref: '#/definitions/ErrorModel' + '404': + description: Not found + schema: + $ref: '#/definitions/ErrorModel' + '500': + description: Internal Server Error + schema: + $ref: '#/definitions/ErrorModel' + parameters: + - $ref: '#/parameters/permissionIdParam' + operationId: getWorkManagementPermission + patch: + tags: + - workManagementPermission + operationId: updateWorkManagementPermission + security: + - Bearer: [] + description: >- + Update a work management permission. Only admin or connect admin can access + this endpoint. + responses: + '200': + description: Successfully updated work management permission. + schema: + $ref: '#/definitions/WorkManagementPermission' + '400': + description: Bad request + schema: + $ref: '#/definitions/ErrorModel' + '401': + description: Unauthorized + schema: + $ref: '#/definitions/ErrorModel' + '403': + description: Forbidden + schema: + $ref: '#/definitions/ErrorModel' + '404': + description: Not found + schema: + $ref: '#/definitions/ErrorModel' + '500': + description: Internal Server Error + schema: + $ref: '#/definitions/ErrorModel' + parameters: + - $ref: '#/parameters/permissionIdParam' + - name: body + in: body + required: true + schema: + $ref: '#/definitions/WorkManagementPermissionCreateRequest' + delete: + tags: + - workManagementPermission + description: >- + Remove an existing work management permission. Only admin or connect admin can + access this endpoint. + security: + - Bearer: [] + parameters: + - $ref: '#/parameters/permissionIdParam' + responses: + '204': + description: Work management permission successfully removed + '401': + description: Unauthorized + schema: + $ref: '#/definitions/ErrorModel' + '403': + description: Forbidden + schema: + $ref: '#/definitions/ErrorModel' + '404': + description: If work management permission is not found + schema: + $ref: '#/definitions/ErrorModel' + '500': + description: Internal Server Error + schema: + $ref: '#/definitions/ErrorModel' + + '/projects/{projectId}/permissions': + get: + tags: + - permissions + description: Retrieve permissions. + security: + - Bearer: [] + responses: + '200': + description: permissions + schema: + title: Single work management permission response object + type: object + example: + 'work.create': true + 'workItem.edit': true + '401': description: Unauthorized schema: @@ -1970,13 +2899,16 @@ paths: schema: $ref: '#/definitions/ErrorModel' '404': - description: If organization config is not found + description: Not found schema: $ref: '#/definitions/ErrorModel' '500': description: Internal Server Error schema: $ref: '#/definitions/ErrorModel' + parameters: + - $ref: '#/parameters/projectIdParam' + operationId: getPermissions /timelines: get: tags: @@ -3660,6 +4592,22 @@ parameters: type: integer format: int64 minimum: 1 + workStreamIdParam: + name: workStreamId + in: path + description: work stream identifier + required: true + type: integer + format: int64 + minimum: 1 + settingIdParam: + name: settingId + in: path + description: project setting identifier + required: true + type: integer + format: int64 + minimum: 1 productIdParam: name: productId in: path @@ -3721,6 +4669,13 @@ parameters: required: true type: integer format: int64 + permissionIdParam: + name: id + in: path + description: work management permission id + required: true + type: integer + format: int64 pageParam: name: page in: query @@ -3824,15 +4779,14 @@ parameters: description: manager filter required: false type: string + projectEstimationIdParam: + name: estimationId + in: path + description: project estimation identifier + required: true + type: integer + format: int64 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: @@ -3902,7 +4856,7 @@ definitions: - buildingBlockKey properties: conditions: - type: string + type: string price: type: number format: float @@ -3915,7 +4869,7 @@ definitions: metadata: type: object buildingBlockKey: - type: string + type: string type: type: string description: project type @@ -4476,6 +5430,12 @@ definitions: name: type: string description: the project phase name + description: + type: string + description: the project phase short description + requirements: + type: string + description: the project phase requirements status: type: string description: the project phase status @@ -4954,12 +5914,16 @@ definitions: blockedText: type: string description: the milestone blocked text + statusComment: + type: string + description: the milestone status history comment Milestone: title: Milestone object allOf: - type: object required: - id + - statusHistory - createdAt - createdBy - updatedAt @@ -4969,6 +5933,8 @@ definitions: type: number format: int64 description: the id + statusHistory: + $ref: '#/definitions/StatusHistory' createdAt: type: string description: Datetime (GMT) when object was created @@ -5117,6 +6083,10 @@ definitions: type: array items: $ref: '#/definitions/ProductCategory' + buildingBlocks: + type: array + items: + $ref: '#/definitions/BuildingBlock' ProjectMemberInvite: type: object properties: @@ -5352,3 +6322,320 @@ definitions: config: description: config json type: object + ProjectSetting: + title: Project setting 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/ProjectSettingRequest' + ProjectSettingRequest: + title: Project setting request object + type: object + required: + - key + - value + - valueType + - readPermission + - writePermission + - metadata + properties: + key: + type: string + description: the project setting key + value: + type: string + description: the project setting value + valueType: + type: string + description: the project setting value type + readPermission: + type: object + description: the project setting read Permission + writePermission: + type: object + description: the project setting write Permission + metadata: + type: object + description: the project setting metadata + WorkStream: + title: Work stream object + allOf: + - type: object + required: + - id + - createdAt + - createdBy + - updatedAt + - updatedBy + properties: + id: + type: number + format: int64 + description: the id + projectId: + type: number + format: int64 + description: the project 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/WorkStreamRequest' + WorkStreamRequest: + title: Work stream request object + type: object + required: + - name + - status + - type + properties: + name: + type: string + description: the work stream name + status: + type: string + description: the work stream status + type: + type: string + description: the type + WorkManagementPermissionCreateRequest: + title: Work Management Permission request object + type: object + required: + - policy + - permission + - projectTemplateId + properties: + policy: + type: string + description: the policy + permission: + type: object + description: the permission + projectTemplateId: + type: number + format: int64 + description: the template id + WorkManagementPermission: + title: Work Management Permission object + allOf: + - type: object + required: + - id + - policy + - permission + - projectTemplateId + - createdAt + - createdBy + - updatedAt + - updatedBy + properties: + id: + type: number + format: int64 + description: the id + policy: + type: string + description: the policy + permission: + type: object + description: the permission + projectTemplateId: + type: number + format: int64 + description: the template 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/WorkManagementPermissionCreateRequest' + StatusHistory: + title: Status history object + type: object + required: + - id + - status + - reference + - referenceId + - comment + properties: + id: + type: string + description: the id + status: + type: string + description: the status + reference: + type: string + description: the referenced model + referenceId: + type: string + description: the referenced id + comment: + type: string + description: the comment + createdAt: + type: string + description: Datetime (GMT) when object was created + readOnly: true + createdBy: + type: integer + format: int64 + description: READ-ONLY. User who created this object + readOnly: true + updatedAt: + type: string + description: READ-ONLY. Datetime (GMT) when object was updated + readOnly: true + updatedBy: + type: integer + format: int64 + description: READ-ONLY. User that last updated this object + readOnly: true + BuildingBlock: + title: BuildingBlock object + type: object + required: + - id + - key + - config + properties: + id: + type: integer + format: int64 + description: the id + key: + type: string + description: building block key. Unique field. + config: + type: object + description: building block config + 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 + ProjectEstimationItem: + title: ProjectEstimationItem object + type: object + required: + - id + - projectEstimationId + - price + - type + - markupUsedReference + - markupUsedReferenceId + - metadata + properties: + id: + type: integer + format: int64 + description: the id + projectEstimationId: + type: integer + format: int64 + description: the ProjectEstimation id + price: + type: number + format: float + description: the price of this estimation item + type: + type: string + description: the type of this estimation + markupUsedReference: + type: string + description: the reference type of this estimation. Can be "buildingBlock" for example + markupUsedReferenceId: + type: integer + format: int64 + description: the reference object id + metadata: + type: object + description: the metadata of this item + 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 \ No newline at end of file diff --git a/local/seed/seedMetadata.js b/local/seed/seedMetadata.js index 6cf6cfa..6af36f2 100644 --- a/local/seed/seedMetadata.js +++ b/local/seed/seedMetadata.js @@ -37,7 +37,45 @@ module.exports = (targetUrl, token) => { 'Authorization': 'Bearer ' + token } - let promises = _(data.result.content.projectTypes).map(pt=>{ + let promises + + promises = _(data.result.content.forms).orderBy(['key', 'asc'], ['version', 'asc']).map(pt=>{ + const param = _.omit(pt, ['id', 'version', 'revision', 'key']); + return axios + .post(destUrl + `metadata/form/${pt.key}/versions`, param, {headers:headers}) + .catch((err) => { + const errMessage = _.get(err, 'response.data.message', ''); + console.log(`Failed to create form with key=${pt.key} version=${pt.version}.`, errMessage) + }) + }); + + await Promise.all(promises); + + promises = _(data.result.content.planConfigs).orderBy(['key', 'asc'], ['version', 'asc']).map(pt=>{ + const param = _.omit(pt, ['id', 'version', 'revision', 'key']); + return axios + .post(destUrl + `metadata/planConfig/${pt.key}/versions`, param, {headers:headers}) + .catch((err) => { + const errMessage = _.get(err, 'response.data.message', ''); + console.log(`Failed to create planConfig with key=${pt.key} version=${pt.version}.`, errMessage) + }) + }); + + await Promise.all(promises); + + promises = _(data.result.content.priceConfigs).orderBy(['key', 'asc'], ['version', 'asc']).map(pt=>{ + const param = _.omit(pt, ['id', 'version', 'revision', 'key']); + return axios + .post(destUrl + `metadata/priceConfig/${pt.key}/versions`, param, {headers:headers}) + .catch((err) => { + const errMessage = _.get(err, 'response.data.message', ''); + console.log(`Failed to create priceConfig with key=${pt.key} version=${pt.version}.`, errMessage) + }) + }); + + await Promise.all(promises); + + promises = _(data.result.content.projectTypes).map(pt=>{ return axios .post(destUrl+'metadata/projectTypes', pt, {headers:headers}) .catch((err) => { diff --git a/local/seed/seedProjects.js b/local/seed/seedProjects.js index 9467758..a84b82d 100644 --- a/local/seed/seedProjects.js +++ b/local/seed/seedProjects.js @@ -1,4 +1,5 @@ import util from '../../src/tests/util'; +import models from '../../src/models'; const axios = require('axios'); const Promise = require('bluebird'); @@ -60,6 +61,21 @@ module.exports = (targetUrl, token) => { }); } + await models.ProjectEstimation.create({ + projectId, + buildingBlockKey: 'BLOCK_KEY', + conditions: '( HAS_DEV_DELIVERABLE && ONLY_ONE_OS_MOBILE && CA_NEEDED )', + price: 6500.50, + quantity: 10, + minTime: 35, + maxTime: 35, + metadata: { + deliverable: 'dev-qa', + }, + createdBy: 1, + updatedBy: 1, + }); + // creating invitations if (Array.isArray(invites)) { let promises = [] diff --git a/migrations/20190502_status_history_create.sql b/migrations/20190502_status_history_create.sql new file mode 100644 index 0000000..cac3875 --- /dev/null +++ b/migrations/20190502_status_history_create.sql @@ -0,0 +1,29 @@ +-- +-- Create table status history +-- + +CREATE TABLE status_history ( + id bigint, + "reference" character varying(45) NOT NULL, + "referenceId" bigint NOT NULL, + "status" character varying(45) NOT NULL, + "comment" text, + "createdAt" timestamp with time zone, + "updatedAt" timestamp with time zone, + "createdBy" integer NOT NULL, + "updatedBy" integer NOT NULL +); + +CREATE SEQUENCE status_history_id_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + +ALTER SEQUENCE status_history_id_seq OWNED BY status_history.id; + +ALTER TABLE ONLY status_history ALTER COLUMN id SET DEFAULT nextval('status_history_id_seq'::regclass); + +ALTER TABLE ONLY status_history + ADD CONSTRAINT status_history_pkey PRIMARY KEY (id); \ No newline at end of file diff --git a/migrations/20190611_extract_scope_from_project_templates_2.sql b/migrations/20190611_extract_scope_from_project_templates_2.sql new file mode 100644 index 0000000..0a0cbaa --- /dev/null +++ b/migrations/20190611_extract_scope_from_project_templates_2.sql @@ -0,0 +1,12 @@ +-- +-- FIX for 20190316_extract_scope_from_project_templates.sql +-- apply created auto-increments sequences to `id` columns + +ALTER TABLE form + ALTER COLUMN id SET DEFAULT nextval('form_id_seq'); + +ALTER TABLE price_config + ALTER COLUMN id SET DEFAULT nextval('price_config_id_seq'); + +ALTER TABLE plan_config + ALTER COLUMN id SET DEFAULT nextval('plan_config_id_seq'); diff --git a/migrations/20190620_migrate_product_templates.sql b/migrations/20190620_migrate_product_templates.sql new file mode 100644 index 0000000..fdd6631 --- /dev/null +++ b/migrations/20190620_migrate_product_templates.sql @@ -0,0 +1,11 @@ +-- +-- UPDATE EXISTING TABLES: +-- template: +-- remove `sections` if exists and change `questions` to `sections` + +-- +-- product_templates + +UPDATE product_templates +SET template = (template::jsonb #- '{questions}' #- '{sections}') || jsonb_build_object('sections', template::jsonb ->'questions') +WHERE template::jsonb ? 'questions'; diff --git a/migrations/20190624_workStream.sql b/migrations/20190624_workStream.sql new file mode 100644 index 0000000..fa975de --- /dev/null +++ b/migrations/20190624_workStream.sql @@ -0,0 +1,82 @@ +-- +-- CREATE NEW TABLE: +-- work_streams +-- +CREATE TABLE work_streams ( + id bigint NOT NULL, + "name" character varying(255) NOT NULL, + "type" character varying(45) NOT NULL, + "status" character varying(255) NOT NULL, + "projectId" bigint NOT NULL, + "deletedAt" timestamp with time zone, + "createdAt" timestamp with time zone, + "updatedAt" timestamp with time zone, + "deletedBy" bigint, + "createdBy" bigint NOT NULL, + "updatedBy" bigint NOT NULL +); + +CREATE SEQUENCE work_streams_id_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + +ALTER SEQUENCE work_streams_id_seq OWNED BY work_streams.id; + +ALTER TABLE work_streams + ALTER COLUMN id SET DEFAULT nextval('work_streams_id_seq'); + +ALTER TABLE ONLY work_streams + ADD CONSTRAINT "work_streams_pkey" PRIMARY KEY (id); + +ALTER TABLE ONLY work_streams + ADD CONSTRAINT "work_streams_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES projects(id) ON UPDATE CASCADE ON DELETE SET NULL; + +-- +-- CREATE NEW TABLE: +-- work_management_permissions +-- +CREATE TABLE work_management_permissions ( + id bigint NOT NULL, + "policy" character varying(255) NOT NULL, + "permission" json NOT NULL, + "projectTemplateId" bigint NOT NULL, + "deletedAt" timestamp with time zone, + "createdAt" timestamp with time zone, + "updatedAt" timestamp with time zone, + "deletedBy" bigint, + "createdBy" bigint NOT NULL, + "updatedBy" bigint NOT NULL +); + +CREATE SEQUENCE work_management_permissions_id_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + +ALTER SEQUENCE work_management_permissions_id_seq OWNED BY work_management_permissions.id; + +ALTER TABLE work_management_permissions + ALTER COLUMN id SET DEFAULT nextval('work_management_permissions_id_seq'); + +-- +-- CREATE NEW TABLE: +-- phase_work_streams +-- +CREATE TABLE phase_work_streams ( + "workStreamId" bigint NOT NULL, + "phaseId" bigint NOT NULL +); + +ALTER TABLE ONLY phase_work_streams + ADD CONSTRAINT "phase_work_streams_pkey" PRIMARY KEY ("workStreamId", "phaseId"); + +ALTER TABLE ONLY phase_work_streams + ADD CONSTRAINT "phase_work_streams_phaseId_fkey" FOREIGN KEY ("phaseId") REFERENCES project_phases(id) ON UPDATE CASCADE ON DELETE CASCADE; + +ALTER TABLE ONLY phase_work_streams + ADD CONSTRAINT "phase_work_streams_workStreamId_fkey" FOREIGN KEY ("workStreamId") REFERENCES work_streams(id) ON UPDATE CASCADE ON DELETE CASCADE; diff --git a/migrations/20190719_project_settings_and_project_estimation_items.sql b/migrations/20190719_project_settings_and_project_estimation_items.sql new file mode 100644 index 0000000..cf6086b --- /dev/null +++ b/migrations/20190719_project_settings_and_project_estimation_items.sql @@ -0,0 +1,74 @@ +-- CREATE NEW TABLES: +-- project_settings +-- project_estimation_items +-- + +-- +-- project_settings +-- + +CREATE TABLE project_settings ( + id bigint NOT NULL, + key character varying(255), + value character varying(255), + "valueType" character varying(255), + "projectId" bigint NOT NULL, + metadata json NOT NULL DEFAULT '{}'::json, + "readPermission" json NOT NULL DEFAULT '{}'::json, + "writePermission" json NOT NULL DEFAULT '{}'::json, + "deletedAt" timestamp with time zone, + "createdAt" timestamp with time zone, + "updatedAt" timestamp with time zone, + "deletedBy" bigint, + "createdBy" bigint NOT NULL, + "updatedBy" bigint NOT NULL, + CONSTRAINT project_settings_pkey PRIMARY KEY (id) +); + +CREATE SEQUENCE project_settings_id_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + +ALTER SEQUENCE project_settings_id_seq OWNED BY project_settings.id; + +ALTER TABLE project_settings + ALTER COLUMN id SET DEFAULT nextval('project_settings_id_seq'); + +ALTER TABLE project_settings + ADD CONSTRAINT project_settings_key_project_id UNIQUE (key, "projectId"); + +-- +-- project_estimation_items +-- + +CREATE TABLE project_estimation_items ( + id bigint NOT NULL, + "projectEstimationId" bigint NOT NULL, + price double precision NOT NULL, + type character varying(255) NOT NULL, + "markupUsedReference" character varying(255) NOT NULL, + "markupUsedReferenceId" bigint NOT NULL, + metadata json NOT NULL DEFAULT '{}'::json, + "deletedAt" timestamp with time zone, + "createdAt" timestamp with time zone, + "updatedAt" timestamp with time zone, + "deletedBy" bigint, + "createdBy" bigint NOT NULL, + "updatedBy" bigint NOT NULL, + CONSTRAINT project_estimation_items_pkey PRIMARY KEY (id) +); + +CREATE SEQUENCE project_estimation_items_id_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + +ALTER SEQUENCE project_estimation_items_id_seq OWNED BY form.id; + +ALTER TABLE project_estimation_items + ALTER COLUMN id SET DEFAULT nextval('project_estimation_items_id_seq'); diff --git a/migrations/20190720_project_building_block.sql b/migrations/20190720_project_building_block.sql new file mode 100644 index 0000000..2342ea0 --- /dev/null +++ b/migrations/20190720_project_building_block.sql @@ -0,0 +1,35 @@ +-- +-- CREATE NEW TABLE: +-- building_blocks +-- +CREATE TABLE building_blocks ( + id bigint NOT NULL, + "key" character varying(255) NOT NULL, + "config" json NOT NULL DEFAULT '{}'::json, + "privateConfig" json NOT NULL DEFAULT '{}'::json, + "deletedAt" timestamp with time zone, + "createdAt" timestamp with time zone, + "updatedAt" timestamp with time zone, + "deletedBy" bigint, + "createdBy" bigint NOT NULL, + "updatedBy" bigint NOT NULL +); + +ALTER TABLE building_blocks + ADD CONSTRAINT building_blocks_key_uniq UNIQUE (key); + +CREATE SEQUENCE building_blocks_id_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + +ALTER SEQUENCE building_blocks_id_seq OWNED BY building_blocks.id; + +ALTER TABLE building_blocks + ALTER COLUMN id SET DEFAULT nextval('building_blocks_id_seq'); + +ALTER TABLE ONLY building_blocks + ADD CONSTRAINT building_blocks_pkey PRIMARY KEY (id); + diff --git a/migrations/20190729_project_phase_description_and_requirements.sql b/migrations/20190729_project_phase_description_and_requirements.sql new file mode 100644 index 0000000..9ad9e35 --- /dev/null +++ b/migrations/20190729_project_phase_description_and_requirements.sql @@ -0,0 +1,8 @@ +-- +-- UPDATE EXISTING TABLES: +-- project_phases +-- description column: added +-- requirements column: added + +ALTER TABLE project_phases ADD COLUMN "description" character varying(255); +ALTER TABLE project_phases ADD COLUMN "requirements" text; \ No newline at end of file diff --git a/migrations/20190729_scope_change_requests.sql b/migrations/20190729_scope_change_requests.sql new file mode 100644 index 0000000..fe382c3 --- /dev/null +++ b/migrations/20190729_scope_change_requests.sql @@ -0,0 +1,42 @@ +-- +-- CREATE NEW TABLE: +-- scope_change_requests +-- + +CREATE TABLE scope_change_requests +( + id bigint NOT NULL, + "projectId" bigint NOT NULL, + "oldScope" json NOT NULL, + "newScope" json NOT NULL, + status character varying(45) NOT NULL, + "deletedAt" timestamp with time zone, + "createdAt" timestamp with time zone, + "updatedAt" timestamp with time zone, + "approvedAt" timestamp with time zone, + "deletedBy" integer, + "createdBy" integer NOT NULL, + "updatedBy" integer NOT NULL, + "approvedBy" integer +); + + +CREATE SEQUENCE scope_change_requests_id_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + + +ALTER SEQUENCE scope_change_requests_id_seq OWNED BY scope_change_requests.id; + +ALTER TABLE scope_change_requests + ALTER COLUMN id SET DEFAULT nextval('scope_change_requests_id_seq'); + +ALTER TABLE ONLY scope_change_requests + ADD CONSTRAINT scope_change_requests_pkey PRIMARY KEY (id); + +ALTER TABLE ONLY scope_change_requests + ADD CONSTRAINT "scope_change_requests_projectId_fkey" FOREIGN KEY ("projectId") + REFERENCES projects(id) MATCH SIMPLE ON UPDATE CASCADE ON DELETE CASCADE; diff --git a/migrations/20190927_milestone_texts_not_required.sql b/migrations/20190927_milestone_texts_not_required.sql new file mode 100644 index 0000000..bda1c11 --- /dev/null +++ b/migrations/20190927_milestone_texts_not_required.sql @@ -0,0 +1,8 @@ +-- +-- UPDATE EXISTING TABLES: +-- milestones + +ALTER TABLE milestones ALTER COLUMN "plannedText" DROP NOT NULL; +ALTER TABLE milestones ALTER COLUMN "activeText" DROP NOT NULL; +ALTER TABLE milestones ALTER COLUMN "completedText" DROP NOT NULL; +ALTER TABLE milestones ALTER COLUMN "blockedText" DROP NOT NULL; \ No newline at end of file diff --git a/migrations/elasticsearch_sync.js b/migrations/elasticsearch_sync.js index d5f19ee..9ded699 100644 --- a/migrations/elasticsearch_sync.js +++ b/migrations/elasticsearch_sync.js @@ -673,6 +673,7 @@ function getRequestBody(indexName) { updateAllTypes: true, body: { mappings: { }, + refresh: 'wait_for', }, }; result.body.mappings[ES_PROJECT_TYPE] = projectMapping; @@ -683,6 +684,7 @@ function getRequestBody(indexName) { updateAllTypes: true, body: { mappings: { }, + refresh: 'wait_for', }, }; result.body.mappings[ES_METADATA_TYPE] = metadataMapping; @@ -693,6 +695,7 @@ function getRequestBody(indexName) { updateAllTypes: true, body: { mappings: { }, + refresh: 'wait_for', }, }; result.body.mappings[ES_TIMELINE_TYPE] = timelineMapping; @@ -703,24 +706,39 @@ function getRequestBody(indexName) { return result; } - // first delete the index if already present -esClient.indices.delete({ - index: ES_PROJECT_INDEX, - // we would want to ignore no such index error - 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(getRequestBody(ES_TIMELINE_INDEX))) -// Re-create metadata index -.then(() => esClient.indices.delete({ index: ES_METADATA_INDEX, ignore: [404] })) -.then(() => esClient.indices.create(getRequestBody(ES_METADATA_INDEX))) -.then(() => { - console.log('elasticsearch indices synced successfully'); - process.exit(); -}) -.catch((err) => { - console.error('elasticsearch indices sync failed', err); - process.exit(); -}); +/** + * Sync elasticsearch indices. + * + * @returns {undefined} + */ +function sync() { + // first delete the index if already present + return esClient.indices.delete({ + index: ES_PROJECT_INDEX, + // we would want to ignore no such index error + 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(getRequestBody(ES_TIMELINE_INDEX))) + // Re-create metadata index + .then(() => esClient.indices.delete({ index: ES_METADATA_INDEX, ignore: [404] })) + .then(() => esClient.indices.create(getRequestBody(ES_METADATA_INDEX))); +} + +if (!module.parent) { + sync() + .then(() => { + console.log('elasticsearch indices synced successfully'); + process.exit(); + }) + .catch((err) => { + console.error('elasticsearch indices sync failed', err); + process.exit(); + }); +} + +module.exports = { + sync, +}; diff --git a/src/constants.js b/src/constants.js index dd4d25f..8b66db6 100644 --- a/src/constants.js +++ b/src/constants.js @@ -9,6 +9,13 @@ export const PROJECT_STATUS = { CANCELLED: 'cancelled', }; +export const WORKSTREAM_STATUS = { + DRAFT: 'draft', + REVIEWED: 'reviewed', + ACTIVE: 'active', + COMPLETED: 'completed', + PAUSED: 'paused', +}; export const PROJECT_PHASE_STATUS = PROJECT_STATUS; export const MILESTONE_STATUS = PROJECT_STATUS; @@ -19,12 +26,20 @@ export const PROJECT_MEMBER_ROLE = { CUSTOMER: 'customer', COPILOT: 'copilot', ACCOUNT_MANAGER: 'account_manager', + PROGRAM_MANAGER: 'program_manager', + ACCOUNT_EXECUTIVE: 'account_executive', + SOLUTION_ARCHITECT: 'solution_architect', + PROJECT_MANAGER: 'project_manager', }; export const PROJECT_MEMBER_MANAGER_ROLES = [ PROJECT_MEMBER_ROLE.MANAGER, PROJECT_MEMBER_ROLE.OBSERVER, PROJECT_MEMBER_ROLE.ACCOUNT_MANAGER, + PROJECT_MEMBER_ROLE.ACCOUNT_EXECUTIVE, + PROJECT_MEMBER_ROLE.PROJECT_MANAGER, + PROJECT_MEMBER_ROLE.PROGRAM_MANAGER, + PROJECT_MEMBER_ROLE.SOLUTION_ARCHITECT, ]; export const USER_ROLE = { @@ -34,6 +49,12 @@ export const USER_ROLE = { COPILOT: 'Connect Copilot', CONNECT_ADMIN: 'Connect Admin', COPILOT_MANAGER: 'Connect Copilot Manager', + BUSINESS_DEVELOPMENT_REPRESENTATIVE: 'Business Development Representative', + PRESALES: 'Presales', + ACCOUNT_EXECUTIVE: 'Account Executive', + PROGRAM_MANAGER: 'Program Manager', + SOLUTION_ARCHITECT: 'Solution Architect', + PROJECT_MANAGER: 'Project Manager', }; export const ADMIN_ROLES = [USER_ROLE.CONNECT_ADMIN, USER_ROLE.TOPCODER_ADMIN]; @@ -43,6 +64,13 @@ export const MANAGER_ROLES = [ USER_ROLE.MANAGER, USER_ROLE.TOPCODER_ACCOUNT_MANAGER, USER_ROLE.COPILOT_MANAGER, + USER_ROLE.BUSINESS_DEVELOPMENT_REPRESENTATIVE, + USER_ROLE.PRESALES, + USER_ROLE.ACCOUNT_EXECUTIVE, + + USER_ROLE.PROGRAM_MANAGER, + USER_ROLE.SOLUTION_ARCHITECT, + USER_ROLE.PROJECT_MANAGER, ]; export const EVENT = { @@ -133,12 +161,6 @@ export const BUS_API_EVENT = { MILESTONE_TEMPLATE_REMOVED: 'project.notification.delete', MILESTONE_TEMPLATE_UPDATED: 'project.notification.update', - // TC Message Service events - TOPIC_CREATED: 'notifications.connect.project.topic.created', - TOPIC_UPDATED: 'notifications.connect.project.topic.updated', - POST_CREATED: 'notifications.connect.project.post.created', - POST_UPDATED: 'notifications.connect.project.post.edited', - // Project Member Invites PROJECT_MEMBER_INVITE_CREATED: 'project.notification.create', PROJECT_MEMBER_INVITE_UPDATED: 'project.notification.update', @@ -150,6 +172,86 @@ export const BUS_API_EVENT = { PROJECT_METADATA_DELETE: 'project.notification.delete', }; +export const CONNECT_NOTIFICATION_EVENT = { + PROJECT_CREATED: 'connect.notification.project.created', + PROJECT_UPDATED: 'connect.notification.project.updated', + PROJECT_SUBMITTED_FOR_REVIEW: 'connect.notification.project.submittedForReview', + PROJECT_APPROVED: 'connect.notification.project.approved', + PROJECT_PAUSED: 'connect.notification.project.paused', + PROJECT_COMPLETED: 'connect.notification.project.completed', + PROJECT_CANCELED: 'connect.notification.project.canceled', + PROJECT_ACTIVE: 'connect.notification.project.active', + + PROJECT_PHASE_TRANSITION_ACTIVE: 'connect.notification.project.phase.transition.active', + PROJECT_PHASE_TRANSITION_COMPLETED: 'connect.notification.project.phase.transition.completed', + PROJECT_PHASE_UPDATE_PAYMENT: 'connect.notification.project.phase.update.payment', + PROJECT_PHASE_UPDATE_PROGRESS: 'connect.notification.project.phase.update.progress', + PROJECT_PHASE_UPDATE_SCOPE: 'connect.notification.project.phase.update.scope', + + PROJECT_WORK_TRANSITION_ACTIVE: 'connect.notification.project.work.transition.active', + PROJECT_WORK_TRANSITION_COMPLETED: 'connect.notification.project.work.transition.completed', + PROJECT_WORK_UPDATE_PAYMENT: 'connect.notification.project.work.update.payment', + PROJECT_WORK_UPDATE_PROGRESS: 'connect.notification.project.work.update.progress', + PROJECT_WORK_UPDATE_SCOPE: 'connect.notification.project.work.update.scope', + + MEMBER_JOINED: 'connect.notification.project.member.joined', + MEMBER_LEFT: 'connect.notification.project.member.left', + MEMBER_REMOVED: 'connect.notification.project.member.removed', + MEMBER_ASSIGNED_AS_OWNER: 'connect.notification.project.member.assignedAsOwner', + MEMBER_JOINED_COPILOT: 'connect.notification.project.member.copilotJoined', + MEMBER_JOINED_MANAGER: 'connect.notification.project.member.managerJoined', + + PROJECT_LINK_CREATED: 'connect.notification.project.linkCreated', + PROJECT_FILE_UPLOADED: 'connect.notification.project.fileUploaded', + PROJECT_SPECIFICATION_MODIFIED: 'connect.notification.project.updated.spec', + PROJECT_PROGRESS_MODIFIED: 'connect.notification.project.updated.progress', + PROJECT_FILES_UPDATED: 'connect.notification.project.files.updated', + PROJECT_TEAM_UPDATED: 'connect.notification.project.team.updated', + + // When phase is added/updated/deleted from the project, + // When product is added/deleted from a phase + // When product is updated on any field other than specification + PROJECT_PLAN_UPDATED: 'connect.notification.project.plan.updated', + + PROJECT_PLAN_READY: 'connect.notification.project.plan.ready', + + // When milestone is added/deleted to/from the phase, + // When milestone is updated for duration/startDate/endDate/status + TIMELINE_ADJUSTED: 'connect.notification.project.timeline.adjusted', + + // When specification of a product is modified + PROJECT_PRODUCT_SPECIFICATION_MODIFIED: 'connect.notification.project.product.update.spec', + + // When specification of a work item is modified + PROJECT_WORKITEM_SPECIFICATION_MODIFIED: 'connect.notification.project.workitem.update.spec', + + MILESTONE_ADDED: 'connect.notification.project.timeline.milestone.added', + MILESTONE_REMOVED: 'connect.notification.project.timeline.milestone.removed', + MILESTONE_UPDATED: 'connect.notification.project.timeline.milestone.updated', + // When milestone is marked as active + MILESTONE_TRANSITION_ACTIVE: 'connect.notification.project.timeline.milestone.transition.active', + // When milestone is marked as completed + MILESTONE_TRANSITION_COMPLETED: 'connect.notification.project.timeline.milestone.transition.completed', + // When milestone is marked as paused + MILESTONE_TRANSITION_PAUSED: 'connect.notification.project.timeline.milestone.transition.paused', + // When milestone is waiting for customers's input + MILESTONE_WAITING_CUSTOMER: 'connect.notification.project.timeline.milestone.waiting.customer', + + // Project Member Invites + PROJECT_MEMBER_INVITE_CREATED: 'connect.notification.project.member.invite.created', + PROJECT_MEMBER_INVITE_REQUESTED: 'connect.notification.project.member.invite.requested', + PROJECT_MEMBER_INVITE_UPDATED: 'connect.notification.project.member.invite.updated', + PROJECT_MEMBER_INVITE_APPROVED: 'connect.notification.project.member.invite.approved', + PROJECT_MEMBER_INVITE_REJECTED: 'connect.notification.project.member.invite.rejected', + PROJECT_MEMBER_EMAIL_INVITE_CREATED: 'connect.notification.email.project.member.invite.created', + + // TC Message Service events + TOPIC_CREATED: 'connect.notification.project.topic.created', + TOPIC_UPDATED: 'connect.notification.project.topic.updated', + POST_CREATED: 'connect.notification.project.post.created', + POST_UPDATED: 'connect.notification.project.post.edited', +}; + export const REGEX = { URL: /^(http(s?):\/\/)?(www\.)?[a-zA-Z0-9\.\-\_]+(\.[a-zA-Z]{2,15})+(\:[0-9]{2,5})?(\/[a-zA-Z0-9\_\-\s\.\/\?\%\#\&\=;]*)?$/, // eslint-disable-line }; @@ -162,6 +264,11 @@ export const TIMELINE_REFERENCES = { PROJECT: 'project', PHASE: 'phase', PRODUCT: 'product', + WORK: 'work', +}; + +export const STATUS_HISTORY_REFERENCES = { + MILESTONE: 'milestone', }; export const MILESTONE_TEMPLATE_REFERENCES = { @@ -178,6 +285,44 @@ export const INVITE_STATUS = { CANCELED: 'canceled', }; +export const SCOPE_CHANGE_REQ_STATUS = { + PENDING: 'pending', + APPROVED: 'approved', + REJECTED: 'rejected', + ACTIVATED: 'activated', + CANCELED: 'canceled', +}; +export const MAX_PARALLEL_REQUEST_QTY = 10; + +export const ROUTES = { + PHASE_PRODUCTS: { + UPDATE: 'phase_products.update', + }, + PHASES: { + UPDATE: 'phases.update', + }, + WORKS: { + UPDATE: 'works.update', + }, + WORK_ITEMS: { + UPDATE: 'work_items.update', + }, +}; + +export const ESTIMATION_TYPE = { + FEE: 'fee', + COMMUNITY: 'community', + TOPCODER_SERVICE: 'topcoder_service', +}; + +export const VALUE_TYPE = { + INT: 'int', + DOUBLE: 'double', + STRING: 'string', + PERCENTAGE: 'percentage', +}; + + export const RESOURCES = { PROJECT: 'project', PROJECT_TEMPLATE: 'project.template', diff --git a/src/events/busApi.js b/src/events/busApi.js index 1dac212..3bc92ca 100644 --- a/src/events/busApi.js +++ b/src/events/busApi.js @@ -1,7 +1,44 @@ import _ from 'lodash'; +import moment from 'moment'; import config from 'config'; -import { EVENT, BUS_API_EVENT } from '../constants'; +import { + EVENT, + BUS_API_EVENT, + CONNECT_NOTIFICATION_EVENT, + PROJECT_STATUS, + PROJECT_PHASE_STATUS, + PROJECT_MEMBER_ROLE, + ROUTES, + MILESTONE_STATUS, + INVITE_STATUS, +} from '../constants'; import { createEvent } from '../services/busApi'; +import models from '../models'; +import util from '../util'; + +/** + * Map of project status and event name sent to bus api + */ +const mapEventTypes = { + [PROJECT_STATUS.DRAFT]: CONNECT_NOTIFICATION_EVENT.PROJECT_CREATED, + [PROJECT_STATUS.IN_REVIEW]: CONNECT_NOTIFICATION_EVENT.PROJECT_SUBMITTED_FOR_REVIEW, + [PROJECT_STATUS.REVIEWED]: CONNECT_NOTIFICATION_EVENT.PROJECT_APPROVED, + [PROJECT_STATUS.COMPLETED]: CONNECT_NOTIFICATION_EVENT.PROJECT_COMPLETED, + [PROJECT_STATUS.CANCELLED]: CONNECT_NOTIFICATION_EVENT.PROJECT_CANCELED, + [PROJECT_STATUS.PAUSED]: CONNECT_NOTIFICATION_EVENT.PROJECT_PAUSED, + [PROJECT_STATUS.ACTIVE]: CONNECT_NOTIFICATION_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. @@ -17,7 +54,7 @@ module.exports = (app, logger) => { /** * PROJECT_DRAFT_CREATED */ - app.on(EVENT.ROUTING_KEY.PROJECT_DRAFT_CREATED, ({ req, project }) => { // eslint-disable-line no-unused-vars + app.on(EVENT.ROUTING_KEY.PROJECT_DRAFT_CREATED, ({ req, project }) => { logger.debug('receive PROJECT_DRAFT_CREATED event'); // send event to bus api @@ -25,18 +62,82 @@ module.exports = (app, logger) => { refCode: _.get(project, 'details.utm.code'), projectUrl: connectProjectUrl(project.id), }), logger); + + /* + Send event for Notification Service + */ + createEvent(CONNECT_NOTIFICATION_EVENT.PROJECT_CREATED, { + projectId: project.id, + projectName: project.name, + refCode: _.get(project, 'details.utm.code'), + projectUrl: connectProjectUrl(project.id), + userId: req.authUser.userId, + initiatorUserId: req.authUser.userId, + }, logger); }); /** * PROJECT_UPDATED */ - app.on(EVENT.ROUTING_KEY.PROJECT_UPDATED, ({ req, updated }) => { // eslint-disable-line no-unused-vars + app.on(EVENT.ROUTING_KEY.PROJECT_UPDATED, ({ req, original, updated }) => { logger.debug('receive PROJECT_UPDATED event'); createEvent(BUS_API_EVENT.PROJECT_UPDATED, _.assign(updated, { refCode: _.get(updated, 'details.utm.code'), projectUrl: connectProjectUrl(updated.id), }), logger); + + /* + Send event for Notification Service + */ + if (original.status !== updated.status) { + logger.debug(`project status is updated from ${original.status} to ${updated.status}`); + createEvent(mapEventTypes[updated.status], { + projectId: updated.id, + projectName: updated.name, + refCode: _.get(updated, 'details.utm.code'), + projectUrl: connectProjectUrl(updated.id), + userId: req.authUser.userId, + initiatorUserId: req.authUser.userId, + }, logger); + } else if ( + !_.isEqual(original.details, updated.details) || + !_.isEqual(original.name, updated.name) || + !_.isEqual(original.description, updated.description)) { + logger.debug('project spec is updated'); + createEvent(CONNECT_NOTIFICATION_EVENT.PROJECT_SPECIFICATION_MODIFIED, { + projectId: updated.id, + projectName: updated.name, + refCode: _.get(updated, 'details.utm.code'), + projectUrl: connectProjectUrl(updated.id), + userId: req.authUser.userId, + initiatorUserId: req.authUser.userId, + }, logger); + } else if (!_.isEqual(original.bookmarks, updated.bookmarks)) { + logger.debug('project bookmarks is updated'); + createEvent(CONNECT_NOTIFICATION_EVENT.PROJECT_LINK_CREATED, { + projectId: updated.id, + projectName: updated.name, + refCode: _.get(updated, 'details.utm.code'), + projectUrl: connectProjectUrl(updated.id), + userId: req.authUser.userId, + initiatorUserId: req.authUser.userId, + }, logger); + } + + // send PROJECT_UPDATED Kafka message when one of the specified below properties changed + const watchProperties = ['status', 'details', 'name', 'description', 'bookmarks']; + if (!_.isEqual(_.pick(original, watchProperties), + _.pick(updated, watchProperties))) { + createEvent(CONNECT_NOTIFICATION_EVENT.PROJECT_UPDATED, { + projectId: updated.id, + projectName: updated.name, + refCode: _.get(updated, 'details.utm.code'), + projectUrl: connectProjectUrl(updated.id), + userId: req.authUser.userId, + initiatorUserId: req.authUser.userId, + }, logger); + } }); /** @@ -79,84 +180,486 @@ module.exports = (app, logger) => { /** * PROJECT_MEMBER_ADDED */ - app.on(EVENT.ROUTING_KEY.PROJECT_MEMBER_ADDED, ({ req, resource }) => { // eslint-disable-line no-unused-vars + app.on(EVENT.ROUTING_KEY.PROJECT_MEMBER_ADDED, ({ req, resource }) => { logger.debug('receive PROJECT_MEMBER_ADDED event'); createEvent(BUS_API_EVENT.PROJECT_MEMBER_ADDED, resource, logger); + + /* + Send event for Notification Service + */ + let eventType; + const member = _.omit(resource, 'resource'); + + if ([ + PROJECT_MEMBER_ROLE.MANAGER, + PROJECT_MEMBER_ROLE.PROJECT_MANAGER, + PROJECT_MEMBER_ROLE.PROGRAM_MANAGER, + PROJECT_MEMBER_ROLE.SOLUTION_ARCHITECT, + ].includes(member.role)) { + eventType = CONNECT_NOTIFICATION_EVENT.MEMBER_JOINED_MANAGER; + } else if (member.role === PROJECT_MEMBER_ROLE.COPILOT) { + eventType = CONNECT_NOTIFICATION_EVENT.MEMBER_JOINED_COPILOT; + } else { + eventType = CONNECT_NOTIFICATION_EVENT.MEMBER_JOINED; + } + const projectId = _.parseInt(req.params.projectId); + + models.Project.findOne({ + where: { id: projectId }, + }) + .then((project) => { + createEvent(eventType, { + projectId, + projectName: project.name, + refCode: _.get(project, 'details.utm.code'), + projectUrl: connectProjectUrl(projectId), + userId: member.userId, + initiatorUserId: req.authUser.userId, + }, logger); + + createEvent(CONNECT_NOTIFICATION_EVENT.PROJECT_TEAM_UPDATED, { + projectId: project.id, + projectName: project.name, + refCode: _.get(project, 'details.utm.code'), + projectUrl: connectProjectUrl(project.id), + userId: req.authUser.userId, + initiatorUserId: req.authUser.userId, + }, logger); + }).catch(err => null); // eslint-disable-line no-unused-vars }); /** * PROJECT_MEMBER_REMOVED */ - app.on(EVENT.ROUTING_KEY.PROJECT_MEMBER_REMOVED, ({ req, resource }) => { // eslint-disable-line no-unused-vars + app.on(EVENT.ROUTING_KEY.PROJECT_MEMBER_REMOVED, ({ req, resource }) => { logger.debug('receive PROJECT_MEMBER_REMOVED event'); createEvent(BUS_API_EVENT.PROJECT_MEMBER_REMOVED, resource, logger); + + /* + Send event for Notification Service + */ + let eventType; + const member = _.omit(resource, 'resource'); + if (member.userId === req.authUser.userId) { + eventType = CONNECT_NOTIFICATION_EVENT.MEMBER_LEFT; + } else { + eventType = CONNECT_NOTIFICATION_EVENT.MEMBER_REMOVED; + } + const projectId = _.parseInt(req.params.projectId); + + models.Project.findOne({ + where: { id: projectId }, + }) + .then((project) => { + if (project) { + createEvent(eventType, { + projectId, + projectName: project.name, + refCode: _.get(project, 'details.utm.code'), + projectUrl: connectProjectUrl(projectId), + userId: member.userId, + initiatorUserId: req.authUser.userId, + }, logger); + + createEvent(CONNECT_NOTIFICATION_EVENT.PROJECT_TEAM_UPDATED, { + projectId: project.id, + projectName: project.name, + refCode: _.get(project, 'details.utm.code'), + projectUrl: connectProjectUrl(project.id), + userId: req.authUser.userId, + initiatorUserId: req.authUser.userId, + }, logger); + } + }).catch(err => null); // eslint-disable-line no-unused-vars }); /** * PROJECT_MEMBER_UPDATED */ - app.on(EVENT.ROUTING_KEY.PROJECT_MEMBER_UPDATED, ({ req, resource }) => { // eslint-disable-line no-unused-vars + app.on(EVENT.ROUTING_KEY.PROJECT_MEMBER_UPDATED, ({ req, resource, originalResource }) => { logger.debug('receive PROJECT_MEMBER_UPDATED event'); createEvent(BUS_API_EVENT.PROJECT_MEMBER_UPDATED, resource, logger); + + /* + Send event for Notification Service + */ + const projectId = _.parseInt(req.params.projectId); + const updated = _.omit(resource, 'resource'); + const original = _.omit(originalResource, 'resource'); + + models.Project.findOne({ + where: { id: projectId }, + }) + .then((project) => { + if (project) { + if (updated.isPrimary && !original.isPrimary) { + createEvent(CONNECT_NOTIFICATION_EVENT.MEMBER_ASSIGNED_AS_OWNER, { + projectId, + projectName: project.name, + refCode: _.get(project, 'details.utm.code'), + projectUrl: connectProjectUrl(projectId), + userId: updated.userId, + initiatorUserId: req.authUser.userId, + }, logger); + } + + createEvent(CONNECT_NOTIFICATION_EVENT.PROJECT_TEAM_UPDATED, { + projectId: project.id, + projectName: project.name, + refCode: _.get(project, 'details.utm.code'), + projectUrl: connectProjectUrl(project.id), + userId: req.authUser.userId, + initiatorUserId: req.authUser.userId, + }, logger); + } + }).catch(err => null); // eslint-disable-line no-unused-vars }); /** * PROJECT_ATTACHMENT_ADDED */ - app.on(EVENT.ROUTING_KEY.PROJECT_ATTACHMENT_ADDED, ({ req, resource }) => { // eslint-disable-line no-unused-vars + app.on(EVENT.ROUTING_KEY.PROJECT_ATTACHMENT_ADDED, ({ req, resource }) => { logger.debug('receive PROJECT_ATTACHMENT_ADDED event'); createEvent(BUS_API_EVENT.PROJECT_ATTACHMENT_ADDED, resource, logger); + + /* + Send event for Notification Service + */ + const projectId = _.parseInt(req.params.projectId); + const attachment = _.omit(resource, 'resource'); + + models.Project.findOne({ + where: { id: projectId }, + }) + .then((project) => { + createEvent(CONNECT_NOTIFICATION_EVENT.PROJECT_FILE_UPLOADED, { + projectId, + projectName: project.name, + refCode: _.get(project, 'details.utm.code'), + projectUrl: connectProjectUrl(projectId), + fileName: attachment.filePath.replace(/^.*[\\\/]/, ''), // eslint-disable-line + fileUrl: connectProjectAttachmentUrl(projectId, attachment.id), + allowedUsers: attachment.allowedUsers, + userId: req.authUser.userId, + initiatorUserId: req.authUser.userId, + }, logger); + + createEvent(CONNECT_NOTIFICATION_EVENT.PROJECT_FILES_UPDATED, { + projectId: project.id, + projectName: project.name, + refCode: _.get(project, 'details.utm.code'), + projectUrl: connectProjectUrl(project.id), + userId: req.authUser.userId, + initiatorUserId: req.authUser.userId, + }, logger); + }).catch(err => null); // eslint-disable-line no-unused-vars }); /** * PROJECT_ATTACHMENT_UPDATED */ - app.on(EVENT.ROUTING_KEY.PROJECT_ATTACHMENT_UPDATED, ({ req, resource }) => { // eslint-disable-line no-unused-vars + app.on(EVENT.ROUTING_KEY.PROJECT_ATTACHMENT_UPDATED, ({ req, resource }) => { logger.debug('receive PROJECT_ATTACHMENT_UPDATED event'); createEvent(BUS_API_EVENT.PROJECT_ATTACHMENT_UPDATED, resource, logger); + + /* + Send event for Notification Service + */ + const projectId = _.parseInt(req.params.projectId); + + models.Project.findOne({ + where: { id: projectId }, + }) + .then((project) => { + createEvent(CONNECT_NOTIFICATION_EVENT.PROJECT_FILES_UPDATED, { + projectId: project.id, + projectName: project.name, + refCode: _.get(project, 'details.utm.code'), + projectUrl: connectProjectUrl(project.id), + userId: req.authUser.userId, + initiatorUserId: req.authUser.userId, + }, logger); + }).catch(err => null); // eslint-disable-line no-unused-vars }); /** * PROJECT_ATTACHMENT_REMOVED */ - app.on(EVENT.ROUTING_KEY.PROJECT_ATTACHMENT_REMOVED, ({ req, resource }) => { // eslint-disable-line no-unused-vars + app.on(EVENT.ROUTING_KEY.PROJECT_ATTACHMENT_REMOVED, ({ req, resource }) => { logger.debug('receive PROJECT_ATTACHMENT_REMOVED event'); createEvent(BUS_API_EVENT.PROJECT_ATTACHMENT_REMOVED, resource, logger); + + /* + Send event for Notification Service + */ + const projectId = _.parseInt(req.params.projectId); + + models.Project.findOne({ + where: { id: projectId }, + }) + .then((project) => { + createEvent(CONNECT_NOTIFICATION_EVENT.PROJECT_FILES_UPDATED, { + projectId: project.id, + projectName: project.name, + refCode: _.get(project, 'details.utm.code'), + projectUrl: connectProjectUrl(project.id), + userId: req.authUser.userId, + initiatorUserId: req.authUser.userId, + }, logger); + }).catch(err => null); // eslint-disable-line no-unused-vars }); + /** + * If the project is in draft status and the phase is in reviewed status, and it's the + * only phase in the project with that status, then send the plan ready event. + * + * @param {object} req the req + * @param {object} project the project + * @param {object} phase the phase that was created/updated + * @returns {Promise<void>} void + */ + async function sendPlanReadyEventIfNeeded(req, project, phase) { + if (project.status === PROJECT_STATUS.DRAFT && + phase.status === PROJECT_PHASE_STATUS.REVIEWED) { + await models.ProjectPhase.count({ + where: { projectId: project.id, status: PROJECT_PHASE_STATUS.REVIEWED }, + }).then(((count) => { + // only send the plan ready event when this is the only reviewed phase in the project + if (count === 1) { + createEvent(CONNECT_NOTIFICATION_EVENT.PROJECT_PLAN_READY, { + projectId: project.id, + phaseId: phase.id, + projectName: project.name, + refCode: _.get(project, 'details.utm.code'), + userId: req.authUser.userId, + initiatorUserId: req.authUser.userId, + }, logger); + } + })); + } + } + /** * PROJECT_PHASE_ADDED */ - app.on(EVENT.ROUTING_KEY.PROJECT_PHASE_ADDED, ({ req, resource }) => { // eslint-disable-line no-unused-vars + app.on(EVENT.ROUTING_KEY.PROJECT_PHASE_ADDED, ({ req, resource }) => { logger.debug('receive PROJECT_PHASE_ADDED event'); createEvent(BUS_API_EVENT.PROJECT_PHASE_CREATED, resource, logger); + + /* + Send event for Notification Service + */ + const projectId = _.parseInt(req.params.projectId); + const created = _.omit(resource, 'resource'); + + models.Project.findOne({ + where: { id: projectId }, + }) + .then((project) => { + createEvent(CONNECT_NOTIFICATION_EVENT.PROJECT_PLAN_UPDATED, { + projectId, + projectName: project.name, + refCode: _.get(project, 'details.utm.code'), + projectUrl: connectProjectUrl(projectId), + userId: req.authUser.userId, + initiatorUserId: req.authUser.userId, + allowedUsers: created.status === PROJECT_PHASE_STATUS.DRAFT ? + util.getTopcoderProjectMembers(project.members) : null, + }, logger); + return sendPlanReadyEventIfNeeded(req, project, created); + }).catch(err => null); // eslint-disable-line no-unused-vars }); /** * PROJECT_PHASE_REMOVED */ - app.on(EVENT.ROUTING_KEY.PROJECT_PHASE_REMOVED, ({ req, resource }) => { // eslint-disable-line no-unused-vars + app.on(EVENT.ROUTING_KEY.PROJECT_PHASE_REMOVED, ({ req, resource }) => { logger.debug('receive PROJECT_PHASE_REMOVED event'); createEvent(BUS_API_EVENT.PROJECT_PHASE_DELETED, resource, logger); + + /* + Send event for Notification Service + */ + const projectId = _.parseInt(req.params.projectId); + const deleted = _.omit(resource, 'resource'); + + models.Project.findOne({ + where: { id: projectId }, + }) + .then((project) => { + createEvent(CONNECT_NOTIFICATION_EVENT.PROJECT_PLAN_UPDATED, { + projectId, + projectName: project.name, + refCode: _.get(project, 'details.utm.code'), + projectUrl: connectProjectUrl(projectId), + userId: req.authUser.userId, + initiatorUserId: req.authUser.userId, + allowedUsers: deleted.status === PROJECT_PHASE_STATUS.DRAFT ? + util.getTopcoderProjectMembers(project.members) : null, + }, logger); + }).catch(err => null); // eslint-disable-line no-unused-vars }); /** * PROJECT_PHASE_UPDATED */ - app.on(EVENT.ROUTING_KEY.PROJECT_PHASE_UPDATED, ({ req, resource }) => { // eslint-disable-line no-unused-vars + app.on(EVENT.ROUTING_KEY.PROJECT_PHASE_UPDATED, ({ req, resource, originalResource, route, skipNotification }) => { // eslint-disable-line no-unused-vars logger.debug('receive PROJECT_PHASE_UPDATED event'); createEvent(BUS_API_EVENT.PROJECT_PHASE_UPDATED, resource, logger); + + /* + Send event for Notification Service + */ + if (!skipNotification) { + const projectId = _.parseInt(req.params.projectId); + const phaseId = _.parseInt(req.params.phaseId); + const updated = _.omit(resource, 'resource'); + const original = _.omit(originalResource, 'resource'); + + models.Project.findOne({ + where: { id: projectId }, + }) + .then((project) => { + logger.debug(`Fetched project ${projectId} for the phase ${phaseId}`); + const eventsMap = {}; + [ + ['duration', CONNECT_NOTIFICATION_EVENT.PROJECT_PLAN_UPDATED], + ['startDate', CONNECT_NOTIFICATION_EVENT.PROJECT_PLAN_UPDATED], + ['spentBudget', route === ROUTES.PHASES.UPDATE + ? CONNECT_NOTIFICATION_EVENT.PROJECT_PHASE_UPDATE_PAYMENT + : CONNECT_NOTIFICATION_EVENT.PROJECT_WORK_UPDATE_PAYMENT, + ], + ['progress', [route === ROUTES.PHASES.UPDATE + ? CONNECT_NOTIFICATION_EVENT.PROJECT_PHASE_UPDATE_PROGRESS + : CONNECT_NOTIFICATION_EVENT.PROJECT_WORK_UPDATE_PROGRESS, + CONNECT_NOTIFICATION_EVENT.PROJECT_PROGRESS_MODIFIED, + ]], + ['details', route === ROUTES.PHASES.UPDATE + ? CONNECT_NOTIFICATION_EVENT.PROJECT_PHASE_UPDATE_SCOPE + : CONNECT_NOTIFICATION_EVENT.PROJECT_WORK_UPDATE_SCOPE, + ], + ['status', route === ROUTES.PHASES.UPDATE + ? CONNECT_NOTIFICATION_EVENT.PROJECT_PHASE_TRANSITION_ACTIVE + : CONNECT_NOTIFICATION_EVENT.PROJECT_WORK_TRANSITION_ACTIVE, + PROJECT_PHASE_STATUS.ACTIVE, + ], + ['status', route === ROUTES.PHASES.UPDATE + ? CONNECT_NOTIFICATION_EVENT.PROJECT_PHASE_TRANSITION_COMPLETED + : CONNECT_NOTIFICATION_EVENT.PROJECT_WORK_TRANSITION_COMPLETED, + PROJECT_PHASE_STATUS.COMPLETED, + ], + // ideally we should validate the old value being 'DRAFT' but there is no other status from which + // we can move phase to REVIEWED status + ['status', CONNECT_NOTIFICATION_EVENT.PROJECT_PLAN_UPDATED, PROJECT_PHASE_STATUS.REVIEWED], + // ideally we should validate the old value being 'REVIEWED' but there is no other status from which + // we can move phase to DRAFT status + ['status', CONNECT_NOTIFICATION_EVENT.PROJECT_PLAN_UPDATED, PROJECT_PHASE_STATUS.DRAFT], + ].forEach(([key, events, sendIfNewEqual]) => { + // eslint-disable-next-line no-param-reassign + events = Array.isArray(events) ? events : [events]; + // eslint-disable-next-line no-param-reassign + events = _.filter(events, e => !eventsMap[e]); + + // send event(s) only if the target field's value was updated, or when an update matches a "sendIfNewEqual" value + if ((!sendIfNewEqual && !_.isEqual(original[key], updated[key])) || + (original[key] !== sendIfNewEqual && updated[key] === sendIfNewEqual)) { + events.forEach(event => createEvent(event, { + projectId, + phaseId, + projectUrl: connectProjectUrl(projectId), + originalPhase: original, + updatedPhase: updated, + projectName: project.name, + userId: req.authUser.userId, + initiatorUserId: req.authUser.userId, + allowedUsers: updated.status === PROJECT_PHASE_STATUS.DRAFT ? + util.getTopcoderProjectMembers(project.members) : null, + }, logger)); + events.forEach((event) => { eventsMap[event] = true; }); + } + }); + + return sendPlanReadyEventIfNeeded(req, project, updated); + }).catch(err => null); // eslint-disable-line no-unused-vars + } }); + /** + * Send milestone notification if needed. + * @param {Object} req the request + * @param {Object} original the original milestone + * @param {Object} updated the updated milestone + * @param {Object} project the project + * @param {Object} timeline the updated timeline + * @returns {Promise<void>} void + */ + function sendMilestoneNotification(req, original, updated, project, timeline) { + logger.debug('sendMilestoneNotification', original, updated); + // throw generic milestone updated bus api event + createEvent(CONNECT_NOTIFICATION_EVENT.MILESTONE_UPDATED, { + projectId: project.id, + projectName: project.name, + refCode: _.get(project, 'details.utm.code'), + projectUrl: connectProjectUrl(project.id), + timeline, + originalMilestone: original, + updatedMilestone: updated, + userId: req.authUser.userId, + initiatorUserId: req.authUser.userId, + }, logger); + // Send transition events + if (original.status !== updated.status) { + let event; + if (updated.status === MILESTONE_STATUS.COMPLETED) { + event = CONNECT_NOTIFICATION_EVENT.MILESTONE_TRANSITION_COMPLETED; + } else if (updated.status === MILESTONE_STATUS.ACTIVE) { + event = CONNECT_NOTIFICATION_EVENT.MILESTONE_TRANSITION_ACTIVE; + } else if (updated.status === MILESTONE_STATUS.PAUSED) { + event = CONNECT_NOTIFICATION_EVENT.MILESTONE_TRANSITION_PAUSED; + } + + if (event) { + createEvent(event, { + projectId: project.id, + projectName: project.name, + refCode: _.get(project, 'details.utm.code'), + projectUrl: connectProjectUrl(project.id), + timeline, + originalMilestone: original, + updatedMilestone: updated, + userId: req.authUser.userId, + initiatorUserId: req.authUser.userId, + }, logger); + } + } + + // Send notifications.connect.project.phase.milestone.waiting.customer event + const originalWaiting = _.get(original, 'details.metadata.waitingForCustomer', false); + const updatedWaiting = _.get(updated, 'details.metadata.waitingForCustomer', false); + if (!originalWaiting && updatedWaiting) { + createEvent(CONNECT_NOTIFICATION_EVENT.MILESTONE_WAITING_CUSTOMER, { + projectId: project.id, + projectName: project.name, + refCode: _.get(project, 'details.utm.code'), + projectUrl: connectProjectUrl(project.id), + timeline, + originalMilestone: original, + updatedMilestone: updated, + userId: req.authUser.userId, + initiatorUserId: req.authUser.userId, + }, logger); + } + } + /** * MILESTONE_ADDED. */ @@ -164,15 +667,95 @@ module.exports = (app, logger) => { logger.debug('receive MILESTONE_ADDED event'); createEvent(BUS_API_EVENT.MILESTONE_ADDED, resource, logger); + + /* + Send event for Notification Service + */ + const projectId = _.parseInt(req.params.projectId); + const created = _.omit(resource, 'resource'); + + models.Project.findOne({ + where: { id: projectId }, + }) + .then((project) => { + if (project) { + createEvent(CONNECT_NOTIFICATION_EVENT.MILESTONE_ADDED, { + projectId, + projectName: project.name, + refCode: _.get(project, 'details.utm.code'), + projectUrl: connectProjectUrl(projectId), + addedMilestone: created, + userId: req.authUser.userId, + initiatorUserId: req.authUser.userId, + }, logger); + } + // sendMilestoneNotification(req, {}, created, project); + }) + .catch(err => null); // eslint-disable-line no-unused-vars }); /** * MILESTONE_UPDATED. */ - app.on(EVENT.ROUTING_KEY.MILESTONE_UPDATED, ({ req, resource }) => { // eslint-disable-line no-unused-vars + app.on(EVENT.ROUTING_KEY.MILESTONE_UPDATED, ({ + req, + resource, + originalResource, + cascadedUpdates, + skipNotification, + }) => { // eslint-disable-line no-unused-vars logger.debug(`receive MILESTONE_UPDATED event for milestone ${resource.id}`); createEvent(BUS_API_EVENT.MILESTONE_UPDATED, resource, logger); + + /* + Send event for Notification Service + */ + if (!skipNotification) { + const projectId = _.parseInt(req.params.projectId); + const timeline = _.omit(req.timeline.toJSON(), 'deletedAt', 'deletedBy'); + const updated = _.omit(resource, 'resource'); + const original = _.omit(originalResource, 'resource'); + + models.Project.findOne({ + where: { id: projectId }, + }) + .then((project) => { + logger.debug(`Found project with id ${projectId}`); + return models.Milestone.getTimelineDuration(timeline.id) + .then(({ duration, progress }) => { + timeline.duration = duration; + timeline.progress = progress; + sendMilestoneNotification(req, original, updated, project, timeline); + + logger.debug('cascadedUpdates', cascadedUpdates); + if (cascadedUpdates && cascadedUpdates.milestones && cascadedUpdates.milestones.length > 0) { + _.each(cascadedUpdates.milestones, cascadedUpdate => + sendMilestoneNotification(req, cascadedUpdate.original, cascadedUpdate.updated, project, timeline), + ); + } + + // if timeline is modified + if (cascadedUpdates && cascadedUpdates.timeline) { + const cTimeline = cascadedUpdates.timeline; + // if endDate of the timeline is modified, raise TIMELINE_ADJUSTED event + if (!moment(cTimeline.original.endDate).isSame(cTimeline.updated.endDate)) { + // Raise Timeline changed event + createEvent(CONNECT_NOTIFICATION_EVENT.TIMELINE_ADJUSTED, { + projectId: project.id, + projectName: project.name, + refCode: _.get(project, 'details.utm.code'), + projectUrl: connectProjectUrl(project.id), + originalTimeline: cTimeline.original, + updatedTimeline: cTimeline.updated, + userId: req.authUser.userId, + initiatorUserId: req.authUser.userId, + }, logger); + } + } + }); + }).catch(err => null); // eslint-disable-line no-unused-vars + } }); /** @@ -182,6 +765,29 @@ module.exports = (app, logger) => { logger.debug('receive MILESTONE_REMOVED event'); createEvent(BUS_API_EVENT.MILESTONE_REMOVED, resource, logger); + + /* + Send event for Notification Service + */ + const projectId = _.parseInt(req.params.projectId); + const deleted = _.omit(resource, 'resource'); + + models.Project.findOne({ + where: { id: projectId }, + }) + .then((project) => { + if (project) { + createEvent(CONNECT_NOTIFICATION_EVENT.MILESTONE_REMOVED, { + projectId, + projectName: project.name, + refCode: _.get(project, 'details.utm.code'), + projectUrl: connectProjectUrl(projectId), + removedMilestone: deleted, + userId: req.authUser.userId, + initiatorUserId: req.authUser.userId, + }, logger); + } + }).catch(err => null); // eslint-disable-line no-unused-vars }); /** @@ -232,10 +838,41 @@ module.exports = (app, logger) => { /** * TIMELINE_UPDATED */ - app.on(EVENT.ROUTING_KEY.TIMELINE_UPDATED, ({ req, resource }) => { // eslint-disable-line no-unused-vars + app.on(EVENT.ROUTING_KEY.TIMELINE_UPDATED, ({ req, resource, originalResource }) => { // eslint-disable-line no-unused-vars logger.debug('receive TIMELINE_UPDATED event'); createEvent(BUS_API_EVENT.TIMELINE_UPDATED, resource, logger); + + /* + Send event for Notification Service + */ + const updated = _.omit(resource, 'resource'); + const original = _.omit(originalResource, 'resource'); + // send PROJECT_UPDATED Kafka message when one of the specified below properties changed + const watchProperties = ['startDate', 'endDate']; + if (!_.isEqual(_.pick(original, watchProperties), + _.pick(updated, watchProperties))) { + // req.params.projectId is set by validateTimelineIdParam middleware + const projectId = _.parseInt(req.params.projectId); + + models.Project.findOne({ + where: { id: projectId }, + }) + .then((project) => { + if (project) { + createEvent(CONNECT_NOTIFICATION_EVENT.TIMELINE_ADJUSTED, { + projectId, + projectName: project.name, + refCode: _.get(project, 'details.utm.code'), + projectUrl: connectProjectUrl(projectId), + originalTimeline: original, + updatedTimeline: updated, + userId: req.authUser.userId, + initiatorUserId: req.authUser.userId, + }, logger); + } + }).catch(err => null); // eslint-disable-line no-unused-vars + } }); /** @@ -259,10 +896,55 @@ module.exports = (app, logger) => { /** * PROJECT_PHASE_PRODUCT_UPDATED */ - app.on(EVENT.ROUTING_KEY.PROJECT_PHASE_PRODUCT_UPDATED, ({ req, resource }) => { // eslint-disable-line no-unused-vars + app.on(EVENT.ROUTING_KEY.PROJECT_PHASE_PRODUCT_UPDATED, ({ req, resource, originalResource, route }) => { // eslint-disable-line no-unused-vars logger.debug('receive PROJECT_PHASE_PRODUCT_UPDATED event'); createEvent(BUS_API_EVENT.PROJECT_PHASE_PRODUCT_UPDATED, resource, logger); + + /* + Send event for Notification Service + */ + const projectId = _.parseInt(req.params.projectId); + const updated = _.omit(resource, 'resource'); + const original = _.omit(originalResource, 'resource'); + + models.Project.findOne({ + where: { id: projectId }, + }) + .then((project) => { + // Spec changes + if (!_.isEqual(original.details, updated.details)) { + logger.debug(`Spec changed for product id ${updated.id}`); + + const busApiEvent = route === ROUTES.PHASE_PRODUCTS.UPDATE + ? CONNECT_NOTIFICATION_EVENT.PROJECT_PRODUCT_SPECIFICATION_MODIFIED + : CONNECT_NOTIFICATION_EVENT.PROJECT_WORKITEM_SPECIFICATION_MODIFIED; + + createEvent(busApiEvent, { + projectId, + projectName: project.name, + refCode: _.get(project, 'details.utm.code'), + projectUrl: connectProjectUrl(projectId), + userId: req.authUser.userId, + initiatorUserId: req.authUser.userId, + }, logger); + } + + const watchProperties = ['name', 'estimatedPrice', 'actualPrice', 'details']; + if (!_.isEqual(_.pick(original, watchProperties), + _.pick(updated, watchProperties))) { + createEvent(CONNECT_NOTIFICATION_EVENT.PROJECT_PLAN_UPDATED, { + projectId, + projectName: project.name, + refCode: _.get(project, 'details.utm.code'), + projectUrl: connectProjectUrl(projectId), + userId: req.authUser.userId, + initiatorUserId: req.authUser.userId, + allowedUsers: updated.status === PROJECT_PHASE_STATUS.DRAFT ? + util.getTopcoderProjectMembers(project.members) : null, + }, logger); + } + }).catch(err => null); // eslint-disable-line no-unused-vars }); /** @@ -272,6 +954,50 @@ module.exports = (app, logger) => { logger.debug('receive PROJECT_MEMBER_INVITE_CREATED event'); createEvent(BUS_API_EVENT.PROJECT_MEMBER_INVITE_CREATED, resource, logger); + + /* + Send event for Notification Service + */ + const projectId = _.parseInt(req.params.projectId); + const userId = resource.userId; + const email = resource.email; + const status = resource.status; + const role = resource.role; + + models.Project.findOne({ + where: { id: projectId }, + }) + .then((project) => { + logger.debug(util.isSSO); + if (status === INVITE_STATUS.REQUESTED) { + createEvent(CONNECT_NOTIFICATION_EVENT.PROJECT_MEMBER_INVITE_REQUESTED, { + projectId, + userId, + email, + role, + initiatorUserId: req.authUser.userId, + isSSO: util.isSSO(project), + }, logger); + } else { + // send event to bus api + logger.debug({ + projectId, + userId, + email, + role, + initiatorUserId: req.authUser.userId, + isSSO: util.isSSO(project), + }); + createEvent(CONNECT_NOTIFICATION_EVENT.PROJECT_MEMBER_INVITE_CREATED, { + projectId, + userId, + email, + role, + initiatorUserId: req.authUser.userId, + isSSO: util.isSSO(project), + }, logger); + } + }).catch(err => logger.error(err)); // eslint-disable-line no-unused-vars }); /** @@ -281,6 +1007,59 @@ module.exports = (app, logger) => { logger.debug('receive PROJECT_MEMBER_INVITE_UPDATED event'); createEvent(BUS_API_EVENT.PROJECT_MEMBER_INVITE_UPDATED, resource, logger); + + /* + Send event for Notification Service + */ + const projectId = _.parseInt(req.params.projectId); + const userId = resource.userId; + const email = resource.email; + const status = resource.status; + const role = resource.role; + const createdBy = resource.createdBy; + + models.Project.findOne({ + where: { id: projectId }, + }) + .then((project) => { + logger.debug(util.isSSO); + if (status === INVITE_STATUS.REQUEST_APPROVED) { + // send event to bus api + createEvent(CONNECT_NOTIFICATION_EVENT.PROJECT_MEMBER_INVITE_APPROVED, { + projectId, + userId, + originator: createdBy, + email, + role, + status, + initiatorUserId: req.authUser.userId, + isSSO: util.isSSO(project), + }, logger); + } else if (status === INVITE_STATUS.REQUEST_REJECTED) { + // send event to bus api + createEvent(CONNECT_NOTIFICATION_EVENT.PROJECT_MEMBER_INVITE_REJECTED, { + projectId, + userId, + originator: createdBy, + email, + role, + status, + initiatorUserId: req.authUser.userId, + isSSO: util.isSSO(project), + }, logger); + } else { + // send event to bus api + createEvent(CONNECT_NOTIFICATION_EVENT.PROJECT_MEMBER_INVITE_UPDATED, { + projectId, + userId, + email, + role, + status, + initiatorUserId: req.authUser.userId, + isSSO: util.isSSO(project), + }, logger); + } + }).catch(err => null); // eslint-disable-line no-unused-vars }); /** diff --git a/src/events/index.js b/src/events/index.js index 67b1b15..e161ef6 100644 --- a/src/events/index.js +++ b/src/events/index.js @@ -1,5 +1,5 @@ -import { EVENT, BUS_API_EVENT } from '../constants'; +import { EVENT, CONNECT_NOTIFICATION_EVENT } from '../constants'; import { projectCreatedHandler, projectUpdatedHandler, projectDeletedHandler, projectUpdatedKafkaHandler } from './projects'; import { projectMemberAddedHandler, projectMemberRemovedHandler, @@ -41,9 +41,6 @@ export const rabbitHandlers = { [EVENT.ROUTING_KEY.PROJECT_PHASE_ADDED]: projectPhaseAddedHandler, [EVENT.ROUTING_KEY.PROJECT_PHASE_REMOVED]: projectPhaseRemovedHandler, [EVENT.ROUTING_KEY.PROJECT_PHASE_UPDATED]: projectPhaseUpdatedHandler, - [EVENT.ROUTING_KEY.PROJECT_PHASE_ADDED]: projectPhaseAddedHandler, - [EVENT.ROUTING_KEY.PROJECT_PHASE_REMOVED]: projectPhaseRemovedHandler, - [EVENT.ROUTING_KEY.PROJECT_PHASE_UPDATED]: projectPhaseUpdatedHandler, [EVENT.ROUTING_KEY.PROJECT_PHASE_PRODUCT_ADDED]: phaseProductAddedHandler, [EVENT.ROUTING_KEY.PROJECT_PHASE_PRODUCT_REMOVED]: phaseProductRemovedHandler, [EVENT.ROUTING_KEY.PROJECT_PHASE_PRODUCT_UPDATED]: phaseProductUpdatedHandler, @@ -60,18 +57,18 @@ export const rabbitHandlers = { export const kafkaHandlers = { // Events defined by project-api - [BUS_API_EVENT.PROJECT_UPDATED]: projectUpdatedKafkaHandler, - [BUS_API_EVENT.PROJECT_FILES_UPDATED]: projectUpdatedKafkaHandler, - [BUS_API_EVENT.PROJECT_TEAM_UPDATED]: projectUpdatedKafkaHandler, - [BUS_API_EVENT.PROJECT_PLAN_UPDATED]: projectUpdatedKafkaHandler, + [CONNECT_NOTIFICATION_EVENT.PROJECT_UPDATED]: projectUpdatedKafkaHandler, + [CONNECT_NOTIFICATION_EVENT.PROJECT_FILES_UPDATED]: projectUpdatedKafkaHandler, + [CONNECT_NOTIFICATION_EVENT.PROJECT_TEAM_UPDATED]: projectUpdatedKafkaHandler, + [CONNECT_NOTIFICATION_EVENT.PROJECT_PLAN_UPDATED]: projectUpdatedKafkaHandler, // Events from message-service - [BUS_API_EVENT.TOPIC_CREATED]: projectUpdatedKafkaHandler, - [BUS_API_EVENT.TOPIC_UPDATED]: projectUpdatedKafkaHandler, - [BUS_API_EVENT.POST_CREATED]: projectUpdatedKafkaHandler, - [BUS_API_EVENT.POST_UPDATED]: projectUpdatedKafkaHandler, + [CONNECT_NOTIFICATION_EVENT.TOPIC_CREATED]: projectUpdatedKafkaHandler, + [CONNECT_NOTIFICATION_EVENT.TOPIC_UPDATED]: projectUpdatedKafkaHandler, + [CONNECT_NOTIFICATION_EVENT.POST_CREATED]: projectUpdatedKafkaHandler, + [CONNECT_NOTIFICATION_EVENT.POST_UPDATED]: projectUpdatedKafkaHandler, // Events coming from timeline/milestones (considering it as a separate module/service in future) - [BUS_API_EVENT.MILESTONE_TRANSITION_COMPLETED]: milestoneUpdatedKafkaHandler, - [BUS_API_EVENT.TIMELINE_ADJUSTED]: timelineAdjustedKafkaHandler, + [CONNECT_NOTIFICATION_EVENT.MILESTONE_TRANSITION_COMPLETED]: milestoneUpdatedKafkaHandler, + [CONNECT_NOTIFICATION_EVENT.TIMELINE_ADJUSTED]: timelineAdjustedKafkaHandler, }; diff --git a/src/events/milestones/index.js b/src/events/milestones/index.js index d8f884a..8b71766 100644 --- a/src/events/milestones/index.js +++ b/src/events/milestones/index.js @@ -7,7 +7,7 @@ import Joi from 'joi'; import Promise from 'bluebird'; import util from '../../util'; // import { createEvent } from '../../services/busApi'; -import { EVENT, TIMELINE_REFERENCES, MILESTONE_STATUS, REGEX } from '../../constants'; +import { EVENT, TIMELINE_REFERENCES, MILESTONE_STATUS, REGEX, RESOURCES, ROUTES } from '../../constants'; import models from '../../models'; const ES_TIMELINE_INDEX = config.get('elasticsearchConfig.timelineIndexName'); @@ -82,35 +82,14 @@ const milestoneUpdatedHandler = Promise.coroutine(function* (logger, msg, channe }); } - // if (data.original.order !== data.updated.order) { - // const milestoneWithSameOrder = - // _.find(milestones, milestone => milestone.id !== data.updated.id && milestone.order === data.updated.order); - // if (milestoneWithSameOrder) { - // // Increase the order from M to K: if there is an item with order K, - // // orders from M+1 to K should be made M to K-1 - // if (data.original.order < data.updated.order) { - // _.each(milestones, (single) => { - // if (single.id !== data.updated.id - // && (data.original.order + 1) <= single.order - // && single.order <= data.updated.order) { - // single.order -= 1; // eslint-disable-line no-param-reassign - // } - // }); - // } else { - // // Decrease the order from M to K: if there is an item with order K, - // // orders from K to M-1 should be made K+1 to M - // _.each(milestones, (single) => { - // if (single.id !== data.updated.id - // && data.updated.order <= single.order - // && single.order <= (data.original.order - 1)) { - // single.order += 1; // eslint-disable-line no-param-reassign - // } - // }); - // } - // } - // } + let updatedTimeline = doc._source; // eslint-disable-line no-underscore-dangle + // if timeline has been modified during milestones updates + if (data.cascadedUpdates && data.cascadedUpdates.timeline && data.cascadedUpdates.timeline.updated) { + // merge updated timeline with the object in ES index, the same way as we do when updating timeline in ES using timeline endpoints + updatedTimeline = _.merge(doc._source, data.cascadedUpdates.timeline.updated); // eslint-disable-line no-underscore-dangle + } - const merged = _.assign(doc._source, { milestones }); // eslint-disable-line no-underscore-dangle + const merged = _.assign(updatedTimeline, { milestones }); yield eClient.update({ index: ES_TIMELINE_INDEX, type: ES_TIMELINE_TYPE, @@ -240,14 +219,18 @@ async function milestoneUpdatedKafkaHandler(app, topic, payload) { }, ['progress', 'duration']); app.logger.debug(`Updated phase progress ${timeline.progress} and duration ${timeline.duration}`); app.logger.debug('Raising node event for PROJECT_PHASE_UPDATED'); - app.emit(EVENT.ROUTING_KEY.PROJECT_PHASE_UPDATED, { - req: { + util.sendResourceToKafkaBus( + { params: { projectId: project.id, phaseId: phase.id }, authUser: { userId: payload.userId }, }, - original: phase, - updated: _.omit(updatedPhase.toJSON(), 'deletedAt', 'deletedBy'), - }); + EVENT.ROUTING_KEY.PROJECT_PHASE_UPDATED, + RESOURCES.PHASE, + _.omit(updatedPhase.toJSON(), 'deletedAt', 'deletedBy'), + phase, + _.get(project, 'details.settings.workstreams') ? ROUTES.WORKS.UPDATE : ROUTES.PHASES.UPDATE, + true, // don't send event to Notification Service as the main event here is updating milestones, not phase + ); } } } diff --git a/src/events/projectPhases/index.js b/src/events/projectPhases/index.js index d9e8c21..e8e3337 100644 --- a/src/events/projectPhases/index.js +++ b/src/events/projectPhases/index.js @@ -7,6 +7,8 @@ import config from 'config'; import _ from 'lodash'; import Promise from 'bluebird'; import util from '../../util'; +import { TIMELINE_REFERENCES } from '../../constants'; + import messageService from '../../services/messageService'; const ES_PROJECT_INDEX = config.get('elasticsearchConfig.indexName'); @@ -14,6 +16,39 @@ const ES_PROJECT_TYPE = config.get('elasticsearchConfig.docType'); const eClient = util.getElasticSearchClient(); +/** + * Build topics data based on route parameter. + * + * @param {Object} logger logger to log along with trace id + * @param {Object} phase phase object + * @param {String} route route value can be PHASE/WORK + * @returns {undefined} + */ +const buildTopicsData = (logger, phase, route) => { + if (route === TIMELINE_REFERENCES.WORK) { + return [{ + tag: `work#${phase.id}-details`, + title: `${phase.name} - Details`, + reference: 'project', + referenceId: `${phase.projectId}`, + body: 'This is the beginning of your phase discussion. During execution of this phase, all related communication will be conducted here - phase updates, questions and answers, suggestions, etc. If you haven\'t already, do please take a moment to review the form in the Specification tab above and fill in as much detail as possible. This will help get started faster. Thanks!', // eslint-disable-line + }, { + tag: `work#${phase.id}-requirements`, + title: `${phase.name} - Requirements`, + reference: 'project', + referenceId: `${phase.projectId}`, + body: 'This is the beginning of your phase discussion. During execution of this phase, all related communication will be conducted here - phase updates, questions and answers, suggestions, etc. If you haven\'t already, do please take a moment to review the form in the Specification tab above and fill in as much detail as possible. This will help get started faster. Thanks!', // eslint-disable-line + }]; + } + return [{ + tag: `phase#${phase.id}`, + title: phase.name, + reference: 'project', + referenceId: `${phase.projectId}`, + body: 'This is the beginning of your phase discussion. During execution of this phase, all related communication will be conducted here - phase updates, questions and answers, suggestions, etc. If you haven\'t already, do please take a moment to review the form in the Specification tab above and fill in as much detail as possible. This will help get started faster. Thanks!', // eslint-disable-line + }]; +}; + /** * Indexes the project phase in the elastic search. * @@ -59,26 +94,22 @@ const indexProjectPhase = Promise.coroutine(function* (logger, phase) { // eslin }); /** - * Creates a new phase topic in message api. + * Creates topics in message api * * @param {Object} logger logger to log along with trace id - * @param {Object} msg event payload + * @param {Object} phase phase object + * @param {String} route route value can be `phase`/`work` * @returns {undefined} */ -const createPhaseTopic = Promise.coroutine(function* (logger, phase) { // eslint-disable-line func-names +const createTopics = Promise.coroutine(function* (logger, phase, route) { // eslint-disable-line func-names try { - logger.debug('Creating topic for phase with phase', phase); - const topic = yield messageService.createTopic({ - reference: 'project', - referenceId: `${phase.projectId}`, - tag: `phase#${phase.id}`, - title: phase.name, - body: 'This is the beginning of your phase discussion. During execution of this phase, all related communication will be conducted here - phase updates, questions and answers, suggestions, etc. If you haven\'t already, do please take a moment to review the form in the Specification tab above and fill in as much detail as possible. This will help get started faster. Thanks!', // eslint-disable-line - }, logger); - logger.debug('topic for the phase created successfully'); - logger.debug('created topic', topic); + logger.debug(`Creating topics for ${route} with phase`, phase); + const topicsData = buildTopicsData(logger, phase, route); + const topics = yield Promise.all(_.map(topicsData, topicData => messageService.createTopic(topicData, logger))); + logger.debug(`topics for the ${route} created successfully`); + logger.debug('created topics', topics); } catch (error) { - logger.error('Error in creating topic for the project phase', error); + logger.error(`Error in creating topic for ${route}`, error); // don't throw the error back to nack the bus, because we don't want to get multiple topics per phase // we can create topic for a phase manually, if somehow it fails } @@ -92,12 +123,14 @@ const createPhaseTopic = Promise.coroutine(function* (logger, phase) { // eslint * @returns {undefined} */ const projectPhaseAddedHandler = Promise.coroutine(function* (logger, msg, channel) { // eslint-disable-line func-names - const phase = JSON.parse(msg.content.toString()); + const data = JSON.parse(msg.content.toString()); + const phase = _.get(data, 'added', {}); + const route = _.get(data, 'route', 'PHASE'); try { logger.debug('calling indexProjectPhase', phase); yield indexProjectPhase(logger, phase, channel); logger.debug('calling createPhaseTopic', phase); - yield createPhaseTopic(logger, phase); + yield createTopics(logger, phase, route); channel.ack(msg); } catch (error) { logger.error('Error handling project.phase.added event', error); @@ -135,31 +168,46 @@ const updateIndexProjectPhase = Promise.coroutine(function* (logger, data) { // }); /** - * Creates a new phase topic in message api. + * Update one topic + * + * @param {Object} logger logger to log along with trace id + * @param {Object} phase phase object + * @param {Object} topicUpdate updated topic data + * @returns {undefined} + */ +const updateOneTopic = Promise.coroutine(function* (logger, phase, topicUpdate) { // eslint-disable-line func-names + const topic = yield messageService.getTopicByTag(phase.projectId, topicUpdate.tag, logger); + logger.trace('Topic', topic); + const title = topicUpdate.title; + const titleChanged = topic && topic.title !== title; + logger.trace('titleChanged', titleChanged); + const contentPost = topic && topic.posts && topic.posts.length > 0 ? topic.posts[0] : null; + logger.trace('contentPost', contentPost); + const postId = _.get(contentPost, 'id'); + const content = _.get(contentPost, 'body'); + if (postId && content && titleChanged) { + const updatedTopic = yield messageService.updateTopic(topic.id, { title, postId, content }, logger); + logger.debug('topic updated successfully'); + logger.trace('updated topic', updatedTopic); + } +}); + +/** + * Update topics in message api. * * @param {Object} logger logger to log along with trace id - * @param {Object} msg event payload + * @param {Object} phase phase object + * @param {String} route route value can be `phase`/`work` * @returns {undefined} */ -const updatePhaseTopic = Promise.coroutine(function* (logger, phase) { // eslint-disable-line func-names +const updateTopics = Promise.coroutine(function* (logger, phase, route) { // eslint-disable-line func-names try { - logger.debug('Updating topic for phase with phase', phase); - const topic = yield messageService.getPhaseTopic(phase.projectId, phase.id, logger); - logger.trace('Topic', topic); - const title = phase.name; - const titleChanged = topic && topic.title !== title; - logger.trace('titleChanged', titleChanged); - const contentPost = topic && topic.posts && topic.posts.length > 0 ? topic.posts[0] : null; - logger.trace('contentPost', contentPost); - const postId = _.get(contentPost, 'id'); - const content = _.get(contentPost, 'body'); - if (postId && content && titleChanged) { - const updatedTopic = yield messageService.updateTopic(topic.id, { title, postId, content }, logger); - logger.debug('topic for the phase updated successfully'); - logger.trace('updated topic', updatedTopic); - } + logger.debug(`Updating topic for ${route} with phase`, phase); + const topicsData = buildTopicsData(logger, phase, route); + yield Promise.all(_.map(topicsData, topicData => updateOneTopic(logger, phase, topicData))); + logger.debug(`topics for the ${route} updated successfully`); } catch (error) { - logger.error('Error in updating topic for the project phase', error); + logger.error(`Error in updating topic for ${route}`, error); // don't throw the error back to nack the bus, because we don't want to get multiple topics per phase // we can create topic for a phase manually, if somehow it fails } @@ -175,10 +223,11 @@ const updatePhaseTopic = Promise.coroutine(function* (logger, phase) { // eslint const projectPhaseUpdatedHandler = Promise.coroutine(function* (logger, msg, channel) { // eslint-disable-line func-names try { const data = JSON.parse(msg.content.toString()); + const route = _.get(data, 'route', 'PHASE'); logger.debug('calling updateIndexProjectPhase', data); yield updateIndexProjectPhase(logger, data, channel); - logger.debug('calling updatePhaseTopic', data.updated); - yield updatePhaseTopic(logger, data.updated); + logger.debug('calling updateTopics', data.updated); + yield updateTopics(logger, data.updated, route); channel.ack(msg); } catch (error) { logger.error('Error handling project.phase.updated event', error); @@ -197,13 +246,14 @@ const projectPhaseUpdatedHandler = Promise.coroutine(function* (logger, msg, cha const removePhaseFromIndex = Promise.coroutine(function* (logger, msg) { // eslint-disable-line func-names try { const data = JSON.parse(msg.content.toString()); - const doc = yield eClient.get({ index: ES_PROJECT_INDEX, type: ES_PROJECT_TYPE, id: data.projectId }); - const phases = _.filter(doc._source.phases, single => single.id !== data.id); // eslint-disable-line no-underscore-dangle + const phase = _.get(data, 'deleted', {}); + const doc = yield eClient.get({ index: ES_PROJECT_INDEX, type: ES_PROJECT_TYPE, id: phase.projectId }); + const phases = _.filter(doc._source.phases, single => single.id !== phase.id); // eslint-disable-line no-underscore-dangle const merged = _.assign(doc._source, { phases }); // eslint-disable-line no-underscore-dangle yield eClient.update({ index: ES_PROJECT_INDEX, type: ES_PROJECT_TYPE, - id: data.projectId, + id: phase.projectId, body: { doc: merged, }, @@ -217,26 +267,46 @@ const removePhaseFromIndex = Promise.coroutine(function* (logger, msg) { // esli }); /** - * Removes the phase topic from the message api. + * Removes one topic from the message api. * - * @param {Object} logger logger to log along with trace id - * @param {Object} msg event payload + * @param {Object} logger logger to log along with trace id + * @param {Object} phase phase object + * @param {Object} tag topic tag * @returns {undefined} */ -const removePhaseTopic = Promise.coroutine(function* (logger, msg) { // eslint-disable-line func-names +const removeOneTopic = Promise.coroutine(function* (logger, phase, tag) { // eslint-disable-line func-names try { - const phase = JSON.parse(msg.content.toString()); - const phaseTopic = yield messageService.getPhaseTopic(phase.projectId, phase.id, logger); + const phaseTopic = yield messageService.getTopicByTag(phase.projectId, tag, logger); yield messageService.deletePosts(phaseTopic.id, phaseTopic.postIds, logger); yield messageService.deleteTopic(phaseTopic.id, logger); - logger.debug('topic for the phase removed successfully'); } catch (error) { - logger.error('Error in removing topic for the project phase', error); + logger.error(`Error removing topic by tab ${tag}`, error); // don't throw the error back to nack the bus // we can delete topic for a phase manually, if somehow it fails } }); +/** + * Remove topics in message api. + * + * @param {Object} logger logger to log along with trace id + * @param {Object} phase phase object + * @param {String} route route value can be `phase`/`work` + * @returns {undefined} + */ +const removeTopics = Promise.coroutine(function* (logger, phase, route) { // eslint-disable-line func-names + try { + logger.debug(`Removing topic for ${route} with phase`, phase); + const topicsData = buildTopicsData(logger, phase, route); + yield Promise.all(_.map(topicsData, topicData => removeOneTopic(logger, phase, topicData.tag))); + logger.debug(`topics for the ${route} removed successfully`); + } catch (error) { + logger.error(`Error in removing topic for ${route}`, error); + // don't throw the error back to nack the bus, because we don't want to get multiple topics per phase + // we can create topic for a phase manually, if somehow it fails + } +}); + /** * Handler for project phase deleted event * @param {Object} logger logger to log along with trace id @@ -247,7 +317,11 @@ const removePhaseTopic = Promise.coroutine(function* (logger, msg) { // eslint-d const projectPhaseRemovedHandler = Promise.coroutine(function* (logger, msg, channel) { // eslint-disable-line func-names try { yield removePhaseFromIndex(logger, msg, channel); - yield removePhaseTopic(logger, msg); + const data = JSON.parse(msg.content.toString()); + const phase = _.get(data, 'deleted', {}); + const route = _.get(data, 'route'); + logger.debug('calling removeTopics'); + yield removeTopics(logger, phase, route); channel.ack(msg); } catch (error) { logger.error('Error fetching project document from elasticsearch', error); @@ -261,5 +335,5 @@ module.exports = { projectPhaseAddedHandler, projectPhaseRemovedHandler, projectPhaseUpdatedHandler, - createPhaseTopic, + createPhaseTopic: createTopics, }; diff --git a/src/events/projects/index.spec.js b/src/events/projects/index.spec.js index 2b149f8..3184a42 100644 --- a/src/events/projects/index.spec.js +++ b/src/events/projects/index.spec.js @@ -94,6 +94,7 @@ describe('projectUpdatedKafkaHandler', () => { beforeEach(async () => { await testUtil.clearDb(); + await testUtil.clearES(); project = await models.Project.create({ type: 'generic', billingAccountId: 1, diff --git a/src/models/buildingBlock.js b/src/models/buildingBlock.js new file mode 100644 index 0000000..a28762a --- /dev/null +++ b/src/models/buildingBlock.js @@ -0,0 +1,57 @@ +/* eslint-disable valid-jsdoc */ +/** + * BuildingBlock model + * + * WARNING: This model contains sensitive data! + * + * - To return data from this model to the user always use methods `find`/`findAll` which would + * filter out the sensitive data which should be never returned to the user. + * - For internal usage you can use `options.includePrivateConfigForInternalUsage` + * which would force `find`/`findAll` to return fields which contain sensitive data. + * Use the data returned in such way ONLY FOR INTERNAL usage. It means such data can be used + * to make some calculations inside Project Service but it should be never returned to the user as it is. + */ +module.exports = (sequelize, DataTypes) => { + const BuildingBlock = sequelize.define('BuildingBlock', { + id: { type: DataTypes.BIGINT, primaryKey: true, autoIncrement: true }, + key: { type: DataTypes.STRING(255), allowNull: false, unique: true }, + config: { type: DataTypes.JSON, allowNull: false, defaultValue: {} }, + privateConfig: { type: DataTypes.JSON, allowNull: false, defaultValue: {} }, + 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: 'building_blocks', + paranoid: true, + timestamps: true, + updatedAt: 'updatedAt', + createdAt: 'createdAt', + deletedAt: 'deletedAt', + hooks: { + /** + * Inside before hook we are evaluating if user has permission to retrieve `privateConfig` field. + * If no, we remove this field from the attributes list, so this field is not requested and thus + * not returned. + * + * @param {Object} options find/findAll options + */ + afterFind: function removePrivateConfig(buildingBlocks, options) { + // ONLY FOR INTERNAL USAGE: don't use this option to return the data by API + if (!options.includePrivateConfigForInternalUsage) { + // try to remove privateConfig from result + buildingBlocks.map((block) => { + const b = block; + delete b.privateConfig; + return b; + }); + } + return buildingBlocks; + }, + }, + }); + + return BuildingBlock; +}; diff --git a/src/models/form.js b/src/models/form.js index 1c41b53..bf7ebe3 100644 --- a/src/models/form.js +++ b/src/models/form.js @@ -42,6 +42,7 @@ module.exports = (sequelize, DataTypes) => { Form.latestVersion = classMethods.latestVersion; Form.latestRevisionOfLatestVersion = classMethods.latestRevisionOfLatestVersion; Form.latestVersionIncludeUsed = classMethods.latestVersionIncludeUsed; + Form.findOneWithLatestRevision = classMethods.findOneWithLatestRevision; return Form; }; diff --git a/src/models/milestone.js b/src/models/milestone.js index 1f01ae7..644f7b0 100644 --- a/src/models/milestone.js +++ b/src/models/milestone.js @@ -1,6 +1,54 @@ +import _ from 'lodash'; import moment from 'moment'; +import models from '../models'; +import { STATUS_HISTORY_REFERENCES } from '../constants'; /* eslint-disable valid-jsdoc */ +/** + * Populate and map milestone model with statusHistory + * NOTE that this function mutates milestone + * + * @param {Array|Object} milestone one milestone or list of milestones + * @param {Object} options options which has been used to call main method + * + * @returns {Promise} promise + */ +const populateWithStatusHistory = async (milestone, options) => { + // depend on this option `milestone` is a sequlize ORM object or plain JS object + const isRaw = !!_.get(options, 'raw'); + const getMilestoneId = m => ( + isRaw ? m.id : m.dataValues.id + ); + const formatMilestone = statusHistory => ( + isRaw ? { statusHistory } : { dataValues: { statusHistory } } + ); + if (Array.isArray(milestone)) { + const allStatusHistory = await models.StatusHistory.findAll({ + where: { + referenceId: { $in: milestone.map(getMilestoneId) }, + reference: 'milestone', + }, + order: [['createdAt', 'desc']], + raw: true, + }); + + return milestone.map((m, index) => { + const statusHistory = _.filter(allStatusHistory, { referenceId: getMilestoneId(m) }); + return _.merge(milestone[index], formatMilestone(statusHistory)); + }); + } + + const statusHistory = await models.StatusHistory.findAll({ + where: { + referenceId: getMilestoneId(milestone), + reference: 'milestone', + }, + order: [['createdAt', 'desc']], + raw: true, + }); + return _.merge(milestone, formatMilestone(statusHistory)); +}; + /** * The Milestone model */ @@ -18,10 +66,10 @@ module.exports = (sequelize, DataTypes) => { 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 }, + plannedText: { type: DataTypes.STRING(512) }, + activeText: { type: DataTypes.STRING(512) }, + completedText: { type: DataTypes.STRING(512) }, + blockedText: { type: DataTypes.STRING(512) }, hidden: { type: DataTypes.BOOLEAN, defaultValue: false }, deletedAt: DataTypes.DATE, createdAt: { type: DataTypes.DATE, defaultValue: DataTypes.NOW }, @@ -36,6 +84,54 @@ module.exports = (sequelize, DataTypes) => { updatedAt: 'updatedAt', createdAt: 'createdAt', deletedAt: 'deletedAt', + hooks: { + afterCreate: (milestone, options) => models.StatusHistory.create({ + reference: STATUS_HISTORY_REFERENCES.MILESTONE, + referenceId: milestone.id, + status: milestone.status, + comment: null, + createdBy: milestone.createdBy, + updatedBy: milestone.updatedBy, + }, { + transaction: options.transaction, + }).then(() => populateWithStatusHistory(milestone, options)), + + afterBulkCreate: (milestones, options) => { + const listStatusHistory = milestones.map(({ dataValues }) => ({ + reference: STATUS_HISTORY_REFERENCES.MILESTONE, + referenceId: dataValues.id, + status: dataValues.status, + comment: null, + createdBy: dataValues.createdBy, + updatedBy: dataValues.updatedBy, + })); + + return models.StatusHistory.bulkCreate(listStatusHistory, { + transaction: options.transaction, + }).then(() => populateWithStatusHistory(milestones, options)); + }, + + afterUpdate: (milestone, options) => { + if (milestone.changed().includes('status')) { + return models.StatusHistory.create({ + reference: STATUS_HISTORY_REFERENCES.MILESTONE, + referenceId: milestone.id, + status: milestone.status, + comment: options.comment || null, + createdBy: milestone.createdBy, + updatedBy: milestone.updatedBy, + }, { + transaction: options.transaction, + }).then(() => populateWithStatusHistory(milestone)); + } + return populateWithStatusHistory(milestone, options); + }, + + afterFind: (milestone, options) => { + if (!milestone) return Promise.resolve(); + return populateWithStatusHistory(milestone, options); + }, + }, }); /** diff --git a/src/models/phaseWorkStream.js b/src/models/phaseWorkStream.js new file mode 100644 index 0000000..d420f21 --- /dev/null +++ b/src/models/phaseWorkStream.js @@ -0,0 +1,14 @@ +/* eslint-disable valid-jsdoc */ + +/** + * The PhaseWorkStream model + */ + +module.exports = (sequelize) => { + const PhaseWorkStream = sequelize.define('PhaseWorkStream', {}, + { + tableName: 'phase_work_streams', + }); + + return PhaseWorkStream; +}; diff --git a/src/models/planConfig.js b/src/models/planConfig.js index 8231a68..4db619c 100644 --- a/src/models/planConfig.js +++ b/src/models/planConfig.js @@ -42,6 +42,7 @@ module.exports = (sequelize, DataTypes) => { PlanConfig.latestVersion = classMethods.latestVersion; PlanConfig.latestRevisionOfLatestVersion = classMethods.latestRevisionOfLatestVersion; PlanConfig.latestVersionIncludeUsed = classMethods.latestVersionIncludeUsed; + PlanConfig.findOneWithLatestRevision = classMethods.findOneWithLatestRevision; return PlanConfig; }; diff --git a/src/models/priceConfig.js b/src/models/priceConfig.js index a954344..31f4a92 100644 --- a/src/models/priceConfig.js +++ b/src/models/priceConfig.js @@ -41,6 +41,7 @@ module.exports = (sequelize, DataTypes) => { PriceConfig.latestVersion = classMethods.latestVersion; PriceConfig.latestRevisionOfLatestVersion = classMethods.latestRevisionOfLatestVersion; PriceConfig.latestVersionIncludeUsed = classMethods.latestVersionIncludeUsed; + PriceConfig.findOneWithLatestRevision = classMethods.findOneWithLatestRevision; return PriceConfig; }; diff --git a/src/models/project.js b/src/models/project.js index b7bd964..6cfb9d0 100644 --- a/src/models/project.js +++ b/src/models/project.js @@ -67,6 +67,8 @@ module.exports = function defineProject(sequelize, DataTypes) { Project.hasMany(models.ProjectAttachment, { as: 'attachments', foreignKey: 'projectId' }); Project.hasMany(models.ProjectPhase, { as: 'phases', foreignKey: 'projectId' }); Project.hasMany(models.ProjectMemberInvite, { as: 'memberInvites', foreignKey: 'projectId' }); + Project.hasMany(models.ScopeChangeRequest, { as: 'scopeChangeRequests', foreignKey: 'projectId' }); + Project.hasMany(models.WorkStream, { as: 'workStreams', foreignKey: 'projectId' }); }; /** diff --git a/src/models/projectEstimation.js b/src/models/projectEstimation.js index 5bc2a7e..9ab0d8b 100644 --- a/src/models/projectEstimation.js +++ b/src/models/projectEstimation.js @@ -1,33 +1,29 @@ -module.exports = function defineProjectHistory(sequelize, DataTypes) { - const ProjectEstimation = sequelize.define( - 'ProjectEstimation', - { - id: { type: DataTypes.BIGINT, primaryKey: true, autoIncrement: true }, - buildingBlockKey: { type: DataTypes.STRING, allowNull: false }, - conditions: { type: DataTypes.STRING, allowNull: false }, - price: { type: DataTypes.DOUBLE, allowNull: false }, - quantity: { type: DataTypes.INTEGER, allowNull: true }, - minTime: { type: DataTypes.INTEGER, allowNull: false }, - maxTime: { type: DataTypes.INTEGER, allowNull: false }, - metadata: { type: DataTypes.JSON, allowNull: false, defaultValue: {} }, - projectId: { type: DataTypes.BIGINT, allowNull: false }, +module.exports = function defineProjectEstimation(sequelize, DataTypes) { + const ProjectEstimation = sequelize.define('ProjectEstimation', { + id: { type: DataTypes.BIGINT, primaryKey: true, autoIncrement: true }, + buildingBlockKey: { type: DataTypes.STRING, allowNull: false }, + conditions: { type: DataTypes.STRING, allowNull: false }, + price: { type: DataTypes.DOUBLE, allowNull: false }, + quantity: { type: DataTypes.INTEGER, allowNull: true }, + minTime: { type: DataTypes.INTEGER, allowNull: false }, + maxTime: { type: DataTypes.INTEGER, allowNull: false }, + metadata: { type: DataTypes.JSON, allowNull: false, defaultValue: {} }, + projectId: { type: DataTypes.BIGINT, allowNull: false }, - 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_estimations', - paranoid: true, - timestamps: true, - updatedAt: 'updatedAt', - createdAt: 'createdAt', - indexes: [], - }, - ); + 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_estimations', + paranoid: true, + timestamps: true, + updatedAt: 'updatedAt', + createdAt: 'createdAt', + indexes: [], + }); return ProjectEstimation; }; diff --git a/src/models/projectEstimationItem.js b/src/models/projectEstimationItem.js new file mode 100644 index 0000000..0e0aeb8 --- /dev/null +++ b/src/models/projectEstimationItem.js @@ -0,0 +1,164 @@ +/* eslint-disable valid-jsdoc */ +/** + * ProjectEstimationItem model + * + * WARNING: This model contains sensitive data! + * + * - To return data from this model to the user always use methods `find`/`findAll` + * and provide to them `options.reqUser` and `options.members` to check what + * types of Project Estimation Items user which makes the request can get. + * - For internal usage you can use `options.includeAllProjectEstimatinoItemsForInternalUsage` + * which would force `find`/`findAll` to return all the records without checking permissions. + * Use the data returned in such way ONLY FOR INTERNAL usage. It means such data can be used + * to make some calculations inside Project Service but it should be never returned to the user as it is. + */ +import _ from 'lodash'; +import util from '../util'; +import { + ESTIMATION_TYPE, + MANAGER_ROLES, + PROJECT_MEMBER_ROLE, +} from '../constants'; + +/* + This config defines which Project Estimation Item `types` users can get + based on their permissions + */ +const permissionsConfigs = [ + // Topcoder managers can get all types of Project Estimation Items + { + permission: { topcoderRoles: MANAGER_ROLES }, + types: _.values(ESTIMATION_TYPE), + }, + + // Project Copilots can get only 'community' type of Project Estimation Items + { + permission: { projectRoles: PROJECT_MEMBER_ROLE.COPILOT }, + types: [ESTIMATION_TYPE.COMMUNITY], + }, +]; + +module.exports = function defineProjectEstimationItem(sequelize, DataTypes) { + const ProjectEstimationItem = sequelize.define( + 'ProjectEstimationItem', + { + id: { type: DataTypes.BIGINT, primaryKey: true, autoIncrement: true }, + projectEstimationId: { type: DataTypes.BIGINT, allowNull: false }, // ProjectEstimation id + price: { type: DataTypes.DOUBLE, allowNull: false }, + type: { + type: DataTypes.STRING, + allowNull: false, + validate: { + isIn: [_.values(ESTIMATION_TYPE)], + }, + }, + markupUsedReference: { type: DataTypes.STRING, allowNull: false }, + markupUsedReferenceId: { type: DataTypes.BIGINT, allowNull: false }, // ProjectSetting id + metadata: { type: DataTypes.JSON, allowNull: false, defaultValue: {} }, + deletedAt: { type: DataTypes.DATE, allowNull: true }, + createdAt: { type: DataTypes.DATE, defaultValue: DataTypes.NOW }, + updatedAt: { type: DataTypes.DATE, defaultValue: DataTypes.NOW }, + deletedBy: DataTypes.INTEGER, + createdBy: { type: DataTypes.INTEGER, allowNull: false }, + updatedBy: { type: DataTypes.INTEGER, allowNull: false }, + }, + { + tableName: 'project_estimation_items', + paranoid: true, + timestamps: true, + updatedAt: 'updatedAt', + createdAt: 'createdAt', + indexes: [], + hooks: { + /** + * Inside before hook we are evaluating what Project Estimation Item types current user may retrieve. + * We update `where` query so only allowed types may be retrieved. + * + * @param {Object} options find/findAll options + * @param {Function} callback callback after hook + */ + beforeFind: (options, callback) => { + // ONLY FOR INTERNAL USAGE: don't use this option to return the data by API + if (options.includeAllProjectEstimatinoItemsForInternalUsage) { + return callback ? callback(null) : null; + } + + if (!options.reqUser || !options.members) { + const err = new Error('You must provide auth user and project members to get project estimation items'); + if (!callback) throw err; + return callback(err); + } + + // find all project estimation item types which are allowed to be returned to the user + let allowedTypes = []; + permissionsConfigs.forEach((permissionsConfig) => { + if (util.hasPermission(permissionsConfig.permission, options.reqUser, options.members)) { + allowedTypes = _.concat(allowedTypes, permissionsConfig.types); + } + }); + allowedTypes = _.uniq(allowedTypes); + + // only return Project Estimation Types which are allowed to the user + options.where.type = allowedTypes; // eslint-disable-line no-param-reassign + return callback ? callback(null) : null; + }, + }, + }, + ); + + /** + * Find all project estimation items for project + * + * TODO: this method can rewritten without using `models` + * and using JOIN instead for retrieving ProjectEstimationTimes by projectId + * + * @param {Object} models all models + * @param {Number} projectId project id + * @param {Object} [options] options + * + * @returns {Promise} list of project estimation items + */ + ProjectEstimationItem.findAllByProject = (models, projectId, options) => + models.ProjectEstimation.findAll({ + raw: true, + where: { + projectId, + }, + }).then((estimations) => { + const optionsCombined = _.assign({}, options); + // update where to always filter by projectEstimationsIds of the project + optionsCombined.where = _.assign({}, optionsCombined.where, { + projectEstimationId: _.map(estimations, 'id'), + }); + + return ProjectEstimationItem.findAll(optionsCombined); + }); + + /** + * Delete all project estimation items for project + * + * TODO: this method can rewritten without using `models` + * and using JOIN instead for retrieving ProjectEstimationTimes by projectId + * + * @param {Object} models all models + * @param {Number} projectId project id + * @param {Object} reqUser user who makes the request + * @param {Object} [options] options + * + * @returns {Promise} result of destroy query + */ + ProjectEstimationItem.deleteAllForProject = (models, projectId, reqUser, options) => + ProjectEstimationItem.findAllByProject(models, projectId, options) + .then((estimationItems) => { + const estimationItemsOptions = { + where: { + id: _.map(estimationItems, 'id'), + }, + }; + + return ProjectEstimationItem.update({ deletedBy: reqUser.userId }, estimationItemsOptions) + .then(() => ProjectEstimationItem.destroy(estimationItemsOptions)); + }); + + return ProjectEstimationItem; +}; diff --git a/src/models/projectMemberInvite.js b/src/models/projectMemberInvite.js index f45d4b7..bacee6c 100644 --- a/src/models/projectMemberInvite.js +++ b/src/models/projectMemberInvite.js @@ -67,7 +67,10 @@ module.exports = function defineProjectMemberInvite(sequelize, DataTypes) { const where = { projectId, status: INVITE_STATUS.PENDING }; if (email && userId) { - _.assign(where, { $or: [{ email: { $eq: email } }, { userId: { $eq: userId } }] }); + _.assign(where, { $or: [ + { email: { $eq: email.toLowerCase() } }, + { userId: { $eq: userId } }, + ] }); } else if (email) { _.assign(where, { email }); } else if (userId) { diff --git a/src/models/projectPhase.js b/src/models/projectPhase.js index a74675c..5c260e1 100644 --- a/src/models/projectPhase.js +++ b/src/models/projectPhase.js @@ -4,6 +4,8 @@ module.exports = function defineProjectPhase(sequelize, DataTypes) { const ProjectPhase = sequelize.define('ProjectPhase', { id: { type: DataTypes.BIGINT, primaryKey: true, autoIncrement: true }, name: { type: DataTypes.STRING, allowNull: true }, + description: { type: DataTypes.STRING, allowNull: true }, + requirements: { type: DataTypes.STRING, allowNull: true }, status: { type: DataTypes.STRING, allowNull: true }, startDate: { type: DataTypes.DATE, allowNull: true }, endDate: { type: DataTypes.DATE, allowNull: true }, @@ -40,6 +42,7 @@ module.exports = function defineProjectPhase(sequelize, DataTypes) { ProjectPhase.associate = (models) => { ProjectPhase.hasMany(models.PhaseProduct, { as: 'products', foreignKey: 'phaseId' }); + ProjectPhase.belongsToMany(models.WorkStream, { through: models.PhaseWorkStream, foreignKey: 'phaseId' }); }; /** @@ -53,22 +56,28 @@ module.exports = function defineProjectPhase(sequelize, DataTypes) { * @return {Object} the result rows and count */ ProjectPhase.search = async (parameters = {}, log) => { - let fieldsStr = _.map(parameters.fields, field => `project_phases."${field}"`); - fieldsStr = `${fieldsStr.join(',')}`; - const replacements = { - projectId: parameters.projectId, - }; - let dbQuery = `SELECT ${fieldsStr} FROM project_phases WHERE project_phases."projectId" = :projectId`; + // ordering + const orderBy = []; if (_.has(parameters, 'sortField') && _.has(parameters, 'sortType')) { - dbQuery = `${dbQuery} ORDER BY project_phases."${parameters.sortField}" ${parameters.sortType}`; + orderBy.push([parameters.sortField, parameters.sortType]); + } + // find options + const options = { + where: { + projectId: parameters.projectId, + }, + order: orderBy, + logging: (str) => { log.debug(str); }, + }; + // select fields + if (_.has(parameters, 'fields')) { + _.set(options, 'attributes', parameters.fields.filter(e => e !== 'products')); + if (parameters.fields.includes('products')) { + _.set(options, 'include', [{ model: this.sequelize.models.PhaseProduct, as: 'products' }]); + } } - return sequelize.query(dbQuery, - { type: sequelize.QueryTypes.SELECT, - logging: (str) => { log.debug(str); }, - replacements, - raw: true, - }) - .then(phases => ({ rows: phases, count: phases.length })); + + return ProjectPhase.findAll(options).then(phases => ({ rows: phases, count: phases.length })); }; return ProjectPhase; diff --git a/src/models/projectSetting.js b/src/models/projectSetting.js new file mode 100644 index 0000000..dd72026 --- /dev/null +++ b/src/models/projectSetting.js @@ -0,0 +1,115 @@ +/* eslint-disable valid-jsdoc */ +/** + * ProjectSetting model + * + * WARNING: This model contains sensitive data! + * + * - To return data from this model to the user always use methods `find`/`findAll` + * and provide to them `options.reqUser` and `options.members` to check which records could be returned + * based on the user roles and `readPermission` property of the records. + * - For internal usage you can use `options.includeAllProjectSettingsForInternalUsage` + * which would force `find`/`findAll` to return all the records without checking permissions. + * Use the data returned in such way ONLY FOR INTERNAL usage. It means such data can be used + * to make some calculations inside Project Service but it should be never returned to the user as it is. + */ + +import _ from 'lodash'; +import { VALUE_TYPE } from '../constants'; +import util from '../util'; + +module.exports = (sequelize, DataTypes) => { + const ProjectSetting = sequelize.define( + 'ProjectSetting', + { + id: { type: DataTypes.BIGINT, primaryKey: true, autoIncrement: true }, + key: { type: DataTypes.STRING(255) }, + value: { type: DataTypes.STRING(255) }, + valueType: { + type: DataTypes.STRING, + validate: { + isIn: [_.values(VALUE_TYPE)], + }, + }, + projectId: { type: DataTypes.BIGINT, allowNull: false }, // Project id + metadata: { type: DataTypes.JSON, allowNull: false, defaultValue: {} }, + readPermission: { type: DataTypes.JSON, allowNull: false, defaultValue: {} }, + writePermission: { type: DataTypes.JSON, allowNull: false, defaultValue: {} }, + deletedAt: { type: DataTypes.DATE, allowNull: true }, + createdAt: { type: DataTypes.DATE, defaultValue: DataTypes.NOW }, + updatedAt: { type: DataTypes.DATE, defaultValue: DataTypes.NOW }, + deletedBy: DataTypes.INTEGER, + createdBy: { type: DataTypes.INTEGER, allowNull: false }, + updatedBy: { type: DataTypes.INTEGER, allowNull: false }, + }, + { + tableName: 'project_settings', + paranoid: true, + timestamps: true, + updatedAt: 'updatedAt', + createdAt: 'createdAt', + indexes: [ + { + unique: true, + fields: ['key', 'projectId'], + }, + ], + hooks: { + /** + * Inside before hook we are checking that required options are provided + * We do it in `beforeFind` instead of `afterFind` to avoid unnecessary data retrievement + * + * @param {Object} options find/findAll options + * @param {Function} callback callback after hook + */ + beforeFind: (options, callback) => { + // ONLY FOR INTERNAL USAGE: don't use this option to return the data by API + if (options.includeAllProjectSettingsForInternalUsage) { + return callback ? callback(null) : null; + } + + if (!options.reqUser || !options.members) { + const err = new Error('You must provide reqUser and project member to get project settings'); + if (!callback) throw err; + return callback(err); + } + + return callback ? callback(null) : null; + }, + + /** + * Inside after hook we are filtering records based on `readPermission` and user roles + * + * @param {Mixed} results one result from `find()` or array of results form `findAll()` + * @param {Object} options find/findAll options + * @param {Function} callback callback after hook + */ + afterFind: (results, options, callback) => { + // ONLY FOR INTERNAL USAGE: don't use this option to return the data by API + if (options.includeAllProjectSettingsForInternalUsage) { + return callback ? callback(null) : null; + } + + // if we have an array of results form `findAll()` we are filtering results + if (_.isArray(results)) { + // remove results from the "end" using `index` if user doesn't have permissions for to access them + for (let index = results.length - 1; index >= 0; index -= 1) { + if (!util.hasPermission(results[index].readPermission, options.reqUser, options.members)) { + results.splice(index, 1); + } + } + + // if we have one result from `find()` we check if user has permission for the record + } else if (results && !util.hasPermission(results.readPermission, options.reqUser, options.members)) { + const err = new Error('User doesn\'t have permission to access this record.'); + if (!callback) throw err; + return callback(err); + } + + return callback ? callback(null) : null; + }, + }, + }, + ); + + return ProjectSetting; +}; diff --git a/src/models/projectTemplate.js b/src/models/projectTemplate.js index dd61765..2d14299 100644 --- a/src/models/projectTemplate.js +++ b/src/models/projectTemplate.js @@ -1,4 +1,7 @@ /* eslint-disable valid-jsdoc */ +import _ from 'lodash'; + +import models from './'; /** * The Project Template model @@ -35,5 +38,15 @@ module.exports = (sequelize, DataTypes) => { deletedAt: 'deletedAt', }); + ProjectTemplate.getTemplate = templateId => + ProjectTemplate.findByPk(templateId, { raw: true }) + .then((template) => { + const formRef = template.form; + return formRef + ? models.Form.findAll({ where: formRef, raw: true }) + .then(forms => Object.assign({}, template, { form: _.maxBy(forms, f => f.revision) })) + : template; + }); + return ProjectTemplate; }; diff --git a/src/models/scopeChangeRequest.js b/src/models/scopeChangeRequest.js new file mode 100644 index 0000000..e527b0c --- /dev/null +++ b/src/models/scopeChangeRequest.js @@ -0,0 +1,68 @@ +/* eslint-disable valid-jsdoc */ +import { SCOPE_CHANGE_REQ_STATUS } from '../constants'; + +/** + * The ScopeChangeRequest model + */ + +module.exports = (sequelize, DataTypes) => { + const ScopeChangeRequest = sequelize.define('ScopeChangeRequest', { + id: { type: DataTypes.BIGINT, primaryKey: true, autoIncrement: true }, + projectId: { type: DataTypes.BIGINT, allowNull: false }, + oldScope: { type: DataTypes.JSON, allowNull: false }, + newScope: { type: DataTypes.JSON, allowNull: false }, + status: { type: DataTypes.STRING(45), allowNull: false }, + + deletedAt: { type: DataTypes.DATE, allowNull: true }, + createdAt: { type: DataTypes.DATE, defaultValue: DataTypes.NOW }, + updatedAt: { type: DataTypes.DATE, defaultValue: DataTypes.NOW }, + approvedAt: { type: DataTypes.DATE, allowNull: true }, + deletedBy: { type: DataTypes.INTEGER, allowNull: true }, + createdBy: { type: DataTypes.INTEGER, allowNull: false }, + updatedBy: { type: DataTypes.INTEGER, allowNull: false }, + approvedBy: { type: DataTypes.INTEGER, allowNull: true }, + }, { + tableName: 'scope_change_requests', + paranoid: true, + timestamps: true, + updatedAt: 'updatedAt', + createdAt: 'createdAt', + deletedAt: 'deletedAt', + }); + + ScopeChangeRequest.findScopeChangeRequest = (projectId, { requestId, status }) => { + const where = { + projectId, + }; + if (status) { + where.status = status; + } + if (requestId) { + where.id = requestId; + } + return ScopeChangeRequest.findOne({ + where, + }); + }; + + ScopeChangeRequest.findPendingScopeChangeRequest = projectId => + ScopeChangeRequest.findScopeChangeRequest( + projectId, + { status: { $in: [SCOPE_CHANGE_REQ_STATUS.PENDING, SCOPE_CHANGE_REQ_STATUS.APPROVED] } }, + ); + + ScopeChangeRequest.getProjectScopeChangeRequests = (projectId, status) => { + const where = { + projectId, + }; + if (status) { + where.status = status; + } + return ScopeChangeRequest.findAll({ + where, + raw: true, + }); + }; + + return ScopeChangeRequest; +}; diff --git a/src/models/statusHistory.js b/src/models/statusHistory.js new file mode 100644 index 0000000..b73d365 --- /dev/null +++ b/src/models/statusHistory.js @@ -0,0 +1,35 @@ +/* eslint-disable valid-jsdoc */ + +import _ from 'lodash'; +import { MILESTONE_STATUS } from '../constants'; + +module.exports = function defineStatusHistory(sequelize, DataTypes) { + const StatusHistory = sequelize.define('StatusHistory', { + id: { type: DataTypes.BIGINT, primaryKey: true, autoIncrement: true }, + reference: { type: DataTypes.STRING, allowNull: false }, + referenceId: { type: DataTypes.BIGINT, allowNull: false }, + status: { + type: DataTypes.STRING, + allowNull: false, + validate: { + isIn: [_.values(MILESTONE_STATUS)], + }, + }, + comment: DataTypes.TEXT, + createdBy: { type: DataTypes.INTEGER, allowNull: false }, + createdAt: { type: DataTypes.DATE, defaultValue: DataTypes.NOW }, + updatedBy: { type: DataTypes.INTEGER, allowNull: false }, + updatedAt: { type: DataTypes.DATE, defaultValue: DataTypes.NOW }, + }, { + tableName: 'status_history', + paranoid: false, + timestamps: true, + updatedAt: 'updatedAt', + createdAt: 'createdAt', + deletedAt: 'deletedAt', + indexes: [], + classMethods: {}, + }); + + return StatusHistory; +}; diff --git a/src/models/versionModelClassMethods.js b/src/models/versionModelClassMethods.js index 13d79d7..bcf7c7f 100644 --- a/src/models/versionModelClassMethods.js +++ b/src/models/versionModelClassMethods.js @@ -9,6 +9,17 @@ */ function versionModelClassMethods(model, jsonField) { return { + findOneWithLatestRevision(query) { + return model.findOne({ + where: { + key: query.key, + version: query.version, + }, + order: [['revision', 'DESC']], + limit: 1, + attributes: { exclude: ['deletedAt', 'deletedBy'] }, + }); + }, deleteOldestRevision(userId, key, version) { return model.findOne({ where: { diff --git a/src/models/workManagementPermission.js b/src/models/workManagementPermission.js new file mode 100644 index 0000000..17915e6 --- /dev/null +++ b/src/models/workManagementPermission.js @@ -0,0 +1,35 @@ +/* eslint-disable valid-jsdoc */ + +/** + * The WorkManagementPermission model + */ +module.exports = (sequelize, DataTypes) => { + const WorkManagementPermission = sequelize.define('WorkManagementPermission', { + id: { type: DataTypes.BIGINT, primaryKey: true, autoIncrement: true }, + policy: { type: DataTypes.STRING(255), allowNull: false }, + permission: { type: DataTypes.JSON, allowNull: false }, + projectTemplateId: { type: DataTypes.BIGINT, allowNull: 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: 'work_management_permissions', + paranoid: true, + timestamps: true, + updatedAt: 'updatedAt', + createdAt: 'createdAt', + deletedAt: 'deletedAt', + indexes: [ + { + unique: true, + fields: ['policy', 'projectTemplateId'], + }, + ], + }); + + return WorkManagementPermission; +}; diff --git a/src/models/workStream.js b/src/models/workStream.js new file mode 100644 index 0000000..d43628b --- /dev/null +++ b/src/models/workStream.js @@ -0,0 +1,42 @@ +/* eslint-disable valid-jsdoc */ + +/** + * The WorkStream model + */ +import _ from 'lodash'; +import { WORKSTREAM_STATUS } from '../constants'; + +module.exports = (sequelize, DataTypes) => { + const WorkStream = sequelize.define('WorkStream', { + id: { type: DataTypes.BIGINT, primaryKey: true, autoIncrement: true }, + name: { type: DataTypes.STRING(255), allowNull: false }, + type: { type: DataTypes.STRING(45), allowNull: false }, + status: { + type: DataTypes.STRING, + allowNull: false, + validate: { + isIn: [_.values(WORKSTREAM_STATUS)], + }, + }, + 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: 'work_streams', + paranoid: true, + timestamps: true, + updatedAt: 'updatedAt', + createdAt: 'createdAt', + deletedAt: 'deletedAt', + indexes: [], + }); + + WorkStream.associate = (models) => { + WorkStream.belongsToMany(models.ProjectPhase, { through: models.PhaseWorkStream, foreignKey: 'workStreamId' }); + }; + + return WorkStream; +}; diff --git a/src/permissions/constants.js b/src/permissions/constants.js new file mode 100644 index 0000000..570bc15 --- /dev/null +++ b/src/permissions/constants.js @@ -0,0 +1,39 @@ +/** + * Definitions of permissions which could be used with util methods + * `util.hasPermission` or `util.hasPermissionForProject`. + * + * We can define permission using two logics: + * 1. **WHAT** can be done with such a permission. Such constants may have names like: + * - `VIEW_PROJECT` + * - `EDIT_MILESTONE` + * - `DELETE_WORK` + * and os on. + * 2. **WHO** can do actions with such a permission. Such constants **MUST** start from the prefix `ROLES_`, examples: + * - `ROLES_COPILOT_AND_ABOVE` + * - `ROLES_PROJECT_MEMBERS` + * - `ROLES_ADMINS` + */ +import { + PROJECT_MEMBER_ROLE, + ADMIN_ROLES, +} from '../constants'; + +export const PERMISSION = { // eslint-disable-line import/prefer-default-export + /** + * Permissions defined by logic: **WHO** can do actions with such a permission. + */ + ROLES_COPILOT_AND_ABOVE: { + topcoderRoles: ADMIN_ROLES, + projectRoles: [ + PROJECT_MEMBER_ROLE.PROGRAM_MANAGER, + PROJECT_MEMBER_ROLE.SOLUTION_ARCHITECT, + PROJECT_MEMBER_ROLE.PROJECT_MANAGER, + PROJECT_MEMBER_ROLE.MANAGER, + PROJECT_MEMBER_ROLE.COPILOT, + ], + }, + /** + * Permissions defined by logic: **WHAT** can be done with such a permission. + */ +}; + diff --git a/src/permissions/copilotAndAbove.js b/src/permissions/copilotAndAbove.js index e5d5121..d6e8b21 100644 --- a/src/permissions/copilotAndAbove.js +++ b/src/permissions/copilotAndAbove.js @@ -1,18 +1,31 @@ +import _ from 'lodash'; import util from '../util'; -import { MANAGER_ROLES, USER_ROLE } from '../constants'; - +import models from '../models'; +import { PERMISSION } from './constants'; /** - * Permission to alloow copilot and above roles to perform certain operations + * Permission to allow copilot and above roles to perform certain operations + * - User with Topcoder admins roles should be able to perform the operations. + * - Project members with copilot and manager Project roles should be also able to perform the operations. * @param {Object} req the express request instance * @return {Promise} returns a promise */ module.exports = req => new Promise((resolve, reject) => { - const hasAccess = util.hasRoles(req, [...MANAGER_ROLES, USER_ROLE.COPILOT]); + const projectId = _.parseInt(req.params.projectId); + + return models.ProjectMember.getActiveProjectMembers(projectId) + .then((members) => { + const hasPermission = util.hasPermission(PERMISSION.ROLES_COPILOT_AND_ABOVE, req.authUser, members); - if (!hasAccess) { - return reject(new Error('You do not have permissions to perform this action')); - } + // TODO should we really do this? + // if no, we can replace `getActiveProjectMembers + util.hasPermission` with one `util.hasPermissionForProject` + req.context = req.context || {}; + req.context.currentProjectMembers = members; - return resolve(true); + if (!hasPermission) { + // the copilot or manager is not a registered project member + return reject(new Error('You do not have permissions to perform this action')); + } + return resolve(true); + }); }); diff --git a/src/permissions/index.js b/src/permissions/index.js index 431816c..33dd62a 100644 --- a/src/permissions/index.js +++ b/src/permissions/index.js @@ -8,8 +8,10 @@ const projectMemberDelete = require('./projectMember.delete'); const projectAdmin = require('./admin.ops'); const projectAttachmentUpdate = require('./project.updateAttachment'); const projectAttachmentDownload = require('./project.downloadAttachment'); -// const connectManagerOrAdmin = require('./connectManagerOrAdmin.ops'); +const connectManagerOrAdmin = require('./connectManagerOrAdmin.ops'); const copilotAndAbove = require('./copilotAndAbove'); +const workManagementPermissions = require('./workManagementForTemplate'); +const projectSettingEdit = require('./projectSetting.edit'); module.exports = () => { Authorizer.setDeniedStatusCode(403); @@ -100,4 +102,40 @@ module.exports = () => { Authorizer.setPolicy('planConfig.edit', projectAdmin); Authorizer.setPolicy('planConfig.delete', projectAdmin); Authorizer.setPolicy('planConfig.view', true); // anyone can view price config + + // Work stream + Authorizer.setPolicy('workStream.create', projectAdmin); + Authorizer.setPolicy('workStream.edit', workManagementPermissions('workStream.edit')); + Authorizer.setPolicy('workStream.delete', projectAdmin); + Authorizer.setPolicy('workStream.view', projectView); + + // Work + Authorizer.setPolicy('work.create', workManagementPermissions('work.create')); + Authorizer.setPolicy('work.edit', workManagementPermissions('work.edit')); + Authorizer.setPolicy('work.delete', workManagementPermissions('work.delete')); + Authorizer.setPolicy('work.view', projectView); + + // Work item + Authorizer.setPolicy('workItem.create', workManagementPermissions('workItem.create')); + Authorizer.setPolicy('workItem.edit', workManagementPermissions('workItem.edit')); + Authorizer.setPolicy('workItem.delete', workManagementPermissions('workItem.delete')); + Authorizer.setPolicy('workItem.view', projectView); + + // Work management permission + Authorizer.setPolicy('workManagementPermission.create', projectAdmin); + Authorizer.setPolicy('workManagementPermission.edit', projectAdmin); + Authorizer.setPolicy('workManagementPermission.delete', projectAdmin); + Authorizer.setPolicy('workManagementPermission.view', projectAdmin); + + // Project Permissions + Authorizer.setPolicy('permissions.view', projectView); + + // Project Settings + Authorizer.setPolicy('projectSetting.create', connectManagerOrAdmin); + Authorizer.setPolicy('projectSetting.edit', projectSettingEdit); + Authorizer.setPolicy('projectSetting.delete', connectManagerOrAdmin); + Authorizer.setPolicy('projectSetting.view', projectView); + + // Project Estimation Items + Authorizer.setPolicy('projectEstimation.item.list', copilotAndAbove); }; diff --git a/src/permissions/project.delete.js b/src/permissions/project.delete.js index 9fe49fc..1d0841c 100644 --- a/src/permissions/project.delete.js +++ b/src/permissions/project.delete.js @@ -23,7 +23,13 @@ module.exports = freq => new Promise((resolve, reject) => { const hasAccess = util.hasAdminRole(req) || !_.isUndefined(_.find(members, m => m.userId === req.authUser.userId && ((m.role === PROJECT_MEMBER_ROLE.CUSTOMER && m.isPrimary) || - m.role === PROJECT_MEMBER_ROLE.MANAGER))); + [ + PROJECT_MEMBER_ROLE.MANAGER, + PROJECT_MEMBER_ROLE.PROGRAM_MANAGER, + PROJECT_MEMBER_ROLE.PROJECT_MANAGER, + PROJECT_MEMBER_ROLE.SOLUTION_ARCHITECT, + ].includes(m.role) + ))); if (!hasAccess) { // user is not an admin nor is a registered project member diff --git a/src/permissions/projectMember.delete.js b/src/permissions/projectMember.delete.js index 0f0ec42..5f4bb94 100644 --- a/src/permissions/projectMember.delete.js +++ b/src/permissions/projectMember.delete.js @@ -25,7 +25,12 @@ module.exports = freq => new Promise((resolve, reject) => { const memberToBeRemoved = _.find(members, m => m.id === prjMemberId); // check if auth user has acecss to this project const hasAccess = util.hasAdminRole(req) - || (authMember && memberToBeRemoved && (authMember.role === PROJECT_MEMBER_ROLE.MANAGER || + || (authMember && memberToBeRemoved && ([ + PROJECT_MEMBER_ROLE.MANAGER, + PROJECT_MEMBER_ROLE.PROGRAM_MANAGER, + PROJECT_MEMBER_ROLE.PROJECT_MANAGER, + PROJECT_MEMBER_ROLE.SOLUTION_ARCHITECT, + ].includes(authMember.role) || (authMember.role === PROJECT_MEMBER_ROLE.CUSTOMER && authMember.isPrimary && memberToBeRemoved.role === PROJECT_MEMBER_ROLE.CUSTOMER) || memberToBeRemoved.userId === req.authUser.userId)); diff --git a/src/permissions/projectSetting.edit.js b/src/permissions/projectSetting.edit.js new file mode 100644 index 0000000..0d9794c --- /dev/null +++ b/src/permissions/projectSetting.edit.js @@ -0,0 +1,40 @@ +import util from '../util'; +import models from '../models'; + +/** + * Only users who have "writePermission" of ProjectSetting can edit this entity. + * @param {Object} freq the express request instance + * @return {Promise} Returns a promise + */ +module.exports = freq => new Promise((resolve, reject) => { + const projectId = freq.params.projectId; + const settingId = freq.params.id; + let projectMembers = []; + + return models.ProjectMember.getActiveProjectMembers(projectId) + .then((members) => { + const req = freq; + req.context = req.context || {}; + req.context.currentProjectMembers = members; + + projectMembers = members; + return Promise.resolve(); + }) + .then(() => models.ProjectSetting.findOne({ + where: { projectId, id: settingId }, + raw: true, + includeAllProjectSettingsForInternalUsage: true, + }).then((setting) => { + if (!setting) { + // let route handle this 404 error. + return resolve(true); + } + const hasAccess = util.hasPermission(setting.writePermission, freq.authUser, projectMembers); + if (!hasAccess) { + const errorMessage = 'You do not have permissions to perform this action'; + // user is not an admin nor is a registered project member + return reject(new Error(errorMessage)); + } + return resolve(true); + })); +}); diff --git a/src/permissions/workManagementForTemplate.js b/src/permissions/workManagementForTemplate.js new file mode 100644 index 0000000..12c97c3 --- /dev/null +++ b/src/permissions/workManagementForTemplate.js @@ -0,0 +1,56 @@ + +import _ from 'lodash'; +import util from '../util'; +import models from '../models'; +import { MANAGER_ROLES } from '../constants'; + +/** + * Based on allowRule and denyRule allow/deny users to execute the policy + * @param {String} policy the work management permission policy + * @return {Promise} Returns a promise + */ +module.exports = policy => req => new Promise((resolve, reject) => { + const projectId = _.parseInt(req.params.projectId); + + return models.Project.findOne({ + where: { + id: projectId, + }, + }) + .then((project) => { + if (!project) { + const apiErr = new Error(`Project not found for id ${projectId}`); + apiErr.status = 404; + return Promise.reject(apiErr); + } + + if (!project.templateId) { + return null; + } + + return models.WorkManagementPermission.findOne({ + where: { + policy, + projectTemplateId: project.templateId, + }, + }); + }) + .then((workManagementPermission) => { + if (!workManagementPermission) { + // TODO REMOVE THIS!!! + // TEMPORARY let all the Topcoder managers to do all the work management + // if there are no permission records in the DB for the template + return util.hasPermission({ topcoderRoles: MANAGER_ROLES }, req.authUser); + // return false; + } + + return util.hasPermissionForProject(workManagementPermission.permission, req.authUser, projectId); + }) + .then((hasAccess) => { + if (!hasAccess) { + const errorMessage = 'You do not have permissions to perform this action'; + return reject(new Error(errorMessage)); + } + return resolve(true); + }); +}); diff --git a/src/routes/admin/project-index-create.js b/src/routes/admin/project-index-create.js index 9a42151..a6f8dfb 100644 --- a/src/routes/admin/project-index-create.js +++ b/src/routes/admin/project-index-create.js @@ -117,6 +117,7 @@ module.exports = [ }) .then((result) => { logger.debug(`project indexed successfully (projectId: ${projectIdStart}-${projectIdEnd})`, result); + logger.debug(result); }) .catch((error) => { logger.error(`Error in indexing project (projectId: ${projectIdStart}-${projectIdEnd})`, error); diff --git a/src/routes/attachments/create.js b/src/routes/attachments/create.js index c594713..f46a4a9 100644 --- a/src/routes/attachments/create.js +++ b/src/routes/attachments/create.js @@ -109,7 +109,7 @@ module.exports = [ }).then((_newAttachment) => { newAttachment = _newAttachment.get({ plain: true }); req.log.debug('New Attachment record: ', newAttachment); - if (process.env.NODE_ENV !== 'development') { + if (process.env.NODE_ENV !== 'development' || config.get('enableFileUpload') === 'true') { // retrieve download url for the response req.log.debug('retrieving download url'); return httpClient.post(`${fileServiceUrl}downloadurl`, { @@ -118,7 +118,7 @@ module.exports = [ } return Promise.resolve(); }).then((resp) => { - if (process.env.NODE_ENV !== 'development') { + if (process.env.NODE_ENV !== 'development' || config.get('enableFileUpload') === 'true') { req.log.debug('Retreiving Presigned Url resp: ', JSON.stringify(resp.data)); return new Promise((accept, reject) => { if (resp.status !== 200 || resp.data.result.status !== 200) { diff --git a/src/routes/attachments/create.spec.js b/src/routes/attachments/create.spec.js index 0b150f6..e7e0294 100644 --- a/src/routes/attachments/create.spec.js +++ b/src/routes/attachments/create.spec.js @@ -7,7 +7,7 @@ import models from '../../models'; import util from '../../util'; import testUtil from '../../tests/util'; import busApi from '../../services/busApi'; -import { BUS_API_EVENT, RESOURCES } from '../../constants'; +import { BUS_API_EVENT, RESOURCES, CONNECT_NOTIFICATION_EVENT } from '../../constants'; const should = chai.should(); @@ -150,7 +150,7 @@ describe('Project Attachments', () => { createEventSpy = sandbox.spy(busApi, 'createEvent'); }); - it('sends BUS_API_EVENT.PROJECT_ATTACHMENT_ADDED message when attachment added', (done) => { + it('sends send correct BUS API messages when attachment added', (done) => { request(server) .post(`/v5/projects/${project1.id}/attachments/`) .set({ @@ -164,17 +164,27 @@ describe('Project Attachments', () => { } else { // Wait for app message handler to complete testUtil.wait(() => { - createEventSpy.calledOnce.should.be.true; - createEventSpy.calledWith(BUS_API_EVENT.PROJECT_ATTACHMENT_ADDED, - sinon.match({ resource: RESOURCES.ATTACHMENT })).should.be.true; - createEventSpy.calledWith(BUS_API_EVENT.PROJECT_ATTACHMENT_ADDED, - sinon.match({ title: body.title })).should.be.true; - createEventSpy.calledWith(BUS_API_EVENT.PROJECT_ATTACHMENT_ADDED, - sinon.match({ description: body.description })).should.be.true; - createEventSpy.calledWith(BUS_API_EVENT.PROJECT_ATTACHMENT_ADDED, - sinon.match({ category: body.category })).should.be.true; - createEventSpy.calledWith(BUS_API_EVENT.PROJECT_ATTACHMENT_ADDED, - sinon.match({ contentType: body.contentType })).should.be.true; + createEventSpy.calledThrice.should.be.true; + + createEventSpy.calledWith(BUS_API_EVENT.PROJECT_ATTACHMENT_ADDED, sinon.match({ + resource: RESOURCES.ATTACHMENT, + title: body.title, + description: body.description, + category: body.category, + contentType: body.contentType, + })).should.be.true; + + // Check Notification Service events + createEventSpy.calledWith(CONNECT_NOTIFICATION_EVENT.PROJECT_FILE_UPLOADED) + .should.be.true; + createEventSpy.calledWith(CONNECT_NOTIFICATION_EVENT.PROJECT_FILES_UPDATED, sinon.match({ + projectId: project1.id, + projectName: project1.name, + projectUrl: `https://local.topcoder-dev.com/projects/${project1.id}`, + userId: 40051333, + initiatorUserId: 40051333, + })).should.be.true; + done(); }); } diff --git a/src/routes/attachments/delete.js b/src/routes/attachments/delete.js index e96050f..2e3a821 100644 --- a/src/routes/attachments/delete.js +++ b/src/routes/attachments/delete.js @@ -5,6 +5,7 @@ import _ from 'lodash'; import { middleware as tcMiddleware, } from 'tc-core-library-js'; +import config from 'config'; import models from '../../models'; import util from '../../util'; import fileService from '../../services/fileService'; @@ -42,7 +43,7 @@ module.exports = [ .then(() => _attachment.destroy()); })) .then((_attachment) => { - if (process.env.NODE_ENV !== 'development') { + if (process.env.NODE_ENV !== 'development' || config.get('enableFileUpload') === 'true') { return fileService.deleteFile(req, _attachment.filePath); } return Promise.resolve(); @@ -55,7 +56,6 @@ module.exports = [ pattachment, { correlationId: req.id }, ); - // req.app.emit(EVENT.ROUTING_KEY.PROJECT_ATTACHMENT_REMOVED, { req, pattachment }); // emit the event util.sendResourceToKafkaBus( req, diff --git a/src/routes/attachments/delete.spec.js b/src/routes/attachments/delete.spec.js index 5911254..d31d171 100644 --- a/src/routes/attachments/delete.spec.js +++ b/src/routes/attachments/delete.spec.js @@ -9,7 +9,7 @@ import util from '../../util'; import server from '../../app'; import testUtil from '../../tests/util'; import busApi from '../../services/busApi'; -import { BUS_API_EVENT, RESOURCES } from '../../constants'; +import { BUS_API_EVENT, RESOURCES, CONNECT_NOTIFICATION_EVENT } from '../../constants'; const should = chai.should(); // eslint-disable-line no-unused-vars @@ -182,7 +182,7 @@ describe('Project Attachments delete', () => { createEventSpy = sandbox.spy(busApi, 'createEvent'); }); - it('sends BUS_API_EVENT.PROJECT_ATTACHMENT_REMOVED message when attachment deleted', (done) => { + it('sends send correct BUS API messages when attachment deleted', (done) => { request(server) .delete(`/v5/projects/${project1.id}/attachments/${attachment.id}`) .set({ @@ -195,11 +195,22 @@ describe('Project Attachments delete', () => { } else { // Wait for app message handler to complete testUtil.wait(() => { - createEventSpy.calledOnce.should.be.true; - createEventSpy.calledWith(BUS_API_EVENT.PROJECT_ATTACHMENT_REMOVED, - sinon.match({ resource: RESOURCES.ATTACHMENT })).should.be.true; - createEventSpy.calledWith(BUS_API_EVENT.PROJECT_ATTACHMENT_REMOVED, - sinon.match({ id: attachment.id })).should.be.true; + createEventSpy.calledTwice.should.be.true; + + createEventSpy.calledWith(BUS_API_EVENT.PROJECT_ATTACHMENT_REMOVED, sinon.match({ + resource: RESOURCES.ATTACHMENT, + id: attachment.id, + })).should.be.true; + + // Check Notification Service events + createEventSpy.calledWith(CONNECT_NOTIFICATION_EVENT.PROJECT_FILES_UPDATED, sinon.match({ + projectId: project1.id, + projectName: project1.name, + projectUrl: `https://local.topcoder-dev.com/projects/${project1.id}`, + userId: 40051333, + initiatorUserId: 40051333, + })).should.be.true; + done(); }); } diff --git a/src/routes/attachments/download.js b/src/routes/attachments/download.js index b3815e5..d925774 100644 --- a/src/routes/attachments/download.js +++ b/src/routes/attachments/download.js @@ -61,6 +61,9 @@ module.exports = [ err.status = 404; return Promise.reject(err); } + if (process.env.NODE_ENV === 'development' && config.get('enableFileUpload') === 'false') { + return ['dummy://url']; + } return getFileDownloadUrl(req, attachment.filePath); }) @@ -76,6 +79,7 @@ module.exports = [ return getFileDownloadUrl(req, attachment.filePath); }) .then((result) => { + req.log.debug('getFileDownloadUrl result: ', JSON.stringify(result)); const url = result[1]; return res.json({ url }); }) diff --git a/src/routes/attachments/update.spec.js b/src/routes/attachments/update.spec.js index 4b7c774..34957a4 100644 --- a/src/routes/attachments/update.spec.js +++ b/src/routes/attachments/update.spec.js @@ -7,7 +7,7 @@ import models from '../../models'; import server from '../../app'; import testUtil from '../../tests/util'; import busApi from '../../services/busApi'; -import { BUS_API_EVENT, RESOURCES } from '../../constants'; +import { BUS_API_EVENT, RESOURCES, CONNECT_NOTIFICATION_EVENT } from '../../constants'; const should = chai.should(); @@ -145,7 +145,7 @@ describe('Project Attachments update', () => { createEventSpy = sandbox.stub(busApi, 'createEvent'); }); - it('sends single BUS_API_EVENT.PROJECT_FILES_UPDATED message when attachment updated', (done) => { + it('sends send correct BUS API messages when attachment updated', (done) => { request(server) .patch(`/v5/projects/${project1.id}/attachments/${attachment.id}`) .set({ @@ -159,13 +159,23 @@ describe('Project Attachments update', () => { } else { // Wait for app message handler to complete testUtil.wait(() => { - createEventSpy.calledOnce.should.be.true; - createEventSpy.calledWith(BUS_API_EVENT.PROJECT_ATTACHMENT_UPDATED, - sinon.match({ resource: RESOURCES.ATTACHMENT })).should.be.true; - createEventSpy.calledWith(BUS_API_EVENT.PROJECT_ATTACHMENT_UPDATED, - sinon.match({ title: 'updated title' })).should.be.true; - createEventSpy.calledWith(BUS_API_EVENT.PROJECT_ATTACHMENT_UPDATED, - sinon.match({ description: 'updated description' })).should.be.true; + createEventSpy.calledTwice.should.be.true; + + createEventSpy.calledWith(BUS_API_EVENT.PROJECT_ATTACHMENT_UPDATED, sinon.match({ + resource: RESOURCES.ATTACHMENT, + title: 'updated title', + description: 'updated description', + })).should.be.true; + + // Check Notification Service events + createEventSpy.calledWith(CONNECT_NOTIFICATION_EVENT.PROJECT_FILES_UPDATED, sinon.match({ + projectId: project1.id, + projectName: project1.name, + projectUrl: `https://local.topcoder-dev.com/projects/${project1.id}`, + userId: 40051333, + initiatorUserId: 40051333, + })).should.be.true; + done(); }); } diff --git a/src/routes/form/version/getVersion.js b/src/routes/form/version/getVersion.js index 345f204..d416715 100644 --- a/src/routes/form/version/getVersion.js +++ b/src/routes/form/version/getVersion.js @@ -4,8 +4,10 @@ import validate from 'express-validation'; import Joi from 'joi'; import { middleware as tcMiddleware } from 'tc-core-library-js'; -import models from '../../../models'; + import util from '../../../util'; +import models from '../../../models'; + const permissions = tcMiddleware.permissions; @@ -44,15 +46,7 @@ module.exports = [ .then((data) => { if (data.length === 0) { req.log.debug('No form found in ES'); - models.Form.findOne({ - where: { - key: req.params.key, - version: req.params.version, - }, - order: [['revision', 'DESC']], - limit: 1, - attributes: { exclude: ['deletedAt', 'deletedBy'] }, - }) + return models.Form.findOneWithLatestRevision(req.params) .then((form) => { // Not found if (!form) { @@ -64,10 +58,10 @@ module.exports = [ return Promise.resolve(); }) .catch(next); - } else { - req.log.debug('forms found in ES'); - res.json(data[0].inner_hits.forms.hits.hits[0]._source); // eslint-disable-line no-underscore-dangle } + req.log.debug('forms found in ES'); + res.json(data[0].inner_hits.forms.hits.hits[0]._source); // eslint-disable-line no-underscore-dangle + return Promise.resolve(); }) .catch(next); }, diff --git a/src/routes/index.js b/src/routes/index.js index adb166b..c57e665 100644 --- a/src/routes/index.js +++ b/src/routes/index.js @@ -65,6 +65,11 @@ router.route('/v5/projects/metadata/productCategories') router.route('/v5/projects/metadata/productCategories/:key') .get(require('./productCategories/get')); +router.route('/v5/projects/metadata/workManagementPermission') + .get(require('./workManagementPermissions/list')); +router.route('/v5/projects/metadata/workManagementPermission/:id') + .get(require('./workManagementPermissions/get')); + router.use('/v5/projects/metadata', compression()); router.route('/v5/projects/metadata') @@ -90,6 +95,14 @@ router.route('/v5/projects/:projectId(\\d+)') .patch(require('./projects/update')) .delete(require('./projects/delete')); +router.route('/v5/projects/:projectId(\\d+)/scopeChangeRequests') + .post(require('./scopeChangeRequests/create')); + // .get(require('./scopeChangeRequests/list')); +router.route('/v5/projects/:projectId(\\d+)/scopeChangeRequests/:requestId(\\d+)') + // .get(require('./scopeChangeRequests/get')) + .patch(require('./scopeChangeRequests/update')); + // .delete(require('./scopeChangeRequests/delete')); + router.route('/v5/projects/:projectId(\\d+)/members') .get(require('./projectMembers/list')) .post(require('./projectMembers/create')); @@ -99,6 +112,10 @@ router.route('/v5/projects/:projectId(\\d+)/members/:id(\\d+)') .delete(require('./projectMembers/delete')) .patch(require('./projectMembers/update')); +// Permissions +router.route('/v5/projects/:projectId(\\d+)/permissions') + .get(require('./permissions/get')); + router.route('/v5/projects/:projectId(\\d+)/attachments') .post(require('./attachments/create')) .get(require('./attachments/list')); @@ -150,6 +167,13 @@ router.route('/v5/projects/metadata/productCategories/:key') .patch(require('./productCategories/update')) .delete(require('./productCategories/delete')); +router.route('/v5/projects/metadata/workManagementPermission') + .post(require('./workManagementPermissions/create')); + +router.route('/v5/projects/metadata/workManagementPermission/:id') + .patch(require('./workManagementPermissions/update')) + .delete(require('./workManagementPermissions/delete')); + router.route('/v5/projects/metadata/projectTypes') .post(require('./projectTypes/create')); @@ -264,6 +288,52 @@ router.route('/v5/projects/metadata/planConfig/:key/versions/:version(\\d+)') .patch(require('./planConfig/version/update')) .delete(require('./planConfig/version/delete')); +// work streams +router.route('/v5/projects/:projectId(\\d+)/workstreams') + .get(require('./workStreams/list')) + .post(require('./workStreams/create')); + +router.route('/v5/projects/:projectId(\\d+)/workstreams/:id(\\d+)') + .get(require('./workStreams/get')) + .patch(require('./workStreams/update')) + .delete(require('./workStreams/delete')); + +// works +router.route('/v5/projects/:projectId(\\d+)/workstreams/:workStreamId(\\d+)/works') + .get(require('./works/list')) + .post(require('./works/create')); + +router.route('/v5/projects/:projectId(\\d+)/workstreams/:workStreamId(\\d+)/works/:id(\\d+)') + .get(require('./works/get')) + .patch(require('./works/update')) + .delete(require('./works/delete')); + +// work items +router.route('/v5/projects/:projectId(\\d+)/workstreams/:workStreamId(\\d+)/works/:workId(\\d+)/workitems') + .get(require('./workItems/list')) + .post(require('./workItems/create')); + +router.route('/v5/projects/:projectId(\\d+)/workstreams/:workStreamId(\\d+)/works/:workId(\\d+)/workitems/:id(\\d+)') + .get(require('./workItems/get')) + .patch(require('./workItems/update')) + .delete(require('./workItems/delete')); + +router.route('/v5/projects/:projectId/reports') + .get(require('./projectReports/getReport')); + +// Project Settings +router.route('/v5/projects/:projectId(\\d+)/settings/:id(\\d+)') + .patch(require('./projectSettings/update')) + .delete(require('./projectSettings/delete')); + +router.route('/v5/projects/:projectId(\\d+)/settings') + .get(require('./projectSettings/list')) + .post(require('./projectSettings/create')); + +// Project Estimation Items +router.route('/v5/projects/:projectId(\\d+)/estimations/:estimationId(\\d+)/items') + .get(require('./projectEstimationItems/list')); + // 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/metadata/list.js b/src/routes/metadata/list.js index d2b239c..e254b97 100644 --- a/src/routes/metadata/list.js +++ b/src/routes/metadata/list.js @@ -26,6 +26,10 @@ function getUsedModel() { priceConfig: { }, }; const query = { + where: { + deletedAt: { $eq: null }, + disabled: false, + }, attributes: { exclude: ['deletedAt', 'deletedBy'] }, raw: true, }; @@ -73,6 +77,14 @@ module.exports = [ attributes: { exclude: ['deletedAt', 'deletedBy'] }, raw: true, }; + const projectProductTemplateQuery = { + where: { + deletedAt: { $eq: null }, + disabled: false, + }, + attributes: { exclude: ['deletedAt', 'deletedBy'] }, + raw: true, + }; // when user query with includeAllReferred, return result with all used version of // Form, PriceConfig, PlanConfig @@ -92,8 +104,8 @@ module.exports = [ }).then((latestVersionModels) => { latestVersion = latestVersionModels; return Promise.all([ - models.ProjectTemplate.findAll(query), - models.ProductTemplate.findAll(query), + models.ProjectTemplate.findAll(projectProductTemplateQuery), + models.ProductTemplate.findAll(projectProductTemplateQuery), models.MilestoneTemplate.findAll(query), models.ProjectType.findAll(query), models.ProductCategory.findAll(query), @@ -116,14 +128,15 @@ module.exports = [ .catch(next); } return Promise.all([ - models.ProjectTemplate.findAll(query), - models.ProductTemplate.findAll(query), + models.ProjectTemplate.findAll(projectProductTemplateQuery), + models.ProductTemplate.findAll(projectProductTemplateQuery), models.MilestoneTemplate.findAll(query), models.ProjectType.findAll(query), models.ProductCategory.findAll(query), models.Form.latestVersion(), models.PriceConfig.latestVersion(), models.PlanConfig.latestVersion(), + models.BuildingBlock.findAll(query), ]) .then((results) => { res.json({ @@ -135,6 +148,7 @@ module.exports = [ forms: results[5], priceConfigs: results[6], planConfigs: results[7], + buildingBlocks: results[8], }); }) .catch(next); diff --git a/src/routes/metadata/list.spec.js b/src/routes/metadata/list.spec.js index 762758e..52956d0 100644 --- a/src/routes/metadata/list.spec.js +++ b/src/routes/metadata/list.spec.js @@ -26,6 +26,7 @@ const projectTemplates = [ priceConfig: { key: 'key1', version: 1 }, createdBy: 1, updatedBy: 1, + disabled: false, }, ]; const productTemplates = [ @@ -111,7 +112,7 @@ const forms = [ { key: 'productKey 1', config: { - questions: [{ + sections: [{ id: 'appDefinition', title: 'Sample Project', required: true, @@ -178,6 +179,31 @@ const planConfigs = [ }, ]; +const buildingBlocks = [ + { + key: 'key1', + config: { + hello: 'world', + }, + privateConfig: { + message: 'you should not see this', + }, + createdBy: 1, + updatedBy: 1, + }, + { + key: 'key2', + config: { + hello: 'topcoder', + }, + privateConfig: { + message: 'you should not see this', + }, + createdBy: 1, + updatedBy: 1, + }, +]; + describe('GET all metadata', () => { before((done) => { testUtil.clearDb() @@ -188,7 +214,8 @@ describe('GET all metadata', () => { .then(() => models.ProductCategory.bulkCreate(productCategories)) .then(() => models.Form.bulkCreate(forms)) .then(() => models.PriceConfig.bulkCreate(priceConfigs)) - .then(() => models.PlanConfig.bulkCreate(planConfigs).then(() => done())); + .then(() => models.PlanConfig.bulkCreate(planConfigs)) + .then(() => models.BuildingBlock.bulkCreate(buildingBlocks).then(() => done())); }); after((done) => { @@ -288,5 +315,29 @@ describe('GET all metadata', () => { }) .expect(200, done); }); + + it('should return correct building blocks for admin', (done) => { + request(server) + .get('/v5/projects/metadata') + .set({ + Authorization: `Bearer ${testUtil.jwts.copilot}`, + }) + .expect(200) + .end((err, res) => { + if (err) { + done(err); + } else { + const resJson = res.body; + should.exist(resJson); + should.exist(resJson.buildingBlocks); + resJson.buildingBlocks.length.should.be.eql(2); + resJson.buildingBlocks[0].key.should.be.eql('key1'); + should.not.exist(resJson.buildingBlocks[0].privateConfig); + resJson.buildingBlocks[1].key.should.be.eql('key2'); + should.not.exist(resJson.buildingBlocks[1].privateConfig); + done(); + } + }); + }); }); }); diff --git a/src/routes/milestoneTemplates/clone.js b/src/routes/milestoneTemplates/clone.js index d6c21f9..2666287 100644 --- a/src/routes/milestoneTemplates/clone.js +++ b/src/routes/milestoneTemplates/clone.js @@ -28,7 +28,7 @@ module.exports = [ (req, res, next) => { let result; - return models.sequelize.transaction(tx => // eslint-disable-line no-unused-vars + return models.sequelize.transaction(() => // Find the product template models.MilestoneTemplate.findAll({ where: { diff --git a/src/routes/milestoneTemplates/create.js b/src/routes/milestoneTemplates/create.js index e2fc595..d72f727 100644 --- a/src/routes/milestoneTemplates/create.js +++ b/src/routes/milestoneTemplates/create.js @@ -48,9 +48,9 @@ module.exports = [ updatedBy: req.authUser.userId, }); let result; - return models.sequelize.transaction(tx => + return models.sequelize.transaction(() => // Create the milestone template - models.MilestoneTemplate.create(entity, { transaction: tx }) + models.MilestoneTemplate.create(entity) .then((createdEntity) => { // Omit deletedAt and deletedBy result = _.omit(createdEntity.toJSON(), 'deletedAt', 'deletedBy'); @@ -64,7 +64,6 @@ module.exports = [ id: { $ne: result.id }, order: { $gte: result.order }, }, - transaction: tx, }); }) .then((updatedCount) => { @@ -77,7 +76,6 @@ module.exports = [ }, order: [['updatedAt', 'DESC']], limit: updatedCount[0], - transaction: tx, }); } return Promise.resolve(); diff --git a/src/routes/milestoneTemplates/list.spec.js b/src/routes/milestoneTemplates/list.spec.js index 74f668a..5a4b156 100644 --- a/src/routes/milestoneTemplates/list.spec.js +++ b/src/routes/milestoneTemplates/list.spec.js @@ -113,6 +113,9 @@ const milestoneTemplates = [ ]; describe('LIST milestone template', () => { + before((done) => { + testUtil.clearES(done); + }); beforeEach((done) => { testUtil.clearDb() .then(() => models.ProductTemplate.bulkCreate(productTemplates)) diff --git a/src/routes/milestones/create.js b/src/routes/milestones/create.js index 1284887..0bbdf54 100644 --- a/src/routes/milestones/create.js +++ b/src/routes/milestones/create.js @@ -30,10 +30,10 @@ const schema = { 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(), + plannedText: Joi.string().max(512), + activeText: Joi.string().max(512), + completedText: Joi.string().max(512), + blockedText: Joi.string().max(512), hidden: Joi.boolean().optional(), createdAt: Joi.any().strip(), updatedAt: Joi.any().strip(), @@ -58,12 +58,10 @@ module.exports = [ }); let result; - // Validate startDate and endDate to be within the timeline startDate and endDate + // Validate startDate is not earlier than timeline startDate let error; if (req.body.startDate < req.timeline.startDate) { error = 'Milestone startDate must not be before the timeline startDate'; - } else if (req.body.endDate && req.timeline.endDate && req.body.endDate > req.timeline.endDate) { - error = 'Milestone endDate must not be after the timeline endDate'; } if (error) { const apiErr = new Error(error); @@ -71,9 +69,9 @@ module.exports = [ return next(apiErr); } - return models.sequelize.transaction(tx => + return models.sequelize.transaction(() => // Save to DB - models.Milestone.create(entity, { transaction: tx }) + models.Milestone.create(entity) .then((createdEntity) => { // Omit deletedAt, deletedBy result = _.omit(createdEntity.toJSON(), 'deletedAt', 'deletedBy'); @@ -86,7 +84,6 @@ module.exports = [ id: { $ne: result.id }, order: { $gte: result.order }, }, - transaction: tx, }); }) .then((updatedCount) => { @@ -99,7 +96,6 @@ module.exports = [ }, order: [['updatedAt', 'DESC']], limit: updatedCount[0], - transaction: tx, }); } return Promise.resolve(); @@ -131,7 +127,14 @@ module.exports = [ req, EVENT.ROUTING_KEY.MILESTONE_UPDATED, RESOURCES.MILESTONE, - _.assign(_.pick(milestone.toJSON(), 'id', 'order', 'updatedBy', 'updatedAt'))), + _.assign(_.pick(milestone.toJSON(), 'id', 'order', 'updatedBy', 'updatedAt')), + // Pass the same object as original milestone even though, their time has changed. + // So far we don't use time properties in the handler so it's ok. But in general, we should pass + // the original milestones. <- TODO + _.assign(_.pick(milestone.toJSON(), 'id', 'order', 'updatedBy', 'updatedAt')), + null, // no route + true, // don't send event to Notification Service as the main event here is updating one milestone + ), ); // Write to the response diff --git a/src/routes/milestones/create.spec.js b/src/routes/milestones/create.spec.js index 8d9163b..271e1d2 100644 --- a/src/routes/milestones/create.spec.js +++ b/src/routes/milestones/create.spec.js @@ -10,7 +10,7 @@ import server from '../../app'; import testUtil from '../../tests/util'; import models from '../../models'; import busApi from '../../services/busApi'; -import { EVENT, RESOURCES, BUS_API_EVENT } from '../../constants'; +import { EVENT, RESOURCES, BUS_API_EVENT, CONNECT_NOTIFICATION_EVENT } from '../../constants'; const should = chai.should(); @@ -142,11 +142,12 @@ describe('CREATE milestone', () => { // Create milestones models.Milestone.bulkCreate([ { + id: 11, timelineId: 1, name: 'milestone 1', duration: 2, startDate: '2018-05-03T00:00:00.000Z', - status: 'open', + status: 'draft', type: 'type1', details: { detail1: { @@ -164,11 +165,12 @@ describe('CREATE milestone', () => { updatedBy: 2, }, { + id: 12, timelineId: 1, name: 'milestone 2', duration: 3, startDate: '2018-05-04T00:00:00.000Z', - status: 'open', + status: 'draft', type: 'type2', order: 2, plannedText: 'plannedText 2', @@ -179,11 +181,12 @@ describe('CREATE milestone', () => { updatedBy: 3, }, { + id: 13, timelineId: 1, name: 'milestone 3', duration: 4, startDate: '2018-05-04T00:00:00.000Z', - status: 'open', + status: 'draft', type: 'type3', order: 3, plannedText: 'plannedText 3', @@ -212,7 +215,7 @@ describe('CREATE milestone', () => { startDate: '2018-05-05T00:00:00.000Z', endDate: '2018-05-07T00:00:00.000Z', completionDate: '2018-05-08T00:00:00.000Z', - status: 'open', + status: 'draft', type: 'type4', details: { detail1: { @@ -309,66 +312,6 @@ describe('CREATE milestone', () => { .expect(400, done); }); - it('should return 400 if missing plannedText', (done) => { - const invalidBody = _.assign({}, body, { - plannedText: undefined, - }); - - request(server) - .post('/v5/timelines/1/milestones') - .set({ - Authorization: `Bearer ${testUtil.jwts.admin}`, - }) - .send(invalidBody) - .expect('Content-Type', /json/) - .expect(400, done); - }); - - it('should return 400 if missing activeText', (done) => { - const invalidBody = _.assign({}, body, { - activeText: undefined, - }); - - request(server) - .post('/v5/timelines/1/milestones') - .set({ - Authorization: `Bearer ${testUtil.jwts.admin}`, - }) - .send(invalidBody) - .expect('Content-Type', /json/) - .expect(400, done); - }); - - it('should return 400 if missing completedText', (done) => { - const invalidBody = _.assign({}, body, { - completedText: undefined, - }); - - request(server) - .post('/v5/timelines/1/milestones') - .set({ - Authorization: `Bearer ${testUtil.jwts.admin}`, - }) - .send(invalidBody) - .expect('Content-Type', /json/) - .expect(400, done); - }); - - it('should return 400 if missing blockedText', (done) => { - const invalidBody = _.assign({}, body, { - blockedText: undefined, - }); - - request(server) - .post('/v5/timelines/1/milestones') - .set({ - Authorization: `Bearer ${testUtil.jwts.admin}`, - }) - .send(invalidBody) - .expect('Content-Type', /json/) - .expect(400, done); - }); - it('should return 400 if startDate is after endDate', (done) => { const invalidBody = _.assign({}, body, { startDate: '2018-05-29T00:00:00.000Z', @@ -416,21 +359,6 @@ describe('CREATE milestone', () => { .expect(400, done); }); - it('should return 400 if endDate is after the timeline endDate', (done) => { - const invalidBody = _.assign({}, body, { - endDate: '2018-06-13T00:00:00.000Z', - }); - - request(server) - .post('/v5/timelines/1/milestones') - .set({ - Authorization: `Bearer ${testUtil.jwts.admin}`, - }) - .send(invalidBody) - .expect('Content-Type', /json/) - .expect(400, done); - }); - it('should return 400 if invalid timelineId param', (done) => { request(server) .post('/v5/timelines/0/milestones') @@ -499,6 +427,15 @@ describe('CREATE milestone', () => { should.not.exist(resJson.deletedBy); should.not.exist(resJson.deletedAt); + // validate statusHistory + should.exist(resJson.statusHistory); + resJson.statusHistory.should.be.an('array'); + resJson.statusHistory.length.should.be.eql(1); + resJson.statusHistory.forEach((statusHistory) => { + statusHistory.reference.should.be.eql('milestone'); + statusHistory.referenceId.should.be.eql(resJson.id); + }); + // eslint-disable-next-line no-unused-expressions server.services.pubsub.publish.calledWith(EVENT.ROUTING_KEY.MILESTONE_ADDED).should.be.true; @@ -506,11 +443,11 @@ describe('CREATE milestone', () => { models.Milestone.findAll({ where: { timelineId: 1 } }) .then((milestones) => { _.each(milestones, (milestone) => { - if (milestone.id === 1) { + if (milestone.id === 11) { milestone.order.should.be.eql(1); - } else if (milestone.id === 2) { + } else if (milestone.id === 12) { milestone.order.should.be.eql(2 + 1); - } else if (milestone.id === 3) { + } else if (milestone.id === 13) { milestone.order.should.be.eql(3 + 1); } }); @@ -605,7 +542,7 @@ describe('CREATE milestone', () => { sandbox.restore(); }); - it('should send message BUS_API_EVENT.MILESTONE_ADDED when milestone created', (done) => { + it('sends send correct BUS API messages milestone created', (done) => { request(server) .post('/v5/timelines/1/milestones') .set({ @@ -619,13 +556,35 @@ describe('CREATE milestone', () => { done(err); } else { testUtil.wait(() => { - createEventSpy.callCount.should.be.eql(3); - createEventSpy.calledWith(BUS_API_EVENT.MILESTONE_ADDED, - sinon.match({ resource: RESOURCES.MILESTONE })).should.be.true; - createEventSpy.calledWith(BUS_API_EVENT.MILESTONE_ADDED, - sinon.match({ name: 'milestone 4' })).should.be.true; - createEventSpy.calledWith(BUS_API_EVENT.MILESTONE_ADDED, - sinon.match({ description: 'description 4' })).should.be.true; + createEventSpy.callCount.should.be.eql(4); + + // added a new milestone + createEventSpy.calledWith(BUS_API_EVENT.MILESTONE_ADDED, sinon.match({ + resource: RESOURCES.MILESTONE, + name: 'milestone 4', + description: 'description 4', + order: 2, + })).should.be.true; + + // as order of the next milestones after the added one have been updated, we send events about their update + createEventSpy.calledWith(BUS_API_EVENT.MILESTONE_UPDATED, sinon.match({ + resource: RESOURCES.MILESTONE, + order: 3, + })).should.be.true; + createEventSpy.calledWith(BUS_API_EVENT.MILESTONE_UPDATED, sinon.match({ + resource: RESOURCES.MILESTONE, + order: 4, + })).should.be.true; + + // Check Notification Service events + createEventSpy.calledWith(CONNECT_NOTIFICATION_EVENT.MILESTONE_ADDED, sinon.match({ + projectId: 1, + projectName: 'test1', + projectUrl: 'https://local.topcoder-dev.com/projects/1', + userId: 40051332, + initiatorUserId: 40051332, + })).should.be.true; + done(); }); } diff --git a/src/routes/milestones/delete.js b/src/routes/milestones/delete.js index 78b9a3e..ade643c 100644 --- a/src/routes/milestones/delete.js +++ b/src/routes/milestones/delete.js @@ -30,11 +30,10 @@ module.exports = [ id: req.params.milestoneId, }; - return models.sequelize.transaction(tx => + return models.sequelize.transaction(() => // Find the milestone models.Milestone.findOne({ where, - transaction: tx, }) .then((milestone) => { // Not found @@ -45,9 +44,10 @@ module.exports = [ } // Update the deletedBy, and soft delete - return milestone.update({ deletedBy: req.authUser.userId }, { transaction: tx }) - .then(() => milestone.destroy({ transaction: tx })); - }) + return milestone.update({ deletedBy: req.authUser.userId }) + .then(() => milestone.destroy()); + }), + ) .then((deleted) => { // Send event to bus req.log.debug('Sending event to RabbitMQ bus for milestone %d', deleted.id); @@ -67,6 +67,6 @@ module.exports = [ res.status(204).end(); return Promise.resolve(); }) - .catch(next)); + .catch(next); }, ]; diff --git a/src/routes/milestones/delete.spec.js b/src/routes/milestones/delete.spec.js index edbc2fb..a505e0d 100644 --- a/src/routes/milestones/delete.spec.js +++ b/src/routes/milestones/delete.spec.js @@ -9,14 +9,13 @@ import chai from 'chai'; import models from '../../models'; import server from '../../app'; import testUtil from '../../tests/util'; -import { EVENT, RESOURCES, BUS_API_EVENT } from '../../constants'; +import { EVENT, RESOURCES, BUS_API_EVENT, CONNECT_NOTIFICATION_EVENT } from '../../constants'; import busApi from '../../services/busApi'; const should = chai.should(); // eslint-disable-line no-unused-vars const expectAfterDelete = (timelineId, id, err, next) => { if (err) throw err; - setTimeout(() => models.Milestone.findOne({ where: { timelineId, @@ -30,12 +29,21 @@ const expectAfterDelete = (timelineId, id, err, next) => { } else { chai.assert.isNotNull(res.deletedAt); chai.assert.isNotNull(res.deletedBy); + + request(server) + .get(`/v5/timelines/${timelineId}/milestones/${id}`) + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .expect(404, next); } - next(); - }), 500); + }); }; describe('DELETE milestone', () => { + before((done) => { + testUtil.clearES(done); + }); beforeEach((done) => { testUtil.clearDb() .then(() => { @@ -157,6 +165,7 @@ describe('DELETE milestone', () => { // Create milestones models.Milestone.bulkCreate([ { + id: 1, timelineId: 1, name: 'milestone 1', duration: 2, @@ -179,6 +188,7 @@ describe('DELETE milestone', () => { updatedBy: 2, }, { + id: 2, timelineId: 1, name: 'milestone 2', duration: 3, @@ -194,6 +204,7 @@ describe('DELETE milestone', () => { updatedBy: 3, }, { + id: 3, timelineId: 1, name: 'milestone 3', duration: 4, @@ -371,7 +382,7 @@ describe('DELETE milestone', () => { sandbox.restore(); }); - it('should send message BUS_API_EVENT.MILESTONE_REMOVED when milestone removed', (done) => { + it('sends send correct BUS API messages when milestone removed', (done) => { request(server) .delete('/v5/timelines/1/milestones/1') .set({ @@ -383,11 +394,22 @@ describe('DELETE milestone', () => { done(err); } else { testUtil.wait(() => { - createEventSpy.calledOnce.should.be.true; - createEventSpy.calledWith(BUS_API_EVENT.MILESTONE_REMOVED, - sinon.match({ resource: RESOURCES.MILESTONE })).should.be.true; - createEventSpy.calledWith(BUS_API_EVENT.MILESTONE_REMOVED, - sinon.match({ id: 1 })).should.be.true; + createEventSpy.callCount.should.be.eql(2); + + createEventSpy.calledWith(BUS_API_EVENT.MILESTONE_REMOVED, sinon.match({ + resource: RESOURCES.MILESTONE, + id: 1, + })).should.be.true; + + // Check Notification Service events + createEventSpy.calledWith(CONNECT_NOTIFICATION_EVENT.MILESTONE_REMOVED, sinon.match({ + projectId: 1, + projectName: 'test1', + projectUrl: 'https://local.topcoder-dev.com/projects/1', + userId: 40051332, + initiatorUserId: 40051332, + })).should.be.true; + done(); }); } diff --git a/src/routes/milestones/get.spec.js b/src/routes/milestones/get.spec.js index 1c6797d..f541eb0 100644 --- a/src/routes/milestones/get.spec.js +++ b/src/routes/milestones/get.spec.js @@ -133,6 +133,7 @@ describe('GET milestone', () => { // Create milestones models.Milestone.bulkCreate([ { + id: 1, timelineId: 1, name: 'milestone 1', duration: 2, @@ -155,6 +156,7 @@ describe('GET milestone', () => { updatedBy: 2, }, { + id: 2, timelineId: 1, name: 'milestone 2', duration: 3, @@ -170,6 +172,7 @@ describe('GET milestone', () => { updatedBy: 3, }, { + id: 3, timelineId: 1, name: 'milestone 3', duration: 4, @@ -303,6 +306,15 @@ describe('GET milestone', () => { should.not.exist(resJson.deletedBy); should.not.exist(resJson.deletedAt); + // validate statusHistory + should.exist(resJson.statusHistory); + resJson.statusHistory.should.be.an('array'); + resJson.statusHistory.length.should.be.eql(1); + resJson.statusHistory.forEach((statusHistory) => { + statusHistory.reference.should.be.eql('milestone'); + statusHistory.referenceId.should.be.eql(resJson.id); + }); + done(); }); }); diff --git a/src/routes/milestones/list.spec.js b/src/routes/milestones/list.spec.js index 570e426..e5ccab4 100644 --- a/src/routes/milestones/list.spec.js +++ b/src/routes/milestones/list.spec.js @@ -3,8 +3,9 @@ */ import chai from 'chai'; import request from 'supertest'; -import sleep from 'sleep'; +// import sleep from 'sleep'; import config from 'config'; +import _ from 'lodash'; import models from '../../models'; import server from '../../app'; @@ -50,6 +51,7 @@ const milestones = [ detail2: [1, 2, 3], }, order: 1, + hidden: false, plannedText: 'plannedText 1', activeText: 'activeText 1', completedText: 'completedText 1', @@ -68,6 +70,7 @@ const milestones = [ status: 'open', type: 'type2', order: 2, + hidden: false, plannedText: 'plannedText 2', activeText: 'activeText 2', completedText: 'completedText 2', @@ -162,25 +165,24 @@ describe('LIST milestones', () => { updatedBy: 2, }, ])) - .then(() => - // Create timelines and milestones - models.Timeline.bulkCreate(timelines) - .then(() => models.Milestone.bulkCreate(milestones))) - .then(() => { + // Create timelines and milestones + .then(() => models.Timeline.bulkCreate(timelines)) + .then(() => models.Milestone.bulkCreate(milestones)) + .then((createdMilestones) => { // Index to ES - timelines[0].milestones = milestones; + timelines[0].milestones = _.map(createdMilestones, cm => _.omit(cm.toJSON(), 'deletedAt', 'deletedBy')); timelines[0].projectId = 1; return server.services.es.index({ index: ES_TIMELINE_INDEX, 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(); - }); + }); + }) + .then(() => { + // sleep for some time, let elasticsearch indices be settled + // sleep.sleep(5); + done(); }); }); }); @@ -244,8 +246,18 @@ describe('LIST milestones', () => { const resJson = res.body; resJson.should.have.length(2); - resJson[0].should.be.eql(milestones[0]); - resJson[1].should.be.eql(milestones[1]); + resJson.forEach((milestone, index) => { + milestone.statusHistory.should.be.an('array'); + milestone.statusHistory.length.should.be.eql(1); + milestone.statusHistory.forEach((statusHistory) => { + statusHistory.reference.should.be.eql('milestone'); + statusHistory.referenceId.should.be.eql(milestone.id); + }); + + const m = _.omitBy(_.omit(milestone, ['statusHistory']), _.isNil); + + m.should.be.eql(milestones[index]); + }); done(); }); @@ -320,8 +332,10 @@ describe('LIST milestones', () => { const resJson = res.body; resJson.should.have.length(2); - resJson[0].should.be.eql(milestones[1]); - resJson[1].should.be.eql(milestones[0]); + const m1 = _.omitBy(_.omit(resJson[0], ['statusHistory']), _.isNil); + const m2 = _.omitBy(_.omit(resJson[1], ['statusHistory']), _.isNil); + m1.should.be.eql(milestones[1]); + m2.should.be.eql(milestones[0]); done(); }); diff --git a/src/routes/milestones/update.js b/src/routes/milestones/update.js index 6ed6b47..850d531 100644 --- a/src/routes/milestones/update.js +++ b/src/routes/milestones/update.js @@ -4,12 +4,13 @@ import validate from 'express-validation'; import _ from 'lodash'; import moment from 'moment'; +import config from 'config'; import Joi from 'joi'; import Sequelize from 'sequelize'; import { middleware as tcMiddleware } from 'tc-core-library-js'; import util from '../../util'; import validateTimeline from '../../middlewares/validateTimeline'; -import { EVENT, RESOURCES, MILESTONE_STATUS } from '../../constants'; +import { EVENT, RESOURCES, MILESTONE_STATUS, ADMIN_ROLES } from '../../constants'; import models from '../../models'; const permissions = tcMiddleware.permissions; @@ -110,6 +111,7 @@ const schema = { completedText: Joi.string().max(512).optional(), blockedText: Joi.string().max(512).optional(), hidden: Joi.boolean().optional(), + statusComment: Joi.string().when('status', { is: 'paused', then: Joi.required(), otherwise: Joi.optional() }), createdAt: Joi.any().strip(), updatedAt: Joi.any().strip(), deletedAt: Joi.any().strip(), @@ -144,13 +146,50 @@ module.exports = [ return models.sequelize.transaction(() => // Find the milestone models.Milestone.findOne({ where }) - .then((milestone) => { + .then(async (milestone) => { // Not found if (!milestone) { const apiErr = new Error(`Milestone not found for milestone id ${req.params.milestoneId}`); apiErr.status = 404; return Promise.reject(apiErr); } + const validStatuses = JSON.parse(config.get('VALID_STATUSES_BEFORE_PAUSED')); + if (entityToUpdate.status === MILESTONE_STATUS.PAUSED && !validStatuses.includes(milestone.status)) { + const validStatutesStr = validStatuses.join(', '); + const apiErr = new Error(`Milestone can only be paused from the next statuses: ${validStatutesStr}`); + apiErr.status = 400; + return Promise.reject(apiErr); + } + + if (entityToUpdate.status === 'resume') { + if (milestone.status !== MILESTONE_STATUS.PAUSED) { + const apiErr = new Error('Milestone status isn\'t paused'); + apiErr.status = 400; + return Promise.reject(apiErr); + } + const statusHistory = await models.StatusHistory.findAll({ + where: { referenceId: milestone.id }, + order: [['createdAt', 'desc'], ['id', 'desc']], + attributes: ['status', 'id'], + limit: 2, + raw: true, + }); + if (statusHistory.length === 2) { + entityToUpdate.status = statusHistory[1].status; + } else { + const apiErr = new Error('No previous status is found'); + apiErr.status = 500; + return Promise.reject(apiErr); + } + } + + if (entityToUpdate.completionDate || entityToUpdate.actualStartDate) { + if (!util.hasPermission({ topcoderRoles: ADMIN_ROLES }, req.authUser)) { + const apiErr = new Error('You are not authorised to perform this action'); + apiErr.status = 403; + 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.'); @@ -205,7 +244,7 @@ module.exports = [ } // Update - return milestone.update(entityToUpdate); + return milestone.update(entityToUpdate, { comment: entityToUpdate.statusComment }); }) .then((updatedMilestone) => { // Omit deletedAt, deletedBy @@ -295,12 +334,13 @@ module.exports = [ ); // emit the event - util.sendResourceToKafkaBus( + // we cannot use `util.sendResourceToKafkaBus` as we have to pass a custom param `cascadedUpdates` + req.app.emit(EVENT.ROUTING_KEY.MILESTONE_UPDATED, { req, - EVENT.ROUTING_KEY.MILESTONE_UPDATED, - RESOURCES.MILESTONE, - _.assign(entityToUpdate, _.pick(updated, 'id', 'updatedAt')), - ); + resource: _.assign({ resource: RESOURCES.MILESTONE }, updated), + originalResource: _.assign({ resource: RESOURCES.MILESTONE }, original), + cascadedUpdates, + }); // Write to response res.json(updated); diff --git a/src/routes/milestones/update.spec.js b/src/routes/milestones/update.spec.js index 056e884..bf8b1c7 100644 --- a/src/routes/milestones/update.spec.js +++ b/src/routes/milestones/update.spec.js @@ -11,7 +11,7 @@ import models from '../../models'; import server from '../../app'; import testUtil from '../../tests/util'; import busApi from '../../services/busApi'; -import { EVENT, RESOURCES, MILESTONE_STATUS, BUS_API_EVENT } from '../../constants'; +import { EVENT, RESOURCES, MILESTONE_STATUS, BUS_API_EVENT, CONNECT_NOTIFICATION_EVENT } from '../../constants'; const should = chai.should(); @@ -141,7 +141,7 @@ describe('UPDATE Milestone', () => { startDate: '2018-05-13T00:00:00.000Z', endDate: '2018-05-14T00:00:00.000Z', completionDate: '2018-05-15T00:00:00.000Z', - status: 'open', + status: 'active', type: 'type1', details: { detail1: { @@ -166,7 +166,7 @@ describe('UPDATE Milestone', () => { name: 'Milestone 2', duration: 3, startDate: '2018-05-14T00:00:00.000Z', - status: 'open', + status: 'reviewed', type: 'type2', order: 2, plannedText: 'plannedText 2', @@ -184,7 +184,7 @@ describe('UPDATE Milestone', () => { name: 'Milestone 3', duration: 3, startDate: '2018-05-14T00:00:00.000Z', - status: 'open', + status: 'active', type: 'type3', order: 3, plannedText: 'plannedText 3', @@ -202,7 +202,7 @@ describe('UPDATE Milestone', () => { name: 'Milestone 4', duration: 3, startDate: '2018-05-14T00:00:00.000Z', - status: 'open', + status: 'active', type: 'type4', order: 4, plannedText: 'plannedText 4', @@ -220,7 +220,7 @@ describe('UPDATE Milestone', () => { name: 'Milestone 5', duration: 3, startDate: '2018-05-14T00:00:00.000Z', - status: 'open', + status: 'active', type: 'type5', order: 5, plannedText: 'plannedText 5', @@ -239,7 +239,7 @@ describe('UPDATE Milestone', () => { name: 'Milestone 6', duration: 3, startDate: '2018-05-14T00:00:00.000Z', - status: 'open', + status: 'active', type: 'type5', order: 1, plannedText: 'plannedText 6', @@ -265,9 +265,8 @@ describe('UPDATE Milestone', () => { const body = { name: 'Milestone 1-updated', duration: 3, - completionDate: '2018-05-16T00:00:00.000Z', description: 'description-updated', - status: 'closed', + status: 'draft', type: 'type1-updated', details: { detail1: { @@ -302,6 +301,30 @@ describe('UPDATE Milestone', () => { .expect(403, done); }); + it('should return 403 for non-admin member updating the completionDate', (done) => { + const newBody = _.cloneDeep(body); + newBody.completionDate = '2019-01-16T00:00:00.000Z'; + request(server) + .patch('/v5/timelines/1/milestones/1') + .set({ + Authorization: `Bearer ${testUtil.jwts.manager}`, + }) + .send(newBody) + .expect(403, done); + }); + + it('should return 403 for non-admin member updating the actualStartDate', (done) => { + const newBody = _.cloneDeep(body); + newBody.actualStartDate = '2018-05-15T00:00:00.000Z'; + request(server) + .patch('/v5/timelines/1/milestones/1') + .set({ + Authorization: `Bearer ${testUtil.jwts.manager}`, + }) + .send(newBody) + .expect(403, done); + }); + it('should return 404 for non-existed timeline', (done) => { request(server) .patch('/v5/timelines/1234/milestones/1') @@ -488,12 +511,14 @@ describe('UPDATE Milestone', () => { }); it('should return 200 for admin', (done) => { + const newBody = _.cloneDeep(body); + newBody.completionDate = '2018-05-15T00:00:00.000Z'; request(server) .patch('/v5/timelines/1/milestones/1') .set({ Authorization: `Bearer ${testUtil.jwts.admin}`, }) - .send(body) + .send(newBody) .expect(200) .end((err, res) => { const resJson = res.body; @@ -501,7 +526,7 @@ describe('UPDATE Milestone', () => { resJson.name.should.be.eql(body.name); resJson.description.should.be.eql(body.description); resJson.duration.should.be.eql(body.duration); - resJson.completionDate.should.be.eql(body.completionDate); + resJson.completionDate.should.be.eql(newBody.completionDate); resJson.status.should.be.eql(body.status); resJson.type.should.be.eql(body.type); resJson.details.should.be.eql({ @@ -522,6 +547,15 @@ describe('UPDATE Milestone', () => { should.not.exist(resJson.deletedBy); should.not.exist(resJson.deletedAt); + // validate statusHistory + should.exist(resJson.statusHistory); + resJson.statusHistory.should.be.an('array'); + resJson.statusHistory.length.should.be.eql(2); + resJson.statusHistory.forEach((statusHistory) => { + statusHistory.reference.should.be.eql('milestone'); + statusHistory.referenceId.should.be.eql(resJson.id); + }); + // eslint-disable-next-line no-unused-expressions server.services.pubsub.publish.calledWith(EVENT.ROUTING_KEY.MILESTONE_UPDATED).should.be.true; @@ -711,7 +745,7 @@ describe('UPDATE Milestone', () => { name: 'Milestone 7', duration: 3, startDate: '2018-05-14T00:00:00.000Z', - status: 'open', + status: 'active', type: 'type7', order: 3, plannedText: 'plannedText 7', @@ -729,7 +763,7 @@ describe('UPDATE Milestone', () => { name: 'Milestone 8', duration: 3, startDate: '2018-05-14T00:00:00.000Z', - status: 'open', + status: 'active', type: 'type7', order: 4, plannedText: 'plannedText 8', @@ -784,7 +818,7 @@ describe('UPDATE Milestone', () => { name: 'Milestone 7', duration: 3, startDate: '2018-05-14T00:00:00.000Z', - status: 'open', + status: 'active', type: 'type7', order: 2, plannedText: 'plannedText 7', @@ -802,7 +836,7 @@ describe('UPDATE Milestone', () => { name: 'Milestone 8', duration: 3, startDate: '2018-05-14T00:00:00.000Z', - status: 'open', + status: 'active', type: 'type7', order: 4, plannedText: 'plannedText 8', @@ -1050,6 +1084,30 @@ describe('UPDATE Milestone', () => { .end(done); }); + it('should return 200 for admin updating the completionDate', (done) => { + const newBody = _.cloneDeep(body); + newBody.completionDate = '2018-05-16T00:00:00.000Z'; + request(server) + .patch('/v5/timelines/1/milestones/1') + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .send(newBody) + .expect(200, done); + }); + + it('should return 200 for admin updating the actualStartDate', (done) => { + const newBody = _.cloneDeep(body); + newBody.actualStartDate = '2018-05-15T00:00:00.000Z'; + request(server) + .patch('/v5/timelines/1/milestones/1') + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .send(newBody) + .expect(200, done); + }); + it('should return 200 for connect manager', (done) => { request(server) .patch('/v5/timelines/1/milestones/1') @@ -1083,6 +1141,146 @@ describe('UPDATE Milestone', () => { .end(done); }); + it('should return 400 if try to pause and statusComment is missed', (done) => { + const newBody = _.cloneDeep(body); + newBody.status = 'paused'; + request(server) + .patch('/v5/timelines/1/milestones/1') + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .send(newBody) + .expect(400, done); + }); + + it('should return 400 if try to pause not active milestone', (done) => { + const newBody = _.cloneDeep(body); + newBody.status = 'paused'; + newBody.statusComment = 'milestone paused'; + request(server) + .patch('/v5/timelines/1/milestones/2') + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .send(newBody) + .expect(400, done); + }); + + it('should return 200 if try to pause and should have one status history created', (done) => { + const newBody = _.cloneDeep(body); + newBody.status = 'paused'; + newBody.statusComment = 'milestone paused'; + request(server) + .patch('/v5/timelines/1/milestones/1') + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .send(newBody) + .expect(200) + .end((err) => { + if (err) { + done(err); + } else { + models.Milestone.findByPk(1).then((milestone) => { + milestone.status.should.be.eql('paused'); + return models.StatusHistory.findAll({ + where: { + reference: 'milestone', + referenceId: milestone.id, + status: milestone.status, + comment: 'milestone paused', + }, + paranoid: false, + }).then((statusHistories) => { + statusHistories.length.should.be.eql(1); + done(); + }); + }); + } + }); + }); + + it('should return 400 if try to resume not paused milestone', (done) => { + const newBody = _.cloneDeep(body); + newBody.status = 'resume'; + request(server) + .patch('/v5/timelines/1/milestones/2') + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .send(newBody) + .expect(400, done); + }); + + it('should return 200 if try to resume then status should update to last status and ' + + 'should have one status history created', (done) => { + const newBody = _.cloneDeep(body); + newBody.status = 'resume'; + newBody.statusComment = 'new comment'; + models.Milestone.bulkCreate([ + { + id: 7, + timelineId: 1, + name: 'Milestone 1 [paused]', + duration: 2, + startDate: '2018-05-13T00:00:00.000Z', + endDate: '2018-05-14T00:00:00.000Z', + completionDate: '2018-05-16T00:00:00.000Z', + status: 'active', + type: 'type1', + details: { + detail1: { + subDetail1A: 1, + subDetail1B: 2, + }, + detail2: [1, 2, 3], + }, + order: 1, + plannedText: 'plannedText 1', + activeText: 'activeText 1', + completedText: 'completedText 1', + blockedText: 'blockedText 1', + createdBy: 1, + updatedBy: 2, + createdAt: '2018-05-11T00:00:00.000Z', + updatedAt: '2018-05-11T00:00:00.000Z', + }, + ]).then(() => models.Milestone.findByPk(7) + // pause milestone before resume + .then(milestone => milestone.update(_.assign({}, milestone.toJSON(), { status: 'paused' }))), + ).then(() => { + request(server) + .patch('/v5/timelines/1/milestones/7') + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .send(newBody) + .expect(200) + .end((err) => { + if (err) { + done(err); + } else { + models.Milestone.findByPk(7).then((milestone) => { + milestone.status.should.be.eql('active'); + + return models.StatusHistory.findAll({ + where: { + reference: 'milestone', + referenceId: milestone.id, + status: 'active', + comment: 'new comment', + }, + paranoid: false, + }).then((statusHistories) => { + statusHistories.length.should.be.eql(1); + done(); + }).catch(done); + }).catch(done); + } + }); + }); + }); + describe('Bus api', () => { let createEventSpy; const sandbox = sinon.sandbox.create(); @@ -1100,14 +1298,13 @@ describe('UPDATE Milestone', () => { sandbox.restore(); }); - it('should send message BUS_API_EVENT.MILESTONE_UPDATED when milestone duration updated', (done) => { + it('sends send correct BUS API messages when milestone details updated and waiting for customer', (done) => { request(server) .patch('/v5/timelines/1/milestones/1') .set({ Authorization: `Bearer ${testUtil.jwts.copilot}`, }) .send({ - // duration: 1, details: { metadata: { waitingForCustomer: true }, }, @@ -1118,18 +1315,26 @@ describe('UPDATE Milestone', () => { done(err); } else { testUtil.wait(() => { - // 5 milestones in total, so it would trigger 5 events - // 4 MILESTONE_UPDATED events are for 4 non deleted milestones - // 1 TIMELINE_ADJUSTED event, because timeline's end date updated - createEventSpy.calledOnce.should.be.true; - createEventSpy.calledWith(BUS_API_EVENT.MILESTONE_UPDATED, - sinon.match({ resource: RESOURCES.MILESTONE })).should.be.true; - createEventSpy.calledWith(BUS_API_EVENT.MILESTONE_UPDATED, - sinon.match({ - details: { - metadata: { waitingForCustomer: true }, - }, - })).should.be.true; + createEventSpy.callCount.should.be.eql(3); + + createEventSpy.calledWith(BUS_API_EVENT.MILESTONE_UPDATED, sinon.match({ + resource: RESOURCES.MILESTONE, + details: { + metadata: { waitingForCustomer: true }, + }, + })).should.be.true; + + // Check Notification Service events + createEventSpy.calledWith(CONNECT_NOTIFICATION_EVENT.MILESTONE_UPDATED, sinon.match({ + projectId: 1, + projectName: 'test1', + projectUrl: 'https://local.topcoder-dev.com/projects/1', + userId: 40051332, + initiatorUserId: 40051332, + })).should.be.true; + createEventSpy.calledWith(CONNECT_NOTIFICATION_EVENT.MILESTONE_WAITING_CUSTOMER) + .should.be.true; + done(); }); } @@ -1191,7 +1396,7 @@ describe('UPDATE Milestone', () => { }); }); - it('should ONLY send message BUS_API_EVENT.MILESTONE_UPDATED when milestone order updated', (done) => { + it('should send correct BUS API messages when milestone order updated', (done) => { request(server) .patch('/v5/timelines/1/milestones/1') .set({ @@ -1206,18 +1411,29 @@ describe('UPDATE Milestone', () => { done(err); } else { testUtil.wait(() => { - createEventSpy.calledOnce.should.be.true; - createEventSpy.calledWith(BUS_API_EVENT.MILESTONE_UPDATED, - sinon.match({ resource: RESOURCES.MILESTONE })).should.be.true; - createEventSpy.calledWith(BUS_API_EVENT.MILESTONE_UPDATED, - sinon.match({ order: 2 })).should.be.true; + createEventSpy.callCount.should.be.eql(2); + + createEventSpy.calledWith(BUS_API_EVENT.MILESTONE_UPDATED, sinon.match({ + resource: RESOURCES.MILESTONE, + order: 2, + })).should.be.true; + + // Check Notification Service events + createEventSpy.calledWith(CONNECT_NOTIFICATION_EVENT.MILESTONE_UPDATED, sinon.match({ + projectId: 1, + projectName: 'test1', + projectUrl: 'https://local.topcoder-dev.com/projects/1', + userId: 40051332, + initiatorUserId: 40051332, + })).should.be.true; + done(); }); } }); }); - it('should ONLY send message BUS_API_EVENT.MILESTONE_UPDATED when milestone plannedText updated', (done) => { + it('should send correct BUS API messages when milestone plannedText updated', (done) => { request(server) .patch('/v5/timelines/1/milestones/1') .set({ @@ -1232,11 +1448,22 @@ describe('UPDATE Milestone', () => { done(err); } else { testUtil.wait(() => { - createEventSpy.calledOnce.should.be.true; - createEventSpy.calledWith(BUS_API_EVENT.MILESTONE_UPDATED, - sinon.match({ resource: RESOURCES.MILESTONE })).should.be.true; - createEventSpy.calledWith(BUS_API_EVENT.MILESTONE_UPDATED, - sinon.match({ plannedText: 'new text' })).should.be.true; + createEventSpy.callCount.should.be.eql(2); + + createEventSpy.calledWith(BUS_API_EVENT.MILESTONE_UPDATED, sinon.match({ + resource: RESOURCES.MILESTONE, + plannedText: 'new text', + })).should.be.true; + + // Check Notification Service events + createEventSpy.calledWith(CONNECT_NOTIFICATION_EVENT.MILESTONE_UPDATED, sinon.match({ + projectId: 1, + projectName: 'test1', + projectUrl: 'https://local.topcoder-dev.com/projects/1', + userId: 40051332, + initiatorUserId: 40051332, + })).should.be.true; + done(); }); } diff --git a/src/routes/orgConfig/list.js b/src/routes/orgConfig/list.js index b77b3dc..6e5fb74 100644 --- a/src/routes/orgConfig/list.js +++ b/src/routes/orgConfig/list.js @@ -3,6 +3,7 @@ */ 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 util from '../../util'; @@ -20,24 +21,51 @@ module.exports = [ validate(schema), permissions('orgConfig.view'), (req, res, next) => { - util.fetchFromES('orgConfigs') - .then((data) => { - // handle filters - const filters = req.query; - // Throw error if orgId is not present in filter - if (!filters.orgId) { - next(util.buildApiError('Missing filter orgId', 400)); - } - if (!util.isValidFilter(filters, ['orgId', 'configName'])) { - util.handleError('Invalid filters', null, req, next); - } - req.log.debug(filters); + // handle filters + const filters = req.query; + // Throw error if orgId is not present in filter + if (!filters.orgId) { + next(util.buildApiError('Missing filter orgId', 400)); + } + if (!util.isValidFilter(filters, ['orgId', 'configName'])) { + util.handleError('Invalid filters', null, req, next); + } + req.log.debug(filters); + const orgIds = filters.orgId.split(','); + // build filter query for ES + const must = [{ + terms: { + 'orgConfigs.orgId': orgIds, + }, + }]; + if (filters.configName) { + must.push({ + term: { + 'orgConfigs.configName': filters.configName, + }, + }); + } + + util.fetchFromES('orgConfigs', { + query: { + nested: { + path: 'orgConfigs', + query: { + bool: { + must, + }, + }, + inner_hits: {}, + }, + }, + }, 'metadata') + .then((data) => { if (data.orgConfigs.length === 0) { req.log.debug('No orgConfig found in ES'); // Get all organization config - const where = filters || {}; + const where = filters ? _.assign({}, filters, { orgId: { $in: orgIds } }) : {}; models.OrgConfig.findAll({ where, attributes: { exclude: ['deletedAt', 'deletedBy'] }, @@ -49,7 +77,7 @@ module.exports = [ .catch(next); } else { req.log.debug('orgConfigs found in ES'); - res.json(data.orgConfigs); + res.json(data.orgConfigs.hits.hits.map(hit => hit._source)); // eslint-disable-line no-underscore-dangle } }); }, diff --git a/src/routes/orgConfig/list.spec.js b/src/routes/orgConfig/list.spec.js index 6931742..498f118 100644 --- a/src/routes/orgConfig/list.spec.js +++ b/src/routes/orgConfig/list.spec.js @@ -3,6 +3,8 @@ */ import chai from 'chai'; import request from 'supertest'; +import config from 'config'; +import _ from 'lodash'; import models from '../../models'; import server from '../../app'; @@ -10,6 +12,21 @@ import testUtil from '../../tests/util'; const should = chai.should(); +const ES_ORGCONFIG_INDEX = config.get('elasticsearchConfig.metadataIndexName'); +const ES_ORGCONFIG_TYPE = config.get('elasticsearchConfig.metadataDocType'); + +const validateOrgConfig = (resJson, orgConfig) => { + resJson.id.should.be.eql(orgConfig.id); + resJson.orgId.should.be.eql(orgConfig.orgId); + resJson.configName.should.be.eql(orgConfig.configName); + resJson.configValue.should.be.eql(orgConfig.configValue); + should.exist(resJson.createdAt); + resJson.updatedBy.should.be.eql(orgConfig.updatedBy); + should.exist(resJson.updatedAt); + should.not.exist(resJson.deletedBy); + should.not.exist(resJson.deletedAt); +}; + describe('LIST organization config', () => { const orgConfigPath = '/v5/projects/metadata/orgConfig'; const configs = [ @@ -23,17 +40,50 @@ describe('LIST organization config', () => { }, { id: 2, - orgId: 'ORG1', + orgId: 'ORG2', configName: 'project_catalog_url', configValue: '/projects/2', createdBy: 1, updatedBy: 1, }, + { + id: 3, + orgId: 'ORG3', + configName: 'project_catalog_url', + configValue: '/projects/3', + createdBy: 1, + updatedBy: 1, + }, + { + id: 4, + orgId: 'ORG4', + configName: 'project_catalog_url', + configValue: '/projects/4', + createdBy: 1, + updatedBy: 1, + }, ]; beforeEach((done) => { testUtil.clearDb() - .then(() => models.OrgConfig.bulkCreate(configs).then(() => done())); + .then(() => models.OrgConfig.bulkCreate(configs, { returning: true }), + ).then(async (createdConfigs) => { + // Index to ES only orgConfigs with id: 3 and 4 + const indexedConfigs = _(createdConfigs).filter(createdConfig => createdConfig.toJSON().id > 2) + .map((filteredConfig) => { + const orgConfigJson = _.omit(filteredConfig.toJSON(), 'deletedAt', 'deletedBy'); + return orgConfigJson; + }).value(); + + await server.services.es.index({ + index: ES_ORGCONFIG_INDEX, + type: ES_ORGCONFIG_TYPE, + body: { + orgConfigs: indexedConfigs, + }, + }); + done(); + }); }); after((done) => { testUtil.clearDb(done); @@ -48,19 +98,79 @@ describe('LIST organization config', () => { }) .expect(200) .end((err, res) => { - const config = configs[0]; + const config1 = configs[0]; + + const resJson = res.body; + resJson.should.have.length(1); + validateOrgConfig(resJson[0], config1); + + done(); + }); + }); + + it('should return 200 for admin with filter (ES)', (done) => { + request(server) + .get(`${orgConfigPath}?orgId=${configs[2].orgId}&configName=${configs[2].configName}`) + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .expect(200) + .end((err, res) => { + const config3 = configs[2]; const resJson = res.body; resJson.should.have.length(1); - resJson[0].id.should.be.eql(config.id); - resJson[0].orgId.should.be.eql(config.orgId); - resJson[0].configName.should.be.eql(config.configName); - resJson[0].configValue.should.be.eql(config.configValue); - should.exist(resJson[0].createdAt); - resJson[0].updatedBy.should.be.eql(config.updatedBy); - should.exist(resJson[0].updatedAt); - should.not.exist(resJson[0].deletedBy); - should.not.exist(resJson[0].deletedAt); + validateOrgConfig(resJson[0], config3); + + done(); + }); + }); + + it('should return 200 for admin and filter by multiple orgId (DB)', (done) => { + request(server) + .get(`${orgConfigPath}?orgId=${configs[0].orgId},${configs[1].orgId}`) + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .expect(200) + .end((err, res) => { + const config1 = configs[0]; + const config2 = configs[1]; + + const resJson = res.body; + resJson.should.have.length(2); + resJson.forEach((result) => { + if (result.id === 1) { + validateOrgConfig(result, config1); + } else { + validateOrgConfig(result, config2); + } + }); + + done(); + }); + }); + + it('should return 200 for admin and filter by multiple orgId (ES)', (done) => { + request(server) + .get(`${orgConfigPath}?orgId=${configs[2].orgId},${configs[3].orgId}`) + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .expect(200) + .end((err, res) => { + const config3 = configs[2]; + const config4 = configs[3]; + + const resJson = res.body; + resJson.should.have.length(2); + resJson.forEach((result) => { + if (result.id === 3) { + validateOrgConfig(result, config3); + } else { + validateOrgConfig(result, config4); + } + }); done(); }); diff --git a/src/routes/orgConfig/update.spec.js b/src/routes/orgConfig/update.spec.js index 4921c58..f25c98b 100644 --- a/src/routes/orgConfig/update.spec.js +++ b/src/routes/orgConfig/update.spec.js @@ -87,6 +87,9 @@ describe('UPDATE organization config', () => { it('should return 404 for deleted config', (done) => { models.OrgConfig.destroy({ where: { id } }) + // we should clear ES, otherwise deleted config would be returned by ES + // TODO we should create an alternative way to test it, as all the data is "cached" in ES now + .then(() => testUtil.clearES()) .then(() => { request(server) .patch(`/v5/projects/metadata/orgConfig/${id}`) diff --git a/src/routes/permissions/get.js b/src/routes/permissions/get.js new file mode 100644 index 0000000..d22acbe --- /dev/null +++ b/src/routes/permissions/get.js @@ -0,0 +1,65 @@ +/** + * API to get project permissions + */ +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: { + projectId: Joi.number().integer().positive().required(), + }, +}; + +module.exports = [ + validate(schema), + permissions('permissions.view'), + (req, res, next) => { + const projectId = req.params.projectId; + return models.Project.findOne({ + where: { + id: projectId, + }, + }) + .then((project) => { + if (!project) { + const apiErr = new Error(`Project not found for id ${projectId}`); + apiErr.status = 404; + return Promise.reject(apiErr); + } + + if (!project.templateId) { + return Promise.resolve([]); + } + + return models.WorkManagementPermission.findAll({ + where: { + projectTemplateId: project.templateId, + }, + }); + }) + .then((workManagementPermissions) => { + const allowPermissions = {}; + + // find all allowed permissions + workManagementPermissions.forEach((workManagementPermission) => { + const isAllowed = util.hasPermission( + workManagementPermission.permission, + req.authUser, + req.context.currentProjectMembers, + ); + + if (isAllowed) { + allowPermissions[workManagementPermission.policy] = true; + } + }); + + res.json(allowPermissions); + }) + .catch(next); + }, +]; diff --git a/src/routes/permissions/get.spec.js b/src/routes/permissions/get.spec.js new file mode 100644 index 0000000..57f7d62 --- /dev/null +++ b/src/routes/permissions/get.spec.js @@ -0,0 +1,233 @@ +/* eslint-disable no-unused-expressions */ +/** + * 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'; + +chai.should(); + +describe('GET permissions', () => { + let projectId; + let templateId; + + const memberUser = { + handle: testUtil.getDecodedToken(testUtil.jwts.member).handle, + userId: testUtil.getDecodedToken(testUtil.jwts.member).userId, + firstName: 'fname', + lastName: 'lName', + email: 'some@abc.com', + }; + const copilotUser = { + handle: testUtil.getDecodedToken(testUtil.jwts.copilot).handle, + userId: testUtil.getDecodedToken(testUtil.jwts.copilot).userId, + firstName: 'fname', + lastName: 'lName', + email: 'some@abc.com', + }; + + const permissions = [ + { + policy: 'work.create', + permission: { + allowRule: { + projectRoles: ['customer', 'copilot'], + topcoderRoles: ['Connect Manager', 'administrator'], + }, + denyRule: { projectRoles: ['copilot'] }, + }, + createdBy: 1, + updatedBy: 1, + }, + { + policy: 'work.edit', + permission: { + allowRule: { + projectRoles: ['copilot'], + topcoderRoles: ['Connect Manager'], + }, + denyRule: { topcoderRoles: ['Connect Admin'] }, + }, + createdBy: 1, + updatedBy: 1, + }, + ]; + + beforeEach((done) => { + testUtil.clearDb() + .then(() => { + models.ProjectTemplate.create({ + name: 'template 2', + key: 'key 2', + category: 'category 2', + icon: 'http://example.com/icon1.ico', + question: 'question 2', + info: 'info 2', + aliases: ['key-2', 'key_2'], + scope: {}, + phases: {}, + createdBy: 1, + updatedBy: 2, + }) + .then((t) => { + templateId = t.id; + // Create projects + models.Project.create({ + type: 'generic', + billingAccountId: 1, + name: 'test1', + description: 'test project1', + status: 'draft', + templateId, + details: {}, + createdBy: 1, + updatedBy: 1, + lastActivityAt: 1, + lastActivityUserId: '1', + }) + .then((project) => { + projectId = project.id; + models.ProjectMember.bulkCreate([{ + id: 1, + userId: copilotUser.userId, + projectId, + role: 'copilot', + isPrimary: false, + createdBy: 1, + updatedBy: 1, + }, { + id: 2, + userId: memberUser.userId, + projectId, + role: 'customer', + isPrimary: true, + createdBy: 1, + updatedBy: 1, + }]).then(() => { + const newPermissions = _.map(permissions, p => _.assign({}, p, { projectTemplateId: templateId })); + models.WorkManagementPermission.bulkCreate(newPermissions, { returning: true }) + .then(() => done()); + }); + }); + }); + }); + }); + + after((done) => { + testUtil.clearDb(done); + }); + + describe('GET /projects/{projectId}/permissions', () => { + it('should return 403 if user is not authenticated', (done) => { + request(server) + .get(`/v5/projects/${projectId}/permissions`) + .expect(403, done); + }); + + it('should return 403 for non-member', (done) => { + request(server) + .get(`/v5/projects/${projectId}/permissions`) + .set({ + Authorization: `Bearer ${testUtil.jwts.member2}`, + }) + .expect(403, done); + }); + + it('should return 404 for non-existed project', (done) => { + request(server) + .get('/v5/projects/9999/permissions') + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .expect(404, done); + }); + + it('should return 200 for project with no template', (done) => { + models.Project.create({ + type: 'generic', + name: 'test1', + status: 'draft', + createdBy: 1, + updatedBy: 1, + lastActivityAt: 1, + lastActivityUserId: '1', + }) + .then((p) => { + request(server) + .get(`/v5/projects/${p.id}/permissions`) + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .expect(200) + .end((err, res) => { + const resJson = res.body; + resJson.should.be.empty; + done(); + }); + }); + }); + + it('should return 200 for connect admin - no permission', (done) => { + request(server) + .get(`/v5/projects/${projectId}/permissions`) + .set({ + Authorization: `Bearer ${testUtil.jwts.connectAdmin}`, + }) + .expect(200) + .end((err, res) => { + const resJson = res.body; + resJson.should.not.have.all.keys(permissions[0].policy, permissions[1].policy); + done(); + }); + }); + + it('should return 200 for copilot - has both no-permission and permission', (done) => { + request(server) + .get(`/v5/projects/${projectId}/permissions`) + .set({ + Authorization: `Bearer ${testUtil.jwts.copilot}`, + }) + .expect(200) + .end((err, res) => { + const resJson = res.body; + resJson.should.have.all.keys(permissions[1].policy); + resJson.should.not.have.all.keys(permissions[0].policy); + done(); + }); + }); + + it('should return 200 for admin - has both permission and no-permission', (done) => { + request(server) + .get(`/v5/projects/${projectId}/permissions`) + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .expect(200) + .end((err, res) => { + const resJson = res.body; + resJson.should.have.all.keys(permissions[0].policy); + resJson.should.not.have.all.keys(permissions[1].policy); + done(); + }); + }); + + it('should return 200 for manager - has permissions', (done) => { + request(server) + .get(`/v5/projects/${projectId}/permissions`) + .set({ + Authorization: `Bearer ${testUtil.jwts.manager}`, + }) + .expect(200) + .end((err, res) => { + const resJson = res.body; + resJson.should.have.all.keys(permissions[0].policy, permissions[1].policy); + done(); + }); + }); + }); +}); diff --git a/src/routes/phaseProducts/create.spec.js b/src/routes/phaseProducts/create.spec.js index 1e51d38..e987ed4 100644 --- a/src/routes/phaseProducts/create.spec.js +++ b/src/routes/phaseProducts/create.spec.js @@ -7,6 +7,7 @@ import server from '../../app'; import models from '../../models'; import testUtil from '../../tests/util'; import busApi from '../../services/busApi'; +import { RESOURCES, BUS_API_EVENT } from '../../constants'; const should = chai.should(); @@ -177,7 +178,7 @@ describe('Phase Products', () => { request(server) .post(`/v5/projects/99999/phases/${phaseId}/products`) .set({ - Authorization: `Bearer ${testUtil.jwts.manager}`, + Authorization: `Bearer ${testUtil.jwts.connectAdmin}`, }) .send(body) .expect('Content-Type', /json/) @@ -188,7 +189,7 @@ describe('Phase Products', () => { request(server) .post(`/v5/projects/${projectId}/phases/99999/products`) .set({ - Authorization: `Bearer ${testUtil.jwts.manager}`, + Authorization: `Bearer ${testUtil.jwts.connectAdmin}`, }) .send(body) .expect('Content-Type', /json/) @@ -220,6 +221,68 @@ describe('Phase Products', () => { }); }); + it('should return 201 if requested by admin', (done) => { + request(server) + .post(`/v5/projects/${projectId}/phases/${phaseId}/products`) + .set({ + Authorization: `Bearer ${testUtil.jwts.connectAdmin}`, + }) + .send(body) + .expect('Content-Type', /json/) + .expect(201) + .end(done); + }); + + it('should return 201 if requested by manager which is a member', (done) => { + models.ProjectMember.create({ + id: 3, + userId: testUtil.userIds.manager, + projectId, + role: 'manager', + isPrimary: false, + createdBy: 1, + updatedBy: 1, + }).then(() => { + request(server) + .post(`/v5/projects/${projectId}/phases/${phaseId}/products`) + .set({ + Authorization: `Bearer ${testUtil.jwts.manager}`, + }) + .send(body) + .expect('Content-Type', /json/) + .expect(201) + .end(done); + }); + }); + + it('should return 403 if requested by manager which is not a member', (done) => { + request(server) + .post(`/v5/projects/${projectId}/phases/${phaseId}/products`) + .set({ + Authorization: `Bearer ${testUtil.jwts.manager}`, + }) + .send(body) + .expect('Content-Type', /json/) + .expect(403) + .end(done); + }); + + it('should return 403 if requested by non-member copilot', (done) => { + models.ProjectMember.destroy({ + where: { userId: testUtil.userIds.copilot, projectId }, + }).then(() => { + request(server) + .post(`/v5/projects/${projectId}/phases/${phaseId}/products`) + .set({ + Authorization: `Bearer ${testUtil.jwts.copilot}`, + }) + .send(body) + .expect('Content-Type', /json/) + .expect(403) + .end(done); + }); + }); + describe('Bus api', () => { let createEventSpy; const sandbox = sinon.sandbox.create(); @@ -237,7 +300,7 @@ describe('Phase Products', () => { sandbox.restore(); }); - it('should not send message BUS_API_EVENT.PROJECT_PHASE_PRODUCT_ADDED when product phase created', (done) => { + it('should send correct BUS API messages when product phase created', (done) => { request(server) .post(`/v5/projects/${projectId}/phases/${phaseId}/products`) .set({ @@ -251,7 +314,12 @@ describe('Phase Products', () => { done(err); } else { testUtil.wait(() => { - createEventSpy.calledOnce.should.be.true; + createEventSpy.callCount.should.be.eql(1); + + createEventSpy.calledWith(BUS_API_EVENT.PROJECT_PHASE_CREATED, sinon.match({ + resource: RESOURCES.PHASE_PRODUCT, + })).should.be.true; + done(); }); } diff --git a/src/routes/phaseProducts/delete.spec.js b/src/routes/phaseProducts/delete.spec.js index dbbd677..c6e7c35 100644 --- a/src/routes/phaseProducts/delete.spec.js +++ b/src/routes/phaseProducts/delete.spec.js @@ -7,6 +7,7 @@ import server from '../../app'; import models from '../../models'; import testUtil from '../../tests/util'; import busApi from '../../services/busApi'; +import { BUS_API_EVENT, RESOURCES } from '../../constants'; const should = chai.should(); // eslint-disable-line no-unused-vars @@ -152,7 +153,7 @@ describe('Phase Products', () => { request(server) .delete(`/v5/projects/999/phases/${phaseId}/products/${productId}`) .set({ - Authorization: `Bearer ${testUtil.jwts.manager}`, + Authorization: `Bearer ${testUtil.jwts.connectAdmin}`, }) .expect('Content-Type', /json/) .expect(404, done); @@ -162,7 +163,7 @@ describe('Phase Products', () => { request(server) .delete(`/v5/projects/${projectId}/phases/99999/products/${productId}`) .set({ - Authorization: `Bearer ${testUtil.jwts.manager}`, + Authorization: `Bearer ${testUtil.jwts.connectAdmin}`, }) .expect('Content-Type', /json/) .expect(404, done); @@ -172,7 +173,7 @@ describe('Phase Products', () => { request(server) .delete(`/v5/projects/${projectId}/phases/${phaseId}/products/99999`) .set({ - Authorization: `Bearer ${testUtil.jwts.manager}`, + Authorization: `Bearer ${testUtil.jwts.connectAdmin}`, }) .expect('Content-Type', /json/) .expect(404, done); @@ -188,6 +189,60 @@ describe('Phase Products', () => { .end(err => expectAfterDelete(projectId, phaseId, productId, err, done)); }); + it('should return 204 if requested by admin', (done) => { + request(server) + .delete(`/v5/projects/${projectId}/phases/${phaseId}/products/${productId}`) + .set({ + Authorization: `Bearer ${testUtil.jwts.connectAdmin}`, + }) + .expect(204) + .end(done); + }); + + it('should return 204 if requested by manager which is a member', (done) => { + models.ProjectMember.create({ + id: 3, + userId: testUtil.userIds.manager, + projectId, + role: 'manager', + isPrimary: false, + createdBy: 1, + updatedBy: 1, + }).then(() => { + request(server) + .delete(`/v5/projects/${projectId}/phases/${phaseId}/products/${productId}`) + .set({ + Authorization: `Bearer ${testUtil.jwts.manager}`, + }) + .expect(204) + .end(done); + }); + }); + + it('should return 403 if requested by manager which is not a member', (done) => { + request(server) + .delete(`/v5/projects/${projectId}/phases/${phaseId}/products/${productId}`) + .set({ + Authorization: `Bearer ${testUtil.jwts.manager}`, + }) + .expect(403) + .end(done); + }); + + it('should return 403 if requested by non-member copilot', (done) => { + models.ProjectMember.destroy({ + where: { userId: testUtil.userIds.copilot, projectId }, + }).then(() => { + request(server) + .delete(`/v5/projects/${projectId}/phases/${phaseId}/products/${productId}`) + .set({ + Authorization: `Bearer ${testUtil.jwts.copilot}`, + }) + .expect(403) + .end(done); + }); + }); + describe('Bus api', () => { let createEventSpy; const sandbox = sinon.sandbox.create(); @@ -205,7 +260,7 @@ describe('Phase Products', () => { sandbox.restore(); }); - it('should send message BUS_API_EVENT.PROJECT_PHASE_PRODUCT_REMOVED when product phase removed', (done) => { + it('should send correct BUS API messages when product phase removed', (done) => { request(server) .delete(`/v5/projects/${projectId}/phases/${phaseId}/products/${productId}`) .set({ @@ -217,7 +272,12 @@ describe('Phase Products', () => { done(err); } else { testUtil.wait(() => { - createEventSpy.calledOnce.should.be.true; + createEventSpy.callCount.should.be.eql(1); + + createEventSpy.calledWith(BUS_API_EVENT.PROJECT_PHASE_DELETED, sinon.match({ + resource: RESOURCES.PHASE_PRODUCT, + })).should.be.true; + done(); }); } diff --git a/src/routes/phaseProducts/list.spec.js b/src/routes/phaseProducts/list.spec.js index 7a5e39d..3a0fc37 100644 --- a/src/routes/phaseProducts/list.spec.js +++ b/src/routes/phaseProducts/list.spec.js @@ -1,7 +1,7 @@ /* eslint-disable no-unused-expressions */ import _ from 'lodash'; import request from 'supertest'; -import sleep from 'sleep'; +// import sleep from 'sleep'; import chai from 'chai'; import config from 'config'; import server from '../../app'; @@ -111,7 +111,7 @@ describe('Phase Products', () => { body: project, }).then(() => { // sleep for some time, let elasticsearch indices be settled - sleep.sleep(5); + // sleep.sleep(5); done(); }); }); diff --git a/src/routes/phaseProducts/update.js b/src/routes/phaseProducts/update.js index c166837..7ae577d 100644 --- a/src/routes/phaseProducts/update.js +++ b/src/routes/phaseProducts/update.js @@ -5,7 +5,7 @@ import Joi from 'joi'; import { middleware as tcMiddleware } from 'tc-core-library-js'; import models from '../../models'; import util from '../../util'; -import { EVENT, RESOURCES } from '../../constants'; +import { EVENT, RESOURCES, ROUTES } from '../../constants'; const permissions = tcMiddleware.permissions; @@ -78,7 +78,9 @@ module.exports = [ req, EVENT.ROUTING_KEY.PROJECT_PHASE_PRODUCT_UPDATED, RESOURCES.PHASE_PRODUCT, - _.assign(updatedProps, _.pick(updated, 'id', 'updatedAt'))); + updatedValue, + previousValue, + ROUTES.PHASE_PRODUCTS.UPDATE); res.json(updated); }).catch(err => next(err)); diff --git a/src/routes/phaseProducts/update.spec.js b/src/routes/phaseProducts/update.spec.js index df8c567..be89221 100644 --- a/src/routes/phaseProducts/update.spec.js +++ b/src/routes/phaseProducts/update.spec.js @@ -7,7 +7,7 @@ import server from '../../app'; import models from '../../models'; import testUtil from '../../tests/util'; import busApi from '../../services/busApi'; -import { BUS_API_EVENT, RESOURCES } from '../../constants'; +import { BUS_API_EVENT, RESOURCES, CONNECT_NOTIFICATION_EVENT } from '../../constants'; const should = chai.should(); @@ -51,7 +51,7 @@ describe('Phase Products', () => { lastName: 'lName', email: 'some@abc.com', }; - before((done) => { + beforeEach((done) => { // mocks testUtil.clearDb() .then(() => { @@ -144,7 +144,7 @@ describe('Phase Products', () => { request(server) .patch(`/v5/projects/999/phases/${phaseId}/products/${productId}`) .set({ - Authorization: `Bearer ${testUtil.jwts.manager}`, + Authorization: `Bearer ${testUtil.jwts.connectAdmin}`, }) .send(updateBody) .expect('Content-Type', /json/) @@ -155,7 +155,7 @@ describe('Phase Products', () => { request(server) .patch(`/v5/projects/${projectId}/phases/99999/products/${productId}`) .set({ - Authorization: `Bearer ${testUtil.jwts.manager}`, + Authorization: `Bearer ${testUtil.jwts.copilot}`, }) .send(updateBody) .expect('Content-Type', /json/) @@ -166,7 +166,7 @@ describe('Phase Products', () => { request(server) .patch(`/v5/projects/${projectId}/phases/${phaseId}/products/99999`) .set({ - Authorization: `Bearer ${testUtil.jwts.manager}`, + Authorization: `Bearer ${testUtil.jwts.copilot}`, }) .send(updateBody) .expect('Content-Type', /json/) @@ -177,7 +177,7 @@ describe('Phase Products', () => { request(server) .patch(`/v5/projects/${projectId}/phases/${phaseId}/products/99999`) .set({ - Authorization: `Bearer ${testUtil.jwts.manager}`, + Authorization: `Bearer ${testUtil.jwts.copilot}`, }) .send({ estimatedPrice: -15, @@ -212,6 +212,68 @@ describe('Phase Products', () => { }); }); + it('should return 200 if requested by admin', (done) => { + request(server) + .patch(`/v5/projects/${projectId}/phases/${phaseId}/products/${productId}`) + .set({ + Authorization: `Bearer ${testUtil.jwts.connectAdmin}`, + }) + .send(updateBody) + .expect('Content-Type', /json/) + .expect(200) + .end(done); + }); + + it('should return 200 if requested by manager which is a member', (done) => { + models.ProjectMember.create({ + id: 3, + userId: testUtil.userIds.manager, + projectId, + role: 'manager', + isPrimary: false, + createdBy: 1, + updatedBy: 1, + }).then(() => { + request(server) + .patch(`/v5/projects/${projectId}/phases/${phaseId}/products/${productId}`) + .set({ + Authorization: `Bearer ${testUtil.jwts.manager}`, + }) + .send(updateBody) + .expect('Content-Type', /json/) + .expect(200) + .end(done); + }); + }); + + it('should return 403 if requested by manager which is not a member', (done) => { + request(server) + .patch(`/v5/projects/${projectId}/phases/${phaseId}/products/${productId}`) + .set({ + Authorization: `Bearer ${testUtil.jwts.manager}`, + }) + .send(updateBody) + .expect('Content-Type', /json/) + .expect(403) + .end(done); + }); + + it('should return 403 if requested by non-member copilot', (done) => { + models.ProjectMember.destroy({ + where: { userId: testUtil.userIds.copilot, projectId }, + }).then(() => { + request(server) + .patch(`/v5/projects/${projectId}/phases/${phaseId}/products/${productId}`) + .set({ + Authorization: `Bearer ${testUtil.jwts.copilot}`, + }) + .send(updateBody) + .expect('Content-Type', /json/) + .expect(403) + .end(done); + }); + }); + describe('Bus api', () => { let createEventSpy; const sandbox = sinon.sandbox.create(); @@ -229,7 +291,7 @@ describe('Phase Products', () => { sandbox.restore(); }); - it('should send message BUS_API_EVENT.PROJECT_PHASE_PRODUCT_UPDATED when name updated', (done) => { + it('should send correct BUS API messages when name updated', (done) => { request(server) .patch(`/v5/projects/${projectId}/phases/${phaseId}/products/${productId}`) .set({ @@ -245,18 +307,29 @@ describe('Phase Products', () => { done(err); } else { testUtil.wait(() => { - createEventSpy.calledOnce.should.be.true; - createEventSpy.firstCall.calledWith(BUS_API_EVENT.PROJECT_PHASE_PRODUCT_UPDATED, - sinon.match({ resource: RESOURCES.PHASE_PRODUCT })).should.be.true; - createEventSpy.firstCall.calledWith(BUS_API_EVENT.PROJECT_PHASE_PRODUCT_UPDATED, - sinon.match({ name: 'new name' })).should.be.true; + createEventSpy.callCount.should.be.eql(2); + + createEventSpy.calledWith(BUS_API_EVENT.PROJECT_PHASE_PRODUCT_UPDATED, sinon.match({ + resource: RESOURCES.PHASE_PRODUCT, + name: 'new name', + })).should.be.true; + + // Check Notification Service events + createEventSpy.calledWith(CONNECT_NOTIFICATION_EVENT.PROJECT_PLAN_UPDATED, sinon.match({ + projectId: 1, + projectName: 'test1', + projectUrl: 'https://local.topcoder-dev.com/projects/1', + userId: 40051332, + initiatorUserId: 40051332, + })).should.be.true; + done(); }); } }); }); - it('should send message BUS_API_EVENT.PROJECT_PHASE_PRODUCT_UPDATED when estimatedPrice updated', (done) => { + it('should send correct BUS API messages when estimatedPrice updated', (done) => { request(server) .patch(`/v5/projects/${projectId}/phases/${phaseId}/products/${productId}`) .set({ @@ -272,18 +345,29 @@ describe('Phase Products', () => { done(err); } else { testUtil.wait(() => { - createEventSpy.calledOnce.should.be.true; - createEventSpy.firstCall.calledWith(BUS_API_EVENT.PROJECT_PHASE_PRODUCT_UPDATED, - sinon.match({ resource: RESOURCES.PHASE_PRODUCT })).should.be.true; - createEventSpy.firstCall.calledWith(BUS_API_EVENT.PROJECT_PHASE_PRODUCT_UPDATED, - sinon.match({ estimatedPrice: 123 })).should.be.true; + createEventSpy.callCount.should.be.eql(2); + + createEventSpy.calledWith(BUS_API_EVENT.PROJECT_PHASE_PRODUCT_UPDATED, sinon.match({ + resource: RESOURCES.PHASE_PRODUCT, + estimatedPrice: 123, + })).should.be.true; + + // Check Notification Service events + createEventSpy.calledWith(CONNECT_NOTIFICATION_EVENT.PROJECT_PLAN_UPDATED, sinon.match({ + projectId: 1, + projectName: 'test1', + projectUrl: 'https://local.topcoder-dev.com/projects/1', + userId: 40051332, + initiatorUserId: 40051332, + })).should.be.true; + done(); }); } }); }); - it('should send message BUS_API_EVENT.PROJECT_PHASE_PRODUCT_UPDATED when actualPrice updated', (done) => { + it('should send correct BUS API messages when actualPrice updated', (done) => { request(server) .patch(`/v5/projects/${projectId}/phases/${phaseId}/products/${productId}`) .set({ @@ -299,18 +383,29 @@ describe('Phase Products', () => { done(err); } else { testUtil.wait(() => { - createEventSpy.calledOnce.should.be.true; - createEventSpy.firstCall.calledWith(BUS_API_EVENT.PROJECT_PHASE_PRODUCT_UPDATED, - sinon.match({ resource: RESOURCES.PHASE_PRODUCT })).should.be.true; - createEventSpy.firstCall.calledWith(BUS_API_EVENT.PROJECT_PHASE_PRODUCT_UPDATED, - sinon.match({ actualPrice: 123 })).should.be.true; + createEventSpy.callCount.should.be.eql(2); + + createEventSpy.calledWith(BUS_API_EVENT.PROJECT_PHASE_PRODUCT_UPDATED, sinon.match({ + resource: RESOURCES.PHASE_PRODUCT, + actualPrice: 123, + })).should.be.true; + + // Check Notification Service events + createEventSpy.calledWith(CONNECT_NOTIFICATION_EVENT.PROJECT_PLAN_UPDATED, sinon.match({ + projectId: 1, + projectName: 'test1', + projectUrl: 'https://local.topcoder-dev.com/projects/1', + userId: 40051332, + initiatorUserId: 40051332, + })).should.be.true; + done(); }); } }); }); - it('should send message BUS_API_EVENT.PROJECT_PHASE_PRODUCT_UPDATED when details updated', (done) => { + it('should send correct BUS API messages when details updated', (done) => { request(server) .patch(`/v5/projects/${projectId}/phases/${phaseId}/products/${productId}`) .set({ @@ -326,18 +421,31 @@ describe('Phase Products', () => { done(err); } else { testUtil.wait(() => { - createEventSpy.calledOnce.should.be.true; - createEventSpy.firstCall.calledWith(BUS_API_EVENT.PROJECT_PHASE_PRODUCT_UPDATED, - sinon.match({ resource: RESOURCES.PHASE_PRODUCT })).should.be.true; - createEventSpy.firstCall.calledWith(BUS_API_EVENT.PROJECT_PHASE_PRODUCT_UPDATED, - sinon.match({ details: 'something' })).should.be.true; + createEventSpy.callCount.should.be.eql(3); + + createEventSpy.calledWith(BUS_API_EVENT.PROJECT_PHASE_PRODUCT_UPDATED, sinon.match({ + resource: RESOURCES.PHASE_PRODUCT, + details: 'something', + })).should.be.true; + + // Check Notification Service events + createEventSpy.calledWith(CONNECT_NOTIFICATION_EVENT.PROJECT_PRODUCT_SPECIFICATION_MODIFIED) + .should.be.true; + createEventSpy.calledWith(CONNECT_NOTIFICATION_EVENT.PROJECT_PLAN_UPDATED, sinon.match({ + projectId: 1, + projectName: 'test1', + projectUrl: 'https://local.topcoder-dev.com/projects/1', + userId: 40051332, + initiatorUserId: 40051332, + })).should.be.true; + done(); }); } }); }); - it('should not send message BUS_API_EVENT.PROJECT_PHASE_PRODUCT_UPDATED when type updated', (done) => { + it('should send correct BUS API messages when type updated', (done) => { request(server) .patch(`/v5/projects/${projectId}/phases/${phaseId}/products/${productId}`) .set({ @@ -353,11 +461,13 @@ describe('Phase Products', () => { done(err); } else { testUtil.wait(() => { - createEventSpy.calledOnce.should.be.true; - createEventSpy.firstCall.calledWith(BUS_API_EVENT.PROJECT_PHASE_PRODUCT_UPDATED, - sinon.match({ resource: RESOURCES.PHASE_PRODUCT })).should.be.true; - createEventSpy.firstCall.calledWith(BUS_API_EVENT.PROJECT_PHASE_PRODUCT_UPDATED, - sinon.match({ type: 'another type' })).should.be.true; + createEventSpy.callCount.should.be.eql(1); + + createEventSpy.calledWith(BUS_API_EVENT.PROJECT_PHASE_PRODUCT_UPDATED, sinon.match({ + resource: RESOURCES.PHASE_PRODUCT, + type: 'another type', + })).should.be.true; + done(); }); } diff --git a/src/routes/phases/create.js b/src/routes/phases/create.js index 59aaee7..5207163 100644 --- a/src/routes/phases/create.js +++ b/src/routes/phases/create.js @@ -5,7 +5,7 @@ import Sequelize from 'sequelize'; import models from '../../models'; import util from '../../util'; -import { EVENT, RESOURCES } from '../../constants'; +import { EVENT, RESOURCES, TIMELINE_REFERENCES } from '../../constants'; const permissions = require('tc-core-library-js').middleware.permissions; @@ -13,6 +13,8 @@ const permissions = require('tc-core-library-js').middleware.permissions; const addProjectPhaseValidations = { body: Joi.object().keys({ name: Joi.string().required(), + description: Joi.string().optional(), + requirements: Joi.string().optional(), status: Joi.string().required(), startDate: Joi.date().optional(), endDate: Joi.date().optional(), @@ -139,7 +141,7 @@ module.exports = [ // 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, + { added: newProjectPhase, route: TIMELINE_REFERENCES.PHASE }, { correlationId: req.id }, ); @@ -156,7 +158,12 @@ module.exports = [ req, EVENT.ROUTING_KEY.PROJECT_PHASE_UPDATED, RESOURCES.PHASE, + _.assign(_.pick(phase.toJSON(), 'id', 'order', 'updatedBy', 'updatedAt')), + // Pass the same object as original phase even though, the order has changed. + // So far we don't use the order so it's ok. But in general, we should pass + // the original phases. <- TODO _.assign(_.pick(phase.toJSON(), 'id', 'order', 'updatedBy', 'updatedAt'))), + true, // don't send event to Notification Service as the main event here is updating one phase ); res.status(201).json(newProjectPhase); diff --git a/src/routes/phases/create.spec.js b/src/routes/phases/create.spec.js index 693683c..94ee55c 100644 --- a/src/routes/phases/create.spec.js +++ b/src/routes/phases/create.spec.js @@ -2,19 +2,28 @@ import _ from 'lodash'; import chai from 'chai'; import sinon from 'sinon'; +import config from 'config'; import request from 'supertest'; import server from '../../app'; import models from '../../models'; import testUtil from '../../tests/util'; import busApi from '../../services/busApi'; +import messageService from '../../services/messageService'; +import RabbitMQService from '../../services/rabbitmq'; +import mockRabbitMQ from '../../tests/mockRabbitMQ'; import { - BUS_API_EVENT, RESOURCES, + BUS_API_EVENT, RESOURCES, CONNECT_NOTIFICATION_EVENT, } from '../../constants'; const should = chai.should(); +const ES_PROJECT_INDEX = config.get('elasticsearchConfig.indexName'); +const ES_PROJECT_TYPE = config.get('elasticsearchConfig.docType'); + const body = { name: 'test project phase', + description: 'test project phase description', + requirements: 'test project phase requirements', status: 'active', startDate: '2018-05-15T00:00:00Z', endDate: '2018-05-15T12:00:00Z', @@ -30,6 +39,8 @@ const body = { const validatePhase = (resJson, expectedPhase) => { should.exist(resJson); resJson.name.should.be.eql(expectedPhase.name); + resJson.description.should.be.eql(expectedPhase.description); + resJson.requirements.should.be.eql(expectedPhase.requirements); resJson.status.should.be.eql(expectedPhase.status); resJson.budget.should.be.eql(expectedPhase.budget); resJson.progress.should.be.eql(expectedPhase.progress); @@ -38,6 +49,7 @@ const validatePhase = (resJson, expectedPhase) => { describe('Project Phases', () => { let projectId; + let projectName; const memberUser = { handle: testUtil.getDecodedToken(testUtil.jwts.member).handle, userId: testUtil.getDecodedToken(testUtil.jwts.member).userId, @@ -52,44 +64,44 @@ describe('Project Phases', () => { lastName: 'lName', email: 'some@abc.com', }; + const project = { + type: 'generic', + billingAccountId: 1, + name: 'test1', + description: 'test project1', + status: 'draft', + details: {}, + createdBy: 1, + updatedBy: 1, + lastActivityAt: 1, + lastActivityUserId: '1', + }; let productTemplateId; - before((done) => { + beforeEach((done) => { // mocks testUtil.clearDb() - .then(() => { - models.Project.create({ - type: 'generic', - billingAccountId: 1, - name: 'test1', - description: 'test project1', - status: 'draft', - details: {}, + .then(() => models.Project.create(project).then((p) => { + projectId = p.id; + projectName = p.name; + // create members + return models.ProjectMember.bulkCreate([{ + id: 1, + userId: copilotUser.userId, + projectId, + role: 'copilot', + isPrimary: false, createdBy: 1, updatedBy: 1, - lastActivityAt: 1, - lastActivityUserId: '1', - }).then((p) => { - projectId = p.id; - // create members - models.ProjectMember.bulkCreate([{ - id: 1, - userId: copilotUser.userId, - projectId, - role: 'copilot', - isPrimary: false, - createdBy: 1, - updatedBy: 1, - }, { - id: 2, - userId: memberUser.userId, - projectId, - role: 'customer', - isPrimary: true, - createdBy: 1, - updatedBy: 1, - }]); - }); - }) + }, { + id: 2, + userId: memberUser.userId, + projectId, + role: 'customer', + isPrimary: true, + createdBy: 1, + updatedBy: 1, + }]); + })) .then(() => models.ProductTemplate.create({ name: 'name 1', @@ -126,7 +138,7 @@ describe('Project Phases', () => { .then(() => done()); }); - after((done) => { + afterEach((done) => { testUtil.clearDb(done); }); @@ -222,7 +234,7 @@ describe('Project Phases', () => { request(server) .post('/v5/projects/99999/phases/') .set({ - Authorization: `Bearer ${testUtil.jwts.manager}`, + Authorization: `Bearer ${testUtil.jwts.admin}`, }) .send(body) .expect('Content-Type', /json/) @@ -345,6 +357,68 @@ describe('Project Phases', () => { }); }); + it('should return 201 if requested by admin', (done) => { + request(server) + .post(`/v5/projects/${projectId}/phases/`) + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .send(body) + .expect('Content-Type', /json/) + .expect(201) + .end(done); + }); + + it('should return 201 if requested by manager which is a member', (done) => { + models.ProjectMember.create({ + id: 3, + userId: testUtil.userIds.manager, + projectId, + role: 'manager', + isPrimary: false, + createdBy: 1, + updatedBy: 1, + }).then(() => { + request(server) + .post(`/v5/projects/${projectId}/phases/`) + .set({ + Authorization: `Bearer ${testUtil.jwts.manager}`, + }) + .send(body) + .expect('Content-Type', /json/) + .expect(201) + .end(done); + }); + }); + + it('should return 403 if requested by manager which is not a member', (done) => { + request(server) + .post(`/v5/projects/${projectId}/phases/`) + .set({ + Authorization: `Bearer ${testUtil.jwts.manager}`, + }) + .send(body) + .expect('Content-Type', /json/) + .expect(403) + .end(done); + }); + + it('should return 403 if requested by non-member copilot', (done) => { + models.ProjectMember.destroy({ + where: { userId: testUtil.userIds.copilot, projectId }, + }).then(() => { + request(server) + .post(`/v5/projects/${projectId}/phases/`) + .set({ + Authorization: `Bearer ${testUtil.jwts.copilot}`, + }) + .send(body) + .expect('Content-Type', /json/) + .expect(403) + .end(done); + }); + }); + describe('Bus api', () => { let createEventSpy; const sandbox = sinon.sandbox.create(); @@ -362,7 +436,7 @@ describe('Project Phases', () => { sandbox.restore(); }); - it('should send message BUS_API_EVENT.PROJECT_PHASE_ADDED when phase added', (done) => { + it('should send correct BUS API messages when phase added', (done) => { request(server) .post(`/v5/projects/${projectId}/phases/`) .set({ @@ -376,24 +450,111 @@ describe('Project Phases', () => { done(err); } else { testUtil.wait(() => { - createEventSpy.calledOnce.should.be.true; - createEventSpy.calledWith(BUS_API_EVENT.PROJECT_PHASE_CREATED, - sinon.match({ resource: RESOURCES.PHASE })).should.be.true; - createEventSpy.calledWith(BUS_API_EVENT.PROJECT_PHASE_CREATED, - sinon.match({ name: body.name })).should.be.true; - createEventSpy.calledWith(BUS_API_EVENT.PROJECT_PHASE_CREATED, - sinon.match({ status: body.status })).should.be.true; - createEventSpy.calledWith(BUS_API_EVENT.PROJECT_PHASE_CREATED, - sinon.match({ budget: body.budget })).should.be.true; - createEventSpy.calledWith(BUS_API_EVENT.PROJECT_PHASE_CREATED, - sinon.match({ progress: body.progress })).should.be.true; - createEventSpy.calledWith(BUS_API_EVENT.PROJECT_PHASE_CREATED, - sinon.match({ projectId })).should.be.true; + createEventSpy.callCount.should.be.eql(2); + + createEventSpy.calledWith(BUS_API_EVENT.PROJECT_PHASE_CREATED, sinon.match({ + resource: RESOURCES.PHASE, + name: body.name, + status: body.status, + budget: body.budget, + progress: body.progress, + projectId, + })).should.be.true; + + // Check Notification Service events + createEventSpy.calledWith(CONNECT_NOTIFICATION_EVENT.PROJECT_PLAN_UPDATED, sinon.match({ + projectId, + projectName, + projectUrl: `https://local.topcoder-dev.com/projects/${projectId}`, + userId: 40051332, + initiatorUserId: 40051332, + })).should.be.true; + done(); }); } }); }); }); + + describe('RabbitMQ Message topic', () => { + let createMessageSpy; + let publishSpy; + let sandbox; + + // Wait for 500ms in order to wait for createEvent calls from previous tests to complete + before(async () => new Promise(resolve => setTimeout(() => resolve(), 500))); + + beforeEach(async () => { + sandbox = sinon.sandbox.create(); + server.services.pubsub = new RabbitMQService(server.logger); + + // initialize RabbitMQ + server.services.pubsub.init( + config.get('rabbitmqURL'), + config.get('pubsubExchangeName'), + config.get('pubsubQueueName'), + ); + + // add project to ES index + await server.services.es.index({ + index: ES_PROJECT_INDEX, + type: ES_PROJECT_TYPE, + id: projectId, + body: { + doc: project, + }, + }); + + return new Promise(resolve => setTimeout(() => { + publishSpy = sandbox.spy(server.services.pubsub, 'publish'); + createMessageSpy = sandbox.spy(messageService, 'createTopic'); + resolve(); + }, 500)); + }); + + afterEach(() => { + sandbox.restore(); + }); + + after(() => { + mockRabbitMQ(server); + }); + + it('should send message topic when phase added', (done) => { + const mockHttpClient = _.merge(testUtil.mockHttpClient, { + post: () => Promise.resolve({ + status: 200, + data: {}, + }), + }); + sandbox.stub(messageService, 'getClient', () => mockHttpClient); + request(server) + .post(`/v5/projects/${projectId}/phases/`) + .set({ + Authorization: `Bearer ${testUtil.jwts.copilot}`, + }) + .send(body) + .expect('Content-Type', /json/) + .expect(201) + .end((err) => { + if (err) { + done(err); + } else { + testUtil.wait(() => { + publishSpy.calledOnce.should.be.true; + publishSpy.calledWith('project.phase.added').should.be.true; + createMessageSpy.calledOnce.should.be.true; + createMessageSpy.calledWith(sinon.match({ reference: 'project', + referenceId: '1', + tag: 'phase#1', + title: 'test project phase', + })).should.be.true; + done(); + }); + } + }); + }); + }); }); }); diff --git a/src/routes/phases/delete.js b/src/routes/phases/delete.js index d035cc9..a7e6d2a 100644 --- a/src/routes/phases/delete.js +++ b/src/routes/phases/delete.js @@ -4,7 +4,7 @@ import _ from 'lodash'; import { middleware as tcMiddleware } from 'tc-core-library-js'; import models from '../../models'; import util from '../../util'; -import { EVENT, RESOURCES } from '../../constants'; +import { EVENT, RESOURCES, TIMELINE_REFERENCES } from '../../constants'; const permissions = tcMiddleware.permissions; @@ -41,7 +41,7 @@ module.exports = [ // Send events to buses req.app.services.pubsub.publish( EVENT.ROUTING_KEY.PROJECT_PHASE_REMOVED, - deleted, + { deleted, route: TIMELINE_REFERENCES.PHASE }, { correlationId: req.id }, ); diff --git a/src/routes/phases/delete.spec.js b/src/routes/phases/delete.spec.js index 7bf7e1e..17eea96 100644 --- a/src/routes/phases/delete.spec.js +++ b/src/routes/phases/delete.spec.js @@ -3,18 +3,25 @@ import _ from 'lodash'; import request from 'supertest'; import sinon from 'sinon'; import chai from 'chai'; +import config from 'config'; import server from '../../app'; import models from '../../models'; import testUtil from '../../tests/util'; import busApi from '../../services/busApi'; - +import messageService from '../../services/messageService'; +import RabbitMQService from '../../services/rabbitmq'; +import mockRabbitMQ from '../../tests/mockRabbitMQ'; import { BUS_API_EVENT, RESOURCES, + CONNECT_NOTIFICATION_EVENT, } from '../../constants'; const should = chai.should(); // eslint-disable-line no-unused-vars +const ES_PROJECT_INDEX = config.get('elasticsearchConfig.indexName'); +const ES_PROJECT_TYPE = config.get('elasticsearchConfig.docType'); + const expectAfterDelete = (projectId, id, err, next) => { if (err) throw err; setTimeout(() => @@ -52,6 +59,7 @@ const body = { describe('Project Phases', () => { let projectId; let phaseId; + let projectName; const memberUser = { handle: testUtil.getDecodedToken(testUtil.jwts.member).handle, userId: testUtil.getDecodedToken(testUtil.jwts.member).userId, @@ -66,23 +74,34 @@ describe('Project Phases', () => { lastName: 'lName', email: 'some@abc.com', }; + const project = { + type: 'generic', + billingAccountId: 1, + name: 'test1', + description: 'test project1', + status: 'draft', + details: {}, + createdBy: 1, + updatedBy: 1, + lastActivityAt: 1, + lastActivityUserId: '1', + }; + const topic = { + id: 1, + title: 'test project phase', + posts: + [{ id: 1, + type: 'post', + body: 'body', + }], + }; beforeEach((done) => { // mocks testUtil.clearDb() .then(() => { - models.Project.create({ - type: 'generic', - billingAccountId: 1, - name: 'test1', - description: 'test project1', - status: 'draft', - details: {}, - createdBy: 1, - updatedBy: 1, - lastActivityAt: 1, - lastActivityUserId: '1', - }).then((p) => { + models.Project.create(project).then((p) => { projectId = p.id; + projectName = p.name; // create members models.ProjectMember.bulkCreate([{ id: 1, @@ -140,7 +159,7 @@ describe('Project Phases', () => { request(server) .delete(`/v5/projects/999/phases/${phaseId}`) .set({ - Authorization: `Bearer ${testUtil.jwts.manager}`, + Authorization: `Bearer ${testUtil.jwts.admin}`, }) .expect('Content-Type', /json/) .expect(404, done); @@ -150,7 +169,7 @@ describe('Project Phases', () => { request(server) .delete(`/v5/projects/${projectId}/phases/999`) .set({ - Authorization: `Bearer ${testUtil.jwts.manager}`, + Authorization: `Bearer ${testUtil.jwts.admin}`, }) .expect('Content-Type', /json/) .expect(404, done); @@ -162,9 +181,64 @@ describe('Project Phases', () => { .set({ Authorization: `Bearer ${testUtil.jwts.copilot}`, }) + .expect(204) .end(err => expectAfterDelete(projectId, phaseId, err, done)); }); + it('should return 204 if requested by admin', (done) => { + request(server) + .delete(`/v5/projects/${projectId}/phases/${phaseId}`) + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .expect(204) + .end(done); + }); + + it('should return 204 if requested by manager which is a member', (done) => { + models.ProjectMember.create({ + id: 3, + userId: testUtil.userIds.manager, + projectId, + role: 'manager', + isPrimary: false, + createdBy: 1, + updatedBy: 1, + }).then(() => { + request(server) + .delete(`/v5/projects/${projectId}/phases/${phaseId}`) + .set({ + Authorization: `Bearer ${testUtil.jwts.manager}`, + }) + .expect(204) + .end(done); + }); + }); + + it('should return 403 if requested by manager which is not a member', (done) => { + request(server) + .delete(`/v5/projects/${projectId}/phases/${phaseId}`) + .set({ + Authorization: `Bearer ${testUtil.jwts.manager}`, + }) + .expect(403) + .end(done); + }); + + it('should return 403 if requested by non-member copilot', (done) => { + models.ProjectMember.destroy({ + where: { userId: testUtil.userIds.copilot, projectId }, + }).then(() => { + request(server) + .delete(`/v5/projects/${projectId}/phases/${phaseId}`) + .set({ + Authorization: `Bearer ${testUtil.jwts.copilot}`, + }) + .expect(403) + .end(done); + }); + }); + describe('Bus api', () => { let createEventSpy; const sandbox = sinon.sandbox.create(); @@ -182,7 +256,7 @@ describe('Project Phases', () => { sandbox.restore(); }); - it('should send message BUS_API_EVENT.PROJECT_PHASE_DELETED when phase removed', (done) => { + it('should send correct BUS API messages when phase removed', (done) => { request(server) .delete(`/v5/projects/${projectId}/phases/${phaseId}`) .set({ @@ -194,16 +268,102 @@ describe('Project Phases', () => { done(err); } else { testUtil.wait(() => { - createEventSpy.calledWith(BUS_API_EVENT.PROJECT_PHASE_DELETED).should.be.true; - createEventSpy.calledWith(BUS_API_EVENT.PROJECT_PHASE_DELETED, - sinon.match({ resource: RESOURCES.PHASE })).should.be.true; - createEventSpy.calledWith(BUS_API_EVENT.PROJECT_PHASE_DELETED, - sinon.match({ id: phaseId })).should.be.true; + createEventSpy.callCount.should.be.eql(2); + + createEventSpy.calledWith(BUS_API_EVENT.PROJECT_PHASE_DELETED, sinon.match({ + resource: RESOURCES.PHASE, + id: phaseId, + })).should.be.true; + + // Check Notification Service events + createEventSpy.calledWith(CONNECT_NOTIFICATION_EVENT.PROJECT_PLAN_UPDATED, sinon.match({ + projectId, + projectName, + projectUrl: `https://local.topcoder-dev.com/projects/${projectId}`, + userId: 40051332, + initiatorUserId: 40051332, + })).should.be.true; + done(); }); } }); }); }); + + describe('RabbitMQ Message topic', () => { + let deleteTopicSpy; + let deletePostsSpy; + let publishSpy; + let sandbox; + + // Wait for 500ms in order to wait for createEvent calls from previous tests to complete + before(async () => new Promise(resolve => setTimeout(() => resolve(), 500))); + + beforeEach(async () => { + sandbox = sinon.sandbox.create(); + server.services.pubsub = new RabbitMQService(server.logger); + + // initialize RabbitMQ + server.services.pubsub.init( + config.get('rabbitmqURL'), + config.get('pubsubExchangeName'), + config.get('pubsubQueueName'), + ); + + // add project to ES index + await server.services.es.index({ + index: ES_PROJECT_INDEX, + type: ES_PROJECT_TYPE, + id: projectId, + body: { + doc: _.assign(project, { phases: [_.assign(body, { id: phaseId, projectId })] }), + }, + }); + + return new Promise(resolve => setTimeout(() => { + publishSpy = sandbox.spy(server.services.pubsub, 'publish'); + deleteTopicSpy = sandbox.spy(messageService, 'deleteTopic'); + deletePostsSpy = sandbox.spy(messageService, 'deletePosts'); + sandbox.stub(messageService, 'getTopicByTag', () => Promise.resolve(topic)); + resolve(); + }, 500)); + }); + + afterEach(() => { + sandbox.restore(); + }); + + after(() => { + mockRabbitMQ(server); + }); + + it('should send message topic when phase deleted', (done) => { + const mockHttpClient = _.merge(testUtil.mockHttpClient, { + delete: () => Promise.resolve(true), + }); + sandbox.stub(messageService, 'getClient', () => mockHttpClient); + request(server) + .delete(`/v5/projects/${projectId}/phases/${phaseId}`) + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .expect(204) + .end((err) => { + if (err) { + done(err); + } else { + testUtil.wait(() => { + publishSpy.calledOnce.should.be.true; + publishSpy.firstCall.calledWith('project.phase.removed').should.be.true; + deleteTopicSpy.calledOnce.should.be.true; + deleteTopicSpy.calledWith(topic.id).should.be.true; + deletePostsSpy.calledWith(topic.id).should.be.true; + done(); + }); + } + }); + }); + }); }); }); diff --git a/src/routes/phases/list.js b/src/routes/phases/list.js index 04c6a63..5370c2e 100644 --- a/src/routes/phases/list.js +++ b/src/routes/phases/list.js @@ -64,7 +64,7 @@ module.exports = [ if (!project) { const apiErr = new Error(`active project not found for project id ${projectId}`); apiErr.status = 404; - next(apiErr); + return next(apiErr); } // Get the phases diff --git a/src/routes/phases/list.spec.js b/src/routes/phases/list.spec.js index 1fc977c..d4d93a7 100644 --- a/src/routes/phases/list.spec.js +++ b/src/routes/phases/list.spec.js @@ -2,7 +2,7 @@ import _ from 'lodash'; import request from 'supertest'; import config from 'config'; -import sleep from 'sleep'; +// import sleep from 'sleep'; import chai from 'chai'; import server from '../../app'; import models from '../../models'; @@ -95,7 +95,7 @@ describe('Project Phases', () => { body: project, }).then(() => { // sleep for some time, let elasticsearch indices be settled - sleep.sleep(5); + // sleep.sleep(5); done(); }); }); diff --git a/src/routes/phases/update.js b/src/routes/phases/update.js index bf85ffb..7bafa01 100644 --- a/src/routes/phases/update.js +++ b/src/routes/phases/update.js @@ -6,7 +6,7 @@ import Sequelize from 'sequelize'; import { middleware as tcMiddleware } from 'tc-core-library-js'; import models from '../../models'; import util from '../../util'; -import { EVENT, RESOURCES } from '../../constants'; +import { EVENT, RESOURCES, TIMELINE_REFERENCES, ROUTES } from '../../constants'; const permissions = tcMiddleware.permissions; @@ -14,6 +14,8 @@ const permissions = tcMiddleware.permissions; const updateProjectPhaseValidation = { body: Joi.object().keys({ name: Joi.string().optional(), + description: Joi.string().optional(), + requirements: Joi.string().optional(), status: Joi.string().optional(), startDate: Joi.date().optional(), endDate: Joi.date().optional(), @@ -149,10 +151,12 @@ module.exports = [ .then((allPhases) => { req.log.debug('updated project phase', JSON.stringify(updated, null, 2)); + const updatedValue = updated.get({ plain: true }); + // emit original and updated project phase information req.app.services.pubsub.publish( EVENT.ROUTING_KEY.PROJECT_PHASE_UPDATED, - { original: previousValue, updated, allPhases }, + { original: previousValue, updated: updatedValue, allPhases, route: TIMELINE_REFERENCES.PHASE }, { correlationId: req.id }, ); @@ -161,7 +165,9 @@ module.exports = [ req, EVENT.ROUTING_KEY.PROJECT_PHASE_UPDATED, RESOURCES.PHASE, - _.assign(updatedProps, _.pick(updated, 'id', 'updatedAt'))); + updatedValue, + previousValue, + ROUTES.PHASES.UPDATE); res.json(updated); }) diff --git a/src/routes/phases/update.spec.js b/src/routes/phases/update.spec.js index bb68211..408670a 100644 --- a/src/routes/phases/update.spec.js +++ b/src/routes/phases/update.spec.js @@ -2,21 +2,30 @@ import _ from 'lodash'; import sinon from 'sinon'; import chai from 'chai'; +import config from 'config'; import request from 'supertest'; import server from '../../app'; import models from '../../models'; import testUtil from '../../tests/util'; import busApi from '../../services/busApi'; - +import messageService from '../../services/messageService'; +import RabbitMQService from '../../services/rabbitmq'; +import mockRabbitMQ from '../../tests/mockRabbitMQ'; import { BUS_API_EVENT, RESOURCES, + CONNECT_NOTIFICATION_EVENT, } from '../../constants'; +const ES_PROJECT_INDEX = config.get('elasticsearchConfig.indexName'); +const ES_PROJECT_TYPE = config.get('elasticsearchConfig.docType'); + const should = chai.should(); const body = { name: 'test project phase', + description: 'test project phase description', + requirements: 'test project phase requirements', status: 'active', startDate: '2018-05-15T00:00:00Z', endDate: '2018-05-15T12:00:00Z', @@ -31,6 +40,8 @@ const body = { const updateBody = { name: 'test project phase xxx', + description: 'test project phase description xxx', + requirements: 'test project phase requirements xxx', status: 'inactive', startDate: '2018-05-11T00:00:00Z', endDate: '2018-05-12T12:00:00Z', @@ -44,6 +55,8 @@ const updateBody = { const validatePhase = (resJson, expectedPhase) => { should.exist(resJson); resJson.name.should.be.eql(expectedPhase.name); + resJson.description.should.be.eql(expectedPhase.description); + resJson.requirements.should.be.eql(expectedPhase.requirements); resJson.status.should.be.eql(expectedPhase.status); resJson.budget.should.be.eql(expectedPhase.budget); resJson.progress.should.be.eql(expectedPhase.progress); @@ -52,8 +65,10 @@ const validatePhase = (resJson, expectedPhase) => { describe('Project Phases', () => { let projectId; + let projectName; let phaseId; let phaseId2; + let phaseId3; const memberUser = { handle: testUtil.getDecodedToken(testUtil.jwts.member).handle, userId: testUtil.getDecodedToken(testUtil.jwts.member).userId, @@ -68,23 +83,34 @@ describe('Project Phases', () => { lastName: 'lName', email: 'some@abc.com', }; - before((done) => { + const project = { + type: 'generic', + billingAccountId: 1, + name: 'test1', + description: 'test project1', + status: 'draft', + details: {}, + createdBy: 1, + updatedBy: 1, + lastActivityAt: 1, + lastActivityUserId: '1', + }; + const topic = { + id: 1, + title: 'test project phase', + posts: + [{ id: 1, + type: 'post', + body: 'body', + }], + }; + beforeEach((done) => { // mocks testUtil.clearDb() .then(() => { - models.Project.create({ - type: 'generic', - billingAccountId: 1, - name: 'test1', - description: 'test project1', - status: 'draft', - details: {}, - createdBy: 1, - updatedBy: 1, - lastActivityAt: 1, - lastActivityUserId: '1', - }).then((p) => { + models.Project.create(project).then((p) => { projectId = p.id; + projectName = p.name; // create members models.ProjectMember.bulkCreate([{ id: 1, @@ -107,11 +133,13 @@ describe('Project Phases', () => { const phases = [ body, _.assign({ order: 1 }, body), + _.assign({}, body, { status: 'draft' }), ]; models.ProjectPhase.bulkCreate(phases, { returning: true }) .then((createdPhases) => { phaseId = createdPhases[0].id; phaseId2 = createdPhases[1].id; + phaseId3 = createdPhases[2].id; done(); }); @@ -120,7 +148,7 @@ describe('Project Phases', () => { }); }); - after((done) => { + afterEach((done) => { testUtil.clearDb(done); }); @@ -151,7 +179,7 @@ describe('Project Phases', () => { request(server) .patch(`/v5/projects/999/phases/${phaseId}`) .set({ - Authorization: `Bearer ${testUtil.jwts.manager}`, + Authorization: `Bearer ${testUtil.jwts.admin}`, }) .send(updateBody) .expect('Content-Type', /json/) @@ -162,7 +190,7 @@ describe('Project Phases', () => { request(server) .patch(`/v5/projects/${projectId}/phases/999`) .set({ - Authorization: `Bearer ${testUtil.jwts.manager}`, + Authorization: `Bearer ${testUtil.jwts.admin}`, }) .send(updateBody) .expect('Content-Type', /json/) @@ -173,7 +201,7 @@ describe('Project Phases', () => { request(server) .patch(`/v5/projects/${projectId}/phases/${phaseId}`) .set({ - Authorization: `Bearer ${testUtil.jwts.manager}`, + Authorization: `Bearer ${testUtil.jwts.admin}`, }) .send({ progress: -15, @@ -186,7 +214,7 @@ describe('Project Phases', () => { request(server) .patch(`/v5/projects/${projectId}/phases/${phaseId}`) .set({ - Authorization: `Bearer ${testUtil.jwts.manager}`, + Authorization: `Bearer ${testUtil.jwts.admin}`, }) .send({ endDate: '2018-05-13T00:00:00Z', @@ -267,24 +295,286 @@ describe('Project Phases', () => { }); }); + it('should return 200 if requested by admin', (done) => { + request(server) + .patch(`/v5/projects/${projectId}/phases/${phaseId}`) + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .send(_.assign({ order: 1 }, updateBody)) + .expect('Content-Type', /json/) + .expect(200) + .end(done); + }); + + it('should return 200 if requested by manager which is a member', (done) => { + models.ProjectMember.create({ + id: 3, + userId: testUtil.userIds.manager, + projectId, + role: 'manager', + isPrimary: false, + createdBy: 1, + updatedBy: 1, + }).then(() => { + request(server) + .patch(`/v5/projects/${projectId}/phases/${phaseId}`) + .set({ + Authorization: `Bearer ${testUtil.jwts.manager}`, + }) + .send(_.assign({ order: 1 }, updateBody)) + .expect('Content-Type', /json/) + .expect(200) + .end(done); + }); + }); + + it('should return 403 if requested by manager which is not a member', (done) => { + request(server) + .patch(`/v5/projects/${projectId}/phases/${phaseId}`) + .set({ + Authorization: `Bearer ${testUtil.jwts.manager}`, + }) + .send(_.assign({ order: 1 }, updateBody)) + .expect('Content-Type', /json/) + .expect(403) + .end(done); + }); + + it('should return 403 if requested by non-member copilot', (done) => { + models.ProjectMember.destroy({ + where: { userId: testUtil.userIds.copilot, projectId }, + }).then(() => { + request(server) + .patch(`/v5/projects/${projectId}/phases/${phaseId}`) + .set({ + Authorization: `Bearer ${testUtil.jwts.copilot}`, + }) + .send(_.assign({ order: 1 }, updateBody)) + .expect('Content-Type', /json/) + .expect(403) + .end(done); + }); + }); + describe('Bus api', () => { let createEventSpy; const sandbox = sinon.sandbox.create(); + before((done) => { // Wait for 500ms in order to wait for createEvent calls from previous tests to complete testUtil.wait(done); }); + beforeEach(() => { createEventSpy = sandbox.spy(busApi, 'createEvent'); }); + afterEach(() => { sandbox.restore(); }); - it('should send message BUS_API_EVENT.PROJECT_PHASE_UPDATED when startDate updated', (done) => { + it('should send correct BUS API messages when spentBudget updated', (done) => { + request(server) + .patch(`/v5/projects/${projectId}/phases/${phaseId}`) + .set({ + Authorization: `Bearer ${testUtil.jwts.copilot}`, + }) + .send({ + spentBudget: 123, + }) + .expect('Content-Type', /json/) + .expect(200) + .end((err) => { + if (err) { + done(err); + } else { + testUtil.wait(() => { + createEventSpy.callCount.should.be.eql(2); + + createEventSpy.calledWith(BUS_API_EVENT.PROJECT_PHASE_UPDATED, sinon.match({ + resource: RESOURCES.PHASE, + id: phaseId, + updatedBy: testUtil.userIds.copilot, + })).should.be.true; + + // Check Notification Service events + createEventSpy.calledWith(CONNECT_NOTIFICATION_EVENT.PROJECT_PHASE_UPDATE_PAYMENT).should.be.true; + + done(); + }); + } + }); + }); + + it('should send correct BUS API messages when progress updated', (done) => { + request(server) + .patch(`/v5/projects/${projectId}/phases/${phaseId}`) + .set({ + Authorization: `Bearer ${testUtil.jwts.copilot}`, + }) + .send({ + progress: 50, + }) + .expect('Content-Type', /json/) + .expect(200) + .end((err) => { + if (err) { + done(err); + } else { + testUtil.wait(() => { + createEventSpy.callCount.should.be.eql(3); + + createEventSpy.calledWith(BUS_API_EVENT.PROJECT_PHASE_UPDATED, sinon.match({ + resource: RESOURCES.PHASE, + id: phaseId, + updatedBy: testUtil.userIds.copilot, + })).should.be.true; + + // Check Notification Service events + createEventSpy.calledWith(CONNECT_NOTIFICATION_EVENT.PROJECT_PHASE_UPDATE_PROGRESS).should.be.true; + createEventSpy.calledWith(CONNECT_NOTIFICATION_EVENT.PROJECT_PROGRESS_MODIFIED).should.be.true; + done(); + }); + } + }); + }); + + it('should send correct BUS API messages when details updated', (done) => { + request(server) + .patch(`/v5/projects/${projectId}/phases/${phaseId}`) + .set({ + Authorization: `Bearer ${testUtil.jwts.copilot}`, + }) + .send({ + details: { + text: 'something', + }, + }) + .expect('Content-Type', /json/) + .expect(200) + .end((err) => { + if (err) { + done(err); + } else { + testUtil.wait(() => { + createEventSpy.callCount.should.be.eql(2); + + createEventSpy.calledWith(BUS_API_EVENT.PROJECT_PHASE_UPDATED, sinon.match({ + resource: RESOURCES.PHASE, + id: phaseId, + updatedBy: testUtil.userIds.copilot, + })).should.be.true; + + // Check Notification Service events + createEventSpy.calledWith(CONNECT_NOTIFICATION_EVENT.PROJECT_PHASE_UPDATE_SCOPE).should.be.true; + + done(); + }); + } + }); + }); + + it('should send correct BUS API messages when status updated (completed)', (done) => { + request(server) + .patch(`/v5/projects/${projectId}/phases/${phaseId}`) + .set({ + Authorization: `Bearer ${testUtil.jwts.copilot}`, + }) + .send({ + status: 'completed', + }) + .expect('Content-Type', /json/) + .expect(200) + .end((err) => { + if (err) { + done(err); + } else { + testUtil.wait(() => { + createEventSpy.callCount.should.be.eql(2); + + createEventSpy.calledWith(BUS_API_EVENT.PROJECT_PHASE_UPDATED, sinon.match({ + resource: RESOURCES.PHASE, + id: phaseId, + updatedBy: testUtil.userIds.copilot, + })).should.be.true; + + // Check Notification Service events + createEventSpy.calledWith(CONNECT_NOTIFICATION_EVENT.PROJECT_PHASE_TRANSITION_COMPLETED).should.be.true; + + done(); + }); + } + }); + }); + + it('should send correct BUS API messages when status updated (active)', (done) => { + request(server) + .patch(`/v5/projects/${projectId}/phases/${phaseId3}`) + .set({ + Authorization: `Bearer ${testUtil.jwts.copilot}`, + }) + .send({ + status: 'active', + }) + .expect('Content-Type', /json/) + .expect(200) + .end((err) => { + if (err) { + done(err); + } else { + testUtil.wait(() => { + createEventSpy.callCount.should.be.eql(2); + + createEventSpy.calledWith(BUS_API_EVENT.PROJECT_PHASE_UPDATED, sinon.match({ + resource: RESOURCES.PHASE, + id: phaseId3, + updatedBy: testUtil.userIds.copilot, + })).should.be.true; + + // Check Notification Service events + createEventSpy.calledWith(CONNECT_NOTIFICATION_EVENT.PROJECT_PHASE_TRANSITION_ACTIVE).should.be.true; + + done(); + }); + } + }); + }); + + it('should send correct BUS API messages when budget updated', (done) => { + request(server) + .patch(`/v5/projects/${projectId}/phases/${phaseId}`) + .set({ + Authorization: `Bearer ${testUtil.jwts.copilot}`, + }) + .send({ + budget: 123, + }) + .expect('Content-Type', /json/) + .expect(200) + .end((err) => { + if (err) { + done(err); + } else { + testUtil.wait(() => { + createEventSpy.callCount.should.be.eql(1); + + createEventSpy.calledWith(BUS_API_EVENT.PROJECT_PHASE_UPDATED, sinon.match({ + resource: RESOURCES.PHASE, + id: phaseId, + updatedBy: testUtil.userIds.copilot, + })).should.be.true; + + done(); + }); + } + }); + }); + + it('should send correct BUS API messages when startDate updated', (done) => { request(server) .patch(`/v5/projects/${projectId}/phases/${phaseId}`) .set({ @@ -300,20 +590,31 @@ describe('Project Phases', () => { done(err); } else { testUtil.wait(() => { - // createEventSpy.calledOnce.should.be.true; - createEventSpy.calledWith(BUS_API_EVENT.PROJECT_PHASE_UPDATED, - sinon.match({ resource: RESOURCES.PHASE })).should.be.true; - createEventSpy.calledWith(BUS_API_EVENT.PROJECT_PHASE_UPDATED, - sinon.match({ id: phaseId })).should.be.true; - createEventSpy.calledWith(BUS_API_EVENT.PROJECT_PHASE_UPDATED, - sinon.match({ updatedBy: testUtil.userIds.copilot })).should.be.true; + createEventSpy.callCount.should.be.eql(2); + + createEventSpy.calledWith(BUS_API_EVENT.PROJECT_PHASE_UPDATED, sinon.match({ + resource: RESOURCES.PHASE, + id: phaseId, + updatedBy: testUtil.userIds.copilot, + })).should.be.true; + + // Check Notification Service events + createEventSpy.calledWith(CONNECT_NOTIFICATION_EVENT.PROJECT_PLAN_UPDATED, sinon.match({ + projectId, + projectName, + projectUrl: `https://local.topcoder-dev.com/projects/${projectId}`, + userId: 40051332, + initiatorUserId: 40051332, + })).should.be.true; + done(); }); } }); }); - it('should send message BUS_API_EVENT.PROJECT_PHASE_UPDATED when duration updated', (done) => { + + it('should send correct BUS API messages when duration updated', (done) => { request(server) .patch(`/v5/projects/${projectId}/phases/${phaseId}`) .set({ @@ -329,14 +630,168 @@ describe('Project Phases', () => { done(err); } else { testUtil.wait(() => { - createEventSpy.calledOnce.should.be.true; - createEventSpy.calledWith(BUS_API_EVENT.PROJECT_PHASE_UPDATED, - sinon.match({ duration: 100 })).should.be.true; + createEventSpy.callCount.should.be.eql(2); + + createEventSpy.calledWith(BUS_API_EVENT.PROJECT_PHASE_UPDATED, sinon.match({ + duration: 100, + })).should.be.true; + + // Check Notification Service events + createEventSpy.calledWith(CONNECT_NOTIFICATION_EVENT.PROJECT_PLAN_UPDATED, sinon.match({ + projectId, + projectName, + projectUrl: `https://local.topcoder-dev.com/projects/${projectId}`, + userId: 40051332, + initiatorUserId: 40051332, + })).should.be.true; + + done(); + }); + } + }); + }); + + it('should send correct BUS API messages when order updated', (done) => { + request(server) + .patch(`/v5/projects/${projectId}/phases/${phaseId}`) + .set({ + Authorization: `Bearer ${testUtil.jwts.copilot}`, + }) + .send({ + order: 100, + }) + .expect('Content-Type', /json/) + .expect(200) + .end((err) => { + if (err) { + done(err); + } else { + testUtil.wait(() => { + createEventSpy.callCount.should.be.eql(1); + + createEventSpy.calledWith(BUS_API_EVENT.PROJECT_PHASE_UPDATED, sinon.match({ + resource: RESOURCES.PHASE, + id: phaseId, + updatedBy: testUtil.userIds.copilot, + })).should.be.true; + + // NOTE: no other event should be called, as this phase doesn't move any other phases + done(); }); } }); }); + + it('should send correct BUS API messages when endDate updated', (done) => { + request(server) + .patch(`/v5/projects/${projectId}/phases/${phaseId}`) + .set({ + Authorization: `Bearer ${testUtil.jwts.copilot}`, + }) + .send({ + endDate: new Date(), + }) + .expect('Content-Type', /json/) + .expect(200) + .end((err) => { + if (err) { + done(err); + } else { + testUtil.wait(() => { + createEventSpy.callCount.should.be.eql(1); + + createEventSpy.calledWith(BUS_API_EVENT.PROJECT_PHASE_UPDATED, sinon.match({ + resource: RESOURCES.PHASE, + id: phaseId, + updatedBy: testUtil.userIds.copilot, + })).should.be.true; + + done(); + }); + } + }); + }); + }); + + describe('RabbitMQ Message topic', () => { + let updateMessageSpy; + let publishSpy; + let sandbox; + + // Wait for 500ms in order to wait for createEvent calls from previous tests to complete + before(async () => new Promise(resolve => setTimeout(() => resolve(), 500))); + + beforeEach(async () => { + sandbox = sinon.sandbox.create(); + server.services.pubsub = new RabbitMQService(server.logger); + + // initialize RabbitMQ + server.services.pubsub.init( + config.get('rabbitmqURL'), + config.get('pubsubExchangeName'), + config.get('pubsubQueueName'), + ); + + // add project to ES index + await server.services.es.index({ + index: ES_PROJECT_INDEX, + type: ES_PROJECT_TYPE, + id: projectId, + body: { + doc: _.assign(project, { phases: [_.assign(body, { id: phaseId, projectId })] }), + }, + }); + + return new Promise(resolve => setTimeout(() => { + publishSpy = sandbox.spy(server.services.pubsub, 'publish'); + updateMessageSpy = sandbox.spy(messageService, 'updateTopic'); + sandbox.stub(messageService, 'getTopicByTag', () => Promise.resolve(topic)); + resolve(); + }, 500)); + }); + + afterEach(() => { + sandbox.restore(); + }); + + after(() => { + mockRabbitMQ(server); + }); + + it('should send message topic when phase Updated', (done) => { + const mockHttpClient = _.merge(testUtil.mockHttpClient, { + post: () => Promise.resolve({ + status: 200, + data: {}, + }), + }); + sandbox.stub(messageService, 'getClient', () => mockHttpClient); + request(server) + .patch(`/v5/projects/${projectId}/phases/${phaseId}`) + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .send(_.assign(updateBody, { budget: 123 })) + .expect('Content-Type', /json/) + .expect(200) + .end((err) => { + if (err) { + done(err); + } else { + testUtil.wait(() => { + publishSpy.calledOnce.should.be.true; + publishSpy.calledWith('project.phase.updated').should.be.true; + updateMessageSpy.calledOnce.should.be.true; + updateMessageSpy.calledWith(topic.id, sinon.match({ + title: updateBody.name, + postId: topic.posts[0].id, + content: topic.posts[0].body })).should.be.true; + done(); + }); + } + }); + }); }); }); }); diff --git a/src/routes/planConfig/version/create.spec.js b/src/routes/planConfig/version/create.spec.js index 1b564d7..d3c1956 100644 --- a/src/routes/planConfig/version/create.spec.js +++ b/src/routes/planConfig/version/create.spec.js @@ -55,7 +55,8 @@ describe('CREATE PlanConfig version', () => { request(server) .post('/v5/projects/metadata/planConfig/dev/versions') .send(body) - .expect(403, done); + .expect(403) + .end(done); }); it('should return 400 if missing config', (done) => { @@ -70,7 +71,8 @@ describe('CREATE PlanConfig version', () => { }) .send(invalidBody) .expect('Content-Type', /json/) - .expect(400, done); + .expect(400) + .end(done); }); it('should return 201 for admin', (done) => { @@ -106,7 +108,8 @@ describe('CREATE PlanConfig version', () => { Authorization: `Bearer ${testUtil.jwts.member}`, }) .send(body) - .expect(403, done); + .expect(403) + .end(done); }); }); }); diff --git a/src/routes/planConfig/version/delete.spec.js b/src/routes/planConfig/version/delete.spec.js index a47b509..79097e6 100644 --- a/src/routes/planConfig/version/delete.spec.js +++ b/src/routes/planConfig/version/delete.spec.js @@ -70,7 +70,8 @@ describe('DELETE planConfig version', () => { it('should return 403 if user is not authenticated', (done) => { request(server) .delete('/v5/projects/metadata/planConfig/dev/versions/1') - .expect(403, done); + .expect(403) + .end(done); }); it('should return 403 for member', (done) => { @@ -79,7 +80,8 @@ describe('DELETE planConfig version', () => { .set({ Authorization: `Bearer ${testUtil.jwts.member}`, }) - .expect(403, done); + .expect(403) + .end(done); }); it('should return 403 for copilot', (done) => { @@ -88,7 +90,8 @@ describe('DELETE planConfig version', () => { .set({ Authorization: `Bearer ${testUtil.jwts.copilot}`, }) - .expect(403, done); + .expect(403) + .end(done); }); it('should return 403 for manager', (done) => { @@ -97,7 +100,8 @@ describe('DELETE planConfig version', () => { .set({ Authorization: `Bearer ${testUtil.jwts.manager}`, }) - .expect(403, done); + .expect(403) + .end(done); }); it('should return 404 for non-existed key', (done) => { @@ -106,7 +110,8 @@ describe('DELETE planConfig version', () => { .set({ Authorization: `Bearer ${testUtil.jwts.admin}`, }) - .expect(404, done); + .expect(404) + .end(done); }); it('should return 404 for non-existed version', (done) => { @@ -115,7 +120,8 @@ describe('DELETE planConfig version', () => { .set({ Authorization: `Bearer ${testUtil.jwts.admin}`, }) - .expect(404, done); + .expect(404) + .end(done); }); it('should return 204, for admin', (done) => { diff --git a/src/routes/planConfig/version/get.spec.js b/src/routes/planConfig/version/get.spec.js index d608a76..a745579 100644 --- a/src/routes/planConfig/version/get.spec.js +++ b/src/routes/planConfig/version/get.spec.js @@ -92,7 +92,8 @@ describe('GET a latest version of specific key of PlanConfig', () => { it('should return 403 if user is not authenticated', (done) => { request(server) .get('/v5/projects/metadata/planConfig/dev') - .expect(403, done); + .expect(403) + .end(done); }); it('should return 200 for connect admin', (done) => { @@ -121,7 +122,8 @@ describe('GET a latest version of specific key of PlanConfig', () => { .set({ Authorization: `Bearer ${testUtil.jwts.member}`, }) - .expect(200, done); + .expect(200) + .end(done); }); it('should return 200 for copilot', (done) => { @@ -130,7 +132,8 @@ describe('GET a latest version of specific key of PlanConfig', () => { .set({ Authorization: `Bearer ${testUtil.jwts.copilot}`, }) - .expect(200, done); + .expect(200) + .end(done); }); }); }); diff --git a/src/routes/planConfig/version/getVersion.js b/src/routes/planConfig/version/getVersion.js index ae2d722..7673c3c 100644 --- a/src/routes/planConfig/version/getVersion.js +++ b/src/routes/planConfig/version/getVersion.js @@ -44,15 +44,7 @@ module.exports = [ .then((data) => { if (data.length === 0) { req.log.debug('No planConfig found in ES'); - models.PlanConfig.findOne({ - where: { - key: req.params.key, - version: req.params.version, - }, - order: [['revision', 'DESC']], - limit: 1, - attributes: { exclude: ['deletedAt', 'deletedBy'] }, - }) + return models.PlanConfig.findOneWithLatestRevision(req.params) .then((planConfig) => { // Not found if (!planConfig) { @@ -64,10 +56,10 @@ module.exports = [ return Promise.resolve(); }) .catch(next); - } else { - req.log.debug('planConfigs found in ES'); - res.json(data[0].inner_hits.planConfigs.hits.hits[0]._source); // eslint-disable-line no-underscore-dangle } + req.log.debug('planConfigs found in ES'); + res.json(data[0].inner_hits.planConfigs.hits.hits[0]._source); // eslint-disable-line no-underscore-dangle + return Promise.resolve(); }) .catch(next), ]; diff --git a/src/routes/planConfig/version/update.spec.js b/src/routes/planConfig/version/update.spec.js index b59283c..025b2b0 100644 --- a/src/routes/planConfig/version/update.spec.js +++ b/src/routes/planConfig/version/update.spec.js @@ -55,7 +55,8 @@ describe('UPDATE PlanConfig version', () => { request(server) .patch('/v5/projects/metadata/planConfig/dev/versions/1') .send(body) - .expect(403, done); + .expect(403) + .end(done); }); it('should return 400 if missing config', (done) => { @@ -69,7 +70,8 @@ describe('UPDATE PlanConfig version', () => { }) .send(invalidBody) .expect('Content-Type', /json/) - .expect(400, done); + .expect(400) + .end(done); }); it('should return 201 for admin', (done) => { @@ -105,7 +107,8 @@ describe('UPDATE PlanConfig version', () => { Authorization: `Bearer ${testUtil.jwts.member}`, }) .send(body) - .expect(403, done); + .expect(403) + .end(done); }); }); }); diff --git a/src/routes/priceConfig/version/getVersion.js b/src/routes/priceConfig/version/getVersion.js index c0593fd..c6b9120 100644 --- a/src/routes/priceConfig/version/getVersion.js +++ b/src/routes/priceConfig/version/getVersion.js @@ -44,15 +44,7 @@ module.exports = [ .then((data) => { if (data.length === 0) { req.log.debug('No priceConfig found in ES'); - models.PriceConfig.findOne({ - where: { - key: req.params.key, - version: req.params.version, - }, - order: [['revision', 'DESC']], - limit: 1, - attributes: { exclude: ['deletedAt', 'deletedBy'] }, - }) + return models.PriceConfig.findOneWithLatestRevision(req.params) .then((priceConfig) => { // Not found if (!priceConfig) { @@ -64,10 +56,10 @@ module.exports = [ return Promise.resolve(); }) .catch(next); - } else { - req.log.debug('priceConfigs found in ES'); - res.json(data[0].inner_hits.priceConfigs.hits.hits[0]._source); // eslint-disable-line no-underscore-dangle } + req.log.debug('priceConfigs found in ES'); + res.json(data[0].inner_hits.priceConfigs.hits.hits[0]._source); // eslint-disable-line no-underscore-dangle + return Promise.resolve(); }) .catch(next); }, diff --git a/src/routes/productCategories/create.js b/src/routes/productCategories/create.js index 65c9413..4abce16 100644 --- a/src/routes/productCategories/create.js +++ b/src/routes/productCategories/create.js @@ -40,10 +40,10 @@ module.exports = [ }); // Check if duplicated key - return models.ProductCategory.findByPk(req.body.key) + return models.ProductCategory.findByPk(req.body.key, { paranoid: false }) .then((existing) => { if (existing) { - const apiErr = new Error(`Product category already exists for key ${req.params.key}`); + const apiErr = new Error(`Product category already exists (may be deleted) for key ${req.body.key}`); apiErr.status = 400; return Promise.reject(apiErr); } diff --git a/src/routes/productCategories/list.spec.js b/src/routes/productCategories/list.spec.js index 8bb216e..9dcbc07 100644 --- a/src/routes/productCategories/list.spec.js +++ b/src/routes/productCategories/list.spec.js @@ -37,7 +37,9 @@ describe('LIST product categories', () => { updatedBy: 1, }, ]; - + before((done) => { + testUtil.clearES(done); + }); beforeEach((done) => { testUtil.clearDb() .then(() => models.ProductCategory.create(productCategories[0])) diff --git a/src/routes/productTemplates/list.js b/src/routes/productTemplates/list.js index 6817fb5..f1816d8 100644 --- a/src/routes/productTemplates/list.js +++ b/src/routes/productTemplates/list.js @@ -16,7 +16,7 @@ module.exports = [ if (!util.isValidFilter(filters, ['productKey'])) { util.handleError('Invalid filters', null, req, next); } - const where = { deletedAt: { $eq: null } }; + const where = { deletedAt: { $eq: null }, disabled: false }; if (filters.productKey) { where.productKey = { $eq: filters.productKey }; } diff --git a/src/routes/productTemplates/list.spec.js b/src/routes/productTemplates/list.spec.js index 50d6ff7..09af469 100644 --- a/src/routes/productTemplates/list.spec.js +++ b/src/routes/productTemplates/list.spec.js @@ -9,11 +9,11 @@ import models from '../../models'; import server from '../../app'; import testUtil from '../../tests/util'; -const should = chai.should(); +const should = chai.should(); // eslint-disable-line no-unused-vars const validateProductTemplates = (count, resJson, expectedTemplates) => { - should.exist(resJson); - resJson.length.should.be.eql(count); + resJson.should.have.length(count); + resJson.forEach((pt, idx) => { pt.should.include.all.keys('id', 'name', 'productKey', 'category', 'subCategory', 'icon', 'brief', 'details', 'aliases', 'template', 'disabled', 'form', 'hidden', 'isAddOn', 'createdBy', 'createdAt', 'updatedBy', 'updatedAt'); @@ -52,7 +52,7 @@ describe('LIST product templates', () => { }, alias2: [1, 2, 3], }, - disabled: true, + disabled: false, hidden: true, isAddOn: true, template: { diff --git a/src/routes/productTemplates/upgrade.spec.js b/src/routes/productTemplates/upgrade.spec.js index d367854..9d39c69 100644 --- a/src/routes/productTemplates/upgrade.spec.js +++ b/src/routes/productTemplates/upgrade.spec.js @@ -86,7 +86,7 @@ describe('UPGRADE product template', () => { ])) .then(() => { const config = { - questions: [{ + sections: [{ id: 'appDefinition', title: 'Sample Project', required: true, diff --git a/src/routes/projectEstimationItems/list.js b/src/routes/projectEstimationItems/list.js new file mode 100644 index 0000000..c958c75 --- /dev/null +++ b/src/routes/projectEstimationItems/list.js @@ -0,0 +1,49 @@ +/** + * API to get project estimation items + */ +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: { + projectId: Joi.number().integer().positive().required(), + estimationId: Joi.number().integer().positive().required(), + }, +}; + +module.exports = [ + validate(schema), + permissions('projectEstimation.item.list'), + (req, res, next) => models.ProjectEstimation.findOne({ + where: { + id: req.params.estimationId, + projectId: req.params.projectId, + deletedAt: { $eq: null }, + }, + raw: true, + }).then((estimation) => { + if (!estimation) { + const apiErr = new Error('Project Estimation not found for projectId ' + + `${req.params.projectId} and estimation id ${req.params.estimationId}`); + apiErr.status = 404; + return Promise.reject(apiErr); + } + return models.ProjectEstimationItem.findAll({ + where: { + projectEstimationId: req.params.estimationId, + deletedAt: { $eq: null }, + }, + attributes: { exclude: ['deletedAt', 'deletedBy'] }, + raw: true, + reqUser: req.authUser, + members: req.context.currentProjectMembers, + }).then((items) => { + res.json(items); + return Promise.resolve(); + }); + }).catch(next), +]; diff --git a/src/routes/projectEstimationItems/list.spec.js b/src/routes/projectEstimationItems/list.spec.js new file mode 100644 index 0000000..6e3c444 --- /dev/null +++ b/src/routes/projectEstimationItems/list.spec.js @@ -0,0 +1,233 @@ +/** + * Tests for list.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 project = { + id: 1, + name: 'test project 1', + type: 'generic', + status: 'active', + createdBy: 1, + updatedBy: 1, + lastActivityAt: new Date(), + lastActivityUserId: 1, +}; + +const projectMembers = [ + { + userId: testUtil.userIds.admin, + role: 'manager', + projectId: 1, + createdBy: 1, + updatedBy: 1, + }, + { + userId: testUtil.userIds.copilot, + role: 'copilot', + projectId: 1, + createdBy: 1, + updatedBy: 1, + }, + { + userId: testUtil.userIds.member, + role: 'manager', + projectId: 1, + createdBy: 1, + updatedBy: 1, + }, +]; + +const projectEstimations = [ + { + id: 1, + buildingBlockKey: 'key1', + conditions: ' empty condition ', + price: 1000, + quantity: 100, + minTime: 2, + maxTime: 2, + metadata: {}, + projectId: 1, + createdBy: 1, + updatedBy: 1, + }, +]; + +const projectEstimationItems = [ + { + projectEstimationId: 1, + price: 1234, + type: 'community', + markupUsedReference: 'buildingBlock', + markupUsedReferenceId: 1, + createdBy: 1, + updatedBy: 1, + }, + { + projectEstimationId: 1, + price: 5678, + type: 'topcoder_service', + markupUsedReference: 'buildingBlock', + markupUsedReferenceId: 1, + createdBy: 1, + updatedBy: 1, + }, + { + projectEstimationId: 1, + price: 1982, + type: 'fee', + markupUsedReference: 'buildingBlock', + markupUsedReferenceId: 1, + createdBy: 1, + updatedBy: 1, + }, +]; + +describe('GET project estimation items', () => { + beforeEach(() => testUtil.clearDb() + .then(() => models.Project.create(project)) + .then(() => models.ProjectMember.bulkCreate(projectMembers)) + .then(() => models.ProjectEstimation.bulkCreate(projectEstimations)) + .then(() => models.ProjectEstimationItem.bulkCreate(projectEstimationItems)) + .then(() => Promise.resolve()), + ); + after((done) => { + testUtil.clearDb(done); + }); + + const url = '/v5/projects/1/estimations/1/items'; + + describe(`GET ${url}`, () => { + it('should return 403 if user is not authenticated', (done) => { + request(server) + .get(url) + .expect(403) + .end(done); + }); + + it('should return 403 if user is not copilot or above', (done) => { + request(server) + .get(url) + .set({ + Authorization: `Bearer ${testUtil.jwts.member2}`, + }) + .expect(403) + .end(done); + }); + + it('should return 404 if project not exists', (done) => { + request(server) + .get('/v5/projects/999/estimations/1/items') + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .expect(404) + .end(done); + }); + + it('should return 404 if project estimation not exists', (done) => { + request(server) + .get('/v5/projects/1/estimations/999/items') + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .expect(404) + .end(done); + }); + + it('should return all project estimation items for admin', (done) => { + request(server) + .get(url) + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .expect(200) + .end((err, res) => { + if (err) { + done(err); + } else { + const resJson = res.body; + should.exist(resJson); + resJson.length.should.be.eql(3); + // convert items to map with type. + const itemMap = {}; + _.forEach(resJson, (item) => { + itemMap[item.type] = item; + }); + should.exist(itemMap.community); + itemMap.community.price.should.be.eql(1234); + itemMap.community.projectEstimationId.should.be.eql(1); + itemMap.community.type.should.be.eql('community'); + + should.exist(itemMap.topcoder_service); + itemMap.topcoder_service.price.should.be.eql(5678); + itemMap.topcoder_service.projectEstimationId.should.be.eql(1); + itemMap.topcoder_service.type.should.be.eql('topcoder_service'); + + should.exist(itemMap.fee); + itemMap.fee.price.should.be.eql(1982); + itemMap.fee.projectEstimationId.should.be.eql(1); + itemMap.fee.type.should.be.eql('fee'); + + done(); + } + }); + }); + + it('should return 1 project estimation item for copilot', (done) => { + request(server) + .get(url) + .set({ + Authorization: `Bearer ${testUtil.jwts.copilot}`, + }) + .expect(200) + .end((err, res) => { + if (err) { + done(err); + } else { + const resJson = res.body; + should.exist(resJson); + resJson.length.should.be.eql(1); + // convert items to map with type. + should.exist(resJson[0]); + + const item = resJson[0]; + + item.price.should.be.eql(1234); + item.projectEstimationId.should.be.eql(1); + item.type.should.be.eql('community'); + + done(); + } + }); + }); + + it('should return 0 project estimation items for a project manager who is not a topcoder manager', (done) => { + request(server) + .get(url) + .set({ + Authorization: `Bearer ${testUtil.jwts.member}`, + }) + .expect(200) + .end((err, res) => { + if (err) { + done(err); + } else { + const resJson = res.body; + should.exist(resJson); + resJson.length.should.be.eql(0); + // convert items to map with type. + done(); + } + }); + }); + }); +}); diff --git a/src/routes/projectMemberInvites/create.js b/src/routes/projectMemberInvites/create.js index b9e68fc..e462044 100644 --- a/src/routes/projectMemberInvites/create.js +++ b/src/routes/projectMemberInvites/create.js @@ -8,7 +8,9 @@ import { middleware as tcMiddleware } from 'tc-core-library-js'; import models from '../../models'; import util from '../../util'; import { PROJECT_MEMBER_ROLE, PROJECT_MEMBER_MANAGER_ROLES, - MANAGER_ROLES, INVITE_STATUS, EVENT, RESOURCES, USER_ROLE } from '../../constants'; + MANAGER_ROLES, INVITE_STATUS, EVENT, RESOURCES, USER_ROLE, + MAX_PARALLEL_REQUEST_QTY, CONNECT_NOTIFICATION_EVENT } from '../../constants'; +import { createEvent } from '../../services/busApi'; /** * API to create member invite to project. @@ -56,14 +58,25 @@ const compareEmail = (email1, email2, options = { UNIQUE_GMAIL_VALIDATION: false * @param {Array} invites existent invites from DB * @param {Object} data template for new invites to be put in DB * @param {Array} failed failed invites error message + * @param {Array} members already members of the project * * @returns {Promise<Promise[]>} list of promises */ -const buildCreateInvitePromises = (req, invite, invites, data, failed) => { +const buildCreateInvitePromises = (req, invite, invites, data, failed, members) => { const invitePromises = []; if (invite.userIds) { // remove invites for users that are invited already - _.remove(invite.userIds, u => _.some(invites, i => i.userId === u)); + const errMessageForAlreadyInvitedUsers = 'User with such handle is already invited to this project.'; + _.remove(invite.userIds, u => _.some(invites, (i) => { + const isPresent = i.userId === u; + if (isPresent) { + failed.push(_.assign({}, { + userId: u, + message: errMessageForAlreadyInvitedUsers, + })); + } + return isPresent; + })); invite.userIds.forEach((userId) => { const dataNew = _.clone(data); @@ -76,7 +89,7 @@ const buildCreateInvitePromises = (req, invite, invites, data, failed) => { if (invite.emails) { // if for some emails there are already existent users, we will invite them by userId, // to avoid sending them registration email - return util.lookupUserEmails(req, invite.emails) + return util.lookupMultipleUserEmails(req, invite.emails, MAX_PARALLEL_REQUEST_QTY) .then((existentUsers) => { // existent user we will invite by userId and email const existentUsersWithNumberId = existentUsers.map((user) => { @@ -91,23 +104,63 @@ const buildCreateInvitePromises = (req, invite, invites, data, failed) => { !_.find(existentUsers, existentUser => compareEmail(existentUser.email, inviteEmail, { UNIQUE_GMAIL_VALIDATION: false })), ); + + // remove users that are already member of the team + const errMessageForAlreadyMemberUsers = 'User with such email is already a member of the team.'; + + _.remove(existentUsersWithNumberId, user => _.some(members, (m) => { + const isPresent = (m.userId === user.id); + if (isPresent) { + failed.push(_.assign({}, { + email: user.email, + message: errMessageForAlreadyMemberUsers, + })); + } + return isPresent; + })); + // remove invites for users that are invited already - _.remove(existentUsersWithNumberId, user => _.some(invites, i => i.userId === user.id)); + const errMessageForAlreadyInvitedUsers = 'User with such email is already invited to this project.'; + + _.remove(existentUsersWithNumberId, user => _.some(invites, (i) => { + const isPresent = (i.userId === user.id); + if (isPresent) { + failed.push(_.assign({}, { + email: i.email, + message: errMessageForAlreadyInvitedUsers, + })); + } + return isPresent; + })); + existentUsersWithNumberId.forEach((user) => { const dataNew = _.clone(data); dataNew.userId = user.id; - dataNew.email = user.email; + dataNew.email = user.email ? user.email.toLowerCase() : user.email; + invitePromises.push(models.ProjectMemberInvite.create(dataNew)); }); // remove invites for users that are invited already _.remove(nonExistentUserEmails, email => - _.some(invites, i => - compareEmail(i.email, email, { UNIQUE_GMAIL_VALIDATION: config.get('UNIQUE_GMAIL_VALIDATION') }))); + _.some(invites, (i) => { + const areEmailsSame = compareEmail(i.email, email, { + UNIQUE_GMAIL_VALIDATION: config.get('UNIQUE_GMAIL_VALIDATION'), + }); + if (areEmailsSame) { + failed.push(_.assign({}, { + email: i.email, + message: errMessageForAlreadyInvitedUsers, + })); + } + return areEmailsSame; + }), + ); nonExistentUserEmails.forEach((email) => { const dataNew = _.clone(data); - dataNew.email = email; + dataNew.email = email.toLowerCase(); + invitePromises.push(models.ProjectMemberInvite.create(dataNew)); }); return invitePromises; @@ -120,8 +173,9 @@ const buildCreateInvitePromises = (req, invite, invites, data, failed) => { return invitePromises; }; -const sendInviteEmail = (req, projectId) => { +const sendInviteEmail = (req, projectId, invite) => { req.log.debug(req.authUser); + const emailEventType = CONNECT_NOTIFICATION_EVENT.PROJECT_MEMBER_EMAIL_INVITE_CREATED; const promises = [ models.Project.findOne({ where: { id: projectId }, @@ -131,6 +185,40 @@ const sendInviteEmail = (req, projectId) => { ]; return Promise.all(promises).then((responses) => { req.log.debug(responses); + const project = responses[0]; + const initiator = responses[1] && responses[1].length ? responses[1][0] : { + userId: req.authUser.userId, + firstName: 'Connect', + lastName: 'User', + }; + createEvent(emailEventType, { + data: { + connectURL: config.get('connectUrl'), + accountsAppURL: config.get('accountsAppUrl'), + subject: config.get('inviteEmailSubject'), + projects: [{ + name: project.name, + projectId, + sections: [ + { + EMAIL_INVITES: true, + title: config.get('inviteEmailSectionTitle'), + projectName: project.name, + projectId, + initiator, + isSSO: util.isSSO(project), + }, + ], + }], + }, + recipients: [invite.email], + version: 'v3', + from: { + name: config.get('EMAIL_INVITE_FROM_NAME'), + email: config.get('EMAIL_INVITE_FROM_EMAIL'), + }, + categories: [`${process.env.NODE_ENV}:${emailEventType}`.toLowerCase()], + }, req.log); }).catch((error) => { req.log.error(error); }); @@ -160,9 +248,19 @@ module.exports = [ const projectId = _.parseInt(req.params.projectId); const promises = []; + const errorMessageForAlreadyMemberUser = 'User with such handle is already a member of the team.'; if (invite.userIds) { // remove members already in the team - _.remove(invite.userIds, u => _.some(members, m => m.userId === u)); + _.remove(invite.userIds, u => _.some(members, (m) => { + const isPresent = m.userId === u; + if (isPresent) { + failed.push(_.assign({}, { + userId: m.userId, + message: errorMessageForAlreadyMemberUser, + })); + } + return isPresent; + })); // permission: // user has to have constants.MANAGER_ROLES role // to be invited as PROJECT_MEMBER_ROLE.MANAGER @@ -187,7 +285,7 @@ module.exports = [ } return Promise.all(promises).then((rolesList) => { if (!!invite.userIds && _.includes(PROJECT_MEMBER_MANAGER_ROLES, invite.role)) { - req.log.debug('Chekcing if userId is allowed as manager'); + req.log.debug('Checking if userId is allowed as manager'); const forbidUserList = []; _.zip(invite.userIds, rolesList).forEach((data) => { const [userId, roles] = data; @@ -218,7 +316,7 @@ module.exports = [ }; req.log.debug('Creating invites'); - return models.Sequelize.Promise.all(buildCreateInvitePromises(req, invite, invites, data, failed)) + return models.Sequelize.Promise.all(buildCreateInvitePromises(req, invite, invites, data, failed, members)) .then((values) => { values.forEach((v) => { // emit the event @@ -235,7 +333,7 @@ module.exports = [ ); // send email invite (async) if (v.email && !v.userId && v.status === INVITE_STATUS.PENDING) { - sendInviteEmail(req, projectId); + sendInviteEmail(req, projectId, v); } }); return values; diff --git a/src/routes/projectMemberInvites/create.spec.js b/src/routes/projectMemberInvites/create.spec.js index e57c04c..6aac5c0 100644 --- a/src/routes/projectMemberInvites/create.spec.js +++ b/src/routes/projectMemberInvites/create.spec.js @@ -9,7 +9,14 @@ import util from '../../util'; import server from '../../app'; import testUtil from '../../tests/util'; import busApi from '../../services/busApi'; -import { USER_ROLE, PROJECT_MEMBER_ROLE, INVITE_STATUS, BUS_API_EVENT, RESOURCES } from '../../constants'; +import { + USER_ROLE, + PROJECT_MEMBER_ROLE, + INVITE_STATUS, + BUS_API_EVENT, + RESOURCES, + CONNECT_NOTIFICATION_EVENT, +} from '../../constants'; const should = chai.should(); @@ -51,6 +58,15 @@ describe('Project Member Invite create', () => { createdBy: 1, updatedBy: 1, }); + + models.ProjectMember.create({ + userId: 40158431, + projectId: project1.id, + role: 'customer', + isPrimary: true, + createdBy: 1, + updatedBy: 1, + }); }).then(() => models.Project.create({ type: 'generic', @@ -147,9 +163,9 @@ describe('Project Member Invite create', () => { server.services.pubsub.publish.restore(); sinon.stub(server.services.pubsub, 'init', () => {}); sinon.stub(server.services.pubsub, 'publish', () => {}); - // by default mock lookupUserEmails return nothing so all the cases are not broken + // by default mock lookupMultipleUserEmails return nothing so all the cases are not broken sandbox.stub(util, 'getUserRoles', () => Promise.resolve([])); - sandbox.stub(util, 'lookupUserEmails', () => Promise.resolve([])); + sandbox.stub(util, 'lookupMultipleUserEmails', () => Promise.resolve([])); sandbox.stub(util, 'getMemberDetailsByUserIds', () => Promise.resolve([{ userId: 40051333, firstName: 'Admin', @@ -168,7 +184,7 @@ describe('Project Member Invite create', () => { Authorization: `Bearer ${testUtil.jwts.admin}`, }) .send({ - userIds: [40051332], + userIds: [40051331], emails: ['hello@world.com'], role: 'customer', }) @@ -178,7 +194,7 @@ describe('Project Member Invite create', () => { if (err) { done(err); } else { - const resJson = res.body.success[0]; + const resJson = res.body.success[1]; should.exist(resJson); resJson.role.should.equal('customer'); resJson.projectId.should.equal(project1.id); @@ -358,8 +374,8 @@ describe('Project Member Invite create', () => { }), }); sandbox.stub(util, 'getHttpClient', () => mockHttpClient); - util.lookupUserEmails.restore(); - sandbox.stub(util, 'lookupUserEmails', () => Promise.resolve([{ + util.lookupMultipleUserEmails.restore(); + sandbox.stub(util, 'lookupMultipleUserEmails', () => Promise.resolve([{ id: '12345', email: 'hello@world.com', }])); @@ -436,7 +452,88 @@ describe('Project Member Invite create', () => { }); }); - it('should return 201 and empty response when trying add already invited member', (done) => { + it('should return 403 and failed list when trying add already team member by userId', (done) => { + const mockHttpClient = _.merge(testUtil.mockHttpClient, { + get: () => Promise.resolve({ + status: 200, + data: { + success: [{ + roleName: USER_ROLE.COPILOT, + }], + }, + }), + }); + sandbox.stub(util, 'getHttpClient', () => mockHttpClient); + request(server) + .post(`/v5/projects/${project1.id}/members/invite`) + .set({ + Authorization: `Bearer ${testUtil.jwts.copilot}`, + }) + .send({ + userIds: [40158431], + role: 'customer', + }) + .expect('Content-Type', /json/) + .expect(403) + .end((err, res) => { + if (err) { + done(err); + } else { + const resJson = res.body.failed; + should.exist(resJson); + resJson[0].userId.should.equal(40158431); + resJson[0].message.should.equal('User with such handle is already a member of the team.'); + resJson.length.should.equal(1); + server.services.pubsub.publish.neverCalledWith('project.member.invite.created').should.be.true; + done(); + } + }); + }); + + it('should return 403 and failed list when trying add already team member by email', (done) => { + const mockHttpClient = _.merge(testUtil.mockHttpClient, { + get: () => Promise.resolve({ + status: 200, + data: { + success: [{ + roleName: USER_ROLE.COPILOT, + }], + }, + }), + }); + sandbox.stub(util, 'getHttpClient', () => mockHttpClient); + util.lookupMultipleUserEmails.restore(); + sandbox.stub(util, 'lookupMultipleUserEmails', () => Promise.resolve([{ + id: '40158431', + email: 'romit.choudhary@rivigo.com', + }])); + request(server) + .post(`/v5/projects/${project1.id}/members/invite`) + .set({ + Authorization: `Bearer ${testUtil.jwts.copilot}`, + }) + .send({ + emails: ['romit.choudhary@rivigo.com'], + role: 'customer', + }) + .expect('Content-Type', /json/) + .expect(403) + .end((err, res) => { + if (err) { + done(err); + } else { + const resJson = res.body.failed; + should.exist(resJson); + resJson[0].email.should.equal('romit.choudhary@rivigo.com'); + resJson[0].message.should.equal('User with such email is already a member of the team.'); + resJson.length.should.equal(1); + server.services.pubsub.publish.neverCalledWith('project.member.invite.created').should.be.true; + done(); + } + }); + }); + + it('should return 403 and failed list when trying add already invited member by userId', (done) => { const mockHttpClient = _.merge(testUtil.mockHttpClient, { get: () => Promise.resolve({ status: 200, @@ -466,14 +563,16 @@ describe('Project Member Invite create', () => { role: 'customer', }) .expect('Content-Type', /json/) - .expect(201) + .expect(403) .end((err, res) => { if (err) { done(err); } else { - const resJson = res.body.success; + const resJson = res.body.failed; should.exist(resJson); - resJson.length.should.equal(0); + resJson.length.should.equal(1); + resJson[0].userId.should.equal(40051335); + resJson[0].message.should.equal('User with such handle is already invited to this project.'); server.services.pubsub.publish.neverCalledWith('project.member.invite.created').should.be.true; done(); } @@ -657,7 +756,7 @@ describe('Project Member Invite create', () => { }); }); - it('should return 201 and empty response when trying add already invited member by lowercase email', (done) => { + it('should return 403 and failed list when trying add already invited member by lowercase email', (done) => { request(server) .post(`/v5/projects/${project1.id}/members/invite`) .set({ @@ -668,20 +767,22 @@ describe('Project Member Invite create', () => { role: 'customer', }) .expect('Content-Type', /json/) - .expect(201) + .expect(403) .end((err, res) => { if (err) { done(err); } else { - const resJson = res.body.success; + const resJson = res.body.failed; should.exist(resJson); - resJson.length.should.equal(0); + resJson[0].email.should.equal('duplicate_lowercase@test.com'); + resJson[0].message.should.equal('User with such email is already invited to this project.'); + resJson.length.should.equal(1); done(); } }); }); - it('should return 201 and empty response when trying add already invited member by uppercase email', (done) => { + it('should return 403 and failed list when trying add already invited member by uppercase email', (done) => { request(server) .post(`/v5/projects/${project1.id}/members/invite`) .set({ @@ -692,20 +793,22 @@ describe('Project Member Invite create', () => { role: 'customer', }) .expect('Content-Type', /json/) - .expect(201) + .expect(403) .end((err, res) => { if (err) { done(err); } else { - const resJson = res.body.success; + const resJson = res.body.failed; should.exist(resJson); - resJson.length.should.equal(0); + resJson[0].email.should.equal('DUPLICATE_UPPERCASE@test.com'); + resJson[0].message.should.equal('User with such email is already invited to this project.'); + resJson.length.should.equal(1); done(); } }); }); - xit('should return 201 and empty response when trying add already invited member by gmail email with dot', + xit('should return 403 and failed list when trying add already invited member by gmail email with dot', (done) => { request(server) .post(`/v5/projects/${project1.id}/members/invite`) @@ -717,20 +820,21 @@ describe('Project Member Invite create', () => { role: 'customer', }) .expect('Content-Type', /json/) - .expect(201) + .expect(403) .end((err, res) => { if (err) { done(err); } else { - const resJson = res.body.success; + const resJson = res.body.failed; should.exist(resJson); - resJson.length.should.equal(0); + resJson[0].email.should.equal('WITHdot@gmail.com'); + resJson.length.should.equal(1); done(); } }); }); - xit('should return 201 and empty response when trying add already invited member by gmail email without dot', + xit('should return 403 and failed list when trying add already invited member by gmail email without dot', (done) => { request(server) .post(`/v5/projects/${project1.id}/members/invite`) @@ -742,14 +846,15 @@ describe('Project Member Invite create', () => { role: 'customer', }) .expect('Content-Type', /json/) - .expect(201) + .expect(403) .end((err, res) => { if (err) { done(err); } else { - const resJson = res.body.success; + const resJson = res.body.failed; should.exist(resJson); - resJson.length.should.equal(0); + resJson.length.should.equal(1); + resJson[0].email.should.equal('WITHOUT.dot@gmail.com'); done(); } }); @@ -767,21 +872,13 @@ describe('Project Member Invite create', () => { createEventSpy = sandbox.spy(busApi, 'createEvent'); }); - it('sends BUS_API_EVENT.PROJECT_MEMBER_INVITE_CREATED message when userId invite added', (done) => { + it('should send correct BUS API messages when invite added by userId', (done) => { const mockHttpClient = _.merge(testUtil.mockHttpClient, { get: () => Promise.resolve({ status: 200, - data: { - id: 'requesterId', - version: 'v3', - result: { - success: true, - status: 200, - content: [{ - roleName: USER_ROLE.MANAGER, - }], - }, - }, + data: [{ + roleName: USER_ROLE.MANAGER, + }], }), }); sandbox.stub(util, 'getHttpClient', () => mockHttpClient); @@ -800,37 +897,36 @@ describe('Project Member Invite create', () => { done(err); } else { testUtil.wait(() => { - createEventSpy.calledOnce.should.be.true; - createEventSpy.calledWith(BUS_API_EVENT.PROJECT_MEMBER_INVITE_CREATED).should.be.true; - createEventSpy.calledWith(BUS_API_EVENT.PROJECT_MEMBER_INVITE_CREATED, - sinon.match({ resource: RESOURCES.PROJECT_MEMBER_INVITE })).should.be.true; - createEventSpy.calledWith(BUS_API_EVENT.PROJECT_MEMBER_INVITE_CREATED, - sinon.match({ projectId: project1.id })).should.be.true; - createEventSpy.calledWith(BUS_API_EVENT.PROJECT_MEMBER_INVITE_CREATED, - sinon.match({ userId: 3 })).should.be.true; - createEventSpy.calledWith(BUS_API_EVENT.PROJECT_MEMBER_INVITE_CREATED, - sinon.match({ email: null })).should.be.true; + createEventSpy.callCount.should.be.eql(2); + + createEventSpy.calledWith(BUS_API_EVENT.PROJECT_MEMBER_INVITE_CREATED, sinon.match({ + resource: RESOURCES.PROJECT_MEMBER_INVITE, + projectId: project1.id, + userId: 3, + email: null, + })).should.be.true; + + // Check Notification Service events + createEventSpy.calledWith(CONNECT_NOTIFICATION_EVENT.PROJECT_MEMBER_INVITE_CREATED, sinon.match({ + projectId: project1.id, + userId: 3, + email: null, + isSSO: false, + })).should.be.true; + done(); }); } }); }); - it('sends BUS_API_EVENT.PROJECT_MEMBER_INVITE_CREATED message when email invite added', (done) => { + it('should send correct BUS API messages when invite added by email', (done) => { const mockHttpClient = _.merge(testUtil.mockHttpClient, { get: () => Promise.resolve({ status: 200, - data: { - id: 'requesterId', - version: 'v3', - result: { - success: true, - status: 200, - content: [{ - roleName: USER_ROLE.MANAGER, - }], - }, - }, + data: [{ + roleName: USER_ROLE.MANAGER, + }], }), }); sandbox.stub(util, 'getHttpClient', () => mockHttpClient); @@ -849,16 +945,26 @@ describe('Project Member Invite create', () => { done(err); } else { testUtil.wait(() => { - createEventSpy.calledOnce.should.be.true; - createEventSpy.calledWith(BUS_API_EVENT.PROJECT_MEMBER_INVITE_CREATED).should.be.true; - createEventSpy.calledWith(BUS_API_EVENT.PROJECT_MEMBER_INVITE_CREATED, - sinon.match({ resource: RESOURCES.PROJECT_MEMBER_INVITE })).should.be.true; - createEventSpy.calledWith(BUS_API_EVENT.PROJECT_MEMBER_INVITE_CREATED, - sinon.match({ projectId: project1.id })).should.be.true; - createEventSpy.calledWith(BUS_API_EVENT.PROJECT_MEMBER_INVITE_CREATED, - sinon.match({ userId: null })).should.be.true; - createEventSpy.calledWith(BUS_API_EVENT.PROJECT_MEMBER_INVITE_CREATED, - sinon.match({ email: 'hello@world.com' })).should.be.true; + createEventSpy.callCount.should.be.eql(3); + + createEventSpy.calledWith(BUS_API_EVENT.PROJECT_MEMBER_INVITE_CREATED, sinon.match({ + resource: RESOURCES.PROJECT_MEMBER_INVITE, + projectId: project1.id, + userId: null, + email: 'hello@world.com', + })).should.be.true; + + // Check Notification Service events + createEventSpy.calledWith(CONNECT_NOTIFICATION_EVENT.PROJECT_MEMBER_INVITE_CREATED, sinon.match({ + projectId: project1.id, + userId: null, + email: 'hello@world.com', + isSSO: false, + })).should.be.true; + createEventSpy.calledWith(CONNECT_NOTIFICATION_EVENT.PROJECT_MEMBER_EMAIL_INVITE_CREATED, sinon.match({ + recipients: ['hello@world.com'], + })).should.be.true; + done(); }); } diff --git a/src/routes/projectMemberInvites/update.spec.js b/src/routes/projectMemberInvites/update.spec.js index 4080ab4..49e53e3 100644 --- a/src/routes/projectMemberInvites/update.spec.js +++ b/src/routes/projectMemberInvites/update.spec.js @@ -8,7 +8,14 @@ import server from '../../app'; import util from '../../util'; import testUtil from '../../tests/util'; import busApi from '../../services/busApi'; -import { BUS_API_EVENT, RESOURCES, USER_ROLE, PROJECT_MEMBER_ROLE, INVITE_STATUS } from '../../constants'; +import { + BUS_API_EVENT, + RESOURCES, + USER_ROLE, + PROJECT_MEMBER_ROLE, + INVITE_STATUS, + CONNECT_NOTIFICATION_EVENT, +} from '../../constants'; const should = chai.should(); @@ -301,21 +308,11 @@ describe('Project member invite update', () => { createEventSpy = sandbox.spy(busApi, 'createEvent'); }); - it('Accept invite sends BUS_API_EVENT.PROJECT_MEMBER_INVITE_UPDATED ' + - 'and BUS_API_EVENT.PROJECT_MEMBER_ADDED messages', (done) => { + it('should send correct BUS API messages when invite is accepted', (done) => { const mockHttpClient = _.merge(testUtil.mockHttpClient, { get: () => Promise.resolve({ status: 200, - data: { - id: 'requesterId', - version: 'v3', - result: { - success: true, - status: 200, - content: [{ - }], - }, - }, + data: {}, }), }); sandbox.stub(util, 'getHttpClient', () => mockHttpClient); @@ -335,18 +332,51 @@ describe('Project member invite update', () => { done(err); } else { testUtil.wait(() => { - createEventSpy.calledOnce.should.be.true; - createEventSpy.calledWith(BUS_API_EVENT.PROJECT_MEMBER_INVITE_UPDATED).should.be.true; - createEventSpy.calledWith(BUS_API_EVENT.PROJECT_MEMBER_INVITE_UPDATED, - sinon.match({ resource: RESOURCES.PROJECT_MEMBER_INVITE })).should.be.true; - createEventSpy.calledWith(BUS_API_EVENT.PROJECT_MEMBER_INVITE_UPDATED, - sinon.match({ projectId: project1.id })).should.be.true; - createEventSpy.calledWith(BUS_API_EVENT.PROJECT_MEMBER_INVITE_UPDATED, - sinon.match({ userId: invite1.userId })).should.be.true; - createEventSpy.calledWith(BUS_API_EVENT.PROJECT_MEMBER_INVITE_UPDATED, - sinon.match({ status: INVITE_STATUS.ACCEPTED })).should.be.true; - createEventSpy.calledWith(BUS_API_EVENT.PROJECT_MEMBER_INVITE_UPDATED, - sinon.match({ email: null })).should.be.true; + createEventSpy.callCount.should.be.eql(5); + + /* + Events for accepted invite + */ + createEventSpy.calledWith(BUS_API_EVENT.PROJECT_MEMBER_INVITE_UPDATED, sinon.match({ + resource: RESOURCES.PROJECT_MEMBER_INVITE, + projectId: project1.id, + userId: invite1.userId, + status: INVITE_STATUS.ACCEPTED, + email: null, + })).should.be.true; + + // Check Notification Service events + createEventSpy.calledWith(CONNECT_NOTIFICATION_EVENT.PROJECT_MEMBER_INVITE_UPDATED, sinon.match({ + projectId: project1.id, + userId: invite1.userId, + status: INVITE_STATUS.ACCEPTED, + email: null, + isSSO: false, + })).should.be.true; + + /* + Events for created member (after invite acceptance) + */ + createEventSpy.calledWith(BUS_API_EVENT.PROJECT_MEMBER_ADDED, sinon.match({ + resource: RESOURCES.PROJECT_MEMBER, + projectId: project1.id, + userId: invite1.userId, + })).should.be.true; + + // Check Notification Service events + createEventSpy.calledWith(CONNECT_NOTIFICATION_EVENT.MEMBER_JOINED, sinon.match({ + projectId: project1.id, + projectName: project1.name, + userId: invite1.userId, + initiatorUserId: 40051331, + })).should.be.true; + createEventSpy.calledWith(CONNECT_NOTIFICATION_EVENT.PROJECT_TEAM_UPDATED, sinon.match({ + projectId: project1.id, + projectName: project1.name, + userId: invite1.userId, + initiatorUserId: 40051331, + })).should.be.true; + done(); }); } diff --git a/src/routes/projectMembers/create.js b/src/routes/projectMembers/create.js index 31ba995..cb72ebc 100644 --- a/src/routes/projectMembers/create.js +++ b/src/routes/projectMembers/create.js @@ -3,7 +3,7 @@ import Joi from 'joi'; import validate from 'express-validation'; import { middleware as tcMiddleware } from 'tc-core-library-js'; import util from '../../util'; -import { INVITE_STATUS, MANAGER_ROLES, PROJECT_MEMBER_ROLE, USER_ROLE, EVENT, RESOURCES } from '../../constants'; +import { INVITE_STATUS, MANAGER_ROLES, PROJECT_MEMBER_ROLE, USER_ROLE } from '../../constants'; import models from '../../models'; /** @@ -16,7 +16,15 @@ const permissions = tcMiddleware.permissions; const createProjectMemberValidations = { body: Joi.object().keys({ role: Joi.any() - .valid(PROJECT_MEMBER_ROLE.MANAGER, PROJECT_MEMBER_ROLE.ACCOUNT_MANAGER, PROJECT_MEMBER_ROLE.COPILOT), + .valid( + PROJECT_MEMBER_ROLE.MANAGER, + PROJECT_MEMBER_ROLE.ACCOUNT_MANAGER, + PROJECT_MEMBER_ROLE.COPILOT, + PROJECT_MEMBER_ROLE.PROJECT_MANAGER, + PROJECT_MEMBER_ROLE.PROGRAM_MANAGER, + PROJECT_MEMBER_ROLE.SOLUTION_ARCHITECT, + PROJECT_MEMBER_ROLE.ACCOUNT_EXECUTIVE, + ), }), }; @@ -36,9 +44,49 @@ module.exports = [ return next(err); } + if (PROJECT_MEMBER_ROLE.SOLUTION_ARCHITECT === targetRole && + !util.hasRoles(req, [USER_ROLE.SOLUTION_ARCHITECT])) { + const err = new Error(`Only solution architect is able to join as ${targetRole}`); + err.status = 401; + return next(err); + } + + if (PROJECT_MEMBER_ROLE.PROJECT_MANAGER === targetRole && + !util.hasRoles(req, [USER_ROLE.PROJECT_MANAGER])) { + const err = new Error(`Only project manager is able to join as ${targetRole}`); + err.status = 401; + return next(err); + } + + if (PROJECT_MEMBER_ROLE.PROGRAM_MANAGER === targetRole && + !util.hasRoles(req, [USER_ROLE.PROGRAM_MANAGER])) { + const err = new Error(`Only program manager is able to join as ${targetRole}`); + err.status = 401; + return next(err); + } + + if (PROJECT_MEMBER_ROLE.ACCOUNT_EXECUTIVE === targetRole && + !util.hasRoles(req, [USER_ROLE.ACCOUNT_EXECUTIVE])) { + const err = new Error(`Only account executive is able to join as ${targetRole}`); + err.status = 401; + return next(err); + } + if (PROJECT_MEMBER_ROLE.ACCOUNT_MANAGER === targetRole && - !util.hasRoles(req, [USER_ROLE.MANAGER, USER_ROLE.TOPCODER_ACCOUNT_MANAGER])) { - const err = new Error(`Only manager or account manager is able to join as ${targetRole}`); + !util.hasRoles(req, [ + USER_ROLE.MANAGER, + USER_ROLE.TOPCODER_ACCOUNT_MANAGER, + USER_ROLE.BUSINESS_DEVELOPMENT_REPRESENTATIVE, + USER_ROLE.PRESALES, + USER_ROLE.ACCOUNT_EXECUTIVE, + USER_ROLE.PROGRAM_MANAGER, + USER_ROLE.SOLUTION_ARCHITECT, + USER_ROLE.PROJECT_MANAGER, + ])) { + const err = new Error( + // eslint-disable-next-line max-len + `Only manager, account manager, business development representative, account executive, program manager, project manager, solution architect, or presales are able to join as ${targetRole}`, + ); err.status = 401; return next(err); } @@ -50,10 +98,22 @@ module.exports = [ } } else if (util.hasRoles(req, [USER_ROLE.MANAGER, USER_ROLE.CONNECT_ADMIN])) { targetRole = PROJECT_MEMBER_ROLE.MANAGER; - } else if (util.hasRoles(req, [USER_ROLE.TOPCODER_ACCOUNT_MANAGER])) { + } else if (util.hasRoles(req, [ + USER_ROLE.TOPCODER_ACCOUNT_MANAGER, + USER_ROLE.BUSINESS_DEVELOPMENT_REPRESENTATIVE, + USER_ROLE.PRESALES, + ])) { targetRole = PROJECT_MEMBER_ROLE.ACCOUNT_MANAGER; } else if (util.hasRoles(req, [USER_ROLE.COPILOT, USER_ROLE.CONNECT_ADMIN])) { targetRole = PROJECT_MEMBER_ROLE.COPILOT; + } else if (util.hasRoles(req, [USER_ROLE.ACCOUNT_EXECUTIVE])) { + targetRole = PROJECT_MEMBER_ROLE.ACCOUNT_EXECUTIVE; + } else if (util.hasRoles(req, [USER_ROLE.PROGRAM_MANAGER])) { + targetRole = PROJECT_MEMBER_ROLE.PROGRAM_MANAGER; + } else if (util.hasRoles(req, [USER_ROLE.SOLUTION_ARCHITECT])) { + targetRole = PROJECT_MEMBER_ROLE.SOLUTION_ARCHITECT; + } else if (util.hasRoles(req, [USER_ROLE.PROJECT_MANAGER])) { + targetRole = PROJECT_MEMBER_ROLE.PROJECT_MANAGER; } else { const err = new Error('Only copilot or manager is able to call this endpoint'); err.status = 401; @@ -85,37 +145,19 @@ module.exports = [ return next(err); } - return util.addUserToProject(req, member) - .then((newMember) => { - let invite; - return models.ProjectMemberInvite.getPendingInviteByEmailOrUserId(projectId, null, newMember.userId) - .then((_invite) => { - invite = _invite; + return util.addUserToProject(req, member) // Kafka event is emitted inside `addUserToProject` + .then(newMember => + models.ProjectMemberInvite.getPendingInviteByEmailOrUserId(projectId, null, newMember.userId) + .then((invite) => { if (!invite) { - // emit the event - util.sendResourceToKafkaBus( - req, - EVENT.ROUTING_KEY.PROJECT_MEMBER_ADDED, - RESOURCES.PROJECT_MEMBER, - newMember); - - return res.status(201) - .json(newMember); + return res.status(201).json(newMember); } return invite.update({ status: INVITE_STATUS.ACCEPTED, }) - .then(() => { - // emit the event - util.sendResourceToKafkaBus( - req, - EVENT.ROUTING_KEY.PROJECT_MEMBER_ADDED, - RESOURCES.PROJECT_MEMBER, - newMember); - return res.status(201).json(newMember); - }); - }); - }); + .then(() => res.status(201).json(newMember)); + }), + ); }) .catch(err => next(err)); }, diff --git a/src/routes/projectMembers/create.spec.js b/src/routes/projectMembers/create.spec.js index 21e05f6..7e19399 100644 --- a/src/routes/projectMembers/create.spec.js +++ b/src/routes/projectMembers/create.spec.js @@ -8,7 +8,8 @@ import models from '../../models'; import util from '../../util'; import server from '../../app'; import testUtil from '../../tests/util'; -import { USER_ROLE } from '../../constants'; +import busApi from '../../services/busApi'; +import { USER_ROLE, BUS_API_EVENT, RESOURCES, CONNECT_NOTIFICATION_EVENT, INVITE_STATUS } from '../../constants'; const should = chai.should(); @@ -199,5 +200,164 @@ describe('Project Members create', () => { } }); }); + + describe('Bus api', () => { + let createEventSpy; + + before((done) => { + // Wait for 500ms in order to wait for createEvent calls from previous tests to complete + testUtil.wait(done); + }); + + beforeEach(() => { + createEventSpy = sandbox.spy(busApi, 'createEvent'); + }); + + it('should send correct BUS API messages when a manager added', (done) => { + const mockHttpClient = _.merge(testUtil.mockHttpClient, { + get: () => Promise.resolve({ + status: 200, + data: { + id: 'requesterId', + version: 'v3', + result: { + success: true, + status: 200, + content: [{ + roleName: USER_ROLE.MANAGER, + }], + }, + }, + }), + post: () => Promise.resolve({ + status: 200, + data: { + id: 'requesterId', + version: 'v3', + result: { + success: true, + status: 200, + content: {}, + }, + }, + }), + }); + sandbox.stub(util, 'getHttpClient', () => mockHttpClient); + request(server) + .post(`/v5/projects/${project1.id}/members/`) + .set({ + Authorization: `Bearer ${testUtil.jwts.manager}`, + }) + .expect(201) + .end((err) => { + if (err) { + done(err); + } else { + testUtil.wait(() => { + createEventSpy.callCount.should.be.eql(3); + + createEventSpy.calledWith(BUS_API_EVENT.PROJECT_MEMBER_ADDED, sinon.match({ + resource: RESOURCES.PROJECT_MEMBER, + projectId: project1.id, + userId: 40051334, + })).should.be.true; + + // Check Notification Service events + createEventSpy.calledWith(CONNECT_NOTIFICATION_EVENT.MEMBER_JOINED_MANAGER).should.be.true; + createEventSpy.calledWith(CONNECT_NOTIFICATION_EVENT.PROJECT_TEAM_UPDATED, sinon.match({ + projectId: project1.id, + projectName: project1.name, + projectUrl: `https://local.topcoder-dev.com/projects/${project1.id}`, + userId: 40051334, + initiatorUserId: 40051334, + })).should.be.true; + + done(); + }); + } + }); + }); + + it('should send correct BUS API messages when copilot added', (done) => { + request(server) + .post(`/v5/projects/${project1.id}/members/invite`) + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .send({ + userIds: [40051332], + role: 'copilot', + }) + .expect(201) + .end((err) => { + if (err) { + done(err); + } else { + request(server) + .put(`/v5/projects/${project1.id}/members/invite`) + .set({ + Authorization: `Bearer ${testUtil.jwts.connectAdmin}`, + }) + .send({ + userId: 40051332, + status: 'accepted', + }) + .expect('Content-Type', /json/) + .expect(200) + .end((err2) => { + if (err2) { + done(err2); + } else { + testUtil.wait(() => { + createEventSpy.callCount.should.equal(7); + + /* + Copilot invitation requested + */ + createEventSpy.calledWith(BUS_API_EVENT.PROJECT_MEMBER_INVITE_CREATED, sinon.match({ + resource: RESOURCES.PROJECT_MEMBER_INVITE, + projectId: project1.id, + userId: 40051332, + email: null, + })).should.be.true; + + // Check Notification Service events + createEventSpy.calledWith(CONNECT_NOTIFICATION_EVENT.PROJECT_MEMBER_INVITE_REQUESTED).should.be.true; + + /* + Copilot invitation accepted + */ + createEventSpy.calledWith(BUS_API_EVENT.PROJECT_MEMBER_INVITE_UPDATED, sinon.match({ + resource: RESOURCES.PROJECT_MEMBER_INVITE, + projectId: project1.id, + userId: 40051332, + status: INVITE_STATUS.ACCEPTED, + email: null, + })).should.be.true; + + createEventSpy.calledWith(BUS_API_EVENT.PROJECT_MEMBER_ADDED, sinon.match({ + resource: RESOURCES.PROJECT_MEMBER, + projectId: project1.id, + userId: 40051332, + })).should.be.true; + + // Check Notification Service events + createEventSpy.calledWith(CONNECT_NOTIFICATION_EVENT.PROJECT_MEMBER_INVITE_UPDATED).should.be.true; + createEventSpy.calledWith(CONNECT_NOTIFICATION_EVENT.MEMBER_JOINED_COPILOT).should.be.true; + createEventSpy.calledWith(CONNECT_NOTIFICATION_EVENT.PROJECT_TEAM_UPDATED, sinon.match({ + projectId: project1.id, + projectName: project1.name, + projectUrl: `https://local.topcoder-dev.com/projects/${project1.id}`, + userId: 40051336, + initiatorUserId: 40051336, + })).should.be.true; + done(); + }); + } + }); + } + }); + }); + }); }); }); diff --git a/src/routes/projectMembers/delete.js b/src/routes/projectMembers/delete.js index 1a14548..d13a8f7 100644 --- a/src/routes/projectMembers/delete.js +++ b/src/routes/projectMembers/delete.js @@ -83,7 +83,7 @@ module.exports = [ req, EVENT.ROUTING_KEY.PROJECT_MEMBER_REMOVED, RESOURCES.PROJECT_MEMBER, - { id: pmember.id }); + pmember); res.status(204).json({}); }).catch(err => next(err)); }, diff --git a/src/routes/projectMembers/delete.spec.js b/src/routes/projectMembers/delete.spec.js index 5eddc35..dd1556a 100644 --- a/src/routes/projectMembers/delete.spec.js +++ b/src/routes/projectMembers/delete.spec.js @@ -9,7 +9,7 @@ import util from '../../util'; import server from '../../app'; import testUtil from '../../tests/util'; import busApi from '../../services/busApi'; -import { BUS_API_EVENT, RESOURCES } from '../../constants'; +import { BUS_API_EVENT, RESOURCES, CONNECT_NOTIFICATION_EVENT } from '../../constants'; const should = chai.should(); @@ -328,7 +328,7 @@ describe('Project members delete', () => { createEventSpy = sandbox.spy(busApi, 'createEvent'); }); - it('sends BUS_API_EVENT.PROJECT_MEMBER_REMOVED message when manager removed', (done) => { + it('should send correct BUS API messages when manager left', (done) => { const mockHttpClient = _.merge(testUtil.mockHttpClient, { post: () => Promise.resolve({ status: 200, @@ -355,19 +355,30 @@ describe('Project members delete', () => { done(err); } else { testUtil.wait(() => { - createEventSpy.calledOnce.should.be.true; - createEventSpy.calledWith(BUS_API_EVENT.PROJECT_MEMBER_REMOVED).should.be.true; - createEventSpy.calledWith(BUS_API_EVENT.PROJECT_MEMBER_REMOVED, - sinon.match({ resource: RESOURCES.PROJECT_MEMBER })).should.be.true; - createEventSpy.calledWith(BUS_API_EVENT.PROJECT_MEMBER_REMOVED, - sinon.match({ id: member2.id })).should.be.true; + createEventSpy.callCount.should.equal(3); + + createEventSpy.calledWith(BUS_API_EVENT.PROJECT_MEMBER_REMOVED, sinon.match({ + resource: RESOURCES.PROJECT_MEMBER, + id: member2.id, + })).should.be.true; + + // Check Notification Service events + createEventSpy.calledWith(CONNECT_NOTIFICATION_EVENT.MEMBER_LEFT).should.be.true; + createEventSpy.calledWith(CONNECT_NOTIFICATION_EVENT.PROJECT_TEAM_UPDATED, sinon.match({ + projectId: project1.id, + projectName: project1.name, + projectUrl: `https://local.topcoder-dev.com/projects/${project1.id}`, + userId: 40051334, + initiatorUserId: 40051334, + })).should.be.true; + done(); }); } }); }); - it('sends BUS_API_EVENT.PROJECT_MEMBER_REMOVED message when copilot removed', (done) => { + it('should send correct BUS API messages when copilot removed', (done) => { request(server) .delete(`/v5/projects/${project1.id}/members/${member1.id}`) .set({ @@ -379,12 +390,23 @@ describe('Project members delete', () => { done(err); } else { testUtil.wait(() => { - createEventSpy.calledOnce.should.be.true; - createEventSpy.calledWith(BUS_API_EVENT.PROJECT_MEMBER_REMOVED).should.be.true; - createEventSpy.calledWith(BUS_API_EVENT.PROJECT_MEMBER_REMOVED, - sinon.match({ resource: RESOURCES.PROJECT_MEMBER })).should.be.true; - createEventSpy.calledWith(BUS_API_EVENT.PROJECT_MEMBER_REMOVED, - sinon.match({ id: member1.id })).should.be.true; + createEventSpy.callCount.should.equal(3); + + createEventSpy.calledWith(BUS_API_EVENT.PROJECT_MEMBER_REMOVED, sinon.match({ + resource: RESOURCES.PROJECT_MEMBER, + id: member1.id, + })).should.be.true; + + // Check Notification Service events + createEventSpy.calledWith(CONNECT_NOTIFICATION_EVENT.MEMBER_REMOVED).should.be.true; + createEventSpy.calledWith(CONNECT_NOTIFICATION_EVENT.PROJECT_TEAM_UPDATED, sinon.match({ + projectId: project1.id, + projectName: project1.name, + projectUrl: `https://local.topcoder-dev.com/projects/${project1.id}`, + userId: 40051334, + initiatorUserId: 40051334, + })).should.be.true; + done(); }); } diff --git a/src/routes/projectMembers/get.spec.js b/src/routes/projectMembers/get.spec.js index fd43ed2..bde598a 100644 --- a/src/routes/projectMembers/get.spec.js +++ b/src/routes/projectMembers/get.spec.js @@ -33,6 +33,7 @@ describe('GET project member', () => { beforeEach((done) => { testUtil.clearDb() + .then(() => testUtil.clearES()) .then(() => { // Create projects models.Project.create({ diff --git a/src/routes/projectMembers/update.js b/src/routes/projectMembers/update.js index 68aa0cc..48d976b 100644 --- a/src/routes/projectMembers/update.js +++ b/src/routes/projectMembers/update.js @@ -15,8 +15,17 @@ const permissions = tcMiddleware.permissions; const updateProjectMemberValdiations = { body: Joi.object().keys({ isPrimary: Joi.boolean(), - role: Joi.any().valid(PROJECT_MEMBER_ROLE.CUSTOMER, PROJECT_MEMBER_ROLE.MANAGER, - PROJECT_MEMBER_ROLE.ACCOUNT_MANAGER, PROJECT_MEMBER_ROLE.COPILOT, PROJECT_MEMBER_ROLE.OBSERVER).required(), + role: Joi.any().valid( + PROJECT_MEMBER_ROLE.CUSTOMER, + PROJECT_MEMBER_ROLE.MANAGER, + PROJECT_MEMBER_ROLE.ACCOUNT_MANAGER, + PROJECT_MEMBER_ROLE.COPILOT, + PROJECT_MEMBER_ROLE.OBSERVER, + PROJECT_MEMBER_ROLE.PROGRAM_MANAGER, + PROJECT_MEMBER_ROLE.ACCOUNT_EXECUTIVE, + PROJECT_MEMBER_ROLE.SOLUTION_ARCHITECT, + PROJECT_MEMBER_ROLE.PROJECT_MANAGER, + ).required(), }), }; @@ -104,7 +113,8 @@ module.exports = [ req, EVENT.ROUTING_KEY.PROJECT_MEMBER_UPDATED, RESOURCES.PROJECT_MEMBER, - projectMember); + projectMember, + previousValue); req.log.debug('updated project member', projectMember); res.json(projectMember); }) diff --git a/src/routes/projectMembers/update.spec.js b/src/routes/projectMembers/update.spec.js index 38594b6..28c3d66 100644 --- a/src/routes/projectMembers/update.spec.js +++ b/src/routes/projectMembers/update.spec.js @@ -8,7 +8,7 @@ import server from '../../app'; import util from '../../util'; import testUtil from '../../tests/util'; import busApi from '../../services/busApi'; -import { BUS_API_EVENT, RESOURCES } from '../../constants'; +import { BUS_API_EVENT, RESOURCES, CONNECT_NOTIFICATION_EVENT } from '../../constants'; const should = chai.should(); @@ -477,7 +477,7 @@ describe('Project members update', () => { createEventSpy = sandbox.spy(busApi, 'createEvent'); }); - it('sends single BUS_API_EVENT.PROJECT_MEMBER_UPDATED message when user role updated', (done) => { + it('should send correct BUS API messages when user role updated', (done) => { const mockHttpClient = _.merge(testUtil.mockHttpClient, { get: () => Promise.resolve({ status: 200, @@ -508,16 +508,24 @@ describe('Project members update', () => { done(err); } else { testUtil.wait(() => { - createEventSpy.calledOnce.should.be.true; - createEventSpy.calledWith(BUS_API_EVENT.PROJECT_MEMBER_UPDATED).should.be.true; - createEventSpy.calledWith(BUS_API_EVENT.PROJECT_MEMBER_UPDATED, - sinon.match({ resource: RESOURCES.PROJECT_MEMBER })).should.be.true; - createEventSpy.calledWith(BUS_API_EVENT.PROJECT_MEMBER_UPDATED, - sinon.match({ id: member2.id })).should.be.true; - createEventSpy.calledWith(BUS_API_EVENT.PROJECT_MEMBER_UPDATED, - sinon.match({ role: 'customer' })).should.be.true; - createEventSpy.calledWith(BUS_API_EVENT.PROJECT_MEMBER_UPDATED, - sinon.match({ userId: 40051332 })).should.be.true; + createEventSpy.callCount.should.equal(2); + + createEventSpy.calledWith(BUS_API_EVENT.PROJECT_MEMBER_UPDATED, sinon.match({ + resource: RESOURCES.PROJECT_MEMBER, + id: member2.id, + role: 'customer', + userId: 40051332, + })).should.be.true; + + // Check Notification Service events + createEventSpy.calledWith(CONNECT_NOTIFICATION_EVENT.PROJECT_TEAM_UPDATED, sinon.match({ + projectId: project1.id, + projectName: project1.name, + projectUrl: `https://local.topcoder-dev.com/projects/${project1.id}`, + userId: 40051332, + initiatorUserId: 40051332, + })).should.be.true; + done(); }); } diff --git a/src/routes/projectReports/LookAuth.js b/src/routes/projectReports/LookAuth.js new file mode 100644 index 0000000..745d318 --- /dev/null +++ b/src/routes/projectReports/LookAuth.js @@ -0,0 +1,73 @@ +/* eslint-disable func-names */ +/* eslint-disable require-jsdoc */ +/* eslint-disable valid-jsdoc */ + +// Look Auth + + +import config from 'config'; + +const axios = require('axios'); + +const NEXT_5_MINS = 5 * 60 * 1000; + + +function LookAuth(logger) { + // load credentials from config + this.BASE_URL = config.lookerConfig.BASE_URL; + this.CLIENT_ID = config.lookerConfig.CLIENT_ID; + this.CLIENT_SECRET = config.lookerConfig.CLIENT_SECRET; + const token = config.lookerConfig.TOKEN; + + this.logger = logger; + + // Token is stringified and saved as string. It has 4 properties, access_token, expires_in and type, timestamp + if (token) { + this.lastToken = JSON.stringify(token); + } +} + +LookAuth.prototype.getToken = async function () { + const res = await new Promise((resolve) => { + if (!this.isExpired()) { + resolve(this.lastToken.access_token); + } else { + resolve(''); + } + }); + if (res === '') { + return this.login(); + } + return res; +}; + +/** *********************Login to Looker ************** */ +LookAuth.prototype.login = async function () { + const loginUrl = `${this.BASE_URL}/login?client_id=${this.CLIENT_ID}&client_secret=${this.CLIENT_SECRET}`; + const res = await axios.post(loginUrl, {}, { headers: { 'Content-Type': 'application/json' } }); + this.lastToken = res.data; + this.lastToken.timestamp = new Date().getTime(); + return this.lastToken.access_token; +}; + + +/** ***************Check if the Token has expired ********** */ +LookAuth.prototype.isExpired = function () { + // If no token is present, assume the token has expired + if (!this.lastToken) { + return true; + } + + const tokenTimestamp = this.lastToken.timestamp; + const expiresIn = this.lastToken.expires_in; + const currentTimestamp = new Date().getTime(); + + // If the token will good for next 5 minutes + if ((tokenTimestamp + expiresIn + NEXT_5_MINS) > currentTimestamp) { + return false; + } + // Token is good, and can be used to make the next call. + return true; +}; + +module.exports = LookAuth; diff --git a/src/routes/projectReports/LookRun.js b/src/routes/projectReports/LookRun.js new file mode 100644 index 0000000..f1606d8 --- /dev/null +++ b/src/routes/projectReports/LookRun.js @@ -0,0 +1,106 @@ +/* eslint-disable valid-jsdoc */ +/* eslint-disable require-jsdoc */ +/* eslint-disable func-names */ + +import config from 'config'; +import LookAuth from './LookAuth'; + +const axios = require('axios'); + +function LookApi(logger) { + this.BASE_URL = config.lookerConfig.BASE_URL; + this.formatting = 'json'; + this.limit = 5000; + this.logger = logger; + this.lookAuth = new LookAuth(logger); +} + +LookApi.prototype.runLook = function (lookId) { + const endpoint = `${this.BASE_URL}/looks/${lookId}/run/${this.formatting}?limit=${this.limit}`; + return this.callApi(endpoint); +}; + +LookApi.prototype.findUserByEmail = function (email) { + const filter = { 'user.email': email }; + return this.runQueryWithFilter(1234, filter); +}; + +LookApi.prototype.findByHandle = function (handle) { + const filter = { 'user.handle': handle }; + return this.runQueryWithFilter(12345, filter); +}; + +LookApi.prototype.findProjectRegSubmissions = function (projectId) { + const queryId = config.lookerConfig.QUERIES.REG_STATS; + const fields = ['connect_project.id', 'challenge.track', 'challenge.num_registrations', 'challenge.num_submissions']; + const view = 'challenge'; + const filters = { 'connect_project.id': projectId }; + return this.runQueryWithFilter(queryId, view, fields, filters); +}; + +LookApi.prototype.findProjectBudget = function (connectProjectId, permissions) { + const queryId = config.lookerConfig.QUERIES.BUDGET; + const { isManager, isAdmin, isCopilot, isCustomer } = permissions; + + const fields = [ + 'project_stream.tc_connect_project_id', + ]; + + // Manager roles have access to more fields. + if (isManager || isAdmin) { + fields.push('project_stream.total_actual_challenge_fee'); + } + if (isManager || isAdmin || isCopilot) { + fields.push('project_stream.total_actual_member_payment'); + } + if (isManager || isAdmin || isCustomer) { + fields.push('project_stream.total_invoiced_amount', 'project_stream.remaining_invoiced_budget'); + } + const view = 'project_stream'; + const filters = { 'project_stream.tc_connect_project_id': connectProjectId }; + return this.runQueryWithFilter(queryId, view, fields, filters); +}; + +LookApi.prototype.runQueryWithFilter = function (queryId, view, fields, filters) { + const endpoint = `${this.BASE_URL}/queries/run/${this.formatting}`; + + const body = { + id: queryId, + model: 'topcoder_model_main', + view, + filters, + fields, + // sorts: ['user.email desc 0'], + limit: 10, + query_timezon: 'America/Los_Angeles', + + }; + return this.callApi(endpoint, body); +}; + +LookApi.prototype.runQuery = function (queryId) { + const endpoint = `${this.BASE_URL}/queries/${queryId}/run/${this.formatting}?limit=${this.limit}`; + return this.callApi(endpoint); +}; + +LookApi.prototype.callApi = function (endpoint, body) { + return this.lookAuth.getToken().then((token) => { + let newReq = null; + if (body) { + newReq = axios.post(endpoint, body, { + headers: { + 'Content-Type': 'application/json', + Authorization: `token ${token}`, + }, + }); + } else { + newReq = axios.get(endpoint); + } + return newReq; + }).then((res) => { + this.logger.info(res.data); + return res.data; + }); +}; + +module.exports = LookApi; diff --git a/src/routes/projectReports/getReport.js b/src/routes/projectReports/getReport.js new file mode 100644 index 0000000..aa4253c --- /dev/null +++ b/src/routes/projectReports/getReport.js @@ -0,0 +1,56 @@ +/* eslint-disable no-unused-vars */ +import config from 'config'; +import _ from 'lodash'; + +import { middleware as tcMiddleware } from 'tc-core-library-js'; +import LookApi from './LookRun'; +import mock from './mock'; +import util from '../../util'; +import { PROJECT_MEMBER_MANAGER_ROLES, USER_ROLE, PROJECT_MEMBER_ROLE } from '../../constants'; + +const permissions = tcMiddleware.permissions; + + +module.exports = [ + permissions('project.view'), + async (req, res, next) => { + const projectId = Number(req.params.projectId); + const reportName = req.query.reportName; + + if (config.lookerConfig.USE_MOCK === 'true') { + req.log.info('using mock'); + // using mock + return mock(projectId, reportName, req, res); + // res.status(200).json(util.wrapResponse(req.id, project)); + } + const lookApi = new LookApi(req.log); + + try { + // check if auth user has acecss to this project + const members = req.context.currentProjectMembers; + const member = _.find(members, m => m.userId === req.authUser.userId); + const isManager = member && PROJECT_MEMBER_MANAGER_ROLES.indexOf(member.role) > -1; + const isAdmin = util.hasRoles(req, [USER_ROLE.CONNECT_ADMIN, USER_ROLE.TOPCODER_ADMIN]); + const isCopilot = member && member.role === PROJECT_MEMBER_ROLE.COPILOT; + const isCustomer = member && member.role === PROJECT_MEMBER_ROLE.CUSTOMER; + // pick the report based on its name + let result = {}; + switch (reportName) { + case 'summary': + result = await lookApi.findProjectRegSubmissions(projectId); + break; + case 'projectBudget': + result = await lookApi.findProjectBudget(projectId, { isManager, isAdmin, isCopilot, isCustomer }); + break; + default: + return res.status(404).send('Report not found'); + } + + req.log.debug(result); + return res.status(200).json(result); + } catch (err) { + req.log.error(err); + return res.status(500).send(err.toString()); + } + }, +]; diff --git a/src/routes/projectReports/mock.js b/src/routes/projectReports/mock.js new file mode 100644 index 0000000..33e6787 --- /dev/null +++ b/src/routes/projectReports/mock.js @@ -0,0 +1,25 @@ +import _ from 'lodash'; +import util from '../../util'; + +const summaryJson = require('./mockFiles/summary.json'); +let projectBudgetJson = require('./mockFiles/projectBudget.json'); + +module.exports = (projectId, reportName, req, res) => { + if (Number(projectId) === 123456) { + res.status(500).json('Invalid project id'); + } + + switch (reportName) { + case 'summary': + res.status(200).json(util.wrapResponse(req.id, summaryJson)); + break; + case 'projectBudget': { + const augmentProjectId = pb => _.assign(pb, { 'project_stream.tc_connect_project_id': projectId }); + projectBudgetJson = _.map(projectBudgetJson, augmentProjectId); + res.status(200).json(util.wrapResponse(req.id, projectBudgetJson)); + break; + } + default: + res.status(400).json('Invalid report name'); + } +}; diff --git a/src/routes/projectReports/mockFiles/projectBudget.json b/src/routes/projectReports/mockFiles/projectBudget.json new file mode 100644 index 0000000..7d8ec03 --- /dev/null +++ b/src/routes/projectReports/mockFiles/projectBudget.json @@ -0,0 +1,57 @@ +[ + { + "project_stream.tc_connect_project_id": "1", + "connect_project.directprojectid": "54321", + "connect_project.id": "12345", + "project_stream.id": "abcd", + "project_stream.total_approved_budget": "63680", + "project_stream.total_invoiced_amount": "43680", + "project_stream.remaining_invoiced_budget": "20000", + "project_stream.total_actual_challenge_fee": "29648.09", + "project_stream.total_actual_member_payment": "16351.91" + }, + { + "project_stream.tc_connect_project_id": "2", + "connect_project.directprojectid": "54321", + "connect_project.id": "12345", + "project_stream.id": "defg", + "project_stream.total_approved_budget": "10000", + "project_stream.total_invoiced_amount": "10000", + "project_stream.remaining_invoiced_budget": "0", + "project_stream.total_actual_challenge_fee": "0", + "project_stream.total_actual_member_payment": "0" + }, + { + "project_stream.tc_connect_project_id": "3", + "connect_project.directprojectid": "54321", + "connect_project.id": "12345", + "project_stream.id": "hijk", + "project_stream.total_approved_budget": "4,300", + "project_stream.total_invoiced_amount": "0", + "project_stream.remaining_invoiced_budget": "0", + "project_stream.total_actual_challenge_fee": "0", + "project_stream.total_actual_member_payment": "0" + }, + { + "project_stream.tc_connect_project_id": "4", + "connect_project.directprojectid": "54321", + "connect_project.id": "12345", + "project_stream.id": "klmn", + "project_stream.total_approved_budget": "1250", + "project_stream.total_invoiced_amount": "0", + "project_stream.remaining_invoiced_budget": "0", + "project_stream.total_actual_challenge_fee": "0", + "project_stream.total_actual_member_payment": "0" + }, + { + "project_stream.tc_connect_project_id": "5", + "connect_project.directprojectid": "54321", + "connect_project.id": "12345", + "project_stream.id": "opqu", + "project_stream.total_approved_budget": "0", + "project_stream.total_invoiced_amount": "0", + "project_stream.remaining_invoiced_budget": "0", + "project_stream.total_actual_challenge_fee": "0", + "project_stream.total_actual_member_payment": "0" + } +] \ No newline at end of file diff --git a/src/routes/projectReports/mockFiles/summary.json b/src/routes/projectReports/mockFiles/summary.json new file mode 100644 index 0000000..b97c953 --- /dev/null +++ b/src/routes/projectReports/mockFiles/summary.json @@ -0,0 +1,17 @@ +[ { "challenge.track": "Develop", + + "challenge.num_registrations": 399, + + "challenge.num_submissions": 72 }, + + { "challenge.track": "Design", + + "challenge.num_registrations": 54, + + "challenge.num_submissions": 28 }, + + { "challenge.track": null, + + "challenge.num_registrations": 453, + + "challenge.num_submissions": 100 } ] \ No newline at end of file diff --git a/src/routes/projectSettings/create.js b/src/routes/projectSettings/create.js new file mode 100644 index 0000000..6623a72 --- /dev/null +++ b/src/routes/projectSettings/create.js @@ -0,0 +1,91 @@ +/** + * API to add a project setting + */ +import validate from 'express-validation'; +import _ from 'lodash'; +import Joi from 'joi'; +import { middleware as tcMiddleware } from 'tc-core-library-js'; +import util from '../../util'; +import models from '../../models'; +import { VALUE_TYPE } from '../../constants'; + +const permissions = tcMiddleware.permissions; + +const schema = { + params: { + projectId: Joi.number().integer().positive().required(), + }, + body: Joi.object().keys({ + key: Joi.string().max(255).required(), + value: Joi.string().max(255).required(), + valueType: Joi.string().valid(_.values(VALUE_TYPE)).required(), + projectId: Joi.any().strip(), + metadata: Joi.object().optional(), + readPermission: Joi.object().required(), + writePermission: Joi.object().required(), + }).required(), +}; + +module.exports = [ + validate(schema), + permissions('projectSetting.create'), + (req, res, next) => { + let setting = null; + const projectId = req.params.projectId; + const entity = _.assign(req.body, { + createdBy: req.authUser.userId, + updatedBy: req.authUser.userId, + projectId, + }); + + // Check if project exists + models.sequelize.transaction(() => + models.Project.findOne({ where: { id: projectId } }) + .then((project) => { + if (!project) { + const apiErr = new Error(`Project not found for id ${projectId}`); + apiErr.status = 404; + return Promise.reject(apiErr); + } + + // Find project setting + return models.ProjectSetting.findOne({ + includeAllProjectSettingsForInternalUsage: true, + where: { + projectId, + key: req.body.key, + }, + paranoid: false, + }); + }) + .then((projectSetting) => { + if (projectSetting) { + const apiErr = new Error(`Project Setting already exists for project id ${projectId} ` + + `and key ${req.body.key}`); + apiErr.status = 400; + return Promise.reject(apiErr); + } + + // Create + return models.ProjectSetting.create(entity); + }) + .then(async (createdEntity) => { + setting = createdEntity; + // Calculate for valid estimation type + if (util.isProjectSettingForEstimation(createdEntity.key)) { + req.log.debug(`Recalculate price breakdown for project id ${projectId}`); + return util.calculateProjectEstimationItems(req, projectId); + } + + return Promise.resolve(); + }), + ) // transaction end + .then(() => { + req.log.debug('new project setting created (id# %d, key: %s)', + setting.id, setting.key); + // Omit deletedAt, deletedBy + res.status(201).json(_.omit(setting.toJSON(), 'deletedAt', 'deletedBy')); + }) + .catch(next); + }, +]; diff --git a/src/routes/projectSettings/create.spec.js b/src/routes/projectSettings/create.spec.js new file mode 100644 index 0000000..152ee7f --- /dev/null +++ b/src/routes/projectSettings/create.spec.js @@ -0,0 +1,380 @@ +/** + * 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'; +import { VALUE_TYPE } from '../../constants'; + +const should = chai.should(); + +const expectAfterCreate = (id, projectId, estimation, len, deletedLen, err, next) => { + if (err) throw err; + + models.ProjectSetting.findOne({ + includeAllProjectSettingsForInternalUsage: true, + where: { + id, + projectId, + }, + }) + .then((res) => { + if (!res) { + throw new Error('Should found the entity'); + } else { + // find deleted ProjectEstimationItems for project + models.ProjectEstimationItem.findAllByProject(models, projectId, { + where: { + deletedAt: { $ne: null }, + }, + includeAllProjectEstimatinoItemsForInternalUsage: true, + paranoid: false, + }).then((items) => { + // deleted project estimation items + items.should.have.lengthOf(deletedLen, 'Number of deleted ProjectEstimationItems doesn\'t match'); + + _.each(items, (item) => { + should.exist(item.deletedBy); + should.exist(item.deletedAt); + }); + + // find (non-deleted) ProjectEstimationItems for project + return models.ProjectEstimationItem.findAllByProject(models, projectId, { + includeAllProjectEstimatinoItemsForInternalUsage: true, + }); + }).then((entities) => { + entities.should.have.lengthOf(len, 'Number of created ProjectEstimationItems doesn\'t match'); + if (len) { + entities[0].projectEstimationId.should.be.eql(estimation.id); + if (estimation.valueType === VALUE_TYPE.PERCENTAGE) { + entities[0].price.should.be.eql((estimation.price * estimation.value) / 100); + } else { + entities[0].price.should.be.eql(Number(estimation.value)); + } + entities[0].type.should.be.eql(estimation.key.split('markup_')[1]); + entities[0].markupUsedReference.should.be.eql('projectSetting'); + entities[0].markupUsedReferenceId.should.be.eql(id); + should.exist(entities[0].updatedAt); + should.not.exist(entities[0].deletedBy); + should.not.exist(entities[0].deletedAt); + } + + next(); + }).catch(next); + } + }); +}; + +describe('CREATE Project Setting', () => { + let projectId; + let estimationId; + + const body = { + key: 'markup_topcoder_service', + value: '3500', + valueType: 'double', + readPermission: { + projectRoles: ['customer'], + topcoderRoles: ['administrator'], + }, + writePermission: { + allowRule: { topcoderRoles: ['administrator'] }, + denyRule: { projectRoles: ['copilot'] }, + }, + }; + + const estimation = { + buildingBlockKey: 'BLOCK_KEY', + conditions: '( HAS_DEV_DELIVERABLE && ONLY_ONE_OS_MOBILE && CA_NEEDED )', + price: 5000, + quantity: 10, + minTime: 35, + maxTime: 35, + metadata: { + deliverable: 'dev-qa', + }, + createdBy: 1, + updatedBy: 1, + }; + + beforeEach((done) => { + testUtil.clearDb() + .then(() => { + // Create projects + models.Project.create({ + type: 'generic', + billingAccountId: 1, + name: 'test1', + description: 'test project1', + status: 'draft', + details: {}, + createdBy: 1, + updatedBy: 1, + lastActivityAt: 1, + lastActivityUserId: '1', + }) + .then((project) => { + projectId = project.id; + + models.ProjectEstimation.create(_.assign(estimation, { projectId })) + .then((e) => { + estimationId = e.id; + done(); + }); + }); + }); + }); + + after((done) => { + testUtil.clearDb(done); + }); + + describe('POST /projects/{projectId}/settings', () => { + it('should return 403 if user is not authenticated', (done) => { + request(server) + .post(`/v5/projects/${projectId}/settings`) + .send(body) + .expect(403, done); + }); + + it('should return 403 for member', (done) => { + request(server) + .post(`/v5/projects/${projectId}/settings`) + .set({ + Authorization: `Bearer ${testUtil.jwts.member}`, + }) + .send(body) + .expect(403, done); + }); + + it('should return 403 for copilot', (done) => { + request(server) + .post(`/v5/projects/${projectId}/settings`) + .set({ + Authorization: `Bearer ${testUtil.jwts.copilot}`, + }) + .send(body) + .expect(403, done); + }); + + it('should return 404 for non-existed project', (done) => { + request(server) + .post('/v5/projects/9999/settings') + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .send(body) + .expect(404, done); + }); + + it('should return 400 for missing key', (done) => { + const invalidBody = _.cloneDeep(body); + delete invalidBody.key; + + request(server) + .post(`/v5/projects/${projectId}/settings`) + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .send(invalidBody) + .expect('Content-Type', /json/) + .expect(400, done); + }); + + it('should return 400 for missing value', (done) => { + const invalidBody = _.cloneDeep(body); + delete invalidBody.value; + + request(server) + .post(`/v5/projects/${projectId}/settings`) + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .send(invalidBody) + .expect('Content-Type', /json/) + .expect(400, done); + }); + + it('should return 400 for missing valueType', (done) => { + const invalidBody = _.cloneDeep(body); + delete invalidBody.valueType; + + request(server) + .post(`/v5/projects/${projectId}/settings`) + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .send(invalidBody) + .expect('Content-Type', /json/) + .expect(400, done); + }); + + xit('should return 400 for negative value when valueType = percentage', (done) => { + const invalidBody = _.cloneDeep(body); + invalidBody.value = '-10'; + invalidBody.valueType = VALUE_TYPE.PERCENTAGE; + + request(server) + .post(`/v5/projects/${projectId}/settings`) + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .send(invalidBody) + .expect('Content-Type', /json/) + .expect(400, done); + }); + + xit('should return 400 for value greater than 100 when valueType = percentage', (done) => { + const invalidBody = _.cloneDeep(body); + invalidBody.value = '150'; + invalidBody.valueType = VALUE_TYPE.PERCENTAGE; + + request(server) + .post(`/v5/projects/${projectId}/settings`) + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .send(invalidBody) + .expect('Content-Type', /json/) + .expect(400, done); + }); + + it('should return 400, for admin, when create key with existing key', (done) => { + const existing = _.cloneDeep(body); + existing.projectId = projectId; + existing.createdBy = 1; + existing.updatedBy = 1; + + models.ProjectSetting.create(existing).then(() => { + request(server) + .post(`/v5/projects/${projectId}/settings`) + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .send(body) + .expect(400, done); + }).catch(done); + }); + + it('should return 201 for manager with non-estimation type, not calculating project estimation items', + (done) => { + const createBody = _.cloneDeep(body); + createBody.key = 'markup_no_estimation'; + + request(server) + .post(`/v5/projects/${projectId}/settings`) + .set({ + Authorization: `Bearer ${testUtil.jwts.manager}`, + }) + .send(createBody) + .expect('Content-Type', /json/) + .expect(201) + .end((err, res) => { + if (err) done(err); + + const resJson = res.body; + resJson.key.should.be.eql(createBody.key); + resJson.value.should.be.eql(createBody.value); + resJson.valueType.should.be.eql(createBody.valueType); + resJson.projectId.should.be.eql(projectId); + resJson.createdBy.should.be.eql(40051334); + should.exist(resJson.createdAt); + resJson.updatedBy.should.be.eql(40051334); + should.exist(resJson.updatedAt); + should.not.exist(resJson.deletedBy); + should.not.exist(resJson.deletedAt); + expectAfterCreate(resJson.id, projectId, null, 0, 0, err, done); + }); + }); + + it('should return 201 for manager, calculating project estimation items', (done) => { + request(server) + .post(`/v5/projects/${projectId}/settings`) + .set({ + Authorization: `Bearer ${testUtil.jwts.manager}`, + }) + .send(body) + .expect('Content-Type', /json/) + .expect(201) + .end((err, res) => { + if (err) done(err); + + const resJson = res.body; + resJson.key.should.be.eql(body.key); + resJson.value.should.be.eql(body.value); + resJson.valueType.should.be.eql(body.valueType); + resJson.projectId.should.be.eql(projectId); + resJson.createdBy.should.be.eql(40051334); + should.exist(resJson.createdAt); + resJson.updatedBy.should.be.eql(40051334); + should.exist(resJson.updatedAt); + should.not.exist(resJson.deletedBy); + should.not.exist(resJson.deletedAt); + expectAfterCreate(resJson.id, projectId, _.assign(estimation, { + id: estimationId, + value: body.value, + valueType: body.valueType, + key: body.key, + }), 1, 0, err, done); + }); + }); + + it('should return 201 for admin', (done) => { + request(server) + .post(`/v5/projects/${projectId}/settings`) + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .send(body) + .expect('Content-Type', /json/) + .expect(201) + .end((err, res) => { + if (err) done(err); + + const resJson = res.body; + resJson.key.should.be.eql(body.key); + resJson.value.should.be.eql(body.value); + resJson.valueType.should.be.eql(body.valueType); + resJson.projectId.should.be.eql(projectId); + resJson.createdBy.should.be.eql(40051333); + should.exist(resJson.createdAt); + resJson.updatedBy.should.be.eql(40051333); + 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(`/v5/projects/${projectId}/settings`) + .set({ + Authorization: `Bearer ${testUtil.jwts.connectAdmin}`, + }) + .send(body) + .expect('Content-Type', /json/) + .expect(201) + .end((err, res) => { + if (err) done(err); + + const resJson = res.body; + resJson.key.should.be.eql(body.key); + resJson.value.should.be.eql(body.value); + resJson.valueType.should.be.eql(body.valueType); + resJson.projectId.should.be.eql(projectId); + resJson.createdBy.should.be.eql(40051336); + resJson.updatedBy.should.be.eql(40051336); + should.exist(resJson.createdAt); + should.exist(resJson.updatedAt); + should.not.exist(resJson.deletedBy); + should.not.exist(resJson.deletedAt); + done(); + }); + }); + }); +}); diff --git a/src/routes/projectSettings/delete.js b/src/routes/projectSettings/delete.js new file mode 100644 index 0000000..aa85f49 --- /dev/null +++ b/src/routes/projectSettings/delete.js @@ -0,0 +1,63 @@ +/** + * API to delete a project setting + */ +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: { + projectId: Joi.number().integer().positive().required(), + id: Joi.number().integer().positive().required(), + }, +}; + +module.exports = [ + validate(schema), + permissions('projectSetting.delete'), + (req, res, next) => { + const projectId = req.params.projectId; + const id = req.params.id; + let deletedEntity = null; + + models.sequelize.transaction(() => + models.ProjectSetting.findOne({ + includeAllProjectSettingsForInternalUsage: true, + where: { + id, + projectId, + }, + }) + .then((entity) => { + // Not found + if (!entity) { + const apiErr = new Error(`Project setting not found for id ${id} and project id ${projectId}`); + apiErr.status = 404; + return Promise.reject(apiErr); + } + + deletedEntity = entity; + // Update the deletedBy, then delete + return entity.update({ deletedBy: req.authUser.userId }); + }) + .then(entity => entity.destroy()) + .then(() => { + // Calculate for valid estimation type + if (util.isProjectSettingForEstimation(deletedEntity.key)) { + req.log.debug(`Recalculate price breakdown for project id ${projectId}`); + return util.calculateProjectEstimationItems(req, projectId); + } + + return Promise.resolve(); + }), + ) // transaction end + .then(() => { + res.status(204).end(); + }) + .catch(next); + }, +]; diff --git a/src/routes/projectSettings/delete.spec.js b/src/routes/projectSettings/delete.spec.js new file mode 100644 index 0000000..78d1e51 --- /dev/null +++ b/src/routes/projectSettings/delete.spec.js @@ -0,0 +1,281 @@ +/** + * Tests for delete.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(); + +const expectAfterDelete = (id, projectId, len, deletedLen, err, next) => { + if (err) throw err; + + models.ProjectSetting.findOne({ + includeAllProjectSettingsForInternalUsage: true, + where: { + id, + projectId, + }, + paranoid: false, + }) + .then((res) => { + if (!res) { + throw new Error('Should found the entity'); + } else { + should.exist(res.deletedBy); + should.exist(res.deletedAt); + + // find deleted ProjectEstimationItems for project + models.ProjectEstimationItem.findAllByProject(models, projectId, { + where: { + deletedAt: { $ne: null }, + }, + includeAllProjectEstimatinoItemsForInternalUsage: true, + paranoid: false, + }).then((items) => { + // deleted project estimation items + items.should.have.lengthOf(deletedLen, 'Number of deleted ProjectEstimationItems doesn\'t match'); + _.each(items, (item) => { + should.exist(item.deletedBy); + should.exist(item.deletedAt); + }); + + // find (non-deleted) ProjectEstimationItems for project + return models.ProjectEstimationItem.findAllByProject(models, projectId, { + includeAllProjectEstimatinoItemsForInternalUsage: true, + }); + }).then((items) => { + // all non-deleted project estimation item count + items.should.have.lengthOf(len, 'Number of created ProjectEstimationItems doesn\'t match'); + next(); + }).catch(next); + } + }); +}; + +describe('DELETE Project Setting', () => { + let projectId; + let estimationId; + let id; + let id2; + + const estimation = { + buildingBlockKey: 'BLOCK_KEY', + conditions: '( HAS_DEV_DELIVERABLE && SCREENS_COUNT_SMALL && CA_NEEDED)', + price: 6500.50, + quantity: 10, + minTime: 35, + maxTime: 35, + metadata: { + deliverable: 'dev-qa', + }, + createdBy: 1, + updatedBy: 1, + }; + + beforeEach((done) => { + testUtil.clearDb() + .then(() => { + // Create projects + models.Project.create({ + type: 'generic', + billingAccountId: 1, + name: 'test1', + description: 'test project1', + status: 'draft', + details: {}, + createdBy: 1, + updatedBy: 1, + lastActivityAt: 1, + lastActivityUserId: '1', + }) + .then((project) => { + projectId = project.id; + + models.ProjectSetting.bulkCreate([{ + projectId, + key: 'markup_topcoder_service', + value: '5599.96', + valueType: 'double', + readPermission: { + projectRoles: ['customer'], + topcoderRoles: ['administrator'], + }, + writePermission: { + allowRule: { + projectRoles: ['customer', 'copilot'], + topcoderRoles: ['administrator'], + }, + denyRule: { + projectRoles: ['copilot'], + }, + }, + createdBy: 1, + updatedBy: 1, + }, { + projectId, + key: 'markup_no_estimation', + value: '40', + valueType: 'percentage', + readPermission: { + topcoderRoles: ['administrator'], + }, + writePermission: { + allowRule: { topcoderRoles: ['administrator'] }, + denyRule: { projectRoles: ['copilot'] }, + }, + createdBy: 1, + updatedBy: 1, + }], { returning: true }) + .then((settings) => { + id = settings[0].id; + id2 = settings[1].id; + models.ProjectEstimation.create(_.assign(estimation, { projectId })) + .then((e) => { + estimationId = e.id; + done(); + }); + }); + }); + }); + }); + + after((done) => { + testUtil.clearDb(done); + }); + + describe('DELETE /projects/{projectId}/settings/{id}', () => { + it('should return 403 if user is not authenticated', (done) => { + request(server) + .delete(`/v5/projects/${projectId}/settings/${id}`) + .expect(403, done); + }); + + it('should return 403 for member', (done) => { + request(server) + .delete(`/v5/projects/${projectId}/settings/${id}`) + .set({ + Authorization: `Bearer ${testUtil.jwts.member}`, + }) + .expect(403, done); + }); + + it('should return 403 for copilot', (done) => { + request(server) + .delete(`/v5/projects/${projectId}/settings/${id}`) + .set({ + Authorization: `Bearer ${testUtil.jwts.copilot}`, + }) + .expect(403, done); + }); + + it('should return 404 for non-existed project', (done) => { + request(server) + .delete(`/v5/projects/9999/settings/${id}`) + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .expect(404, done); + }); + + it('should return 404 for non-existed project setting', (done) => { + request(server) + .delete(`/v5/projects/${projectId}/settings/1234`) + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .expect(404, done); + }); + + it('should return 404 for deleted project setting', (done) => { + models.ProjectSetting.destroy({ where: { id } }) + .then(() => { + request(server) + .delete(`/v5/projects/${projectId}/settings/${id}`) + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .expect(404, done); + }).catch(done); + }); + + it('should return 204, for admin, if project setting was successfully removed', (done) => { + models.ProjectEstimationItem.create({ + projectEstimationId: estimationId, + price: 1200, + type: 'topcoder_service', + markupUsedReference: 'projectSetting', + markupUsedReferenceId: id, + createdBy: 1, + updatedBy: 1, + }) + .then(() => { + request(server) + .delete(`/v5/projects/${projectId}/settings/${id}`) + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .expect(204) + .end(err => expectAfterDelete(id, projectId, 0, 1, err, done)); + }).catch(done); + }); + + it('should return 204, for admin, if project setting with non-estimation type was successfully removed', + (done) => { + request(server) + .delete(`/v5/projects/${projectId}/settings/${id2}`) + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .expect(204) + .end(err => expectAfterDelete(id2, projectId, 0, 0, err, done)); + }); + + it('should return 204, for admin, another project setting exists if the project setting was successfully removed', + (done) => { + models.ProjectSetting.create({ + projectId, + key: 'markup_fee', + value: '25', + valueType: 'percentage', + readPermission: { + projectRoles: ['customer'], + topcoderRoles: ['administrator'], + }, + writePermission: { + allowRule: { + projectRoles: ['customer', 'copilot'], + topcoderRoles: ['administrator'], + }, + denyRule: { + projectRoles: ['copilot'], + }, + }, + createdBy: 1, + updatedBy: 1, + }).then((anotherSetting) => { + models.ProjectEstimationItem.create({ + projectEstimationId: estimationId, + price: 1200, + type: 'fee', + markupUsedReference: 'projectSetting', + markupUsedReferenceId: anotherSetting.id, + createdBy: 1, + updatedBy: 1, + }).then(() => { + request(server) + .delete(`/v5/projects/${projectId}/settings/${id}`) + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .expect(204) + .end(err => expectAfterDelete(id, projectId, 1, 1, err, done)); + }); + }).catch(done); + }); + }); +}); diff --git a/src/routes/projectSettings/list.js b/src/routes/projectSettings/list.js new file mode 100644 index 0000000..480cd26 --- /dev/null +++ b/src/routes/projectSettings/list.js @@ -0,0 +1,50 @@ +/** + * API to list project setting + */ +import _ from 'lodash'; +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 = { + query: { + includeAllProjectSettingsForInternalUsage: Joi.boolean().optional(), + }, +}; + +module.exports = [ + validate(schema), + permissions('projectSetting.view'), + (req, res, next) => { + const projectId = req.params.projectId; + const options = { + where: { + projectId, + }, + attributes: { exclude: ['deletedAt', 'deletedBy'] }, + raw: true, + // provide current user and project members list so `ProjectSetting.findAll` will return + // only records available to view by the current user + reqUser: req.authUser, + members: req.context.currentProjectMembers, + }; + + models.Project.findOne({ where: { id: projectId } }) + .then((project) => { + if (!project) { + const apiErr = new Error(`Project not found for id ${projectId}`); + apiErr.status = 404; + return Promise.reject(apiErr); + } + + return models.ProjectSetting.findAll(options); + }) + .then((result) => { + res.json(_.filter(result, r => r)); + }) + .catch(next); + }, +]; diff --git a/src/routes/projectSettings/list.spec.js b/src/routes/projectSettings/list.spec.js new file mode 100644 index 0000000..f5e0b59 --- /dev/null +++ b/src/routes/projectSettings/list.spec.js @@ -0,0 +1,243 @@ +/** + * Tests for list.js + */ +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 should = chai.should(); + +describe('LIST Project Settings', () => { + let projectId; + + const memberUser = { + handle: testUtil.getDecodedToken(testUtil.jwts.member).handle, + userId: testUtil.getDecodedToken(testUtil.jwts.member).userId, + firstName: 'fname', + lastName: 'lName', + email: 'some@abc.com', + }; + const copilotUser = { + handle: testUtil.getDecodedToken(testUtil.jwts.copilot).handle, + userId: testUtil.getDecodedToken(testUtil.jwts.copilot).userId, + firstName: 'fname', + lastName: 'lName', + email: 'some@abc.com', + }; + + const settings = [{ + key: 'markup_topcoder_service', + value: '3500', + valueType: 'double', + readPermission: { + allowRule: { + projectRoles: ['customer', 'copilot'], + topcoderRoles: ['administrator'], + }, + denyRule: { + projectRoles: ['copilot'], + topcoderRoles: ['Connect Admin'], + }, + }, + writePermission: { + allowRule: { topcoderRoles: ['administrator'] }, + denyRule: { projectRoles: ['copilot'] }, + }, + createdBy: 1, + updatedBy: 1, + }, { + key: 'markup_fee', + value: '15', + valueType: 'percentage', + readPermission: { + topcoderRoles: ['administrator'], + }, + writePermission: { + allowRule: { topcoderRoles: ['administrator'] }, + denyRule: { projectRoles: ['copilot'] }, + }, + createdBy: 1, + updatedBy: 1, + }]; + + beforeEach((done) => { + testUtil.clearDb() + .then(() => { + // Create projects + models.Project.create({ + type: 'generic', + billingAccountId: 1, + name: 'test1', + description: 'test project1', + status: 'draft', + details: {}, + createdBy: 1, + updatedBy: 1, + lastActivityAt: 1, + lastActivityUserId: '1', + }) + .then((project) => { + projectId = project.id; + // create members + models.ProjectMember.bulkCreate([{ + id: 1, + userId: copilotUser.userId, + projectId, + role: 'copilot', + isPrimary: false, + createdBy: 1, + updatedBy: 1, + }, { + id: 2, + userId: memberUser.userId, + projectId, + role: 'customer', + isPrimary: true, + createdBy: 1, + updatedBy: 1, + }]) + .then(() => { + models.ProjectSetting.bulkCreate(_.map(settings, s => _.assign(s, { projectId }))).then(() => done()); + }); + }); + }); + }); + + after((done) => { + testUtil.clearDb(done); + }); + + describe('GET /projects/{projectId}/settings', () => { + it('should return 403 if user is not authenticated', (done) => { + request(server) + .get(`/v5/projects/${projectId}/settings`) + .expect(403, done); + }); + + it('should return 403 when user have no permission (non team member)', (done) => { + request(server) + .get(`/v5/projects/${projectId}/settings`) + .set({ + Authorization: `Bearer ${testUtil.jwts.member2}`, + }) + .expect(403, done); + }); + + it('should return 404 for deleted project', (done) => { + models.Project.destroy({ where: { id: projectId } }) + .then(() => { + request(server) + .get(`/v5/projects/${projectId}/settings`) + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .expect(404, done); + }); + }); + + it('should return 404 for non-existed project', (done) => { + request(server) + .get('/v5/projects/99999/settings') + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .expect(404, done); + }); + + it('should return 0 setting when copilot has readPermission for both denyRule and allowRule', (done) => { + request(server) + .get(`/v5/projects/${projectId}/settings`) + .set({ + Authorization: `Bearer ${testUtil.jwts.copilot}`, + }) + .expect('Content-Type', /json/) + .expect(200) + .end((err, res) => { + if (err) { + done(err); + } else { + const resJson = res.body; + should.exist(resJson); + resJson.should.have.lengthOf(0); + done(); + } + }); + }); + + it('should return 0 setting when connect admin has readPermission for denyRule', (done) => { + request(server) + .get(`/v5/projects/${projectId}/settings`) + .set({ + Authorization: `Bearer ${testUtil.jwts.connectAdmin}`, + }) + .expect('Content-Type', /json/) + .expect(200) + .end((err, res) => { + if (err) { + done(err); + } else { + const resJson = res.body; + should.exist(resJson); + resJson.should.have.lengthOf(0); + done(); + } + }); + }); + + it('should return 1 setting when user have readPermission (customer)', (done) => { + request(server) + .get(`/v5/projects/${projectId}/settings`) + .set({ + Authorization: `Bearer ${testUtil.jwts.member}`, + }) + .expect(200) + .end((err, res) => { + if (err) { + done(err); + } else { + const resJson = res.body; + should.exist(resJson); + resJson.should.have.lengthOf(1); + const setting = settings[0]; + resJson[0].key.should.be.eql(setting.key); + resJson[0].value.should.be.eql(setting.value); + resJson[0].valueType.should.be.eql(setting.valueType); + resJson[0].projectId.should.be.eql(projectId); + resJson[0].readPermission.should.be.eql(setting.readPermission); + resJson[0].writePermission.should.be.eql(setting.writePermission); + done(); + } + }); + }); + + it('should return 2 settings when user have readPermission (administrator)', (done) => { + request(server) + .get(`/v5/projects/${projectId}/settings`) + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .expect('Content-Type', /json/) + .expect(200) + .end((err, res) => { + if (err) { + done(err); + } else { + const resJson = res.body; + should.exist(resJson); + resJson.should.have.lengthOf(2); + const setting = settings[0]; + resJson[0].key.should.be.eql(setting.key); + resJson[0].value.should.be.eql(setting.value); + resJson[0].valueType.should.be.eql(setting.valueType); + resJson[0].projectId.should.be.eql(projectId); + resJson[0].readPermission.should.be.eql(setting.readPermission); + resJson[0].writePermission.should.be.eql(setting.writePermission); + done(); + } + }); + }); + }); +}); diff --git a/src/routes/projectSettings/update.js b/src/routes/projectSettings/update.js new file mode 100644 index 0000000..9f22b1d --- /dev/null +++ b/src/routes/projectSettings/update.js @@ -0,0 +1,74 @@ +/** + * API to update a project setting + */ +import validate from 'express-validation'; +import _ from 'lodash'; +import Joi from 'joi'; +import { middleware as tcMiddleware } from 'tc-core-library-js'; +import util from '../../util'; +import models from '../../models'; +import { VALUE_TYPE } from '../../constants'; + +const permissions = tcMiddleware.permissions; + +const schema = { + params: { + projectId: Joi.number().integer().positive().required(), + id: Joi.number().integer().positive().required(), + }, + body: Joi.object().keys({ + value: Joi.string().max(255), + valueType: Joi.string().valid(_.values(VALUE_TYPE)), + projectId: Joi.any().strip(), + metadata: Joi.object(), + readPermission: Joi.object(), + writePermission: Joi.object(), + }).required(), +}; + +module.exports = [ + validate(schema), + permissions('projectSetting.edit'), + (req, res, next) => { + let oldKey = null; + let updatedSetting = null; + const projectId = req.params.projectId; + const id = req.params.id; + const entityToUpdate = _.assign(req.body, { + updatedBy: req.authUser.userId, + }); + + models.sequelize.transaction(() => + models.ProjectSetting.findOne({ + includeAllProjectSettingsForInternalUsage: true, + where: { + id, + projectId, + }, + }) + .then((existing) => { + // Not found + if (!existing) { + const apiErr = new Error(`Project setting not found for id ${id} and project id ${projectId}`); + apiErr.status = 404; + return Promise.reject(apiErr); + } + + oldKey = existing.key; + return existing.update(entityToUpdate); + }) + .then((updated) => { + updatedSetting = updated; + if (util.isProjectSettingForEstimation(updatedSetting.key) || util.isProjectSettingForEstimation(oldKey)) { + req.log.debug(`Recalculate price breakdown for project id ${projectId}`); + return util.calculateProjectEstimationItems(req, projectId); + } + return Promise.resolve(); + }), + ) // transaction end + .then(() => { + res.json(updatedSetting); + }) + .catch(next); + }, +]; diff --git a/src/routes/projectSettings/update.spec.js b/src/routes/projectSettings/update.spec.js new file mode 100644 index 0000000..916757f --- /dev/null +++ b/src/routes/projectSettings/update.spec.js @@ -0,0 +1,408 @@ +/** + * Tests for update.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'; +import { VALUE_TYPE } from '../../constants'; + +const should = chai.should(); + +const expectAfterUpdate = (id, projectId, estimation, len, deletedLen, err, next) => { + if (err) throw err; + + models.ProjectSetting.findOne({ + includeAllProjectSettingsForInternalUsage: true, + where: { + id, + projectId, + }, + }) + .then((res) => { + if (!res) { + throw new Error('Should found the entity'); + } else { + // find deleted ProjectEstimationItems for project + models.ProjectEstimationItem.findAllByProject(models, projectId, { + where: { + deletedAt: { $ne: null }, + }, + includeAllProjectEstimatinoItemsForInternalUsage: true, + paranoid: false, + }).then((items) => { + // deleted project estimation items + items.should.have.lengthOf(deletedLen, 'Number of deleted ProjectEstimationItems doesn\'t match'); + + _.each(items, (item) => { + should.exist(item.deletedBy); + should.exist(item.deletedAt); + }); + + // find (non-deleted) ProjectEstimationItems for project + return models.ProjectEstimationItem.findAllByProject(models, projectId, { + includeAllProjectEstimatinoItemsForInternalUsage: true, + }); + }).then((entities) => { + entities.should.have.lengthOf(len, 'Number of created ProjectEstimationItems doesn\'t match'); + if (len) { + entities[0].projectEstimationId.should.be.eql(estimation.id); + if (estimation.valueType === VALUE_TYPE.PERCENTAGE) { + entities[0].price.should.be.eql((estimation.price * estimation.value) / 100); + } else { + entities[0].price.should.be.eql(Number(estimation.value)); + } + entities[0].type.should.be.eql(estimation.key.split('markup_')[1]); + entities[0].markupUsedReference.should.be.eql('projectSetting'); + entities[0].markupUsedReferenceId.should.be.eql(id); + should.exist(entities[0].updatedAt); + should.not.exist(entities[0].deletedBy); + should.not.exist(entities[0].deletedAt); + } + + next(); + }); + } + }); +}; + +describe('UPDATE Project Setting', () => { + let projectId; + let estimationId; + let id; + + const memberUser = { + handle: testUtil.getDecodedToken(testUtil.jwts.member).handle, + userId: testUtil.getDecodedToken(testUtil.jwts.member).userId, + firstName: 'fname', + lastName: 'lName', + email: 'some@abc.com', + }; + const copilotUser = { + handle: testUtil.getDecodedToken(testUtil.jwts.copilot).handle, + userId: testUtil.getDecodedToken(testUtil.jwts.copilot).userId, + firstName: 'fname', + lastName: 'lName', + email: 'some@abc.com', + }; + + const body = { + value: '5599.96', + valueType: 'double', + readPermission: { + projectRoles: ['customer'], + topcoderRoles: ['administrator'], + }, + writePermission: { + allowRule: { + projectRoles: ['customer', 'copilot'], + topcoderRoles: ['administrator', 'Connect Admin'], + }, + denyRule: { + projectRoles: ['copilot'], + topcoderRoles: ['Connect Admin'], + }, + }, + }; + + // we don't include these params into the body, we cannot update them + // but we use them for creating model directly and for checking returned values + const bodyNonMutable = { + key: 'markup_topcoder_service', + createdBy: 1, + updatedBy: 1, + }; + + const estimation = { + buildingBlockKey: 'BLOCK_KEY', + conditions: '( HAS_DEV_DELIVERABLE && ONLY_ONE_OS_MOBILE && CA_NEEDED )', + price: 6500.50, + quantity: 10, + minTime: 35, + maxTime: 35, + metadata: { + deliverable: 'dev-qa', + }, + createdBy: 1, + updatedBy: 1, + }; + + beforeEach((done) => { + testUtil.clearDb() + .then(() => { + // Create projects + models.Project.create({ + type: 'generic', + billingAccountId: 1, + name: 'test1', + description: 'test project1', + status: 'draft', + details: {}, + createdBy: 1, + updatedBy: 1, + lastActivityAt: 1, + lastActivityUserId: '1', + }) + .then((project) => { + projectId = project.id; + + models.ProjectMember.bulkCreate([{ + id: 1, + userId: copilotUser.userId, + projectId, + role: 'copilot', + isPrimary: false, + createdBy: 1, + updatedBy: 1, + }, { + id: 2, + userId: memberUser.userId, + projectId, + role: 'customer', + isPrimary: true, + createdBy: 1, + updatedBy: 1, + }]) + .then(() => { + models.ProjectSetting.create(_.assign({}, body, bodyNonMutable, { + projectId, + })) + .then((s) => { + id = s.id; + + models.ProjectEstimation.create(_.assign(estimation, { projectId })) + .then((e) => { + estimationId = e.id; + done(); + }); + }).catch(done); + }); + }); + }); + }); + + after((done) => { + testUtil.clearDb(done); + }); + + describe('PATCH /projects/{projectId}/settings/{id}', () => { + it('should return 403 if user is not authenticated', (done) => { + request(server) + .patch(`/v5/projects/${projectId}/settings/${id}`) + .send(body) + .expect(403, done); + }); + + it('should return 403 when user have no permission (non team member)', (done) => { + request(server) + .patch(`/v5/projects/${projectId}/settings/${id}`) + .set({ + Authorization: `Bearer ${testUtil.jwts.member2}`, + }) + .send(body) + .expect(403, done); + }); + + it('should return 403 when copilot is in both denyRule and allowRule', (done) => { + request(server) + .patch(`/v5/projects/${projectId}/settings/${id}`) + .set({ + Authorization: `Bearer ${testUtil.jwts.copilot}`, + }) + .send(body) + .expect(403, done); + }); + + it('should return 403 when connect admin is in both denyRule and allowRule', (done) => { + request(server) + .patch(`/v5/projects/${projectId}/settings/${id}`) + .set({ + Authorization: `Bearer ${testUtil.jwts.connectAdmin}`, + }) + .send(body) + .expect(403, done); + }); + + it('should return 404 for non-existed project', (done) => { + request(server) + .patch(`/v5/projects/9999/settings/${id}`) + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .send(body) + .expect(404, done); + }); + + it('should return 404 for non-existed project setting', (done) => { + request(server) + .patch(`/v5/projects/${projectId}/settings/1234`) + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .send(body) + .expect(404, done); + }); + + it('should return 404 for deleted project setting', (done) => { + models.ProjectSetting.destroy({ where: { id } }) + .then(() => { + request(server) + .patch(`/v5/projects/${projectId}/settings/${id}`) + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .send(body) + .expect(404, done); + }); + }); + + it('should return 400, when try to update key', (done) => { + request(server) + .patch(`/v5/projects/${projectId}/settings/${id}`) + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .send({ + key: 'updated_key', + }) + .expect(400, done); + }); + + it('should return 200, for member with permission (team member), value updated but no project estimation present', + (done) => { + const notPresent = _.cloneDeep(body); + notPresent.value = '4500'; + + models.ProjectEstimation.destroy({ + where: { + id: estimationId, + }, + }).then(() => { + models.ProjectEstimationItem.destroy({ + where: { + markupUsedReference: 'projectSetting', + markupUsedReferenceId: id, + }, + }).then(() => { + request(server) + .patch(`/v5/projects/${projectId}/settings/${id}`) + .set({ + Authorization: `Bearer ${testUtil.jwts.member}`, + }) + .send({ + value: notPresent.value, + }) + .expect('Content-Type', /json/) + .expect(200) + .end((err, res) => { + if (err) done(err); + + const resJson = res.body; + resJson.id.should.be.eql(id); + resJson.key.should.be.eql(bodyNonMutable.key); + resJson.value.should.be.eql(notPresent.value); + resJson.valueType.should.be.eql(notPresent.valueType); + resJson.projectId.should.be.eql(projectId); + resJson.createdBy.should.be.eql(bodyNonMutable.createdBy); + resJson.updatedBy.should.be.eql(40051331); + should.exist(resJson.updatedAt); + should.not.exist(resJson.deletedBy); + should.not.exist(resJson.deletedAt); + expectAfterUpdate(id, projectId, _.assign(estimation, { + id: estimationId, + value: notPresent.value, + valueType: notPresent.valueType, + key: bodyNonMutable.key, + }), 0, 0, err, done); + }); + }); + }).catch(done); + }); + + it('should return 200 for admin when value updated, calculating project estimation items', (done) => { + body.value = '4500'; + + models.ProjectEstimationItem.create({ + projectEstimationId: estimationId, + price: 1200, + type: 'topcoder_service', + markupUsedReference: 'projectSetting', + markupUsedReferenceId: id, + createdBy: 1, + updatedBy: 1, + }).then(() => { + request(server) + .patch(`/v5/projects/${projectId}/settings/${id}`) + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .send({ + value: body.value, + }) + .expect('Content-Type', /json/) + .expect(200) + .end((err, res) => { + if (err) done(err); + + const resJson = res.body; + resJson.id.should.be.eql(id); + resJson.key.should.be.eql(bodyNonMutable.key); + resJson.value.should.be.eql(body.value); + resJson.valueType.should.be.eql(body.valueType); + resJson.projectId.should.be.eql(projectId); + resJson.createdBy.should.be.eql(bodyNonMutable.createdBy); + resJson.updatedBy.should.be.eql(40051333); // admin + should.exist(resJson.updatedAt); + should.not.exist(resJson.deletedBy); + should.not.exist(resJson.deletedAt); + expectAfterUpdate(id, projectId, _.assign(estimation, { + id: estimationId, + value: body.value, + valueType: body.valueType, + key: bodyNonMutable.key, + }), 1, 1, err, done); + }); + }).catch(done); + }); + + it('should return 200, for admin, update valueType from double to percentage', (done) => { + body.value = '10.76'; + body.valueType = VALUE_TYPE.PERCENTAGE; + request(server) + .patch(`/v5/projects/${projectId}/settings/${id}`) + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .send({ + value: body.value, + valueType: VALUE_TYPE.PERCENTAGE, + }) + .expect('Content-Type', /json/) + .expect(200) + .end((err, res) => { + if (err) done(err); + + const resJson = res.body; + resJson.id.should.be.eql(id); + resJson.key.should.be.eql(bodyNonMutable.key); + resJson.value.should.be.eql(body.value); + resJson.valueType.should.be.eql(VALUE_TYPE.PERCENTAGE); + resJson.projectId.should.be.eql(projectId); + resJson.createdBy.should.be.eql(bodyNonMutable.createdBy); + resJson.updatedBy.should.be.eql(40051333); // admin + should.exist(resJson.updatedAt); + should.not.exist(resJson.deletedBy); + should.not.exist(resJson.deletedAt); + expectAfterUpdate(id, projectId, _.assign(estimation, { + id: estimationId, + value: body.value, + valueType: body.valueType, + key: bodyNonMutable.key, + }), 1, 0, err, done); + }); + }); + }); +}); diff --git a/src/routes/projectTemplates/list.js b/src/routes/projectTemplates/list.js index f278c2e..81d1ee4 100644 --- a/src/routes/projectTemplates/list.js +++ b/src/routes/projectTemplates/list.js @@ -10,13 +10,24 @@ const permissions = tcMiddleware.permissions; module.exports = [ permissions('projectTemplate.view'), (req, res, next) => { - util.fetchFromES('projectTemplates') + util.fetchFromES('projectTemplates', { + query: { + nested: { + path: 'projectTemplates', + query: { + match: { 'projectTemplates.disabled': false }, + }, + inner_hits: {}, + }, + }, + }, 'metadata') .then((data) => { if (data.projectTemplates.length === 0) { req.log.debug('No projectTemplate found in ES'); models.ProjectTemplate.findAll({ where: { deletedAt: { $eq: null }, + disabled: false, }, attributes: { exclude: ['deletedAt', 'deletedBy'] }, raw: true, diff --git a/src/routes/projectTemplates/list.spec.js b/src/routes/projectTemplates/list.spec.js index 02a6a4b..25eaae9 100644 --- a/src/routes/projectTemplates/list.spec.js +++ b/src/routes/projectTemplates/list.spec.js @@ -20,7 +20,7 @@ describe('LIST project templates', () => { question: 'question 1', info: 'info 1', aliases: ['key-1', 'key_1'], - disabled: true, + disabled: false, hidden: true, scope: { scope1: { diff --git a/src/routes/projectTypes/create.js b/src/routes/projectTypes/create.js index 5f2f6fd..88051a5 100644 --- a/src/routes/projectTypes/create.js +++ b/src/routes/projectTypes/create.js @@ -41,10 +41,10 @@ module.exports = [ }); // Check if duplicated key - return models.ProjectType.findByPk(req.body.key) + return models.ProjectType.findByPk(req.body.key, { paranoid: false }) .then((existing) => { if (existing) { - const apiErr = new Error(`Project type already exists for key ${req.params.key}`); + const apiErr = new Error(`Project type already exists (may be deleted) for key ${req.body.key}`); apiErr.status = 400; return Promise.reject(apiErr); } diff --git a/src/routes/projectTypes/list.spec.js b/src/routes/projectTypes/list.spec.js index 05700bd..7074ae8 100644 --- a/src/routes/projectTypes/list.spec.js +++ b/src/routes/projectTypes/list.spec.js @@ -40,6 +40,9 @@ describe('LIST project types', () => { }, ]; + before((done) => { + testUtil.clearES(done); + }); beforeEach((done) => { testUtil.clearDb() .then(() => models.ProjectType.create(types[0])) diff --git a/src/routes/projectUpgrade/create.js b/src/routes/projectUpgrade/create.js index a1fe2fc..1037f00 100644 --- a/src/routes/projectUpgrade/create.js +++ b/src/routes/projectUpgrade/create.js @@ -45,9 +45,9 @@ async function findCompletedProjectEndDate(projectId, transaction) { */ function applyTemplate(template, source, destination) { if (!template || typeof template !== 'object') { return; } - if (!template.questions || !template.questions.length) { return; } + if (!template.sections || !template.sections.length) { return; } // questions field is actually array of sections - const templateQuestions = template.questions; + const templateQuestions = template.sections; // loop through for every section templateQuestions.forEach((section) => { // find subsections @@ -208,6 +208,7 @@ async function migrateFromV2ToV3(req, project, defaultProductTemplateId, phaseNa req.app.emit(EVENT.ROUTING_KEY.PROJECT_UPDATED, { req, + original: previousValue, updated: _.assign({ resource: RESOURCES.PROJECT }, project.toJSON()), }); } diff --git a/src/routes/projectUpgrade/create.spec.js b/src/routes/projectUpgrade/create.spec.js index 9ce5641..c0a1b68 100644 --- a/src/routes/projectUpgrade/create.spec.js +++ b/src/routes/projectUpgrade/create.spec.js @@ -107,7 +107,7 @@ describe('Project upgrade', () => { alias2: [1, 2, 3], }, template: { - questions: [ + sections: [ { subSections: [ { fieldName: 'details.name' }, diff --git a/src/routes/projects/create.js b/src/routes/projects/create.js index 40278d2..9e5214d 100644 --- a/src/routes/projects/create.js +++ b/src/routes/projects/create.js @@ -8,7 +8,7 @@ import moment from 'moment'; import models from '../../models'; import { PROJECT_MEMBER_ROLE, MANAGER_ROLES, PROJECT_STATUS, PROJECT_PHASE_STATUS, - EVENT, RESOURCES, REGEX } from '../../constants'; + EVENT, RESOURCES, REGEX, WORKSTREAM_STATUS } from '../../constants'; import fieldLookupValidation from '../../middlewares/fieldLookupValidation'; import util from '../../util'; @@ -24,7 +24,7 @@ const traverse = require('traverse'); */ const permissions = require('tc-core-library-js').middleware.permissions; -const createProjectValdiations = { +const createProjectValidations = { body: Joi.object().keys({ name: Joi.string().required(), description: Joi.string().allow(null).allow('').optional(), @@ -37,6 +37,10 @@ const createProjectValdiations = { bookmarks: Joi.array().items(Joi.object().keys({ title: Joi.string(), address: Joi.string().regex(REGEX.URL), + createdAt: Joi.date(), + createdBy: Joi.number().integer().positive(), + updatedAt: Joi.date(), + updatedBy: Joi.number().integer().positive(), })).optional().allow(null), estimatedPrice: Joi.number().precision(2).positive().optional() .allow(null), @@ -64,18 +68,117 @@ const createProjectValdiations = { buildingBlockKey: Joi.string().required(), metadata: Joi.object().optional(), })).optional(), + attachments: Joi.array().items(Joi.object().keys({ + category: Joi.string().required(), + contentType: Joi.string().required(), + description: Joi.string().allow(null).allow('').optional(), + filePath: Joi.string().required(), + size: Joi.number().required(), + title: Joi.string().required(), + })).optional(), }).required(), }; +/** + * Create ProjectEstimationItem with BuildingBlock. + * @param {Array} estimations the project estimations + * @param {Number} userId the request user id + * @returns {Promise} the promise that resolves to the created ProjectEstimationItem + */ +function createEstimationItemsWithBuildingBlock(estimations, userId) { + const buildingBlockKeys = _.map(estimations, estimation => estimation.buildingBlockKey); + // get all building blocks + return models.BuildingBlock.findAll({ + where: { deletedAt: { $eq: null }, key: buildingBlockKeys }, + raw: true, + includePrivateConfigForInternalUsage: true, + }).then((buildingBlocks) => { + const blocks = {}; + _.forEach(buildingBlocks, (block) => { + if (block) { + blocks[block.key] = block; + } + }); + const estimationItems = []; + _.forEach(estimations, (estimation) => { + const block = blocks[estimation.buildingBlockKey]; + if (block && _.get(block, 'privateConfig.priceItems')) { + _.forOwn(block.privateConfig.priceItems, (item, key) => { + let itemPrice; + if (_.isString(item) && item.endsWith('%')) { + const percent = _.toNumber(item.replace('%', '')) / 100; + itemPrice = _.toNumber(estimation.price) * percent; + } else { + itemPrice = item; + } + estimationItems.push({ + projectEstimationId: estimation.id, + price: itemPrice, + type: key, + markupUsedReference: 'buildingBlock', + markupUsedReferenceId: block.id, + createdBy: userId, + updatedBy: userId, + }); + }); + } + }); + + return models.ProjectEstimationItem.bulkCreate(estimationItems, { returning: true }); + }); +} + +/** + * Create workstreams for newly created project based on provided workstreams config + * and project details + * + * @param {Object} req express request object + * @param {Object} newProject new created project + * @param {Object} workstreamsConfig config of workstreams to create + * + * @returns {Promise} the list of created WorkStreams + */ +function createWorkstreams(req, newProject, workstreamsConfig) { + if (!workstreamsConfig) { + req.log.debug('no workstream config found'); + return Promise.resolve([]); + } + + req.log.debug('creating project workstreams'); + + // get value of the field in the project data which would determine which workstream types to create + const projectFieldValue = _.get(newProject, workstreamsConfig.projectFieldName); + + // the list of workstream types to create, based on the project field values + // mapping provided in `workstreamTypesToProjectValues` + const workstreamTypesToCreate = _.keys(_.pickBy(workstreamsConfig.workstreamTypesToProjectValues, fieldValues => ( + _.intersection(fieldValues, projectFieldValue).length > 0 + ))); + + // the list workstreams to create + const workstreamsToCreate = _.filter(workstreamsConfig.workstreams, workstream => ( + _.includes(workstreamTypesToCreate, workstream.type) + )).map(workstreamToCreate => _.assign({}, workstreamToCreate, { + projectId: newProject.id, + status: WORKSTREAM_STATUS.DRAFT, + createdBy: req.authUser.userId, + updatedBy: req.authUser.userId, + })); + + return models.WorkStream.bulkCreate(workstreamsToCreate); + // return Promise.resolve(workstreamsToCreate); +} + /** * Create the project, project phases and products. This needs to be done before creating direct project. * @param {Object} req the request * @param {Object} project the project * @param {Object} projectTemplate the project template - * @param {Array} productTemplates array of the templates of the products used in the projec template + * @param {Array} productTemplates array of the templates of the products used in the project template + * @param {Array} phasesList list phases definitions to create * @returns {Promise} the promise that resolves to the created project and phases */ -function createProjectAndPhases(req, project, projectTemplate, productTemplates) { +function createProjectAndPhases(req, project, projectTemplate, productTemplates, phasesList) { const result = { newProject: null, newPhases: [], @@ -103,63 +206,94 @@ function createProjectAndPhases(req, project, projectTemplate, productTemplates) } return Promise.resolve(newProject); }).then((newProject) => { + req.log.debug('creating project estimation items with building blocks'); + if (result.estimations && result.estimations.length > 0) { + return createEstimationItemsWithBuildingBlock(result.estimations, req.authUser.userId) + .then((estimationItems) => { + req.log.debug(`creating ${estimationItems.length} project estimation items`); + // ignore project estimation items for now + return Promise.resolve(newProject); + }); + } + return Promise.resolve(newProject); + }).then((newProject) => { + if (project.attachments && (project.attachments.length > 0)) { + req.log.debug('creating project attachments'); + const attachments = project.attachments.map(attachment => Object.assign({ + projectId: newProject.id, + createdBy: req.authUser.userId, + updatedBy: req.authUser.userId, + }, attachment)); + return models.ProjectAttachment.bulkCreate(attachments, { returning: true }).then((projectAttachments) => { + result.attachments = _.map(projectAttachments, attachment => + _.omit(attachment.toJSON(), ['deletedAt', 'deletedBy'])); + return Promise.resolve(newProject); + }); + } + return Promise.resolve(newProject); + }) + .then((newProject) => { result.newProject = newProject; // backward compatibility for releasing the service before releasing the front end if (!projectTemplate) { return Promise.resolve(result); } - const phases = _.filter(_.values(projectTemplate.phases), p => !!p); const productTemplateMap = {}; productTemplates.forEach((pt) => { productTemplateMap[pt.id] = pt; }); - 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 - return models.ProjectPhase.create({ - projectId: newProject.id, - name: _.get(phase, 'name', `Stage ${phaseIdx}`), - 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, - createdBy: req.authUser.userId, - }).then((newPhase) => { - req.log.debug(`Creating products in the newly created phase ${newPhase.id}`); - // Create products - return models.PhaseProduct.bulkCreate(_.map(phase.products, (product, productIndex) => ({ - phaseId: newPhase.id, + + if (phasesList) { + return Promise.all(_.map(phasesList, (phase, phaseIdx) => { + const duration = _.get(phase, 'duration', 1); + const startDate = moment.utc().hours(0).minutes(0).seconds(0) + .milliseconds(0); + // Create phase + return models.ProjectPhase.create({ projectId: newProject.id, - estimatedPrice: _.get(product, 'estimatedPrice', 0), - name: _.get(product, 'name', _.get(productTemplateMap, `${product.id}.name`, `Product ${productIndex}`)), - // assumes that phase template always contains id of each product - templateId: parseInt(product.id, 10), + name: _.get(phase, 'name', `Stage ${phaseIdx}`), + 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, createdBy: req.authUser.userId, - })), { returning: true }) - .then((products) => { - // Add phases and products to the project JSON, so they can be stored to ES later - const newPhaseJson = _.omit(newPhase.toJSON(), ['deletedAt', 'deletedBy']); - newPhaseJson.products = _.map(products, product => - _.omit(product.toJSON(), ['deletedAt', 'deletedBy'])); - result.newPhases.push(newPhaseJson); - return Promise.resolve(); + }).then((newPhase) => { + req.log.debug(`Creating products in the newly created phase ${newPhase.id}`); + // Create products + return models.PhaseProduct.bulkCreate(_.map(phase.products, (product, productIndex) => ({ + phaseId: newPhase.id, + projectId: newProject.id, + estimatedPrice: _.get(product, 'estimatedPrice', 0), + name: _.get(product, 'name', _.get(productTemplateMap, `${product.id}.name`, `Product ${productIndex}`)), + // assumes that phase template always contains id of each product + templateId: parseInt(product.id, 10), + updatedBy: req.authUser.userId, + createdBy: req.authUser.userId, + })), { returning: true }) + .then((products) => { + // Add phases and products to the project JSON, so they can be stored to ES later + const newPhaseJson = _.omit(newPhase.toJSON(), ['deletedAt', 'deletedBy']); + newPhaseJson.products = _.map(products, product => + _.omit(product.toJSON(), ['deletedAt', 'deletedBy'])); + result.newPhases.push(newPhaseJson); + return Promise.resolve(); + }); }); - }); - })); - }).then(() => Promise.resolve(result)); + })); + } + return Promise.resolve(); + }) + .then(() => Promise.resolve(result)); } /** * Validates the project and product templates for the give project template id. * * @param {Integer} templateId id of the project template which should be validated - * @returns {Promise} the promise that resolves to an object containing validated project and product templates + * @returns {Promise} the promise that resolves to an object containing validated project, product templates and phases list */ function validateAndFetchTemplates(templateId) { // backward compatibility for releasing the service before releasing the front end @@ -176,40 +310,72 @@ function validateAndFetchTemplates(templateId) { return Promise.resolve(existingProjectTemplate); }) .then((projectTemplate) => { - const phases = _.values(projectTemplate.phases); + // for old projectTemplate with `phases` just get phases config directly from projectTemplate + if (projectTemplate.phases) { + // for now support both ways: creating phases and creating workstreams + const phasesList = _(projectTemplate.phases).omit('workstreamsConfig').values().value(); + const workstreamsConfig = _.get(projectTemplate.phases, 'workstreamsConfig'); + + return { projectTemplate, phasesList, workstreamsConfig }; + } + + // for new projectTemplates try to get phases from the `planConfig`, if it's defined + if (projectTemplate.planConfig) { + return models.PlanConfig.findOneWithLatestRevision(projectTemplate.planConfig).then((planConfig) => { + if (!planConfig) { + const apiErr = new Error(`Cannot find planConfig ${JSON.stringify(projectTemplate.planConfig)}`); + apiErr.status = 400; + throw apiErr; + } + + // for now support both ways: creating phases and creating workstreams + const phasesList = _(planConfig.config).omit('workstreamsConfig').values().value(); + const workstreamsConfig = _.get(planConfig.config, 'workstreamsConfig'); + + return { projectTemplate, phasesList, workstreamsConfig }; + }); + } + + return { projectTemplate }; + }) + .then(({ projectTemplate, phasesList, workstreamsConfig }) => { const productPromises = []; - phases.forEach((phase) => { - // Make sure number of products of per phase <= max value - const productCount = _.isArray(phase.products) ? phase.products.length : 0; - if (productCount > config.maxPhaseProductCount) { - const apiErr = new Error(`Number of products per phase cannot exceed ${config.maxPhaseProductCount}`); - apiErr.status = 400; - throw apiErr; - } - _.map(phase.products, (product) => { - productPromises.push(models.ProductTemplate.findByPk(product.id) - .then((productTemplate) => { - if (!productTemplate) { - // Not found - const apiErr = new Error(`Product template not found for id ${product.id}`); - apiErr.status = 400; - return Promise.reject(apiErr); - } - return Promise.resolve(productTemplate); - })); + if (phasesList) { + phasesList.forEach((phase) => { + // Make sure number of products of per phase <= max value + const productCount = _.isArray(phase.products) ? phase.products.length : 0; + if (productCount > config.maxPhaseProductCount) { + const apiErr = new Error(`Number of products per phase cannot exceed ${config.maxPhaseProductCount}`); + apiErr.status = 400; + throw apiErr; + } + _.map(phase.products, (product) => { + productPromises.push(models.ProductTemplate.findByPk(product.id) + .then((productTemplate) => { + if (!productTemplate) { + // Not found + const apiErr = new Error(`Product template not found for id ${product.id}`); + apiErr.status = 400; + return Promise.reject(apiErr); + } + return Promise.resolve(productTemplate); + })); + }); }); - }); + } if (productPromises.length > 0) { - return Promise.all(productPromises).then(productTemplates => ({ projectTemplate, productTemplates })); + return Promise.all(productPromises).then(productTemplates => ( + { projectTemplate, productTemplates, phasesList, workstreamsConfig } + )); } // if there is no phase or product in a phase is specified, return empty product templates - return Promise.resolve({ projectTemplate, productTemplates: [] }); + return Promise.resolve({ projectTemplate, productTemplates: [], phasesList, workstreamsConfig }); }); } module.exports = [ // handles request validations - validate(createProjectValdiations), + validate(createProjectValidations), permissions('project.create'), fieldLookupValidation(models.ProjectType, 'key', 'body.type', 'Project type'), /** @@ -260,19 +426,30 @@ module.exports = [ let newProject = null; let newPhases; let projectEstimations; + let projectAttachments; models.sequelize.transaction(() => { req.log.debug('Create Project - Starting transaction'); // Validate the templates return validateAndFetchTemplates(project.templateId) // Create project and phases - .then(({ projectTemplate, productTemplates }) => { + .then(({ projectTemplate, productTemplates, phasesList, workstreamsConfig }) => { req.log.debug('Creating project, phase and products'); - return createProjectAndPhases(req, project, projectTemplate, productTemplates); + // only if workstream config is provided, treat such project as using workstreams + // otherwise project would still use phases + if (workstreamsConfig) { + _.set(project, 'details.settings.workstreams', true); + } + return createProjectAndPhases(req, project, projectTemplate, productTemplates, phasesList) + .then(createdProjectAndPhases => + createWorkstreams(req, createdProjectAndPhases.newProject, workstreamsConfig) + .then(() => createdProjectAndPhases), + ); }) .then((createdProjectAndPhases) => { newProject = createdProjectAndPhases.newProject; newPhases = createdProjectAndPhases.newPhases; projectEstimations = createdProjectAndPhases.estimations; + projectAttachments = createdProjectAndPhases.attachments; req.log.debug('new project created (id# %d, name: %s)', newProject.id, newProject.name); // create direct project with name and description @@ -286,21 +463,22 @@ module.exports = [ } req.log.debug('creating project history for project %d', newProject.id); // add to project history asynchronously, don't wait for it to complete - return models.ProjectHistory.create({ + models.ProjectHistory.create({ projectId: newProject.id, status: PROJECT_STATUS.DRAFT, cancelReason: null, updatedBy: req.authUser.userId, }).then(() => req.log.debug('project history created for project %d', newProject.id)) .catch(() => req.log.error('project history failed for project %d', newProject.id)); + 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 = []; + // add the project attachments, if any + newProject.attachments = projectAttachments; // set phases array newProject.phases = newPhases; // sets estimations array diff --git a/src/routes/projects/create.spec.js b/src/routes/projects/create.spec.js index a8e0a0f..0249784 100644 --- a/src/routes/projects/create.spec.js +++ b/src/routes/projects/create.spec.js @@ -157,6 +157,96 @@ describe('Project create', () => { createdBy: 1, updatedBy: 2, }, + { + id: 4, + name: 'template with workstreams', + key: 'key 3', + category: 'category 3', + icon: 'http://example.com/icon3.ico', + question: 'question 3', + info: 'info 3', + aliases: [], + scope: {}, + phases: { + workstreamsConfig: { + projectFieldName: 'details.appDefinition.deliverables', + workstreamTypesToProjectValues: { + development: [ + 'dev-qa', + ], + design: [ + 'design', + ], + deployment: [ + 'deployment', + ], + qa: [ + 'dev-qa', + ], + }, + workstreams: [ + { + name: 'Design Workstream', + type: 'design', + }, + { + name: 'Development Workstream', + type: 'development', + }, + { + name: 'QA Workstream', + type: 'qa', + }, + { + name: 'Deployment Workstream', + typ: 'deployment', + }, + ], + }, + }, + createdBy: 1, + updatedBy: 2, + }, + ])) + .then(() => models.BuildingBlock.bulkCreate([ + { + id: 1, + key: 'BLOCK_KEY', + config: {}, + privateConfig: { + priceItems: { + community: 3456, + topcoder_service: '19%', + fee: 1234, + }, + }, + createdBy: 1, + updatedBy: 2, + }, + { + id: 2, + key: 'BLOCK_KEY2', + config: {}, + privateConfig: { + message: 'invalid config', + }, + createdBy: 1, + updatedBy: 2, + }, + { + id: 3, + key: 'BLOCK_KEY3', + config: {}, + privateConfig: { + priceItems: { + community: '34%', + topcoder_service: 6789, + fee: '56%', + }, + }, + createdBy: 1, + updatedBy: 2, + }, ])) .then(() => done()); }); @@ -277,31 +367,6 @@ describe('Project create', () => { .expect(400, done); }); - it('should return 201 if error to create direct project', (done) => { - const validBody = _.cloneDeep(body); - validBody.templateId = 3; - const mockHttpClient = _.merge(testUtil.mockHttpClient, { - post: () => Promise.reject(new Error('error message')), - }); - sandbox.stub(util, 'getHttpClient', () => mockHttpClient); - request(server) - .post('/v5/projects') - .set({ - Authorization: `Bearer ${testUtil.jwts.member}`, - }) - .send(validBody) - .expect('Content-Type', /json/) - .expect(201) - .end((err) => { - if (err) { - done(err); - } else { - server.services.pubsub.publish.calledWith('project.draft-created').should.be.true; - done(); - } - }); - }); - it('should return 201 if valid user and data', (done) => { const validBody = _.cloneDeep(body); validBody.templateId = 3; @@ -478,6 +543,80 @@ describe('Project create', () => { }); }); + it('should create project with workstreams if template has them defined', (done) => { + const mockHttpClient = _.merge(testUtil.mockHttpClient, { + post: () => Promise.resolve({ + status: 200, + data: { + id: 'requesterId', + version: 'v3', + result: { + success: true, + status: 200, + content: { + projectId: 128, + }, + }, + }, + }), + }); + sandbox.stub(util, 'getHttpClient', () => mockHttpClient); + request(server) + .post('/v5/projects') + .set({ + Authorization: `Bearer ${testUtil.jwts.member}`, + }) + .send(_.merge({ + templateId: 4, + details: { + appDefinition: { + deliverables: ['dev-qa', 'design'], + }, + }, + }, body)) + .expect('Content-Type', /json/) + .expect(201) + .end((err, res) => { + if (err) { + done(err); + } else { + const resJson = res.body; + should.exist(resJson); + should.exist(resJson.billingAccountId); + should.exist(resJson.name); + resJson.status.should.be.eql('draft'); + resJson.type.should.be.eql(body.type); + resJson.members.should.have.lengthOf(1); + resJson.members[0].role.should.be.eql('customer'); + resJson.members[0].userId.should.be.eql(40051331); + resJson.members[0].projectId.should.be.eql(resJson.id); + resJson.members[0].isPrimary.should.be.truthy; + resJson.bookmarks.should.have.lengthOf(1); + resJson.bookmarks[0].title.should.be.eql('title1'); + resJson.bookmarks[0].address.should.be.eql('http://www.address.com'); + resJson.phases.should.have.lengthOf(0); + server.services.pubsub.publish.calledWith('project.draft-created').should.be.true; + + // verify that project has been marked to use workstreams + resJson.details.settings.workstreams.should.be.true; + + // Check Workstreams records are created correctly + models.WorkStream.findAll({ + where: { + projectId: resJson.id, + }, + raw: true, + }).then((workStreams) => { + workStreams.length.should.be.eql(3); + _.filter(workStreams, { type: 'development', name: 'Development Workstream' }).length.should.be.eql(1); + _.filter(workStreams, { type: 'design', name: 'Design Workstream' }).length.should.be.eql(1); + _.filter(workStreams, { type: 'qa', name: 'QA Workstream' }).length.should.be.eql(1); + done(); + }).catch(done); + } + }); + }); + it('should return 201 if valid user and data (with estimation)', (done) => { const validBody = _.cloneDeep(body); validBody.estimation = [ @@ -609,7 +748,7 @@ describe('Project create', () => { projectEstimations[0].metadata.deliverable.should.be.eql('design'); projectEstimations[0].buildingBlockKey.should.be.eql('ZEPLIN_APP_ADDON_CA'); done(); - }); + }).catch(done); } }); }); @@ -697,5 +836,112 @@ describe('Project create', () => { } }); }); + + it('should create correct estimation items with estimation', (done) => { + const validBody = _.cloneDeep(body); + validBody.estimation = [ + { + conditions: '( HAS_DEV_DELIVERABLE && (ONLY_ONE_OS_MOBILE) )', + price: 1000, + minTime: 2, + maxTime: 2, + metadata: {}, + buildingBlockKey: 'BLOCK_KEY', + }, + { + conditions: '( HAS_DEV_DELIVERABLE && (ONLY_ONE_OS_MOBILE) )', + price: 1000, + minTime: 2, + maxTime: 2, + metadata: {}, + buildingBlockKey: 'BLOCK_KEY2', + }, + { + conditions: '( HAS_DEV_DELIVERABLE && (ONLY_ONE_OS_MOBILE) )', + price: 1000, + minTime: 2, + maxTime: 2, + metadata: {}, + buildingBlockKey: 'BLOCK_KEY3', + }, + ]; + validBody.templateId = 3; + const mockHttpClient = _.merge(testUtil.mockHttpClient, { + post: () => Promise.resolve({ + status: 200, + data: { + projectId: 128, + }, + }), + }); + sandbox.stub(util, 'getHttpClient', () => mockHttpClient); + request(server) + .post('/v5/projects') + .set({ + Authorization: `Bearer ${testUtil.jwts.member}`, + }) + .send(validBody) + .expect('Content-Type', /json/) + .expect(201) + .end((err, res) => { + if (err) { + done(err); + } else { + const resJson = res.body; + should.exist(resJson); + should.exist(resJson.name); + should.exist(resJson.estimations); + resJson.estimations.length.should.be.eql(3); + + const totalPromises = []; + // check estimation items one by one + _.forEach(resJson.estimations, estimation => models.ProjectEstimationItem.findAll({ + where: { + projectEstimationId: estimation.id, + }, + raw: true, + }).then((items) => { + totalPromises.concat(_.map(items, (item) => { + should.exist(item.type); + should.exist(item.price); + should.exist(item.markupUsedReference); + should.exist(item.markupUsedReferenceId); + + item.markupUsedReference.should.be.eql('buildingBlock'); + if (estimation.buildingBlockKey === 'BLOCK_KEY') { + if (item.type === 'community') { + item.price.should.be.eql(3456); + } else if (item.type === 'topcoder_service') { + item.price.should.be.eql(190); + } else if (item.type === 'fee') { + item.price.should.be.eql(1234); + } else { + return Promise.reject('estimation item type is not correct'); + } + } else if (estimation.buildingBlockKey === 'BLOCK_KEY2') { + return Promise.reject('should not create estimation item for invalid building block'); + } else if (estimation.buildingBlockKey === 'BLOCK_KEY3') { + if (item.type === 'community') { + item.price.should.be.eql(340); + } else if (item.type === 'topcoder_service') { + item.price.should.be.eql(6789); + } else if (item.type === 'fee') { + item.price.should.be.eql(560); + } else { + return Promise.reject('estimation item type is not correct'); + } + } else { + return Promise.reject('estimation building block key is not correct'); + } + return Promise.resolve(); + })); + })); + + Promise.all(totalPromises).then(() => { + done(); + }).catch(e => done(e)); + } + }); + }); }); }); diff --git a/src/routes/projects/delete.spec.js b/src/routes/projects/delete.spec.js index 9a9bc3d..5a4447d 100644 --- a/src/routes/projects/delete.spec.js +++ b/src/routes/projects/delete.spec.js @@ -19,8 +19,6 @@ const expectAfterDelete = (id, err, next) => { 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); @@ -29,7 +27,8 @@ const expectAfterDelete = (id, err, next) => { .set({ Authorization: `Bearer ${testUtil.jwts.admin}`, }) - .expect(404, next); + .expect(404) + .end(next); } }), 500); }; @@ -110,7 +109,8 @@ describe('Project delete test', () => { .set({ Authorization: `Bearer ${testUtil.jwts.copilot}`, }) - .expect(403, done); + .expect(403) + .end(done); }); it('should return 204 if project was successfully removed', (done) => { diff --git a/src/routes/projects/get.js b/src/routes/projects/get.js index 770f611..3169a0d 100644 --- a/src/routes/projects/get.js +++ b/src/routes/projects/get.js @@ -68,6 +68,10 @@ module.exports = [ }) .then((invites) => { project.invites = invites; + return models.ScopeChangeRequest.getProjectScopeChangeRequests(projectId); + }) + .then((scopeChangeRequests) => { + project.scopeChangeRequests = scopeChangeRequests; res.status(200).json(project); }) .catch(err => next(err)); diff --git a/src/routes/projects/get.spec.js b/src/routes/projects/get.spec.js index c697605..2731d0a 100644 --- a/src/routes/projects/get.spec.js +++ b/src/routes/projects/get.spec.js @@ -76,7 +76,8 @@ describe('GET Project', () => { it('should return 403 if user is not authenticated', (done) => { request(server) .get(`/v5/projects/${project2.id}`) - .expect(403, done); + .expect(403) + .end(done); }); it('should return 404 if requested project doesn\'t exist', (done) => { @@ -85,16 +86,18 @@ describe('GET Project', () => { .set({ Authorization: `Bearer ${testUtil.jwts.admin}`, }) - .expect(404, done); + .expect(404) + .end(done); }); - it('should return 404 if user does not have access to the project', (done) => { + it('should return 403 if user does not have access to the project', (done) => { request(server) .get(`/v5/projects/${project2.id}`) .set({ Authorization: `Bearer ${testUtil.jwts.member}`, }) - .expect(403, done); + .expect(403) + .end(done); }); it('should return the project when registerd member attempts to access the project', (done) => { diff --git a/src/routes/projects/list.js b/src/routes/projects/list.js old mode 100644 new mode 100755 diff --git a/src/routes/projects/list.spec.js b/src/routes/projects/list.spec.js index 5c826f1..7b1f00e 100644 --- a/src/routes/projects/list.spec.js +++ b/src/routes/projects/list.spec.js @@ -2,7 +2,7 @@ /* eslint-disable max-len */ import chai from 'chai'; import request from 'supertest'; -import sleep from 'sleep'; +// import sleep from 'sleep'; import config from 'config'; import models from '../../models'; import server from '../../app'; @@ -126,6 +126,7 @@ describe('LIST Project', () => { before(function inner(done) { this.timeout(10000); testUtil.clearDb() + .then(() => testUtil.clearES()) .then(() => { const p1 = models.Project.create({ type: 'generic', @@ -246,8 +247,9 @@ describe('LIST Project', () => { return Promise.all([esp1, esp2, esp3]); }).then(() => { // sleep for some time, let elasticsearch indices be settled - sleep.sleep(5); - done(); + // sleep.sleep(5); + testUtil.wait(done); + // done(); }); }); }); diff --git a/src/routes/projects/update.js b/src/routes/projects/update.js index 20beea4..6418bf1 100644 --- a/src/routes/projects/update.js +++ b/src/routes/projects/update.js @@ -56,6 +56,10 @@ const updateProjectValdiations = { bookmarks: Joi.array().items(Joi.object().keys({ title: Joi.string(), address: Joi.string().regex(REGEX.URL), + createdAt: Joi.date(), + createdBy: Joi.number().integer().positive(), + updatedAt: Joi.date(), + updatedBy: Joi.number().integer().positive(), })).optional().allow(null), type: Joi.string().max(45), details: Joi.any(), @@ -79,13 +83,53 @@ const updateProjectValdiations = { }), }; +/** + * Gets scopechange fields either from + * "template.scope" (for old templates) or from "form.scope" (for new templates). + * + * @param {Object} project The project object + * + * @returns {Array} - the scopeChangeFields + */ +const getScopeChangeFields = (project) => { + const scopeChangeFields = _.get(project, 'template.scope.scopeChangeFields'); + const getFromForm = _project => _.get(_project, 'template.form.config.scopeChangeFields'); + + return scopeChangeFields || getFromForm(project); +}; + +const isScopeUpdated = (existingProject, updatedProps) => { + const scopeFields = getScopeChangeFields(existingProject); + + if (scopeFields) { + for (let idx = 0; idx < scopeFields.length; idx += 1) { + const field = scopeFields[idx]; + const oldFieldValue = _.get(existingProject, field); + const updateFieldValue = _.get(updatedProps, field); + if (oldFieldValue !== updateFieldValue) { + return true; + } + } + } + return false; +}; + // NOTE- decided to disable all additional checks for now. const validateUpdates = (existingProject, updatedProps, req) => { const errors = []; switch (existingProject.status) { case PROJECT_STATUS.COMPLETED: - errors.push(`cannot update a project that is in ${existingProject.status}' state`); + errors.push(`cannot update a project that is in '${existingProject.status}' state`); break; + case PROJECT_STATUS.REVIEWED: + case PROJECT_STATUS.ACTIVE: + case PROJECT_STATUS.PAUSED: { + if (isScopeUpdated(existingProject, updatedProps)) { + // TODO commented to disable the scope change flow for immediate release + // errors.push(`Scope changes are not allowed for '${existingProject.status}' project`); + } + break; + } default: break; // disabling this check for now. @@ -150,14 +194,20 @@ module.exports = [ lock: { of: models.Project }, }) .then((_prj) => { - project = _prj; - if (!project) { + if (!_prj) { // handle 404 const err = new Error(`project not found for id ${projectId}`); err.status = 404; return Promise.reject(err); } + if (!_prj.templateId) return Promise.resolve({ _prj }); + return models.ProjectTemplate.getTemplate(_prj.templateId) + .then(template => Promise.resolve({ _prj, template })); + }) + .then(({ _prj, template }) => { + project = _prj; previousValue = _.clone(project.get({ plain: true })); + previousValue.template = template; // run additional validations const validationErrors = validateUpdates(previousValue, updatedProps, req); if (validationErrors.length > 0) { @@ -173,7 +223,9 @@ module.exports = [ const members = req.context.currentProjectMembers; const validRoles = [ PROJECT_MEMBER_ROLE.MANAGER, - PROJECT_MEMBER_ROLE.MANAGER, + PROJECT_MEMBER_ROLE.PROGRAM_MANAGER, + PROJECT_MEMBER_ROLE.PROJECT_MANAGER, + PROJECT_MEMBER_ROLE.SOLUTION_ARCHITECT, ].map(x => x.toLowerCase()); const matchRole = role => _.indexOf(validRoles, role.toLowerCase()) >= 0; if (updatedProps.status === PROJECT_STATUS.ACTIVE && @@ -228,6 +280,7 @@ module.exports = [ ); req.app.emit(EVENT.ROUTING_KEY.PROJECT_UPDATED, { req, + original: previousValue, updated: _.assign({ resource: RESOURCES.PROJECT }, project), }); diff --git a/src/routes/projects/update.spec.js b/src/routes/projects/update.spec.js index 78ebe96..ea49063 100644 --- a/src/routes/projects/update.spec.js +++ b/src/routes/projects/update.spec.js @@ -12,6 +12,7 @@ import busApi from '../../services/busApi'; import { PROJECT_STATUS, BUS_API_EVENT, + CONNECT_NOTIFICATION_EVENT, } from '../../constants'; const should = chai.should(); @@ -745,7 +746,7 @@ describe('Project', () => { createEventSpy = sandbox.spy(busApi, 'createEvent'); }); - it('sends single BUS_API_EVENT.PROJECT_UPDATED message on project status update', (done) => { + it('should send correct BUS API messages when project status updated', (done) => { request(server) .patch(`/v5/projects/${project1.id}`) .set({ @@ -753,7 +754,6 @@ describe('Project', () => { }) .send({ status: PROJECT_STATUS.COMPLETED, - }) .expect(200) .end((err) => { @@ -761,14 +761,32 @@ describe('Project', () => { done(err); } else { testUtil.wait(() => { - createEventSpy.calledOnce.should.be.true; + createEventSpy.callCount.should.equal(3); + + createEventSpy.calledWith(BUS_API_EVENT.PROJECT_UPDATED, sinon.match({ + resource: 'project', + id: project1.id, + status: PROJECT_STATUS.COMPLETED, + updatedBy: testUtil.userIds.admin, + })).should.be.true; + + // Check Notification Service events + createEventSpy.calledWith(CONNECT_NOTIFICATION_EVENT.PROJECT_COMPLETED).should.be.true; + createEventSpy.calledWith(CONNECT_NOTIFICATION_EVENT.PROJECT_UPDATED, sinon.match({ + projectId: project1.id, + projectName: project1.name, + projectUrl: `https://local.topcoder-dev.com/projects/${project1.id}`, + userId: 40051333, + initiatorUserId: 40051333, + })).should.be.true; + done(); }); } }); }); - it('sends single BUS_API_EVENT.PROJECT_UPDATED message on project details update', (done) => { + it('should send correct BUS API messages when project details updated', (done) => { request(server) .patch(`/v5/projects/${project1.id}`) .set({ @@ -785,22 +803,32 @@ describe('Project', () => { done(err); } else { testUtil.wait(() => { - createEventSpy.calledOnce.should.be.true; - createEventSpy.firstCall.calledWith(BUS_API_EVENT.PROJECT_UPDATED, - sinon.match({ resource: 'project' })).should.be.true; - createEventSpy.firstCall.calledWith(BUS_API_EVENT.PROJECT_UPDATED, - sinon.match({ id: project1.id })).should.be.true; - createEventSpy.firstCall.calledWith(BUS_API_EVENT.PROJECT_UPDATED, - sinon.match({ details: { info: 'something' } })).should.be.true; - createEventSpy.firstCall.calledWith(BUS_API_EVENT.PROJECT_UPDATED, - sinon.match({ updatedBy: testUtil.userIds.admin })).should.be.true; + createEventSpy.callCount.should.equal(3); + + createEventSpy.calledWith(BUS_API_EVENT.PROJECT_UPDATED, sinon.match({ + resource: 'project', + id: project1.id, + details: { info: 'something' }, + updatedBy: testUtil.userIds.admin, + })).should.be.true; + + // Check Notification Service events + createEventSpy.calledWith(CONNECT_NOTIFICATION_EVENT.PROJECT_SPECIFICATION_MODIFIED).should.be.true; + createEventSpy.calledWith(CONNECT_NOTIFICATION_EVENT.PROJECT_UPDATED, sinon.match({ + projectId: project1.id, + projectName: project1.name, + projectUrl: `https://local.topcoder-dev.com/projects/${project1.id}`, + userId: 40051333, + initiatorUserId: 40051333, + })).should.be.true; + done(); }); } }); }); - it('sends single BUS_API_EVENT.PROJECT_UPDATED message on project name update', (done) => { + it('should send correct BUS API messages when project name updated', (done) => { request(server) .patch(`/v5/projects/${project1.id}`) .set({ @@ -808,7 +836,6 @@ describe('Project', () => { }) .send({ name: 'New project name', - }) .expect(200) .end((err) => { @@ -816,22 +843,32 @@ describe('Project', () => { done(err); } else { testUtil.wait(() => { - createEventSpy.calledOnce.should.be.true; - createEventSpy.firstCall.calledWith(BUS_API_EVENT.PROJECT_UPDATED, - sinon.match({ resource: 'project' })).should.be.true; - createEventSpy.firstCall.calledWith(BUS_API_EVENT.PROJECT_UPDATED, - sinon.match({ id: project1.id })).should.be.true; - createEventSpy.firstCall.calledWith(BUS_API_EVENT.PROJECT_UPDATED, - sinon.match({ name: 'New project name' })).should.be.true; - createEventSpy.firstCall.calledWith(BUS_API_EVENT.PROJECT_UPDATED, - sinon.match({ updatedBy: testUtil.userIds.admin })).should.be.true; + createEventSpy.callCount.should.equal(3); + + createEventSpy.calledWith(BUS_API_EVENT.PROJECT_UPDATED, sinon.match({ + resource: 'project', + id: project1.id, + name: 'New project name', + updatedBy: testUtil.userIds.admin, + })).should.be.true; + + // Check Notification Service events + createEventSpy.calledWith(CONNECT_NOTIFICATION_EVENT.PROJECT_SPECIFICATION_MODIFIED).should.be.true; + createEventSpy.calledWith(CONNECT_NOTIFICATION_EVENT.PROJECT_UPDATED, sinon.match({ + projectId: project1.id, + projectName: 'New project name', + projectUrl: `https://local.topcoder-dev.com/projects/${project1.id}`, + userId: 40051333, + initiatorUserId: 40051333, + })).should.be.true; + done(); }); } }); }); - it('sends single BUS_API_EVENT.PROJECT_UPDATED message on project description update', (done) => { + it('should send correct BUS API messages when project description updated', (done) => { request(server) .patch(`/v5/projects/${project1.id}`) .set({ @@ -839,7 +876,6 @@ describe('Project', () => { }) .send({ description: 'Updated description', - }) .expect(200) .end((err) => { @@ -847,22 +883,32 @@ describe('Project', () => { done(err); } else { testUtil.wait(() => { - createEventSpy.calledOnce.should.be.true; - createEventSpy.firstCall.calledWith(BUS_API_EVENT.PROJECT_UPDATED, - sinon.match({ resource: 'project' })).should.be.true; - createEventSpy.firstCall.calledWith(BUS_API_EVENT.PROJECT_UPDATED, - sinon.match({ id: project1.id })).should.be.true; - createEventSpy.firstCall.calledWith(BUS_API_EVENT.PROJECT_UPDATED, - sinon.match({ description: 'Updated description' })).should.be.true; - createEventSpy.firstCall.calledWith(BUS_API_EVENT.PROJECT_UPDATED, - sinon.match({ updatedBy: testUtil.userIds.admin })).should.be.true; + createEventSpy.callCount.should.equal(3); + + createEventSpy.calledWith(BUS_API_EVENT.PROJECT_UPDATED, sinon.match({ + resource: 'project', + id: project1.id, + description: 'Updated description', + updatedBy: testUtil.userIds.admin, + })).should.be.true; + + // Check Notification Service events + createEventSpy.calledWith(CONNECT_NOTIFICATION_EVENT.PROJECT_SPECIFICATION_MODIFIED).should.be.true; + createEventSpy.calledWith(CONNECT_NOTIFICATION_EVENT.PROJECT_UPDATED, sinon.match({ + projectId: project1.id, + projectName: project1.name, + projectUrl: `https://local.topcoder-dev.com/projects/${project1.id}`, + userId: 40051333, + initiatorUserId: 40051333, + })).should.be.true; + done(); }); } }); }); - it('sends single BUS_API_EVENT.PROJECT_UPDATED message on project bookmarks update', (done) => { + it('should send correct BUS API messages when project bookmarks updated', (done) => { request(server) .patch(`/v5/projects/${project1.id}`) .set({ @@ -880,22 +926,32 @@ describe('Project', () => { done(err); } else { testUtil.wait(() => { - createEventSpy.calledOnce.should.be.true; - createEventSpy.firstCall.calledWith(BUS_API_EVENT.PROJECT_UPDATED, - sinon.match({ resource: 'project' })).should.be.true; - createEventSpy.firstCall.calledWith(BUS_API_EVENT.PROJECT_UPDATED, - sinon.match({ id: project1.id })).should.be.true; - createEventSpy.firstCall.calledWith(BUS_API_EVENT.PROJECT_UPDATED, - sinon.match({ bookmarks: [{ title: 'title1', address: 'http://someurl.com' }] })).should.be.true; - createEventSpy.firstCall.calledWith(BUS_API_EVENT.PROJECT_UPDATED, - sinon.match({ updatedBy: testUtil.userIds.admin })).should.be.true; + createEventSpy.callCount.should.equal(3); + + createEventSpy.calledWith(BUS_API_EVENT.PROJECT_UPDATED, sinon.match({ + resource: 'project', + id: project1.id, + bookmarks: [{ title: 'title1', address: 'http://someurl.com' }], + updatedBy: testUtil.userIds.admin, + })).should.be.true; + + // Check Notification Service events + createEventSpy.calledWith(CONNECT_NOTIFICATION_EVENT.PROJECT_LINK_CREATED).should.be.true; + createEventSpy.calledWith(CONNECT_NOTIFICATION_EVENT.PROJECT_UPDATED, sinon.match({ + projectId: project1.id, + projectName: project1.name, + projectUrl: `https://local.topcoder-dev.com/projects/${project1.id}`, + userId: 40051333, + initiatorUserId: 40051333, + })).should.be.true; + done(); }); } }); }); - it('should send BUS_API_EVENT.PROJECT_UPDATED message when project estimatedPrice is updated', (done) => { + it('should send correct BUS API messages when project estimatedPrice updated', (done) => { request(server) .patch(`/v5/projects/${project1.id}`) .set({ @@ -903,7 +959,6 @@ describe('Project', () => { }) .send({ estimatedPrice: 123, - }) .expect(200) .end((err) => { @@ -911,14 +966,23 @@ describe('Project', () => { done(err); } else { testUtil.wait(() => { - createEventSpy.called.should.be.true; + createEventSpy.callCount.should.equal(1); + + createEventSpy.calledWith(BUS_API_EVENT.PROJECT_UPDATED, sinon.match({ + resource: 'project', + id: project1.id, + // FIXME https://github.com/sequelize/sequelize/issues/8019 + // estimatedPrice: 123, + updatedBy: testUtil.userIds.admin, + })).should.be.true; + done(); }); } }); }); - it('should send BUS_API_EVENT.PROJECT_UPDATED message when project actualPrice is updated', (done) => { + it('should send correct BUS API messages when project actualPrice updated', (done) => { request(server) .patch(`/v5/projects/${project1.id}`) .set({ @@ -926,7 +990,6 @@ describe('Project', () => { }) .send({ actualPrice: 123, - }) .expect(200) .end((err) => { @@ -934,14 +997,23 @@ describe('Project', () => { done(err); } else { testUtil.wait(() => { - createEventSpy.called.should.be.true; + createEventSpy.callCount.should.equal(1); + + createEventSpy.calledWith(BUS_API_EVENT.PROJECT_UPDATED, sinon.match({ + resource: 'project', + id: project1.id, + // FIXME https://github.com/sequelize/sequelize/issues/8019 + // actualPrice: 123, + updatedBy: testUtil.userIds.admin, + })).should.be.true; + done(); }); } }); }); - it('should send BUS_API_EVENT.PROJECT_UPDATED message when project terms are updated', (done) => { + it('should send correct BUS API messages when project terms are updated', (done) => { request(server) .patch(`/v5/projects/${project1.id}`) .set({ @@ -949,7 +1021,6 @@ describe('Project', () => { }) .send({ terms: [1, 2, 3], - }) .expect(200) .end((err) => { @@ -957,7 +1028,15 @@ describe('Project', () => { done(err); } else { testUtil.wait(() => { - createEventSpy.called.should.be.true; + createEventSpy.callCount.should.equal(1); + + createEventSpy.calledWith(BUS_API_EVENT.PROJECT_UPDATED, sinon.match({ + resource: 'project', + id: project1.id, + terms: [1, 2, 3], + updatedBy: testUtil.userIds.admin, + })).should.be.true; + done(); }); } diff --git a/src/routes/scopeChangeRequests/create.js b/src/routes/scopeChangeRequests/create.js new file mode 100644 index 0000000..e63caae --- /dev/null +++ b/src/routes/scopeChangeRequests/create.js @@ -0,0 +1,85 @@ +import _ from 'lodash'; +import Joi from 'joi'; +import validate from 'express-validation'; +import { middleware as tcMiddleware } from 'tc-core-library-js'; +import { SCOPE_CHANGE_REQ_STATUS, PROJECT_MEMBER_ROLE, PROJECT_STATUS } from '../../constants'; +import models from '../../models'; + +/** + * API to add a scope change request for a project. + */ +const permissions = tcMiddleware.permissions; + +const createScopeChangeRequestValidations = { + body: { + oldScope: Joi.object(), + newScope: Joi.object(), + }, +}; + +module.exports = [ + // handles request validations + validate(createScopeChangeRequestValidations), + permissions('project.edit'), + (req, res, next) => { + const projectId = _.parseInt(req.params.projectId); + const oldScope = _.get(req, 'body.oldScope'); + const newScope = _.get(req, 'body.newScope'); + const members = req.context.currentProjectMembers; + const isCustomer = !_.isUndefined(_.find(members, + m => m.userId === req.authUser.userId && m.role === PROJECT_MEMBER_ROLE.CUSTOMER)); + + const scopeChange = { + oldScope, + newScope, + status: isCustomer ? SCOPE_CHANGE_REQ_STATUS.APPROVED : SCOPE_CHANGE_REQ_STATUS.PENDING, + projectId, + createdBy: req.authUser.userId, + updatedBy: req.authUser.userId, + }; + + return models.Project.findOne({ + where: { id: projectId }, + }) + + .then((project) => { + if (!project) { + const err = new Error(`Project with id ${projectId} not found`); + err.status = 404; + return Promise.reject(err); + } + + // If the project is not frozen yet, the changes can be saved directly into projects db. + // Scope change request workflow is not required. + const statusesForNonFrozenProjects = [PROJECT_STATUS.DRAFT, PROJECT_STATUS.IN_REVIEW]; + if (statusesForNonFrozenProjects.indexOf(project.status) > -1) { + const err = new Error( + `Cannot create a scope change request for projects with statuses: ${ + statusesForNonFrozenProjects.join(', ')}`); + err.status = 403; + return Promise.reject(err); + } + + return models.ScopeChangeRequest.findPendingScopeChangeRequest(projectId); + }) + + .then((pendingScopeChangeReq) => { + if (pendingScopeChangeReq) { + const err = new Error('Cannot create a new scope change request while there is a pending request'); + err.status = 403; + return Promise.reject(err); + } + + req.log.debug('creating scope change request'); + return models.ScopeChangeRequest.create(scopeChange); + }) + + .then((_newScopeChange) => { + req.log.debug('Created scope change request'); + res.json(_newScopeChange); + return Promise.resolve(); + }) + + .catch(err => next(err)); + }, +]; diff --git a/src/routes/scopeChangeRequests/create.spec.js b/src/routes/scopeChangeRequests/create.spec.js new file mode 100644 index 0000000..ca15f65 --- /dev/null +++ b/src/routes/scopeChangeRequests/create.spec.js @@ -0,0 +1,280 @@ +import sinon from 'sinon'; +import request from 'supertest'; +import _ from 'lodash'; +import Promise from 'bluebird'; + +import models from '../../models'; +import server from '../../app'; +import testUtil from '../../tests/util'; + +import { PROJECT_STATUS, PROJECT_MEMBER_ROLE, SCOPE_CHANGE_REQ_STATUS } from '../../constants'; + +/** + * Creates a project with given status + * @param {string} status - Status of the project + * + * @returns {Promise} - promise for project creation + */ +function createProject(status) { + const newMember = (userId, role, project) => ({ + userId, + projectId: project.id, + role, + isPrimary: true, + createdBy: 1, + updatedBy: 1, + }); + + return models.Project.create({ + type: 'generic', + billingAccountId: 1, + name: 'test1', + description: 'test project1', + status, + details: {}, + createdBy: 1, + updatedBy: 1, + lastActivityAt: 1, + lastActivityUserId: '1', + }).then(project => + Promise.all([ + models.ProjectMember.create(newMember(testUtil.userIds.member, PROJECT_MEMBER_ROLE.CUSTOMER, project)), + models.ProjectMember.create(newMember(testUtil.userIds.manager, PROJECT_MEMBER_ROLE.MANAGER, project)), + ]).then(() => project), + ); +} + +/** + * creates a new scope change request object + * @returns {Object} - scope change request object + */ +function newScopeChangeRequest() { + return { + newScope: { + appDefinition: { + numberScreens: '5-8', + }, + }, + oldScope: { + appDefinition: { + numberScreens: '2-4', + }, + }, + }; +} + +/** + * Asserts the status of the Scope change request + * @param {Object} response - Response object from the post service + * @param {string} expectedStatus - Expected status of the Scope Change Request + * + * @returns {undefined} - throws error if assertion failed + */ +function assertStatus(response, expectedStatus) { + const status = _.get(response, 'body.status'); + sinon.assert.match(status, expectedStatus); +} + +/** + * Updaes the status of scope change requests for the given project in db + * @param {Object} project - the project + * @param {string} status - the new status for update + * + * @returns {Promise} the promise to update the status + */ +function updateScopeChangeStatuses(project, status) { + return models.ScopeChangeRequest.update({ status }, { where: { projectId: project.id } }); +} + + +describe('Create Scope Change Rquest', () => { + let projects; + let projectWithPendingChange; + let projectWithApprovedChange; + + before((done) => { + const projectStatuses = [ + PROJECT_STATUS.DRAFT, + PROJECT_STATUS.IN_REVIEW, + PROJECT_STATUS.REVIEWED, + PROJECT_STATUS.ACTIVE, + ]; + + Promise.all(projectStatuses.map(status => createProject(status))) + .then(_projects => _projects.map((project, i) => [projectStatuses[i], project])) + .then((_projectStatusPairs) => { + projects = _.fromPairs(_projectStatusPairs); + }) + .then(() => done()); + }); + + after((done) => { + testUtil.clearDb(done); + }); + + describe('POST projects/{projectId}/scopeChangeRequests', () => { + it('Should create scope change request for project in reviewed status', (done) => { + const project = projects[PROJECT_STATUS.REVIEWED]; + + request(server) + .post(`/v5/projects/${project.id}/scopeChangeRequests`) + .set({ + Authorization: `Bearer ${testUtil.jwts.manager}`, + }) + .send(newScopeChangeRequest()) + .expect(200) + .end((err, res) => { + if (err) { + done(err); + } else { + projectWithPendingChange = project; + + assertStatus(res, SCOPE_CHANGE_REQ_STATUS.PENDING); + done(); + } + }); + }); + + it('Should create scope change request for project in active status', (done) => { + const project = projects[PROJECT_STATUS.ACTIVE]; + + request(server) + .post(`/v5/projects/${project.id}/scopeChangeRequests`) + .set({ + Authorization: `Bearer ${testUtil.jwts.member}`, + }) + .send(newScopeChangeRequest()) + .expect(200) + .end((err, res) => { + if (err) { + done(err); + } else { + projectWithApprovedChange = project; + + assertStatus(res, SCOPE_CHANGE_REQ_STATUS.APPROVED); + done(); + } + }); + }); + + it('Should return error with status 403 if project is in draft status', (done) => { + const project = projects[PROJECT_STATUS.DRAFT]; + + request(server) + .post(`/v5/projects/${project.id}/scopeChangeRequests`) + .set({ + Authorization: `Bearer ${testUtil.jwts.member}`, + }) + .send(newScopeChangeRequest()) + .expect(403) + .end(err => done(err)); + }); + + it('Should return error with status 403 if project is in in_review status', (done) => { + const project = projects[PROJECT_STATUS.IN_REVIEW]; + + request(server) + .post(`/v5/projects/${project.id}/scopeChangeRequests`) + .set({ + Authorization: `Bearer ${testUtil.jwts.member}`, + }) + .send(newScopeChangeRequest()) + .expect(403) + .end(err => done(err)); + }); + + it('Should return error with status 404 if project not present', (done) => { + const nonExistentProjectId = 341212; + request(server) + .post(`/v5/projects/${nonExistentProjectId}/scopeChangeRequests`) + .set({ + Authorization: `Bearer ${testUtil.jwts.manager}`, + }) + .send(newScopeChangeRequest()) + .expect(404) + .end(err => done(err)); + }); + + it('Should return error with status 403 if there is a request in pending status', (done) => { + request(server) + .post(`/v5/projects/${projectWithPendingChange.id}/scopeChangeRequests`) + .set({ + Authorization: `Bearer ${testUtil.jwts.member}`, + }) + .send(newScopeChangeRequest()) + .expect(403) + .end(err => done(err)); + }); + + it('Should return error with status 403 if there is a request in approved status', (done) => { + request(server) + .post(`/v5/projects/${projectWithApprovedChange.id}/scopeChangeRequests`) + .set({ + Authorization: `Bearer ${testUtil.jwts.member}`, + }) + .send(newScopeChangeRequest()) + .expect(403) + .end(err => done(err)); + }); + + it('Should create scope change request if there is a request in canceled status', (done) => { + updateScopeChangeStatuses(projectWithApprovedChange, SCOPE_CHANGE_REQ_STATUS.CANCELED).then(() => { + request(server) + .post(`/v5/projects/${projectWithApprovedChange.id}/scopeChangeRequests`) + .set({ + Authorization: `Bearer ${testUtil.jwts.member}`, + }) + .send(newScopeChangeRequest()) + .expect(200) + .end((err, res) => { + if (err) { + done(err); + } else { + assertStatus(res, SCOPE_CHANGE_REQ_STATUS.APPROVED); + done(); + } + }); + }); + }); + + it('Should create scope change request if there is a request in rejected status', (done) => { + updateScopeChangeStatuses(projectWithApprovedChange, SCOPE_CHANGE_REQ_STATUS.REJECTED).then(() => { + request(server) + .post(`/v5/projects/${projectWithApprovedChange.id}/scopeChangeRequests`) + .set({ + Authorization: `Bearer ${testUtil.jwts.member}`, + }) + .send(newScopeChangeRequest()) + .expect(200) + .end((err, res) => { + if (err) { + done(err); + } else { + assertStatus(res, SCOPE_CHANGE_REQ_STATUS.APPROVED); + done(); + } + }); + }); + }); + + it('Should create scope change request if there is a request in activated status', (done) => { + updateScopeChangeStatuses(projectWithApprovedChange, SCOPE_CHANGE_REQ_STATUS.ACTIVATED).then(() => { + request(server) + .post(`/v5/projects/${projectWithApprovedChange.id}/scopeChangeRequests`) + .set({ + Authorization: `Bearer ${testUtil.jwts.member}`, + }) + .send(newScopeChangeRequest()) + .expect(200) + .end((err, res) => { + if (err) { + done(err); + } else { + assertStatus(res, SCOPE_CHANGE_REQ_STATUS.APPROVED); + done(); + } + }); + }); + }); + }); +}); diff --git a/src/routes/scopeChangeRequests/update.js b/src/routes/scopeChangeRequests/update.js new file mode 100644 index 0000000..1f1e8ea --- /dev/null +++ b/src/routes/scopeChangeRequests/update.js @@ -0,0 +1,127 @@ +import _ from 'lodash'; +import Joi from 'joi'; +import validate from 'express-validation'; +import { middleware as tcMiddleware } from 'tc-core-library-js'; +import util from '../../util'; +import { + SCOPE_CHANGE_REQ_STATUS, + PROJECT_MEMBER_ROLE, + USER_ROLE, + PROJECT_MEMBER_MANAGER_ROLES, + EVENT, +} from '../../constants'; +import models from '../../models'; + +/** + * API to add a scope change request for a project. + */ +const permissions = tcMiddleware.permissions; + +const updateScopeChangeRequestValidations = { + body: { + status: Joi.string().valid(_.values(SCOPE_CHANGE_REQ_STATUS)), + }, +}; + +/** + * Merges the new scope that's being activated into the details json of the project and updates the db + * @param {Object} req The request object + * @param {Object} newScope The new scope to apply + * @param {string} projectId The project id + * + * @returns {Promise} The promise to update the project with merged data + */ +function updateProjectDetails(req, newScope, projectId) { + return models.Project.findByPk(projectId).then((project) => { + const previousValue = _.clone(project.get({ plain: true })); + + if (!project) { + const err = new Error('Project not found'); + err.status = 404; + return Promise.reject(err); + } + + const updatedDetails = _.mergeWith( + {}, project.details, newScope, + (_objValue, srcValue) => { + if (_.isArray(srcValue)) { + return srcValue; + } + return undefined; + }); + + return project.update({ details: updatedDetails }).then((updatedProject) => { + const updated = updatedProject.get({ plain: true }); + const original = _.omit(previousValue, ['deletedAt', 'deletedBy']); + + // publish original and updated project data + req.app.services.pubsub.publish( + EVENT.ROUTING_KEY.PROJECT_UPDATED, + { original, updated }, + { correlationId: req.id }, + ); + req.app.emit(EVENT.ROUTING_KEY.PROJECT_UPDATED, { req, original, updated }); + + return updatedProject; + }); + }); +} + +module.exports = [ + // handles request validations + validate(updateScopeChangeRequestValidations), + permissions('project.edit'), + (req, res, next) => { + const projectId = _.parseInt(req.params.projectId); + const requestId = _.parseInt(req.params.requestId); + const updatedProps = req.body; + const members = req.context.currentProjectMembers; + const member = _.find(members, m => m.userId === req.authUser.userId); + const isCustomer = member && member.role === PROJECT_MEMBER_ROLE.CUSTOMER; + // const isCopilot = member && member.role === PROJECT_MEMBER_ROLE.COPILOT; + const isManager = member && PROJECT_MEMBER_MANAGER_ROLES.indexOf(member.role) > -1; + const isAdmin = util.hasRoles(req, [USER_ROLE.CONNECT_ADMIN, USER_ROLE.TOPCODER_ADMIN]); + + req.log.debug('finding scope change', requestId); + return models.ScopeChangeRequest.findScopeChangeRequest(projectId, { requestId }) + .then((scopeChangeReq) => { + // req.log.debug(scopeChangeReq); + if (!scopeChangeReq) { + const err = new Error('Scope change request does not exist'); + err.status = 404; + return next(err); + } + const statusesForCustomers = [SCOPE_CHANGE_REQ_STATUS.APPROVED, SCOPE_CHANGE_REQ_STATUS.REJECTED]; + if (statusesForCustomers.indexOf(updatedProps.status) > -1 && !isCustomer && !isAdmin) { + const err = new Error('Only customer can approve the request'); + err.status = 401; + return next(err); + } + const statusesForManagers = [SCOPE_CHANGE_REQ_STATUS.ACTIVATED]; + if (statusesForManagers.indexOf(updatedProps.status) > -1 && !isManager && !isAdmin) { + const err = new Error('Only managers can activate the request'); + err.status = 401; + return next(err); + } + const statusesForSelf = [SCOPE_CHANGE_REQ_STATUS.CANCELED]; + const isSelf = scopeChangeReq.createdBy === req.authUser.userId; + if (statusesForSelf.indexOf(updatedProps.status) > -1 && !isSelf && !isAdmin) { + const err = new Error('One can cancel only own requests'); + err.status = 401; + return next(err); + } + + return ( + updatedProps.status === SCOPE_CHANGE_REQ_STATUS.ACTIVATED + ? updateProjectDetails(req, scopeChangeReq.newScope, projectId) + : Promise.resolve() + ) + .then(() => scopeChangeReq.update(updatedProps)) + .then((_updatedReq) => { + res.json(_updatedReq); + return Promise.resolve(); + }); + }) + .catch(err => next(err)); + }, +]; diff --git a/src/routes/scopeChangeRequests/update.spec.js b/src/routes/scopeChangeRequests/update.spec.js new file mode 100644 index 0000000..cbceca5 --- /dev/null +++ b/src/routes/scopeChangeRequests/update.spec.js @@ -0,0 +1,213 @@ +import sinon from 'sinon'; +import request from 'supertest'; +import _ from 'lodash'; + +import models from '../../models'; +import server from '../../app'; +import testUtil from '../../tests/util'; + +import { PROJECT_STATUS, PROJECT_MEMBER_ROLE, SCOPE_CHANGE_REQ_STATUS } from '../../constants'; + +/** + * Creates a project with given status + * @param {string} status - Status of the project + * + * @returns {Promise} - promise for project creation + */ +function createProject(status) { + const newMember = (userId, role, project) => ({ + userId, + projectId: project.id, + role, + isPrimary: true, + createdBy: 1, + updatedBy: 1, + }); + + return models.Project.create({ + type: 'generic', + billingAccountId: 1, + name: 'test1', + description: 'test project1', + status, + details: {}, + createdBy: 1, + updatedBy: 1, + lastActivityAt: 1, + lastActivityUserId: '1', + }).then(project => + Promise.all([ + models.ProjectMember.create(newMember(testUtil.userIds.member, PROJECT_MEMBER_ROLE.CUSTOMER, project)), + models.ProjectMember.create(newMember(testUtil.userIds.manager, PROJECT_MEMBER_ROLE.MANAGER, project)), + ]).then(() => project), + ); +} + +/** + * Asserts the status of the Scope change request + * @param {Object} updatedScopeChangeRequest - the updated scope change request from db + * @param {string} expectedStatus - Expected status of the Scope Change Request + * + * @returns {undefined} - throws error if assertion failed + */ +function assertStatus(updatedScopeChangeRequest, expectedStatus) { + sinon.assert.match(updatedScopeChangeRequest.status, expectedStatus); +} + +/** + * create scope change request for the given project + * @param {Object} project - the project + * + * @returns {Promise} - the promise to create scope change request + */ +function createScopeChangeRequest(project) { + return models.ScopeChangeRequest.create({ + newScope: { + appDefinition: { + numberScreens: '5-8', + }, + }, + oldScope: { + appDefinition: { + numberScreens: '2-4', + }, + }, + projectId: project.id, + status: SCOPE_CHANGE_REQ_STATUS.PENDING, + createdBy: 1, + updatedBy: 1, + lastActivityAt: 1, + lastActivityUserId: '1', + }); +} + +/** + * Updates the details json of the project + * @param {string} projectId The project id + * @param {Object} detailsChange The changes to be merged with details json + * + * @returns {Promise} A promise to update details json in the project + */ +function updateProjectDetails(projectId, detailsChange) { + return models.Project.findByPk(projectId).then((project) => { + const updatedDetails = _.merge({}, project.details, detailsChange); + return project.update({ details: updatedDetails }); + }); +} + +describe('Update Scope Change Rquest', () => { + let project; + let scopeChangeRequest; + + before((done) => { + testUtil + .clearDb() + .then(() => createProject(PROJECT_STATUS.REVIEWED)) + .then((_project) => { + project = _project; + return project; + }) + .then(_project => createScopeChangeRequest(_project)) + .then((_scopeChangeRequest) => { + scopeChangeRequest = _scopeChangeRequest; + return scopeChangeRequest; + }) + .then(() => done()); + }); + + after((done) => { + testUtil.clearDb(done); + }); + + describe('PATCH projects/{projectId}/scopeChangeRequests/{requestId}', () => { + it('Should approve change request with customer login', (done) => { + request(server) + .patch(`/v5/projects/${project.id}/scopeChangeRequests/${scopeChangeRequest.id}`) + .set({ + Authorization: `Bearer ${testUtil.jwts.member}`, + }) + .send({ + status: SCOPE_CHANGE_REQ_STATUS.APPROVED, + }) + .expect(200) + .end((err) => { + if (err) { + done(err); + } else { + models.ScopeChangeRequest.findOne({ where: { id: scopeChangeRequest.id } }).then((_scopeChangeRequest) => { + assertStatus(_scopeChangeRequest, SCOPE_CHANGE_REQ_STATUS.APPROVED); + done(); + }); + } + }); + }); + + it('Should activate change request with manager login', (done) => { + // Updating project details before activation. This is used in a later test case + updateProjectDetails(project.id, { apiDefinition: { notes: 'Please include swagger docs' } }).then(() => { + request(server) + .patch(`/v5/projects/${project.id}/scopeChangeRequests/${scopeChangeRequest.id}`) + .set({ + Authorization: `Bearer ${testUtil.jwts.manager}`, + }) + .send({ + status: SCOPE_CHANGE_REQ_STATUS.ACTIVATED, + }) + .expect(200) + .end((err) => { + if (err) { + done(err); + } else { + models.ScopeChangeRequest.findOne({ where: { id: scopeChangeRequest.id } }) + .then((_scopeChangeRequest) => { + assertStatus(_scopeChangeRequest, SCOPE_CHANGE_REQ_STATUS.ACTIVATED); + done(); + }); + } + }); + }); + }); + + it('Should update details field of project on activation', (done) => { + models.Project.findOne({ where: { id: project.id } }).then((_project) => { + const numberScreens = _.get(_project, 'details.appDefinition.numberScreens'); + sinon.assert.match(numberScreens, '5-8'); + done(); + }); + }); + + it("Should preserve fields of details json that doesn't change the scope on activation", (done) => { + models.Project.findOne({ where: { id: project.id } }).then((_project) => { + const apiNotes = _.get(_project, 'details.apiDefinition.notes'); + sinon.assert.match(apiNotes, 'Please include swagger docs'); + done(); + }); + }); + + it('Should not allow updating oldScope', (done) => { + request(server) + .patch(`/v5/projects/${project.id}/scopeChangeRequests/${scopeChangeRequest.id}`) + .set({ + Authorization: `Bearer ${testUtil.jwts.manager}`, + }) + .send({ + oldScope: {}, + }) + .expect(400) + .end(err => done(err)); + }); + + it('Should not allow updating newScope', (done) => { + request(server) + .patch(`/v5/projects/${project.id}/scopeChangeRequests/${scopeChangeRequest.id}`) + .set({ + Authorization: `Bearer ${testUtil.jwts.manager}`, + }) + .send({ + newScope: {}, + }) + .expect(400) + .end(err => done(err)); + }); + }); +}); diff --git a/src/routes/timelines/create.spec.js b/src/routes/timelines/create.spec.js index 6879987..8e94687 100644 --- a/src/routes/timelines/create.spec.js +++ b/src/routes/timelines/create.spec.js @@ -505,6 +505,15 @@ describe('CREATE timeline', () => { should.exist(milestone.updatedAt); should.not.exist(milestone.deletedBy); should.not.exist(milestone.deletedAt); + + // validate statusHistory + should.exist(milestone.statusHistory); + milestone.statusHistory.should.be.an('array'); + milestone.statusHistory.length.should.be.eql(1); + milestone.statusHistory.forEach((statusHistory) => { + statusHistory.reference.should.be.eql('milestone'); + statusHistory.referenceId.should.be.eql(milestone.id); + }); }); // eslint-disable-next-line no-unused-expressions diff --git a/src/routes/timelines/delete.spec.js b/src/routes/timelines/delete.spec.js index e9f6d1c..e082578 100644 --- a/src/routes/timelines/delete.spec.js +++ b/src/routes/timelines/delete.spec.js @@ -159,6 +159,7 @@ describe('DELETE timeline', () => { // Create milestones models.Milestone.bulkCreate([ { + id: 1, timelineId: 1, name: 'milestone 1', duration: 2, @@ -181,6 +182,7 @@ describe('DELETE timeline', () => { updatedBy: 2, }, { + id: 2, timelineId: 1, name: 'milestone 2', duration: 2, diff --git a/src/routes/timelines/get.js b/src/routes/timelines/get.js index d55b5af..f5145ff 100644 --- a/src/routes/timelines/get.js +++ b/src/routes/timelines/get.js @@ -20,8 +20,22 @@ const schema = { params: { timelineId: Joi.number().integer().positive().required(), }, + query: { + db: Joi.boolean().optional(), + }, }; +// Load the milestones +const loadMilestones = timeline => + timeline.getMilestones() + .then((milestones) => { + const loadedTimeline = _.omit(timeline.toJSON(), ['deletedAt', 'deletedBy']); + loadedTimeline.milestones = + _.map(milestones, milestone => _.omit(milestone.toJSON(), ['deletedAt', 'deletedBy'])); + + return Promise.resolve(loadedTimeline); + }); + module.exports = [ validate(schema), // Validate and get projectId from the timelineId param, and set to request params for @@ -29,27 +43,24 @@ module.exports = [ validateTimeline.validateTimelineIdParam, permissions('timeline.view'), (req, res, next) => { - eClient.get({ index: ES_TIMELINE_INDEX, + // when user query with db, bypass the elasticsearch + // and get the data directly from database + if (req.query.db) { + req.log.debug('bypass ES, gets timeline directly from database'); + return loadMilestones(req.timeline).then(timeline => res.json(timeline)); + } + return eClient.get({ index: ES_TIMELINE_INDEX, type: ES_TIMELINE_TYPE, id: req.params.timelineId, }) .then((doc) => { req.log.debug('timeline found in ES'); - res.json(doc._source); // eslint-disable-line no-underscore-dangle + return res.json(doc._source); // eslint-disable-line no-underscore-dangle }) .catch((err) => { if (err.status === 404) { req.log.debug('No timeline found in ES'); - // Load the milestones - return 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 - return res.json(timeline); - }); + return loadMilestones(req.timeline).then(timeline => res.json(timeline)); } return next(err); }); diff --git a/src/routes/timelines/get.spec.js b/src/routes/timelines/get.spec.js index 7bec2f8..82ac3b5 100644 --- a/src/routes/timelines/get.spec.js +++ b/src/routes/timelines/get.spec.js @@ -3,6 +3,8 @@ */ import chai from 'chai'; import request from 'supertest'; +import config from 'config'; +import _ from 'lodash'; import models from '../../models'; import server from '../../app'; @@ -10,6 +12,42 @@ import testUtil from '../../tests/util'; const should = chai.should(); +const ES_TIMELINE_INDEX = config.get('elasticsearchConfig.timelineIndexName'); +const ES_TIMELINE_TYPE = config.get('elasticsearchConfig.timelineDocType'); + +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: 1, + createdBy: 1, + updatedBy: 1, + deletedAt: '2018-05-14T00:00:00.000Z', + }, +]; const milestones = [ { id: 1, @@ -143,41 +181,37 @@ describe('GET timeline', () => { ])) .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()); + // Create timelines + models.Timeline.bulkCreate(timelines, { returning: true }) + .then(createdTimelines => ( + // create milestones after timelines + models.Milestone.bulkCreate(milestones)) + .then(createdMilestones => [createdTimelines, createdMilestones]), + ), + ).then(([createdTimelines, createdMilestones]) => + // Index to ES + Promise.all(_.map(createdTimelines, async (createdTimeline) => { + const timelineJson = _.omit(createdTimeline.toJSON(), 'deletedAt', 'deletedBy'); + timelineJson.projectId = createdTimeline.id !== 3 ? 1 : 2; + if (timelineJson.id === 1) { + timelineJson.milestones = _.map( + createdMilestones, + cm => _.omit(cm.toJSON(), 'deletedAt', 'deletedBy'), + ); + } else if (timelineJson.id === 2) { + timelineJson.description = 'from ES'; + } + + await server.services.es.index({ + index: ES_TIMELINE_INDEX, + type: ES_TIMELINE_TYPE, + id: timelineJson.id, + body: timelineJson, + }); + })) + .then(() => { + done(); + })); }); }); }); @@ -264,6 +298,16 @@ describe('GET timeline', () => { // Milestones resJson.milestones.should.have.length(2); + resJson.milestones.forEach((milestone) => { + // validate statusHistory + should.exist(milestone.statusHistory); + milestone.statusHistory.should.be.an('array'); + milestone.statusHistory.length.should.be.eql(1); + milestone.statusHistory.forEach((statusHistory) => { + statusHistory.reference.should.be.eql('milestone'); + statusHistory.referenceId.should.be.eql(milestone.id); + }); + }); done(); }); @@ -306,5 +350,53 @@ describe('GET timeline', () => { }) .expect(200, done); }); + + it('should return data from ES when db param is not set', (done) => { + request(server) + .get('/v5/timelines/2') + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .expect(200) + .end((err, res) => { + const resJson = res.body; + resJson.id.should.be.eql(2); + resJson.name.should.be.eql('name 2'); + resJson.description.should.be.eql('from ES'); + + resJson.startDate.should.be.eql('2018-05-12T00:00:00.000Z'); + resJson.endDate.should.be.eql('2018-05-13T00:00:00.000Z'); + resJson.reference.should.be.eql('phase'); + 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); + + should.not.exist(resJson.milestones); + + done(); + }); + }); + + it('should return data from DB without calling ES when db param is set', (done) => { + request(server) + .get('/v5/timelines/2?db=true') + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .expect(200) + .end((err, res) => { + const resJson = res.body; + resJson.id.should.be.eql(2); + resJson.name.should.be.eql('name 2'); + resJson.description.should.be.eql('description 2'); + + done(); + }); + }); }); }); diff --git a/src/routes/timelines/list.spec.js b/src/routes/timelines/list.spec.js index 5ebe767..3a3e999 100644 --- a/src/routes/timelines/list.spec.js +++ b/src/routes/timelines/list.spec.js @@ -3,7 +3,7 @@ */ import chai from 'chai'; import request from 'supertest'; -import sleep from 'sleep'; +// import sleep from 'sleep'; import config from 'config'; import _ from 'lodash'; @@ -182,25 +182,34 @@ describe('LIST timelines', () => { ])) .then(() => // Create timelines - models.Timeline.bulkCreate(timelines, { returning: true })) - .then(createdTimelines => + models.Timeline.bulkCreate(timelines, { returning: true }) + .then(createdTimelines => ( + // create milestones after timelines + models.Milestone.bulkCreate(milestones)) + .then(createdMilestones => [createdTimelines, createdMilestones]), + ), + ).then(([createdTimelines, createdMilestones]) => // Index to ES - Promise.all(_.map(createdTimelines, (createdTimeline) => { - const timelineJson = _.omit(createdTimeline.toJSON(), 'deletedAt', 'deletedBy'); - timelineJson.projectId = createdTimeline.id !== 3 ? 1 : 2; - if (timelineJson.id === 1) { - timelineJson.milestones = milestones; - } - return server.services.es.index({ - index: ES_TIMELINE_INDEX, - type: ES_TIMELINE_TYPE, - id: timelineJson.id, - body: timelineJson, - }); - })) + Promise.all(_.map(createdTimelines, (createdTimeline) => { + const timelineJson = _.omit(createdTimeline.toJSON(), 'deletedAt', 'deletedBy'); + timelineJson.projectId = createdTimeline.id !== 3 ? 1 : 2; + if (timelineJson.id === 1) { + timelineJson.milestones = _.map( + createdMilestones, + cm => _.omit(cm.toJSON(), 'deletedAt', 'deletedBy'), + ); + } + + return server.services.es.index({ + index: ES_TIMELINE_INDEX, + type: ES_TIMELINE_TYPE, + id: timelineJson.id, + body: timelineJson, + }); + })) .then(() => { // sleep for some time, let elasticsearch indices be settled - sleep.sleep(5); + // sleep.sleep(5); done(); })); }); @@ -278,6 +287,16 @@ describe('LIST timelines', () => { // Milestones resJson[0].milestones.should.have.length(2); + resJson[0].milestones.forEach((milestone) => { + // validate statusHistory + should.exist(milestone.statusHistory); + milestone.statusHistory.should.be.an('array'); + milestone.statusHistory.length.should.be.eql(1); + milestone.statusHistory.forEach((statusHistory) => { + statusHistory.reference.should.be.eql('milestone'); + statusHistory.referenceId.should.be.eql(milestone.id); + }); + }); done(); }); diff --git a/src/routes/timelines/update.js b/src/routes/timelines/update.js index e4c8fd5..885d7db 100644 --- a/src/routes/timelines/update.js +++ b/src/routes/timelines/update.js @@ -107,7 +107,8 @@ module.exports = [ req, EVENT.ROUTING_KEY.TIMELINE_UPDATED, RESOURCES.TIMELINE, - _.assign(entityToUpdate, _.pick(updated, 'id', 'updatedAt'))); + updated, + original); // Write to response res.json(updated); diff --git a/src/routes/timelines/update.spec.js b/src/routes/timelines/update.spec.js index 1d08ccc..73ae626 100644 --- a/src/routes/timelines/update.spec.js +++ b/src/routes/timelines/update.spec.js @@ -9,7 +9,7 @@ import _ from 'lodash'; import models from '../../models'; import server from '../../app'; import testUtil from '../../tests/util'; -import { EVENT, BUS_API_EVENT, RESOURCES } from '../../constants'; +import { EVENT, BUS_API_EVENT, RESOURCES, CONNECT_NOTIFICATION_EVENT } from '../../constants'; import busApi from '../../services/busApi'; const should = chai.should(); @@ -470,6 +470,19 @@ describe('UPDATE timeline', () => { should.not.exist(resJson.deletedAt); should.not.exist(resJson.deletedBy); + // Milestones + resJson.milestones.should.have.length(2); + resJson.milestones.forEach((milestone) => { + // validate statusHistory + should.exist(milestone.statusHistory); + milestone.statusHistory.should.be.an('array'); + milestone.statusHistory.length.should.be.eql(1); + milestone.statusHistory.forEach((statusHistory) => { + statusHistory.reference.should.be.eql('milestone'); + statusHistory.referenceId.should.be.eql(milestone.id); + }); + }); + // eslint-disable-next-line no-unused-expressions server.services.pubsub.publish.calledWith(EVENT.ROUTING_KEY.TIMELINE_UPDATED).should.be.true; @@ -621,7 +634,7 @@ describe('UPDATE timeline', () => { // not testing fields separately as startDate is required parameter, // thus TIMELINE_ADJUSTED will be always sent - it('should send message BUS_API_EVENT.TIMELINE_UPDATED when timeline updated', (done) => { + it('should send correct BUS API messages when timeline updated', (done) => { request(server) .patch('/v5/timelines/1') .set({ @@ -634,9 +647,22 @@ describe('UPDATE timeline', () => { done(err); } else { testUtil.wait(() => { - createEventSpy.calledOnce.should.be.true; - createEventSpy.calledWith(BUS_API_EVENT.TIMELINE_UPDATED, - sinon.match({ resource: RESOURCES.TIMELINE })).should.be.true; + createEventSpy.callCount.should.equal(2); + + createEventSpy.calledWith(BUS_API_EVENT.TIMELINE_UPDATED, sinon.match({ + resource: RESOURCES.TIMELINE, + name: body.name, + })).should.be.true; + + // Check Notification Service events + createEventSpy.calledWith(CONNECT_NOTIFICATION_EVENT.TIMELINE_ADJUSTED, sinon.match({ + projectId: 1, + projectName: 'test1', + projectUrl: 'https://local.topcoder-dev.com/projects/1', + userId: 40051332, + initiatorUserId: 40051332, + })).should.be.true; + done(); }); } diff --git a/src/routes/workItems/create.js b/src/routes/workItems/create.js new file mode 100644 index 0000000..f53ef1b --- /dev/null +++ b/src/routes/workItems/create.js @@ -0,0 +1,139 @@ +/** + * API to add a work item + */ +import validate from 'express-validation'; +import _ from 'lodash'; +import Joi from 'joi'; + +import models from '../../models'; +import util from '../../util'; +import { EVENT, RESOURCES } from '../../constants'; + +const permissions = require('tc-core-library-js').middleware.permissions; + +const schema = { + params: { + projectId: Joi.number().integer().positive().required(), + workStreamId: Joi.number().integer().positive().required(), + workId: Joi.number().integer().positive().required(), + }, + body: { + name: Joi.string().required(), + type: Joi.string().required(), + templateId: Joi.number().positive().optional(), + directProjectId: Joi.number().positive().optional(), + billingAccountId: Joi.number().positive().optional(), + estimatedPrice: Joi.number().positive().optional(), + actualPrice: Joi.number().positive().optional(), + details: Joi.any().optional(), + }, +}; + +module.exports = [ + // validate request payload + validate(schema), + // check permission + permissions('workItem.create'), + // do the real work + (req, res, next) => { + const projectId = _.parseInt(req.params.projectId); + const workStreamId = _.parseInt(req.params.workStreamId); + const phaseId = _.parseInt(req.params.workId); + + const data = req.body; + // default values + _.assign(data, { + projectId, + phaseId, + createdBy: req.authUser.userId, + updatedBy: req.authUser.userId, + }); + + let newPhaseProduct = null; + models.sequelize.transaction(() => models.ProjectPhase.findOne({ + where: { + id: phaseId, + projectId, + }, + include: [{ + model: models.WorkStream, + where: { + id: workStreamId, + projectId, + }, + }], + }).then((existing) => { + // make sure work stream exists + if (!existing) { + const err = new Error(`project work stream not found for project id ${projectId}` + + ` and work stream ${workStreamId} and phase id ${phaseId}`); + err.status = 404; + throw err; + } + + return models.Project.findOne({ + where: { id: projectId, deletedAt: { $eq: null } }, + raw: true, + }); + }) + .then((existingProject) => { + // make sure project exists + if (!existingProject) { + const err = new Error(`project not found for project id ${projectId}`); + err.status = 404; + throw err; + } + + _.assign(data, { + phaseId, + projectId, + directProjectId: existingProject.directProjectId, + billingAccountId: existingProject.billingAccountId, + }); + + return models.PhaseProduct.count({ + where: { + projectId, + phaseId, + deletedAt: { $eq: null }, + }, + raw: true, + }); + }) + .then((productCount) => { + // make sure number of products of per phase <= max value + if (productCount >= 100) { + const err = new Error('the number of products per phase cannot exceed ' + + `${100}`); + err.status = 400; + throw err; + } + return models.PhaseProduct.create(data) + .then((_newPhaseProduct) => { + newPhaseProduct = _.cloneDeep(_newPhaseProduct); + req.log.debug('new work 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); + // emit the event + util.sendResourceToKafkaBus( + req, + EVENT.ROUTING_KEY.PROJECT_PHASE_PRODUCT_ADDED, + RESOURCES.PHASE_PRODUCT, + newPhaseProduct); + + res.status(201).json(newPhaseProduct); + }) + .catch((err) => { next(err); }); + }, +]; diff --git a/src/routes/workItems/create.spec.js b/src/routes/workItems/create.spec.js new file mode 100644 index 0000000..0e7e348 --- /dev/null +++ b/src/routes/workItems/create.spec.js @@ -0,0 +1,344 @@ +/* eslint-disable no-unused-expressions */ +/** + * Tests for create.js + */ +import _ from 'lodash'; +import chai from 'chai'; +import request from 'supertest'; +import sinon from 'sinon'; + +import server from '../../app'; +import models from '../../models'; +import testUtil from '../../tests/util'; +import busApi from '../../services/busApi'; + +import { BUS_API_EVENT, RESOURCES } from '../../constants'; + +const should = chai.should(); + +const body = { + name: 'test phase product', + type: 'product1', + estimatedPrice: 20.0, + actualPrice: 1.23456, + details: { + message: 'This can be any json', + }, +}; + +describe('CREATE Work Item', () => { + let projectId; + let workStreamId; + let workId; + + const memberUser = { + handle: testUtil.getDecodedToken(testUtil.jwts.member).handle, + userId: testUtil.getDecodedToken(testUtil.jwts.member).userId, + firstName: 'fname', + lastName: 'lName', + email: 'some@abc.com', + }; + const copilotUser = { + handle: testUtil.getDecodedToken(testUtil.jwts.copilot).handle, + userId: testUtil.getDecodedToken(testUtil.jwts.copilot).userId, + firstName: 'fname', + lastName: 'lName', + email: 'some@abc.com', + }; + + beforeEach((done) => { + testUtil.clearDb() + .then(() => { + models.ProjectTemplate.create({ + name: 'template 2', + key: 'key 2', + category: 'category 2', + icon: 'http://example.com/icon1.ico', + question: 'question 2', + info: 'info 2', + aliases: ['key-2', 'key_2'], + scope: {}, + phases: {}, + createdBy: 1, + updatedBy: 2, + }) + .then((template) => { + models.WorkManagementPermission.create({ + policy: 'workItem.create', + permission: { + allowRule: { + projectRoles: ['customer', 'copilot'], + topcoderRoles: ['Connect Manager', 'Connect Admin', 'administrator'], + }, + denyRule: { projectRoles: ['copilot'] }, + }, + projectTemplateId: template.id, + details: {}, + createdBy: 1, + updatedBy: 1, + lastActivityAt: 1, + lastActivityUserId: '1', + }) + .then(() => { + // Create projects + models.Project.create({ + type: 'generic', + billingAccountId: 1, + name: 'test1', + description: 'test project1', + status: 'draft', + templateId: template.id, + details: {}, + createdBy: 1, + updatedBy: 1, + lastActivityAt: 1, + lastActivityUserId: '1', + }) + .then((project) => { + projectId = project.id; + models.WorkStream.create({ + name: 'Work Stream', + type: 'generic', + status: 'active', + projectId, + createdBy: 1, + updatedBy: 1, + }).then((entity) => { + workStreamId = entity.id; + models.ProjectPhase.create({ + name: 'test project phase', + status: 'active', + startDate: '2018-05-15T00:00:00Z', + endDate: '2018-05-15T12:00:00Z', + budget: 20.0, + progress: 1.23456, + details: { + message: 'This can be any json', + }, + createdBy: 1, + updatedBy: 1, + projectId, + }).then((phase) => { + workId = phase.id; + models.PhaseWorkStream.create({ + phaseId: workId, + workStreamId, + }).then(() => { + // create members + models.ProjectMember.bulkCreate([{ + id: 1, + userId: copilotUser.userId, + projectId, + role: 'copilot', + isPrimary: false, + createdBy: 1, + updatedBy: 1, + }, { + id: 2, + userId: memberUser.userId, + projectId, + role: 'customer', + isPrimary: true, + createdBy: 1, + updatedBy: 1, + }]).then(() => done()); + }); + }); + }); + }); + }); + }); + }); + }); + + afterEach((done) => { + testUtil.clearDb(done); + }); + + describe('POST /projects/{projectId}/workstreams/{workStreamId}/works/{workId}/workitems', () => { + it('should return 403 if user is not authenticated', (done) => { + request(server) + .post(`/v5/projects/${projectId}/workstreams/${workStreamId}/works/${workId}/workitems`) + .send(body) + .expect(403, done); + }); + + it('should return 403 for copilot', (done) => { + request(server) + .post(`/v5/projects/${projectId}/workstreams/${workStreamId}/works/${workId}/workitems`) + .set({ + Authorization: `Bearer ${testUtil.jwts.copilot}`, + }) + .send(body) + .expect(403, done); + }); + + it('should return 404 when no work stream with specific workStreamId', (done) => { + request(server) + .post(`/v5/projects/${projectId}/workstreams/999/works/${workId}/workitems`) + .set({ + Authorization: `Bearer ${testUtil.jwts.manager}`, + }) + .send(body) + .expect('Content-Type', /json/) + .expect(404, done); + }); + + it('should return 404 when no work with specific workId', (done) => { + request(server) + .post(`/v5/projects/${projectId}/workstreams/${workStreamId}/works/999/workitems`) + .set({ + Authorization: `Bearer ${testUtil.jwts.manager}`, + }) + .send(body) + .expect('Content-Type', /json/) + .expect(404, done); + }); + + it('should return 400 when name not provided', (done) => { + const reqBody = _.cloneDeep(body); + delete reqBody.name; + request(server) + .post(`/v5/projects/${projectId}/workstreams/${workStreamId}/works/${workId}/workitems`) + .set({ + Authorization: `Bearer ${testUtil.jwts.manager}`, + }) + .send({ param: reqBody }) + .expect('Content-Type', /json/) + .expect(400, done); + }); + + it('should return 400 when type not provided', (done) => { + const reqBody = _.cloneDeep(body); + delete reqBody.type; + request(server) + .post(`/v5/projects/${projectId}/workstreams/${workStreamId}/works/${workId}/workitems`) + .set({ + Authorization: `Bearer ${testUtil.jwts.manager}`, + }) + .send({ param: reqBody }) + .expect('Content-Type', /json/) + .expect(400, done); + }); + + it('should return 400 when estimatedPrice is negative', (done) => { + const reqBody = _.cloneDeep(body); + reqBody.estimatedPrice = -20; + request(server) + .post(`/v5/projects/${projectId}/workstreams/${workStreamId}/works/${workId}/workitems`) + .set({ + Authorization: `Bearer ${testUtil.jwts.manager}`, + }) + .send({ param: reqBody }) + .expect('Content-Type', /json/) + .expect(400, done); + }); + + it('should return 400 when actualPrice is negative', (done) => { + const reqBody = _.cloneDeep(body); + reqBody.actualPrice = -20; + request(server) + .post(`/v5/projects/${projectId}/workstreams/${workStreamId}/works/${workId}/workitems`) + .set({ + Authorization: `Bearer ${testUtil.jwts.manager}`, + }) + .send({ param: reqBody }) + .expect('Content-Type', /json/) + .expect(400, done); + }); + + it('should return 200 for member', (done) => { + request(server) + .post(`/v5/projects/${projectId}/workstreams/${workStreamId}/works/${workId}/workitems`) + .set({ + Authorization: `Bearer ${testUtil.jwts.member}`, + }) + .send(body) + .expect('Content-Type', /json/) + .expect(201) + .end((err, res) => { + if (err) { + done(err); + } else { + const resJson = res.body; + should.exist(resJson); + resJson.name.should.be.eql(body.name); + resJson.type.should.be.eql(body.type); + resJson.estimatedPrice.should.be.eql(body.estimatedPrice); + resJson.actualPrice.should.be.eql(body.actualPrice); + resJson.details.should.be.eql(body.details); + done(); + } + }); + }); + + it('should return 201 if payload is valid', (done) => { + request(server) + .post(`/v5/projects/${projectId}/workstreams/${workStreamId}/works/${workId}/workitems`) + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .send(body) + .expect('Content-Type', /json/) + .expect(201) + .end((err, res) => { + if (err) { + done(err); + } else { + const resJson = res.body; + should.exist(resJson); + resJson.name.should.be.eql(body.name); + resJson.type.should.be.eql(body.type); + resJson.estimatedPrice.should.be.eql(body.estimatedPrice); + resJson.actualPrice.should.be.eql(body.actualPrice); + resJson.details.should.be.eql(body.details); + done(); + } + }); + }); + + describe('Bus api', () => { + let createEventSpy; + const sandbox = sinon.sandbox.create(); + + before((done) => { + // Wait for 500ms in order to wait for createEvent calls from previous tests to complete + testUtil.wait(done); + }); + + beforeEach(() => { + createEventSpy = sandbox.spy(busApi, 'createEvent'); + }); + + afterEach(() => { + sandbox.restore(); + }); + + it('should send correct BUS API messages when work item created', (done) => { + request(server) + .post(`/v5/projects/${projectId}/workstreams/${workStreamId}/works/${workId}/workitems`) + .set({ + Authorization: `Bearer ${testUtil.jwts.member}`, + }) + .send(body) + .expect('Content-Type', /json/) + .expect(201) + .end((err) => { + if (err) { + done(err); + } else { + testUtil.wait(() => { + createEventSpy.callCount.should.be.eql(1); + + createEventSpy.calledWith(BUS_API_EVENT.PROJECT_PHASE_CREATED, sinon.match({ + resource: RESOURCES.PHASE_PRODUCT, + })).should.be.true; + + done(); + }); + } + }); + }); + }); + }); +}); diff --git a/src/routes/workItems/delete.js b/src/routes/workItems/delete.js new file mode 100644 index 0000000..bab0380 --- /dev/null +++ b/src/routes/workItems/delete.js @@ -0,0 +1,98 @@ +/** + * API to delete a work item + */ +import validate from 'express-validation'; +import _ from 'lodash'; +import Joi from 'joi'; +import { middleware as tcMiddleware } from 'tc-core-library-js'; +import models from '../../models'; +import util from '../../util'; +import { EVENT, RESOURCES } from '../../constants'; + +const permissions = tcMiddleware.permissions; + +const schema = { + params: { + projectId: Joi.number().integer().positive().required(), + workStreamId: Joi.number().integer().positive().required(), + workId: Joi.number().integer().positive().required(), + id: Joi.number().integer().positive().required(), + }, +}; + +module.exports = [ + validate(schema), + // check permission + permissions('workItem.delete'), + + (req, res, next) => { + const projectId = _.parseInt(req.params.projectId); + const workStreamId = _.parseInt(req.params.workStreamId); + const phaseId = _.parseInt(req.params.workId); + const productId = _.parseInt(req.params.id); + + models.sequelize.transaction(() => + models.ProjectPhase.findOne({ + where: { + id: phaseId, + }, + include: [{ + model: models.WorkStream, + where: { + id: workStreamId, + projectId, + }, + }, + ], + }) + .then((existing) => { + if (!existing) { + // handle 404 + const err = new Error('No active work item found for project id ' + + `${projectId}, phase id ${phaseId} and work stream id ${workStreamId}`); + err.status = 404; + return Promise.reject(err); + } + + // soft delete the record + return models.PhaseProduct.findOne({ + where: { + id: productId, + projectId, + phaseId, + deletedAt: { $eq: null }, + }, + }); + }) + .then((existing) => { + if (!existing) { + // handle 404 + const err = new Error('No active work item found for project id ' + + `${projectId}, phase id ${phaseId} and product id ${productId}`); + err.status = 404; + return Promise.reject(err); + } + return existing.update({ deletedBy: req.authUser.userId }); + }) + .then(entity => entity.destroy())) + .then((deleted) => { + req.log.debug('deleted work item', JSON.stringify(deleted, null, 2)); + + // Send events to buses + req.app.services.pubsub.publish( + EVENT.ROUTING_KEY.PROJECT_PHASE_PRODUCT_REMOVED, + deleted, + { correlationId: req.id }, + ); + // emit the event + util.sendResourceToKafkaBus( + req, + EVENT.ROUTING_KEY.PROJECT_PHASE_PRODUCT_REMOVED, + RESOURCES.PHASE_PRODUCT, + _.pick(deleted.toJSON(), 'id')); + + res.status(204).json({}); + }) + .catch(err => next(err)); + }, +]; diff --git a/src/routes/workItems/delete.spec.js b/src/routes/workItems/delete.spec.js new file mode 100644 index 0000000..f9e9873 --- /dev/null +++ b/src/routes/workItems/delete.spec.js @@ -0,0 +1,291 @@ +/* eslint-disable no-unused-expressions */ +/** + * Tests for delete.js + */ +import _ from 'lodash'; +import request from 'supertest'; +import chai from 'chai'; +import sinon from 'sinon'; + +import server from '../../app'; +import models from '../../models'; +import testUtil from '../../tests/util'; +import busApi from '../../services/busApi'; + +import { BUS_API_EVENT, RESOURCES } from '../../constants'; + +chai.should(); + +const expectAfterDelete = (projectId, workStreamId, 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(`/v5/projects/${projectId}/workstreams/${workStreamId}/works/${phaseId}/workitems/${id}`) + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .expect(404, next); + } + }), 500); +}; +const body = { + name: 'test phase product', + type: 'product1', + estimatedPrice: 20.0, + actualPrice: 1.23456, + details: { + message: 'This can be any json', + }, + createdBy: 1, + updatedBy: 1, +}; + +describe('DELETE Work Item', () => { + let projectId; + let workStreamId; + let workId; + let productId; + + const memberUser = { + handle: testUtil.getDecodedToken(testUtil.jwts.member).handle, + userId: testUtil.getDecodedToken(testUtil.jwts.member).userId, + firstName: 'fname', + lastName: 'lName', + email: 'some@abc.com', + }; + const copilotUser = { + handle: testUtil.getDecodedToken(testUtil.jwts.copilot).handle, + userId: testUtil.getDecodedToken(testUtil.jwts.copilot).userId, + firstName: 'fname', + lastName: 'lName', + email: 'some@abc.com', + }; + + + beforeEach((done) => { + testUtil.clearDb() + .then(() => { + models.ProjectTemplate.create({ + name: 'template 2', + key: 'key 2', + category: 'category 2', + icon: 'http://example.com/icon1.ico', + question: 'question 2', + info: 'info 2', + aliases: ['key-2', 'key_2'], + scope: {}, + phases: {}, + createdBy: 1, + updatedBy: 2, + }) + .then((template) => { + models.WorkManagementPermission.create({ + policy: 'workItem.delete', + permission: { + allowRule: { + projectRoles: ['customer', 'copilot'], + topcoderRoles: ['Connect Manager', 'Connect Admin', 'administrator'], + }, + denyRule: { projectRoles: ['copilot'] }, + }, + projectTemplateId: template.id, + details: {}, + createdBy: 1, + updatedBy: 1, + lastActivityAt: 1, + lastActivityUserId: '1', + }) + .then(() => { + // Create projects + models.Project.create({ + type: 'generic', + billingAccountId: 1, + name: 'test1', + description: 'test project1', + status: 'draft', + templateId: template.id, + details: {}, + createdBy: 1, + updatedBy: 1, + lastActivityAt: 1, + lastActivityUserId: '1', + }) + .then((project) => { + projectId = project.id; + models.WorkStream.create({ + name: 'Work Stream', + type: 'generic', + status: 'active', + projectId, + createdBy: 1, + updatedBy: 1, + }).then((entity) => { + workStreamId = entity.id; + models.ProjectPhase.create({ + name: 'test project phase', + status: 'active', + startDate: '2018-05-15T00:00:00Z', + endDate: '2018-05-15T12:00:00Z', + budget: 20.0, + progress: 1.23456, + details: { + message: 'This can be any json', + }, + createdBy: 1, + updatedBy: 1, + projectId, + }).then((phase) => { + workId = phase.id; + models.PhaseWorkStream.create({ + phaseId: workId, + workStreamId, + }) + .then(() => { + _.assign(body, { phaseId: workId, projectId }); + models.PhaseProduct.create(body).then((product) => { + productId = product.id; + // create members + models.ProjectMember.bulkCreate([{ + id: 1, + userId: copilotUser.userId, + projectId, + role: 'copilot', + isPrimary: false, + createdBy: 1, + updatedBy: 1, + }, { + id: 2, + userId: memberUser.userId, + projectId, + role: 'customer', + isPrimary: true, + createdBy: 1, + updatedBy: 1, + }]).then(() => done()); + }); + }); + }); + }); + }); + }); + }); + }); + }); + + afterEach((done) => { + testUtil.clearDb(done); + }); + + describe('DELETE /projects/{projectId}/workstreams/{workStreamId}/works/{workId}/workitems/{productId}', () => { + it('should return 403 if user is not authenticated', (done) => { + request(server) + .delete(`/v5/projects/${projectId}/workstreams/${workStreamId}/works/${workId}/workitems/${productId}`) + .expect(403, done); + }); + + it('should return 403 for copilot', (done) => { + request(server) + .delete(`/v5/projects/${projectId}/workstreams/${workStreamId}/works/${workId}/workitems/${productId}`) + .set({ + Authorization: `Bearer ${testUtil.jwts.copilot}`, + }) + .expect(403, done); + }); + + it('should return 404 when no work stream with specific workStreamId', (done) => { + request(server) + .delete(`/v5/projects/${projectId}/workstreams/999/works/${workId}/workitems/${productId}`) + .set({ + Authorization: `Bearer ${testUtil.jwts.manager}`, + }) + .expect('Content-Type', /json/) + .expect(404, done); + }); + + it('should return 404 when no work with specific workId', (done) => { + request(server) + .delete(`/v5/projects/${projectId}/workstreams/${workStreamId}/works/999/workitems/${productId}`) + .set({ + Authorization: `Bearer ${testUtil.jwts.manager}`, + }) + .expect('Content-Type', /json/) + .expect(404, done); + }); + + it('should return 204 for member', (done) => { + request(server) + .delete(`/v5/projects/${projectId}/workstreams/${workStreamId}/works/${workId}/workitems/${productId}`) + .set({ + Authorization: `Bearer ${testUtil.jwts.member}`, + }) + .expect(204, done); + }); + + it('should return 204 when user have project permission', (done) => { + request(server) + .delete(`/v5/projects/${projectId}/workstreams/${workStreamId}/works/${workId}/workitems/${productId}`) + .set({ + Authorization: `Bearer ${testUtil.jwts.manager}`, + }) + .expect(204) + .end(err => expectAfterDelete(projectId, workStreamId, workId, productId, err, done)); + }); + + describe('Bus api', () => { + let createEventSpy; + const sandbox = sinon.sandbox.create(); + + before((done) => { + // Wait for 500ms in order to wait for createEvent calls from previous tests to complete + testUtil.wait(done); + }); + + beforeEach(() => { + createEventSpy = sandbox.spy(busApi, 'createEvent'); + }); + + afterEach(() => { + sandbox.restore(); + }); + + it('should send correct BUS API messages when work item removed', (done) => { + request(server) + .delete(`/v5/projects/${projectId}/workstreams/${workStreamId}/works/${workId}/workitems/${productId}`) + .set({ + Authorization: `Bearer ${testUtil.jwts.member}`, + }) + .expect(204) + .end((err) => { + if (err) { + done(err); + } else { + testUtil.wait(() => { + createEventSpy.callCount.should.be.eql(1); + + createEventSpy.calledWith(BUS_API_EVENT.PROJECT_PHASE_DELETED, sinon.match({ + resource: RESOURCES.PHASE_PRODUCT, + })).should.be.true; + + done(); + }); + } + }); + }); + }); + }); +}); diff --git a/src/routes/workItems/get.js b/src/routes/workItems/get.js new file mode 100644 index 0000000..44d2071 --- /dev/null +++ b/src/routes/workItems/get.js @@ -0,0 +1,74 @@ +/** + * API to get a work item + */ +import validate from 'express-validation'; +import _ from 'lodash'; +import Joi from 'joi'; +import models from '../../models'; + +const permissions = require('tc-core-library-js').middleware.permissions; + +const schema = { + params: { + projectId: Joi.number().integer().positive().required(), + workStreamId: Joi.number().integer().positive().required(), + workId: Joi.number().integer().positive().required(), + id: Joi.number().integer().positive().required(), + }, +}; + +module.exports = [ + validate(schema), + // check permission + permissions('workItem.view'), + + (req, res, next) => { + const projectId = _.parseInt(req.params.projectId); + const workStreamId = _.parseInt(req.params.workStreamId); + const phaseId = _.parseInt(req.params.workId); + const productId = _.parseInt(req.params.id); + + models.ProjectPhase.findOne({ + where: { + id: phaseId, + projectId, + }, + include: [{ + model: models.WorkStream, + where: { + id: workStreamId, + projectId, + }, + }, + ], + }) + .then((existing) => { + if (!existing) { + // handle 404 + const err = new Error('No active work item found for project id ' + + `${projectId}, phase id ${phaseId} and work stream id ${workStreamId}`); + err.status = 404; + return Promise.reject(err); + } + + return models.PhaseProduct.findOne({ + where: { + id: productId, + projectId, + phaseId, + deletedAt: { $eq: null }, + }, + }); + }).then((product) => { + if (!product) { + // handle 404 + const err = new Error('phase product not found for project id ' + + `${projectId}, phase id ${phaseId} and product id ${productId}`); + err.status = 404; + throw err; + } else { + res.json(product); + } + }).catch(err => next(err)); + }, +]; diff --git a/src/routes/workItems/get.spec.js b/src/routes/workItems/get.spec.js new file mode 100644 index 0000000..b3acd08 --- /dev/null +++ b/src/routes/workItems/get.spec.js @@ -0,0 +1,234 @@ +/** + * Tests for get.js + */ +import _ from 'lodash'; +import chai from 'chai'; +import request from 'supertest'; +import server from '../../app'; +import models from '../../models'; +import testUtil from '../../tests/util'; + +const should = chai.should(); + +const body = { + name: 'test phase product', + type: 'product1', + estimatedPrice: 20.0, + actualPrice: 1.23456, + details: { + message: 'This can be any json', + }, + createdBy: 1, + updatedBy: 1, +}; + +describe('GET Work Item', () => { + let projectId; + let workStreamId; + let workId; + let productId; + + const memberUser = { + handle: testUtil.getDecodedToken(testUtil.jwts.member).handle, + userId: testUtil.getDecodedToken(testUtil.jwts.member).userId, + firstName: 'fname', + lastName: 'lName', + email: 'some@abc.com', + }; + const copilotUser = { + handle: testUtil.getDecodedToken(testUtil.jwts.copilot).handle, + userId: testUtil.getDecodedToken(testUtil.jwts.copilot).userId, + firstName: 'fname', + lastName: 'lName', + email: 'some@abc.com', + }; + + beforeEach((done) => { + testUtil.clearDb() + .then(() => { + models.ProjectTemplate.create({ + name: 'template 2', + key: 'key 2', + category: 'category 2', + icon: 'http://example.com/icon1.ico', + question: 'question 2', + info: 'info 2', + aliases: ['key-2', 'key_2'], + scope: {}, + phases: {}, + createdBy: 1, + updatedBy: 2, + }) + .then((template) => { + // Create projects + models.Project.create({ + type: 'generic', + billingAccountId: 1, + name: 'test1', + description: 'test project1', + status: 'draft', + templateId: template.id, + details: {}, + createdBy: 1, + updatedBy: 1, + lastActivityAt: 1, + lastActivityUserId: '1', + }) + .then((project) => { + projectId = project.id; + // create members + models.ProjectMember.bulkCreate([{ + id: 1, + userId: copilotUser.userId, + projectId, + role: 'copilot', + isPrimary: false, + createdBy: 1, + updatedBy: 1, + }, { + id: 2, + userId: memberUser.userId, + projectId, + role: 'customer', + isPrimary: true, + createdBy: 1, + updatedBy: 1, + }]) + .then(() => { + models.WorkStream.create({ + name: 'Work Stream', + type: 'generic', + status: 'active', + projectId, + createdBy: 1, + updatedBy: 1, + }).then((entity) => { + workStreamId = entity.id; + models.ProjectPhase.create({ + name: 'test project phase', + status: 'active', + startDate: '2018-05-15T00:00:00Z', + endDate: '2018-05-15T12:00:00Z', + budget: 20.0, + progress: 1.23456, + details: { + message: 'This can be any json', + }, + createdBy: 1, + updatedBy: 1, + projectId, + }).then((phase) => { + workId = phase.id; + models.PhaseWorkStream.create({ + phaseId: workId, + workStreamId, + }) + .then(() => { + _.assign(body, { phaseId: workId, projectId }); + models.PhaseProduct.create(body).then((product) => { + productId = product.id; + done(); + }); + }); + }); + }); + }); + }); + }); + }); + }); + + after((done) => { + testUtil.clearDb(done); + }); + + describe('GET /projects/{projectId}/workstreams/{workStreamId}/works/{workId}/workitems/{productId}', () => { + it('should return 403 when user have no permission (non team member)', (done) => { + request(server) + .get(`/v5/projects/${projectId}/workstreams/${workStreamId}/works/${workId}/workitems/${productId}`) + .set({ + Authorization: `Bearer ${testUtil.jwts.member2}`, + }) + .expect('Content-Type', /json/) + .expect(403, done); + }); + + it('should return 404 when no project with specific projectId', (done) => { + request(server) + .get(`/v5/projects/9999/workstreams/${workStreamId}/works/${workId}/workitems/${productId}`) + .set({ + Authorization: `Bearer ${testUtil.jwts.manager}`, + }) + .expect('Content-Type', /json/) + .expect(404, done); + }); + + it('should return 404 when no work stream with specific workStreamId', (done) => { + request(server) + .get(`/v5/projects/${projectId}/workstreams/999/works/${workId}/workitems/${productId}`) + .set({ + Authorization: `Bearer ${testUtil.jwts.manager}`, + }) + .expect('Content-Type', /json/) + .expect(404, done); + }); + + it('should return 404 when no work with specific workId', (done) => { + request(server) + .get(`/v5/projects/${projectId}/workstreams/${workStreamId}/works/999/workitems/${productId}`) + .set({ + Authorization: `Bearer ${testUtil.jwts.manager}`, + }) + .expect('Content-Type', /json/) + .expect(404, done); + }); + + it('should return 1 phase when user have project permission (customer)', (done) => { + request(server) + .get(`/v5/projects/${projectId}/workstreams/${workStreamId}/works/${workId}/workitems/${productId}`) + .set({ + Authorization: `Bearer ${testUtil.jwts.member}`, + }) + .expect('Content-Type', /json/) + .expect(200) + .end((err, res) => { + if (err) { + done(err); + } else { + const resJson = res.body; + should.exist(resJson); + resJson.name.should.be.eql(body.name); + resJson.type.should.be.eql(body.type); + resJson.estimatedPrice.should.be.eql(body.estimatedPrice); + resJson.actualPrice.should.be.eql(body.actualPrice); + resJson.details.should.be.eql(body.details); + done(); + } + }); + }); + + it('should return 1 phase when user have project permission (copilot)', (done) => { + request(server) + .get(`/v5/projects/${projectId}/workstreams/${workStreamId}/works/${workId}/workitems/${productId}`) + .set({ + Authorization: `Bearer ${testUtil.jwts.copilot}`, + }) + .expect('Content-Type', /json/) + .expect(200) + .end((err, res) => { + if (err) { + done(err); + } else { + const resJson = res.body; + should.exist(resJson); + resJson.name.should.be.eql(body.name); + resJson.type.should.be.eql(body.type); + resJson.estimatedPrice.should.be.eql(body.estimatedPrice); + resJson.actualPrice.should.be.eql(body.actualPrice); + resJson.details.should.be.eql(body.details); + done(); + } + }); + }); + }); +}); diff --git a/src/routes/workItems/list.js b/src/routes/workItems/list.js new file mode 100644 index 0000000..b9dd656 --- /dev/null +++ b/src/routes/workItems/list.js @@ -0,0 +1,63 @@ +/** + * API to get a list of work items + */ +import validate from 'express-validation'; +import _ from 'lodash'; +import Joi from 'joi'; +import models from '../../models'; + +const permissions = require('tc-core-library-js').middleware.permissions; + +const schema = { + params: { + projectId: Joi.number().integer().positive().required(), + workStreamId: Joi.number().integer().positive().required(), + workId: Joi.number().integer().positive().required(), + }, +}; + +module.exports = [ + // validate request payload + validate(schema), + // check permission + permissions('workItem.view'), + + (req, res, next) => { + const projectId = _.parseInt(req.params.projectId); + const workStreamId = _.parseInt(req.params.workStreamId); + const phaseId = _.parseInt(req.params.workId); + + models.ProjectPhase.findOne({ + where: { + id: phaseId, + projectId, + }, + include: [{ + model: models.WorkStream, + where: { + id: workStreamId, + projectId, + }, + }, + ], + }) + .then((existing) => { + if (!existing) { + // handle 404 + const err = new Error('No active phase product found for project id ' + + `${projectId}, work stream id ${workStreamId} and phase id ${phaseId}`); + err.status = 404; + throw err; + } + + return models.PhaseProduct.findAll({ + where: { + phaseId, + projectId, + }, + }); + }) + .then(products => res.json(products)) + .catch(err => next(err)); + }, +]; diff --git a/src/routes/workItems/list.spec.js b/src/routes/workItems/list.spec.js new file mode 100644 index 0000000..bb2cd9f --- /dev/null +++ b/src/routes/workItems/list.spec.js @@ -0,0 +1,225 @@ +/** + * Tests for list.js + */ +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 should = chai.should(); + +const body = { + name: 'test phase product', + type: 'product1', + estimatedPrice: 20.0, + actualPrice: 1.23456, + details: { + message: 'This can be any json', + }, + createdBy: 1, + updatedBy: 1, +}; + +describe('LIST Work Items', () => { + let projectId; + let workStreamId; + let workId; + + const memberUser = { + handle: testUtil.getDecodedToken(testUtil.jwts.member).handle, + userId: testUtil.getDecodedToken(testUtil.jwts.member).userId, + firstName: 'fname', + lastName: 'lName', + email: 'some@abc.com', + }; + const copilotUser = { + handle: testUtil.getDecodedToken(testUtil.jwts.copilot).handle, + userId: testUtil.getDecodedToken(testUtil.jwts.copilot).userId, + firstName: 'fname', + lastName: 'lName', + email: 'some@abc.com', + }; + + beforeEach((done) => { + testUtil.clearDb() + .then(() => { + models.ProjectTemplate.create({ + name: 'template 2', + key: 'key 2', + category: 'category 2', + icon: 'http://example.com/icon1.ico', + question: 'question 2', + info: 'info 2', + aliases: ['key-2', 'key_2'], + scope: {}, + phases: {}, + createdBy: 1, + updatedBy: 2, + }) + .then((template) => { + // Create projects + models.Project.create({ + type: 'generic', + billingAccountId: 1, + name: 'test1', + description: 'test project1', + status: 'draft', + templateId: template.id, + details: {}, + createdBy: 1, + updatedBy: 1, + lastActivityAt: 1, + lastActivityUserId: '1', + }) + .then((project) => { + projectId = project.id; + // create members + models.ProjectMember.bulkCreate([{ + id: 1, + userId: copilotUser.userId, + projectId, + role: 'copilot', + isPrimary: false, + createdBy: 1, + updatedBy: 1, + }, { + id: 2, + userId: memberUser.userId, + projectId, + role: 'customer', + isPrimary: true, + createdBy: 1, + updatedBy: 1, + }]) + .then(() => { + models.WorkStream.create({ + name: 'Work Stream', + type: 'generic', + status: 'active', + projectId, + createdBy: 1, + updatedBy: 1, + }).then((entity) => { + workStreamId = entity.id; + models.ProjectPhase.create({ + name: 'test project phase', + status: 'active', + startDate: '2018-05-15T00:00:00Z', + endDate: '2018-05-15T12:00:00Z', + budget: 20.0, + progress: 1.23456, + details: { + message: 'This can be any json', + }, + createdBy: 1, + updatedBy: 1, + projectId, + }).then((phase) => { + workId = phase.id; + models.PhaseWorkStream.create({ + phaseId: workId, + workStreamId, + }) + .then(() => { + _.assign(body, { phaseId: workId, projectId }); + models.PhaseProduct.create(body).then(() => done()); + }); + }); + }); + }); + }); + }); + }); + }); + + after((done) => { + testUtil.clearDb(done); + }); + + describe('GET /projects/{projectId}/workstreams/{workStreamId}/works/{workId}/workitems', () => { + it('should return 403 when user have no permission (non team member)', (done) => { + request(server) + .get(`/v5/projects/${projectId}/workstreams/${workStreamId}/works/${workId}/workitems`) + .set({ + Authorization: `Bearer ${testUtil.jwts.member2}`, + }) + .send(body) + .expect('Content-Type', /json/) + .expect(403, done); + }); + + it('should return 404 when no project with specific projectId', (done) => { + request(server) + .get(`/v5/projects/9999/workstreams/${workStreamId}/works/${workId}/workitems`) + .set({ + Authorization: `Bearer ${testUtil.jwts.manager}`, + }) + .expect('Content-Type', /json/) + .expect(404, done); + }); + + it('should return 404 when no work stream with specific workStreamId', (done) => { + request(server) + .get(`/v5/projects/${projectId}/workstreams/999/works/${workId}/workitems`) + .set({ + Authorization: `Bearer ${testUtil.jwts.manager}`, + }) + .expect('Content-Type', /json/) + .expect(404, done); + }); + + it('should return 404 when no work with specific workId', (done) => { + request(server) + .get(`/v5/projects/${projectId}/workstreams/${workStreamId}/works/999/workitems`) + .set({ + Authorization: `Bearer ${testUtil.jwts.manager}`, + }) + .expect('Content-Type', /json/) + .expect(404, done); + }); + + it('should return 1 phase when user have project permission (customer)', (done) => { + request(server) + .get(`/v5/projects/${projectId}/workstreams/${workStreamId}/works/${workId}/workitems`) + .set({ + Authorization: `Bearer ${testUtil.jwts.member}`, + }) + .send(body) + .expect('Content-Type', /json/) + .expect(200) + .end((err, res) => { + if (err) { + done(err); + } else { + const resJson = res.body; + should.exist(resJson); + resJson.should.have.lengthOf(1); + done(); + } + }); + }); + + it('should return 1 phase when user have project permission (copilot)', (done) => { + request(server) + .get(`/v5/projects/${projectId}/workstreams/${workStreamId}/works/${workId}/workitems`) + .set({ + Authorization: `Bearer ${testUtil.jwts.copilot}`, + }) + .send(body) + .expect('Content-Type', /json/) + .expect(200) + .end((err, res) => { + if (err) { + done(err); + } else { + const resJson = res.body; + should.exist(resJson); + resJson.should.have.lengthOf(1); + done(); + } + }); + }); + }); +}); diff --git a/src/routes/workItems/update.js b/src/routes/workItems/update.js new file mode 100644 index 0000000..d22e00b --- /dev/null +++ b/src/routes/workItems/update.js @@ -0,0 +1,119 @@ +/** + * API to update a work item + */ +import validate from 'express-validation'; +import _ from 'lodash'; +import Joi from 'joi'; +import { middleware as tcMiddleware } from 'tc-core-library-js'; +import models from '../../models'; +import util from '../../util'; +import { EVENT, RESOURCES, ROUTES } from '../../constants'; + +const permissions = tcMiddleware.permissions; + +const schema = { + params: { + projectId: Joi.number().integer().positive().required(), + workStreamId: Joi.number().integer().positive().required(), + workId: Joi.number().integer().positive().required(), + id: Joi.number().integer().positive().required(), + }, + body: { + name: Joi.string().optional(), + type: Joi.string().optional(), + templateId: Joi.number().positive().optional(), + directProjectId: Joi.number().positive().optional(), + billingAccountId: Joi.number().positive().optional(), + estimatedPrice: Joi.number().positive().optional(), + actualPrice: Joi.number().positive().optional(), + details: Joi.any().optional(), + }, +}; + + +module.exports = [ + // validate request payload + validate(schema), + // check permission + permissions('workItem.edit'), + + (req, res, next) => { + const projectId = _.parseInt(req.params.projectId); + const workStreamId = _.parseInt(req.params.workStreamId); + const phaseId = _.parseInt(req.params.workId); + const productId = _.parseInt(req.params.id); + + const updatedProps = req.body; + updatedProps.updatedBy = req.authUser.userId; + + let previousValue; + + models.sequelize.transaction(() => models.ProjectPhase.findOne({ + where: { + id: phaseId, + projectId, + }, + include: [{ + model: models.WorkStream, + where: { + id: workStreamId, + projectId, + }, + }, + ], + }) + .then((existingWork) => { + if (!existingWork) { + // handle 404 + const err = new Error('No active work item found for project id ' + + `${projectId}, phase id ${phaseId} and work stream id ${workStreamId}`); + err.status = 404; + return Promise.reject(err); + } + + return models.PhaseProduct.findOne({ + where: { + id: productId, + projectId, + phaseId, + deletedAt: { $eq: null }, + }, + }); + }) + .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; + throw err; + } + + previousValue = _.clone(existing.get({ plain: true })); + _.extend(existing, updatedProps); + return existing.save().catch(next); + })) + .then((updated) => { + req.log.debug('updated work item', JSON.stringify(updated, null, 2)); + + const updatedValue = updated.get({ plain: true }); + + // emit original and updated project phase information + req.app.services.pubsub.publish( + EVENT.ROUTING_KEY.PROJECT_PHASE_PRODUCT_UPDATED, + { original: previousValue, updated: updatedValue }, + { correlationId: req.id }, + ); + util.sendResourceToKafkaBus( + req, + EVENT.ROUTING_KEY.PROJECT_PHASE_PRODUCT_UPDATED, + RESOURCES.PHASE_PRODUCT, + updatedValue, + previousValue, + ROUTES.WORK_ITEMS.UPDATE, + ); + + res.json(updated); + }).catch(err => next(err)); + }, +]; diff --git a/src/routes/workItems/update.spec.js b/src/routes/workItems/update.spec.js new file mode 100644 index 0000000..311e716 --- /dev/null +++ b/src/routes/workItems/update.spec.js @@ -0,0 +1,466 @@ +/* eslint-disable no-unused-expressions */ +/** + * Tests for update.js + */ +import _ from 'lodash'; +import chai from 'chai'; +import request from 'supertest'; +import sinon from 'sinon'; + +import server from '../../app'; +import models from '../../models'; +import testUtil from '../../tests/util'; +import busApi from '../../services/busApi'; +import { BUS_API_EVENT, RESOURCES, CONNECT_NOTIFICATION_EVENT } from '../../constants'; + +const should = chai.should(); + +const body = { + name: 'test phase product', + type: 'product1', + estimatedPrice: 20.0, + actualPrice: 1.23456, + details: { + message: 'This can be any json', + }, + createdBy: 1, + updatedBy: 1, +}; + +const updateBody = { + name: 'test phase product xxx', + type: 'product2', + estimatedPrice: 123456.789, + actualPrice: 9.8765432, + details: { + message: 'This is another json', + }, +}; + +describe('UPDATE Work Item', () => { + let projectId; + let workStreamId; + let workId; + let productId; + + const memberUser = { + handle: testUtil.getDecodedToken(testUtil.jwts.member).handle, + userId: testUtil.getDecodedToken(testUtil.jwts.member).userId, + firstName: 'fname', + lastName: 'lName', + email: 'some@abc.com', + }; + const copilotUser = { + handle: testUtil.getDecodedToken(testUtil.jwts.copilot).handle, + userId: testUtil.getDecodedToken(testUtil.jwts.copilot).userId, + firstName: 'fname', + lastName: 'lName', + email: 'some@abc.com', + }; + + beforeEach((done) => { + testUtil.clearDb() + .then(() => { + models.ProjectTemplate.create({ + name: 'template 2', + key: 'key 2', + category: 'category 2', + icon: 'http://example.com/icon1.ico', + question: 'question 2', + info: 'info 2', + aliases: ['key-2', 'key_2'], + scope: {}, + phases: {}, + createdBy: 1, + updatedBy: 2, + }) + .then((template) => { + models.WorkManagementPermission.create({ + policy: 'workItem.edit', + permission: { + allowRule: { + projectRoles: ['customer', 'copilot'], + topcoderRoles: ['Connect Manager', 'Connect Admin', 'administrator'], + }, + denyRule: { projectRoles: ['copilot'] }, + }, + projectTemplateId: template.id, + details: {}, + createdBy: 1, + updatedBy: 1, + lastActivityAt: 1, + lastActivityUserId: '1', + }) + .then(() => { + // Create projects + models.Project.create({ + type: 'generic', + billingAccountId: 1, + name: 'test1', + description: 'test project1', + status: 'draft', + templateId: template.id, + details: {}, + createdBy: 1, + updatedBy: 1, + lastActivityAt: 1, + lastActivityUserId: '1', + }) + .then((project) => { + projectId = project.id; + models.WorkStream.create({ + name: 'Work Stream', + type: 'generic', + status: 'active', + projectId, + createdBy: 1, + updatedBy: 1, + }).then((entity) => { + workStreamId = entity.id; + models.ProjectPhase.create({ + name: 'test project phase', + status: 'active', + startDate: '2018-05-15T00:00:00Z', + endDate: '2018-05-15T12:00:00Z', + budget: 20.0, + progress: 1.23456, + details: { + message: 'This can be any json', + }, + createdBy: 1, + updatedBy: 1, + projectId, + }).then((phase) => { + workId = phase.id; + models.PhaseWorkStream.create({ + phaseId: workId, + workStreamId, + }) + .then(() => { + _.assign(body, { phaseId: workId, projectId }); + models.PhaseProduct.create(body).then((product) => { + productId = product.id; + // create members + models.ProjectMember.bulkCreate([{ + id: 1, + userId: copilotUser.userId, + projectId, + role: 'copilot', + isPrimary: false, + createdBy: 1, + updatedBy: 1, + }, { + id: 2, + userId: memberUser.userId, + projectId, + role: 'customer', + isPrimary: true, + createdBy: 1, + updatedBy: 1, + }]).then(() => done()); + }); + }); + }); + }); + }); + }); + }); + }); + }); + + after((done) => { + testUtil.clearDb(done); + }); + + describe('PATCH/projects/{projectId}/workstreams/{workStreamId}/works/{workId}/workitems/{productId}', () => { + it('should return 403 if user is not authenticated', (done) => { + request(server) + .patch(`/v5/projects/${projectId}/workstreams/${workStreamId}/works/${workId}/workitems/${productId}`) + .send(updateBody) + .expect(403, done); + }); + + it('should return 403 for copilot', (done) => { + request(server) + .patch(`/v5/projects/${projectId}/workstreams/${workStreamId}/works/${workId}/workitems/${productId}`) + .set({ + Authorization: `Bearer ${testUtil.jwts.copilot}`, + }) + .send(updateBody) + .expect(403, done); + }); + + it('should return 404 when no work stream with specific workStreamId', (done) => { + request(server) + .patch(`/v5/projects/${projectId}/workstreams/999/works/${workId}/workitems/${productId}`) + .set({ + Authorization: `Bearer ${testUtil.jwts.manager}`, + }) + .send(updateBody) + .expect('Content-Type', /json/) + .expect(404, done); + }); + + it('should return 404 when no work with specific workId', (done) => { + request(server) + .patch(`/v5/projects/${projectId}/workstreams/${workStreamId}/works/999/workitems/${productId}`) + .set({ + Authorization: `Bearer ${testUtil.jwts.manager}`, + }) + .send(updateBody) + .expect('Content-Type', /json/) + .expect(404, done); + }); + + it('should return 400 when parameters are invalid', (done) => { + request(server) + .patch(`/v5/projects/${projectId}/workstreams/${workStreamId}/works/999/workitems/99999`) + .set({ + Authorization: `Bearer ${testUtil.jwts.manager}`, + }) + .send({ + param: { + estimatedPrice: -15, + }, + }) + .expect('Content-Type', /json/) + .expect(400, done); + }); + + it('should return 200 for member', (done) => { + request(server) + .patch(`/v5/projects/${projectId}/workstreams/${workStreamId}/works/${workId}/workitems/${productId}`) + .set({ + Authorization: `Bearer ${testUtil.jwts.member}`, + }) + .send(updateBody) + .expect(200, done); + }); + + it('should return updated product when user have permission and parameters are valid', (done) => { + request(server) + .patch(`/v5/projects/${projectId}/workstreams/${workStreamId}/works/${workId}/workitems/${productId}`) + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .send(updateBody) + .expect('Content-Type', /json/) + .expect(200) + .end((err, res) => { + if (err) { + done(err); + } else { + const resJson = res.body; + should.exist(resJson); + resJson.name.should.be.eql(updateBody.name); + resJson.type.should.be.eql(updateBody.type); + resJson.estimatedPrice.should.be.eql(updateBody.estimatedPrice); + resJson.actualPrice.should.be.eql(updateBody.actualPrice); + resJson.details.should.be.eql(updateBody.details); + done(); + } + }); + }); + + describe('Bus api', () => { + let createEventSpy; + const sandbox = sinon.sandbox.create(); + + before((done) => { + // Wait for 500ms in order to wait for createEvent calls from previous tests to complete + testUtil.wait(done); + }); + + beforeEach(() => { + createEventSpy = sandbox.spy(busApi, 'createEvent'); + }); + + afterEach(() => { + sandbox.restore(); + }); + + it('should send correct BUS API messages when name updated', (done) => { + request(server) + .patch(`/v5/projects/${projectId}/workstreams/${workStreamId}/works/${workId}/workitems/${productId}`) + .set({ + Authorization: `Bearer ${testUtil.jwts.member}`, + }) + .send({ + name: 'new name', + }) + .expect('Content-Type', /json/) + .expect(200) + .end((err) => { + if (err) { + done(err); + } else { + testUtil.wait(() => { + createEventSpy.callCount.should.be.eql(2); + + createEventSpy.calledWith(BUS_API_EVENT.PROJECT_PHASE_PRODUCT_UPDATED, sinon.match({ + resource: RESOURCES.PHASE_PRODUCT, + name: 'new name', + })).should.be.true; + + // Check Notification Service events + createEventSpy.calledWith(CONNECT_NOTIFICATION_EVENT.PROJECT_PLAN_UPDATED, sinon.match({ + projectId: 1, + projectName: 'test1', + projectUrl: 'https://local.topcoder-dev.com/projects/1', + userId: 40051331, + initiatorUserId: 40051331, + })).should.be.true; + + done(); + }); + } + }); + }); + + it('should send correct BUS API messages when estimatedPrice updated', (done) => { + request(server) + .patch(`/v5/projects/${projectId}/workstreams/${workStreamId}/works/${workId}/workitems/${productId}`) + .set({ + Authorization: `Bearer ${testUtil.jwts.member}`, + }) + .send({ + estimatedPrice: 123, + }) + .expect('Content-Type', /json/) + .expect(200) + .end((err) => { + if (err) { + done(err); + } else { + testUtil.wait(() => { + createEventSpy.callCount.should.be.eql(2); + + createEventSpy.calledWith(BUS_API_EVENT.PROJECT_PHASE_PRODUCT_UPDATED, sinon.match({ + resource: RESOURCES.PHASE_PRODUCT, + estimatedPrice: 123, + })).should.be.true; + + // Check Notification Service events + createEventSpy.calledWith(CONNECT_NOTIFICATION_EVENT.PROJECT_PLAN_UPDATED, sinon.match({ + projectId: 1, + projectName: 'test1', + projectUrl: 'https://local.topcoder-dev.com/projects/1', + userId: 40051331, + initiatorUserId: 40051331, + })).should.be.true; + + done(); + }); + } + }); + }); + + it('should send correct BUS API messages when actualPrice updated', (done) => { + request(server) + .patch(`/v5/projects/${projectId}/workstreams/${workStreamId}/works/${workId}/workitems/${productId}`) + .set({ + Authorization: `Bearer ${testUtil.jwts.member}`, + }) + .send({ + actualPrice: 123, + }) + .expect('Content-Type', /json/) + .expect(200) + .end((err) => { + if (err) { + done(err); + } else { + testUtil.wait(() => { + createEventSpy.callCount.should.be.eql(2); + + createEventSpy.calledWith(BUS_API_EVENT.PROJECT_PHASE_PRODUCT_UPDATED, sinon.match({ + resource: RESOURCES.PHASE_PRODUCT, + actualPrice: 123, + })).should.be.true; + + // Check Notification Service events + createEventSpy.calledWith(CONNECT_NOTIFICATION_EVENT.PROJECT_PLAN_UPDATED, sinon.match({ + projectId: 1, + projectName: 'test1', + projectUrl: 'https://local.topcoder-dev.com/projects/1', + userId: 40051331, + initiatorUserId: 40051331, + })).should.be.true; + + done(); + }); + } + }); + }); + + it('should send correct BUS API messages when details updated', (done) => { + request(server) + .patch(`/v5/projects/${projectId}/workstreams/${workStreamId}/works/${workId}/workitems/${productId}`) + .set({ + Authorization: `Bearer ${testUtil.jwts.member}`, + }) + .send({ + details: 'something', + }) + .expect('Content-Type', /json/) + .expect(200) + .end((err) => { + if (err) { + done(err); + } else { + testUtil.wait(() => { + createEventSpy.callCount.should.be.eql(3); + + createEventSpy.calledWith(BUS_API_EVENT.PROJECT_PHASE_PRODUCT_UPDATED, sinon.match({ + resource: RESOURCES.PHASE_PRODUCT, + details: 'something', + })).should.be.true; + + // Check Notification Service events + createEventSpy.calledWith(CONNECT_NOTIFICATION_EVENT.PROJECT_WORKITEM_SPECIFICATION_MODIFIED) + .should.be.true; + createEventSpy.calledWith(CONNECT_NOTIFICATION_EVENT.PROJECT_PLAN_UPDATED, sinon.match({ + projectId: 1, + projectName: 'test1', + projectUrl: 'https://local.topcoder-dev.com/projects/1', + userId: 40051331, + initiatorUserId: 40051331, + })).should.be.true; + + done(); + }); + } + }); + }); + + it('should send correct BUS API messages when type updated', (done) => { + request(server) + .patch(`/v5/projects/${projectId}/workstreams/${workStreamId}/works/${workId}/workitems/${productId}`) + .set({ + Authorization: `Bearer ${testUtil.jwts.member}`, + }) + .send({ + type: 'another type', + }) + .expect('Content-Type', /json/) + .expect(200) + .end((err) => { + if (err) { + done(err); + } else { + testUtil.wait(() => { + createEventSpy.callCount.should.be.eql(1); + + createEventSpy.calledWith(BUS_API_EVENT.PROJECT_PHASE_PRODUCT_UPDATED, sinon.match({ + resource: RESOURCES.PHASE_PRODUCT, + type: 'another type', + })).should.be.true; + + done(); + }); + } + }); + }); + }); + }); +}); diff --git a/src/routes/workManagementPermissions/create.js b/src/routes/workManagementPermissions/create.js new file mode 100644 index 0000000..d5be1c7 --- /dev/null +++ b/src/routes/workManagementPermissions/create.js @@ -0,0 +1,59 @@ +/* eslint-disable max-len */ +/** + * API to add a work management permission + */ +import validate from 'express-validation'; +import _ from 'lodash'; +import Joi from 'joi'; +import { middleware as tcMiddleware } from 'tc-core-library-js'; +import models from '../../models'; + +const permissions = tcMiddleware.permissions; + +const schema = { + body: Joi.object().keys({ + policy: Joi.string().max(255).required(), + permission: Joi.object().required(), + projectTemplateId: 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), + permissions('workManagementPermission.create'), + (req, res, next) => { + const entity = _.assign(req.body, { + createdBy: req.authUser.userId, + updatedBy: req.authUser.userId, + }); + + // Check if already exists + return models.WorkManagementPermission.findOne({ + where: { + policy: entity.policy, + projectTemplateId: entity.projectTemplateId, + }, + paranoid: false, + }) + .then((existing) => { + if (existing) { + const apiErr = new Error(`Work Management Permission already exists (may be deleted) for policy "${entity.policy}" and project template id ${entity.projectTemplateId}`); + apiErr.status = 400; + return Promise.reject(apiErr); + } + + // Create + return models.WorkManagementPermission.create(entity); + }).then((createdEntity) => { + // Omit deletedAt, deletedBy + res.status(201).json(_.omit(createdEntity.toJSON(), 'deletedAt', 'deletedBy')); + }) + .catch(next); + }, +]; diff --git a/src/routes/workManagementPermissions/create.spec.js b/src/routes/workManagementPermissions/create.spec.js new file mode 100644 index 0000000..8c8eb19 --- /dev/null +++ b/src/routes/workManagementPermissions/create.spec.js @@ -0,0 +1,203 @@ +/** + * 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 work management permission', () => { + let templateId; + + const body = { + policy: 'work.create', + permission: { + allowRule: { + projectRoles: ['customer', 'copilot'], + topcoderRoles: ['Connect Manager', 'Connect Admin', 'administrator'], + }, + denyRule: { projectRoles: ['copilot'] }, + }, + createdBy: 1, + updatedBy: 1, + }; + + beforeEach((done) => { + testUtil.clearDb() + .then(() => { + models.ProjectTemplate.create({ + name: 'template 2', + key: 'key 2', + category: 'category 2', + icon: 'http://example.com/icon1.ico', + question: 'question 2', + info: 'info 2', + aliases: ['key-2', 'key_2'], + scope: {}, + phases: {}, + createdBy: 1, + updatedBy: 2, + }) + .then((t) => { + templateId = t.id; + body.projectTemplateId = templateId; + }).then(() => done()); + }); + }); + + after((done) => { + testUtil.clearDb(done); + }); + + describe('POST /projects/metadata/workManagementPermission', () => { + it('should return 403 if user is not authenticated', (done) => { + request(server) + .post('/v5/projects/metadata/workManagementPermission') + .send(body) + .expect(403, done); + }); + + it('should return 403 for member', (done) => { + request(server) + .post('/v5/projects/metadata/workManagementPermission') + .set({ + Authorization: `Bearer ${testUtil.jwts.member}`, + }) + .send(body) + .expect(403, done); + }); + + it('should return 403 for copilot', (done) => { + request(server) + .post('/v5/projects/metadata/workManagementPermission') + .set({ + Authorization: `Bearer ${testUtil.jwts.copilot}`, + }) + .send(body) + .expect(403, done); + }); + + it('should return 403 for manager', (done) => { + request(server) + .post('/v5/projects/metadata/workManagementPermission') + .set({ + Authorization: `Bearer ${testUtil.jwts.manager}`, + }) + .send(body) + .expect(403, done); + }); + + it('should return 403 for non-member', (done) => { + request(server) + .post('/v5/projects/metadata/workManagementPermission') + .set({ + Authorization: `Bearer ${testUtil.jwts.member2}`, + }) + .send(body) + .expect(403, done); + }); + + it('should return 400 for missing policy', (done) => { + const invalidBody = _.cloneDeep(body); + delete invalidBody.policy; + + request(server) + .post('/v5/projects/metadata/workManagementPermission') + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .send(invalidBody) + .expect('Content-Type', /json/) + .expect(400, done); + }); + + it('should return 400 for missing permission', (done) => { + const invalidBody = _.cloneDeep(body); + delete invalidBody.permission; + + request(server) + .post('/v5/projects/metadata/workManagementPermission') + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .send(invalidBody) + .expect('Content-Type', /json/) + .expect(400, done); + }); + + it('should return 400 for missing projectTemplateId', (done) => { + const invalidBody = _.cloneDeep(body); + delete invalidBody.projectTemplateId; + + request(server) + .post('/v5/projects/metadata/workManagementPermission') + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .send(invalidBody) + .expect('Content-Type', /json/) + .expect(400, done); + }); + + it('should return 400 for duplicated policy and projectTemplateId', (done) => { + models.WorkManagementPermission.create(body) + .then(() => { + request(server) + .post('/v5/projects/metadata/workManagementPermission') + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .send(body) + .expect('Content-Type', /json/) + .expect(400, done); + }); + }); + + it('should return 400 for deleted but duplicated policy and projectTemplateId', (done) => { + models.WorkManagementPermission.create(body) + .then((permission) => { + models.WorkManagementPermission.destroy({ where: { id: permission.id } }); + }) + .then(() => { + request(server) + .post('/v5/projects/metadata/workManagementPermission') + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .send(body) + .expect('Content-Type', /json/) + .expect(400, done); + }); + }); + + it('should return 201 for admin', (done) => { + request(server) + .post('/v5/projects/metadata/workManagementPermission') + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .send(body) + .expect('Content-Type', /json/) + .expect(201) + .end((err, res) => { + const resJson = res.body; + resJson.policy.should.be.eql(body.policy); + resJson.permission.should.be.eql(body.permission); + resJson.projectTemplateId.should.be.eql(body.projectTemplateId); + 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(); + }); + }); + }); +}); diff --git a/src/routes/workManagementPermissions/delete.js b/src/routes/workManagementPermissions/delete.js new file mode 100644 index 0000000..1228918 --- /dev/null +++ b/src/routes/workManagementPermissions/delete.js @@ -0,0 +1,37 @@ +/** + * API to delete a work management permission + */ +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: { + id: Joi.number().integer().positive().required(), + }, +}; + +module.exports = [ + validate(schema), + permissions('workManagementPermission.delete'), + (req, res, next) => + models.sequelize.transaction(() => + models.WorkManagementPermission.findByPk(req.params.id) + .then((entity) => { + if (!entity) { + const apiErr = new Error(`Work Management Permission not found for id ${req.params.id}`); + 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/workManagementPermissions/delete.spec.js b/src/routes/workManagementPermissions/delete.spec.js new file mode 100644 index 0000000..9a24dcf --- /dev/null +++ b/src/routes/workManagementPermissions/delete.spec.js @@ -0,0 +1,211 @@ +/** + * Tests for delete.js + */ +import _ from 'lodash'; +import request from 'supertest'; +import chai from 'chai'; +import models from '../../models'; +import server from '../../app'; +import testUtil from '../../tests/util'; + +const expectAfterDelete = (permissionId, err, next) => { + if (err) throw err; + setTimeout(() => + models.WorkManagementPermission.findOne({ + where: { + id: permissionId, + }, + 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(`/v5/projects/metadata/workManagementPermission/${permissionId}`) + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .expect(404) + .end(next); + } + }), 500); +}; + +describe('DELETE work management permission', () => { + let permissionId; + + const memberUser = { + handle: testUtil.getDecodedToken(testUtil.jwts.member).handle, + userId: testUtil.getDecodedToken(testUtil.jwts.member).userId, + firstName: 'fname', + lastName: 'lName', + email: 'some@abc.com', + }; + const copilotUser = { + handle: testUtil.getDecodedToken(testUtil.jwts.copilot).handle, + userId: testUtil.getDecodedToken(testUtil.jwts.copilot).userId, + firstName: 'fname', + lastName: 'lName', + email: 'some@abc.com', + }; + + let permission = { + policy: 'work.create', + permission: { + allowRule: { + projectRoles: ['customer', 'copilot'], + topcoderRoles: ['Connect Manager', 'Connect Admin', 'administrator'], + }, + denyRule: { projectRoles: ['copilot'] }, + }, + createdBy: 1, + updatedBy: 1, + }; + + beforeEach((done) => { + testUtil.clearDb() + .then(() => { + models.ProjectTemplate.create({ + name: 'template 2', + key: 'permissionId 2', + category: 'category 2', + icon: 'http://example.com/icon1.ico', + question: 'question 2', + info: 'info 2', + aliases: ['permissionId-2', 'key_2'], + scope: {}, + phases: {}, + createdBy: 1, + updatedBy: 2, + }) + .then((t) => { + permission = _.assign({}, permission, { projectTemplateId: t.id }); + // Create projects + models.Project.create({ + type: 'generic', + billingAccountId: 1, + name: 'test1', + description: 'test project1', + status: 'draft', + templateId: t.id, + details: {}, + createdBy: 1, + updatedBy: 1, + lastActivityAt: 1, + lastActivityUserId: '1', + }) + .then((project) => { + // create members + models.ProjectMember.bulkCreate([{ + id: 1, + userId: copilotUser.userId, + projectId: project.id, + role: 'copilot', + isPrimary: false, + createdBy: 1, + updatedBy: 1, + }, { + id: 2, + userId: memberUser.userId, + projectId: project.id, + role: 'customer', + isPrimary: true, + createdBy: 1, + updatedBy: 1, + }]).then(() => { + models.WorkManagementPermission.create(permission) + .then((p) => { + permissionId = p.id; + }) + .then(() => done()); + }); + }); + }); + }); + }); + + after((done) => { + testUtil.clearDb(done); + }); + + + describe('DELETE /projects/metadata/workManagementPermission/{permissionId}', () => { + it('should return 403 if user is not authenticated', (done) => { + request(server) + .delete(`/v5/projects/metadata/workManagementPermission/${permissionId}`) + .expect(403, done); + }); + + it('should return 403 for member', (done) => { + request(server) + .delete(`/v5/projects/metadata/workManagementPermission/${permissionId}`) + .set({ + Authorization: `Bearer ${testUtil.jwts.member}`, + }) + .expect(403, done); + }); + + it('should return 403 for copilot', (done) => { + request(server) + .delete(`/v5/projects/metadata/workManagementPermission/${permissionId}`) + .set({ + Authorization: `Bearer ${testUtil.jwts.copilot}`, + }) + .expect(403, done); + }); + + it('should return 403 for manager', (done) => { + request(server) + .delete(`/v5/projects/metadata/workManagementPermission/${permissionId}`) + .set({ + Authorization: `Bearer ${testUtil.jwts.manager}`, + }) + .expect(403, done); + }); + + it('should return 404 for non-existed permission', (done) => { + request(server) + .delete('/v5/projects/metadata/workManagementPermission/123') + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .expect(404, done); + }); + + it('should return 404 for deleted permission', (done) => { + models.WorkManagementPermission.destroy({ where: { id: permissionId } }) + .then(() => { + request(server) + .delete(`/v5/projects/metadata/workManagementPermission/${permissionId}`) + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .expect(404, done); + }); + }); + + it('should return 204, for admin, if permission was successfully removed', (done) => { + request(server) + .delete(`/v5/projects/metadata/workManagementPermission/${permissionId}`) + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .expect(204) + .end(err => expectAfterDelete(permissionId, err, done)); + }); + + it('should return 204, for connect admin, if permission was successfully removed', (done) => { + request(server) + .delete(`/v5/projects/metadata/workManagementPermission/${permissionId}`) + .set({ + Authorization: `Bearer ${testUtil.jwts.connectAdmin}`, + }) + .expect(204) + .end(err => expectAfterDelete(permissionId, err, done)); + }); + }); +}); diff --git a/src/routes/workManagementPermissions/get.js b/src/routes/workManagementPermissions/get.js new file mode 100644 index 0000000..eab1bb1 --- /dev/null +++ b/src/routes/workManagementPermissions/get.js @@ -0,0 +1,38 @@ +/** + * API to get a work management permission + */ +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: { + id: Joi.number().integer().positive().required(), + }, +}; + +module.exports = [ + validate(schema), + permissions('workManagementPermission.view'), + (req, res, next) => models.WorkManagementPermission.findOne({ + where: { + id: req.params.id, + }, + attributes: { exclude: ['deletedAt', 'deletedBy'] }, + }) + .then((existing) => { + // Not found + if (!existing) { + const apiErr = new Error(`Work Management Permission not found for id ${req.params.id}`); + apiErr.status = 404; + return Promise.reject(apiErr); + } + + res.json(existing); + return Promise.resolve(); + }) + .catch(next), +]; diff --git a/src/routes/workManagementPermissions/get.spec.js b/src/routes/workManagementPermissions/get.spec.js new file mode 100644 index 0000000..2945768 --- /dev/null +++ b/src/routes/workManagementPermissions/get.spec.js @@ -0,0 +1,149 @@ +/** + * 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('GET work management permission', () => { + let permissionId; + + let permission = { + policy: 'work.create', + permission: { + allowRule: { + projectRoles: ['customer', 'copilot'], + topcoderRoles: ['Connect Manager', 'Connect Admin', 'administrator'], + }, + denyRule: { projectRoles: ['copilot'] }, + }, + createdBy: 1, + updatedBy: 1, + }; + + beforeEach((done) => { + testUtil.clearDb() + .then(() => { + models.ProjectTemplate.create({ + name: 'template 2', + key: 'key 2', + category: 'category 2', + icon: 'http://example.com/icon1.ico', + question: 'question 2', + info: 'info 2', + aliases: ['key-2', 'key_2'], + scope: {}, + phases: {}, + createdBy: 1, + updatedBy: 2, + }) + .then((t) => { + permission = _.assign({}, permission, { projectTemplateId: t.id }); + models.WorkManagementPermission.create(permission) + .then((p) => { + permissionId = p.id; + }) + .then(() => done()); + }); + }); + }); + + after((done) => { + testUtil.clearDb(done); + }); + + describe('GET /projects/metadata/workManagementPermission/{permissionId}', () => { + it('should return 403 if user is not authenticated', (done) => { + request(server) + .get(`/v5/projects/metadata/workManagementPermission/${permissionId}`) + .expect(403, done); + }); + + it('should return 403 for member', (done) => { + request(server) + .get(`/v5/projects/metadata/workManagementPermission/${permissionId}`) + .set({ + Authorization: `Bearer ${testUtil.jwts.member}`, + }) + .expect(403, done); + }); + + it('should return 403 for copilot', (done) => { + request(server) + .get(`/v5/projects/metadata/workManagementPermission/${permissionId}`) + .set({ + Authorization: `Bearer ${testUtil.jwts.copilot}`, + }) + .expect(403, done); + }); + + it('should return 403 for manager', (done) => { + request(server) + .get(`/v5/projects/metadata/workManagementPermission/${permissionId}`) + .set({ + Authorization: `Bearer ${testUtil.jwts.manager}`, + }) + .expect(403, done); + }); + + it('should return 403 for non-member', (done) => { + request(server) + .get(`/v5/projects/metadata/workManagementPermission/${permissionId}`) + .set({ + Authorization: `Bearer ${testUtil.jwts.member2}`, + }) + .expect(403, done); + }); + + it('should return 404 for non-existed permission', (done) => { + request(server) + .get('/v5/projects/metadata/workManagementPermission/1234') + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .expect(404, done); + }); + + it('should return 404 for deleted permission', (done) => { + models.WorkManagementPermission.destroy({ where: { id: permissionId } }) + .then(() => { + request(server) + .get(`/v5/projects/metadata/workManagementPermission/${permissionId}`) + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .expect(404, done); + }); + }); + + it('should return 200 for admin', (done) => { + request(server) + .get(`/v5/projects/metadata/workManagementPermission/${permissionId}`) + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .expect(200) + .end((err, res) => { + const resJson = res.body; + resJson.id.should.be.eql(permissionId); + resJson.policy.should.be.eql(permission.policy); + resJson.permission.should.be.eql(permission.permission); + resJson.projectTemplateId.should.be.eql(permission.projectTemplateId); + resJson.createdBy.should.be.eql(permission.createdBy); + should.exist(resJson.createdAt); + resJson.updatedBy.should.be.eql(permission.updatedBy); + should.exist(resJson.updatedAt); + should.not.exist(resJson.deletedBy); + should.not.exist(resJson.deletedAt); + + done(); + }); + }); + }); +}); diff --git a/src/routes/workManagementPermissions/list.js b/src/routes/workManagementPermissions/list.js new file mode 100644 index 0000000..cef27bd --- /dev/null +++ b/src/routes/workManagementPermissions/list.js @@ -0,0 +1,43 @@ +/** + * API to list all work management permissions + */ +import validate from 'express-validation'; +import { middleware as tcMiddleware } from 'tc-core-library-js'; +import Joi from 'joi'; +import util from '../../util'; +import models from '../../models'; + +const permissions = tcMiddleware.permissions; + +const schema = { + query: { + filter: Joi.string().required(), + }, +}; + +module.exports = [ + validate(schema), + permissions('workManagementPermission.view'), + (req, res, next) => { + // handle filters + const filters = util.parseQueryFilter(req.query.filter); + // Throw error if projectTemplateId is not present in filter + if (!filters.projectTemplateId) { + return next(util.buildApiError('Missing filter projectTemplateId', 400)); + } + if (!util.isValidFilter(filters, ['projectTemplateId'])) { + return util.handleError('Invalid filters', null, req, next); + } + req.log.debug(filters); + + return models.WorkManagementPermission.findAll({ + where: filters, + attributes: { exclude: ['deletedAt', 'deletedBy'] }, + raw: true, + }) + .then((result) => { + res.json(result); + }) + .catch(next); + }, +]; diff --git a/src/routes/workManagementPermissions/list.spec.js b/src/routes/workManagementPermissions/list.spec.js new file mode 100644 index 0000000..0a9728a --- /dev/null +++ b/src/routes/workManagementPermissions/list.spec.js @@ -0,0 +1,241 @@ +/* eslint-disable max-len */ +/** + * Tests for list.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('LIST work management permissions', () => { + let templateIds; + + const templates = [ + { + name: 'template 1', + key: 'key 1', + category: 'category 1', + icon: 'http://example.com/icon1.ico', + question: 'question 1', + info: 'info 1', + aliases: ['key-1', 'key_1'], + disabled: true, + hidden: true, + scope: { + scope1: { + subScope1A: 1, + subScope1B: 2, + }, + scope2: [1, 2, 3], + }, + phases: { + phase1: { + name: 'phase 1', + details: { + anyDetails: 'any details 1', + }, + others: ['others 11', 'others 12'], + }, + phase2: { + name: 'phase 2', + details: { + anyDetails: 'any details 2', + }, + others: ['others 21', 'others 22'], + }, + }, + createdBy: 1, + updatedBy: 1, + }, + { + name: 'template 2', + key: 'key 2', + category: 'category 2', + icon: 'http://example.com/icon1.ico', + question: 'question 2', + info: 'info 2', + aliases: ['key-2', 'key_2'], + scope: {}, + phases: {}, + createdBy: 1, + updatedBy: 2, + }, + ]; + const permissions = [ + { + policy: 'work.create', + permission: { + allowRule: { + projectRoles: ['customer', 'copilot'], + topcoderRoles: ['Connect Manager', 'Connect Admin', 'administrator'], + }, + denyRule: { projectRoles: ['copilot'] }, + }, + createdBy: 1, + updatedBy: 1, + }, + { + policy: 'work.edit', + permission: { + allowRule: { + projectRoles: ['customer', 'copilot'], + topcoderRoles: ['Connect Manager', 'Connect Admin', 'administrator'], + }, + denyRule: { projectRoles: ['copilot'] }, + }, + createdBy: 1, + updatedBy: 1, + }, + ]; + beforeEach((done) => { + testUtil.clearDb() + .then(() => { + models.ProjectTemplate.bulkCreate(templates, { returning: true }) + .then((t) => { + templateIds = _.map(t, template => template.id); + const newPermissions = _.map(permissions, p => _.assign({}, p, { projectTemplateId: templateIds[0] })); + newPermissions.push(_.assign({}, permissions[0], { projectTemplateId: templateIds[1] })); + models.WorkManagementPermission.bulkCreate(newPermissions, { returning: true }) + .then(() => done()); + }); + }); + }); + + after((done) => { + testUtil.clearDb(done); + }); + + describe('GET /projects/metadata/workManagementPermission', () => { + it('should return 403 if user is not authenticated', (done) => { + request(server) + .get('/v5/projects/metadata/workManagementPermission?filter=projectTemplateId%3D1') + .expect(403, done); + }); + + it('should return 403 for member', (done) => { + request(server) + .get('/v5/projects/metadata/workManagementPermission?filter=projectTemplateId%3D1') + .set({ + Authorization: `Bearer ${testUtil.jwts.member}`, + }) + .expect(403, done); + }); + + it('should return 403 for copilot', (done) => { + request(server) + .get('/v5/projects/metadata/workManagementPermission?filter=projectTemplateId%3D1') + .set({ + Authorization: `Bearer ${testUtil.jwts.copilot}`, + }) + .expect(403, done); + }); + + it('should return 403 for manager', (done) => { + request(server) + .get('/v5/projects/metadata/workManagementPermission?filter=projectTemplateId%3D1') + .set({ + Authorization: `Bearer ${testUtil.jwts.manager}`, + }) + .expect(403, done); + }); + + it('should return 403 for non-member', (done) => { + request(server) + .get('/v5/projects/metadata/workManagementPermission?filter=projectTemplateId%3D1') + .set({ + Authorization: `Bearer ${testUtil.jwts.member2}`, + }) + .expect(403, done); + }); + + it('should return 400 for missing filter', (done) => { + request(server) + .get('/v5/projects/metadata/workManagementPermission') + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .expect('Content-Type', /json/) + .expect(400, done); + }); + + it('should return 400 for missing projectTemplateId', (done) => { + request(server) + .get('/v5/projects/metadata/workManagementPermission?filter=template') + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .expect('Content-Type', /json/) + .expect(400, done); + }); + + it('should return 400 for invalid filter', (done) => { + request(server) + .get(`/v5/projects/metadata/workManagementPermission?filter=invalid%3D2%26projectTemplateId%3D${templateIds[0]}`) + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .expect('Content-Type', /json/) + .expect(500, done); + }); + + + it('should return 200 for admin for projectTemplateId=1', (done) => { + request(server) + .get(`/v5/projects/metadata/workManagementPermission?filter=projectTemplateId%3D${templateIds[0]}`) + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .expect(200) + .end((err, res) => { + const resJson = res.body; + resJson.should.have.length(2); + resJson[0].policy.should.be.eql(permissions[0].policy); + resJson[0].permission.should.be.eql(permissions[0].permission); + resJson[0].projectTemplateId.should.be.eql(templateIds[0]); + should.exist(resJson[0].createdAt); + resJson[0].updatedBy.should.be.eql(permissions[0].updatedBy); + should.exist(resJson[0].updatedAt); + should.not.exist(resJson[0].deletedBy); + should.not.exist(resJson[0].deletedAt); + resJson[1].policy.should.be.eql(permissions[1].policy); + resJson[1].permission.should.be.eql(permissions[1].permission); + resJson[1].projectTemplateId.should.be.eql(templateIds[0]); + should.exist(resJson[1].createdAt); + resJson[1].updatedBy.should.be.eql(permissions[1].updatedBy); + should.exist(resJson[1].updatedAt); + should.not.exist(resJson[1].deletedBy); + should.not.exist(resJson[1].deletedAt); + + done(); + }); + }); + + it('should return 200 for admin for projectTemplateId=2', (done) => { + request(server) + .get(`/v5/projects/metadata/workManagementPermission?filter=projectTemplateId%3D${templateIds[1]}`) + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .expect(200) + .end((err, res) => { + const resJson = res.body; + resJson.should.have.length(1); + resJson[0].policy.should.be.eql(permissions[0].policy); + resJson[0].permission.should.be.eql(permissions[0].permission); + resJson[0].projectTemplateId.should.be.eql(templateIds[1]); + should.exist(resJson[0].createdAt); + resJson[0].updatedBy.should.be.eql(permissions[0].updatedBy); + should.exist(resJson[0].updatedAt); + should.not.exist(resJson[0].deletedBy); + should.not.exist(resJson[0].deletedAt); + + done(); + }); + }); + }); +}); diff --git a/src/routes/workManagementPermissions/update.js b/src/routes/workManagementPermissions/update.js new file mode 100644 index 0000000..7e18851 --- /dev/null +++ b/src/routes/workManagementPermissions/update.js @@ -0,0 +1,81 @@ +/* eslint-disable max-len */ +/** + * API to update a work management permission + */ +import validate from 'express-validation'; +import _ from 'lodash'; +import Joi from 'joi'; +import { middleware as tcMiddleware } from 'tc-core-library-js'; +import models from '../../models'; + +const permissions = tcMiddleware.permissions; + +const schema = { + params: { + id: Joi.number().integer().positive().required(), + }, + body: Joi.object().keys({ + policy: Joi.string().max(255).optional(), + permission: Joi.object().optional(), + projectTemplateId: Joi.number().integer().positive().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('workManagementPermission.edit'), + (req, res, next) => { + const entityToUpdate = _.assign(req.body, { + updatedBy: req.authUser.userId, + }); + + let permissionToUpdate; + + return models.sequelize.transaction(() => // Get work management permission + models.WorkManagementPermission.findOne({ + where: { + id: req.params.id, + }, + attributes: { exclude: ['deletedAt', 'deletedBy'] }, + }) + .then((permission) => { + // Not found + if (!permission) { + const apiErr = new Error(`Work Management Permission not found for id ${req.params.id}`); + apiErr.status = 404; + return Promise.reject(apiErr); + } + + permissionToUpdate = permission; + return models.WorkManagementPermission.findOne({ + where: { + policy: entityToUpdate.policy, + projectTemplateId: entityToUpdate.projectTemplateId, + id: { $ne: req.params.id }, + }, + paranoid: false, + }); + }) + .then((existing) => { + if (existing) { + const apiErr = new Error(`Work Management Permission already exists (may be deleted) for policy "${entityToUpdate.policy}" and project template id ${entityToUpdate.projectTemplateId}`); + apiErr.status = 400; + return Promise.reject(apiErr); + } + + return permissionToUpdate.update(entityToUpdate); + }), + ) + .then((updated) => { + res.json(updated); + return Promise.resolve(); + }) + .catch(next); + }, +]; diff --git a/src/routes/workManagementPermissions/update.spec.js b/src/routes/workManagementPermissions/update.spec.js new file mode 100644 index 0000000..cb16248 --- /dev/null +++ b/src/routes/workManagementPermissions/update.spec.js @@ -0,0 +1,248 @@ +/** + * Tests for update.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 work management permission', () => { + let permissionId; + let templateId; + + let permission = { + policy: 'work.create', + permission: { + allowRule: { + projectRoles: ['customer', 'copilot'], + topcoderRoles: ['Connect Manager', 'Connect Admin', 'administrator'], + }, + denyRule: { projectRoles: ['copilot'] }, + }, + createdBy: 1, + updatedBy: 1, + }; + + beforeEach((done) => { + testUtil.clearDb() + .then(() => { + models.ProjectTemplate.create({ + name: 'template 2', + key: 'key 2', + category: 'category 2', + icon: 'http://example.com/icon1.ico', + question: 'question 2', + info: 'info 2', + aliases: ['key-2', 'key_2'], + scope: {}, + phases: {}, + createdBy: 1, + updatedBy: 2, + }) + .then((t) => { + templateId = t.id; + permission = _.assign({}, permission, { projectTemplateId: templateId }); + models.WorkManagementPermission.create(permission) + .then((p) => { + permissionId = p.id; + }) + .then(() => done()); + }); + }); + }); + + after((done) => { + testUtil.clearDb(done); + }); + + describe('PATCH /projects/metadata/workManagementPermission/{permissionId}', () => { + const body = { + policy: 'work.edit', + permission: { + allowRule: { + projectRoles: ['customer', 'copilot'], + topcoderRoles: ['Connect Manager', 'Connect Admin', 'administrator'], + }, + denyRule: { projectRoles: ['copilot'] }, + }, + createdBy: 1, + updatedBy: 1, + }; + + it('should return 403 if user is not authenticated', (done) => { + request(server) + .patch(`/v5/projects/metadata/workManagementPermission/${permissionId}`) + .send(body) + .expect(403, done); + }); + + it('should return 403 for member', (done) => { + request(server) + .patch(`/v5/projects/metadata/workManagementPermission/${permissionId}`) + .set({ + Authorization: `Bearer ${testUtil.jwts.member}`, + }) + .send(body) + .expect(403, done); + }); + + it('should return 403 for copilot', (done) => { + request(server) + .patch(`/v5/projects/metadata/workManagementPermission/${permissionId}`) + .send(body) + .set({ + Authorization: `Bearer ${testUtil.jwts.copilot}`, + }) + .expect(403, done); + }); + + it('should return 403 for manager', (done) => { + request(server) + .patch(`/v5/projects/metadata/workManagementPermission/${permissionId}`) + .send(body) + .set({ + Authorization: `Bearer ${testUtil.jwts.manager}`, + }) + .expect(403, done); + }); + + it('should return 403 for non-member', (done) => { + request(server) + .patch(`/v5/projects/metadata/workManagementPermission/${permissionId}`) + .set({ + Authorization: `Bearer ${testUtil.jwts.member2}`, + }) + .send(body) + .expect(403, done); + }); + + it('should return 404 for non-existed type', (done) => { + request(server) + .patch('/v5/projects/metadata/workManagementPermission/1234') + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .send(body) + .expect(404) + .end(done); + }); + + it('should return 404 for deleted type', (done) => { + models.WorkManagementPermission.destroy({ where: { id: permissionId } }) + .then(() => { + request(server) + .patch(`/v5/projects/metadata/workManagementPermission/${permissionId}`) + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .send(body) + .expect(404) + .end(done); + }); + }); + + it('should return 400 when updated with invalid param', (done) => { + request(server) + .patch(`/v5/projects/metadata/workManagementPermission/${permissionId}`) + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .send({ invalid: null }) + .expect('Content-Type', /json/) + .expect(400) + .end(done); + }); + + it('should return 400 for policy and projectTemplateId updated with existing(non-deleted) values', (done) => { + const newParam = _.assign({}, body, { projectTemplateId: templateId }); + models.WorkManagementPermission.create(newParam) + .then(() => { + request(server) + .patch(`/v5/projects/metadata/workManagementPermission/${permissionId}`) + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .send(newParam) + .expect('Content-Type', /json/) + .expect(400) + .end(done); + }); + }); + + it('should return 400 for policy and projectTemplateId updated with existing(deleted) values', (done) => { + const newParam = _.assign({}, body, { projectTemplateId: templateId }); + models.WorkManagementPermission.create(newParam) + .then((p) => { + models.WorkManagementPermission.destroy({ where: { id: p.id } }); + }) + .then(() => { + request(server) + .patch(`/v5/projects/metadata/workManagementPermission/${permissionId}`) + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .send(newParam) + .expect('Content-Type', /json/) + .expect(400) + .end(done); + }); + }); + + it('should return 200 for permission updated', (done) => { + const partialBody = _.assign({}, body, { projectTemplateId: templateId }); + delete partialBody.permission; + + request(server) + .patch(`/v5/projects/metadata/workManagementPermission/${permissionId}`) + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .send(partialBody) + .expect(200) + .end((err, res) => { + const resJson = res.body; + resJson.id.should.be.eql(permissionId); + resJson.policy.should.be.eql(partialBody.policy); + resJson.permission.should.be.eql(permission.permission); + resJson.projectTemplateId.should.be.eql(permission.projectTemplateId); + resJson.createdBy.should.be.eql(permission.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) => { + const newParam = _.assign({}, body, { projectTemplateId: templateId }); + request(server) + .patch(`/v5/projects/metadata/workManagementPermission/${permissionId}`) + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .send(newParam) + .expect(200) + .end((err, res) => { + const resJson = res.body; + resJson.id.should.be.eql(permissionId); + resJson.policy.should.be.eql(body.policy); + resJson.permission.should.be.eql(body.permission); + resJson.projectTemplateId.should.be.eql(newParam.projectTemplateId); + resJson.createdBy.should.be.eql(permission.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(); + }); + }); + }); +}); diff --git a/src/routes/workStreams/create.js b/src/routes/workStreams/create.js new file mode 100644 index 0000000..bcd7221 --- /dev/null +++ b/src/routes/workStreams/create.js @@ -0,0 +1,70 @@ +/** + * API to add a work stream + */ +import validate from 'express-validation'; +import _ from 'lodash'; +import Joi from 'joi'; +import { middleware as tcMiddleware } from 'tc-core-library-js'; +import util from '../../util'; +import models from '../../models'; +import { WORKSTREAM_STATUS } from '../../constants'; + +const permissions = tcMiddleware.permissions; + +const schema = { + params: { + projectId: Joi.number().integer().positive().required(), + }, + body: Joi.object().keys({ + name: Joi.string().max(255).required(), + type: Joi.string().max(45).required(), + status: Joi.string().valid(_.values(WORKSTREAM_STATUS)).required(), + createdAt: Joi.any().strip(), + updatedAt: Joi.any().strip(), + deletedAt: Joi.any().strip(), + createdBy: Joi.any().strip(), + updatedBy: Joi.any().strip(), + deletedBy: Joi.any().strip(), + }).required(), +}; + +module.exports = [ + validate(schema), + permissions('workStream.create'), + // do the real work + (req, res, next) => { + const data = req.body; + // default values + const projectId = _.parseInt(req.params.projectId); + _.assign(data, { + projectId, + createdBy: req.authUser.userId, + updatedBy: req.authUser.userId, + }); + + models.sequelize.transaction(() => { + req.log.debug('Create WorkStream - Starting transaction'); + return models.Project.findOne({ + where: { id: projectId, deletedAt: { $eq: null } }, + }) + .then((existingProject) => { + if (!existingProject) { + const err = new Error(`active project not found for project id ${projectId}`); + err.status = 404; + throw err; + } + + return models.WorkStream.create(data); + }) + .catch(next); + }) + .then((createdEntity) => { + req.log.debug('new work stream created (id# %d, name: %s)', + createdEntity.id, createdEntity.name); + res.status(201).json(_.omit(createdEntity.toJSON(), 'deletedBy', 'deletedAt')); + }) + .catch((err) => { + util.handleError('Error creating work stream', err, req, next); + }); + }, +]; diff --git a/src/routes/workStreams/create.spec.js b/src/routes/workStreams/create.spec.js new file mode 100644 index 0000000..4a66cdb --- /dev/null +++ b/src/routes/workStreams/create.spec.js @@ -0,0 +1,255 @@ +/** + * 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 work stream', () => { + const templates = [ + { + name: 'template 1', + key: 'key 1', + category: 'category 1', + icon: 'http://example.com/icon1.ico', + question: 'question 1', + info: 'info 1', + aliases: ['key-1', 'key_1'], + disabled: true, + hidden: true, + scope: { + scope1: { + subScope1A: 1, + subScope1B: 2, + }, + scope2: [1, 2, 3], + }, + phases: { + phase1: { + name: 'phase 1', + details: { + anyDetails: 'any details 1', + }, + others: ['others 11', 'others 12'], + }, + phase2: { + name: 'phase 2', + details: { + anyDetails: 'any details 2', + }, + others: ['others 21', 'others 22'], + }, + }, + createdBy: 1, + updatedBy: 1, + }, + { + name: 'template 2', + key: 'key 2', + category: 'category 2', + icon: 'http://example.com/icon1.ico', + question: 'question 2', + info: 'info 2', + aliases: ['key-2', 'key_2'], + scope: {}, + phases: {}, + createdBy: 1, + updatedBy: 2, + }, + ]; + + let projectId; + + beforeEach((done) => { + testUtil.clearDb() + .then(() => { + models.ProjectTemplate.bulkCreate(templates, { returning: true }) + .then((t) => { + // Create projects + models.Project.create({ + type: 'generic', + billingAccountId: 1, + name: 'test1', + description: 'test project1', + status: 'draft', + templateId: t[0].id, + details: {}, + createdBy: 1, + updatedBy: 1, + lastActivityAt: 1, + lastActivityUserId: '1', + }) + .then((project) => { + projectId = project.id; + }) + .then(() => done()); + }); + }); + }); + + after((done) => { + testUtil.clearDb(done); + }); + + describe('POST /projects/{projectId}/workstreams', () => { + const body = { + name: 'Work Stream', + type: 'generic', + status: 'active', + }; + + it('should return 403 if user is not authenticated', (done) => { + request(server) + .post(`/v5/projects/${projectId}/workstreams`) + .send(body) + .expect(403, done); + }); + + it('should return 403 for member', (done) => { + request(server) + .post(`/v5/projects/${projectId}/workstreams`) + .set({ + Authorization: `Bearer ${testUtil.jwts.member}`, + }) + .send(body) + .expect(403, done); + }); + + it('should return 404 for non-existed project id', (done) => { + request(server) + .delete('/v5/projects/999/workstreams') + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .expect(404, done); + }); + + it('should return 404 for deleted type', (done) => { + models.Project.destroy({ where: { id: projectId } }) + .then(() => { + request(server) + .delete(`/v5/projects/${projectId}/workstreams`) + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .expect(404, done); + }); + }); + + it('should return 403 for copilot', (done) => { + request(server) + .post(`/v5/projects/${projectId}/workstreams`) + .set({ + Authorization: `Bearer ${testUtil.jwts.copilot}`, + }) + .send(body) + .expect(403, done); + }); + + it('should return 403 for manager', (done) => { + request(server) + .post(`/v5/projects/${projectId}/workstreams`) + .set({ + Authorization: `Bearer ${testUtil.jwts.manager}`, + }) + .send(body) + .expect(403, done); + }); + + it('should return 400 for missing type', (done) => { + const invalidBody = _.cloneDeep(body); + delete invalidBody.type; + + request(server) + .post(`/v5/projects/${projectId}/workstreams`) + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .send(invalidBody) + .expect('Content-Type', /json/) + .expect(400, done); + }); + + it('should return 400 for missing name', (done) => { + const invalidBody = _.cloneDeep(body); + delete invalidBody.name; + + request(server) + .post(`/v5/projects/${projectId}/workstreams`) + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .send(invalidBody) + .expect('Content-Type', /json/) + .expect(400, done); + }); + + it('should return 400 for status', (done) => { + const invalidBody = _.cloneDeep(body); + delete invalidBody.status; + + request(server) + .post(`/v5/projects/${projectId}/workstreams`) + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .send(invalidBody) + .expect('Content-Type', /json/) + .expect(400, done); + }); + + it('should return 201 for admin', (done) => { + request(server) + .post(`/v5/projects/${projectId}/workstreams`) + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .send(body) + .expect('Content-Type', /json/) + .expect(201) + .end((err, res) => { + const resJson = res.body; + resJson.name.should.be.eql(body.name); + resJson.type.should.be.eql(body.type); + resJson.status.should.be.eql(body.status); + resJson.projectId.should.be.eql(projectId); + + resJson.createdBy.should.be.eql(40051333); + should.exist(resJson.createdAt); + resJson.updatedBy.should.be.eql(40051333); + 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(`/v5/projects/${projectId}/workstreams`) + .set({ + Authorization: `Bearer ${testUtil.jwts.connectAdmin}`, + }) + .send(body) + .expect('Content-Type', /json/) + .expect(201) + .end((err, res) => { + const resJson = res.body; + resJson.name.should.be.eql(body.name); + resJson.type.should.be.eql(body.type); + resJson.status.should.be.eql(body.status); + resJson.projectId.should.be.eql(projectId); + resJson.createdBy.should.be.eql(40051336); + resJson.updatedBy.should.be.eql(40051336); + done(); + }); + }); + }); +}); diff --git a/src/routes/workStreams/delete.js b/src/routes/workStreams/delete.js new file mode 100644 index 0000000..aa2ef46 --- /dev/null +++ b/src/routes/workStreams/delete.js @@ -0,0 +1,44 @@ +/** + * API to delete a work stream + */ +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: { + projectId: Joi.number().integer().positive().required(), + id: Joi.number().integer().positive().required(), + }, +}; + +module.exports = [ + validate(schema), + permissions('workStream.delete'), + (req, res, next) => + models.sequelize.transaction(() => + models.WorkStream.findOne({ + where: { + id: req.params.id, + projectId: req.params.projectId, + }, + }) + .then((entity) => { + if (!entity) { + const apiErr = new Error(`Work Stream not found for id ${req.params.id} ` + + `and project id ${req.params.projectId}`); + 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/workStreams/delete.spec.js b/src/routes/workStreams/delete.spec.js new file mode 100644 index 0000000..5a3b92b --- /dev/null +++ b/src/routes/workStreams/delete.spec.js @@ -0,0 +1,168 @@ +/** + * 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, projectId, err, next) => { + if (err) throw err; + setTimeout(() => + models.WorkStream.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(`/v5/projects/${projectId}/workstreams/${id}`) + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .expect(404, next); + } + }), 500); +}; + +describe('DELETE work stream', () => { + let projectId; + let id; + + beforeEach((done) => { + testUtil.clearDb() + .then(() => { + models.ProjectTemplate.create({ + name: 'template 2', + key: 'key 2', + category: 'category 2', + icon: 'http://example.com/icon1.ico', + question: 'question 2', + info: 'info 2', + aliases: ['key-2', 'key_2'], + scope: {}, + phases: {}, + createdBy: 1, + updatedBy: 2, + }) + .then((template) => { + // Create projects + models.Project.create({ + type: 'generic', + billingAccountId: 1, + name: 'test1', + description: 'test project1', + status: 'draft', + templateId: template.id, + details: {}, + createdBy: 1, + updatedBy: 1, + lastActivityAt: 1, + lastActivityUserId: '1', + }) + .then((project) => { + projectId = project.id; + models.WorkStream.create({ + name: 'Work Stream', + type: 'generic', + status: 'active', + projectId, + createdBy: 1, + updatedBy: 1, + }).then((entity) => { + id = entity.id; + done(); + }); + }); + }); + }); + }); + + after((done) => { + testUtil.clearDb(done); + }); + + describe('DELETE /projects/{projectId}/workstreams/{id}', () => { + it('should return 403 if user is not authenticated', (done) => { + request(server) + .delete(`/v5/projects/${projectId}/workstreams/${id}`) + .expect(403, done); + }); + + it('should return 403 for member', (done) => { + request(server) + .delete(`/v5/projects/${projectId}/workstreams/${id}`) + .set({ + Authorization: `Bearer ${testUtil.jwts.member}`, + }) + .expect(403, done); + }); + + it('should return 403 for copilot', (done) => { + request(server) + .delete(`/v5/projects/${projectId}/workstreams/${id}`) + .set({ + Authorization: `Bearer ${testUtil.jwts.copilot}`, + }) + .expect(403, done); + }); + + it('should return 403 for manager', (done) => { + request(server) + .delete(`/v5/projects/${projectId}/workstreams/${id}`) + .set({ + Authorization: `Bearer ${testUtil.jwts.manager}`, + }) + .expect(403, done); + }); + + it('should return 404 for non-existed type', (done) => { + request(server) + .delete('/v5/projects/metadata/projectTypes/not_existed') + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .expect(404, done); + }); + + it('should return 404 for deleted type', (done) => { + models.WorkStream.destroy({ where: { id } }) + .then(() => { + request(server) + .delete(`/v5/projects/${projectId}/workstreams/${id}`) + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .expect(404, done); + }); + }); + + it('should return 204, for admin, if type was successfully removed', (done) => { + request(server) + .delete(`/v5/projects/${projectId}/workstreams/${id}`) + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .expect(204) + .end(err => expectAfterDelete(id, projectId, err, done)); + }); + + it('should return 204, for connect admin, if type was successfully removed', (done) => { + request(server) + .delete(`/v5/projects/${projectId}/workstreams/${id}`) + .set({ + Authorization: `Bearer ${testUtil.jwts.connectAdmin}`, + }) + .expect(204) + .end(err => expectAfterDelete(id, projectId, err, done)); + }); + }); +}); diff --git a/src/routes/workStreams/get.js b/src/routes/workStreams/get.js new file mode 100644 index 0000000..83ac929 --- /dev/null +++ b/src/routes/workStreams/get.js @@ -0,0 +1,41 @@ +/** + * API to get a work stream + */ +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: { + projectId: Joi.number().integer().positive().required(), + id: Joi.number().integer().positive().required(), + }, +}; + +module.exports = [ + validate(schema), + permissions('workStream.view'), + (req, res, next) => models.WorkStream.findOne({ + where: { + id: req.params.id, + projectId: req.params.projectId, + }, + attributes: { exclude: ['deletedAt', 'deletedBy'] }, + }) + .then((workStream) => { + // Not found + if (!workStream) { + const apiErr = new Error(`work stream not found for project id ${req.params.projectId} ` + + `and work stream id ${req.params.id}`); + apiErr.status = 404; + return Promise.reject(apiErr); + } + + res.json(workStream); + return Promise.resolve(); + }) + .catch(next), +]; diff --git a/src/routes/workStreams/get.spec.js b/src/routes/workStreams/get.spec.js new file mode 100644 index 0000000..d696da9 --- /dev/null +++ b/src/routes/workStreams/get.spec.js @@ -0,0 +1,162 @@ +/** + * 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 work stream', () => { + let projectId; + let id; + let workStream; + + beforeEach((done) => { + testUtil.clearDb() + .then(() => { + models.ProjectTemplate.create({ + name: 'template 2', + key: 'key 2', + category: 'category 2', + icon: 'http://example.com/icon1.ico', + question: 'question 2', + info: 'info 2', + aliases: ['key-2', 'key_2'], + scope: {}, + phases: {}, + createdBy: 1, + updatedBy: 2, + }) + .then((template) => { + // Create projects + models.Project.create({ + type: 'generic', + billingAccountId: 1, + name: 'test1', + description: 'test project1', + status: 'draft', + templateId: template.id, + details: {}, + createdBy: 1, + updatedBy: 1, + lastActivityAt: 1, + lastActivityUserId: '1', + }) + .then((project) => { + projectId = project.id; + models.WorkStream.create({ + name: 'Work Stream', + type: 'generic', + status: 'active', + projectId, + createdBy: 1, + updatedBy: 1, + }).then((entity) => { + id = entity.id; + workStream = entity; + done(); + }); + }); + }); + }); + }); + + after((done) => { + testUtil.clearDb(done); + }); + + describe('GET /projects/{projectId}/workstreams/{id}', () => { + it('should return 404 for non-existed work stream', (done) => { + request(server) + .get(`/v5/projects/${projectId}/workstreams/1234`) + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .expect(404, done); + }); + + it('should return 404 for deleted work stream', (done) => { + models.WorkStream.destroy({ where: { id } }) + .then(() => { + request(server) + .get(`/v5/projects/${projectId}/workstreams/${id}`) + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .expect(404, done); + }); + }); + + it('should return 200 for admin', (done) => { + request(server) + .get(`/v5/projects/${projectId}/workstreams/${id}`) + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .expect(200) + .end((err, res) => { + const resJson = res.body; + resJson.name.should.be.eql(workStream.name); + resJson.type.should.be.eql(workStream.type); + resJson.status.should.be.eql(workStream.status); + resJson.projectId.should.be.eql(workStream.projectId); + resJson.createdBy.should.be.eql(workStream.createdBy); + should.exist(resJson.createdAt); + resJson.updatedBy.should.be.eql(workStream.updatedBy); + should.exist(resJson.updatedAt); + should.not.exist(resJson.deletedBy); + should.not.exist(resJson.deletedAt); + + done(); + }); + }); + + it('should return 403 if user is not authenticated', (done) => { + request(server) + .get(`/v5/projects/${projectId}/workstreams/${id}`) + .expect(403, done); + }); + + it('should return 403 for member', (done) => { + request(server) + .get(`/v5/projects/${projectId}/workstreams/${id}`) + .set({ + Authorization: `Bearer ${testUtil.jwts.member}`, + }) + .expect(403, done); + }); + + it('should return 403 for copilot', (done) => { + request(server) + .get(`/v5/projects/${projectId}/workstreams/${id}`) + .set({ + Authorization: `Bearer ${testUtil.jwts.copilot}`, + }) + .expect(403, done); + }); + + it('should return 200 for connect admin', (done) => { + request(server) + .get(`/v5/projects/${projectId}/workstreams/${id}`) + .set({ + Authorization: `Bearer ${testUtil.jwts.connectAdmin}`, + }) + .expect(200) + .end(done); + }); + + it('should return 200 for connect manager', (done) => { + request(server) + .get(`/v5/projects/${projectId}/workstreams/${id}`) + .set({ + Authorization: `Bearer ${testUtil.jwts.manager}`, + }) + .expect(200) + .end(done); + }); + }); +}); diff --git a/src/routes/workStreams/list.js b/src/routes/workStreams/list.js new file mode 100644 index 0000000..c536e2b --- /dev/null +++ b/src/routes/workStreams/list.js @@ -0,0 +1,45 @@ +/** + * API to list all work streams + */ +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: { + projectId: Joi.number().integer().positive().required(), + }, +}; + +module.exports = [ + validate(schema), + permissions('workStream.view'), + (req, res, next) => { + const projectId = req.params.projectId; + models.Project.count({ + where: { + id: projectId, + }, + }) + .then((countProject) => { + if (countProject === 0) { + const apiErr = new Error(`active project not found for project id ${projectId}`); + apiErr.status = 404; + throw apiErr; + } + + return models.WorkStream.findAll({ + where: { + projectId, + }, + attributes: { exclude: ['deletedAt', 'deletedBy'] }, + raw: true, + }); + }) + .then(workStreams => res.json(workStreams)) + .catch(next); + }, +]; diff --git a/src/routes/workStreams/list.spec.js b/src/routes/workStreams/list.spec.js new file mode 100644 index 0000000..371ccc9 --- /dev/null +++ b/src/routes/workStreams/list.spec.js @@ -0,0 +1,157 @@ +/** + * Tests for list.js + */ +import chai from 'chai'; +import _ from 'lodash'; +import request from 'supertest'; + +import models from '../../models'; +import server from '../../app'; +import testUtil from '../../tests/util'; + +const should = chai.should(); + +describe('LIST work streams', () => { + const workStreams = [{ + name: 'Work Stream 1', + type: 'generic', + status: 'active', + createdBy: 1, + updatedBy: 1, + }, { + name: 'Work Stream 2', + type: 'generic', + status: 'reviewed', + createdBy: 1, + updatedBy: 1, + }]; + + let projectId; + + beforeEach((done) => { + testUtil.clearDb() + .then(() => { + models.ProjectTemplate.create({ + name: 'template 2', + key: 'key 2', + category: 'category 2', + icon: 'http://example.com/icon1.ico', + question: 'question 2', + info: 'info 2', + aliases: ['key-2', 'key_2'], + scope: {}, + phases: {}, + createdBy: 1, + updatedBy: 2, + }) + .then((template) => { + // Create projects + models.Project.create({ + type: 'generic', + billingAccountId: 1, + name: 'test1', + description: 'test project1', + status: 'draft', + templateId: template.id, + details: {}, + createdBy: 1, + updatedBy: 1, + lastActivityAt: 1, + lastActivityUserId: '1', + }) + .then((project) => { + projectId = project.id; + models.WorkStream.bulkCreate(_.map(workStreams, w => _.assign(w, { projectId }))).then(() => done()); + }); + }); + }); + }); + + after((done) => { + testUtil.clearDb(done); + }); + + describe('GET /projects/{projectId}/workstreams', () => { + it('should return 200 for admin', (done) => { + request(server) + .get(`/v5/projects/${projectId}/workstreams`) + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .expect(200) + .end((err, res) => { + const workStream = workStreams[0]; + + const resJson = res.body; + resJson.should.have.length(2); + resJson[0].name.should.be.eql(workStream.name); + resJson[0].type.should.be.eql(workStream.type); + resJson[0].status.should.be.eql(workStream.status); + resJson[0].projectId.should.be.eql(workStream.projectId); + should.exist(resJson[0].createdAt); + resJson[0].updatedBy.should.be.eql(workStream.updatedBy); + should.exist(resJson[0].updatedAt); + should.not.exist(resJson[0].deletedBy); + should.not.exist(resJson[0].deletedAt); + + done(); + }); + }); + + it('should return 404 for deleted project', (done) => { + models.Project.destroy({ where: { id: projectId } }) + .then(() => { + request(server) + .get(`/v5/projects/${projectId}/workstreams`) + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .expect(404, done); + }); + }); + + it('should return 403 if user is not authenticated', (done) => { + request(server) + .get(`/v5/projects/${projectId}/workstreams`) + .expect(403, done); + }); + + it('should return 403 for member', (done) => { + request(server) + .get(`/v5/projects/${projectId}/workstreams`) + .set({ + Authorization: `Bearer ${testUtil.jwts.member}`, + }) + .expect(403, done); + }); + + it('should return 403 for copilot', (done) => { + request(server) + .get(`/v5/projects/${projectId}/workstreams`) + .set({ + Authorization: `Bearer ${testUtil.jwts.copilot}`, + }) + .expect(403, done); + }); + + it('should return 200 for connect admin', (done) => { + request(server) + .get(`/v5/projects/${projectId}/workstreams`) + .set({ + Authorization: `Bearer ${testUtil.jwts.connectAdmin}`, + }) + .expect(200) + .end(done); + }); + + it('should return 200 for connect manager', (done) => { + request(server) + .get(`/v5/projects/${projectId}/workstreams`) + .set({ + Authorization: `Bearer ${testUtil.jwts.manager}`, + }) + .expect(200) + .end(done); + }); + }); +}); diff --git a/src/routes/workStreams/update.js b/src/routes/workStreams/update.js new file mode 100644 index 0000000..e370c80 --- /dev/null +++ b/src/routes/workStreams/update.js @@ -0,0 +1,65 @@ +/** + * API to update a work stream + */ +import validate from 'express-validation'; +import _ from 'lodash'; +import Joi from 'joi'; +import { middleware as tcMiddleware } from 'tc-core-library-js'; +import models from '../../models'; +import { WORKSTREAM_STATUS } from '../../constants'; + +const permissions = tcMiddleware.permissions; + +const schema = { + params: { + projectId: Joi.number().integer().positive().required(), + id: Joi.number().integer().positive().required(), + }, + body: Joi.object().keys({ + id: Joi.number().valid(Joi.ref('$params.id')), + name: Joi.string().max(255), + type: Joi.string().max(45), + status: Joi.string().valid(_.values(WORKSTREAM_STATUS)), + 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('workStream.edit'), + (req, res, next) => { + const entityToUpdate = _.assign(req.body, { + updatedBy: req.authUser.userId, + }); + const projectId = req.params.projectId; + const workStreamId = req.params.id; + + return models.WorkStream.findOne({ + where: { + id: workStreamId, + projectId, + }, + }) + .then((workStream) => { + if (!workStream) { + // handle 404 + const err = new Error(`work stream not found for project id ${projectId} ` + + `and work stream id ${workStreamId}`); + err.status = 404; + return Promise.reject(err); + } + + return workStream.update(entityToUpdate); + }) + .then((workStream) => { + res.json(workStream); + return Promise.resolve(); + }) + .catch(next); + }, +]; diff --git a/src/routes/workStreams/update.spec.js b/src/routes/workStreams/update.spec.js new file mode 100644 index 0000000..96c074f --- /dev/null +++ b/src/routes/workStreams/update.spec.js @@ -0,0 +1,230 @@ +/** + * Tests for update.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 Work Stream', () => { + let projectId; + let id; + let workStream; + + beforeEach((done) => { + testUtil.clearDb() + .then(() => { + models.ProjectTemplate.create({ + name: 'template 2', + key: 'key 2', + category: 'category 2', + icon: 'http://example.com/icon1.ico', + question: 'question 2', + info: 'info 2', + aliases: ['key-2', 'key_2'], + scope: {}, + phases: {}, + createdBy: 1, + updatedBy: 2, + }) + .then((template) => { + models.WorkManagementPermission.create({ + policy: 'workStream.edit', + permission: { + allowRule: { projectRoles: ['manager', 'copilot'], topcoderRoles: ['Connect Admin', 'administrator'] }, + denyRule: { projectRoles: ['copilot'] }, + }, + projectTemplateId: template.id, + details: {}, + createdBy: 1, + updatedBy: 1, + lastActivityAt: 1, + lastActivityUserId: '1', + }) + .then(() => { + // Create project + models.Project.create({ + type: 'generic', + billingAccountId: 1, + name: 'test1', + description: 'test project1', + status: 'draft', + templateId: template.id, + details: {}, + createdBy: 1, + updatedBy: 1, + lastActivityAt: 1, + lastActivityUserId: '1', + }) + .then((project) => { + projectId = project.id; + models.WorkStream.create({ + name: 'Work Stream', + type: 'generic', + status: 'active', + projectId, + createdBy: 1, + updatedBy: 1, + }).then((entity) => { + id = entity.id; + workStream = entity; + done(); + }); + }); + }); + }); + }); + }); + + after((done) => { + testUtil.clearDb(done); + }); + + describe('PATCH /projects/{projectId}/workstreams/{id}', () => { + const body = { + name: 'Work Stream', + type: 'generic', + status: 'active', + }; + + it('should return 403 if user is not authenticated', (done) => { + request(server) + .patch(`/v5/projects/${projectId}/workstreams/${id}`) + .send(body) + .expect(403, done); + }); + + it('should return 403 for member', (done) => { + request(server) + .patch(`/v5/projects/${projectId}/workstreams/${id}`) + .set({ + Authorization: `Bearer ${testUtil.jwts.member}`, + }) + .send(body) + .expect(403, done); + }); + + it('should return 403 for copilot', (done) => { + request(server) + .patch(`/v5/projects/${projectId}/workstreams/${id}`) + .send(body) + .set({ + Authorization: `Bearer ${testUtil.jwts.copilot}`, + }) + .expect(403, done); + }); + + it('should return 403 for manager', (done) => { + request(server) + .patch(`/v5/projects/${projectId}/workstreams/${id}`) + .send(body) + .set({ + Authorization: `Bearer ${testUtil.jwts.manager}`, + }) + .expect(403, done); + }); + + it('should return 404 for non-existed work stream', (done) => { + request(server) + .patch(`/v5/projects/${projectId}/workstreams/1234`) + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .send(body) + .expect(404, done); + }); + + it('should return 404 for deleted work stream', (done) => { + models.WorkStream.destroy({ where: { id } }) + .then(() => { + request(server) + .patch(`/v5/projects/${projectId}/workstreams/${id}`) + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .send(body) + .expect(404, done); + }); + }); + + it('should return 200 for admin name updated', (done) => { + const partialBody = _.cloneDeep(body); + delete partialBody.type; + delete partialBody.status; + request(server) + .patch(`/v5/projects/${projectId}/workstreams/${id}`) + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .send(partialBody) + .expect(200) + .end((err, res) => { + const resJson = res.body; + resJson.id.should.be.eql(id); + resJson.name.should.be.eql(workStream.name); + resJson.type.should.be.eql(workStream.type); + resJson.status.should.be.eql(workStream.status); + resJson.projectId.should.be.eql(workStream.projectId); + resJson.createdBy.should.be.eql(workStream.createdBy); + resJson.createdBy.should.be.eql(workStream.createdBy); + 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(`/v5/projects/${projectId}/workstreams/${id}`) + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .send(body) + .expect(200) + .end((err, res) => { + const resJson = res.body; + resJson.id.should.be.eql(id); + resJson.name.should.be.eql(workStream.name); + resJson.type.should.be.eql(workStream.type); + resJson.status.should.be.eql(workStream.status); + resJson.projectId.should.be.eql(workStream.projectId); + resJson.createdBy.should.be.eql(workStream.createdBy); + 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(`/v5/projects/${projectId}/workstreams/${id}`) + .set({ + Authorization: `Bearer ${testUtil.jwts.connectAdmin}`, + }) + .send(body) + .expect(200) + .end((err, res) => { + const resJson = res.body; + resJson.id.should.be.eql(id); + resJson.name.should.be.eql(workStream.name); + resJson.type.should.be.eql(workStream.type); + resJson.status.should.be.eql(workStream.status); + resJson.projectId.should.be.eql(workStream.projectId); + resJson.createdBy.should.be.eql(workStream.createdBy); + resJson.updatedBy.should.be.eql(40051336); // connect admin + done(); + }); + }); + }); +}); diff --git a/src/routes/works/create.js b/src/routes/works/create.js new file mode 100644 index 0000000..a1d3cf5 --- /dev/null +++ b/src/routes/works/create.js @@ -0,0 +1,159 @@ +/** + * API to add a phase as work + */ +import validate from 'express-validation'; +import _ from 'lodash'; +import Joi from 'joi'; +import Sequelize from 'sequelize'; + +import models from '../../models'; +import util from '../../util'; +import { EVENT, RESOURCES, TIMELINE_REFERENCES } from '../../constants'; + +const permissions = require('tc-core-library-js').middleware.permissions; + +const schema = { + params: { + projectId: Joi.number().integer().positive().required(), + workStreamId: Joi.number().integer().positive().required(), + }, + body: Joi.object().keys({ + name: Joi.string().required(), + description: Joi.string().optional(), + requirements: Joi.string().optional(), + status: Joi.string().required(), + startDate: Joi.date().optional(), + endDate: Joi.date().optional(), + duration: Joi.number().min(0).optional(), + budget: Joi.number().min(0).optional(), + spentBudget: Joi.number().min(0).optional(), + progress: Joi.number().min(0).optional(), + details: Joi.any().optional(), + order: Joi.number().integer().optional(), + productTemplateId: Joi.number().integer().positive().optional(), + }).required(), +}; + +module.exports = [ + // validate request payload + validate(schema), + // check permission + permissions('work.create'), + // do the real work + (req, res, next) => { + // default values + const projectId = _.parseInt(req.params.projectId); + const workStreamId = _.parseInt(req.params.workStreamId); + + const data = req.body; + _.assign(data, { + projectId, + createdBy: req.authUser.userId, + updatedBy: req.authUser.userId, + }); + + let existingWorkStream = null; + let newProjectPhase = null; + + req.log.debug('Create Work - Starting transaction'); + return models.sequelize.transaction(() => + models.WorkStream.findOne({ + where: { + id: workStreamId, + projectId, + deletedAt: { $eq: null }, + }, + }) + .then((_existingWorkStream) => { + if (!_existingWorkStream) { + // handle 404 + const err = new Error(`active work stream not found for project id ${projectId} ` + + `and work stream id ${workStreamId}`); + err.status = 404; + throw err; + } + + existingWorkStream = _existingWorkStream; + + if (data.startDate !== null && data.endDate !== null && data.startDate > data.endDate) { + const err = new Error('startDate must not be after endDate.'); + err.status = 400; + throw err; + } + return models.ProjectPhase.create(data); + }) + .then((_newProjectPhase) => { + newProjectPhase = _.omit(_newProjectPhase.toJSON(), ['deletedAt', 'deletedBy']); + return existingWorkStream.addProjectPhase(_newProjectPhase.id); + }) + .then(() => { + req.log.debug('re-ordering the other phases'); + + if (_.isNil(newProjectPhase.order)) { + return Promise.resolve(); + } + // Increase the order of the other phases in the same project, + // which have `order` >= this phase order + return models.ProjectPhase.update({ order: Sequelize.literal('"order" + 1') }, { + where: { + projectId, + id: { $ne: newProjectPhase.id }, + order: { $gte: newProjectPhase.order }, + }, + }); + }) + .then(() => { + if (_.isNil(data.productTemplateId)) { + return Promise.resolve(); + } + + // Get the product template + return models.ProductTemplate.findByPk(data.productTemplateId) + .then((productTemplate) => { + if (!productTemplate) { + const err = new Error(`Product template does not exist with id = ${data.productTemplateId}`); + err.status = 400; + throw err; + } + + // Create the phase product + return models.PhaseProduct.create({ + name: productTemplate.name, + templateId: data.productTemplateId, + type: productTemplate.productKey, + projectId, + phaseId: newProjectPhase.id, + createdBy: req.authUser.userId, + updatedBy: req.authUser.userId, + }) + .then((phaseProduct) => { + newProjectPhase.products = [ + _.omit(phaseProduct.toJSON(), ['deletedAt', 'deletedBy']), + ]; + }); + }); + }), + ) + .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, + { added: newProjectPhase, route: TIMELINE_REFERENCES.WORK }, + { correlationId: req.id }, + ); + + req.log.debug('Sending event to Kafka bus for project phase %d', newProjectPhase.id); + util.sendResourceToKafkaBus( + req, + EVENT.ROUTING_KEY.PROJECT_PHASE_ADDED, + RESOURCES.PHASE, + newProjectPhase, + ); + + res.status(201).json(newProjectPhase); + }) + .catch((err) => { + util.handleError('Error creating work', err, req, next); + }); + }, +]; diff --git a/src/routes/works/create.spec.js b/src/routes/works/create.spec.js new file mode 100644 index 0000000..52fe870 --- /dev/null +++ b/src/routes/works/create.spec.js @@ -0,0 +1,374 @@ +/* eslint-disable no-unused-expressions */ +/** + * Tests for create.js + */ + +import _ from 'lodash'; +import chai from 'chai'; +import sinon from 'sinon'; +import request from 'supertest'; +import models from '../../models'; +import server from '../../app'; +import testUtil from '../../tests/util'; +import busApi from '../../services/busApi'; +import { BUS_API_EVENT, CONNECT_NOTIFICATION_EVENT, RESOURCES } from '../../constants'; + +const should = chai.should(); + +const validatePhase = (resJson, expectedPhase) => { + should.exist(resJson); + resJson.name.should.be.eql(expectedPhase.name); + resJson.status.should.be.eql(expectedPhase.status); + resJson.budget.should.be.eql(expectedPhase.budget); + resJson.progress.should.be.eql(expectedPhase.progress); + resJson.details.should.be.eql(expectedPhase.details); +}; + +describe('CREATE work', () => { + let projectId; + let projectName; + let workStreamId; + + const memberUser = { + handle: testUtil.getDecodedToken(testUtil.jwts.member).handle, + userId: testUtil.getDecodedToken(testUtil.jwts.member).userId, + firstName: 'fname', + lastName: 'lName', + email: 'some@abc.com', + }; + const copilotUser = { + handle: testUtil.getDecodedToken(testUtil.jwts.copilot).handle, + userId: testUtil.getDecodedToken(testUtil.jwts.copilot).userId, + firstName: 'fname', + lastName: 'lName', + email: 'some@abc.com', + }; + const project = { + type: 'generic', + billingAccountId: 1, + name: 'test1', + description: 'test project1', + status: 'draft', + details: {}, + createdBy: 1, + updatedBy: 1, + lastActivityAt: 1, + lastActivityUserId: '1', + }; + let productTemplateId; + beforeEach((done) => { + testUtil.clearDb() + .then(() => { + models.ProjectTemplate.create({ + name: 'template 2', + key: 'key 2', + category: 'category 2', + icon: 'http://example.com/icon1.ico', + question: 'question 2', + info: 'info 2', + aliases: ['key-2', 'key_2'], + scope: {}, + phases: {}, + createdBy: 1, + updatedBy: 2, + }) + .then((template) => { + models.WorkManagementPermission.create({ + policy: 'work.create', + permission: { + allowRule: { + projectRoles: ['customer', 'copilot'], + topcoderRoles: ['Connect Manager', 'Connect Admin', 'administrator'], + }, + denyRule: { projectRoles: ['copilot'] }, + }, + projectTemplateId: template.id, + details: {}, + createdBy: 1, + updatedBy: 1, + lastActivityAt: 1, + lastActivityUserId: '1', + }) + .then(() => { + // Create projects + models.Project.create(_.assign(project, { templateId: template.id })) + .then((_project) => { + projectId = _project.id; + projectName = _project.name; + models.WorkStream.create({ + name: 'Work Stream', + type: 'generic', + status: 'active', + projectId, + createdBy: 1, + updatedBy: 1, + }).then((entity) => { + workStreamId = entity.id; + // create members + models.ProjectMember.bulkCreate([{ + id: 1, + userId: copilotUser.userId, + projectId, + role: 'copilot', + isPrimary: false, + createdBy: 1, + updatedBy: 1, + }, { + id: 2, + userId: memberUser.userId, + projectId, + role: 'customer', + isPrimary: true, + createdBy: 1, + updatedBy: 1, + }]) + .then(() => + models.ProductTemplate.create({ + name: 'name 1', + productKey: 'productKey 1', + category: 'generic', + subCategory: 'generic', + icon: 'http://example.com/icon1.ico', + brief: 'brief 1', + details: 'details 1', + aliases: ['product key 1', 'product_key_1'], + template: { + template1: { + name: 'template 1', + details: { + anyDetails: 'any details 1', + }, + others: ['others 11', 'others 12'], + }, + template2: { + name: 'template 2', + details: { + anyDetails: 'any details 2', + }, + others: ['others 21', 'others 22'], + }, + }, + createdBy: 1, + updatedBy: 2, + }).then((productTemplate) => { + productTemplateId = productTemplate.id; + done(); + }), + ); + }); + }); + }); + }); + }); + }); + + after((done) => { + testUtil.clearDb(done); + }); + + describe('PATCH /projects/{projectId}/workstreams/{workStreamId}/works', () => { + const body = { + name: 'test project phase', + description: 'test project phase description', + requirements: 'test project phase requirements', + status: 'active', + startDate: '2018-05-15T00:00:00Z', + endDate: '2018-05-15T12:00:00Z', + budget: 20.0, + progress: 1.23456, + spentBudget: 10.0, + duration: 10, + details: { + message: 'This can be any json', + }, + }; + + it('should return 403 if user is not authenticated', (done) => { + request(server) + .post(`/v5/projects/${projectId}/workstreams/${workStreamId}/works`) + .send(body) + .expect(403, done); + }); + + it('should return 403 for copilot', (done) => { + request(server) + .post(`/v5/projects/${projectId}/workstreams/${workStreamId}/works`) + .send(body) + .set({ + Authorization: `Bearer ${testUtil.jwts.copilot}`, + }) + .expect(403, done); + }); + + it('should return 404 for non-existed work stream', (done) => { + request(server) + .post(`/v5/projects/${projectId}/workstreams/1234/works`) + .set({ + Authorization: `Bearer ${testUtil.jwts.manager}`, + }) + .send(body) + .expect(404, done); + }); + + it('should return 404 for deleted work stream', (done) => { + models.WorkStream.destroy({ where: { id: workStreamId } }) + .then(() => { + request(server) + .post(`/v5/projects/${projectId}/workstreams/${workStreamId}/works`) + .set({ + Authorization: `Bearer ${testUtil.jwts.manager}`, + }) + .send(body) + .expect(404, done); + }); + }); + + it('should return 400 when name not provided', (done) => { + const reqBody = _.cloneDeep(body); + delete reqBody.name; + request(server) + .post(`/v5/projects/${projectId}/workstreams/${workStreamId}/works`) + .set({ + Authorization: `Bearer ${testUtil.jwts.copilot}`, + }) + .send(reqBody) + .expect('Content-Type', /json/) + .expect(400, done); + }); + + it('should return 400 when status not provided', (done) => { + const reqBody = _.cloneDeep(body); + delete reqBody.status; + request(server) + .post(`/v5/projects/${projectId}/workstreams/${workStreamId}/works`) + .set({ + Authorization: `Bearer ${testUtil.jwts.copilot}`, + }) + .send(reqBody) + .expect('Content-Type', /json/) + .expect(400, done); + }); + + it('should return 400 when startDate > endDate', (done) => { + const reqBody = _.cloneDeep(body); + reqBody.startDate = '2018-05-16T12:00:00'; + request(server) + .post(`/v5/projects/${projectId}/workstreams/${workStreamId}/works`) + .set({ + Authorization: `Bearer ${testUtil.jwts.manager}`, + }) + .send(reqBody) + .expect('Content-Type', /json/) + .expect(400, done); + }); + + it('should return 400 when budget is negative', (done) => { + const reqBody = _.cloneDeep(body); + reqBody.budget = -20; + request(server) + .post(`/v5/projects/${projectId}/workstreams/${workStreamId}/works`) + .set({ + Authorization: `Bearer ${testUtil.jwts.copilot}`, + }) + .send(reqBody) + .expect('Content-Type', /json/) + .expect(400, done); + }); + + it('should return 400 when progress is negative', (done) => { + const reqBody = _.cloneDeep(body); + reqBody.progress = -20; + request(server) + .post(`/v5/projects/${projectId}/workstreams/${workStreamId}/works`) + .set({ + Authorization: `Bearer ${testUtil.jwts.copilot}`, + }) + .send(reqBody) + .expect('Content-Type', /json/) + .expect(400, done); + }); + + it('should return 201 for member', (done) => { + request(server) + .post(`/v5/projects/${projectId}/workstreams/${workStreamId}/works`) + .set({ + Authorization: `Bearer ${testUtil.jwts.member}`, + }) + .send(_.assign({ productTemplateId }, body)) + .expect(201, done); + }); + + it('should return 201 for connect admin', (done) => { + request(server) + .post(`/v5/projects/${projectId}/workstreams/${workStreamId}/works`) + .set({ + Authorization: `Bearer ${testUtil.jwts.connectAdmin}`, + }) + .send(_.assign({ productTemplateId }, body)) + .expect(201) + .end((err, res) => { + const resJson = res.body; + validatePhase(resJson, body); + done(); + }); + }); + + describe('Bus api', () => { + let createEventSpy; + const sandbox = sinon.sandbox.create(); + + before((done) => { + // Wait for 500ms in order to wait for createEvent calls from previous tests to complete + testUtil.wait(done); + }); + + beforeEach(() => { + createEventSpy = sandbox.spy(busApi, 'createEvent'); + }); + + afterEach(() => { + sandbox.restore(); + }); + + it('should send correct BUS API messages when work added', (done) => { + request(server) + .post(`/v5/projects/${projectId}/workstreams/${workStreamId}/works`) + .set({ + Authorization: `Bearer ${testUtil.jwts.member}`, + }) + .send(body) + .expect('Content-Type', /json/) + .expect(201) + .end((err) => { + if (err) { + done(err); + } else { + testUtil.wait(() => { + createEventSpy.callCount.should.be.eql(2); + + createEventSpy.calledWith(BUS_API_EVENT.PROJECT_PHASE_CREATED, sinon.match({ + resource: RESOURCES.PHASE, + name: body.name, + status: body.status, + budget: body.budget, + progress: body.progress, + projectId, + })).should.be.true; + + // Check Notification Service events + createEventSpy.calledWith(CONNECT_NOTIFICATION_EVENT.PROJECT_PLAN_UPDATED, sinon.match({ + projectId, + projectName, + projectUrl: `https://local.topcoder-dev.com/projects/${projectId}`, + userId: 40051331, + initiatorUserId: 40051331, + })).should.be.true; + + done(); + }); + } + }); + }); + }); + }); +}); diff --git a/src/routes/works/delete.js b/src/routes/works/delete.js new file mode 100644 index 0000000..885c8c5 --- /dev/null +++ b/src/routes/works/delete.js @@ -0,0 +1,81 @@ +/** + * API to delete a work + */ +import _ from 'lodash'; +import validate from 'express-validation'; +import Joi from 'joi'; +import { middleware as tcMiddleware } from 'tc-core-library-js'; +import models from '../../models'; +import util from '../../util'; +import { EVENT, RESOURCES, TIMELINE_REFERENCES } from '../../constants'; + +const permissions = tcMiddleware.permissions; + +const schema = { + params: { + projectId: Joi.number().integer().positive().required(), + workStreamId: Joi.number().integer().positive().required(), + id: Joi.number().integer().positive().required(), + }, +}; + +module.exports = [ + validate(schema), + permissions('work.delete'), + (req, res, next) => { + const projectId = req.params.projectId; + models.sequelize.transaction(() => + models.PhaseWorkStream.findOne({ + where: { + phaseId: req.params.id, + workStreamId: req.params.workStreamId, + }, + }) + .then((work) => { + // Not found + if (!work) { + const apiErr = new Error(`work not found for work stream id ${req.params.workStreamId} ` + + `and work id ${req.params.id}`); + apiErr.status = 404; + throw apiErr; + } + + return models.ProjectPhase.findOne({ + where: { + id: req.params.id, + projectId, + }, + }); + }) + .then((entity) => { + if (!entity) { + const apiErr = new Error(`work not found for work stream id ${req.params.workStreamId}, ` + + `project id ${projectId} and work id ${req.params.id}`); + apiErr.status = 404; + return Promise.reject(apiErr); + } + // Update the deletedBy, then delete + return entity.update({ deletedBy: req.authUser.userId }); + }) + .then(entity => entity.destroy())) + .then((deleted) => { + req.log.debug('deleted work', JSON.stringify(deleted, null, 2)); + + // Send events to buses + req.app.services.pubsub.publish( + EVENT.ROUTING_KEY.PROJECT_PHASE_REMOVED, + { deleted, route: TIMELINE_REFERENCES.WORK }, + { correlationId: req.id }, + ); + + // emit event + util.sendResourceToKafkaBus( + req, + EVENT.ROUTING_KEY.PROJECT_PHASE_REMOVED, + RESOURCES.PHASE, + _.pick(deleted.toJSON(), 'id')); + + res.status(204).json({}); + }).catch(err => next(err)); + }, +]; diff --git a/src/routes/works/delete.spec.js b/src/routes/works/delete.spec.js new file mode 100644 index 0000000..b57c25c --- /dev/null +++ b/src/routes/works/delete.spec.js @@ -0,0 +1,304 @@ +/* eslint-disable no-unused-expressions */ +/** + * Tests for delete.js + */ +import _ from 'lodash'; +import request from 'supertest'; +import chai from 'chai'; +import sinon from 'sinon'; +import models from '../../models'; +import server from '../../app'; +import testUtil from '../../tests/util'; +import busApi from '../../services/busApi'; +import { BUS_API_EVENT, CONNECT_NOTIFICATION_EVENT, RESOURCES } from '../../constants'; + +chai.should(); + +const expectAfterDelete = (workId, projectId, workStreamId, err, next) => { + if (err) throw err; + setTimeout(() => + models.ProjectPhase.findOne({ + where: { + id: workId, + }, + 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(`/v5/projects/${projectId}/workstreams/${workStreamId}/works/${workId}`) + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .expect(404, next); + } + }), 500); +}; + +describe('DELETE work', () => { + let projectId; + let projectName; + let workStreamId; + let workId; + + const memberUser = { + handle: testUtil.getDecodedToken(testUtil.jwts.member).handle, + userId: testUtil.getDecodedToken(testUtil.jwts.member).userId, + firstName: 'fname', + lastName: 'lName', + email: 'some@abc.com', + }; + const copilotUser = { + handle: testUtil.getDecodedToken(testUtil.jwts.copilot).handle, + userId: testUtil.getDecodedToken(testUtil.jwts.copilot).userId, + firstName: 'fname', + lastName: 'lName', + email: 'some@abc.com', + }; + const project = { + type: 'generic', + billingAccountId: 1, + name: 'test1', + description: 'test project1', + status: 'draft', + details: {}, + createdBy: 1, + updatedBy: 1, + lastActivityAt: 1, + lastActivityUserId: '1', + }; + + beforeEach((done) => { + testUtil.clearDb() + .then(() => { + models.ProjectTemplate.create({ + name: 'template 2', + key: 'key 2', + category: 'category 2', + icon: 'http://example.com/icon1.ico', + question: 'question 2', + info: 'info 2', + aliases: ['key-2', 'key_2'], + scope: {}, + phases: {}, + createdBy: 1, + updatedBy: 2, + }) + .then((template) => { + models.WorkManagementPermission.create({ + policy: 'work.delete', + permission: { + allowRule: { + projectRoles: ['customer', 'copilot'], + topcoderRoles: ['Connect Manager', 'Connect Admin', 'administrator'], + }, + denyRule: { projectRoles: ['copilot'] }, + }, + projectTemplateId: template.id, + details: {}, + createdBy: 1, + updatedBy: 1, + lastActivityAt: 1, + lastActivityUserId: '1', + }) + .then(() => { + // Create projects + models.Project.create(_.assign(project, { templateId: template.id })) + .then((_project) => { + projectId = _project.id; + projectName = _project.name; + models.WorkStream.create({ + name: 'Work Stream', + type: 'generic', + status: 'active', + projectId, + createdBy: 1, + updatedBy: 1, + }).then((entity) => { + workStreamId = entity.id; + models.ProjectPhase.create({ + name: 'test project phase', + status: 'active', + startDate: '2018-05-15T00:00:00Z', + endDate: '2018-05-15T12:00:00Z', + budget: 20.0, + progress: 1.23456, + details: { + message: 'This can be any json', + }, + createdBy: 1, + updatedBy: 1, + projectId, + }).then((phase) => { + workId = phase.id; + models.PhaseWorkStream.create({ + phaseId: workId, + workStreamId, + }).then(() => { + // create members + models.ProjectMember.bulkCreate([{ + id: 1, + userId: copilotUser.userId, + projectId, + role: 'copilot', + isPrimary: false, + createdBy: 1, + updatedBy: 1, + }, { + id: 2, + userId: memberUser.userId, + projectId, + role: 'customer', + isPrimary: true, + createdBy: 1, + updatedBy: 1, + }]).then(() => done()); + }); + }); + }); + }); + }); + }); + }); + }); + + after((done) => { + testUtil.clearDb(done); + }); + + describe('DELETE /projects/{projectId}/workstreams/{workStreamId}/works/{workId}', () => { + it('should return 403 if user is not authenticated', (done) => { + request(server) + .delete(`/v5/projects/${projectId}/workstreams/${workStreamId}/works/${workId}`) + .expect(403, done); + }); + + it('should return 403 for copilot', (done) => { + request(server) + .delete(`/v5/projects/${projectId}/workstreams/${workStreamId}/works/${workId}`) + .set({ + Authorization: `Bearer ${testUtil.jwts.copilot}`, + }) + .expect(403, done); + }); + + it('should return 404 when no work stream with specific workStreamId', (done) => { + request(server) + .delete(`/v5/projects/${projectId}/workstreams/999/works/${workId}`) + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .expect('Content-Type', /json/) + .expect(404, done); + }); + + it('should return 404 when no work with specific workId', (done) => { + request(server) + .delete(`/v5/projects/${projectId}/workstreams/${workStreamId}/works/999`) + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .expect('Content-Type', /json/) + .expect(404, done); + }); + + it('should return 404 for deleted type', (done) => { + models.ProjectPhase.destroy({ where: { id: workId } }) + .then(() => { + request(server) + .delete(`/v5/projects/${projectId}/workstreams/${workStreamId}/works/${workId}`) + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .expect(404, done); + }); + }); + + it('should return 204 for member', (done) => { + request(server) + .delete(`/v5/projects/${projectId}/workstreams/${workStreamId}/works/${workId}`) + .set({ + Authorization: `Bearer ${testUtil.jwts.member}`, + }) + .expect(204) + .end(err => expectAfterDelete(workId, projectId, workStreamId, err, done)); + }); + + it('should return 204, for admin, if type was successfully removed', (done) => { + request(server) + .delete(`/v5/projects/${projectId}/workstreams/${workStreamId}/works/${workId}`) + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .expect(204) + .end(err => expectAfterDelete(workId, projectId, workStreamId, err, done)); + }); + + it('should return 204, for connect admin, if type was successfully removed', (done) => { + request(server) + .delete(`/v5/projects/${projectId}/workstreams/${workStreamId}/works/${workId}`) + .set({ + Authorization: `Bearer ${testUtil.jwts.connectAdmin}`, + }) + .expect(204) + .end(err => expectAfterDelete(workId, projectId, workStreamId, err, done)); + }); + + describe('Bus api', () => { + let createEventSpy; + const sandbox = sinon.sandbox.create(); + + before((done) => { + // Wait for 500ms in order to wait for createEvent calls from previous tests to complete + testUtil.wait(done); + }); + + beforeEach(() => { + createEventSpy = sandbox.spy(busApi, 'createEvent'); + }); + + afterEach(() => { + sandbox.restore(); + }); + + it('should send correct BUS API messages when work removed', (done) => { + request(server) + .delete(`/v5/projects/${projectId}/workstreams/${workStreamId}/works/${workId}`) + .set({ + Authorization: `Bearer ${testUtil.jwts.connectAdmin}`, + }) + .expect(204) + .end((err) => { + if (err) { + done(err); + } else { + testUtil.wait(() => { + createEventSpy.callCount.should.be.eql(2); + + createEventSpy.calledWith(BUS_API_EVENT.PROJECT_PHASE_DELETED, sinon.match({ + resource: RESOURCES.PHASE, + id: workId, + })).should.be.true; + + // Check Notification Service events + createEventSpy.calledWith(CONNECT_NOTIFICATION_EVENT.PROJECT_PLAN_UPDATED, sinon.match({ + projectId, + projectName, + projectUrl: `https://local.topcoder-dev.com/projects/${projectId}`, + userId: 40051336, + initiatorUserId: 40051336, + })).should.be.true; + + done(); + }); + } + }); + }); + }); + }); +}); diff --git a/src/routes/works/get.js b/src/routes/works/get.js new file mode 100644 index 0000000..b94b974 --- /dev/null +++ b/src/routes/works/get.js @@ -0,0 +1,58 @@ +/** + * API to get a work + */ +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: { + projectId: Joi.number().integer().positive().required(), + workStreamId: Joi.number().integer().positive().required(), + id: Joi.number().integer().positive().required(), + }, +}; + +module.exports = [ + validate(schema), + permissions('work.view'), + (req, res, next) => models.PhaseWorkStream.findOne({ + where: { + phaseId: req.params.id, + workStreamId: req.params.workStreamId, + }, + // attributes: { exclude: ['deletedAt', 'deletedBy'] }, + }) + .then((work) => { + // Not found + if (!work) { + const apiErr = new Error(`work not found for work stream id ${req.params.workStreamId}, ` + + `project id ${req.params.projectId} and work id ${req.params.id}`); + apiErr.status = 404; + return Promise.reject(apiErr); + } + + return models.ProjectPhase.findOne({ + where: { + id: req.params.id, + projectId: req.params.projectId, + }, + attributes: { exclude: ['deletedAt', 'deletedBy'] }, + }); + }) + .then((phase) => { + // Not found + if (!phase) { + const apiErr = new Error(`work not found for work stream id ${req.params.workStreamId}, ` + + `project id ${req.params.projectId} and work id ${req.params.id}`); + apiErr.status = 404; + return Promise.reject(apiErr); + } + + return res.json(phase); + }) + .catch(next), +]; diff --git a/src/routes/works/get.spec.js b/src/routes/works/get.spec.js new file mode 100644 index 0000000..768fd78 --- /dev/null +++ b/src/routes/works/get.spec.js @@ -0,0 +1,216 @@ +/** + * 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 work', () => { + const body = { + name: 'test project phase', + status: 'active', + startDate: '2018-05-15T00:00:00Z', + endDate: '2018-05-15T12:00:00Z', + budget: 20.0, + progress: 1.23456, + details: { + message: 'This can be any json', + }, + createdBy: 1, + updatedBy: 1, + }; + + let projectId; + let workStreamId; + let workId; + + beforeEach((done) => { + testUtil.clearDb() + .then(() => { + models.ProjectTemplate.create({ + name: 'template 2', + key: 'key 2', + category: 'category 2', + icon: 'http://example.com/icon1.ico', + question: 'question 2', + info: 'info 2', + aliases: ['key-2', 'key_2'], + scope: {}, + phases: {}, + createdBy: 1, + updatedBy: 2, + }) + .then((template) => { + // Create projects + models.Project.create({ + type: 'generic', + billingAccountId: 1, + name: 'test1', + description: 'test project1', + status: 'draft', + templateId: template.id, + details: {}, + createdBy: 1, + updatedBy: 1, + lastActivityAt: 1, + lastActivityUserId: '1', + }) + .then((project) => { + projectId = project.id; + models.WorkStream.create({ + name: 'Work Stream', + type: 'generic', + status: 'active', + projectId, + createdBy: 1, + updatedBy: 1, + }).then((entity) => { + workStreamId = entity.id; + models.ProjectPhase.create({ + name: 'test project phase', + status: 'active', + startDate: '2018-05-15T00:00:00Z', + endDate: '2018-05-15T12:00:00Z', + budget: 20.0, + progress: 1.23456, + details: { + message: 'This can be any json', + }, + createdBy: 1, + updatedBy: 1, + projectId, + }).then((phase) => { + workId = phase.id; + models.PhaseWorkStream.create({ + phaseId: workId, + workStreamId, + }).then(() => done()); + }); + }); + }); + }); + }); + }); + + after((done) => { + testUtil.clearDb(done); + }); + + describe('GET /projects/{projectId}/workstreams/{workStreamId}/works/{workId}', () => { + it('should return 403 if user is not authenticated', (done) => { + request(server) + .get(`/v5/projects/${projectId}/workstreams/${workStreamId}/works/${workId}`) + .expect(403, done); + }); + + it('should return 403 for member', (done) => { + request(server) + .get(`/v5/projects/${projectId}/workstreams/${workStreamId}/works/${workId}`) + .set({ + Authorization: `Bearer ${testUtil.jwts.member}`, + }) + .expect(403, done); + }); + + it('should return 403 for copilot', (done) => { + request(server) + .get(`/v5/projects/${projectId}/workstreams/${workStreamId}/works/${workId}`) + .set({ + Authorization: `Bearer ${testUtil.jwts.copilot}`, + }) + .expect(403, done); + }); + + it('should return 404 when no project with specific projectId', (done) => { + request(server) + .get(`/v5/projects/9999/workstreams/${workStreamId}/works/${workId}`) + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .expect('Content-Type', /json/) + .expect(404, done); + }); + + it('should return 404 when no work stream with specific workStreamId', (done) => { + request(server) + .get(`/v5/projects/${projectId}/workstreams/999/works/${workId}`) + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .expect('Content-Type', /json/) + .expect(404, done); + }); + + it('should return 404 when no work with specific workId', (done) => { + request(server) + .get(`/v5/projects/${projectId}/workstreams/${workStreamId}/works/999`) + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .expect('Content-Type', /json/) + .expect(404, done); + }); + + it('should return 404 for deleted type', (done) => { + models.ProjectPhase.destroy({ where: { id: workId } }) + .then(() => { + request(server) + .get(`/v5/projects/${projectId}/workstreams/${workStreamId}/works/${workId}`) + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .expect(404, done); + }); + }); + + it('should return 200 for admin', (done) => { + request(server) + .get(`/v5/projects/${projectId}/workstreams/${workStreamId}/works/${workId}`) + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .expect(200) + .end((err, res) => { + const resJson = res.body; + resJson.name.should.be.eql(body.name); + resJson.status.should.be.eql(body.status); + resJson.budget.should.be.eql(body.budget); + resJson.progress.should.be.eql(body.progress); + resJson.details.should.be.eql(body.details); + resJson.createdBy.should.be.eql(body.createdBy); + should.exist(resJson.createdAt); + resJson.updatedBy.should.be.eql(body.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(`/v5/projects/${projectId}/workstreams/${workStreamId}/works/${workId}`) + .set({ + Authorization: `Bearer ${testUtil.jwts.connectAdmin}`, + }) + .expect(200) + .end(done); + }); + + it('should return 200 for connect manager', (done) => { + request(server) + .get(`/v5/projects/${projectId}/workstreams/${workStreamId}/works/${workId}`) + .set({ + Authorization: `Bearer ${testUtil.jwts.manager}`, + }) + .expect(200) + .end(done); + }); + }); +}); diff --git a/src/routes/works/list.js b/src/routes/works/list.js new file mode 100644 index 0000000..78f00f0 --- /dev/null +++ b/src/routes/works/list.js @@ -0,0 +1,96 @@ +/** + * API to list all works + */ +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 PHASE_ATTRIBUTES = _.keys(models.ProjectPhase.rawAttributes); +const permissions = tcMiddleware.permissions; + +const schema = { + params: { + projectId: Joi.number().integer().positive().required(), + workStreamId: Joi.number().integer().positive().required(), + }, +}; + +module.exports = [ + validate(schema), + permissions('work.view'), + (req, res, next) => { + const workStreamId = req.params.workStreamId; + const projectId = req.params.projectId; + + // Parse the fields string to determine what fields are to be returned + const rawFields = 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'; + } + const sortableProps = [ + 'startDate asc', 'startDate desc', + 'endDate asc', 'endDate desc', + 'status asc', 'status desc', + 'order asc', 'order desc', + ]; + if (sort && _.indexOf(sortableProps, sort) < 0) { + return util.handleError('Invalid sort criteria', null, req, next); + } + + const sortParameters = sort.split(' '); + + const fields = _.union( + _.intersection(rawFields, [...PHASE_ATTRIBUTES, 'workItems']), + ['id'], // required fields + ); + + // search condition for ProjectPhase + const include = { + model: models.ProjectPhase, + through: { attributes: [] }, + where: { + projectId, + }, + attributes: fields.filter(f => f !== 'workItems'), + required: false, + }; + if (fields.includes('workItems')) { + _.set(include, 'include', [{ model: models.PhaseProduct, as: 'products' }]); + } + + return models.WorkStream.findOne({ + where: { + id: workStreamId, + projectId, + deletedAt: { $eq: null }, + }, + include: [include], + order: [[models.ProjectPhase, sortParameters[0], sortParameters[1]]], + }) + .then((existingWorkStream) => { + if (!existingWorkStream) { + // handle 404 + const err = new Error(`active work stream not found for project id ${projectId} ` + + `and work stream id ${workStreamId}`); + err.status = 404; + throw err; + } + + // rename 'products' to 'workItems' + return existingWorkStream.ProjectPhases.map((phase) => { + const phaseObj = phase.get({ plain: true }); + if (_.has(phaseObj, 'products')) { + _.set(phaseObj, 'workItems', _.get(phaseObj, 'products')); + _.unset(phaseObj, 'products'); + } + return phaseObj; + }); + }) + .then(phases => res.json(phases)) + .catch(next); + }, +]; diff --git a/src/routes/works/list.spec.js b/src/routes/works/list.spec.js new file mode 100644 index 0000000..4edc179 --- /dev/null +++ b/src/routes/works/list.spec.js @@ -0,0 +1,224 @@ +/** + * Tests for list.js + */ +import chai from 'chai'; +import _ from 'lodash'; +import request from 'supertest'; + +import models from '../../models'; +import server from '../../app'; +import testUtil from '../../tests/util'; + +const should = chai.should(); + +describe('LIST works', () => { + const phases = [ + { + name: 'test project phase', + status: 'active', + startDate: '2018-05-15T00:00:00Z', + endDate: '2018-05-15T12:00:00Z', + budget: 20.0, + progress: 1.23456, + details: { + message: 'This can be any json', + }, + createdBy: 1, + updatedBy: 1, + }, + { + name: 'test project phase', + status: 'active', + startDate: '2018-05-15T00:00:00Z', + endDate: '2018-05-15T12:00:00Z', + budget: 20.0, + progress: 1.23456, + details: { + message: 'This can be any json', + }, + createdBy: 1, + updatedBy: 1, + }, + ]; + + const productBody = { + name: 'test phase product', + type: 'product1', + estimatedPrice: 20.0, + actualPrice: 1.23456, + details: { + message: 'This can be any json', + }, + createdBy: 1, + updatedBy: 1, + }; + + + let projectId; + let workStreamId; + + beforeEach((done) => { + testUtil.clearDb() + .then(() => { + models.ProjectTemplate.create({ + name: 'template 2', + key: 'key 2', + category: 'category 2', + icon: 'http://example.com/icon1.ico', + question: 'question 2', + info: 'info 2', + aliases: ['key-2', 'key_2'], + scope: {}, + phases: {}, + createdBy: 1, + updatedBy: 2, + }) + .then((template) => { + // Create projects + models.Project.create({ + type: 'generic', + billingAccountId: 1, + name: 'test1', + description: 'test project1', + status: 'draft', + templateId: template.id, + details: {}, + createdBy: 1, + updatedBy: 1, + lastActivityAt: 1, + lastActivityUserId: '1', + }) + .then((project) => { + projectId = project.id; + models.WorkStream.create({ + name: 'Work Stream', + type: 'generic', + status: 'active', + projectId, + createdBy: 1, + updatedBy: 1, + }).then((entity) => { + workStreamId = entity.id; + models.ProjectPhase.bulkCreate(_.map(phases, p => _.assign(p, { projectId })), + { returning: true }) + .then((p) => { + models.PhaseWorkStream.bulkCreate([{ + phaseId: p[0].id, + workStreamId, + }, { + phaseId: p[1].id, + workStreamId, + }]).then((ws) => { + _.assign(productBody, { phaseId: ws[0].phaseId, projectId }); + + models.PhaseProduct.create(productBody).then(() => { + done(); + }); + }); + }); + }); + }); + }); + }); + }); + + after((done) => { + testUtil.clearDb(done); + }); + + describe('GET /projects/{projectId}/workstreams/{workStreamId}/works', () => { + it('should return 200 for admin', (done) => { + request(server) + .get(`/v5/projects/${projectId}/workstreams/${workStreamId}/works`) + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .expect(200) + .end((err, res) => { + const phase = phases[0]; + + const resJson = res.body; + resJson.should.have.length(2); + resJson[0].name.should.be.eql(phase.name); + resJson[0].status.should.be.eql(phase.status); + resJson[0].budget.should.be.eql(phase.budget); + resJson[0].progress.should.be.eql(phase.progress); + resJson[0].details.should.be.eql(phase.details); + should.exist(resJson[0].createdAt); + resJson[0].updatedBy.should.be.eql(phase.updatedBy); + should.exist(resJson[0].updatedAt); + should.not.exist(resJson[0].deletedBy); + should.not.exist(resJson[0].deletedAt); + + done(); + }); + }); + + it('should return 403 if user is not authenticated', (done) => { + request(server) + .get(`/v5/projects/${projectId}/workstreams/${workStreamId}/works`) + .expect(403, done); + }); + + it('should return 403 for member', (done) => { + request(server) + .get(`/v5/projects/${projectId}/workstreams/${workStreamId}/works`) + .set({ + Authorization: `Bearer ${testUtil.jwts.member}`, + }) + .expect(403, done); + }); + + it('should return 403 for copilot', (done) => { + request(server) + .get(`/v5/projects/${projectId}/workstreams/${workStreamId}/works`) + .set({ + Authorization: `Bearer ${testUtil.jwts.copilot}`, + }) + .expect(403, done); + }); + + it('should return 200 for connect admin', (done) => { + request(server) + .get(`/v5/projects/${projectId}/workstreams/${workStreamId}/works`) + .set({ + Authorization: `Bearer ${testUtil.jwts.connectAdmin}`, + }) + .expect(200) + .end(done); + }); + + it('should return 200 for connect manager', (done) => { + request(server) + .get(`/v5/projects/${projectId}/workstreams/${workStreamId}/works`) + .set({ + Authorization: `Bearer ${testUtil.jwts.manager}`, + }) + .expect(200) + .end(done); + }); + + it('should return with populated workItems if fields=workItems is used', (done) => { + request(server) + .get(`/v5/projects/${projectId}/workstreams/${workStreamId}/works?fields=workItems`) + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .expect(200) + .end((err, res) => { + const resJson = res.body; + resJson.should.have.length(2); + resJson[0].should.have.property('workItems'); + resJson[0].workItems.should.be.a('array'); + resJson[0].workItems.should.have.lengthOf(1); + resJson[0].workItems[0].should.have.property('projectId'); + resJson[0].workItems[0].projectId.should.equal(projectId); + resJson[0].workItems[0].name.should.equal(productBody.name); + resJson[1].should.have.property('workItems'); + resJson[1].workItems.should.be.a('array'); + resJson[1].workItems.should.have.lengthOf(0); + done(); + }); + }); + }); +}); diff --git a/src/routes/works/update.js b/src/routes/works/update.js new file mode 100644 index 0000000..0dd343a --- /dev/null +++ b/src/routes/works/update.js @@ -0,0 +1,194 @@ +/** + * API to update work + */ +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 models from '../../models'; +import util from '../../util'; +import { EVENT, RESOURCES, TIMELINE_REFERENCES, ROUTES } from '../../constants'; + +const permissions = tcMiddleware.permissions; + +const schema = { + params: { + projectId: Joi.number().integer().positive().required(), + workStreamId: Joi.number().integer().positive().required(), + id: Joi.number().integer().positive().required(), + }, + body: Joi.object().keys({ + name: Joi.string().optional(), + description: Joi.string().optional(), + requirements: Joi.string().optional(), + status: Joi.string().optional(), + startDate: Joi.date().optional(), + endDate: Joi.date().optional(), + duration: Joi.number().min(0).optional(), + budget: Joi.number().min(0).optional(), + spentBudget: Joi.number().min(0).optional(), + progress: Joi.number().min(0).optional(), + details: Joi.any().optional(), + order: Joi.number().integer().optional(), + }).required(), +}; + + +module.exports = [ + // validate request payload + validate(schema), + // check permission + permissions('work.edit'), + + (req, res, next) => { + const projectId = _.parseInt(req.params.projectId); + const workStreamId = _.parseInt(req.params.workStreamId); + const phaseId = _.parseInt(req.params.id); + + const updatedProps = req.body; + updatedProps.updatedBy = req.authUser.userId; + + let previousValue; + let updated; + + models.sequelize.transaction(() => models.ProjectPhase.findOne({ + where: { + id: phaseId, + projectId, + }, + include: [{ + model: models.WorkStream, + through: { attributes: [] }, + where: { + id: workStreamId, + projectId, + }, + }], + }) + .then((existing) => { + if (!existing) { + // handle 404 + const err = new Error('No active project phase found for project id ' + + `${projectId} and work stream ${workStreamId} and phase id ${phaseId}`); + err.status = 404; + throw err; + } else { + previousValue = _.clone(existing.get({ plain: true })); + + // make sure startDate < endDate + let startDate; + let endDate; + if (updatedProps.startDate) { + startDate = new Date(updatedProps.startDate); + } else { + startDate = existing.startDate !== null ? new Date(existing.startDate) : null; + } + + if (updatedProps.endDate) { + endDate = new Date(updatedProps.endDate); + } else { + endDate = existing.endDate !== null ? new Date(existing.endDate) : null; + } + + if (startDate !== null && endDate !== null && startDate > endDate) { + const err = new Error('startDate must not be after endDate.'); + err.status = 400; + throw err; + } else { + _.extend(existing, updatedProps); + return existing.save().catch(next); + } + } + }) + .then((updatedPhase) => { + updated = updatedPhase; + // Ignore re-ordering if there's no order specified for this phase + if (_.isNil(updated.order)) { + return Promise.resolve(); + } + + // Update order of the other phases only if the order was changed + if (previousValue.order === updated.order) { + return Promise.resolve(); + } + + return models.ProjectPhase.count({ + where: { + id: { $ne: phaseId }, + }, + include: [{ + model: models.WorkStream, + where: { + id: workStreamId, + projectId, + }, + }], + }) + .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 (!_.isNil(previousValue.order) && previousValue.order < updated.order) { + return models.ProjectPhase.update({ order: Sequelize.literal('"order" - 1') }, { + where: { + projectId, + id: { $ne: updated.id }, + order: { $between: [previousValue.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.ProjectPhase.update({ order: Sequelize.literal('"order" + 1') }, { + where: { + projectId, + id: { $ne: updated.id }, + order: { + $between: [ + updated.order, + (previousValue.order ? previousValue.order : Number.MAX_SAFE_INTEGER) - 1, + ], + }, + }, + }); + }); + }) + .then(() => + // To simpify the logic, reload the phases from DB and send to the message queue + models.ProjectPhase.findAll({ + where: { + projectId, + }, + include: [{ model: models.PhaseProduct, as: 'products' }], + })), + ) + .then((allPhases) => { + req.log.debug('updated project phase', JSON.stringify(updated, null, 2)); + + const updatedValue = updated.get({ plain: true }); + + // emit original and updated project phase information + req.app.services.pubsub.publish( + EVENT.ROUTING_KEY.PROJECT_PHASE_UPDATED, + { original: previousValue, updated: updatedValue, allPhases, route: TIMELINE_REFERENCES.WORK }, + { correlationId: req.id }, + ); + util.sendResourceToKafkaBus( + req, + EVENT.ROUTING_KEY.PROJECT_PHASE_UPDATED, + RESOURCES.PHASE, + updatedValue, + previousValue, + ROUTES.WORKS.UPDATE, + ); + + res.json(updated); + }) + .catch(err => next(err)); + }, +]; diff --git a/src/routes/works/update.spec.js b/src/routes/works/update.spec.js new file mode 100644 index 0000000..9528669 --- /dev/null +++ b/src/routes/works/update.spec.js @@ -0,0 +1,780 @@ +/* eslint-disable no-unused-expressions */ +/** + * Tests for update.js + */ +import _ from 'lodash'; +import chai from 'chai'; +import request from 'supertest'; +import sinon from 'sinon'; +import models from '../../models'; +import server from '../../app'; +import testUtil from '../../tests/util'; +import busApi from '../../services/busApi'; +import { BUS_API_EVENT, RESOURCES, CONNECT_NOTIFICATION_EVENT } from '../../constants'; + +const should = chai.should(); + +const body = { + name: 'test project phase', + description: 'test project phase description', + requirements: 'test project phase requirements', + status: 'active', + startDate: '2018-05-15T00:00:00Z', + endDate: '2018-05-15T12:00:00Z', + budget: 20.0, + progress: 1.23456, + details: { + message: 'This can be any json', + }, + createdBy: 1, + updatedBy: 1, +}; + +const updateBody = { + name: 'test project phase xxx', + description: 'test project phase description xxx', + requirements: 'test project phase requirements xxx', + status: 'inactive', + startDate: '2018-05-11T00:00:00Z', + endDate: '2018-05-12T12:00:00Z', + budget: 123456.789, + progress: 9.8765432, + details: { + message: 'This is another json', + }, +}; + +const validatePhase = (resJson, expectedPhase) => { + should.exist(resJson); + resJson.name.should.be.eql(expectedPhase.name); + resJson.status.should.be.eql(expectedPhase.status); + resJson.budget.should.be.eql(expectedPhase.budget); + resJson.progress.should.be.eql(expectedPhase.progress); + resJson.details.should.be.eql(expectedPhase.details); +}; + +describe('UPDATE work', () => { + let projectId; + let projectName; + let workStreamId; + let workId; + let workId2; + let workId3; + + const memberUser = { + handle: testUtil.getDecodedToken(testUtil.jwts.member).handle, + userId: testUtil.getDecodedToken(testUtil.jwts.member).userId, + firstName: 'fname', + lastName: 'lName', + email: 'some@abc.com', + }; + const copilotUser = { + handle: testUtil.getDecodedToken(testUtil.jwts.copilot).handle, + userId: testUtil.getDecodedToken(testUtil.jwts.copilot).userId, + firstName: 'fname', + lastName: 'lName', + email: 'some@abc.com', + }; + const project = { + type: 'generic', + billingAccountId: 1, + name: 'test1', + description: 'test project1', + status: 'draft', + details: {}, + createdBy: 1, + updatedBy: 1, + lastActivityAt: 1, + lastActivityUserId: '1', + }; + beforeEach((done) => { + testUtil.clearDb() + .then(() => { + models.ProjectTemplate.create({ + name: 'template 2', + key: 'key 2', + category: 'category 2', + icon: 'http://example.com/icon1.ico', + question: 'question 2', + info: 'info 2', + aliases: ['key-2', 'key_2'], + scope: {}, + phases: {}, + createdBy: 1, + updatedBy: 2, + }) + .then((template) => { + models.WorkManagementPermission.create({ + policy: 'work.edit', + permission: { + allowRule: { + projectRoles: ['customer', 'copilot'], + topcoderRoles: ['Connect Manager', 'Connect Admin', 'administrator'], + }, + denyRule: { projectRoles: ['copilot'] }, + }, + projectTemplateId: template.id, + details: {}, + createdBy: 1, + updatedBy: 1, + lastActivityAt: 1, + lastActivityUserId: '1', + }) + .then(() => { + // Create projects + models.Project.create(_.assign(project, { templateId: template.id })) + .then((_project) => { + projectId = _project.id; + projectName = _project.name; + models.WorkStream.create({ + name: 'Work Stream', + type: 'generic', + status: 'active', + projectId, + createdBy: 1, + updatedBy: 1, + }).then((entity) => { + workStreamId = entity.id; + _.assign(body, { projectId }); + const createPhases = [ + body, + _.assign({ order: 1 }, body), + _.assign({}, body, { status: 'draft' }), + ]; + models.ProjectPhase.bulkCreate(createPhases, { returning: true }).then((phases) => { + workId = phases[0].id; + workId2 = phases[1].id; + workId3 = phases[2].id; + models.PhaseWorkStream.bulkCreate([{ + phaseId: phases[0].id, + workStreamId, + }, { + phaseId: phases[1].id, + workStreamId, + }, { + phaseId: phases[2].id, + workStreamId, + }]).then(() => { + // create members + models.ProjectMember.bulkCreate([{ + id: 1, + userId: copilotUser.userId, + projectId, + role: 'copilot', + isPrimary: false, + createdBy: 1, + updatedBy: 1, + }, { + id: 2, + userId: memberUser.userId, + projectId, + role: 'customer', + isPrimary: true, + createdBy: 1, + updatedBy: 1, + }]).then(() => done()); + }); + }); + }); + }); + }); + }); + }); + }); + + after((done) => { + testUtil.clearDb(done); + }); + + describe('PATCH /projects/{projectId}/workstreams/{workStreamId}/works/{workId}', () => { + it('should return 403 if user is not authenticated', (done) => { + request(server) + .patch(`/v5/projects/${projectId}/workstreams/${workStreamId}/works/${workId}`) + .send(updateBody) + .expect(403, done); + }); + + it('should return 403 for copilot', (done) => { + request(server) + .patch(`/v5/projects/${projectId}/workstreams/${workStreamId}/works/${workId}`) + .set({ + Authorization: `Bearer ${testUtil.jwts.copilot}`, + }) + .send(updateBody) + .expect(403, done); + }); + + it('should return 404 when no work stream with specific workStreamId', (done) => { + request(server) + .patch(`/v5/projects/${projectId}/workstreams/999/works/${workId}`) + .set({ + Authorization: `Bearer ${testUtil.jwts.manager}`, + }) + .send(updateBody) + .expect('Content-Type', /json/) + .expect(404, done); + }); + + it('should return 404 when no work with specific workId', (done) => { + request(server) + .patch(`/v5/projects/${projectId}/workstreams/${workStreamId}/works/999`) + .set({ + Authorization: `Bearer ${testUtil.jwts.manager}`, + }) + .send(updateBody) + .expect('Content-Type', /json/) + .expect(404, done); + }); + + it('should return 400 when parameters are invalid', (done) => { + request(server) + .patch(`/v5/projects/${projectId}/workstreams/${workStreamId}/works/${workId}`) + .set({ + Authorization: `Bearer ${testUtil.jwts.manager}`, + }) + .send({ + progress: -15, + }) + .expect('Content-Type', /json/) + .expect(400, done); + }); + + it('should return 400 when startDate > endDate', (done) => { + request(server) + .patch(`/v5/projects/${projectId}/workstreams/${workStreamId}/works/${workId}`) + .set({ + Authorization: `Bearer ${testUtil.jwts.manager}`, + }) + .send({ + endDate: '2018-05-13T00:00:00Z', + }) + .expect('Content-Type', /json/) + .expect(400, done); + }); + + it('should return 200 for member', (done) => { + request(server) + .patch(`/v5/projects/${projectId}/workstreams/${workStreamId}/works/${workId}`) + .set({ + Authorization: `Bearer ${testUtil.jwts.member}`, + }) + .send(updateBody) + .expect(200, done); + }); + + it('should return updated phase when user have permission and parameters are valid', (done) => { + request(server) + .patch(`/v5/projects/${projectId}/workstreams/${workStreamId}/works/${workId}`) + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .send(updateBody) + .expect('Content-Type', /json/) + .expect(200) + .end((err, res) => { + if (err) { + done(err); + } else { + const resJson = res.body; + validatePhase(resJson, updateBody); + done(); + } + }); + }); + + it('should return updated phase when parameters are valid (0 for non -ve numbers)', (done) => { + const bodyWithZeros = _.cloneDeep(updateBody); + bodyWithZeros.duration = 0; + bodyWithZeros.spentBudget = 0.0; + bodyWithZeros.budget = 0.0; + bodyWithZeros.progress = 0.0; + request(server) + .patch(`/v5/projects/${projectId}/workstreams/${workStreamId}/works/${workId}`) + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .send(bodyWithZeros) + .expect('Content-Type', /json/) + .expect(200) + .end((err, res) => { + if (err) { + done(err); + } else { + const resJson = res.body; + validatePhase(resJson, bodyWithZeros); + done(); + } + }); + }); + + it('should return updated phase if the order is specified', (done) => { + request(server) + .patch(`/v5/projects/${projectId}/workstreams/${workStreamId}/works/${workId}`) + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .send(_.assign({ order: 1 }, updateBody)) + .expect('Content-Type', /json/) + .expect(200) + .end((err, res) => { + if (err) { + done(err); + } else { + const resJson = res.body; + validatePhase(resJson, updateBody); + resJson.order.should.be.eql(1); + + // Check the order of the other phase + models.ProjectPhase.findOne({ where: { id: workId2 } }) + .then((work2) => { + work2.order.should.be.eql(2); + done(); + }); + } + }); + }); + + describe('Bus api', () => { + let createEventSpy; + const sandbox = sinon.sandbox.create(); + + + before((done) => { + // Wait for 500ms in order to wait for createEvent calls from previous tests to complete + testUtil.wait(done); + }); + + + beforeEach(() => { + createEventSpy = sandbox.spy(busApi, 'createEvent'); + }); + + + afterEach(() => { + sandbox.restore(); + }); + + it('should send correct BUS API messages when spentBudget updated', (done) => { + request(server) + .patch(`/v5/projects/${projectId}/workstreams/${workStreamId}/works/${workId}`) + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .send({ + spentBudget: 123, + }) + .expect('Content-Type', /json/) + .expect(200) + .end((err) => { + if (err) { + done(err); + } else { + testUtil.wait(() => { + createEventSpy.callCount.should.be.eql(2); + + createEventSpy.calledWith(BUS_API_EVENT.PROJECT_PHASE_UPDATED, sinon.match({ + resource: RESOURCES.PHASE, + id: workId, + updatedBy: testUtil.userIds.admin, + })).should.be.true; + + // Check Notification Service events + createEventSpy.calledWith(CONNECT_NOTIFICATION_EVENT.PROJECT_WORK_UPDATE_PAYMENT).should.be.true; + + done(); + }); + } + }); + }); + + it('should send correct BUS API messages when progress updated', (done) => { + request(server) + .patch(`/v5/projects/${projectId}/workstreams/${workStreamId}/works/${workId}`) + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .send({ + progress: 50, + }) + .expect('Content-Type', /json/) + .expect(200) + .end((err) => { + if (err) { + done(err); + } else { + testUtil.wait(() => { + createEventSpy.callCount.should.be.eql(3); + + createEventSpy.calledWith(BUS_API_EVENT.PROJECT_PHASE_UPDATED, sinon.match({ + resource: RESOURCES.PHASE, + id: workId, + updatedBy: testUtil.userIds.admin, + })).should.be.true; + + // Check Notification Service events + createEventSpy.calledWith(CONNECT_NOTIFICATION_EVENT.PROJECT_WORK_UPDATE_PROGRESS).should.be.true; + createEventSpy.calledWith(CONNECT_NOTIFICATION_EVENT.PROJECT_PROGRESS_MODIFIED).should.be.true; + done(); + }); + } + }); + }); + + it('should send correct BUS API messages when details updated', (done) => { + request(server) + .patch(`/v5/projects/${projectId}/workstreams/${workStreamId}/works/${workId}`) + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .send({ + details: { + text: 'something', + }, + }) + .expect('Content-Type', /json/) + .expect(200) + .end((err) => { + if (err) { + done(err); + } else { + testUtil.wait(() => { + createEventSpy.callCount.should.be.eql(2); + + createEventSpy.calledWith(BUS_API_EVENT.PROJECT_PHASE_UPDATED, sinon.match({ + resource: RESOURCES.PHASE, + id: workId, + updatedBy: testUtil.userIds.admin, + })).should.be.true; + + // Check Notification Service events + createEventSpy.calledWith(CONNECT_NOTIFICATION_EVENT.PROJECT_WORK_UPDATE_SCOPE).should.be.true; + + done(); + }); + } + }); + }); + + it('should send correct BUS API messages when status updated (completed)', (done) => { + request(server) + .patch(`/v5/projects/${projectId}/workstreams/${workStreamId}/works/${workId}`) + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .send({ + status: 'completed', + }) + .expect('Content-Type', /json/) + .expect(200) + .end((err) => { + if (err) { + done(err); + } else { + testUtil.wait(() => { + createEventSpy.callCount.should.be.eql(2); + + createEventSpy.calledWith(BUS_API_EVENT.PROJECT_PHASE_UPDATED, sinon.match({ + resource: RESOURCES.PHASE, + id: workId, + updatedBy: testUtil.userIds.admin, + })).should.be.true; + + // Check Notification Service events + createEventSpy.calledWith(CONNECT_NOTIFICATION_EVENT.PROJECT_WORK_TRANSITION_COMPLETED).should.be.true; + + done(); + }); + } + }); + }); + + it('should send correct BUS API messages when status updated (active)', (done) => { + request(server) + .patch(`/v5/projects/${projectId}/workstreams/${workStreamId}/works/${workId3}`) + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .send({ + status: 'active', + }) + .expect('Content-Type', /json/) + .expect(200) + .end((err) => { + if (err) { + done(err); + } else { + testUtil.wait(() => { + createEventSpy.callCount.should.be.eql(2); + + createEventSpy.calledWith(BUS_API_EVENT.PROJECT_PHASE_UPDATED, sinon.match({ + resource: RESOURCES.PHASE, + id: workId3, + updatedBy: testUtil.userIds.admin, + })).should.be.true; + + // Check Notification Service events + createEventSpy.calledWith(CONNECT_NOTIFICATION_EVENT.PROJECT_WORK_TRANSITION_ACTIVE).should.be.true; + + done(); + }); + } + }); + }); + + it('should send correct BUS API messages when budget updated', (done) => { + request(server) + .patch(`/v5/projects/${projectId}/workstreams/${workStreamId}/works/${workId}`) + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .send({ + budget: 123, + }) + .expect('Content-Type', /json/) + .expect(200) + .end((err) => { + if (err) { + done(err); + } else { + testUtil.wait(() => { + createEventSpy.callCount.should.be.eql(1); + + createEventSpy.calledWith(BUS_API_EVENT.PROJECT_PHASE_UPDATED, sinon.match({ + resource: RESOURCES.PHASE, + id: workId, + updatedBy: testUtil.userIds.admin, + })).should.be.true; + + done(); + }); + } + }); + }); + + it('should send correct BUS API messages when startDate updated', (done) => { + request(server) + .patch(`/v5/projects/${projectId}/workstreams/${workStreamId}/works/${workId}`) + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .send({ + startDate: 123, + }) + .expect('Content-Type', /json/) + .expect(200) + .end((err) => { + if (err) { + done(err); + } else { + testUtil.wait(() => { + createEventSpy.callCount.should.be.eql(2); + + createEventSpy.calledWith(BUS_API_EVENT.PROJECT_PHASE_UPDATED, sinon.match({ + resource: RESOURCES.PHASE, + id: workId, + updatedBy: testUtil.userIds.admin, + })).should.be.true; + + // Check Notification Service events + createEventSpy.calledWith(CONNECT_NOTIFICATION_EVENT.PROJECT_PLAN_UPDATED, sinon.match({ + projectId, + projectName, + projectUrl: `https://local.topcoder-dev.com/projects/${projectId}`, + userId: 40051333, + initiatorUserId: 40051333, + })).should.be.true; + + done(); + }); + } + }); + }); + + + it('should send correct BUS API messages when duration updated', (done) => { + request(server) + .patch(`/v5/projects/${projectId}/workstreams/${workStreamId}/works/${workId}`) + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .send({ + duration: 100, + }) + .expect('Content-Type', /json/) + .expect(200) + .end((err) => { + if (err) { + done(err); + } else { + testUtil.wait(() => { + createEventSpy.callCount.should.be.eql(2); + + createEventSpy.calledWith(BUS_API_EVENT.PROJECT_PHASE_UPDATED, sinon.match({ + duration: 100, + })).should.be.true; + + // Check Notification Service events + createEventSpy.calledWith(CONNECT_NOTIFICATION_EVENT.PROJECT_PLAN_UPDATED, sinon.match({ + projectId, + projectName, + projectUrl: `https://local.topcoder-dev.com/projects/${projectId}`, + userId: 40051333, + initiatorUserId: 40051333, + })).should.be.true; + + done(); + }); + } + }); + }); + + it('should send correct BUS API messages when order updated', (done) => { + request(server) + .patch(`/v5/projects/${projectId}/workstreams/${workStreamId}/works/${workId}`) + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .send({ + order: 100, + }) + .expect('Content-Type', /json/) + .expect(200) + .end((err) => { + if (err) { + done(err); + } else { + testUtil.wait(() => { + createEventSpy.callCount.should.be.eql(1); + + createEventSpy.calledWith(BUS_API_EVENT.PROJECT_PHASE_UPDATED, sinon.match({ + resource: RESOURCES.PHASE, + id: workId, + updatedBy: testUtil.userIds.admin, + })).should.be.true; + + // NOTE: no other event should be called, as this phase doesn't move any other phases + + done(); + }); + } + }); + }); + + it('should send correct BUS API messages when endDate updated', (done) => { + request(server) + .patch(`/v5/projects/${projectId}/workstreams/${workStreamId}/works/${workId}`) + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .send({ + endDate: new Date(), + }) + .expect('Content-Type', /json/) + .expect(200) + .end((err) => { + if (err) { + done(err); + } else { + testUtil.wait(() => { + createEventSpy.callCount.should.be.eql(1); + + createEventSpy.calledWith(BUS_API_EVENT.PROJECT_PHASE_UPDATED, sinon.match({ + resource: RESOURCES.PHASE, + id: workId, + updatedBy: testUtil.userIds.admin, + })).should.be.true; + + done(); + }); + } + }); + }); + }); + + /* describe('RabbitMQ Message topic', () => { + let updateMessageSpy; + let publishSpy; + let sandbox; + + before(async (done) => { + // Wait for 500ms in order to wait for createEvent calls from previous tests to complete + testUtil.wait(done); + }); + + beforeEach(async (done) => { + sandbox = sinon.sandbox.create(); + server.services.pubsub = new RabbitMQService(server.logger); + + // initialize RabbitMQ + server.services.pubsub.init( + config.get('rabbitmqURL'), + config.get('pubsubExchangeName'), + config.get('pubsubQueueName'), + ); + + // add project to ES index + await server.services.es.index({ + index: ES_PROJECT_INDEX, + type: ES_PROJECT_TYPE, + id: projectId, + body: { + doc: _.assign(project, { phases: [_.assign(body, { id: workId, projectId })] }), + }, + }); + + testUtil.wait(() => { + publishSpy = sandbox.spy(server.services.pubsub, 'publish'); + updateMessageSpy = sandbox.spy(messageService, 'updateTopic'); + sandbox.stub(messageService, 'getTopicByTag', () => Promise.resolve(topic)); + done(); + }); + }); + + afterEach(() => { + sandbox.restore(); + }); + + after(() => { + mockRabbitMQ(server); + }); + + it('should send message topic when work updated', (done) => { + const mockHttpClient = _.merge(testUtil.mockHttpClient, { + post: () => Promise.resolve({ + status: 200, + data: { + id: 'requesterId', + version: 'v3', + result: { + success: true, + status: 200, + content: {}, + }, + }, + }), + }); + sandbox.stub(messageService, 'getClient', () => mockHttpClient); + request(server) + .patch(`/v4/projects/${projectId}/workstreams/${workStreamId}/works/${workId}`) + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .send({ param: _.assign(updateBody, { budget: 123 }) }) + .expect('Content-Type', /json/) + .expect(200) + .end((err) => { + if (err) { + done(err); + } else { + testUtil.wait(() => { + publishSpy.calledOnce.should.be.true; + publishSpy.calledWith('project.phase.updated').should.be.true; + updateMessageSpy.calledTwice.should.be.true; + done(); + }); + } + }); + }); + }); */ + }); +}); diff --git a/src/services/messageService.js b/src/services/messageService.js index 949c013..c596ddd 100644 --- a/src/services/messageService.js +++ b/src/services/messageService.js @@ -1,6 +1,5 @@ import config from 'config'; import _ from 'lodash'; -// import util from '../util'; const Promise = require('bluebird'); const axios = require('axios'); @@ -141,19 +140,19 @@ function deletePosts(topicId, postIds, logger) { * Fetches the topic of given phase of the project. * * @param {Integer} projectId id of the project - * @param {Integer} phaseId id of the phase of the project + * @param {String} tag tag * @param {Object} logger object * @return {Promise} topic promise */ -function getPhaseTopic(projectId, phaseId, logger) { - logger.debug(`getPhaseTopic for projectId: ${projectId} phaseId: ${phaseId}`); +function getTopicByTag(projectId, tag, logger) { + logger.debug(`getTopicByTag for projectId: ${projectId} tag: ${tag}`); return getClient(logger).then((msgClient) => { - logger.debug(`calling message service for fetching phaseId#${phaseId}`); - const encodedFilter = encodeURIComponent(`reference=project&referenceId=${projectId}&tag=phase#${phaseId}`); + logger.debug(`calling message service for fetching ${tag}`); + const encodedFilter = encodeURIComponent(`reference=project&referenceId=${projectId}&tag=${tag}`); return msgClient.get(`/topics/list/db?filter=${encodedFilter}`) .then((resp) => { - logger.debug('Fetched phase topic', resp); const topics = _.get(resp.data, 'result.content', []); + logger.debug(`Fetched ${topics.length} topics`); if (topics && topics.length > 0) { return topics[0]; } @@ -181,6 +180,7 @@ module.exports = { createTopic, updateTopic, deletePosts, - getPhaseTopic, + getTopicByTag, deleteTopic, + getClient, }; diff --git a/src/tests/mockRabbitMQ.js b/src/tests/mockRabbitMQ.js new file mode 100644 index 0000000..3913bd7 --- /dev/null +++ b/src/tests/mockRabbitMQ.js @@ -0,0 +1,18 @@ +/** + * Mock RabbitMQ service + */ +/* globals Promise */ + +import sinon from 'sinon'; +import _ from 'lodash'; + +module.exports = (app) => { + _.assign(app.services, { + pubsub: { + init: () => {}, + publish: () => {}, + }, + }); + sinon.stub(app.services.pubsub, 'init', () => Promise.resolve(true)); + sinon.stub(app.services.pubsub, 'publish', () => Promise.resolve(true)); +}; diff --git a/src/tests/serviceMocks.js b/src/tests/serviceMocks.js index 662bd2c..060596d 100644 --- a/src/tests/serviceMocks.js +++ b/src/tests/serviceMocks.js @@ -7,16 +7,13 @@ import _ from 'lodash'; import config from 'config'; import elasticsearch from 'elasticsearch'; import util from '../util'; +import mockRabbitMQ from './mockRabbitMQ'; module.exports = (app) => { + mockRabbitMQ(app); + _.assign(app.services, { - pubsub: { - init: () => {}, - publish: () => {}, - }, es: new elasticsearch.Client(_.cloneDeep(config.elasticsearchConfig)), }); - sinon.stub(app.services.pubsub, 'init', () => Promise.resolve(true)); - sinon.stub(app.services.pubsub, 'publish', () => Promise.resolve(true)); sinon.stub(util, 'getM2MToken', () => Promise.resolve('MOCK_TOKEN')); }; diff --git a/src/tests/util.js b/src/tests/util.js index 3031dd1..08f0a65 100644 --- a/src/tests/util.js +++ b/src/tests/util.js @@ -1,6 +1,7 @@ /* eslint-disable max-len */ import models from '../models'; +import elasticsearchSync from '../../migrations/elasticsearch_sync'; const jwt = require('jsonwebtoken'); @@ -9,6 +10,10 @@ export default { .then(() => { if (done) done(); }), + clearES: done => elasticsearchSync.sync() + .then(() => { + if (done) done(); + }), mockHttpClient: { defaults: { headers: { common: {} } }, interceptors: { response: { use: () => {} } }, @@ -26,6 +31,8 @@ export default { member2: 'eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJyb2xlcyI6WyJUb3Bjb2RlciBVc2VyIl0sImlzcyI6Imh0dHBzOi8vYXBpLnRvcGNvZGVyLWRldi5jb20iLCJoYW5kbGUiOiJtZW1iZXIyIiwiZXhwIjoyNTYzMDc2Njg5LCJ1c2VySWQiOiI0MDA1MTMzNSIsImlhdCI6MTQ2MzA3NjA4OSwiZW1haWwiOiJ0ZXN0QHRvcGNvZGVyLmNvbSIsImp0aSI6ImIzM2I3N2NkLWI1MmUtNDBmZS04MzdlLWJlYjhlMGFlNmE0YSJ9.Mh4bw3wm-cn5Kcf96gLFVlD0kySOqqk4xN3qnreAKL4', // userId = 40051336, [ 'Connect Admin' ], handle: 'connect_admin1', email: 'connect_admin1@topcoder.com' connectAdmin: 'eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJyb2xlcyI6WyJDb25uZWN0IEFkbWluIl0sImlzcyI6Imh0dHBzOi8vYXBpLnRvcGNvZGVyLWRldi5jb20iLCJoYW5kbGUiOiJjb25uZWN0X2FkbWluMSIsImV4cCI6MjU2MzA3NjY4OSwidXNlcklkIjoiNDAwNTEzMzYiLCJpYXQiOjE0NjMwNzYwODksImVtYWlsIjoiY29ubmVjdF9hZG1pbjFAdG9wY29kZXIuY29tIiwianRpIjoiYjMzYjc3Y2QtYjUyZS00MGZlLTgzN2UtYmViOGUwYWU2YTRhIn0.nSGfXMl02NZ90ZKLiEKPg75iAjU92mfteaY6xgqkM30', + // userId = 40158431, [ 'Topcoder user' ], handle: 'romitchoudhary', email: 'romit.choudhary@rivigo.com' + romit: 'eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJyb2xlcyI6WyJUb3Bjb2RlciBVc2VyIl0sImlzcyI6Imh0dHBzOi8vYXBpLnRvcGNvZGVyLWRldi5jb20iLCJoYW5kbGUiOiJyb21pdGNob3VkaGFyeSIsImV4cCI6MTU2MjkxOTc5MSwidXNlcklkIjoiNDAxNTg0MzEiLCJpYXQiOjE1NjI5MTkxOTEsImVtYWlsIjoicm9taXQuY2hvdWRoYXJ5QHJpdmlnby5jb20iLCJqdGkiOiJlMmM1ZTc2NS03OTI5LTRiNzgtYjI2OS1iZDRlODA0NDI4YjMifQ.P1CoydCJuQ8Hv_b0-a8V7Wu0pgIt9qv4NYyB7FTbua0', }, userIds: { member: 40051331, @@ -34,6 +41,7 @@ export default { manager: 40051334, member2: 40051335, connectAdmin: 40051336, + romit: 40158431, }, getDecodedToken: token => jwt.decode(token), diff --git a/src/util.js b/src/util.js index da991b5..b17fdeb 100644 --- a/src/util.js +++ b/src/util.js @@ -11,22 +11,33 @@ import _ from 'lodash'; +import querystring from 'querystring'; import config from 'config'; import urlencode from 'urlencode'; import elasticsearch from 'elasticsearch'; import Promise from 'bluebird'; +import models from './models'; // import AWS from 'aws-sdk'; -import { ADMIN_ROLES, TOKEN_SCOPES, EVENT, PROJECT_MEMBER_ROLE } from './constants'; +import { + ADMIN_ROLES, + TOKEN_SCOPES, + EVENT, + PROJECT_MEMBER_ROLE, + VALUE_TYPE, + ESTIMATION_TYPE, + RESOURCES, +} from './constants'; const exec = require('child_process').exec; -const models = require('./models').default; const tcCoreLibAuth = require('tc-core-library-js').auth; const m2m = tcCoreLibAuth.m2m(config); const util = _.cloneDeep(require('tc-core-library-js').util(config)); +const ssoRefCodes = JSON.parse(config.get('SSO_REFCODES')); + // the client modifies the config object, so always passed the cloned object let esClient = null; @@ -77,6 +88,76 @@ _.assignIn(util, { }); return valid; }, + /** + * Calculate project estimation item price + * @param {object} valueType value type can be int, string, double, percentage + * @param {String} value value + * @param {Double} price price + * @return {Double|String} calculated price value + */ + calculateEstimationItemPrice: (valueType, value, price) => { + if (valueType === VALUE_TYPE.PERCENTAGE) { + return (value * price) / 100; + } + return value; + }, + /** + * Calculate project estimation item price + * @param {Object} req the request + * @param {Number} projectId project id + * @return {Array} estimation items + */ + calculateProjectEstimationItems: (req, projectId) => + // delete ALL existent ProjectEstimationItems for the project + models.ProjectEstimationItem.deleteAllForProject(models, projectId, req.authUser, { + includeAllProjectEstimatinoItemsForInternalUsage: true, + }) + + // retrieve ProjectSettings and ProjectEstimations + .then(() => Promise.all([ + models.ProjectSetting.findAll({ + includeAllProjectSettingsForInternalUsage: true, + where: { + projectId, + key: _.map(_.values(ESTIMATION_TYPE), type => `markup_${type}`), + }, + raw: true, + }), + models.ProjectEstimation.findAll({ + where: { projectId: req.params.projectId }, + raw: true, + }), + ])) + + // create ProjectEstimationItems + .then(([settings, estimations]) => { + if (!settings || settings.length === 0) { + req.log.debug('No project settings for prices found, therefore no estimation items are created'); + return []; + } + + if (!estimations || estimations.length === 0) { + req.log.debug('No price estimations found, therefore no estimation items are created'); + return []; + } + + const estimationItems = []; + _.each(estimations, (estimation) => { + _.each(settings, (setting) => { + estimationItems.push({ + projectEstimationId: estimation.id, + price: util.calculateEstimationItemPrice(setting.valueType, setting.value, estimation.price), + type: setting.key.replace(/^markup_/, ''), + markupUsedReference: 'projectSetting', + markupUsedReferenceId: setting.id, + createdBy: req.authUser.userId, + updatedBy: req.authUser.userId, + }); + }); + }); + + return models.ProjectEstimationItem.bulkCreate(estimationItems); + }), /** * Helper funtion to verify if user has specified role * @param {object} req Request object that should contain authUser @@ -165,6 +246,26 @@ _.assignIn(util, { return fields; }, + /** + * Parse the query filters + * @param {String} fqueryFilter the query filter string + * @return {Object} the parsed array + */ + parseQueryFilter: (fqueryFilter) => { + let queryFilter = querystring.parse(fqueryFilter); + // convert in to array + queryFilter = _.mapValues(queryFilter, (val) => { + if (val.indexOf('in(') > -1) { + return { $in: val.substring(3, val.length - 1).split(',') }; + } + return val; + }); + if (queryFilter.id && queryFilter.id.$in) { + queryFilter.id.$in = _.map(queryFilter.id.$in, _.parseInt); + } + return queryFilter; + }, + /** * Moves file from source to destination * @param {object} req request object @@ -296,6 +397,16 @@ _.assignIn(util, { } else { esClient = new elasticsearch.Client(_.cloneDeep(config.elasticsearchConfig)); } + // during unit tests, we need to refresh the indices + // before making get/search requests to make sure all ES data can be visible. + if (process.env.NODE_ENV.toLowerCase() === 'test') { + esClient.originalSearch = esClient.search; + esClient.search = (params, cb) => esClient.indices.refresh({ index: '' }) + .then(() => esClient.originalSearch(params, cb)); // refresh index before reply + esClient.originalGet = esClient.get; + esClient.get = (params, cb) => esClient.indices.refresh({ index: '' }) + .then(() => esClient.originalGet(params, cb)); // refresh index before reply + } return esClient; }, @@ -432,11 +543,21 @@ _.assignIn(util, { * @param {String} key the event key * @param {String} name the resource name * @param {object} resource the resource + * @param {object} [originalResource] original resource in case resource was updated + * @param {String} [route] route which called the event (for phases and works) + * @param {Boolean}[skipNotification] if true, than event is not send to Notification Service */ - sendResourceToKafkaBus: Promise.coroutine(function* (req, key, name, resource) { // eslint-disable-line + sendResourceToKafkaBus: Promise.coroutine(function* (req, key, name, resource, originalResource, route, skipNotification) { // eslint-disable-line req.log.debug('Sending event to Kafka bus for resource %s %s', name, resource.id || resource.key); + // emit event - req.app.emit(key, { req, resource: _.assign({ resource: name }, resource) }); + req.app.emit(key, { + req, + resource: _.assign({ resource: name }, resource), + originalResource: originalResource ? _.assign({ resource: name }, originalResource) : undefined, + route, + skipNotification, + }); }), /** @@ -468,6 +589,12 @@ _.assignIn(util, { newMember, { correlationId: req.id }, ); + // emit the event + util.sendResourceToKafkaBus( + req, + EVENT.ROUTING_KEY.PROJECT_MEMBER_ADDED, + RESOURCES.PROJECT_MEMBER, + newMember); return newMember; }) @@ -517,6 +644,72 @@ _.assignIn(util, { }); }, + /** + * Lookup user handles from multiple emails + * @param {Object} req request + * @param {Array} userEmails user emails + * @param {Number} maximumRequests limit number of request on one batch + * @param {Boolean} isPattern flag to indicate that pattern matching is required or not + * @return {Promise} promise + */ + lookupMultipleUserEmails(req, userEmails, maximumRequests, isPattern = false) { + req.log.debug(`identityServiceEndpoint: ${config.get('identityServiceEndpoint')}`); + + const httpClient = util.getHttpClient({ id: req.id, log: req.log }); + // request generator function + const generateRequest = ({ token, email }) => { + let filter = `email=${email}`; + if (isPattern) { + filter += '&like=true'; + } + return httpClient.get(`${config.get('identityServiceEndpoint')}users`, { + headers: { + Authorization: `Bearer ${token}`, + Accept: 'application/json', + 'Content-Type': 'application/json', + }, + params: { + fields: 'handle,id,email', + filter, + }, + // set longer timeout as default 3000 could be not enough for identity service response + timeout: 15000, + }).catch(() => { + // in case of any error happens during getting user by email + // we treat such users as not found and don't return error + // as per discussion in issue #334 + }); + }; + // send batch of requests, one batch at one time + const sendBatch = (options) => { + const token = options.token; + const emails = options.emails; + const users = options.users || []; + const batch = options.batch || 0; + const start = batch * maximumRequests; + const end = (batch + 1) * maximumRequests; + const requests = emails.slice(start, end).map(userEmail => + generateRequest({ token, email: userEmail })); + return Promise.all(requests) + .then((responses) => { + const data = responses.reduce((contents, response) => { + const content = _.get(response, 'data.result.content', []); + return _.concat(contents, content); + }, users); + req.log.debug(`UserHandle response batch-${batch}`, data); + if (end < emails.length) { + return sendBatch({ token, users: data, emails, batch: batch + 1 }); + } + return data; + }); + }; + return util.getM2MToken() + .then((m2mToken) => { + req.log.debug(`Bearer ${m2mToken}`); + return sendBatch({ token: m2mToken, emails: userEmails }); + }); + }, + /** * Filter only members of topcoder team * @param {Array} members project members @@ -524,6 +717,13 @@ _.assignIn(util, { */ getTopcoderProjectMembers: members => _(members).filter(m => m.role !== PROJECT_MEMBER_ROLE.CUSTOMER), + /** + * Check if project is for SSO users + * @param {Object} project project + * @return {Boolean} is SSO project + */ + isSSO: project => ssoRefCodes.indexOf(_.get(project, 'details.utm.code')) > -1, + /** * Set paginated header and respond with data * @param {Object} req HTTP request @@ -634,6 +834,174 @@ _.assignIn(util, { return Promise.resolve(null); }, + + /** + * Check if user match the permission rule. + * + * This method uses permission rule defined in `permissionRule` + * and checks that the `user` matches it. + * + * If we define a rule with `projectRoles` list, we also should provide `projectMembers` + * - the list of project members. + * + * @param {Object} permissionRule permission rule + * @param {Array<String>} permissionRule.projectRoles the list of project roles of the user + * @param {Array<String>} permissionRule.topcoderRoles the list of Topcoder roles of the user + * @param {Object} user user for whom we check permissions + * @param {Object} user.roles list of user roles + * @param {Object} user.isMachine `true` - if it's machine, `false` - real user + * @param {Object} user.scopes scopes of user token + * @param {Array} projectMembers (optional) list of project members - required to check `topcoderRoles` + * + * @returns {Boolean} true, if has permission + */ + matchPermissionRule: (permissionRule, user, projectMembers) => { + const member = _.find(projectMembers, { userId: user.userId }); + let hasProjectRole = false; + let hasTopcoderRole = false; + + if (permissionRule) { + if (permissionRule.projectRoles + && permissionRule.projectRoles.length > 0 + && !!member + ) { + hasProjectRole = _.includes(permissionRule.projectRoles, member.role); + } + + if (permissionRule.topcoderRoles && permissionRule.topcoderRoles.length > 0) { + hasTopcoderRole = util.hasRoles({ authUser: user }, permissionRule.topcoderRoles); + } + } + + return hasProjectRole || hasTopcoderRole; + }, + + /** + * Check if user has permission. + * + * This method uses permission defined in `permission` and checks that the `user` matches it. + * + * `permission` may be defined in two ways: + * - **Full** way with defined `allowRule` and optional `denyRule`, example: + * ```js + * { + * allowRule: { + * projectRoles: [], + * topcoderRoles: [] + * }, + * denyRule: { + * projectRoles: [], + * topcoderRoles: [] + * } + * } + * ``` + * If user matches `denyRule` then the access would be dined even if matches `allowRule`. + * - **Simplified** way may be used if we only want to define `allowRule`. + * We can skip the `allowRule` property and define `allowRule` directly inside `permission` object, example: + * ```js + * { + * projectRoles: [], + * topcoderRoles: [] + * } + * ``` + * This **simplified** permission is equal to a **full** permission: + * ```js + * { + * allowRule: { + * projectRoles: [], + * topcoderRoles: [] + * } + * } + * ``` + * + * If we define any rule with `projectRoles` list, we also should provide `projectMembers` + * - the list of project members. + * + * @param {Object} permission permission or permissionRule + * @param {Object} user user for whom we check permissions + * @param {Object} user.roles list of user roles + * @param {Object} user.isMachine `true` - if it's machine, `false` - real user + * @param {Object} user.scopes scopes of user token + * @param {Array} projectMembers (optional) list of project members - required to check `topcoderRoles` + * + * @returns {Boolean} true, if has permission + */ + hasPermission: (permission, user, projectMembers) => { + const allowRule = permission.allowRule ? permission.allowRule : permission; + const denyRule = permission.denyRule ? permission.denyRule : null; + + const allow = util.matchPermissionRule(allowRule, user, projectMembers); + const deny = util.matchPermissionRule(denyRule, user, projectMembers); + + return allow && !deny; + }, + + /** + * Check if user has permission for the project by `projectId`. + * + * This method uses permission defined in `permission` and checks that the `user` matches it. + * + * `permission` may be defined in two ways: + * - **Full** way with defined `allowRule` and optional `denyRule`, example: + * ```js + * { + * allowRule: { + * projectRoles: [], + * topcoderRoles: [] + * }, + * denyRule: { + * projectRoles: [], + * topcoderRoles: [] + * } + * } + * ``` + * If user matches `denyRule` then the access would be dined even if matches `allowRule`. + * - **Simplified** way may be used if we only want to define `allowRule`. + * We can skip the `allowRule` property and define `allowRule` directly inside `permission` object, example: + * ```js + * { + * projectRoles: [], + * topcoderRoles: [] + * } + * ``` + * This **simplified** permission is equal to a **full** permission: + * ```js + * { + * allowRule: { + * projectRoles: [], + * topcoderRoles: [] + * } + * } + * ``` + * + * @param {Object} permission permission or permissionRule + * @param {Object} user user for whom we check permissions + * @param {Object} user.roles list of user roles + * @param {Object} user.isMachine `true` - if it's machine, `false` - real user + * @param {Object} user.scopes scopes of user token + * @param {Number} projectId project id to check permissions for + * + * @returns {Promise<Boolean>} true, if has permission + */ + hasPermissionForProject: (permission, user, projectId) => ( + models.ProjectMember.getActiveProjectMembers(projectId).then(projectMembers => + util.hasPermission(permission, user, projectMembers), + ) + ), + + /** + * Checks if the Project Setting represents price estimation setting + * + * @param {String} key project setting key + * + * @returns {Boolean} true it's project setting for price estimation + */ + isProjectSettingForEstimation: (key) => { + const markupMatch = key.match(/^markup_(.+)$/); + const markupKey = markupMatch && markupMatch[1] ? markupMatch[1] : null; + + return markupKey ? _.includes(_.values(ESTIMATION_TYPE), markupKey) : false; + }, }); export default util; diff --git a/src/util.spec.js b/src/util.spec.js new file mode 100644 index 0000000..9972269 --- /dev/null +++ b/src/util.spec.js @@ -0,0 +1,39 @@ +/** + * Tests for util.js + */ +import chai from 'chai'; +import util from './util'; + +chai.should(); + +describe('Util method', () => { + describe('isProjectSettingForEstimation', () => { + it('should return "true" if key is correct: "markup_fee"', () => { + util.isProjectSettingForEstimation('markup_fee').should.equal(true); + }); + + it('should return "false" if key has unknown estimation type: "markup_unknown"', () => { + util.isProjectSettingForEstimation('markup_unknown').should.equal(false); + }); + + it('should return "false" if key doesn\'t have "markup_" prefix: "fee"', () => { + util.isProjectSettingForEstimation('fee').should.equal(false); + }); + + it('should return "false" if key doesn\'t have duplicated prefix "markup_": "markup_markup_fee"', () => { + util.isProjectSettingForEstimation('markup_markup_fee').should.equal(false); + }); + + it('should return "false" if has prefix "markup_" at the end: "feemarkup_"', () => { + util.isProjectSettingForEstimation('feemarkup_').should.equal(false); + }); + + it('should return "false" if has additional text after: "markup_fee_text"', () => { + util.isProjectSettingForEstimation('markup_fee_text').should.equal(false); + }); + + it('should return "false" if has additional text before: "text_markup_fee"', () => { + util.isProjectSettingForEstimation('text_markup_fee').should.equal(false); + }); + }); +});