diff --git a/.circleci/config.yml b/.circleci/config.yml index 83db606d..c3db4502 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -76,7 +76,7 @@ workflows: - test filters: branches: - only: 'dev' + only: ['dev', 'feature/dev-challenges'] - deployProd: requires: - test diff --git a/README.md b/README.md index 2a18f10c..e2e5a707 100644 --- a/README.md +++ b/README.md @@ -71,7 +71,7 @@ Run image: `docker run -p 3000:3000 -i -t -e DB_HOST=172.17.0.1 tc_projects_services` You may replace 172.17.0.1 with your docker0 IP. -You can paste **swagger.yaml** to [swagger editor](http://editor.swagger.io/) or import **postman.json** to verify endpoints. +You can paste **swagger.yaml** to [swagger editor](http://editor.swagger.io/) or import **postman.json** and **postman_environment.json** to verify endpoints. #### Deploying without docker If you don't want to use docker to deploy to localhost. You can simply run `npm run start` from root of project. This should start the server on default port `3000`. diff --git a/config/custom-environment-variables.json b/config/custom-environment-variables.json index 07c7936f..6b5731a7 100644 --- a/config/custom-environment-variables.json +++ b/config/custom-environment-variables.json @@ -31,6 +31,7 @@ "VALID_ISSUERS": "VALID_ISSUERS", "jwksUri": "JWKS_URI", "busApiUrl": "BUS_API_URL", + "messageApiUrl": "MESSAGE_SERVICE_URL", "AUTH0_URL" : "AUTH0_URL", "AUTH0_CLIENT_ID": "AUTH0_CLIENT_ID", "AUTH0_CLIENT_SECRET": "AUTH0_CLIENT_SECRET", diff --git a/config/default.json b/config/default.json index 1db936d4..053f0c35 100644 --- a/config/default.json +++ b/config/default.json @@ -33,8 +33,13 @@ }, "analyticsKey": "", "VALID_ISSUERS": "[\"https:\/\/topcoder-newauth.auth0.com\/\",\"https:\/\/api.topcoder-dev.com\"]", - "busApiUrl": "http://api.topcoder-dev.com", + "validIssuers": "[\"https:\/\/topcoder-newauth.auth0.com\/\",\"https:\/\/api.topcoder-dev.com\"]", + "jwksUri": "", + "busApiUrl": "http://api.topcoder-dev.com/v5", + "messageApiUrl": "http://api.topcoder-dev.com/v5", + "busApiToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJuYW1lIjoicHJvamVjdC1zZXJ2aWNlIiwiaWF0IjoxNTEyNzQ3MDgyLCJleHAiOjE1MjEzODcwODJ9.PHuNcFDaotGAL8RhQXQMdpL8yOKXxjB5DbBIodmt7RE", "HEALTH_CHECK_URL": "_health", + "maxPhaseProductCount": 1, "AUTH0_CLIENT_ID": "", "AUTH0_CLIENT_SECRET": "", "AUTH0_AUDIENCE": "", diff --git a/local/mock-services/authMiddleware.js b/local/mock-services/authMiddleware.js index f7939a67..9cf0387c 100644 --- a/local/mock-services/authMiddleware.js +++ b/local/mock-services/authMiddleware.js @@ -1,5 +1,5 @@ module.exports = function def(req, res, next) { - if (req.method === 'POST' && req.url === '/authorizations/') { + if (req.method === 'POST' && (req.url === '/authorizations/' || req.url === '/authorizations')) { const resp = { id: '1', result: { diff --git a/local/mock-services/server.js b/local/mock-services/server.js index 0a5ca5fd..029df5b7 100644 --- a/local/mock-services/server.js +++ b/local/mock-services/server.js @@ -23,9 +23,10 @@ server.use(authMiddleware); // add additional search route for project members server.get('/v3/members/_search', (req, res) => { const fields = _.isString(req.query.fields) ? req.query.fields.split(',') : []; - const filter = _.isString(req.query.query) ? req.query.query.split(' OR ') : []; + const filter = _.isString(req.query.query) ? + req.query.query.replace('%2520', ' ').replace('%20', ' ').split(' OR ') : []; const criteria = _.map(filter, (single) => { - const ret = { }; + const ret = {}; const splitted = single.split(':'); // if the result can be parsed successfully const parsed = jsprim.parseInteger(splitted[1], { allowTrailing: true, trimWhitespace: true }); diff --git a/local/mock-services/services.json b/local/mock-services/services.json index e391a9b9..87ef7863 100644 --- a/local/mock-services/services.json +++ b/local/mock-services/services.json @@ -99,6 +99,156 @@ } }, "version": "v3" + }, + { + "id": "test_customer1", + "result": { + "success": true, + "status": 200, + "metadata": null, + "content": { + "maxRating": { + "rating": 1114, + "track": "DATA_SCIENCE", + "subTrack": "SRM" + }, + "createdBy": "40011578", + "updatedBy": "40011578", + "userId": 40051331, + "firstName": "Firstname", + "lastName": "Lastname", + "quote": "It is a mistake to think you can solve any major problems just with potatoes.", + "description": null, + "otherLangName": null, + "handle": "test_customer1", + "handleLower": "test_customer1", + "status": "ACTIVE", + "email": "test_customer1@email.com", + "addresses": [ + { + "streetAddr1": "100 Main Street", + "streetAddr2": "", + "city": "Chicago", + "zip": "60601", + "stateCode": "IL", + "type": "HOME", + "updatedAt": null, + "createdAt": null, + "createdBy": null, + "updatedBy": null + } + ], + "homeCountryCode": "USA", + "competitionCountryCode": "USA", + "photoURL": null, + "tracks": [ + "DEVELOP" + ], + "updatedAt": "2015-12-02T14:00Z", + "createdAt": "2014-04-10T10:55Z" + } + }, + "version": "v3" + }, + { + "id": "test_copilot1", + "result": { + "success": true, + "status": 200, + "metadata": null, + "content": { + "maxRating": { + "rating": 1114, + "track": "DATA_SCIENCE", + "subTrack": "SRM" + }, + "createdBy": "40011578", + "updatedBy": "40011578", + "userId": 40051332, + "firstName": "Firstname", + "lastName": "Lastname", + "quote": "It is a mistake to think you can solve any major problems just with potatoes.", + "description": null, + "otherLangName": null, + "handle": "test_copilot1", + "handleLower": "test_copilot1", + "status": "ACTIVE", + "email": "test_copilot1@email.com", + "addresses": [ + { + "streetAddr1": "100 Main Street", + "streetAddr2": "", + "city": "Chicago", + "zip": "60601", + "stateCode": "IL", + "type": "HOME", + "updatedAt": null, + "createdAt": null, + "createdBy": null, + "updatedBy": null + } + ], + "homeCountryCode": "USA", + "competitionCountryCode": "USA", + "photoURL": null, + "tracks": [ + "DEVELOP" + ], + "updatedAt": "2015-12-02T14:00Z", + "createdAt": "2014-04-10T10:55Z" + } + }, + "version": "v3" + }, + { + "id": "test_manager1", + "result": { + "success": true, + "status": 200, + "metadata": null, + "content": { + "maxRating": { + "rating": 1114, + "track": "DATA_SCIENCE", + "subTrack": "SRM" + }, + "createdBy": "40011578", + "updatedBy": "40011578", + "userId": 40051333, + "firstName": "Firstname", + "lastName": "Lastname", + "quote": "It is a mistake to think you can solve any major problems just with potatoes.", + "description": null, + "otherLangName": null, + "handle": "test_manager1", + "handleLower": "test_manager1", + "status": "ACTIVE", + "email": "test_manager1@email.com", + "addresses": [ + { + "streetAddr1": "100 Main Street", + "streetAddr2": "", + "city": "Chicago", + "zip": "60601", + "stateCode": "IL", + "type": "HOME", + "updatedAt": null, + "createdAt": null, + "createdBy": null, + "updatedBy": null + } + ], + "homeCountryCode": "USA", + "competitionCountryCode": "USA", + "photoURL": null, + "tracks": [ + "DEVELOP" + ], + "updatedAt": "2015-12-02T14:00Z", + "createdAt": "2014-04-10T10:55Z" + } + }, + "version": "v3" } ] } diff --git a/migrations/20180608_project_add_templateId_and_new_tables.sql b/migrations/20180608_project_add_templateId_and_new_tables.sql new file mode 100644 index 00000000..c6409077 --- /dev/null +++ b/migrations/20180608_project_add_templateId_and_new_tables.sql @@ -0,0 +1,321 @@ +-- +-- UPDATE EXISTING TABLES: +-- projects +-- templateId column: added +-- version column: added +-- CREATE NEW TABLES: +-- milestones +-- phase_products +-- product_milestone_templates +-- product_templates +-- project_phases +-- project_templates +-- project_types +-- timelines +-- + +-- +-- projects +-- +ALTER TABLE projects ADD COLUMN "templateId" bigint; + +-- make sure to update existing projects to have this field set to "v2" +ALTER TABLE projects ADD COLUMN "version" varchar(3) NOT NULL DEFAULT 'v2'; +-- make sure new projects from now on have "v3" as default value +ALTER TABLE projects ALTER COLUMN "version" SET DEFAULT 'v3'; + +-- +-- milestones +-- + +CREATE TABLE milestones ( + id bigint NOT NULL, + name character varying(255) NOT NULL, + description character varying(255), + duration integer NOT NULL, + "startDate" timestamp with time zone NOT NULL, + "endDate" timestamp with time zone, + "completionDate" timestamp with time zone, + status character varying(45) NOT NULL, + type character varying(45) NOT NULL, + details json, + "order" integer NOT NULL, + "plannedText" character varying(512) NOT NULL, + "activeText" character varying(512) NOT NULL, + "completedText" character varying(512) NOT NULL, + "blockedText" character varying(512) NOT NULL, + "deletedAt" timestamp with time zone, + "createdAt" timestamp with time zone, + "updatedAt" timestamp with time zone, + "deletedBy" bigint, + "createdBy" bigint NOT NULL, + "updatedBy" bigint NOT NULL, + "timelineId" bigint +); + +CREATE SEQUENCE milestones_id_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + +ALTER SEQUENCE milestones_id_seq OWNED BY milestones.id; + + +-- +-- phase_products +-- + +CREATE TABLE phase_products ( + id bigint NOT NULL, + name character varying(255), + "projectId" bigint, + "directProjectId" bigint, + "billingAccountId" bigint, + "templateId" bigint DEFAULT 0, + type character varying(255), + "estimatedPrice" double precision DEFAULT 0, + "actualPrice" double precision DEFAULT 0, + details json DEFAULT '{}'::json, + "deletedAt" timestamp with time zone, + "createdAt" timestamp with time zone, + "updatedAt" timestamp with time zone, + "deletedBy" integer, + "createdBy" integer NOT NULL, + "updatedBy" integer NOT NULL, + "phaseId" bigint +); + + +CREATE SEQUENCE phase_products_id_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + +ALTER SEQUENCE phase_products_id_seq OWNED BY phase_products.id; + +-- +-- product_milestone_templates +-- + +CREATE TABLE product_milestone_templates ( + id bigint NOT NULL, + name character varying(255) NOT NULL, + description character varying(255), + duration integer NOT NULL, + type character varying(45) NOT NULL, + "order" integer NOT NULL, + "deletedAt" timestamp with time zone, + "createdAt" timestamp with time zone, + "updatedAt" timestamp with time zone, + "deletedBy" bigint, + "createdBy" bigint NOT NULL, + "updatedBy" bigint NOT NULL, + "productTemplateId" bigint +); + +CREATE SEQUENCE product_milestone_templates_id_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + +ALTER SEQUENCE product_milestone_templates_id_seq OWNED BY product_milestone_templates.id; + + +-- +-- product_templates +-- +CREATE TABLE product_templates ( + id bigint NOT NULL, + name character varying(255) NOT NULL, + "productKey" character varying(45) NOT NULL, + icon character varying(255) NOT NULL, + brief character varying(45) NOT NULL, + details character varying(255) NOT NULL, + aliases json NOT NULL, + template json NOT NULL, + disabled: boolean DEFAULT false, + hidden: boolean DEFAULT false, + "deletedAt" timestamp with time zone, + "createdAt" timestamp with time zone, + "updatedAt" timestamp with time zone, + "deletedBy" bigint, + "createdBy" bigint NOT NULL, + "updatedBy" bigint NOT NULL +); + +CREATE SEQUENCE product_templates_id_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + +ALTER SEQUENCE product_templates_id_seq OWNED BY product_templates.id; + +-- +-- project_phases +-- + +CREATE TABLE project_phases ( + id bigint NOT NULL, + name character varying(255), + status character varying(255), + "startDate" timestamp with time zone, + "endDate" timestamp with time zone, + duration integer, + budget double precision DEFAULT 0, + "spentBudget" double precision DEFAULT 0, + progress double precision DEFAULT 0, + details json DEFAULT '{}'::json, + "deletedAt" timestamp with time zone, + "createdAt" timestamp with time zone, + "updatedAt" timestamp with time zone, + "deletedBy" integer, + "createdBy" integer NOT NULL, + "updatedBy" integer NOT NULL, + "projectId" bigint +); + +CREATE SEQUENCE project_phases_id_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + +ALTER SEQUENCE project_phases_id_seq OWNED BY project_phases.id; + + +-- +-- project_templates +-- +CREATE TABLE project_templates ( + id bigint NOT NULL, + name character varying(255) NOT NULL, + key character varying(45) NOT NULL, + category character varying(45) NOT NULL, + icon character varying(255) NOT NULL, + question character varying(255) NOT NULL, + info character varying(255) NOT NULL, + aliases json NOT NULL, + scope json NOT NULL, + phases json NOT NULL, + disabled: boolean DEFAULT false, + hidden: boolean DEFAULT false, + "deletedAt" timestamp with time zone, + "createdAt" timestamp with time zone, + "updatedAt" timestamp with time zone, + "deletedBy" bigint, + "createdBy" bigint NOT NULL, + "updatedBy" bigint NOT NULL +); + +CREATE SEQUENCE project_templates_id_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + +ALTER SEQUENCE project_templates_id_seq OWNED BY project_templates.id; + +-- +-- project_types +-- + +CREATE TABLE project_types ( + key character varying(45) NOT NULL, + "displayName" character varying(255) NOT NULL, + "deletedAt" timestamp with time zone, + "createdAt" timestamp with time zone, + "updatedAt" timestamp with time zone, + "deletedBy" integer, + "createdBy" integer NOT NULL, + "updatedBy" integer NOT NULL +); + +-- +-- timelines +-- +CREATE TABLE timelines ( + id bigint NOT NULL, + name character varying(255) NOT NULL, + description character varying(255), + "startDate" timestamp with time zone NOT NULL, + "endDate" timestamp with time zone, + reference character varying(45) NOT NULL, + "referenceId" bigint NOT NULL, + "deletedAt" timestamp with time zone, + "createdAt" timestamp with time zone, + "updatedAt" timestamp with time zone, + "deletedBy" bigint, + "createdBy" bigint NOT NULL, + "updatedBy" bigint NOT NULL +); + +CREATE SEQUENCE timelines_id_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + +ALTER SEQUENCE timelines_id_seq OWNED BY timelines.id; + + +ALTER TABLE ONLY milestones ALTER COLUMN id SET DEFAULT nextval('milestones_id_seq'::regclass); + +ALTER TABLE ONLY phase_products ALTER COLUMN id SET DEFAULT nextval('phase_products_id_seq'::regclass); + +ALTER TABLE ONLY product_milestone_templates ALTER COLUMN id SET DEFAULT nextval('product_milestone_templates_id_seq'::regclass); + +ALTER TABLE ONLY product_templates ALTER COLUMN id SET DEFAULT nextval('product_templates_id_seq'::regclass); + +ALTER TABLE ONLY project_phases ALTER COLUMN id SET DEFAULT nextval('project_phases_id_seq'::regclass); + +ALTER TABLE ONLY project_templates ALTER COLUMN id SET DEFAULT nextval('project_templates_id_seq'::regclass); + +ALTER TABLE ONLY timelines ALTER COLUMN id SET DEFAULT nextval('timelines_id_seq'::regclass); + +ALTER TABLE ONLY milestones + ADD CONSTRAINT milestones_pkey PRIMARY KEY (id); + +ALTER TABLE ONLY phase_products + ADD CONSTRAINT phase_products_pkey PRIMARY KEY (id); + +ALTER TABLE ONLY product_milestone_templates + ADD CONSTRAINT product_milestone_templates_pkey PRIMARY KEY (id); + +ALTER TABLE ONLY product_templates + ADD CONSTRAINT product_templates_pkey PRIMARY KEY (id); + +ALTER TABLE ONLY project_phases + ADD CONSTRAINT project_phases_pkey PRIMARY KEY (id); + +ALTER TABLE ONLY project_templates + ADD CONSTRAINT project_templates_pkey PRIMARY KEY (id); + +ALTER TABLE ONLY project_types + ADD CONSTRAINT project_types_pkey PRIMARY KEY (key); + +ALTER TABLE ONLY timelines + ADD CONSTRAINT timelines_pkey PRIMARY KEY (id); + +ALTER TABLE ONLY milestones + ADD CONSTRAINT "milestones_timelineId_fkey" FOREIGN KEY ("timelineId") REFERENCES timelines(id) ON UPDATE CASCADE ON DELETE CASCADE; + +ALTER TABLE ONLY phase_products + ADD CONSTRAINT "phase_products_phaseId_fkey" FOREIGN KEY ("phaseId") REFERENCES project_phases(id) ON UPDATE CASCADE ON DELETE SET NULL; + + +ALTER TABLE ONLY product_milestone_templates + ADD CONSTRAINT "product_milestone_templates_productTemplateId_fkey" FOREIGN KEY ("productTemplateId") REFERENCES product_templates(id) ON UPDATE CASCADE ON DELETE CASCADE; + +ALTER TABLE ONLY project_phases + ADD CONSTRAINT "project_phases_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES projects(id) ON UPDATE CASCADE ON DELETE SET NULL; diff --git a/migrations/elasticsearch_sync.js b/migrations/elasticsearch_sync.js index 1c9e5713..321f86cb 100644 --- a/migrations/elasticsearch_sync.js +++ b/migrations/elasticsearch_sync.js @@ -293,6 +293,10 @@ function getRequestBody(indexName) { }, }, }, + phases: { + type: 'nested', + dynamic: true, + }, }, }; switch (indexName) { diff --git a/migrations/seedElasticsearchIndex.js b/migrations/seedElasticsearchIndex.js index 7752efeb..4a10ec48 100644 --- a/migrations/seedElasticsearchIndex.js +++ b/migrations/seedElasticsearchIndex.js @@ -33,15 +33,21 @@ Promise.coroutine(function* wrapped() { config.get('pubsubQueueName'), ); - const projectIds = getProjectIds(); const projectWhereClause = (projectIds.length > 0) ? { id: { $in: projectIds } } : { deletedAt: { $eq: null } }; - const projects = yield models.Project.findAll({ + let projects = yield models.Project.findAll({ where: projectWhereClause, - raw: true, + include: [{ + model: models.ProjectPhase, + as: 'phases', + include: [{ model: models.PhaseProduct, as: 'products' }], + }], }); logger.info(`Retrieved #${projects.length} projects`); + // Convert to raw json + projects = _.map(projects, project => project.toJSON()); + const memberWhereClause = (projectIds.length > 0) ? { projectId: { $in: projectIds } } : { deletedAt: { $eq: null } }; @@ -59,14 +65,14 @@ Promise.coroutine(function* wrapped() { promises.push(rabbit.publish('project.initial', p, {})); }); Promise.all(promises) - .then(() => { - logger.info(`Published ${promises.length} msgs`); - process.exit(); - }) - .catch((err) => { - logger.error(err); - process.exit(); - }); + .then(() => { + logger.info(`Published ${promises.length} msgs`); + process.exit(); + }) + .catch((err) => { + logger.error(err); + process.exit(); + }); } catch (err) { logger.error(err); process.exit(); diff --git a/package-lock.json b/package-lock.json index 0eedd948..d5e4d622 100644 --- a/package-lock.json +++ b/package-lock.json @@ -388,6 +388,84 @@ "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", "integrity": "sha1-x57Zf380y48robyXkLzDZkdLS3k=" }, + "auth0-js": { + "version": "9.6.0", + "resolved": "https://registry.npmjs.org/auth0-js/-/auth0-js-9.6.0.tgz", + "integrity": "sha1-2a4wFIBzZtO0ecKtGKNTfz4Mlpk=", + "requires": { + "base64-js": "1.2.1", + "idtoken-verifier": "1.2.0", + "js-cookie": "2.2.0", + "qs": "6.5.1", + "superagent": "3.8.3", + "url-join": "1.1.0", + "winchan": "0.2.0" + }, + "dependencies": { + "debug": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.1.0.tgz", + "integrity": "sha512-OX8XqP7/1a9cqkxYw2yXss15f26NKWBpDXQd0/uK/KPqdQhxbPa994hnzjcE2VqQpDslf55723cKPUOGSmMY3g==", + "requires": { + "ms": "2.0.0" + } + }, + "formidable": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/formidable/-/formidable-1.2.1.tgz", + "integrity": "sha512-Fs9VRguL0gqGHkXS5GQiMCr1VhZBxz0JnJs4JmMp/2jL18Fmbzvv7vOFRU+U8TBkHEE/CX1qDXzJplVULgsLeg==" + }, + "isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha1-u5NdSFgsuhaMBoNJV6VKPgcSTxE=" + }, + "process-nextick-args": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.0.tgz", + "integrity": "sha512-MtEC1TqN0EU5nephaJ4rAtThHtC86dNN9qCuEhtshvpVBkAW5ZO7BASN9REnF9eoXGcRub+pFuKEpOHE+HbEMw==" + }, + "readable-stream": { + "version": "2.3.6", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.6.tgz", + "integrity": "sha512-tQtKA9WIAhBF3+VLAseyMqZeBjW0AHJoxOtYqSUZNJxauErmLbVm2FW1y+J/YA9dUrAC39ITejlZWhVIwawkKw==", + "requires": { + "core-util-is": "1.0.2", + "inherits": "2.0.3", + "isarray": "1.0.0", + "process-nextick-args": "2.0.0", + "safe-buffer": "5.1.1", + "string_decoder": "1.1.1", + "util-deprecate": "1.0.2" + } + }, + "string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "requires": { + "safe-buffer": "5.1.1" + } + }, + "superagent": { + "version": "3.8.3", + "resolved": "https://registry.npmjs.org/superagent/-/superagent-3.8.3.tgz", + "integrity": "sha512-GLQtLMCoEIK4eDv6OGtkOoSMt3D+oq0y3dsxMuYuDvaNUvuT8eFBuLmfR0iYYzHC1e8hpzC6ZsxbuP6DIalMFA==", + "requires": { + "component-emitter": "1.2.1", + "cookiejar": "2.1.1", + "debug": "3.1.0", + "extend": "3.0.1", + "form-data": "2.3.1", + "formidable": "1.2.1", + "methods": "1.1.2", + "mime": "1.4.1", + "qs": "6.5.1", + "readable-stream": "2.3.6" + } + } + } + }, "aws-sdk": { "version": "2.143.0", "resolved": "https://registry.npmjs.org/aws-sdk/-/aws-sdk-2.143.0.tgz", @@ -2247,6 +2325,11 @@ "resolved": "https://registry.npmjs.org/crypto-browserify/-/crypto-browserify-1.0.9.tgz", "integrity": "sha1-zFRJaF37hesRyYKKzHy4erW7/MA=" }, + "crypto-js": { + "version": "3.1.9-1", + "resolved": "https://registry.npmjs.org/crypto-js/-/crypto-js-3.1.9-1.tgz", + "integrity": "sha1-/aGedh/Ad+Af+/3G6f38WeiAbNg=" + }, "crypto-random-string": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/crypto-random-string/-/crypto-random-string-1.0.0.tgz", @@ -2493,11 +2576,10 @@ } }, "ecdsa-sig-formatter": { - "version": "1.0.9", - "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.9.tgz", - "integrity": "sha1-S8kmJ07Dtau1AW5+HWCSGsJisqE=", + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.10.tgz", + "integrity": "sha1-HFlQAPBKiJffuFAAiSoPTDOvhsM=", "requires": { - "base64url": "2.0.0", "safe-buffer": "5.1.1" } }, @@ -3949,6 +4031,82 @@ "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.19.tgz", "integrity": "sha512-oTZqweIP51xaGPI4uPa56/Pri/480R+mo7SeU+YETByQNhDG55ycFyNLIgta9vXhILrxXDmF7ZGhqZIcuN0gJQ==" }, + "idtoken-verifier": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/idtoken-verifier/-/idtoken-verifier-1.2.0.tgz", + "integrity": "sha512-8jmmFHwdPz8L73zGNAXHHOV9yXNC+Z0TUBN5rafpoaFaLFltlIFr1JkQa3FYAETP23eSsulVw0sBiwrE8jqbUg==", + "requires": { + "base64-js": "1.2.1", + "crypto-js": "3.1.9-1", + "jsbn": "0.1.1", + "superagent": "3.8.3", + "url-join": "1.1.0" + }, + "dependencies": { + "debug": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.1.0.tgz", + "integrity": "sha512-OX8XqP7/1a9cqkxYw2yXss15f26NKWBpDXQd0/uK/KPqdQhxbPa994hnzjcE2VqQpDslf55723cKPUOGSmMY3g==", + "requires": { + "ms": "2.0.0" + } + }, + "formidable": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/formidable/-/formidable-1.2.1.tgz", + "integrity": "sha512-Fs9VRguL0gqGHkXS5GQiMCr1VhZBxz0JnJs4JmMp/2jL18Fmbzvv7vOFRU+U8TBkHEE/CX1qDXzJplVULgsLeg==" + }, + "isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha1-u5NdSFgsuhaMBoNJV6VKPgcSTxE=" + }, + "process-nextick-args": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.0.tgz", + "integrity": "sha512-MtEC1TqN0EU5nephaJ4rAtThHtC86dNN9qCuEhtshvpVBkAW5ZO7BASN9REnF9eoXGcRub+pFuKEpOHE+HbEMw==" + }, + "readable-stream": { + "version": "2.3.6", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.6.tgz", + "integrity": "sha512-tQtKA9WIAhBF3+VLAseyMqZeBjW0AHJoxOtYqSUZNJxauErmLbVm2FW1y+J/YA9dUrAC39ITejlZWhVIwawkKw==", + "requires": { + "core-util-is": "1.0.2", + "inherits": "2.0.3", + "isarray": "1.0.0", + "process-nextick-args": "2.0.0", + "safe-buffer": "5.1.1", + "string_decoder": "1.1.1", + "util-deprecate": "1.0.2" + } + }, + "string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "requires": { + "safe-buffer": "5.1.1" + } + }, + "superagent": { + "version": "3.8.3", + "resolved": "https://registry.npmjs.org/superagent/-/superagent-3.8.3.tgz", + "integrity": "sha512-GLQtLMCoEIK4eDv6OGtkOoSMt3D+oq0y3dsxMuYuDvaNUvuT8eFBuLmfR0iYYzHC1e8hpzC6ZsxbuP6DIalMFA==", + "requires": { + "component-emitter": "1.2.1", + "cookiejar": "2.1.1", + "debug": "3.1.0", + "extend": "3.0.1", + "form-data": "2.3.1", + "formidable": "1.2.1", + "methods": "1.1.2", + "mime": "1.4.1", + "qs": "6.5.1", + "readable-stream": "2.3.6" + } + } + } + }, "ieee754": { "version": "1.1.8", "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.1.8.tgz", @@ -4563,6 +4721,11 @@ "nopt": "3.0.6" } }, + "js-cookie": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/js-cookie/-/js-cookie-2.2.0.tgz", + "integrity": "sha1-Gywnmm7s44ChIWi5JIUmWzWx7/s=" + }, "js-string-escape": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/js-string-escape/-/js-string-escape-1.0.1.tgz", @@ -4587,8 +4750,7 @@ "jsbn": { "version": "0.1.1", "resolved": "https://registry.npmjs.org/jsbn/-/jsbn-0.1.1.tgz", - "integrity": "sha1-peZUwuWi3rXyAdls77yoDA7y9RM=", - "optional": true + "integrity": "sha1-peZUwuWi3rXyAdls77yoDA7y9RM=" }, "jsesc": { "version": "1.3.0", @@ -4647,45 +4809,25 @@ "dev": true }, "jsonwebtoken": { - "version": "7.4.3", - "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-7.4.3.tgz", - "integrity": "sha1-d/UCHeBYtgWheD+hKD6ZgS5kVjg=", - "requires": { - "joi": "6.10.1", - "jws": "3.1.4", + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-8.3.0.tgz", + "integrity": "sha512-oge/hvlmeJCH+iIz1DwcO7vKPkNGJHhgkspk8OH3VKlw+mbi42WtD4ig1+VXRln765vxptAv+xT26Fd3cteqag==", + "requires": { + "jws": "3.1.5", + "lodash.includes": "4.3.0", + "lodash.isboolean": "3.0.3", + "lodash.isinteger": "4.0.4", + "lodash.isnumber": "3.0.3", + "lodash.isplainobject": "4.0.6", + "lodash.isstring": "4.0.1", "lodash.once": "4.1.1", - "ms": "2.0.0", - "xtend": "4.0.1" + "ms": "2.1.1" }, "dependencies": { - "hoek": { - "version": "2.16.3", - "resolved": "https://registry.npmjs.org/hoek/-/hoek-2.16.3.tgz", - "integrity": "sha1-ILt0A9POo5jpHcRxCo/xuCdKJe0=" - }, - "isemail": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/isemail/-/isemail-1.2.0.tgz", - "integrity": "sha1-vgPfjMPineTSxd9lASY/H6RZXpo=" - }, - "joi": { - "version": "6.10.1", - "resolved": "https://registry.npmjs.org/joi/-/joi-6.10.1.tgz", - "integrity": "sha1-TVDDGAeRIgAP5fFq8f+OGRe3fgY=", - "requires": { - "hoek": "2.16.3", - "isemail": "1.2.0", - "moment": "2.19.1", - "topo": "1.1.0" - } - }, - "topo": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/topo/-/topo-1.1.0.tgz", - "integrity": "sha1-6ddRYV0buH3IZdsYL6HKCl71NtU=", - "requires": { - "hoek": "2.16.3" - } + "ms": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.1.tgz", + "integrity": "sha512-tgp+dl5cGk28utYktBsrFqA7HKgrhgPsg6Z/EfhWI4gl1Hwq8B/GmY/0oXZ6nF8hDVesS/FpnYaD/kOWhYQvyg==" } } }, @@ -4701,13 +4843,12 @@ } }, "jwa": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/jwa/-/jwa-1.1.5.tgz", - "integrity": "sha1-oFUs4CIHQs1S4VN3SjKQXDDnVuU=", + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/jwa/-/jwa-1.1.6.tgz", + "integrity": "sha512-tBO/cf++BUsJkYql/kBbJroKOgHWEigTKBAjjBEmrMGYd1QMBC74Hr4Wo2zCZw6ZrVhlJPvoMrkcOnlWR/DJfw==", "requires": { - "base64url": "2.0.0", "buffer-equal-constant-time": "1.0.1", - "ecdsa-sig-formatter": "1.0.9", + "ecdsa-sig-formatter": "1.0.10", "safe-buffer": "5.1.1" } }, @@ -4725,12 +4866,11 @@ } }, "jws": { - "version": "3.1.4", - "resolved": "https://registry.npmjs.org/jws/-/jws-3.1.4.tgz", - "integrity": "sha1-+ei5M46KhHJ31kRLFGT2GIDgUKI=", + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/jws/-/jws-3.1.5.tgz", + "integrity": "sha512-GsCSexFADNQUr8T5HPJvayTjvPIfoyJPtLQBwn5a4WZQchcrPMPMAWcC1AzJVRDKyD6ZPROPAxgv6rfHViO4uQ==", "requires": { - "base64url": "2.0.0", - "jwa": "1.1.5", + "jwa": "1.1.6", "safe-buffer": "5.1.1" } }, @@ -6991,11 +7131,6 @@ "resolved": "https://registry.npmjs.org/stream-consume/-/stream-consume-0.1.0.tgz", "integrity": "sha1-pB6tGm1ggc63n2WwYZAbbY89HQ8=" }, - "string_decoder": { - "version": "0.10.31", - "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-0.10.31.tgz", - "integrity": "sha1-YuIDvEF2bGwoyfyEMB2rHFMQ+pQ=" - }, "string-width": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/string-width/-/string-width-1.0.2.tgz", @@ -7007,6 +7142,11 @@ "strip-ansi": "3.0.1" } }, + "string_decoder": { + "version": "0.10.31", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-0.10.31.tgz", + "integrity": "sha1-YuIDvEF2bGwoyfyEMB2rHFMQ+pQ=" + }, "stringstream": { "version": "0.0.5", "resolved": "https://registry.npmjs.org/stringstream/-/stringstream-0.0.5.tgz", @@ -7221,8 +7361,9 @@ } }, "tc-core-library-js": { - "version": "github:appirio-tech/tc-core-library-js#4346c62b2c08d8f1d6b7a7642ef4c0c011c3f732", + "version": "github:appirio-tech/tc-core-library-js#df1f5c1a5578d3d1e475bfb4a7413d9dec25525a", "requires": { + "auth0-js": "9.6.0", "axios": "0.12.0", "bunyan": "1.8.12", "config": "1.27.0", @@ -7621,6 +7762,11 @@ "querystring": "0.2.0" } }, + "url-join": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/url-join/-/url-join-1.1.0.tgz", + "integrity": "sha1-dBxsL0WWxIMNZxhGCSDQySIC3Hg=" + }, "url-parse-lax": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/url-parse-lax/-/url-parse-lax-1.0.0.tgz", @@ -7838,6 +7984,11 @@ "string-width": "1.0.2" } }, + "winchan": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/winchan/-/winchan-0.2.0.tgz", + "integrity": "sha1-OGMCjn+XSw2hQS8oQXukJJcqvZQ=" + }, "window-size": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/window-size/-/window-size-0.1.0.tgz", diff --git a/package.json b/package.json index 7d8dd65e..dac6d5a8 100644 --- a/package.json +++ b/package.json @@ -50,10 +50,11 @@ "express-validation": "^0.6.0", "http-aws-es": "^1.1.3", "joi": "^8.0.5", + "jsonwebtoken": "^8.3.0", "lodash": "^4.16.4", "method-override": "^2.3.9", "pg": "^4.5.5", - "pg-native": "^1.10.0", + "pg-native": "^1.10.1", "sequelize": "^3.23.0", "tc-core-library-js": "appirio-tech/tc-core-library-js.git#v2.3", "traverse": "^0.6.6", diff --git a/postman.json b/postman.json index 807c4f87..d9cb23a4 100644 --- a/postman.json +++ b/postman.json @@ -1,13 +1,13 @@ { "info": { - "name": "tc-project-service ", - "_postman_id": "8f323d9c-63bd-5f2c-87f1-1e99083786f3", - "description": "", - "schema": "https://schema.getpostman.com/json/collection/v2.0.0/collection.json" + "_postman_id": "1791b330-5331-4768-a265-f1cb5e6b4492", + "name": "tc-project-service", + "schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json" }, "item": [ { "name": "Project Attachments", + "description": null, "item": [ { "name": "Upload attachment", @@ -27,7 +27,18 @@ "mode": "raw", "raw": "{\n\t\"param\": {\n\t\t\"title\": \"first attachment submission\",\n\t\t\"filePath\": \"asdjshdasdas/asdsadj/asdasd.png\",\n\t\t\"s3Bucket\": \"topcoder-project-service\",\n\t\t\"contentType\": \"application/png\"\n\t}\n}" }, - "url": "{{api-url}}/v4/projects/7/attachments", + "url": { + "raw": "{{api-url}}/v4/projects/7/attachments", + "host": [ + "{{api-url}}" + ], + "path": [ + "v4", + "projects", + "7", + "attachments" + ] + }, "description": "Create an project attachment" }, "response": [] @@ -50,7 +61,19 @@ "mode": "raw", "raw": "{\n\t\"param\": {\n\t\t\"title\": \"first attachment submission updated\",\n\t\t\"description\": \"updated project attachment\"\n\t}\n}" }, - "url": "{{api-url}}/v4/projects/7/attachments/2", + "url": { + "raw": "{{api-url}}/v4/projects/7/attachments/2", + "host": [ + "{{api-url}}" + ], + "path": [ + "v4", + "projects", + "7", + "attachments", + "2" + ] + }, "description": "Update project attachment" }, "response": [] @@ -73,7 +96,19 @@ "mode": "raw", "raw": "" }, - "url": "{{api-url}}/v4/projects/7/attachments/2", + "url": { + "raw": "{{api-url}}/v4/projects/7/attachments/2", + "host": [ + "{{api-url}}" + ], + "path": [ + "v4", + "projects", + "7", + "attachments", + "2" + ] + }, "description": "Delete a project attachment" }, "response": [] @@ -82,6 +117,7 @@ }, { "name": "Project Members", + "description": null, "item": [ { "name": "Create project member with no payload", @@ -101,7 +137,18 @@ "mode": "raw", "raw": "" }, - "url": "{{api-url}}/v4/projects/1/members", + "url": { + "raw": "{{api-url}}/v4/projects/1/members", + "host": [ + "{{api-url}}" + ], + "path": [ + "v4", + "projects", + "1", + "members" + ] + }, "description": "Request payload is mandatory while creating project. If no request payload is specified this should result in 422 status code." }, "response": [] @@ -124,7 +171,18 @@ "mode": "raw", "raw": "{\n\t\"role\": \"copilot\"\n}" }, - "url": "{{api-url}}/v4/projects/1/members", + "url": { + "raw": "{{api-url}}/v4/projects/1/members", + "host": [ + "{{api-url}}" + ], + "path": [ + "v4", + "projects", + "1", + "members" + ] + }, "description": "Certain fields are mandatory while creating project. If invalid fields are specified this should result in 422 status code." }, "response": [] @@ -147,7 +205,18 @@ "mode": "raw", "raw": "{\n\t\"param\": {\n\t\t\"role\": \"copilot\",\n\t\t\"userId\": 40051331,\n\t\t\"isPrimary\": true\n\t}\n}" }, - "url": "{{api-url}}/v4/projects/7/members", + "url": { + "raw": "{{api-url}}/v4/projects/7/members", + "host": [ + "{{api-url}}" + ], + "path": [ + "v4", + "projects", + "7", + "members" + ] + }, "description": "If the request payload is valid, than project member should be created." }, "response": [] @@ -170,7 +239,18 @@ "mode": "raw", "raw": "{\n\t\"param\": {\n\t\t\"role\": \"copilot\",\n\t\t\"userId\": 40051331,\n\t\t\"isPrimary\": true\n\t}\n}" }, - "url": "{{api-url}}/v4/projects/1/members", + "url": { + "raw": "{{api-url}}/v4/projects/1/members", + "host": [ + "{{api-url}}" + ], + "path": [ + "v4", + "projects", + "1", + "members" + ] + }, "description": "If the request payload is valid and user is already registered with the specified role than this should result in 400." }, "response": [] @@ -193,7 +273,18 @@ "mode": "raw", "raw": "{\n\t\"param\": {\n\t\t\"role\": \"manager\",\n\t\t\"userId\": 40051330,\n\t\t\"isPrimary\": true\n\t}\n}" }, - "url": "{{api-url}}/v4/projects/7/members", + "url": { + "raw": "{{api-url}}/v4/projects/7/members", + "host": [ + "{{api-url}}" + ], + "path": [ + "v4", + "projects", + "7", + "members" + ] + }, "description": "If the request payload is valid, than project manager should be added. This should sync with the direct project is project is associated with direct project." }, "response": [] @@ -216,7 +307,18 @@ "mode": "raw", "raw": "{\n\t\"param\": {\n\t\t\"role\": \"customer\",\n\t\t\"userId\": 40051332,\n\t\t\"isPrimary\": true\n\t}\n}" }, - "url": "{{api-url}}/v4/projects/7/members", + "url": { + "raw": "{{api-url}}/v4/projects/7/members", + "host": [ + "{{api-url}}" + ], + "path": [ + "v4", + "projects", + "7", + "members" + ] + }, "description": "If the request payload is valid, than project customer should be added. This should sync with the direct project is project is associated with direct project." }, "response": [] @@ -239,7 +341,19 @@ "mode": "raw", "raw": "{\n\t\"param\": {\n\t\t\"role\": \"copilot\",\n\t\t\"isPrimary\": true\n\t}\n}" }, - "url": "{{api-url}}/v4/projects/7/members/16", + "url": { + "raw": "{{api-url}}/v4/projects/7/members/16", + "host": [ + "{{api-url}}" + ], + "path": [ + "v4", + "projects", + "7", + "members", + "16" + ] + }, "description": "Update a project's member." }, "response": [] @@ -262,7 +376,19 @@ "mode": "raw", "raw": "{\n\t\"param\": {\n\t\t\"role\": \"copilot\",\n\t\t\"isPrimary\": false\n\t}\n}" }, - "url": "{{api-url}}/v4/projects/7/members/16", + "url": { + "raw": "{{api-url}}/v4/projects/7/members/16", + "host": [ + "{{api-url}}" + ], + "path": [ + "v4", + "projects", + "7", + "members", + "16" + ] + }, "description": "Update a project's member." }, "response": [] @@ -285,7 +411,19 @@ "mode": "raw", "raw": "" }, - "url": "{{api-url}}/v4/projects/7/members/15", + "url": { + "raw": "{{api-url}}/v4/projects/7/members/15", + "host": [ + "{{api-url}}" + ], + "path": [ + "v4", + "projects", + "7", + "members", + "15" + ] + }, "description": "Delete a project's member" }, "response": [] @@ -314,7 +452,16 @@ "mode": "raw", "raw": "{\n\t\n}" }, - "url": "{{api-url}}/v4/projects", + "url": { + "raw": "{{api-url}}/v4/projects", + "host": [ + "{{api-url}}" + ], + "path": [ + "v4", + "projects" + ] + }, "description": "Request body is mandatory while creating project. If invalid request body is supplied this should return 422 status code." }, "response": [] @@ -337,7 +484,16 @@ "mode": "raw", "raw": "{\n\t\"param\": {\n\t}\n}" }, - "url": "{{api-url}}/v4/projects", + "url": { + "raw": "{{api-url}}/v4/projects", + "host": [ + "{{api-url}}" + ], + "path": [ + "v4", + "projects" + ] + }, "description": "Certain fields are mandatory while creating project. If invalid request body is supplied this should return 422 status code." }, "response": [] @@ -360,7 +516,16 @@ "mode": "raw", "raw": "{\n\t\"param\": {\n\t\t\"name\": \"test project\",\n\t\t\"description\": \"Hello I am a test project\",\n\t\t\"type\": \"generic\"\n\t}\n}" }, - "url": "{{api-url}}/v4/projects", + "url": { + "raw": "{{api-url}}/v4/projects", + "host": [ + "{{api-url}}" + ], + "path": [ + "v4", + "projects" + ] + }, "description": "Valid request body. Project should be created successfully." }, "response": [] @@ -379,7 +544,17 @@ "mode": "raw", "raw": "" }, - "url": "{{api-url}}/v4/projects/7", + "url": { + "raw": "{{api-url}}/v4/projects/7", + "host": [ + "{{api-url}}" + ], + "path": [ + "v4", + "projects", + "7" + ] + }, "description": "Get a project by id. project members and attachments should also be returned." }, "response": [] @@ -433,7 +608,16 @@ "mode": "raw", "raw": "" }, - "url": "{{api-url}}/v4/projects", + "url": { + "raw": "{{api-url}}/v4/projects", + "host": [ + "{{api-url}}" + ], + "path": [ + "v4", + "projects" + ] + }, "description": "List all the project with no filter. Default sort and limits are applied." }, "response": [] @@ -592,7 +776,17 @@ "mode": "raw", "raw": "" }, - "url": "{{api-url}}/v4/projects/3", + "url": { + "raw": "{{api-url}}/v4/projects/3", + "host": [ + "{{api-url}}" + ], + "path": [ + "v4", + "projects", + "3" + ] + }, "description": "Delete a project by id" }, "response": [] @@ -615,7 +809,17 @@ "mode": "raw", "raw": "{\n \"param\": {\n \"name\": \"project name updated\"\n }\n}" }, - "url": "{{api-url}}/v4/projects/13", + "url": { + "raw": "{{api-url}}/v4/projects/13", + "host": [ + "{{api-url}}" + ], + "path": [ + "v4", + "projects", + "13" + ] + }, "description": "Update the project name. Name should be updated successfully." }, "response": [] @@ -638,7 +842,17 @@ "mode": "raw", "raw": "{\n\t\"param\": {\n\t\t\"name\": \"project name updated\"\n\t}\n}" }, - "url": "{{api-url}}/v4/projects/2", + "url": { + "raw": "{{api-url}}/v4/projects/2", + "host": [ + "{{api-url}}" + ], + "path": [ + "v4", + "projects", + "2" + ] + }, "description": "Update the project name. If user don't have permission to the project than it should return 403." }, "response": [] @@ -661,7 +875,17 @@ "mode": "raw", "raw": "{\n\t\"param\": {\n\t\t\"name\": \"project name updated\"\n\t}\n}" }, - "url": "{{api-url}}/v4/projects/10", + "url": { + "raw": "{{api-url}}/v4/projects/10", + "host": [ + "{{api-url}}" + ], + "path": [ + "v4", + "projects", + "10" + ] + }, "description": "Update the project name. If project is not found than this result in 404 status code." }, "response": [] @@ -684,7 +908,17 @@ "mode": "raw", "raw": "{\n \"param\": {\n \"status\": \"in_review\"\n }\n}" }, - "url": "{{api-url}}/v4/projects/7", + "url": { + "raw": "{{api-url}}/v4/projects/7", + "host": [ + "{{api-url}}" + ], + "path": [ + "v4", + "projects", + "7" + ] + }, "description": "Update the project status." }, "response": [] @@ -707,7 +941,17 @@ "mode": "raw", "raw": "{\n \"param\": {\n \"status\": \"reviewed\"\n }\n}" }, - "url": "{{api-url}}/v4/projects/7", + "url": { + "raw": "{{api-url}}/v4/projects/7", + "host": [ + "{{api-url}}" + ], + "path": [ + "v4", + "projects", + "7" + ] + }, "description": "Update the project status." }, "response": [] @@ -730,7 +974,17 @@ "mode": "raw", "raw": "{\n \"param\": {\n \"status\": \"paused\"\n }\n}" }, - "url": "{{api-url}}/v4/projects/7", + "url": { + "raw": "{{api-url}}/v4/projects/7", + "host": [ + "{{api-url}}" + ], + "path": [ + "v4", + "projects", + "7" + ] + }, "description": "Update the project status." }, "response": [] @@ -753,7 +1007,17 @@ "mode": "raw", "raw": "{\n \"param\": {\n \"status\": \"cancelled\",\n \"cancelReason\": \"price/cost\"\n }\n}" }, - "url": "{{api-url}}/v4/projects/7", + "url": { + "raw": "{{api-url}}/v4/projects/7", + "host": [ + "{{api-url}}" + ], + "path": [ + "v4", + "projects", + "7" + ] + }, "description": "Update the project status. While cancelling the project `cancelReason` is mandatory." }, "response": [] @@ -776,7 +1040,17 @@ "mode": "raw", "raw": "{\n\t\"param\": {\n\t\t\"status\": \"cancelled\"\n\t}\n}" }, - "url": "{{api-url}}/v4/projects/1", + "url": { + "raw": "{{api-url}}/v4/projects/1", + "host": [ + "{{api-url}}" + ], + "path": [ + "v4", + "projects", + "1" + ] + }, "description": "Update the project status. While cancelling the project `cancelReason` is mandatory. If no `cancelReason` is supplied this should result in 422 status code." }, "response": [] @@ -799,7 +1073,17 @@ "mode": "raw", "raw": "{\n \"param\": {\n \"status\": \"completed\"\n }\n}" }, - "url": "{{api-url}}/v4/projects/7", + "url": { + "raw": "{{api-url}}/v4/projects/7", + "host": [ + "{{api-url}}" + ], + "path": [ + "v4", + "projects", + "7" + ] + }, "description": "Update the project status." }, "response": [] @@ -822,7 +1106,17 @@ "mode": "raw", "raw": "{\n\t\"param\": {\n\t\t\"status\": \"active\"\n\t}\n}" }, - "url": "{{api-url}}/v4/projects/1", + "url": { + "raw": "{{api-url}}/v4/projects/1", + "host": [ + "{{api-url}}" + ], + "path": [ + "v4", + "projects", + "1" + ] + }, "description": "Move a project out of cancel state. Only admin and manager is allowed to do so." }, "response": [] @@ -845,9 +1139,19 @@ "mode": "raw", "raw": "{\n\t\"param\": {\n\t\t\"status\": \"active\"\n\t}\n}" }, - "url": "{{api-url}}/v4/projects/1", - "description": "Move a project out of cancel state. Only admin and manager is allowed to do so." - }, + "url": { + "raw": "{{api-url}}/v4/projects/1", + "host": [ + "{{api-url}}" + ], + "path": [ + "v4", + "projects", + "1" + ] + }, + "description": "Move a project out of cancel state. Only admin and manager is allowed to do so." + }, "response": [] }, { @@ -868,7 +1172,17 @@ "mode": "raw", "raw": "{\n \"param\": {\n \"details\": {\n \"summary\": \"project name updated\"\n }\n }\n}" }, - "url": "{{api-url}}/v4/projects/8", + "url": { + "raw": "{{api-url}}/v4/projects/8", + "host": [ + "{{api-url}}" + ], + "path": [ + "v4", + "projects", + "8" + ] + }, "description": "Update the project details. This should fire specification modified event" }, "response": [] @@ -891,7 +1205,17 @@ "mode": "raw", "raw": "{\n \"param\": {\n \"bookmarks\": [\n {\n \"title\": \"test\",\n \"address\": \"http://topcoder.com\"\n }\n \n ]\n }\n}" }, - "url": "{{api-url}}/v4/projects/8", + "url": { + "raw": "{{api-url}}/v4/projects/8", + "host": [ + "{{api-url}}" + ], + "path": [ + "v4", + "projects", + "8" + ] + }, "description": "Update the project bookmarks. This should fire project link created event" }, "response": [] @@ -900,6 +1224,7 @@ }, { "name": "bookmarks", + "description": null, "item": [ { "name": " Create project without bookmarks", @@ -919,7 +1244,16 @@ "mode": "raw", "raw": "{\n \"param\": {\n \"type\": \"generic\",\n \"description\": \"test project\",\n \"details\": {},\n \"billingAccountId\": 123,\n \"name\": \"test project1\"\n }\n}" }, - "url": "{{api-url}}/v4/projects" + "url": { + "raw": "{{api-url}}/v4/projects", + "host": [ + "{{api-url}}" + ], + "path": [ + "v4", + "projects" + ] + } }, "response": [] }, @@ -941,7 +1275,16 @@ "mode": "raw", "raw": "{\n \"param\": {\n \"type\": \"generic\",\n \"description\": \"test project\",\n \"details\": {},\n \"bookmarks\":[{\n \"title\":\"title1\",\n \"address\":\"address1\"\n },{\n \"title\":\"title2\",\n \"address\":\"address2\"\n }],\n \"billingAccountId\": 123,\n \"name\": \"test project1\"\n }\n}" }, - "url": "{{api-url}}/v4/projects" + "url": { + "raw": "{{api-url}}/v4/projects", + "host": [ + "{{api-url}}" + ], + "path": [ + "v4", + "projects" + ] + } }, "response": [] }, @@ -963,7 +1306,16 @@ "mode": "raw", "raw": "{\n \"param\": {\n \"type\": \"generic\",\n \"description\": \"test project\",\n \"details\": {},\n \"bookmarks\":[{\n \"title\":\"title1\",\n \"invalid\":3,\n \"address\":\"address1\"\n },{\n \"title\":\"title2\",\n \"address\":\"address2\"\n }],\n \"billingAccountId\": 123,\n \"name\": \"test project1\"\n }\n}" }, - "url": "{{api-url}}/v4/projects" + "url": { + "raw": "{{api-url}}/v4/projects", + "host": [ + "{{api-url}}" + ], + "path": [ + "v4", + "projects" + ] + } }, "response": [] }, @@ -985,7 +1337,17 @@ "mode": "raw", "raw": "{\n \"param\": {\n \"billingAccountId\": 9999, \n \"name\": \"new project name\"\n }\n}" }, - "url": "{{api-url}}/v4/projects/2" + "url": { + "raw": "{{api-url}}/v4/projects/2", + "host": [ + "{{api-url}}" + ], + "path": [ + "v4", + "projects", + "2" + ] + } }, "response": [] }, @@ -1007,7 +1369,17 @@ "mode": "raw", "raw": "{\n \"param\": {\n \"billingAccountId\": 9999, \n \"name\": \"new project name\",\n \"bookmarks\":[{\n \"title\":\"title1\",\n \"address\":\"address1\"\n },{\n \"title\":\"title2\",\n \"address\":\"address2\"\n }]\n }\n}" }, - "url": "{{api-url}}/v4/projects/2" + "url": { + "raw": "{{api-url}}/v4/projects/2", + "host": [ + "{{api-url}}" + ], + "path": [ + "v4", + "projects", + "2" + ] + } }, "response": [] }, @@ -1029,7 +1401,17 @@ "mode": "raw", "raw": "{\n \"param\": {\n \"billingAccountId\": 9999, \n \"name\": \"new project name2\",\n \"bookmarks\":null\n }\n}" }, - "url": "{{api-url}}/v4/projects/2" + "url": { + "raw": "{{api-url}}/v4/projects/2", + "host": [ + "{{api-url}}" + ], + "path": [ + "v4", + "projects", + "2" + ] + } }, "response": [] }, @@ -1051,7 +1433,17 @@ "mode": "raw", "raw": "{\n \"param\": {\n \"billingAccountId\": 9999, \n \"name\": \"new project name2\",\n \"bookmarks\":3\n }\n}" }, - "url": "{{api-url}}/v4/projects/2" + "url": { + "raw": "{{api-url}}/v4/projects/2", + "host": [ + "{{api-url}}" + ], + "path": [ + "v4", + "projects", + "2" + ] + } }, "response": [] }, @@ -1069,7 +1461,16 @@ "mode": "raw", "raw": "" }, - "url": "{{api-url}}/v4/projects" + "url": { + "raw": "{{api-url}}/v4/projects", + "host": [ + "{{api-url}}" + ], + "path": [ + "v4", + "projects" + ] + } }, "response": [] } @@ -1077,6 +1478,7 @@ }, { "name": "issue1", + "description": null, "item": [ { "name": "get projects with copilot token", @@ -1092,7 +1494,16 @@ "mode": "raw", "raw": "" }, - "url": "{{api-url}}/v4/projects" + "url": { + "raw": "{{api-url}}/v4/projects", + "host": [ + "{{api-url}}" + ], + "path": [ + "v4", + "projects" + ] + } }, "response": [] } @@ -1100,6 +1511,7 @@ }, { "name": "issue10", + "description": null, "item": [ { "name": "wrong role", @@ -1119,7 +1531,19 @@ "mode": "raw", "raw": " {\n \"param\": {\n \"role\": \"wrong\"\n }\n } " }, - "url": "{{api-url}}/v4/projects/3/members/5" + "url": { + "raw": "{{api-url}}/v4/projects/3/members/5", + "host": [ + "{{api-url}}" + ], + "path": [ + "v4", + "projects", + "3", + "members", + "5" + ] + } }, "response": [] }, @@ -1141,7 +1565,19 @@ "mode": "raw", "raw": " {\n \"param\": {\n \"role\": \"manager\",\n \"isPrimary\": true\n }\n } " }, - "url": "{{api-url}}/v4/projects/1/members/1" + "url": { + "raw": "{{api-url}}/v4/projects/1/members/1", + "host": [ + "{{api-url}}" + ], + "path": [ + "v4", + "projects", + "1", + "members", + "1" + ] + } }, "response": [] } @@ -1149,6 +1585,7 @@ }, { "name": "issue5", + "description": null, "item": [ { "name": "launch a project by topcoder managers ", @@ -1168,7 +1605,17 @@ "mode": "raw", "raw": "{\n \n \"param\":{\n \"name\": \"updatedProject name\",\n \"status\": \"active\"\n }\n}" }, - "url": "{{api-url}}/v4/projects/1" + "url": { + "raw": "{{api-url}}/v4/projects/1", + "host": [ + "{{api-url}}" + ], + "path": [ + "v4", + "projects", + "1" + ] + } }, "response": [] }, @@ -1190,7 +1637,17 @@ "mode": "raw", "raw": "{\n \n \"param\":{\n \"name\": \"updatedProject name\",\n \"status\": \"active\"\n }\n}" }, - "url": "{{api-url}}/v4/projects/1" + "url": { + "raw": "{{api-url}}/v4/projects/1", + "host": [ + "{{api-url}}" + ], + "path": [ + "v4", + "projects", + "1" + ] + } }, "response": [] }, @@ -1212,7 +1669,17 @@ "mode": "raw", "raw": "{\n \n \"param\":{\n \"name\": \"updatedProject name\",\n \"status\": \"active\"\n }\n}" }, - "url": "{{api-url}}/v4/projects/1" + "url": { + "raw": "{{api-url}}/v4/projects/1", + "host": [ + "{{api-url}}" + ], + "path": [ + "v4", + "projects", + "1" + ] + } }, "response": [] } @@ -1220,6 +1687,7 @@ }, { "name": "issue8", + "description": null, "item": [ { "name": "mock direct projects", @@ -1239,7 +1707,19 @@ "mode": "raw", "raw": " {\n \"param\": {\n \"role\": \"copilot\",\n \"isPrimary\": true\n }\n } " }, - "url": "https://localhost:8443/v3/direct/projects" + "url": { + "raw": "https://localhost:8443/v3/direct/projects", + "protocol": "https", + "host": [ + "localhost" + ], + "port": "8443", + "path": [ + "v3", + "direct", + "projects" + ] + } }, "response": [] }, @@ -1261,7 +1741,16 @@ "mode": "raw", "raw": "{\n \"param\": {\n \"type\": \"generic\",\n \"description\": \"test project\",\n \"details\": {},\n \"billingAccountId\": 123,\n \"name\": \"test project1\"\n }\n}" }, - "url": "{{api-url}}/v4/projects" + "url": { + "raw": "{{api-url}}/v4/projects", + "host": [ + "{{api-url}}" + ], + "path": [ + "v4", + "projects" + ] + } }, "response": [] }, @@ -1283,7 +1772,18 @@ "mode": "raw", "raw": "{\n \"param\": {\n \"userId\": 2, \n \"role\": \"copilot\"\n }\n}" }, - "url": "{{api-url}}/v4/projects/1/members" + "url": { + "raw": "{{api-url}}/v4/projects/1/members", + "host": [ + "{{api-url}}" + ], + "path": [ + "v4", + "projects", + "1", + "members" + ] + } }, "response": [] }, @@ -1305,7 +1805,18 @@ "mode": "raw", "raw": "{\n \"param\": {\n \"userId\": 2, \n \"role\": \"copilot\"\n }\n}" }, - "url": "{{api-url}}/v4/projects/2/members" + "url": { + "raw": "{{api-url}}/v4/projects/2/members", + "host": [ + "{{api-url}}" + ], + "path": [ + "v4", + "projects", + "2", + "members" + ] + } }, "response": [] }, @@ -1327,7 +1838,19 @@ "mode": "raw", "raw": " {\n \"param\": {\n \"role\": \"customer\",\n \"isPrimary\": true\n }\n } " }, - "url": "{{api-url}}/v4/projects/2/members/4" + "url": { + "raw": "{{api-url}}/v4/projects/2/members/4", + "host": [ + "{{api-url}}" + ], + "path": [ + "v4", + "projects", + "2", + "members", + "4" + ] + } }, "response": [] }, @@ -1349,7 +1872,17 @@ "mode": "raw", "raw": "{\n \"param\": {\n \"billingAccountId\": 9999, \n \"name\": \"new project name\"\n }\n}" }, - "url": "{{api-url}}/v4/projects/2" + "url": { + "raw": "{{api-url}}/v4/projects/2", + "host": [ + "{{api-url}}" + ], + "path": [ + "v4", + "projects", + "2" + ] + } }, "response": [] }, @@ -1371,11 +1904,1105 @@ "mode": "raw", "raw": "" }, - "url": "{{api-url}}/v4/projects/2/members/4" + "url": { + "raw": "{{api-url}}/v4/projects/2/members/4", + "host": [ + "{{api-url}}" + ], + "path": [ + "v4", + "projects", + "2", + "members", + "4" + ] + } + }, + "response": [] + } + ] + }, + { + "name": "Project Phase", + "description": null, + "item": [ + { + "name": "Create Phase", + "request": { + "method": "POST", + "header": [ + { + "key": "Authorization", + "value": "Bearer {{jwt-token}}" + }, + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n\t\"param\": {\n\t\t\"name\": \"test project phase\",\n\t\t\"status\": \"active\",\n\t\t\"startDate\": \"2018-05-15T00:00:00\",\n\t\t\"endDate\": \"2018-05-16T00:00:00\",\n\t\t\"budget\": 20,\n\t\t\"details\": {\n\t\t\t\"aDetails\": \"a details\"\n\t\t}\n\t}\n}" + }, + "url": { + "raw": "{{api-url}}/v4/projects/1/phases", + "host": [ + "{{api-url}}" + ], + "path": [ + "v4", + "projects", + "1", + "phases" + ] + } + }, + "response": [] + }, + { + "name": "List Phase", + "request": { + "method": "GET", + "header": [ + { + "key": "Authorization", + "value": "Bearer {{jwt-token}}" + }, + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n\t\"param\": {\n\t\t\"name\": \"test project phase\",\n\t\t\"status\": \"active\",\n\t\t\"startDate\": \"2018-05-15T00:00:00\",\n\t\t\"endDate\": \"2018-05-16T00:00:00\",\n\t\t\"budget\": 20\n\t}\n}" + }, + "url": { + "raw": "{{api-url}}/v4/projects/1/phases", + "host": [ + "{{api-url}}" + ], + "path": [ + "v4", + "projects", + "1", + "phases" + ] + } + }, + "response": [] + }, + { + "name": "List Phase with fields", + "request": { + "method": "GET", + "header": [ + { + "key": "Authorization", + "value": "Bearer {{jwt-token}}" + }, + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n\t\"param\": {\n\t\t\"name\": \"test project phase\",\n\t\t\"status\": \"active\",\n\t\t\"startDate\": \"2018-05-15T00:00:00\",\n\t\t\"endDate\": \"2018-05-16T00:00:00\",\n\t\t\"budget\": 20\n\t}\n}" + }, + "url": { + "raw": "{{api-url}}/v4/projects/1/phases?fields=status,name,budget", + "host": [ + "{{api-url}}" + ], + "path": [ + "v4", + "projects", + "1", + "phases" + ], + "query": [ + { + "key": "fields", + "value": "status,name,budget" + } + ] + } + }, + "response": [] + }, + { + "name": "List Phase with sort", + "request": { + "method": "GET", + "header": [ + { + "key": "Authorization", + "value": "Bearer {{jwt-token}}" + }, + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n\t\"param\": {\n\t\t\"name\": \"test project phase\",\n\t\t\"status\": \"active\",\n\t\t\"startDate\": \"2018-05-15T00:00:00\",\n\t\t\"endDate\": \"2018-05-16T00:00:00\",\n\t\t\"budget\": 20\n\t}\n}" + }, + "url": { + "raw": "{{api-url}}/v4/projects/1/phases?sort=status desc", + "host": [ + "{{api-url}}" + ], + "path": [ + "v4", + "projects", + "1", + "phases" + ], + "query": [ + { + "key": "sort", + "value": "status desc" + } + ] + } + }, + "response": [] + }, + { + "name": "Get Phase", + "request": { + "method": "GET", + "header": [ + { + "key": "Authorization", + "value": "Bearer {{jwt-token}}" + }, + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n\t\"param\": {\n\t\t\"name\": \"test project phase\",\n\t\t\"status\": \"active\",\n\t\t\"startDate\": \"2018-05-15T00:00:00\",\n\t\t\"endDate\": \"2018-05-16T00:00:00\",\n\t\t\"budget\": 20\n\t}\n}" + }, + "url": { + "raw": "{{api-url}}/v4/projects/1/phases/1", + "host": [ + "{{api-url}}" + ], + "path": [ + "v4", + "projects", + "1", + "phases", + "1" + ] + } + }, + "response": [] + }, + { + "name": "Update Phase", + "request": { + "method": "PATCH", + "header": [ + { + "key": "Authorization", + "value": "Bearer {{jwt-token}}" + }, + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n\t\"param\": {\n\t\t\"name\": \"test project phase xxx\",\n\t\t\"status\": \"inactive\",\n\t\t\"startDate\": \"2018-05-14T00:00:00\",\n\t\t\"endDate\": \"2018-05-15T00:00:00\",\n\t\t\"budget\": 30,\n\t\t\"progress\": 15,\n\t\t\"details\": {\n\t\t\t\"message\": \"phase details\"\n\t\t}\n\t}\n}" + }, + "url": { + "raw": "{{api-url}}/v4/projects/1/phases/1", + "host": [ + "{{api-url}}" + ], + "path": [ + "v4", + "projects", + "1", + "phases", + "1" + ] + } + }, + "response": [] + }, + { + "name": "Delete Phase", + "request": { + "method": "DELETE", + "header": [ + { + "key": "Authorization", + "value": "Bearer {{jwt-token}}" + }, + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "" + }, + "url": { + "raw": "{{api-url}}/v4/projects/1/phases/3", + "host": [ + "{{api-url}}" + ], + "path": [ + "v4", + "projects", + "1", + "phases", + "3" + ] + } + }, + "response": [] + } + ] + }, + { + "name": "Phase Products", + "description": null, + "item": [ + { + "name": "Create Phase Product", + "request": { + "method": "POST", + "header": [ + { + "key": "Authorization", + "value": "Bearer {{jwt-token}}" + }, + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n\t\"param\": {\n\t\t\"name\": \"test phase product\",\n\t\t\"type\": \"type 1\",\n\t\t\"estimatedPrice\": 10\n\t}\n}" + }, + "url": { + "raw": "{{api-url}}/v4/projects/1/phases/1/products", + "host": [ + "{{api-url}}" + ], + "path": [ + "v4", + "projects", + "1", + "phases", + "1", + "products" + ] + } + }, + "response": [] + }, + { + "name": "List Phase Products", + "request": { + "method": "GET", + "header": [ + { + "key": "Authorization", + "value": "Bearer {{jwt-token}}" + } + ], + "body": { + "mode": "raw", + "raw": "" + }, + "url": { + "raw": "{{api-url}}/v4/projects/1/phases/1/products", + "host": [ + "{{api-url}}" + ], + "path": [ + "v4", + "projects", + "1", + "phases", + "1", + "products" + ] + } + }, + "response": [] + }, + { + "name": "Get Phase Product", + "request": { + "method": "GET", + "header": [ + { + "key": "Authorization", + "value": "Bearer {{jwt-token}}" + } + ], + "body": { + "mode": "raw", + "raw": "" + }, + "url": { + "raw": "{{api-url}}/v4/projects/1/phases/1/products/1", + "host": [ + "{{api-url}}" + ], + "path": [ + "v4", + "projects", + "1", + "phases", + "1", + "products", + "1" + ] + } + }, + "response": [] + }, + { + "name": "Update Phase Product", + "request": { + "method": "PATCH", + "header": [ + { + "key": "Authorization", + "value": "Bearer {{jwt-token}}" + }, + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n\t\"param\": {\n\t\t\"name\": \"test phase product xxx\",\n\t\t\"type\": \"type 2\",\n\t\t\"templateId\": 10,\n\t\t\"estimatedPrice\": 1.234567,\n\t\t\"actualPrice\": 2.34567,\n\t\t\"details\": {\n\t\t\t\"message\": \"this is a JSON type. You can use any json\"\n\t\t}\n\t}\n}" + }, + "url": { + "raw": "{{api-url}}/v4/projects/1/phases/1/products/1", + "host": [ + "{{api-url}}" + ], + "path": [ + "v4", + "projects", + "1", + "phases", + "1", + "products", + "1" + ] + } + }, + "response": [] + }, + { + "name": "Delete Phase Product", + "request": { + "method": "DELETE", + "header": [ + { + "key": "Authorization", + "value": "Bearer {{jwt-token}}" + } + ], + "body": { + "mode": "raw", + "raw": "" + }, + "url": { + "raw": "{{api-url}}/v4/projects/1/phases/1/products/1", + "host": [ + "{{api-url}}" + ], + "path": [ + "v4", + "projects", + "1", + "phases", + "1", + "products", + "1" + ] + } + }, + "response": [] + } + ] + }, + { + "name": "Project Templates", + "description": null, + "item": [ + { + "name": "Create project template", + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + }, + { + "key": "Authorization", + "value": "Bearer {{jwt-token}}" + } + ], + "body": { + "mode": "raw", + "raw": "{\r\n \"param\":{\r\n \"name\":\"new name\",\r\n \"key\":\"new key\",\r\n \"category\":\"new category\",\r\n \"scope\":{\r\n \"scope1\":\"scope 1\"\r\n },\r\n \"phases\":{\r\n \"phase1\":\"phase 1\"\r\n }\r\n }\r\n}" + }, + "url": { + "raw": "{{api-url}}/v4/projectTemplates", + "host": [ + "{{api-url}}" + ], + "path": [ + "v4", + "projectTemplates" + ] + } + }, + "response": [] + }, + { + "name": "List project templates", + "request": { + "method": "GET", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + }, + { + "key": "Authorization", + "value": "Bearer {{jwt-token}}" + } + ], + "body": { + "mode": "raw", + "raw": "{\r\n \"param\":{\r\n \"name\":\"new name\",\r\n \"key\":\"new key\",\r\n \"category\":\"new category\",\r\n \"scope\":{\r\n \"scope1\":\"scope 1\"\r\n },\r\n \"phases\":{\r\n \"phase1\":\"phase 1\"\r\n }\r\n }\r\n}" + }, + "url": { + "raw": "{{api-url}}/v4/projectTemplates", + "host": [ + "{{api-url}}" + ], + "path": [ + "v4", + "projectTemplates" + ] + } + }, + "response": [] + }, + { + "name": "Get project template", + "request": { + "method": "GET", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + }, + { + "key": "Authorization", + "value": "Bearer {{jwt-token}}" + } + ], + "body": { + "mode": "raw", + "raw": "{\r\n \"param\":{\r\n \"name\":\"new name\",\r\n \"key\":\"new key\",\r\n \"category\":\"new category\",\r\n \"scope\":{\r\n \"scope1\":\"scope 1\"\r\n },\r\n \"phases\":{\r\n \"phase1\":\"phase 1\"\r\n }\r\n }\r\n}" + }, + "url": { + "raw": "{{api-url}}/v4/projectTemplates/1", + "host": [ + "{{api-url}}" + ], + "path": [ + "v4", + "projectTemplates", + "1" + ] + } + }, + "response": [] + }, + { + "name": "Update project template", + "request": { + "method": "PATCH", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + }, + { + "key": "Authorization", + "value": "Bearer {{jwt-token}}" + } + ], + "body": { + "mode": "raw", + "raw": "{\r\n \"param\":{\r\n \"name\":\"new name\",\r\n \"key\":\"new key\",\r\n \"category\":\"new category\",\r\n \"scope\":{\r\n \"scope1\":\"scope 1\",\r\n \"scope2\": [\"a\"]\r\n },\r\n \"phases\":{\r\n \"phase1\":\"phase 1\",\r\n \"phase2\": {\r\n \t\"another\": \"another\"\r\n }\r\n }\r\n }\r\n}" + }, + "url": { + "raw": "{{api-url}}/v4/projectTemplates/1", + "host": [ + "{{api-url}}" + ], + "path": [ + "v4", + "projectTemplates", + "1" + ] + } + }, + "response": [] + }, + { + "name": "Delete project template", + "request": { + "method": "DELETE", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + }, + { + "key": "Authorization", + "value": "Bearer {{jwt-token}}" + } + ], + "body": { + "mode": "raw", + "raw": "{\r\n \"param\":{\r\n \"name\":\"new name\",\r\n \"key\":\"new key\",\r\n \"category\":\"new category\",\r\n \"scope\":{\r\n \"scope1\":\"scope 1\",\r\n \"scope2\": [\"a\"]\r\n },\r\n \"phases\":{\r\n \"phase1\":\"phase 1\",\r\n \"phase2\": {\r\n \t\"another\": \"another\"\r\n }\r\n }\r\n }\r\n}" + }, + "url": { + "raw": "{{api-url}}/v4/projectTemplates/1", + "host": [ + "{{api-url}}" + ], + "path": [ + "v4", + "projectTemplates", + "1" + ] + } + }, + "response": [] + } + ] + }, + { + "name": "Product Templates", + "description": null, + "item": [ + { + "name": "Create product template", + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + }, + { + "key": "Authorization", + "value": "Bearer {{jwt-token}}" + } + ], + "body": { + "mode": "raw", + "raw": "{\r\n \"param\":{\r\n \"name\":\"new name\",\r\n \"productKey\":\"new productKey\",\r\n \"icon\":\"http://example.com/icon-new.ico\",\r\n \"brief\": \"new brief\",\r\n \"details\": \"new details\",\r\n \"aliases\":{\r\n \"alias1\":\"alias 1\"\r\n },\r\n \"template\":{\r\n \"template1\":\"template 1\"\r\n }\r\n }\r\n}" + }, + "url": { + "raw": "{{api-url}}/v4/productTemplates", + "host": [ + "{{api-url}}" + ], + "path": [ + "v4", + "productTemplates" + ] + } + }, + "response": [] + }, + { + "name": "List product templates", + "request": { + "method": "GET", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + }, + { + "key": "Authorization", + "value": "Bearer {{jwt-token}}" + } + ], + "body": { + "mode": "raw", + "raw": "{\r\n \"param\":{\r\n \"name\":\"new name\",\r\n \"key\":\"new key\",\r\n \"category\":\"new category\",\r\n \"scope\":{\r\n \"scope1\":\"scope 1\"\r\n },\r\n \"phases\":{\r\n \"phase1\":\"phase 1\"\r\n }\r\n }\r\n}" + }, + "url": { + "raw": "{{api-url}}/v4/productTemplates", + "host": [ + "{{api-url}}" + ], + "path": [ + "v4", + "productTemplates" + ] + } + }, + "response": [] + }, + { + "name": "Get product template", + "request": { + "method": "GET", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + }, + { + "key": "Authorization", + "value": "Bearer {{jwt-token}}" + } + ], + "body": { + "mode": "raw", + "raw": "{\r\n \"param\":{\r\n \"name\":\"new name\",\r\n \"key\":\"new key\",\r\n \"category\":\"new category\",\r\n \"scope\":{\r\n \"scope1\":\"scope 1\"\r\n },\r\n \"phases\":{\r\n \"phase1\":\"phase 1\"\r\n }\r\n }\r\n}" + }, + "url": { + "raw": "{{api-url}}/v4/productTemplates/1", + "host": [ + "{{api-url}}" + ], + "path": [ + "v4", + "productTemplates", + "1" + ] + } + }, + "response": [] + }, + { + "name": "Update product template", + "request": { + "method": "PATCH", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + }, + { + "key": "Authorization", + "value": "Bearer {{jwt-token}}" + } + ], + "body": { + "mode": "raw", + "raw": "{\r\n \"param\":{\r\n \"name\":\"new name\",\r\n \"productKey\":\"new productKey\",\r\n \"icon\":\"http://example.com/icon-new.ico\",\r\n \"brief\": \"new brief\",\r\n \"details\": \"new details\",\r\n \"aliases\":{\r\n \"alias1\":\"scope 1\",\r\n \"alias2\": [\"a\"]\r\n },\r\n \"template\":{\r\n \"template1\":\"template 1\",\r\n \"template2\": {\r\n \t\"another\": \"another\"\r\n }\r\n }\r\n }\r\n}" + }, + "url": { + "raw": "{{api-url}}/v4/productTemplates/1", + "host": [ + "{{api-url}}" + ], + "path": [ + "v4", + "productTemplates", + "1" + ] + } + }, + "response": [] + }, + { + "name": "Delete product template", + "request": { + "method": "DELETE", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + }, + { + "key": "Authorization", + "value": "Bearer {{jwt-token}}" + } + ], + "body": { + "mode": "raw", + "raw": "{\r\n \"param\":{\r\n \"name\":\"new name\",\r\n \"key\":\"new key\",\r\n \"category\":\"new category\",\r\n \"scope\":{\r\n \"scope1\":\"scope 1\",\r\n \"scope2\": [\"a\"]\r\n },\r\n \"phases\":{\r\n \"phase1\":\"phase 1\",\r\n \"phase2\": {\r\n \t\"another\": \"another\"\r\n }\r\n }\r\n }\r\n}" + }, + "url": { + "raw": "{{api-url}}/v4/productTemplates/1", + "host": [ + "{{api-url}}" + ], + "path": [ + "v4", + "productTemplates", + "1" + ] + } + }, + "response": [] + } + ] + }, + { + "name": "Project Type", + "description": null, + "item": [ + { + "name": "Create project type", + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + }, + { + "key": "Authorization", + "value": "Bearer {{jwt-token}}" + } + ], + "body": { + "mode": "raw", + "raw": "{\r\n \"param\":{\r\n \"key\": \"new key\",\r\n \"displayName\": \"new displayName\"\r\n }\r\n}" + }, + "url": { + "raw": "{{api-url}}/v4/projectTypes", + "host": [ + "{{api-url}}" + ], + "path": [ + "v4", + "projectTypes" + ] + } + }, + "response": [] + }, + { + "name": "List project types", + "request": { + "method": "GET", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + }, + { + "key": "Authorization", + "value": "Bearer {{jwt-token}}" + } + ], + "body": { + "mode": "raw", + "raw": "{\r\n \"param\":{\r\n \"name\":\"new name\",\r\n \"key\":\"new key\",\r\n \"category\":\"new category\",\r\n \"scope\":{\r\n \"scope1\":\"scope 1\"\r\n },\r\n \"phases\":{\r\n \"phase1\":\"phase 1\"\r\n }\r\n }\r\n}" + }, + "url": { + "raw": "{{api-url}}/v4/projectTypes", + "host": [ + "{{api-url}}" + ], + "path": [ + "v4", + "projectTypes" + ] + } + }, + "response": [] + }, + { + "name": "Get project type", + "request": { + "method": "GET", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + }, + { + "key": "Authorization", + "value": "Bearer {{jwt-token}}" + } + ], + "body": { + "mode": "raw", + "raw": "{\r\n \"param\":{\r\n \"name\":\"new name\",\r\n \"key\":\"new key\",\r\n \"category\":\"new category\",\r\n \"scope\":{\r\n \"scope1\":\"scope 1\"\r\n },\r\n \"phases\":{\r\n \"phase1\":\"phase 1\"\r\n }\r\n }\r\n}" + }, + "url": { + "raw": "{{api-url}}/v4/projectTypes/generic", + "host": [ + "{{api-url}}" + ], + "path": [ + "v4", + "projectTypes", + "generic" + ] + } + }, + "response": [] + }, + { + "name": "Update project type", + "request": { + "method": "PATCH", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + }, + { + "key": "Authorization", + "value": "Bearer {{jwt-token}}" + } + ], + "body": { + "mode": "raw", + "raw": "{\r\n \"param\":{\r\n \"displayName\": \"Chatbot-updated\"\r\n }\r\n}" + }, + "url": { + "raw": "{{api-url}}/v4/projectTypes/chatbot", + "host": [ + "{{api-url}}" + ], + "path": [ + "v4", + "projectTypes", + "chatbot" + ] + } + }, + "response": [] + }, + { + "name": "Delete project type", + "request": { + "method": "DELETE", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + }, + { + "key": "Authorization", + "value": "Bearer {{jwt-token}}" + } + ], + "body": { + "mode": "raw", + "raw": "{\r\n \"param\":{\r\n \"name\":\"new name\",\r\n \"key\":\"new key\",\r\n \"category\":\"new category\",\r\n \"scope\":{\r\n \"scope1\":\"scope 1\",\r\n \"scope2\": [\"a\"]\r\n },\r\n \"phases\":{\r\n \"phase1\":\"phase 1\",\r\n \"phase2\": {\r\n \t\"another\": \"another\"\r\n }\r\n }\r\n }\r\n}" + }, + "url": { + "raw": "{{api-url}}/v4/projectTypes/chatbot", + "host": [ + "{{api-url}}" + ], + "path": [ + "v4", + "projectTypes", + "chatbot" + ] + } + }, + "response": [] + } + ] + }, + { + "name": "issue86 (create project with templateId)", + "description": "", + "item": [ + { + "name": "Create project with templateId", + "request": { + "method": "POST", + "header": [ + { + "key": "Authorization", + "value": "Bearer {{jwt-token}}" + }, + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"param\": {\n \"name\": \"test project with templateId\",\n \"description\": \"Hello I am a test project with templateId\",\n \"type\": \"generic\",\n \"templateId\": 3\n }\n}" + }, + "url": { + "raw": "{{api-url}}/v4/projects", + "host": [ + "{{api-url}}" + ], + "path": [ + "v4", + "projects" + ] + } + }, + "response": [] + }, + { + "name": "Create project with templateId (not existed)", + "request": { + "method": "POST", + "header": [ + { + "key": "Authorization", + "value": "Bearer {{jwt-token}}" + }, + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n\t\"param\": {\n\t\t\"name\": \"test project with templateId\",\n\t\t\"description\": \"Hello I am a test project with templateId\",\n\t\t\"type\": \"generic\",\n\t\t\"templateId\": 3000\n\t}\n}" + }, + "url": { + "raw": "{{api-url}}/v4/projects", + "host": [ + "{{api-url}}" + ], + "path": [ + "v4", + "projects" + ] + } + }, + "response": [] + } + ] + }, + { + "name": "Project upgrade", + "description": "Request to migrate projects.", + "item": [ + { + "name": "Migrate project", + "request": { + "method": "POST", + "header": [ + { + "key": "Authorization", + "value": "Bearer {{jwt-token}}" + }, + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n\t\"param\": {\n\t\t\"targetVersion\": \"v3\",\n\t\t\"defaultProductTemplateId\": 3\n\t}\n}" + }, + "url": "{{api-url}}/v4/projects/6/upgrade", + "description": "" + }, + "response": [] + }, + { + "name": "Migrate project (completed)", + "request": { + "method": "POST", + "header": [ + { + "key": "Authorization", + "value": "Bearer {{jwt-token}}" + }, + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n\t\"param\": {\n\t\t\"targetVersion\": \"v3\",\n\t\t\"defaultProductTemplateId\": 3\n\t}\n}" + }, + "url": "{{api-url}}/v4/projects/7/upgrade", + "description": "" + }, + "response": [] + }, + { + "name": "Migrate project with phase name", + "request": { + "method": "POST", + "header": [ + { + "key": "Authorization", + "value": "Bearer {{jwt-token}}" + }, + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n\t\"param\": {\n\t\t\"targetVersion\": \"v3\",\n\t\t\"defaultProductTemplateId\": 3,\n\t\t\"phaseName\": \"Custom phase name\"\n\t}\n}" + }, + "url": "{{api-url}}/v4/projects/6/upgrade", + "description": "" + }, + "response": [] + }, + { + "name": "Migrate project with phase name (completed)", + "request": { + "method": "POST", + "header": [ + { + "key": "Authorization", + "value": "Bearer {{jwt-token}}" + }, + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n\t\"param\": {\n\t\t\"targetVersion\": \"v3\",\n\t\t\"defaultProductTemplateId\": 3,\n\t\t\"phaseName\": \"Custom phase name\"\n\t}\n}" + }, + "url": "{{api-url}}/v4/projects/7/upgrade", + "description": "" }, "response": [] } ] } ] -} \ No newline at end of file +} diff --git a/postman_environment.json b/postman_environment.json new file mode 100644 index 00000000..12fab912 --- /dev/null +++ b/postman_environment.json @@ -0,0 +1,23 @@ +{ + "id": "e6b30b4b-1388-4622-8314-bc49ba1d752b", + "name": "tc-project-service", + "values": [ + { + "key": "api-url", + "value": "http://localhost:3000", + "description": "", + "type": "text", + "enabled": true + }, + { + "key": "jwt-token", + "value": "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJyb2xlcyI6WyJUb3Bjb2RlciBVc2VyIiwiYWRtaW5pc3RyYXRvciJdLCJpc3MiOiJodHRwczovL2FwaS50b3Bjb2Rlci1kZXYuY29tIiwiaGFuZGxlIjoidGVzdDEiLCJleHAiOjI1NjMwNzY2ODksInVzZXJJZCI6IjQwMDUxMzMzIiwiaWF0IjoxNDYzMDc2MDg5LCJlbWFpbCI6InRlc3RAdG9wY29kZXIuY29tIiwianRpIjoiYjMzYjc3Y2QtYjUyZS00MGZlLTgzN2UtYmViOGUwYWU2YTRhIn0.wKWUe0-SaiFVN-VR_-GwgFlvWaDkSbc8H55ktb9LAVw", + "description": "", + "type": "text", + "enabled": true + } + ], + "_postman_variable_scope": "environment", + "_postman_exported_at": "2018-05-18T18:54:18.167Z", + "_postman_exported_using": "Postman/6.0.10" +} \ No newline at end of file diff --git a/src/constants.js b/src/constants.js index 39b0b398..4e24c806 100644 --- a/src/constants.js +++ b/src/constants.js @@ -1,15 +1,4 @@ -export const PROJECT_TYPE = { - APP_DEV: 'app_dev', - GENERIC: 'generic', - VISUAL_PROTOTYPE: 'visual_prototype', - VISUAL_DESIGN: 'visual_design', - WEBSITE: 'website', - APP: 'app', - QUALITY_ASSURANCE: 'quality_assurance', - CHATBOT: 'chatbot', -}; - export const PROJECT_STATUS = { DRAFT: 'draft', IN_REVIEW: 'in_review', @@ -20,6 +9,8 @@ export const PROJECT_STATUS = { CANCELLED: 'cancelled', }; +export const PROJECT_PHASE_STATUS = PROJECT_STATUS; + export const PROJECT_MEMBER_ROLE = { MANAGER: 'manager', CUSTOMER: 'customer', @@ -50,6 +41,14 @@ export const EVENT = { PROJECT_DRAFT_CREATED: 'project.draft-created', PROJECT_UPDATED: 'project.updated', PROJECT_DELETED: 'project.deleted', + + PROJECT_PHASE_ADDED: 'project.phase.added', + PROJECT_PHASE_UPDATED: 'project.phase.updated', + PROJECT_PHASE_REMOVED: 'project.phase.removed', + + PROJECT_PHASE_PRODUCT_ADDED: 'project.phase.product.added', + PROJECT_PHASE_PRODUCT_UPDATED: 'project.phase.product.updated', + PROJECT_PHASE_PRODUCT_REMOVED: 'project.phase.product.removed', }, }; @@ -72,6 +71,14 @@ export const BUS_API_EVENT = { PROJECT_LINK_CREATED: 'notifications.connect.project.linkCreated', PROJECT_FILE_UPLOADED: 'notifications.connect.project.fileUploaded', PROJECT_SPECIFICATION_MODIFIED: 'notifications.connect.project.specificationModified', + + // When phase is added/updated/deleted from the project, + // When product is added/deleted from a phase + // When product is updated on any field other than specification + PROJECT_PLAN_MODIFIED: 'notifications.connect.project.planModified', + + // When specification of a product is modified + PROJECT_PRODUCT_SPECIFICATION_MODIFIED: 'notifications.connect.project.productSpecificationModified', }; export const REGEX = { diff --git a/src/events/busApi.js b/src/events/busApi.js index ac5f7b45..afb1b432 100644 --- a/src/events/busApi.js +++ b/src/events/busApi.js @@ -92,14 +92,14 @@ module.exports = (app, logger) => { models.Project.findOne({ where: { id: projectId }, }) - .then((project) => { - createEvent(eventType, { - projectId, - projectName: project.name, - userId: member.userId, - initiatorUserId: req.authUser.userId, - }, logger); - }).catch(err => null); // eslint-disable-line no-unused-vars + .then((project) => { + createEvent(eventType, { + projectId, + projectName: project.name, + userId: member.userId, + initiatorUserId: req.authUser.userId, + }, logger); + }).catch(err => null); // eslint-disable-line no-unused-vars }); /** @@ -119,16 +119,16 @@ module.exports = (app, logger) => { models.Project.findOne({ where: { id: projectId }, }) - .then((project) => { - if (project) { - createEvent(eventType, { - projectId, - projectName: project.name, - userId: member.userId, - initiatorUserId: req.authUser.userId, - }, logger); - } - }).catch(err => null); // eslint-disable-line no-unused-vars + .then((project) => { + if (project) { + createEvent(eventType, { + projectId, + projectName: project.name, + userId: member.userId, + initiatorUserId: req.authUser.userId, + }, logger); + } + }).catch(err => null); // eslint-disable-line no-unused-vars }); /** @@ -142,16 +142,16 @@ module.exports = (app, logger) => { models.Project.findOne({ where: { id: projectId }, }) - .then((project) => { - if (project) { - createEvent(BUS_API_EVENT.MEMBER_ASSIGNED_AS_OWNER, { - projectId, - projectName: project.name, - userId: updated.userId, - initiatorUserId: req.authUser.userId, - }, logger); - } - }).catch(err => null); // eslint-disable-line no-unused-vars + .then((project) => { + if (project) { + createEvent(BUS_API_EVENT.MEMBER_ASSIGNED_AS_OWNER, { + projectId, + projectName: project.name, + userId: updated.userId, + initiatorUserId: req.authUser.userId, + }, logger); + } + }).catch(err => null); // eslint-disable-line no-unused-vars } }); @@ -166,14 +166,158 @@ module.exports = (app, logger) => { models.Project.findOne({ where: { id: projectId }, }) - .then((project) => { - createEvent(BUS_API_EVENT.PROJECT_FILE_UPLOADED, { - projectId, - projectName: project.name, - fileName: attachment.filePath.replace(/^.*[\\\/]/, ''), // eslint-disable-line - userId: req.authUser.userId, - initiatorUserId: req.authUser.userId, - }, logger); - }).catch(err => null); // eslint-disable-line no-unused-vars + .then((project) => { + createEvent(BUS_API_EVENT.PROJECT_FILE_UPLOADED, { + projectId, + projectName: project.name, + fileName: attachment.filePath.replace(/^.*[\\\/]/, ''), // eslint-disable-line + userId: req.authUser.userId, + initiatorUserId: req.authUser.userId, + }, logger); + }).catch(err => null); // eslint-disable-line no-unused-vars + }); + + /** + * PROJECT_PHASE_ADDED + */ + app.on(EVENT.ROUTING_KEY.PROJECT_PHASE_ADDED, ({ req, created }) => { // eslint-disable-line no-unused-vars + logger.debug('receive PROJECT_PHASE_ADDED event'); + + const projectId = _.parseInt(req.params.projectId); + + models.Project.findOne({ + where: { id: projectId }, + }) + .then((project) => { + createEvent(BUS_API_EVENT.PROJECT_PLAN_MODIFIED, { + projectId, + projectName: project.name, + userId: req.authUser.userId, + initiatorUserId: req.authUser.userId, + }, logger); + }).catch(err => null); // eslint-disable-line no-unused-vars + }); + + /** + * PROJECT_PHASE_REMOVED + */ + app.on(EVENT.ROUTING_KEY.PROJECT_PHASE_REMOVED, ({ req, deleted }) => { // eslint-disable-line no-unused-vars + logger.debug('receive PROJECT_PHASE_REMOVED event'); + + const projectId = _.parseInt(req.params.projectId); + + models.Project.findOne({ + where: { id: projectId }, + }) + .then((project) => { + createEvent(BUS_API_EVENT.PROJECT_PLAN_MODIFIED, { + projectId, + projectName: project.name, + userId: req.authUser.userId, + initiatorUserId: req.authUser.userId, + }, logger); + }).catch(err => null); // eslint-disable-line no-unused-vars + }); + + /** + * PROJECT_PHASE_UPDATED + */ + app.on(EVENT.ROUTING_KEY.PROJECT_PHASE_UPDATED, ({ req, original, updated }) => { // eslint-disable-line no-unused-vars + logger.debug('receive PROJECT_PHASE_UPDATED event'); + + const projectId = _.parseInt(req.params.projectId); + + models.Project.findOne({ + where: { id: projectId }, + }) + .then((project) => { + createEvent(BUS_API_EVENT.PROJECT_PLAN_MODIFIED, { + projectId, + projectName: project.name, + userId: req.authUser.userId, + initiatorUserId: req.authUser.userId, + }, logger); + }).catch(err => null); // eslint-disable-line no-unused-vars + }); + + /** + * PROJECT_PHASE_PRODUCT_ADDED + */ + app.on(EVENT.ROUTING_KEY.PROJECT_PHASE_PRODUCT_ADDED, ({ req, created }) => { // eslint-disable-line no-unused-vars + logger.debug('receive PROJECT_PHASE_PRODUCT_ADDED event'); + + const projectId = _.parseInt(req.params.projectId); + + models.Project.findOne({ + where: { id: projectId }, + }) + .then((project) => { + createEvent(BUS_API_EVENT.PROJECT_PLAN_MODIFIED, { + projectId, + projectName: project.name, + userId: req.authUser.userId, + initiatorUserId: req.authUser.userId, + phase: created, + }, logger); + }).catch(err => null); // eslint-disable-line no-unused-vars + }); + + /** + * PROJECT_PHASE_PRODUCT_REMOVED + */ + app.on(EVENT.ROUTING_KEY.PROJECT_PHASE_PRODUCT_REMOVED, ({ req, deleted }) => { // eslint-disable-line no-unused-vars + logger.debug('receive PROJECT_PHASE_PRODUCT_REMOVED event'); + + const projectId = _.parseInt(req.params.projectId); + + models.Project.findOne({ + where: { id: projectId }, + }) + .then((project) => { + createEvent(BUS_API_EVENT.PROJECT_PLAN_MODIFIED, { + projectId, + projectName: project.name, + userId: req.authUser.userId, + initiatorUserId: req.authUser.userId, + }, logger); + }).catch(err => null); // eslint-disable-line no-unused-vars + }); + + /** + * PROJECT_PHASE_PRODUCT_UPDATED + */ + app.on(EVENT.ROUTING_KEY.PROJECT_PHASE_PRODUCT_UPDATED, ({ req, original, updated }) => { // eslint-disable-line no-unused-vars + logger.debug('receive PROJECT_PHASE_PRODUCT_UPDATED event'); + + const projectId = _.parseInt(req.params.projectId); + + models.Project.findOne({ + where: { id: projectId }, + }) + .then((project) => { + // Spec changes + if (!_.isEqual(original.details, updated.details)) { + logger.debug(`Spec changed for product id ${updated.id}`); + + createEvent(BUS_API_EVENT.PROJECT_PRODUCT_SPECIFICATION_MODIFIED, { + projectId, + projectName: project.name, + userId: req.authUser.userId, + initiatorUserId: req.authUser.userId, + }, logger); + } + + // Other fields change + const originalWithouDetails = _.omit(original, 'details'); + const updatedWithouDetails = _.omit(updated, 'details'); + if (!_.isEqual(originalWithouDetails.details, updatedWithouDetails.details)) { + createEvent(BUS_API_EVENT.PROJECT_PLAN_MODIFIED, { + projectId, + projectName: project.name, + userId: req.authUser.userId, + initiatorUserId: req.authUser.userId, + }, logger); + } + }).catch(err => null); // eslint-disable-line no-unused-vars }); }; diff --git a/src/events/index.js b/src/events/index.js index a8ac3096..cf6decf8 100644 --- a/src/events/index.js +++ b/src/events/index.js @@ -5,6 +5,10 @@ import { projectMemberAddedHandler, projectMemberRemovedHandler, projectMemberUpdatedHandler } from './projectMembers'; import { projectAttachmentAddedHandler, projectAttachmentRemovedHandler, projectAttachmentUpdatedHandler } from './projectAttachments'; +import { projectPhaseAddedHandler, projectPhaseRemovedHandler, + projectPhaseUpdatedHandler } from './projectPhases'; +import { phaseProductAddedHandler, phaseProductRemovedHandler, + phaseProductUpdatedHandler } from './phaseProducts'; export default { 'project.initial': projectCreatedHandler, @@ -17,4 +21,13 @@ export default { [EVENT.ROUTING_KEY.PROJECT_ATTACHMENT_ADDED]: projectAttachmentAddedHandler, [EVENT.ROUTING_KEY.PROJECT_ATTACHMENT_REMOVED]: projectAttachmentRemovedHandler, [EVENT.ROUTING_KEY.PROJECT_ATTACHMENT_UPDATED]: projectAttachmentUpdatedHandler, + [EVENT.ROUTING_KEY.PROJECT_PHASE_ADDED]: projectPhaseAddedHandler, + [EVENT.ROUTING_KEY.PROJECT_PHASE_REMOVED]: projectPhaseRemovedHandler, + [EVENT.ROUTING_KEY.PROJECT_PHASE_UPDATED]: projectPhaseUpdatedHandler, + [EVENT.ROUTING_KEY.PROJECT_PHASE_ADDED]: projectPhaseAddedHandler, + [EVENT.ROUTING_KEY.PROJECT_PHASE_REMOVED]: projectPhaseRemovedHandler, + [EVENT.ROUTING_KEY.PROJECT_PHASE_UPDATED]: projectPhaseUpdatedHandler, + [EVENT.ROUTING_KEY.PROJECT_PHASE_PRODUCT_ADDED]: phaseProductAddedHandler, + [EVENT.ROUTING_KEY.PROJECT_PHASE_PRODUCT_REMOVED]: phaseProductRemovedHandler, + [EVENT.ROUTING_KEY.PROJECT_PHASE_PRODUCT_UPDATED]: phaseProductUpdatedHandler, }; diff --git a/src/events/phaseProducts/index.js b/src/events/phaseProducts/index.js new file mode 100644 index 00000000..b6b6c063 --- /dev/null +++ b/src/events/phaseProducts/index.js @@ -0,0 +1,129 @@ +/** + * Event handlers for phase product create, update and delete. + * Current functionality just updates the elasticsearch indexes. + */ + +import config from 'config'; +import _ from 'lodash'; +import Promise from 'bluebird'; +import util from '../../util'; + +const ES_PROJECT_INDEX = config.get('elasticsearchConfig.indexName'); +const ES_PROJECT_TYPE = config.get('elasticsearchConfig.docType'); + +const eClient = util.getElasticSearchClient(); + +/** + * Handler for phase product creation event + * @param {Object} logger logger to log along with trace id + * @param {Object} msg event payload + * @param {Object} channel channel to ack, nack + * @returns {undefined} + */ +const phaseProductAddedHandler = Promise.coroutine(function* (logger, msg, channel) { // eslint-disable-line func-names + try { + const data = JSON.parse(msg.content.toString()); + const doc = yield eClient.get({ index: ES_PROJECT_INDEX, type: ES_PROJECT_TYPE, id: data.projectId }); + const phases = _.isArray(doc._source.phases) ? doc._source.phases : []; // eslint-disable-line no-underscore-dangle + + _.each(phases, (phase) => { + if (phase.id === data.phaseId) { + phase.products = _.isArray(phase.products) ? phase.products : []; // eslint-disable-line no-param-reassign + phase.products.push(_.omit(data, ['deletedAt', 'deletedBy'])); + } + }); + + const merged = _.assign(doc._source, { phases }); // eslint-disable-line no-underscore-dangle + yield eClient.update({ index: ES_PROJECT_INDEX, type: ES_PROJECT_TYPE, id: data.projectId, body: { doc: merged } }); + logger.debug('phase product added to project document successfully'); + channel.ack(msg); + } catch (error) { + logger.error('Error handling project.phase.added event', error); + // if the message has been redelivered dont attempt to reprocess it + channel.nack(msg, false, !msg.fields.redelivered); + } +}); + +/** + * Handler for phase product updated event + * @param {Object} logger logger to log along with trace id + * @param {Object} msg event payload + * @param {Object} channel channel to ack, nack + * @returns {undefined} + */ +const phaseProductUpdatedHandler = Promise.coroutine(function* (logger, msg, channel) { // eslint-disable-line func-names + try { + const data = JSON.parse(msg.content.toString()); + const doc = yield eClient.get({ index: ES_PROJECT_INDEX, type: ES_PROJECT_TYPE, id: data.original.projectId }); + const phases = _.map(doc._source.phases, (phase) => { // eslint-disable-line no-underscore-dangle + if (phase.id === data.original.phaseId) { + phase.products = _.map(phase.products, (product) => { // eslint-disable-line no-param-reassign + if (product.id === data.original.id) { + return _.assign(product, _.omit(data.updated, ['deletedAt', 'deletedBy'])); + } + return product; + }); + } + return phase; + }); + const merged = _.assign(doc._source, { phases }); // eslint-disable-line no-underscore-dangle + yield eClient.update({ + index: ES_PROJECT_INDEX, + type: ES_PROJECT_TYPE, + id: data.original.projectId, + body: { + doc: merged, + }, + }); + logger.debug('elasticsearch index updated, phase product updated successfully'); + channel.ack(msg); + } catch (error) { + logger.error('Error handling project.phase.updated event', error); + // if the message has been redelivered dont attempt to reprocess it + channel.nack(msg, false, !msg.fields.redelivered); + } +}); + +/** + * Handler for phase product deleted event + * @param {Object} logger logger to log along with trace id + * @param {Object} msg event payload + * @param {Object} channel channel to ack, nack + * @returns {undefined} + */ +const phaseProductRemovedHandler = Promise.coroutine(function* (logger, msg, channel) { // eslint-disable-line func-names + try { + const data = JSON.parse(msg.content.toString()); + const doc = yield eClient.get({ index: ES_PROJECT_INDEX, type: ES_PROJECT_TYPE, id: data.projectId }); + const phases = _.map(doc._source.phases, (phase) => { // eslint-disable-line no-underscore-dangle + if (phase.id === data.phaseId) { + phase.products = _.filter(phase.products, product => product.id !== data.id); // eslint-disable-line no-param-reassign + } + return phase; + }); + + const merged = _.assign(doc._source, { phases }); // eslint-disable-line no-underscore-dangle + + yield eClient.update({ + index: ES_PROJECT_INDEX, + type: ES_PROJECT_TYPE, + id: data.projectId, + body: { + doc: merged, + }, + }); + logger.debug('phase product removed from project document successfully'); + channel.ack(msg); + } catch (error) { + logger.error('Error fetching project document from elasticsearch', error); + // if the message has been redelivered dont attempt to reprocess it + channel.nack(msg, false, !msg.fields.redelivered); + } +}); + + +module.exports = { + phaseProductAddedHandler, + phaseProductRemovedHandler, + phaseProductUpdatedHandler, +}; diff --git a/src/events/projectPhases/index.js b/src/events/projectPhases/index.js new file mode 100644 index 00000000..98123522 --- /dev/null +++ b/src/events/projectPhases/index.js @@ -0,0 +1,211 @@ +/** + * Event handlers for project phase create, update and delete. + * Current functionality just updates the elasticsearch indexes. + */ + +import config from 'config'; +import _ from 'lodash'; +import Promise from 'bluebird'; +import util from '../../util'; +import messageService from '../../services/messageService'; + +const ES_PROJECT_INDEX = config.get('elasticsearchConfig.indexName'); +const ES_PROJECT_TYPE = config.get('elasticsearchConfig.docType'); + +const eClient = util.getElasticSearchClient(); + +/** + * Indexes the project phase in the elastic search. + * + * @param {Object} logger logger to log along with trace id + * @param {Object} phase event payload + * @returns {undefined} + */ +const indexProjectPhase = Promise.coroutine(function* (logger, phase) { // eslint-disable-line func-names + try { + // const phase = JSON.parse(msg.content.toString()); + const doc = yield eClient.get({ index: ES_PROJECT_INDEX, type: ES_PROJECT_TYPE, id: phase.projectId }); + const phases = _.isArray(doc._source.phases) ? doc._source.phases : []; // eslint-disable-line no-underscore-dangle + const existingPhaseIndex = _.findIndex(phases, p => p.id === phase.id); + // if phase does not exists already + if (existingPhaseIndex === -1) { + phases.push(_.omit(phase, ['deletedAt', 'deletedBy'])); + } else { // if phase already exists, ideally we should never land here, but code handles the buggy indexing + // replaces the old inconsistent index where previously phase was not removed from the index but deleted + // from the database + phases.splice(existingPhaseIndex, 1, phase); + } + const merged = _.assign(doc._source, { phases }); // eslint-disable-line no-underscore-dangle + yield eClient.update({ + index: ES_PROJECT_INDEX, + type: ES_PROJECT_TYPE, + id: phase.projectId, + body: { doc: merged }, + }); + logger.debug('project phase added to project document successfully'); + } catch (error) { + logger.error('Error handling indexing the project phase', error); + // throw the error back to nack the bus + throw error; + } +}); + +/** + * Creates a new phase topic in message api. + * + * @param {Object} logger logger to log along with trace id + * @param {Object} msg event payload + * @returns {undefined} + */ +const createPhaseTopic = Promise.coroutine(function* (logger, phase) { // eslint-disable-line func-names + try { + logger.debug('Creating topic for phase with phase', phase); + const topic = yield messageService.createTopic({ + reference: 'project', + referenceId: `${phase.projectId}`, + tag: `phase#${phase.id}`, + title: phase.name, + body: 'Welcome!!! Please use this channel for communication around the phase.', + }, logger); + logger.debug('topic for the phase created successfully'); + logger.debug('created topic', topic); + } catch (error) { + logger.error('Error in creating topic for the project phase', error); + // don't throw the error back to nack the bus, because we don't want to get multiple topics per phase + // we can create topic for a phase manually, if somehow it fails + } +}); + +/** + * Handler for project phase creation event + * @param {Object} logger logger to log along with trace id + * @param {Object} msg event payload + * @param {Object} channel channel to ack, nack + * @returns {undefined} + */ +const projectPhaseAddedHandler = Promise.coroutine(function* (logger, msg, channel) { // eslint-disable-line func-names + const phase = JSON.parse(msg.content.toString()); + try { + logger.debug('calling indexProjectPhase', phase); + yield indexProjectPhase(logger, phase, channel); + logger.debug('calling createPhaseTopic', phase); + yield createPhaseTopic(logger, phase); + channel.ack(msg); + } catch (error) { + logger.error('Error handling project.phase.added event', error); + // if the message has been redelivered dont attempt to reprocess it + channel.nack(msg, false, !msg.fields.redelivered); + } +}); + +/** + * Handler for project phase updated event + * @param {Object} logger logger to log along with trace id + * @param {Object} msg event payload + * @param {Object} channel channel to ack, nack + * @returns {undefined} + */ +const projectPhaseUpdatedHandler = Promise.coroutine(function* (logger, msg, channel) { // eslint-disable-line func-names + try { + const data = JSON.parse(msg.content.toString()); + const doc = yield eClient.get({ index: ES_PROJECT_INDEX, type: ES_PROJECT_TYPE, id: data.original.projectId }); + const phases = _.map(doc._source.phases, (single) => { // eslint-disable-line no-underscore-dangle + if (single.id === data.original.id) { + return _.assign(single, _.omit(data.updated, ['deletedAt', 'deletedBy'])); + } + return single; + }); + const merged = _.assign(doc._source, { phases }); // eslint-disable-line no-underscore-dangle + yield eClient.update({ + index: ES_PROJECT_INDEX, + type: ES_PROJECT_TYPE, + id: data.original.projectId, + body: { + doc: merged, + }, + }); + logger.debug('elasticsearch index updated, project phase updated successfully'); + channel.ack(msg); + } catch (error) { + logger.error('Error handling project.phase.updated event', error); + // if the message has been redelivered dont attempt to reprocess it + channel.nack(msg, false, !msg.fields.redelivered); + } +}); + +/** + * Removes the project phase from the elastic search. + * + * @param {Object} logger logger to log along with trace id + * @param {Object} msg event payload + * @returns {undefined} + */ +const removePhaseFromIndex = Promise.coroutine(function* (logger, msg) { // eslint-disable-line func-names + try { + const data = JSON.parse(msg.content.toString()); + const doc = yield eClient.get({ index: ES_PROJECT_INDEX, type: ES_PROJECT_TYPE, id: data.projectId }); + const phases = _.filter(doc._source.phases, single => single.id !== data.id); // eslint-disable-line no-underscore-dangle + const merged = _.assign(doc._source, { phases }); // eslint-disable-line no-underscore-dangle + yield eClient.update({ + index: ES_PROJECT_INDEX, + type: ES_PROJECT_TYPE, + id: data.projectId, + body: { + doc: merged, + }, + }); + logger.debug('project phase removed from project document successfully'); + } catch (error) { + logger.error('Error in removing project phase from index', error); + // throw the error back to nack the bus + throw error; + } +}); + +/** + * Removes the phase topic from the message api. + * + * @param {Object} logger logger to log along with trace id + * @param {Object} msg event payload + * @returns {undefined} + */ +const removePhaseTopic = Promise.coroutine(function* (logger, msg) { // eslint-disable-line func-names + try { + const phase = JSON.parse(msg.content.toString()); + const phaseTopic = yield messageService.getPhaseTopic(phase.projectId, phase.id, logger); + yield messageService.deletePosts(phaseTopic.id, phaseTopic.postIds, logger); + yield messageService.deleteTopic(phaseTopic.id, logger); + logger.debug('topic for the phase removed successfully'); + } catch (error) { + logger.error('Error in removing topic for the project phase', error); + // don't throw the error back to nack the bus + // we can delete topic for a phase manually, if somehow it fails + } +}); + +/** + * Handler for project phase deleted event + * @param {Object} logger logger to log along with trace id + * @param {Object} msg event payload + * @param {Object} channel channel to ack, nack + * @returns {undefined} + */ +const projectPhaseRemovedHandler = Promise.coroutine(function* (logger, msg, channel) { // eslint-disable-line func-names + try { + yield removePhaseFromIndex(logger, msg, channel); + yield removePhaseTopic(logger, msg); + channel.ack(msg); + } catch (error) { + logger.error('Error fetching project document from elasticsearch', error); + // if the message has been redelivered dont attempt to reprocess it + channel.nack(msg, false, !msg.fields.redelivered); + } +}); + + +module.exports = { + projectPhaseAddedHandler, + projectPhaseRemovedHandler, + projectPhaseUpdatedHandler, + createPhaseTopic, +}; diff --git a/src/events/projects/index.js b/src/events/projects/index.js index 2f8afa2d..64a7690a 100644 --- a/src/events/projects/index.js +++ b/src/events/projects/index.js @@ -5,19 +5,20 @@ import _ from 'lodash'; import Promise from 'bluebird'; import config from 'config'; import util from '../../util'; +import { createPhaseTopic } from '../projectPhases'; const ES_PROJECT_INDEX = config.get('elasticsearchConfig.indexName'); const ES_PROJECT_TYPE = config.get('elasticsearchConfig.docType'); const eClient = util.getElasticSearchClient(); /** - * Handler for project creation event + * Indexes the project in the elastic search. + * * @param {Object} logger logger to log along with trace id - * @param {Object} msg event payload - * @param {Object} channel channel to ack, nack + * @param {Object} msg event payload which is essentially a project in JSON format * @returns {undefined} */ -const projectCreatedHandler = Promise.coroutine(function* (logger, msg, channel) { // eslint-disable-line func-names +const indexProject = Promise.coroutine(function* (logger, msg) { // eslint-disable-line func-names const data = JSON.parse(msg.content.toString()); const userIds = data.members ? data.members.map(single => `userId:${single.userId}`) : []; try { @@ -26,14 +27,17 @@ const projectCreatedHandler = Promise.coroutine(function* (logger, msg, channel) // if no members are returned than this should result in nack if (!_.isArray(memberDetails) || memberDetails.length === 0) { logger.error(`Empty member details for userIds ${userIds.join(',')} requeing the message`); - channel.nack(msg, false, !msg.fields.redelivered); - return undefined; + throw new Error(`Empty member details for userIds ${userIds.join(',')} requeing the message`); } // update project member record with details data.members = data.members.map((single) => { const detail = _.find(memberDetails, md => md.userId === single.userId); return _.merge(single, _.pick(detail, 'handle', 'firstName', 'lastName', 'email')); }); + if (data.phases) { + // removes non required fields from phase objects + data.phases = data.phases.map(phase => _.omit(phase, ['deletedAt', 'deletedBy'])); + } // add the record to the index const result = yield eClient.index({ index: ES_PROJECT_INDEX, @@ -42,12 +46,32 @@ const projectCreatedHandler = Promise.coroutine(function* (logger, msg, channel) body: data, }); logger.debug(`project indexed successfully (projectId: ${data.id})`, result); + } catch (error) { + logger.error(`Error indexing project (projectId: ${data.id})`, error); + throw error; + } +}); + +/** + * Handler for project creation event + * @param {Object} logger logger to log along with trace id + * @param {Object} msg event payload + * @param {Object} channel channel to ack, nack + * @returns {undefined} + */ +const projectCreatedHandler = Promise.coroutine(function* (logger, msg, channel) { // eslint-disable-line func-names + const project = JSON.parse(msg.content.toString()); + try { + yield indexProject(logger, msg); + if (project.phases && project.phases.length > 0) { + logger.debug('Phases found for the project, trying to create topics for each phase.'); + const topicPromises = _.map(project.phases, phase => createPhaseTopic(logger, phase)); + yield Promise.all(topicPromises); + } channel.ack(msg); - return undefined; } catch (error) { - logger.error(`Error processing event (projectId: ${data.id})`, error); + logger.error(`Error processing event (projectId: ${project.id})`, error); channel.nack(msg, false, !msg.fields.redelivered); - return undefined; } }); @@ -73,11 +97,11 @@ const projectUpdatedHandler = Promise.coroutine(function* (logger, msg, channel) doc: merged, }, }); - logger.debug(`project updated successfully in elasticsearh index, (projectId: ${data.id})`); + logger.debug(`project updated successfully in elasticsearh index, (projectId: ${data.original.id})`); channel.ack(msg); return undefined; } catch (error) { - logger.error(`failed to get project document, (projectId: ${data.id})`, error); + logger.error(`failed to get project document, (projectId: ${data.original.id})`, error); channel.nack(msg, false, !msg.fields.redelivered); return undefined; } diff --git a/src/models/phaseProduct.js b/src/models/phaseProduct.js new file mode 100644 index 00000000..4ec1ea90 --- /dev/null +++ b/src/models/phaseProduct.js @@ -0,0 +1,45 @@ + + +module.exports = function definePhaseProduct(sequelize, DataTypes) { + const PhaseProduct = sequelize.define('PhaseProduct', { + id: { type: DataTypes.BIGINT, primaryKey: true, autoIncrement: true }, + name: { type: DataTypes.STRING, allowNull: true }, + projectId: DataTypes.BIGINT, + directProjectId: DataTypes.BIGINT, + billingAccountId: DataTypes.BIGINT, + // TODO: associate this with product_template + templateId: { type: DataTypes.BIGINT, defaultValue: 0 }, + type: { type: DataTypes.STRING, allowNull: true }, + estimatedPrice: { type: DataTypes.DOUBLE, defaultValue: 0.0 }, + actualPrice: { type: DataTypes.DOUBLE, defaultValue: 0.0 }, + details: { type: DataTypes.JSON, defaultValue: {} }, + + deletedAt: { type: DataTypes.DATE, allowNull: true }, + createdAt: { type: DataTypes.DATE, defaultValue: DataTypes.NOW }, + updatedAt: { type: DataTypes.DATE, defaultValue: DataTypes.NOW }, + deletedBy: { type: DataTypes.INTEGER, allowNull: true }, + createdBy: { type: DataTypes.INTEGER, allowNull: false }, + updatedBy: { type: DataTypes.INTEGER, allowNull: false }, + }, { + tableName: 'phase_products', + paranoid: false, + timestamps: true, + updatedAt: 'updatedAt', + createdAt: 'createdAt', + deletedAt: 'deletedAt', + indexes: [], + classMethods: { + getActivePhaseProducts(phaseId) { + return this.findAll({ + where: { + deletedAt: { $eq: null }, + phaseId, + }, + raw: true, + }); + }, + }, + }); + + return PhaseProduct; +}; diff --git a/src/models/productTemplate.js b/src/models/productTemplate.js new file mode 100644 index 00000000..cf1cf2db --- /dev/null +++ b/src/models/productTemplate.js @@ -0,0 +1,34 @@ +/* eslint-disable valid-jsdoc */ + +/** + * The Product Template model + */ +module.exports = (sequelize, DataTypes) => { + const ProductTemplate = sequelize.define('ProductTemplate', { + id: { type: DataTypes.BIGINT, primaryKey: true, autoIncrement: true }, + name: { type: DataTypes.STRING(255), allowNull: false }, + productKey: { type: DataTypes.STRING(45), allowNull: false }, + icon: { type: DataTypes.STRING(255), allowNull: false }, + brief: { type: DataTypes.STRING(45), allowNull: false }, + details: { type: DataTypes.STRING(255), allowNull: false }, + aliases: { type: DataTypes.JSON, allowNull: false }, + template: { type: DataTypes.JSON, allowNull: false }, + deletedAt: DataTypes.DATE, + disabled: { type: DataTypes.BOOLEAN, defaultValue: false }, + hidden: { type: DataTypes.BOOLEAN, defaultValue: false }, + createdAt: { type: DataTypes.DATE, defaultValue: DataTypes.NOW }, + updatedAt: { type: DataTypes.DATE, defaultValue: DataTypes.NOW }, + deletedBy: DataTypes.BIGINT, + createdBy: { type: DataTypes.BIGINT, allowNull: false }, + updatedBy: { type: DataTypes.BIGINT, allowNull: false }, + }, { + tableName: 'product_templates', + paranoid: true, + timestamps: true, + updatedAt: 'updatedAt', + createdAt: 'createdAt', + deletedAt: 'deletedAt', + }); + + return ProductTemplate; +}; diff --git a/src/models/project.js b/src/models/project.js index 0c832e5f..310a3e75 100644 --- a/src/models/project.js +++ b/src/models/project.js @@ -1,7 +1,7 @@ /* eslint-disable valid-jsdoc */ import _ from 'lodash'; -import { PROJECT_TYPE, PROJECT_STATUS, PROJECT_MEMBER_ROLE } from '../constants'; +import { PROJECT_STATUS, PROJECT_MEMBER_ROLE } from '../constants'; module.exports = function defineProject(sequelize, DataTypes) { const Project = sequelize.define('Project', { @@ -23,9 +23,6 @@ module.exports = function defineProject(sequelize, DataTypes) { type: { type: DataTypes.STRING, allowNull: false, - validate: { - isIn: [_.values(PROJECT_TYPE)], - }, }, status: { type: DataTypes.STRING, @@ -37,11 +34,13 @@ module.exports = function defineProject(sequelize, DataTypes) { details: { type: DataTypes.JSON }, challengeEligibility: DataTypes.JSON, cancelReason: DataTypes.STRING, + templateId: DataTypes.BIGINT, deletedAt: { type: DataTypes.DATE, allowNull: true }, createdAt: { type: DataTypes.DATE, defaultValue: DataTypes.NOW }, updatedAt: { type: DataTypes.DATE, defaultValue: DataTypes.NOW }, createdBy: { type: DataTypes.INTEGER, allowNull: false }, updatedBy: { type: DataTypes.INTEGER, allowNull: false }, + version: { type: DataTypes.STRING(3), allowNull: false, defaultValue: 'v3' }, }, { tableName: 'projects', timestamps: true, @@ -92,6 +91,7 @@ module.exports = function defineProject(sequelize, DataTypes) { associate: (models) => { Project.hasMany(models.ProjectMember, { as: 'members', foreignKey: 'projectId' }); Project.hasMany(models.ProjectAttachment, { as: 'attachments', foreignKey: 'projectId' }); + Project.hasMany(models.ProjectPhase, { as: 'phases', foreignKey: 'projectId' }); }, /** @@ -155,11 +155,21 @@ module.exports = function defineProject(sequelize, DataTypes) { .then(projects => ({ rows: projects, count })); }); }, - findProjectRange(startId, endId, fields) { + findProjectRange(models, startId, endId, fields) { return this.findAll({ where: { id: { $between: [startId, endId] } }, attributes: _.get(fields, 'projects', null), raw: true, + include: [{ + model: models.ProjectPhase, + as: 'phases', + order: [['startDate', 'asc']], + // where: phasesWhere, + include: [{ + model: models.PhaseProduct, + as: 'products', + }], + }], }); }, }, diff --git a/src/models/projectPhase.js b/src/models/projectPhase.js new file mode 100644 index 00000000..bcfe827a --- /dev/null +++ b/src/models/projectPhase.js @@ -0,0 +1,107 @@ +/* eslint-disable valid-jsdoc */ + +import _ from 'lodash'; + +module.exports = function defineProjectPhase(sequelize, DataTypes) { + const ProjectPhase = sequelize.define('ProjectPhase', { + id: { type: DataTypes.BIGINT, primaryKey: true, autoIncrement: true }, + name: { type: DataTypes.STRING, allowNull: true }, + status: { type: DataTypes.STRING, allowNull: true }, + startDate: { type: DataTypes.DATE, allowNull: true }, + endDate: { type: DataTypes.DATE, allowNull: true }, + duration: { type: DataTypes.INTEGER, allowNull: true }, + budget: { type: DataTypes.DOUBLE, defaultValue: 0.0 }, + spentBudget: { type: DataTypes.DOUBLE, defaultValue: 0.0 }, + progress: { type: DataTypes.DOUBLE, defaultValue: 0.0 }, + details: { type: DataTypes.JSON, defaultValue: {} }, + + deletedAt: { type: DataTypes.DATE, allowNull: true }, + createdAt: { type: DataTypes.DATE, defaultValue: DataTypes.NOW }, + updatedAt: { type: DataTypes.DATE, defaultValue: DataTypes.NOW }, + deletedBy: { type: DataTypes.INTEGER, allowNull: true }, + createdBy: { type: DataTypes.INTEGER, allowNull: false }, + updatedBy: { type: DataTypes.INTEGER, allowNull: false }, + }, { + tableName: 'project_phases', + paranoid: false, + timestamps: true, + updatedAt: 'updatedAt', + createdAt: 'createdAt', + deletedAt: 'deletedAt', + indexes: [], + classMethods: { + getActiveProjectPhases(projectId) { + return this.findAll({ + where: { + deletedAt: { $eq: null }, + projectId, + }, + raw: true, + }); + }, + associate: (models) => { + ProjectPhase.hasMany(models.PhaseProduct, { as: 'products', foreignKey: 'phaseId' }); + }, + /** + * Search name or status + * @param parameters the parameters + * - filters: the filters contains keyword + * - order: the order + * - limit: the limit + * - offset: the offset + * - attributes: the attributes to get + * @param log the request log + * @return the result rows and count + */ + searchText(parameters, log) { + // special handling for keyword filter + let query = '1=1 '; + if (_.has(parameters.filters, 'id')) { + if (_.isObject(parameters.filters.id)) { + if (parameters.filters.id.$in.length === 0) { + parameters.filters.id.$in.push(-1); + } + query += `AND id IN (${parameters.filters.id.$in}) `; + } else if (_.isString(parameters.filters.id) || _.isNumber(parameters.filters.id)) { + query += `AND id = ${parameters.filters.id} `; + } + } + if (_.has(parameters.filters, 'status')) { + const statusFilter = parameters.filters.status; + if (_.isObject(statusFilter)) { + const statuses = statusFilter.$in.join("','"); + query += `AND status IN ('${statuses}') `; + } else if (_.isString(statusFilter)) { + query += `AND status ='${statusFilter}'`; + } + } + if (_.has(parameters.filters, 'name')) { + query += `AND name like '%${parameters.filters.name}%' `; + } + + const attributesStr = `"${parameters.attributes.join('","')}"`; + const orderStr = `"${parameters.order[0][0]}" ${parameters.order[0][1]}`; + + // select count of project_phases + return sequelize.query(`SELECT COUNT(1) FROM project_phases WHERE ${query}`, + { type: sequelize.QueryTypes.SELECT, + logging: (str) => { log.debug(str); }, + raw: true, + }) + .then((fcount) => { + const count = fcount[0].count; + // select project attributes + return sequelize.query(`SELECT ${attributesStr} FROM project_phases WHERE ${query} ORDER BY ` + + ` ${orderStr} LIMIT ${parameters.limit} OFFSET ${parameters.offset}`, + { type: sequelize.QueryTypes.SELECT, + logging: (str) => { log.debug(str); }, + raw: true, + }) + .then(phases => ({ rows: phases, count })); + }); + }, + }, + }); + + return ProjectPhase; +}; diff --git a/src/models/projectTemplate.js b/src/models/projectTemplate.js new file mode 100644 index 00000000..b2cb2581 --- /dev/null +++ b/src/models/projectTemplate.js @@ -0,0 +1,36 @@ +/* eslint-disable valid-jsdoc */ + +/** + * The Project Template model + */ +module.exports = (sequelize, DataTypes) => { + const ProjectTemplate = sequelize.define('ProjectTemplate', { + id: { type: DataTypes.BIGINT, primaryKey: true, autoIncrement: true }, + name: { type: DataTypes.STRING(255), allowNull: false }, + key: { type: DataTypes.STRING(45), allowNull: false }, + category: { type: DataTypes.STRING(45), allowNull: false }, + icon: { type: DataTypes.STRING(255), allowNull: false }, + question: { type: DataTypes.STRING(255), allowNull: false }, + info: { type: DataTypes.STRING(255), allowNull: false }, + aliases: { type: DataTypes.JSON, allowNull: false }, + scope: { type: DataTypes.JSON, allowNull: false }, + phases: { type: DataTypes.JSON, allowNull: false }, + disabled: { type: DataTypes.BOOLEAN, defaultValue: false }, + hidden: { type: DataTypes.BOOLEAN, defaultValue: false }, + deletedAt: DataTypes.DATE, + createdAt: { type: DataTypes.DATE, defaultValue: DataTypes.NOW }, + updatedAt: { type: DataTypes.DATE, defaultValue: DataTypes.NOW }, + deletedBy: DataTypes.BIGINT, + createdBy: { type: DataTypes.BIGINT, allowNull: false }, + updatedBy: { type: DataTypes.BIGINT, allowNull: false }, + }, { + tableName: 'project_templates', + paranoid: true, + timestamps: true, + updatedAt: 'updatedAt', + createdAt: 'createdAt', + deletedAt: 'deletedAt', + }); + + return ProjectTemplate; +}; diff --git a/src/models/projectType.js b/src/models/projectType.js new file mode 100644 index 00000000..ff8163ff --- /dev/null +++ b/src/models/projectType.js @@ -0,0 +1,24 @@ + + +module.exports = function definePhaseProduct(sequelize, DataTypes) { + const ProjectType = sequelize.define('ProjectType', { + key: { type: DataTypes.STRING(45), primaryKey: true }, + displayName: { type: DataTypes.STRING(255), allowNull: false }, + + deletedAt: { type: DataTypes.DATE, allowNull: true }, + createdAt: { type: DataTypes.DATE, defaultValue: DataTypes.NOW }, + updatedAt: { type: DataTypes.DATE, defaultValue: DataTypes.NOW }, + deletedBy: { type: DataTypes.INTEGER, allowNull: true }, + createdBy: { type: DataTypes.INTEGER, allowNull: false }, + updatedBy: { type: DataTypes.INTEGER, allowNull: false }, + }, { + tableName: 'project_types', + paranoid: true, + timestamps: true, + updatedAt: 'updatedAt', + createdAt: 'createdAt', + deletedAt: 'deletedAt', + }); + + return ProjectType; +}; diff --git a/src/permissions/connectManagerOrAdmin.ops.js b/src/permissions/connectManagerOrAdmin.ops.js new file mode 100644 index 00000000..0a5fb15a --- /dev/null +++ b/src/permissions/connectManagerOrAdmin.ops.js @@ -0,0 +1,18 @@ +import util from '../util'; +import { MANAGER_ROLES } from '../constants'; + + +/** + * Only Connect Manager, Connect Admin, and administrator are allowed to perform the operations + * @param {Object} req the express request instance + * @return {Promise} returns a promise + */ +module.exports = req => new Promise((resolve, reject) => { + const hasAccess = util.hasRoles(req, MANAGER_ROLES); + + if (!hasAccess) { + return reject(new Error('You do not have permissions to perform this action')); + } + + return resolve(true); +}); diff --git a/src/permissions/copilotAndAbove.js b/src/permissions/copilotAndAbove.js new file mode 100644 index 00000000..e5d5121a --- /dev/null +++ b/src/permissions/copilotAndAbove.js @@ -0,0 +1,18 @@ +import util from '../util'; +import { MANAGER_ROLES, USER_ROLE } from '../constants'; + + +/** + * Permission to alloow copilot and above roles to perform certain operations + * @param {Object} req the express request instance + * @return {Promise} returns a promise + */ +module.exports = req => new Promise((resolve, reject) => { + const hasAccess = util.hasRoles(req, [...MANAGER_ROLES, USER_ROLE.COPILOT]); + + if (!hasAccess) { + return reject(new Error('You do not have permissions to perform this action')); + } + + return resolve(true); +}); diff --git a/src/permissions/index.js b/src/permissions/index.js index e0797b3c..1cd1591a 100644 --- a/src/permissions/index.js +++ b/src/permissions/index.js @@ -6,6 +6,8 @@ const projectEdit = require('./project.edit'); const projectDelete = require('./project.delete'); const projectMemberDelete = require('./projectMember.delete'); const projectAdmin = require('./admin.ops'); +const connectManagerOrAdmin = require('./connectManagerOrAdmin.ops'); +const copilotAndAbove = require('./copilotAndAbove'); module.exports = () => { Authorizer.setDeniedStatusCode(403); @@ -23,4 +25,26 @@ module.exports = () => { Authorizer.setPolicy('project.downloadAttachment', projectView); Authorizer.setPolicy('project.updateMember', projectEdit); Authorizer.setPolicy('project.admin', projectAdmin); + + Authorizer.setPolicy('projectTemplate.create', connectManagerOrAdmin); + Authorizer.setPolicy('projectTemplate.edit', connectManagerOrAdmin); + Authorizer.setPolicy('projectTemplate.delete', connectManagerOrAdmin); + Authorizer.setPolicy('projectTemplate.view', true); + + Authorizer.setPolicy('productTemplate.create', connectManagerOrAdmin); + Authorizer.setPolicy('productTemplate.edit', connectManagerOrAdmin); + Authorizer.setPolicy('productTemplate.delete', connectManagerOrAdmin); + Authorizer.setPolicy('productTemplate.view', true); + + Authorizer.setPolicy('project.addProjectPhase', copilotAndAbove); + Authorizer.setPolicy('project.updateProjectPhase', copilotAndAbove); + Authorizer.setPolicy('project.deleteProjectPhase', copilotAndAbove); + Authorizer.setPolicy('project.addPhaseProduct', copilotAndAbove); + Authorizer.setPolicy('project.updatePhaseProduct', copilotAndAbove); + Authorizer.setPolicy('project.deletePhaseProduct', copilotAndAbove); + + Authorizer.setPolicy('projectType.create', projectAdmin); + Authorizer.setPolicy('projectType.edit', projectAdmin); + Authorizer.setPolicy('projectType.delete', projectAdmin); + Authorizer.setPolicy('projectType.view', true); // anyone can view project types }; diff --git a/src/routes/admin/project-index-create.js b/src/routes/admin/project-index-create.js index 96e4757e..2a57a00b 100644 --- a/src/routes/admin/project-index-create.js +++ b/src/routes/admin/project-index-create.js @@ -50,13 +50,17 @@ module.exports = [ }); const eClient = util.getElasticSearchClient(); - return models.Project.findProjectRange(projectIdStart, projectIdEnd, fields) + return models.Project.findProjectRange(models, projectIdStart, projectIdEnd, fields) .then((_projects) => { const projects = _projects.map((_project) => { const project = _project; if (!project) { return Promise.resolve(null); } + if (project.phases) { + // removs the delete audit fields from the index data + project.phases = project.phases.map(phase => _.omit(phase, ['deletedAt', 'deletedBy'])); + } return models.ProjectMember.getActiveProjectMembers(project.id) .then((currentProjectMembers) => { // check context for project members diff --git a/src/routes/admin/project-index-delete.js b/src/routes/admin/project-index-delete.js index 2564aca8..54f1c403 100644 --- a/src/routes/admin/project-index-delete.js +++ b/src/routes/admin/project-index-delete.js @@ -49,7 +49,7 @@ module.exports = [ }); const eClient = util.getElasticSearchClient(); - return models.Project.findProjectRange(projectIdStart, projectIdEnd, fields) + return models.Project.findProjectRange(models, projectIdStart, projectIdEnd, fields) .then((_projects) => { const projects = _projects.map((_project) => { const project = _project; diff --git a/src/routes/index.js b/src/routes/index.js index 47a51502..289f47c6 100644 --- a/src/routes/index.js +++ b/src/routes/index.js @@ -26,7 +26,9 @@ router.get(`/${apiVersion}/projects/health`, (req, res) => { // All project service endpoints need authentication const jwtAuth = require('tc-core-library-js').middleware.jwtAuthenticator; -router.all(RegExp(`\\/${apiVersion}\\/projects(?!\\/health).*`), jwtAuth()); +router.all( + RegExp(`\\/${apiVersion}\\/(projects|projectTemplates|productTemplates|projectTypes)(?!\\/health).*`), + jwtAuth()); // Register all the routes router.route('/v4/projects') @@ -51,19 +53,67 @@ router.route('/v4/projects/:projectId(\\d+)') .delete(require('./projects/delete')); router.route('/v4/projects/:projectId(\\d+)/members') - .post(require('./projectMembers/create')); + .post(require('./projectMembers/create')); router.route('/v4/projects/:projectId(\\d+)/members/:id(\\d+)') - .delete(require('./projectMembers/delete')) - .patch(require('./projectMembers/update')); + .delete(require('./projectMembers/delete')) + .patch(require('./projectMembers/update')); router.route('/v4/projects/:projectId(\\d+)/attachments') - .post(require('./attachments/create')); -router.route('/v4/projects/:projectId(\\d+)/attachments/:id(\\d+)') - .get(require('./attachments/download')) - .patch(require('./attachments/update')) - .delete(require('./attachments/delete')); + .post(require('./attachments/create')); +router.route('/v4/projects/:projectId(\\d+)/attachments/:id(\\d+)') + .get(require('./attachments/download')) + .patch(require('./attachments/update')) + .delete(require('./attachments/delete')); + +router.route('/v4/projects/:projectId(\\d+)/upgrade') + .post(require('./projectUpgrade/create')); + +router.route('/v4/projectTemplates') + .post(require('./projectTemplates/create')) + .get(require('./projectTemplates/list')); + +router.route('/v4/projectTemplates/:templateId(\\d+)') + .get(require('./projectTemplates/get')) + .patch(require('./projectTemplates/update')) + .delete(require('./projectTemplates/delete')); + +router.route('/v4/productTemplates') + .post(require('./productTemplates/create')) + .get(require('./productTemplates/list')); + +router.route('/v4/productTemplates/:templateId(\\d+)') + .get(require('./productTemplates/get')) + .patch(require('./productTemplates/update')) + .delete(require('./productTemplates/delete')); + +router.route('/v4/projects/:projectId(\\d+)/phases') + .get(require('./phases/list')) + .post(require('./phases/create')); + +router.route('/v4/projects/:projectId(\\d+)/phases/:phaseId(\\d+)') + .get(require('./phases/get')) + .patch(require('./phases/update')) + .delete(require('./phases/delete')); + +router.route('/v4/projects/:projectId(\\d+)/phases/:phaseId(\\d+)/products') + .get(require('./phaseProducts/list')) + .post(require('./phaseProducts/create')); + +router.route('/v4/projects/:projectId(\\d+)/phases/:phaseId(\\d+)/products/:productId(\\d+)') + .get(require('./phaseProducts/get')) + .patch(require('./phaseProducts/update')) + .delete(require('./phaseProducts/delete')); + +router.route('/v4/projectTypes') + .post(require('./projectTypes/create')) + .get(require('./projectTypes/list')); + +router.route('/v4/projectTypes/:key') + .get(require('./projectTypes/get')) + .patch(require('./projectTypes/update')) + .delete(require('./projectTypes/delete')); // register error handler router.use((err, req, res, next) => { // eslint-disable-line no-unused-vars diff --git a/src/routes/phaseProducts/create.js b/src/routes/phaseProducts/create.js new file mode 100644 index 00000000..47ba2825 --- /dev/null +++ b/src/routes/phaseProducts/create.js @@ -0,0 +1,115 @@ + +import validate from 'express-validation'; +import _ from 'lodash'; +import config from 'config'; +import Joi from 'joi'; + +import models from '../../models'; +import util from '../../util'; +import { EVENT } from '../../constants'; + +const permissions = require('tc-core-library-js').middleware.permissions; + +const addPhaseProductValidations = { + body: { + param: Joi.object().keys({ + name: Joi.string().required(), + type: Joi.string().required(), + templateId: Joi.number().positive().optional(), + directProjectId: Joi.number().positive().optional(), + billingAccountId: Joi.number().positive().optional(), + estimatedPrice: Joi.number().positive().optional(), + actualPrice: Joi.number().positive().optional(), + details: Joi.any().optional(), + }).required(), + }, +}; + +module.exports = [ + // validate request payload + validate(addPhaseProductValidations), + // check permission + permissions('project.addPhaseProduct'), + // do the real work + (req, res, next) => { + const projectId = _.parseInt(req.params.projectId); + const phaseId = _.parseInt(req.params.phaseId); + + const data = req.body.param; + // default values + _.assign(data, { + createdBy: req.authUser.userId, + updatedBy: req.authUser.userId, + }); + + let newPhaseProduct = null; + models.sequelize.transaction(() => models.Project.findOne({ + where: { id: projectId, deletedAt: { $eq: null } }, + raw: true, + }).then((existingProject) => { + // make sure project exists + if (!existingProject) { + const err = new Error(`project not found for project id ${projectId}`); + err.status = 404; + throw err; + } + _.assign(data, { + projectId, + directProjectId: existingProject.directProjectId, + billingAccountId: existingProject.billingAccountId, + }); + + return models.ProjectPhase.findOne({ + where: { id: phaseId, projectId, deletedAt: { $eq: null } }, + raw: true, + }); + }).then((existingPhase) => { + // make sure phase exists + if (!existingPhase) { + const err = new Error(`project phase not found for project id ${projectId}` + + ` and phase id ${phaseId}`); + err.status = 404; + throw err; + } + _.assign(data, { + phaseId, + }); + + return models.PhaseProduct.count({ + where: { + projectId, + phaseId, + deletedAt: { $eq: null }, + }, + raw: true, + }); + }).then((productCount) => { + // make sure number of products of per phase <= max value + if (productCount >= config.maxPhaseProductCount) { + const err = new Error('the number of products per phase cannot exceed ' + + `${config.maxPhaseProductCount}`); + err.status = 400; + throw err; + } + return models.PhaseProduct.create(data); + }) + .then((_newPhaseProduct) => { + newPhaseProduct = _.cloneDeep(_newPhaseProduct); + req.log.debug('new phase product created (id# %d, name: %s)', + newPhaseProduct.id, newPhaseProduct.name); + newPhaseProduct = newPhaseProduct.get({ plain: true }); + newPhaseProduct = _.omit(newPhaseProduct, ['deletedAt', 'utm']); + + // Send events to buses + req.log.debug('Sending event to RabbitMQ bus for phase product %d', newPhaseProduct.id); + req.app.services.pubsub.publish(EVENT.ROUTING_KEY.PROJECT_PHASE_PRODUCT_ADDED, + newPhaseProduct, + { correlationId: req.id }, + ); + req.log.debug('Sending event to Kafka bus for phase product %d', newPhaseProduct.id); + req.app.emit(EVENT.ROUTING_KEY.PROJECT_PHASE_PRODUCT_ADDED, { req, created: newPhaseProduct }); + + res.status(201).json(util.wrapResponse(req.id, newPhaseProduct, 1, 201)); + })).catch((err) => { next(err); }); + }, +]; diff --git a/src/routes/phaseProducts/create.spec.js b/src/routes/phaseProducts/create.spec.js new file mode 100644 index 00000000..e5fe049d --- /dev/null +++ b/src/routes/phaseProducts/create.spec.js @@ -0,0 +1,219 @@ +/* eslint-disable no-unused-expressions */ +import _ from 'lodash'; +import chai from 'chai'; +import request from 'supertest'; +import server from '../../app'; +import models from '../../models'; +import testUtil from '../../tests/util'; + +const should = chai.should(); + +const body = { + name: 'test phase product', + type: 'product1', + estimatedPrice: 20.0, + actualPrice: 1.23456, + details: { + message: 'This can be any json', + }, +}; + +describe('Phase Products', () => { + let projectId; + let phaseId; + 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((done) => { + // mocks + testUtil.clearDb() + .then(() => { + models.Project.create({ + type: 'generic', + billingAccountId: 1, + name: 'test1', + description: 'test project1', + status: 'draft', + details: {}, + createdBy: 1, + updatedBy: 1, + }).then((p) => { + projectId = p.id; + // create members + models.ProjectMember.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; + done(); + }); + }); + }); + }); + }); + + after((done) => { + testUtil.clearDb(done); + }); + + describe('POST /projects/{projectId}/phases/{phaseId}/products', () => { + it('should return 403 if user does not have permissions (non team member)', (done) => { + request(server) + .post(`/v4/projects/${projectId}/phases/${phaseId}/products`) + .set({ + Authorization: `Bearer ${testUtil.jwts.member2}`, + }) + .send({ param: body }) + .expect('Content-Type', /json/) + .expect(403, done); + }); + + it('should return 403 if user does not have permissions (customer)', (done) => { + request(server) + .post(`/v4/projects/${projectId}/phases/${phaseId}/products`) + .set({ + Authorization: `Bearer ${testUtil.jwts.member}`, + }) + .send({ param: body }) + .expect('Content-Type', /json/) + .expect(403, done); + }); + + it('should return 422 when name not provided', (done) => { + const reqBody = _.cloneDeep(body); + delete reqBody.name; + request(server) + .post(`/v4/projects/${projectId}/phases/${phaseId}/products`) + .set({ + Authorization: `Bearer ${testUtil.jwts.copilot}`, + }) + .send({ param: reqBody }) + .expect('Content-Type', /json/) + .expect(422, done); + }); + + it('should return 422 when type not provided', (done) => { + const reqBody = _.cloneDeep(body); + delete reqBody.type; + request(server) + .post(`/v4/projects/${projectId}/phases/${phaseId}/products`) + .set({ + Authorization: `Bearer ${testUtil.jwts.copilot}`, + }) + .send({ param: reqBody }) + .expect('Content-Type', /json/) + .expect(422, done); + }); + + it('should return 422 when estimatedPrice is negative', (done) => { + const reqBody = _.cloneDeep(body); + reqBody.estimatedPrice = -20; + request(server) + .post(`/v4/projects/${projectId}/phases/${phaseId}/products`) + .set({ + Authorization: `Bearer ${testUtil.jwts.copilot}`, + }) + .send({ param: reqBody }) + .expect('Content-Type', /json/) + .expect(422, done); + }); + + it('should return 422 when actualPrice is negative', (done) => { + const reqBody = _.cloneDeep(body); + reqBody.actualPrice = -20; + request(server) + .post(`/v4/projects/${projectId}/phases/${phaseId}/products`) + .set({ + Authorization: `Bearer ${testUtil.jwts.copilot}`, + }) + .send({ param: reqBody }) + .expect('Content-Type', /json/) + .expect(422, done); + }); + + it('should return 404 when project is not found', (done) => { + request(server) + .post(`/v4/projects/99999/phases/${phaseId}/products`) + .set({ + Authorization: `Bearer ${testUtil.jwts.manager}`, + }) + .send({ param: body }) + .expect('Content-Type', /json/) + .expect(404, done); + }); + + it('should return 404 when project phase is not found', (done) => { + request(server) + .post(`/v4/projects/${projectId}/phases/99999/products`) + .set({ + Authorization: `Bearer ${testUtil.jwts.manager}`, + }) + .send({ param: body }) + .expect('Content-Type', /json/) + .expect(404, done); + }); + + it('should return 201 if payload is valid', (done) => { + request(server) + .post(`/v4/projects/${projectId}/phases/${phaseId}/products`) + .set({ + Authorization: `Bearer ${testUtil.jwts.copilot}`, + }) + .send({ param: body }) + .expect('Content-Type', /json/) + .expect(201) + .end((err, res) => { + if (err) { + done(err); + } else { + const resJson = res.body.result.content; + should.exist(resJson); + resJson.name.should.be.eql(body.name); + resJson.type.should.be.eql(body.type); + resJson.estimatedPrice.should.be.eql(body.estimatedPrice); + resJson.actualPrice.should.be.eql(body.actualPrice); + resJson.details.should.be.eql(body.details); + done(); + } + }); + }); + }); +}); diff --git a/src/routes/phaseProducts/delete.js b/src/routes/phaseProducts/delete.js new file mode 100644 index 00000000..2faa6295 --- /dev/null +++ b/src/routes/phaseProducts/delete.js @@ -0,0 +1,53 @@ + + +import _ from 'lodash'; +import { middleware as tcMiddleware } from 'tc-core-library-js'; +import models from '../../models'; +import { EVENT } from '../../constants'; + +const permissions = tcMiddleware.permissions; + +module.exports = [ + // check permission + permissions('project.deletePhaseProduct'), + + (req, res, next) => { + const projectId = _.parseInt(req.params.projectId); + const phaseId = _.parseInt(req.params.phaseId); + const productId = _.parseInt(req.params.productId); + + models.sequelize.transaction(() => + // soft delete the record + models.PhaseProduct.findOne({ + where: { + id: productId, + projectId, + phaseId, + deletedAt: { $eq: null }, + }, + }).then(existing => new Promise((accept, reject) => { + if (!existing) { + // handle 404 + const err = new Error('No active phase product found for project id ' + + `${projectId}, phase id ${phaseId} and product id ${productId}`); + err.status = 404; + reject(err); + } else { + _.extend(existing, { deletedBy: req.authUser.userId, deletedAt: Date.now() }); + existing.save().then(accept).catch(reject); + } + })).then((deleted) => { + req.log.debug('deleted phase product', JSON.stringify(deleted, null, 2)); + + // Send events to buses + req.app.services.pubsub.publish( + EVENT.ROUTING_KEY.PROJECT_PHASE_PRODUCT_REMOVED, + deleted, + { correlationId: req.id }, + ); + req.app.emit(EVENT.ROUTING_KEY.PROJECT_PHASE_PRODUCT_REMOVED, { req, deleted }); + + res.status(204).json({}); + }).catch(err => next(err))); + }, +]; diff --git a/src/routes/phaseProducts/delete.spec.js b/src/routes/phaseProducts/delete.spec.js new file mode 100644 index 00000000..9bf234b4 --- /dev/null +++ b/src/routes/phaseProducts/delete.spec.js @@ -0,0 +1,162 @@ +/* eslint-disable no-unused-expressions */ +import _ from 'lodash'; +import request from 'supertest'; +import server from '../../app'; +import models from '../../models'; +import testUtil from '../../tests/util'; + +const body = { + name: 'test phase product', + type: 'product1', + estimatedPrice: 20.0, + actualPrice: 1.23456, + details: { + message: 'This can be any json', + }, + createdBy: 1, + updatedBy: 1, +}; + +describe('Phase Products', () => { + let projectId; + let phaseId; + let productId; + 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((done) => { + // mocks + testUtil.clearDb() + .then(() => { + models.Project.create({ + type: 'generic', + billingAccountId: 1, + name: 'test1', + description: 'test project1', + status: 'draft', + details: {}, + createdBy: 1, + updatedBy: 1, + }).then((p) => { + projectId = p.id; + // create members + models.ProjectMember.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 }); + + models.PhaseProduct.create(body).then((product) => { + productId = product.id; + done(); + }); + }); + }); + }); + }); + }); + + after((done) => { + testUtil.clearDb(done); + }); + + describe('DELETE /projects/{id}/phases/{phaseId}/products/{productId}', () => { + it('should return 403 when user have no permission (non team member)', (done) => { + request(server) + .delete(`/v4/projects/${projectId}/phases/${phaseId}/products/${productId}`) + .set({ + Authorization: `Bearer ${testUtil.jwts.member2}`, + }) + .expect('Content-Type', /json/) + .expect(403, done); + }); + + it('should return 403 when user have no permission (customer)', (done) => { + request(server) + .delete(`/v4/projects/${projectId}/phases/${phaseId}/products/${productId}`) + .set({ + Authorization: `Bearer ${testUtil.jwts.member}`, + }) + .expect('Content-Type', /json/) + .expect(403, done); + }); + + it('should return 404 when no project with specific projectId', (done) => { + request(server) + .delete(`/v4/projects/999/phases/${phaseId}/products/${productId}`) + .set({ + Authorization: `Bearer ${testUtil.jwts.manager}`, + }) + .expect('Content-Type', /json/) + .expect(404, done); + }); + + it('should return 404 when no phase with specific phaseId', (done) => { + request(server) + .delete(`/v4/projects/${projectId}/phases/99999/products/${productId}`) + .set({ + Authorization: `Bearer ${testUtil.jwts.manager}`, + }) + .expect('Content-Type', /json/) + .expect(404, done); + }); + + it('should return 404 when no product with specific productId', (done) => { + request(server) + .delete(`/v4/projects/${projectId}/phases/${phaseId}/products/99999`) + .set({ + Authorization: `Bearer ${testUtil.jwts.manager}`, + }) + .expect('Content-Type', /json/) + .expect(404, done); + }); + + it('should return 204 when user have project permission', (done) => { + request(server) + .delete(`/v4/projects/${projectId}/phases/${phaseId}/products/${productId}`) + .set({ + Authorization: `Bearer ${testUtil.jwts.copilot}`, + }) + .expect(204, done); + }); + }); +}); diff --git a/src/routes/phaseProducts/get.js b/src/routes/phaseProducts/get.js new file mode 100644 index 00000000..30aa2ed4 --- /dev/null +++ b/src/routes/phaseProducts/get.js @@ -0,0 +1,36 @@ + +import _ from 'lodash'; + +import models from '../../models'; +import util from '../../util'; + +const permissions = require('tc-core-library-js').middleware.permissions; + +module.exports = [ + // check permission + permissions('project.view'), + + (req, res, next) => { + const projectId = _.parseInt(req.params.projectId); + const phaseId = _.parseInt(req.params.phaseId); + const productId = _.parseInt(req.params.productId); + + return models.PhaseProduct.findOne({ + where: { + id: productId, + projectId, + phaseId, + }, + }).then((product) => { + if (!product) { + // handle 404 + const err = new Error('phase product not found for project id ' + + `${projectId}, phase id ${phaseId} and product id ${productId}`); + err.status = 404; + throw err; + } else { + res.json(util.wrapResponse(req.id, product)); + } + }).catch(err => next(err)); + }, +]; diff --git a/src/routes/phaseProducts/get.spec.js b/src/routes/phaseProducts/get.spec.js new file mode 100644 index 00000000..d2022f17 --- /dev/null +++ b/src/routes/phaseProducts/get.spec.js @@ -0,0 +1,194 @@ +/* eslint-disable no-unused-expressions */ +import _ from 'lodash'; +import chai from 'chai'; +import request from 'supertest'; +import server from '../../app'; +import models from '../../models'; +import testUtil from '../../tests/util'; + +const should = chai.should(); + +const body = { + name: 'test phase product', + type: 'product1', + estimatedPrice: 20.0, + actualPrice: 1.23456, + details: { + message: 'This can be any json', + }, + createdBy: 1, + updatedBy: 1, +}; + +describe('Phase Products', () => { + let projectId; + let phaseId; + let productId; + 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((done) => { + // mocks + testUtil.clearDb() + .then(() => { + models.Project.create({ + type: 'generic', + billingAccountId: 1, + name: 'test1', + description: 'test project1', + status: 'draft', + details: {}, + createdBy: 1, + updatedBy: 1, + }).then((p) => { + projectId = p.id; + // create members + models.ProjectMember.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 }); + + models.PhaseProduct.create(body).then((product) => { + productId = product.id; + done(); + }); + }); + }); + }); + }); + }); + + after((done) => { + testUtil.clearDb(done); + }); + + describe('GET /projects/{id}/phases/{phaseId}/products/{productId}', () => { + it('should return 403 when user have no permission (non team member)', (done) => { + request(server) + .get(`/v4/projects/${projectId}/phases/${phaseId}/products/${productId}`) + .set({ + Authorization: `Bearer ${testUtil.jwts.member2}`, + }) + .expect('Content-Type', /json/) + .expect(403, done); + }); + + it('should return 404 when no project with specific projectId', (done) => { + request(server) + .get(`/v4/projects/999/phases/${phaseId}/products/${productId}`) + .set({ + Authorization: `Bearer ${testUtil.jwts.manager}`, + }) + .expect('Content-Type', /json/) + .expect(404, done); + }); + + it('should return 404 when no phase with specific phaseId', (done) => { + request(server) + .get(`/v4/projects/${projectId}/phases/99999/products/${productId}`) + .set({ + Authorization: `Bearer ${testUtil.jwts.manager}`, + }) + .expect('Content-Type', /json/) + .expect(404, done); + }); + + it('should return 404 when no product with specific productId', (done) => { + request(server) + .get(`/v4/projects/${projectId}/phases/${phaseId}/products/99999`) + .set({ + Authorization: `Bearer ${testUtil.jwts.manager}`, + }) + .expect('Content-Type', /json/) + .expect(404, done); + }); + + it('should return 1 phase when user have project permission (customer)', (done) => { + request(server) + .get(`/v4/projects/${projectId}/phases/${phaseId}/products/${productId}`) + .set({ + Authorization: `Bearer ${testUtil.jwts.member}`, + }) + .expect('Content-Type', /json/) + .expect(200) + .end((err, res) => { + if (err) { + done(err); + } else { + const resJson = res.body.result.content; + should.exist(resJson); + resJson.name.should.be.eql(body.name); + resJson.type.should.be.eql(body.type); + resJson.estimatedPrice.should.be.eql(body.estimatedPrice); + resJson.actualPrice.should.be.eql(body.actualPrice); + resJson.details.should.be.eql(body.details); + done(); + } + }); + }); + + it('should return 1 phase when user have project permission (copilot)', (done) => { + request(server) + .get(`/v4/projects/${projectId}/phases/${phaseId}/products/${productId}`) + .set({ + Authorization: `Bearer ${testUtil.jwts.copilot}`, + }) + .expect('Content-Type', /json/) + .expect(200) + .end((err, res) => { + if (err) { + done(err); + } else { + const resJson = res.body.result.content; + should.exist(resJson); + resJson.name.should.be.eql(body.name); + resJson.type.should.be.eql(body.type); + resJson.estimatedPrice.should.be.eql(body.estimatedPrice); + resJson.actualPrice.should.be.eql(body.actualPrice); + resJson.details.should.be.eql(body.details); + done(); + } + }); + }); + }); +}); diff --git a/src/routes/phaseProducts/list.js b/src/routes/phaseProducts/list.js new file mode 100644 index 00000000..5899c425 --- /dev/null +++ b/src/routes/phaseProducts/list.js @@ -0,0 +1,49 @@ + +import _ from 'lodash'; +import config from 'config'; +import util from '../../util'; + +const ES_PROJECT_INDEX = config.get('elasticsearchConfig.indexName'); +const ES_PROJECT_TYPE = config.get('elasticsearchConfig.docType'); + +const eClient = util.getElasticSearchClient(); + +const permissions = require('tc-core-library-js').middleware.permissions; + +module.exports = [ + // check permission + permissions('project.view'), + + (req, res, next) => { + const projectId = _.parseInt(req.params.projectId); + const phaseId = _.parseInt(req.params.phaseId); + + // Get project from ES + eClient.get({ index: ES_PROJECT_INDEX, type: ES_PROJECT_TYPE, id: req.params.projectId }) + .then((doc) => { + if (!doc) { + const err = new Error(`active project not found for project id ${projectId}`); + err.status = 404; + throw err; + } + + // Get the phases + let phases = _.isArray(doc._source.phases) ? doc._source.phases : []; // eslint-disable-line no-underscore-dangle + + // Get the phase by id + phases = _.filter(phases, { id: phaseId }); + if (phases.length <= 0) { + const err = new Error(`active project phase not found for phase id ${phaseId}`); + err.status = 404; + throw err; + } + + // Get the products + let products = phases[0].products; + products = _.isArray(products) ? products : []; // eslint-disable-line no-underscore-dangle + + res.json(util.wrapResponse(req.id, products, products.length)); + }) + .catch(err => next(err)); + }, +]; diff --git a/src/routes/phaseProducts/list.spec.js b/src/routes/phaseProducts/list.spec.js new file mode 100644 index 00000000..0b0b46db --- /dev/null +++ b/src/routes/phaseProducts/list.spec.js @@ -0,0 +1,201 @@ +/* eslint-disable no-unused-expressions */ +import _ from 'lodash'; +import request from 'supertest'; +import sleep from 'sleep'; +import chai from 'chai'; +import config from 'config'; +import server from '../../app'; +import models from '../../models'; +import testUtil from '../../tests/util'; + +const ES_PROJECT_INDEX = config.get('elasticsearchConfig.indexName'); +const ES_PROJECT_TYPE = config.get('elasticsearchConfig.docType'); + +const should = chai.should(); + +const body = { + name: 'test phase product', + type: 'product1', + estimatedPrice: 20.0, + actualPrice: 1.23456, + details: { + message: 'This can be any json', + }, + createdBy: 1, + updatedBy: 1, +}; + +describe('Phase Products', () => { + let projectId; + let phaseId; + let project; + 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, + }).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.phases = [phase.toJSON()]; + + models.PhaseProduct.create(body).then((product) => { + project.phases[0].products = [product.toJSON()]; + + // Index to ES + return server.services.es.index({ + index: ES_PROJECT_INDEX, + type: ES_PROJECT_TYPE, + id: projectId, + body: project, + }).then(() => { + // sleep for some time, let elasticsearch indices be settled + sleep.sleep(5); + done(); + }); + }); + }); + }); + }); + }); + }); + + after((done) => { + testUtil.clearDb(done); + }); + + describe('GET /projects/{id}/phases/{phaseId}/products', () => { + it('should return 403 when user have no permission (non team member)', (done) => { + request(server) + .get(`/v4/projects/${projectId}/phases/${phaseId}/products`) + .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`) + .set({ + Authorization: `Bearer ${testUtil.jwts.manager}`, + }) + .send({ param: body }) + .expect('Content-Type', /json/) + .expect(404, done); + }); + + it('should return 404 when no phase with specific phaseId', (done) => { + request(server) + .get(`/v4/projects/${projectId}/phases/99999/products`) + .set({ + Authorization: `Bearer ${testUtil.jwts.manager}`, + }) + .send({ param: body }) + .expect('Content-Type', /json/) + .expect(404, done); + }); + + it('should return 1 phase when user have project permission (customer)', (done) => { + request(server) + .get(`/v4/projects/${projectId}/phases/${phaseId}/products`) + .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`) + .set({ + Authorization: `Bearer ${testUtil.jwts.copilot}`, + }) + .send({ param: body }) + .expect('Content-Type', /json/) + .expect(200) + .end((err, res) => { + if (err) { + done(err); + } else { + const resJson = res.body.result.content; + should.exist(resJson); + resJson.should.have.lengthOf(1); + done(); + } + }); + }); + }); +}); diff --git a/src/routes/phaseProducts/update.js b/src/routes/phaseProducts/update.js new file mode 100644 index 00000000..fa7ac460 --- /dev/null +++ b/src/routes/phaseProducts/update.js @@ -0,0 +1,82 @@ + +import validate from 'express-validation'; +import _ from 'lodash'; +import Joi from 'joi'; +import { middleware as tcMiddleware } from 'tc-core-library-js'; +import models from '../../models'; +import util from '../../util'; +import { EVENT } from '../../constants'; + + +const permissions = tcMiddleware.permissions; + +const updatePhaseProductValidation = { + body: { + param: Joi.object().keys({ + name: Joi.string().optional(), + type: Joi.string().optional(), + templateId: Joi.number().optional(), + directProjectId: Joi.number().positive().optional(), + billingAccountId: Joi.number().positive().optional(), + estimatedPrice: Joi.number().positive().optional(), + actualPrice: Joi.number().positive().optional(), + details: Joi.any().optional(), + }).required(), + }, +}; + + +module.exports = [ + // validate request payload + validate(updatePhaseProductValidation), + // check permission + permissions('project.updatePhaseProduct'), + + (req, res, next) => { + const projectId = _.parseInt(req.params.projectId); + const phaseId = _.parseInt(req.params.phaseId); + const productId = _.parseInt(req.params.productId); + + const updatedProps = req.body.param; + updatedProps.updatedBy = req.authUser.userId; + + let previousValue; + + models.sequelize.transaction(() => models.PhaseProduct.findOne({ + where: { + id: productId, + projectId, + phaseId, + deletedAt: { $eq: null }, + }, + }).then(existing => new Promise((accept, reject) => { + if (!existing) { + // handle 404 + const err = new Error('No active phase product found for project id ' + + `${projectId}, phase id ${phaseId} and product id ${productId}`); + err.status = 404; + reject(err); + } else { + previousValue = _.clone(existing.get({ plain: true })); + + _.extend(existing, updatedProps); + existing.save().then(accept).catch(reject); + } + })).then((updated) => { + req.log.debug('updated phase product', JSON.stringify(updated, null, 2)); + + const updatedValue = updated.get({ plain: true }); + + // emit original and updated project phase information + req.app.services.pubsub.publish( + EVENT.ROUTING_KEY.PROJECT_PHASE_PRODUCT_UPDATED, + { original: previousValue, updated: updatedValue }, + { correlationId: req.id }, + ); + req.app.emit(EVENT.ROUTING_KEY.PROJECT_PHASE_PRODUCT_UPDATED, + { req, original: previousValue, updated: updatedValue }); + + res.json(util.wrapResponse(req.id, updated)); + }).catch(err => next(err))); + }, +]; diff --git a/src/routes/phaseProducts/update.spec.js b/src/routes/phaseProducts/update.spec.js new file mode 100644 index 00000000..1f0d91ac --- /dev/null +++ b/src/routes/phaseProducts/update.spec.js @@ -0,0 +1,212 @@ +/* eslint-disable no-unused-expressions */ +import _ from 'lodash'; +import chai from 'chai'; +import request from 'supertest'; +import server from '../../app'; +import models from '../../models'; +import testUtil from '../../tests/util'; + +const should = chai.should(); + +const body = { + name: 'test phase product', + type: 'product1', + estimatedPrice: 20.0, + actualPrice: 1.23456, + details: { + message: 'This can be any json', + }, + createdBy: 1, + updatedBy: 1, +}; + +const updateBody = { + name: 'test phase product xxx', + type: 'product2', + estimatedPrice: 123456.789, + actualPrice: 9.8765432, + details: { + message: 'This is another json', + }, +}; + +describe('Phase Products', () => { + let projectId; + let phaseId; + let productId; + 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((done) => { + // mocks + testUtil.clearDb() + .then(() => { + models.Project.create({ + type: 'generic', + billingAccountId: 1, + name: 'test1', + description: 'test project1', + status: 'draft', + details: {}, + createdBy: 1, + updatedBy: 1, + }).then((p) => { + projectId = p.id; + // create members + models.ProjectMember.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 }); + + models.PhaseProduct.create(body).then((product) => { + productId = product.id; + done(); + }); + }); + }); + }); + }); + }); + + after((done) => { + testUtil.clearDb(done); + }); + + describe('PATCH /projects/{id}/phases/{phaseId}/products/{productId}', () => { + it('should return 403 when user have no permission (non team member)', (done) => { + request(server) + .patch(`/v4/projects/${projectId}/phases/${phaseId}/products/${productId}`) + .set({ + Authorization: `Bearer ${testUtil.jwts.member2}`, + }) + .send({ param: updateBody }) + .expect('Content-Type', /json/) + .expect(403, done); + }); + + it('should return 403 when user have no permission (customer)', (done) => { + request(server) + .patch(`/v4/projects/${projectId}/phases/${phaseId}/products/${productId}`) + .set({ + Authorization: `Bearer ${testUtil.jwts.member}`, + }) + .send({ param: updateBody }) + .expect('Content-Type', /json/) + .expect(403, done); + }); + + it('should return 404 when no project with specific projectId', (done) => { + request(server) + .patch(`/v4/projects/999/phases/${phaseId}/products/${productId}`) + .set({ + Authorization: `Bearer ${testUtil.jwts.manager}`, + }) + .send({ param: updateBody }) + .expect('Content-Type', /json/) + .expect(404, done); + }); + + it('should return 404 when no phase with specific phaseId', (done) => { + request(server) + .patch(`/v4/projects/${projectId}/phases/99999/products/${productId}`) + .set({ + Authorization: `Bearer ${testUtil.jwts.manager}`, + }) + .send({ param: updateBody }) + .expect('Content-Type', /json/) + .expect(404, done); + }); + + it('should return 404 when no product with specific productId', (done) => { + request(server) + .patch(`/v4/projects/${projectId}/phases/${phaseId}/products/99999`) + .set({ + Authorization: `Bearer ${testUtil.jwts.manager}`, + }) + .send({ param: updateBody }) + .expect('Content-Type', /json/) + .expect(404, done); + }); + + it('should return 422 when parameters are invalid', (done) => { + request(server) + .patch(`/v4/projects/${projectId}/phases/${phaseId}/products/99999`) + .set({ + Authorization: `Bearer ${testUtil.jwts.manager}`, + }) + .send({ + param: { + estimatedPrice: -15, + }, + }) + .expect('Content-Type', /json/) + .expect(422, done); + }); + + + it('should return updated product when user have permission and parameters are valid', (done) => { + request(server) + .patch(`/v4/projects/${projectId}/phases/${phaseId}/products/${productId}`) + .set({ + Authorization: `Bearer ${testUtil.jwts.copilot}`, + }) + .send({ param: updateBody }) + .expect('Content-Type', /json/) + .expect(200) + .end((err, res) => { + if (err) { + done(err); + } else { + const resJson = res.body.result.content; + should.exist(resJson); + resJson.name.should.be.eql(updateBody.name); + resJson.type.should.be.eql(updateBody.type); + resJson.estimatedPrice.should.be.eql(updateBody.estimatedPrice); + resJson.actualPrice.should.be.eql(updateBody.actualPrice); + resJson.details.should.be.eql(updateBody.details); + done(); + } + }); + }); + }); +}); diff --git a/src/routes/phases/create.js b/src/routes/phases/create.js new file mode 100644 index 00000000..f4a7a9b6 --- /dev/null +++ b/src/routes/phases/create.js @@ -0,0 +1,82 @@ +import validate from 'express-validation'; +import _ from 'lodash'; +import Joi from 'joi'; + +import models from '../../models'; +import util from '../../util'; +import { EVENT } from '../../constants'; + +const permissions = require('tc-core-library-js').middleware.permissions; + + +const addProjectPhaseValidations = { + body: { + param: Joi.object().keys({ + name: Joi.string().required(), + status: Joi.string().required(), + startDate: Joi.date().max(Joi.ref('endDate')).optional(), + endDate: Joi.date().optional(), + duration: Joi.number().min(0).optional(), + budget: Joi.number().min(0).optional(), + spentBudget: Joi.number().min(0).optional(), + progress: Joi.number().min(0).optional(), + details: Joi.any().optional(), + }).required(), + }, +}; + +module.exports = [ + // validate request payload + validate(addProjectPhaseValidations), + // check permission + permissions('project.addProjectPhase'), + // do the real work + (req, res, next) => { + const data = req.body.param; + // default values + const projectId = _.parseInt(req.params.projectId); + _.assign(data, { + projectId, + createdBy: req.authUser.userId, + updatedBy: req.authUser.userId, + }); + + models.sequelize.transaction(() => { + let newProjectPhase = null; + + return models.Project.findOne({ + where: { id: projectId, deletedAt: { $eq: null } }, + }).then((existingProject) => { + if (!existingProject) { + const err = new Error(`active project not found for project id ${projectId}`); + err.status = 404; + throw err; + } + return models.ProjectPhase + .create(data) + .then((_newProjectPhase) => { + newProjectPhase = _.cloneDeep(_newProjectPhase); + req.log.debug('new project phase created (id# %d, name: %s)', + newProjectPhase.id, newProjectPhase.name); + + newProjectPhase = newProjectPhase.get({ plain: true }); + newProjectPhase = _.omit(newProjectPhase, ['deletedAt', 'deletedBy', 'utm']); + + // Send events to buses + req.log.debug('Sending event to RabbitMQ bus for project phase %d', newProjectPhase.id); + req.app.services.pubsub.publish(EVENT.ROUTING_KEY.PROJECT_PHASE_ADDED, + newProjectPhase, + { correlationId: req.id }, + ); + req.log.debug('Sending event to Kafka bus for project phase %d', newProjectPhase.id); + req.app.emit(EVENT.ROUTING_KEY.PROJECT_PHASE_ADDED, { req, created: newProjectPhase }); + + res.status(201).json(util.wrapResponse(req.id, newProjectPhase, 1, 201)); + }); + }).catch((err) => { + util.handleError('Error creating project phase', err, req, next); + }); + }); + }, + +]; diff --git a/src/routes/phases/create.spec.js b/src/routes/phases/create.spec.js new file mode 100644 index 00000000..ff5de77e --- /dev/null +++ b/src/routes/phases/create.spec.js @@ -0,0 +1,235 @@ +/* eslint-disable no-unused-expressions */ +import _ from 'lodash'; +import chai from 'chai'; +import request from 'supertest'; +import server from '../../app'; +import models from '../../models'; +import testUtil from '../../tests/util'; + +const should = chai.should(); + +const body = { + name: 'test project phase', + status: 'active', + startDate: '2018-05-15T00:00:00Z', + endDate: '2018-05-15T12:00:00Z', + budget: 20.0, + progress: 1.23456, + spentBudget: 10.0, + duration: 10, + details: { + message: 'This can be any json', + }, +}; + +const validatePhase = (resJson, expectedPhase) => { + should.exist(resJson); + resJson.name.should.be.eql(expectedPhase.name); + resJson.status.should.be.eql(expectedPhase.status); + resJson.budget.should.be.eql(expectedPhase.budget); + resJson.progress.should.be.eql(expectedPhase.progress); + resJson.details.should.be.eql(expectedPhase.details); +}; + +describe('Project Phases', () => { + let projectId; + const memberUser = { + handle: testUtil.getDecodedToken(testUtil.jwts.member).handle, + userId: testUtil.getDecodedToken(testUtil.jwts.member).userId, + firstName: 'fname', + lastName: 'lName', + email: 'some@abc.com', + }; + const copilotUser = { + handle: testUtil.getDecodedToken(testUtil.jwts.copilot).handle, + userId: testUtil.getDecodedToken(testUtil.jwts.copilot).userId, + firstName: 'fname', + lastName: 'lName', + email: 'some@abc.com', + }; + before((done) => { + // mocks + testUtil.clearDb() + .then(() => { + models.Project.create({ + type: 'generic', + billingAccountId: 1, + name: 'test1', + description: 'test project1', + status: 'draft', + details: {}, + createdBy: 1, + updatedBy: 1, + }).then((p) => { + projectId = p.id; + // create members + models.ProjectMember.bulkCreate([{ + id: 1, + userId: copilotUser.userId, + projectId, + role: 'copilot', + isPrimary: false, + createdBy: 1, + updatedBy: 1, + }, { + id: 2, + userId: memberUser.userId, + projectId, + role: 'customer', + isPrimary: true, + createdBy: 1, + updatedBy: 1, + }]).then(() => done()); + }); + }); + }); + + after((done) => { + testUtil.clearDb(done); + }); + + describe('POST /projects/{id}/phases/', () => { + it('should return 403 if user does not have permissions (non team member)', (done) => { + request(server) + .post(`/v4/projects/${projectId}/phases/`) + .set({ + Authorization: `Bearer ${testUtil.jwts.member2}`, + }) + .send({ param: body }) + .expect('Content-Type', /json/) + .expect(403, done); + }); + + it('should return 403 if user does not have permissions (customer)', (done) => { + request(server) + .post(`/v4/projects/${projectId}/phases/`) + .set({ + Authorization: `Bearer ${testUtil.jwts.member}`, + }) + .send({ param: body }) + .expect('Content-Type', /json/) + .expect(403, done); + }); + + it('should return 422 when name not provided', (done) => { + const reqBody = _.cloneDeep(body); + delete reqBody.name; + request(server) + .post(`/v4/projects/${projectId}/phases/`) + .set({ + Authorization: `Bearer ${testUtil.jwts.copilot}`, + }) + .send({ param: reqBody }) + .expect('Content-Type', /json/) + .expect(422, done); + }); + + it('should return 422 when status not provided', (done) => { + const reqBody = _.cloneDeep(body); + delete reqBody.status; + request(server) + .post(`/v4/projects/${projectId}/phases/`) + .set({ + Authorization: `Bearer ${testUtil.jwts.copilot}`, + }) + .send({ param: reqBody }) + .expect('Content-Type', /json/) + .expect(422, done); + }); + + it('should return 422 when startDate > endDate', (done) => { + const reqBody = _.cloneDeep(body); + reqBody.startDate = '2018-05-16T12:00:00'; + request(server) + .post(`/v4/projects/${projectId}/phases/`) + .set({ + Authorization: `Bearer ${testUtil.jwts.copilot}`, + }) + .send({ param: reqBody }) + .expect('Content-Type', /json/) + .expect(422, done); + }); + + it('should return 422 when budget is negative', (done) => { + const reqBody = _.cloneDeep(body); + reqBody.budget = -20; + request(server) + .post(`/v4/projects/${projectId}/phases/`) + .set({ + Authorization: `Bearer ${testUtil.jwts.copilot}`, + }) + .send({ param: reqBody }) + .expect('Content-Type', /json/) + .expect(422, done); + }); + + it('should return 422 when progress is negative', (done) => { + const reqBody = _.cloneDeep(body); + reqBody.progress = -20; + request(server) + .post(`/v4/projects/${projectId}/phases/`) + .set({ + Authorization: `Bearer ${testUtil.jwts.copilot}`, + }) + .send({ param: reqBody }) + .expect('Content-Type', /json/) + .expect(422, done); + }); + + it('should return 404 when project is not found', (done) => { + request(server) + .post('/v4/projects/99999/phases/') + .set({ + Authorization: `Bearer ${testUtil.jwts.manager}`, + }) + .send({ param: body }) + .expect('Content-Type', /json/) + .expect(404, done); + }); + + it('should return 201 if payload is valid', (done) => { + request(server) + .post(`/v4/projects/${projectId}/phases/`) + .set({ + Authorization: `Bearer ${testUtil.jwts.copilot}`, + }) + .send({ param: body }) + .expect('Content-Type', /json/) + .expect(201) + .end((err, res) => { + if (err) { + done(err); + } else { + const resJson = res.body.result.content; + validatePhase(resJson, body); + done(); + } + }); + }); + + it('should return 201 if payload is valid (0 for non negative numbers)', (done) => { + const bodyWithZeros = _.cloneDeep(body); + bodyWithZeros.duration = 0; + bodyWithZeros.spentBudget = 0.0; + bodyWithZeros.budget = 0.0; + bodyWithZeros.progress = 0.0; + request(server) + .post(`/v4/projects/${projectId}/phases/`) + .set({ + Authorization: `Bearer ${testUtil.jwts.copilot}`, + }) + .send({ param: bodyWithZeros }) + .expect('Content-Type', /json/) + .expect(201) + .end((err, res) => { + if (err) { + done(err); + } else { + const resJson = res.body.result.content; + validatePhase(resJson, bodyWithZeros); + done(); + } + }); + }); + }); +}); diff --git a/src/routes/phases/delete.js b/src/routes/phases/delete.js new file mode 100644 index 00000000..3bc34012 --- /dev/null +++ b/src/routes/phases/delete.js @@ -0,0 +1,52 @@ + + +import _ from 'lodash'; +import { middleware as tcMiddleware } from 'tc-core-library-js'; +import models from '../../models'; +import { EVENT } from '../../constants'; + +const permissions = tcMiddleware.permissions; + +module.exports = [ + // check permission + permissions('project.deleteProjectPhase'), + + (req, res, next) => { + const projectId = _.parseInt(req.params.projectId); + const phaseId = _.parseInt(req.params.phaseId); + + models.sequelize.transaction(() => + // soft delete the record + models.ProjectPhase.findOne({ + where: { + id: phaseId, + projectId, + deletedAt: { $eq: null }, + }, + }).then(existing => new Promise((accept, reject) => { + if (!existing) { + // handle 404 + const err = new Error('no active project phase found for project id ' + + `${projectId} and phase id ${phaseId}`); + err.status = 404; + reject(err); + } else { + _.extend(existing, { deletedBy: req.authUser.userId, deletedAt: Date.now() }); + existing.save().then(accept).catch(reject); + } + })).then((deleted) => { + req.log.debug('deleted project phase', JSON.stringify(deleted, null, 2)); + + // Send events to buses + req.app.services.pubsub.publish( + EVENT.ROUTING_KEY.PROJECT_PHASE_REMOVED, + deleted, + { correlationId: req.id }, + ); + req.app.emit(EVENT.ROUTING_KEY.PROJECT_PHASE_REMOVED, { req, deleted }); + + res.status(204).json({}); + }).catch(err => next(err))); + }, +]; + diff --git a/src/routes/phases/delete.spec.js b/src/routes/phases/delete.spec.js new file mode 100644 index 00000000..43a56b13 --- /dev/null +++ b/src/routes/phases/delete.spec.js @@ -0,0 +1,136 @@ +/* eslint-disable no-unused-expressions */ +import _ from 'lodash'; +import request from 'supertest'; +import server from '../../app'; +import models from '../../models'; +import testUtil from '../../tests/util'; + +const body = { + name: 'test project phase', + status: 'active', + startDate: '2018-05-15T00:00:00Z', + endDate: '2018-05-15T12:00:00Z', + budget: 20.0, + progress: 1.23456, + details: { + message: 'This can be any json', + }, + createdBy: 1, + updatedBy: 1, +}; + +describe('Project Phases', () => { + let projectId; + let phaseId; + 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((done) => { + // mocks + testUtil.clearDb() + .then(() => { + models.Project.create({ + type: 'generic', + billingAccountId: 1, + name: 'test1', + description: 'test project1', + status: 'draft', + details: {}, + createdBy: 1, + updatedBy: 1, + }).then((p) => { + projectId = p.id; + // create members + models.ProjectMember.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 }); + models.ProjectPhase.create(body).then((phase) => { + phaseId = phase.id; + done(); + }); + }); + }); + }); + }); + + after((done) => { + testUtil.clearDb(done); + }); + + describe('DELETE /projects/{projectId}/phases/{phaseId}', () => { + it('should return 403 if user does not have permissions (non team member)', (done) => { + request(server) + .delete(`/v4/projects/${projectId}/phases/${phaseId}`) + .set({ + Authorization: `Bearer ${testUtil.jwts.member2}`, + }) + .expect('Content-Type', /json/) + .expect(403, done); + }); + + it('should return 403 if user does not have permissions (customer)', (done) => { + request(server) + .delete(`/v4/projects/${projectId}/phases/${phaseId}`) + .set({ + Authorization: `Bearer ${testUtil.jwts.member}`, + }) + .expect('Content-Type', /json/) + .expect(403, done); + }); + + it('should return 404 when no project with specific projectId', (done) => { + request(server) + .delete(`/v4/projects/999/phases/${phaseId}`) + .set({ + Authorization: `Bearer ${testUtil.jwts.manager}`, + }) + .expect('Content-Type', /json/) + .expect(404, done); + }); + + it('should return 404 when no phase with specific phaseId', (done) => { + request(server) + .delete(`/v4/projects/${projectId}/phases/999`) + .set({ + Authorization: `Bearer ${testUtil.jwts.manager}`, + }) + .expect('Content-Type', /json/) + .expect(404, done); + }); + + it('should return 204 when user have project permission', (done) => { + request(server) + .delete(`/v4/projects/${projectId}/phases/${phaseId}`) + .set({ + Authorization: `Bearer ${testUtil.jwts.copilot}`, + }) + .expect(204, done); + }); + }); +}); diff --git a/src/routes/phases/get.js b/src/routes/phases/get.js new file mode 100644 index 00000000..cee2c3f2 --- /dev/null +++ b/src/routes/phases/get.js @@ -0,0 +1,31 @@ + +import _ from 'lodash'; +import { middleware as tcMiddleware } from 'tc-core-library-js'; +import util from '../../util'; +import models from '../../models'; + +const permissions = tcMiddleware.permissions; + +module.exports = [ + permissions('project.view'), + (req, res, next) => { + const projectId = _.parseInt(req.params.projectId); + const phaseId = _.parseInt(req.params.phaseId); + return models.ProjectPhase + .findOne({ + where: { id: phaseId, projectId }, + raw: true, + }) + .then((phase) => { + if (!phase) { + // handle 404 + const err = new Error('project phase not found for project id ' + + `${projectId} and phase id ${phaseId}`); + err.status = 404; + throw err; + } + res.json(util.wrapResponse(req.id, phase)); + }) + .catch(err => next(err)); + }, +]; diff --git a/src/routes/phases/get.spec.js b/src/routes/phases/get.spec.js new file mode 100644 index 00000000..1dab6542 --- /dev/null +++ b/src/routes/phases/get.spec.js @@ -0,0 +1,168 @@ +/* eslint-disable no-unused-expressions */ +import _ from 'lodash'; +import chai from 'chai'; +import request from 'supertest'; +import server from '../../app'; +import models from '../../models'; +import testUtil from '../../tests/util'; + +const should = chai.should(); + +const body = { + name: 'test project phase', + status: 'active', + startDate: '2018-05-15T00:00:00Z', + endDate: '2018-05-15T12:00:00Z', + budget: 20.0, + progress: 1.23456, + details: { + message: 'This can be any json', + }, + createdBy: 1, + updatedBy: 1, +}; + +describe('Project Phases', () => { + let projectId; + let phaseId; + 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((done) => { + // mocks + testUtil.clearDb() + .then(() => { + models.Project.create({ + type: 'generic', + billingAccountId: 1, + name: 'test1', + description: 'test project1', + status: 'draft', + details: {}, + createdBy: 1, + updatedBy: 1, + }).then((p) => { + projectId = p.id; + // create members + models.ProjectMember.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 }); + models.ProjectPhase.create(body).then((phase) => { + phaseId = phase.id; + done(); + }); + }); + }); + }); + }); + + after((done) => { + testUtil.clearDb(done); + }); + + describe('GET /projects/{projectId}/phases/{phaseId}', () => { + it('should return 403 when user have no permission (non team member)', (done) => { + request(server) + .get(`/v4/projects/${projectId}/phases/${phaseId}`) + .set({ + Authorization: `Bearer ${testUtil.jwts.member2}`, + }) + .expect('Content-Type', /json/) + .expect(403, done); + }); + + it('should return 404 when no project with specific projectId', (done) => { + request(server) + .get(`/v4/projects/999/phases/${phaseId}`) + .set({ + Authorization: `Bearer ${testUtil.jwts.manager}`, + }) + .expect('Content-Type', /json/) + .expect(404, done); + }); + + it('should return 404 when no phase with specific phaseId', (done) => { + request(server) + .get(`/v4/projects/${projectId}/phases/999`) + .set({ + Authorization: `Bearer ${testUtil.jwts.manager}`, + }) + .expect('Content-Type', /json/) + .expect(404, done); + }); + + it('should return 1 phase when user have project permission (customer)', (done) => { + request(server) + .get(`/v4/projects/${projectId}/phases/${phaseId}`) + .set({ + Authorization: `Bearer ${testUtil.jwts.member}`, + }) + .expect('Content-Type', /json/) + .expect(200) + .end((err, res) => { + if (err) { + done(err); + } else { + const resJson = res.body.result.content; + should.exist(resJson); + resJson.name.should.be.eql('test project phase'); + resJson.status.should.be.eql('active'); + resJson.budget.should.be.eql(20.0); + resJson.progress.should.be.eql(1.23456); + resJson.details.should.be.eql({ message: 'This can be any json' }); + done(); + } + }); + }); + + it('should return 1 phase when user have project permission (copilot)', (done) => { + request(server) + .get(`/v4/projects/${projectId}/phases/${phaseId}`) + .set({ + Authorization: `Bearer ${testUtil.jwts.copilot}`, + }) + .expect('Content-Type', /json/) + .expect(200) + .end((err, res) => { + if (err) { + done(err); + } else { + const resJson = res.body.result.content; + should.exist(resJson); + resJson.name.should.be.eql('test project phase'); + resJson.status.should.be.eql('active'); + resJson.budget.should.be.eql(20.0); + resJson.progress.should.be.eql(1.23456); + resJson.details.should.be.eql({ message: 'This can be any json' }); + done(); + } + }); + }); + }); +}); diff --git a/src/routes/phases/list.js b/src/routes/phases/list.js new file mode 100644 index 00000000..6644a365 --- /dev/null +++ b/src/routes/phases/list.js @@ -0,0 +1,65 @@ + +import _ from 'lodash'; +import config from 'config'; +import { middleware as tcMiddleware } from 'tc-core-library-js'; +import util from '../../util'; +import models from '../../models'; + +const ES_PROJECT_INDEX = config.get('elasticsearchConfig.indexName'); +const ES_PROJECT_TYPE = config.get('elasticsearchConfig.docType'); + +const eClient = util.getElasticSearchClient(); + +const PHASE_ATTRIBUTES = _.keys(models.ProjectPhase.rawAttributes); + +const permissions = tcMiddleware.permissions; + + +module.exports = [ + permissions('project.view'), + (req, res, next) => { + const projectId = _.parseInt(req.params.projectId); + + let sort = req.query.sort ? decodeURIComponent(req.query.sort) : 'startDate'; + if (sort && sort.indexOf(' ') === -1) { + sort += ' asc'; + } + const sortableProps = [ + 'startDate asc', 'startDate desc', + 'endDate asc', 'endDate desc', + 'status asc', 'status desc', + ]; + if (sort && _.indexOf(sortableProps, sort) < 0) { + return util.handleError('Invalid sort criteria', null, req, next); + } + const sortColumnAndOrder = sort.split(' '); + + // Get project from ES + return eClient.get({ index: ES_PROJECT_INDEX, type: ES_PROJECT_TYPE, id: req.params.projectId }) + .then((doc) => { + if (!doc) { + const err = new Error(`active project not found for project id ${projectId}`); + err.status = 404; + throw err; + } + + // Get the phases + let phases = _.isArray(doc._source.phases) ? doc._source.phases : []; // eslint-disable-line no-underscore-dangle + + // Sort + phases = _.sortBy(phases, [sortColumnAndOrder[0]], [sortColumnAndOrder[1]]); + + // Parse the fields string to determine what fields are to be returned + let fields = req.query.fields ? req.query.fields.split(',') : PHASE_ATTRIBUTES; + fields = _.intersection(fields, PHASE_ATTRIBUTES); + if (_.indexOf(fields, 'id') < 0) { + fields.push('id'); + } + + phases = _.map(phases, phase => _.pick(phase, fields)); + + res.json(util.wrapResponse(req.id, phases, phases.length)); + }) + .catch(err => next(err)); + }, +]; diff --git a/src/routes/phases/list.spec.js b/src/routes/phases/list.spec.js new file mode 100644 index 00000000..86b6d7e9 --- /dev/null +++ b/src/routes/phases/list.spec.js @@ -0,0 +1,171 @@ +/* eslint-disable no-unused-expressions */ +import _ from 'lodash'; +import request from 'supertest'; +import config from 'config'; +import sleep from 'sleep'; +import chai from 'chai'; +import server from '../../app'; +import models from '../../models'; +import testUtil from '../../tests/util'; + +const ES_PROJECT_INDEX = config.get('elasticsearchConfig.indexName'); +const ES_PROJECT_TYPE = config.get('elasticsearchConfig.docType'); + +const should = chai.should(); + +const body = { + name: 'test project phase', + status: 'active', + startDate: '2018-05-15T00:00:00Z', + endDate: '2018-05-15T12:00:00Z', + budget: 20.0, + progress: 1.23456, + details: { + message: 'This can be any json', + }, + createdBy: 1, + updatedBy: 1, +}; + +describe('Project Phases', () => { + let projectId; + let project; + 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, + }).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) => { + // Index to ES + project.phases = [phase]; + return server.services.es.index({ + index: ES_PROJECT_INDEX, + type: ES_PROJECT_TYPE, + id: projectId, + body: project, + }).then(() => { + // sleep for some time, let elasticsearch indices be settled + sleep.sleep(5); + done(); + }); + }); + }); + }); + }); + + after((done) => { + testUtil.clearDb(done); + }); + + describe('GET /projects/{id}/phases/', () => { + it('should return 403 when user have no permission (non team member)', (done) => { + request(server) + .get(`/v4/projects/${projectId}/phases/`) + .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/') + .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/`) + .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/`) + .set({ + Authorization: `Bearer ${testUtil.jwts.copilot}`, + }) + .send({ param: body }) + .expect('Content-Type', /json/) + .expect(200) + .end((err, res) => { + if (err) { + done(err); + } else { + const resJson = res.body.result.content; + should.exist(resJson); + resJson.should.have.lengthOf(1); + done(); + } + }); + }); + }); +}); diff --git a/src/routes/phases/update.js b/src/routes/phases/update.js new file mode 100644 index 00000000..c64d2313 --- /dev/null +++ b/src/routes/phases/update.js @@ -0,0 +1,100 @@ + +import validate from 'express-validation'; +import _ from 'lodash'; +import Joi from 'joi'; +import { middleware as tcMiddleware } from 'tc-core-library-js'; +import models from '../../models'; +import util from '../../util'; +import { EVENT } from '../../constants'; + + +const permissions = tcMiddleware.permissions; + +const updateProjectPhaseValidation = { + body: { + param: Joi.object().keys({ + name: Joi.string().optional(), + status: Joi.string().optional(), + startDate: Joi.date().optional(), + endDate: Joi.date().optional(), + duration: Joi.number().min(0).optional(), + budget: Joi.number().min(0).optional(), + spentBudget: Joi.number().min(0).optional(), + progress: Joi.number().min(0).optional(), + details: Joi.any().optional(), + }).required(), + }, +}; + + +module.exports = [ + // validate request payload + validate(updateProjectPhaseValidation), + // check permission + permissions('project.updateProjectPhase'), + + (req, res, next) => { + const projectId = _.parseInt(req.params.projectId); + const phaseId = _.parseInt(req.params.phaseId); + + const updatedProps = req.body.param; + updatedProps.updatedBy = req.authUser.userId; + + let previousValue; + + models.sequelize.transaction(() => models.ProjectPhase.findOne({ + where: { + id: phaseId, + projectId, + deletedAt: { $eq: null }, + }, + }).then(existing => new Promise((accept, reject) => { + if (!existing) { + // handle 404 + const err = new Error('No active project phase found for project id ' + + `${projectId} and phase id ${phaseId}`); + err.status = 404; + reject(err); + } else { + previousValue = _.clone(existing.get({ plain: true })); + + // make sure startDate < endDate + let startDate; + let endDate; + if (updatedProps.startDate) { + startDate = new Date(updatedProps.startDate); + } else { + startDate = existing.startDate !== null ? new Date(existing.startDate) : null; + } + + if (updatedProps.endDate) { + endDate = new Date(updatedProps.endDate); + } else { + endDate = existing.endDate !== null ? new Date(existing.endDate) : null; + } + + if (startDate !== null && endDate !== null && startDate >= endDate) { + const err = new Error('startDate must be before endDate.'); + err.status = 400; + reject(err); + } else { + _.extend(existing, updatedProps); + existing.save().then(accept).catch(reject); + } + } + })).then((updated) => { + req.log.debug('updated project phase', JSON.stringify(updated, null, 2)); + + // emit original and updated project phase information + req.app.services.pubsub.publish( + EVENT.ROUTING_KEY.PROJECT_PHASE_UPDATED, + { original: previousValue, updated }, + { correlationId: req.id }, + ); + req.app.emit(EVENT.ROUTING_KEY.PROJECT_PHASE_UPDATED, + { req, original: previousValue, updated }); + + res.json(util.wrapResponse(req.id, updated)); + }).catch(err => next(err))); + }, +]; diff --git a/src/routes/phases/update.spec.js b/src/routes/phases/update.spec.js new file mode 100644 index 00000000..5ff80b7a --- /dev/null +++ b/src/routes/phases/update.spec.js @@ -0,0 +1,230 @@ +/* eslint-disable no-unused-expressions */ +import _ from 'lodash'; +import chai from 'chai'; +import request from 'supertest'; +import server from '../../app'; +import models from '../../models'; +import testUtil from '../../tests/util'; + +const should = chai.should(); + +const body = { + name: 'test project phase', + status: 'active', + startDate: '2018-05-15T00:00:00Z', + endDate: '2018-05-15T12:00:00Z', + budget: 20.0, + progress: 1.23456, + details: { + message: 'This can be any json', + }, + createdBy: 1, + updatedBy: 1, +}; + +const updateBody = { + name: 'test project phase xxx', + status: 'inactive', + startDate: '2018-05-11T00:00:00Z', + endDate: '2018-05-12T12:00:00Z', + budget: 123456.789, + progress: 9.8765432, + details: { + message: 'This is another json', + }, +}; + +const validatePhase = (resJson, expectedPhase) => { + should.exist(resJson); + resJson.name.should.be.eql(expectedPhase.name); + resJson.status.should.be.eql(expectedPhase.status); + resJson.budget.should.be.eql(expectedPhase.budget); + resJson.progress.should.be.eql(expectedPhase.progress); + resJson.details.should.be.eql(expectedPhase.details); +}; + +describe('Project Phases', () => { + let projectId; + let phaseId; + 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((done) => { + // mocks + testUtil.clearDb() + .then(() => { + models.Project.create({ + type: 'generic', + billingAccountId: 1, + name: 'test1', + description: 'test project1', + status: 'draft', + details: {}, + createdBy: 1, + updatedBy: 1, + }).then((p) => { + projectId = p.id; + // create members + models.ProjectMember.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 }); + models.ProjectPhase.create(body).then((phase) => { + phaseId = phase.id; + done(); + }); + }); + }); + }); + }); + + after((done) => { + testUtil.clearDb(done); + }); + + describe('PATCH /projects/{projectId}/phases/{phaseId}', () => { + it('should return 403 if user does not have permissions (non team member)', (done) => { + request(server) + .patch(`/v4/projects/${projectId}/phases/${phaseId}`) + .set({ + Authorization: `Bearer ${testUtil.jwts.member2}`, + }) + .send({ param: updateBody }) + .expect('Content-Type', /json/) + .expect(403, done); + }); + + it('should return 403 if user does not have permissions (customer)', (done) => { + request(server) + .patch(`/v4/projects/${projectId}/phases/${phaseId}`) + .set({ + Authorization: `Bearer ${testUtil.jwts.member}`, + }) + .send({ param: updateBody }) + .expect('Content-Type', /json/) + .expect(403, done); + }); + + it('should return 404 when no project with specific projectId', (done) => { + request(server) + .patch(`/v4/projects/999/phases/${phaseId}`) + .set({ + Authorization: `Bearer ${testUtil.jwts.manager}`, + }) + .send({ param: updateBody }) + .expect('Content-Type', /json/) + .expect(404, done); + }); + + it('should return 404 when no phase with specific phaseId', (done) => { + request(server) + .patch(`/v4/projects/${projectId}/phases/999`) + .set({ + Authorization: `Bearer ${testUtil.jwts.manager}`, + }) + .send({ param: updateBody }) + .expect('Content-Type', /json/) + .expect(404, done); + }); + + it('should return 422 when parameters are invalid', (done) => { + request(server) + .patch(`/v4/projects/${projectId}/phases/${phaseId}`) + .set({ + Authorization: `Bearer ${testUtil.jwts.manager}`, + }) + .send({ + param: { + progress: -15, + }, + }) + .expect('Content-Type', /json/) + .expect(422, done); + }); + + it('should return 400 when startDate >= endDate', (done) => { + request(server) + .patch(`/v4/projects/${projectId}/phases/${phaseId}`) + .set({ + Authorization: `Bearer ${testUtil.jwts.manager}`, + }) + .send({ + param: { + endDate: '2018-05-13T00:00:00Z', + }, + }) + .expect('Content-Type', /json/) + .expect(400, done); + }); + + it('should return updated phase when user have permission and parameters are valid', (done) => { + request(server) + .patch(`/v4/projects/${projectId}/phases/${phaseId}`) + .set({ + Authorization: `Bearer ${testUtil.jwts.copilot}`, + }) + .send({ param: updateBody }) + .expect('Content-Type', /json/) + .expect(200) + .end((err, res) => { + if (err) { + done(err); + } else { + const resJson = res.body.result.content; + validatePhase(resJson, updateBody); + done(); + } + }); + }); + + it('should return updated phase when parameters are valid (0 for non -ve numbers)', (done) => { + const bodyWithZeros = _.cloneDeep(updateBody); + bodyWithZeros.duration = 0; + bodyWithZeros.spentBudget = 0.0; + bodyWithZeros.budget = 0.0; + bodyWithZeros.progress = 0.0; + request(server) + .patch(`/v4/projects/${projectId}/phases/${phaseId}`) + .set({ + Authorization: `Bearer ${testUtil.jwts.copilot}`, + }) + .send({ param: bodyWithZeros }) + .expect('Content-Type', /json/) + .expect(200) + .end((err, res) => { + if (err) { + done(err); + } else { + const resJson = res.body.result.content; + validatePhase(resJson, bodyWithZeros); + done(); + } + }); + }); + }); +}); diff --git a/src/routes/productTemplates/create.js b/src/routes/productTemplates/create.js new file mode 100644 index 00000000..f0ab51b0 --- /dev/null +++ b/src/routes/productTemplates/create.js @@ -0,0 +1,53 @@ +/** + * API to add a product template + */ +import validate from 'express-validation'; +import _ from 'lodash'; +import Joi from 'joi'; +import { middleware as tcMiddleware } from 'tc-core-library-js'; +import util from '../../util'; +import models from '../../models'; + +const permissions = tcMiddleware.permissions; + +const schema = { + body: { + param: Joi.object().keys({ + id: Joi.any().strip(), + name: Joi.string().max(255).required(), + productKey: Joi.string().max(45).required(), + icon: Joi.string().max(255).required(), + brief: Joi.string().max(45).required(), + details: Joi.string().max(255).required(), + aliases: Joi.array().required(), + template: Joi.object().required(), + disabled: Joi.boolean().optional(), + hidden: Joi.boolean().optional(), + createdAt: Joi.any().strip(), + updatedAt: Joi.any().strip(), + deletedAt: Joi.any().strip(), + createdBy: Joi.any().strip(), + updatedBy: Joi.any().strip(), + deletedBy: Joi.any().strip(), + }).required(), + }, +}; + +module.exports = [ + validate(schema), + permissions('productTemplate.create'), + (req, res, next) => { + const entity = _.assign(req.body.param, { + createdBy: req.authUser.userId, + updatedBy: req.authUser.userId, + }); + + return models.ProductTemplate.create(entity) + .then((createdEntity) => { + // Omit deletedAt, deletedBy + res.status(201).json(util.wrapResponse( + req.id, _.omit(createdEntity.toJSON(), 'deletedAt', 'deletedBy'), 1, 201)); + }) + .catch(next); + }, +]; diff --git a/src/routes/productTemplates/create.spec.js b/src/routes/productTemplates/create.spec.js new file mode 100644 index 00000000..e874873b --- /dev/null +++ b/src/routes/productTemplates/create.spec.js @@ -0,0 +1,155 @@ +/** + * Tests for create.js + */ +import chai from 'chai'; +import request from 'supertest'; + +import server from '../../app'; +import testUtil from '../../tests/util'; + +const should = chai.should(); + +describe('CREATE product template', () => { + describe('POST /productTemplates', () => { + const body = { + param: { + name: 'name 1', + productKey: 'productKey 1', + icon: 'http://example.com/icon1.ico', + brief: 'brief 1', + details: 'details 1', + aliases: ['product key 1', 'product_key_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'], + }, + }, + }, + }; + + it('should return 403 if user is not authenticated', (done) => { + request(server) + .post('/v4/productTemplates') + .send(body) + .expect(403, done); + }); + + it('should return 403 for member', (done) => { + request(server) + .post('/v4/productTemplates') + .set({ + Authorization: `Bearer ${testUtil.jwts.member}`, + }) + .send(body) + .expect(403, done); + }); + + it('should return 403 for copilot', (done) => { + request(server) + .post('/v4/productTemplates') + .set({ + Authorization: `Bearer ${testUtil.jwts.copilot}`, + }) + .send(body) + .expect(403, done); + }); + + it('should return 422 if validations dont pass', (done) => { + const invalidBody = { + param: { + aliases: 'a', + template: 1, + }, + }; + + request(server) + .post('/v4/productTemplates') + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .send(invalidBody) + .expect('Content-Type', /json/) + .expect(422, done); + }); + + it('should return 201 for admin', (done) => { + request(server) + .post('/v4/productTemplates') + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .send(body) + .expect('Content-Type', /json/) + .expect(201) + .end((err, res) => { + const resJson = res.body.result.content; + should.exist(resJson.id); + resJson.name.should.be.eql(body.param.name); + resJson.productKey.should.be.eql(body.param.productKey); + resJson.icon.should.be.eql(body.param.icon); + resJson.brief.should.be.eql(body.param.brief); + resJson.details.should.be.eql(body.param.details); + resJson.aliases.should.be.eql(body.param.aliases); + resJson.template.should.be.eql(body.param.template); + resJson.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 201 for connect manager', (done) => { + request(server) + .post('/v4/productTemplates') + .set({ + Authorization: `Bearer ${testUtil.jwts.manager}`, + }) + .send(body) + .expect('Content-Type', /json/) + .expect(201) + .end((err, res) => { + const resJson = res.body.result.content; + resJson.createdBy.should.be.eql(40051334); // manager + resJson.updatedBy.should.be.eql(40051334); // manager + done(); + }); + }); + + it('should return 201 for connect admin', (done) => { + request(server) + .post('/v4/productTemplates') + .set({ + Authorization: `Bearer ${testUtil.jwts.connectAdmin}`, + }) + .send(body) + .expect('Content-Type', /json/) + .expect(201) + .end((err, res) => { + const resJson = res.body.result.content; + resJson.createdBy.should.be.eql(40051336); // connect admin + resJson.updatedBy.should.be.eql(40051336); // connect admin + done(); + }); + }); + }); +}); diff --git a/src/routes/productTemplates/delete.js b/src/routes/productTemplates/delete.js new file mode 100644 index 00000000..81c65b6b --- /dev/null +++ b/src/routes/productTemplates/delete.js @@ -0,0 +1,55 @@ +/** + * API to delete a product template + */ +import validate from 'express-validation'; +import Joi from 'joi'; +import { middleware as tcMiddleware } from 'tc-core-library-js'; +import models from '../../models'; + +const permissions = tcMiddleware.permissions; + +const schema = { + params: { + templateId: Joi.number().integer().positive().required(), + }, +}; + +module.exports = [ + validate(schema), + permissions('productTemplate.delete'), + (req, res, next) => { + const where = { + deletedAt: { $eq: null }, + id: req.params.templateId, + }; + + return models.sequelize.transaction(tx => + // Update the deletedBy + models.ProductTemplate.update({ deletedBy: req.authUser.userId }, { + where, + returning: true, + raw: true, + transaction: tx, + }) + .then((updatedResults) => { + // Not found + if (updatedResults[0] === 0) { + const apiErr = new Error(`Product template not found for template id ${req.params.templateId}`); + apiErr.status = 404; + return Promise.reject(apiErr); + } + + // Soft delete + return models.ProductTemplate.destroy({ + where, + transaction: tx, + raw: true, + }); + }) + .then(() => { + res.status(204).end(); + }) + .catch(next), + ); + }, +]; diff --git a/src/routes/productTemplates/delete.spec.js b/src/routes/productTemplates/delete.spec.js new file mode 100644 index 00000000..07dab1c0 --- /dev/null +++ b/src/routes/productTemplates/delete.spec.js @@ -0,0 +1,123 @@ +/** + * Tests for delete.js + */ +import request from 'supertest'; + +import models from '../../models'; +import server from '../../app'; +import testUtil from '../../tests/util'; + + +describe('DELETE product template', () => { + let templateId; + + beforeEach(() => testUtil.clearDb() + .then(() => models.ProductTemplate.create({ + name: 'name 1', + productKey: 'productKey 1', + icon: 'http://example.com/icon1.ico', + brief: 'brief 1', + details: 'details 1', + aliases: ['product key 1', 'product_key_1'], + template: { + template1: { + name: 'template 1', + details: { + anyDetails: 'any details 1', + }, + others: ['others 11', 'others 12'], + }, + template2: { + name: 'template 2', + details: { + anyDetails: 'any details 2', + }, + others: ['others 21', 'others 22'], + }, + }, + createdBy: 1, + updatedBy: 2, + })).then((template) => { + templateId = template.id; + return Promise.resolve(); + }), + ); + after(testUtil.clearDb); + + describe('DELETE /productTemplates/{templateId}', () => { + it('should return 403 if user is not authenticated', (done) => { + request(server) + .delete(`/v4/productTemplates/${templateId}`) + .expect(403, done); + }); + + it('should return 403 for member', (done) => { + request(server) + .delete(`/v4/productTemplates/${templateId}`) + .set({ + Authorization: `Bearer ${testUtil.jwts.member}`, + }) + .expect(403, done); + }); + + it('should return 403 for copilot', (done) => { + request(server) + .delete(`/v4/productTemplates/${templateId}`) + .set({ + Authorization: `Bearer ${testUtil.jwts.copilot}`, + }) + .expect(403, done); + }); + + it('should return 404 for non-existed template', (done) => { + request(server) + .delete('/v4/productTemplates/1234') + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .expect(404, done); + }); + + it('should return 404 for deleted template', (done) => { + models.ProductTemplate.destroy({ where: { id: templateId } }) + .then(() => { + request(server) + .delete(`/v4/productTemplates/${templateId}`) + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .expect(404, done); + }); + }); + + it('should return 204, for admin, if template was successfully removed', (done) => { + request(server) + .delete(`/v4/productTemplates/${templateId}`) + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .expect(204) + .end(done); + }); + + it('should return 204, for connect admin, if template was successfully removed', (done) => { + request(server) + .delete(`/v4/productTemplates/${templateId}`) + .set({ + Authorization: `Bearer ${testUtil.jwts.connectAdmin}`, + }) + .expect(204) + .end(done); + }); + + it('should return 204, for connect manager, if template was successfully removed', (done) => { + request(server) + .delete(`/v4/productTemplates/${templateId}`) + .set({ + Authorization: `Bearer ${testUtil.jwts.manager}`, + }) + .expect(204) + .end(done); + }); + }); +}); diff --git a/src/routes/productTemplates/get.js b/src/routes/productTemplates/get.js new file mode 100644 index 00000000..e660f979 --- /dev/null +++ b/src/routes/productTemplates/get.js @@ -0,0 +1,41 @@ +/** + * API to get a product template + */ +import validate from 'express-validation'; +import Joi from 'joi'; +import { middleware as tcMiddleware } from 'tc-core-library-js'; +import util from '../../util'; +import models from '../../models'; + +const permissions = tcMiddleware.permissions; + +const schema = { + params: { + templateId: Joi.number().integer().positive().required(), + }, +}; + +module.exports = [ + validate(schema), + permissions('productTemplate.view'), + (req, res, next) => models.ProductTemplate.findOne({ + where: { + deletedAt: { $eq: null }, + id: req.params.templateId, + }, + attributes: { exclude: ['deletedAt', 'deletedBy'] }, + raw: true, + }) + .then((productTemplate) => { + // Not found + if (!productTemplate) { + const apiErr = new Error(`Product template not found for product id ${req.params.templateId}`); + apiErr.status = 404; + return Promise.reject(apiErr); + } + + res.json(util.wrapResponse(req.id, productTemplate)); + return Promise.resolve(); + }) + .catch(next), +]; diff --git a/src/routes/productTemplates/get.spec.js b/src/routes/productTemplates/get.spec.js new file mode 100644 index 00000000..0fbdf37e --- /dev/null +++ b/src/routes/productTemplates/get.spec.js @@ -0,0 +1,153 @@ +/** + * Tests for get.js + */ +import chai from 'chai'; +import request from 'supertest'; + +import models from '../../models'; +import server from '../../app'; +import testUtil from '../../tests/util'; + +const should = chai.should(); + +describe('GET product template', () => { + const template = { + name: 'name 1', + productKey: 'productKey 1', + icon: 'http://example.com/icon1.ico', + brief: 'brief 1', + details: 'details 1', + aliases: { + alias1: { + subAlias1A: 1, + subAlias1B: 2, + }, + alias2: [1, 2, 3], + }, + template: { + template1: { + name: 'template 1', + details: { + anyDetails: 'any details 1', + }, + others: ['others 11', 'others 12'], + }, + template2: { + name: 'template 2', + details: { + anyDetails: 'any details 2', + }, + others: ['others 21', 'others 22'], + }, + }, + createdBy: 1, + updatedBy: 2, + }; + + let templateId; + + beforeEach(() => testUtil.clearDb() + .then(() => models.ProductTemplate.create(template)) + .then((createdTemplate) => { + templateId = createdTemplate.id; + return Promise.resolve(); + }), + ); + after(testUtil.clearDb); + + describe('GET /productTemplates/{templateId}', () => { + it('should return 403 if user is not authenticated', (done) => { + request(server) + .get(`/v4/productTemplates/${templateId}`) + .expect(403, done); + }); + + it('should return 404 for non-existed template', (done) => { + request(server) + .get('/v4/productTemplates/1234') + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .expect(404, done); + }); + + it('should return 404 for deleted template', (done) => { + models.ProductTemplate.destroy({ where: { id: templateId } }) + .then(() => { + request(server) + .get(`/v4/productTemplates/${templateId}`) + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .expect(404, done); + }); + }); + + it('should return 200 for admin', (done) => { + request(server) + .get(`/v4/productTemplates/${templateId}`) + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .expect(200) + .end((err, res) => { + const resJson = res.body.result.content; + resJson.id.should.be.eql(templateId); + resJson.name.should.be.eql(template.name); + resJson.productKey.should.be.eql(template.productKey); + resJson.icon.should.be.eql(template.icon); + resJson.brief.should.be.eql(template.brief); + resJson.details.should.be.eql(template.details); + resJson.aliases.should.be.eql(template.aliases); + resJson.template.should.be.eql(template.template); + + resJson.createdBy.should.be.eql(template.createdBy); + should.exist(resJson.createdAt); + resJson.updatedBy.should.be.eql(template.updatedBy); + should.exist(resJson.updatedAt); + should.not.exist(resJson.deletedBy); + should.not.exist(resJson.deletedAt); + + done(); + }); + }); + + it('should return 200 for connect admin', (done) => { + request(server) + .get(`/v4/productTemplates/${templateId}`) + .set({ + Authorization: `Bearer ${testUtil.jwts.connectAdmin}`, + }) + .expect(200) + .end(done); + }); + + it('should return 200 for connect manager', (done) => { + request(server) + .get(`/v4/productTemplates/${templateId}`) + .set({ + Authorization: `Bearer ${testUtil.jwts.manager}`, + }) + .expect(200) + .end(done); + }); + + it('should return 200 for member', (done) => { + request(server) + .get(`/v4/productTemplates/${templateId}`) + .set({ + Authorization: `Bearer ${testUtil.jwts.member}`, + }) + .expect(200, done); + }); + + it('should return 200 for copilot', (done) => { + request(server) + .get(`/v4/productTemplates/${templateId}`) + .set({ + Authorization: `Bearer ${testUtil.jwts.copilot}`, + }) + .expect(200, done); + }); + }); +}); diff --git a/src/routes/productTemplates/list.js b/src/routes/productTemplates/list.js new file mode 100644 index 00000000..d34f7d3e --- /dev/null +++ b/src/routes/productTemplates/list.js @@ -0,0 +1,31 @@ +/** + * API to list all product templates + */ +import { middleware as tcMiddleware } from 'tc-core-library-js'; +import util from '../../util'; +import models from '../../models'; + +const permissions = tcMiddleware.permissions; + +module.exports = [ + permissions('productTemplate.view'), + (req, res, next) => { + const filters = util.parseQueryFilter(req.query.filter); + if (!util.isValidFilter(filters, ['productKey'])) { + return util.handleError('Invalid filters', null, req, next); + } + const where = { deletedAt: { $eq: null } }; + if (filters.productKey) { + where.productKey = { $eq: filters.productKey }; + } + return models.ProductTemplate.findAll({ + where, + attributes: { exclude: ['deletedAt', 'deletedBy'] }, + raw: true, + }) + .then((productTemplates) => { + res.json(util.wrapResponse(req.id, productTemplates)); + }) + .catch(next); + }, +]; diff --git a/src/routes/productTemplates/list.spec.js b/src/routes/productTemplates/list.spec.js new file mode 100644 index 00000000..10d95f18 --- /dev/null +++ b/src/routes/productTemplates/list.spec.js @@ -0,0 +1,188 @@ +/** + * Tests for list.js + */ +// import chai from 'chai'; +import _ from 'lodash'; +import request from 'supertest'; + +import models from '../../models'; +import server from '../../app'; +import testUtil from '../../tests/util'; + +// const should = chai.should(); + +const validateProductTemplates = (count, resJson, expectedTemplates) => { + resJson.should.have.length(count); + resJson.forEach((pt, idx) => { + pt.should.have.all.keys('id', 'name', 'productKey', 'icon', 'brief', 'details', 'aliases', + 'template', 'disabled', 'hidden', 'createdBy', 'createdAt', 'updatedBy', 'updatedAt'); + pt.should.not.have.all.keys('deletedAt', 'deletedBy'); + pt.name.should.be.eql(expectedTemplates[idx].name); + pt.productKey.should.be.eql(expectedTemplates[idx].productKey); + pt.icon.should.be.eql(expectedTemplates[idx].icon); + pt.brief.should.be.eql(expectedTemplates[idx].brief); + pt.details.should.be.eql(expectedTemplates[idx].details); + pt.aliases.should.be.eql(expectedTemplates[idx].aliases); + pt.template.should.be.eql(expectedTemplates[idx].template); + pt.createdBy.should.be.eql(expectedTemplates[idx].createdBy); + pt.updatedBy.should.be.eql(expectedTemplates[idx].updatedBy); + pt.disabled.should.be.eql(_.get(expectedTemplates[idx], 'disabled', false)); + pt.hidden.should.be.eql(_.get(expectedTemplates[idx], 'hidden', false)); + }); +}; + +describe('LIST product templates', () => { + const templates = [ + { + name: 'name 1', + productKey: 'productKey-1', + icon: 'http://example.com/icon1.ico', + brief: 'brief 1', + details: 'details 1', + aliases: { + alias1: { + subAlias1A: 1, + subAlias1B: 2, + }, + alias2: [1, 2, 3], + }, + 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, + }, + { + name: 'template 2', + productKey: 'productKey-2', + icon: 'http://example.com/icon2.ico', + brief: 'brief 2', + details: 'details 2', + aliases: {}, + template: {}, + createdBy: 3, + updatedBy: 4, + }, + ]; + + let templateId; + + beforeEach(() => testUtil.clearDb() + .then(() => models.ProductTemplate.create(templates[0])) + .then((createdTemplate) => { + templateId = createdTemplate.id; + return models.ProductTemplate.create(templates[1]); + }).then(() => Promise.resolve()), + ); + after(testUtil.clearDb); + + describe('GET /productTemplates', () => { + it('should return 403 if user is not authenticated', (done) => { + request(server) + .get('/v4/productTemplates') + .expect(403, done); + }); + + it('should return 200 for admin', (done) => { + request(server) + .get('/v4/productTemplates') + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .expect(200) + .end((err, res) => { + const resJson = res.body.result.content; + validateProductTemplates(2, resJson, templates); + resJson[0].id.should.be.eql(templateId); + done(); + }); + }); + + it('should return 200 for connect admin', (done) => { + request(server) + .get('/v4/productTemplates') + .set({ + Authorization: `Bearer ${testUtil.jwts.connectAdmin}`, + }) + .expect(200) + .end((err, res) => { + const resJson = res.body.result.content; + validateProductTemplates(2, resJson, templates); + resJson[0].id.should.be.eql(templateId); + done(); + }); + }); + + it('should return 200 for connect manager', (done) => { + request(server) + .get('/v4/productTemplates') + .set({ + Authorization: `Bearer ${testUtil.jwts.manager}`, + }) + .expect(200) + .end((err, res) => { + const resJson = res.body.result.content; + validateProductTemplates(2, resJson, templates); + resJson[0].id.should.be.eql(templateId); + done(); + }); + }); + + it('should return 200 for member', (done) => { + request(server) + .get('/v4/productTemplates') + .set({ + Authorization: `Bearer ${testUtil.jwts.member}`, + }) + .end((err, res) => { + const resJson = res.body.result.content; + validateProductTemplates(2, resJson, templates); + resJson[0].id.should.be.eql(templateId); + done(); + }); + }); + + it('should return 200 for copilot', (done) => { + request(server) + .get('/v4/productTemplates') + .set({ + Authorization: `Bearer ${testUtil.jwts.copilot}`, + }) + .end((err, res) => { + const resJson = res.body.result.content; + validateProductTemplates(2, resJson, templates); + resJson[0].id.should.be.eql(templateId); + done(); + }); + }); + + it('should return filtered templates', (done) => { + request(server) + .get('/v4/productTemplates?filter=productKey%3DproductKey-2') + .set({ + Authorization: `Bearer ${testUtil.jwts.manager}`, + }) + .expect(200) + .end((err, res) => { + const resJson = res.body.result.content; + validateProductTemplates(1, resJson, [templates[1]]); + done(); + }); + }); + }); +}); diff --git a/src/routes/productTemplates/update.js b/src/routes/productTemplates/update.js new file mode 100644 index 00000000..eb559fa2 --- /dev/null +++ b/src/routes/productTemplates/update.js @@ -0,0 +1,74 @@ +/** + * API to update a product template + */ +import validate from 'express-validation'; +import _ from 'lodash'; +import Joi from 'joi'; +import { middleware as tcMiddleware } from 'tc-core-library-js'; +import util from '../../util'; +import models from '../../models'; + +const permissions = tcMiddleware.permissions; + +const schema = { + params: { + templateId: Joi.number().integer().positive().required(), + }, + body: { + param: Joi.object().keys({ + id: Joi.any().strip(), + name: Joi.string().max(255), + productKey: Joi.string().max(45), + icon: Joi.string().max(255), + brief: Joi.string().max(45), + details: Joi.string().max(255), + aliases: Joi.object(), + template: Joi.object(), + disabled: Joi.boolean().optional(), + hidden: Joi.boolean().optional(), + createdAt: Joi.any().strip(), + updatedAt: Joi.any().strip(), + deletedAt: Joi.any().strip(), + createdBy: Joi.any().strip(), + updatedBy: Joi.any().strip(), + deletedBy: Joi.any().strip(), + }).required(), + }, +}; + +module.exports = [ + validate(schema), + permissions('productTemplate.edit'), + (req, res, next) => { + const entityToUpdate = _.assign(req.body.param, { + updatedBy: req.authUser.userId, + }); + + return models.ProductTemplate.findOne({ + where: { + deletedAt: { $eq: null }, + id: req.params.templateId, + }, + attributes: { exclude: ['deletedAt', 'deletedBy'] }, + }) + .then((productTemplate) => { + // Not found + if (!productTemplate) { + const apiErr = new Error(`Product template not found for template id ${req.params.templateId}`); + apiErr.status = 404; + return Promise.reject(apiErr); + } + + // Merge JSON fields + entityToUpdate.aliases = util.mergeJsonObjects(productTemplate.aliases, entityToUpdate.aliases); + entityToUpdate.template = util.mergeJsonObjects(productTemplate.template, entityToUpdate.template); + + return productTemplate.update(entityToUpdate); + }) + .then((productTemplate) => { + res.json(util.wrapResponse(req.id, productTemplate)); + return Promise.resolve(); + }) + .catch(next); + }, +]; diff --git a/src/routes/productTemplates/update.spec.js b/src/routes/productTemplates/update.spec.js new file mode 100644 index 00000000..7eee9f01 --- /dev/null +++ b/src/routes/productTemplates/update.spec.js @@ -0,0 +1,248 @@ +/** + * Tests for get.js + */ +import chai from 'chai'; +import request from 'supertest'; + +import models from '../../models'; +import server from '../../app'; +import testUtil from '../../tests/util'; + +const should = chai.should(); + +describe('UPDATE product template', () => { + const template = { + name: 'name 1', + productKey: 'productKey 1', + icon: 'http://example.com/icon1.ico', + brief: 'brief 1', + details: 'details 1', + aliases: { + alias1: { + subAlias1A: 1, + subAlias1B: 2, + }, + alias2: [1, 2, 3], + }, + 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, + }; + + let templateId; + + beforeEach(() => testUtil.clearDb() + .then(() => models.ProductTemplate.create(template)) + .then((createdTemplate) => { + templateId = createdTemplate.id; + return Promise.resolve(); + }), + ); + after(testUtil.clearDb); + + describe('PATCH /productTemplates/{templateId}', () => { + const body = { + param: { + name: 'template 1 - update', + productKey: 'productKey 1 - update', + icon: 'http://example.com/icon1-update.ico', + brief: 'brief 1 - update', + details: 'details 1 - update', + aliases: { + alias1: { + subAlias1A: 11, + subAlias1C: 'new', + }, + alias2: [4], + alias3: 'new', + }, + template: { + template1: { + name: 'template 1 - update', + details: { + anyDetails: 'any details 1 - update', + newDetails: 'new', + }, + others: ['others new'], + }, + template3: { + name: 'template 3', + details: { + anyDetails: 'any details 3', + }, + others: ['others 31', 'others 32'], + }, + }, + }, + }; + + it('should return 403 if user is not authenticated', (done) => { + request(server) + .patch(`/v4/productTemplates/${templateId}`) + .send(body) + .expect(403, done); + }); + + it('should return 403 for member', (done) => { + request(server) + .patch(`/v4/productTemplates/${templateId}`) + .set({ + Authorization: `Bearer ${testUtil.jwts.member}`, + }) + .send(body) + .expect(403, done); + }); + + it('should return 403 for copilot', (done) => { + request(server) + .patch(`/v4/productTemplates/${templateId}`) + .send(body) + .set({ + Authorization: `Bearer ${testUtil.jwts.copilot}`, + }) + .expect(403, done); + }); + + it('should return 422 for invalid request', (done) => { + const invalidBody = { + param: { + aliases: 'a', + template: 1, + }, + }; + + request(server) + .patch(`/v4/productTemplates/${templateId}`) + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .send(invalidBody) + .expect(422, done); + }); + + it('should return 404 for non-existed template', (done) => { + request(server) + .patch('/v4/productTemplates/1234') + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .send(body) + .expect(404, done); + }); + + it('should return 404 for deleted template', (done) => { + models.ProductTemplate.destroy({ where: { id: templateId } }) + .then(() => { + request(server) + .patch(`/v4/productTemplates/${templateId}`) + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .send(body) + .expect(404, done); + }); + }); + + it('should return 200 for admin', (done) => { + request(server) + .patch(`/v4/productTemplates/${templateId}`) + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .send(body) + .expect(200) + .end((err, res) => { + const resJson = res.body.result.content; + resJson.id.should.be.eql(templateId); + resJson.name.should.be.eql(body.param.name); + resJson.productKey.should.be.eql(body.param.productKey); + resJson.icon.should.be.eql(body.param.icon); + resJson.brief.should.be.eql(body.param.brief); + resJson.details.should.be.eql(body.param.details); + resJson.disabled.should.be.eql(true); + resJson.hidden.should.be.eql(true); + + resJson.aliases.should.be.eql({ + alias1: { + subAlias1A: 11, + subAlias1B: 2, + subAlias1C: 'new', + }, + alias2: [4], + alias3: 'new', + }); + resJson.template.should.be.eql({ + template1: { + name: 'template 1 - update', + details: { + anyDetails: 'any details 1 - update', + newDetails: 'new', + }, + others: ['others new'], + }, + template2: { + name: 'template 2', + details: { + anyDetails: 'any details 2', + }, + others: ['others 21', 'others 22'], + }, + template3: { + name: 'template 3', + details: { + anyDetails: 'any details 3', + }, + others: ['others 31', 'others 32'], + }, + }); + resJson.createdBy.should.be.eql(template.createdBy); + should.exist(resJson.createdAt); + resJson.updatedBy.should.be.eql(40051333); // admin + should.exist(resJson.updatedAt); + should.not.exist(resJson.deletedBy); + should.not.exist(resJson.deletedAt); + + done(); + }); + }); + + it('should return 200 for connect admin', (done) => { + request(server) + .patch(`/v4/productTemplates/${templateId}`) + .set({ + Authorization: `Bearer ${testUtil.jwts.connectAdmin}`, + }) + .send(body) + .expect(200) + .end(done); + }); + + it('should return 200 for connect manager', (done) => { + request(server) + .patch(`/v4/productTemplates/${templateId}`) + .set({ + Authorization: `Bearer ${testUtil.jwts.manager}`, + }) + .send(body) + .expect(200) + .end(done); + }); + }); +}); diff --git a/src/routes/projectTemplates/create.js b/src/routes/projectTemplates/create.js new file mode 100644 index 00000000..cdae740e --- /dev/null +++ b/src/routes/projectTemplates/create.js @@ -0,0 +1,55 @@ +/** + * API to add a project template + */ +import validate from 'express-validation'; +import _ from 'lodash'; +import Joi from 'joi'; +import { middleware as tcMiddleware } from 'tc-core-library-js'; +import util from '../../util'; +import models from '../../models'; + +const permissions = tcMiddleware.permissions; + +const schema = { + body: { + param: Joi.object().keys({ + id: Joi.any().strip(), + name: Joi.string().max(255).required(), + key: Joi.string().max(45).required(), + category: Joi.string().max(45).required(), + icon: Joi.string().max(255).required(), + question: Joi.string().max(255).required(), + info: Joi.string().max(255).required(), + aliases: Joi.array().required(), + scope: Joi.object().required(), + phases: Joi.object().required(), + disabled: Joi.boolean().optional(), + hidden: Joi.boolean().optional(), + createdAt: Joi.any().strip(), + updatedAt: Joi.any().strip(), + deletedAt: Joi.any().strip(), + createdBy: Joi.any().strip(), + updatedBy: Joi.any().strip(), + deletedBy: Joi.any().strip(), + }).required(), + }, +}; + +module.exports = [ + validate(schema), + permissions('projectTemplate.create'), + (req, res, next) => { + const entity = _.assign(req.body.param, { + createdBy: req.authUser.userId, + updatedBy: req.authUser.userId, + }); + + return models.ProjectTemplate.create(entity) + .then((createdEntity) => { + // Omit deletedAt, deletedBy + res.status(201).json(util.wrapResponse( + req.id, _.omit(createdEntity.toJSON(), 'deletedAt', 'deletedBy'), 1, 201)); + }) + .catch(next); + }, +]; diff --git a/src/routes/projectTemplates/create.spec.js b/src/routes/projectTemplates/create.spec.js new file mode 100644 index 00000000..ecf86a11 --- /dev/null +++ b/src/routes/projectTemplates/create.spec.js @@ -0,0 +1,161 @@ +/** + * Tests for create.js + */ +import chai from 'chai'; +import request from 'supertest'; + +import server from '../../app'; +import testUtil from '../../tests/util'; + +const should = chai.should(); + +describe('CREATE project template', () => { + describe('POST /projectTemplates', () => { + const body = { + param: { + name: 'template 1', + key: 'key 1', + category: 'category 1', + icon: 'http://example.com/icon1.ico', + question: 'question 1', + info: 'info 1', + aliases: ['key-1', 'key_1'], + disabled: true, + hidden: true, + scope: { + scope1: { + subScope1A: 1, + subScope1B: 2, + }, + scope2: [1, 2, 3], + }, + phases: { + phase1: { + name: 'phase 1', + details: { + anyDetails: 'any details 1', + }, + others: ['others 11', 'others 12'], + }, + phase2: { + name: 'phase 2', + details: { + anyDetails: 'any details 2', + }, + others: ['others 21', 'others 22'], + }, + }, + }, + }; + + it('should return 403 if user is not authenticated', (done) => { + request(server) + .post('/v4/projectTemplates') + .send(body) + .expect(403, done); + }); + + it('should return 403 for member', (done) => { + request(server) + .post('/v4/projectTemplates') + .set({ + Authorization: `Bearer ${testUtil.jwts.member}`, + }) + .send(body) + .expect(403, done); + }); + + it('should return 403 for copilot', (done) => { + request(server) + .post('/v4/projectTemplates') + .set({ + Authorization: `Bearer ${testUtil.jwts.copilot}`, + }) + .send(body) + .expect(403, done); + }); + + it('should return 422 if validations dont pass', (done) => { + const invalidBody = { + param: { + scope: 'a', + phases: 1, + }, + }; + + request(server) + .post('/v4/projectTemplates') + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .send(invalidBody) + .expect('Content-Type', /json/) + .expect(422, done); + }); + + it('should return 201 for admin', (done) => { + request(server) + .post('/v4/projectTemplates') + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .send(body) + .expect('Content-Type', /json/) + .expect(201) + .end((err, res) => { + const resJson = res.body.result.content; + should.exist(resJson.id); + resJson.name.should.be.eql(body.param.name); + resJson.key.should.be.eql(body.param.key); + resJson.category.should.be.eql(body.param.category); + resJson.disabled.should.be.eql(true); + resJson.hidden.should.be.eql(true); + resJson.scope.should.be.eql(body.param.scope); + resJson.phases.should.be.eql(body.param.phases); + + resJson.createdBy.should.be.eql(40051333); // admin + should.exist(resJson.createdAt); + resJson.updatedBy.should.be.eql(40051333); // admin + should.exist(resJson.updatedAt); + should.not.exist(resJson.deletedBy); + should.not.exist(resJson.deletedAt); + + done(); + }); + }); + + it('should return 201 for connect manager', (done) => { + request(server) + .post('/v4/projectTemplates') + .set({ + Authorization: `Bearer ${testUtil.jwts.manager}`, + }) + .send(body) + .expect('Content-Type', /json/) + .expect(201) + .end((err, res) => { + const resJson = res.body.result.content; + resJson.createdBy.should.be.eql(40051334); // manager + resJson.updatedBy.should.be.eql(40051334); // manager + done(); + }); + }); + + it('should return 201 for connect admin', (done) => { + request(server) + .post('/v4/projectTemplates') + .set({ + Authorization: `Bearer ${testUtil.jwts.connectAdmin}`, + }) + .send(body) + .expect('Content-Type', /json/) + .expect(201) + .end((err, res) => { + const resJson = res.body.result.content; + resJson.createdBy.should.be.eql(40051336); // connect admin + resJson.updatedBy.should.be.eql(40051336); // connect admin + done(); + }); + }); + }); +}); diff --git a/src/routes/projectTemplates/delete.js b/src/routes/projectTemplates/delete.js new file mode 100644 index 00000000..4db9a855 --- /dev/null +++ b/src/routes/projectTemplates/delete.js @@ -0,0 +1,55 @@ +/** + * API to delete a project template + */ +import validate from 'express-validation'; +import Joi from 'joi'; +import { middleware as tcMiddleware } from 'tc-core-library-js'; +import models from '../../models'; + +const permissions = tcMiddleware.permissions; + +const schema = { + params: { + templateId: Joi.number().integer().positive().required(), + }, +}; + +module.exports = [ + validate(schema), + permissions('projectTemplate.delete'), + (req, res, next) => { + const where = { + deletedAt: { $eq: null }, + id: req.params.templateId, + }; + + return models.sequelize.transaction(tx => + // Update the deletedBy + models.ProjectTemplate.update({ deletedBy: req.authUser.userId }, { + where, + returning: true, + raw: true, + transaction: tx, + }) + .then((updatedResults) => { + // Not found + if (updatedResults[0] === 0) { + const apiErr = new Error(`Project template not found for template id ${req.params.templateId}`); + apiErr.status = 404; + return Promise.reject(apiErr); + } + + // Soft delete + return models.ProjectTemplate.destroy({ + where, + transaction: tx, + raw: true, + }); + }) + .then(() => { + res.status(204).end(); + }) + .catch(next), + ); + }, +]; diff --git a/src/routes/projectTemplates/delete.spec.js b/src/routes/projectTemplates/delete.spec.js new file mode 100644 index 00000000..3ff10470 --- /dev/null +++ b/src/routes/projectTemplates/delete.spec.js @@ -0,0 +1,131 @@ +/** + * Tests for delete.js + */ +import request from 'supertest'; + +import models from '../../models'; +import server from '../../app'; +import testUtil from '../../tests/util'; + + +describe('DELETE project template', () => { + let templateId; + + beforeEach(() => testUtil.clearDb() + .then(() => models.ProjectTemplate.create({ + name: 'template 1', + key: 'key 1', + category: 'category 1', + icon: 'http://example.com/icon1.ico', + question: 'question 1', + info: 'info 1', + aliases: ['key-1', 'key_1'], + scope: { + scope1: { + subScope1A: 1, + subScope1B: 2, + }, + scope2: [1, 2, 3], + }, + phases: { + phase1: { + name: 'phase 1', + details: { + anyDetails: 'any details 1', + }, + others: ['others 11', 'others 12'], + }, + phase2: { + name: 'phase 2', + details: { + anyDetails: 'any details 2', + }, + others: ['others 21', 'others 22'], + }, + }, + createdBy: 1, + updatedBy: 1, + })).then((template) => { + templateId = template.id; + return Promise.resolve(); + }), + ); + after(testUtil.clearDb); + + describe('DELETE /projectTemplates/{templateId}', () => { + it('should return 403 if user is not authenticated', (done) => { + request(server) + .delete(`/v4/projectTemplates/${templateId}`) + .expect(403, done); + }); + + it('should return 403 for member', (done) => { + request(server) + .delete(`/v4/projectTemplates/${templateId}`) + .set({ + Authorization: `Bearer ${testUtil.jwts.member}`, + }) + .expect(403, done); + }); + + it('should return 403 for copilot', (done) => { + request(server) + .delete(`/v4/projectTemplates/${templateId}`) + .set({ + Authorization: `Bearer ${testUtil.jwts.copilot}`, + }) + .expect(403, done); + }); + + it('should return 404 for non-existed template', (done) => { + request(server) + .delete('/v4/projectTemplates/1234') + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .expect(404, done); + }); + + it('should return 404 for deleted template', (done) => { + models.ProjectTemplate.destroy({ where: { id: templateId } }) + .then(() => { + request(server) + .delete(`/v4/projectTemplates/${templateId}`) + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .expect(404, done); + }); + }); + + it('should return 204, for admin, if template was successfully removed', (done) => { + request(server) + .delete(`/v4/projectTemplates/${templateId}`) + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .expect(204) + .end(done); + }); + + it('should return 204, for connect admin, if template was successfully removed', (done) => { + request(server) + .delete(`/v4/projectTemplates/${templateId}`) + .set({ + Authorization: `Bearer ${testUtil.jwts.connectAdmin}`, + }) + .expect(204) + .end(done); + }); + + it('should return 204, for connect manager, if template was successfully removed', (done) => { + request(server) + .delete(`/v4/projectTemplates/${templateId}`) + .set({ + Authorization: `Bearer ${testUtil.jwts.manager}`, + }) + .expect(204) + .end(done); + }); + }); +}); diff --git a/src/routes/projectTemplates/get.js b/src/routes/projectTemplates/get.js new file mode 100644 index 00000000..e81c0939 --- /dev/null +++ b/src/routes/projectTemplates/get.js @@ -0,0 +1,41 @@ +/** + * API to get a project template + */ +import validate from 'express-validation'; +import Joi from 'joi'; +import { middleware as tcMiddleware } from 'tc-core-library-js'; +import util from '../../util'; +import models from '../../models'; + +const permissions = tcMiddleware.permissions; + +const schema = { + params: { + templateId: Joi.number().integer().positive().required(), + }, +}; + +module.exports = [ + validate(schema), + permissions('projectTemplate.view'), + (req, res, next) => models.ProjectTemplate.findOne({ + where: { + deletedAt: { $eq: null }, + id: req.params.templateId, + }, + attributes: { exclude: ['deletedAt', 'deletedBy'] }, + raw: true, + }) + .then((projectTemplate) => { + // Not found + if (!projectTemplate) { + const apiErr = new Error(`Project template not found for project id ${req.params.templateId}`); + apiErr.status = 404; + return Promise.reject(apiErr); + } + + res.json(util.wrapResponse(req.id, projectTemplate)); + return Promise.resolve(); + }) + .catch(next), +]; diff --git a/src/routes/projectTemplates/get.spec.js b/src/routes/projectTemplates/get.spec.js new file mode 100644 index 00000000..764093c5 --- /dev/null +++ b/src/routes/projectTemplates/get.spec.js @@ -0,0 +1,152 @@ +/** + * Tests for get.js + */ +import chai from 'chai'; +import request from 'supertest'; + +import models from '../../models'; +import server from '../../app'; +import testUtil from '../../tests/util'; + +const should = chai.should(); + +describe('GET project template', () => { + const template = { + name: 'template 1', + key: 'key 1', + category: 'category 1', + icon: 'http://example.com/icon1.ico', + question: 'question 1', + info: 'info 1', + aliases: ['key-1', 'key_1'], + scope: { + scope1: { + subScope1A: 1, + subScope1B: 2, + }, + scope2: [1, 2, 3], + }, + phases: { + phase1: { + name: 'phase 1', + details: { + anyDetails: 'any details 1', + }, + others: ['others 11', 'others 12'], + }, + phase2: { + name: 'phase 2', + details: { + anyDetails: 'any details 2', + }, + others: ['others 21', 'others 22'], + }, + }, + createdBy: 1, + updatedBy: 1, + }; + + let templateId; + + beforeEach(() => testUtil.clearDb() + .then(() => models.ProjectTemplate.create(template)) + .then((createdTemplate) => { + templateId = createdTemplate.id; + return Promise.resolve(); + }), + ); + after(testUtil.clearDb); + + describe('GET /projectTemplates/{templateId}', () => { + it('should return 403 if user is not authenticated', (done) => { + request(server) + .get(`/v4/projectTemplates/${templateId}`) + .expect(403, done); + }); + + it('should return 404 for non-existed template', (done) => { + request(server) + .get('/v4/projectTemplates/1234') + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .expect(404, done); + }); + + it('should return 404 for deleted template', (done) => { + models.ProjectTemplate.destroy({ where: { id: templateId } }) + .then(() => { + request(server) + .get(`/v4/projectTemplates/${templateId}`) + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .expect(404, done); + }); + }); + + it('should return 200 for admin', (done) => { + request(server) + .get(`/v4/projectTemplates/${templateId}`) + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .expect(200) + .end((err, res) => { + const resJson = res.body.result.content; + resJson.id.should.be.eql(templateId); + resJson.name.should.be.eql(template.name); + resJson.key.should.be.eql(template.key); + resJson.category.should.be.eql(template.category); + resJson.scope.should.be.eql(template.scope); + resJson.phases.should.be.eql(template.phases); + resJson.createdBy.should.be.eql(template.createdBy); + should.exist(resJson.createdAt); + resJson.updatedBy.should.be.eql(template.updatedBy); + should.exist(resJson.updatedAt); + should.not.exist(resJson.deletedBy); + should.not.exist(resJson.deletedAt); + + done(); + }); + }); + + it('should return 200 for connect admin', (done) => { + request(server) + .get(`/v4/projectTemplates/${templateId}`) + .set({ + Authorization: `Bearer ${testUtil.jwts.connectAdmin}`, + }) + .expect(200) + .end(done); + }); + + it('should return 200 for connect manager', (done) => { + request(server) + .get(`/v4/projectTemplates/${templateId}`) + .set({ + Authorization: `Bearer ${testUtil.jwts.manager}`, + }) + .expect(200) + .end(done); + }); + + it('should return 200 for member', (done) => { + request(server) + .get(`/v4/projectTemplates/${templateId}`) + .set({ + Authorization: `Bearer ${testUtil.jwts.member}`, + }) + .expect(200, done); + }); + + it('should return 200 for copilot', (done) => { + request(server) + .get(`/v4/projectTemplates/${templateId}`) + .set({ + Authorization: `Bearer ${testUtil.jwts.copilot}`, + }) + .expect(200, done); + }); + }); +}); diff --git a/src/routes/projectTemplates/list.js b/src/routes/projectTemplates/list.js new file mode 100644 index 00000000..3e83f2e4 --- /dev/null +++ b/src/routes/projectTemplates/list.js @@ -0,0 +1,23 @@ +/** + * API to list all project templates + */ +import { middleware as tcMiddleware } from 'tc-core-library-js'; +import util from '../../util'; +import models from '../../models'; + +const permissions = tcMiddleware.permissions; + +module.exports = [ + permissions('projectTemplate.view'), + (req, res, next) => models.ProjectTemplate.findAll({ + where: { + deletedAt: { $eq: null }, + }, + attributes: { exclude: ['deletedAt', 'deletedBy'] }, + raw: true, + }) + .then((projectTemplates) => { + res.json(util.wrapResponse(req.id, projectTemplates)); + }) + .catch(next), +]; diff --git a/src/routes/projectTemplates/list.spec.js b/src/routes/projectTemplates/list.spec.js new file mode 100644 index 00000000..f6bae1f7 --- /dev/null +++ b/src/routes/projectTemplates/list.spec.js @@ -0,0 +1,151 @@ +/** + * Tests for list.js + */ +import chai from 'chai'; +import request from 'supertest'; + +import models from '../../models'; +import server from '../../app'; +import testUtil from '../../tests/util'; + +const should = chai.should(); + +describe('LIST project templates', () => { + const templates = [ + { + name: 'template 1', + key: 'key 1', + category: 'category 1', + icon: 'http://example.com/icon1.ico', + question: 'question 1', + info: 'info 1', + aliases: ['key-1', 'key_1'], + disabled: true, + hidden: true, + scope: { + scope1: { + subScope1A: 1, + subScope1B: 2, + }, + scope2: [1, 2, 3], + }, + phases: { + phase1: { + name: 'phase 1', + details: { + anyDetails: 'any details 1', + }, + others: ['others 11', 'others 12'], + }, + phase2: { + name: 'phase 2', + details: { + anyDetails: 'any details 2', + }, + others: ['others 21', 'others 22'], + }, + }, + createdBy: 1, + updatedBy: 1, + }, + { + name: 'template 2', + key: 'key 2', + category: 'category 2', + icon: 'http://example.com/icon1.ico', + question: 'question 2', + info: 'info 2', + aliases: ['key-2', 'key_2'], + scope: {}, + phases: {}, + createdBy: 1, + updatedBy: 2, + }, + ]; + + let templateId; + + beforeEach(() => testUtil.clearDb() + .then(() => models.ProjectTemplate.create(templates[0])) + .then((createdTemplate) => { + templateId = createdTemplate.id; + return models.ProjectTemplate.create(templates[1]); + }).then(() => Promise.resolve()), + ); + after(testUtil.clearDb); + + describe('GET /projectTemplates', () => { + it('should return 403 if user is not authenticated', (done) => { + request(server) + .get('/v4/projectTemplates') + .expect(403, done); + }); + + it('should return 200 for admin', (done) => { + request(server) + .get('/v4/projectTemplates') + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .expect(200) + .end((err, res) => { + const template = templates[0]; + + const resJson = res.body.result.content; + resJson.should.have.length(2); + resJson[0].id.should.be.eql(templateId); + resJson[0].name.should.be.eql(template.name); + resJson[0].key.should.be.eql(template.key); + resJson[0].category.should.be.eql(template.category); + resJson[0].scope.should.be.eql(template.scope); + resJson[0].phases.should.be.eql(template.phases); + resJson[0].createdBy.should.be.eql(template.createdBy); + should.exist(resJson[0].createdAt); + resJson[0].updatedBy.should.be.eql(template.updatedBy); + should.exist(resJson[0].updatedAt); + should.not.exist(resJson[0].deletedBy); + should.not.exist(resJson[0].deletedAt); + + done(); + }); + }); + + it('should return 200 for connect admin', (done) => { + request(server) + .get('/v4/projectTemplates') + .set({ + Authorization: `Bearer ${testUtil.jwts.connectAdmin}`, + }) + .expect(200) + .end(done); + }); + + it('should return 200 for connect manager', (done) => { + request(server) + .get('/v4/projectTemplates') + .set({ + Authorization: `Bearer ${testUtil.jwts.manager}`, + }) + .expect(200) + .end(done); + }); + + it('should return 200 for member', (done) => { + request(server) + .get('/v4/projectTemplates') + .set({ + Authorization: `Bearer ${testUtil.jwts.member}`, + }) + .expect(200, done); + }); + + it('should return 200 for copilot', (done) => { + request(server) + .get('/v4/projectTemplates') + .set({ + Authorization: `Bearer ${testUtil.jwts.copilot}`, + }) + .expect(200, done); + }); + }); +}); diff --git a/src/routes/projectTemplates/update.js b/src/routes/projectTemplates/update.js new file mode 100644 index 00000000..7a9f27e5 --- /dev/null +++ b/src/routes/projectTemplates/update.js @@ -0,0 +1,76 @@ +/** + * API to update a project template + */ +import validate from 'express-validation'; +import _ from 'lodash'; +import Joi from 'joi'; +import { middleware as tcMiddleware } from 'tc-core-library-js'; +import util from '../../util'; +import models from '../../models'; + +const permissions = tcMiddleware.permissions; + +const schema = { + params: { + templateId: Joi.number().integer().positive().required(), + }, + body: { + param: Joi.object().keys({ + id: Joi.any().strip(), + name: Joi.string().max(255), + key: Joi.string().max(45), + category: Joi.string().max(45), + icon: Joi.string().max(255), + question: Joi.string().max(255), + info: Joi.string().max(255), + aliases: Joi.array(), + scope: Joi.object(), + phases: Joi.object(), + disabled: Joi.boolean().optional(), + hidden: Joi.boolean().optional(), + createdAt: Joi.any().strip(), + updatedAt: Joi.any().strip(), + deletedAt: Joi.any().strip(), + createdBy: Joi.any().strip(), + updatedBy: Joi.any().strip(), + deletedBy: Joi.any().strip(), + }).required(), + }, +}; + +module.exports = [ + validate(schema), + permissions('projectTemplate.edit'), + (req, res, next) => { + const entityToUpdate = _.assign(req.body.param, { + updatedBy: req.authUser.userId, + }); + + return models.ProjectTemplate.findOne({ + where: { + deletedAt: { $eq: null }, + id: req.params.templateId, + }, + attributes: { exclude: ['deletedAt', 'deletedBy'] }, + }) + .then((projectTemplate) => { + // Not found + if (!projectTemplate) { + const apiErr = new Error(`Project template not found for template id ${req.params.templateId}`); + apiErr.status = 404; + return Promise.reject(apiErr); + } + + // Merge JSON fields + entityToUpdate.scope = util.mergeJsonObjects(projectTemplate.scope, entityToUpdate.scope); + entityToUpdate.phases = util.mergeJsonObjects(projectTemplate.phases, entityToUpdate.phases); + + return projectTemplate.update(entityToUpdate); + }) + .then((projectTemplate) => { + res.json(util.wrapResponse(req.id, projectTemplate)); + return Promise.resolve(); + }) + .catch(next); + }, +]; diff --git a/src/routes/projectTemplates/update.spec.js b/src/routes/projectTemplates/update.spec.js new file mode 100644 index 00000000..7ee11dd1 --- /dev/null +++ b/src/routes/projectTemplates/update.spec.js @@ -0,0 +1,245 @@ +/** + * Tests for get.js + */ +import chai from 'chai'; +import request from 'supertest'; + +import models from '../../models'; +import server from '../../app'; +import testUtil from '../../tests/util'; + +const should = chai.should(); + +describe('UPDATE project template', () => { + const template = { + name: 'template 1', + key: 'key 1', + category: 'category 1', + icon: 'http://example.com/icon1.ico', + question: 'question 1', + info: 'info 1', + aliases: ['key-1', 'key_1'], + disabled: true, + hidden: true, + scope: { + scope1: { + subScope1A: 1, + subScope1B: 2, + }, + scope2: [1, 2, 3], + }, + phases: { + phase1: { + name: 'phase 1', + details: { + anyDetails: 'any details 1', + }, + others: ['others 11', 'others 12'], + }, + phase2: { + name: 'phase 2', + details: { + anyDetails: 'any details 2', + }, + others: ['others 21', 'others 22'], + }, + }, + createdBy: 1, + updatedBy: 1, + }; + + let templateId; + + beforeEach(() => testUtil.clearDb() + .then(() => models.ProjectTemplate.create(template)) + .then((createdTemplate) => { + templateId = createdTemplate.id; + return Promise.resolve(); + }), + ); + after(testUtil.clearDb); + + describe('PATCH /projectTemplates/{templateId}', () => { + const body = { + param: { + name: 'template 1 - update', + key: 'key 1 - update', + category: 'category 1 - update', + scope: { + scope1: { + subScope1A: 11, + subScope1C: 'new', + }, + scope2: [4], + scope3: 'new', + }, + phases: { + phase1: { + name: 'phase 1 - update', + details: { + anyDetails: 'any details 1 - update', + newDetails: 'new', + }, + others: ['others new'], + }, + phase3: { + name: 'phase 3', + details: { + anyDetails: 'any details 3', + }, + others: ['others 31', 'others 32'], + }, + }, + }, + }; + + it('should return 403 if user is not authenticated', (done) => { + request(server) + .patch(`/v4/projectTemplates/${templateId}`) + .send(body) + .expect(403, done); + }); + + it('should return 403 for member', (done) => { + request(server) + .patch(`/v4/projectTemplates/${templateId}`) + .set({ + Authorization: `Bearer ${testUtil.jwts.member}`, + }) + .send(body) + .expect(403, done); + }); + + it('should return 403 for copilot', (done) => { + request(server) + .patch(`/v4/projectTemplates/${templateId}`) + .send(body) + .set({ + Authorization: `Bearer ${testUtil.jwts.copilot}`, + }) + .expect(403, done); + }); + + it('should return 422 for invalid request', (done) => { + const invalidBody = { + param: { + scope: 'a', + phases: 1, + }, + }; + + request(server) + .patch(`/v4/projectTemplates/${templateId}`) + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .send(invalidBody) + .expect(422, done); + }); + + it('should return 404 for non-existed template', (done) => { + request(server) + .patch('/v4/projectTemplates/1234') + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .send(body) + .expect(404, done); + }); + + it('should return 404 for deleted template', (done) => { + models.ProjectTemplate.destroy({ where: { id: templateId } }) + .then(() => { + request(server) + .patch(`/v4/projectTemplates/${templateId}`) + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .send(body) + .expect(404, done); + }); + }); + + it('should return 200 for admin', (done) => { + request(server) + .patch(`/v4/projectTemplates/${templateId}`) + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .send(body) + .expect(200) + .end((err, res) => { + const resJson = res.body.result.content; + resJson.id.should.be.eql(templateId); + resJson.name.should.be.eql(body.param.name); + resJson.key.should.be.eql(body.param.key); + resJson.category.should.be.eql(body.param.category); + resJson.scope.should.be.eql({ + scope1: { + subScope1A: 11, + subScope1B: 2, + subScope1C: 'new', + }, + scope2: [4], + scope3: 'new', + }); + resJson.phases.should.be.eql({ + phase1: { + name: 'phase 1 - update', + details: { + anyDetails: 'any details 1 - update', + newDetails: 'new', + }, + others: ['others new'], + }, + phase2: { + name: 'phase 2', + details: { + anyDetails: 'any details 2', + }, + others: ['others 21', 'others 22'], + }, + phase3: { + name: 'phase 3', + details: { + anyDetails: 'any details 3', + }, + others: ['others 31', 'others 32'], + }, + }); + resJson.disabled.should.be.eql(true); + resJson.hidden.should.be.eql(true); + resJson.createdBy.should.be.eql(template.createdBy); + should.exist(resJson.createdAt); + resJson.updatedBy.should.be.eql(40051333); // admin + should.exist(resJson.updatedAt); + should.not.exist(resJson.deletedBy); + should.not.exist(resJson.deletedAt); + + done(); + }); + }); + + it('should return 200 for connect admin', (done) => { + request(server) + .patch(`/v4/projectTemplates/${templateId}`) + .set({ + Authorization: `Bearer ${testUtil.jwts.connectAdmin}`, + }) + .send(body) + .expect(200) + .end(done); + }); + + it('should return 200 for connect manager', (done) => { + request(server) + .patch(`/v4/projectTemplates/${templateId}`) + .set({ + Authorization: `Bearer ${testUtil.jwts.manager}`, + }) + .send(body) + .expect(200) + .end(done); + }); + }); +}); diff --git a/src/routes/projectTypes/create.js b/src/routes/projectTypes/create.js new file mode 100644 index 00000000..3cbcf579 --- /dev/null +++ b/src/routes/projectTypes/create.js @@ -0,0 +1,55 @@ +/** + * API to add a project type + */ +import validate from 'express-validation'; +import _ from 'lodash'; +import Joi from 'joi'; +import { middleware as tcMiddleware } from 'tc-core-library-js'; +import util from '../../util'; +import models from '../../models'; + +const permissions = tcMiddleware.permissions; + +const schema = { + body: { + param: Joi.object().keys({ + key: Joi.string().max(45).required(), + displayName: Joi.string().max(255).required(), + createdAt: Joi.any().strip(), + updatedAt: Joi.any().strip(), + deletedAt: Joi.any().strip(), + createdBy: Joi.any().strip(), + updatedBy: Joi.any().strip(), + deletedBy: Joi.any().strip(), + }).required(), + }, +}; + +module.exports = [ + validate(schema), + permissions('projectType.create'), + (req, res, next) => { + const entity = _.assign(req.body.param, { + createdBy: req.authUser.userId, + updatedBy: req.authUser.userId, + }); + + // Check if duplicated key + return models.ProjectType.findById(req.body.param.key) + .then((existing) => { + if (existing) { + const apiErr = new Error(`Project type already exists for key ${req.params.key}`); + apiErr.status = 422; + return Promise.reject(apiErr); + } + + // Create + return models.ProjectType.create(entity); + }).then((createdEntity) => { + // Omit deletedAt, deletedBy + res.status(201).json(util.wrapResponse( + req.id, _.omit(createdEntity.toJSON(), 'deletedAt', 'deletedBy'), 1, 201)); + }) + .catch(next); + }, +]; diff --git a/src/routes/projectTypes/create.spec.js b/src/routes/projectTypes/create.spec.js new file mode 100644 index 00000000..69b4b391 --- /dev/null +++ b/src/routes/projectTypes/create.spec.js @@ -0,0 +1,163 @@ +/** + * Tests for create.js + */ +import chai from 'chai'; +import request from 'supertest'; + +import server from '../../app'; +import testUtil from '../../tests/util'; +import models from '../../models'; + +const should = chai.should(); + +describe('CREATE project type', () => { + beforeEach(() => testUtil.clearDb() + .then(() => models.ProjectType.create({ + key: 'key1', + displayName: 'displayName 1', + createdBy: 1, + updatedBy: 1, + })).then(() => Promise.resolve()), + ); + after(testUtil.clearDb); + + describe('POST /projectTypes', () => { + const body = { + param: { + key: 'app_dev', + displayName: 'Application Development', + }, + }; + + it('should return 403 if user is not authenticated', (done) => { + request(server) + .post('/v4/projectTypes') + .send(body) + .expect(403, done); + }); + + it('should return 403 for member', (done) => { + request(server) + .post('/v4/projectTypes') + .set({ + Authorization: `Bearer ${testUtil.jwts.member}`, + }) + .send(body) + .expect(403, done); + }); + + it('should return 403 for copilot', (done) => { + request(server) + .post('/v4/projectTypes') + .set({ + Authorization: `Bearer ${testUtil.jwts.copilot}`, + }) + .send(body) + .expect(403, done); + }); + + it('should return 403 for manager', (done) => { + request(server) + .post('/v4/projectTypes') + .set({ + Authorization: `Bearer ${testUtil.jwts.manager}`, + }) + .send(body) + .expect(403, done); + }); + + it('should return 422 for missing key', (done) => { + const invalidBody = { + param: { + displayName: 'displayName', + }, + }; + + request(server) + .post('/v4/projectTypes') + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .send(invalidBody) + .expect('Content-Type', /json/) + .expect(422, done); + }); + + it('should return 422 for missing displayName', (done) => { + const invalidBody = { + param: { + key: 'key', + }, + }; + + request(server) + .post('/v4/projectTypes') + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .send(invalidBody) + .expect('Content-Type', /json/) + .expect(422, done); + }); + + it('should return 422 for duplicated key', (done) => { + const invalidBody = { + param: { + key: 'key1', + displayName: 'displayName', + }, + }; + + request(server) + .post('/v4/projectTypes') + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .send(invalidBody) + .expect('Content-Type', /json/) + .expect(422, done); + }); + + it('should return 201 for admin', (done) => { + request(server) + .post('/v4/projectTypes') + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .send(body) + .expect('Content-Type', /json/) + .expect(201) + .end((err, res) => { + const resJson = res.body.result.content; + resJson.key.should.be.eql(body.param.key); + resJson.displayName.should.be.eql(body.param.displayName); + + resJson.createdBy.should.be.eql(40051333); // admin + should.exist(resJson.createdAt); + resJson.updatedBy.should.be.eql(40051333); // admin + should.exist(resJson.updatedAt); + should.not.exist(resJson.deletedBy); + should.not.exist(resJson.deletedAt); + + done(); + }); + }); + + it('should return 201 for connect admin', (done) => { + request(server) + .post('/v4/projectTypes') + .set({ + Authorization: `Bearer ${testUtil.jwts.connectAdmin}`, + }) + .send(body) + .expect('Content-Type', /json/) + .expect(201) + .end((err, res) => { + const resJson = res.body.result.content; + resJson.createdBy.should.be.eql(40051336); // connect admin + resJson.updatedBy.should.be.eql(40051336); // connect admin + done(); + }); + }); + }); +}); diff --git a/src/routes/projectTypes/delete.js b/src/routes/projectTypes/delete.js new file mode 100644 index 00000000..7592641c --- /dev/null +++ b/src/routes/projectTypes/delete.js @@ -0,0 +1,54 @@ +/** + * API to delete a project type + */ +import validate from 'express-validation'; +import Joi from 'joi'; +import { middleware as tcMiddleware } from 'tc-core-library-js'; +import models from '../../models'; + +const permissions = tcMiddleware.permissions; + +const schema = { + params: { + key: Joi.string().max(45).required(), + }, +}; + +module.exports = [ + validate(schema), + permissions('projectType.delete'), + (req, res, next) => { + const where = { + deletedAt: { $eq: null }, + key: req.params.key, + }; + + return models.sequelize.transaction(tx => + // Update the deletedBy + models.ProjectType.update({ deletedBy: req.authUser.userId }, { + where, + returning: true, + raw: true, + transaction: tx, + }) + .then((updatedResults) => { + // Not found + if (updatedResults[0] === 0) { + const apiErr = new Error(`Project type not found for key ${req.params.key}`); + apiErr.status = 404; + return Promise.reject(apiErr); + } + + // Soft delete + return models.ProjectType.destroy({ + where, + transaction: tx, + }); + }) + .then(() => { + res.status(204).end(); + }) + .catch(next), + ); + }, +]; diff --git a/src/routes/projectTypes/delete.spec.js b/src/routes/projectTypes/delete.spec.js new file mode 100644 index 00000000..4d38f666 --- /dev/null +++ b/src/routes/projectTypes/delete.spec.js @@ -0,0 +1,99 @@ +/** + * Tests for delete.js + */ +import request from 'supertest'; + +import models from '../../models'; +import server from '../../app'; +import testUtil from '../../tests/util'; + + +describe('DELETE project type', () => { + const key = 'key1'; + + beforeEach(() => testUtil.clearDb() + .then(() => models.ProjectType.create({ + key: 'key1', + displayName: 'displayName 1', + createdBy: 1, + updatedBy: 1, + })).then(() => Promise.resolve()), + ); + after(testUtil.clearDb); + + describe('DELETE /projectTypes/{key}', () => { + it('should return 403 if user is not authenticated', (done) => { + request(server) + .delete(`/v4/projectTypes/${key}`) + .expect(403, done); + }); + + it('should return 403 for member', (done) => { + request(server) + .delete(`/v4/projectTypes/${key}`) + .set({ + Authorization: `Bearer ${testUtil.jwts.member}`, + }) + .expect(403, done); + }); + + it('should return 403 for copilot', (done) => { + request(server) + .delete(`/v4/projectTypes/${key}`) + .set({ + Authorization: `Bearer ${testUtil.jwts.copilot}`, + }) + .expect(403, done); + }); + + it('should return 403 for manager', (done) => { + request(server) + .delete(`/v4/projectTypes/${key}`) + .set({ + Authorization: `Bearer ${testUtil.jwts.manager}`, + }) + .expect(403, done); + }); + + it('should return 404 for non-existed type', (done) => { + request(server) + .delete('/v4/projectTypes/not_existed') + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .expect(404, done); + }); + + it('should return 404 for deleted type', (done) => { + models.ProjectType.destroy({ where: { key } }) + .then(() => { + request(server) + .delete(`/v4/projectTypes/${key}`) + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .expect(404, done); + }); + }); + + it('should return 204, for admin, if type was successfully removed', (done) => { + request(server) + .delete(`/v4/projectTypes/${key}`) + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .expect(204) + .end(done); + }); + + it('should return 204, for connect admin, if type was successfully removed', (done) => { + request(server) + .delete(`/v4/projectTypes/${key}`) + .set({ + Authorization: `Bearer ${testUtil.jwts.connectAdmin}`, + }) + .expect(204) + .end(done); + }); + }); +}); diff --git a/src/routes/projectTypes/get.js b/src/routes/projectTypes/get.js new file mode 100644 index 00000000..f7eb0b95 --- /dev/null +++ b/src/routes/projectTypes/get.js @@ -0,0 +1,39 @@ +/** + * API to get a project type + */ +import validate from 'express-validation'; +import Joi from 'joi'; +import { middleware as tcMiddleware } from 'tc-core-library-js'; +import util from '../../util'; +import models from '../../models'; + +const permissions = tcMiddleware.permissions; + +const schema = { + params: { + key: Joi.string().max(45).required(), + }, +}; + +module.exports = [ + validate(schema), + permissions('projectType.view'), + (req, res, next) => models.ProjectType.findOne({ + where: { + key: req.params.key, + }, + attributes: { exclude: ['deletedAt', 'deletedBy'] }, + }) + .then((projectType) => { + // Not found + if (!projectType) { + const apiErr = new Error(`Project type not found for key ${req.params.key}`); + apiErr.status = 404; + return Promise.reject(apiErr); + } + + res.json(util.wrapResponse(req.id, projectType)); + return Promise.resolve(); + }) + .catch(next), +]; diff --git a/src/routes/projectTypes/get.spec.js b/src/routes/projectTypes/get.spec.js new file mode 100644 index 00000000..f85e61af --- /dev/null +++ b/src/routes/projectTypes/get.spec.js @@ -0,0 +1,117 @@ +/** + * Tests for get.js + */ +import chai from 'chai'; +import request from 'supertest'; + +import models from '../../models'; +import server from '../../app'; +import testUtil from '../../tests/util'; + +const should = chai.should(); + +describe('GET project type', () => { + const type = { + key: 'key1', + displayName: 'displayName 1', + createdBy: 1, + updatedBy: 1, + }; + + const key = type.key; + + beforeEach(() => testUtil.clearDb() + .then(() => models.ProjectType.create(type)) + .then(() => Promise.resolve()), + ); + after(testUtil.clearDb); + + describe('GET /projectTypes/{key}', () => { + it('should return 403 if user is not authenticated', (done) => { + request(server) + .get(`/v4/projectTypes/${key}`) + .expect(403, done); + }); + + it('should return 404 for non-existed type', (done) => { + request(server) + .get('/v4/projectTypes/1234') + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .expect(404, done); + }); + + it('should return 404 for deleted type', (done) => { + models.ProjectType.destroy({ where: { key } }) + .then(() => { + request(server) + .get(`/v4/projectTypes/${key}`) + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .expect(404, done); + }); + }); + + it('should return 200 for admin', (done) => { + request(server) + .get(`/v4/projectTypes/${key}`) + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .expect(200) + .end((err, res) => { + const resJson = res.body.result.content; + resJson.key.should.be.eql(type.key); + resJson.displayName.should.be.eql(type.displayName); + resJson.createdBy.should.be.eql(type.createdBy); + should.exist(resJson.createdAt); + resJson.updatedBy.should.be.eql(type.updatedBy); + should.exist(resJson.updatedAt); + should.not.exist(resJson.deletedBy); + should.not.exist(resJson.deletedAt); + + done(); + }); + }); + + it('should return 200 for connect admin', (done) => { + request(server) + .get(`/v4/projectTypes/${key}`) + .set({ + Authorization: `Bearer ${testUtil.jwts.connectAdmin}`, + }) + .expect(200) + .end(done); + }); + + it('should return 200 for connect manager', (done) => { + request(server) + .get(`/v4/projectTypes/${key}`) + .set({ + Authorization: `Bearer ${testUtil.jwts.manager}`, + }) + .expect(200) + .end(done); + }); + + it('should return 200 for member', (done) => { + request(server) + .get(`/v4/projectTypes/${key}`) + .set({ + Authorization: `Bearer ${testUtil.jwts.member}`, + }) + .expect(200, done); + }); + + it('should return 200 for copilot', (done) => { + request(server) + .get(`/v4/projectTypes/${key}`) + .set({ + Authorization: `Bearer ${testUtil.jwts.copilot}`, + }) + .expect(200, done); + }); + }); +}); diff --git a/src/routes/projectTypes/list.js b/src/routes/projectTypes/list.js new file mode 100644 index 00000000..56bc2059 --- /dev/null +++ b/src/routes/projectTypes/list.js @@ -0,0 +1,20 @@ +/** + * API to list all project types + */ +import { middleware as tcMiddleware } from 'tc-core-library-js'; +import util from '../../util'; +import models from '../../models'; + +const permissions = tcMiddleware.permissions; + +module.exports = [ + permissions('projectType.view'), + (req, res, next) => models.ProjectType.findAll({ + attributes: { exclude: ['deletedAt', 'deletedBy'] }, + raw: true, + }) + .then((projectTypes) => { + res.json(util.wrapResponse(req.id, projectTypes)); + }) + .catch(next), +]; diff --git a/src/routes/projectTypes/list.spec.js b/src/routes/projectTypes/list.spec.js new file mode 100644 index 00000000..94497692 --- /dev/null +++ b/src/routes/projectTypes/list.spec.js @@ -0,0 +1,106 @@ +/** + * Tests for list.js + */ +import chai from 'chai'; +import request from 'supertest'; + +import models from '../../models'; +import server from '../../app'; +import testUtil from '../../tests/util'; + +const should = chai.should(); + +describe('LIST project types', () => { + const types = [ + { + key: 'key1', + displayName: 'displayName 1', + createdBy: 1, + updatedBy: 1, + }, + { + key: 'key2', + displayName: 'displayName 1', + createdBy: 1, + updatedBy: 1, + }, + ]; + + beforeEach(() => testUtil.clearDb() + .then(() => models.ProjectType.create(types[0])) + .then(() => models.ProjectType.create(types[1])) + .then(() => Promise.resolve()), + ); + after(testUtil.clearDb); + + describe('GET /projectTypes', () => { + it('should return 403 if user is not authenticated', (done) => { + request(server) + .get('/v4/projectTypes') + .expect(403, done); + }); + + it('should return 200 for admin', (done) => { + request(server) + .get('/v4/projectTypes') + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .expect(200) + .end((err, res) => { + const type = types[0]; + + const resJson = res.body.result.content; + resJson.should.have.length(2); + resJson[0].key.should.be.eql(type.key); + resJson[0].displayName.should.be.eql(type.displayName); + resJson[0].createdBy.should.be.eql(type.createdBy); + should.exist(resJson[0].createdAt); + resJson[0].updatedBy.should.be.eql(type.updatedBy); + should.exist(resJson[0].updatedAt); + should.not.exist(resJson[0].deletedBy); + should.not.exist(resJson[0].deletedAt); + + done(); + }); + }); + + it('should return 200 for connect admin', (done) => { + request(server) + .get('/v4/projectTypes') + .set({ + Authorization: `Bearer ${testUtil.jwts.connectAdmin}`, + }) + .expect(200) + .end(done); + }); + + it('should return 200 for connect manager', (done) => { + request(server) + .get('/v4/projectTypes') + .set({ + Authorization: `Bearer ${testUtil.jwts.manager}`, + }) + .expect(200) + .end(done); + }); + + it('should return 200 for member', (done) => { + request(server) + .get('/v4/projectTypes') + .set({ + Authorization: `Bearer ${testUtil.jwts.member}`, + }) + .expect(200, done); + }); + + it('should return 200 for copilot', (done) => { + request(server) + .get('/v4/projectTypes') + .set({ + Authorization: `Bearer ${testUtil.jwts.copilot}`, + }) + .expect(200, done); + }); + }); +}); diff --git a/src/routes/projectTypes/update.js b/src/routes/projectTypes/update.js new file mode 100644 index 00000000..4a0aa26b --- /dev/null +++ b/src/routes/projectTypes/update.js @@ -0,0 +1,61 @@ +/** + * API to update a project type + */ +import validate from 'express-validation'; +import _ from 'lodash'; +import Joi from 'joi'; +import { middleware as tcMiddleware } from 'tc-core-library-js'; +import util from '../../util'; +import models from '../../models'; + +const permissions = tcMiddleware.permissions; + +const schema = { + params: { + key: Joi.string().max(45).required(), + }, + body: { + param: Joi.object().keys({ + key: Joi.any().strip(), + displayName: Joi.string().max(255).required(), + createdAt: Joi.any().strip(), + updatedAt: Joi.any().strip(), + deletedAt: Joi.any().strip(), + createdBy: Joi.any().strip(), + updatedBy: Joi.any().strip(), + deletedBy: Joi.any().strip(), + }).required(), + }, +}; + +module.exports = [ + validate(schema), + permissions('projectType.edit'), + (req, res, next) => { + const entityToUpdate = _.assign(req.body.param, { + updatedBy: req.authUser.userId, + }); + + return models.ProjectType.findOne({ + where: { + key: req.params.key, + }, + attributes: { exclude: ['deletedAt', 'deletedBy'] }, + }) + .then((projectType) => { + // Not found + if (!projectType) { + const apiErr = new Error(`Project type not found for key ${req.params.key}`); + apiErr.status = 404; + return Promise.reject(apiErr); + } + + return projectType.update(entityToUpdate); + }) + .then((projectType) => { + res.json(util.wrapResponse(req.id, projectType)); + return Promise.resolve(); + }) + .catch(next); + }, +]; diff --git a/src/routes/projectTypes/update.spec.js b/src/routes/projectTypes/update.spec.js new file mode 100644 index 00000000..ce3fcc79 --- /dev/null +++ b/src/routes/projectTypes/update.spec.js @@ -0,0 +1,145 @@ +/** + * Tests for get.js + */ +import chai from 'chai'; +import request from 'supertest'; + +import models from '../../models'; +import server from '../../app'; +import testUtil from '../../tests/util'; + +const should = chai.should(); + +describe('UPDATE project type', () => { + const type = { + key: 'key1', + displayName: 'displayName 1', + createdBy: 1, + updatedBy: 1, + }; + const key = type.key; + + beforeEach(() => testUtil.clearDb() + .then(() => models.ProjectType.create(type)) + .then(() => Promise.resolve()), + ); + after(testUtil.clearDb); + + describe('PATCH /projectTypes/{key}', () => { + const body = { + param: { + displayName: 'displayName 1 - update', + }, + }; + + it('should return 403 if user is not authenticated', (done) => { + request(server) + .patch(`/v4/projectTypes/${key}`) + .send(body) + .expect(403, done); + }); + + it('should return 403 for member', (done) => { + request(server) + .patch(`/v4/projectTypes/${key}`) + .set({ + Authorization: `Bearer ${testUtil.jwts.member}`, + }) + .send(body) + .expect(403, done); + }); + + it('should return 403 for copilot', (done) => { + request(server) + .patch(`/v4/projectTypes/${key}`) + .send(body) + .set({ + Authorization: `Bearer ${testUtil.jwts.copilot}`, + }) + .expect(403, done); + }); + + it('should return 403 for manager', (done) => { + request(server) + .patch(`/v4/projectTypes/${key}`) + .send(body) + .set({ + Authorization: `Bearer ${testUtil.jwts.manager}`, + }) + .expect(403, done); + }); + + it('should return 422 for missing displayName', (done) => { + const invalidBody = { + param: { + displayName: null, + }, + }; + + request(server) + .patch(`/v4/projectTypes/${key}`) + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .send(invalidBody) + .expect(422, done); + }); + + it('should return 404 for non-existed type', (done) => { + request(server) + .patch('/v4/projectTypes/1234') + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .send(body) + .expect(404, done); + }); + + it('should return 404 for deleted type', (done) => { + models.ProjectType.destroy({ where: { key } }) + .then(() => { + request(server) + .patch(`/v4/projectTypes/${key}`) + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .send(body) + .expect(404, done); + }); + }); + + it('should return 200 for admin', (done) => { + request(server) + .patch(`/v4/projectTypes/${key}`) + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .send(body) + .expect(200) + .end((err, res) => { + const resJson = res.body.result.content; + resJson.key.should.be.eql(key); + resJson.displayName.should.be.eql(body.param.displayName); + resJson.createdBy.should.be.eql(type.createdBy); + should.exist(resJson.createdAt); + resJson.updatedBy.should.be.eql(40051333); // admin + should.exist(resJson.updatedAt); + should.not.exist(resJson.deletedBy); + should.not.exist(resJson.deletedAt); + + done(); + }); + }); + + it('should return 200 for connect admin', (done) => { + request(server) + .patch(`/v4/projectTypes/${key}`) + .set({ + Authorization: `Bearer ${testUtil.jwts.connectAdmin}`, + }) + .send(body) + .expect(200) + .end(done); + }); + }); +}); diff --git a/src/routes/projectUpgrade/create.js b/src/routes/projectUpgrade/create.js new file mode 100644 index 00000000..df16475b --- /dev/null +++ b/src/routes/projectUpgrade/create.js @@ -0,0 +1,241 @@ +/* eslint-disable no-await-in-loop */ + +/** + * API to upgrade projects + */ +import _ from 'lodash'; +import validate from 'express-validation'; +import Joi from 'joi'; +import { middleware as tcMiddleware } from 'tc-core-library-js'; +import models from '../../models'; +import util from '../../util'; +import { + PROJECT_STATUS, + EVENT, +} from '../../constants'; + +const permissions = tcMiddleware.permissions; + +/** + * Given a completed project id, find the latest completed status' creation date. + * + * @param {number} projectId the project id + * @param {Transaction} [transaction] the transaction + * @returns {Promise} the latest completed status' creation date, or undefined if not found + */ +async function findCompletedProjectEndDate(projectId, transaction) { + const projectHistoryRecord = await models.ProjectHistory.find({ + where: { projectId, status: PROJECT_STATUS.COMPLETED }, + order: [['createdAt', 'DESC']], + attributes: ['createdAt'], + raw: true, + transaction, + }); + return projectHistoryRecord && projectHistoryRecord.createdAt; +} + +/** + * Applies a given template to the destination object by taking data from the source object. + * @param {object} template the template object + * @param {object} source the source object + * @param {object} destination the destination object + * @returns {void} + */ +function applyTemplate(template, source, destination) { + if (!template || typeof template !== 'object') { return; } + Object.keys(template).forEach((key) => { + const templateValue = template[key]; + if (typeof templateValue === 'object') { + // eslint-disable-next-line no-param-reassign + destination[key] = {}; + applyTemplate(templateValue, source[key], destination[key]); + } else if (source && typeof source === 'object') { + // eslint-disable-next-line no-param-reassign + destination[key] = source[key]; + } + }); +} + +/** + * Migrates a given project record from v2 to v3. + * + * @param {express.Request} req the request + * @param {object} project the project record + * @param {number} defaultProductTemplateId the default product template id + * @param {string|undefined} phaseName the phase name (optional) + * @returns {Promise} promise + */ +async function migrateFromV2ToV3(req, project, defaultProductTemplateId, phaseName) { + if (!project.details || !project.details.products || !project.details.products.length) { + throw util.buildApiError(`could not locate product id for project ${project.id}`, 500); + } + /** @type {{ phase: {}, products: {}[] }[]} */ + const newPhasesAndProducts = []; + const previousValue = _.clone(project.get({ plain: true })); + await models.sequelize.transaction(async (transaction) => { + const products = project.details.products; + const projectTemplate = await models.ProjectTemplate.find({ + where: { key: project.type }, + attributes: ['phases'], + raw: true, + transaction, + }); + const phaseKeys = projectTemplate && projectTemplate.phases && Object.keys(projectTemplate.phases); + // eslint-disable-next-line no-restricted-syntax + for (const phaseKey of (phaseKeys || [])) { + const phaseObject = projectTemplate.phases[phaseKey]; + const projectCompleted = project.status === PROJECT_STATUS.COMPLETED; + const endDate = projectCompleted + ? (await findCompletedProjectEndDate(project.id, transaction)) || project.updatedAt + : null; + const projectPhase = await models.ProjectPhase.create({ + projectId: project.id, + // TODO: there should be a clear requirement about how to set the phase's name without relying on its + // products, as they are multiple, and this needs a single value + // setting the name that was on the original phase's object, as is the most promising/obvious way of doing + // this + name: phaseName || phaseObject.name || '', + status: project.status, + startDate: project.createdAt, + endDate, + budget: project.details && project.details.appDefinition && project.details.appDefinition.budget, + progress: projectCompleted ? 100 : 0, + details: null, + createdBy: req.authUser.userId, + updatedBy: req.authUser.userId, + }, { transaction }); + const phaseAndProducts = { + phase: projectPhase, + products: [], + }; + newPhasesAndProducts.push(phaseAndProducts); + // eslint-disable-next-line no-restricted-syntax + for (const phaseProduct of (phaseObject.products || [])) { + const useDefaultProductTemplateId = products.indexOf(phaseProduct.productKey) === -1; + let query; + if (useDefaultProductTemplateId) { + // default strategy is to use the passed default product template id + query = { id: defaultProductTemplateId }; + } else { + query = { productKey: phaseProduct.productKey }; + } + const productTemplate = await models.ProductTemplate.find({ + where: query, + attributes: ['id', 'name', 'productKey', 'template'], + raw: true, + transaction, + }); + if (!productTemplate) { + throw util.buildApiError(`could not locate product template for project ${project.id}`, 500); + } + let detailsObject; + if (productTemplate.template) { + detailsObject = {}; + applyTemplate(productTemplate.template, project.details, detailsObject); + } + phaseAndProducts.products.push( + await models.PhaseProduct.create({ + phaseId: projectPhase.id, + projectId: project.id, + templateId: productTemplate.id, + directProjectId: project.directProjectId, + billingAccountId: project.billingAccountId, + name: productTemplate.name, + type: productTemplate.productKey, + estimatedPrice: project.estimatedPrice, + actualPrice: project.actualPrice, + details: detailsObject, + createdBy: req.authUser.userId, + updatedBy: req.authUser.userId, + }, { transaction })); + } + } + await project.update({ version: 'v3' }, { transaction }); + }); + newPhasesAndProducts.forEach(({ phase, products }) => { + // Send events to buses (ProjectPhase) + req.log.debug('Sending event to RabbitMQ bus for project phase %d', phase.id); + req.app.services.pubsub.publish(EVENT.ROUTING_KEY.PROJECT_PHASE_ADDED, + phase, + { correlationId: req.id }, + ); + req.log.debug('Sending event to Kafka bus for project phase %d', phase.id); + req.app.emit(EVENT.ROUTING_KEY.PROJECT_PHASE_ADDED, { req, created: phase }); + + products.forEach((newPhaseProduct) => { + // Send events to buses (PhaseProduct) + req.log.debug('Sending event to RabbitMQ bus for phase product %d', newPhaseProduct.id); + req.app.services.pubsub.publish(EVENT.ROUTING_KEY.PROJECT_PHASE_PRODUCT_ADDED, + newPhaseProduct, + { correlationId: req.id }, + ); + req.log.debug('Sending event to Kafka bus for phase product %d', newPhaseProduct.id); + req.app.emit(EVENT.ROUTING_KEY.PROJECT_PHASE_PRODUCT_ADDED, { req, created: newPhaseProduct }); + }); + }); + + // Send events to buses (Project) + req.log.debug('updated project', project); + + // publish original and updated project data + req.app.services.pubsub.publish( + EVENT.ROUTING_KEY.PROJECT_UPDATED, { + original: previousValue, + updated: project, + }, { + correlationId: req.id, + }, + ); + req.app.emit(EVENT.ROUTING_KEY.PROJECT_UPDATED, { + req, + original: previousValue, + updated: project, + }); +} + +const allowedMigrations = { + v3: { + v2: migrateFromV2ToV3, + }, +}; + +const schema = { + body: { + param: Joi.object().keys({ + targetVersion: Joi.string().valid(Object.keys(allowedMigrations)).required(), + defaultProductTemplateId: Joi.number().integer().positive().required(), + phaseName: Joi.string(), + }).required(), + }, + options: { + status: 400, + }, +}; + +module.exports = [ + validate(schema), + permissions('project.admin'), + async (req, res, next) => { + try { + const projectId = Number(req.params.projectId); + const targetVersion = req.body.param.targetVersion; + const targetVersionMigrationData = allowedMigrations[targetVersion]; + const project = await models.Project.find({ where: { id: projectId } }); + if (!project) { + // returning 404 + throw util.buildApiError(`project not found for id ${projectId}`, 404); + } + const handler = targetVersionMigrationData[project.version]; + if (!handler) { + // returning 400 + throw util.buildApiError(`current project version ${project.version} is not supported to be upgraded to ${ + targetVersion}`, 400); + } + // we have a valid project to be migrated + await handler(req, project, req.body.param.defaultProductTemplateId, req.body.param.phaseName); + res.status(200).json(util.wrapResponse(req.id, { message: 'Project successfully migrated' })); + } catch (err) { + next(err); + } + }, +]; diff --git a/src/routes/projectUpgrade/create.spec.js b/src/routes/projectUpgrade/create.spec.js new file mode 100644 index 00000000..d8401b08 --- /dev/null +++ b/src/routes/projectUpgrade/create.spec.js @@ -0,0 +1,354 @@ +/* eslint-disable no-unused-expressions, no-await-in-loop, no-restricted-syntax */ + +import { expect } from 'chai'; +import sinon from 'sinon'; +import request from 'supertest'; +import server from '../../app'; +import { PROJECT_STATUS } from '../../constants'; +import models from '../../models'; +import testUtil from '../../tests/util'; +import RabbitMQService from '../../services/rabbitmq'; + +describe('Project upgrade', () => { + describe('POST /projects/:id/upgrade', () => { + // v2 by default + let project; + let projectTemplate; + let defaultProductTemplate; + let matchingProductTemplate; + let validBody; + + beforeEach(async () => { + // mocks + await testUtil.clearDb(); + const productId = 'application_development'; + project = await models.Project.create({ + type: 'generic', + billingAccountId: 1, + name: 'test1', + description: 'test project1', + icon: 'http://example.com/icon1.ico', + question: 'question 1', + info: 'info 1', + aliases: [], + status: 'draft', + details: { + name: 'a specific name', + products: [productId], + appDefinition: { budget: 10000 }, + sampleKey1: { + sampleSubKey1: 'a specific value', + }, + sampleKey2: { + sampleSubKey2: 'a specific value', + }, + }, + createdBy: 1, + updatedBy: 1, + version: 'v2', + directProjectId: 123, + estimatedPrice: 15000, + actualPrice: 18000, + }); + projectTemplate = await models.ProjectTemplate.create({ + name: 'template 1', + key: project.type, + category: 'category 1', + icon: 'http://example.com/icon1.ico', + question: 'question 1', + info: 'info 1', + aliases: [], + scope: { + scope1: { + subScope1A: 1, + subScope1B: 2, + }, + scope2: [1, 2, 3], + }, + phases: { + // for all tests, use a project template that maps to a product template by productKey + phase1: { + name: 'phase 1', + products: [{ + productKey: productId, + }], + details: { + anyDetails: 'any details 1', + }, + others: ['others 11', 'others 12'], + }, + phase2: { + name: 'phase 2', + products: [{ + productKey: productId, + }], + details: { + anyDetails: 'any details 2', + }, + others: ['others 21', 'others 22'], + }, + }, + createdBy: 1, + updatedBy: 1, + }); + [defaultProductTemplate, matchingProductTemplate] = await Promise.all([ + {}, + { productKey: productId }, + ].map(specific => models.ProductTemplate.create(Object.assign({ + name: 'name 1', + productKey: 'a product key', + icon: 'http://example.com/icon1.ico', + brief: 'brief 1', + details: 'details 1', + aliases: { + alias1: { + subAlias1A: 1, + subAlias1B: 2, + }, + alias2: [1, 2, 3], + }, + template: { + name: 'a template name', + sampleKey1: { + sampleSubKey1: 'a value', + }, + sampleKey2: { + sampleSubKey2: 'a value', + }, + }, + createdBy: 1, + updatedBy: 2, + }, specific)))); + validBody = { + param: { + targetVersion: 'v3', + defaultProductTemplateId: defaultProductTemplate.id, + }, + }; + sinon.stub(RabbitMQService.prototype, 'init', () => {}); + sinon.stub(RabbitMQService.prototype, 'publish', () => {}); + }); + + afterEach(async () => { + RabbitMQService.prototype.init.restore(); + RabbitMQService.prototype.publish.restore(); + await testUtil.clearDb(); + }); + + it('should return 403 if user is not authenticated', async () => { + await request(server) + .post(`/v4/projects/${project.id}/upgrade`) + .send(validBody) + .expect(403); + }); + + it('should return 403 for non admin', async () => { + await request(server) + .post(`/v4/projects/${project.id}/upgrade`) + .set({ + Authorization: `Bearer ${testUtil.jwts.member}`, + }) + .send(validBody) + .expect(403); + }); + + it('should return 500 when a project doesn\'t have a valid product id', async () => { + // since the product id is extracted from 'details.products', clearing that should trigger this error + await project.update({ details: {} }); + await request(server) + .post(`/v4/projects/${project.id}/upgrade`) + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .send(validBody) + .expect(500); + }); + + it('should return 500 when a product template couldn\'t be found by productKey', async () => { + // by changing this we cause no matching product template to be found + await matchingProductTemplate.update({ productKey: 'non matching product key' }); + await request(server) + .post(`/v4/projects/${project.id}/upgrade`) + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .send(validBody) + .expect(500); + }); + + it('should return 500 when a product template couldn\'t be found by defaultProductTemplateId', async () => { + // by changing this the default product template id will be used + await projectTemplate.update({ phases: { nonMatchingPhase1: { products: ['non existing product'] } } }); + // and we simulate a non existing one + validBody.param.defaultProductTemplateId += 1000; + await request(server) + .post(`/v4/projects/${project.id}/upgrade`) + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .send(validBody) + .expect(500); + }); + + it('should return 400 if the project was already migrated', async () => { + // simulate an already migrated project + await project.update({ version: 'v3' }); + await request(server) + .post(`/v4/projects/${project.id}/upgrade`) + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .send(validBody) + .expect(400); + }); + + it('should return 400 if there\'s no migration handler for the sent target version', async () => { + validBody.param.targetVersion = 'v4'; + await request(server) + .post(`/v4/projects/${project.id}/upgrade`) + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .send(validBody) + .expect(400); + }); + + it('should return 404 if the project does not exist', async () => { + // simulate an already migrated project + await project.update({ version: 'v3' }); + await request(server) + .post(`/v4/projects/${project.id + 1}/upgrade`) + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .send(validBody) + .expect(404); + }); + + [true, false].forEach((useDefault) => { + describe(useDefault ? 'when using the default product template id' : + 'when using the matching product template by productKey', () => { + let productTemplate; + + beforeEach(async () => { + productTemplate = matchingProductTemplate; + if (useDefault) { + // by changing this the default product template id will be used + await projectTemplate.update({ + phases: { + nonMatchingPhase1: { name: 'phase 1', products: ['non_existing'] }, + nonMatchingPhase2: { name: 'phase 2', products: ['non_existing'] }, + }, + }); + productTemplate = defaultProductTemplate; + } + }); + + const commonTest = async (testCompleted, completedOnDate, additionalPhaseName) => { + const migratedProject = await models.Project.find({ id: project.id }); + expect(migratedProject.version).to.equal('v3'); + const newProjectPhases = await models.ProjectPhase.findAll({ + where: { projectId: project.id }, + }); + for (const newProjectPhase of newProjectPhases) { + expect(newProjectPhase).to.exist; + expect(newProjectPhase.name).to.be.oneOf(['phase 1', 'phase 2'].concat(additionalPhaseName || [])); + expect(newProjectPhase.status).to.equal(project.status); + expect(newProjectPhase.startDate).to.deep.equal(project.createdAt); + expect(newProjectPhase.budget).to.equal(project.details.appDefinition.budget); + expect(newProjectPhase.details).to.equal(null); + if (testCompleted) { + expect(newProjectPhase.status).to.equal(PROJECT_STATUS.COMPLETED); + expect(newProjectPhase.progress).to.equal(100); + expect(newProjectPhase.endDate).to.deep.equal(completedOnDate); + } else { + expect(newProjectPhase.progress).to.equal(0); + expect(newProjectPhase.endDate).to.equal(null); + } + const newPhaseProducts = await models.PhaseProduct.findAll({ where: + { phaseId: newProjectPhase.id }, + }); + for (const newPhaseProduct of newPhaseProducts) { + expect(newPhaseProduct).to.exist; + expect(newPhaseProduct.projectId).to.equal(project.id); + expect(newPhaseProduct.templateId).to.equal(productTemplate.id); + expect(newPhaseProduct.directProjectId).to.equal(project.directProjectId); + expect(newPhaseProduct.billingAccountId).to.equal(project.billingAccountId); + expect(newPhaseProduct.name).to.equal(productTemplate.name); + expect(newPhaseProduct.type).to.equal(productTemplate.productKey); + expect(newPhaseProduct.estimatedPrice).to.equal(parseInt(project.estimatedPrice, 10)); + expect(newPhaseProduct.actualPrice).to.equal(parseInt(project.actualPrice, 10)); + expect(newPhaseProduct.details).to.deep.equal({ + name: 'a specific name', + sampleKey1: { + sampleSubKey1: 'a specific value', + }, + sampleKey2: { + sampleSubKey2: 'a specific value', + }, + }); + } + } + + expect(server.services.pubsub.publish.calledWith('project.phase.added')).to.be.true; + expect(server.services.pubsub.publish.calledWith('project.phase.product.added')).to.be.true; + expect(server.services.pubsub.publish.calledWith('project.updated')).to.be.true; + }; + + it('should migrate a non completed project to the expected state', async () => { + await request(server) + .post(`/v4/projects/${project.id}/upgrade`) + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .send(validBody) + .expect(200); + await commonTest(); + }); + + it('should migrate a completed project to the expected state', async () => { + await project.update({ status: PROJECT_STATUS.COMPLETED }); + const millisInADay = 1000 * 60 * 60 * 24; + const dbNow = Math.floor(Date.now() / 1000) * 1000; + + // simulate multiple completed statuses so we can test the set endDate is the latest + await models.ProjectHistory.create({ + projectId: project.id, + status: PROJECT_STATUS.COMPLETED, + updatedBy: 1, + // 10 days ago + createdAt: new Date(dbNow - (millisInADay * 10)), + }); + const yesterday = new Date(dbNow - millisInADay); + await models.ProjectHistory.create({ + projectId: project.id, + status: PROJECT_STATUS.COMPLETED, + updatedBy: 1, + // yesterday + createdAt: yesterday, + }); + await request(server) + .post(`/v4/projects/${project.id}/upgrade`) + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .send(validBody) + .expect(200); + await commonTest(true, yesterday); + }); + + it('should migrate a project and assign the phase name passed in the parameters', async () => { + validBody.param.phaseName = 'A custom phase name'; + await request(server) + .post(`/v4/projects/${project.id}/upgrade`) + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .send(validBody) + .expect(200); + await commonTest(false, null, 'A custom phase name'); + }); + }); + }); + }); +}); diff --git a/src/routes/projects/create.js b/src/routes/projects/create.js index 393ffbf6..da3e9e5d 100644 --- a/src/routes/projects/create.js +++ b/src/routes/projects/create.js @@ -3,9 +3,10 @@ import validate from 'express-validation'; import _ from 'lodash'; import Joi from 'joi'; +import config from 'config'; import models from '../../models'; -import { PROJECT_TYPE, PROJECT_MEMBER_ROLE, PROJECT_STATUS, USER_ROLE, EVENT, REGEX } from '../../constants'; +import { PROJECT_MEMBER_ROLE, PROJECT_STATUS, PROJECT_PHASE_STATUS, USER_ROLE, EVENT, REGEX } from '../../constants'; import util from '../../util'; import directProject from '../../services/directProject'; @@ -44,18 +45,159 @@ const createProjectValdiations = { type: Joi.any().valid('github', 'jira', 'asana', 'other'), data: Joi.string().max(300), // TODO - restrict length }).allow(null), - // TODO - add more types - type: Joi.any().valid(_.values(PROJECT_TYPE)).required(), + type: Joi.string().max(45).required(), details: Joi.any(), challengeEligibility: Joi.array().items(Joi.object().keys({ role: Joi.string().valid('submitter', 'reviewer', 'copilot'), users: Joi.array().items(Joi.number().positive()), groups: Joi.array().items(Joi.number().positive()), })).allow(null), + templateId: Joi.number().integer().positive(), + version: Joi.string(), }).required(), }, }; +/** + * Create the project, project phases and products. This needs to be done before creating direct project. + * @param {Object} req the request + * @param {Object} project the project + * @param {Object} projectTemplate the project template + * @param {Array} productTemplates array of the templates of the products used in the projec template + * @returns {Promise} the promise that resolves to the created project and phases + */ +function createProjectAndPhases(req, project, projectTemplate, productTemplates) { + const result = { + newProject: null, + newPhases: [], + }; + + // Create project + return models.Project.create(project, { + include: [{ + model: models.ProjectMember, + as: 'members', + }], + }).then((newProject) => { + result.newProject = newProject; + + // backward compatibility for releasing the service before releasing the front end + if (!projectTemplate) { + return Promise.resolve(result); + } + const phases = _.values(projectTemplate.phases); + const productTemplateMap = {}; + productTemplates.forEach((pt) => { + productTemplateMap[pt.id] = pt; + }); + return Promise.all(_.map(phases, (phase, phaseIdx) => + // Create phase + models.ProjectPhase.create({ + projectId: newProject.id, + name: _.get(phase, 'name', `Stage ${phaseIdx}`), + duration: _.get(phase, 'duration', 0), + status: _.get(phase, 'status', PROJECT_PHASE_STATUS.DRAFT), + budget: _.get(phase, 'budget', 0), + updatedBy: req.authUser.userId, + createdBy: req.authUser.userId, + }).then((newPhase) => { + req.log.debug(`Creating products in the newly created phase ${newPhase.id}`); + // Create products + return models.PhaseProduct.bulkCreate(_.map(phase.products, (product, productIndex) => ({ + phaseId: newPhase.id, + projectId: newProject.id, + estimatedPrice: _.get(product, 'estimatedPrice', 0), + name: _.get(product, 'name', _.get(productTemplateMap, `${product.id}.name`, `Product ${productIndex}`)), + // assumes that phase template always contains id of each product + templateId: parseInt(product.id, 10), + updatedBy: req.authUser.userId, + createdBy: req.authUser.userId, + })), { returning: true }) + .then((products) => { + // Add phases and products to the project JSON, so they can be stored to ES later + const newPhaseJson = _.omit(newPhase.toJSON(), ['deletedAt', 'deletedBy']); + newPhaseJson.products = _.map(products, product => + _.omit(product.toJSON(), ['deletedAt', 'deletedBy'])); + result.newPhases.push(newPhaseJson); + return Promise.resolve(); + }); + }), + )); + }).then(() => Promise.resolve(result)); +} + +/** + * Validates the project and product templates for the give project template id. + * + * @param {Integer} templateId id of the project template which should be validated + * @returns {Promise} the promise that resolves to an object containing validated project and product templates + */ +function validateAndFetchTemplates(templateId) { + // backward compatibility for releasing the service before releasing the front end + // we ignore missing template id field and create a project without phase/products + if (!templateId) return Promise.resolve({}); + return models.ProjectTemplate.findById(templateId, { raw: true }) + .then((existingProjectTemplate) => { + if (!existingProjectTemplate) { + // Not found + const apiErr = new Error(`Project template not found for id ${templateId}`); + apiErr.status = 422; + return Promise.reject(apiErr); + } + return Promise.resolve(existingProjectTemplate); + }) + .then((projectTemplate) => { + const phases = _.values(projectTemplate.phases); + const productPromises = []; + phases.forEach((phase) => { + // Make sure number of products of per phase <= max value + const productCount = _.isArray(phase.products) ? phase.products.length : 0; + if (productCount > config.maxPhaseProductCount) { + const apiErr = new Error(`Number of products per phase cannot exceed ${config.maxPhaseProductCount}`); + apiErr.status = 422; + throw apiErr; + } + _.map(phase.products, (product) => { + productPromises.push(models.ProductTemplate.findById(product.id) + .then((productTemplate) => { + if (!productTemplate) { + // Not found + const apiErr = new Error(`Product template not found for id ${product.id}`); + apiErr.status = 422; + return Promise.reject(apiErr); + } + return Promise.resolve(productTemplate); + })); + }); + }); + if (productPromises.length > 0) { + return Promise.all(productPromises).then(productTemplates => ({ projectTemplate, productTemplates })); + } + // if there is no phase or product in a phase is specified, return empty product templates + return Promise.resolve({ projectTemplate, productTemplates: [] }); + }); +} + +/** + * Validates the project type being one from the allowed ones. + * + * @param {String} type key of the project type to be used + * @returns {Promise} promise which resolves to a project type if it is valid, rejects otherwise with 422 error + */ +function validateProjectType(type) { + return models.ProjectType.findOne({ where: { key: type } }) + .then((projectType) => { + if (!projectType) { + // Not found + const apiErr = new Error(`Project type not found for key ${type}`); + apiErr.status = 422; + return Promise.reject(apiErr); + } + + return Promise.resolve(projectType); + }); +} + module.exports = [ // handles request validations validate(createProjectValdiations), @@ -68,8 +210,8 @@ module.exports = [ const project = req.body.param; // by default connect admin and managers joins projects as manager const userRole = util.hasRoles(req, [USER_ROLE.CONNECT_ADMIN, USER_ROLE.MANAGER]) - ? PROJECT_MEMBER_ROLE.MANAGER - : PROJECT_MEMBER_ROLE.CUSTOMER; + ? PROJECT_MEMBER_ROLE.MANAGER + : PROJECT_MEMBER_ROLE.CUSTOMER; // set defaults _.defaults(project, { description: '', @@ -80,7 +222,7 @@ module.exports = [ external: null, utm: null, }); - traverse(project).forEach(function (x) { + traverse(project).forEach(function (x) { // eslint-disable-line func-names if (this.isLeaf && typeof x === 'string') this.update(req.sanitize(x)); }); // override values @@ -96,71 +238,86 @@ module.exports = [ createdBy: req.authUser.userId, }], }); + // backward compatibility for releasing the service before releasing the front end + if (!project.templateId) { + project.version = 'v2'; + } models.sequelize.transaction(() => { let newProject = null; - return models.Project - .create(project, { - include: [{ - model: models.ProjectMember, - as: 'members', - }], - }) - .then((_newProject) => { - newProject = _newProject; - req.log.debug('new project created (id# %d, name: %s)', - newProject.id, newProject.name); - // create direct project with name and description - const body = { - projectName: newProject.name, - projectDescription: newProject.description, - }; - // billingAccountId is optional field - if (newProject.billingAccountId) { - body.billingAccountId = newProject.billingAccountId; - } - req.log.debug('creating project history for project %d', newProject.id); - // add to project history - models.ProjectHistory.create({ - projectId: _newProject.id, - status: PROJECT_STATUS.DRAFT, - cancelReason: null, - updatedBy: req.authUser.userId, - }).then(() => req.log.debug('project history created for project %d', newProject.id)) - .catch(() => req.log.error('project history failed for project %d', newProject.id)); - req.log.debug('creating direct project for project %d', newProject.id); - return directProject.createDirectProject(req, body) - .then((resp) => { - newProject.directProjectId = resp.data.result.content.projectId; - return newProject.save(); - }) - .then(() => newProject.reload(newProject.id)) - .catch((err) => { - // log the error and continue - req.log.error('Error creating direct project'); - req.log.error(err); - return Promise.resolve(); - }); - // return Promise.resolve(); - }) - .then(() => { - newProject = newProject.get({ plain: true }); - // remove utm details & deletedAt field - newProject = _.omit(newProject, ['deletedAt', 'utm']); - // add an empty attachments array - newProject.attachments = []; - req.log.debug('Sending event to RabbitMQ bus for project %d', newProject.id); - req.app.services.pubsub.publish(EVENT.ROUTING_KEY.PROJECT_DRAFT_CREATED, - newProject, - { correlationId: req.id }, - ); - req.log.debug('Sending event to Kafka bus for project %d', newProject.id); - // emit event - req.app.emit(EVENT.ROUTING_KEY.PROJECT_DRAFT_CREATED, { req, project: newProject }); - res.status(201).json(util.wrapResponse(req.id, newProject, 1, 201)); + let newPhases; + // Validate the project type + return validateProjectType(project.type) + // Validate the templates + .then((projectType) => { + req.log.debug(`Project type ${projectType.key} validated successfully`); + return validateAndFetchTemplates(project.templateId); + }) + // Create project and phases + .then(({ projectTemplate, productTemplates }) => { + req.log.debug('Creating project, phase and products'); + return createProjectAndPhases(req, project, projectTemplate, productTemplates); + }) + .then((createdProjectAndPhases) => { + newProject = createdProjectAndPhases.newProject; + newPhases = createdProjectAndPhases.newPhases; + + req.log.debug('new project created (id# %d, name: %s)', newProject.id, newProject.name); + // create direct project with name and description + const body = { + projectName: newProject.name, + projectDescription: newProject.description, + }; + // billingAccountId is optional field + if (newProject.billingAccountId) { + body.billingAccountId = newProject.billingAccountId; + } + req.log.debug('creating project history for project %d', newProject.id); + // add to project history asynchronously, don't wait for it to complete + models.ProjectHistory.create({ + projectId: newProject.id, + status: PROJECT_STATUS.DRAFT, + cancelReason: null, + updatedBy: req.authUser.userId, + }).then(() => req.log.debug('project history created for project %d', newProject.id)) + .catch(() => req.log.error('project history failed for project %d', newProject.id)); + req.log.debug('creating direct project for project %d', newProject.id); + return directProject.createDirectProject(req, body) + .then((resp) => { + newProject.directProjectId = resp.data.result.content.projectId; + return newProject.save(); }) + .then(() => newProject.reload(newProject.id)) .catch((err) => { - util.handleError('Error creating project', err, req, next); + // log the error and continue + req.log.error('Error creating direct project'); + req.log.error(err); + return Promise.resolve(); }); + // return Promise.resolve(); + }) + .then(() => { + newProject = newProject.get({ plain: true }); + // remove utm details & deletedAt field + newProject = _.omit(newProject, ['deletedAt', 'utm']); + // add an empty attachments array + newProject.attachments = []; + // set phases array + newProject.phases = newPhases; + + req.log.debug('Sending event to RabbitMQ bus for project %d', newProject.id); + req.app.services.pubsub.publish(EVENT.ROUTING_KEY.PROJECT_DRAFT_CREATED, + newProject, + { correlationId: req.id }, + ); + req.log.debug('Sending event to Kafka bus for project %d', newProject.id); + // emit event + req.app.emit(EVENT.ROUTING_KEY.PROJECT_DRAFT_CREATED, { req, project: newProject }); + res.status(201).json(util.wrapResponse(req.id, newProject, 1, 201)); + }) + .catch((err) => { + req.log.error(err.message); + util.handleError('Error creating project', err, req, next); + }); }); }, ]; diff --git a/src/routes/projects/create.spec.js b/src/routes/projects/create.spec.js index 9e9cb0dc..b365f58d 100644 --- a/src/routes/projects/create.spec.js +++ b/src/routes/projects/create.spec.js @@ -8,18 +8,147 @@ import util from '../../util'; import server from '../../app'; import testUtil from '../../tests/util'; import RabbitMQService from '../../services/rabbitmq'; +import models from '../../models'; const should = chai.should(); - -sinon.stub(RabbitMQService.prototype, 'init', () => {}); -sinon.stub(RabbitMQService.prototype, 'publish', () => {}); +const expect = chai.expect; describe('Project create', () => { before((done) => { - testUtil.clearDb(done); + sinon.stub(RabbitMQService.prototype, 'init', () => {}); + sinon.stub(RabbitMQService.prototype, 'publish', () => {}); + testUtil.clearDb() + .then(() => models.ProjectType.bulkCreate([ + { + key: 'generic', + displayName: 'Generic', + createdBy: 1, + updatedBy: 1, + }, + ])) + .then(() => models.ProductTemplate.bulkCreate([ + { + id: 21, + name: 'template 1', + productKey: 'productKey-1', + icon: 'http://example.com/icon2.ico', + brief: 'brief 1', + details: 'details 1', + aliases: {}, + template: {}, + createdBy: 3, + updatedBy: 4, + }, + { + id: 22, + name: 'template 2', + productKey: 'productKey-2', + icon: 'http://example.com/icon2.ico', + brief: 'brief 2', + details: 'details 2', + aliases: {}, + template: {}, + createdBy: 3, + updatedBy: 4, + }, + { + id: 23, + name: 'template 3', + productKey: 'productKey-3', + icon: 'http://example.com/icon3.ico', + brief: 'brief 3', + details: 'details 3', + aliases: {}, + template: {}, + createdBy: 3, + updatedBy: 4, + }, + ])) + .then(() => models.ProjectTemplate.bulkCreate([ + { + id: 1, + name: 'template 1', + key: 'key 1', + category: 'category 1', + icon: 'http://example.com/icon1.ico', + question: 'question 1', + info: 'info 1', + aliases: [], + scope: {}, + phases: { + phase1: { + name: 'phase 1', + products: [ + { + id: 21, + name: 'product 1', + productKey: 'visual_design_prod1', + }, + { + id: 22, + name: 'product 2', + productKey: 'visual_design_prod2', + }, + ], + }, + }, + createdBy: 1, + updatedBy: 1, + }, + { + id: 3, + name: 'template 3', + key: 'key 3', + category: 'category 3', + icon: 'http://example.com/icon3.ico', + question: 'question 3', + info: 'info 3', + aliases: [], + scope: {}, + phases: { + 1: { + name: 'Design Stage', + status: 'open', + details: { + description: 'detailed description', + }, + products: [ + { + id: 21, + name: 'product 1', + productKey: 'visual_design_prod', + }, + ], + }, + 2: { + name: 'Development Stage', + status: 'open', + products: [ + { + id: 23, + name: 'product 2', + details: { + subDetails: 'subDetails 2', + }, + productKey: 'website_development', + }, + ], + }, + 3: { + name: 'QA Stage', + status: 'open', + }, + }, + createdBy: 1, + updatedBy: 2, + }, + ])) + .then(() => done()); }); after((done) => { + RabbitMQService.prototype.init.restore(); + RabbitMQService.prototype.publish.restore(); testUtil.clearDb(done); }); @@ -66,7 +195,61 @@ describe('Project create', () => { .expect(422, done); }); + it('should return 422 if project type is missing', (done) => { + const invalidBody = _.cloneDeep(body); + invalidBody.param.type = null; + request(server) + .post('/v4/projects') + .set({ + Authorization: `Bearer ${testUtil.jwts.member}`, + }) + .send(invalidBody) + .expect('Content-Type', /json/) + .expect(422, done); + }); + + it('should return 422 if project type does not exist', (done) => { + const invalidBody = _.cloneDeep(body); + invalidBody.param.type = 'not_exist'; + request(server) + .post('/v4/projects') + .set({ + Authorization: `Bearer ${testUtil.jwts.member}`, + }) + .send(invalidBody) + .expect('Content-Type', /json/) + .expect(422, done); + }); + + it('should return 422 if templateId does not exist', (done) => { + const invalidBody = _.cloneDeep(body); + invalidBody.param.templateId = 3000; + request(server) + .post('/v4/projects') + .set({ + Authorization: `Bearer ${testUtil.jwts.member}`, + }) + .send(invalidBody) + .expect('Content-Type', /json/) + .expect(422, done); + }); + + it('should return 422 if phaseProduct count exceeds max value', (done) => { + const invalidBody = _.cloneDeep(body); + invalidBody.param.templateId = 1; + 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; const mockHttpClient = _.merge(testUtil.mockHttpClient, { post: () => Promise.reject(new Error('error message')), }); @@ -76,7 +259,7 @@ describe('Project create', () => { .set({ Authorization: `Bearer ${testUtil.jwts.member}`, }) - .send(body) + .send(validBody) .expect('Content-Type', /json/) .expect(201) .end((err, res) => { @@ -93,6 +276,8 @@ describe('Project create', () => { }); it('should return 201 if valid user and data', (done) => { + const validBody = _.cloneDeep(body); + validBody.param.templateId = 3; const mockHttpClient = _.merge(testUtil.mockHttpClient, { post: () => Promise.resolve({ status: 200, @@ -115,7 +300,114 @@ describe('Project create', () => { .set({ Authorization: `Bearer ${testUtil.jwts.member}`, }) - .send(body) + .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'); + server.services.pubsub.publish.calledWith('project.draft-created').should.be.true; + done(); + } + }); + }); + + it('should return 201 if valid user and data (without template id: backward compatibility)', (done) => { + const validBody = _.cloneDeep(body); + 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('v2'); + 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'); + server.services.pubsub.publish.calledWith('project.draft-created').should.be.true; + // should not create phases without a template id + resJson.phases.should.have.lengthOf(0); + done(); + } + }); + }); + + it('should return 201 if valid user and data (with templateId)', (done) => { + const mockHttpClient = _.merge(testUtil.mockHttpClient, { + post: () => Promise.resolve({ + status: 200, + data: { + id: 'requesterId', + version: 'v3', + result: { + success: true, + status: 200, + content: { + projectId: 128, + }, + }, + }, + }), + }); + sandbox.stub(util, 'getHttpClient', () => mockHttpClient); + request(server) + .post('/v4/projects') + .set({ + Authorization: `Bearer ${testUtil.jwts.member}`, + }) + .send(_.merge({ param: { templateId: 3 } }, body)) .expect('Content-Type', /json/) .expect(201) .end((err, res) => { @@ -137,6 +429,14 @@ describe('Project create', () => { resJson.bookmarks.should.have.lengthOf(1); resJson.bookmarks[0].title.should.be.eql('title1'); resJson.bookmarks[0].address.should.be.eql('http://www.address.com'); + resJson.phases.should.have.lengthOf(3); + const phases = _.sortBy(resJson.phases, p => p.name); + phases[0].name.should.be.eql('Design Stage'); + phases[0].status.should.be.eql('open'); + expect(phases[0].details).to.be.empty; + phases[0].products.should.have.lengthOf(1); + phases[0].products[0].name.should.be.eql('product 1'); + phases[0].products[0].templateId.should.be.eql(21); server.services.pubsub.publish.calledWith('project.draft-created').should.be.true; done(); } diff --git a/src/routes/projects/list.js b/src/routes/projects/list.js index f1cd2c9a..b0b31169 100755 --- a/src/routes/projects/list.js +++ b/src/routes/projects/list.js @@ -29,6 +29,14 @@ const PROJECT_ATTACHMENT_ATTRIBUTES = _.without( _.keys(models.ProjectAttachment.rawAttributes), 'deletedAt', ); +const PROJECT_PHASE_ATTRIBUTES = _.without( + _.keys(models.ProjectPhase.rawAttributes), + 'deletedAt', +); +const PROJECT_PHASE_PRODUCTS_ATTRIBUTES = _.without( + _.keys(models.PhaseProduct.rawAttributes), + 'deletedAt', +); const escapeEsKeyword = keyword => keyword.replace(/[+-=> { const memberFields = _.get(fields, 'project_members'); sourceInclude = sourceInclude.concat(_.map(memberFields, single => `members.${single}`)); } + if (_.get(fields, 'project_phases', null)) { + const phaseFields = _.get(fields, 'project_phases'); + sourceInclude = sourceInclude.concat(_.map(phaseFields, single => `phases.${single}`)); + } + if (_.get(fields, 'project_phases_products', null)) { + const phaseFields = _.get(fields, 'project_phases_products'); + sourceInclude = sourceInclude.concat(_.map(phaseFields, single => `phases.products.${single}`)); + } sourceInclude = sourceInclude.concat(_.map(PROJECT_ATTACHMENT_ATTRIBUTES, single => `attachments.${single}`)); if (sourceInclude) { @@ -163,6 +179,8 @@ const retrieveProjects = (req, criteria, sort, ffields) => { fields = util.parseFields(fields, { projects: PROJECT_ATTRIBUTES, project_members: PROJECT_MEMBER_ATTRIBUTES, + project_phases: PROJECT_PHASE_ATTRIBUTES, + project_phases_products: PROJECT_PHASE_PRODUCTS_ATTRIBUTES, }); // make sure project.id is part of fields if (_.indexOf(fields.projects, 'id') < 0) { diff --git a/src/routes/projects/update.js b/src/routes/projects/update.js index b02fc55d..fc17b451 100644 --- a/src/routes/projects/update.js +++ b/src/routes/projects/update.js @@ -6,7 +6,6 @@ import { } from 'tc-core-library-js'; import models from '../../models'; import { - PROJECT_TYPE, PROJECT_STATUS, PROJECT_MEMBER_ROLE, EVENT, @@ -60,9 +59,10 @@ const updateProjectValdiations = { title: Joi.string(), address: Joi.string().regex(REGEX.URL), })).optional().allow(null), - type: Joi.any().valid(_.values(PROJECT_TYPE)), + type: Joi.string().max(45), details: Joi.any(), memers: Joi.any(), + templateId: Joi.any().strip(), // ignore the template id createdBy: Joi.any(), createdAt: Joi.any(), updatedBy: Joi.any(), @@ -91,15 +91,15 @@ const validateUpdates = (existingProject, updatedProps, req) => { break; default: break; - // disabling this check for now. - // case PROJECT_STATUS.DRAFT: - // if (_.get(updatedProject, 'status', '') === 'active') { - // // attempting to launch the project make sure certain - // // properties are set - // if (!updatedProject.billingAccountId && !existingProject.billingAccountId) { - // errors.push('\'billingAccountId\' must be set before activating the project') - // } - // } + // disabling this check for now. + // case PROJECT_STATUS.DRAFT: + // if (_.get(updatedProject, 'status', '') === 'active') { + // // attempting to launch the project make sure certain + // // properties are set + // if (!updatedProject.billingAccountId && !existingProject.billingAccountId) { + // errors.push('\'billingAccountId\' must be set before activating the project') + // } + // } } if (_.has(updatedProps, 'directProjectId') && !util.hasRoles(req, [USER_ROLE.MANAGER, USER_ROLE.TOPCODER_ADMIN])) { @@ -113,6 +113,25 @@ module.exports = [ // handles request validations validate(updateProjectValdiations), permissions('project.edit'), + /** + * Validate project type to be existed. + */ + (req, res, next) => { + if (req.body.param.type) { + models.ProjectType.findOne({ where: { key: req.body.param.type } }) + .then((projectType) => { + if (projectType) { + next(); + } else { + const err = new Error(`Project type not found for key ${req.body.param.type}`); + err.status = 422; + next(err); + } + }); + } else { + next(); + } + }, /** * POST projects/ * Create a project if the user has access @@ -123,7 +142,7 @@ module.exports = [ const projectId = _.parseInt(req.params.projectId); // prune any fields that cannot be updated directly updatedProps = _.omit(updatedProps, ['createdBy', 'createdAt', 'updatedBy', 'updatedAt', 'id']); - traverse(updatedProps).forEach(function (x) { + traverse(updatedProps).forEach(function (x) { // eslint-disable-line func-names if (x && this.isLeaf && typeof x === 'string') this.update(req.sanitize(x)); }); let previousValue; diff --git a/src/routes/projects/update.spec.js b/src/routes/projects/update.spec.js index 520d93db..4671e0c7 100644 --- a/src/routes/projects/update.spec.js +++ b/src/routes/projects/update.spec.js @@ -19,7 +19,16 @@ describe('Project', () => { let project2; let project3; beforeEach((done) => { - testUtil.clearDb(done); + testUtil.clearDb() + .then(() => models.ProjectType.bulkCreate([ + { + key: 'generic', + displayName: 'Generic', + createdBy: 1, + updatedBy: 1, + }, + ])) + .then(() => done()); }); after((done) => { @@ -29,6 +38,7 @@ describe('Project', () => { const body = { param: { name: 'updatedProject name', + type: 'generic', }, }; let sandbox; @@ -319,6 +329,22 @@ describe('Project', () => { }); }); + it('should return 422 if project type does not exist', (done) => { + const mbody = { + param: { + type: 'not_exist', + }, + }; + request(server) + .patch(`/v4/projects/${project1.id}`) + .set({ + Authorization: `Bearer ${testUtil.jwts.copilot}`, + }) + .send(mbody) + .expect('Content-Type', /json/) + .expect(422, done); + }); + it('should return 200 and project history should be updated for cancelled project', (done) => { const mbody = { param: { diff --git a/src/services/busApi.js b/src/services/busApi.js index c6edba48..3962dd0f 100644 --- a/src/services/busApi.js +++ b/src/services/busApi.js @@ -47,35 +47,33 @@ function createEvent(type, message, logger) { logger.debug(`Sending message: ${JSON.stringify(message)}`); return getClient().then((busClient) => { logger.debug('calling bus-api'); - busClient.post('/bus/events', { + return busClient.post('/bus/events', { type, message: body, - }) - .then((resp) => { - logger.debug('Sent event to bus-api'); - logger.debug(`Sent event to bus-api [data]: ${resp.data}`); - logger.debug(`Sent event to bus-api [status]: ${resp.status}`); - }) - .catch((error) => { - logger.debug('Error sending event to bus-api'); - if (error.response) { - // The request was made and the server responded with a status code - // that falls out of the range of 2xx - logger.debug(error.response.data); - logger.debug(error.response.status); - logger.debug(error.response.headers); - } else if (error.request) { - // The request was made but no response was received - // `error.request` is an instance of XMLHttpRequest in the browser and an instance of - // http.ClientRequest in node.js - logger.debug(error.request); - } else { - // Something happened in setting up the request that triggered an Error - logger.debug(error.message); - } - logger.debug(error.config); - Promise.resolve(); // eslint-disable-line - }); + }).then((resp) => { + logger.debug('Sent event to bus-api'); + logger.debug(`Sent event to bus-api [data]: ${resp.data}`); + logger.debug(`Sent event to bus-api [status]: ${resp.status}`); + }).catch((error) => { + logger.debug('Error sending event to bus-api'); + if (error.response) { + // The request was made and the server responded with a status code + // that falls out of the range of 2xx + logger.debug(error.response.data); + logger.debug(error.response.status); + logger.debug(error.response.headers); + } else if (error.request) { + // The request was made but no response was received + // `error.request` is an instance of XMLHttpRequest in the browser and an instance of + // http.ClientRequest in node.js + logger.debug(error.request); + } else { + // Something happened in setting up the request that triggered an Error + logger.debug(error.message); + } + logger.debug(error.config); + Promise.resolve(); // eslint-disable-line + }); }).catch((errMessage) => { logger.debug(errMessage); }); diff --git a/src/services/messageService.js b/src/services/messageService.js new file mode 100644 index 00000000..d7e0f678 --- /dev/null +++ b/src/services/messageService.js @@ -0,0 +1,159 @@ +import config from 'config'; +import _ from 'lodash'; +// import util from '../util'; + +const Promise = require('bluebird'); +const axios = require('axios'); +const tcCoreLibAuth = require('tc-core-library-js').auth; + +const m2m = tcCoreLibAuth.m2m(config); + +let client = null; + +/** + * Get Http client to bus api + * @param {Object} logger object + * @return {Object} Http Client to bus api + */ +async function getClient(logger) { + if (client) return client; + const msgApiUrl = config.get('messageApiUrl'); + try { + const token = await m2m.getMachineToken(config.AUTH0_CLIENT_ID, config.AUTH0_CLIENT_SECRET); + client = axios.create({ baseURL: msgApiUrl }); + + // Alter defaults after instance has been created + client.defaults.headers.common.Authorization = `Bearer ${token}`; + + // Add a response interceptor + client.interceptors.response.use(function (res) { // eslint-disable-line + return res; + }, function (error) { // eslint-disable-line + if (error.response) { + // The request was made and the server responded with a status code + // that falls out of the range of 2xx + logger.debug(error.response.data); + logger.debug(error.response.status); + logger.debug(error.response.headers); + } else if (error.request) { + // The request was made but no response was received + // `error.request` is an instance of XMLHttpRequest in the browser and an instance of + // http.ClientRequest in node.js + logger.debug(error.request); + } else { + // Something happened in setting up the request that triggered an Error + logger.debug(error.message); + } + logger.debug(error.config); + // Ingore response errors + return Promise.reject(error); + }); + + return client; + } catch (err) { + return Promise.reject(`Message api calling - Error in genearting m2m token : ${err.message}`); + } +} + +/** + * Creates a new topic in message api + * + * @param {Object} topic the topic, should be a JSON object + * @param {Object} logger object + * @return {Promise} new topic promise + */ +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}`); + logger.debug(`Topic created successfully [data]: ${resp.data}`); + return _.get(resp.data, 'result.content', {}); + }) + .catch((error) => { + logger.debug('Error creating topic'); + logger.error(error); + // eslint-disable-line + }); + }).catch((errMessage) => { + logger.debug(errMessage); + }); +} + +/** + * Deletes the given posts for the given topic. + * + * @param {Integer} topicId id of the topic + * @param {Array} postIds array of post ids to be deleted, array of integers + * @param {Object} logger object + * @return {Promise} delete posts promise + */ +function deletePosts(topicId, postIds, logger) { + logger.debug(`deletePosts for topicId: ${topicId} and postIds: ${postIds}`); + const promises = []; + if (postIds && postIds.length > 0) { + postIds.forEach((postId) => { + promises.push(getClient(logger).then((msgClient) => { + logger.debug(`calling message service for deleting post#${postId}`); + return msgClient.delete(`/topics/${topicId}/posts/${postId}/remove`); + })); + }); + } + if (promises.length > 0) { + return Promise.all(promises).then(() => logger.debug(`All posts deleted for topic ${topicId}`)); + } + return Promise.resolve(); +} + +/** + * Fetches the topic of given phase of the project. + * + * @param {Integer} projectId id of the project + * @param {Integer} phaseId id of the phase of the project + * @param {Object} logger object + * @return {Promise} topic promise + */ +function getPhaseTopic(projectId, phaseId, logger) { + logger.debug(`getPhaseTopic for phaseId: ${phaseId}`); + return getClient(logger).then((msgClient) => { + logger.debug(`calling message service for fetching phaseId#${phaseId}`); + return msgClient.get('/topics/list', { + params: { filter: `reference=project&referenceId=${projectId}&tag=phase#${phaseId}` }, + }).then((resp) => { + const topics = _.get(resp.data, 'result.content', []); + if (topics && topics.length > 0) { + return topics[0]; + } + return null; + }); + }); +} + +/** + * Deletes the given topic. + * + * @param {Integer} topicId id of the topic + * @param {Object} logger object + * @return {Promise} delete topic promise + */ +function deleteTopic(topicId, logger) { + logger.debug(`deleteTopic for topicId: ${topicId}`); + return getClient(logger).then((msgClient) => { + logger.debug(`calling message service for deleting topic#${topicId}`); + return msgClient.delete(`/topics/${topicId}/remove`); + }); +} + +module.exports = { + createTopic, + deletePosts, + getPhaseTopic, + deleteTopic, +}; diff --git a/src/tests/seed.js b/src/tests/seed.js index a1f53c84..350480c4 100644 --- a/src/tests/seed.js +++ b/src/tests/seed.js @@ -1,108 +1,493 @@ import models from '../models'; models.sequelize.sync({ force: true }) - .then(() => - models.Project.bulkCreate([{ - type: 'generic', - directProjectId: 9999999, - billingAccountId: 1, - name: 'test1', - description: 'test project1', - status: 'active', - details: {}, - createdBy: 1, - updatedBy: 1, - }, { - type: 'visual_design', - directProjectId: 1, - billingAccountId: 2, - name: 'test2', - description: 'test project2', - status: 'draft', - details: {}, - createdBy: 1, - updatedBy: 1, - }, { - type: 'visual_design', - billingAccountId: 3, - name: 'test2', - description: 'completed project without copilot', - status: 'completed', - details: {}, - createdBy: 1, - updatedBy: 1, - }, { - type: 'generic', - billingAccountId: 4, - name: 'test2', - description: 'draft project without copilot', - status: 'draft', - details: {}, - createdBy: 1, - updatedBy: 1, - }, { - type: 'generic', - billingAccountId: 5, - name: 'test2', - description: 'active project without copilot', - status: 'active', - details: {}, - createdBy: 1, - updatedBy: 1, - }])) - .then(() => models.Project.findAll()) - .then((projects) => { - const project1 = projects[0]; - const project2 = projects[1]; - const operations = []; - operations.push(models.ProjectMember.bulkCreate([{ - userId: 40051331, - projectId: project1.id, - role: 'customer', - isPrimary: false, - createdBy: 1, - updatedBy: 1, - }, { - userId: 40051332, - projectId: project1.id, - role: 'copilot', - isPrimary: false, - createdBy: 1, - updatedBy: 1, - }, { - userId: 40051333, - projectId: project1.id, - role: 'manager', - isPrimary: true, - createdBy: 1, - updatedBy: 1, - }, { - userId: 40051332, - projectId: project2.id, - role: 'copilot', - isPrimary: false, - createdBy: 1, - updatedBy: 1, - }, { - userId: 40051331, - projectId: projects[2].id, - role: 'customer', - isPrimary: false, - createdBy: 1, - updatedBy: 1, - }])); - operations.push(models.ProjectAttachment.create({ - title: 'Spec', - projectId: project1.id, - description: 'specification', - filePath: 'projects/1/spec.pdf', - contentType: 'application/pdf', - createdBy: 1, - updatedBy: 1, - })); - return Promise.all(operations); - }) - .then(() => { - process.exit(0); - }) - .catch(() => process.exit(1)); + .then(() => + models.Project.bulkCreate([{ + type: 'generic', + directProjectId: 9999999, + billingAccountId: 1, + name: 'test1', + description: 'test project1', + status: 'active', + details: {}, + createdBy: 1, + updatedBy: 1, + }, { + type: 'visual_design', + directProjectId: 1, + billingAccountId: 2, + name: 'test2', + description: 'test project2', + status: 'draft', + details: {}, + createdBy: 1, + updatedBy: 1, + }, { + type: 'visual_design', + billingAccountId: 3, + name: 'test2', + description: 'completed project without copilot', + status: 'completed', + details: {}, + createdBy: 1, + updatedBy: 1, + }, { + type: 'generic', + billingAccountId: 4, + name: 'test2', + description: 'draft project without copilot', + status: 'draft', + details: {}, + createdBy: 1, + updatedBy: 1, + }, { + type: 'generic', + billingAccountId: 5, + name: 'test2', + description: 'active project without copilot', + status: 'active', + details: {}, + createdBy: 1, + updatedBy: 1, + }, { + type: 'generic', + billingAccountId: 5, + name: 'test2', + description: 'Ongoing project', + status: 'active', + details: { + name: 'a specific name', + products: ['application_development', 'website_development'], + appDefinition: { budget: 10000 }, + sampleKey1: { + sampleSubKey1: 'a specific value', + }, + sampleKey2: { + sampleSubKey2: 'a specific value', + }, + }, + createdBy: 1, + updatedBy: 1, + version: 'v2', + directProjectId: 123, + estimatedPrice: 15000, + actualPrice: 18000, + }, { + type: 'generic', + billingAccountId: 5, + name: 'test2', + description: 'Completed project', + status: 'completed', + details: { + name: 'a specific name', + products: ['application_development', 'website_development'], + appDefinition: { budget: 10000 }, + sampleKey1: { + sampleSubKey1: 'a specific value', + }, + sampleKey2: { + sampleSubKey2: 'a specific value', + }, + }, + createdBy: 1, + updatedBy: 1, + version: 'v2', + directProjectId: 123, + estimatedPrice: 15000, + actualPrice: 18000, + }])) + .then(() => models.Project.findAll()) + .then((projects) => { + const project1 = projects[0]; + const project2 = projects[1]; + const project7 = projects[6]; + const operations = []; + operations.push(models.ProjectMember.bulkCreate([{ + userId: 40051331, + projectId: project1.id, + role: 'customer', + isPrimary: false, + createdBy: 1, + updatedBy: 1, + }, { + userId: 40051332, + projectId: project1.id, + role: 'copilot', + isPrimary: false, + createdBy: 1, + updatedBy: 1, + }, { + userId: 40051333, + projectId: project1.id, + role: 'manager', + isPrimary: true, + createdBy: 1, + updatedBy: 1, + }, { + userId: 40051332, + projectId: project2.id, + role: 'copilot', + isPrimary: false, + createdBy: 1, + updatedBy: 1, + }, { + userId: 40051331, + projectId: projects[2].id, + role: 'customer', + isPrimary: false, + createdBy: 1, + updatedBy: 1, + }])); + operations.push(models.ProjectAttachment.create({ + title: 'Spec', + projectId: project1.id, + description: 'specification', + filePath: 'projects/1/spec.pdf', + contentType: 'application/pdf', + createdBy: 1, + updatedBy: 1, + })); + const dbNow = Math.floor(Date.now() / 1000) * 1000; + const millisInADay = 1000 * 60 * 60 * 24; + const yesterday = new Date(dbNow - millisInADay); + operations.push(models.ProjectHistory.bulkCreate([{ + projectId: project7.id, + status: 'completed', + createdAt: yesterday, + createdBy: 1, + updatedBy: 1, + }, { + projectId: project7.id, + status: 'completed', + // 10 days ago + createdAt: new Date(dbNow - (millisInADay * 10)), + createdBy: 1, + updatedBy: 1, + }])); + return Promise.all(operations); + }) + .then(() => models.ProjectTemplate.bulkCreate([ + { + name: 'template 1', + key: 'key 1', + category: 'category 1', + icon: 'http://example.com/icon1.ico', + question: 'question 1', + info: 'info 1', + aliases: [], + scope: { + scope1: { + subScope1A: 1, + subScope1B: 2, + }, + scope2: [1, 2, 3], + }, + phases: { + phase1: { + name: 'phase 1', + details: { + anyDetails: 'any details 1', + }, + others: ['others 11', 'others 12'], + }, + phase2: { + name: 'phase 2', + details: { + anyDetails: 'any details 2', + }, + others: ['others 21', 'others 22'], + }, + }, + createdBy: 1, + updatedBy: 1, + }, + { + name: 'template 2', + key: 'key 2', + category: 'category 2', + icon: 'http://example.com/icon1.ico', + info: 'info 2', + aliases: [], + scope: {}, + phases: {}, + question: 'question 2', + createdBy: 1, + updatedBy: 2, + }, + { + name: 'template 3', + key: 'key 3', + category: 'category 3', + icon: 'http://example.com/icon3.ico', + question: 'question 3', + info: 'info 3', + aliases: [], + scope: {}, + phases: { + 1: { + name: 'Design Stage', + status: 'open', + details: { + description: 'detailed description', + }, + products: [ + { + id: 21, + name: 'product 1', + productKey: 'visual_design_prod', + }, + ], + }, + 2: { + name: 'Development Stage', + status: 'open', + products: [ + { + id: 23, + name: 'product 2', + details: { + subDetails: 'subDetails 2', + }, + productKey: 'website_development', + }, + ], + }, + 3: { + name: 'QA Stage', + status: 'open', + }, + }, + createdBy: 1, + updatedBy: 2, + }, + { + name: 'template 1', + key: 'generic', + category: 'category 1', + icon: 'http://example.com/icon1.ico', + question: 'question 1', + info: 'info 1', + aliases: [], + scope: { + scope1: { + subScope1A: 1, + subScope1B: 2, + }, + scope2: [1, 2, 3], + }, + phases: { + // for all tests, use a project template that maps to a product template by productKey + phase1: { + name: 'phase 1', + products: [{ + productKey: 'application_development', + }, { + productKey: 'product_key_2', + }], + details: { + anyDetails: 'any details 1', + }, + others: ['others 11', 'others 12'], + }, + phase2: { + name: 'phase 2', + products: [{ + productKey: 'website_development', + }, { + productKey: 'product_key_4', + }], + details: { + anyDetails: 'any details 2', + }, + others: ['others 21', 'others 22'], + }, + }, + createdBy: 1, + updatedBy: 1, + }, + ])) + .then(() => models.ProductTemplate.bulkCreate([ + { + name: 'name 1', + productKey: 'productKey 1', + icon: 'http://example.com/icon1.ico', + question: 'question 1', + info: 'info 1', + brief: 'brief 1', + details: 'details 1', + aliases: { + alias1: { + subAlias1A: 1, + subAlias1B: 2, + }, + alias2: [1, 2, 3], + }, + template: { + template1: { + name: 'template 1', + details: { + anyDetails: 'any details 1', + }, + others: ['others 11', 'others 12'], + }, + template2: { + name: 'template 2', + details: { + anyDetails: 'any details 2', + }, + others: ['others 21', 'others 22'], + }, + }, + createdBy: 1, + updatedBy: 2, + }, + { + name: 'template 2', + productKey: 'productKey 2', + icon: 'http://example.com/icon1.ico', + question: 'question 1', + info: 'info 1', + brief: 'brief 2', + details: 'details 2', + aliases: [], + template: {}, + createdBy: 3, + updatedBy: 4, + }, + { + name: 'Generic work', + productKey: 'generic_work', + icon: 'http://example.com/icon1.ico', + question: 'question 1', + info: 'info 1', + brief: 'brief 1', + details: 'details 1', + aliases: { + alias1: { + subAlias1A: 1, + subAlias1B: 2, + }, + alias2: [1, 2, 3], + }, + template: { + name: 'a template name', + sampleKey1: { + sampleSubKey1: 'a value', + }, + sampleKey2: { + sampleSubKey2: 'a value', + }, + }, + createdBy: 1, + updatedBy: 2, + }, + { + name: 'Website product', + productKey: 'website_development', + icon: 'http://example.com/icon1.ico', + question: 'question 1', + info: 'info 1', + brief: 'brief 1', + details: 'details 1', + aliases: { + alias1: { + subAlias1A: 1, + subAlias1B: 2, + }, + alias2: [1, 2, 3], + }, + template: { + name: 'a template name', + sampleKey1: { + sampleSubKey1: 'a value', + }, + sampleKey2: { + sampleSubKey2: 'a value', + }, + }, + createdBy: 1, + updatedBy: 2, + }, + { + name: 'Application product', + productKey: 'application_development', + icon: 'http://example.com/icon1.ico', + brief: 'brief 1', + details: 'details 1', + aliases: { + alias1: { + subAlias1A: 1, + subAlias1B: 2, + }, + alias2: [1, 2, 3], + }, + template: { + name: 'a template name', + sampleKey1: { + sampleSubKey1: 'a value', + }, + sampleKey2: { + sampleSubKey2: 'a value', + }, + }, + createdBy: 1, + updatedBy: 2, + }, + ])) + .then(() => models.ProjectType.bulkCreate([ + { + key: 'app_dev', + displayName: 'Application development', + createdBy: 1, + updatedBy: 2, + }, + { + key: 'generic', + displayName: 'Generic', + createdBy: 1, + updatedBy: 2, + }, + { + key: 'visual_prototype', + displayName: 'Visual Prototype', + createdBy: 1, + updatedBy: 2, + }, + { + key: 'visual_design', + displayName: 'Visual Design', + createdBy: 1, + updatedBy: 2, + }, + { + key: 'website', + displayName: 'Website', + createdBy: 1, + updatedBy: 2, + }, + { + key: 'app', + displayName: 'Application', + createdBy: 1, + updatedBy: 2, + }, + { + key: 'quality_assurance', + displayName: 'Quality Assurance', + createdBy: 1, + updatedBy: 2, + }, + { + key: 'chatbot', + displayName: 'Chatbot', + createdBy: 1, + updatedBy: 2, + }, + ])) + .then(() => { + process.exit(0); + }) + .catch((err) => { + console.log(err); // eslint-disable-line no-console + process.exit(1); + }); diff --git a/src/tests/util.js b/src/tests/util.js index ded1ff9f..f3dff595 100644 --- a/src/tests/util.js +++ b/src/tests/util.js @@ -2,6 +2,8 @@ import models from '../models'; +const jwt = require('jsonwebtoken'); + export default { clearDb: done => models.sequelize.sync({ force: true }) .then(() => { @@ -25,4 +27,5 @@ export default { // userId = 40051336, [ 'Connect Admin' ], handle: 'connect_admin1', email: 'connect_admin1@topcoder.com' connectAdmin: 'eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJyb2xlcyI6WyJDb25uZWN0IEFkbWluIl0sImlzcyI6Imh0dHBzOi8vYXBpLnRvcGNvZGVyLWRldi5jb20iLCJoYW5kbGUiOiJjb25uZWN0X2FkbWluMSIsImV4cCI6MjU2MzA3NjY4OSwidXNlcklkIjoiNDAwNTEzMzYiLCJpYXQiOjE0NjMwNzYwODksImVtYWlsIjoiY29ubmVjdF9hZG1pbjFAdG9wY29kZXIuY29tIiwianRpIjoiYjMzYjc3Y2QtYjUyZS00MGZlLTgzN2UtYmViOGUwYWU2YTRhIn0.nSGfXMl02NZ90ZKLiEKPg75iAjU92mfteaY6xgqkM30', }, + getDecodedToken: token => jwt.decode(token), }; diff --git a/src/util.js b/src/util.js index 0afbdfb0..add05eb6 100644 --- a/src/util.js +++ b/src/util.js @@ -28,6 +28,17 @@ const util = _.cloneDeep(require('tc-core-library-js').util(config)); let esClient = null; _.assignIn(util, { + /** + * Build API error + * @param {string} message the API error message + * @param {number} status the API status code + * @returns {Error} the built API error + */ + buildApiError: (message, status) => { + const apiErr = new Error(message); + apiErr.status = status || 500; + return apiErr; + }, /** * Handle error * @param {String} msg the default error message @@ -227,30 +238,30 @@ _.assignIn(util, { getProjectAttachments: (req, projectId) => { let attachments = []; return models.ProjectAttachment.getActiveProjectAttachments(projectId) - .then((_attachments) => { - // if attachments were requested - if (attachments) { - attachments = _attachments; - } else { - return attachments; - } - // TODO consider using redis to cache attachments urls - const promises = []; - _.each(attachments, (a) => { - promises.push(util.getFileDownloadUrl(req, a.filePath)); - }); - return Promise.all(promises); - }) - .then((result) => { - // result is an array of 'tuples' => [[path, url], [path,url]] - // convert it to a map for easy lookup - const urls = _.fromPairs(result); - _.each(attachments, (at) => { - const a = at; - a.downloadUrl = urls[a.filePath]; - }); + .then((_attachments) => { + // if attachments were requested + if (attachments) { + attachments = _attachments; + } else { return attachments; + } + // TODO consider using redis to cache attachments urls + const promises = []; + _.each(attachments, (a) => { + promises.push(util.getFileDownloadUrl(req, a.filePath)); + }); + return Promise.all(promises); + }) + .then((result) => { + // result is an array of 'tuples' => [[path, url], [path,url]] + // convert it to a map for easy lookup + const urls = _.fromPairs(result); + _.each(attachments, (at) => { + const a = at; + a.downloadUrl = urls[a.filePath]; }); + return attachments; + }); }, getSystemUserToken: (logger, id = 'system') => { @@ -263,19 +274,19 @@ _.assignIn(util, { timeout: 4000, headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, }, - ) + ) .then(res => res.data.result.content.token); }, - /** - * Fetches the topcoder user details using the given JWT token. - * - * @param {Number} userId id of the user to be fetched - * @param {String} jwtToken JWT token of the admin user or JWT token of the user to be fecthed - * @param {Object} logger logger to be used for logging purposes - * - * @return {Promise} promise which resolves to the user's information - */ + /** + * Fetches the topcoder user details using the given JWT token. + * + * @param {Number} userId id of the user to be fetched + * @param {String} jwtToken JWT token of the admin user or JWT token of the user to be fecthed + * @param {Object} logger logger to be used for logging purposes + * + * @return {Promise} promise which resolves to the user's information + */ getTopcoderUser: (userId, jwtToken, logger) => { const httpClient = util.getHttpClient({ id: `userService_${userId}`, log: logger }); httpClient.defaults.timeout = 3000; @@ -284,7 +295,7 @@ _.assignIn(util, { httpClient.defaults.headers.common.Authorization = `Bearer ${jwtToken}`; return httpClient.get(`${config.identityServiceEndpoint}users/${userId}`).then((response) => { if (response.data && response.data.result - && response.data.result.status === 200 && response.data.result.content) { + && response.data.result.status === 200 && response.data.result.content) { return response.data.result.content; } return null; @@ -356,6 +367,20 @@ _.assignIn(util, { return Promise.reject(err); } }), + + /** + * Merge two JSON objects. For array fields, the target will be replaced by source. + * @param {Object} targetObj the target object + * @param {Object} sourceObj the source object + * @returns {Object} the merged object + */ + // eslint-disable-next-line consistent-return + mergeJsonObjects: (targetObj, sourceObj) => _.mergeWith(targetObj, sourceObj, (target, source) => { + // Overwrite the array + if (_.isArray(source)) { + return source; + } + }), }); export default util; diff --git a/swagger.yaml b/swagger.yaml index f195828f..39bad0d3 100755 --- a/swagger.yaml +++ b/swagger.yaml @@ -1,4 +1,3 @@ ---- swagger: "2.0" info: version: "v4" @@ -336,41 +335,1142 @@ paths: schema: $ref: "#/definitions/UpdateProjectMemberBodyParam" + /projects/{projectId}/phases: + parameters: + - $ref: "#/parameters/projectIdParam" + get: + tags: + - phase + operationId: findProjectPhases + security: + - Bearer: [] + description: Retreive all project phases. All users who can edit project can access this endpoint. + parameters: + - name: fields + required: false + type: string + in: query + description: | + Comma separated list of project phase fields to return. + - name: sort + required: false + description: | + sort project phases by startDate, endDate, status. Default is startDate asc + in: query + type: string + responses: + '403': + description: No permission or wrong token + schema: + $ref: "#/definitions/ErrorModel" + '200': + description: A list of project phases + schema: + $ref: "#/definitions/ProjectPhaseListResponse" + post: + tags: + - phase + operationId: addProjectPhase + security: + - Bearer: [] + description: Create a project phase + parameters: + - in: body + name: body + required: true + schema: + $ref: '#/definitions/ProjectPhaseBodyParam' + responses: + '403': + description: No permission or wrong token + schema: + $ref: "#/definitions/ErrorModel" + '201': + description: Returns the newly created project phase + schema: + $ref: "#/definitions/ProjectPhaseResponse" + '422': + description: Invalid input + schema: + $ref: "#/definitions/ErrorModel" + + /projects/{projectId}/phases/{phaseId}: + parameters: + - $ref: "#/parameters/projectIdParam" + - $ref: "#/parameters/phaseIdParam" + get: + tags: + - phase + description: Retrieve project phase by id. All users who can edit project can access this endpoint. + security: + - Bearer: [] + responses: + '404': + description: Not found + schema: + $ref: "#/definitions/ErrorModel" + '403': + description: No permission or wrong token + schema: + $ref: "#/definitions/ErrorModel" + '200': + description: a project phase + schema: + $ref: "#/definitions/ProjectPhaseResponse" + parameters: + - $ref: "#/parameters/phaseIdParam" + operationId: getProjectPhase + + patch: + tags: + - phase + operationId: updateProjectPhase + security: + - Bearer: [] + description: Update a project phase. All users who can edit project can access this endpoint. + responses: + '403': + description: No permission or wrong token + schema: + $ref: "#/definitions/ErrorModel" + '404': + description: Not found + schema: + $ref: "#/definitions/ErrorModel" + '200': + description: Successfully updated project phase. + schema: + $ref: "#/definitions/ProjectPhaseResponse" + '422': + description: Invalid input + schema: + $ref: "#/definitions/ErrorModel" + default: + description: error payload + schema: + $ref: '#/definitions/ErrorModel' + parameters: + - $ref: "#/parameters/phaseIdParam" + - name: body + in: body + required: true + schema: + $ref: "#/definitions/ProjectPhaseBodyParam" + + delete: + tags: + - phase + description: Remove an existing project phase. All users who can edit project can access this endpoint. + security: + - Bearer: [] + parameters: + - $ref: "#/parameters/phaseIdParam" + responses: + '403': + description: No permission or wrong token + schema: + $ref: "#/definitions/ErrorModel" + '404': + description: If project is not found + schema: + $ref: "#/definitions/ErrorModel" + '204': + description: Project phase successfully removed + + + + /projects/{projectId}/phases/{phaseId}/products: + parameters: + - $ref: "#/parameters/projectIdParam" + - $ref: "#/parameters/phaseIdParam" + get: + tags: + - phase product + operationId: findPhaseProducts + security: + - Bearer: [] + description: Retreive all phase products. All users who can edit project can access this endpoint. + responses: + '403': + description: No permission or wrong token + schema: + $ref: "#/definitions/ErrorModel" + '200': + description: A list of phase products + schema: + $ref: "#/definitions/PhaseProductListResponse" + post: + tags: + - phase product + operationId: addPhaseProduct + security: + - Bearer: [] + description: Create a phase product + parameters: + - in: body + name: body + required: true + schema: + $ref: '#/definitions/PhaseProductBodyParam' + responses: + '403': + description: No permission or wrong token + schema: + $ref: "#/definitions/ErrorModel" + '201': + description: Returns the newly created phase product + schema: + $ref: "#/definitions/PhaseProductResponse" + '422': + description: Invalid input + schema: + $ref: "#/definitions/ErrorModel" + + /projects/{projectId}/phases/{phaseId}/products/{productId}: + parameters: + - $ref: "#/parameters/projectIdParam" + - $ref: "#/parameters/phaseIdParam" + - $ref: "#/parameters/productIdParam" + get: + tags: + - phase product + description: Retrieve phase product by id. All users who can edit project can access this endpoint. + security: + - Bearer: [] + responses: + '404': + description: Not found + schema: + $ref: "#/definitions/ErrorModel" + '403': + description: No permission or wrong token + schema: + $ref: "#/definitions/ErrorModel" + '200': + description: a phase product + schema: + $ref: "#/definitions/PhaseProductResponse" + parameters: + - $ref: "#/parameters/phaseIdParam" + operationId: getPhaseProduct + + patch: + tags: + - phase product + operationId: updatePhaseProduct + security: + - Bearer: [] + description: Update a phase product. All users who can edit project can access this endpoint. + responses: + '403': + description: No permission or wrong token + schema: + $ref: "#/definitions/ErrorModel" + '404': + description: Not found + schema: + $ref: "#/definitions/ErrorModel" + '200': + description: Successfully updated phase product. + schema: + $ref: "#/definitions/PhaseProductResponse" + '422': + description: Invalid input + schema: + $ref: "#/definitions/ErrorModel" + default: + description: error payload + schema: + $ref: '#/definitions/ErrorModel' + parameters: + - $ref: "#/parameters/phaseIdParam" + - name: body + in: body + required: true + schema: + $ref: "#/definitions/PhaseProductBodyParam" + + delete: + tags: + - phase product + description: Remove an existing phase product. All users who can edit project can access this endpoint. + security: + - Bearer: [] + parameters: + - $ref: "#/parameters/phaseIdParam" + responses: + '403': + description: No permission or wrong token + schema: + $ref: "#/definitions/ErrorModel" + '404': + description: If project is not found + schema: + $ref: "#/definitions/ErrorModel" + '204': + description: Project phase successfully removed + + /projects/{projectId}/upgrade: + post: + tags: + - project + operationId: upgradeProject + security: + - Bearer: [] + description: Migrates a project to a target version. Only users with "administrator" or "Connect admin" roles can access to this endpoint + parameters: + - $ref: "#/parameters/projectIdParam" + - name: body + in: body + required: true + description: Project upgrade body + schema: + $ref: "#/definitions/ProjectUpgradeBodyParam" + responses: + '400': + description: Invalid input + schema: + $ref: "#/definitions/ErrorModel" + '403': + description: No permission or wrong token + schema: + $ref: "#/definitions/ErrorModel" + '404': + description: Project not found + schema: + $ref: "#/definitions/ErrorModel" + '500': + description: Invalid server state or unknown error + schema: + $ref: "#/definitions/ErrorModel" + '200': + description: Project migrated successfully + schema: + $ref: "#/definitions/ProjectUpgradeResponse" + + /projectTemplates: + get: + tags: + - projectTemplate + operationId: findProjectTemplates + security: + - Bearer: [] + description: Retreive all project templates. All user roles can access this endpoint. + responses: + '403': + description: No permission or wrong token + schema: + $ref: "#/definitions/ErrorModel" + '200': + description: A list of project templates + schema: + $ref: "#/definitions/ProjectTemplateListResponse" + post: + tags: + - projectTemplate + operationId: addProjectTemplate + security: + - Bearer: [] + description: Create a project template + parameters: + - in: body + name: body + required: true + schema: + $ref: '#/definitions/ProjectTemplateBodyParam' + responses: + '403': + description: No permission or wrong token + schema: + $ref: "#/definitions/ErrorModel" + '201': + description: Returns the newly created project template + schema: + $ref: "#/definitions/ProjectTemplateResponse" + '422': + description: Invalid input + schema: + $ref: "#/definitions/ErrorModel" + + /projectTemplates/{templateId}: + get: + tags: + - projectTemplate + description: Retrieve project template by id. All user roles can access this endpoint. + security: + - Bearer: [] + responses: + '404': + description: Not found + schema: + $ref: "#/definitions/ErrorModel" + '403': + description: No permission or wrong token + schema: + $ref: "#/definitions/ErrorModel" + '200': + description: a project template + schema: + $ref: "#/definitions/ProjectTemplateResponse" + parameters: + - $ref: "#/parameters/templateIdParam" + operationId: getProjectTemplate + + patch: + tags: + - projectTemplate + operationId: updateProjectTemplate + security: + - Bearer: [] + description: Update a project template. Only connect manager, connect admin, and admin can access this endpoint. + For attributes with JSON object type, it would overwrite the existing fields, or add new if the fields don't exist in the JSON object. + responses: + '403': + description: No permission or wrong token + schema: + $ref: "#/definitions/ErrorModel" + '404': + description: Not found + schema: + $ref: "#/definitions/ErrorModel" + '200': + description: Successfully updated project template. + schema: + $ref: "#/definitions/ProjectTemplateResponse" + '422': + description: Invalid input + schema: + $ref: "#/definitions/ErrorModel" + default: + description: error payload + schema: + $ref: '#/definitions/ErrorModel' + parameters: + - $ref: "#/parameters/templateIdParam" + - name: body + in: body + required: true + schema: + $ref: "#/definitions/ProjectTemplateBodyParam" + + delete: + tags: + - projectTemplate + description: Remove an existing project template. Only connect manager, connect admin, and admin can access this endpoint. + security: + - Bearer: [] + parameters: + - $ref: "#/parameters/templateIdParam" + responses: + '403': + description: No permission or wrong token + schema: + $ref: "#/definitions/ErrorModel" + '404': + description: If project is not found + schema: + $ref: "#/definitions/ErrorModel" + '204': + description: Project template successfully removed + + + /productTemplates: + get: + tags: + - productTemplate + operationId: findProductTemplates + security: + - Bearer: [] + description: Retreive all product templates. All user roles can access this endpoint. + responses: + '403': + description: No permission or wrong token + schema: + $ref: "#/definitions/ErrorModel" + '200': + description: A list of product templates + schema: + $ref: "#/definitions/ProductTemplateListResponse" + post: + tags: + - productTemplate + operationId: addProductTemplate + security: + - Bearer: [] + description: Create a product template + parameters: + - in: body + name: body + required: true + schema: + $ref: '#/definitions/ProductTemplateBodyParam' + responses: + '403': + description: No permission or wrong token + schema: + $ref: "#/definitions/ErrorModel" + '201': + description: Returns the newly created product template + schema: + $ref: "#/definitions/ProductTemplateResponse" + '422': + description: Invalid input + schema: + $ref: "#/definitions/ErrorModel" + + /productTemplates/{templateId}: + get: + tags: + - productTemplate + description: Retrieve product template by id. All user roles can access this endpoint. + security: + - Bearer: [] + responses: + '404': + description: Not found + schema: + $ref: "#/definitions/ErrorModel" + '403': + description: No permission or wrong token + schema: + $ref: "#/definitions/ErrorModel" + '200': + description: a product template + schema: + $ref: "#/definitions/ProductTemplateResponse" + parameters: + - $ref: "#/parameters/templateIdParam" + operationId: getProductTemplate + + patch: + tags: + - productTemplate + operationId: updateProductTemplate + security: + - Bearer: [] + description: Update a product template. Only connect manager, connect admin, and admin can access this endpoint. + For attributes with JSON object type, it would overwrite the existing fields, or add new if the fields don't exist in the JSON object. + responses: + '403': + description: No permission or wrong token + schema: + $ref: "#/definitions/ErrorModel" + '404': + description: Not found + schema: + $ref: "#/definitions/ErrorModel" + '200': + description: Successfully updated product template. + schema: + $ref: "#/definitions/ProductTemplateResponse" + '422': + description: Invalid input + schema: + $ref: "#/definitions/ErrorModel" + default: + description: error payload + schema: + $ref: '#/definitions/ErrorModel' + parameters: + - $ref: "#/parameters/templateIdParam" + - name: body + in: body + required: true + schema: + $ref: "#/definitions/ProductTemplateBodyParam" + + delete: + tags: + - productTemplate + description: Remove an existing product template. Only connect manager, connect admin, and admin can access this endpoint. + security: + - Bearer: [] + parameters: + - $ref: "#/parameters/templateIdParam" + responses: + '403': + description: No permission or wrong token + schema: + $ref: "#/definitions/ErrorModel" + '404': + description: If product is not found + schema: + $ref: "#/definitions/ErrorModel" + '204': + description: Product template successfully removed + + + /projectTypes: + get: + tags: + - projectType + operationId: findProjectTypes + security: + - Bearer: [] + description: Retreive all project types. All user roles can access this endpoint. + responses: + '403': + description: No permission or wrong token + schema: + $ref: "#/definitions/ErrorModel" + '200': + description: A list of project types + schema: + $ref: "#/definitions/ProjectTypeListResponse" + post: + tags: + - projectType + operationId: addProjectType + security: + - Bearer: [] + description: Create a project type. Only admin or connect admin can access this endpoint. + parameters: + - in: body + name: body + required: true + schema: + $ref: '#/definitions/ProjectTypeCreateBodyParam' + responses: + '403': + description: No permission or wrong token + schema: + $ref: "#/definitions/ErrorModel" + '201': + description: Returns the newly created project type + schema: + $ref: "#/definitions/ProjectTypeResponse" + '422': + description: Invalid input + schema: + $ref: "#/definitions/ErrorModel" + + /projectTypes/{key}: + get: + tags: + - projectType + description: Retrieve project type by id. All user roles can access this endpoint. + security: + - Bearer: [] + responses: + '404': + description: Not found + schema: + $ref: "#/definitions/ErrorModel" + '403': + description: No permission or wrong token + schema: + $ref: "#/definitions/ErrorModel" + '200': + description: a project type + schema: + $ref: "#/definitions/ProjectTypeResponse" + parameters: + - $ref: "#/parameters/keyParam" + operationId: getProjectType + + patch: + tags: + - projectType + operationId: updateProjectType + security: + - Bearer: [] + description: Update a project type. Only admin or connect admin can access this endpoint. + responses: + '403': + description: No permission or wrong token + schema: + $ref: "#/definitions/ErrorModel" + '404': + description: Not found + schema: + $ref: "#/definitions/ErrorModel" + '200': + description: Successfully updated project type. + schema: + $ref: "#/definitions/ProjectTypeResponse" + '422': + description: Invalid input + schema: + $ref: "#/definitions/ErrorModel" + default: + description: error payload + schema: + $ref: '#/definitions/ErrorModel' + parameters: + - $ref: "#/parameters/keyParam" + - name: body + in: body + required: true + schema: + $ref: "#/definitions/ProjectTypeBodyParam" + + delete: + tags: + - projectType + description: Remove an existing project type. Only admin or connect admin can access this endpoint. + security: + - Bearer: [] + parameters: + - $ref: "#/parameters/keyParam" + responses: + '403': + description: No permission or wrong token + schema: + $ref: "#/definitions/ErrorModel" + '404': + description: If project is not found + schema: + $ref: "#/definitions/ErrorModel" + '204': + description: Project type successfully removed + + + + +parameters: + projectIdParam: + name: projectId + in: path + description: project identifier + required: true + type: integer + format: int64 + phaseIdParam: + name: phaseId + in: path + description: project phase identifier + required: true + type: integer + format: int64 + minimum: 1 + productIdParam: + name: productId + in: path + description: project phase product identifier + required: true + type: integer + format: int64 + minimum: 1 + templateIdParam: + name: templateId + in: path + description: template identifier + required: true + type: integer + format: int64 + minimum: 1 + keyParam: + name: key + in: path + description: project type key + required: true + type: string + offsetParam: + name: offset + description: "number of items to skip. Defaults to 0" + in: query + required: false + type: integer + format: int32 + limitParam: + name: limit + description: "max records to return. Defaults to 20" + in: query + required: false + type: integer + format: int32 + +definitions: + ResponseMetadata: + title: Metadata object for a response + type: object + properties: + totalCount: + type: integer + format: int64 + description: Total count of the objects + + ErrorModel: + type: object + properties: + id: + type: string + description: unique id identifying the request + version: + type: string + result: + type: object + properties: + success: + type: boolean + status: + description: http status code + type: integer + format: int32 + debug: + type: object + content: + type: object + + ProjectBookMark: + title: Project bookmark + type: object + properties: + title: + type: string + address: + type: string + + ProjectBodyParam: + type: object + properties: + param: + $ref: "#/definitions/Project" + + ProjectUpgradeBodyParam: + type: object + properties: + param: + $ref: "#/definitions/ProjectUpgrade" + + NewProject: + type: object + required: + - name + - description + - type + properties: + name: + type: string + description: project name (required) + description: + type: string + description: Project description + billingAccountId: + type: number + format: long + description: the customer billing account id + estimatedPrice: + type: number + format: float + description: The estimated price of the project + terms: + type: array + items: + type: number + format: integer + external: + type: object + description: READ-ONLY, OPTIONAL. Refernce to external task/issue. + properties: + id: + type: string + description: Identifier for external reference + type: + type: string + description: external source type + enum: [ "github", "jira", "asana", "other"] + data: + type: string + description: "300 Char length text blob for customer provided data" + type: + type: string + description: project type + bookmarks: + type: array + items: + $ref: "#/definitions/ProjectBookMark" + challengeEligibility: + description: List of eligibility criteria (one entry per role) + type: array + items: + $ref: "#/definitions/ChallengeEligibility" + details: + $ref: "#/definitions/ProjectDetails" + utm: + description: READ-ONLY. Used for tracking + type: object + properties: + campaign: + type: string + medium: + type: string + source: + type: string + templateId: + description: the project template identifier + type: number + format: long -parameters: - projectIdParam: - name: projectId - in: path - description: project identifier - required: true - type: integer - format: int64 - offsetParam: - name: offset - description: "number of items to skip. Defaults to 0" - in: query - required: false - type: integer - format: int32 - limitParam: - name: limit - description: "max records to return. Defaults to 20" - in: query - required: false - type: integer - format: int32 -definitions: - ResponseMetadata: - title: Metadata object for a response + NewProjectBodyParam: + type: object + properties: + param: + $ref: "#/definitions/NewProject" + + ChallengeEligibility: + description: Object describing who is eligible to work on this task + type: object + properties: + role: + type: string + enum: ["submitter", "reviewer", "copilot"] + users: + type: array + items: + type: integer + format: int64 + groups: + type: array + items: + type: integer + format: int64 + + + Project: + type: object + properties: + id: + description: unique identifier + type: integer + format: int64 + directProjectId: + description: unique identifier in direct + type: integer + format: int64 + billingAccountId: + type: integer + format: int64 + description: The customer billing account id + utm: + description: READ-ONLY. Used for tracking + type: object + properties: + campaign: + type: string + medium: + type: string + source: + type: string + estimatedPrice: + type: number + format: float + description: The estimated price of the project + actualPrice: + type: number + format: float + description: The actual price of the project + terms: + type: array + items: + type: number + format: integer + name: + type: string + description: project name + description: + type: string + description: Project description + + external: + type: object + description: READ-ONLY, OPTIONAL. Refernce to external task/issue. + properties: + id: + type: string + description: Identifier for external reference + type: + type: string + description: external source type + enum: [ "github", "jira", "asana", "other"] + data: + type: string + description: "300 Char length text blob for customer provided data" + type: + type: string + description: project type + status: + type: string + description: current state of the task + enum: ["draft", "in_review", "reviewed", "active", "paused", "cancelled", "completed"] + cancelReason: + type: string + description: If a project is cancelled, define the reason of cancellation + challengeEligibility: + description: List of eligibility criteria (one entry per role) + type: array + items: + $ref: "#/definitions/ChallengeEligibility" + bookmarks: + type: array + items: + $ref: "#/definitions/ProjectBookMark" + members: + description: | + READ-ONLY. List of project members. + Use project member api to add/remove members + type: array + items: + $ref: "#/definitions/ProjectMember" + attachments: + description: | + READ-ONLY. List of project attachmens. + Use project attachment api to add/remove attachments + type: array + items: + $ref: "#/definitions/ProjectAttachment" + details: + $ref: "#/definitions/ProjectDetails" + templateId: + description: the project template identifier + type: number + format: long + + createdAt: + type: string + description: Datetime (GMT) when task was created + readOnly: true + createdBy: + type: integer + format: int64 + description: READ-ONLY. User who created this task + readOnly: true + updatedAt: + type: string + description: READ-ONLY. Datetime (GMT) when task was updated + readOnly: true + updatedBy: + type: integer + format: int64 + description: READ-ONLY. User that last updated this task + readOnly: true + + ProjectDetails: + description: Project details + type: object + properties: + summary: + type: string + description: text summary of the project + TBD_usageDescription: + type: string + description: a description of how the app will be used + TBD_features: + type: object + properties: + id: + type: integer + title: + type: string + description: + type: string + isCustom: + type: boolean + + ProjectUpgrade: + title: Project Upgrade object + type: object + required: + - targetVersion + - defaultProductTemplateId + properties: + targetVersion: + type: string + description: Version identifier + defaultProductTemplateId: + type: number + format: int64 + description: Default product template id, used when the associated project template is not found, or there's no matching phase with the project's product id + phaseName: + type: string + description: This value will be used instead of the product template's name for the created ProjectPhase + + NewProjectMember: + title: Project Member object + type: object + required: + - userId + - role + properties: + userId: + type: number + format: int64 + description: user identifier + isPrimary: + type: boolean + description: Flag to indicate this member is primary for specified role + role: + type: string + description: member role on specified project + enum: ["customer", "manager", "copilot"] + + NewProjectMemberBodyParam: + type: object + properties: + param: + $ref: "#/definitions/NewProjectMember" + + UpdateProjectMember: + title: Project Member object + type: object + required: + - role + properties: + isPrimary: + type: boolean + description: primary option + role: + type: string + description: member role on specified project + enum: ["customer", "manager", "copilot"] + + UpdateProjectMemberBodyParam: + type: object + properties: + param: + $ref: "#/definitions/UpdateProjectMember" + + NewProjectAttachment: + title: Project attachment request + type: object + required: + - filePath + - s3Bucket + - title + - contentType + properties: + filePath: + type: string + description: path where file is stored + s3Bucket: + type: string + description: The s3 bucket of attachment + contentType: + type: string + description: Uploaded file content type + title: + type: string + description: Name of the attachment + description: + type: string + description: Optional description for the attached file. + category: + type: string + description: Category of attachment + size: + type: number + format: float + description: The size of attachment + + NewProjectAttachmentBodyParam: type: object properties: - totalCount: - type: integer - format: int64 - description: Total count of the objects + param: + $ref: "#/definitions/NewProjectAttachment" - ErrorModel: + NewProjectAttachmentResponse: + title: Project attachment object response type: object properties: id: @@ -384,344 +1484,551 @@ definitions: success: type: boolean status: + type: string description: http status code - type: integer - format: int32 - debug: - type: object content: - type: object + $ref: "#/definitions/ProjectAttachment" - ProjectBookMark: - title: Project bookmark + ProjectAttachment: + title: Project attachment type: object properties: + id: + type: number + description: unique id for the attachment + size: + type: number + format: float + description: The size of attachment + category: + type: string + description: The category of attachment + contentType: + type: string + description: Uploaded file content type title: type: string - address: + description: Name of the attachment + description: + type: string + description: Optional description for the attached file. + downloadUrl: + type: string + description: download link for the attachment. + createdAt: + type: string + description: Datetime (GMT) when task was created + readOnly: true + createdBy: + type: integer + format: int64 + description: READ-ONLY. User who created this task + readOnly: true + updatedAt: type: string + description: READ-ONLY. Datetime (GMT) when task was updated + readOnly: true + updatedBy: + type: integer + format: int64 + description: READ-ONLY. User that last updated this task + readOnly: true - ProjectBodyParam: + ProjectMember: + title: Project Member object type: object properties: - param: - $ref: "#/definitions/Project" + id: + type: number + description: unique identifier for record + userId: + type: number + format: int64 + description: user identifier + isPrimary: + type: boolean + description: Flag to indicate this member is primary for specified role + projectId: + type: number + format: int64 + description: project identifier + role: + type: string + description: member role on specified project + enum: ["customer", "manager", "copilot"] + createdAt: + type: string + description: Datetime (GMT) when task was created + readOnly: true + createdBy: + type: integer + format: int64 + description: READ-ONLY. User who created this task + readOnly: true + updatedAt: + type: string + description: READ-ONLY. Datetime (GMT) when task was updated + readOnly: true + updatedBy: + type: integer + format: int64 + description: READ-ONLY. User that last updated this task + readOnly: true - NewProject: + + + NewProjectMemberResponse: + title: Project member object response type: object - required: - - name - - description - - type properties: - name: + id: type: string - description: project name (required) - description: + description: unique id identifying the request + version: type: string - description: Project description - billingAccountId: - type: number - format: long - description: the customer billing account id - estimatedPrice: - type: number - format: float - description: The estimated price of the project - terms: - type: array - items: - type: number - format: integer - external: + result: type: object - description: READ-ONLY, OPTIONAL. Refernce to external task/issue. properties: - id: + success: + type: boolean + status: type: string - description: Identifier for external reference - type: + description: http status code + content: + $ref: "#/definitions/ProjectMember" + + UpdateProjectMemberResponse: + title: Project member object response + type: object + properties: + id: + type: string + description: unique id identifying the request + version: + type: string + result: + type: object + properties: + success: + type: boolean + status: type: string - description: external source type - enum: [ "github", "jira", "asana", "other"] - data: + description: http status code + content: + $ref: "#/definitions/ProjectMember" + + + ProjectResponse: + title: Single project object + type: object + properties: + id: + type: string + description: unique id identifying the request + version: + type: string + result: + type: object + properties: + success: + type: boolean + status: type: string - description: "300 Char length text blob for customer provided data" - type: + description: http status code + content: + $ref: "#/definitions/Project" + + UpdateProjectResponse: + title: response with original and updated project object + type: object + properties: + id: type: string - description: project type - enum: ["generic", "visual_design", "visual_prototype", "app_dev"] - bookmarks: - type: array - items: - $ref: "#/definitions/ProjectBookMark" - challengeEligibility: - description: List of eligibility criteria (one entry per role) - type: array - items: - $ref: "#/definitions/ChallengeEligibility" - details: - $ref: "#/definitions/ProjectDetails" - utm: - description: READ-ONLY. Used for tracking + description: unique id identifying the request + version: + type: string + result: + type: object + properties: + success: + type: boolean + status: + type: string + description: http status code + content: + type: object + properties: + original: + $ref: "#/definitions/Project" + updated: + $ref: "#/definitions/Project" + + ProjectListResponse: + title: List response + type: object + properties: + id: + type: string + readOnly: true + description: unique id identifying the request + version: + type: string + result: type: object properties: - campaign: - type: string - medium: - type: string - source: + success: + type: boolean + status: type: string + description: http status code + metadata: + $ref: "#/definitions/ResponseMetadata" + content: + type: array + items: + $ref: "#/definitions/Project" - - NewProjectBodyParam: + ProjectTemplateRequest: + title: Project template request object type: object + required: + - name + - key + - category + - scope + - phases properties: - param: - $ref: "#/definitions/NewProject" + name: + type: string + description: the project template name + key: + type: string + description: the project template key + category: + type: string + description: the project template category + scope: + type: object + description: the project template scope + phases: + type: object + description: the project template phases - ChallengeEligibility: - description: Object describing who is eligible to work on this task + ProjectTemplateBodyParam: + title: Project template body param type: object + required: + - param properties: - role: - type: string - enum: ["submitter", "reviewer", "copilot"] - users: - type: array - items: + param: + $ref: "#/definitions/ProjectTemplateRequest" + + ProjectTemplate: + title: Project template object + allOf: + - type: object + required: + - id + - createdAt + - createdBy + - updatedAt + - updatedBy + properties: + id: + type: number + format: int64 + description: the id + createdAt: + type: string + description: Datetime (GMT) when object was created + readOnly: true + createdBy: type: integer format: int64 - groups: - type: array - items: + description: READ-ONLY. User who created this object + readOnly: true + updatedAt: + type: string + description: READ-ONLY. Datetime (GMT) when object was updated + readOnly: true + updatedBy: type: integer format: int64 + description: READ-ONLY. User that last updated this object + readOnly: true + - $ref: "#/definitions/ProjectTemplateRequest" - Project: + ProjectTemplateResponse: + title: Single project template response object type: object properties: id: - description: unique identifier - type: integer - format: int64 - directProjectId: - description: unique identifier in direct - type: integer - format: int64 - billingAccountId: - type: integer - format: int64 - description: The customer billing account id - utm: - description: READ-ONLY. Used for tracking + type: string + description: unique id identifying the request + version: + type: string + result: type: object properties: - campaign: - type: string - medium: - type: string - source: + success: + type: boolean + status: type: string - estimatedPrice: - type: number - format: float - description: The estimated price of the project - actualPrice: - type: number - format: float - description: The actual price of the project - terms: - type: array - items: - type: number - format: integer - name: + description: http status code + metadata: + $ref: "#/definitions/ResponseMetadata" + content: + $ref: "#/definitions/ProjectTemplate" + + ProjectTemplateListResponse: + title: Project template list response object + type: object + properties: + id: type: string - description: project name - description: + readOnly: true + description: unique id identifying the request + version: type: string - description: Project description - - external: + result: type: object - description: READ-ONLY, OPTIONAL. Refernce to external task/issue. properties: - id: - type: string - description: Identifier for external reference - type: - type: string - description: external source type - enum: [ "github", "jira", "asana", "other"] - data: + success: + type: boolean + status: type: string - description: "300 Char length text blob for customer provided data" - type: + description: http status code + metadata: + $ref: "#/definitions/ResponseMetadata" + content: + type: array + items: + $ref: "#/definitions/ProjectTemplate" + + ProductTemplateRequest: + title: Product template request object + type: object + required: + - name + - key + - category + - scope + - phases + properties: + name: type: string - description: project type - enum: ["app_dev", "generic", "visual_prototype", "visual_design"] - status: + description: the product template name + productKey: type: string - description: current state of the task - enum: ["draft", "in_review", "reviewed", "active", "paused", "cancelled", "completed"] - cancelReason: + description: the product template key + icon: type: string - description: If a project is cancelled, define the reason of cancellation - challengeEligibility: - description: List of eligibility criteria (one entry per role) - type: array - items: - $ref: "#/definitions/ChallengeEligibility" - bookmarks: - type: array - items: - $ref: "#/definitions/ProjectBookMark" - members: - description: | - READ-ONLY. List of project members. - Use project member api to add/remove members - type: array - items: - $ref: "#/definitions/ProjectMember" - attachments: - description: | - READ-ONLY. List of project attachmens. - Use project attachment api to add/remove attachments - type: array - items: - $ref: "#/definitions/ProjectAttachment" - details: - $ref: "#/definitions/ProjectDetails" - - createdAt: + description: the product template icon + brief: type: string - description: Datetime (GMT) when task was created - readOnly: true - createdBy: - type: integer - format: int64 - description: READ-ONLY. User who created this task - readOnly: true - updatedAt: + description: the product template brief + details: type: string - description: READ-ONLY. Datetime (GMT) when task was updated - readOnly: true - updatedBy: - type: integer - format: int64 - description: READ-ONLY. User that last updated this task - readOnly: true + description: the product template details + aliases: + type: object + description: the product template aliases + template: + type: object + description: the product template template - ProjectDetails: - description: Project details + ProductTemplateBodyParam: + title: Product template body param type: object + required: + - param properties: - summary: - type: string - description: text summary of the project - TBD_usageDescription: - type: string - description: a description of how the app will be used - TBD_features: - type: object + param: + $ref: "#/definitions/ProductTemplateRequest" + + ProductTemplate: + title: Product template object + allOf: + - type: object + required: + - id + - createdAt + - createdBy + - updatedAt + - updatedBy properties: id: - type: integer - title: + type: number + format: int64 + description: the id + createdAt: type: string - description: + description: Datetime (GMT) when object was created + readOnly: true + createdBy: + type: integer + format: int64 + description: READ-ONLY. User who created this object + readOnly: true + updatedAt: type: string - isCustom: - type: boolean - + description: READ-ONLY. Datetime (GMT) when object was updated + readOnly: true + updatedBy: + type: integer + format: int64 + description: READ-ONLY. User that last updated this object + readOnly: true + - $ref: "#/definitions/ProductTemplateRequest" - NewProjectMember: - title: Project Member object + ProjectUpgradeResponse: + title: Project upgrade response object type: object - required: - - userId - - role properties: - userId: - type: number - format: int64 - description: user identifier - isPrimary: - type: boolean - description: Flag to indicate this member is primary for specified role - role: + id: type: string - description: member role on specified project - enum: ["customer", "manager", "copilot"] + description: unique id identifying the request + version: + type: string + result: + type: object + properties: + success: + type: boolean + status: + type: string + description: http status code + metadata: + $ref: "#/definitions/ResponseMetadata" - NewProjectMemberBodyParam: + ProductTemplateResponse: + title: Single product template response object type: object properties: - param: - $ref: "#/definitions/NewProjectMember" - - UpdateProjectMember: - title: Project Member object - type: object - required: - - role - properties: - isPrimary: - type: boolean - description: primary option - role: - type: string - description: member role on specified project - enum: ["customer", "manager", "copilot"] + id: + type: string + description: unique id identifying the request + version: + type: string + result: + type: object + properties: + success: + type: boolean + status: + type: string + description: http status code + metadata: + $ref: "#/definitions/ResponseMetadata" + content: + $ref: "#/definitions/ProductTemplate" - UpdateProjectMemberBodyParam: + ProductTemplateListResponse: + title: Product template list response object type: object properties: - param: - $ref: "#/definitions/UpdateProjectMember" + id: + type: string + readOnly: true + description: unique id identifying the request + version: + type: string + result: + type: object + properties: + success: + type: boolean + status: + type: string + description: http status code + metadata: + $ref: "#/definitions/ResponseMetadata" + content: + type: array + items: + $ref: "#/definitions/ProductTemplate" - NewProjectAttachment: - title: Project attachment request + ProjectPhaseRequest: + title: Project phase request object type: object required: - - filePath - - s3Bucket - - title - - contentType + - name + - status + - startDate + - endDate properties: - filePath: - type: string - description: path where file is stored - s3Bucket: - type: string - description: The s3 bucket of attachment - contentType: + name: type: string - description: Uploaded file content type - title: + description: the project phase name + status: type: string - description: Name of the attachment - description: + description: the project phase status + startDate: type: string - description: Optional description for the attached file. - category: + format: date + description: the project phase start date + endDate: type: string - description: Category of attachment - size: + format: date + description: the project phase end date + budget: type: number - format: float - description: The size of attachment + description: the project phase budget + progress: + type: number + description: the project phase progress + details: + type: object + description: the project phase details - NewProjectAttachmentBodyParam: + ProjectPhaseBodyParam: + title: Project phase body param type: object + required: + - param properties: param: - $ref: "#/definitions/NewProjectAttachment" + $ref: "#/definitions/ProjectPhaseRequest" - NewProjectAttachmentResponse: - title: Project attachment object response + ProjectPhase: + title: Project phase object + allOf: + - type: object + required: + - id + - createdAt + - createdBy + - updatedAt + - updatedBy + properties: + id: + type: number + format: int64 + description: the id + createdAt: + type: string + description: Datetime (GMT) when object was created + readOnly: true + createdBy: + type: integer + format: int64 + description: READ-ONLY. User who created this object + readOnly: true + updatedAt: + type: string + description: READ-ONLY. Datetime (GMT) when object was updated + readOnly: true + updatedBy: + type: integer + format: int64 + description: READ-ONLY. User that last updated this object + readOnly: true + - $ref: "#/definitions/ProjectPhaseRequest" + + + ProjectPhaseResponse: + title: Single project phase response object type: object properties: id: @@ -737,99 +2044,113 @@ definitions: status: type: string description: http status code + metadata: + $ref: "#/definitions/ResponseMetadata" content: - $ref: "#/definitions/ProjectAttachment" + $ref: "#/definitions/ProjectPhase" - ProjectAttachment: - title: Project attachment + ProjectPhaseListResponse: + title: Project phase list response object type: object properties: id: - type: number - description: unique id for the attachment - size: - type: number - format: float - description: The size of attachment - category: - type: string - description: The category of attachment - contentType: - type: string - description: Uploaded file content type - title: - type: string - description: Name of the attachment - description: - type: string - description: Optional description for the attached file. - downloadUrl: - type: string - description: download link for the attachment. - createdAt: type: string - description: Datetime (GMT) when task was created - readOnly: true - createdBy: - type: integer - format: int64 - description: READ-ONLY. User who created this task readOnly: true - updatedAt: + description: unique id identifying the request + version: type: string - description: READ-ONLY. Datetime (GMT) when task was updated - readOnly: true - updatedBy: - type: integer - format: int64 - description: READ-ONLY. User that last updated this task - readOnly: true + result: + type: object + properties: + success: + type: boolean + status: + type: string + description: http status code + metadata: + $ref: "#/definitions/ResponseMetadata" + content: + type: array + items: + $ref: "#/definitions/ProjectPhase" - ProjectMember: - title: Project Member object + + PhaseProductRequest: + title: Phase product request object type: object properties: - id: + name: + type: string + description: the phase product name + directProjectId: type: number - description: unique identifier for record - userId: + description: the phase product direct project id + billingAccountId: type: number - format: int64 - description: user identifier - isPrimary: - type: boolean - description: Flag to indicate this member is primary for specified role - projectId: + description: the phase product billing account Id + templateId: type: number - format: int64 - description: project identifier - role: - type: string - description: member role on specified project - enum: ["customer", "manager", "copilot"] - createdAt: - type: string - description: Datetime (GMT) when task was created - readOnly: true - createdBy: - type: integer - format: int64 - description: READ-ONLY. User who created this task - readOnly: true - updatedAt: + description: the phase product template id + type: type: string - description: READ-ONLY. Datetime (GMT) when task was updated - readOnly: true - updatedBy: - type: integer - format: int64 - description: READ-ONLY. User that last updated this task - readOnly: true + description: the phase product type + estimatedPrice: + type: number + description: the phase product estimated price + actualPrice: + type: number + description: the phase product actual price + details: + type: object + description: the phase product details + + PhaseProductBodyParam: + title: Phase product body param + type: object + required: + - param + properties: + param: + $ref: "#/definitions/PhaseProductRequest" + PhaseProduct: + title: Phase product object + allOf: + - type: object + required: + - id + - createdAt + - createdBy + - updatedAt + - updatedBy + properties: + id: + type: number + format: int64 + description: the id + createdAt: + type: string + description: Datetime (GMT) when object was created + readOnly: true + createdBy: + type: integer + format: int64 + description: READ-ONLY. User who created this object + readOnly: true + updatedAt: + type: string + description: READ-ONLY. Datetime (GMT) when object was updated + readOnly: true + updatedBy: + type: integer + format: int64 + description: READ-ONLY. User that last updated this object + readOnly: true + - $ref: "#/definitions/PhaseProductRequest" - NewProjectMemberResponse: - title: Project member object response + PhaseProductResponse: + title: Single phase product response object type: object properties: id: @@ -845,15 +2166,18 @@ definitions: status: type: string description: http status code + metadata: + $ref: "#/definitions/ResponseMetadata" content: - $ref: "#/definitions/ProjectMember" + $ref: "#/definitions/PhaseProduct" - UpdateProjectMemberResponse: - title: Project member object response + PhaseProductListResponse: + title: Phase product list response object type: object properties: id: type: string + readOnly: true description: unique id identifying the request version: type: string @@ -865,32 +2189,92 @@ definitions: status: type: string description: http status code + metadata: + $ref: "#/definitions/ResponseMetadata" content: - $ref: "#/definitions/ProjectMember" + type: array + items: + $ref: "#/definitions/PhaseProduct" + + + + ProjectTypeRequest: + title: Project type request object + type: object + required: + - displayName + properties: + displayName: + type: string + description: the project type display name + ProjectTypeBodyParam: + title: Project type body param + type: object + required: + - param + properties: + param: + $ref: "#/definitions/ProjectTypeRequest" - ProjectResponse: - title: Single project object + ProjectTypeCreateRequest: + title: Project type creation request object type: object + allOf: + - type: object + required: + - key + properties: + key: + type: string + description: the project type key + - $ref: "#/definitions/ProjectTypeRequest" + + ProjectTypeCreateBodyParam: + title: Project type creation body param + type: object + required: + - param properties: - id: - type: string - description: unique id identifying the request - version: - type: string - result: - type: object + param: + $ref: "#/definitions/ProjectTypeCreateRequest" + + ProjectType: + title: Project type object + allOf: + - type: object + required: + - createdAt + - createdBy + - updatedAt + - updatedBy properties: - success: - type: boolean - status: + key: type: string - description: http status code - content: - $ref: "#/definitions/Project" + description: the project type key + createdAt: + type: string + description: Datetime (GMT) when object was created + readOnly: true + createdBy: + type: integer + format: int64 + description: READ-ONLY. User who created this object + readOnly: true + updatedAt: + type: string + description: READ-ONLY. Datetime (GMT) when object was updated + readOnly: true + updatedBy: + type: integer + format: int64 + description: READ-ONLY. User that last updated this object + readOnly: true + - $ref: "#/definitions/ProjectTypeCreateRequest" - UpdateProjectResponse: - title: response with original and updated project object + + ProjectTypeResponse: + title: Single project type response object type: object properties: id: @@ -906,16 +2290,13 @@ definitions: status: type: string description: http status code + metadata: + $ref: "#/definitions/ResponseMetadata" content: - type: object - properties: - original: - $ref: "#/definitions/Project" - updated: - $ref: "#/definitions/Project" + $ref: "#/definitions/ProjectType" - ProjectListResponse: - title: List response + ProjectTypeListResponse: + title: Project type list response object type: object properties: id: @@ -937,4 +2318,4 @@ definitions: content: type: array items: - $ref: "#/definitions/Project" + $ref: "#/definitions/ProjectType"