diff --git a/.circleci/config.yml b/.circleci/config.yml index 974b981ab..87572fdc3 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: cimg/ruby:3.0.2-browsers + - image: cimg/ruby:3.0.4-browsers # Specify service dependencies here if necessary # CircleCI maintains a library of pre-built images @@ -40,14 +40,13 @@ jobs: - restore_cache: keys: - - chatwoot-bundle-{{ .Environment.CACHE_VERSION }}-{{ checksum "Gemfile.lock" }} - - chatwoot-bundle + - chatwoot-bundle-{{ .Environment.CACHE_VERSION }}-v20220524-{{ checksum "Gemfile.lock" }} - run: bundle install --frozen --path ~/.bundle - save_cache: paths: - ~/.bundle - key: chatwoot-bundle-{{ .Environment.CACHE_VERSION }}-{{ checksum "Gemfile.lock" }} + key: chatwoot-bundle-{{ .Environment.CACHE_VERSION }}-v20220524-{{ checksum "Gemfile.lock" }} # Only necessary if app uses webpacker or yarn in some other way diff --git a/.eslintrc.js b/.eslintrc.js index f30e21e03..e699f0fd4 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -19,18 +19,32 @@ module.exports = { 'jsx-a11y/label-has-for': 'off', 'jsx-a11y/anchor-is-valid': 'off', 'import/no-unresolved': 'off', - 'vue/max-attributes-per-line': ['error', { - 'singleline': 20, - 'multiline': { - 'max': 1, - 'allowFirstLine': false + 'vue/max-attributes-per-line': [ + 'error', + { + singleline: 20, + multiline: { + max: 1, + allowFirstLine: false, + }, }, - }], - 'vue/html-self-closing': 'off', - "vue/no-v-html": 'off', + ], + 'vue/html-self-closing': [ + 'error', + { + html: { + void: 'always', + normal: 'always', + component: 'always', + }, + svg: 'always', + math: 'always', + }, + ], + 'vue/no-v-html': 'off', 'vue/singleline-html-element-content-newline': 'off', 'import/extensions': ['off'], - 'no-console': 'error' + 'no-console': 'error', }, settings: { 'import/resolver': { @@ -41,12 +55,10 @@ module.exports = { }, env: { browser: true, - node: true, jest: true, - jasmine: true + node: true, }, globals: { - __WEBPACK_ENV__: true, bus: true, }, }; diff --git a/.github/workflows/run_foss_spec.yml b/.github/workflows/run_foss_spec.yml index 395e063e7..39df7aa3d 100644 --- a/.github/workflows/run_foss_spec.yml +++ b/.github/workflows/run_foss_spec.yml @@ -47,7 +47,7 @@ jobs: - uses: ruby/setup-ruby@v1 with: - ruby-version: 3.0.2 # Not needed with a .ruby-version file + ruby-version: 3.0.4 # Not needed with a .ruby-version file bundler-cache: true # runs 'bundle install' and caches installed gems automatically - name: yarn diff --git a/.ruby-version b/.ruby-version index b50214693..b0f2dcb32 100644 --- a/.ruby-version +++ b/.ruby-version @@ -1 +1 @@ -3.0.2 +3.0.4 diff --git a/Gemfile b/Gemfile index 55d02d87c..9029ccbcb 100644 --- a/Gemfile +++ b/Gemfile @@ -1,6 +1,6 @@ source 'https://rubygems.org' -ruby '3.0.2' +ruby '3.0.4' ##-- base gems for rails --## gem 'rack-cors', require: 'rack/cors' @@ -128,6 +128,9 @@ gem 'html2text' # to calculate working hours gem 'working_hours' +# full text search for articles +gem 'pg_search' + group :production, :staging do # we dont want request timing out in development while using byebug gem 'rack-timeout' @@ -156,11 +159,6 @@ group :test do end group :development, :test do - # TODO: is this needed ? - # errors thrown by devise password gem - gem 'flay' - gem 'rspec' - # for error thrown by devise password gem gem 'active_record_query_trace' gem 'bundle-audit', require: false gem 'byebug', platform: :mri diff --git a/Gemfile.lock b/Gemfile.lock index 253c35da6..cff30308b 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,6 +1,6 @@ GIT remote: https://github.com/chatwoot/devise-secure_password - revision: de11e8765654b8242d42101ee9c8ffc8126f7975 + revision: d777b04f12652d576b1272b8f39857e3e0b3fc26 specs: devise-secure_password (2.0.1) devise (>= 4.0.0, < 5.0.0) @@ -182,7 +182,6 @@ GEM regexp_parser (~> 2.2) email_reply_trimmer (0.1.13) erubi (1.10.0) - erubis (2.7.0) et-orbi (1.2.7) tzinfo execjs (2.8.1) @@ -204,11 +203,6 @@ GEM faraday (~> 1) ffi (1.15.5) flag_shih_tzu (0.3.23) - flay (2.12.1) - erubis (~> 2.7.0) - path_expander (~> 1.0) - ruby_parser (~> 3.0) - sexp_processor (~> 4.0) foreman (0.87.2) fugit (1.5.3) et-orbi (~> 1, >= 1.2.7) @@ -309,7 +303,7 @@ GEM jbuilder (2.11.5) actionview (>= 5.0.0) activesupport (>= 5.0.0) - jmespath (1.6.0) + jmespath (1.6.1) jquery-rails (4.4.0) rails-dom-testing (>= 1, < 3) railties (>= 4.2.0) @@ -378,14 +372,14 @@ GEM netrc (0.11.0) newrelic_rpm (8.7.0) nio4r (2.5.8) - nokogiri (1.13.5) + nokogiri (1.13.6) mini_portile2 (~> 2.8.0) racc (~> 1.4) - nokogiri (1.13.5-arm64-darwin) + nokogiri (1.13.6-arm64-darwin) racc (~> 1.4) - nokogiri (1.13.5-x86_64-darwin) + nokogiri (1.13.6-x86_64-darwin) racc (~> 1.4) - nokogiri (1.13.5-x86_64-linux) + nokogiri (1.13.6-x86_64-linux) racc (~> 1.4) oauth (0.5.8) orm_adapter (0.5.0) @@ -393,8 +387,10 @@ GEM parallel (1.21.0) parser (3.1.1.0) ast (~> 2.4.1) - path_expander (1.1.0) pg (1.3.2) + pg_search (2.3.6) + activerecord (>= 5.2) + activesupport (>= 5.2) procore-sift (0.16.0) rails (> 4.2.0) pry (0.14.1) @@ -409,7 +405,7 @@ GEM activesupport (>= 3.0.0) raabro (1.4.0) racc (1.6.0) - rack (2.2.3) + rack (2.2.3.1) rack-attack (6.6.0) rack (>= 1.0, < 3) rack-cors (1.1.1) @@ -468,10 +464,6 @@ GEM netrc (~> 0.8) retriable (3.1.2) rexml (3.2.5) - rspec (3.11.0) - rspec-core (~> 3.11.0) - rspec-expectations (~> 3.11.0) - rspec-mocks (~> 3.11.0) rspec-core (3.11.0) rspec-support (~> 3.11.0) rspec-expectations (3.11.0) @@ -681,7 +673,6 @@ DEPENDENCIES faker fcm flag_shih_tzu - flay foreman geocoder google-cloud-dialogflow @@ -706,6 +697,7 @@ DEPENDENCIES mock_redis newrelic_rpm pg + pg_search procore-sift pry-rails puma @@ -718,7 +710,6 @@ DEPENDENCIES redis-namespace responders rest-client - rspec rspec-rails (~> 5.0.0) rubocop rubocop-performance @@ -752,7 +743,7 @@ DEPENDENCIES working_hours RUBY VERSION - ruby 3.0.2p107 + ruby 3.0.4p208 BUNDLED WITH - 2.3.10 + 2.3.15 diff --git a/app/builders/messages/facebook/message_builder.rb b/app/builders/messages/facebook/message_builder.rb index 3b3248ed1..f19d3c8b7 100644 --- a/app/builders/messages/facebook/message_builder.rb +++ b/app/builders/messages/facebook/message_builder.rb @@ -27,7 +27,7 @@ class Messages::Facebook::MessageBuilder < Messages::Messenger::MessageBuilder end ensure_contact_avatar rescue Koala::Facebook::AuthenticationError - Rails.logger.error "Facebook Authorization expired for Inbox #{@inbox.id}" + @inbox.channel.authorization_error! rescue StandardError => e ChatwootExceptionTracker.new(e, account: @inbox.account).capture_exception true diff --git a/app/builders/messages/message_builder.rb b/app/builders/messages/message_builder.rb index 5c8cadbcd..e9bf0802b 100644 --- a/app/builders/messages/message_builder.rb +++ b/app/builders/messages/message_builder.rb @@ -73,6 +73,10 @@ class Messages::MessageBuilder @params[:campaign_id].present? ? { additional_attributes: { campaign_id: @params[:campaign_id] } } : {} end + def template_params + @params[:template_params].present? ? { additional_attributes: { template_params: JSON.parse(@params[:template_params].to_json) } } : {} + end + def message_sender return if @params[:sender_type] != 'AgentBot' @@ -91,6 +95,6 @@ class Messages::MessageBuilder items: @items, in_reply_to: @in_reply_to, echo_id: @params[:echo_id] - }.merge(external_created_at).merge(automation_rule_id).merge(campaign_id) + }.merge(external_created_at).merge(automation_rule_id).merge(campaign_id).merge(template_params) end end diff --git a/app/controllers/api/v1/accounts/articles_controller.rb b/app/controllers/api/v1/accounts/articles_controller.rb new file mode 100644 index 000000000..232eecd32 --- /dev/null +++ b/app/controllers/api/v1/accounts/articles_controller.rb @@ -0,0 +1,48 @@ +class Api::V1::Accounts::ArticlesController < Api::V1::Accounts::BaseController + before_action :portal + before_action :fetch_article, except: [:index, :create] + + def index + @articles = @portal.articles + @articles.search(list_params) if params[:payload].present? + end + + def create + @article = @portal.articles.create!(article_params) + end + + def edit; end + + def show; end + + def update + @article.update!(article_params) + end + + def destroy + @article.destroy! + head :ok + end + + private + + def fetch_article + @article = @portal.articles.find(params[:id]) + end + + def portal + @portal ||= Current.account.portals.find_by(slug: params[:portal_id]) + end + + def article_params + params.require(:article).permit( + :title, :content, :description, :position, :category_id, :author_id + ) + end + + def list_params + params.require(:payload).permit( + :category_slug, :locale, :query + ) + end +end diff --git a/app/controllers/api/v1/accounts/assignable_agents_controller.rb b/app/controllers/api/v1/accounts/assignable_agents_controller.rb new file mode 100644 index 000000000..a712342dd --- /dev/null +++ b/app/controllers/api/v1/accounts/assignable_agents_controller.rb @@ -0,0 +1,24 @@ +class Api::V1::Accounts::AssignableAgentsController < Api::V1::Accounts::BaseController + before_action :fetch_inboxes + + def index + agent_ids = @inboxes.map do |inbox| + authorize inbox, :show? + member_ids = inbox.members.pluck(:user_id) + member_ids + end + agent_ids = agent_ids.inject(:&) + agents = Current.account.users.where(id: agent_ids) + @assignable_agents = (agents + Current.account.administrators).uniq + end + + private + + def fetch_inboxes + @inboxes = Current.account.inboxes.find(permitted_params[:inbox_ids]) + end + + def permitted_params + params.permit(inbox_ids: []) + end +end diff --git a/app/controllers/api/v1/accounts/automation_rules_controller.rb b/app/controllers/api/v1/accounts/automation_rules_controller.rb index 5e649b6e0..859c8a4bf 100644 --- a/app/controllers/api/v1/accounts/automation_rules_controller.rb +++ b/app/controllers/api/v1/accounts/automation_rules_controller.rb @@ -9,6 +9,7 @@ class Api::V1::Accounts::AutomationRulesController < Api::V1::Accounts::BaseCont def create @automation_rule = Current.account.automation_rules.new(automation_rules_permit) @automation_rule.actions = params[:actions] + @automation_rule.conditions = params[:conditions] render json: { error: @automation_rule.errors.messages }, status: :unprocessable_entity and return unless @automation_rule.valid? @@ -31,9 +32,7 @@ class Api::V1::Accounts::AutomationRulesController < Api::V1::Accounts::BaseCont def update ActiveRecord::Base.transaction do - @automation_rule.update!(automation_rules_permit) - @automation_rule.actions = params[:actions] if params[:actions] - @automation_rule.save! + automation_rule_update process_attachments rescue StandardError => e @@ -67,10 +66,17 @@ class Api::V1::Accounts::AutomationRulesController < Api::V1::Accounts::BaseCont private + def automation_rule_update + @automation_rule.update!(automation_rules_permit) + @automation_rule.actions = params[:actions] if params[:actions] + @automation_rule.conditions = params[:conditions] if params[:conditions] + @automation_rule.save! + end + def automation_rules_permit params.permit( :name, :description, :event_name, :account_id, :active, - conditions: [:attribute_key, :filter_operator, :query_operator, { values: [] }], + conditions: [:attribute_key, :filter_operator, :query_operator, :custom_attribute_type, { values: [] }], actions: [:action_name, { action_params: [] }] ) end diff --git a/app/controllers/api/v1/accounts/kbase/categories_controller.rb b/app/controllers/api/v1/accounts/categories_controller.rb similarity index 59% rename from app/controllers/api/v1/accounts/kbase/categories_controller.rb rename to app/controllers/api/v1/accounts/categories_controller.rb index a40053dd2..a77f1fb2a 100644 --- a/app/controllers/api/v1/accounts/kbase/categories_controller.rb +++ b/app/controllers/api/v1/accounts/categories_controller.rb @@ -1,8 +1,9 @@ -class Api::V1::Accounts::Kbase::CategoriesController < Api::V1::Accounts::Kbase::BaseController +class Api::V1::Accounts::CategoriesController < Api::V1::Accounts::BaseController + before_action :portal before_action :fetch_category, except: [:index, :create] def index - @categories = @portal.categories + @categories = @portal.categories.search(params) end def create @@ -24,9 +25,13 @@ class Api::V1::Accounts::Kbase::CategoriesController < Api::V1::Accounts::Kbase: @category = @portal.categories.find(params[:id]) end + def portal + @portal ||= Current.account.portals.find_by(slug: params[:portal_id]) + end + def category_params params.require(:category).permit( - :name, :description, :position + :name, :description, :position, :slug, :locale ) end end diff --git a/app/controllers/api/v1/accounts/csat_survey_responses_controller.rb b/app/controllers/api/v1/accounts/csat_survey_responses_controller.rb index 347f028f7..7b5c51d6e 100644 --- a/app/controllers/api/v1/accounts/csat_survey_responses_controller.rb +++ b/app/controllers/api/v1/accounts/csat_survey_responses_controller.rb @@ -5,7 +5,7 @@ class Api::V1::Accounts::CsatSurveyResponsesController < Api::V1::Accounts::Base RESULTS_PER_PAGE = 25 before_action :check_authorization - before_action :set_csat_survey_responses, only: [:index, :metrics] + before_action :set_csat_survey_responses, only: [:index, :metrics, :download] before_action :set_current_page, only: [:index] before_action :set_current_page_surveys, only: [:index] before_action :set_total_sent_messages_count, only: [:metrics] @@ -19,6 +19,12 @@ class Api::V1::Accounts::CsatSurveyResponsesController < Api::V1::Accounts::Base @ratings_count = @csat_survey_responses.group(:rating).count end + def download + response.headers['Content-Type'] = 'text/csv' + response.headers['Content-Disposition'] = 'attachment; filename=csat_report.csv' + render layout: false, template: 'api/v1/accounts/csat_survey_responses/download.csv.erb', format: 'csv' + end + private def set_total_sent_messages_count diff --git a/app/controllers/api/v1/accounts/dashboard_apps_controller.rb b/app/controllers/api/v1/accounts/dashboard_apps_controller.rb new file mode 100644 index 000000000..a8d7ebcb9 --- /dev/null +++ b/app/controllers/api/v1/accounts/dashboard_apps_controller.rb @@ -0,0 +1,44 @@ +class Api::V1::Accounts::DashboardAppsController < Api::V1::Accounts::BaseController + before_action :fetch_dashboard_apps, except: [:create] + before_action :fetch_dashboard_app, only: [:show, :update, :destroy] + + def index; end + + def show; end + + def create + @dashboard_app = Current.account.dashboard_apps.create!( + permitted_payload.merge(user_id: Current.user.id) + ) + end + + def update + @dashboard_app.update!(permitted_payload) + end + + def destroy + @dashboard_app.destroy! + head :no_content + end + + private + + def fetch_dashboard_apps + @dashboard_apps = Current.account.dashboard_apps + end + + def fetch_dashboard_app + @dashboard_app = @dashboard_apps.find(permitted_params[:id]) + end + + def permitted_payload + params.require(:dashboard_app).permit( + :title, + content: [:url, :type] + ) + end + + def permitted_params + params.permit(:id) + end +end diff --git a/app/controllers/api/v1/accounts/inboxes_controller.rb b/app/controllers/api/v1/accounts/inboxes_controller.rb index 66a71985d..95662e29b 100644 --- a/app/controllers/api/v1/accounts/inboxes_controller.rb +++ b/app/controllers/api/v1/accounts/inboxes_controller.rb @@ -12,6 +12,7 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController def show; end + # Deprecated: This API will be removed in 2.7.0 def assignable_agents @assignable_agents = (Current.account.users.where(id: @inbox.members.select(:user_id)) + Current.account.administrators).uniq end @@ -41,15 +42,19 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController end def update - @inbox.update(permitted_params.except(:channel)) - @inbox.update_working_hours(params.permit(working_hours: Inbox::OFFISABLE_ATTRS)[:working_hours]) if params[:working_hours] + @inbox.update!(permitted_params.except(:channel)) + update_inbox_working_hours channel_attributes = get_channel_attributes(@inbox.channel_type) # Inbox update doesn't necessarily need channel attributes return if permitted_params(channel_attributes)[:channel].blank? if @inbox.inbox_type == 'Email' - validate_email_channel(channel_attributes) + begin + validate_email_channel(channel_attributes) + rescue StandardError => e + render json: { message: e }, status: :unprocessable_entity and return + end @inbox.channel.reauthorized! end @@ -57,6 +62,10 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController update_channel_feature_flags end + def update_inbox_working_hours + @inbox.update_working_hours(params.permit(working_hours: Inbox::OFFISABLE_ATTRS)[:working_hours]) if params[:working_hours] + end + def agent_bot @agent_bot = @inbox.agent_bot end @@ -88,12 +97,6 @@ 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 return unless %w[web_widget api email line telegram whatsapp sms].include?(permitted_params[:channel][:type]) @@ -108,10 +111,14 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController @inbox.channel.save! end + def inbox_attributes + [:name, :avatar, :greeting_enabled, :greeting_message, :enable_email_collect, :csat_survey_enabled, + :enable_auto_assignment, :working_hours_enabled, :out_of_office_message, :timezone, :allow_messages_after_resolved] + end + 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, :allow_messages_after_resolved, + *inbox_attributes, channel: [:type, *channel_attributes] ) end @@ -128,18 +135,6 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController }[permitted_params[:channel][:type]] end - def account_channels_method - { - 'web_widget' => Current.account.web_widgets, - 'api' => Current.account.api_channels, - 'email' => Current.account.email_channels, - 'line' => Current.account.line_channels, - 'telegram' => Current.account.telegram_channels, - 'whatsapp' => Current.account.whatsapp_channels, - 'sms' => Current.account.sms_channels - }[permitted_params[:channel][:type]] - end - def get_channel_attributes(channel_type) if channel_type.constantize.const_defined?(:EDITABLE_ATTRS) channel_type.constantize::EDITABLE_ATTRS.presence @@ -147,10 +142,6 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController [] end end - - def validate_limit - return unless Current.account.inboxes.count >= Current.account.usage_limits[:inboxes] - - render_payment_required('Account limit exceeded. Upgrade to a higher plan') - end end + +Api::V1::Accounts::InboxesController.prepend_mod_with('Api::V1::Accounts::InboxesController') diff --git a/app/controllers/api/v1/accounts/kbase/base_controller.rb b/app/controllers/api/v1/accounts/kbase/base_controller.rb deleted file mode 100644 index 4f62cd858..000000000 --- a/app/controllers/api/v1/accounts/kbase/base_controller.rb +++ /dev/null @@ -1,9 +0,0 @@ -class Api::V1::Accounts::Kbase::BaseController < Api::V1::Accounts::BaseController - before_action :portal - - private - - def portal - @portal ||= Current.account.kbase_portals.find_by(slug: params[:portal_id]) - end -end diff --git a/app/controllers/api/v1/accounts/kbase/portals_controller.rb b/app/controllers/api/v1/accounts/portals_controller.rb similarity index 58% rename from app/controllers/api/v1/accounts/kbase/portals_controller.rb rename to app/controllers/api/v1/accounts/portals_controller.rb index 5ec1b4a83..75ffc35e9 100644 --- a/app/controllers/api/v1/accounts/kbase/portals_controller.rb +++ b/app/controllers/api/v1/accounts/portals_controller.rb @@ -1,14 +1,14 @@ -class Api::V1::Accounts::Kbase::PortalsController < Api::V1::Accounts::BaseController +class Api::V1::Accounts::PortalsController < Api::V1::Accounts::BaseController before_action :fetch_portal, except: [:index, :create] def index - @portals = Current.account.kbase_portals + @portals = Current.account.portals end def show; end def create - @portal = Current.account.kbase_portals.create!(portal_params) + @portal = Current.account.portals.create!(portal_params) end def update @@ -23,7 +23,7 @@ class Api::V1::Accounts::Kbase::PortalsController < Api::V1::Accounts::BaseContr private def fetch_portal - @portal = Current.account.kbase_portals.find_by(slug: permitted_params[:id]) + @portal = Current.account.portals.find_by(slug: permitted_params[:id]) end def permitted_params @@ -32,7 +32,7 @@ class Api::V1::Accounts::Kbase::PortalsController < Api::V1::Accounts::BaseContr def portal_params params.require(:portal).permit( - :account_id, :color, :custom_domain, :header_text, :homepage_link, :name, :page_title, :slug + :account_id, :color, :custom_domain, :header_text, :homepage_link, :name, :page_title, :slug, :archived ) end end diff --git a/app/controllers/api/v1/widget/base_controller.rb b/app/controllers/api/v1/widget/base_controller.rb index 646d70acf..fc4d33fc3 100644 --- a/app/controllers/api/v1/widget/base_controller.rb +++ b/app/controllers/api/v1/widget/base_controller.rb @@ -54,6 +54,7 @@ class Api::V1::Widget::BaseController < ApplicationController ).perform else @contact.update!(email: email) + update_contact_name end end @@ -67,9 +68,14 @@ class Api::V1::Widget::BaseController < ApplicationController ).perform else @contact.update!(phone_number: phone_number) + update_contact_name end end + def update_contact_name + @contact.update!(name: contact_name) if contact_name.present? + end + def contact_email permitted_params.dig(:contact, :email)&.downcase end diff --git a/app/controllers/api/v1/widget/conversations_controller.rb b/app/controllers/api/v1/widget/conversations_controller.rb index cfe107ac6..8d054229c 100644 --- a/app/controllers/api/v1/widget/conversations_controller.rb +++ b/app/controllers/api/v1/widget/conversations_controller.rb @@ -16,7 +16,6 @@ class Api::V1::Widget::ConversationsController < Api::V1::Widget::BaseController def process_update_contact update_contact(contact_email) if @contact.email.blank? && contact_email.present? update_contact_phone_number(contact_phone_number) if @contact.phone_number.blank? && contact_phone_number.present? - @contact.update!(name: contact_name) if contact_name.present? end def update_last_seen diff --git a/app/controllers/api/v2/accounts/reports_controller.rb b/app/controllers/api/v2/accounts/reports_controller.rb index c2117583a..bbc4dde7b 100644 --- a/app/controllers/api/v2/accounts/reports_controller.rb +++ b/app/controllers/api/v2/accounts/reports_controller.rb @@ -1,4 +1,5 @@ class Api::V2::Accounts::ReportsController < Api::V1::Accounts::BaseController + include Api::V2::Accounts::ReportsHelper before_action :check_authorization def index @@ -12,27 +13,23 @@ class Api::V2::Accounts::ReportsController < Api::V1::Accounts::BaseController end def agents - response.headers['Content-Type'] = 'text/csv' - response.headers['Content-Disposition'] = 'attachment; filename=agents_report.csv' - render layout: false, template: 'api/v2/accounts/reports/agents.csv.erb', format: 'csv' + @report_data = generate_agents_report + generate_csv('agents_report', 'api/v2/accounts/reports/agents.csv.erb') end def inboxes - response.headers['Content-Type'] = 'text/csv' - response.headers['Content-Disposition'] = 'attachment; filename=inboxes_report.csv' - render layout: false, template: 'api/v2/accounts/reports/inboxes.csv.erb', format: 'csv' + @report_data = generate_inboxes_report + generate_csv('inboxes_report', 'api/v2/accounts/reports/inboxes.csv.erb') 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' + @report_data = generate_labels_report + generate_csv('labels_report', 'api/v2/accounts/reports/labels.csv.erb') end def teams - response.headers['Content-Type'] = 'text/csv' - response.headers['Content-Disposition'] = 'attachment; filename=teams_report.csv' - render layout: false, template: 'api/v2/accounts/reports/teams.csv.erb', format: 'csv' + @report_data = generate_teams_report + generate_csv('teams_report', 'api/v2/accounts/reports/teams.csv.erb') end def conversations @@ -43,6 +40,12 @@ class Api::V2::Accounts::ReportsController < Api::V1::Accounts::BaseController private + def generate_csv(filename, template) + response.headers['Content-Type'] = 'text/csv' + response.headers['Content-Disposition'] = "attachment; filename=#{filename}.csv" + render layout: false, template: template, format: 'csv' + end + def check_authorization raise Pundit::NotAuthorizedError unless Current.account_user.administrator? end diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index 0a1c672ec..0762c3d91 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -1,7 +1,7 @@ class ApplicationController < ActionController::Base include DeviseTokenAuth::Concerns::SetUserByToken include RequestExceptionHandler - include Pundit + include Pundit::Authorization include SwitchLocale skip_before_action :verify_authenticity_token diff --git a/app/controllers/dashboard_controller.rb b/app/controllers/dashboard_controller.rb index 107aec56e..36c789b52 100644 --- a/app/controllers/dashboard_controller.rb +++ b/app/controllers/dashboard_controller.rb @@ -43,7 +43,8 @@ class DashboardController < ActionController::Base VAPID_PUBLIC_KEY: VapidService.public_key, ENABLE_ACCOUNT_SIGNUP: GlobalConfigService.load('ENABLE_ACCOUNT_SIGNUP', 'false'), FB_APP_ID: GlobalConfigService.load('FB_APP_ID', ''), - FACEBOOK_API_VERSION: 'v13.0' + FACEBOOK_API_VERSION: 'v14.0', + IS_ENTERPRISE: ChatwootApp.enterprise? } end end diff --git a/app/finders/conversation_finder.rb b/app/finders/conversation_finder.rb index 58013f128..ecbccfb88 100644 --- a/app/finders/conversation_finder.rb +++ b/app/finders/conversation_finder.rb @@ -51,6 +51,7 @@ class ConversationFinder filter_by_team if @team filter_by_labels if params[:labels] filter_by_query if params[:q] + filter_by_reply_status end def set_inboxes @@ -90,6 +91,10 @@ class ConversationFinder @conversations end + def filter_by_reply_status + @conversations = @conversations.where(first_reply_created_at: nil) if params[:reply_status] == 'unattended' + end + def filter_by_query allowed_message_types = [Message.message_types[:incoming], Message.message_types[:outgoing]] @conversations = conversations.joins(:messages).where('messages.content ILIKE :search', search: "%#{params[:q]}%") diff --git a/app/helpers/api/v1/inboxes_helper.rb b/app/helpers/api/v1/inboxes_helper.rb index 56fb79908..aa28e4ccc 100644 --- a/app/helpers/api/v1/inboxes_helper.rb +++ b/app/helpers/api/v1/inboxes_helper.rb @@ -1,4 +1,10 @@ module Api::V1::InboxesHelper + def inbox_name(channel) + return channel.try(:bot_name) if channel.is_a?(Channel::Telegram) + + permitted_params[:name] + end + def validate_email_channel(attributes) channel_data = permitted_params(attributes)[:channel] @@ -19,8 +25,7 @@ module Api::V1::InboxesHelper enable_ssl: channel_data[:imap_enable_ssl] } end - Mail.connection do # rubocop:disable:block - end + check_imap_connection(channel_data) end def validate_smtp(channel_data) @@ -32,6 +37,25 @@ module Api::V1::InboxesHelper check_smtp_connection(channel_data, smtp) end + def check_imap_connection(channel_data) + Mail.connection {} # rubocop:disable:block + rescue SocketError => e + raise StandardError, I18n.t('errors.inboxes.imap.socket_error') + rescue Net::IMAP::NoResponseError => e + raise StandardError, I18n.t('errors.inboxes.imap.no_response_error') + rescue Errno::EHOSTUNREACH => e + raise StandardError, I18n.t('errors.inboxes.imap.host_unreachable_error') + rescue Net::OpenTimeout => e + raise StandardError, + I18n.t('errors.inboxes.imap.connection_timed_out_error', address: channel_data[:imap_address], port: channel_data[:imap_port]) + rescue Net::IMAP::Error => e + raise StandardError, I18n.t('errors.inboxes.imap.connection_closed_error') + rescue StandardError => e + raise StandardError, e.message + ensure + Rails.logger.error e if e.present? + end + def check_smtp_connection(channel_data, smtp) smtp.start(channel_data[:smtp_domain], channel_data[:smtp_login], channel_data[:smtp_password], channel_data[:smtp_authentication]&.to_sym || :login) @@ -74,4 +98,22 @@ module Api::V1::InboxesHelper context.verify_mode = openssl_verify_mode context end + + def account_channels_method + { + 'web_widget' => Current.account.web_widgets, + 'api' => Current.account.api_channels, + 'email' => Current.account.email_channels, + 'line' => Current.account.line_channels, + 'telegram' => Current.account.telegram_channels, + 'whatsapp' => Current.account.whatsapp_channels, + 'sms' => Current.account.sms_channels + }[permitted_params[:channel][:type]] + end + + def validate_limit + return unless Current.account.inboxes.count >= Current.account.usage_limits[:inboxes] + + render_payment_required('Account limit exceeded. Upgrade to a higher plan') + end end diff --git a/app/helpers/api/v2/accounts/reports_helper.rb b/app/helpers/api/v2/accounts/reports_helper.rb new file mode 100644 index 000000000..0604eba2f --- /dev/null +++ b/app/helpers/api/v2/accounts/reports_helper.rb @@ -0,0 +1,56 @@ +module Api::V2::Accounts::ReportsHelper + def generate_agents_report + Current.account.users.map do |agent| + agent_report = generate_report({ type: :agent, id: agent.id }) + [agent.name] + generate_readable_report_metrics(agent_report) + end + end + + def generate_inboxes_report + Current.account.inboxes.map do |inbox| + inbox_report = generate_report({ type: :inbox, id: inbox.id }) + [inbox.name, inbox.channel&.name] + generate_readable_report_metrics(inbox_report) + end + end + + def generate_teams_report + Current.account.teams.map do |team| + team_report = generate_report({ type: :team, id: team.id }) + [team.name] + generate_readable_report_metrics(team_report) + end + end + + def generate_labels_report + Current.account.labels.map do |label| + label_report = generate_report({ type: :label, id: label.id }) + [label.title] + generate_readable_report_metrics(label_report) + end + end + + def generate_report(report_params) + V2::ReportBuilder.new( + Current.account, + report_params.merge( + { + since: params[:since], + until: params[:until], + business_hours: ActiveModel::Type::Boolean.new.cast(params[:business_hours]) + } + ) + ).summary + end + + private + + def generate_readable_report_metrics(report_metric) + [ + report_metric[:conversations_count], + time_to_minutes(report_metric[:avg_first_response_time]), + time_to_minutes(report_metric[:avg_resolution_time]) + ] + end + + def time_to_minutes(time_in_seconds) + (time_in_seconds / 60).to_i + end +end diff --git a/app/javascript/dashboard/App.vue b/app/javascript/dashboard/App.vue index 1e262f1ff..7243e566e 100644 --- a/app/javascript/dashboard/App.vue +++ b/app/javascript/dashboard/App.vue @@ -2,7 +2,7 @@
- + { let payload; if (files && files.length !== 0) { @@ -32,6 +33,7 @@ export const buildCreatePayload = ({ content_attributes: contentAttributes, cc_emails: ccEmails, bcc_emails: bccEmails, + template_params: templateParams, }; } return payload; @@ -51,6 +53,7 @@ class MessageApi extends ApiClient { files, ccEmails = '', bccEmails = '', + templateParams, }) { return axios({ method: 'post', @@ -63,6 +66,7 @@ class MessageApi extends ApiClient { files, ccEmails, bccEmails, + templateParams, }), }); } diff --git a/app/javascript/dashboard/api/inboxes.js b/app/javascript/dashboard/api/inboxes.js index 1cf6ba113..a76ef1414 100644 --- a/app/javascript/dashboard/api/inboxes.js +++ b/app/javascript/dashboard/api/inboxes.js @@ -6,10 +6,6 @@ class Inboxes extends ApiClient { super('inboxes', { accountScoped: true }); } - getAssignableAgents(inboxId) { - return axios.get(`${this.url}/${inboxId}/assignable_agents`); - } - getCampaigns(inboxId) { return axios.get(`${this.url}/${inboxId}/campaigns`); } diff --git a/app/javascript/dashboard/api/reports.js b/app/javascript/dashboard/api/reports.js index ca50c062f..61fd0adca 100644 --- a/app/javascript/dashboard/api/reports.js +++ b/app/javascript/dashboard/api/reports.js @@ -53,27 +53,27 @@ class ReportsAPI extends ApiClient { }); } - getAgentReports(since, until) { + getAgentReports({ from: since, to: until, businessHours }) { return axios.get(`${this.url}/agents`, { - params: { since, until }, + params: { since, until, business_hours: businessHours }, }); } - getLabelReports(since, until) { + getLabelReports({ from: since, to: until, businessHours }) { return axios.get(`${this.url}/labels`, { - params: { since, until }, + params: { since, until, business_hours: businessHours }, }); } - getInboxReports(since, until) { + getInboxReports({ from: since, to: until, businessHours }) { return axios.get(`${this.url}/inboxes`, { - params: { since, until }, + params: { since, until, business_hours: businessHours }, }); } - getTeamReports(since, until) { + getTeamReports({ from: since, to: until, businessHours }) { return axios.get(`${this.url}/teams`, { - params: { since, until }, + params: { since, until, business_hours: businessHours }, }); } } diff --git a/app/javascript/dashboard/api/specs/assignableAgents.spec.js b/app/javascript/dashboard/api/specs/assignableAgents.spec.js new file mode 100644 index 000000000..6c9c5ec9a --- /dev/null +++ b/app/javascript/dashboard/api/specs/assignableAgents.spec.js @@ -0,0 +1,18 @@ +import assignableAgentsAPI from '../assignableAgents'; +import describeWithAPIMock from './apiSpecHelper'; + +describe('#AssignableAgentsAPI', () => { + describeWithAPIMock('API calls', context => { + it('#getAssignableAgents', () => { + assignableAgentsAPI.get([1]); + expect(context.axiosMock.get).toHaveBeenCalledWith( + '/api/v1/assignable_agents', + { + params: { + inbox_ids: [1], + }, + } + ); + }); + }); +}); diff --git a/app/javascript/dashboard/api/specs/bulkAction.spec.js b/app/javascript/dashboard/api/specs/bulkAction.spec.js new file mode 100644 index 000000000..aec0b1e3e --- /dev/null +++ b/app/javascript/dashboard/api/specs/bulkAction.spec.js @@ -0,0 +1,9 @@ +import bulkActions from '../bulkActions'; +import ApiClient from '../ApiClient'; + +describe('#BulkActionsAPI', () => { + it('creates correct instance', () => { + expect(bulkActions).toBeInstanceOf(ApiClient); + expect(bulkActions).toHaveProperty('create'); + }); +}); diff --git a/app/javascript/dashboard/api/specs/csatReports.spec.js b/app/javascript/dashboard/api/specs/csatReports.spec.js index 0022a91ad..a1d6e50f2 100644 --- a/app/javascript/dashboard/api/specs/csatReports.spec.js +++ b/app/javascript/dashboard/api/specs/csatReports.spec.js @@ -33,5 +33,23 @@ describe('#Reports API', () => { } ); }); + it('#download', () => { + csatReportsAPI.download({ + from: 1622485800, + to: 1623695400, + user_ids: 1, + }); + expect(context.axiosMock.get).toHaveBeenCalledWith( + '/api/v1/csat_survey_responses/download', + { + params: { + since: 1622485800, + until: 1623695400, + user_ids: 1, + sort: '-created_at', + }, + } + ); + }); }); }); diff --git a/app/javascript/dashboard/api/specs/dashboardApps.spec.js b/app/javascript/dashboard/api/specs/dashboardApps.spec.js new file mode 100644 index 000000000..f3196ae2a --- /dev/null +++ b/app/javascript/dashboard/api/specs/dashboardApps.spec.js @@ -0,0 +1,13 @@ +import dashboardAppsAPI from '../dashboardApps'; +import ApiClient from '../ApiClient'; + +describe('#dashboardAppsAPI', () => { + it('creates correct instance', () => { + expect(dashboardAppsAPI).toBeInstanceOf(ApiClient); + expect(dashboardAppsAPI).toHaveProperty('get'); + expect(dashboardAppsAPI).toHaveProperty('show'); + expect(dashboardAppsAPI).toHaveProperty('create'); + expect(dashboardAppsAPI).toHaveProperty('update'); + expect(dashboardAppsAPI).toHaveProperty('delete'); + }); +}); diff --git a/app/javascript/dashboard/api/specs/inboxes.spec.js b/app/javascript/dashboard/api/specs/inboxes.spec.js index b261bb930..0d755d44a 100644 --- a/app/javascript/dashboard/api/specs/inboxes.spec.js +++ b/app/javascript/dashboard/api/specs/inboxes.spec.js @@ -10,17 +10,9 @@ describe('#InboxesAPI', () => { expect(inboxesAPI).toHaveProperty('create'); expect(inboxesAPI).toHaveProperty('update'); expect(inboxesAPI).toHaveProperty('delete'); - expect(inboxesAPI).toHaveProperty('getAssignableAgents'); expect(inboxesAPI).toHaveProperty('getCampaigns'); }); describeWithAPIMock('API calls', context => { - it('#getAssignableAgents', () => { - inboxesAPI.getAssignableAgents(1); - expect(context.axiosMock.get).toHaveBeenCalledWith( - '/api/v1/inboxes/1/assignable_agents' - ); - }); - it('#getCampaigns', () => { inboxesAPI.getCampaigns(2); expect(context.axiosMock.get).toHaveBeenCalledWith( diff --git a/app/javascript/dashboard/api/specs/reports.spec.js b/app/javascript/dashboard/api/specs/reports.spec.js index b51c87db5..4e2a8f38f 100644 --- a/app/javascript/dashboard/api/specs/reports.spec.js +++ b/app/javascript/dashboard/api/specs/reports.spec.js @@ -47,20 +47,25 @@ describe('#Reports API', () => { }); it('#getAgentReports', () => { - reportsAPI.getAgentReports(1621103400, 1621621800); + reportsAPI.getAgentReports({ + from: 1621103400, + to: 1621621800, + businessHours: true, + }); expect(context.axiosMock.get).toHaveBeenCalledWith( '/api/v2/reports/agents', { params: { since: 1621103400, until: 1621621800, + business_hours: true, }, } ); }); it('#getLabelReports', () => { - reportsAPI.getLabelReports(1621103400, 1621621800); + reportsAPI.getLabelReports({ from: 1621103400, to: 1621621800 }); expect(context.axiosMock.get).toHaveBeenCalledWith( '/api/v2/reports/labels', { @@ -73,7 +78,7 @@ describe('#Reports API', () => { }); it('#getInboxReports', () => { - reportsAPI.getInboxReports(1621103400, 1621621800); + reportsAPI.getInboxReports({ from: 1621103400, to: 1621621800 }); expect(context.axiosMock.get).toHaveBeenCalledWith( '/api/v2/reports/inboxes', { @@ -86,7 +91,7 @@ describe('#Reports API', () => { }); it('#getTeamReports', () => { - reportsAPI.getTeamReports(1621103400, 1621621800); + reportsAPI.getTeamReports({ from: 1621103400, to: 1621621800 }); expect(context.axiosMock.get).toHaveBeenCalledWith( '/api/v2/reports/teams', { diff --git a/app/javascript/dashboard/assets/scss/_utility-helpers.scss b/app/javascript/dashboard/assets/scss/_utility-helpers.scss index 71977cf2b..4120af396 100644 --- a/app/javascript/dashboard/assets/scss/_utility-helpers.scss +++ b/app/javascript/dashboard/assets/scss/_utility-helpers.scss @@ -60,3 +60,9 @@ text-overflow: ellipsis; white-space: nowrap; } + +.flex-between { + align-items: center; + display: flex; + justify-content: space-between; +} diff --git a/app/javascript/dashboard/components/ChatList.vue b/app/javascript/dashboard/components/ChatList.vue index deb0276bf..d1ac4945f 100644 --- a/app/javascript/dashboard/components/ChatList.vue +++ b/app/javascript/dashboard/components/ChatList.vue @@ -1,6 +1,6 @@ diff --git a/app/javascript/dashboard/components/NetworkNotification.vue b/app/javascript/dashboard/components/NetworkNotification.vue index 115a674a2..6f29dbb74 100644 --- a/app/javascript/dashboard/components/NetworkNotification.vue +++ b/app/javascript/dashboard/components/NetworkNotification.vue @@ -20,8 +20,7 @@ color-scheme="warning" icon="dismiss-circle" @click="closeNotification" - > - + />
diff --git a/app/javascript/dashboard/components/SettingsSection.vue b/app/javascript/dashboard/components/SettingsSection.vue index e700e9467..b3348ae04 100644 --- a/app/javascript/dashboard/components/SettingsSection.vue +++ b/app/javascript/dashboard/components/SettingsSection.vue @@ -13,7 +13,7 @@

- +
diff --git a/app/javascript/dashboard/components/buttons/Button.vue b/app/javascript/dashboard/components/buttons/Button.vue index 69dbc3658..733b84639 100644 --- a/app/javascript/dashboard/components/buttons/Button.vue +++ b/app/javascript/dashboard/components/buttons/Button.vue @@ -7,7 +7,7 @@ :icon="icon" /> - + diff --git a/app/javascript/dashboard/components/layout/sidebarComponents/Secondary.vue b/app/javascript/dashboard/components/layout/sidebarComponents/Secondary.vue index 724576347..4b19a3ec3 100644 --- a/app/javascript/dashboard/components/layout/sidebarComponents/Secondary.vue +++ b/app/javascript/dashboard/components/layout/sidebarComponents/Secondary.vue @@ -95,6 +95,7 @@ export default { ), type: inbox.channel_type, phoneNumber: inbox.phone_number, + reauthorizationRequired: inbox.reauthorization_required, })) .sort((a, b) => a.label.toLowerCase() > b.label.toLowerCase() ? 1 : -1 diff --git a/app/javascript/dashboard/components/layout/sidebarComponents/SecondaryChildNavItem.vue b/app/javascript/dashboard/components/layout/sidebarComponents/SecondaryChildNavItem.vue index 7bf57df1a..4d2601774 100644 --- a/app/javascript/dashboard/components/layout/sidebarComponents/SecondaryChildNavItem.vue +++ b/app/javascript/dashboard/components/layout/sidebarComponents/SecondaryChildNavItem.vue @@ -30,6 +30,14 @@ {{ count }} + + + @@ -57,6 +65,10 @@ export default { type: String, default: '', }, + warningIcon: { + type: String, + default: '', + }, count: { type: String, default: '', diff --git a/app/javascript/dashboard/components/layout/sidebarComponents/SecondaryNavItem.vue b/app/javascript/dashboard/components/layout/sidebarComponents/SecondaryNavItem.vue index ffd8018d5..1fcde8948 100644 --- a/app/javascript/dashboard/components/layout/sidebarComponents/SecondaryNavItem.vue +++ b/app/javascript/dashboard/components/layout/sidebarComponents/SecondaryNavItem.vue @@ -34,6 +34,7 @@ :label-color="child.color" :should-truncate="child.truncateLabel" :icon="computedInboxClass(child)" + :warning-icon="computedInboxErrorClass(child)" /> - + /> diff --git a/app/javascript/dashboard/components/ui/Switch.vue b/app/javascript/dashboard/components/ui/Switch.vue index b0bc32881..e15258c22 100644 --- a/app/javascript/dashboard/components/ui/Switch.vue +++ b/app/javascript/dashboard/components/ui/Switch.vue @@ -7,7 +7,7 @@ :aria-checked="value.toString()" @click="onClick" > - +