diff --git a/.circleci/config.yml b/.circleci/config.yml index f8ffdf076..c5a6430a3 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:3.0.2-node-browsers + - image: cimg/ruby:3.0.2-node # Specify service dependencies here if necessary # CircleCI maintains a library of pre-built images @@ -40,20 +40,20 @@ jobs: - restore_cache: keys: - - chatwoot-bundle-{{ checksum "Gemfile.lock" }} + - chatwoot-bundle-{{ .Environment.CACHE_VERSION }}-{{ checksum "Gemfile.lock" }} - chatwoot-bundle - run: bundle install --frozen --path ~/.bundle - save_cache: paths: - ~/.bundle - key: chatwoot-bundle-{{ checksum "Gemfile.lock" }} + key: chatwoot-bundle-{{ .Environment.CACHE_VERSION }}-{{ checksum "Gemfile.lock" }} # Only necessary if app uses webpacker or yarn in some other way - restore_cache: keys: - - chatwoot-yarn-{{ checksum "yarn.lock" }} + - chatwoot-yarn-{{ .Environment.CACHE_VERSION }}-{{ checksum "yarn.lock" }} - chatwoot-yarn- - run: @@ -62,7 +62,7 @@ jobs: # Store yarn / webpacker cache - save_cache: - key: chatwoot-yarn-{{ checksum "yarn.lock" }} + key: chatwoot-yarn-{{ .Environment.CACHE_VERSION }}-{{ checksum "yarn.lock" }} paths: - ~/.cache/yarn diff --git a/.env.example b/.env.example index eb41a88f5..39a533355 100644 --- a/.env.example +++ b/.env.example @@ -85,8 +85,6 @@ AWS_ACCESS_KEY_ID= AWS_SECRET_ACCESS_KEY= AWS_REGION= -# Sentry -SENTRY_DSN= # Log settings # Disable if you want to write logs to a file @@ -139,6 +137,25 @@ ANDROID_SHA256_CERT_FINGERPRINT=AC:73:8E:DE:EB:56:EA:CC:10:87:02:A7:65:37:7B:38: USE_INBOX_AVATAR_FOR_BOT=true +### APM and Error Monitoring configurations +## Sentry +# SENTRY_DSN= + +## Scout +## https://scoutapm.com/docs/ruby/configuration +# SCOUT_KEY=YOURKEY +# SCOUT_NAME=YOURAPPNAME (Production) +# SCOUT_MONITOR=true + +## NewRelic +# https://docs.newrelic.com/docs/agents/ruby-agent/configuration/ruby-agent-configuration/ +# NEW_RELIC_LICENSE_KEY= + +## Datadog +## https://github.com/DataDog/dd-trace-rb/blob/master/docs/GettingStarted.md#environment-variables +# DD_TRACE_AGENT_URL= + + ## IP look up configuration ## ref https://github.com/alexreisner/geocoder/blob/master/README_API_GUIDE.md diff --git a/.rubocop.yml b/.rubocop.yml index c05cbb9bf..76ef5fcfc 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -100,6 +100,7 @@ Metrics/AbcSize: - 'app/controllers/concerns/auth_helper.rb' - 'db/migrate/20190819005836_add_missing_indexes_on_taggings.acts_as_taggable_on_engine.rb' - 'db/migrate/20161123131628_devise_token_auth_create_users.rb' + - 'app/controllers/api/v1/accounts/inboxes_controller.rb' Metrics/CyclomaticComplexity: Max: 7 Exclude: diff --git a/Gemfile b/Gemfile index c172b7d08..1dddb482c 100644 --- a/Gemfile +++ b/Gemfile @@ -63,7 +63,7 @@ gem 'barnes' ##--- gems for authentication & authorization ---## gem 'devise' -gem 'devise-secure_password', '~> 2.0' +gem 'devise-secure_password', '~> 2.0', git: 'https://github.com/chatwoot/devise-secure_password' gem 'devise_token_auth' # authorization gem 'jwt' @@ -78,7 +78,7 @@ gem 'wisper', '2.0.0' ##--- gems for channels ---## # TODO: bump up gem to 2.0 gem 'facebook-messenger' -gem 'telegram-bot-ruby' +gem 'line-bot-api' gem 'twilio-ruby', '~> 5.32.0' # twitty will handle subscription of twitter account events # gem 'twitty', git: 'https://github.com/chatwoot/twitty' @@ -93,7 +93,10 @@ gem 'google-cloud-dialogflow' ##--- gems for debugging and error reporting ---## # static analysis gem 'brakeman' + +##-- apm and error monitoring ---# gem 'ddtrace' +gem 'newrelic_rpm' gem 'scout_apm' gem 'sentry-rails' gem 'sentry-ruby' diff --git a/Gemfile.lock b/Gemfile.lock index 30c6c022a..45a7b9133 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,63 +1,71 @@ +GIT + remote: https://github.com/chatwoot/devise-secure_password + revision: de11e8765654b8242d42101ee9c8ffc8126f7975 + specs: + devise-secure_password (2.0.1) + devise (>= 4.0.0, < 5.0.0) + railties (>= 5.0.0, < 7.0.0) + GEM remote: https://rubygems.org/ specs: - actioncable (6.1.4) - actionpack (= 6.1.4) - activesupport (= 6.1.4) + actioncable (6.1.4.1) + actionpack (= 6.1.4.1) + activesupport (= 6.1.4.1) nio4r (~> 2.0) websocket-driver (>= 0.6.1) - actionmailbox (6.1.4) - actionpack (= 6.1.4) - activejob (= 6.1.4) - activerecord (= 6.1.4) - activestorage (= 6.1.4) - activesupport (= 6.1.4) + actionmailbox (6.1.4.1) + actionpack (= 6.1.4.1) + activejob (= 6.1.4.1) + activerecord (= 6.1.4.1) + activestorage (= 6.1.4.1) + activesupport (= 6.1.4.1) mail (>= 2.7.1) - actionmailer (6.1.4) - actionpack (= 6.1.4) - actionview (= 6.1.4) - activejob (= 6.1.4) - activesupport (= 6.1.4) + actionmailer (6.1.4.1) + actionpack (= 6.1.4.1) + actionview (= 6.1.4.1) + activejob (= 6.1.4.1) + activesupport (= 6.1.4.1) mail (~> 2.5, >= 2.5.4) rails-dom-testing (~> 2.0) - actionpack (6.1.4) - actionview (= 6.1.4) - activesupport (= 6.1.4) + actionpack (6.1.4.1) + actionview (= 6.1.4.1) + activesupport (= 6.1.4.1) rack (~> 2.0, >= 2.0.9) rack-test (>= 0.6.3) rails-dom-testing (~> 2.0) rails-html-sanitizer (~> 1.0, >= 1.2.0) - actiontext (6.1.4) - actionpack (= 6.1.4) - activerecord (= 6.1.4) - activestorage (= 6.1.4) - activesupport (= 6.1.4) + actiontext (6.1.4.1) + actionpack (= 6.1.4.1) + activerecord (= 6.1.4.1) + activestorage (= 6.1.4.1) + activesupport (= 6.1.4.1) nokogiri (>= 1.8.5) - actionview (6.1.4) - activesupport (= 6.1.4) + actionview (6.1.4.1) + activesupport (= 6.1.4.1) builder (~> 3.1) erubi (~> 1.4) rails-dom-testing (~> 2.0) rails-html-sanitizer (~> 1.1, >= 1.2.0) active_record_query_trace (1.8) - activejob (6.1.4) - activesupport (= 6.1.4) + activejob (6.1.4.1) + activesupport (= 6.1.4.1) globalid (>= 0.3.6) - activemodel (6.1.4) - activesupport (= 6.1.4) - activerecord (6.1.4) - activemodel (= 6.1.4) - activesupport (= 6.1.4) + activemodel (6.1.4.1) + activesupport (= 6.1.4.1) + activerecord (6.1.4.1) + activemodel (= 6.1.4.1) + activesupport (= 6.1.4.1) activerecord-import (1.2.0) activerecord (>= 3.2) - activestorage (6.1.4) - actionpack (= 6.1.4) - activejob (= 6.1.4) - activerecord (= 6.1.4) - activesupport (= 6.1.4) + activestorage (6.1.4.1) + actionpack (= 6.1.4.1) + activejob (= 6.1.4.1) + activerecord (= 6.1.4.1) + activesupport (= 6.1.4.1) marcel (~> 1.0.0) mini_mime (>= 1.1.0) - activesupport (6.1.4) + activesupport (6.1.4.1) concurrent-ruby (~> 1.0, >= 1.0.2) i18n (>= 1.6, < 2) minitest (>= 5.1) @@ -98,10 +106,6 @@ GEM aws-sigv4 (~> 1.1) aws-sigv4 (1.2.4) aws-eventstream (~> 1, >= 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-storage-blob (2.0.1) azure-storage-common (~> 2.0) nokogiri (~> 1.11.0.rc2) @@ -115,7 +119,7 @@ GEM statsd-ruby (~> 1.1) bcrypt (3.1.16) bindex (0.8.1) - bootsnap (1.7.6) + bootsnap (1.7.7) msgpack (~> 1.0) brakeman (5.1.1) browser (5.3.1) @@ -130,8 +134,6 @@ GEM thor (~> 1.0) byebug (11.1.3) coderay (1.1.3) - coercible (1.0.0) - descendants_tracker (~> 0.0.1) commonmarker (0.22.0) concurrent-ruby (1.1.9) connection_pool (2.2.5) @@ -152,17 +154,12 @@ GEM ffi (~> 1.0) msgpack declarative (0.0.20) - descendants_tracker (0.0.4) - thread_safe (~> 0.3, >= 0.3.1) devise (4.8.0) bcrypt (~> 3.0) orm_adapter (~> 0.1) railties (>= 4.1.0) responders warden (~> 1.2.3) - devise-secure_password (2.0.1) - devise (>= 4.0.0, < 5.0.0) - railties (>= 5.0.0, < 7.0.0) devise_token_auth (1.2.0) bcrypt (~> 3.0) devise (> 3.5.2, < 5) @@ -179,7 +176,6 @@ GEM railties (>= 3.2) down (5.2.3) addressable (~> 2.8) - dry-inflector (0.2.1) ecma-re-validator (0.3.0) regexp_parser (~> 2.0) erubi (1.10.0) @@ -216,7 +212,7 @@ GEM grpc (~> 1.25) geocoder (1.6.7) gli (2.20.1) - globalid (0.5.1) + globalid (0.5.2) activesupport (>= 5.0) google-apis-core (0.4.1) addressable (~> 2.5, >= 2.5.1) @@ -292,7 +288,6 @@ GEM httpclient (2.8.3) i18n (1.8.10) concurrent-ruby (~> 1.0) - ice_nine (0.11.2) image_processing (1.12.1) mini_magick (>= 4.9.5, < 5) ruby-vips (>= 2.0.17, < 3) @@ -332,11 +327,12 @@ GEM addressable (~> 2.7) letter_opener (1.7.0) launchy (~> 2.2) + line-bot-api (1.21.0) liquid (5.0.1) listen (3.6.0) rb-fsevent (~> 0.10, >= 0.10.3) rb-inotify (~> 0.9, >= 0.9.10) - loofah (2.11.0) + loofah (2.12.0) crass (~> 1.0.2) nokogiri (>= 1.5.9) mail (2.7.1) @@ -349,7 +345,7 @@ GEM mime-types-data (~> 3.2015) mime-types-data (3.2021.0704) mini_magick (4.11.0) - mini_mime (1.1.0) + mini_mime (1.1.1) minitest (5.14.4) mock_redis (0.28.0) ruby2_keywords @@ -362,7 +358,8 @@ GEM net-http-persistent (4.0.1) connection_pool (~> 2.2) netrc (0.11.0) - nio4r (2.5.7) + newrelic_rpm (7.2.0) + nio4r (2.5.8) nokogiri (1.11.7-arm64-darwin) racc (~> 1.4) nokogiri (1.11.7-x86_64-darwin) @@ -400,29 +397,29 @@ GEM rack-test (1.1.0) rack (>= 1.0, < 3) rack-timeout (0.6.0) - rails (6.1.4) - actioncable (= 6.1.4) - actionmailbox (= 6.1.4) - actionmailer (= 6.1.4) - actionpack (= 6.1.4) - actiontext (= 6.1.4) - actionview (= 6.1.4) - activejob (= 6.1.4) - activemodel (= 6.1.4) - activerecord (= 6.1.4) - activestorage (= 6.1.4) - activesupport (= 6.1.4) + rails (6.1.4.1) + actioncable (= 6.1.4.1) + actionmailbox (= 6.1.4.1) + actionmailer (= 6.1.4.1) + actionpack (= 6.1.4.1) + actiontext (= 6.1.4.1) + actionview (= 6.1.4.1) + activejob (= 6.1.4.1) + activemodel (= 6.1.4.1) + activerecord (= 6.1.4.1) + activestorage (= 6.1.4.1) + activesupport (= 6.1.4.1) bundler (>= 1.15.0) - railties (= 6.1.4) + railties (= 6.1.4.1) sprockets-rails (>= 2.0.0) rails-dom-testing (2.0.3) activesupport (>= 4.2.0) nokogiri (>= 1.6) - rails-html-sanitizer (1.3.0) + rails-html-sanitizer (1.4.1) loofah (~> 2.3) - railties (6.1.4) - actionpack (= 6.1.4) - activesupport (= 6.1.4) + railties (6.1.4.1) + actionpack (= 6.1.4.1) + activesupport (= 6.1.4.1) method_source rake (>= 0.13) thor (~> 1.0) @@ -563,13 +560,8 @@ GEM sprockets (>= 3.0.0) squasher (0.6.2) statsd-ruby (1.5.0) - telegram-bot-ruby (0.16.0) - dry-inflector - faraday - virtus (~> 2.0) telephone_number (1.4.12) thor (1.1.0) - thread_safe (0.3.6) tilt (2.0.10) time_diff (0.3.0) activesupport @@ -597,10 +589,6 @@ GEM valid_email2 (4.0.0) activemodel (>= 3.2) mail (~> 2.5) - virtus (2.0.0) - axiom-types (~> 0.1) - coercible (~> 1.0) - descendants_tracker (~> 0.0, >= 0.0.3) warden (1.2.9) rack (>= 2.0.9) web-console (4.1.0) @@ -629,6 +617,8 @@ GEM PLATFORMS arm64-darwin-20 + x86_64-darwin-18 + x86_64-darwin-20 x86_64-darwin-21 x86_64-linux @@ -653,7 +643,7 @@ DEPENDENCIES database_cleaner ddtrace devise - devise-secure_password (~> 2.0) + devise-secure_password (~> 2.0)! devise_token_auth dotenv-rails down (~> 5.0) @@ -678,10 +668,12 @@ DEPENDENCIES kaminari koala letter_opener + line-bot-api liquid listen maxminddb mock_redis + newrelic_rpm pg procore-sift pry-rails @@ -713,7 +705,6 @@ DEPENDENCIES spring spring-watcher-listen squasher - telegram-bot-ruby telephone_number time_diff twilio-ruby (~> 5.32.0) diff --git a/README.md b/README.md index 2720de61e..41f15553d 100644 --- a/README.md +++ b/README.md @@ -22,6 +22,8 @@ ___ Discord Huntr + uptime + response time

