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 @@
{{ $t('CHAT_LIST.LOAD_MORE_CONVERSATIONS') }} 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/layout/Sidebar.vue b/app/javascript/dashboard/components/layout/Sidebar.vue index 52838fa3f..dfb9081d2 100644 --- a/app/javascript/dashboard/components/layout/Sidebar.vue +++ b/app/javascript/dashboard/components/layout/Sidebar.vue @@ -43,13 +43,13 @@ > @@ -139,23 +139,23 @@ export default { inboxSection() { return { icon: 'ion-folder', - label: 'Inboxes', + label: 'INBOXES', hasSubMenu: true, newLink: true, key: 'inbox', cssClass: 'menu-title align-justify', - toState: frontendURL('settings/inboxes'), + toState: frontendURL(`accounts/${this.accountId}/settings/inboxes`), toStateName: 'settings_inbox_list', children: this.inboxes.map(inbox => ({ id: inbox.id, label: inbox.name, - toState: frontendURL(`inbox/${inbox.id}`), + toState: frontendURL(`accounts/${this.accountId}/inbox/${inbox.id}`), type: inbox.channel_type, })), }; }, dashboardPath() { - return frontendURL('dashboard'); + return frontendURL(`accounts/${this.accountId}/dashboard`); }, shouldShowStatusBox() { return ( @@ -176,6 +176,9 @@ export default { trialMessage() { return `${this.daysLeft} ${this.$t('APP_GLOBAL.TRIAL_MESSAGE')}`; }, + accountId() { + return this.currentUser.account_id; + }, }, mounted() { this.$store.dispatch('inboxes/get'); diff --git a/app/javascript/dashboard/components/layout/SidebarItem.vue b/app/javascript/dashboard/components/layout/SidebarItem.vue index 2340d1f7f..8e8297e01 100644 --- a/app/javascript/dashboard/components/layout/SidebarItem.vue +++ b/app/javascript/dashboard/components/layout/SidebarItem.vue @@ -6,16 +6,19 @@ :class="computedClass" > - - {{ menuItem.label }} +
+ + {{ $t(`SIDEBAR.${menuItem.label}`) }} +
@@ -28,12 +31,14 @@ :to="child.toState" > - - {{ child.label }} +
+ + {{ child.label }} +
@@ -51,6 +56,7 @@ const INBOX_TYPES = { WEB: 'Channel::WebWidget', FB: 'Channel::FacebookPage', TWITTER: 'Channel::TwitterProfile', + TWILIO: 'Channel::TwilioSms', }; const getInboxClassByType = type => { switch (type) { @@ -63,6 +69,9 @@ const getInboxClassByType = type => { case INBOX_TYPES.TWITTER: return 'ion-social-twitter'; + case INBOX_TYPES.TWILIO: + return 'ion-android-textsms'; + default: return ''; } @@ -115,3 +124,9 @@ export default { }, }; + diff --git a/app/javascript/dashboard/components/ui/Tabs/TabsItem.js b/app/javascript/dashboard/components/ui/Tabs/TabsItem.js index 929d7897d..3c57abdc3 100644 --- a/app/javascript/dashboard/components/ui/Tabs/TabsItem.js +++ b/app/javascript/dashboard/components/ui/Tabs/TabsItem.js @@ -80,7 +80,8 @@ export default { } }} > - {`${this.name} (${this.getItemCount})`} + {`${this.name}`} + {this.getItemCount} ); diff --git a/app/javascript/dashboard/components/ui/Wizard.vue b/app/javascript/dashboard/components/ui/Wizard.vue index 8b0d46ed4..f34b8f182 100644 --- a/app/javascript/dashboard/components/ui/Wizard.vue +++ b/app/javascript/dashboard/components/ui/Wizard.vue @@ -29,12 +29,6 @@ /* eslint no-console: 0 */ export default { props: { - items: { - type: Array, - default() { - return []; - }, - }, isFullwidth: Boolean, }, @@ -45,6 +39,9 @@ export default { activeIndex() { return this.items.findIndex(i => i.route === this.$route.name); }, + items() { + return this.$t('INBOX_MGMT.CREATE_FLOW'); + }, }, methods: { isActive(item) { diff --git a/app/javascript/dashboard/components/widgets/Avatar.vue b/app/javascript/dashboard/components/widgets/Avatar.vue index b23b5a737..5489278d5 100644 --- a/app/javascript/dashboard/components/widgets/Avatar.vue +++ b/app/javascript/dashboard/components/widgets/Avatar.vue @@ -81,5 +81,6 @@ export default { align-items: center; justify-content: center; text-align: center; + background-image: linear-gradient(to top, #4481eb 0%, #04befe 100%); } diff --git a/app/javascript/dashboard/components/widgets/ChannelItem.vue b/app/javascript/dashboard/components/widgets/ChannelItem.vue index da22f71bd..948bff103 100644 --- a/app/javascript/dashboard/components/widgets/ChannelItem.vue +++ b/app/javascript/dashboard/components/widgets/ChannelItem.vue @@ -24,6 +24,10 @@ v-if="channel === 'website'" src="~dashboard/assets/images/channels/website.png" /> +

{{ channel }}

@@ -39,7 +43,7 @@ export default { }, methods: { isActive(channel) { - return ['facebook', 'website', 'twitter'].includes(channel); + return ['facebook', 'website', 'twitter', 'twilio'].includes(channel); }, onItemClick() { if (this.isActive(this.channel)) { diff --git a/app/javascript/dashboard/components/widgets/Thumbnail.vue b/app/javascript/dashboard/components/widgets/Thumbnail.vue index 2d9b31fdd..01cd7f76b 100644 --- a/app/javascript/dashboard/components/widgets/Thumbnail.vue +++ b/app/javascript/dashboard/components/widgets/Thumbnail.vue @@ -11,7 +11,6 @@ v-else :username="username" :class="thumbnailClass" - background-color="#1f93ff" color="white" :size="avatarSize" /> diff --git a/app/javascript/dashboard/components/widgets/conversation/ConversationCard.vue b/app/javascript/dashboard/components/widgets/conversation/ConversationCard.vue index ce7040c21..7292344f8 100644 --- a/app/javascript/dashboard/components/widgets/conversation/ConversationCard.vue +++ b/app/javascript/dashboard/components/widgets/conversation/ConversationCard.vue @@ -74,6 +74,7 @@ export default { currentChat: 'getSelectedChat', inboxesList: 'inboxes/getInboxes', activeInbox: 'getSelectedInbox', + currentUser: 'getCurrentUser', }), isActiveChat() { @@ -96,7 +97,11 @@ export default { methods: { cardClick(chat) { const { activeInbox } = this; - const path = conversationUrl(activeInbox, chat.id); + const path = conversationUrl( + this.currentUser.account_id, + activeInbox, + chat.id + ); router.push({ path: frontendURL(path) }); }, extractMessageText(chatItem) { @@ -111,7 +116,7 @@ export default { } const key = `CHAT_LIST.ATTACHMENTS.${fileType}`; return ` - + ${this.$t(`${key}.CONTENT`)} `; }, diff --git a/app/javascript/dashboard/components/widgets/conversation/ConversationHeader.vue b/app/javascript/dashboard/components/widgets/conversation/ConversationHeader.vue index 1833bd095..29215362f 100644 --- a/app/javascript/dashboard/components/widgets/conversation/ConversationHeader.vue +++ b/app/javascript/dashboard/components/widgets/conversation/ConversationHeader.vue @@ -12,7 +12,7 @@ {{ chat.meta.sender.name }} @@ -34,9 +40,18 @@
+ + diff --git a/app/javascript/shared/components/ChatCard.vue b/app/javascript/shared/components/ChatCard.vue new file mode 100644 index 000000000..8accb6d8c --- /dev/null +++ b/app/javascript/shared/components/ChatCard.vue @@ -0,0 +1,89 @@ + + + + + diff --git a/app/javascript/shared/components/ChatOption.vue b/app/javascript/shared/components/ChatOption.vue new file mode 100644 index 000000000..1096a1470 --- /dev/null +++ b/app/javascript/shared/components/ChatOption.vue @@ -0,0 +1,68 @@ + + + + + diff --git a/app/javascript/shared/components/ChatOptions.vue b/app/javascript/shared/components/ChatOptions.vue new file mode 100644 index 000000000..bbb714a9f --- /dev/null +++ b/app/javascript/shared/components/ChatOptions.vue @@ -0,0 +1,89 @@ + + + + + + diff --git a/app/javascript/shared/helpers/BaseActionCableConnector.js b/app/javascript/shared/helpers/BaseActionCableConnector.js index 4ad5fc2f4..703b6e74a 100644 --- a/app/javascript/shared/helpers/BaseActionCableConnector.js +++ b/app/javascript/shared/helpers/BaseActionCableConnector.js @@ -2,8 +2,8 @@ import { createConsumer } from '@rails/actioncable'; class BaseActionCableConnector { constructor(app, pubsubToken) { - const consumer = createConsumer(); - consumer.subscriptions.create( + this.consumer = createConsumer(); + this.consumer.subscriptions.create( { channel: 'RoomChannel', pubsub_token: pubsubToken, @@ -16,6 +16,10 @@ class BaseActionCableConnector { this.events = {}; } + disconnect() { + this.consumer.disconnect(); + } + onReceived = ({ event, data } = {}) => { if (this.events[event] && typeof this.events[event] === 'function') { this.events[event](data); diff --git a/app/javascript/shared/helpers/MessageFormatter.js b/app/javascript/shared/helpers/MessageFormatter.js index 32aeef48f..2edd5576b 100644 --- a/app/javascript/shared/helpers/MessageFormatter.js +++ b/app/javascript/shared/helpers/MessageFormatter.js @@ -15,7 +15,8 @@ class MessageFormatter { const urlRegex = /(https?:\/\/[^\s]+)/g; return this.message.replace( urlRegex, - url => `${url}` + url => + `${url}` ); } diff --git a/app/javascript/shared/helpers/specs/MessageFormatter.spec.js b/app/javascript/shared/helpers/specs/MessageFormatter.spec.js index f6ffb34f8..847f9efd9 100644 --- a/app/javascript/shared/helpers/specs/MessageFormatter.spec.js +++ b/app/javascript/shared/helpers/specs/MessageFormatter.spec.js @@ -6,7 +6,7 @@ describe('#MessageFormatter', () => { const message = 'Chatwoot is an opensource tool\nSee more at https://www.chatwoot.com'; expect(new MessageFormatter(message).formattedMessage).toEqual( - 'Chatwoot is an opensource tool
See more at https://www.chatwoot.com' + 'Chatwoot is an opensource tool
See more at https://www.chatwoot.com' ); }); }); diff --git a/app/javascript/shared/mixins/alertMixin.js b/app/javascript/shared/mixins/alertMixin.js new file mode 100644 index 000000000..61aa7e04e --- /dev/null +++ b/app/javascript/shared/mixins/alertMixin.js @@ -0,0 +1,8 @@ +/* global bus */ +export default { + methods: { + showAlert(message) { + bus.$emit('newToastMessage', message); + }, + }, +}; diff --git a/app/javascript/shared/mixins/configMixin.js b/app/javascript/shared/mixins/configMixin.js new file mode 100644 index 000000000..89c7569d8 --- /dev/null +++ b/app/javascript/shared/mixins/configMixin.js @@ -0,0 +1,7 @@ +export default { + computed: { + hostURL() { + return window.chatwootConfig.hostURL; + }, + }, +}; diff --git a/app/javascript/widget/App.vue b/app/javascript/widget/App.vue index 050c5b7c1..11cc3c508 100755 --- a/app/javascript/widget/App.vue +++ b/app/javascript/widget/App.vue @@ -1,5 +1,5 @@ @@ -11,6 +11,11 @@ import { IFrameHelper } from 'widget/helpers/utils'; export default { name: 'App', + data() { + return { + isMobile: false, + }; + }, mounted() { const { website_token: websiteToken = '' } = window.chatwootWebChannel; if (IFrameHelper.isIFrame()) { @@ -40,6 +45,14 @@ export default { this.scrollConversationToBottom(); } else if (message.event === 'set-current-url') { window.refererURL = message.refererURL; + } else if (message.event === 'toggle-close-button') { + this.isMobile = message.showClose; + } else if (message.event === 'set-label') { + this.$store.dispatch('conversationLabels/create', message.label); + } else if (message.event === 'remove-label') { + this.$store.dispatch('conversationLabels/destroy', message.label); + } else if (message.event === 'set-user') { + this.$store.dispatch('contacts/update', message); } }); }, diff --git a/app/javascript/widget/api/contact.js b/app/javascript/widget/api/contact.js deleted file mode 100755 index e5529c3dc..000000000 --- a/app/javascript/widget/api/contact.js +++ /dev/null @@ -1,10 +0,0 @@ -import authEndPoint from 'widget/api/endPoints'; -import { API } from 'widget/helpers/axios'; - -export const updateContact = async ({ messageId, email }) => { - const urlData = authEndPoint.updateContact(messageId); - const result = await API.patch(urlData.url, { - contact: { email }, - }); - return result; -}; diff --git a/app/javascript/widget/api/contacts.js b/app/javascript/widget/api/contacts.js new file mode 100644 index 000000000..1a8ee5ea6 --- /dev/null +++ b/app/javascript/widget/api/contacts.js @@ -0,0 +1,12 @@ +import { API } from 'widget/helpers/axios'; + +const buildUrl = endPoint => `/api/v1/${endPoint}${window.location.search}`; + +export default { + update(identifier, userObject) { + return API.patch(buildUrl('widget/contact'), { + identifier, + ...userObject, + }); + }, +}; diff --git a/app/javascript/widget/api/conversation.js b/app/javascript/widget/api/conversation.js index b7a243f67..542d8a213 100755 --- a/app/javascript/widget/api/conversation.js +++ b/app/javascript/widget/api/conversation.js @@ -7,10 +7,16 @@ const sendMessageAPI = async content => { return result; }; +const sendAttachmentAPI = async attachment => { + const urlData = endPoints.sendAttachmnet(attachment); + const result = await API.post(urlData.url, urlData.params); + return result; +}; + const getConversationAPI = async ({ before }) => { const urlData = endPoints.getConversation({ before }); const result = await API.get(urlData.url, { params: urlData.params }); return result; }; -export { sendMessageAPI, getConversationAPI }; +export { sendMessageAPI, getConversationAPI, sendAttachmentAPI }; diff --git a/app/javascript/widget/api/conversationLabels.js b/app/javascript/widget/api/conversationLabels.js new file mode 100644 index 000000000..95ae90f78 --- /dev/null +++ b/app/javascript/widget/api/conversationLabels.js @@ -0,0 +1,12 @@ +import { API } from 'widget/helpers/axios'; + +const buildUrl = endPoint => `/api/v1/${endPoint}${window.location.search}`; + +export default { + create(label) { + return API.post(buildUrl('widget/labels'), { label }); + }, + destroy(label) { + return API.delete(buildUrl(`widget/labels/${label}`)); + }, +}; diff --git a/app/javascript/widget/api/endPoints.js b/app/javascript/widget/api/endPoints.js index 2a36bc793..36cae18ba 100755 --- a/app/javascript/widget/api/endPoints.js +++ b/app/javascript/widget/api/endPoints.js @@ -9,6 +9,22 @@ const sendMessage = content => ({ }, }); +const sendAttachmnet = ({ attachment }) => { + const { refererURL = '' } = window; + const timestamp = new Date().toString(); + const { file, file_type: fileType } = attachment; + + const formData = new FormData(); + formData.append('message[attachment][file]', file); + formData.append('message[attachment][file_type]', fileType); + formData.append('message[referer_url]', refererURL); + formData.append('message[timestamp]', timestamp); + return { + url: `/api/v1/widget/messages${window.location.search}`, + params: formData, + }; +}; + const getConversation = ({ before }) => ({ url: `/api/v1/widget/messages${window.location.search}`, params: { before }, @@ -27,6 +43,7 @@ const getAvailableAgents = token => ({ export default { sendMessage, + sendAttachmnet, getConversation, updateContact, getAvailableAgents, diff --git a/app/javascript/widget/api/message.js b/app/javascript/widget/api/message.js new file mode 100755 index 000000000..96723775d --- /dev/null +++ b/app/javascript/widget/api/message.js @@ -0,0 +1,11 @@ +import authEndPoint from 'widget/api/endPoints'; +import { API } from 'widget/helpers/axios'; + +export default { + update: ({ messageId, email }) => { + const urlData = authEndPoint.updateContact(messageId); + return API.patch(urlData.url, { + contact: { email }, + }); + }, +}; diff --git a/app/javascript/widget/assets/images/paperclip.svg b/app/javascript/widget/assets/images/paperclip.svg new file mode 100644 index 000000000..b1f69b7a7 --- /dev/null +++ b/app/javascript/widget/assets/images/paperclip.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/app/javascript/widget/assets/scss/woot.scss b/app/javascript/widget/assets/scss/woot.scss index 40304fbfe..222275592 100755 --- a/app/javascript/widget/assets/scss/woot.scss +++ b/app/javascript/widget/assets/scss/woot.scss @@ -17,3 +17,36 @@ body { .woot-widget-wrap { height: 100%; } + +.close-button { + cursor: pointer; + position: relative; + width: $space-two; + + &::before, + &::after { + background-color: $color-heading; + content: ' '; + height: $space-normal; + left: $space-small; + position: absolute; + top: $space-micro; + width: 2px; + } + + &::before { + transform: rotate(45deg); + } + + &::after { + transform: rotate(-45deg); + } +} + +.is-mobile { + .header-wrap { + .close-button { + display: block !important; + } + } +} diff --git a/app/javascript/widget/components/AgentMessage.vue b/app/javascript/widget/components/AgentMessage.vue index 62e478f00..109a396b1 100755 --- a/app/javascript/widget/components/AgentMessage.vue +++ b/app/javascript/widget/components/AgentMessage.vue @@ -2,7 +2,7 @@
-

+

+ + +
+

{{ agentName }}

@@ -24,43 +37,75 @@ - + diff --git a/app/javascript/widget/components/ChatAttachment.vue b/app/javascript/widget/components/ChatAttachment.vue new file mode 100755 index 000000000..f17344afa --- /dev/null +++ b/app/javascript/widget/components/ChatAttachment.vue @@ -0,0 +1,69 @@ + + + + diff --git a/app/javascript/widget/components/ChatFooter.vue b/app/javascript/widget/components/ChatFooter.vue index 01dbb16ac..dbfbb5633 100755 --- a/app/javascript/widget/components/ChatFooter.vue +++ b/app/javascript/widget/components/ChatFooter.vue @@ -1,10 +1,14 @@ @@ -47,12 +56,19 @@ export default { padding: $space-larger $space-medium $space-large; width: 100%; box-sizing: border-box; + position: relative; .logo { width: 64px; height: 64px; } + .close { + position: absolute; + right: $space-medium; + top: $space-medium; + display: none; + } .title { color: $color-heading; font-size: $font-size-mega; diff --git a/app/javascript/widget/components/ChatInputWrap.vue b/app/javascript/widget/components/ChatInputWrap.vue index 5a4cdd369..82e68ac94 100755 --- a/app/javascript/widget/components/ChatInputWrap.vue +++ b/app/javascript/widget/components/ChatInputWrap.vue @@ -1,5 +1,6 @@ @@ -79,9 +70,10 @@ export default { flex-shrink: 0; border-radius: $space-normal; background: white; + z-index: 99; @include shadow-large; - @media only screen and (min-device-width: 320px) and (max-device-width: 480px) { + @media only screen and (min-device-width: 320px) and (max-device-width: 667px) { border-radius: 0; } } diff --git a/app/jobs/agent_bot_job.rb b/app/jobs/agent_bot_job.rb new file mode 100644 index 000000000..e988701c4 --- /dev/null +++ b/app/jobs/agent_bot_job.rb @@ -0,0 +1,3 @@ +class AgentBotJob < WebhookJob + queue_as :bots +end diff --git a/app/jobs/contact_avatar_job.rb b/app/jobs/contact_avatar_job.rb new file mode 100644 index 000000000..a99daca3e --- /dev/null +++ b/app/jobs/contact_avatar_job.rb @@ -0,0 +1,8 @@ +class ContactAvatarJob < ApplicationJob + queue_as :default + + def perform(contact, avatar_url) + avatar_resource = LocalResource.new(avatar_url) + contact.avatar.attach(io: avatar_resource.file, filename: avatar_resource.tmp_filename, content_type: avatar_resource.encoding) + end +end diff --git a/app/jobs/event_dispatcher_job.rb b/app/jobs/event_dispatcher_job.rb new file mode 100644 index 000000000..c08d52d74 --- /dev/null +++ b/app/jobs/event_dispatcher_job.rb @@ -0,0 +1,7 @@ +class EventDispatcherJob < ApplicationJob + queue_as :events + + def perform(event_name, timestamp, data) + Rails.configuration.dispatcher.async_dispatcher.publish_event(event_name, timestamp, data) + end +end diff --git a/app/listeners/agent_bot_listener.rb b/app/listeners/agent_bot_listener.rb new file mode 100644 index 000000000..848c09cb7 --- /dev/null +++ b/app/listeners/agent_bot_listener.rb @@ -0,0 +1,13 @@ +class AgentBotListener < BaseListener + def message_created(event) + message = extract_message_and_account(event)[0] + inbox = message.inbox + return unless message.reportable? && inbox.agent_bot_inbox.present? + return unless inbox.agent_bot_inbox.active? + + agent_bot = inbox.agent_bot_inbox.agent_bot + + payload = message.webhook_data.merge(event: __method__.to_s) + AgentBotJob.perform_later(agent_bot.outgoing_url, payload) + end +end diff --git a/app/listeners/email_notification_listener.rb b/app/listeners/email_notification_listener.rb index c9dfcfb09..a928960b2 100644 --- a/app/listeners/email_notification_listener.rb +++ b/app/listeners/email_notification_listener.rb @@ -1,6 +1,8 @@ class EmailNotificationListener < BaseListener def conversation_created(event) conversation, _account, _timestamp = extract_conversation_and_account(event) + return if conversation.bot? + conversation.inbox.members.each do |agent| next unless agent.notification_settings.find_by(account_id: conversation.account_id).conversation_creation? diff --git a/app/listeners/event_listener.rb b/app/listeners/event_listener.rb new file mode 100644 index 000000000..fd072e81d --- /dev/null +++ b/app/listeners/event_listener.rb @@ -0,0 +1,31 @@ +class EventListener < BaseListener + def conversation_resolved(event) + conversation = extract_conversation_and_account(event)[0] + time_to_resolve = conversation.updated_at.to_i - conversation.created_at.to_i + + event = Event.new( + name: 'conversation_resolved', + value: time_to_resolve, + account_id: conversation.account_id, + inbox_id: conversation.inbox_id, + user_id: conversation.assignee_id, + conversation_id: conversation.id + ) + event.save + end + + def first_reply_created(event) + message = extract_message_and_account(event)[0] + conversation = message.conversation + first_response_time = message.created_at.to_i - conversation.created_at.to_i + + event = Event.new( + name: 'first_response', + value: first_response_time, + account_id: conversation.account_id, + inbox_id: conversation.inbox_id, + user_id: conversation.assignee_id + ) + event.save + end +end diff --git a/app/mailers/conversation_reply_mailer.rb b/app/mailers/conversation_reply_mailer.rb index 9fe65d231..ce8a91670 100644 --- a/app/mailers/conversation_reply_mailer.rb +++ b/app/mailers/conversation_reply_mailer.rb @@ -20,7 +20,8 @@ class ConversationReplyMailer < ApplicationMailer private - def mail_subject(last_message, trim_length = 30) - "[##{@conversation.display_id}] #{last_message.content.truncate(trim_length)}" + def mail_subject(last_message, trim_length = 50) + subject_line = last_message&.content&.truncate(trim_length) || 'New messages on this conversation' + "[##{@conversation.display_id}] #{subject_line}" end end diff --git a/app/models/access_token.rb b/app/models/access_token.rb new file mode 100644 index 000000000..3e98498b0 --- /dev/null +++ b/app/models/access_token.rb @@ -0,0 +1,21 @@ +# == Schema Information +# +# Table name: access_tokens +# +# id :bigint not null, primary key +# owner_type :string +# token :string +# created_at :datetime not null +# updated_at :datetime not null +# owner_id :bigint +# +# Indexes +# +# index_access_tokens_on_owner_type_and_owner_id (owner_type,owner_id) +# index_access_tokens_on_token (token) UNIQUE +# + +class AccessToken < ApplicationRecord + has_secure_token :token + belongs_to :owner, polymorphic: true +end diff --git a/app/models/account.rb b/app/models/account.rb index 77b102847..883ed28aa 100644 --- a/app/models/account.rb +++ b/app/models/account.rb @@ -3,6 +3,7 @@ # Table name: accounts # # id :integer not null, primary key +# locale :integer default("eng") # name :string not null # created_at :datetime not null # updated_at :datetime not null @@ -10,22 +11,29 @@ class Account < ApplicationRecord include Events::Types + include Reportable validates :name, presence: true - has_many :users, dependent: :destroy + has_many :account_users, dependent: :destroy + has_many :agent_bot_inboxes, dependent: :destroy + has_many :users, through: :account_users has_many :inboxes, dependent: :destroy has_many :conversations, dependent: :destroy + has_many :messages, dependent: :destroy has_many :contacts, dependent: :destroy has_many :facebook_pages, dependent: :destroy, class_name: '::Channel::FacebookPage' + has_many :telegram_bots, dependent: :destroy + has_many :twilio_sms, dependent: :destroy, class_name: '::Channel::TwilioSms' has_many :twitter_profiles, dependent: :destroy, class_name: '::Channel::TwitterProfile' has_many :web_widgets, dependent: :destroy, class_name: '::Channel::WebWidget' - has_many :telegram_bots, dependent: :destroy has_many :canned_responses, dependent: :destroy has_many :webhooks, dependent: :destroy has_one :subscription, dependent: :destroy has_many :notification_settings, dependent: :destroy + enum locale: LANGUAGES_CONFIG.map { |key, val| [val[:iso_639_1_code], key] }.to_h + after_create :create_subscription after_create :notify_creation after_destroy :notify_deletion diff --git a/app/models/account_user.rb b/app/models/account_user.rb new file mode 100644 index 000000000..e86f1d3e2 --- /dev/null +++ b/app/models/account_user.rb @@ -0,0 +1,48 @@ +# == Schema Information +# +# Table name: account_users +# +# id :bigint not null, primary key +# role :integer default("agent") +# created_at :datetime not null +# updated_at :datetime not null +# account_id :bigint +# inviter_id :bigint +# user_id :bigint +# +# Indexes +# +# index_account_users_on_account_id (account_id) +# index_account_users_on_user_id (user_id) +# uniq_user_id_per_account_id (account_id,user_id) UNIQUE +# +# Foreign Keys +# +# fk_rails_... (account_id => accounts.id) +# fk_rails_... (user_id => users.id) +# + +class AccountUser < ApplicationRecord + belongs_to :account + belongs_to :user + belongs_to :inviter, class_name: 'User', optional: true + + enum role: { agent: 0, administrator: 1 } + accepts_nested_attributes_for :account + + after_create :create_notification_setting + after_destroy :destroy_notification_setting + + validates :user_id, uniqueness: { scope: :account_id } + + def create_notification_setting + setting = user.notification_settings.new(account_id: account.id) + setting.selected_email_flags = [:conversation_assignment] + setting.save! + end + + def destroy_notification_setting + setting = user.notification_settings.new(account_id: account.id) + setting.destroy! + end +end diff --git a/app/models/agent_bot.rb b/app/models/agent_bot.rb new file mode 100644 index 000000000..38b128d8d --- /dev/null +++ b/app/models/agent_bot.rb @@ -0,0 +1,19 @@ +# == Schema Information +# +# Table name: agent_bots +# +# id :bigint not null, primary key +# description :string +# name :string +# outgoing_url :string +# created_at :datetime not null +# updated_at :datetime not null +# + +class AgentBot < ApplicationRecord + include AccessTokenable + include Avatarable + + has_many :agent_bot_inboxes, dependent: :destroy + has_many :inboxes, through: :agent_bot_inboxes +end diff --git a/app/models/agent_bot_inbox.rb b/app/models/agent_bot_inbox.rb new file mode 100644 index 000000000..ffcf483b4 --- /dev/null +++ b/app/models/agent_bot_inbox.rb @@ -0,0 +1,29 @@ +# == Schema Information +# +# Table name: agent_bot_inboxes +# +# id :bigint not null, primary key +# status :integer default("active") +# created_at :datetime not null +# updated_at :datetime not null +# account_id :integer +# agent_bot_id :integer +# inbox_id :integer +# + +class AgentBotInbox < ApplicationRecord + validates :inbox_id, presence: true + validates :agent_bot_id, presence: true + before_validation :ensure_account_id + + belongs_to :inbox + belongs_to :agent_bot + belongs_to :account + enum status: { active: 0, inactive: 1 } + + private + + def ensure_account_id + self.account_id = inbox&.account_id + end +end diff --git a/app/models/attachment.rb b/app/models/attachment.rb index 1c7ac75b6..697de2767 100644 --- a/app/models/attachment.rb +++ b/app/models/attachment.rb @@ -15,8 +15,6 @@ # message_id :integer not null # -require 'uri' -require 'open-uri' class Attachment < ApplicationRecord include Rails.application.routes.url_helpers belongs_to :account @@ -32,13 +30,25 @@ class Attachment < ApplicationRecord base_data.merge(file_metadata) end + def file_url + file.attached? ? url_for(file) : '' + end + + def thumb_url + if file.attached? && file.representable? + url_for(file.representation(resize: '250x250')) + else + '' + end + end + private def file_metadata { extension: extension, data_url: file_url, - thumb_url: file.try(:thumb).try(:url) # will exist only for images + thumb_url: thumb_url } end @@ -66,8 +76,4 @@ class Attachment < ApplicationRecord account_id: account_id } end - - def file_url - file.attached? ? url_for(file) : '' - end end diff --git a/app/models/channel/twilio_sms.rb b/app/models/channel/twilio_sms.rb new file mode 100644 index 000000000..305ba576c --- /dev/null +++ b/app/models/channel/twilio_sms.rb @@ -0,0 +1,33 @@ +# == Schema Information +# +# Table name: channel_twilio_sms +# +# id :bigint not null, primary key +# account_sid :string not null +# auth_token :string not null +# phone_number :string not null +# created_at :datetime not null +# updated_at :datetime not null +# account_id :integer not null +# +# Indexes +# +# index_channel_twilio_sms_on_account_id_and_phone_number (account_id,phone_number) UNIQUE +# + +class Channel::TwilioSms < ApplicationRecord + self.table_name = 'channel_twilio_sms' + + validates :account_id, presence: true + validates :account_sid, presence: true + validates :auth_token, presence: true + validates :phone_number, uniqueness: { scope: :account_id }, presence: true + + belongs_to :account + + has_one :inbox, as: :channel, dependent: :destroy + + def name + 'Twilio SMS' + end +end diff --git a/app/models/channel/twitter_profile.rb b/app/models/channel/twitter_profile.rb index a8427c55e..ec71f6436 100644 --- a/app/models/channel/twitter_profile.rb +++ b/app/models/channel/twitter_profile.rb @@ -11,6 +11,10 @@ # account_id :integer not null # profile_id :string not null # +# Indexes +# +# index_channel_twitter_profiles_on_account_id_and_profile_id (account_id,profile_id) UNIQUE +# class Channel::TwitterProfile < ApplicationRecord self.table_name = 'channel_twitter_profiles' diff --git a/app/models/channel/web_widget.rb b/app/models/channel/web_widget.rb index 97b16eef2..68fe67ae8 100644 --- a/app/models/channel/web_widget.rb +++ b/app/models/channel/web_widget.rb @@ -31,6 +31,23 @@ class Channel::WebWidget < ApplicationRecord 'Website' end + def web_widget_script + "" + end + def create_contact_inbox ActiveRecord::Base.transaction do contact = inbox.account.contacts.create!(name: ::Haikunator.haikunate(1000)) diff --git a/app/models/concerns/access_tokenable.rb b/app/models/concerns/access_tokenable.rb new file mode 100644 index 000000000..ae9aad2e5 --- /dev/null +++ b/app/models/concerns/access_tokenable.rb @@ -0,0 +1,11 @@ +module AccessTokenable + extend ActiveSupport::Concern + included do + has_one :access_token, as: :owner, dependent: :destroy + after_create :create_access_token + end + + def create_access_token + AccessToken.create!(owner: self) + end +end diff --git a/app/models/concerns/reportable.rb b/app/models/concerns/reportable.rb new file mode 100644 index 000000000..3f230650f --- /dev/null +++ b/app/models/concerns/reportable.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +module Reportable + extend ActiveSupport::Concern + + included do + has_many :events, dependent: :destroy + end +end diff --git a/app/models/contact.rb b/app/models/contact.rb index 9e07fc8fe..9687bcda5 100644 --- a/app/models/contact.rb +++ b/app/models/contact.rb @@ -5,6 +5,7 @@ # id :integer not null, primary key # additional_attributes :jsonb # email :string +# identifier :string # name :string # phone_number :string # pubsub_token :string @@ -14,8 +15,10 @@ # # Indexes # -# index_contacts_on_account_id (account_id) -# index_contacts_on_pubsub_token (pubsub_token) UNIQUE +# index_contacts_on_account_id (account_id) +# index_contacts_on_pubsub_token (pubsub_token) UNIQUE +# uniq_email_per_account_contact (email,account_id) UNIQUE +# uniq_identifier_per_account_contact (identifier,account_id) UNIQUE # class Contact < ApplicationRecord @@ -23,6 +26,8 @@ class Contact < ApplicationRecord include Avatarable include AvailabilityStatusable validates :account_id, presence: true + validates :email, allow_blank: true, uniqueness: { scope: [:account_id], case_sensitive: false } + validates :identifier, allow_blank: true, uniqueness: { scope: [:account_id] } belongs_to :account has_many :conversations, dependent: :destroy @@ -30,6 +35,8 @@ class Contact < ApplicationRecord has_many :inboxes, through: :contact_inboxes has_many :messages, dependent: :destroy + before_validation :downcase_email + def get_source_id(inbox_id) contact_inboxes.find_by!(inbox_id: inbox_id).source_id end @@ -49,4 +56,8 @@ class Contact < ApplicationRecord name: name } end + + def downcase_email + email.downcase! if email.present? + end end diff --git a/app/models/conversation.rb b/app/models/conversation.rb index 51f53039a..75ac69f5c 100644 --- a/app/models/conversation.rb +++ b/app/models/conversation.rb @@ -34,9 +34,9 @@ class Conversation < ApplicationRecord validates :account_id, presence: true validates :inbox_id, presence: true - enum status: [:open, :resolved] + enum status: { open: 0, resolved: 1, bot: 2 } - scope :latest, -> { order(created_at: :desc) } + scope :latest, -> { order(updated_at: :desc) } scope :unassigned, -> { where(assignee_id: nil) } scope :assigned_to, ->(agent) { where(assignee_id: agent.id) } @@ -50,6 +50,8 @@ class Conversation < ApplicationRecord before_create :set_display_id, unless: :display_id? + before_create :set_bot_conversation + after_update :notify_status_change, :create_activity, :send_email_notification_to_assignee after_create :send_events, :run_round_robin @@ -65,7 +67,9 @@ class Conversation < ApplicationRecord end def toggle_status + # FIXME: implement state machine with aasm self.status = open? ? :resolved : :open + self.status = :open if bot? save end @@ -102,6 +106,10 @@ class Conversation < ApplicationRecord private + def set_bot_conversation + self.status = :bot if inbox.agent_bot_inbox&.active? + end + def dispatch_events dispatcher_dispatch(CONVERSATION_RESOLVED) end @@ -110,11 +118,18 @@ class Conversation < ApplicationRecord dispatcher_dispatch(CONVERSATION_CREATED) end + def notifiable_assignee_change? + return false if self_assign?(assignee_id) + return false unless saved_change_to_assignee_id? + return false if assignee_id.blank? + + true + end + def send_email_notification_to_assignee - return if self_assign?(assignee_id) - return unless saved_change_to_assignee_id? - return if assignee_id.blank? + return unless notifiable_assignee_change? return if assignee.notification_settings.find_by(account_id: account_id).not_conversation_assignment? + return if bot? AgentNotifications::ConversationNotificationsMailer.conversation_assigned(self, assignee).deliver_later end @@ -161,6 +176,7 @@ class Conversation < ApplicationRecord def run_round_robin return unless inbox.enable_auto_assignment return if assignee + return if bot? inbox.next_available_agent.then { |new_assignee| update_assignee(new_assignee) } end diff --git a/app/models/event.rb b/app/models/event.rb new file mode 100644 index 000000000..7cb49c576 --- /dev/null +++ b/app/models/event.rb @@ -0,0 +1,33 @@ +# == Schema Information +# +# Table name: events +# +# id :bigint not null, primary key +# name :string +# value :float +# created_at :datetime not null +# updated_at :datetime not null +# account_id :integer +# conversation_id :integer +# inbox_id :integer +# user_id :integer +# +# Indexes +# +# index_events_on_account_id (account_id) +# index_events_on_created_at (created_at) +# index_events_on_inbox_id (inbox_id) +# index_events_on_name (name) +# index_events_on_user_id (user_id) +# + +class Event < ApplicationRecord + validates :account_id, presence: true + validates :name, presence: true + validates :value, presence: true + + belongs_to :account + belongs_to :user, optional: true + belongs_to :inbox, optional: true + belongs_to :conversation, optional: true +end diff --git a/app/models/inbox.rb b/app/models/inbox.rb index 3420cf3f0..867559ddc 100644 --- a/app/models/inbox.rb +++ b/app/models/inbox.rb @@ -19,6 +19,8 @@ # class Inbox < ApplicationRecord + include Reportable + validates :account_id, presence: true belongs_to :account @@ -33,7 +35,10 @@ class Inbox < ApplicationRecord has_many :members, through: :inbox_members, source: :user has_many :conversations, dependent: :destroy has_many :messages, through: :conversations + + has_one :agent_bot_inbox, dependent: :destroy has_many :webhooks, dependent: :destroy + after_create :subscribe_webhook, if: :facebook? after_destroy :delete_round_robin_agents diff --git a/app/models/message.rb b/app/models/message.rb index 7d7b8acbc..1e434ffa2 100644 --- a/app/models/message.rb +++ b/app/models/message.rb @@ -20,9 +20,12 @@ # # Indexes # +# index_messages_on_account_id (account_id) # index_messages_on_contact_id (contact_id) # index_messages_on_conversation_id (conversation_id) +# index_messages_on_inbox_id (inbox_id) # index_messages_on_source_id (source_id) +# index_messages_on_user_id (user_id) # # Foreign Keys # @@ -48,7 +51,7 @@ class Message < ApplicationRecord belongs_to :account belongs_to :inbox - belongs_to :conversation + belongs_to :conversation, touch: true belongs_to :user, required: false belongs_to :contact, required: false @@ -110,6 +113,8 @@ class Message < ApplicationRecord ::Facebook::SendReplyService.new(message: self).perform elsif channel_name == 'Channel::TwitterProfile' ::Twitter::SendReplyService.new(message: self).perform + elsif channel_name == 'Channel::TwilioSms' + ::Twilio::OutgoingMessageService.new(message: self).perform end end diff --git a/app/models/user.rb b/app/models/user.rb index 2aa47a011..7779c9ba6 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -19,37 +19,31 @@ # remember_created_at :datetime # reset_password_sent_at :datetime # reset_password_token :string -# role :integer default("agent") # sign_in_count :integer default(0), not null # tokens :json # uid :string default(""), not null # unconfirmed_email :string # created_at :datetime not null # updated_at :datetime not null -# account_id :integer not null -# inviter_id :bigint # # Indexes # # index_users_on_email (email) -# index_users_on_inviter_id (inviter_id) # index_users_on_pubsub_token (pubsub_token) UNIQUE # index_users_on_reset_password_token (reset_password_token) UNIQUE # index_users_on_uid_and_provider (uid,provider) UNIQUE # -# Foreign Keys -# -# fk_rails_... (inviter_id => users.id) ON DELETE => nullify -# class User < ApplicationRecord + include AccessTokenable + include AvailabilityStatusable + include Avatarable # Include default devise modules. include DeviseTokenAuth::Concerns::User include Events::Types include Pubsubable - include Avatarable - include AvailabilityStatusable include Rails.application.routes.url_helpers + include Reportable devise :database_authenticatable, :registerable, @@ -62,25 +56,23 @@ class User < ApplicationRecord # The validation below has been commented out as it does not # work because :validatable in devise overrides this. # validates_uniqueness_of :email, scope: :account_id - validates :email, :name, :account_id, presence: true + validates :email, :name, presence: true - enum role: [:agent, :administrator] - - belongs_to :account - belongs_to :inviter, class_name: 'User', required: false - has_many :invitees, class_name: 'User', foreign_key: 'inviter_id', dependent: :nullify + has_many :account_users, dependent: :destroy + has_many :accounts, through: :account_users + accepts_nested_attributes_for :account_users has_many :assigned_conversations, foreign_key: 'assignee_id', class_name: 'Conversation', dependent: :nullify has_many :inbox_members, dependent: :destroy has_many :assigned_inboxes, through: :inbox_members, source: :inbox has_many :messages + has_many :invitees, through: :account_users, class_name: 'User', foreign_key: 'inviter_id', dependent: :nullify has_many :notification_settings, dependent: :destroy before_validation :set_password_and_uid, on: :create - accepts_nested_attributes_for :account + after_create :notify_creation, :create_access_token - after_create :notify_creation, :create_notification_setting after_destroy :notify_deletion def send_devise_notification(notification, *args) @@ -91,6 +83,32 @@ class User < ApplicationRecord self.uid = email end + def account_user + # FIXME : temporary hack to transition over to multiple accounts per user + # We should be fetching the current account user relationship here. + account_users&.first + end + + def account + account_user&.account + end + + def administrator? + account_user&.administrator? + end + + def agent? + account_user&.agent? + end + + def role + account_user&.role + end + + def inviter + account_user&.inviter + end + def serializable_hash(options = nil) serialized_user = super(options).merge(confirmed: confirmed?) serialized_user.merge(subscription: account.try(:subscription).try(:summary)) if ENV['BILLING_ENABLED'] @@ -102,7 +120,7 @@ class User < ApplicationRecord end def create_notification_setting - setting = notification_settings.new(account_id: account_id) + setting = notification_settings.new(account_id: account.id) setting.selected_email_flags = [:conversation_assignment] setting.save! end diff --git a/app/models/webhook.rb b/app/models/webhook.rb index 54baa022a..2895978f9 100644 --- a/app/models/webhook.rb +++ b/app/models/webhook.rb @@ -10,6 +10,10 @@ # account_id :integer # inbox_id :integer # +# Indexes +# +# index_webhooks_on_account_id_and_url (account_id,url) UNIQUE +# class Webhook < ApplicationRecord belongs_to :account diff --git a/app/policies/account_policy.rb b/app/policies/account_policy.rb new file mode 100644 index 000000000..9577a3247 --- /dev/null +++ b/app/policies/account_policy.rb @@ -0,0 +1,13 @@ +class AccountPolicy < ApplicationPolicy + def show? + # FIXME : temporary hack to transition over to multiple accounts per user + # We should be fetching the current account user relationship here. + @user.administrator? + end + + def update? + # FIXME : temporary hack to transition over to multiple accounts per user + # We should be fetching the current account user relationship here. + @user.administrator? + end +end diff --git a/app/services/facebook/send_reply_service.rb b/app/services/facebook/send_reply_service.rb index ab535fd0a..87e07fe8c 100644 --- a/app/services/facebook/send_reply_service.rb +++ b/app/services/facebook/send_reply_service.rb @@ -32,13 +32,35 @@ class Facebook::SendReplyService # end # end - def fb_message_params + def fb_text_message_params { recipient: { id: contact.get_source_id(inbox.id) }, message: { text: message.content } } end + def fb_attachment_message_params + { + recipient: { id: contact.get_source_id(inbox.id) }, + message: { + attachment: { + type: 'image', + payload: { + url: message.attachment.file_url + } + } + } + } + end + + def fb_message_params + if message.attachment.blank? + fb_text_message_params + else + fb_attachment_message_params + end + end + def delivery_params if twenty_four_hour_window_over? fb_message_params.merge(tag: 'ISSUE_RESOLUTION') @@ -48,19 +70,26 @@ class Facebook::SendReplyService end def twenty_four_hour_window_over? - last_incoming_message = conversation.messages.incoming.last - - is_after_24_hours = (Time.current - last_incoming_message.created_at) / 3600 >= 24 - - return false unless is_after_24_hours - - return false if last_incoming_message && sent_first_outgoing_message_after_24_hours?(last_incoming_message.id) + return false unless after_24_hours? + return false if last_incoming_and_outgoing_message_after_one_day? true end - def sent_first_outgoing_message_after_24_hours?(last_incoming_message_id) + def last_incoming_and_outgoing_message_after_one_day? + last_incoming_message && sent_first_outgoing_message_after_24_hours? + end + + def after_24_hours? + (Time.current - last_incoming_message.created_at) / 3600 >= 24 + end + + def sent_first_outgoing_message_after_24_hours? # we can send max 1 message after 24 hour window - conversation.messages.outgoing.where('id > ?', last_incoming_message_id).count == 1 + conversation.messages.outgoing.where('id > ?', last_incoming_message.id).count == 1 + end + + def last_incoming_message + conversation.messages.incoming.last end end diff --git a/app/services/message_templates/hook_execution_service.rb b/app/services/message_templates/hook_execution_service.rb index 071e12a64..c626c4671 100644 --- a/app/services/message_templates/hook_execution_service.rb +++ b/app/services/message_templates/hook_execution_service.rb @@ -2,6 +2,8 @@ class MessageTemplates::HookExecutionService pattr_initialize [:message!] def perform + return if inbox.agent_bot_inbox&.active? + ::MessageTemplates::Template::EmailCollect.new(conversation: conversation).perform if should_send_email_collect? end @@ -15,6 +17,10 @@ class MessageTemplates::HookExecutionService end def should_send_email_collect? - conversation.inbox.web_widget? && first_message_from_contact? + !contact_has_email? && conversation.inbox.web_widget? && first_message_from_contact? + end + + def contact_has_email? + contact.email end end diff --git a/app/services/twilio/incoming_message_service.rb b/app/services/twilio/incoming_message_service.rb new file mode 100644 index 000000000..5d3ee0d26 --- /dev/null +++ b/app/services/twilio/incoming_message_service.rb @@ -0,0 +1,77 @@ +class Twilio::IncomingMessageService + pattr_initialize [:params!] + + def perform + set_contact + set_conversation + @conversation.messages.create( + content: params[:Body], + account_id: @inbox.account_id, + inbox_id: @inbox.id, + message_type: :incoming, + contact_id: @contact.id, + source_id: params[:SmsSid] + ) + end + + private + + def twilio_inbox + @twilio_inbox ||= ::Channel::TwilioSms.find_by!( + account_sid: params[:AccountSid], + phone_number: params[:To] + ) + end + + def inbox + @inbox ||= twilio_inbox.inbox + end + + def account + @account ||= inbox.account + end + + def set_contact + contact_inbox = ::ContactBuilder.new( + source_id: params[:From], + inbox: inbox, + contact_attributes: contact_attributes + ).perform + + @contact_inbox = contact_inbox + @contact = contact_inbox.contact + end + + def conversation_params + { + account_id: @inbox.account_id, + inbox_id: @inbox.id, + contact_id: @contact.id, + contact_inbox_id: @contact_inbox.id, + additional_attributes: additional_attributes + } + end + + def set_conversation + @conversation = @contact_inbox.conversations.first + return if @conversation + + @conversation = ::Conversation.create!(conversation_params) + end + + def contact_attributes + { + name: params[:From], + phone_number: params[:From], + contact_attributes: additional_attributes + } + end + + def additional_attributes + { + from_zip_code: params[:FromZip], + from_country: params[:FromCountry], + from_state: params[:FromState] + } + end +end diff --git a/app/services/twilio/outgoing_message_service.rb b/app/services/twilio/outgoing_message_service.rb new file mode 100644 index 000000000..946fef1fa --- /dev/null +++ b/app/services/twilio/outgoing_message_service.rb @@ -0,0 +1,34 @@ +class Twilio::OutgoingMessageService + pattr_initialize [:message!] + + def perform + return if message.private + return if message.source_id + return if inbox.channel.class.to_s != 'Channel::TwilioSms' + return unless message.outgoing? + + twilio_message = client.messages.create( + body: message.content, + from: channel.phone_number, + to: contact.phone_number + ) + message.update!(source_id: twilio_message.sid) + end + + private + + delegate :conversation, to: :message + delegate :contact, to: :conversation + + def inbox + @inbox ||= message.inbox + end + + def channel + @channel ||= inbox.channel + end + + def client + ::Twilio::REST::Client.new(channel.account_sid, channel.auth_token) + end +end diff --git a/app/services/twilio/webhook_setup_service.rb b/app/services/twilio/webhook_setup_service.rb new file mode 100644 index 000000000..21ac1750b --- /dev/null +++ b/app/services/twilio/webhook_setup_service.rb @@ -0,0 +1,35 @@ +class Twilio::WebhookSetupService + include Rails.application.routes.url_helpers + + pattr_initialize [:inbox!] + + def perform + if phone_numbers.empty? + Rails.logger.info "TWILIO_PHONE_NUMBER_NOT_FOUND: #{channel.phone_number}" + else + twilio_client + .incoming_phone_numbers(phonenumber_sid) + .update(sms_method: 'POST', sms_url: twilio_callback_index_url) + end + rescue Twilio::REST::TwilioError => e + Rails.logger.info "TWILIO_FAILURE: #{e.message}" + end + + private + + def phonenumber_sid + phone_numbers.first.sid + end + + def phone_numbers + @phone_numbers ||= twilio_client.incoming_phone_numbers.list(phone_number: channel.phone_number) + end + + def channel + @channel ||= inbox.channel + end + + def twilio_client + @twilio_client ||= ::Twilio::REST::Client.new(channel.account_sid, channel.auth_token) + end +end diff --git a/app/services/twitter/webhook_subscribe_service.rb b/app/services/twitter/webhook_subscribe_service.rb index 11e7af86f..0c2badaff 100644 --- a/app/services/twitter/webhook_subscribe_service.rb +++ b/app/services/twitter/webhook_subscribe_service.rb @@ -4,7 +4,7 @@ class Twitter::WebhookSubscribeService pattr_initialize [:inbox_id] def perform - register_response = twitter_client.register_webhook(url: webhooks_twitter_url) + register_response = twitter_client.register_webhook(url: webhooks_twitter_url(protocol: 'https')) twitter_client.subscribe_webhook if register_response.status == '200' Rails.logger.info 'TWITTER_REGISTER_WEBHOOK_FAILURE: ' + register_response.body.to_s end diff --git a/app/services/twitter/webhooks_base_service.rb b/app/services/twitter/webhooks_base_service.rb index 2f3d09290..ea0abadf8 100644 --- a/app/services/twitter/webhooks_base_service.rb +++ b/app/services/twitter/webhooks_base_service.rb @@ -30,11 +30,6 @@ class Twitter::WebhooksBaseService user['id'], user['name'], additional_contact_attributes(user) ) @contact = @contact_inbox.contact - avatar_resource = LocalResource.new(user['profile_image_url']) - @contact.avatar.attach( - io: avatar_resource.file, - filename: avatar_resource.tmp_filename, - content_type: avatar_resource.encoding - ) + ContactAvatarJob.perform_later(@contact, user['profile_image_url']) if user['profile_image_url'] end end diff --git a/app/views/api/v1/account/index.json.jbuilder b/app/views/api/v1/account/index.json.jbuilder deleted file mode 100644 index b2883ad2e..000000000 --- a/app/views/api/v1/account/index.json.jbuilder +++ /dev/null @@ -1,10 +0,0 @@ -json.array! @agents do |agent| - json.account_id agent.account_id - json.availability_status agent.availability_status - json.confirmed agent.confirmed? - json.email agent.email - json.id agent.id - json.name agent.name - json.role agent.role - json.thumbnail agent.avatar_url -end diff --git a/app/views/api/v1/agents/index.json.jbuilder b/app/views/api/v1/accounts/agents/index.json.jbuilder similarity index 87% rename from app/views/api/v1/agents/index.json.jbuilder rename to app/views/api/v1/accounts/agents/index.json.jbuilder index b2883ad2e..0b6a01f1c 100644 --- a/app/views/api/v1/agents/index.json.jbuilder +++ b/app/views/api/v1/accounts/agents/index.json.jbuilder @@ -1,5 +1,5 @@ json.array! @agents do |agent| - json.account_id agent.account_id + json.account_id agent.account.id json.availability_status agent.availability_status json.confirmed agent.confirmed? json.email agent.email diff --git a/app/views/api/v1/callbacks/get_facebook_pages.json.jbuilder b/app/views/api/v1/accounts/callbacks/facebook_pages.json.jbuilder similarity index 100% rename from app/views/api/v1/callbacks/get_facebook_pages.json.jbuilder rename to app/views/api/v1/accounts/callbacks/facebook_pages.json.jbuilder diff --git a/app/views/api/v1/accounts/channels/twilio_channels/create.json.jbuilder b/app/views/api/v1/accounts/channels/twilio_channels/create.json.jbuilder new file mode 100644 index 000000000..8b27a80a7 --- /dev/null +++ b/app/views/api/v1/accounts/channels/twilio_channels/create.json.jbuilder @@ -0,0 +1,6 @@ +json.id @inbox.id +json.channel_id @inbox.channel_id +json.name @inbox.name +json.channel_type @inbox.channel_type +json.enable_auto_assignment @inbox.enable_auto_assignment +json.phone_number @inbox.channel.phone_number diff --git a/app/views/api/v1/contacts/conversations/index.json.jbuilder b/app/views/api/v1/accounts/contacts/conversations/index.json.jbuilder similarity index 100% rename from app/views/api/v1/contacts/conversations/index.json.jbuilder rename to app/views/api/v1/accounts/contacts/conversations/index.json.jbuilder diff --git a/app/views/api/v1/contacts/index.json.jbuilder b/app/views/api/v1/accounts/contacts/index.json.jbuilder similarity index 100% rename from app/views/api/v1/contacts/index.json.jbuilder rename to app/views/api/v1/accounts/contacts/index.json.jbuilder diff --git a/app/views/api/v1/contacts/show.json.jbuilder b/app/views/api/v1/accounts/contacts/show.json.jbuilder similarity index 100% rename from app/views/api/v1/contacts/show.json.jbuilder rename to app/views/api/v1/accounts/contacts/show.json.jbuilder diff --git a/app/views/api/v1/contacts/update.json.jbuilder b/app/views/api/v1/accounts/contacts/update.json.jbuilder similarity index 100% rename from app/views/api/v1/contacts/update.json.jbuilder rename to app/views/api/v1/accounts/contacts/update.json.jbuilder diff --git a/app/views/api/v1/conversations/assignments/create.json.jbuilder b/app/views/api/v1/accounts/conversations/assignments/create.json.jbuilder similarity index 100% rename from app/views/api/v1/conversations/assignments/create.json.jbuilder rename to app/views/api/v1/accounts/conversations/assignments/create.json.jbuilder diff --git a/app/views/api/v1/conversations/index.json.jbuilder b/app/views/api/v1/accounts/conversations/index.json.jbuilder similarity index 100% rename from app/views/api/v1/conversations/index.json.jbuilder rename to app/views/api/v1/accounts/conversations/index.json.jbuilder diff --git a/app/views/api/v1/conversations/labels/create.json.jbuilder b/app/views/api/v1/accounts/conversations/labels/create.json.jbuilder similarity index 100% rename from app/views/api/v1/conversations/labels/create.json.jbuilder rename to app/views/api/v1/accounts/conversations/labels/create.json.jbuilder diff --git a/app/views/api/v1/conversations/labels/index.json.jbuilder b/app/views/api/v1/accounts/conversations/labels/index.json.jbuilder similarity index 100% rename from app/views/api/v1/conversations/labels/index.json.jbuilder rename to app/views/api/v1/accounts/conversations/labels/index.json.jbuilder diff --git a/app/views/api/v1/conversations/messages/create.json.jbuilder b/app/views/api/v1/accounts/conversations/messages/create.json.jbuilder similarity index 100% rename from app/views/api/v1/conversations/messages/create.json.jbuilder rename to app/views/api/v1/accounts/conversations/messages/create.json.jbuilder diff --git a/app/views/api/v1/conversations/messages/index.json.jbuilder b/app/views/api/v1/accounts/conversations/messages/index.json.jbuilder similarity index 100% rename from app/views/api/v1/conversations/messages/index.json.jbuilder rename to app/views/api/v1/accounts/conversations/messages/index.json.jbuilder diff --git a/app/views/api/v1/conversations/show.json.jbuilder b/app/views/api/v1/accounts/conversations/show.json.jbuilder similarity index 100% rename from app/views/api/v1/conversations/show.json.jbuilder rename to app/views/api/v1/accounts/conversations/show.json.jbuilder diff --git a/app/views/api/v1/conversations/toggle_status.json.jbuilder b/app/views/api/v1/accounts/conversations/toggle_status.json.jbuilder similarity index 100% rename from app/views/api/v1/conversations/toggle_status.json.jbuilder rename to app/views/api/v1/accounts/conversations/toggle_status.json.jbuilder diff --git a/app/views/api/v1/inbox_members/show.json.jbuilder b/app/views/api/v1/accounts/inbox_members/show.json.jbuilder similarity index 100% rename from app/views/api/v1/inbox_members/show.json.jbuilder rename to app/views/api/v1/accounts/inbox_members/show.json.jbuilder diff --git a/app/views/api/v1/inboxes/index.json.jbuilder b/app/views/api/v1/accounts/inboxes/index.json.jbuilder similarity index 79% rename from app/views/api/v1/inboxes/index.json.jbuilder rename to app/views/api/v1/accounts/inboxes/index.json.jbuilder index 3256f04cd..c6123cc89 100644 --- a/app/views/api/v1/inboxes/index.json.jbuilder +++ b/app/views/api/v1/accounts/inboxes/index.json.jbuilder @@ -9,5 +9,7 @@ json.payload do json.widget_color inbox.channel.try(:widget_color) json.website_token inbox.channel.try(:website_token) json.enable_auto_assignment inbox.enable_auto_assignment + json.web_widget_script inbox.channel.try(:web_widget_script) + json.phone_number inbox.channel.try(:phone_number) end end diff --git a/app/views/api/v1/labels/index.json.jbuilder b/app/views/api/v1/accounts/labels/index.json.jbuilder similarity index 100% rename from app/views/api/v1/labels/index.json.jbuilder rename to app/views/api/v1/accounts/labels/index.json.jbuilder diff --git a/app/views/api/v1/labels/most_used.json.jbuilder b/app/views/api/v1/accounts/labels/most_used.json.jbuilder similarity index 100% rename from app/views/api/v1/labels/most_used.json.jbuilder rename to app/views/api/v1/accounts/labels/most_used.json.jbuilder diff --git a/app/views/api/v1/user/notification_settings/show.json.jbuilder b/app/views/api/v1/accounts/notification_settings/show.json.jbuilder similarity index 100% rename from app/views/api/v1/user/notification_settings/show.json.jbuilder rename to app/views/api/v1/accounts/notification_settings/show.json.jbuilder diff --git a/app/views/api/v1/accounts/show.json.jbuilder b/app/views/api/v1/accounts/show.json.jbuilder new file mode 100644 index 000000000..9c063f912 --- /dev/null +++ b/app/views/api/v1/accounts/show.json.jbuilder @@ -0,0 +1,3 @@ +json.id @account.id +json.name @account.name +json.locale @account.locale diff --git a/app/views/api/v1/accounts/update.json.jbuilder b/app/views/api/v1/accounts/update.json.jbuilder new file mode 100644 index 000000000..9c063f912 --- /dev/null +++ b/app/views/api/v1/accounts/update.json.jbuilder @@ -0,0 +1,3 @@ +json.id @account.id +json.name @account.name +json.locale @account.locale diff --git a/app/views/api/v1/account/webhooks/_webhook.json.jbuilder b/app/views/api/v1/accounts/webhooks/_webhook.json.jbuilder similarity index 100% rename from app/views/api/v1/account/webhooks/_webhook.json.jbuilder rename to app/views/api/v1/accounts/webhooks/_webhook.json.jbuilder diff --git a/app/views/api/v1/account/webhooks/create.json.jbuilder b/app/views/api/v1/accounts/webhooks/create.json.jbuilder similarity index 100% rename from app/views/api/v1/account/webhooks/create.json.jbuilder rename to app/views/api/v1/accounts/webhooks/create.json.jbuilder diff --git a/app/views/api/v1/account/webhooks/index.json.jbuilder b/app/views/api/v1/accounts/webhooks/index.json.jbuilder similarity index 100% rename from app/views/api/v1/account/webhooks/index.json.jbuilder rename to app/views/api/v1/accounts/webhooks/index.json.jbuilder diff --git a/app/views/api/v1/account/webhooks/update.json.jbuilder b/app/views/api/v1/accounts/webhooks/update.json.jbuilder similarity index 100% rename from app/views/api/v1/account/webhooks/update.json.jbuilder rename to app/views/api/v1/accounts/webhooks/update.json.jbuilder diff --git a/app/views/api/v1/widget/inboxes/create.json.jbuilder b/app/views/api/v1/accounts/widget/inboxes/create.json.jbuilder similarity index 77% rename from app/views/api/v1/widget/inboxes/create.json.jbuilder rename to app/views/api/v1/accounts/widget/inboxes/create.json.jbuilder index ac444ba48..fc5260605 100644 --- a/app/views/api/v1/widget/inboxes/create.json.jbuilder +++ b/app/views/api/v1/accounts/widget/inboxes/create.json.jbuilder @@ -4,3 +4,4 @@ json.name @inbox.name json.channel_type @inbox.channel_type json.website_token @inbox.channel.try(:website_token) json.widget_color @inbox.channel.try(:widget_color) +json.web_widget_script @inbox.channel.try(:web_widget_script) diff --git a/app/views/api/v1/widget/inboxes/update.json.jbuilder b/app/views/api/v1/accounts/widget/inboxes/update.json.jbuilder similarity index 76% rename from app/views/api/v1/widget/inboxes/update.json.jbuilder rename to app/views/api/v1/accounts/widget/inboxes/update.json.jbuilder index e43655f60..3fb1bf4a2 100644 --- a/app/views/api/v1/widget/inboxes/update.json.jbuilder +++ b/app/views/api/v1/accounts/widget/inboxes/update.json.jbuilder @@ -4,3 +4,4 @@ json.name @inbox.name json.channel_type @inbox.channel_type json.website_token @inbox.channel.website_token json.widget_color @inbox.channel.widget_color +json.web_widget_script @inbox.channel.try(:web_widget_script) diff --git a/app/views/api/v1/models/user.json.jbuilder b/app/views/api/v1/models/user.json.jbuilder new file mode 100644 index 000000000..2d99937d0 --- /dev/null +++ b/app/views/api/v1/models/user.json.jbuilder @@ -0,0 +1,12 @@ +json.id resource.id +json.provider resource.provider +json.uid resource.uid +json.name resource.name +json.nickname resource.nickname +json.email resource.email +json.account_id resource.account.id +json.pubsub_token resource.pubsub_token +json.role resource.role +json.inviter_id resource.account_user.inviter_id +json.confirmed resource.confirmed? +json.avatar_url resource.avatar_url diff --git a/app/views/api/v1/profiles/update.json.jbuilder b/app/views/api/v1/profiles/update.json.jbuilder index b55f96967..a1d99525a 100644 --- a/app/views/api/v1/profiles/update.json.jbuilder +++ b/app/views/api/v1/profiles/update.json.jbuilder @@ -4,7 +4,7 @@ json.uid @user.uid json.name @user.name json.nickname @user.nickname json.email @user.email -json.account_id @user.account_id +json.account_id @user.account.id json.pubsub_token @user.pubsub_token json.role @user.role json.confirmed @user.confirmed? diff --git a/app/views/api/v1/widget/messages/create.json.jbuilder b/app/views/api/v1/widget/messages/create.json.jbuilder new file mode 100644 index 000000000..051a911e7 --- /dev/null +++ b/app/views/api/v1/widget/messages/create.json.jbuilder @@ -0,0 +1,10 @@ +json.id @message.id +json.content @message.content +json.inbox_id @message.inbox_id +json.conversation_id @message.conversation.display_id +json.message_type @message.message_type_before_type_cast +json.created_at @message.created_at.to_i +json.private @message.private +json.source_id @message.source_id +json.attachment @message.attachment.push_event_data if @message.attachment +json.sender @message.user.push_event_data if @message.user diff --git a/app/views/api/v1/widget/messages/index.json.jbuilder b/app/views/api/v1/widget/messages/index.json.jbuilder index 1f423beb1..c39ed54b8 100644 --- a/app/views/api/v1/widget/messages/index.json.jbuilder +++ b/app/views/api/v1/widget/messages/index.json.jbuilder @@ -5,7 +5,7 @@ json.array! @messages do |message| json.content_type message.content_type json.content_attributes message.content_attributes json.created_at message.created_at.to_i - json.conversation_id message. conversation_id + json.conversation_id message.conversation.display_id json.attachment message.attachment.push_event_data if message.attachment json.sender message.user.push_event_data if message.user end diff --git a/app/views/api/v1/widget/messages/update.json.jbuilder b/app/views/api/v1/widget/messages/update.json.jbuilder new file mode 100644 index 000000000..da1e28d00 --- /dev/null +++ b/app/views/api/v1/widget/messages/update.json.jbuilder @@ -0,0 +1 @@ +json.contact @contact diff --git a/app/views/devise/auth.json.jbuilder b/app/views/devise/auth.json.jbuilder index d2caa20ec..f7992e673 100644 --- a/app/views/devise/auth.json.jbuilder +++ b/app/views/devise/auth.json.jbuilder @@ -1,14 +1,14 @@ json.data do - json.id @resource.id - json.provider @resource.provider - json.uid @resource.uid - json.name @resource.name - json.nickname @resource.nickname - json.email @resource.email - json.account_id @resource.account_id - json.pubsub_token @resource.pubsub_token - json.role @resource.role - json.inviter_id @resource.inviter_id - json.confirmed @resource.confirmed? - json.avatar_url @resource.avatar_url + json.id resource.id + json.provider resource.provider + json.uid resource.uid + json.name resource.name + json.nickname resource.nickname + json.email resource.email + json.account_id resource.account.id + json.pubsub_token resource.pubsub_token + json.role resource.account_user.role + json.inviter_id resource.account_user.inviter_id + json.confirmed resource.confirmed? + json.avatar_url resource.avatar_url end diff --git a/app/views/devise/token.json.jbuilder b/app/views/devise/token.json.jbuilder index 78cd44235..ec9d740aa 100644 --- a/app/views/devise/token.json.jbuilder +++ b/app/views/devise/token.json.jbuilder @@ -7,11 +7,12 @@ json.payload do json.name @resource.name json.nickname @resource.nickname json.email @resource.email - json.account_id @resource.account_id + json.account_id @resource.account.id json.pubsub_token @resource.pubsub_token - json.role @resource.role - json.inviter_id @resource.inviter_id + json.role @resource.account_user.role + json.inviter_id @resource.account_user.inviter_id json.confirmed @resource.confirmed? json.avatar_url @resource.avatar_url + json.access_token @resource.access_token&.token end end diff --git a/app/views/layouts/vueapp.html.erb b/app/views/layouts/vueapp.html.erb index c7973bdf9..e1855f77c 100644 --- a/app/views/layouts/vueapp.html.erb +++ b/app/views/layouts/vueapp.html.erb @@ -31,6 +31,7 @@ <%= yield %>