diff --git a/local/seed/seedProjects.js b/local/seed/seedProjects.js index e1d50417..4138296d 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'); @@ -54,6 +55,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 = [] @@ -134,4 +150,3 @@ function updateProjectMemberInvite(projectId, params, targetUrl, headers) { console.log(`Failed to update project member invites ${projectId}: ${err.message}`); }) } - 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 00000000..cf6086b0 --- /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 00000000..2342ea0a --- /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/postman.json b/postman.json index 796643d0..ae743441 100644 --- a/postman.json +++ b/postman.json @@ -8696,6 +8696,740 @@ "_postman_isSubFolder": true } ] + }, + { + "name": "Project Setting", + "item": [ + { + "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().result.content.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 \"param\":{\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 }\r\n}" + }, + "url": { + "raw": "{{api-url}}/v4/projects/1/settings", + "host": [ + "{{api-url}}" + ], + "path": [ + "v4", + "projects", + "1", + "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 \"param\":{\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 }\r\n}" + }, + "url": { + "raw": "{{api-url}}/v4/projects/1/settings", + "host": [ + "{{api-url}}" + ], + "path": [ + "v4", + "projects", + "1", + "settings" + ] + } + }, + "response": [] + }, + { + "name": "Create project setting - for project = 2", + "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-admin-40051333}}" + } + ], + "body": { + "mode": "raw", + "raw": "{\r\n \"param\":{\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 }\r\n}" + }, + "url": { + "raw": "{{api-url}}/v4/projects/2/settings", + "host": [ + "{{api-url}}" + ], + "path": [ + "v4", + "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 \"param\":{\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 }\r\n}" + }, + "url": { + "raw": "{{api-url}}/v4/projects/1/settings", + "host": [ + "{{api-url}}" + ], + "path": [ + "v4", + "projects", + "1", + "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 \"param\":{\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 }\r\n}" + }, + "url": { + "raw": "{{api-url}}/v4/projects/1/settings", + "host": [ + "{{api-url}}" + ], + "path": [ + "v4", + "projects", + "1", + "settings" + ] + } + }, + "response": [] + }, + { + "name": "Create project setting - duplicate key", + "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-admin-40051333}}" + } + ], + "body": { + "mode": "raw", + "raw": "{\r\n \"param\":{\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 }\r\n}" + }, + "url": { + "raw": "{{api-url}}/v4/projects/1/settings", + "host": [ + "{{api-url}}" + ], + "path": [ + "v4", + "projects", + "1", + "settings" + ] + } + }, + "response": [] + }, + { + "name": "Create project setting with invalid valueType", + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + }, + { + "key": "Authorization", + "value": "Bearer {{jwt-token-admin-40051333}}" + } + ], + "body": { + "mode": "raw", + "raw": "{\r\n \"param\":{\r\n \"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 }\r\n}" + }, + "url": { + "raw": "{{api-url}}/v4/projects/1/settings", + "host": [ + "{{api-url}}" + ], + "path": [ + "v4", + "projects", + "1", + "settings" + ] + } + }, + "response": [] + }, + { + "name": "Create project setting with invalid percentage value", + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + }, + { + "key": "Authorization", + "value": "Bearer {{jwt-token-admin-40051333}}" + } + ], + "body": { + "mode": "raw", + "raw": "{\r\n \"param\":{\r\n \"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 }\r\n}" + }, + "url": { + "raw": "{{api-url}}/v4/projects/1/settings", + "host": [ + "{{api-url}}" + ], + "path": [ + "v4", + "projects", + "1", + "settings" + ] + } + }, + "response": [] + }, + { + "name": "Create project setting with missing readPermission", + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + }, + { + "key": "Authorization", + "value": "Bearer {{jwt-token-admin-40051333}}" + } + ], + "body": { + "mode": "raw", + "raw": "{\r\n \"param\":{\r\n \"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 }\r\n}" + }, + "url": { + "raw": "{{api-url}}/v4/projects/1/settings", + "host": [ + "{{api-url}}" + ], + "path": [ + "v4", + "projects", + "1", + "settings" + ] + } + }, + "response": [] + }, + { + "name": "Create project setting with empty body", + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + }, + { + "key": "Authorization", + "value": "Bearer {{jwt-token-admin-40051333}}" + } + ], + "body": { + "mode": "raw", + "raw": "{\r\n \"param\":{\r\n }\r\n}" + }, + "url": { + "raw": "{{api-url}}/v4/projects/1/settings", + "host": [ + "{{api-url}}" + ], + "path": [ + "v4", + "projects", + "1", + "settings" + ] + } + }, + "response": [] + }, + { + "name": "Create project setting - not permitted", + "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-member-40051331}}" + } + ], + "body": { + "mode": "raw", + "raw": "{\r\n \"param\":{\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 }\r\n}" + }, + "url": { + "raw": "{{api-url}}/v4/projects/1/settings", + "host": [ + "{{api-url}}" + ], + "path": [ + "v4", + "projects", + "1", + "settings" + ] + } + }, + "response": [] + }, + { + "name": "List project setting", + "request": { + "method": "GET", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + }, + { + "key": "Authorization", + "value": "Bearer {{jwt-token}}" + } + ], + "url": { + "raw": "{{api-url}}/v4/projects/1/settings", + "host": [ + "{{api-url}}" + ], + "path": [ + "v4", + "projects", + "1", + "settings" + ] + } + }, + "response": [] + }, + { + "name": "List project setting - 403", + "request": { + "method": "GET", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + }, + { + "key": "Authorization", + "value": "Bearer {{jwt-token-copilot-40051332}}" + } + ], + "url": { + "raw": "{{api-url}}/v4/projects/1/settings", + "host": [ + "{{api-url}}" + ], + "path": [ + "v4", + "projects", + "1", + "settings" + ] + } + }, + "response": [] + }, + { + "name": "List project setting - manager", + "request": { + "method": "GET", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + }, + { + "key": "Authorization", + "value": "Bearer {{jwt-token-manager-40051334}}" + } + ], + "url": { + "raw": "{{api-url}}/v4/projects/1/settings", + "host": [ + "{{api-url}}" + ], + "path": [ + "v4", + "projects", + "1", + "settings" + ] + } + }, + "response": [] + }, + { + "name": "Update project setting - (failed) change key", + "request": { + "method": "PATCH", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + }, + { + "key": "Authorization", + "value": "Bearer {{jwt-token-admin-40051333}}" + } + ], + "body": { + "mode": "raw", + "raw": "{\r\n \"param\":{\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 }\r\n}" + }, + "url": { + "raw": "{{api-url}}/v4/projects/1/settings/{{settingId}}", + "host": [ + "{{api-url}}" + ], + "path": [ + "v4", + "projects", + "1", + "settings", + "{{settingId}}" + ] + } + }, + "response": [] + }, + { + "name": "Update project setting - change double to percentage", + "request": { + "method": "PATCH", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + }, + { + "key": "Authorization", + "value": "Bearer {{jwt-token-admin-40051333}}" + } + ], + "body": { + "mode": "raw", + "raw": "{\r\n \"param\":{\r\n \"value\": \"35.60\",\r\n \"valueType\": \"percentage\"\r\n }\r\n}" + }, + "url": { + "raw": "{{api-url}}/v4/projects/1/settings/{{settingId}}", + "host": [ + "{{api-url}}" + ], + "path": [ + "v4", + "projects", + "1", + "settings", + "{{settingId}}" + ] + } + }, + "response": [] + }, + { + "name": "Update project setting - non-existent project", + "request": { + "method": "PATCH", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + }, + { + "key": "Authorization", + "value": "Bearer {{jwt-token-admin-40051333}}" + } + ], + "body": { + "mode": "raw", + "raw": "{\r\n \"param\":{\r\n \"value\": \"30\",\r\n \"valueType\": \"percentage\"\r\n }\r\n}" + }, + "url": { + "raw": "{{api-url}}/v4/projects/9999/settings/{{settingId}}", + "host": [ + "{{api-url}}" + ], + "path": [ + "v4", + "projects", + "9999", + "settings", + "{{settingId}}" + ] + } + }, + "response": [] + }, + { + "name": "Update project setting - non-existent project setting", + "request": { + "method": "PATCH", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + }, + { + "key": "Authorization", + "value": "Bearer {{jwt-token-admin-40051333}}" + } + ], + "body": { + "mode": "raw", + "raw": "{\r\n \"param\":{\r\n \"value\": \"30\",\r\n \"valueType\": \"percentage\"\r\n }\r\n}" + }, + "url": { + "raw": "{{api-url}}/v4/projects/1/settings/9999", + "host": [ + "{{api-url}}" + ], + "path": [ + "v4", + "projects", + "1", + "settings", + "9999" + ] + } + }, + "response": [] + }, + { + "name": "Update project setting - change readPermission", + "request": { + "method": "PATCH", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + }, + { + "key": "Authorization", + "value": "Bearer {{jwt-token-admin-40051333}}" + } + ], + "body": { + "mode": "raw", + "raw": "{\r\n \"param\":{\r\n\t\"readPermission\": {\r\n\t \t\"projectRoles\": [\"manager\"],\r\n\t \"topcoderRoles\": [\"Connect Manager\"]\r\n\t }\r\n }\r\n}" + }, + "url": { + "raw": "{{api-url}}/v4/projects/1/settings/{{settingId}}", + "host": [ + "{{api-url}}" + ], + "path": [ + "v4", + "projects", + "1", + "settings", + "{{settingId}}" + ] + } + }, + "response": [] + }, + { + "name": "Delete project setting", + "request": { + "method": "DELETE", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + }, + { + "key": "Authorization", + "value": "Bearer {{jwt-token}}" + } + ], + "body": { + "mode": "raw", + "raw": "" + }, + "url": { + "raw": "{{api-url}}/v4/projects/1/settings/{{settingId}}", + "host": [ + "{{api-url}}" + ], + "path": [ + "v4", + "projects", + "1", + "settings", + "{{settingId}}" + ] + } + }, + "response": [] + } + ] } ] } diff --git a/src/constants.js b/src/constants.js index b6086334..129a83d3 100644 --- a/src/constants.js +++ b/src/constants.js @@ -224,3 +224,16 @@ export const ROUTES = { 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', +}; diff --git a/src/models/buildingBlock.js b/src/models/buildingBlock.js new file mode 100644 index 00000000..abf160b9 --- /dev/null +++ b/src/models/buildingBlock.js @@ -0,0 +1,58 @@ +/* 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 + * @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.includePrivateConfigForInternalUsage) { + // try to remove privateConfig from attributes + const idx = options.attributes.indexOf('privateConfig'); + if (idx >= 0) { + options.attributes.splice(idx, 1); + } + } + + return callback(null); + }, + }, + }); + + return BuildingBlock; +}; diff --git a/src/models/projectEstimationItem.js b/src/models/projectEstimationItem.js new file mode 100644 index 00000000..245333ec --- /dev/null +++ b/src/models/projectEstimationItem.js @@ -0,0 +1,166 @@ +/* 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(null); + } + + if (!options.reqUser || !options.members) { + return callback(new Error( + 'You must provide auth user and project members to get project estimation items')); + } + + // 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(null); + }, + }, + classMethods: { + /** + * 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 + */ + findAllByProject(models, projectId, options) { + return 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 this.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 + */ + deleteAllForProject(models, projectId, reqUser, options) { + return this.findAllByProject(models, projectId, options) + .then((estimationItems) => { + const estimationItemsOptions = { + where: { + id: _.map(estimationItems, 'id'), + }, + }; + + return this.update({ deletedBy: reqUser.userId }, estimationItemsOptions) + .then(() => this.destroy(estimationItemsOptions)); + }); + }, + }, + }, + ); + + return ProjectEstimationItem; +}; diff --git a/src/models/projectSetting.js b/src/models/projectSetting.js new file mode 100644 index 00000000..f0437cc3 --- /dev/null +++ b/src/models/projectSetting.js @@ -0,0 +1,111 @@ +/* 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(null); + } + + if (!options.reqUser || !options.members) { + return callback(new Error('You must provide reqUser and project member to get project settings')); + } + + return callback(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(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)) { + return callback(new Error('User doesn\'t have permission to access this record.')); + } + + return callback(null); + }, + }, + }, + ); + + return ProjectSetting; +}; diff --git a/src/permissions/index.js b/src/permissions/index.js index f27346da..aedba45e 100644 --- a/src/permissions/index.js +++ b/src/permissions/index.js @@ -8,9 +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); @@ -124,5 +125,15 @@ module.exports = () => { 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/projectSetting.edit.js b/src/permissions/projectSetting.edit.js new file mode 100644 index 00000000..e75a2855 --- /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.find({ + 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/routes/index.js b/src/routes/index.js index eb2fce43..b800bb24 100644 --- a/src/routes/index.js +++ b/src/routes/index.js @@ -327,6 +327,19 @@ router.route('/v4/projects/:projectId(\\d+)/workstreams/:workStreamId(\\d+)/work router.route('/v4/projects/:projectId/reports') .get(require('./projectReports/getReport')); +// Project Settings +router.route('/v4/projects/:projectId(\\d+)/settings/:id(\\d+)') + .patch(require('./projectSettings/update')) + .delete(require('./projectSettings/delete')); + +router.route('/v4/projects/:projectId(\\d+)/settings') + .get(require('./projectSettings/list')) + .post(require('./projectSettings/create')); + +// Project Estimation Items +router.route('/v4/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 cde3445c..6ce46915 100644 --- a/src/routes/metadata/list.js +++ b/src/routes/metadata/list.js @@ -137,6 +137,7 @@ module.exports = [ models.Form.latestVersion(), models.PriceConfig.latestVersion(), models.PlanConfig.latestVersion(), + models.BuildingBlock.findAll(query), ]) .then((results) => { res.json(util.wrapResponse(req.id, { @@ -148,6 +149,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 56e3471c..38326fcf 100644 --- a/src/routes/metadata/list.spec.js +++ b/src/routes/metadata/list.spec.js @@ -179,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', () => { beforeEach(() => testUtil.clearDb() .then(() => models.ProjectTemplate.bulkCreate(projectTemplates)) @@ -188,7 +213,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(() => models.PlanConfig.bulkCreate(planConfigs)) + .then(() => models.BuildingBlock.bulkCreate(buildingBlocks)), ); after(testUtil.clearDb); @@ -285,5 +311,29 @@ describe('GET all metadata', () => { }) .expect(200, done); }); + + it('should return correct building blocks for admin', (done) => { + request(server) + .get('/v4/projects/metadata') + .set({ + Authorization: `Bearer ${testUtil.jwts.copilot}`, + }) + .expect(200) + .end((err, res) => { + if (err) { + done(err); + } else { + const resJson = res.body.result.content; + 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/projectEstimationItems/list.js b/src/routes/projectEstimationItems/list.js new file mode 100644 index 00000000..3b960c92 --- /dev/null +++ b/src/routes/projectEstimationItems/list.js @@ -0,0 +1,50 @@ +/** + * 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 util from '../../util'; +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.find({ + 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(util.wrapResponse(req.id, 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 00000000..11f8a942 --- /dev/null +++ b/src/routes/projectEstimationItems/list.spec.js @@ -0,0 +1,227 @@ +/** + * 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(testUtil.clearDb); + + const url = '/v4/projects/1/estimations/1/items'; + + describe(`GET ${url}`, () => { + it('should return 403 if user is not authenticated', (done) => { + request(server) + .get(url) + .expect(403, 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, done); + }); + + it('should return 404 if project not exists', (done) => { + request(server) + .get('/v4/projects/999/estimations/1/items') + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .expect(404, done); + }); + + it('should return 404 if project estimation not exists', (done) => { + request(server) + .get('/v4/projects/1/estimations/999/items') + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .expect(404, 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.result.content; + 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.result.content; + 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.result.content; + should.exist(resJson); + resJson.length.should.be.eql(0); + // convert items to map with type. + done(); + } + }); + }); + }); +}); diff --git a/src/routes/projectSettings/create.js b/src/routes/projectSettings/create.js new file mode 100644 index 00000000..f97fc115 --- /dev/null +++ b/src/routes/projectSettings/create.js @@ -0,0 +1,94 @@ +/** + * 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: { + param: 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.param, { + 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.param.key, + }, + paranoid: false, + }); + }) + .then((projectSetting) => { + if (projectSetting) { + const apiErr = new Error(`Project Setting already exists for project id ${projectId} ` + + `and key ${req.body.param.key}`); + apiErr.status = 422; + 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(util.wrapResponse( + req.id, _.omit(setting.toJSON(), 'deletedAt', 'deletedBy'), 1, 201)); + }) + .catch(next); + }, +]; diff --git a/src/routes/projectSettings/create.spec.js b/src/routes/projectSettings/create.spec.js new file mode 100644 index 00000000..1e9a70de --- /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 = { + param: { + 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(testUtil.clearDb); + + describe('POST /projects/{projectId}/settings', () => { + it('should return 403 if user is not authenticated', (done) => { + request(server) + .post(`/v4/projects/${projectId}/settings`) + .send(body) + .expect(403, done); + }); + + it('should return 403 for member', (done) => { + request(server) + .post(`/v4/projects/${projectId}/settings`) + .set({ + Authorization: `Bearer ${testUtil.jwts.member}`, + }) + .send(body) + .expect(403, done); + }); + + it('should return 403 for copilot', (done) => { + request(server) + .post(`/v4/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('/v4/projects/9999/settings') + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .send(body) + .expect(404, done); + }); + + it('should return 422 for missing key', (done) => { + const invalidBody = _.cloneDeep(body); + delete invalidBody.param.key; + + request(server) + .post(`/v4/projects/${projectId}/settings`) + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .send(invalidBody) + .expect('Content-Type', /json/) + .expect(422, done); + }); + + it('should return 422 for missing value', (done) => { + const invalidBody = _.cloneDeep(body); + delete invalidBody.param.value; + + request(server) + .post(`/v4/projects/${projectId}/settings`) + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .send(invalidBody) + .expect('Content-Type', /json/) + .expect(422, done); + }); + + it('should return 422 for missing valueType', (done) => { + const invalidBody = _.cloneDeep(body); + delete invalidBody.param.valueType; + + request(server) + .post(`/v4/projects/${projectId}/settings`) + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .send(invalidBody) + .expect('Content-Type', /json/) + .expect(422, done); + }); + + xit('should return 422 for negative value when valueType = percentage', (done) => { + const invalidBody = _.cloneDeep(body); + invalidBody.param.value = '-10'; + invalidBody.param.valueType = VALUE_TYPE.PERCENTAGE; + + request(server) + .post(`/v4/projects/${projectId}/settings`) + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .send(invalidBody) + .expect('Content-Type', /json/) + .expect(422, done); + }); + + xit('should return 422 for value greater than 100 when valueType = percentage', (done) => { + const invalidBody = _.cloneDeep(body); + invalidBody.param.value = '150'; + invalidBody.param.valueType = VALUE_TYPE.PERCENTAGE; + + request(server) + .post(`/v4/projects/${projectId}/settings`) + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .send(invalidBody) + .expect('Content-Type', /json/) + .expect(422, done); + }); + + it('should return 422, for admin, when create key with existing key', (done) => { + const existing = _.cloneDeep(body); + existing.param.projectId = projectId; + existing.param.createdBy = 1; + existing.param.updatedBy = 1; + + models.ProjectSetting.create(existing.param).then(() => { + request(server) + .post(`/v4/projects/${projectId}/settings`) + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .send(body) + .expect(422, done); + }).catch(done); + }); + + it('should return 201 for manager with non-estimation type, not calculating project estimation items', + (done) => { + const createBody = _.cloneDeep(body); + createBody.param.key = 'markup_no_estimation'; + + request(server) + .post(`/v4/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.result.content; + resJson.key.should.be.eql(createBody.param.key); + resJson.value.should.be.eql(createBody.param.value); + resJson.valueType.should.be.eql(createBody.param.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(`/v4/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.result.content; + resJson.key.should.be.eql(body.param.key); + resJson.value.should.be.eql(body.param.value); + resJson.valueType.should.be.eql(body.param.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.param.value, + valueType: body.param.valueType, + key: body.param.key, + }), 1, 0, err, done); + }); + }); + + it('should return 201 for admin', (done) => { + request(server) + .post(`/v4/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.result.content; + resJson.key.should.be.eql(body.param.key); + resJson.value.should.be.eql(body.param.value); + resJson.valueType.should.be.eql(body.param.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(`/v4/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.result.content; + resJson.key.should.be.eql(body.param.key); + resJson.value.should.be.eql(body.param.value); + resJson.valueType.should.be.eql(body.param.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 00000000..aa85f493 --- /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 00000000..368f883d --- /dev/null +++ b/src/routes/projectSettings/delete.spec.js @@ -0,0 +1,279 @@ +/** + * 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(testUtil.clearDb); + + describe('DELETE /projects/{projectId}/settings/{id}', () => { + it('should return 403 if user is not authenticated', (done) => { + request(server) + .delete(`/v4/projects/${projectId}/settings/${id}`) + .expect(403, done); + }); + + it('should return 403 for member', (done) => { + request(server) + .delete(`/v4/projects/${projectId}/settings/${id}`) + .set({ + Authorization: `Bearer ${testUtil.jwts.member}`, + }) + .expect(403, done); + }); + + it('should return 403 for copilot', (done) => { + request(server) + .delete(`/v4/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(`/v4/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(`/v4/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(`/v4/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(`/v4/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(`/v4/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(`/v4/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 00000000..1668178f --- /dev/null +++ b/src/routes/projectSettings/list.js @@ -0,0 +1,51 @@ +/** + * 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'; +import util from '../../util'; + +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(util.wrapResponse(req.id, _.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 00000000..0ea1a14f --- /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(`/v4/projects/${projectId}/settings`) + .expect(403, done); + }); + + it('should return 403 when user have no permission (non team member)', (done) => { + request(server) + .get(`/v4/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(`/v4/projects/${projectId}/settings`) + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .expect(404, done); + }); + }); + + it('should return 404 for non-existed project', (done) => { + request(server) + .get('/v4/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(`/v4/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.result.content; + 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(`/v4/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.result.content; + should.exist(resJson); + resJson.should.have.lengthOf(0); + done(); + } + }); + }); + + it('should return 1 setting when user have readPermission (customer)', (done) => { + request(server) + .get(`/v4/projects/${projectId}/settings`) + .set({ + Authorization: `Bearer ${testUtil.jwts.member}`, + }) + .expect(200) + .end((err, res) => { + if (err) { + done(err); + } else { + const resJson = res.body.result.content; + 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(`/v4/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.result.content; + 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 00000000..df9504c4 --- /dev/null +++ b/src/routes/projectSettings/update.js @@ -0,0 +1,76 @@ +/** + * 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: { + param: 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.param, { + 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(util.wrapResponse(req.id, updatedSetting)); + }) + .catch(next); + }, +]; diff --git a/src/routes/projectSettings/update.spec.js b/src/routes/projectSettings/update.spec.js new file mode 100644 index 00000000..0c477e0d --- /dev/null +++ b/src/routes/projectSettings/update.spec.js @@ -0,0 +1,416 @@ +/** + * 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 = { + param: { + 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 bodyParamNonMutable = { + 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.param, bodyParamNonMutable, { + projectId, + })) + .then((s) => { + id = s.id; + + models.ProjectEstimation.create(_.assign(estimation, { projectId })) + .then((e) => { + estimationId = e.id; + done(); + }); + }).catch(done); + }); + }); + }); + }); + + after(testUtil.clearDb); + + describe('PATCH /projects/{projectId}/settings/{id}', () => { + it('should return 403 if user is not authenticated', (done) => { + request(server) + .patch(`/v4/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(`/v4/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(`/v4/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(`/v4/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(`/v4/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(`/v4/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(`/v4/projects/${projectId}/settings/${id}`) + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .send(body) + .expect(404, done); + }); + }); + + it('should return 422, when try to update key', (done) => { + request(server) + .patch(`/v4/projects/${projectId}/settings/${id}`) + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .send({ + param: { + key: 'updated_key', + }, + }) + .expect(422, done); + }); + + it('should return 200, for member with permission (team member), value updated but no project estimation present', + (done) => { + const notPresent = _.cloneDeep(body); + notPresent.param.value = '4500'; + + models.ProjectEstimation.destroy({ + where: { + id: estimationId, + }, + }).then(() => { + models.ProjectEstimationItem.destroy({ + where: { + markupUsedReference: 'projectSetting', + markupUsedReferenceId: id, + }, + }).then(() => { + request(server) + .patch(`/v4/projects/${projectId}/settings/${id}`) + .set({ + Authorization: `Bearer ${testUtil.jwts.member}`, + }) + .send({ + param: { + value: notPresent.param.value, + }, + }) + .expect('Content-Type', /json/) + .expect(200) + .end((err, res) => { + if (err) done(err); + + const resJson = res.body.result.content; + resJson.id.should.be.eql(id); + resJson.key.should.be.eql(bodyParamNonMutable.key); + resJson.value.should.be.eql(notPresent.param.value); + resJson.valueType.should.be.eql(notPresent.param.valueType); + resJson.projectId.should.be.eql(projectId); + resJson.createdBy.should.be.eql(bodyParamNonMutable.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.param.value, + valueType: notPresent.param.valueType, + key: bodyParamNonMutable.key, + }), 0, 0, err, done); + }); + }); + }).catch(done); + }); + + it('should return 200 for admin when value updated, calculating project estimation items', (done) => { + body.param.value = '4500'; + + models.ProjectEstimationItem.create({ + projectEstimationId: estimationId, + price: 1200, + type: 'topcoder_service', + markupUsedReference: 'projectSetting', + markupUsedReferenceId: id, + createdBy: 1, + updatedBy: 1, + }).then(() => { + request(server) + .patch(`/v4/projects/${projectId}/settings/${id}`) + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .send({ + param: { + value: body.param.value, + }, + }) + .expect('Content-Type', /json/) + .expect(200) + .end((err, res) => { + if (err) done(err); + + const resJson = res.body.result.content; + resJson.id.should.be.eql(id); + resJson.key.should.be.eql(bodyParamNonMutable.key); + resJson.value.should.be.eql(body.param.value); + resJson.valueType.should.be.eql(body.param.valueType); + resJson.projectId.should.be.eql(projectId); + resJson.createdBy.should.be.eql(bodyParamNonMutable.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.param.value, + valueType: body.param.valueType, + key: bodyParamNonMutable.key, + }), 1, 1, err, done); + }); + }).catch(done); + }); + + it('should return 200, for admin, update valueType from double to percentage', (done) => { + body.param.value = '10.76'; + body.param.valueType = VALUE_TYPE.PERCENTAGE; + request(server) + .patch(`/v4/projects/${projectId}/settings/${id}`) + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .send({ + param: { + value: body.param.value, + valueType: VALUE_TYPE.PERCENTAGE, + }, + }) + .expect('Content-Type', /json/) + .expect(200) + .end((err, res) => { + if (err) done(err); + + const resJson = res.body.result.content; + resJson.id.should.be.eql(id); + resJson.key.should.be.eql(bodyParamNonMutable.key); + resJson.value.should.be.eql(body.param.value); + resJson.valueType.should.be.eql(VALUE_TYPE.PERCENTAGE); + resJson.projectId.should.be.eql(projectId); + resJson.createdBy.should.be.eql(bodyParamNonMutable.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.param.value, + valueType: body.param.valueType, + key: bodyParamNonMutable.key, + }), 1, 0, err, done); + }); + }); + }); +}); diff --git a/src/routes/projects/create.js b/src/routes/projects/create.js index 8e942b63..702666df 100644 --- a/src/routes/projects/create.js +++ b/src/routes/projects/create.js @@ -78,6 +78,55 @@ const createProjectValdiations = { }, }; +/** + * 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 @@ -155,6 +204,17 @@ 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'); @@ -170,7 +230,8 @@ function createProjectAndPhases(req, project, projectTemplate, productTemplates, }); } return Promise.resolve(newProject); - }).then((newProject) => { + }) + .then((newProject) => { result.newProject = newProject; // backward compatibility for releasing the service before releasing the front end diff --git a/src/routes/projects/create.spec.js b/src/routes/projects/create.spec.js index cb235aa9..670041c9 100644 --- a/src/routes/projects/create.spec.js +++ b/src/routes/projects/create.spec.js @@ -208,6 +208,46 @@ describe('Project create', () => { 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()); }); @@ -834,5 +874,120 @@ describe('Project create', () => { } }); }); + + it('should create correct estimation items with estimation', (done) => { + const validBody = _.cloneDeep(body); + validBody.param.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.param.templateId = 3; + 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('/v4/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.result.content; + 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/util.js b/src/util.js index b14117f8..8e2507f5 100644 --- a/src/util.js +++ b/src/util.js @@ -18,7 +18,7 @@ import elasticsearch from 'elasticsearch'; import Promise from 'bluebird'; // 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 } from './constants'; const exec = require('child_process').exec; const models = require('./models').default; @@ -80,6 +80,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 @@ -746,6 +816,20 @@ _.assignIn(util, { 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 00000000..99722697 --- /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); + }); + }); +}); diff --git a/swagger.yaml b/swagger.yaml index 4879a085..77b16a60 100644 --- a/swagger.yaml +++ b/swagger.yaml @@ -1038,6 +1038,142 @@ paths: description: If project, workstream or phase is not found 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: + $ref: '#/definitions/ProjectSettingListResponse' + '403': + description: No permission or wrong token + 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/ProjectSettingBodyParam' + responses: + '201': + description: Returns the newly created project phase + schema: + $ref: '#/definitions/ProjectPhaseResponse' + '403': + description: No permission or wrong token + schema: + $ref: '#/definitions/ErrorModel' + '422': + description: Invalid input + 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/ProjectSettingResponse' + '403': + description: No permission or wrong token + schema: + $ref: '#/definitions/ErrorModel' + '404': + description: Not found + schema: + $ref: '#/definitions/ErrorModel' + '422': + description: Invalid input + schema: + $ref: '#/definitions/ErrorModel' + default: + description: error payload + schema: + $ref: '#/definitions/ErrorModel' + parameters: + - name: body + in: body + required: true + schema: + $ref: '#/definitions/ProjectSettingBodyParam' + 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 + '403': + description: No permission or wrong token + schema: + $ref: '#/definitions/ErrorModel' + '404': + description: If project is not found + 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: + $ref: '#/definitions/ProjectEstimationItemListResponse' + '403': + description: No permission or wrong token + 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/db': parameters: - $ref: '#/parameters/projectIdParam' @@ -3587,6 +3723,14 @@ parameters: 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 @@ -3703,6 +3847,13 @@ parameters: required: true type: integer format: int64 + projectEstimationIdParam: + name: estimationId + in: path + description: project estimation identifier + required: true + type: integer + format: int64 definitions: ResponseMetadata: title: Metadata object for a response @@ -4697,6 +4848,43 @@ definitions: type: number format: integer description: the project phase order + 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 + ProjectSettingBodyParam: + title: Project setting body param + type: object + required: + - param + properties: + param: + $ref: '#/definitions/ProjectSettingRequest' ProjectPhaseBodyParam: title: Project phase body param type: object @@ -4803,6 +4991,85 @@ definitions: properties: param: $ref: '#/definitions/WorkStreamRequest' + 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' + ProjectSettingResponse: + title: Single project setting response object + type: object + properties: + id: + type: string + description: unique id identifying the request + version: + type: string + result: + type: object + properties: + success: + type: boolean + status: + type: string + description: http status code + metadata: + $ref: '#/definitions/ResponseMetadata' + content: + $ref: '#/definitions/ProjectSetting' + ProjectSettingListResponse: + title: Project setting list response object + type: object + properties: + id: + type: string + readOnly: true + description: unique id identifying the request + version: + type: string + result: + type: object + properties: + success: + type: boolean + status: + type: string + description: http status code + metadata: + $ref: '#/definitions/ResponseMetadata' + content: + type: array + items: + $ref: '#/definitions/ProjectSetting' ProjectPhaseResponse: title: Single project phase response object type: object @@ -6041,6 +6308,10 @@ definitions: type: array items: $ref: '#/definitions/ProductCategory' + buildingBlocks: + type: array + items: + $ref: '#/definitions/BuildingBlock' ProjectMemberInvite: type: object properties: @@ -6482,3 +6753,115 @@ definitions: 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 + ProjectEstimationItemListResponse: + title: ProjectEstimationItem list response + type: object + properties: + id: + type: string + description: unique id identifying the request + version: + type: string + result: + type: object + properties: + success: + type: boolean + status: + type: string + description: http status code + content: + type: array + items: + $ref: '#/definitions/ProjectEstimationItem' \ No newline at end of file