diff --git a/.circleci/config.yml b/.circleci/config.yml index ce22c7c86..7b72fb0c0 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -7,7 +7,7 @@ defaults: &defaults working_directory: ~/build docker: # specify the version you desire here - - image: circleci/ruby:2.6.5-node-browsers + - image: circleci/ruby:2.7.0-node-browsers # Specify service dependencies here if necessary # CircleCI maintains a library of pre-built images diff --git a/.codeclimate.yml b/.codeclimate.yml index 0bf089f4b..761ad4d7a 100644 --- a/.codeclimate.yml +++ b/.codeclimate.yml @@ -26,3 +26,7 @@ exclude_patterns: - "node_modules/**/*" - "lib/tasks/auto_annotate_models.rake" - "app/test-matchers.js" + - "docs/*" + - "**/*.md" + - "**/*.yml" + - "app/javascript/dashboard/i18n/locale" diff --git a/.env.example b/.env.example index e6075e55e..bc36e8588 100644 --- a/.env.example +++ b/.env.example @@ -1,6 +1,19 @@ -SECRET_KEY_BASE= +# Used to verify the integrity of signed cookies. so ensure a secure value is set +SECRET_KEY_BASE=replace_with_lengthy_secure_hex -#redis config +# Replace with the URL you are planning to use for your app +FRONTEND_URL=http://0.0.0.0:3000 + +# Force all access to the app over SSL, default is set to false +FORCE_SSL=false + +# This lets you control new sign ups on your chatwoot installation +# true : default option, allows sign ups +# false : disables all the end points related to sign ups +# api_only: disables the UI for signup, but you can create sign ups via the account apis +ENABLE_ACCOUNT_SIGNUP=true + +# Redis config REDIS_URL=redis://redis:6379 # If you are using docker-compose, set this variable's value to be any string, # which will be the password for the redis service running inside the docker-compose @@ -14,18 +27,7 @@ POSTGRES_PASSWORD= RAILS_ENV=development RAILS_MAX_THREADS=5 -#fb app -FB_VERIFY_TOKEN= -FB_APP_SECRET= -FB_APP_ID= - -#twitter app -TWITTER_APP_ID= -TWITTER_CONSUMER_KEY= -TWITTER_CONSUMER_SECRET= -TWITTER_ENVIRONMENT= - -#mail +# Mail outgoing MAILER_SENDER_EMAIL=accounts@chatwoot.com SMTP_PORT=1025 SMTP_DOMAIN=chatwoot.com @@ -37,23 +39,47 @@ SMTP_PASSWORD= SMTP_AUTHENTICATION= SMTP_ENABLE_STARTTLS_AUTO= -#misc -FRONTEND_URL=http://0.0.0.0:3000 +# Mail Incoming +# Use one of the following based on the email ingress service +# Ref: https://edgeguides.rubyonrails.org/action_mailbox_basics.html +RAILS_INBOUND_EMAIL_PASSWORD= +MAILGUN_INGRESS_SIGNING_KEY= +MANDRILL_INGRESS_API_KEY= + +# Storage ACTIVE_STORAGE_SERVICE=local -#s3 +# Amazon S3 S3_BUCKET_NAME= AWS_ACCESS_KEY_ID= AWS_SECRET_ACCESS_KEY= AWS_REGION= -#sentry +# Sentry SENTRY_DSN= +# Log settings +# Disable if you want to write logs to a file +RAILS_LOG_TO_STDOUT=true +LOG_LEVEL=info +LOG_SIZE=500 + # Credentials to access sidekiq dashboard in production SIDEKIQ_AUTH_USERNAME= SIDEKIQ_AUTH_PASSWORD= +### This environment variables are only required if you are setting up social media channels +#facebook +FB_VERIFY_TOKEN= +FB_APP_SECRET= +FB_APP_ID= + +# Twitter +TWITTER_APP_ID= +TWITTER_CONSUMER_KEY= +TWITTER_CONSUMER_SECRET= +TWITTER_ENVIRONMENT= + #### This environment variables are only required in hosted version which has billing ENABLE_BILLING= diff --git a/.eslintrc.js b/.eslintrc.js index c6c52bb34..9b99b9b61 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -1,8 +1,8 @@ module.exports = { - extends: ['airbnb/base', 'prettier', 'plugin:vue/recommended'], + extends: ['airbnb-base/legacy', 'prettier', 'plugin:vue/recommended'], parserOptions: { parser: 'babel-eslint', - ecmaVersion: 2017, + ecmaVersion: 2020, sourceType: 'module', }, plugins: ['html', 'prettier', 'babel'], @@ -24,10 +24,12 @@ module.exports = { 'multiline': { 'max': 1, 'allowFirstLine': false - } + }, }], 'vue/html-self-closing': 'off', - "vue/no-v-html": 'off' + "vue/no-v-html": 'off', + 'import/extensions': ['off'] + }, settings: { 'import/resolver': { diff --git a/.gitignore b/.gitignore index 7192ba9d9..d058d20d1 100644 --- a/.gitignore +++ b/.gitignore @@ -37,6 +37,10 @@ public/packs* *.swo *.un~ .jest-cache + +#VS Code files +.vscode + # ignore jetbrains IDE files .idea @@ -48,4 +52,6 @@ coverage # ignore packages node_modules -package-lock.json \ No newline at end of file +package-lock.json + +*.dump diff --git a/.nvmrc b/.nvmrc new file mode 100644 index 000000000..66df3b7ab --- /dev/null +++ b/.nvmrc @@ -0,0 +1 @@ +12.16.1 diff --git a/.rubocop.yml b/.rubocop.yml index 7c73f0191..48d234ba0 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -4,6 +4,10 @@ require: - rubocop-rspec inherit_from: .rubocop_todo.yml +Lint/RaiseException: + Enabled: true +Lint/StructNewOverride: + Enabled: true Layout/LineLength: Max: 150 Metrics/ClassLength: @@ -16,6 +20,12 @@ Style/FrozenStringLiteralComment: Enabled: false Style/SymbolArray: Enabled: false +Style/HashEachMethods: + Enabled: true +Style/HashTransformKeys: + Enabled: true +Style/HashTransformValues: + Enabled: true Style/GlobalVars: Exclude: - 'config/initializers/redis.rb' @@ -41,14 +51,58 @@ RSpec/NestedGroups: Max: 4 RSpec/MessageSpies: Enabled: false +Metrics/MethodLength: + Exclude: + - 'db/migrate/20161123131628_devise_token_auth_create_users.rb' +Rails/CreateTableWithTimestamps: + Exclude: + - 'db/migrate/20170207092002_acts_as_taggable_on_migration.acts_as_taggable_on_engine.rb' +Style/GuardClause: + Exclude: + - 'app/builders/account_builder.rb' + - 'app/models/attachment.rb' + - 'app/models/message.rb' + - 'lib/webhooks/chargebee.rb' + - 'db/migrate/20190819005836_add_missing_indexes_on_taggings.acts_as_taggable_on_engine.rb' +Metrics/AbcSize: + Exclude: + - 'db/migrate/20190819005836_add_missing_indexes_on_taggings.acts_as_taggable_on_engine.rb' +Metrics/CyclomaticComplexity: + Exclude: + - 'db/migrate/20190819005836_add_missing_indexes_on_taggings.acts_as_taggable_on_engine.rb' +Rails/ReversibleMigration: + Exclude: + - 'db/migrate/20161025070152_removechannelsfrommodels.rb' + - 'db/migrate/20161025070645_remchannel.rb' + - 'db/migrate/20161025070645_remchannel.rb' + - 'db/migrate/20161110102609_removeinboxid.rb' + - 'db/migrate/20170519091539_add_avatar_to_fb.rb' + - 'db/migrate/20191020085608_rename_old_tables.rb' + - 'db/migrate/20191126185833_update_user_invite_foreign_key.rb' + - 'db/migrate/20191130164019_add_template_type_to_messages.rb' +Rails/BulkChangeTable: + Exclude: + - 'db/migrate/20161025070152_removechannelsfrommodels.rb' + - 'db/migrate/20200121190901_create_account_users.rb' + - 'db/migrate/20170211092540_notnullableusers.rb' + - 'db/migrate/20170403095203_contactadder.rb' + - 'db/migrate/20170406104018_add_default_status_conv.rb' + - 'db/migrate/20170511134418_latlong.rb' + - 'db/migrate/20191027054756_create_contact_inboxes.rb' + - 'db/migrate/20191130164019_add_template_type_to_messages.rb' +Rails/UniqueValidationWithoutIndex: + Exclude: + - 'app/models/channel/twitter_profile.rb' + - 'app/models/webhook.rb' AllCops: Exclude: - - db/* - - bin/**/* - - db/**/* - - config/**/* - - public/**/* - - vendor/**/* - - node_modules/**/* - - lib/tasks/auto_annotate_models.rake - - config/environments/**/* + - 'bin/**/*' + - 'db/schema.rb' + - 'config/**/*' + - 'public/**/*' + - 'vendor/**/*' + - 'node_modules/**/*' + - 'lib/tasks/auto_annotate_models.rake' + - 'config/environments/**/*' + - 'tmp/**/*' + - 'storage/**/*' diff --git a/.rubocop_todo.yml b/.rubocop_todo.yml index 17dfb84f9..06a287ae1 100644 --- a/.rubocop_todo.yml +++ b/.rubocop_todo.yml @@ -282,15 +282,6 @@ Style/GlobalVars: Exclude: - 'lib/redis/alfred.rb' -# Offense count: 7 -# Configuration parameters: MinBodyLength. -Style/GuardClause: - Exclude: - - 'app/builders/account_builder.rb' - - 'app/models/attachment.rb' - - 'app/models/message.rb' - - 'lib/webhooks/chargebee.rb' - # Offense count: 4 Style/IdenticalConditionalBranches: Exclude: diff --git a/.ruby-version b/.ruby-version index 57cf282eb..24ba9a38d 100644 --- a/.ruby-version +++ b/.ruby-version @@ -1 +1 @@ -2.6.5 +2.7.0 diff --git a/.scss-lint.yml b/.scss-lint.yml index dadb2c2cd..9f5f4fe10 100644 --- a/.scss-lint.yml +++ b/.scss-lint.yml @@ -82,7 +82,7 @@ linters: enabled: true ImportantRule: - enabled: true + enabled: false ImportPath: enabled: true @@ -252,7 +252,7 @@ linters: enabled: false UnnecessaryParentReference: - enabled: true + enabled: false UrlFormat: enabled: true diff --git a/Gemfile b/Gemfile index d2f3465e2..9f4d3cf40 100644 --- a/Gemfile +++ b/Gemfile @@ -1,6 +1,6 @@ source 'https://rubygems.org' -ruby '2.6.5' +ruby '2.7.0' ##-- base gems for rails --## gem 'rack-cors', require: 'rack/cors' @@ -17,6 +17,7 @@ gem 'jbuilder' gem 'kaminari' gem 'responders' gem 'rest-client' +gem 'telephone_number' gem 'time_diff' gem 'tzinfo-data' gem 'valid_email2' @@ -25,11 +26,12 @@ gem 'uglifier' ##-- for active storage --## gem 'aws-sdk-s3', require: false -gem 'azure-storage', require: false +gem 'azure-storage-blob', require: false gem 'google-cloud-storage', require: false gem 'mini_magick' ##-- gems for database --# +gem 'groupdate' gem 'pg' gem 'redis' gem 'redis-namespace' @@ -61,9 +63,9 @@ gem 'chargebee' ##--- gems for channels ---## gem 'facebook-messenger' gem 'telegram-bot-ruby' +gem 'twilio-ruby', '~> 5.32.0' # twitty will handle subscription of twitter account events gem 'twitty', git: 'https://github.com/chatwoot/twitty' - # facebook client gem 'koala' # Random name generator @@ -78,11 +80,17 @@ gem 'sentry-raven' ##-- background job processing --## gem 'sidekiq' +##-- used for single column multiple binary flags in notification settings/feature flagging --## +gem 'flag_shih_tzu' + group :development do gem 'annotate' gem 'bullet' gem 'letter_opener' gem 'web-console' + + # used in swagger build + gem 'json_refs', git: 'https://github.com/tzmfreedom/json_refs', ref: 'e32deb0' end group :development, :test do @@ -93,7 +101,7 @@ group :development, :test do gem 'factory_bot_rails' gem 'faker' gem 'listen' - gem 'mock_redis' + gem 'mock_redis', git: 'https://github.com/sds/mock_redis', ref: '16d00789f0341a3aac35126c0ffe97a596753ff9' gem 'pry-rails' gem 'rspec-rails', '~> 4.0.0.beta2' gem 'rubocop', require: false diff --git a/Gemfile.lock b/Gemfile.lock index 9bb07458f..577676cf5 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,65 +1,80 @@ GIT remote: https://github.com/chatwoot/twitty - revision: c1edd557401d1e8a197b19e738f82e39507a8e2d + revision: af4f3e45dca55e42c64f7741a1fedfaa94d36419 specs: twitty (0.1.0) oauth +GIT + remote: https://github.com/sds/mock_redis + revision: 16d00789f0341a3aac35126c0ffe97a596753ff9 + ref: 16d00789f0341a3aac35126c0ffe97a596753ff9 + specs: + mock_redis (0.22.0) + +GIT + remote: https://github.com/tzmfreedom/json_refs + revision: e32deb073ce9aef39bdd63556bffd7fe7c2a803d + ref: e32deb0 + specs: + json_refs (0.1.2) + hana + GEM remote: https://rubygems.org/ specs: - action-cable-testing (0.6.0) + action-cable-testing (0.6.1) actioncable (>= 5.0) - actioncable (6.0.2.1) - actionpack (= 6.0.2.1) + actioncable (6.0.2.2) + actionpack (= 6.0.2.2) nio4r (~> 2.0) websocket-driver (>= 0.6.1) - actionmailbox (6.0.2.1) - actionpack (= 6.0.2.1) - activejob (= 6.0.2.1) - activerecord (= 6.0.2.1) - activestorage (= 6.0.2.1) - activesupport (= 6.0.2.1) + actionmailbox (6.0.2.2) + actionpack (= 6.0.2.2) + activejob (= 6.0.2.2) + activerecord (= 6.0.2.2) + activestorage (= 6.0.2.2) + activesupport (= 6.0.2.2) mail (>= 2.7.1) - actionmailer (6.0.2.1) - actionpack (= 6.0.2.1) - actionview (= 6.0.2.1) - activejob (= 6.0.2.1) + actionmailer (6.0.2.2) + actionpack (= 6.0.2.2) + actionview (= 6.0.2.2) + activejob (= 6.0.2.2) mail (~> 2.5, >= 2.5.4) rails-dom-testing (~> 2.0) - actionpack (6.0.2.1) - actionview (= 6.0.2.1) - activesupport (= 6.0.2.1) + actionpack (6.0.2.2) + actionview (= 6.0.2.2) + activesupport (= 6.0.2.2) rack (~> 2.0, >= 2.0.8) rack-test (>= 0.6.3) rails-dom-testing (~> 2.0) rails-html-sanitizer (~> 1.0, >= 1.2.0) - actiontext (6.0.2.1) - actionpack (= 6.0.2.1) - activerecord (= 6.0.2.1) - activestorage (= 6.0.2.1) - activesupport (= 6.0.2.1) + actiontext (6.0.2.2) + actionpack (= 6.0.2.2) + activerecord (= 6.0.2.2) + activestorage (= 6.0.2.2) + activesupport (= 6.0.2.2) nokogiri (>= 1.8.5) - actionview (6.0.2.1) - activesupport (= 6.0.2.1) + actionview (6.0.2.2) + activesupport (= 6.0.2.2) builder (~> 3.1) erubi (~> 1.4) rails-dom-testing (~> 2.0) rails-html-sanitizer (~> 1.1, >= 1.2.0) - activejob (6.0.2.1) - activesupport (= 6.0.2.1) + activejob (6.0.2.2) + activesupport (= 6.0.2.2) globalid (>= 0.3.6) - activemodel (6.0.2.1) - activesupport (= 6.0.2.1) - activerecord (6.0.2.1) - activemodel (= 6.0.2.1) - activesupport (= 6.0.2.1) - activestorage (6.0.2.1) - actionpack (= 6.0.2.1) - activejob (= 6.0.2.1) - activerecord (= 6.0.2.1) + activemodel (6.0.2.2) + activesupport (= 6.0.2.2) + activerecord (6.0.2.2) + activemodel (= 6.0.2.2) + activesupport (= 6.0.2.2) + activestorage (6.0.2.2) + actionpack (= 6.0.2.2) + activejob (= 6.0.2.2) + activerecord (= 6.0.2.2) marcel (~> 0.3.1) - activesupport (6.0.2.1) + activesupport (6.0.2.2) concurrent-ruby (~> 1.0, >= 1.0.2) i18n (>= 0.7, < 2) minitest (~> 5.1) @@ -69,46 +84,44 @@ GEM activerecord (>= 5.0, < 6.1) addressable (2.7.0) public_suffix (>= 2.0.2, < 5.0) - annotate (3.0.3) + annotate (3.1.1) activerecord (>= 3.2, < 7.0) rake (>= 10.4, < 14.0) ast (2.4.0) attr_extras (6.2.3) - aws-eventstream (1.0.3) - aws-partitions (1.269.0) - aws-sdk-core (3.89.1) - aws-eventstream (~> 1.0, >= 1.0.2) + aws-eventstream (1.1.0) + aws-partitions (1.296.0) + aws-sdk-core (3.94.0) + aws-eventstream (~> 1, >= 1.0.2) aws-partitions (~> 1, >= 1.239.0) aws-sigv4 (~> 1.1) jmespath (~> 1.0) - aws-sdk-kms (1.28.0) + aws-sdk-kms (1.30.0) aws-sdk-core (~> 3, >= 3.71.0) aws-sigv4 (~> 1.1) - aws-sdk-s3 (1.60.1) + aws-sdk-s3 (1.61.2) aws-sdk-core (~> 3, >= 3.83.0) aws-sdk-kms (~> 1) aws-sigv4 (~> 1.1) - aws-sigv4 (1.1.0) + aws-sigv4 (1.1.1) aws-eventstream (~> 1.0, >= 1.0.2) axiom-types (0.1.1) descendants_tracker (~> 0.0.4) ice_nine (~> 0.11.0) thread_safe (~> 0.3, >= 0.3.1) - azure-core (0.1.15) - faraday (~> 0.9) - faraday_middleware (~> 0.10) - nokogiri (~> 1.6) - azure-storage (0.15.0.preview) - azure-core (~> 0.1) - faraday (~> 0.9) - faraday_middleware (~> 0.10) - nokogiri (~> 1.6, >= 1.6.8) + azure-storage-blob (2.0.0) + azure-storage-common (~> 2.0) + nokogiri (~> 1.10.4) + azure-storage-common (2.0.1) + faraday (~> 1.0) + faraday_middleware (~> 1.0.0.rc1) + nokogiri (~> 1.10.4) bcrypt (3.1.13) bindex (0.8.1) - bootsnap (1.4.5) + bootsnap (1.4.6) msgpack (~> 1.0) - brakeman (4.7.2) - browser (3.0.3) + brakeman (4.8.1) + browser (4.0.0) builder (3.2.4) bullet (6.1.0) activesupport (>= 3.0.0) @@ -119,13 +132,13 @@ GEM bundler (>= 1.2.0, < 3) thor (~> 0.18) byebug (11.1.1) - chargebee (2.7.3) + chargebee (2.7.5) json_pure (~> 2.1) rest-client (>= 1.8, < 3.0) coderay (1.1.2) coercible (1.0.0) descendants_tracker (~> 0.0.1) - concurrent-ruby (1.1.5) + concurrent-ruby (1.1.6) connection_pool (2.2.2) crass (1.0.6) declarative (0.0.10) @@ -143,7 +156,7 @@ GEM devise (> 3.5.2, < 5) rails (>= 4.2.0, < 6.1) diff-lcs (1.3) - digest-crc (0.4.1) + digest-crc (0.5.1) docile (1.3.2) domain_name (0.5.20190701) unf (>= 0.0.5, < 1.0.0) @@ -157,22 +170,23 @@ GEM facebook-messenger (1.4.1) httparty (~> 0.13, >= 0.13.7) rack (>= 1.4.5) - factory_bot (5.1.1) + factory_bot (5.1.2) activesupport (>= 4.2.0) factory_bot_rails (5.1.1) factory_bot (~> 5.1.0) railties (>= 4.2.0) - faker (2.10.1) + faker (2.11.0) i18n (>= 1.6, < 2) - faraday (0.17.3) + faraday (1.0.1) multipart-post (>= 1.2, < 3) - faraday_middleware (0.14.0) - faraday (>= 0.7.4, < 1.0) + faraday_middleware (1.0.0) + faraday (~> 1.0) ffi (1.12.2) - foreman (0.87.0) + flag_shih_tzu (0.3.23) + foreman (0.87.1) globalid (0.4.2) activesupport (>= 4.2.0) - google-api-client (0.36.4) + google-api-client (0.38.0) addressable (~> 2.5, >= 2.5.1) googleauth (~> 0.9) httpclient (>= 2.8.1, < 3.0) @@ -183,29 +197,32 @@ GEM google-cloud-core (1.5.0) google-cloud-env (~> 1.0) google-cloud-errors (~> 1.0) - google-cloud-env (1.3.0) - faraday (~> 0.11) + google-cloud-env (1.3.1) + faraday (>= 0.17.3, < 2.0) google-cloud-errors (1.0.0) - google-cloud-storage (1.25.1) + google-cloud-storage (1.26.0) addressable (~> 2.5) digest-crc (~> 0.4) google-api-client (~> 0.33) google-cloud-core (~> 1.2) googleauth (~> 0.9) mini_mime (~> 1.0) - googleauth (0.10.0) - faraday (~> 0.12) + googleauth (0.12.0) + faraday (>= 0.17.3, < 2.0) jwt (>= 1.4, < 3.0) memoist (~> 0.16) multi_json (~> 1.11) os (>= 0.9, < 2.0) - signet (~> 0.12) + signet (~> 0.14) + groupdate (5.0.0) + activesupport (>= 5) haikunator (1.1.0) + hana (1.3.5) hashie (4.1.0) http-accept (1.7.0) http-cookie (1.0.3) domain_name (~> 0.5) - httparty (0.17.3) + httparty (0.18.0) mime-types (~> 3.0) multi_xml (>= 0.5.2) httpclient (2.8.3) @@ -214,11 +231,11 @@ GEM ice_nine (0.11.2) inflecto (0.0.2) jaro_winkler (1.5.4) - jbuilder (2.9.1) - activesupport (>= 4.2.0) + jbuilder (2.10.0) + activesupport (>= 5.0.0) jmespath (1.4.0) json (2.3.0) - json_pure (2.2.0) + json_pure (2.3.0) jwt (2.2.1) kaminari (1.2.0) activesupport (>= 4.1.0) @@ -236,14 +253,14 @@ GEM addressable faraday json (>= 1.8) - launchy (2.4.3) - addressable (~> 2.3) + launchy (2.5.0) + addressable (~> 2.7) letter_opener (1.7.0) launchy (~> 2.2) listen (3.2.1) rb-fsevent (~> 0.10, >= 0.10.3) rb-inotify (~> 0.9, >= 0.9.10) - loofah (2.4.0) + loofah (2.5.0) crass (~> 1.0.2) nokogiri (>= 1.5.9) mail (2.7.1) @@ -251,7 +268,7 @@ GEM marcel (0.3.3) mimemagic (~> 0.3.2) memoist (0.16.2) - method_source (0.9.2) + method_source (1.0.0) mime-types (3.3.1) mime-types-data (~> 3.2015) mime-types-data (3.2019.1009) @@ -260,35 +277,34 @@ GEM mini_mime (1.0.2) mini_portile2 (2.4.0) minitest (5.14.0) - mock_redis (0.22.0) - msgpack (1.3.1) + msgpack (1.3.3) multi_json (1.14.1) multi_xml (0.6.0) multipart-post (2.1.1) netrc (0.11.0) nightfury (1.0.1) nio4r (2.5.2) - nokogiri (1.10.7) + nokogiri (1.10.9) mini_portile2 (~> 2.4.0) oauth (0.5.4) orm_adapter (0.5.0) - os (1.0.1) + os (1.1.0) parallel (1.19.1) - parser (2.7.0.2) + parser (2.7.1.1) ast (~> 2.4.0) - pg (1.2.2) - pry (0.12.2) - coderay (~> 1.1.0) - method_source (~> 0.9.0) + pg (1.2.3) + pry (0.13.1) + coderay (~> 1.1) + method_source (~> 1.0) pry-rails (0.3.9) pry (>= 0.10.4) - public_suffix (4.0.3) - puma (4.3.1) + public_suffix (4.0.4) + puma (4.3.3) nio4r (~> 2.0) pundit (2.1.0) activesupport (>= 3.0.0) - rack (2.1.2) - rack-cache (1.11.0) + rack (2.2.2) + rack-cache (1.11.1) rack (>= 0.4) rack-cors (1.1.1) rack (>= 2.0.0) @@ -298,29 +314,29 @@ GEM rack rack-test (1.1.0) rack (>= 1.0, < 3) - rails (6.0.2.1) - actioncable (= 6.0.2.1) - actionmailbox (= 6.0.2.1) - actionmailer (= 6.0.2.1) - actionpack (= 6.0.2.1) - actiontext (= 6.0.2.1) - actionview (= 6.0.2.1) - activejob (= 6.0.2.1) - activemodel (= 6.0.2.1) - activerecord (= 6.0.2.1) - activestorage (= 6.0.2.1) - activesupport (= 6.0.2.1) + rails (6.0.2.2) + actioncable (= 6.0.2.2) + actionmailbox (= 6.0.2.2) + actionmailer (= 6.0.2.2) + actionpack (= 6.0.2.2) + actiontext (= 6.0.2.2) + actionview (= 6.0.2.2) + activejob (= 6.0.2.2) + activemodel (= 6.0.2.2) + activerecord (= 6.0.2.2) + activestorage (= 6.0.2.2) + activesupport (= 6.0.2.2) bundler (>= 1.3.0) - railties (= 6.0.2.1) + railties (= 6.0.2.2) sprockets-rails (>= 2.0.0) rails-dom-testing (2.0.3) activesupport (>= 4.2.0) nokogiri (>= 1.6) rails-html-sanitizer (1.3.0) loofah (~> 2.3) - railties (6.0.2.1) - actionpack (= 6.0.2.1) - activesupport (= 6.0.2.1) + railties (6.0.2.2) + actionpack (= 6.0.2.2) + activesupport (= 6.0.2.2) method_source rake (>= 0.8.7) thor (>= 0.20.3, < 2.0) @@ -335,7 +351,7 @@ GEM redis-rack-cache (2.2.1) rack-cache (>= 1.10, < 2) redis-store (>= 1.6, < 2) - redis-store (1.8.1) + redis-store (1.8.2) redis (>= 4, < 5) representable (3.0.4) declarative (< 0.1.0) @@ -350,15 +366,16 @@ GEM mime-types (>= 1.16, < 4.0) netrc (~> 0.8) retriable (3.1.2) + rexml (3.2.4) rspec-core (3.9.1) rspec-support (~> 3.9.1) - rspec-expectations (3.9.0) + rspec-expectations (3.9.1) diff-lcs (>= 1.2.0, < 2.0) rspec-support (~> 3.9.0) rspec-mocks (3.9.1) diff-lcs (>= 1.2.0, < 2.0) rspec-support (~> 3.9.0) - rspec-rails (4.0.0.beta4) + rspec-rails (4.0.0) actionpack (>= 4.2) activesupport (>= 4.2) railties (>= 4.2) @@ -367,19 +384,21 @@ GEM rspec-mocks (~> 3.9) rspec-support (~> 3.9) rspec-support (3.9.2) - rubocop (0.79.0) + rubocop (0.81.0) jaro_winkler (~> 1.5.1) parallel (~> 1.10) parser (>= 2.7.0.1) rainbow (>= 2.2.2, < 4.0) + rexml ruby-progressbar (~> 1.7) - unicode-display_width (>= 1.4.0, < 1.7) + unicode-display_width (>= 1.4.0, < 2.0) rubocop-performance (1.5.2) rubocop (>= 0.71.0) - rubocop-rails (2.4.2) + rubocop-rails (2.5.2) + activesupport rack (>= 1.1) rubocop (>= 0.72.0) - rubocop-rspec (1.37.1) + rubocop-rspec (1.38.1) rubocop (>= 0.68.1) ruby-progressbar (1.10.1) sass (3.7.4) @@ -387,25 +406,26 @@ GEM sass-listen (4.0.0) rb-fsevent (~> 0.9, >= 0.9.4) rb-inotify (~> 0.9, >= 0.9.7) - scout_apm (2.6.6) + scout_apm (2.6.7) parser scss_lint (0.59.0) sass (~> 3.5, >= 3.5.5) seed_dump (3.3.1) activerecord (>= 4) activesupport (>= 4) - sentry-raven (2.13.0) - faraday (>= 0.7.6, < 1.0) - shoulda-matchers (4.2.0) + semantic_range (2.3.0) + sentry-raven (3.0.0) + faraday (>= 1.0) + shoulda-matchers (4.3.0) activesupport (>= 4.2.0) - sidekiq (6.0.4) + sidekiq (6.0.6) connection_pool (>= 2.2.2) - rack (>= 2.0.0) + rack (~> 2.0) rack-protection (>= 2.0.0) redis (>= 4.1.0) - signet (0.12.0) + signet (0.14.0) addressable (~> 2.3) - faraday (~> 0.9) + faraday (>= 0.17.3, < 2.0) jwt (>= 1.5, < 3.0) multi_json (~> 1.10) simplecov (0.17.1) @@ -428,12 +448,17 @@ GEM faraday inflecto virtus + telephone_number (1.4.6) thor (0.20.3) thread_safe (0.3.6) time_diff (0.3.0) activesupport i18n - tzinfo (1.2.6) + twilio-ruby (5.32.0) + faraday (~> 1.0.0) + jwt (>= 1.5, <= 2.5) + nokogiri (>= 1.6, < 2.0) + tzinfo (1.2.7) thread_safe (~> 0.1) tzinfo-data (1.2019.3) tzinfo (>= 1.0.0) @@ -442,10 +467,10 @@ GEM execjs (>= 0.3.0, < 3) unf (0.1.4) unf_ext - unf_ext (0.0.7.6) - unicode-display_width (1.6.1) + unf_ext (0.0.7.7) + unicode-display_width (1.7.0) uniform_notifier (1.13.0) - valid_email2 (3.1.3) + valid_email2 (3.2.2) activemodel (>= 3.2) mail (~> 2.5) virtus (1.0.5) @@ -460,15 +485,16 @@ GEM activemodel (>= 6.0.0) bindex (>= 0.4.0) railties (>= 6.0.0) - webpacker (4.2.2) - activesupport (>= 4.2) + webpacker (5.0.1) + activesupport (>= 5.2) rack-proxy (>= 0.6.1) - railties (>= 4.2) + railties (>= 5.2) + semantic_range (>= 2.3.0) websocket-driver (0.7.1) websocket-extensions (>= 0.1.0) websocket-extensions (0.1.4) wisper (2.0.0) - zeitwerk (2.2.2) + zeitwerk (2.3.0) PLATFORMS ruby @@ -479,7 +505,7 @@ DEPENDENCIES annotate attr_extras aws-sdk-s3 - azure-storage + azure-storage-blob bootsnap brakeman browser @@ -493,18 +519,21 @@ DEPENDENCIES facebook-messenger factory_bot_rails faker + flag_shih_tzu foreman google-cloud-storage + groupdate haikunator hashie jbuilder + json_refs! jwt kaminari koala letter_opener listen mini_magick - mock_redis + mock_redis! nightfury pg pry-rails @@ -532,7 +561,9 @@ DEPENDENCIES spring spring-watcher-listen telegram-bot-ruby + telephone_number time_diff + twilio-ruby (~> 5.32.0) twitty! tzinfo-data uglifier @@ -542,7 +573,7 @@ DEPENDENCIES wisper (= 2.0.0) RUBY VERSION - ruby 2.6.5p114 + ruby 2.7.0p0 BUNDLED WITH - 2.0.2 + 2.1.2 diff --git a/README.md b/README.md index 117a1fc9e..e71b8c6d1 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,5 @@

