diff --git a/VERSION_CW b/VERSION_CW index ecbc3b030..27593c841 100644 --- a/VERSION_CW +++ b/VERSION_CW @@ -1 +1 @@ -4.16.0 +4.16.1 diff --git a/app/builders/agent_builder.rb b/app/builders/agent_builder.rb index d2715011c..af68eefc5 100644 --- a/app/builders/agent_builder.rb +++ b/app/builders/agent_builder.rb @@ -2,6 +2,14 @@ # It initializes with necessary attributes and provides a perform method # to create a user and account user in a transaction. class AgentBuilder + LIMIT_EXCEEDED_MESSAGE = 'Account limit exceeded. Please purchase more licenses'.freeze + + class LimitExceededError < StandardError + def initialize + super(AgentBuilder::LIMIT_EXCEEDED_MESSAGE) + end + end + # Initializes an AgentBuilder with necessary attributes. # @param email [String] the email of the user. # @param name [String] the name of the user. @@ -14,15 +22,23 @@ class AgentBuilder # Creates a user and account user in a transaction. # @return [User] the created user. def perform - ActiveRecord::Base.transaction do - @user = find_or_create_user - create_account_user + account.with_lock do + raise LimitExceededError unless can_add_agent? + + ActiveRecord::Base.transaction do + @user = find_or_create_user + create_account_user + end end @user end private + def can_add_agent? + account.usage_limits[:agents] > account.account_users.count + end + # Finds a user by email or creates a new one with a temporary password. # @return [User] the found or created user. def find_or_create_user diff --git a/app/controllers/api/v1/accounts/agents_controller.rb b/app/controllers/api/v1/accounts/agents_controller.rb index 438944f04..864c50bb4 100644 --- a/app/controllers/api/v1/accounts/agents_controller.rb +++ b/app/controllers/api/v1/accounts/agents_controller.rb @@ -1,8 +1,6 @@ class Api::V1::Accounts::AgentsController < Api::V1::Accounts::BaseController before_action :fetch_agent, except: [:create, :index, :bulk_create] before_action :check_authorization - before_action :validate_limit, only: [:create] - before_action :validate_limit_for_bulk_create, only: [:bulk_create] def index @agents = agents @@ -20,6 +18,8 @@ class Api::V1::Accounts::AgentsController < Api::V1::Accounts::BaseController ) @agent = builder.perform + rescue AgentBuilder::LimitExceededError => e + render_payment_required(e.message) end def update @@ -36,25 +36,13 @@ class Api::V1::Accounts::AgentsController < Api::V1::Accounts::BaseController def bulk_create emails = params[:emails] - emails.each do |email| - builder = AgentBuilder.new( - email: email, - name: email.split('@').first, - inviter: current_user, - account: Current.account - ) - begin - builder.perform - rescue ActiveRecord::RecordInvalid => e - Rails.logger.info "[Agent#bulk_create] ignoring email #{email}, errors: #{e.record.errors}" - end - end - + bulk_create_agents(emails) # This endpoint is used to bulk create agents during onboarding # onboarding_step key in present in Current account custom attributes, since this is a one time operation - Current.account.custom_attributes.delete('onboarding_step') - Current.account.save! + clear_onboarding_step head :ok + rescue AgentBuilder::LimitExceededError => e + render_payment_required(e.message) end private @@ -87,22 +75,33 @@ class Api::V1::Accounts::AgentsController < Api::V1::Accounts::BaseController @agents ||= Current.account.users.order_by_full_name.includes(:account_users, { avatar_attachment: [:blob] }) end - def validate_limit_for_bulk_create - limit_available = params[:emails].count <= available_agent_count + def bulk_create_agents(emails) + Current.account.with_lock do + raise AgentBuilder::LimitExceededError if emails.count > available_agent_count - render_payment_required('Account limit exceeded. Please purchase more licenses') unless limit_available + emails.each { |email| create_agent_from_email(email) } + end end - def validate_limit - render_payment_required('Account limit exceeded. Please purchase more licenses') unless can_add_agent? + def create_agent_from_email(email) + builder = AgentBuilder.new( + email: email, + name: email.split('@').first, + inviter: current_user, + account: Current.account + ) + builder.perform + rescue ActiveRecord::RecordInvalid => e + Rails.logger.info "[Agent#bulk_create] ignoring email #{email}, errors: #{e.record.errors}" + end + + def clear_onboarding_step + Current.account.custom_attributes.delete('onboarding_step') + Current.account.save! end def available_agent_count - Current.account.usage_limits[:agents] - agents.count - end - - def can_add_agent? - available_agent_count.positive? + Current.account.usage_limits[:agents] - Current.account.account_users.count end def delete_user_record(agent) diff --git a/app/controllers/api/v1/accounts/integrations/base_controller.rb b/app/controllers/api/v1/accounts/integrations/base_controller.rb new file mode 100644 index 000000000..ef1ebb713 --- /dev/null +++ b/app/controllers/api/v1/accounts/integrations/base_controller.rb @@ -0,0 +1,9 @@ +class Api::V1::Accounts::Integrations::BaseController < Api::V1::Accounts::BaseController + private + + # Managing an integration hook (create/update/destroy) is admin-only, enforced via HookPolicy. + # Subclasses opt in per action with `before_action :check_authorization, only: [...]`. + def check_authorization + authorize(:hook) + end +end diff --git a/app/controllers/api/v1/accounts/integrations/hooks_controller.rb b/app/controllers/api/v1/accounts/integrations/hooks_controller.rb index 087a9b78d..aec105930 100644 --- a/app/controllers/api/v1/accounts/integrations/hooks_controller.rb +++ b/app/controllers/api/v1/accounts/integrations/hooks_controller.rb @@ -1,4 +1,4 @@ -class Api::V1::Accounts::Integrations::HooksController < Api::V1::Accounts::BaseController +class Api::V1::Accounts::Integrations::HooksController < Api::V1::Accounts::Integrations::BaseController before_action :fetch_hook, except: [:create] before_action :check_authorization @@ -35,10 +35,6 @@ class Api::V1::Accounts::Integrations::HooksController < Api::V1::Accounts::Base @hook = Current.account.hooks.find(params[:id]) end - def check_authorization - authorize(:hook) - end - def permitted_params params.require(:hook).permit(:app_id, :inbox_id, :status, settings: {}) end diff --git a/app/controllers/api/v1/accounts/integrations/linear_controller.rb b/app/controllers/api/v1/accounts/integrations/linear_controller.rb index 9ca0c72fd..8ae3109b9 100644 --- a/app/controllers/api/v1/accounts/integrations/linear_controller.rb +++ b/app/controllers/api/v1/accounts/integrations/linear_controller.rb @@ -1,6 +1,7 @@ -class Api::V1::Accounts::Integrations::LinearController < Api::V1::Accounts::BaseController +class Api::V1::Accounts::Integrations::LinearController < Api::V1::Accounts::Integrations::BaseController before_action :fetch_conversation, only: [:create_issue, :link_issue, :unlink_issue, :linked_issues] before_action :fetch_hook, only: [:destroy] + before_action :check_authorization, only: [:destroy] def destroy revoke_linear_token diff --git a/app/controllers/api/v1/accounts/integrations/notion_controller.rb b/app/controllers/api/v1/accounts/integrations/notion_controller.rb index ecf6bae6e..29343e4f5 100644 --- a/app/controllers/api/v1/accounts/integrations/notion_controller.rb +++ b/app/controllers/api/v1/accounts/integrations/notion_controller.rb @@ -1,5 +1,6 @@ -class Api::V1::Accounts::Integrations::NotionController < Api::V1::Accounts::BaseController +class Api::V1::Accounts::Integrations::NotionController < Api::V1::Accounts::Integrations::BaseController before_action :fetch_hook, only: [:destroy] + before_action :check_authorization, only: [:destroy] def destroy @hook.destroy! diff --git a/app/controllers/api/v1/accounts/integrations/shopify_controller.rb b/app/controllers/api/v1/accounts/integrations/shopify_controller.rb index 7fe31889b..c847a85df 100644 --- a/app/controllers/api/v1/accounts/integrations/shopify_controller.rb +++ b/app/controllers/api/v1/accounts/integrations/shopify_controller.rb @@ -1,7 +1,8 @@ -class Api::V1::Accounts::Integrations::ShopifyController < Api::V1::Accounts::BaseController +class Api::V1::Accounts::Integrations::ShopifyController < Api::V1::Accounts::Integrations::BaseController include Shopify::IntegrationHelper before_action :setup_shopify_context, only: [:orders] before_action :fetch_hook, except: [:auth] + before_action :check_authorization, only: [:destroy] before_action :validate_contact, only: [:orders] def auth diff --git a/app/controllers/api/v1/accounts/whatsapp/authorizations_controller.rb b/app/controllers/api/v1/accounts/whatsapp/authorizations_controller.rb index 580ae77c6..46d89a1ba 100644 --- a/app/controllers/api/v1/accounts/whatsapp/authorizations_controller.rb +++ b/app/controllers/api/v1/accounts/whatsapp/authorizations_controller.rb @@ -1,4 +1,5 @@ class Api::V1::Accounts::Whatsapp::AuthorizationsController < Api::V1::Accounts::BaseController + before_action :ensure_embedded_signup_enabled # Reconfiguring/reauthorizing a live inbox swaps its credentials, so restrict it to admins. before_action :check_admin_authorization?, if: -> { params[:inbox_id].present? } before_action :fetch_and_validate_inbox, if: -> { params[:inbox_id].present? } @@ -18,6 +19,13 @@ class Api::V1::Accounts::Whatsapp::AuthorizationsController < Api::V1::Accounts: private + def ensure_embedded_signup_enabled + return unless ChatwootApp.chatwoot_cloud? + return if Current.account.feature_enabled?('whatsapp_embedded_signup_inbox_creation') + + raise Pundit::NotAuthorizedError + end + def process_embedded_signup service = Whatsapp::EmbeddedSignupService.new( account: Current.account, @@ -44,8 +52,7 @@ class Api::V1::Accounts::Whatsapp::AuthorizationsController < Api::V1::Accounts: def can_reconfigure_channel? channel = @inbox.channel return false unless channel.provider == 'whatsapp_cloud' - - # Reconfiguring a live embedded-signup channel requires the feature flag. + return true if ChatwootApp.chatwoot_cloud? return Current.account.feature_enabled?('whatsapp_reconfigure') if channel.provider_config['source'] == 'embedded_signup' true diff --git a/app/controllers/webhooks/whatsapp_controller.rb b/app/controllers/webhooks/whatsapp_controller.rb index ee71f3c92..8fd18678c 100644 --- a/app/controllers/webhooks/whatsapp_controller.rb +++ b/app/controllers/webhooks/whatsapp_controller.rb @@ -47,18 +47,10 @@ class Webhooks::WhatsappController < ActionController::API metadata = params.dig(:entry, 0, :changes, 0, :value, :metadata) return if metadata.blank? - phone_number = normalized_phone_number(metadata[:display_phone_number]) - phone_number_id = metadata[:phone_number_id] - channel = Channel::Whatsapp.find_by(phone_number: phone_number) - - return channel if channel && channel.provider_config['phone_number_id'] == phone_number_id - end - - def normalized_phone_number(phone_number) - return if phone_number.blank? - - phone_number = phone_number.to_s - phone_number.start_with?('+') ? phone_number : "+#{phone_number}" + Whatsapp::WebhookChannelFinderService.new( + display_phone_number: metadata[:display_phone_number], + phone_number_id: metadata[:phone_number_id] + ).perform end def inactive_whatsapp_number? diff --git a/app/javascript/dashboard/api/captain/agentSessions.js b/app/javascript/dashboard/api/captain/agentSessions.js new file mode 100644 index 000000000..a557b5938 --- /dev/null +++ b/app/javascript/dashboard/api/captain/agentSessions.js @@ -0,0 +1,9 @@ +import ApiClient from '../ApiClient'; + +class CaptainAgentSessions extends ApiClient { + constructor() { + super('captain/agent_sessions', { accountScoped: true }); + } +} + +export default new CaptainAgentSessions(); diff --git a/app/javascript/dashboard/api/captain/assistant.js b/app/javascript/dashboard/api/captain/assistant.js index 1fc17798d..806b45bb2 100644 --- a/app/javascript/dashboard/api/captain/assistant.js +++ b/app/javascript/dashboard/api/captain/assistant.js @@ -26,15 +26,25 @@ class CaptainAssistant extends ApiClient { }); } - getStats({ assistantId, range }) { - return axios.get(`${this.url}/${assistantId}/stats`, { + getMetrics({ assistantId, range, signal }) { + const requestConfig = { params: { range, timezone_offset: getTimezoneOffset() }, - }); + }; + if (signal) requestConfig.signal = signal; + + return axios.get(`${this.url}/${assistantId}/metrics`, requestConfig); } - getSummary({ assistantId, range }) { + getFaqStats({ assistantId, signal }) { + const requestConfig = {}; + if (signal) requestConfig.signal = signal; + + return axios.get(`${this.url}/${assistantId}/faq_stats`, requestConfig); + } + + getSummary({ assistantId, range, stats }) { return axios.get(`${this.url}/${assistantId}/summary`, { - params: { range, timezone_offset: getTimezoneOffset() }, + params: { range, timezone_offset: getTimezoneOffset(), stats }, }); } diff --git a/app/javascript/dashboard/api/channel/whatsapp/whatsappCallsAPI.js b/app/javascript/dashboard/api/channel/whatsapp/whatsappCallsAPI.js index ec24aae34..d458c1e5d 100644 --- a/app/javascript/dashboard/api/channel/whatsapp/whatsappCallsAPI.js +++ b/app/javascript/dashboard/api/channel/whatsapp/whatsappCallsAPI.js @@ -10,10 +10,13 @@ class WhatsappCallsAPI extends ApiClient { return axios.get(`${this.url}/${callId}`).then(r => r.data); } - initiate(conversationId, sdpOffer) { + // Either conversationId, or contactId + inboxId to let the BE resolve the conversation. + initiate({ conversationId, contactId, inboxId }, sdpOffer) { return axios .post(`${this.url}/initiate`, { conversation_id: conversationId, + contact_id: contactId, + inbox_id: inboxId, sdp_offer: sdpOffer, }) .then(r => r.data); diff --git a/app/javascript/dashboard/components-next/Calls/CallListItem.vue b/app/javascript/dashboard/components-next/Calls/CallListItem.vue index 8b0a56bfd..dd2b3af37 100644 --- a/app/javascript/dashboard/components-next/Calls/CallListItem.vue +++ b/app/javascript/dashboard/components-next/Calls/CallListItem.vue @@ -25,8 +25,11 @@ const route = useRoute(); const kind = computed(() => getCallKind(props.call)); -const contactName = computed( - () => props.call.contact.name || props.call.contact.phoneNumber +const contactName = computed(() => + (props.call.contact.name || props.call.contact.phoneNumber || '').replace( + /^\+/, + '' + ) ); const agentActionLabel = computed(() => { diff --git a/app/javascript/dashboard/components-next/Contacts/VoiceCallButton.vue b/app/javascript/dashboard/components-next/Contacts/VoiceCallButton.vue index 7e2b6f0c4..5c7ec691d 100644 --- a/app/javascript/dashboard/components-next/Contacts/VoiceCallButton.vue +++ b/app/javascript/dashboard/components-next/Contacts/VoiceCallButton.vue @@ -16,7 +16,6 @@ import { useAlert } from 'dashboard/composables'; import { frontendURL, conversationUrl } from 'dashboard/helper/URLHelper'; import { useCallsStore } from 'dashboard/stores/calls'; import { useWhatsappCallSession } from 'dashboard/composables/useWhatsappCallSession'; -import ContactAPI from 'dashboard/api/contacts'; import Button from 'dashboard/components-next/button/Button.vue'; import Dialog from 'dashboard/components-next/dialog/Dialog.vue'; @@ -83,39 +82,18 @@ const navigateToConversation = conversationId => { const whatsappCallSession = useWhatsappCallSession(); -// Find the most recent open conversation for this contact in the picked inbox. -// WhatsApp /initiate is conversation-scoped (unlike Twilio's contact-scoped path). -// Pass inboxId so the BE applies the filter before the 20-row cap — without it, -// contacts whose latest WhatsApp conversation falls outside the 20 most recent -// across all inboxes would be treated as having no conversation. -const findWhatsappConversationId = async inboxId => { - const { data } = await ContactAPI.getConversations(props.contactId, { - inboxId, - }); - const conversations = data?.payload || []; - const match = [...conversations].sort( - (a, b) => (b.last_activity_at || 0) - (a.last_activity_at || 0) - )[0]; - return match?.id || null; -}; - const startWhatsappCall = async (inboxId, conversationIdHint) => { - // WhatsApp /initiate is conversation-scoped, so we must hand it a - // conversation. Use the caller's hint when given (in-conversation flow); - // otherwise pick the most recent one in the inbox. - const conversationId = - conversationIdHint || (await findWhatsappConversationId(inboxId)); - if (!conversationId) { - useAlert(t('CONTACT_PANEL.CALL_FAILED')); - return; - } - - const response = - await whatsappCallSession.initiateOutboundCall(conversationId); + const response = await whatsappCallSession.initiateOutboundCall( + conversationIdHint + ? { conversationId: conversationIdHint } + : { contactId: props.contactId, inboxId } + ); // The composable returns { status: 'locked' } when an init is already in // flight or a call is already active; treat that as a soft no-op rather than // claiming success. if (response?.status === VOICE_CALL_OUTBOUND_INIT_STATUS.LOCKED) return; + + const conversationId = response?.conversation_id || conversationIdHint; if (!response?.id) { // Permission template path returns no call id. Mirror the header button and // surface whether the request was just sent or is already pending instead of diff --git a/app/javascript/dashboard/components-next/NewConversation/ComposeConversation.vue b/app/javascript/dashboard/components-next/NewConversation/ComposeConversation.vue index 02a00c703..2446e0e2b 100644 --- a/app/javascript/dashboard/components-next/NewConversation/ComposeConversation.vue +++ b/app/javascript/dashboard/components-next/NewConversation/ComposeConversation.vue @@ -234,6 +234,7 @@ onMounted(() => resetContacts()); ref="popoverRef" :align="align" :show-content-border="false" + :close-on-scroll="false" @show="onPopoverShow" @hide="onPopoverHide" > diff --git a/app/javascript/dashboard/components-next/captain/pageComponents/overview/MetricCard.vue b/app/javascript/dashboard/components-next/captain/pageComponents/overview/MetricCard.vue index cf66a0a2f..9a68b71ce 100644 --- a/app/javascript/dashboard/components-next/captain/pageComponents/overview/MetricCard.vue +++ b/app/javascript/dashboard/components-next/captain/pageComponents/overview/MetricCard.vue @@ -9,6 +9,7 @@ const props = defineProps({ // null = neutral, true = good direction, false = bad direction trendGood: { type: Boolean, default: null }, clickable: { type: Boolean, default: false }, + loading: { type: Boolean, default: false }, }); const emit = defineEmits(['click']); @@ -45,7 +46,11 @@ const onActivate = () => { class="transition-opacity opacity-0 cursor-help i-lucide-info size-3.5 text-n-slate-10 group-hover:opacity-100" /> -
+ {{ reasoning }} +
+