diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 1015fe997..499e5c120 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -1,7 +1,2 @@ -## All javascript files should be reviewed by pranav before merging -*.js @pranavrajs -*.vue @pranavrajs - - ## All enterprise related files should be reviewed by sojan before merging /enterprise/* @sojan-official diff --git a/Gemfile b/Gemfile index 59bfc5d7f..0dac6e95f 100644 --- a/Gemfile +++ b/Gemfile @@ -69,7 +69,7 @@ gem 'webpacker' gem 'barnes' ##--- gems for authentication & authorization ---## -gem 'devise', '>= 4.9.3' +gem 'devise', '>= 4.9.4' gem 'devise-secure_password', git: 'https://github.com/chatwoot/devise-secure_password', branch: 'chatwoot' gem 'devise_token_auth' # authorization @@ -165,7 +165,7 @@ gem 'audited', '~> 5.4', '>= 5.4.1' # need for google auth gem 'omniauth', '>= 2.1.2' -gem 'omniauth-google-oauth2' +gem 'omniauth-google-oauth2', '>= 1.1.2' gem 'omniauth-rails_csrf_protection', '~> 1.0' ## Gems for reponse bot @@ -203,7 +203,7 @@ group :development do gem 'rack-mini-profiler', '>= 3.2.0', require: false gem 'stackprof' # Should install the associated chrome extension to view query logs - gem 'meta_request' + gem 'meta_request', '>= 0.8.0' end group :test do diff --git a/Gemfile.lock b/Gemfile.lock index 41cb0f20b..55021a04b 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -149,7 +149,7 @@ GEM multi_json (~> 1) statsd-ruby (~> 1.1) base64 (0.1.1) - bcrypt (3.1.19) + bcrypt (3.1.20) bindex (0.8.1) blingfire (0.1.8) bootsnap (1.16.0) @@ -194,7 +194,7 @@ GEM irb (>= 1.5.0) reline (>= 0.3.1) declarative (0.0.20) - devise (4.9.3) + devise (4.9.4) bcrypt (~> 3.0) orm_adapter (~> 0.1) railties (>= 4.1.0) @@ -237,9 +237,8 @@ GEM railties (>= 5.0.0) faker (3.2.0) i18n (>= 1.8.11, < 2) - faraday (2.7.4) - faraday-net_http (>= 2.0, < 3.1) - ruby2_keywords (>= 0.0.4) + faraday (2.9.0) + faraday-net_http (>= 2.0, < 3.2) faraday-follow_redirects (0.3.0) faraday (>= 1, < 3) faraday-mashify (0.1.1) @@ -247,7 +246,8 @@ GEM hashie faraday-multipart (1.0.4) multipart-post (~> 2) - faraday-net_http (3.0.2) + faraday-net_http (3.1.0) + net-http faraday-net_http_persistent (2.1.0) faraday (~> 2.5) net-http-persistent (~> 4.0) @@ -366,7 +366,7 @@ GEM mini_mime (>= 1.0.0) multi_xml (>= 0.5.2) httpclient (2.8.3) - i18n (1.14.1) + i18n (1.14.4) concurrent-ruby (~> 1.0) image_processing (1.12.2) mini_magick (>= 4.9.5, < 5) @@ -394,7 +394,8 @@ GEM hana (~> 1.3) regexp_parser (~> 2.0) uri_template (~> 0.7) - jwt (2.7.0) + jwt (2.8.1) + base64 kaminari (1.2.2) activesupport (>= 4.1.0) kaminari-actionview (= 1.2.2) @@ -451,17 +452,17 @@ GEM marcel (1.0.2) maxminddb (0.1.22) memoist (0.16.2) - meta_request (0.7.4) + meta_request (0.8.2) rack-contrib (>= 1.1, < 3) - railties (>= 3.0.0, < 7.1) + railties (>= 3.0.0, < 8) method_source (1.0.0) mime-types (3.4.1) mime-types-data (~> 3.2015) mime-types-data (3.2023.0218.1) mini_magick (4.12.0) mini_mime (1.1.5) - mini_portile2 (2.8.5) - minitest (5.21.2) + mini_portile2 (2.8.6) + minitest (5.22.3) mock_redis (0.36.0) ruby2_keywords msgpack (1.7.0) @@ -470,6 +471,8 @@ GEM multipart-post (2.3.0) neighbor (0.2.3) activerecord (>= 5.2) + net-http (0.4.1) + uri net-http-persistent (4.0.2) connection_pool (~> 2.2) net-imap (0.4.9) @@ -488,14 +491,14 @@ GEM newrelic_rpm (9.6.0) base64 nio4r (2.7.0) - nokogiri (1.16.2) + nokogiri (1.16.4) mini_portile2 (~> 2.8.2) racc (~> 1.4) - nokogiri (1.16.2-arm64-darwin) + nokogiri (1.16.4-arm64-darwin) racc (~> 1.4) - nokogiri (1.16.2-x86_64-darwin) + nokogiri (1.16.4-x86_64-darwin) racc (~> 1.4) - nokogiri (1.16.2-x86_64-linux) + nokogiri (1.16.4-x86_64-linux) racc (~> 1.4) numo-narray (0.9.2.1) oauth (1.1.0) @@ -515,11 +518,11 @@ GEM hashie (>= 3.4.6) rack (>= 2.2.3) rack-protection - omniauth-google-oauth2 (1.1.1) + omniauth-google-oauth2 (1.1.2) jwt (>= 2.0) - oauth2 (~> 2.0.6) + oauth2 (~> 2.0) omniauth (~> 2.0) - omniauth-oauth2 (~> 1.8.0) + omniauth-oauth2 (~> 1.8) omniauth-oauth2 (1.8.0) oauth2 (>= 1.4, < 3) omniauth (~> 2.0) @@ -559,7 +562,7 @@ GEM activesupport (>= 3.0.0) raabro (1.4.0) racc (1.7.3) - rack (2.2.8.1) + rack (2.2.9) rack-attack (6.7.0) rack (>= 1.0, < 4) rack-contrib (2.4.0) @@ -568,7 +571,8 @@ GEM rack (>= 2.0.0) rack-mini-profiler (3.2.0) rack (>= 1.2.0) - rack-protection (3.1.0) + rack-protection (3.2.0) + base64 (>= 0.1.0) rack (~> 2.2, >= 2.2.4) rack-proxy (0.7.6) rack @@ -604,7 +608,7 @@ GEM thor (~> 1.0) zeitwerk (~> 2.5) rainbow (3.1.1) - rake (13.1.0) + rake (13.2.1) rb-fsevent (0.11.2) rb-inotify (0.10.1) ffi (~> 1.0) @@ -765,7 +769,7 @@ GEM stripe (8.5.0) telephone_number (1.4.20) test-prof (1.2.1) - thor (1.3.0) + thor (1.3.1) tilt (2.3.0) time_diff (0.3.0) activesupport @@ -790,11 +794,12 @@ GEM unf_ext (0.0.8.2) unicode-display_width (2.4.2) uniform_notifier (1.16.0) + uri (0.13.0) uri_template (0.7.0) valid_email2 (4.0.6) activemodel (>= 3.2) mail (~> 2.5) - version_gem (1.1.3) + version_gem (1.1.4) warden (1.2.9) rack (>= 2.0.9) web-console (4.2.1) @@ -823,7 +828,7 @@ GEM working_hours (1.4.1) activesupport (>= 3.2) tzinfo - zeitwerk (2.6.12) + zeitwerk (2.6.13) PLATFORMS arm64-darwin-20 @@ -862,7 +867,7 @@ DEPENDENCIES database_cleaner ddtrace debug (~> 1.8) - devise (>= 4.9.3) + devise (>= 4.9.4) devise-secure_password! devise_token_auth dotenv-rails @@ -900,14 +905,14 @@ DEPENDENCIES listen lograge (~> 0.14.0) maxminddb - meta_request + meta_request (>= 0.8.0) mock_redis neighbor net-smtp (~> 0.3.4) newrelic-sidekiq-metrics (>= 1.6.2) newrelic_rpm omniauth (>= 2.1.2) - omniauth-google-oauth2 + omniauth-google-oauth2 (>= 1.1.2) omniauth-oauth2 omniauth-rails_csrf_protection (~> 1.0) pg diff --git a/Makefile b/Makefile index 16eb80718..7f2680a2d 100644 --- a/Makefile +++ b/Makefile @@ -17,6 +17,9 @@ db_migrate: db_seed: RAILS_ENV=$(RAILS_ENV) bundle exec rails db:seed +db_reset: + RAILS_ENV=$(RAILS_ENV) bundle exec rails db:reset + db: RAILS_ENV=$(RAILS_ENV) bundle exec rails db:chatwoot_prepare @@ -49,4 +52,4 @@ debug_worker: docker: docker build -t $(APP_NAME) -f ./docker/Dockerfile . -.PHONY: setup db_create db_migrate db_seed db console server burn docker run force_run debug debug_worker +.PHONY: setup db_create db_migrate db_seed db_reset db console server burn docker run force_run debug debug_worker diff --git a/app/builders/agent_builder.rb b/app/builders/agent_builder.rb index 07b0e7345..54f478920 100644 --- a/app/builders/agent_builder.rb +++ b/app/builders/agent_builder.rb @@ -16,7 +16,6 @@ class AgentBuilder def perform ActiveRecord::Base.transaction do @user = find_or_create_user - send_confirmation_if_required create_account_user end @user @@ -34,11 +33,6 @@ class AgentBuilder User.create!(email: email, name: name, password: temp_password, password_confirmation: temp_password) end - # Sends confirmation instructions if the user is persisted and not confirmed. - def send_confirmation_if_required - @user.send_confirmation_instructions if user_needs_confirmation? - end - # Checks if the user needs confirmation. # @return [Boolean] true if the user is persisted and not confirmed, false otherwise. def user_needs_confirmation? diff --git a/app/builders/messages/facebook/message_builder.rb b/app/builders/messages/facebook/message_builder.rb index fec298bce..2c55922f6 100644 --- a/app/builders/messages/facebook/message_builder.rb +++ b/app/builders/messages/facebook/message_builder.rb @@ -53,7 +53,23 @@ class Messages::Facebook::MessageBuilder < Messages::Messenger::MessageBuilder end def conversation - @conversation ||= Conversation.find_by(conversation_params) || build_conversation + @conversation ||= set_conversation_based_on_inbox_config + end + + def set_conversation_based_on_inbox_config + if @inbox.lock_to_single_conversation + Conversation.where(conversation_params).order(created_at: :desc).first || build_conversation + else + find_or_build_for_multiple_conversations + end + end + + def find_or_build_for_multiple_conversations + # If lock to single conversation is disabled, we will create a new conversation if previous conversation is resolved + last_conversation = Conversation.where(conversation_params).where.not(status: :resolved).order(created_at: :desc).first + return build_conversation if last_conversation.nil? + + last_conversation end def build_conversation diff --git a/app/builders/messages/instagram/message_builder.rb b/app/builders/messages/instagram/message_builder.rb index e9debb767..7f1b9cab2 100644 --- a/app/builders/messages/instagram/message_builder.rb +++ b/app/builders/messages/instagram/message_builder.rb @@ -69,9 +69,28 @@ class Messages::Instagram::MessageBuilder < Messages::Messenger::MessageBuilder end def conversation - @conversation ||= Conversation.where(conversation_params).find_by( - "additional_attributes ->> 'type' = 'instagram_direct_message'" - ) || build_conversation + @conversation ||= set_conversation_based_on_inbox_config + end + + def instagram_direct_message_conversation + Conversation.where(conversation_params) + .where("additional_attributes ->> 'type' = 'instagram_direct_message'") + end + + def set_conversation_based_on_inbox_config + if @inbox.lock_to_single_conversation + instagram_direct_message_conversation.order(created_at: :desc).first || build_conversation + else + find_or_build_for_multiple_conversations + end + end + + def find_or_build_for_multiple_conversations + last_conversation = instagram_direct_message_conversation.where.not(status: :resolved).order(created_at: :desc).first + + return build_conversation if last_conversation.nil? + + last_conversation end def message_content diff --git a/app/controllers/api/v1/accounts/agents_controller.rb b/app/controllers/api/v1/accounts/agents_controller.rb index eff9975f7..4eff10127 100644 --- a/app/controllers/api/v1/accounts/agents_controller.rb +++ b/app/controllers/api/v1/accounts/agents_controller.rb @@ -19,7 +19,7 @@ class Api::V1::Accounts::AgentsController < Api::V1::Accounts::BaseController account: Current.account ) - builder.perform + @agent = builder.perform end def update diff --git a/app/controllers/api/v1/accounts/channels/twilio_channels_controller.rb b/app/controllers/api/v1/accounts/channels/twilio_channels_controller.rb index d2d51baef..ebf8e49dd 100644 --- a/app/controllers/api/v1/accounts/channels/twilio_channels_controller.rb +++ b/app/controllers/api/v1/accounts/channels/twilio_channels_controller.rb @@ -2,13 +2,9 @@ class Api::V1::Accounts::Channels::TwilioChannelsController < Api::V1::Accounts: before_action :authorize_request def create - ActiveRecord::Base.transaction do - authenticate_twilio - build_inbox - setup_webhooks if @twilio_channel.sms? - rescue StandardError => e - render_could_not_create_error(e.message) - end + process_create + rescue StandardError => e + render_could_not_create_error(e.message) end private @@ -17,6 +13,14 @@ class Api::V1::Accounts::Channels::TwilioChannelsController < Api::V1::Accounts: authorize ::Inbox end + def process_create + ActiveRecord::Base.transaction do + authenticate_twilio + build_inbox + setup_webhooks if @twilio_channel.sms? + end + end + def authenticate_twilio client = if permitted_params[:api_key_sid].present? Twilio::REST::Client.new(permitted_params[:api_key_sid], permitted_params[:auth_token], permitted_params[:account_sid]) diff --git a/app/controllers/api/v1/accounts/contacts_controller.rb b/app/controllers/api/v1/accounts/contacts_controller.rb index 71e9100e7..729db34b5 100644 --- a/app/controllers/api/v1/accounts/contacts_controller.rb +++ b/app/controllers/api/v1/accounts/contacts_controller.rb @@ -65,6 +65,10 @@ class Api::V1::Accounts::ContactsController < Api::V1::Accounts::BaseController contacts = result[:contacts] @contacts_count = result[:count] @contacts = fetch_contacts(contacts) + rescue CustomExceptions::CustomFilter::InvalidAttribute, + CustomExceptions::CustomFilter::InvalidOperator, + CustomExceptions::CustomFilter::InvalidValue => e + render_could_not_create_error(e.message) end def contactable_inboxes diff --git a/app/controllers/api/v1/accounts/conversations_controller.rb b/app/controllers/api/v1/accounts/conversations_controller.rb index d0d8f6d5b..2aedf1928 100644 --- a/app/controllers/api/v1/accounts/conversations_controller.rb +++ b/app/controllers/api/v1/accounts/conversations_controller.rb @@ -44,6 +44,10 @@ class Api::V1::Accounts::ConversationsController < Api::V1::Accounts::BaseContro result = ::Conversations::FilterService.new(params.permit!, current_user).perform @conversations = result[:conversations] @conversations_count = result[:count] + rescue CustomExceptions::CustomFilter::InvalidAttribute, + CustomExceptions::CustomFilter::InvalidOperator, + CustomExceptions::CustomFilter::InvalidValue => e + render_could_not_create_error(e.message) end def mute diff --git a/app/controllers/api/v1/widget/conversations_controller.rb b/app/controllers/api/v1/widget/conversations_controller.rb index 1d1ba8a7e..7e6d84bd2 100644 --- a/app/controllers/api/v1/widget/conversations_controller.rb +++ b/app/controllers/api/v1/widget/conversations_controller.rb @@ -33,10 +33,10 @@ class Api::V1::Widget::ConversationsController < Api::V1::Widget::BaseController end def transcript - if permitted_params[:email].present? && conversation.present? + if conversation.present? && conversation.contact.present? && conversation.contact.email.present? ConversationReplyMailer.with(account: conversation.account).conversation_transcript( conversation, - permitted_params[:email] + conversation.contact.email )&.deliver_later end head :ok diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index d2960a699..2f389049d 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -25,3 +25,4 @@ class ApplicationController < ActionController::Base } end end +ApplicationController.include_mod_with('Concerns::ApplicationControllerConcern') diff --git a/app/controllers/concerns/domain_helper.rb b/app/controllers/concerns/domain_helper.rb new file mode 100644 index 000000000..1b7d8f187 --- /dev/null +++ b/app/controllers/concerns/domain_helper.rb @@ -0,0 +1,5 @@ +module DomainHelper + def self.chatwoot_domain?(domain = request.host) + [URI.parse(ENV.fetch('FRONTEND_URL', '')).host, URI.parse(ENV.fetch('HELPCENTER_URL', '')).host].include?(domain) + end +end diff --git a/app/controllers/concerns/switch_locale.rb b/app/controllers/concerns/switch_locale.rb index 744a70da9..3013ff3cc 100644 --- a/app/controllers/concerns/switch_locale.rb +++ b/app/controllers/concerns/switch_locale.rb @@ -6,6 +6,7 @@ module SwitchLocale def switch_locale(&) # priority is for locale set in query string (mostly for widget/from js sdk) locale ||= locale_from_params + locale ||= locale_from_custom_domain # if locale is not set in account, let's use DEFAULT_LOCALE env variable locale ||= locale_from_env_variable set_locale(locale, &) @@ -16,6 +17,20 @@ module SwitchLocale set_locale(locale, &) end + # If the request is coming from a custom domain, it should be for a helpcenter portal + # We will use the portal locale in such cases + def locale_from_custom_domain(&) + return if params[:locale] + + domain = request.host + return if DomainHelper.chatwoot_domain?(domain) + + @portal = Portal.find_by(custom_domain: domain) + return unless @portal + + @portal.default_locale + end + def set_locale(locale, &) # if locale is empty, use default_locale locale ||= I18n.default_locale diff --git a/app/controllers/dashboard_controller.rb b/app/controllers/dashboard_controller.rb index 0aea9df83..047fd10c3 100644 --- a/app/controllers/dashboard_controller.rb +++ b/app/controllers/dashboard_controller.rb @@ -18,6 +18,7 @@ class DashboardController < ActionController::Base 'LOGO', 'LOGO_DARK', 'LOGO_THUMBNAIL', 'INSTALLATION_NAME', 'WIDGET_BRAND_URL', 'TERMS_URL', + 'BRAND_URL', 'BRAND_NAME', 'PRIVACY_URL', 'DISPLAY_MANIFEST', 'CREATE_NEW_ACCOUNT_FROM_DASHBOARD', diff --git a/app/controllers/devise_overrides/sessions_controller.rb b/app/controllers/devise_overrides/sessions_controller.rb index e623e52f7..fc7b12767 100644 --- a/app/controllers/devise_overrides/sessions_controller.rb +++ b/app/controllers/devise_overrides/sessions_controller.rb @@ -4,6 +4,10 @@ class DeviseOverrides::SessionsController < DeviseTokenAuth::SessionsController wrap_parameters format: [] before_action :process_sso_auth_token, only: [:create] + def new + redirect_to login_page_url(error: 'access-denied') + end + def create # Authenticate user via the temporary sso auth token if params[:sso_auth_token].present? && @resource.present? @@ -21,6 +25,12 @@ class DeviseOverrides::SessionsController < DeviseTokenAuth::SessionsController private + def login_page_url(error: nil) + frontend_url = ENV.fetch('FRONTEND_URL', nil) + + "#{frontend_url}/app/login?error=#{error}" + end + def authenticate_resource_with_sso_token @token = @resource.create_token @resource.save! diff --git a/app/controllers/public/api/v1/portals/articles_controller.rb b/app/controllers/public/api/v1/portals/articles_controller.rb index 4e967cfbb..46ecf19a7 100644 --- a/app/controllers/public/api/v1/portals/articles_controller.rb +++ b/app/controllers/public/api/v1/portals/articles_controller.rb @@ -7,7 +7,7 @@ class Public::Api::V1::Portals::ArticlesController < Public::Api::V1::Portals::B def index @articles = @portal.articles - @articles = @articles.search(list_params) if list_params.present? + search_articles order_by_sort_param @articles.page(list_params[:page]) if list_params[:page].present? end @@ -16,6 +16,10 @@ class Public::Api::V1::Portals::ArticlesController < Public::Api::V1::Portals::B private + def search_articles + @articles = @articles.search(list_params) if list_params.present? + end + def order_by_sort_param @articles = if list_params[:sort].present? && list_params[:sort] == 'views' @articles.order_by_views @@ -51,3 +55,5 @@ class Public::Api::V1::Portals::ArticlesController < Public::Api::V1::Portals::B ChatwootMarkdownRenderer.new(content).render_article end end + +Public::Api::V1::Portals::ArticlesController.prepend_mod_with('Public::Api::V1::Portals::ArticlesController') diff --git a/app/controllers/public/api/v1/portals/base_controller.rb b/app/controllers/public/api/v1/portals/base_controller.rb index 4d3cc56b8..f6c10f7c4 100644 --- a/app/controllers/public/api/v1/portals/base_controller.rb +++ b/app/controllers/public/api/v1/portals/base_controller.rb @@ -47,7 +47,7 @@ class Public::Api::V1::Portals::BaseController < PublicController @locale = if article.category.present? article.category.locale else - 'en' + article.portal.default_locale end I18n.with_locale(@locale, &) diff --git a/app/controllers/public_controller.rb b/app/controllers/public_controller.rb index 0c3f52ff6..3b83a2210 100644 --- a/app/controllers/public_controller.rb +++ b/app/controllers/public_controller.rb @@ -8,8 +8,7 @@ class PublicController < ActionController::Base def ensure_custom_domain_request domain = request.host - - return if [URI.parse(ENV.fetch('FRONTEND_URL', '')).host, URI.parse(ENV.fetch('HELPCENTER_URL', '')).host].include?(domain) + return if DomainHelper.chatwoot_domain?(domain) @portal = ::Portal.find_by(custom_domain: domain) return if @portal.present? diff --git a/app/finders/conversation_finder.rb b/app/finders/conversation_finder.rb index f3c85be7a..44592c201 100644 --- a/app/finders/conversation_finder.rb +++ b/app/finders/conversation_finder.rb @@ -163,10 +163,14 @@ class ConversationFinder params[:page] || 1 end - def conversations - @conversations = @conversations.includes( + def conversations_base_query + @conversations.includes( :taggings, :inbox, { assignee: { avatar_attachment: [:blob] } }, { contact: { avatar_attachment: [:blob] } }, :team, :contact_inbox ) + end + + def conversations + @conversations = conversations_base_query sort_by, sort_order = SORT_OPTIONS[params[:sort_by]] || SORT_OPTIONS['last_activity_at_desc'] @conversations = @conversations.send(sort_by, sort_order) @@ -178,3 +182,4 @@ class ConversationFinder end end end +ConversationFinder.prepend_mod_with('ConversationFinder') diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb index b52b2300e..76d0bacc8 100644 --- a/app/helpers/application_helper.rb +++ b/app/helpers/application_helper.rb @@ -2,4 +2,11 @@ module ApplicationHelper def available_locales_with_name LANGUAGES_CONFIG.map { |_key, val| val.slice(:name, :iso_639_1_code) } end + + def feature_help_urls + features = YAML.safe_load(Rails.root.join('config/features.yml').read).freeze + features.each_with_object({}) do |feature, hash| + hash[feature['name']] = feature['help_url'] if feature['help_url'] + end + end end diff --git a/app/helpers/filter_helper.rb b/app/helpers/filter_helper.rb new file mode 100644 index 000000000..bce2de5ea --- /dev/null +++ b/app/helpers/filter_helper.rb @@ -0,0 +1,84 @@ +module FilterHelper + def build_condition_query(model_filters, query_hash, current_index) + current_filter = model_filters[query_hash['attribute_key']] + + # Throw InvalidOperator Error if the attribute is a standard attribute + # and the operator is not allowed in the config + if current_filter.present? && current_filter['filter_operators'].exclude?(query_hash[:filter_operator]) + raise CustomExceptions::CustomFilter::InvalidOperator.new( + attribute_name: query_hash['attribute_key'], + allowed_keys: current_filter['filter_operators'] + ) + end + + # Every other filter expects a value to be present + if %w[is_present is_not_present].exclude?(query_hash[:filter_operator]) && query_hash['values'].blank? + raise CustomExceptions::CustomFilter::InvalidValue.new(attribute_name: query_hash['attribute_key']) + end + + condition_query = build_condition_query_string(current_filter, query_hash, current_index) + # The query becomes empty only when it doesn't match to any supported + # standard attribute or custom attribute defined in the account. + if condition_query.empty? + raise CustomExceptions::CustomFilter::InvalidAttribute.new(key: query_hash['attribute_key'], + allowed_keys: model_filters.keys) + end + + condition_query + end + + def build_condition_query_string(current_filter, query_hash, current_index) + filter_operator_value = filter_operation(query_hash, current_index) + + return handle_nil_filter(query_hash, current_index) if current_filter.nil? + + case current_filter['attribute_type'] + when 'additional_attributes' + handle_additional_attributes(query_hash, filter_operator_value, current_filter['data_type']) + else + handle_standard_attributes(current_filter, query_hash, current_index, filter_operator_value) + end + end + + def handle_nil_filter(query_hash, current_index) + attribute_type = "#{filter_config[:entity].downcase}_attribute" + custom_attribute_query(query_hash, attribute_type, current_index) + end + + def handle_additional_attributes(query_hash, filter_operator_value, data_type) + if data_type == 'text_case_insensitive' + "LOWER(#{filter_config[:table_name]}.additional_attributes ->> '#{query_hash[:attribute_key]}') " \ + "#{filter_operator_value} #{query_hash[:query_operator]}" + else + "#{filter_config[:table_name]}.additional_attributes ->> '#{query_hash[:attribute_key]}' " \ + "#{filter_operator_value} #{query_hash[:query_operator]} " + end + end + + def handle_standard_attributes(current_filter, query_hash, current_index, filter_operator_value) + case current_filter['data_type'] + when 'date' + date_filter(current_filter, query_hash, filter_operator_value) + when 'labels' + tag_filter_query(query_hash, current_index) + when 'text_case_insensitive' + text_case_insensitive_filter(query_hash, filter_operator_value) + else + default_filter(query_hash, filter_operator_value) + end + end + + def date_filter(current_filter, query_hash, filter_operator_value) + "(#{filter_config[:table_name]}.#{query_hash[:attribute_key]})::#{current_filter['data_type']} " \ + "#{filter_operator_value}#{current_filter['data_type']} #{query_hash[:query_operator]}" + end + + def text_case_insensitive_filter(query_hash, filter_operator_value) + "LOWER(#{filter_config[:table_name]}.#{query_hash[:attribute_key]}) " \ + "#{filter_operator_value} #{query_hash[:query_operator]}" + end + + def default_filter(query_hash, filter_operator_value) + "#{filter_config[:table_name]}.#{query_hash[:attribute_key]} #{filter_operator_value} #{query_hash[:query_operator]}" + end +end diff --git a/app/javascript/dashboard/api/slaReports.js b/app/javascript/dashboard/api/slaReports.js new file mode 100644 index 000000000..fedc988b2 --- /dev/null +++ b/app/javascript/dashboard/api/slaReports.js @@ -0,0 +1,78 @@ +/* global axios */ +import ApiClient from './ApiClient'; + +class SLAReportsAPI extends ApiClient { + constructor() { + super('applied_slas', { accountScoped: true }); + } + + get({ + from, + to, + assigned_agent_id, + inbox_id, + team_id, + sla_policy_id, + label_list, + page, + } = {}) { + return axios.get(this.url, { + params: { + since: from, + until: to, + assigned_agent_id, + inbox_id, + team_id, + sla_policy_id, + label_list, + page, + }, + }); + } + + download({ + from, + to, + assigned_agent_id, + inbox_id, + team_id, + sla_policy_id, + label_list, + } = {}) { + return axios.get(`${this.url}/download`, { + params: { + since: from, + until: to, + assigned_agent_id, + inbox_id, + team_id, + label_list, + sla_policy_id, + }, + }); + } + + getMetrics({ + from, + to, + assigned_agent_id, + inbox_id, + team_id, + label_list, + sla_policy_id, + } = {}) { + return axios.get(`${this.url}/metrics`, { + params: { + since: from, + until: to, + assigned_agent_id, + inbox_id, + label_list, + team_id, + sla_policy_id, + }, + }); + } +} + +export default new SLAReportsAPI(); diff --git a/app/javascript/dashboard/api/specs/slaReports.spec.js b/app/javascript/dashboard/api/specs/slaReports.spec.js new file mode 100644 index 000000000..f540b6acc --- /dev/null +++ b/app/javascript/dashboard/api/specs/slaReports.spec.js @@ -0,0 +1,104 @@ +import SLAReportsAPI from '../slaReports'; +import ApiClient from '../ApiClient'; + +describe('#SLAReports API', () => { + it('creates correct instance', () => { + expect(SLAReportsAPI).toBeInstanceOf(ApiClient); + expect(SLAReportsAPI.apiVersion).toBe('/api/v1'); + expect(SLAReportsAPI).toHaveProperty('get'); + expect(SLAReportsAPI).toHaveProperty('getMetrics'); + }); + + describe('API calls', () => { + const originalAxios = window.axios; + const axiosMock = { + post: jest.fn(() => Promise.resolve()), + get: jest.fn(() => Promise.resolve()), + patch: jest.fn(() => Promise.resolve()), + delete: jest.fn(() => Promise.resolve()), + }; + + beforeEach(() => { + window.axios = axiosMock; + }); + + afterEach(() => { + window.axios = originalAxios; + }); + + it('#get', () => { + SLAReportsAPI.get({ + page: 1, + from: 1622485800, + to: 1623695400, + assigned_agent_id: 1, + inbox_id: 1, + team_id: 1, + sla_policy_id: 1, + label_list: ['label1'], + }); + expect(axiosMock.get).toHaveBeenCalledWith('/api/v1/applied_slas', { + params: { + page: 1, + since: 1622485800, + until: 1623695400, + assigned_agent_id: 1, + inbox_id: 1, + team_id: 1, + sla_policy_id: 1, + label_list: ['label1'], + }, + }); + }); + it('#getMetrics', () => { + SLAReportsAPI.getMetrics({ + from: 1622485800, + to: 1623695400, + assigned_agent_id: 1, + inbox_id: 1, + team_id: 1, + sla_policy_id: 1, + label_list: ['label1'], + }); + expect(axiosMock.get).toHaveBeenCalledWith( + '/api/v1/applied_slas/metrics', + { + params: { + since: 1622485800, + until: 1623695400, + assigned_agent_id: 1, + inbox_id: 1, + team_id: 1, + sla_policy_id: 1, + label_list: ['label1'], + }, + } + ); + }); + it('#download', () => { + SLAReportsAPI.download({ + from: 1622485800, + to: 1623695400, + assigned_agent_id: 1, + inbox_id: 1, + team_id: 1, + sla_policy_id: 1, + label_list: ['label1'], + }); + expect(axiosMock.get).toHaveBeenCalledWith( + '/api/v1/applied_slas/download', + { + params: { + since: 1622485800, + until: 1623695400, + assigned_agent_id: 1, + inbox_id: 1, + team_id: 1, + sla_policy_id: 1, + label_list: ['label1'], + }, + } + ); + }); + }); +}); diff --git a/app/javascript/dashboard/assets/scss/_date-picker.scss b/app/javascript/dashboard/assets/scss/_date-picker.scss index 2132d5fd5..60c24b421 100644 --- a/app/javascript/dashboard/assets/scss/_date-picker.scss +++ b/app/javascript/dashboard/assets/scss/_date-picker.scss @@ -1,6 +1,13 @@ @import '~vue2-datepicker/scss/index'; .date-picker { + // To be removed one SLA reports date picker is created + &.small { + .mx-input { + @apply h-8 text-sm; + } + } + &.no-margin { .mx-input { @apply mb-0; diff --git a/app/javascript/dashboard/assets/scss/_layout.scss b/app/javascript/dashboard/assets/scss/_layout.scss index ea40c1f3a..b9c8a9bf1 100644 --- a/app/javascript/dashboard/assets/scss/_layout.scss +++ b/app/javascript/dashboard/assets/scss/_layout.scss @@ -1,17 +1,17 @@ // scss-lint:disable SpaceAfterPropertyColon -// @import 'shared/assets/fonts/inter'; - +@import 'shared/assets/fonts/inter'; +// Inter, html, body { font-family: 'PlusJakarta', - Inter, -apple-system, system-ui, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', + Tahoma, Arial, sans-serif !important; -moz-osx-font-smoothing: grayscale; diff --git a/app/javascript/dashboard/assets/scss/plugins/_multiselect.scss b/app/javascript/dashboard/assets/scss/plugins/_multiselect.scss index 9170715e0..99b006045 100644 --- a/app/javascript/dashboard/assets/scss/plugins/_multiselect.scss +++ b/app/javascript/dashboard/assets/scss/plugins/_multiselect.scss @@ -153,6 +153,29 @@ } .multiselect-wrap--small { + // To be removed one SLA reports date picker is created + &.tiny { + .multiselect.no-margin { + @apply min-h-[32px]; + } + + .multiselect__select { + @apply min-h-[32px] h-8; + + &::before { + @apply top-[60%]; + } + } + + .multiselect__tags { + @apply min-h-[32px] max-h-[32px]; + + .multiselect__single { + @apply pt-1 pb-1; + } + } + } + .multiselect__tags, .multiselect__input, .multiselect { diff --git a/app/javascript/dashboard/assets/scss/widgets/_base.scss b/app/javascript/dashboard/assets/scss/widgets/_base.scss index e64231dfc..1ebcef53c 100644 --- a/app/javascript/dashboard/assets/scss/widgets/_base.scss +++ b/app/javascript/dashboard/assets/scss/widgets/_base.scss @@ -63,12 +63,12 @@ input:focus { } // Inputs -input[type='text'], -input[type='number'], -input[type='password'], -input[type='date'], -input[type='email'], -input[type='url'] { +input[type='text']:not(.reset-base), +input[type='number']:not(.reset-base), +input[type='password']:not(.reset-base), +input[type='date']:not(.reset-base), +input[type='email']:not(.reset-base), +input[type='url']:not(.reset-base) { @apply block box-border w-full transition-colors focus:border-woot-500 dark:focus:border-woot-600 duration-[0.25s] ease-[ease-in-out] h-10 appearance-none mx-0 mt-0 mb-4 p-2 rounded-md text-base font-normal bg-white dark:bg-slate-900 focus:bg-white focus:dark:bg-slate-900 text-slate-900 dark:text-slate-100 border border-solid border-slate-200 dark:border-slate-600; &[disabled] { diff --git a/app/javascript/dashboard/assets/scss/widgets/_buttons.scss b/app/javascript/dashboard/assets/scss/widgets/_buttons.scss index 54fc27e25..587b810ca 100644 --- a/app/javascript/dashboard/assets/scss/widgets/_buttons.scss +++ b/app/javascript/dashboard/assets/scss/widgets/_buttons.scss @@ -118,7 +118,7 @@ button { @apply border border-woot-500 bg-transparent dark:bg-transparent dark:border-woot-500 text-woot-500 dark:text-woot-500 hover:bg-woot-50 dark:hover:bg-woot-900; &.secondary { - @apply text-slate-700 border-slate-200 dark:border-slate-600 dark:text-slate-100 hover:bg-slate-50 dark:hover:bg-slate-700; + @apply text-slate-700 border-slate-100 dark:border-slate-800 dark:text-slate-100 hover:bg-slate-50 dark:hover:bg-slate-700; } &.success { diff --git a/app/javascript/dashboard/components/ChatList.vue b/app/javascript/dashboard/components/ChatList.vue index a75f0c12b..26c057f98 100644 --- a/app/javascript/dashboard/components/ChatList.vue +++ b/app/javascript/dashboard/components/ChatList.vue @@ -1,14 +1,14 @@