diff --git a/.dockerignore b/.dockerignore index 543017905..666561620 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,5 +1,6 @@ +.git .gitignore *.md -scripts *.yml -initialData +scripts +.snyk \ No newline at end of file diff --git a/.drone.yml b/.drone.yml index 02568b05f..f51e444d3 100644 --- a/.drone.yml +++ b/.drone.yml @@ -15,39 +15,38 @@ pipeline: - yarn install linting: - image: node:8-slim + image: erxes/runner:latest commands: - yarn lint typescript: - image: node:8-slim + image: erxes/runner:latest commands: - yarn tsc testing: - image: node:8-slim + image: erxes/runner:latest environment: - TEST_MONGO_URL=mongodb://mongo/test commands: + - mkdir src/private/xlsTemplateOutputs - yarn test - publish: - image: plugins/docker - repo: goharbor.erxes.io/${DRONE_REPO_OWNER}/${DRONE_REPO_NAME} - registry: goharbor.erxes.io - dockerfile: Dockerfile.local - secrets: [ docker_username, docker_password ] - tags: - - ${DRONE_BRANCH}-latest + build: + image: erxes/runner:latest + commands: + - yarn build + - cp .env.sample .env when: - branch: [ develop ] + branch: [master, develop] event: push status: success docker: image: plugins/docker repo: ${DRONE_REPO_OWNER}/${DRONE_REPO_NAME} - dockerfile: Dockerfile.local + dockerfile: Dockerfile + custom_dns: 8.8.8.8 secrets: - source: docker_hub_username target: docker_username @@ -56,11 +55,11 @@ pipeline: tags: - ${DRONE_BRANCH}-latest when: - branch: [ develop, master ] + branch: [master, develop] event: push status: success services: mongo: - image: mongo:3.4.4 - command: [ --smallfiles ] + image: mongo:3.6 + command: [--smallfiles] diff --git a/.env.sample b/.env.sample index 9f4a373e4..fd4712d6c 100644 --- a/.env.sample +++ b/.env.sample @@ -1,66 +1,66 @@ -NODE_ENV=development -PORT=3300 - +# MongoDB MONGO_URL=mongodb://localhost/erxes +TEST_MONGO_URL=mongodb://localhost/test # Set this variables as True if you are using replication set # in MONGO_URL which means Collection.watch method works as # expected and can send notification USE_REPLICATION=false -TEST_MONGO_URL=mongodb://localhost/test - +# Redis REDIS_HOST=localhost REDIS_PORT=6379 REDIS_PASSWORD= +# erxes-api HTTPS=false - MAIN_APP_DOMAIN=http://localhost:3000 - # Using this config to upload file directly from widgets in cors origins WIDGETS_DOMAIN=http://localhost:3200 - DOMAIN=http://localhost:3300 +MAX_IMPORT_SIZE=600 +NODE_ENV=development +PORT=3300 +# Email COMPANY_EMAIL_FROM=noreply@erxes.io - DEFAULT_EMAIL_SERVICE=sendgrid MAIL_SERVICE=sendgrid MAIL_PORT='' MAIL_USER='' MAIL_PASS='' +MAIL_HOST='' +# Twitter TWITTER_CONSUMER_KEY='' TWITTER_CONSUMER_SECRET='' TWITTER_REDIRECT_URL='' +# Aws S3 AWS_ACCESS_KEY_ID='' AWS_SECRET_ACCESS_KEY='' AWS_BUCKET='' AWS_PREFIX='' +# Aws SES AWS_SES_ACCESS_KEY_ID='' AWS_SES_SECRET_ACCESS_KEY='' AWS_SES_CONFIG_SET='' - AWS_REGION='' AWS_ENDPOINT='' +# Facebook FACEBOOK_APP_ID='' FACEBOOK_APP_SECRET='' FACEBOOK_PERMISSIONS='manage_pages, pages_show_list, pages_messaging, publish_pages, pages_messaging_phone_number, pages_messaging_subscriptions' -MAX_IMPORT_SIZE=600 - +# Gmail GOOGLE_CLIENT_ID='' GOOGLE_CLIENT_SECRET='' -GOOGLE_REDIRECT_URI='' GOOGLE_APPLICATION_CREDENTIALS='' GOOGLE_TOPIC='' -GOOGLE_SUPSCRIPTION_NAME='' +GOOGLE_SUBSCRIPTION_NAME='' GOOGLE_PROJECT_ID='' - GMAIL_REDIRECT_URL = http://localhost:3300/ ERXES_PATH='' diff --git a/.gitignore b/.gitignore index 1921f661b..3d4eb1841 100755 --- a/.gitignore +++ b/.gitignore @@ -10,4 +10,4 @@ dump.rdb *.un~ *.swp *.swo -.migrate \ No newline at end of file +.migrate diff --git a/.prettierrc b/.prettierrc new file mode 100644 index 000000000..6a8ebca5e --- /dev/null +++ b/.prettierrc @@ -0,0 +1,5 @@ +{ + "printWidth": 120, + "singleQuote": true, + "trailingComma": "all" +} \ No newline at end of file diff --git a/Dockerfile b/Dockerfile index 45d6f29a9..321ac9a4a 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,19 +1,6 @@ -FROM erxes/runner:latest as build-deps +FROM erxes/runner:latest WORKDIR /erxes-api/ -COPY package.json yarn.lock ./ -RUN yarn -COPY . . -RUN cp .env.sample .env -RUN yarn build -RUN mkdir /erxes-api/prod -RUN rsync -a /erxes-api/dist /erxes-api/prod/ && \ - rsync -a /erxes-api/node_modules /erxes-api/prod/ && \ - rsync /erxes-api/package.json /erxes-api/prod/ && \ - rsync /erxes-api/.env.sample /erxes-api/prod/.env - -FROM node:8-slim -WORKDIR /erxes-api/ -COPY --from=build-deps /erxes-api/prod /erxes-api +COPY . /erxes-api RUN chown -R node:node /erxes-api USER node EXPOSE 3300 diff --git a/Dockerfile.dev b/Dockerfile.dev new file mode 100644 index 000000000..bf2ba66e3 --- /dev/null +++ b/Dockerfile.dev @@ -0,0 +1,5 @@ +FROM erxes/runner +WORKDIR /erxes-api +COPY yarn.lock package.json ./ +RUN yarn install +CMD ["yarn", "dev"] diff --git a/Dockerfile.local b/Dockerfile.local deleted file mode 100644 index 33f12b5b0..000000000 --- a/Dockerfile.local +++ /dev/null @@ -1,13 +0,0 @@ -FROM erxes/runner:latest -WORKDIR /erxes-api/ -COPY package.json yarn.lock ./ -RUN yarn -COPY . . -RUN cp .env.sample .env -RUN yarn build - -RUN chown -R node:node /erxes-api -USER node -EXPOSE 3300 -#CMD ["yarn", "start"] -ENTRYPOINT [ "sh", "/erxes-api/start.sh" ] diff --git a/initialData/activity_logs.metadata.json b/initialData/activity_logs.metadata.json index ca64f833b..1b1c5283f 100644 --- a/initialData/activity_logs.metadata.json +++ b/initialData/activity_logs.metadata.json @@ -1 +1 @@ -{"options":{},"indexes":[{"v":2,"key":{"_id":1},"name":"_id_","ns":"erxes.activity_logs"}]} \ No newline at end of file +{"options":{},"indexes":[{"v":2,"key":{"_id":1},"name":"_id_","ns":"erxes.activity_logs"}],"uuid":"475cbddb84ce4a8b90d3cfad816f01db"} \ No newline at end of file diff --git a/initialData/brands.metadata.json b/initialData/brands.metadata.json index 9b28a27c6..256dd48ba 100644 --- a/initialData/brands.metadata.json +++ b/initialData/brands.metadata.json @@ -1 +1 @@ -{"options":{},"indexes":[{"v":2,"key":{"_id":1},"name":"_id_","ns":"erxes.brands"}]} \ No newline at end of file +{"options":{},"indexes":[{"v":2,"key":{"_id":1},"name":"_id_","ns":"erxes.brands"}],"uuid":"f2489825947d47918b5c80dafc344e48"} \ No newline at end of file diff --git a/initialData/channels.metadata.json b/initialData/channels.metadata.json index ebf12e281..947d963b2 100644 --- a/initialData/channels.metadata.json +++ b/initialData/channels.metadata.json @@ -1 +1 @@ -{"options":{},"indexes":[{"v":2,"key":{"_id":1},"name":"_id_","ns":"erxes.channels"}]} \ No newline at end of file +{"options":{},"indexes":[{"v":2,"key":{"_id":1},"name":"_id_","ns":"erxes.channels"}],"uuid":"c791f01cfe5f40e3991a47f391861e6a"} \ No newline at end of file diff --git a/initialData/companies.metadata.json b/initialData/companies.metadata.json index 38bcbddfa..4870a43fe 100644 --- a/initialData/companies.metadata.json +++ b/initialData/companies.metadata.json @@ -1 +1 @@ -{"options":{},"indexes":[{"v":2,"key":{"_id":1},"name":"_id_","ns":"erxes.companies"}]} \ No newline at end of file +{"options":{},"indexes":[{"v":2,"key":{"_id":1},"name":"_id_","ns":"erxes.companies"}],"uuid":"21e6f9d86b414f539bbde07758f6add2"} \ No newline at end of file diff --git a/initialData/configs.metadata.json b/initialData/configs.metadata.json index 99b3344f9..165763076 100644 --- a/initialData/configs.metadata.json +++ b/initialData/configs.metadata.json @@ -1 +1 @@ -{"options":{},"indexes":[{"v":2,"key":{"_id":1},"name":"_id_","ns":"erxes.configs"}]} \ No newline at end of file +{"options":{},"indexes":[{"v":2,"key":{"_id":1},"name":"_id_","ns":"erxes.configs"}],"uuid":"5f2010fddb0b43c3a9cd5065b4e1dfe4"} \ No newline at end of file diff --git a/initialData/conversation_messages.metadata.json b/initialData/conversation_messages.metadata.json index f5064d2dd..352c1d2af 100644 --- a/initialData/conversation_messages.metadata.json +++ b/initialData/conversation_messages.metadata.json @@ -1 +1 @@ -{"options":{},"indexes":[{"v":2,"key":{"_id":1},"name":"_id_","ns":"erxes.conversation_messages"}]} \ No newline at end of file +{"options":{},"indexes":[{"v":2,"key":{"_id":1},"name":"_id_","ns":"erxes.conversation_messages"},{"v":2,"key":{"conversationId":1},"name":"conversationId_1","ns":"erxes.conversation_messages","background":true},{"v":2,"key":{"createdAt":1},"name":"createdAt_1","ns":"erxes.conversation_messages","background":true}],"uuid":"7810722d091640058f889b497ecbc306"} \ No newline at end of file diff --git a/initialData/conversations.metadata.json b/initialData/conversations.metadata.json index 3f43b1856..158427e33 100644 --- a/initialData/conversations.metadata.json +++ b/initialData/conversations.metadata.json @@ -1 +1 @@ -{"options":{},"indexes":[{"v":2,"key":{"_id":1},"name":"_id_","ns":"erxes.conversations"}]} \ No newline at end of file +{"options":{},"indexes":[{"v":2,"key":{"_id":1},"name":"_id_","ns":"erxes.conversations"},{"v":2,"key":{"createdAt":1},"name":"createdAt_1","ns":"erxes.conversations","background":true}],"uuid":"873a980e2ce040d8b357dfb880c90a96"} \ No newline at end of file diff --git a/initialData/customers.metadata.json b/initialData/customers.metadata.json index ca0f3bdb4..cbe2e38ea 100644 --- a/initialData/customers.metadata.json +++ b/initialData/customers.metadata.json @@ -1 +1 @@ -{"options":{},"indexes":[{"v":2,"key":{"_id":1},"name":"_id_","ns":"erxes.customers"}]} \ No newline at end of file +{"options":{},"indexes":[{"v":2,"key":{"_id":1},"name":"_id_","ns":"erxes.customers"}],"uuid":"d0a7475bf3a041919474eab4418ad40f"} \ No newline at end of file diff --git a/initialData/deal_boards.metadata.json b/initialData/deal_boards.metadata.json index 5215159b5..ac6de24a8 100644 --- a/initialData/deal_boards.metadata.json +++ b/initialData/deal_boards.metadata.json @@ -1 +1 @@ -{"options":{},"indexes":[{"v":2,"key":{"_id":1},"name":"_id_","ns":"erxes.deal_boards"}]} \ No newline at end of file +{"options":{},"indexes":[{"v":2,"key":{"_id":1},"name":"_id_","ns":"erxes.deal_boards"}],"uuid":"3c54e2fe8ebe43229f138ab0ec4d99fa"} \ No newline at end of file diff --git a/initialData/deal_pipelines.metadata.json b/initialData/deal_pipelines.metadata.json index 99fe537f6..ad93f0718 100644 --- a/initialData/deal_pipelines.metadata.json +++ b/initialData/deal_pipelines.metadata.json @@ -1 +1 @@ -{"options":{},"indexes":[{"v":2,"key":{"_id":1},"name":"_id_","ns":"erxes.deal_pipelines"}]} \ No newline at end of file +{"options":{},"indexes":[{"v":2,"key":{"_id":1},"name":"_id_","ns":"erxes.deal_pipelines"}],"uuid":"69cf853096134ea9ae6dc334fb5fe962"} \ No newline at end of file diff --git a/initialData/deal_stages.metadata.json b/initialData/deal_stages.metadata.json index ad33bc57f..fb7ad50fc 100644 --- a/initialData/deal_stages.metadata.json +++ b/initialData/deal_stages.metadata.json @@ -1 +1 @@ -{"options":{},"indexes":[{"v":2,"key":{"_id":1},"name":"_id_","ns":"erxes.deal_stages"}]} \ No newline at end of file +{"options":{},"indexes":[{"v":2,"key":{"_id":1},"name":"_id_","ns":"erxes.deal_stages"}],"uuid":"2bf45d537f5c4470aa301624fa9d1eae"} \ No newline at end of file diff --git a/initialData/deals.metadata.json b/initialData/deals.metadata.json index 8239a9ac5..5a3c52537 100644 --- a/initialData/deals.metadata.json +++ b/initialData/deals.metadata.json @@ -1 +1 @@ -{"options":{},"indexes":[{"v":2,"key":{"_id":1},"name":"_id_","ns":"erxes.deals"}]} \ No newline at end of file +{"options":{},"indexes":[{"v":2,"key":{"_id":1},"name":"_id_","ns":"erxes.deals"}],"uuid":"21ee5a02d35a4dab9bc9d710a68adb38"} \ No newline at end of file diff --git a/initialData/email_templates.metadata.json b/initialData/email_templates.metadata.json index 48f70ca58..7750e679f 100644 --- a/initialData/email_templates.metadata.json +++ b/initialData/email_templates.metadata.json @@ -1 +1 @@ -{"options":{},"indexes":[{"v":2,"key":{"_id":1},"name":"_id_","ns":"erxes.email_templates"}]} \ No newline at end of file +{"options":{},"indexes":[{"v":2,"key":{"_id":1},"name":"_id_","ns":"erxes.email_templates"}],"uuid":"8c3a4f0bddad459986221fd8fede82dd"} \ No newline at end of file diff --git a/initialData/engage_messages.metadata.json b/initialData/engage_messages.metadata.json index 9c9a5a702..ce3cd3b1d 100644 --- a/initialData/engage_messages.metadata.json +++ b/initialData/engage_messages.metadata.json @@ -1 +1 @@ -{"options":{},"indexes":[{"v":2,"key":{"_id":1},"name":"_id_","ns":"erxes.engage_messages"}]} \ No newline at end of file +{"options":{},"indexes":[{"v":2,"key":{"_id":1},"name":"_id_","ns":"erxes.engage_messages"}],"uuid":"6d9f9dc037294135a32f282fedcfffce"} \ No newline at end of file diff --git a/initialData/fields.metadata.json b/initialData/fields.metadata.json index 3c32548f5..af1cf233d 100644 --- a/initialData/fields.metadata.json +++ b/initialData/fields.metadata.json @@ -1 +1 @@ -{"options":{},"indexes":[{"v":2,"key":{"_id":1},"name":"_id_","ns":"erxes.fields"}]} \ No newline at end of file +{"options":{},"indexes":[{"v":2,"key":{"_id":1},"name":"_id_","ns":"erxes.fields"}],"uuid":"ad91f4aa9e8446df97087fb789a2cff4"} \ No newline at end of file diff --git a/initialData/forms.metadata.json b/initialData/forms.metadata.json index 5ad9e41b5..457745515 100644 --- a/initialData/forms.metadata.json +++ b/initialData/forms.metadata.json @@ -1 +1 @@ -{"options":{},"indexes":[{"v":2,"key":{"_id":1},"name":"_id_","ns":"erxes.forms"}]} \ No newline at end of file +{"options":{},"indexes":[{"v":2,"key":{"_id":1},"name":"_id_","ns":"erxes.forms"}],"uuid":"826e52fceb964791bcce7951896572cb"} \ No newline at end of file diff --git a/initialData/integrations.metadata.json b/initialData/integrations.metadata.json index 9406ed2b5..b899f360c 100644 --- a/initialData/integrations.metadata.json +++ b/initialData/integrations.metadata.json @@ -1 +1 @@ -{"options":{},"indexes":[{"v":2,"key":{"_id":1},"name":"_id_","ns":"erxes.integrations"}]} \ No newline at end of file +{"options":{},"indexes":[{"v":2,"key":{"_id":1},"name":"_id_","ns":"erxes.integrations"}],"uuid":"ccec122f943f4d4489c2a114014f8231"} \ No newline at end of file diff --git a/initialData/internal_notes.metadata.json b/initialData/internal_notes.metadata.json index b7cc97e6d..d079d1317 100644 --- a/initialData/internal_notes.metadata.json +++ b/initialData/internal_notes.metadata.json @@ -1 +1 @@ -{"options":{},"indexes":[{"v":2,"key":{"_id":1},"name":"_id_","ns":"erxes.internal_notes"}]} \ No newline at end of file +{"options":{},"indexes":[{"v":2,"key":{"_id":1},"name":"_id_","ns":"erxes.internal_notes"}],"uuid":"76a399333a644d6b88d791b7f8b960e4"} \ No newline at end of file diff --git a/initialData/knowledgebase_articles.metadata.json b/initialData/knowledgebase_articles.metadata.json index 35d69d145..a05b9b038 100644 --- a/initialData/knowledgebase_articles.metadata.json +++ b/initialData/knowledgebase_articles.metadata.json @@ -1 +1 @@ -{"options":{},"indexes":[{"v":2,"key":{"_id":1},"name":"_id_","ns":"erxes.knowledgebase_articles"}]} \ No newline at end of file +{"options":{},"indexes":[{"v":2,"key":{"_id":1},"name":"_id_","ns":"erxes.knowledgebase_articles"}],"uuid":"0665ecf554904625a12158f7fdab02d5"} \ No newline at end of file diff --git a/initialData/knowledgebase_categories.metadata.json b/initialData/knowledgebase_categories.metadata.json index b2e8788f8..d5838a714 100644 --- a/initialData/knowledgebase_categories.metadata.json +++ b/initialData/knowledgebase_categories.metadata.json @@ -1 +1 @@ -{"options":{},"indexes":[{"v":2,"key":{"_id":1},"name":"_id_","ns":"erxes.knowledgebase_categories"}]} \ No newline at end of file +{"options":{},"indexes":[{"v":2,"key":{"_id":1},"name":"_id_","ns":"erxes.knowledgebase_categories"}],"uuid":"2577052f5c794711943a68baa5500818"} \ No newline at end of file diff --git a/initialData/knowledgebase_topics.metadata.json b/initialData/knowledgebase_topics.metadata.json index 1d62d767d..38e3a737a 100644 --- a/initialData/knowledgebase_topics.metadata.json +++ b/initialData/knowledgebase_topics.metadata.json @@ -1 +1 @@ -{"options":{},"indexes":[{"v":2,"key":{"_id":1},"name":"_id_","ns":"erxes.knowledgebase_topics"}]} \ No newline at end of file +{"options":{},"indexes":[{"v":2,"key":{"_id":1},"name":"_id_","ns":"erxes.knowledgebase_topics"}],"uuid":"426a2e6a6560474db3f9588d587788a7"} \ No newline at end of file diff --git a/initialData/permissions.bson b/initialData/permissions.bson new file mode 100644 index 000000000..6f0625e85 Binary files /dev/null and b/initialData/permissions.bson differ diff --git a/initialData/permissions.metadata.json b/initialData/permissions.metadata.json new file mode 100644 index 000000000..baa3aa5d0 --- /dev/null +++ b/initialData/permissions.metadata.json @@ -0,0 +1 @@ +{"options":{},"indexes":[{"v":2,"key":{"_id":1},"name":"_id_","ns":"erxes.permissions"}],"uuid":"eac83e42ddfe49e995638ae9bd3e68e0"} \ No newline at end of file diff --git a/initialData/products.metadata.json b/initialData/products.metadata.json index 0d7b77d1d..c2c18c9a0 100644 --- a/initialData/products.metadata.json +++ b/initialData/products.metadata.json @@ -1 +1 @@ -{"options":{},"indexes":[{"v":2,"key":{"_id":1},"name":"_id_","ns":"erxes.products"}]} \ No newline at end of file +{"options":{},"indexes":[{"v":2,"key":{"_id":1},"name":"_id_","ns":"erxes.products"}],"uuid":"7f80bf5ae8684c2bb2d66fd70d465137"} \ No newline at end of file diff --git a/initialData/response_templates.metadata.json b/initialData/response_templates.metadata.json index 47ec102e4..3c1cba508 100644 --- a/initialData/response_templates.metadata.json +++ b/initialData/response_templates.metadata.json @@ -1 +1 @@ -{"options":{},"indexes":[{"v":2,"key":{"_id":1},"name":"_id_","ns":"erxes.response_templates"}]} \ No newline at end of file +{"options":{},"indexes":[{"v":2,"key":{"_id":1},"name":"_id_","ns":"erxes.response_templates"}],"uuid":"8f3c68dbd321479da147a5882af9f186"} \ No newline at end of file diff --git a/initialData/segments.metadata.json b/initialData/segments.metadata.json index a50b956bd..31fdfafde 100644 --- a/initialData/segments.metadata.json +++ b/initialData/segments.metadata.json @@ -1 +1 @@ -{"options":{},"indexes":[{"v":2,"key":{"_id":1},"name":"_id_","ns":"erxes.segments"}]} \ No newline at end of file +{"options":{},"indexes":[{"v":2,"key":{"_id":1},"name":"_id_","ns":"erxes.segments"}],"uuid":"df6562c4c46b40d4848251ab9ae6f693"} \ No newline at end of file diff --git a/initialData/tags.metadata.json b/initialData/tags.metadata.json index 96957f38e..fb73c3940 100644 --- a/initialData/tags.metadata.json +++ b/initialData/tags.metadata.json @@ -1 +1 @@ -{"options":{},"indexes":[{"v":2,"key":{"_id":1},"name":"_id_","ns":"erxes.tags"}]} \ No newline at end of file +{"options":{},"indexes":[{"v":2,"key":{"_id":1},"name":"_id_","ns":"erxes.tags"}],"uuid":"4dcf8eb86254449db489394e0599011e"} \ No newline at end of file diff --git a/initialData/user_groups.bson b/initialData/user_groups.bson new file mode 100644 index 000000000..b429fb9d2 Binary files /dev/null and b/initialData/user_groups.bson differ diff --git a/initialData/user_groups.metadata.json b/initialData/user_groups.metadata.json new file mode 100644 index 000000000..1594ea523 --- /dev/null +++ b/initialData/user_groups.metadata.json @@ -0,0 +1 @@ +{"options":{},"indexes":[{"v":2,"key":{"_id":1},"name":"_id_","ns":"erxes.user_groups"},{"v":2,"unique":true,"key":{"name":1},"name":"name_1","ns":"erxes.user_groups","background":true}],"uuid":"137452c8f8b1458e8403ff286292e2f1"} \ No newline at end of file diff --git a/initialData/users.bson b/initialData/users.bson index e11868cbc..4c0b721d9 100644 Binary files a/initialData/users.bson and b/initialData/users.bson differ diff --git a/initialData/users.metadata.json b/initialData/users.metadata.json index 70c309b32..34b51adab 100644 --- a/initialData/users.metadata.json +++ b/initialData/users.metadata.json @@ -1 +1 @@ -{"options":{},"indexes":[{"v":2,"key":{"_id":1},"name":"_id_","ns":"erxes.users"},{"v":2,"unique":true,"key":{"email":1},"name":"email_1","ns":"erxes.users","background":true}]} \ No newline at end of file +{"options":{},"indexes":[{"v":2,"key":{"_id":1},"name":"_id_","ns":"erxes.users"},{"v":2,"unique":true,"key":{"email":1},"name":"email_1","background":true,"ns":"erxes.users"}],"uuid":"e560ab63d85b40babba073d6257a26c0"} \ No newline at end of file diff --git a/migrations/1546587503212-permission.ts b/migrations/1546587503212-permission.ts new file mode 100644 index 000000000..e491b9dd5 --- /dev/null +++ b/migrations/1546587503212-permission.ts @@ -0,0 +1,47 @@ +import * as dotenv from 'dotenv'; +import * as mongoose from 'mongoose'; +import { moduleObjects } from '../src/data/permissions/actions/permission'; +import { Permissions, Users, UsersGroups } from '../src/db/models'; + +dotenv.config(); + +/** + * Updating existing user's user group and permissions + * + */ +module.exports.up = next => { + const { MONGO_URL = '' } = process.env; + + mongoose.connect( + MONGO_URL, + { useNewUrlParser: true, useCreateIndex: true }, + async () => { + const userGroup = await UsersGroups.create({ name: 'Admin', description: 'Admin permission' }); + const moduleKeys = Object.keys(moduleObjects); + const groupId = userGroup._id; + + await Users.updateMany({ isActive: { $exists: false } }, { $set: { isActive: true } }); + await Users.updateMany({}, { $set: { groupIds: [groupId] } }); + + // Creating permissions according to user groups + for (const key of moduleKeys) { + const module = moduleObjects[key]; + const moduleName = module.name; + + for (const action of module.actions) { + const { use = [], name } = action; + const doc = { module: moduleName, allowed: true, action: name, groupId }; + + if (use.length > 0) { + await Permissions.create({ ...doc, requiredActions: use }); + break; + } + + await Permissions.create(doc); + } + } + + next(); + }, + ); +}; diff --git a/migrations/1553603187131-rename-coc-to-contentType.ts b/migrations/1553603187131-rename-coc-to-contentType.ts new file mode 100644 index 000000000..2697cb332 --- /dev/null +++ b/migrations/1553603187131-rename-coc-to-contentType.ts @@ -0,0 +1,23 @@ +import * as dotenv from 'dotenv'; +import * as mongoose from 'mongoose'; +import { ActivityLogs } from '../src/db/models'; + +dotenv.config(); + +/** + * Rename coc field to contentType + * + */ +module.exports.up = next => { + const { MONGO_URL = '' } = process.env; + + mongoose.connect( + MONGO_URL, + { useNewUrlParser: true, useCreateIndex: true }, + async () => { + await ActivityLogs.updateMany({}, { $rename: { coc: 'contentType' } }); + + next(); + }, + ); +}; diff --git a/package.json b/package.json index 278d48352..2e705a32d 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "erxes-api", - "version": "0.9.14", + "version": "0.9.15", "description": "GraphQL API for erxes main project", "homepage": "https://erxes.io", "repository": "https://github.com/erxes/erxes-api", @@ -15,21 +15,20 @@ "private": true, "scripts": { "start": "node dist", - "dev": "NODE_ENV=development nodemon -e ts src --exec ts-node", + "dev": "NODE_ENV=development node_modules/.bin/ts-node-dev --respawn src", "build": "tsc -p tsconfig.prod.json && cp -rf src/private dist/private", "lint": "tslint 'src/**/*.ts'", - "format": "prettier --write --print-width 120 --single-quote --trailing-comma all 'src/**/*.ts'", + "format": "prettier --write 'src/**/*.ts'", "precommit": "lint-staged", "test": "jest --maxWorkers 4 --forceExit && ts-node ./src/commands/aftertest.ts ", "loadInitialData": "mongorestore --db erxes ./initialData", - "customCommand": "ts-node ./src/commands/custom.ts", "initProject": "ts-node ./src/commands/initProject.ts", "engageSubscriptions": "ts-node ./src/commands/engageSubscriptions.ts", "migrate": "migrate --compiler='ts:./ts-node-compiler.js' up" }, "lint-staged": { "*.ts": [ - "prettier --write --print-width 120 --single-quote --trailing-comma all", + "prettier --write", "git add" ] }, @@ -107,11 +106,11 @@ "jest-tobetype": "^1.1.0", "lint-staged": "^3.6.0", "migrate": "^1.6.2", - "nodemon": "^1.11.0", "prettier": "^1.14.2", "sinon": "^7.2.2", "ts-jest": "^22.0.0", "ts-node": "^7.0.0", + "ts-node-dev": "^1.0.0-pre.32", "tslint": "^5.8.0", "tslint-config-prettier": "^1.1.0", "tslint-config-standard": "^7.0.0", diff --git a/scripts/install.sh b/scripts/install.sh index 899e36533..715daf976 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -3,7 +3,7 @@ echo 'Clone erxes-api repository and install its dependencies:' git clone https://github.com/erxes/erxes-api.git cd erxes-api -git checkout master +git checkout develop yarn install echo 'Create `.env.sample` from default settings file and configure it on your own:' @@ -12,7 +12,4 @@ cp .env.sample .env CURRENT_FOLDER=${PWD##*/} if [ $CURRENT_FOLDER = 'erxes-api' ]; then cd .. -fi - -echo 'Install erxes-widgets-api' -curl https://raw.githubusercontent.com/erxes/erxes-widgets-api/master/scripts/install.sh | sh +fi \ No newline at end of file diff --git a/src/__tests__/accountDb.test.ts b/src/__tests__/accountDb.test.ts new file mode 100644 index 000000000..1d138ccc3 --- /dev/null +++ b/src/__tests__/accountDb.test.ts @@ -0,0 +1,75 @@ +import { + accountFactory, + conversationFactory, + conversationMessageFactory, + customerFactory, + integrationFactory, +} from '../db/factories'; +import { Accounts, ConversationMessages, Conversations, Integrations } from '../db/models'; + +describe('Account db test', () => { + let _account; + let _customer; + let _integration; + + beforeEach(async () => { + // Creating test data + _account = await accountFactory({ + kind: 'gmail', + }); + _integration = await integrationFactory({ + kind: 'gmail', + gmailData: { + accountId: _account._id, + email: 'erkhet@nmma.co', + expiration: '1547701961664', + historyId: '11055', + }, + }); + _customer = await customerFactory({ + integrationId: _integration._id, + }); + }); + + afterEach(async () => { + // Clearing test data + await Accounts.deleteMany({}); + }); + + test('Delete account', async () => { + const conv = await conversationFactory({ + integrationId: _integration._id, + customerId: _customer._id, + content: 'content', + gmailData: { + messageId: `1683d006f6d5521e`, + }, + messageCount: 1, + number: 1, + }); + + await conversationMessageFactory({ + conversationId: conv._id, + customerId: _customer._id, + content: 'content', + gmailData: { + labelIds: ['IMPORTANT', 'TRASH', 'CATEGORY_PERSONAL'], + messageId: `1683d006f6d5521e`, + subject: 'subject', + from: 'munkhbold dembel ', + to: 'munkhbold.d@nmtec.co', + headerId: '', + threadId: '1683d006f6d5521e', + textPlain: 'this is a test\r\n', + attachments: [], + }, + }); + + await Accounts.removeAccount(_account.id); + + expect(await Accounts.findOne({ _id: _account.id }).countDocuments()).toBe(0); + expect(await ConversationMessages.find({}).countDocuments()).toBe(0); + expect(await Conversations.findOne({ integrationId: _integration.id }).countDocuments()).toBe(0); + expect(await Integrations.findOne({ 'gmailData.accountId': _account.id }).countDocuments()).toBe(0); + }); +}); diff --git a/src/__tests__/accountMutations.test.ts b/src/__tests__/accountMutations.test.ts new file mode 100644 index 000000000..c97a14444 --- /dev/null +++ b/src/__tests__/accountMutations.test.ts @@ -0,0 +1,104 @@ +import * as sinon from 'sinon'; +import accountMutations from '../data/resolvers/mutations/accounts'; +import { graphqlRequest } from '../db/connection'; +import { + accountFactory, + conversationFactory, + conversationMessageFactory, + customerFactory, + integrationFactory, + userFactory, +} from '../db/factories'; +import { Accounts, Brands, ConversationMessages, Conversations, Customers, Integrations, Users } from '../db/models'; +import { utils } from '../trackers/gmailTracker'; + +describe('Accounts mutations', () => { + let _account; + let _user; + let _integration; + let context; + + beforeEach(async () => { + // Creating test data + _account = await accountFactory({ + kind: 'gmail', + }); + _user = await userFactory({}); + _integration = await integrationFactory({ + gmailData: { + accountId: _account._id, + }, + }); + + context = { user: _user }; + }); + + afterEach(async () => { + // Clearing test data + await Brands.deleteMany({}); + await Users.deleteMany({}); + await Integrations.deleteMany({}); + }); + + test(`test if Error('Login required') exception is working as intended`, async () => { + expect.assertions(1); + + const expectError = async func => { + try { + await func(null, {}, {}); + } catch (e) { + expect(e.message).toBe('Login required'); + } + }; + + expectError(accountMutations.accountsRemove); + }); + + test('Remove account mutation test', async () => { + const mutationQuery = ` + mutation accountsRemove($_id: String!) { + accountsRemove(_id: $_id) + } + `; + + const customer = await customerFactory({ + integrationId: _integration._id, + }); + const conversation = await conversationFactory({ + integrationId: _integration._id, + customerId: customer._id, + content: 'content', + }); + await conversationMessageFactory({ + conversationId: conversation._id, + customerId: customer._id, + content: 'content', + gmailData: { + labelIds: ['IMPORTANT', 'TRASH', 'CATEGORY_PERSONAL'], + messageId: `1683d006f6d5521e`, + subject: 'subject', + from: 'munkhbold dembel ', + to: 'munkhbold.d@nmtec.co', + headerId: '', + threadId: '1683d006f6d5521e', + textPlain: 'this is a test\r\n', + }, + }); + + try { + await graphqlRequest(mutationQuery, 'accountsRemove', { _id: 'fakeId' }, context); + } catch (e) { + expect(e[0].message).toBe(`Account not found id with fakeId`); + } + + const mock = sinon.stub(utils, 'stopReceivingEmail').callsFake(); + await graphqlRequest(mutationQuery, 'accountsRemove', { _id: _account._id }, context); + mock.restore(); + + expect(await Accounts.findOne({ _id: _account._id })).toBe(null); + expect(await Integrations.findOne({ 'gmailData.accountId': _account._id })).toBe(null); + expect(await Customers.findOne({ integrationId: _integration._id })).toBe(null); + expect(await Conversations.findOne({ integrationId: _integration._id })).toBe(null); + expect(await ConversationMessages.findOne({ conversationId: conversation._id })).toBe(null); + }); +}); diff --git a/src/__tests__/accountQueries.ts b/src/__tests__/accountQueries.ts new file mode 100644 index 000000000..67a2c1fb8 --- /dev/null +++ b/src/__tests__/accountQueries.ts @@ -0,0 +1,45 @@ +import { graphqlRequest } from '../db/connection'; +import { accountFactory } from '../db/factories'; +import { Accounts } from '../db/models'; + +describe('accountQueries', () => { + afterEach(async () => { + // Clearing test data + await Accounts.deleteMany({}); + }); + + test('Accounts', async () => { + const args = { + kind: 'gmail', + }; + + await accountFactory({ + name: 'admin@erxes.io', + kind: 'gmail', + }); + + await accountFactory({ + name: 'erxes Inc', + kind: 'facebook', + }); + + const qry = ` + query accounts($kind: String) { + accounts(kind: $kind) { + _id + kind + name + } + } + `; + + const response = await graphqlRequest(qry, 'accounts', args); + const account = response[0]; + expect(account.name).toBe('admin@erxes.io'); + expect(account.kind).toBe('gmail'); + expect(response.length).toBe(1); + + const responses = await graphqlRequest(qry, 'accounts', {}); + expect(responses.length).toBe(2); + }); +}); diff --git a/src/__tests__/activityLogCronJob.test.ts b/src/__tests__/activityLogCronJob.test.ts index d9cbf4923..e0fd7cfbe 100644 --- a/src/__tests__/activityLogCronJob.test.ts +++ b/src/__tests__/activityLogCronJob.test.ts @@ -1,5 +1,5 @@ import cronJobs from '../cronJobs'; -import { ACTIVITY_ACTIONS, ACTIVITY_PERFORMER_TYPES, ACTIVITY_TYPES, COC_CONTENT_TYPES } from '../data/constants'; +import { ACTIVITY_ACTIONS, ACTIVITY_CONTENT_TYPES, ACTIVITY_PERFORMER_TYPES, ACTIVITY_TYPES } from '../data/constants'; import { customerFactory, segmentFactory } from '../db/factories'; import { ActivityLogs } from '../db/models'; @@ -19,7 +19,7 @@ describe('test activityLogsCronJob', () => { const customer = await customerFactory({ firstName: 'john smith' }); const segment = await segmentFactory({ - contentType: COC_CONTENT_TYPES.CUSTOMER, + contentType: ACTIVITY_CONTENT_TYPES.CUSTOMER, conditions: nameEqualsConditions, }); @@ -39,8 +39,8 @@ describe('test activityLogsCronJob', () => { content: segment.name, id: segment._id, }); - expect(aLog.coc.toObject()).toEqual({ - type: COC_CONTENT_TYPES.CUSTOMER, + expect(aLog.contentType.toObject()).toEqual({ + type: ACTIVITY_CONTENT_TYPES.CUSTOMER, id: customer._id, }); @@ -67,7 +67,7 @@ describe('test activityLogsCronJob', () => { await customerFactory({ firstName: 'jane smith' }); await segmentFactory({ - contentType: COC_CONTENT_TYPES.CUSTOMER, + contentType: ACTIVITY_CONTENT_TYPES.CUSTOMER, conditions: nameEqualsConditions2, }); diff --git a/src/__tests__/activityLogDb.test.ts b/src/__tests__/activityLogDb.test.ts deleted file mode 100644 index b4ea9cf80..000000000 --- a/src/__tests__/activityLogDb.test.ts +++ /dev/null @@ -1,416 +0,0 @@ -import { - companyFactory, - conversationFactory, - customerFactory, - dealFactory, - internalNoteFactory, - segmentFactory, - userFactory, -} from '../db/factories'; -import { ActivityLogs, Conversations } from '../db/models'; -import { - ACTIVITY_ACTIONS, - ACTIVITY_PERFORMER_TYPES, - ACTIVITY_TYPES, - COC_CONTENT_TYPES, -} from '../db/models/definitions/constants'; - -describe('ActivityLogs model methods', () => { - afterEach(async () => { - await ActivityLogs.deleteMany({}); - await Conversations.deleteMany({}); - }); - - test(`check whether not setting 'user' - is setting expected values in the collection or not`, async () => { - const activityDoc = { - type: ACTIVITY_TYPES.INTERNAL_NOTE, - action: ACTIVITY_ACTIONS.CREATE, - content: 'content', - id: 'testInternalNoteId', - }; - - const customerDoc = { - type: COC_CONTENT_TYPES.CUSTOMER, - id: 'testCustomerId', - }; - - const doc = { - activity: activityDoc, - coc: customerDoc, - }; - - const aLog = await ActivityLogs.createDoc(doc); - - expect(aLog.activity.toJSON()).toEqual(activityDoc); - expect(aLog.coc.toJSON()).toEqual(customerDoc); - - if (!aLog.performedBy) { - throw new Error('Performer is empty'); - } - - expect(aLog.performedBy.type).toBe(ACTIVITY_PERFORMER_TYPES.SYSTEM); - }); - - test(`createInternalNoteLog with setting 'user'`, async () => { - const user = await userFactory({}); - - const customer = await customerFactory({}); - - const internalNote = await internalNoteFactory({ - contentType: COC_CONTENT_TYPES.CUSTOMER, - contentTypeId: customer._id, - }); - - const aLog = await ActivityLogs.createInternalNoteLog(internalNote, user); - - if (!aLog.performedBy) { - throw new Error('Performer not found'); - } - - expect(aLog.performedBy.type).toBe(ACTIVITY_PERFORMER_TYPES.USER); - expect(aLog.performedBy.id).toBe(user._id); - expect(aLog.coc.type).toBe(COC_CONTENT_TYPES.CUSTOMER); - expect(aLog.coc.id).toBe(internalNote.contentTypeId); - expect(aLog.activity.toJSON()).toEqual({ - type: ACTIVITY_TYPES.INTERNAL_NOTE, - action: ACTIVITY_ACTIONS.CREATE, - id: internalNote._id, - content: internalNote.content, - }); - }); - - test(`check if exception is being thrown when calling - createSegmentLog without setting 'customer'`, async () => { - expect.assertions(1); - - const segment = await segmentFactory({}); - - try { - await ActivityLogs.createSegmentLog(segment, undefined); - } catch (e) { - expect(e.message).toBe('customer must be supplied'); - } - }); - - test(`createSegmentLog with setting 'customer'`, async () => { - // check if the activity log is being created ================== - const nameEqualsConditions = [ - { - type: 'string', - dateUnit: 'days', - value: 'John Smith', - operator: 'e', - field: 'name', - }, - ]; - - const customer = await customerFactory({ firstName: 'john smith' }); - const segment = await segmentFactory({ - contentType: COC_CONTENT_TYPES.CUSTOMER, - conditions: nameEqualsConditions, - }); - - const segmentLog = await ActivityLogs.createSegmentLog(segment, customer); - - expect(segmentLog.activity.toJSON()).toEqual({ - type: ACTIVITY_TYPES.SEGMENT, - action: ACTIVITY_ACTIONS.CREATE, - content: segment.name, - id: segment._id, - }); - expect(segmentLog.coc.toJSON()).toEqual({ - type: segment.contentType, - id: customer._id, - }); - - if (!segmentLog.performedBy) { - throw new Error('Performer is empty'); - } - - expect(segmentLog.performedBy.toJSON()).toEqual({ - type: ACTIVITY_PERFORMER_TYPES.SYSTEM, - }); - }); - - test(`Testing found segment`, async () => { - const segment = await segmentFactory({}); - const customer = await customerFactory({}); - await ActivityLogs.createSegmentLog(segment, customer); - - await ActivityLogs.createSegmentLog(segment, customer); - - expect( - await ActivityLogs.find({ - 'activity.type': ACTIVITY_TYPES.SEGMENT, - 'activity.action': ACTIVITY_ACTIONS.CREATE, - 'activity.id': segment._id, - 'coc.type': segment.contentType, - 'coc.id': customer._id, - }), - ).toHaveLength(1); - }); - - test(`check if exceptions are being thrown as intended when - calling createConversationLog`, async () => { - expect.assertions(1); - const conversation = await conversationFactory({}); - - try { - await ActivityLogs.createConversationLog(conversation, undefined); - } catch (e) { - expect(e.message).toBe(`'customer' must be supplied when adding activity log for conversations`); - } - }); - - test(`check if createConversationLog is working as intended`, async () => { - const conversation = await conversationFactory({}); - const companyA = await companyFactory({}); - const companyB = await companyFactory({}); - const customer = await customerFactory({ - companyIds: [companyA._id, companyB._id], - }); - - const aLog = await ActivityLogs.createConversationLog(conversation, customer); - - if (!aLog.performedBy) { - throw new Error('Performer is empty'); - } - - // check customer conversation log - expect(aLog.performedBy.toJSON()).toEqual({ - type: ACTIVITY_PERFORMER_TYPES.CUSTOMER, - id: customer._id, - }); - expect(aLog.coc.toJSON()).toEqual({ - type: COC_CONTENT_TYPES.CUSTOMER, - id: customer._id, - }); - expect(aLog.activity.toJSON()).toEqual({ - type: ACTIVITY_TYPES.CONVERSATION, - action: ACTIVITY_ACTIONS.CREATE, - content: conversation.content, - id: conversation._id, - }); - - // check company conversation logs ===================================== - let activity = await ActivityLogs.findOne({ - 'activity.type': ACTIVITY_TYPES.CONVERSATION, - 'activity.action': ACTIVITY_ACTIONS.CREATE, - 'activity.id': conversation._id, - 'coc.type': COC_CONTENT_TYPES.COMPANY, - 'coc.id': companyA._id, - }); - - expect(activity).toBeDefined(); - - if (!activity) { - throw new Error('Acitivty not found'); - } - - expect(activity.coc.id).toBe(companyA._id); - - activity = await ActivityLogs.findOne({ - 'activity.type': ACTIVITY_TYPES.CONVERSATION, - 'activity.action': ACTIVITY_ACTIONS.CREATE, - 'activity.id': conversation._id, - 'coc.type': COC_CONTENT_TYPES.COMPANY, - 'coc.id': companyB._id, - }); - - if (!activity) { - throw new Error('Acitivty not found'); - } - - expect(activity).toBeDefined(); - expect(activity.coc.id).toBe(companyB._id); - - expect(await ActivityLogs.find({}).countDocuments()).toBe(3); - - // test whether activity logs for this conversation is being duplicated or not ======== - await ActivityLogs.createConversationLog(conversation, customer); - - expect(await ActivityLogs.find({}).countDocuments()).toBe(3); - }); - - test(`createCustomerRegistrationLog`, async () => { - const customer = await customerFactory({}); - const user = await userFactory({}); - - const aLog = await ActivityLogs.createCustomerRegistrationLog(customer, user); - - const customerFullName = `${customer.firstName || ''} ${customer.lastName || ''}`; - - if (!aLog.performedBy) { - throw new Error('Performer is empty'); - } - - expect(aLog.performedBy.toJSON()).toEqual({ - type: ACTIVITY_PERFORMER_TYPES.USER, - id: user._id, - }); - expect(aLog.activity.toJSON()).toEqual({ - type: ACTIVITY_TYPES.CUSTOMER, - action: ACTIVITY_ACTIONS.CREATE, - content: customerFullName, - id: customer._id, - }); - expect(aLog.coc.toJSON()).toEqual({ - type: COC_CONTENT_TYPES.CUSTOMER, - id: customer._id, - }); - }); - - test(`createCompanyRegistrationLog`, async () => { - const company = await companyFactory({}); - const user = await userFactory({}); - - const aLog = await ActivityLogs.createCompanyRegistrationLog(company, user); - - if (!aLog.performedBy) { - throw new Error('Performer is empty'); - } - - expect(aLog.performedBy.toJSON()).toEqual({ - type: ACTIVITY_PERFORMER_TYPES.USER, - id: user._id, - }); - expect(aLog.activity.toJSON()).toEqual({ - type: ACTIVITY_TYPES.COMPANY, - action: ACTIVITY_ACTIONS.CREATE, - content: company.primaryName, - id: company._id, - }); - }); - - test(`createDealRegistrationLog`, async () => { - const deal = await dealFactory({}); - const user = await userFactory({}); - - const aLog = await ActivityLogs.createDealRegistrationLog(deal, user); - - if (!aLog.performedBy) { - throw new Error('Performer is empty'); - } - - expect(aLog.performedBy.toJSON()).toEqual({ - type: ACTIVITY_PERFORMER_TYPES.USER, - id: user._id, - }); - expect(aLog.activity.toJSON()).toEqual({ - type: ACTIVITY_TYPES.DEAL, - action: ACTIVITY_ACTIONS.CREATE, - content: deal.name, - id: deal._id, - }); - expect(aLog.coc.toJSON()).toEqual({ - type: COC_CONTENT_TYPES.DEAL, - id: deal._id, - }); - }); - - test(`changeCustomer`, async () => { - const customer = await customerFactory({}); - const newCustomer = await customerFactory({}); - const conversation = await conversationFactory({}); - - const log = await ActivityLogs.createConversationLog(conversation, customer); - - await ActivityLogs.updateOne( - { _id: log._id }, - { $set: { coc: { id: customer._id, type: COC_CONTENT_TYPES.CUSTOMER } } }, - ); - - const aLogs = await ActivityLogs.changeCustomer(newCustomer._id, [customer._id]); - - for (const aLog of aLogs) { - expect(aLog.coc.toJSON()).toEqual({ - type: COC_CONTENT_TYPES.CUSTOMER, - id: newCustomer._id, - }); - } - }); - - test(`changeCompany`, async () => { - const company = await companyFactory({}); - const newCompany = await companyFactory({}); - const user = await userFactory({}); - - const log = await ActivityLogs.createCompanyRegistrationLog(company, user); - - await ActivityLogs.updateOne( - { _id: log._id }, - { $set: { coc: { id: company._id, type: COC_CONTENT_TYPES.COMPANY } } }, - ); - - const aLogs = await ActivityLogs.changeCompany(newCompany._id, [company._id]); - - for (const aLog of aLogs) { - expect(aLog.coc.toJSON()).toEqual({ - type: COC_CONTENT_TYPES.COMPANY, - id: newCompany._id, - }); - } - }); - - test(`removeCustomerActivityLog`, async () => { - const customer = await customerFactory({}); - const conversation = await conversationFactory({}); - - await ActivityLogs.createConversationLog(conversation, customer); - await ActivityLogs.removeCustomerActivityLog(customer._id); - - const activityLog = await ActivityLogs.find({ - coc: { - type: COC_CONTENT_TYPES.CUSTOMER, - id: customer._id, - }, - }); - - expect(activityLog).toHaveLength(0); - }); - - test(`removeCompanyActivityLog`, async () => { - const company = await companyFactory({}); - const user = await userFactory({}); - - await ActivityLogs.createCompanyRegistrationLog(company, user); - await ActivityLogs.removeCompanyActivityLog(company._id); - - const activityLog = await ActivityLogs.find({ - coc: { - type: COC_CONTENT_TYPES.COMPANY, - id: company._id, - }, - }); - - expect(activityLog).toHaveLength(0); - }); - - test(`Create gmail activity log`, async () => { - const company = await companyFactory({}); - const user = await userFactory({}); - const cocType = 'company'; - const cocId = company._id; - const subject = 'gmail subject'; - - const gmailLog = await ActivityLogs.createGmailLog(subject, cocType, cocId, user._id); - - if (!gmailLog.activity) { - throw new Error('Activity is empty'); - } - - expect(gmailLog.activity.type).toBe('email'); - expect(gmailLog.activity.action).toBe('send'); - expect(gmailLog.activity.content).toBe(subject); - - if (!gmailLog.coc) { - throw new Error('Coc is empty'); - } - - expect(gmailLog.coc.toJSON()).toEqual({ - type: 'company', - id: company._id, - }); - }); -}); diff --git a/src/__tests__/activityLogMutations.test.ts b/src/__tests__/activityLogMutations.test.ts deleted file mode 100644 index 6ccf7abb8..000000000 --- a/src/__tests__/activityLogMutations.test.ts +++ /dev/null @@ -1,119 +0,0 @@ -import { graphqlRequest } from '../db/connection'; -import { companyFactory, conversationFactory, customerFactory, dealFactory } from '../db/factories'; -import { ActivityLogs, Companies, Conversations, Customers, Deals } from '../db/models'; - -describe('ActivityLog creation on Customer creation', () => { - let _customer; - let _company; - let _conversation; - let _deal; - - beforeEach(async () => { - // Creating test data - _customer = await customerFactory({}); - _company = await companyFactory({}); - _conversation = await conversationFactory({}); - _deal = await dealFactory({}); - }); - - afterEach(async () => { - // Clearing test data - await ActivityLogs.deleteMany({}); - await Conversations.deleteMany({}); - await Customers.deleteMany({}); - await Companies.deleteMany({}); - await Deals.deleteMany({}); - }); - - test('Add conversation log', async () => { - const args = { - customerId: _customer._id, - conversationId: _conversation._id, - }; - - const mutation = ` - mutation activityLogsAddConversationLog( - $customerId: String! - $conversationId: String! - ) { - activityLogsAddConversationLog( - customerId: $customerId - conversationId: $conversationId - ) { - _id - } - } - `; - - const activityLogId = await graphqlRequest(mutation, 'activityLogsAddConversationLog', args); - - const activityLog = await ActivityLogs.findOne({ _id: activityLogId }); - - if (!activityLog || !activityLog.coc || !activityLog.activity) { - throw new Error('Activity not found'); - } - - expect(activityLog.coc.id).toBe(args.customerId); - expect(activityLog.activity.id).toBe(args.conversationId); - }); - - test('Add customer log', async () => { - const mutation = ` - mutation activityLogsAddCustomerLog($_id: String!) { - activityLogsAddCustomerLog(_id: $_id) { - _id - } - } - `; - - await graphqlRequest(mutation, 'activityLogsAddCustomerLog', { - _id: _customer._id, - }); - - const customerLog = await ActivityLogs.findOne({ - 'activity.id': _customer._id, - }); - - expect(customerLog).toBeDefined(); - }); - - test('Add company log', async () => { - const mutation = ` - mutation activityLogsAddCompanyLog($_id: String!) { - activityLogsAddCompanyLog(_id: $_id) { - _id - } - } - `; - - await graphqlRequest(mutation, 'activityLogsAddCompanyLog', { - _id: _company._id, - }); - - const companyLog = await ActivityLogs.findOne({ - 'activity.id': _company._id, - }); - - expect(companyLog).toBeDefined(); - }); - - test('Add deal log', async () => { - const mutation = ` - mutation activityLogsAddDealLog($_id: String!) { - activityLogsAddDealLog(_id: $_id) { - _id - } - } - `; - - await graphqlRequest(mutation, 'activityLogsAddDealLog', { - _id: _deal._id, - }); - - const dealLog = await ActivityLogs.findOne({ - 'activity.id': _deal._id, - }); - - expect(dealLog).toBeDefined(); - }); -}); diff --git a/src/__tests__/activityLogQueries.test.ts b/src/__tests__/activityLogQueries.test.ts index 310351252..e83c3be06 100644 --- a/src/__tests__/activityLogQueries.test.ts +++ b/src/__tests__/activityLogQueries.test.ts @@ -1,256 +1,70 @@ -import cronJobs from '../cronJobs'; -import { COC_CONTENT_TYPES } from '../data/constants'; -import mutations from '../data/resolvers/mutations'; -import { graphqlRequest } from '../db/connection'; -import { conversationFactory, customerFactory, segmentFactory, userFactory } from '../db/factories'; -import { ActivityLogs, Customers, Segments } from '../db/models'; - -describe('activityLogs', () => { - let _user; - let _conversation; - - beforeAll(async () => { - _user = await userFactory({}); - _conversation = await conversationFactory({}); - }); - - afterEach(async () => { - await ActivityLogs.deleteMany({}); - await Customers.deleteMany({}); - await Segments.deleteMany({}); - }); - - test('customerActivityLog', async () => { - // create customer - const customer = await mutations.customersAdd( - null, - { - firstName: 'firstName', - primaryEmail: 'test@test.test', - emails: ['test@test.test'], - phones: ['123456789'], - }, - { user: _user }, - ); - - expect(await ActivityLogs.find({}).countDocuments()).toBe(1); - - // create conversation - await mutations.activityLogsAddConversationLog(null, { - customerId: customer._id, - conversationId: _conversation._id, - }); - - expect(await ActivityLogs.find({}).countDocuments()).toBe(2); - - // create internal note - const internalNote = await mutations.internalNotesAdd( - null, - { - contentType: COC_CONTENT_TYPES.CUSTOMER, - contentTypeId: customer._id, - content: 'test internal note', - }, - { user: _user }, - ); - - expect(await ActivityLogs.find({}).countDocuments()).toBe(3); - - const nameEqualsConditions: any = [ - { - type: 'string', - dateUnit: 'days', - value: 'firstName', - operator: 'c', - field: 'firstName', - }, - ]; - - const segment = await segmentFactory({ - contentType: COC_CONTENT_TYPES.CUSTOMER, - conditions: nameEqualsConditions, - }); - - // create segment log - await cronJobs.createActivityLogsFromSegments(); - - expect(await ActivityLogs.find({}).countDocuments()).toBe(4); - - const query = ` - query activityLogsCustomer($_id: String!) { - activityLogsCustomer(_id: $_id) { - date { - year - month - } - list { - id - action - content - createdAt - by { - _id - type - details { - avatar - fullName - } - } - } - } - } - `; - - const context = { user: _user }; - - // call graphql query - const result = await graphqlRequest(query, 'activityLogsCustomer', { _id: customer._id }, context); - - // test values ============== - expect(result[0].list[0].action).toBe('segment-create'); - expect(result[0].list[0].id).toBe(segment._id); - expect(result[0].list[0].content).toBe(segment.name); - - expect(result[0].list[1].action).toBe('internal_note-create'); - expect(result[0].list[1].id).toBe(internalNote._id); - expect(result[0].list[1].content).toBe(internalNote.content); - - expect(result[0].list[2].action).toBe('conversation-create'); - expect(result[0].list[2].id).toBe(_conversation._id); - expect(result[0].list[2].content).toBe(_conversation.content); - - expect(result[0].list[3].action).toBe('customer-create'); - expect(result[0].list[3].id).toBe(customer._id); - expect(result[0].list[3].content).toBe(`${customer.firstName || ''} ${customer.lastName || ''}`); - - // change activity log 'createdAt' values =================== - await ActivityLogs.updateMany( - { - 'activity.type': 'segment', - 'activity.action': 'create', - }, - { - $set: { - createdAt: new Date(2017, 0, 1), - }, - }, - ); - - await ActivityLogs.updateMany( - { - 'activity.type': 'internal_note', - 'activity.action': 'create', - }, - { - $set: { - createdAt: new Date(2017, 1, 1), - }, - }, - ); - - await ActivityLogs.updateMany( - { - 'activity.type': 'conversation', - 'activity.action': 'create', - }, - { - $set: { - createdAt: new Date(2017, 1, 3), - }, - }, - ); - - await ActivityLogs.updateMany( - { - 'activity.type': 'customer', - 'activity.action': 'create', - }, - { - $set: { - createdAt: new Date(2017, 1, 4), - }, - }, - ); - - const logs = await graphqlRequest(query, 'activityLogsCustomer', { _id: customer._id }, context); - - // test the fetched data ============================= - const yearMonthLength = logs.length - 1; - - const customerFullName = `${customer.firstName || ''} ${customer.lastName || ''}`; - - expect(logs[yearMonthLength].list[0].id).toBe(segment._id); - expect(logs[yearMonthLength].list[0].action).toBe('segment-create'); - expect(logs[yearMonthLength].list[0].content).toBe(segment.name); - - const februaryLogLength = logs[yearMonthLength - 1].list.length - 1; - - expect(logs[yearMonthLength - 1].list[februaryLogLength].id).toBe(internalNote._id); - expect(logs[yearMonthLength - 1].list[februaryLogLength].action).toBe('internal_note-create'); - expect(logs[yearMonthLength - 1].list[februaryLogLength].content).toBe(internalNote.content); - - expect(logs[yearMonthLength - 1].list[februaryLogLength - 1].id).toBe(_conversation._id); - expect(logs[yearMonthLength - 1].list[februaryLogLength - 1].action).toBe('conversation-create'); - expect(logs[yearMonthLength - 1].list[februaryLogLength - 1].content).toBe(_conversation.content); - - expect(logs[yearMonthLength - 1].list[februaryLogLength - 2].id).toBe(customer._id); - expect(logs[yearMonthLength - 1].list[februaryLogLength - 2].action).toBe('customer-create'); - expect(logs[yearMonthLength - 1].list[februaryLogLength - 2].content).toBe(customerFullName); - }); - - test('companyActivityLog', async () => { - const company = await mutations.companiesAdd( - null, - { - primaryName: 'test company', - names: ['test company'], - website: 'test.test.test', - }, - { user: _user }, - ); - const customer = await customerFactory({ companyIds: [company._id] }); - - await mutations.activityLogsAddConversationLog(null, { - customerId: customer._id, - conversationId: _conversation._id, - }); - - const query = ` - query activityLogsCompany($_id: String!) { - activityLogsCompany(_id: $_id) { - date { - year - month - } - list { - id - action - content - createdAt - by { - _id - type - details { - avatar - fullName - } - } - } - } - } - `; - - const context = { user: _user }; - - const logs = await graphqlRequest(query, 'activityLogsCompany', { _id: company._id }, context); - - // test values =========================== - expect(logs[0].list[0].id).toBe(_conversation._id); - expect(logs[0].list[0].action).toBe('conversation-create'); - expect(logs[0].list[0].content).toBe(_conversation.content); - - expect(logs[0].list[1].id).toBe(company._id); - expect(logs[0].list[1].action).toBe('company-create'); - expect(logs[0].list[1].content).toBe(company.primaryName); - }); -}); +import * as faker from 'faker'; +import { ACTIVITY_ACTIONS, ACTIVITY_TYPES } from '../data/constants'; +import { graphqlRequest } from '../db/connection'; +import { activityLogFactory } from '../db/factories'; +import { ActivityLogs } from '../db/models'; +import { ACTIVITY_CONTENT_TYPES } from '../db/models/definitions/constants'; + +describe('activityLogQueries', () => { + const commonParamDefs = ` + $contentType: String!, + $contentId: String!, + $activityType: String!, + $limit: Int, + `; + + const commonParams = ` + contentType: $contentType + contentId: $contentId + activityType: $activityType + limit: $limit + `; + + const qryActivityLogs = ` + query activityLogs(${commonParamDefs}) { + activityLogs(${commonParams}) { + _id + action + id + createdAt + content + by { + type + details { + avatar + fullName + position + } + } + } + } + `; + + afterEach(async () => { + // Clearing test data + await ActivityLogs.deleteMany({}); + }); + + test('Activity log list', async () => { + const contentType = ACTIVITY_CONTENT_TYPES.CUSTOMER; + const activityType = ACTIVITY_TYPES.INTERNAL_NOTE; + const contentId = faker.random.uuid(); + + for (let i = 0; i < 3; i++) { + await activityLogFactory({ + activity: { type: activityType, action: ACTIVITY_ACTIONS.CREATE }, + contentType: { type: contentType, id: contentId }, + }); + } + + const args = { contentType, activityType, contentId }; + + const responses = await graphqlRequest(qryActivityLogs, 'activityLogs', args); + + expect(responses.length).toBe(3); + + const responsesWithLimit = await graphqlRequest(qryActivityLogs, 'activityLogs', { ...args, limit: 2 }); + + expect(responsesWithLimit.length).toBe(2); + }); +}); diff --git a/src/__tests__/brandMutations.test.ts b/src/__tests__/brandMutations.test.ts index 948d32b41..f4d84f233 100644 --- a/src/__tests__/brandMutations.test.ts +++ b/src/__tests__/brandMutations.test.ts @@ -21,7 +21,7 @@ describe('Brands mutations', () => { beforeEach(async () => { // Creating test data _brand = await brandFactory({}); - _user = await userFactory({ role: 'admin' }); + _user = await userFactory({}); _integration = await integrationFactory({}); context = { user: _user }; diff --git a/src/__tests__/channelMutations.test.ts b/src/__tests__/channelMutations.test.ts index 0b2e4e973..0ef384e58 100644 --- a/src/__tests__/channelMutations.test.ts +++ b/src/__tests__/channelMutations.test.ts @@ -12,7 +12,7 @@ describe('mutations', () => { // Creating test data _channel = await channelFactory({}); _integration = await integrationFactory({}); - _user = await userFactory({ role: 'admin' }); + _user = await userFactory({}); context = { user: _user }; }); @@ -39,6 +39,9 @@ describe('mutations', () => { `; test('Add channel', async () => { + process.env.DEFAULT_EMAIL_SERIVCE = ''; + process.env.COMPANY_EMAIL_FROM = ' '; + const args = { name: _channel.name, description: _channel.description, @@ -66,6 +69,7 @@ describe('mutations', () => { }); test('Edit channel', async () => { + process.env.COMPANY_EMAIL_FROM = ' '; const member = await userFactory({}); const args = { diff --git a/src/__tests__/companyDb.test.ts b/src/__tests__/companyDb.test.ts index f8168d937..a4f08ec2f 100644 --- a/src/__tests__/companyDb.test.ts +++ b/src/__tests__/companyDb.test.ts @@ -1,14 +1,7 @@ -import { - activityLogFactory, - companyFactory, - customerFactory, - dealFactory, - fieldFactory, - internalNoteFactory, -} from '../db/factories'; -import { ActivityLogs, Companies, Customers, Deals, InternalNotes } from '../db/models'; +import { companyFactory, customerFactory, dealFactory, fieldFactory, internalNoteFactory } from '../db/factories'; +import { Companies, Customers, Deals, InternalNotes } from '../db/models'; import { ICompany, ICompanyDocument } from '../db/models/definitions/companies'; -import { COC_CONTENT_TYPES } from '../db/models/definitions/constants'; +import { ACTIVITY_CONTENT_TYPES, STATUSES } from '../db/models/definitions/constants'; const check = (companyObj: ICompanyDocument, doc: ICompany) => { expect(companyObj.createdAt).toBeDefined(); @@ -157,35 +150,27 @@ describe('Companies model tests', () => { await customerFactory({ companyIds: [company._id] }); await internalNoteFactory({ - contentType: COC_CONTENT_TYPES.COMPANY, + contentType: ACTIVITY_CONTENT_TYPES.COMPANY, contentTypeId: company._id, }); await Companies.removeCompany(company._id); const internalNote = await InternalNotes.find({ - contentType: COC_CONTENT_TYPES.COMPANY, + contentType: ACTIVITY_CONTENT_TYPES.COMPANY, contentTypeId: company._id, }); - const activityLog = await ActivityLogs.find({ - coc: { - type: COC_CONTENT_TYPES.COMPANY, - id: company._id, - }, - }); - const customers = await Customers.find({ companyIds: { $in: [company._id] }, }); expect(customers).toHaveLength(0); expect(internalNote).toHaveLength(0); - expect(activityLog).toHaveLength(0); }); test('mergeCompanies', async () => { - expect.assertions(23); + expect.assertions(21); const company1 = await companyFactory({ tagIds: ['123', '456', '1234'], @@ -223,17 +208,10 @@ describe('Companies model tests', () => { // Merge without any errors =========== await internalNoteFactory({ - contentType: COC_CONTENT_TYPES.COMPANY, + contentType: ACTIVITY_CONTENT_TYPES.COMPANY, contentTypeId: companyIds[0], }); - await activityLogFactory({ - coc: { - type: COC_CONTENT_TYPES.COMPANY, - id: companyIds[0], - }, - }); - await dealFactory({ companyIds, }); @@ -260,7 +238,9 @@ describe('Companies model tests', () => { expect(updatedCompany.parentCompanyId).toBe('123'); // Checking old company datas deleted - expect(await Companies.find({ _id: companyIds[0] })).toHaveLength(0); + const oldCompany = (await Companies.findOne({ _id: companyIds[0] })) || { status: '' }; + + expect(oldCompany.status).toBe(STATUSES.DELETED); expect(updatedCompany.tagIds).toEqual(expect.arrayContaining(mergedTagIds)); const customerObj1 = await Customers.findOne({ _id: customer1._id }); @@ -280,19 +260,11 @@ describe('Companies model tests', () => { expect(customerObj2.companyIds).not.toContain(company2._id); let internalNote = await InternalNotes.find({ - contentType: COC_CONTENT_TYPES.COMPANY, + contentType: ACTIVITY_CONTENT_TYPES.COMPANY, contentTypeId: companyIds[0], }); - let activityLog = await ActivityLogs.find({ - coc: { - type: COC_CONTENT_TYPES.COMPANY, - id: companyIds[0], - }, - }); - expect(internalNote).toHaveLength(0); - expect(activityLog).toHaveLength(0); // Checking new company datas updated expect(updatedCompany.tagIds).toEqual(expect.arrayContaining(mergedTagIds)); @@ -301,19 +273,11 @@ describe('Companies model tests', () => { expect(customerObj2.companyIds).toContain(updatedCompany._id); internalNote = await InternalNotes.find({ - contentType: COC_CONTENT_TYPES.COMPANY, + contentType: ACTIVITY_CONTENT_TYPES.COMPANY, contentTypeId: updatedCompany._id, }); - activityLog = await ActivityLogs.find({ - coc: { - type: COC_CONTENT_TYPES.COMPANY, - id: updatedCompany._id, - }, - }); - expect(internalNote).not.toHaveLength(0); - expect(activityLog).not.toHaveLength(0); const deals = await Deals.find({ companyIds: { $in: companyIds }, diff --git a/src/__tests__/companyMutations.test.ts b/src/__tests__/companyMutations.test.ts index 3cec8136b..095f132af 100644 --- a/src/__tests__/companyMutations.test.ts +++ b/src/__tests__/companyMutations.test.ts @@ -39,7 +39,7 @@ describe('Companies mutations', () => { // Creating test data _company = await companyFactory({}); _customer = await customerFactory({}); - _user = await userFactory({ role: 'admin' }); + _user = await userFactory({}); context = { user: _user }; }); diff --git a/src/__tests__/companyQueries.test.ts b/src/__tests__/companyQueries.test.ts index 4e2349255..4bcfd6819 100644 --- a/src/__tests__/companyQueries.test.ts +++ b/src/__tests__/companyQueries.test.ts @@ -1,3 +1,4 @@ +import * as moment from 'moment'; import { graphqlRequest } from '../db/connection'; import { brandFactory, @@ -100,6 +101,12 @@ describe('companyQueries', () => { } `; + const qryCompaniesExport = ` + query companiesExport(${commonParamDefs}) { + companiesExport(${commonParams}) + } + `; + const qryCount = ` query companyCounts(${commonParamDefs}, $only: String) { companyCounts(${commonParams}, only: $only) @@ -386,4 +393,18 @@ describe('companyQueries', () => { expect(response._id).toBe(company._id); }); + + test('companiesExport', async () => { + await companyFactory({}); + await companyFactory({}); + await companyFactory({}); + await companyFactory({}); + + process.env.DOMAIN = 'http://localhost:3300'; + const args = { page: 1, perPage: 3 }; + + const exportTime = moment().format('YYYY-MM-DD HH:mm'); + const response = await graphqlRequest(qryCompaniesExport, 'companiesExport', args); + expect(response).toBe(`${process.env.DOMAIN}/static/xlsTemplateOutputs/company - ${exportTime}.xlsx`); + }); }); diff --git a/src/__tests__/configMutations.test.ts b/src/__tests__/configMutations.test.ts index ba0d8df53..1d3e5519f 100644 --- a/src/__tests__/configMutations.test.ts +++ b/src/__tests__/configMutations.test.ts @@ -3,7 +3,7 @@ import { userFactory } from '../db/factories'; describe('Test configs mutations', () => { test('Insert config', async () => { - const context = { user: await userFactory({ role: 'admin' }) }; + const context = { user: await userFactory({}) }; const args = { code: 'dealUOM', diff --git a/src/__tests__/conversationCronJob.test.ts b/src/__tests__/conversationCronJob.test.ts index 7de1f7fea..00b0d1801 100644 --- a/src/__tests__/conversationCronJob.test.ts +++ b/src/__tests__/conversationCronJob.test.ts @@ -51,6 +51,9 @@ describe('Cronjob conversation send email', () => { }); test('Conversations utils', async () => { + process.env.DEFAULT_EMAIL_SERIVCE = ' '; + process.env.COMPANY_EMAIL_FROM = ' '; + const spyEmail = jest.spyOn(utils, 'sendEmail'); const spyNewOrOpenConversation = jest.spyOn(Conversations, 'newOrOpenConversation'); @@ -100,7 +103,7 @@ describe('Cronjob conversation send email', () => { data.answers = [answer]; const expectedArgs = { - to: _customer.primaryEmail, + toEmails: [_customer.primaryEmail], title: `Reply from "${_brand.name}"`, template: { name: 'conversationCron', @@ -113,7 +116,7 @@ describe('Cronjob conversation send email', () => { const calledArgs = spyEmail.mock.calls[0][0]; - expect(expectedArgs.to).toBe(calledArgs.to); + expect(expectedArgs.toEmails[0]).toBe(calledArgs.toEmails[0]); expect(expectedArgs.title).toBe(calledArgs.title); expect(expectedArgs.template.name).toBe(calledArgs.template.name); expect(expectedArgs.template.isCustom).toBe(calledArgs.template.isCustom); diff --git a/src/__tests__/conversationMutations.test.ts b/src/__tests__/conversationMutations.test.ts index f1ae6fe2f..247d61c4f 100644 --- a/src/__tests__/conversationMutations.test.ts +++ b/src/__tests__/conversationMutations.test.ts @@ -58,6 +58,9 @@ describe('Conversation message mutations', () => { }); test('Add conversation message', async () => { + process.env.DEFAULT_EMAIL_SERIVCE = ' '; + process.env.COMPANY_EMAIL_FROM = ' '; + const args = { conversationId: _conversation._id, content: _conversationMessage.content, @@ -110,6 +113,9 @@ describe('Conversation message mutations', () => { }); test('Tweet conversation', async () => { + process.env.DEFAULT_EMAIL_SERIVCE = ' '; + process.env.COMPANY_EMAIL_FROM = ' '; + const twit = {}; twitMap[_integrationTwitter._id] = twit; @@ -245,6 +251,9 @@ describe('Conversation message mutations', () => { }); test('Assign conversation', async () => { + process.env.DEFAULT_EMAIL_SERIVCE = ' '; + process.env.COMPANY_EMAIL_FROM = ' '; + const args = { conversationIds: [_conversation._id], assignedUserId: _user._id, @@ -290,6 +299,9 @@ describe('Conversation message mutations', () => { }); test('Change conversation status', async () => { + process.env.DEFAULT_EMAIL_SERIVCE = ' '; + process.env.COMPANY_EMAIL_FROM = ' '; + const args = { _ids: [_conversation._id], status: 'closed', @@ -309,6 +321,9 @@ describe('Conversation message mutations', () => { }); test('Mark conversation as read', async () => { + process.env.DEFAULT_EMAIL_SERIVCE = ' '; + process.env.COMPANY_EMAIL_FROM = ' '; + const mutation = ` mutation conversationMarkAsRead($_id: String) { conversationMarkAsRead(_id: $_id) { diff --git a/src/__tests__/customerDb.test.ts b/src/__tests__/customerDb.test.ts index a8ce4a022..5af08c43c 100644 --- a/src/__tests__/customerDb.test.ts +++ b/src/__tests__/customerDb.test.ts @@ -1,5 +1,4 @@ import { - activityLogFactory, conversationFactory, conversationMessageFactory, customerFactory, @@ -9,16 +8,8 @@ import { internalNoteFactory, userFactory, } from '../db/factories'; -import { - ActivityLogs, - ConversationMessages, - Conversations, - Customers, - Deals, - ImportHistory, - InternalNotes, -} from '../db/models'; -import { COC_CONTENT_TYPES } from '../db/models/definitions/constants'; +import { ConversationMessages, Conversations, Customers, Deals, ImportHistory, InternalNotes } from '../db/models'; +import { ACTIVITY_CONTENT_TYPES, STATUSES } from '../db/models/definitions/constants'; describe('Customers model tests', () => { let _customer; @@ -212,7 +203,7 @@ describe('Customers model tests', () => { const customer = await customerFactory({}); await internalNoteFactory({ - contentType: COC_CONTENT_TYPES.CUSTOMER, + contentType: ACTIVITY_CONTENT_TYPES.CUSTOMER, contentTypeId: customer._id, }); @@ -228,7 +219,7 @@ describe('Customers model tests', () => { await Customers.removeCustomer(customer._id); const internalNote = await InternalNotes.find({ - contentType: COC_CONTENT_TYPES.CUSTOMER, + contentType: ACTIVITY_CONTENT_TYPES.CUSTOMER, contentTypeId: customer._id, }); @@ -254,7 +245,7 @@ describe('Customers model tests', () => { }); test('Merge customers', async () => { - expect.assertions(25); + expect.assertions(23); const integration = await integrationFactory({}); @@ -304,7 +295,7 @@ describe('Customers model tests', () => { // Merge without any errors =========== await internalNoteFactory({ - contentType: COC_CONTENT_TYPES.CUSTOMER, + contentType: ACTIVITY_CONTENT_TYPES.CUSTOMER, contentTypeId: customerIds[0], }); @@ -316,13 +307,6 @@ describe('Customers model tests', () => { customerId: customerIds[0], }); - await activityLogFactory({ - coc: { - type: COC_CONTENT_TYPES.CUSTOMER, - id: customerIds[0], - }, - }); - await dealFactory({ customerIds, }); @@ -374,43 +358,29 @@ describe('Customers model tests', () => { expect(mergedCustomer.ownerId).toBe('456'); // Checking old customers datas to be deleted - expect(await Customers.find({ _id: customerIds[0] })).toHaveLength(0); + const oldCustomer = (await Customers.findOne({ _id: customerIds[0] })) || { status: '' }; + + expect(oldCustomer.status).toBe(STATUSES.DELETED); expect(await Conversations.find({ customerId: customerIds[0] })).toHaveLength(0); expect(await ConversationMessages.find({ customerId: customerIds[0] })).toHaveLength(0); let internalNote = await InternalNotes.find({ - contentType: COC_CONTENT_TYPES.CUSTOMER, + contentType: ACTIVITY_CONTENT_TYPES.CUSTOMER, contentTypeId: customerIds[0], }); - let activityLog = await ActivityLogs.find({ - coc: { - id: customerIds[0], - type: COC_CONTENT_TYPES.CUSTOMER, - }, - }); - expect(internalNote).toHaveLength(0); - expect(activityLog).toHaveLength(0); // Checking merged customer datas expect(await Conversations.find({ customerId: mergedCustomer._id })).not.toHaveLength(0); expect(await ConversationMessages.find({ customerId: mergedCustomer._id })).not.toHaveLength(0); internalNote = await InternalNotes.find({ - contentType: COC_CONTENT_TYPES.CUSTOMER, + contentType: ACTIVITY_CONTENT_TYPES.CUSTOMER, contentTypeId: mergedCustomer._id, }); - activityLog = await ActivityLogs.find({ - coc: { - type: COC_CONTENT_TYPES.CUSTOMER, - id: mergedCustomer._id, - }, - }); - expect(internalNote).not.toHaveLength(0); - expect(activityLog).not.toHaveLength(0); const deals = await Deals.find({ customerIds: { $in: customerIds }, diff --git a/src/__tests__/customerQueries.test.ts b/src/__tests__/customerQueries.test.ts index a4440be8b..e7a73c63e 100644 --- a/src/__tests__/customerQueries.test.ts +++ b/src/__tests__/customerQueries.test.ts @@ -122,9 +122,8 @@ describe('customerQueries', () => { }); test('Customers', async () => { - await customerFactory({}); - await customerFactory({}); - await customerFactory({}); + const integration = await integrationFactory(); + await customerFactory({ integrationId: integration._id }); await customerFactory({}); await customerFactory({}); diff --git a/src/__tests__/dealInsightQueries.test.ts b/src/__tests__/dealInsightQueries.test.ts new file mode 100644 index 000000000..780d03472 --- /dev/null +++ b/src/__tests__/dealInsightQueries.test.ts @@ -0,0 +1,112 @@ +import * as moment from 'moment'; +import dealInsightQueries from '../data/resolvers/queries/insights/dealInsights'; +import { graphqlRequest } from '../db/connection'; +import { dealBoardFactory, dealFactory, dealPipelineFactory, dealStageFactory, userFactory } from '../db/factories'; +import { DealBoards, DealPipelines, Deals, DealStages } from '../db/models'; + +describe('dealInsightQueries', () => { + let board; + let pipeline; + let stage; + let deal; + let doc; + + const endDate = new Date( + moment(new Date()) + .add(1, 'days') + .toString(), + ).toISOString(); + + const startDate = new Date( + moment(endDate) + .add(-7, 'days') + .toString(), + ).toISOString(); + + beforeEach(async () => { + // creating test data + board = await dealBoardFactory(); + pipeline = await dealPipelineFactory({ boardId: board._id }); + stage = await dealStageFactory({ pipelineId: pipeline._id }); + deal = await dealFactory({ stageId: stage._id }); + + doc = { + pipelineIds: pipeline._id, + boardId: board._id, + startDate, + endDate, + }; + }); + + afterEach(async () => { + // Clearing test data + await DealBoards.deleteMany({}); + await DealPipelines.deleteMany({}); + await DealStages.deleteMany({}); + await Deals.deleteMany({}); + }); + + test(`test if Error('Login required') exception is working as intended`, async () => { + expect.assertions(3); + + const expectError = async func => { + try { + await func(null, {}, {}); + } catch (e) { + expect(e.message).toBe('Login required'); + } + }; + + expectError(dealInsightQueries.dealInsightsPunchCard); + expectError(dealInsightQueries.dealInsightsMain); + expectError(dealInsightQueries.dealInsightsByTeamMember); + }); + + test('dealInsightsPunchCard', async () => { + const qry = ` + query dealInsightsPunchCard( + $pipelineIds: String, + $boardId: String, + $startDate: String, + $endDate: String + ) { + dealInsightsPunchCard( + pipelineIds: $pipelineIds, + boardId: $boardId, + startDate: $startDate, + endDate: $endDate + ) + } + `; + + const response = await graphqlRequest(qry, 'dealInsightsPunchCard', doc); + expect(response.length).toBe(1); + }); + + test('dealInsightsByTeamMember', async () => { + const user = await userFactory({}); + + Deals.findByIdAndUpdate(deal._id, { + modifiedAt: new Date(), + modifiedBy: user._id, + }); + + const qry = ` + query dealInsightsByTeamMember( + $pipelineIds: String, + $boardId: String, + $startDate: String, + $endDate: String + ) { + dealInsightsByTeamMember( + pipelineIds: $pipelineIds, + boardId: $boardId, + startDate: $startDate, + endDate: $endDate) + } + `; + + const response = await graphqlRequest(qry, 'dealInsightsByTeamMember', doc); + expect(response.length).toBe(1); + }); +}); diff --git a/src/__tests__/dealMutations.test.ts b/src/__tests__/dealMutations.test.ts index 6ce1e291d..b8c048ac0 100644 --- a/src/__tests__/dealMutations.test.ts +++ b/src/__tests__/dealMutations.test.ts @@ -48,7 +48,7 @@ describe('Test deals mutations', () => { pipeline = await dealPipelineFactory({ boardId: board._id }); stage = await dealStageFactory({ pipelineId: pipeline._id }); deal = await dealFactory({ stageId: stage._id }); - context = { user: await userFactory({ role: 'admin' }) }; + context = { user: await userFactory({}) }; }); afterEach(async () => { diff --git a/src/__tests__/engageMessageMutations.test.ts b/src/__tests__/engageMessageMutations.test.ts index 95f2175d2..326b1ed19 100644 --- a/src/__tests__/engageMessageMutations.test.ts +++ b/src/__tests__/engageMessageMutations.test.ts @@ -1,6 +1,7 @@ import * as faker from 'faker'; import * as moment from 'moment'; import * as sinon from 'sinon'; +import { INTEGRATION_KIND_CHOICES } from '../data/constants'; import * as engageUtils from '../data/resolvers/mutations/engageUtils'; import { graphqlRequest } from '../db/connection'; import { @@ -27,6 +28,7 @@ import { Tags, Users, } from '../db/models'; + import { awsRequests } from '../trackers/engageTracker'; describe('engage message mutation tests', () => { @@ -40,6 +42,7 @@ describe('engage message mutation tests', () => { let _emailTemplate; let _doc; let context; + let spy; const commonParamDefs = ` $title: String!, @@ -78,10 +81,22 @@ describe('engage message mutation tests', () => { _user = await userFactory({}); _tag = await tagsFactory({}); _brand = await brandFactory({}); - _segment = await segmentFactory({}); - _message = await engageMessageFactory({ kind: 'auto', userId: _user._id }); + _segment = await segmentFactory({ + connector: 'any', + conditions: [{ field: 'primaryEmail', operator: 'c', value: '@', type: 'string' }], + }); + _message = await engageMessageFactory({ + kind: 'auto', + userId: _user._id, + messenger: { + content: 'content', + brandId: _brand.id, + }, + }); _emailTemplate = await emailTemplateFactory({}); - _customer = await customerFactory({}); + _customer = await customerFactory({ + hasValidEmail: true, + }); _integration = await integrationFactory({ brandId: 'brandId' }); _doc = { @@ -99,7 +114,6 @@ describe('engage message mutation tests', () => { templateId: _emailTemplate._id, subject: faker.random.word(), content: faker.random.word(), - attachments: [{ name: 'document', url: 'documentPath' }, { name: 'image', url: 'imagePath' }], }, scheduleDate: { type: 'year', @@ -125,9 +139,12 @@ describe('engage message mutation tests', () => { }; context = { user: _user }; + + spy = jest.spyOn(engageUtils, 'send'); }); afterEach(async () => { + spy.mockRestore(); // Clearing test data _doc = null; await Users.deleteMany({}); @@ -142,25 +159,149 @@ describe('engage message mutation tests', () => { await ConversationMessages.deleteMany({}); }); - test('Engage utils send via messenger: integration not found', async () => { - expect.assertions(1); + test('Engage utils send via messenger', async () => { + const brand = await brandFactory(); + const emessage = await engageMessageFactory({ + method: 'messenger', + title: 'Send via messenger', + userId: _user._id, + segmentId: _segment._id, + customerIds: [_customer._id], + isLive: true, + messenger: { + brandId: brand._id, + content: 'content', + }, + }); + + const emessageWithoutUser = await engageMessageFactory({ + method: 'messenger', + title: 'Send via messenger', + userId: 'fromUserId', + segmentId: _segment._id, + isLive: true, + messenger: { + brandId: brand._id, + content: 'content', + }, + }); try { - await engageUtils.send({ - _id: _message._id, - method: 'messenger', - title: 'Send via messenger', - fromUserId: _user._id, - segmentId: _segment._id, - isLive: true, - messenger: { - brandId: '', - content: 'content', - }, - }); + await engageUtils.send(emessage); } catch (e) { expect(e.message).toEqual('Integration not found'); } + + try { + await engageUtils.send(emessageWithoutUser); + } catch (e) { + expect(e.message).toEqual('User not found'); + } + + const integration = await integrationFactory({ + brandId: brand._id, + kind: INTEGRATION_KIND_CHOICES.MESSENGER, + }); + + const emessageWithBrand = await engageMessageFactory({ + method: 'messenger', + title: 'Send via messenger', + userId: _user._id, + segmentId: _segment._id, + isLive: true, + customerIds: [_customer._id], + messenger: { + brandId: brand._id, + content: 'content', + }, + }); + + await engageUtils.send(emessageWithBrand); + + // setCustomerIds + const setCustomer = await EngageMessages.findOne({ _id: _message._id, customerIds: _customer._id }); + expect(setCustomer).toBeDefined(); + + // create conversation + const newConversation = await Conversations.findOne({ + userId: _user._id, + customerId: _customer._id, + integrationId: integration._id, + content: 'content', + }); + + if (!newConversation) { + throw new Error('Conversation not found'); + } + + expect(newConversation).toBeDefined(); + + // create message + const newMessage = await ConversationMessages.findOne({ + conversationId: newConversation._id, + customerId: _customer._id, + userId: _user._id, + content: 'content', + }); + + if (!newMessage || !newMessage.engageData) { + throw new Error('Message not found'); + } + + expect(newMessage).toBeDefined(); + expect(newMessage.engageData.messageId).toBe(emessageWithBrand._id); + expect(newMessage.engageData.fromUserId).toBe(_user._id); + expect(newMessage.engageData.brandId).toBe(brand._id); + }); + + test('Engage utils send via email', async () => { + process.env.AWS_SES_ACCESS_KEY_ID = '123'; + process.env.AWS_SES_SECRET_ACCESS_KEY = '123'; + process.env.AWS_SES_CONFIG_SET = 'aws-ses'; + process.env.AWS_ENDPOINT = '123'; + process.env.MAIL_PORT = '123'; + process.env.AWS_REGION = 'us-west-2'; + + sinon.stub(engageUtils.utils, 'executeSendViaEmail').callsFake(); + + const emailTemplate = await emailTemplateFactory(); + const emessage = await engageMessageFactory({ + method: 'email', + title: 'Send via email', + userId: 'fromUserId', + segmentId: _segment._id, + email: { + templateId: emailTemplate._id, + subject: 'subject', + content: 'content', + attachments: [], + }, + }); + + try { + await engageUtils.send(emessage); + } catch (e) { + expect(e.message).toBe('User not found'); + } + + const executeSendViaEmail = jest.spyOn(engageUtils.utils, 'executeSendViaEmail'); + + const emessageWithUser = await engageMessageFactory({ + method: 'email', + title: 'Send via email', + userId: _user._id, + segmentId: _segment._id, + email: { + templateId: emailTemplate._id, + subject: 'subject', + content: 'content', + attachments: [], + }, + }); + + await engageUtils.send(emessageWithUser); + + expect(executeSendViaEmail.mock.calls.length).toBe(1); }); const engageMessageAddMutation = ` @@ -201,8 +342,8 @@ describe('engage message mutation tests', () => { `; test('Add engage message', async () => { - process.env.AWS_SES_ACCESS_KEY_ID = ''; - process.env.AWS_SES_SECRET_ACCESS_KEY = ''; + process.env.AWS_SES_ACCESS_KEY_ID = '123'; + process.env.AWS_SES_SECRET_ACCESS_KEY = '123'; process.env.AWS_SES_CONFIG_SET = 'aws-ses'; process.env.AWS_ENDPOINT = '123'; @@ -220,20 +361,15 @@ describe('engage message mutation tests', () => { }); }); - sandbox.stub(engageUtils, 'send').callsFake(() => { - return new Promise(resolve => { - return resolve('sent'); - }); - }); - - const sendSpy = jest.spyOn(engageUtils, 'send'); const awsSpy = jest.spyOn(awsRequests, 'getVerifiedEmails'); + const sendSpy = jest.spyOn(engageUtils, 'send'); const engageMessage = await graphqlRequest(engageMessageAddMutation, 'engageMessageAdd', _doc, context); const tags = engageMessage.getTags.map(tag => tag._id); - expect(engageUtils.send).toHaveBeenCalled(); + expect(spy.mock.calls.length).toBe(1); + expect(sendSpy.mock.calls.length).toBe(1); expect(engageMessage.kind).toBe(_doc.kind); expect(new Date(engageMessage.stopDate)).toEqual(_doc.stopDate); expect(engageMessage.segmentId).toBe(_doc.segmentId); @@ -258,18 +394,6 @@ describe('engage message mutation tests', () => { sendSpy.mockRestore(); }); - test('Engage add without aws config', async () => { - expect.assertions(1); - - try { - // mock settings - process.env.AWS_SES_CONFIG_SET = ''; - await graphqlRequest(engageMessageAddMutation, 'engageMessageAdd', _doc, context); - } catch (e) { - expect(e.toString()).toContain('Could not locate configs on AWS SES'); - } - }); - test('Engage add with unverified email', async () => { expect.assertions(1); @@ -278,6 +402,7 @@ describe('engage message mutation tests', () => { const sandbox = sinon.createSandbox(); const awsSpy = jest.spyOn(awsRequests, 'getVerifiedEmails'); + const mock = sinon.stub(engageUtils.utils, 'executeSendViaEmail').callsFake(); sandbox.stub(awsRequests, 'getVerifiedEmails').callsFake(() => { return new Promise(resolve => { @@ -292,6 +417,7 @@ describe('engage message mutation tests', () => { } awsSpy.mockRestore(); + mock.mockRestore(); }); test('Edit engage message', async () => { @@ -375,7 +501,6 @@ describe('engage message mutation tests', () => { } } `; - const engageMessage = await graphqlRequest(mutation, 'engageMessageSetLive', { _id: _message._id }, context); expect(engageMessage.isLive).toBe(true); diff --git a/src/__tests__/formMutations.test.ts b/src/__tests__/formMutations.test.ts index 524a1ad8a..ba06630d6 100644 --- a/src/__tests__/formMutations.test.ts +++ b/src/__tests__/formMutations.test.ts @@ -28,7 +28,7 @@ describe('form and formField mutations', () => { beforeEach(async () => { // Creating test data - _user = await userFactory({ role: 'admin' }); + _user = await userFactory({}); _form = await formFactory({}); context = { user: _user }; diff --git a/src/__tests__/formQueries.test.ts b/src/__tests__/formQueries.test.ts new file mode 100644 index 000000000..9a66d9aa4 --- /dev/null +++ b/src/__tests__/formQueries.test.ts @@ -0,0 +1,56 @@ +import { graphqlRequest } from '../db/connection'; +import { formFactory } from '../db/factories'; +import { Fields, FieldsGroups } from '../db/models'; + +describe('formQueries', () => { + afterEach(async () => { + // Clearing test data + await Fields.deleteMany({}); + await FieldsGroups.deleteMany({}); + }); + + test('Forms', async () => { + // Creating test data + + await formFactory(); + await formFactory(); + + const qry = ` + query forms { + forms { + _id + title + code + } + } + `; + + // company =================== + const responses = await graphqlRequest(qry, 'forms'); + + expect(responses.length).toBe(2); + expect(responses[0].title).toBeDefined(); + expect(responses[0].code).toBeDefined(); + }); + + test('formDetail', async () => { + const form = await formFactory({ + title: 'title', + code: 'code', + }); + + const qry = ` + query formDetail($_id: String!) { + formDetail(_id: $_id) { + _id + title + code + } + } + `; + + const response = await graphqlRequest(qry, 'formDetail', { _id: form._id }); + expect(response.title).toBe('title'); + expect(response.code).toBe('code'); + }); +}); diff --git a/src/__tests__/insightExportQueries.ts b/src/__tests__/insightExportQueries.ts new file mode 100644 index 000000000..3aced8680 --- /dev/null +++ b/src/__tests__/insightExportQueries.ts @@ -0,0 +1,235 @@ +import * as moment from 'moment'; +import insightExportQueries from '../data/resolvers/queries/insights/insightExport'; +import { graphqlRequest } from '../db/connection'; +import { brandFactory, conversationFactory, conversationMessageFactory, integrationFactory } from '../db/factories'; +import { Brands, ConversationMessages, Conversations, Integrations } from '../db/models'; + +const dateToString = (date: Date) => { + return moment(date).format('YYYY-MM-DD HH:mm'); +}; + +describe('insightExportQueries', () => { + let brand; + let integration; + let conversation; + + beforeEach(async () => { + process.env.DOMAIN = 'http://localhost:3000'; + // Clearing test data + brand = await brandFactory(); + integration = await integrationFactory({ + brandId: brand._id, + kind: 'gmail', + }); + conversation = await conversationFactory({ + integrationId: integration._id, + }); + + await conversationFactory({ + integrationId: integration._id, + }); + await conversationMessageFactory({ + conversationId: conversation._id, + userId: null, + }); + }); + + afterEach(async () => { + // Clearing test data + await Brands.deleteMany({}); + await Integrations.deleteMany({}); + await Conversations.deleteMany({}); + await ConversationMessages.deleteMany({}); + }); + + test(`test if Error('Login required') exception is working as intended`, async () => { + expect.assertions(4); + + const expectError = async func => { + try { + await func(null, {}, {}); + } catch (e) { + expect(e.message).toBe('Login required'); + } + }; + + expectError(insightExportQueries.insightVolumeReportExport); + expectError(insightExportQueries.insightActivityReportExport); + expectError(insightExportQueries.insightFirstResponseReportExport); + expectError(insightExportQueries.insightTagReportExport); + }); + + test('insightVolumeReportExport', async () => { + const endDate = new Date( + moment(new Date()) + .add(1, 'days') + .toString(), + ); + const startDate = new Date( + moment(endDate) + .add(-7, 'days') + .toString(), + ); + + const args = { + integrationIds: 'gmail', + brandIds: brand._id, + startDate: dateToString(startDate), + endDate: dateToString(endDate), + }; + + const qry = ` + query insightVolumeReportExport( + $type: String, + $integrationIds: String, + $brandIds: String, + $startDate: String, + $endDate: String + ) { + insightVolumeReportExport( + type: $type, + integrationIds: $integrationIds, + brandIds: $brandIds, + startDate: $startDate, + endDate: $endDate + ) + } + `; + + const DOMAIN = process.env.DOMAIN; + const response = await graphqlRequest(qry, 'insightVolumeReportExport', args); + expect(response).toBe( + `${DOMAIN}/static/xlsTemplateOutputs/Volume report By date - ${dateToString(startDate)} - ${dateToString( + endDate, + )}.xlsx`, + ); + }); + + test('insightActivityReportExport', async () => { + const endDate = new Date( + moment(new Date()) + .add(1, 'days') + .toString(), + ); + const startDate = new Date( + moment(endDate) + .add(-7, 'days') + .toString(), + ); + + const args = { + integrationIds: 'gmail', + brandIds: brand._id, + startDate: startDate.toISOString(), + endDate: endDate.toString(), + }; + + const qry = ` + query insightActivityReportExport( + $integrationIds: String, + $brandIds: String, + $startDate: String, + $endDate: String + ) { + insightActivityReportExport( + integrationIds: $integrationIds, + brandIds: $brandIds, + startDate: $startDate, + endDate: $endDate + ) + } + `; + + const DOMAIN = process.env.DOMAIN; + const response = await graphqlRequest(qry, 'insightActivityReportExport', args); + expect(response).toBe( + `${DOMAIN}/static/xlsTemplateOutputs/Operator Activity report - ${dateToString(startDate)} - ${dateToString( + endDate, + )}.xlsx`, + ); + }); + + test('insightFirstResponseReportExport', async () => { + const endDate = new Date( + moment(new Date()) + .add(1, 'days') + .toString(), + ); + const startDate = new Date( + moment(endDate) + .add(-7, 'days') + .toString(), + ); + + const args = { + integrationIds: integration._id, + brandIds: brand._id, + startDate: startDate.toISOString(), + endDate: endDate.toString(), + }; + + const qry = ` + query insightFirstResponseReportExport( + $integrationIds: String, + $brandIds: String, + $startDate: String, + $endDate: String + ) { + insightFirstResponseReportExport( + integrationIds: $integrationIds, + brandIds: $brandIds, + startDate: $startDate, + endDate: $endDate + ) + } + `; + + const DOMAIN = process.env.DOMAIN; + const response = await graphqlRequest(qry, 'insightFirstResponseReportExport', args); + expect(response).toBe( + `${DOMAIN}/static/xlsTemplateOutputs/First Response - ${dateToString(startDate)} - ${dateToString(endDate)}.xlsx`, + ); + }); + + test('insightTagReportExport', async () => { + const endDate = new Date( + moment(new Date()) + .add(1, 'days') + .toString(), + ); + const startDate = new Date( + moment(endDate) + .add(-7, 'days') + .toString(), + ); + + const args = { + integrationIds: 'gmail', + brandIds: brand._id, + startDate: startDate.toISOString(), + endDate: endDate.toString(), + }; + + const qry = ` + query insightTagReportExport( + $integrationIds: String, + $brandIds: String, + $startDate: String, + $endDate: String + ) { + insightTagReportExport( + integrationIds: $integrationIds, + brandIds: $brandIds, + startDate: $startDate, + endDate: $endDate + ) + } + `; + + const DOMAIN = process.env.DOMAIN; + const response = await graphqlRequest(qry, 'insightTagReportExport', args); + expect(response).toBe( + `${DOMAIN}/static/xlsTemplateOutputs/Tag report - ${dateToString(startDate)} - ${dateToString(endDate)}.xlsx`, + ); + }); +}); diff --git a/src/__tests__/insightQueries.test.ts b/src/__tests__/insightQueries.test.ts index b59ca7b95..bdc4ea324 100644 --- a/src/__tests__/insightQueries.test.ts +++ b/src/__tests__/insightQueries.test.ts @@ -1,8 +1,70 @@ -import insightQueries from '../data/resolvers/queries/insights'; +import * as moment from 'moment'; +import insightQueries from '../data/resolvers/queries/insights/insights'; +import { graphqlRequest } from '../db/connection'; +import { + brandFactory, + conversationFactory, + conversationMessageFactory, + integrationFactory, + userFactory, +} from '../db/factories'; +import { Brands, ConversationMessages, Conversations, Integrations } from '../db/models'; describe('insightQueries', () => { + let brand; + let integration; + let conversation; + let doc; + const endDate = new Date( + moment(new Date()) + .add(1, 'days') + .toString(), + ); + + const startDate = new Date( + moment(endDate) + .add(-7, 'days') + .toString(), + ).toISOString(); + + beforeEach(async () => { + // Clearing test data + brand = await brandFactory(); + integration = await integrationFactory({ + brandId: brand._id, + kind: 'gmail', + }); + + doc = { + integrationIds: 'gmail', + brandIds: brand._id, + startDate, + endDate: endDate.toISOString(), + }; + + conversation = await conversationFactory({ + integrationId: integration._id, + }); + + await conversationFactory({ + integrationId: integration._id, + }); + await conversationMessageFactory({ + conversationId: conversation._id, + userId: null, + }); + }); + + afterEach(async () => { + // Clearing test data + await Brands.deleteMany({}); + await Integrations.deleteMany({}); + await Conversations.deleteMany({}); + await ConversationMessages.deleteMany({}); + }); + test(`test if Error('Login required') exception is working as intended`, async () => { - expect.assertions(5); + expect.assertions(6); const expectError = async func => { try { @@ -14,8 +76,133 @@ describe('insightQueries', () => { expectError(insightQueries.insights); expectError(insightQueries.insightsPunchCard); - expectError(insightQueries.insightsMain); + expectError(insightQueries.insightsTrend); + expectError(insightQueries.insightsSummaryData); expectError(insightQueries.insightsFirstResponse); expectError(insightQueries.insightsResponseClose); }); + + test('Insights', async () => { + const qry = ` + query insights($integrationIds: String, + $brandIds: String, + $startDate: String, + $endDate: String + ) { + insights(integrationIds: $integrationIds, + brandIds: $brandIds, + startDate: $startDate, + endDate: $endDate) + } + `; + + const jsonResponse = await graphqlRequest(qry, 'insights', doc); + expect(jsonResponse.integration).toBeDefined(); + }); + + test('insightsPunchCard', async () => { + const qry = ` + query insightsPunchCard( + $integrationIds: String, + $brandIds: String, + $startDate: String, + $endDate: String + ) { + insightsPunchCard( + integrationIds: $integrationIds, + brandIds: $brandIds, + startDate: $startDate, + endDate: $endDate + ) + } + `; + + const response = await graphqlRequest(qry, 'insightsPunchCard', doc); + expect(response.length).toBe(1); + }); + + test('insightsConversation', async () => { + const qry = ` + query insightsConversation($integrationIds: String, + $brandIds: String, + $startDate: String, + $endDate: String + ) { + insightsConversation(integrationIds: $integrationIds, + brandIds: $brandIds, + startDate: $startDate, + endDate: $endDate) + } + `; + + const jsonResponse = await graphqlRequest(qry, 'insightsConversation', doc); + expect(jsonResponse.summary).toBeDefined(); + }); + + test('insightsFirstResponse', async () => { + const user = await userFactory({}); + const _conv = await conversationFactory({ + integrationId: integration._id, + firstRespondedUserId: user._id, + firstRespondedDate: new Date(), + messageCount: 2, + }); + + await conversationMessageFactory({ + conversationId: _conv._id, + }); + await conversationMessageFactory({ + conversationId: _conv._id, + }); + + const qry = ` + query insightsFirstResponse( + $integrationIds: String, + $brandIds: String, + $startDate: String, + $endDate: String + ) { + insightsFirstResponse(integrationIds: $integrationIds, + brandIds: $brandIds, + startDate: $startDate, + endDate: $endDate) + } + `; + + const response = await graphqlRequest(qry, 'insightsFirstResponse', doc); + expect(response.trend.length).toBe(1); + expect(response.teamMembers.length).toBe(1); + }); + + test('insightsResponseClose', async () => { + const user = await userFactory({}); + const _conv = await conversationFactory({ + closedAt: new Date(), + closedUserId: user._id, + integrationId: integration._id, + }); + + await conversationMessageFactory({ + conversationId: _conv._id, + }); + + const qry = ` + query insightsResponseClose( + $integrationIds: String, + $brandIds: String, + $startDate: String, + $endDate: String + ) { + insightsResponseClose( + integrationIds: $integrationIds, + brandIds: $brandIds, + startDate: $startDate, + endDate: $endDate) + } + `; + + const response = await graphqlRequest(qry, 'insightsResponseClose', doc); + expect(response.trend.length).toBe(1); + expect(response.teamMembers.length).toBe(1); + }); }); diff --git a/src/__tests__/integrationMutations.test.ts b/src/__tests__/integrationMutations.test.ts index 7577f965e..fc2ce4d5c 100644 --- a/src/__tests__/integrationMutations.test.ts +++ b/src/__tests__/integrationMutations.test.ts @@ -43,7 +43,7 @@ describe('mutations', () => { beforeEach(async () => { // Creating test data - _user = await userFactory({ role: 'admin' }); + _user = await userFactory({}); _integration = await integrationFactory({}); _brand = await brandFactory({}); @@ -284,6 +284,8 @@ describe('mutations', () => { test('Create facebook integration', async () => { process.env.FACEBOOK_APP_ID = '123321'; process.env.DOMAIN = 'qwqwe'; + process.env.INTEGRATION_ENDPOINT_URL = ''; + const account = await accountFactory({}); const args = { brandId: _brand._id, diff --git a/src/__tests__/integrationQueries.test.ts b/src/__tests__/integrationQueries.test.ts index ae958b07b..d445bef4d 100644 --- a/src/__tests__/integrationQueries.test.ts +++ b/src/__tests__/integrationQueries.test.ts @@ -3,7 +3,6 @@ import { TAG_TYPES } from '../data/constants'; import { graphqlRequest } from '../db/connection'; import { brandFactory, channelFactory, integrationFactory, tagsFactory } from '../db/factories'; import { Brands, Channels, Integrations } from '../db/models'; -import { socUtils } from '../trackers/twitterTracker'; describe('integrationQueries', () => { const qryIntegrations = ` @@ -247,18 +246,6 @@ describe('integrationQueries', () => { expect(response.byBrand[brand._id]).toBe(2); }); - test('Integration get twitter auth url', async () => { - socUtils.getTwitterAuthorizeUrl = jest.fn(); - - const qry = ` - query integrationGetTwitterAuthUrl { - integrationGetTwitterAuthUrl - } - `; - - await graphqlRequest(qry, 'integrationGetTwitterAuthUrl'); - }); - test('Integration get facebook apps list', async () => { process.env.FACEBOOK = JSON.stringify([ { diff --git a/src/__tests__/internalNoteDb.test.ts b/src/__tests__/internalNoteDb.test.ts index 5f3a18c6a..9a1774493 100644 --- a/src/__tests__/internalNoteDb.test.ts +++ b/src/__tests__/internalNoteDb.test.ts @@ -1,7 +1,7 @@ import * as faker from 'faker'; import { companyFactory, customerFactory, internalNoteFactory, userFactory } from '../db/factories'; import { InternalNotes, Users } from '../db/models'; -import { COC_CONTENT_TYPES } from '../db/models/definitions/constants'; +import { ACTIVITY_CONTENT_TYPES } from '../db/models/definitions/constants'; /* * Generate test data @@ -76,7 +76,7 @@ describe('InternalNotes model test', () => { const newCustomer = await customerFactory({}); await internalNoteFactory({ - contentType: COC_CONTENT_TYPES.CUSTOMER, + contentType: ACTIVITY_CONTENT_TYPES.CUSTOMER, contentTypeId: customer._id, }); @@ -92,7 +92,7 @@ describe('InternalNotes model test', () => { const newCompany = await companyFactory({}); await internalNoteFactory({ - contentType: COC_CONTENT_TYPES.COMPANY, + contentType: ACTIVITY_CONTENT_TYPES.COMPANY, contentTypeId: company._id, }); @@ -107,14 +107,14 @@ describe('InternalNotes model test', () => { const customer = await customerFactory({}); await internalNoteFactory({ - contentType: COC_CONTENT_TYPES.CUSTOMER, + contentType: ACTIVITY_CONTENT_TYPES.CUSTOMER, contentTypeId: customer._id, }); await InternalNotes.removeCustomerInternalNotes(customer._id); const internalNote = await InternalNotes.find({ - contentType: COC_CONTENT_TYPES.CUSTOMER, + contentType: ACTIVITY_CONTENT_TYPES.CUSTOMER, contentTypeId: customer._id, }); @@ -125,14 +125,14 @@ describe('InternalNotes model test', () => { const company = await companyFactory({}); await internalNoteFactory({ - contentType: COC_CONTENT_TYPES.COMPANY, + contentType: ACTIVITY_CONTENT_TYPES.COMPANY, contentTypeId: company._id, }); await InternalNotes.removeCompanyInternalNotes(company._id); const internalNote = await InternalNotes.find({ - contentType: COC_CONTENT_TYPES.COMPANY, + contentType: ACTIVITY_CONTENT_TYPES.COMPANY, contentTypeId: company._id, }); diff --git a/src/__tests__/messengerAppMutations.ts b/src/__tests__/messengerAppMutations.ts index 03da31ff3..5e5be83a1 100644 --- a/src/__tests__/messengerAppMutations.ts +++ b/src/__tests__/messengerAppMutations.ts @@ -8,7 +8,7 @@ describe('mutations', () => { beforeEach(async () => { // Creating test data - _user = await userFactory({ role: 'admin' }); + _user = await userFactory({}); context = { user: _user }; }); @@ -22,18 +22,16 @@ describe('mutations', () => { test('Add google meet', async () => { const args = { name: 'google meet', - credentials: { - access_token: 'access_token', - }, + accountId: Math.random().toString(), }; const mutation = ` - mutation messengerAppsAddGoogleMeet($name: String!, $credentials: JSON!) { - messengerAppsAddGoogleMeet(name: $name, credentials: $credentials) { + mutation messengerAppsAddGoogleMeet($name: String!, $accountId: String!) { + messengerAppsAddGoogleMeet(name: $name, accountId: $accountId) { name kind showInInbox - credentials + accountId } } `; @@ -43,7 +41,7 @@ describe('mutations', () => { expect(app.kind).toBe('googleMeet'); expect(app.showInInbox).toBe(true); expect(app.name).toBe(args.name); - expect(app.credentials).toBe(args.credentials); + expect(app.accountId).toBe(args.accountId); }); test('Add knowledgebase', async () => { diff --git a/src/__tests__/messengerAppsQueries.test.ts b/src/__tests__/messengerAppsQueries.test.ts new file mode 100644 index 000000000..afcd3355e --- /dev/null +++ b/src/__tests__/messengerAppsQueries.test.ts @@ -0,0 +1,79 @@ +import { graphqlRequest } from '../db/connection'; +import { messengerAppFactory } from '../db/factories'; +import { MessengerApps } from '../db/models'; + +describe('Messenger app queries', () => { + afterEach(async () => { + // Clearing test data + await MessengerApps.deleteMany({}); + }); + + test('Messenger Apps list', async () => { + await messengerAppFactory({ + credentials: { + access_token: '123', + expiry_date: Date.now(), + formCode: '123', + integrationId: '123', + }, + kind: 'knowledgebase', + }); + await messengerAppFactory({ + credentials: { + access_token: '123', + expiry_date: Date.now(), + formCode: '123', + integrationId: '123', + }, + kind: 'knowledgebase', + }); + + const qry = ` + query messengerApps($kind: String) { + messengerApps(kind: $kind) { + _id + } + } + `; + + const responses = await graphqlRequest(qry, 'messengerApps', { + kind: 'knowledgebase', + }); + + expect(responses.length).toBe(2); + }); + + test('Messenger Apps count', async () => { + await messengerAppFactory({ + credentials: { + access_token: '123', + expiry_date: Date.now(), + formCode: '123', + integrationId: '123', + }, + kind: 'knowledgebase', + }); + await messengerAppFactory({ + credentials: { + access_token: '123', + expiry_date: Date.now(), + formCode: '123', + integrationId: '123', + }, + kind: 'lead', + }); + + const qry = ` + query messengerAppsCount($kind: String) { + messengerAppsCount(kind: $kind) + } + `; + + // customer =========================== + const response = await graphqlRequest(qry, 'messengerAppsCount', { + kind: 'knowledgebase', + }); + + expect(response).toBe(1); + }); +}); diff --git a/src/__tests__/notificationTools.test.ts b/src/__tests__/notificationTools.test.ts index f35b20bc8..2d9981b95 100644 --- a/src/__tests__/notificationTools.test.ts +++ b/src/__tests__/notificationTools.test.ts @@ -20,6 +20,9 @@ describe('testings helper methods', () => { }); test('testing tools.sendNotification method', async () => { + process.env.DEFAULT_EMAIL_SERIVCE = ' '; + process.env.COMPANY_EMAIL_FROM = ' '; + // Try to send notifications when there is config not allowing it ========= await notificationConfigurationFactory({ notifType: NOTIFICATION_TYPES.CHANNEL_MEMBERS_CHANGE, diff --git a/src/__tests__/permissionDb.test.ts b/src/__tests__/permissionDb.test.ts new file mode 100644 index 000000000..8a3546a6f --- /dev/null +++ b/src/__tests__/permissionDb.test.ts @@ -0,0 +1,113 @@ +import { registerModule } from '../data/permissions/utils'; +import { permissionFactory, userFactory, usersGroupFactory } from '../db/factories'; +import { Permissions, UsersGroups } from '../db/models'; + +describe('Test permissions model', () => { + let _permission; + let _user; + let _group; + + const docGroup = { + name: 'New Group', + description: 'User group', + }; + + const doc = { + actions: ['up', ' test'], + allowed: true, + module: 'module name', + }; + + registerModule({ + module: { + name: 'new module', + description: 'd', + actions: [ + { name: 'action', description: 'd', use: [] }, + { name: 'action1', description: 'd', use: [] }, + { name: 'action2', description: 'd', use: [] }, + { name: 'action3', description: 'd', use: [] }, + ], + }, + }); + + beforeEach(async () => { + // Creating test data + _permission = await permissionFactory(); + _user = await userFactory(); + _group = await usersGroupFactory(); + _group = await usersGroupFactory(); + }); + + afterEach(async () => { + // Clearing test data + await Permissions.remove({}); + await UsersGroups.remove({}); + }); + + test('Create permission invalid action', async () => { + expect.assertions(1); + try { + await Permissions.createPermission({ userIds: [_user._id], ...doc }); + } catch (e) { + expect(e.message).toEqual('Invalid data'); + } + }); + + test('Create permission', async () => { + const permission = await Permissions.createPermission({ + ...doc, + userIds: [_user._id], + groupIds: [_group._id], + actions: ['action', 'action1', 'action2', 'action3'], + }); + + expect(permission.length).toEqual(8); + const per = permission.find(p => p.groupId === _group._id && p.action === 'action'); + expect(per.module).toEqual(doc.module); + }); + + test('Remove permission not found', async () => { + expect.assertions(1); + try { + await Permissions.removePermission([_user._id]); + } catch (e) { + expect(e.message).toEqual(`Permission not found`); + } + }); + + test('Remove permission', async () => { + const isDeleted = await Permissions.removePermission([_permission.id]); + + expect(isDeleted).toBeTruthy(); + }); + + test('Create user group', async () => { + const groupObj = await UsersGroups.createGroup(docGroup); + + expect(groupObj).toBeDefined(); + expect(groupObj.name).toEqual(docGroup.name); + expect(groupObj.description).toEqual(docGroup.description); + }); + + test('Update group', async () => { + const groupObj = await UsersGroups.updateGroup(_group._id, docGroup); + + expect(groupObj).toBeDefined(); + expect(groupObj.name).toEqual(docGroup.name); + expect(groupObj.description).toEqual(docGroup.description); + }); + + test('Remove group', async () => { + const isDeleted = await UsersGroups.removeGroup(_group.id); + expect(isDeleted).toBeTruthy(); + }); + + test('Remove group not found', async () => { + try { + await UsersGroups.removeGroup('groupId'); + } catch (e) { + expect(e.message).toBe('Group not found with id groupId'); + } + }); +}); diff --git a/src/__tests__/permissionMutations.test.ts b/src/__tests__/permissionMutations.test.ts new file mode 100644 index 000000000..76ab9c3f5 --- /dev/null +++ b/src/__tests__/permissionMutations.test.ts @@ -0,0 +1,179 @@ +import { permissionMutations } from '../data/resolvers/mutations/permissions'; +import { graphqlRequest } from '../db/connection'; +import { permissionFactory, userFactory, usersGroupFactory } from '../db/factories'; +import { Permissions, Users, UsersGroups } from '../db/models'; +import { IUserGroup } from '../db/models/definitions/permissions'; + +describe('Test permissions mutations', () => { + let _permission; + let _user; + let _group; + let context; + + const doc = { + actions: ['up', ' test'], + allowed: true, + module: 'module name', + }; + + beforeEach(async () => { + // Creating test data + _permission = await permissionFactory(); + _group = await usersGroupFactory(); + _user = await userFactory({ isOwner: true }); + + context = { user: _user }; + }); + + afterEach(async () => { + // Clearing test data + await Permissions.remove({}); + await UsersGroups.remove({}); + await Users.remove({}); + }); + + test('Permission login required functions', async () => { + const checkLogin = async (fn, args) => { + try { + await fn({}, args, {}); + } catch (e) { + expect(e.message).toEqual('Login required'); + } + }; + + expect.assertions(2); + + // add permission + checkLogin(permissionMutations.permissionsAdd, doc); + + // remove permission + checkLogin(permissionMutations.permissionsRemove, { ids: [] }); + }); + + test(`test if Error('Permission required') error is working as intended`, async () => { + const checkLogin = async (fn, args) => { + try { + await fn({}, args, { user: { _id: 'fakeId' } }); + } catch (e) { + expect(e.message).toEqual('Permission required'); + } + }; + + expect.assertions(2); + + // add permission + checkLogin(permissionMutations.permissionsAdd, doc); + + // remove permission + checkLogin(permissionMutations.permissionsRemove, { ids: [_permission._id] }); + }); + + test('Create permission', async () => { + const args = { + module: 'module name', + actions: ['manageBrands'], + userIds: [_user._id], + groupIds: [_group._id], + allowed: true, + }; + + const mutation = ` + mutation permissionsAdd( + $module: String!, + $actions: [String!]!, + $userIds: [String!], + $groupIds: [String!], + $allowed: Boolean + ) { + permissionsAdd( + module: $module + actions: $actions + userIds: $userIds + groupIds: $groupIds + allowed: $allowed + ) { + _id + module + action + userId + groupId + requiredActions + allowed + } + } + `; + + const [permission] = await graphqlRequest(mutation, 'permissionsAdd', args, context); + + expect(permission.module).toEqual('module name'); + }); + + test('Remove permission', async () => { + const ids = [_permission._id]; + + const mutation = ` + mutation permissionsRemove($ids: [String]!) { + permissionsRemove(ids: $ids) + } + `; + + await graphqlRequest(mutation, 'permissionsRemove', { ids }, context); + + expect(await Permissions.find({ _id: _permission._id })).toEqual([]); + }); + + test('Create group', async () => { + const args = { name: 'created name', description: 'created description' }; + + const mutation = ` + mutation usersGroupsAdd($name: String! $description: String!) { + usersGroupsAdd(name: $name description: $description) { + _id + name + description + } + } + `; + + const createdGroup = await graphqlRequest(mutation, 'usersGroupsAdd', args, context); + + expect(createdGroup.name).toEqual('created name'); + expect(createdGroup.description).toEqual('created description'); + }); + + test('Update group', async () => { + const args: IUserGroup = { name: 'updated name', description: 'updated description' }; + + const mutation = ` + mutation usersGroupsEdit($_id: String! $name: String! $description: String!) { + usersGroupsEdit(_id: $_id name: $name description: $description) { + _id + name + description + } + } + `; + + const updatedGroup = await graphqlRequest( + mutation, + 'usersGroupsEdit', + { _id: _group._id, ...args }, + { user: _user }, + ); + + expect(updatedGroup.name).toBe('updated name'); + expect(updatedGroup.description).toBe('updated description'); + }); + + test('Remove group', async () => { + const mutation = ` + mutation usersGroupsRemove($_id: String!) { + usersGroupsRemove(_id: $_id) + } + `; + + await graphqlRequest(mutation, 'usersGroupsRemove', { _id: _group._id }, context); + + expect(await UsersGroups.findOne({ _id: _group._id })).toBe(null); + }); +}); diff --git a/src/__tests__/permissionQueries.test.ts b/src/__tests__/permissionQueries.test.ts new file mode 100644 index 000000000..16684ae3e --- /dev/null +++ b/src/__tests__/permissionQueries.test.ts @@ -0,0 +1,37 @@ +import { permissionQueries, usersGroupQueries } from '../data/resolvers/queries/permissions'; + +describe('permissionQueries', () => { + test(`test if Error('Login required') exception is working as intended`, async () => { + expect.assertions(4); + + const expectError = async func => { + try { + await func(null, {}, {}); + } catch (e) { + expect(e.message).toBe('Login required'); + } + }; + + expectError(permissionQueries.permissions); + expectError(permissionQueries.permissionModules); + expectError(permissionQueries.permissionActions); + expectError(permissionQueries.permissionsTotalCount); + }); +}); + +describe('usersGroupQueries', () => { + test(`test if Error('Login required') exception is working as intended`, async () => { + expect.assertions(2); + + const expectError = async func => { + try { + await func(null, {}, {}); + } catch (e) { + expect(e.message).toBe('Login required'); + } + }; + + expectError(usersGroupQueries.usersGroups); + expectError(usersGroupQueries.usersGroupsTotalCount); + }); +}); diff --git a/src/__tests__/permissionUtil.test.ts b/src/__tests__/permissionUtil.test.ts new file mode 100644 index 000000000..0ca75dae9 --- /dev/null +++ b/src/__tests__/permissionUtil.test.ts @@ -0,0 +1,119 @@ +import { can, registerModule } from '../data/permissions/utils'; +import { permissionFactory, userFactory, usersGroupFactory } from '../db/factories'; +import { Permissions, Users, UsersGroups } from '../db/models'; + +describe('Test permission utils', () => { + let _user; + let _user2; + let _user3; + + const moduleObj = { + name: 'testModule', + description: 'Test module', + actions: [ + { + name: 'testModuleAction', + description: 'Test module action', + use: ['action 1', 'action 2'], + }, + ], + }; + + beforeEach(async () => { + const _group = await usersGroupFactory(); + const _group1 = await usersGroupFactory(); + + // Creating test data + _user = await userFactory({ isOwner: true }); + _user2 = await userFactory({}); + _user3 = await userFactory({ + groupIds: [_group1._id, _group._id], + }); + + await permissionFactory({ + action: 'action1', + userId: _user2._id, + }); + + await permissionFactory({ + requiredActions: ['action1', 'action2'], + userId: _user2._id, + allowed: true, + }); + + await permissionFactory({ + action: 'action3', + groupId: _group._id, + allowed: false, + }); + + await permissionFactory({ + action: 'action3', + groupId: _group._id, + allowed: true, + }); + }); + + afterEach(async () => { + // Clearing test data + await Users.remove({}); + await Permissions.remove({}); + await UsersGroups.remove({}); + }); + + test('Register module check duplicated module', async () => { + registerModule({ moduleObj }); + + expect.assertions(1); + try { + registerModule({ moduleObj }); + } catch (e) { + expect(e.message).toEqual(`"${moduleObj.name}" module has been registered`); + } + }); + + test('Register module check duplicated action', async () => { + expect.assertions(1); + try { + registerModule({ + anyModule: { + name: 'new module', + description: 'd', + actions: moduleObj.actions, + }, + }); + } catch (e) { + expect(e.message).toEqual(`"${moduleObj.actions[0].name}" action has been registered`); + } + }); + + test('Check permission userId required', async () => { + const checkPermission = await can('action'); + + expect(checkPermission).toEqual(false); + }); + + test('Check permission user not found', async () => { + const checkPermission = await can('action', 'fakeId'); + + expect(checkPermission).toEqual(false); + }); + + test('Check permission is owner', async () => { + const checkPermission = await can('action', _user._id); + + expect(checkPermission).toEqual(true); + }); + + test('Check permission', async () => { + const checkPermission = await can('action1', _user2._id); + + expect(checkPermission).toEqual(true); + }); + + test('Check permission', async () => { + const checkPermission = await can('action3', _user3._id); + + expect(checkPermission).toEqual(true); + }); +}); diff --git a/src/__tests__/productMutations.test.ts b/src/__tests__/productMutations.test.ts index 229287cb4..52f610175 100644 --- a/src/__tests__/productMutations.test.ts +++ b/src/__tests__/productMutations.test.ts @@ -23,7 +23,7 @@ describe('Test products mutations', () => { beforeEach(async () => { // Creating test data product = await productFactory({ type: 'product' }); - context = { user: await userFactory({ role: 'admin' }) }; + context = { user: await userFactory({}) }; }); afterEach(async () => { diff --git a/src/__tests__/scriptMutations.test.ts b/src/__tests__/scriptMutations.test.ts new file mode 100644 index 000000000..cb65ffb5a --- /dev/null +++ b/src/__tests__/scriptMutations.test.ts @@ -0,0 +1,105 @@ +import { graphqlRequest } from '../db/connection'; +import { userFactory } from '../db/factories'; +import { Scripts, Users } from '../db/models'; + +describe('scriptMutations', () => { + let _user; + let context; + + beforeEach(async () => { + // Creating test data + _user = await userFactory({}); + + context = { user: _user }; + }); + + afterEach(async () => { + // Clearing test data + await Users.deleteMany({}); + await Scripts.deleteMany({}); + }); + + const commonParamDefs = ` + $name: String! + $messengerId: String + $kbTopicId: String + $leadIds: [String] + `; + + const commonParams = ` + name: $name + messengerId: $messengerId + kbTopicId: $kbTopicId + leadIds: $leadIds + `; + + const doc = { + name: 'name', + messengerId: 'messengerId', + leadIds: ['leadIds'], + kbTopicId: 'kbTopicId', + }; + + test('scriptsAdd', async () => { + const mutation = ` + mutation scriptsAdd(${commonParamDefs}) { + scriptsAdd(${commonParams}) { + name + messengerId + leadIds + kbTopicId + } + } + `; + + const script = await graphqlRequest(mutation, 'scriptsAdd', doc, context); + + expect(script.name).toBe(doc.name); + expect(script.messengerId).toBe(doc.messengerId); + expect(script.leadIds[0]).toBe(doc.leadIds[0]); + expect(script.kbTopicId).toBe(doc.kbTopicId); + }); + + test('scriptsEdit', async () => { + const mutation = ` + mutation scriptsEdit($_id: String! ${commonParamDefs}){ + scriptsEdit(_id: $_id ${commonParams}) { + _id + name + messengerId + leadIds + kbTopicId + } + } + `; + + const newScript = await Scripts.create(doc); + + const updateDoc = { + name: 'name_updated', + messengerId: 'messengerId_updated', + leadIds: ['leadIds_updated'], + kbTopicId: 'kbTopicId_updated', + }; + + const script = await graphqlRequest(mutation, 'scriptsEdit', { _id: newScript._id, ...updateDoc }, context); + + expect(script.name).toBe(updateDoc.name); + expect(script.messengerId).toBe(updateDoc.messengerId); + expect(script.leadIds[0]).toBe(updateDoc.leadIds[0]); + expect(script.kbTopicId).toBe(updateDoc.kbTopicId); + }); + + test('scriptsRemove', async () => { + const mutation = ` + mutation scriptsRemove($_id: String!) { + scriptsRemove(_id: $_id) + } + `; + + const script = await Scripts.create(doc); + await graphqlRequest(mutation, 'scriptsRemove', { _id: script._id }, context); + + expect(await Scripts.find({}).countDocuments()).toBe(0); + }); +}); diff --git a/src/__tests__/social/facebook.conversationByFeed.test.ts b/src/__tests__/social/facebook.conversationByFeed.test.ts index dd53fc7ed..89bf3ee89 100755 --- a/src/__tests__/social/facebook.conversationByFeed.test.ts +++ b/src/__tests__/social/facebook.conversationByFeed.test.ts @@ -1,7 +1,7 @@ import * as sinon from 'sinon'; import { CONVERSATION_STATUSES } from '../../data/constants'; import { conversationMessageFactory, integrationFactory } from '../../db/factories'; -import { ActivityLogs, ConversationMessages, Conversations, Customers } from '../../db/models'; +import { ConversationMessages, Conversations, Customers } from '../../db/models'; import { SaveWebhookResponse } from '../../trackers/facebook'; import { graphRequest } from '../../trackers/facebookTracker'; @@ -85,9 +85,6 @@ describe('facebook integration: get or create conversation by feed info', () => expect(await ConversationMessages.find().countDocuments()).toBe(1); // 1 message expect(await Customers.find().countDocuments()).toBe(1); // 1 customer - // 1 logs - expect(await ActivityLogs.find({ 'activity.type': 'customer' }).countDocuments()).toBe(1); - const conversation = await Conversations.findOne(); if (!conversation) { diff --git a/src/__tests__/social/facebook.getOrCreateConversation.test.ts b/src/__tests__/social/facebook.getOrCreateConversation.test.ts index 1b97c14c5..70674ba41 100644 --- a/src/__tests__/social/facebook.getOrCreateConversation.test.ts +++ b/src/__tests__/social/facebook.getOrCreateConversation.test.ts @@ -253,7 +253,7 @@ describe('facebook integration: get or create conversation', () => { item: 'status', video_id: '12331213', link: 'video link', - created_time: '1533712330', + created_time: 1533712330, }; const response = await saveWebhookResponse.handlePosts(postParams); @@ -275,7 +275,7 @@ describe('facebook integration: get or create conversation', () => { verb: 'add', parent_id: '12344', item: 'status', - created_time: '1533712330', + created_time: 1533712330, }; const response = await saveWebhookResponse.handleComments(commentParams); diff --git a/src/__tests__/social/facebook.saveResponse.test.ts b/src/__tests__/social/facebook.saveResponse.test.ts index 831a8045c..be648f1de 100755 --- a/src/__tests__/social/facebook.saveResponse.test.ts +++ b/src/__tests__/social/facebook.saveResponse.test.ts @@ -1,7 +1,7 @@ import * as sinon from 'sinon'; import { CONVERSATION_STATUSES, FACEBOOK_DATA_KINDS } from '../../data/constants'; import { integrationFactory } from '../../db/factories'; -import { ActivityLogs, ConversationMessages, Conversations, Customers } from '../../db/models'; +import { ConversationMessages, Conversations, Customers } from '../../db/models'; import { SaveWebhookResponse } from '../../trackers/facebook'; import { graphRequest } from '../../trackers/facebookTracker'; @@ -73,7 +73,6 @@ describe('facebook integration: save webhook response', () => { await Conversations.deleteMany({}); await Customers.deleteMany({}); await ConversationMessages.deleteMany({}); - await ActivityLogs.deleteMany({}); }); test('via messenger event', async () => { @@ -322,9 +321,6 @@ describe('facebook integration: save webhook response', () => { expect(customer.firstName).toBe(senderName); // from mocked get info above expect(customer.facebookData.id).toBe(senderId); - // 1 logs - expect(await ActivityLogs.find({ 'activity.type': 'customer' }).countDocuments()).toBe(1); - // check message field values expect(message.createdAt).toBeDefined(); expect(message.conversationId).toBe(conversation._id); diff --git a/src/__tests__/social/gmail.test.ts b/src/__tests__/social/gmail.test.ts index 8260fa107..b066365a0 100644 --- a/src/__tests__/social/gmail.test.ts +++ b/src/__tests__/social/gmail.test.ts @@ -1,12 +1,21 @@ import * as sinon from 'sinon'; import { CONVERSATION_STATUSES } from '../../data/constants'; -import { accountFactory, conversationFactory, customerFactory, integrationFactory } from '../../db/factories'; -import { ConversationMessages, Conversations, Integrations } from '../../db/models'; +import { + accountFactory, + conversationFactory, + conversationMessageFactory, + customerFactory, + integrationFactory, + userFactory, +} from '../../db/factories'; +import { Accounts, ConversationMessages, Conversations, Integrations } from '../../db/models'; import { createMessage, + getAttachment, getGmailUpdates, getOrCreateCustomer, parseMessage, + refreshAccessToken, sendGmail, syncConversation, } from '../../trackers/gmail'; @@ -37,8 +46,8 @@ describe('gmail integration tests', () => { }; // must be created new conversation, new message - expect(await Conversations.find({}).count()).toBe(0); - expect(await ConversationMessages.find({}).count()).toBe(0); + expect(await Conversations.find({}).countDocuments()).toBe(0); + expect(await ConversationMessages.find({}).countDocuments()).toBe(0); await syncConversation(integration._id, gmailData); @@ -49,7 +58,7 @@ describe('gmail integration tests', () => { } expect(conversation.status).toBe(CONVERSATION_STATUSES.NEW); - expect(await ConversationMessages.find({}).count()).toBe(1); + expect(await ConversationMessages.find({}).countDocuments()).toBe(1); gmailData = { from: 'test@gmail.com', @@ -69,8 +78,8 @@ describe('gmail integration tests', () => { expect(conversation.status).toBe(CONVERSATION_STATUSES.OPEN); expect(conversation.content).toBe(gmailData.subject); - expect(await Conversations.find({}).count()).toBe(1); - expect(await ConversationMessages.find({}).count()).toBe(2); + expect(await Conversations.find({}).countDocuments()).toBe(1); + expect(await ConversationMessages.find({}).countDocuments()).toBe(2); }); test('Create message', async () => { @@ -312,8 +321,10 @@ describe('gmail integration tests', () => { cocId: 'customerId', }; + const user = await userFactory({}); + try { - await sendGmail(mailParams); + await sendGmail(mailParams, user); } catch (e) { expect(e.message).toBe(`Integration not found id with ${mailParams.integrationId}`); } @@ -333,8 +344,97 @@ describe('gmail integration tests', () => { mailParams.integrationId = integration._id; const mock = sinon.stub(utils, 'sendEmail').callsFake(); - await sendGmail(mailParams); + await sendGmail(mailParams, user); mock.restore(); // unwraps the spy }); + + test('Get attachment', async () => { + const conversationMessageId = 'conversationMessageId'; + const attachmentId = 'attachmentId'; + + try { + await getAttachment(conversationMessageId, attachmentId); + } catch (e) { + expect(e.message).toBe(`Conversation message not found id with ${conversationMessageId}`); + } + + const account = await accountFactory({ + uid: 'admin@erxes.io', + kind: 'gmail', + }); + + const integration = await integrationFactory({ + gmailData: { + email: 'admin@erxes.io', + accountId: account._id, + }, + }); + const conversation = await conversationFactory({ + integrationId: integration._id, + }); + const message = await conversationMessageFactory({ + conversationId: conversation._id, + gmailData: { + messageId: 'messageId', + attachments: [], + labelIds: [], + }, + }); + + const getGmailAttachment = jest.spyOn(utils, 'getGmailAttachment').mockImplementation(() => ({})); + + await getAttachment(message._id, attachmentId); + + expect(getGmailAttachment.mock.calls.length).toBe(1); + }); + + test('Refresh access token', async () => { + const integration = await integrationFactory({ + gmailData: { + email: 'admin@erxes.io', + accountId: 'accountId', + }, + }); + + const integrationId = 'integrationId'; + const tokens = { + access_token: 'access_token', + refresh_token: 'refresh_token', + expiry_date: 'expiry_date', + }; + + try { + await refreshAccessToken(integrationId, tokens); + } catch (e) { + expect(e.message).toBe(`Integration not found id with ${integrationId}`); + } + + if (!integration || !integration.gmailData) { + throw new Error('Integration not found'); + } + + try { + await refreshAccessToken(integration.id, tokens); + } catch (e) { + expect(e.message).toBe(`Account not found id with ${integration.gmailData.accountId}`); + } + + const account = await accountFactory({ kind: 'gmail' }); + const _integration = await integrationFactory({ + gmailData: { + email: 'admin@erxes.io', + accountId: account._id, + }, + }); + + await refreshAccessToken(_integration.id, tokens); + const _account = await Accounts.findOne({ _id: account._id }); + if (!_account) { + throw new Error('Account not found'); + } + expect(_account.token).toBe(tokens.access_token); + expect(_account.tokenSecret).toBe(tokens.refresh_token); + expect(_account.expireDate).toBe(tokens.expiry_date); + }); }); diff --git a/src/__tests__/social/gmailCronJob.test.ts b/src/__tests__/social/gmailCronJob.test.ts new file mode 100644 index 000000000..403f6a9d9 --- /dev/null +++ b/src/__tests__/social/gmailCronJob.test.ts @@ -0,0 +1,50 @@ +import * as sinon from 'sinon'; +import cronJobs from '../../cronJobs'; + +import { accountFactory, integrationFactory } from '../../db/factories'; +import { ConversationMessages, Conversations, Integrations } from '../../db/models'; +import { utils } from '../../trackers/gmailTracker'; + +describe('Gmail cronjob test', () => { + afterEach(async () => { + // clear + await Integrations.deleteMany({}); + await Conversations.deleteMany({}); + await ConversationMessages.deleteMany({}); + }); + + test('Gmail update integration gmailData test', async () => { + const _account = await accountFactory({ + kind: 'gmail', + uid: 'admin@erxes.io', + }); + + const integration = await integrationFactory({ + kind: 'gmail', + gmailData: { + accountId: _account._id, + email: 'admin@erxes.io', + expiration: '1547701961664', + historyId: '11055', + }, + }); + + const mock = sinon.stub(utils, 'callWatch').callsFake(() => ({ + data: { + expiration: 'expiration', + historyId: 'historyId', + }, + })); + + await cronJobs.callGmailUsersWatch(); + + const updatedIntegration = await Integrations.findOne({ _id: integration._id }); + + if (updatedIntegration && updatedIntegration.gmailData) { + expect(updatedIntegration.gmailData.expiration).toBe('expiration'); + expect(updatedIntegration.gmailData.historyId).toBe('historyId'); + } + + mock.restore(); // unwraps the spy + }); +}); diff --git a/src/__tests__/social/twitterReceiveDirectMessage.test.ts b/src/__tests__/social/twitterReceiveDirectMessage.test.ts index e6f205741..21ec2e6fc 100644 --- a/src/__tests__/social/twitterReceiveDirectMessage.test.ts +++ b/src/__tests__/social/twitterReceiveDirectMessage.test.ts @@ -1,6 +1,6 @@ import { CONVERSATION_STATUSES } from '../../data/constants'; import { conversationFactory, integrationFactory } from '../../db/factories'; -import { ActivityLogs, ConversationMessages, Conversations, Customers, Integrations } from '../../db/models'; +import { ConversationMessages, Conversations, Customers, Integrations } from '../../db/models'; import { receiveDirectMessageInformation } from '../../trackers/twitter'; describe('receive direct message response', () => { @@ -22,7 +22,6 @@ describe('receive direct message response', () => { await Conversations.deleteMany({}); await ConversationMessages.deleteMany({}); await Customers.deleteMany({}); - await ActivityLogs.deleteMany({}); }); test('reopen', async () => { @@ -159,9 +158,6 @@ describe('receive direct message response', () => { expect(customer.twitterData.id).toBe(data.sender_id); expect(customer.twitterData.id_str).toBe(data.sender_id_str); - // 1 log - expect(await ActivityLogs.find().countDocuments()).toBe(1); - // check message field values expect(message.createdAt).toBeDefined(); expect(message.conversationId).toBe(conv._id); diff --git a/src/__tests__/social/twitterReceiveTimeline.test.ts b/src/__tests__/social/twitterReceiveTimeline.test.ts index 1b3997b24..6ff7d2943 100755 --- a/src/__tests__/social/twitterReceiveTimeline.test.ts +++ b/src/__tests__/social/twitterReceiveTimeline.test.ts @@ -1,6 +1,6 @@ import { CONVERSATION_STATUSES } from '../../data/constants'; import { conversationFactory, integrationFactory } from '../../db/factories'; -import { ActivityLogs, ConversationMessages, Conversations, Customers, Integrations } from '../../db/models'; +import { ConversationMessages, Conversations, Customers, Integrations } from '../../db/models'; import { createOrUpdateTimelineConversation, @@ -22,7 +22,6 @@ describe('createOrUpdateTimelineConversation', () => { await Conversations.deleteMany({}); await ConversationMessages.deleteMany({}); await Customers.deleteMany({}); - await ActivityLogs.deleteMany({}); }); const data = { @@ -114,9 +113,6 @@ describe('createOrUpdateTimelineConversation', () => { expect(customer.twitterData.id).toBe(2424242424); expect(customer.twitterData.id_str).toBe('24242424242'); - // 1 log - expect(await ActivityLogs.find().countDocuments()).toBe(1); - // second call ================= const updatedData = { ...data, @@ -130,7 +126,6 @@ describe('createOrUpdateTimelineConversation', () => { expect(await Conversations.find({}).countDocuments()).toBe(1); // 1 conversation expect(await Customers.find({}).countDocuments()).toBe(1); // 1 customer - expect(await ActivityLogs.find({}).countDocuments()).toBe(1); // 1 log await Conversations.updateOne({ _id: conversation._id }, { $set: { status: 'closed' } }); @@ -204,9 +199,6 @@ describe('createOrUpdateTimelineConversation', () => { expect(customer.twitterData.id).toBe(2424242424); expect(customer.twitterData.id_str).toBe('24242424242'); - // 1 log - expect(await ActivityLogs.find().countDocuments()).toBe(1); - // second call ================= const updatedData = { ...data, @@ -221,7 +213,6 @@ describe('createOrUpdateTimelineConversation', () => { expect(await Conversations.find().countDocuments()).toBe(1); // 1 conversation expect(await ConversationMessages.find().countDocuments()).toBe(1); // 1 message expect(await Customers.find().countDocuments()).toBe(1); // 1 customer - expect(await ActivityLogs.find().countDocuments()).toBe(1); // 1 log message = await ConversationMessages.findOne(); @@ -248,7 +239,6 @@ describe('createOrUpdateTimelineConversation', () => { expect(await Conversations.find().countDocuments()).toBe(1); // 1 conversation expect(await ConversationMessages.find().countDocuments()).toBe(2); // 2 message expect(await Customers.find().countDocuments()).toBe(1); // 1 customer - expect(await ActivityLogs.find().countDocuments()).toBe(1); // 1 log }); test('receive', async () => { @@ -258,6 +248,5 @@ describe('createOrUpdateTimelineConversation', () => { expect(await Conversations.find().countDocuments()).toBe(1); // 1 conversation expect(await ConversationMessages.find().countDocuments()).toBe(1); // 1 message expect(await Customers.find().countDocuments()).toBe(1); // 1 customer - expect(await ActivityLogs.find().countDocuments()).toBe(1); // 1 log }); }); diff --git a/src/__tests__/userDb.test.ts b/src/__tests__/userDb.test.ts index bbff6e5d6..0df48b6f6 100644 --- a/src/__tests__/userDb.test.ts +++ b/src/__tests__/userDb.test.ts @@ -1,6 +1,7 @@ import * as bcrypt from 'bcrypt'; import * as jwt from 'jsonwebtoken'; -import { userFactory } from '../db/factories'; +import * as moment from 'moment'; +import { userFactory, usersGroupFactory } from '../db/factories'; import { Users } from '../db/models'; beforeAll(() => { @@ -39,7 +40,6 @@ describe('User db utils', () => { expect(userObj._id).toBeDefined(); expect(userObj.username).toBe(_user.username); expect(userObj.email).toBe('qwerty@qwerty.com'); - expect(userObj.role).toBe(_user.role); expect(bcrypt.compare(testPassword, userObj.password)).toBeTruthy(); expect(userObj.details.position).toBe(_user.details.position); expect(userObj.details.fullName).toBe(_user.details.fullName); @@ -114,6 +114,133 @@ describe('User db utils', () => { } }); + test('createUserWithConfirmation', async () => { + const group = await usersGroupFactory(); + const token = await Users.createUserWithConfirmation({ email: '123@gmail.com', groupId: group._id }); + + const userObj = await Users.findOne({ registrationToken: token }).lean(); + + if (!userObj) { + throw new Error('User not found'); + } + + expect(userObj).toBeDefined(); + expect(userObj._id).toBeDefined(); + expect(userObj.groupIds).toEqual([group._id]); + expect(userObj.registrationToken).toBeDefined(); + }); + + test('updateOnBoardSeen', async () => { + const user = await userFactory({}); + + await Users.updateOnBoardSeen({ _id: user._id }); + + const userObj = await Users.findOne({ _id: user._id }); + + if (!userObj) { + throw new Error('User not found'); + } + + expect(userObj.hasSeenOnBoard).toBeTruthy(); + }); + + test('confirmInvitation', async () => { + const email = '123@gmail.com'; + const token = 'token'; + + let userObj = await userFactory({ + email, + registrationToken: token, + registrationTokenExpires: moment(Date.now()) + .add(7, 'days') + .toDate(), + }); + + if (!userObj) { + throw new Error('User not found'); + } + + await Users.confirmInvitation({ + token, + password: '123', + passwordConfirmation: '123', + fullName: 'fullname', + username: 'username', + }); + + const result = await Users.findOne({ + _id: userObj._id, + }); + + if (!result || !result.details) { + throw new Error('User not found'); + } + + expect(result.password).toBeDefined(); + expect(result.details.fullName).toBe('fullname'); + expect(result.username).toBe('username'); + + await Users.remove({ _id: userObj._id }); + + userObj = await userFactory({ + email, + registrationToken: token, + registrationTokenExpires: moment(Date.now()) + .add(7, 'days') + .toDate(), + }); + + try { + await Users.confirmInvitation({ + token: '123321312312', + password: '', + passwordConfirmation: '', + }); + } catch (e) { + expect(e.message).toBe('Token is invalid or has expired'); + } + + try { + await Users.confirmInvitation({ + token, + password: '', + passwordConfirmation: '', + }); + } catch (e) { + expect(e.message).toBe('Password can not be empty'); + } + + try { + await Users.confirmInvitation({ + token, + password: '123', + passwordConfirmation: '1234', + }); + } catch (e) { + expect(e.message).toBe('Password does not match'); + } + + await Users.update( + { _id: userObj._id }, + { + $set: { + registrationTokenExpires: moment(Date.now()).subtract(7, 'days'), + }, + }, + ); + + // Checking expired token + try { + await Users.confirmInvitation({ + token, + password: '123', + passwordConfirmation: '123', + }); + } catch (e) { + expect(e.message).toBe('Token is invalid or has expired'); + } + }); + test('Update user', async () => { const updateDoc = await userFactory({}); @@ -140,7 +267,6 @@ describe('User db utils', () => { expect(userObj.username).toBe(updateDoc.username); expect(userObj.email).toBe('123@gmail.com'); - expect(userObj.role).toBe(userObj.role); expect(bcrypt.compare(testPassword, userObj.password)).toBeTruthy(); expect(userObj.details.position).toBe(updateDoc.details.position); expect(userObj.details.fullName).toBe(updateDoc.details.fullName); @@ -163,17 +289,48 @@ describe('User db utils', () => { expect(bcrypt.compare(testPassword, userObj.password)).toBeTruthy(); }); - test('Remove user', async () => { - const deactivatedUser = await Users.removeUser(_user._id); + test('Set user to active', async () => { + // User not found + try { + await Users.setUserActiveOrInactive('noid'); + } catch (e) { + expect(e.message).toBe('User not found'); + } + + // Can not remove owner + try { + const user = await userFactory({}); + await Users.setUserActiveOrInactive(user._id); + } catch (e) { + expect(e.message).toBe('Can not deactivate owner'); + } + + await Users.updateOne({ _id: _user._id }, { $unset: { registrationToken: 1, isOwner: false } }); + + const deactivatedUser = await Users.setUserActiveOrInactive(_user._id); + // ensure deactivated expect(deactivatedUser.isActive).toBe(false); }); + test('Set user to inactive', async () => { + await Users.updateOne( + { _id: _user._id }, + { $unset: { registrationToken: 1 }, $set: { isActive: true, isOwner: false } }, + ); + + const activatedUser = await Users.setUserActiveOrInactive(_user._id); + + // ensure deactivated + expect(activatedUser.isActive).toBe(false); + }); + test('Edit profile', async () => { const updateDoc = await userFactory({}); + const email = 'testEmail@yahoo.com'; await Users.editProfile(_user._id, { - email: 'testEmail@yahoo.com', + email, username: updateDoc.username, details: updateDoc.details, links: updateDoc.links, @@ -189,7 +346,7 @@ describe('User db utils', () => { } // TODO: find out why email field lowered automatically after mongoose v5.x expect(userObj.username).toBe(updateDoc.username); - expect(userObj.email).toBe('testemail@yahoo.com'); + expect(userObj.email).toBe(email); expect(userObj.details.position).toBe(updateDoc.details.position); expect(userObj.details.fullName).toBe(updateDoc.details.fullName); expect(userObj.details.avatar).toBe(updateDoc.details.avatar); diff --git a/src/__tests__/userMutations.test.ts b/src/__tests__/userMutations.test.ts index b74884273..1fe3aaa64 100644 --- a/src/__tests__/userMutations.test.ts +++ b/src/__tests__/userMutations.test.ts @@ -1,8 +1,9 @@ import * as bcrypt from 'bcrypt'; import * as faker from 'faker'; +import * as moment from 'moment'; import utils from '../data/utils'; import { graphqlRequest } from '../db/connection'; -import { brandFactory, channelFactory, userFactory } from '../db/factories'; +import { brandFactory, channelFactory, userFactory, usersGroupFactory } from '../db/factories'; import { Brands, Channels, Users } from '../db/models'; /* @@ -44,29 +45,23 @@ describe('User mutations', () => { const commonParamDefs = ` $username: String! $email: String! - $role: String! $details: UserDetails $links: UserLinks $channelIds: [String] - $password: String! - $passwordConfirmation: String! `; const commonParams = ` username: $username email: $email - role: $role details: $details links: $links channelIds: $channelIds - password: $password - passwordConfirmation: $passwordConfirmation `; beforeEach(async () => { // Creating test data _user = await userFactory({}); - _admin = await userFactory({ role: 'admin' }); + _admin = await userFactory({}); _channel = await channelFactory({}); _brand = await brandFactory({}); @@ -81,6 +76,8 @@ describe('User mutations', () => { }); test('Login', async () => { + process.env.HTTPS = 'false'; + const mutation = ` mutation login($email: String! $password: String!) { login(email: $email password: $password) @@ -96,6 +93,9 @@ describe('User mutations', () => { }); test('Forgot password', async () => { + process.env.MAIN_APP_DOMAIN = ' '; + process.env.COMPANY_EMAIL_FROM = ' '; + const mutation = ` mutation forgotPassword($email: String!) { forgotPassword(email: $email) @@ -150,84 +150,110 @@ describe('User mutations', () => { expect(bcrypt.compare(params.newPassword, updatedUser.password)).toBeTruthy(); }); - test('Add user', async () => { - const doc = { - ...args, - role: 'contributor', - passwordConfirmation: 'pass', - channelIds: [_channel._id], - }; + test('usersInvite', async () => { + process.env.MAIN_APP_DOMAIN = ' '; + process.env.COMPANY_EMAIL_FROM = ' '; const spyEmail = jest.spyOn(utils, 'sendEmail'); const mutation = ` - mutation usersAdd(${commonParamDefs}) { - usersAdd(${commonParams}) { - _id - username - email - role - details { - fullName - avatar - location - position - description - } - links { - linkedIn - twitter - facebook - github - youtube - website - } - } + mutation usersInvite($entries: [InvitationEntry]) { + usersInvite(entries: $entries) } `; - const user = await graphqlRequest(mutation, 'usersAdd', doc, context); + const group = await usersGroupFactory(); - const channel = await Channels.findOne({ _id: _channel._id }); + const params = { + entries: [{ email: 'test@example.com', groupId: group._id }], + }; - if (!channel) { - throw new Error('Channel not found'); + await graphqlRequest(mutation, 'usersInvite', params, { user: _admin }); + + const user = await Users.findOne({ email: 'test@example.com' }); + + if (!user) { + throw new Error('User not found'); } - expect(channel.memberIds).toContain(user._id); - expect(user.username).toBe(doc.username); - expect(user.email).toBe(doc.email.toLowerCase()); - expect(user.role).toBe(doc.role); - expect(user.details.fullName).toBe(doc.details.fullName); - expect(user.details.avatar).toBe(doc.details.avatar); - expect(user.details.location).toBe(doc.details.location); - expect(user.details.position).toBe(doc.details.position); - expect(user.details.description).toBe(doc.details.description); - expect(user.links.linkedIn).toBe(doc.links.linkedIn); - expect(user.links.twitter).toBe(doc.links.twitter); - expect(user.links.facebook).toBe(doc.links.facebook); - expect(user.links.github).toBe(doc.links.github); - expect(user.links.youtube).toBe(doc.links.youtube); - expect(user.links.website).toBe(doc.links.website); + const token = user.registrationToken || ''; + + const { MAIN_APP_DOMAIN } = process.env; + const invitationUrl = `${MAIN_APP_DOMAIN}/confirmation?token=${token}`; // send email call expect(spyEmail).toBeCalledWith({ - toEmails: [doc.email], - subject: 'Invitation info', + toEmails: ['test@example.com'], + title: 'Team member invitation', template: { - name: 'invitation', + name: 'userInvitation', data: { - username: doc.username, - password: doc.password, + content: invitationUrl, + domain: MAIN_APP_DOMAIN, }, + isCustom: true, }, }); }); + test('usersSeenOnBoard', async () => { + const mutation = ` + mutation usersSeenOnBoard { + usersSeenOnBoard { + _id + } + } + `; + + await graphqlRequest(mutation, 'usersSeenOnBoard', {}, context); + const userObj = await Users.findOne({ _id: _user._id }); + + if (!userObj) { + throw new Error('User not found'); + } + + // send email call + expect(userObj.hasSeenOnBoard).toBeTruthy(); + }); + + test('usersConfirmInvitation', async () => { + await userFactory({ + email: 'test@example.com', + registrationToken: '123', + registrationTokenExpires: moment(Date.now()) + .add(7, 'days') + .toDate(), + }); + + const mutation = ` + mutation usersConfirmInvitation($token: String, $password: String, $passwordConfirmation: String) { + usersConfirmInvitation(token: $token, password: $password, passwordConfirmation: $passwordConfirmation) { + _id + } + } + `; + + const params = { + token: '123', + password: '123', + passwordConfirmation: '123', + }; + + await graphqlRequest(mutation, 'usersConfirmInvitation', params); + + const userObj = await Users.findOne({ email: 'test@example.com' }); + + if (!userObj) { + throw new Error('User not found'); + } + + // send email call + expect(userObj).toBeDefined(); + }); + test('Edit user', async () => { const doc = { ...args, - role: 'contributor', passwordConfirmation: 'pass', channelIds: [_channel._id], }; @@ -238,7 +264,6 @@ describe('User mutations', () => { _id username email - role details { fullName avatar @@ -258,7 +283,7 @@ describe('User mutations', () => { } `; - const user = await graphqlRequest(mutation, 'usersEdit', { _id: _user._id, ...doc }, context); + const user = await graphqlRequest(mutation, 'usersEdit', { _id: _user._id, ...doc }, { user: _admin }); const channel = await Channels.findOne({ _id: _channel._id }); @@ -269,7 +294,6 @@ describe('User mutations', () => { expect(channel.memberIds).toContain(user._id); expect(user.username).toBe(doc.username); expect(user.email.toLowerCase()).toBe(doc.email.toLowerCase()); - expect(user.role).toBe(doc.role); expect(user.details.fullName).toBe(doc.details.fullName); expect(user.details.avatar).toBe(doc.details.avatar); expect(user.details.location).toBe(doc.details.location); @@ -375,12 +399,17 @@ describe('User mutations', () => { test('Remove user', async () => { const mutation = ` - mutation usersRemove($_id: String!) { - usersRemove(_id: $_id) + mutation usersSetActiveStatus($_id: String!) { + usersSetActiveStatus(_id: $_id) { + _id + isActive + } } `; - await graphqlRequest(mutation, 'usersRemove', { _id: _user._id }, { user: _admin }); + await Users.updateOne({ _id: _user._id }, { $unset: { registrationToken: 1, isOwner: false } }); + + await graphqlRequest(mutation, 'usersSetActiveStatus', { _id: _user._id }, { user: _admin }); const deactivedUser = await Users.findOne({ _id: _user._id }); diff --git a/src/__tests__/userQueries.test.ts b/src/__tests__/userQueries.test.ts index b43504bda..b291f6f74 100644 --- a/src/__tests__/userQueries.test.ts +++ b/src/__tests__/userQueries.test.ts @@ -22,7 +22,6 @@ describe('userQueries', () => { _id username email - role details { avatar fullName @@ -46,10 +45,10 @@ describe('userQueries', () => { const response = await graphqlRequest(qry, 'users', { page: 1, - perPage: 2, + perPage: 20, }); - // 1 in graphRequest + above 3 - expect(response.length).toBe(2); + + expect(response.length).toBe(5); }); test('User detail', async () => { diff --git a/src/commands/aftertest.ts b/src/commands/aftertest.ts index 36ae41b3f..c3c8861a7 100644 --- a/src/commands/aftertest.ts +++ b/src/commands/aftertest.ts @@ -1,12 +1,13 @@ import * as dotenv from 'dotenv'; import mongoose = require('mongoose'); +import { getEnv } from '../data/utils'; mongoose.Promise = global.Promise; // load environment variables dotenv.config(); -const { TEST_MONGO_URL = 'mongodb://localhost/test' } = process.env; +const TEST_MONGO_URL = getEnv({ name: 'TEST_MONGO_URL', defaultValue: `mongodb://localhost/test` }); // prevent deprecated warning related findAndModify // https://github.com/Automattic/mongoose/issues/6880 diff --git a/src/commands/custom.ts b/src/commands/custom.ts deleted file mode 100644 index e84a5b7a2..000000000 --- a/src/commands/custom.ts +++ /dev/null @@ -1,67 +0,0 @@ -import * as EmailValidator from 'email-deep-validator'; -import { connect, disconnect } from '../db/connection'; -import { ConversationMessages, Conversations, Customers } from '../db/models'; - -const validateEmail = async email => { - const emailValidator = new EmailValidator(); - const { validDomain, validMailbox } = await emailValidator.verify(email); - return validDomain && validMailbox; -}; -export const checkMail = async () => { - connect(); - - const customersWithoutMailChecks = await Customers.find({ - hasValidEmail: { $exists: false }, - primaryEmail: { $ne: '' }, - }); - - const bulkOps: Array<{ - updateOne: { filter: { _id: string }; update: { hasValidEmail: boolean } }; - }> = []; - for (const customer of customersWithoutMailChecks) { - const isValid = await validateEmail(customer.primaryEmail); - bulkOps.push({ - updateOne: { - filter: { _id: customer._id }, - update: { hasValidEmail: isValid }, - }, - }); - } - await Customers.bulkWrite(bulkOps); -}; -export const customCommand = async () => { - connect(); - - const conversations = await Conversations.find({ - firstRespondedUserId: null, - firstRespondedDate: null, - messageCount: { $gt: 1 }, - }) - .select('_id') - .sort({ createdAt: -1 }); - - for (const { _id } of conversations) { - // First message that answered to a conversation - const message = await ConversationMessages.findOne({ - conversationId: _id, - userId: { $ne: null }, - }).sort({ createdAt: 1 }); - - if (message) { - await Conversations.updateOne( - { _id }, - { - $set: { - firstRespondedUserId: message.userId, - firstRespondedDate: message.createdAt, - }, - }, - ); - } - } - - disconnect(); -}; - -customCommand(); -checkMail(); diff --git a/src/commands/engageSubscriptions.ts b/src/commands/engageSubscriptions.ts index 687d85d38..1f867aa14 100644 --- a/src/commands/engageSubscriptions.ts +++ b/src/commands/engageSubscriptions.ts @@ -1,15 +1,13 @@ import * as dotenv from 'dotenv'; +import { getEnv } from '../data/utils'; import { getApi } from '../trackers/engageTracker'; const start = () => { // load environment variables dotenv.config(); - const { AWS_SES_CONFIG_SET = '', AWS_ENDPOINT = '' } = process.env; - - if (AWS_SES_CONFIG_SET === '' || AWS_ENDPOINT === '') { - console.log('Couldnt locate configs on AWS SES'); - } + const AWS_SES_CONFIG_SET = getEnv({ name: 'AWS_SES_CONFIG_SET' }); + const AWS_ENDPOINT = getEnv({ name: 'AWS_ENDPOINT' }); let topicArn = ''; diff --git a/src/commands/initProject.ts b/src/commands/initProject.ts index b0af304a5..4f0a7a07e 100644 --- a/src/commands/initProject.ts +++ b/src/commands/initProject.ts @@ -2,18 +2,21 @@ import { connect, disconnect } from '../db/connection'; import { Users } from '../db/models'; connect() - .then(() => { + .then(async () => { // create admin user - return Users.createUser({ + const user = await Users.createUser({ username: 'admin', password: 'erxes', email: 'admin@erxes.io', isOwner: true, - role: 'admin', details: { fullName: 'Admin', }, }); + + await Users.updateOne({ _id: user._id }, { $set: { isOwner: true } }); + + return Users.findOne({ _id: user._id }); }) .then(() => { diff --git a/src/cronJobs/conversations.ts b/src/cronJobs/conversations.ts index 81c8a9bf3..1fe446af2 100644 --- a/src/cronJobs/conversations.ts +++ b/src/cronJobs/conversations.ts @@ -79,7 +79,7 @@ export const sendMessageEmail = async () => { // send email utils.sendEmail({ - to: customer.primaryEmail, + toEmails: [customer.primaryEmail], title: `Reply from "${brand.name}"`, template: { name: 'conversationCron', diff --git a/src/cronJobs/gmail.ts b/src/cronJobs/gmail.ts index e02a56c7b..97e43aa9d 100644 --- a/src/cronJobs/gmail.ts +++ b/src/cronJobs/gmail.ts @@ -3,7 +3,7 @@ import { Integrations } from '../db/models'; import { updateHistoryId } from '../trackers/gmail'; /** - * Send conversation messages to customer + * notify user to google push notification */ export const callGmailUsersWatch = async () => { const integrations = await Integrations.find({ @@ -27,7 +27,7 @@ export const callGmailUsersWatch = async () => { * └───────────────────────── second (0 - 59, OPTIONAL) */ -schedule.scheduleJob('* 45 23 * *', () => { +schedule.scheduleJob('0 22 * * *', () => { callGmailUsersWatch(); }); diff --git a/src/data/constants.ts b/src/data/constants.ts index 2acfd3162..89d9de4e4 100644 --- a/src/data/constants.ts +++ b/src/data/constants.ts @@ -164,7 +164,7 @@ export const FIELD_CONTENT_TYPES = { ALL: ['form', 'customer', 'company'], }; -export const COC_CONTENT_TYPES = { +export const ACTIVITY_CONTENT_TYPES = { CUSTOMER: 'customer', COMPANY: 'company', USER: 'user', @@ -196,11 +196,6 @@ export const COC_LIFECYCLE_STATE_TYPES = [ 'other', ]; -export const ROLES = { - ADMIN: 'admin', - CONTRIBUTOR: 'contributor', -}; - export const PUBLISH_STATUSES = { DRAFT: 'draft', PUBLISH: 'publish', @@ -328,3 +323,9 @@ export const PROBABILITY = { }; export const FACEBOOK_POST_TYPES = ['status', 'video', 'photo', 'post', 'share']; + +export const INSIGHT_TYPES = { + DEAL: 'deal', + CONVERSATION: 'conversation', + ALL: ['deal', 'conversation'], +}; diff --git a/src/data/permissions.ts b/src/data/permissions.ts index aa707dd44..5bcd261b6 100644 --- a/src/data/permissions.ts +++ b/src/data/permissions.ts @@ -1,5 +1,5 @@ import { IUserDocument } from '../db/models/definitions/users'; -import { ROLES } from './constants'; +import { can } from './permissions/utils'; /** * Checks whether user is logged in or not @@ -10,15 +10,6 @@ export const checkLogin = (user: IUserDocument) => { } }; -/** - * Checks if user is logged and if user is admin - */ -export const checkAdmin = (user: IUserDocument) => { - if (!user.isOwner && user.role !== ROLES.ADMIN) { - throw new Error('Permission required'); - } -}; - /** * Wraps object property (function) with permission checkers */ @@ -39,12 +30,6 @@ export const permissionWrapper = (cls: any, methodName: string, checkers: any) = */ export const requireLogin = (cls: any, methodName: string) => permissionWrapper(cls, methodName, [checkLogin]); -/** - * Wraps a method with 'Permission required' permission checker - */ -export const requireAdmin = (cls: any, methodName: string) => - permissionWrapper(cls, methodName, [checkLogin, checkAdmin]); - /** * Wraps all properties (methods) of a given object with 'Login required' permission checker */ @@ -57,19 +42,48 @@ export const moduleRequireLogin = (mdl: any) => { }; /** - * Wraps all properties (methods) of a given object with 'Permission required' permission checker + * Wraps all properties (methods) of a given object with 'Permission action required' permission checker */ -export const moduleRequireAdmin = (mdl: any) => { +export const moduleCheckPermission = (mdl: any, action: string, defaultValue?: any) => { for (const method in mdl) { if (mdl.hasOwnProperty(method)) { - requireAdmin(mdl, method); + checkPermission(mdl, method, action, defaultValue); } } }; +/** + * Checks if user is logged and if user is can action + * @param {Object} user - User object + * @throws {Exception} throws Error('Permission required') + * @return {null} + */ +export const checkPermission = async (cls: any, methodName: string, actionName: string, defaultValue?: any) => { + const oldMethod = cls[methodName]; + + cls[methodName] = async (root, args, { user }) => { + checkLogin(user); + + let allowed = await can(actionName, user._id); + + if (user.isOwner) { + allowed = true; + } + + if (!allowed) { + if (defaultValue) { + return defaultValue; + } + + throw new Error('Permission required'); + } + + return oldMethod(root, args, { user }); + }; +}; + export default { requireLogin, - requireAdmin, moduleRequireLogin, - moduleRequireAdmin, + checkPermission, }; diff --git a/src/data/permissions/actions/index.ts b/src/data/permissions/actions/index.ts new file mode 100644 index 000000000..ec8ce095e --- /dev/null +++ b/src/data/permissions/actions/index.ts @@ -0,0 +1,4 @@ +import { registerModule } from '../utils'; +import { moduleObjects } from './permission'; + +registerModule(moduleObjects); diff --git a/src/data/permissions/actions/permission.ts b/src/data/permissions/actions/permission.ts new file mode 100644 index 000000000..81f1625be --- /dev/null +++ b/src/data/permissions/actions/permission.ts @@ -0,0 +1,741 @@ +export const moduleObjects = { + brands: { + name: 'brands', + description: 'Brands', + actions: [ + { + name: 'brandsAll', + description: 'All', + use: ['showBrands', 'manageBrands'], + }, + { + name: 'manageBrands', + description: 'Manage brands', + }, + { + name: 'showBrands', + description: 'Show brands', + }, + ], + }, + channels: { + name: 'channels', + description: 'Channels', + actions: [ + { + name: 'channelsAll', + description: 'All', + use: ['showChannels', 'manageChannels'], + }, + { + name: 'manageChannels', + description: 'Manage channels', + }, + { + name: 'showChannels', + description: 'Show channel', + }, + ], + }, + companies: { + name: 'companies', + description: 'Companies', + actions: [ + { + name: 'companiesAll', + description: 'All', + use: [ + 'companiesAdd', + 'companiesEdit', + 'companiesEditCustomers', + 'companiesRemove', + 'companiesMerge', + 'showCompanies', + 'showCompaniesMain', + 'exportCompanies', + ], + }, + { + name: 'companiesAdd', + description: 'Add companies', + }, + { + name: 'companiesEdit', + description: 'Edit companies', + }, + { + name: 'companiesRemove', + description: 'Remove companies', + }, + { + name: 'companiesEditCustomers', + description: 'Edit companies customer', + }, + { + name: 'companiesMerge', + description: 'Merge companies', + }, + { + name: 'showCompanies', + description: 'Show companies', + }, + { + name: 'showCompaniesMain', + description: 'Show companies main', + }, + { + name: 'exportCompanies', + description: 'Export companies to xls file', + }, + ], + }, + customers: { + name: 'customers', + description: 'Customers', + actions: [ + { + name: 'customersAll', + description: 'All', + use: [ + 'showCustomers', + 'customersAdd', + 'customersEdit', + 'customersEditCompanies', + 'customersMerge', + 'customersRemove', + 'exportCustomers', + ], + }, + { + name: 'exportCustomers', + description: 'Export customers', + }, + { + name: 'showCustomers', + description: 'Show customers', + }, + { + name: 'customersAdd', + description: 'Add customer', + }, + { + name: 'customersEdit', + description: 'Edit customer', + }, + { + name: 'customersEditCompanies', + description: 'Update customers companies', + }, + { + name: 'customersMerge', + description: 'Merge customers', + }, + { + name: 'customersRemove', + description: 'Remove customers', + }, + ], + }, + deals: { + name: 'deals', + description: 'Deals', + actions: [ + { + name: 'dealsAll', + description: 'All', + use: [ + 'showDeals', + 'dealBoardsAdd', + 'dealBoardsEdit', + 'dealBoardsRemove', + 'dealPipelinesAdd', + 'dealPipelinesEdit', + 'dealPipelinesUpdateOrder', + 'dealPipelinesRemove', + 'dealStagesAdd', + 'dealStagesEdit', + 'dealStagesChange', + 'dealStagesUpdateOrder', + 'dealStagesRemove', + 'dealsAdd', + 'dealsEdit', + 'dealsRemove', + 'dealsUpdateOrder', + ], + }, + { + name: 'showDeals', + description: 'Show deals', + }, + { + name: 'dealBoardsAdd', + description: 'Add deal board', + }, + { + name: 'dealBoardsRemove', + description: 'Remove deal board', + }, + { + name: 'dealPipelinesAdd', + description: 'Add deal pipeline', + }, + { + name: 'dealPipelinesEdit', + description: 'Edit deal pipeline', + }, + { + name: 'dealPipelinesRemove', + description: 'Remove deal pipeline', + }, + { + name: 'dealPipelinesUpdateOrder', + description: 'Update pipeline order', + }, + { + name: 'dealStagesAdd', + description: 'Add deal stage', + }, + { + name: 'dealStagesEdit', + description: 'Edit deal stage', + }, + { + name: 'dealStagesChange', + description: 'Change deal stages', + }, + { + name: 'dealStagesUpdateOrder', + description: 'Update stage order', + }, + { + name: 'dealStagesRemove', + description: 'Remove deal stage', + }, + { + name: 'dealsAdd', + description: 'Add deal', + }, + { + name: 'dealsEdit', + description: 'Edit deal', + }, + { + name: 'dealsUpdateOrder', + description: 'Update deal order', + }, + { + name: 'dealsRemove', + description: 'Remove deal', + }, + ], + }, + engages: { + name: 'engages', + description: 'Engages', + actions: [ + { + name: 'engagesAll', + description: 'All', + use: [ + 'engageMessageSetLiveManual', + 'engageMessageSetPause', + 'engageMessageSetLive', + 'showEngagesMessages', + 'engageMessageAdd', + 'engageMessageEdit', + 'engageMessageRemove', + ], + }, + { + name: 'engageMessageSetLive', + description: 'Set live engage message', + }, + { + name: 'engageMessageSetPause', + description: 'Set pause engage message', + }, + { + name: 'engageMessageSetLiveManual', + description: 'Set live engage message manual', + }, + { + name: 'engageMessageRemove', + description: 'Remove engage message', + }, + { + name: 'engageMessageEdit', + description: 'Edit engage message', + }, + { + name: 'engageMessageAdd', + description: 'Add engage message', + }, + { + name: 'showEngagesMessages', + description: 'Show engages messages list', + }, + ], + }, + insights: { + name: 'insights', + description: 'Insights', + actions: [ + { + name: 'insightsAll', + description: 'All', + use: ['manageExportInsights', 'showInsights'], + }, + { + name: 'manageExportInsights', + description: 'Manage export insights', + }, + { + name: 'showInsights', + description: 'Show insights', + }, + ], + }, + knowledgeBase: { + name: 'knowledgeBase', + description: 'KnowledgeBase', + actions: [ + { + name: 'knowledgeBaseAll', + description: 'All', + use: ['showKnowledgeBase', 'manageKnowledgeBase'], + }, + { + name: 'manageKnowledgeBase', + description: 'Manage knowledge base', + }, + { + name: 'showKnowledgeBase', + description: 'Show knowledge base', + }, + ], + }, + permissions: { + name: 'permissions', + description: 'Permissions config', + actions: [ + { + name: 'permissionsAll', + description: 'All', + use: ['managePermissions', 'showPermissions', 'showPermissionModules', 'showPermissionActions'], + }, + { + name: 'managePermissions', + description: 'Manage permissions', + }, + { + name: 'showPermissions', + description: 'Show permissions', + }, + { + name: 'showPermissionsModules', + description: 'Show permissions modules', + }, + { + name: 'showPermissionsActions', + description: 'Show permissions actions', + }, + ], + }, + usersGroups: { + name: 'usersGroups', + description: 'Users Groups', + actions: [ + { + name: 'usersGroupsAll', + description: 'All', + use: ['showUsersGroups', 'manageUsersGroups'], + }, + { + name: 'manageUsersGroups', + description: 'Manage users groups', + }, + { + name: 'showUsersGroups', + description: 'Show users groups', + }, + ], + }, + scripts: { + name: 'scripts', + description: 'Scripts', + actions: [ + { + name: 'scriptsAll', + description: 'All', + use: ['showScripts', 'manageScripts'], + }, + { + name: 'manageScripts', + description: 'Manage scripts', + }, + { + name: 'showScripts', + description: 'Show scripts', + }, + ], + }, + products: { + name: 'products', + description: 'Products', + actions: [ + { + name: 'productsAll', + description: 'All', + use: ['showProducts', 'manageProducts'], + }, + { + name: 'manageProducts', + description: 'Manage products', + use: ['showProducts'], + }, + { + name: 'showProducts', + description: 'Show products', + }, + ], + }, + users: { + name: 'users', + description: 'Users', + actions: [ + { + name: 'usersAll', + description: 'All', + use: ['showUsers', 'usersEdit', 'usersInvite', 'usersSetActiveStatus'], + }, + { + name: 'showUsers', + description: 'Show users', + }, + { + name: 'usersSetActiveStatus', + description: 'Set active/deactive user', + }, + { + name: 'usersEdit', + description: 'Update user', + }, + { + name: 'usersInvite', + description: 'Invite user', + }, + ], + }, + emailTemplates: { + name: 'emailTemplates', + description: 'Email template', + actions: [ + { + name: 'emailTemplateAll', + description: 'All', + use: ['showEmailTemplates', 'manageEmailTemplate'], + }, + { + name: 'manageEmailTemplate', + description: 'Manage email template', + }, + { + name: 'showEmailTemplates', + description: 'Show email templates', + }, + ], + }, + responseTemplates: { + name: 'responseTemplates', + description: 'Response templates', + actions: [ + { + name: 'responseTemplatesAll', + description: 'All', + use: ['manageResponseTemplate', 'showResponseTemplates'], + }, + { + name: 'manageResponseTemplate', + description: 'Manage response template', + }, + { + name: 'showResponseTemplates', + description: 'Show response templates', + }, + ], + }, + importHistories: { + name: 'importHistories', + description: 'Import histories', + actions: [ + { + name: 'importHistoriesAll', + description: 'All', + use: ['importHistories', 'removeImportHistories', 'importXlsFile'], + }, + { + name: 'importXlsFile', + description: 'Import xls files', + }, + { + name: 'removeImportHistories', + description: 'Remove import histories', + }, + { + name: 'importHistories', + description: 'Show import histories', + }, + ], + }, + tags: { + name: 'tags', + description: 'Tags', + actions: [ + { + name: 'tagsAll', + description: 'All', + use: ['showTags', 'manageTags'], + }, + { + name: 'manageTags', + description: 'Manage tags', + }, + { + name: 'showTags', + description: 'Show tags', + }, + ], + }, + forms: { + name: 'forms', + description: 'Form', + actions: [ + { + name: 'formsAll', + description: 'All', + use: ['showForms', 'manageForms'], + }, + { + name: 'manageForms', + description: 'Manage forms', + }, + { + name: 'showForms', + description: 'Show forms', + }, + ], + }, + segments: { + name: 'segments', + description: 'Segments', + actions: [ + { + name: 'segmentsAll', + description: 'All', + use: ['showSegments', 'manageSegments'], + }, + { + name: 'manageSegments', + description: 'Manage segments', + }, + { + name: 'showSegments', + description: 'Show segments list', + }, + ], + }, + integrations: { + name: 'integrations', + description: 'Integrations', + actions: [ + { + name: 'integrationsAll', + description: 'All', + use: [ + 'showIntegrations', + 'integrationsCreateMessengerIntegration', + 'integrationsEditMessengerIntegration', + 'integrationsSaveMessengerAppearanceData', + 'integrationsSaveMessengerConfigs', + 'integrationsCreateFormIntegration', + 'integrationsEditFormIntegration', + 'integrationsCreateTwitterIntegration', + 'integrationsCreateFacebookIntegration', + 'integrationsCreateGmailIntegration', + 'integrationsSendGmail', + 'integrationsRemove', + ], + }, + { + name: 'showIntegrations', + description: 'Show integrations', + }, + { + name: 'integrationsCreateMessengerIntegration', + description: 'Create messenger integration', + }, + { + name: 'integrationsEditMessengerIntegration', + description: 'Edit messenger integration', + }, + { + name: 'integrationsSaveMessengerAppearanceData', + description: 'Save messenger appearance data', + }, + { + name: 'integrationsSaveMessengerConfigs', + description: 'Save messenger config', + }, + { + name: 'integrationsCreateFormIntegration', + description: 'Create form integration', + }, + { + name: 'integrationsEditFormIntegration', + description: 'Edit form integration', + }, + { + name: 'integrationsCreateTwitterIntegration', + description: 'Create twitter integration', + }, + { + name: 'integrationsCreateFacebookIntegration', + description: 'Create facebook integration', + }, + { + name: 'integrationsCreateGmailIntegration', + description: 'Create gmail integration', + }, + { + name: 'integrationsSendGmail', + description: 'Send gmail', + }, + { + name: 'integrationsRemove', + description: 'Remove integration', + }, + ], + }, + fields: { + name: 'fields', + description: 'Fields', + actions: [ + { + name: 'fieldsAll', + description: 'All', + use: ['showFields', 'manageFields'], + }, + { + name: 'manageFields', + description: 'Manage fields', + }, + { + name: 'showFields', + description: 'Show fields', + }, + ], + }, + fieldsGroups: { + name: 'fieldsGroups', + description: 'Fields groups', + actions: [ + { + name: 'fieldsGroupsAll', + description: 'All', + use: ['showFieldsGroups', 'manageFieldsGroups'], + }, + { + name: 'manageFieldsGroups', + description: 'Manage fields groups', + }, + { + name: 'showFieldsGroups', + description: 'Show fields groups', + }, + ], + }, + accounts: { + name: 'accounts', + description: 'Accounts', + actions: [ + { + name: 'accountsAll', + description: 'All', + use: ['manageAccounts', 'showAccounts'], + }, + { + name: 'manageAccounts', + description: 'Manage accounts', + }, + { + name: 'showAccounts', + description: 'Show accounts', + }, + ], + }, + inbox: { + name: 'inbox', + description: 'Inbox', + actions: [ + { + name: 'inboxAll', + description: 'All', + use: ['showConversations', 'changeConversationStatus', 'assignConversation', 'conversationMessageAdd'], + }, + { + name: 'showConversations', + description: 'Show conversations', + }, + { + name: 'changeConversationStatus', + description: 'Change conversation status', + }, + { + name: 'assignConversation', + description: 'Assign conversation', + }, + { + name: 'conversationMessageAdd', + description: 'Add conversation message', + }, + ], + }, + generalSettings: { + name: 'generalSettings', + description: 'General settings', + actions: [ + { + name: 'generalSettingsAll', + description: 'All', + use: ['manageGeneralSettings', 'showGeneralSettings'], + }, + { + name: 'showGeneralSettings', + description: 'Show general settings', + }, + { + name: 'manageGeneralSettings', + description: 'Manage general settings', + }, + ], + }, + emailAppearance: { + name: 'emailAppearance', + description: 'Email appearance', + actions: [ + { + name: 'emailAppearanceAll', + description: 'All', + use: ['manageEmailAppearance', 'showEmailappearance'], + }, + { + name: 'showEmailappearance', + description: 'Show email appearance', + }, + { + name: 'manageEmailAppearance', + description: 'Manage email appearance', + }, + ], + }, +}; diff --git a/src/data/permissions/utils.ts b/src/data/permissions/utils.ts new file mode 100644 index 000000000..1dc9fcc72 --- /dev/null +++ b/src/data/permissions/utils.ts @@ -0,0 +1,136 @@ +import { Permissions, Users } from '../../db/models'; +import { IUserDocument } from '../../db/models/definitions/users'; + +export interface IModulesMap { + name: string; + description?: string; + actions?: IActionsMap[]; +} + +export interface IActionsMap { + name?: string; + module?: string; + description?: string; + use?: string[]; +} + +// Schema: {name: description} +export const modulesMap: IModulesMap[] = []; + +/* +Schema: + { + name: { + module: '', // module name + description: '', // human friendly description + use: [] // Optional: required actions + } + } +*/ +export const actionsMap: IActionsMap = {}; + +export const registerModule = (modules: any): void => { + const moduleKeys = Object.keys(modules); + + for (const key of moduleKeys) { + const module = modules[key]; + + if (!module.actions) { + throw new Error(`Actions not found in module`); + } + + // check module, actions duplicate + if (modulesMap[module.name]) { + throw new Error(`"${module.name}" module has been registered`); + } + + if (module.actions) { + for (const action of module.actions) { + if (!action.name) { + throw new Error(`Action name is missing`); + } + + if (actionsMap[action.name]) { + throw new Error(`"${action.name}" action has been registered`); + } + } + } + + // save + modulesMap[module.name] = module.description; + + if (module.actions) { + for (const action of module.actions) { + if (!action.name) { + throw new Error('Action name is missing'); + } + + actionsMap[action.name] = { + module: module.name, + description: action.description, + }; + + if (action.use) { + actionsMap[action.name].use = action.use; + } + } + } + } +}; + +export const can = async (action: string, userId: string = ''): Promise => { + if (!userId) { + return false; + } + + const user = await Users.findOne({ _id: userId }).select({ + isOwner: 1, + groupIds: 1, + }); + + if (!user) { + return false; + } + + if (user.isOwner) { + return true; + } + + const actionMap: IActionMap = await userAllowedActions(user); + + return actionMap[action] === true; +}; + +interface IActionMap { + [key: string]: boolean; +} + +export const userAllowedActions = async (user: IUserDocument): Promise => { + const userPermissions = await Permissions.find({ userId: user._id }); + const groupPermissions = await Permissions.find({ groupId: { $in: user.groupIds } }); + + const totalPermissions = [...userPermissions, ...groupPermissions]; + const allowedActions: IActionMap = {}; + + const check = (name: string, allowed: boolean) => { + if (typeof allowedActions[name] === 'undefined') { + allowedActions[name] = allowed; + } + + if (allowedActions[name] && !allowed) { + allowedActions[name] = false; + } + }; + + for (const { requiredActions, allowed, action } of totalPermissions) { + if (requiredActions.length > 0) { + for (const actionName of requiredActions) { + check(actionName, allowed); + } + } else { + check(action, allowed); + } + } + + return allowedActions; +}; diff --git a/src/data/resolvers/activityLogForMonth.ts b/src/data/resolvers/activityLogForMonth.ts deleted file mode 100644 index f82235f83..000000000 --- a/src/data/resolvers/activityLogForMonth.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { ActivityLogs } from '../../db/models'; - -// TODO: to check obj type - -/* - * Placeholder object for ActivityLogForMonth resolver (used with graphql) - */ -export default { - /** - * Returns current date interval - */ - date(obj) { - return obj.date.yearMonth; - }, - - /** - * Returns a list of activity logs present in the given date interval - */ - list(obj) { - return ActivityLogs.find({ - 'coc.type': obj.cocContentType, - 'coc.id': obj.coc._id, - createdAt: { - $gte: obj.date.interval.start, - $lt: obj.date.interval.end, - }, - }).sort({ createdAt: -1 }); - }, -}; diff --git a/src/data/resolvers/dealStages.ts b/src/data/resolvers/dealStages.ts index a9aa850c0..164eb90ec 100644 --- a/src/data/resolvers/dealStages.ts +++ b/src/data/resolvers/dealStages.ts @@ -1,30 +1,45 @@ import { Deals } from '../../db/models'; import { IStageDocument } from '../../db/models/definitions/deals'; +import { dealsCommonFilter } from './queries/utils'; export default { - async amount(stage: IStageDocument) { - const deals = await Deals.find({ stageId: stage._id }); - const amountsMap = {}; - - deals.forEach(deal => { - const data = deal.productsData || []; - - data.forEach(product => { - const type = product.currency; + async amount(stage: IStageDocument, _args, _context, { variableValues: { search } }) { + const amountList = await Deals.aggregate([ + { + $match: dealsCommonFilter({ stageId: stage._id }, { search }), + }, + { + $unwind: '$productsData', + }, + { + $project: { + amount: '$productsData.amount', + currency: '$productsData.currency', + }, + }, + { + $group: { + _id: '$currency', + amount: { $sum: '$amount' }, + }, + }, + ]); - if (type) { - if (!amountsMap[type]) { - amountsMap[type] = 0; - } + const amountsMap = {}; - amountsMap[type] += product.amount || 0; - } - }); + amountList.forEach(item => { + if (item._id) { + amountsMap[item._id] = item.amount; + } }); return amountsMap; }, + dealsTotalCount(stage: IStageDocument, _args, _context, { variableValues: { search } }) { + return Deals.find(dealsCommonFilter({}, { search })).count({ stageId: stage._id }); + }, + deals(stage: IStageDocument) { return Deals.find({ stageId: stage._id }).sort({ order: 1, createdAt: -1 }); }, diff --git a/src/data/resolvers/index.ts b/src/data/resolvers/index.ts index 5c5c36bc7..aa7092f01 100644 --- a/src/data/resolvers/index.ts +++ b/src/data/resolvers/index.ts @@ -1,5 +1,5 @@ +import * as permissionActions from '../permissions/actions'; import ActivityLog from './activityLog'; -import ActivityLogForMonth from './activityLogForMonth'; import Brand from './brand'; import Channel from './channel'; import Company from './company'; @@ -21,14 +21,17 @@ import KnowledgeBaseCategory from './knowledgeBaseCategory'; import KnowledgeBaseTopic from './knowledgeBaseTopic'; import Mutation from './mutations'; import Notification from './notification'; +import Permission from './permission'; import Query from './queries'; import ResponseTemplate from './responseTemplate'; import Script from './script'; import Segment from './segment'; import Subscription from './subscriptions'; +import User from './user'; const resolvers: any = { ...customScalars, + ...permissionActions, ResponseTemplate, Script, @@ -57,11 +60,12 @@ const resolvers: any = { Notification, ActivityLog, - ActivityLogForMonth, Form, FieldsGroup: fieldsGroup, Field: field, + User, ImportHistory, + Permission, }; export default resolvers; diff --git a/src/data/resolvers/mutations/accounts.ts b/src/data/resolvers/mutations/accounts.ts index df9a25643..f8252a924 100644 --- a/src/data/resolvers/mutations/accounts.ts +++ b/src/data/resolvers/mutations/accounts.ts @@ -1,26 +1,6 @@ import { Accounts } from '../../../db/models'; -import { getGmailUserProfile, stopReceivingEmail } from '../../../trackers/gmailTracker'; -import { getAccessToken } from '../../../trackers/googleTracker'; -import { socUtils } from '../../../trackers/twitterTracker'; -import { moduleRequireLogin } from '../../permissions'; - -interface ITwitterAuthParams { - oauth_token: string; - oauth_verifier: string; -} - -interface IAuthenticateResponse { - tokens: { - auth: { - token: string; - token_secret: string; - }; - }; - info?: { - name?: string; - id?: string; - }; -} +import { utils } from '../../../trackers/gmailTracker'; +import { moduleCheckPermission } from '../../permissions'; const accountMutations = { /** @@ -36,48 +16,13 @@ const accountMutations = { if (account.kind === 'gmail') { const credentials = await Accounts.getGmailCredentials(account.uid); // remove email from google push notification - stopReceivingEmail(account.uid, credentials); + await utils.stopReceivingEmail(account.uid, credentials); } return Accounts.removeAccount(_id); }, - - /** - * link twitter account - */ - async accountsAddTwitter(_root, { queryParams }: { queryParams: ITwitterAuthParams }) { - const data: IAuthenticateResponse = await socUtils.authenticate(queryParams); - - return Accounts.create({ - kind: 'twitter', - token: data.tokens.auth.token, - tokenSecret: data.tokens.auth.token_secret, - name: data.info && data.info.name, - uid: data.info && data.info.id, - }); - }, - - /** - * link Gmail account - */ - async accountsAddGmail(_root, { code }: { code: string }) { - const credentials: any = await getAccessToken(code, 'gmail'); - // get email address connected with - const { data } = await getGmailUserProfile(credentials); - const email = data.emailAddress || ''; - - return Accounts.createAccount({ - name: email, - uid: email, - kind: 'gmail', - token: credentials.access_token, - tokenSecret: credentials.refresh_token, - expireDate: credentials.expiry_date, - scope: credentials.scope, - }); - }, }; -moduleRequireLogin(accountMutations); +moduleCheckPermission(accountMutations, 'manageAccounts'); export default accountMutations; diff --git a/src/data/resolvers/mutations/activityLogs.ts b/src/data/resolvers/mutations/activityLogs.ts deleted file mode 100644 index 2c2e323f8..000000000 --- a/src/data/resolvers/mutations/activityLogs.ts +++ /dev/null @@ -1,63 +0,0 @@ -import { ActivityLogs, Companies, Conversations, Customers, Deals } from '../../../db/models'; - -export default { - /** - * Add conversation log - */ - async activityLogsAddConversationLog( - _root, - { customerId, conversationId }: { customerId: string; conversationId: string }, - ) { - const customer = await Customers.findOne({ _id: customerId }); - const conversation = await Conversations.findOne({ _id: conversationId }); - - if (!conversation) { - throw new Error('Conversation not found'); - } - - if (!customer) { - throw new Error('Customer not found'); - } - - return ActivityLogs.createConversationLog(conversation, customer); - }, - - /** - * Create customer registration log for the given customer - */ - async activityLogsAddCustomerLog(_root, { _id }: { _id: string }) { - const customer = await Customers.findOne({ _id }); - - if (!customer) { - throw new Error('Customer not found'); - } - - return ActivityLogs.createCustomerRegistrationLog(customer); - }, - - /** - * Creates company registration log for the given company - */ - async activityLogsAddCompanyLog(_root, { _id }: { _id: string }) { - const company = await Companies.findOne({ _id }); - - if (!company) { - throw new Error('Company not found'); - } - - return ActivityLogs.createCompanyRegistrationLog(company); - }, - - /** - * Creates deal registration log for the given deal - */ - async activityLogsAddDealLog(_root, { _id }: { _id: string }) { - const deal = await Deals.findOne({ _id }); - - if (!deal) { - throw new Error('Deal not found'); - } - - return ActivityLogs.createDealRegistrationLog(deal); - }, -}; diff --git a/src/data/resolvers/mutations/brands.ts b/src/data/resolvers/mutations/brands.ts index 446022ec5..98f08476a 100644 --- a/src/data/resolvers/mutations/brands.ts +++ b/src/data/resolvers/mutations/brands.ts @@ -1,7 +1,7 @@ import { Brands } from '../../../db/models'; import { IBrand, IBrandEmailConfig } from '../../../db/models/definitions/brands'; import { IUserDocument } from '../../../db/models/definitions/users'; -import { moduleRequireAdmin } from '../../permissions'; +import { moduleCheckPermission } from '../../permissions'; interface IBrandsEdit extends IBrand { _id: string; @@ -44,6 +44,6 @@ const brandMutations = { }, }; -moduleRequireAdmin(brandMutations); +moduleCheckPermission(brandMutations, 'manageBrands'); export default brandMutations; diff --git a/src/data/resolvers/mutations/channels.ts b/src/data/resolvers/mutations/channels.ts index 2bc8b9c7a..7c49def11 100644 --- a/src/data/resolvers/mutations/channels.ts +++ b/src/data/resolvers/mutations/channels.ts @@ -2,7 +2,7 @@ import { Channels } from '../../../db/models'; import { IChannel, IChannelDocument } from '../../../db/models/definitions/channels'; import { IUserDocument } from '../../../db/models/definitions/users'; import { NOTIFICATION_TYPES } from '../../constants'; -import { moduleRequireAdmin } from '../../permissions'; +import { moduleCheckPermission } from '../../permissions'; import utils from '../../utils'; interface IChannelsEdit extends IChannel { @@ -43,23 +43,17 @@ const channelMutations = { * Update channel data */ async channelsEdit(_root, { _id, ...doc }: IChannelsEdit) { - const channel = await Channels.updateChannel(_id, doc); - - await sendChannelNotifications(channel); - - return channel; + return Channels.updateChannel(_id, doc); }, /** * Remove a channel */ - async channelsRemove(_root, { _id }: { _id: string }) { - await Channels.removeChannel(_id); - - return _id; + channelsRemove(_root, { _id }: { _id: string }) { + return Channels.removeChannel(_id); }, }; -moduleRequireAdmin(channelMutations); +moduleCheckPermission(channelMutations, 'manageChannels'); export default channelMutations; diff --git a/src/data/resolvers/mutations/companies.ts b/src/data/resolvers/mutations/companies.ts index 9580b5386..ee30bd1f9 100644 --- a/src/data/resolvers/mutations/companies.ts +++ b/src/data/resolvers/mutations/companies.ts @@ -1,7 +1,7 @@ -import { ActivityLogs, Companies } from '../../../db/models'; +import { Companies } from '../../../db/models'; import { ICompany } from '../../../db/models/definitions/companies'; import { IUserDocument } from '../../../db/models/definitions/users'; -import { moduleRequireLogin } from '../../permissions'; +import { checkPermission } from '../../permissions'; interface ICompaniesEdit extends ICompany { _id: string; @@ -14,8 +14,6 @@ const companyMutations = { async companiesAdd(_root, doc: ICompany, { user }: { user: IUserDocument }) { const company = await Companies.createCompany(doc, user); - await ActivityLogs.createCompanyRegistrationLog(company, user); - return company; }, @@ -53,6 +51,10 @@ const companyMutations = { }, }; -moduleRequireLogin(companyMutations); +checkPermission(companyMutations, 'companiesAdd', 'companiesAdd'); +checkPermission(companyMutations, 'companiesEdit', 'companiesEdit'); +checkPermission(companyMutations, 'companiesEditCustomers', 'companiesEditCustomers'); +checkPermission(companyMutations, 'companiesRemove', 'companiesRemove'); +checkPermission(companyMutations, 'companiesMerge', 'companiesMerge'); export default companyMutations; diff --git a/src/data/resolvers/mutations/configs.ts b/src/data/resolvers/mutations/configs.ts index 05ae6cd02..e7a2e69ab 100644 --- a/src/data/resolvers/mutations/configs.ts +++ b/src/data/resolvers/mutations/configs.ts @@ -1,6 +1,6 @@ import { Configs } from '../../../db/models'; import { IConfig } from '../../../db/models/definitions/configs'; -import { moduleRequireLogin } from '../../permissions'; +import { moduleCheckPermission } from '../../permissions'; const configMutations = { /** @@ -11,6 +11,6 @@ const configMutations = { }, }; -moduleRequireLogin(configMutations); +moduleCheckPermission(configMutations, 'manageGeneralSettings'); export default configMutations; diff --git a/src/data/resolvers/mutations/conversations.ts b/src/data/resolvers/mutations/conversations.ts index fd008026f..5fe51d893 100644 --- a/src/data/resolvers/mutations/conversations.ts +++ b/src/data/resolvers/mutations/conversations.ts @@ -1,14 +1,21 @@ import * as strip from 'strip'; import * as _ from 'underscore'; import { ConversationMessages, Conversations, Customers, Integrations } from '../../../db/models'; -import { CONVERSATION_STATUSES, KIND_CHOICES, NOTIFICATION_TYPES } from '../../../db/models/definitions/constants'; +import { + ACTIVITY_CONTENT_TYPES, + CONVERSATION_STATUSES, + KIND_CHOICES, + NOTIFICATION_TYPES, +} from '../../../db/models/definitions/constants'; import { IMessageDocument } from '../../../db/models/definitions/conversationMessages'; import { IConversationDocument } from '../../../db/models/definitions/conversations'; import { IMessengerData } from '../../../db/models/definitions/integrations'; import { IUserDocument } from '../../../db/models/definitions/users'; import { facebookReply, IFacebookReply } from '../../../trackers/facebook'; +import { sendGmail } from '../../../trackers/gmail'; import { favorite, retweet, tweet, tweetReply } from '../../../trackers/twitter'; -import { requireLogin } from '../../permissions'; +import { IMailParams } from '../../../trackers/types'; +import { checkPermission, requireLogin } from '../../permissions'; import utils from '../../utils'; import { pubsub } from '../subscriptions'; @@ -174,7 +181,7 @@ const conversationMutations = { if (kind === KIND_CHOICES.FORM && email) { utils.sendEmail({ - to: email, + toEmails: [email], title: 'Reply', template: { data: doc.content, @@ -182,6 +189,33 @@ const conversationMutations = { }); } + if (kind === KIND_CHOICES.GMAIL) { + const firstMessage = await ConversationMessages.findOne({ conversationId: conversation._id }).sort({ + createdAt: 1, + }); + + if (firstMessage && firstMessage.gmailData) { + const gmailData = firstMessage.gmailData; + + const args: IMailParams = { + integrationId: integration._id, + cocType: ACTIVITY_CONTENT_TYPES.CUSTOMER, + cocId: conversation.customerId || '', + subject: `Re: ${gmailData.subject}`, + body: doc.content, + toEmails: gmailData.from, + cc: gmailData.cc || '', + bcc: gmailData.bcc || '', + headerId: gmailData.headerId, + threadId: gmailData.threadId, + }; + + await sendGmail(args, user); + } + + return null; + } + const message = await ConversationMessages.addMessage(doc, user._id); // send reply to facebook @@ -319,8 +353,8 @@ const conversationMutations = { if (notifyCustomer && customer.primaryEmail) { // send email to customer utils.sendEmail({ - to: customer.primaryEmail, - subject: 'Conversation detail', + toEmails: [customer.primaryEmail], + title: 'Conversation detail', template: { name: 'conversationDetail', data: { @@ -360,10 +394,11 @@ const conversationMutations = { }, }; -requireLogin(conversationMutations, 'conversationMessageAdd'); -requireLogin(conversationMutations, 'conversationsAssign'); -requireLogin(conversationMutations, 'conversationsUnassign'); -requireLogin(conversationMutations, 'conversationsChangeStatus'); requireLogin(conversationMutations, 'conversationMarkAsRead'); +checkPermission(conversationMutations, 'conversationMessageAdd', 'conversationMessageAdd'); +checkPermission(conversationMutations, 'conversationsAssign', 'assignConversation'); +checkPermission(conversationMutations, 'conversationsUnassign', 'assignConversation'); +checkPermission(conversationMutations, 'conversationsChangeStatus', 'changeConversationStatus'); + export default conversationMutations; diff --git a/src/data/resolvers/mutations/customers.ts b/src/data/resolvers/mutations/customers.ts index f87254ccd..fb38b7b69 100644 --- a/src/data/resolvers/mutations/customers.ts +++ b/src/data/resolvers/mutations/customers.ts @@ -1,8 +1,8 @@ -import { ActivityLogs, Customers } from '../../../db/models'; +import { Customers } from '../../../db/models'; import { ICustomer } from '../../../db/models/definitions/customers'; import { IUserDocument } from '../../../db/models/definitions/users'; -import { moduleRequireLogin } from '../../permissions'; +import { checkPermission } from '../../permissions'; interface ICustomersEdit extends ICustomer { _id: string; @@ -15,8 +15,6 @@ const customerMutations = { async customersAdd(_root, doc: ICustomer, { user }: { user: IUserDocument }) { const customer = await Customers.createCustomer(doc, user); - await ActivityLogs.createCustomerRegistrationLog(customer, user); - return customer; }, @@ -54,6 +52,10 @@ const customerMutations = { }, }; -moduleRequireLogin(customerMutations); +checkPermission(customerMutations, 'customersAdd', 'customersAdd'); +checkPermission(customerMutations, 'customersEdit', 'customersEdit'); +checkPermission(customerMutations, 'customersEditCompanies', 'customersEditCompanies'); +checkPermission(customerMutations, 'customersMerge', 'customersMerge'); +checkPermission(customerMutations, 'customersRemove', 'customersRemove'); export default customerMutations; diff --git a/src/data/resolvers/mutations/deals.ts b/src/data/resolvers/mutations/deals.ts index c7bbe338a..fc89552f3 100644 --- a/src/data/resolvers/mutations/deals.ts +++ b/src/data/resolvers/mutations/deals.ts @@ -1,8 +1,8 @@ -import { ActivityLogs, DealBoards, DealPipelines, Deals, DealStages } from '../../../db/models'; +import { DealBoards, DealPipelines, Deals, DealStages } from '../../../db/models'; import { IOrderInput } from '../../../db/models/Deals'; import { IBoard, IDeal, IPipeline, IStage, IStageDocument } from '../../../db/models/definitions/deals'; import { IUserDocument } from '../../../db/models/definitions/users'; -import { moduleRequireLogin } from '../../permissions'; +import { checkPermission } from '../../permissions'; interface IDealBoardsEdit extends IBoard { _id: string; @@ -112,15 +112,11 @@ const dealMutations = { /** * Create new deal */ - async dealsAdd(_root, doc: IDeal, { user }: { user: IUserDocument }) { - const deal = await Deals.createDeal({ + dealsAdd(_root, doc: IDeal, { user }: { user: IUserDocument }) { + return Deals.createDeal({ ...doc, modifiedBy: user._id, }); - - await ActivityLogs.createDealRegistrationLog(deal, user); - - return deal; }, /** @@ -160,6 +156,21 @@ const dealMutations = { }, }; -moduleRequireLogin(dealMutations); +checkPermission(dealMutations, 'dealBoardsAdd', 'dealBoardsAdd'); +checkPermission(dealMutations, 'dealBoardsEdit', 'dealBoardsEdit'); +checkPermission(dealMutations, 'dealBoardsRemove', 'dealBoardsRemove'); +checkPermission(dealMutations, 'dealPipelinesAdd', 'dealPipelinesAdd'); +checkPermission(dealMutations, 'dealPipelinesEdit', 'dealPipelinesEdit'); +checkPermission(dealMutations, 'dealPipelinesUpdateOrder', 'dealPipelinesUpdateOrder'); +checkPermission(dealMutations, 'dealPipelinesRemove', 'dealPipelinesRemove'); +checkPermission(dealMutations, 'dealStagesAdd', 'dealStagesAdd'); +checkPermission(dealMutations, 'dealStagesChange', 'dealStagesChange'); +checkPermission(dealMutations, 'dealStagesUpdateOrder', 'dealStagesUpdateOrder'); +checkPermission(dealMutations, 'dealStagesRemove', 'dealStagesRemove'); +checkPermission(dealMutations, 'dealsAdd', 'dealsAdd'); +checkPermission(dealMutations, 'dealsEdit', 'dealsEdit'); +checkPermission(dealMutations, 'dealsChange', 'dealsChange'); +checkPermission(dealMutations, 'dealsUpdateOrder', 'dealsUpdateOrder'); +checkPermission(dealMutations, 'dealsRemove', 'dealsRemove'); export default dealMutations; diff --git a/src/data/resolvers/mutations/emailTemplates.ts b/src/data/resolvers/mutations/emailTemplates.ts index 902bb2815..be2d10582 100644 --- a/src/data/resolvers/mutations/emailTemplates.ts +++ b/src/data/resolvers/mutations/emailTemplates.ts @@ -1,6 +1,6 @@ import { EmailTemplates } from '../../../db/models'; import { IEmailTemplate } from '../../../db/models/definitions/emailTemplates'; -import { moduleRequireLogin } from '../../permissions'; +import { moduleCheckPermission } from '../../permissions'; interface IEmailTemplatesEdit extends IEmailTemplate { _id: string; @@ -29,6 +29,6 @@ const emailTemplateMutations = { }, }; -moduleRequireLogin(emailTemplateMutations); +moduleCheckPermission(emailTemplateMutations, 'manageEmailTemplate'); export default emailTemplateMutations; diff --git a/src/data/resolvers/mutations/engageUtils.ts b/src/data/resolvers/mutations/engageUtils.ts index 039f04f15..58555be9e 100644 --- a/src/data/resolvers/mutations/engageUtils.ts +++ b/src/data/resolvers/mutations/engageUtils.ts @@ -13,7 +13,7 @@ import { ICustomerDocument } from '../../../db/models/definitions/customers'; import { IEngageMessageDocument } from '../../../db/models/definitions/engages'; import { IUserDocument } from '../../../db/models/definitions/users'; import { EMAIL_CONTENT_PLACEHOLDER, INTEGRATION_KIND_CHOICES, MESSAGE_KINDS, METHODS } from '../../constants'; -import { createTransporter } from '../../utils'; +import { createTransporter, getEnv } from '../../utils'; import QueryBuilder from '../queries/segmentQueryBuilder'; /** @@ -58,25 +58,50 @@ const findCustomers = async ({ // find matched customers let customerQuery: any = { _id: { $in: customerIds || [] } }; - const dndAndValidEmailQuery = { - $and: [ - { - $or: [{ doNotDisturb: 'No' }, { doNotDisturb: { $exists: false } }], - }, - { - $and: [{ hasValidEmail: true }, { hasValidEmail: { $exists: true } }], - }, - ], - }; - if (segmentId) { const segment = await Segments.findOne({ _id: segmentId }); customerQuery = await QueryBuilder.segments(segment); } - return Customers.find({ ...customerQuery, ...dndAndValidEmailQuery }); + return Customers.find({ ...customerQuery, $or: [{ doNotDisturb: 'No' }, { doNotDisturb: { $exists: false } }] }); }; +const executeSendViaEmail = async ( + userEmail: string, + attachments: any, + customer: ICustomerDocument, + replacedSubject: string, + replacedContent: string, + AWS_SES_CONFIG_SET: string, + messageId: string, + mailMessageId: string, +) => { + const transporter = await createTransporter({ ses: true }); + let mailAttachment = []; + + if (attachments.length > 0) { + mailAttachment = attachments.map(file => { + return { + filename: file.name || '', + path: file.url || '', + }; + }); + } + + transporter.sendMail({ + from: userEmail, + to: customer.primaryEmail, + subject: replacedSubject, + attachments: mailAttachment, + html: replacedContent, + headers: { + 'X-SES-CONFIGURATION-SET': AWS_SES_CONFIG_SET, + EngageMessageId: messageId, + CustomerId: customer._id, + MailMessageId: mailMessageId, + }, + }); +}; /** * Send via email */ @@ -89,7 +114,8 @@ const sendViaEmail = async (message: IEngageMessageDocument) => { const { templateId, subject, content, attachments = [] } = message.email.toJSON(); - const { AWS_SES_CONFIG_SET, AWS_ENDPOINT } = process.env; + const AWS_SES_CONFIG_SET = getEnv({ name: 'AWS_SES_CONFIG_SET' }); + const AWS_ENDPOINT = getEnv({ name: 'AWS_ENDPOINT' }); const user = await Users.findOne({ _id: fromUserId }); @@ -98,6 +124,10 @@ const sendViaEmail = async (message: IEngageMessageDocument) => { } const userEmail = user.email; + if (!userEmail) { + throw new Error(`email not found with ${userEmail}`); + } + const template = await EmailTemplates.findOne({ _id: templateId }); // find matched customers @@ -129,32 +159,16 @@ const sendViaEmail = async (message: IEngageMessageDocument) => { EngageMessages.addNewDeliveryReport(message._id, mailMessageId, customer._id); // send email ========= - const transporter = await createTransporter({ ses: true }); - - let mailAttachment = []; - - if (attachments.length > 0) { - mailAttachment = attachments.map(file => { - return { - filename: file.name || '', - path: file.url || '', - }; - }); - } - - transporter.sendMail({ - from: userEmail, - to: customer.primaryEmail, - subject: replacedSubject, - attachments: mailAttachment, - html: replacedContent, - headers: { - 'X-SES-CONFIGURATION-SET': AWS_SES_CONFIG_SET, - EngageMessageId: message._id, - CustomerId: customer._id, - MailMessageId: mailMessageId, - }, - }); + utils.executeSendViaEmail( + userEmail, + attachments, + customer, + replacedSubject, + replacedContent, + AWS_SES_CONFIG_SET, + message._id, + mailMessageId, + ); } }; @@ -195,7 +209,6 @@ const sendViaMessenger = async (message: IEngageMessageDocument) => { for (const customer of customers) { // replace keys in content const replacedContent = replaceKeys({ content, customer, user }); - // create conversation const conversation = await Conversations.createConversation({ userId: fromUserId, @@ -222,7 +235,7 @@ const sendViaMessenger = async (message: IEngageMessageDocument) => { /* * Send engage messages */ -export const send = message => { +export const send = (message: IEngageMessageDocument) => { const { method, kind } = message; if (method === METHODS.EMAIL) { @@ -241,6 +254,10 @@ export const send = message => { export const handleEngageUnSubscribe = (query: { cid: string }) => Customers.updateOne({ _id: query.cid }, { $set: { doNotDisturb: 'Yes' } }); +export const utils = { + executeSendViaEmail, +}; + export default { replaceKeys, send, diff --git a/src/data/resolvers/mutations/engages.ts b/src/data/resolvers/mutations/engages.ts index 0ed21452f..e11a36eb6 100644 --- a/src/data/resolvers/mutations/engages.ts +++ b/src/data/resolvers/mutations/engages.ts @@ -3,7 +3,8 @@ import { EngageMessages, Users } from '../../../db/models'; import { IEngageMessage } from '../../../db/models/definitions/engages'; import { awsRequests } from '../../../trackers/engageTracker'; import { MESSAGE_KINDS, METHODS } from '../../constants'; -import { moduleRequireLogin } from '../../permissions'; +import { checkPermission } from '../../permissions'; +import { getEnv } from '../../utils'; import { send } from './engageUtils'; interface IEngageMessageEdit extends IEngageMessage { @@ -19,11 +20,8 @@ const engageMutations = { if (method === METHODS.EMAIL) { // Checking if configs exist - const { AWS_SES_CONFIG_SET = '', AWS_ENDPOINT = '' } = process.env; - - if (AWS_SES_CONFIG_SET === '' || AWS_ENDPOINT === '') { - throw new Error('Could not locate configs on AWS SES'); - } + getEnv({ name: 'AWS_SES_CONFIG_SET' }); + getEnv({ name: 'AWS_ENDPOINT' }); const user = await Users.findOne({ _id: fromUserId }); @@ -99,6 +97,11 @@ const engageMutations = { }, }; -moduleRequireLogin(engageMutations); +checkPermission(engageMutations, 'engageMessageAdd', 'engageMessageAdd'); +checkPermission(engageMutations, 'engageMessageEdit', 'engageMessageEdit'); +checkPermission(engageMutations, 'engageMessageRemove', 'engageMessageRemove'); +checkPermission(engageMutations, 'engageMessageSetLive', 'engageMessageSetLive'); +checkPermission(engageMutations, 'engageMessageSetPause', 'engageMessageSetPause'); +checkPermission(engageMutations, 'engageMessageSetLiveManual', 'engageMessageSetLiveManual'); export default engageMutations; diff --git a/src/data/resolvers/mutations/fields.ts b/src/data/resolvers/mutations/fields.ts index 931b553f9..cfdeacfb7 100644 --- a/src/data/resolvers/mutations/fields.ts +++ b/src/data/resolvers/mutations/fields.ts @@ -2,7 +2,7 @@ import { Fields, FieldsGroups } from '../../../db/models'; import { IField, IFieldGroup } from '../../../db/models/definitions/fields'; import { IUserDocument } from '../../../db/models/definitions/users'; import { IOrderInput } from '../../../db/models/Fields'; -import { moduleRequireLogin } from '../../permissions'; +import { moduleCheckPermission } from '../../permissions'; interface IFieldsEdit extends IField { _id: string; @@ -90,7 +90,7 @@ const fieldsGroupsMutations = { }, }; -moduleRequireLogin(fieldMutations); -moduleRequireLogin(fieldsGroupsMutations); +moduleCheckPermission(fieldMutations, 'manageFields'); +moduleCheckPermission(fieldsGroupsMutations, 'manageFieldsGroups'); export { fieldsGroupsMutations, fieldMutations }; diff --git a/src/data/resolvers/mutations/forms.ts b/src/data/resolvers/mutations/forms.ts index 262118387..cf6b06af0 100644 --- a/src/data/resolvers/mutations/forms.ts +++ b/src/data/resolvers/mutations/forms.ts @@ -1,7 +1,7 @@ import { Forms } from '../../../db/models'; import { IForm } from '../../../db/models/definitions/forms'; import { IUserDocument } from '../../../db/models/definitions/users'; -import { requireAdmin } from '../../permissions'; +import { moduleCheckPermission } from '../../permissions'; interface IFormsEdit extends IForm { _id: string; @@ -23,7 +23,6 @@ const formMutations = { }, }; -requireAdmin(formMutations, 'formsAdd'); -requireAdmin(formMutations, 'formsEdit'); +moduleCheckPermission(formMutations, 'manageForms'); export default formMutations; diff --git a/src/data/resolvers/mutations/importHistory.ts b/src/data/resolvers/mutations/importHistory.ts index 8bb0090c5..2091b84f1 100644 --- a/src/data/resolvers/mutations/importHistory.ts +++ b/src/data/resolvers/mutations/importHistory.ts @@ -1,5 +1,5 @@ import { ImportHistory } from '../../../db/models'; -import { moduleRequireLogin } from '../../permissions'; +import { checkPermission } from '../../permissions'; const importHistoryMutations = { /** @@ -10,6 +10,6 @@ const importHistoryMutations = { }, }; -moduleRequireLogin(importHistoryMutations); +checkPermission(importHistoryMutations, 'importHistoriesRemove', 'removeImportHistories'); export default importHistoryMutations; diff --git a/src/data/resolvers/mutations/index.ts b/src/data/resolvers/mutations/index.ts index 1ede20d04..199091d60 100644 --- a/src/data/resolvers/mutations/index.ts +++ b/src/data/resolvers/mutations/index.ts @@ -1,5 +1,4 @@ import accounts from './accounts'; -import activityLogs from './activityLogs'; import brands from './brands'; import channels from './channels'; import companies from './companies'; @@ -17,6 +16,7 @@ import internalNotes from './internalNotes'; import knowledgeBase from './knowledgeBase'; import messengerApps from './messengerApps'; import notifications from './notifications'; +import { permissionMutations as permissions, usersGroupMutations as usersGroups } from './permissions'; import products from './products'; import responseTemplates from './responseTemplates'; import scripts from './scripts'; @@ -44,11 +44,12 @@ export default { ...integrations, ...notifications, ...knowledgeBase, - ...activityLogs, ...deals, ...products, ...configs, ...fieldsgroups, ...importHistory, ...messengerApps, + ...permissions, + ...usersGroups, }; diff --git a/src/data/resolvers/mutations/integrations.ts b/src/data/resolvers/mutations/integrations.ts index 6177acbf6..a00af68ee 100644 --- a/src/data/resolvers/mutations/integrations.ts +++ b/src/data/resolvers/mutations/integrations.ts @@ -1,10 +1,11 @@ import { Accounts, Integrations } from '../../../db/models'; import { IIntegration, IMessengerData, IUiOptions } from '../../../db/models/definitions/integrations'; +import { IUserDocument } from '../../../db/models/definitions/users'; import { IMessengerIntegration } from '../../../db/models/Integrations'; import { sendGmail, updateHistoryId } from '../../../trackers/gmail'; import { socUtils } from '../../../trackers/twitterTracker'; -import { requireAdmin, requireLogin } from '../../permissions'; -import { sendPostRequest } from '../../utils'; +import { checkPermission } from '../../permissions'; +import { getEnv, sendPostRequest } from '../../utils'; interface IEditMessengerIntegration extends IMessengerIntegration { _id: string; @@ -91,9 +92,11 @@ const integrationMutations = { }, }); - const { INTEGRATION_ENDPOINT_URL, FACEBOOK_APP_ID, DOMAIN } = process.env; + const INTEGRATION_ENDPOINT_URL = getEnv({ name: 'INTEGRATION_ENDPOINT_URL', defaultValue: '' }); + const FACEBOOK_APP_ID = getEnv({ name: 'FACEBOOK_APP_ID' }); + const DOMAIN = getEnv({ name: 'DOMAIN' }); - if (INTEGRATION_ENDPOINT_URL) { + if (INTEGRATION_ENDPOINT_URL !== '') { for (const pageId of pageIds) { await sendPostRequest(`${INTEGRATION_ENDPOINT_URL}/service/facebook/${FACEBOOK_APP_ID}/webhook-callback`, { endPoint: DOMAIN || '', @@ -149,21 +152,28 @@ const integrationMutations = { /** * Send mail by gmail api */ - integrationsSendGmail(_root, args) { - return sendGmail(args); + integrationsSendGmail(_root, args, { user }: { user: IUserDocument }) { + return sendGmail(args, user); }, }; -requireLogin(integrationMutations, 'integrationsCreateMessengerIntegration'); -requireLogin(integrationMutations, 'integrationsEditMessengerIntegration'); -requireLogin(integrationMutations, 'integrationsSaveMessengerAppearanceData'); -requireLogin(integrationMutations, 'integrationsSaveMessengerConfigs'); -requireLogin(integrationMutations, 'integrationsCreateFormIntegration'); -requireLogin(integrationMutations, 'integrationsEditFormIntegration'); -requireLogin(integrationMutations, 'integrationsCreateTwitterIntegration'); -requireLogin(integrationMutations, 'integrationsCreateFacebookIntegration'); -requireLogin(integrationMutations, 'integrationsCreateGmailIntegration'); -requireLogin(integrationMutations, 'integrationsSendGmail'); -requireAdmin(integrationMutations, 'integrationsRemove'); +checkPermission( + integrationMutations, + 'integrationsCreateMessengerIntegration', + 'integrationsCreateMessengerIntegration', +); +checkPermission( + integrationMutations, + 'integrationsSaveMessengerAppearanceData', + 'integrationsSaveMessengerAppearanceData', +); +checkPermission(integrationMutations, 'integrationsSaveMessengerConfigs', 'integrationsSaveMessengerConfigs'); +checkPermission(integrationMutations, 'integrationsCreateFormIntegration', 'integrationsCreateFormIntegration'); +checkPermission(integrationMutations, 'integrationsEditFormIntegration', 'integrationsEditFormIntegration'); +checkPermission(integrationMutations, 'integrationsCreateTwitterIntegration', 'integrationsCreateTwitterIntegration'); +checkPermission(integrationMutations, 'integrationsCreateFacebookIntegration', 'integrationsCreateFacebookIntegration'); +checkPermission(integrationMutations, 'integrationsCreateGmailIntegration', 'integrationsCreateGmailIntegration'); +checkPermission(integrationMutations, 'integrationsSendGmail', 'integrationsSendGmail'); +checkPermission(integrationMutations, 'integrationsRemove', 'integrationsRemove'); export default integrationMutations; diff --git a/src/data/resolvers/mutations/internalNotes.ts b/src/data/resolvers/mutations/internalNotes.ts index f8db034c0..49899ec87 100644 --- a/src/data/resolvers/mutations/internalNotes.ts +++ b/src/data/resolvers/mutations/internalNotes.ts @@ -1,4 +1,4 @@ -import { ActivityLogs, InternalNotes } from '../../../db/models'; +import { InternalNotes } from '../../../db/models'; import { IInternalNote } from '../../../db/models/definitions/internalNotes'; import { IUserDocument } from '../../../db/models/definitions/users'; import { moduleRequireLogin } from '../../permissions'; @@ -14,8 +14,6 @@ const internalNoteMutations = { async internalNotesAdd(_root, args: IInternalNote, { user }: { user: IUserDocument }) { const internalNote = await InternalNotes.createInternalNote(args, user); - await ActivityLogs.createInternalNoteLog(internalNote, user); - return internalNote; }, diff --git a/src/data/resolvers/mutations/knowledgeBase.ts b/src/data/resolvers/mutations/knowledgeBase.ts index 04bc50845..8e5d50c6d 100644 --- a/src/data/resolvers/mutations/knowledgeBase.ts +++ b/src/data/resolvers/mutations/knowledgeBase.ts @@ -3,7 +3,7 @@ import { KnowledgeBaseArticles, KnowledgeBaseCategories, KnowledgeBaseTopics } f import { ITopic } from '../../../db/models/definitions/knowledgebase'; import { IUserDocument } from '../../../db/models/definitions/users'; import { IArticleCreate, ICategoryCreate } from '../../../db/models/KnowledgeBase'; -import { moduleRequireLogin } from '../../permissions'; +import { moduleCheckPermission } from '../../permissions'; const knowledgeBaseMutations = { /** @@ -78,6 +78,6 @@ const knowledgeBaseMutations = { }, }; -moduleRequireLogin(knowledgeBaseMutations); +moduleCheckPermission(knowledgeBaseMutations, 'manageKnowledgeBase'); export default knowledgeBaseMutations; diff --git a/src/data/resolvers/mutations/messengerApps.ts b/src/data/resolvers/mutations/messengerApps.ts index b6605bfdc..65733ccf4 100644 --- a/src/data/resolvers/mutations/messengerApps.ts +++ b/src/data/resolvers/mutations/messengerApps.ts @@ -1,6 +1,5 @@ import * as moment from 'moment'; -import { ConversationMessages, Conversations, Customers, Forms, MessengerApps } from '../../../db/models'; -import { IGoogleCredentials } from '../../../db/models/definitions/messengerApps'; +import { Accounts, ConversationMessages, Conversations, Customers, Forms, MessengerApps } from '../../../db/models'; import { createMeetEvent } from '../../../trackers/googleTracker'; import { requireLogin } from '../../permissions'; import { publishMessage } from './conversations'; @@ -9,12 +8,12 @@ const messengerAppMutations = { /* * Google meet */ - async messengerAppsAddGoogleMeet(_root, { name, credentials }: { name: string; credentials: IGoogleCredentials }) { + async messengerAppsAddGoogleMeet(_root, { name, accountId }: { name: string; accountId: string }) { return MessengerApps.createApp({ name, kind: 'googleMeet', showInInbox: true, - credentials, + accountId, }); }, @@ -89,6 +88,12 @@ const messengerAppMutations = { throw new Error('App not found'); } + const account = await Accounts.findOne({ _id: app.accountId }); + + if (!account) { + throw new Error('Account not found'); + } + // get customer email let email = customer.primaryEmail; @@ -96,7 +101,14 @@ const messengerAppMutations = { email = customer.visitorContactInfo.email; } - const eventData: any = await createMeetEvent(app.credentials, { + const credentials = { + access_token: account.token, + scope: 'https://www.googleapis.com/auth/calendar', + token_type: 'Bearer', + expiry_date: account.expireDate, + }; + + const eventData: any = await createMeetEvent(credentials, { summary: `Meet with ${customer.firstName} ${customer.lastName}`, attendees: [{ email }], start: { diff --git a/src/data/resolvers/mutations/permissions.ts b/src/data/resolvers/mutations/permissions.ts new file mode 100644 index 000000000..eec8ad584 --- /dev/null +++ b/src/data/resolvers/mutations/permissions.ts @@ -0,0 +1,62 @@ +import { Permissions, UsersGroups } from '../../../db/models'; +import { IPermissionParams, IUserGroup } from '../../../db/models/definitions/permissions'; +import { moduleCheckPermission } from '../../permissions'; + +const permissionMutations = { + /** + * Create new permission + * @param {String} doc.module + * @param {[String]} doc.actions + * @param {[String]} doc.userIds + * @param {Boolean} doc.allowed + * @return {Promise} newly created permission object + */ + permissionsAdd(_root, doc: IPermissionParams) { + return Permissions.createPermission(doc); + }, + + /** + * Remove permission + * @param {[String]} ids + * @return {Promise} + */ + permissionsRemove(_root, { ids }: { ids: string[] }) { + return Permissions.removePermission(ids); + }, +}; + +const usersGroupMutations = { + /** + * Create new group + * @param {String} doc.name + * @param {String} doc.description + * @return {Promise} newly created group object + */ + usersGroupsAdd(_root, doc: IUserGroup) { + return UsersGroups.createGroup(doc); + }, + + /** + * Edit group + * @param {String} doc.name + * @param {String} doc.description + * @return {Promise} updated group object + */ + usersGroupsEdit(_root, { _id, ...doc }: { _id: string } & IUserGroup) { + return UsersGroups.updateGroup(_id, doc); + }, + + /** + * Remove group + * @param {String} _id + * @return {Promise} + */ + usersGroupsRemove(_root, { _id }: { _id: string }) { + return UsersGroups.removeGroup(_id); + }, +}; + +moduleCheckPermission(permissionMutations, 'managePermissions'); +moduleCheckPermission(usersGroupMutations, 'manageUsersGroups'); + +export { permissionMutations, usersGroupMutations }; diff --git a/src/data/resolvers/mutations/products.ts b/src/data/resolvers/mutations/products.ts index 938d40298..9769b11dc 100644 --- a/src/data/resolvers/mutations/products.ts +++ b/src/data/resolvers/mutations/products.ts @@ -1,6 +1,6 @@ import { Products } from '../../../db/models'; import { IProduct } from '../../../db/models/definitions/deals'; -import { moduleRequireLogin } from '../../permissions'; +import { moduleCheckPermission } from '../../permissions'; interface IProductsEdit extends IProduct { _id: string; @@ -29,6 +29,6 @@ const productMutations = { }, }; -moduleRequireLogin(productMutations); +moduleCheckPermission(productMutations, 'manageProducts'); export default productMutations; diff --git a/src/data/resolvers/mutations/responseTemplates.ts b/src/data/resolvers/mutations/responseTemplates.ts index da7658e09..bd9c720df 100644 --- a/src/data/resolvers/mutations/responseTemplates.ts +++ b/src/data/resolvers/mutations/responseTemplates.ts @@ -1,6 +1,6 @@ import { ResponseTemplates } from '../../../db/models'; import { IResponseTemplate } from '../../../db/models/definitions/responseTemplates'; -import { moduleRequireLogin } from '../../permissions'; +import { moduleCheckPermission } from '../../permissions'; interface IResponseTemplatesEdit extends IResponseTemplate { _id: string; @@ -29,6 +29,6 @@ const responseTemplateMutations = { }, }; -moduleRequireLogin(responseTemplateMutations); +moduleCheckPermission(responseTemplateMutations, 'manageResponseTemplate'); export default responseTemplateMutations; diff --git a/src/data/resolvers/mutations/scripts.ts b/src/data/resolvers/mutations/scripts.ts index 7533f12e8..eb75b91d4 100644 --- a/src/data/resolvers/mutations/scripts.ts +++ b/src/data/resolvers/mutations/scripts.ts @@ -1,6 +1,6 @@ import { Scripts } from '../../../db/models'; import { IScript } from '../../../db/models/definitions/scripts'; -import { moduleRequireLogin } from '../../permissions'; +import { moduleCheckPermission } from '../../permissions'; interface IScriptsEdit extends IScript { _id: string; @@ -29,6 +29,6 @@ const scriptMutations = { }, }; -moduleRequireLogin(scriptMutations); +moduleCheckPermission(scriptMutations, 'manageScripts'); export default scriptMutations; diff --git a/src/data/resolvers/mutations/segments.ts b/src/data/resolvers/mutations/segments.ts index 6f587505f..6f9405664 100644 --- a/src/data/resolvers/mutations/segments.ts +++ b/src/data/resolvers/mutations/segments.ts @@ -1,6 +1,6 @@ import { Segments } from '../../../db/models'; import { ISegment } from '../../../db/models/definitions/segments'; -import { moduleRequireLogin } from '../../permissions'; +import { moduleCheckPermission } from '../../permissions'; interface ISegmentsEdit extends ISegment { _id: string; @@ -29,6 +29,6 @@ const segmentMutations = { }, }; -moduleRequireLogin(segmentMutations); +moduleCheckPermission(segmentMutations, 'manageSegments'); export default segmentMutations; diff --git a/src/data/resolvers/mutations/tags.ts b/src/data/resolvers/mutations/tags.ts index d38a3ea59..dfd94be22 100644 --- a/src/data/resolvers/mutations/tags.ts +++ b/src/data/resolvers/mutations/tags.ts @@ -1,6 +1,6 @@ import { Tags } from '../../../db/models'; import { ITag } from '../../../db/models/definitions/tags'; -import { moduleRequireLogin } from '../../permissions'; +import { checkPermission, requireLogin } from '../../permissions'; import { publishConversationsChanged } from './conversations'; interface ITagsEdit extends ITag { @@ -41,6 +41,10 @@ const tagMutations = { }, }; -moduleRequireLogin(tagMutations); +requireLogin(tagMutations, 'tagsTag'); + +checkPermission(tagMutations, 'tagsAdd', 'manageTags'); +checkPermission(tagMutations, 'tagsEdit', 'manageTags'); +checkPermission(tagMutations, 'tagsRemove', 'manageTags'); export default tagMutations; diff --git a/src/data/resolvers/mutations/users.ts b/src/data/resolvers/mutations/users.ts index d36e74edc..bae87acf5 100644 --- a/src/data/resolvers/mutations/users.ts +++ b/src/data/resolvers/mutations/users.ts @@ -1,14 +1,11 @@ import { Channels, Users } from '../../../db/models'; import { IDetail, IEmailSignature, ILink, IUser, IUserDocument } from '../../../db/models/definitions/users'; -import { requireAdmin, requireLogin } from '../../permissions'; -import utils from '../../utils'; +import { checkPermission, requireLogin } from '../../permissions'; +import utils, { authCookieOptions, getEnv } from '../../utils'; -interface IUsersAdd extends IUser { +interface IUsersEdit extends IUser { channelIds?: string[]; - passwordConfirmation?: string; -} - -interface IUsersEdit extends IUsersAdd { + groupIds?: string[]; _id: string; } @@ -21,22 +18,7 @@ const userMutations = { const { token } = response; - const oneDay = 1 * 24 * 3600 * 1000; // 1 day - - const cookieOptions = { - httpOnly: true, - expires: new Date(Date.now() + oneDay), - maxAge: oneDay, - secure: false, - }; - - const { HTTPS } = process.env; - - if (HTTPS === 'true') { - cookieOptions.secure = true; - } - - res.cookie('auth-token', token, cookieOptions); + res.cookie('auth-token', token, authCookieOptions()); return 'loggedIn'; }, @@ -56,7 +38,7 @@ const userMutations = { const token = await Users.forgotPassword(email); // send email ============== - const { MAIN_APP_DOMAIN } = process.env; + const MAIN_APP_DOMAIN = getEnv({ name: 'MAIN_APP_DOMAIN' }); const link = `${MAIN_APP_DOMAIN}/reset-password?token=${token}`; @@ -92,64 +74,18 @@ const userMutations = { return Users.changePassword({ _id: user._id, ...args }); }, - /* - * Create new user - */ - async usersAdd(_root, args: IUsersAdd) { - const { username, password, passwordConfirmation, email, role, channelIds = [], details, links } = args; - - if (password !== passwordConfirmation) { - throw new Error('Incorrect password confirmation'); - } - - const createdUser = await Users.createUser({ - username, - password, - email, - role, - details, - links, - }); - - // add new user to channels - await Channels.updateUserChannels(channelIds, createdUser._id); - - const toEmails = email ? [email] : []; - - // send email ================ - utils.sendEmail({ - toEmails, - subject: 'Invitation info', - template: { - name: 'invitation', - data: { - username, - password, - }, - }, - }); - - return createdUser; - }, - /* * Update user */ async usersEdit(_root, args: IUsersEdit) { - const { _id, username, password, passwordConfirmation, email, role, channelIds = [], details, links } = args; + const { _id, username, email, channelIds = [], groupIds = [], details, links } = args; - if (password && password !== passwordConfirmation) { - throw new Error('Incorrect password confirmation'); - } - - // TODO check isOwner const updatedUser = await Users.updateUser(_id, { username, - password, email, - role, details, links, + groupIds, }); // add new user to channels @@ -188,41 +124,74 @@ const userMutations = { if (!password || !valid) { // bad password - throw new Error('Invalid password'); + throw new Error('Invalid password. Try again'); } return Users.editProfile(user._id, { username, email, details, links }); }, /* - * Remove user + * Set Active or inactive user */ - async usersRemove(_root, { _id }: { _id: string }) { - const userToRemove = await Users.findOne({ _id }); - - if (!userToRemove) { - throw new Error('User not found'); + async usersSetActiveStatus(_root, { _id }: { _id: string }, { user }: { user: IUserDocument }) { + if (user._id === _id) { + throw new Error('You can not delete yourself'); } - // can not remove owner - if (userToRemove.isOwner) { - throw new Error('Can not remove owner'); - } + return Users.setUserActiveOrInactive(_id); + }, - // if the user involved in any channel then can not delete this user - if ((await Channels.find({ userId: userToRemove._id }).countDocuments()) > 0) { - throw new Error('You cannot delete this user. This user belongs other channel.'); + /* + * Invites users to team members + */ + async usersInvite(_root, { entries }: { entries: Array<{ email: string; groupId: string }> }) { + for (const entry of entries) { + await Users.checkDuplication({ email: entry.email }); + + const token = await Users.createUserWithConfirmation(entry); + + const MAIN_APP_DOMAIN = getEnv({ name: 'MAIN_APP_DOMAIN' }); + const confirmationUrl = `${MAIN_APP_DOMAIN}/confirmation?token=${token}`; + + utils.sendEmail({ + toEmails: [entry.email], + title: 'Team member invitation', + template: { + name: 'userInvitation', + data: { + content: confirmationUrl, + domain: MAIN_APP_DOMAIN, + }, + isCustom: true, + }, + }); } + }, - if ( - (await Channels.find({ - memberIds: { $in: [userToRemove._id] }, - }).countDocuments()) > 0 - ) { - throw new Error('You cannot delete this user. This user belongs other channel.'); - } + /* + * User has seen onboard + */ + async usersSeenOnBoard(_root, {}, { user }: { user: IUserDocument }) { + return Users.updateOnBoardSeen({ _id: user._id }); + }, - return Users.removeUser(_id); + async usersConfirmInvitation( + _root, + { + token, + password, + passwordConfirmation, + fullName, + username, + }: { + token: string; + password: string; + passwordConfirmation: string; + fullName?: string; + username?: string; + }, + ) { + return Users.confirmInvitation({ token, password, passwordConfirmation, fullName, username }); }, usersConfigEmailSignatures( @@ -238,12 +207,13 @@ const userMutations = { }, }; -requireLogin(userMutations, 'usersAdd'); -requireLogin(userMutations, 'usersEdit'); requireLogin(userMutations, 'usersChangePassword'); requireLogin(userMutations, 'usersEditProfile'); requireLogin(userMutations, 'usersConfigGetNotificationByEmail'); requireLogin(userMutations, 'usersConfigEmailSignatures'); -requireAdmin(userMutations, 'usersRemove'); + +checkPermission(userMutations, 'usersEdit', 'usersEdit'); +checkPermission(userMutations, 'usersInvite', 'usersInvite'); +checkPermission(userMutations, 'usersSetActiveStatus', 'usersSetActiveStatus'); export default userMutations; diff --git a/src/data/resolvers/permission.ts b/src/data/resolvers/permission.ts new file mode 100644 index 000000000..fc3a91497 --- /dev/null +++ b/src/data/resolvers/permission.ts @@ -0,0 +1,12 @@ +import { Users, UsersGroups } from '../../db/models'; +import { IPermissionDocument } from '../../db/models/definitions/permissions'; + +export default { + user(entry: IPermissionDocument) { + return Users.findOne({ _id: entry.userId }); + }, + + group(entry: IPermissionDocument) { + return UsersGroups.findOne({ _id: entry.groupId }); + }, +}; diff --git a/src/data/resolvers/queries/accounts.ts b/src/data/resolvers/queries/accounts.ts index 75325b239..0e3bdf60d 100644 --- a/src/data/resolvers/queries/accounts.ts +++ b/src/data/resolvers/queries/accounts.ts @@ -1,26 +1,15 @@ import { Accounts } from '../../../db/models'; -import { moduleRequireLogin } from '../../permissions'; +import { checkPermission } from '../../permissions'; const accountQueries = { /** * Get linked social accounts */ - - async accounts(_root, args: { kind?: string }) { - const generateParams = params => { - const selector: { [key: string]: string } = {}; - - if (params.kind) { - selector.kind = params.kind; - } - - return selector; - }; - - return Accounts.find(generateParams(args)); + accounts(_root, args: { kind?: string }) { + return Accounts.find(args); }, }; -moduleRequireLogin(accountQueries); +checkPermission(accountQueries, 'accounts', 'showAccounts', []); export default accountQueries; diff --git a/src/data/resolvers/queries/activityLogUtils.ts b/src/data/resolvers/queries/activityLogUtils.ts deleted file mode 100644 index 11be89571..000000000 --- a/src/data/resolvers/queries/activityLogUtils.ts +++ /dev/null @@ -1,138 +0,0 @@ -import { ICompanyDocument } from '../../../db/models/definitions/companies'; -import { ICustomerDocument } from '../../../db/models/definitions/customers'; -import { IDealDocument } from '../../../db/models/definitions/deals'; -import { IUserDocument } from '../../../db/models/definitions/users'; -import { COC_CONTENT_TYPES } from '../../constants'; - -const START_DATE = { - year: 2017, - month: 0, -}; - -type IDocs = IDealDocument | IUserDocument | ICompanyDocument | ICustomerDocument; - -interface IMonthIntervals { - yearMonth: { - year: number; - month: number; - }; - interval: { - start: Date; - end: Date; - }; -} - -interface IList { - coc: IDocs; - cocContentType: string; - date: IMonthIntervals; -} - -class BaseMonthActivityBuilder { - public coc: IDocs; - public cocContentType: string; - constructor(coc: IDocs) { - this.coc = coc; - this.cocContentType = ''; - } - - /** - * Get the number of days in the given month - */ - public getIntervalEnd(year: number, month: number): Date { - const date = new Date(year, month, 0); - date.setDate(date.getDate() + 1); - return date; - } - - /** - * Generate dates with interval dates used to query ActivityLogs - */ - public generateDates(): IMonthIntervals[] { - const now = new Date(); - - const endYear = now.getFullYear(); - const endMonth = now.getMonth(); - const monthIntervals: IMonthIntervals[] = []; - - let year = START_DATE.year; - let month = START_DATE.month; - - do { - monthIntervals.push({ - yearMonth: { - year, - month, - }, - interval: { - start: new Date(year, month, 1), - end: this.getIntervalEnd(year, month + 1), - }, - }); - - month++; - - if (month % 12 === 0) { - month = 0; - year++; - } - } while (year < endYear || (year === endYear && month <= endMonth)); - - return monthIntervals; - } - - /** - * Build month intervals and collect ActivityLogForMonth resolver placeholders into them - */ - public build(): IList[] { - const dates = this.generateDates(); - const list: IList[] = []; - - for (const date of dates) { - list.unshift({ - coc: this.coc, - cocContentType: this.cocContentType, - date, - }); - } - - return list; - } -} - -// Monthly log builder for customers -export class CustomerMonthActivityLogBuilder extends BaseMonthActivityBuilder { - constructor(coc) { - super(coc); - this.cocContentType = COC_CONTENT_TYPES.CUSTOMER; - } -} - -// Monthly log builder for companies -export class CompanyMonthActivityLogBuilder extends BaseMonthActivityBuilder { - constructor(coc) { - super(coc); - this.cocContentType = COC_CONTENT_TYPES.COMPANY; - } -} - -export class UserMonthActivityLogBuilder extends BaseMonthActivityBuilder { - constructor(coc) { - super(coc); - this.cocContentType = COC_CONTENT_TYPES.USER; - } -} - -export class DealMonthActivityLogBuilder extends BaseMonthActivityBuilder { - constructor(coc) { - super(coc); - this.cocContentType = COC_CONTENT_TYPES.DEAL; - } -} - -export default { - CustomerMonthActivityLogBuilder, - CompanyMonthActivityLogBuilder, - UserMonthActivityLogBuilder, - DealMonthActivityLogBuilder, -}; diff --git a/src/data/resolvers/queries/activityLogs.ts b/src/data/resolvers/queries/activityLogs.ts index 8369ee4ba..d4c5bb4de 100644 --- a/src/data/resolvers/queries/activityLogs.ts +++ b/src/data/resolvers/queries/activityLogs.ts @@ -1,51 +1,25 @@ -import { Companies, Customers, Deals, Users } from '../../../db/models'; +import { ActivityLogs } from '../../../db/models'; +import { IActivityLog } from '../../../db/models/definitions/activityLogs'; import { moduleRequireLogin } from '../../permissions'; -import { - CompanyMonthActivityLogBuilder, - CustomerMonthActivityLogBuilder, - DealMonthActivityLogBuilder, - UserMonthActivityLogBuilder, -} from './activityLogUtils'; const activityLogQueries = { /** - * Get activity log for customer + * Get activity log list */ - async activityLogsCustomer(_root, { _id }: { _id: string }) { - const customer = await Customers.findOne({ _id }); + activityLogs(_root, doc: IActivityLog) { + const { contentType, contentId, activityType, limit } = doc; - const customerMonthActivityLogBuilder = new CustomerMonthActivityLogBuilder(customer); - return customerMonthActivityLogBuilder.build(); - }, - - /** - * Get activity log for company - */ - async activityLogsCompany(_root, { _id }: { _id: string }) { - const company = await Companies.findOne({ _id }); - - const companyMonthActivityLogBuilder = new CompanyMonthActivityLogBuilder(company); - return companyMonthActivityLogBuilder.build(); - }, + const query = { 'contentType.type': contentType, 'contentType.id': contentId }; - /** - * Get activity logs for user - */ - async activityLogsUser(_root, { _id }: { _id: string }) { - const user = await Users.findOne({ _id }); - - const userMonthActivityLogBuilder = new UserMonthActivityLogBuilder(user); - return userMonthActivityLogBuilder.build(); - }, + if (activityType) { + query['activity.type'] = activityType; + } - /** - * Get activity logs for deal - */ - async activityLogsDeal(_root, { _id }: { _id: string }) { - const deal = await Deals.findOne({ _id }); + const sort = { createdAt: -1 }; - const dealMonthActivityLogBuilder = new DealMonthActivityLogBuilder(deal); - return dealMonthActivityLogBuilder.build(); + return ActivityLogs.find(query) + .sort(sort) + .limit(limit); }, }; diff --git a/src/data/resolvers/queries/aggregationUtils.ts b/src/data/resolvers/queries/aggregationUtils.ts new file mode 100644 index 000000000..4e4f2538b --- /dev/null +++ b/src/data/resolvers/queries/aggregationUtils.ts @@ -0,0 +1,43 @@ +/** + * Converts dateField into string using timeFormat + * @param fieldName + * @param timeFormat + */ +export const getDateFieldAsStr = ({ + fieldName = '$createdAt', + timeFormat = '%Y-%m-%d', + timeZone = '+08', +}): { + $dateToString: { + format: string; + date: string; + timezone: string; + }; +} => { + return { + $dateToString: { + format: timeFormat, + date: fieldName, + timezone: timeZone, + }, + }; +}; + +/** + * Return duration calculation as seconds + */ +export const getDurationField = ({ + startField = '$createdAt', + endField = '$closedAt', +}): { + $divide: [{ $subtract: string[] }, number]; +} => { + return { + $divide: [ + { + $subtract: [startField, endField], + }, + 1000, + ], + }; +}; diff --git a/src/data/resolvers/queries/brands.ts b/src/data/resolvers/queries/brands.ts index 7219a5137..51e9d0c4b 100644 --- a/src/data/resolvers/queries/brands.ts +++ b/src/data/resolvers/queries/brands.ts @@ -1,5 +1,5 @@ import { Brands } from '../../../db/models'; -import { moduleRequireLogin } from '../../permissions'; +import { checkPermission, requireLogin } from '../../permissions'; import { paginate } from './utils'; const brandQueries = { @@ -33,6 +33,10 @@ const brandQueries = { }, }; -moduleRequireLogin(brandQueries); +requireLogin(brandQueries, 'brandsTotalCount'); +requireLogin(brandQueries, 'brandsGetLast'); +requireLogin(brandQueries, 'brandDetail'); + +checkPermission(brandQueries, 'brands', 'showBrands', []); export default brandQueries; diff --git a/src/data/resolvers/queries/channels.ts b/src/data/resolvers/queries/channels.ts index 1c645cb27..612df16b8 100644 --- a/src/data/resolvers/queries/channels.ts +++ b/src/data/resolvers/queries/channels.ts @@ -1,5 +1,5 @@ import { Channels } from '../../../db/models'; -import { moduleRequireLogin } from '../../permissions'; +import { checkPermission, requireLogin } from '../../permissions'; import { paginate } from './utils'; interface IIn { @@ -49,6 +49,10 @@ const channelQueries = { }, }; -moduleRequireLogin(channelQueries); +requireLogin(channelQueries, 'channelsGetLast'); +requireLogin(channelQueries, 'channelsTotalCount'); +requireLogin(channelQueries, 'channelDetail'); + +checkPermission(channelQueries, 'channels', 'showChannels', []); export default channelQueries; diff --git a/src/data/resolvers/queries/companies.ts b/src/data/resolvers/queries/companies.ts index 45c2beba4..33cf24abf 100644 --- a/src/data/resolvers/queries/companies.ts +++ b/src/data/resolvers/queries/companies.ts @@ -1,6 +1,7 @@ import { Brands, Companies, Customers, Integrations, Segments, Tags } from '../../../db/models'; -import { COC_CONTENT_TYPES, COC_LEAD_STATUS_TYPES, COC_LIFECYCLE_STATE_TYPES, TAG_TYPES } from '../../constants'; -import { moduleRequireLogin } from '../../permissions'; +import { STATUSES } from '../../../db/models/definitions/constants'; +import { ACTIVITY_CONTENT_TYPES, COC_LEAD_STATUS_TYPES, COC_LIFECYCLE_STATE_TYPES, TAG_TYPES } from '../../constants'; +import { checkPermission, requireLogin } from '../../permissions'; import { cocsExport } from './cocExport'; import QueryBuilder from './segmentQueryBuilder'; import { paginate } from './utils'; @@ -57,7 +58,9 @@ const brandFilter = async (brandId: string): Promise => { }; const listQuery = async (params: IListArgs) => { - let selector: any = {}; + let selector: any = { + status: { $ne: STATUSES.DELETED }, + }; // Filter by segments if (params.segment) { @@ -134,7 +137,7 @@ const countBySegment = async (args: ICountArgs): Promise => { // Count companies by segments ========= const segments = await Segments.find({ - contentType: COC_CONTENT_TYPES.COMPANY, + contentType: ACTIVITY_CONTENT_TYPES.COMPANY, }); for (const s of segments) { @@ -264,6 +267,12 @@ const companyQueries = { }, }; -moduleRequireLogin(companyQueries); +requireLogin(companyQueries, 'companiesMain'); +requireLogin(companyQueries, 'companyCounts'); +requireLogin(companyQueries, 'companyDetail'); + +checkPermission(companyQueries, 'companies', 'showCompanies', []); +checkPermission(companyQueries, 'companiesMain', 'showCompanies', { list: [], totalCount: 0 }); +checkPermission(companyQueries, 'companiesExport', 'exportCompanies'); export default companyQueries; diff --git a/src/data/resolvers/queries/configs.ts b/src/data/resolvers/queries/configs.ts index 59c88bf38..ef7f56798 100644 --- a/src/data/resolvers/queries/configs.ts +++ b/src/data/resolvers/queries/configs.ts @@ -2,6 +2,7 @@ import * as gitRepoInfo from 'git-repo-info'; import * as path from 'path'; import { Configs } from '../../../db/models'; import { moduleRequireLogin } from '../../permissions'; +import { getEnv } from '../../utils'; interface IInfo { branch: string; // current branch @@ -50,12 +51,13 @@ const configQueries = { }, configsVersions(_root) { - const { ERXES_PATH, API_PATH, WIDGET_PATH, WIDGET_API_PATH } = process.env; - - const erxesProjectPath = ERXES_PATH || `${process.cwd()}/../erxes`; - const apiProjectPath = API_PATH || process.cwd(); - const widgetProjectPath = WIDGET_PATH || `${process.cwd()}/../erxes-widgets`; - const widgetApiProjectPath = WIDGET_API_PATH || `${process.cwd()}/../erxes-widgets-api`; + const erxesProjectPath = getEnv({ name: 'ERXES_PATH', defaultValue: `${process.cwd()}/../erxes` }); + const apiProjectPath = getEnv({ name: 'API_PATH', defaultValue: process.cwd() }); + const widgetProjectPath = getEnv({ name: 'WIDGET_PATH', defaultValue: `${process.cwd()}/../erxes-widgets` }); + const widgetApiProjectPath = getEnv({ + name: 'WIDGET_API_PATH', + defaultValue: `${process.cwd()}/../erxes-widgets-api`, + }); const response = { erxesVersion: getGitInfos(erxesProjectPath), diff --git a/src/data/resolvers/queries/conversationQueryBuilder.ts b/src/data/resolvers/queries/conversationQueryBuilder.ts index a1673d516..0687caf1a 100644 --- a/src/data/resolvers/queries/conversationQueryBuilder.ts +++ b/src/data/resolvers/queries/conversationQueryBuilder.ts @@ -1,7 +1,7 @@ import * as _ from 'underscore'; import { Channels, Integrations } from '../../../db/models'; import { CONVERSATION_STATUSES } from '../../constants'; -import { fixDate } from './insightUtils'; +import { fixDate } from './utils'; interface IIn { $in: string[]; diff --git a/src/data/resolvers/queries/conversations.ts b/src/data/resolvers/queries/conversations.ts index 887d99b4f..6133a0679 100644 --- a/src/data/resolvers/queries/conversations.ts +++ b/src/data/resolvers/queries/conversations.ts @@ -3,7 +3,7 @@ import { IUserDocument } from '../../../db/models/definitions/users'; import { IMessageDocument } from '../../../db/models/definitions/conversationMessages'; import { CONVERSATION_STATUSES, FACEBOOK_DATA_KINDS, INTEGRATION_KIND_CHOICES } from '../../constants'; -import { moduleRequireLogin } from '../../permissions'; +import { checkPermission, moduleRequireLogin } from '../../permissions'; import QueryBuilder, { IListArgs } from './conversationQueryBuilder'; interface ICountBy { @@ -376,4 +376,6 @@ const conversationQueries = { moduleRequireLogin(conversationQueries); +checkPermission(conversationQueries, 'conversations', 'showConversations', []); + export default conversationQueries; diff --git a/src/data/resolvers/queries/customerQueryBuilder.ts b/src/data/resolvers/queries/customerQueryBuilder.ts index 08ce61019..3a2de4017 100644 --- a/src/data/resolvers/queries/customerQueryBuilder.ts +++ b/src/data/resolvers/queries/customerQueryBuilder.ts @@ -1,6 +1,7 @@ import * as moment from 'moment'; import * as _ from 'underscore'; import { Forms, Integrations, Segments } from '../../../db/models'; +import { STATUSES } from '../../../db/models/definitions/constants'; import QueryBuilder from './segmentQueryBuilder'; interface IIn { @@ -15,7 +16,6 @@ export interface IListArgs { ids?: string[]; searchValue?: string; brand?: string; - numberegration?: string; form?: string; startDate?: string; endDate?: string; @@ -44,10 +44,11 @@ export default class Builder { this.params = params; } - public defaultFilters(): { $nor: [{ [index: string]: IIn | any }] } { + public defaultFilters(): { status: {}; $nor: [{ [index: string]: IIn | any }] } { const emptySelector = { $in: [null, ''] }; return { + status: { $ne: STATUSES.DELETED }, $nor: [ { firstName: emptySelector, diff --git a/src/data/resolvers/queries/customers.ts b/src/data/resolvers/queries/customers.ts index 5b18d240f..c55d28ddc 100644 --- a/src/data/resolvers/queries/customers.ts +++ b/src/data/resolvers/queries/customers.ts @@ -1,13 +1,13 @@ import { Brands, Customers, Forms, Segments, Tags } from '../../../db/models'; import { ISegment } from '../../../db/models/definitions/segments'; import { - COC_CONTENT_TYPES, + ACTIVITY_CONTENT_TYPES, COC_LEAD_STATUS_TYPES, COC_LIFECYCLE_STATE_TYPES, INTEGRATION_KIND_CHOICES, TAG_TYPES, } from '../../constants'; -import { moduleRequireLogin } from '../../permissions'; +import { checkPermission, moduleRequireLogin } from '../../permissions'; import { cocsExport } from './cocExport'; import BuildQuery, { IListArgs } from './customerQueryBuilder'; import QueryBuilder from './segmentQueryBuilder'; @@ -49,7 +49,7 @@ const countBySegment = async (qb: any, mainQuery: any): Promise => { // Count customers by segments const segments = await Segments.find({ - contentType: COC_CONTENT_TYPES.CUSTOMER, + contentType: ACTIVITY_CONTENT_TYPES.CUSTOMER, }); // Count customers by segment @@ -250,4 +250,8 @@ const customerQueries = { moduleRequireLogin(customerQueries); +checkPermission(customerQueries, 'customers', 'showCustomers', []); +checkPermission(customerQueries, 'customersMain', 'showCustomers', { list: [], totalCount: 0 }); +checkPermission(customerQueries, 'customersExport', 'exportCustomers'); + export default customerQueries; diff --git a/src/data/resolvers/queries/deals.ts b/src/data/resolvers/queries/deals.ts index 104c1f1b9..5d8f083d0 100644 --- a/src/data/resolvers/queries/deals.ts +++ b/src/data/resolvers/queries/deals.ts @@ -1,5 +1,34 @@ import { DealBoards, DealPipelines, Deals, DealStages } from '../../../db/models'; -import { moduleRequireLogin } from '../../permissions'; +import { checkPermission, moduleRequireLogin } from '../../permissions'; +import { dealsCommonFilter } from './utils'; + +interface IDate { + month: number; + year: number; +} + +interface IDealListParams { + pipelineId?: string; + stageId: string; + customerId: string; + companyId: string; + skip?: number; + date?: IDate; + search?: string; +} + +const dateSelector = (date: IDate) => { + const { year, month } = date; + const currentDate = new Date(); + + const start = currentDate.setFullYear(year, month, 1); + const end = currentDate.setFullYear(year, month + 1, 0); + + return { + $gte: new Date(start), + $lte: new Date(end), + }; +}; const dealQueries = { /** @@ -54,8 +83,9 @@ const dealQueries = { /** * Deals list */ - deals(_root, { stageId, customerId, companyId }: { stageId: string; customerId: string; companyId: string }) { - const filter: any = {}; + async deals(_root, { pipelineId, stageId, customerId, companyId, date, skip, search }: IDealListParams) { + const filter: any = dealsCommonFilter({}, { search }); + const sort = { order: 1, createdAt: -1 }; if (stageId) { filter.stageId = stageId; @@ -69,7 +99,53 @@ const dealQueries = { filter.companyIds = { $in: [companyId] }; } - return Deals.find(filter).sort({ order: 1, createdAt: -1 }); + if (date) { + const stageIds = await DealStages.find({ pipelineId }).distinct('_id'); + + filter.closeDate = dateSelector(date); + filter.stageId = { $in: stageIds }; + } + + return Deals.find(filter) + .sort(sort) + .skip(skip || 0) + .limit(10); + }, + + /** + * Deal total amounts + */ + async dealsTotalAmounts(_root, { pipelineId, date }: { date: IDate; pipelineId: string }) { + const stageIds = await DealStages.find({ pipelineId }).distinct('_id'); + const filter = { stageId: { $in: stageIds }, closeDate: dateSelector(date) }; + + const dealCount = await Deals.find(filter).countDocuments(); + const amountList = await Deals.aggregate([ + { + $match: filter, + }, + { + $unwind: '$productsData', + }, + { + $project: { + amount: '$productsData.amount', + currency: '$productsData.currency', + }, + }, + { + $group: { + _id: '$currency', + amount: { $sum: '$amount' }, + }, + }, + ]); + + const dealAmounts = amountList.map(deal => { + return { _id: Math.random(), currency: deal._id, amount: deal.amount }; + }); + + return { _id: Math.random(), dealCount, dealAmounts }; }, /** @@ -82,4 +158,6 @@ const dealQueries = { moduleRequireLogin(dealQueries); +checkPermission(dealQueries, 'deals', 'showDeals', []); + export default dealQueries; diff --git a/src/data/resolvers/queries/emailTemplates.ts b/src/data/resolvers/queries/emailTemplates.ts index d5344e2fa..e121935a0 100644 --- a/src/data/resolvers/queries/emailTemplates.ts +++ b/src/data/resolvers/queries/emailTemplates.ts @@ -1,5 +1,5 @@ import { EmailTemplates } from '../../../db/models'; -import { moduleRequireLogin } from '../../permissions'; +import { checkPermission, requireLogin } from '../../permissions'; import { paginate } from './utils'; const emailTemplateQueries = { @@ -18,6 +18,7 @@ const emailTemplateQueries = { }, }; -moduleRequireLogin(emailTemplateQueries); +requireLogin(emailTemplateQueries, 'emailTemplatesTotalCount'); +checkPermission(emailTemplateQueries, 'emailTemplates', 'showEmailTemplates', []); export default emailTemplateQueries; diff --git a/src/data/resolvers/queries/engages.ts b/src/data/resolvers/queries/engages.ts index 71c9c6d02..f3d01bdc1 100644 --- a/src/data/resolvers/queries/engages.ts +++ b/src/data/resolvers/queries/engages.ts @@ -1,6 +1,6 @@ import { EngageMessages, Tags } from '../../../db/models'; import { IUserDocument } from '../../../db/models/definitions/users'; -import { moduleRequireLogin } from '../../permissions'; +import { checkPermission, requireLogin } from '../../permissions'; import { paginate } from './utils'; interface IListArgs { @@ -186,6 +186,10 @@ const engageQueries = { }, }; -moduleRequireLogin(engageQueries); +requireLogin(engageQueries, 'engageMessagesTotalCount'); +requireLogin(engageQueries, 'engageMessageCounts'); +requireLogin(engageQueries, 'engageMessageDetail'); + +checkPermission(engageQueries, 'engageMessages', 'showEngagesMessages', []); export default engageQueries; diff --git a/src/data/resolvers/queries/fields.ts b/src/data/resolvers/queries/fields.ts index b1cd4d37e..cfdfe6a09 100644 --- a/src/data/resolvers/queries/fields.ts +++ b/src/data/resolvers/queries/fields.ts @@ -1,6 +1,6 @@ import { FIELD_CONTENT_TYPES, FIELDS_GROUPS_CONTENT_TYPES } from '../../../data/constants'; import { Companies, Customers, Fields, FieldsGroups } from '../../../db/models'; -import { moduleRequireLogin } from '../../permissions'; +import { checkPermission, requireLogin } from '../../permissions'; interface IFieldsQuery { contentType: string; @@ -122,7 +122,10 @@ const fieldQueries = { }, }; -moduleRequireLogin(fieldQueries); +requireLogin(fieldQueries, 'fieldsCombinedByContentType'); +requireLogin(fieldQueries, 'fieldsDefaultColumnsConfig'); + +checkPermission(fieldQueries, 'fields', 'showFields', []); const fieldsGroupQueries = { /** @@ -138,6 +141,6 @@ const fieldsGroupQueries = { }, }; -moduleRequireLogin(fieldsGroupQueries); +checkPermission(fieldsGroupQueries, 'fieldsGroups', 'showFieldsGroups', []); export { fieldQueries, fieldsGroupQueries }; diff --git a/src/data/resolvers/queries/forms.ts b/src/data/resolvers/queries/forms.ts index 66a648a92..470a62a42 100644 --- a/src/data/resolvers/queries/forms.ts +++ b/src/data/resolvers/queries/forms.ts @@ -1,14 +1,12 @@ import { Forms } from '../../../db/models'; -import { moduleRequireLogin } from '../../permissions'; +import { checkPermission, requireLogin } from '../../permissions'; const formQueries = { /** * Forms list */ - async forms() { - const forms = Forms.find({}); - - return forms.sort({ title: 1 }); + forms() { + return Forms.find({}).sort({ title: 1 }); }, /** @@ -19,6 +17,7 @@ const formQueries = { }, }; -moduleRequireLogin(formQueries); +requireLogin(formQueries, 'formDetail'); +checkPermission(formQueries, 'forms', 'showForms', []); export default formQueries; diff --git a/src/data/resolvers/queries/importHistory.ts b/src/data/resolvers/queries/importHistory.ts index 7c3fc9333..45df6b675 100644 --- a/src/data/resolvers/queries/importHistory.ts +++ b/src/data/resolvers/queries/importHistory.ts @@ -1,5 +1,5 @@ import { ImportHistory } from '../../../db/models'; -import { moduleRequireLogin } from '../../permissions'; +import { checkPermission } from '../../permissions'; import { paginate } from './utils'; const importHistoryQueries = { @@ -11,6 +11,6 @@ const importHistoryQueries = { }, }; -moduleRequireLogin(importHistoryQueries); +checkPermission(importHistoryQueries, 'importHistories', 'importHistories', []); export default importHistoryQueries; diff --git a/src/data/resolvers/queries/index.ts b/src/data/resolvers/queries/index.ts index 41e73aed9..14d02951d 100644 --- a/src/data/resolvers/queries/index.ts +++ b/src/data/resolvers/queries/index.ts @@ -12,13 +12,15 @@ import engages from './engages'; import { fieldQueries as fields, fieldsGroupQueries as fieldsgroups } from './fields'; import forms from './forms'; import importHistory from './importHistory'; -import insightExport from './insightExport'; -import insights from './insights'; +import dealInsights from './insights/dealInsights'; +import insightExport from './insights/insightExport'; +import insights from './insights/insights'; import integrations from './integrations'; import internalNotes from './internalNotes'; import knowledgeBase from './knowledgeBase'; import messengerApps from './messengerApps'; import notifications from './notifications'; +import { permissionQueries as permissions, usersGroupQueries as usersGroups } from './permissions'; import products from './products'; import responseTemplates from './responseTemplates'; import scripts from './scripts'; @@ -50,9 +52,12 @@ export default { ...notifications, ...activityLogs, ...deals, + ...dealInsights, ...products, ...configs, ...fieldsgroups, ...importHistory, ...messengerApps, + ...permissions, + ...usersGroups, }; diff --git a/src/data/resolvers/queries/insightUtils.ts b/src/data/resolvers/queries/insightUtils.ts deleted file mode 100644 index 1ca53248a..000000000 --- a/src/data/resolvers/queries/insightUtils.ts +++ /dev/null @@ -1,389 +0,0 @@ -import * as moment from 'moment'; -import * as _ from 'underscore'; -import { ConversationMessages, Conversations, Integrations, Users } from '../../../db/models'; -import { IMessageDocument } from '../../../db/models/definitions/conversationMessages'; -import { IConversationDocument } from '../../../db/models/definitions/conversations'; - -interface IMessageSelector { - userId?: string; - createdAt: any; - fromBot?: any; - conversationId?: { - $in: string[]; - }; -} - -interface IGenerateChartData { - x: any; - y: number; -} - -interface IGenerateTimeIntervals { - title: string; - start: any; - end: any; -} - -interface IGenerateUserChartData { - fullName?: string; - avatar?: string; - graph: IGenerateChartData[]; -} - -interface IIntegrationSelector { - brandId?: any; - kind?: any; -} - -interface IFixDates { - start: Date; - end: Date; -} - -interface IResponseUserData { - [index: string]: { - responseTime: number; - count: number; - summaries?: number[]; - }; -} - -interface IGenerateResponseData { - trend: IGenerateChartData[]; - time: number; - teamMembers: { - data: IGenerateUserChartData[]; - }; -} - -interface IChartData { - collection?: any; - messageSelector?: any; -} - -export interface IListArgs { - integrationType: string; - brandId: string; - startDate: string; - endDate: string; - type: string; -} -/** - * Return integrationSelector for aggregations - * @param args - */ -export const getIntegrationSelector = async (args: IIntegrationSelector): Promise => { - const integrationSelector: IIntegrationSelector = {}; - const { kind, brandId } = args; - if (brandId) { - integrationSelector.brandId = { $in: brandId.split(',') }; - } - - if (kind) { - integrationSelector.kind = { $in: kind.split(',') }; - } - return integrationSelector; -}; -/** - * Return conversationSelect for aggregation - * @param args - * @param conversationSelector - * @param selectIds - */ -export const getConversationSelector = async (args: IIntegrationSelector, conversationSelector: any): Promise => { - const integrationSelector = await getIntegrationSelector(args); - const { kind, brandId } = args; - - if (kind || brandId) { - conversationSelector.integrationIds = await Integrations.find(integrationSelector).select('_id'); - } - - return conversationSelector; -}; - -/** - * Find conversations or conversationIds. - */ -export const findConversations = async ( - args: IIntegrationSelector, - conversationSelector: any, - selectIds?: boolean, -): Promise => { - const integrationSelector = await getIntegrationSelector(args); - const { kind, brandId } = args; - - if (kind || brandId) { - const integrationIds = await Integrations.find(integrationSelector).select('_id'); - conversationSelector.integrationId = integrationIds.map(row => row._id); - } - - if (selectIds) { - return Conversations.find(conversationSelector).select('_id'); - } - - return Conversations.find(conversationSelector).sort({ createdAt: 1 }); -}; - -/** - * Builds messages find query selector. - */ -export const generateMessageSelector = async ( - brandId: string, - integrationType: string, - conversationSelector: any, - messageSelector: IMessageSelector, -): Promise => { - const conversationIds = await findConversations({ brandId, kind: integrationType }, conversationSelector, true); - const rawConversationIds = conversationIds.map(obj => obj._id); - messageSelector.conversationId = { $in: rawConversationIds }; - - return messageSelector; -}; -/** - * Fix trend for missing values because from then aggregation, - * it could return missing values for some dates. This method - * will assign 0 values for missing x values. - * @param startDate - * @param endDate - * @param data - */ -export const fixChartData = async (data: any[], hintX: string, hintY: string): Promise => { - const results = {}; - data.map(row => { - results[row[hintX]] = row[hintY]; - }); - - return Object.keys(results) - .sort() - .map(key => { - return { x: formatTime(moment(key), 'MM-DD'), y: results[key] }; - }); -}; -/** - * Populates message collection into date range - * by given duration and loop count for chart data. - */ -export const generateChartData = async (args: IChartData): Promise => { - const { collection, messageSelector } = args; - - const pipelineStages = [ - { - $match: messageSelector, - }, - { - $project: { - date: { - $dateToString: { - format: '%m-%d', - date: '$createdAt', - timezone: '+08', - }, - }, - }, - }, - { - $group: { - _id: '$date', - y: { $sum: 1 }, - }, - }, - { - $project: { - x: '$_id', - y: 1, - _id: 0, - }, - }, - { - $sort: { - x: 1, - }, - }, - ]; - - if (collection) { - const results = {}; - - collection.map(obj => { - const date = formatTime(moment(obj.createdAt), 'YYYY-MM-DD'); - - results[date] = (results[date] || 0) + 1; - }); - - return Object.keys(results) - .sort() - .map(key => { - return { x: formatTime(moment(key), 'MM-DD'), y: results[key] }; - }); - } - - return ConversationMessages.aggregate([pipelineStages]); -}; - -/** - * Generates time intervals for main report - */ -export const generateTimeIntervals = (start: Date, end: Date): IGenerateTimeIntervals[] => { - const month = moment(end).month(); - - return [ - { - title: 'In time range', - start: moment(start), - end: moment(end), - }, - { - title: 'This month', - start: moment(1, 'DD'), - end: moment(), - }, - { - title: 'This week', - start: moment(end).weekday(0), - end: moment(end), - }, - { - title: 'Today', - start: moment(end).add(-1, 'days'), - end: moment(end), - }, - { - title: 'Last 30 days', - start: moment(end).add(-30, 'days'), - end: moment(end), - }, - { - title: 'Last month', - start: moment(month + 1, 'MM').subtract(1, 'months'), - end: moment(month + 1, 'MM'), - }, - { - title: 'Last week', - start: moment(end).weekday(-7), - end: moment(end).weekday(0), - }, - { - title: 'Yesterday', - start: moment(end).add(-2, 'days'), - end: moment(end).add(-1, 'days'), - }, - ]; -}; - -/** - * Generate chart data for given user - */ -export const generateUserChartData = async ({ - userId, - userMessages, -}: { - userId: string; - userMessages: IMessageDocument[]; -}): Promise => { - const user = await Users.findOne({ _id: userId }); - const userData = await generateChartData({ collection: userMessages }); - - if (!user) { - return { - graph: userData, - }; - } - - const userDetail = user.details; - - return { - fullName: userDetail ? userDetail.fullName : '', - avatar: userDetail ? userDetail.avatar : '', - graph: userData, - }; -}; - -export const formatTime = (time, format = 'YYYY-MM-DD HH:mm:ss') => { - return time.format(format); -}; - -// TODO: check usage -export const getTime = (date: string | number): number => { - return new Date(date).getTime(); -}; - -/* - * Converts given value to date or if value in valid date - * then returns default value - */ -export const fixDate = (value, defaultValue = new Date()): Date => { - const date = new Date(value); - - if (!isNaN(date.getTime())) { - return date; - } - - return defaultValue; -}; - -export const fixDates = (startValue: string, endValue: string, count?: number): IFixDates => { - // convert given value or get today - const endDate = fixDate(endValue); - - const startDateDefaultValue = new Date( - moment(endDate) - .add(count ? count * -1 : -7, 'days') - .toString(), - ); - - // convert given value or generate from endDate - const startDate = fixDate(startValue, startDateDefaultValue); - - return { start: startDate, end: endDate }; -}; - -/* - * Determines user or client - */ -export const generateUserSelector = (type: string): any => { - let volumeOrResponse: any = null; - - if (type === 'response') { - volumeOrResponse = { $ne: null }; - } - - return volumeOrResponse; -}; - -/** - * Generate response chart data. - */ -export const generateResponseData = async ( - responsData: IMessageDocument[], - responseUserData: IResponseUserData, - allResponseTime: number, -): Promise => { - // preparing trend chart data - const trend = await generateChartData({ collection: responsData }); - - // Average response time for all messages - const time = Math.floor(allResponseTime / responsData.length); - - const teamMembers: any = []; - - const userIds = _.uniq(_.pluck(responsData, 'userId')); - - for (const userId of userIds) { - const { responseTime, count, summaries } = responseUserData[userId]; - - // Average response time for users. - const avgResTime = Math.floor(responseTime / count); - - // preparing each team member's chart data - teamMembers.push({ - data: await generateUserChartData({ - userId, - userMessages: responsData.filter(message => userId === message.userId), - }), - time: avgResTime, - summaries, - }); - } - - return { trend, time, teamMembers }; -}; diff --git a/src/data/resolvers/queries/insights/dealInsights.ts b/src/data/resolvers/queries/insights/dealInsights.ts new file mode 100644 index 000000000..0e4af5087 --- /dev/null +++ b/src/data/resolvers/queries/insights/dealInsights.ts @@ -0,0 +1,172 @@ +import * as moment from 'moment'; +import { Deals } from '../../../../db/models'; +import { IUserDocument } from '../../../../db/models/definitions/users'; +import { INSIGHT_TYPES } from '../../../constants'; +import { moduleRequireLogin } from '../../../permissions'; +import { getDateFieldAsStr } from '../aggregationUtils'; +import { fixDate } from '../utils'; +import { IDealListArgs } from './types'; +import { + fixChartData, + fixDates, + generateChartDataBySelector, + generatePunchData, + getDealSelector, + getSummaryData, + getTimezone, +} from './utils'; + +const dealInsightQueries = { + /** + * Counts deals by each hours in each days. + */ + async dealInsightsPunchCard(_root, args: IDealListArgs, { user }: { user: IUserDocument }) { + const { endDate } = args; + + // check & convert endDate's value + const end = moment(fixDate(endDate)).format('YYYY-MM-DD'); + const start = moment(end).add(-7, 'days'); + + const matchMessageSelector = { + // client or user + createdAt: { $gte: start.toDate(), $lte: new Date(end) }, + }; + + return generatePunchData(Deals, matchMessageSelector, user); + }, + + /** + * Sends combined charting data for trends and summaries. + */ + async dealInsightsMain(_root, args: IDealListArgs) { + const { startDate, endDate, status } = args; + const { start, end } = fixDates(startDate, endDate); + + const selector = await getDealSelector(args); + + const dateFieldName = status ? 'modifiedAt' : 'createdAt'; + + const insightData: any = {}; + + insightData.trend = await generateChartDataBySelector({ + selector, + type: INSIGHT_TYPES.DEAL, + dateFieldName: `$${dateFieldName}`, + }); + + insightData.summary = await getSummaryData({ + startDate: start, + endDate: end, + collection: Deals, + selector: { ...selector }, + dateFieldName, + }); + + return insightData; + }, + + /** + * Calculates won or lost deals for each team members. + */ + async dealInsightsByTeamMember(_root, args: IDealListArgs, { user }: { user: IUserDocument }) { + const dealMatch = await getDealSelector(args); + + const insightAggregateData = await Deals.aggregate([ + { + $match: dealMatch, + }, + { + $project: { + date: await getDateFieldAsStr({ fieldName: '$modifiedAt', timeZone: getTimezone(user) }), + modifiedBy: 1, + }, + }, + { + $group: { + _id: { + modifiedBy: '$modifiedBy', + date: '$date', + }, + count: { $sum: 1 }, + }, + }, + { + $project: { + _id: 0, + modifiedBy: '$_id.modifiedBy', + date: '$_id.date', + count: 1, + }, + }, + { + $lookup: { + from: 'users', + localField: 'modifiedBy', + foreignField: '_id', + as: 'userDoc', + }, + }, + { + $replaceRoot: { newRoot: { $mergeObjects: [{ $arrayElemAt: ['$userDoc.details', 0] }, '$$ROOT'] } }, + }, + { + $group: { + _id: '$modifiedBy', + count: { $sum: '$count' }, + fullName: { $first: '$fullName' }, + avatar: { $first: '$avatar' }, + chartDatas: { + $push: { + date: '$date', + count: '$count', + }, + }, + }, + }, + ]); + + if (insightAggregateData.length < 1) { + return []; + } + + // Variables holds every user's response time. + const teamMembers: any = []; + const responseUserData: any = {}; + + const aggregatedTrend = {}; + + for (const userData of insightAggregateData) { + // responseUserData + responseUserData[userData._id] = { + count: userData.count, + fullName: userData.fullName, + avatar: userData.avatar, + }; + + // team members gather + const fixedChartData = await fixChartData(userData.chartDatas, 'date', 'count'); + + userData.chartDatas.forEach(row => { + if (row.date in aggregatedTrend) { + aggregatedTrend[row.date] += row.count; + } else { + aggregatedTrend[row.date] = row.count; + } + }); + + teamMembers.push({ + data: { + fullName: userData.fullName, + avatar: userData.avatar, + graph: fixedChartData, + }, + }); + } + + return teamMembers; + }, +}; + +moduleRequireLogin(dealInsightQueries); + +export default dealInsightQueries; diff --git a/src/data/resolvers/queries/insights/exportUtils.ts b/src/data/resolvers/queries/insights/exportUtils.ts new file mode 100644 index 000000000..0035758ef --- /dev/null +++ b/src/data/resolvers/queries/insights/exportUtils.ts @@ -0,0 +1,83 @@ +import * as moment from 'moment'; +import { Brands } from '../../../../db/models'; +import { IAddCellArgs, IListArgs } from './types'; +import { fixDates } from './utils'; + +/** + * Fix number if it is either NaN or Infinity + */ + +export const fixNumber = (num: number) => { + if (isNaN(num) || num === Infinity) { + return 0; + } + return num; +}; + +/** + * Time format HH:mm:ii + */ +export const convertTime = (duration: number) => { + const hours = Math.floor(duration / 3600); + const minutes = Math.floor((duration % 3600) / 60); + const seconds = Math.floor((duration % 3600) % 60); + + return hours.toString() + 'h : ' + minutes.toString() + 'm : ' + seconds.toString() + 's'; +}; + +/** + * Add header into excel file + * @param title + * @param args + * @param excel + */ +export const addHeader = async (title: string, args: IListArgs, excel: any): Promise => { + const { integrationIds = '', brandIds = '', startDate, endDate } = args; + const selectedBrands = await Brands.find({ _id: { $in: brandIds.split(',') } }).select('name'); + const brandNames = selectedBrands.map(row => row.name).join(','); + const { start, end } = fixDates(startDate, endDate); + excel.cell(1, 1).value(title); + excel.cell(2, 1).value('date:'); + excel.cell(2, 2).value(`${dateToString(start)}-${dateToString(end)}`); + excel.cell(2, 4).value('Integration:'); + excel.cell(2, 5).value(integrationIds); + excel.cell(2, 6).value('Brand:'); + excel.cell(2, 7).value(brandNames || ''); + return {}; +}; + +/* + * Sheet add cell + */ +export const addCell = (args: IAddCellArgs): void => { + const { cols, sheet, col, rowIndex, value } = args; + + // Checking if existing column + if (cols.includes(col)) { + // If column already exists adding cell + sheet.cell(rowIndex, cols.indexOf(col) + 1).value(value); + } else { + // Creating column + sheet + .column(cols.length + 1) + .width(25) + .hidden(false); + sheet.cell(3, cols.length + 1).value(col); + // Creating cell + sheet.cell(rowIndex, cols.length + 1).value(value); + + cols.push(col); + } +}; + +export const nextTime = (start: Date, type?: string) => { + return new Date( + moment(start) + .add(1, type ? 'hours' : 'days') + .toString(), + ); +}; + +export const dateToString = (date: Date) => { + return moment(date).format('YYYY-MM-DD HH:mm'); +}; diff --git a/src/data/resolvers/queries/insightExport.ts b/src/data/resolvers/queries/insights/insightExport.ts similarity index 67% rename from src/data/resolvers/queries/insightExport.ts rename to src/data/resolvers/queries/insights/insightExport.ts index 35aa97c7c..cd7dfcbd2 100644 --- a/src/data/resolvers/queries/insightExport.ts +++ b/src/data/resolvers/queries/insights/insightExport.ts @@ -1,95 +1,21 @@ import * as moment from 'moment'; -import { ConversationMessages, Conversations, Integrations, Tags, Users } from '../../../db/models'; -import { IUserDocument } from '../../../db/models/definitions/users'; -import { INSIGHT_BASIC_INFOS, TAG_TYPES } from '../../constants'; -import { moduleRequireLogin } from '../../permissions'; -import { createXlsFile, generateXlsx } from '../../utils'; +import { ConversationMessages, Conversations, Integrations, Tags, Users } from '../../../../db/models'; +import { IUserDocument } from '../../../../db/models/definitions/users'; +import { INSIGHT_BASIC_INFOS, TAG_TYPES } from '../../../constants'; +import { moduleCheckPermission } from '../../../permissions'; +import { createXlsFile, generateXlsx } from '../../../utils'; +import { getDateFieldAsStr, getDurationField } from '../aggregationUtils'; +import { IListArgs, IListArgsWithUserId, IVolumeReportExportArgs } from './types'; import { findConversations, fixDates, generateMessageSelector, - generateUserSelector, getConversationSelector, - IListArgs, -} from './insightUtils'; - -interface IVolumeReportExportArgs { - date: string; - count: number; - customerCount: number; - customerCountPercentage: string; - messageCount: number; - resolvedCount: number; - averageResponseDuration: string; - firstResponseDuration: string; -} - -interface IAddCellArgs { - sheet: any; - cols: string[]; - rowIndex: number; - col: string; - value: string | number; -} - -export interface IListArgsWithUserId extends IListArgs { - userId?: string; -} - -/** - * Time format HH:mm:ii - */ -const convertTime = (duration: number) => { - const hours = Math.floor(duration / 3600); - const minutes = Math.floor((duration % 3600) / 60); - const seconds = Math.floor((duration % 3600) % 60); - - const timeFormat = (num: number) => { - if (num < 10) { - return '0' + num.toString(); - } - - return num.toString(); - }; - - return timeFormat(hours) + ':' + timeFormat(minutes) + ':' + timeFormat(seconds); -}; - -/* - * Sheet add cell - */ -const addCell = (args: IAddCellArgs): void => { - const { cols, sheet, col, rowIndex, value } = args; - - // Checking if existing column - if (cols.includes(col)) { - // If column already exists adding cell - sheet.cell(rowIndex, cols.indexOf(col) + 1).value(value); - } else { - // Creating column - sheet - .column(cols.length + 1) - .width(25) - .hidden(false); - sheet.cell(1, cols.length + 1).value(col); - // Creating cell - sheet.cell(rowIndex, cols.length + 1).value(value); - - cols.push(col); - } -}; + getFilterSelector, + getTimezone, +} from './utils'; -const nextTime = (start: Date, type?: string) => { - return new Date( - moment(start) - .add(1, type ? 'hours' : 'days') - .toString(), - ); -}; - -const dateToString = (date: Date) => { - return moment(date).format('YYYY-MM-DD HH:mm'); -}; +import { addCell, addHeader, convertTime, dateToString, fixNumber, nextTime } from './exportUtils'; const timeIntervals: string[] = [ '0-5 second', @@ -115,8 +41,9 @@ const insightExportQueries = { /* * Volume report export */ - async insightVolumeReportExport(_root, args: IListArgs) { + async insightVolumeReportExport(_root, args: IListArgs, { user }: { user: IUserDocument }) { const { startDate, endDate, type } = args; + let diffCount = 7; let timeFormat = 'YYYY-MM-DD'; let aggregationTimeFormat = '%Y-%m-%d'; @@ -142,20 +69,11 @@ const insightExportQueries = { }, { $project: { - date: { - $dateToString: { - format: aggregationTimeFormat, - date: '$createdAt', - }, - }, + date: await getDateFieldAsStr({ timeFormat: aggregationTimeFormat, timeZone: getTimezone(user) }), customerId: 1, status: 1, - closeTime: { - $divide: [{ $subtract: ['$closedAt', '$createdAt'] }, 1000], - }, - firstRespondTime: { - $divide: [{ $subtract: ['$firstRespondedDate', '$createdAt'] }, 1000], - }, + closeTime: getDurationField({ startField: '$closedAt', endField: '$createdAt' }), + firstRespondTime: getDurationField({ startField: '$firstRespondedDate', endField: '$createdAt' }), }, }, { @@ -166,16 +84,16 @@ const insightExportQueries = { $sum: { $cond: [{ $eq: ['$status', 'closed'] }, 1, 0] }, }, totalCount: { $sum: 1 }, - averageCloseTime: { $avg: '$closeTime' }, - averageRespondTime: { $avg: '$firstRespondTime' }, + totalResponseTime: { $sum: '$firstRespondTime' }, + totalCloseTime: { $sum: '$closeTime' }, }, }, { $project: { uniqueCustomerCount: { $size: '$uniqueCustomerIds' }, totalCount: 1, - averageCloseTime: 1, - averageRespondTime: 1, + totalCloseTime: 1, + totalResponseTime: 1, resolvedCount: 1, percentage: { $multiply: [ @@ -191,16 +109,17 @@ const insightExportQueries = { const volumeDictionary = {}; - let totalSumCount = 0; let totalCustomerCount = 0; + let totalUniqueCount = 0; let totalConversationMessages = 0; - let totalPercentage = 0; let totalResolved = 0; - let totalAverageClosed = 0; - let totalAverageRespond = 0; - let totalRowCount = 0; - aggregatedData.map(row => { + let averageResponseDuration = 0; + let firstResponseDuration = 0; + let totalClosedTime = 0; + let totalRespondTime = 0; + + aggregatedData.forEach(row => { volumeDictionary[row._id] = row; }); @@ -208,16 +127,12 @@ const insightExportQueries = { { $match: { conversationId: { $in: conversationRawIds }, + createdAt: { $gte: start, $lte: end }, }, }, { $project: { - date: { - $dateToString: { - format: aggregationTimeFormat, - date: '$createdAt', - }, - }, + date: await getDateFieldAsStr({ timeFormat: aggregationTimeFormat, timeZone: getTimezone(user) }), status: 1, }, }, @@ -228,8 +143,9 @@ const insightExportQueries = { }, }, ]); + const conversationDictionary = {}; - messageAggregationData.map(row => { + messageAggregationData.forEach(row => { conversationDictionary[row._id] = row.totalCount; totalConversationMessages += row.totalCount; }); @@ -243,37 +159,40 @@ const insightExportQueries = { const { resolvedCount, totalCount, - averageCloseTime, - averageRespondTime, + totalResponseTime, + totalCloseTime, uniqueCustomerCount, percentage, } = volumeDictionary[dateKey] || { resolvedCount: 0, totalCount: 0, - averageCloseTime: 0, - averageRespondTime: 0, + totalResponseTime: 0, + totalCloseTime: 0, uniqueCustomerCount: 0, percentage: 0, }; - const messageCount = conversationDictionary[dateKey]; + const messageCount = conversationDictionary[dateKey] || 0; - totalSumCount += totalCount; + totalCustomerCount += totalCount; totalResolved += resolvedCount; - totalAverageClosed += averageCloseTime; - totalAverageRespond += averageRespondTime; - totalCustomerCount += uniqueCustomerCount; - totalPercentage += percentage; - totalRowCount += 1; + + totalUniqueCount += uniqueCustomerCount; + + totalClosedTime += totalCloseTime; + totalRespondTime += totalResponseTime; + + averageResponseDuration = fixNumber(totalCloseTime / resolvedCount); + firstResponseDuration = fixNumber(totalResponseTime / totalCount); data.push({ date: moment(begin).format(timeFormat), - count: totalCount, - customerCount: uniqueCustomerCount, + count: uniqueCustomerCount, + customerCount: totalCount, customerCountPercentage: `${percentage.toFixed(0)}%`, messageCount, resolvedCount, - averageResponseDuration: convertTime(averageCloseTime), - firstResponseDuration: convertTime(averageRespondTime), + averageResponseDuration: convertTime(averageResponseDuration), + firstResponseDuration: convertTime(firstResponseDuration), }); if (next.getTime() < end.getTime()) { @@ -287,21 +206,22 @@ const insightExportQueries = { data.push({ date: 'Total', - count: totalSumCount, + count: totalUniqueCount, customerCount: totalCustomerCount, - customerCountPercentage: `${(totalPercentage / totalRowCount).toFixed(0)}%`, + customerCountPercentage: `${((totalUniqueCount / totalCustomerCount) * 100).toFixed(0)}%`, messageCount: totalConversationMessages, resolvedCount: totalResolved, - averageResponseDuration: convertTime(totalAverageClosed / totalRowCount), - firstResponseDuration: convertTime(totalAverageRespond / totalRowCount), + averageResponseDuration: convertTime(fixNumber(totalClosedTime / totalResolved)), + firstResponseDuration: convertTime(fixNumber(totalRespondTime / totalCustomerCount)), }); const basicInfos = INSIGHT_BASIC_INFOS; // Reads default template const { workbook, sheet } = await createXlsFile(); + await addHeader(`Volume Report By ${type || 'date'}`, args, sheet); - let rowIndex: number = 1; + let rowIndex: number = 3; const cols: string[] = []; for (const obj of data) { @@ -320,27 +240,17 @@ const insightExportQueries = { } // Write to file. - return generateXlsx(workbook, `Volume report - ${dateToString(start)} - ${dateToString(end)}`); + return generateXlsx(workbook, `Volume report By ${type || 'date'} - ${dateToString(start)} - ${dateToString(end)}`); }, /* * Operator Activity Report */ - async insightActivityReportExport(_root, args: IListArgs) { - const { integrationType, brandId, startDate, endDate } = args; + async insightActivityReportExport(_root, args: IListArgs, { user }: { user: IUserDocument }) { + const { startDate, endDate } = args; const { start, end } = fixDates(startDate, endDate, 1); - const messageSelector = await generateMessageSelector( - brandId, - integrationType, - // conversation selector - {}, - // message selector - { - userId: generateUserSelector('response'), - createdAt: { $gte: start, $lte: end }, - }, - ); + const messageSelector = await generateMessageSelector({ args, type: 'response' }); const data = await ConversationMessages.aggregate([ { @@ -348,12 +258,7 @@ const insightExportQueries = { }, { $project: { - date: { - $dateToString: { - format: '%Y-%m-%d %H', - date: '$createdAt', - }, - }, + date: await getDateFieldAsStr({ timeFormat: '%Y-%m-%d %H', timeZone: getTimezone(user) }), userId: 1, }, }, @@ -375,19 +280,22 @@ const insightExportQueries = { }, }, ]); + const userDataDictionary = {}; const rawUserIds = {}; const userTotals = {}; - data.map(row => { + data.forEach(row => { userDataDictionary[`${row.userId}_${row.date}`] = row.count; rawUserIds[row.userId] = 1; }); const userIds = Object.keys(rawUserIds); + const users: any = {}; // Reads default template const { workbook, sheet } = await createXlsFile(); - let rowIndex = 1; + await addHeader('Operator Activity report', args, sheet); + let rowIndex = 3; const cols: string[] = []; let begin = start; @@ -405,7 +313,9 @@ const insightExportQueries = { for (const userId of userIds) { if (!users[userId]) { - const { details, email } = (await Users.findOne({ _id: userId })) as IUserDocument; + const { details, email } = (await Users.findOne({ + _id: userId, + })) as IUserDocument; users[userId] = (details && details.fullName) || email; } @@ -451,19 +361,20 @@ const insightExportQueries = { } // Write to file. - return generateXlsx(workbook, `Activity report - ${dateToString(start)} - ${dateToString(end)}`); + return generateXlsx(workbook, `Operator Activity report - ${dateToString(start)} - ${dateToString(end)}`); }, /* * First Response Report */ async insightFirstResponseReportExport(_root, args: IListArgsWithUserId) { - const { integrationType, brandId, startDate, endDate, userId, type } = args; + const { startDate, endDate, userId, type } = args; + const filterSelector = getFilterSelector(args); const { start, end } = fixDates(startDate, endDate); // Reads default template const { workbook, sheet } = await createXlsFile(); - let rowIndex = 1; + let rowIndex = 3; const cols: string[] = []; for (const t of timeIntervals) { @@ -488,11 +399,11 @@ const insightExportQueries = { messageCounts.push(0); }); - const conversations = await findConversations({ kind: integrationType, brandId }, conversationSelector); + const conversations = await findConversations(filterSelector, conversationSelector); // Processes total first response time for each users. for (const conversation of conversations) { - rowIndex = 1; + rowIndex = 3; const { firstRespondedDate, createdAt } = conversation; let responseTime = 0; @@ -533,9 +444,21 @@ const insightExportQueries = { } }; + let fullName = ''; + + if (userId) { + const { details, email } = (await Users.findOne({ + _id: userId, + })) as IUserDocument; + + fullName = `${(details && details.fullName) || email || ''} `; + } + if (type === 'operator') { const users = await Users.find(); + await addHeader(`${fullName} First Response`, args, sheet); + for (const user of users) { const { _id, details, username } = user; @@ -566,17 +489,10 @@ const insightExportQueries = { } }; + await addHeader(`${fullName} First Response`, args, sheet); await generateData(); } - let fullName = ''; - - if (userId) { - const { details, email } = (await Users.findOne({ _id: userId })) as IUserDocument; - - fullName = `${(details && details.fullName) || email || ''} `; - } - // Write to file. return generateXlsx(workbook, `${fullName}First Response - ${dateToString(start)} - ${dateToString(end)}`); }, @@ -584,27 +500,20 @@ const insightExportQueries = { /* * Tag Report */ - async insightTagReportExport(_root, args: IListArgs) { - const { integrationType, brandId, startDate, endDate } = args; + async insightTagReportExport(_root, args: IListArgs, { user }: { user: IUserDocument }) { + const { startDate, endDate } = args; const { start, end } = fixDates(startDate, endDate); - - const integrationSelector: { brandId?: string; kind?: string } = {}; - - if (brandId) { - integrationSelector.brandId = brandId; - } + const filterSelector = getFilterSelector(args); const tags = await Tags.find({ type: TAG_TYPES.CONVERSATION }).select('name'); - if (integrationType) { - integrationSelector.kind = integrationType; - } - - const integrationIds = await Integrations.find(integrationSelector).select('_id'); + const integrationIds = await Integrations.find(filterSelector.integration).select('_id'); // Reads default template const { workbook, sheet } = await createXlsFile(); - let rowIndex = 1; + await addHeader('Tag Report', args, sheet); + + let rowIndex = 3; const cols: string[] = []; let begin = start; @@ -617,7 +526,7 @@ const insightExportQueries = { $match: { $or: [{ userId: { $exists: true }, messageCount: { $gt: 1 } }, { userId: { $exists: false } }], integrationId: { $in: rawIntegrationIds }, - createdAt: { $gte: start, $lte: end }, + createdAt: filterSelector.createdAt, }, }, { @@ -632,12 +541,7 @@ const insightExportQueries = { $group: { _id: { tagId: '$tagIds', - date: { - $dateToString: { - format: '%Y-%m-%d', - date: '$createdAt', - }, - }, + date: getDateFieldAsStr({ timeZone: getTimezone(user) }), }, count: { $sum: 1 }, }, @@ -717,6 +621,6 @@ const insightExportQueries = { }, }; -moduleRequireLogin(insightExportQueries); +moduleCheckPermission(insightExportQueries, 'manageExportInsights'); export default insightExportQueries; diff --git a/src/data/resolvers/queries/insights.ts b/src/data/resolvers/queries/insights/insights.ts similarity index 54% rename from src/data/resolvers/queries/insights.ts rename to src/data/resolvers/queries/insights/insights.ts index f745164d3..9a091a3fb 100644 --- a/src/data/resolvers/queries/insights.ts +++ b/src/data/resolvers/queries/insights/insights.ts @@ -1,33 +1,24 @@ -import * as moment from 'moment'; -import { ConversationMessages, Conversations, Integrations, Tags, Users } from '../../../db/models'; -import { FACEBOOK_DATA_KINDS, INTEGRATION_KIND_CHOICES, TAG_TYPES } from '../../constants'; -import { moduleRequireLogin } from '../../permissions'; +import { ConversationMessages, Conversations, Integrations, Tags } from '../../../../db/models'; +import { IUserDocument } from '../../../../db/models/definitions/users'; +import { FACEBOOK_DATA_KINDS, INTEGRATION_KIND_CHOICES, TAG_TYPES } from '../../../constants'; +import { checkPermission, moduleRequireLogin } from '../../../permissions'; +import { getDateFieldAsStr, getDurationField } from '../aggregationUtils'; +import { IListArgs, IPieChartData } from './types'; import { findConversations, fixChartData, - fixDate, fixDates, - generateChartData, + generateChartDataByCollection, + generateChartDataBySelector, generateMessageSelector, + generatePunchData, generateResponseData, - generateTimeIntervals, - generateUserSelector, + getConversationDates, getConversationSelector, -} from './insightUtils'; - -interface IListArgs { - integrationType: string; - brandId: string; - startDate: string; - endDate: string; - type: string; -} - -interface IPieChartData { - id: string; - label: string; - value: number; -} + getFilterSelector, + getSummaryData, + getTimezone, +} from './utils'; const insightQueries = { /** @@ -35,15 +26,10 @@ const insightQueries = { * count of conversations in various integrations kinds. */ async insights(_root, args: IListArgs) { - const { brandId, integrationType, startDate, endDate } = args; + const { startDate, endDate } = args; + const filterSelector = getFilterSelector(args); const { start, end } = fixDates(startDate, endDate); - const integrationSelector: { brandId?: string; kind?: string } = {}; - - if (brandId) { - integrationSelector.brandId = brandId; - } - const insights: { integration: IPieChartData[]; tag: IPieChartData[] } = { integration: [], tag: [], @@ -57,7 +43,7 @@ const insightQueries = { // count conversations by each integration kind for (const kind of INTEGRATION_KIND_CHOICES.ALL) { const integrationIds = await Integrations.find({ - ...integrationSelector, + ...filterSelector.integration, kind, }).select('_id'); @@ -94,11 +80,7 @@ const insightQueries = { const tags = await Tags.find({ type: TAG_TYPES.CONVERSATION }).select('name'); - if (integrationType) { - integrationSelector.kind = integrationType; - } - - const integrationIdsByTag = await Integrations.find(integrationSelector).select('_id'); + const integrationIdsByTag = await Integrations.find(filterSelector.integration).select('_id'); const rawIntegrationIdsByTag = integrationIdsByTag.map(row => row._id); const tagData = await Conversations.aggregate([ { @@ -117,10 +99,12 @@ const insightQueries = { }, }, ]); + const tagDictionaryData = {}; - tagData.map(row => { + tagData.forEach(row => { tagDictionaryData[row._id] = row.count; }); + // count conversations by each tag for (const tag of tags) { // find conversation counts of given tag @@ -129,206 +113,86 @@ const insightQueries = { insights.tag.push({ id: tag.name, label: tag.name, value }); } } + return insights; }, /** * Counts conversations by each hours in each days. */ - async insightsPunchCard(_root, args: IListArgs) { - const { type, integrationType, brandId, endDate } = args; + async insightsPunchCard(_root, args: IListArgs, { user }: { user: IUserDocument }) { + const { type } = args; - // check & convert endDate's value - const end = moment(fixDate(endDate)).format('YYYY-MM-DD'); - const start = moment(end).add(-7, 'days'); + const messageSelector = await generateMessageSelector({ + args, + excludeBot: true, + type, + }); - const conversationIds = await findConversations( - { brandId, kind: integrationType }, - { - createdAt: { $gte: start, $lte: end }, - }, - true, - ); + return generatePunchData(ConversationMessages, messageSelector, user); + }, - const rawConversationIds = conversationIds.map(obj => obj._id); - const matchMessageSelector = { - conversationId: { $in: rawConversationIds }, - fromBot: { $exists: false }, - // client or user - userId: generateUserSelector(type), - createdAt: { $gte: start.toDate(), $lte: new Date(end) }, - }; + /** + * Sends combined charting data for trends. + */ + async insightsTrend(_root, args: IListArgs) { + const { type } = args; - // TODO: need improvements on timezone calculation. - const punchData = await ConversationMessages.aggregate([ - { - $match: matchMessageSelector, - }, - { - $project: { - hour: { $hour: { date: '$createdAt', timezone: '+08' } }, - day: { $isoDayOfWeek: { date: '$createdAt', timezone: '+08' } }, - date: { - $dateToString: { - format: '%Y-%m-%d', - date: '$createdAt', - // timezone: "+08" - }, - }, - }, - }, - { - $group: { - _id: { - hour: '$hour', - day: '$day', - date: '$date', - }, - count: { $sum: 1 }, - }, - }, - { - $project: { - _id: 0, - day: '$_id.day', - hour: '$_id.hour', - date: '$_id.date', - count: 1, - }, - }, - ]); + const messageSelector = await generateMessageSelector({ + args, + excludeBot: true, + type, + }); - return punchData; + return generateChartDataBySelector({ selector: messageSelector }); }, /** - * Sends combined charting data for trends, summaries and team members. + * Sends summary datas. */ - async insightsMain(_root, args: IListArgs) { - const { type, integrationType, brandId, startDate, endDate } = args; - const { start, end } = fixDates(startDate, endDate); - - const messageSelector = await generateMessageSelector( - brandId, - integrationType, - // conversation selector - { - createdAt: { $gte: start, $lte: end }, - }, - // message selector - { - userId: generateUserSelector(type), - createdAt: { $gte: start, $lte: end }, - // exclude bot messages - fromBot: { $exists: false }, - }, - ); - - const insightData: any = { - summary: [], - trend: await generateChartData({ messageSelector }), - }; + async insightsSummaryData(_root, args: IListArgs) { + const { startDate, endDate, type } = args; + + const messageSelector = await generateMessageSelector({ + args, + excludeBot: true, + type, + createdAt: getConversationDates(args.endDate), + }); - const summaries = generateTimeIntervals(start, end); - const facets = {}; - // finds a respective message counts for different time intervals. - for (const summary of summaries) { - messageSelector.createdAt = { - $gt: summary.start.toDate(), - $lte: summary.end.toDate(), - }; - facets[summary.title] = [ - { - $match: { - userId: generateUserSelector(type), - createdAt: { - $gte: summary.start.toDate(), - $lte: summary.end.toDate(), - }, - // exclude bot messages - fromBot: { $exists: false }, - }, - }, - { - $group: { - _id: null, - count: { $sum: 1 }, - }, - }, - { - $project: { - _id: 0, - count: 1, - }, - }, - ]; - } - const data = await ConversationMessages.aggregate([ - { - $facet: facets, - }, - ]); - for (const summary of summaries) { - const count = data['0'][summary.title][0] ? data['0'][summary.title][0].count : 0; - insightData.summary.push({ - title: summary.title, - count, - }); - } + const { start, end } = fixDates(startDate, endDate); - return insightData; + return getSummaryData({ + startDate: start, + endDate: end, + collection: ConversationMessages, + selector: { ...messageSelector }, + }); }, /** * Sends combined charting data for trends and summaries. */ async insightsConversation(_root, args: IListArgs) { - const { integrationType, brandId, startDate, endDate } = args; - const { start, end } = fixDates(startDate, endDate); - + const filterSelector = getFilterSelector(args); const conversationSelector = { - createdAt: { $gt: start, $lte: end }, + createdAt: filterSelector.createdAt, $or: [{ userId: { $exists: true }, messageCount: { $gt: 1 } }, { userId: { $exists: false } }], }; - - const conversations = await findConversations({ kind: integrationType, brandId }, conversationSelector); + const conversations = await findConversations(filterSelector, { ...conversationSelector }); const insightData: any = { summary: [], - trend: await generateChartData({ collection: conversations }), + trend: await generateChartDataByCollection(conversations), }; - const summaries = generateTimeIntervals(start, end); - const facets = {}; - - // finds a respective message counts for different time intervals. - for (const summary of summaries) { - facets[summary.title] = [ - { - $match: { - createdAt: { - $gt: summary.start.toDate(), - $lte: summary.end.toDate(), - }, - $or: [{ userId: { $exists: true }, messageCount: { $gt: 1 } }, { userId: { $exists: false } }], - }, - }, - { $group: { _id: null, count: { $sum: 1 } } }, - { $project: { _id: 0, count: 1 } }, - ]; - } - const data = await Conversations.aggregate([ - { - $facet: facets, - }, - ]); - - for (const summary of summaries) { - const count = data['0'][summary.title][0] ? data['0'][summary.title][0].count : 0; - insightData.summary.push({ - title: summary.title, - count, - }); - } + const { startDate, endDate } = args; + const { start, end } = fixDates(startDate, endDate); + insightData.summary = await getSummaryData({ + startDate: start, + endDate: end, + collection: Conversations, + selector: { ...conversationSelector }, + }); return insightData; }, @@ -337,7 +201,8 @@ const insightQueries = { * Calculates average first response time for each team members. */ async insightsFirstResponse(_root, args: IListArgs) { - const { integrationType, brandId, startDate, endDate } = args; + const { startDate, endDate } = args; + const filterSelector = getFilterSelector(args); const { start, end } = fixDates(startDate, endDate); const conversationSelector = { @@ -357,7 +222,7 @@ const insightQueries = { let allResponseTime = 0; - const conversations = await findConversations({ kind: integrationType, brandId }, conversationSelector); + const conversations = await findConversations(filterSelector, conversationSelector); if (conversations.length < 1) { return insightData; @@ -415,38 +280,26 @@ const insightQueries = { /** * Calculates average response close time for each team members. */ - async insightsResponseClose(_root, args: IListArgs) { - const { integrationType, brandId, startDate, endDate } = args; + async insightsResponseClose(_root, args: IListArgs, { user }: { user: IUserDocument }) { + const { startDate, endDate } = args; const { start, end } = fixDates(startDate, endDate); const conversationSelector = { createdAt: { $gte: start, $lte: end }, - closedAt: { $ne: null }, - closedUserId: { $ne: null }, + closedAt: { $exists: true }, + closedUserId: { $exists: true }, }; - const conversationMatch = await getConversationSelector({ kind: integrationType, brandId }, conversationSelector); + const conversationMatch = await getConversationSelector(args, { ...conversationSelector }); + const insightAggregateData = await Conversations.aggregate([ { $match: conversationMatch, }, - { - $match: { - closedAt: { $exists: true }, - }, - }, { $project: { - responseTime: { - $divide: [{ $subtract: ['$closedAt', '$createdAt'] }, 1000], - }, - date: { - $dateToString: { - format: '%Y-%m-%d', - date: '$createdAt', - // timezone: "+08" - }, - }, + responseTime: getDurationField({ startField: '$closedAt', endField: '$createdAt' }), + date: await getDateFieldAsStr({ timeZone: getTimezone(user) }), closedUserId: 1, }, }, @@ -471,12 +324,25 @@ const insightQueries = { count: 1, }, }, + { + $lookup: { + from: 'users', + localField: 'closedUserId', + foreignField: '_id', + as: 'userDoc', + }, + }, + { + $replaceRoot: { newRoot: { $mergeObjects: [{ $arrayElemAt: ['$userDoc.details', 0] }, '$$ROOT'] } }, + }, { $group: { _id: '$closedUserId', responseTime: { $sum: '$totalResponseTime' }, avgResponseTime: { $avg: '$avgResponseTime' }, count: { $sum: '$count' }, + fullName: { $first: '$fullName' }, + avatar: { $first: '$avatar' }, chartDatas: { $push: { date: '$date', @@ -486,6 +352,7 @@ const insightQueries = { }, }, ]); + // Variables holds every user's response time. const teamMembers: any = []; const responseUserData: any = {}; @@ -493,31 +360,30 @@ const insightQueries = { let allResponseTime = 0; let totalCount = 0; const aggregatedTrend = {}; + for (const userData of insightAggregateData) { // responseUserData responseUserData[userData._id] = { responseTime: userData.responseTime, count: userData.count, avgResponseTime: userData.avgResponseTime, + fullName: userData.fullName, + avatar: userData.avatar, }; // team members gather const fixedChartData = await fixChartData(userData.chartDatas, 'date', 'count'); - const user = await Users.findOne({ _id: userData._id }); - userData.chartDatas.map(row => { + userData.chartDatas.forEach(row => { if (row.date in aggregatedTrend) { aggregatedTrend[row.date] += row.count; } else { aggregatedTrend[row.date] = row.count; } }); - if (!user) { - continue; - } - const userDetail = user.details; + teamMembers.push({ data: { - fullName: userDetail ? userDetail.fullName : '', - avatar: userDetail ? userDetail.avatar : '', + fullName: userData.fullName, + avatar: userData.avatar, graph: fixedChartData, }, }); @@ -547,4 +413,6 @@ const insightQueries = { moduleRequireLogin(insightQueries); +checkPermission(insightQueries, 'insights', 'showInsights'); + export default insightQueries; diff --git a/src/data/resolvers/queries/insights/types.ts b/src/data/resolvers/queries/insights/types.ts new file mode 100644 index 000000000..801ce96e0 --- /dev/null +++ b/src/data/resolvers/queries/insights/types.ts @@ -0,0 +1,135 @@ +import * as moment from 'moment'; + +export interface IMessageSelector { + userId?: string; + createdAt?: { $gte: Date; $lte: Date }; + fromBot?: { $exists: boolean }; + conversationId?: { + $in: string[]; + }; +} + +export interface IGenerateChartData { + x: any; + y: number; +} + +export interface IGeneratePunchCard { + day: number; + hour: number; + date: number; + count: number; +} + +export interface IGenerateTimeIntervals { + title: string; + start: moment.Moment; + end: moment.Moment; +} + +export interface IGenerateUserChartData { + fullName?: string; + avatar?: string; + graph: IGenerateChartData[]; +} + +export interface IFixDates { + start: Date; + end: Date; +} + +export interface IResponseUserData { + [index: string]: { + responseTime: number; + count: number; + summaries?: number[]; + }; +} + +export interface IGenerateResponseData { + trend: IGenerateChartData[]; + time: number; + teamMembers: { + data: IGenerateUserChartData[]; + }; +} + +export interface IListArgs { + integrationIds: string; + brandIds: string; + startDate: string; + endDate: string; + type: string; +} + +export interface IDealListArgs { + pipelineIds: string; + boardId: string; + startDate: string; + endDate: string; + status: string; +} + +export interface IFilterSelector { + createdAt?: { $gte: Date; $lte: Date }; + integration: { + kind?: { $in: string[] }; + brandId?: { $in: string[] }; + }; +} + +export interface IDealSelector { + modifiedAt?: { + $gte: Date; + $lte: Date; + }; + createdAt?: { + $gte: Date; + $lte: Date; + }; + stageId?: object; +} + +export interface IStageSelector { + probability?: string; + pipelineId?: {}; +} + +export interface IPieChartData { + id: string; + label: string; + value: number; +} + +export interface IVolumeReportExportArgs { + date: string; + count: number; + customerCount: number; + customerCountPercentage: string; + messageCount: number; + resolvedCount: number; + averageResponseDuration: string; + firstResponseDuration: string; +} + +export interface IAddCellArgs { + sheet: any; + cols: string[]; + rowIndex: number; + col: string; + value: string | number; +} + +export interface IListArgsWithUserId extends IListArgs { + userId?: string; +} + +export interface IGenerateMessage { + args: IListArgs; + createdAt?: { + $gte: Date; + $lte: Date; + }; + type: string; + excludeBot?: boolean; +} diff --git a/src/data/resolvers/queries/insights/utils.ts b/src/data/resolvers/queries/insights/utils.ts new file mode 100644 index 000000000..8d6b7a2e2 --- /dev/null +++ b/src/data/resolvers/queries/insights/utils.ts @@ -0,0 +1,541 @@ +import * as moment from 'moment'; +import * as _ from 'underscore'; +import { + ConversationMessages, + Conversations, + DealPipelines, + Deals, + DealStages, + Integrations, + Users, +} from '../../../../db/models'; +import { IMessageDocument } from '../../../../db/models/definitions/conversationMessages'; +import { IConversationDocument } from '../../../../db/models/definitions/conversations'; +import { IStageDocument } from '../../../../db/models/definitions/deals'; +import { IUser } from '../../../../db/models/definitions/users'; +import { INSIGHT_TYPES } from '../../../constants'; +import { getDateFieldAsStr } from '../aggregationUtils'; +import { fixDate } from '../utils'; +import { + IDealListArgs, + IDealSelector, + IFilterSelector, + IFixDates, + IGenerateChartData, + IGenerateMessage, + IGeneratePunchCard, + IGenerateResponseData, + IGenerateTimeIntervals, + IGenerateUserChartData, + IListArgs, + IMessageSelector, + IResponseUserData, + IStageSelector, +} from './types'; + +/** + * Return filterSelector + * @param args + */ +export const getFilterSelector = (args: IListArgs): any => { + const selector: IFilterSelector = { integration: {} }; + const { startDate, endDate, integrationIds, brandIds } = args; + const { start, end } = fixDates(startDate, endDate); + + if (integrationIds) { + selector.integration.kind = { $in: integrationIds.split(',') }; + } + + if (brandIds) { + selector.integration.brandId = { $in: brandIds.split(',') }; + } + + selector.createdAt = { $gte: start, $lte: end }; + + return selector; +}; + +/** + * Return filterSelector + * @param args + */ +export const getDealSelector = async (args: IDealListArgs): Promise => { + const { startDate, endDate, boardId, pipelineIds, status } = args; + const { start, end } = fixDates(startDate, endDate); + + const selector: IDealSelector = {}; + const date = { + $gte: start, + $lte: end, + }; + + // If status is either won or lost, modified date is more important + if (status) { + selector.modifiedAt = date; + } else { + selector.createdAt = date; + } + + const stageSelector: IStageSelector = {}; + + if (status) { + stageSelector.probability = status; + } + + let stages: IStageDocument[] = []; + + if (boardId) { + if (pipelineIds) { + stageSelector.pipelineId = { $in: pipelineIds.split(',') }; + } else { + const pipelines = await DealPipelines.find({ boardId }); + stageSelector.pipelineId = { $in: pipelines.map(p => p._id) }; + } + + stages = await DealStages.find(stageSelector); + selector.stageId = { $in: stages.map(s => s._id) }; + } else { + if (status) { + stages = await DealStages.find(stageSelector); + selector.stageId = { $in: stages.map(s => s._id) }; + } + } + + return selector; +}; + +/** + * Return conversationSelect for aggregation + * @param args + * @param conversationSelector + * @param selectIds + */ +export const getConversationSelector = async (args: IListArgs, conversationSelector: any): Promise => { + const filterSelector = await getFilterSelector(args); + + if (filterSelector.integration) { + const integrationIds = await Integrations.find(filterSelector.integration).select('_id'); + conversationSelector.integrationId = { $in: integrationIds.map(row => row._id) }; + } + conversationSelector.createdAt = filterSelector.createdAt; + + return conversationSelector; +}; + +/** + * Find conversations or conversationIds. + */ +export const findConversations = async ( + filterSelector: IFilterSelector, + conversationSelector: any, + selectIds?: boolean, +): Promise => { + if (Object.keys(filterSelector.integration).length > 0) { + const integrationIds = await Integrations.find(filterSelector.integration).select('_id'); + conversationSelector.integrationId = integrationIds.map(row => row._id); + } + + if (selectIds) { + return Conversations.find(conversationSelector).select('_id'); + } + + return Conversations.find(conversationSelector).sort({ createdAt: 1 }); +}; +/** + * + * @param summaries + * @param collection + * @param selector + */ +export const getSummaryData = async ({ + startDate, + endDate, + selector, + collection, + dateFieldName = 'createdAt', +}: { + startDate: Date; + endDate: Date; + selector: any; + collection: any; + dateFieldName?: string; +}): Promise => { + const summaries: Array<{ title?: string; count?: number }> = []; + const intervals = generateTimeIntervals(startDate, endDate); + const facets = {}; + // finds a respective message counts for different time intervals. + for (const interval of intervals) { + const facetMessageSelector = { ...selector }; + facetMessageSelector[dateFieldName] = { + $gte: interval.start.toDate(), + $lte: interval.end.toDate(), + }; + + facets[interval.title] = [ + { + $match: facetMessageSelector, + }, + { + $group: { + _id: null, + count: { $sum: 1 }, + }, + }, + { + $project: { + _id: 0, + count: 1, + }, + }, + ]; + } + + const [legend] = await collection.aggregate([ + { + $facet: facets, + }, + ]); + + for (const interval of intervals) { + const count = legend[interval.title][0] ? legend[interval.title][0].count : 0; + summaries.push({ + title: interval.title, + count, + }); + } + return summaries; +}; + +/** + * Builds messages find query selector. + */ +export const generateMessageSelector = async ({ + args, + createdAt, + type, + excludeBot, +}: IGenerateMessage): Promise => { + const filterSelector = getFilterSelector(args); + const updatedCreatedAt = createdAt || filterSelector.createdAt; + + const messageSelector: any = { + createdAt: updatedCreatedAt, + userId: generateUserSelector(type), + }; + + if (excludeBot) { + messageSelector.fromBot = { $exists: false }; + } + + // While searching by integration + if (Object.keys(filterSelector.integration).length > 0) { + const conversationIds = await findConversations(filterSelector, { createdAt: updatedCreatedAt }, true); + + const rawConversationIds = conversationIds.map(obj => obj._id); + messageSelector.conversationId = { $in: rawConversationIds }; + } + + return messageSelector; +}; +/** + * Fix trend for missing values because from then aggregation, + * it could return missing values for some dates. This method + * will assign 0 values for missing x values. + * @param startDate + * @param endDate + * @param data + */ +export const fixChartData = async (data: any[], hintX: string, hintY: string): Promise => { + const results = {}; + data.map(row => { + results[row[hintX]] = row[hintY]; + }); + + return Object.keys(results) + .sort() + .map(key => { + return { x: formatTime(moment(key), 'MM-DD'), y: results[key] }; + }); +}; + +/** + * Populates message collection into date range + * by given duration and loop count for chart data. + */ +export const generateChartDataBySelector = async ({ + selector, + type = INSIGHT_TYPES.CONVERSATION, + dateFieldName = '$createdAt', +}: { + selector: IMessageSelector; + type?: string; + dateFieldName?: string; +}): Promise => { + const pipelineStages = [ + { + $match: selector, + }, + { + $project: { + date: getDateFieldAsStr({ fieldName: dateFieldName }), + }, + }, + { + $group: { + _id: '$date', + y: { $sum: 1 }, + }, + }, + { + $project: { + x: '$_id', + y: 1, + _id: 0, + }, + }, + { + $sort: { + x: 1, + }, + }, + ]; + + if (type === INSIGHT_TYPES.DEAL) { + return Deals.aggregate([pipelineStages]); + } + + return ConversationMessages.aggregate([pipelineStages]); +}; + +export const generatePunchData = async ( + collection: any, + selector: object, + user: IUser, +): Promise => { + const pipelineStages = [ + { + $match: selector, + }, + { + $project: { + hour: { $hour: { date: '$createdAt', timezone: '+08' } }, + day: { $isoDayOfWeek: { date: '$createdAt', timezone: '+08' } }, + date: await getDateFieldAsStr({ timeZone: getTimezone(user) }), + }, + }, + { + $group: { + _id: { + hour: '$hour', + day: '$day', + date: '$date', + }, + count: { $sum: 1 }, + }, + }, + { + $project: { + _id: 0, + day: '$_id.day', + hour: '$_id.hour', + date: '$_id.date', + count: 1, + }, + }, + ]; + + return collection.aggregate(pipelineStages); +}; + +/** + * Populates message collection into date range + * by given duration and loop count for chart data. + */ + +export const generateChartDataByCollection = async (collection: any): Promise => { + const results = {}; + + collection.map(obj => { + const date = formatTime(moment(obj.createdAt), 'YYYY-MM-DD'); + + results[date] = (results[date] || 0) + 1; + }); + + return Object.keys(results) + .sort() + .map(key => { + return { x: formatTime(moment(key), 'MM-DD'), y: results[key] }; + }); +}; + +/** + * Generates time intervals for main report + */ +export const generateTimeIntervals = (start: Date, end: Date): IGenerateTimeIntervals[] => { + const month = moment(end).month(); + + return [ + { + title: 'In time range', + start: moment(start), + end: moment(end), + }, + { + title: 'This month', + start: moment(1, 'DD'), + end: moment(), + }, + { + title: 'This week', + start: moment(end).weekday(0), + end: moment(end), + }, + { + title: 'Today', + start: moment(end).add(-1, 'days'), + end: moment(end), + }, + { + title: 'Last 30 days', + start: moment(end).add(-30, 'days'), + end: moment(end), + }, + { + title: 'Last month', + start: moment(month + 1, 'MM').subtract(1, 'months'), + end: moment(month + 1, 'MM'), + }, + { + title: 'Last week', + start: moment(end).weekday(-7), + end: moment(end).weekday(0), + }, + { + title: 'Yesterday', + start: moment(end).add(-2, 'days'), + end: moment(end).add(-1, 'days'), + }, + ]; +}; + +/** + * Generate chart data for given user + */ +export const generateUserChartData = async ({ + userId, + userMessages, +}: { + userId: string; + userMessages: IMessageDocument[]; +}): Promise => { + const user = await Users.findOne({ _id: userId }); + const userData = await generateChartDataByCollection(userMessages); + + if (!user) { + return { + graph: userData, + }; + } + + const userDetail = user.details; + + return { + fullName: userDetail ? userDetail.fullName : '', + avatar: userDetail ? userDetail.avatar : '', + graph: userData, + }; +}; + +export const formatTime = (time, format = 'YYYY-MM-DD HH:mm:ss') => { + return time.format(format); +}; + +// TODO: check usage +export const getTime = (date: string | number): number => { + return new Date(date).getTime(); +}; + +export const fixDates = (startValue: string, endValue: string, count?: number): IFixDates => { + // convert given value or get today + const endDate = fixDate(endValue); + + const startDateDefaultValue = new Date( + moment(endDate) + .add(count ? count * -1 : -7, 'days') + .toString(), + ); + + // convert given value or generate from endDate + const startDate = fixDate(startValue, startDateDefaultValue); + + return { start: startDate, end: endDate }; +}; + +export const getConversationDates = (endValue: string): any => { + // convert given value or get today + const endDate = fixDate(endValue); + + const month = moment(endDate).month(); + const startDate = new Date( + moment(month + 1, 'MM') + .subtract(1, 'months') + .toString(), + ); + + return { $gte: startDate, $lte: endDate }; +}; + +/* + * Determines user or client + */ +export const generateUserSelector = (type: string): any => { + let volumeOrResponse: any = null; + + if (type === 'response') { + volumeOrResponse = { $ne: null }; + } + + return volumeOrResponse; +}; + +/** + * Generate response chart data. + */ +export const generateResponseData = async ( + responsData: IMessageDocument[], + responseUserData: IResponseUserData, + allResponseTime: number, +): Promise => { + // preparing trend chart data + const trend = await generateChartDataByCollection(responsData); + + // Average response time for all messages + const time = Math.floor(allResponseTime / responsData.length); + + const teamMembers: any = []; + + const userIds = _.uniq(_.pluck(responsData, 'userId')); + + for (const userId of userIds) { + const { responseTime, count, summaries } = responseUserData[userId]; + + // Average response time for users. + const avgResTime = Math.floor(responseTime / count); + + // preparing each team member's chart data + teamMembers.push({ + data: await generateUserChartData({ + userId, + userMessages: responsData.filter(message => userId === message.userId), + }), + time: avgResTime, + summaries, + }); + } + + return { trend, time, teamMembers }; +}; + +export const getTimezone = (user: IUser): string => { + return (user.details ? user.details.location : '+08') || '+08'; +}; diff --git a/src/data/resolvers/queries/integrations.ts b/src/data/resolvers/queries/integrations.ts index 514405a06..a7150d9a1 100644 --- a/src/data/resolvers/queries/integrations.ts +++ b/src/data/resolvers/queries/integrations.ts @@ -1,9 +1,7 @@ import { Accounts, Brands, Channels, Integrations, Tags } from '../../../db/models'; import { getConfig, getPageList } from '../../../trackers/facebook'; -import { getAccessToken, getAuthorizeUrl } from '../../../trackers/googleTracker'; -import { socUtils } from '../../../trackers/twitterTracker'; import { KIND_CHOICES, TAG_TYPES } from '../../constants'; -import { moduleRequireLogin } from '../../permissions'; +import { checkPermission, moduleRequireLogin } from '../../permissions'; import { paginate } from './utils'; /** @@ -118,29 +116,6 @@ const integrationQueries = { return counts; }, - /** - * Generate twitter integration auth url using credentials in .env - */ - integrationGetTwitterAuthUrl() { - return socUtils.getTwitterAuthorizeUrl(); - }, - - /* - * Generate google integration auth url using credentials in .env - * @return {Promise} - Generated url - */ - integrationGetGoogleAuthUrl(_root, { service }) { - return getAuthorizeUrl(service); - }, - - /* - * Generate google integration auth url using credentials in .env - * @return {Promise} - Generated url - */ - integrationGetGoogleAccessToken(_root, { code }) { - return getAccessToken(code); - }, - /** * Get facebook app list .env */ @@ -169,4 +144,6 @@ const integrationQueries = { moduleRequireLogin(integrationQueries); +checkPermission(integrationQueries, 'integrations', 'showIntegrations', []); + export default integrationQueries; diff --git a/src/data/resolvers/queries/knowledgeBase.ts b/src/data/resolvers/queries/knowledgeBase.ts index 35abe7ae1..dd13b23ed 100644 --- a/src/data/resolvers/queries/knowledgeBase.ts +++ b/src/data/resolvers/queries/knowledgeBase.ts @@ -1,6 +1,6 @@ import { KnowledgeBaseArticles, KnowledgeBaseCategories, KnowledgeBaseTopics } from '../../../db/models'; -import { moduleRequireLogin } from '../../permissions'; +import { checkPermission, requireLogin } from '../../permissions'; import { paginate } from './utils'; /* Articles list & total count helper */ @@ -145,6 +145,16 @@ const knowledgeBaseQueries = { }, }; -moduleRequireLogin(knowledgeBaseQueries); +requireLogin(knowledgeBaseQueries, 'knowledgeBaseArticleDetail'); +requireLogin(knowledgeBaseQueries, 'knowledgeBaseArticlesTotalCount'); +requireLogin(knowledgeBaseQueries, 'knowledgeBaseTopicsTotalCount'); +requireLogin(knowledgeBaseQueries, 'knowledgeBaseTopicDetail'); +requireLogin(knowledgeBaseQueries, 'knowledgeBaseCategoriesGetLast'); +requireLogin(knowledgeBaseQueries, 'knowledgeBaseCategoriesTotalCount'); +requireLogin(knowledgeBaseQueries, 'knowledgeBaseCategoryDetail'); + +checkPermission(knowledgeBaseQueries, 'knowledgeBaseArticles', 'showKnowledgeBase', []); +checkPermission(knowledgeBaseQueries, 'knowledgeBaseTopics', 'showKnowledgeBase', []); +checkPermission(knowledgeBaseQueries, 'knowledgeBaseCategories', 'showKnowledgeBase', []); export default knowledgeBaseQueries; diff --git a/src/data/resolvers/queries/messengerApps.ts b/src/data/resolvers/queries/messengerApps.ts index c3d415057..fe160224d 100644 --- a/src/data/resolvers/queries/messengerApps.ts +++ b/src/data/resolvers/queries/messengerApps.ts @@ -14,6 +14,19 @@ const messengerAppQueries = { return MessengerApps.find(query); }, + + /* + * MessengerApps count + */ + messengerAppsCount(_root, { kind }: { kind: string }) { + const query: any = {}; + + if (kind) { + query.kind = kind; + } + + return MessengerApps.find(query).countDocuments(); + }, }; moduleRequireLogin(messengerAppQueries); diff --git a/src/data/resolvers/queries/permissions.ts b/src/data/resolvers/queries/permissions.ts new file mode 100644 index 000000000..b44b481a1 --- /dev/null +++ b/src/data/resolvers/queries/permissions.ts @@ -0,0 +1,117 @@ +import * as _ from 'underscore'; +import { Permissions, UsersGroups } from '../../../db/models'; +import { checkPermission, requireLogin } from '../../permissions'; +import { actionsMap, IActionsMap, IModulesMap, modulesMap } from '../../permissions/utils'; +import { paginate } from './utils'; + +interface IListArgs { + page?: number; + perPage?: number; + searchValue?: string; +} + +const generateSelector = ({ module, action, userId, groupId }) => { + const filter: any = {}; + + if (module) { + filter.module = module; + } + + if (action) { + filter.action = action; + } + + if (userId) { + filter.userId = userId; + } + + if (groupId) { + filter.groupId = groupId; + } + + return filter; +}; + +const permissionQueries = { + /** + * Permissions list + * @param {Object} args + * @param {String} args.module + * @param {String} args.action + * @param {String} args.userId + * @param {Int} args.page + * @param {Int} args.perPage + * @return {Promise} filtered permissions list by given parameter + */ + permissions(_root, { module, action, userId, groupId, ...args }) { + const filter = generateSelector({ module, action, userId, groupId }); + return paginate(Permissions.find(filter), args); + }, + + permissionModules() { + const modules: IModulesMap[] = []; + + for (const m of _.pairs(modulesMap)) { + modules.push({ name: m[0], description: m[1] }); + } + + return modules; + }, + + permissionActions() { + const actions: IActionsMap[] = []; + + for (const a of _.pairs(actionsMap)) { + actions.push({ + name: a[0], + description: a[1].description, + module: a[1].module, + }); + } + + return actions; + }, + + /** + * Get all permissions count. We will use it in pager + * @param {String} args.module + * @param {String} args.action + * @param {String} args.userId + * @return {Promise} total count + */ + permissionsTotalCount(_root, args) { + const filter = generateSelector(args); + return Permissions.find(filter).countDocuments(); + }, +}; + +const usersGroupQueries = { + /** + * Users groups list + * @param {Object} args - Search params + * @return {Promise} sorted and filtered users objects + */ + usersGroups(_root, args: IListArgs) { + const users = paginate(UsersGroups.find({}), args); + return users.sort({ name: 1 }); + }, + + /** + * Get all groups list. We will use it in pager + * @return {Promise} total count + */ + usersGroupsTotalCount() { + return UsersGroups.find({}).countDocuments(); + }, +}; + +requireLogin(permissionQueries, 'permissionsTotalCount'); +requireLogin(usersGroupQueries, 'usersGroupsTotalCount'); + +checkPermission(permissionQueries, 'permissions', 'showPermissions', []); +checkPermission(permissionQueries, 'permissionModules', 'showPermissionModules', []); +checkPermission(permissionQueries, 'permissionActions', 'showPermissionActions', []); + +checkPermission(usersGroupQueries, 'usersGroups', 'showUsersGroups', []); + +export { permissionQueries, usersGroupQueries }; diff --git a/src/data/resolvers/queries/products.ts b/src/data/resolvers/queries/products.ts index 6e23b0462..885d69541 100644 --- a/src/data/resolvers/queries/products.ts +++ b/src/data/resolvers/queries/products.ts @@ -1,5 +1,5 @@ import { Products } from '../../../db/models'; -import { moduleRequireLogin } from '../../permissions'; +import { checkPermission, requireLogin } from '../../permissions'; import { paginate } from './utils'; const productQueries = { @@ -38,6 +38,7 @@ const productQueries = { }, }; -moduleRequireLogin(productQueries); +requireLogin(productQueries, 'productsTotalCount'); +checkPermission(productQueries, 'products', 'showProducts', []); export default productQueries; diff --git a/src/data/resolvers/queries/responseTemplates.ts b/src/data/resolvers/queries/responseTemplates.ts index b5deaf92f..d15295d04 100644 --- a/src/data/resolvers/queries/responseTemplates.ts +++ b/src/data/resolvers/queries/responseTemplates.ts @@ -1,5 +1,5 @@ import { ResponseTemplates } from '../../../db/models'; -import { moduleRequireLogin } from '../../permissions'; +import { checkPermission, requireLogin } from '../../permissions'; import { paginate } from './utils'; const responseTemplateQueries = { @@ -18,6 +18,7 @@ const responseTemplateQueries = { }, }; -moduleRequireLogin(responseTemplateQueries); +requireLogin(responseTemplateQueries, 'responseTemplatesTotalCount'); +checkPermission(responseTemplateQueries, 'responseTemplates', 'showResponseTemplates', []); export default responseTemplateQueries; diff --git a/src/data/resolvers/queries/scripts.ts b/src/data/resolvers/queries/scripts.ts index 1f37baf05..1a586e7a4 100644 --- a/src/data/resolvers/queries/scripts.ts +++ b/src/data/resolvers/queries/scripts.ts @@ -1,5 +1,5 @@ import { Scripts } from '../../../db/models'; -import { moduleRequireLogin } from '../../permissions'; +import { checkPermission, requireLogin } from '../../permissions'; import { paginate } from './utils'; const scriptQueries = { @@ -18,6 +18,8 @@ const scriptQueries = { }, }; -moduleRequireLogin(scriptQueries); +requireLogin(scriptQueries, 'scriptsTotalCount'); + +checkPermission(scriptQueries, 'scripts', 'showScripts', []); export default scriptQueries; diff --git a/src/data/resolvers/queries/segmentQueryBuilder.ts b/src/data/resolvers/queries/segmentQueryBuilder.ts index 87804907f..c9d3d1e4d 100644 --- a/src/data/resolvers/queries/segmentQueryBuilder.ts +++ b/src/data/resolvers/queries/segmentQueryBuilder.ts @@ -108,7 +108,7 @@ function convertConditionToQuery(condition: ICondition) { .toDate(), }; case 'is': - return { $exists: true }; + return { $exists: true, $ne: '' }; case 'ins': return { $exists: false }; } diff --git a/src/data/resolvers/queries/segments.ts b/src/data/resolvers/queries/segments.ts index 32900c135..fd018b9d4 100644 --- a/src/data/resolvers/queries/segments.ts +++ b/src/data/resolvers/queries/segments.ts @@ -1,5 +1,5 @@ import { Segments } from '../../../db/models'; -import { moduleRequireLogin } from '../../permissions'; +import { checkPermission, requireLogin } from '../../permissions'; const segmentQueries = { /** @@ -12,8 +12,8 @@ const segmentQueries = { /** * Only segment that has no sub segments */ - segmentsGetHeads() { - return Segments.find({ subOf: { $exists: false } }); + async segmentsGetHeads() { + return Segments.find({ $or: [{ subOf: { $exists: false } }, { subOf: '' }] }); }, /** @@ -24,6 +24,9 @@ const segmentQueries = { }, }; -moduleRequireLogin(segmentQueries); +requireLogin(segmentQueries, 'segmentsGetHeads'); +requireLogin(segmentQueries, 'segmentDetail'); + +checkPermission(segmentQueries, 'segments', 'showSegments', []); export default segmentQueries; diff --git a/src/data/resolvers/queries/tags.ts b/src/data/resolvers/queries/tags.ts index 862979f40..e58e769f1 100644 --- a/src/data/resolvers/queries/tags.ts +++ b/src/data/resolvers/queries/tags.ts @@ -1,5 +1,5 @@ import { Tags } from '../../../db/models'; -import { moduleRequireLogin } from '../../permissions'; +import { checkPermission, requireLogin } from '../../permissions'; const tagQueries = { /** @@ -17,6 +17,7 @@ const tagQueries = { }, }; -moduleRequireLogin(tagQueries); +requireLogin(tagQueries, 'tagDetail'); +checkPermission(tagQueries, 'tags', 'showTags', []); export default tagQueries; diff --git a/src/data/resolvers/queries/users.ts b/src/data/resolvers/queries/users.ts index 4b7f88aa5..67981ae85 100644 --- a/src/data/resolvers/queries/users.ts +++ b/src/data/resolvers/queries/users.ts @@ -1,27 +1,34 @@ import { Conversations, Users } from '../../../db/models'; import { IUserDocument } from '../../../db/models/definitions/users'; -import { requireLogin } from '../../permissions'; +import { checkPermission, requireLogin } from '../../permissions'; import { paginate } from './utils'; interface IListArgs { page?: number; perPage?: number; searchValue?: string; + isActive?: boolean; } const queryBuilder = async (params: IListArgs) => { - let selector: any = {}; + const { searchValue, isActive } = params; - if (params.searchValue) { + const selector: any = { + isActive, + }; + + if (searchValue) { const fields = [ { 'details.fullName': new RegExp(`.*${params.searchValue}.*`, 'i') }, { 'details.position': new RegExp(`.*${params.searchValue}.*`, 'i') }, ]; - selector = { $or: fields }; + selector.$or = fields; } - selector.isActive = { $ne: false }; + if (isActive === undefined || isActive === null) { + selector.isActive = true; + } return selector; }; @@ -32,15 +39,16 @@ const userQueries = { */ async users(_root, args: IListArgs) { const selector = await queryBuilder(args); - const users = paginate(Users.find(selector), args); - return users.sort({ username: 1 }); + const sort = { username: 1 }; + + return paginate(Users.find(selector).sort(sort), args); }, /** * Get one user */ userDetail(_root, { _id }: { _id: string }) { - return Users.findOne({ _id, isActive: { $ne: false } }); + return Users.findOne({ _id }); }, /** @@ -76,8 +84,9 @@ const userQueries = { }, }; -requireLogin(userQueries, 'users'); -requireLogin(userQueries, 'userDetail'); requireLogin(userQueries, 'usersTotalCount'); +requireLogin(userQueries, 'userDetail'); + +checkPermission(userQueries, 'users', 'showUsers', []); export default userQueries; diff --git a/src/data/resolvers/queries/utils.ts b/src/data/resolvers/queries/utils.ts index d35cfe741..dede6f95b 100644 --- a/src/data/resolvers/queries/utils.ts +++ b/src/data/resolvers/queries/utils.ts @@ -10,3 +10,24 @@ export const paginate = (collection, params: { page?: number; perPage?: number } return collection.limit(_limit).skip((_page - 1) * _limit); }; + +export const dealsCommonFilter = (filter, { search }: { search?: string }) => { + return { + ...filter, + $or: [{ name: new RegExp(`.*${search || ''}.*`, 'i') }, { description: new RegExp(`.*${search || ''}.*`, 'i') }], + }; +}; + +/* + * Converts given value to date or if value in valid date + * then returns default value + */ +export const fixDate = (value, defaultValue = new Date()): Date => { + const date = new Date(value); + + if (!isNaN(date.getTime())) { + return date; + } + + return defaultValue; +}; diff --git a/src/data/resolvers/subscriptions/activityLogs.ts b/src/data/resolvers/subscriptions/activityLogs.ts new file mode 100644 index 000000000..11b28b3aa --- /dev/null +++ b/src/data/resolvers/subscriptions/activityLogs.ts @@ -0,0 +1,10 @@ +import pubsub from './pubsub'; + +export default { + /* + * Listen for activity log connection + */ + activityLogsChanged: { + subscribe: () => pubsub.asyncIterator('activityLogsChanged'), + }, +}; diff --git a/src/data/resolvers/subscriptions/conversations.ts b/src/data/resolvers/subscriptions/conversations.ts index a6d9b5399..9a064a644 100644 --- a/src/data/resolvers/subscriptions/conversations.ts +++ b/src/data/resolvers/subscriptions/conversations.ts @@ -1,4 +1,5 @@ import { withFilter } from 'apollo-server-express'; +import { Channels, Conversations, Integrations } from '../../../db/models'; import pubsub from './pubsub'; export default { @@ -32,7 +33,30 @@ export default { * Admin is listening for this subscription to show unread notification */ conversationClientMessageInserted: { - subscribe: () => pubsub.asyncIterator('conversationClientMessageInserted'), + subscribe: withFilter( + () => pubsub.asyncIterator('conversationClientMessageInserted'), + async (payload, variables) => { + const message = payload.conversationClientMessageInserted; + const conversation = await Conversations.findOne({ _id: message.conversationId }, { integrationId: 1 }); + + if (!conversation) { + return false; + } + + const integration = await Integrations.findOne({ _id: conversation.integrationId }, { _id: 1 }); + + if (!integration) { + return false; + } + + const availableChannelsCount = await Channels.count({ + integrationIds: { $in: [integration._id] }, + memberIds: { $in: [variables.userId] }, + }); + + return availableChannelsCount > 0; + }, + ), }, /* diff --git a/src/data/resolvers/subscriptions/index.ts b/src/data/resolvers/subscriptions/index.ts index 5774f4a1f..b2d78dc31 100644 --- a/src/data/resolvers/subscriptions/index.ts +++ b/src/data/resolvers/subscriptions/index.ts @@ -1,3 +1,4 @@ +import activityLogs from './activityLogs'; import conversations from './conversations'; import customers from './customers'; import pubsub from './pubsub'; @@ -7,6 +8,7 @@ export { pubsub }; let subscriptions: any = { ...conversations, ...customers, + ...activityLogs, }; const { NODE_ENV } = process.env; diff --git a/src/data/resolvers/user.ts b/src/data/resolvers/user.ts new file mode 100644 index 000000000..d4a3a7624 --- /dev/null +++ b/src/data/resolvers/user.ts @@ -0,0 +1,16 @@ +import { IUserDocument } from '../../db/models/definitions/users'; +import { userAllowedActions } from '../permissions/utils'; + +export default { + status(user: IUserDocument) { + if (user.registrationToken) { + return 'Pending Invitation'; + } + + return 'Verified'; + }, + + async permissionActions(user: IUserDocument) { + return userAllowedActions(user); + }, +}; diff --git a/src/data/schema/accounts.ts b/src/data/schema/accounts.ts index fc0f06f8f..209c6c016 100644 --- a/src/data/schema/accounts.ts +++ b/src/data/schema/accounts.ts @@ -5,11 +5,6 @@ export const types = ` name: String id: String } - - input TwitterIntegrationAuthParams { - oauth_token: String! - oauth_verifier: String! - } `; export const queries = ` @@ -18,6 +13,4 @@ export const queries = ` export const mutations = ` accountsRemove(_id: String!): JSON - accountsAddTwitter(queryParams: TwitterIntegrationAuthParams): Account - accountsAddGmail(code: String!): Account `; diff --git a/src/data/schema/activityLog.ts b/src/data/schema/activityLog.ts index 62fac2195..0ae656895 100644 --- a/src/data/schema/activityLog.ts +++ b/src/data/schema/activityLog.ts @@ -1,14 +1,4 @@ export const types = ` - type ActivityLogYearMonthDoc { - year: Int - month: Int - } - - type ActivityLogForMonth { - date: ActivityLogYearMonthDoc! - list: [ActivityLog]! - } - type ActivityLogPerformerDetails { avatar: String fullName: String @@ -32,15 +22,5 @@ export const types = ` `; export const queries = ` - activityLogsCustomer(_id: String!): [ActivityLogForMonth] - activityLogsCompany(_id: String!): [ActivityLogForMonth] - activityLogsUser(_id: String!): [ActivityLogForMonth] - activityLogsDeal(_id: String!): [ActivityLogForMonth] -`; - -export const mutations = ` - activityLogsAddConversationLog(customerId: String!, conversationId: String!): ActivityLog - activityLogsAddCustomerLog(_id: String!): ActivityLog - activityLogsAddCompanyLog(_id: String!): ActivityLog - activityLogsAddDealLog(_id: String!): ActivityLog + activityLogs(contentType: String!, contentId: String!, activityType: String, limit: Int): [ActivityLog] `; diff --git a/src/data/schema/brand.ts b/src/data/schema/brand.ts index 2626e1701..e87b0fb78 100644 --- a/src/data/schema/brand.ts +++ b/src/data/schema/brand.ts @@ -20,8 +20,8 @@ export const queries = ` `; export const mutations = ` - brandsAdd(name: String, description: String): Brand - brandsEdit(_id: String!, name: String, description: String): Brand + brandsAdd(name: String!, description: String): Brand + brandsEdit(_id: String!, name: String!, description: String): Brand brandsRemove(_id: String!): JSON brandsConfigEmail(_id: String!, emailConfig: JSON): Brand brandsManageIntegrations(_id: String!, integrationIds: [String]!): [Integration] diff --git a/src/data/schema/customer.ts b/src/data/schema/customer.ts index b50eaa21e..85dec17f4 100644 --- a/src/data/schema/customer.ts +++ b/src/data/schema/customer.ts @@ -81,7 +81,7 @@ const queryParams = ` endDate: String, lifecycleState: String, leadStatus: String, - sortField: String + sortField: String, sortDirection: Int `; diff --git a/src/data/schema/deal.ts b/src/data/schema/deal.ts index 4383958a7..c8a83da8f 100644 --- a/src/data/schema/deal.ts +++ b/src/data/schema/deal.ts @@ -25,13 +25,14 @@ export const types = ` pipelineId: String! amount: JSON deals: [Deal] + dealsTotalCount: Int ${commonTypes} } type Deal { _id: String! name: String! - stageId: String! + stageId: String pipeline: DealPipeline boardId: String companyIds: [String] @@ -50,6 +51,23 @@ export const types = ` stage: DealStage ${commonTypes} } + + type DealTotalAmount { + _id: String + currency: String + amount: Float + } + + type DealTotalAmounts { + _id: String + dealCount: Int + dealAmounts: [DealTotalAmount] + } + + input DealDate { + month: Int + year: Int + } `; export const queries = ` @@ -58,15 +76,24 @@ export const queries = ` dealBoardDetail(_id: String!): DealBoard dealPipelines(boardId: String!): [DealPipeline] dealPipelineDetail(_id: String!): DealPipeline - dealStages(pipelineId: String!): [DealStage] + dealStages(pipelineId: String!, search: String): [DealStage] dealStageDetail(_id: String!): DealStage - deals(stageId: String, customerId: String, companyId: String): [Deal] dealDetail(_id: String!): Deal + deals( + pipelineId: String, + stageId: String, + customerId: String, + companyId: String, + date: DealDate, + skip: Int + search: String, + ): [Deal] + dealsTotalAmounts(date: DealDate pipelineId: String): DealTotalAmounts `; const dealMutationParams = ` name: String!, - stageId: String!, + stageId: String, assignedUserIds: [String], companyIds: [String], customerIds: [String], diff --git a/src/data/schema/index.ts b/src/data/schema/index.ts index 06926c0b6..5d50a3748 100755 --- a/src/data/schema/index.ts +++ b/src/data/schema/index.ts @@ -8,6 +8,8 @@ import { mutations as ChannelMutations, queries as ChannelQueries, types as Chan import { mutations as BrandMutations, queries as BrandQueries, types as BrandTypes } from './brand'; +import { mutations as PermissionMutations, queries as PermissionQueries, types as PermissionTypes } from './permission'; + import { mutations as IntegrationMutations, queries as IntegrationQueries, @@ -73,11 +75,7 @@ import { types as ConversationTypes, } from './conversation'; -import { - mutations as ActivityLogMutations, - queries as ActivityLogQueries, - types as ActivityLogTypes, -} from './activityLog'; +import { queries as ActivityLogQueries, types as ActivityLogTypes } from './activityLog'; import { mutations as DealMutations, queries as DealQueries, types as DealTypes } from './deal'; @@ -130,6 +128,7 @@ export const types = ` ${ImportHistoryTypes} ${MessengerAppTypes} ${AccountTypes} + ${PermissionTypes} `; export const queries = ` @@ -161,6 +160,7 @@ export const queries = ` ${ImportHistoryQueries} ${MessengerAppQueries} ${AccountQueries} + ${PermissionQueries} } `; @@ -184,7 +184,6 @@ export const mutations = ` ${IntegrationMutations} ${KnowledgeBaseMutations} ${NotificationMutations} - ${ActivityLogMutations} ${DealMutations} ${ProductMutations} ${ConfigMutations} @@ -192,6 +191,7 @@ export const mutations = ` ${ImportHistoryMutations} ${MessengerAppMutations} ${AccountMutations} + ${PermissionMutations} } `; @@ -199,9 +199,10 @@ export const subscriptions = ` type Subscription { conversationChanged(_id: String!): ConversationChangedResponse conversationMessageInserted(_id: String!): ConversationMessage - conversationClientMessageInserted: ConversationMessage + conversationClientMessageInserted(userId: String!): ConversationMessage conversationAdminMessageInserted(customerId: String!): ConversationMessage customerConnectionChanged(_id: String): CustomerConnectionChangedResponse + activityLogsChanged: Boolean } `; diff --git a/src/data/schema/insight.ts b/src/data/schema/insight.ts index e340d3d25..e43e65ed3 100644 --- a/src/data/schema/insight.ts +++ b/src/data/schema/insight.ts @@ -1,16 +1,25 @@ export const types = ``; const params = ` - integrationType: String, - brandId: String, + integrationIds: String, + brandIds: String, startDate: String, endDate: String `; +const dealParams = ` + pipelineIds: String, + boardId: String, + startDate: String, + endDate: String + status: String +`; + export const queries = ` insights(${params}): JSON - insightsPunchCard(type: String, integrationType: String, brandId: String, endDate: String): JSON - insightsMain(type: String, ${params}): JSON + insightsPunchCard(type: String, ${params}): JSON + insightsTrend(type: String, ${params}): JSON + insightsSummaryData(type: String, ${params}): JSON insightsConversation(${params}): JSON insightsFirstResponse(${params}): JSON insightsResponseClose(${params}): JSON @@ -18,4 +27,8 @@ export const queries = ` insightActivityReportExport(${params}): JSON insightFirstResponseReportExport(type: String, userId: String, ${params}): JSON insightTagReportExport(${params}): JSON + + dealInsightsMain(${dealParams}): JSON + dealInsightsPunchCard(${dealParams}): JSON + dealInsightsByTeamMember(${dealParams}): JSON `; diff --git a/src/data/schema/integration.ts b/src/data/schema/integration.ts index a928aa630..1862b0f76 100644 --- a/src/data/schema/integration.ts +++ b/src/data/schema/integration.ts @@ -101,9 +101,6 @@ export const queries = ` integrationDetail(_id: String!): Integration integrationsTotalCount: integrationsTotalCount - integrationGetTwitterAuthUrl: String - integrationGetGoogleAuthUrl(service: String): String - integrationGetGoogleAccessToken(code: String): JSON integrationFacebookAppsList: [JSON] integrationFacebookPagesList(accountId: String): [JSON] `; diff --git a/src/data/schema/messengerApp.ts b/src/data/schema/messengerApp.ts index b898266d2..643dbb7ad 100644 --- a/src/data/schema/messengerApp.ts +++ b/src/data/schema/messengerApp.ts @@ -5,17 +5,19 @@ export const types = ` name: String! showInInbox: Boolean credentials: JSON + accountId: String } `; export const queries = ` messengerApps(kind: String): [MessengerApp] + messengerAppsCount(kind: String): Int `; export const mutations = ` - messengerAppsAddGoogleMeet(name: String!, credentials: JSON): MessengerApp + messengerAppsAddGoogleMeet(name: String!, accountId: String!): MessengerApp messengerAppsAddKnowledgebase(name: String!, integrationId: String!, topicId: String!): MessengerApp messengerAppsAddLead(name: String!, integrationId: String!, formId: String!): MessengerApp messengerAppsRemove(_id: String!): JSON - messengerAppsExecuteGoogleMeet(_id: String!, conversationId: String!): String + messengerAppsExecuteGoogleMeet(_id: String!, conversationId: String!): MessengerApp `; diff --git a/src/data/schema/permission.ts b/src/data/schema/permission.ts new file mode 100644 index 000000000..21eabe79f --- /dev/null +++ b/src/data/schema/permission.ts @@ -0,0 +1,63 @@ +export const types = ` + type Permission { + _id: String! + module: String + action: String + userId: String + groupId: String + requiredActions: [String] + allowed: Boolean + user: User + group: UsersGroup + } + type PermissionModule { + name: String + description: String + } + type PermissionAction { + name: String + description: String + module: String + } + + type UsersGroup { + _id: String! + name: String! + description: String! + } +`; + +const commonParams = ` + module: String, + action: String, + userId: String, + groupId: String +`; + +const commonUserGroupParams = ` + name: String!, + description: String, +`; + +export const queries = ` + permissions(${commonParams}, page: Int, perPage: Int): [Permission] + permissionModules: [PermissionModule] + permissionActions: [PermissionAction] + permissionsTotalCount(${commonParams}): Int + usersGroups(page: Int, perPage: Int): [UsersGroup] + usersGroupsTotalCount: Int +`; + +export const mutations = ` + permissionsAdd( + module: String!, + actions: [String!]!, + userIds: [String!], + groupIds: [String!], + allowed: Boolean + ): [Permission] + permissionsRemove(ids: [String]!): JSON + usersGroupsAdd(${commonUserGroupParams}): UsersGroup + usersGroupsEdit(_id: String!, ${commonUserGroupParams}): UsersGroup + usersGroupsRemove(_id: String!): JSON +`; diff --git a/src/data/schema/user.ts b/src/data/schema/user.ts index f0e5b43d8..f243f93e8 100644 --- a/src/data/schema/user.ts +++ b/src/data/schema/user.ts @@ -22,6 +22,11 @@ export const types = ` signature: String } + input InvitationEntry { + email: String + groupId: String + } + type UserDetailsType { avatar: String fullName: String @@ -44,11 +49,17 @@ export const types = ` _id: String! username: String email: String - role: String + isActive: Boolean details: UserDetailsType links: UserLinksType + status: String + hasSeenOnBoard: Boolean emailSignatures: JSON getNotificationByEmail: Boolean + groupIds: [String] + + isOwner: Boolean + permissionActions: JSON } type UserConversationListResponse { @@ -57,33 +68,33 @@ export const types = ` } `; +const commonParams = ` + username: String!, + email: String!, + details: UserDetails, + links: UserLinks, + channelIds: [String], + groupIds: [String] +`; + +const commonSelector = ` + searchValue: String, + isActive: Boolean +`; + export const queries = ` - users(page: Int, perPage: Int, searchValue: String): [User] + users(page: Int, perPage: Int, ${commonSelector}): [User] userDetail(_id: String): User - usersTotalCount: Int + usersTotalCount(${commonSelector}): Int currentUser: User userConversations(_id: String, perPage: Int): UserConversationListResponse `; -const commonParams = ` - username: String!, - email: String!, - role: String! - details: UserDetails, - links: UserLinks, - channelIds: [String], - password: String!, - passwordConfirmation: String! -`; - export const mutations = ` login(email: String!, password: String!): String logout: String forgotPassword(email: String!): String! resetPassword(token: String!, newPassword: String!): JSON - usersAdd(${commonParams}): User - usersEdit(_id: String!, ${commonParams}): User - usersEditProfile( username: String!, email: String!, @@ -91,10 +102,12 @@ export const mutations = ` links: UserLinks password: String! ): User - + usersEdit(_id: String!, ${commonParams}): User usersChangePassword(currentPassword: String!, newPassword: String!): User - usersRemove(_id: String!): JSON - + usersSetActiveStatus(_id: String!): User + usersInvite(entries: [InvitationEntry]): Boolean + usersConfirmInvitation(token: String, password: String, passwordConfirmation: String, fullName: String, username: String): User + usersSeenOnBoard: User usersConfigEmailSignatures(signatures: [EmailSignature]): User usersConfigGetNotificationByEmail(isAllowed: Boolean): User -`; + `; diff --git a/src/data/utils.ts b/src/data/utils.ts index 9da6de1ce..f3039b338 100644 --- a/src/data/utils.ts +++ b/src/data/utils.ts @@ -8,6 +8,7 @@ import * as requestify from 'requestify'; import * as xlsxPopulate from 'xlsx-populate'; import { Companies, Customers, Notifications, Users } from '../db/models'; import { IUserDocument } from '../db/models/definitions/users'; +import { can } from './permissions/utils'; /* * Check that given file is not harmful @@ -52,12 +53,10 @@ export const checkFile = async file => { * Save binary data to amazon s3 */ export const uploadFile = async (file: { name: string; path: string }): Promise => { - const { AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY, AWS_BUCKET = '', AWS_PREFIX = '' } = process.env; - - // check credentials - if (!(AWS_ACCESS_KEY_ID || AWS_SECRET_ACCESS_KEY || AWS_BUCKET)) { - throw new Error('Security credentials are not configured'); - } + const AWS_ACCESS_KEY_ID = getEnv({ name: 'AWS_ACCESS_KEY_ID' }); + const AWS_SECRET_ACCESS_KEY = getEnv({ name: 'AWS_SECRET_ACCESS_KEY' }); + const AWS_BUCKET = getEnv({ name: 'AWS_BUCKET' }); + const AWS_PREFIX = getEnv({ name: 'AWS_PREFIX', defaultValue: '' }); // initialize s3 const s3 = new AWS.S3({ @@ -117,14 +116,10 @@ const applyTemplate = async (data: any, templateName: string) => { * Create default or ses transporter */ export const createTransporter = ({ ses }) => { - const { MAIL_SERVICE, MAIL_PORT, MAIL_USER, MAIL_PASS } = process.env; - if (ses) { - const { AWS_SES_ACCESS_KEY_ID, AWS_SES_SECRET_ACCESS_KEY, AWS_REGION } = process.env; - - if (!AWS_SES_ACCESS_KEY_ID || !AWS_SES_SECRET_ACCESS_KEY) { - throw new Error('Invalid SES configuration'); - } + const AWS_SES_ACCESS_KEY_ID = getEnv({ name: 'AWS_SES_ACCESS_KEY_ID' }); + const AWS_SES_SECRET_ACCESS_KEY = getEnv({ name: 'AWS_SES_SECRET_ACCESS_KEY' }); + const AWS_REGION = getEnv({ name: 'AWS_REGION' }); AWS.config.update({ region: AWS_REGION, @@ -137,12 +132,15 @@ export const createTransporter = ({ ses }) => { }); } - if (!MAIL_SERVICE || !MAIL_PORT || !MAIL_USER || !MAIL_PASS) { - throw new Error('Invalid mail service configuration'); - } + const MAIL_SERVICE = getEnv({ name: 'MAIL_SERVICE' }); + const MAIL_PORT = getEnv({ name: 'MAIL_PORT' }); + const MAIL_USER = getEnv({ name: 'MAIL_USER' }); + const MAIL_PASS = getEnv({ name: 'MAIL_PASS' }); + const MAIL_HOST = getEnv({ name: 'MAIL_HOST' }); return nodemailer.createTransport({ service: MAIL_SERVICE, + host: MAIL_HOST, port: MAIL_PORT, auth: { user: MAIL_USER, @@ -164,10 +162,10 @@ export const sendEmail = async ({ fromEmail?: string; title?: string; template?: { name?: string; data?: any; isCustom?: boolean }; - subject?: string; - to?: string; }) => { - const { NODE_ENV, DEFAULT_EMAIL_SERVICE, COMPANY_EMAIL_FROM } = process.env; + const NODE_ENV = getEnv({ name: 'NODE_ENV' }); + const DEFAULT_EMAIL_SERVICE = getEnv({ name: 'DEFAULT_EMAIL_SERVICE', defaultValue: '' }); + const COMPANY_EMAIL_FROM = getEnv({ name: 'COMPANY_EMAIL_FROM' }); // do not send email it is running in test mode if (NODE_ENV === 'test') { @@ -270,7 +268,11 @@ export const sendNotification = async ({ * and imports customers to the database */ export const importXlsFile = async (file: any, type: string, { user }: { user: IUserDocument }) => { - return new Promise((resolve, reject) => { + return new Promise(async (resolve, reject) => { + if (!(await can('importXlsFile', user._id))) { + return reject('Permission denied!'); + } + const readStream = fs.createReadStream(file.path); // Directory to save file @@ -352,7 +354,7 @@ export const createXlsFile = async () => { export const generateXlsx = async (workbook: any, name: string): Promise => { // Url to download xls file const url = `xlsTemplateOutputs/${name}.xlsx`; - const { DOMAIN } = process.env; + const DOMAIN = getEnv({ name: 'DOMAIN' }); // Saving xls workbook to the directory await workbook.toFileAsync(`${__dirname}/../private/${url}`); @@ -388,6 +390,39 @@ export const validateEmail = async email => { return true; }; +export const authCookieOptions = () => { + const oneDay = 1 * 24 * 3600 * 1000; // 1 day + + const cookieOptions = { + httpOnly: true, + expires: new Date(Date.now() + oneDay), + maxAge: oneDay, + secure: false, + }; + + const HTTPS = getEnv({ name: 'HTTPS', defaultValue: 'false' }); + + if (HTTPS === 'true') { + cookieOptions.secure = true; + } + + return cookieOptions; +}; + +export const getEnv = ({ name, defaultValue }: { name: string; defaultValue?: string }): string => { + const value = process.env[name]; + + if (!value && typeof defaultValue !== 'undefined') { + return defaultValue; + } + + if (!value) { + console.log(`Missing environment variable configuration for ${name}`); + } + + return value || ''; +}; + export default { sendEmail, validateEmail, diff --git a/src/db/connection.ts b/src/db/connection.ts index 47fda0a35..d72397027 100644 --- a/src/db/connection.ts +++ b/src/db/connection.ts @@ -4,11 +4,13 @@ import { makeExecutableSchema } from 'graphql-tools'; import mongoose = require('mongoose'); import resolvers from '../data/resolvers'; import typeDefs from '../data/schema'; +import { getEnv } from '../data/utils'; import { userFactory } from './factories'; dotenv.config(); -const { NODE_ENV, MONGO_URL = '' } = process.env; +const NODE_ENV = getEnv({ name: 'NODE_ENV' }); +const MONGO_URL = getEnv({ name: 'MONGO_URL', defaultValue: '' }); mongoose.set('useFindAndModify', false); @@ -30,7 +32,7 @@ mongoose.connection export function connect() { return mongoose.connect( MONGO_URL, - { useNewUrlParser: true, useCreateIndex: true }, + { useNewUrlParser: true, useCreateIndex: true, poolSize: 100, autoReconnect: true }, ); } diff --git a/src/db/factories.ts b/src/db/factories.ts index c63b94ff5..b92e3a74a 100644 --- a/src/db/factories.ts +++ b/src/db/factories.ts @@ -3,14 +3,14 @@ import * as faker from 'faker'; import * as Random from 'meteor-random'; import { ACTIVITY_ACTIONS, + ACTIVITY_CONTENT_TYPES, ACTIVITY_PERFORMER_TYPES, ACTIVITY_TYPES, - COC_CONTENT_TYPES, FIELDS_GROUPS_CONTENT_TYPES, NOTIFICATION_TYPES, PRODUCT_TYPES, } from '../data/constants'; -import { IActionPerformer, IActivity, ICoc } from '../db/models/definitions/activityLogs'; +import { IActionPerformer, IActivity, IContentType } from '../db/models/definitions/activityLogs'; import { Accounts, ActivityLogs, @@ -39,15 +39,47 @@ import { MessengerApps, NotificationConfigurations, Notifications, + Permissions, Products, ResponseTemplates, Segments, Tags, Users, + UsersGroups, } from './models'; +import { STATUSES } from './models/definitions/constants'; +import { IEmail, IMessenger } from './models/definitions/engages'; import { IMessengerAppCrendentials } from './models/definitions/messengerApps'; import { IUserDocument } from './models/definitions/users'; +interface IActivityLogFactoryInput { + performer?: IActionPerformer; + performedBy?: IActionPerformer; + activity?: IActivity; + contentType?: IContentType; +} + +export const activityLogFactory = (params: IActivityLogFactoryInput) => { + const doc = { + activity: { + type: ACTIVITY_TYPES.INTERNAL_NOTE, + action: ACTIVITY_ACTIONS.CREATE, + id: faker.random.uuid(), + content: faker.random.word(), + }, + performer: { + type: ACTIVITY_PERFORMER_TYPES.USER, + id: faker.random.uuid(), + }, + contentType: { + type: ACTIVITY_CONTENT_TYPES.CUSTOMER, + id: faker.random.uuid(), + }, + }; + + return ActivityLogs.createDoc({ ...doc, ...params }); +}; + interface IUserFactoryInput { username?: string; fullName?: string; @@ -60,13 +92,15 @@ interface IUserFactoryInput { github?: string; website?: string; email?: string; - role?: string; password?: string; isOwner?: boolean; isActive?: boolean; + groupIds?: string[]; + registrationToken?: string; + registrationTokenExpires?: Date; } -export const userFactory = (params: IUserFactoryInput) => { +export const userFactory = (params: IUserFactoryInput = {}) => { const user = new Users({ username: params.username || faker.internet.userName(), details: { @@ -74,6 +108,8 @@ export const userFactory = (params: IUserFactoryInput) => { avatar: params.avatar || faker.image.imageUrl(), position: params.position || 'admin', }, + registrationToken: params.registrationToken || faker.random.word(), + registrationTokenExpires: params.registrationTokenExpires || Date.now(), links: { twitter: params.twitter || faker.random.word(), facebook: params.facebook || faker.random.word(), @@ -83,10 +119,10 @@ export const userFactory = (params: IUserFactoryInput) => { website: params.website || faker.random.word(), }, email: params.email || faker.internet.email(), - role: params.role || 'contributor', password: params.password || '$2a$10$qfBFBmWmUjeRcR.nBBfgDO/BEbxgoai5qQhyjsrDUMiZC6dG7sg1q', - isOwner: params.isOwner || false, + isOwner: typeof params.isOwner !== 'undefined' ? params.isOwner : true, isActive: params.isActive || true, + groupIds: params.groupIds || [], }); return user.save(); @@ -116,22 +152,24 @@ interface IEngageMessageFactoryInput { isLive?: boolean; isDraft?: boolean; customerIds?: string[]; + method?: string; + messenger?: IMessenger; + title?: string; + email?: IEmail; } export const engageMessageFactory = (params: IEngageMessageFactoryInput = {}) => { const engageMessage = new EngageMessages({ kind: params.kind || 'manual', - method: 'messenger', - title: faker.random.word(), + method: params.method || 'messenger', + title: params.title || faker.random.word(), fromUserId: params.userId || faker.random.uuid(), segmentId: params.segmentId || faker.random.word(), tagIds: params.tagIds || [], isLive: params.isLive || false, isDraft: params.isDraft || false, - messenger: { - brandId: faker.random.word(), - content: faker.random.word(), - }, + messenger: params.messenger, + email: params.email, }); return engageMessage.save(); @@ -216,7 +254,7 @@ export const segmentFactory = (params: ISegmentFactoryInput = {}) => { ]; const segment = new Segments({ - contentType: params.contentType || COC_CONTENT_TYPES.CUSTOMER, + contentType: params.contentType || ACTIVITY_CONTENT_TYPES.CUSTOMER, name: faker.random.word(), description: params.description || faker.random.word(), subOf: params.subOf, @@ -236,7 +274,7 @@ interface IInternalNoteFactoryInput { export const internalNoteFactory = (params: IInternalNoteFactoryInput) => { const internalNote = new InternalNotes({ - contentType: params.contentType || COC_CONTENT_TYPES.CUSTOMER, + contentType: params.contentType || ACTIVITY_CONTENT_TYPES.CUSTOMER, contentTypeId: params.contentTypeId || faker.random.uuid().toString(), content: params.content || faker.random.word(), }); @@ -253,6 +291,7 @@ interface ICompanyFactoryInput { tagIds?: string[]; plan?: string; leadStatus?: string; + status?: string; lifecycleState?: string; createdAt?: Date; modifiedAt?: Date; @@ -272,6 +311,7 @@ export const companyFactory = (params: ICompanyFactoryInput = {}) => { tagIds: params.tagIds || [faker.random.number()], plan: params.plan || faker.random.word(), leadStatus: params.leadStatus || 'open', + status: params.status || STATUSES.ACTIVE, lifecycleState: params.lifecycleState || 'lead', createdAt: params.createdAt || new Date(), modifiedAt: params.modifiedAt || new Date(), @@ -294,6 +334,7 @@ interface ICustomerFactoryInput { phones?: string[]; doNotDisturb?: string; leadStatus?: string; + status?: string; lifecycleState?: string; messengerData?: any; customFieldsData?: any; @@ -301,6 +342,7 @@ interface ICustomerFactoryInput { tagIds?: string[] | string; twitterData?: any; ownerId?: string; + hasValidEmail?: boolean; } export const customerFactory = (params: ICustomerFactoryInput = {}) => { @@ -313,6 +355,7 @@ export const customerFactory = (params: ICustomerFactoryInput = {}) => { emails: params.emails || [faker.internet.email()], phones: params.phones || [faker.phone.phoneNumber()], leadStatus: params.leadStatus || 'open', + status: params.status || STATUSES.ACTIVE, lifecycleState: params.lifecycleState || 'lead', messengerData: params.messengerData || {}, customFieldsData: params.customFieldsData || {}, @@ -320,6 +363,7 @@ export const customerFactory = (params: ICustomerFactoryInput = {}) => { tagIds: params.tagIds || [faker.random.number(), faker.random.number()], twitterData: params.twitterData || { id: faker.random.number() }, ownerId: params.ownerId || Random.id(), + hasValidEmail: params.hasValidEmail || null, }); return customer.save(); @@ -376,18 +420,23 @@ interface IConversationFactoryInput { participatedUserIds?: string[]; facebookData?: any; twitterData?: any; + gmailData?: any; status?: string; closedAt?: dateType; closedUserId?: string; readUserIds?: string[]; tagIds?: string[]; + messageCount?: number; + number?: number; + firstRespondedUserId?: string; + firstRespondedDate?: dateType; } export const conversationFactory = (params: IConversationFactoryInput = {}) => { const doc = { - content: faker.lorem.sentence(), - customerId: Random.id(), - integrationId: Random.id(), + content: params.content || faker.lorem.sentence(), + customerId: params.customerId || Random.id(), + integrationId: params.integrationId || Random.id(), }; return Conversations.createConversation({ @@ -402,11 +451,12 @@ interface IConversationMessageFactoryInput { mentionedUserIds?: string[]; internal?: boolean; customerId?: string; - userId?: string; + userId?: any; isCustomerRead?: boolean; engageData?: any; formWidgetData?: any; facebookData?: any; + gmailData?: any; } export const conversationMessageFactory = async (params: IConversationMessageFactoryInput) => { @@ -417,6 +467,11 @@ export const conversationMessageFactory = async (params: IConversationMessageFac conversationId = conversation._id; } + let userId = params.userId; + if (params.userId === undefined) { + userId = Random.id(); + } + return ConversationMessages.createMessage({ content: params.content || faker.random.word(), attachments: {}, @@ -424,11 +479,12 @@ export const conversationMessageFactory = async (params: IConversationMessageFac conversationId, internal: params.internal || true, customerId: params.customerId || Random.id(), - userId: params.userId || Random.id(), + userId, isCustomerRead: params.isCustomerRead || true, engageData: params.engageData || {}, formWidgetData: params.formWidgetData || {}, facebookData: params.facebookData || {}, + gmailData: params.gmailData || {}, }); }; @@ -612,34 +668,6 @@ export const knowledgeBaseArticleFactory = async (params: IKnowledgeBaseArticleC return KnowledgeBaseArticles.createDoc({ ...doc, ...params }, params.userId || faker.random.word()); }; -interface IActivityLogFactoryInput { - performer?: IActionPerformer; - performedBy?: IActionPerformer; - activity?: IActivity; - coc?: ICoc; -} - -export const activityLogFactory = (params: IActivityLogFactoryInput) => { - const doc = { - activity: { - type: ACTIVITY_TYPES.INTERNAL_NOTE, - action: ACTIVITY_ACTIONS.CREATE, - id: faker.random.number(), - content: faker.random.word(), - }, - performer: { - type: ACTIVITY_PERFORMER_TYPES.USER, - id: faker.random.number(), - }, - coc: { - type: COC_CONTENT_TYPES.CUSTOMER, - id: faker.random.number(), - }, - }; - - return ActivityLogs.createDoc({ ...doc, ...params }); -}; - export const dealBoardFactory = () => { const board = new DealBoards({ name: faker.random.word(), @@ -811,3 +839,34 @@ export const accountFactory = async (params: IAccountFactoryInput) => { return Accounts.create(doc); }; + +interface IPermissionParams { + module?: string; + action?: string; + allowed?: boolean; + userId?: string; + requiredActions?: string[]; + groupId?: string; +} + +export const permissionFactory = async (params: IPermissionParams = {}) => { + const permission = new Permissions({ + module: faker.random.word(), + action: params.action || faker.random.word(), + allowed: params.allowed || false, + userId: params.userId || Random.id(), + requiredActions: params.requiredActions || [], + groupId: params.groupId || faker.random.word(), + }); + + return permission.save(); +}; + +export const usersGroupFactory = () => { + const usersGroup = new UsersGroups({ + name: faker.random.word(), + description: faker.random.word(), + }); + + return usersGroup.save(); +}; diff --git a/src/db/listener.ts b/src/db/listener.ts deleted file mode 100644 index e5b9d76ec..000000000 --- a/src/db/listener.ts +++ /dev/null @@ -1,47 +0,0 @@ -import { publishClientMessage, publishMessage } from '../data/resolvers/mutations/conversations'; -import { connect } from './connection'; -import { ActivityLogs, Companies, ConversationMessages, Conversations, Customers } from './models'; - -export const listenChangeConversation = async () => { - try { - await connect(); - - ConversationMessages.watch().on('change', data => { - const message = data.fullDocument; - - if (data.operationType === 'insert' && message) { - publishClientMessage(message); - publishMessage(message); - } - }); - - Conversations.watch().on('change', async data => { - const conversation = data.fullDocument; - - if (data.operationType === 'insert' && conversation) { - const customer = await Customers.findOne({ _id: conversation.customerId }); - if (customer) { - ActivityLogs.createConversationLog(conversation, customer); - } - } - }); - - Customers.watch().on('change', data => { - const customer = data.fullDocument; - - if (data.operationType === 'insert' && customer) { - ActivityLogs.createCustomerRegistrationLog(customer); - } - }); - - Companies.watch().on('change', data => { - const company = data.fullDocument; - - if (data.operationType === 'insert' && company) { - ActivityLogs.createCompanyRegistrationLog(company); - } - }); - } catch (error) { - console.log(error); - } -}; diff --git a/src/db/listeners/Companies.ts b/src/db/listeners/Companies.ts new file mode 100644 index 000000000..c5231cd19 --- /dev/null +++ b/src/db/listeners/Companies.ts @@ -0,0 +1,53 @@ +import { ActivityLogs, Companies } from '../models'; +import { + ACTIVITY_ACTIONS, + ACTIVITY_CONTENT_TYPES, + ACTIVITY_PERFORMER_TYPES, + ACTIVITY_TYPES, +} from '../models/definitions/constants'; + +const companyListeners = () => + Companies.watch().on('change', async data => { + const company = data.fullDocument; + + if (data.operationType === 'insert' && company) { + let performer; + + if (company.ownerId) { + performer = { + type: ACTIVITY_PERFORMER_TYPES.USER, + id: company.ownerId, + }; + } + + let action = ACTIVITY_ACTIONS.CREATE; + let content = company.primaryName || ''; + + if (company.mergedIds && company.mergedIds.length > 0) { + action = ACTIVITY_ACTIONS.MERGE; + content = company.mergedIds.toString(); + } + + ActivityLogs.createDoc({ + activity: { + type: ACTIVITY_TYPES.COMPANY, + action, + content, + id: company._id, + }, + contentType: { + type: ACTIVITY_CONTENT_TYPES.COMPANY, + id: company._id, + }, + performer, + }); + } + + const companyId = data.documentKey; + + if (data.operationType === 'delete' && companyId) { + await ActivityLogs.deleteMany({ 'contentType.id': companyId }); + } + }); + +export default companyListeners; diff --git a/src/db/listeners/Conversations.ts b/src/db/listeners/Conversations.ts new file mode 100644 index 000000000..94f54aecc --- /dev/null +++ b/src/db/listeners/Conversations.ts @@ -0,0 +1,88 @@ +import { publishClientMessage, publishMessage } from '../../data/resolvers/mutations/conversations'; +import { ActivityLogs, ConversationMessages, Conversations, Customers } from '../models'; +import { + ACTIVITY_ACTIONS, + ACTIVITY_CONTENT_TYPES, + ACTIVITY_PERFORMER_TYPES, + ACTIVITY_TYPES, +} from '../models/definitions/constants'; + +const cocFindOne = (conversationId: string, cocId: string, cocType: string) => { + return ActivityLogs.findOne({ + 'activity.type': ACTIVITY_TYPES.CONVERSATION, + 'activity.action': ACTIVITY_ACTIONS.CREATE, + 'activity.id': conversationId, + 'contentType.type': cocType, + 'performedBy.type': ACTIVITY_PERFORMER_TYPES.CUSTOMER, + 'contentType.id': cocId, + }); +}; + +const cocCreate = (conversationId: string, content: string, cocId: string, cocType: string) => { + return ActivityLogs.createDoc({ + activity: { + type: ACTIVITY_TYPES.CONVERSATION, + action: ACTIVITY_ACTIONS.CREATE, + content, + id: conversationId, + }, + performer: { + type: ACTIVITY_PERFORMER_TYPES.CUSTOMER, + id: cocId, + }, + contentType: { + type: cocType, + id: cocId, + }, + }); +}; + +const conversationListeners = () => { + ConversationMessages.watch().on('change', data => { + const message = data.fullDocument; + + if (data.operationType === 'insert' && message) { + publishClientMessage(message); + publishMessage(message); + } + }); + + Conversations.watch().on('change', async data => { + const conversation = data.fullDocument; + + /** + * Create a conversation log for a given customer, + * if the customer is related to companies, + * then create conversation log with all related companies + */ + if (data.operationType === 'insert' && conversation) { + const customer = await Customers.findOne({ _id: conversation.customerId }); + + if (customer) { + if (customer == null || (customer && !customer._id)) { + throw new Error(`'customer' must be supplied when adding activity log for conversations`); + } + + if (customer.companyIds && customer.companyIds.length > 0) { + for (const companyId of customer.companyIds) { + // check against duplication + const log = await cocFindOne(conversation._id, companyId, ACTIVITY_CONTENT_TYPES.COMPANY); + + if (!log) { + await cocCreate(conversation._id, conversation.content || '', companyId, ACTIVITY_CONTENT_TYPES.COMPANY); + } + } + } + + // check against duplication ====== + const foundLog = await cocFindOne(conversation._id, customer._id, ACTIVITY_CONTENT_TYPES.CUSTOMER); + + if (!foundLog) { + return cocCreate(conversation._id, conversation.content || '', customer._id, ACTIVITY_CONTENT_TYPES.CUSTOMER); + } + } + } + }); +}; + +export default conversationListeners; diff --git a/src/db/listeners/Customers.ts b/src/db/listeners/Customers.ts new file mode 100644 index 000000000..288326e56 --- /dev/null +++ b/src/db/listeners/Customers.ts @@ -0,0 +1,53 @@ +import { ActivityLogs, Customers } from '../models'; +import { + ACTIVITY_ACTIONS, + ACTIVITY_CONTENT_TYPES, + ACTIVITY_PERFORMER_TYPES, + ACTIVITY_TYPES, +} from '../models/definitions/constants'; + +const customerListeners = () => + Customers.watch().on('change', async data => { + const customer = data.fullDocument; + + if (data.operationType === 'insert' && customer) { + let performer; + + if (customer.ownerId) { + performer = { + type: ACTIVITY_PERFORMER_TYPES.USER, + id: customer.ownerId, + }; + } + + let action = ACTIVITY_ACTIONS.CREATE; + let content = `${customer.firstName || ''} ${customer.lastName || ''}`; + + if (customer.mergedIds && customer.mergedIds.length > 0) { + action = ACTIVITY_ACTIONS.MERGE; + content = customer.mergedIds.toString(); + } + + ActivityLogs.createDoc({ + activity: { + type: ACTIVITY_TYPES.CUSTOMER, + action, + content, + id: customer._id, + }, + contentType: { + type: ACTIVITY_CONTENT_TYPES.CUSTOMER, + id: customer._id, + }, + performer, + }); + } + + const customerId = data.documentKey; + + if (data.operationType === 'delete' && customerId) { + await ActivityLogs.deleteMany({ 'contentType.id': customerId }); + } + }); + +export default customerListeners; diff --git a/src/db/listeners/Deals.ts b/src/db/listeners/Deals.ts new file mode 100644 index 000000000..5aaa1cb17 --- /dev/null +++ b/src/db/listeners/Deals.ts @@ -0,0 +1,42 @@ +import { ActivityLogs, Deals } from '../models'; +import { + ACTIVITY_ACTIONS, + ACTIVITY_CONTENT_TYPES, + ACTIVITY_PERFORMER_TYPES, + ACTIVITY_TYPES, +} from '../models/definitions/constants'; + +const dealListeners = () => + Deals.watch().on('change', data => { + const deal = data.fullDocument; + + /** + * Creates a deal company registration log + */ + if (data.operationType === 'insert' && deal) { + let performer; + + if (deal.userId) { + performer = { + type: ACTIVITY_PERFORMER_TYPES.USER, + id: deal.userId, + }; + } + + ActivityLogs.createDoc({ + activity: { + type: ACTIVITY_TYPES.DEAL, + action: ACTIVITY_ACTIONS.CREATE, + content: deal.name || '', + id: deal._id, + }, + contentType: { + type: ACTIVITY_CONTENT_TYPES.DEAL, + id: deal._id, + }, + performer, + }); + } + }); + +export default dealListeners; diff --git a/src/db/listeners/EmailDeliveries.ts b/src/db/listeners/EmailDeliveries.ts new file mode 100644 index 000000000..cf7621ded --- /dev/null +++ b/src/db/listeners/EmailDeliveries.ts @@ -0,0 +1,28 @@ +import { ActivityLogs, EmailDeliveries } from '../models'; +import { ACTIVITY_ACTIONS, ACTIVITY_PERFORMER_TYPES, ACTIVITY_TYPES } from '../models/definitions/constants'; + +const emailDeliveryListeners = () => + EmailDeliveries.watch().on('change', data => { + const email = data.fullDocument; + + if (data.operationType === 'insert' && email) { + ActivityLogs.createDoc({ + activity: { + id: Math.random().toString(), + type: ACTIVITY_TYPES.EMAIL, + action: ACTIVITY_ACTIONS.SEND, + content: email.body, + }, + contentType: { + type: email.cocType, + id: email.cocId, + }, + performer: { + type: ACTIVITY_PERFORMER_TYPES.USER, + id: email.userId, + }, + }); + } + }); + +export default emailDeliveryListeners; diff --git a/src/db/listeners/InternalNotes.ts b/src/db/listeners/InternalNotes.ts new file mode 100644 index 000000000..13624ebb3 --- /dev/null +++ b/src/db/listeners/InternalNotes.ts @@ -0,0 +1,28 @@ +import { ActivityLogs, InternalNotes } from '../models'; +import { ACTIVITY_ACTIONS, ACTIVITY_PERFORMER_TYPES, ACTIVITY_TYPES } from '../models/definitions/constants'; + +const internalNoteListeners = () => + InternalNotes.watch().on('change', data => { + const internalNote = data.fullDocument; + + if (data.operationType === 'insert' && internalNote) { + ActivityLogs.createDoc({ + activity: { + type: ACTIVITY_TYPES.INTERNAL_NOTE, + action: ACTIVITY_ACTIONS.CREATE, + content: internalNote.content, + id: internalNote._id, + }, + contentType: { + type: internalNote.contentType, + id: internalNote.contentTypeId, + }, + performer: { + type: ACTIVITY_PERFORMER_TYPES.USER, + id: internalNote.createdUserId, + }, + }); + } + }); + +export default internalNoteListeners; diff --git a/src/db/listeners/index.ts b/src/db/listeners/index.ts new file mode 100644 index 000000000..6f76d82cd --- /dev/null +++ b/src/db/listeners/index.ts @@ -0,0 +1,28 @@ +import { connect } from '../connection'; + +import CompanyListeners from './Companies'; +import ConversationListeners from './Conversations'; +import CustomerListeners from './Customers'; +import DealListeners from './Deals'; +import EmailDeliveryListeners from './EmailDeliveries'; +import InternalNoteListeners from './InternalNotes'; + +export const listen = async () => { + try { + await connect(); + + CompanyListeners(); + + ConversationListeners(); + + CustomerListeners(); + + DealListeners(); + + EmailDeliveryListeners(); + + InternalNoteListeners(); + } catch (error) { + console.log(error); + } +}; diff --git a/src/db/models/Accounts.ts b/src/db/models/Accounts.ts index fb22eb289..f7f63c207 100644 --- a/src/db/models/Accounts.ts +++ b/src/db/models/Accounts.ts @@ -5,7 +5,7 @@ import { accountSchema, IAccount, IAccountDocument } from './definitions/account export interface IAccountModel extends Model { createAccount(doc: IAccount): Promise; removeAccount(_id: string): void; - getGmailCredentials(uid: string): Promise; + getGmailCredentials(uid: string): Promise; } export const loadClass = () => { @@ -39,7 +39,7 @@ export const loadClass = () => { return Accounts.deleteOne({ _id }); } - public static async getGmailCredentials(uid) { + public static async getGmailCredentials(uid: string) { const account = await Accounts.findOne({ uid, kind: 'gmail' }); if (!account) { diff --git a/src/db/models/ActivityLogs.ts b/src/db/models/ActivityLogs.ts index 8c4b8c1d6..bed32f1bc 100644 --- a/src/db/models/ActivityLogs.ts +++ b/src/db/models/ActivityLogs.ts @@ -1,52 +1,28 @@ import { Model, model } from 'mongoose'; -import { activityLogSchema, IActionPerformer, IActivity, IActivityLogDocument, ICoc } from './definitions/activityLogs'; -import { ICompanyDocument } from './definitions/companies'; -import { ACTIVITY_ACTIONS, ACTIVITY_PERFORMER_TYPES, ACTIVITY_TYPES, COC_CONTENT_TYPES } from './definitions/constants'; -import { IConversationDocument } from './definitions/conversations'; +import { pubsub } from '../../data/resolvers/subscriptions'; +import { + activityLogSchema, + IActionPerformer, + IActivity, + IActivityLogDocument, + IContentType, +} from './definitions/activityLogs'; +import { ACTIVITY_ACTIONS, ACTIVITY_PERFORMER_TYPES, ACTIVITY_TYPES } from './definitions/constants'; import { ICustomerDocument } from './definitions/customers'; -import { IDealDocument } from './definitions/deals'; -import { IInternalNoteDocument } from './definitions/internalNotes'; import { ISegmentDocument } from './definitions/segments'; -import { IUserDocument } from './definitions/users'; interface ICreateDocInput { performer?: IActionPerformer; performedBy?: IActionPerformer; activity: IActivity; - coc: ICoc; + contentType: IContentType; + // TODO: remove + coc?: IContentType; } export interface IActivityLogModel extends Model { createDoc(doc: ICreateDocInput): Promise; - - createInternalNoteLog(internalNote: IInternalNoteDocument, user: IUserDocument): Promise; - - cocFindOne(conversationId: string, cocId: string, cocType: string): Promise; - - cocCreate(conversationId: string, content: string, cocId: string, cocType: string): Promise; - - createConversationLog( - conversation: IConversationDocument, - customer?: ICustomerDocument, - ): Promise; - createSegmentLog(segment: ISegmentDocument, customer?: ICustomerDocument): Promise; - - createCustomerRegistrationLog(customer: ICustomerDocument, user?: IUserDocument): Promise; - - createCompanyRegistrationLog(company: ICompanyDocument, user?: IUserDocument): Promise; - - createDealRegistrationLog(deal: IDealDocument, user?: IUserDocument): Promise; - - changeCustomer(newCustomerId: string, customerIds: string[]): Promise; - - removeCustomerActivityLog(customerId: string): void; - - removeCompanyActivityLog(companyId: string): void; - - changeCompany(newCompanyId: string, companyIds: string[]): Promise; - - createGmailLog(content: string, cocType: string, cocId: string, userId: string): Promise; } export const loadClass = () => { @@ -54,7 +30,7 @@ export const loadClass = () => { /** * Create an ActivityLog document */ - public static createDoc(doc: ICreateDocInput) { + public static async createDoc(doc: ICreateDocInput) { const { performer } = doc; let performedBy = { @@ -65,88 +41,11 @@ export const loadClass = () => { performedBy = performer; } - return ActivityLogs.create({ performedBy, ...doc }); - } - - /** - * Create activity log for internal note - */ - public static createInternalNoteLog(internalNote: IInternalNoteDocument, user: IUserDocument) { - return this.createDoc({ - activity: { - type: ACTIVITY_TYPES.INTERNAL_NOTE, - action: ACTIVITY_ACTIONS.CREATE, - id: internalNote._id, - content: internalNote.content, - }, - performer: { - type: ACTIVITY_PERFORMER_TYPES.USER, - id: user._id, - }, - coc: { - type: internalNote.contentType, - id: internalNote.contentTypeId, - }, - }); - } - - public static cocFindOne(conversationId: string, cocId: string, cocType: string) { - return ActivityLogs.findOne({ - 'activity.type': ACTIVITY_TYPES.CONVERSATION, - 'activity.action': ACTIVITY_ACTIONS.CREATE, - 'activity.id': conversationId, - 'coc.type': cocType, - 'performedBy.type': ACTIVITY_PERFORMER_TYPES.CUSTOMER, - 'coc.id': cocId, - }); - } - - public static cocCreate(conversationId: string, content: string, cocId: string, cocType: string) { - return this.createDoc({ - activity: { - type: ACTIVITY_TYPES.CONVERSATION, - action: ACTIVITY_ACTIONS.CREATE, - content, - id: conversationId, - }, - performer: { - type: ACTIVITY_PERFORMER_TYPES.CUSTOMER, - id: cocId, - }, - coc: { - type: cocType, - id: cocId, - }, - }); - } - - /** - * Create a conversation log for a given customer, - * if the customer is related to companies, - * then create conversation log with all related companies - */ - public static async createConversationLog(conversation: IConversationDocument, customer?: ICustomerDocument) { - if (customer == null || (customer && !customer._id)) { - throw new Error(`'customer' must be supplied when adding activity log for conversations`); - } - - if (customer.companyIds && customer.companyIds.length > 0) { - for (const companyId of customer.companyIds) { - // check against duplication - const log = await this.cocFindOne(conversation._id, companyId, COC_CONTENT_TYPES.COMPANY); - - if (!log) { - await this.cocCreate(conversation._id, conversation.content || '', companyId, COC_CONTENT_TYPES.COMPANY); - } - } - } + const log = await ActivityLogs.create({ performedBy, ...doc }); - // check against duplication ====== - const foundLog = await this.cocFindOne(conversation._id, customer._id, COC_CONTENT_TYPES.CUSTOMER); + pubsub.publish('activityLogsChanged', { activityLogsChanged: true }); - if (!foundLog) { - return this.cocCreate(conversation._id, conversation.content || '', customer._id, COC_CONTENT_TYPES.CUSTOMER); - } + return log; } /** @@ -161,8 +60,8 @@ export const loadClass = () => { 'activity.type': ACTIVITY_TYPES.SEGMENT, 'activity.action': ACTIVITY_ACTIONS.CREATE, 'activity.id': segment._id, - 'coc.type': segment.contentType, - 'coc.id': customer._id, + 'contentType.type': segment.contentType, + 'contentType.id': customer._id, }); if (foundSegment) { @@ -177,175 +76,12 @@ export const loadClass = () => { content: segment.name, id: segment._id, }, - coc: { + contentType: { type: segment.contentType, id: customer._id, }, }); } - - /** - * Creates a customer registration log - */ - public static createCustomerRegistrationLog(customer: ICustomerDocument, user?: IUserDocument) { - let performer; - - if (user && user._id) { - performer = { - type: ACTIVITY_PERFORMER_TYPES.USER, - id: user._id, - }; - } - - const customerFullName = `${customer.firstName || ''} ${customer.lastName || ''}`; - - return this.createDoc({ - activity: { - type: ACTIVITY_TYPES.CUSTOMER, - action: ACTIVITY_ACTIONS.CREATE, - content: customerFullName, - id: customer._id, - }, - coc: { - type: COC_CONTENT_TYPES.CUSTOMER, - id: customer._id, - }, - performer, - }); - } - - /** - * Creates a customer company registration log - */ - public static createCompanyRegistrationLog(company: ICompanyDocument, user?: IUserDocument) { - let performer; - - if (user && user._id) { - performer = { - type: ACTIVITY_PERFORMER_TYPES.USER, - id: user._id, - }; - } - - return this.createDoc({ - activity: { - type: ACTIVITY_TYPES.COMPANY, - action: ACTIVITY_ACTIONS.CREATE, - content: company.primaryName || '', - id: company._id, - }, - coc: { - type: COC_CONTENT_TYPES.COMPANY, - id: company._id, - }, - performer, - }); - } - - /** - * Creates a deal company registration log - */ - public static createDealRegistrationLog(deal: IDealDocument, user?: IUserDocument) { - let performer; - - if (user && user._id) { - performer = { - type: ACTIVITY_PERFORMER_TYPES.USER, - id: user._id, - }; - } - - return this.createDoc({ - activity: { - type: ACTIVITY_TYPES.DEAL, - action: ACTIVITY_ACTIONS.CREATE, - content: deal.name || '', - id: deal._id, - }, - coc: { - type: COC_CONTENT_TYPES.DEAL, - id: deal._id, - }, - performer, - }); - } - - /** - * Transfers customers' activity logs to another customer - */ - public static async changeCustomer(newCustomerId: string, customerIds: string[]) { - for (const customerId of customerIds) { - // Updating every activity log of customer - await ActivityLogs.updateMany( - { 'coc.id': customerId, 'coc.type': COC_CONTENT_TYPES.CUSTOMER }, - { - $set: { coc: { type: COC_CONTENT_TYPES.CUSTOMER, id: newCustomerId } }, - }, - ); - } - - // Returning updated list of activity logs of new customer - return ActivityLogs.find({ - coc: { type: COC_CONTENT_TYPES.CUSTOMER, id: newCustomerId }, - }); - } - - /** - * Removes customer's activity logs - */ - public static async removeCustomerActivityLog(customerId: string) { - // Removing every activity log of customer - return ActivityLogs.deleteMany({ - coc: { type: COC_CONTENT_TYPES.CUSTOMER, id: customerId }, - }); - } - - /** - * Removes company's activity logs - */ - public static async removeCompanyActivityLog(companyId: string) { - // Removing every activity log of company - return ActivityLogs.deleteMany({ - coc: { type: COC_CONTENT_TYPES.COMPANY, id: companyId }, - }); - } - - /** - * Transfers companies' activity logs to another company - */ - public static async changeCompany(newCompanyId: string, companyIds: string[]) { - for (const companyId of companyIds) { - // Updating every activity log of company - await ActivityLogs.updateMany( - { 'coc.id': companyId, 'coc.type': COC_CONTENT_TYPES.COMPANY }, - { $set: { coc: { type: COC_CONTENT_TYPES.COMPANY, id: newCompanyId } } }, - ); - } - - // Returning updated list of activity logs of new company - return ActivityLogs.find({ - coc: { type: COC_CONTENT_TYPES.COMPANY, id: newCompanyId }, - }); - } - - public static createGmailLog(content: string, cocType: string, cocId: string, userId: string) { - return this.createDoc({ - activity: { - id: Math.random().toString(), - type: ACTIVITY_TYPES.EMAIL, - action: ACTIVITY_ACTIONS.SEND, - content, - }, - performer: { - type: ACTIVITY_PERFORMER_TYPES.USER, - id: userId, - }, - coc: { - type: cocType, - id: cocId, - }, - }); - } } activityLogSchema.loadClass(ActivityLog); diff --git a/src/db/models/Companies.ts b/src/db/models/Companies.ts index 6fc811c15..04cd219ad 100644 --- a/src/db/models/Companies.ts +++ b/src/db/models/Companies.ts @@ -1,7 +1,7 @@ import { Model, model } from 'mongoose'; -import { ActivityLogs, Customers, Deals, Fields, InternalNotes } from './'; +import { Customers, Deals, Fields, InternalNotes } from './'; import { companySchema, ICompany, ICompanyDocument } from './definitions/companies'; -import { COMPANY_BASIC_INFOS } from './definitions/constants'; +import { COMPANY_BASIC_INFOS, STATUSES } from './definitions/constants'; import { IUserDocument } from './definitions/users'; import { bulkInsert } from './utils'; @@ -37,7 +37,7 @@ export const loadClass = () => { }, idsToExclude?: string[] | string, ) { - const query: { [key: string]: any } = {}; + const query: { status: {}; [key: string]: any } = { status: { $ne: STATUSES.DELETED } }; // Adding exclude operator to the query if (idsToExclude) { @@ -125,7 +125,6 @@ export const loadClass = () => { */ public static async removeCompany(companyId: string) { // Removing modules associated with company - await ActivityLogs.removeCompanyActivityLog(companyId); await InternalNotes.removeCompanyInternalNotes(companyId); await Customers.updateMany({ companyIds: { $in: [companyId] } }, { $pull: { companyIds: companyId } }); @@ -167,8 +166,9 @@ export const loadClass = () => { // Merging company phones phones = phones.concat(companyPhones); - // Removing company - await Companies.deleteOne({ _id: companyId }); + companyObj.status = STATUSES.DELETED; + + await Companies.findByIdAndUpdate(companyId, { $set: { status: STATUSES.DELETED } }); } } @@ -188,6 +188,7 @@ export const loadClass = () => { const company = await Companies.createCompany({ ...companyFields, tagIds, + mergedIds: companyIds, names, emails, phones, @@ -199,7 +200,6 @@ export const loadClass = () => { await Customers.updateMany({ companyIds: { $in: companyIds } }, { $pullAll: { companyIds } }); // Removing modules associated with current companies - await ActivityLogs.changeCompany(company._id, companyIds); await InternalNotes.changeCompany(company._id, companyIds); await Deals.changeCompany(company._id, companyIds); diff --git a/src/db/models/Customers.ts b/src/db/models/Customers.ts index 2544f41fd..b0f5edf59 100644 --- a/src/db/models/Customers.ts +++ b/src/db/models/Customers.ts @@ -1,7 +1,7 @@ import { Model, model } from 'mongoose'; import { validateEmail } from '../../data/utils'; -import { ActivityLogs, Conversations, Deals, EngageMessages, Fields, InternalNotes } from './'; -import { CUSTOMER_BASIC_INFOS } from './definitions/constants'; +import { Conversations, Deals, EngageMessages, Fields, InternalNotes } from './'; +import { CUSTOMER_BASIC_INFOS, STATUSES } from './definitions/constants'; import { customerSchema, ICustomer, ICustomerDocument, IFacebookData, ITwitterData } from './definitions/customers'; import { IUserDocument } from './definitions/users'; import { bulkInsert } from './utils'; @@ -37,7 +37,7 @@ export const loadClass = () => { * Checking if customer has duplicated unique properties */ public static async checkDuplication(customerFields: ICustomerFieldsInput, idsToExclude?: string[] | string) { - const query: { [key: string]: any } = {}; + const query: { status: {}; [key: string]: any } = { status: { $ne: STATUSES.DELETED } }; let previousEntry; // Adding exclude operator to the query @@ -204,7 +204,6 @@ export const loadClass = () => { */ public static async removeCustomer(customerId: string) { // Removing every modules that associated with customer - await ActivityLogs.removeCustomerActivityLog(customerId); await Conversations.removeCustomerConversations(customerId); await EngageMessages.removeCustomerEngages(customerId); await InternalNotes.removeCustomerInternalNotes(customerId); @@ -252,8 +251,7 @@ export const loadClass = () => { emails = [...emails, ...(customerObj.emails || [])]; phones = [...phones, ...(customerObj.phones || [])]; - // Removing Customers - await Customers.deleteOne({ _id: customerId }); + await Customers.findByIdAndUpdate(customerId, { $set: { status: STATUSES.DELETED } }); } } @@ -274,12 +272,12 @@ export const loadClass = () => { ...customerFields, tagIds, companyIds, + mergedIds: customerIds, emails, phones, }); // Updating every modules associated with customers - await ActivityLogs.changeCustomer(customer._id, customerIds); await Conversations.changeCustomer(customer._id, customerIds); await EngageMessages.changeCustomer(customer._id, customerIds); await InternalNotes.changeCustomer(customer._id, customerIds); diff --git a/src/db/models/EmailDeliveries.ts b/src/db/models/EmailDeliveries.ts new file mode 100644 index 000000000..3f9756f7a --- /dev/null +++ b/src/db/models/EmailDeliveries.ts @@ -0,0 +1,28 @@ +import { Model, model } from 'mongoose'; +import { emailDeliverySchema, IEmailDeliveries, IEmailDeliveriesDocument } from './definitions/emailDeliveries'; + +export interface IEmailDeliveryModel extends Model { + createEmailDelivery(doc: IEmailDeliveries): Promise; +} + +export const loadClass = () => { + class EmailDelivery { + /** + * Create an EmailDelivery document + */ + public static createEmailDelivery(doc: IEmailDeliveries) { + return EmailDeliveries.create(doc); + } + } + + emailDeliverySchema.loadClass(EmailDelivery); + + return emailDeliverySchema; +}; + +loadClass(); + +// tslint:disable-next-line +const EmailDeliveries = model('email_deliveries', emailDeliverySchema); + +export default EmailDeliveries; diff --git a/src/db/models/Integrations.ts b/src/db/models/Integrations.ts index 1fe34ec48..3ba83e4c0 100644 --- a/src/db/models/Integrations.ts +++ b/src/db/models/Integrations.ts @@ -2,6 +2,7 @@ import { Model, model } from 'mongoose'; import 'mongoose-type-email'; import { Accounts, ConversationMessages, Conversations, Customers, Forms } from '.'; import { KIND_CHOICES } from '../../data/constants'; +import { getEnv } from '../../data/utils'; import { getPageInfo, subscribePage } from '../../trackers/facebookTracker'; import { IFacebookData, @@ -130,11 +131,8 @@ export const loadClass = () => { brandId: string; facebookData: IFacebookData; }) { - const { FACEBOOK_APP_ID, DOMAIN } = process.env; - - if (!FACEBOOK_APP_ID || !DOMAIN) { - throw new Error('Invalid configuration'); - } + getEnv({ name: 'FACEBOOK_APP_ID' }); + getEnv({ name: 'DOMAIN' }); const { pageIds, accountId } = facebookData; diff --git a/src/db/models/InternalNotes.ts b/src/db/models/InternalNotes.ts index 41042f715..2ed5f7644 100644 --- a/src/db/models/InternalNotes.ts +++ b/src/db/models/InternalNotes.ts @@ -1,5 +1,5 @@ import { Model, model } from 'mongoose'; -import { COC_CONTENT_TYPES } from './definitions/constants'; +import { ACTIVITY_CONTENT_TYPES } from './definitions/constants'; import { IInternalNote, IInternalNoteDocument, internalNoteSchema } from './definitions/internalNotes'; import { IUserDocument } from './definitions/users'; @@ -68,7 +68,7 @@ export const loadClass = () => { // Updating every internal notes of customer await InternalNotes.updateMany( { - contentType: COC_CONTENT_TYPES.CUSTOMER, + contentType: ACTIVITY_CONTENT_TYPES.CUSTOMER, contentTypeId: { $in: customerIds || [] }, }, { contentTypeId: newCustomerId }, @@ -76,7 +76,7 @@ export const loadClass = () => { // Returning updated list of internal notes of new customer return InternalNotes.find({ - contentType: COC_CONTENT_TYPES.CUSTOMER, + contentType: ACTIVITY_CONTENT_TYPES.CUSTOMER, contentTypeId: newCustomerId, }); } @@ -87,7 +87,7 @@ export const loadClass = () => { public static async removeCustomerInternalNotes(customerId: string) { // Removing every internal notes of customer return InternalNotes.deleteMany({ - contentType: COC_CONTENT_TYPES.CUSTOMER, + contentType: ACTIVITY_CONTENT_TYPES.CUSTOMER, contentTypeId: customerId, }); } @@ -98,7 +98,7 @@ export const loadClass = () => { public static async removeCompanyInternalNotes(companyId: string) { // Removing every internal notes of company return InternalNotes.deleteMany({ - contentType: COC_CONTENT_TYPES.COMPANY, + contentType: ACTIVITY_CONTENT_TYPES.COMPANY, contentTypeId: companyId, }); } @@ -110,7 +110,7 @@ export const loadClass = () => { // Updating every internal notes of company await InternalNotes.updateMany( { - contentType: COC_CONTENT_TYPES.COMPANY, + contentType: ACTIVITY_CONTENT_TYPES.COMPANY, contentTypeId: { $in: oldCompanyIds || [] }, }, { contentTypeId: newCompanyId }, @@ -118,7 +118,7 @@ export const loadClass = () => { // Returning updated list of internal notes of new company return InternalNotes.find({ - contentType: COC_CONTENT_TYPES.COMPANY, + contentType: ACTIVITY_CONTENT_TYPES.COMPANY, contentTypeId: newCompanyId, }); } diff --git a/src/db/models/Permissions.ts b/src/db/models/Permissions.ts new file mode 100644 index 000000000..3a2829796 --- /dev/null +++ b/src/db/models/Permissions.ts @@ -0,0 +1,163 @@ +import { Model, model } from 'mongoose'; +import { actionsMap, IActionsMap } from '../../data/permissions/utils'; +import { + IPermission, + IPermissionDocument, + IPermissionParams, + IUserGroup, + IUserGroupDocument, + permissionSchema, + userGroupSchema, +} from './definitions/permissions'; + +export interface IPermissionModel extends Model { + createPermission(doc: IPermissionParams): Promise; + removePermission(ids: string[]): Promise; +} + +export interface IUserGroupModel extends Model { + createGroup(doc: IUserGroup): Promise; + updateGroup(_id: string, doc: IUserGroup): Promise; + removeGroup(_id: string): Promise; +} + +export const permissionLoadClass = () => { + class Permission { + /** + * Create a permission + * @param {Object} doc object + * @return {Promise} Newly created permission object + */ + public static async createPermission(doc: IPermissionParams) { + const permissions: IPermissionDocument[] = []; + + if (!doc.actions) { + throw new Error('Actions not found'); + } + + for (const action of doc.actions) { + if (!actionsMap[action]) { + throw new Error('Invalid data'); + } + } + + let filter = {}; + + let actionObj: IActionsMap = {}; + + for (const action of doc.actions) { + const entry: IPermission = { + action, + module: doc.module, + allowed: doc.allowed || false, + requiredActions: [], + }; + + actionObj = actionsMap[action]; + + if (actionObj.use) { + entry.requiredActions = actionObj.use; + } + + if (doc.userIds) { + for (const userId of doc.userIds) { + filter = { action, userId }; + + const entryObj = await Permissions.findOne(filter); + + if (!entryObj) { + const newEntry = await Permissions.create({ ...entry, userId }); + permissions.push(newEntry); + } + } + } + + if (doc.groupIds) { + for (const groupId of doc.groupIds) { + filter = { action, groupId }; + + const entryObj = await Permissions.findOne(filter); + + if (!entryObj) { + const newEntry = await Permissions.create({ ...entry, groupId }); + permissions.push(newEntry); + } + } + } + } + + return permissions; + } + + /** + * Delete permission + * @param {[string]} ids + * @return {Promise} + */ + public static async removePermission(ids: string[]) { + const count = await Permissions.find({ _id: { $in: ids } }).countDocuments(); + + if (count !== ids.length) { + throw new Error('Permission not found'); + } + + return Permissions.remove({ _id: { $in: ids } }); + } + } + + permissionSchema.loadClass(Permission); + + return permissionSchema; +}; + +export const userGroupLoadClass = () => { + class UserGroup { + /** + * Create a group + * @param {Object} doc + * @return {Promise} Newly created group object + */ + public static async createGroup(doc: IUserGroup) { + return UsersGroups.create(doc); + } + + /** + * Update Group + * @param {Object} doc + * @return {Promise} updated group object + */ + public static async updateGroup(_id: string, doc: IUserGroup) { + await UsersGroups.update({ _id }, { $set: doc }); + + return UsersGroups.findOne({ _id }); + } + + /** + * Remove Group + * @param {String} _id + * @return {Promise} + */ + public static async removeGroup(_id: string) { + const groupObj = await UsersGroups.findOne({ _id }); + + if (!groupObj) { + throw new Error(`Group not found with id ${_id}`); + } + + return groupObj.remove(); + } + } + + userGroupSchema.loadClass(UserGroup); + + return userGroupSchema; +}; + +permissionLoadClass(); +userGroupLoadClass(); + +// tslint:disable-next-line +export const Permissions = model('permissions', permissionSchema); + +// tslint:disable-next-line +export const UsersGroups = model('user_groups', userGroupSchema); diff --git a/src/db/models/Users.ts b/src/db/models/Users.ts index c12f5a053..19bf2ccd8 100644 --- a/src/db/models/Users.ts +++ b/src/db/models/Users.ts @@ -3,7 +3,7 @@ import * as crypto from 'crypto'; import * as jwt from 'jsonwebtoken'; import { Model, model } from 'mongoose'; import * as sha256 from 'sha256'; -import { Session } from '.'; +import { Session, UsersGroups } from '.'; import { IDetail, IEmailSignature, ILink, IUser, IUserDocument, userSchema } from './definitions/users'; const SALT_WORK_FACTOR = 10; @@ -16,20 +16,43 @@ interface IEditProfile { } interface IUpdateUser extends IEditProfile { - role?: string; password?: string; + groupIds?: string[]; } export interface IUserModel extends Model { - checkDuplication(email?: string, idsToExclude?: string | string[]): never; + checkDuplication({ + email, + idsToExclude, + emails, + }: { + email?: string; + idsToExclude?: string | string[]; + emails?: string[]; + }): never; getSecret(): string; createUser(doc: IUser): Promise; updateUser(_id: string, doc: IUpdateUser): Promise; editProfile(_id: string, doc: IEditProfile): Promise; + updateOnBoardSeen({ _id }: { _id: string }): Promise; configEmailSignatures(_id: string, signatures: IEmailSignature[]): Promise; configGetNotificationByEmail(_id: string, isAllowed: boolean): Promise; - removeUser(_id: string): Promise; + setUserActiveOrInactive(_id: string): Promise; generatePassword(password: string): string; + createUserWithConfirmation({ email, groupId }: { email: string; groupId: string }): string; + confirmInvitation({ + token, + password, + passwordConfirmation, + fullName, + username, + }: { + token: string; + password: string; + passwordConfirmation: string; + fullName?: string; + username?: string; + }): Promise; comparePassword(password: string, userPassword: string): boolean; resetPassword({ token, newPassword }: { token: string; newPassword: string }): Promise; changePassword({ @@ -53,7 +76,15 @@ export const loadClass = () => { /** * Checking if user has duplicated properties */ - public static async checkDuplication(email?: string, idsToExclude?: string | string[]) { + public static async checkDuplication({ + email, + idsToExclude, + emails, + }: { + email?: string; + idsToExclude?: string | string[]; + emails?: string[]; + }) { const query: { [key: string]: any } = {}; let previousEntry; @@ -71,6 +102,14 @@ export const loadClass = () => { throw new Error('Duplicated email'); } } + + if (emails) { + previousEntry = await Users.find({ email: { $in: [emails] } }); + + if (previousEntry.length > 0) { + throw new Error('Duplicated emails'); + } + } } public static getSecret() { @@ -80,21 +119,21 @@ export const loadClass = () => { /** * Create new user */ - public static async createUser({ username, email, password, role, details, links }: IUser) { + public static async createUser({ username, email, password, details, links, groupIds }: IUser) { // empty string password validation if (password === '') { throw new Error('Password can not be empty'); } // Checking duplicated email - await Users.checkDuplication(email); + await Users.checkDuplication({ email }); return Users.create({ username, email, - role, details, links, + groupIds, isActive: true, // hash password password: await this.generatePassword(password), @@ -104,11 +143,11 @@ export const loadClass = () => { /** * Update user information */ - public static async updateUser(_id: string, { username, email, password, role, details, links }: IUpdateUser) { - const doc = { username, email, password, role, details, links }; + public static async updateUser(_id: string, { username, email, password, details, links, groupIds }: IUpdateUser) { + const doc = { username, email, password, details, links, groupIds }; // Checking duplicated email - await this.checkDuplication(email, _id); + await this.checkDuplication({ email, idsToExclude: _id }); // change password if (password) { @@ -124,12 +163,104 @@ export const loadClass = () => { return Users.findOne({ _id }); } + /** + * Create new user with invitation token + */ + public static async createUserWithConfirmation({ email, groupId }: { email: string; groupId: string }) { + // Checking duplicated email + await Users.checkDuplication({ email }); + + if (!(await UsersGroups.findOne({ _id: groupId }))) { + throw new Error('Invalid group'); + } + + const buffer = await crypto.randomBytes(20); + const token = buffer.toString('hex'); + + await Users.create({ + email, + groupIds: [groupId], + registrationToken: token, + registrationTokenExpires: Date.now() + 86400000, + }); + + return token; + } + + /** + * User has seen on board set up + */ + public static async updateOnBoardSeen({ _id }: { _id: string }) { + const user = await Users.findOne({ _id }); + + if (!user) { + throw new Error('User not found'); + } + + await Users.updateOne({ _id }, { $set: { hasSeenOnBoard: true } }); + + return user; + } + + /** + * Confirms user by invitation + */ + public static async confirmInvitation({ + token, + password, + passwordConfirmation, + fullName, + username, + }: { + token: string; + password: string; + passwordConfirmation: string; + fullName?: string; + username?: string; + }) { + const user = await Users.findOne({ + registrationToken: token, + registrationTokenExpires: { + $gt: Date.now(), + }, + }); + + if (!user || !token) { + throw new Error('Token is invalid or has expired'); + } + + if (password === '') { + throw new Error('Password can not be empty'); + } + + if (password !== passwordConfirmation) { + throw new Error('Password does not match'); + } + + await Users.updateOne( + { _id: user._id }, + { + $set: { + password: await this.generatePassword(password), + isActive: true, + registrationToken: undefined, + username, + details: { + fullName, + }, + }, + }, + ); + + return user; + } + /* * Update user profile */ public static async editProfile(_id: string, { username, email, details, links }: IEditProfile) { // Checking duplicated email - await this.checkDuplication(email, _id); + await this.checkDuplication({ email, idsToExclude: _id }); await Users.updateOne({ _id }, { $set: { username, email, details, links } }); @@ -157,7 +288,23 @@ export const loadClass = () => { /* * Remove user */ - public static async removeUser(_id: string) { + public static async setUserActiveOrInactive(_id: string) { + const user = await Users.findOne({ _id }); + + if (!user) { + throw new Error('User not found'); + } + + if (user.isActive === false) { + await Users.updateOne({ _id }, { $set: { isActive: true } }); + + return Users.findOne({ _id }); + } + + if (user.isOwner) { + throw new Error('Can not deactivate owner'); + } + await Users.updateOne({ _id }, { $set: { isActive: false } }); return Users.findOne({ _id }); @@ -290,7 +437,6 @@ export const loadClass = () => { _id: _user._id, email: _user.email, details: _user.details, - role: _user.role, isOwner: _user.isOwner, }; diff --git a/src/db/models/definitions/activityLogs.ts b/src/db/models/definitions/activityLogs.ts index 907c4e38d..28e513ae2 100644 --- a/src/db/models/definitions/activityLogs.ts +++ b/src/db/models/definitions/activityLogs.ts @@ -1,6 +1,6 @@ import { Document, Schema } from 'mongoose'; import { field } from '../utils'; -import { ACTIVITY_ACTIONS, ACTIVITY_PERFORMER_TYPES, ACTIVITY_TYPES, COC_CONTENT_TYPES } from './constants'; +import { ACTIVITY_ACTIONS, ACTIVITY_CONTENT_TYPES, ACTIVITY_PERFORMER_TYPES, ACTIVITY_TYPES } from './constants'; export interface IActionPerformer { type: string; @@ -22,12 +22,12 @@ interface IActivityDocument extends IActivity, Document { id?: string; } -export interface ICoc { +export interface IContentType { id: string; type: string; } -interface ICocDocument extends ICoc, Document { +interface IContentTypeDocument extends IContentType, Document { id: string; } @@ -35,10 +35,17 @@ export interface IActivityLogDocument extends Document { _id: string; activity: IActivityDocument; performedBy?: IActionPerformerDocument; - coc: ICocDocument; + contentType: IContentTypeDocument; createdAt: Date; } +export interface IActivityLog { + contentType: string; + contentId: string; + activityType: string; + limit: number; +} + // Mongoose schemas =========== /* Performer of the action: @@ -105,7 +112,7 @@ const activitySchema = new Schema( /* the customer that is related to a given ActivityLog can be both Company or Customer documents */ -const cocSchema = new Schema( +const contentTypeSchema = new Schema( { id: field({ type: String, @@ -113,7 +120,7 @@ const cocSchema = new Schema( }), type: field({ type: String, - enum: COC_CONTENT_TYPES.ALL, + enum: ACTIVITY_CONTENT_TYPES.ALL, required: true, }), }, @@ -124,7 +131,9 @@ export const activityLogSchema = new Schema({ _id: field({ pkey: true }), activity: { type: activitySchema }, performedBy: { type: actionPerformerSchema, optional: true }, - coc: { type: cocSchema }, + contentType: { type: contentTypeSchema }, + // TODO: remove + coc: { type: contentTypeSchema, optional: true }, createdAt: field({ type: Date, diff --git a/src/db/models/definitions/brands.ts b/src/db/models/definitions/brands.ts index 0b0d2fbd2..5cbff8fb2 100644 --- a/src/db/models/definitions/brands.ts +++ b/src/db/models/definitions/brands.ts @@ -35,7 +35,7 @@ export const brandSchema = new Schema({ _id: field({ pkey: true }), code: field({ type: String }), name: field({ type: String }), - description: field({ type: String }), + description: field({ type: String, optional: true }), userId: field({ type: String }), createdAt: field({ type: Date }), emailConfig: field({ type: brandEmailConfigSchema }), diff --git a/src/db/models/definitions/companies.ts b/src/db/models/definitions/companies.ts index 7356c986c..ab536e697 100644 --- a/src/db/models/definitions/companies.ts +++ b/src/db/models/definitions/companies.ts @@ -5,6 +5,7 @@ import { COMPANY_INDUSTRY_TYPES, COMPANY_LEAD_STATUS_TYPES, COMPANY_LIFECYCLE_STATE_TYPES, + STATUSES, } from './constants'; import { field } from '../utils'; @@ -37,7 +38,9 @@ export interface ICompany { primaryPhone?: string; phones?: string[]; + mergedIds?: string[]; leadStatus?: string; + status?: string; lifecycleState?: string; businessType?: string; description?: string; @@ -52,6 +55,7 @@ export interface ICompany { export interface ICompanyDocument extends ICompany, Document { _id: string; links?: ILinkDocument; + status?: string; createdAt: Date; modifiedAt: Date; } @@ -135,6 +139,14 @@ export const companySchema = new Schema({ label: 'Lead Status', }), + status: field({ + type: String, + enum: STATUSES.ALL, + default: STATUSES.ACTIVE, + optional: true, + label: 'Status', + }), + lifecycleState: field({ type: String, enum: COMPANY_LIFECYCLE_STATE_TYPES, @@ -163,6 +175,9 @@ export const companySchema = new Schema({ optional: true, }), + // Merged company ids + mergedIds: field({ type: [String], optional: true }), + customFieldsData: field({ type: Object, }), diff --git a/src/db/models/definitions/constants.ts b/src/db/models/definitions/constants.ts index 1ebc972e5..62e757d9e 100644 --- a/src/db/models/definitions/constants.ts +++ b/src/db/models/definitions/constants.ts @@ -163,7 +163,7 @@ export const FIELD_CONTENT_TYPES = { ALL: ['form', 'customer', 'company'], }; -export const COC_CONTENT_TYPES = { +export const ACTIVITY_CONTENT_TYPES = { CUSTOMER: 'customer', COMPANY: 'company', USER: 'user', @@ -171,11 +171,6 @@ export const COC_CONTENT_TYPES = { ALL: ['customer', 'company', 'user', 'deal'], }; -export const ROLES = { - ADMIN: 'admin', - CONTRIBUTOR: 'contributor', -}; - export const PUBLISH_STATUSES = { DRAFT: 'draft', PUBLISH: 'publish', @@ -198,9 +193,10 @@ export const ACTIVITY_ACTIONS = { CREATE: 'create', UPDATE: 'update', DELETE: 'delete', + MERGE: 'merge', SEND: 'send', - ALL: ['create', 'update', 'delete', 'send'], + ALL: ['create', 'update', 'delete', 'merge', 'send'], }; export const ACTIVITY_PERFORMER_TYPES = { @@ -389,3 +385,15 @@ export const PROBABILITY = { LOST: 'Lost', ALL: ['10%', '20%', '30%', '40%', '50%', '60%', '70%', '80%', '90%', 'Won', 'Lost'], }; + +export const STATUSES = { + ACTIVE: 'Active', + DELETED: 'Deleted', + ALL: ['Active', 'Deleted'], +}; + +export const EMAIL_TYPES = { + GMAIL: 'gmail', + OTHER: 'other', + ALL: ['gmail', 'other'], +}; diff --git a/src/db/models/definitions/conversationMessages.ts b/src/db/models/definitions/conversationMessages.ts index 541ff7b9a..9f9f3d03f 100644 --- a/src/db/models/definitions/conversationMessages.ts +++ b/src/db/models/definitions/conversationMessages.ts @@ -46,7 +46,7 @@ export interface IGmail { threadId?: string; messageId?: string; headerId?: string; - from?: string; + from: string; to?: string; cc?: string; bcc?: string; @@ -56,6 +56,7 @@ export interface IGmail { textPlain?: string; textHtml?: string; attachments?: IGmailAttachment[]; + labelIds?: string[]; } interface IFacebookDataDocument extends IFacebook, Document {} @@ -287,16 +288,23 @@ const gmailSchema = new Schema( type: [gmailAttachmentSchema], optional: true, }), + labelIds: field({ + type: [String], + optional: true, + }), }, { _id: false }, ); -const engageDataRuleSchema = new Schema({ - kind: field({ type: String }), - text: field({ type: String }), - condition: field({ type: String }), - value: field({ type: String, optional: true }), -}); +const engageDataRuleSchema = new Schema( + { + kind: field({ type: String }), + text: field({ type: String }), + condition: field({ type: String }), + value: field({ type: String, optional: true }), + }, + { _id: false }, +); const engageDataSchema = new Schema( { @@ -316,12 +324,12 @@ export const messageSchema = new Schema({ content: field({ type: String }), attachments: [attachmentSchema], mentionedUserIds: field({ type: [String] }), - conversationId: field({ type: String }), + conversationId: field({ type: String, index: true }), internal: field({ type: Boolean }), customerId: field({ type: String }), fromBot: field({ type: Boolean }), userId: field({ type: String }), - createdAt: field({ type: Date }), + createdAt: field({ type: Date, index: true }), isCustomerRead: field({ type: Boolean }), formWidgetData: field({ type: Object }), messengerAppData: field({ type: Object }), diff --git a/src/db/models/definitions/conversations.ts b/src/db/models/definitions/conversations.ts index bad54176f..b72c147dd 100644 --- a/src/db/models/definitions/conversations.ts +++ b/src/db/models/definitions/conversations.ts @@ -184,7 +184,7 @@ export const conversationSchema = new Schema({ assignedUserId: field({ type: String }), participatedUserIds: field({ type: [String] }), readUserIds: field({ type: [String] }), - createdAt: field({ type: Date }), + createdAt: field({ type: Date, index: true }), updatedAt: field({ type: Date }), closedAt: field({ diff --git a/src/db/models/definitions/customers.ts b/src/db/models/definitions/customers.ts index cab2794d3..8b48e5e31 100644 --- a/src/db/models/definitions/customers.ts +++ b/src/db/models/definitions/customers.ts @@ -1,6 +1,6 @@ import { Document, Schema } from 'mongoose'; -import { CUSTOMER_LEAD_STATUS_TYPES, CUSTOMER_LIFECYCLE_STATE_TYPES } from './constants'; +import { CUSTOMER_LEAD_STATUS_TYPES, CUSTOMER_LIFECYCLE_STATE_TYPES, STATUSES } from './constants'; import { field } from '../utils'; @@ -87,6 +87,8 @@ export interface ICustomer { integrationId?: string; tagIds?: string[]; companyIds?: string[]; + mergedIds?: string[]; + status?: string; customFieldsData?: any; messengerData?: IMessengerData; twitterData?: ITwitterData; @@ -104,6 +106,7 @@ export interface ICustomerDocument extends ICustomer, Document { location?: ILocationDocument; links?: ILinkDocument; visitorContactInfo?: IVisitorContactDocument; + status?: string; createdAt: Date; modifiedAt: Date; } @@ -230,6 +233,14 @@ export const customerSchema = new Schema({ label: 'Lead Status', }), + status: field({ + type: String, + enum: STATUSES.ALL, + default: STATUSES.ACTIVE, + optional: true, + label: 'Status', + }), + lifecycleState: field({ type: String, enum: CUSTOMER_LIFECYCLE_STATE_TYPES, @@ -252,6 +263,9 @@ export const customerSchema = new Schema({ tagIds: field({ type: [String], optional: true }), companyIds: field({ type: [String], optional: true }), + // Merged customer ids + mergedIds: field({ type: [String], optional: true }), + customFieldsData: field({ type: Object, optional: true }), messengerData: field({ type: messengerSchema, optional: true }), twitterData: field({ type: twitterSchema, optional: true }), @@ -261,6 +275,10 @@ export const customerSchema = new Schema({ // if customer is not a user then we will contact with this visitor using // this information - visitorContactInfo: field({ type: visitorContactSchema, optional: true, label: 'Visitor contact info' }), + visitorContactInfo: field({ + type: visitorContactSchema, + optional: true, + label: 'Visitor contact info', + }), urlVisits: Object, }); diff --git a/src/db/models/definitions/deals.ts b/src/db/models/definitions/deals.ts index 8fd2ac841..7365a4d5d 100644 --- a/src/db/models/definitions/deals.ts +++ b/src/db/models/definitions/deals.ts @@ -158,7 +158,7 @@ export const dealSchema = new Schema({ closeDate: field({ type: Date }), description: field({ type: String, optional: true }), assignedUserIds: field({ type: [String] }), - stageId: field({ type: String }), + stageId: field({ type: String, optional: true }), modifiedAt: field({ type: Date, default: new Date(), diff --git a/src/db/models/definitions/emailDeliveries.ts b/src/db/models/definitions/emailDeliveries.ts new file mode 100644 index 000000000..45f37fded --- /dev/null +++ b/src/db/models/definitions/emailDeliveries.ts @@ -0,0 +1,58 @@ +import { Document, Schema } from 'mongoose'; +import { field } from '../utils'; +import { EMAIL_TYPES } from './constants'; + +interface IAttachmentParams { + data: string; + filename: string; + size: number; + mimeType: string; +} + +export interface IEmailDeliveries { + cocType: string; + cocId?: string; + subject: string; + body: string; + toEmails: string; + cc?: string; + bcc?: string; + attachments?: IAttachmentParams[]; + fromEmail?: string; + type?: string; + userId: string; +} + +export interface IEmailDeliveriesDocument extends IEmailDeliveries, Document { + id: string; +} + +// Mongoose schemas =========== + +const attachmentSchema = new Schema( + { + data: field({ type: String }), + filename: field({ type: String }), + size: field({ type: Number }), + mimeType: field({ type: String }), + }, + { _id: false }, +); + +export const emailDeliverySchema = new Schema({ + _id: field({ pkey: true }), + cocType: field({ type: String }), + cocId: field({ type: String }), + subject: field({ type: String, optional: true }), + body: field({ type: String }), + toEmails: field({ type: String }), + cc: field({ type: String, optional: true }), + bcc: field({ type: String, optional: true }), + attachments: field({ type: [attachmentSchema] }), + fromEmail: field({ type: String }), + userId: field({ type: String }), + + type: { type: String, enum: EMAIL_TYPES.ALL, default: EMAIL_TYPES.GMAIL }, + + createdAt: field({ type: Date, default: Date.now }), +}); diff --git a/src/db/models/definitions/emailTemplates.ts b/src/db/models/definitions/emailTemplates.ts index 79181d406..e3669297c 100644 --- a/src/db/models/definitions/emailTemplates.ts +++ b/src/db/models/definitions/emailTemplates.ts @@ -13,5 +13,5 @@ export interface IEmailTemplateDocument extends IEmailTemplate, Document { export const emailTemplateSchema = new Schema({ _id: field({ pkey: true }), name: field({ type: String }), - content: field({ type: String }), + content: field({ type: String, optional: true }), }); diff --git a/src/db/models/definitions/engages.ts b/src/db/models/definitions/engages.ts index 8a0f85354..705520b45 100644 --- a/src/db/models/definitions/engages.ts +++ b/src/db/models/definitions/engages.ts @@ -11,7 +11,7 @@ export interface IScheduleDate { interface IScheduleDateDocument extends IScheduleDate, Document {} -interface IEmail { +export interface IEmail { templateId?: string; attachments?: any; subject?: string; diff --git a/src/db/models/definitions/integrations.ts b/src/db/models/definitions/integrations.ts index e17d4f7cb..fe2ee9d03 100644 --- a/src/db/models/definitions/integrations.ts +++ b/src/db/models/definitions/integrations.ts @@ -61,11 +61,11 @@ export interface IMessengerData { notifyCustomer?: boolean; availabilityMethod?: string; isOnline?: boolean; - requireAuth?: boolean; onlineHours?: IMessengerOnlineHours[]; timezone?: string; messages?: IMessageDataMessages; links?: ILink; + requireAuth?: boolean; } export interface IMessengerDataDocument extends IMessengerData, Document {} diff --git a/src/db/models/definitions/internalNotes.ts b/src/db/models/definitions/internalNotes.ts index fd438cc14..4579d3286 100644 --- a/src/db/models/definitions/internalNotes.ts +++ b/src/db/models/definitions/internalNotes.ts @@ -1,6 +1,6 @@ import { Document, Schema } from 'mongoose'; import { field } from '../utils'; -import { COC_CONTENT_TYPES } from './constants'; +import { ACTIVITY_CONTENT_TYPES } from './constants'; export interface IInternalNote { contentType: string; @@ -20,7 +20,7 @@ export const internalNoteSchema = new Schema({ _id: field({ pkey: true }), contentType: field({ type: String, - enum: COC_CONTENT_TYPES.ALL, + enum: ACTIVITY_CONTENT_TYPES.ALL, }), contentTypeId: field({ type: String }), content: field({ diff --git a/src/db/models/definitions/knowledgebase.ts b/src/db/models/definitions/knowledgebase.ts index eb7ad9ae3..00706e0f5 100644 --- a/src/db/models/definitions/knowledgebase.ts +++ b/src/db/models/definitions/knowledgebase.ts @@ -61,7 +61,7 @@ const commonFields = { export const articleSchema = new Schema({ _id: field({ pkey: true }), title: field({ type: String }), - summary: field({ type: String }), + summary: field({ type: String, optional: true }), content: field({ type: String }), status: field({ type: String, @@ -74,24 +74,24 @@ export const articleSchema = new Schema({ export const categorySchema = new Schema({ _id: field({ pkey: true }), title: field({ type: String }), - description: field({ type: String }), + description: field({ type: String, optional: true }), articleIds: field({ type: [String] }), - icon: field({ type: String }), + icon: field({ type: String, optional: true }), ...commonFields, }); export const topicSchema = new Schema({ _id: field({ pkey: true }), title: field({ type: String }), - description: field({ type: String }), - brandId: field({ type: String }), + description: field({ type: String, optional: true }), + brandId: field({ type: String, optional: true }), categoryIds: field({ type: [String], required: false, }), - color: field({ type: String }), + color: field({ type: String, optional: true }), languageCode: field({ type: String, diff --git a/src/db/models/definitions/messengerApps.ts b/src/db/models/definitions/messengerApps.ts index e48282eab..564054605 100644 --- a/src/db/models/definitions/messengerApps.ts +++ b/src/db/models/definitions/messengerApps.ts @@ -23,8 +23,9 @@ export type IMessengerAppCrendentials = IGoogleCredentials | IKnowledgebaseCrede export interface IMessengerApp { kind: 'googleMeet' | 'knowledgebase' | 'lead'; name: string; + accountId?: string; showInInbox?: boolean; - credentials: IMessengerAppCrendentials; + credentials?: IMessengerAppCrendentials; } export interface IMessengerAppDocument extends IMessengerApp, Document { @@ -41,6 +42,7 @@ export const messengerAppSchema = new Schema({ }), name: field({ type: String }), + accountId: field({ type: String, optional: true }), showInInbox: field({ type: Boolean, default: false }), credentials: field({ type: Object }), }); diff --git a/src/db/models/definitions/permissions.ts b/src/db/models/definitions/permissions.ts new file mode 100644 index 000000000..efec4904e --- /dev/null +++ b/src/db/models/definitions/permissions.ts @@ -0,0 +1,51 @@ +import { Document, Schema } from 'mongoose'; +import { field } from '../utils'; + +export interface IPermission { + module?: string; + action: string; + userId?: string; + groupId?: string; + requiredActions: string[]; + allowed: boolean; +} + +export interface IPermissionParams { + module?: string; + actions?: string[]; + userIds?: string[]; + groupIds?: string[]; + requiredActions?: string[]; + allowed?: boolean; +} + +export interface IPermissionDocument extends IPermission, Document { + _id: string; + length: number; + find(arg0: (p: any) => boolean): any; +} + +export const permissionSchema = new Schema({ + _id: field({ pkey: true }), + module: field({ type: String }), + action: field({ type: String }), + userId: field({ type: String }), + groupId: field({ type: String }), + requiredActions: field({ type: [String], default: [] }), + allowed: field({ type: Boolean, default: false }), +}); + +export interface IUserGroup { + name?: string; + description?: string; +} + +export interface IUserGroupDocument extends IUserGroup, Document { + _id: string; +} + +export const userGroupSchema = new Schema({ + _id: field({ pkey: true }), + name: field({ type: String, unique: true }), + description: field({ type: String }), +}); diff --git a/src/db/models/definitions/segments.ts b/src/db/models/definitions/segments.ts index d0f8885f6..e458d1339 100644 --- a/src/db/models/definitions/segments.ts +++ b/src/db/models/definitions/segments.ts @@ -1,6 +1,6 @@ import { Document, Schema } from 'mongoose'; import { field } from '../utils'; -import { COC_CONTENT_TYPES } from './constants'; +import { ACTIVITY_CONTENT_TYPES } from './constants'; export interface ICondition { field: string; @@ -51,7 +51,7 @@ export const segmentSchema = new Schema({ _id: field({ pkey: true }), contentType: field({ type: String, - enum: COC_CONTENT_TYPES.ALL, + enum: ACTIVITY_CONTENT_TYPES.ALL, }), name: field({ type: String }), description: field({ type: String, optional: true }), diff --git a/src/db/models/definitions/users.ts b/src/db/models/definitions/users.ts index e47ce990a..91c781ee1 100644 --- a/src/db/models/definitions/users.ts +++ b/src/db/models/definitions/users.ts @@ -1,6 +1,5 @@ import { Document, Schema } from 'mongoose'; import { field } from '../utils'; -import { ROLES } from './constants'; export interface IEmailSignature { brandId?: string; @@ -36,8 +35,10 @@ export interface IUser { password: string; resetPasswordToken?: string; resetPasswordExpires?: Date; - role?: string; + registrationToken?: string; + registrationTokenExpires?: Date; isOwner?: boolean; + hasSeenOnBoard?: boolean; email?: string; getNotificationByEmail?: boolean; emailSignatures?: IEmailSignature[]; @@ -45,6 +46,7 @@ export interface IUser { details?: IDetail; links?: ILink; isActive?: boolean; + groupIds?: string[]; } export interface IUserDocument extends IUser, Document { @@ -52,6 +54,7 @@ export interface IUserDocument extends IUser, Document { emailSignatures?: IEmailSignatureDocument[]; details?: IDetailDocument; links?: ILinkDocument; + groupIds?: string[]; } // Mongoose schemas =============================== @@ -94,22 +97,21 @@ export const userSchema = new Schema({ username: field({ type: String }), password: field({ type: String }), resetPasswordToken: field({ type: String }), + registrationToken: field({ type: String }), + registrationTokenExpires: field({ type: Date }), resetPasswordExpires: field({ type: Date }), - role: field({ - type: String, - enum: [ROLES.ADMIN, ROLES.CONTRIBUTOR], - }), isOwner: field({ type: Boolean }), + hasSeenOnBoard: field({ type: Boolean }), email: field({ type: String, - lowercase: true, unique: true, match: [/^\w+([\.-]?\w+)*@\w+([\.-]?\w+)*(\.\w{2,3})+$/, 'Please fill a valid email address'], }), getNotificationByEmail: field({ type: Boolean }), emailSignatures: field({ type: [emailSignatureSchema] }), starredConversationIds: field({ type: [String] }), - details: field({ type: detailSchema }), + details: field({ type: detailSchema, default: {} }), links: field({ type: linkSchema, default: {} }), isActive: field({ type: Boolean, default: true }), + groupIds: field({ type: [String] }), }); diff --git a/src/db/models/index.ts b/src/db/models/index.ts index c848586a3..5b812c12b 100644 --- a/src/db/models/index.ts +++ b/src/db/models/index.ts @@ -8,6 +8,7 @@ import ConversationMessages from './ConversationMessages'; import Conversations from './Conversations'; import Customers from './Customers'; import { DealBoards, DealPipelines, Deals, DealStages } from './Deals'; +import EmailDeliveries from './EmailDeliveries'; import EmailTemplates from './EmailTemplates'; import EngageMessages from './Engages'; import { Fields, FieldsGroups } from './Fields'; @@ -18,6 +19,7 @@ import InternalNotes from './InternalNotes'; import { KnowledgeBaseArticles, KnowledgeBaseCategories, KnowledgeBaseTopics } from './KnowledgeBase'; import MessengerApps from './MessengerApps'; import { NotificationConfigurations, Notifications } from './Notifications'; +import { Permissions, UsersGroups } from './Permissions'; import Products from './Products'; import ResponseTemplates from './ResponseTemplates'; import Scripts from './Scripts'; @@ -44,6 +46,7 @@ export { InternalNotes, Customers, Companies, + EmailDeliveries, Conversations, ConversationMessages, KnowledgeBaseArticles, @@ -61,4 +64,6 @@ export { FieldsGroups, ImportHistory, MessengerApps, + Permissions, + UsersGroups, }; diff --git a/src/db/models/utils.ts b/src/db/models/utils.ts index 057a6ee77..680d88b9a 100644 --- a/src/db/models/utils.ts +++ b/src/db/models/utils.ts @@ -1,4 +1,5 @@ import * as Random from 'meteor-random'; +import { getEnv } from '../../data/utils'; import { Fields, ImportHistory } from './'; import { ICompany, ICompanyDocument } from './definitions/companies'; import { ICustomer, ICustomerDocument } from './definitions/customers'; @@ -56,7 +57,7 @@ export const bulkInsert = async (params: { failed: 0, }; - const { MAX_IMPORT_SIZE = 600 } = process.env; + const MAX_IMPORT_SIZE = Number(getEnv({ name: 'MAX_IMPORT_SIZE', defaultValue: '600' })); if (fieldValues.length > MAX_IMPORT_SIZE) { return [`You can only import max ${MAX_IMPORT_SIZE} at a time`]; diff --git a/src/index.ts b/src/index.ts index a9ba77b49..7662ee7fc 100755 --- a/src/index.ts +++ b/src/index.ts @@ -13,7 +13,7 @@ import resolvers from './data/resolvers'; import { handleEngageUnSubscribe } from './data/resolvers/mutations/engageUtils'; import { pubsub } from './data/resolvers/subscriptions'; import typeDefs from './data/schema'; -import { checkFile, importXlsFile, uploadFile } from './data/utils'; +import { checkFile, getEnv, importXlsFile, uploadFile } from './data/utils'; import { connect } from './db/connection'; import { Conversations, Customers } from './db/models'; import { init } from './startup'; @@ -22,7 +22,9 @@ import { getAttachment } from './trackers/gmail'; // load environment variables dotenv.config(); -const { NODE_ENV, MAIN_APP_DOMAIN = '', WIDGETS_DOMAIN = '' } = process.env; +const NODE_ENV = getEnv({ name: 'NODE_ENV' }); +const MAIN_APP_DOMAIN = getEnv({ name: 'MAIN_APP_DOMAIN', defaultValue: '' }); +const WIDGETS_DOMAIN = getEnv({ name: 'WIDGETS_DOMAIN', defaultValue: '' }); // connect to mongo database connect(); @@ -30,7 +32,11 @@ connect(); const app = express(); app.use(bodyParser.urlencoded({ extended: true })); -app.use(bodyParser.json()); +app.use( + bodyParser.json({ + limit: '10mb', + }), +); app.use(cookieParser()); const corsOptions = { @@ -59,6 +65,9 @@ if (NODE_ENV !== 'production') { }; } +const clients: string[] = []; +const connectedClients: string[] = []; + const apolloServer = new ApolloServer({ typeDefs, resolvers, @@ -84,25 +93,36 @@ const apolloServer = new ApolloServer({ const customerId = webSocket.messengerData.customerId; - // mark as online - await Customers.markCustomerAsActive(customerId); - - // customer has joined + time - const conversationMessages = await Conversations.changeCustomerStatus('joined', customerId, integrationId); + if (!connectedClients.includes(customerId)) { + connectedClients.push(customerId); + } - for (const _message of conversationMessages) { - pubsub.publish('conversationMessageInserted', { - conversationMessageInserted: _message, + // Waited for 5 seconds to reconnect in disconnect hook and disconnect hook + // removed this customer from connected clients list. So it means this customer + // is back online + if (!clients.includes(customerId)) { + clients.push(customerId); + + // mark as online + await Customers.markCustomerAsActive(customerId); + + // customer has joined + time + const conversationMessages = await Conversations.changeCustomerStatus('joined', customerId, integrationId); + + for (const _message of conversationMessages) { + pubsub.publish('conversationMessageInserted', { + conversationMessageInserted: _message, + }); + } + + // notify as connected + pubsub.publish('customerConnectionChanged', { + customerConnectionChanged: { + _id: customerId, + status: 'connected', + }, }); } - - // notify as connected - pubsub.publish('customerConnectionChanged', { - customerConnectionChanged: { - _id: customerId, - status: 'connected', - }, - }); } }); }, @@ -114,24 +134,39 @@ const apolloServer = new ApolloServer({ const customerId = messengerData.customerId; const integrationId = messengerData.integrationId; - // mark as offline - await Customers.markCustomerAsNotActive(customerId); + // Temporarily marking as disconnected + // If client refreshes his browser, It will trigger disconnect, connect hooks. + // So to determine this issue. We are marking as disconnected here and waiting + // for 5 seconds to reconnect. + connectedClients.splice(connectedClients.indexOf(customerId), 1); + + setTimeout(async () => { + if (connectedClients.includes(customerId)) { + return; + } + + clients.splice(clients.indexOf(customerId), 1); - // customer has left + time - const conversationMessages = await Conversations.changeCustomerStatus('left', customerId, integrationId); + // mark as offline + await Customers.markCustomerAsNotActive(customerId); - for (const message of conversationMessages) { - pubsub.publish('conversationMessageInserted', { - conversationMessageInserted: message, + // customer has left + time + const conversationMessages = await Conversations.changeCustomerStatus('left', customerId, integrationId); + + for (const message of conversationMessages) { + pubsub.publish('conversationMessageInserted', { + conversationMessageInserted: message, + }); + } + + // notify as disconnected + pubsub.publish('customerConnectionChanged', { + customerConnectionChanged: { + _id: customerId, + status: 'disconnected', + }, }); - } - // notify as disconnected - pubsub.publish('customerConnectionChanged', { - customerConnectionChanged: { - _id: customerId, - status: 'disconnected', - }, - }); + }, 10000); } }, }, @@ -194,18 +229,18 @@ app.post('/import-file', (req: any, res) => { // get gmail attachment file app.get('/read-gmail-attachment', async (req: any, res) => { if (!req.query.message || !req.query.attach) { - return res.status(404).send('Not found'); + return res.status(404).send('Attachment not found'); } const attachment: { filename?: string; data?: string } = await getAttachment(req.query.message, req.query.attach); if (!attachment.data) { - return res.status(404).send('Not found'); + return res.status(404).send('Attachment not found'); } res.attachment(attachment.filename); - - return res.send(Buffer.from(attachment.data, 'base64')); + res.write(attachment.data, 'base64'); + res.end(); }); // engage unsubscribe @@ -227,7 +262,7 @@ apolloServer.applyMiddleware({ app, path: '/graphql', cors: corsOptions }); const httpServer = createServer(app); // subscriptions server -const { PORT } = process.env; +const PORT = getEnv({ name: 'PORT' }); apolloServer.installSubscriptionHandlers(httpServer); diff --git a/src/private/emailTemplates/base.html b/src/private/emailTemplates/base.html index 32fd235b4..a0dedf133 100644 --- a/src/private/emailTemplates/base.html +++ b/src/private/emailTemplates/base.html @@ -1,164 +1,163 @@ - - - - - - - - - + + - - - + + + - + + - -
-
- - - - - - -
-
- - - - - - -
- - - - - - -
-
-
-
-
-
- - - - - - -
{{{ content }}}
-
+ +
+
+ + + + + + +
+ {{{ content }}} +
+
-
- - - - - - -
- -
-
+ -
- - - - - - -
-
- - - - - - -
-
-

- {{{ signature }}} -

-
-
-
-
+
+ + + + + + +
+
+ + + + + + +
+
+

+ {{{ signature }}} +

+
+
+
+
+
-
- - + diff --git a/src/private/emailTemplates/conversationCron.html b/src/private/emailTemplates/conversationCron.html index d266abd93..7af9d406f 100644 --- a/src/private/emailTemplates/conversationCron.html +++ b/src/private/emailTemplates/conversationCron.html @@ -2,66 +2,66 @@ - - - - - - - + + + - + -
- -
- - - - - + + -
- -
- - - - - - -
-
- {{brand.name}} -
-
-
- -
+
-
- - - - - - -
- -
+
- - - -
- - - - - -
- - - - - -
-

{{{question.content}}}

-
- - - - - - - - -
- {{customer.name}} - {{question.createdAt}}
-
- {{#each answers}} - - - + + +
- - - -
-

{{{content}}}

-
+
+ + + + + +
+ + + + + + +
+

{{{question.content}}}

+
- - - - +
{{createdAt}}
+ + + + + + +
+ {{customer.name}} + {{question.createdAt}}
+
+ {{#each answers}} + + + +
+ + + + +
+

{{{content}}}

+
-
- {{user.details.fullName}} -
+ + + - + + + + +
{{createdAt}}
+ {{user.details.fullName}} +
+
+ +
+ {{/each}} +
-
- -
- {{/each}} - - - -
- - - - - + + + +
- - -
- - - - - - -
- - - -
+
- -
+ + + + + + + + + + + + + +
+ +
+ +
+ Copyright © 2019 + erxes Inc. All rights + reserved. +
+ - + \ No newline at end of file diff --git a/src/private/emailTemplates/conversationDetail.html b/src/private/emailTemplates/conversationDetail.html index fa468c575..257d88a72 100644 --- a/src/private/emailTemplates/conversationDetail.html +++ b/src/private/emailTemplates/conversationDetail.html @@ -1,20 +1,153 @@ - +
- + + +
+
-

- {{ conversationDetail.title }} -
+

+ + + + + + + + +
+ +
+
+ +

+ {{ conversationDetail.title }}

-

- {{#each conversationDetail.messages}} -

{{{ content }}}

+
+

{{#each conversationDetail.messages}}

+

{{{ content }}}

{{/each}} -

-

{{conversationDetail.date}}

+
+ +

{{ conversationDetail.date }}

+
+
+ + + + + + + + + + +
+
+ +
+ Copyright © 2019 + erxes Inc. All rights + reserved. +
diff --git a/src/private/emailTemplates/invitation.html b/src/private/emailTemplates/invitation.html index 5640aaefb..16ce9a469 100644 --- a/src/private/emailTemplates/invitation.html +++ b/src/private/emailTemplates/invitation.html @@ -1,18 +1,152 @@ - +
+ + +
+
+ + + + + + + + +
+ +
+
+

- Username: {{ username }} -
+ Username: {{ username }}

- Password: {{ password }} -
+ Password: {{ password }}

+ + + + + + + + + + + + + +
+ +
+ +
+ Copyright © 2019 + erxes Inc. All rights + reserved. +
diff --git a/src/private/emailTemplates/notification.html b/src/private/emailTemplates/notification.html index d55833e79..4d340c919 100644 --- a/src/private/emailTemplates/notification.html +++ b/src/private/emailTemplates/notification.html @@ -1,22 +1,69 @@ - +
-
+
-

- {{ notification.title }} -
-

-

- {{{ notification.content }}} +

+ + + + + + + + +
+ +
+
+ +

+ {{ notification.title }}

-

{{notification.date}}

+

{{{ notification.content }}}

+

{{ notification.date }}

- +
- @@ -28,3 +75,108 @@
- + + View notification
+ + + + + + + + + + + + + +
+ +
+ +
+ Copyright © 2019 + erxes Inc. All rights + reserved. +
diff --git a/src/private/emailTemplates/resetPassword.html b/src/private/emailTemplates/resetPassword.html index e5ad978b5..cc39b81c3 100644 --- a/src/private/emailTemplates/resetPassword.html +++ b/src/private/emailTemplates/resetPassword.html @@ -1,18 +1,60 @@ - +
- + + +
+
-

- You recently requested a password reset. Click the link below to continue. -
+

+ + + + + + + + +
+ +
+
+

+ You recently requested a password reset. Click the link below to continue.

- +
- @@ -20,12 +62,118 @@
- + + Reset password
-

-

or click here
- {{{ content }}} -

+

+
or click the link below
+ {{{ content }}} +

+
+
+
+ + + + + + + + + + +
+
+ +
+ Copyright © 2019 + erxes Inc. All rights + reserved. +
diff --git a/src/private/emailTemplates/unsubscribe.html b/src/private/emailTemplates/unsubscribe.html index 37a7e7bf9..187dcdb9a 100644 --- a/src/private/emailTemplates/unsubscribe.html +++ b/src/private/emailTemplates/unsubscribe.html @@ -1,26 +1,142 @@ - + - - - + + + -
- -

Unsubscribe Successful

-
You will no longer receive email from erxes.
- « return to our website -
- \ No newline at end of file +
+ +

Unsubscribe Successful

+
+ You will no longer receive email from erxes. +
+ « return to our website +
+ + + + + + + + + + + + + +
+ +
+ +
+ Copyright © 2019 + erxes Inc. All + rights reserved. +
+ diff --git a/src/private/emailTemplates/userInvitation.html b/src/private/emailTemplates/userInvitation.html new file mode 100644 index 000000000..f8d298b26 --- /dev/null +++ b/src/private/emailTemplates/userInvitation.html @@ -0,0 +1,264 @@ + + + + + + + + + + + +
+
+ + + + + + +
+
+ + + + + + + + +
+ +
+
+
+
+
+ + + + + + +
+
+

+ You have been invited to become team member of + {{{ domain }}}. + Click the button below to continue.
+

+
+ + Click here + +
+
+ Or paste this link
+ {{ content }} +
+
+
+ + + + + + + + + + + + + +
+ +
+ +
+ Copyright © 2019 + erxes Inc. All + rights reserved. +
+
+ diff --git a/src/setupTests.ts b/src/setupTests.ts index da8e75475..d6124435e 100644 --- a/src/setupTests.ts +++ b/src/setupTests.ts @@ -1,12 +1,13 @@ import * as dotenv from 'dotenv'; import mongoose = require('mongoose'); +import { getEnv } from './data/utils'; mongoose.Promise = global.Promise; // load environment variables dotenv.config(); -const { TEST_MONGO_URL = 'mongodb://localhost/test' } = process.env; +const TEST_MONGO_URL = getEnv({ name: 'TEST_MONGO_URL' }); // prevent deprecated warning related findAndModify // https://github.com/Automattic/mongoose/issues/6880 diff --git a/src/startup.ts b/src/startup.ts index 69ac37737..824c35d97 100644 --- a/src/startup.ts +++ b/src/startup.ts @@ -1,15 +1,16 @@ import * as dotenv from 'dotenv'; import * as fs from 'fs'; import './cronJobs'; -import { listenChangeConversation } from './db/listener'; +import { getEnv } from './data/utils'; +import { listen } from './db/listeners'; import { trackEngages } from './trackers/engageTracker'; import { trackFbLogin, trackIntegrations as trackFacebooks } from './trackers/facebookTracker'; -import { trackGmail } from './trackers/gmailTracker'; +import { trackGmail, trackGmailLogin } from './trackers/gmailTracker'; import { trackIntegrations as trackTwitters } from './trackers/twitterTracker'; dotenv.config(); -const { USE_REPLICATION } = process.env; +const USE_REPLICATION = getEnv({ name: 'USE_REPLICATION', defaultValue: 'false' }); export const init = async app => { const makeDirs = () => { @@ -20,17 +21,22 @@ export const init = async app => { } }; - trackTwitters(); - trackEngages(app); - trackFacebooks(app); - trackGmail(); - trackFbLogin(app); + try { + trackTwitters(); + trackEngages(app); + trackFacebooks(app); + trackGmail(); + trackFbLogin(app); + trackGmailLogin(app); + } catch (e) { + console.log(e.toString()); + } /* USE_REPLICATION=true means we are using replicaset, so we can * use Collection.watch */ if (USE_REPLICATION === 'true') { - listenChangeConversation(); + listen(); } makeDirs(); diff --git a/src/trackers/engageTracker.ts b/src/trackers/engageTracker.ts index 957bcae33..c806b092e 100644 --- a/src/trackers/engageTracker.ts +++ b/src/trackers/engageTracker.ts @@ -1,8 +1,11 @@ import * as AWS from 'aws-sdk'; +import { getEnv } from '../data/utils'; import { EngageMessages } from '../db/models'; export const getApi = (type: string): any => { - const { AWS_SES_ACCESS_KEY_ID, AWS_SES_SECRET_ACCESS_KEY, AWS_REGION } = process.env; + const AWS_SES_ACCESS_KEY_ID = getEnv({ name: 'AWS_SES_ACCESS_KEY_ID' }); + const AWS_SES_SECRET_ACCESS_KEY = getEnv({ name: 'AWS_SES_SECRET_ACCESS_KEY' }); + const AWS_REGION = getEnv({ name: 'AWS_REGION' }); AWS.config.update({ accessKeyId: AWS_SES_ACCESS_KEY_ID, diff --git a/src/trackers/facebook.ts b/src/trackers/facebook.ts index 2d244897b..a942915dd 100755 --- a/src/trackers/facebook.ts +++ b/src/trackers/facebook.ts @@ -1,4 +1,4 @@ -import { Accounts, ActivityLogs, ConversationMessages, Conversations, Customers, Integrations } from '../db/models'; +import { Accounts, ConversationMessages, Conversations, Customers, Integrations } from '../db/models'; import { publishClientMessage, publishMessage } from '../data/resolvers/mutations/conversations'; @@ -9,6 +9,7 @@ import { INTEGRATION_KIND_CHOICES, } from '../data/constants'; +import { getEnv } from '../data/utils'; import { IFacebook as IMsgFacebook, IFbUser, IMessageDocument } from '../db/models/definitions/conversationMessages'; import { IConversationDocument, IFacebook } from '../db/models/definitions/conversations'; import { ICustomerDocument } from '../db/models/definitions/customers'; @@ -16,7 +17,7 @@ import { IIntegrationDocument } from '../db/models/definitions/integrations'; import { findPostComments, graphRequest, IComments, IPost } from './facebookTracker'; interface IPostParams { - created_time?: string; + created_time?: number; post_id?: string; video_id?: string; link?: string; @@ -36,7 +37,7 @@ interface IPostParams { interface ICommentParams { post_id: string; - created_time?: string; + created_time?: number; parent_id?: string; item?: string; comment_id?: string; @@ -194,7 +195,7 @@ export class SaveWebhookResponse { } if (created_time) { - doc.createdTime = created_time; + doc.createdTime = (created_time * 1000).toString(); } return doc; @@ -225,7 +226,7 @@ export class SaveWebhookResponse { } if (created_time) { - doc.createdTime = created_time; + doc.createdTime = (created_time * 1000).toString(); } // Counting post comments only @@ -352,9 +353,6 @@ export class SaveWebhookResponse { pageId: this.currentPageId, }, }); - - // Creating conversation created activity log for customer - await ActivityLogs.createConversationLog(conversation, customer); } else { conversation = await Conversations.reopen(conversation._id); } @@ -594,9 +592,6 @@ export class SaveWebhookResponse { }, }); - // create log - await ActivityLogs.createCustomerRegistrationLog(createdCustomer); - return createdCustomer; } @@ -726,7 +721,7 @@ export class SaveWebhookResponse { senderId: comment.from.id, senderName: comment.from.name, parentId: comment.parent && comment.parent.id, - createdTime: comment.created_time, + createdTime: (comment.created_time || 0 * 1000).toString(), commentCount: comment.comments.summary.total_count, }, restoring: true, @@ -867,20 +862,11 @@ export const facebookReply = async ( // save commentId and parentId in message object await ConversationMessages.updateOne({ _id: message._id }, { $set: { facebookData } }); - - // finding parent post and increasing comment count - await ConversationMessages.updateMany( - { - 'facebookData.isPost': true, - conversationId: message.conversationId, - }, - { $inc: { 'facebookData.commentCount': 1 } }, - ); } }; export const getConfig = () => { - const { FACEBOOK } = process.env; + const FACEBOOK = getEnv({ name: 'FACEBOOK' }); if (!FACEBOOK) { throw new Error("getConfig: Couldn't get facebook config"); diff --git a/src/trackers/facebookTracker.ts b/src/trackers/facebookTracker.ts index 80af90757..d25cc3542 100644 --- a/src/trackers/facebookTracker.ts +++ b/src/trackers/facebookTracker.ts @@ -1,4 +1,5 @@ import * as graph from 'fbgraph'; +import { getEnv } from '../data/utils'; import { Accounts } from '../db/models'; import { IFbUser } from '../db/models/definitions/conversationMessages'; import { receiveWebhookResponse } from './facebook'; @@ -37,7 +38,7 @@ export const graphRequest = { * Listen for facebook webhook response */ export const trackIntegrations = expressApp => { - const { FACEBOOK_APP_ID } = process.env; + const FACEBOOK_APP_ID = getEnv({ name: 'FACEBOOK_APP_ID' }); expressApp.get(`/service/facebook/${FACEBOOK_APP_ID}/webhook-callback`, (req, res) => { const query = req.query; @@ -65,12 +66,19 @@ export const trackIntegrations = expressApp => { export const trackFbLogin = expressApp => { expressApp.get('/fblogin', (req, res) => { - const { FACEBOOK_APP_ID, FACEBOOK_APP_SECRET, DOMAIN, MAIN_APP_DOMAIN, FACEBOOK_PERMISSIONS } = process.env; + const FACEBOOK_APP_ID = getEnv({ name: 'FACEBOOK_APP_ID' }); + const FACEBOOK_APP_SECRET = getEnv({ name: 'FACEBOOK_APP_SECRET' }); + const DOMAIN = getEnv({ name: 'DOMAIN' }); + const MAIN_APP_DOMAIN = getEnv({ name: 'MAIN_APP_DOMAIN' }); + const FACEBOOK_PERMISSIONS = getEnv({ + name: 'FACEBOOK_PERMISSIONS', + defaultValue: 'manage_pages, pages_show_list, pages_messaging', + }); const conf = { client_id: FACEBOOK_APP_ID, client_secret: FACEBOOK_APP_SECRET, - scope: FACEBOOK_PERMISSIONS || 'manage_pages, pages_show_list, pages_messaging', + scope: FACEBOOK_PERMISSIONS, redirect_uri: `${DOMAIN}/fblogin`, }; @@ -113,12 +121,18 @@ export const trackFbLogin = expressApp => { const name = `${userAccount.first_name} ${userAccount.last_name}`; - await Accounts.createAccount({ - token: access_token, - name, - kind: 'facebook', - uid: userAccount.id, - }); + const account = await Accounts.findOne({ uid: userAccount.id }); + + if (account) { + await Accounts.updateOne({ _id: account._id }, { $set: { token: access_token } }); + } else { + await Accounts.createAccount({ + token: access_token, + name, + kind: 'facebook', + uid: userAccount.id, + }); + } return res.redirect(`${MAIN_APP_DOMAIN}/settings/integrations?fbAuthorized=true`); }, @@ -150,7 +164,7 @@ export interface IComment { from: { name: string; id: string }; message: string; attachment_url: string; - created_time?: string; + created_time?: number; summary: ISummary; comments: IComments; } diff --git a/src/trackers/gmail.ts b/src/trackers/gmail.ts index 486144c4b..993963ff2 100644 --- a/src/trackers/gmail.ts +++ b/src/trackers/gmail.ts @@ -4,30 +4,10 @@ import { Accounts, ConversationMessages, Conversations, Customers, Integrations import { IGmail as IMsgGmail } from '../db/models/definitions/conversationMessages'; import { IConversationDocument } from '../db/models/definitions/conversations'; import { ICustomerDocument } from '../db/models/definitions/customers'; +import { IUserDocument } from '../db/models/definitions/users'; +import EmailDeliveries from '../db/models/EmailDeliveries'; import { utils } from './gmailTracker'; - -interface IAttachmentParams { - data: string; - filename: string; - size: number; - mimeType: string; -} - -interface IMailParams { - integrationId: string; - cocType: string; - cocId: string; - subject: string; - body: string; - toEmails: string; - cc?: string; - bcc?: string; - attachments?: IAttachmentParams[]; - references?: string; - headerId?: string; - threadId?: string; - fromEmail?: string; -} +import { IMailParams } from './types'; /** * Create string sequence that generates email body encrypted to base64 @@ -91,9 +71,21 @@ const encodeEmail = async (params: IMailParams) => { /** * Send email & create activiy log with gmail kind */ -export const sendGmail = async (mailParams: IMailParams) => { - const { integrationId, threadId } = mailParams; +export const sendGmail = async (mailParams: IMailParams, user: IUserDocument) => { + let totalSize = 0; + // 10mb + const limit = 1000000 * 10; + if (mailParams.attachments) { + for (const attach of mailParams.attachments) { + totalSize += attach.size; + + if (attach.size > limit || totalSize > limit) { + throw new Error(`${attach.filename} file size exceeded`); + } + } + } + const { integrationId, threadId } = mailParams; const integration = await Integrations.findOne({ _id: integrationId }); if (!integration || !integration.gmailData) { @@ -103,10 +95,27 @@ export const sendGmail = async (mailParams: IMailParams) => { const credentials = await Accounts.getGmailCredentials(integration.gmailData.email); const fromEmail = integration.gmailData.email; + + // save delivered email + const emailDelivery = { + fromEmail, + cocType: mailParams.cocType, + cocId: mailParams.cocId || '', + subject: mailParams.subject, + body: mailParams.body, + toEmails: mailParams.toEmails, + cc: mailParams.cc, + bcc: mailParams.bcc, + attachments: mailParams.attachments, + userId: user._id, + }; + + await EmailDeliveries.createEmailDelivery(emailDelivery); + // get raw string encrypted by base64 const raw = await encodeEmail({ fromEmail, ...mailParams }); - await utils.sendEmail(credentials, raw, threadId); + await utils.sendEmail(integration._id, credentials, raw, threadId); return { status: 200, statusText: 'ok ' }; }; @@ -128,7 +137,7 @@ export const mapHeaders = (headers: any) => { /** * Get headers specific values from gmail.users.messages.get response */ -const getHeaderProperties = (headers: any, messageId: string, threadId: string) => { +const getHeaderProperties = (headers: any, messageId: string, threadId: string, labelIds: string[]) => { return { subject: headers.subject, from: headers.from, @@ -140,6 +149,7 @@ const getHeaderProperties = (headers: any, messageId: string, threadId: string) reply: headers['in-reply-to'], messageId, threadId, + labelIds, }; }; @@ -184,14 +194,14 @@ const getBodyProperties = (headers: any, part: any, gmailData: IMsgGmail) => { * Parse result of users.messages.get response */ export const parseMessage = (response: any) => { - const { id, threadId, payload } = response; + const { id, threadId, payload, labelIds } = response; - if (!payload) { + if (!payload || labelIds.includes('TRASH') || labelIds.includes('DRAFT')) { return; } let headers = mapHeaders(payload.headers); - let gmailData: IMsgGmail = getHeaderProperties(headers, id, threadId); + let gmailData: IMsgGmail = getHeaderProperties(headers, id, threadId, labelIds); let parts = [payload]; let firstPartProcessed = false; @@ -343,6 +353,7 @@ const getOrCreateConversation = async ( if (conversation) { conversation.status = CONVERSATION_STATUSES.OPEN; conversation.content = content; + conversation.readUserIds = []; await conversation.save(); return conversation; } @@ -372,10 +383,10 @@ export const syncConversation = async (integrationId: string, gmailData: IMsgGma throw new Error('Empty gmail data'); } - // check if message has arrived true return previous message instance + // check if message exists const prevMessage = await ConversationMessages.findOne({ 'gmailData.messageId': messageId, - }).sort({ createdAt: -1 }); + }); if (prevMessage) { return prevMessage; @@ -430,10 +441,48 @@ export const getAttachment = async (conversationMessageId: string, attachmentId: */ export const updateHistoryId = async integration => { const credentials = await Accounts.getGmailCredentials(integration.gmailData.email); - const { data } = await utils.callWatch(credentials); + const { data } = await utils.callWatch(credentials, integration._id); integration.gmailData.historyId = data.historyId; integration.gmailData.expiration = data.expiration; await integration.save(); }; + +/* + * store the historyId of the most recent message (the first message in the list response) for future partial synchronization + */ +export const updateHistoryByLastReceived = async (integrationId: string, historyId: string) => { + const integration = await Integrations.findOne({ _id: integrationId }); + + if (!integration || !integration.gmailData) { + throw new Error(`Integration not found id with ${integrationId}`); + } + + integration.gmailData.historyId = historyId; + await integration.save(); +}; + +/* + * refresh token and save when access_token expires + */ +export const refreshAccessToken = async (integrationId: string, tokens: any) => { + const integration = await Integrations.findOne({ _id: integrationId }); + if (!integration || !integration.gmailData) { + throw new Error(`Integration not found id with ${integrationId}`); + } + const account = await Accounts.findOne({ _id: integration.gmailData.accountId }); + if (!account) { + throw new Error(`Account not found id with ${integration.gmailData.accountId}`); + } + + account.token = tokens.access_token; + if (tokens.refresh_token) { + account.tokenSecret = tokens.refresh_token; + } + + if (tokens.expiry_date) { + account.expireDate = tokens.expiry_date; + } + await account.save(); +}; diff --git a/src/trackers/gmailTracker.ts b/src/trackers/gmailTracker.ts index 1c308d70f..070a57ce6 100644 --- a/src/trackers/gmailTracker.ts +++ b/src/trackers/gmailTracker.ts @@ -1,14 +1,75 @@ import * as PubSub from '@google-cloud/pubsub'; import { google } from 'googleapis'; +import { getEnv } from '../data/utils'; +import { Accounts } from '../db/models'; import { IGmail as IMsgGmail } from '../db/models/definitions/conversationMessages'; -import { getGmailUpdates, parseMessage, syncConversation } from './gmail'; -import { getOauthClient } from './googleTracker'; +import { + getGmailUpdates, + parseMessage, + refreshAccessToken, + syncConversation, + updateHistoryByLastReceived, +} from './gmail'; +import { getAccessToken, getAuthorizeUrl, getOauthClient } from './googleTracker'; + +export const trackGmailLogin = expressApp => { + expressApp.get('/gmailLogin', async (req, res) => { + // we don't have a code yet + // so we'll redirect to the oauth dialog + if (!req.query.code) { + if (!req.query.error) { + return res.redirect(getAuthorizeUrl()); + } + + return res.send('access denied'); + } + + const credentials: any = await getAccessToken(req.query.code); + + if (!credentials.refresh_token) { + return res.send('You must remove Erxes from your gmail apps before reconnecting this account.'); + } + + // get email address connected with + const { data } = await getGmailUserProfile(credentials); + const email = data.emailAddress || ''; + + await Accounts.createAccount({ + name: email, + uid: email, + kind: 'gmail', + token: credentials.access_token, + tokenSecret: credentials.refresh_token, + expireDate: credentials.expiry_date, + scope: credentials.scope, + }); + + const MAIN_APP_DOMAIN = getEnv({ name: 'MAIN_APP_DOMAIN' }); + + return res.redirect(`${MAIN_APP_DOMAIN}/settings/integrations?gmailAuthorized=true`); + }); +}; +/** + * Get auth with valid credentials + */ +const getOAuth = (integrationId: string, credentials: any) => { + const auth = getOauthClient(); + + // Access tokens expire. This library will automatically use a refresh token to obtain a new access token + auth.on('tokens', async tokens => { + await refreshAccessToken(integrationId, tokens); + credentials = tokens; + }); + + auth.setCredentials(credentials); + return auth; +}; /** * Get permission granted email information */ export const getGmailUserProfile = (credentials: any) => { - const auth = getOauthClient('gmail'); + const auth = getOauthClient(); auth.setCredentials(credentials); @@ -22,10 +83,8 @@ export const getGmailUserProfile = (credentials: any) => { /** * Send email */ -const sendEmail = (credentials: any, raw: string, threadId?: string) => { - const auth = getOauthClient('gmail'); - - auth.setCredentials(credentials); +const sendEmail = (integrationId: string, credentials: any, raw: string, threadId?: string) => { + const auth = getOAuth(integrationId, credentials); const gmail = google.gmail('v1'); const data = { @@ -59,7 +118,7 @@ const getGmailAttachment = async (credentials: any, gmailData: IMsgGmail, attach const { messageId } = gmailData; const gmail = await google.gmail('v1'); - const auth = getOauthClient('gmail'); + const auth = getOauthClient(); auth.setCredentials(credentials); @@ -85,11 +144,8 @@ const getGmailAttachment = async (credentials: any, gmailData: IMsgGmail, attach * Get new messages by stored history id */ const getMessagesByHistoryId = async (historyId: string, integrationId: string, credentials: any) => { - const auth = getOauthClient('gmail'); - - auth.setCredentials(credentials); - - const gmail = await google.gmail('v1'); + const auth = getOAuth(integrationId, credentials); + const gmail = google.gmail('v1'); const response = await gmail.users.history.list({ auth, @@ -106,20 +162,29 @@ const getMessagesByHistoryId = async (historyId: string, integrationId: string, continue; } + await updateHistoryByLastReceived(integrationId, '' + history.id); + for (const message of history.messages) { - const { data } = await gmail.users.messages.get({ - auth, - userId: 'me', - id: message.id, - }); - // get gmailData - const gmailData = await parseMessage(data); - - if (!gmailData) { - throw new Error('Couldn`t parse users.messages.get response'); + try { + const { data } = await gmail.users.messages.get({ + auth, + userId: 'me', + id: message.id, + }); + + // get gmailData + const gmailData = await parseMessage(data); + if (gmailData) { + await syncConversation(integrationId, gmailData); + } + } catch (e) { + // catch & continue if email doesn't exist with message.id + if (e.message === 'Not Found') { + console.log(`Email not found id with ${message.id}`); + } else { + console.log(e.message); + } } - - await syncConversation(integrationId, gmailData); } } }; @@ -128,9 +193,12 @@ const getMessagesByHistoryId = async (historyId: string, integrationId: string, * Listening email that connected with */ export const trackGmail = async () => { - const { GOOGLE_APPLICATION_CREDENTIALS, GOOGLE_TOPIC, GOOGLE_SUPSCRIPTION_NAME, GOOGLE_PROJECT_ID } = process.env; + const GOOGLE_APPLICATION_CREDENTIALS = getEnv({ name: 'GOOGLE_APPLICATION_CREDENTIALS' }); + const GOOGLE_TOPIC = getEnv({ name: 'GOOGLE_TOPIC' }); + const GOOGLE_SUBSCRIPTION_NAME = getEnv({ name: 'GOOGLE_SUBSCRIPTION_NAME' }); + const GOOGLE_PROJECT_ID = getEnv({ name: 'GOOGLE_PROJECT_ID' }); - if (!GOOGLE_APPLICATION_CREDENTIALS || !GOOGLE_PROJECT_ID || !GOOGLE_TOPIC || !GOOGLE_SUPSCRIPTION_NAME) { + if (!GOOGLE_APPLICATION_CREDENTIALS || !GOOGLE_TOPIC || !GOOGLE_SUBSCRIPTION_NAME || !GOOGLE_PROJECT_ID) { return; } @@ -141,7 +209,7 @@ export const trackGmail = async () => { const topic = pubsubClient.topic(GOOGLE_TOPIC); - topic.createSubscription(GOOGLE_SUPSCRIPTION_NAME, (error, subscription) => { + topic.createSubscription(GOOGLE_SUBSCRIPTION_NAME, (error, subscription) => { if (error) { throw error; } @@ -161,7 +229,7 @@ export const trackGmail = async () => { } // All notifications need to be acknowledged as per the Cloud Pub/Sub - message.ack(); + await message.ack(); }; subscription.on('error', errorHandler); @@ -169,17 +237,26 @@ export const trackGmail = async () => { }); }; -export const callWatch = (credentials: any) => { - const auth = getOauthClient('gmail'); +export const callWatch = (credentials: any, integrationId: string) => { const gmail: any = google.gmail('v1'); - const { GOOGLE_TOPIC } = process.env; - - auth.setCredentials(credentials); + const GOOGLE_TOPIC = getEnv({ name: 'GOOGLE_TOPIC' }); + const auth = getOAuth(integrationId, credentials); return gmail.users .watch({ auth, userId: 'me', + labelIds: [ + 'CATEGORY_UPDATES', + 'DRAFT', + 'CATEGORY_PROMOTIONS', + 'CATEGORY_SOCIAL', + 'CATEGORY_FORUMS', + 'TRASH', + 'CHAT', + 'SPAM', + ], + labelFilterAction: 'exclude', requestBody: { topicName: GOOGLE_TOPIC, }, @@ -190,7 +267,7 @@ export const callWatch = (credentials: any) => { }; export const stopReceivingEmail = (email: string, credentials: any) => { - const auth = getOauthClient('gmail'); + const auth = getOauthClient(); const gmail: any = google.gmail('v1'); auth.setCredentials(credentials); @@ -203,6 +280,7 @@ export const stopReceivingEmail = (email: string, credentials: any) => { export const utils = { getMessagesByHistoryId, + getGmailUserProfile, getGmailAttachment, sendEmail, stopReceivingEmail, diff --git a/src/trackers/googleTracker.ts b/src/trackers/googleTracker.ts index 27af3a501..5272b92b9 100644 --- a/src/trackers/googleTracker.ts +++ b/src/trackers/googleTracker.ts @@ -1,42 +1,42 @@ import { google } from 'googleapis'; +import { getEnv } from '../data/utils'; -const SCOPES_CALENDAR = ['https://www.googleapis.com/auth/calendar']; const SCOPES_GMAIL = [ 'https://mail.google.com/', 'https://www.googleapis.com/auth/gmail.modify', 'https://www.googleapis.com/auth/gmail.compose', 'https://www.googleapis.com/auth/gmail.send', 'https://www.googleapis.com/auth/gmail.readonly', + 'https://www.googleapis.com/auth/calendar', ]; -export const getOauthClient = (service?: string) => { - const { GOOGLE_CLIENT_ID, GOOGLE_CLIENT_SECRET, GOOGLE_REDIRECT_URI, GMAIL_REDIRECT_URL } = process.env; +export const getOauthClient = () => { + const GOOGLE_CLIENT_ID = getEnv({ name: 'GOOGLE_CLIENT_ID' }); + const GOOGLE_CLIENT_SECRET = getEnv({ name: 'GOOGLE_CLIENT_SECRET' }); + const GMAIL_REDIRECT_URL = getEnv({ name: 'GMAIL_REDIRECT_URL' }); - const redirectUrl = service === 'gmail' ? GMAIL_REDIRECT_URL : GOOGLE_REDIRECT_URI; - - return new google.auth.OAuth2(GOOGLE_CLIENT_ID, GOOGLE_CLIENT_SECRET, redirectUrl); + return new google.auth.OAuth2(GOOGLE_CLIENT_ID, GOOGLE_CLIENT_SECRET, GMAIL_REDIRECT_URL); }; /** * Get auth url defends on google services such us gmail, calendar */ -export const getAuthorizeUrl = (service?: string) => { - const oauthClient = getOauthClient(service); - const scopes = service === 'gmail' ? SCOPES_GMAIL : SCOPES_CALENDAR; +export const getAuthorizeUrl = () => { + const oauthClient = getOauthClient(); return oauthClient.generateAuthUrl({ access_type: 'offline', - scope: scopes, + scope: SCOPES_GMAIL, }); }; -export const getAccessToken = (code: string, service?: string) => { - const oauthClient = getOauthClient(service); +export const getAccessToken = (code: string) => { + const oauthClient = getOauthClient(); return new Promise((resolve, reject) => - oauthClient.getToken(code, (err, token: any) => { + oauthClient.getToken(code, (err: any, token: any) => { if (err) { - return reject(err); + return reject(err.response.data.error); } return resolve(token); @@ -76,3 +76,7 @@ export const createMeetEvent = (credentials, event) => { ); }); }; + +export const googleUtils = { + getAccessToken, +}; diff --git a/src/trackers/twitter.ts b/src/trackers/twitter.ts index ac0abf8fe..54e568d70 100755 --- a/src/trackers/twitter.ts +++ b/src/trackers/twitter.ts @@ -1,6 +1,6 @@ import { CONVERSATION_STATUSES } from '../data/constants'; import { publishMessage } from '../data/resolvers/mutations/conversations'; -import { ActivityLogs, ConversationMessages, Conversations, Customers, Integrations } from '../db/models'; +import { ConversationMessages, Conversations, Customers, Integrations } from '../db/models'; import { IConversationDocument } from '../db/models/definitions/conversations'; import { IIntegrationDocument, ITwitterData } from '../db/models/definitions/integrations'; import { findParentTweets, twitRequest } from './twitterTracker'; @@ -57,9 +57,6 @@ const getOrCreateCustomer = async (integrationId: string, user: any): Promise { - const { TWITTER_CONSUMER_KEY, TWITTER_CONSUMER_SECRET } = process.env; - - if (!TWITTER_CONSUMER_KEY || !TWITTER_CONSUMER_SECRET) { - return; - } + const TWITTER_CONSUMER_KEY = getEnv({ name: 'TWITTER_CONSUMER_KEY' }); + const TWITTER_CONSUMER_SECRET = getEnv({ name: 'TWITTER_CONSUMER_SECRET' }); // Twit instance const twit = new Twit({ @@ -44,7 +42,9 @@ const trackIntegration = (account: IAccountDocument, integration: IIntegrationDo // twitter oauth =============== const getOauth = () => { - const { TWITTER_CONSUMER_KEY, TWITTER_CONSUMER_SECRET, TWITTER_REDIRECT_URL } = process.env; + const TWITTER_CONSUMER_KEY = getEnv({ name: 'TWITTER_CONSUMER_KEY' }); + const TWITTER_CONSUMER_SECRET = getEnv({ name: 'TWITTER_CONSUMER_SECRET' }); + const TWITTER_REDIRECT_URL = getEnv({ name: 'TWITTER_REDIRECT_URL' }); return new OAuth( 'https://api.twitter.com/oauth/request_token', diff --git a/src/trackers/types.ts b/src/trackers/types.ts new file mode 100644 index 000000000..4b57d5eb9 --- /dev/null +++ b/src/trackers/types.ts @@ -0,0 +1,22 @@ +interface IAttachmentParams { + data: string; + filename: string; + size: number; + mimeType: string; +} + +export interface IMailParams { + integrationId: string; + cocType: string; + cocId: string; + subject: string; + body: string; + toEmails: string; + cc?: string; + bcc?: string; + attachments?: IAttachmentParams[]; + references?: string; + headerId?: string; + threadId?: string; + fromEmail?: string; +} diff --git a/start.sh b/start.sh deleted file mode 100755 index 36af83474..000000000 --- a/start.sh +++ /dev/null @@ -1,12 +0,0 @@ -echo "`jo \`env | grep type\` \ - \`env | grep project_id\` \ - \`env | grep private_key_id\` \ - \`env | grep client_email\` \ - \`env | grep client_id\` \ - \`env | grep auth_uri\` \ - \`env | grep token_uri\` \ - \`env | grep auth_provider_x509_cert_url\` \ - \`env | grep client_x509_cert_url\` \ - private_key="$private_key" \ - `" > google_cred.json -yarn start diff --git a/wait-for.sh b/wait-for.sh new file mode 100755 index 000000000..071c2bee3 --- /dev/null +++ b/wait-for.sh @@ -0,0 +1,178 @@ +#!/usr/bin/env bash +# Use this script to test if a given TCP host/port are available + +WAITFORIT_cmdname=${0##*/} + +echoerr() { if [[ $WAITFORIT_QUIET -ne 1 ]]; then echo "$@" 1>&2; fi } + +usage() +{ + cat << USAGE >&2 +Usage: + $WAITFORIT_cmdname host:port [-s] [-t timeout] [-- command args] + -h HOST | --host=HOST Host or IP under test + -p PORT | --port=PORT TCP port under test + Alternatively, you specify the host and port as host:port + -s | --strict Only execute subcommand if the test succeeds + -q | --quiet Don't output any status messages + -t TIMEOUT | --timeout=TIMEOUT + Timeout in seconds, zero for no timeout + -- COMMAND ARGS Execute command with args after the test finishes +USAGE + exit 1 +} + +wait_for() +{ + if [[ $WAITFORIT_TIMEOUT -gt 0 ]]; then + echoerr "$WAITFORIT_cmdname: waiting $WAITFORIT_TIMEOUT seconds for $WAITFORIT_HOST:$WAITFORIT_PORT" + else + echoerr "$WAITFORIT_cmdname: waiting for $WAITFORIT_HOST:$WAITFORIT_PORT without a timeout" + fi + WAITFORIT_start_ts=$(date +%s) + while : + do + if [[ $WAITFORIT_ISBUSY -eq 1 ]]; then + nc -z $WAITFORIT_HOST $WAITFORIT_PORT + WAITFORIT_result=$? + else + (echo > /dev/tcp/$WAITFORIT_HOST/$WAITFORIT_PORT) >/dev/null 2>&1 + WAITFORIT_result=$? + fi + if [[ $WAITFORIT_result -eq 0 ]]; then + WAITFORIT_end_ts=$(date +%s) + echoerr "$WAITFORIT_cmdname: $WAITFORIT_HOST:$WAITFORIT_PORT is available after $((WAITFORIT_end_ts - WAITFORIT_start_ts)) seconds" + break + fi + sleep 1 + done + return $WAITFORIT_result +} + +wait_for_wrapper() +{ + # In order to support SIGINT during timeout: http://unix.stackexchange.com/a/57692 + if [[ $WAITFORIT_QUIET -eq 1 ]]; then + timeout $WAITFORIT_BUSYTIMEFLAG $WAITFORIT_TIMEOUT $0 --quiet --child --host=$WAITFORIT_HOST --port=$WAITFORIT_PORT --timeout=$WAITFORIT_TIMEOUT & + else + timeout $WAITFORIT_BUSYTIMEFLAG $WAITFORIT_TIMEOUT $0 --child --host=$WAITFORIT_HOST --port=$WAITFORIT_PORT --timeout=$WAITFORIT_TIMEOUT & + fi + WAITFORIT_PID=$! + trap "kill -INT -$WAITFORIT_PID" INT + wait $WAITFORIT_PID + WAITFORIT_RESULT=$? + if [[ $WAITFORIT_RESULT -ne 0 ]]; then + echoerr "$WAITFORIT_cmdname: timeout occurred after waiting $WAITFORIT_TIMEOUT seconds for $WAITFORIT_HOST:$WAITFORIT_PORT" + fi + return $WAITFORIT_RESULT +} + +# process arguments +while [[ $# -gt 0 ]] +do + case "$1" in + *:* ) + WAITFORIT_hostport=(${1//:/ }) + WAITFORIT_HOST=${WAITFORIT_hostport[0]} + WAITFORIT_PORT=${WAITFORIT_hostport[1]} + shift 1 + ;; + --child) + WAITFORIT_CHILD=1 + shift 1 + ;; + -q | --quiet) + WAITFORIT_QUIET=1 + shift 1 + ;; + -s | --strict) + WAITFORIT_STRICT=1 + shift 1 + ;; + -h) + WAITFORIT_HOST="$2" + if [[ $WAITFORIT_HOST == "" ]]; then break; fi + shift 2 + ;; + --host=*) + WAITFORIT_HOST="${1#*=}" + shift 1 + ;; + -p) + WAITFORIT_PORT="$2" + if [[ $WAITFORIT_PORT == "" ]]; then break; fi + shift 2 + ;; + --port=*) + WAITFORIT_PORT="${1#*=}" + shift 1 + ;; + -t) + WAITFORIT_TIMEOUT="$2" + if [[ $WAITFORIT_TIMEOUT == "" ]]; then break; fi + shift 2 + ;; + --timeout=*) + WAITFORIT_TIMEOUT="${1#*=}" + shift 1 + ;; + --) + shift + WAITFORIT_CLI=("$@") + break + ;; + --help) + usage + ;; + *) + echoerr "Unknown argument: $1" + usage + ;; + esac +done + +if [[ "$WAITFORIT_HOST" == "" || "$WAITFORIT_PORT" == "" ]]; then + echoerr "Error: you need to provide a host and port to test." + usage +fi + +WAITFORIT_TIMEOUT=${WAITFORIT_TIMEOUT:-15} +WAITFORIT_STRICT=${WAITFORIT_STRICT:-0} +WAITFORIT_CHILD=${WAITFORIT_CHILD:-0} +WAITFORIT_QUIET=${WAITFORIT_QUIET:-0} + +# check to see if timeout is from busybox? +WAITFORIT_TIMEOUT_PATH=$(type -p timeout) +WAITFORIT_TIMEOUT_PATH=$(realpath $WAITFORIT_TIMEOUT_PATH 2>/dev/null || readlink -f $WAITFORIT_TIMEOUT_PATH) +if [[ $WAITFORIT_TIMEOUT_PATH =~ "busybox" ]]; then + WAITFORIT_ISBUSY=1 + WAITFORIT_BUSYTIMEFLAG="-t" + +else + WAITFORIT_ISBUSY=0 + WAITFORIT_BUSYTIMEFLAG="" +fi + +if [[ $WAITFORIT_CHILD -gt 0 ]]; then + wait_for + WAITFORIT_RESULT=$? + exit $WAITFORIT_RESULT +else + if [[ $WAITFORIT_TIMEOUT -gt 0 ]]; then + wait_for_wrapper + WAITFORIT_RESULT=$? + else + wait_for + WAITFORIT_RESULT=$? + fi +fi + +if [[ $WAITFORIT_CLI != "" ]]; then + if [[ $WAITFORIT_RESULT -ne 0 && $WAITFORIT_STRICT -eq 1 ]]; then + echoerr "$WAITFORIT_cmdname: strict mode, refusing to execute subprocess" + exit $WAITFORIT_RESULT + fi + exec "${WAITFORIT_CLI[@]}" +else + exit $WAITFORIT_RESULT +fi diff --git a/yarn.lock b/yarn.lock index a97659432..bbce32d7e 100644 --- a/yarn.lock +++ b/yarn.lock @@ -341,6 +341,16 @@ "@types/express-serve-static-core" "*" "@types/mime" "*" +"@types/strip-bom@^3.0.0": + version "3.0.0" + resolved "https://registry.yarnpkg.com/@types/strip-bom/-/strip-bom-3.0.0.tgz#14a8ec3956c2e81edb7520790aecf21c290aebd2" + integrity sha1-FKjsOVbC6B7bdSB5CuzyHCkK69I= + +"@types/strip-json-comments@0.0.30": + version "0.0.30" + resolved "https://registry.yarnpkg.com/@types/strip-json-comments/-/strip-json-comments-0.0.30.tgz#9aa30c04db212a9a0649d6ae6fd50accc40748a1" + integrity sha512-7NQmHra/JILCd1QqpSzl8+mJRc8ZHz3uDm8YV1Ks9IhK0epEiTw8aIErbvH9PI+6XbqhyIQy3462nEsn7UVzjQ== + "@types/tough-cookie@*": version "2.3.4" resolved "https://registry.yarnpkg.com/@types/tough-cookie/-/tough-cookie-2.3.4.tgz#821878b81bfab971b93a265a561d54ea61f9059f" @@ -440,13 +450,6 @@ ajv@^6.5.5: json-schema-traverse "^0.4.1" uri-js "^4.2.2" -ansi-align@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/ansi-align/-/ansi-align-2.0.0.tgz#c36aeccba563b89ceb556f3690f0b1d9e3547f7f" - integrity sha1-w2rsy6VjuJzrVW82kPCx2eNUf38= - dependencies: - string-width "^2.0.0" - ansi-escapes@^1.0.0: version "1.4.0" resolved "https://registry.yarnpkg.com/ansi-escapes/-/ansi-escapes-1.4.0.tgz#d3a8a83b319aa67793662b13e761c7911422306e" @@ -457,6 +460,11 @@ ansi-escapes@^3.0.0: resolved "https://registry.yarnpkg.com/ansi-escapes/-/ansi-escapes-3.1.0.tgz#f73207bb81207d75fd6c83f125af26eea378ca30" integrity sha512-UgAb8H9D41AQnu/PbWlCofQVcnV4Gs2bBJi9eZPxfU/hgglFh3SMDMENRIqdr7H6XFnXdoknctFByVsCOotTVw== +ansi-escapes@^3.1.0: + version "3.2.0" + resolved "https://registry.yarnpkg.com/ansi-escapes/-/ansi-escapes-3.2.0.tgz#8780b98ff9dbf5638152d1f1fe5c1d7b4442976b" + integrity sha512-cBhpre4ma+U0T1oM5fXg7Dy1Jw7zzwv7lt/GoCpr+hDQJoYnKVPLL4dCvSEFMmQurOQvSrwT7SL/DAlhBI97RQ== + ansi-regex@^2.0.0: version "2.1.1" resolved "https://registry.yarnpkg.com/ansi-regex/-/ansi-regex-2.1.1.tgz#c3b33ab5ee360d86e0e628f0468ae7ef27d654df" @@ -479,6 +487,11 @@ ansi-styles@^3.2.0, ansi-styles@^3.2.1: dependencies: color-convert "^1.9.0" +ansicolors@~0.3.2: + version "0.3.2" + resolved "https://registry.yarnpkg.com/ansicolors/-/ansicolors-0.3.2.tgz#665597de86a9ffe3aa9bfbe6cae5c6ea426b4979" + integrity sha1-ZlWX3oap/+Oqm/vmyuXG6kJrSXk= + anymatch@^1.3.0: version "1.3.2" resolved "https://registry.yarnpkg.com/anymatch/-/anymatch-1.3.2.tgz#553dcb8f91e3c889845dfdba34c77721b90b9d7a" @@ -654,6 +667,11 @@ are-we-there-yet@~1.1.2: delegates "^1.0.0" readable-stream "^2.0.6" +arg@^4.1.0: + version "4.1.0" + resolved "https://registry.yarnpkg.com/arg/-/arg-4.1.0.tgz#583c518199419e0037abb74062c37f8519e575f0" + integrity sha512-ZWc51jO3qegGkVh8Hwpv636EkbesNV5ZNQPCtRa+0qytRYPEs9IYT9qITY9buezqUH5uqyzlWLcufrzU2rffdg== + argparse@^1.0.7: version "1.0.10" resolved "https://registry.yarnpkg.com/argparse/-/argparse-1.0.10.tgz#bcd6791ea5ae09725e17e5ad988134cd40b3d911" @@ -698,6 +716,11 @@ array-filter@~0.0.0: resolved "https://registry.yarnpkg.com/array-filter/-/array-filter-0.0.1.tgz#7da8cf2e26628ed732803581fd21f67cacd2eeec" integrity sha1-fajPLiZijtcygDWB/SH2fKzS7uw= +array-find-index@^1.0.1: + version "1.0.2" + resolved "https://registry.yarnpkg.com/array-find-index/-/array-find-index-1.0.2.tgz#df010aa1287e164bbda6f9723b0a96a1ec4187a1" + integrity sha1-3wEKoSh+Fku9pvlyOwqWoexBh6E= + array-flatten@1.1.1: version "1.1.1" resolved "https://registry.yarnpkg.com/array-flatten/-/array-flatten-1.1.1.tgz#9a5f699051b1e7073328f2a008968b64ea2955d2" @@ -1108,19 +1131,6 @@ body-parser@1.18.3, body-parser@^1.17.1, body-parser@^1.18.3: raw-body "2.3.3" type-is "~1.6.16" -boxen@^1.2.1: - version "1.3.0" - resolved "https://registry.yarnpkg.com/boxen/-/boxen-1.3.0.tgz#55c6c39a8ba58d9c61ad22cd877532deb665a20b" - integrity sha512-TNPjfTr432qx7yOjQyaXm3dSR0MH9vXp7eT1BFSl/C51g+EFnOR9hTg1IreahGBmDNCehscshe45f+C1TBZbLw== - dependencies: - ansi-align "^2.0.0" - camelcase "^4.0.0" - chalk "^2.0.1" - cli-boxes "^1.0.0" - string-width "^2.0.0" - term-size "^1.2.0" - widest-line "^2.0.0" - brace-expansion@^1.1.7: version "1.1.11" resolved "https://registry.yarnpkg.com/brace-expansion/-/brace-expansion-1.1.11.tgz#3c7fcbf529d87226f3d2f52b966ff5271eb441dd" @@ -1138,7 +1148,7 @@ braces@^1.8.2: preserve "^0.2.0" repeat-element "^1.1.2" -braces@^2.3.0, braces@^2.3.1: +braces@^2.3.1: version "2.3.2" resolved "https://registry.yarnpkg.com/braces/-/braces-2.3.2.tgz#5979fd3f14cd531565e5fa2df1abfff1dfaee729" integrity sha512-aNdbnj9P8PjdXU4ybaWLK2IF3jc/EoDYbC7AazW6to3TRsfXxscC9UXOB5iDiEQrkyIbWp2SLQda4+QAa7nc3w== @@ -1252,12 +1262,20 @@ callsites@^2.0.0: resolved "https://registry.yarnpkg.com/callsites/-/callsites-2.0.0.tgz#06eb84f00eea413da86affefacbffb36093b3c50" integrity sha1-BuuE8A7qQT2oav/vrL/7Ngk7PFA= -camelcase@^2.0.1: +camelcase-keys@^2.0.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/camelcase-keys/-/camelcase-keys-2.1.0.tgz#308beeaffdf28119051efa1d932213c91b8f92e7" + integrity sha1-MIvur/3ygRkFHvodkyITyRuPkuc= + dependencies: + camelcase "^2.0.0" + map-obj "^1.0.0" + +camelcase@^2.0.0, camelcase@^2.0.1: version "2.1.1" resolved "https://registry.yarnpkg.com/camelcase/-/camelcase-2.1.1.tgz#7c1d16d679a1bbe59ca02cacecfb011e201f5a1f" integrity sha1-fB0W1nmhu+WcoCys7PsBHiAfWh8= -camelcase@^4.0.0, camelcase@^4.1.0: +camelcase@^4.1.0: version "4.1.0" resolved "https://registry.yarnpkg.com/camelcase/-/camelcase-4.1.0.tgz#d545635be1e33c542649c69173e5de6acfae34dd" integrity sha1-1UVjW+HjPFQmScaRc+Xeas+uNN0= @@ -1274,6 +1292,14 @@ capture-stack-trace@^1.0.0: resolved "https://registry.yarnpkg.com/capture-stack-trace/-/capture-stack-trace-1.0.1.tgz#a6c0bbe1f38f3aa0b92238ecb6ff42c344d4135d" integrity sha512-mYQLZnx5Qt1JgB1WEiMCf2647plpGeQ2NMR/5L0HNZzGQo4fuSPnK+wjfPnKZV0aiJDgzmWqqkV/g7JD+DW0qw== +cardinal@^2.1.1: + version "2.1.1" + resolved "https://registry.yarnpkg.com/cardinal/-/cardinal-2.1.1.tgz#7cc1055d822d212954d07b085dea251cc7bc5505" + integrity sha1-fMEFXYItISlU0HsIXeolHMe8VQU= + dependencies: + ansicolors "~0.3.2" + redeyed "~2.1.0" + caseless@~0.12.0: version "0.12.0" resolved "https://registry.yarnpkg.com/caseless/-/caseless-0.12.0.tgz#1b681c21ff84033c826543090689420d187151dc" @@ -1309,6 +1335,15 @@ chalk@^2.0.0, chalk@^2.0.1, chalk@^2.3.0: escape-string-regexp "^1.0.5" supports-color "^5.3.0" +chalk@^2.4.1: + version "2.4.2" + resolved "https://registry.yarnpkg.com/chalk/-/chalk-2.4.2.tgz#cd42541677a54333cf541a49108c1432b44c9424" + integrity sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ== + dependencies: + ansi-styles "^3.2.1" + escape-string-regexp "^1.0.5" + supports-color "^5.3.0" + chokidar@^1.6.0: version "1.7.0" resolved "https://registry.yarnpkg.com/chokidar/-/chokidar-1.7.0.tgz#798e689778151c8076b4b360e5edd28cda2bb468" @@ -1325,26 +1360,6 @@ chokidar@^1.6.0: optionalDependencies: fsevents "^1.0.0" -chokidar@^2.0.4: - version "2.0.4" - resolved "https://registry.yarnpkg.com/chokidar/-/chokidar-2.0.4.tgz#356ff4e2b0e8e43e322d18a372460bbcf3accd26" - integrity sha512-z9n7yt9rOvIJrMhvDtDictKrkFHeihkNl6uWMmZlmL6tJtX9Cs+87oK+teBx+JIgzvbX3yZHT3eF8vpbDxHJXQ== - dependencies: - anymatch "^2.0.0" - async-each "^1.0.0" - braces "^2.3.0" - glob-parent "^3.1.0" - inherits "^2.0.1" - is-binary-path "^1.0.0" - is-glob "^4.0.0" - lodash.debounce "^4.0.8" - normalize-path "^2.1.1" - path-is-absolute "^1.0.0" - readdirp "^2.0.0" - upath "^1.0.5" - optionalDependencies: - fsevents "^1.2.2" - chownr@^1.1.1: version "1.1.1" resolved "https://registry.yarnpkg.com/chownr/-/chownr-1.1.1.tgz#54726b8b8fff4df053c42187e801fb4412df1494" @@ -1365,11 +1380,6 @@ class-utils@^0.3.5: isobject "^3.0.0" static-extend "^0.1.1" -cli-boxes@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/cli-boxes/-/cli-boxes-1.0.0.tgz#4fa917c3e59c94a004cd61f8ee509da651687143" - integrity sha1-T6kXw+WclKAEzWH47lCdplFocUM= - cli-cursor@^1.0.2: version "1.0.2" resolved "https://registry.yarnpkg.com/cli-cursor/-/cli-cursor-1.0.2.tgz#64da3f7d56a54412e59794bd62dc35295e8f2987" @@ -1382,6 +1392,13 @@ cli-spinners@^0.1.2: resolved "https://registry.yarnpkg.com/cli-spinners/-/cli-spinners-0.1.2.tgz#bb764d88e185fb9e1e6a2a1f19772318f605e31c" integrity sha1-u3ZNiOGF+54eaiofGXcjGPYF4xw= +cli-table@^0.3.1: + version "0.3.1" + resolved "https://registry.yarnpkg.com/cli-table/-/cli-table-0.3.1.tgz#f53b05266a8b1a0b934b3d0821e6e2dc5914ae23" + integrity sha1-9TsFJmqLGguTSz0IIebi3FkUriM= + dependencies: + colors "1.0.3" + cli-truncate@^0.2.1: version "0.2.1" resolved "https://registry.yarnpkg.com/cli-truncate/-/cli-truncate-0.2.1.tgz#9f15cfbb0705005369216c626ac7d05ab90dd574" @@ -1390,6 +1407,14 @@ cli-truncate@^0.2.1: slice-ansi "0.0.4" string-width "^1.0.1" +cli-usage@^0.1.1: + version "0.1.8" + resolved "https://registry.yarnpkg.com/cli-usage/-/cli-usage-0.1.8.tgz#16479361f3a895a81062d02d9634827c713aaaf8" + integrity sha512-EZJ+ty1TsqdnhZNt2QbI+ed3IUNHTH31blSOJLVph3oL4IExskPRyCDGJH7RuCBPy3QBmWgpbeUxXPhK0isXIw== + dependencies: + marked "^0.5.0" + marked-terminal "^3.0.0" + cliui@^3.0.3, cliui@^3.2.0: version "3.2.0" resolved "https://registry.yarnpkg.com/cliui/-/cliui-3.2.0.tgz#120601537a916d29940f934da3b48d585a39213d" @@ -1443,6 +1468,11 @@ color-name@1.1.3: resolved "https://registry.yarnpkg.com/color-name/-/color-name-1.1.3.tgz#a7d0558bd89c42f795dd42328f740831ca53bc25" integrity sha1-p9BVi9icQveV3UIyj3QIMcpTvCU= +colors@1.0.3: + version "1.0.3" + resolved "https://registry.yarnpkg.com/colors/-/colors-1.0.3.tgz#0433f44d809680fdeb60ed260f1b0c262e82a40b" + integrity sha1-BDP0TYCWgP3rYO0mDxsMJi6CpAs= + colour@~0.7.1: version "0.7.1" resolved "https://registry.yarnpkg.com/colour/-/colour-0.7.1.tgz#9cb169917ec5d12c0736d3e8685746df1cadf778" @@ -1485,18 +1515,6 @@ concat-stream@^1.6.0: readable-stream "^2.2.2" typedarray "^0.0.6" -configstore@^3.0.0: - version "3.1.2" - resolved "https://registry.yarnpkg.com/configstore/-/configstore-3.1.2.tgz#c6f25defaeef26df12dd33414b001fe81a543f8f" - integrity sha512-vtv5HtGjcYUgFrXc6Kx747B83MRRVS5R1VTEQoXvuP+kMI+if6uywV0nDGoiydJRy4yk7h9od5Og0kxx4zUXmw== - dependencies: - dot-prop "^4.1.0" - graceful-fs "^4.1.2" - make-dir "^1.0.0" - unique-string "^1.0.0" - write-file-atomic "^2.0.0" - xdg-basedir "^3.0.0" - console-control-strings@^1.0.0, console-control-strings@~1.1.0: version "1.1.0" resolved "https://registry.yarnpkg.com/console-control-strings/-/console-control-strings-1.1.0.tgz#3d7cf4464db6446ea644bf4b39507f9851008e8e" @@ -1624,7 +1642,7 @@ crc-32@~1.2.0: exit-on-epipe "~1.0.1" printj "~1.1.0" -create-error-class@^3.0.0, create-error-class@^3.0.2: +create-error-class@^3.0.2: version "3.0.2" resolved "https://registry.yarnpkg.com/create-error-class/-/create-error-class-3.0.2.tgz#06be7abef947a3f14a30fd610671d401bca8b7b6" integrity sha1-Br56vvlHo/FKMP1hBnHUAbyot7Y= @@ -1648,11 +1666,6 @@ cross-spawn@^5.0.1: shebang-command "^1.2.0" which "^1.2.9" -crypto-random-string@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/crypto-random-string/-/crypto-random-string-1.0.0.tgz#a230f64f568310e1498009940790ec99545bca7e" - integrity sha1-ojD2T1aDEOFJgAmUB5DsmVRbyn4= - crypto@0.0.3: version "0.0.3" resolved "https://registry.yarnpkg.com/crypto/-/crypto-0.0.3.tgz#470a81b86be4c5ee17acc8207a1f5315ae20dbb0" @@ -1677,6 +1690,13 @@ cssstyle@^1.0.0: dependencies: cssom "0.3.x" +currently-unhandled@^0.4.1: + version "0.4.1" + resolved "https://registry.yarnpkg.com/currently-unhandled/-/currently-unhandled-0.4.1.tgz#988df33feab191ef799a61369dd76c17adf957ea" + integrity sha1-mI3zP+qxke95mmE2nddsF635V+o= + dependencies: + array-find-index "^1.0.1" + dashdash@^1.12.0: version "1.14.1" resolved "https://registry.yarnpkg.com/dashdash/-/dashdash-1.14.1.tgz#853cfa0f7cbe2fed5de20326b8dd581035f6e2f0" @@ -1703,6 +1723,19 @@ dateformat@^2.0.0: resolved "https://registry.yarnpkg.com/dateformat/-/dateformat-2.2.0.tgz#4065e2013cf9fb916ddfd82efb506ad4c6769062" integrity sha1-QGXiATz5+5Ft39gu+1Bq1MZ2kGI= +dateformat@~1.0.4-1.2.3: + version "1.0.12" + resolved "https://registry.yarnpkg.com/dateformat/-/dateformat-1.0.12.tgz#9f124b67594c937ff706932e4a642cca8dbbfee9" + integrity sha1-nxJLZ1lMk3/3BpMuSmQsyo27/uk= + dependencies: + get-stdin "^4.0.1" + meow "^3.3.0" + +debounce@^1.0.0: + version "1.2.0" + resolved "https://registry.yarnpkg.com/debounce/-/debounce-1.2.0.tgz#44a540abc0ea9943018dc0eaa95cce87f65cd131" + integrity sha512-mYtLl1xfZLi1m4RtQYlZgJUNQjl4ZxVnHzIR8nLLgi4q1YT8o/WM+MK/f8yfcc9s5Ir5zRaPZyZU6xs1Syoocg== + debug@2.6.9, debug@^2.1.2, debug@^2.2.0, debug@^2.3.3, debug@^2.6.8, debug@^2.6.9: version "2.6.9" resolved "https://registry.yarnpkg.com/debug/-/debug-2.6.9.tgz#5d128515df134ff327e90a4c93f4e077a536341f" @@ -1724,7 +1757,7 @@ debug@^3.1.0: dependencies: ms "^2.1.1" -decamelize@^1.1.1: +decamelize@^1.1.1, decamelize@^1.1.2: version "1.2.0" resolved "https://registry.yarnpkg.com/decamelize/-/decamelize-1.2.0.tgz#f6534d15148269b20352e7bee26f501f9a191290" integrity sha1-9lNNFRSCabIDUue+4m9QH5oZEpA= @@ -1870,23 +1903,11 @@ domexception@^1.0.1: dependencies: webidl-conversions "^4.0.2" -dot-prop@^4.1.0: - version "4.2.0" - resolved "https://registry.yarnpkg.com/dot-prop/-/dot-prop-4.2.0.tgz#1f19e0c2e1aa0e32797c49799f2837ac6af69c57" - integrity sha512-tUMXrxlExSW6U2EXiiKGSBVdYgtV8qlHL+C10TsW4PURY/ic+eaysnSkwB4kA/mBlCyy/IKDJ+Lc3wbWeaXtuQ== - dependencies: - is-obj "^1.0.0" - dotenv@^4.0.0: version "4.0.0" resolved "https://registry.yarnpkg.com/dotenv/-/dotenv-4.0.0.tgz#864ef1379aced55ce6f95debecdce179f7a0cd1d" integrity sha1-hk7xN5rO1Vzm+V3r7NzhefegzR0= -duplexer3@^0.1.4: - version "0.1.4" - resolved "https://registry.yarnpkg.com/duplexer3/-/duplexer3-0.1.4.tgz#ee01dd1cac0ed3cbc7fdbea37dc0a8f1ce002ce2" - integrity sha1-7gHdHKwO08vH/b6jfcCo8c4ALOI= - duplexer@^0.1.1: version "0.1.1" resolved "https://registry.yarnpkg.com/duplexer/-/duplexer-0.1.1.tgz#ace6ff808c1ce66b57d1ebf97977acb02334cfc1" @@ -1902,6 +1923,13 @@ duplexify@^3.5.0, duplexify@^3.5.4: readable-stream "^2.0.0" stream-shift "^1.0.0" +dynamic-dedupe@^0.3.0: + version "0.3.0" + resolved "https://registry.yarnpkg.com/dynamic-dedupe/-/dynamic-dedupe-0.3.0.tgz#06e44c223f5e4e94d78ef9db23a6515ce2f962a1" + integrity sha1-BuRMIj9eTpTXjvnbI6ZRXOL5YqE= + dependencies: + xtend "^4.0.0" + eastasianwidth@^0.2.0: version "0.2.0" resolved "https://registry.yarnpkg.com/eastasianwidth/-/eastasianwidth-0.2.0.tgz#696ce2ec0aa0e6ea93a397ffcf24aa7840c827cb" @@ -2038,7 +2066,7 @@ esprima@^3.1.3: resolved "https://registry.yarnpkg.com/esprima/-/esprima-3.1.3.tgz#fdca51cee6133895e3c88d535ce49dbff62a4633" integrity sha1-/cpRzuYTOJXjyI1TXOSdv/YqRjM= -esprima@^4.0.0: +esprima@^4.0.0, esprima@~4.0.0: version "4.0.1" resolved "https://registry.yarnpkg.com/esprima/-/esprima-4.0.1.tgz#13b04cdb3e6c5d19df91ab6987a8695619b0aa71" integrity sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A== @@ -2321,6 +2349,13 @@ fileset@^2.0.2: glob "^7.0.3" minimatch "^3.0.3" +filewatcher@~3.0.0: + version "3.0.1" + resolved "https://registry.yarnpkg.com/filewatcher/-/filewatcher-3.0.1.tgz#f4a1957355ddaf443ccd78a895f3d55e23c8a034" + integrity sha1-9KGVc1Xdr0Q8zXiolfPVXiPIoDQ= + dependencies: + debounce "^1.0.0" + fill-range@^2.1.0: version "2.2.4" resolved "https://registry.yarnpkg.com/fill-range/-/fill-range-2.2.4.tgz#eb1e773abb056dcd8df2bfdf6af59b8b3a936565" @@ -2466,7 +2501,7 @@ fs.realpath@^1.0.0: resolved "https://registry.yarnpkg.com/fs.realpath/-/fs.realpath-1.0.0.tgz#1504ad2523158caa40db4a2787cb01411994ea4f" integrity sha1-FQStJSMVjKpA20onh8sBQRmU6k8= -fsevents@^1.0.0, fsevents@^1.2.2, fsevents@^1.2.3: +fsevents@^1.0.0, fsevents@^1.2.3: version "1.2.4" resolved "https://registry.yarnpkg.com/fsevents/-/fsevents-1.2.4.tgz#f41dcb1af2582af3692da36fc55cbd8e1041c426" integrity sha512-z8H8/diyk76B7q5wg+Ud0+CqzcAF3mBBI/bA5ne5zrRUUIvNkJY//D3BqyH571KuAC4Nr7Rw7CjWX4r0y9DvNg== @@ -2507,6 +2542,11 @@ get-caller-file@^1.0.1: resolved "https://registry.yarnpkg.com/get-caller-file/-/get-caller-file-1.0.3.tgz#f978fa4c90d1dfe7ff2d6beda2a515e713bdcf4a" integrity sha512-3t6rVToeoZfYSGd8YoLFR2DJkiQrIiUrGcjvFX2mDw3bn6k2OtwHN0TNCLbBO+w8qTvimhDkv+LSscbJY1vE6w== +get-stdin@^4.0.1: + version "4.0.1" + resolved "https://registry.yarnpkg.com/get-stdin/-/get-stdin-4.0.1.tgz#b968c6b0a04384324902e8bf1a5df32579a450fe" + integrity sha1-uWjGsKBDhDJJAui/Gl3zJXmkUP4= + get-stream@^3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/get-stream/-/get-stream-3.0.0.tgz#8e943d1358dc37555054ecbe2edb05aa174ede14" @@ -2576,13 +2616,6 @@ glob@^7.0.3, glob@^7.0.5, glob@^7.1.1, glob@^7.1.2: once "^1.3.0" path-is-absolute "^1.0.0" -global-dirs@^0.1.0: - version "0.1.1" - resolved "https://registry.yarnpkg.com/global-dirs/-/global-dirs-0.1.1.tgz#b319c0dd4607f353f3be9cca4c72fc148c49f445" - integrity sha1-sxnA3UYH81PzvpzKTHL8FIxJ9EU= - dependencies: - ini "^1.3.4" - globals@^9.18.0: version "9.18.0" resolved "https://registry.yarnpkg.com/globals/-/globals-9.18.0.tgz#aa3896b3e69b487f17e31ed2143d69a8e30c2d8a" @@ -2699,23 +2732,6 @@ googleapis@^33.0.0: google-auth-library "^1.6.0" googleapis-common "^0.2.0" -got@^6.7.1: - version "6.7.1" - resolved "https://registry.yarnpkg.com/got/-/got-6.7.1.tgz#240cd05785a9a18e561dc1b44b41c763ef1e8db0" - integrity sha1-JAzQV4WpoY5WHcG0S0HHY+8ejbA= - dependencies: - create-error-class "^3.0.0" - duplexer3 "^0.1.4" - get-stream "^3.0.0" - is-redirect "^1.0.0" - is-retry-allowed "^1.0.0" - is-stream "^1.0.0" - lowercase-keys "^1.0.0" - safe-buffer "^5.0.1" - timed-out "^4.0.0" - unzip-response "^2.0.1" - url-parse-lax "^1.0.0" - graceful-fs@^4.1.11, graceful-fs@^4.1.2, graceful-fs@^4.1.6: version "4.1.15" resolved "https://registry.yarnpkg.com/graceful-fs/-/graceful-fs-4.1.15.tgz#ffb703e1066e8a0eeaa4c8b80ba9253eeefbfb00" @@ -2792,7 +2808,7 @@ graphql@^14.0.2: dependencies: iterall "^1.2.2" -growly@^1.3.0: +growly@^1.2.0, growly@^1.3.0: version "1.3.0" resolved "https://registry.yarnpkg.com/growly/-/growly-1.3.0.tgz#f10748cbe76af964b7c96c93c6bcc28af120c081" integrity sha1-8QdIy+dq+WS3yWyTxrzCivEgwIE= @@ -2855,6 +2871,11 @@ has-flag@^1.0.0: resolved "https://registry.yarnpkg.com/has-flag/-/has-flag-1.0.0.tgz#9d9e793165ce017a00f00418c43f942a7b1d11fa" integrity sha1-nZ55MWXOAXoA8AQYxD+UKnsdEfo= +has-flag@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/has-flag/-/has-flag-2.0.0.tgz#e8207af1cc7b30d446cc70b734b5e8be18f88d51" + integrity sha1-6CB68cx7MNRGzHC3NLXovhj4jVE= + has-flag@^3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/has-flag/-/has-flag-3.0.0.tgz#b5d454dc2199ae225699f3467e5a07f3b955bafd" @@ -2992,11 +3013,6 @@ ieee754@^1.1.4: resolved "https://registry.yarnpkg.com/ieee754/-/ieee754-1.1.12.tgz#50bf24e5b9c8bb98af4964c941cdb0918da7b60b" integrity sha512-GguP+DRY+pJ3soyIiGPTvdiVXjZ+DbXOxGpXn3eMvNW4x4irjqXm4wHKscC+TfxSJ0yw/S1F24tqdMNsMZTiLA== -ignore-by-default@^1.0.1: - version "1.0.1" - resolved "https://registry.yarnpkg.com/ignore-by-default/-/ignore-by-default-1.0.1.tgz#48ca6d72f6c6a3af00a9ad4ae6876be3889e2b09" - integrity sha1-SMptcvbGo68Aqa1K5odr44ieKwk= - ignore-walk@^3.0.1: version "3.0.1" resolved "https://registry.yarnpkg.com/ignore-walk/-/ignore-walk-3.0.1.tgz#a83e62e7d272ac0e3b551aaa82831a19b69f82f8" @@ -3014,11 +3030,6 @@ immediate@~3.0.5: resolved "https://registry.yarnpkg.com/immediate/-/immediate-3.0.6.tgz#9db1dbd0faf8de6fbe0f5dd5e56bb606280de69b" integrity sha1-nbHb0Pr43m++D13V5Wu2BigN5ps= -import-lazy@^2.1.0: - version "2.1.0" - resolved "https://registry.yarnpkg.com/import-lazy/-/import-lazy-2.1.0.tgz#05698e3d45c88e8d7e9d92cb0584e77f096f3e43" - integrity sha1-BWmOPUXIjo1+nZLLBYTnfwlvPkM= - imurmurhash@^0.1.4: version "0.1.4" resolved "https://registry.yarnpkg.com/imurmurhash/-/imurmurhash-0.1.4.tgz#9218b9b2b928a238b13dc4fb6b6d576f231453ea" @@ -3054,7 +3065,7 @@ inherits@2, inherits@2.0.3, inherits@^2.0.1, inherits@^2.0.3, inherits@~2.0.1, i resolved "https://registry.yarnpkg.com/inherits/-/inherits-2.0.3.tgz#633c2c83e3da42a502f52466022480f4208261de" integrity sha1-Yzwsg+PaQqUC9SRmAiSA9CCCYd4= -ini@^1.3.4, ini@~1.3.0: +ini@~1.3.0: version "1.3.5" resolved "https://registry.yarnpkg.com/ini/-/ini-1.3.5.tgz#eee25f56db1c9ec6085e0c22778083f596abf927" integrity sha512-RZY5huIKCMRWDUqZlEi72f/lmXKMvuszcMBduliQ3nnWbx9X/ZBQO7DijMEYS9EhHBb2qacRUMtC7svLwe0lcw== @@ -3271,14 +3282,6 @@ is-glob@^4.0.0: dependencies: is-extglob "^2.1.1" -is-installed-globally@^0.1.0: - version "0.1.0" - resolved "https://registry.yarnpkg.com/is-installed-globally/-/is-installed-globally-0.1.0.tgz#0dfd98f5a9111716dd535dda6492f67bf3d25a80" - integrity sha1-Df2Y9akRFxbdU13aZJL2e/PSWoA= - dependencies: - global-dirs "^0.1.0" - is-path-inside "^1.0.0" - is-nan@^1.2.1: version "1.2.1" resolved "https://registry.yarnpkg.com/is-nan/-/is-nan-1.2.1.tgz#9faf65b6fb6db24b7f5c0628475ea71f988401e2" @@ -3286,11 +3289,6 @@ is-nan@^1.2.1: dependencies: define-properties "^1.1.1" -is-npm@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/is-npm/-/is-npm-1.0.0.tgz#f2fb63a65e4905b406c86072765a1a4dc793b9f4" - integrity sha1-8vtjpl5JBbQGyGBydloaTceTufQ= - is-number@^2.1.0: version "2.1.0" resolved "https://registry.yarnpkg.com/is-number/-/is-number-2.1.0.tgz#01fcbbb393463a548f2f466cce16dece49db908f" @@ -3310,18 +3308,6 @@ is-number@^4.0.0: resolved "https://registry.yarnpkg.com/is-number/-/is-number-4.0.0.tgz#0026e37f5454d73e356dfe6564699867c6a7f0ff" integrity sha512-rSklcAIlf1OmFdyAqbnWTLVelsQ58uvZ66S/ZyawjWqIviTWCjg2PzVGw8WUA+nNuPTqb4wgA+NszrJ+08LlgQ== -is-obj@^1.0.0: - version "1.0.1" - resolved "https://registry.yarnpkg.com/is-obj/-/is-obj-1.0.1.tgz#3e4729ac1f5fde025cd7d83a896dab9f4f67db0f" - integrity sha1-PkcprB9f3gJc19g6iW2rn09n2w8= - -is-path-inside@^1.0.0: - version "1.0.1" - resolved "https://registry.yarnpkg.com/is-path-inside/-/is-path-inside-1.0.1.tgz#8ef5b7de50437a3fdca6b4e865ef7aa55cb48036" - integrity sha1-jvW33lBDej/cprToZe96pVy0gDY= - dependencies: - path-is-inside "^1.0.1" - is-plain-object@^2.0.1, is-plain-object@^2.0.3, is-plain-object@^2.0.4: version "2.0.4" resolved "https://registry.yarnpkg.com/is-plain-object/-/is-plain-object-2.0.4.tgz#2c163b3fafb1b606d9d17928f05c2a1c38e07677" @@ -3344,11 +3330,6 @@ is-promise@^2.1.0: resolved "https://registry.yarnpkg.com/is-promise/-/is-promise-2.1.0.tgz#79a2a9ece7f096e80f36d2b2f3bc16c1ff4bf3fa" integrity sha1-eaKp7OfwlugPNtKy87wWwf9L8/o= -is-redirect@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/is-redirect/-/is-redirect-1.0.0.tgz#1d03dded53bd8db0f30c26e4f95d36fc7c87dc24" - integrity sha1-HQPd7VO9jbDzDCbk+V02/HyH3CQ= - is-regex@^1.0.4: version "1.0.4" resolved "https://registry.yarnpkg.com/is-regex/-/is-regex-1.0.4.tgz#5517489b547091b0930e095654ced25ee97e9491" @@ -3356,17 +3337,12 @@ is-regex@^1.0.4: dependencies: has "^1.0.1" -is-retry-allowed@^1.0.0: - version "1.1.0" - resolved "https://registry.yarnpkg.com/is-retry-allowed/-/is-retry-allowed-1.1.0.tgz#11a060568b67339444033d0125a61a20d564fb34" - integrity sha1-EaBgVotnM5REAz0BJaYaINVk+zQ= - is-stream-ended@^0.1.0: version "0.1.4" resolved "https://registry.yarnpkg.com/is-stream-ended/-/is-stream-ended-0.1.4.tgz#f50224e95e06bce0e356d440a4827cd35b267eda" integrity sha512-xj0XPvmr7bQFTvirqnFr50o0hQIh6ZItDqloxt5aJrR4NQsYeSsyFQERYGCAzfindAcnKjINnwEEgLx4IqVzQw== -is-stream@^1.0.0, is-stream@^1.1.0: +is-stream@^1.1.0: version "1.1.0" resolved "https://registry.yarnpkg.com/is-stream/-/is-stream-1.1.0.tgz#12d4a3dd4e68e0b79ceb8dbc84173ae80d91ca44" integrity sha1-EtSj3U5o4Lec6428hBc66A2RykQ= @@ -4130,13 +4106,6 @@ kind-of@^6.0.0, kind-of@^6.0.2: resolved "https://registry.yarnpkg.com/kind-of/-/kind-of-6.0.2.tgz#01146b36a6218e64e58f3a8d66de5d7fc6f6d051" integrity sha512-s5kLOcnH0XqDO+FvuaLX8DDjZ18CGFk7VygH40QoKPUQhW4e2rvM0rwUq0t8IQDOwYSeLK01U90OjzBTme2QqA== -latest-version@^3.0.0: - version "3.1.0" - resolved "https://registry.yarnpkg.com/latest-version/-/latest-version-3.1.0.tgz#a205383fea322b33b5ae3b18abee0dc2f356ee15" - integrity sha1-ogU4P+oyKzO1rjsYq+4NwvNW7hU= - dependencies: - package-json "^4.0.0" - lcid@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/lcid/-/lcid-1.0.0.tgz#308accafa0bc483a3867b4b6f2b9506251d1b835" @@ -4264,6 +4233,56 @@ locate-path@^2.0.0: p-locate "^2.0.0" path-exists "^3.0.0" +lodash._arraycopy@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/lodash._arraycopy/-/lodash._arraycopy-3.0.0.tgz#76e7b7c1f1fb92547374878a562ed06a3e50f6e1" + integrity sha1-due3wfH7klRzdIeKVi7Qaj5Q9uE= + +lodash._arrayeach@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/lodash._arrayeach/-/lodash._arrayeach-3.0.0.tgz#bab156b2a90d3f1bbd5c653403349e5e5933ef9e" + integrity sha1-urFWsqkNPxu9XGU0AzSeXlkz754= + +lodash._baseassign@^3.0.0: + version "3.2.0" + resolved "https://registry.yarnpkg.com/lodash._baseassign/-/lodash._baseassign-3.2.0.tgz#8c38a099500f215ad09e59f1722fd0c52bfe0a4e" + integrity sha1-jDigmVAPIVrQnlnxci/QxSv+Ck4= + dependencies: + lodash._basecopy "^3.0.0" + lodash.keys "^3.0.0" + +lodash._baseclone@^3.0.0: + version "3.3.0" + resolved "https://registry.yarnpkg.com/lodash._baseclone/-/lodash._baseclone-3.3.0.tgz#303519bf6393fe7e42f34d8b630ef7794e3542b7" + integrity sha1-MDUZv2OT/n5C802LYw73eU41Qrc= + dependencies: + lodash._arraycopy "^3.0.0" + lodash._arrayeach "^3.0.0" + lodash._baseassign "^3.0.0" + lodash._basefor "^3.0.0" + lodash.isarray "^3.0.0" + lodash.keys "^3.0.0" + +lodash._basecopy@^3.0.0: + version "3.0.1" + resolved "https://registry.yarnpkg.com/lodash._basecopy/-/lodash._basecopy-3.0.1.tgz#8da0e6a876cf344c0ad8a54882111dd3c5c7ca36" + integrity sha1-jaDmqHbPNEwK2KVIghEd08XHyjY= + +lodash._basefor@^3.0.0: + version "3.0.3" + resolved "https://registry.yarnpkg.com/lodash._basefor/-/lodash._basefor-3.0.3.tgz#7550b4e9218ef09fad24343b612021c79b4c20c2" + integrity sha1-dVC06SGO8J+tJDQ7YSAhx5tMIMI= + +lodash._bindcallback@^3.0.0: + version "3.0.1" + resolved "https://registry.yarnpkg.com/lodash._bindcallback/-/lodash._bindcallback-3.0.1.tgz#e531c27644cf8b57a99e17ed95b35c748789392e" + integrity sha1-5THCdkTPi1epnhftlbNcdIeJOS4= + +lodash._getnative@^3.0.0: + version "3.9.1" + resolved "https://registry.yarnpkg.com/lodash._getnative/-/lodash._getnative-3.9.1.tgz#570bc7dede46d61cdcde687d65d3eecbaa3aaff5" + integrity sha1-VwvH3t5G1hzc3mh9ZdPuy6o6r/U= + lodash.assign@^4.2.0: version "4.2.0" resolved "https://registry.yarnpkg.com/lodash.assign/-/lodash.assign-4.2.0.tgz#0d99f3ccd7a6d261d19bdaeb9245005d285808e7" @@ -4289,16 +4308,19 @@ lodash.clone@^4.5.0: resolved "https://registry.yarnpkg.com/lodash.clone/-/lodash.clone-4.5.0.tgz#195870450f5a13192478df4bc3d23d2dea1907b6" integrity sha1-GVhwRQ9aExkkeN9Lw9I9LeoZB7Y= +lodash.clonedeep@^3.0.0: + version "3.0.2" + resolved "https://registry.yarnpkg.com/lodash.clonedeep/-/lodash.clonedeep-3.0.2.tgz#a0a1e40d82a5ea89ff5b147b8444ed63d92827db" + integrity sha1-oKHkDYKl6on/WxR7hETtY9koJ9s= + dependencies: + lodash._baseclone "^3.0.0" + lodash._bindcallback "^3.0.0" + lodash.clonedeep@^4.5.0: version "4.5.0" resolved "https://registry.yarnpkg.com/lodash.clonedeep/-/lodash.clonedeep-4.5.0.tgz#e23f3f9c4f8fbdde872529c1071857a086e5ccef" integrity sha1-4j8/nE+Pvd6HJSnBBxhXoIblzO8= -lodash.debounce@^4.0.8: - version "4.0.8" - resolved "https://registry.yarnpkg.com/lodash.debounce/-/lodash.debounce-4.0.8.tgz#82d79bff30a67c4005ffd5e2515300ad9ca4d7af" - integrity sha1-gteb/zCmfEAF/9XiUVMArZyk168= - lodash.defaults@^4.2.0: version "4.2.0" resolved "https://registry.yarnpkg.com/lodash.defaults/-/lodash.defaults-4.2.0.tgz#d09178716ffea4dde9e5fb7b37f6f0802274580c" @@ -4329,6 +4351,16 @@ lodash.includes@^4.3.0: resolved "https://registry.yarnpkg.com/lodash.includes/-/lodash.includes-4.3.0.tgz#60bb98a87cb923c68ca1e51325483314849f553f" integrity sha1-YLuYqHy5I8aMoeUTJUgzFISfVT8= +lodash.isarguments@^3.0.0: + version "3.1.0" + resolved "https://registry.yarnpkg.com/lodash.isarguments/-/lodash.isarguments-3.1.0.tgz#2f573d85c6a24289ff00663b491c1d338ff3458a" + integrity sha1-L1c9hcaiQon/AGY7SRwdM4/zRYo= + +lodash.isarray@^3.0.0: + version "3.0.4" + resolved "https://registry.yarnpkg.com/lodash.isarray/-/lodash.isarray-3.0.4.tgz#79e4eb88c36a8122af86f844aa9bcd851b5fbb55" + integrity sha1-eeTriMNqgSKvhvhEqpvNhRtfu1U= + lodash.isboolean@^3.0.3: version "3.0.3" resolved "https://registry.yarnpkg.com/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz#6c2e171db2a257cd96802fd43b01b20d5f5870f6" @@ -4359,6 +4391,15 @@ lodash.isstring@^4.0.1: resolved "https://registry.yarnpkg.com/lodash.isstring/-/lodash.isstring-4.0.1.tgz#d527dfb5456eca7cc9bb95d5daeaf88ba54a5451" integrity sha1-1SfftUVuynzJu5XV2ur4i6VKVFE= +lodash.keys@^3.0.0: + version "3.1.2" + resolved "https://registry.yarnpkg.com/lodash.keys/-/lodash.keys-3.1.2.tgz#4dbc0472b156be50a0b286855d1bd0b0c656098a" + integrity sha1-TbwEcrFWvlCgsoaFXRvQsMZWCYo= + dependencies: + lodash._getnative "^3.0.0" + lodash.isarguments "^3.0.0" + lodash.isarray "^3.0.0" + lodash.keys@^4.2.0: version "4.2.0" resolved "https://registry.yarnpkg.com/lodash.keys/-/lodash.keys-4.2.0.tgz#a08602ac12e4fb83f91fc1fb7a360a4d9ba35205" @@ -4409,6 +4450,11 @@ lodash.sortby@^4.7.0: resolved "https://registry.yarnpkg.com/lodash.sortby/-/lodash.sortby-4.7.0.tgz#edd14c824e2cc9c1e0b0a1b42bb5210516a42438" integrity sha1-7dFMgk4sycHgsKG0K7UhBRakJDg= +lodash.toarray@^4.4.0: + version "4.4.0" + resolved "https://registry.yarnpkg.com/lodash.toarray/-/lodash.toarray-4.4.0.tgz#24c4bfcd6b2fba38bfd0594db1179d8e9b656561" + integrity sha1-JMS/zWsvuji/0FlNsRedjptlZWE= + lodash.values@^4.3.0: version "4.3.0" resolved "https://registry.yarnpkg.com/lodash.values/-/lodash.values-4.3.0.tgz#a3a6c2b0ebecc5c2cba1c17e6e620fe81b53d347" @@ -4476,10 +4522,13 @@ loose-envify@^1.0.0: dependencies: js-tokens "^3.0.0 || ^4.0.0" -lowercase-keys@^1.0.0: - version "1.0.1" - resolved "https://registry.yarnpkg.com/lowercase-keys/-/lowercase-keys-1.0.1.tgz#6f9e30b47084d971a7c820ff15a6c5167b74c26f" - integrity sha512-G2Lj61tXDnVFFOi8VZds+SoQjtQC3dgokKdDG2mTm1tx4m50NUHBOZSBwQQHyy0V12A0JTG4icfZQH+xPyh8VA== +loud-rejection@^1.0.0: + version "1.6.0" + resolved "https://registry.yarnpkg.com/loud-rejection/-/loud-rejection-1.6.0.tgz#5b46f80147edee578870f086d04821cf998e551f" + integrity sha1-W0b4AUft7leIcPCG0Eghz5mOVR8= + dependencies: + currently-unhandled "^0.4.1" + signal-exit "^3.0.0" lru-cache@^4.0.1, lru-cache@^4.1.3: version "4.1.5" @@ -4496,13 +4545,6 @@ lru-cache@^5.0.0: dependencies: yallist "^3.0.2" -make-dir@^1.0.0: - version "1.3.0" - resolved "https://registry.yarnpkg.com/make-dir/-/make-dir-1.3.0.tgz#79c1033b80515bd6d24ec9933e860ca75ee27f0c" - integrity sha512-2w31R7SJtieJJnQtGc7RVL2StM2vGYVfqUOvUDxH6bC6aJTxPxTF0GnIgCyu7tjockiUWAYQRbxa7vKn34s5sQ== - dependencies: - pify "^3.0.0" - make-error@^1.1.1: version "1.3.5" resolved "https://registry.yarnpkg.com/make-error/-/make-error-1.3.5.tgz#efe4e81f6db28cadd605c70f29c831b58ef776c8" @@ -4520,6 +4562,11 @@ map-cache@^0.2.2: resolved "https://registry.yarnpkg.com/map-cache/-/map-cache-0.2.2.tgz#c32abd0bd6525d9b051645bb4f26ac5dc98a0dbf" integrity sha1-wyq9C9ZSXZsFFkW7TyasXcmKDb8= +map-obj@^1.0.0, map-obj@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/map-obj/-/map-obj-1.0.1.tgz#d933ceb9205d82bdcf4886f6742bdc2b4dea146d" + integrity sha1-2TPOuSBdgr3PSIb2dCvcK03qFG0= + map-visit@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/map-visit/-/map-visit-1.0.0.tgz#ecdca8f13144e660f1b5bd41f12f3479d98dfb8f" @@ -4527,6 +4574,23 @@ map-visit@^1.0.0: dependencies: object-visit "^1.0.0" +marked-terminal@^3.0.0: + version "3.2.0" + resolved "https://registry.yarnpkg.com/marked-terminal/-/marked-terminal-3.2.0.tgz#3fc91d54569332bcf096292af178d82219000474" + integrity sha512-Yr1yVS0BbDG55vx7be1D0mdv+jGs9AW563o/Tt/7FTsId2J0yqhrTeXAqq/Q0DyyXltIn6CSxzesQuFqXgafjQ== + dependencies: + ansi-escapes "^3.1.0" + cardinal "^2.1.1" + chalk "^2.4.1" + cli-table "^0.3.1" + node-emoji "^1.4.1" + supports-hyperlinks "^1.0.1" + +marked@^0.5.0: + version "0.5.2" + resolved "https://registry.yarnpkg.com/marked/-/marked-0.5.2.tgz#3efdb27b1fd0ecec4f5aba362bddcd18120e5ba9" + integrity sha512-fdZvBa7/vSQIZCi4uuwo2N3q+7jJURpMVCcbaX0S1Mg65WZ5ilXvC67MviJAsdjqqgD+CEq4RKo5AYGgINkVAA== + math-random@^1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/math-random/-/math-random-1.0.1.tgz#8b3aac588b8a66e4975e3cdea67f7bb329601fac" @@ -4549,6 +4613,22 @@ memory-pager@^1.0.2: resolved "https://registry.yarnpkg.com/memory-pager/-/memory-pager-1.4.0.tgz#8902c72ce2fa34319adc0dae586b7d83cec6d6ac" integrity sha512-ycuyV5gKpZln7HB/A11wCpAxEY9VQ2EhYU1F56pUAxvmj6OyOHtB9tkLLjAyFsPdghSP2S3Ujk3aYJCusgiMZg== +meow@^3.3.0: + version "3.7.0" + resolved "https://registry.yarnpkg.com/meow/-/meow-3.7.0.tgz#72cb668b425228290abbfa856892587308a801fb" + integrity sha1-cstmi0JSKCkKu/qFaJJYcwioAfs= + dependencies: + camelcase-keys "^2.0.0" + decamelize "^1.1.2" + loud-rejection "^1.0.0" + map-obj "^1.0.1" + minimist "^1.1.3" + normalize-package-data "^2.3.4" + object-assign "^4.0.1" + read-pkg-up "^1.0.1" + redent "^1.0.0" + trim-newlines "^1.0.0" + merge-descriptors@1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/merge-descriptors/-/merge-descriptors-1.0.1.tgz#b00aaa556dd8b44568150ec9d1b953f3f90cbb61" @@ -4677,7 +4757,7 @@ minimist@0.0.8: resolved "https://registry.yarnpkg.com/minimist/-/minimist-0.0.8.tgz#857fcabfc3397d2625b8228262e86aa7a011b05d" integrity sha1-hX/Kv8M5fSYluCKCYuhqp6ARsF0= -minimist@^1.1.0, minimist@^1.1.1, minimist@^1.2.0: +minimist@^1.1.0, minimist@^1.1.1, minimist@^1.1.3, minimist@^1.2.0: version "1.2.0" resolved "https://registry.yarnpkg.com/minimist/-/minimist-1.2.0.tgz#a35008b20f41383eec1fb914f4cd5df79a264284" integrity sha1-o1AIsg9BOD7sH7kU9M1d95omQoQ= @@ -4860,6 +4940,13 @@ nise@^1.4.7: path-to-regexp "^1.7.0" text-encoding "^0.6.4" +node-emoji@^1.4.1: + version "1.10.0" + resolved "https://registry.yarnpkg.com/node-emoji/-/node-emoji-1.10.0.tgz#8886abd25d9c7bb61802a658523d1f8d2a89b2da" + integrity sha512-Yt3384If5H6BYGVHiHwTL+99OzJKHhgp82S8/dktEK73T26BazdgZ4JZh92xSVtGNJvz9UbXdNAc5hcrXV42vw== + dependencies: + lodash.toarray "^4.4.0" + node-fetch@^2.1.2, node-fetch@^2.2.0: version "2.3.0" resolved "https://registry.yarnpkg.com/node-fetch/-/node-fetch-2.3.0.tgz#1a1d940bbfb916a1d3e0219f037e89e71f8c5fa5" @@ -4875,6 +4962,19 @@ node-int64@^0.4.0: resolved "https://registry.yarnpkg.com/node-int64/-/node-int64-0.4.0.tgz#87a9065cdb355d3182d8f94ce11188b825c68a3b" integrity sha1-h6kGXNs1XTGC2PlM4RGIuCXGijs= +node-notifier@^4.0.2: + version "4.6.1" + resolved "https://registry.yarnpkg.com/node-notifier/-/node-notifier-4.6.1.tgz#056d14244f3dcc1ceadfe68af9cff0c5473a33f3" + integrity sha1-BW0UJE89zBzq3+aK+c/wxUc6M/M= + dependencies: + cli-usage "^0.1.1" + growly "^1.2.0" + lodash.clonedeep "^3.0.0" + minimist "^1.1.1" + semver "^5.1.0" + shellwords "^0.1.0" + which "^1.0.5" + node-notifier@^5.0.2: version "5.3.0" resolved "https://registry.yarnpkg.com/node-notifier/-/node-notifier-5.3.0.tgz#c77a4a7b84038733d5fb351aafd8a268bfe19a01" @@ -4931,22 +5031,6 @@ nodemailer@^4.1.3: resolved "https://registry.yarnpkg.com/nodemailer/-/nodemailer-4.7.0.tgz#4420e06abfffd77d0618f184ea49047db84f4ad8" integrity sha512-IludxDypFpYw4xpzKdMAozBSkzKHmNBvGanUREjJItgJ2NYcK/s8+PggVhj7c2yGFQykKsnnmv1+Aqo0ZfjHmw== -nodemon@^1.11.0: - version "1.18.9" - resolved "https://registry.yarnpkg.com/nodemon/-/nodemon-1.18.9.tgz#90b467efd3b3c81b9453380aeb2a2cba535d0ead" - integrity sha512-oj/eEVTEI47pzYAjGkpcNw0xYwTl4XSTUQv2NPQI6PpN3b75PhpuYk3Vb3U80xHCyM2Jm+1j68ULHXl4OR3Afw== - dependencies: - chokidar "^2.0.4" - debug "^3.1.0" - ignore-by-default "^1.0.1" - minimatch "^3.0.4" - pstree.remy "^1.1.6" - semver "^5.5.0" - supports-color "^5.2.0" - touch "^3.1.0" - undefsafe "^2.0.2" - update-notifier "^2.5.0" - nopt@^4.0.1: version "4.0.1" resolved "https://registry.yarnpkg.com/nopt/-/nopt-4.0.1.tgz#d0d4685afd5415193c8c7505602d0d17cd64474d" @@ -4955,13 +5039,6 @@ nopt@^4.0.1: abbrev "1" osenv "^0.1.4" -nopt@~1.0.10: - version "1.0.10" - resolved "https://registry.yarnpkg.com/nopt/-/nopt-1.0.10.tgz#6ddd21bd2a31417b92727dd585f8a6f37608ebee" - integrity sha1-bd0hvSoxQXuScn3Vhfim83YI6+4= - dependencies: - abbrev "1" - normalize-package-data@^2.3.2: version "2.4.0" resolved "https://registry.yarnpkg.com/normalize-package-data/-/normalize-package-data-2.4.0.tgz#12f95a307d58352075a04907b84ac8be98ac012f" @@ -4972,6 +5049,16 @@ normalize-package-data@^2.3.2: semver "2 || 3 || 4 || 5" validate-npm-package-license "^3.0.1" +normalize-package-data@^2.3.4: + version "2.5.0" + resolved "https://registry.yarnpkg.com/normalize-package-data/-/normalize-package-data-2.5.0.tgz#e66db1838b200c1dfc233225d12cb36520e234a8" + integrity sha512-/5CMN3T0R4XTj4DcGaexo+roZSdSFW/0AOOTROrjxzCG1wrWXEsGbRKevjlIL+ZDE4sZlJr5ED4YW0yqmkK+eA== + dependencies: + hosted-git-info "^2.1.4" + resolve "^1.10.0" + semver "2 || 3 || 4 || 5" + validate-npm-package-license "^3.0.1" + normalize-path@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/normalize-path/-/normalize-path-1.0.0.tgz#32d0e472f91ff345701c15a8311018d3b0a90379" @@ -5236,16 +5323,6 @@ p-try@^1.0.0: resolved "https://registry.yarnpkg.com/p-try/-/p-try-1.0.0.tgz#cbc79cdbaf8fd4228e13f621f2b1a237c1b207b3" integrity sha1-y8ec26+P1CKOE/Yh8rGiN8GyB7M= -package-json@^4.0.0: - version "4.0.1" - resolved "https://registry.yarnpkg.com/package-json/-/package-json-4.0.1.tgz#8869a0401253661c4c4ca3da6c2121ed555f5eed" - integrity sha1-iGmgQBJTZhxMTKPabCEh7VVfXu0= - dependencies: - got "^6.7.1" - registry-auth-token "^3.0.1" - registry-url "^3.0.3" - semver "^5.1.0" - pako@~1.0.2: version "1.0.7" resolved "https://registry.yarnpkg.com/pako/-/pako-1.0.7.tgz#2473439021b57f1516c82f58be7275ad8ef1bb27" @@ -5310,11 +5387,6 @@ path-is-absolute@^1.0.0, path-is-absolute@^1.0.1: resolved "https://registry.yarnpkg.com/path-is-absolute/-/path-is-absolute-1.0.1.tgz#174b9268735534ffbc7ace6bf53a5a9e1b5c5f5f" integrity sha1-F0uSaHNVNP+8es5r9TpanhtcX18= -path-is-inside@^1.0.1: - version "1.0.2" - resolved "https://registry.yarnpkg.com/path-is-inside/-/path-is-inside-1.0.2.tgz#365417dede44430d1c11af61027facf074bdfc53" - integrity sha1-NlQX3t5EQw0cEa9hAn+s8HS9/FM= - path-key@^2.0.0: version "2.0.1" resolved "https://registry.yarnpkg.com/path-key/-/path-key-2.0.1.tgz#411cadb574c5a140d3a4b1910d40d80cc9f40b40" @@ -5513,11 +5585,6 @@ prelude-ls@~1.1.2: resolved "https://registry.yarnpkg.com/prelude-ls/-/prelude-ls-1.1.2.tgz#21932a549f5e52ffd9a827f570e04be62a97da54" integrity sha1-IZMqVJ9eUv/ZqCf1cOBL5iqX2lQ= -prepend-http@^1.0.1: - version "1.0.4" - resolved "https://registry.yarnpkg.com/prepend-http/-/prepend-http-1.0.4.tgz#d4f4562b0ce3696e41ac52d0e002e57a635dc6dc" - integrity sha1-1PRWKwzjaW5BrFLQ4ALlemNdxtw= - preserve@^0.2.0: version "0.2.0" resolved "https://registry.yarnpkg.com/preserve/-/preserve-0.2.0.tgz#815ed1f6ebc65926f865b310c0713bcb3315ce4b" @@ -5624,11 +5691,6 @@ psl@^1.1.24, psl@^1.1.28: resolved "https://registry.yarnpkg.com/psl/-/psl-1.1.31.tgz#e9aa86d0101b5b105cbe93ac6b784cd547276184" integrity sha512-/6pt4+C+T+wZUieKR620OpzN/LlnNKuWjy1iFLQ/UG35JqHlR/89MP1d96dUfkf6Dne3TuLQzOYEYshJ+Hx8mw== -pstree.remy@^1.1.6: - version "1.1.6" - resolved "https://registry.yarnpkg.com/pstree.remy/-/pstree.remy-1.1.6.tgz#73a55aad9e2d95814927131fbf4dc1b62d259f47" - integrity sha512-NdF35+QsqD7EgNEI5mkI/X+UwaxVEbQaz9f4IooEmMUv6ZPmlTQYGjBPJGgrlzNdjSvIy4MWMg6Q6vCgBO2K+w== - punycode@1.3.2: version "1.3.2" resolved "https://registry.yarnpkg.com/punycode/-/punycode-1.3.2.tgz#9653a036fb7c1ee42342f2325cceefea3926c48d" @@ -5688,7 +5750,7 @@ raw-body@2.3.3: iconv-lite "0.4.23" unpipe "1.0.0" -rc@^1.0.1, rc@^1.1.6, rc@^1.2.7: +rc@^1.2.7: version "1.2.8" resolved "https://registry.yarnpkg.com/rc/-/rc-1.2.8.tgz#cd924bf5200a075b83c188cd6b9e211b7fc0d3ed" integrity sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw== @@ -5776,6 +5838,21 @@ readdirp@^2.0.0: micromatch "^3.1.10" readable-stream "^2.0.2" +redent@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/redent/-/redent-1.0.0.tgz#cf916ab1fd5f1f16dfb20822dd6ec7f730c2afde" + integrity sha1-z5Fqsf1fHxbfsggi3W7H9zDCr94= + dependencies: + indent-string "^2.1.0" + strip-indent "^1.0.1" + +redeyed@~2.1.0: + version "2.1.1" + resolved "https://registry.yarnpkg.com/redeyed/-/redeyed-2.1.1.tgz#8984b5815d99cb220469c99eeeffe38913e6cc0b" + integrity sha1-iYS1gV2ZyyIEacme7v/jiRPmzAs= + dependencies: + esprima "~4.0.0" + redis-commands@^1.2.0: version "1.4.0" resolved "https://registry.yarnpkg.com/redis-commands/-/redis-commands-1.4.0.tgz#52f9cf99153efcce56a8f86af986bd04e988602f" @@ -5811,21 +5888,6 @@ regexp-clone@0.0.1: resolved "https://registry.yarnpkg.com/regexp-clone/-/regexp-clone-0.0.1.tgz#a7c2e09891fdbf38fbb10d376fb73003e68ac589" integrity sha1-p8LgmJH9vzj7sQ03b7cwA+aKxYk= -registry-auth-token@^3.0.1: - version "3.3.2" - resolved "https://registry.yarnpkg.com/registry-auth-token/-/registry-auth-token-3.3.2.tgz#851fd49038eecb586911115af845260eec983f20" - integrity sha512-JL39c60XlzCVgNrO+qq68FoNb56w/m7JYvGR2jT5iR1xBrUA3Mfx5Twk5rqTThPmQKMWydGmq8oFtDlxfrmxnQ== - dependencies: - rc "^1.1.6" - safe-buffer "^5.0.1" - -registry-url@^3.0.3: - version "3.1.0" - resolved "https://registry.yarnpkg.com/registry-url/-/registry-url-3.1.0.tgz#3d4ef870f73dde1d77f0cf9a381432444e174942" - integrity sha1-PU74cPc93h138M+aOBQyRE4XSUI= - dependencies: - rc "^1.0.1" - remove-trailing-separator@^1.0.1: version "1.1.0" resolved "https://registry.yarnpkg.com/remove-trailing-separator/-/remove-trailing-separator-1.1.0.tgz#c24bce2a283adad5bc3f58e0d48249b92379d8ef" @@ -5937,6 +5999,13 @@ resolve@1.1.7: resolved "https://registry.yarnpkg.com/resolve/-/resolve-1.1.7.tgz#203114d82ad2c5ed9e8e0411b3932875e889e97b" integrity sha1-IDEU2CrSxe2ejgQRs5ModeiJ6Xs= +resolve@^1.0.0, resolve@^1.10.0: + version "1.10.0" + resolved "https://registry.yarnpkg.com/resolve/-/resolve-1.10.0.tgz#3bdaaeaf45cc07f375656dfd2e54ed0810b101ba" + integrity sha512-3sUr9aq5OfSg2S9pNtPA9hL1FVEAjvfOC4leW0SNf/mpnaakz2a9femSd6LqAww2RaFctwyf1lCqnTHuF1rxDg== + dependencies: + path-parse "^1.0.6" + resolve@^1.1.7, resolve@^1.3.2: version "1.9.0" resolved "https://registry.yarnpkg.com/resolve/-/resolve-1.9.0.tgz#a14c6fdfa8f92a7df1d996cb7105fa744658ea06" @@ -6044,14 +6113,7 @@ sax@>=0.6.0, sax@^1.2.1, sax@^1.2.4: resolved "https://registry.yarnpkg.com/sax/-/sax-1.2.4.tgz#2816234e2378bddc4e5354fab5caa895df7100d9" integrity sha512-NqVDv9TpANUjFm0N8uM5GxL36UgKi9/atZw+x7YFnQ8ckwFGKrl4xX4yWtrey3UJm5nP1kUbnYgLopqWNSRhWw== -semver-diff@^2.0.0: - version "2.1.0" - resolved "https://registry.yarnpkg.com/semver-diff/-/semver-diff-2.1.0.tgz#4bbb8437c8d37e4b0cf1a68fd726ec6d645d6d36" - integrity sha1-S7uEN8jTfksM8aaP1ybsbWRdbTY= - dependencies: - semver "^5.0.3" - -"semver@2 || 3 || 4 || 5", semver@^5.0.3, semver@^5.1.0, semver@^5.3.0, semver@^5.5.0: +"semver@2 || 3 || 4 || 5", semver@^5.1.0, semver@^5.3.0, semver@^5.5.0: version "5.6.0" resolved "https://registry.yarnpkg.com/semver/-/semver-5.6.0.tgz#7e74256fbaa49c75aa7c7a205cc22799cac80004" integrity sha512-RS9R6R35NYgQn++fkDWaOmqGoj4Ek9gGs+DPxNUZKuwE183xjJroKvyo1IzVFeXvUrvmALy6FWD5xrdJT25gMg== @@ -6145,7 +6207,7 @@ shell-quote@^1.6.1: array-reduce "~0.0.0" jsonify "~0.0.0" -shellwords@^0.1.1: +shellwords@^0.1.0, shellwords@^0.1.1: version "0.1.1" resolved "https://registry.yarnpkg.com/shellwords/-/shellwords-0.1.1.tgz#d6b9181c1a48d397324c84871efbcfc73fc0654b" integrity sha512-vFwSUfQvqybiICwZY5+DAWIPLKsWO31Q91JSKl3UYv+K5c2QRPzn0qzec6QPu1Qc9eHYItiP3NdJqNVqetYAww== @@ -6471,7 +6533,14 @@ strip-eof@^1.0.0: resolved "https://registry.yarnpkg.com/strip-eof/-/strip-eof-1.0.0.tgz#bb43ff5598a6eb05d89b59fcd129c983313606bf" integrity sha1-u0P/VZim6wXYm1n80SnJgzE2Br8= -strip-json-comments@~2.0.1: +strip-indent@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/strip-indent/-/strip-indent-1.0.1.tgz#0c7962a6adefa7bbd4ac366460a638552ae1a0a2" + integrity sha1-DHlipq3vp7vUrDZkYKY4VSrhoKI= + dependencies: + get-stdin "^4.0.1" + +strip-json-comments@^2.0.0, strip-json-comments@~2.0.1: version "2.0.1" resolved "https://registry.yarnpkg.com/strip-json-comments/-/strip-json-comments-2.0.1.tgz#3c531942e908c2697c0ec344858c286c7ca0a60a" integrity sha1-PFMZQukIwml8DsNEhYwobHygpgo= @@ -6516,13 +6585,21 @@ supports-color@^3.1.2: dependencies: has-flag "^1.0.0" -supports-color@^5.2.0, supports-color@^5.3.0, supports-color@^5.5.0: +supports-color@^5.0.0, supports-color@^5.3.0, supports-color@^5.5.0: version "5.5.0" resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-5.5.0.tgz#e2e69a44ac8772f78a1ec0b35b689df6530efc8f" integrity sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow== dependencies: has-flag "^3.0.0" +supports-hyperlinks@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/supports-hyperlinks/-/supports-hyperlinks-1.0.1.tgz#71daedf36cc1060ac5100c351bb3da48c29c0ef7" + integrity sha512-HHi5kVSefKaJkGYXbDuKbUGRVxqnWGn3J2e39CYcNJEfWciGq2zYtOhXLTlvrOZW1QU7VX67w7fMmWafHX9Pfw== + dependencies: + has-flag "^2.0.0" + supports-color "^5.0.0" + symbol-observable@1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/symbol-observable/-/symbol-observable-1.0.1.tgz#8340fc4702c3122df5d22288f88283f513d3fdd4" @@ -6551,13 +6628,6 @@ tar@^4: safe-buffer "^5.1.2" yallist "^3.0.2" -term-size@^1.2.0: - version "1.2.0" - resolved "https://registry.yarnpkg.com/term-size/-/term-size-1.2.0.tgz#458b83887f288fc56d6fffbfad262e26638efa69" - integrity sha1-RYuDiH8oj8Vtb/+/rSYuJmOO+mk= - dependencies: - execa "^0.7.0" - test-exclude@^4.2.1: version "4.2.3" resolved "https://registry.yarnpkg.com/test-exclude/-/test-exclude-4.2.3.tgz#a9a5e64474e4398339245a0a769ad7c2f4a97c20" @@ -6587,11 +6657,6 @@ through2@^2.0.0, through2@^2.0.3: readable-stream "~2.3.6" xtend "~4.0.1" -timed-out@^4.0.0: - version "4.0.1" - resolved "https://registry.yarnpkg.com/timed-out/-/timed-out-4.0.1.tgz#f32eacac5a175bea25d7fab565ab3ed8741ef56f" - integrity sha1-8y6srFoXW+ol1/q1Zas+2HQe9W8= - tmpl@1.0.x: version "1.0.4" resolved "https://registry.yarnpkg.com/tmpl/-/tmpl-1.0.4.tgz#23640dd7b42d00433911140820e5cf440e521dd1" @@ -6632,13 +6697,6 @@ toidentifier@1.0.0: resolved "https://registry.yarnpkg.com/toidentifier/-/toidentifier-1.0.0.tgz#7e1be3470f1e77948bc43d94a3c8f4d7752ba553" integrity sha512-yaOH/Pk/VEhBWWTlhI+qXxDFXlejDGcQipMlyxda9nthulaxLZUNcUqFxokp0vcYnvteJln5FNQDRrxj3YcbVw== -touch@^3.1.0: - version "3.1.0" - resolved "https://registry.yarnpkg.com/touch/-/touch-3.1.0.tgz#fe365f5f75ec9ed4e56825e0bb76d24ab74af83b" - integrity sha512-WBx8Uy5TLtOSRtIq+M03/sKDrXCLHxwDcquSP2c43Le03/9serjQBIztjRz6FkJez9D/hleyAXTBGLwwZUw9lA== - dependencies: - nopt "~1.0.10" - tough-cookie@>=2.3.3, tough-cookie@^2.3.2, tough-cookie@^2.3.4: version "2.5.0" resolved "https://registry.yarnpkg.com/tough-cookie/-/tough-cookie-2.5.0.tgz#cd9fb2a0aa1d5a12b473bd9fb96fa3dcff65ade2" @@ -6672,6 +6730,11 @@ traverse@^0.6.6: resolved "https://registry.yarnpkg.com/traverse/-/traverse-0.6.6.tgz#cbdf560fd7b9af632502fed40f918c157ea97137" integrity sha1-y99WD9e5r2MlAv7UD5GMFX6pcTc= +trim-newlines@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/trim-newlines/-/trim-newlines-1.0.0.tgz#5887966bb582a4503a41eb524f7d35011815a613" + integrity sha1-WIeWa7WCpFA6QetST301ARgVphM= + trim-right@^1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/trim-right/-/trim-right-1.0.1.tgz#cb2e1203067e0c8de1f614094b9fe45704ea6003" @@ -6694,6 +6757,33 @@ ts-jest@^22.0.0: source-map-support "^0.5.5" yargs "^11.0.0" +ts-node-dev@^1.0.0-pre.32: + version "1.0.0-pre.32" + resolved "https://registry.yarnpkg.com/ts-node-dev/-/ts-node-dev-1.0.0-pre.32.tgz#aa3bb9056c002713cfc393b2c324459020db41dc" + integrity sha512-hOy2mp5ncnKAJFjuQtfHOk6tqG7FfTnuARDsV5WGSQz0ldPG9iTQLbDn+q2wwNuvjbCfsAeGWwDMJc/73EraPQ== + dependencies: + dateformat "~1.0.4-1.2.3" + dynamic-dedupe "^0.3.0" + filewatcher "~3.0.0" + minimist "^1.1.3" + mkdirp "^0.5.1" + node-notifier "^4.0.2" + resolve "^1.0.0" + rimraf "^2.6.1" + ts-node "*" + tsconfig "^7.0.0" + +ts-node@*: + version "8.0.2" + resolved "https://registry.yarnpkg.com/ts-node/-/ts-node-8.0.2.tgz#9ecdf8d782a0ca4c80d1d641cbb236af4ac1b756" + integrity sha512-MosTrinKmaAcWgO8tqMjMJB22h+sp3Rd1i4fdoWY4mhBDekOwIAKI/bzmRi7IcbCmjquccYg2gcF6NBkLgr0Tw== + dependencies: + arg "^4.1.0" + diff "^3.1.0" + make-error "^1.1.1" + source-map-support "^0.5.6" + yn "^3.0.0" + ts-node@^7.0.0: version "7.0.1" resolved "https://registry.yarnpkg.com/ts-node/-/ts-node-7.0.1.tgz#9562dc2d1e6d248d24bc55f773e3f614337d9baf" @@ -6708,6 +6798,16 @@ ts-node@^7.0.0: source-map-support "^0.5.6" yn "^2.0.0" +tsconfig@^7.0.0: + version "7.0.0" + resolved "https://registry.yarnpkg.com/tsconfig/-/tsconfig-7.0.0.tgz#84538875a4dc216e5c4a5432b3a4dec3d54e91b7" + integrity sha512-vZXmzPrL+EmC4T/4rVlT2jNVMWCi/O4DIiSj3UHg1OE5kCKbk4mfrXc6dZksLgRM/TZlKnousKH9bbTazUWRRw== + dependencies: + "@types/strip-bom" "^3.0.0" + "@types/strip-json-comments" "0.0.30" + strip-bom "^3.0.0" + strip-json-comments "^2.0.0" + tslib@1.9.0: version "1.9.0" resolved "https://registry.yarnpkg.com/tslib/-/tslib-1.9.0.tgz#e37a86fda8cbbaf23a057f473c9f4dc64e5fc2e8" @@ -6835,13 +6935,6 @@ uglify-js@^3.1.4: commander "~2.17.1" source-map "~0.6.1" -undefsafe@^2.0.2: - version "2.0.2" - resolved "https://registry.yarnpkg.com/undefsafe/-/undefsafe-2.0.2.tgz#225f6b9e0337663e0d8e7cfd686fc2836ccace76" - integrity sha1-Il9rngM3Zj4Njnz9aG/Cg2zKznY= - dependencies: - debug "^2.2.0" - underscore@^1.8.3: version "1.9.1" resolved "https://registry.yarnpkg.com/underscore/-/underscore-1.9.1.tgz#06dce34a0e68a7babc29b365b8e74b8925203961" @@ -6862,13 +6955,6 @@ union-value@^1.0.0: is-extendable "^0.1.1" set-value "^0.4.3" -unique-string@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/unique-string/-/unique-string-1.0.0.tgz#9e1057cca851abb93398f8b33ae187b99caec11a" - integrity sha1-nhBXzKhRq7kzmPizOuGHuZyuwRo= - dependencies: - crypto-random-string "^1.0.0" - universal-deep-strict-equal@^1.2.1: version "1.2.2" resolved "https://registry.yarnpkg.com/universal-deep-strict-equal/-/universal-deep-strict-equal-1.2.2.tgz#0da4ac2f73cff7924c81fa4de018ca562ca2b0a7" @@ -6896,32 +6982,6 @@ unset-value@^1.0.0: has-value "^0.3.1" isobject "^3.0.0" -unzip-response@^2.0.1: - version "2.0.1" - resolved "https://registry.yarnpkg.com/unzip-response/-/unzip-response-2.0.1.tgz#d2f0f737d16b0615e72a6935ed04214572d56f97" - integrity sha1-0vD3N9FrBhXnKmk17QQhRXLVb5c= - -upath@^1.0.5: - version "1.1.0" - resolved "https://registry.yarnpkg.com/upath/-/upath-1.1.0.tgz#35256597e46a581db4793d0ce47fa9aebfc9fabd" - integrity sha512-bzpH/oBhoS/QI/YtbkqCg6VEiPYjSZtrHQM6/QnJS6OL9pKUFLqb3aFh4Scvwm45+7iAgiMkLhSbaZxUqmrprw== - -update-notifier@^2.5.0: - version "2.5.0" - resolved "https://registry.yarnpkg.com/update-notifier/-/update-notifier-2.5.0.tgz#d0744593e13f161e406acb1d9408b72cad08aff6" - integrity sha512-gwMdhgJHGuj/+wHJJs9e6PcCszpxR1b236igrOkUofGhqJuG+amlIKwApH1IW1WWl7ovZxsX49lMBWLxSdm5Dw== - dependencies: - boxen "^1.2.1" - chalk "^2.0.1" - configstore "^3.0.0" - import-lazy "^2.1.0" - is-ci "^1.0.10" - is-installed-globally "^0.1.0" - is-npm "^1.0.0" - latest-version "^3.0.0" - semver-diff "^2.0.0" - xdg-basedir "^3.0.0" - uri-js@^4.2.2: version "4.2.2" resolved "https://registry.yarnpkg.com/uri-js/-/uri-js-4.2.2.tgz#94c540e1ff772956e2299507c010aea6c8838eb0" @@ -6934,13 +6994,6 @@ urix@^0.1.0: resolved "https://registry.yarnpkg.com/urix/-/urix-0.1.0.tgz#da937f7a62e21fec1fd18d49b35c2935067a6c72" integrity sha1-2pN/emLiH+wf0Y1Js1wpNQZ6bHI= -url-parse-lax@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/url-parse-lax/-/url-parse-lax-1.0.0.tgz#7af8f303645e9bd79a272e7a14ac68bc0609da73" - integrity sha1-evjzA2Rem9eaJy56FKxovAYJ2nM= - dependencies: - prepend-http "^1.0.1" - url-template@^2.0.8: version "2.0.8" resolved "https://registry.yarnpkg.com/url-template/-/url-template-2.0.8.tgz#fc565a3cccbff7730c775f5641f9555791439f21" @@ -7089,7 +7142,7 @@ which-module@^2.0.0: resolved "https://registry.yarnpkg.com/which-module/-/which-module-2.0.0.tgz#d9ef07dce77b9902b8a3a8fa4b31c3e3f7e6e87a" integrity sha1-2e8H3Od7mQK4o6j6SzHD4/fm6Ho= -which@^1.2.10, which@^1.2.12, which@^1.2.9, which@^1.3.0: +which@^1.0.5, which@^1.2.10, which@^1.2.12, which@^1.2.9, which@^1.3.0: version "1.3.1" resolved "https://registry.yarnpkg.com/which/-/which-1.3.1.tgz#a45043d54f5805316da8d62f9f50918d3da70b0a" integrity sha512-HxJdYWq1MTIQbJ3nw0cqssHoTNU267KlrDuGZ1WYlxDStUtKUhOaJmh112/TZmHxxUfuJqPXSOm7tDyas0OSIQ== @@ -7103,13 +7156,6 @@ wide-align@^1.1.0: dependencies: string-width "^1.0.2 || 2" -widest-line@^2.0.0: - version "2.0.1" - resolved "https://registry.yarnpkg.com/widest-line/-/widest-line-2.0.1.tgz#7438764730ec7ef4381ce4df82fb98a53142a3fc" - integrity sha512-Ba5m9/Fa4Xt9eb2ELXt77JxVDV8w7qQrH0zS/TWSJdLyAwQjWoOzpzj5lwVftDz6n/EOu3tNACS84v509qwnJA== - dependencies: - string-width "^2.1.1" - window-size@^0.1.4: version "0.1.4" resolved "https://registry.yarnpkg.com/window-size/-/window-size-0.1.4.tgz#f8e1aa1ee5a53ec5bf151ffa09742a6ad7697876" @@ -7145,7 +7191,7 @@ wrappy@1: resolved "https://registry.yarnpkg.com/wrappy/-/wrappy-1.0.2.tgz#b5243d8f3ec1aa35f1364605bc0d1036e30ab69f" integrity sha1-tSQ9jz7BqjXxNkYFvA0QNuMKtp8= -write-file-atomic@^2.0.0, write-file-atomic@^2.1.0: +write-file-atomic@^2.1.0: version "2.3.0" resolved "https://registry.yarnpkg.com/write-file-atomic/-/write-file-atomic-2.3.0.tgz#1ff61575c2e2a4e8e510d6fa4e243cce183999ab" integrity sha512-xuPeK4OdjWqtfi59ylvVL0Yn35SF3zgcAcv7rBPFHVaEapaDr4GdGgm3j7ckTwH9wHL7fGmgfAnb0+THrHb8tA== @@ -7168,11 +7214,6 @@ ws@^6.0.0: dependencies: async-limiter "~1.0.0" -xdg-basedir@^3.0.0: - version "3.0.0" - resolved "https://registry.yarnpkg.com/xdg-basedir/-/xdg-basedir-3.0.0.tgz#496b2cc109eca8dbacfe2dc72b603c17c5870ad4" - integrity sha1-SWsswQnsqNus/i3HK2A8F8WHCtQ= - xlsx-populate@^1.14.0: version "1.17.0" resolved "https://registry.yarnpkg.com/xlsx-populate/-/xlsx-populate-1.17.0.tgz#af48ab54f83badd81d6114f3d4f7b0af6a0d839d" @@ -7295,6 +7336,11 @@ yn@^2.0.0: resolved "https://registry.yarnpkg.com/yn/-/yn-2.0.0.tgz#e5adabc8acf408f6385fc76495684c88e6af689a" integrity sha1-5a2ryKz0CPY4X8dklWhMiOavaJo= +yn@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/yn/-/yn-3.0.0.tgz#0073c6b56e92aed652fbdfd62431f2d6b9a7a091" + integrity sha512-+Wo/p5VRfxUgBUGy2j/6KX2mj9AYJWOHuhMjMcbBFc3y54o9/4buK1ksBvuiK01C3kby8DH9lSmJdSxw+4G/2Q== + zen-observable-ts@^0.8.13: version "0.8.13" resolved "https://registry.yarnpkg.com/zen-observable-ts/-/zen-observable-ts-0.8.13.tgz#ae1fd77c84ef95510188b1f8bca579d7a5448fc2"