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 566ed8dff..7240829b3 100644 --- a/.env.example +++ b/.env.example @@ -22,17 +22,6 @@ 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 MAILER_SENDER_EMAIL=accounts@chatwoot.com SMTP_PORT=1025 @@ -59,13 +48,25 @@ AWS_REGION= SENTRY_DSN= #Log settings -LOG_LEVEL= -LOG_SIZE= +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/.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..ff4a0160a 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -41,14 +41,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..eeaac4c52 100644 --- a/.scss-lint.yml +++ b/.scss-lint.yml @@ -82,7 +82,7 @@ linters: enabled: true ImportantRule: - enabled: true + enabled: false ImportPath: enabled: true diff --git a/Gemfile b/Gemfile index b3f1a2995..4225f2904 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' @@ -25,11 +25,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 +62,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 diff --git a/Gemfile.lock b/Gemfile.lock index e998700d5..0ad798ef4 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,6 +1,6 @@ GIT remote: https://github.com/chatwoot/twitty - revision: c1edd557401d1e8a197b19e738f82e39507a8e2d + revision: af4f3e45dca55e42c64f7741a1fedfaa94d36419 specs: twitty (0.1.0) oauth @@ -16,7 +16,7 @@ GIT 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.2) actionpack (= 6.0.2.2) @@ -77,46 +77,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-partitions (1.294.0) + aws-sdk-core (3.92.0) aws-eventstream (~> 1.0, >= 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.0) + browser (4.0.0) builder (3.2.4) bullet (6.1.0) activesupport (>= 3.0.0) @@ -127,7 +125,7 @@ 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) @@ -151,7 +149,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) @@ -165,23 +163,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) flag_shih_tzu (0.3.23) - foreman (0.87.0) + foreman (0.87.1) globalid (0.4.2) activesupport (>= 4.2.0) - google-api-client (0.36.4) + google-api-client (0.37.2) addressable (~> 2.5, >= 2.5.1) googleauth (~> 0.9) httpclient (>= 2.8.1, < 3.0) @@ -192,8 +190,8 @@ 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) addressable (~> 2.5) @@ -202,20 +200,22 @@ GEM google-cloud-core (~> 1.2) googleauth (~> 0.9) mini_mime (~> 1.0) - googleauth (0.10.0) - faraday (~> 0.12) + googleauth (0.11.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) + 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) @@ -224,11 +224,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) @@ -246,8 +246,8 @@ 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) @@ -261,7 +261,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) @@ -271,7 +271,7 @@ GEM 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) @@ -282,14 +282,14 @@ GEM 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.0) 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.0) + coderay (~> 1.1) + method_source (~> 1.0) pry-rails (0.3.9) pry (>= 0.10.4) public_suffix (4.0.3) @@ -345,7 +345,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) @@ -360,15 +360,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) @@ -377,19 +378,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.1) + 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) @@ -397,25 +400,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) @@ -443,7 +447,11 @@ GEM 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) @@ -452,10 +460,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.1) activemodel (>= 3.2) mail (~> 2.5) virtus (1.0.5) @@ -470,10 +478,11 @@ 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) @@ -489,7 +498,7 @@ DEPENDENCIES annotate attr_extras aws-sdk-s3 - azure-storage + azure-storage-blob bootsnap brakeman browser @@ -506,6 +515,7 @@ DEPENDENCIES flag_shih_tzu foreman google-cloud-storage + groupdate haikunator hashie jbuilder @@ -545,6 +555,7 @@ DEPENDENCIES spring-watcher-listen telegram-bot-ruby time_diff + twilio-ruby (~> 5.32.0) twitty! tzinfo-data uglifier @@ -554,7 +565,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/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 393b3861b..8261a51ad 100644 --- a/app/actions/contact_merge_action.rb +++ b/app/actions/contact_merge_action.rb @@ -9,6 +9,7 @@ class ContactMergeAction merge_contact_inboxes remove_mergee_contact end + @base_contact end private 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..70b994ac2 --- /dev/null +++ b/app/builders/contact_builder.rb @@ -0,0 +1,37 @@ +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[:identifier] + ) + 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..a01df50c4 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,9 +34,7 @@ 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 diff --git a/app/builders/messages/outgoing/normal_builder.rb b/app/builders/messages/outgoing/normal_builder.rb index b75e8f84b..668022e64 100644 --- a/app/builders/messages/outgoing/normal_builder.rb +++ b/app/builders/messages/outgoing/normal_builder.rb @@ -1,16 +1,27 @@ 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] + @private = params[:private] || false @conversation = conversation @user = user @fb_id = params[:fb_id] + @attachment = params[:attachment] end def perform - @message = @conversation.messages.create!(message_params) + @message = @conversation.messages.build(message_params) + if @attachment + @message.attachment = Attachment.new( + account_id: message.account_id, + file_type: file_type(@attachment[:file]&.content_type) + ) + @message.attachment.file.attach(@attachment[:file]) + end + @message.save + @message end private @@ -22,7 +33,7 @@ class Messages::Outgoing::NormalBuilder message_type: :outgoing, content: @content, private: @private, - user_id: @user.id, + user_id: @user&.id, source_id: @fb_id } end diff --git a/app/builders/v2/report_builder.rb b/app/builders/v2/report_builder.rb new file mode 100644 index 000000000..3e5e01880 --- /dev/null +++ b/app/builders/v2/report_builder.rb @@ -0,0 +1,110 @@ +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 + + def incoming_messages_count + scope.messages.unscoped.incoming + .group_by_day(:created_at, range: range, default_value: 0) + .count + end + + def outgoing_messages_count + scope.messages.unscoped.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..d512eeabf 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? + end + def set_conversation @conversation ||= current_account.conversations.find_by(display_id: params[:conversation_id]) end diff --git a/app/controllers/api/v1/accounts_controller.rb b/app/controllers/api/v1/accounts/accounts_controller.rb similarity index 59% rename from app/controllers/api/v1/accounts_controller.rb rename to app/controllers/api/v1/accounts/accounts_controller.rb index 96e488231..b8ad4fa46 100644 --- a/app/controllers/api/v1/accounts_controller.rb +++ b/app/controllers/api/v1/accounts/accounts_controller.rb @@ -1,10 +1,12 @@ -class Api::V1::AccountsController < Api::BaseController +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 + 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, @@ -18,18 +20,32 @@ class Api::V1::AccountsController < Api::BaseController ).perform if @user send_auth_headers(@user) - render json: { - data: @user.token_validation_response - } + 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)) + 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) + params.permit(:account_name, :email, :name, :locale) end def check_signup_enabled 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/callbacks_controller.rb b/app/controllers/api/v1/accounts/callbacks_controller.rb similarity index 89% rename from app/controllers/api/v1/callbacks_controller.rb rename to app/controllers/api/v1/accounts/callbacks_controller.rb index 031d5accc..ab276ba29 100644 --- a/app/controllers/api/v1/callbacks_controller.rb +++ b/app/controllers/api/v1/accounts/callbacks_controller.rb @@ -1,6 +1,4 @@ -require 'rest-client' -require 'telegram/bot' -class Api::V1::CallbacksController < Api::BaseController +class Api::V1::Accounts::CallbacksController < Api::BaseController before_action :inbox, only: [:reauthorize_page] def register_facebook_page @@ -18,7 +16,7 @@ class Api::V1::CallbacksController < Api::BaseController render json: inbox end - def get_facebook_pages + def facebook_pages @page_details = mark_already_existing_facebook_pages(fb_object.get_connections('me', 'accounts')) end @@ -61,13 +59,15 @@ class Api::V1::CallbacksController < Api::BaseController 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| - current_account.facebook_pages.exists?(page_id: page_detail['id']) ? page_detail.merge!(exists: true) : page_detail.merge!(exists: false) + page_detail[:exists] = current_account.facebook_pages.exists?(page_id: page_detail['id']) ? true : false result << page_detail end end @@ -90,11 +90,12 @@ class Api::V1::CallbacksController < Api::BaseController response = uri.open(redirect: false) rescue OpenURI::HTTPRedirect => e uri = e.uri # assigned from the "Location" response header - retry if (tries -= 1) > 0 + 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 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..c3d6554fd --- /dev/null +++ b/app/controllers/api/v1/accounts/channels/twilio_channels_controller.rb @@ -0,0 +1,50 @@ +class Api::V1::Accounts::Channels::TwilioChannelsController < Api::BaseController + before_action :authorize_request + + def create + authenticate_twilio + build_inbox + setup_webhooks + rescue Twilio::REST::TwilioError => e + render_could_not_create_error(e.message) + rescue StandardError => e + render_could_not_create_error(e.message) + 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 build_inbox + ActiveRecord::Base.transaction do + twilio_sms = current_account.twilio_sms.create( + account_sid: permitted_params[:account_sid], + auth_token: permitted_params[:auth_token], + phone_number: permitted_params[:phone_number] + ) + @inbox = current_account.inboxes.create( + name: permitted_params[:name], + channel: twilio_sms + ) + rescue StandardError => e + render_could_not_create_error(e.message) + end + end + + def permitted_params + params.require(:twilio_channel).permit( + :account_id, :phone_number, :account_sid, :auth_token, :name + ) + 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/conversations/messages_controller.rb b/app/controllers/api/v1/accounts/conversations/messages_controller.rb similarity index 81% rename from app/controllers/api/v1/conversations/messages_controller.rb rename to app/controllers/api/v1/accounts/conversations/messages_controller.rb index 793b3d054..d0ed39edb 100644 --- a/app/controllers/api/v1/conversations/messages_controller.rb +++ b/app/controllers/api/v1/accounts/conversations/messages_controller.rb @@ -1,4 +1,4 @@ -class Api::V1::Conversations::MessagesController < Api::BaseController +class Api::V1::Accounts::Conversations::MessagesController < Api::BaseController before_action :set_conversation, only: [:index, :create] def index diff --git a/app/controllers/api/v1/conversations_controller.rb b/app/controllers/api/v1/accounts/conversations_controller.rb similarity index 82% rename from app/controllers/api/v1/conversations_controller.rb rename to app/controllers/api/v1/accounts/conversations_controller.rb index cf0d55779..895091cca 100644 --- a/app/controllers/api/v1/conversations_controller.rb +++ b/app/controllers/api/v1/accounts/conversations_controller.rb @@ -1,5 +1,5 @@ -class Api::V1::ConversationsController < Api::BaseController - before_action :set_conversation, except: [:index] +class Api::V1::Accounts::ConversationsController < Api::BaseController + before_action :conversation, except: [:index] def index result = conversation_finder.perform @@ -25,7 +25,7 @@ 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 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/inboxes_controller.rb b/app/controllers/api/v1/accounts/inboxes_controller.rb similarity index 88% rename from app/controllers/api/v1/inboxes_controller.rb rename to app/controllers/api/v1/accounts/inboxes_controller.rb index e9005a0c7..630f93e0d 100644 --- a/app/controllers/api/v1/inboxes_controller.rb +++ b/app/controllers/api/v1/accounts/inboxes_controller.rb @@ -1,4 +1,4 @@ -class Api::V1::InboxesController < Api::BaseController +class Api::V1::Accounts::InboxesController < Api::BaseController before_action :check_authorization before_action :fetch_inbox, only: [:destroy, :update] @@ -6,15 +6,15 @@ class Api::V1::InboxesController < Api::BaseController @inboxes = policy_scope(current_account.inboxes) end + def update + @inbox.update(inbox_update_params) + end + def destroy @inbox.destroy head :ok end - def update - @inbox.update(inbox_update_params) - end - private def fetch_inbox 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/user/notification_settings_controller.rb b/app/controllers/api/v1/accounts/notification_settings_controller.rb similarity index 88% rename from app/controllers/api/v1/user/notification_settings_controller.rb rename to app/controllers/api/v1/accounts/notification_settings_controller.rb index 78c5dc720..ba6e43804 100644 --- a/app/controllers/api/v1/user/notification_settings_controller.rb +++ b/app/controllers/api/v1/accounts/notification_settings_controller.rb @@ -1,4 +1,4 @@ -class Api::V1::User::NotificationSettingsController < Api::BaseController +class Api::V1::Accounts::NotificationSettingsController < Api::BaseController before_action :set_user, :load_notification_setting def show; 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/account/webhooks_controller.rb b/app/controllers/api/v1/accounts/webhooks_controller.rb similarity index 90% rename from app/controllers/api/v1/account/webhooks_controller.rb rename to app/controllers/api/v1/accounts/webhooks_controller.rb index 730e7b9b1..dbdd953ed 100644 --- a/app/controllers/api/v1/account/webhooks_controller.rb +++ b/app/controllers/api/v1/accounts/webhooks_controller.rb @@ -1,4 +1,4 @@ -class Api::V1::Account::WebhooksController < Api::BaseController +class Api::V1::Accounts::WebhooksController < Api::BaseController before_action :check_authorization before_action :fetch_webhook, only: [:update, :destroy] diff --git a/app/controllers/api/v1/widget/inboxes_controller.rb b/app/controllers/api/v1/accounts/widget/inboxes_controller.rb similarity index 93% rename from app/controllers/api/v1/widget/inboxes_controller.rb rename to app/controllers/api/v1/accounts/widget/inboxes_controller.rb index ce739fef0..f6305e4eb 100644 --- a/app/controllers/api/v1/widget/inboxes_controller.rb +++ b/app/controllers/api/v1/accounts/widget/inboxes_controller.rb @@ -1,4 +1,4 @@ -class Api::V1::Widget::InboxesController < Api::BaseController +class Api::V1::Accounts::Widget::InboxesController < Api::BaseController before_action :authorize_request before_action :set_web_widget_channel, only: [:update] before_action :set_inbox, only: [:update] 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/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/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/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 6e1eae9fe..7d16f7641 100644 --- a/app/controllers/api/v1/widget/messages_controller.rb +++ b/app/controllers/api/v1/widget/messages_controller.rb @@ -10,20 +10,29 @@ class Api::V1::Widget::MessagesController < Api::V1::Widget::BaseController def create @message = conversation.messages.new(message_params) + build_attachment @message.save! - render json: @message end def update @message.update!(input_submitted_email: contact_email) update_contact(contact_email) - head :no_content rescue StandardError => e render json: { error: @contact.errors, message: e.message }.to_json, status: 500 end private + def build_attachment + return if params[:message][:attachment].blank? + + @message.attachment = Attachment.new( + account_id: @message.account_id, + file_type: helpers.file_type(params[:message][:attachment][:file]&.content_type) + ) + @message.attachment.file.attach(params[:message][:attachment][:file]) + end + def set_conversation @conversation = ::Conversation.create!(conversation_params) if conversation.nil? end @@ -86,7 +95,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, 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/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..3e3875333 --- /dev/null +++ b/app/controllers/concerns/access_token_auth_helper.rb @@ -0,0 +1,25 @@ +module AccessTokenAuthHelper + BOT_ACCESSIBLE_ENDPOINTS = { + 'api/v1/accounts/conversations' => ['toggle_status'], + 'api/v1/accounts/conversations/messages' => ['create'] + }.freeze + + def authenticate_access_token! + access_token = AccessToken.find_by(token: request.headers[:api_access_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/twilio/callback_controller.rb b/app/controllers/twilio/callback_controller.rb new file mode 100644 index 000000000..f6cb5356c --- /dev/null +++ b/app/controllers/twilio/callback_controller.rb @@ -0,0 +1,29 @@ +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 + ) + end +end diff --git a/app/controllers/twitter/callbacks_controller.rb b/app/controllers/twitter/callbacks_controller.rb index a46c54821..fe55ba3a1 100644 --- a/app/controllers/twitter/callbacks_controller.rb +++ b/app/controllers/twitter/callbacks_controller.rb @@ -1,6 +1,6 @@ class Twitter::CallbacksController < Twitter::BaseController def show - return redirect_to app_new_twitter_inbox_url if permitted_params[:denied] + return redirect_to twitter_app_redirect_url if permitted_params[:denied] @response = twitter_client.access_token( oauth_token: permitted_params[:oauth_token], @@ -10,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 @@ -30,6 +30,10 @@ 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( diff --git a/app/dispatchers/async_dispatcher.rb b/app/dispatchers/async_dispatcher.rb index dff0c9d92..65569c1b0 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 = [EmailNotificationListener.instance, ReportingListener.instance, WebhookListener.instance] + listeners = [AgentBotListener.instance, EmailNotificationListener.instance, ReportingListener.instance, WebhookListener.instance] + listeners << EventListener.instance listeners << SubscriptionListener.instance if ENV['BILLING_ENABLED'] listeners end 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..5354787c3 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('widget/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 6a86cff7f..d5212957a 100644 --- a/app/javascript/dashboard/api/inbox/conversation.js +++ b/app/javascript/dashboard/api/inbox/conversation.js @@ -3,7 +3,7 @@ import ApiClient from '../ApiClient'; class ConversationApi extends ApiClient { constructor() { - super('conversations'); + super('conversations', { accountScoped: true }); } get({ inboxId, status, assigneeType, page }) { diff --git a/app/javascript/dashboard/api/inbox/message.js b/app/javascript/dashboard/api/inbox/message.js index 00dda8a63..579d97a10 100644 --- a/app/javascript/dashboard/api/inbox/message.js +++ b/app/javascript/dashboard/api/inbox/message.js @@ -4,7 +4,7 @@ import ApiClient from '../ApiClient'; class MessageApi extends ApiClient { constructor() { - super('conversations'); + super('conversations', { accountScoped: true }); } create({ conversationId, message, private: isPrivate }) { @@ -19,6 +19,16 @@ class MessageApi extends ApiClient { params: { before }, }); } + + sendAttachment([conversationId, { file }]) { + const formData = new FormData(); + formData.append('attachment[file]', file); + 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/userNotificationSettings.js b/app/javascript/dashboard/api/userNotificationSettings.js index 15cea8942..33829a6bd 100644 --- a/app/javascript/dashboard/api/userNotificationSettings.js +++ b/app/javascript/dashboard/api/userNotificationSettings.js @@ -3,7 +3,7 @@ import ApiClient from './ApiClient'; class UserNotificationSettings extends ApiClient { constructor() { - super('user/notification_settings'); + super('notification_settings', { accountScoped: true }); } update(params) { diff --git a/app/javascript/dashboard/api/webhooks.js b/app/javascript/dashboard/api/webhooks.js index 229519dd7..1e03f25f7 100644 --- a/app/javascript/dashboard/api/webhooks.js +++ b/app/javascript/dashboard/api/webhooks.js @@ -2,7 +2,7 @@ import ApiClient from './ApiClient'; class WebHooks extends ApiClient { constructor() { - super('account/webhooks'); + super('webhooks', { accountScoped: true }); } } 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/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/_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/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 cf625423b..ecf682b2d 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 { @@ -83,18 +93,12 @@ flex-direction: column; .load-more-conversations { - color: $color-woot; - cursor: pointer; font-size: $font-size-small; padding: $space-normal; - - &:hover { - background: $color-background; - } + width: 100%; } .end-of-list-text { - font-style: italic; padding: $space-normal; } diff --git a/app/javascript/dashboard/assets/scss/widgets/_reply-box.scss b/app/javascript/dashboard/assets/scss/widgets/_reply-box.scss index 15dde2966..d0bab2074 100644 --- a/app/javascript/dashboard/assets/scss/widgets/_reply-box.scss +++ b/app/javascript/dashboard/assets/scss/widgets/_reply-box.scss @@ -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 83e1766b5..c0ddb1987 100644 --- a/app/javascript/dashboard/components/ChatList.vue +++ b/app/javascript/dashboard/components/ChatList.vue @@ -32,7 +32,7 @@