diff --git a/.circleci/config.yml b/.circleci/config.yml index fec74ad4..459ec723 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -76,7 +76,7 @@ workflows: - test filters: branches: - only: ['dev', 'feature/timeline-milestone'] + only: ['dev'] - deployProd: requires: - test diff --git a/.gitignore b/.gitignore index 0cf4a82a..edd85b28 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,8 @@ # Created by https://www.gitignore.io/api/node config/local.js +# can be used locally to config some env variables and after apply them using `source .env` +.env ### Node ### # Logs logs diff --git a/README.md b/README.md index c487aab6..60abab9e 100644 --- a/README.md +++ b/README.md @@ -18,7 +18,7 @@ Microservice to manage CRUD operations for all things Projects. Copy config/sample.local.js as config/local.js, update the properties and according to your env setup #### Database -Once you start your PostgreSQL database through docker, it will create a projectsDB. +Once you start your PostgreSQL database through docker, it will create a projectsdb. *To create tables - note this will drop tables if they already exist* ``` NODE_ENV=development npm run sync:db @@ -40,6 +40,33 @@ Run `npm run sync:es` from the root of project to execute the script. **NOTE**: In production these dependencies / services are hosted & managed outside tc-projects-service. +#### Kafka +Kafka must be installed and configured prior starting the application. +Following topics must be created: +``` +notifications.connect.project.updated +notifications.connect.project.files.updated +notifications.connect.project.team.updated +notifications.connect.project.plan.updated +notifications.connect.project.topic.created +notifications.connect.project.topic.updated +notifications.connect.project.post.created +notifications.connect.project.post.edited +``` + +New Kafka related configuration options has been introduced: +``` +"kafkaConfig": { + "hosts": List of Kafka brokers. Default: localhost: 9092 + "clientCert": SSL certificate + "clientCertKey": Certificate key +} +``` +Environment variables: +KAFKA_HOSTS - same as "hosts" +KAFKA_CLIENT_CERT - same as "clientCert" +KAFKA_CLIENT_CERT_KEY - same as "clientCertKey" + ### Test Each of the individual modules/services are unit tested. diff --git a/config/custom-environment-variables.json b/config/custom-environment-variables.json index 3e0b0287..59814a2c 100644 --- a/config/custom-environment-variables.json +++ b/config/custom-environment-variables.json @@ -4,6 +4,7 @@ "logLevel": "LOG_LEVEL", "version": "APP_VERSION", "captureLogs": "CAPTURE_LOGS", + "enableFileUpload": "ENABLE_FILE_UPLOAD", "logentriesToken": "LOGENTRIES_TOKEN", "elasticsearchConfig": { "host": "PROJECTS_ES_URL", @@ -29,6 +30,12 @@ "maxPoolSize": "DB_MAX_POOL_SIZE", "minPoolSize": "DB_MIN_POOL_SIZE" }, + "kafkaConfig": { + "groupId": "KAFKA_GROUP_ID", + "url": "KAFKA_URL", + "clientCert": "KAFKA_CLIENT_CERT", + "clientCertKey": "KAFKA_CLIENT_CERT_KEY" + }, "analyticsKey": "SEGMENT_ANALYTICS_KEY", "VALID_ISSUERS": "VALID_ISSUERS", "jwksUri": "JWKS_URI", @@ -38,5 +45,6 @@ "AUTH0_CLIENT_ID": "AUTH0_CLIENT_ID", "AUTH0_CLIENT_SECRET": "AUTH0_CLIENT_SECRET", "AUTH0_AUDIENCE": "AUTH0_AUDIENCE", - "TOKEN_CACHE_TIME" : "TOKEN_CACHE_TIME" + "TOKEN_CACHE_TIME" : "TOKEN_CACHE_TIME", + "whitelistedOriginsForUserIdAuth": "WHITELISTED_ORIGINS_FOR_USERID_AUTH" } diff --git a/config/default.json b/config/default.json index 556a5a10..da84855b 100644 --- a/config/default.json +++ b/config/default.json @@ -4,6 +4,7 @@ "logLevel": "info", "version": "v4", "captureLogs": "false", + "enableFileUpload": "true", "logentriesToken": "", "rabbitmqURL": "", "pubsubQueueName": "project.service", @@ -33,6 +34,8 @@ "minPoolSize": 4, "idleTimeout": 1000 }, + "kafkaConfig": { + }, "analyticsKey": "", "VALID_ISSUERS": "[\"https:\/\/topcoder-newauth.auth0.com\/\",\"https:\/\/api.topcoder-dev.com\"]", "validIssuers": "[\"https:\/\/topcoder-newauth.auth0.com\/\",\"https:\/\/api.topcoder-dev.com\"]", @@ -46,5 +49,6 @@ "AUTH0_CLIENT_SECRET": "", "AUTH0_AUDIENCE": "", "AUTH0_URL": "", - "TOKEN_CACHE_TIME": "" + "TOKEN_CACHE_TIME": "", + "whitelistedOriginsForUserIdAuth": "[\"https:\/\/topcoder-newauth.auth0.com\/\",\"https:\/\/api.topcoder-dev.com\"]" } diff --git a/config/sample.local.js b/config/sample.local.js index 058f9089..bec4dd70 100644 --- a/config/sample.local.js +++ b/config/sample.local.js @@ -5,6 +5,7 @@ if (process.env.NODE_ENV === 'test') { config = require('./test.json'); } else { config = { + identityServiceEndpoint: "http://dockerhost:3001/", authSecret: 'secret', authDomain: 'topcoder-dev.com', logLevel: 'debug', @@ -14,9 +15,9 @@ if (process.env.NODE_ENV === 'test') { fileServiceEndpoint: 'https://api.topcoder-dev.com/v3/files/', directProjectServiceEndpoint: 'https://api.topcoder-dev.com/v3/direct', connectProjectsUrl: 'https://connect.topcoder-dev.com/projects/', - memberServiceEndpoint: 'http://dockerhost:3001/members', + memberServiceEndpoint: 'http://dockerhost:3001/v3/members', dbConfig: { - masterUrl: 'postgres://coder:mysecretpassword@dockerhost:54321/projectsdb', + masterUrl: 'postgres://coder:mysecretpassword@dockerhost:5432/projectsdb', maxPoolSize: 50, minPoolSize: 4, idleTimeout: 1000, @@ -28,6 +29,7 @@ if (process.env.NODE_ENV === 'test') { indexName: 'projects', docType: 'projectV4' }, + whitelistedOriginsForUserIdAuth: "[\"\"]", }; } module.exports = config; diff --git a/config/test.json b/config/test.json index ab652133..23ca972e 100644 --- a/config/test.json +++ b/config/test.json @@ -18,5 +18,6 @@ "maxPoolSize": 50, "minPoolSize": 4, "idleTimeout": 1000 - } + }, + "whitelistedOriginsForUserIdAuth": "[\"\"]" } diff --git a/deploy.sh b/deploy.sh index 7390c60b..1d4ea007 100755 --- a/deploy.sh +++ b/deploy.sh @@ -54,13 +54,17 @@ make_task_def(){ "name": "%s", "image": "%s.dkr.ecr.%s.amazonaws.com/%s:%s", "essential": true, - "memory": 200, - "cpu": 10, + "memory": 1536, + "cpu": 768, "environment": [ { "name": "NODE_ENV", "value": "%s" }, + { + "name": "ENABLE_FILE_UPLOAD", + "value": "%s" + }, { "name": "LOG_LEVEL", "value": "%s" @@ -176,6 +180,22 @@ make_task_def(){ { "name": "TOKEN_CACHE_TIME", "value": "%s" + }, + { + "name": "KAFKA_CLIENT_CERT", + "value": "%s" + }, + { + "name": "KAFKA_CLIENT_CERT_KEY", + "value": "%s" + }, + { + "name": "KAFKA_GROUP_ID", + "value": "%s" + }, + { + "name": "KAFKA_URL", + "value": "%s" } ], "portMappings": [ @@ -220,15 +240,20 @@ make_task_def(){ fi echo "NODE_ENV" echo $NODE_ENV + ENABLE_FILE_UPLOAD=$(eval "echo \$${ENV}_ENABLE_FILE_UPLOAD") AUTH0_URL=$(eval "echo \$${ENV}_AUTH0_URL") AUTH0_AUDIENCE=$(eval "echo \$${ENV}_AUTH0_AUDIENCE") AUTH0_CLIENT_ID=$(eval "echo \$${ENV}_AUTH0_CLIENT_ID") AUTH0_CLIENT_SECRET=$(eval "echo \$${ENV}_AUTH0_CLIENT_SECRET") TOKEN_CACHE_TIME=$(eval "echo \$${ENV}_TOKEN_CACHE_TIME") + KAFKA_CLIENT_CERT=$(eval "echo \$${ENV}_KAFKA_CLIENT_CERT") + KAFKA_CLIENT_CERT_KEY=$(eval "echo \$${ENV}_KAFKA_CLIENT_CERT_KEY") + KAFKA_GROUP_ID=$(eval "echo \$${ENV}_KAFKA_GROUP_ID") + KAFKA_URL=$(eval "echo \$${ENV}_KAFKA_URL") - task_def=$(printf "$task_template" $family $ACCOUNT_ID $AWS_ECS_CONTAINER_NAME $ACCOUNT_ID $AWS_REGION $AWS_REPOSITORY $CIRCLE_SHA1 $NODE_ENV $LOG_LEVEL $CAPTURE_LOGS $LOGENTRIES_TOKEN $API_VERSION $AWS_REGION $AWS_ACCESS_KEY_ID $AWS_SECRET_ACCESS_KEY $AUTH_DOMAIN $AUTH_SECRET $VALID_ISSUERS $DB_MASTER_URL $MEMBER_SERVICE_ENDPOINT $IDENTITY_SERVICE_ENDPOINT $BUS_API_URL $MESSAGE_SERVICE_URL $SYSTEM_USER_CLIENT_ID $SYSTEM_USER_CLIENT_SECRET $PROJECTS_ES_URL $PROJECTS_ES_INDEX_NAME $RABBITMQ_URL $DIRECT_PROJECT_SERVICE_ENDPOINT $FILE_SERVICE_ENDPOINT $CONNECT_PROJECTS_URL $SEGMENT_ANALYTICS_KEY "$AUTH0_URL" "$AUTH0_AUDIENCE" $AUTH0_CLIENT_ID "$AUTH0_CLIENT_SECRET" $TOKEN_CACHE_TIME $PORT $PORT $AWS_ECS_CLUSTER $AWS_REGION $NODE_ENV) + task_def=$(printf "$task_template" $family $ACCOUNT_ID $AWS_ECS_CONTAINER_NAME $ACCOUNT_ID $AWS_REGION $AWS_REPOSITORY $CIRCLE_SHA1 $NODE_ENV $ENABLE_FILE_UPLOAD $LOG_LEVEL $CAPTURE_LOGS $LOGENTRIES_TOKEN $API_VERSION $AWS_REGION $AWS_ACCESS_KEY_ID $AWS_SECRET_ACCESS_KEY $AUTH_DOMAIN $AUTH_SECRET $VALID_ISSUERS $DB_MASTER_URL $MEMBER_SERVICE_ENDPOINT $IDENTITY_SERVICE_ENDPOINT $BUS_API_URL $MESSAGE_SERVICE_URL $SYSTEM_USER_CLIENT_ID $SYSTEM_USER_CLIENT_SECRET $PROJECTS_ES_URL $PROJECTS_ES_INDEX_NAME $RABBITMQ_URL $DIRECT_PROJECT_SERVICE_ENDPOINT $FILE_SERVICE_ENDPOINT $CONNECT_PROJECTS_URL $SEGMENT_ANALYTICS_KEY "$AUTH0_URL" "$AUTH0_AUDIENCE" $AUTH0_CLIENT_ID "$AUTH0_CLIENT_SECRET" $TOKEN_CACHE_TIME "$KAFKA_CLIENT_CERT" "$KAFKA_CLIENT_CERT_KEY" $KAFKA_GROUP_ID $KAFKA_URL $PORT $PORT $AWS_ECS_CLUSTER $AWS_REGION $NODE_ENV) } push_ecr_image(){ diff --git a/local/mock-services/services.json b/local/mock-services/services.json index 87ef7863..d77800cb 100644 --- a/local/mock-services/services.json +++ b/local/mock-services/services.json @@ -249,6 +249,56 @@ } }, "version": "v3" + }, + { + "id": "test_admin1", + "result": { + "success": true, + "status": 200, + "metadata": null, + "content": { + "maxRating": { + "rating": 1114, + "track": "DATA_SCIENCE", + "subTrack": "SRM" + }, + "createdBy": "40011578", + "updatedBy": "40011578", + "userId": 40135978, + "firstName": "Adminname", + "lastName": "Adminlastname", + "quote": "It is a mistake to think you can solve any major problems just with potatoes.", + "description": null, + "otherLangName": null, + "handle": "test_admin1", + "handleLower": "test_admin1", + "status": "ACTIVE", + "email": "pshah1@test.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 index 3f3d6723..84f5f25b 100644 --- a/migrations/20180608_project_add_templateId_and_new_tables.sql +++ b/migrations/20180608_project_add_templateId_and_new_tables.sql @@ -45,6 +45,7 @@ CREATE TABLE milestones ( "activeText" character varying(512) NOT NULL, "completedText" character varying(512) NOT NULL, "blockedText" character varying(512) NOT NULL, + "hidden" boolean DEFAULT false, "deletedAt" timestamp with time zone, "createdAt" timestamp with time zone, "updatedAt" timestamp with time zone, diff --git a/migrations/20180824_milestone_templates_metadata.sql b/migrations/20180824_milestone_templates_metadata.sql new file mode 100644 index 00000000..4aedb692 --- /dev/null +++ b/migrations/20180824_milestone_templates_metadata.sql @@ -0,0 +1,27 @@ +-- +-- UPDATE EXISTING TABLES: +-- product_milestone_templates: +-- removed constraint `product_milestone_templates_productTemplateId_fkey` +-- changed column `productTemplateId` to `referenceId` +-- added column `reference` +-- added column `metadata` +-- changed table name to `milestone_templates` + +-- +-- product_milestone_templates +-- +ALTER TABLE product_milestone_templates DROP CONSTRAINT "product_milestone_templates_productTemplateId_fkey"; + +ALTER TABLE product_milestone_templates RENAME COLUMN "productTemplateId" TO "referenceId"; + +ALTER TABLE product_milestone_templates ADD COLUMN "reference" character varying(45); +UPDATE product_milestone_templates set reference='productTemplate' where reference is null; +ALTER TABLE product_milestone_templates ALTER COLUMN "reference" SET NOT NULL; + +ALTER TABLE product_milestone_templates ADD COLUMN "metadata" json; +UPDATE product_milestone_templates set metadata='{}' where metadata is null; +ALTER TABLE product_milestone_templates ALTER COLUMN "metadata" SET NOT NULL; + +ALTER TABLE product_milestone_templates RENAME TO milestone_templates; + + diff --git a/migrations/20180827_project_phases_order.sql b/migrations/20180827_project_phases_order.sql new file mode 100644 index 00000000..4049c15a --- /dev/null +++ b/migrations/20180827_project_phases_order.sql @@ -0,0 +1,9 @@ +-- +-- UPDATE EXISTING TABLES: +-- project_phases: +-- added column `order` + +-- +-- project_phases +-- +ALTER TABLE project_phases ADD COLUMN "order" integer NULL; diff --git a/migrations/20180910_project_activity.sql b/migrations/20180910_project_activity.sql new file mode 100644 index 00000000..540d5491 --- /dev/null +++ b/migrations/20180910_project_activity.sql @@ -0,0 +1,18 @@ +-- +-- UPDATE EXISTING TABLES: +-- projects: +-- added column `lastActivityAt` +-- added column `lastActivityUserId` + +-- +-- projects + +-- Add new columns +ALTER TABLE projects ADD COLUMN "lastActivityAt" timestamp with time zone; +ALTER TABLE projects ADD COLUMN "lastActivityUserId" character varying(45); +-- Update new colums +UPDATE projects SET "lastActivityAt"="updatedAt" WHERE "lastActivityAt" is NULL; +UPDATE projects SET "lastActivityUserId"=cast("updatedBy" as varchar) WHERE "lastActivityUserId" is NULL; +-- Set not null +ALTER TABLE projects ALTER COLUMN "lastActivityAt" SET NOT NULL; +ALTER TABLE projects ALTER COLUMN "lastActivityUserId" SET NOT NULL; diff --git a/migrations/elasticsearch_sync.js b/migrations/elasticsearch_sync.js index eac45e5f..349be0b6 100644 --- a/migrations/elasticsearch_sync.js +++ b/migrations/elasticsearch_sync.js @@ -281,6 +281,13 @@ function getRequestBody(indexName) { updatedBy: { type: 'integer', }, + lastActivityAt: { + type: 'date', + format: 'strict_date_optional_time||epoch_millis', + }, + lastActivityUserId: { + type: 'string', + }, utm: { properties: { campaign: { diff --git a/package-lock.json b/package-lock.json index 88934335..228a50d6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,23 +13,41 @@ "join-component": "1.1.0" } }, + "@types/bluebird": { + "version": "3.5.0", + "resolved": "https://registry.npmjs.org/@types/bluebird/-/bluebird-3.5.0.tgz", + "integrity": "sha1-JjNHCk6r6aR82aRf2yDtX5NAe8o=" + }, "@types/body-parser": { - "version": "1.16.8", - "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.16.8.tgz", - "integrity": "sha512-BdN2PXxOFnTXFcyONPW6t0fHjz2fvRZHVMFpaS0wYr+Y8fWEaNOs4V8LEu/fpzQlMx+ahdndgTaGTwPC+J/EeA==", + "version": "1.17.0", + "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.17.0.tgz", + "integrity": "sha512-a2+YeUjPkztKJu5aIF2yArYFQQp8d51wZ7DavSHjFuY1mqVgidGyzEQ41JIVNy82fXj8yPgy2vJmfIywgESW6w==", + "requires": { + "@types/connect": "3.4.32", + "@types/node": "10.9.4" + } + }, + "@types/connect": { + "version": "3.4.32", + "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.32.tgz", + "integrity": "sha512-4r8qa0quOvh7lGD0pre62CAb1oni1OO6ecJLGCezTmhQ8Fz50Arx9RUszryR8KlgK6avuSXvviL6yWyViQABOg==", "requires": { - "@types/express": "4.0.39", - "@types/node": "8.5.1" + "@types/node": "10.9.4" } }, + "@types/events": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@types/events/-/events-1.2.0.tgz", + "integrity": "sha512-KEIlhXnIutzKwRbQkGWb/I4HFqBuUykAdHgDED6xqwXJfONCjF5VoE0cXEiurh3XauygxzeDzgtXUqvLkxFzzA==" + }, "@types/express": { - "version": "4.0.39", - "resolved": "https://registry.npmjs.org/@types/express/-/express-4.0.39.tgz", - "integrity": "sha512-dBUam7jEjyuEofigUXCtublUHknRZvcRgITlGsTbFgPvnTwtQUt2NgLakbsf+PsGo/Nupqr3IXCYsOpBpofyrA==", + "version": "4.16.0", + "resolved": "https://registry.npmjs.org/@types/express/-/express-4.16.0.tgz", + "integrity": "sha512-TtPEYumsmSTtTetAPXlJVf3kEqb6wZK0bZojpJQrnD/djV4q1oB6QQ8aKvKqwNPACoe02GNiy5zDzcYivR5Z2w==", "requires": { - "@types/body-parser": "1.16.8", - "@types/express-serve-static-core": "4.0.57", - "@types/serve-static": "1.13.1" + "@types/body-parser": "1.17.0", + "@types/express-serve-static-core": "4.16.0", + "@types/serve-static": "1.13.2" } }, "@types/express-jwt": { @@ -37,16 +55,18 @@ "resolved": "https://registry.npmjs.org/@types/express-jwt/-/express-jwt-0.0.34.tgz", "integrity": "sha1-/b7kxq9cCiRu8qkz9VGZc8dxfwI=", "requires": { - "@types/express": "4.0.39", + "@types/express": "4.16.0", "@types/express-unless": "0.0.32" } }, "@types/express-serve-static-core": { - "version": "4.0.57", - "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-4.0.57.tgz", - "integrity": "sha512-QLAHjdLwEICm3thVbXSKRoisjfgMVI4xJH/HU8F385BR2HI7PmM6ax4ELXf8Du6sLmSpySXMYaI+xc//oQ/IFw==", + "version": "4.16.0", + "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-4.16.0.tgz", + "integrity": "sha512-lTeoCu5NxJU4OD9moCgm0ESZzweAx0YqsAcab6OB0EB3+As1OaHtKnaGJvcngQxYsi9UNv0abn4/DRavrRxt4w==", "requires": { - "@types/node": "8.5.1" + "@types/events": "1.2.0", + "@types/node": "10.9.4", + "@types/range-parser": "1.2.2" } }, "@types/express-unless": { @@ -54,7 +74,7 @@ "resolved": "https://registry.npmjs.org/@types/express-unless/-/express-unless-0.0.32.tgz", "integrity": "sha512-6YpJyFNlDDnPnRjMOvJCoDYlSDDmG/OEEUsPk7yhNkL4G9hUYtgab6vi1CcWsGSSSM0CsvNlWTG+ywAGnvF03g==", "requires": { - "@types/express": "4.0.39" + "@types/express": "4.16.0" } }, "@types/geojson": { @@ -62,22 +82,32 @@ "resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-1.0.6.tgz", "integrity": "sha512-Xqg/lIZMrUd0VRmSRbCAewtwGZiAk3mEUDvV4op1tGl+LvyPcb/MIOSxTl9z+9+J+R4/vpjiCAT4xeKzH9ji1w==" }, + "@types/lodash": { + "version": "4.14.116", + "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.14.116.tgz", + "integrity": "sha512-lRnAtKnxMXcYYXqOiotTmJd74uawNWuPnsnPrrO7HiFuE3npE2iQhfABatbYDyxTNqZNuXzcKGhw37R7RjBFLg==" + }, "@types/mime": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/@types/mime/-/mime-2.0.0.tgz", "integrity": "sha512-A2TAGbTFdBw9azHbpVd+/FkdW2T6msN1uct1O9bH3vTerEHKZhTXJUQXy+hNq1B0RagfU8U+KBdqiZpxjhOUQA==" }, "@types/node": { - "version": "8.5.1", - "resolved": "https://registry.npmjs.org/@types/node/-/node-8.5.1.tgz", - "integrity": "sha512-SrmAO+NhnsuG/6TychSl2VdxBZiw/d6V+8j+DFo8O3PwFi+QeYXWHhAw+b170aSc6zYab6/PjEWRZHIDN9mNUw==" + "version": "10.9.4", + "resolved": "https://registry.npmjs.org/@types/node/-/node-10.9.4.tgz", + "integrity": "sha512-fCHV45gS+m3hH17zgkgADUSi2RR1Vht6wOZ0jyHP8rjiQra9f+mIcgwPQHllmDocYOstIEbKlxbFDYlgrTPYqw==" + }, + "@types/range-parser": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.2.tgz", + "integrity": "sha512-HtKGu+qG1NPvYe1z7ezLsyIaXYyi8SoAVqWDZgDQ8dLrsZvSzUNCwZyfX33uhWxL/SU0ZDQZ3nwZ0nimt507Kw==" }, "@types/serve-static": { - "version": "1.13.1", - "resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-1.13.1.tgz", - "integrity": "sha512-jDMH+3BQPtvqZVIcsH700Dfi8Q3MIcEx16g/VdxjoqiGR/NntekB10xdBpirMKnPe9z2C5cBmL0vte0YttOr3Q==", + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-1.13.2.tgz", + "integrity": "sha512-/BZ4QRLpH/bNYgZgwhKEh+5AsboDBcUdlBYgzoLX0fpj3Y2gp6EApyOlM3bK53wQS/OE1SrdSYBAbux2D1528Q==", "requires": { - "@types/express-serve-static-core": "4.0.57", + "@types/express-serve-static-core": "4.16.0", "@types/mime": "2.0.0" } }, @@ -347,9 +377,12 @@ "integrity": "sha1-5QNHYR1+aQlDIIu9r+vLwvuGbUY=" }, "asn1": { - "version": "0.2.3", - "resolved": "https://registry.npmjs.org/asn1/-/asn1-0.2.3.tgz", - "integrity": "sha1-2sh4dxPJlmhJ/IGAd36+nB3fO4Y=" + "version": "0.2.4", + "resolved": "https://registry.npmjs.org/asn1/-/asn1-0.2.4.tgz", + "integrity": "sha512-jxwzQpLQjSmWXgwaCZE9Nz+glAG01yF1QnWgbhGwHI5A6FRIEY6IVqtHhIepHqI7/kyEyQEagBC5mBEFlIYvdg==", + "requires": { + "safer-buffer": "2.1.2" + } }, "assert-plus": { "version": "1.0.0", @@ -389,9 +422,9 @@ "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=", + "version": "9.7.3", + "resolved": "https://registry.npmjs.org/auth0-js/-/auth0-js-9.7.3.tgz", + "integrity": "sha512-iZAqoN4EbsNCS/3VkFPNb4glTyj8hq57T7gcUF+XH8Rua7hBTUzpb101K9zqcdUIBilIdF9XBLCTJ4JGgZ/oFA==", "requires": { "base64-js": "1.2.1", "idtoken-verifier": "1.2.0", @@ -403,11 +436,11 @@ }, "dependencies": { "debug": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/debug/-/debug-3.1.0.tgz", - "integrity": "sha512-OX8XqP7/1a9cqkxYw2yXss15f26NKWBpDXQd0/uK/KPqdQhxbPa994hnzjcE2VqQpDslf55723cKPUOGSmMY3g==", + "version": "3.2.5", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.5.tgz", + "integrity": "sha512-D61LaDQPQkxJ5AUM2mbSJRbPkNs/TmdmOeLAi1hgDkpDfIfetSrjmWhccwtuResSwMbACjx/xXQofvM9CE/aeg==", "requires": { - "ms": "2.0.0" + "ms": "2.1.1" } }, "formidable": { @@ -420,6 +453,11 @@ "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", "integrity": "sha1-u5NdSFgsuhaMBoNJV6VKPgcSTxE=" }, + "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==" + }, "process-nextick-args": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.0.tgz", @@ -427,7 +465,7 @@ }, "readable-stream": { "version": "2.3.6", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.6.tgz", + "resolved": "http://registry.npmjs.org/readable-stream/-/readable-stream-2.3.6.tgz", "integrity": "sha512-tQtKA9WIAhBF3+VLAseyMqZeBjW0AHJoxOtYqSUZNJxauErmLbVm2FW1y+J/YA9dUrAC39ITejlZWhVIwawkKw==", "requires": { "core-util-is": "1.0.2", @@ -454,7 +492,7 @@ "requires": { "component-emitter": "1.2.1", "cookiejar": "2.1.1", - "debug": "3.1.0", + "debug": "3.2.5", "extend": "3.0.1", "form-data": "2.3.1", "formidable": "1.2.1", @@ -481,6 +519,13 @@ "uuid": "3.1.0", "xml2js": "0.4.17", "xmlbuilder": "4.2.1" + }, + "dependencies": { + "uuid": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-3.1.0.tgz", + "integrity": "sha512-DIWtzUkw04M4k3bf1IcpS2tngXEL26YUD2M0tMDUpnUrz2hgzUBlD55a4FjdLGPvfHxS6uluGWvaVEqgBcVa+g==" + } } }, "aws-sign2": { @@ -489,9 +534,9 @@ "integrity": "sha1-tG6JCTSpWR8tL2+G1+ap8bP+dqg=" }, "aws4": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/aws4/-/aws4-1.6.0.tgz", - "integrity": "sha1-g+9cqGCysy5KDe7e6MdxudtXRx4=" + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/aws4/-/aws4-1.8.0.tgz", + "integrity": "sha512-ReZxvNHIOv88FlT7rxcXIIC0fPt4KZqZbOlivyWtXLt8ESx84zd3kMC6iK5jVeS2qt+g7ftS7ye4fi06X5rtRQ==" }, "axios": { "version": "0.17.1", @@ -1553,7 +1598,7 @@ }, "babel-runtime": { "version": "6.6.1", - "resolved": "https://registry.npmjs.org/babel-runtime/-/babel-runtime-6.6.1.tgz", + "resolved": "http://registry.npmjs.org/babel-runtime/-/babel-runtime-6.6.1.tgz", "integrity": "sha1-eIuUtvY04luRvWxd9y1GdFevsAA=", "requires": { "core-js": "2.5.1" @@ -1662,9 +1707,9 @@ "integrity": "sha512-dwVUVIXsBZXwTuwnXI9RK8sBmgq09NDHzyR9SAph9eqk76gKK2JSQmZARC2zRC81JC2QTtxD0ARU5qTS25gIGw==" }, "bcrypt-pbkdf": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/bcrypt-pbkdf/-/bcrypt-pbkdf-1.0.1.tgz", - "integrity": "sha1-Y7xdy2EzG5K8Bf1SiVPDNGKgb40=", + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/bcrypt-pbkdf/-/bcrypt-pbkdf-1.0.2.tgz", + "integrity": "sha1-pDAdOJtqQ/m2f/PKEaP2Y342Dp4=", "optional": true, "requires": { "tweetnacl": "0.14.5" @@ -1676,6 +1721,16 @@ "integrity": "sha1-5tXqjF2tABMEpwsiY4RH9pyy+Ak=", "dev": true }, + "bin-protocol": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/bin-protocol/-/bin-protocol-3.0.4.tgz", + "integrity": "sha1-RlqdNQb+sOEmtStbIWDZNuFbJ/Q=", + "requires": { + "lodash": "4.17.4", + "long": "3.2.0", + "protocol-buffers-schema": "3.3.2" + } + }, "binary-extensions": { "version": "1.10.0", "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-1.10.0.tgz", @@ -1717,14 +1772,6 @@ "type-is": "1.6.15" } }, - "boom": { - "version": "4.3.1", - "resolved": "https://registry.npmjs.org/boom/-/boom-4.3.1.tgz", - "integrity": "sha1-T4owBctKfjiJ90kDD9JbluAdLjE=", - "requires": { - "hoek": "4.2.0" - } - }, "boxen": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/boxen/-/boxen-1.2.2.tgz", @@ -1851,6 +1898,11 @@ } } }, + "buffer-crc32": { + "version": "0.2.13", + "resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-0.2.13.tgz", + "integrity": "sha1-DTM+PwDqxQqhRUq9MO+MKl2ackI=" + }, "buffer-equal-constant-time": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", @@ -1943,6 +1995,15 @@ "type-detect": "1.0.0" } }, + "chai-as-promised": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/chai-as-promised/-/chai-as-promised-7.1.1.tgz", + "integrity": "sha512-azL6xMoi+uxu6z4rhWQ1jbdUhOMhis2PvscD/xjLqNMkv3BPPp2JyyuTHOrf9BOosGpNQ11v6BKv/g57RXbiaA==", + "dev": true, + "requires": { + "check-error": "1.0.2" + } + }, "chalk": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/chalk/-/chalk-1.1.3.tgz", @@ -1955,6 +2016,12 @@ "supports-color": "2.0.0" } }, + "check-error": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/check-error/-/check-error-1.0.2.tgz", + "integrity": "sha1-V00xLt2Iu13YkS6Sht1sCu1KrII=", + "dev": true + }, "check-more-types": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/check-more-types/-/check-more-types-2.3.0.tgz", @@ -1969,6 +2036,7 @@ "requires": { "anymatch": "1.3.2", "async-each": "1.0.1", + "fsevents": "1.2.4", "glob-parent": "2.0.0", "inherits": "2.0.3", "is-binary-path": "1.0.1", @@ -2200,6 +2268,11 @@ "xdg-basedir": "3.0.0" } }, + "connection-parse": { + "version": "0.0.7", + "resolved": "https://registry.npmjs.org/connection-parse/-/connection-parse-0.0.7.tgz", + "integrity": "sha1-GOcxiqsGppkmc3KxDFIm0locmmk=" + }, "contains-path": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/contains-path/-/contains-path-0.1.0.tgz", @@ -2297,24 +2370,6 @@ } } }, - "cryptiles": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/cryptiles/-/cryptiles-3.1.2.tgz", - "integrity": "sha1-qJ+7Ig9c4l7FboxKqKT9e1sNKf4=", - "requires": { - "boom": "5.2.0" - }, - "dependencies": { - "boom": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/boom/-/boom-5.2.0.tgz", - "integrity": "sha512-Z5BTk6ZRe4tXXQlkqftmsAUANpXmuwlsF5Oov8ThoMbQRzdGTA1ngYRW160GexgOgjsFOKJz0LYhoNi+2AMBUw==", - "requires": { - "hoek": "4.2.0" - } - } - } - }, "crypto-browserify": { "version": "1.0.9", "resolved": "https://registry.npmjs.org/crypto-browserify/-/crypto-browserify-1.0.9.tgz", @@ -2562,12 +2617,13 @@ "dev": true }, "ecc-jsbn": { - "version": "0.1.1", - "resolved": "https://registry.npmjs.org/ecc-jsbn/-/ecc-jsbn-0.1.1.tgz", - "integrity": "sha1-D8c6ntXw1Tw4GTOYUj735UN3dQU=", + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/ecc-jsbn/-/ecc-jsbn-0.1.2.tgz", + "integrity": "sha1-OoOpBOVDUyh4dMVkt1SThoSamMk=", "optional": true, "requires": { - "jsbn": "0.1.1" + "jsbn": "0.1.1", + "safer-buffer": "2.1.2" } }, "ecdsa-sig-formatter": { @@ -3112,7 +3168,7 @@ "resolved": "https://registry.npmjs.org/express-request-id/-/express-request-id-1.4.0.tgz", "integrity": "sha1-J3ssCUmAPmgQTJ1Fw+aJNPlr9aI=", "requires": { - "uuid": "3.1.0" + "uuid": "3.3.2" } }, "express-sanitizer": { @@ -3161,9 +3217,9 @@ } }, "fast-deep-equal": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-1.0.0.tgz", - "integrity": "sha1-liVqO8l1WV6zbYLpkp0GDYk0Of8=" + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-1.1.0.tgz", + "integrity": "sha1-wFNHeBfIa1HaqFPIHgWbcz0CNhQ=" }, "fast-json-stable-stringify": { "version": "2.0.0", @@ -3445,6 +3501,542 @@ "integrity": "sha1-FQStJSMVjKpA20onh8sBQRmU6k8=", "dev": true }, + "fsevents": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-1.2.4.tgz", + "integrity": "sha512-z8H8/diyk76B7q5wg+Ud0+CqzcAF3mBBI/bA5ne5zrRUUIvNkJY//D3BqyH571KuAC4Nr7Rw7CjWX4r0y9DvNg==", + "dev": true, + "optional": true, + "requires": { + "nan": "2.11.0", + "node-pre-gyp": "0.10.0" + }, + "dependencies": { + "abbrev": { + "version": "1.1.1", + "bundled": true, + "dev": true, + "optional": true + }, + "ansi-regex": { + "version": "2.1.1", + "bundled": true, + "dev": true + }, + "aproba": { + "version": "1.2.0", + "bundled": true, + "dev": true, + "optional": true + }, + "are-we-there-yet": { + "version": "1.1.4", + "bundled": true, + "dev": true, + "optional": true, + "requires": { + "delegates": "1.0.0", + "readable-stream": "2.3.6" + } + }, + "balanced-match": { + "version": "1.0.0", + "bundled": true, + "dev": true + }, + "brace-expansion": { + "version": "1.1.11", + "bundled": true, + "dev": true, + "requires": { + "balanced-match": "1.0.0", + "concat-map": "0.0.1" + } + }, + "chownr": { + "version": "1.0.1", + "bundled": true, + "dev": true, + "optional": true + }, + "code-point-at": { + "version": "1.1.0", + "bundled": true, + "dev": true + }, + "concat-map": { + "version": "0.0.1", + "bundled": true, + "dev": true + }, + "console-control-strings": { + "version": "1.1.0", + "bundled": true, + "dev": true + }, + "core-util-is": { + "version": "1.0.2", + "bundled": true, + "dev": true, + "optional": true + }, + "debug": { + "version": "2.6.9", + "bundled": true, + "dev": true, + "optional": true, + "requires": { + "ms": "2.0.0" + } + }, + "deep-extend": { + "version": "0.5.1", + "bundled": true, + "dev": true, + "optional": true + }, + "delegates": { + "version": "1.0.0", + "bundled": true, + "dev": true, + "optional": true + }, + "detect-libc": { + "version": "1.0.3", + "bundled": true, + "dev": true, + "optional": true + }, + "fs-minipass": { + "version": "1.2.5", + "bundled": true, + "dev": true, + "optional": true, + "requires": { + "minipass": "2.2.4" + } + }, + "fs.realpath": { + "version": "1.0.0", + "bundled": true, + "dev": true, + "optional": true + }, + "gauge": { + "version": "2.7.4", + "bundled": true, + "dev": true, + "optional": true, + "requires": { + "aproba": "1.2.0", + "console-control-strings": "1.1.0", + "has-unicode": "2.0.1", + "object-assign": "4.1.1", + "signal-exit": "3.0.2", + "string-width": "1.0.2", + "strip-ansi": "3.0.1", + "wide-align": "1.1.2" + } + }, + "glob": { + "version": "7.1.2", + "bundled": true, + "dev": true, + "optional": true, + "requires": { + "fs.realpath": "1.0.0", + "inflight": "1.0.6", + "inherits": "2.0.3", + "minimatch": "3.0.4", + "once": "1.4.0", + "path-is-absolute": "1.0.1" + } + }, + "has-unicode": { + "version": "2.0.1", + "bundled": true, + "dev": true, + "optional": true + }, + "iconv-lite": { + "version": "0.4.21", + "bundled": true, + "dev": true, + "optional": true, + "requires": { + "safer-buffer": "2.1.2" + } + }, + "ignore-walk": { + "version": "3.0.1", + "bundled": true, + "dev": true, + "optional": true, + "requires": { + "minimatch": "3.0.4" + } + }, + "inflight": { + "version": "1.0.6", + "bundled": true, + "dev": true, + "optional": true, + "requires": { + "once": "1.4.0", + "wrappy": "1.0.2" + } + }, + "inherits": { + "version": "2.0.3", + "bundled": true, + "dev": true + }, + "ini": { + "version": "1.3.5", + "bundled": true, + "dev": true, + "optional": true + }, + "is-fullwidth-code-point": { + "version": "1.0.0", + "bundled": true, + "dev": true, + "requires": { + "number-is-nan": "1.0.1" + } + }, + "isarray": { + "version": "1.0.0", + "bundled": true, + "dev": true, + "optional": true + }, + "minimatch": { + "version": "3.0.4", + "bundled": true, + "dev": true, + "requires": { + "brace-expansion": "1.1.11" + } + }, + "minimist": { + "version": "0.0.8", + "bundled": true, + "dev": true + }, + "minipass": { + "version": "2.2.4", + "bundled": true, + "dev": true, + "requires": { + "safe-buffer": "5.1.1", + "yallist": "3.0.2" + } + }, + "minizlib": { + "version": "1.1.0", + "bundled": true, + "dev": true, + "optional": true, + "requires": { + "minipass": "2.2.4" + } + }, + "mkdirp": { + "version": "0.5.1", + "bundled": true, + "dev": true, + "requires": { + "minimist": "0.0.8" + } + }, + "ms": { + "version": "2.0.0", + "bundled": true, + "dev": true, + "optional": true + }, + "nan": { + "version": "2.11.0", + "resolved": "https://registry.npmjs.org/nan/-/nan-2.11.0.tgz", + "integrity": "sha512-F4miItu2rGnV2ySkXOQoA8FKz/SR2Q2sWP0sbTxNxz/tuokeC8WxOhPMcwi0qIyGtVn/rrSeLbvVkznqCdwYnw==", + "dev": true, + "optional": true + }, + "needle": { + "version": "2.2.0", + "bundled": true, + "dev": true, + "optional": true, + "requires": { + "debug": "2.6.9", + "iconv-lite": "0.4.21", + "sax": "1.2.4" + } + }, + "node-pre-gyp": { + "version": "0.10.0", + "bundled": true, + "dev": true, + "optional": true, + "requires": { + "detect-libc": "1.0.3", + "mkdirp": "0.5.1", + "needle": "2.2.0", + "nopt": "4.0.1", + "npm-packlist": "1.1.10", + "npmlog": "4.1.2", + "rc": "1.2.7", + "rimraf": "2.6.2", + "semver": "5.5.0", + "tar": "4.4.1" + } + }, + "nopt": { + "version": "4.0.1", + "bundled": true, + "dev": true, + "optional": true, + "requires": { + "abbrev": "1.1.1", + "osenv": "0.1.5" + } + }, + "npm-bundled": { + "version": "1.0.3", + "bundled": true, + "dev": true, + "optional": true + }, + "npm-packlist": { + "version": "1.1.10", + "bundled": true, + "dev": true, + "optional": true, + "requires": { + "ignore-walk": "3.0.1", + "npm-bundled": "1.0.3" + } + }, + "npmlog": { + "version": "4.1.2", + "bundled": true, + "dev": true, + "optional": true, + "requires": { + "are-we-there-yet": "1.1.4", + "console-control-strings": "1.1.0", + "gauge": "2.7.4", + "set-blocking": "2.0.0" + } + }, + "number-is-nan": { + "version": "1.0.1", + "bundled": true, + "dev": true + }, + "object-assign": { + "version": "4.1.1", + "bundled": true, + "dev": true, + "optional": true + }, + "once": { + "version": "1.4.0", + "bundled": true, + "dev": true, + "requires": { + "wrappy": "1.0.2" + } + }, + "os-homedir": { + "version": "1.0.2", + "bundled": true, + "dev": true, + "optional": true + }, + "os-tmpdir": { + "version": "1.0.2", + "bundled": true, + "dev": true, + "optional": true + }, + "osenv": { + "version": "0.1.5", + "bundled": true, + "dev": true, + "optional": true, + "requires": { + "os-homedir": "1.0.2", + "os-tmpdir": "1.0.2" + } + }, + "path-is-absolute": { + "version": "1.0.1", + "bundled": true, + "dev": true, + "optional": true + }, + "process-nextick-args": { + "version": "2.0.0", + "bundled": true, + "dev": true, + "optional": true + }, + "rc": { + "version": "1.2.7", + "bundled": true, + "dev": true, + "optional": true, + "requires": { + "deep-extend": "0.5.1", + "ini": "1.3.5", + "minimist": "1.2.0", + "strip-json-comments": "2.0.1" + }, + "dependencies": { + "minimist": { + "version": "1.2.0", + "bundled": true, + "dev": true, + "optional": true + } + } + }, + "readable-stream": { + "version": "2.3.6", + "bundled": true, + "dev": true, + "optional": true, + "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" + } + }, + "rimraf": { + "version": "2.6.2", + "bundled": true, + "dev": true, + "optional": true, + "requires": { + "glob": "7.1.2" + } + }, + "safe-buffer": { + "version": "5.1.1", + "bundled": true, + "dev": true + }, + "safer-buffer": { + "version": "2.1.2", + "bundled": true, + "dev": true, + "optional": true + }, + "sax": { + "version": "1.2.4", + "bundled": true, + "dev": true, + "optional": true + }, + "semver": { + "version": "5.5.0", + "bundled": true, + "dev": true, + "optional": true + }, + "set-blocking": { + "version": "2.0.0", + "bundled": true, + "dev": true, + "optional": true + }, + "signal-exit": { + "version": "3.0.2", + "bundled": true, + "dev": true, + "optional": true + }, + "string-width": { + "version": "1.0.2", + "bundled": true, + "dev": true, + "requires": { + "code-point-at": "1.1.0", + "is-fullwidth-code-point": "1.0.0", + "strip-ansi": "3.0.1" + } + }, + "string_decoder": { + "version": "1.1.1", + "bundled": true, + "dev": true, + "optional": true, + "requires": { + "safe-buffer": "5.1.1" + } + }, + "strip-ansi": { + "version": "3.0.1", + "bundled": true, + "dev": true, + "requires": { + "ansi-regex": "2.1.1" + } + }, + "strip-json-comments": { + "version": "2.0.1", + "bundled": true, + "dev": true, + "optional": true + }, + "tar": { + "version": "4.4.1", + "bundled": true, + "dev": true, + "optional": true, + "requires": { + "chownr": "1.0.1", + "fs-minipass": "1.2.5", + "minipass": "2.2.4", + "minizlib": "1.1.0", + "mkdirp": "0.5.1", + "safe-buffer": "5.1.1", + "yallist": "3.0.2" + } + }, + "util-deprecate": { + "version": "1.0.2", + "bundled": true, + "dev": true, + "optional": true + }, + "wide-align": { + "version": "1.1.2", + "bundled": true, + "dev": true, + "optional": true, + "requires": { + "string-width": "1.0.2" + } + }, + "wrappy": { + "version": "1.0.2", + "bundled": true, + "dev": true + }, + "yallist": { + "version": "3.0.2", + "bundled": true, + "dev": true + } + } + }, "function-bind": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.1.tgz", @@ -3898,21 +4490,21 @@ "integrity": "sha1-qUwiJOvKwEeCoNkDVSHyRzW37JI=" }, "har-validator": { - "version": "5.0.3", - "resolved": "https://registry.npmjs.org/har-validator/-/har-validator-5.0.3.tgz", - "integrity": "sha1-ukAsJmGU8VlW7xXg/PJCmT9qff0=", + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/har-validator/-/har-validator-5.1.0.tgz", + "integrity": "sha512-+qnmNjI4OfH2ipQ9VQOw23bBd/ibtfbVdK2fYbY4acTDqKTW/YDp9McimZdDbG8iV9fZizUqQMD5xvriB146TA==", "requires": { - "ajv": "5.5.1", + "ajv": "5.5.2", "har-schema": "2.0.0" }, "dependencies": { "ajv": { - "version": "5.5.1", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-5.5.1.tgz", - "integrity": "sha1-s4u4h22ehr7plJVqBOch6IskjrI=", + "version": "5.5.2", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-5.5.2.tgz", + "integrity": "sha1-c7Xuyj+rZT49P5Qis0GtQiBdyWU=", "requires": { "co": "4.6.0", - "fast-deep-equal": "1.0.0", + "fast-deep-equal": "1.1.0", "fast-json-stable-stringify": "2.0.0", "json-schema-traverse": "0.3.1" } @@ -3951,15 +4543,13 @@ "sparkles": "1.0.0" } }, - "hawk": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/hawk/-/hawk-6.0.2.tgz", - "integrity": "sha512-miowhl2+U7Qle4vdLqDdPt9m09K6yZhkLDTWGoUiUzrQCn+mHHSmfJgAyGaLRZbPmTqfFFjRV1QWCW0VWUJBbQ==", + "hashring": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/hashring/-/hashring-3.2.0.tgz", + "integrity": "sha1-/aTv3oqiLNuX+x0qZeiEAeHBRM4=", "requires": { - "boom": "4.3.1", - "cryptiles": "3.1.2", - "hoek": "4.2.0", - "sntp": "2.1.0" + "connection-parse": "0.0.7", + "simple-lru-cache": "0.0.2" } }, "hoek": { @@ -4018,7 +4608,7 @@ "requires": { "assert-plus": "1.0.0", "jsprim": "1.4.1", - "sshpk": "1.13.1" + "sshpk": "1.14.2" } }, "iconv-lite": { @@ -4039,11 +4629,11 @@ }, "dependencies": { "debug": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/debug/-/debug-3.1.0.tgz", - "integrity": "sha512-OX8XqP7/1a9cqkxYw2yXss15f26NKWBpDXQd0/uK/KPqdQhxbPa994hnzjcE2VqQpDslf55723cKPUOGSmMY3g==", + "version": "3.2.5", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.5.tgz", + "integrity": "sha512-D61LaDQPQkxJ5AUM2mbSJRbPkNs/TmdmOeLAi1hgDkpDfIfetSrjmWhccwtuResSwMbACjx/xXQofvM9CE/aeg==", "requires": { - "ms": "2.0.0" + "ms": "2.1.1" } }, "formidable": { @@ -4056,6 +4646,11 @@ "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", "integrity": "sha1-u5NdSFgsuhaMBoNJV6VKPgcSTxE=" }, + "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==" + }, "process-nextick-args": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.0.tgz", @@ -4063,7 +4658,7 @@ }, "readable-stream": { "version": "2.3.6", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.6.tgz", + "resolved": "http://registry.npmjs.org/readable-stream/-/readable-stream-2.3.6.tgz", "integrity": "sha512-tQtKA9WIAhBF3+VLAseyMqZeBjW0AHJoxOtYqSUZNJxauErmLbVm2FW1y+J/YA9dUrAC39ITejlZWhVIwawkKw==", "requires": { "core-util-is": "1.0.2", @@ -4090,7 +4685,7 @@ "requires": { "component-emitter": "1.2.1", "cookiejar": "2.1.1", - "debug": "3.1.0", + "debug": "3.2.5", "extend": "3.0.1", "form-data": "2.3.1", "formidable": "1.2.1", @@ -4848,16 +5443,16 @@ } }, "jwks-rsa": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/jwks-rsa/-/jwks-rsa-1.2.1.tgz", - "integrity": "sha512-xg+fw7FOV4eGdDIEMqQJvPLmFv85h4uN+j/GKwJZAxlCrDQpM8ov1F709xKGEp/dG3l4TUxoSOeN6YK7+KpinQ==", + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/jwks-rsa/-/jwks-rsa-1.3.0.tgz", + "integrity": "sha512-9q+d5VffK/FvFAjuXoddrq7zQybFSINV4mcwJJExGKXGyjWWpTt3vsn/aX33aB0heY02LK0qSyicdtRK0gVTig==", "requires": { "@types/express-jwt": "0.0.34", "debug": "2.6.9", - "limiter": "1.1.2", - "lru-memoizer": "1.11.1", + "limiter": "1.1.3", + "lru-memoizer": "1.12.0", "ms": "2.0.0", - "request": "2.83.0" + "request": "2.88.0" } }, "jws": { @@ -4924,7 +5519,7 @@ "dependencies": { "lodash": { "version": "3.9.3", - "resolved": "https://registry.npmjs.org/lodash/-/lodash-3.9.3.tgz", + "resolved": "http://registry.npmjs.org/lodash/-/lodash-3.9.3.tgz", "integrity": "sha1-AVnoaDL+/8bWHYUrEqlTuZSWvTI=" }, "semver": { @@ -4945,12 +5540,19 @@ } }, "libpq": { - "version": "1.8.7", - "resolved": "https://registry.npmjs.org/libpq/-/libpq-1.8.7.tgz", - "integrity": "sha1-wt6xIeKPf4S9OyRRr/9otmY+dPk=", + "version": "1.8.8", + "resolved": "https://registry.npmjs.org/libpq/-/libpq-1.8.8.tgz", + "integrity": "sha512-0TVzqkbAZZiM8JJy5sagRyXOkvU9zTBlgGX6YdzuWECobc5F81Tp6uuS+djMZrnB5YN4O/ff52hsvXYBRW2gdQ==", "requires": { "bindings": "1.2.1", - "nan": "2.7.0" + "nan": "2.11.0" + }, + "dependencies": { + "nan": { + "version": "2.11.0", + "resolved": "https://registry.npmjs.org/nan/-/nan-2.11.0.tgz", + "integrity": "sha512-F4miItu2rGnV2ySkXOQoA8FKz/SR2Q2sWP0sbTxNxz/tuokeC8WxOhPMcwi0qIyGtVn/rrSeLbvVkznqCdwYnw==" + } } }, "liftoff": { @@ -4985,9 +5587,9 @@ } }, "limiter": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/limiter/-/limiter-1.1.2.tgz", - "integrity": "sha512-JIKZ0xb6fZZYa3deZ0BgXCgX6HgV8Nx3mFGeFHmFWW8Fb2c08e0CyE+G3nalpD0xGvGssjGb1UdFr+PprxZEbw==" + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/limiter/-/limiter-1.1.3.tgz", + "integrity": "sha512-zrycnIMsLw/3ZxTbW7HCez56rcFGecWTx5OZNplzcXUUmJLmoYArC6qdJzmAN5BWiNXGcpjhF9RQ1HSv5zebEw==" }, "load-json-file": { "version": "2.0.0", @@ -5254,6 +5856,11 @@ "integrity": "sha1-fD2mL/yzDw9agKJWbKJORdigHzE=", "dev": true }, + "long": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/long/-/long-3.2.0.tgz", + "integrity": "sha1-2CG3E4yhy1gcFymQ7xTbIAtcR0s=" + }, "longest": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/longest/-/longest-1.0.1.tgz", @@ -5282,21 +5889,16 @@ "dev": true }, "lru-memoizer": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/lru-memoizer/-/lru-memoizer-1.11.1.tgz", - "integrity": "sha1-BpP2EAWTkUwC4ZK/m42TiEy/UNM=", + "version": "1.12.0", + "resolved": "https://registry.npmjs.org/lru-memoizer/-/lru-memoizer-1.12.0.tgz", + "integrity": "sha1-7+ZXBsyKnMZT+A8NWm6jitlQ41I=", "requires": { "lock": "0.1.4", - "lodash": "4.5.1", + "lodash": "4.17.4", "lru-cache": "4.0.2", "very-fast-args": "1.1.0" }, "dependencies": { - "lodash": { - "version": "4.5.1", - "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.5.1.tgz", - "integrity": "sha1-gOigdMpfOJOmscELKmNkktcQwxY=" - }, "lru-cache": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-4.0.2.tgz", @@ -5376,6 +5978,15 @@ "timers-ext": "0.1.2" } }, + "memwatch-next": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/memwatch-next/-/memwatch-next-0.3.0.tgz", + "integrity": "sha1-IREFD5qQbgqi1ypOwPAInHhyb48=", + "requires": { + "bindings": "1.2.1", + "nan": "2.7.0" + } + }, "merge-descriptors": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.1.tgz", @@ -5568,6 +6179,11 @@ "duplexer2": "0.0.2" } }, + "murmur-hash-js": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/murmur-hash-js/-/murmur-hash-js-1.0.0.tgz", + "integrity": "sha1-UEEEkmnJZjPIZjhpYLL0KJ515bA=" + }, "mute-stream": { "version": "0.0.5", "resolved": "https://registry.npmjs.org/mute-stream/-/mute-stream-0.0.5.tgz", @@ -5619,6 +6235,38 @@ "integrity": "sha1-yobR/ogoFpsBICCOPchCS524NCw=", "dev": true }, + "nice-simple-logger": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/nice-simple-logger/-/nice-simple-logger-1.0.1.tgz", + "integrity": "sha1-D55khSe+e+PkmrdvqMjAmK+VG/Y=", + "requires": { + "lodash": "4.17.4" + } + }, + "no-kafka": { + "version": "3.2.10", + "resolved": "https://registry.npmjs.org/no-kafka/-/no-kafka-3.2.10.tgz", + "integrity": "sha1-0sq8QwZbSS24wVyiOK6V8WgIGvU=", + "requires": { + "@types/bluebird": "3.5.0", + "@types/lodash": "4.14.116", + "bin-protocol": "3.0.4", + "bluebird": "3.5.1", + "buffer-crc32": "0.2.13", + "hashring": "3.2.0", + "lodash": "4.17.5", + "murmur-hash-js": "1.0.0", + "nice-simple-logger": "1.0.1", + "wrr-pool": "1.1.3" + }, + "dependencies": { + "lodash": { + "version": "4.17.5", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.5.tgz", + "integrity": "sha512-svL3uiZf1RwhH+cWrfZn3A4+U58wbP0tGVTLQPbjplZxZ8ROD9VLuNgsRniTlLe7OlSqR79RUehXgpBW/s0IQw==" + } + } + }, "nodemon": { "version": "1.12.1", "resolved": "https://registry.npmjs.org/nodemon/-/nodemon-1.12.1.tgz", @@ -5683,9 +6331,9 @@ "dev": true }, "oauth-sign": { - "version": "0.8.2", - "resolved": "https://registry.npmjs.org/oauth-sign/-/oauth-sign-0.8.2.tgz", - "integrity": "sha1-Rqarfwrq2N6unsBWV4C31O/rnUM=" + "version": "0.9.0", + "resolved": "https://registry.npmjs.org/oauth-sign/-/oauth-sign-0.9.0.tgz", + "integrity": "sha512-fexhUFFPTGV8ybAtSIGbV6gOkSv8UtRbDBnAyLQw4QPKkgNlsH2ByPGtMUqdWkos6YCRmAqViwgZrJc/mRDzZQ==" }, "object-assign": { "version": "4.1.1", @@ -6040,7 +6688,7 @@ "resolved": "https://registry.npmjs.org/pg-native/-/pg-native-1.10.1.tgz", "integrity": "sha1-lOYcy7hafzQ2suUmMVx1gRB/5Aw=", "requires": { - "libpq": "1.8.7", + "libpq": "1.8.8", "pg-types": "1.6.0", "readable-stream": "1.0.31" }, @@ -6201,6 +6849,11 @@ "integrity": "sha1-IS1b/hMYMGpCD2QCuOJv85ZHqEk=", "dev": true }, + "protocol-buffers-schema": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/protocol-buffers-schema/-/protocol-buffers-schema-3.3.2.tgz", + "integrity": "sha512-Xdayp8sB/mU+sUV4G7ws8xtYMGdQnxbeIfLjyO9TZZRJdztBGhlmbI5x1qcY4TG5hBkIKGnc28i7nXxaugu88w==" + }, "proxy-addr": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.2.tgz", @@ -6224,6 +6877,11 @@ "resolved": "https://registry.npmjs.org/pseudomap/-/pseudomap-1.0.2.tgz", "integrity": "sha1-8FKijacOYYkX7wqKw0wa5aaChrM=" }, + "psl": { + "version": "1.1.29", + "resolved": "https://registry.npmjs.org/psl/-/psl-1.1.29.tgz", + "integrity": "sha512-AeUmQ0oLN02flVHXWh9sSJF7mcdFq0ppid/JkErufc3hGIV/AMa8Fo9VgDo/cT2jFdOWoFvHp90qqBH54W+gjQ==" + }, "punycode": { "version": "1.3.2", "resolved": "https://registry.npmjs.org/punycode/-/punycode-1.3.2.tgz", @@ -6583,32 +7241,78 @@ "dev": true }, "request": { - "version": "2.83.0", - "resolved": "https://registry.npmjs.org/request/-/request-2.83.0.tgz", - "integrity": "sha512-lR3gD69osqm6EYLk9wB/G1W/laGWjzH90t1vEa2xuxHD5KUrSzp9pUSfTm+YC5Nxt2T8nMPEvKlhbQayU7bgFw==", + "version": "2.88.0", + "resolved": "https://registry.npmjs.org/request/-/request-2.88.0.tgz", + "integrity": "sha512-NAqBSrijGLZdM0WZNsInLJpkJokL72XYjUpnB0iwsRgxh7dB6COrHnTBNwN0E+lHDAJzu7kLAkDeY08z2/A0hg==", "requires": { "aws-sign2": "0.7.0", - "aws4": "1.6.0", + "aws4": "1.8.0", "caseless": "0.12.0", - "combined-stream": "1.0.5", - "extend": "3.0.1", + "combined-stream": "1.0.6", + "extend": "3.0.2", "forever-agent": "0.6.1", - "form-data": "2.3.1", - "har-validator": "5.0.3", - "hawk": "6.0.2", + "form-data": "2.3.2", + "har-validator": "5.1.0", "http-signature": "1.2.0", "is-typedarray": "1.0.0", "isstream": "0.1.2", "json-stringify-safe": "5.0.1", - "mime-types": "2.1.17", - "oauth-sign": "0.8.2", + "mime-types": "2.1.20", + "oauth-sign": "0.9.0", "performance-now": "2.1.0", - "qs": "6.5.1", - "safe-buffer": "5.1.1", - "stringstream": "0.0.5", - "tough-cookie": "2.3.3", + "qs": "6.5.2", + "safe-buffer": "5.1.2", + "tough-cookie": "2.4.3", "tunnel-agent": "0.6.0", - "uuid": "3.1.0" + "uuid": "3.3.2" + }, + "dependencies": { + "combined-stream": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.6.tgz", + "integrity": "sha1-cj599ugBrFYTETp+RFqbactjKBg=", + "requires": { + "delayed-stream": "1.0.0" + } + }, + "extend": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", + "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==" + }, + "form-data": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-2.3.2.tgz", + "integrity": "sha1-SXBJi+YEwgwAXU9cI67NIda0kJk=", + "requires": { + "asynckit": "0.4.0", + "combined-stream": "1.0.6", + "mime-types": "2.1.20" + } + }, + "mime-db": { + "version": "1.36.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.36.0.tgz", + "integrity": "sha512-L+xvyD9MkoYMXb1jAmzI/lWYAxAMCPvIBSWur0PZ5nOf5euahRLVqH//FKW9mWp2lkqUgYiXPgkzfMUFi4zVDw==" + }, + "mime-types": { + "version": "2.1.20", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.20.tgz", + "integrity": "sha512-HrkrPaP9vGuWbLK1B1FfgAkbqNjIuy4eHlIYnFi7kamZyLLrGlo2mpcx0bBmNpKqBtYtAfGbodDddIgddSJC2A==", + "requires": { + "mime-db": "1.36.0" + } + }, + "qs": { + "version": "6.5.2", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.5.2.tgz", + "integrity": "sha512-N5ZAX4/LxJmF+7wN74pUD6qAh9/wnvdQcjq9TZjevvXzSUo7bfmw91saqMjzGS2xq91/odN2dW/WOl7qQHNDGA==" + }, + "safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==" + } } }, "require-directory": { @@ -6721,6 +7425,11 @@ "integrity": "sha1-gaCY9Efku8P/MxKiQ1IbwGDvWRE=", "optional": true }, + "safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==" + }, "samsam": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/samsam/-/samsam-1.1.2.tgz", @@ -6796,7 +7505,7 @@ "shimmer": "1.1.0", "terraformer-wkt-parser": "1.1.2", "toposort-class": "1.0.1", - "uuid": "3.1.0", + "uuid": "3.3.2", "validator": "5.7.0", "wkx": "0.2.0" }, @@ -7007,6 +7716,11 @@ "integrity": "sha1-tf3AjxKH6hF4Yo5BXiUTK3NkbG0=", "dev": true }, + "simple-lru-cache": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/simple-lru-cache/-/simple-lru-cache-0.0.2.tgz", + "integrity": "sha1-1ZzDoZPBpdAyD4Tucy9uRxPlEd0=" + }, "sinon": { "version": "1.17.7", "resolved": "https://registry.npmjs.org/sinon/-/sinon-1.17.7.tgz", @@ -7046,14 +7760,6 @@ "integrity": "sha1-7b+JA/ZvfOL46v1s7tZeJkyDGzU=", "dev": true }, - "sntp": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/sntp/-/sntp-2.1.0.tgz", - "integrity": "sha512-FL1b58BDrqS3A11lJ0zEdnJ3UOKqVxawAkF3k7F0CVN7VQ34aZrV+G8BZ1WC9ZL7NyrwsW0oviwsWDgRuVYtJg==", - "requires": { - "hoek": "4.2.0" - } - }, "source-map": { "version": "0.5.7", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.5.7.tgz", @@ -7111,17 +7817,18 @@ "dev": true }, "sshpk": { - "version": "1.13.1", - "resolved": "https://registry.npmjs.org/sshpk/-/sshpk-1.13.1.tgz", - "integrity": "sha1-US322mKHFEMW3EwY/hzx2UBzm+M=", + "version": "1.14.2", + "resolved": "https://registry.npmjs.org/sshpk/-/sshpk-1.14.2.tgz", + "integrity": "sha1-xvxhZIo9nE52T9P8306hBeSSupg=", "requires": { - "asn1": "0.2.3", + "asn1": "0.2.4", "assert-plus": "1.0.0", - "bcrypt-pbkdf": "1.0.1", + "bcrypt-pbkdf": "1.0.2", "dashdash": "1.14.1", - "ecc-jsbn": "0.1.1", + "ecc-jsbn": "0.1.2", "getpass": "0.1.7", "jsbn": "0.1.1", + "safer-buffer": "2.1.2", "tweetnacl": "0.14.5" } }, @@ -7144,11 +7851,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", @@ -7160,10 +7862,10 @@ "strip-ansi": "3.0.1" } }, - "stringstream": { - "version": "0.0.5", - "resolved": "https://registry.npmjs.org/stringstream/-/stringstream-0.0.5.tgz", - "integrity": "sha1-TkhM1N5aC7vuGORjB3EKioFiGHg=" + "string_decoder": { + "version": "0.10.31", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-0.10.31.tgz", + "integrity": "sha1-YuIDvEF2bGwoyfyEMB2rHFMQ+pQ=" }, "strip-ansi": { "version": "3.0.1", @@ -7376,12 +8078,12 @@ "tc-core-library-js": { "version": "github:appirio-tech/tc-core-library-js#df1f5c1a5578d3d1e475bfb4a7413d9dec25525a", "requires": { - "auth0-js": "9.6.0", + "auth0-js": "9.7.3", "axios": "0.12.0", "bunyan": "1.8.12", "config": "1.27.0", "jsonwebtoken": "7.4.3", - "jwks-rsa": "1.2.1", + "jwks-rsa": "1.3.0", "le_node": "1.7.1", "lodash": "4.17.4", "millisecond": "0.1.2" @@ -7389,7 +8091,7 @@ "dependencies": { "axios": { "version": "0.12.0", - "resolved": "https://registry.npmjs.org/axios/-/axios-0.12.0.tgz", + "resolved": "http://registry.npmjs.org/axios/-/axios-0.12.0.tgz", "integrity": "sha1-uQewIhzDTsHJ+sGOx/B935V4W6Q=", "requires": { "follow-redirects": "0.0.7" @@ -7397,7 +8099,7 @@ }, "follow-redirects": { "version": "0.0.7", - "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-0.0.7.tgz", + "resolved": "http://registry.npmjs.org/follow-redirects/-/follow-redirects-0.0.7.tgz", "integrity": "sha1-NLkLqyqRGqNHVx2pDyK9NuzYqRk=", "requires": { "debug": "2.6.9", @@ -7416,7 +8118,7 @@ }, "joi": { "version": "6.10.1", - "resolved": "https://registry.npmjs.org/joi/-/joi-6.10.1.tgz", + "resolved": "http://registry.npmjs.org/joi/-/joi-6.10.1.tgz", "integrity": "sha1-TVDDGAeRIgAP5fFq8f+OGRe3fgY=", "requires": { "hoek": "2.16.3", @@ -7602,10 +8304,11 @@ } }, "tough-cookie": { - "version": "2.3.3", - "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-2.3.3.tgz", - "integrity": "sha1-C2GKVWW23qkL80JdBNVe3EdadWE=", + "version": "2.4.3", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-2.4.3.tgz", + "integrity": "sha512-Q5srk/4vDM54WJsJio3XNn6K2sCG+CQ8G5Wz6bZhRZoAe/+TxjWB/GlFAnYEbkYVlON9FMk/fE3h2RLpPXo4lQ==", "requires": { + "psl": "1.1.29", "punycode": "1.4.1" }, "dependencies": { @@ -7872,9 +8575,9 @@ "integrity": "sha1-n5VxD1CiZ5R7LMwSR0HBAoQn5xM=" }, "uuid": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-3.1.0.tgz", - "integrity": "sha512-DIWtzUkw04M4k3bf1IcpS2tngXEL26YUD2M0tMDUpnUrz2hgzUBlD55a4FjdLGPvfHxS6uluGWvaVEqgBcVa+g==" + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-3.3.2.tgz", + "integrity": "sha512-yXJmeNaw3DnnKAOKJE51sL/ZaYfWJRl1pK9dr19YFCu0ObS231AB1/LbqTKRAQ5kw8A90rA6fr4riOUpTZvQZA==" }, "v8flags": { "version": "2.1.1", @@ -8096,6 +8799,14 @@ "signal-exit": "3.0.2" } }, + "wrr-pool": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/wrr-pool/-/wrr-pool-1.1.3.tgz", + "integrity": "sha1-/a0i8uofMDY//l14HPeUl6d/8H4=", + "requires": { + "lodash": "4.17.4" + } + }, "xdg-basedir": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/xdg-basedir/-/xdg-basedir-3.0.0.tgz", diff --git a/package.json b/package.json index 459d09cc..008d7ad1 100644 --- a/package.json +++ b/package.json @@ -16,7 +16,7 @@ "prestart": "npm run -s build", "start": "node dist", "start:dev": "NODE_ENV=development PORT=8001 nodemon -w src --exec \"babel-node src --presets es2015\" | ./node_modules/.bin/bunyan", - "test": "NODE_ENV=test npm run lint && NODE_ENV=test npm run sync:es && NODE_ENV=test npm run sync:db && NODE_ENV=test ./node_modules/.bin/istanbul cover ./node_modules/mocha/bin/_mocha -- --timeout 5000 --compilers js:babel-core/register $(find src -path '*spec.js*')", + "test": "NODE_ENV=test npm run lint && NODE_ENV=test npm run sync:es && NODE_ENV=test npm run sync:db && NODE_ENV=test ./node_modules/.bin/istanbul cover ./node_modules/mocha/bin/_mocha -- --timeout 10000 --compilers js:babel-core/register $(find src -path '*spec.js*')", "test:watch": "NODE_ENV=test ./node_modules/.bin/mocha -w --compilers js:babel-core/register $(find src -path '*spec.js*')", "seed": "babel-node src/tests/seed.js --presets es2015" }, @@ -52,8 +52,10 @@ "joi": "^8.0.5", "jsonwebtoken": "^8.3.0", "lodash": "^4.16.4", + "memwatch-next": "^0.3.0", "method-override": "^2.3.9", "moment": "^2.22.2", + "no-kafka": "^3.2.10", "pg": "^4.5.5", "pg-native": "^1.10.1", "sequelize": "^3.23.0", @@ -70,6 +72,7 @@ "babel-preset-es2015": "^6.9.0", "bunyan": "^1.8.1", "chai": "^3.5.0", + "chai-as-promised": "^7.1.1", "eslint": "^3.16.1", "eslint-config-airbnb-base": "^11.1.0", "eslint-plugin-import": "^2.2.0", diff --git a/postman.json b/postman.json index ed8a690f..8f6bdcc6 100644 --- a/postman.json +++ b/postman.json @@ -1,8 +1,7 @@ { "info": { + "_postman_id": "160fcac7-f74a-4047-a4e4-b53f08d991c5", "name": "tc-project-service", - "_postman_id": "63bb8939-b1c0-0c3c-ad9d-68e63063eda7", - "description": "", "schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json" }, "item": [ @@ -371,7 +370,6 @@ }, { "name": "Project With TemplateId issue", - "description": "", "item": [ { "name": "Create project with templateId (not existed)", @@ -821,7 +819,6 @@ }, { "name": "Projects", - "description": "Requests for all things projects.", "item": [ { "name": "Create project without payload", @@ -919,6 +916,38 @@ }, "response": [] }, + { + "name": "Create project by inactive user", + "request": { + "method": "POST", + "header": [ + { + "key": "Authorization", + "value": "Bearer userId_{{inactive-userId}}" + }, + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n\t\"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": { + "raw": "{{api-url}}/v4/projects", + "host": [ + "{{api-url}}" + ], + "path": [ + "v4", + "projects" + ] + }, + "description": "Valid request body. Project should be created successfully." + }, + "response": [] + }, { "name": "Get project by id", "request": { @@ -1732,7 +1761,8 @@ }, "response": [] } - ] + ], + "description": "Requests for all things projects." }, { "name": "EventHandling and Integration with Direct Project API", @@ -2029,6 +2059,72 @@ }, "response": [] }, + { + "name": "Create Phase with order", + "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\t\"order\": 1\n\t}\n}" + }, + "url": { + "raw": "{{api-url}}/v4/projects/1/phases", + "host": [ + "{{api-url}}" + ], + "path": [ + "v4", + "projects", + "1", + "phases" + ] + } + }, + "response": [] + }, + { + "name": "Create Phase with productTemplateId", + "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\t\"order\": 1,\n\t\t\"productTemplateId\": 1\n\t}\n}" + }, + "url": { + "raw": "{{api-url}}/v4/projects/1/phases", + "host": [ + "{{api-url}}" + ], + "path": [ + "v4", + "projects", + "1", + "phases" + ] + } + }, + "response": [] + }, { "name": "List Phase", "request": { @@ -2140,6 +2236,45 @@ }, "response": [] }, + { + "name": "List Phase with sort by order", + "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=order desc", + "host": [ + "{{api-url}}" + ], + "path": [ + "v4", + "projects", + "1", + "phases" + ], + "query": [ + { + "key": "sort", + "value": "order desc" + } + ] + } + }, + "response": [] + }, { "name": "Get Phase", "request": { @@ -2208,6 +2343,40 @@ }, "response": [] }, + { + "name": "Update Phase with order", + "request": { + "method": "PATCH", + "header": [ + { + "key": "Authorization", + "value": "Bearer {{jwt-token}}" + }, + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n\t\"param\": {\n\t\t\"name\": \"test project phase xxx\",\n\t\t\"status\": \"inactive\",\n\t\t\"startDate\": \"2018-05-14T00:00:00\",\n\t\t\"endDate\": \"2018-05-15T00:00:00\",\n\t\t\"budget\": 30,\n\t\t\"progress\": 15,\n\t\t\"details\": {\n\t\t\t\"message\": \"phase details\"\n\t\t},\n\t\t\"order\": 1\n\t}\n}" + }, + "url": { + "raw": "{{api-url}}/v4/projects/1/phases/1", + "host": [ + "{{api-url}}" + ], + "path": [ + "v4", + "projects", + "1", + "phases", + "1" + ] + } + }, + "response": [] + }, { "name": "Delete Phase", "request": { @@ -2437,12 +2606,14 @@ "raw": "{\r\n \"param\":{\r\n \"name\":\"new name\",\r\n \"key\":\"new key\",\r\n \"category\":\"app\",\r\n \"icon\": \"http://example.com/icon1.ico\",\r\n \"question\": \"question 1\",\r\n \"info\": \"info 1\",\r\n \"aliases\": [\"key-1\", \"key_1\"],\r\n \"scope\":{\r\n \"scope1\":\"scope 1\"\r\n },\r\n \"phases\":{\r\n \"phase1\":\"phase 1\"\r\n }\r\n }\r\n}" }, "url": { - "raw": "{{api-url}}/v4/projectTemplates", + "raw": "{{api-url}}/v4/projects/metadata/projectTemplates", "host": [ "{{api-url}}" ], "path": [ "v4", + "projects", + "metadata", "projectTemplates" ] } @@ -2468,12 +2639,14 @@ "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", + "raw": "{{api-url}}/v4/projects/metadata/projectTemplates", "host": [ "{{api-url}}" ], "path": [ "v4", + "projects", + "metadata", "projectTemplates" ] } @@ -2499,12 +2672,14 @@ "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", + "raw": "{{api-url}}/v4/projects/metadata/projectTemplates/1", "host": [ "{{api-url}}" ], "path": [ "v4", + "projects", + "metadata", "projectTemplates", "1" ] @@ -2531,12 +2706,14 @@ "raw": "{\r\n \"param\":{\r\n \"name\":\"new name\",\r\n \"key\":\"new key\",\r\n \"category\":\"app\",\r\n \"scope\":{\r\n \"scope1\":\"scope 1\",\r\n \"scope2\": [\"a\"]\r\n },\r\n \"phases\":{\r\n \"phase1\":\"phase 1\",\r\n \"phase2\": {\r\n \t\"another\": \"another\"\r\n }\r\n }\r\n }\r\n}" }, "url": { - "raw": "{{api-url}}/v4/projectTemplates/1", + "raw": "{{api-url}}/v4/projects/metadata/projectTemplates/1", "host": [ "{{api-url}}" ], "path": [ "v4", + "projects", + "metadata", "projectTemplates", "1" ] @@ -2563,12 +2740,14 @@ "raw": "{\r\n \"param\":{\r\n \"name\":\"new name\",\r\n \"key\":\"new key\",\r\n \"category\":\"new category\",\r\n \"scope\":{\r\n \"scope1\":\"scope 1\",\r\n \"scope2\": [\"a\"]\r\n },\r\n \"phases\":{\r\n \"phase1\":\"phase 1\",\r\n \"phase2\": {\r\n \t\"another\": \"another\"\r\n }\r\n }\r\n }\r\n}" }, "url": { - "raw": "{{api-url}}/v4/projectTemplates/2", + "raw": "{{api-url}}/v4/projects/metadata/projectTemplates/2", "host": [ "{{api-url}}" ], "path": [ "v4", + "projects", + "metadata", "projectTemplates", "2" ] @@ -2600,12 +2779,14 @@ "raw": "{\r\n \"param\": {\r\n \"name\": \"name 1\",\r\n \"productKey\": \"productKey 1\",\r\n \"category\": \"key1\",\r\n \"icon\": \"http://example.com/icon1.ico\",\r\n \"brief\": \"brief 1\",\r\n \"details\": \"details 1\",\r\n \"aliases\": [\"product key 1\", \"product_key_1\"],\r\n \"template\": {\r\n \"template1\": {\r\n \"name\": \"template 1\",\r\n \"details\": {\r\n \"anyDetails\": \"any details 1\"\r\n },\r\n \"others\": [\"others 11\", \"others 12\"]\r\n },\r\n \"template2\": {\r\n \"name\": \"template 2\",\r\n \"details\": {\r\n \"anyDetails\": \"any details 2\"\r\n },\r\n \"others\": [\"others 21\", \"others 22\"]\r\n }\r\n }\r\n }\r\n }" }, "url": { - "raw": "{{api-url}}/v4/productTemplates", + "raw": "{{api-url}}/v4/projects/metadata/productTemplates", "host": [ "{{api-url}}" ], "path": [ "v4", + "projects", + "metadata", "productTemplates" ] } @@ -2631,12 +2812,14 @@ "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", + "raw": "{{api-url}}/v4/projects/metadata/productTemplates", "host": [ "{{api-url}}" ], "path": [ "v4", + "projects", + "metadata", "productTemplates" ] } @@ -2662,12 +2845,14 @@ "raw": "{\r\n \"param\":{\r\n \"name\":\"new name\",\r\n \"key\":\"new key\",\r\n \"category\":\"new category\",\r\n \"scope\":{\r\n \"scope1\":\"scope 1\"\r\n },\r\n \"phases\":{\r\n \"phase1\":\"phase 1\"\r\n }\r\n }\r\n}" }, "url": { - "raw": "{{api-url}}/v4/productTemplates/3", + "raw": "{{api-url}}/v4/projects/metadata/productTemplates/3", "host": [ "{{api-url}}" ], "path": [ "v4", + "projects", + "metadata", "productTemplates", "3" ] @@ -2694,12 +2879,14 @@ "raw": "{\r\n \"param\":{\r\n \"name\":\"new name\",\r\n \"productKey\":\"new productKey\",\r\n \"category\":\"key1\",\r\n \"icon\":\"http://example.com/icon-new.ico\",\r\n \"brief\": \"new brief\",\r\n \"details\": \"new details\",\r\n \"aliases\":{\r\n \"alias1\":\"scope 1\",\r\n \"alias2\": [\"a\"]\r\n },\r\n \"template\":{\r\n \"template1\":\"template 1\",\r\n \"template2\": {\r\n \t\"another\": \"another\"\r\n }\r\n }\r\n }\r\n}" }, "url": { - "raw": "{{api-url}}/v4/productTemplates/1", + "raw": "{{api-url}}/v4/projects/metadata/productTemplates/1", "host": [ "{{api-url}}" ], "path": [ "v4", + "projects", + "metadata", "productTemplates", "1" ] @@ -2726,12 +2913,14 @@ "raw": "" }, "url": { - "raw": "{{api-url}}/v4/productTemplates/1", + "raw": "{{api-url}}/v4/projects/metadata/productTemplates/1", "host": [ "{{api-url}}" ], "path": [ "v4", + "projects", + "metadata", "productTemplates", "1" ] @@ -2760,15 +2949,17 @@ ], "body": { "mode": "raw", - "raw": "{\r\n \"param\":{\r\n \"key\": \"new key\",\r\n \"displayName\": \"new displayName\",\r\n \"icon\": \"http://example.com/icon4.ico\",\r\n \t\"question\": \"question 4\",\r\n \t\"info\": \"info 4\",\r\n \t\"aliases\": [\"key-41\", \"key_42\"]\r\n }\r\n}" + "raw": "{\r\n \"param\":{\r\n \"key\": \"new key\",\r\n \"displayName\": \"new displayName\",\r\n \"icon\": \"http://example.com/icon4.ico\",\r\n \t\"question\": \"question 4\",\r\n \t\"info\": \"info 4\",\r\n \t\"aliases\": [\"key-41\", \"key_42\"],\r\n \t\"metadata\": {}\r\n }\r\n}" }, "url": { - "raw": "{{api-url}}/v4/projectTypes", + "raw": "{{api-url}}/v4/projects/metadata/projectTypes", "host": [ "{{api-url}}" ], "path": [ "v4", + "projects", + "metadata", "projectTypes" ] } @@ -2794,12 +2985,14 @@ "raw": "{\r\n \"param\":{\r\n \"name\":\"new name\",\r\n \"key\":\"new key\",\r\n \"category\":\"new category\",\r\n \"scope\":{\r\n \"scope1\":\"scope 1\"\r\n },\r\n \"phases\":{\r\n \"phase1\":\"phase 1\"\r\n }\r\n }\r\n}" }, "url": { - "raw": "{{api-url}}/v4/projectTypes", + "raw": "{{api-url}}/v4/projects/metadata/projectTypes", "host": [ "{{api-url}}" ], "path": [ "v4", + "projects", + "metadata", "projectTypes" ] } @@ -2825,12 +3018,14 @@ "raw": "{\r\n \"param\":{\r\n \"name\":\"new name\",\r\n \"key\":\"new key\",\r\n \"category\":\"new category\",\r\n \"scope\":{\r\n \"scope1\":\"scope 1\"\r\n },\r\n \"phases\":{\r\n \"phase1\":\"phase 1\"\r\n }\r\n }\r\n}" }, "url": { - "raw": "{{api-url}}/v4/projectTypes/generic", + "raw": "{{api-url}}/v4/projects/metadata/projectTypes/generic", "host": [ "{{api-url}}" ], "path": [ "v4", + "projects", + "metadata", "projectTypes", "generic" ] @@ -2857,12 +3052,14 @@ "raw": "{\r\n \"param\":{\r\n \"displayName\": \"Chatbot-updated\"\r\n }\r\n}" }, "url": { - "raw": "{{api-url}}/v4/projectTypes/chatbot", + "raw": "{{api-url}}/v4/projects/metadata/projectTypes/chatbot", "host": [ "{{api-url}}" ], "path": [ "v4", + "projects", + "metadata", "projectTypes", "chatbot" ] @@ -2889,12 +3086,14 @@ "raw": "" }, "url": { - "raw": "{{api-url}}/v4/projectTypes/chatbot", + "raw": "{{api-url}}/v4/projects/metadata/projectTypes/chatbot", "host": [ "{{api-url}}" ], "path": [ "v4", + "projects", + "metadata", "projectTypes", "chatbot" ] @@ -2926,12 +3125,14 @@ "raw": "{\r\n \"param\":{\r\n \"key\": \"generic\",\r\n \"displayName\": \"new displayName\",\r\n \"icon\": \"icon\",\r\n \"question\": \"question\",\r\n \"info\": \"info\",\r\n \"aliases\": [\"key-1\", \"key-2\"]\r\n }\r\n}" }, "url": { - "raw": "{{api-url}}/v4/productCategories", + "raw": "{{api-url}}/v4/projects/metadata/productCategories", "host": [ "{{api-url}}" ], "path": [ "v4", + "projects", + "metadata", "productCategories" ] } @@ -2957,12 +3158,14 @@ "raw": "{\r\n \"param\":{\r\n \"name\":\"new name\",\r\n \"key\":\"new key\",\r\n \"category\":\"new category\",\r\n \"scope\":{\r\n \"scope1\":\"scope 1\"\r\n },\r\n \"phases\":{\r\n \"phase1\":\"phase 1\"\r\n }\r\n }\r\n}" }, "url": { - "raw": "{{api-url}}/v4/productCategories", + "raw": "{{api-url}}/v4/projects/metadata/productCategories", "host": [ "{{api-url}}" ], "path": [ "v4", + "projects", + "metadata", "productCategories" ] } @@ -2988,12 +3191,14 @@ "raw": "{\r\n \"param\":{\r\n \"name\":\"new name\",\r\n \"key\":\"new key\",\r\n \"category\":\"new category\",\r\n \"scope\":{\r\n \"scope1\":\"scope 1\"\r\n },\r\n \"phases\":{\r\n \"phase1\":\"phase 1\"\r\n }\r\n }\r\n}" }, "url": { - "raw": "{{api-url}}/v4/productCategories/generic", + "raw": "{{api-url}}/v4/projects/metadata/productCategories/generic", "host": [ "{{api-url}}" ], "path": [ "v4", + "projects", + "metadata", "productCategories", "generic" ] @@ -3020,12 +3225,14 @@ "raw": "{\r\n \"param\":{\r\n \"displayName\": \"Chatbot-updated\"\r\n }\r\n}" }, "url": { - "raw": "{{api-url}}/v4/productCategories/generic", + "raw": "{{api-url}}/v4/projects/metadata/productCategories/generic", "host": [ "{{api-url}}" ], "path": [ "v4", + "projects", + "metadata", "productCategories", "generic" ] @@ -3052,12 +3259,14 @@ "raw": "{\r\n \"param\":{\r\n \"name\":\"new name\",\r\n \"key\":\"new key\",\r\n \"category\":\"new category\",\r\n \"scope\":{\r\n \"scope1\":\"scope 1\",\r\n \"scope2\": [\"a\"]\r\n },\r\n \"phases\":{\r\n \"phase1\":\"phase 1\",\r\n \"phase2\": {\r\n \t\"another\": \"another\"\r\n }\r\n }\r\n }\r\n}" }, "url": { - "raw": "{{api-url}}/v4/productCategories/generic", + "raw": "{{api-url}}/v4/projects/metadata/productCategories/generic", "host": [ "{{api-url}}" ], "path": [ "v4", + "projects", + "metadata", "productCategories", "generic" ] @@ -3101,7 +3310,6 @@ }, { "name": "Project upgrade", - "description": "Request to migrate projects.", "item": [ { "name": "Migrate project", @@ -3235,7 +3443,8 @@ }, "response": [] } - ] + ], + "description": "Request to migrate projects." }, { "name": "Timeline", @@ -3271,6 +3480,37 @@ }, "response": [] }, + { + "name": "Create timeline with templateId", + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + }, + { + "key": "Authorization", + "value": "Bearer {{jwt-token-connectAdmin-40051336}}" + } + ], + "body": { + "mode": "raw", + "raw": "{\r\n \"param\":{\r\n \"name\":\"new name\",\r\n \"description\":\"new description\",\r\n \"startDate\":\"2018-05-29T00:00:00.000Z\",\r\n \"endDate\": \"2018-05-30T00:00:00.000Z\",\r\n \"reference\": \"project\",\r\n \"referenceId\": 1,\r\n \"templateId\": 1\r\n }\r\n}" + }, + "url": { + "raw": "{{api-url}}/v4/timelines", + "host": [ + "{{api-url}}" + ], + "path": [ + "v4", + "timelines" + ] + } + }, + "response": [] + }, { "name": "Create timeline with invalid data", "request": { @@ -3332,8 +3572,7 @@ "query": [ { "key": "filter", - "value": "reference%3Dphase%26referenceId%3D1", - "equals": true + "value": "reference%3Dphase%26referenceId%3D1" } ] } @@ -3693,7 +3932,7 @@ ], "body": { "mode": "raw", - "raw": "{\r\n \"param\":{\r\n \"name\": \"milestone 1-updated\",\r\n \"description\": \"description-updated\",\r\n \"duration\": 3,\r\n \"completionDate\": \"2018-05-07T00:00:00.000Z\",\r\n \"status\": \"closed\",\r\n \"type\": \"type2\",\r\n \"details\": {\r\n \"detail1\": {\r\n \"subDetail1C\": 3\r\n },\r\n \"detail2\": [\r\n 4\r\n ]\r\n },\r\n \"order\": 1,\r\n \"plannedText\": \"plannedText 1-updated\",\r\n \"activeText\": \"activeText 1-updated\",\r\n \"completedText\": \"completedText 1-updated\",\r\n \"blockedText\": \"blockedText 1-updated\"\r\n }\r\n}" + "raw": "{\r\n \"param\":{\r\n \"name\": \"milestone 1-updated\",\r\n \"description\": \"description-updated\",\r\n \"duration\": 3,\r\n \"completionDate\": \"2018-09-28T00:00:00.000Z\",\r\n \"status\": \"closed\",\r\n \"type\": \"type2\",\r\n \"details\": {\r\n \"detail1\": {\r\n \"subDetail1C\": 3\r\n },\r\n \"detail2\": [\r\n 4\r\n ]\r\n },\r\n \"order\": 1,\r\n \"plannedText\": \"plannedText 1-updated\",\r\n \"activeText\": \"activeText 1-updated\",\r\n \"completedText\": \"completedText 1-updated\",\r\n \"blockedText\": \"blockedText 1-updated\"\r\n }\r\n}" }, "url": { "raw": "{{api-url}}/v4/timelines/1/milestones/1", @@ -3711,6 +3950,74 @@ }, "response": [] }, + { + "name": "Update milestone (active)", + "request": { + "method": "PATCH", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + }, + { + "key": "Authorization", + "value": "Bearer {{jwt-token}}" + } + ], + "body": { + "mode": "raw", + "raw": "{\r\n \"param\":{\r\n \"name\": \"milestone 2-updated\",\r\n \"description\": \"description-updated\",\r\n \"duration\": 3,\r\n \"completionDate\": \"2018-10-28T00:00:00.000Z\",\r\n \"status\": \"active\",\r\n \"type\": \"type2\",\r\n \"details\": {\r\n \"detail1\": {\r\n \"subDetail1C\": 3\r\n },\r\n \"detail2\": [\r\n 4\r\n ]\r\n },\r\n \"order\": 1,\r\n \"plannedText\": \"plannedText 1-updated\",\r\n \"activeText\": \"activeText 1-updated\",\r\n \"completedText\": \"completedText 1-updated\",\r\n \"blockedText\": \"blockedText 1-updated\"\r\n }\r\n}" + }, + "url": { + "raw": "{{api-url}}/v4/timelines/1/milestones/2", + "host": [ + "{{api-url}}" + ], + "path": [ + "v4", + "timelines", + "1", + "milestones", + "2" + ] + } + }, + "response": [] + }, + { + "name": "Update milestone (completed)", + "request": { + "method": "PATCH", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + }, + { + "key": "Authorization", + "value": "Bearer {{jwt-token}}" + } + ], + "body": { + "mode": "raw", + "raw": "{\r\n \"param\":{\r\n \"name\": \"milestone 2-updated\",\r\n \"description\": \"description-updated\",\r\n \"duration\": 3,\r\n \"completionDate\": \"2018-10-28T00:00:00.000Z\",\r\n \"status\": \"completed\",\r\n \"type\": \"type2\",\r\n \"details\": {\r\n \"detail1\": {\r\n \"subDetail1C\": 3\r\n },\r\n \"detail2\": [\r\n 4\r\n ]\r\n },\r\n \"order\": 1,\r\n \"plannedText\": \"plannedText 1-updated\",\r\n \"activeText\": \"activeText 1-updated\",\r\n \"completedText\": \"completedText 1-updated\",\r\n \"blockedText\": \"blockedText 1-updated\"\r\n }\r\n}" + }, + "url": { + "raw": "{{api-url}}/v4/timelines/1/milestones/2", + "host": [ + "{{api-url}}" + ], + "path": [ + "v4", + "timelines", + "1", + "milestones", + "2" + ] + } + }, + "response": [] + }, { "name": "Update milestone (order 1 => 2)", "request": { @@ -3902,18 +4209,51 @@ ], "body": { "mode": "raw", - "raw": "{\r\n \"param\":{\r\n \"name\": \"milestoneTemplate 3\",\r\n \"description\": \"description 3\",\r\n \"duration\": 33,\r\n \"type\": \"type3\",\r\n \"order\": 1,\r\n \"activeText\": \"activeText 1\",\r\n \"completedText\": \"completedText 1\",\r\n \"blockedText\": \"blockedText 1\",\r\n \"plannedText\": \"planned Text 1\"\r\n\r\n }\r\n}" + "raw": "{\r\n \"param\":{\r\n \"name\": \"milestoneTemplate 3\",\r\n \"description\": \"description 3\",\r\n \"duration\": 33,\r\n \"type\": \"type3\",\r\n \"order\": 1,\r\n \"activeText\": \"activeText 1\",\r\n \"completedText\": \"completedText 1\",\r\n \"blockedText\": \"blockedText 1\",\r\n \"plannedText\": \"planned Text 1\",\r\n\t\"reference\": \"productTemplate\",\r\n\t\"referenceId\": 1,\r\n\t\"metadata\": {}\r\n }\r\n}" }, "url": { - "raw": "{{api-url}}/v4/productTemplates/1/milestones", + "raw": "{{api-url}}/v4/timelines/metadata/milestoneTemplates", "host": [ "{{api-url}}" ], "path": [ "v4", - "productTemplates", - "1", - "milestones" + "timelines", + "metadata", + "milestoneTemplates" + ] + } + }, + "response": [] + }, + { + "name": "Create milestone template with invalid referenceId", + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + }, + { + "key": "Authorization", + "value": "Bearer {{jwt-token-admin-40051333}}" + } + ], + "body": { + "mode": "raw", + "raw": "{\r\n \"param\":{\r\n \"name\": \"milestoneTemplate 3\",\r\n \"description\": \"description 3\",\r\n \"duration\": 33,\r\n \"type\": \"type3\",\r\n \"order\": 1,\r\n \"activeText\": \"activeText 1\",\r\n \"completedText\": \"completedText 1\",\r\n \"blockedText\": \"blockedText 1\",\r\n \"plannedText\": \"planned Text 1\",\r\n\t\"reference\": \"productTemplate\",\r\n\t\"referenceId\": 1000,\r\n\t\"metadata\": {}\r\n }\r\n}" + }, + "url": { + "raw": "{{api-url}}/v4/timelines/metadata/milestoneTemplates", + "host": [ + "{{api-url}}" + ], + "path": [ + "v4", + "timelines", + "metadata", + "milestoneTemplates" ] } }, @@ -3938,15 +4278,15 @@ "raw": "{\r\n \"param\":{\r\n\r\n }\r\n}" }, "url": { - "raw": "{{api-url}}/v4/productTemplates/1/milestones", + "raw": "{{api-url}}/v4/timelines/metadata/milestoneTemplates", "host": [ "{{api-url}}" ], "path": [ "v4", - "productTemplates", - "1", - "milestones" + "timelines", + "metadata", + "milestoneTemplates" ] } }, @@ -3968,18 +4308,18 @@ ], "body": { "mode": "raw", - "raw": "{\r\n \"param\":{\r\n \"sourceTemplateId\": 1\r\n }\r\n}" + "raw": "{\r\n \"param\":{\r\n \"sourceReference\": \"productTemplate\",\r\n \"sourceReferenceId\": 1,\r\n \"reference\": \"productTemplate\",\r\n \"referenceId\": 2\r\n }\r\n}" }, "url": { - "raw": "{{api-url}}/v4/productTemplates/2/milestones/clone", + "raw": "{{api-url}}/v4/timelines/metadata/milestoneTemplates/clone", "host": [ "{{api-url}}" ], "path": [ "v4", - "productTemplates", - "2", - "milestones", + "timelines", + "metadata", + "milestoneTemplates", "clone" ] } @@ -3987,7 +4327,7 @@ "response": [] }, { - "name": "Clone milestone template with invalid product template id", + "name": "Clone milestone template with invalid referenceId", "request": { "method": "POST", "header": [ @@ -4002,18 +4342,18 @@ ], "body": { "mode": "raw", - "raw": "{\r\n \"param\":{\r\n \"sourceTemplateId\": 1\r\n }\r\n}" + "raw": "{\r\n \"param\":{\r\n \"sourceReference\": \"productTemplate\",\r\n \"sourceReferenceId\": 1,\r\n \"reference\": \"productTemplate\",\r\n \"referenceId\": 2000\r\n }\r\n}" }, "url": { - "raw": "{{api-url}}/v4/productTemplates/5/milestones/clone", + "raw": "{{api-url}}/v4/timelines/metadata/milestoneTemplates/clone", "host": [ "{{api-url}}" ], "path": [ "v4", - "productTemplates", - "5", - "milestones", + "timelines", + "metadata", + "milestoneTemplates", "clone" ] } @@ -4021,7 +4361,7 @@ "response": [] }, { - "name": "Clone milestone template with invalid source product template id", + "name": "Clone milestone template with invalid sourceReferenceId", "request": { "method": "POST", "header": [ @@ -4036,18 +4376,18 @@ ], "body": { "mode": "raw", - "raw": "{\r\n \"param\":{\r\n \"sourceTemplateId\": 6\r\n }\r\n}" + "raw": "{\r\n \"param\":{\r\n \"sourceReference\": \"productTemplate\",\r\n \"sourceReferenceId\": 1000,\r\n \"reference\": \"productTemplate\",\r\n \"referenceId\": 2\r\n }\r\n}" }, "url": { - "raw": "{{api-url}}/v4/productTemplates/2/milestones/clone", + "raw": "{{api-url}}/v4/timelines/metadata/milestoneTemplates/clone", "host": [ "{{api-url}}" ], "path": [ "v4", - "productTemplates", - "2", - "milestones", + "timelines", + "metadata", + "milestoneTemplates", "clone" ] } @@ -4073,15 +4413,54 @@ "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/milestones", + "raw": "{{api-url}}/v4/timelines/metadata/milestoneTemplates", "host": [ "{{api-url}}" ], "path": [ "v4", - "productTemplates", - "1", - "milestones" + "timelines", + "metadata", + "milestoneTemplates" + ] + } + }, + "response": [] + }, + { + "name": "List milestone templates (filter)", + "request": { + "method": "GET", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + }, + { + "key": "Authorization", + "value": "Bearer {{jwt-token-copilot-40051332}}" + } + ], + "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/timelines/metadata/milestoneTemplates?filter=reference%3DproductTemplate%26referenceId%3D1", + "host": [ + "{{api-url}}" + ], + "path": [ + "v4", + "timelines", + "metadata", + "milestoneTemplates" + ], + "query": [ + { + "key": "filter", + "value": "reference%3DproductTemplate%26referenceId%3D1" + } ] } }, @@ -4106,17 +4485,21 @@ "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/milestones?sort=order desc", + "raw": "{{api-url}}/v4/timelines/metadata/milestoneTemplates?filter=reference%3DproductTemplate%26referenceId%3D1&sort=order desc", "host": [ "{{api-url}}" ], "path": [ "v4", - "productTemplates", - "1", - "milestones" + "timelines", + "metadata", + "milestoneTemplates" ], "query": [ + { + "key": "filter", + "value": "reference%3DproductTemplate%26referenceId%3D1" + }, { "key": "sort", "value": "order desc" @@ -4145,15 +4528,15 @@ "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/milestones/1", + "raw": "{{api-url}}/v4/timelines/metadata/milestoneTemplates/1", "host": [ "{{api-url}}" ], "path": [ "v4", - "productTemplates", - "1", - "milestones", + "timelines", + "metadata", + "milestoneTemplates", "1" ] } @@ -4176,18 +4559,18 @@ ], "body": { "mode": "raw", - "raw": "{\r\n \"param\":{\r\n\t\"name\": \"milestoneTemplate 1-updated\",\r\n\t\"description\": \"description 1-updated\",\r\n\t\"duration\": 34,\r\n\t\"type\": \"type1-updated\",\r\n\t\"order\": 1\r\n }\r\n}" + "raw": "{\r\n \"param\":{\r\n\t\"name\": \"milestoneTemplate 1-updated\",\r\n\t\"description\": \"description 1-updated\",\r\n\t\"duration\": 34,\r\n\t\"type\": \"type1-updated\",\r\n\t\"order\": 1,\r\n\t\"reference\": \"productTemplate\",\r\n\t\"referenceId\": 1\r\n }\r\n}" }, "url": { - "raw": "{{api-url}}/v4/productTemplates/1/milestones/1", + "raw": "{{api-url}}/v4/timelines/metadata/milestoneTemplates/1", "host": [ "{{api-url}}" ], "path": [ "v4", - "productTemplates", - "1", - "milestones", + "timelines", + "metadata", + "milestoneTemplates", "1" ] } @@ -4210,18 +4593,18 @@ ], "body": { "mode": "raw", - "raw": "{\r\n \"param\":{\r\n \"name\": \"milestoneTemplate 1-updated\",\r\n \"description\": \"description 1-updated\",\r\n \"duration\": 34,\r\n \"type\": \"type1-updated\",\r\n \"order\": 2\r\n }\r\n}" + "raw": "{\r\n \"param\":{\r\n \"name\": \"milestoneTemplate 1-updated\",\r\n \"description\": \"description 1-updated\",\r\n \"duration\": 34,\r\n \"type\": \"type1-updated\",\r\n \"order\": 2,\r\n\t\"reference\": \"productTemplate\",\r\n\t\"referenceId\": 1\r\n }\r\n}" }, "url": { - "raw": "{{api-url}}/v4/productTemplates/1/milestones/1", + "raw": "{{api-url}}/v4/timelines/metadata/milestoneTemplates/1", "host": [ "{{api-url}}" ], "path": [ "v4", - "productTemplates", - "1", - "milestones", + "timelines", + "metadata", + "milestoneTemplates", "1" ] } @@ -4244,18 +4627,18 @@ ], "body": { "mode": "raw", - "raw": "{\r\n \"param\":{\r\n\t\"name\": \"milestoneTemplate 1-updated\",\r\n\t\"description\": \"description 1-updated\",\r\n\t\"duration\": 34,\r\n\t\"type\": \"type1-updated\",\r\n\t\"order\": 1\r\n }\r\n}" + "raw": "{\r\n \"param\":{\r\n\t\"name\": \"milestoneTemplate 1-updated\",\r\n\t\"description\": \"description 1-updated\",\r\n\t\"duration\": 34,\r\n\t\"type\": \"type1-updated\",\r\n\t\"order\": 1,\r\n\t\"reference\": \"productTemplate\",\r\n\t\"referenceId\": 1\r\n }\r\n}" }, "url": { - "raw": "{{api-url}}/v4/productTemplates/1/milestones/1", + "raw": "{{api-url}}/v4/timelines/metadata/milestoneTemplates/1", "host": [ "{{api-url}}" ], "path": [ "v4", - "productTemplates", - "1", - "milestones", + "timelines", + "metadata", + "milestoneTemplates", "1" ] } @@ -4278,18 +4661,18 @@ ], "body": { "mode": "raw", - "raw": "{\r\n \"param\":{\r\n \"name\": \"milestoneTemplate 1-updated\",\r\n \"description\": \"description 1-updated\",\r\n \"duration\": 34,\r\n \"type\": \"type1-updated\",\r\n \"order\": 3\r\n }\r\n}" + "raw": "{\r\n \"param\":{\r\n \"name\": \"milestoneTemplate 1-updated\",\r\n \"description\": \"description 1-updated\",\r\n \"duration\": 34,\r\n \"type\": \"type1-updated\",\r\n \"order\": 3,\r\n\t\"reference\": \"productTemplate\",\r\n\t\"referenceId\": 1\r\n }\r\n}" }, "url": { - "raw": "{{api-url}}/v4/productTemplates/1/milestones/1", + "raw": "{{api-url}}/v4/timelines/metadata/milestoneTemplates/1", "host": [ "{{api-url}}" ], "path": [ "v4", - "productTemplates", - "1", - "milestones", + "timelines", + "metadata", + "milestoneTemplates", "1" ] } @@ -4312,18 +4695,52 @@ ], "body": { "mode": "raw", - "raw": "{\r\n \"param\":{\r\n \"name\": \"milestoneTemplate 1-updated\",\r\n \"description\": \"description 1-updated\",\r\n \"duration\": 34,\r\n \"type\": \"type1-updated\",\r\n \"order\": 1\r\n }\r\n}" + "raw": "{\r\n \"param\":{\r\n \"name\": \"milestoneTemplate 1-updated\",\r\n \"description\": \"description 1-updated\",\r\n \"duration\": 34,\r\n \"type\": \"type1-updated\",\r\n \"order\": 1,\r\n\t\"reference\": \"productTemplate\",\r\n\t\"referenceId\": 1\r\n }\r\n}" }, "url": { - "raw": "{{api-url}}/v4/productTemplates/1/milestones/1", + "raw": "{{api-url}}/v4/timelines/metadata/milestoneTemplates/1", "host": [ "{{api-url}}" ], "path": [ "v4", - "productTemplates", - "1", - "milestones", + "timelines", + "metadata", + "milestoneTemplates", + "1" + ] + } + }, + "response": [] + }, + { + "name": "Update milestone with metadata", + "request": { + "method": "PATCH", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + }, + { + "key": "Authorization", + "value": "Bearer {{jwt-token}}" + } + ], + "body": { + "mode": "raw", + "raw": "{\r\n \"param\":{\r\n\t\"name\": \"milestoneTemplate 5-updated\",\r\n\t\"description\": \"description 5-updated\",\r\n\t\"duration\": 34,\r\n\t\"type\": \"type5-updated\",\r\n\t\"order\": 5,\r\n\t\"reference\": \"productTemplate\",\r\n\t\"referenceId\": 1,\r\n\t\"metadata\": {\r\n \"metadata1\": {\r\n \"name\": \"metadata 1 - update\",\r\n \"details\": {\r\n \"anyDetails\": \"any details 1 - update\",\r\n \"newDetails\": \"new\"\r\n },\r\n \"others\": [\"others new\"]\r\n },\r\n \"metadata3\": {\r\n \"name\": \"metadata 3\",\r\n \"details\": {\r\n \"anyDetails\": \"any details 3\"\r\n },\r\n \"others\": [\"others 31\", \"others 32\"]\r\n }\r\n }\r\n }\r\n}" + }, + "url": { + "raw": "{{api-url}}/v4/timelines/metadata/milestoneTemplates/1", + "host": [ + "{{api-url}}" + ], + "path": [ + "v4", + "timelines", + "metadata", + "milestoneTemplates", "1" ] } @@ -4349,15 +4766,15 @@ "raw": "" }, "url": { - "raw": "{{api-url}}/v4/productTemplates/1/milestones/2", + "raw": "{{api-url}}/v4/timelines/metadata/milestoneTemplates/2", "host": [ "{{api-url}}" ], "path": [ "v4", - "productTemplates", - "1", - "milestones", + "timelines", + "metadata", + "milestoneTemplates", "2" ] } @@ -4365,6 +4782,34 @@ "response": [] } ] + }, + { + "name": "Metadata", + "item": [ + { + "name": "Get all metadata", + "request": { + "method": "GET", + "header": [], + "body": { + "mode": "raw", + "raw": "" + }, + "url": { + "raw": "{{api-url}}/v4/projects/metadata", + "host": [ + "{{api-url}}" + ], + "path": [ + "v4", + "projects", + "metadata" + ] + } + }, + "response": [] + } + ] } ] } \ No newline at end of file diff --git a/postman_environment.json b/postman_environment.json index d1ccbbd4..261834ae 100644 --- a/postman_environment.json +++ b/postman_environment.json @@ -1,65 +1,63 @@ { - "id": "e6b30b4b-1388-4622-8314-bc49ba1d752b", + "id": "53925cd5-ff42-43a2-bb87-29f9aa73ffd9", "name": "tc-project-service", "values": [ { "key": "api-url", "value": "http://localhost:3000", "description": "", - "type": "text", "enabled": true }, { "key": "jwt-token", - "value": "", + "value": "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJyb2xlcyI6WyJUb3Bjb2RlciBVc2VyIiwiYWRtaW5pc3RyYXRvciJdLCJpc3MiOiJodHRwczovL2FwaS50b3Bjb2Rlci1kZXYuY29tIiwiaGFuZGxlIjoidGVzdDEiLCJleHAiOjI1NjMwNzY2ODksInVzZXJJZCI6IjQwMDUxMzMzIiwiaWF0IjoxNDYzMDc2MDg5LCJlbWFpbCI6InRlc3RAdG9wY29kZXIuY29tIiwianRpIjoiYjMzYjc3Y2QtYjUyZS00MGZlLTgzN2UtYmViOGUwYWU2YTRhIn0.wKWUe0-SaiFVN-VR_-GwgFlvWaDkSbc8H55ktb9LAVw", "description": "", - "type": "text", "enabled": true }, { "key": "jwt-token-admin-40051333", - "value": "", + "value": "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJyb2xlcyI6WyJUb3Bjb2RlciBVc2VyIiwiYWRtaW5pc3RyYXRvciJdLCJpc3MiOiJodHRwczovL2FwaS50b3Bjb2Rlci1kZXYuY29tIiwiaGFuZGxlIjoidGVzdDEiLCJleHAiOjI1NjMwNzY2ODksInVzZXJJZCI6IjQwMDUxMzMzIiwiaWF0IjoxNDYzMDc2MDg5LCJlbWFpbCI6InRlc3RAdG9wY29kZXIuY29tIiwianRpIjoiYjMzYjc3Y2QtYjUyZS00MGZlLTgzN2UtYmViOGUwYWU2YTRhIn0.wKWUe0-SaiFVN-VR_-GwgFlvWaDkSbc8H55ktb9LAVw", "description": "", - "type": "text", "enabled": true }, { "key": "jwt-token-member-40051331", - "value": "", + "value": "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJyb2xlcyI6WyJUb3Bjb2RlciBVc2VyIl0sImlzcyI6Imh0dHBzOi8vYXBpLnRvcGNvZGVyLWRldi5jb20iLCJoYW5kbGUiOiJ0ZXN0MSIsImV4cCI6MjU2MzA3NjY4OSwidXNlcklkIjoiNDAwNTEzMzEiLCJpYXQiOjE0NjMwNzYwODksImVtYWlsIjoidGVzdEB0b3Bjb2Rlci5jb20iLCJqdGkiOiJiMzNiNzdjZC1iNTJlLTQwZmUtODM3ZS1iZWI4ZTBhZTZhNGEifQ.pDtRzcGQjgCBD6aLsW-1OFhzmrv5mXhb8YLDWbGAnKo", "description": "", - "type": "text", "enabled": true }, { "key": "jwt-token-copilot-40051332", - "value": "", + "value": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlcyI6WyJUb3Bjb2RlciBVc2VyIiwiQ29ubmVjdCBDb3BpbG90Il0sImlzcyI6Imh0dHBzOi8vYXBpLnRvcGNvZGVyLWRldi5jb20iLCJoYW5kbGUiOiJ0ZXN0MSIsImV4cCI6MjU2MzA3NjY4OSwidXNlcklkIjo0MDA1MTMzMiwiZW1haWwiOiJ0ZXN0QHRvcGNvZGVyLmNvbSIsImlhdCI6MTQ3MDYyMDA0NH0.DnX17gBaVF2JTuRai-C2BDSdEjij9da_s4eYcMIjP0c", "description": "", - "type": "text", "enabled": true }, { "key": "jwt-token-manager-40051334", - "value": "", + "value": "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJyb2xlcyI6WyJUb3Bjb2RlciBVc2VyIiwiQ29ubmVjdCBNYW5hZ2VyIl0sImlzcyI6Imh0dHBzOi8vYXBpLnRvcGNvZGVyLWRldi5jb20iLCJoYW5kbGUiOiJ0ZXN0MSIsImV4cCI6MjU2MzA3NjY4OSwidXNlcklkIjoiNDAwNTEzMzQiLCJpYXQiOjE0NjMwNzYwODksImVtYWlsIjoidGVzdEB0b3Bjb2Rlci5jb20iLCJqdGkiOiJiMzNiNzdjZC1iNTJlLTQwZmUtODM3ZS1iZWI4ZTBhZTZhNGEifQ.J5VtOEQVph5jfe2Ji-NH7txEDcx_5gthhFeD-MzX9ck", "description": "", - "type": "text", "enabled": true }, { "key": "jwt-token-member2-40051335", - "value": "", + "value": "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJyb2xlcyI6WyJUb3Bjb2RlciBVc2VyIl0sImlzcyI6Imh0dHBzOi8vYXBpLnRvcGNvZGVyLWRldi5jb20iLCJoYW5kbGUiOiJtZW1iZXIyIiwiZXhwIjoyNTYzMDc2Njg5LCJ1c2VySWQiOiI0MDA1MTMzNSIsImlhdCI6MTQ2MzA3NjA4OSwiZW1haWwiOiJ0ZXN0QHRvcGNvZGVyLmNvbSIsImp0aSI6ImIzM2I3N2NkLWI1MmUtNDBmZS04MzdlLWJlYjhlMGFlNmE0YSJ9.Mh4bw3wm-cn5Kcf96gLFVlD0kySOqqk4xN3qnreAKL4", "description": "", - "type": "text", "enabled": true }, { "key": "jwt-token-connectAdmin-40051336", - "value": "", + "value": "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJyb2xlcyI6WyJDb25uZWN0IEFkbWluIl0sImlzcyI6Imh0dHBzOi8vYXBpLnRvcGNvZGVyLWRldi5jb20iLCJoYW5kbGUiOiJjb25uZWN0X2FkbWluMSIsImV4cCI6MjU2MzA3NjY4OSwidXNlcklkIjoiNDAwNTEzMzYiLCJpYXQiOjE0NjMwNzYwODksImVtYWlsIjoiY29ubmVjdF9hZG1pbjFAdG9wY29kZXIuY29tIiwianRpIjoiYjMzYjc3Y2QtYjUyZS00MGZlLTgzN2UtYmViOGUwYWU2YTRhIn0.nSGfXMl02NZ90ZKLiEKPg75iAjU92mfteaY6xgqkM30", + "description": "", + "enabled": true + }, + { + "key": "inactive-userId", + "value": "1800075", "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" + "_postman_exported_at": "2018-08-28T10:28:37.251Z", + "_postman_exported_using": "Postman/6.2.5" } \ No newline at end of file diff --git a/src/app.js b/src/app.js index 864fb0e6..ff6d807c 100644 --- a/src/app.js +++ b/src/app.js @@ -7,6 +7,8 @@ import config from 'config'; import cors from 'cors'; import coreLib from 'tc-core-library-js'; import expressRequestId from 'express-request-id'; +import memWatch from 'memwatch-next'; +import performanceRequestLogger from './middlewares/performanceRequestLogger'; import router from './routes'; import permissions from './permissions'; import models from './models'; @@ -61,9 +63,35 @@ const logger = coreLib.logger({ captureLogs: config.get('captureLogs'), logentriesToken: _.get(config, 'logentriesToken', null), }); -app.use(coreLib.middleware.logger(null, logger)); +app.use(performanceRequestLogger(logger)); app.logger = logger; +// ======================== +// Memory leak detection +// ======================== +if (process.env.NODE_ENV.toLowerCase() === 'development') { + let heapDiff = null; + + // A leak event will be emitted when the heap usage has increased + // for five consecutive garbage collections + memWatch.on('leak', (info) => { + logger.error('memwatch::leak=>', info); + + if (!heapDiff) { + heapDiff = new memWatch.HeapDiff(); + } else { + const diff = heapDiff.end(); + logger.error('memwatch::diff=>', diff); + heapDiff = null; + } + }); + + // When V8 performs a garbage collection, memwatch will emit a stats event + memWatch.on('stats', (stats) => { + logger.debug('memwatch::stats=>', stats); + }); +} + // ======================= // CORS ================ // ======================= diff --git a/src/constants.js b/src/constants.js index 12696193..7f238600 100644 --- a/src/constants.js +++ b/src/constants.js @@ -64,6 +64,7 @@ export const EVENT = { export const BUS_API_EVENT = { PROJECT_CREATED: 'notifications.connect.project.created', + PROJECT_UPDATED: 'connect.action.project.updated', PROJECT_SUBMITTED_FOR_REVIEW: 'notifications.connect.project.submittedForReview', PROJECT_APPROVED: 'notifications.connect.project.approved', PROJECT_PAUSED: 'notifications.connect.project.paused', @@ -71,6 +72,12 @@ export const BUS_API_EVENT = { PROJECT_CANCELED: 'notifications.connect.project.canceled', PROJECT_ACTIVE: 'notifications.connect.project.active', + PROJECT_PHASE_TRANSITION_ACTIVE: 'notifications.connect.project.phase.transition.active', + PROJECT_PHASE_TRANSITION_COMPLETED: 'notifications.connect.project.phase.transition.completed', + PROJECT_PHASE_UPDATE_PAYMENT: 'notifications.connect.project.phase.update.payment', + PROJECT_PHASE_UPDATE_PROGRESS: 'notifications.connect.project.phase.update.progress', + PROJECT_PHASE_UPDATE_SCOPE: 'notifications.connect.project.phase.update.scope', + MEMBER_JOINED: 'notifications.connect.project.member.joined', MEMBER_LEFT: 'notifications.connect.project.member.left', MEMBER_REMOVED: 'notifications.connect.project.member.removed', @@ -81,14 +88,39 @@ 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', + PROJECT_PROGRESS_MODIFIED: 'connect.action.project.updated.progress', + PROJECT_FILES_UPDATED: 'connect.action.project.files.updated', + PROJECT_TEAM_UPDATED: 'connect.action.project.team.updated', // When phase is added/updated/deleted from the project, // When product is added/deleted from a phase // When product is updated on any field other than specification - PROJECT_PLAN_MODIFIED: 'notifications.connect.project.planModified', + PROJECT_PLAN_UPDATED: 'connect.action.project.plan.updated', + + PROJECT_PLAN_READY: 'connect.action.project.plan.ready', + + // When milestone is added/deleted to/from the phase, + // When milestone is updated for duration/startDate/endDate/status + TIMELINE_ADJUSTED: 'connect.action.timeline.adjusted', // When specification of a product is modified - PROJECT_PRODUCT_SPECIFICATION_MODIFIED: 'notifications.connect.project.productSpecificationModified', + PROJECT_PRODUCT_SPECIFICATION_MODIFIED: 'connect.action.project.product.update.spec', + + MILESTONE_ADDED: 'connect.action.timeline.milestone.added', + MILESTONE_REMOVED: 'connect.action.timeline.milestone.removed', + MILESTONE_UPDATED: 'connect.action.timeline.milestone.updated', + // When milestone is marked as active + MILESTONE_TRANSITION_ACTIVE: 'connect.action.timeline.milestone.transition.active', + // When milestone is marked as completed + MILESTONE_TRANSITION_COMPLETED: 'connect.action.timeline.milestone.transition.completed', + // When milestone is waiting for customers's input + MILESTONE_WAITING_CUSTOMER: 'connect.action.timeline.milestone.waiting.customer', + + // TC Message Service events + TOPIC_CREATED: 'notifications.connect.project.topic.created', + TOPIC_UPDATED: 'notifications.connect.project.topic.updated', + POST_CREATED: 'notifications.connect.project.post.created', + POST_UPDATED: 'notifications.connect.project.post.edited', }; export const REGEX = { @@ -104,3 +136,8 @@ export const TIMELINE_REFERENCES = { PHASE: 'phase', PRODUCT: 'product', }; + +export const MILESTONE_TEMPLATE_REFERENCES = { + PRODUCT_TEMPLATE: 'productTemplate', +}; + diff --git a/src/events/busApi.js b/src/events/busApi.js index 9608de1c..50ff0f61 100644 --- a/src/events/busApi.js +++ b/src/events/busApi.js @@ -1,6 +1,7 @@ import _ from 'lodash'; import config from 'config'; -import { EVENT, BUS_API_EVENT, PROJECT_STATUS, PROJECT_MEMBER_ROLE } from '../constants'; +import { EVENT, BUS_API_EVENT, PROJECT_STATUS, PROJECT_PHASE_STATUS, PROJECT_MEMBER_ROLE, MILESTONE_STATUS } + from '../constants'; import { createEvent } from '../services/busApi'; import models from '../models'; @@ -92,6 +93,19 @@ module.exports = (app, logger) => { initiatorUserId: req.authUser.userId, }, logger); } + + // send PROJECT_UPDATED Kafka message when one of the specified below properties changed + const watchProperties = ['status', 'details', 'name', 'description', 'bookmarks']; + if (!_.isEqual(_.pick(original, watchProperties), + _.pick(updated, watchProperties))) { + createEvent(BUS_API_EVENT.PROJECT_UPDATED, { + projectId: updated.id, + projectName: updated.name, + projectUrl: connectProjectUrl(updated.id), + userId: req.authUser.userId, + initiatorUserId: req.authUser.userId, + }, logger); + } }); /** @@ -125,6 +139,14 @@ module.exports = (app, logger) => { userId: member.userId, initiatorUserId: req.authUser.userId, }, logger); + + createEvent(BUS_API_EVENT.PROJECT_TEAM_UPDATED, { + projectId: project.id, + projectName: project.name, + projectUrl: connectProjectUrl(project.id), + userId: req.authUser.userId, + initiatorUserId: req.authUser.userId, + }, logger); }).catch(err => null); // eslint-disable-line no-unused-vars }); @@ -154,6 +176,14 @@ module.exports = (app, logger) => { userId: member.userId, initiatorUserId: req.authUser.userId, }, logger); + + createEvent(BUS_API_EVENT.PROJECT_TEAM_UPDATED, { + projectId: project.id, + projectName: project.name, + projectUrl: connectProjectUrl(project.id), + userId: req.authUser.userId, + initiatorUserId: req.authUser.userId, + }, logger); } }).catch(err => null); // eslint-disable-line no-unused-vars }); @@ -165,12 +195,13 @@ module.exports = (app, logger) => { logger.debug('receive PROJECT_MEMBER_UPDATED event'); const projectId = _.parseInt(req.params.projectId); - if (updated.isPrimary && !original.isPrimary) { - models.Project.findOne({ - where: { id: projectId }, - }) - .then((project) => { - if (project) { + + models.Project.findOne({ + where: { id: projectId }, + }) + .then((project) => { + if (project) { + if (updated.isPrimary && !original.isPrimary) { createEvent(BUS_API_EVENT.MEMBER_ASSIGNED_AS_OWNER, { projectId, projectName: project.name, @@ -179,8 +210,16 @@ module.exports = (app, logger) => { initiatorUserId: req.authUser.userId, }, logger); } - }).catch(err => null); // eslint-disable-line no-unused-vars - } + + createEvent(BUS_API_EVENT.PROJECT_TEAM_UPDATED, { + projectId: project.id, + projectName: project.name, + projectUrl: connectProjectUrl(project.id), + userId: req.authUser.userId, + initiatorUserId: req.authUser.userId, + }, logger); + } + }).catch(err => null); // eslint-disable-line no-unused-vars }); /** @@ -204,9 +243,91 @@ module.exports = (app, logger) => { userId: req.authUser.userId, initiatorUserId: req.authUser.userId, }, logger); + + createEvent(BUS_API_EVENT.PROJECT_FILES_UPDATED, { + projectId: project.id, + projectName: project.name, + projectUrl: connectProjectUrl(project.id), + userId: req.authUser.userId, + initiatorUserId: req.authUser.userId, + }, logger); }).catch(err => null); // eslint-disable-line no-unused-vars }); + + /** + * PROJECT_ATTACHMENT_UPDATED + */ + app.on(EVENT.ROUTING_KEY.PROJECT_ATTACHMENT_UPDATED, ({ req }) => { + logger.debug('receive PROJECT_ATTACHMENT_UPDATED event'); + + const projectId = _.parseInt(req.params.projectId); + + models.Project.findOne({ + where: { id: projectId }, + }) + .then((project) => { + createEvent(BUS_API_EVENT.PROJECT_FILES_UPDATED, { + projectId: project.id, + projectName: project.name, + projectUrl: connectProjectUrl(project.id), + userId: req.authUser.userId, + initiatorUserId: req.authUser.userId, + }, logger); + }).catch(err => null); // eslint-disable-line no-unused-vars + }); + + /** + * PROJECT_ATTACHMENT_REMOVED + */ + app.on(EVENT.ROUTING_KEY.PROJECT_ATTACHMENT_REMOVED, ({ req }) => { + logger.debug('receive PROJECT_ATTACHMENT_REMOVED event'); + + const projectId = _.parseInt(req.params.projectId); + + models.Project.findOne({ + where: { id: projectId }, + }) + .then((project) => { + createEvent(BUS_API_EVENT.PROJECT_FILES_UPDATED, { + projectId: project.id, + projectName: project.name, + projectUrl: connectProjectUrl(project.id), + userId: req.authUser.userId, + initiatorUserId: req.authUser.userId, + }, logger); + }).catch(err => null); // eslint-disable-line no-unused-vars + }); + + /** + * If the project is in draft status and the phase is in reviewed status, and it's the + * only phase in the project with that status, then send the plan ready event. + * + * @param {object} req the req + * @param {object} project the project + * @param {object} phase the phase that was created/updated + * @returns {Promise} void + */ + async function sendPlanReadyEventIfNeeded(req, project, phase) { + if (project.status === PROJECT_STATUS.DRAFT && + phase.status === PROJECT_PHASE_STATUS.REVIEWED) { + await models.ProjectPhase.count({ + where: { projectId: project.id, status: PROJECT_PHASE_STATUS.REVIEWED }, + }).then(((count) => { + // only send the plan ready event when this is the only reviewed phase in the project + if (count === 1) { + createEvent(BUS_API_EVENT.PROJECT_PLAN_READY, { + projectId: project.id, + phaseId: phase.id, + projectName: project.name, + userId: req.authUser.userId, + initiatorUserId: req.authUser.userId, + }, logger); + } + })); + } + } + /** * PROJECT_PHASE_ADDED */ @@ -219,13 +340,14 @@ module.exports = (app, logger) => { where: { id: projectId }, }) .then((project) => { - createEvent(BUS_API_EVENT.PROJECT_PLAN_MODIFIED, { + createEvent(BUS_API_EVENT.PROJECT_PLAN_UPDATED, { projectId, projectName: project.name, projectUrl: connectProjectUrl(projectId), userId: req.authUser.userId, initiatorUserId: req.authUser.userId, }, logger); + return sendPlanReadyEventIfNeeded(req, project, created); }).catch(err => null); // eslint-disable-line no-unused-vars }); @@ -241,7 +363,7 @@ module.exports = (app, logger) => { where: { id: projectId }, }) .then((project) => { - createEvent(BUS_API_EVENT.PROJECT_PLAN_MODIFIED, { + createEvent(BUS_API_EVENT.PROJECT_PLAN_UPDATED, { projectId, projectName: project.name, projectUrl: connectProjectUrl(projectId), @@ -258,26 +380,60 @@ module.exports = (app, logger) => { logger.debug('receive PROJECT_PHASE_UPDATED event'); const projectId = _.parseInt(req.params.projectId); + const phaseId = _.parseInt(req.params.phaseId); models.Project.findOne({ where: { id: projectId }, }) .then((project) => { - createEvent(BUS_API_EVENT.PROJECT_PLAN_MODIFIED, { - projectId, - projectName: project.name, - projectUrl: connectProjectUrl(projectId), - userId: req.authUser.userId, - initiatorUserId: req.authUser.userId, - }, logger); + logger.debug(`Fetched project ${projectId} for the phase ${phaseId}`); + const eventsMap = {}; + [ + ['duration', BUS_API_EVENT.PROJECT_PLAN_UPDATED], + ['startDate', BUS_API_EVENT.PROJECT_PLAN_UPDATED], + ['spentBudget', BUS_API_EVENT.PROJECT_PHASE_UPDATE_PAYMENT], + ['progress', [BUS_API_EVENT.PROJECT_PHASE_UPDATE_PROGRESS, BUS_API_EVENT.PROJECT_PROGRESS_MODIFIED]], + ['details', BUS_API_EVENT.PROJECT_PHASE_UPDATE_SCOPE], + ['status', BUS_API_EVENT.PROJECT_PHASE_TRANSITION_ACTIVE, PROJECT_PHASE_STATUS.ACTIVE], + ['status', BUS_API_EVENT.PROJECT_PHASE_TRANSITION_COMPLETED, PROJECT_PHASE_STATUS.COMPLETED], + // ideally we should validate the old value being 'DRAFT' but there is no other status from which + // we can move phase to REVIEWED status + ['status', BUS_API_EVENT.PROJECT_PLAN_UPDATED, PROJECT_PHASE_STATUS.REVIEWED], + // ideally we should validate the old value being 'REVIEWED' but there is no other status from which + // we can move phase to DRAFT status + ['status', BUS_API_EVENT.PROJECT_PLAN_UPDATED, PROJECT_PHASE_STATUS.DRAFT], + ].forEach(([key, events, sendIfNewEqual]) => { + // eslint-disable-next-line no-param-reassign + events = Array.isArray(events) ? events : [events]; + // eslint-disable-next-line no-param-reassign + events = _.filter(events, e => !eventsMap[e]); + + // send event(s) only if the target field's value was updated, or when an update matches a "sendIfNewEqual" value + if ((!sendIfNewEqual && !_.isEqual(original[key], updated[key])) || + (original[key] !== sendIfNewEqual && updated[key] === sendIfNewEqual)) { + events.forEach(event => createEvent(event, { + projectId, + phaseId, + projectUrl: connectProjectUrl(projectId), + originalPhase: original, + updatedPhase: updated, + projectName: project.name, + userId: req.authUser.userId, + initiatorUserId: req.authUser.userId, + }, logger)); + events.forEach((event) => { eventsMap[event] = true; }); + } + }); + + return sendPlanReadyEventIfNeeded(req, project, updated); }).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'); + * 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); @@ -285,22 +441,100 @@ module.exports = (app, logger) => { where: { id: projectId }, }) .then((project) => { - createEvent(BUS_API_EVENT.PROJECT_PLAN_MODIFIED, { - projectId, + // 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, + projectUrl: connectProjectUrl(projectId), + userId: req.authUser.userId, + initiatorUserId: req.authUser.userId, + }, logger); + } + + const watchProperties = ['name', 'estimatedPrice', 'actualPrice', 'details']; + if (!_.isEqual(_.pick(original, watchProperties), + _.pick(updated, watchProperties))) { + createEvent(BUS_API_EVENT.PROJECT_PLAN_UPDATED, { + projectId, + projectName: project.name, + projectUrl: connectProjectUrl(projectId), + userId: req.authUser.userId, + initiatorUserId: req.authUser.userId, + }, logger); + } + }).catch(err => null); // eslint-disable-line no-unused-vars + }); + + /** + * Send milestone notification if needed. + * @param {Object} req the request + * @param {Object} original the original milestone + * @param {Object} updated the updated milestone + * @param {Object} project the project + * @param {Object} timeline the updated timeline + * @returns {Promise} void + */ + function sendMilestoneNotification(req, original, updated, project, timeline) { + logger.debug('sendMilestoneNotification', original, updated); + // throw generic milestone updated bus api event + createEvent(BUS_API_EVENT.MILESTONE_UPDATED, { + projectId: project.id, + projectName: project.name, + projectUrl: connectProjectUrl(project.id), + timeline, + originalMilestone: original, + updatedMilestone: updated, + userId: req.authUser.userId, + initiatorUserId: req.authUser.userId, + }, logger); + // Send transition events + if (original.status !== updated.status) { + let event; + if (updated.status === MILESTONE_STATUS.COMPLETED) { + event = BUS_API_EVENT.MILESTONE_TRANSITION_COMPLETED; + } else if (updated.status === MILESTONE_STATUS.ACTIVE) { + event = BUS_API_EVENT.MILESTONE_TRANSITION_ACTIVE; + } + + if (event) { + createEvent(event, { + projectId: project.id, projectName: project.name, - projectUrl: connectProjectUrl(projectId), + projectUrl: connectProjectUrl(project.id), + timeline, + originalMilestone: original, + updatedMilestone: updated, userId: req.authUser.userId, initiatorUserId: req.authUser.userId, - phase: created, }, logger); - }).catch(err => null); // eslint-disable-line no-unused-vars - }); + } + } + + // Send notifications.connect.project.phase.milestone.waiting.customer event + const originalWaiting = _.get(original, 'details.metadata.waitingForCustomer', false); + const updatedWaiting = _.get(updated, 'details.metadata.waitingForCustomer', false); + if (!originalWaiting && updatedWaiting) { + createEvent(BUS_API_EVENT.MILESTONE_WAITING_CUSTOMER, { + projectId: project.id, + projectName: project.name, + projectUrl: connectProjectUrl(project.id), + timeline, + originalMilestone: original, + updatedMilestone: updated, + userId: req.authUser.userId, + initiatorUserId: req.authUser.userId, + }, logger); + } + } /** - * 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'); + * MILESTONE_ADDED. + */ + app.on(EVENT.ROUTING_KEY.MILESTONE_ADDED, ({ req, created }) => { + logger.debug('receive MILESTONE_ADDED event'); const projectId = _.parseInt(req.params.projectId); @@ -308,53 +542,120 @@ module.exports = (app, logger) => { where: { id: projectId }, }) .then((project) => { - createEvent(BUS_API_EVENT.PROJECT_PLAN_MODIFIED, { - projectId, - projectName: project.name, - projectUrl: connectProjectUrl(projectId), - userId: req.authUser.userId, - initiatorUserId: req.authUser.userId, - }, logger); - }).catch(err => null); // eslint-disable-line no-unused-vars + if (project) { + createEvent(BUS_API_EVENT.MILESTONE_ADDED, { + projectId, + projectName: project.name, + projectUrl: connectProjectUrl(projectId), + addedMilestone: created, + userId: req.authUser.userId, + initiatorUserId: req.authUser.userId, + }, logger); + } + // sendMilestoneNotification(req, {}, created, project); + }) + .catch(err => null); // eslint-disable-line no-unused-vars }); /** - * PROJECT_PHASE_PRODUCT_UPDATED + * MILESTONE_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'); + // eslint-disable-next-line no-unused-vars + app.on(EVENT.ROUTING_KEY.MILESTONE_UPDATED, ({ req, original, updated, cascadedUpdates }) => { + logger.debug(`receive MILESTONE_UPDATED event for milestone ${original.id}`); const projectId = _.parseInt(req.params.projectId); + const timeline = _.omit(req.timeline.toJSON(), 'deletedAt', 'deletedBy'); 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}`); + .then((project) => { + logger.debug(`Found project with id ${projectId}`); + return models.Milestone.getTimelineDuration(timeline.id) + .then(({ duration, progress }) => { + timeline.duration = duration; + timeline.progress = progress; + sendMilestoneNotification(req, original, updated, project, timeline); + + logger.debug('cascadedUpdates', cascadedUpdates); + if (cascadedUpdates && cascadedUpdates.milestones && cascadedUpdates.milestones.length > 0) { + _.each(cascadedUpdates.milestones, cascadedUpdate => + sendMilestoneNotification(req, cascadedUpdate.original, cascadedUpdate.updated, project, timeline), + ); + } - createEvent(BUS_API_EVENT.PROJECT_PRODUCT_SPECIFICATION_MODIFIED, { - projectId, - projectName: project.name, - projectUrl: connectProjectUrl(projectId), - userId: req.authUser.userId, - initiatorUserId: req.authUser.userId, - }, logger); + // if timeline is modified + if (cascadedUpdates && cascadedUpdates.timeline) { + const cTimeline = cascadedUpdates.timeline; + // if endDate of the timeline is modified, raise TIMELINE_ADJUSTED event + if (cTimeline.original.endDate !== cTimeline.updated.endDate) { + // Raise Timeline changed event + createEvent(BUS_API_EVENT.TIMELINE_ADJUSTED, { + projectId: project.id, + projectName: project.name, + projectUrl: connectProjectUrl(project.id), + originalTimeline: cTimeline.original, + updatedTimeline: cTimeline.updated, + userId: req.authUser.userId, + initiatorUserId: req.authUser.userId, + }, logger); + } } + }); + }).catch(err => null); // eslint-disable-line no-unused-vars + }); + + /** + * MILESTONE_REMOVED. + */ + app.on(EVENT.ROUTING_KEY.MILESTONE_REMOVED, ({ req, deleted }) => { + logger.debug('receive MILESTONE_REMOVED event'); + // req.params.projectId is set by validateTimelineIdParam middleware + const projectId = _.parseInt(req.params.projectId); + + models.Project.findOne({ + where: { id: projectId }, + }) + .then((project) => { + if (project) { + createEvent(BUS_API_EVENT.MILESTONE_REMOVED, { + projectId, + projectName: project.name, + projectUrl: connectProjectUrl(projectId), + removedMilestone: deleted, + userId: req.authUser.userId, + initiatorUserId: req.authUser.userId, + }, logger); + } + }).catch(err => null); // eslint-disable-line no-unused-vars + }); + + app.on(EVENT.ROUTING_KEY.TIMELINE_UPDATED, ({ req, original, updated }) => { + logger.debug('receive TIMELINE_UPDATED event'); + // send PROJECT_UPDATED Kafka message when one of the specified below properties changed + const watchProperties = ['startDate', 'endDate']; + if (!_.isEqual(_.pick(original, watchProperties), + _.pick(updated, watchProperties))) { + // req.params.projectId is set by validateTimelineIdParam middleware + const projectId = _.parseInt(req.params.projectId); - // 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, { + models.Project.findOne({ + where: { id: projectId }, + }) + .then((project) => { + if (project) { + createEvent(BUS_API_EVENT.TIMELINE_ADJUSTED, { projectId, projectName: project.name, projectUrl: connectProjectUrl(projectId), + originalTimeline: original, + updatedTimeline: updated, userId: req.authUser.userId, initiatorUserId: req.authUser.userId, }, logger); } }).catch(err => null); // eslint-disable-line no-unused-vars + } }); }; diff --git a/src/events/index.js b/src/events/index.js index fac17d8d..23a3f037 100644 --- a/src/events/index.js +++ b/src/events/index.js @@ -1,6 +1,7 @@ -import { EVENT } from '../constants'; -import { projectCreatedHandler, projectUpdatedHandler, projectDeletedHandler } from './projects'; +import { EVENT, BUS_API_EVENT } from '../constants'; +import { projectCreatedHandler, projectUpdatedHandler, projectDeletedHandler, + projectUpdatedKafkaHandler } from './projects'; import { projectMemberAddedHandler, projectMemberRemovedHandler, projectMemberUpdatedHandler } from './projectMembers'; import { projectAttachmentAddedHandler, projectAttachmentRemovedHandler, @@ -9,10 +10,20 @@ import { projectPhaseAddedHandler, projectPhaseRemovedHandler, projectPhaseUpdatedHandler } from './projectPhases'; import { phaseProductAddedHandler, phaseProductRemovedHandler, phaseProductUpdatedHandler } from './phaseProducts'; -import { timelineAddedHandler, timelineUpdatedHandler, timelineRemovedHandler } from './timelines'; -import { milestoneAddedHandler, milestoneUpdatedHandler, milestoneRemovedHandler } from './milestones'; +import { + timelineAddedHandler, + timelineUpdatedHandler, + timelineRemovedHandler, + timelineAdjustedKafkaHandler, +} from './timelines'; +import { + milestoneAddedHandler, + milestoneUpdatedHandler, + milestoneRemovedHandler, + milestoneUpdatedKafkaHandler, +} from './milestones'; -export default { +export const rabbitHandlers = { 'project.initial': projectCreatedHandler, [EVENT.ROUTING_KEY.PROJECT_DRAFT_CREATED]: projectCreatedHandler, [EVENT.ROUTING_KEY.PROJECT_UPDATED]: projectUpdatedHandler, @@ -42,3 +53,21 @@ export default { [EVENT.ROUTING_KEY.MILESTONE_REMOVED]: milestoneRemovedHandler, [EVENT.ROUTING_KEY.MILESTONE_UPDATED]: milestoneUpdatedHandler, }; + +export const kafkaHandlers = { + // Events defined by project-service + [BUS_API_EVENT.PROJECT_UPDATED]: projectUpdatedKafkaHandler, + [BUS_API_EVENT.PROJECT_FILES_UPDATED]: projectUpdatedKafkaHandler, + [BUS_API_EVENT.PROJECT_TEAM_UPDATED]: projectUpdatedKafkaHandler, + [BUS_API_EVENT.PROJECT_PLAN_UPDATED]: projectUpdatedKafkaHandler, + + // Events from message-service + [BUS_API_EVENT.TOPIC_CREATED]: projectUpdatedKafkaHandler, + [BUS_API_EVENT.TOPIC_UPDATED]: projectUpdatedKafkaHandler, + [BUS_API_EVENT.POST_CREATED]: projectUpdatedKafkaHandler, + [BUS_API_EVENT.POST_UPDATED]: projectUpdatedKafkaHandler, + + // Events coming from timeline/milestones (considering it as a separate module/service in future) + [BUS_API_EVENT.MILESTONE_TRANSITION_COMPLETED]: milestoneUpdatedKafkaHandler, + [BUS_API_EVENT.TIMELINE_ADJUSTED]: timelineAdjustedKafkaHandler, +}; diff --git a/src/events/milestones/index.js b/src/events/milestones/index.js index 71fd0d6b..d8f884a9 100644 --- a/src/events/milestones/index.js +++ b/src/events/milestones/index.js @@ -3,8 +3,12 @@ */ import config from 'config'; import _ from 'lodash'; +import Joi from 'joi'; import Promise from 'bluebird'; import util from '../../util'; +// import { createEvent } from '../../services/busApi'; +import { EVENT, TIMELINE_REFERENCES, MILESTONE_STATUS, REGEX } from '../../constants'; +import models from '../../models'; const ES_TIMELINE_INDEX = config.get('elasticsearchConfig.timelineIndexName'); const ES_TIMELINE_TYPE = config.get('elasticsearchConfig.timelineDocType'); @@ -154,9 +158,105 @@ const milestoneRemovedHandler = Promise.coroutine(function* (logger, msg, channe } }); +/** + * Kafka event handlers + */ + +const payloadSchema = Joi.object().keys({ + projectId: Joi.number().integer().positive().required(), + projectName: Joi.string().optional(), + projectUrl: Joi.string().regex(REGEX.URL).optional(), + userId: Joi.number().integer().positive().required(), + initiatorUserId: Joi.number().integer().positive().required(), +}).unknown(true).required(); + +const findProjectPhaseProduct = function (logger, productId, raw = true) { // eslint-disable-line func-names + let product; + return models.PhaseProduct.findOne({ + where: { id: productId }, + raw, + }).then((_product) => { + logger.debug('_product', _product); + if (_product) { + product = _product; + const phaseId = product.phaseId; + const projectId = product.projectId; + return Promise.all([ + models.ProjectPhase.findOne({ + where: { id: phaseId, projectId }, + raw, + }), + models.Project.findOne({ + where: { id: projectId }, + raw, + }), + ]); + } + return Promise.reject('Unable to find product'); + }).then((projectAndPhase) => { + logger.debug('projectAndPhase', projectAndPhase); + if (projectAndPhase) { + const phase = projectAndPhase[0]; + const project = projectAndPhase[1]; + return Promise.resolve({ product, phase, project }); + } + return Promise.reject('Unable to find phase/project'); + }); +}; + +/** + * Raises the project plan modified event + * @param {Object} app Application object used to interact with RMQ service + * @param {String} topic Kafka topic + * @param {Object} payload Message payload + * @return {Promise} Promise + */ +async function milestoneUpdatedKafkaHandler(app, topic, payload) { + app.logger.info(`Handling Kafka event for ${topic}`); + // Validate payload + const result = Joi.validate(payload, payloadSchema); + if (result.error) { + throw new Error(result.error); + } + + const timeline = payload.timeline; + // process only if timeline is related to a product reference + if (timeline && timeline.reference === TIMELINE_REFERENCES.PRODUCT) { + const productId = timeline.referenceId; + const original = payload.originalMilestone; + const updated = payload.updatedMilestone; + app.logger.debug('Calling findProjectPhaseProduct'); + const { project, phase } = await findProjectPhaseProduct(app.logger, productId, false); + app.logger.debug('Successfully fetched project, phase and product'); + if (original.status !== updated.status) { + if (updated.status === MILESTONE_STATUS.COMPLETED) { + app.logger.debug('Found milestone status to be completed'); + app.logger.debug(`Duration: ${timeline.duration}`); + if (!isNaN(timeline.duration) && !isNaN(timeline.progress)) { + app.logger.debug(`Current phase progress ${phase.progress} and duration ${phase.duration}`); + const updatedPhase = await phase.update({ + progress: timeline.progress, + duration: timeline.duration, + }, ['progress', 'duration']); + app.logger.debug(`Updated phase progress ${timeline.progress} and duration ${timeline.duration}`); + app.logger.debug('Raising node event for PROJECT_PHASE_UPDATED'); + app.emit(EVENT.ROUTING_KEY.PROJECT_PHASE_UPDATED, { + req: { + params: { projectId: project.id, phaseId: phase.id }, + authUser: { userId: payload.userId }, + }, + original: phase, + updated: _.omit(updatedPhase.toJSON(), 'deletedAt', 'deletedBy'), + }); + } + } + } + } +} module.exports = { milestoneAddedHandler, milestoneRemovedHandler, milestoneUpdatedHandler, + milestoneUpdatedKafkaHandler, }; diff --git a/src/events/projectPhases/index.js b/src/events/projectPhases/index.js index 656dcfb1..09e964cb 100644 --- a/src/events/projectPhases/index.js +++ b/src/events/projectPhases/index.js @@ -29,6 +29,14 @@ const indexProjectPhase = Promise.coroutine(function* (logger, phase) { // eslin const existingPhaseIndex = _.findIndex(phases, p => p.id === phase.id); // if phase does not exists already if (existingPhaseIndex === -1) { + // Increase the order of the other phases in the same project, + // which have `order` >= this phase order + _.each(phases, (_phase) => { + if (!_.isNil(_phase.order) && !_.isNil(phase.order) && _phase.order >= phase.order) { + _phase.order += 1; // eslint-disable-line no-param-reassign + } + }); + 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 @@ -109,12 +117,7 @@ const projectPhaseUpdatedHandler = Promise.coroutine(function* (logger, msg, cha 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 phases = _.map(data.allPhases, single => _.omit(single, ['deletedAt', 'deletedBy'])); const merged = _.assign(doc._source, { phases }); // eslint-disable-line no-underscore-dangle yield eClient.update({ index: ES_PROJECT_INDEX, diff --git a/src/events/projects/index.js b/src/events/projects/index.js index 64a7690a..34fe0834 100644 --- a/src/events/projects/index.js +++ b/src/events/projects/index.js @@ -2,10 +2,13 @@ * Event handlers for project create, update and delete */ import _ from 'lodash'; +import Joi from 'joi'; import Promise from 'bluebird'; import config from 'config'; import util from '../../util'; +import models from '../../models'; import { createPhaseTopic } from '../projectPhases'; +import { REGEX } from '../../constants'; const ES_PROJECT_INDEX = config.get('elasticsearchConfig.indexName'); const ES_PROJECT_TYPE = config.get('elasticsearchConfig.docType'); @@ -128,8 +131,66 @@ const projectDeletedHandler = Promise.coroutine(function* (logger, msg, channel) } }); +/** + * Kafka event handlers + */ + +const payloadSchema = Joi.object().keys({ + projectId: Joi.number().integer().positive().required(), + projectName: Joi.string().optional(), + projectUrl: Joi.string().regex(REGEX.URL).optional(), + userId: Joi.number().integer().positive().required(), + initiatorUserId: Joi.number().integer().positive().required(), +}).unknown(true).required(); + +/** + * Updates project activity fields. throws exceptions in case of error + * @param {Object} app Application object used to interact with RMQ service + * @param {String} topic Kafka topic + * @param {Object} payload Message payload + * @return {Promise} Promise + */ +async function projectUpdatedKafkaHandler(app, topic, payload) { + // Validate payload + const result = Joi.validate(payload, payloadSchema); + if (result.error) { + throw new Error(result.error); + } + + // Find project by id and update activity. Single update is used as there is no need to wrap it into transaction + const projectId = payload.projectId; + const project = await models.Project.findById(projectId); + if (!project) { + throw new Error(`Project with id ${projectId} not found`); + } + const previousValue = project.get({ plain: true }); + project.lastActivityAt = new Date(); + project.lastActivityUserId = payload.initiatorUserId.toString(); + + await project.save(); + + // first get the existing document and than merge the updated changes and save the new document + try { + const doc = await eClient.get({ index: ES_PROJECT_INDEX, type: ES_PROJECT_TYPE, id: previousValue.id }); + const merged = _.merge(doc._source, project.get({ plain: true })); // eslint-disable-line no-underscore-dangle + // update the merged document + await eClient.update({ + index: ES_PROJECT_INDEX, + type: ES_PROJECT_TYPE, + id: previousValue.id, + body: { + doc: merged, + }, + }); + } catch (error) { + throw Error(`failed to updated project document in elasitcsearch index (projectId: ${previousValue.id})` + + `. Details: '${error}'.`); + } +} + module.exports = { projectCreatedHandler, projectUpdatedHandler, projectDeletedHandler, + projectUpdatedKafkaHandler, }; diff --git a/src/events/projects/index.spec.js b/src/events/projects/index.spec.js new file mode 100644 index 00000000..9067166e --- /dev/null +++ b/src/events/projects/index.spec.js @@ -0,0 +1,148 @@ +/* eslint-disable no-unused-expressions */ +import _ from 'lodash'; +import sinon from 'sinon'; +import chai, { expect } from 'chai'; +import config from 'config'; +import util from '../../util'; +import models from '../../models'; +import { projectUpdatedKafkaHandler } from './index'; +import testUtil from '../../tests/util'; +import server from '../../app'; + +const ES_PROJECT_INDEX = config.get('elasticsearchConfig.indexName'); +const ES_PROJECT_TYPE = config.get('elasticsearchConfig.docType'); +const eClient = util.getElasticSearchClient(); + +chai.use(require('chai-as-promised')); + +describe('projectUpdatedKafkaHandler', () => { + // Any topic name is fine here as routing happens in kafkaConsumer + const topic = 'topic'; + + const validPayload = { + projectId: 1, + projectName: 'test project', + projectUrl: 'http://someurl.com', + userId: 1, + initiatorUserId: 2, + }; + + const mockedApp = { + services: { + pubsub: { + publish: sinon.stub(), + }, + }, + }; + + it('should throw validation exception when payload is empty', async () => { + await expect(projectUpdatedKafkaHandler(mockedApp, topic, {})).to.be.rejectedWith(Error); + }); + + it('should throw validation exception when projectId is not set', async () => { + const payload = _.omit(validPayload, 'projectId'); + await expect(projectUpdatedKafkaHandler(mockedApp, topic, payload)).to.be.rejectedWith(Error); + }); + + it('should throw validation exception when projectName is not set', async () => { + const payload = _.omit(validPayload, 'projectName'); + await expect(projectUpdatedKafkaHandler(mockedApp, mockedApp, topic, payload)) + .to.be.rejectedWith(Error); + }); + + it('should throw validation exception when projectUrl is not set', async () => { + const payload = _.omit(validPayload, 'projectUrl'); + await expect(projectUpdatedKafkaHandler(mockedApp, topic, payload)).to.be.rejectedWith(Error); + }); + + it('should throw validation exception when userId is not set', async () => { + const payload = _.omit(validPayload, 'userId'); + await expect(projectUpdatedKafkaHandler(mockedApp, topic, payload)).to.be.rejectedWith(Error); + }); + + it('should throw validation exception when initiatorUserId is not set', async () => { + const payload = _.omit(validPayload, 'initiatorUserId'); + await expect(projectUpdatedKafkaHandler(mockedApp, topic, payload)).to.be.rejectedWith(Error); + }); + + it('should throw validation exception when projectId is not integer', async () => { + const payload = _.clone(validPayload); + payload.projectId = 'string'; + await expect(projectUpdatedKafkaHandler(mockedApp, topic, payload)).to.be.rejectedWith(Error); + }); + + it('should throw validation exception when projectUrl is not a valid url', async () => { + const payload = _.clone(validPayload); + payload.projectUrl = 'string'; + await expect(projectUpdatedKafkaHandler(mockedApp, topic, payload)).to.be.rejectedWith(Error); + }); + + it('should throw validation exception when userId is not integer', async () => { + const payload = _.clone(validPayload); + payload.userId = 'string'; + await expect(projectUpdatedKafkaHandler(mockedApp, topic, payload)).to.be.rejectedWith(Error); + }); + + it('should throw validation exception when initiatorUserId is not integer', async () => { + const payload = _.clone(validPayload); + payload.initiatorUserId = 'string'; + await expect(projectUpdatedKafkaHandler(mockedApp, topic, payload)).to.be.rejectedWith(Error); + }); + + describe('integration', () => { + let project; + + beforeEach(async () => { + await testUtil.clearDb(); + project = await models.Project.create({ + type: 'generic', + billingAccountId: 1, + name: 'test1', + description: 'test project1', + status: 'draft', + details: {}, + createdBy: 1, + updatedBy: 1, + lastActivityAt: 1, + lastActivityUserId: '1', + }); + // add project to ES index + await server.services.es.index({ + index: ES_PROJECT_INDEX, + type: ES_PROJECT_TYPE, + id: project.id, + body: { + doc: project.get({ plain: true }), + }, + }); + }); + + after(async () => { + await testUtil.clearDb(); + }); + + it('should throw exception when project not found by id', async () => { + const payload = _.clone(validPayload); + payload.projectId = 2; + await expect(projectUpdatedKafkaHandler(mockedApp, topic, payload)).to.be + .rejectedWith(Error, 'Project with id 2 not found'); + }); + + it('should update lastActivityAt and lastActivityUserId columns in db', async () => { + await projectUpdatedKafkaHandler(mockedApp, topic, validPayload); + + const updatedProject = await models.Project.findById(project.id); + expect(updatedProject.lastActivityUserId).to.be.eql('2'); + expect(updatedProject.lastActivityAt).to.be.greaterThan(project.lastActivityAt); + }); + + it('should update ES index', async () => { + await projectUpdatedKafkaHandler(mockedApp, topic, validPayload); + + const doc = await eClient.get({ index: ES_PROJECT_INDEX, type: ES_PROJECT_TYPE, id: validPayload.projectId }); + const esProject = doc._source; // eslint-disable-line no-underscore-dangle + expect(esProject.lastActivityUserId).to.be.eql('2'); + expect(new Date(esProject.lastActivityAt)).to.be.greaterThan(project.lastActivityAt); + }); + }); +}); diff --git a/src/events/timelines/index.js b/src/events/timelines/index.js index 0de36410..39ed0636 100644 --- a/src/events/timelines/index.js +++ b/src/events/timelines/index.js @@ -2,14 +2,29 @@ * Event handlers for timeline create, update and delete */ import _ from 'lodash'; +import Joi from 'joi'; import Promise from 'bluebird'; import config from 'config'; import util from '../../util'; +import { BUS_API_EVENT, TIMELINE_REFERENCES, REGEX } from '../../constants'; +import models from '../../models'; +import { createEvent } from '../../services/busApi'; const ES_TIMELINE_INDEX = config.get('elasticsearchConfig.timelineIndexName'); const ES_TIMELINE_TYPE = config.get('elasticsearchConfig.timelineDocType'); const eClient = util.getElasticSearchClient(); + +/** + * Builds the connect project url for the given project id. + * + * @param {string|number} projectId the project id + * @returns {string} the connect project url + */ +function connectProjectUrl(projectId) { + return `${config.get('connectProjectsUrl')}${projectId}`; +} + /** * Handler for timeline creation event * @param {Object} logger logger to log along with trace id @@ -81,10 +96,89 @@ const timelineRemovedHandler = Promise.coroutine(function* (logger, msg, channel channel.nack(msg, false, !msg.fields.redelivered); } }); +/** + * Kafka event handlers + */ + +const payloadSchema = Joi.object().keys({ + projectId: Joi.number().integer().positive().required(), + projectName: Joi.string().optional(), + projectUrl: Joi.string().regex(REGEX.URL).optional(), + userId: Joi.number().integer().positive().required(), + initiatorUserId: Joi.number().integer().positive().required(), +}).unknown(true).required(); +const findProjectPhaseProduct = function (logger, productId) { // eslint-disable-line func-names + let product; + return models.PhaseProduct.findOne({ + where: { id: productId }, + raw: true, + }).then((_product) => { + logger.debug('_product', _product); + if (_product) { + product = _product; + const phaseId = product.phaseId; + const projectId = product.projectId; + return Promise.all([ + models.ProjectPhase.findOne({ + where: { id: phaseId, projectId }, + raw: true, + }), + models.Project.findOne({ + where: { id: projectId }, + raw: true, + }), + ]); + } + return Promise.reject('Unable to find product'); + }).then((projectAndPhase) => { + logger.debug('projectAndPhase', projectAndPhase); + if (projectAndPhase) { + const phase = projectAndPhase[0]; + const project = projectAndPhase[1]; + return Promise.resolve({ product, phase, project }); + } + return Promise.reject('Unable to find phase/project'); + }); +}; + +/** + * Raises the project plan modified event + * @param {Object} app Application object used to interact with RMQ service + * @param {String} topic Kafka topic + * @param {Object} payload Message payload + * @return {Promise} Promise + */ +async function timelineAdjustedKafkaHandler(app, topic, payload) { + app.logger.debug(`Handling Kafka event for ${topic}`); + // Validate payload + const result = Joi.validate(payload, payloadSchema); + if (result.error) { + throw new Error(result.error); + } + + const timeline = payload.updatedTimeline; + // process only if timeline is related to a product reference + if (timeline && timeline.reference === TIMELINE_REFERENCES.PRODUCT) { + app.logger.debug('Found product timelin event '); + const productId = timeline.referenceId; + app.logger.debug('Calling findProjectPhaseProduct'); + const { project } = await findProjectPhaseProduct(app.logger, productId); + app.logger.debug('Successfully fetched project, phase and product'); + app.logger.debug('Raising BUS event for PROJECT_PLAN_UPDATED'); + createEvent(BUS_API_EVENT.PROJECT_PLAN_UPDATED, { + projectId: project.id, + projectName: project.name, + projectUrl: connectProjectUrl(project.id), + userId: payload.userId, + initiatorUserId: payload.userId, + }, app.logger); + } +} module.exports = { timelineAddedHandler, timelineUpdatedHandler, timelineRemovedHandler, + timelineAdjustedKafkaHandler, }; diff --git a/src/middlewares/performanceRequestLogger.js b/src/middlewares/performanceRequestLogger.js new file mode 100644 index 00000000..b0a32d8e --- /dev/null +++ b/src/middlewares/performanceRequestLogger.js @@ -0,0 +1,38 @@ +import coreLib from 'tc-core-library-js'; + +module.exports = function logRequest(logger) { + if (!logger) { + throw new Error('Logger must be provided'); + } + + // Use the logger from core lib for non-dev environment + if (process.env.NODE_ENV.toLowerCase() !== 'development') { + return coreLib.middleware.logger(null, logger); + } + + // Use the logger with memory usage info + return (req, res, next) => { + const startOpts = { + method: req.method, + url: req.url, + }; + // Create a per-request child + req.log = logger.child({ requestId: req.id }); + res.log = req.log; + req.log.info('start request', startOpts); + const time = process.hrtime(); + res.on('finish', () => { + const diff = process.hrtime(time); + res.log.info('end request', { + method: startOpts.method, + url: startOpts.url, + statusCode: res.statusCode, + statusMessage: res.statusMessage, + duration: diff[0] * 1e3 + diff[1] * 1e-6, // eslint-disable-line no-mixed-operators + heapUsed: process.memoryUsage().heapUsed, + }); + }); + + next(); + }; +}; diff --git a/src/middlewares/userIdAuth.js b/src/middlewares/userIdAuth.js new file mode 100644 index 00000000..20f90c9d --- /dev/null +++ b/src/middlewares/userIdAuth.js @@ -0,0 +1,72 @@ +/** + * The userId authentication middleware. + */ +import config from 'config'; +import _ from 'lodash'; +import util from '../util'; + +const whitelistedOrigins = JSON.parse(config.get('whitelistedOriginsForUserIdAuth')); + +/** + * The userId authentication middleware. + * @param {Object} req the request + * @param {Object} res the response + * @param {Function} next the next middleware + * @returns {Promise} void + */ +module.exports = function userIdAuth(req, res, next) { // eslint-disable-line consistent-return + req.log.debug('Enter userIdAuth middleware'); + + const bearerUserId = 'Bearer userId_'; + + if (!req.headers.authorization || + !req.headers.authorization.startsWith(bearerUserId) || + req.headers.authorization.length === bearerUserId.length) { + res.status(403) + .json(util.wrapErrorResponse(req.id, 403, 'No userId provided.')); + return res.send(); + } + + // Check origin + const origin = req.header('Origin') || ' '; + if (!_.some(whitelistedOrigins, whitelistedOrigin => origin.startsWith(whitelistedOrigin))) { + res.status(403).json( + util.wrapErrorResponse(req.id, 403, `Origin ${origin} is not allowed to access this authentication scheme`)); + return res.end(); + } + + const userId = req.headers.authorization.split(bearerUserId)[1]; + + req.log.debug('Get m2m token'); + util.getM2MToken() + .then((token) => { + req.log.debug(`Get topcoder user from identity service, userId = ${userId}`); + + return util.getTopcoderUser(userId, token, req.log) + .then((user) => { + if (!user) { + res.status(403) + .json(util.wrapErrorResponse(req.id, 403, 'User does not exist.')); + return res.end(); + } + + if (user.active) { + res.status(403) + .json(util.wrapErrorResponse(req.id, 403, 'User is not inactive.')); + return res.end(); + } + + // Store user into the request + req.authUser = user; + req.authUser.userId = user.id; + req.authUser.roles = req.authUser.roles || []; + req.log.debug('req.authUser=>', req.authUser); + + return next(); + }); + }) + .catch((err) => { + req.log.error('Failed to get m2m token', err); + next(err); + }); +}; diff --git a/src/middlewares/validateMilestoneTemplate.js b/src/middlewares/validateMilestoneTemplate.js new file mode 100644 index 00000000..1aea3ace --- /dev/null +++ b/src/middlewares/validateMilestoneTemplate.js @@ -0,0 +1,136 @@ +import _ from 'lodash'; +import { MILESTONE_TEMPLATE_REFERENCES } from '../constants'; +import models from '../models'; +import util from '../util'; + +// eslint-disable-next-line valid-jsdoc +/** + * Common validation code for types of milestone template references. + * @param {{ reference: string, referenceId: string|number }} sourceObject + * @returns {Promise} + */ +async function validateReference(sourceObject) { + // The source object refers to a product template + if (sourceObject.reference === MILESTONE_TEMPLATE_REFERENCES.PRODUCT_TEMPLATE) { + // Validate ProductTemplate to be existed + const productTemplate = await models.ProductTemplate.findOne({ + where: { + id: sourceObject.referenceId, + deletedAt: { $eq: null }, + }, + }); + if (!productTemplate) { + const apiErr = new Error( + `Product template not found for product template id ${sourceObject.referenceId}`); + apiErr.status = 422; + throw apiErr; + } + } +} + +const validateMilestoneTemplate = { + + /** + * The middleware to validate MilestoneTemplate request object. + * This should be called after the validate() middleware, + * and before the permissions() middleware. + * @param {Object} req the express request instance + * @param {Object} res the express response instance + * @param {Function} next the express next middleware + */ + // eslint-disable-next-line valid-jsdoc + validateRequestBody: (req, res, next) => { + validateReference(req.body.param, req) + .then(() => { + if (req.body.param.sourceReference) { + return validateReference({ + reference: req.body.param.sourceReference, + referenceId: req.body.param.sourceReferenceId, + }); + } + + return Promise.resolve(); + }) + .then(next) + .catch(next); + }, + + /** + * The middleware to validate reference/referenceId pair + * present in the request's query filter and set to the request params. Because of the filter needs + * to be parsed, this can be the first middleware in the stack, and can be placed before the permissions() + * middleware. + * @param {Object} req the express request instance + * @param {Object} res the express response instance + * @param {Function} next the express next middleware + */ + // eslint-disable-next-line valid-jsdoc + validateQueryFilter: (req, res, next) => { + if (!req.query.filter) { + return next(); + } + + // Validate the filter + const filter = util.parseQueryFilter(req.query.filter); + + // Save the parsed filter for later + req.params.filter = filter; + + if (!util.isValidFilter(filter, ['reference', 'referenceId'])) { + const apiErr = new Error('Only allowed to filter by reference and referenceId'); + apiErr.status = 422; + return next(apiErr); + } + + // Verify required filters are present + if (!filter.reference || !filter.referenceId) { + const apiErr = new Error('Please provide reference and referenceId filter parameters'); + apiErr.status = 422; + return next(apiErr); + } + + // Verify reference is a valid value + if (!_.includes(MILESTONE_TEMPLATE_REFERENCES, filter.reference)) { + const apiErr = new Error(`reference filter must be in ${MILESTONE_TEMPLATE_REFERENCES}`); + apiErr.status = 422; + return next(apiErr); + } + + if (_.lt(filter.referenceId, 1)) { + const apiErr = new Error('referenceId filter must be a positive integer'); + apiErr.status = 422; + return next(apiErr); + } + + return validateReference(filter, req) + .then(next) + .catch(next); + }, + + /** + * The middleware to validate milestoneTemplateId from request + * path parameter, and set to the request params. This should be called after the validate() + * middleware, and before the permissions() middleware. + * @param {Object} req the express request instance + * @param {Object} res the express response instance + * @param {Function} next the express next middleware + */ + // eslint-disable-next-line valid-jsdoc + validateIdParam: (req, res, next) => { + models.MilestoneTemplate.findById(req.params.milestoneTemplateId) + .then((milestoneTemplate) => { + if (!milestoneTemplate) { + const apiErr = new Error( + `MilestoneTemplate not found for id ${req.params.milestoneTemplateId}`); + apiErr.status = 404; + return next(apiErr); + } + + req.milestoneTemplate = milestoneTemplate; + + return next(); + }); + }, +}; + +export default validateMilestoneTemplate; diff --git a/src/models/milestone.js b/src/models/milestone.js index 0f4bc4ec..53429883 100644 --- a/src/models/milestone.js +++ b/src/models/milestone.js @@ -1,3 +1,4 @@ +import moment from 'moment'; /* eslint-disable valid-jsdoc */ /** @@ -35,6 +36,54 @@ module.exports = (sequelize, DataTypes) => { updatedAt: 'updatedAt', createdAt: 'createdAt', deletedAt: 'deletedAt', + classMethods: { + /** + * Get total duration of the given timeline by summing up individual milestone durations + * @param timelineId the id of timeline + */ + getTimelineDuration(timelineId) { + console.log('getTimelineDuration'); + const where = { timelineId, hidden: false }; + return this.findAll({ + where, + order: [['order', 'asc']], + attributes: ['id', 'duration', 'startDate', 'endDate', 'actualStartDate', 'completionDate'], + raw: true, + }) + .then((milestones) => { + let scheduledDuration = 0; + let completedDuration = 0; + let duration = 0; + let progress = 0; + if (milestones) { + const fMilestone = milestones[0]; + const lMilestone = milestones[milestones.length - 1]; + const startDate = fMilestone.actualStartDate ? fMilestone.actualStartDate : fMilestone.startDate; + const endDate = lMilestone.completionDate ? lMilestone.completionDate : lMilestone.endDate; + duration = moment.utc(endDate).diff(moment.utc(startDate), 'days') + 1; + milestones.forEach((m) => { + if (m.completionDate !== null) { + let mDuration = 0; + if (m.actualStartDate !== null) { + mDuration = moment.utc(m.completionDate).diff(moment.utc(m.actualStartDate), 'days') + 1; + } else { + mDuration = moment.utc(m.completionDate).diff(moment.utc(m.startDate), 'days') + 1; + } + scheduledDuration += mDuration; + completedDuration += mDuration; + } else { + scheduledDuration += m.duration; + } + }); + console.log(`${completedDuration} completed out of ${scheduledDuration} duration`); + if (scheduledDuration > 0) { + progress = Math.round((completedDuration / scheduledDuration) * 100); + } + } + return Promise.resolve({ duration, progress }); + }); + }, + }, }); return Milestone; diff --git a/src/models/productMilestoneTemplate.js b/src/models/milestoneTemplate.js similarity index 77% rename from src/models/productMilestoneTemplate.js rename to src/models/milestoneTemplate.js index 7db76b52..feb37a1d 100644 --- a/src/models/productMilestoneTemplate.js +++ b/src/models/milestoneTemplate.js @@ -1,10 +1,10 @@ /* eslint-disable valid-jsdoc */ /** - * The Product Milestone Template model + * The Milestone Template model */ module.exports = (sequelize, DataTypes) => { - const ProductMilestoneTemplate = sequelize.define('ProductMilestoneTemplate', { + const MilestoneTemplate = sequelize.define('MilestoneTemplate', { id: { type: DataTypes.BIGINT, primaryKey: true, autoIncrement: true }, name: { type: DataTypes.STRING(255), allowNull: false }, description: DataTypes.STRING(255), @@ -16,6 +16,11 @@ module.exports = (sequelize, DataTypes) => { completedText: { type: DataTypes.STRING(512), allowNull: false }, blockedText: { type: DataTypes.STRING(512), allowNull: false }, hidden: { type: DataTypes.BOOLEAN, defaultValue: false }, + + reference: { type: DataTypes.STRING(45), allowNull: false }, + referenceId: { type: DataTypes.BIGINT, allowNull: false }, + metadata: { type: DataTypes.JSON, defaultValue: {}, allowNull: false }, + deletedAt: DataTypes.DATE, createdAt: { type: DataTypes.DATE, defaultValue: DataTypes.NOW }, updatedAt: { type: DataTypes.DATE, defaultValue: DataTypes.NOW }, @@ -23,7 +28,7 @@ module.exports = (sequelize, DataTypes) => { createdBy: { type: DataTypes.BIGINT, allowNull: false }, updatedBy: { type: DataTypes.BIGINT, allowNull: false }, }, { - tableName: 'product_milestone_templates', + tableName: 'milestone_templates', paranoid: true, timestamps: true, updatedAt: 'updatedAt', @@ -31,5 +36,5 @@ module.exports = (sequelize, DataTypes) => { deletedAt: 'deletedAt', }); - return ProductMilestoneTemplate; + return MilestoneTemplate; }; diff --git a/src/models/productTemplate.js b/src/models/productTemplate.js index fe955de8..4e4dc184 100644 --- a/src/models/productTemplate.js +++ b/src/models/productTemplate.js @@ -29,15 +29,6 @@ module.exports = (sequelize, DataTypes) => { updatedAt: 'updatedAt', createdAt: 'createdAt', deletedAt: 'deletedAt', - classMethods: { - associate: (models) => { - ProductTemplate.hasMany(models.ProductMilestoneTemplate, { - as: 'milestones', - foreignKey: 'productTemplateId', - onDelete: 'cascade', - }); - }, - }, }); return ProductTemplate; diff --git a/src/models/project.js b/src/models/project.js index 6bb6b66f..7fdf81d0 100644 --- a/src/models/project.js +++ b/src/models/project.js @@ -42,6 +42,10 @@ module.exports = function defineProject(sequelize, DataTypes) { createdBy: { type: DataTypes.INTEGER, allowNull: false }, updatedBy: { type: DataTypes.INTEGER, allowNull: false }, version: { type: DataTypes.STRING(3), allowNull: false, defaultValue: 'v3' }, + lastActivityAt: { type: DataTypes.DATE, allowNull: false }, + // we use string for `lastActivityUserId` because it comes in Kafka messages payloads + // and can be not only user id but also `coderbot`, `system` or some kind of autopilot bot id in the future + lastActivityUserId: { type: DataTypes.STRING, allowNull: false }, }, { tableName: 'projects', paranoid: true, diff --git a/src/models/projectPhase.js b/src/models/projectPhase.js index 75b8e3dd..35649382 100644 --- a/src/models/projectPhase.js +++ b/src/models/projectPhase.js @@ -14,6 +14,7 @@ module.exports = function defineProjectPhase(sequelize, DataTypes) { spentBudget: { type: DataTypes.DOUBLE, defaultValue: 0.0 }, progress: { type: DataTypes.DOUBLE, defaultValue: 0.0 }, details: { type: DataTypes.JSON, defaultValue: {} }, + order: { type: DataTypes.INTEGER, allowNull: true }, deletedAt: { type: DataTypes.DATE, allowNull: true }, createdAt: { type: DataTypes.DATE, defaultValue: DataTypes.NOW }, diff --git a/src/permissions/index.js b/src/permissions/index.js index f9b3668c..2bdfd10e 100644 --- a/src/permissions/index.js +++ b/src/permissions/index.js @@ -68,4 +68,6 @@ module.exports = () => { Authorizer.setPolicy('milestone.edit', projectEdit); Authorizer.setPolicy('milestone.delete', projectEdit); Authorizer.setPolicy('milestone.view', projectView); + + Authorizer.setPolicy('metadata.list', true); // anyone can view all metadata }; diff --git a/src/routes/admin/project-create-index.js b/src/routes/admin/project-create-index.js index e03a6e2b..d23738ea 100644 --- a/src/routes/admin/project-create-index.js +++ b/src/routes/admin/project-create-index.js @@ -253,6 +253,13 @@ function getRequestBody(indexName, docType) { updatedBy: { type: 'integer', }, + lastActivityAt: { + type: 'date', + format: 'strict_date_optional_time||epoch_millis', + }, + lastActivityUserId: { + type: 'string', + }, userId: { type: 'long', }, diff --git a/src/routes/admin/project-delete-index.js b/src/routes/admin/project-delete-index.js index 472a1322..eb72ccc6 100644 --- a/src/routes/admin/project-delete-index.js +++ b/src/routes/admin/project-delete-index.js @@ -2,7 +2,6 @@ /* globals Promise */ import _ from 'lodash'; -import config from 'config'; import { middleware as tcMiddleware } from 'tc-core-library-js'; import util from '../../util'; @@ -17,8 +16,6 @@ import util from '../../util'; // var permissions = require('tc-core-library-js').middleware.permissions const permissions = tcMiddleware.permissions; -const ES_PROJECT_INDEX = config.get('elasticsearchConfig.indexName'); -// const ES_PROJECT_TYPE = config.get('elasticsearchConfig.docType'); module.exports = [ permissions('project.admin'), @@ -29,10 +26,13 @@ module.exports = [ (req, res, next) => { // eslint-disable-line no-unused-vars const logger = req.log; logger.debug('Entered Admin#deleteIndex'); - const indexName = _.get(req, 'body.param.indexName', ES_PROJECT_INDEX); - // const docType = _.get(req, 'body.param.docType', ES_PROJECT_TYPE); + const indexName = _.get(req, 'body.param.indexName'); logger.debug('indexName', indexName); - // logger.debug('docType', docType); + if (!indexName) { + const apiErr = new Error('indexName is required'); + apiErr.status = 400; + return Promise.reject(apiErr); + } const esClient = util.getElasticSearchClient(); esClient.indices.delete({ @@ -40,6 +40,6 @@ module.exports = [ // we would want to ignore no such index error ignore: [404], }); - res.status(200).json(util.wrapResponse(req.id, { message: 'Delete index request successfully submitted' })); + return res.status(200).json(util.wrapResponse(req.id, { message: 'Delete index request successfully submitted' })); }, ]; diff --git a/src/routes/attachments/create.js b/src/routes/attachments/create.js index 31fc1db8..5aa5ed2f 100644 --- a/src/routes/attachments/create.js +++ b/src/routes/attachments/create.js @@ -67,7 +67,7 @@ module.exports = [ } const fileTransferPromise = new Promise((accept, reject) => { - if (process.env.NODE_ENV !== 'development') { + if (process.env.NODE_ENV !== 'development' || config.get('enableFileUpload') === 'true') { // get pre-signed Url req.log.debug('requesting presigned Url'); httpClient.post(`${fileServiceUrl}uploadurl/`, { diff --git a/src/routes/attachments/create.spec.js b/src/routes/attachments/create.spec.js index 2aff8b54..5cbae3fb 100644 --- a/src/routes/attachments/create.spec.js +++ b/src/routes/attachments/create.spec.js @@ -6,6 +6,8 @@ import server from '../../app'; import models from '../../models'; import util from '../../util'; import testUtil from '../../tests/util'; +import busApi from '../../services/busApi'; +import { BUS_API_EVENT } from '../../constants'; const should = chai.should(); @@ -17,9 +19,46 @@ const body = { s3Bucket: 'submissions-staging-dev', contentType: 'application/pdf', }; + describe('Project Attachments', () => { let project1; - before((done) => { + let postSpy; + let getSpy; + let stub; + let sandbox; + + beforeEach((done) => { + const mockHttpClient = { + defaults: { headers: { common: {} } }, + post: () => new Promise(resolve => resolve({ + status: 200, + data: { + status: 200, + result: { + success: true, + status: 200, + content: { + filePath: 'tmp/spec.pdf', + preSignedURL: 'www.topcoder.com/media/spec.pdf', + }, + }, + }, + })), + get: () => new Promise(resolve => resolve({ + status: 200, + data: { + result: { + success: true, + status: 200, + content: { + filePath: 'tmp/spec.pdf', + preSignedURL: 'http://topcoder-media.s3.amazon.com/projects/1/spec.pdf', + }, + }, + }, + })), + }; + // mocks testUtil.clearDb() .then(() => { @@ -32,6 +71,8 @@ describe('Project Attachments', () => { details: {}, createdBy: 1, updatedBy: 1, + lastActivityAt: 1, + lastActivityUserId: '1', }).then((p) => { project1 = p; // create members @@ -42,12 +83,20 @@ describe('Project Attachments', () => { isPrimary: true, createdBy: 1, updatedBy: 1, - }).then(() => done()); + }).then(() => { + sandbox = sinon.sandbox.create(); + postSpy = sandbox.spy(mockHttpClient, 'post'); + getSpy = sandbox.spy(mockHttpClient, 'get'); + stub = sandbox.stub(util, 'getHttpClient', () => mockHttpClient); + sandbox.stub(util, 's3FileTransfer').returns(Promise.resolve(true)); + done(); + }); }); }); }); - after((done) => { + afterEach((done) => { + sandbox.restore(); testUtil.clearDb(done); }); @@ -64,41 +113,6 @@ describe('Project Attachments', () => { }); it('should return 201 return attachment record', (done) => { - const mockHttpClient = { - defaults: { headers: { common: {} } }, - post: () => new Promise(resolve => resolve({ - status: 200, - data: { - status: 200, - result: { - success: true, - status: 200, - content: { - filePath: 'tmp/spec.pdf', - preSignedURL: 'www.topcoder.com/media/spec.pdf', - }, - }, - }, - })), - get: () => new Promise(resolve => resolve({ - status: 200, - data: { - result: { - success: true, - status: 200, - content: { - filePath: 'tmp/spec.pdf', - preSignedURL: 'http://topcoder-media.s3.amazon.com/projects/1/spec.pdf', - }, - }, - }, - })), - }; - const postSpy = sinon.spy(mockHttpClient, 'post'); - const getSpy = sinon.spy(mockHttpClient, 'get'); - const stub = sinon.stub(util, 'getHttpClient', () => mockHttpClient); - // mock util s3FileTransfer - util.s3FileTransfer = () => Promise.resolve(true); request(server) .post(`/v4/projects/${project1.id}/attachments/`) .set({ @@ -123,5 +137,47 @@ describe('Project Attachments', () => { } }); }); + + describe('Bus api', () => { + let createEventSpy; + + before((done) => { + // Wait for 500ms in order to wait for createEvent calls from previous tests to complete + testUtil.wait(done); + }); + + beforeEach(() => { + createEventSpy = sandbox.spy(busApi, 'createEvent'); + }); + + it('sends single BUS_API_EVENT.PROJECT_FILES_UPDATED message when attachment added', (done) => { + request(server) + .post(`/v4/projects/${project1.id}/attachments/`) + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .send({ param: body }) + .expect(201) + .end((err) => { + if (err) { + done(err); + } else { + // Wait for app message handler to complete + testUtil.wait(() => { + createEventSpy.calledTwice.should.be.true; + createEventSpy.firstCall.calledWith(BUS_API_EVENT.PROJECT_FILE_UPLOADED); + createEventSpy.secondCall.calledWith(BUS_API_EVENT.PROJECT_FILES_UPDATED, sinon.match({ + projectId: project1.id, + projectName: project1.name, + projectUrl: `https://local.topcoder-dev.com/projects/${project1.id}`, + userId: 40051333, + initiatorUserId: 40051333, + })).should.be.true; + done(); + }); + } + }); + }); + }); }); }); diff --git a/src/routes/attachments/delete.spec.js b/src/routes/attachments/delete.spec.js index 850b8ca1..b9366d2d 100644 --- a/src/routes/attachments/delete.spec.js +++ b/src/routes/attachments/delete.spec.js @@ -8,6 +8,8 @@ import models from '../../models'; import util from '../../util'; import server from '../../app'; import testUtil from '../../tests/util'; +import busApi from '../../services/busApi'; +import { BUS_API_EVENT } from '../../constants'; describe('Project Attachments delete', () => { @@ -26,6 +28,8 @@ describe('Project Attachments delete', () => { details: {}, createdBy: 1, updatedBy: 1, + lastActivityAt: 1, + lastActivityUserId: '1', }).then((p) => { project1 = p; // create members @@ -142,5 +146,45 @@ describe('Project Attachments delete', () => { } }); }); + + describe('Bus api', () => { + let createEventSpy; + + before((done) => { + // Wait for 500ms in order to wait for createEvent calls from previous tests to complete + testUtil.wait(done); + }); + + beforeEach(() => { + createEventSpy = sandbox.spy(busApi, 'createEvent'); + }); + + it('sends single BUS_API_EVENT.PROJECT_FILES_UPDATED message when attachment deleted', (done) => { + request(server) + .delete(`/v4/projects/${project1.id}/attachments/${attachment.id}`) + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .expect(204) + .end((err) => { + if (err) { + done(err); + } else { + // Wait for app message handler to complete + testUtil.wait(() => { + createEventSpy.calledOnce.should.be.true; + createEventSpy.calledWith(BUS_API_EVENT.PROJECT_FILES_UPDATED, sinon.match({ + projectId: project1.id, + projectName: project1.name, + projectUrl: `https://local.topcoder-dev.com/projects/${project1.id}`, + userId: 40051333, + initiatorUserId: 40051333, + })).should.be.true; + done(); + }); + } + }); + }); + }); }); }); diff --git a/src/routes/attachments/update.js b/src/routes/attachments/update.js index 8bebd832..9a67a86a 100644 --- a/src/routes/attachments/update.js +++ b/src/routes/attachments/update.js @@ -62,6 +62,7 @@ module.exports = [ { original: previousValue, updated: updated.get({ plain: true }) }, { correlationId: req.id }, ); + req.app.emit(EVENT.ROUTING_KEY.PROJECT_ATTACHMENT_UPDATED, { req, original: previousValue, updated }); }).catch(err => next(err))); }, ]; diff --git a/src/routes/attachments/update.spec.js b/src/routes/attachments/update.spec.js index 3735eca2..99dd0b15 100644 --- a/src/routes/attachments/update.spec.js +++ b/src/routes/attachments/update.spec.js @@ -6,6 +6,8 @@ import request from 'supertest'; import models from '../../models'; import server from '../../app'; import testUtil from '../../tests/util'; +import busApi from '../../services/busApi'; +import { BUS_API_EVENT } from '../../constants'; const should = chai.should(); @@ -25,6 +27,8 @@ describe('Project Attachments update', () => { details: {}, createdBy: 1, updatedBy: 1, + lastActivityAt: 1, + lastActivityUserId: '1', }).then((p) => { project1 = p; // create members @@ -106,5 +110,46 @@ describe('Project Attachments update', () => { } }); }); + + describe('Bus api', () => { + let createEventSpy; + + before((done) => { + // Wait for 500ms in order to wait for createEvent calls from previous tests to complete + testUtil.wait(done); + }); + + beforeEach(() => { + createEventSpy = sandbox.stub(busApi, 'createEvent'); + }); + + it('sends single BUS_API_EVENT.PROJECT_FILES_UPDATED message when attachment updated', (done) => { + request(server) + .patch(`/v4/projects/${project1.id}/attachments/${attachment.id}`) + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .send({ param: { title: 'updated title', description: 'updated description' } }) + .expect(200) + .end((err) => { + if (err) { + done(err); + } else { + // Wait for app message handler to complete + testUtil.wait(() => { + createEventSpy.calledOnce.should.be.true; + createEventSpy.calledWith(BUS_API_EVENT.PROJECT_FILES_UPDATED, sinon.match({ + projectId: project1.id, + projectName: project1.name, + projectUrl: `https://local.topcoder-dev.com/projects/${project1.id}`, + userId: 40051333, + initiatorUserId: 40051333, + })).should.be.true; + done(); + }); + } + }); + }); + }); }); }); diff --git a/src/routes/index.js b/src/routes/index.js index 8e4f034f..7dcba789 100644 --- a/src/routes/index.js +++ b/src/routes/index.js @@ -26,29 +26,35 @@ router.get(`/${apiVersion}/projects/health`, (req, res) => { // All project service endpoints need authentication const jwtAuth = require('tc-core-library-js').middleware.jwtAuthenticator; -router.route('/v4/projectTemplates') +router.route('/v4/projects/metadata/projectTemplates') .get(require('./projectTemplates/list')); -router.route('/v4/projectTemplates/:templateId(\\d+)') +router.route('/v4/projects/metadata/projectTemplates/:templateId(\\d+)') .get(require('./projectTemplates/get')); -router.route('/v4/productTemplates') +router.route('/v4/projects/metadata/productTemplates') .get(require('./productTemplates/list')); -router.route('/v4/productTemplates/:templateId(\\d+)') +router.route('/v4/projects/metadata/productTemplates/:templateId(\\d+)') .get(require('./productTemplates/get')); -router.route('/v4/projectTypes') +router.route('/v4/projects/metadata/projectTypes') .get(require('./projectTypes/list')); -router.route('/v4/projectTypes/:key') +router.route('/v4/projects/metadata/projectTypes/:key') .get(require('./projectTypes/get')); -router.route('/v4/productCategories') +router.route('/v4/projects/metadata/productCategories') .get(require('./productCategories/list')); -router.route('/v4/productCategories/:key') +router.route('/v4/projects/metadata/productCategories/:key') .get(require('./productCategories/get')); +router.route('/v4/projects/metadata') + .get(require('./metadata/list')); + router.all( - RegExp(`\\/${apiVersion}\\/(projects|projectTemplates|productTemplates|productCategories|projectTypes|` + - 'timelines)(?!\\/health).*'), jwtAuth()); + RegExp(`\\/${apiVersion}\\/(projects|timelines)(?!\\/health).*`), (req, res, next) => ( + // JWT authentication + jwtAuth()(req, res, next) + ), +); // Register all the routes router.route('/v4/projects') @@ -90,32 +96,20 @@ router.route('/v4/projects/:projectId(\\d+)/attachments/:id(\\d+)') router.route('/v4/projects/:projectId(\\d+)/upgrade') .post(require('./projectUpgrade/create')); -router.route('/v4/projectTemplates') +router.route('/v4/projects/metadata/projectTemplates') .post(require('./projectTemplates/create')); -router.route('/v4/projectTemplates/:templateId(\\d+)') +router.route('/v4/projects/metadata/projectTemplates/:templateId(\\d+)') .patch(require('./projectTemplates/update')) .delete(require('./projectTemplates/delete')); -router.route('/v4/productTemplates') +router.route('/v4/projects/metadata/productTemplates') .post(require('./productTemplates/create')); -router.route('/v4/productTemplates/:templateId(\\d+)') +router.route('/v4/projects/metadata/productTemplates/:templateId(\\d+)') .patch(require('./productTemplates/update')) .delete(require('./productTemplates/delete')); -router.route('/v4/productTemplates/:productTemplateId(\\d+)/milestones') - .post(require('./milestoneTemplates/create')) - .get(require('./milestoneTemplates/list')); - -router.route('/v4/productTemplates/:productTemplateId(\\d+)/milestones/clone') - .post(require('./milestoneTemplates/clone')); - -router.route('/v4/productTemplates/:productTemplateId(\\d+)/milestones/:milestoneTemplateId(\\d+)') - .get(require('./milestoneTemplates/get')) - .patch(require('./milestoneTemplates/update')) - .delete(require('./milestoneTemplates/delete')); - router.route('/v4/projects/:projectId(\\d+)/phases') .get(require('./phases/list')) .post(require('./phases/create')); @@ -134,17 +128,17 @@ router.route('/v4/projects/:projectId(\\d+)/phases/:phaseId(\\d+)/products/:prod .patch(require('./phaseProducts/update')) .delete(require('./phaseProducts/delete')); -router.route('/v4/productCategories') +router.route('/v4/projects/metadata/productCategories') .post(require('./productCategories/create')); -router.route('/v4/productCategories/:key') +router.route('/v4/projects/metadata/productCategories/:key') .patch(require('./productCategories/update')) .delete(require('./productCategories/delete')); -router.route('/v4/projectTypes') +router.route('/v4/projects/metadata/projectTypes') .post(require('./projectTypes/create')); -router.route('/v4/projectTypes/:key') +router.route('/v4/projects/metadata/projectTypes/:key') .patch(require('./projectTypes/update')) .delete(require('./projectTypes/delete')); @@ -166,6 +160,18 @@ router.route('/v4/timelines/:timelineId(\\d+)/milestones/:milestoneId(\\d+)') .patch(require('./milestones/update')) .delete(require('./milestones/delete')); +router.route('/v4/timelines/metadata/milestoneTemplates') + .post(require('./milestoneTemplates/create')) + .get(require('./milestoneTemplates/list')); + +router.route('/v4/timelines/metadata/milestoneTemplates/clone') + .post(require('./milestoneTemplates/clone')); + +router.route('/v4/timelines/metadata/milestoneTemplates/:milestoneTemplateId(\\d+)') + .get(require('./milestoneTemplates/get')) + .patch(require('./milestoneTemplates/update')) + .delete(require('./milestoneTemplates/delete')); + // register error handler router.use((err, req, res, next) => { // eslint-disable-line no-unused-vars // DO NOT REMOVE next arg.. even though eslint diff --git a/src/routes/metadata/list.js b/src/routes/metadata/list.js new file mode 100644 index 00000000..5a173913 --- /dev/null +++ b/src/routes/metadata/list.js @@ -0,0 +1,36 @@ +/** + * API to list all metadata + */ +import { middleware as tcMiddleware } from 'tc-core-library-js'; +import util from '../../util'; +import models from '../../models'; + +const permissions = tcMiddleware.permissions; + +module.exports = [ + permissions('metadata.list'), + (req, res, next) => { + const query = { + attributes: { exclude: ['deletedAt', 'deletedBy'] }, + raw: true, + }; + + return Promise.all([ + models.ProjectTemplate.findAll(query), + models.ProductTemplate.findAll(query), + models.MilestoneTemplate.findAll(query), + models.ProjectType.findAll(query), + models.ProductCategory.findAll(query), + ]) + .then((results) => { + res.json(util.wrapResponse(req.id, { + projectTemplates: results[0], + productTemplates: results[1], + milestoneTemplates: results[2], + projectTypes: results[3], + productCategories: results[4], + })); + }) + .catch(next); + }, +]; diff --git a/src/routes/metadata/list.spec.js b/src/routes/metadata/list.spec.js new file mode 100644 index 00000000..7e7c9b4f --- /dev/null +++ b/src/routes/metadata/list.spec.js @@ -0,0 +1,162 @@ +/** + * Tests for list.js + */ +import chai from 'chai'; +import request from 'supertest'; + +import models from '../../models'; +import server from '../../app'; +import testUtil from '../../tests/util'; + +const should = chai.should(); + +const projectTemplates = [ + { + 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: {}, + phases: {}, + createdBy: 1, + updatedBy: 1, + }, +]; +const productTemplates = [ + { + name: 'name 1', + productKey: 'productKey 1', + category: 'category', + icon: 'http://example.com/icon1.ico', + brief: 'brief 1', + details: 'details 1', + aliases: {}, + template: {}, + createdBy: 1, + updatedBy: 2, + }, +]; +const milestoneTemplates = [ + { + id: 1, + name: 'milestoneTemplate 1', + duration: 3, + type: 'type1', + order: 1, + plannedText: 'text to be shown in planned stage', + blockedText: 'text to be shown in blocked stage', + activeText: 'text to be shown in active stage', + completedText: 'text to be shown in completed stage', + reference: 'product', + referenceId: 1, + metadata: {}, + createdBy: 1, + updatedBy: 2, + }, +]; +const projectTypes = [ + { + key: 'key1', + displayName: 'displayName 1', + icon: 'http://example.com/icon1.ico', + question: 'question 1', + info: 'info 1', + aliases: ['key-1', 'key_1'], + metadata: { 'slack-notification-mappings': { color: '#96d957', label: 'Full App' } }, + createdBy: 1, + updatedBy: 1, + }, +]; +const productCategories = [ + { + key: 'key1', + displayName: 'displayName 1', + icon: 'http://example.com/icon1.ico', + question: 'question 1', + info: 'info 1', + aliases: ['key-1', 'key_1'], + createdBy: 1, + updatedBy: 1, + }, +]; + +describe('GET all metadata', () => { + beforeEach(() => testUtil.clearDb() + .then(() => models.ProjectTemplate.bulkCreate(projectTemplates)) + .then(() => models.ProductTemplate.bulkCreate(productTemplates)) + .then(() => models.MilestoneTemplate.bulkCreate(milestoneTemplates)) + .then(() => models.ProjectType.bulkCreate(projectTypes)) + .then(() => models.ProductCategory.bulkCreate(productCategories)), + ); + after(testUtil.clearDb); + + describe('GET /projects/metadata', () => { + it('should return 200 even if user is not authenticated', (done) => { + request(server) + .get('/v4/projects/metadata') + .expect(200) + .end((err, res) => { + const resJson = res.body.result.content; + should.exist(resJson); + resJson.projectTemplates.should.have.length(1); + resJson.productTemplates.should.have.length(1); + resJson.milestoneTemplates.should.have.length(1); + resJson.projectTypes.should.have.length(1); + resJson.productCategories.should.have.length(1); + + done(); + }); + }); + + it('should return 200 for admin', (done) => { + request(server) + .get('/v4/projects/metadata') + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .expect(200) + .end(done); + }); + + it('should return 200 for admin', (done) => { + request(server) + .get('/v4/projects/metadata') + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .expect(200) + .end(done); + }); + + it('should return 200 for connect manager', (done) => { + request(server) + .get('/v4/projects/metadata') + .set({ + Authorization: `Bearer ${testUtil.jwts.manager}`, + }) + .expect(200) + .end(done); + }); + + it('should return 200 for member', (done) => { + request(server) + .get('/v4/projects/metadata') + .set({ + Authorization: `Bearer ${testUtil.jwts.member}`, + }) + .expect(200, done); + }); + + it('should return 200 for copilot', (done) => { + request(server) + .get('/v4/projects/metadata') + .set({ + Authorization: `Bearer ${testUtil.jwts.copilot}`, + }) + .expect(200, done); + }); + }); +}); diff --git a/src/routes/milestoneTemplates/clone.js b/src/routes/milestoneTemplates/clone.js index c7fd1bf3..bfe95a46 100644 --- a/src/routes/milestoneTemplates/clone.js +++ b/src/routes/milestoneTemplates/clone.js @@ -7,94 +7,68 @@ import Joi from 'joi'; import { middleware as tcMiddleware } from 'tc-core-library-js'; import util from '../../util'; import models from '../../models'; +import { MILESTONE_TEMPLATE_REFERENCES } from '../../constants'; +import validateMilestoneTemplate from '../../middlewares/validateMilestoneTemplate'; const permissions = tcMiddleware.permissions; const schema = { - params: { - productTemplateId: Joi.number().integer().positive().required(), - }, body: { param: Joi.object().keys({ - sourceTemplateId: Joi.number().integer().positive().required(), + sourceReference: Joi.string().valid(_.values(MILESTONE_TEMPLATE_REFERENCES)).required(), + sourceReferenceId: Joi.number().integer().positive().required(), + reference: Joi.string().valid(_.values(MILESTONE_TEMPLATE_REFERENCES)).required(), + referenceId: Joi.number().integer().positive().required(), }).required(), }, }; module.exports = [ validate(schema), + validateMilestoneTemplate.validateRequestBody, permissions('milestoneTemplate.clone'), (req, res, next) => { let result; return models.sequelize.transaction(tx => // Find the product template - models.ProductTemplate.findAll({ where: { id: [req.params.productTemplateId, req.body.param.sourceTemplateId] }, - transaction: tx }) - .then((productTemplates) => { - // Not found - if (!productTemplates) { - const apiErr = new Error( - `Product template not found for product template ids ${req.params.productTemplateId} - ${req.body.param.sourceTemplateId}`); - apiErr.status = 404; - return Promise.reject(apiErr); - } - - const targetProductTemplate = _.find(productTemplates, ['id', req.params.productTemplateId]); - const sourceProductTemplate = _.find(productTemplates, ['id', req.body.param.sourceTemplateId]); - - // Not found - if (!targetProductTemplate) { - const apiErr = new Error( - `Product template not found for product template id ${req.params.productTemplateId}`); - apiErr.status = 404; - return Promise.reject(apiErr); - } - - // Not found - if (!sourceProductTemplate) { - const apiErr = new Error( - `Product template not found for source product template id ${req.body.param.sourceTemplateId}`); - apiErr.status = 404; - return Promise.reject(apiErr); - } - - return models.ProductMilestoneTemplate.findAll({ - where: { - productTemplateId: req.body.param.sourceTemplateId, - }, - attributes: { exclude: ['id', 'deletedAt', 'createdAt', 'updatedAt', 'deletedBy'] }, - raw: true, - }) - .then((milestoneTemplatesToClone) => { - const newMilestoneTemplates = _.cloneDeep(milestoneTemplatesToClone); - _.each(newMilestoneTemplates, (milestone) => { - milestone.productTemplateId = req.params.productTemplateId; // eslint-disable-line no-param-reassign - milestone.createdBy = req.authUser.userId; // eslint-disable-line no-param-reassign - milestone.updatedBy = req.authUser.userId; // eslint-disable-line no-param-reassign - }); - return models.ProductMilestoneTemplate.bulkCreate(newMilestoneTemplates, { transaction: tx }); + models.MilestoneTemplate.findAll({ + where: { + reference: req.body.param.sourceReference, + referenceId: req.body.param.sourceReferenceId, + }, + attributes: { exclude: ['id', 'deletedAt', 'createdAt', 'updatedAt', 'deletedBy'] }, + raw: true, + }) + .then((milestoneTemplatesToClone) => { + const newMilestoneTemplates = _.cloneDeep(milestoneTemplatesToClone); + _.each(newMilestoneTemplates, (milestone) => { + milestone.reference = req.body.param.reference; // eslint-disable-line no-param-reassign + milestone.referenceId = req.body.param.referenceId; // eslint-disable-line no-param-reassign + milestone.createdBy = req.authUser.userId; // eslint-disable-line no-param-reassign + milestone.updatedBy = req.authUser.userId; // eslint-disable-line no-param-reassign }); + return models.MilestoneTemplate.bulkCreate(newMilestoneTemplates, { transaction: tx }); }) .then(() => { // eslint-disable-line arrow-body-style - return models.ProductMilestoneTemplate.findAll({ + return models.MilestoneTemplate.findAll({ where: { - productTemplateId: req.params.productTemplateId, + reference: req.body.param.reference, + referenceId: req.body.param.referenceId, }, attributes: { exclude: ['deletedAt', 'deletedBy'] }, raw: true, }) - .then((clonedMilestoneTemplates) => { - result = clonedMilestoneTemplates; - return result; - }); + .then((clonedMilestoneTemplates) => { + result = clonedMilestoneTemplates; + return result; + }); }), ) - .then(() => { - // Write to response - res.status(201).json(util.wrapResponse(req.id, result, 1, 201)); - }) - .catch(next); + .then(() => { + // Write to response + res.status(201).json(util.wrapResponse(req.id, result, result.length, 201)); + }) + .catch(next); }, ]; diff --git a/src/routes/milestoneTemplates/clone.spec.js b/src/routes/milestoneTemplates/clone.spec.js index 2f008e8d..03ad322a 100644 --- a/src/routes/milestoneTemplates/clone.spec.js +++ b/src/routes/milestoneTemplates/clone.spec.js @@ -83,7 +83,9 @@ const milestoneTemplates = [ duration: 3, type: 'type1', order: 1, - productTemplateId: 1, + reference: 'productTemplate', + referenceId: 1, + metadata: {}, plannedText: 'text to be shown in planned stage', blockedText: 'text to be shown in blocked stage', activeText: 'text to be shown in active stage', @@ -100,7 +102,9 @@ const milestoneTemplates = [ blockedText: 'text to be shown in blocked stage - 2', activeText: 'text to be shown in active stage - 2', completedText: 'text to be shown in completed stage - 2', - productTemplateId: 1, + reference: 'productTemplate', + referenceId: 1, + metadata: {}, createdBy: 2, updatedBy: 3, }, @@ -109,27 +113,30 @@ const milestoneTemplates = [ describe('CLONE milestone template', () => { beforeEach(() => testUtil.clearDb() .then(() => models.ProductTemplate.bulkCreate(productTemplates)) - .then(() => models.ProductMilestoneTemplate.bulkCreate(milestoneTemplates)), + .then(() => models.MilestoneTemplate.bulkCreate(milestoneTemplates)), ); after(testUtil.clearDb); - describe('POST /productTemplates/{productTemplateId}/milestones/clone', () => { + describe('POST /timelines/metadata/milestoneTemplates/clone', () => { const body = { param: { - sourceTemplateId: 1, + sourceReference: 'productTemplate', + sourceReferenceId: 1, + reference: 'productTemplate', + referenceId: 2, }, }; it('should return 403 if user is not authenticated/clone', (done) => { request(server) - .post('/v4/productTemplates/2/milestones') + .post('/v4/timelines/metadata/milestoneTemplates/clone') .send(body) .expect(403, done); }); it('should return 403 for member', (done) => { request(server) - .post('/v4/productTemplates/2/milestones/clone') + .post('/v4/timelines/metadata/milestoneTemplates/clone') .set({ Authorization: `Bearer ${testUtil.jwts.member}`, }) @@ -139,7 +146,7 @@ describe('CLONE milestone template', () => { it('should return 403 for copilot', (done) => { request(server) - .post('/v4/productTemplates/2/milestones/clone') + .post('/v4/timelines/metadata/milestoneTemplates/clone') .set({ Authorization: `Bearer ${testUtil.jwts.copilot}`, }) @@ -149,7 +156,7 @@ describe('CLONE milestone template', () => { it('should return 403 for manager', (done) => { request(server) - .post('/v4/productTemplates/2/milestones/clone') + .post('/v4/timelines/metadata/milestoneTemplates/clone') .set({ Authorization: `Bearer ${testUtil.jwts.manager}`, }) @@ -157,41 +164,55 @@ describe('CLONE milestone template', () => { .expect(403, done); }); - it('should return 404 for non-existent product template', (done) => { + it('should return 422 for non-existent product template', (done) => { + const invalidBody = { + param: { + sourceReference: 'productTemplate', + sourceReferenceId: 1, + reference: 'productTemplate', + referenceId: 2000, + }, + }; + request(server) - .post('/v4/productTemplates/1000/milestones/clone') + .post('/v4/timelines/metadata/milestoneTemplates/clone') .set({ Authorization: `Bearer ${testUtil.jwts.admin}`, }) - .send(body) - .expect(404, done); + .send(invalidBody) + .expect(422, done); }); - it('should return 404 for non-existent source product template', (done) => { + it('should return 422 for non-existent source product template', (done) => { const invalidBody = { param: { - sourceTemplateId: 99, + sourceReference: 'product', + sourceReferenceId: 1000, + reference: 'product', + referenceId: 2, }, }; request(server) - .post('/v4/productTemplates/2/milestones/clone') + .post('/v4/timelines/metadata/milestoneTemplates/clone') .set({ Authorization: `Bearer ${testUtil.jwts.admin}`, }) .send(invalidBody) - .expect(404, done); + .expect(422, done); }); - it('should return 422 if missing sourceTemplateId', (done) => { + it('should return 422 if missing sourceReference', (done) => { const invalidBody = { param: { - sourceTemplateId: undefined, + sourceReferenceId: 1000, + reference: 'productTemplate', + referenceId: 2, }, }; request(server) - .post('/v4/productTemplates/2/milestones/clone') + .post('/v4/timelines/metadata/milestoneTemplates/clone') .set({ Authorization: `Bearer ${testUtil.jwts.admin}`, }) @@ -202,7 +223,7 @@ describe('CLONE milestone template', () => { it('should return 201 for admin', (done) => { request(server) - .post('/v4/productTemplates/2/milestones/clone') + .post('/v4/timelines/metadata/milestoneTemplates/clone') .set({ Authorization: `Bearer ${testUtil.jwts.admin}`, }) @@ -220,7 +241,9 @@ describe('CLONE milestone template', () => { resJson[0].blockedText.should.be.eql(milestoneTemplates[0].blockedText); resJson[0].activeText.should.be.eql(milestoneTemplates[0].activeText); resJson[0].completedText.should.be.eql(milestoneTemplates[0].completedText); - resJson[0].productTemplateId.should.be.eql(2); + resJson[0].reference.should.be.eql('productTemplate'); + resJson[0].referenceId.should.be.eql(2); + resJson[0].metadata.should.be.eql({}); resJson[0].createdBy.should.be.eql(40051333); // admin should.exist(resJson[0].createdAt); @@ -235,7 +258,7 @@ describe('CLONE milestone template', () => { it('should return 201 for connect admin', (done) => { request(server) - .post('/v4/productTemplates/2/milestones/clone') + .post('/v4/timelines/metadata/milestoneTemplates/clone') .set({ Authorization: `Bearer ${testUtil.jwts.connectAdmin}`, }) diff --git a/src/routes/milestoneTemplates/create.js b/src/routes/milestoneTemplates/create.js index b207b72a..321c9b65 100644 --- a/src/routes/milestoneTemplates/create.js +++ b/src/routes/milestoneTemplates/create.js @@ -8,13 +8,12 @@ import Sequelize from 'sequelize'; import { middleware as tcMiddleware } from 'tc-core-library-js'; import util from '../../util'; import models from '../../models'; +import validateMilestoneTemplate from '../../middlewares/validateMilestoneTemplate'; +import { MILESTONE_TEMPLATE_REFERENCES } from '../../constants'; const permissions = tcMiddleware.permissions; const schema = { - params: { - productTemplateId: Joi.number().integer().positive().required(), - }, body: { param: Joi.object().keys({ id: Joi.any().strip(), @@ -27,7 +26,9 @@ const schema = { activeText: Joi.string().max(512).required(), completedText: Joi.string().max(512).required(), blockedText: Joi.string().max(512).required(), - productTemplateId: Joi.any().strip(), + reference: Joi.string().valid(_.values(MILESTONE_TEMPLATE_REFERENCES)).required(), + referenceId: Joi.number().integer().positive().required(), + metadata: Joi.object().required(), hidden: Joi.boolean().optional(), createdAt: Joi.any().strip(), updatedAt: Joi.any().strip(), @@ -41,39 +42,28 @@ const schema = { module.exports = [ validate(schema), + validateMilestoneTemplate.validateRequestBody, permissions('milestoneTemplate.create'), (req, res, next) => { const entity = _.assign(req.body.param, { createdBy: req.authUser.userId, updatedBy: req.authUser.userId, - productTemplateId: req.params.productTemplateId, }); let result; return models.sequelize.transaction(tx => - // Find the product template - models.ProductTemplate.findById(req.params.productTemplateId, { transaction: tx }) - .then((productTemplate) => { - // Not found - if (!productTemplate) { - const apiErr = new Error( - `Product template not found for product template id ${req.params.productTemplateId}`); - apiErr.status = 404; - return Promise.reject(apiErr); - } - - // Create the milestone template - return models.ProductMilestoneTemplate.create(entity, { transaction: tx }); - }) + // Create the milestone template + models.MilestoneTemplate.create(entity, { transaction: tx }) .then((createdEntity) => { // Omit deletedAt and deletedBy result = _.omit(createdEntity.toJSON(), 'deletedAt', 'deletedBy'); - // Increase the order of the other milestone templates in the same product template, + // Increase the order of the other milestone templates in the same referenceId, // which have `order` >= this milestone template order - return models.ProductMilestoneTemplate.update({ order: Sequelize.literal('"order" + 1') }, { + return models.MilestoneTemplate.update({ order: Sequelize.literal('"order" + 1') }, { where: { - productTemplateId: req.params.productTemplateId, + reference: result.reference, + referenceId: result.referenceId, id: { $ne: result.id }, order: { $gte: result.order }, }, diff --git a/src/routes/milestoneTemplates/create.spec.js b/src/routes/milestoneTemplates/create.spec.js index c821a5fe..00dad710 100644 --- a/src/routes/milestoneTemplates/create.spec.js +++ b/src/routes/milestoneTemplates/create.spec.js @@ -64,7 +64,9 @@ const milestoneTemplates = [ duration: 3, type: 'type1', order: 1, - productTemplateId: 1, + reference: 'productTemplate', + referenceId: 1, + metadata: {}, plannedText: 'text to be shown in planned stage', blockedText: 'text to be shown in blocked stage', activeText: 'text to be shown in active stage', @@ -81,7 +83,9 @@ const milestoneTemplates = [ blockedText: 'text to be shown in blocked stage - 2', activeText: 'text to be shown in active stage - 2', completedText: 'text to be shown in completed stage - 2', - productTemplateId: 1, + reference: 'productTemplate', + referenceId: 1, + metadata: {}, createdBy: 2, updatedBy: 3, }, @@ -90,11 +94,11 @@ const milestoneTemplates = [ describe('CREATE milestone template', () => { beforeEach(() => testUtil.clearDb() .then(() => models.ProductTemplate.bulkCreate(productTemplates)) - .then(() => models.ProductMilestoneTemplate.bulkCreate(milestoneTemplates)), + .then(() => models.MilestoneTemplate.bulkCreate(milestoneTemplates)), ); after(testUtil.clearDb); - describe('POST /productTemplates/{productTemplateId}/milestones', () => { + describe('POST /timelines/metadata/milestoneTemplates', () => { const body = { param: { name: 'milestoneTemplate 3', @@ -107,19 +111,22 @@ describe('CREATE milestone template', () => { activeText: 'text to be shown in active stage - 3', completedText: 'text to be shown in completed stage - 3', hidden: true, + reference: 'productTemplate', + referenceId: 1, + metadata: {}, }, }; it('should return 403 if user is not authenticated', (done) => { request(server) - .post('/v4/productTemplates/1/milestones') + .post('/v4/timelines/metadata/milestoneTemplates') .send(body) .expect(403, done); }); it('should return 403 for member', (done) => { request(server) - .post('/v4/productTemplates/1/milestones') + .post('/v4/timelines/metadata/milestoneTemplates') .set({ Authorization: `Bearer ${testUtil.jwts.member}`, }) @@ -129,7 +136,7 @@ describe('CREATE milestone template', () => { it('should return 403 for copilot', (done) => { request(server) - .post('/v4/productTemplates/1/milestones') + .post('/v4/timelines/metadata/milestoneTemplates') .set({ Authorization: `Bearer ${testUtil.jwts.copilot}`, }) @@ -139,7 +146,7 @@ describe('CREATE milestone template', () => { it('should return 403 for manager', (done) => { request(server) - .post('/v4/productTemplates/1/milestones') + .post('/v4/timelines/metadata/milestoneTemplates') .set({ Authorization: `Bearer ${testUtil.jwts.manager}`, }) @@ -147,14 +154,18 @@ describe('CREATE milestone template', () => { .expect(403, done); }); - it('should return 404 for non-existed product template', (done) => { + it('should return 422 for non-existed product template', (done) => { + const invalidBody = { + param: _.assign({}, body.param, { referenceId: 1000 }), + }; + request(server) - .post('/v4/productTemplates/1000/milestones') + .post('/v4/timelines/metadata/milestoneTemplates') .set({ Authorization: `Bearer ${testUtil.jwts.admin}`, }) - .send(body) - .expect(404, done); + .send(invalidBody) + .expect(422, done); }); it('should return 422 if missing name', (done) => { @@ -165,7 +176,7 @@ describe('CREATE milestone template', () => { }; request(server) - .post('/v4/productTemplates/1/milestones') + .post('/v4/timelines/metadata/milestoneTemplates') .set({ Authorization: `Bearer ${testUtil.jwts.admin}`, }) @@ -182,7 +193,7 @@ describe('CREATE milestone template', () => { }; request(server) - .post('/v4/productTemplates/1/milestones') + .post('/v4/timelines/metadata/milestoneTemplates') .set({ Authorization: `Bearer ${testUtil.jwts.admin}`, }) @@ -199,7 +210,7 @@ describe('CREATE milestone template', () => { }; request(server) - .post('/v4/productTemplates/1/milestones') + .post('/v4/timelines/metadata/milestoneTemplates') .set({ Authorization: `Bearer ${testUtil.jwts.admin}`, }) @@ -216,7 +227,7 @@ describe('CREATE milestone template', () => { }; request(server) - .post('/v4/productTemplates/1/milestones') + .post('/v4/timelines/metadata/milestoneTemplates') .set({ Authorization: `Bearer ${testUtil.jwts.admin}`, }) @@ -227,7 +238,7 @@ describe('CREATE milestone template', () => { it('should return 201 for admin', (done) => { request(server) - .post('/v4/productTemplates/1/milestones') + .post('/v4/timelines/metadata/milestoneTemplates') .set({ Authorization: `Bearer ${testUtil.jwts.admin}`, }) @@ -246,6 +257,9 @@ describe('CREATE milestone template', () => { resJson.blockedText.should.be.eql(body.param.blockedText); resJson.activeText.should.be.eql(body.param.activeText); resJson.completedText.should.be.eql(body.param.completedText); + resJson.reference.should.be.eql(body.param.reference); + resJson.referenceId.should.be.eql(body.param.referenceId); + resJson.metadata.should.be.eql(body.param.metadata); resJson.createdBy.should.be.eql(40051333); // admin should.exist(resJson.createdAt); @@ -255,9 +269,10 @@ describe('CREATE milestone template', () => { should.not.exist(resJson.deletedAt); // Verify 'order' of the other milestones - models.ProductMilestoneTemplate.findAll({ + models.MilestoneTemplate.findAll({ where: { - productTemplateId: 1, + reference: body.param.reference, + referenceId: body.param.referenceId, }, }).then((milestones) => { _.each(milestones, (milestone) => { @@ -278,7 +293,7 @@ describe('CREATE milestone template', () => { const minimalBody = _.cloneDeep(body); delete minimalBody.param.hidden; request(server) - .post('/v4/productTemplates/1/milestones') + .post('/v4/timelines/metadata/milestoneTemplates') .set({ Authorization: `Bearer ${testUtil.jwts.admin}`, }) @@ -294,7 +309,7 @@ describe('CREATE milestone template', () => { it('should return 201 for connect admin', (done) => { request(server) - .post('/v4/productTemplates/1/milestones') + .post('/v4/timelines/metadata/milestoneTemplates') .set({ Authorization: `Bearer ${testUtil.jwts.connectAdmin}`, }) diff --git a/src/routes/milestoneTemplates/delete.js b/src/routes/milestoneTemplates/delete.js index 43d3fd57..5cd0454b 100644 --- a/src/routes/milestoneTemplates/delete.js +++ b/src/routes/milestoneTemplates/delete.js @@ -5,44 +5,27 @@ import validate from 'express-validation'; import Joi from 'joi'; import { middleware as tcMiddleware } from 'tc-core-library-js'; import models from '../../models'; +import validateMilestoneTemplate from '../../middlewares/validateMilestoneTemplate'; const permissions = tcMiddleware.permissions; const schema = { params: { - productTemplateId: Joi.number().integer().positive().required(), milestoneTemplateId: Joi.number().integer().positive().required(), }, }; module.exports = [ validate(schema), + validateMilestoneTemplate.validateIdParam, permissions('milestoneTemplate.delete'), - (req, res, next) => { - const where = { - id: req.params.milestoneTemplateId, - deletedAt: { $eq: null }, - productTemplateId: req.params.productTemplateId, - }; - - return models.sequelize.transaction(() => + (req, res, next) => models.sequelize.transaction(() => // soft delete the record - models.ProductMilestoneTemplate.findOne({ - where, - }).then((existing) => { - if (!existing) { - // handle 404 - const err = new Error( - `Milestone template not found for milestone template id ${req.params.milestoneTemplateId}`); - err.status = 404; - return Promise.reject(err); - } - return existing.update({ deletedBy: req.authUser.userId }); + req.milestoneTemplate.update({ deletedBy: req.authUser.userId }) + .then(entity => entity.destroy()), + ) + .then(() => { + res.status(204).end(); }) - .then(entity => entity.destroy())) - .then(() => { - res.status(204).end(); - }) - .catch(next); - }, + .catch(next), ]; diff --git a/src/routes/milestoneTemplates/delete.spec.js b/src/routes/milestoneTemplates/delete.spec.js index 63091b36..0d0d5527 100644 --- a/src/routes/milestoneTemplates/delete.spec.js +++ b/src/routes/milestoneTemplates/delete.spec.js @@ -8,31 +8,32 @@ import models from '../../models'; import server from '../../app'; import testUtil from '../../tests/util'; -const expectAfterDelete = (productTemplateId, id, err, next) => { +const expectAfterDelete = (id, err, next) => { if (err) throw err; - setTimeout(() => - models.ProductMilestoneTemplate.findOne({ - where: { - id, - productTemplateId, - }, - paranoid: false, - }) - .then((res) => { - if (!res) { - throw new Error('Should found the entity'); - } else { - chai.assert.isNotNull(res.deletedAt); - chai.assert.isNotNull(res.deletedBy); + setTimeout(() => { + models.MilestoneTemplate.findOne({ + where: { + id, + }, + paranoid: false, + }) + .then((res) => { + server.logger.error(`res = ${res}`); + if (!res) { + throw new Error('Should found the entity'); + } else { + chai.assert.isNotNull(res.deletedAt); + chai.assert.isNotNull(res.deletedBy); - request(server) - .get(`/v4/productTemplates/${productTemplateId}/milestones/${id}`) - .set({ - Authorization: `Bearer ${testUtil.jwts.admin}`, - }) - .expect(404, next); - } - }), 500); + request(server) + .get(`/v4/timelines/metadata/milestoneTemplates/${id}`) + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .expect(404, next); + } + }); + }, 500); }; const productTemplates = [ { @@ -93,7 +94,9 @@ const milestoneTemplates = [ blockedText: 'text to be shown in blocked stage', activeText: 'text to be shown in active stage', completedText: 'text to be shown in completed stage', - productTemplateId: 1, + reference: 'productTemplate', + referenceId: 1, + metadata: {}, createdBy: 1, updatedBy: 2, }, @@ -107,7 +110,9 @@ const milestoneTemplates = [ blockedText: 'text to be shown in blocked stage - 2', activeText: 'text to be shown in active stage - 2', completedText: 'text to be shown in completed stage - 2', - productTemplateId: 1, + reference: 'productTemplate', + referenceId: 1, + metadata: {}, createdBy: 2, updatedBy: 3, deletedAt: new Date(), @@ -117,20 +122,20 @@ const milestoneTemplates = [ describe('DELETE milestone template', () => { beforeEach(() => testUtil.clearDb() .then(() => models.ProductTemplate.bulkCreate(productTemplates)) - .then(() => models.ProductMilestoneTemplate.bulkCreate(milestoneTemplates)), + .then(() => models.MilestoneTemplate.bulkCreate(milestoneTemplates)), ); after(testUtil.clearDb); - describe('DELETE /productTemplates/{productTemplateId}/milestones/{milestoneTemplateId}', () => { + describe('DELETE /timelines/metadata/milestoneTemplates/{milestoneTemplateId}', () => { it('should return 403 if user is not authenticated', (done) => { request(server) - .delete('/v4/productTemplates/1/milestones/1') + .delete('/v4/timelines/metadata/milestoneTemplates/1') .expect(403, done); }); it('should return 403 for member', (done) => { request(server) - .delete('/v4/productTemplates/1/milestones/1') + .delete('/v4/timelines/metadata/milestoneTemplates/1') .set({ Authorization: `Bearer ${testUtil.jwts.member}`, }) @@ -139,7 +144,7 @@ describe('DELETE milestone template', () => { it('should return 403 for copilot', (done) => { request(server) - .delete('/v4/productTemplates/1/milestones/1') + .delete('/v4/timelines/metadata/milestoneTemplates/1') .set({ Authorization: `Bearer ${testUtil.jwts.copilot}`, }) @@ -148,25 +153,16 @@ describe('DELETE milestone template', () => { it('should return 403 for manager', (done) => { request(server) - .delete('/v4/productTemplates/1/milestones/1') + .delete('/v4/timelines/metadata/milestoneTemplates/1') .set({ Authorization: `Bearer ${testUtil.jwts.manager}`, }) .expect(403, done); }); - it('should return 404 for non-existed product template', (done) => { - request(server) - .delete('/v4/productTemplates/1234/milestones/1') - .set({ - Authorization: `Bearer ${testUtil.jwts.admin}`, - }) - .expect(404, done); - }); - it('should return 404 for non-existed milestone template', (done) => { request(server) - .delete('/v4/productTemplates/1/milestones/444') + .delete('/v4/timelines/metadata/milestoneTemplates/444') .set({ Authorization: `Bearer ${testUtil.jwts.admin}`, }) @@ -175,25 +171,16 @@ describe('DELETE milestone template', () => { it('should return 404 for deleted milestone template', (done) => { request(server) - .delete('/v4/productTemplates/1/milestones/2') + .delete('/v4/timelines/metadata/milestoneTemplates/2') .set({ Authorization: `Bearer ${testUtil.jwts.admin}`, }) .expect(404, done); }); - it('should return 422 for invalid productTemplateId param', (done) => { - request(server) - .delete('/v4/productTemplates/0/milestones/2') - .set({ - Authorization: `Bearer ${testUtil.jwts.admin}`, - }) - .expect(422, done); - }); - it('should return 422 for invalid milestoneTemplateId param', (done) => { request(server) - .delete('/v4/productTemplates/1/milestones/0') + .delete('/v4/timelines/metadata/milestoneTemplates/0') .set({ Authorization: `Bearer ${testUtil.jwts.admin}`, }) @@ -202,22 +189,22 @@ describe('DELETE milestone template', () => { it('should return 204, for admin, if template was successfully removed', (done) => { request(server) - .delete('/v4/productTemplates/1/milestones/1') + .delete('/v4/timelines/metadata/milestoneTemplates/1') .set({ Authorization: `Bearer ${testUtil.jwts.admin}`, }) .expect(204) - .end(err => expectAfterDelete(1, 1, err, done)); + .end(err => expectAfterDelete(1, err, done)); }); it('should return 204, for connect admin, if template was successfully removed', (done) => { request(server) - .delete('/v4/productTemplates/1/milestones/1') + .delete('/v4/timelines/metadata/milestoneTemplates/1') .set({ Authorization: `Bearer ${testUtil.jwts.connectAdmin}`, }) .expect(204) - .end(err => expectAfterDelete(1, 1, err, done)); + .end(err => expectAfterDelete(1, err, done)); }); }); }); diff --git a/src/routes/milestoneTemplates/get.js b/src/routes/milestoneTemplates/get.js index 1c1fa3f0..1f6cd2f7 100644 --- a/src/routes/milestoneTemplates/get.js +++ b/src/routes/milestoneTemplates/get.js @@ -3,41 +3,22 @@ */ import validate from 'express-validation'; import Joi from 'joi'; +import _ from 'lodash'; import { middleware as tcMiddleware } from 'tc-core-library-js'; import util from '../../util'; -import models from '../../models'; +import validateMilestoneTemplate from '../../middlewares/validateMilestoneTemplate'; const permissions = tcMiddleware.permissions; const schema = { params: { - productTemplateId: Joi.number().integer().positive().required(), milestoneTemplateId: Joi.number().integer().positive().required(), }, }; module.exports = [ validate(schema), + validateMilestoneTemplate.validateIdParam, permissions('milestoneTemplate.view'), - (req, res, next) => models.ProductMilestoneTemplate.findOne({ - where: { - id: req.params.milestoneTemplateId, - productTemplateId: req.params.productTemplateId, - }, - attributes: { exclude: ['deletedAt', 'deletedBy'] }, - raw: true, - }) - .then((milestoneTemplate) => { - // Not found - if (!milestoneTemplate) { - const apiErr = new Error( - `Milestone template not found for milestone template id ${req.params.milestoneTemplateId}`); - apiErr.status = 404; - return Promise.reject(apiErr); - } - - res.json(util.wrapResponse(req.id, milestoneTemplate)); - return Promise.resolve(); - }) - .catch(next), + (req, res) => res.json(util.wrapResponse(req.id, _.omit(req.milestoneTemplate.toJSON(), 'deletedAt', 'deletedBy'))), ]; diff --git a/src/routes/milestoneTemplates/get.spec.js b/src/routes/milestoneTemplates/get.spec.js index 958a30a6..58ce6a5a 100644 --- a/src/routes/milestoneTemplates/get.spec.js +++ b/src/routes/milestoneTemplates/get.spec.js @@ -69,7 +69,9 @@ const milestoneTemplates = [ blockedText: 'text to be shown in blocked stage', activeText: 'text to be shown in active stage', completedText: 'text to be shown in completed stage', - productTemplateId: 1, + reference: 'productTemplate', + referenceId: 1, + metadata: {}, createdBy: 1, updatedBy: 2, }, @@ -83,7 +85,9 @@ const milestoneTemplates = [ blockedText: 'text to be shown in blocked stage - 2', activeText: 'text to be shown in active stage - 2', completedText: 'text to be shown in completed stage - 2', - productTemplateId: 1, + reference: 'productTemplate', + referenceId: 1, + metadata: {}, createdBy: 2, updatedBy: 3, deletedAt: new Date(), @@ -93,29 +97,20 @@ const milestoneTemplates = [ describe('GET milestone template', () => { beforeEach(() => testUtil.clearDb() .then(() => models.ProductTemplate.bulkCreate(productTemplates)) - .then(() => models.ProductMilestoneTemplate.bulkCreate(milestoneTemplates)), + .then(() => models.MilestoneTemplate.bulkCreate(milestoneTemplates)), ); after(testUtil.clearDb); - describe('GET /productTemplates/{productTemplateId}/milestones/{milestoneTemplateId}', () => { + describe('GET /timelines/metadata/milestoneTemplates/{milestoneTemplateId}', () => { it('should return 403 if user is not authenticated', (done) => { request(server) - .get('/v4/productTemplates/1/milestones/1') + .get('/v4/timelines/metadata/milestoneTemplates/1') .expect(403, done); }); - it('should return 404 for non-existed product template', (done) => { - request(server) - .get('/v4/productTemplates/1234/milestones/1') - .set({ - Authorization: `Bearer ${testUtil.jwts.admin}`, - }) - .expect(404, done); - }); - it('should return 404 for non-existed milestone template', (done) => { request(server) - .get('/v4/productTemplates/1/milestones/1111') + .get('/v4/timelines/metadata/milestoneTemplates/1111') .set({ Authorization: `Bearer ${testUtil.jwts.admin}`, }) @@ -124,7 +119,7 @@ describe('GET milestone template', () => { it('should return 404 for deleted milestone template', (done) => { request(server) - .get('/v4/productTemplates/1/milestones/2') + .get('/v4/timelines/metadata/milestoneTemplates/2') .set({ Authorization: `Bearer ${testUtil.jwts.admin}`, }) @@ -133,7 +128,7 @@ describe('GET milestone template', () => { it('should return 200 for admin', (done) => { request(server) - .get('/v4/productTemplates/1/milestones/1') + .get('/v4/timelines/metadata/milestoneTemplates/1') .set({ Authorization: `Bearer ${testUtil.jwts.admin}`, }) @@ -149,7 +144,9 @@ describe('GET milestone template', () => { resJson.blockedText.should.be.eql(milestoneTemplates[0].blockedText); resJson.activeText.should.be.eql(milestoneTemplates[0].activeText); resJson.completedText.should.be.eql(milestoneTemplates[0].completedText); - resJson.productTemplateId.should.be.eql(milestoneTemplates[0].productTemplateId); + resJson.reference.should.be.eql(milestoneTemplates[0].reference); + resJson.referenceId.should.be.eql(milestoneTemplates[0].referenceId); + resJson.metadata.should.be.eql(milestoneTemplates[0].metadata); resJson.createdBy.should.be.eql(milestoneTemplates[0].createdBy); should.exist(resJson.createdAt); @@ -164,7 +161,7 @@ describe('GET milestone template', () => { it('should return 200 for connect admin', (done) => { request(server) - .get('/v4/productTemplates/1/milestones/1') + .get('/v4/timelines/metadata/milestoneTemplates/1') .set({ Authorization: `Bearer ${testUtil.jwts.connectAdmin}`, }) @@ -174,7 +171,7 @@ describe('GET milestone template', () => { it('should return 200 for connect manager', (done) => { request(server) - .get('/v4/productTemplates/1/milestones/1') + .get('/v4/timelines/metadata/milestoneTemplates/1') .set({ Authorization: `Bearer ${testUtil.jwts.manager}`, }) @@ -184,7 +181,7 @@ describe('GET milestone template', () => { it('should return 200 for member', (done) => { request(server) - .get('/v4/productTemplates/1/milestones/1') + .get('/v4/timelines/metadata/milestoneTemplates/1') .set({ Authorization: `Bearer ${testUtil.jwts.member}`, }) @@ -193,7 +190,7 @@ describe('GET milestone template', () => { it('should return 200 for copilot', (done) => { request(server) - .get('/v4/productTemplates/1/milestones/1') + .get('/v4/timelines/metadata/milestoneTemplates/1') .set({ Authorization: `Bearer ${testUtil.jwts.copilot}`, }) diff --git a/src/routes/milestoneTemplates/list.js b/src/routes/milestoneTemplates/list.js index 40b6ae19..64d93acf 100644 --- a/src/routes/milestoneTemplates/list.js +++ b/src/routes/milestoneTemplates/list.js @@ -1,23 +1,16 @@ /** * API to list all milestone templates */ -import validate from 'express-validation'; -import Joi from 'joi'; import _ from 'lodash'; import { middleware as tcMiddleware } from 'tc-core-library-js'; import util from '../../util'; import models from '../../models'; +import validateMilestoneTemplate from '../../middlewares/validateMilestoneTemplate'; const permissions = tcMiddleware.permissions; -const schema = { - params: { - productTemplateId: Joi.number().integer().positive().required(), - }, -}; - module.exports = [ - validate(schema), + validateMilestoneTemplate.validateQueryFilter, permissions('milestoneTemplate.view'), (req, res, next) => { // Parse the sort query @@ -36,10 +29,9 @@ module.exports = [ const sortColumnAndOrder = sort.split(' '); // Get all milestone templates - return models.ProductMilestoneTemplate.findAll({ - where: { - productTemplateId: req.params.productTemplateId, - }, + const where = req.params.filter || {}; + return models.MilestoneTemplate.findAll({ + where, order: [sortColumnAndOrder], attributes: { exclude: ['deletedAt', 'deletedBy'] }, raw: true, diff --git a/src/routes/milestoneTemplates/list.spec.js b/src/routes/milestoneTemplates/list.spec.js index 086356c3..2ee2f25f 100644 --- a/src/routes/milestoneTemplates/list.spec.js +++ b/src/routes/milestoneTemplates/list.spec.js @@ -69,7 +69,9 @@ const milestoneTemplates = [ blockedText: 'text to be shown in blocked stage', activeText: 'text to be shown in active stage', completedText: 'text to be shown in completed stage', - productTemplateId: 1, + reference: 'productTemplate', + referenceId: 1, + metadata: {}, createdBy: 1, updatedBy: 2, }, @@ -83,7 +85,9 @@ const milestoneTemplates = [ blockedText: 'text to be shown in blocked stage - 2', activeText: 'text to be shown in active stage - 2', completedText: 'text to be shown in completed stage - 2', - productTemplateId: 1, + reference: 'productTemplate', + referenceId: 1, + metadata: {}, createdBy: 2, updatedBy: 3, }, @@ -97,7 +101,9 @@ const milestoneTemplates = [ blockedText: 'text to be shown in blocked stage - 3', activeText: 'text to be shown in active stage - 3', completedText: 'text to be shown in completed stage - 3', - productTemplateId: 1, + reference: 'productTemplate', + referenceId: 1, + metadata: {}, createdBy: 2, updatedBy: 3, deletedAt: new Date(), @@ -107,29 +113,20 @@ const milestoneTemplates = [ describe('LIST milestone template', () => { beforeEach(() => testUtil.clearDb() .then(() => models.ProductTemplate.bulkCreate(productTemplates)) - .then(() => models.ProductMilestoneTemplate.bulkCreate(milestoneTemplates)), + .then(() => models.MilestoneTemplate.bulkCreate(milestoneTemplates)), ); after(testUtil.clearDb); - describe('GET /productTemplates/{productTemplateId}/milestones', () => { + describe('GET /timelines/metadata/milestoneTemplates', () => { it('should return 403 if user is not authenticated', (done) => { request(server) - .get('/v4/productTemplates/1/milestones') + .get('/v4/timelines/metadata/milestoneTemplates') .expect(403, done); }); - it('should return 422 for invalid productTemplateId param', (done) => { - request(server) - .get('/v4/productTemplates/0/milestones') - .set({ - Authorization: `Bearer ${testUtil.jwts.admin}`, - }) - .expect(422, done); - }); - it('should return 422 for invalid sort column', (done) => { request(server) - .get('/v4/productTemplates/1/milestones?sort=id') + .get('/v4/timelines/metadata/milestoneTemplates?sort=id') .set({ Authorization: `Bearer ${testUtil.jwts.admin}`, }) @@ -138,7 +135,7 @@ describe('LIST milestone template', () => { it('should return 422 for invalid sort order', (done) => { request(server) - .get('/v4/productTemplates/1/milestones?sort=order%20invalid') + .get('/v4/timelines/metadata/milestoneTemplates?sort=order%20invalid') .set({ Authorization: `Bearer ${testUtil.jwts.admin}`, }) @@ -147,7 +144,7 @@ describe('LIST milestone template', () => { it('should return 200 for admin', (done) => { request(server) - .get('/v4/productTemplates/1/milestones') + .get('/v4/timelines/metadata/milestoneTemplates') .set({ Authorization: `Bearer ${testUtil.jwts.admin}`, }) @@ -164,7 +161,9 @@ describe('LIST milestone template', () => { resJson[0].blockedText.should.be.eql(milestoneTemplates[0].blockedText); resJson[0].activeText.should.be.eql(milestoneTemplates[0].activeText); resJson[0].completedText.should.be.eql(milestoneTemplates[0].completedText); - resJson[0].productTemplateId.should.be.eql(milestoneTemplates[0].productTemplateId); + resJson[0].reference.should.be.eql(milestoneTemplates[0].reference); + resJson[0].referenceId.should.be.eql(milestoneTemplates[0].referenceId); + resJson[0].metadata.should.be.eql(milestoneTemplates[0].metadata); resJson[0].createdBy.should.be.eql(milestoneTemplates[0].createdBy); should.exist(resJson[0].createdAt); @@ -177,9 +176,9 @@ describe('LIST milestone template', () => { }); }); - it('should return 200 for connect admin', (done) => { + it('should return 200 for connect admin with filter', (done) => { request(server) - .get('/v4/productTemplates/1/milestones') + .get('/v4/timelines/metadata/milestoneTemplates?filter=reference%3DproductTemplate%26referenceId%3D1') .set({ Authorization: `Bearer ${testUtil.jwts.connectAdmin}`, }) @@ -189,7 +188,7 @@ describe('LIST milestone template', () => { it('should return 200 for connect manager', (done) => { request(server) - .get('/v4/productTemplates/1/milestones') + .get('/v4/timelines/metadata/milestoneTemplates') .set({ Authorization: `Bearer ${testUtil.jwts.manager}`, }) @@ -199,7 +198,7 @@ describe('LIST milestone template', () => { it('should return 200 for member', (done) => { request(server) - .get('/v4/productTemplates/1/milestones') + .get('/v4/timelines/metadata/milestoneTemplates') .set({ Authorization: `Bearer ${testUtil.jwts.member}`, }) @@ -208,7 +207,7 @@ describe('LIST milestone template', () => { it('should return 200 for copilot', (done) => { request(server) - .get('/v4/productTemplates/1/milestones') + .get('/v4/timelines/metadata/milestoneTemplates') .set({ Authorization: `Bearer ${testUtil.jwts.copilot}`, }) @@ -217,7 +216,7 @@ describe('LIST milestone template', () => { it('should return 200 with sort desc', (done) => { request(server) - .get('/v4/productTemplates/1/milestones?sort=order%20desc') + .get('/v4/timelines/metadata/milestoneTemplates?sort=order%20desc') .set({ Authorization: `Bearer ${testUtil.jwts.copilot}`, }) diff --git a/src/routes/milestoneTemplates/update.js b/src/routes/milestoneTemplates/update.js index 8936b72c..c9ce67e4 100644 --- a/src/routes/milestoneTemplates/update.js +++ b/src/routes/milestoneTemplates/update.js @@ -8,12 +8,13 @@ import Sequelize from 'sequelize'; import { middleware as tcMiddleware } from 'tc-core-library-js'; import util from '../../util'; import models from '../../models'; +import validateMilestoneTemplate from '../../middlewares/validateMilestoneTemplate'; +import { MILESTONE_TEMPLATE_REFERENCES } from '../../constants'; const permissions = tcMiddleware.permissions; const schema = { params: { - productTemplateId: Joi.number().integer().positive().required(), milestoneTemplateId: Joi.number().integer().positive().required(), }, body: { @@ -30,6 +31,9 @@ const schema = { blockedText: Joi.string().max(512).optional(), productTemplateId: Joi.any().strip(), hidden: Joi.boolean().optional(), + reference: Joi.string().valid(_.values(MILESTONE_TEMPLATE_REFERENCES)).required(), + referenceId: Joi.number().integer().positive().required(), + metadata: Joi.object().optional(), createdAt: Joi.any().strip(), updatedAt: Joi.any().strip(), deletedAt: Joi.any().strip(), @@ -42,37 +46,23 @@ const schema = { module.exports = [ validate(schema), + validateMilestoneTemplate.validateIdParam, + validateMilestoneTemplate.validateRequestBody, permissions('milestoneTemplate.edit'), (req, res, next) => { const entityToUpdate = _.assign(req.body.param, { updatedBy: req.authUser.userId, }); - let original; + const original = _.omit(req.milestoneTemplate.toJSON(), 'deletedAt', 'deletedBy'); let updated; - return models.sequelize.transaction(() => - // Get the milestone template - models.ProductMilestoneTemplate.findOne({ - where: { - id: req.params.milestoneTemplateId, - productTemplateId: req.params.productTemplateId, - }, - attributes: { exclude: ['deletedAt', 'deletedBy'] }, - }) - .then((milestoneTemplate) => { - // Not found - if (!milestoneTemplate) { - const apiErr = new Error(`Milestone template not found for template id ${req.params.milestoneTemplateId}`); - apiErr.status = 404; - return Promise.reject(apiErr); - } + // Merge JSON field + entityToUpdate.metadata = util.mergeJsonObjects(original.metadata, entityToUpdate.metadata || {}); - original = _.omit(milestoneTemplate.toJSON(), ['deletedAt', 'deletedBy']); - - // Update - return milestoneTemplate.update(entityToUpdate); - }) + return models.sequelize.transaction(() => + // Update + req.milestoneTemplate.update(entityToUpdate) .then((milestoneTemplate) => { updated = _.omit(milestoneTemplate.toJSON(), ['deletedAt', 'deletedBy']); @@ -81,9 +71,10 @@ module.exports = [ return Promise.resolve(); } - return models.ProductMilestoneTemplate.count({ + return models.MilestoneTemplate.count({ where: { - productTemplateId: updated.productTemplateId, + reference: updated.reference, + referenceId: updated.referenceId, id: { $ne: updated.id }, order: updated.order, }, @@ -96,9 +87,10 @@ module.exports = [ // Increase the order from M to K: if there is an item with order K, // orders from M+1 to K should be made M to K-1 if (original.order < updated.order) { - return models.ProductMilestoneTemplate.update({ order: Sequelize.literal('"order" - 1') }, { + return models.MilestoneTemplate.update({ order: Sequelize.literal('"order" - 1') }, { where: { - productTemplateId: updated.productTemplateId, + reference: updated.reference, + referenceId: updated.referenceId, id: { $ne: updated.id }, order: { $between: [original.order + 1, updated.order] }, }, @@ -107,9 +99,10 @@ module.exports = [ // Decrease the order from M to K: if there is an item with order K, // orders from K to M-1 should be made K+1 to M - return models.ProductMilestoneTemplate.update({ order: Sequelize.literal('"order" + 1') }, { + return models.MilestoneTemplate.update({ order: Sequelize.literal('"order" + 1') }, { where: { - productTemplateId: updated.productTemplateId, + reference: updated.reference, + referenceId: updated.referenceId, id: { $ne: updated.id }, order: { $between: [updated.order, original.order - 1] }, }, @@ -117,10 +110,10 @@ module.exports = [ }); }), ) - .then(() => { - res.json(util.wrapResponse(req.id, updated)); - return Promise.resolve(); - }) - .catch(next); + .then(() => { + res.json(util.wrapResponse(req.id, updated)); + return Promise.resolve(); + }) + .catch(next); }, ]; diff --git a/src/routes/milestoneTemplates/update.spec.js b/src/routes/milestoneTemplates/update.spec.js index 59bbcb00..68be6f31 100644 --- a/src/routes/milestoneTemplates/update.spec.js +++ b/src/routes/milestoneTemplates/update.spec.js @@ -69,7 +69,9 @@ const milestoneTemplates = [ blockedText: 'text to be shown in blocked stage', activeText: 'text to be shown in active stage', completedText: 'text to be shown in completed stage', - productTemplateId: 1, + reference: 'productTemplate', + referenceId: 1, + metadata: {}, createdBy: 1, updatedBy: 2, }, @@ -83,7 +85,9 @@ const milestoneTemplates = [ blockedText: 'text to be shown in blocked stage - 2', activeText: 'text to be shown in active stage - 2', completedText: 'text to be shown in completed stage - 2', - productTemplateId: 1, + reference: 'productTemplate', + referenceId: 1, + metadata: {}, createdBy: 2, updatedBy: 3, }, @@ -97,7 +101,9 @@ const milestoneTemplates = [ blockedText: 'text to be shown in blocked stage - 3', activeText: 'text to be shown in active stage - 3', completedText: 'text to be shown in completed stage - 3', - productTemplateId: 1, + reference: 'productTemplate', + referenceId: 1, + metadata: {}, createdBy: 2, updatedBy: 3, }, @@ -111,21 +117,54 @@ const milestoneTemplates = [ blockedText: 'text to be shown in blocked stage - 4', activeText: 'text to be shown in active stage - 4', completedText: 'text to be shown in completed stage - 4', - productTemplateId: 1, + reference: 'productTemplate', + referenceId: 1, + metadata: {}, createdBy: 2, updatedBy: 3, deletedAt: new Date(), }, + { + id: 5, + name: 'milestoneTemplate 5', + duration: 5, + type: 'type5', + order: 5, + plannedText: 'text to be shown in planned stage - 5', + blockedText: 'text to be shown in blocked stage - 5', + activeText: 'text to be shown in active stage - 5', + completedText: 'text to be shown in completed stage - 5', + reference: 'productTemplate', + referenceId: 1, + metadata: { + metadata1: { + name: 'metadata 1', + details: { + anyDetails: 'any details 1', + }, + others: ['others 11', 'others 12'], + }, + metadata2: { + name: 'metadata 2', + details: { + anyDetails: 'any details 2', + }, + others: ['others 21', 'others 22'], + }, + }, + createdBy: 2, + updatedBy: 3, + }, ]; describe('UPDATE milestone template', () => { beforeEach(() => testUtil.clearDb() .then(() => models.ProductTemplate.bulkCreate(productTemplates)) - .then(() => models.ProductMilestoneTemplate.bulkCreate(milestoneTemplates)), + .then(() => models.MilestoneTemplate.bulkCreate(milestoneTemplates)), ); after(testUtil.clearDb); - describe('PATCH /productTemplates/{productTemplateId}/milestones/{milestoneTemplateId}', () => { + describe('PATCH /timelines/metadata/milestoneTemplates/{milestoneTemplateId}', () => { const body = { param: { name: 'milestoneTemplate 1-updated', @@ -138,19 +177,22 @@ describe('UPDATE milestone template', () => { activeText: 'text to be shown in active stage', completedText: 'text to be shown in completed stage', hidden: true, + reference: 'productTemplate', + referenceId: 1, + metadata: {}, }, }; it('should return 403 if user is not authenticated', (done) => { request(server) - .patch('/v4/productTemplates/1/milestones/1') + .patch('/v4/timelines/metadata/milestoneTemplates/1') .send(body) .expect(403, done); }); it('should return 403 for member', (done) => { request(server) - .patch('/v4/productTemplates/1/milestones/1') + .patch('/v4/timelines/metadata/milestoneTemplates/1') .set({ Authorization: `Bearer ${testUtil.jwts.member}`, }) @@ -160,7 +202,7 @@ describe('UPDATE milestone template', () => { it('should return 403 for copilot', (done) => { request(server) - .patch('/v4/productTemplates/1/milestones/1') + .patch('/v4/timelines/metadata/milestoneTemplates/1') .send(body) .set({ Authorization: `Bearer ${testUtil.jwts.copilot}`, @@ -170,7 +212,7 @@ describe('UPDATE milestone template', () => { it('should return 403 for manager', (done) => { request(server) - .patch('/v4/productTemplates/1/milestones/1') + .patch('/v4/timelines/metadata/milestoneTemplates/1') .send(body) .set({ Authorization: `Bearer ${testUtil.jwts.manager}`, @@ -178,19 +220,9 @@ describe('UPDATE milestone template', () => { .expect(403, done); }); - it('should return 404 for non-existed product template', (done) => { - request(server) - .patch('/v4/productTemplates/122/milestones/1') - .set({ - Authorization: `Bearer ${testUtil.jwts.admin}`, - }) - .send(body) - .expect(404, done); - }); - it('should return 404 for non-existed milestone template', (done) => { request(server) - .patch('/v4/productTemplates/1/milestones/111') + .patch('/v4/timelines/metadata/milestoneTemplates/111') .set({ Authorization: `Bearer ${testUtil.jwts.admin}`, }) @@ -200,7 +232,7 @@ describe('UPDATE milestone template', () => { it('should return 404 for deleted milestone template', (done) => { request(server) - .patch('/v4/productTemplates/1/milestones/4') + .patch('/v4/timelines/metadata/milestoneTemplates/4') .set({ Authorization: `Bearer ${testUtil.jwts.admin}`, }) @@ -210,7 +242,7 @@ describe('UPDATE milestone template', () => { it('should return 200 for admin', (done) => { request(server) - .patch('/v4/productTemplates/1/milestones/1') + .patch('/v4/timelines/metadata/milestoneTemplates/1') .set({ Authorization: `Bearer ${testUtil.jwts.admin}`, }) @@ -228,6 +260,9 @@ describe('UPDATE milestone template', () => { resJson.blockedText.should.be.eql(body.param.blockedText); resJson.activeText.should.be.eql(body.param.activeText); resJson.completedText.should.be.eql(body.param.completedText); + resJson.reference.should.be.eql(body.param.reference); + resJson.referenceId.should.be.eql(body.param.referenceId); + resJson.metadata.should.be.eql(body.param.metadata); should.exist(resJson.createdBy); should.exist(resJson.createdAt); @@ -245,7 +280,7 @@ describe('UPDATE milestone template', () => { this.timeout(10000); request(server) - .patch('/v4/productTemplates/1/milestones/1') + .patch('/v4/timelines/metadata/milestoneTemplates/1') .set({ Authorization: `Bearer ${testUtil.jwts.admin}`, }) @@ -255,15 +290,15 @@ describe('UPDATE milestone template', () => { // Milestone 1: order 3 // Milestone 2: order 2 - 1 = 1 // Milestone 3: order 3 - 1 = 2 - models.ProductMilestoneTemplate.findById(1) + models.MilestoneTemplate.findById(1) .then((milestone) => { milestone.order.should.be.eql(3); }) - .then(() => models.ProductMilestoneTemplate.findById(2)) + .then(() => models.MilestoneTemplate.findById(2)) .then((milestone) => { milestone.order.should.be.eql(1); }) - .then(() => models.ProductMilestoneTemplate.findById(3)) + .then(() => models.MilestoneTemplate.findById(3)) .then((milestone) => { milestone.order.should.be.eql(2); @@ -277,7 +312,7 @@ describe('UPDATE milestone template', () => { this.timeout(10000); request(server) - .patch('/v4/productTemplates/1/milestones/1') + .patch('/v4/timelines/metadata/milestoneTemplates/1') .set({ Authorization: `Bearer ${testUtil.jwts.admin}`, }) @@ -287,15 +322,15 @@ describe('UPDATE milestone template', () => { // Milestone 1: order 4 // Milestone 2: order 2 // Milestone 3: order 3 - models.ProductMilestoneTemplate.findById(1) + models.MilestoneTemplate.findById(1) .then((milestone) => { milestone.order.should.be.eql(4); }) - .then(() => models.ProductMilestoneTemplate.findById(2)) + .then(() => models.MilestoneTemplate.findById(2)) .then((milestone) => { milestone.order.should.be.eql(2); }) - .then(() => models.ProductMilestoneTemplate.findById(3)) + .then(() => models.MilestoneTemplate.findById(3)) .then((milestone) => { milestone.order.should.be.eql(3); @@ -309,7 +344,7 @@ describe('UPDATE milestone template', () => { this.timeout(10000); request(server) - .patch('/v4/productTemplates/1/milestones/3') + .patch('/v4/timelines/metadata/milestoneTemplates/3') .set({ Authorization: `Bearer ${testUtil.jwts.admin}`, }) @@ -319,15 +354,15 @@ describe('UPDATE milestone template', () => { // Milestone 1: order 2 // Milestone 2: order 3 // Milestone 3: order 1 - models.ProductMilestoneTemplate.findById(1) + models.MilestoneTemplate.findById(1) .then((milestone) => { milestone.order.should.be.eql(2); }) - .then(() => models.ProductMilestoneTemplate.findById(2)) + .then(() => models.MilestoneTemplate.findById(2)) .then((milestone) => { milestone.order.should.be.eql(3); }) - .then(() => models.ProductMilestoneTemplate.findById(3)) + .then(() => models.MilestoneTemplate.findById(3)) .then((milestone) => { milestone.order.should.be.eql(1); @@ -341,7 +376,7 @@ describe('UPDATE milestone template', () => { this.timeout(10000); request(server) - .patch('/v4/productTemplates/1/milestones/3') + .patch('/v4/timelines/metadata/milestoneTemplates/3') .set({ Authorization: `Bearer ${testUtil.jwts.admin}`, }) @@ -351,15 +386,15 @@ describe('UPDATE milestone template', () => { // Milestone 1: order 1 // Milestone 2: order 2 // Milestone 3: order 0 - models.ProductMilestoneTemplate.findById(1) + models.MilestoneTemplate.findById(1) .then((milestone) => { milestone.order.should.be.eql(1); }) - .then(() => models.ProductMilestoneTemplate.findById(2)) + .then(() => models.MilestoneTemplate.findById(2)) .then((milestone) => { milestone.order.should.be.eql(2); }) - .then(() => models.ProductMilestoneTemplate.findById(3)) + .then(() => models.MilestoneTemplate.findById(3)) .then((milestone) => { milestone.order.should.be.eql(0); @@ -372,7 +407,7 @@ describe('UPDATE milestone template', () => { const partialBody = _.cloneDeep(body); delete partialBody.param.name; request(server) - .patch('/v4/productTemplates/1/milestones/1') + .patch('/v4/timelines/metadata/milestoneTemplates/1') .set({ Authorization: `Bearer ${testUtil.jwts.admin}`, }) @@ -384,7 +419,7 @@ describe('UPDATE milestone template', () => { const partialBody = _.cloneDeep(body); delete partialBody.param.type; request(server) - .patch('/v4/productTemplates/1/milestones/1') + .patch('/v4/timelines/metadata/milestoneTemplates/1') .set({ Authorization: `Bearer ${testUtil.jwts.admin}`, }) @@ -396,7 +431,7 @@ describe('UPDATE milestone template', () => { const partialBody = _.cloneDeep(body); delete partialBody.param.duration; request(server) - .patch('/v4/productTemplates/1/milestones/1') + .patch('/v4/timelines/metadata/milestoneTemplates/1') .set({ Authorization: `Bearer ${testUtil.jwts.admin}`, }) @@ -408,7 +443,7 @@ describe('UPDATE milestone template', () => { const partialBody = _.cloneDeep(body); delete partialBody.param.order; request(server) - .patch('/v4/productTemplates/1/milestones/1') + .patch('/v4/timelines/metadata/milestoneTemplates/1') .set({ Authorization: `Bearer ${testUtil.jwts.admin}`, }) @@ -420,7 +455,7 @@ describe('UPDATE milestone template', () => { const partialBody = _.cloneDeep(body); delete partialBody.param.plannedText; request(server) - .patch('/v4/productTemplates/1/milestones/1') + .patch('/v4/timelines/metadata/milestoneTemplates/1') .set({ Authorization: `Bearer ${testUtil.jwts.admin}`, }) @@ -432,7 +467,7 @@ describe('UPDATE milestone template', () => { const partialBody = _.cloneDeep(body); delete partialBody.param.blockedText; request(server) - .patch('/v4/productTemplates/1/milestones/1') + .patch('/v4/timelines/metadata/milestoneTemplates/1') .set({ Authorization: `Bearer ${testUtil.jwts.admin}`, }) @@ -444,7 +479,7 @@ describe('UPDATE milestone template', () => { const partialBody = _.cloneDeep(body); delete partialBody.param.activeText; request(server) - .patch('/v4/productTemplates/1/milestones/1') + .patch('/v4/timelines/metadata/milestoneTemplates/1') .set({ Authorization: `Bearer ${testUtil.jwts.admin}`, }) @@ -456,7 +491,7 @@ describe('UPDATE milestone template', () => { const partialBody = _.cloneDeep(body); delete partialBody.param.completedText; request(server) - .patch('/v4/productTemplates/1/milestones/1') + .patch('/v4/timelines/metadata/milestoneTemplates/1') .set({ Authorization: `Bearer ${testUtil.jwts.admin}`, }) @@ -468,7 +503,7 @@ describe('UPDATE milestone template', () => { const partialBody = _.cloneDeep(body); delete partialBody.param.hidden; request(server) - .patch('/v4/productTemplates/1/milestones/1') + .patch('/v4/timelines/metadata/milestoneTemplates/1') .set({ Authorization: `Bearer ${testUtil.jwts.admin}`, }) @@ -478,7 +513,7 @@ describe('UPDATE milestone template', () => { it('should return 200 for connect admin', (done) => { request(server) - .patch('/v4/productTemplates/1/milestones/1') + .patch('/v4/timelines/metadata/milestoneTemplates/1') .set({ Authorization: `Bearer ${testUtil.jwts.connectAdmin}`, }) @@ -486,5 +521,78 @@ describe('UPDATE milestone template', () => { .expect(200) .end(done); }); + + it('should return 200 for admin - updating metadata', (done) => { + const bodyWithMetadata = { + param: { + name: 'milestoneTemplate 5-updated', + description: 'description-updated', + duration: 6, + type: 'type5-updated', + order: 5, + plannedText: 'text to be shown in planned stage', + blockedText: 'text to be shown in blocked stage', + activeText: 'text to be shown in active stage', + completedText: 'text to be shown in completed stage', + hidden: true, + reference: 'productTemplate', + referenceId: 1, + metadata: { + metadata1: { + name: 'metadata 1 - update', + details: { + anyDetails: 'any details 1 - update', + newDetails: 'new', + }, + others: ['others new'], + }, + metadata3: { + name: 'metadata 3', + details: { + anyDetails: 'any details 3', + }, + others: ['others 31', 'others 32'], + }, + }, + }, + }; + + request(server) + .patch('/v4/timelines/metadata/milestoneTemplates/5') + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .send(bodyWithMetadata) + .expect(200) + .end((err, res) => { + const resJson = res.body.result.content; + resJson.metadata.should.be.eql({ + metadata1: { + name: 'metadata 1 - update', + details: { + anyDetails: 'any details 1 - update', + newDetails: 'new', + }, + others: ['others new'], + }, + metadata2: { + name: 'metadata 2', + details: { + anyDetails: 'any details 2', + }, + others: ['others 21', 'others 22'], + }, + metadata3: { + name: 'metadata 3', + details: { + anyDetails: 'any details 3', + }, + others: ['others 31', 'others 32'], + }, + }); + + done(); + }); + }); }); }); diff --git a/src/routes/milestones/create.js b/src/routes/milestones/create.js index e0643d38..eadec1f4 100644 --- a/src/routes/milestones/create.js +++ b/src/routes/milestones/create.js @@ -80,13 +80,6 @@ module.exports = [ // Omit deletedAt, deletedBy result = _.omit(createdEntity.toJSON(), 'deletedAt', 'deletedBy'); - // Send event to bus - req.log.debug('Sending event to RabbitMQ bus for milestone %d', result.id); - req.app.services.pubsub.publish(EVENT.ROUTING_KEY.MILESTONE_ADDED, - result, - { correlationId: req.id }, - ); - // Increase the order of the other milestones in the same timeline, // which have `order` >= this milestone order return models.Milestone.update({ order: Sequelize.literal('"order" + 1') }, { @@ -104,6 +97,16 @@ module.exports = [ // because it will make 'version conflict' error in ES. // The order of the other milestones need to be updated in the MILESTONE_ADDED event handler + // Send event to bus + req.log.debug('Sending event to RabbitMQ bus for milestone %d', result.id); + req.app.services.pubsub.publish(EVENT.ROUTING_KEY.MILESTONE_ADDED, + result, + { correlationId: req.id }, + ); + + req.app.emit(EVENT.ROUTING_KEY.MILESTONE_ADDED, + { req, created: result }); + // Write to the response res.status(201).json(util.wrapResponse(req.id, result, 1, 201)); return Promise.resolve(); diff --git a/src/routes/milestones/create.spec.js b/src/routes/milestones/create.spec.js index 250e8175..ab1424ca 100644 --- a/src/routes/milestones/create.spec.js +++ b/src/routes/milestones/create.spec.js @@ -1,13 +1,16 @@ +/* eslint-disable no-unused-expressions */ /** * Tests for create.js */ import chai from 'chai'; +import sinon from 'sinon'; import request from 'supertest'; import _ from 'lodash'; import server from '../../app'; import testUtil from '../../tests/util'; import models from '../../models'; -import { EVENT } from '../../constants'; +import busApi from '../../services/busApi'; +import { EVENT, BUS_API_EVENT } from '../../constants'; const should = chai.should(); @@ -28,6 +31,8 @@ describe('CREATE milestone', () => { details: {}, createdBy: 1, updatedBy: 1, + lastActivityAt: 1, + lastActivityUserId: '1', }, { type: 'generic', @@ -38,6 +43,8 @@ describe('CREATE milestone', () => { details: {}, createdBy: 2, updatedBy: 2, + lastActivityAt: 1, + lastActivityUserId: '1', deletedAt: '2018-05-15T00:00:00Z', }, ], { returning: true }) @@ -604,5 +611,51 @@ describe('CREATE milestone', () => { done(); }); }); + + describe('Bus api', () => { + let createEventSpy; + const sandbox = sinon.sandbox.create(); + + before((done) => { + // Wait for 500ms in order to wait for createEvent calls from previous tests to complete + testUtil.wait(done); + }); + + beforeEach(() => { + createEventSpy = sandbox.spy(busApi, 'createEvent'); + }); + + afterEach(() => { + sandbox.restore(); + }); + + it('should send message BUS_API_EVENT.TIMELINE_ADJUSTED when milestone created', (done) => { + request(server) + .post('/v4/timelines/1/milestones') + .set({ + Authorization: `Bearer ${testUtil.jwts.copilot}`, + }) + .send(body) + .expect('Content-Type', /json/) + .expect(201) + .end((err) => { + if (err) { + done(err); + } else { + testUtil.wait(() => { + createEventSpy.calledOnce.should.be.true; + createEventSpy.calledWith(BUS_API_EVENT.MILESTONE_ADDED, sinon.match({ + projectId: 1, + projectName: 'test1', + projectUrl: 'https://local.topcoder-dev.com/projects/1', + userId: 40051332, + initiatorUserId: 40051332, + })).should.be.true; + done(); + }); + } + }); + }); + }); }); }); diff --git a/src/routes/milestones/delete.js b/src/routes/milestones/delete.js index 50377c90..45e5a41b 100644 --- a/src/routes/milestones/delete.js +++ b/src/routes/milestones/delete.js @@ -55,6 +55,8 @@ module.exports = [ deleted, { correlationId: req.id }, ); + req.app.emit(EVENT.ROUTING_KEY.MILESTONE_REMOVED, + { req, deleted }); // Write to response res.status(204).end(); diff --git a/src/routes/milestones/delete.spec.js b/src/routes/milestones/delete.spec.js index a82294e9..c756b7b0 100644 --- a/src/routes/milestones/delete.spec.js +++ b/src/routes/milestones/delete.spec.js @@ -1,14 +1,16 @@ +/* eslint-disable no-unused-expressions */ /** * Tests for delete.js */ import request from 'supertest'; +import sinon from 'sinon'; import chai from 'chai'; import models from '../../models'; import server from '../../app'; import testUtil from '../../tests/util'; -import { EVENT } from '../../constants'; - +import { EVENT, BUS_API_EVENT } from '../../constants'; +import busApi from '../../services/busApi'; const expectAfterDelete = (timelineId, id, err, next) => { if (err) throw err; @@ -50,6 +52,8 @@ describe('DELETE milestone', () => { details: {}, createdBy: 1, updatedBy: 1, + lastActivityAt: 1, + lastActivityUserId: '1', }, { type: 'generic', @@ -60,6 +64,8 @@ describe('DELETE milestone', () => { details: {}, createdBy: 2, updatedBy: 2, + lastActivityAt: 1, + lastActivityUserId: '1', deletedAt: '2018-05-15T00:00:00Z', }, ]) @@ -348,5 +354,51 @@ describe('DELETE milestone', () => { .expect(204) .end(err => expectAfterDelete(1, 1, err, done)); }); + + describe('Bus api', () => { + let createEventSpy; + const sandbox = sinon.sandbox.create(); + + before((done) => { + // Wait for 500ms in order to wait for createEvent calls from previous tests to complete + testUtil.wait(done); + }); + + beforeEach(() => { + createEventSpy = sandbox.spy(busApi, 'createEvent'); + }); + + afterEach(() => { + sandbox.restore(); + }); + + // not testing fields separately as startDate is required parameter, + // thus TIMELINE_ADJUSTED will be always sent + it('should send message BUS_API_EVENT.TIMELINE_ADJUSTED when milestone removed', (done) => { + request(server) + .delete('/v4/timelines/1/milestones/1') + .set({ + Authorization: `Bearer ${testUtil.jwts.copilot}`, + }) + .expect(204) + .end((err) => { + if (err) { + done(err); + } else { + testUtil.wait(() => { + createEventSpy.calledOnce.should.be.true; + createEventSpy.calledWith(BUS_API_EVENT.MILESTONE_REMOVED, sinon.match({ + projectId: 1, + projectName: 'test1', + projectUrl: 'https://local.topcoder-dev.com/projects/1', + userId: 40051332, + initiatorUserId: 40051332, + })).should.be.true; + done(); + }); + } + }); + }); + }); }); }); diff --git a/src/routes/milestones/get.spec.js b/src/routes/milestones/get.spec.js index 919b756d..fb0451d1 100644 --- a/src/routes/milestones/get.spec.js +++ b/src/routes/milestones/get.spec.js @@ -24,6 +24,8 @@ describe('GET milestone', () => { details: {}, createdBy: 1, updatedBy: 1, + lastActivityAt: 1, + lastActivityUserId: '1', }, { type: 'generic', @@ -34,6 +36,8 @@ describe('GET milestone', () => { details: {}, createdBy: 2, updatedBy: 2, + lastActivityAt: 1, + lastActivityUserId: '1', deletedAt: '2018-05-15T00:00:00Z', }, ]) diff --git a/src/routes/milestones/list.spec.js b/src/routes/milestones/list.spec.js index 0240ee43..21c07336 100644 --- a/src/routes/milestones/list.spec.js +++ b/src/routes/milestones/list.spec.js @@ -94,6 +94,8 @@ describe('LIST timelines', () => { details: {}, createdBy: 1, updatedBy: 1, + lastActivityAt: 1, + lastActivityUserId: '1', }, { type: 'generic', @@ -104,6 +106,8 @@ describe('LIST timelines', () => { details: {}, createdBy: 2, updatedBy: 2, + lastActivityAt: 1, + lastActivityUserId: '1', deletedAt: '2018-05-15T00:00:00Z', }, ]) diff --git a/src/routes/milestones/update.js b/src/routes/milestones/update.js index 24230354..98a5cdf1 100644 --- a/src/routes/milestones/update.js +++ b/src/routes/milestones/update.js @@ -33,12 +33,14 @@ function updateComingMilestones(origMilestone, updMilestone) { let updMilestoneEndDate = moment.utc(updMSStartDate).add(updMilestone.duration - 1, 'days').toDate(); // if the milestone, in context, is completed, overrides the end date to the completion date updMilestoneEndDate = updMilestone.completionDate ? updMilestone.completionDate : updMilestoneEndDate; + let originalMilestones; return models.Milestone.findAll({ where: { timelineId: updMilestone.timelineId, order: { $gt: updMilestone.order }, }, }).then((affectedMilestones) => { + originalMilestones = affectedMilestones.map(am => _.omit(am.toJSON(), 'deletedAt', 'deletedBy')); const comingMilestones = _.sortBy(affectedMilestones, 'order'); // calculates the schedule start date for the next milestone let startDate = moment.utc(updMilestoneEndDate).add(1, 'days').toDate(); @@ -53,7 +55,7 @@ function updateComingMilestones(origMilestone, updMilestone) { } // Calculate the endDate, and update it if different - const endDate = moment.utc(startDate).add(milestone.duration - 1, 'days').toDate(); + const endDate = moment.utc(milestone.startDate).add(milestone.duration - 1, 'days').toDate(); if (!_.isEqual(milestone.endDate, endDate)) { milestone.endDate = endDate; milestone.updatedBy = updMilestone.updatedBy; @@ -67,17 +69,20 @@ function updateComingMilestones(origMilestone, updMilestone) { firstMilestoneFound = true; } - // Set the next startDate value to the next day after completionDate if present or the endDate - startDate = moment.utc(milestone.completionDate - ? milestone.completionDate - : milestone.endDate).add(1, 'days').toDate(); + // if milestone is not hidden, update the startDate for the next milestone, otherwise keep the same startDate for next milestone + if (!milestone.hidden) { + // Set the next startDate value to the next day after completionDate if present or the endDate + startDate = moment.utc(milestone.completionDate + ? milestone.completionDate + : milestone.endDate).add(1, 'days').toDate(); + } return milestone.save(); }); // Resolve promise with all original and updated milestones return Promise.all(promises).then(updatedMilestones => ({ - originalMilestones: affectedMilestones, - updatedMilestones, + originalMilestones, + updatedMilestones: updatedMilestones.map(um => _.omit(um.toJSON(), 'deletedAt', 'deletedBy')), })); }); } @@ -133,6 +138,7 @@ module.exports = [ }); const timeline = req.timeline; + const originalTimeline = _.omit(timeline.toJSON(), 'deletedAt', 'deletedBy'); let original; let updated; @@ -171,6 +177,7 @@ module.exports = [ // if status has changed to be completed, set the compeltionDate if not provided if (entityToUpdate.status === MILESTONE_STATUS.COMPLETED) { entityToUpdate.completionDate = entityToUpdate.completionDate ? entityToUpdate.completionDate : today; + entityToUpdate.duration = entityToUpdate.completionDate.diff(entityToUpdate.actualStartDate, 'days') + 1; } // if status has changed to be active, set the startDate to today if (entityToUpdate.status === MILESTONE_STATUS.ACTIVE) { @@ -195,6 +202,7 @@ module.exports = [ // if completionDate has changed if (!statusChanged && completionDateChanged) { + entityToUpdate.duration = entityToUpdate.completionDate.diff(entityToUpdate.actualStartDate, 'days') + 1; entityToUpdate.status = MILESTONE_STATUS.COMPLETED; } @@ -250,6 +258,7 @@ module.exports = [ const needToCascade = !_.isEqual(original.completionDate, updated.completionDate) // completion date changed || original.duration !== updated.duration // duration changed || original.actualStartDate !== updated.actualStartDate; // actual start date updated + req.log.debug('needToCascade', needToCascade); // Update dates of the other milestones only if cascade updates needed if (needToCascade) { return updateComingMilestones(original, updated) @@ -273,6 +282,13 @@ module.exports = [ original: om, updated: _.find(updatedMilestones, um => um.id === om.id), })); const cascadedUpdates = { milestones: cascadedMilestones }; + // if there is a change in timeline, add it to the cascadedUpdates + if (originalTimeline.updatedAt !== timeline.updatedAt) { + cascadedUpdates.timeline = { + original: originalTimeline, + updated: _.omit(timeline.toJSON(), 'deletedAt', 'deletedBy'), + }; + } // Send event to bus req.log.debug('Sending event to RabbitMQ bus for milestone %d', updated.id); req.app.services.pubsub.publish(EVENT.ROUTING_KEY.MILESTONE_UPDATED, @@ -284,6 +300,9 @@ module.exports = [ // because it will make 'version conflict' error in ES. // The order of the other milestones need to be updated in the MILESTONE_UPDATED event above + req.app.emit(EVENT.ROUTING_KEY.MILESTONE_UPDATED, + { req, original, updated, cascadedUpdates }); + // Write to response res.json(util.wrapResponse(req.id, updated)); return Promise.resolve(); diff --git a/src/routes/milestones/update.spec.js b/src/routes/milestones/update.spec.js index d6b77bb9..ad591287 100644 --- a/src/routes/milestones/update.spec.js +++ b/src/routes/milestones/update.spec.js @@ -1,14 +1,17 @@ +/* eslint-disable no-unused-expressions */ /** * Tests for get.js */ import chai from 'chai'; +import sinon from 'sinon'; import request from 'supertest'; import moment from 'moment'; import _ from 'lodash'; import models from '../../models'; import server from '../../app'; import testUtil from '../../tests/util'; -import { EVENT, MILESTONE_STATUS } from '../../constants'; +import busApi from '../../services/busApi'; +import { EVENT, MILESTONE_STATUS, BUS_API_EVENT } from '../../constants'; const should = chai.should(); @@ -26,6 +29,8 @@ describe('UPDATE Milestone', () => { details: {}, createdBy: 1, updatedBy: 1, + lastActivityAt: 1, + lastActivityUserId: '1', }, { type: 'generic', @@ -36,6 +41,8 @@ describe('UPDATE Milestone', () => { details: {}, createdBy: 2, updatedBy: 2, + lastActivityAt: 1, + lastActivityUserId: '1', deletedAt: '2018-05-15T00:00:00Z', }, ]) @@ -1077,5 +1084,151 @@ describe('UPDATE Milestone', () => { .expect(200) .end(done); }); + + describe('Bus api', () => { + let createEventSpy; + const sandbox = sinon.sandbox.create(); + + before((done) => { + // Wait for 500ms in order to wait for createEvent calls from previous tests to complete + testUtil.wait(done); + }); + + beforeEach(() => { + createEventSpy = sandbox.spy(busApi, 'createEvent'); + }); + + afterEach(() => { + sandbox.restore(); + }); + + it('should send message BUS_API_EVENT.TIMELINE_ADJUSTED when milestone duration updated', (done) => { + request(server) + .patch('/v4/timelines/1/milestones/1') + .set({ + Authorization: `Bearer ${testUtil.jwts.copilot}`, + }) + .send({ + param: { + duration: 1, + }, + }) + .expect(200) + .end((err) => { + if (err) { + done(err); + } else { + testUtil.wait(() => { + // 5 milestones in total, so it would trigger 5 events + // 4 MILESTONE_UPDATED events are for 4 non deleted milestones + // 1 TIMELINE_ADJUSTED event, because timeline's end date updated + createEventSpy.callCount.should.be.eql(5); + createEventSpy.firstCall.calledWith(BUS_API_EVENT.MILESTONE_UPDATED, sinon.match({ + projectId: 1, + projectName: 'test1', + projectUrl: 'https://local.topcoder-dev.com/projects/1', + userId: 40051332, + initiatorUserId: 40051332, + })).should.be.true; + createEventSpy.lastCall.calledWith(BUS_API_EVENT.TIMELINE_ADJUSTED); + done(); + }); + } + }); + }); + + it('should send message BUS_API_EVENT.MILESTONE_UPDATED when milestone status updated', (done) => { + request(server) + .patch('/v4/timelines/1/milestones/1') + .set({ + Authorization: `Bearer ${testUtil.jwts.copilot}`, + }) + .send({ + param: { + status: 'reviewed', + }, + }) + .expect(200) + .end((err) => { + if (err) { + done(err); + } else { + testUtil.wait(() => { + createEventSpy.calledOnce.should.be.true; + createEventSpy.firstCall.calledWith(BUS_API_EVENT.MILESTONE_UPDATED, sinon.match({ + projectId: 1, + projectName: 'test1', + projectUrl: 'https://local.topcoder-dev.com/projects/1', + userId: 40051332, + initiatorUserId: 40051332, + })).should.be.true; + done(); + }); + } + }); + }); + + it('should ONLY send message BUS_API_EVENT.MILESTONE_UPDATED when milestone order updated', (done) => { + request(server) + .patch('/v4/timelines/1/milestones/1') + .set({ + Authorization: `Bearer ${testUtil.jwts.copilot}`, + }) + .send({ + param: { + order: 2, + }, + }) + .expect(200) + .end((err) => { + if (err) { + done(err); + } else { + testUtil.wait(() => { + createEventSpy.calledOnce.should.be.true; + createEventSpy.firstCall.calledWith(BUS_API_EVENT.MILESTONE_UPDATED, sinon.match({ + projectId: 1, + projectName: 'test1', + projectUrl: 'https://local.topcoder-dev.com/projects/1', + userId: 40051332, + initiatorUserId: 40051332, + })).should.be.true; + done(); + }); + } + }); + }); + + it('should ONLY send message BUS_API_EVENT.MILESTONE_UPDATED when milestone plannedText updated', (done) => { + request(server) + .patch('/v4/timelines/1/milestones/1') + .set({ + Authorization: `Bearer ${testUtil.jwts.copilot}`, + }) + .send({ + param: { + plannedText: 'new text', + }, + }) + .expect(200) + .end((err) => { + if (err) { + done(err); + } else { + testUtil.wait(() => { + createEventSpy.calledOnce.should.be.true; + createEventSpy.firstCall.calledWith(BUS_API_EVENT.MILESTONE_UPDATED, sinon.match({ + projectId: 1, + projectName: 'test1', + projectUrl: 'https://local.topcoder-dev.com/projects/1', + userId: 40051332, + initiatorUserId: 40051332, + })).should.be.true; + done(); + }); + } + }); + }); + }); }); }); diff --git a/src/routes/phaseProducts/create.spec.js b/src/routes/phaseProducts/create.spec.js index e5fe049d..8f81a28b 100644 --- a/src/routes/phaseProducts/create.spec.js +++ b/src/routes/phaseProducts/create.spec.js @@ -1,10 +1,12 @@ /* eslint-disable no-unused-expressions */ import _ from 'lodash'; +import sinon from 'sinon'; import chai from 'chai'; import request from 'supertest'; import server from '../../app'; import models from '../../models'; import testUtil from '../../tests/util'; +import busApi from '../../services/busApi'; const should = chai.should(); @@ -35,7 +37,7 @@ describe('Phase Products', () => { lastName: 'lName', email: 'some@abc.com', }; - before((done) => { + beforeEach((done) => { // mocks testUtil.clearDb() .then(() => { @@ -48,6 +50,8 @@ describe('Phase Products', () => { details: {}, createdBy: 1, updatedBy: 1, + lastActivityAt: 1, + lastActivityUserId: '1', }).then((p) => { projectId = p.id; // create members @@ -90,7 +94,7 @@ describe('Phase Products', () => { }); }); - after((done) => { + afterEach((done) => { testUtil.clearDb(done); }); @@ -215,5 +219,44 @@ describe('Phase Products', () => { } }); }); + + describe('Bus api', () => { + let createEventSpy; + const sandbox = sinon.sandbox.create(); + + before((done) => { + // Wait for 500ms in order to wait for createEvent calls from previous tests to complete + testUtil.wait(done); + }); + + beforeEach(() => { + createEventSpy = sandbox.spy(busApi, 'createEvent'); + }); + + afterEach(() => { + sandbox.restore(); + }); + + it('should not send message BUS_API_EVENT.PROJECT_PLAN_UPDATED when product phase created', (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) => { + if (err) { + done(err); + } else { + testUtil.wait(() => { + createEventSpy.notCalled.should.be.true; + done(); + }); + } + }); + }); + }); }); }); diff --git a/src/routes/phaseProducts/delete.spec.js b/src/routes/phaseProducts/delete.spec.js index 5f958f4d..69942fa6 100644 --- a/src/routes/phaseProducts/delete.spec.js +++ b/src/routes/phaseProducts/delete.spec.js @@ -1,10 +1,12 @@ /* eslint-disable no-unused-expressions */ import _ from 'lodash'; +import sinon from 'sinon'; import request from 'supertest'; import chai from 'chai'; import server from '../../app'; import models from '../../models'; import testUtil from '../../tests/util'; +import busApi from '../../services/busApi'; const expectAfterDelete = (projectId, phaseId, id, err, next) => { if (err) throw err; @@ -63,7 +65,7 @@ describe('Phase Products', () => { lastName: 'lName', email: 'some@abc.com', }; - before((done) => { + beforeEach((done) => { // mocks testUtil.clearDb() .then(() => { @@ -76,6 +78,8 @@ describe('Phase Products', () => { details: {}, createdBy: 1, updatedBy: 1, + lastActivityAt: 1, + lastActivityUserId: '1', }).then((p) => { projectId = p.id; // create members @@ -123,7 +127,7 @@ describe('Phase Products', () => { }); }); - after((done) => { + afterEach((done) => { testUtil.clearDb(done); }); @@ -187,5 +191,42 @@ describe('Phase Products', () => { .expect(204) .end(err => expectAfterDelete(projectId, phaseId, productId, err, done)); }); + + describe('Bus api', () => { + let createEventSpy; + const sandbox = sinon.sandbox.create(); + + before((done) => { + // Wait for 500ms in order to wait for createEvent calls from previous tests to complete + testUtil.wait(done); + }); + + beforeEach(() => { + createEventSpy = sandbox.spy(busApi, 'createEvent'); + }); + + afterEach(() => { + sandbox.restore(); + }); + + it('should not send message BUS_API_EVENT.PROJECT_PLAN_UPDATED when product phase removed', (done) => { + request(server) + .delete(`/v4/projects/${projectId}/phases/${phaseId}/products/${productId}`) + .set({ + Authorization: `Bearer ${testUtil.jwts.copilot}`, + }) + .expect(204) + .end((err) => { + if (err) { + done(err); + } else { + testUtil.wait(() => { + createEventSpy.notCalled.should.be.true; + done(); + }); + } + }); + }); + }); }); }); diff --git a/src/routes/phaseProducts/get.spec.js b/src/routes/phaseProducts/get.spec.js index d2022f17..a71d8a7f 100644 --- a/src/routes/phaseProducts/get.spec.js +++ b/src/routes/phaseProducts/get.spec.js @@ -51,6 +51,8 @@ describe('Phase Products', () => { details: {}, createdBy: 1, updatedBy: 1, + lastActivityAt: 1, + lastActivityUserId: '1', }).then((p) => { projectId = p.id; // create members diff --git a/src/routes/phaseProducts/list.spec.js b/src/routes/phaseProducts/list.spec.js index 0b0b46db..8dcd08d2 100644 --- a/src/routes/phaseProducts/list.spec.js +++ b/src/routes/phaseProducts/list.spec.js @@ -57,6 +57,8 @@ describe('Phase Products', () => { details: {}, createdBy: 1, updatedBy: 1, + lastActivityAt: 1, + lastActivityUserId: '1', }).then((p) => { projectId = p.id; project = p.toJSON(); @@ -94,12 +96,13 @@ describe('Phase Products', () => { }).then((phase) => { phaseId = phase.id; _.assign(body, { phaseId, projectId }); - + project.lastActivityAt = 1; project.phases = [phase.toJSON()]; models.PhaseProduct.create(body).then((product) => { project.phases[0].products = [product.toJSON()]; - + // Overwrite lastActivityAt as otherwise ES fill not be able to parse it + project.lastActivityAt = 1; // Index to ES return server.services.es.index({ index: ES_PROJECT_INDEX, diff --git a/src/routes/phaseProducts/update.spec.js b/src/routes/phaseProducts/update.spec.js index 1f0d91ac..3c35871b 100644 --- a/src/routes/phaseProducts/update.spec.js +++ b/src/routes/phaseProducts/update.spec.js @@ -1,10 +1,13 @@ /* eslint-disable no-unused-expressions */ import _ from 'lodash'; import chai from 'chai'; +import sinon from 'sinon'; import request from 'supertest'; import server from '../../app'; import models from '../../models'; import testUtil from '../../tests/util'; +import busApi from '../../services/busApi'; +import { BUS_API_EVENT } from '../../constants'; const should = chai.should(); @@ -61,6 +64,8 @@ describe('Phase Products', () => { details: {}, createdBy: 1, updatedBy: 1, + lastActivityAt: 1, + lastActivityUserId: '1', }).then((p) => { projectId = p.id; // create members @@ -208,5 +213,177 @@ describe('Phase Products', () => { } }); }); + + describe('Bus api', () => { + let createEventSpy; + const sandbox = sinon.sandbox.create(); + + before((done) => { + // Wait for 500ms in order to wait for createEvent calls from previous tests to complete + testUtil.wait(done); + }); + + beforeEach(() => { + createEventSpy = sandbox.spy(busApi, 'createEvent'); + }); + + afterEach(() => { + sandbox.restore(); + }); + + it('should send message BUS_API_EVENT.PROJECT_PLAN_UPDATED when name updated', (done) => { + request(server) + .patch(`/v4/projects/${projectId}/phases/${phaseId}/products/${productId}`) + .set({ + Authorization: `Bearer ${testUtil.jwts.copilot}`, + }) + .send({ + param: { + name: 'new name', + }, + }) + .expect('Content-Type', /json/) + .expect(200) + .end((err) => { + if (err) { + done(err); + } else { + testUtil.wait(() => { + createEventSpy.calledOnce.should.be.true; + createEventSpy.firstCall.calledWith(BUS_API_EVENT.PROJECT_PLAN_UPDATED, sinon.match({ + projectId: 1, + projectName: 'test1', + projectUrl: 'https://local.topcoder-dev.com/projects/1', + userId: 40051332, + initiatorUserId: 40051332, + })).should.be.true; + done(); + }); + } + }); + }); + + it('should send message BUS_API_EVENT.PROJECT_PLAN_UPDATED when estimatedPrice updated', (done) => { + request(server) + .patch(`/v4/projects/${projectId}/phases/${phaseId}/products/${productId}`) + .set({ + Authorization: `Bearer ${testUtil.jwts.copilot}`, + }) + .send({ + param: { + estimatedPrice: 123, + }, + }) + .expect('Content-Type', /json/) + .expect(200) + .end((err) => { + if (err) { + done(err); + } else { + testUtil.wait(() => { + createEventSpy.calledOnce.should.be.true; + createEventSpy.firstCall.calledWith(BUS_API_EVENT.PROJECT_PLAN_UPDATED, sinon.match({ + projectId: 1, + projectName: 'test1', + projectUrl: 'https://local.topcoder-dev.com/projects/1', + userId: 40051332, + initiatorUserId: 40051332, + })).should.be.true; + done(); + }); + } + }); + }); + + it('should send message BUS_API_EVENT.PROJECT_PLAN_UPDATED when actualPrice updated', (done) => { + request(server) + .patch(`/v4/projects/${projectId}/phases/${phaseId}/products/${productId}`) + .set({ + Authorization: `Bearer ${testUtil.jwts.copilot}`, + }) + .send({ + param: { + actualPrice: 123, + }, + }) + .expect('Content-Type', /json/) + .expect(200) + .end((err) => { + if (err) { + done(err); + } else { + testUtil.wait(() => { + createEventSpy.calledOnce.should.be.true; + createEventSpy.firstCall.calledWith(BUS_API_EVENT.PROJECT_PLAN_UPDATED, sinon.match({ + projectId: 1, + projectName: 'test1', + projectUrl: 'https://local.topcoder-dev.com/projects/1', + userId: 40051332, + initiatorUserId: 40051332, + })).should.be.true; + done(); + }); + } + }); + }); + + it('should send message BUS_API_EVENT.PROJECT_PLAN_UPDATED when details updated', (done) => { + request(server) + .patch(`/v4/projects/${projectId}/phases/${phaseId}/products/${productId}`) + .set({ + Authorization: `Bearer ${testUtil.jwts.copilot}`, + }) + .send({ + param: { + details: 'something', + }, + }) + .expect('Content-Type', /json/) + .expect(200) + .end((err) => { + if (err) { + done(err); + } else { + testUtil.wait(() => { + createEventSpy.calledTwice.should.be.true; + createEventSpy.firstCall.calledWith(BUS_API_EVENT.PROJECT_PRODUCT_SPECIFICATION_MODIFIED); + createEventSpy.secondCall.calledWith(BUS_API_EVENT.PROJECT_PLAN_UPDATED, sinon.match({ + projectId: 1, + projectName: 'test1', + projectUrl: 'https://local.topcoder-dev.com/projects/1', + userId: 40051332, + initiatorUserId: 40051332, + })).should.be.true; + done(); + }); + } + }); + }); + + it('should not send message BUS_API_EVENT.PROJECT_PLAN_UPDATED when type updated', (done) => { + request(server) + .patch(`/v4/projects/${projectId}/phases/${phaseId}/products/${productId}`) + .set({ + Authorization: `Bearer ${testUtil.jwts.copilot}`, + }) + .send({ + param: { + type: 'another type', + }, + }) + .expect('Content-Type', /json/) + .expect(200) + .end((err) => { + if (err) { + done(err); + } else { + testUtil.wait(() => { + createEventSpy.notCalled.should.be.true; + done(); + }); + } + }); + }); + }); }); }); diff --git a/src/routes/phases/create.js b/src/routes/phases/create.js index 3c5c2f83..b4da22a1 100644 --- a/src/routes/phases/create.js +++ b/src/routes/phases/create.js @@ -1,6 +1,7 @@ import validate from 'express-validation'; import _ from 'lodash'; import Joi from 'joi'; +import Sequelize from 'sequelize'; import models from '../../models'; import util from '../../util'; @@ -21,6 +22,8 @@ const addProjectPhaseValidations = { spentBudget: Joi.number().min(0).optional(), progress: Joi.number().min(0).optional(), details: Joi.any().optional(), + order: Joi.number().integer().optional(), + productTemplateId: Joi.number().integer().positive().optional(), }).required(), }, }; @@ -46,44 +49,93 @@ module.exports = [ req.log.debug('Create Phase - Starting transaction'); return models.Project.findOne({ where: { id: projectId, deletedAt: { $eq: null } }, - }).then((existingProject) => { - if (!existingProject) { - const err = new Error(`active project not found for project id ${projectId}`); - err.status = 404; - throw err; - } - if (data.startDate !== null && data.endDate !== null && data.startDate > data.endDate) { - const err = new Error('startDate must not be after endDate.'); - err.status = 422; - throw err; - } - return models.ProjectPhase - .create(data) - .then((_newProjectPhase) => { - newProjectPhase = _.cloneDeep(_newProjectPhase); - req.log.debug('new project phase created (id# %d, name: %s)', - newProjectPhase.id, newProjectPhase.name); + }) + .then((existingProject) => { + if (!existingProject) { + const err = new Error(`active project not found for project id ${projectId}`); + err.status = 404; + throw err; + } + if (data.startDate !== null && data.endDate !== null && data.startDate > data.endDate) { + const err = new Error('startDate must not be after endDate.'); + err.status = 422; + throw err; + } + return models.ProjectPhase + .create(data) + .then((_newProjectPhase) => { + 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']); + newProjectPhase = newProjectPhase.get({ plain: true }); + newProjectPhase = _.omit(newProjectPhase, ['deletedAt', 'deletedBy', 'utm']); + }); + }) + .then(() => { + req.log.debug('re-ordering the other phases'); + + if (_.isNil(newProjectPhase.order)) { + return Promise.resolve(); + } + + // Increase the order of the other phases in the same project, + // which have `order` >= this phase order + return models.ProjectPhase.update({ order: Sequelize.literal('"order" + 1') }, { + where: { + projectId, + id: { $ne: newProjectPhase.id }, + order: { $gte: newProjectPhase.order }, + }, }); - }); - }) - .then(() => { - // 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 }); + }) + .then(() => { + if (_.isNil(data.productTemplateId)) { + return Promise.resolve(); + } - res.status(201).json(util.wrapResponse(req.id, newProjectPhase, 1, 201)); + // Get the product template + return models.ProductTemplate.findById(data.productTemplateId) + .then((productTemplate) => { + if (!productTemplate) { + const err = new Error(`Product template does not exist with id = ${data.productTemplateId}`); + err.status = 422; + throw err; + } + + // Create the phase product + return models.PhaseProduct.create({ + name: productTemplate.name, + templateId: data.productTemplateId, + type: productTemplate.productKey, + projectId, + phaseId: newProjectPhase.id, + createdBy: req.authUser.userId, + updatedBy: req.authUser.userId, + }) + .then((phaseProduct) => { + newProjectPhase.products = [ + _.omit(phaseProduct.toJSON(), ['deletedAt', 'deletedBy']), + ]; + }); + }); + }); }) - .catch((err) => { - util.handleError('Error creating project phase', err, req, next); - }); + .then(() => { + // Send events to buses + req.log.debug('Sending event to RabbitMQ bus for project phase %d', newProjectPhase.id); + req.app.services.pubsub.publish(EVENT.ROUTING_KEY.PROJECT_PHASE_ADDED, + newProjectPhase, + { correlationId: req.id }, + ); + req.log.debug('Sending event to Kafka bus for project phase %d', newProjectPhase.id); + req.app.emit(EVENT.ROUTING_KEY.PROJECT_PHASE_ADDED, { req, created: newProjectPhase }); + + res.status(201).json(util.wrapResponse(req.id, newProjectPhase, 1, 201)); + }) + .catch((err) => { + util.handleError('Error creating project phase', err, req, next); + }); }, ]; diff --git a/src/routes/phases/create.spec.js b/src/routes/phases/create.spec.js index ff5de77e..8bdcb5d8 100644 --- a/src/routes/phases/create.spec.js +++ b/src/routes/phases/create.spec.js @@ -1,10 +1,15 @@ /* eslint-disable no-unused-expressions */ import _ from 'lodash'; import chai from 'chai'; +import sinon from 'sinon'; import request from 'supertest'; import server from '../../app'; import models from '../../models'; import testUtil from '../../tests/util'; +import busApi from '../../services/busApi'; +import { + BUS_API_EVENT, +} from '../../constants'; const should = chai.should(); @@ -33,6 +38,7 @@ const validatePhase = (resJson, expectedPhase) => { describe('Project Phases', () => { let projectId; + let projectName; const memberUser = { handle: testUtil.getDecodedToken(testUtil.jwts.member).handle, userId: testUtil.getDecodedToken(testUtil.jwts.member).userId, @@ -47,41 +53,78 @@ describe('Project Phases', () => { lastName: 'lName', email: 'some@abc.com', }; + let productTemplateId; before((done) => { // mocks testUtil.clearDb() - .then(() => { - models.Project.create({ - type: 'generic', - billingAccountId: 1, - name: 'test1', - description: 'test project1', - status: 'draft', - details: {}, + .then(() => { + models.Project.create({ + type: 'generic', + billingAccountId: 1, + name: 'test1', + description: 'test project1', + status: 'draft', + details: {}, + createdBy: 1, + updatedBy: 1, + lastActivityAt: 1, + lastActivityUserId: '1', + }).then((p) => { + projectId = p.id; + projectName = p.name; + // create members + models.ProjectMember.bulkCreate([{ + id: 1, + userId: copilotUser.userId, + projectId, + role: 'copilot', + isPrimary: false, 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()); - }); + }, { + id: 2, + userId: memberUser.userId, + projectId, + role: 'customer', + isPrimary: true, + createdBy: 1, + updatedBy: 1, + }]); }); + }) + .then(() => + models.ProductTemplate.create({ + name: 'name 1', + productKey: 'productKey 1', + category: 'generic', + 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) => { + productTemplateId = template.id; + return Promise.resolve(); + }), + ) + .then(() => done()); }); after((done) => { @@ -91,24 +134,24 @@ describe('Project Phases', () => { 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); + .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); + .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) => { @@ -231,5 +274,122 @@ describe('Project Phases', () => { } }); }); + + it('should return 201 if payload has order specified', (done) => { + request(server) + .post(`/v4/projects/${projectId}/phases/`) + .set({ + Authorization: `Bearer ${testUtil.jwts.copilot}`, + }) + .send({ param: _.assign({ order: 1 }, body) }) + .expect('Content-Type', /json/) + .expect(201) + .end((err, res) => { + if (err) { + done(err); + } else { + const resJson = res.body.result.content; + validatePhase(resJson, body); + resJson.order.should.be.eql(1); + + const firstPhaseId = resJson.id; + + // Create second phase + request(server) + .post(`/v4/projects/${projectId}/phases/`) + .set({ + Authorization: `Bearer ${testUtil.jwts.copilot}`, + }) + .send({ param: _.assign({ order: 1 }, body) }) + .expect('Content-Type', /json/) + .expect(201) + .end((err2, res2) => { + const resJson2 = res2.body.result.content; + validatePhase(resJson2, body); + resJson2.order.should.be.eql(1); + + models.ProjectPhase.findOne({ where: { id: firstPhaseId } }) + .then((firstPhase) => { + firstPhase.order.should.be.eql(2); + done(); + }); + }); + } + }); + }); + + it('should return 201 if payload has productTemplateId specified', (done) => { + request(server) + .post(`/v4/projects/${projectId}/phases/`) + .set({ + Authorization: `Bearer ${testUtil.jwts.copilot}`, + }) + .send({ param: _.assign({ productTemplateId }, body) }) + .expect('Content-Type', /json/) + .expect(201) + .end((err, res) => { + if (err) { + done(err); + } else { + const resJson = res.body.result.content; + validatePhase(resJson, body); + resJson.products.should.have.length(1); + + resJson.products[0].name.should.be.eql('name 1'); + resJson.products[0].templateId.should.be.eql(1); + resJson.products[0].type.should.be.eql('productKey 1'); + resJson.products[0].projectId.should.be.eql(1); + resJson.products[0].phaseId.should.be.eql(resJson.id); + + done(); + } + }); + }); + + describe('Bus api', () => { + let createEventSpy; + const sandbox = sinon.sandbox.create(); + + before((done) => { + // Wait for 500ms in order to wait for createEvent calls from previous tests to complete + testUtil.wait(done); + }); + + beforeEach(() => { + createEventSpy = sandbox.spy(busApi, 'createEvent'); + }); + + afterEach(() => { + sandbox.restore(); + }); + + it('should send message BUS_API_EVENT.PROJECT_PLAN_UPDATED when phase added', (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) => { + if (err) { + done(err); + } else { + testUtil.wait(() => { + createEventSpy.calledOnce.should.be.true; + createEventSpy.calledWith(BUS_API_EVENT.PROJECT_PLAN_UPDATED, sinon.match({ + projectId, + projectName, + projectUrl: `https://local.topcoder-dev.com/projects/${projectId}`, + userId: 40051332, + initiatorUserId: 40051332, + })).should.be.true; + done(); + }); + } + }); + }); + }); }); }); diff --git a/src/routes/phases/delete.spec.js b/src/routes/phases/delete.spec.js index 1b3ace93..78453f39 100644 --- a/src/routes/phases/delete.spec.js +++ b/src/routes/phases/delete.spec.js @@ -1,10 +1,16 @@ /* eslint-disable no-unused-expressions */ import _ from 'lodash'; import request from 'supertest'; +import sinon from 'sinon'; import chai from 'chai'; import server from '../../app'; import models from '../../models'; import testUtil from '../../tests/util'; +import busApi from '../../services/busApi'; + +import { + BUS_API_EVENT, +} from '../../constants'; const expectAfterDelete = (projectId, id, err, next) => { if (err) throw err; @@ -48,6 +54,7 @@ const body = { describe('Project Phases', () => { let projectId; + let projectName; let phaseId; const memberUser = { handle: testUtil.getDecodedToken(testUtil.jwts.member).handle, @@ -63,7 +70,7 @@ describe('Project Phases', () => { lastName: 'lName', email: 'some@abc.com', }; - before((done) => { + beforeEach((done) => { // mocks testUtil.clearDb() .then(() => { @@ -76,8 +83,11 @@ describe('Project Phases', () => { details: {}, createdBy: 1, updatedBy: 1, + lastActivityAt: 1, + lastActivityUserId: '1', }).then((p) => { projectId = p.id; + projectName = p.name; // create members models.ProjectMember.bulkCreate([{ id: 1, @@ -106,7 +116,7 @@ describe('Project Phases', () => { }); }); - after((done) => { + afterEach((done) => { testUtil.clearDb(done); }); @@ -159,5 +169,49 @@ describe('Project Phases', () => { }) .end(err => expectAfterDelete(projectId, phaseId, err, done)); }); + + describe('Bus api', () => { + let createEventSpy; + const sandbox = sinon.sandbox.create(); + + before((done) => { + // Wait for 500ms in order to wait for createEvent calls from previous tests to complete + testUtil.wait(done); + }); + + beforeEach(() => { + createEventSpy = sandbox.spy(busApi, 'createEvent'); + }); + + afterEach(() => { + sandbox.restore(); + }); + + it('should send message BUS_API_EVENT.PROJECT_PLAN_UPDATED when phase removed', (done) => { + request(server) + .delete(`/v4/projects/${projectId}/phases/${phaseId}`) + .set({ + Authorization: `Bearer ${testUtil.jwts.copilot}`, + }) + .expect(204) + .end((err) => { + if (err) { + done(err); + } else { + testUtil.wait(() => { + createEventSpy.calledOnce.should.be.true; + createEventSpy.calledWith(BUS_API_EVENT.PROJECT_PLAN_UPDATED, sinon.match({ + projectId, + projectName, + projectUrl: `https://local.topcoder-dev.com/projects/${projectId}`, + userId: 40051332, + initiatorUserId: 40051332, + })).should.be.true; + done(); + }); + } + }); + }); + }); }); }); diff --git a/src/routes/phases/get.spec.js b/src/routes/phases/get.spec.js index 1dab6542..f6cde663 100644 --- a/src/routes/phases/get.spec.js +++ b/src/routes/phases/get.spec.js @@ -52,6 +52,8 @@ describe('Project Phases', () => { details: {}, createdBy: 1, updatedBy: 1, + lastActivityAt: 1, + lastActivityUserId: '1', }).then((p) => { projectId = p.id; // create members diff --git a/src/routes/phases/list.js b/src/routes/phases/list.js index 55d5ba63..e0084184 100644 --- a/src/routes/phases/list.js +++ b/src/routes/phases/list.js @@ -30,6 +30,7 @@ module.exports = [ 'startDate asc', 'startDate desc', 'endDate asc', 'endDate desc', 'status asc', 'status desc', + 'order asc', 'order desc', ]; if (sort && _.indexOf(sortableProps, sort) < 0) { return util.handleError('Invalid sort criteria', null, req, next); diff --git a/src/routes/phases/list.spec.js b/src/routes/phases/list.spec.js index 86b6d7e9..43a1d14f 100644 --- a/src/routes/phases/list.spec.js +++ b/src/routes/phases/list.spec.js @@ -58,6 +58,8 @@ describe('Project Phases', () => { details: {}, createdBy: 1, updatedBy: 1, + lastActivityAt: 1, + lastActivityUserId: '1', }).then((p) => { projectId = p.id; project = p.toJSON(); @@ -83,6 +85,8 @@ describe('Project Phases', () => { return models.ProjectPhase.create(body); }).then((phase) => { // Index to ES + // Overwrite lastActivityAt as otherwise ES fill not be able to parse it + project.lastActivityAt = 1; project.phases = [phase]; return server.services.es.index({ index: ES_PROJECT_INDEX, diff --git a/src/routes/phases/update.js b/src/routes/phases/update.js index 389157f2..56cb287c 100644 --- a/src/routes/phases/update.js +++ b/src/routes/phases/update.js @@ -2,6 +2,7 @@ import validate from 'express-validation'; import _ from 'lodash'; import Joi from 'joi'; +import Sequelize from 'sequelize'; import { middleware as tcMiddleware } from 'tc-core-library-js'; import models from '../../models'; import util from '../../util'; @@ -22,6 +23,7 @@ const updateProjectPhaseValidation = { spentBudget: Joi.number().min(0).optional(), progress: Joi.number().min(0).optional(), details: Joi.any().optional(), + order: Joi.number().integer().optional(), }).required(), }, }; @@ -41,6 +43,7 @@ module.exports = [ updatedProps.updatedBy = req.authUser.userId; let previousValue; + let updated; models.sequelize.transaction(() => models.ProjectPhase.findOne({ where: { @@ -82,21 +85,83 @@ module.exports = [ 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)); + })) + .then((updatedPhase) => { + updated = updatedPhase; + + // Ignore re-ordering if there's no order specified for this phase + if (_.isNil(updated.order)) { + return Promise.resolve(); + } + + // Update order of the other phases only if the order was changed + if (previousValue.order === updated.order) { + return Promise.resolve(); + } + + return models.ProjectPhase.count({ + where: { + projectId, + id: { $ne: updated.id }, + order: updated.order, + }, + }) + .then((count) => { + if (count === 0) { + return Promise.resolve(); + } + + // Increase the order from M to K: if there is an item with order K, + // orders from M+1 to K should be made M to K-1 + if (!_.isNil(previousValue.order) && previousValue.order < updated.order) { + return models.ProjectPhase.update({ order: Sequelize.literal('"order" - 1') }, { + where: { + projectId, + id: { $ne: updated.id }, + order: { $between: [previousValue.order + 1, updated.order] }, + }, + }); + } + + // Decrease the order from M to K: if there is an item with order K, + // orders from K to M-1 should be made K+1 to M + return models.ProjectPhase.update({ order: Sequelize.literal('"order" + 1') }, { + where: { + projectId, + id: { $ne: updated.id }, + order: { + $between: [ + updated.order, + (previousValue.order ? previousValue.order : Number.MAX_SAFE_INTEGER) - 1, + ], + }, + }, + }); + }); + }) + .then(() => + // To simpify the logic, reload the phases from DB and send to the message queue + models.ProjectPhase.findAll({ + where: { + projectId, + }, + include: [{ model: models.PhaseProduct, as: 'products' }], + })), + ) + .then((allPhases) => { + req.log.debug('updated project phase', JSON.stringify(updated, null, 2)); + + // emit original and updated project phase information + req.app.services.pubsub.publish( + EVENT.ROUTING_KEY.PROJECT_PHASE_UPDATED, + { original: previousValue, updated, allPhases }, + { correlationId: req.id }, + ); + req.app.emit(EVENT.ROUTING_KEY.PROJECT_PHASE_UPDATED, + { req, original: previousValue, updated: _.clone(updated.get({ plain: true })) }); + + 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 index b154d339..85e8e44d 100644 --- a/src/routes/phases/update.spec.js +++ b/src/routes/phases/update.spec.js @@ -1,10 +1,16 @@ /* eslint-disable no-unused-expressions */ import _ from 'lodash'; +import sinon from 'sinon'; import chai from 'chai'; import request from 'supertest'; import server from '../../app'; import models from '../../models'; import testUtil from '../../tests/util'; +import busApi from '../../services/busApi'; + +import { + BUS_API_EVENT, +} from '../../constants'; const should = chai.should(); @@ -45,7 +51,9 @@ const validatePhase = (resJson, expectedPhase) => { describe('Project Phases', () => { let projectId; + let projectName; let phaseId; + let phaseId2; const memberUser = { handle: testUtil.getDecodedToken(testUtil.jwts.member).handle, userId: testUtil.getDecodedToken(testUtil.jwts.member).userId, @@ -63,44 +71,54 @@ describe('Project Phases', () => { before((done) => { // mocks testUtil.clearDb() - .then(() => { - models.Project.create({ - type: 'generic', - billingAccountId: 1, - name: 'test1', - description: 'test project1', - status: 'draft', - details: {}, + .then(() => { + models.Project.create({ + type: 'generic', + billingAccountId: 1, + name: 'test1', + description: 'test project1', + status: 'draft', + details: {}, + createdBy: 1, + updatedBy: 1, + lastActivityAt: 1, + lastActivityUserId: '1', + }).then((p) => { + projectId = p.id; + projectName = p.name; + // 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((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; + }]).then(() => { + _.assign(body, { projectId }); + const phases = [ + body, + _.assign({ order: 1 }, body), + ]; + models.ProjectPhase.bulkCreate(phases, { returning: true }) + .then((createdPhases) => { + phaseId = createdPhases[0].id; + phaseId2 = createdPhases[1].id; + done(); }); - }); }); }); + }); }); after((done) => { @@ -226,5 +244,299 @@ describe('Project Phases', () => { } }); }); + + it('should return updated phase if the order is specified', (done) => { + request(server) + .patch(`/v4/projects/${projectId}/phases/${phaseId}`) + .set({ + Authorization: `Bearer ${testUtil.jwts.copilot}`, + }) + .send({ param: _.assign({ order: 1 }, updateBody) }) + .expect('Content-Type', /json/) + .expect(200) + .end((err, res) => { + if (err) { + done(err); + } else { + const resJson = res.body.result.content; + validatePhase(resJson, updateBody); + resJson.order.should.be.eql(1); + + // Check the order of the other phase + models.ProjectPhase.findOne({ where: { id: phaseId2 } }) + .then((phase2) => { + phase2.order.should.be.eql(2); + done(); + }); + } + }); + }); + + describe('Bus api', () => { + let createEventSpy; + const sandbox = sinon.sandbox.create(); + + before((done) => { + // Wait for 500ms in order to wait for createEvent calls from previous tests to complete + testUtil.wait(done); + }); + + beforeEach(() => { + createEventSpy = sandbox.spy(busApi, 'createEvent'); + }); + + afterEach(() => { + sandbox.restore(); + }); + + it('should NOT send message BUS_API_EVENT.PROJECT_PLAN_UPDATED when spentBudget updated', (done) => { + request(server) + .patch(`/v4/projects/${projectId}/phases/${phaseId}`) + .set({ + Authorization: `Bearer ${testUtil.jwts.copilot}`, + }) + .send({ + param: { + spentBudget: 123, + }, + }) + .expect('Content-Type', /json/) + .expect(200) + .end((err) => { + if (err) { + done(err); + } else { + testUtil.wait(() => { + createEventSpy.calledOnce.should.be.true; + + createEventSpy.firstCall.calledWith(BUS_API_EVENT.PROJECT_PHASE_UPDATE_PAYMENT); + done(); + }); + } + }); + }); + + it('should NOT send message BUS_API_EVENT.PROJECT_PLAN_UPDATED when progress updated', (done) => { + request(server) + .patch(`/v4/projects/${projectId}/phases/${phaseId}`) + .set({ + Authorization: `Bearer ${testUtil.jwts.copilot}`, + }) + .send({ + param: { + progress: 50, + }, + }) + .expect('Content-Type', /json/) + .expect(200) + .end((err) => { + if (err) { + done(err); + } else { + testUtil.wait(() => { + createEventSpy.callCount.should.be.eql(2); + createEventSpy.firstCall.calledWith(BUS_API_EVENT.PROJECT_PHASE_UPDATE_PROGRESS); + createEventSpy.secondCall.calledWith(BUS_API_EVENT.PROJECT_PROGRESS_MODIFIED); + done(); + }); + } + }); + }); + + it('should NOT send message BUS_API_EVENT.PROJECT_PLAN_UPDATED when details updated', (done) => { + request(server) + .patch(`/v4/projects/${projectId}/phases/${phaseId}`) + .set({ + Authorization: `Bearer ${testUtil.jwts.copilot}`, + }) + .send({ + param: { + details: { + text: 'something', + }, + }, + }) + .expect('Content-Type', /json/) + .expect(200) + .end((err) => { + if (err) { + done(err); + } else { + testUtil.wait(() => { + createEventSpy.calledOnce.should.be.true; + createEventSpy.firstCall.calledWith(BUS_API_EVENT.PROJECT_PHASE_UPDATE_SCOPE); + done(); + }); + } + }); + }); + + it('should NOT send message BUS_API_EVENT.PROJECT_PLAN_UPDATED when status updated (completed)', (done) => { + request(server) + .patch(`/v4/projects/${projectId}/phases/${phaseId}`) + .set({ + Authorization: `Bearer ${testUtil.jwts.copilot}`, + }) + .send({ + param: { + status: 'completed', + }, + }) + .expect('Content-Type', /json/) + .expect(200) + .end((err) => { + if (err) { + done(err); + } else { + testUtil.wait(() => { + createEventSpy.calledOnce.should.be.true; + createEventSpy.firstCall.calledWith(BUS_API_EVENT.PROJECT_PHASE_TRANSITION_COMPLETED); + done(); + }); + } + }); + }); + + it('should NOT send message BUS_API_EVENT.PROJECT_PLAN_UPDATED when budget updated', (done) => { + request(server) + .patch(`/v4/projects/${projectId}/phases/${phaseId}`) + .set({ + Authorization: `Bearer ${testUtil.jwts.copilot}`, + }) + .send({ + param: { + budget: 123, + }, + }) + .expect('Content-Type', /json/) + .expect(200) + .end((err) => { + if (err) { + done(err); + } else { + testUtil.wait(() => { + createEventSpy.notCalled.should.be.true; + done(); + }); + } + }); + }); + + it('should send message BUS_API_EVENT.PROJECT_PLAN_UPDATED when startDate updated', (done) => { + request(server) + .patch(`/v4/projects/${projectId}/phases/${phaseId}`) + .set({ + Authorization: `Bearer ${testUtil.jwts.copilot}`, + }) + .send({ + param: { + startDate: 123, + }, + }) + .expect('Content-Type', /json/) + .expect(200) + .end((err) => { + if (err) { + done(err); + } else { + testUtil.wait(() => { + createEventSpy.calledOnce.should.be.true; + createEventSpy.calledWith(BUS_API_EVENT.PROJECT_PLAN_UPDATED, sinon.match({ + projectId, + projectName, + projectUrl: `https://local.topcoder-dev.com/projects/${projectId}`, + // originalPhase: sinon.match(originalPhase), + // updatedPhase: sinon.match(updatedPhase), + userId: 40051332, + initiatorUserId: 40051332, + })).should.be.true; + done(); + }); + } + }); + }); + + it('should send message BUS_API_EVENT.PROJECT_PLAN_UPDATED when duration updated', (done) => { + request(server) + .patch(`/v4/projects/${projectId}/phases/${phaseId}`) + .set({ + Authorization: `Bearer ${testUtil.jwts.copilot}`, + }) + .send({ + param: { + duration: 100, + }, + }) + .expect('Content-Type', /json/) + .expect(200) + .end((err) => { + if (err) { + done(err); + } else { + testUtil.wait(() => { + createEventSpy.calledOnce.should.be.true; + createEventSpy.calledWith(BUS_API_EVENT.PROJECT_PLAN_UPDATED, sinon.match({ + projectId, + projectName, + projectUrl: `https://local.topcoder-dev.com/projects/${projectId}`, + userId: 40051332, + initiatorUserId: 40051332, + })).should.be.true; + done(); + }); + } + }); + }); + + it('should not send message BUS_API_EVENT.PROJECT_PLAN_UPDATED when order updated', (done) => { + request(server) + .patch(`/v4/projects/${projectId}/phases/${phaseId}`) + .set({ + Authorization: `Bearer ${testUtil.jwts.copilot}`, + }) + .send({ + param: { + order: 100, + }, + }) + .expect('Content-Type', /json/) + .expect(200) + .end((err) => { + if (err) { + done(err); + } else { + testUtil.wait(() => { + createEventSpy.notCalled.should.be.true; + done(); + }); + } + }); + }); + + it('should not send message BUS_API_EVENT.PROJECT_PLAN_UPDATED when endDate updated', (done) => { + request(server) + .patch(`/v4/projects/${projectId}/phases/${phaseId}`) + .set({ + Authorization: `Bearer ${testUtil.jwts.copilot}`, + }) + .send({ + param: { + endDate: new Date(), + }, + }) + .expect('Content-Type', /json/) + .expect(200) + .end((err) => { + if (err) { + done(err); + } else { + testUtil.wait(() => { + createEventSpy.notCalled.should.be.true; + done(); + }); + } + }); + }); + }); }); }); diff --git a/src/routes/productCategories/create.spec.js b/src/routes/productCategories/create.spec.js index 7b8f0089..c345eda3 100644 --- a/src/routes/productCategories/create.spec.js +++ b/src/routes/productCategories/create.spec.js @@ -28,7 +28,7 @@ describe('CREATE product category', () => { ); after(testUtil.clearDb); - describe('POST /productCategories', () => { + describe('POST /projects/metadata/productCategories', () => { const body = { param: { key: 'app_dev', @@ -44,14 +44,14 @@ describe('CREATE product category', () => { it('should return 403 if user is not authenticated', (done) => { request(server) - .post('/v4/productCategories') + .post('/v4/projects/metadata/productCategories') .send(body) .expect(403, done); }); it('should return 403 for member', (done) => { request(server) - .post('/v4/productCategories') + .post('/v4/projects/metadata/productCategories') .set({ Authorization: `Bearer ${testUtil.jwts.member}`, }) @@ -61,7 +61,7 @@ describe('CREATE product category', () => { it('should return 403 for copilot', (done) => { request(server) - .post('/v4/productCategories') + .post('/v4/projects/metadata/productCategories') .set({ Authorization: `Bearer ${testUtil.jwts.copilot}`, }) @@ -71,7 +71,7 @@ describe('CREATE product category', () => { it('should return 403 for manager', (done) => { request(server) - .post('/v4/productCategories') + .post('/v4/projects/metadata/productCategories') .set({ Authorization: `Bearer ${testUtil.jwts.manager}`, }) @@ -84,7 +84,7 @@ describe('CREATE product category', () => { delete invalidBody.param.key; request(server) - .post('/v4/productCategories') + .post('/v4/projects/metadata/productCategories') .set({ Authorization: `Bearer ${testUtil.jwts.admin}`, }) @@ -98,7 +98,7 @@ describe('CREATE product category', () => { delete invalidBody.param.displayName; request(server) - .post('/v4/productCategories') + .post('/v4/projects/metadata/productCategories') .set({ Authorization: `Bearer ${testUtil.jwts.admin}`, }) @@ -112,7 +112,7 @@ describe('CREATE product category', () => { delete invalidBody.param.icon; request(server) - .post('/v4/productCategories') + .post('/v4/projects/metadata/productCategories') .set({ Authorization: `Bearer ${testUtil.jwts.admin}`, }) @@ -126,7 +126,7 @@ describe('CREATE product category', () => { delete invalidBody.param.question; request(server) - .post('/v4/productCategories') + .post('/v4/projects/metadata/productCategories') .set({ Authorization: `Bearer ${testUtil.jwts.admin}`, }) @@ -140,7 +140,7 @@ describe('CREATE product category', () => { delete invalidBody.param.info; request(server) - .post('/v4/productCategories') + .post('/v4/projects/metadata/productCategories') .set({ Authorization: `Bearer ${testUtil.jwts.admin}`, }) @@ -154,7 +154,7 @@ describe('CREATE product category', () => { invalidBody.param.key = 'key1'; request(server) - .post('/v4/productCategories') + .post('/v4/projects/metadata/productCategories') .set({ Authorization: `Bearer ${testUtil.jwts.admin}`, }) @@ -165,7 +165,7 @@ describe('CREATE product category', () => { it('should return 201 for admin', (done) => { request(server) - .post('/v4/productCategories') + .post('/v4/projects/metadata/productCategories') .set({ Authorization: `Bearer ${testUtil.jwts.admin}`, }) @@ -196,7 +196,7 @@ describe('CREATE product category', () => { it('should return 201 for connect admin', (done) => { request(server) - .post('/v4/productCategories') + .post('/v4/projects/metadata/productCategories') .set({ Authorization: `Bearer ${testUtil.jwts.connectAdmin}`, }) diff --git a/src/routes/productCategories/delete.spec.js b/src/routes/productCategories/delete.spec.js index dc33a92f..40f8baa5 100644 --- a/src/routes/productCategories/delete.spec.js +++ b/src/routes/productCategories/delete.spec.js @@ -26,7 +26,7 @@ const expectAfterDelete = (key, err, next) => { chai.assert.isNotNull(res.deletedBy); request(server) - .get(`/v4/productCategories/${key}`) + .get(`/v4/projects/metadata/productCategories/${key}`) .set({ Authorization: `Bearer ${testUtil.jwts.admin}`, }) @@ -52,16 +52,16 @@ describe('DELETE product category', () => { ); after(testUtil.clearDb); - describe('DELETE /productCategories/{key}', () => { + describe('DELETE /projects/metadata/productCategories/{key}', () => { it('should return 403 if user is not authenticated', (done) => { request(server) - .delete(`/v4/productCategories/${key}`) + .delete(`/v4/projects/metadata/productCategories/${key}`) .expect(403, done); }); it('should return 403 for member', (done) => { request(server) - .delete(`/v4/productCategories/${key}`) + .delete(`/v4/projects/metadata/productCategories/${key}`) .set({ Authorization: `Bearer ${testUtil.jwts.member}`, }) @@ -70,7 +70,7 @@ describe('DELETE product category', () => { it('should return 403 for copilot', (done) => { request(server) - .delete(`/v4/productCategories/${key}`) + .delete(`/v4/projects/metadata/productCategories/${key}`) .set({ Authorization: `Bearer ${testUtil.jwts.copilot}`, }) @@ -79,7 +79,7 @@ describe('DELETE product category', () => { it('should return 403 for manager', (done) => { request(server) - .delete(`/v4/productCategories/${key}`) + .delete(`/v4/projects/metadata/productCategories/${key}`) .set({ Authorization: `Bearer ${testUtil.jwts.manager}`, }) @@ -88,7 +88,7 @@ describe('DELETE product category', () => { it('should return 404 for non-existed product category', (done) => { request(server) - .delete('/v4/productCategories/not_existed') + .delete('/v4/projects/metadata/productCategories/not_existed') .set({ Authorization: `Bearer ${testUtil.jwts.admin}`, }) @@ -99,7 +99,7 @@ describe('DELETE product category', () => { models.ProductCategory.destroy({ where: { key } }) .then(() => { request(server) - .delete(`/v4/productCategories/${key}`) + .delete(`/v4/projects/metadata/productCategories/${key}`) .set({ Authorization: `Bearer ${testUtil.jwts.admin}`, }) @@ -109,7 +109,7 @@ describe('DELETE product category', () => { it('should return 204, for admin, if the product category was successfully removed', (done) => { request(server) - .delete(`/v4/productCategories/${key}`) + .delete(`/v4/projects/metadata/productCategories/${key}`) .set({ Authorization: `Bearer ${testUtil.jwts.admin}`, }) @@ -119,7 +119,7 @@ describe('DELETE product category', () => { it('should return 204, for connect admin, if the product category was successfully removed', (done) => { request(server) - .delete(`/v4/productCategories/${key}`) + .delete(`/v4/projects/metadata/productCategories/${key}`) .set({ Authorization: `Bearer ${testUtil.jwts.connectAdmin}`, }) diff --git a/src/routes/productCategories/get.spec.js b/src/routes/productCategories/get.spec.js index 9d06dbf4..78ba07ce 100644 --- a/src/routes/productCategories/get.spec.js +++ b/src/routes/productCategories/get.spec.js @@ -32,10 +32,10 @@ describe('GET product category', () => { ); after(testUtil.clearDb); - describe('GET /productCategories/{key}', () => { + describe('GET /projects/metadata/productCategories/{key}', () => { it('should return 404 for non-existed product category', (done) => { request(server) - .get('/v4/productCategories/1234') + .get('/v4/projects/metadata/productCategories/1234') .set({ Authorization: `Bearer ${testUtil.jwts.admin}`, }) @@ -46,7 +46,7 @@ describe('GET product category', () => { models.ProductCategory.destroy({ where: { key } }) .then(() => { request(server) - .get(`/v4/productCategories/${key}`) + .get(`/v4/projects/metadata/productCategories/${key}`) .set({ Authorization: `Bearer ${testUtil.jwts.admin}`, }) @@ -56,7 +56,7 @@ describe('GET product category', () => { it('should return 200 for admin', (done) => { request(server) - .get(`/v4/productCategories/${key}`) + .get(`/v4/projects/metadata/productCategories/${key}`) .set({ Authorization: `Bearer ${testUtil.jwts.admin}`, }) @@ -84,13 +84,13 @@ describe('GET product category', () => { it('should return 200 even if user is not authenticated', (done) => { request(server) - .get(`/v4/productCategories/${key}`) + .get(`/v4/projects/metadata/productCategories/${key}`) .expect(200, done); }); it('should return 200 for connect admin', (done) => { request(server) - .get(`/v4/productCategories/${key}`) + .get(`/v4/projects/metadata/productCategories/${key}`) .set({ Authorization: `Bearer ${testUtil.jwts.connectAdmin}`, }) @@ -100,7 +100,7 @@ describe('GET product category', () => { it('should return 200 for connect manager', (done) => { request(server) - .get(`/v4/productCategories/${key}`) + .get(`/v4/projects/metadata/productCategories/${key}`) .set({ Authorization: `Bearer ${testUtil.jwts.manager}`, }) @@ -110,7 +110,7 @@ describe('GET product category', () => { it('should return 200 for member', (done) => { request(server) - .get(`/v4/productCategories/${key}`) + .get(`/v4/projects/metadata/productCategories/${key}`) .set({ Authorization: `Bearer ${testUtil.jwts.member}`, }) @@ -119,7 +119,7 @@ describe('GET product category', () => { it('should return 200 for copilot', (done) => { request(server) - .get(`/v4/productCategories/${key}`) + .get(`/v4/projects/metadata/productCategories/${key}`) .set({ Authorization: `Bearer ${testUtil.jwts.copilot}`, }) diff --git a/src/routes/productCategories/list.spec.js b/src/routes/productCategories/list.spec.js index e0e56ec7..d055f7df 100644 --- a/src/routes/productCategories/list.spec.js +++ b/src/routes/productCategories/list.spec.js @@ -45,10 +45,10 @@ describe('LIST product categories', () => { ); after(testUtil.clearDb); - describe('GET /productCategories', () => { + describe('GET /projects/metadata/productCategories', () => { it('should return 200 for admin', (done) => { request(server) - .get('/v4/productCategories') + .get('/v4/projects/metadata/productCategories') .set({ Authorization: `Bearer ${testUtil.jwts.admin}`, }) @@ -79,13 +79,13 @@ describe('LIST product categories', () => { it('should return 200 even if user is not authenticated', (done) => { request(server) - .get('/v4/productCategories') + .get('/v4/projects/metadata/productCategories') .expect(200, done); }); it('should return 200 for connect admin', (done) => { request(server) - .get('/v4/productCategories') + .get('/v4/projects/metadata/productCategories') .set({ Authorization: `Bearer ${testUtil.jwts.connectAdmin}`, }) @@ -95,7 +95,7 @@ describe('LIST product categories', () => { it('should return 200 for connect manager', (done) => { request(server) - .get('/v4/productCategories') + .get('/v4/projects/metadata/productCategories') .set({ Authorization: `Bearer ${testUtil.jwts.manager}`, }) @@ -105,7 +105,7 @@ describe('LIST product categories', () => { it('should return 200 for member', (done) => { request(server) - .get('/v4/productCategories') + .get('/v4/projects/metadata/productCategories') .set({ Authorization: `Bearer ${testUtil.jwts.member}`, }) @@ -114,7 +114,7 @@ describe('LIST product categories', () => { it('should return 200 for copilot', (done) => { request(server) - .get('/v4/productCategories') + .get('/v4/projects/metadata/productCategories') .set({ Authorization: `Bearer ${testUtil.jwts.copilot}`, }) diff --git a/src/routes/productCategories/update.spec.js b/src/routes/productCategories/update.spec.js index 7b877553..ad9a43b5 100644 --- a/src/routes/productCategories/update.spec.js +++ b/src/routes/productCategories/update.spec.js @@ -32,7 +32,7 @@ describe('UPDATE product category', () => { ); after(testUtil.clearDb); - describe('PATCH /productCategories/{key}', () => { + describe('PATCH /projects/metadata/productCategories/{key}', () => { const body = { param: { displayName: 'displayName 1 - update', @@ -47,14 +47,14 @@ describe('UPDATE product category', () => { it('should return 403 if user is not authenticated', (done) => { request(server) - .patch(`/v4/productCategories/${key}`) + .patch(`/v4/projects/metadata/productCategories/${key}`) .send(body) .expect(403, done); }); it('should return 403 for member', (done) => { request(server) - .patch(`/v4/productCategories/${key}`) + .patch(`/v4/projects/metadata/productCategories/${key}`) .set({ Authorization: `Bearer ${testUtil.jwts.member}`, }) @@ -64,7 +64,7 @@ describe('UPDATE product category', () => { it('should return 403 for copilot', (done) => { request(server) - .patch(`/v4/productCategories/${key}`) + .patch(`/v4/projects/metadata/productCategories/${key}`) .send(body) .set({ Authorization: `Bearer ${testUtil.jwts.copilot}`, @@ -74,7 +74,7 @@ describe('UPDATE product category', () => { it('should return 403 for manager', (done) => { request(server) - .patch(`/v4/productCategories/${key}`) + .patch(`/v4/projects/metadata/productCategories/${key}`) .send(body) .set({ Authorization: `Bearer ${testUtil.jwts.manager}`, @@ -84,7 +84,7 @@ describe('UPDATE product category', () => { it('should return 404 for non-existed product category', (done) => { request(server) - .patch('/v4/productCategories/1234') + .patch('/v4/projects/metadata/productCategories/1234') .set({ Authorization: `Bearer ${testUtil.jwts.admin}`, }) @@ -96,7 +96,7 @@ describe('UPDATE product category', () => { models.ProductCategory.destroy({ where: { key } }) .then(() => { request(server) - .patch(`/v4/productCategories/${key}`) + .patch(`/v4/projects/metadata/productCategories/${key}`) .set({ Authorization: `Bearer ${testUtil.jwts.admin}`, }) @@ -114,7 +114,7 @@ describe('UPDATE product category', () => { delete partialBody.param.disabled; delete partialBody.param.hidden; request(server) - .patch(`/v4/productCategories/${key}`) + .patch(`/v4/projects/metadata/productCategories/${key}`) .set({ Authorization: `Bearer ${testUtil.jwts.admin}`, }) @@ -150,7 +150,7 @@ describe('UPDATE product category', () => { delete partialBody.param.disabled; delete partialBody.param.hidden; request(server) - .patch(`/v4/productCategories/${key}`) + .patch(`/v4/projects/metadata/productCategories/${key}`) .set({ Authorization: `Bearer ${testUtil.jwts.admin}`, }) @@ -186,7 +186,7 @@ describe('UPDATE product category', () => { delete partialBody.param.disabled; delete partialBody.param.hidden; request(server) - .patch(`/v4/productCategories/${key}`) + .patch(`/v4/projects/metadata/productCategories/${key}`) .set({ Authorization: `Bearer ${testUtil.jwts.admin}`, }) @@ -222,7 +222,7 @@ describe('UPDATE product category', () => { delete partialBody.param.disabled; delete partialBody.param.hidden; request(server) - .patch(`/v4/productCategories/${key}`) + .patch(`/v4/projects/metadata/productCategories/${key}`) .set({ Authorization: `Bearer ${testUtil.jwts.admin}`, }) @@ -258,7 +258,7 @@ describe('UPDATE product category', () => { delete partialBody.param.disabled; delete partialBody.param.hidden; request(server) - .patch(`/v4/productCategories/${key}`) + .patch(`/v4/projects/metadata/productCategories/${key}`) .set({ Authorization: `Bearer ${testUtil.jwts.admin}`, }) @@ -294,7 +294,7 @@ describe('UPDATE product category', () => { delete partialBody.param.aliases; delete partialBody.param.hidden; request(server) - .patch(`/v4/productCategories/${key}`) + .patch(`/v4/projects/metadata/productCategories/${key}`) .set({ Authorization: `Bearer ${testUtil.jwts.admin}`, }) @@ -330,7 +330,7 @@ describe('UPDATE product category', () => { delete partialBody.param.disabled; delete partialBody.param.aliases; request(server) - .patch(`/v4/productCategories/${key}`) + .patch(`/v4/projects/metadata/productCategories/${key}`) .set({ Authorization: `Bearer ${testUtil.jwts.admin}`, }) @@ -358,7 +358,7 @@ describe('UPDATE product category', () => { it('should return 200 for admin all fields updated', (done) => { request(server) - .patch(`/v4/productCategories/${key}`) + .patch(`/v4/projects/metadata/productCategories/${key}`) .set({ Authorization: `Bearer ${testUtil.jwts.admin}`, }) @@ -386,7 +386,7 @@ describe('UPDATE product category', () => { it('should return 200 for connect admin', (done) => { request(server) - .patch(`/v4/productCategories/${key}`) + .patch(`/v4/projects/metadata/productCategories/${key}`) .set({ Authorization: `Bearer ${testUtil.jwts.connectAdmin}`, }) diff --git a/src/routes/productTemplates/create.spec.js b/src/routes/productTemplates/create.spec.js index 846e429f..b4da9239 100644 --- a/src/routes/productTemplates/create.spec.js +++ b/src/routes/productTemplates/create.spec.js @@ -29,7 +29,7 @@ describe('CREATE product template', () => { .then(() => done()); }); - describe('POST /productTemplates', () => { + describe('POST /projects/metadata/productTemplates', () => { const body = { param: { name: 'name 1', @@ -62,14 +62,14 @@ describe('CREATE product template', () => { it('should return 403 if user is not authenticated', (done) => { request(server) - .post('/v4/productTemplates') + .post('/v4/projects/metadata/productTemplates') .send(body) .expect(403, done); }); it('should return 403 for member', (done) => { request(server) - .post('/v4/productTemplates') + .post('/v4/projects/metadata/productTemplates') .set({ Authorization: `Bearer ${testUtil.jwts.member}`, }) @@ -79,7 +79,7 @@ describe('CREATE product template', () => { it('should return 403 for copilot', (done) => { request(server) - .post('/v4/productTemplates') + .post('/v4/projects/metadata/productTemplates') .set({ Authorization: `Bearer ${testUtil.jwts.copilot}`, }) @@ -89,7 +89,7 @@ describe('CREATE product template', () => { it('should return 403 for connect manager', (done) => { request(server) - .post('/v4/productTemplates') + .post('/v4/projects/metadata/productTemplates') .set({ Authorization: `Bearer ${testUtil.jwts.manager}`, }) @@ -106,7 +106,7 @@ describe('CREATE product template', () => { }; request(server) - .post('/v4/productTemplates') + .post('/v4/projects/metadata/productTemplates') .set({ Authorization: `Bearer ${testUtil.jwts.admin}`, }) @@ -119,7 +119,7 @@ describe('CREATE product template', () => { const invalidBody = _.cloneDeep(body); invalidBody.param.category = null; request(server) - .post('/v4/productTemplates') + .post('/v4/projects/metadata/productTemplates') .set({ Authorization: `Bearer ${testUtil.jwts.admin}`, }) @@ -132,7 +132,7 @@ describe('CREATE product template', () => { const invalidBody = _.cloneDeep(body); invalidBody.param.category = 'not_exist'; request(server) - .post('/v4/productTemplates') + .post('/v4/projects/metadata/productTemplates') .set({ Authorization: `Bearer ${testUtil.jwts.admin}`, }) @@ -143,7 +143,7 @@ describe('CREATE product template', () => { it('should return 201 for admin', (done) => { request(server) - .post('/v4/productTemplates') + .post('/v4/projects/metadata/productTemplates') .set({ Authorization: `Bearer ${testUtil.jwts.admin}`, }) @@ -177,7 +177,7 @@ describe('CREATE product template', () => { it('should return 201 for connect admin', (done) => { request(server) - .post('/v4/productTemplates') + .post('/v4/projects/metadata/productTemplates') .set({ Authorization: `Bearer ${testUtil.jwts.connectAdmin}`, }) diff --git a/src/routes/productTemplates/delete.spec.js b/src/routes/productTemplates/delete.spec.js index 0f39eb15..edb53d74 100644 --- a/src/routes/productTemplates/delete.spec.js +++ b/src/routes/productTemplates/delete.spec.js @@ -25,7 +25,7 @@ const expectAfterDelete = (id, err, next) => { chai.assert.isNotNull(res.deletedBy); request(server) - .get(`/v4/productTemplates/${id}`) + .get(`/v4/projects/metadata/productTemplates/${id}`) .set({ Authorization: `Bearer ${testUtil.jwts.admin}`, }) @@ -72,16 +72,16 @@ describe('DELETE product template', () => { ); after(testUtil.clearDb); - describe('DELETE /productTemplates/{templateId}', () => { + describe('DELETE /projects/metadata/productTemplates/{templateId}', () => { it('should return 403 if user is not authenticated', (done) => { request(server) - .delete(`/v4/productTemplates/${templateId}`) + .delete(`/v4/projects/metadata/productTemplates/${templateId}`) .expect(403, done); }); it('should return 403 for member', (done) => { request(server) - .delete(`/v4/productTemplates/${templateId}`) + .delete(`/v4/projects/metadata/productTemplates/${templateId}`) .set({ Authorization: `Bearer ${testUtil.jwts.member}`, }) @@ -90,7 +90,7 @@ describe('DELETE product template', () => { it('should return 403 for copilot', (done) => { request(server) - .delete(`/v4/productTemplates/${templateId}`) + .delete(`/v4/projects/metadata/productTemplates/${templateId}`) .set({ Authorization: `Bearer ${testUtil.jwts.copilot}`, }) @@ -99,7 +99,7 @@ describe('DELETE product template', () => { it('should return 403 for connect manager', (done) => { request(server) - .delete(`/v4/productTemplates/${templateId}`) + .delete(`/v4/projects/metadata/productTemplates/${templateId}`) .set({ Authorization: `Bearer ${testUtil.jwts.manager}`, }) @@ -108,7 +108,7 @@ describe('DELETE product template', () => { it('should return 404 for non-existed template', (done) => { request(server) - .delete('/v4/productTemplates/1234') + .delete('/v4/projects/metadata/productTemplates/1234') .set({ Authorization: `Bearer ${testUtil.jwts.admin}`, }) @@ -119,7 +119,7 @@ describe('DELETE product template', () => { models.ProductTemplate.destroy({ where: { id: templateId } }) .then(() => { request(server) - .delete(`/v4/productTemplates/${templateId}`) + .delete(`/v4/projects/metadata/productTemplates/${templateId}`) .set({ Authorization: `Bearer ${testUtil.jwts.admin}`, }) @@ -129,7 +129,7 @@ describe('DELETE product template', () => { it('should return 204, for admin, if template was successfully removed', (done) => { request(server) - .delete(`/v4/productTemplates/${templateId}`) + .delete(`/v4/projects/metadata/productTemplates/${templateId}`) .set({ Authorization: `Bearer ${testUtil.jwts.admin}`, }) @@ -139,7 +139,7 @@ describe('DELETE product template', () => { it('should return 204, for connect admin, if template was successfully removed', (done) => { request(server) - .delete(`/v4/productTemplates/${templateId}`) + .delete(`/v4/projects/metadata/productTemplates/${templateId}`) .set({ Authorization: `Bearer ${testUtil.jwts.connectAdmin}`, }) diff --git a/src/routes/productTemplates/get.spec.js b/src/routes/productTemplates/get.spec.js index c690efca..42b34e30 100644 --- a/src/routes/productTemplates/get.spec.js +++ b/src/routes/productTemplates/get.spec.js @@ -56,10 +56,10 @@ describe('GET product template', () => { ); after(testUtil.clearDb); - describe('GET /productTemplates/{templateId}', () => { + describe('GET /projects/metadata/productTemplates/{templateId}', () => { it('should return 404 for non-existed template', (done) => { request(server) - .get('/v4/productTemplates/1234') + .get('/v4/projects/metadata/productTemplates/1234') .set({ Authorization: `Bearer ${testUtil.jwts.admin}`, }) @@ -70,7 +70,7 @@ describe('GET product template', () => { models.ProductTemplate.destroy({ where: { id: templateId } }) .then(() => { request(server) - .get(`/v4/productTemplates/${templateId}`) + .get(`/v4/projects/metadata/productTemplates/${templateId}`) .set({ Authorization: `Bearer ${testUtil.jwts.admin}`, }) @@ -80,7 +80,7 @@ describe('GET product template', () => { it('should return 200 for admin', (done) => { request(server) - .get(`/v4/productTemplates/${templateId}`) + .get(`/v4/projects/metadata/productTemplates/${templateId}`) .set({ Authorization: `Bearer ${testUtil.jwts.admin}`, }) @@ -110,13 +110,13 @@ describe('GET product template', () => { it('should return 200 even if user is not authenticated', (done) => { request(server) - .get(`/v4/productTemplates/${templateId}`) + .get(`/v4/projects/metadata/productTemplates/${templateId}`) .expect(200, done); }); it('should return 200 for connect admin', (done) => { request(server) - .get(`/v4/productTemplates/${templateId}`) + .get(`/v4/projects/metadata/productTemplates/${templateId}`) .set({ Authorization: `Bearer ${testUtil.jwts.connectAdmin}`, }) @@ -126,7 +126,7 @@ describe('GET product template', () => { it('should return 200 for connect manager', (done) => { request(server) - .get(`/v4/productTemplates/${templateId}`) + .get(`/v4/projects/metadata/productTemplates/${templateId}`) .set({ Authorization: `Bearer ${testUtil.jwts.manager}`, }) @@ -136,7 +136,7 @@ describe('GET product template', () => { it('should return 200 for member', (done) => { request(server) - .get(`/v4/productTemplates/${templateId}`) + .get(`/v4/projects/metadata/productTemplates/${templateId}`) .set({ Authorization: `Bearer ${testUtil.jwts.member}`, }) @@ -145,7 +145,7 @@ describe('GET product template', () => { it('should return 200 for copilot', (done) => { request(server) - .get(`/v4/productTemplates/${templateId}`) + .get(`/v4/projects/metadata/productTemplates/${templateId}`) .set({ Authorization: `Bearer ${testUtil.jwts.copilot}`, }) diff --git a/src/routes/productTemplates/list.spec.js b/src/routes/productTemplates/list.spec.js index b0e8c56f..9282e409 100644 --- a/src/routes/productTemplates/list.spec.js +++ b/src/routes/productTemplates/list.spec.js @@ -94,10 +94,10 @@ describe('LIST product templates', () => { ); after(testUtil.clearDb); - describe('GET /productTemplates', () => { + describe('GET /projects/metadata/productTemplates', () => { it('should return 200 for admin', (done) => { request(server) - .get('/v4/productTemplates') + .get('/v4/projects/metadata/productTemplates') .set({ Authorization: `Bearer ${testUtil.jwts.admin}`, }) @@ -112,7 +112,7 @@ describe('LIST product templates', () => { it('should return 200 even if user is not authenticated', (done) => { request(server) - .get('/v4/productTemplates') + .get('/v4/projects/metadata/productTemplates') .expect(200) .end((err, res) => { const resJson = res.body.result.content; @@ -124,7 +124,7 @@ describe('LIST product templates', () => { it('should return 200 for connect admin', (done) => { request(server) - .get('/v4/productTemplates') + .get('/v4/projects/metadata/productTemplates') .set({ Authorization: `Bearer ${testUtil.jwts.connectAdmin}`, }) @@ -139,7 +139,7 @@ describe('LIST product templates', () => { it('should return 200 for connect manager', (done) => { request(server) - .get('/v4/productTemplates') + .get('/v4/projects/metadata/productTemplates') .set({ Authorization: `Bearer ${testUtil.jwts.manager}`, }) @@ -154,7 +154,7 @@ describe('LIST product templates', () => { it('should return 200 for member', (done) => { request(server) - .get('/v4/productTemplates') + .get('/v4/projects/metadata/productTemplates') .set({ Authorization: `Bearer ${testUtil.jwts.member}`, }) @@ -168,7 +168,7 @@ describe('LIST product templates', () => { it('should return 200 for copilot', (done) => { request(server) - .get('/v4/productTemplates') + .get('/v4/projects/metadata/productTemplates') .set({ Authorization: `Bearer ${testUtil.jwts.copilot}`, }) @@ -182,7 +182,7 @@ describe('LIST product templates', () => { it('should return filtered templates', (done) => { request(server) - .get('/v4/productTemplates?filter=productKey%3DproductKey-2') + .get('/v4/projects/metadata/productTemplates?filter=productKey%3DproductKey-2') .set({ Authorization: `Bearer ${testUtil.jwts.manager}`, }) diff --git a/src/routes/productTemplates/update.spec.js b/src/routes/productTemplates/update.spec.js index 80667f0f..6e65461d 100644 --- a/src/routes/productTemplates/update.spec.js +++ b/src/routes/productTemplates/update.spec.js @@ -80,7 +80,7 @@ describe('UPDATE product template', () => { ); after(testUtil.clearDb); - describe('PATCH /productTemplates/{templateId}', () => { + describe('PATCH /projects/metadata/productTemplates/{templateId}', () => { const body = { param: { name: 'template 1 - update', @@ -119,14 +119,14 @@ describe('UPDATE product template', () => { it('should return 403 if user is not authenticated', (done) => { request(server) - .patch(`/v4/productTemplates/${templateId}`) + .patch(`/v4/projects/metadata/productTemplates/${templateId}`) .send(body) .expect(403, done); }); it('should return 403 for member', (done) => { request(server) - .patch(`/v4/productTemplates/${templateId}`) + .patch(`/v4/projects/metadata/productTemplates/${templateId}`) .set({ Authorization: `Bearer ${testUtil.jwts.member}`, }) @@ -136,7 +136,7 @@ describe('UPDATE product template', () => { it('should return 403 for copilot', (done) => { request(server) - .patch(`/v4/productTemplates/${templateId}`) + .patch(`/v4/projects/metadata/productTemplates/${templateId}`) .send(body) .set({ Authorization: `Bearer ${testUtil.jwts.copilot}`, @@ -146,7 +146,7 @@ describe('UPDATE product template', () => { it('should return 403 for connect manager', (done) => { request(server) - .patch(`/v4/productTemplates/${templateId}`) + .patch(`/v4/projects/metadata/productTemplates/${templateId}`) .send(body) .set({ Authorization: `Bearer ${testUtil.jwts.manager}`, @@ -163,7 +163,7 @@ describe('UPDATE product template', () => { }; request(server) - .patch(`/v4/productTemplates/${templateId}`) + .patch(`/v4/projects/metadata/productTemplates/${templateId}`) .set({ Authorization: `Bearer ${testUtil.jwts.admin}`, }) @@ -173,7 +173,7 @@ describe('UPDATE product template', () => { it('should return 404 for non-existed template', (done) => { request(server) - .patch('/v4/productTemplates/1234') + .patch('/v4/projects/metadata/productTemplates/1234') .set({ Authorization: `Bearer ${testUtil.jwts.admin}`, }) @@ -185,7 +185,7 @@ describe('UPDATE product template', () => { models.ProductTemplate.destroy({ where: { id: templateId } }) .then(() => { request(server) - .patch(`/v4/productTemplates/${templateId}`) + .patch(`/v4/projects/metadata/productTemplates/${templateId}`) .set({ Authorization: `Bearer ${testUtil.jwts.admin}`, }) @@ -196,7 +196,7 @@ describe('UPDATE product template', () => { it('should return 200 for admin', (done) => { request(server) - .patch(`/v4/productTemplates/${templateId}`) + .patch(`/v4/projects/metadata/productTemplates/${templateId}`) .set({ Authorization: `Bearer ${testUtil.jwts.admin}`, }) @@ -260,7 +260,7 @@ describe('UPDATE product template', () => { it('should return 200 for connect admin', (done) => { request(server) - .patch(`/v4/productTemplates/${templateId}`) + .patch(`/v4/projects/metadata/productTemplates/${templateId}`) .set({ Authorization: `Bearer ${testUtil.jwts.connectAdmin}`, }) diff --git a/src/routes/projectMembers/create.spec.js b/src/routes/projectMembers/create.spec.js index 4d0c9f2f..429b50de 100644 --- a/src/routes/projectMembers/create.spec.js +++ b/src/routes/projectMembers/create.spec.js @@ -8,7 +8,8 @@ import models from '../../models'; import util from '../../util'; import server from '../../app'; import testUtil from '../../tests/util'; -import { USER_ROLE } from '../../constants'; +import busApi from '../../services/busApi'; +import { USER_ROLE, PROJECT_MEMBER_ROLE, BUS_API_EVENT } from '../../constants'; const should = chai.should(); @@ -28,6 +29,8 @@ describe('Project Members create', () => { details: {}, createdBy: 1, updatedBy: 1, + lastActivityAt: 1, + lastActivityUserId: '1', }).then((p) => { project1 = p; // create members @@ -49,6 +52,8 @@ describe('Project Members create', () => { details: {}, createdBy: 1, updatedBy: 1, + lastActivityAt: 1, + lastActivityUserId: '1', }).then((p2) => { project2 = p2; done(); @@ -474,5 +479,146 @@ describe('Project Members create', () => { } }); }); + + describe('Bus api', () => { + let createEventSpy; + + before((done) => { + // Wait for 500ms in order to wait for createEvent calls from previous tests to complete + testUtil.wait(done); + }); + + beforeEach(() => { + createEventSpy = sandbox.spy(busApi, 'createEvent'); + }); + + it('sends single BUS_API_EVENT.PROJECT_TEAM_UPDATED message when manager added', (done) => { + const mockHttpClient = _.merge(testUtil.mockHttpClient, { + get: () => Promise.resolve({ + status: 200, + data: { + id: 'requesterId', + version: 'v3', + result: { + success: true, + status: 200, + content: [{ + roleName: USER_ROLE.MANAGER, + }], + }, + }, + }), + post: () => Promise.resolve({ + status: 200, + data: { + id: 'requesterId', + version: 'v3', + result: { + success: true, + status: 200, + content: {}, + }, + }, + }), + }); + sandbox.stub(util, 'getHttpClient', () => mockHttpClient); + request(server) + .post(`/v4/projects/${project1.id}/members/`) + .set({ + Authorization: `Bearer ${testUtil.jwts.manager}`, + }) + .send({ + param: { + userId: 3, + role: PROJECT_MEMBER_ROLE.MANAGER, + }, + }) + .expect(201) + .end((err) => { + if (err) { + done(err); + } else { + testUtil.wait(() => { + createEventSpy.calledTwice.should.be.true; + createEventSpy.firstCall.calledWith(BUS_API_EVENT.MEMBER_JOINED_MANAGER); + createEventSpy.secondCall.calledWith(BUS_API_EVENT.PROJECT_TEAM_UPDATED, sinon.match({ + projectId: project1.id, + projectName: project1.name, + projectUrl: `https://local.topcoder-dev.com/projects/${project1.id}`, + userId: 40051334, + initiatorUserId: 40051334, + })).should.be.true; + done(); + }); + } + }); + }); + + it('sends single BUS_API_EVENT.PROJECT_TEAM_UPDATED message when copilot added', (done) => { + request(server) + .post(`/v4/projects/${project1.id}/members/`) + .set({ + Authorization: `Bearer ${testUtil.jwts.copilot}`, + }) + .send({ + param: { + userId: 3, + role: PROJECT_MEMBER_ROLE.COPILOT, + }, + }) + .expect(201) + .end((err) => { + if (err) { + done(err); + } else { + testUtil.wait(() => { + createEventSpy.calledTwice.should.be.true; + createEventSpy.firstCall.calledWith(BUS_API_EVENT.MEMBER_JOINED_COPILOT); + createEventSpy.secondCall.calledWith(BUS_API_EVENT.PROJECT_TEAM_UPDATED, sinon.match({ + projectId: project1.id, + projectName: project1.name, + projectUrl: `https://local.topcoder-dev.com/projects/${project1.id}`, + userId: 40051332, + initiatorUserId: 40051332, + })).should.be.true; + done(); + }); + } + }); + }); + + it('sends single BUS_API_EVENT.PROJECT_TEAM_UPDATED message when customer added', (done) => { + request(server) + .post(`/v4/projects/${project1.id}/members/`) + .set({ + Authorization: `Bearer ${testUtil.jwts.copilot}`, + }) + .send({ + param: { + userId: 3, + role: PROJECT_MEMBER_ROLE.CUSTOMER, + }, + }) + .expect(201) + .end((err) => { + if (err) { + done(err); + } else { + testUtil.wait(() => { + createEventSpy.calledTwice.should.be.true; + createEventSpy.firstCall.calledWith(BUS_API_EVENT.MEMBER_JOINED); + createEventSpy.secondCall.calledWith(BUS_API_EVENT.PROJECT_TEAM_UPDATED, sinon.match({ + projectId: project1.id, + projectName: project1.name, + projectUrl: `https://local.topcoder-dev.com/projects/${project1.id}`, + userId: 40051332, + initiatorUserId: 40051332, + })).should.be.true; + done(); + }); + } + }); + }); + }); }); }); diff --git a/src/routes/projectMembers/delete.js b/src/routes/projectMembers/delete.js index 43e1f037..b58aaa84 100644 --- a/src/routes/projectMembers/delete.js +++ b/src/routes/projectMembers/delete.js @@ -28,9 +28,9 @@ module.exports = [ err.status = 404; return Promise.reject(err); } - return member.update({ deletedBy: req.authUser.userId }); // eslint-disable-line no-console + return member.update({ deletedBy: req.authUser.userId }); }) - .then(member => member.destroy({ logging: console.log })) + .then(member => member.destroy({ logging: console.log })) // eslint-disable-line no-console .then(member => member.save()) // if primary co-pilot is removed promote the next co-pilot to primary #43 .then(member => new Promise((accept, reject) => { diff --git a/src/routes/projectMembers/delete.spec.js b/src/routes/projectMembers/delete.spec.js index 3667bcff..9f539ef2 100644 --- a/src/routes/projectMembers/delete.spec.js +++ b/src/routes/projectMembers/delete.spec.js @@ -8,6 +8,8 @@ import models from '../../models'; import util from '../../util'; import server from '../../app'; import testUtil from '../../tests/util'; +import busApi from '../../services/busApi'; +import { BUS_API_EVENT } from '../../constants'; const should = chai.should(); @@ -46,6 +48,8 @@ describe('Project members delete', () => { details: {}, createdBy: 1, updatedBy: 1, + lastActivityAt: 1, + lastActivityUserId: '1', }).then((p) => { project1 = p; // create members @@ -315,5 +319,87 @@ describe('Project members delete', () => { }) .expect(403, done); }); + + describe('Bus api', () => { + let createEventSpy; + + before((done) => { + // Wait for 500ms in order to wait for createEvent calls from previous tests to complete + testUtil.wait(done); + }); + + beforeEach(() => { + createEventSpy = sandbox.spy(busApi, 'createEvent'); + }); + + it('sends single BUS_API_EVENT.PROJECT_TEAM_UPDATED message when manager removed', (done) => { + const mockHttpClient = _.merge(testUtil.mockHttpClient, { + post: () => Promise.resolve({ + status: 200, + data: { + id: 'requesterId', + version: 'v3', + result: { + success: true, + status: 200, + content: {}, + }, + }, + }), + }); + sandbox.stub(util, 'getHttpClient', () => mockHttpClient); + request(server) + .delete(`/v4/projects/${project1.id}/members/${member2.id}`) + .set({ + Authorization: `Bearer ${testUtil.jwts.manager}`, + }) + .expect(204) + .end((err) => { + if (err) { + done(err); + } else { + testUtil.wait(() => { + createEventSpy.calledTwice.should.be.true; + createEventSpy.firstCall.calledWith(BUS_API_EVENT.MEMBER_LEFT); + createEventSpy.secondCall.calledWith(BUS_API_EVENT.PROJECT_TEAM_UPDATED, sinon.match({ + projectId: project1.id, + projectName: project1.name, + projectUrl: `https://local.topcoder-dev.com/projects/${project1.id}`, + userId: 40051334, + initiatorUserId: 40051334, + })).should.be.true; + done(); + }); + } + }); + }); + + it('sends single BUS_API_EVENT.PROJECT_TEAM_UPDATED message when copilot removed', (done) => { + request(server) + .delete(`/v4/projects/${project1.id}/members/${member1.id}`) + .set({ + Authorization: `Bearer ${testUtil.jwts.manager}`, + }) + .expect(204) + .end((err) => { + if (err) { + done(err); + } else { + testUtil.wait(() => { + createEventSpy.calledTwice.should.be.true; + createEventSpy.firstCall.calledWith(BUS_API_EVENT.MEMBER_REMOVED); + createEventSpy.secondCall.calledWith(BUS_API_EVENT.PROJECT_TEAM_UPDATED, sinon.match({ + projectId: project1.id, + projectName: project1.name, + projectUrl: `https://local.topcoder-dev.com/projects/${project1.id}`, + userId: 40051334, + initiatorUserId: 40051334, + })).should.be.true; + done(); + }); + } + }); + }); + }); }); }); diff --git a/src/routes/projectMembers/update.spec.js b/src/routes/projectMembers/update.spec.js index 9497c416..4db550cd 100644 --- a/src/routes/projectMembers/update.spec.js +++ b/src/routes/projectMembers/update.spec.js @@ -7,6 +7,8 @@ import models from '../../models'; import server from '../../app'; import util from '../../util'; import testUtil from '../../tests/util'; +import busApi from '../../services/busApi'; +import { BUS_API_EVENT } from '../../constants'; const should = chai.should(); @@ -28,6 +30,8 @@ describe('Project members update', () => { details: {}, createdBy: 1, updatedBy: 1, + lastActivityAt: 1, + lastActivityUserId: '1', }).then((p) => { project1 = p; // create members @@ -449,5 +453,50 @@ describe('Project members update', () => { } }); }); + + describe('Bus api', () => { + let createEventSpy; + + before((done) => { + // Wait for 500ms in order to wait for createEvent calls from previous tests to complete + testUtil.wait(done); + }); + + beforeEach(() => { + createEventSpy = sandbox.spy(busApi, 'createEvent'); + }); + + it('sends single BUS_API_EVENT.PROJECT_TEAM_UPDATED message when user role updated', (done) => { + request(server) + .patch(`/v4/projects/${project1.id}/members/${member2.id}`) + .set({ + Authorization: `Bearer ${testUtil.jwts.copilot}`, + }) + .send({ + param: { + role: 'customer', + }, + }) + .expect('Content-Type', /json/) + .expect(200) + .end((err) => { + if (err) { + done(err); + } else { + testUtil.wait(() => { + createEventSpy.calledOnce.should.be.true; + createEventSpy.calledWith(BUS_API_EVENT.PROJECT_TEAM_UPDATED, sinon.match({ + projectId: project1.id, + projectName: project1.name, + projectUrl: `https://local.topcoder-dev.com/projects/${project1.id}`, + userId: 40051332, + initiatorUserId: 40051332, + })).should.be.true; + done(); + }); + } + }); + }); + }); }); }); diff --git a/src/routes/projectTemplates/create.spec.js b/src/routes/projectTemplates/create.spec.js index c18123cb..51989b4c 100644 --- a/src/routes/projectTemplates/create.spec.js +++ b/src/routes/projectTemplates/create.spec.js @@ -30,7 +30,7 @@ describe('CREATE project template', () => { .then(() => done()); }); - describe('POST /projectTemplates', () => { + describe('POST /projects/metadata/projectTemplates', () => { const body = { param: { name: 'template 1', @@ -70,14 +70,14 @@ describe('CREATE project template', () => { it('should return 403 if user is not authenticated', (done) => { request(server) - .post('/v4/projectTemplates') + .post('/v4/projects/metadata/projectTemplates') .send(body) .expect(403, done); }); it('should return 403 for member', (done) => { request(server) - .post('/v4/projectTemplates') + .post('/v4/projects/metadata/projectTemplates') .set({ Authorization: `Bearer ${testUtil.jwts.member}`, }) @@ -87,7 +87,7 @@ describe('CREATE project template', () => { it('should return 403 for copilot', (done) => { request(server) - .post('/v4/projectTemplates') + .post('/v4/projects/metadata/projectTemplates') .set({ Authorization: `Bearer ${testUtil.jwts.copilot}`, }) @@ -97,7 +97,7 @@ describe('CREATE project template', () => { it('should return 403 for connect manager', (done) => { request(server) - .post('/v4/projectTemplates') + .post('/v4/projects/metadata/projectTemplates') .set({ Authorization: `Bearer ${testUtil.jwts.manager}`, }) @@ -114,7 +114,7 @@ describe('CREATE project template', () => { }; request(server) - .post('/v4/projectTemplates') + .post('/v4/projects/metadata/projectTemplates') .set({ Authorization: `Bearer ${testUtil.jwts.admin}`, }) @@ -127,7 +127,7 @@ describe('CREATE project template', () => { const invalidBody = _.cloneDeep(body); invalidBody.param.type = null; request(server) - .post('/v4/projectTemplates') + .post('/v4/projects/metadata/projectTemplates') .set({ Authorization: `Bearer ${testUtil.jwts.admin}`, }) @@ -140,7 +140,7 @@ describe('CREATE project template', () => { const invalidBody = _.cloneDeep(body); invalidBody.param.type = 'not_exist'; request(server) - .post('/v4/projectTemplates') + .post('/v4/projects/metadata/projectTemplates') .set({ Authorization: `Bearer ${testUtil.jwts.admin}`, }) @@ -151,7 +151,7 @@ describe('CREATE project template', () => { it('should return 201 for admin', (done) => { request(server) - .post('/v4/projectTemplates') + .post('/v4/projects/metadata/projectTemplates') .set({ Authorization: `Bearer ${testUtil.jwts.admin}`, }) @@ -182,7 +182,7 @@ describe('CREATE project template', () => { it('should return 201 for connect admin', (done) => { request(server) - .post('/v4/projectTemplates') + .post('/v4/projects/metadata/projectTemplates') .set({ Authorization: `Bearer ${testUtil.jwts.connectAdmin}`, }) diff --git a/src/routes/projectTemplates/delete.spec.js b/src/routes/projectTemplates/delete.spec.js index a475ee5d..8c3c268f 100644 --- a/src/routes/projectTemplates/delete.spec.js +++ b/src/routes/projectTemplates/delete.spec.js @@ -25,7 +25,7 @@ const expectAfterDelete = (id, err, next) => { chai.assert.isNotNull(res.deletedBy); request(server) - .get(`/v4/projectTemplates/${id}`) + .get(`/v4/projects/metadata/projectTemplates/${id}`) .set({ Authorization: `Bearer ${testUtil.jwts.admin}`, }) @@ -78,16 +78,16 @@ describe('DELETE project template', () => { ); after(testUtil.clearDb); - describe('DELETE /projectTemplates/{templateId}', () => { + describe('DELETE /projects/metadata/projectTemplates/{templateId}', () => { it('should return 403 if user is not authenticated', (done) => { request(server) - .delete(`/v4/projectTemplates/${templateId}`) + .delete(`/v4/projects/metadata/projectTemplates/${templateId}`) .expect(403, done); }); it('should return 403 for member', (done) => { request(server) - .delete(`/v4/projectTemplates/${templateId}`) + .delete(`/v4/projects/metadata/projectTemplates/${templateId}`) .set({ Authorization: `Bearer ${testUtil.jwts.member}`, }) @@ -96,7 +96,7 @@ describe('DELETE project template', () => { it('should return 403 for copilot', (done) => { request(server) - .delete(`/v4/projectTemplates/${templateId}`) + .delete(`/v4/projects/metadata/projectTemplates/${templateId}`) .set({ Authorization: `Bearer ${testUtil.jwts.copilot}`, }) @@ -105,7 +105,7 @@ describe('DELETE project template', () => { it('should return 403 for connect manager', (done) => { request(server) - .delete(`/v4/projectTemplates/${templateId}`) + .delete(`/v4/projects/metadata/projectTemplates/${templateId}`) .set({ Authorization: `Bearer ${testUtil.jwts.manager}`, }) @@ -114,7 +114,7 @@ describe('DELETE project template', () => { it('should return 404 for non-existed template', (done) => { request(server) - .delete('/v4/projectTemplates/1234') + .delete('/v4/projects/metadata/projectTemplates/1234') .set({ Authorization: `Bearer ${testUtil.jwts.admin}`, }) @@ -125,7 +125,7 @@ describe('DELETE project template', () => { models.ProjectTemplate.destroy({ where: { id: templateId } }) .then(() => { request(server) - .delete(`/v4/projectTemplates/${templateId}`) + .delete(`/v4/projects/metadata/projectTemplates/${templateId}`) .set({ Authorization: `Bearer ${testUtil.jwts.admin}`, }) @@ -135,7 +135,7 @@ describe('DELETE project template', () => { it('should return 204, for admin, if template was successfully removed', (done) => { request(server) - .delete(`/v4/projectTemplates/${templateId}`) + .delete(`/v4/projects/metadata/projectTemplates/${templateId}`) .set({ Authorization: `Bearer ${testUtil.jwts.admin}`, }) @@ -144,7 +144,7 @@ describe('DELETE project template', () => { it('should return 204, for connect admin, if template was successfully removed', (done) => { request(server) - .delete(`/v4/projectTemplates/${templateId}`) + .delete(`/v4/projects/metadata/projectTemplates/${templateId}`) .set({ Authorization: `Bearer ${testUtil.jwts.connectAdmin}`, }) diff --git a/src/routes/projectTemplates/get.spec.js b/src/routes/projectTemplates/get.spec.js index 50899ec4..ae6607fa 100644 --- a/src/routes/projectTemplates/get.spec.js +++ b/src/routes/projectTemplates/get.spec.js @@ -57,10 +57,10 @@ describe('GET project template', () => { ); after(testUtil.clearDb); - describe('GET /projectTemplates/{templateId}', () => { + describe('GET /projects/metadata/projectTemplates/{templateId}', () => { it('should return 404 for non-existed template', (done) => { request(server) - .get('/v4/projectTemplates/1234') + .get('/v4/projects/metadata/projectTemplates/1234') .set({ Authorization: `Bearer ${testUtil.jwts.admin}`, }) @@ -71,7 +71,7 @@ describe('GET project template', () => { models.ProjectTemplate.destroy({ where: { id: templateId } }) .then(() => { request(server) - .get(`/v4/projectTemplates/${templateId}`) + .get(`/v4/projects/metadata/projectTemplates/${templateId}`) .set({ Authorization: `Bearer ${testUtil.jwts.admin}`, }) @@ -81,7 +81,7 @@ describe('GET project template', () => { it('should return 200 for admin', (done) => { request(server) - .get(`/v4/projectTemplates/${templateId}`) + .get(`/v4/projects/metadata/projectTemplates/${templateId}`) .set({ Authorization: `Bearer ${testUtil.jwts.admin}`, }) @@ -107,13 +107,13 @@ describe('GET project template', () => { it('should return 200 even if user is not authenticated', (done) => { request(server) - .get(`/v4/projectTemplates/${templateId}`) + .get(`/v4/projects/metadata/projectTemplates/${templateId}`) .expect(200, done); }); it('should return 200 for connect admin', (done) => { request(server) - .get(`/v4/projectTemplates/${templateId}`) + .get(`/v4/projects/metadata/projectTemplates/${templateId}`) .set({ Authorization: `Bearer ${testUtil.jwts.connectAdmin}`, }) @@ -123,7 +123,7 @@ describe('GET project template', () => { it('should return 200 for connect manager', (done) => { request(server) - .get(`/v4/projectTemplates/${templateId}`) + .get(`/v4/projects/metadata/projectTemplates/${templateId}`) .set({ Authorization: `Bearer ${testUtil.jwts.manager}`, }) @@ -133,7 +133,7 @@ describe('GET project template', () => { it('should return 200 for member', (done) => { request(server) - .get(`/v4/projectTemplates/${templateId}`) + .get(`/v4/projects/metadata/projectTemplates/${templateId}`) .set({ Authorization: `Bearer ${testUtil.jwts.member}`, }) @@ -142,7 +142,7 @@ describe('GET project template', () => { it('should return 200 for copilot', (done) => { request(server) - .get(`/v4/projectTemplates/${templateId}`) + .get(`/v4/projects/metadata/projectTemplates/${templateId}`) .set({ Authorization: `Bearer ${testUtil.jwts.copilot}`, }) diff --git a/src/routes/projectTemplates/list.spec.js b/src/routes/projectTemplates/list.spec.js index e826ea9f..9c3008e8 100644 --- a/src/routes/projectTemplates/list.spec.js +++ b/src/routes/projectTemplates/list.spec.js @@ -74,10 +74,10 @@ describe('LIST project templates', () => { ); after(testUtil.clearDb); - describe('GET /projectTemplates', () => { + describe('GET /projects/metadata/projectTemplates', () => { it('should return 200 for admin', (done) => { request(server) - .get('/v4/projectTemplates') + .get('/v4/projects/metadata/projectTemplates') .set({ Authorization: `Bearer ${testUtil.jwts.admin}`, }) @@ -106,13 +106,13 @@ describe('LIST project templates', () => { it('should return 200 for anonymous user', (done) => { request(server) - .get('/v4/projectTemplates') + .get('/v4/projects/metadata/projectTemplates') .expect(200, done); }); it('should return 200 for connect admin', (done) => { request(server) - .get('/v4/projectTemplates') + .get('/v4/projects/metadata/projectTemplates') .set({ Authorization: `Bearer ${testUtil.jwts.connectAdmin}`, }) @@ -122,7 +122,7 @@ describe('LIST project templates', () => { it('should return 200 for connect manager', (done) => { request(server) - .get('/v4/projectTemplates') + .get('/v4/projects/metadata/projectTemplates') .set({ Authorization: `Bearer ${testUtil.jwts.manager}`, }) @@ -132,7 +132,7 @@ describe('LIST project templates', () => { it('should return 200 for member', (done) => { request(server) - .get('/v4/projectTemplates') + .get('/v4/projects/metadata/projectTemplates') .set({ Authorization: `Bearer ${testUtil.jwts.member}`, }) @@ -141,7 +141,7 @@ describe('LIST project templates', () => { it('should return 200 for copilot', (done) => { request(server) - .get('/v4/projectTemplates') + .get('/v4/projects/metadata/projectTemplates') .set({ Authorization: `Bearer ${testUtil.jwts.copilot}`, }) diff --git a/src/routes/projectTemplates/update.spec.js b/src/routes/projectTemplates/update.spec.js index 514df753..ff051381 100644 --- a/src/routes/projectTemplates/update.spec.js +++ b/src/routes/projectTemplates/update.spec.js @@ -83,7 +83,7 @@ describe('UPDATE project template', () => { ); after(testUtil.clearDb); - describe('PATCH /projectTemplates/{templateId}', () => { + describe('PATCH /projects/metadata/projectTemplates/{templateId}', () => { const body = { param: { name: 'template 1 - update', @@ -119,14 +119,14 @@ describe('UPDATE project template', () => { it('should return 403 if user is not authenticated', (done) => { request(server) - .patch(`/v4/projectTemplates/${templateId}`) + .patch(`/v4/projects/metadata/projectTemplates/${templateId}`) .send(body) .expect(403, done); }); it('should return 403 for member', (done) => { request(server) - .patch(`/v4/projectTemplates/${templateId}`) + .patch(`/v4/projects/metadata/projectTemplates/${templateId}`) .set({ Authorization: `Bearer ${testUtil.jwts.member}`, }) @@ -136,7 +136,7 @@ describe('UPDATE project template', () => { it('should return 403 for copilot', (done) => { request(server) - .patch(`/v4/projectTemplates/${templateId}`) + .patch(`/v4/projects/metadata/projectTemplates/${templateId}`) .send(body) .set({ Authorization: `Bearer ${testUtil.jwts.copilot}`, @@ -146,7 +146,7 @@ describe('UPDATE project template', () => { it('should return 403 for connect manager', (done) => { request(server) - .patch(`/v4/projectTemplates/${templateId}`) + .patch(`/v4/projects/metadata/projectTemplates/${templateId}`) .send(body) .set({ Authorization: `Bearer ${testUtil.jwts.manager}`, @@ -163,7 +163,7 @@ describe('UPDATE project template', () => { }; request(server) - .patch(`/v4/projectTemplates/${templateId}`) + .patch(`/v4/projects/metadata/projectTemplates/${templateId}`) .set({ Authorization: `Bearer ${testUtil.jwts.admin}`, }) @@ -173,7 +173,7 @@ describe('UPDATE project template', () => { it('should return 404 for non-existed template', (done) => { request(server) - .patch('/v4/projectTemplates/1234') + .patch('/v4/projects/metadata/projectTemplates/1234') .set({ Authorization: `Bearer ${testUtil.jwts.admin}`, }) @@ -185,7 +185,7 @@ describe('UPDATE project template', () => { models.ProjectTemplate.destroy({ where: { id: templateId } }) .then(() => { request(server) - .patch(`/v4/projectTemplates/${templateId}`) + .patch(`/v4/projects/metadata/projectTemplates/${templateId}`) .set({ Authorization: `Bearer ${testUtil.jwts.admin}`, }) @@ -196,7 +196,7 @@ describe('UPDATE project template', () => { it('should return 200 for admin', (done) => { request(server) - .patch(`/v4/projectTemplates/${templateId}`) + .patch(`/v4/projects/metadata/projectTemplates/${templateId}`) .set({ Authorization: `Bearer ${testUtil.jwts.admin}`, }) @@ -256,7 +256,7 @@ describe('UPDATE project template', () => { it('should return 200 for connect admin', (done) => { request(server) - .patch(`/v4/projectTemplates/${templateId}`) + .patch(`/v4/projects/metadata/projectTemplates/${templateId}`) .set({ Authorization: `Bearer ${testUtil.jwts.connectAdmin}`, }) diff --git a/src/routes/projectTypes/create.spec.js b/src/routes/projectTypes/create.spec.js index d2e339bc..7823e45b 100644 --- a/src/routes/projectTypes/create.spec.js +++ b/src/routes/projectTypes/create.spec.js @@ -29,7 +29,7 @@ describe('CREATE project type', () => { ); after(testUtil.clearDb); - describe('POST /projectTypes', () => { + describe('POST /projects/metadata/projectTypes', () => { const body = { param: { key: 'app_dev', @@ -46,14 +46,14 @@ describe('CREATE project type', () => { it('should return 403 if user is not authenticated', (done) => { request(server) - .post('/v4/projectTypes') + .post('/v4/projects/metadata/projectTypes') .send(body) .expect(403, done); }); it('should return 403 for member', (done) => { request(server) - .post('/v4/projectTypes') + .post('/v4/projects/metadata/projectTypes') .set({ Authorization: `Bearer ${testUtil.jwts.member}`, }) @@ -63,7 +63,7 @@ describe('CREATE project type', () => { it('should return 403 for copilot', (done) => { request(server) - .post('/v4/projectTypes') + .post('/v4/projects/metadata/projectTypes') .set({ Authorization: `Bearer ${testUtil.jwts.copilot}`, }) @@ -73,7 +73,7 @@ describe('CREATE project type', () => { it('should return 403 for manager', (done) => { request(server) - .post('/v4/projectTypes') + .post('/v4/projects/metadata/projectTypes') .set({ Authorization: `Bearer ${testUtil.jwts.manager}`, }) @@ -86,7 +86,7 @@ describe('CREATE project type', () => { delete invalidBody.param.key; request(server) - .post('/v4/projectTypes') + .post('/v4/projects/metadata/projectTypes') .set({ Authorization: `Bearer ${testUtil.jwts.admin}`, }) @@ -100,7 +100,7 @@ describe('CREATE project type', () => { delete invalidBody.param.displayName; request(server) - .post('/v4/projectTypes') + .post('/v4/projects/metadata/projectTypes') .set({ Authorization: `Bearer ${testUtil.jwts.admin}`, }) @@ -114,7 +114,7 @@ describe('CREATE project type', () => { delete invalidBody.param.icon; request(server) - .post('/v4/projectTypes') + .post('/v4/projects/metadata/projectTypes') .set({ Authorization: `Bearer ${testUtil.jwts.admin}`, }) @@ -128,7 +128,7 @@ describe('CREATE project type', () => { delete invalidBody.param.question; request(server) - .post('/v4/projectTypes') + .post('/v4/projects/metadata/projectTypes') .set({ Authorization: `Bearer ${testUtil.jwts.admin}`, }) @@ -142,7 +142,7 @@ describe('CREATE project type', () => { delete invalidBody.param.info; request(server) - .post('/v4/projectTypes') + .post('/v4/projects/metadata/projectTypes') .set({ Authorization: `Bearer ${testUtil.jwts.admin}`, }) @@ -156,7 +156,7 @@ describe('CREATE project type', () => { delete invalidBody.param.metadata; request(server) - .post('/v4/projectTypes') + .post('/v4/projects/metadata/projectTypes') .set({ Authorization: `Bearer ${testUtil.jwts.admin}`, }) @@ -170,7 +170,7 @@ describe('CREATE project type', () => { invalidBody.param.key = 'key1'; request(server) - .post('/v4/projectTypes') + .post('/v4/projects/metadata/projectTypes') .set({ Authorization: `Bearer ${testUtil.jwts.admin}`, }) @@ -181,7 +181,7 @@ describe('CREATE project type', () => { it('should return 201 for admin', (done) => { request(server) - .post('/v4/projectTypes') + .post('/v4/projects/metadata/projectTypes') .set({ Authorization: `Bearer ${testUtil.jwts.admin}`, }) @@ -213,7 +213,7 @@ describe('CREATE project type', () => { it('should return 201 for connect admin', (done) => { request(server) - .post('/v4/projectTypes') + .post('/v4/projects/metadata/projectTypes') .set({ Authorization: `Bearer ${testUtil.jwts.connectAdmin}`, }) diff --git a/src/routes/projectTypes/delete.spec.js b/src/routes/projectTypes/delete.spec.js index 26b35994..5b9cee54 100644 --- a/src/routes/projectTypes/delete.spec.js +++ b/src/routes/projectTypes/delete.spec.js @@ -24,7 +24,7 @@ const expectAfterDelete = (key, err, next) => { chai.assert.isNotNull(res.deletedBy); request(server) - .get(`/v4/projectTypes/${key}`) + .get(`/v4/projects/metadata/projectTypes/${key}`) .set({ Authorization: `Bearer ${testUtil.jwts.admin}`, }) @@ -51,16 +51,16 @@ describe('DELETE project type', () => { ); after(testUtil.clearDb); - describe('DELETE /projectTypes/{key}', () => { + describe('DELETE /projects/metadata/projectTypes/{key}', () => { it('should return 403 if user is not authenticated', (done) => { request(server) - .delete(`/v4/projectTypes/${key}`) + .delete(`/v4/projects/metadata/projectTypes/${key}`) .expect(403, done); }); it('should return 403 for member', (done) => { request(server) - .delete(`/v4/projectTypes/${key}`) + .delete(`/v4/projects/metadata/projectTypes/${key}`) .set({ Authorization: `Bearer ${testUtil.jwts.member}`, }) @@ -69,7 +69,7 @@ describe('DELETE project type', () => { it('should return 403 for copilot', (done) => { request(server) - .delete(`/v4/projectTypes/${key}`) + .delete(`/v4/projects/metadata/projectTypes/${key}`) .set({ Authorization: `Bearer ${testUtil.jwts.copilot}`, }) @@ -78,7 +78,7 @@ describe('DELETE project type', () => { it('should return 403 for manager', (done) => { request(server) - .delete(`/v4/projectTypes/${key}`) + .delete(`/v4/projects/metadata/projectTypes/${key}`) .set({ Authorization: `Bearer ${testUtil.jwts.manager}`, }) @@ -87,7 +87,7 @@ describe('DELETE project type', () => { it('should return 404 for non-existed type', (done) => { request(server) - .delete('/v4/projectTypes/not_existed') + .delete('/v4/projects/metadata/projectTypes/not_existed') .set({ Authorization: `Bearer ${testUtil.jwts.admin}`, }) @@ -98,7 +98,7 @@ describe('DELETE project type', () => { models.ProjectType.destroy({ where: { key } }) .then(() => { request(server) - .delete(`/v4/projectTypes/${key}`) + .delete(`/v4/projects/metadata/projectTypes/${key}`) .set({ Authorization: `Bearer ${testUtil.jwts.admin}`, }) @@ -108,7 +108,7 @@ describe('DELETE project type', () => { it('should return 204, for admin, if type was successfully removed', (done) => { request(server) - .delete(`/v4/projectTypes/${key}`) + .delete(`/v4/projects/metadata/projectTypes/${key}`) .set({ Authorization: `Bearer ${testUtil.jwts.admin}`, }) @@ -118,7 +118,7 @@ describe('DELETE project type', () => { it('should return 204, for connect admin, if type was successfully removed', (done) => { request(server) - .delete(`/v4/projectTypes/${key}`) + .delete(`/v4/projects/metadata/projectTypes/${key}`) .set({ Authorization: `Bearer ${testUtil.jwts.connectAdmin}`, }) diff --git a/src/routes/projectTypes/get.spec.js b/src/routes/projectTypes/get.spec.js index eb72604f..099b4c66 100644 --- a/src/routes/projectTypes/get.spec.js +++ b/src/routes/projectTypes/get.spec.js @@ -33,10 +33,10 @@ describe('GET project type', () => { ); after(testUtil.clearDb); - describe('GET /projectTypes/{key}', () => { + describe('GET /projects/metadata/projectTypes/{key}', () => { it('should return 404 for non-existed type', (done) => { request(server) - .get('/v4/projectTypes/1234') + .get('/v4/projects/metadata/projectTypes/1234') .set({ Authorization: `Bearer ${testUtil.jwts.admin}`, }) @@ -47,7 +47,7 @@ describe('GET project type', () => { models.ProjectType.destroy({ where: { key } }) .then(() => { request(server) - .get(`/v4/projectTypes/${key}`) + .get(`/v4/projects/metadata/projectTypes/${key}`) .set({ Authorization: `Bearer ${testUtil.jwts.admin}`, }) @@ -57,7 +57,7 @@ describe('GET project type', () => { it('should return 200 for admin', (done) => { request(server) - .get(`/v4/projectTypes/${key}`) + .get(`/v4/projects/metadata/projectTypes/${key}`) .set({ Authorization: `Bearer ${testUtil.jwts.admin}`, }) @@ -86,13 +86,13 @@ describe('GET project type', () => { it('should return 200 even if user is not authenticated', (done) => { request(server) - .get(`/v4/projectTypes/${key}`) + .get(`/v4/projects/metadata/projectTypes/${key}`) .expect(200, done); }); it('should return 200 for connect admin', (done) => { request(server) - .get(`/v4/projectTypes/${key}`) + .get(`/v4/projects/metadata/projectTypes/${key}`) .set({ Authorization: `Bearer ${testUtil.jwts.connectAdmin}`, }) @@ -102,7 +102,7 @@ describe('GET project type', () => { it('should return 200 for connect manager', (done) => { request(server) - .get(`/v4/projectTypes/${key}`) + .get(`/v4/projects/metadata/projectTypes/${key}`) .set({ Authorization: `Bearer ${testUtil.jwts.manager}`, }) @@ -112,7 +112,7 @@ describe('GET project type', () => { it('should return 200 for member', (done) => { request(server) - .get(`/v4/projectTypes/${key}`) + .get(`/v4/projects/metadata/projectTypes/${key}`) .set({ Authorization: `Bearer ${testUtil.jwts.member}`, }) @@ -121,7 +121,7 @@ describe('GET project type', () => { it('should return 200 for copilot', (done) => { request(server) - .get(`/v4/projectTypes/${key}`) + .get(`/v4/projects/metadata/projectTypes/${key}`) .set({ Authorization: `Bearer ${testUtil.jwts.copilot}`, }) diff --git a/src/routes/projectTypes/list.spec.js b/src/routes/projectTypes/list.spec.js index 991667b5..f941fe02 100644 --- a/src/routes/projectTypes/list.spec.js +++ b/src/routes/projectTypes/list.spec.js @@ -47,10 +47,10 @@ describe('LIST project types', () => { ); after(testUtil.clearDb); - describe('GET /projectTypes', () => { + describe('GET /projects/metadata/projectTypes', () => { it('should return 200 for admin', (done) => { request(server) - .get('/v4/projectTypes') + .get('/v4/projects/metadata/projectTypes') .set({ Authorization: `Bearer ${testUtil.jwts.admin}`, }) @@ -82,13 +82,13 @@ describe('LIST project types', () => { it('should return 200 even if user is not authenticated', (done) => { request(server) - .get('/v4/projectTypes') + .get('/v4/projects/metadata/projectTypes') .expect(200, done); }); it('should return 200 for connect admin', (done) => { request(server) - .get('/v4/projectTypes') + .get('/v4/projects/metadata/projectTypes') .set({ Authorization: `Bearer ${testUtil.jwts.connectAdmin}`, }) @@ -98,7 +98,7 @@ describe('LIST project types', () => { it('should return 200 for connect manager', (done) => { request(server) - .get('/v4/projectTypes') + .get('/v4/projects/metadata/projectTypes') .set({ Authorization: `Bearer ${testUtil.jwts.manager}`, }) @@ -108,7 +108,7 @@ describe('LIST project types', () => { it('should return 200 for member', (done) => { request(server) - .get('/v4/projectTypes') + .get('/v4/projects/metadata/projectTypes') .set({ Authorization: `Bearer ${testUtil.jwts.member}`, }) @@ -117,7 +117,7 @@ describe('LIST project types', () => { it('should return 200 for copilot', (done) => { request(server) - .get('/v4/projectTypes') + .get('/v4/projects/metadata/projectTypes') .set({ Authorization: `Bearer ${testUtil.jwts.copilot}`, }) diff --git a/src/routes/projectTypes/update.spec.js b/src/routes/projectTypes/update.spec.js index 0402020c..229f544a 100644 --- a/src/routes/projectTypes/update.spec.js +++ b/src/routes/projectTypes/update.spec.js @@ -33,7 +33,7 @@ describe('UPDATE project type', () => { ); after(testUtil.clearDb); - describe('PATCH /projectTypes/{key}', () => { + describe('PATCH /projects/metadata/projectTypes/{key}', () => { const body = { param: { displayName: 'displayName 1 - update', @@ -49,14 +49,14 @@ describe('UPDATE project type', () => { it('should return 403 if user is not authenticated', (done) => { request(server) - .patch(`/v4/projectTypes/${key}`) + .patch(`/v4/projects/metadata/projectTypes/${key}`) .send(body) .expect(403, done); }); it('should return 403 for member', (done) => { request(server) - .patch(`/v4/projectTypes/${key}`) + .patch(`/v4/projects/metadata/projectTypes/${key}`) .set({ Authorization: `Bearer ${testUtil.jwts.member}`, }) @@ -66,7 +66,7 @@ describe('UPDATE project type', () => { it('should return 403 for copilot', (done) => { request(server) - .patch(`/v4/projectTypes/${key}`) + .patch(`/v4/projects/metadata/projectTypes/${key}`) .send(body) .set({ Authorization: `Bearer ${testUtil.jwts.copilot}`, @@ -76,7 +76,7 @@ describe('UPDATE project type', () => { it('should return 403 for manager', (done) => { request(server) - .patch(`/v4/projectTypes/${key}`) + .patch(`/v4/projects/metadata/projectTypes/${key}`) .send(body) .set({ Authorization: `Bearer ${testUtil.jwts.manager}`, @@ -86,7 +86,7 @@ describe('UPDATE project type', () => { it('should return 404 for non-existed type', (done) => { request(server) - .patch('/v4/projectTypes/1234') + .patch('/v4/projects/metadata/projectTypes/1234') .set({ Authorization: `Bearer ${testUtil.jwts.admin}`, }) @@ -98,7 +98,7 @@ describe('UPDATE project type', () => { models.ProjectType.destroy({ where: { key } }) .then(() => { request(server) - .patch(`/v4/projectTypes/${key}`) + .patch(`/v4/projects/metadata/projectTypes/${key}`) .set({ Authorization: `Bearer ${testUtil.jwts.admin}`, }) @@ -117,7 +117,7 @@ describe('UPDATE project type', () => { delete partialBody.param.hidden; delete partialBody.param.metadata; request(server) - .patch(`/v4/projectTypes/${key}`) + .patch(`/v4/projects/metadata/projectTypes/${key}`) .set({ Authorization: `Bearer ${testUtil.jwts.admin}`, }) @@ -155,7 +155,7 @@ describe('UPDATE project type', () => { delete partialBody.param.hidden; delete partialBody.param.metadata; request(server) - .patch(`/v4/projectTypes/${key}`) + .patch(`/v4/projects/metadata/projectTypes/${key}`) .set({ Authorization: `Bearer ${testUtil.jwts.admin}`, }) @@ -193,7 +193,7 @@ describe('UPDATE project type', () => { delete partialBody.param.hidden; delete partialBody.param.metadata; request(server) - .patch(`/v4/projectTypes/${key}`) + .patch(`/v4/projects/metadata/projectTypes/${key}`) .set({ Authorization: `Bearer ${testUtil.jwts.admin}`, }) @@ -231,7 +231,7 @@ describe('UPDATE project type', () => { delete partialBody.param.hidden; delete partialBody.param.metadata; request(server) - .patch(`/v4/projectTypes/${key}`) + .patch(`/v4/projects/metadata/projectTypes/${key}`) .set({ Authorization: `Bearer ${testUtil.jwts.admin}`, }) @@ -269,7 +269,7 @@ describe('UPDATE project type', () => { delete partialBody.param.hidden; delete partialBody.param.metadata; request(server) - .patch(`/v4/projectTypes/${key}`) + .patch(`/v4/projects/metadata/projectTypes/${key}`) .set({ Authorization: `Bearer ${testUtil.jwts.admin}`, }) @@ -307,7 +307,7 @@ describe('UPDATE project type', () => { delete partialBody.param.hidden; delete partialBody.param.metadata; request(server) - .patch(`/v4/projectTypes/${key}`) + .patch(`/v4/projects/metadata/projectTypes/${key}`) .set({ Authorization: `Bearer ${testUtil.jwts.admin}`, }) @@ -345,7 +345,7 @@ describe('UPDATE project type', () => { delete partialBody.param.aliases; delete partialBody.param.metadata; request(server) - .patch(`/v4/projectTypes/${key}`) + .patch(`/v4/projects/metadata/projectTypes/${key}`) .set({ Authorization: `Bearer ${testUtil.jwts.admin}`, }) @@ -382,7 +382,7 @@ describe('UPDATE project type', () => { delete partialBody.param.aliases; delete partialBody.param.hidden; request(server) - .patch(`/v4/projectTypes/${key}`) + .patch(`/v4/projects/metadata/projectTypes/${key}`) .set({ Authorization: `Bearer ${testUtil.jwts.admin}`, }) @@ -411,7 +411,7 @@ describe('UPDATE project type', () => { it('should return 200 for admin all fields updated', (done) => { request(server) - .patch(`/v4/projectTypes/${key}`) + .patch(`/v4/projects/metadata/projectTypes/${key}`) .set({ Authorization: `Bearer ${testUtil.jwts.admin}`, }) @@ -440,7 +440,7 @@ describe('UPDATE project type', () => { it('should return 200 for connect admin', (done) => { request(server) - .patch(`/v4/projectTypes/${key}`) + .patch(`/v4/projects/metadata/projectTypes/${key}`) .set({ Authorization: `Bearer ${testUtil.jwts.connectAdmin}`, }) diff --git a/src/routes/projectUpgrade/create.spec.js b/src/routes/projectUpgrade/create.spec.js index 853f508a..41ee7e0b 100644 --- a/src/routes/projectUpgrade/create.spec.js +++ b/src/routes/projectUpgrade/create.spec.js @@ -40,6 +40,8 @@ describe('Project upgrade', () => { }, createdBy: 1, updatedBy: 1, + lastActivityAt: 1, + lastActivityUserId: '1', version: 'v2', directProjectId: 123, estimatedPrice: 15000, diff --git a/src/routes/projects/create.js b/src/routes/projects/create.js index b83fb4ea..2600203b 100644 --- a/src/routes/projects/create.js +++ b/src/routes/projects/create.js @@ -218,6 +218,8 @@ module.exports = [ status: PROJECT_STATUS.DRAFT, createdBy: req.authUser.userId, updatedBy: req.authUser.userId, + lastActivityAt: new Date(), + lastActivityUserId: req.authUser.userId.toString(10), members: [{ isPrimary: true, role: userRole, diff --git a/src/routes/projects/create.spec.js b/src/routes/projects/create.spec.js index 7637cefe..5810401e 100644 --- a/src/routes/projects/create.spec.js +++ b/src/routes/projects/create.spec.js @@ -335,6 +335,9 @@ 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'); + // Check that activity fields are set + resJson.lastActivityUserId.should.be.eql('40051331'); + resJson.lastActivityAt.should.be.not.null; server.services.pubsub.publish.calledWith('project.draft-created').should.be.true; done(); } @@ -462,5 +465,90 @@ describe('Project create', () => { } }); }); + + xit('should return 201 if valid user and data (using Bearer userId_)', (done) => { + const mockHttpClient = _.merge(testUtil.mockHttpClient, { + post: () => Promise.resolve({ + status: 200, + data: { + id: 'requesterId', + version: 'v3', + result: { + success: true, + status: 200, + content: { + projectId: 128, + }, + }, + }, + }), + get: () => Promise.resolve({ + status: 200, + data: { + id: 'requesterId', + version: 'v3', + result: { + success: true, + status: 200, + content: [ + { + id: 1800075, + active: false, + }, + ], + }, + }, + }), + }); + sandbox.stub(util, 'getHttpClient', () => mockHttpClient); + request(server) + .post('/v4/projects') + .set({ + Authorization: 'Bearer userId_1800075', + }) + .send(_.merge({ param: { templateId: 3 } }, body)) + .expect('Content-Type', /json/) + .expect(201) + .end((err, res) => { + if (err) { + server.log.error(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.members.should.have.lengthOf(1); + resJson.members[0].role.should.be.eql('customer'); + resJson.members[0].userId.should.be.eql(1800075); + resJson.members[0].projectId.should.be.eql(resJson.id); + resJson.members[0].isPrimary.should.be.truthy; + resJson.bookmarks.should.have.lengthOf(1); + resJson.bookmarks[0].title.should.be.eql('title1'); + resJson.bookmarks[0].address.should.be.eql('http://www.address.com'); + resJson.phases.should.have.lengthOf(3); + const phases = _.sortBy(resJson.phases, p => p.name); + phases[0].name.should.be.eql('Design Stage'); + phases[0].status.should.be.eql('open'); + phases[0].startDate.should.be.a('string'); + phases[0].duration.should.be.eql(10); + const startDate = moment.utc(phases[0].startDate); + startDate.hours().should.be.eql(0); + startDate.minutes().should.be.eql(0); + startDate.seconds().should.be.eql(0); + startDate.milliseconds().should.be.eql(0); + new Date(phases[0].endDate).should.be.eql(startDate.add(9, 'days').toDate()); + expect(phases[0].details).to.be.empty; + phases[0].products.should.have.lengthOf(1); + phases[0].products[0].name.should.be.eql('product 1'); + 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/delete.spec.js b/src/routes/projects/delete.spec.js index 852d8fb4..0ade73b7 100644 --- a/src/routes/projects/delete.spec.js +++ b/src/routes/projects/delete.spec.js @@ -48,6 +48,8 @@ describe('Project delete test', () => { details: {}, createdBy: 1, updatedBy: 1, + lastActivityAt: 1, + lastActivityUserId: '1', }).then((p) => { project1 = p; // create members diff --git a/src/routes/projects/get.spec.js b/src/routes/projects/get.spec.js index 9fc165ee..af1b4e30 100644 --- a/src/routes/projects/get.spec.js +++ b/src/routes/projects/get.spec.js @@ -25,6 +25,8 @@ describe('GET Project', () => { details: {}, createdBy: 1, updatedBy: 1, + lastActivityAt: 1, + lastActivityUserId: '1', }).then((p) => { project1 = p; // create members @@ -56,6 +58,8 @@ describe('GET Project', () => { details: {}, createdBy: 1, updatedBy: 1, + lastActivityAt: 1, + lastActivityUserId: '1', }).then((p) => { project2 = p; }); @@ -200,7 +204,8 @@ describe('GET Project', () => { spy.should.have.been.calledOnce; resJson.attachments.should.have.lengthOf(1); resJson.attachments[0].filePath.should.equal(attachment.filePath); - resJson.attachments[0].downloadUrl.should.exist; + // downloadUrl no more needed + // resJson.attachments[0].downloadUrl.should.exist; done(); } }); diff --git a/src/routes/projects/list-db.js b/src/routes/projects/list-db.js index a2ce93fa..68b07761 100644 --- a/src/routes/projects/list-db.js +++ b/src/routes/projects/list-db.js @@ -102,6 +102,7 @@ module.exports = [ const sortableProps = [ 'createdAt', 'createdAt asc', 'createdAt desc', 'updatedAt', 'updatedAt asc', 'updatedAt desc', + 'lastActivityAt', 'lastActivityAt asc', 'lastActivityAt desc', 'id', 'id asc', 'id desc', 'status', 'status asc', 'status desc', 'name', 'name asc', 'name desc', diff --git a/src/routes/projects/list-db.spec.js b/src/routes/projects/list-db.spec.js index 2c9560fb..08f4d14c 100644 --- a/src/routes/projects/list-db.spec.js +++ b/src/routes/projects/list-db.spec.js @@ -1,4 +1,5 @@ /* eslint-disable no-unused-expressions */ +/* eslint-disable max-len */ import chai from 'chai'; import request from 'supertest'; @@ -61,6 +62,8 @@ describe('LIST Project db', () => { }, createdBy: 1, updatedBy: 1, + lastActivityAt: 1, + lastActivityUserId: '1', }).then((p) => { project1 = p; // create members @@ -101,6 +104,8 @@ describe('LIST Project db', () => { details: {}, createdBy: 1, updatedBy: 1, + lastActivityAt: 2, + lastActivityUserId: '1', }).then((p) => { project2 = p; return models.ProjectMember.create({ @@ -115,12 +120,14 @@ describe('LIST Project db', () => { const p3 = models.Project.create({ type: 'visual_design', billingAccountId: 1, - name: 'test2', + name: 'test3', description: 'test project3', status: 'reviewed', details: {}, createdBy: 1, updatedBy: 1, + lastActivityAt: 3, + lastActivityUserId: '1', }); return Promise.all([p1, p2, p3]) .then(() => done()); @@ -403,6 +410,72 @@ describe('LIST Project db', () => { } }); }); + + it('should return list of projects ordered ascending by lastActivityAt when sort column is "lastActivityAt"', (done) => { + request(server) + .get('/v4/projects/db/?sort=lastActivityAt') + .set({ + Authorization: `Bearer ${testUtil.jwts.connectAdmin}`, + }) + .expect(200) + .end((err, res) => { + if (err) { + done(err); + } else { + const resJson = res.body.result.content; + should.exist(resJson); + resJson.should.have.lengthOf(3); + resJson[0].name.should.equal('test1'); + resJson[1].name.should.equal('test2'); + resJson[2].name.should.equal('test3'); + done(); + } + }); + }); + + it('should return list of projects ordered descending by lastActivityAt when sort column is "lastActivityAt desc"', (done) => { + request(server) + .get('/v4/projects/db/?sort=lastActivityAt desc') + .set({ + Authorization: `Bearer ${testUtil.jwts.connectAdmin}`, + }) + .expect(200) + .end((err, res) => { + if (err) { + done(err); + } else { + const resJson = res.body.result.content; + should.exist(resJson); + resJson.should.have.lengthOf(3); + resJson[0].name.should.equal('test3'); + resJson[1].name.should.equal('test2'); + resJson[2].name.should.equal('test1'); + done(); + } + }); + }); + + it('should return list of projects ordered ascending by lastActivityAt when sort column is "lastActivityAt asc"', (done) => { + request(server) + .get('/v4/projects/db/?sort=lastActivityAt asc') + .set({ + Authorization: `Bearer ${testUtil.jwts.connectAdmin}`, + }) + .expect(200) + .end((err, res) => { + if (err) { + done(err); + } else { + const resJson = res.body.result.content; + should.exist(resJson); + resJson.should.have.lengthOf(3); + resJson[0].name.should.equal('test1'); + resJson[1].name.should.equal('test2'); + resJson[2].name.should.equal('test3'); + done(); + } + }); + }); }); }); }); diff --git a/src/routes/projects/list.js b/src/routes/projects/list.js index 483cff4d..c890619d 100755 --- a/src/routes/projects/list.js +++ b/src/routes/projects/list.js @@ -285,6 +285,7 @@ module.exports = [ 'best match', 'createdAt', 'createdAt asc', 'createdAt desc', 'updatedAt', 'updatedAt asc', 'updatedAt desc', + 'lastActivityAt', 'lastActivityAt asc', 'lastActivityAt desc', 'id', 'id asc', 'id desc', 'status', 'status asc', 'status desc', 'name', 'name asc', 'name desc', diff --git a/src/routes/projects/list.spec.js b/src/routes/projects/list.spec.js index b69fcf6f..ef1bb7c6 100644 --- a/src/routes/projects/list.spec.js +++ b/src/routes/projects/list.spec.js @@ -1,4 +1,5 @@ /* eslint-disable no-unused-expressions */ +/* eslint-disable max-len */ import chai from 'chai'; import request from 'supertest'; import sleep from 'sleep'; @@ -28,6 +29,8 @@ const data = [ }, createdBy: 1, updatedBy: 1, + lastActivityAt: 1, + lastActivityUserId: '1', members: [ { id: 1, @@ -72,6 +75,8 @@ const data = [ details: {}, createdBy: 1, updatedBy: 1, + lastActivityAt: 2, + lastActivityUserId: '1', members: [ { id: 1, @@ -88,12 +93,14 @@ const data = [ id: 3, type: 'visual_design', billingAccountId: 1, - name: 'test2', + name: 'test3', description: 'test project3', status: 'reviewed', details: {}, createdBy: 1, updatedBy: 1, + lastActivityAt: 3, + lastActivityUserId: '1', }, ]; @@ -118,6 +125,8 @@ describe('LIST Project', () => { }, createdBy: 1, updatedBy: 1, + lastActivityAt: 1, + lastActivityUserId: '1', }).then((p) => { project1 = p; // create members @@ -158,6 +167,8 @@ describe('LIST Project', () => { details: {}, createdBy: 1, updatedBy: 1, + lastActivityAt: 2, + lastActivityUserId: '1', }).then((p) => { project2 = p; return models.ProjectMember.create({ @@ -172,12 +183,14 @@ describe('LIST Project', () => { const p3 = models.Project.create({ type: 'visual_design', billingAccountId: 1, - name: 'test2', + name: 'test3', description: 'test project3', status: 'reviewed', details: {}, createdBy: 1, updatedBy: 1, + lastActivityAt: 3, + lastActivityUserId: '1', }).then((p) => { project3 = p; return Promise.resolve(); @@ -474,6 +487,72 @@ describe('LIST Project', () => { }); }); + it('should return list of projects ordered ascending by lastActivityAt when sort column is "lastActivityAt"', (done) => { + request(server) + .get('/v4/projects/?sort=lastActivityAt') + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .expect(200) + .end((err, res) => { + if (err) { + done(err); + } else { + const resJson = res.body.result.content; + should.exist(resJson); + resJson.should.have.lengthOf(3); + resJson[0].name.should.equal('test1'); + resJson[1].name.should.equal('test2'); + resJson[2].name.should.equal('test3'); + done(); + } + }); + }); + + it('should return list of projects ordered descending by lastActivityAt when sort column is "lastActivityAt desc"', (done) => { + request(server) + .get('/v4/projects/?sort=lastActivityAt desc') + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .expect(200) + .end((err, res) => { + if (err) { + done(err); + } else { + const resJson = res.body.result.content; + should.exist(resJson); + resJson.should.have.lengthOf(3); + resJson[0].name.should.equal('test3'); + resJson[1].name.should.equal('test2'); + resJson[2].name.should.equal('test1'); + done(); + } + }); + }); + + it('should return list of projects ordered ascending by lastActivityAt when sort column is "lastActivityAt asc"', (done) => { + request(server) + .get('/v4/projects/?sort=lastActivityAt asc') + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .expect(200) + .end((err, res) => { + if (err) { + done(err); + } else { + const resJson = res.body.result.content; + should.exist(resJson); + resJson.should.have.lengthOf(3); + resJson[0].name.should.equal('test1'); + resJson[1].name.should.equal('test2'); + resJson[2].name.should.equal('test3'); + done(); + } + }); + }); + describe('GET All /projects/ for Connect Admin, ', () => { it('should return the project ', (done) => { request(server) diff --git a/src/routes/projects/update.spec.js b/src/routes/projects/update.spec.js index 42abf4f0..f1cb51ef 100644 --- a/src/routes/projects/update.spec.js +++ b/src/routes/projects/update.spec.js @@ -8,8 +8,12 @@ import models from '../../models'; import server from '../../app'; import testUtil from '../../tests/util'; import util from '../../util'; + +import busApi from '../../services/busApi'; + import { PROJECT_STATUS, + BUS_API_EVENT, } from '../../constants'; const should = chai.should(); @@ -62,6 +66,8 @@ describe('Project', () => { details: {}, createdBy: 1, updatedBy: 1, + lastActivityAt: 1, + lastActivityUserId: '1', createdAt: '2016-06-30 00:33:07+00', updatedAt: '2016-06-30 00:33:07+00', }, { @@ -73,6 +79,8 @@ describe('Project', () => { details: {}, createdBy: 1, updatedBy: 1, + lastActivityAt: 1, + lastActivityUserId: '1', createdAt: '2016-06-30 00:33:07+00', updatedAt: '2016-06-30 00:33:07+00', }, { @@ -83,6 +91,8 @@ describe('Project', () => { details: {}, createdBy: 1, updatedBy: 1, + lastActivityAt: 1, + lastActivityUserId: '1', createdAt: '2016-06-30 00:33:07+00', updatedAt: '2016-06-30 00:33:07+00', }]) @@ -809,5 +819,254 @@ describe('Project', () => { }); }); }); + + describe('Bus api', () => { + let createEventSpy; + + before((done) => { + // Wait for 500ms in order to wait for createEvent calls from previous tests to complete + testUtil.wait(done); + }); + + beforeEach(() => { + createEventSpy = sandbox.spy(busApi, 'createEvent'); + }); + + it('sends single BUS_API_EVENT.PROJECT_UPDATED message on project status update', (done) => { + request(server) + .patch(`/v4/projects/${project1.id}`) + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .send({ + param: { + status: PROJECT_STATUS.COMPLETED, + }, + }) + .expect(200) + .end((err) => { + if (err) { + done(err); + } else { + testUtil.wait(() => { + createEventSpy.calledTwice.should.be.true; + createEventSpy.firstCall.calledWith(BUS_API_EVENT.PROJECT_COMPLETED); + createEventSpy.secondCall.calledWith(BUS_API_EVENT.PROJECT_UPDATED, sinon.match({ + projectId: project1.id, + projectName: project1.name, + projectUrl: `https://local.topcoder-dev.com/projects/${project1.id}`, + userId: 40051333, + initiatorUserId: 40051333, + })).should.be.true; + done(); + }); + } + }); + }); + + it('sends single BUS_API_EVENT.PROJECT_UPDATED message on project details update', (done) => { + request(server) + .patch(`/v4/projects/${project1.id}`) + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .send({ + param: { + details: { + info: 'something', + }, + }, + }) + .expect(200) + .end((err) => { + if (err) { + done(err); + } else { + testUtil.wait(() => { + createEventSpy.calledTwice.should.be.true; + createEventSpy.firstCall.calledWith(BUS_API_EVENT.PROJECT_SPECIFICATION_MODIFIED); + createEventSpy.secondCall.calledWith(BUS_API_EVENT.PROJECT_UPDATED, sinon.match({ + projectId: project1.id, + projectName: project1.name, + projectUrl: `https://local.topcoder-dev.com/projects/${project1.id}`, + userId: 40051333, + initiatorUserId: 40051333, + })).should.be.true; + done(); + }); + } + }); + }); + + it('sends single BUS_API_EVENT.PROJECT_UPDATED message on project name update', (done) => { + request(server) + .patch(`/v4/projects/${project1.id}`) + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .send({ + param: { + name: 'New project name', + }, + }) + .expect(200) + .end((err) => { + if (err) { + done(err); + } else { + testUtil.wait(() => { + createEventSpy.calledTwice.should.be.true; + createEventSpy.firstCall.calledWith(BUS_API_EVENT.PROJECT_SPECIFICATION_MODIFIED); + createEventSpy.secondCall.calledWith(BUS_API_EVENT.PROJECT_UPDATED, sinon.match({ + projectId: project1.id, + projectName: 'New project name', + projectUrl: `https://local.topcoder-dev.com/projects/${project1.id}`, + userId: 40051333, + initiatorUserId: 40051333, + })).should.be.true; + done(); + }); + } + }); + }); + + it('sends single BUS_API_EVENT.PROJECT_UPDATED message on project description update', (done) => { + request(server) + .patch(`/v4/projects/${project1.id}`) + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .send({ + param: { + description: 'Updated description', + }, + }) + .expect(200) + .end((err) => { + if (err) { + done(err); + } else { + testUtil.wait(() => { + createEventSpy.calledTwice.should.be.true; + createEventSpy.secondCall.calledWith(BUS_API_EVENT.PROJECT_UPDATED, sinon.match({ + projectId: project1.id, + projectName: project1.name, + projectUrl: `https://local.topcoder-dev.com/projects/${project1.id}`, + userId: 40051333, + initiatorUserId: 40051333, + })).should.be.true; + done(); + }); + } + }); + }); + + it('sends single BUS_API_EVENT.PROJECT_UPDATED message on project bookmarks update', (done) => { + request(server) + .patch(`/v4/projects/${project1.id}`) + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .send({ + param: { + bookmarks: [{ + title: 'title1', + address: 'http://someurl.com', + }], + }, + }) + .expect(200) + .end((err) => { + if (err) { + done(err); + } else { + testUtil.wait(() => { + createEventSpy.calledTwice.should.be.true; + createEventSpy.firstCall.calledWith(BUS_API_EVENT.PROJECT_SPECIFICATION_MODIFIED); + createEventSpy.secondCall.calledWith(BUS_API_EVENT.PROJECT_UPDATED, sinon.match({ + projectId: project1.id, + projectName: project1.name, + projectUrl: `https://local.topcoder-dev.com/projects/${project1.id}`, + userId: 40051333, + initiatorUserId: 40051333, + })).should.be.true; + done(); + }); + } + }); + }); + + it('should not send BUS_API_EVENT.PROJECT_UPDATED message when project estimatedPrice is updated', (done) => { + request(server) + .patch(`/v4/projects/${project1.id}`) + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .send({ + param: { + estimatedPrice: 123, + }, + }) + .expect(200) + .end((err) => { + if (err) { + done(err); + } else { + testUtil.wait(() => { + createEventSpy.notCalled.should.be.true; + done(); + }); + } + }); + }); + + it('should not send BUS_API_EVENT.PROJECT_UPDATED message when project actualPrice is updated', (done) => { + request(server) + .patch(`/v4/projects/${project1.id}`) + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .send({ + param: { + actualPrice: 123, + }, + }) + .expect(200) + .end((err) => { + if (err) { + done(err); + } else { + testUtil.wait(() => { + createEventSpy.notCalled.should.be.true; + done(); + }); + } + }); + }); + + it('should not send BUS_API_EVENT.PROJECT_UPDATED message when project terms are updated', (done) => { + request(server) + .patch(`/v4/projects/${project1.id}`) + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .send({ + param: { + terms: [1, 2, 3], + }, + }) + .expect(200) + .end((err) => { + if (err) { + done(err); + } else { + testUtil.wait(() => { + createEventSpy.notCalled.should.be.true; + done(); + }); + } + }); + }); + }); }); }); diff --git a/src/routes/timelines/create.js b/src/routes/timelines/create.js index 7aa53502..44ec10be 100644 --- a/src/routes/timelines/create.js +++ b/src/routes/timelines/create.js @@ -9,7 +9,8 @@ import { middleware as tcMiddleware } from 'tc-core-library-js'; import util from '../../util'; import validateTimeline from '../../middlewares/validateTimeline'; import models from '../../models'; -import { EVENT, TIMELINE_REFERENCES, MILESTONE_STATUS } from '../../constants'; +import { EVENT, TIMELINE_REFERENCES, MILESTONE_STATUS, MILESTONE_TEMPLATE_REFERENCES } + from '../../constants'; const permissions = tcMiddleware.permissions; @@ -59,9 +60,10 @@ module.exports = [ req.log.debug('Checking templateId %d for creating milestones', templateId); if (templateId) { req.log.debug('Found templateId, finding milestone templates for the template'); - return models.ProductMilestoneTemplate.findAll({ + return models.MilestoneTemplate.findAll({ where: { - productTemplateId: templateId, + reference: MILESTONE_TEMPLATE_REFERENCES.PRODUCT_TEMPLATE, + referenceId: templateId, deletedAt: { $eq: null }, }, order: [['order', 'asc']], @@ -83,7 +85,7 @@ module.exports = [ blockedText: mt.blockedText, completedText: mt.completedText, hidden: !!mt.hidden, - details: {}, + details: { metadata: mt.metadata }, status: MILESTONE_STATUS.REVIEWED, startDate: startDate.format(), endDate: endDate.format(), diff --git a/src/routes/timelines/create.spec.js b/src/routes/timelines/create.spec.js index c35d4661..41590c37 100644 --- a/src/routes/timelines/create.spec.js +++ b/src/routes/timelines/create.spec.js @@ -22,6 +22,8 @@ const testProjects = [ details: {}, createdBy: 1, updatedBy: 1, + lastActivityAt: 1, + lastActivityUserId: '1', }, { type: 'generic', @@ -33,6 +35,8 @@ const testProjects = [ createdBy: 2, updatedBy: 2, deletedAt: '2018-05-15T00:00:00Z', + lastActivityAt: 1, + lastActivityUserId: '1', }, ]; @@ -62,7 +66,9 @@ const milestoneTemplates = [ blockedText: 'text to be shown in blocked stage', activeText: 'text to be shown in active stage', completedText: 'text to be shown in completed stage', - productTemplateId: 1, + reference: 'product', + referenceId: 1, + metadata: {}, createdBy: 1, updatedBy: 2, hidden: false, @@ -78,7 +84,9 @@ const milestoneTemplates = [ blockedText: 'text to be shown in blocked stage - 2', activeText: 'text to be shown in active stage - 2', completedText: 'text to be shown in completed stage - 2', - productTemplateId: 1, + reference: 'product', + referenceId: 1, + metadata: {}, createdBy: 2, updatedBy: 3, hidden: false, @@ -94,7 +102,9 @@ const milestoneTemplates = [ blockedText: 'text to be shown in blocked stage - 3', activeText: 'text to be shown in active stage - 3', completedText: 'text to be shown in completed stage - 3', - productTemplateId: 1, + reference: 'product', + referenceId: 1, + metadata: {}, createdBy: 2, updatedBy: 3, hidden: false, @@ -168,7 +178,7 @@ describe('CREATE timeline', () => { }); }) .then(() => models.ProductTemplate.bulkCreate(productTemplates)) - .then(() => models.ProductMilestoneTemplate.bulkCreate(milestoneTemplates)) + .then(() => models.MilestoneTemplate.bulkCreate(milestoneTemplates)) .then(() => { done(); }); diff --git a/src/routes/timelines/delete.spec.js b/src/routes/timelines/delete.spec.js index 44ad6f07..68c505b2 100644 --- a/src/routes/timelines/delete.spec.js +++ b/src/routes/timelines/delete.spec.js @@ -51,6 +51,8 @@ describe('DELETE timeline', () => { details: {}, createdBy: 1, updatedBy: 1, + lastActivityAt: 1, + lastActivityUserId: '1', }, { type: 'generic', @@ -61,6 +63,8 @@ describe('DELETE timeline', () => { details: {}, createdBy: 2, updatedBy: 2, + lastActivityAt: 1, + lastActivityUserId: '1', deletedAt: '2018-05-15T00:00:00Z', }, ]) diff --git a/src/routes/timelines/get.spec.js b/src/routes/timelines/get.spec.js index 253013da..2331295c 100644 --- a/src/routes/timelines/get.spec.js +++ b/src/routes/timelines/get.spec.js @@ -72,6 +72,8 @@ describe('GET timeline', () => { details: {}, createdBy: 1, updatedBy: 1, + lastActivityAt: 1, + lastActivityUserId: '1', }, { type: 'generic', @@ -82,6 +84,8 @@ describe('GET timeline', () => { details: {}, createdBy: 2, updatedBy: 2, + lastActivityAt: 1, + lastActivityUserId: '1', deletedAt: '2018-05-15T00:00:00Z', }, ]) diff --git a/src/routes/timelines/list.spec.js b/src/routes/timelines/list.spec.js index 877141de..2c1395c8 100644 --- a/src/routes/timelines/list.spec.js +++ b/src/routes/timelines/list.spec.js @@ -112,6 +112,8 @@ describe('LIST timelines', () => { details: {}, createdBy: 1, updatedBy: 1, + lastActivityAt: 1, + lastActivityUserId: '1', }, { type: 'generic', @@ -122,6 +124,8 @@ describe('LIST timelines', () => { details: {}, createdBy: 2, updatedBy: 2, + lastActivityAt: 1, + lastActivityUserId: '1', deletedAt: '2018-05-15T00:00:00Z', }, ]) diff --git a/src/routes/timelines/update.js b/src/routes/timelines/update.js index e91783e4..bf403148 100644 --- a/src/routes/timelines/update.js +++ b/src/routes/timelines/update.js @@ -104,6 +104,8 @@ module.exports = [ { original, updated }, { correlationId: req.id }, ); + req.app.emit(EVENT.ROUTING_KEY.TIMELINE_UPDATED, + { req, original, updated }); // Write to response res.json(util.wrapResponse(req.id, updated)); diff --git a/src/routes/timelines/update.spec.js b/src/routes/timelines/update.spec.js index cf00398c..72fffcb3 100644 --- a/src/routes/timelines/update.spec.js +++ b/src/routes/timelines/update.spec.js @@ -1,17 +1,19 @@ +/* eslint-disable no-unused-expressions */ /** * Tests for get.js */ import chai from 'chai'; +import sinon from 'sinon'; import request from 'supertest'; import _ from 'lodash'; import models from '../../models'; import server from '../../app'; import testUtil from '../../tests/util'; -import { EVENT } from '../../constants'; +import { EVENT, BUS_API_EVENT } from '../../constants'; +import busApi from '../../services/busApi'; const should = chai.should(); - const milestones = [ { id: 1, @@ -74,6 +76,8 @@ describe('UPDATE timeline', () => { details: {}, createdBy: 1, updatedBy: 1, + lastActivityAt: 1, + lastActivityUserId: '1', }, { type: 'generic', @@ -84,6 +88,8 @@ describe('UPDATE timeline', () => { details: {}, createdBy: 2, updatedBy: 2, + lastActivityAt: 1, + lastActivityUserId: '1', deletedAt: '2018-05-15T00:00:00Z', }, ], { returning: true }) @@ -623,5 +629,52 @@ describe('UPDATE timeline', () => { .expect(200) .end(done); }); + + describe('Bus api', () => { + let createEventSpy; + const sandbox = sinon.sandbox.create(); + + before((done) => { + // Wait for 500ms in order to wait for createEvent calls from previous tests to complete + testUtil.wait(done); + }); + + beforeEach(() => { + createEventSpy = sandbox.spy(busApi, 'createEvent'); + }); + + afterEach(() => { + sandbox.restore(); + }); + + // not testing fields separately as startDate is required parameter, + // thus TIMELINE_ADJUSTED will be always sent + it('should send message BUS_API_EVENT.TIMELINE_ADJUSTED when timeline updated', (done) => { + request(server) + .patch('/v4/timelines/1') + .set({ + Authorization: `Bearer ${testUtil.jwts.copilot}`, + }) + .send(body) + .expect(200) + .end((err) => { + if (err) { + done(err); + } else { + testUtil.wait(() => { + createEventSpy.calledOnce.should.be.true; + createEventSpy.calledWith(BUS_API_EVENT.TIMELINE_ADJUSTED, sinon.match({ + projectId: 1, + projectName: 'test1', + projectUrl: 'https://local.topcoder-dev.com/projects/1', + userId: 40051332, + initiatorUserId: 40051332, + })).should.be.true; + done(); + }); + } + }); + }); + }); }); }); diff --git a/src/services/busApi.js b/src/services/busApi.js index 5d3aa07d..ddc58656 100644 --- a/src/services/busApi.js +++ b/src/services/busApi.js @@ -43,7 +43,7 @@ async function getClient() { * @return {Promise} new event promise */ function createEvent(topic, payload, logger) { - logger.debug(`Sending message: ${JSON.stringify(payload)}`); + logger.debug(`Sending message to topic ${topic}: ${JSON.stringify(payload)}`); return getClient().then((busClient) => { logger.debug('calling bus-api'); return busClient.post('/bus/events', { diff --git a/src/services/index.js b/src/services/index.js index a1d84c12..017a6ec2 100644 --- a/src/services/index.js +++ b/src/services/index.js @@ -2,6 +2,8 @@ import config from 'config'; import RabbitMQService from './rabbitmq'; +import startKafkaConsumer from './kafkaConsumer'; +import { kafkaHandlers } from '../events'; /** * Responsible for establishing connections to all external services @@ -31,6 +33,10 @@ module.exports = (fapp, logger) => { .then(() => { logger.info('RabbitMQ service initialized'); }) + .then(() => startKafkaConsumer(kafkaHandlers, app, logger)) + .then(() => { + logger.info('Kafka consumer service initialized'); + }) .catch((err) => { logger.error('Error initializing services', err); // gracefulShutdown() diff --git a/src/services/kafkaConsumer.js b/src/services/kafkaConsumer.js new file mode 100644 index 00000000..7ed73787 --- /dev/null +++ b/src/services/kafkaConsumer.js @@ -0,0 +1,66 @@ +import Kafka from 'no-kafka'; +import config from 'config'; +/** + * Initializes Kafka consumer and subscribes for the topics + * @param {Object} handlers Object that holds kafka handlers. Where property name is kafka topic and value is handler + * @param {Object} app Application object + * @param {Object} logger Logger object + * @return {Promise} Promise that got resolved on successful consumer creation + */ +export default async function startKafkaConsumer(handlers, app, logger) { + // Read config and prepare Kafka options object + const kafkaConfig = config.get('kafkaConfig'); + + const options = {}; + if (kafkaConfig.has('groupId')) { + options.groupId = kafkaConfig.get('groupId'); + } + if (kafkaConfig.has('url')) { + options.connectionString = kafkaConfig.get('url'); + } + if (kafkaConfig.has('clientCert') && kafkaConfig.has('clientCertKey')) { + const clientCert = kafkaConfig.get('clientCert').replace('\\n', '\n'); + const clientCertKey = kafkaConfig.get('clientCertKey').replace('\\n', '\n'); + options.ssl = { + cert: clientCert, + key: clientCertKey, + }; + } + + const consumer = new Kafka.SimpleConsumer(options); + await consumer.init(); + + /** + * Function is invoked each time new messages are written to specified topic. + * Calls handler for each message in messageSet. Outputs errors from handler into logger without + * interrupting the application. + * @param {Array} messageSet list of received messages + * @param {String} topic topic where messages are written + * @param {Number} partition partition where messages are written + * @return {Promise} Promise + */ + const onConsume = async (messageSet, topic, partition) => { + messageSet.forEach(async (kafkaMessage) => { + logger.debug(`Consume topic '${topic}' with message: '${kafkaMessage.message.value.toString('utf8')}'.`); + try { + const handler = handlers[topic]; + if (!handler) { + logger.info(`No handler configured for topic: ${topic}`); + return; + } + + const busMessage = JSON.parse(kafkaMessage.message.value.toString('utf8')); + const payload = busMessage.payload; + await handler(app, topic, payload); + await consumer.commitOffset({ topic, partition, offset: kafkaMessage.offset }); + logger.info(`Message for topic '${topic}' was successfully processed`); + } catch (error) { + logger.error(`Message processing failed: ${error}`); + } + }); + }; + + // Subscribe for all topics defined in handlers + const promises = Object.keys(handlers).map(topic => consumer.subscribe(topic, onConsume)); + await Promise.all(promises); +} diff --git a/src/services/kafkaConsumer.spec.js b/src/services/kafkaConsumer.spec.js new file mode 100644 index 00000000..887a6b16 --- /dev/null +++ b/src/services/kafkaConsumer.spec.js @@ -0,0 +1,124 @@ +/* eslint-disable no-unused-expressions */ +import sinon from 'sinon'; +import * as Kafka from 'no-kafka'; +import chai from 'chai'; + +import startKafkaConsumer from './kafkaConsumer'; + +chai.should(); + +describe('Kafka service', () => { + const sandbox = sinon.sandbox.create(); + let mockedSubscribe; + + const mockedLogger = { + debug: sinon.stub(), + info: sinon.stub(), + error: sinon.stub(), + }; + + const mockedApp = { + services: { + pubsub: { + publish: sinon.stub(), + }, + }, + }; + + // Mock Kafka consumer class + before(() => { + mockedSubscribe = sinon.stub(Kafka.SimpleConsumer.prototype, 'subscribe'); + sinon.stub(Kafka.SimpleConsumer.prototype, 'init').returns(Promise.resolve()); + sinon.stub(Kafka.SimpleConsumer.prototype, 'commitOffset').returns(Promise.resolve()); + }); + + afterEach(() => { + sandbox.reset(); + }); + + after(() => { + sandbox.restore(); + }); + + it('subscribes to every topic defined in handlers', async () => { + const handlers = { + topic1: () => {}, + topic2: () => {}, + }; + + await startKafkaConsumer(handlers, mockedApp, mockedLogger); + + mockedSubscribe.calledTwice.should.be.true; + mockedSubscribe.firstCall.calledWith('topic1').should.be.true; + mockedSubscribe.secondCall.calledWith('topic2').should.be.true; + }); + + describe('consumer', () => { + let consumerFunction; + + let handlers; + + beforeEach(async () => { + mockedLogger.error.reset(); + mockedLogger.info.reset(); + + handlers = { + topic1: sinon.stub(), + topic2: sinon.stub(), + }; + await startKafkaConsumer(handlers, mockedApp, mockedLogger); + // Get consumer function + consumerFunction = mockedSubscribe.lastCall.args[1]; + }); + + it('calls handler for specific topic only', async () => { + await consumerFunction([{ + message: { + value: `{ + "payload": { + "prop": "message" + } + }`, + }, + }], 'topic1', {}); + + handlers.topic1.calledOnce.should.be.true; + handlers.topic2.notCalled.should.be.true; + }); + + it('logs error and continues when handler fails', async () => { + handlers.topic2 = sinon.stub().returns(Promise.reject('failure')); + + await consumerFunction([{ + message: { + value: `{ + "payload": { + "prop": "message" + } + }`, + }, + }], 'topic2', {}); + + handlers.topic2.calledOnce.should.be.true; + mockedLogger.error.calledOnce.should.be.true; + mockedLogger.error.calledWith('Message processing failed: failure'); + }); + + it('drops message when handler not found', async () => { + await consumerFunction([{ + message: { + value: `{ + "payload": { + "prop": "message" + } + }`, + }, + }], 'unknown-topic', {}); + + handlers.topic1.notCalled.should.be.true; + handlers.topic2.notCalled.should.be.true; + mockedLogger.info.calledOnce.should.be.true; + mockedLogger.info.calledWith('No handler configured for topic: unknown-topic').should.be.true; + }); + }); +}); diff --git a/src/services/rabbitmq.js b/src/services/rabbitmq.js index f2f9917f..bddcfab7 100644 --- a/src/services/rabbitmq.js +++ b/src/services/rabbitmq.js @@ -1,7 +1,7 @@ /* globals Promise */ import _ from 'lodash'; import amqplib from 'amqplib'; -import handlers from '../events'; +import { rabbitHandlers as handlers } from '../events'; module.exports = class RabbitMQService { diff --git a/src/tests/seed.js b/src/tests/seed.js index ea2ea5fb..5c4f553d 100644 --- a/src/tests/seed.js +++ b/src/tests/seed.js @@ -13,6 +13,8 @@ models.sequelize.sync({ force: true }) details: {}, createdBy: 1, updatedBy: 1, + lastActivityAt: new Date(), + lastActivityUserId: '1', }, { type: 'visual_design', directProjectId: 1, @@ -23,6 +25,8 @@ models.sequelize.sync({ force: true }) details: {}, createdBy: 1, updatedBy: 1, + lastActivityAt: new Date(), + lastActivityUserId: '1', }, { type: 'visual_design', billingAccountId: 3, @@ -32,6 +36,8 @@ models.sequelize.sync({ force: true }) details: {}, createdBy: 1, updatedBy: 1, + lastActivityAt: new Date(), + lastActivityUserId: '1', }, { type: 'generic', billingAccountId: 4, @@ -41,6 +47,8 @@ models.sequelize.sync({ force: true }) details: {}, createdBy: 1, updatedBy: 1, + lastActivityAt: new Date(), + lastActivityUserId: '1', }, { type: 'generic', billingAccountId: 5, @@ -50,6 +58,8 @@ models.sequelize.sync({ force: true }) details: {}, createdBy: 1, updatedBy: 1, + lastActivityAt: new Date(), + lastActivityUserId: '1', }, { type: 'generic', billingAccountId: 5, @@ -69,6 +79,8 @@ models.sequelize.sync({ force: true }) }, createdBy: 1, updatedBy: 1, + lastActivityAt: new Date(), + lastActivityUserId: '1', version: 'v2', directProjectId: 123, estimatedPrice: 15000, @@ -92,6 +104,8 @@ models.sequelize.sync({ force: true }) }, createdBy: 1, updatedBy: 1, + lastActivityAt: new Date(), + lastActivityUserId: '1', version: 'v2', directProjectId: 123, estimatedPrice: 15000, @@ -441,13 +455,30 @@ models.sequelize.sync({ force: true }) }, ], { returning: true })) // Product milestone templates - .then(productTemplates => models.ProductMilestoneTemplate.bulkCreate([ + .then(productTemplates => models.MilestoneTemplate.bulkCreate([ { name: 'milestoneTemplate 1', duration: 3, type: 'type1', order: 1, - productTemplateId: productTemplates[0].id, + reference: 'productTemplate', + referenceId: productTemplates[0].id, + metadata: { + metadata1: { + name: 'metadata 1', + details: { + anyDetails: 'any details 1', + }, + others: ['others 11', 'others 12'], + }, + metadata2: { + name: 'metadata 2', + details: { + anyDetails: 'any details 2', + }, + others: ['others 21', 'others 22'], + }, + }, activeText: 'activeText 1', completedText: 'completedText 1', blockedText: 'blockedText 1', @@ -460,7 +491,9 @@ models.sequelize.sync({ force: true }) duration: 4, type: 'type2', order: 2, - productTemplateId: productTemplates[0].id, + metadata: {}, + reference: 'productTemplate', + referenceId: productTemplates[0].id, activeText: 'activeText 2', completedText: 'completedText 2', blockedText: 'blockedText 2', @@ -584,6 +617,7 @@ models.sequelize.sync({ force: true }) question: 'question 1', info: 'info 1', aliases: ['key-11', 'key_12'], + metadata: {}, }, { key: 'generic', @@ -594,6 +628,7 @@ models.sequelize.sync({ force: true }) question: 'question 2', info: 'info 2', aliases: ['key-21', 'key_22'], + metadata: {}, }, { key: 'visual_prototype', @@ -604,6 +639,7 @@ models.sequelize.sync({ force: true }) question: 'question 3', info: 'info 1', aliases: ['key-31', 'key_32'], + metadata: {}, }, { key: 'visual_design', @@ -614,6 +650,7 @@ models.sequelize.sync({ force: true }) question: 'question 4', info: 'info 4', aliases: ['key-41', 'key_42'], + metadata: {}, }, { key: 'website', @@ -624,6 +661,7 @@ models.sequelize.sync({ force: true }) question: 'question 5', info: 'info 5', aliases: ['key-51', 'key_52'], + metadata: {}, }, { key: 'app', @@ -634,6 +672,7 @@ models.sequelize.sync({ force: true }) question: 'question 6', info: 'info 6', aliases: ['key-61', 'key_62'], + metadata: {}, }, { key: 'quality_assurance', @@ -644,6 +683,7 @@ models.sequelize.sync({ force: true }) question: 'question 7', info: 'info 7', aliases: ['key-71', 'key_72'], + metadata: {}, }, { key: 'chatbot', @@ -654,6 +694,7 @@ models.sequelize.sync({ force: true }) question: 'question 8', info: 'info 8', aliases: ['key-81', 'key_82'], + metadata: {}, }, ])) .then(() => models.ProductCategory.bulkCreate([ diff --git a/src/tests/util.js b/src/tests/util.js index f3dff595..232c553d 100644 --- a/src/tests/util.js +++ b/src/tests/util.js @@ -28,4 +28,7 @@ export default { connectAdmin: 'eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJyb2xlcyI6WyJDb25uZWN0IEFkbWluIl0sImlzcyI6Imh0dHBzOi8vYXBpLnRvcGNvZGVyLWRldi5jb20iLCJoYW5kbGUiOiJjb25uZWN0X2FkbWluMSIsImV4cCI6MjU2MzA3NjY4OSwidXNlcklkIjoiNDAwNTEzMzYiLCJpYXQiOjE0NjMwNzYwODksImVtYWlsIjoiY29ubmVjdF9hZG1pbjFAdG9wY29kZXIuY29tIiwianRpIjoiYjMzYjc3Y2QtYjUyZS00MGZlLTgzN2UtYmViOGUwYWU2YTRhIn0.nSGfXMl02NZ90ZKLiEKPg75iAjU92mfteaY6xgqkM30', }, getDecodedToken: token => jwt.decode(token), + + // Waits for 500ms and executes cb function + wait: cb => setTimeout(cb, 500), }; diff --git a/src/util.js b/src/util.js index 79cecbc3..906f1389 100644 --- a/src/util.js +++ b/src/util.js @@ -17,10 +17,14 @@ import urlencode from 'urlencode'; import elasticsearch from 'elasticsearch'; import Promise from 'bluebird'; import AWS from 'aws-sdk'; + import { ADMIN_ROLES, TOKEN_SCOPES } from './constants'; const exec = require('child_process').exec; const models = require('./models').default; +const tcCoreLibAuth = require('tc-core-library-js').auth; + +const m2m = tcCoreLibAuth.m2m(config); const util = _.cloneDeep(require('tc-core-library-js').util(config)); @@ -244,26 +248,9 @@ _.assignIn(util, { return models.ProjectAttachment.getActiveProjectAttachments(projectId) .then((_attachments) => { // if attachments were requested - if (attachments) { + 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; }); }, @@ -282,6 +269,12 @@ _.assignIn(util, { .then(res => res.data.result.content.token); }, + /** + * Get machine to machine token. + * @returns {Promise} promise which resolves to the m2m token + */ + getM2MToken: () => m2m.getMachineToken(config.AUTH0_CLIENT_ID, config.AUTH0_CLIENT_SECRET), + /** * Fetches the topcoder user details using the given JWT token. * @@ -297,13 +290,19 @@ _.assignIn(util, { httpClient.defaults.headers.common.Accept = 'application/json'; httpClient.defaults.headers.common['Content-Type'] = 'application/json'; 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) { - return response.data.result.content; - } - return null; - }); + return httpClient.get(`${config.identityServiceEndpoint}users`, { + params: { + filter: `id=${userId}`, + }, + }) + .then((response) => { + if (response.data && response.data.result + && response.data.result.status === 200 && response.data.result.content + && response.data.result.content.length === 1) { + return response.data.result.content[0]; + } + return null; + }); }, /** diff --git a/swagger.yaml b/swagger.yaml index b95bbe94..8f081f9f 100755 --- a/swagger.yaml +++ b/swagger.yaml @@ -355,7 +355,7 @@ paths: - name: sort required: false description: | - sort project phases by startDate, endDate, status. Default is startDate asc + sort project phases by startDate, endDate, status, order. Default is startDate asc in: query type: string responses: @@ -373,13 +373,24 @@ paths: operationId: addProjectPhase security: - Bearer: [] - description: Create a project phase + description: Create a project phase. + It also updates the `order` field of all other phases in the same project which have `order` greater than or equal to the `order` specified in the POST body. parameters: - in: body name: body required: true schema: - $ref: '#/definitions/ProjectPhaseBodyParam' + type: object + allOf: + - $ref: '#/definitions/ProjectPhaseBodyParam' + properties: + param: + type: object + properties: + productTemplateId: + type: number + format: long + description: the optional productTemplateId used to populate a new phase product for the created phase responses: '403': description: No permission or wrong token @@ -428,6 +439,7 @@ paths: security: - Bearer: [] description: Update a project phase. All users who can edit project can access this endpoint. + It also updates the `order` field of all other phases in the same project which have `order` greater than or equal to the `order` specified in the POST body. responses: '403': description: No permission or wrong token @@ -647,8 +659,26 @@ paths: description: Project migrated successfully schema: $ref: "#/definitions/ProjectUpgradeResponse" + + /projects/metadata: + get: + tags: + - metadata + operationId: getAllMetadata + security: + - Bearer: [] + description: Retrieve all metadata including projectTemplates, productTemplates, milestoneTemplates, projectTypes, productCategories. All user roles can access this endpoint. + responses: + '200': + description: The metadata + schema: + $ref: "#/definitions/AllMetadataResponse" + '500': + description: Invalid server state or unknown error + schema: + $ref: "#/definitions/ErrorModel" - /projectTemplates: + /projects/metadata/projectTemplates: get: tags: - projectTemplate @@ -692,7 +722,7 @@ paths: schema: $ref: "#/definitions/ErrorModel" - /projectTemplates/{templateId}: + /projects/metadata/projectTemplates/{templateId}: get: tags: - projectTemplate @@ -774,7 +804,7 @@ paths: description: Project template successfully removed - /productTemplates: + /projects/metadata/productTemplates: get: tags: - productTemplate @@ -818,7 +848,7 @@ paths: schema: $ref: "#/definitions/ErrorModel" - /productTemplates/{templateId}: + /projects/metadata/productTemplates/{templateId}: get: tags: - productTemplate @@ -900,7 +930,7 @@ paths: description: Product template successfully removed - /productCategories: + /projects/metadata/productCategories: get: tags: - productCategory @@ -944,7 +974,7 @@ paths: schema: $ref: "#/definitions/ErrorModel" - /productCategories/{key}: + /projects/metadata/productCategories/{key}: get: tags: - productCategory @@ -1023,7 +1053,7 @@ paths: description: Product category successfully removed - /projectTypes: + /projects/metadata/projectTypes: get: tags: - projectType @@ -1067,7 +1097,7 @@ paths: schema: $ref: "#/definitions/ErrorModel" - /projectTypes/{key}: + /projects/metadata/projectTypes/{key}: get: tags: - projectType @@ -1438,12 +1468,10 @@ paths: description: Milestone successfully removed - /productTemplates/{productTemplateId}/milestones: - parameters: - - $ref: "#/parameters/productTemplateIdParam" + /timelines/metadata/milestoneTemplates: get: tags: - - productMilestoneTemplate + - milestoneTemplates operationId: findMilestoneTemplates security: - Bearer: [] @@ -1454,6 +1482,14 @@ paths: description: sort by `order`. Default is `order asc` in: query type: string + - name: filter + required: false + type: string + in: query + description: | + Url encoded list of supported filters + - reference + - referenceId responses: '403': description: No permission or wrong token @@ -1469,7 +1505,7 @@ paths: $ref: "#/definitions/MilestoneTemplateListResponse" post: tags: - - productMilestoneTemplate + - milestoneTemplates operationId: addMilestoneTemplate security: - Bearer: [] @@ -1494,12 +1530,10 @@ paths: schema: $ref: "#/definitions/ErrorModel" - /productTemplates/{productTemplateId}/milestones/clone: - parameters: - - $ref: "#/parameters/productTemplateIdParam" + /timelines/metadata/milestoneTemplates/clone: post: tags: - - productMilestoneTemplate + - milestoneTemplates operationId: cloneMilestoneTemplate security: - Bearer: [] @@ -1529,13 +1563,12 @@ paths: $ref: "#/definitions/ErrorModel" - /productTemplates/{productTemplateId}/milestones/{milestoneTemplateId}: + /timelines/metadata/milestoneTemplates/{milestoneTemplateId}: parameters: - - $ref: "#/parameters/productTemplateIdParam" - $ref: "#/parameters/milestoneTemplateIdParam" get: tags: - - productMilestoneTemplate + - milestoneTemplates description: Retrieve milestone template by id. All user roles can access this endpoint. security: - Bearer: [] @@ -1560,7 +1593,7 @@ paths: patch: tags: - - productMilestoneTemplate + - milestoneTemplates operationId: updateMilestoneTemplate security: - Bearer: [] @@ -1595,7 +1628,7 @@ paths: delete: tags: - - productMilestoneTemplate + - milestoneTemplates description: Remove an existing milestone template. Only connect manager, connect admin, and admin can access this endpoint. security: - Bearer: [] @@ -2613,6 +2646,10 @@ definitions: details: type: object description: the project phase details + order: + type: number + format: integer + description: the project phase order ProjectPhaseBodyParam: title: Project phase body param @@ -3427,6 +3464,9 @@ definitions: - duration - type - order + - reference + - referenceId + - metadata properties: name: type: string @@ -3445,6 +3485,19 @@ definitions: type: number format: integer description: the milestone template order + reference: + type: string + enum: + - productTemplate + description: the milestone template reference + refereneceId: + type: number + format: long + minimum: 1 + description: the milestone template reference id + metadata: + type: object + description: the milestone template metadata MilestoneTemplateBodyParam: title: Milestone template body param @@ -3459,12 +3512,31 @@ definitions: title: Milestone clone template request object type: object required: - - sourceTemplateId + - sourceReference + - sourceReferenceId + - reference + - referenceId properties: - sourceTemplateId: + sourceReference: + type: string + enum: + - productTemplate + description: the source reference to clone the milestone templates from + sourceReferenceId: type: number - format: integer - description: the product template id where to clone the milestone templates from + format: long + minimum: 1 + description: the source reference id to clone the milestone templates from + reference: + type: string + enum: + - productTemplate + description: the target reference to clone the milestone templates to + refereneceId: + type: number + format: long + minimum: 1 + description: the target reference id to clone the milestone templates to MilestoneTemplate: title: Milestone template object @@ -3547,3 +3619,48 @@ definitions: type: array items: $ref: "#/definitions/MilestoneTemplate" + + AllMetadataResponse: + title: All metadata response object + type: object + properties: + id: + type: string + readOnly: true + description: unique id identifying the request + version: + type: string + result: + type: object + properties: + success: + type: boolean + status: + type: string + description: http status code + metadata: + $ref: "#/definitions/ResponseMetadata" + content: + type: object + properties: + projectTemplates: + type: array + items: + $ref: "#/definitions/ProjectTemplate" + productTemplates: + type: array + items: + $ref: "#/definitions/ProductTemplate" + milestoneTemplates: + type: array + items: + $ref: "#/definitions/MilestoneTemplate" + projectTypes: + type: array + items: + $ref: "#/definitions/ProjectType" + productCategories: + type: array + items: + $ref: "#/definitions/ProductCategory" + \ No newline at end of file