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/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/assistant.js b/app/javascript/dashboard/api/captain/assistant.js index 5af6110ab..806b45bb2 100644 --- a/app/javascript/dashboard/api/captain/assistant.js +++ b/app/javascript/dashboard/api/captain/assistant.js @@ -26,13 +26,20 @@ class CaptainAssistant extends ApiClient { }); } - getStats({ assistantId, range, signal }) { + getMetrics({ assistantId, range, signal }) { const requestConfig = { params: { range, timezone_offset: getTimezoneOffset() }, }; if (signal) requestConfig.signal = signal; - return axios.get(`${this.url}/${assistantId}/stats`, requestConfig); + return axios.get(`${this.url}/${assistantId}/metrics`, requestConfig); + } + + getFaqStats({ assistantId, signal }) { + const requestConfig = {}; + if (signal) requestConfig.signal = signal; + + return axios.get(`${this.url}/${assistantId}/faq_stats`, requestConfig); } getSummary({ assistantId, range, stats }) { 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/popover/Popover.vue b/app/javascript/dashboard/components-next/popover/Popover.vue index 5b67e572f..9d369133f 100644 --- a/app/javascript/dashboard/components-next/popover/Popover.vue +++ b/app/javascript/dashboard/components-next/popover/Popover.vue @@ -1,7 +1,11 @@ diff --git a/app/javascript/dashboard/composables/spec/useAgentsList.spec.js b/app/javascript/dashboard/composables/spec/useAgentsList.spec.js index 3a39a6be9..33a8edc0d 100644 --- a/app/javascript/dashboard/composables/spec/useAgentsList.spec.js +++ b/app/javascript/dashboard/composables/spec/useAgentsList.spec.js @@ -1,9 +1,9 @@ -import { ref } from 'vue'; -import { describe, it, expect, vi, beforeEach } from 'vitest'; -import { useAgentsList } from '../useAgentsList'; import { useMapGetter } from 'dashboard/composables/store'; -import { allAgentsData, formattedAgentsData } from './fixtures/agentFixtures'; import * as agentHelper from 'dashboard/helper/agentHelper'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { ref } from 'vue'; +import { useAgentsList } from '../useAgentsList'; +import { allAgentsData, formattedAgentsData } from './fixtures/agentFixtures'; // Mock vue-i18n vi.mock('vue-i18n', () => ({ @@ -94,6 +94,32 @@ describe('useAgentsList', () => { expect(agentsList.value.length).toBe(formattedAgentsData.slice(1).length); }); + it('keeps nameless agent bots and applies a fallback label', () => { + const namelessBot = { + id: 91, + name: null, + assignee_type: 'AgentBot', + availability_status: 'offline', + }; + mockUseMapGetter({ + 'inboxAssignableAgents/getAssignableAgents': ref(() => [ + ...allAgentsData, + namelessBot, + ]), + }); + + const { agentsList } = useAgentsList(); + // access the computed to trigger evaluation + expect(agentsList.value).toBeDefined(); + + const passedAgents = + agentHelper.getAgentsByUpdatedPresence.mock.calls[0][0]; + expect(passedAgents).toContainEqual({ + ...namelessBot, + name: '-', + }); + }); + it('handles empty assignable agents', () => { mockUseMapGetter({ 'inboxAssignableAgents/getAssignableAgents': ref(() => []), diff --git a/app/javascript/dashboard/composables/useAgentsList.js b/app/javascript/dashboard/composables/useAgentsList.js index 8e8ee5568..d39b54c33 100644 --- a/app/javascript/dashboard/composables/useAgentsList.js +++ b/app/javascript/dashboard/composables/useAgentsList.js @@ -1,10 +1,10 @@ -import { computed } from 'vue'; import { useMapGetter } from 'dashboard/composables/store'; -import { useI18n } from 'vue-i18n'; import { getAgentsByUpdatedPresence, getSortedAgentsByAvailability, } from 'dashboard/helper/agentHelper'; +import { computed } from 'vue'; +import { useI18n } from 'vue-i18n'; /** * A composable function that provides a list of agents for assignment. @@ -53,7 +53,11 @@ export function useAgentsList( * @type {import('vue').ComputedRef} */ const agentsList = computed(() => { - const agents = assignableAgents.value || []; + const agents = (assignableAgents.value || []).map(agent => + !agent.name && agent.assignee_type === 'AgentBot' + ? { ...agent, name: '-' } + : agent + ); const agentsByUpdatedPresence = getAgentsByUpdatedPresence( agents, currentUser.value, diff --git a/app/javascript/dashboard/constants/globals.js b/app/javascript/dashboard/constants/globals.js index e1f6b8cf6..24c0f07b1 100644 --- a/app/javascript/dashboard/constants/globals.js +++ b/app/javascript/dashboard/constants/globals.js @@ -78,5 +78,3 @@ export default { }, }; export const DEFAULT_REDIRECT_URL = '/app/'; -export const META_RESTRICTION_STATUS_URL = - 'https://status.chatwoot.com/incident/948346'; diff --git a/app/javascript/dashboard/helper/agentHelper.js b/app/javascript/dashboard/helper/agentHelper.js index ff1123f66..6d592e551 100644 --- a/app/javascript/dashboard/helper/agentHelper.js +++ b/app/javascript/dashboard/helper/agentHelper.js @@ -7,7 +7,7 @@ export const getAgentsByAvailability = (agents, availability) => { return agents .filter(agent => agent.availability_status === availability) - .sort((a, b) => a.name.localeCompare(b.name)); + .sort((a, b) => (a.name || '').localeCompare(b.name || '')); }; /** diff --git a/app/javascript/dashboard/helper/editorHelper.js b/app/javascript/dashboard/helper/editorHelper.js index 2d3c75777..3d342f06e 100644 --- a/app/javascript/dashboard/helper/editorHelper.js +++ b/app/javascript/dashboard/helper/editorHelper.js @@ -1,4 +1,6 @@ import { + InputRule, + inputRules, MessageMarkdownSerializer, MessageMarkdownTransformer, messageSchema, @@ -9,7 +11,6 @@ import * as Sentry from '@sentry/vue'; import camelcaseKeys from 'camelcase-keys'; import { FORMATTING, MARKDOWN_PATTERNS } from 'dashboard/constants/editor'; import { INBOX_TYPES, TWILIO_CHANNEL_MEDIUM } from 'dashboard/helper/inbox'; -import { InputRule, inputRules } from 'prosemirror-inputrules'; /** * Extract text from markdown, and remove all images, code blocks, links, headers, bold, italic, lists etc. diff --git a/app/javascript/dashboard/helper/specs/agentHelper.spec.js b/app/javascript/dashboard/helper/specs/agentHelper.spec.js index 273834a11..154d8faf4 100644 --- a/app/javascript/dashboard/helper/specs/agentHelper.spec.js +++ b/app/javascript/dashboard/helper/specs/agentHelper.spec.js @@ -26,6 +26,18 @@ describe('agentHelper', () => { offlineAgentsData ); }); + + it('does not throw when an agent has a null name', () => { + const agents = [ + { id: 1, name: null, availability_status: 'offline' }, + { id: 2, name: 'Zoe', availability_status: 'offline' }, + ]; + + expect(() => getAgentsByAvailability(agents, 'offline')).not.toThrow(); + expect( + getAgentsByAvailability(agents, 'offline').map(agent => agent.id) + ).toEqual([1, 2]); + }); }); describe('getSortedAgentsByAvailability', () => { diff --git a/app/javascript/dashboard/i18n/locale/en/conversation.json b/app/javascript/dashboard/i18n/locale/en/conversation.json index f3c81615b..62510ddd0 100644 --- a/app/javascript/dashboard/i18n/locale/en/conversation.json +++ b/app/javascript/dashboard/i18n/locale/en/conversation.json @@ -44,8 +44,6 @@ "TWILIO_WHATSAPP_CAN_REPLY": "You can only reply to this conversation using a template message due to", "TWILIO_WHATSAPP_24_HOURS_WINDOW": "24 hour message window restriction", "OLD_INSTAGRAM_INBOX_REPLY_BANNER": "This Instagram account was migrated to the new Instagram channel inbox. All new messages will show up there. You won’t be able to send messages from this conversation anymore.", - "INSTAGRAM_RESTRICTION_BANNER": "Instagram is currently restricted. Some messages or actions may be delayed or unavailable while we restore full support.", - "INSTAGRAM_RESTRICTION_STATUS_LINK": "View status update", "REPLYING_TO": "You are replying to:", "REMOVE_SELECTION": "Remove Selection", "DOWNLOAD": "Download", @@ -195,6 +193,8 @@ }, "ASSIGN_AGENT": "Assign agent", "ASSIGN_LABEL": "Assign label", + "SEARCH_LABELS": "Search labels", + "NO_LABELS_FOUND": "No labels found", "AGENTS_LOADING": "Loading agents...", "ASSIGN_TEAM": "Assign team", "DELETE": "Delete conversation", diff --git a/app/javascript/dashboard/i18n/locale/en/inboxMgmt.json b/app/javascript/dashboard/i18n/locale/en/inboxMgmt.json index 48f7ab215..dfc371a56 100644 --- a/app/javascript/dashboard/i18n/locale/en/inboxMgmt.json +++ b/app/javascript/dashboard/i18n/locale/en/inboxMgmt.json @@ -58,9 +58,7 @@ "ERROR_MESSAGE": "There was an error connecting to Instagram, please try again", "ERROR_AUTH": "There was an error connecting to Instagram, please try again", "NEW_INBOX_SUGGESTION": "This Instagram account was previously linked to a different inbox and has now been migrated here. All new messages will appear here. The old inbox will no longer be able to send or receive messages for this account.", - "DUPLICATE_INBOX_BANNER": "This Instagram account was migrated to the new Instagram channel inbox. You won’t be able to send/receive Instagram messages from this inbox anymore.", - "SETTINGS_RESTRICTED_WARNING": "Instagram is currently restricted. Some messages or actions may be delayed or unavailable while we restore full support.", - "STATUS_LINK": "View status update" + "DUPLICATE_INBOX_BANNER": "This Instagram account was migrated to the new Instagram channel inbox. You won’t be able to send/receive Instagram messages from this inbox anymore." }, "TIKTOK": { "CONTINUE_WITH_TIKTOK": "Continue with TikTok", diff --git a/app/javascript/dashboard/routes/dashboard/captain/assistants/overview/Index.vue b/app/javascript/dashboard/routes/dashboard/captain/assistants/overview/Index.vue index ba31bc05d..124b7c924 100644 --- a/app/javascript/dashboard/routes/dashboard/captain/assistants/overview/Index.vue +++ b/app/javascript/dashboard/routes/dashboard/captain/assistants/overview/Index.vue @@ -26,25 +26,28 @@ const canDrilldown = computed(() => checkPermissions(['administrator'])); const selectedRange = ref('this_month'); const assistantId = computed(() => route.params.assistantId); -const stats = ref(null); -const isFetching = ref(false); +const metricStats = ref(null); +const faqStats = ref(null); +const isFetchingMetrics = ref(false); // Increments on every fetch so a response (or retry) from a superseded // range/assistant can't clobber the latest request's state. -let fetchToken = 0; -let abortController = null; +let metricsFetchToken = 0; +let faqStatsFetchToken = 0; +let metricsAbortController = null; +let faqStatsAbortController = null; -const fetchStats = async () => { - fetchToken += 1; - const token = fetchToken; - abortController?.abort(); - abortController = new AbortController(); - const { signal } = abortController; - stats.value = null; - isFetching.value = true; +const fetchMetrics = async () => { + metricsFetchToken += 1; + const token = metricsFetchToken; + metricsAbortController?.abort(); + metricsAbortController = new AbortController(); + const { signal } = metricsAbortController; + metricStats.value = null; + isFetchingMetrics.value = true; - const requestStats = () => - CaptainAssistant.getStats({ + const requestMetrics = () => + CaptainAssistant.getMetrics({ assistantId: assistantId.value, range: selectedRange.value, signal, @@ -52,25 +55,54 @@ const fetchStats = async () => { let data = null; try { - ({ data } = await requestStats()); + ({ data } = await requestMetrics()); } catch { // One silent retry before giving up, unless the request was aborted. try { - if (token === fetchToken && !signal.aborted) - ({ data } = await requestStats()); + if (token === metricsFetchToken && !signal.aborted) + ({ data } = await requestMetrics()); } catch { data = null; } } - if (token !== fetchToken || signal.aborted) return; - stats.value = data; - isFetching.value = false; + if (token !== metricsFetchToken || signal.aborted) return; + metricStats.value = data; + isFetchingMetrics.value = false; }; -onUnmounted(() => abortController?.abort()); +const fetchFaqStats = async () => { + faqStatsFetchToken += 1; + const token = faqStatsFetchToken; + faqStatsAbortController?.abort(); + faqStatsAbortController = new AbortController(); + const { signal } = faqStatsAbortController; + faqStats.value = null; -watch([selectedRange, assistantId], fetchStats, { immediate: true }); + try { + const { data } = await CaptainAssistant.getFaqStats({ + assistantId: assistantId.value, + signal, + }); + if (token === faqStatsFetchToken && !signal.aborted) faqStats.value = data; + } catch { + if (token === faqStatsFetchToken && !signal.aborted) faqStats.value = null; + } +}; + +const summaryStats = computed(() => { + if (!metricStats.value || !faqStats.value) return null; + + return { ...metricStats.value, knowledge: faqStats.value }; +}); + +onUnmounted(() => { + metricsAbortController?.abort(); + faqStatsAbortController?.abort(); +}); + +watch([selectedRange, assistantId], fetchMetrics, { immediate: true }); +watch(assistantId, fetchFaqStats, { immediate: true }); // `direction` says whether a rising trend is good ('up'), bad ('down'), or // neutral, so we can colour the delta independently of its sign. @@ -90,7 +122,7 @@ const formatDuration = hours => hours >= 100 ? `${Math.round(hours / 24)}d` : `${hours}h`; const metricFor = (statKey, formatValue, direction, trendKind = 'percent') => { - const data = stats.value?.[statKey]; + const data = metricStats.value?.[statKey]; if (!data) return { value: '—', trend: '', trendGood: null }; const sign = data.trend > 0 ? '+' : ''; @@ -184,9 +216,9 @@ const closeDrilldown = () => {
- + - +
{ :trend="metric.trend" :hint="metric.hint" :trend-good="metric.trendGood" - :loading="isFetching" - :clickable="canDrilldown && Boolean(metric.metric) && !isFetching" + :loading="isFetchingMetrics" + :clickable=" + canDrilldown && Boolean(metric.metric) && !isFetchingMetrics + " @click="openDrilldown(metric)" />
- +
diff --git a/app/javascript/dashboard/routes/dashboard/settings/agentBots/Index.vue b/app/javascript/dashboard/routes/dashboard/settings/agentBots/Index.vue index 88286bd88..5547d7a4a 100644 --- a/app/javascript/dashboard/routes/dashboard/settings/agentBots/Index.vue +++ b/app/javascript/dashboard/routes/dashboard/settings/agentBots/Index.vue @@ -135,7 +135,7 @@ onMounted(() => {
- -
- - - {{ $t('INBOX_MGMT.ADD.INSTAGRAM.SETTINGS_RESTRICTED_WARNING') }} - - {{ $t('INBOX_MGMT.ADD.INSTAGRAM.STATUS_LINK') }} - - -
-
props.selectedItem?.assignee_type === 'AgentBot' ); +const selectedItemName = computed(() => + !props.selectedItem?.name && isAgentBot.value ? '-' : props.selectedItem?.name +); + const selectedThumbnail = computed( () => props.selectedItem?.thumbnail || props.selectedItem?.avatar_url ); @@ -95,16 +99,16 @@ const selectedThumbnail = computed(

- {{ selectedItem.name }} + {{ selectedItemName }}

{ - return option.name.toLowerCase().includes(this.search.toLowerCase()); + return (option.name || '') + .toLowerCase() + .includes(this.search.toLowerCase()); }); }, noResult() { diff --git a/app/javascript/widget/components/ChatFooter.vue b/app/javascript/widget/components/ChatFooter.vue index c85727a2b..2cad4d177 100755 --- a/app/javascript/widget/components/ChatFooter.vue +++ b/app/javascript/widget/components/ChatFooter.vue @@ -11,6 +11,8 @@ import { IFrameHelper } from '../helpers/utils'; import { CHATWOOT_ON_START_CONVERSATION } from '../constants/sdkEvents'; import { emitter } from 'shared/helpers/mitt'; +const TRANSCRIPT_COOLDOWN_MS = 15000; + export default { components: { ChatInputWrap, @@ -24,6 +26,9 @@ export default { data() { return { inReplyTo: null, + isSendingTranscript: false, + transcriptCooldown: false, + transcriptCooldownTimer: null, }; }, computed: { @@ -57,6 +62,9 @@ export default { mounted() { emitter.on(BUS_EVENTS.TOGGLE_REPLY_TO_MESSAGE, this.toggleReplyTo); }, + beforeUnmount() { + clearTimeout(this.transcriptCooldownTimer); + }, methods: { ...mapActions('conversation', ['sendMessage', 'sendAttachment']), ...mapActions('conversationAttributes', ['getAttributes']), @@ -90,19 +98,35 @@ export default { toggleReplyTo(message) { this.inReplyTo = message; }, + startTranscriptCooldown() { + this.transcriptCooldown = true; + clearTimeout(this.transcriptCooldownTimer); + this.transcriptCooldownTimer = setTimeout(() => { + this.transcriptCooldown = false; + }, TRANSCRIPT_COOLDOWN_MS); + }, async sendTranscript() { - if (this.hasEmail) { - try { - await sendEmailTranscript(); - emitter.emit(BUS_EVENTS.SHOW_ALERT, { - message: this.$t('EMAIL_TRANSCRIPT.SEND_EMAIL_SUCCESS'), - type: 'success', - }); - } catch (error) { - emitter.$emit(BUS_EVENTS.SHOW_ALERT, { - message: this.$t('EMAIL_TRANSCRIPT.SEND_EMAIL_ERROR'), - }); - } + if ( + !this.hasEmail || + this.isSendingTranscript || + this.transcriptCooldown + ) { + return; + } + this.isSendingTranscript = true; + try { + await sendEmailTranscript(); + this.startTranscriptCooldown(); + emitter.emit(BUS_EVENTS.SHOW_ALERT, { + message: this.$t('EMAIL_TRANSCRIPT.SEND_EMAIL_SUCCESS'), + type: 'success', + }); + } catch (error) { + emitter.emit(BUS_EVENTS.SHOW_ALERT, { + message: this.$t('EMAIL_TRANSCRIPT.SEND_EMAIL_ERROR'), + }); + } finally { + this.isSendingTranscript = false; } }, }, @@ -144,6 +168,7 @@ export default { v-if="showEmailTranscriptButton" type="clear" class="font-normal" + :disabled="isSendingTranscript || transcriptCooldown" @click="sendTranscript" > {{ $t('EMAIL_TRANSCRIPT.BUTTON_TEXT') }} diff --git a/app/jobs/webhooks/whatsapp_events_job.rb b/app/jobs/webhooks/whatsapp_events_job.rb index 14429e61c..f81ce242b 100644 --- a/app/jobs/webhooks/whatsapp_events_job.rb +++ b/app/jobs/webhooks/whatsapp_events_job.rb @@ -153,11 +153,11 @@ class Webhooks::WhatsappEventsJob < MutexApplicationJob end def get_channel_from_wb_payload(wb_params) - phone_number = "+#{wb_params[:entry].first[:changes].first.dig(:value, :metadata, :display_phone_number)}" - phone_number_id = wb_params[:entry].first[:changes].first.dig(:value, :metadata, :phone_number_id) - channel = Channel::Whatsapp.find_by(phone_number: phone_number) - # validate to ensure the phone number id matches the whatsapp channel - return channel if channel && channel.provider_config['phone_number_id'] == phone_number_id + metadata = wb_params[:entry].first[:changes].first.dig(:value, :metadata) || {} + Whatsapp::WebhookChannelFinderService.new( + display_phone_number: metadata[:display_phone_number], + phone_number_id: metadata[:phone_number_id] + ).perform end end diff --git a/app/services/whatsapp/send_on_whatsapp_service.rb b/app/services/whatsapp/send_on_whatsapp_service.rb index 20419c0cd..b8de35d1c 100644 --- a/app/services/whatsapp/send_on_whatsapp_service.rb +++ b/app/services/whatsapp/send_on_whatsapp_service.rb @@ -6,12 +6,10 @@ class Whatsapp::SendOnWhatsappService < Base::SendOnChannelService end def perform_reply - should_send_template_message = template_params.present? || !message.conversation.can_reply? - if should_send_template_message - send_template_message - else - send_session_message - end + return send_template_message if template_params.present? + return send_session_message if message.conversation.can_reply? + + message.update!(status: :failed, external_error: I18n.t('errors.whatsapp.message_outside_messaging_window')) end def send_template_message diff --git a/app/services/whatsapp/webhook_channel_finder_service.rb b/app/services/whatsapp/webhook_channel_finder_service.rb new file mode 100644 index 000000000..32fe8356f --- /dev/null +++ b/app/services/whatsapp/webhook_channel_finder_service.rb @@ -0,0 +1,35 @@ +# Resolves the WhatsApp channel for an inbound WhatsApp Cloud webhook. Meta's +# display_phone_number can arrive formatted or in a country-specific variant (e.g. Brazil +# omits the mobile 9, Argentina adds a digit after the country code), so we try the +# raw digits first and then a normalized fallback, accepting only a candidate whose +# phone_number_id matches. +class Whatsapp::WebhookChannelFinderService + def initialize(display_phone_number:, phone_number_id:) + @display_phone_number = display_phone_number + @phone_number_id = phone_number_id + end + + def perform + return if digits.blank? + + candidates = [ + Channel::Whatsapp.find_by(phone_number: "+#{digits}"), + channel_by_normalized_number + ] + candidates.compact.find { |channel| channel.provider_config['phone_number_id'] == @phone_number_id } + end + + private + + def digits + @digits ||= @display_phone_number.to_s.gsub(/[^0-9]/, '') + end + + def channel_by_normalized_number + normalizer = Whatsapp::PhoneNumberNormalizationService::NORMALIZERS + .lazy.map(&:new).find { |n| n.handles_country?(digits) } + return unless normalizer + + Channel::Whatsapp.find_by(phone_number: "+#{normalizer.normalize(digits)}") + end +end diff --git a/config/app.yml b/config/app.yml index 085af6efb..ffb681f61 100644 --- a/config/app.yml +++ b/config/app.yml @@ -1,5 +1,5 @@ shared: &shared - version: '4.16.0' + version: '4.16.1' development: <<: *shared diff --git a/config/locales/en.yml b/config/locales/en.yml index 4c7e74788..a1f1077be 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -154,6 +154,7 @@ en: invalid_token_permissions: 'The access token does not have the required permissions for WhatsApp.' phone_info_fetch_failed: 'Failed to fetch phone number information. Please try again.' phone_number_already_exists: 'Channel already exists for this phone number: %{phone_number}, please contact support if the error persists' + message_outside_messaging_window: 'Message not sent because the WhatsApp 24-hour customer service window is closed and no template parameters were provided. Send an approved template message instead.' reauthorization: generic: 'Failed to reauthorize WhatsApp. Please try again.' not_supported: 'Reauthorization is not supported for this type of WhatsApp channel.' diff --git a/config/routes.rb b/config/routes.rb index 75ce87193..fdef3be23 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -66,7 +66,8 @@ Rails.application.routes.draw do resources :assistants do member do post :playground - get :stats + get :metrics + get :faq_stats get :summary get :drilldown end diff --git a/enterprise/app/builders/captain/assistant_stats_builder.rb b/enterprise/app/builders/captain/assistant_stats_builder.rb index bfc4017aa..899f508ac 100644 --- a/enterprise/app/builders/captain/assistant_stats_builder.rb +++ b/enterprise/app/builders/captain/assistant_stats_builder.rb @@ -38,6 +38,23 @@ class Captain::AssistantStatsBuilder build_metrics(current, previous) end + # Approved FAQ, open suggestion, and document counts in a single round trip. + def faq_stats + approved, suggestions, documents = Captain::AssistantResponse.by_assistant(assistant.id).reorder(nil).pick( + Arel.sql("COUNT(*) FILTER (WHERE status = #{Captain::AssistantResponse.statuses['approved']})"), + Arel.sql("(#{open_suggestion_count_sql})"), + Arel.sql("(SELECT COUNT(*) FROM captain_documents WHERE assistant_id = #{assistant.id.to_i})") + ) + total = approved + suggestions + + { + approved: approved, + suggestions: suggestions, + documents: documents, + coverage: total.zero? ? 0 : (approved.to_f / total * 100).round + } + end + private attr_reader :window, :suggestions_scope @@ -57,8 +74,7 @@ class Captain::AssistantStatsBuilder handoff_rate: pack(current[:handoff], previous[:handoff], :point), hours_saved: pack(current[:hours_saved], previous[:hours_saved], :percent), reopen_rate: pack(current[:reopen], previous[:reopen], :point), - conversation_depth: pack(current[:depth], previous[:depth], :absolute), - knowledge: knowledge + conversation_depth: pack(current[:depth], previous[:depth], :absolute) } end @@ -74,7 +90,7 @@ class Captain::AssistantStatsBuilder auto_resolution: rate(resolution[:resolved], handled), handoff: rate(resolution[:handoff], handled), hours_saved: (public_count * SECONDS_SAVED_PER_REPLY / 3600.0).round, - reopen: reopen_rate(range), + reopen: reopen_rate(range, resolution[:resolved]), depth: depth_conversations.zero? ? 0 : (public_count.to_f / depth_conversations).round(1) } end @@ -159,7 +175,9 @@ class Captain::AssistantStatsBuilder # derived from the assistant's handled conversations (not current inbox membership) so a later # inbox reassignment doesn't drop historical resolves, and covers both the evaluated (inference) # and time-based (bot) resolve paths so the denominator matches auto_resolution_rate. - def reopen_rate(range) + def reopen_rate(range, resolved_count) + return 0 if resolved_count.zero? + resolved_scope = account.reporting_events .where(name: RESOLVED_EVENT_NAMES, created_at: range, conversation_id: handled_scope(range).select(:conversation_id)) @@ -179,24 +197,7 @@ class Captain::AssistantStatsBuilder 'ON resolves.conversation_id = reporting_events.conversation_id ' \ 'AND reporting_events.event_end_time >= resolves.event_end_time') .distinct.count('reporting_events.conversation_id') - rate(reopened, resolved_scope.distinct.count(:conversation_id)) - end - - # Approved FAQ, open suggestion, and document counts in a single round trip. - def knowledge - approved, suggestions, documents = Captain::AssistantResponse.by_assistant(assistant.id).reorder(nil).pick( - Arel.sql("COUNT(*) FILTER (WHERE status = #{Captain::AssistantResponse.statuses['approved']})"), - Arel.sql("(#{open_suggestion_count_sql})"), - Arel.sql("(SELECT COUNT(*) FROM captain_documents WHERE assistant_id = #{assistant.id.to_i})") - ) - total = approved + suggestions - - { - approved: approved, - suggestions: suggestions, - documents: documents, - coverage: total.zero? ? 0 : (approved.to_f / total * 100).round - } + rate(reopened, resolved_count) end def open_suggestion_count_sql diff --git a/enterprise/app/controllers/api/v1/accounts/captain/assistants_controller.rb b/enterprise/app/controllers/api/v1/accounts/captain/assistants_controller.rb index 811b0af71..c196c3463 100644 --- a/enterprise/app/controllers/api/v1/accounts/captain/assistants_controller.rb +++ b/enterprise/app/controllers/api/v1/accounts/captain/assistants_controller.rb @@ -1,7 +1,7 @@ class Api::V1::Accounts::Captain::AssistantsController < Api::V1::Accounts::BaseController before_action -> { check_authorization(Captain::Assistant) } - before_action :set_assistant, only: [:show, :update, :destroy, :playground, :stats, :summary, :drilldown] + before_action :set_assistant, only: [:show, :update, :destroy, :playground, :metrics, :faq_stats, :summary, :drilldown] def index @assistants = account_assistants.ordered @@ -42,8 +42,17 @@ class Api::V1::Accounts::Captain::AssistantsController < Api::V1::Accounts::Base @tools = assistant.available_agent_tools end - def stats - render json: assistant_stats_builder.metrics + def metrics + render json: Captain::AssistantStatsBuilder.new(@assistant, params[:range], params[:timezone_offset]).metrics + end + + def faq_stats + builder = Captain::AssistantStatsBuilder.new( + @assistant, + suggestions_scope: Captain::FaqSuggestionFinder.new(Current.user, Current.account).perform + ) + + render json: builder.faq_stats end def summary @@ -65,15 +74,6 @@ class Api::V1::Accounts::Captain::AssistantsController < Api::V1::Accounts::Base private - def assistant_stats_builder - Captain::AssistantStatsBuilder.new( - @assistant, - params[:range], - params[:timezone_offset], - suggestions_scope: Captain::FaqSuggestionFinder.new(Current.user, Current.account).perform - ) - end - def drilldown_params params.permit(:metric, :range, :timezone_offset, :page, :per_page) end diff --git a/enterprise/app/controllers/enterprise/api/v1/accounts/agents_controller.rb b/enterprise/app/controllers/enterprise/api/v1/accounts/agents_controller.rb index b3a27c4ad..02d027b58 100644 --- a/enterprise/app/controllers/enterprise/api/v1/accounts/agents_controller.rb +++ b/enterprise/app/controllers/enterprise/api/v1/accounts/agents_controller.rb @@ -1,6 +1,8 @@ module Enterprise::Api::V1::Accounts::AgentsController def create super + return if @agent.blank? + associate_agent_with_custom_role end diff --git a/enterprise/app/policies/captain/assistant_policy.rb b/enterprise/app/policies/captain/assistant_policy.rb index 90d2118f0..7e72bf4c8 100644 --- a/enterprise/app/policies/captain/assistant_policy.rb +++ b/enterprise/app/policies/captain/assistant_policy.rb @@ -7,7 +7,11 @@ class Captain::AssistantPolicy < ApplicationPolicy true end - def stats? + def metrics? + true + end + + def faq_stats? true end diff --git a/package.json b/package.json index c699787f2..15b0272ad 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@chatwoot/chatwoot", - "version": "4.16.0", + "version": "4.16.1", "license": "MIT", "scripts": { "eslint": "eslint app/**/*.{js,vue}", @@ -34,7 +34,7 @@ "@amplitude/analytics-browser": "^2.11.10", "@breezystack/lamejs": "^1.2.7", "@chatwoot/ninja-keys": "1.2.3", - "@chatwoot/prosemirror-schema": "1.3.22", + "@chatwoot/prosemirror-schema": "1.3.23", "@chatwoot/utils": "^0.0.56", "@formkit/core": "^1.7.2", "@formkit/vue": "^1.7.2", @@ -86,9 +86,6 @@ "mitt": "^3.0.1", "opus-recorder": "^8.0.5", "pinia": "^3.0.4", - "prosemirror-commands": "^1.7.1", - "prosemirror-inputrules": "^1.4.0", - "prosemirror-schema-list": "^1.5.1", "qrcode": "^1.5.4", "semver": "7.6.3", "snakecase-keys": "^8.0.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 40f84477a..87c892d19 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -25,8 +25,8 @@ importers: specifier: 1.2.3 version: 1.2.3 '@chatwoot/prosemirror-schema': - specifier: 1.3.22 - version: 1.3.22 + specifier: 1.3.23 + version: 1.3.23 '@chatwoot/utils': specifier: ^0.0.56 version: 0.0.56 @@ -180,15 +180,6 @@ importers: pinia: specifier: ^3.0.4 version: 3.0.4(typescript@5.6.2)(vue@3.5.12(typescript@5.6.2)) - prosemirror-commands: - specifier: ^1.7.1 - version: 1.7.1 - prosemirror-inputrules: - specifier: ^1.4.0 - version: 1.4.0 - prosemirror-schema-list: - specifier: ^1.5.1 - version: 1.5.1 qrcode: specifier: ^1.5.4 version: 1.5.4 @@ -461,8 +452,8 @@ packages: '@chatwoot/ninja-keys@1.2.3': resolution: {integrity: sha512-xM8d9P5ikDMZm2WbaCTk/TW5HFauylrU3cJ75fq5je6ixKwyhl/0kZbVN/vbbZN4+AUX/OaSIn6IJbtCgIF67g==} - '@chatwoot/prosemirror-schema@1.3.22': - resolution: {integrity: sha512-0r+PT8xhQLCKCpoV9k9XVTTRECs/0Nr37wbcLsRS7yvc7WkF9FY05z2hGCRJReWmTOcmmshHtb042LVP+MyB/w==} + '@chatwoot/prosemirror-schema@1.3.23': + resolution: {integrity: sha512-jGxbWELCdlVI64BJiE1wT84ekJHYDXXKiluQIKT3aKPEjPwMR48umKF3A0yHjKoR7IIxCC9oM77TvXOA0ebLtw==} '@chatwoot/utils@0.0.56': resolution: {integrity: sha512-A6dmPLfTSrW4qYNY73btyi4PqpfzcXRSaucscZTQdzNqF6G/QUdgnBmHtho8HeiYby/kSHXaSxLJj+0dx3yEQQ==} @@ -4001,9 +3992,6 @@ packages: prosemirror-tables@1.5.0: resolution: {integrity: sha512-VMx4zlYWm7aBlZ5xtfJHpqa3Xgu3b7srV54fXYnXgsAcIGRqKSrhiK3f89omzzgaAgAtDOV4ImXnLKhVfheVNQ==} - prosemirror-transform@1.10.0: - resolution: {integrity: sha512-9UOgFSgN6Gj2ekQH5CTDJ8Rp/fnKR2IkYfGdzzp5zQMFsS4zDllLVx/+jGcX86YlACpG7UR5fwAXiWzxqWtBTg==} - prosemirror-transform@1.12.0: resolution: {integrity: sha512-GxboyN4AMIsoHNtz5uf2r2Ru551i5hWeCMD6E2Ib4Eogqoub0NflniaBPVQ4MrGE5yZ8JV9tUHg9qcZTTrcN4w==} @@ -5136,7 +5124,7 @@ snapshots: hotkeys-js: 3.8.7 lit: 2.2.6 - '@chatwoot/prosemirror-schema@1.3.22': + '@chatwoot/prosemirror-schema@1.3.23': dependencies: markdown-it-sup: 2.0.0 prosemirror-commands: 1.7.1 @@ -9035,7 +9023,7 @@ snapshots: dependencies: prosemirror-model: 1.22.3 prosemirror-state: 1.4.3 - prosemirror-transform: 1.10.0 + prosemirror-transform: 1.12.0 prosemirror-state@1.4.3: dependencies: @@ -9051,10 +9039,6 @@ snapshots: prosemirror-transform: 1.12.0 prosemirror-view: 1.34.1 - prosemirror-transform@1.10.0: - dependencies: - prosemirror-model: 1.22.3 - prosemirror-transform@1.12.0: dependencies: prosemirror-model: 1.22.3 diff --git a/spec/builders/agent_builder_spec.rb b/spec/builders/agent_builder_spec.rb index f140f2f29..69cacb22b 100644 --- a/spec/builders/agent_builder_spec.rb +++ b/spec/builders/agent_builder_spec.rb @@ -23,6 +23,12 @@ RSpec.describe AgentBuilder, type: :model do end describe '#perform' do + it 'locks the account while checking and creating the agent' do + expect(account).to receive(:with_lock).and_call_original + + agent_builder.perform + end + context 'when user does not exist' do it 'creates a new user' do expect { agent_builder.perform }.to change(User, :count).by(1) @@ -67,5 +73,17 @@ RSpec.describe AgentBuilder, type: :model do expect(user.encrypted_password).not_to be_empty end end + + context 'when the account has reached its agent limit' do + before do + allow(account).to receive(:usage_limits).and_return({ agents: account.account_users.count }) + end + + it 'raises a limit exceeded error without creating a user' do + expect { agent_builder.perform }.to raise_error(described_class::LimitExceededError, described_class::LIMIT_EXCEEDED_MESSAGE) + + expect(User.from_email(email)).to be_nil + end + end end end diff --git a/spec/controllers/api/v1/accounts/integrations/linear_controller_spec.rb b/spec/controllers/api/v1/accounts/integrations/linear_controller_spec.rb index 5f512b2bd..8d28a2f2a 100644 --- a/spec/controllers/api/v1/accounts/integrations/linear_controller_spec.rb +++ b/spec/controllers/api/v1/accounts/integrations/linear_controller_spec.rb @@ -13,7 +13,9 @@ RSpec.describe 'Linear Integration API', type: :request do end describe 'DELETE /api/v1/accounts/:account_id/integrations/linear' do - it 'deletes the linear integration' do + let(:admin) { create(:user, account: account, role: :administrator) } + + it 'deletes the linear integration when the user is an administrator' do # Stub the HTTP call to Linear's revoke endpoint allow(HTTParty).to receive(:post).with( 'https://api.linear.app/oauth/revoke', @@ -21,11 +23,19 @@ RSpec.describe 'Linear Integration API', type: :request do ).and_return(instance_double(HTTParty::Response, success?: true)) delete "/api/v1/accounts/#{account.id}/integrations/linear", - headers: agent.create_new_auth_token, + headers: admin.create_new_auth_token, as: :json expect(response).to have_http_status(:ok) expect(account.hooks.count).to eq(0) end + + it 'returns unauthorized for an agent and keeps the integration' do + delete "/api/v1/accounts/#{account.id}/integrations/linear", + headers: agent.create_new_auth_token, + as: :json + expect(response).to have_http_status(:unauthorized) + expect(account.hooks.count).to eq(1) + end end describe 'GET /api/v1/accounts/:account_id/integrations/linear/teams' do diff --git a/spec/controllers/api/v1/accounts/integrations/shopify_controller_spec.rb b/spec/controllers/api/v1/accounts/integrations/shopify_controller_spec.rb index ef6c4d367..7f9a032ce 100644 --- a/spec/controllers/api/v1/accounts/integrations/shopify_controller_spec.rb +++ b/spec/controllers/api/v1/accounts/integrations/shopify_controller_spec.rb @@ -159,15 +159,17 @@ RSpec.describe 'Shopify Integration API', type: :request do end describe 'DELETE /api/v1/accounts/:account_id/integrations/shopify' do + let(:admin) { create(:user, account: account, role: :administrator) } + before do create(:integrations_hook, :shopify, account: account) end - context 'when it is an authenticated user' do + context 'when it is an administrator' do it 'deletes the shopify integration' do expect do delete "/api/v1/accounts/#{account.id}/integrations/shopify", - headers: agent.create_new_auth_token, + headers: admin.create_new_auth_token, as: :json end.to change { account.hooks.count }.by(-1) @@ -175,6 +177,18 @@ RSpec.describe 'Shopify Integration API', type: :request do end end + context 'when it is an agent' do + it 'returns unauthorized and keeps the integration' do + expect do + delete "/api/v1/accounts/#{account.id}/integrations/shopify", + headers: agent.create_new_auth_token, + as: :json + end.not_to(change { account.hooks.count }) + + expect(response).to have_http_status(:unauthorized) + end + end + context 'when it is an unauthenticated user' do it 'returns unauthorized' do delete "/api/v1/accounts/#{account.id}/integrations/shopify", diff --git a/spec/enterprise/builders/captain/assistant_stats_builder_spec.rb b/spec/enterprise/builders/captain/assistant_stats_builder_spec.rb index 785c80e8e..f08748ef5 100644 --- a/spec/enterprise/builders/captain/assistant_stats_builder_spec.rb +++ b/spec/enterprise/builders/captain/assistant_stats_builder_spec.rb @@ -27,7 +27,7 @@ RSpec.describe Captain::AssistantStatsBuilder do expect(metrics.keys).to contain_exactly( :conversations_handled, :auto_resolution_rate, :handoff_rate, - :hours_saved, :reopen_rate, :conversation_depth, :knowledge + :hours_saved, :reopen_rate, :conversation_depth ) expect(metrics[:conversations_handled]).to include(:current, :previous, :trend) end @@ -229,6 +229,32 @@ RSpec.describe Captain::AssistantStatsBuilder do end end + describe '#faq_stats' do + before do + create_list(:captain_assistant_response, 3, assistant: assistant, account: account, status: :approved) + assistant.faq_suggestions.create!( + question: 'How do I enable the feature?', + answer: 'Turn it on in settings.' + ) + create_list(:captain_document, 2, assistant: assistant, account: account) + end + + it 'returns approved FAQ, open suggestion, document counts and coverage' do + stats = described_class.new(assistant).faq_stats + + expect(stats).to eq(approved: 3, suggestions: 1, documents: 2, coverage: 75) + end + + it 'reports zero coverage when there are no FAQs or suggestions' do + Captain::AssistantResponse.where(assistant: assistant).delete_all + Captain::FaqSuggestion.where(assistant: assistant).delete_all + + stats = described_class.new(assistant).faq_stats + + expect(stats).to eq(approved: 0, suggestions: 0, documents: 2, coverage: 0) + end + end + describe '#period' do it 'labels a day range and exposes its bounds' do period = described_class.new(assistant, '30').period diff --git a/spec/enterprise/controllers/api/v1/accounts/agents_controller_spec.rb b/spec/enterprise/controllers/api/v1/accounts/agents_controller_spec.rb index e5a8a5b7d..270150979 100644 --- a/spec/enterprise/controllers/api/v1/accounts/agents_controller_spec.rb +++ b/spec/enterprise/controllers/api/v1/accounts/agents_controller_spec.rb @@ -21,6 +21,27 @@ RSpec.describe 'Agents API', type: :request do expect(response).to have_http_status(:payment_required) expect(response.body).to include('Account limit exceeded. Please purchase more licenses') end + + it 'prevents adding an agent if the last seat is consumed before creation' do + account.update!(limits: { agents: account.account_users.count + 1 }) + competing_agent_created = false + + allow(AgentBuilder).to receive(:new).and_wrap_original do |method, *args| + unless competing_agent_created + create(:user, account: account, role: :agent) + competing_agent_created = true + end + + method.call(*args) + end + + post "/api/v1/accounts/#{account.id}/agents", params: params, headers: admin.create_new_auth_token, as: :json + + expect(response).to have_http_status(:payment_required) + expect(response.body).to include('Account limit exceeded. Please purchase more licenses') + expect(User.from_email(params[:email])).to be_nil + expect(account.account_users.count).to eq(account.usage_limits[:agents]) + end end end diff --git a/spec/enterprise/controllers/api/v1/accounts/captain/assistants_controller_spec.rb b/spec/enterprise/controllers/api/v1/accounts/captain/assistants_controller_spec.rb index aa70c3a9b..8ba8b693a 100644 --- a/spec/enterprise/controllers/api/v1/accounts/captain/assistants_controller_spec.rb +++ b/spec/enterprise/controllers/api/v1/accounts/captain/assistants_controller_spec.rb @@ -252,7 +252,7 @@ RSpec.describe 'Api::V1::Accounts::Captain::Assistants', type: :request do end end - describe 'GET /api/v1/accounts/{account.id}/captain/assistants/{id}/stats' do + describe 'GET /api/v1/accounts/{account.id}/captain/assistants/{id}/faq_stats' do let(:assistant) { create(:captain_assistant, account: account) } it 'returns approved FAQ, open suggestion, document counts and coverage' do @@ -260,23 +260,21 @@ RSpec.describe 'Api::V1::Accounts::Captain::Assistants', type: :request do assistant.faq_suggestions.create!(question: 'How do I enable the feature?', answer: 'Turn it on in settings.') create_list(:captain_document, 2, assistant: assistant, account: account) - get "/api/v1/accounts/#{account.id}/captain/assistants/#{assistant.id}/stats", - params: { range: '30' }, + get "/api/v1/accounts/#{account.id}/captain/assistants/#{assistant.id}/faq_stats", headers: admin.create_new_auth_token, as: :json expect(response).to have_http_status(:success) - expect(json_response[:knowledge]).to eq(approved: 3, suggestions: 1, documents: 2, coverage: 75) + expect(json_response).to eq(approved: 3, suggestions: 1, documents: 2, coverage: 75) end it 'returns zero coverage when there are no FAQs or suggestions' do - get "/api/v1/accounts/#{account.id}/captain/assistants/#{assistant.id}/stats", - params: { range: '30' }, + get "/api/v1/accounts/#{account.id}/captain/assistants/#{assistant.id}/faq_stats", headers: admin.create_new_auth_token, as: :json expect(response).to have_http_status(:success) - expect(json_response[:knowledge]).to include(approved: 0, suggestions: 0, coverage: 0) + expect(json_response).to include(approved: 0, suggestions: 0, coverage: 0) end it 'counts only suggestions backed by conversations the agent can access' do @@ -300,13 +298,12 @@ RSpec.describe 'Api::V1::Accounts::Captain::Assistants', type: :request do language: hidden_suggestion.language ) - get "/api/v1/accounts/#{account.id}/captain/assistants/#{assistant.id}/stats", - params: { range: '30' }, + get "/api/v1/accounts/#{account.id}/captain/assistants/#{assistant.id}/faq_stats", headers: agent.create_new_auth_token, as: :json expect(response).to have_http_status(:success) - expect(json_response[:knowledge]).to include(approved: 1, suggestions: 1, coverage: 50) + expect(json_response).to include(approved: 1, suggestions: 1, coverage: 50) end end diff --git a/spec/enterprise/policies/captain/assistant_policy_spec.rb b/spec/enterprise/policies/captain/assistant_policy_spec.rb index e04b680e4..b53d73a5e 100644 --- a/spec/enterprise/policies/captain/assistant_policy_spec.rb +++ b/spec/enterprise/policies/captain/assistant_policy_spec.rb @@ -12,7 +12,7 @@ RSpec.describe Captain::AssistantPolicy, type: :policy do let(:administrator_context) { { user: administrator, account: account, account_user: account.account_users.first } } let(:agent_context) { { user: agent, account: account, account_user: account.account_users.first } } - permissions :index?, :show?, :playground? do + permissions :index?, :show?, :playground?, :metrics?, :faq_stats? do context 'when administrator' do it { expect(assistant_policy).to permit(administrator_context, assistant) } end diff --git a/spec/jobs/webhooks/whatsapp_events_job_spec.rb b/spec/jobs/webhooks/whatsapp_events_job_spec.rb index 8d1b24b52..8310839da 100644 --- a/spec/jobs/webhooks/whatsapp_events_job_spec.rb +++ b/spec/jobs/webhooks/whatsapp_events_job_spec.rb @@ -345,6 +345,94 @@ RSpec.describe Webhooks::WhatsappEventsJob do end.not_to change(Conversation, :count) end + it 'finds channel using normalized Brazil phone number when display_phone_number is missing the 9 digit' do + brazil_channel = create(:channel_whatsapp, phone_number: '+5541999887766', provider: 'whatsapp_cloud', + sync_templates: false, validate_provider_config: false) + wb_params = { + object: 'whatsapp_business_account', + entry: [{ + changes: [{ + value: { + metadata: { + phone_number_id: brazil_channel.provider_config['phone_number_id'], + display_phone_number: '554199887766' + } + } + }] + }] + } + allow(Whatsapp::IncomingMessageWhatsappCloudService).to receive(:new).and_return(process_service) + expect(Whatsapp::IncomingMessageWhatsappCloudService).to receive(:new).with(inbox: brazil_channel.inbox, params: wb_params) + job.perform_now(wb_params) + end + + it 'finds channel using normalized Argentina phone number when display_phone_number has extra 9 digit' do + argentina_channel = create(:channel_whatsapp, phone_number: '+541112345678', provider: 'whatsapp_cloud', + sync_templates: false, validate_provider_config: false) + wb_params = { + object: 'whatsapp_business_account', + entry: [{ + changes: [{ + value: { + metadata: { + phone_number_id: argentina_channel.provider_config['phone_number_id'], + display_phone_number: '5491112345678' + } + } + }] + }] + } + allow(Whatsapp::IncomingMessageWhatsappCloudService).to receive(:new).and_return(process_service) + expect(Whatsapp::IncomingMessageWhatsappCloudService).to receive(:new).with(inbox: argentina_channel.inbox, params: wb_params) + job.perform_now(wb_params) + end + + it 'finds channel when display_phone_number contains formatting characters' do + formatted_channel = create(:channel_whatsapp, phone_number: '+14155552671', provider: 'whatsapp_cloud', + sync_templates: false, validate_provider_config: false) + wb_params = { + object: 'whatsapp_business_account', + entry: [{ + changes: [{ + value: { + metadata: { + phone_number_id: formatted_channel.provider_config['phone_number_id'], + display_phone_number: '+1 415-555-2671' + } + } + }] + }] + } + allow(Whatsapp::IncomingMessageWhatsappCloudService).to receive(:new).and_return(process_service) + expect(Whatsapp::IncomingMessageWhatsappCloudService).to receive(:new).with(inbox: formatted_channel.inbox, params: wb_params) + job.perform_now(wb_params) + end + + it 'prefers the phone_number_id match when a raw display_phone_number collision exists' do + normalized_channel = create(:channel_whatsapp, phone_number: '+5541999887766', provider: 'whatsapp_cloud', + sync_templates: false, validate_provider_config: false) + create(:channel_whatsapp, phone_number: '+554199887766', provider: 'whatsapp_cloud', + sync_templates: false, validate_provider_config: false).tap do |raw_channel| + raw_channel.update!(provider_config: raw_channel.provider_config.merge('phone_number_id' => 'other-id')) + end + wb_params = { + object: 'whatsapp_business_account', + entry: [{ + changes: [{ + value: { + metadata: { + phone_number_id: normalized_channel.provider_config['phone_number_id'], + display_phone_number: '554199887766' + } + } + }] + }] + } + allow(Whatsapp::IncomingMessageWhatsappCloudService).to receive(:new).and_return(process_service) + expect(Whatsapp::IncomingMessageWhatsappCloudService).to receive(:new).with(inbox: normalized_channel.inbox, params: wb_params) + job.perform_now(wb_params) + end + it 'will not enque Whatsapp::IncomingMessageWhatsappCloudService when invalid phone number id' do other_channel = create(:channel_whatsapp, phone_number: '+1987654', provider: 'whatsapp_cloud', sync_templates: false, validate_provider_config: false) diff --git a/spec/services/whatsapp/send_on_whatsapp_service_spec.rb b/spec/services/whatsapp/send_on_whatsapp_service_spec.rb index c27295b3e..850685718 100644 --- a/spec/services/whatsapp/send_on_whatsapp_service_spec.rb +++ b/spec/services/whatsapp/send_on_whatsapp_service_spec.rb @@ -72,6 +72,21 @@ describe Whatsapp::SendOnWhatsappService do expect(message.reload.source_id).to eq('123456789') end + it 'fails a free-form message without contacting the provider when outside the 24 hour limit' do + create(:message, message_type: :incoming, content: 'test', created_at: 25.hours.ago, + conversation: conversation, account: conversation.account) + message = create(:message, message_type: :outgoing, content: 'test', + conversation: conversation, account: conversation.account) + + expect(Whatsapp::TemplateProcessorService).not_to receive(:new) + + described_class.new(message: message).perform + + expect(message.reload.status).to eq('failed') + expect(message.external_error).to eq(I18n.t('errors.whatsapp.message_outside_messaging_window')) + expect(a_request(:post, 'https://waba.360dialog.io/v1/messages')).not_to have_been_made + end + it 'marks message as failed when template name is blank' do processor = instance_double(Whatsapp::TemplateProcessorService) allow(Whatsapp::TemplateProcessorService).to receive(:new).and_return(processor)