diff --git a/Gemfile.lock b/Gemfile.lock index bd41474a3..68674155e 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1030,7 +1030,7 @@ GEM addressable (>= 2.8.0) crack (>= 0.3.2) hashdiff (>= 0.4.0, < 2.0.0) - websocket-driver (0.7.7) + websocket-driver (0.8.2) base64 websocket-extensions (>= 0.1.0) websocket-extensions (0.1.5) diff --git a/app/controllers/api/v1/accounts/assignable_agents_controller.rb b/app/controllers/api/v1/accounts/assignable_agents_controller.rb index a712342dd..29dcb62b2 100644 --- a/app/controllers/api/v1/accounts/assignable_agents_controller.rb +++ b/app/controllers/api/v1/accounts/assignable_agents_controller.rb @@ -2,6 +2,8 @@ class Api::V1::Accounts::AssignableAgentsController < Api::V1::Accounts::BaseCon before_action :fetch_inboxes def index + # TODO: Remove this opt-in once mobile clients support AgentBot assignees in this payload. + @include_agent_bots = params[:include_agent_bots].present? agent_ids = @inboxes.map do |inbox| authorize inbox, :show? member_ids = inbox.members.pluck(:user_id) @@ -10,6 +12,7 @@ class Api::V1::Accounts::AssignableAgentsController < Api::V1::Accounts::BaseCon agent_ids = agent_ids.inject(:&) agents = Current.account.users.where(id: agent_ids) @assignable_agents = (agents + Current.account.administrators).uniq + @agent_bots = @include_agent_bots ? AgentBot.accessible_to(Current.account) : [] end private diff --git a/app/controllers/api/v1/accounts/callbacks_controller.rb b/app/controllers/api/v1/accounts/callbacks_controller.rb index 90cdf2418..08c0ffe43 100644 --- a/app/controllers/api/v1/accounts/callbacks_controller.rb +++ b/app/controllers/api/v1/accounts/callbacks_controller.rb @@ -6,6 +6,7 @@ class Api::V1::Accounts::CallbacksController < Api::V1::Accounts::BaseController page_access_token = params[:page_access_token] page_id = params[:page_id] inbox_name = params[:inbox_name] + ActiveRecord::Base.transaction do facebook_channel = Current.account.facebook_pages.create!( page_id: page_id, user_access_token: user_access_token, @@ -15,6 +16,8 @@ class Api::V1::Accounts::CallbacksController < Api::V1::Accounts::BaseController set_instagram_id(page_access_token, facebook_channel) set_avatar(@facebook_inbox, page_id) end + rescue CustomExceptions::Inbox::LimitExceeded => e + render_error_response(e) rescue StandardError => e ChatwootExceptionTracker.new(e).capture_exception Rails.logger.error "Error in register_facebook_page: #{e.message}" diff --git a/app/controllers/api/v1/accounts/captain/preferences_controller.rb b/app/controllers/api/v1/accounts/captain/preferences_controller.rb index 04eeff92b..482b001d6 100644 --- a/app/controllers/api/v1/accounts/captain/preferences_controller.rb +++ b/app/controllers/api/v1/accounts/captain/preferences_controller.rb @@ -66,6 +66,7 @@ class Api::V1::Accounts::Captain::PreferencesController < Api::V1::Accounts::Bas config = Llm::Models.feature_config(feature_key) route = Llm::FeatureRouter.resolve(feature: feature_key, account: Current.account) config.merge( + default: default_model_for(feature_key), enabled: account_features[feature_key] == true, model: route[:model], selected: route[:model], @@ -74,4 +75,10 @@ class Api::V1::Accounts::Captain::PreferencesController < Api::V1::Accounts::Bas ) end end + + def default_model_for(feature_key) + return Llm::FeatureRouter::CAPTAIN_V2_ASSISTANT_MODEL if feature_key == 'assistant' && Current.account.feature_enabled?('captain_integration_v2') + + Llm::Models.default_model_for(feature_key) + end end 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 f3b14d49f..1691b5489 100644 --- a/app/controllers/api/v1/accounts/channels/twilio_channels_controller.rb +++ b/app/controllers/api/v1/accounts/channels/twilio_channels_controller.rb @@ -6,6 +6,8 @@ class Api::V1::Accounts::Channels::TwilioChannelsController < Api::V1::Accounts: def create process_create + rescue CustomExceptions::Inbox::LimitExceeded => e + render_error_response(e) rescue StandardError => e render_could_not_create_error(e.message) end diff --git a/app/controllers/api/v1/accounts/conversations/participants_controller.rb b/app/controllers/api/v1/accounts/conversations/participants_controller.rb index ebd02380f..7569142b2 100644 --- a/app/controllers/api/v1/accounts/conversations/participants_controller.rb +++ b/app/controllers/api/v1/accounts/conversations/participants_controller.rb @@ -1,27 +1,40 @@ class Api::V1::Accounts::Conversations::ParticipantsController < Api::V1::Accounts::Conversations::BaseController + include Events::Types + def show @participants = @conversation.conversation_participants end def create + participant_ids_to_add = participants_to_be_added_ids + ActiveRecord::Base.transaction do - @participants = participants_to_be_added_ids.map { |user_id| @conversation.conversation_participants.find_or_create_by(user_id: user_id) } + @participants = participant_ids_to_add.map { |user_id| @conversation.conversation_participants.find_or_create_by(user_id: user_id) } end + notify_unread_count_change if participant_ids_to_add.any? end def update + participant_ids_to_add = participants_to_be_added_ids + participant_ids_to_remove = participants_to_be_removed_ids + changed_participant_ids = participant_ids_to_add + participant_ids_to_remove + ActiveRecord::Base.transaction do - participants_to_be_added_ids.each { |user_id| @conversation.conversation_participants.find_or_create_by(user_id: user_id) } - participants_to_be_removed_ids.each { |user_id| @conversation.conversation_participants.find_by(user_id: user_id)&.destroy } + participant_ids_to_add.each { |user_id| @conversation.conversation_participants.find_or_create_by(user_id: user_id) } + participant_ids_to_remove.each { |user_id| @conversation.conversation_participants.find_by(user_id: user_id)&.destroy } end + notify_unread_count_change if changed_participant_ids.any? @participants = @conversation.conversation_participants render action: 'show' end def destroy + participant_ids_to_remove = current_participant_ids & params[:user_ids] + ActiveRecord::Base.transaction do params[:user_ids].map { |user_id| @conversation.conversation_participants.find_by(user_id: user_id)&.destroy } end + notify_unread_count_change if participant_ids_to_remove.any? head :ok end @@ -38,4 +51,11 @@ class Api::V1::Accounts::Conversations::ParticipantsController < Api::V1::Accoun def current_participant_ids @current_participant_ids ||= @conversation.conversation_participants.pluck(:user_id) end + + def notify_unread_count_change + return unless Current.account.feature_enabled?('conversation_unread_counts') + return unless Current.account.feature_enabled?('unread_count_for_filters') + + Rails.configuration.dispatcher.dispatch(CONVERSATION_UNREAD_COUNT_CHANGED, Time.zone.now, conversation: @conversation) + end end diff --git a/app/controllers/api/v1/accounts/conversations/unread_counts_controller.rb b/app/controllers/api/v1/accounts/conversations/unread_counts_controller.rb index d9f15613b..0b2335475 100644 --- a/app/controllers/api/v1/accounts/conversations/unread_counts_controller.rb +++ b/app/controllers/api/v1/accounts/conversations/unread_counts_controller.rb @@ -2,12 +2,28 @@ class Api::V1::Accounts::Conversations::UnreadCountsController < Api::V1::Accoun before_action :ensure_unread_counts_enabled def index - counts = ::Conversations::UnreadCounts::Counter.new(account: Current.account, user: Current.user).perform + counts = if filtered_unread_counts_enabled? + instrumentation.summarize_request(account_id: Current.account.id) { unread_counts } + else + unread_counts + end render json: { payload: counts } end private + def unread_counts + ::Conversations::UnreadCounts::Counter.new(account: Current.account, user: Current.user).perform + end + + def filtered_unread_counts_enabled? + Current.account.feature_enabled?(::Conversations::UnreadCounts::FilteredCounter::FEATURE_FLAG) + end + + def instrumentation + ::Conversations::UnreadCounts::FilteredCountInstrumentation + end + def ensure_unread_counts_enabled return if Current.account.feature_enabled?('conversation_unread_counts') diff --git a/app/controllers/api/v1/accounts/conversations_controller.rb b/app/controllers/api/v1/accounts/conversations_controller.rb index 2e53fa7c9..38e8d94aa 100644 --- a/app/controllers/api/v1/accounts/conversations_controller.rb +++ b/app/controllers/api/v1/accounts/conversations_controller.rb @@ -164,6 +164,7 @@ class Api::V1::Accounts::ConversationsController < Api::V1::Accounts::BaseContro # rubocop:enable Rails/SkipsModelValidations ::Conversations::UnreadCounts::Notifier.new(@conversation).perform + ::Conversations::UnreadCounts::FilteredCountInvalidator.new(Current.account).conversation_changed! end def should_update_last_seen? diff --git a/app/controllers/api/v1/accounts/inboxes_controller.rb b/app/controllers/api/v1/accounts/inboxes_controller.rb index 757af9b62..9f56c3817 100644 --- a/app/controllers/api/v1/accounts/inboxes_controller.rb +++ b/app/controllers/api/v1/accounts/inboxes_controller.rb @@ -2,7 +2,6 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController include Api::V1::InboxesHelper before_action :fetch_inbox, except: [:index, :create] before_action :fetch_agent_bot, only: [:set_agent_bot] - before_action :validate_limit, only: [:create] # we are already handling the authorization in fetch inbox before_action :check_authorization, except: [:show] diff --git a/app/controllers/api/v1/accounts/whatsapp/authorizations_controller.rb b/app/controllers/api/v1/accounts/whatsapp/authorizations_controller.rb index d52f396fc..db94113d9 100644 --- a/app/controllers/api/v1/accounts/whatsapp/authorizations_controller.rb +++ b/app/controllers/api/v1/accounts/whatsapp/authorizations_controller.rb @@ -8,8 +8,10 @@ class Api::V1::Accounts::Whatsapp::AuthorizationsController < Api::V1::Accounts: validate_embedded_signup_params! channel = process_embedded_signup render_success_response(channel.inbox) - rescue StandardError => e + rescue CustomExceptions::Inbox::LimitExceeded => e render_error_response(e) + rescue StandardError => e + render_embedded_signup_error(e) end private @@ -55,7 +57,7 @@ class Api::V1::Accounts::Whatsapp::AuthorizationsController < Api::V1::Accounts: render json: response end - def render_error_response(error) + def render_embedded_signup_error(error) Rails.logger.error "[WHATSAPP AUTHORIZATION] Embedded signup error: #{error.message}" Rails.logger.error error.backtrace.join("\n") render json: { diff --git a/app/controllers/api/v1/widget/contacts_controller.rb b/app/controllers/api/v1/widget/contacts_controller.rb index 6c595ab59..9a7d5193a 100644 --- a/app/controllers/api/v1/widget/contacts_controller.rb +++ b/app/controllers/api/v1/widget/contacts_controller.rb @@ -2,6 +2,7 @@ class Api::V1::Widget::ContactsController < Api::V1::Widget::BaseController include WidgetHelper before_action :validate_hmac, only: [:set_user] + before_action :validate_hmac_for_identified_update, only: [:update] def show; end @@ -46,6 +47,16 @@ class Api::V1::Widget::ContactsController < Api::V1::Widget::BaseController @contact.identifier.present? && @contact.identifier != permitted_params[:identifier] end + # The plain update endpoint is also used for anonymous prechat updates + # (name/email/phone/custom_attributes with no identifier), which must keep + # working on hmac_mandatory inboxes. Only the identity-binding path, where an + # identifier is supplied and the contact can be rebound, requires HMAC. + def validate_hmac_for_identified_update + return if params[:identifier].blank? + + validate_hmac + end + def validate_hmac return unless should_verify_hmac? @@ -62,11 +73,15 @@ class Api::V1::Widget::ContactsController < Api::V1::Widget::BaseController end def valid_hmac? - params[:identifier_hash] == OpenSSL::HMAC.hexdigest( + expected_hash = OpenSSL::HMAC.hexdigest( 'sha256', @web_widget.hmac_token, params[:identifier].to_s ) + identifier_hash = params[:identifier_hash].to_s + return false unless identifier_hash.bytesize == expected_hash.bytesize + + ActiveSupport::SecurityUtils.secure_compare(identifier_hash, expected_hash) end def permitted_params diff --git a/app/controllers/concerns/request_exception_handler.rb b/app/controllers/concerns/request_exception_handler.rb index ccab0090a..43d6edf1f 100644 --- a/app/controllers/concerns/request_exception_handler.rb +++ b/app/controllers/concerns/request_exception_handler.rb @@ -1,8 +1,15 @@ module RequestExceptionHandler extend ActiveSupport::Concern + QUERY_CANCELED_ERROR_MESSAGE_PATTERNS = [ + 'ActiveRecord::QueryCanceled', + 'PG::QueryCanceled', + 'canceling statement due to statement timeout' + ].freeze + included do rescue_from ActiveRecord::RecordInvalid, with: :render_record_invalid + rescue_from CustomExceptions::Inbox::LimitExceeded, with: :render_error_response end private @@ -18,6 +25,9 @@ module RequestExceptionHandler rescue ActionController::ParameterMissing => e log_handled_error(e) render_could_not_create_error(e.message) + rescue ActiveRecord::QueryCanceled => e + log_handled_error(e) + render_could_not_create_error(database_query_canceled_message) ensure # to address the thread variable leak issues in Puma/Thin webserver Current.reset @@ -31,8 +41,8 @@ module RequestExceptionHandler render json: { error: message }, status: :not_found end - def render_could_not_create_error(message) - render json: { error: message }, status: :unprocessable_entity + def render_could_not_create_error(error) + render json: { error: sanitized_error_message(error) }, status: :unprocessable_entity end def render_payment_required(message) @@ -59,4 +69,19 @@ module RequestExceptionHandler def log_handled_error(exception) logger.info("Handled error: #{exception.inspect}") end + + def sanitized_error_message(message) + return database_query_canceled_message if database_query_canceled_message?(message) + + message + end + + def database_query_canceled_message?(message) + error_message = message.to_s + QUERY_CANCELED_ERROR_MESSAGE_PATTERNS.any? { |pattern| error_message.include?(pattern) } + end + + def database_query_canceled_message + I18n.t('errors.database.query_canceled') + end end diff --git a/app/controllers/instagram/callbacks_controller.rb b/app/controllers/instagram/callbacks_controller.rb index cd317363c..e9065119f 100644 --- a/app/controllers/instagram/callbacks_controller.rb +++ b/app/controllers/instagram/callbacks_controller.rb @@ -11,6 +11,8 @@ class Instagram::CallbacksController < ApplicationController end process_successful_authorization + rescue CustomExceptions::Inbox::LimitExceeded => e + handle_limit_error(e) rescue StandardError => e handle_error(e) end @@ -47,6 +49,14 @@ class Instagram::CallbacksController < ApplicationController redirect_to_error_page(error_info) end + def handle_limit_error(error) + redirect_to_error_page( + 'error_type' => error.class.name, + 'code' => Rack::Utils.status_code(error.http_status), + 'error_message' => error.message + ) + end + # Extract error details from the exception def extract_error_info(error) if error.is_a?(OAuth2::Error) diff --git a/app/controllers/public/api/v1/inboxes/contacts_controller.rb b/app/controllers/public/api/v1/inboxes/contacts_controller.rb index 835c2596b..838b10951 100644 --- a/app/controllers/public/api/v1/inboxes/contacts_controller.rb +++ b/app/controllers/public/api/v1/inboxes/contacts_controller.rb @@ -35,11 +35,15 @@ class Public::Api::V1::Inboxes::ContactsController < Public::Api::V1::InboxesCon end def valid_hmac? - params[:identifier_hash] == OpenSSL::HMAC.hexdigest( + expected_hash = OpenSSL::HMAC.hexdigest( 'sha256', @inbox_channel.hmac_token, params[:identifier].to_s ) + identifier_hash = params[:identifier_hash].to_s + return false unless identifier_hash.bytesize == expected_hash.bytesize + + ActiveSupport::SecurityUtils.secure_compare(identifier_hash, expected_hash) end def permitted_params diff --git a/app/controllers/tiktok/callbacks_controller.rb b/app/controllers/tiktok/callbacks_controller.rb index 20c0ee9c0..a39fec5ed 100644 --- a/app/controllers/tiktok/callbacks_controller.rb +++ b/app/controllers/tiktok/callbacks_controller.rb @@ -6,6 +6,8 @@ class Tiktok::CallbacksController < ApplicationController return handle_ungranted_scopes_error unless all_scopes_granted? process_successful_authorization + rescue CustomExceptions::Inbox::LimitExceeded => e + handle_limit_error(e) rescue StandardError => e handle_error(e) end @@ -36,6 +38,14 @@ class Tiktok::CallbacksController < ApplicationController redirect_to_error_page(error_type: error.class.name, code: 500, error_message: error.message) end + def handle_limit_error(error) + redirect_to_error_page( + error_type: error.class.name, + code: Rack::Utils.status_code(error.http_status), + error_message: error.message + ) + end + # Handles the case when a user denies permissions or cancels the authorization flow def handle_authorization_error redirect_to_error_page( diff --git a/app/helpers/api/v1/inboxes_helper.rb b/app/helpers/api/v1/inboxes_helper.rb index 8a10fa99c..6c64dd009 100644 --- a/app/helpers/api/v1/inboxes_helper.rb +++ b/app/helpers/api/v1/inboxes_helper.rb @@ -114,10 +114,4 @@ module Api::V1::InboxesHelper '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/filters/filter_helper.rb b/app/helpers/filters/filter_helper.rb index 4f345676e..d32c9468a 100644 --- a/app/helpers/filters/filter_helper.rb +++ b/app/helpers/filters/filter_helper.rb @@ -68,6 +68,8 @@ module Filters::FilterHelper when 'text_case_insensitive' text_case_insensitive_filter(query_hash, filter_operator_value) else + return text_cast_filter(query_hash, filter_operator_value) if text_search_on_display_id?(query_hash) + default_filter(query_hash, filter_operator_value) end end @@ -82,10 +84,18 @@ module Filters::FilterHelper "#{filter_operator_value} #{query_hash[:query_operator]}" end + def text_cast_filter(query_hash, filter_operator_value) + "(#{filter_config[:table_name]}.#{query_hash[:attribute_key]})::text #{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 + def text_search_on_display_id?(query_hash) + query_hash[:attribute_key] == 'display_id' && %w[contains does_not_contain].include?(query_hash[:filter_operator]) + end + def validate_single_condition(condition) return if condition['query_operator'].nil? return if condition['query_operator'].empty? diff --git a/app/javascript/dashboard/api/assignableAgents.js b/app/javascript/dashboard/api/assignableAgents.js index 5b999facf..febb05ff9 100644 --- a/app/javascript/dashboard/api/assignableAgents.js +++ b/app/javascript/dashboard/api/assignableAgents.js @@ -6,9 +6,12 @@ class AssignableAgents extends ApiClient { super('assignable_agents', { accountScoped: true }); } - get(inboxIds) { + get(inboxIds, { includeAgentBots = false } = {}) { return axios.get(this.url, { - params: { inbox_ids: inboxIds }, + params: { + inbox_ids: inboxIds, + ...(includeAgentBots ? { include_agent_bots: true } : {}), + }, }); } } diff --git a/app/javascript/dashboard/api/captain/assistant.js b/app/javascript/dashboard/api/captain/assistant.js index 157eba74e..dcd92f735 100644 --- a/app/javascript/dashboard/api/captain/assistant.js +++ b/app/javascript/dashboard/api/captain/assistant.js @@ -1,6 +1,10 @@ /* global axios */ import ApiClient from '../ApiClient'; +// Viewer's UTC offset in hours, matching the reports API convention so the +// backend can anchor calendar ranges to the viewer's day. +const getTimezoneOffset = () => -new Date().getTimezoneOffset() / 60; + class CaptainAssistant extends ApiClient { constructor() { super('captain/assistants', { accountScoped: true }); @@ -21,6 +25,18 @@ class CaptainAssistant extends ApiClient { message_history: messageHistory, }); } + + getStats({ assistantId, range }) { + return axios.get(`${this.url}/${assistantId}/stats`, { + params: { range, timezone_offset: getTimezoneOffset() }, + }); + } + + getSummary({ assistantId, range }) { + return axios.get(`${this.url}/${assistantId}/summary`, { + params: { range, timezone_offset: getTimezoneOffset() }, + }); + } } export default new CaptainAssistant(); diff --git a/app/javascript/dashboard/api/inbox/conversation.js b/app/javascript/dashboard/api/inbox/conversation.js index f94fca452..08820aac9 100644 --- a/app/javascript/dashboard/api/inbox/conversation.js +++ b/app/javascript/dashboard/api/inbox/conversation.js @@ -62,9 +62,10 @@ class ConversationApi extends ApiClient { }); } - assignAgent({ conversationId, agentId }) { + assignAgent({ conversationId, agentId, assigneeType }) { return axios.post(`${this.url}/${conversationId}/assignments`, { assignee_id: agentId, + assignee_type: assigneeType, }); } diff --git a/app/javascript/dashboard/api/specs/assignableAgents.spec.js b/app/javascript/dashboard/api/specs/assignableAgents.spec.js index d553d55cb..be00cf07f 100644 --- a/app/javascript/dashboard/api/specs/assignableAgents.spec.js +++ b/app/javascript/dashboard/api/specs/assignableAgents.spec.js @@ -26,5 +26,15 @@ describe('#AssignableAgentsAPI', () => { }, }); }); + + it('#getAssignableAgents with agent bots', () => { + assignableAgentsAPI.get([1], { includeAgentBots: true }); + expect(axiosMock.get).toHaveBeenCalledWith('/api/v1/assignable_agents', { + params: { + inbox_ids: [1], + include_agent_bots: true, + }, + }); + }); }); }); diff --git a/app/javascript/dashboard/api/specs/inbox/conversation.spec.js b/app/javascript/dashboard/api/specs/inbox/conversation.spec.js index de0d7a7d0..ea0ef3e75 100644 --- a/app/javascript/dashboard/api/specs/inbox/conversation.spec.js +++ b/app/javascript/dashboard/api/specs/inbox/conversation.spec.js @@ -90,11 +90,16 @@ describe('#ConversationAPI', () => { }); it('#assignAgent', () => { - conversationAPI.assignAgent({ conversationId: 12, agentId: 34 }); + conversationAPI.assignAgent({ + conversationId: 12, + agentId: 34, + assigneeType: 'AgentBot', + }); expect(axiosMock.post).toHaveBeenCalledWith( `/api/v1/conversations/12/assignments`, { assignee_id: 34, + assignee_type: 'AgentBot', } ); }); diff --git a/app/javascript/dashboard/components-next/captain/PageLayout.vue b/app/javascript/dashboard/components-next/captain/PageLayout.vue index 817608f62..6c0d37d15 100644 --- a/app/javascript/dashboard/components-next/captain/PageLayout.vue +++ b/app/javascript/dashboard/components-next/captain/PageLayout.vue @@ -182,7 +182,8 @@ const handleCreateAssistant = () => { -
+
+
+import { computed, ref, watch } from 'vue'; +import { useRoute, useRouter } from 'vue-router'; +import { LocalStorage } from 'shared/helpers/localStorage'; + +const props = defineProps({ + knowledge: { + type: Object, + default: () => ({ approved: 0, pending: 0, documents: 0, coverage: 0 }), + }, +}); + +const route = useRoute(); +const router = useRouter(); + +// Dismissal is remembered per assistant for 24 hours (setFlag's default expiry). +const DISMISS_STORE = 'captain_overview_coverage_banner'; + +const accountId = computed(() => route.params.accountId); +const assistantId = computed(() => route.params.assistantId); + +// Re-read the stored flag whenever the assistant changes, otherwise the banner +// would keep the first assistant's dismissed state after switching. +const dismissed = ref(false); + +watch( + [accountId, assistantId], + ([account, assistant]) => { + dismissed.value = LocalStorage.getFlag(DISMISS_STORE, account, assistant); + }, + { immediate: true } +); + +// Thin coverage paired with a large review backlog: approving the pending FAQs +// is the quickest lever to lift auto-resolution, so nudge the team to act. +const COVERAGE_THRESHOLD = 85; +const PENDING_THRESHOLD = 100; + +const showBanner = computed( + () => + !dismissed.value && + (props.knowledge?.coverage ?? 0) < COVERAGE_THRESHOLD && + (props.knowledge?.pending ?? 0) > PENDING_THRESHOLD +); + +const dismiss = () => { + LocalStorage.setFlag(DISMISS_STORE, accountId.value, assistantId.value); + dismissed.value = true; +}; + +const goToPending = () => { + router.push({ + name: 'captain_assistants_responses_pending', + params: { + accountId: route.params.accountId, + assistantId: route.params.assistantId, + }, + }); +}; + + + diff --git a/app/javascript/dashboard/components-next/captain/pageComponents/overview/InboxBanner.vue b/app/javascript/dashboard/components-next/captain/pageComponents/overview/InboxBanner.vue new file mode 100644 index 000000000..45952fa11 --- /dev/null +++ b/app/javascript/dashboard/components-next/captain/pageComponents/overview/InboxBanner.vue @@ -0,0 +1,73 @@ + + + diff --git a/app/javascript/dashboard/components-next/captain/pageComponents/overview/KnowledgeCard.vue b/app/javascript/dashboard/components-next/captain/pageComponents/overview/KnowledgeCard.vue new file mode 100644 index 000000000..80c7ae69d --- /dev/null +++ b/app/javascript/dashboard/components-next/captain/pageComponents/overview/KnowledgeCard.vue @@ -0,0 +1,87 @@ + + + diff --git a/app/javascript/dashboard/components-next/captain/pageComponents/overview/MetricCard.vue b/app/javascript/dashboard/components-next/captain/pageComponents/overview/MetricCard.vue new file mode 100644 index 000000000..9f8a76f43 --- /dev/null +++ b/app/javascript/dashboard/components-next/captain/pageComponents/overview/MetricCard.vue @@ -0,0 +1,40 @@ + + + diff --git a/app/javascript/dashboard/components-next/captain/pageComponents/overview/QuickLinks.vue b/app/javascript/dashboard/components-next/captain/pageComponents/overview/QuickLinks.vue new file mode 100644 index 000000000..e9fb45a07 --- /dev/null +++ b/app/javascript/dashboard/components-next/captain/pageComponents/overview/QuickLinks.vue @@ -0,0 +1,81 @@ + + + diff --git a/app/javascript/dashboard/components-next/captain/pageComponents/overview/RangeSelector.vue b/app/javascript/dashboard/components-next/captain/pageComponents/overview/RangeSelector.vue new file mode 100644 index 000000000..d003480dd --- /dev/null +++ b/app/javascript/dashboard/components-next/captain/pageComponents/overview/RangeSelector.vue @@ -0,0 +1,78 @@ + + + diff --git a/app/javascript/dashboard/components-next/captain/pageComponents/overview/WelcomeCard.vue b/app/javascript/dashboard/components-next/captain/pageComponents/overview/WelcomeCard.vue new file mode 100644 index 000000000..df336a7e4 --- /dev/null +++ b/app/javascript/dashboard/components-next/captain/pageComponents/overview/WelcomeCard.vue @@ -0,0 +1,75 @@ + + + + diff --git a/app/javascript/dashboard/components-next/captain/pageComponents/switcher/AssistantSwitcher.vue b/app/javascript/dashboard/components-next/captain/pageComponents/switcher/AssistantSwitcher.vue index a67d685f5..c78842241 100644 --- a/app/javascript/dashboard/components-next/captain/pageComponents/switcher/AssistantSwitcher.vue +++ b/app/javascript/dashboard/components-next/captain/pageComponents/switcher/AssistantSwitcher.vue @@ -65,7 +65,7 @@ const handleAssistantChange = async assistant => { const currentRouteName = route.name; const targetRouteName = - currentRouteName || 'captain_assistants_responses_index'; + currentRouteName || 'captain_assistants_overview_index'; await fetchDataForRoute(targetRouteName, assistant.id); diff --git a/app/javascript/dashboard/components-next/sidebar/Sidebar.vue b/app/javascript/dashboard/components-next/sidebar/Sidebar.vue index 15a007a74..a78460ca2 100644 --- a/app/javascript/dashboard/components-next/sidebar/Sidebar.vue +++ b/app/javascript/dashboard/components-next/sidebar/Sidebar.vue @@ -75,6 +75,16 @@ const hasConversationUnreadCounts = computed(() => { ); }); +const hasFilteredUnreadCounts = computed(() => { + return ( + hasConversationUnreadCounts.value && + isFeatureEnabledonAccount.value( + accountId.value, + FEATURE_FLAGS.UNREAD_COUNT_FOR_FILTERS + ) + ); +}); + const fetchConversationUnreadCounts = ([currentAccountId, isEnabled]) => { if (!currentAccountId) return; @@ -199,6 +209,18 @@ const getLabelUnreadCount = useMapGetter( const getTeamUnreadCount = useMapGetter( 'conversationUnreadCounts/getTeamUnreadCount' ); +const mentionsUnreadCount = useMapGetter( + 'conversationUnreadCounts/getMentionsUnreadCount' +); +const participatingUnreadCount = useMapGetter( + 'conversationUnreadCounts/getParticipatingUnreadCount' +); +const unattendedUnreadCount = useMapGetter( + 'conversationUnreadCounts/getUnattendedUnreadCount' +); +const getFolderUnreadCount = useMapGetter( + 'conversationUnreadCounts/getFolderUnreadCount' +); const teams = useMapGetter('teams/getMyTeams'); const contactCustomViews = useMapGetter('customViews/getContactCustomViews'); const conversationCustomViews = useMapGetter( @@ -226,14 +248,22 @@ watch([accountId, currentUserId], fetchSidebarSortPreferences, { immediate: true, }); +const hasUnreadCountsForSection = section => { + if (section === SIDEBAR_SORT_SECTIONS.FOLDERS) { + return hasFilteredUnreadCounts.value; + } + + return hasConversationUnreadCounts.value; +}; + const getSortOptionsForSection = section => getSidebarSortOptions(section, { - hasUnreadCounts: hasConversationUnreadCounts.value, + hasUnreadCounts: hasUnreadCountsForSection(section), }); const getSortForSection = section => resolveSidebarSort(section, getSidebarSectionSort.value(section), { - hasUnreadCounts: hasConversationUnreadCounts.value, + hasUnreadCounts: hasUnreadCountsForSection(section), }); const updateSortPreference = (section, sortBy) => { @@ -253,6 +283,7 @@ const sortedFolders = computed(() => sortSidebarItems(conversationCustomViews.value, { sortBy: getSortForSection(SIDEBAR_SORT_SECTIONS.FOLDERS), labelKey: view => view.name, + unreadCountKey: view => getFolderUnreadCount.value(view.id), }) ); @@ -342,6 +373,9 @@ const menuItems = computed(() => { name: 'Mentions', label: t('SIDEBAR.MENTIONED_CONVERSATIONS'), icon: 'i-lucide-at-sign', + badgeCount: hasFilteredUnreadCounts.value + ? mentionsUnreadCount.value + : 0, activeOn: ['conversation_through_mentions'], to: accountScopedRoute('conversation_mentions'), }, @@ -349,6 +383,9 @@ const menuItems = computed(() => { name: 'Participating', label: t('SIDEBAR.PARTICIPATING_CONVERSATIONS'), icon: 'i-lucide-user-round-check', + badgeCount: hasFilteredUnreadCounts.value + ? participatingUnreadCount.value + : 0, activeOn: ['conversation_through_participating'], to: accountScopedRoute('conversation_participating'), }, @@ -357,6 +394,9 @@ const menuItems = computed(() => { activeOn: ['conversation_through_unattended'], label: t('SIDEBAR.UNATTENDED_CONVERSATIONS'), icon: 'i-lucide-clock-alert', + badgeCount: hasFilteredUnreadCounts.value + ? unattendedUnreadCount.value + : 0, to: accountScopedRoute('conversation_unattended'), }, { @@ -370,6 +410,9 @@ const menuItems = computed(() => { children: sortedFolders.value.map(view => ({ name: `${view.name}-${view.id}`, label: view.name, + badgeCount: hasFilteredUnreadCounts.value + ? getFolderUnreadCount.value(view.id) + : 0, to: accountScopedRoute('folder_conversations', { id: view.id }), })), }, @@ -440,6 +483,14 @@ const menuItems = computed(() => { label: t('SIDEBAR.CAPTAIN'), activeOn: ['captain_assistants_create_index'], children: [ + { + name: 'Overview', + label: t('SIDEBAR.CAPTAIN_OVERVIEW'), + activeOn: ['captain_assistants_overview_index'], + to: accountScopedRoute('captain_assistants_index', { + navigationPath: 'captain_assistants_overview_index', + }), + }, { name: 'FAQs', label: t('SIDEBAR.CAPTAIN_RESPONSES'), diff --git a/app/javascript/dashboard/components/widgets/ChannelItem.vue b/app/javascript/dashboard/components/widgets/ChannelItem.vue index 2429ebe7b..2652b582b 100644 --- a/app/javascript/dashboard/components/widgets/ChannelItem.vue +++ b/app/javascript/dashboard/components/widgets/ChannelItem.vue @@ -1,6 +1,7 @@ + + diff --git a/app/javascript/dashboard/routes/dashboard/captain/captain.routes.js b/app/javascript/dashboard/routes/dashboard/captain/captain.routes.js index 1ab4fa501..8448f32cf 100644 --- a/app/javascript/dashboard/routes/dashboard/captain/captain.routes.js +++ b/app/javascript/dashboard/routes/dashboard/captain/captain.routes.js @@ -6,6 +6,7 @@ import CaptainPageRouteView from './pages/CaptainPageRouteView.vue'; import AssistantsIndexPage from './pages/AssistantsIndexPage.vue'; import AssistantEmptyStateIndex from './assistants/Index.vue'; +import AssistantOverviewIndex from './assistants/overview/Index.vue'; import AssistantSettingsIndex from './assistants/settings/Settings.vue'; import AssistantInboxesIndex from './assistants/inboxes/Index.vue'; import AssistantPlaygroundIndex from './assistants/playground/Index.vue'; @@ -36,6 +37,12 @@ const metaV2 = { }; const assistantRoutes = [ + { + path: frontendURL('accounts/:accountId/captain/:assistantId/overview'), + component: AssistantOverviewIndex, + name: 'captain_assistants_overview_index', + meta, + }, { path: frontendURL('accounts/:accountId/captain/:assistantId/faqs'), component: ResponsesIndex, @@ -129,7 +136,7 @@ export const routes = [ return { name: 'captain_assistants_index', params: { - navigationPath: 'captain_assistants_responses_index', + navigationPath: 'captain_assistants_overview_index', ...to.params, }, }; diff --git a/app/javascript/dashboard/routes/dashboard/captain/pages/AssistantsIndexPage.vue b/app/javascript/dashboard/routes/dashboard/captain/pages/AssistantsIndexPage.vue index 01ec64618..d366d4254 100644 --- a/app/javascript/dashboard/routes/dashboard/captain/pages/AssistantsIndexPage.vue +++ b/app/javascript/dashboard/routes/dashboard/captain/pages/AssistantsIndexPage.vue @@ -53,6 +53,7 @@ const routeToLastActiveAssistant = () => { const { navigationPath } = route.params; const isAValidRoute = [ + 'captain_assistants_overview_index', // Overview page 'captain_assistants_responses_index', // Faq page 'captain_assistants_documents_index', // Document page 'captain_assistants_scenarios_index', // Scenario page @@ -64,7 +65,7 @@ const routeToLastActiveAssistant = () => { const navigateTo = isAValidRoute ? navigationPath - : 'captain_assistants_responses_index'; + : 'captain_assistants_overview_index'; return routeToView(navigateTo, { accountId: route.params.accountId, diff --git a/app/javascript/dashboard/routes/dashboard/conversation/ConversationAction.vue b/app/javascript/dashboard/routes/dashboard/conversation/ConversationAction.vue index fede6e3cb..1e33a192b 100644 --- a/app/javascript/dashboard/routes/dashboard/conversation/ConversationAction.vue +++ b/app/javascript/dashboard/routes/dashboard/conversation/ConversationAction.vue @@ -25,7 +25,7 @@ export default { }, }, setup() { - const { agentsList } = useAgentsList(); + const { agentsList } = useAgentsList(true, { includeAgentBots: true }); return { agentsList, }; @@ -81,18 +81,27 @@ export default { }, assignedAgent: { get() { - return this.currentChat.meta.assignee; + const assignee = this.currentChat.meta.assignee; + return ( + assignee && { + ...assignee, + assignee_type: this.currentChat.meta.assignee_type || 'User', + } + ); }, set(agent) { const agentId = agent ? agent.id : null; + const assigneeType = agent ? agent.assignee_type || 'User' : null; this.$store.dispatch('setCurrentChatAssignee', { conversationId: this.currentChat.id, assignee: agent, + assigneeType, }); this.$store .dispatch('assignAgent', { conversationId: this.currentChat.id, agentId, + assigneeType, }) .then(() => { useAlert(this.$t('CONVERSATION.CHANGE_AGENT')); @@ -152,7 +161,10 @@ export default { if (!this.assignedAgent) { return true; } - if (this.assignedAgent.id !== this.currentUser.id) { + if ( + this.assignedAgent.id !== this.currentUser.id || + (this.assignedAgent.assignee_type || 'User') !== 'User' + ) { return true; } return false; @@ -183,7 +195,11 @@ export default { this.assignedAgent = selfAssign; }, onClickAssignAgent(selectedItem) { - if (this.assignedAgent && this.assignedAgent.id === selectedItem.id) { + if ( + this.assignedAgent?.id === selectedItem.id && + (this.assignedAgent?.assignee_type || 'User') === + (selectedItem.assignee_type || 'User') + ) { this.assignedAgent = null; } else { this.assignedAgent = selectedItem; diff --git a/app/javascript/dashboard/routes/dashboard/onboarding/inbox-setup/useChannelConfig.js b/app/javascript/dashboard/routes/dashboard/onboarding/inbox-setup/useChannelConfig.js index ba2d6f0dc..9618c5e44 100644 --- a/app/javascript/dashboard/routes/dashboard/onboarding/inbox-setup/useChannelConfig.js +++ b/app/javascript/dashboard/routes/dashboard/onboarding/inbox-setup/useChannelConfig.js @@ -1,4 +1,5 @@ import { useMapGetter } from 'dashboard/composables/store'; +import { IS_INSTAGRAM_WHATSAPP_INBOX_CREATION_DISABLED } from 'dashboard/constants/globals'; // OAuth/SDK channels need installation-level app credentials to be usable. When // the credential is missing the channel is "not configured" and is hidden from @@ -13,11 +14,14 @@ export function useChannelConfig() { // WhatsApp is onboarded only via Meta embedded signup, which needs both the // app id (not the 'none' sentinel) and the signup configuration id. whatsapp: () => + !IS_INSTAGRAM_WHATSAPP_INBOX_CREATION_DISABLED && Boolean(installationConfig.whatsappAppId) && installationConfig.whatsappAppId !== 'none' && Boolean(installationConfig.whatsappConfigurationId), facebook: () => Boolean(installationConfig.fbAppId), - instagram: () => Boolean(installationConfig.instagramAppId), + instagram: () => + !IS_INSTAGRAM_WHATSAPP_INBOX_CREATION_DISABLED && + Boolean(installationConfig.instagramAppId), tiktok: () => Boolean(installationConfig.tiktokAppId), gmail: () => Boolean(installationConfig.googleOAuthClientId), outlook: () => Boolean(globalConfig.value.azureAppId), diff --git a/app/javascript/dashboard/routes/dashboard/onboarding/specs/inbox-setup/useDetectedChannels.spec.js b/app/javascript/dashboard/routes/dashboard/onboarding/specs/inbox-setup/useDetectedChannels.spec.js index 1134d4494..875bfaf0d 100644 --- a/app/javascript/dashboard/routes/dashboard/onboarding/specs/inbox-setup/useDetectedChannels.spec.js +++ b/app/javascript/dashboard/routes/dashboard/onboarding/specs/inbox-setup/useDetectedChannels.spec.js @@ -6,6 +6,13 @@ import { useDetectedChannels } from '../../inbox-setup/useDetectedChannels'; vi.mock('vue-router'); +// Neutralize the temporary Instagram/WhatsApp kill switch so these specs keep +// covering the credential-based gating it currently short-circuits. +vi.mock('dashboard/constants/globals', async importOriginal => ({ + ...(await importOriginal()), + IS_INSTAGRAM_WHATSAPP_INBOX_CREATION_DISABLED: false, +})); + // Mounts the composable against a real store and the real useAccount (only // useRoute and the underlying getters are faked), so a change to how useAccount // resolves the current account is exercised here too. The real ./constants are diff --git a/app/javascript/dashboard/routes/dashboard/settings/inbox/channels/Whatsapp.vue b/app/javascript/dashboard/routes/dashboard/settings/inbox/channels/Whatsapp.vue index b8b8126c7..98cc90fee 100644 --- a/app/javascript/dashboard/routes/dashboard/settings/inbox/channels/Whatsapp.vue +++ b/app/javascript/dashboard/routes/dashboard/settings/inbox/channels/Whatsapp.vue @@ -7,6 +7,7 @@ import ThreeSixtyDialogWhatsapp from './360DialogWhatsapp.vue'; import CloudWhatsapp from './CloudWhatsapp.vue'; import WhatsappEmbeddedSignup from './WhatsappEmbeddedSignup.vue'; import ChannelSelector from 'dashboard/components/ChannelSelector.vue'; +import { IS_INSTAGRAM_WHATSAPP_INBOX_CREATION_DISABLED } from 'dashboard/constants/globals'; const route = useRoute(); const router = useRouter(); @@ -23,6 +24,7 @@ const PROVIDER_TYPES = { const hasWhatsappAppId = computed(() => { return ( + !IS_INSTAGRAM_WHATSAPP_INBOX_CREATION_DISABLED && window.chatwootConfig?.whatsappAppId && window.chatwootConfig.whatsappAppId !== 'none' ); diff --git a/app/javascript/dashboard/store/modules/conversationUnreadCounts.js b/app/javascript/dashboard/store/modules/conversationUnreadCounts.js index c03249311..03bbb31eb 100644 --- a/app/javascript/dashboard/store/modules/conversationUnreadCounts.js +++ b/app/javascript/dashboard/store/modules/conversationUnreadCounts.js @@ -6,6 +6,10 @@ export const state = { inboxes: {}, labels: {}, teams: {}, + mentionsCount: 0, + participatingCount: 0, + unattendedCount: 0, + folders: {}, }; const normalizeCount = count => { @@ -37,6 +41,18 @@ export const getters = { getTeamUnreadCount: $state => teamId => { return $state.teams[String(teamId)] || 0; }, + getMentionsUnreadCount($state) { + return $state.mentionsCount; + }, + getParticipatingUnreadCount($state) { + return $state.participatingCount; + }, + getUnattendedUnreadCount($state) { + return $state.unattendedCount; + }, + getFolderUnreadCount: $state => folderId => { + return $state.folders[String(folderId)] || 0; + }, getInboxUnreadCounts($state) { return $state.inboxes; }, @@ -46,6 +62,9 @@ export const getters = { getTeamUnreadCounts($state) { return $state.teams; }, + getFolderUnreadCounts($state) { + return $state.folders; + }, }; export const actions = { @@ -68,6 +87,10 @@ export const mutations = { $state.inboxes = normalizeCounts(payload.inboxes); $state.labels = normalizeCounts(payload.labels); $state.teams = normalizeCounts(payload.teams); + $state.mentionsCount = normalizeCount(payload.mentions_count); + $state.participatingCount = normalizeCount(payload.participating_count); + $state.unattendedCount = normalizeCount(payload.unattended_count); + $state.folders = normalizeCounts(payload.folders); }, }; diff --git a/app/javascript/dashboard/store/modules/conversationWatchers.js b/app/javascript/dashboard/store/modules/conversationWatchers.js index c690da21f..da29acaf6 100644 --- a/app/javascript/dashboard/store/modules/conversationWatchers.js +++ b/app/javascript/dashboard/store/modules/conversationWatchers.js @@ -1,8 +1,15 @@ import types from '../mutation-types'; import { throwErrorMessage } from 'dashboard/store/utils/api'; +import { FEATURE_FLAGS } from 'dashboard/featureFlags'; import ConversationInboxApi from '../../api/inbox/conversation'; +const FILTERED_UNREAD_COUNTS_REFRESH_RETRY_MS = 30000; +const FILTERED_UNREAD_COUNTS_REFRESH_RETRY_JITTER_MS = 15000; +const getFilteredUnreadCountsRefreshRetryDelay = () => + FILTERED_UNREAD_COUNTS_REFRESH_RETRY_MS + + Math.random() * FILTERED_UNREAD_COUNTS_REFRESH_RETRY_JITTER_MS; + const state = { records: {}, uiFlags: { @@ -20,6 +27,43 @@ export const getters = { }, }; +const hasFeatureEnabled = (rootGetters, featureFlag) => { + const accountId = rootGetters?.getCurrentAccountId; + const isFeatureEnabled = rootGetters?.['accounts/isFeatureEnabledonAccount']; + + return Boolean(accountId && isFeatureEnabled?.(accountId, featureFlag)); +}; + +const hasCurrentUser = (participants, currentUserId) => + (Array.isArray(participants) ? participants : []).some( + participant => participant.id === currentUserId + ); + +const refreshConversationUnreadCounts = dispatch => { + dispatch('conversationUnreadCounts/get', {}, { root: true }); + setTimeout( + () => dispatch('conversationUnreadCounts/get', {}, { root: true }), + getFilteredUnreadCountsRefreshRetryDelay() + ); +}; + +const shouldRefreshConversationUnreadCounts = ( + { rootGetters, state: moduleState }, + conversationId, + participants +) => { + const currentUserId = + rootGetters?.getCurrentUserID || rootGetters?.getCurrentUser?.id; + + return ( + currentUserId && + hasFeatureEnabled(rootGetters, FEATURE_FLAGS.CONVERSATION_UNREAD_COUNTS) && + hasFeatureEnabled(rootGetters, FEATURE_FLAGS.UNREAD_COUNT_FOR_FILTERS) && + hasCurrentUser(moduleState.records[conversationId], currentUserId) !== + hasCurrentUser(participants, currentUserId) + ); +}; + export const actions = { show: async ({ commit }, { conversationId }) => { commit(types.SET_CONVERSATION_PARTICIPANTS_UI_FLAG, { @@ -42,7 +86,10 @@ export const actions = { } }, - update: async ({ commit }, { conversationId, userIds }) => { + update: async ( + { commit, dispatch, rootGetters, state: moduleState }, + { conversationId, userIds } + ) => { commit(types.SET_CONVERSATION_PARTICIPANTS_UI_FLAG, { isUpdating: true, }); @@ -52,10 +99,18 @@ export const actions = { conversationId, userIds, }); + const shouldRefreshUnreadCounts = shouldRefreshConversationUnreadCounts( + { rootGetters, state: moduleState }, + conversationId, + response.data + ); commit(types.SET_CONVERSATION_PARTICIPANTS, { conversationId, data: response.data, }); + if (shouldRefreshUnreadCounts) { + refreshConversationUnreadCounts(dispatch); + } } catch (error) { throwErrorMessage(error); } finally { diff --git a/app/javascript/dashboard/store/modules/conversations/actions.js b/app/javascript/dashboard/store/modules/conversations/actions.js index 72ab8fa5e..f8fdecc36 100644 --- a/app/javascript/dashboard/store/modules/conversations/actions.js +++ b/app/javascript/dashboard/store/modules/conversations/actions.js @@ -208,23 +208,31 @@ const actions = { } }, - assignAgent: async ({ dispatch }, { conversationId, agentId }) => { + assignAgent: async ( + { dispatch }, + { conversationId, agentId, assigneeType } + ) => { try { const response = await ConversationApi.assignAgent({ conversationId, agentId, + assigneeType, }); dispatch('setCurrentChatAssignee', { conversationId, assignee: response.data, + assigneeType, }); } catch (error) { // Handle error } }, - setCurrentChatAssignee({ commit }, { conversationId, assignee }) { - commit(types.ASSIGN_AGENT, { conversationId, assignee }); + setCurrentChatAssignee( + { commit }, + { conversationId, assignee, assigneeType } + ) { + commit(types.ASSIGN_AGENT, { conversationId, assignee, assigneeType }); }, assignTeam: async ({ dispatch }, { conversationId, teamId }) => { diff --git a/app/javascript/dashboard/store/modules/conversations/index.js b/app/javascript/dashboard/store/modules/conversations/index.js index 8a13940c0..4f539e22d 100644 --- a/app/javascript/dashboard/store/modules/conversations/index.js +++ b/app/javascript/dashboard/store/modules/conversations/index.js @@ -108,10 +108,11 @@ export const mutations = { } }, - [types.ASSIGN_AGENT](_state, { conversationId, assignee }) { + [types.ASSIGN_AGENT](_state, { conversationId, assignee, assigneeType }) { const chat = getConversationById(_state)(conversationId); if (chat) { chat.meta.assignee = assignee; + chat.meta.assignee_type = assigneeType; } }, diff --git a/app/javascript/dashboard/store/modules/customViews.js b/app/javascript/dashboard/store/modules/customViews.js index 388388e0f..b4dff3610 100644 --- a/app/javascript/dashboard/store/modules/customViews.js +++ b/app/javascript/dashboard/store/modules/customViews.js @@ -1,11 +1,17 @@ import * as MutationHelpers from 'shared/helpers/vuex/mutationHelpers'; import types from '../mutation-types'; import CustomViewsAPI from '../../api/customViews'; +import { FEATURE_FLAGS } from 'dashboard/featureFlags'; const VIEW_TYPES = { CONVERSATION: 'conversation', CONTACT: 'contact', }; +const FILTERED_UNREAD_COUNTS_REFRESH_RETRY_MS = 30000; +const FILTERED_UNREAD_COUNTS_REFRESH_RETRY_JITTER_MS = 15000; +const getFilteredUnreadCountsRefreshRetryDelay = () => + FILTERED_UNREAD_COUNTS_REFRESH_RETRY_MS + + Math.random() * FILTERED_UNREAD_COUNTS_REFRESH_RETRY_JITTER_MS; // use to normalize the filter type const FILTER_KEYS = { @@ -21,6 +27,38 @@ const getFolderContactId = folder => folder?.query?.payload?.find(filter => filter.attribute_key === 'contact_id') ?.values?.[0]; +const hasFeatureEnabled = (rootGetters, featureFlag) => { + const accountId = rootGetters?.getCurrentAccountId; + const isFeatureEnabled = rootGetters?.['accounts/isFeatureEnabledonAccount']; + + return Boolean(accountId && isFeatureEnabled?.(accountId, featureFlag)); +}; + +const shouldRefreshConversationUnreadCounts = (filterType, rootGetters) => { + return ( + FILTER_KEYS[filterType] === VIEW_TYPES.CONVERSATION && + hasFeatureEnabled(rootGetters, FEATURE_FLAGS.CONVERSATION_UNREAD_COUNTS) && + hasFeatureEnabled(rootGetters, FEATURE_FLAGS.UNREAD_COUNT_FOR_FILTERS) + ); +}; + +const dispatchConversationUnreadCounts = dispatch => { + dispatch('conversationUnreadCounts/get', {}, { root: true }); +}; + +const refreshConversationUnreadCounts = ( + { dispatch, rootGetters }, + filterType +) => { + if (!shouldRefreshConversationUnreadCounts(filterType, rootGetters)) return; + + dispatchConversationUnreadCounts(dispatch); + setTimeout( + () => dispatchConversationUnreadCounts(dispatch), + getFilteredUnreadCountsRefreshRetryDelay() + ); +}; + export const state = { [VIEW_TYPES.CONVERSATION]: { records: [], @@ -71,14 +109,19 @@ export const actions = { commit(types.SET_CUSTOM_VIEW_UI_FLAG, { isFetching: false }); } }, - create: async function createCustomViews({ commit }, obj) { + create: async function createCustomViews( + { commit, dispatch, rootGetters }, + obj + ) { commit(types.SET_CUSTOM_VIEW_UI_FLAG, { isCreating: true }); try { const response = await CustomViewsAPI.create(obj); + const filterType = FILTER_KEYS[obj.filter_type]; commit(types.ADD_CUSTOM_VIEW, { data: response.data, - filterType: FILTER_KEYS[obj.filter_type], + filterType, }); + refreshConversationUnreadCounts({ dispatch, rootGetters }, filterType); return response; } catch (error) { const errorMessage = error?.response?.data?.message; @@ -87,14 +130,19 @@ export const actions = { commit(types.SET_CUSTOM_VIEW_UI_FLAG, { isCreating: false }); } }, - update: async function updateCustomViews({ commit }, obj) { + update: async function updateCustomViews( + { commit, dispatch, rootGetters }, + obj + ) { commit(types.SET_CUSTOM_VIEW_UI_FLAG, { isCreating: true }); try { const response = await CustomViewsAPI.update(obj.id, obj); + const filterType = FILTER_KEYS[obj.filter_type]; commit(types.UPDATE_CUSTOM_VIEW, { data: response.data, - filterType: FILTER_KEYS[obj.filter_type], + filterType, }); + refreshConversationUnreadCounts({ dispatch, rootGetters }, filterType); } catch (error) { const errorMessage = error?.response?.data?.message; throw new Error(errorMessage); @@ -102,11 +150,12 @@ export const actions = { commit(types.SET_CUSTOM_VIEW_UI_FLAG, { isCreating: false }); } }, - delete: async ({ commit }, { id, filterType }) => { + delete: async ({ commit, dispatch, rootGetters }, { id, filterType }) => { commit(types.SET_CUSTOM_VIEW_UI_FLAG, { isDeleting: true }); try { await CustomViewsAPI.deleteCustomViews(id, filterType); commit(types.DELETE_CUSTOM_VIEW, { data: id, filterType }); + refreshConversationUnreadCounts({ dispatch, rootGetters }, filterType); } catch (error) { throw new Error(error); } finally { diff --git a/app/javascript/dashboard/store/modules/inboxAssignableAgents.js b/app/javascript/dashboard/store/modules/inboxAssignableAgents.js index 1b129e3ef..6dbee9ea8 100644 --- a/app/javascript/dashboard/store/modules/inboxAssignableAgents.js +++ b/app/javascript/dashboard/store/modules/inboxAssignableAgents.js @@ -7,31 +7,52 @@ const state = { }, }; +const recordKey = (inboxId, { includeAgentBots = false } = {}) => + includeAgentBots ? `${inboxId}:with_agent_bots` : inboxId; + export const types = { SET_INBOX_ASSIGNABLE_AGENTS_UI_FLAG: 'SET_INBOX_ASSIGNABLE_AGENTS_UI_FLAG', SET_INBOX_ASSIGNABLE_AGENTS: 'SET_INBOX_ASSIGNABLE_AGENTS', }; export const getters = { - getAssignableAgents: $state => inboxId => { - const allAgents = $state.records[inboxId] || []; - const verifiedAgents = allAgents.filter(record => record.confirmed); - return verifiedAgents; - }, + getAssignableAgents: + $state => + (inboxId, options = {}) => { + const includeAgentBots = options.includeAgentBots || false; + const allAgents = $state.records[recordKey(inboxId, options)] || []; + const verifiedAgents = allAgents.filter( + record => + record.confirmed || + (includeAgentBots && record.assignee_type === 'AgentBot') + ); + return verifiedAgents; + }, getUIFlags($state) { return $state.uiFlags; }, }; export const actions = { - async fetch({ commit }, inboxIds) { + async fetch({ commit }, actionPayload) { + const inboxIds = Array.isArray(actionPayload) + ? actionPayload + : actionPayload.inboxIds; + const includeAgentBots = + !Array.isArray(actionPayload) && actionPayload.includeAgentBots; commit(types.SET_INBOX_ASSIGNABLE_AGENTS_UI_FLAG, { isFetching: true }); try { const { data: { payload }, - } = await AssignableAgentsAPI.get(inboxIds); + } = await AssignableAgentsAPI.get(inboxIds, { includeAgentBots }); + if (includeAgentBots) { + commit(types.SET_INBOX_ASSIGNABLE_AGENTS, { + inboxId: inboxIds.join(','), + members: payload, + }); + } commit(types.SET_INBOX_ASSIGNABLE_AGENTS, { - inboxId: inboxIds.join(','), + inboxId: recordKey(inboxIds.join(','), { includeAgentBots }), members: payload, }); } catch (error) { diff --git a/app/javascript/dashboard/store/modules/specs/conversationUnreadCounts/actions.spec.js b/app/javascript/dashboard/store/modules/specs/conversationUnreadCounts/actions.spec.js index 29fadc897..c3d040d84 100644 --- a/app/javascript/dashboard/store/modules/specs/conversationUnreadCounts/actions.spec.js +++ b/app/javascript/dashboard/store/modules/specs/conversationUnreadCounts/actions.spec.js @@ -19,6 +19,10 @@ describe('#actions', () => { inboxes: { 1: '2' }, labels: { 3: 4 }, teams: { 5: 6 }, + mentions_count: 7, + participating_count: 8, + unattended_count: 9, + folders: { 10: 11 }, }; axios.get.mockResolvedValue({ data: { payload } }); diff --git a/app/javascript/dashboard/store/modules/specs/conversationUnreadCounts/getters.spec.js b/app/javascript/dashboard/store/modules/specs/conversationUnreadCounts/getters.spec.js index 9fe19e22d..9b7467ae1 100644 --- a/app/javascript/dashboard/store/modules/specs/conversationUnreadCounts/getters.spec.js +++ b/app/javascript/dashboard/store/modules/specs/conversationUnreadCounts/getters.spec.js @@ -7,6 +7,7 @@ describe('#getters', () => { inboxes: { 1: 2 }, labels: {}, teams: {}, + folders: {}, }; expect(getters.getInboxUnreadCount(state)(1)).toBe(2); @@ -20,6 +21,7 @@ describe('#getters', () => { inboxes: {}, labels: { 3: 4 }, teams: {}, + folders: {}, }; expect(getters.getLabelUnreadCount(state)(3)).toBe(4); @@ -33,6 +35,7 @@ describe('#getters', () => { inboxes: {}, labels: {}, teams: { 5: 6 }, + folders: {}, }; expect(getters.getTeamUnreadCount(state)(5)).toBe(6); @@ -46,21 +49,44 @@ describe('#getters', () => { inboxes: {}, labels: {}, teams: {}, + folders: {}, }; expect(getters.getAllUnreadCount(state)).toBe(7); }); + it('returns filtered unread counts', () => { + const state = { + allCount: 0, + inboxes: {}, + labels: {}, + teams: {}, + mentionsCount: 1, + participatingCount: 2, + unattendedCount: 3, + folders: { 8: 4 }, + }; + + expect(getters.getMentionsUnreadCount(state)).toBe(1); + expect(getters.getParticipatingUnreadCount(state)).toBe(2); + expect(getters.getUnattendedUnreadCount(state)).toBe(3); + expect(getters.getFolderUnreadCount(state)(8)).toBe(4); + expect(getters.getFolderUnreadCount(state)('8')).toBe(4); + expect(getters.getFolderUnreadCount(state)(9)).toBe(0); + }); + it('returns unread count maps', () => { const state = { allCount: 0, inboxes: { 1: 2 }, labels: { 3: 4 }, teams: { 5: 6 }, + folders: { 7: 8 }, }; expect(getters.getInboxUnreadCounts(state)).toEqual({ 1: 2 }); expect(getters.getLabelUnreadCounts(state)).toEqual({ 3: 4 }); expect(getters.getTeamUnreadCounts(state)).toEqual({ 5: 6 }); + expect(getters.getFolderUnreadCounts(state)).toEqual({ 7: 8 }); }); }); diff --git a/app/javascript/dashboard/store/modules/specs/conversationUnreadCounts/mutations.spec.js b/app/javascript/dashboard/store/modules/specs/conversationUnreadCounts/mutations.spec.js index 8941d7430..60785593c 100644 --- a/app/javascript/dashboard/store/modules/specs/conversationUnreadCounts/mutations.spec.js +++ b/app/javascript/dashboard/store/modules/specs/conversationUnreadCounts/mutations.spec.js @@ -4,7 +4,16 @@ import { mutations } from '../../conversationUnreadCounts'; describe('#mutations', () => { describe('#SET_CONVERSATION_UNREAD_COUNTS', () => { it('normalizes unread count payload', () => { - const state = { allCount: 0, inboxes: {}, labels: {}, teams: {} }; + const state = { + allCount: 0, + inboxes: {}, + labels: {}, + teams: {}, + mentionsCount: 0, + participatingCount: 0, + unattendedCount: 0, + folders: {}, + }; mutations[types.SET_CONVERSATION_UNREAD_COUNTS](state, { all_count: '3', @@ -21,6 +30,13 @@ describe('#mutations', () => { 6: '7', 7: 0, }, + mentions_count: '8', + participating_count: 9, + unattended_count: 0, + folders: { + 10: '11', + 12: -1, + }, }); expect(state).toEqual({ @@ -28,6 +44,10 @@ describe('#mutations', () => { inboxes: { 1: 2 }, labels: { 4: 5 }, teams: { 6: 7 }, + mentionsCount: 8, + participatingCount: 9, + unattendedCount: 0, + folders: { 10: 11 }, }); }); @@ -37,6 +57,10 @@ describe('#mutations', () => { inboxes: { 1: 2 }, labels: { 4: 5 }, teams: { 6: 7 }, + mentionsCount: 8, + participatingCount: 9, + unattendedCount: 10, + folders: { 11: 12 }, }; mutations[types.SET_CONVERSATION_UNREAD_COUNTS](state, {}); @@ -46,17 +70,36 @@ describe('#mutations', () => { inboxes: {}, labels: {}, teams: {}, + mentionsCount: 0, + participatingCount: 0, + unattendedCount: 0, + folders: {}, }); }); it('normalizes invalid aggregate counts to zero', () => { - const state = { allCount: 2, inboxes: {}, labels: {}, teams: {} }; + const state = { + allCount: 2, + inboxes: {}, + labels: {}, + teams: {}, + mentionsCount: 2, + participatingCount: 3, + unattendedCount: 4, + folders: {}, + }; mutations[types.SET_CONVERSATION_UNREAD_COUNTS](state, { all_count: 'invalid', + mentions_count: 'invalid', + participating_count: -1, + unattended_count: 0, }); expect(state.allCount).toBe(0); + expect(state.mentionsCount).toBe(0); + expect(state.participatingCount).toBe(0); + expect(state.unattendedCount).toBe(0); }); }); }); diff --git a/app/javascript/dashboard/store/modules/specs/conversationWatchers/actions.spec.js b/app/javascript/dashboard/store/modules/specs/conversationWatchers/actions.spec.js index b3cfabeba..defa9a3db 100644 --- a/app/javascript/dashboard/store/modules/specs/conversationWatchers/actions.spec.js +++ b/app/javascript/dashboard/store/modules/specs/conversationWatchers/actions.spec.js @@ -1,11 +1,32 @@ import axios from 'axios'; import { actions } from '../../conversationWatchers'; import types from '../../../mutation-types'; +import { FEATURE_FLAGS } from '../../../../featureFlags'; const commit = vi.fn(); global.axios = axios; vi.mock('axios'); +const mockRetryJitter = value => + vi.spyOn(Math, 'random').mockReturnValue(value); + +afterEach(() => { + vi.restoreAllMocks(); + vi.clearAllTimers(); + vi.useRealTimers(); +}); + +const conversationUnreadCountsEnabledRootGetters = { + getCurrentAccountId: 1, + getCurrentUserID: 1, + 'accounts/isFeatureEnabledonAccount': vi.fn((_, featureFlag) => + [ + FEATURE_FLAGS.CONVERSATION_UNREAD_COUNTS, + FEATURE_FLAGS.UNREAD_COUNT_FOR_FILTERS, + ].includes(featureFlag) + ), +}; + describe('#actions', () => { describe('#get', () => { it('sends correct actions if API is success', async () => { @@ -48,6 +69,56 @@ describe('#actions', () => { [types.SET_CONVERSATION_PARTICIPANTS_UI_FLAG, { isUpdating: false }], ]); }); + it('refetches unread counts when the current user starts watching', async () => { + vi.useFakeTimers(); + mockRetryJitter(0.5); + const dispatch = vi.fn(); + const moduleState = { records: { 2: [] } }; + const mutatingCommit = vi.fn((mutation, payload) => { + if (mutation === types.SET_CONVERSATION_PARTICIPANTS) { + moduleState.records[payload.conversationId] = payload.data; + } + }); + axios.patch.mockResolvedValue({ data: [{ id: 1 }] }); + + await actions.update( + { + commit: mutatingCommit, + dispatch, + rootGetters: conversationUnreadCountsEnabledRootGetters, + state: moduleState, + }, + { conversationId: 2, userIds: [1] } + ); + + expect(dispatch).toHaveBeenCalledWith( + 'conversationUnreadCounts/get', + {}, + { root: true } + ); + + vi.advanceTimersByTime(37499); + expect(dispatch).toHaveBeenCalledTimes(1); + + vi.advanceTimersByTime(1); + expect(dispatch).toHaveBeenCalledTimes(2); + }); + it('does not refetch unread counts when another watcher changes', async () => { + const dispatch = vi.fn(); + axios.patch.mockResolvedValue({ data: [{ id: 1 }, { id: 2 }] }); + + await actions.update( + { + commit, + dispatch, + rootGetters: conversationUnreadCountsEnabledRootGetters, + state: { records: { 2: [{ id: 1 }] } }, + }, + { conversationId: 2, userIds: [1, 2] } + ); + + expect(dispatch).not.toHaveBeenCalled(); + }); it('sends correct actions if API is error', async () => { axios.patch.mockRejectedValue({ message: 'Incorrect header' }); await expect( diff --git a/app/javascript/dashboard/store/modules/specs/conversations/actions.spec.js b/app/javascript/dashboard/store/modules/specs/conversations/actions.spec.js index fa052ec1b..5014b63ca 100644 --- a/app/javascript/dashboard/store/modules/specs/conversations/actions.spec.js +++ b/app/javascript/dashboard/store/modules/specs/conversations/actions.spec.js @@ -357,11 +357,12 @@ describe('#actions', () => { }); await actions.assignAgent( { dispatch }, - { conversationId: 1, agentId: 1 } + { conversationId: 1, agentId: 1, assigneeType: 'AgentBot' } ); expect(dispatch).toHaveBeenCalledWith('setCurrentChatAssignee', { conversationId: 1, assignee: { id: 1, name: 'User' }, + assigneeType: 'AgentBot', }); }); }); @@ -371,6 +372,7 @@ describe('#actions', () => { const payload = { conversationId: 1, assignee: { id: 1, name: 'User' }, + assigneeType: 'AgentBot', }; await actions.setCurrentChatAssignee({ commit }, payload); expect(commit).toHaveBeenCalledTimes(1); diff --git a/app/javascript/dashboard/store/modules/specs/conversations/mutations.spec.js b/app/javascript/dashboard/store/modules/specs/conversations/mutations.spec.js index fc1c61b35..a97bdad53 100644 --- a/app/javascript/dashboard/store/modules/specs/conversations/mutations.spec.js +++ b/app/javascript/dashboard/store/modules/specs/conversations/mutations.spec.js @@ -712,8 +712,10 @@ describe('#mutations', () => { mutations[types.ASSIGN_AGENT](state, { conversationId: 1, assignee, + assigneeType: 'AgentBot', }); expect(state.allConversations[0].meta.assignee).toEqual(assignee); + expect(state.allConversations[0].meta.assignee_type).toEqual('AgentBot'); expect(state.allConversations[1].meta.assignee).toBeUndefined(); }); }); diff --git a/app/javascript/dashboard/store/modules/specs/customViews/actions.spec.js b/app/javascript/dashboard/store/modules/specs/customViews/actions.spec.js index a09dda17f..a1ea0638d 100644 --- a/app/javascript/dashboard/store/modules/specs/customViews/actions.spec.js +++ b/app/javascript/dashboard/store/modules/specs/customViews/actions.spec.js @@ -1,6 +1,7 @@ import axios from 'axios'; import * as types from '../../../mutation-types'; import { actions } from '../../customViews'; +import { FEATURE_FLAGS } from '../../../../featureFlags'; import { contactFilterView, customViewList, @@ -11,6 +12,25 @@ const commit = vi.fn(); global.axios = axios; vi.mock('axios'); +const mockRetryJitter = value => + vi.spyOn(Math, 'random').mockReturnValue(value); + +const conversationUnreadCountsEnabledRootGetters = { + getCurrentAccountId: 1, + 'accounts/isFeatureEnabledonAccount': vi.fn((_, featureFlag) => + [ + FEATURE_FLAGS.CONVERSATION_UNREAD_COUNTS, + FEATURE_FLAGS.UNREAD_COUNT_FOR_FILTERS, + ].includes(featureFlag) + ), +}; + +afterEach(() => { + vi.restoreAllMocks(); + vi.clearAllTimers(); + vi.useRealTimers(); +}); + describe('#actions', () => { describe('#get', () => { it('sends correct actions if API is success', async () => { @@ -49,6 +69,36 @@ describe('#actions', () => { [types.default.SET_CUSTOM_VIEW_UI_FLAG, { isCreating: false }], ]); }); + + it('refetches unread counts after creating a conversation folder', async () => { + vi.useFakeTimers(); + mockRetryJitter(0.5); + const dispatch = vi.fn(); + const firstItem = customViewList[0]; + axios.post.mockResolvedValue({ data: firstItem }); + + await actions.create( + { + commit, + dispatch, + rootGetters: conversationUnreadCountsEnabledRootGetters, + }, + firstItem + ); + + expect(dispatch).toHaveBeenCalledWith( + 'conversationUnreadCounts/get', + {}, + { root: true } + ); + + vi.advanceTimersByTime(37499); + expect(dispatch).toHaveBeenCalledTimes(1); + + vi.advanceTimersByTime(1); + expect(dispatch).toHaveBeenCalledTimes(2); + }); + it('sends correct actions if API is error', async () => { axios.post.mockRejectedValue({ message: 'Incorrect header' }); await expect(actions.create({ commit })).rejects.toThrow(Error); @@ -69,6 +119,44 @@ describe('#actions', () => { [types.default.SET_CUSTOM_VIEW_UI_FLAG, { isDeleting: false }], ]); }); + + it('refetches unread counts after deleting a conversation folder', async () => { + vi.useFakeTimers(); + const dispatch = vi.fn(); + axios.delete.mockResolvedValue({ data: customViewList[0] }); + + await actions.delete( + { + commit, + dispatch, + rootGetters: conversationUnreadCountsEnabledRootGetters, + }, + { id: 1, filterType: 'conversation' } + ); + + expect(dispatch).toHaveBeenCalledWith( + 'conversationUnreadCounts/get', + {}, + { root: true } + ); + }); + + it('does not refetch unread counts after deleting a contact segment', async () => { + const dispatch = vi.fn(); + axios.delete.mockResolvedValue({ data: contactFilterView }); + + await actions.delete( + { + commit, + dispatch, + rootGetters: conversationUnreadCountsEnabledRootGetters, + }, + { id: 1, filterType: 'contact' } + ); + + expect(dispatch).not.toHaveBeenCalled(); + }); + it('sends correct actions if API is error', async () => { axios.delete.mockRejectedValue({ message: 'Incorrect header' }); await expect(actions.delete({ commit }, 1)).rejects.toThrow(Error); @@ -93,6 +181,29 @@ describe('#actions', () => { [types.default.SET_CUSTOM_VIEW_UI_FLAG, { isCreating: false }], ]); }); + + it('refetches unread counts after updating a conversation folder', async () => { + vi.useFakeTimers(); + const dispatch = vi.fn(); + const item = updateCustomViewList[0]; + axios.patch.mockResolvedValue({ data: item }); + + await actions.update( + { + commit, + dispatch, + rootGetters: conversationUnreadCountsEnabledRootGetters, + }, + item + ); + + expect(dispatch).toHaveBeenCalledWith( + 'conversationUnreadCounts/get', + {}, + { root: true } + ); + }); + it('sends correct actions if API is error', async () => { axios.patch.mockRejectedValue({ message: 'Incorrect header' }); await expect(actions.update({ commit }, 1)).rejects.toThrow(Error); diff --git a/app/javascript/dashboard/store/modules/specs/inboxAssignableMembers/actions.spec.js b/app/javascript/dashboard/store/modules/specs/inboxAssignableMembers/actions.spec.js index eac8e7d08..bda8f1c6e 100644 --- a/app/javascript/dashboard/store/modules/specs/inboxAssignableMembers/actions.spec.js +++ b/app/javascript/dashboard/store/modules/specs/inboxAssignableMembers/actions.spec.js @@ -7,12 +7,21 @@ global.axios = axios; vi.mock('axios'); describe('#actions', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + describe('#fetch', () => { it('sends correct actions if API is success', async () => { axios.get.mockResolvedValue({ data: { payload: agentsData }, }); await actions.fetch({ commit }, [1]); + expect(axios.get).toHaveBeenCalledWith('/api/v1/assignable_agents', { + params: { + inbox_ids: [1], + }, + }); expect(commit.mock.calls).toEqual([ [types.SET_INBOX_ASSIGNABLE_AGENTS_UI_FLAG, { isFetching: true }], [ @@ -24,13 +33,37 @@ describe('#actions', () => { }); it('sends correct actions if API is error', async () => { axios.get.mockRejectedValue({ message: 'Incorrect header' }); - await expect(actions.fetch({ commit }, { inboxId: 1 })).rejects.toThrow( - Error - ); + await expect(actions.fetch({ commit }, [1])).rejects.toThrow(Error); expect(commit.mock.calls).toEqual([ [types.SET_INBOX_ASSIGNABLE_AGENTS_UI_FLAG, { isFetching: true }], [types.SET_INBOX_ASSIGNABLE_AGENTS_UI_FLAG, { isFetching: false }], ]); }); + + it('requests agent bots only when opted in', async () => { + axios.get.mockResolvedValue({ + data: { payload: agentsData }, + }); + + await actions.fetch( + { commit }, + { inboxIds: [1], includeAgentBots: true } + ); + + expect(axios.get).toHaveBeenCalledWith('/api/v1/assignable_agents', { + params: { + inbox_ids: [1], + include_agent_bots: true, + }, + }); + expect(commit).toHaveBeenCalledWith(types.SET_INBOX_ASSIGNABLE_AGENTS, { + inboxId: '1', + members: agentsData, + }); + expect(commit).toHaveBeenCalledWith(types.SET_INBOX_ASSIGNABLE_AGENTS, { + inboxId: '1:with_agent_bots', + members: agentsData, + }); + }); }); }); diff --git a/app/javascript/dashboard/store/modules/specs/inboxAssignableMembers/getters.spec.js b/app/javascript/dashboard/store/modules/specs/inboxAssignableMembers/getters.spec.js index ac287e2b2..744bcbccf 100644 --- a/app/javascript/dashboard/store/modules/specs/inboxAssignableMembers/getters.spec.js +++ b/app/javascript/dashboard/store/modules/specs/inboxAssignableMembers/getters.spec.js @@ -1,4 +1,4 @@ -import { getters } from '../../teamMembers'; +import { getters } from '../../inboxAssignableAgents'; import agentsData from './fixtures'; describe('#getters', () => { @@ -8,7 +8,26 @@ describe('#getters', () => { 1: [agentsData[0]], }, }; - expect(getters.getTeamMembers(state)(1)).toEqual([agentsData[0]]); + expect(getters.getAssignableAgents(state)(1)).toEqual([agentsData[0]]); + }); + + it('keeps agent bots scoped to bot-inclusive lists', () => { + const agentBot = { + id: 1, + name: 'Captain', + assignee_type: 'AgentBot', + }; + const state = { + records: { + 1: [agentBot, agentsData[0]], + '1:with_agent_bots': [agentBot, agentsData[0]], + }, + }; + + expect(getters.getAssignableAgents(state)(1)).toEqual([agentsData[0]]); + expect( + getters.getAssignableAgents(state)(1, { includeAgentBots: true }) + ).toEqual([agentBot, agentsData[0]]); }); it('getUIFlags', () => { diff --git a/app/javascript/dashboard/store/modules/specs/sidebarSortPreferences/actions.spec.js b/app/javascript/dashboard/store/modules/specs/sidebarSortPreferences/actions.spec.js index 7fee1d018..5242f2125 100644 --- a/app/javascript/dashboard/store/modules/specs/sidebarSortPreferences/actions.spec.js +++ b/app/javascript/dashboard/store/modules/specs/sidebarSortPreferences/actions.spec.js @@ -83,7 +83,7 @@ describe('#actions', () => { ); }); - it('ignores invalid preferences', () => { + it('ignores invalid sort values', () => { actions.setSectionSort( { commit, @@ -95,7 +95,7 @@ describe('#actions', () => { }, { section: SIDEBAR_SORT_SECTIONS.FOLDERS, - sortBy: SIDEBAR_SORT_KEYS.UNREAD_COUNT_DESC, + sortBy: 'invalid_sort', } ); diff --git a/app/javascript/shared/components/ui/MultiselectDropdown.vue b/app/javascript/shared/components/ui/MultiselectDropdown.vue index 898f89db4..0f775c679 100644 --- a/app/javascript/shared/components/ui/MultiselectDropdown.vue +++ b/app/javascript/shared/components/ui/MultiselectDropdown.vue @@ -63,6 +63,14 @@ const hasValue = computed(() => { const hasIcon = computed(() => { return props.selectedItem?.icon || false; }); + +const isAgentBot = computed( + () => props.selectedItem?.assignee_type === 'AgentBot' +); + +const selectedThumbnail = computed( + () => props.selectedItem?.thumbnail || props.selectedItem?.avatar_url +);