diff --git a/config/custom-environment-variables.json b/config/custom-environment-variables.json index c807e407..4260d83e 100644 --- a/config/custom-environment-variables.json +++ b/config/custom-environment-variables.json @@ -22,8 +22,6 @@ "fileServiceEndpoint": "FILE_SERVICE_ENDPOINT", "identityServiceEndpoint": "IDENTITY_SERVICE_ENDPOINT", "memberServiceEndpoint": "MEMBER_SERVICE_ENDPOINT", - "systemUserClientId": "SYSTEM_USER_CLIENT_ID", - "systemUserClientSecret": "SYSTEM_USER_CLIENT_SECRET", "connectProjectsUrl": "CONNECT_PROJECTS_URL", "dbConfig": { "masterUrl": "DB_MASTER_URL", diff --git a/config/default.json b/config/default.json index 95f5666d..426be0be 100644 --- a/config/default.json +++ b/config/default.json @@ -25,8 +25,6 @@ "timelineIndexName": "timelines", "timelineDocType": "timelineV4" }, - "systemUserClientId": "", - "systemUserClientSecret": "", "connectProjectUrl":"", "dbConfig": { "masterUrl": "", @@ -59,5 +57,6 @@ "inviteEmailSectionTitle": "Project Invitation", "connectUrl":"https://connect.topcoder-dev.com", "accountsAppUrl": "https://accounts.topcoder-dev.com", - "MAX_REVISION_NUMBER": 100 + "MAX_REVISION_NUMBER": 100, + "UNIQUE_GMAIL_VALIDATION": false } diff --git a/migrations/20190410_refactor_product_templates.sql b/migrations/20190410_refactor_product_templates.sql new file mode 100644 index 00000000..69b17b85 --- /dev/null +++ b/migrations/20190410_refactor_product_templates.sql @@ -0,0 +1,6 @@ +-- +-- product_templates +-- +ALTER TABLE product_templates ALTER COLUMN "template" DROP NOT NULL; + +ALTER TABLE product_templates ADD COLUMN "form" json; diff --git a/migrations/20190526_project_estimation.sql b/migrations/20190526_project_estimation.sql new file mode 100644 index 00000000..11b3a725 --- /dev/null +++ b/migrations/20190526_project_estimation.sql @@ -0,0 +1,35 @@ +-- +-- CREATE NEW TABLE: +-- project_estimations +-- + +CREATE TABLE project_estimations +( + id bigint NOT NULL, + "buildingBlockKey" character varying(255) NOT NULL, + conditions character varying(512) NOT NULL, + price double precision NOT NULL, + "minTime" integer NOT NULL, + "maxTime" integer NOT NULL, + metadata json NOT NULL DEFAULT '{}'::json, + "projectId" bigint NOT NULL, + "deletedAt" timestamp with time zone, + "createdAt" timestamp with time zone, + "updatedAt" timestamp with time zone, + "deletedBy" bigint, + "createdBy" integer NOT NULL, + "updatedBy" integer NOT NULL, + CONSTRAINT project_estimations_pkey PRIMARY KEY (id) +); + +CREATE SEQUENCE project_estimations_id_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + +ALTER SEQUENCE project_estimations_id_seq OWNED BY project_estimations.id; + +ALTER TABLE project_estimations + ALTER COLUMN id SET DEFAULT nextval('project_estimations_id_seq'); diff --git a/postman.json b/postman.json index a157e443..3e17f076 100644 --- a/postman.json +++ b/postman.json @@ -1,6 +1,6 @@ { "info": { - "_postman_id": "db83f8a1-5b3f-4276-a371-aa3c3497542d", + "_postman_id": "57206894-511c-4ffb-94bb-e50d2dd416fb", "name": "tc-project-service", "schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json" }, @@ -1012,6 +1012,207 @@ }, "response": [] }, + { + "name": "List projects DB", + "request": { + "method": "GET", + "header": [ + { + "key": "Authorization", + "value": "Bearer {{jwt-token}}" + } + ], + "body": { + "mode": "raw", + "raw": "" + }, + "url": { + "raw": "{{api-url}}/v4/projects/db", + "host": [ + "{{api-url}}" + ], + "path": [ + "v4", + "projects", + "db" + ] + }, + "description": "List all the project with no filter. Default sort and limits are applied." + }, + "response": [] + }, + { + "name": "List projects DB with limit and offset", + "request": { + "method": "GET", + "header": [ + { + "key": "Authorization", + "value": "Bearer {{jwt-token}}" + } + ], + "body": { + "mode": "raw", + "raw": "" + }, + "url": { + "raw": "{{api-url}}/v4/projects/db?limit=1&offset=1", + "host": [ + "{{api-url}}" + ], + "path": [ + "v4", + "projects", + "db" + ], + "query": [ + { + "key": "limit", + "value": "1" + }, + { + "key": "offset", + "value": "1" + } + ] + }, + "description": "List all the project with no filter. Limit of 1 and offset of 1 is applied" + }, + "response": [] + }, + { + "name": "List projects DB with filters applied", + "request": { + "method": "GET", + "header": [ + { + "key": "Authorization", + "value": "Bearer {{jwt-token}}" + } + ], + "body": { + "mode": "raw", + "raw": "" + }, + "url": { + "raw": "{{api-url}}/v4/projects/db?filter=type%3Dgeneric", + "host": [ + "{{api-url}}" + ], + "path": [ + "v4", + "projects", + "db" + ], + "query": [ + { + "key": "filter", + "value": "type%3Dgeneric" + } + ] + }, + "description": "List all the project with filters applied. The filter string should be url encoded. Default limit and offset is applicable" + }, + "response": [] + }, + { + "name": "List projects DB with sort applied", + "request": { + "method": "GET", + "header": [ + { + "key": "Authorization", + "value": "Bearer {{jwt-token}}" + } + ], + "body": { + "mode": "raw", + "raw": "" + }, + "url": { + "raw": "{{api-url}}/v4/projects/db?sort=type%20desc", + "host": [ + "{{api-url}}" + ], + "path": [ + "v4", + "projects", + "db" + ], + "query": [ + { + "key": "sort", + "value": "type%20desc" + } + ] + }, + "description": "List all the project with custom sort and no filter. Default sort and limits are applied. The sort string has to be url encoded. Sort is of type `key asc|desc`" + }, + "response": [] + }, + { + "name": "List projects DB and return specific fields", + "request": { + "method": "GET", + "header": [ + { + "key": "Authorization", + "value": "Bearer {{jwt-token}}" + } + ], + "body": { + "mode": "raw", + "raw": "" + }, + "url": { + "raw": "{{api-url}}/v4/projects/db?fields=id,name,description", + "host": [ + "{{api-url}}" + ], + "path": [ + "v4", + "projects", + "db" + ], + "query": [ + { + "key": "fields", + "value": "id,name,description" + } + ] + }, + "description": "List all the project with no filter. Default sort and limits are applied. The fields to return is specified as comma separated list. Only those fields should be returned." + }, + "response": [] + }, + { + "name": "List projects DB with copilot token", + "request": { + "method": "GET", + "header": [ + { + "key": "Authorization", + "value": "Bearer {{jwt-token-copilot-40051332}}" + } + ], + "body": { + "mode": "raw", + "raw": "" + }, + "url": { + "raw": "{{api-url}}/v4/projects/db", + "host": [ + "{{api-url}}" + ], + "path": [ + "v4", + "projects", + "db" + ] + } + }, + "response": [] + }, { "name": "List projects", "request": { @@ -1181,7 +1382,7 @@ "response": [] }, { - "name": "get projects with copilot token", + "name": "List projects with copilot token", "request": { "method": "GET", "header": [ @@ -2126,7 +2327,7 @@ "response": [] }, { - "name": "List Phase", + "name": "List Phase DB", "request": { "method": "GET", "header": [ @@ -2144,7 +2345,7 @@ "raw": "" }, "url": { - "raw": "{{api-url}}/v4/projects/1/phases", + "raw": "{{api-url}}/v4/projects/1/phases/db", "host": [ "{{api-url}}" ], @@ -2152,14 +2353,15 @@ "v4", "projects", "1", - "phases" + "phases", + "db" ] } }, "response": [] }, { - "name": "List Phase with fields", + "name": "List Phase DB with fields", "request": { "method": "GET", "header": [ @@ -2177,7 +2379,7 @@ "raw": "" }, "url": { - "raw": "{{api-url}}/v4/projects/1/phases?fields=status,name,budget", + "raw": "{{api-url}}/v4/projects/1/phases/db?fields=status,name,budget", "host": [ "{{api-url}}" ], @@ -2185,7 +2387,8 @@ "v4", "projects", "1", - "phases" + "phases", + "db" ], "query": [ { @@ -2198,7 +2401,7 @@ "response": [] }, { - "name": "List Phase with sort", + "name": "List Phase DB with sort", "request": { "method": "GET", "header": [ @@ -2216,7 +2419,7 @@ "raw": "" }, "url": { - "raw": "{{api-url}}/v4/projects/1/phases?sort=status desc", + "raw": "{{api-url}}/v4/projects/1/phases/db?sort=status desc", "host": [ "{{api-url}}" ], @@ -2224,7 +2427,8 @@ "v4", "projects", "1", - "phases" + "phases", + "db" ], "query": [ { @@ -2237,7 +2441,7 @@ "response": [] }, { - "name": "List Phase with sort by order", + "name": "List Phase DB with sort by order", "request": { "method": "GET", "header": [ @@ -2255,7 +2459,7 @@ "raw": "" }, "url": { - "raw": "{{api-url}}/v4/projects/1/phases?sort=order desc", + "raw": "{{api-url}}/v4/projects/1/phases/db?sort=order desc", "host": [ "{{api-url}}" ], @@ -2263,7 +2467,8 @@ "v4", "projects", "1", - "phases" + "phases", + "db" ], "query": [ { @@ -2276,7 +2481,7 @@ "response": [] }, { - "name": "Get Phase", + "name": "List Phase", "request": { "method": "GET", "header": [ @@ -2294,7 +2499,7 @@ "raw": "" }, "url": { - "raw": "{{api-url}}/v4/projects/1/phases/1", + "raw": "{{api-url}}/v4/projects/1/phases", "host": [ "{{api-url}}" ], @@ -2302,17 +2507,16 @@ "v4", "projects", "1", - "phases", - "1" + "phases" ] } }, "response": [] }, { - "name": "Update Phase", + "name": "List Phase with fields", "request": { - "method": "PATCH", + "method": "GET", "header": [ { "key": "Authorization", @@ -2325,10 +2529,10 @@ ], "body": { "mode": "raw", - "raw": "{\n\t\"param\": {\n\t\t\"name\": \"test project phase xxx\",\n\t\t\"status\": \"inactive\",\n\t\t\"startDate\": \"2018-05-14T00:00:00\",\n\t\t\"endDate\": \"2018-05-15T00:00:00\",\n\t\t\"budget\": 30,\n\t\t\"progress\": 15,\n\t\t\"details\": {\n\t\t\t\"message\": \"phase details\"\n\t\t}\n\t}\n}" + "raw": "" }, "url": { - "raw": "{{api-url}}/v4/projects/1/phases/1", + "raw": "{{api-url}}/v4/projects/1/phases?fields=status,name,budget", "host": [ "{{api-url}}" ], @@ -2336,17 +2540,22 @@ "v4", "projects", "1", - "phases", - "1" + "phases" + ], + "query": [ + { + "key": "fields", + "value": "status,name,budget" + } ] } }, "response": [] }, { - "name": "Update Phase with order", + "name": "List Phase with sort", "request": { - "method": "PATCH", + "method": "GET", "header": [ { "key": "Authorization", @@ -2359,10 +2568,10 @@ ], "body": { "mode": "raw", - "raw": "{\n\t\"param\": {\n\t\t\"name\": \"test project phase xxx\",\n\t\t\"status\": \"inactive\",\n\t\t\"startDate\": \"2018-05-14T00:00:00\",\n\t\t\"endDate\": \"2018-05-15T00:00:00\",\n\t\t\"budget\": 30,\n\t\t\"progress\": 15,\n\t\t\"details\": {\n\t\t\t\"message\": \"phase details\"\n\t\t},\n\t\t\"order\": 1\n\t}\n}" + "raw": "" }, "url": { - "raw": "{{api-url}}/v4/projects/1/phases/1", + "raw": "{{api-url}}/v4/projects/1/phases?sort=status desc", "host": [ "{{api-url}}" ], @@ -2370,17 +2579,22 @@ "v4", "projects", "1", - "phases", - "1" + "phases" + ], + "query": [ + { + "key": "sort", + "value": "status desc" + } ] } }, "response": [] }, { - "name": "Delete Phase", + "name": "List Phase with sort by order", "request": { - "method": "DELETE", + "method": "GET", "header": [ { "key": "Authorization", @@ -2396,7 +2610,7 @@ "raw": "" }, "url": { - "raw": "{{api-url}}/v4/projects/1/phases/3", + "raw": "{{api-url}}/v4/projects/1/phases?sort=order desc", "host": [ "{{api-url}}" ], @@ -2404,13 +2618,154 @@ "v4", "projects", "1", - "phases", - "3" + "phases" + ], + "query": [ + { + "key": "sort", + "value": "order desc" + } ] } }, "response": [] - } + }, + { + "name": "Get Phase", + "request": { + "method": "GET", + "header": [ + { + "key": "Authorization", + "value": "Bearer {{jwt-token}}" + }, + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "" + }, + "url": { + "raw": "{{api-url}}/v4/projects/1/phases/1", + "host": [ + "{{api-url}}" + ], + "path": [ + "v4", + "projects", + "1", + "phases", + "1" + ] + } + }, + "response": [] + }, + { + "name": "Update Phase", + "request": { + "method": "PATCH", + "header": [ + { + "key": "Authorization", + "value": "Bearer {{jwt-token}}" + }, + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n\t\"param\": {\n\t\t\"name\": \"test project phase xxx\",\n\t\t\"status\": \"inactive\",\n\t\t\"startDate\": \"2018-05-14T00:00:00\",\n\t\t\"endDate\": \"2018-05-15T00:00:00\",\n\t\t\"budget\": 30,\n\t\t\"progress\": 15,\n\t\t\"details\": {\n\t\t\t\"message\": \"phase details\"\n\t\t}\n\t}\n}" + }, + "url": { + "raw": "{{api-url}}/v4/projects/1/phases/1", + "host": [ + "{{api-url}}" + ], + "path": [ + "v4", + "projects", + "1", + "phases", + "1" + ] + } + }, + "response": [] + }, + { + "name": "Update Phase with order", + "request": { + "method": "PATCH", + "header": [ + { + "key": "Authorization", + "value": "Bearer {{jwt-token}}" + }, + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n\t\"param\": {\n\t\t\"name\": \"test project phase xxx\",\n\t\t\"status\": \"inactive\",\n\t\t\"startDate\": \"2018-05-14T00:00:00\",\n\t\t\"endDate\": \"2018-05-15T00:00:00\",\n\t\t\"budget\": 30,\n\t\t\"progress\": 15,\n\t\t\"details\": {\n\t\t\t\"message\": \"phase details\"\n\t\t},\n\t\t\"order\": 1\n\t}\n}" + }, + "url": { + "raw": "{{api-url}}/v4/projects/1/phases/1", + "host": [ + "{{api-url}}" + ], + "path": [ + "v4", + "projects", + "1", + "phases", + "1" + ] + } + }, + "response": [] + }, + { + "name": "Delete Phase", + "request": { + "method": "DELETE", + "header": [ + { + "key": "Authorization", + "value": "Bearer {{jwt-token}}" + }, + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "" + }, + "url": { + "raw": "{{api-url}}/v4/projects/1/phases/3", + "host": [ + "{{api-url}}" + ], + "path": [ + "v4", + "projects", + "1", + "phases", + "3" + ] + } + }, + "response": [] + } ] }, { @@ -2451,6 +2806,38 @@ }, "response": [] }, + { + "name": "List Phase DB Products", + "request": { + "method": "GET", + "header": [ + { + "key": "Authorization", + "value": "Bearer {{jwt-token}}" + } + ], + "body": { + "mode": "raw", + "raw": "" + }, + "url": { + "raw": "{{api-url}}/v4/projects/1/phases/1/products/db", + "host": [ + "{{api-url}}" + ], + "path": [ + "v4", + "projects", + "1", + "phases", + "1", + "products", + "db" + ] + } + }, + "response": [] + }, { "name": "List Phase Products", "request": { @@ -3081,7 +3468,106 @@ ], "body": { "mode": "raw", - "raw": "{\r\n \"param\": {\r\n \"name\": \"name 1\",\r\n \"productKey\": \"productKey 1\",\r\n \"category\": \"key1\",\r\n \"icon\": \"http://example.com/icon1.ico\",\r\n \"brief\": \"brief 1\",\r\n \"details\": \"details 1\",\r\n \"aliases\": [\"product key 1\", \"product_key_1\"],\r\n \"template\": {\r\n \"template1\": {\r\n \"name\": \"template 1\",\r\n \"details\": {\r\n \"anyDetails\": \"any details 1\"\r\n },\r\n \"others\": [\"others 11\", \"others 12\"]\r\n },\r\n \"template2\": {\r\n \"name\": \"template 2\",\r\n \"details\": {\r\n \"anyDetails\": \"any details 2\"\r\n },\r\n \"others\": [\"others 21\", \"others 22\"]\r\n }\r\n }\r\n }\r\n }" + "raw": "{\r\n \"param\": {\r\n \"name\": \"name 1\",\r\n \"productKey\": \"productKey 1\",\r\n \"category\": \"key1\",\r\n \"icon\": \"http://example.com/icon1.ico\",\r\n \"brief\": \"brief 1\",\r\n \"details\": \"details 1\",\r\n \"aliases\": [\"product key 1\", \"product_key_1\"],\r\n \"template\": {\r\n \"template1\": {\r\n \"name\": \"template 1\",\r\n \"details\": {\r\n \"anyDetails\": \"any details 1\"\r\n },\r\n \"others\": [\"others 11\", \"others 12\"]\r\n },\r\n \"template2\": {\r\n \"name\": \"template 2\",\r\n \"details\": {\r\n \"anyDetails\": \"any details 2\"\r\n },\r\n \"others\": [\"others 21\", \"others 22\"]\r\n }\r\n }\r\n }\r\n}" + }, + "url": { + "raw": "{{api-url}}/v4/projects/metadata/productTemplates", + "host": [ + "{{api-url}}" + ], + "path": [ + "v4", + "projects", + "metadata", + "productTemplates" + ] + } + }, + "response": [] + }, + { + "name": "Create product template with form", + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + }, + { + "key": "Authorization", + "value": "Bearer {{jwt-token}}" + } + ], + "body": { + "mode": "raw", + "raw": "{\r\n \"param\": {\r\n \"name\": \"name 1\",\r\n \"productKey\": \"productKey 1\",\r\n \"category\": \"key1\",\r\n \"icon\": \"http://example.com/icon1.ico\",\r\n \"brief\": \"brief 1\",\r\n \"details\": \"details 1\",\r\n \"aliases\": [\"product key 1\", \"product_key_1\"],\r\n \"form\": {\r\n\t\t\"key\": \"dev\",\r\n\t\t\"version\": 1\r\n\t}\r\n }\r\n}" + }, + "url": { + "raw": "{{api-url}}/v4/projects/metadata/productTemplates", + "host": [ + "{{api-url}}" + ], + "path": [ + "v4", + "projects", + "metadata", + "productTemplates" + ] + } + }, + "response": [] + }, + { + "name": "Create product template with wrong form key", + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + }, + { + "key": "Authorization", + "value": "Bearer {{jwt-token}}" + } + ], + "body": { + "mode": "raw", + "raw": "{\r\n \"param\": {\r\n \"name\": \"name 1\",\r\n \"productKey\": \"productKey 1\",\r\n \"category\": \"key1\",\r\n \"icon\": \"http://example.com/icon1.ico\",\r\n \"brief\": \"brief 1\",\r\n \"details\": \"details 1\",\r\n \"aliases\": [\"product key 1\", \"product_key_1\"],\r\n \"form\": {\r\n\t\t\"key\": \"wrong-key\"\r\n\t}\r\n }\r\n}" + }, + "url": { + "raw": "{{api-url}}/v4/projects/metadata/productTemplates", + "host": [ + "{{api-url}}" + ], + "path": [ + "v4", + "projects", + "metadata", + "productTemplates" + ] + } + }, + "response": [] + }, + { + "name": "Create product template with wrong model version", + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + }, + { + "key": "Authorization", + "value": "Bearer {{jwt-token}}" + } + ], + "body": { + "mode": "raw", + "raw": "{\r\n \"param\": {\r\n \"name\": \"name 1\",\r\n \"productKey\": \"productKey 1\",\r\n \"category\": \"key1\",\r\n \"icon\": \"http://example.com/icon1.ico\",\r\n \"brief\": \"brief 1\",\r\n \"details\": \"details 1\",\r\n \"aliases\": [\"product key 1\", \"product_key_1\"],\r\n \"form\": {\r\n\t\t\"key\": \"dev\",\r\n\t\t\"version\": 1123\r\n\t}\r\n }\r\n}" }, "url": { "raw": "{{api-url}}/v4/projects/metadata/productTemplates", @@ -3232,6 +3718,117 @@ } }, "response": [] + }, + { + "name": "Upgrade a product template with form", + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json", + "type": "text" + }, + { + "key": "Authorization", + "value": "Bearer {{jwt-token}}", + "type": "text" + } + ], + "body": { + "mode": "raw", + "raw": "{\r\n \"param\":{\r\n \"form\": {\r\n \t\"key\": \"dev\",\t\r\n \t\"version\": 2\r\n }\r\n }\r\n}" + }, + "url": { + "raw": "{{api-url}}/v4/projects/metadata/productTemplates/2/upgrade", + "host": [ + "{{api-url}}" + ], + "path": [ + "v4", + "projects", + "metadata", + "productTemplates", + "2", + "upgrade" + ] + } + }, + "response": [] + }, + { + "name": "Upgrade a product template with wrong model version", + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json", + "type": "text" + }, + { + "key": "Authorization", + "value": "Bearer {{jwt-token}}", + "type": "text" + } + ], + "body": { + "mode": "raw", + "raw": "{\r\n \"param\":{\r\n \"form\": {\r\n \t\"key\": \"dev\",\t\r\n \t\"version\": 1234\r\n }\r\n }\r\n}" + }, + "url": { + "raw": "{{api-url}}/v4/projects/metadata/productTemplates/1/upgrade", + "host": [ + "{{api-url}}" + ], + "path": [ + "v4", + "projects", + "metadata", + "productTemplates", + "1", + "upgrade" + ] + } + }, + "response": [] + }, + { + "name": "Upgrade a product template without define form", + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json", + "type": "text" + }, + { + "key": "Authorization", + "value": "Bearer {{jwt-token}}", + "type": "text" + } + ], + "body": { + "mode": "raw", + "raw": "{\r\n \"param\":{ \r\n }\r\n}" + }, + "url": { + "raw": "{{api-url}}/v4/projects/metadata/productTemplates/3/upgrade", + "host": [ + "{{api-url}}" + ], + "path": [ + "v4", + "projects", + "metadata", + "productTemplates", + "3", + "upgrade" + ] + } + }, + "response": [] } ] }, diff --git a/src/events/projectMembers/index.js b/src/events/projectMembers/index.js index bab7e486..92380e7d 100644 --- a/src/events/projectMembers/index.js +++ b/src/events/projectMembers/index.js @@ -48,7 +48,7 @@ const projectMemberAddedHandler = Promise.coroutine(function* a(logger, msg, cha // add copilot/update manager permissions operation promise const directProjectId = yield models.Project.getDirectProjectId(projectId); if (directProjectId) { - const token = yield util.getSystemUserToken(logger); + const token = yield util.getM2MToken(); const req = { id: origRequestId, log: logger, @@ -119,7 +119,7 @@ const projectMemberRemovedHandler = Promise.coroutine(function* (logger, msg, ch if (_.indexOf([PROJECT_MEMBER_ROLE.COPILOT, PROJECT_MEMBER_ROLE.MANAGER], member.role) > -1) { const directProjectId = yield models.Project.getDirectProjectId(projectId); if (directProjectId) { - const token = yield util.getSystemUserToken(logger); + const token = yield util.getM2MToken(); const req = { id: origRequestId, log: logger, @@ -152,7 +152,7 @@ const projectMemberRemovedHandler = Promise.coroutine(function* (logger, msg, ch const updateDocPromise = (doc) => { const members = _.filter(doc._source.members, single => single.id !== member.id); // eslint-disable-line no-underscore-dangle - return Promise.resolve(_.merge(doc._source, { members })); // eslint-disable-line no-underscore-dangle + return Promise.resolve(_.set(doc._source, 'members', members)); // eslint-disable-line no-underscore-dangle }; yield Promise.all([ updateDirectProjectPromise(), diff --git a/src/models/phaseProduct.js b/src/models/phaseProduct.js index 04ec131e..04f97479 100644 --- a/src/models/phaseProduct.js +++ b/src/models/phaseProduct.js @@ -1,5 +1,3 @@ - - module.exports = function definePhaseProduct(sequelize, DataTypes) { const PhaseProduct = sequelize.define('PhaseProduct', { id: { type: DataTypes.BIGINT, primaryKey: true, autoIncrement: true }, @@ -38,6 +36,25 @@ module.exports = function definePhaseProduct(sequelize, DataTypes) { raw: true, }); }, + /** + * Search Phase Products + * @param {Object} parameters the replacements for sequelize + * - projectId id of the project + * - phaseId id of phase + * @param {Object} log the request log + * @return {Object} the result rows and count + */ + async search(parameters = {}, log) { + const whereQuery = 'phase_products."projectId"= :projectId AND phase_products."phaseId" = :phaseId'; + const dbQuery = `SELECT * FROM phase_products WHERE ${whereQuery}`; + return sequelize.query(dbQuery, + { type: sequelize.QueryTypes.SELECT, + replacements: parameters, + logging: (str) => { log.debug(str); }, + raw: true, + }) + .then(phases => ({ rows: phases, count: phases.length })); + }, }, }); diff --git a/src/models/productTemplate.js b/src/models/productTemplate.js index 9149ce04..65252e4e 100644 --- a/src/models/productTemplate.js +++ b/src/models/productTemplate.js @@ -14,7 +14,8 @@ module.exports = (sequelize, DataTypes) => { brief: { type: DataTypes.STRING(45), allowNull: false }, details: { type: DataTypes.STRING(255), allowNull: false }, aliases: { type: DataTypes.JSON, allowNull: false }, - template: { type: DataTypes.JSON, allowNull: false }, + template: { type: DataTypes.JSON, allowNull: true }, + form: { type: DataTypes.JSON, allowNull: true }, deletedAt: DataTypes.DATE, disabled: { type: DataTypes.BOOLEAN, defaultValue: false }, hidden: { type: DataTypes.BOOLEAN, defaultValue: false }, diff --git a/src/models/projectEstimation.js b/src/models/projectEstimation.js new file mode 100644 index 00000000..241db456 --- /dev/null +++ b/src/models/projectEstimation.js @@ -0,0 +1,32 @@ +module.exports = function defineProjectHistory(sequelize, DataTypes) { + const ProjectEstimation = sequelize.define( + 'ProjectEstimation', + { + id: { type: DataTypes.BIGINT, primaryKey: true, autoIncrement: true }, + buildingBlockKey: { type: DataTypes.STRING, allowNull: false }, + conditions: { type: DataTypes.STRING, allowNull: false }, + price: { type: DataTypes.DOUBLE, allowNull: false }, + minTime: { type: DataTypes.INTEGER, allowNull: false }, + maxTime: { type: DataTypes.INTEGER, allowNull: false }, + metadata: { type: DataTypes.JSON, allowNull: false, defaultValue: {} }, + projectId: { type: DataTypes.BIGINT, allowNull: false }, + + deletedAt: { type: DataTypes.DATE, allowNull: true }, + createdAt: { type: DataTypes.DATE, defaultValue: DataTypes.NOW }, + updatedAt: { type: DataTypes.DATE, defaultValue: DataTypes.NOW }, + deletedBy: DataTypes.BIGINT, + createdBy: { type: DataTypes.INTEGER, allowNull: false }, + updatedBy: { type: DataTypes.INTEGER, allowNull: false }, + }, + { + tableName: 'project_estimations', + paranoid: true, + timestamps: true, + updatedAt: 'updatedAt', + createdAt: 'createdAt', + indexes: [], + }, + ); + + return ProjectEstimation; +}; diff --git a/src/models/projectPhase.js b/src/models/projectPhase.js index 35649382..651c9c70 100644 --- a/src/models/projectPhase.js +++ b/src/models/projectPhase.js @@ -1,5 +1,3 @@ -/* eslint-disable valid-jsdoc */ - import _ from 'lodash'; module.exports = function defineProjectPhase(sequelize, DataTypes) { @@ -44,62 +42,32 @@ module.exports = function defineProjectPhase(sequelize, DataTypes) { ProjectPhase.hasMany(models.PhaseProduct, { as: 'products', foreignKey: 'phaseId' }); }, /** - * Search name or status - * @param parameters the parameters - * - filters: the filters contains keyword - * - order: the order - * - limit: the limit - * - offset: the offset - * - attributes: the attributes to get - * @param log the request log - * @return the result rows and count + * Search project phases + * @param {Object} parameters the parameters + * - sortField: the field that will be references when sorting + * - sortType: ASC or DESC + * - fields: the fields to retrieved + * - projectId: the id of project + * @param {Object} log the request log + * @return {Object} the result rows and count */ - searchText(parameters, log) { - // special handling for keyword filter - let query = '1=1 '; - if (_.has(parameters.filters, 'id')) { - if (_.isObject(parameters.filters.id)) { - if (parameters.filters.id.$in.length === 0) { - parameters.filters.id.$in.push(-1); - } - query += `AND id IN (${parameters.filters.id.$in}) `; - } else if (_.isString(parameters.filters.id) || _.isNumber(parameters.filters.id)) { - query += `AND id = ${parameters.filters.id} `; - } - } - if (_.has(parameters.filters, 'status')) { - const statusFilter = parameters.filters.status; - if (_.isObject(statusFilter)) { - const statuses = statusFilter.$in.join("','"); - query += `AND status IN ('${statuses}') `; - } else if (_.isString(statusFilter)) { - query += `AND status ='${statusFilter}'`; - } - } - if (_.has(parameters.filters, 'name')) { - query += `AND name like '%${parameters.filters.name}%' `; + async search(parameters = {}, log) { + let fieldsStr = _.map(parameters.fields, field => `project_phases."${field}"`); + fieldsStr = `${fieldsStr.join(',')}`; + const replacements = { + projectId: parameters.projectId, + }; + let dbQuery = `SELECT ${fieldsStr} FROM project_phases WHERE project_phases."projectId" = :projectId`; + if (_.has(parameters, 'sortField') && _.has(parameters, 'sortType')) { + dbQuery = `${dbQuery} ORDER BY project_phases."${parameters.sortField}" ${parameters.sortType}`; } - - const attributesStr = `"${parameters.attributes.join('","')}"`; - const orderStr = `"${parameters.order[0][0]}" ${parameters.order[0][1]}`; - - // select count of project_phases - return sequelize.query(`SELECT COUNT(1) FROM project_phases WHERE ${query}`, + return sequelize.query(dbQuery, { type: sequelize.QueryTypes.SELECT, logging: (str) => { log.debug(str); }, + replacements, raw: true, }) - .then((fcount) => { - const count = fcount[0].count; - // select project attributes - return sequelize.query(`SELECT ${attributesStr} FROM project_phases WHERE ${query} ORDER BY ` + - ` ${orderStr} LIMIT ${parameters.limit} OFFSET ${parameters.offset}`, - { type: sequelize.QueryTypes.SELECT, - logging: (str) => { log.debug(str); }, - raw: true, - }) - .then(phases => ({ rows: phases, count })); - }); + .then(phases => ({ rows: phases, count: phases.length })); }, }, }); diff --git a/src/permissions/index.js b/src/permissions/index.js index c68c44a4..5acab2be 100644 --- a/src/permissions/index.js +++ b/src/permissions/index.js @@ -35,6 +35,7 @@ module.exports = () => { Authorizer.setPolicy('productTemplate.create', projectAdmin); Authorizer.setPolicy('productTemplate.edit', projectAdmin); + Authorizer.setPolicy('productTemplate.upgrade', projectAdmin); Authorizer.setPolicy('productTemplate.delete', projectAdmin); Authorizer.setPolicy('projectTemplate.upgrade', projectAdmin); Authorizer.setPolicy('productTemplate.view', true); diff --git a/src/routes/index.js b/src/routes/index.js index 045384bf..c6f711ef 100644 --- a/src/routes/index.js +++ b/src/routes/index.js @@ -43,6 +43,8 @@ router.route('/v4/projects/metadata/productTemplates') .get(require('./productTemplates/list')); router.route('/v4/projects/metadata/productTemplates/:templateId(\\d+)') .get(require('./productTemplates/get')); +router.route('/v4/projects/metadata/productTemplates/:templateId(\\d+)/upgrade') + .post(require('./productTemplates/upgrade')); router.route('/v4/projects/metadata/projectTypes') .get(require('./projectTypes/list')); @@ -50,7 +52,7 @@ router.route('/v4/projects/metadata/projectTypes/:key') .get(require('./projectTypes/get')); router.route('/v4/projects/metadata/projectTemplates/:templateId(\\d+)/upgrade') -.post(require('./projectTemplates/upgrade')); + .post(require('./projectTemplates/upgrade')); router.route('/v4/projects/metadata/orgConfig') .get(require('./orgConfig/list')); @@ -128,6 +130,9 @@ router.route('/v4/projects/:projectId(\\d+)/phases') .get(require('./phases/list')) .post(require('./phases/create')); +router.route('/v4/projects/:projectId(\\d+)/phases/db') + .get(require('./phases/list-db')); + router.route('/v4/projects/:projectId(\\d+)/phases/:phaseId(\\d+)') .get(require('./phases/get')) .patch(require('./phases/update')) @@ -137,6 +142,9 @@ router.route('/v4/projects/:projectId(\\d+)/phases/:phaseId(\\d+)/products') .get(require('./phaseProducts/list')) .post(require('./phaseProducts/create')); +router.route('/v4/projects/:projectId(\\d+)/phases/:phaseId(\\d+)/products/db') + .get(require('./phaseProducts/list-db')); + router.route('/v4/projects/:projectId(\\d+)/phases/:phaseId(\\d+)/products/:productId(\\d+)') .get(require('./phaseProducts/get')) .patch(require('./phaseProducts/update')) diff --git a/src/routes/metadata/list.js b/src/routes/metadata/list.js index 7a4b0388..3358350f 100644 --- a/src/routes/metadata/list.js +++ b/src/routes/metadata/list.js @@ -30,27 +30,39 @@ function getUsedModel() { attributes: { exclude: ['deletedAt', 'deletedBy'] }, raw: true, }; - return models.ProjectTemplate.findAll(query) - .then((templates) => { - templates.forEach((template) => { - const { form, planConfig, priceConfig } = template; - if ((form) && (form.key) && (form.version)) { - modelUsed.form[form.key] = modelUsed.form[form.key] ? modelUsed.form[form.key] : {}; - modelUsed.form[form.key][form.version] = true; - } - if ((priceConfig) && (priceConfig.key) && (priceConfig.version)) { - modelUsed.priceConfig[priceConfig.key] = modelUsed.priceConfig[priceConfig.key] ? - modelUsed.priceConfig[priceConfig.key] : {}; - modelUsed.priceConfig[priceConfig.key][priceConfig.version] = true; - } - if ((planConfig) && (planConfig.key) && (planConfig.version)) { - modelUsed.planConfig[planConfig.key] = modelUsed.planConfig[planConfig.key] ? - modelUsed.planConfig[planConfig.key] : {}; - modelUsed.planConfig[planConfig.key][planConfig.version] = true; - } - }); - return Promise.resolve(modelUsed); - }); + + return Promise.all([ + models.ProjectTemplate.findAll(query), + models.ProductTemplate.findAll(query), + ]).then(([projectTemplates, productTemplates]) => { + projectTemplates.forEach((template) => { + const { form, planConfig, priceConfig } = template; + if ((form) && (form.key) && (form.version)) { + modelUsed.form[form.key] = modelUsed.form[form.key] ? modelUsed.form[form.key] : {}; + modelUsed.form[form.key][form.version] = true; + } + if ((priceConfig) && (priceConfig.key) && (priceConfig.version)) { + modelUsed.priceConfig[priceConfig.key] = modelUsed.priceConfig[priceConfig.key] ? + modelUsed.priceConfig[priceConfig.key] : {}; + modelUsed.priceConfig[priceConfig.key][priceConfig.version] = true; + } + if ((planConfig) && (planConfig.key) && (planConfig.version)) { + modelUsed.planConfig[planConfig.key] = modelUsed.planConfig[planConfig.key] ? + modelUsed.planConfig[planConfig.key] : {}; + modelUsed.planConfig[planConfig.key][planConfig.version] = true; + } + }); + + productTemplates.forEach((template) => { + const { form } = template; + if ((form) && (form.key) && (form.version)) { + modelUsed.form[form.key] = modelUsed.form[form.key] ? modelUsed.form[form.key] : {}; + modelUsed.form[form.key][form.version] = true; + } + }); + + return Promise.resolve(modelUsed); + }); } diff --git a/src/routes/metadata/list.spec.js b/src/routes/metadata/list.spec.js index 6e037309..6bbf9cb8 100644 --- a/src/routes/metadata/list.spec.js +++ b/src/routes/metadata/list.spec.js @@ -38,7 +38,8 @@ const productTemplates = [ brief: 'brief 1', details: 'details 1', aliases: {}, - template: {}, + form: { key: 'productKey 1', version: 1 }, + template: null, createdBy: 1, updatedBy: 2, }, @@ -107,6 +108,30 @@ const forms = [ createdBy: 1, updatedBy: 1, }, + { + key: 'productKey 1', + config: { + questions: [{ + id: 'appDefinition', + title: 'Sample Project', + required: true, + description: 'Please answer a few basic questions', + subSections: [{ + id: 'projectName', + required: true, + validationError: 'Please provide a name for your project', + fieldName: 'name', + description: '', + title: 'Project Name', + type: 'project-name', + }], + }], + }, + version: 2, + revision: 1, + createdBy: 1, + updatedBy: 1, + }, ]; const priceConfigs = [ { @@ -198,7 +223,7 @@ describe('GET all metadata', () => { resJson.milestoneTemplates.should.have.length(1); resJson.projectTypes.should.have.length(1); resJson.productCategories.should.have.length(1); - resJson.forms.should.have.length(1); + resJson.forms.should.have.length(2); resJson.planConfigs.should.have.length(1); resJson.priceConfigs.should.have.length(1); @@ -225,7 +250,7 @@ describe('GET all metadata', () => { resJson.milestoneTemplates.should.have.length(1); resJson.projectTypes.should.have.length(1); resJson.productCategories.should.have.length(1); - resJson.forms.should.have.length(2); + resJson.forms.should.have.length(3); resJson.planConfigs.should.have.length(2); resJson.priceConfigs.should.have.length(2); done(); diff --git a/src/routes/phaseProducts/list-db.js b/src/routes/phaseProducts/list-db.js new file mode 100644 index 00000000..d0eab2f5 --- /dev/null +++ b/src/routes/phaseProducts/list-db.js @@ -0,0 +1,45 @@ +import _ from 'lodash'; +import { middleware as tcMiddleware } from 'tc-core-library-js'; +import util from '../../util'; +import models from '../../models'; + +const permissions = tcMiddleware.permissions; + +module.exports = [ + permissions('project.view'), + async (req, res, next) => { + const projectId = _.parseInt(req.params.projectId); + const phaseId = _.parseInt(req.params.phaseId); + + // check if the project and phase are exist + try { + const countProject = await models.Project.count({ where: { id: projectId } }); + if (countProject === 0) { + const apiErr = new Error(`active project not found for project id ${projectId}`); + apiErr.status = 404; + throw apiErr; + } + + const countPhase = await models.ProjectPhase.count({ where: { id: phaseId } }); + if (countPhase === 0) { + const apiErr = new Error(`active project phase not found for id ${phaseId}`); + apiErr.status = 404; + throw apiErr; + } + } catch (err) { + return next(err); + } + + const parameters = { + projectId, + phaseId, + }; + + try { + const { rows, count } = await models.PhaseProduct.search(parameters, req.log); + return res.json(util.wrapResponse(req.id, rows, count)); + } catch (err) { + return next(err); + } + }, +]; diff --git a/src/routes/phaseProducts/list-db.spec.js b/src/routes/phaseProducts/list-db.spec.js new file mode 100644 index 00000000..eb32119c --- /dev/null +++ b/src/routes/phaseProducts/list-db.spec.js @@ -0,0 +1,188 @@ +/* eslint-disable no-unused-expressions */ +import _ from 'lodash'; +import request from 'supertest'; +import chai from 'chai'; +import server from '../../app'; +import models from '../../models'; +import testUtil from '../../tests/util'; + +const should = chai.should(); + +const body = { + name: 'test phase product', + type: 'product1', + estimatedPrice: 20.0, + actualPrice: 1.23456, + details: { + message: 'This can be any json', + }, + createdBy: 1, + updatedBy: 1, +}; + +describe('Phase Products', () => { + let projectId; + let phaseId; + let project; + 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', + }; + before(function beforeHook(done) { + this.timeout(10000); + // mocks + testUtil.clearDb() + .then(() => { + models.Project.create({ + type: 'generic', + billingAccountId: 1, + name: 'test1', + description: 'test project1', + status: 'draft', + details: {}, + createdBy: 1, + updatedBy: 1, + lastActivityAt: 1, + lastActivityUserId: '1', + }).then((p) => { + projectId = p.id; + project = p.toJSON(); + // 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.ProjectPhase.create({ + name: 'test project phase', + status: 'active', + startDate: '2018-05-15T00:00:00Z', + endDate: '2018-05-15T12:00:00Z', + budget: 20.0, + progress: 1.23456, + details: { + message: 'This can be any json', + }, + createdBy: 1, + updatedBy: 1, + projectId, + }).then((phase) => { + phaseId = phase.id; + _.assign(body, { phaseId, projectId }); + project.lastActivityAt = 1; + project.phases = [phase.toJSON()]; + + models.PhaseProduct.create(body).then((product) => { + project.phases[0].products = [product.toJSON()]; + project.lastActivityAt = 1; + done(); + }); + }); + }); + }); + }); + }); + + after((done) => { + testUtil.clearDb(done); + }); + + describe('GET /projects/{id}/phases/{phaseId}/products/db', () => { + it('should return 403 when user have no permission (non team member)', (done) => { + request(server) + .get(`/v4/projects/${projectId}/phases/${phaseId}/products/db`) + .set({ + Authorization: `Bearer ${testUtil.jwts.member2}`, + }) + .send({ param: body }) + .expect('Content-Type', /json/) + .expect(403, done); + }); + + it('should return 404 when no project with specific projectId', (done) => { + request(server) + .get(`/v4/projects/999/phases/${phaseId}/products/db`) + .set({ + Authorization: `Bearer ${testUtil.jwts.manager}`, + }) + .send({ param: body }) + .expect('Content-Type', /json/) + .expect(404, done); + }); + + it('should return 404 when no phase with specific phaseId', (done) => { + request(server) + .get(`/v4/projects/${projectId}/phases/99999/products/db`) + .set({ + Authorization: `Bearer ${testUtil.jwts.manager}`, + }) + .send({ param: body }) + .expect('Content-Type', /json/) + .expect(404, done); + }); + + it('should return 1 phase when user have project permission (customer)', (done) => { + request(server) + .get(`/v4/projects/${projectId}/phases/${phaseId}/products/db`) + .set({ + Authorization: `Bearer ${testUtil.jwts.member}`, + }) + .send({ param: body }) + .expect('Content-Type', /json/) + .expect(200) + .end((err, res) => { + if (err) { + done(err); + } else { + const resJson = res.body.result.content; + should.exist(resJson); + resJson.should.have.lengthOf(1); + done(); + } + }); + }); + + it('should return 1 phase when user have project permission (copilot)', (done) => { + request(server) + .get(`/v4/projects/${projectId}/phases/${phaseId}/products/db`) + .set({ + Authorization: `Bearer ${testUtil.jwts.copilot}`, + }) + .send({ param: body }) + .expect('Content-Type', /json/) + .expect(200) + .end((err, res) => { + if (err) { + done(err); + } else { + const resJson = res.body.result.content; + should.exist(resJson); + resJson.should.have.lengthOf(1); + done(); + } + }); + }); + }); +}); diff --git a/src/routes/phases/list-db.js b/src/routes/phases/list-db.js new file mode 100644 index 00000000..60337cb1 --- /dev/null +++ b/src/routes/phases/list-db.js @@ -0,0 +1,62 @@ +import _ from 'lodash'; +import { middleware as tcMiddleware } from 'tc-core-library-js'; +import util from '../../util'; +import models from '../../models'; + +const PHASE_ATTRIBUTES = _.keys(models.ProjectPhase.rawAttributes); +const permissions = tcMiddleware.permissions; + +module.exports = [ + permissions('project.view'), + async (req, res, next) => { + const projectId = _.parseInt(req.params.projectId); + // check if the project is exist + try { + const count = await models.Project.count({ where: { id: projectId } }); + if (count === 0) { + const apiErr = new Error(`active project not found for project id ${projectId}`); + apiErr.status = 404; + throw apiErr; + } + } catch (err) { + return next(err); + } + + // Parse the fields string to determine what fields are to be returned + const rawFields = req.query.fields ? decodeURIComponent(req.query.fields).split(',') : PHASE_ATTRIBUTES; + let sort = req.query.sort ? decodeURIComponent(req.query.sort) : 'startDate'; + if (sort && sort.indexOf(' ') === -1) { + sort += ' asc'; + } + const sortableProps = [ + 'startDate asc', 'startDate desc', + 'endDate asc', 'endDate desc', + 'status asc', 'status desc', + 'order asc', 'order desc', + ]; + if (sort && _.indexOf(sortableProps, sort) < 0) { + return util.handleError('Invalid sort criteria', null, req, next); + } + + const sortParameters = sort.split(' '); + + const fields = _.union( + _.intersection(rawFields, [...PHASE_ATTRIBUTES, 'products']), + ['id'], // required fields + ); + + const parameters = { + projectId, + sortField: sortParameters[0], + sortType: sortParameters[1], + fields, + }; + + try { + const { rows, count } = await models.ProjectPhase.search(parameters, req.log); + return res.json(util.wrapResponse(req.id, rows, count)); + } catch (err) { + return next(err); + } + }, +]; diff --git a/src/routes/phases/list-db.spec.js b/src/routes/phases/list-db.spec.js new file mode 100644 index 00000000..568f2815 --- /dev/null +++ b/src/routes/phases/list-db.spec.js @@ -0,0 +1,159 @@ +/* eslint-disable no-unused-expressions */ +import _ from 'lodash'; +import request from 'supertest'; +import chai from 'chai'; +import server from '../../app'; +import models from '../../models'; +import testUtil from '../../tests/util'; + +const should = chai.should(); + +const body = { + name: 'test project phase', + status: 'active', + startDate: '2018-05-15T00:00:00Z', + endDate: '2018-05-15T12:00:00Z', + budget: 20.0, + progress: 1.23456, + details: { + message: 'This can be any json', + }, + createdBy: 1, + updatedBy: 1, +}; + +describe('Project Phases', () => { + let projectId; + let project; + 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', + }; + before(function beforeHook(done) { + this.timeout(10000); + // mocks + testUtil.clearDb() + .then(() => { + models.Project.create({ + type: 'generic', + billingAccountId: 1, + name: 'test1', + description: 'test project1', + status: 'draft', + details: {}, + createdBy: 1, + updatedBy: 1, + lastActivityAt: 1, + lastActivityUserId: '1', + }).then((p) => { + projectId = p.id; + project = p.toJSON(); + // 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(() => { + _.assign(body, { projectId }); + return models.ProjectPhase.create(body); + }).then((phase) => { + project.lastActivityAt = 1; + project.phases = [phase]; + done(); + }); + }); + }); + }); + + after((done) => { + testUtil.clearDb(done); + }); + + describe('GET /projects/{id}/phases/db', () => { + it('should return 403 when user have no permission (non team member)', (done) => { + request(server) + .get(`/v4/projects/${projectId}/phases/db`) + .set({ + Authorization: `Bearer ${testUtil.jwts.member2}`, + }) + .send({ param: body }) + .expect('Content-Type', /json/) + .expect(403, done); + }); + + it('should return 404 when no project with specific projectId', (done) => { + request(server) + .get('/v4/projects/999/phases/db') + .set({ + Authorization: `Bearer ${testUtil.jwts.manager}`, + }) + .send({ param: body }) + .expect('Content-Type', /json/) + .expect(404, done); + }); + + it('should return 1 phase when user have project permission (customer)', (done) => { + request(server) + .get(`/v4/projects/${projectId}/phases/db`) + .set({ + Authorization: `Bearer ${testUtil.jwts.member}`, + }) + .send({ param: body }) + .expect('Content-Type', /json/) + .expect(200) + .end((err, res) => { + if (err) { + done(err); + } else { + const resJson = res.body.result.content; + should.exist(resJson); + resJson.should.have.lengthOf(1); + done(); + } + }); + }); + + it('should return 1 phase when user have project permission (copilot)', (done) => { + request(server) + .get(`/v4/projects/${projectId}/phases/db`) + .set({ + Authorization: `Bearer ${testUtil.jwts.copilot}`, + }) + .send({ param: body }) + .expect('Content-Type', /json/) + .expect(200) + .end((err, res) => { + if (err) { + done(err); + } else { + const resJson = res.body.result.content; + should.exist(resJson); + resJson.should.have.lengthOf(1); + done(); + } + }); + }); + }); +}); diff --git a/src/routes/productTemplates/create.js b/src/routes/productTemplates/create.js index aa9e4b98..8875aa55 100644 --- a/src/routes/productTemplates/create.js +++ b/src/routes/productTemplates/create.js @@ -23,7 +23,11 @@ const schema = { brief: Joi.string().max(45).required(), details: Joi.string().max(255).required(), aliases: Joi.array().required(), - template: Joi.object().required(), + template: Joi.object().empty(null), + form: Joi.object().keys({ + key: Joi.string().required(), + version: Joi.number(), + }).empty(null), disabled: Joi.boolean().optional(), hidden: Joi.boolean().optional(), isAddOn: Joi.boolean().optional(), @@ -33,7 +37,9 @@ const schema = { createdBy: Joi.any().strip(), updatedBy: Joi.any().strip(), deletedBy: Joi.any().strip(), - }).required(), + }) + .xor('form', 'template') + .required(), }, }; @@ -42,16 +48,22 @@ module.exports = [ permissions('productTemplate.create'), fieldLookupValidation(models.ProductCategory, 'key', 'body.param.category', 'Category'), (req, res, next) => { - const entity = _.assign(req.body.param, { - createdBy: req.authUser.userId, - updatedBy: req.authUser.userId, - }); + const param = req.body.param; + const { form } = param; + return util.checkModel(form, 'Form', models.Form, 'product template') + .then(() => { + const entity = _.assign(param, { + createdBy: req.authUser.userId, + updatedBy: req.authUser.userId, + }); - return models.ProductTemplate.create(entity) - .then((createdEntity) => { - // Omit deletedAt, deletedBy - res.status(201).json(util.wrapResponse( - req.id, _.omit(createdEntity.toJSON(), 'deletedAt', 'deletedBy'), 1, 201)); + return models.ProductTemplate.create(entity) + .then((createdEntity) => { + // Omit deletedAt, deletedBy + res.status(201).json(util.wrapResponse( + req.id, _.omit(createdEntity.toJSON(), 'deletedAt', 'deletedBy'), 1, 201)); + }) + .catch(next); }) .catch(next); }, diff --git a/src/routes/productTemplates/create.spec.js b/src/routes/productTemplates/create.spec.js index fc367625..14f3807d 100644 --- a/src/routes/productTemplates/create.spec.js +++ b/src/routes/productTemplates/create.spec.js @@ -12,22 +12,49 @@ import models from '../../models'; const should = chai.should(); describe('CREATE product template', () => { - before((done) => { - testUtil.clearDb() - .then(() => models.ProductCategory.bulkCreate([ - { - key: 'generic', - displayName: 'Generic', - icon: 'http://example.com/icon1.ico', - question: 'question 1', - info: 'info 1', - aliases: ['key-1', 'key_1'], - createdBy: 1, - updatedBy: 1, - }, - ])) - .then(() => done()); - }); + const productCategories = [ + { + key: 'generic', + displayName: 'Generic', + icon: 'http://example.com/icon1.ico', + question: 'question 1', + info: 'info 1', + aliases: ['key-1', 'key_1'], + createdBy: 1, + updatedBy: 1, + }, + ]; + + const forms = [ + { + key: 'dev', + config: { + test: 'test1', + }, + version: 1, + revision: 1, + createdBy: 1, + updatedBy: 1, + }, + { + key: 'dev', + config: { + test: 'test2', + }, + version: 2, + revision: 1, + createdBy: 1, + updatedBy: 1, + }, + ]; + + beforeEach(() => testUtil.clearDb() + .then(() => models.ProductCategory.bulkCreate(productCategories)) + .then(() => models.Form.create(forms[0])) + .then(() => models.Form.create(forms[1])) + .then(() => Promise.resolve()), + ); + after(testUtil.clearDb); describe('POST /projects/metadata/productTemplates', () => { const body = { @@ -62,6 +89,24 @@ describe('CREATE product template', () => { }, }; + const bodyDefinedFormTemplate = _.cloneDeep(body); + bodyDefinedFormTemplate.param.form = { + version: 1, + key: 'dev', + }; + + const bodyWithForm = _.cloneDeep(bodyDefinedFormTemplate); + delete bodyWithForm.param.template; + + const bodyMissingFormTemplate = _.cloneDeep(bodyWithForm); + delete bodyMissingFormTemplate.param.form; + + const bodyInvalidForm = _.cloneDeep(body); + bodyInvalidForm.param.form = { + version: 1, + key: 'wrongKey', + }; + it('should return 403 if user is not authenticated', (done) => { request(server) .post('/v4/projects/metadata/productTemplates') @@ -193,5 +238,71 @@ describe('CREATE product template', () => { done(); }); }); + + it('should return 201 with form data', (done) => { + request(server) + .post('/v4/projects/metadata/productTemplates') + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .send(bodyWithForm) + .expect('Content-Type', /json/) + .expect(201) + .end((err, res) => { + const resJson = res.body.result.content; + should.exist(resJson.id); + resJson.name.should.be.eql(bodyWithForm.param.name); + resJson.productKey.should.be.eql(bodyWithForm.param.productKey); + resJson.category.should.be.eql(bodyWithForm.param.category); + resJson.icon.should.be.eql(bodyWithForm.param.icon); + resJson.brief.should.be.eql(bodyWithForm.param.brief); + resJson.details.should.be.eql(bodyWithForm.param.details); + resJson.aliases.should.be.eql(bodyWithForm.param.aliases); + resJson.form.should.be.eql(bodyWithForm.param.form); + resJson.disabled.should.be.eql(true); + resJson.hidden.should.be.eql(true); + + resJson.createdBy.should.be.eql(40051333); // admin + should.exist(resJson.createdAt); + resJson.updatedBy.should.be.eql(40051333); // admin + should.exist(resJson.updatedAt); + should.not.exist(resJson.deletedBy); + should.not.exist(resJson.deletedAt); + + done(); + }); + }); + + it('should return 422 when form is invalid', (done) => { + request(server) + .post('/v4/projects/metadata/productTemplates') + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .send(bodyInvalidForm) + .expect(422, done); + }); + + it('should return 422 if both form or template field are defined', (done) => { + request(server) + .post('/v4/projects/metadata/productTemplates') + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .send(bodyDefinedFormTemplate) + .expect('Content-Type', /json/) + .expect(422, done); + }); + + it('should return 422 if both form or template field are missing', (done) => { + request(server) + .post('/v4/projects/metadata/productTemplates') + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .send(bodyMissingFormTemplate) + .expect('Content-Type', /json/) + .expect(422, done); + }); }); }); diff --git a/src/routes/productTemplates/list.spec.js b/src/routes/productTemplates/list.spec.js index f975a3a9..d872be8e 100644 --- a/src/routes/productTemplates/list.spec.js +++ b/src/routes/productTemplates/list.spec.js @@ -15,7 +15,7 @@ const validateProductTemplates = (count, resJson, expectedTemplates) => { resJson.should.have.length(count); resJson.forEach((pt, idx) => { pt.should.have.all.keys('id', 'name', 'productKey', 'category', 'subCategory', 'icon', 'brief', 'details', - 'aliases', 'template', 'disabled', 'hidden', 'isAddOn', 'createdBy', 'createdAt', 'updatedBy', 'updatedAt'); + 'aliases', 'template', 'disabled', 'form', 'hidden', 'isAddOn', 'createdBy', 'createdAt', 'updatedBy', 'updatedAt'); pt.should.not.have.all.keys('deletedAt', 'deletedBy'); pt.name.should.be.eql(expectedTemplates[idx].name); pt.productKey.should.be.eql(expectedTemplates[idx].productKey); diff --git a/src/routes/productTemplates/update.js b/src/routes/productTemplates/update.js index ad245b8e..d3d2fc2e 100644 --- a/src/routes/productTemplates/update.js +++ b/src/routes/productTemplates/update.js @@ -26,7 +26,11 @@ const schema = { brief: Joi.string().max(45), details: Joi.string().max(255), aliases: Joi.array(), - template: Joi.object(), + template: Joi.object().empty(null), + form: Joi.object().keys({ + key: Joi.string().required(), + version: Joi.number(), + }).empty(null), disabled: Joi.boolean().optional(), hidden: Joi.boolean().optional(), isAddOn: Joi.boolean().optional(), @@ -36,7 +40,9 @@ const schema = { createdBy: Joi.any().strip(), updatedBy: Joi.any().strip(), deletedBy: Joi.any().strip(), - }).required(), + }) + .xor('form', 'template') + .required(), }, }; @@ -45,34 +51,41 @@ module.exports = [ permissions('productTemplate.edit'), fieldLookupValidation(models.ProductCategory, 'key', 'body.param.category', 'Category'), (req, res, next) => { - const entityToUpdate = _.assign(req.body.param, { - updatedBy: req.authUser.userId, - }); + const param = req.body.param; + const { form } = param; + return util.checkModel(form, 'Form', models.Form, 'product template') + .then(() => { + const entityToUpdate = _.assign(req.body.param, { + updatedBy: req.authUser.userId, + }); - return models.ProductTemplate.findOne({ - where: { - deletedAt: { $eq: null }, - id: req.params.templateId, - }, - attributes: { exclude: ['deletedAt', 'deletedBy'] }, - }) - .then((productTemplate) => { - // Not found - if (!productTemplate) { - const apiErr = new Error(`Product template not found for template id ${req.params.templateId}`); - apiErr.status = 404; - return Promise.reject(apiErr); - } + return models.ProductTemplate.findOne({ + where: { + deletedAt: { $eq: null }, + id: req.params.templateId, + }, + attributes: { exclude: ['deletedAt', 'deletedBy'] }, + }) + .then((productTemplate) => { + // Not found + if (!productTemplate) { + const apiErr = new Error(`Product template not found for template id ${req.params.templateId}`); + apiErr.status = 404; + return Promise.reject(apiErr); + } - // Merge JSON fields - // entityToUpdate.aliases = util.mergeJsonObjects(productTemplate.aliases, entityToUpdate.aliases); - entityToUpdate.template = util.mergeJsonObjects(productTemplate.template, entityToUpdate.template); + if (entityToUpdate.template) { + // Merge JSON fields + entityToUpdate.template = util.mergeJsonObjects(productTemplate.template, entityToUpdate.template); + } - return productTemplate.update(entityToUpdate); - }) - .then((productTemplate) => { - res.json(util.wrapResponse(req.id, productTemplate)); - return Promise.resolve(); + return productTemplate.update(entityToUpdate); + }) + .then((productTemplate) => { + res.json(util.wrapResponse(req.id, productTemplate)); + return Promise.resolve(); + }) + .catch(next); }) .catch(next); }, diff --git a/src/routes/productTemplates/update.spec.js b/src/routes/productTemplates/update.spec.js index a1508e2c..633ca3e0 100644 --- a/src/routes/productTemplates/update.spec.js +++ b/src/routes/productTemplates/update.spec.js @@ -1,6 +1,7 @@ /** * Tests for get.js */ +import _ from 'lodash'; import chai from 'chai'; import request from 'supertest'; @@ -43,9 +44,34 @@ describe('UPDATE product template', () => { updatedBy: 2, }; + const forms = [ + { + key: 'dev', + config: { + test: 'test1', + }, + version: 1, + revision: 1, + createdBy: 1, + updatedBy: 1, + }, + { + key: 'dev', + config: { + test: 'test2', + }, + version: 2, + revision: 1, + createdBy: 1, + updatedBy: 1, + }, + ]; + let templateId; beforeEach(() => testUtil.clearDb() + .then(() => models.Form.create(forms[0])) + .then(() => models.Form.create(forms[1])) .then(() => models.ProductCategory.bulkCreate([ { key: 'generic', @@ -107,6 +133,24 @@ describe('UPDATE product template', () => { }, }; + const bodyDefinedFormTemplate = _.cloneDeep(body); + bodyDefinedFormTemplate.param.form = { + version: 1, + key: 'dev', + }; + + const bodyWithForm = _.cloneDeep(bodyDefinedFormTemplate); + delete bodyWithForm.param.template; + + const bodyMissingFormTemplate = _.cloneDeep(bodyWithForm); + delete bodyMissingFormTemplate.param.form; + + const bodyInvalidForm = _.cloneDeep(body); + bodyInvalidForm.param.form = { + version: 1, + key: 'wrongKey', + }; + it('should return 403 if user is not authenticated', (done) => { request(server) .patch(`/v4/projects/metadata/productTemplates/${templateId}`) @@ -249,5 +293,50 @@ describe('UPDATE product template', () => { .expect(200) .end(done); }); + + it('should return 200 when update form', (done) => { + request(server) + .patch(`/v4/projects/metadata/productTemplates/${templateId}`) + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .send(bodyWithForm) + .expect(200) + .end((err, res) => { + const resJson = res.body.result.content; + resJson.form.should.be.eql(bodyWithForm.param.form); + done(); + }); + }); + + it('should return 422 when form is invalid', (done) => { + request(server) + .patch(`/v4/projects/metadata/productTemplates/${templateId}`) + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .send(bodyInvalidForm) + .expect(422, done); + }); + + it('should return 422 if both form or template field are defined', (done) => { + request(server) + .patch(`/v4/projects/metadata/productTemplates/${templateId}`) + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .send(bodyDefinedFormTemplate) + .expect(422, done); + }); + + it('should return 422 if both form or template field are missing', (done) => { + request(server) + .patch(`/v4/projects/metadata/productTemplates/${templateId}`) + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .send(bodyMissingFormTemplate) + .expect(422, done); + }); }); }); diff --git a/src/routes/productTemplates/upgrade.js b/src/routes/productTemplates/upgrade.js new file mode 100644 index 00000000..c78fe827 --- /dev/null +++ b/src/routes/productTemplates/upgrade.js @@ -0,0 +1,75 @@ +/** + * API to add a new version of form + */ +import validate from 'express-validation'; +import _ from 'lodash'; +import Joi from 'joi'; +import { middleware as tcMiddleware } from 'tc-core-library-js'; +import util from '../../util'; +import models from '../../models'; + +const permissions = tcMiddleware.permissions; + +const schema = { + body: { + param: Joi.object().keys({ + form: Joi.object().keys({ + version: Joi.number().integer().positive().required(), + key: Joi.string().required(), + }).optional(), + }).optional(), + }, +}; + +module.exports = [ + validate(schema), + permissions('productTemplate.upgrade'), + (req, res, next) => { + models.sequelize.transaction( + () => models.ProductTemplate.findOne({ + where: { + id: req.params.templateId, + }, + }).then(async (productTemplate) => { + if (_.isNil(productTemplate)) { + const apiErr = new Error(`product template not found for id ${req.body.param.templateId}`); + apiErr.status = 404; + throw apiErr; + } + + if (_.isNil(productTemplate.template)) { + const apiErr = new Error('Current product template\'s template is null'); + apiErr.status = 422; + throw apiErr; + } + + let newForm = {}; + if (_.isNil(req.body.param.form)) { + const { productKey, template = {} } = productTemplate; + const { version } = await models.Form.createNewVersion(productKey, template, req.authUser.userId); + newForm = { + version, + key: productKey, + }; + } else { + newForm = req.body.param.form; + await util.checkModel(newForm, 'Form', models.Form, 'product template'); + } + // update product template with new form data + const updatePayload = { + template: null, + form: newForm, + updatedBy: req.authUser.userId, + }; + + const newProductTemplate = await productTemplate.update(updatePayload); + const response = util.wrapResponse( + req.id, + _.omit(newProductTemplate.toJSON(), 'deletedAt', 'deletedBy'), + 1, + 201, + ); + return res.status(201).json(response); + }).catch(next)); + }, +]; diff --git a/src/routes/productTemplates/upgrade.spec.js b/src/routes/productTemplates/upgrade.spec.js new file mode 100644 index 00000000..0cdfa5d4 --- /dev/null +++ b/src/routes/productTemplates/upgrade.spec.js @@ -0,0 +1,313 @@ +/** + * Tests for get.js + */ +import chai from 'chai'; +import request from 'supertest'; + +import models from '../../models'; +import server from '../../app'; +import testUtil from '../../tests/util'; + +const should = chai.should(); + +describe('UPGRADE product template', () => { + const productTemplate = { + name: 'name 1', + productKey: 'productKey1', + category: 'generic', + subCategory: 'generic', + icon: 'http://example.com/icon1.ico', + brief: 'brief 1', + details: 'details 1', + aliases: ['productTemplate-1', 'productTemplate_1'], + disabled: true, + hidden: true, + template: { + template1: { + name: 'template 1', + details: { + anyDetails: 'any details 1', + }, + others: ['others 11', 'others 12'], + }, + template2: { + name: 'template 2', + details: { + anyDetails: 'any details 2', + }, + others: ['others 21', 'others 22'], + }, + }, + createdBy: 1, + updatedBy: 2, + }; + + const productTemplateMissed = { + name: 'name 2', + productKey: 'productKey2', + category: 'generic', + subCategory: 'generic', + icon: 'http://example.com/icon1.ico', + brief: 'brief 1', + details: 'details 1', + aliases: ['productTemplate-1', 'productTemplate_1'], + disabled: true, + hidden: true, + createdBy: 1, + updatedBy: 2, + }; + + let templateId; + let missingTemplateId; + + beforeEach(() => testUtil.clearDb() + .then(() => models.ProductCategory.bulkCreate([ + { + key: 'generic', + displayName: 'Generic', + icon: 'http://example.com/icon1.ico', + question: 'question 1', + info: 'info 1', + aliases: ['key-1', 'key_1'], + createdBy: 1, + updatedBy: 1, + }, + { + key: 'concrete', + displayName: 'Concrete', + icon: 'http://example.com/icon1.ico', + question: 'question 2', + info: 'info 2', + aliases: ['key-2', 'key_2'], + createdBy: 1, + updatedBy: 1, + }, + ])) + .then(() => { + const config = { + questions: [{ + id: 'appDefinition', + title: 'Sample Project', + required: true, + description: 'Please answer a few basic questions', + subSections: [{ + id: 'projectName', + required: true, + validationError: 'Please provide a name for your project', + fieldName: 'name', + description: '', + title: 'Project Name', + type: 'project-name', + }, { + id: 'notes', + fieldName: 'details.appDefinition.notes', + title: 'Notes', + description: 'Add any other important information', + type: 'notes', + }], + }], + }; + models.Form.bulkCreate([ + { + key: 'newKey', + version: 1, + revision: 1, + config, + createdBy: 1, + updatedBy: 1, + }, + ]); + }) + .then(() => models.ProductTemplate.create(productTemplate)) + .then((createdTemplate) => { + templateId = createdTemplate.id; + }) + .then(() => models.ProductTemplate.create(productTemplateMissed)) + .then((createdTemplate) => { + missingTemplateId = createdTemplate.id; + }), + ); + after(testUtil.clearDb); + + describe('POST /projects/metadata/productTemplates/{templateId}/upgrade', () => { + const body = { + param: { + form: { + key: 'newKey', + version: 1, + }, + }, + }; + + const bodyInvalidForm = { + param: { + form: { + key: 'wrongKey', + version: 1, + }, + }, + }; + + const emptyBody = { + param: { + }, + }; + + it('should return 403 if user is not authenticated', (done) => { + request(server) + .post(`/v4/projects/metadata/productTemplates/${templateId}/upgrade`) + .send(body) + .expect(403, done); + }); + + it('should return 403 for member', (done) => { + request(server) + .post(`/v4/projects/metadata/productTemplates/${templateId}/upgrade`) + .set({ + Authorization: `Bearer ${testUtil.jwts.member}`, + }) + .send(body) + .expect(403, done); + }); + + it('should return 403 for copilot', (done) => { + request(server) + .post(`/v4/projects/metadata/productTemplates/${templateId}/upgrade`) + .send(body) + .set({ + Authorization: `Bearer ${testUtil.jwts.copilot}`, + }) + .expect(403, done); + }); + + it('should return 403 for connect manager', (done) => { + request(server) + .post(`/v4/projects/metadata/productTemplates/${templateId}/upgrade`) + .send(body) + .set({ + Authorization: `Bearer ${testUtil.jwts.manager}`, + }) + .expect(403, done); + }); + + it('should return 422 for invalid request', (done) => { + const invalidBody = { + param: { + form: { + key: 'notvalid', + version: 1, + }, + }, + }; + + request(server) + .post(`/v4/projects/metadata/productTemplates/${templateId}/upgrade`) + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .send(invalidBody) + .expect(422, done); + }); + + it('should return 404 for non-existed template', (done) => { + request(server) + .post('/v4/projects/metadata/productTemplates/1234/upgrade') + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .send(body) + .expect(404, done); + }); + + it('should return 404 for deleted template', (done) => { + models.ProductTemplate.destroy({ where: { id: templateId } }) + .then(() => { + request(server) + .post(`/v4/projects/metadata/productTemplates/${templateId}/upgrade`) + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .send(body) + .expect(404, done); + }); + }); + + it('should return 200 for admin', (done) => { + request(server) + .post(`/v4/projects/metadata/productTemplates/${templateId}/upgrade`) + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .send(body) + .expect(200) + .end((err, res) => { + const resJson = res.body.result.content; + resJson.id.should.be.eql(templateId); + should.not.exist(resJson.template); + + resJson.form.should.be.eql({ + key: 'newKey', + version: 1, + }); + + should.exist(resJson.createdAt); + resJson.updatedBy.should.be.eql(40051333); // admin + should.exist(resJson.updatedAt); + should.not.exist(resJson.deletedBy); + should.not.exist(resJson.deletedAt); + + done(); + }); + }); + + it('should create new version of model if param not given model key and version', (done) => { + request(server) + .post(`/v4/projects/metadata/productTemplates/${templateId}/upgrade`) + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .send(emptyBody) + .expect(200) + .end((err, res) => { + const resJson = res.body.result.content; + + should.not.exist(resJson.scope); + should.not.exist(resJson.phases); + + resJson.form.should.be.eql({ + key: 'productKey1', + version: 1, + }); + + resJson.createdBy.should.be.eql(productTemplate.createdBy); + should.exist(resJson.createdAt); + resJson.updatedBy.should.be.eql(40051333); // admin + should.exist(resJson.updatedAt); + should.not.exist(resJson.deletedBy); + should.not.exist(resJson.deletedAt); + + done(); + }); + }); + + it('should return 422 when form is invalid', (done) => { + request(server) + .post(`/v4/projects/metadata/productTemplates/${templateId}/upgrade`) + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .send(bodyInvalidForm) + .expect(422, done); + }); + + it('should return 422 when template is missing', (done) => { + request(server) + .post(`/v4/projects/metadata/productTemplates/${missingTemplateId}/upgrade`) + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .send(body) + .expect(422, done); + }); + }); +}); diff --git a/src/routes/projectMemberInvites/create.js b/src/routes/projectMemberInvites/create.js index 4839ba45..46b70399 100644 --- a/src/routes/projectMemberInvites/create.js +++ b/src/routes/projectMemberInvites/create.js @@ -28,6 +28,30 @@ const addMemberValidations = { }, }; +/** + * Helper method to check the uniqueness of two emails + * + * @param {String} email1 first email to compare + * @param {String} email2 second email to compare + * @param {Object} options the options + * + * @returns {Boolean} true if two emails are same + */ +const compareEmail = (email1, email2, options = { UNIQUE_GMAIL_VALIDATION: false }) => { + if (options.UNIQUE_GMAIL_VALIDATION) { + // email is gmail + const emailSplit = /(^[\w.+-]+)(@gmail\.com|@googlemail\.com)$/g.exec(_.toLower(email1)); + if (emailSplit) { + const address = emailSplit[1].replace('.', ''); + const emailDomain = emailSplit[2].replace('.', '\\.'); + const regexAddress = address.split('').join('\\.?'); + const regex = new RegExp(`${regexAddress}${emailDomain}`); + return regex.test(_.toLower(email2)); + } + } + return _.toLower(email1) === _.toLower(email2); +}; + /** * Helper method to build promises for creating new invites in DB * @@ -68,7 +92,8 @@ const buildCreateInvitePromises = (req, invite, invites, data, failed) => { }); // non-existent users we will invite them by email only const nonExistentUserEmails = invite.emails.filter(inviteEmail => - !_.find(existentUsers, { email: inviteEmail }), + !_.find(existentUsers, existentUser => + compareEmail(existentUser.email, inviteEmail, { UNIQUE_GMAIL_VALIDATION: false })), ); // remove invites for users that are invited already @@ -83,7 +108,9 @@ const buildCreateInvitePromises = (req, invite, invites, data, failed) => { }); // remove invites for users that are invited already - _.remove(nonExistentUserEmails, email => _.some(invites, i => i.email === email)); + _.remove(nonExistentUserEmails, email => + _.some(invites, i => + compareEmail(i.email, email, { UNIQUE_GMAIL_VALIDATION: config.get('UNIQUE_GMAIL_VALIDATION') }))); nonExistentUserEmails.forEach((email) => { const dataNew = _.clone(data); diff --git a/src/routes/projectMemberInvites/create.spec.js b/src/routes/projectMemberInvites/create.spec.js index 6965d2c4..4aae6cd5 100644 --- a/src/routes/projectMemberInvites/create.spec.js +++ b/src/routes/projectMemberInvites/create.spec.js @@ -73,17 +73,60 @@ describe('Project Member Invite create', () => { createdBy: 1, updatedBy: 1, }).then(() => { - models.ProjectMemberInvite.create({ - projectId: project1.id, - userId: 40051335, - email: null, - role: PROJECT_MEMBER_ROLE.MANAGER, - status: INVITE_STATUS.PENDING, - createdBy: 1, - updatedBy: 1, - createdAt: '2016-06-30 00:33:07+00', - updatedAt: '2016-06-30 00:33:07+00', - }).then(() => { + const promises = [ + models.ProjectMemberInvite.create({ + projectId: project1.id, + userId: 40051335, + email: null, + role: PROJECT_MEMBER_ROLE.MANAGER, + status: INVITE_STATUS.PENDING, + createdBy: 1, + updatedBy: 1, + createdAt: '2016-06-30 00:33:07+00', + updatedAt: '2016-06-30 00:33:07+00', + }), + models.ProjectMemberInvite.create({ + projectId: project1.id, + email: 'duplicate_lowercase@test.com', + role: PROJECT_MEMBER_ROLE.MANAGER, + status: INVITE_STATUS.PENDING, + createdBy: 1, + updatedBy: 1, + createdAt: '2016-06-30 00:33:07+00', + updatedAt: '2016-06-30 00:33:07+00', + }), + models.ProjectMemberInvite.create({ + projectId: project1.id, + email: 'DUPLICATE_UPPERCASE@test.com', + role: PROJECT_MEMBER_ROLE.MANAGER, + status: INVITE_STATUS.PENDING, + createdBy: 1, + updatedBy: 1, + createdAt: '2016-06-30 00:33:07+00', + updatedAt: '2016-06-30 00:33:07+00', + }), + models.ProjectMemberInvite.create({ + projectId: project1.id, + email: 'with.dot@gmail.com', + role: PROJECT_MEMBER_ROLE.MANAGER, + status: INVITE_STATUS.PENDING, + createdBy: 1, + updatedBy: 1, + createdAt: '2016-06-30 00:33:07+00', + updatedAt: '2016-06-30 00:33:07+00', + }), + models.ProjectMemberInvite.create({ + projectId: project1.id, + email: 'withoutdot@gmail.com', + role: PROJECT_MEMBER_ROLE.MANAGER, + status: INVITE_STATUS.PENDING, + createdBy: 1, + updatedBy: 1, + createdAt: '2016-06-30 00:33:07+00', + updatedAt: '2016-06-30 00:33:07+00', + }), + ]; + Promise.all(promises).then(() => { done(); }); }); @@ -640,6 +683,112 @@ describe('Project Member Invite create', () => { }); }); + it('should return 201 and empty response when trying add already invited member by lowercase email', (done) => { + request(server) + .post(`/v4/projects/${project1.id}/members/invite`) + .set({ + Authorization: `Bearer ${testUtil.jwts.copilot}`, + }) + .send({ + param: { + emails: ['DUPLICATE_LOWERCASE@test.com'], + role: 'customer', + }, + }) + .expect('Content-Type', /json/) + .expect(201) + .end((err, res) => { + if (err) { + done(err); + } else { + const resJson = res.body.result.content.success; + should.exist(resJson); + resJson.length.should.equal(0); + done(); + } + }); + }); + + it('should return 201 and empty response when trying add already invited member by uppercase email', (done) => { + request(server) + .post(`/v4/projects/${project1.id}/members/invite`) + .set({ + Authorization: `Bearer ${testUtil.jwts.copilot}`, + }) + .send({ + param: { + emails: ['duplicate_uppercase@test.com'], + role: 'customer', + }, + }) + .expect('Content-Type', /json/) + .expect(201) + .end((err, res) => { + if (err) { + done(err); + } else { + const resJson = res.body.result.content.success; + should.exist(resJson); + resJson.length.should.equal(0); + done(); + } + }); + }); + + xit('should return 201 and empty response when trying add already invited member by gmail email with dot', + (done) => { + request(server) + .post(`/v4/projects/${project1.id}/members/invite`) + .set({ + Authorization: `Bearer ${testUtil.jwts.copilot}`, + }) + .send({ + param: { + emails: ['WITHdot@gmail.com'], + role: 'customer', + }, + }) + .expect('Content-Type', /json/) + .expect(201) + .end((err, res) => { + if (err) { + done(err); + } else { + const resJson = res.body.result.content.success; + should.exist(resJson); + resJson.length.should.equal(0); + done(); + } + }); + }); + + xit('should return 201 and empty response when trying add already invited member by gmail email without dot', + (done) => { + request(server) + .post(`/v4/projects/${project1.id}/members/invite`) + .set({ + Authorization: `Bearer ${testUtil.jwts.copilot}`, + }) + .send({ + param: { + emails: ['WITHOUT.dot@gmail.com'], + role: 'customer', + }, + }) + .expect('Content-Type', /json/) + .expect(201) + .end((err, res) => { + if (err) { + done(err); + } else { + const resJson = res.body.result.content.success; + should.exist(resJson); + resJson.length.should.equal(0); + done(); + } + }); + }); + describe('Bus api', () => { let createEventSpy; diff --git a/src/routes/projectTemplates/create.js b/src/routes/projectTemplates/create.js index 12a0dfd2..80cd520e 100644 --- a/src/routes/projectTemplates/create.js +++ b/src/routes/projectTemplates/create.js @@ -22,11 +22,20 @@ const schema = { question: Joi.string().max(255).required(), info: Joi.string().max(255).required(), aliases: Joi.array().required(), - scope: Joi.object().optional().allow(null), - phases: Joi.object().optional().allow(null), - form: Joi.object().optional().allow(null), - planConfig: Joi.object().optional().allow(null), - priceConfig: Joi.object().optional().allow(null), + scope: Joi.object().empty(null), + phases: Joi.object().empty(null), + form: Joi.object().keys({ + key: Joi.string().required(), + version: Joi.number(), + }).empty(null), + planConfig: Joi.object().keys({ + key: Joi.string().required(), + version: Joi.number(), + }).empty(null), + priceConfig: Joi.object().keys({ + key: Joi.string().required(), + version: Joi.number(), + }).empty(null), disabled: Joi.boolean().optional(), hidden: Joi.boolean().optional(), createdAt: Joi.any().strip(), @@ -35,7 +44,11 @@ const schema = { createdBy: Joi.any().strip(), updatedBy: Joi.any().strip(), deletedBy: Joi.any().strip(), - }).required(), + }) + .xor('form', 'scope') + .xor('phases', 'planConfig') + .nand('priceConfig', 'scope') + .required(), }, }; @@ -47,54 +60,12 @@ module.exports = [ const param = req.body.param; const { form, priceConfig, planConfig } = param; - const checkModel = (keyInfo, modelName, model) => { - let errorMessage = ''; - if (keyInfo == null) { - return Promise.resolve(null); - } - if ((keyInfo.version != null) && (keyInfo.key != null)) { - errorMessage = `${modelName} with key ${keyInfo.key} and version ${keyInfo.version}` - + ' referred in the project template is not found'; - return (model.findOne({ - where: { - key: keyInfo.key, - version: keyInfo.version, - }, - })).then((record) => { - if (record == null) { - return Promise.resolve(errorMessage); - } - return Promise.resolve(null); - }); - } else if ((keyInfo.version == null) && (keyInfo.key != null)) { - errorMessage = `${modelName} with key ${keyInfo.key}` - + ' referred in the project template is not found'; - return model.findOne({ - where: { - key: keyInfo.key, - }, - }).then((record) => { - if (record == null) { - return Promise.resolve(errorMessage); - } - return Promise.resolve(null); - }); - } - return Promise.resolve(null); - }; - return Promise.all([ - checkModel(form, 'Form', models.Form, next), - checkModel(priceConfig, 'PriceConfig', models.PriceConfig, next), - checkModel(planConfig, 'PlanConfig', models.PlanConfig, next), + util.checkModel(form, 'Form', models.Form, 'project template'), + util.checkModel(priceConfig, 'PriceConfig', models.PriceConfig, 'project template'), + util.checkModel(planConfig, 'PlanConfig', models.PlanConfig, 'project template'), ]) - .then((errorMessages) => { - const errorMessage = errorMessages.find(e => e && e.length > 0); - if (errorMessage) { - const apiErr = new Error(errorMessage); - apiErr.status = 422; - throw apiErr; - } + .then(() => { const entity = _.assign(req.body.param, { createdBy: req.authUser.userId, updatedBy: req.authUser.userId, diff --git a/src/routes/projectTemplates/create.spec.js b/src/routes/projectTemplates/create.spec.js index 9d9fd594..2355548a 100644 --- a/src/routes/projectTemplates/create.spec.js +++ b/src/routes/projectTemplates/create.spec.js @@ -12,23 +12,51 @@ import testUtil from '../../tests/util'; const should = chai.should(); describe('CREATE project template', () => { - before((done) => { - testUtil.clearDb() - .then(() => models.ProjectType.bulkCreate([ - { - key: 'generic', - displayName: 'Generic', - icon: 'http://example.com/icon1.ico', - question: 'question 1', - info: 'info 1', - aliases: ['key-1', 'key_1'], - metadata: {}, - createdBy: 1, - updatedBy: 1, - }, - ])) - .then(() => done()); - }); + before(() => testUtil.clearDb() + .then(() => models.ProjectType.bulkCreate([ + { + key: 'generic', + displayName: 'Generic', + icon: 'http://example.com/icon1.ico', + question: 'question 1', + info: 'info 1', + aliases: ['key-1', 'key_1'], + metadata: {}, + createdBy: 1, + updatedBy: 1, + }, + ])) + .then(() => models.Form.create({ + key: 'test', + config: { + test: 'test1', + }, + version: 1, + revision: 1, + createdBy: 1, + updatedBy: 1, + })) + .then(() => models.PlanConfig.create({ + key: 'test', + config: { + test: 'test1', + }, + version: 1, + revision: 1, + createdBy: 1, + updatedBy: 1, + })) + .then(() => models.PriceConfig.create({ + key: 'test', + config: { + test: 'test1', + }, + version: 1, + revision: 1, + createdBy: 1, + updatedBy: 1, + })), + ); describe('POST /projects/metadata/projectTemplates', () => { const body = { @@ -80,34 +108,29 @@ describe('CREATE project template', () => { disabled: true, hidden: true, form: { - scope1: { - subScope1A: 1, - subScope1B: 2, - }, - scope2: [1, 2, 3], + key: 'test', + version: 1, }, priceConfig: { - first: '$800', + key: 'test', }, planConfig: { - phase1: { - name: 'phase 1', - details: { - anyDetails: 'any details 1', - }, - others: ['others 11', 'others 12'], - }, - phase2: { - name: 'phase 2', - details: { - anyDetails: 'any details 2', - }, - others: ['others 21', 'others 22'], - }, + key: 'test', }, }, }; + const bodyDefinedFormScope = _.cloneDeep(body); + bodyDefinedFormScope.param.form = { + scope1: { + subScope1A: 1, + subScope1B: 2, + }, + scope2: [1, 2, 3], + }; + const bodyMissingFormScope = _.cloneDeep(body); + delete bodyMissingFormScope.param.scope; + it('should return 403 if user is not authenticated', (done) => { request(server) .post('/v4/projects/metadata/projectTemplates') @@ -270,5 +293,27 @@ describe('CREATE project template', () => { done(); }); }); + + it('should return 422 if both scope and form are defined', (done) => { + request(server) + .post('/v4/projects/metadata/projectTemplates') + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .send(bodyDefinedFormScope) + .expect('Content-Type', /json/) + .expect(422, done); + }); + + it('should return 422 if both scope and form are missing', (done) => { + request(server) + .post('/v4/projects/metadata/projectTemplates') + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .send(bodyMissingFormScope) + .expect('Content-Type', /json/) + .expect(422, done); + }); }); }); diff --git a/src/routes/projectTemplates/update.js b/src/routes/projectTemplates/update.js index 4991a310..00384a1c 100644 --- a/src/routes/projectTemplates/update.js +++ b/src/routes/projectTemplates/update.js @@ -25,11 +25,20 @@ const schema = { question: Joi.string().max(255), info: Joi.string().max(255), aliases: Joi.array(), - scope: Joi.object().optional().allow(null), - phases: Joi.object().optional().allow(null), - form: Joi.object().optional().allow(null), - planConfig: Joi.object().optional().allow(null), - priceConfig: Joi.object().optional().allow(null), + scope: Joi.object().empty(null), + phases: Joi.object().empty(null), + form: Joi.object().keys({ + key: Joi.string().required(), + version: Joi.number(), + }).empty(null), + planConfig: Joi.object().keys({ + key: Joi.string().required(), + version: Joi.number(), + }).empty(null), + priceConfig: Joi.object().keys({ + key: Joi.string().required(), + version: Joi.number(), + }).empty(null), disabled: Joi.boolean().optional(), hidden: Joi.boolean().optional(), createdAt: Joi.any().strip(), @@ -38,7 +47,11 @@ const schema = { createdBy: Joi.any().strip(), updatedBy: Joi.any().strip(), deletedBy: Joi.any().strip(), - }).required(), + }) + .xor('form', 'scope') + .xor('phases', 'planConfig') + .nand('priceConfig', 'scope') + .required(), }, }; @@ -50,54 +63,12 @@ module.exports = [ const param = req.body.param; const { form, priceConfig, planConfig } = param; - const checkModel = (keyInfo, modelName, model) => { - let errorMessage = ''; - if (keyInfo == null) { - return Promise.resolve(null); - } - if ((keyInfo.version != null) && (keyInfo.key != null)) { - errorMessage = `${modelName} with key ${keyInfo.key} and version ${keyInfo.version}` - + ' referred in the project template is not found'; - return (model.findOne({ - where: { - key: keyInfo.key, - version: keyInfo.version, - }, - })).then((record) => { - if (record == null) { - return Promise.resolve(errorMessage); - } - return Promise.resolve(null); - }); - } else if ((keyInfo.version == null) && (keyInfo.key != null)) { - errorMessage = `${modelName} with key ${keyInfo.key}` - + ' referred in the project template is not found'; - return model.findOne({ - where: { - key: keyInfo.key, - }, - }).then((record) => { - if (record == null) { - return Promise.resolve(errorMessage); - } - return Promise.resolve(null); - }); - } - return Promise.resolve(null); - }; - return Promise.all([ - checkModel(form, 'Form', models.Form, next), - checkModel(priceConfig, 'PriceConfig', models.PriceConfig, next), - checkModel(planConfig, 'PlanConfig', models.PlanConfig, next), + util.checkModel(form, 'Form', models.Form, 'project template'), + util.checkModel(priceConfig, 'PriceConfig', models.PriceConfig, 'project template'), + util.checkModel(planConfig, 'PlanConfig', models.PlanConfig, 'project template'), ]) - .then((errorMessages) => { - const errorMessage = errorMessages.find(e => e && e.length > 0); - if (errorMessage) { - const apiErr = new Error(errorMessage); - apiErr.status = 422; - throw apiErr; - } + .then(() => { const entityToUpdate = _.assign(req.body.param, { updatedBy: req.authUser.userId, }); @@ -115,7 +86,7 @@ module.exports = [ if (!projectTemplate) { const apiErr = new Error(`Project template not found for template id ${req.params.templateId}`); apiErr.status = 404; - return Promise.reject(apiErr); + throw apiErr; } // Merge JSON fields @@ -132,9 +103,7 @@ module.exports = [ }) .then((projectTemplate) => { res.json(util.wrapResponse(req.id, projectTemplate)); - return Promise.resolve(); - }) - .catch(next); + }); }).catch(next); }, ]; diff --git a/src/routes/projectTemplates/update.spec.js b/src/routes/projectTemplates/update.spec.js index f62a8252..22854cf4 100644 --- a/src/routes/projectTemplates/update.spec.js +++ b/src/routes/projectTemplates/update.spec.js @@ -2,6 +2,7 @@ * Tests for get.js */ import chai from 'chai'; +import _ from 'lodash'; import request from 'supertest'; import models from '../../models'; @@ -80,7 +81,37 @@ describe('UPDATE project template', () => { .then((createdTemplate) => { templateId = createdTemplate.id; return Promise.resolve(); - }), + }) + .then(() => models.Form.create({ + key: 'test', + config: { + test: 'test1', + }, + version: 1, + revision: 1, + createdBy: 1, + updatedBy: 1, + })) + .then(() => models.PlanConfig.create({ + key: 'test', + config: { + test: 'test1', + }, + version: 1, + revision: 1, + createdBy: 1, + updatedBy: 1, + })) + .then(() => models.PriceConfig.create({ + key: 'test', + config: { + test: 'test1', + }, + version: 1, + revision: 1, + createdBy: 1, + updatedBy: 1, + })), ); after(testUtil.clearDb); @@ -130,34 +161,29 @@ describe('UPDATE project template', () => { disabled: true, hidden: true, form: { - scope1: { - subScope1A: 1, - subScope1B: 2, - }, - scope2: [1, 2, 3], + key: 'test', + version: 1, }, priceConfig: { - first: '$800', + key: 'test', }, planConfig: { - phase1: { - name: 'phase 1', - details: { - anyDetails: 'any details 1', - }, - others: ['others 11', 'others 12'], - }, - phase2: { - name: 'phase 2', - details: { - anyDetails: 'any details 2', - }, - others: ['others 21', 'others 22'], - }, + key: 'test', }, }, }; + const bodyDefinedFormScope = _.cloneDeep(body); + bodyDefinedFormScope.param.form = { + scope1: { + subScope1A: 1, + subScope1B: 2, + }, + scope2: [1, 2, 3], + }; + const bodyMissingFormScope = _.cloneDeep(body); + delete bodyMissingFormScope.param.scope; + it('should return 403 if user is not authenticated', (done) => { request(server) .patch(`/v4/projects/metadata/projectTemplates/${templateId}`) @@ -336,5 +362,25 @@ describe('UPDATE project template', () => { .expect(200) .end(done); }); + + it('should return 422 if both scope and form are defined', (done) => { + request(server) + .patch(`/v4/projects/metadata/projectTemplates/${templateId}`) + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .send(bodyDefinedFormScope) + .expect(422, done); + }); + + it('should return 422 if both scope and form are missing', (done) => { + request(server) + .patch(`/v4/projects/metadata/projectTemplates/${templateId}`) + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .send(bodyMissingFormScope) + .expect(422, done); + }); }); }); diff --git a/src/routes/projectTemplates/upgrade.js b/src/routes/projectTemplates/upgrade.js index f1eb4625..c6706b80 100644 --- a/src/routes/projectTemplates/upgrade.js +++ b/src/routes/projectTemplates/upgrade.js @@ -44,37 +44,14 @@ module.exports = [ if (pt == null) { const apiErr = new Error(`project template not found for id ${req.body.param.templateId}`); apiErr.status = 404; - return Promise.reject(apiErr); + throw apiErr; } if ((pt.scope == null) || (pt.phases == null)) { const apiErr = new Error('Current project template\'s scope or phases is null'); apiErr.status = 422; - return Promise.reject(apiErr); + throw apiErr; } - const checkModel = (keyInfo, modelName, model) => { - let errorMessage = ''; - errorMessage = `${modelName} with key ${keyInfo.key} and version ${keyInfo.version}` - + ' referred in param is not found'; - return (model.findOne({ - where: { - key: keyInfo.key, - version: keyInfo.version, - }, - })).then((record) => { - if (record == null) { - return Promise.resolve(errorMessage); - } - return Promise.resolve(null); - }); - }; - - const reportError = (errorMessage) => { - const apiErr = new Error(errorMessage); - apiErr.status = 422; - return Promise.reject(apiErr).catch(next); - }; - // get form field let newForm = {}; if (req.body.param.form == null) { @@ -90,10 +67,7 @@ module.exports = [ }; } else { newForm = req.body.param.form; - const err = await checkModel(newForm, 'Form', models.Form); - if (err != null) { - reportError(err); - } + await util.checkModel(newForm, 'Form', models.Form, 'project template'); } // get price config field let newPriceConfig = {}; @@ -111,10 +85,7 @@ module.exports = [ }; } else { newPriceConfig = req.body.param.priceConfig; - const err = await checkModel(newPriceConfig, 'PriceConfig', models.PriceConfig); - if (err != null) { - reportError(err); - } + await util.checkModel(newPriceConfig, 'PriceConfig', models.PriceConfig, 'project template'); } // get plan config field let newPlanConfig = {}; @@ -126,10 +97,7 @@ module.exports = [ }; } else { newPlanConfig = req.body.param.planConfig; - const err = await checkModel(newPlanConfig, 'PlanConfig', models.PlanConfig); - if (err != null) { - reportError(err); - } + await util.checkModel(newPlanConfig, 'PlanConfig', models.PlanConfig, 'project template'); } const updateInfo = { diff --git a/src/routes/projects/create.js b/src/routes/projects/create.js index 61a9e299..ecab51b4 100644 --- a/src/routes/projects/create.js +++ b/src/routes/projects/create.js @@ -57,6 +57,14 @@ const createProjectValdiations = { })).allow(null), templateId: Joi.number().integer().positive(), version: Joi.string(), + estimation: Joi.array().items(Joi.object().keys({ + conditions: Joi.string().required(), + price: Joi.number().required(), + minTime: Joi.number().integer().required(), + maxTime: Joi.number().integer().required(), + buildingBlockKey: Joi.string().required(), + metadata: Joi.object().optional(), + })).optional(), }).required(), }, }; @@ -81,6 +89,21 @@ function createProjectAndPhases(req, project, projectTemplate, productTemplates) model: models.ProjectMember, as: 'members', }], + }).then((newProject) => { + if (project.estimation && (project.estimation.length > 0)) { + req.log.debug('creating project estimation'); + const estimations = project.estimation.map(estimation => Object.assign({ + projectId: newProject.id, + createdBy: req.authUser.userId, + updatedBy: req.authUser.userId, + }, estimation)); + return models.ProjectEstimation.bulkCreate(estimations, { returning: true }).then((projectEstimations) => { + result.estimations = _.map(projectEstimations, estimation => + _.omit(estimation.toJSON(), ['deletedAt', 'deletedBy'])); + return Promise.resolve(newProject); + }); + } + return Promise.resolve(newProject); }).then((newProject) => { result.newProject = newProject; @@ -212,7 +235,10 @@ module.exports = [ utm: null, }); traverse(project).forEach(function (x) { // eslint-disable-line func-names - if (this.isLeaf && typeof x === 'string') this.update(req.sanitize(x)); + // keep the raw '&&' string in conditions string in estimation + const isEstimationCondition = + (this.path.length === 3) && (this.path[0] === 'estimation') && (this.key === 'conditions'); + if (this.isLeaf && typeof x === 'string' && (!isEstimationCondition)) this.update(req.sanitize(x)); }); // override values _.assign(project, { @@ -235,6 +261,7 @@ module.exports = [ } let newProject = null; let newPhases; + let projectEstimations; models.sequelize.transaction(() => { req.log.debug('Create Project - Starting transaction'); // Validate the templates @@ -247,6 +274,7 @@ module.exports = [ .then((createdProjectAndPhases) => { newProject = createdProjectAndPhases.newProject; newPhases = createdProjectAndPhases.newPhases; + projectEstimations = createdProjectAndPhases.estimations; req.log.debug('new project created (id# %d, name: %s)', newProject.id, newProject.name); // create direct project with name and description @@ -291,6 +319,10 @@ module.exports = [ newProject.attachments = []; // set phases array newProject.phases = newPhases; + // sets estimations array + if (projectEstimations) { + newProject.estimations = projectEstimations; + } req.log.debug('Sending event to RabbitMQ bus for project %d', newProject.id); req.app.services.pubsub.publish(EVENT.ROUTING_KEY.PROJECT_DRAFT_CREATED, diff --git a/src/routes/projects/create.spec.js b/src/routes/projects/create.spec.js index e8807e38..ef057d35 100644 --- a/src/routes/projects/create.spec.js +++ b/src/routes/projects/create.spec.js @@ -262,6 +262,23 @@ describe('Project create', () => { .expect(422, done); }); + it('should return 422 with wrong format estimation field', (done) => { + const invalidBody = _.cloneDeep(body); + invalidBody.param.estimation = [ + { + + }, + ]; + request(server) + .post('/v4/projects') + .set({ + Authorization: `Bearer ${testUtil.jwts.member}`, + }) + .send(invalidBody) + .expect('Content-Type', /json/) + .expect(422, done); + }); + it('should return 201 if error to create direct project', (done) => { const validBody = _.cloneDeep(body); validBody.param.templateId = 3; @@ -469,6 +486,143 @@ describe('Project create', () => { }); }); + it('should return 201 if valid user and data (with estimation)', (done) => { + const validBody = _.cloneDeep(body); + validBody.param.estimation = [ + { + conditions: '( HAS_DESIGN_DELIVERABLE && HAS_ZEPLIN_APP_ADDON && CA_NEEDED)', + price: 6, + minTime: 2, + maxTime: 2, + metadata: { + deliverable: 'design', + }, + buildingBlockKey: 'ZEPLIN_APP_ADDON_CA', + }, + { + conditions: '( HAS_DESIGN_DELIVERABLE && COMPREHENSIVE_DESIGN && TWO_TARGET_DEVICES' + + ' && SCREENS_COUNT_SMALL && CA_NEEDED )', + price: 95, + minTime: 14, + maxTime: 14, + metadata: { + deliverable: 'design', + }, + buildingBlockKey: 'SMALL_COMP_DESIGN_TWO_DEVICE_CA', + }, + { + conditions: '( HAS_DEV_DELIVERABLE && (ONLY_ONE_OS_MOBILE || ONLY_ONE_OS_DESKTOP' + + ' || ONLY_ONE_OS_PROGRESSIVE) && SCREENS_COUNT_SMALL && CA_NEEDED)', + price: 50, + minTime: 35, + maxTime: 35, + metadata: { + deliverable: 'dev-qa', + }, + buildingBlockKey: 'SMALL_DEV_ONE_OS_CA', + }, + { + conditions: '( HAS_DEV_DELIVERABLE && HAS_SSO_INTEGRATION_ADDON && CA_NEEDED)', + price: 80, + minTime: 5, + maxTime: 5, + metadata: { + deliverable: 'dev-qa', + }, + buildingBlockKey: 'HAS_SSO_INTEGRATION_ADDON_CA', + }, + { + conditions: '( HAS_DEV_DELIVERABLE && HAS_CHECKMARX_SCANNING_ADDON && CA_NEEDED)', + price: 4, + minTime: 10, + maxTime: 10, + metadata: { + deliverable: 'dev-qa', + }, + buildingBlockKey: 'HAS_CHECKMARX_SCANNING_ADDON_CA', + }, + { + conditions: '( HAS_DEV_DELIVERABLE && HAS_UNIT_TESTING_ADDON && CA_NEEDED)', + price: 90, + minTime: 12, + maxTime: 12, + metadata: { + deliverable: 'dev-qa', + }, + buildingBlockKey: 'HAS_UNIT_TESTING_ADDON_CA', + }, + ]; + 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.billingAccountId); + should.exist(resJson.name); + resJson.directProjectId.should.be.eql(128); + resJson.status.should.be.eql('draft'); + resJson.type.should.be.eql(body.param.type); + resJson.version.should.be.eql('v3'); + resJson.members.should.have.lengthOf(1); + resJson.members[0].role.should.be.eql('customer'); + resJson.members[0].userId.should.be.eql(40051331); + resJson.members[0].projectId.should.be.eql(resJson.id); + resJson.members[0].isPrimary.should.be.truthy; + resJson.bookmarks.should.have.lengthOf(1); + resJson.bookmarks[0].title.should.be.eql('title1'); + resJson.bookmarks[0].address.should.be.eql('http://www.address.com'); + // Check that activity fields are set + resJson.lastActivityUserId.should.be.eql('40051331'); + resJson.lastActivityAt.should.be.not.null; + server.services.pubsub.publish.calledWith('project.draft-created').should.be.true; + + // Check new ProjectEstimation records are created. + models.ProjectEstimation.findAll({ + where: { + projectId: resJson.id, + }, + }).then((projectEstimations) => { + projectEstimations.length.should.be.eql(6); + projectEstimations[0].conditions.should.be.eql( + '( HAS_DESIGN_DELIVERABLE && HAS_ZEPLIN_APP_ADDON && CA_NEEDED)'); + projectEstimations[0].price.should.be.eql(6); + projectEstimations[0].minTime.should.be.eql(2); + projectEstimations[0].maxTime.should.be.eql(2); + projectEstimations[0].metadata.deliverable.should.be.eql('design'); + projectEstimations[0].buildingBlockKey.should.be.eql('ZEPLIN_APP_ADDON_CA'); + done(); + }); + } + }); + }); + xit('should return 201 if valid user and data (using Bearer userId_)', (done) => { const mockHttpClient = _.merge(testUtil.mockHttpClient, { post: () => Promise.resolve({ diff --git a/src/services/messageService.js b/src/services/messageService.js index 4ea5f04b..949c0134 100644 --- a/src/services/messageService.js +++ b/src/services/messageService.js @@ -65,12 +65,8 @@ async function getClient(logger) { function createTopic(topic, logger) { logger.debug(`createTopic for topic: ${JSON.stringify(topic)}`); return getClient(logger).then((msgClient) => { - // return util.getSystemUserToken(logger).then((adminToken) => { logger.debug('calling message service'); return msgClient.post('/topics/create', topic) - // const httpClient = util.getHttpClient({ id: `topic#create#${topic.referenceId}`, log: logger }); - // httpClient.defaults.headers.common.Authorization = `Bearer ${adminToken}`; - // return httpClient.post(`${config.get('messageApiUrl')}/topics/create`, topic) .then((resp) => { logger.debug('Topic created successfully'); logger.debug(`Topic created successfully [status]: ${resp.status}`); diff --git a/src/util.js b/src/util.js index 853e71c4..7aa7b731 100644 --- a/src/util.js +++ b/src/util.js @@ -261,20 +261,6 @@ _.assignIn(util, { }); }, - getSystemUserToken: (logger, id = 'system') => { - const httpClient = util.getHttpClient({ id, log: logger }); - const url = `${config.get('identityServiceEndpoint')}authorizations`; - const formData = `clientId=${config.get('systemUserClientId')}&` + - `secret=${encodeURIComponent(config.get('systemUserClientSecret'))}`; - return httpClient.post(url, formData, - { - timeout: 4000, - headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, - }, - ) - .then(res => res.data.result.content.token); - }, - /** * Get machine to machine token. * @returns {Promise} promise which resolves to the m2m token @@ -481,6 +467,56 @@ _.assignIn(util, { * @return {Array} tpcoder project members */ getTopcoderProjectMembers: members => _(members).filter(m => m.role !== PROJECT_MEMBER_ROLE.CUSTOMER), + + /** + * Check if the following model exist + * @param {Object} keyInfo key information, it includes version and key + * @param {String} modelName name of model + * @param {Object} model model that will be checked + * @param {String} referredEntityName entity that referred by this model + * @return {Promise} promise whether the record exists or not + */ + checkModel: (keyInfo, modelName, model, referredEntityName) => { + if (_.isNil(keyInfo)) { + return Promise.resolve(null); + } + + const { version, key } = keyInfo; + let errorMessage = ''; + + if (!_.isNil(version) && !_.isNil(key)) { + errorMessage = `${modelName} with key ${key} and version ${version}` + + ` referred in the ${referredEntityName} is not found`; + return (model.findOne({ + where: { + key, + version, + }, + })).then((record) => { + if (_.isNil(record)) { + const apiErr = new Error(errorMessage); + apiErr.status = 422; + throw apiErr; + } + }); + } else if (_.isNil(version) && !_.isNil(key)) { + errorMessage = `${modelName} with key ${key}` + + ` referred in ${referredEntityName} is not found`; + return (model.findOne({ + where: { + key, + }, + })).then((record) => { + if (_.isNil(record)) { + const apiErr = new Error(errorMessage); + apiErr.status = 422; + throw apiErr; + } + }); + } + + return Promise.resolve(null); + }, }); export default util; diff --git a/swagger.yaml b/swagger.yaml index a7085701..27d458c1 100644 --- a/swagger.yaml +++ b/swagger.yaml @@ -16,7 +16,53 @@ securityDefinitions: name: Authorization in: header paths: - /projects: + '/projects/db': + get: + tags: + - project + operationId: findProjectsDB + security: + - Bearer: [] + description: Retrieve projects that match the filter directly from database + responses: + '200': + description: A list of projects + schema: + $ref: '#/definitions/ProjectListResponse' + '403': + description: No permission or wrong token + schema: + $ref: '#/definitions/ErrorModel' + '422': + description: Invalid input + schema: + $ref: '#/definitions/ErrorModel' + parameters: + - $ref: '#/parameters/offsetParam' + - $ref: '#/parameters/limitParam' + - name: filter + required: true + type: string + in: query + description: | + Url encoded list of Supported filters + - id + - status + - type + - memberOnly + - keyword + - name + - code + - customer + - manager + - name: sort + required: false + description: > + sort projects by status, name, type, createdAt, updatedAt. Default + is createdAt asc + in: query + type: string + '/projects': get: tags: - project @@ -63,6 +109,8 @@ paths: in: query type: string post: + tags: + - project operationId: addProject security: - Bearer: [] @@ -88,6 +136,8 @@ paths: $ref: '#/definitions/ErrorModel' '/projects/{projectId}': get: + tags: + - project description: Retrieve project by id security: - Bearer: [] @@ -120,6 +170,8 @@ paths: allowed. operationId: getProject patch: + tags: + - project operationId: updateProject security: - Bearer: [] @@ -160,6 +212,8 @@ paths: schema: $ref: '#/definitions/ProjectBodyParam' delete: + tags: + - project description: remove an existing project security: - Bearer: [] @@ -178,6 +232,8 @@ paths: $ref: '#/definitions/ErrorModel' '/projects/{projectId}/attachments': post: + tags: + - project description: add a new project attachment security: - Bearer: [] @@ -203,6 +259,8 @@ paths: $ref: '#/definitions/ErrorModel' '/projects/{projectId}/attachments/{id}': patch: + tags: + - project description: Update an existing attachment security: - Bearer: [] @@ -237,6 +295,8 @@ paths: schema: $ref: '#/definitions/ErrorModel' delete: + tags: + - project description: remove an existing attachment security: - Bearer: [] @@ -260,6 +320,8 @@ paths: $ref: '#/definitions/ErrorModel' '/projects/{projectId}/members': post: + tags: + - project description: add a new project member security: - Bearer: [] @@ -285,6 +347,8 @@ paths: $ref: '#/definitions/ErrorModel' '/projects/{projectId}/members/{id}': delete: + tags: + - project description: Delete a project member security: - Bearer: [] @@ -302,6 +366,8 @@ paths: schema: $ref: '#/definitions/ErrorModel' patch: + tags: + - project security: - Bearer: [] description: Support editing project member roles & primary option. @@ -339,6 +405,41 @@ paths: required: true schema: $ref: '#/definitions/UpdateProjectMemberBodyParam' + '/projects/{projectId}/phases/db': + parameters: + - $ref: '#/parameters/projectIdParam' + get: + tags: + - phase + operationId: findProjectPhasesDB + security: + - Bearer: [] + description: >- + Retrieve all project phases directly from database. All users who can edit project can access + this endpoint. + parameters: + - name: fields + required: false + type: string + in: query + description: | + Comma separated list of project phase fields to return. + - name: sort + required: false + description: > + sort project phases by startDate, endDate, status, order. Default is + startDate asc + in: query + type: string + responses: + '200': + description: A list of project phases + schema: + $ref: '#/definitions/ProjectPhaseListResponse' + '403': + description: No permission or wrong token + schema: + $ref: '#/definitions/ErrorModel' '/projects/{projectId}/phases': parameters: - $ref: '#/parameters/projectIdParam' @@ -503,6 +604,28 @@ paths: description: If project is not found schema: $ref: '#/definitions/ErrorModel' + '/projects/{projectId}/phases/{phaseId}/products/db': + parameters: + - $ref: '#/parameters/projectIdParam' + - $ref: '#/parameters/phaseIdParam' + get: + tags: + - phase product + operationId: findPhaseProductsDB + security: + - Bearer: [] + description: >- + Retrieve all phase products directly from database. All users who can edit project can access + this endpoint. + responses: + '200': + description: A list of phase products + schema: + $ref: '#/definitions/PhaseProductListResponse' + '403': + description: No permission or wrong token + schema: + $ref: '#/definitions/ErrorModel' '/projects/{projectId}/phases/{phaseId}/products': parameters: - $ref: '#/parameters/projectIdParam' @@ -825,12 +948,12 @@ paths: description: If project is not found schema: $ref: '#/definitions/ErrorModel' - '/projects/metadata/productTemplates/{templateId}/upgrade': + '/projects/metadata/projectTemplates/{templateId}/upgrade': post: tags: - - productTemplate + - projectTemplate description: >- - upgrade projectTemplate model, + upgrade projectTemplate model security: - Bearer: [] parameters: @@ -842,13 +965,13 @@ paths: $ref: '#/definitions/ProjectTemplateUpgradeBodyParam' responses: '200': - description: Product template successfully upgrade + description: Project template successfully upgrade '403': description: No permission or wrong token schema: $ref: '#/definitions/ErrorModel' '404': - description: If product template is not found + description: If project template is not found schema: $ref: '#/definitions/ErrorModel' '422': @@ -987,6 +1110,40 @@ paths: description: If product is not found schema: $ref: '#/definitions/ErrorModel' + '/projects/metadata/productTemplates/{templateId}/upgrade': + post: + tags: + - productTemplate + description: >- + upgrade productTemplate model + security: + - Bearer: [] + parameters: + - $ref: '#/parameters/templateIdParam' + - in: body + name: body + required: true + schema: + $ref: '#/definitions/ProductTemplateUpgradeBodyParam' + responses: + '200': + description: Product template successfully upgraded + '403': + description: No permission or wrong token + schema: + $ref: '#/definitions/ErrorModel' + '404': + description: If product template is not found + schema: + $ref: '#/definitions/ErrorModel' + '422': + description: Invalid input + schema: + $ref: '#/definitions/ErrorModel' + '500': + description: Server Error + schema: + $ref: '#/definitions/ErrorModel' /projects/metadata/productCategories: get: tags: @@ -3004,6 +3161,32 @@ definitions: data: type: string description: 300 Char length text blob for customer provided data + estimation: + type: array + items: + type: object + required: + - conditions + - price + - maxTime + - minTime + - buildingBlockKey + properties: + conditions: + type: string + price: + type: number + format: float + maxTime: + type: number + format: integer + minTime: + type: integer + format: integer + metadata: + type: object + buildingBlockKey: + type: string type: type: string description: project type @@ -3555,7 +3738,7 @@ definitions: priceConfig: $ref: '#/definitions/VersionModelParam' planConfig: - $ref: '#/definitions/VersionModelParam' + $ref: '#/definitions/VersionModelParam' ProjectTemplateUpgradeBodyParam: title: Project template type: object @@ -3564,11 +3747,20 @@ definitions: type: object properties: form: - $ref: '#/definitions/VersionModelParam' + $ref: '#/definitions/VersionModelParam' priceConfig: - $ref: '#/definitions/VersionModelParam' + $ref: '#/definitions/VersionModelParam' planConfig: - $ref: '#/definitions/VersionModelParam' + $ref: '#/definitions/VersionModelParam' + ProductTemplateUpgradeBodyParam: + title: Product template + type: object + properties: + param: + type: object + properties: + form: + $ref: '#/definitions/VersionModelParam' VersionModelParam: title: version model param type: object @@ -3703,6 +3895,8 @@ definitions: template: type: object description: the product template template + form: + $ref: '#/definitions/VersionModelParam' isAddOn: type: boolean description: the flag that shows if the product template is an add on