- Woot-logo + Woot-logo

A simple and elegant live chat software
An opensource alternative to Intercom, Zendesk, Drift, Crisp etc.
@@ -23,7 +23,7 @@ ___ Chat on Discord

-![ChatUI progess](https://storage.googleapis.com/chatwoot-assets/dashboard-screen.png) +![ChatUI progess](https://s3.us-west-2.amazonaws.com/gh-assets.chatwoot.com/chatwoot-dashboard-assets.png) ## Background @@ -52,16 +52,10 @@ Follow this [link](https://www.chatwoot.com/docs/environment-variables) to under ## Docker -You can use our official Docker image from [https://hub.docker.com/r/chatwoot/chatwoot](https://hub.docker.com/r/chatwoot/chatwoot) - -```bash -docker pull chatwoot/chatwoot -``` +Follow our [docker development guide](https://www.chatwoot.com/docs/installation-guide-docker) to develop and debug the application using `docker-compose`. Follow our [environment variables](https://www.chatwoot.com/docs/environment-variables/) guide to setup environment for Docker. -Follow our [docker development guide](https://www.chatwoot.com/docs/installation-guide-docker) to develop and debug the application using docker composer. - ## Contributors ✨ Thanks goes to all these [wonderful people](https://www.chatwoot.com/docs/contributors): diff --git a/app/actions/contact_identify_action.rb b/app/actions/contact_identify_action.rb new file mode 100644 index 000000000..75af71a1f --- /dev/null +++ b/app/actions/contact_identify_action.rb @@ -0,0 +1,47 @@ +class ContactIdentifyAction + pattr_initialize [:contact!, :params!] + + def perform + ActiveRecord::Base.transaction do + @contact = merge_contact(existing_identified_contact, @contact) if merge_contacts?(existing_identified_contact, @contact) + @contact = merge_contact(existing_email_contact, @contact) if merge_contacts?(existing_email_contact, @contact) + update_contact + end + @contact + end + + private + + def account + @account ||= @contact.account + end + + def existing_identified_contact + return if params[:identifier].blank? + + @existing_identified_contact ||= Contact.where(account_id: account.id).find_by(identifier: params[:identifier]) + end + + def existing_email_contact + return if params[:email].blank? + + @existing_email_contact ||= Contact.where(account_id: account.id).find_by(email: params[:email]) + end + + def merge_contacts?(existing_contact, _contact) + existing_contact && existing_contact.id != @contact.id + end + + def update_contact + @contact.update!(params.slice(:name, :email, :identifier)) + ContactAvatarJob.perform_later(@contact, params[:avatar_url]) if params[:avatar_url].present? + end + + def merge_contact(base_contact, merge_contact) + ContactMergeAction.new( + account: account, + base_contact: base_contact, + mergee_contact: merge_contact + ).perform + end +end diff --git a/app/actions/contact_merge_action.rb b/app/actions/contact_merge_action.rb index 61a26e185..343e78032 100644 --- a/app/actions/contact_merge_action.rb +++ b/app/actions/contact_merge_action.rb @@ -5,9 +5,11 @@ class ContactMergeAction ActiveRecord::Base.transaction do validate_contacts merge_conversations + merge_messages merge_contact_inboxes remove_mergee_contact end + @base_contact end private @@ -15,7 +17,7 @@ class ContactMergeAction def validate_contacts return if belongs_to_account?(@base_contact) && belongs_to_account?(@mergee_contact) - raise Exception, 'contact does not belong to the account' + raise StandardError, 'contact does not belong to the account' end def belongs_to_account?(contact) @@ -26,6 +28,10 @@ class ContactMergeAction Conversation.where(contact_id: @mergee_contact.id).update(contact_id: @base_contact.id) end + def merge_messages + Message.where(contact_id: @mergee_contact.id).update(contact_id: @base_contact.id) + end + def merge_contact_inboxes ContactInbox.where(contact_id: @mergee_contact.id).update(contact_id: @base_contact.id) end diff --git a/app/builders/account_builder.rb b/app/builders/account_builder.rb index c02bea13f..126eedce0 100644 --- a/app/builders/account_builder.rb +++ b/app/builders/account_builder.rb @@ -42,18 +42,26 @@ class AccountBuilder def create_and_link_user password = Time.now.to_i - @user = @account.users.new(email: @email, - password: password, - password_confirmation: password, - role: User.roles['administrator'], - name: email_to_name(@email)) + @user = User.new(email: @email, + password: password, + password_confirmation: password, + name: email_to_name(@email)) if @user.save! + link_user_to_account(@user, @account) @user else raise UserErrors.new(errors: @user.errors) end end + def link_user_to_account(user, account) + AccountUser.create!( + account_id: account.id, + user_id: user.id, + role: AccountUser.roles['administrator'] + ) + end + def email_to_name(email) name = email[/[^@]+/] name.split('.').map(&:capitalize).join(' ') diff --git a/app/builders/contact_builder.rb b/app/builders/contact_builder.rb new file mode 100644 index 000000000..9bb3ef3ac --- /dev/null +++ b/app/builders/contact_builder.rb @@ -0,0 +1,38 @@ +class ContactBuilder + pattr_initialize [:source_id!, :inbox!, :contact_attributes!] + + def perform + contact_inbox = inbox.contact_inboxes.find_by(source_id: source_id) + return contact_inbox if contact_inbox + + build_contact + end + + private + + def account + @account ||= inbox.account + end + + def build_contact + ActiveRecord::Base.transaction do + contact = account.contacts.create!( + name: contact_attributes[:name], + phone_number: contact_attributes[:phone_number], + email: contact_attributes[:email], + identifier: contact_attributes[:identifier], + additional_attributes: contact_attributes[:additional_attributes] + ) + contact_inbox = ::ContactInbox.create!( + contact_id: contact.id, + inbox_id: inbox.id, + source_id: source_id + ) + + ::ContactAvatarJob.perform_later(contact, contact_attributes[:avatar_url]) if contact_attributes[:avatar_url] + contact_inbox + rescue StandardError => e + Rails.logger e + end + end +end diff --git a/app/builders/messages/message_builder.rb b/app/builders/messages/message_builder.rb index 6d3a7d39b..0eeba6b95 100644 --- a/app/builders/messages/message_builder.rb +++ b/app/builders/messages/message_builder.rb @@ -1,5 +1,3 @@ -require 'open-uri' - # This class creates both outgoing messages from chatwoot and echo outgoing messages based on the flag `outgoing_echo` # Assumptions # 1. Incase of an outgoing message which is echo, source_id will NOT be nil, @@ -36,16 +34,14 @@ class Messages::MessageBuilder return if contact.present? @contact = Contact.create!(contact_params.except(:remote_avatar_url)) - avatar_resource = LocalResource.new(contact_params[:remote_avatar_url]) - @contact.avatar.attach(io: avatar_resource.file, filename: avatar_resource.tmp_filename, content_type: avatar_resource.encoding) - + ContactAvatarJob.perform_later(@contact, contact_params[:remote_avatar_url]) if contact_params[:remote_avatar_url] @contact_inbox = ContactInbox.create(contact: contact, inbox: @inbox, source_id: @sender_id) end def build_message @message = conversation.messages.create!(message_params) (response.attachments || []).each do |attachment| - attachment_obj = @message.build_attachment(attachment_params(attachment).except(:remote_file_url)) + attachment_obj = @message.attachments.new(attachment_params(attachment).except(:remote_file_url)) attachment_obj.save! attach_file(attachment_obj, attachment_params(attachment)[:remote_file_url]) if attachment_params(attachment)[:remote_file_url] end diff --git a/app/builders/messages/outgoing/normal_builder.rb b/app/builders/messages/outgoing/normal_builder.rb index b75e8f84b..1b15f24c0 100644 --- a/app/builders/messages/outgoing/normal_builder.rb +++ b/app/builders/messages/outgoing/normal_builder.rb @@ -1,16 +1,31 @@ class Messages::Outgoing::NormalBuilder + include ::FileTypeHelper attr_reader :message def initialize(user, conversation, params) - @content = params[:message] - @private = ['1', 'true', 1, true].include? params[:private] + @content = params[:content] + @private = params[:private] || false @conversation = conversation @user = user @fb_id = params[:fb_id] + @content_type = params[:content_type] + @items = params.to_unsafe_h&.dig(:content_attributes, :items) + @attachments = params[:attachments] end def perform - @message = @conversation.messages.create!(message_params) + @message = @conversation.messages.build(message_params) + if @attachments.present? + @attachments.each do |uploaded_attachment| + attachment = @message.attachments.new( + account_id: @message.account_id, + file_type: file_type(uploaded_attachment&.content_type) + ) + attachment.file.attach(uploaded_attachment) + end + end + @message.save + @message end private @@ -22,8 +37,10 @@ class Messages::Outgoing::NormalBuilder message_type: :outgoing, content: @content, private: @private, - user_id: @user.id, - source_id: @fb_id + user_id: @user&.id, + source_id: @fb_id, + content_type: @content_type, + items: @items } end end diff --git a/app/builders/v2/report_builder.rb b/app/builders/v2/report_builder.rb new file mode 100644 index 000000000..35f484f95 --- /dev/null +++ b/app/builders/v2/report_builder.rb @@ -0,0 +1,111 @@ +class V2::ReportBuilder + attr_reader :account, :params + + def initialize(account, params) + @account = account + @params = params + end + + def timeseries + send(params[:metric]) + end + + # For backward compatible with old report + def build + timeseries.each_with_object([]) do |p, arr| + arr << { value: p[1], timestamp: p[0].to_time.to_i } + end + end + + def summary + { + conversations_count: conversations_count.values.sum, + incoming_messages_count: incoming_messages_count.values.sum, + outgoing_messages_count: outgoing_messages_count.values.sum, + avg_first_response_time: avg_first_response_time_summary, + avg_resolution_time: avg_resolution_time_summary, + resolutions_count: resolutions_count.values.sum + } + end + + private + + def scope + return account if params[:type].match?('account') + return inbox if params[:type].match?('inbox') + return user if params[:type].match?('agent') + end + + def inbox + @inbox ||= account.inboxes.where(id: params[:id]).first + end + + def user + @user ||= account.users.where(id: params[:id]).first + end + + def conversations_count + scope.conversations + .group_by_day(:created_at, range: range, default_value: 0) + .count + end + + # unscoped removes all scopes added to a model previously + def incoming_messages_count + scope.messages.unscoped.where(account_id: account.id).incoming + .group_by_day(:created_at, range: range, default_value: 0) + .count + end + + def outgoing_messages_count + scope.messages.unscoped.where(account_id: account.id).outgoing + .group_by_day(:created_at, range: range, default_value: 0) + .count + end + + def resolutions_count + scope.conversations + .resolved + .group_by_day(:created_at, range: range, default_value: 0) + .count + end + + def avg_first_response_time + scope.events + .where(name: 'first_response') + .group_by_day(:created_at, range: range, default_value: 0) + .average(:value) + end + + def avg_resolution_time + scope.events.where(name: 'conversation_resolved') + .group_by_day(:created_at, range: range, default_value: 0) + .average(:value) + end + + def range + parse_date_time(params[:since])..parse_date_time(params[:until]) + end + + # Taking average of average is not too accurate + # https://en.wikipedia.org/wiki/Simpson's_paradox + # TODO: Will optimize this later + def avg_resolution_time_summary + return 0 if avg_resolution_time.values.empty? + + (avg_resolution_time.values.sum / avg_resolution_time.values.length) + end + + def avg_first_response_time_summary + return 0 if avg_first_response_time.values.empty? + + (avg_first_response_time.values.sum / avg_first_response_time.values.length) + end + + def parse_date_time(datetime) + return datetime if datetime.is_a?(DateTime) + return datetime.to_datetime if datetime.is_a?(Time) || datetime.is_a?(Date) + + DateTime.strptime(datetime, '%s') + end +end diff --git a/app/controllers/api/base_controller.rb b/app/controllers/api/base_controller.rb index 097a40dab..8db37ced7 100644 --- a/app/controllers/api/base_controller.rb +++ b/app/controllers/api/base_controller.rb @@ -1,9 +1,16 @@ class Api::BaseController < ApplicationController + include AccessTokenAuthHelper respond_to :json - before_action :authenticate_user! + before_action :authenticate_access_token!, if: :authenticate_by_access_token? + before_action :validate_bot_access_token!, if: :authenticate_by_access_token? + before_action :authenticate_user!, unless: :authenticate_by_access_token? private + def authenticate_by_access_token? + request.headers[:api_access_token].present? || request.headers[:HTTP_API_ACCESS_TOKEN].present? + end + def set_conversation @conversation ||= current_account.conversations.find_by(display_id: params[:conversation_id]) end diff --git a/app/controllers/api/v1/accounts/accounts_controller.rb b/app/controllers/api/v1/accounts/accounts_controller.rb new file mode 100644 index 000000000..0fd5dc7cf --- /dev/null +++ b/app/controllers/api/v1/accounts/accounts_controller.rb @@ -0,0 +1,54 @@ +class Api::V1::Accounts::AccountsController < Api::BaseController + include AuthHelper + + skip_before_action :verify_authenticity_token, only: [:create] + skip_before_action :authenticate_user!, :set_current_user, :check_subscription, :handle_with_exception, + only: [:create], raise: false + before_action :check_signup_enabled, only: [:create] + before_action :check_authorization, except: [:create] + before_action :fetch_account, except: [:create] + + rescue_from CustomExceptions::Account::InvalidEmail, + CustomExceptions::Account::UserExists, + CustomExceptions::Account::UserErrors, + with: :render_error_response + + def create + @user = AccountBuilder.new( + account_name: account_params[:account_name], + email: account_params[:email] + ).perform + if @user + send_auth_headers(@user) + render 'devise/auth.json', locals: { resource: @user } + else + render_error_response(CustomExceptions::Account::SignupFailed.new({})) + end + end + + def show + render 'api/v1/accounts/show.json' + end + + def update + @account.update!(account_params.slice(:name, :locale, :domain, :support_email, :domain_emails_enabled)) + end + + private + + def check_authorization + authorize(Account) + end + + def fetch_account + @account = current_user.accounts.find(params[:id]) + end + + def account_params + params.permit(:account_name, :email, :name, :locale, :domain, :support_email, :domain_emails_enabled) + end + + def check_signup_enabled + raise ActionController::RoutingError, 'Not Found' if ENV.fetch('ENABLE_ACCOUNT_SIGNUP', true) == 'false' + end +end diff --git a/app/controllers/api/v1/actions/contact_merges_controller.rb b/app/controllers/api/v1/accounts/actions/contact_merges_controller.rb similarity index 88% rename from app/controllers/api/v1/actions/contact_merges_controller.rb rename to app/controllers/api/v1/accounts/actions/contact_merges_controller.rb index 2eead4869..1296b6a55 100644 --- a/app/controllers/api/v1/actions/contact_merges_controller.rb +++ b/app/controllers/api/v1/accounts/actions/contact_merges_controller.rb @@ -1,4 +1,4 @@ -class Api::V1::Actions::ContactMergesController < Api::BaseController +class Api::V1::Accounts::Actions::ContactMergesController < Api::BaseController before_action :set_base_contact, only: [:create] before_action :set_mergee_contact, only: [:create] diff --git a/app/controllers/api/v1/accounts/agents_controller.rb b/app/controllers/api/v1/accounts/agents_controller.rb new file mode 100644 index 000000000..ca796ceef --- /dev/null +++ b/app/controllers/api/v1/accounts/agents_controller.rb @@ -0,0 +1,69 @@ +class Api::V1::Accounts::AgentsController < Api::BaseController + before_action :fetch_agent, except: [:create, :index] + before_action :check_authorization + before_action :find_user, only: [:create] + before_action :create_user, only: [:create] + before_action :save_account_user, only: [:create] + + def index + @agents = agents + end + + def destroy + @agent.account_user.destroy + head :ok + end + + def update + @agent.update!(agent_params.except(:role)) + @agent.account_user.update!(role: agent_params[:role]) if agent_params[:role] + render 'api/v1/models/user.json', locals: { resource: @agent } + end + + def create + render 'api/v1/models/user.json', locals: { resource: @user } + end + + private + + def check_authorization + authorize(User) + end + + def fetch_agent + @agent = agents.find(params[:id]) + end + + def find_user + @user = User.find_by(email: new_agent_params[:email]) + end + + def create_user + return if @user + + @user = User.create!(new_agent_params.slice(:email, :name, :password, :password_confirmation)) + end + + def save_account_user + AccountUser.create!( + account_id: current_account.id, + user_id: @user.id, + role: new_agent_params[:role], + inviter_id: current_user.id + ) + end + + def agent_params + params.require(:agent).permit(:email, :name, :role) + end + + def new_agent_params + time = Time.now.to_i + params.require(:agent).permit(:email, :name, :role) + .merge!(password: time, password_confirmation: time, inviter: current_user) + end + + def agents + @agents ||= current_account.users + end +end diff --git a/app/controllers/api/v1/accounts/callbacks_controller.rb b/app/controllers/api/v1/accounts/callbacks_controller.rb new file mode 100644 index 000000000..bf42b215a --- /dev/null +++ b/app/controllers/api/v1/accounts/callbacks_controller.rb @@ -0,0 +1,105 @@ +class Api::V1::Accounts::CallbacksController < Api::BaseController + before_action :inbox, only: [:reauthorize_page] + + def register_facebook_page + user_access_token = params[:user_access_token] + page_access_token = params[:page_access_token] + page_id = params[:page_id] + inbox_name = params[:inbox_name] + ActiveRecord::Base.transaction do + facebook_channel = current_account.facebook_pages.create!( + page_id: page_id, user_access_token: user_access_token, + page_access_token: page_access_token + ) + @facebook_inbox = current_account.inboxes.create!(name: inbox_name, channel: facebook_channel) + set_avatar(@facebook_inbox, page_id) + rescue StandardError => e + Rails.logger e + end + end + + def facebook_pages + @page_details = mark_already_existing_facebook_pages(fb_object.get_connections('me', 'accounts')) + end + + # get params[:inbox_id], current_account, params[:omniauth_token] + def reauthorize_page + if @inbox&.facebook? + fb_page_id = @inbox.channel.page_id + page_details = fb_object.get_connections('me', 'accounts') + + if (page_detail = (page_details || []).detect { |page| fb_page_id == page['id'] }) + update_fb_page(fb_page_id, page_detail['access_token']) + return head :ok + end + end + + head :unprocessable_entity + end + + private + + def inbox + @inbox = current_account.inboxes.find_by(id: params[:inbox_id]) + end + + def update_fb_page(fb_page_id, access_token) + get_fb_page(fb_page_id)&.update!( + user_access_token: @user_access_token, page_access_token: access_token + ) + end + + def get_fb_page(fb_page_id) + current_account.facebook_pages.find_by(page_id: fb_page_id) + end + + def fb_object + @user_access_token = long_lived_token(params[:omniauth_token]) + Koala::Facebook::API.new(@user_access_token) + end + + def long_lived_token(omniauth_token) + koala = Koala::Facebook::OAuth.new(ENV['FB_APP_ID'], ENV['FB_APP_SECRET']) + koala.exchange_access_token_info(omniauth_token)['access_token'] + rescue StandardError => e + Rails.logger e + end + + def mark_already_existing_facebook_pages(data) + return [] if data.empty? + + data.inject([]) do |result, page_detail| + page_detail[:exists] = current_account.facebook_pages.exists?(page_id: page_detail['id']) ? true : false + result << page_detail + end + end + + def set_avatar(facebook_inbox, page_id) + uri = get_avatar_url(page_id) + + return unless uri + + avatar_resource = LocalResource.new(uri) + facebook_inbox.avatar.attach(io: avatar_resource.file, filename: avatar_resource.tmp_filename, content_type: avatar_resource.encoding) + end + + def get_avatar_url(page_id) + begin + url = 'http://graph.facebook.com/' << page_id << '/picture?type=large' + uri = URI.parse(url) + tries = 3 + begin + response = uri.open(redirect: false) + rescue OpenURI::HTTPRedirect => e + uri = e.uri # assigned from the "Location" response header + retry if (tries -= 1).positive? + raise + end + pic_url = response.base_uri.to_s + rescue StandardError => e + Rails.logger.debug "Rescued: #{e.inspect}" + pic_url = nil + end + pic_url + end +end diff --git a/app/controllers/api/v1/canned_responses_controller.rb b/app/controllers/api/v1/accounts/canned_responses_controller.rb similarity index 92% rename from app/controllers/api/v1/canned_responses_controller.rb rename to app/controllers/api/v1/accounts/canned_responses_controller.rb index aa82ea3c4..b76da5f8c 100644 --- a/app/controllers/api/v1/canned_responses_controller.rb +++ b/app/controllers/api/v1/accounts/canned_responses_controller.rb @@ -1,4 +1,4 @@ -class Api::V1::CannedResponsesController < Api::BaseController +class Api::V1::Accounts::CannedResponsesController < Api::BaseController before_action :fetch_canned_response, only: [:update, :destroy] def index diff --git a/app/controllers/api/v1/accounts/channels/twilio_channels_controller.rb b/app/controllers/api/v1/accounts/channels/twilio_channels_controller.rb new file mode 100644 index 000000000..c0c121900 --- /dev/null +++ b/app/controllers/api/v1/accounts/channels/twilio_channels_controller.rb @@ -0,0 +1,57 @@ +class Api::V1::Accounts::Channels::TwilioChannelsController < Api::BaseController + before_action :authorize_request + + def create + ActiveRecord::Base.transaction do + authenticate_twilio + build_inbox + setup_webhooks if @twilio_channel.sms? + rescue Twilio::REST::TwilioError => e + render_could_not_create_error(e.message) + rescue StandardError => e + render_could_not_create_error(e.message) + end + end + + private + + def authorize_request + authorize ::Inbox + end + + def authenticate_twilio + client = Twilio::REST::Client.new(permitted_params[:account_sid], permitted_params[:auth_token]) + client.messages.list(limit: 1) + end + + def setup_webhooks + ::Twilio::WebhookSetupService.new(inbox: @inbox).perform + end + + def phone_number + medium == 'sms' ? permitted_params[:phone_number] : "whatsapp:#{permitted_params[:phone_number]}" + end + + def medium + permitted_params[:medium] + end + + def build_inbox + @twilio_channel = current_account.twilio_sms.create!( + account_sid: permitted_params[:account_sid], + auth_token: permitted_params[:auth_token], + phone_number: phone_number, + medium: medium + ) + @inbox = current_account.inboxes.create( + name: permitted_params[:name], + channel: @twilio_channel + ) + end + + def permitted_params + params.require(:twilio_channel).permit( + :account_id, :phone_number, :account_sid, :auth_token, :name, :medium + ) + end +end diff --git a/app/controllers/api/v1/contacts/conversations_controller.rb b/app/controllers/api/v1/accounts/contacts/conversations_controller.rb similarity index 85% rename from app/controllers/api/v1/contacts/conversations_controller.rb rename to app/controllers/api/v1/accounts/contacts/conversations_controller.rb index bce503ad6..8fcb4df13 100644 --- a/app/controllers/api/v1/contacts/conversations_controller.rb +++ b/app/controllers/api/v1/accounts/contacts/conversations_controller.rb @@ -1,4 +1,4 @@ -class Api::V1::Contacts::ConversationsController < Api::BaseController +class Api::V1::Accounts::Contacts::ConversationsController < Api::BaseController def index @conversations = current_account.conversations.includes( :assignee, :contact, :inbox diff --git a/app/controllers/api/v1/contacts_controller.rb b/app/controllers/api/v1/accounts/contacts_controller.rb similarity index 93% rename from app/controllers/api/v1/contacts_controller.rb rename to app/controllers/api/v1/accounts/contacts_controller.rb index b5885d708..9d95f69aa 100644 --- a/app/controllers/api/v1/contacts_controller.rb +++ b/app/controllers/api/v1/accounts/contacts_controller.rb @@ -1,4 +1,4 @@ -class Api::V1::ContactsController < Api::BaseController +class Api::V1::Accounts::ContactsController < Api::BaseController protect_from_forgery with: :null_session before_action :check_authorization diff --git a/app/controllers/api/v1/conversations/assignments_controller.rb b/app/controllers/api/v1/accounts/conversations/assignments_controller.rb similarity index 69% rename from app/controllers/api/v1/conversations/assignments_controller.rb rename to app/controllers/api/v1/accounts/conversations/assignments_controller.rb index e411022fe..6da3c05da 100644 --- a/app/controllers/api/v1/conversations/assignments_controller.rb +++ b/app/controllers/api/v1/accounts/conversations/assignments_controller.rb @@ -1,7 +1,8 @@ -class Api::V1::Conversations::AssignmentsController < Api::BaseController +class Api::V1::Accounts::Conversations::AssignmentsController < Api::BaseController before_action :set_conversation, only: [:create] - def create # assign agent to a conversation + # assign agent to a conversation + def create # if params[:assignee_id] is not a valid id, it will set to nil, hence unassigning the conversation assignee = current_account.users.find_by(id: params[:assignee_id]) @conversation.update_assignee(assignee) diff --git a/app/controllers/api/v1/conversations/labels_controller.rb b/app/controllers/api/v1/accounts/conversations/labels_controller.rb similarity index 61% rename from app/controllers/api/v1/conversations/labels_controller.rb rename to app/controllers/api/v1/accounts/conversations/labels_controller.rb index e9074ac03..3e80e2825 100644 --- a/app/controllers/api/v1/conversations/labels_controller.rb +++ b/app/controllers/api/v1/accounts/conversations/labels_controller.rb @@ -1,4 +1,4 @@ -class Api::V1::Conversations::LabelsController < Api::BaseController +class Api::V1::Accounts::Conversations::LabelsController < Api::BaseController before_action :set_conversation, only: [:create, :index] def create @@ -6,7 +6,8 @@ class Api::V1::Conversations::LabelsController < Api::BaseController @labels = @conversation.label_list end - def index # all labels of the current conversation + # all labels of the current conversation + def index @labels = @conversation.label_list end end diff --git a/app/controllers/api/v1/accounts/conversations/messages_controller.rb b/app/controllers/api/v1/accounts/conversations/messages_controller.rb new file mode 100644 index 000000000..d0ed39edb --- /dev/null +++ b/app/controllers/api/v1/accounts/conversations/messages_controller.rb @@ -0,0 +1,18 @@ +class Api::V1::Accounts::Conversations::MessagesController < Api::BaseController + before_action :set_conversation, only: [:index, :create] + + def index + @messages = message_finder.perform + end + + def create + mb = Messages::Outgoing::NormalBuilder.new(current_user, @conversation, params) + @message = mb.perform + end + + private + + def message_finder + @message_finder ||= MessageFinder.new(@conversation, params) + end +end diff --git a/app/controllers/api/v1/conversations_controller.rb b/app/controllers/api/v1/accounts/conversations_controller.rb similarity index 53% rename from app/controllers/api/v1/conversations_controller.rb rename to app/controllers/api/v1/accounts/conversations_controller.rb index 6c44114be..3050c8ab7 100644 --- a/app/controllers/api/v1/conversations_controller.rb +++ b/app/controllers/api/v1/accounts/conversations_controller.rb @@ -1,5 +1,6 @@ -class Api::V1::ConversationsController < Api::BaseController - before_action :set_conversation, except: [:index] +class Api::V1::Accounts::ConversationsController < Api::BaseController + before_action :conversation, except: [:index] + before_action :contact_inbox, only: [:create] def index result = conversation_finder.perform @@ -7,10 +8,12 @@ class Api::V1::ConversationsController < Api::BaseController @conversations_count = result[:count] end - def show - @messages = messages_finder.perform + def create + @conversation = ::Conversation.create!(conversation_params) end + def show; end + def toggle_status @status = @conversation.toggle_status end @@ -27,15 +30,24 @@ class Api::V1::ConversationsController < Api::BaseController DateTime.strptime(params[:agent_last_seen_at].to_s, '%s') end - def set_conversation + def conversation @conversation ||= current_account.conversations.find_by(display_id: params[:id]) end + def contact_inbox + @contact_inbox ||= ::ContactInbox.find_by!(source_id: params[:source_id]) + end + + def conversation_params + { + account_id: current_account.id, + inbox_id: @contact_inbox.inbox_id, + contact_id: @contact_inbox.contact_id, + contact_inbox_id: @contact_inbox.id + } + end + def conversation_finder @conversation_finder ||= ConversationFinder.new(current_user, params) end - - def messages_finder - @message_finder ||= MessageFinder.new(@conversation, params) - end end diff --git a/app/controllers/api/v1/facebook_indicators_controller.rb b/app/controllers/api/v1/accounts/facebook_indicators_controller.rb similarity index 89% rename from app/controllers/api/v1/facebook_indicators_controller.rb rename to app/controllers/api/v1/accounts/facebook_indicators_controller.rb index dccf508c9..7cea774cf 100644 --- a/app/controllers/api/v1/facebook_indicators_controller.rb +++ b/app/controllers/api/v1/accounts/facebook_indicators_controller.rb @@ -1,4 +1,4 @@ -class Api::V1::FacebookIndicatorsController < Api::BaseController +class Api::V1::Accounts::FacebookIndicatorsController < Api::BaseController before_action :set_access_token around_action :handle_with_exception @@ -26,6 +26,7 @@ class Api::V1::FacebookIndicatorsController < Api::BaseController def handle_with_exception yield rescue Facebook::Messenger::Error => e + Rails.logger.debug "Rescued: #{e.inspect}" true end diff --git a/app/controllers/api/v1/inbox_members_controller.rb b/app/controllers/api/v1/accounts/inbox_members_controller.rb similarity index 94% rename from app/controllers/api/v1/inbox_members_controller.rb rename to app/controllers/api/v1/accounts/inbox_members_controller.rb index 982ad00ba..f71b3869d 100644 --- a/app/controllers/api/v1/inbox_members_controller.rb +++ b/app/controllers/api/v1/accounts/inbox_members_controller.rb @@ -1,4 +1,4 @@ -class Api::V1::InboxMembersController < Api::BaseController +class Api::V1::Accounts::InboxMembersController < Api::BaseController before_action :fetch_inbox, only: [:create, :show] before_action :current_agents_ids, only: [:create] diff --git a/app/controllers/api/v1/accounts/inboxes_controller.rb b/app/controllers/api/v1/accounts/inboxes_controller.rb new file mode 100644 index 000000000..4b3ed836e --- /dev/null +++ b/app/controllers/api/v1/accounts/inboxes_controller.rb @@ -0,0 +1,66 @@ +class Api::V1::Accounts::InboxesController < Api::BaseController + before_action :check_authorization + before_action :fetch_inbox, except: [:index, :create] + before_action :fetch_agent_bot, only: [:set_agent_bot] + + def index + @inboxes = policy_scope(current_account.inboxes) + end + + def create + ActiveRecord::Base.transaction do + channel = web_widgets.create!(permitted_params[:channel].except(:type)) if permitted_params[:channel][:type] == 'web_widget' + @inbox = current_account.inboxes.build(name: permitted_params[:name], channel: channel) + @inbox.avatar.attach(permitted_params[:avatar]) + @inbox.save! + end + end + + def update + @inbox.update(inbox_update_params.except(:channel)) + @inbox.channel.update!(inbox_update_params[:channel]) if @inbox.channel.is_a?(Channel::WebWidget) && inbox_update_params[:channel].present? + end + + def set_agent_bot + if @agent_bot + agent_bot_inbox = @inbox.agent_bot_inbox || AgentBotInbox.new(inbox: @inbox) + agent_bot_inbox.agent_bot = @agent_bot + agent_bot_inbox.save! + elsif @inbox.agent_bot_inbox.present? + @inbox.agent_bot_inbox.destroy! + end + head :ok + end + + def destroy + @inbox.destroy + head :ok + end + + private + + def fetch_inbox + @inbox = current_account.inboxes.find(params[:id]) + end + + def fetch_agent_bot + @agent_bot = AgentBot.find(params[:agent_bot]) if params[:agent_bot] + end + + def web_widgets + current_account.web_widgets + end + + def check_authorization + authorize(Inbox) + end + + def permitted_params + params.permit(:id, :avatar, :name, channel: [:type, :website_url, :widget_color, :welcome_title, :welcome_tagline, :agent_away_message]) + end + + def inbox_update_params + params.permit(:enable_auto_assignment, :name, :avatar, channel: [:website_url, :widget_color, :welcome_title, + :welcome_tagline, :agent_away_message]) + end +end diff --git a/app/controllers/api/v1/labels_controller.rb b/app/controllers/api/v1/accounts/labels_controller.rb similarity index 58% rename from app/controllers/api/v1/labels_controller.rb rename to app/controllers/api/v1/accounts/labels_controller.rb index 4426b7018..c9f15bdae 100644 --- a/app/controllers/api/v1/labels_controller.rb +++ b/app/controllers/api/v1/accounts/labels_controller.rb @@ -1,5 +1,6 @@ -class Api::V1::LabelsController < Api::BaseController - def index # list all labels in account +class Api::V1::Accounts::LabelsController < Api::BaseController + # list all labels in account + def index @labels = current_account.all_conversation_tags end diff --git a/app/controllers/api/v1/accounts/notification_settings_controller.rb b/app/controllers/api/v1/accounts/notification_settings_controller.rb new file mode 100644 index 000000000..ba6e43804 --- /dev/null +++ b/app/controllers/api/v1/accounts/notification_settings_controller.rb @@ -0,0 +1,29 @@ +class Api::V1::Accounts::NotificationSettingsController < Api::BaseController + before_action :set_user, :load_notification_setting + + def show; end + + def update + update_flags + @notification_setting.save! + render action: 'show' + end + + private + + def set_user + @user = current_user + end + + def load_notification_setting + @notification_setting = @user.notification_settings.find_by(account_id: current_account.id) + end + + def notification_setting_params + params.require(:notification_settings).permit(selected_email_flags: []) + end + + def update_flags + @notification_setting.selected_email_flags = notification_setting_params[:selected_email_flags] + end +end diff --git a/app/controllers/api/v1/reports_controller.rb b/app/controllers/api/v1/accounts/reports_controller.rb similarity index 82% rename from app/controllers/api/v1/reports_controller.rb rename to app/controllers/api/v1/accounts/reports_controller.rb index e155d3af7..c93574b6c 100644 --- a/app/controllers/api/v1/reports_controller.rb +++ b/app/controllers/api/v1/accounts/reports_controller.rb @@ -1,4 +1,4 @@ -class Api::V1::ReportsController < Api::BaseController +class Api::V1::Accounts::ReportsController < Api::BaseController include CustomExceptions::Report include Constants::Report @@ -36,10 +36,6 @@ class Api::V1::ReportsController < Api::BaseController current_user.account end - def agent - @agent ||= current_account.users.find(params[:agent_id]) - end - def account_summary_metrics summary_metrics(ACCOUNT_METRICS, :account_summary_params, AVG_ACCOUNT_METRICS) end @@ -51,18 +47,18 @@ class Api::V1::ReportsController < Api::BaseController def summary_metrics(metrics, calc_function, avg_metrics) metrics.each_with_object({}) do |metric, result| data = ReportBuilder.new(current_account, send(calc_function, metric)).build - - if avg_metrics.include?(metric) - sum = data.inject(0) { |sum, hash| sum + hash[:value].to_i } - sum /= data.length unless sum.zero? - else - sum = data.inject(0) { |sum, hash| sum + hash[:value].to_i } - end - - result[metric] = sum + result[metric] = calculate_metric(data, metric, avg_metrics) end end + def calculate_metric(data, metric, avg_metrics) + sum = data.inject(0) { |val, hash| val + hash[:value].to_i } + if avg_metrics.include?(metric) + sum /= data.length unless sum.zero? + end + sum + end + def account_summary_params(metric) { metric: metric.to_s, diff --git a/app/controllers/api/v1/subscriptions_controller.rb b/app/controllers/api/v1/accounts/subscriptions_controller.rb similarity index 76% rename from app/controllers/api/v1/subscriptions_controller.rb rename to app/controllers/api/v1/accounts/subscriptions_controller.rb index 92e4f7f13..f9b3141d6 100644 --- a/app/controllers/api/v1/subscriptions_controller.rb +++ b/app/controllers/api/v1/accounts/subscriptions_controller.rb @@ -1,4 +1,4 @@ -class Api::V1::SubscriptionsController < Api::BaseController +class Api::V1::Accounts::SubscriptionsController < Api::BaseController skip_before_action :check_subscription before_action :check_billing_enabled diff --git a/app/controllers/api/v1/inbox/webhooks_controller.rb b/app/controllers/api/v1/accounts/webhooks_controller.rb similarity index 77% rename from app/controllers/api/v1/inbox/webhooks_controller.rb rename to app/controllers/api/v1/accounts/webhooks_controller.rb index 47ada02a8..dbdd953ed 100644 --- a/app/controllers/api/v1/inbox/webhooks_controller.rb +++ b/app/controllers/api/v1/accounts/webhooks_controller.rb @@ -1,4 +1,4 @@ -class Api::V1::Inbox::WebhooksController < Api::BaseController +class Api::V1::Accounts::WebhooksController < Api::BaseController before_action :check_authorization before_action :fetch_webhook, only: [:update, :destroy] @@ -23,7 +23,7 @@ class Api::V1::Inbox::WebhooksController < Api::BaseController private def webhook_params - params.require(:webhook).permit(:account_id, :inbox_id, :urls).merge(urls: params[:urls]) + params.require(:webhook).permit(:inbox_id, :url) end def fetch_webhook diff --git a/app/controllers/api/v1/accounts_controller.rb b/app/controllers/api/v1/accounts_controller.rb deleted file mode 100644 index 0bbf2f67f..000000000 --- a/app/controllers/api/v1/accounts_controller.rb +++ /dev/null @@ -1,33 +0,0 @@ -class Api::V1::AccountsController < Api::BaseController - include AuthHelper - - skip_before_action :verify_authenticity_token, only: [:create] - skip_before_action :authenticate_user!, :set_current_user, :check_subscription, :handle_with_exception, - only: [:create], raise: false - - rescue_from CustomExceptions::Account::InvalidEmail, - CustomExceptions::Account::UserExists, - CustomExceptions::Account::UserErrors, - with: :render_error_response - - def create - @user = AccountBuilder.new( - account_name: account_params[:account_name], - email: account_params[:email] - ).perform - if @user - send_auth_headers(@user) - render json: { - data: @user.token_validation_response - } - else - render_error_response(CustomExceptions::Account::SignupFailed.new({})) - end - end - - private - - def account_params - params.permit(:account_name, :email) - end -end diff --git a/app/controllers/api/v1/agent_bots_controller.rb b/app/controllers/api/v1/agent_bots_controller.rb new file mode 100644 index 000000000..4c17fd4f4 --- /dev/null +++ b/app/controllers/api/v1/agent_bots_controller.rb @@ -0,0 +1,8 @@ +class Api::V1::AgentBotsController < Api::BaseController + skip_before_action :authenticate_user! + skip_before_action :check_subscription + + def index + render json: AgentBot.all + end +end diff --git a/app/controllers/api/v1/agents_controller.rb b/app/controllers/api/v1/agents_controller.rb deleted file mode 100644 index a1758b40b..000000000 --- a/app/controllers/api/v1/agents_controller.rb +++ /dev/null @@ -1,52 +0,0 @@ -class Api::V1::AgentsController < Api::BaseController - before_action :fetch_agent, except: [:create, :index] - before_action :check_authorization - before_action :build_agent, only: [:create] - - def index - @agents = agents - end - - def destroy - @agent.destroy - head :ok - end - - def update - @agent.update!(agent_params) - render json: @agent - end - - def create - @agent.save! - render json: @agent - end - - private - - def check_authorization - authorize(User) - end - - def fetch_agent - @agent = agents.find(params[:id]) - end - - def build_agent - @agent = agents.new(new_agent_params) - end - - def agent_params - params.require(:agent).permit(:email, :name, :role) - end - - def new_agent_params - time = Time.now.to_i - params.require(:agent).permit(:email, :name, :role) - .merge!(password: time, password_confirmation: time, inviter: current_user) - end - - def agents - @agents ||= current_account.users - end -end diff --git a/app/controllers/api/v1/callbacks_controller.rb b/app/controllers/api/v1/callbacks_controller.rb deleted file mode 100644 index cc6b00958..000000000 --- a/app/controllers/api/v1/callbacks_controller.rb +++ /dev/null @@ -1,107 +0,0 @@ -require 'rest-client' -require 'telegram/bot' -class Api::V1::CallbacksController < ApplicationController - skip_before_action :verify_authenticity_token, only: [:register_facebook_page] - skip_before_action :authenticate_user!, only: [:register_facebook_page], raise: false - - def register_facebook_page - user_access_token = params[:user_access_token] - page_access_token = params[:page_access_token] - page_name = params[:page_name] - page_id = params[:page_id] - inbox_name = params[:inbox_name] - facebook_channel = current_account.facebook_pages.create!( - name: page_name, page_id: page_id, user_access_token: user_access_token, - page_access_token: page_access_token - ) - set_avatar(facebook_channel, page_id) - inbox = current_account.inboxes.create!(name: inbox_name, channel: facebook_channel) - render json: inbox - end - - def get_facebook_pages - @page_details = mark_already_existing_facebook_pages(fb_object.get_connections('me', 'accounts')) - end - - # get params[:inbox_id], current_account, params[:omniauth_token] - def reauthorize_page - if @inbox&.first&.facebook? - fb_page_id = @inbox.channel.page_id - page_details = fb_object.get_connections('me', 'accounts') - - (page_details || []).each do |page_detail| - if fb_page_id == page_detail['id'] # found the page which has to be reauthorised - update_fb_page(fb_page_id, page_detail['access_token']) - head :ok - end - end - end - - head :unprocessable_entity - end - - private - - def inbox - @inbox = current_account.inboxes.find_by(id: params[:inbox_id]) - end - - def update_fb_page - if fb_page(fb_page_id) - fb_page.update_attributes!( - user_access_token: @user_access_token, page_access_token: access_token - ) - head :ok - else - head :unprocessable_entity - end - end - - def fb_page(fb_page_id) - current_account.facebook_pages.find_by(page_id: fb_page_id) - end - - def fb_object - @user_access_token = long_lived_token(params[:omniauth_token]) - Koala::Facebook::API.new(@user_access_token) - end - - def long_lived_token(omniauth_token) - koala = Koala::Facebook::OAuth.new(ENV['FB_APP_ID'], ENV['FB_APP_SECRET']) - long_lived_token = koala.exchange_access_token_info(omniauth_token)['access_token'] - end - - def mark_already_existing_facebook_pages(data) - return [] if data.empty? - - data.inject([]) do |result, page_detail| - current_account.facebook_pages.exists?(page_id: page_detail['id']) ? page_detail.merge!(exists: true) : page_detail.merge!(exists: false) - result << page_detail - end - end - - def set_avatar(facebook_channel, page_id) - avatar_resource = LocalResource.new(get_avatar_url(page_id)) - facebook_channel.avatar.attach(io: avatar_resource.file, filename: avatar_resource.tmp_filename, content_type: avatar_resource.encoding) - end - - def get_avatar_url(page_id) - begin - url = 'http://graph.facebook.com/' << page_id << '/picture?type=large' - uri = URI.parse(url) - tries = 3 - begin - response = uri.open(redirect: false) - rescue OpenURI::HTTPRedirect => e - uri = e.uri # assigned from the "Location" response header - retry if (tries -= 1) > 0 - raise - end - pic_url = response.base_uri.to_s - Rails.logger.info(pic_url) - rescue StandardError => e - pic_url = nil - end - pic_url - end -end diff --git a/app/controllers/api/v1/conversations/messages_controller.rb b/app/controllers/api/v1/conversations/messages_controller.rb deleted file mode 100644 index 22fdd88fb..000000000 --- a/app/controllers/api/v1/conversations/messages_controller.rb +++ /dev/null @@ -1,8 +0,0 @@ -class Api::V1::Conversations::MessagesController < Api::BaseController - before_action :set_conversation, only: [:create] - - def create - mb = Messages::Outgoing::NormalBuilder.new(current_user, @conversation, params) - @message = mb.perform - end -end diff --git a/app/controllers/api/v1/inboxes_controller.rb b/app/controllers/api/v1/inboxes_controller.rb deleted file mode 100644 index e9005a0c7..000000000 --- a/app/controllers/api/v1/inboxes_controller.rb +++ /dev/null @@ -1,31 +0,0 @@ -class Api::V1::InboxesController < Api::BaseController - before_action :check_authorization - before_action :fetch_inbox, only: [:destroy, :update] - - def index - @inboxes = policy_scope(current_account.inboxes) - end - - def destroy - @inbox.destroy - head :ok - end - - def update - @inbox.update(inbox_update_params) - end - - private - - def fetch_inbox - @inbox = current_account.inboxes.find(params[:id]) - end - - def check_authorization - authorize(Inbox) - end - - def inbox_update_params - params.require(:inbox).permit(:enable_auto_assignment) - end -end diff --git a/app/controllers/api/v1/webhooks_controller.rb b/app/controllers/api/v1/webhooks_controller.rb index d15b414c1..5db576000 100644 --- a/app/controllers/api/v1/webhooks_controller.rb +++ b/app/controllers/api/v1/webhooks_controller.rb @@ -5,6 +5,7 @@ class Api::V1::WebhooksController < ApplicationController before_action :login_from_basic_auth, only: [:chargebee] before_action :check_billing_enabled, only: [:chargebee] + def chargebee chargebee_consumer.consume head :ok diff --git a/app/controllers/api/v1/widget/base_controller.rb b/app/controllers/api/v1/widget/base_controller.rb index eb19e2bdd..9ef4a04f2 100644 --- a/app/controllers/api/v1/widget/base_controller.rb +++ b/app/controllers/api/v1/widget/base_controller.rb @@ -2,9 +2,9 @@ class Api::V1::Widget::BaseController < ApplicationController private def conversation - @conversation ||= @contact_inbox.conversations.find_by( + @conversation ||= @contact_inbox.conversations.where( inbox_id: auth_token_params[:inbox_id] - ) + ).last end def auth_token_params diff --git a/app/controllers/api/v1/widget/contacts_controller.rb b/app/controllers/api/v1/widget/contacts_controller.rb new file mode 100644 index 000000000..b7ac793e7 --- /dev/null +++ b/app/controllers/api/v1/widget/contacts_controller.rb @@ -0,0 +1,18 @@ +class Api::V1::Widget::ContactsController < Api::V1::Widget::BaseController + before_action :set_web_widget + before_action :set_contact + + def update + contact_identify_action = ContactIdentifyAction.new( + contact: @contact, + params: permitted_params.to_h.deep_symbolize_keys + ) + render json: contact_identify_action.perform + end + + private + + def permitted_params + params.permit(:website_token, :identifier, :email, :name, :avatar_url) + end +end diff --git a/app/controllers/api/v1/widget/events_controller.rb b/app/controllers/api/v1/widget/events_controller.rb new file mode 100644 index 000000000..faa44994c --- /dev/null +++ b/app/controllers/api/v1/widget/events_controller.rb @@ -0,0 +1,16 @@ +class Api::V1::Widget::EventsController < Api::V1::Widget::BaseController + include Events::Types + before_action :set_web_widget + before_action :set_contact + + def create + Rails.configuration.dispatcher.dispatch(permitted_params[:name], Time.zone.now, contact_inbox: @contact_inbox) + head :no_content + end + + private + + def permitted_params + params.permit(:name, :website_token) + end +end diff --git a/app/controllers/api/v1/widget/inboxes_controller.rb b/app/controllers/api/v1/widget/inboxes_controller.rb deleted file mode 100644 index ce739fef0..000000000 --- a/app/controllers/api/v1/widget/inboxes_controller.rb +++ /dev/null @@ -1,48 +0,0 @@ -class Api::V1::Widget::InboxesController < Api::BaseController - before_action :authorize_request - before_action :set_web_widget_channel, only: [:update] - before_action :set_inbox, only: [:update] - - def create - ActiveRecord::Base.transaction do - channel = web_widgets.create!( - website_name: permitted_params[:website][:website_name], - website_url: permitted_params[:website][:website_url], - widget_color: permitted_params[:website][:widget_color] - ) - @inbox = inboxes.create!(name: permitted_params[:website][:website_name], channel: channel) - end - end - - def update - @channel.update!( - widget_color: permitted_params[:website][:widget_color] - ) - end - - private - - def authorize_request - authorize ::Inbox - end - - def inboxes - current_account.inboxes - end - - def web_widgets - current_account.web_widgets - end - - def set_web_widget_channel - @channel = web_widgets.find_by(id: permitted_params[:id]) - end - - def set_inbox - @inbox = @channel.inbox - end - - def permitted_params - params.permit(:id, website: [:website_name, :website_url, :widget_color]) - end -end diff --git a/app/controllers/api/v1/widget/labels_controller.rb b/app/controllers/api/v1/widget/labels_controller.rb new file mode 100644 index 000000000..efe84f5e3 --- /dev/null +++ b/app/controllers/api/v1/widget/labels_controller.rb @@ -0,0 +1,24 @@ +class Api::V1::Widget::LabelsController < Api::V1::Widget::BaseController + before_action :set_web_widget + before_action :set_contact + + def create + conversation.label_list.add(permitted_params[:label]) + conversation.save! + + head :no_content + end + + def destroy + conversation.label_list.remove(permitted_params[:id]) + conversation.save! + + head :no_content + end + + private + + def permitted_params + params.permit(:id, :label, :website_token) + end +end diff --git a/app/controllers/api/v1/widget/messages_controller.rb b/app/controllers/api/v1/widget/messages_controller.rb index a6087bb8c..a65e01f9c 100644 --- a/app/controllers/api/v1/widget/messages_controller.rb +++ b/app/controllers/api/v1/widget/messages_controller.rb @@ -10,20 +10,36 @@ class Api::V1::Widget::MessagesController < Api::V1::Widget::BaseController def create @message = conversation.messages.new(message_params) - @message.save! - render json: @message + @message.save + build_attachment end def update - @message.update!(input_submitted_email: contact_email) - update_contact(contact_email) - head :no_content + if @message.content_type == 'input_email' + @message.update!(submitted_email: contact_email) + update_contact(contact_email) + else + @message.update!(message_update_params[:message]) + end rescue StandardError => e render json: { error: @contact.errors, message: e.message }.to_json, status: 500 end private + def build_attachment + return if params[:message][:attachments].blank? + + params[:message][:attachments].each do |uploaded_attachment| + attachment = @message.attachments.new( + account_id: @message.account_id, + file_type: helpers.file_type(uploaded_attachment&.content_type) + ) + attachment.file.attach(uploaded_attachment) + end + @message.save! + end + def set_conversation @conversation = ::Conversation.create!(conversation_params) if conversation.nil? end @@ -31,9 +47,10 @@ class Api::V1::Widget::MessagesController < Api::V1::Widget::BaseController def message_params { account_id: conversation.account_id, + contact_id: @contact.id, + content: permitted_params[:message][:content], inbox_id: conversation.inbox_id, - message_type: :incoming, - content: permitted_params[:message][:content] + message_type: :incoming } end @@ -85,7 +102,11 @@ class Api::V1::Widget::MessagesController < Api::V1::Widget::BaseController def update_contact(email) contact_with_email = @account.contacts.find_by(email: email) if contact_with_email - ::ContactMergeAction.new(account: @account, base_contact: contact_with_email, mergee_contact: @contact).perform + @contact = ::ContactMergeAction.new( + account: @account, + base_contact: contact_with_email, + mergee_contact: @contact + ).perform else @contact.update!( email: email, @@ -102,6 +123,10 @@ class Api::V1::Widget::MessagesController < Api::V1::Widget::BaseController contact_email.split('@')[0] end + def message_update_params + params.permit(message: [submitted_values: [:name, :title, :value]]) + end + def permitted_params params.permit(:id, :before, :website_token, contact: [:email], message: [:content, :referer_url, :timestamp]) end diff --git a/app/controllers/api/v2/accounts/reports_controller.rb b/app/controllers/api/v2/accounts/reports_controller.rb new file mode 100644 index 000000000..fe94db4e1 --- /dev/null +++ b/app/controllers/api/v2/accounts/reports_controller.rb @@ -0,0 +1,39 @@ +class Api::V2::Accounts::ReportsController < Api::BaseController + def account + builder = V2::ReportBuilder.new(current_account, account_report_params) + data = builder.build + render json: data + end + + def account_summary + render json: account_summary_metrics + end + + private + + def current_account + current_user.account + end + + def account_summary_params + { + type: :account, + since: params[:since], + until: params[:until] + } + end + + def account_report_params + { + metric: params[:metric], + type: :account, + since: params[:since], + until: params[:until] + } + end + + def account_summary_metrics + builder = V2::ReportBuilder.new(current_account, account_summary_params) + builder.summary + end +end diff --git a/app/controllers/api_controller.rb b/app/controllers/api_controller.rb new file mode 100644 index 000000000..3d7f59f0c --- /dev/null +++ b/app/controllers/api_controller.rb @@ -0,0 +1,8 @@ +class ApiController < ApplicationController + skip_before_action :set_current_user, only: [:index] + skip_before_action :check_subscription, only: [:index] + + def index + render json: { version: Chatwoot.config[:version], timestamp: Time.now.utc.to_formatted_s(:db) } + end +end diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index f38c45c63..5bac8991e 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -14,7 +14,25 @@ class ApplicationController < ActionController::Base private def current_account - @_ ||= current_user.account + @_ ||= find_current_account + end + + def find_current_account + account = Account.find(params[:account_id]) + if current_user + account_accessible_for_user?(account) + elsif @resource&.is_a?(AgentBot) + account_accessible_for_bot?(account) + end + account + end + + def account_accessible_for_user?(account) + render_unauthorized('You are not authorized to access this account') unless account.account_users.find_by(user_id: current_user.id) + end + + def account_accessible_for_bot?(account) + render_unauthorized('You are not authorized to access this account') unless @resource.agent_bot_inboxes.find_by(account_id: account.id) end def handle_with_exception diff --git a/app/controllers/concerns/access_token_auth_helper.rb b/app/controllers/concerns/access_token_auth_helper.rb new file mode 100644 index 000000000..e7af9e116 --- /dev/null +++ b/app/controllers/concerns/access_token_auth_helper.rb @@ -0,0 +1,26 @@ +module AccessTokenAuthHelper + BOT_ACCESSIBLE_ENDPOINTS = { + 'api/v1/accounts/conversations' => %w[toggle_status create], + 'api/v1/accounts/conversations/messages' => ['create'] + }.freeze + + def authenticate_access_token! + token = request.headers[:api_access_token] || request.headers[:HTTP_API_ACCESS_TOKEN] + access_token = AccessToken.find_by(token: token) + render_unauthorized('Invalid Access Token') && return unless access_token + + token_owner = access_token.owner + @resource = token_owner + end + + def validate_bot_access_token! + return if current_user.is_a?(User) + return if agent_bot_accessible? + + render_unauthorized('Access to this endpoint is not authorized for bots') + end + + def agent_bot_accessible? + BOT_ACCESSIBLE_ENDPOINTS.fetch(params[:controller], []).include?(params[:action]) + end +end diff --git a/app/controllers/devise_overrides/passwords_controller.rb b/app/controllers/devise_overrides/passwords_controller.rb index adea8687d..4289d5af2 100644 --- a/app/controllers/devise_overrides/passwords_controller.rb +++ b/app/controllers/devise_overrides/passwords_controller.rb @@ -11,9 +11,7 @@ class DeviseOverrides::PasswordsController < Devise::PasswordsController @recoverable = User.find_by(reset_password_token: reset_password_token) if @recoverable && reset_password_and_confirmation(@recoverable) send_auth_headers(@recoverable) - render json: { - data: @recoverable.token_validation_response - } + render 'devise/auth.json', locals: { resource: @recoverable } else render json: { "message": 'Invalid token', "redirect_url": '/' }, status: 422 end diff --git a/app/controllers/devise_overrides/sessions_controller.rb b/app/controllers/devise_overrides/sessions_controller.rb index 3a6614074..b9cec5447 100644 --- a/app/controllers/devise_overrides/sessions_controller.rb +++ b/app/controllers/devise_overrides/sessions_controller.rb @@ -4,6 +4,6 @@ class DeviseOverrides::SessionsController < ::DeviseTokenAuth::SessionsControlle wrap_parameters format: [] def render_create_success - render 'devise/auth.json' + render 'devise/auth.json', locals: { resource: @resource } end end diff --git a/app/controllers/swagger_controller.rb b/app/controllers/swagger_controller.rb new file mode 100644 index 000000000..c5f8c0f5b --- /dev/null +++ b/app/controllers/swagger_controller.rb @@ -0,0 +1,18 @@ +class SwaggerController < ApplicationController + def respond + if Rails.env.development? || Rails.env.test? + render inline: File.read(Rails.root.join('swagger', derived_path)) + else + head 404 + end + end + + private + + def derived_path + params[:path] ||= 'index.html' + path = params[:path] + path << ".#{params[:format]}" unless path.ends_with?(params[:format].to_s) + path + end +end diff --git a/app/controllers/twilio/callback_controller.rb b/app/controllers/twilio/callback_controller.rb new file mode 100644 index 000000000..44dcc9b6f --- /dev/null +++ b/app/controllers/twilio/callback_controller.rb @@ -0,0 +1,31 @@ +class Twilio::CallbackController < ApplicationController + def create + ::Twilio::IncomingMessageService.new(params: permitted_params).perform + + head :no_content + end + + private + + def permitted_params + params.permit( + :ApiVersion, + :SmsSid, + :From, + :ToState, + :ToZip, + :AccountSid, + :MessageSid, + :FromCountry, + :ToCity, + :FromCity, + :To, + :FromZip, + :Body, + :ToCountry, + :FromState, + :MediaUrl0, + :MediaContentType0 + ) + end +end diff --git a/app/controllers/twitter/authorizations_controller.rb b/app/controllers/twitter/authorizations_controller.rb index 69145ff84..765e026fe 100644 --- a/app/controllers/twitter/authorizations_controller.rb +++ b/app/controllers/twitter/authorizations_controller.rb @@ -6,7 +6,7 @@ class Twitter::AuthorizationsController < Twitter::BaseController ::Redis::Alfred.setex(oauth_token, account.id) redirect_to oauth_authorize_endpoint(oauth_token) else - redirect_to app_new_twitter_inbox_url + redirect_to app_new_twitter_inbox_url(account_id: account.id) end end diff --git a/app/controllers/twitter/callbacks_controller.rb b/app/controllers/twitter/callbacks_controller.rb index 876720f26..6065e3546 100644 --- a/app/controllers/twitter/callbacks_controller.rb +++ b/app/controllers/twitter/callbacks_controller.rb @@ -1,5 +1,7 @@ class Twitter::CallbacksController < Twitter::BaseController def show + return redirect_to twitter_app_redirect_url if permitted_params[:denied] + @response = twitter_client.access_token( oauth_token: permitted_params[:oauth_token], oauth_verifier: permitted_params[:oauth_verifier] @@ -8,9 +10,9 @@ class Twitter::CallbacksController < Twitter::BaseController inbox = build_inbox ::Redis::Alfred.delete(permitted_params[:oauth_token]) ::Twitter::WebhookSubscribeService.new(inbox_id: inbox.id).perform - redirect_to app_twitter_inbox_agents_url(inbox_id: inbox.id) + redirect_to app_twitter_inbox_agents_url(account_id: account.id, inbox_id: inbox.id) else - redirect_to app_new_twitter_inbox_url + redirect_to twitter_app_redirect_url end end @@ -28,13 +30,16 @@ class Twitter::CallbacksController < Twitter::BaseController @account ||= Account.find_by!(id: account_id) end + def twitter_app_redirect_url + app_new_twitter_inbox_url(account_id: account.id) + end + def build_inbox ActiveRecord::Base.transaction do twitter_profile = account.twitter_profiles.create( twitter_access_token: parsed_body['oauth_token'], twitter_access_token_secret: parsed_body['oauth_token_secret'], - profile_id: parsed_body['user_id'], - name: parsed_body['screen_name'] + profile_id: parsed_body['user_id'] ) account.inboxes.create( name: parsed_body['screen_name'], @@ -46,6 +51,6 @@ class Twitter::CallbacksController < Twitter::BaseController end def permitted_params - params.permit(:oauth_token, :oauth_verifier) + params.permit(:oauth_token, :oauth_verifier, :denied) end end diff --git a/app/dispatchers/async_dispatcher.rb b/app/dispatchers/async_dispatcher.rb index 97da6de93..72e44a1c2 100644 --- a/app/dispatchers/async_dispatcher.rb +++ b/app/dispatchers/async_dispatcher.rb @@ -1,11 +1,16 @@ class AsyncDispatcher < BaseDispatcher def dispatch(event_name, timestamp, data) + EventDispatcherJob.perform_later(event_name, timestamp, data) + end + + def publish_event(event_name, timestamp, data) event_object = Events::Base.new(event_name, timestamp, data) publish(event_object.method_name, event_object) end def listeners - listeners = [ReportingListener.instance] + listeners = [EmailNotificationListener.instance, ReportingListener.instance, WebhookListener.instance] + listeners << EventListener.instance listeners << SubscriptionListener.instance if ENV['BILLING_ENABLED'] listeners end diff --git a/app/dispatchers/sync_dispatcher.rb b/app/dispatchers/sync_dispatcher.rb index e3bad28a1..509a42727 100644 --- a/app/dispatchers/sync_dispatcher.rb +++ b/app/dispatchers/sync_dispatcher.rb @@ -5,6 +5,6 @@ class SyncDispatcher < BaseDispatcher end def listeners - [ActionCableListener.instance] + [ActionCableListener.instance, AgentBotListener.instance] end end diff --git a/app/finders/conversation_finder.rb b/app/finders/conversation_finder.rb index f4e114bf4..fcc0daf98 100644 --- a/app/finders/conversation_finder.rb +++ b/app/finders/conversation_finder.rb @@ -1,23 +1,18 @@ class ConversationFinder attr_reader :current_user, :current_account, :params - ASSIGNEE_TYPES = { me: 0, unassigned: 1, all: 2 }.freeze - - ASSIGNEE_TYPES_BY_ID = ASSIGNEE_TYPES.invert - ASSIGNEE_TYPES_BY_ID.default = :me - DEFAULT_STATUS = 'open'.freeze # assumptions # inbox_id if not given, take from all conversations, else specific to inbox - # assignee_type if not given, take 'me' + # assignee_type if not given, take 'all' # conversation_status if not given, take 'open' # response of this class will be of type # {conversations: [array of conversations], count: {open: count, resolved: count}} # params - # assignee_type_id, inbox_id, :status + # assignee_type, inbox_id, :status def initialize(current_user, params) @current_user = current_user @@ -62,7 +57,7 @@ class ConversationFinder end def set_assignee_type - @assignee_type_id = ASSIGNEE_TYPES[ASSIGNEE_TYPES_BY_ID[params[:assignee_type_id].to_i]] + @assignee_type = params[:assignee_type] end def find_all_conversations @@ -72,12 +67,10 @@ class ConversationFinder end def filter_by_assignee_type - if @assignee_type_id == ASSIGNEE_TYPES[:me] + if @assignee_type == 'me' @conversations = @conversations.assigned_to(current_user) - elsif @assignee_type_id == ASSIGNEE_TYPES[:unassigned] + elsif @assignee_type == 'unassigned' @conversations = @conversations.unassigned - elsif @assignee_type_id == ASSIGNEE_TYPES[:all] - @conversations end @conversations end diff --git a/app/finders/message_finder.rb b/app/finders/message_finder.rb index f21093b48..00ef8ab3b 100644 --- a/app/finders/message_finder.rb +++ b/app/finders/message_finder.rb @@ -11,7 +11,7 @@ class MessageFinder private def conversation_messages - @conversation.messages.includes(:attachment, user: { avatar_attachment: :blob }) + @conversation.messages.includes(:attachments, user: { avatar_attachment: :blob }) end def messages diff --git a/app/helpers/file_type_helper.rb b/app/helpers/file_type_helper.rb new file mode 100644 index 000000000..9f6939565 --- /dev/null +++ b/app/helpers/file_type_helper.rb @@ -0,0 +1,14 @@ +module FileTypeHelper + def file_type(content_type) + return :image if [ + 'image/jpeg', + 'image/png', + 'image/svg+xml', + 'image/gif', + 'image/tiff', + 'image/bmp' + ].include?(content_type) + + :file + end +end diff --git a/app/javascript/dashboard/App.vue b/app/javascript/dashboard/App.vue index acf177d7c..bef1323e2 100644 --- a/app/javascript/dashboard/App.vue +++ b/app/javascript/dashboard/App.vue @@ -8,7 +8,10 @@ diff --git a/app/javascript/dashboard/api/ApiClient.js b/app/javascript/dashboard/api/ApiClient.js index a3b87dae1..0b2ed28d0 100644 --- a/app/javascript/dashboard/api/ApiClient.js +++ b/app/javascript/dashboard/api/ApiClient.js @@ -3,9 +3,25 @@ const API_VERSION = `/api/v1`; class ApiClient { - constructor(url) { + constructor(resource, options = {}) { this.apiVersion = API_VERSION; - this.url = `${this.apiVersion}/${url}`; + this.options = options; + this.resource = resource; + } + + get url() { + let url = this.apiVersion; + if (this.options.accountScoped) { + const isInsideAccountScopedURLs = window.location.pathname.includes( + '/app/accounts' + ); + + if (isInsideAccountScopedURLs) { + const accountId = window.location.pathname.split('/')[3]; + url = `${url}/accounts/${accountId}`; + } + } + return `${url}/${this.resource}`; } get() { diff --git a/app/javascript/dashboard/api/account.js b/app/javascript/dashboard/api/account.js new file mode 100644 index 000000000..207420da6 --- /dev/null +++ b/app/javascript/dashboard/api/account.js @@ -0,0 +1,9 @@ +import ApiClient from './ApiClient'; + +class AccountAPI extends ApiClient { + constructor() { + super('', { accountScoped: true }); + } +} + +export default new AccountAPI(); diff --git a/app/javascript/dashboard/api/agents.js b/app/javascript/dashboard/api/agents.js index 62d8e6623..7cc5e6d0c 100644 --- a/app/javascript/dashboard/api/agents.js +++ b/app/javascript/dashboard/api/agents.js @@ -2,7 +2,7 @@ import ApiClient from './ApiClient'; class Agents extends ApiClient { constructor() { - super('agents'); + super('agents', { accountScoped: true }); } } diff --git a/app/javascript/dashboard/api/cannedResponse.js b/app/javascript/dashboard/api/cannedResponse.js index 3a17d2735..f558dcaca 100644 --- a/app/javascript/dashboard/api/cannedResponse.js +++ b/app/javascript/dashboard/api/cannedResponse.js @@ -4,7 +4,7 @@ import ApiClient from './ApiClient'; class CannedResponse extends ApiClient { constructor() { - super('canned_responses'); + super('canned_responses', { accountScoped: true }); } get({ searchKey }) { diff --git a/app/javascript/dashboard/api/channel/fbChannel.js b/app/javascript/dashboard/api/channel/fbChannel.js index f9781097c..e53885b4a 100644 --- a/app/javascript/dashboard/api/channel/fbChannel.js +++ b/app/javascript/dashboard/api/channel/fbChannel.js @@ -3,7 +3,7 @@ import ApiClient from '../ApiClient'; class FBChannel extends ApiClient { constructor() { - super('facebook_indicators'); + super('facebook_indicators', { accountScoped: true }); } markSeen({ inboxId, contactId }) { @@ -22,7 +22,7 @@ class FBChannel extends ApiClient { create(params) { return axios.post( - `${this.apiVersion}/callbacks/register_facebook_page`, + `${this.url.replace(this.resource, '')}callbacks/register_facebook_page`, params ); } diff --git a/app/javascript/dashboard/api/channel/twilioChannel.js b/app/javascript/dashboard/api/channel/twilioChannel.js new file mode 100644 index 000000000..a688a1f11 --- /dev/null +++ b/app/javascript/dashboard/api/channel/twilioChannel.js @@ -0,0 +1,9 @@ +import ApiClient from '../ApiClient'; + +class TwilioChannel extends ApiClient { + constructor() { + super('channels/twilio_channel', { accountScoped: true }); + } +} + +export default new TwilioChannel(); diff --git a/app/javascript/dashboard/api/channel/webChannel.js b/app/javascript/dashboard/api/channel/webChannel.js index 7fc5fb2db..81a145462 100644 --- a/app/javascript/dashboard/api/channel/webChannel.js +++ b/app/javascript/dashboard/api/channel/webChannel.js @@ -2,7 +2,7 @@ import ApiClient from '../ApiClient'; class WebChannel extends ApiClient { constructor() { - super('widget/inboxes'); + super('inboxes', { accountScoped: true }); } } diff --git a/app/javascript/dashboard/api/channels.js b/app/javascript/dashboard/api/channels.js index f7db9afbc..25998b1a2 100644 --- a/app/javascript/dashboard/api/channels.js +++ b/app/javascript/dashboard/api/channels.js @@ -5,9 +5,9 @@ import endPoints from './endPoints'; export default { - fetchFacebookPages(token) { + fetchFacebookPages(token, accountId) { const urlData = endPoints('fetchFacebookPages'); urlData.params.omniauth_token = token; - return axios.post(urlData.url, urlData.params); + return axios.post(urlData.url(accountId), urlData.params); }, }; diff --git a/app/javascript/dashboard/api/contacts.js b/app/javascript/dashboard/api/contacts.js index bad89b182..0988141d3 100644 --- a/app/javascript/dashboard/api/contacts.js +++ b/app/javascript/dashboard/api/contacts.js @@ -3,7 +3,7 @@ import ApiClient from './ApiClient'; class ContactAPI extends ApiClient { constructor() { - super('contacts'); + super('contacts', { accountScoped: true }); } getConversations(contactId) { diff --git a/app/javascript/dashboard/api/conversations.js b/app/javascript/dashboard/api/conversations.js index fd36f8db3..876103694 100644 --- a/app/javascript/dashboard/api/conversations.js +++ b/app/javascript/dashboard/api/conversations.js @@ -3,7 +3,7 @@ import ApiClient from './ApiClient'; class ConversationApi extends ApiClient { constructor() { - super('conversations'); + super('conversations', { accountScoped: true }); } getLabels(conversationID) { diff --git a/app/javascript/dashboard/api/endPoints.js b/app/javascript/dashboard/api/endPoints.js index 10a0608bd..53c669eb6 100644 --- a/app/javascript/dashboard/api/endPoints.js +++ b/app/javascript/dashboard/api/endPoints.js @@ -28,23 +28,12 @@ const endPoints = { }, fetchFacebookPages: { - url: 'api/v1/callbacks/get_facebook_pages.json', + url(accountId) { + return `api/v1/accounts/${accountId}/callbacks/facebook_pages.json`; + }, params: { omniauth_token: '' }, }, - reports: { - account(metric, from, to) { - return { - url: `/api/v1/reports/account?metric=${metric}&since=${from}&to=${to}`, - }; - }, - accountSummary(accountId, from, to) { - return { - url: `/api/v1/reports/${accountId}/account_summary?since=${from}&to=${to}`, - }; - }, - }, - subscriptions: { get() { return { diff --git a/app/javascript/dashboard/api/inbox/conversation.js b/app/javascript/dashboard/api/inbox/conversation.js index b444d0e17..d5212957a 100644 --- a/app/javascript/dashboard/api/inbox/conversation.js +++ b/app/javascript/dashboard/api/inbox/conversation.js @@ -3,15 +3,16 @@ import ApiClient from '../ApiClient'; class ConversationApi extends ApiClient { constructor() { - super('conversations'); + super('conversations', { accountScoped: true }); } - get({ inboxId, status, assigneeType }) { + get({ inboxId, status, assigneeType, page }) { return axios.get(this.url, { params: { inbox_id: inboxId, status, - assignee_type_id: assigneeType, + assignee_type: assigneeType, + page, }, }); } diff --git a/app/javascript/dashboard/api/inbox/message.js b/app/javascript/dashboard/api/inbox/message.js index 367e3e00c..c9681f685 100644 --- a/app/javascript/dashboard/api/inbox/message.js +++ b/app/javascript/dashboard/api/inbox/message.js @@ -4,21 +4,31 @@ import ApiClient from '../ApiClient'; class MessageApi extends ApiClient { constructor() { - super('conversations'); + super('conversations', { accountScoped: true }); } create({ conversationId, message, private: isPrivate }) { return axios.post(`${this.url}/${conversationId}/messages`, { - message, + content: message, private: isPrivate, }); } getPreviousMessages({ conversationId, before }) { - return axios.get(`${this.url}/${conversationId}`, { + return axios.get(`${this.url}/${conversationId}/messages`, { params: { before }, }); } + + sendAttachment([conversationId, { file }]) { + const formData = new FormData(); + formData.append('attachments[]', file, file.name); + return axios({ + method: 'post', + url: `${this.url}/${conversationId}/messages`, + data: formData, + }); + } } export default new MessageApi(); diff --git a/app/javascript/dashboard/api/inboxMembers.js b/app/javascript/dashboard/api/inboxMembers.js index 2d7001562..3716f89ab 100644 --- a/app/javascript/dashboard/api/inboxMembers.js +++ b/app/javascript/dashboard/api/inboxMembers.js @@ -3,7 +3,7 @@ import ApiClient from './ApiClient'; class InboxMembers extends ApiClient { constructor() { - super('inbox_members'); + super('inbox_members', { accountScoped: true }); } create({ inboxId, agentList }) { diff --git a/app/javascript/dashboard/api/inboxes.js b/app/javascript/dashboard/api/inboxes.js index fb3e63dfd..b5cea1d01 100644 --- a/app/javascript/dashboard/api/inboxes.js +++ b/app/javascript/dashboard/api/inboxes.js @@ -2,7 +2,7 @@ import ApiClient from './ApiClient'; class Inboxes extends ApiClient { constructor() { - super('inboxes'); + super('inboxes', { accountScoped: true }); } } diff --git a/app/javascript/dashboard/api/reports.js b/app/javascript/dashboard/api/reports.js index 94d6ac726..d2a96cda8 100644 --- a/app/javascript/dashboard/api/reports.js +++ b/app/javascript/dashboard/api/reports.js @@ -1,14 +1,22 @@ /* global axios */ +import ApiClient from './ApiClient'; -import endPoints from './endPoints'; +class ReportsAPI extends ApiClient { + constructor() { + super('reports', { accountScoped: true }); + } -export default { - getAccountReports(metric, from, to) { - const { url } = endPoints('reports').account(metric, from, to); - return axios.get(url); - }, - getAccountSummary(accountId, from, to) { - const urlData = endPoints('reports').accountSummary(accountId, from, to); - return axios.get(urlData.url); - }, -}; + getAccountReports(metric, since, until) { + return axios.get(`${this.url}/account`, { + params: { metric, since, until }, + }); + } + + getAccountSummary(accountId, since, until) { + return axios.get(`${this.url}/${accountId}/account_summary`, { + params: { since, until }, + }); + } +} + +export default new ReportsAPI(); diff --git a/app/javascript/dashboard/api/specs/channel/fbChannel.spec.js b/app/javascript/dashboard/api/specs/channel/fbChannel.spec.js new file mode 100644 index 000000000..63ae1492d --- /dev/null +++ b/app/javascript/dashboard/api/specs/channel/fbChannel.spec.js @@ -0,0 +1,15 @@ +import fbChannel from '../../channel/fbChannel'; +import ApiClient from '../../ApiClient'; + +describe('#FBChannel', () => { + it('creates correct instance', () => { + expect(fbChannel).toBeInstanceOf(ApiClient); + expect(fbChannel).toHaveProperty('get'); + expect(fbChannel).toHaveProperty('show'); + expect(fbChannel).toHaveProperty('create'); + expect(fbChannel).toHaveProperty('update'); + expect(fbChannel).toHaveProperty('delete'); + expect(fbChannel).toHaveProperty('markSeen'); + expect(fbChannel).toHaveProperty('toggleTyping'); + }); +}); diff --git a/app/javascript/dashboard/api/specs/userNotificationSettings.spec.js b/app/javascript/dashboard/api/specs/userNotificationSettings.spec.js new file mode 100644 index 000000000..f65f1c3a3 --- /dev/null +++ b/app/javascript/dashboard/api/specs/userNotificationSettings.spec.js @@ -0,0 +1,13 @@ +import userNotificationSettings from '../userNotificationSettings'; +import ApiClient from '../ApiClient'; + +describe('#AgentAPI', () => { + it('creates correct instance', () => { + expect(userNotificationSettings).toBeInstanceOf(ApiClient); + expect(userNotificationSettings).toHaveProperty('get'); + expect(userNotificationSettings).toHaveProperty('show'); + expect(userNotificationSettings).toHaveProperty('create'); + expect(userNotificationSettings).toHaveProperty('update'); + expect(userNotificationSettings).toHaveProperty('delete'); + }); +}); diff --git a/app/javascript/dashboard/api/userNotificationSettings.js b/app/javascript/dashboard/api/userNotificationSettings.js new file mode 100644 index 000000000..33829a6bd --- /dev/null +++ b/app/javascript/dashboard/api/userNotificationSettings.js @@ -0,0 +1,14 @@ +/* global axios */ +import ApiClient from './ApiClient'; + +class UserNotificationSettings extends ApiClient { + constructor() { + super('notification_settings', { accountScoped: true }); + } + + update(params) { + return axios.patch(`${this.url}`, params); + } +} + +export default new UserNotificationSettings(); diff --git a/app/javascript/dashboard/api/webhooks.js b/app/javascript/dashboard/api/webhooks.js new file mode 100644 index 000000000..1e03f25f7 --- /dev/null +++ b/app/javascript/dashboard/api/webhooks.js @@ -0,0 +1,9 @@ +import ApiClient from './ApiClient'; + +class WebHooks extends ApiClient { + constructor() { + super('webhooks', { accountScoped: true }); + } +} + +export default new WebHooks(); diff --git a/app/javascript/dashboard/assets/audio/ding.mp3 b/app/javascript/dashboard/assets/audio/ding.mp3 deleted file mode 100644 index 1c4921711..000000000 Binary files a/app/javascript/dashboard/assets/audio/ding.mp3 and /dev/null differ diff --git a/app/javascript/dashboard/assets/images/channels/twilio.png b/app/javascript/dashboard/assets/images/channels/twilio.png new file mode 100644 index 000000000..627a8e9d4 Binary files /dev/null and b/app/javascript/dashboard/assets/images/channels/twilio.png differ diff --git a/app/javascript/dashboard/assets/images/channels/whatsapp.png b/app/javascript/dashboard/assets/images/channels/whatsapp.png new file mode 100644 index 000000000..547ff675e Binary files /dev/null and b/app/javascript/dashboard/assets/images/channels/whatsapp.png differ diff --git a/app/javascript/dashboard/assets/images/integrations/cable.svg b/app/javascript/dashboard/assets/images/integrations/cable.svg new file mode 100644 index 000000000..2a9f7008d --- /dev/null +++ b/app/javascript/dashboard/assets/images/integrations/cable.svg @@ -0,0 +1,64 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/javascript/dashboard/assets/scss/_helper-classes.scss b/app/javascript/dashboard/assets/scss/_helper-classes.scss index 4a32d17d1..fa84afa0c 100644 --- a/app/javascript/dashboard/assets/scss/_helper-classes.scss +++ b/app/javascript/dashboard/assets/scss/_helper-classes.scss @@ -3,8 +3,8 @@ } .flex-center { - display: flex; @include flex-align(center, middle); + display: flex; } .bottom-space-fix { @@ -17,42 +17,43 @@ .spinner { @include color-spinner(); - position: relative; display: inline-block; - width: $space-medium; height: $space-medium; padding: $zero $space-medium; + position: relative; vertical-align: middle; + width: $space-medium; &.message { - padding: $space-normal; - top: 0; - left: 0; - margin: 0 auto; - margin-top: $space-slab; + @include elegent-shadow; background: $color-white; border-radius: $space-large; - @include elegent-shadow; + left: 0; + margin: $space-slab 0 auto; + padding: $space-normal; + top: 0; - &:before { - margin-top: -$space-slab; + &::before { margin-left: -$space-slab; + margin-top: -$space-slab; } } &.small { - width: $space-normal; height: $space-normal; + width: $space-normal; - &:before { - width: $space-normal; + &::before { height: $space-normal; margin-top: -$space-small; + width: $space-normal; } } } -input, textarea { +input, +textarea, +select { border-radius: 4px !important; } diff --git a/app/javascript/dashboard/assets/scss/_layout.scss b/app/javascript/dashboard/assets/scss/_layout.scss index e636b2873..cdb81c76a 100644 --- a/app/javascript/dashboard/assets/scss/_layout.scss +++ b/app/javascript/dashboard/assets/scss/_layout.scss @@ -35,11 +35,11 @@ body { flex-direction: column; @include margin($zero); @include padding($space-normal); - overflow-y: scroll; + overflow-y: auto; } .content-box { - overflow: scroll; + overflow: auto; @include padding($space-normal); } diff --git a/app/javascript/dashboard/assets/scss/_mixins.scss b/app/javascript/dashboard/assets/scss/_mixins.scss index 953ab11a4..e4efee5ca 100644 --- a/app/javascript/dashboard/assets/scss/_mixins.scss +++ b/app/javascript/dashboard/assets/scss/_mixins.scss @@ -129,17 +129,16 @@ $spinner-before-border-color: rgba(255, 255, 255, 0.7); } @mixin scroll-on-hover() { - transition: all .4s $ease-in-out-cubic; overflow: hidden; &:hover { - overflow-y: scroll; + overflow-y: auto; } } @mixin horizontal-scroll() { - overflow-y: scroll; + overflow-y: auto; } @mixin elegent-shadow() { diff --git a/app/javascript/dashboard/assets/scss/_typography.scss b/app/javascript/dashboard/assets/scss/_typography.scss index 40d1c5a29..8c6d2e0f2 100644 --- a/app/javascript/dashboard/assets/scss/_typography.scss +++ b/app/javascript/dashboard/assets/scss/_typography.scss @@ -18,10 +18,14 @@ font-size: $font-size-small; } +.text-muted { + color: $color-gray; +} + a { font-size: $font-size-small; } p { font-size: $font-size-small; -} \ No newline at end of file +} diff --git a/app/javascript/dashboard/assets/scss/_woot.scss b/app/javascript/dashboard/assets/scss/_woot.scss index c85012e9f..05d822b5a 100644 --- a/app/javascript/dashboard/assets/scss/_woot.scss +++ b/app/javascript/dashboard/assets/scss/_woot.scss @@ -9,7 +9,6 @@ @import 'widgets/conv-header'; @import 'widgets/conversation-card'; @import 'widgets/conversation-view'; -@import 'widgets/emojiinput'; @import 'widgets/forms'; @import 'widgets/login'; @import 'widgets/modal'; @@ -25,6 +24,7 @@ @import 'views/settings/inbox'; @import 'views/settings/channel'; +@import 'views/settings/integrations'; @import 'views/signup'; @import 'plugins/multiselect'; diff --git a/app/javascript/dashboard/assets/scss/views/settings/inbox.scss b/app/javascript/dashboard/assets/scss/views/settings/inbox.scss index b6865213e..438945920 100644 --- a/app/javascript/dashboard/assets/scss/views/settings/inbox.scss +++ b/app/javascript/dashboard/assets/scss/views/settings/inbox.scss @@ -31,42 +31,39 @@ .wizard-box { .item { @include padding($space-normal $space-normal $space-normal $space-medium); - position: relative; @include background-light; - cursor: pointer; - &:before, - &:after { - content: ''; - position: absolute; - width: 2px; - height: 100%; + cursor: pointer; + position: relative; + + &::before, + &::after { background: $color-border; + content: ''; + height: 100%; + position: absolute; top: $space-normal; + width: 2px; } - &:before { - top: $zero; + &::before { height: $space-normal; + top: $zero; } &:first-child { - &:before { + &::before { height: 0; } } &:last-child { - &:after { + &::after { height: $zero; } } &.active { - // left: 1px; - // @include background-white; - // @include border-light; - // border-right: 0; h3 { color: $color-woot; } @@ -78,7 +75,7 @@ &.over { - &:after { + &::after { background: $color-woot; } @@ -86,18 +83,18 @@ background: $color-woot; } - &+.item { - &:before { + & + .item { + &::before { background: $color-woot; } } } h3 { - font-size: $font-size-default; - padding-left: $space-medium; - line-height: 1; color: $color-body; + font-size: $font-size-default; + line-height: 1; + padding-left: $space-medium; .completed { color: $success-color; @@ -105,25 +102,25 @@ } p { - font-size: $font-size-small; color: $color-light-gray; - padding-left: $space-medium; + font-size: $font-size-small; margin: 0; + padding-left: $space-medium; } .step { - position: absolute; - left: $space-normal; - top: $space-normal; - font-size: $font-size-small; - font-weight: $font-weight-medium; background: $color-border; border-radius: 20px; - width: $space-normal; + color: $color-white; + font-size: $font-size-small; + font-weight: $font-weight-medium; height: $space-normal; - text-align: center; + left: $space-normal; line-height: $space-normal; - color: #fff; + position: absolute; + text-align: center; + top: $space-normal; + width: $space-normal; z-index: 999; i { @@ -141,10 +138,6 @@ } .inoboxes-list { - // @include margin(auto); - // @include background-white; - // @include border-light; - // width: 50%; .inbox-item { @include margin($space-normal); @@ -152,16 +145,18 @@ @include flex-shrink; @include padding($space-normal $space-normal); @include border-light-bottom(); - flex-direction: column; + background: $color-white; cursor: pointer; - width: 20%; + flex-direction: column; float: left; min-height: 10rem; + width: 20%; &:last-child { - margin-bottom: $zero; @include border-nil; + + margin-bottom: $zero; } &:hover { @@ -174,8 +169,8 @@ .switch { align-self: center; - margin-right: $space-normal; margin-bottom: $zero; + margin-right: $space-normal; } .item--details { @@ -187,15 +182,15 @@ } .item--sub { - margin-bottom: 0; font-size: $font-size-small; + margin-bottom: 0; } } .arrow { align-self: center; - font-size: $font-size-small; color: $medium-gray; + font-size: $font-size-small; opacity: .7; transform: translateX(0); transition: opacity 0.100s ease-in 0s, transform 0.200s ease-in 0.030s; @@ -204,18 +199,19 @@ } .settings--content { - @include margin($space-small $space-medium); + @include margin($space-small $space-larger); .title { font-weight: $font-weight-medium; } .code { - max-height: $space-mega; - overflow: scroll; - white-space: nowrap; @include padding($space-one); + background: $color-background; + max-height: $space-mega; + overflow: auto; + white-space: nowrap; code { background: transparent; @@ -225,8 +221,8 @@ } .login-init { - text-align: center; padding-top: 30%; + text-align: center; p { @include padding($space-medium); diff --git a/app/javascript/dashboard/assets/scss/views/settings/integrations.scss b/app/javascript/dashboard/assets/scss/views/settings/integrations.scss new file mode 100644 index 000000000..183fa9a23 --- /dev/null +++ b/app/javascript/dashboard/assets/scss/views/settings/integrations.scss @@ -0,0 +1,37 @@ +.integrations-wrap { + .integration { + background: $color-white; + border: 2px solid $color-border; + border-radius: $space-slab; + padding: $space-normal; + + .integration--image { + display: flex; + margin-right: $space-normal; + width: 8rem; + + img { + max-width: 8rem; + padding: $space-small; + } + } + + .integration--title { + font-size: $font-size-large; + } + + .integration--description { + padding-right: $space-medium; + } + + .button-wrap { + @include flex; + @include flex-align(center, middle); + margin-bottom: 0; + } + } +} + +.help-wrap { + padding-left: $space-large; +} diff --git a/app/javascript/dashboard/assets/scss/widgets/_conv-header.scss b/app/javascript/dashboard/assets/scss/widgets/_conv-header.scss index f4a037886..0122ba96b 100644 --- a/app/javascript/dashboard/assets/scss/widgets/_conv-header.scss +++ b/app/javascript/dashboard/assets/scss/widgets/_conv-header.scss @@ -20,12 +20,12 @@ border-radius: $space-smaller; margin-right: $space-normal; - &:before { - line-height: 3.8rem; + &::before { + color: $medium-gray; font-size: $font-size-default; + line-height: 3.8rem; padding-left: $space-slab; padding-right: $space-smaller; - color: $medium-gray; } .multiselect { @@ -49,33 +49,32 @@ } .user--profile__meta { + align-items: flex-start; display: flex; flex-direction: column; - align-items: flex-start; justify-content: center; margin-left: $space-slab; } .user--profile__button { - color: $color-woot; font-size: $font-size-mini; margin-top: $space-micro; - cursor: pointer; + padding: 0; } } } .button.resolve--button { >.icon { - padding-right: $space-small; font-size: $font-size-default; + padding-right: $space-small; } .spinner { - padding: 0 $space-one; margin-right: $space-smaller; + padding: 0 $space-one; - &:before { + &::before { border-top-color: $color-white; } } diff --git a/app/javascript/dashboard/assets/scss/widgets/_conversation-card.scss b/app/javascript/dashboard/assets/scss/widgets/_conversation-card.scss index 649b3b31b..c331492d2 100644 --- a/app/javascript/dashboard/assets/scss/widgets/_conversation-card.scss +++ b/app/javascript/dashboard/assets/scss/widgets/_conversation-card.scss @@ -43,6 +43,11 @@ text-overflow: ellipsis; white-space: nowrap; width: 27rem; + + .small-icon { + font-size: $font-size-mini; + vertical-align: top; + } } .conversation--meta { diff --git a/app/javascript/dashboard/assets/scss/widgets/_conversation-view.scss b/app/javascript/dashboard/assets/scss/widgets/_conversation-view.scss index 7c28e3231..283281cbc 100644 --- a/app/javascript/dashboard/assets/scss/widgets/_conversation-view.scss +++ b/app/javascript/dashboard/assets/scss/widgets/_conversation-view.scss @@ -31,26 +31,36 @@ } .image { - @include flex; - align-items: flex-end; - justify-content: center; - text-align: center; - - img { - @include padding($space-small); - max-height: 30rem; - max-width: 20rem; - } + cursor: pointer; + position: relative; .time { - margin-left: -$space-large; + bottom: $space-smaller; + color: $color-white; + position: absolute; + right: $space-small; white-space: nowrap; } + .modal-container { + text-align: center; + } + .modal-image { - max-height: 80%; max-width: 80%; } + + &::before { + $color-black: #000; + background-image: linear-gradient(-180deg, transparent 3%, $color-black 70%); + bottom: 0; + content: ''; + height: 20%; + left: 0; + opacity: .8; + position: absolute; + width: 100%; + } } .map { @@ -82,6 +92,21 @@ @include flex; flex-direction: column; + .load-more-conversations { + font-size: $font-size-small; + padding: $space-normal; + width: 100%; + } + + .end-of-list-text { + padding: $space-normal; + } + + .conversations-list { + @include flex-weight(1); + @include scroll-on-hover; + } + .chat-list__top { @include flex; @include padding($space-normal $zero $space-small $zero); @@ -108,10 +133,7 @@ } } - .conversations-list { - @include flex-weight(1); - @include scroll-on-hover; - } + .content-box { text-align: center; @@ -169,7 +191,7 @@ // Firefox flexbox fix height: 100%; margin-bottom: $space-small; - overflow-y: scroll; + overflow-y: auto; li { @include flex; diff --git a/app/javascript/dashboard/assets/scss/widgets/_emojiinput.scss b/app/javascript/dashboard/assets/scss/widgets/_emojiinput.scss index 8fd11ec4d..f4bc48e7c 100644 --- a/app/javascript/dashboard/assets/scss/widgets/_emojiinput.scss +++ b/app/javascript/dashboard/assets/scss/widgets/_emojiinput.scss @@ -1,3 +1,6 @@ +@import '../variables'; +@import '../mixins'; + .emoji-dialog { @include elegant-card; background: $color-white; @@ -15,15 +18,15 @@ } .emojione { - @include margin($zero); - font-size: $font-size-medium; + font-size: $font-size-default; + margin: $zero; } .emoji-row { - @include padding($space-small); box-sizing: border-box; height: 180px; - overflow-y: scroll; + overflow-y: auto; + padding: $space-small; .emoji { border-radius: 4px; @@ -52,27 +55,33 @@ } .emoji-dialog-header { - @include padding($zero $space-smaller); - background-color: $light-gray; + background-color: $color-body; border-top-left-radius: $space-small; border-top-right-radius: $space-small; + padding: $zero $space-smaller; ul { + display: flex; list-style: none; margin: 0; padding: $space-smaller 0 0; - > li { - @include padding($space-smaller $space-small); - box-sizing: border-box; + >li { + align-items: center; cursor: pointer; - display: inline-block; - height: 3.4rem; - text-align: center; + display: flex; + height: $space-medium; + justify-content: center; + padding: $space-smaller $space-small; } - > .active { - background: $white; + .emojione { + height: $space-two; + width: $space-normal; + } + + >.active { + background: $color-white; border-top-left-radius: $space-small; border-top-right-radius: $space-small; } @@ -84,13 +93,14 @@ } .active { + img, svg { filter: grayscale(0); } } - > * { + >* { display: table-cell; vertical-align: middle; } diff --git a/app/javascript/dashboard/assets/scss/widgets/_login.scss b/app/javascript/dashboard/assets/scss/widgets/_login.scss index 608823d39..8720c6b57 100644 --- a/app/javascript/dashboard/assets/scss/widgets/_login.scss +++ b/app/javascript/dashboard/assets/scss/widgets/_login.scss @@ -5,7 +5,7 @@ // Outside login wrapper .login { @include full-height; - overflow-y: scroll; + overflow-y: auto; padding-top: $space-larger * 1.2; .login__hero { diff --git a/app/javascript/dashboard/assets/scss/widgets/_modal.scss b/app/javascript/dashboard/assets/scss/widgets/_modal.scss index 5a2cc7b3d..641400b1e 100644 --- a/app/javascript/dashboard/assets/scss/widgets/_modal.scss +++ b/app/javascript/dashboard/assets/scss/widgets/_modal.scss @@ -40,7 +40,7 @@ background-color: $color-white; border-radius: $space-small; max-height: 100%; - overflow: scroll; + overflow: auto; position: relative; width: 60rem; diff --git a/app/javascript/dashboard/assets/scss/widgets/_reply-box.scss b/app/javascript/dashboard/assets/scss/widgets/_reply-box.scss index 11350ceaf..d0bab2074 100644 --- a/app/javascript/dashboard/assets/scss/widgets/_reply-box.scss +++ b/app/javascript/dashboard/assets/scss/widgets/_reply-box.scss @@ -24,7 +24,7 @@ left: 0; max-height: 14rem; - overflow: scroll; + overflow: auto; position: absolute; width: 24rem; z-index: 100; @@ -47,7 +47,7 @@ } } - >.icon { + .icon { color: $medium-gray; cursor: pointer; font-size: $font-size-medium; @@ -58,6 +58,16 @@ } } + .file-uploads>label { + cursor: pointer; + } + + .attachment { + cursor: pointer; + margin-right: $space-one; + padding: 0 $space-small; + } + >textarea { @include ghost-input(); @include margin(0); diff --git a/app/javascript/dashboard/assets/scss/widgets/_sidemenu.scss b/app/javascript/dashboard/assets/scss/widgets/_sidemenu.scss index ef5e1dea9..2d1fd7511 100644 --- a/app/javascript/dashboard/assets/scss/widgets/_sidemenu.scss +++ b/app/javascript/dashboard/assets/scss/widgets/_sidemenu.scss @@ -28,9 +28,16 @@ color: $color-gray; font-size: $font-size-default; font-weight: $font-weight-medium; + + .wrap, + .child-icon { + &:hover { + color: $color-woot; + } + } } - .active a { + .active a .wrap { color: $color-woot; } } @@ -100,7 +107,7 @@ margin-top: $space-medium; >span { - margin-left: auto; + margin-left: $space-one; } } } diff --git a/app/javascript/dashboard/assets/scss/widgets/_tabs.scss b/app/javascript/dashboard/assets/scss/widgets/_tabs.scss index 0e76ffde9..e7d261954 100644 --- a/app/javascript/dashboard/assets/scss/widgets/_tabs.scss +++ b/app/javascript/dashboard/assets/scss/widgets/_tabs.scss @@ -21,7 +21,17 @@ } .tabs-title { - @include margin($zero $space-one); + @include margin($zero $space-slab); + + .badge { + background: $color-background; + border-radius: $space-small; + color: $color-gray; + font-size: $font-size-micro; + font-weight: $font-weight-black; + margin-left: $space-smaller; + padding: $space-smaller; + } &:first-child { margin-left: 0; @@ -40,10 +50,13 @@ a { @include position(relative, 1px null null null); - transition: all .15s $ease-in-out-cubic; + align-items: center; border-bottom: 2px solid transparent; color: $medium-gray; + display: flex; + flex-direction: row; font-size: $font-size-small; + transition: all .15s $ease-in-out-cubic; } &.is-active { @@ -51,5 +64,10 @@ border-bottom-color: $color-woot; color: $color-woot; } + + .badge { + background: $color-extra-light-blue; + color: $color-woot; + } } } diff --git a/app/javascript/dashboard/components/ChatList.vue b/app/javascript/dashboard/components/ChatList.vue index 6f69857f7..c0ddb1987 100644 --- a/app/javascript/dashboard/components/ChatList.vue +++ b/app/javascript/dashboard/components/ChatList.vue @@ -3,40 +3,52 @@

- {{ inbox.name || pageTitle }} + {{ inbox.name || $t('CHAT_LIST.TAB_HEADING') }}

- +
-

+

{{ $t('CHAT_LIST.LIST.404') }}

-
- -
- - +
- + +
+ +
+ +
+ {{ $t('CHAT_LIST.LOAD_MORE_CONVERSATIONS') }} +
+ +

+ {{ $t('CHAT_LIST.EOF') }} +

+
@@ -59,11 +71,11 @@ export default { ChatFilter, }, mixins: [timeMixin, conversationMixin], - props: ['conversationInbox', 'pageTitle'], + props: ['conversationInbox'], data() { return { - activeAssigneeTab: 0, - activeStatus: 0, + activeAssigneeTab: wootConstants.ASSIGNEE_TYPE.ME, + activeStatus: wootConstants.STATUS_TYPE.OPEN, }; }, computed: { @@ -78,66 +90,69 @@ export default { convStats: 'getConvTabStats', }), assigneeTabItems() { - return this.$t('CHAT_LIST.ASSIGNEE_TYPE_TABS').map((item, index) => ({ - id: index, + return this.$t('CHAT_LIST.ASSIGNEE_TYPE_TABS').map(item => ({ + key: item.KEY, name: item.NAME, - count: this.convStats[item.KEY] || 0, + count: this.convStats[item.COUNT_KEY] || 0, })); }, inbox() { return this.$store.getters['inboxes/getInbox'](this.activeInbox); }, - getToggleStatus() { - if (this.toggleType) { - return 'Open'; - } - return 'Resolved'; + currentPage() { + return this.$store.getters['conversationPage/getCurrentPage']( + this.activeAssigneeTab + ); + }, + hasCurrentPageEndReached() { + return this.$store.getters['conversationPage/getHasEndReached']( + this.activeAssigneeTab + ); + }, + }, + watch: { + conversationInbox() { + this.resetAndFetchData(); }, }, mounted() { - this.$watch('$store.state.route', () => { - if (this.$store.state.route.name !== 'inbox_conversation') { - this.$store.dispatch('emptyAllConversations'); - this.fetchData(); - } - }); - - this.$store.dispatch('emptyAllConversations'); - this.fetchData(); + this.$store.dispatch('setChatFilter', this.activeStatus); + this.resetAndFetchData(); this.$store.dispatch('agents/get'); }, methods: { - fetchData() { - if (this.chatLists.length === 0) { - this.fetchConversations(); - } + resetAndFetchData() { + this.$store.dispatch('conversationPage/reset'); + this.$store.dispatch('emptyAllConversations'); + this.fetchConversations(); }, fetchConversations() { this.$store.dispatch('fetchAllConversations', { inboxId: this.conversationInbox ? this.conversationInbox : undefined, assigneeType: this.activeAssigneeTab, - status: this.activeStatus ? 'resolved' : 'open', + status: this.activeStatus, + page: this.currentPage + 1, }); }, - getDataForTab(index) { - if (this.activeAssigneeTab !== index) { - this.activeAssigneeTab = index; - this.fetchConversations(); + updateAssigneeTab(selectedTab) { + if (this.activeAssigneeTab !== selectedTab) { + this.activeAssigneeTab = selectedTab; + if (!this.currentPage) { + this.fetchConversations(); + } } }, - getDataForStatusTab(index) { + updateStatusType(index) { if (this.activeStatus !== index) { this.activeStatus = index; - this.fetchConversations(); + this.resetAndFetchData(); } }, getChatsForTab() { let copyList = []; - if (this.activeAssigneeTab === wootConstants.ASSIGNEE_TYPE_SLUG.MINE) { + if (this.activeAssigneeTab === 'me') { copyList = this.mineChatsList.slice(); - } else if ( - this.activeAssigneeTab === wootConstants.ASSIGNEE_TYPE_SLUG.UNASSIGNED - ) { + } else if (this.activeAssigneeTab === 'unassigned') { copyList = this.unAssignedChatsList.slice(); } else { copyList = this.allChatList.slice(); diff --git a/app/javascript/dashboard/components/Code.vue b/app/javascript/dashboard/components/Code.vue index 17134ebdc..95b09e82c 100644 --- a/app/javascript/dashboard/components/Code.vue +++ b/app/javascript/dashboard/components/Code.vue @@ -26,7 +26,8 @@ export default { }, }, methods: { - onCopy() { + onCopy(e) { + e.preventDefault(); copy(this.script); bus.$emit('newToastMessage', this.$t('COMPONENTS.CODE.COPY_SUCCESSFUL')); }, diff --git a/app/javascript/dashboard/components/Modal.vue b/app/javascript/dashboard/components/Modal.vue index b3179a641..b7d9489fb 100644 --- a/app/javascript/dashboard/components/Modal.vue +++ b/app/javascript/dashboard/components/Modal.vue @@ -1,6 +1,11 @@ + + + + diff --git a/app/javascript/dashboard/components/widgets/emoji/EmojiInput.vue b/app/javascript/dashboard/components/widgets/emoji/EmojiInput.vue index 5fd762bcf..693680281 100644 --- a/app/javascript/dashboard/components/widgets/emoji/EmojiInput.vue +++ b/app/javascript/dashboard/components/widgets/emoji/EmojiInput.vue @@ -3,37 +3,39 @@
-
{{selectedKey}}
+
+ {{ selectedKey }} +
+ diff --git a/app/javascript/dashboard/constants.js b/app/javascript/dashboard/constants.js index 1f1b5cda4..35559648c 100644 --- a/app/javascript/dashboard/constants.js +++ b/app/javascript/dashboard/constants.js @@ -4,9 +4,13 @@ export default { return `${this.APP_BASE_URL}/`; }, GRAVATAR_URL: 'https://www.gravatar.com/avatar/', - ASSIGNEE_TYPE_SLUG: { - MINE: 0, - UNASSIGNED: 1, - OPEN: 0, + ASSIGNEE_TYPE: { + ME: 'me', + UNASSIGNED: 'unassigned', + ALL: 'all', + }, + STATUS_TYPE: { + OPEN: 'open', + RESOLVED: 'resolved', }, }; diff --git a/app/javascript/dashboard/helper/URLHelper.js b/app/javascript/dashboard/helper/URLHelper.js index 5448a9d6a..5e6cae568 100644 --- a/app/javascript/dashboard/helper/URLHelper.js +++ b/app/javascript/dashboard/helper/URLHelper.js @@ -4,3 +4,19 @@ export const frontendURL = (path, params) => { const stringifiedParams = params ? `?${queryString.stringify(params)}` : ''; return `/app/${path}${stringifiedParams}`; }; + +export const conversationUrl = (accountId, activeInbox, id) => { + const path = activeInbox + ? `accounts/${accountId}/inbox/${activeInbox}/conversations/${id}` + : `accounts/${accountId}/conversations/${id}`; + return path; +}; + +export const accountIdFromPathname = pathname => { + const isInsideAccountScopedURLs = pathname.includes('/app/accounts'); + const urlParam = pathname.split('/')[3]; + // eslint-disable-next-line no-restricted-globals + const isScoped = isInsideAccountScopedURLs && !isNaN(urlParam); + const accountId = isScoped ? Number(urlParam) : ''; + return accountId; +}; diff --git a/app/javascript/dashboard/helper/actionCable.js b/app/javascript/dashboard/helper/actionCable.js index 27ff80dbc..a1f70702e 100644 --- a/app/javascript/dashboard/helper/actionCable.js +++ b/app/javascript/dashboard/helper/actionCable.js @@ -6,14 +6,20 @@ class ActionCableConnector extends BaseActionCableConnector { super(app, pubsubToken); this.events = { 'message.created': this.onMessageCreated, + 'message.updated': this.onMessageUpdated, 'conversation.created': this.onConversationCreated, - 'status_change:conversation': this.onStatusChange, + 'conversation.opened': this.onStatusChange, + 'conversation.resolved': this.onStatusChange, 'user:logout': this.onLogout, 'page:reload': this.onReload, 'assignee.changed': this.onAssigneeChanged, }; } + onMessageUpdated = data => { + this.app.$store.dispatch('updateMessage', data); + }; + onAssigneeChanged = payload => { const { meta = {}, id } = payload; const { assignee } = meta || {}; @@ -35,7 +41,7 @@ class ActionCableConnector extends BaseActionCableConnector { onReload = () => window.location.reload(); onStatusChange = data => { - this.app.$store.dispatch('addConversation', data); + this.app.$store.dispatch('updateConversation', data); }; } diff --git a/app/javascript/dashboard/helper/scriptGenerator.js b/app/javascript/dashboard/helper/scriptGenerator.js index bfb900718..5a278d30a 100644 --- a/app/javascript/dashboard/helper/scriptGenerator.js +++ b/app/javascript/dashboard/helper/scriptGenerator.js @@ -1,20 +1,3 @@ -export const createWebsiteWidgetScript = websiteToken => ` - -`; - export const createMessengerScript = pageId => ` @@ -165,8 +184,16 @@ export default { overflow-y: auto; background: white; overflow: auto; + position: relative; } +.close-button { + position: absolute; + right: $space-slab; + top: $space-slab; + font-size: $font-size-default; + color: $color-heading; +} .contact--profile { padding: $space-medium $space-normal 0 $space-medium; align-items: center; @@ -191,7 +218,7 @@ export default { text-transform: capitalize; font-weight: $font-weight-bold; - font-size: $font-size-medium; + font-size: $font-size-default; } .contact--email { diff --git a/app/javascript/dashboard/routes/dashboard/conversation/ConversationView.vue b/app/javascript/dashboard/routes/dashboard/conversation/ConversationView.vue index a0f4cb42d..c51053c19 100644 --- a/app/javascript/dashboard/routes/dashboard/conversation/ConversationView.vue +++ b/app/javascript/dashboard/routes/dashboard/conversation/ConversationView.vue @@ -1,10 +1,6 @@ @@ -36,7 +33,6 @@ export default { data() { return { - pageTitle: this.$state, panelToggleState: false, }; }, @@ -59,7 +55,16 @@ export default { props: ['inboxId', 'conversationId'], mounted() { - this.$watch('$store.state.route', () => { + this.initialize(); + this.$watch('$store.state.route', () => this.initialize()); + this.$watch('chatList.length', () => { + this.fetchConversation(); + this.setActiveChat(); + }); + }, + + methods: { + initialize() { switch (this.$store.state.route.name) { case 'inbox_conversation': this.setActiveChat(); @@ -69,20 +74,36 @@ export default { this.$store.dispatch('setActiveInbox', this.inboxId); } break; + case 'conversation_through_inbox': + if (this.inboxId) { + this.$store.dispatch('setActiveInbox', this.inboxId); + } + this.setActiveChat(); + break; default: this.$store.dispatch('setActiveInbox', null); + this.$store.dispatch('clearSelectedState'); break; } - }); - this.$watch('chatList.length', () => { - this.setActiveChat(); - }); - }, + }, - methods: { - setActiveChat() { + fetchConversation() { + if (!this.conversationId) { + return; + } + const chat = this.findConversation(); + if (!chat) { + this.$store.dispatch('getConversation', this.conversationId); + } + }, + findConversation() { const conversationId = parseInt(this.conversationId, 10); const [chat] = this.chatList.filter(c => c.id === conversationId); + return chat; + }, + + setActiveChat() { + const chat = this.findConversation(); if (!chat) return; this.$store.dispatch('setActiveChat', chat).then(() => { bus.$emit('scrollToMessage'); diff --git a/app/javascript/dashboard/routes/dashboard/conversation/conversation.routes.js b/app/javascript/dashboard/routes/dashboard/conversation/conversation.routes.js index 8b47e1304..0a51c24c2 100644 --- a/app/javascript/dashboard/routes/dashboard/conversation/conversation.routes.js +++ b/app/javascript/dashboard/routes/dashboard/conversation/conversation.routes.js @@ -5,7 +5,7 @@ import { frontendURL } from '../../../helper/URLHelper'; export default { routes: [ { - path: frontendURL('dashboard'), + path: frontendURL('accounts/:accountId/dashboard'), name: 'home', roles: ['administrator', 'agent'], component: ConversationView, @@ -14,7 +14,7 @@ export default { }, }, { - path: frontendURL('inbox/:inbox_id'), + path: frontendURL('accounts/:accountId/inbox/:inbox_id'), name: 'inbox_dashboard', roles: ['administrator', 'agent'], component: ConversationView, @@ -23,12 +23,26 @@ export default { }, }, { - path: frontendURL('conversations/:conversation_id'), + path: frontendURL('accounts/:accountId/conversations/:conversation_id'), name: 'inbox_conversation', roles: ['administrator', 'agent'], component: ConversationView, props: route => { - return { conversationId: route.params.conversation_id }; + return { inboxId: 0, conversationId: route.params.conversation_id }; + }, + }, + { + path: frontendURL( + 'accounts/:accountId/inbox/:inbox_id/conversations/:conversation_id' + ), + name: 'conversation_through_inbox', + roles: ['administrator', 'agent'], + component: ConversationView, + props: route => { + return { + conversationId: route.params.conversation_id, + inboxId: route.params.inbox_id, + }; }, }, ], diff --git a/app/javascript/dashboard/routes/dashboard/dashboard.routes.js b/app/javascript/dashboard/routes/dashboard/dashboard.routes.js index 9fd87d851..42db278c7 100644 --- a/app/javascript/dashboard/routes/dashboard/dashboard.routes.js +++ b/app/javascript/dashboard/routes/dashboard/dashboard.routes.js @@ -6,7 +6,7 @@ import { frontendURL } from '../../helper/URLHelper'; export default { routes: [ { - path: frontendURL(''), + path: frontendURL('accounts/:account_id'), component: AppContainer, children: [...conversation.routes, ...settings.routes], }, diff --git a/app/javascript/dashboard/routes/dashboard/settings/SettingsHeader.vue b/app/javascript/dashboard/routes/dashboard/settings/SettingsHeader.vue index fc1028007..76858695c 100644 --- a/app/javascript/dashboard/routes/dashboard/settings/SettingsHeader.vue +++ b/app/javascript/dashboard/routes/dashboard/settings/SettingsHeader.vue @@ -2,12 +2,12 @@

- + {{ headerTitle }}

@@ -41,14 +41,8 @@ export default { default: '', type: String, }, - showButton: Boolean, - showNewButton: Boolean, - hideButtonRoutes: { - type: Array, - default() { - return ['agent_list', 'settings_inbox_list']; - }, - }, + showBackButton: { type: Boolean, default: false }, + showNewButton: { type: Boolean, default: false }, }, computed: { ...mapGetters({ diff --git a/app/javascript/dashboard/routes/dashboard/settings/Wrapper.vue b/app/javascript/dashboard/routes/dashboard/settings/Wrapper.vue index 54c072785..04138cea1 100644 --- a/app/javascript/dashboard/routes/dashboard/settings/Wrapper.vue +++ b/app/javascript/dashboard/routes/dashboard/settings/Wrapper.vue @@ -5,8 +5,8 @@ :icon="icon" :header-title="$t(headerTitle)" :button-text="$t(headerButtonText)" - :show-button="showButton()" - :show-new-button="showNewButton()" + :show-back-button="showBackButton" + :show-new-button="showNewButton" /> @@ -26,7 +26,14 @@ export default { headerTitle: String, headerButtonText: String, icon: String, - newButtonRoutes: Array, + newButtonRoutes: { + type: Array, + default: () => [], + }, + showBackButton: { + type: Boolean, + default: false, + }, }, data() { return {}; @@ -35,16 +42,8 @@ export default { currentPage() { return this.$store.state.route.name; }, - }, - methods: { - showButton() { - /* eslint-disable no-unneeded-ternary */ - return this.newButtonRoutes - ? this.newButtonRoutes.indexOf(this.currentPage) > -1 - : true; - }, showNewButton() { - return this.newButtonRoutes ? true : false; + return this.newButtonRoutes.length !== 0 && !this.showBackButton; }, }, }; diff --git a/app/javascript/dashboard/routes/dashboard/settings/account/Index.vue b/app/javascript/dashboard/routes/dashboard/settings/account/Index.vue new file mode 100644 index 000000000..af595ecc5 --- /dev/null +++ b/app/javascript/dashboard/routes/dashboard/settings/account/Index.vue @@ -0,0 +1,199 @@ + + + + + diff --git a/app/javascript/dashboard/routes/dashboard/settings/account/account.routes.js b/app/javascript/dashboard/routes/dashboard/settings/account/account.routes.js new file mode 100644 index 000000000..6957af495 --- /dev/null +++ b/app/javascript/dashboard/routes/dashboard/settings/account/account.routes.js @@ -0,0 +1,27 @@ +import SettingsContent from '../Wrapper'; +import Index from './Index.vue'; +import { frontendURL } from '../../../../helper/URLHelper'; + +export default { + routes: [ + { + path: frontendURL('accounts/:accountId/settings/general'), + name: 'general_settings', + roles: ['administrator'], + component: SettingsContent, + props: { + headerTitle: 'GENERAL_SETTINGS.TITLE', + icon: 'ion-gear-a', + showNewButton: false, + }, + children: [ + { + path: '', + name: 'general_settings_index', + component: Index, + roles: ['administrator'], + }, + ], + }, + ], +}; diff --git a/app/javascript/dashboard/routes/dashboard/settings/agents/AddAgent.vue b/app/javascript/dashboard/routes/dashboard/settings/agents/AddAgent.vue index b10af87bc..0fa469d9b 100644 --- a/app/javascript/dashboard/routes/dashboard/settings/agents/AddAgent.vue +++ b/app/javascript/dashboard/routes/dashboard/settings/agents/AddAgent.vue @@ -128,7 +128,11 @@ export default { this.showAlert(this.$t('AGENT_MGMT.ADD.API.SUCCESS_MESSAGE')); this.onClose(); } catch (error) { - this.showAlert(this.$t('AGENT_MGMT.ADD.API.ERROR_MESSAGE')); + if (error.response.status === 422) { + this.showAlert(this.$t('AGENT_MGMT.ADD.API.EXIST_MESSAGE')); + } else { + this.showAlert(this.$t('AGENT_MGMT.ADD.API.ERROR_MESSAGE')); + } } }, }, diff --git a/app/javascript/dashboard/routes/dashboard/settings/agents/agent.routes.js b/app/javascript/dashboard/routes/dashboard/settings/agents/agent.routes.js index 5c34399d8..ab1fde521 100644 --- a/app/javascript/dashboard/routes/dashboard/settings/agents/agent.routes.js +++ b/app/javascript/dashboard/routes/dashboard/settings/agents/agent.routes.js @@ -5,7 +5,7 @@ import { frontendURL } from '../../../../helper/URLHelper'; export default { routes: [ { - path: frontendURL('settings/agents'), + path: frontendURL('accounts/:accountId/settings/agents'), component: SettingsContent, props: { headerTitle: 'AGENT_MGMT.HEADER', diff --git a/app/javascript/dashboard/routes/dashboard/settings/billing/billing.routes.js b/app/javascript/dashboard/routes/dashboard/settings/billing/billing.routes.js index 2a7c951e4..58bf309c3 100644 --- a/app/javascript/dashboard/routes/dashboard/settings/billing/billing.routes.js +++ b/app/javascript/dashboard/routes/dashboard/settings/billing/billing.routes.js @@ -6,7 +6,7 @@ import { frontendURL } from '../../../../helper/URLHelper'; export default { routes: [ { - path: frontendURL('settings/billing'), + path: frontendURL('accounts/:accountId/settings/billing'), component: SettingsContent, props: { headerTitle: 'BILLING.HEADER', diff --git a/app/javascript/dashboard/routes/dashboard/settings/canned/canned.routes.js b/app/javascript/dashboard/routes/dashboard/settings/canned/canned.routes.js index 6d68207c2..9e761f8f1 100644 --- a/app/javascript/dashboard/routes/dashboard/settings/canned/canned.routes.js +++ b/app/javascript/dashboard/routes/dashboard/settings/canned/canned.routes.js @@ -5,7 +5,7 @@ import { frontendURL } from '../../../../helper/URLHelper'; export default { routes: [ { - path: frontendURL('settings/canned-response'), + path: frontendURL('accounts/:accountId/settings/canned-response'), component: SettingsContent, props: { headerTitle: 'CANNED_MGMT.HEADER', diff --git a/app/javascript/dashboard/routes/dashboard/settings/inbox/ChannelList.vue b/app/javascript/dashboard/routes/dashboard/settings/inbox/ChannelList.vue index 3b3ba9dc3..12bba91d3 100644 --- a/app/javascript/dashboard/routes/dashboard/settings/inbox/ChannelList.vue +++ b/app/javascript/dashboard/routes/dashboard/settings/inbox/ChannelList.vue @@ -27,7 +27,14 @@ export default { }, data() { return { - channelList: ['website', 'facebook', 'twitter', 'telegram', 'line'], + channelList: [ + 'website', + 'facebook', + 'twitter', + 'twilio', + 'telegram', + 'line', + ], }; }, methods: { diff --git a/app/javascript/dashboard/routes/dashboard/settings/inbox/FinishSetup.vue b/app/javascript/dashboard/routes/dashboard/settings/inbox/FinishSetup.vue index 0e4470d60..accba4cfb 100644 --- a/app/javascript/dashboard/routes/dashboard/settings/inbox/FinishSetup.vue +++ b/app/javascript/dashboard/routes/dashboard/settings/inbox/FinishSetup.vue @@ -7,7 +7,10 @@ >
- +
diff --git a/app/javascript/dashboard/routes/dashboard/settings/inbox/InboxChannels.vue b/app/javascript/dashboard/routes/dashboard/settings/inbox/InboxChannels.vue index 40d5a659e..27dfe2c24 100644 --- a/app/javascript/dashboard/routes/dashboard/settings/inbox/InboxChannels.vue +++ b/app/javascript/dashboard/routes/dashboard/settings/inbox/InboxChannels.vue @@ -1,18 +1,6 @@ - diff --git a/app/javascript/dashboard/routes/dashboard/settings/inbox/Index.vue b/app/javascript/dashboard/routes/dashboard/settings/inbox/Index.vue index 28eaa40de..1d4ab689b 100644 --- a/app/javascript/dashboard/routes/dashboard/settings/inbox/Index.vue +++ b/app/javascript/dashboard/routes/dashboard/settings/inbox/Index.vue @@ -7,7 +7,7 @@ {{ $t('INBOX_MGMT.LIST.404') }} {{ $t('SETTINGS.INBOXES.NEW_INBOX') }} @@ -39,12 +39,20 @@ Website + + Twitter + + + Twilio SMS +
- + diff --git a/app/javascript/dashboard/routes/dashboard/settings/inbox/Settings.vue b/app/javascript/dashboard/routes/dashboard/settings/inbox/Settings.vue index 7bce9e6f9..a035c7631 100644 --- a/app/javascript/dashboard/routes/dashboard/settings/inbox/Settings.vue +++ b/app/javascript/dashboard/routes/dashboard/settings/inbox/Settings.vue @@ -2,77 +2,179 @@
+ +
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+ +
+ +
+
+ +
+ +
+ + + +
+
+ + + +
+ + + + + + +
- - - + +
- - - + +
-
- - - -
-
-
- - - -
-
- - -
@@ -81,24 +183,27 @@ /* eslint no-console: 0 */ /* global bus */ import { mapGetters } from 'vuex'; -import { - createWebsiteWidgetScript, - createMessengerScript, -} from 'dashboard/helper/scriptGenerator'; +import { createMessengerScript } from 'dashboard/helper/scriptGenerator'; import { Compact } from 'vue-color'; -import SettingsFormHeader from '../../../../components/SettingsFormHeader.vue'; +import configMixin from 'shared/mixins/configMixin'; +import SettingsSection from '../../../../components/SettingsSection'; export default { components: { Compact, - SettingsFormHeader, + SettingsSection, }, + mixins: [configMixin], data() { return { selectedAgents: [], autoAssignment: false, isUpdating: false, isAgentListUpdating: false, + channelWebsiteUrl: '', + channelWelcomeTitle: '', + channelWelcomeTagline: '', + channelAgentAwayMessage: '', }; }, computed: { @@ -112,8 +217,11 @@ export default { inbox() { return this.$store.getters['inboxes/getInbox'](this.currentInboxId); }, - webWidgetScript() { - return createWebsiteWidgetScript(this.inbox.website_token); + inboxName() { + if (this.inbox.channel_type === 'Channel::TwilioSms') { + return `${this.inbox.name} (${this.inbox.phone_number})`; + } + return this.inbox.name; }, messengerScript() { return createMessengerScript(this.inbox.page_id); @@ -139,6 +247,10 @@ export default { this.$store.dispatch('inboxes/get').then(() => { this.fetchAttachedAgents(); this.autoAssignment = this.inbox.enable_auto_assignment; + this.channelWebsiteUrl = this.inbox.website_url; + this.channelWelcomeTitle = this.inbox.welcome_title; + this.channelWelcomeTagline = this.inbox.welcome_tagline; + this.channelAgentAwayMessage = this.inbox.agent_away_message; }); }, async fetchAttachedAgents() { @@ -175,30 +287,23 @@ export default { } this.isAgentListUpdating = false; }, - async updateWidgetColor() { + async updateInbox() { try { - await this.$store.dispatch('inboxes/updateWebsiteChannel', { - id: this.inbox.channel_id, - website: { - widget_color: this.getWidgetColor(this.inbox.widget_color), - }, - }); - this.showAlert(this.$t('INBOX_MGMT.EDIT.API.SUCCESS_MESSAGE')); - } catch (error) { - this.showAlert(this.$t('INBOX_MGMT.EDIT.API.SUCCESS_MESSAGE')); - } - }, - async updateAutoAssignment() { - try { - await this.$store.dispatch('inboxes/updateAutoAssignment', { + await this.$store.dispatch('inboxes/updateInbox', { id: this.currentInboxId, - inbox: { - enable_auto_assignment: this.autoAssignment, + name: this.inboxName, + enable_auto_assignment: this.autoAssignment, + channel: { + widget_color: this.getWidgetColor(this.inbox.widget_color), + website_url: this.channelWebsiteUrl, + welcome_title: this.channelWelcomeTitle, + welcome_tagline: this.channelWelcomeTagline, + agent_away_message: this.channelAgentAwayMessage, }, }); - this.showAlert(this.$t('INBOX_MGMT.EDIT.API.AUTO_ASSIGNMENT_SUCCESS_MESSAGE')); + this.showAlert(this.$t('INBOX_MGMT.EDIT.API.SUCCESS_MESSAGE')); } catch (error) { - this.showAlert(this.$t('INBOX_MGMT.EDIT.API.AUTO_ASSIGNMENT_SUCCESS_MESSAGE')); + this.showAlert(this.$t('INBOX_MGMT.EDIT.API.SUCCESS_MESSAGE')); } }, getWidgetColor() { @@ -216,3 +321,26 @@ export default { }, }; + + diff --git a/app/javascript/dashboard/routes/dashboard/settings/inbox/channel-factory.js b/app/javascript/dashboard/routes/dashboard/settings/inbox/channel-factory.js index 6e2dc3403..9d15b3664 100644 --- a/app/javascript/dashboard/routes/dashboard/settings/inbox/channel-factory.js +++ b/app/javascript/dashboard/routes/dashboard/settings/inbox/channel-factory.js @@ -1,11 +1,13 @@ import Facebook from './channels/Facebook'; import Website from './channels/Website'; import Twitter from './channels/Twitter'; +import Twilio from './channels/Twilio'; const channelViewList = { facebook: Facebook, website: Website, twitter: Twitter, + twilio: Twilio, }; export default { diff --git a/app/javascript/dashboard/routes/dashboard/settings/inbox/channels/Facebook.vue b/app/javascript/dashboard/routes/dashboard/settings/inbox/channels/Facebook.vue index 15199c08d..07cdfa0f9 100644 --- a/app/javascript/dashboard/routes/dashboard/settings/inbox/channels/Facebook.vue +++ b/app/javascript/dashboard/routes/dashboard/settings/inbox/channels/Facebook.vue @@ -68,6 +68,7 @@ /* global FB */ import { required } from 'vuelidate/lib/validators'; import LoadingState from 'dashboard/components/widgets/LoadingState'; +import { mapGetters } from 'vuex'; import ChannelApi from '../../../../../api/channels'; import PageHeader from '../../SettingsSubPageHeader'; import router from '../../../../index'; @@ -111,6 +112,12 @@ export default { getSelectablePages() { return this.pageList.filter(item => !item.exists); }, + ...mapGetters({ + currentUser: 'getCurrentUser', + }), + accountId() { + return this.currentUser.account_id; + }, }, created() { @@ -188,28 +195,33 @@ export default { } }, { - scope: - 'manage_pages,read_page_mailboxes,pages_messaging,pages_messaging_phone_number', + scope: 'manage_pages,pages_messaging,pages_messaging_phone_number', } ); }, - fetchPages(_token) { - ChannelApi.fetchFacebookPages(_token) - .then(response => { - this.pageList = response.data.data.page_details; - this.user_access_token = response.data.data.user_access_token; - }) - .catch(); + async fetchPages(_token) { + try { + const response = await ChannelApi.fetchFacebookPages( + _token, + this.accountId + ); + const { + data: { data }, + } = response; + this.pageList = data.page_details; + this.user_access_token = data.user_access_token; + } catch (error) { + // Ignore error + } }, channelParams() { return { user_access_token: this.user_access_token, page_access_token: this.selectedPage.access_token, - page_name: this.selectedPage.name, page_id: this.selectedPage.id, - inbox_name: this.pageName, + inbox_name: this.selectedPage.name, }; }, diff --git a/app/javascript/dashboard/routes/dashboard/settings/inbox/channels/Twilio.vue b/app/javascript/dashboard/routes/dashboard/settings/inbox/channels/Twilio.vue new file mode 100644 index 000000000..ac5ba6859 --- /dev/null +++ b/app/javascript/dashboard/routes/dashboard/settings/inbox/channels/Twilio.vue @@ -0,0 +1,159 @@ + + + diff --git a/app/javascript/dashboard/routes/dashboard/settings/inbox/channels/Website.vue b/app/javascript/dashboard/routes/dashboard/settings/inbox/channels/Website.vue index 8641210db..e6d93ae41 100644 --- a/app/javascript/dashboard/routes/dashboard/settings/inbox/channels/Website.vue +++ b/app/javascript/dashboard/routes/dashboard/settings/inbox/channels/Website.vue @@ -5,16 +5,20 @@ :header-content="$t('INBOX_MGMT.ADD.WEBSITE_CHANNEL.DESC')" /> -
+
+
+ +
+
+ +