From 887897ea986eca619e750e0651a54443d204c601 Mon Sep 17 00:00:00 2001 From: Sony Mathew Date: Wed, 22 Jul 2026 18:43:00 +0530 Subject: [PATCH 1/4] fix: lock agent quota checks (#15029) # Pull Request Template ## Description Locks the agent quota check to the account row while creating account users. This fixes a race where concurrent agent-create requests could all observe the same remaining seat before any `account_users` row was inserted. The API continues to return the existing `402 Account limit exceeded. Please purchase more licenses` response when the limit is reached. Bulk create now preflights the requested email count while holding the account lock, then creates each agent through the same locked builder path. The Enterprise custom-role hook now no-ops when create did not produce an agent. Fixes: [CW-7039](https://linear.app/chatwoot/issue/CW-7039/race-condition-in-agent-creation-bypasses-plan-agent-seat-limit) ## Type of change - [x] Bug fix (non-breaking change which fixes an issue) ## How Has This Been Tested? - `POSTGRES_DATABASE=chatwoot_test_c20f_agent_quota REDIS_DB=9 bundle exec rspec spec/builders/agent_builder_spec.rb spec/enterprise/builders/agent_builder_spec.rb spec/controllers/api/v1/accounts/agents_controller_spec.rb spec/enterprise/controllers/api/v1/accounts/agents_controller_spec.rb spec/enterprise/controllers/enterprise/api/v1/accounts/agents_controller_spec.rb` - `bundle exec rubocop app/builders/agent_builder.rb app/controllers/api/v1/accounts/agents_controller.rb enterprise/app/controllers/enterprise/api/v1/accounts/agents_controller.rb spec/builders/agent_builder_spec.rb spec/enterprise/controllers/api/v1/accounts/agents_controller_spec.rb` - `git diff --check` - One-off threaded Rails validation with 8 concurrent `AgentBuilder` calls against an account with one remaining seat: `created: 1`, `limited: 7`, final `count=2`, `limit=2`. ## Checklist: - [x] My code follows the style guidelines of this project - [x] I have performed a self-review of my code - [x] I have commented on my code, particularly in hard-to-understand areas - [ ] I have made corresponding changes to the documentation - [x] My changes generate no new warnings - [x] I have added tests that prove my fix is effective or that my feature works - [x] New and existing unit tests pass locally with my changes - [ ] Any dependent changes have been merged and published in downstream modules Co-authored-by: Muhsin Keloth --- app/builders/agent_builder.rb | 22 +++++++- .../api/v1/accounts/agents_controller.rb | 55 +++++++++---------- .../api/v1/accounts/agents_controller.rb | 2 + spec/builders/agent_builder_spec.rb | 18 ++++++ .../api/v1/accounts/agents_controller_spec.rb | 21 +++++++ 5 files changed, 87 insertions(+), 31 deletions(-) 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/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/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/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 From 42cbf7d3b935042c275798ba68c9c242b80d459b Mon Sep 17 00:00:00 2001 From: Sivin Varghese <64252451+iamsivin@users.noreply.github.com> Date: Wed, 22 Jul 2026 20:07:00 +0530 Subject: [PATCH 2/4] fix: stray backslash after hard breaks before formatted list items (#15112) --- .../widgets/WootWriter/FullEditor.vue | 4 +-- .../dashboard/helper/editorHelper.js | 3 +- package.json | 5 +--- pnpm-lock.yaml | 28 ++++--------------- 4 files changed, 11 insertions(+), 29 deletions(-) diff --git a/app/javascript/dashboard/components/widgets/WootWriter/FullEditor.vue b/app/javascript/dashboard/components/widgets/WootWriter/FullEditor.vue index 1c7787de6..1f3c03a2e 100644 --- a/app/javascript/dashboard/components/widgets/WootWriter/FullEditor.vue +++ b/app/javascript/dashboard/components/widgets/WootWriter/FullEditor.vue @@ -8,6 +8,8 @@ import { EditorState, Selection, imageResizeView, + toggleMark, + wrapInList, } from '@chatwoot/prosemirror-schema'; import { suggestionsPlugin, @@ -17,8 +19,6 @@ import imagePastePlugin from '@chatwoot/prosemirror-schema/src/plugins/image'; import embedPreviewPlugin from '@chatwoot/prosemirror-schema/src/plugins/embedPreview'; import trailingParagraphPlugin from '@chatwoot/prosemirror-schema/src/plugins/trailingParagraph'; import { embeds as markdownEmbeds } from 'dashboard/helper/markdownEmbeds'; -import { toggleMark } from 'prosemirror-commands'; -import { wrapInList } from 'prosemirror-schema-list'; import { toggleBlockType } from '@chatwoot/prosemirror-schema/src/menu/common'; import { checkFileSizeLimit } from 'shared/helpers/FileHelper'; import { isEscape } from 'shared/helpers/KeyboardHelpers'; 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/package.json b/package.json index c699787f2..5c3ac2229 100644 --- a/package.json +++ b/package.json @@ -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 From ddb0535a93f032ef8ea1b215a7ee1b11763340b0 Mon Sep 17 00:00:00 2001 From: Shivam Mishra Date: Wed, 22 Jul 2026 22:03:25 +0530 Subject: [PATCH 3/4] perf: reuse resolved count for reopen rate (#15122) This improves the Captain overview by loading reporting metrics and FAQ stats from separate endpoints. Range changes now refresh only the metrics, while reopen-rate calculation reuses the resolved conversation count to avoid redundant database queries. ## What changed - Split Captain overview metrics and FAQ stats into separate APIs. - Fetch FAQ stats independently from range-based metrics. - Reuse resolved conversation totals when calculating reopen rate. - Skip the reopen query when there are no resolved conversations. --- .../dashboard/api/captain/assistant.js | 11 ++- .../captain/assistants/overview/Index.vue | 90 +++++++++++++------ config/routes.rb | 3 +- .../captain/assistant_stats_builder.rb | 45 +++++----- .../accounts/captain/assistants_controller.rb | 8 +- .../app/policies/captain/assistant_policy.rb | 6 +- .../captain/assistant_stats_builder_spec.rb | 8 +- .../policies/captain/assistant_policy_spec.rb | 2 +- 8 files changed, 112 insertions(+), 61 deletions(-) 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/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/config/routes.rb b/config/routes.rb index 0bf6b40c8..7314dbda5 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 d162406ad..93f51448b 100644 --- a/enterprise/app/builders/captain/assistant_stats_builder.rb +++ b/enterprise/app/builders/captain/assistant_stats_builder.rb @@ -37,6 +37,23 @@ class Captain::AssistantStatsBuilder build_metrics(current, previous) end + # Approved/pending FAQ counts and the document total in a single round trip. + def faq_stats + approved, pending, documents = Captain::AssistantResponse.by_assistant(assistant.id).reorder(nil).pick( + Arel.sql("COUNT(*) FILTER (WHERE status = #{Captain::AssistantResponse.statuses['approved']})"), + Arel.sql("COUNT(*) FILTER (WHERE status = #{Captain::AssistantResponse.statuses['pending']})"), + Arel.sql("(SELECT COUNT(*) FROM captain_documents WHERE assistant_id = #{assistant.id.to_i})") + ) + total = approved + pending + + { + approved: approved, + pending: pending, + documents: documents, + coverage: total.zero? ? 0 : (approved.to_f / total * 100).round + } + end + private attr_reader :window @@ -56,8 +73,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 @@ -73,7 +89,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 @@ -158,7 +174,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)) @@ -178,24 +196,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/pending FAQ counts and the document total in a single round trip. - def knowledge - approved, pending, documents = Captain::AssistantResponse.by_assistant(assistant.id).reorder(nil).pick( - Arel.sql("COUNT(*) FILTER (WHERE status = #{Captain::AssistantResponse.statuses['approved']})"), - Arel.sql("COUNT(*) FILTER (WHERE status = #{Captain::AssistantResponse.statuses['pending']})"), - Arel.sql("(SELECT COUNT(*) FROM captain_documents WHERE assistant_id = #{assistant.id.to_i})") - ) - total = approved + pending - - { - approved: approved, - pending: pending, - documents: documents, - coverage: total.zero? ? 0 : (approved.to_f / total * 100).round - } + rate(reopened, resolved_count) end def rate(numerator, denominator) 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 1b7d77289..f1509f11c 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,10 +42,14 @@ class Api::V1::Accounts::Captain::AssistantsController < Api::V1::Accounts::Base @tools = assistant.available_agent_tools end - def stats + def metrics render json: Captain::AssistantStatsBuilder.new(@assistant, params[:range], params[:timezone_offset]).metrics end + def faq_stats + render json: Captain::AssistantStatsBuilder.new(@assistant).faq_stats + end + def summary window = Captain::AssistantStatsWindow.new(params[:range], params[:timezone_offset]) result = cached_or_generated_summary(window, summary_stats) diff --git a/enterprise/app/policies/captain/assistant_policy.rb b/enterprise/app/policies/captain/assistant_policy.rb index 573c0400c..fdcb2db89 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/spec/enterprise/builders/captain/assistant_stats_builder_spec.rb b/spec/enterprise/builders/captain/assistant_stats_builder_spec.rb index 6575c9856..c495244c4 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,7 +229,7 @@ RSpec.describe Captain::AssistantStatsBuilder do end end - describe '#metrics knowledge' do + describe '#faq_stats' do before do create_list(:captain_assistant_response, 3, assistant: assistant, account: account, status: :approved) create(:captain_assistant_response, assistant: assistant, account: account, status: :pending) @@ -237,7 +237,7 @@ RSpec.describe Captain::AssistantStatsBuilder do end it 'returns approved, pending, document counts and coverage' do - knowledge = described_class.new(assistant, '30').metrics[:knowledge] + knowledge = described_class.new(assistant).faq_stats expect(knowledge).to eq(approved: 3, pending: 1, documents: 2, coverage: 75) end @@ -245,7 +245,7 @@ RSpec.describe Captain::AssistantStatsBuilder do it 'reports zero coverage when there are no responses' do Captain::AssistantResponse.where(assistant: assistant).delete_all - knowledge = described_class.new(assistant, '30').metrics[:knowledge] + knowledge = described_class.new(assistant).faq_stats expect(knowledge[:coverage]).to eq(0) 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 From 34ad78b1220d45ba7ec391e7de3a67f8aff95749 Mon Sep 17 00:00:00 2001 From: Muhsin Keloth Date: Thu, 23 Jul 2026 11:26:21 +0400 Subject: [PATCH 4/4] fix(instagram): remove resolved restriction banners (#15136) --- .../widgets/conversation/MessagesView.vue | 22 ++---------- app/javascript/dashboard/constants/globals.js | 2 -- .../i18n/locale/en/conversation.json | 2 -- .../dashboard/i18n/locale/en/inboxMgmt.json | 4 +-- .../dashboard/settings/inbox/Settings.vue | 34 ------------------- 5 files changed, 3 insertions(+), 61 deletions(-) diff --git a/app/javascript/dashboard/components/widgets/conversation/MessagesView.vue b/app/javascript/dashboard/components/widgets/conversation/MessagesView.vue index 6cc2747da..b013a47b9 100644 --- a/app/javascript/dashboard/components/widgets/conversation/MessagesView.vue +++ b/app/javascript/dashboard/components/widgets/conversation/MessagesView.vue @@ -33,9 +33,7 @@ import { // constants import { BUS_EVENTS } from 'shared/constants/busEvents'; import { REPLY_POLICY } from 'shared/constants/links'; -import wootConstants, { - META_RESTRICTION_STATUS_URL, -} from 'dashboard/constants/globals'; +import wootConstants from 'dashboard/constants/globals'; import { LOCAL_STORAGE_KEYS } from 'dashboard/constants/localStorage'; import { INBOX_TYPES } from 'dashboard/helper/inbox'; @@ -95,7 +93,6 @@ export default { currentUserId: 'getCurrentUserID', listLoadingStatus: 'getAllMessagesLoaded', currentAccountId: 'getCurrentAccountId', - isOnChatwootCloud: 'globalConfig/isOnChatwootCloud', }), isOpen() { return this.currentChat?.status === wootConstants.STATUS_TYPE.OPEN; @@ -173,13 +170,6 @@ export default { instagramInbox ); }, - isInstagramRestrictionBannerVisible() { - return this.isOnChatwootCloud && this.isAnInstagramChannel; - }, - instagramRestrictionStatusUrl() { - return META_RESTRICTION_STATUS_URL; - }, - replyWindowBannerMessage() { if (this.isAWhatsAppChannel) { return this.$t('CONVERSATION.TWILIO_WHATSAPP_CAN_REPLY'); @@ -464,15 +454,7 @@ export default { >
- - -
- - - {{ $t('INBOX_MGMT.ADD.INSTAGRAM.SETTINGS_RESTRICTED_WARNING') }} - - {{ $t('INBOX_MGMT.ADD.INSTAGRAM.STATUS_LINK') }} - - -
-