Chat dashboard diff --git a/app/actions/contact_merge_action.rb b/app/actions/contact_merge_action.rb index 3a3ef11ed..f20e2ffb4 100644 --- a/app/actions/contact_merge_action.rb +++ b/app/actions/contact_merge_action.rb @@ -1,4 +1,5 @@ class ContactMergeAction + include Events::Types pattr_initialize [:account!, :base_contact!, :mergee_contact!] def perform @@ -11,7 +12,7 @@ class ContactMergeAction merge_conversations merge_messages merge_contact_inboxes - remove_mergee_contact + merge_and_remove_mergee_contact end @base_contact end @@ -40,7 +41,18 @@ class ContactMergeAction ContactInbox.where(contact_id: @mergee_contact.id).update(contact_id: @base_contact.id) end - def remove_mergee_contact + def merge_and_remove_mergee_contact + mergable_attribute_keys = %w[identifier name email phone_number custom_attributes] + base_contact_attributes = base_contact.attributes.slice(*mergable_attribute_keys).compact_blank + mergee_contact_attributes = mergee_contact.attributes.slice(*mergable_attribute_keys).compact_blank + + # attributes in base contact are given preference + merged_attributes = mergee_contact_attributes.deep_merge(base_contact_attributes) + # retaining old pubsub token to notify the contacts that are listening + mergee_pubsub_token = mergee_contact.pubsub_token + @mergee_contact.destroy! + Rails.configuration.dispatcher.dispatch(CONTACT_MERGED, Time.zone.now, contact: @base_contact, tokens: [mergee_pubsub_token]) + @base_contact.update!(merged_attributes) end end diff --git a/app/assets/stylesheets/administrate/utilities/_variables.scss b/app/assets/stylesheets/administrate/utilities/_variables.scss index c46326612..db8d1a302 100644 --- a/app/assets/stylesheets/administrate/utilities/_variables.scss +++ b/app/assets/stylesheets/administrate/utilities/_variables.scss @@ -43,7 +43,7 @@ $woot-logo-padding: $space-large $space-two; // Colors $color-woot: #1f93ff; $color-gray: #6e6f73; -$color-light-gray: #999a9b; +$color-light-gray: #747677; $color-border: #e0e6ed; $color-border-light: #f0f4f5; $color-background: #f4f6fb; diff --git a/app/builders/messages/facebook/message_builder.rb b/app/builders/messages/facebook/message_builder.rb index 23c9d92b4..4ffb4b101 100644 --- a/app/builders/messages/facebook/message_builder.rb +++ b/app/builders/messages/facebook/message_builder.rb @@ -148,6 +148,14 @@ class Messages::Facebook::MessageBuilder } end + def process_contact_params_result(result) + { + name: "#{result['first_name'] || 'John'} #{result['last_name'] || 'Doe'}", + account_id: @inbox.account_id, + remote_avatar_url: result['profile_pic'] || '' + } + end + def contact_params begin k = Koala::Facebook::API.new(@inbox.channel.page_access_token) if @inbox.facebook? @@ -155,14 +163,15 @@ class Messages::Facebook::MessageBuilder rescue Koala::Facebook::AuthenticationError @inbox.channel.authorization_error! raise + rescue Koala::Facebook::ClientError => e + result = {} + # OAuthException, code: 100, error_subcode: 2018218, message: (#100) No profile available for this user + # We don't need to capture this error as we don't care about contact params in case of echo messages + Sentry.capture_exception(e) unless outgoing_echo? rescue StandardError => e result = {} Sentry.capture_exception(e) end - { - name: "#{result['first_name'] || 'John'} #{result['last_name'] || 'Doe'}", - account_id: @inbox.account_id, - remote_avatar_url: result['profile_pic'] || '' - } + process_contact_params_result(result) end end diff --git a/app/builders/messages/message_builder.rb b/app/builders/messages/message_builder.rb index ba78d76d7..b6ae5409d 100644 --- a/app/builders/messages/message_builder.rb +++ b/app/builders/messages/message_builder.rb @@ -15,21 +15,25 @@ class Messages::MessageBuilder def perform @message = @conversation.messages.build(message_params) - if @attachments.present? - @attachments.each do |uploaded_attachment| - attachment = @message.attachments.new( - account_id: @message.account_id, - file_type: file_type(uploaded_attachment&.content_type) - ) - attachment.file.attach(uploaded_attachment) - end - end - @message.save + process_attachments + @message.save! @message end private + def process_attachments + return if @attachments.blank? + + @attachments.each do |uploaded_attachment| + @message.attachments.build( + account_id: @message.account_id, + file_type: file_type(uploaded_attachment&.content_type), + file: uploaded_attachment + ) + end + end + def message_type if @conversation.inbox.channel_type != 'Channel::Api' && @message_type == 'incoming' raise StandardError, 'Incoming messages are only allowed in Api inboxes' diff --git a/app/builders/notification_subscription_builder.rb b/app/builders/notification_subscription_builder.rb index c1574bfc1..af75ef2fd 100644 --- a/app/builders/notification_subscription_builder.rb +++ b/app/builders/notification_subscription_builder.rb @@ -4,7 +4,7 @@ class NotificationSubscriptionBuilder def perform # if multiple accounts were used to login in same browser move_subscription_to_user if identifier_subscription && identifier_subscription.user_id != user.id - build_identifier_subscription if identifier_subscription.blank? + identifier_subscription.blank? ? build_identifier_subscription : update_identifier_subscription identifier_subscription end @@ -25,6 +25,10 @@ class NotificationSubscriptionBuilder end def build_identifier_subscription - user.notification_subscriptions.create(params.merge(identifier: identifier)) + @identifier_subscription = user.notification_subscriptions.create(params.merge(identifier: identifier)) + end + + def update_identifier_subscription + identifier_subscription.update(params.merge(identifier: identifier)) end end diff --git a/app/builders/v2/report_builder.rb b/app/builders/v2/report_builder.rb index 2a7a55a8a..30804b8e8 100644 --- a/app/builders/v2/report_builder.rb +++ b/app/builders/v2/report_builder.rb @@ -32,9 +32,16 @@ class V2::ReportBuilder private def scope - return account if params[:type].match?('account') - return inbox if params[:type].match?('inbox') - return user if params[:type].match?('agent') + case params[:type] + when :account + account + when :inbox + inbox + when :agent + user + when :label + label + end end def inbox @@ -45,6 +52,10 @@ class V2::ReportBuilder @user ||= account.users.where(id: params[:id]).first end + def label + @label ||= account.labels.where(id: params[:id]).first + end + def conversations_count scope.conversations .group_by_day(:created_at, range: range, default_value: 0) diff --git a/app/controllers/api/v1/accounts/contacts_controller.rb b/app/controllers/api/v1/accounts/contacts_controller.rb index b9b9ffafe..444f0c918 100644 --- a/app/controllers/api/v1/accounts/contacts_controller.rb +++ b/app/controllers/api/v1/accounts/contacts_controller.rb @@ -79,6 +79,7 @@ class Api::V1::Accounts::ContactsController < Api::V1::Accounts::BaseController @resolved_contacts = Current.account.contacts .where.not(email: [nil, '']) .or(Current.account.contacts.where.not(phone_number: [nil, ''])) + .or(Current.account.contacts.where.not(identifier: [nil, ''])) @resolved_contacts = @resolved_contacts.tagged_with(params[:labels], any: true) if params[:labels].present? @resolved_contacts end diff --git a/app/controllers/api/v1/accounts/conversations/assignments_controller.rb b/app/controllers/api/v1/accounts/conversations/assignments_controller.rb index 670abf37a..68ff2e67d 100644 --- a/app/controllers/api/v1/accounts/conversations/assignments_controller.rb +++ b/app/controllers/api/v1/accounts/conversations/assignments_controller.rb @@ -1,20 +1,34 @@ class Api::V1::Accounts::Conversations::AssignmentsController < Api::V1::Accounts::Conversations::BaseController # assigns agent/team to a conversation def create - set_assignee - render json: @assignee + if params.key?(:assignee_id) + set_agent + elsif params.key?(:team_id) + set_team + else + render json: nil + end end private - def set_assignee - # if params[:assignee_id] is not a valid id, it will set to nil, hence unassigning the conversation - if params.key?(:assignee_id) - @assignee = Current.account.users.find_by(id: params[:assignee_id]) - @conversation.update_assignee(@assignee) - elsif params.key?(:team_id) - @assignee = Current.account.teams.find_by(id: params[:team_id]) - @conversation.update!(team: @assignee) + def set_agent + @agent = Current.account.users.find_by(id: params[:assignee_id]) + @conversation.update_assignee(@agent) + render_agent + end + + def render_agent + if @agent.nil? + render json: nil + else + render partial: 'api/v1/models/agent', formats: [:json], locals: { resource: @agent } end end + + def set_team + @team = Current.account.teams.find_by(id: params[:team_id]) + @conversation.update!(team: @team) + render json: @team + end end diff --git a/app/controllers/api/v1/accounts/custom_attribute_definitions_controller.rb b/app/controllers/api/v1/accounts/custom_attribute_definitions_controller.rb index 687da55bd..e02c090db 100644 --- a/app/controllers/api/v1/accounts/custom_attribute_definitions_controller.rb +++ b/app/controllers/api/v1/accounts/custom_attribute_definitions_controller.rb @@ -31,12 +31,13 @@ class Api::V1::Accounts::CustomAttributeDefinitionsController < Api::V1::Account end def fetch_custom_attribute_definition - @custom_attribute_definition = @custom_attribute_definitions.find(permitted_params[:id]) + @custom_attribute_definition = Current.account.custom_attribute_definitions.find(permitted_params[:id]) end def permitted_payload params.require(:custom_attribute_definition).permit( :attribute_display_name, + :attribute_description, :attribute_display_type, :attribute_key, :attribute_model, @@ -45,6 +46,6 @@ class Api::V1::Accounts::CustomAttributeDefinitionsController < Api::V1::Account end def permitted_params - params.permit(:id, :filter_type) + params.permit(:id, :filter_type, :attribute_model) end end diff --git a/app/controllers/api/v1/accounts/inbox_members_controller.rb b/app/controllers/api/v1/accounts/inbox_members_controller.rb index c5fb3486b..591aa5637 100644 --- a/app/controllers/api/v1/accounts/inbox_members_controller.rb +++ b/app/controllers/api/v1/accounts/inbox_members_controller.rb @@ -1,26 +1,40 @@ class Api::V1::Accounts::InboxMembersController < Api::V1::Accounts::BaseController - before_action :fetch_inbox, only: [:create, :show] - before_action :current_agents_ids, only: [:create] + before_action :fetch_inbox + before_action :current_agents_ids, only: [:update] def create authorize @inbox, :create? - begin - # update also done via same action - update_agents_list - head :ok - rescue StandardError => e - Rails.logger.debug { "Rescued: #{e.inspect}" } - render_could_not_create_error('Could not add agents to inbox') + ActiveRecord::Base.transaction do + params[:user_ids].map { |user_id| @inbox.add_member(user_id) } end + fetch_updated_agents end def show authorize @inbox, :show? - @agents = Current.account.users.where(id: @inbox.members.select(:user_id)) + fetch_updated_agents + end + + def update + authorize @inbox, :update? + update_agents_list + fetch_updated_agents + end + + def destroy + authorize @inbox, :destroy? + ActiveRecord::Base.transaction do + params[:user_ids].map { |user_id| @inbox.remove_member(user_id) } + end + head :ok end private + def fetch_updated_agents + @agents = Current.account.users.where(id: @inbox.members.select(:user_id)) + end + def update_agents_list # get all the user_ids which the inbox currently has as members. # get the list of user_ids from params diff --git a/app/controllers/api/v1/accounts/inboxes_controller.rb b/app/controllers/api/v1/accounts/inboxes_controller.rb index 2621a4a33..df055923c 100644 --- a/app/controllers/api/v1/accounts/inboxes_controller.rb +++ b/app/controllers/api/v1/accounts/inboxes_controller.rb @@ -1,12 +1,15 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController before_action :fetch_inbox, except: [:index, :create] before_action :fetch_agent_bot, only: [:set_agent_bot] - before_action :check_authorization + # we are already handling the authorization in fetch inbox + before_action :check_authorization, except: [:show] def index @inboxes = policy_scope(Current.account.inboxes.order_by_name.includes(:channel, { avatar_attachment: [:blob] })) end + def show; end + def assignable_agents @assignable_agents = (Current.account.users.where(id: @inbox.members.select(:user_id)) + Current.account.administrators).uniq end @@ -15,26 +18,32 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController @campaigns = @inbox.campaigns end + def avatar + @inbox.avatar.attachment.destroy! if @inbox.avatar.attached? + head :ok + end + def create ActiveRecord::Base.transaction do channel = create_channel @inbox = Current.account.inboxes.build( - name: permitted_params[:name], - greeting_message: permitted_params[:greeting_message], - greeting_enabled: permitted_params[:greeting_enabled], - channel: channel + { + name: inbox_name(channel), + channel: channel + }.merge( + permitted_params.except(:channel) + ) ) - @inbox.avatar.attach(permitted_params[:avatar]) @inbox.save! end end def update - @inbox.update(inbox_update_params.except(:channel)) + @inbox.update(permitted_params.except(:channel)) @inbox.update_working_hours(params.permit(working_hours: Inbox::OFFISABLE_ATTRS)[:working_hours]) if params[:working_hours] - return unless @inbox.channel.is_a?(Channel::WebWidget) && inbox_update_params[:channel].present? - @inbox.channel.update!(inbox_update_params[:channel]) + channel_attributes = get_channel_attributes(@inbox.channel_type) + @inbox.channel.update!(permitted_params(channel_attributes)[:channel]) if permitted_params(channel_attributes)[:channel].present? update_channel_feature_flags end @@ -69,43 +78,57 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController @agent_bot = AgentBot.find(params[:agent_bot]) if params[:agent_bot] end + def inbox_name(channel) + return channel.try(:bot_name) if channel.is_a?(Channel::Telegram) + + permitted_params[:name] + end + def create_channel case permitted_params[:channel][:type] when 'web_widget' - Current.account.web_widgets.create!(permitted_params[:channel].except(:type)) + Current.account.web_widgets.create!(permitted_params(Channel::WebWidget::EDITABLE_ATTRS)[:channel].except(:type)) when 'api' - Current.account.api_channels.create!(permitted_params[:channel].except(:type)) + Current.account.api_channels.create!(permitted_params(Channel::Api::EDITABLE_ATTRS)[:channel].except(:type)) when 'email' - Current.account.email_channels.create!(permitted_params[:channel].except(:type)) + Current.account.email_channels.create!(permitted_params(Channel::Email::EDITABLE_ATTRS)[:channel].except(:type)) + when 'line' + Current.account.line_channels.create!(permitted_params(Channel::Line::EDITABLE_ATTRS)[:channel].except(:type)) + when 'telegram' + Current.account.telegram_channels.create!(permitted_params(Channel::Telegram::EDITABLE_ATTRS)[:channel].except(:type)) end end def update_channel_feature_flags - return unless inbox_update_params[:channel].key? :selected_feature_flags + return unless @inbox.web_widget? + return unless permitted_params(Channel::WebWidget::EDITABLE_ATTRS)[:channel].key? :selected_feature_flags - @inbox.channel.selected_feature_flags = inbox_update_params[:channel][:selected_feature_flags] + @inbox.channel.selected_feature_flags = permitted_params(Channel::WebWidget::EDITABLE_ATTRS)[:channel][:selected_feature_flags] @inbox.channel.save! end - def permitted_params - params.permit(:id, :avatar, :name, :greeting_message, :greeting_enabled, :enable_email_collect, :csat_survey_enabled, channel: - [:type, :website_url, :widget_color, :welcome_title, :welcome_tagline, :webhook_url, :email, :reply_time]) + def permitted_params(channel_attributes = []) + params.permit( + :name, :avatar, :greeting_enabled, :greeting_message, :enable_email_collect, :csat_survey_enabled, + :enable_auto_assignment, :working_hours_enabled, :out_of_office_message, :timezone, + channel: [:type, *channel_attributes] + ) end - def inbox_update_params - params.permit(:enable_auto_assignment, :enable_email_collect, :name, :avatar, :greeting_message, :greeting_enabled, :csat_survey_enabled, - :working_hours_enabled, :out_of_office_message, :timezone, - channel: [ - :website_url, - :widget_color, - :welcome_title, - :welcome_tagline, - :webhook_url, - :email, - :reply_time, - :pre_chat_form_enabled, - { pre_chat_form_options: [:pre_chat_message, :require_email] }, - { selected_feature_flags: [] } - ]) + def get_channel_attributes(channel_type) + case channel_type + when 'Channel::WebWidget' + Channel::WebWidget::EDITABLE_ATTRS + when 'Channel::Api' + Channel::Api::EDITABLE_ATTRS + when 'Channel::Email' + Channel::Email::EDITABLE_ATTRS + when 'Channel::Telegram' + Channel::Telegram::EDITABLE_ATTRS + when 'Channel::Line' + Channel::Line::EDITABLE_ATTRS + else + [] + end end end diff --git a/app/controllers/api/v1/widget/messages_controller.rb b/app/controllers/api/v1/widget/messages_controller.rb index 9e6f770ad..0d9ab6323 100644 --- a/app/controllers/api/v1/widget/messages_controller.rb +++ b/app/controllers/api/v1/widget/messages_controller.rb @@ -8,8 +8,8 @@ class Api::V1::Widget::MessagesController < Api::V1::Widget::BaseController def create @message = conversation.messages.new(message_params) - @message.save build_attachment + @message.save! end def update @@ -29,13 +29,12 @@ class Api::V1::Widget::MessagesController < Api::V1::Widget::BaseController return if params[:message][:attachments].blank? params[:message][:attachments].each do |uploaded_attachment| - attachment = @message.attachments.new( + @message.attachments.new( account_id: @message.account_id, - file_type: helpers.file_type(uploaded_attachment&.content_type) + file_type: helpers.file_type(uploaded_attachment&.content_type), + file: uploaded_attachment ) - attachment.file.attach(uploaded_attachment) end - @message.save! end def set_conversation diff --git a/app/controllers/api/v2/accounts/reports_controller.rb b/app/controllers/api/v2/accounts/reports_controller.rb index 8fc980255..af28fe544 100644 --- a/app/controllers/api/v2/accounts/reports_controller.rb +++ b/app/controllers/api/v2/accounts/reports_controller.rb @@ -1,14 +1,14 @@ class Api::V2::Accounts::ReportsController < Api::V1::Accounts::BaseController before_action :check_authorization - def account - builder = V2::ReportBuilder.new(Current.account, account_report_params) + def index + builder = V2::ReportBuilder.new(Current.account, report_params) data = builder.build render json: data end - def account_summary - render json: account_summary_metrics + def summary + render json: summary_metrics end def agents @@ -23,31 +23,39 @@ class Api::V2::Accounts::ReportsController < Api::V1::Accounts::BaseController render layout: false, template: 'api/v2/accounts/reports/inboxes.csv.erb', format: 'csv' end + def labels + response.headers['Content-Type'] = 'text/csv' + response.headers['Content-Disposition'] = 'attachment; filename=labels_report.csv' + render layout: false, template: 'api/v2/accounts/reports/labels.csv.erb', format: 'csv' + end + private def check_authorization raise Pundit::NotAuthorizedError unless Current.account_user.administrator? end - def account_summary_params + def summary_params { - type: :account, + type: params[:type].to_sym, since: params[:since], - until: params[:until] + until: params[:until], + id: params[:id] } end - def account_report_params + def report_params { metric: params[:metric], - type: :account, + type: params[:type].to_sym, since: params[:since], - until: params[:until] + until: params[:until], + id: params[:id] } end - def account_summary_metrics - builder = V2::ReportBuilder.new(Current.account, account_summary_params) + def summary_metrics + builder = V2::ReportBuilder.new(Current.account, summary_params) builder.summary end end diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index d25f88269..0a1c672ec 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -1,5 +1,6 @@ class ApplicationController < ActionController::Base include DeviseTokenAuth::Concerns::SetUserByToken + include RequestExceptionHandler include Pundit include SwitchLocale @@ -9,22 +10,8 @@ class ApplicationController < ActionController::Base around_action :switch_locale around_action :handle_with_exception, unless: :devise_controller? - rescue_from ActiveRecord::RecordInvalid, with: :render_record_invalid - private - def handle_with_exception - yield - rescue ActiveRecord::RecordNotFound => e - Sentry.capture_exception(e) - render_not_found_error('Resource could not be found') - rescue Pundit::NotAuthorizedError - render_unauthorized('You are not authorized to do this action') - ensure - # to address the thread variable leak issues in Puma/Thin webserver - Current.reset - end - def set_current_user @user ||= current_user Current.user = @user @@ -34,32 +21,6 @@ class ApplicationController < ActionController::Base @subscription ||= Current.account.subscription end - def render_unauthorized(message) - render json: { error: message }, status: :unauthorized - end - - def render_not_found_error(message) - render json: { error: message }, status: :not_found - end - - def render_could_not_create_error(message) - render json: { error: message }, status: :unprocessable_entity - end - - def render_internal_server_error(message) - render json: { error: message }, status: :internal_server_error - end - - def render_record_invalid(exception) - render json: { - message: exception.record.errors.full_messages.join(', ') - }, status: :unprocessable_entity - end - - def render_error_response(exception) - render json: exception.to_hash, status: exception.http_status - end - def pundit_user { user: Current.user, diff --git a/app/controllers/concerns/request_exception_handler.rb b/app/controllers/concerns/request_exception_handler.rb new file mode 100644 index 000000000..3ce486012 --- /dev/null +++ b/app/controllers/concerns/request_exception_handler.rb @@ -0,0 +1,47 @@ +module RequestExceptionHandler + extend ActiveSupport::Concern + + included do + rescue_from ActiveRecord::RecordInvalid, with: :render_record_invalid + end + + private + + def handle_with_exception + yield + rescue ActiveRecord::RecordNotFound => e + Sentry.capture_exception(e) + render_not_found_error('Resource could not be found') + rescue Pundit::NotAuthorizedError + render_unauthorized('You are not authorized to do this action') + ensure + # to address the thread variable leak issues in Puma/Thin webserver + Current.reset + end + + def render_unauthorized(message) + render json: { error: message }, status: :unauthorized + end + + def render_not_found_error(message) + render json: { error: message }, status: :not_found + end + + def render_could_not_create_error(message) + render json: { error: message }, status: :unprocessable_entity + end + + def render_internal_server_error(message) + render json: { error: message }, status: :internal_server_error + end + + def render_record_invalid(exception) + render json: { + message: exception.record.errors.full_messages.join(', ') + }, status: :unprocessable_entity + end + + def render_error_response(exception) + render json: exception.to_hash, status: exception.http_status + end +end diff --git a/app/controllers/dashboard_controller.rb b/app/controllers/dashboard_controller.rb index 53c0abb02..d00aafc18 100644 --- a/app/controllers/dashboard_controller.rb +++ b/app/controllers/dashboard_controller.rb @@ -23,7 +23,9 @@ class DashboardController < ActionController::Base 'CREATE_NEW_ACCOUNT_FROM_DASHBOARD', 'CHATWOOT_INBOX_TOKEN', 'API_CHANNEL_NAME', - 'API_CHANNEL_THUMBNAIL' + 'API_CHANNEL_THUMBNAIL', + 'ANALYTICS_TOKEN', + 'ANALYTICS_HOST' ).merge( APP_VERSION: Chatwoot.config[:version] ) diff --git a/app/controllers/platform/api/v1/accounts_controller.rb b/app/controllers/platform/api/v1/accounts_controller.rb index 24b39aa3e..a037f4e2f 100644 --- a/app/controllers/platform/api/v1/accounts_controller.rb +++ b/app/controllers/platform/api/v1/accounts_controller.rb @@ -16,7 +16,7 @@ class Platform::Api::V1::AccountsController < PlatformController end def destroy - # TODO: obfusicate account + DeleteObjectJob.perform_later(@resource) head :ok end diff --git a/app/controllers/platform/api/v1/users_controller.rb b/app/controllers/platform/api/v1/users_controller.rb index b6e9237b9..4ee14d0b6 100644 --- a/app/controllers/platform/api/v1/users_controller.rb +++ b/app/controllers/platform/api/v1/users_controller.rb @@ -7,8 +7,8 @@ class Platform::Api::V1::UsersController < PlatformController def create @resource = (User.find_by(email: user_params[:email]) || User.new(user_params)) - @resource.confirm @resource.save! + @resource.confirm @platform_app.platform_app_permissibles.find_or_create_by(permissible: @resource) end @@ -19,21 +19,33 @@ class Platform::Api::V1::UsersController < PlatformController def show; end def update - @resource.update!(user_params) + @resource.assign_attributes(user_update_params) + @resource.save! end def destroy - # TODO: obfusicate user + DeleteObjectJob.perform_later(@resource) head :ok end private + def user_custom_attributes + return @resource.custom_attributes.merge(user_params[:custom_attributes]) if user_params[:custom_attributes] + + @resource.custom_attributes + end + + def user_update_params + # we want the merged custom attributes not the original one + user_params.except(:custom_attributes).merge({ custom_attributes: user_custom_attributes }) + end + def set_resource @resource = User.find(params[:id]) end def user_params - params.permit(:name, :email, :password) + params.permit(:name, :email, :password, custom_attributes: {}) end end diff --git a/app/controllers/platform_controller.rb b/app/controllers/platform_controller.rb index c1ecfc500..335f92483 100644 --- a/app/controllers/platform_controller.rb +++ b/app/controllers/platform_controller.rb @@ -1,4 +1,6 @@ class PlatformController < ActionController::API + include RequestExceptionHandler + before_action :ensure_access_token before_action :set_platform_app before_action :set_resource, only: [:update, :show, :destroy] diff --git a/app/controllers/public/api/v1/inboxes/messages_controller.rb b/app/controllers/public/api/v1/inboxes/messages_controller.rb index 68c0f5223..925c16d38 100644 --- a/app/controllers/public/api/v1/inboxes/messages_controller.rb +++ b/app/controllers/public/api/v1/inboxes/messages_controller.rb @@ -7,8 +7,8 @@ class Public::Api::V1::Inboxes::MessagesController < Public::Api::V1::InboxesCon def create @message = @conversation.messages.new(message_params) - @message.save build_attachment + @message.save! end def update @@ -23,13 +23,12 @@ class Public::Api::V1::Inboxes::MessagesController < Public::Api::V1::InboxesCon return if params[:attachments].blank? params[:attachments].each do |uploaded_attachment| - attachment = @message.attachments.new( + @message.attachments.new( account_id: @message.account_id, - file_type: helpers.file_type(uploaded_attachment&.content_type) + file_type: helpers.file_type(uploaded_attachment&.content_type), + file: uploaded_attachment ) - attachment.file.attach(uploaded_attachment) end - @message.save! end def message_finder_params diff --git a/app/controllers/public_controller.rb b/app/controllers/public_controller.rb index 9e5a7e6ba..b59a4296e 100644 --- a/app/controllers/public_controller.rb +++ b/app/controllers/public_controller.rb @@ -1,5 +1,6 @@ # TODO: we should switch to ActionController::API for the base classes # One of the specs is failing when I tried doing that, lets revisit in future class PublicController < ActionController::Base + include RequestExceptionHandler skip_before_action :verify_authenticity_token end diff --git a/app/controllers/super_admin/dashboard_controller.rb b/app/controllers/super_admin/dashboard_controller.rb index b5f3d34eb..56d19d563 100644 --- a/app/controllers/super_admin/dashboard_controller.rb +++ b/app/controllers/super_admin/dashboard_controller.rb @@ -3,10 +3,9 @@ class SuperAdmin::DashboardController < SuperAdmin::ApplicationController def index @data = Conversation.unscoped.group_by_day(:created_at, range: 30.days.ago..2.seconds.ago).count.to_a - @accounts_count = number_with_delimiter(Account.all.length) - @users_count = number_with_delimiter(User.all.length) - @inboxes_count = number_with_delimiter(Inbox.all.length) - @conversations_count = number_with_delimiter(Conversation.all.length) - @messages_count = number_with_delimiter(Message.all.length) + @accounts_count = number_with_delimiter(Account.count) + @users_count = number_with_delimiter(User.count) + @inboxes_count = number_with_delimiter(Inbox.count) + @conversations_count = number_with_delimiter(Conversation.count) end end diff --git a/app/controllers/webhooks/line_controller.rb b/app/controllers/webhooks/line_controller.rb new file mode 100644 index 000000000..74e22f119 --- /dev/null +++ b/app/controllers/webhooks/line_controller.rb @@ -0,0 +1,6 @@ +class Webhooks::LineController < ActionController::API + def process_payload + Webhooks::LineEventsJob.perform_later(params: params.to_unsafe_hash, signature: request.headers['x-line-signature'], post_body: request.raw_post) + head :ok + end +end diff --git a/app/controllers/webhooks/telegram_controller.rb b/app/controllers/webhooks/telegram_controller.rb new file mode 100644 index 000000000..bc65061e7 --- /dev/null +++ b/app/controllers/webhooks/telegram_controller.rb @@ -0,0 +1,6 @@ +class Webhooks::TelegramController < ActionController::API + def process_payload + Webhooks::TelegramEventsJob.perform_later(params.to_unsafe_hash) + head :ok + end +end diff --git a/app/drops/message_drop.rb b/app/drops/message_drop.rb new file mode 100644 index 000000000..cbb3f1782 --- /dev/null +++ b/app/drops/message_drop.rb @@ -0,0 +1,12 @@ +class MessageDrop < BaseDrop + include MessageFormatHelper + + def sender_display_name + @obj.sender.try(:available_name) + end + + def text_content + content = @obj.try(:content) + transform_user_mention_content content + end +end diff --git a/app/helpers/file_type_helper.rb b/app/helpers/file_type_helper.rb index 4936fa155..64d67701a 100644 --- a/app/helpers/file_type_helper.rb +++ b/app/helpers/file_type_helper.rb @@ -3,12 +3,12 @@ module FileTypeHelper return :image if [ 'image/jpeg', 'image/png', - 'image/svg+xml', 'image/gif', 'image/tiff', 'image/bmp' ].include?(content_type) + return :video if content_type.include?('video/') return :audio if content_type.include?('audio/') :file diff --git a/app/javascript/dashboard/api/attributes.js b/app/javascript/dashboard/api/attributes.js new file mode 100644 index 000000000..56eb5da76 --- /dev/null +++ b/app/javascript/dashboard/api/attributes.js @@ -0,0 +1,14 @@ +/* global axios */ +import ApiClient from './ApiClient'; + +class AttributeAPI extends ApiClient { + constructor() { + super('custom_attribute_definitions', { accountScoped: true }); + } + + getAttributesByModel(modelId) { + return axios.get(`${this.url}?attribute_model=${modelId}`); + } +} + +export default new AttributeAPI(); diff --git a/app/javascript/dashboard/api/inboxMembers.js b/app/javascript/dashboard/api/inboxMembers.js index 3716f89ab..64f78845f 100644 --- a/app/javascript/dashboard/api/inboxMembers.js +++ b/app/javascript/dashboard/api/inboxMembers.js @@ -6,8 +6,8 @@ class InboxMembers extends ApiClient { super('inbox_members', { accountScoped: true }); } - create({ inboxId, agentList }) { - return axios.post(this.url, { + update({ inboxId, agentList }) { + return axios.patch(this.url, { inbox_id: inboxId, user_ids: agentList, }); diff --git a/app/javascript/dashboard/api/inboxes.js b/app/javascript/dashboard/api/inboxes.js index b6d8ab860..1cf6ba113 100644 --- a/app/javascript/dashboard/api/inboxes.js +++ b/app/javascript/dashboard/api/inboxes.js @@ -13,6 +13,10 @@ class Inboxes extends ApiClient { getCampaigns(inboxId) { return axios.get(`${this.url}/${inboxId}/campaigns`); } + + deleteInboxAvatar(inboxId) { + return axios.delete(`${this.url}/${inboxId}/avatar`); + } } export default new Inboxes(); diff --git a/app/javascript/dashboard/api/reports.js b/app/javascript/dashboard/api/reports.js index 9b7eeaf45..9f7875c79 100644 --- a/app/javascript/dashboard/api/reports.js +++ b/app/javascript/dashboard/api/reports.js @@ -7,14 +7,14 @@ class ReportsAPI extends ApiClient { } getAccountReports(metric, since, until) { - return axios.get(`${this.url}/account`, { - params: { metric, since, until }, + return axios.get(`${this.url}`, { + params: { metric, since, until, type: 'account' }, }); } getAccountSummary(since, until) { - return axios.get(`${this.url}/account_summary`, { - params: { since, until }, + return axios.get(`${this.url}/summary`, { + params: { since, until, type: 'account' }, }); } diff --git a/app/javascript/dashboard/api/specs/inboxes.spec.js b/app/javascript/dashboard/api/specs/inboxes.spec.js index 6c5cf38ea..b261bb930 100644 --- a/app/javascript/dashboard/api/specs/inboxes.spec.js +++ b/app/javascript/dashboard/api/specs/inboxes.spec.js @@ -27,5 +27,12 @@ describe('#InboxesAPI', () => { '/api/v1/inboxes/2/campaigns' ); }); + + it('#deleteInboxAvatar', () => { + inboxesAPI.deleteInboxAvatar(2); + expect(context.axiosMock.delete).toHaveBeenCalledWith( + '/api/v1/inboxes/2/avatar' + ); + }); }); }); diff --git a/app/javascript/dashboard/api/specs/reports.spec.js b/app/javascript/dashboard/api/specs/reports.spec.js index 0ca5f4be7..72d5b7a90 100644 --- a/app/javascript/dashboard/api/specs/reports.spec.js +++ b/app/javascript/dashboard/api/specs/reports.spec.js @@ -23,12 +23,13 @@ describe('#Reports API', () => { 1621621800 ); expect(context.axiosMock.get).toHaveBeenCalledWith( - '/api/v2/reports/account', + '/api/v2/reports', { params: { metric: 'conversations_count', since: 1621103400, until: 1621621800, + type: 'account' }, } ); @@ -37,11 +38,12 @@ describe('#Reports API', () => { it('#getAccountSummary', () => { reportsAPI.getAccountSummary(1621103400, 1621621800); expect(context.axiosMock.get).toHaveBeenCalledWith( - '/api/v2/reports/account_summary', + '/api/v2/reports/summary', { params: { since: 1621103400, until: 1621621800, + type: 'account' }, } ); diff --git a/app/javascript/dashboard/assets/scss/_woot.scss b/app/javascript/dashboard/assets/scss/_woot.scss index e3ad32e2e..908daa315 100644 --- a/app/javascript/dashboard/assets/scss/_woot.scss +++ b/app/javascript/dashboard/assets/scss/_woot.scss @@ -5,6 +5,7 @@ @import 'shared/assets/stylesheets/font-weights'; @import 'shared/assets/stylesheets/shadows'; @import 'shared/assets/stylesheets/border-radius'; +@import 'shared/assets/stylesheets/z-index'; @import 'variables'; diff --git a/app/javascript/dashboard/assets/scss/views/settings/inbox.scss b/app/javascript/dashboard/assets/scss/views/settings/inbox.scss index cc2abcf9b..e3b45726f 100644 --- a/app/javascript/dashboard/assets/scss/views/settings/inbox.scss +++ b/app/javascript/dashboard/assets/scss/views/settings/inbox.scss @@ -71,7 +71,6 @@ } &.over { - &::after { background: $color-woot; } @@ -80,7 +79,7 @@ background: $color-woot; } - &+.item { + & + .item { &::before { background: $color-woot; } @@ -132,10 +131,13 @@ @include padding($space-medium); @include border-light; @include full-height(); + + &.height-auto { + height: auto; + } } .inoboxes-list { - .inbox-item { @include margin($space-normal); @include flex; @@ -189,9 +191,9 @@ align-self: center; color: $medium-gray; font-size: $font-size-small; - opacity: .7; + opacity: 0.7; transform: translateX(0); - transition: opacity 0.100s ease-in 0s, transform 0.200s ease-in 0.030s; + transition: opacity 0.1s ease-in 0s, transform 0.2s ease-in 0.03s; } } } @@ -226,7 +228,7 @@ @include padding($space-medium); } - >a>img { + > a > img { width: $space-larger * 5; } } diff --git a/app/javascript/dashboard/assets/scss/widgets/_conversation-card.scss b/app/javascript/dashboard/assets/scss/widgets/_conversation-card.scss index f7b96381e..a7a256b4d 100644 --- a/app/javascript/dashboard/assets/scss/widgets/_conversation-card.scss +++ b/app/javascript/dashboard/assets/scss/widgets/_conversation-card.scss @@ -144,7 +144,10 @@ padding-left: 0; .conversation--details { + border-radius: var(--border-radius-small); margin-left: 0; + padding-left: var(--space-two); + padding-right: var(--space-small); } } } diff --git a/app/javascript/dashboard/assets/scss/widgets/_conversation-view.scss b/app/javascript/dashboard/assets/scss/widgets/_conversation-view.scss index d79cfe80d..f2c156542 100644 --- a/app/javascript/dashboard/assets/scss/widgets/_conversation-view.scss +++ b/app/javascript/dashboard/assets/scss/widgets/_conversation-view.scss @@ -113,10 +113,18 @@ } &:last-child { - margin-bottom: $space-small; + margin-bottom: 0; } &.unread--toast { + +.right { + margin-bottom: 0; + } + + +.left { + margin-bottom: 0; + } + span { @include elegant-card; @include round-corner; @@ -140,6 +148,7 @@ } } + &.left { .bubble { @@ -183,6 +192,19 @@ } } + +.unread--toast { + +.right { + margin-top: $space-one; + + .bubble { + border-top-right-radius: $space-one; + } + } + + +.left { + margin-top: 0; + } + } } &.right { @@ -226,6 +248,21 @@ border-top-left-radius: $space-one; } } + + +.unread--toast { + +.left { + margin-top: $space-one; + + .bubble { + border-top-left-radius: $space-one; + } + } + + +.right { + margin-top: 0; + } + } + } &.center { @@ -259,11 +296,11 @@ display: flex; font-size: var(--font-size-small); justify-content: center; - margin: var(--space-small) var(--space-normal); - padding: var(--space-small) var(--space-normal); + margin: var(--space-smaller) 0; + padding: var(--space-smaller) var(--space-micro) var(--space-smaller) var(--space-one); .is-text { - display: inline-block; + display: inline-flex; text-align: start; @include breakpoint(xxxlarge up) { diff --git a/app/javascript/dashboard/components/Accordion/AccordionItem.stories.js b/app/javascript/dashboard/components/Accordion/AccordionItem.stories.js new file mode 100644 index 000000000..5ff6af8cf --- /dev/null +++ b/app/javascript/dashboard/components/Accordion/AccordionItem.stories.js @@ -0,0 +1,30 @@ +import { action } from '@storybook/addon-actions'; +import AccordionItemComponent from './AccordionItem'; + +export default { + title: 'Components/Generic/Accordion', + component: AccordionItemComponent, + argTypes: { + title: { + control: { + type: 'string', + }, + }, + }, +}; + +const Template = (args, { argTypes }) => ({ + props: Object.keys(argTypes), + components: { AccordionItem: AccordionItemComponent }, + template: ` + + This is a sample content you can pass as a slot + + `, +}); + +export const AccordionItem = Template.bind({}); +AccordionItem.args = { + onClick: action('Added'), + title: 'Title of the accordion item', +}; diff --git a/app/javascript/dashboard/components/Accordion/AccordionItem.vue b/app/javascript/dashboard/components/Accordion/AccordionItem.vue new file mode 100644 index 000000000..c162ee313 --- /dev/null +++ b/app/javascript/dashboard/components/Accordion/AccordionItem.vue @@ -0,0 +1,110 @@ + + + + + diff --git a/app/javascript/dashboard/components/ChatList.vue b/app/javascript/dashboard/components/ChatList.vue index 6d65ba2dd..5ca64d215 100644 --- a/app/javascript/dashboard/components/ChatList.vue +++ b/app/javascript/dashboard/components/ChatList.vue @@ -194,7 +194,7 @@ export default { }); }, methods: { - handleKeyEvents(e) { + getKeyboardListenerParams() { const allConversations = this.$refs.activeConversation.querySelectorAll( 'div.conversations-list div.conversation' ); @@ -205,7 +205,19 @@ export default { activeConversation ); const lastConversationIndex = allConversations.length - 1; + return { + allConversations, + activeConversation, + activeConversationIndex, + lastConversationIndex, + }; + }, + handleKeyEvents(e) { if (hasPressedAltAndJKey(e)) { + const { + allConversations, + activeConversationIndex, + } = this.getKeyboardListenerParams(); if (activeConversationIndex === -1) { allConversations[0].click(); } @@ -214,6 +226,11 @@ export default { } } if (hasPressedAltAndKKey(e)) { + const { + allConversations, + activeConversationIndex, + lastConversationIndex, + } = this.getKeyboardListenerParams(); if (activeConversationIndex === -1) { allConversations[lastConversationIndex].click(); } else if (activeConversationIndex < lastConversationIndex) { diff --git a/app/javascript/dashboard/components/Modal.vue b/app/javascript/dashboard/components/Modal.vue index 6332bd118..ad423f918 100644 --- a/app/javascript/dashboard/components/Modal.vue +++ b/app/javascript/dashboard/components/Modal.vue @@ -54,7 +54,7 @@ export default { }, mounted() { document.addEventListener('keydown', e => { - if (this.show && e.keyCode === 27) { + if (this.show && e.code === 'Escape') { this.onClose(); } }); diff --git a/app/javascript/dashboard/components/buttons/ResolveAction.vue b/app/javascript/dashboard/components/buttons/ResolveAction.vue index 819ca9097..637424815 100644 --- a/app/javascript/dashboard/components/buttons/ResolveAction.vue +++ b/app/javascript/dashboard/components/buttons/ResolveAction.vue @@ -176,7 +176,9 @@ export default { '.conversations-list .conversation' ); if (hasPressedAltAndMKey(e)) { - this.$refs.arrowDownButton.$el.click(); + if (this.$refs.arrowDownButton) { + this.$refs.arrowDownButton.$el.click(); + } } if (hasPressedAltAndEKey(e)) { const activeConversation = document.querySelector( @@ -198,6 +200,7 @@ export default { allConversations[0].click(); document.querySelector('.conversations-list').scrollTop = 0; } + e.preventDefault(); } } }, diff --git a/app/javascript/dashboard/components/layout/Sidebar.vue b/app/javascript/dashboard/components/layout/Sidebar.vue index 5db8e704d..07314e9c0 100644 --- a/app/javascript/dashboard/components/layout/Sidebar.vue +++ b/app/javascript/dashboard/components/layout/Sidebar.vue @@ -94,8 +94,16 @@ import AccountSelector from './sidebarComponents/AccountSelector.vue'; import AddAccountModal from './sidebarComponents/AddAccountModal.vue'; import AddLabelModal from '../../routes/dashboard/settings/labels/AddLabel'; import WootKeyShortcutModal from 'components/widgets/modal/WootKeyShortcutModal'; -import { hasPressedCommandAndForwardSlash } from 'shared/helpers/KeyboardHelpers'; +import { + hasPressedAltAndCKey, + hasPressedAltAndRKey, + hasPressedAltAndSKey, + hasPressedAltAndVKey, + hasPressedCommandAndForwardSlash, + isEscape, +} from 'shared/helpers/KeyboardHelpers'; import eventListenerMixins from 'shared/mixins/eventListenerMixins'; +import router from '../../routes'; export default { components: { @@ -252,19 +260,11 @@ export default { return frontendURL(`accounts/${this.accountId}/dashboard`); }, }, - watch: { - currentUser(newUserInfo, oldUserInfo) { - if (!oldUserInfo.email && newUserInfo.email) { - this.setChatwootUser(); - } - }, - }, mounted() { this.$store.dispatch('labels/get'); this.$store.dispatch('inboxes/get'); this.$store.dispatch('notifications/unReadCount'); this.$store.dispatch('teams/get'); - this.setChatwootUser(); }, methods: { @@ -278,21 +278,34 @@ export default { if (hasPressedCommandAndForwardSlash(e)) { this.toggleKeyShortcutModal(); } + if (isEscape(e)) { + this.closeKeyShortcutModal(); + } + + if (hasPressedAltAndCKey(e)) { + if (!this.isCurrentRouteSameAsNavigation('home')) { + router.push({ name: 'home' }); + } + } else if (hasPressedAltAndVKey(e)) { + if (!this.isCurrentRouteSameAsNavigation('contacts_dashboard')) { + router.push({ name: 'contacts_dashboard' }); + } + } else if (hasPressedAltAndRKey(e)) { + if (!this.isCurrentRouteSameAsNavigation('settings_account_reports')) { + router.push({ name: 'settings_account_reports' }); + } + } else if (hasPressedAltAndSKey(e)) { + if (!this.isCurrentRouteSameAsNavigation('agent_list')) { + router.push({ name: 'agent_list' }); + } + } + }, + isCurrentRouteSameAsNavigation(routeName) { + return router.currentRoute && router.currentRoute.name === routeName; }, toggleSupportChatWindow() { window.$chatwoot.toggle(); }, - setChatwootUser() { - if (!this.currentUser.email || !this.globalConfig.chatwootInboxToken) { - return; - } - window.$chatwoot.setUser(this.currentUser.email, { - name: this.currentUser.name, - email: this.currentUser.email, - avatar_url: this.currentUser.avatar_url, - identifier_hash: this.currentUser.hmac_identifier, - }); - }, filterMenuItemsByRole(menuItems) { if (!this.currentRole) { return []; diff --git a/app/javascript/dashboard/components/layout/SidebarItem.vue b/app/javascript/dashboard/components/layout/SidebarItem.vue index 1799a829a..462853e6a 100644 --- a/app/javascript/dashboard/components/layout/SidebarItem.vue +++ b/app/javascript/dashboard/components/layout/SidebarItem.vue @@ -33,7 +33,7 @@
@@ -59,17 +59,10 @@ import { mapGetters } from 'vuex'; import router from '../../routes'; -import { - hasPressedAltAndCKey, - hasPressedAltAndVKey, - hasPressedAltAndRKey, - hasPressedAltAndSKey, -} from 'shared/helpers/KeyboardHelpers'; import adminMixin from '../../mixins/isAdmin'; -import eventListenerMixins from 'shared/mixins/eventListenerMixins'; import { getInboxClassByType } from 'dashboard/helper/inbox'; export default { - mixins: [adminMixin, eventListenerMixins], + mixins: [adminMixin], props: { menuItem: { type: Object, @@ -124,20 +117,6 @@ export default { } } }, - handleKeyEvents(e) { - if (hasPressedAltAndCKey(e)) { - router.push({ name: 'home' }); - } - if (hasPressedAltAndVKey(e)) { - router.push({ name: 'contacts_dashboard' }); - } - if (hasPressedAltAndRKey(e)) { - router.push({ name: 'settings_account_reports' }); - } - if (hasPressedAltAndSKey(e)) { - router.push({ name: 'settings_home' }); - } - }, showItem(item) { return this.isAdmin && item.newLink !== undefined; }, diff --git a/app/javascript/dashboard/components/layout/sidebarComponents/OptionsMenu.vue b/app/javascript/dashboard/components/layout/sidebarComponents/OptionsMenu.vue index 52c1ede28..3fb66c14e 100644 --- a/app/javascript/dashboard/components/layout/sidebarComponents/OptionsMenu.vue +++ b/app/javascript/dashboard/components/layout/sidebarComponents/OptionsMenu.vue @@ -94,10 +94,6 @@ export default { methods: { logout() { Auth.logout(); - - if (this.globalConfig.chatwootInboxToken) { - window.$chatwoot.reset(); - } }, }, }; diff --git a/app/javascript/dashboard/components/widgets/ChannelItem.vue b/app/javascript/dashboard/components/widgets/ChannelItem.vue index e612fa14b..183f42c10 100644 --- a/app/javascript/dashboard/components/widgets/ChannelItem.vue +++ b/app/javascript/dashboard/components/widgets/ChannelItem.vue @@ -76,7 +76,16 @@ export default { if (key === 'email') { return this.enabledFeatures.channel_email; } - return ['website', 'twilio', 'api', 'whatsapp', 'sms'].includes(key); + + return [ + 'website', + 'twilio', + 'api', + 'whatsapp', + 'sms', + 'telegram', + 'line', + ].includes(key); }, }, methods: { diff --git a/app/javascript/dashboard/components/widgets/LabelSelector.vue b/app/javascript/dashboard/components/widgets/LabelSelector.vue index 8a6270bb8..c99647b30 100644 --- a/app/javascript/dashboard/components/widgets/LabelSelector.vue +++ b/app/javascript/dashboard/components/widgets/LabelSelector.vue @@ -1,33 +1,27 @@ @@ -60,6 +78,10 @@ export default { type: Number, default: () => 0, }, + popoutReplyBox: { + type: Boolean, + default: false, + }, }, computed: { replyButtonClass() { @@ -105,7 +127,7 @@ export default { display: flex; justify-content: space-between; - background: var(--b-100); + background: var(--b-50); } .button-group { @@ -167,4 +189,11 @@ export default { color: var(--s-600); } } + +.popout-button { + display: flex; + justify-content: flex-end; + height: auto; + padding-right: var(--space-normal); +} diff --git a/app/javascript/dashboard/components/widgets/conversation/ConversationBox.vue b/app/javascript/dashboard/components/widgets/conversation/ConversationBox.vue index ebdd03521..423e64280 100644 --- a/app/javascript/dashboard/components/widgets/conversation/ConversationBox.vue +++ b/app/javascript/dashboard/components/widgets/conversation/ConversationBox.vue @@ -119,7 +119,6 @@ export default { width: 100%; height: 100%; max-width: 100%; - padding: var(--space-normal) var(--space-two); } } diff --git a/app/javascript/dashboard/components/widgets/conversation/Message.vue b/app/javascript/dashboard/components/widgets/conversation/Message.vue index 3b716d9eb..e5de1b040 100644 --- a/app/javascript/dashboard/components/widgets/conversation/Message.vue +++ b/app/javascript/dashboard/components/widgets/conversation/Message.vue @@ -12,6 +12,7 @@ :message="message" :is-email="isEmailContentType" :readable-time="readableTime" + :display-quoted-button="displayQuotedButton" />
- -