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/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/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..7f4e313b1 100644 --- a/app/controllers/concerns/request_exception_handler.rb +++ b/app/controllers/concerns/request_exception_handler.rb @@ -1,6 +1,12 @@ 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 end @@ -18,6 +24,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 @@ -32,7 +41,7 @@ module RequestExceptionHandler end def render_could_not_create_error(message) - render json: { error: message }, status: :unprocessable_entity + render json: { error: sanitized_error_message(message) }, status: :unprocessable_entity end def render_payment_required(message) @@ -59,4 +68,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/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/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..0e6b3468b 100644 --- a/app/javascript/dashboard/components-next/sidebar/Sidebar.vue +++ b/app/javascript/dashboard/components-next/sidebar/Sidebar.vue @@ -440,6 +440,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/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/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/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/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/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 +);