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/inboxes_controller.rb b/app/controllers/api/v1/accounts/inboxes_controller.rb index 018f1f3d8..1ed512fd7 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 6b3f8fe72..e5ef47588 100644 --- a/app/controllers/concerns/request_exception_handler.rb +++ b/app/controllers/concerns/request_exception_handler.rb @@ -1,9 +1,16 @@ 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::InboxDisabled, with: :render_inbox_disabled_error + rescue_from CustomExceptions::Inbox::LimitExceeded, with: :render_error_response end private @@ -19,6 +26,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,8 +42,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_inbox_disabled_error(_exception = nil) @@ -67,4 +77,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 72c148dd4..75d38777f 100644 --- a/app/controllers/public/api/v1/inboxes/contacts_controller.rb +++ b/app/controllers/public/api/v1/inboxes/contacts_controller.rb @@ -36,11 +36,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/billing_helper.rb b/app/helpers/billing_helper.rb index e2ada7e86..7545b6d8f 100644 --- a/app/helpers/billing_helper.rb +++ b/app/helpers/billing_helper.rb @@ -22,4 +22,16 @@ module BillingHelper def agents(account) account.users.count end + + # current_period_end moved to the subscription item in newer Stripe API versions; read both. + def subscription_period_end(subscription) + subscription['current_period_end'] || subscription['items']['data'].first&.[]('current_period_end') + end + + def subscription_ends_on(subscription) + period_end = subscription_period_end(subscription) + return if period_end.blank? + + Time.zone.at(period_end) + end end 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/enterprise/account.js b/app/javascript/dashboard/api/enterprise/account.js index 9e6d40a62..03456a288 100644 --- a/app/javascript/dashboard/api/enterprise/account.js +++ b/app/javascript/dashboard/api/enterprise/account.js @@ -14,6 +14,10 @@ class EnterpriseAccountAPI extends ApiClient { return axios.post(`${this.url}subscription`); } + selectBillingCurrency(currency) { + return axios.post(`${this.url}select_billing_currency`, { currency }); + } + getLimits() { return axios.get(`${this.url}limits`); } @@ -27,6 +31,11 @@ class EnterpriseAccountAPI extends ApiClient { createTopupCheckout(credits) { return axios.post(`${this.url}topup_checkout`, { credits }); } + + // Topup packages for the account's billing currency. + getTopupOptions() { + return axios.get(`${this.url}topup_options`); + } } export default new EnterpriseAccountAPI(); 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/billing/Index.vue b/app/javascript/dashboard/routes/dashboard/settings/billing/Index.vue index bcfa46193..521fa654b 100644 --- a/app/javascript/dashboard/routes/dashboard/settings/billing/Index.vue +++ b/app/javascript/dashboard/routes/dashboard/settings/billing/Index.vue @@ -15,6 +15,8 @@ import PurchaseCreditsModal from './components/PurchaseCreditsModal.vue'; import BaseSettingsHeader from '../components/BaseSettingsHeader.vue'; import SettingsLayout from '../SettingsLayout.vue'; import ButtonV4 from 'next/button/Button.vue'; +import { getCurrencyConfig } from 'dashboard/constants/billing'; +import { useI18n } from 'vue-i18n'; const router = useRouter(); const { currentAccount, isOnChatwootCloud } = useAccount(); @@ -29,6 +31,7 @@ const { const uiFlags = useMapGetter('accounts/getUIFlags'); const store = useStore(); +const { t } = useI18n(); const BILLING_REFRESH_ATTEMPTED = 'billing_refresh_attempted'; @@ -36,6 +39,10 @@ const BILLING_REFRESH_ATTEMPTED = 'billing_refresh_attempted'; const isWaitingForBilling = ref(false); const purchaseCreditsModalRef = ref(null); +// Currency selection shown to new accounts whose locale supports a non-USD currency. +const currencySelectionRequired = ref(false); +const currencyOptions = ref([]); + const customAttributes = computed(() => { return currentAccount.value.custom_attributes || {}; }); @@ -61,6 +68,13 @@ const subscribedQuantity = computed(() => { return customAttributes.value.subscribed_quantity; }); +const billingCurrency = computed(() => { + if (!customAttributes.value.billing_currency) return ''; + return t( + getCurrencyConfig(customAttributes.value.billing_currency).i18nLabelKey + ); +}); + const subscriptionRenewsOn = computed(() => { if (!customAttributes.value.subscription_ends_on) return ''; const endDate = new Date(customAttributes.value.subscription_ends_on); @@ -78,7 +92,9 @@ const hasABillingPlan = computed(() => { const fetchAccountDetails = async () => { if (!hasABillingPlan.value) { - await store.dispatch('accounts/subscription'); + const data = await store.dispatch('accounts/subscription'); + currencySelectionRequired.value = !!data?.currency_selection_required; + currencyOptions.value = data?.currency_options || []; } // Always fetch limits for billing page to show credit usage fetchLimits(); @@ -97,6 +113,9 @@ const handleBillingPageLogic = async () => { // If cloud user, fetch account details first await fetchAccountDetails(); + // Waiting on the user to pick a billing currency — don't auto-refresh. + if (currencySelectionRequired.value) return; + // If still no billing plan after fetch if (!hasABillingPlan.value) { // If we haven't attempted refresh yet, do it once @@ -118,6 +137,13 @@ const handleBillingPageLogic = async () => { } }; +const onSelectCurrency = async code => { + await store.dispatch('accounts/selectBillingCurrency', code); + currencySelectionRequired.value = false; + // Currency stored and customer creation kicked off — resume the standard wait flow. + await handleBillingPageLogic(); +}; + const onClickBillingPortal = () => { store.dispatch('accounts/checkout'); }; @@ -148,7 +174,9 @@ onMounted(handleBillingPageLogic); ? $t('BILLING_SETTINGS.NO_BILLING_USER') : $t('ATTRIBUTES_MGMT.LOADING') " - :no-records-found="!hasABillingPlan && !isWaitingForBilling" + :no-records-found=" + !hasABillingPlan && !isWaitingForBilling && !currencySelectionRequired + " :no-records-message="$t('BILLING_SETTINGS.NO_BILLING_USER')" >