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 b8907ff2e..864c50bb4 100644 --- a/app/controllers/api/v1/accounts/agents_controller.rb +++ b/app/controllers/api/v1/accounts/agents_controller.rb @@ -1,30 +1,25 @@ 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 end def create - # Lock the account row so concurrent invites can't both pass the seat-limit check (TOCTOU). - Current.account.with_lock do - next unless can_add_agent? + builder = AgentBuilder.new( + email: new_agent_params['email'], + name: new_agent_params['name'], + role: new_agent_params['role'], + availability: new_agent_params['availability'], + auto_offline: new_agent_params['auto_offline'], + inviter: current_user, + account: Current.account + ) - @agent = AgentBuilder.new( - email: new_agent_params['email'], - name: new_agent_params['name'], - role: new_agent_params['role'], - availability: new_agent_params['availability'], - auto_offline: new_agent_params['auto_offline'], - inviter: current_user, - account: Current.account - ).perform - end - - render_payment_required('Account limit exceeded. Please purchase more licenses') if @agent.blank? + @agent = builder.perform + rescue AgentBuilder::LimitExceededError => e + render_payment_required(e.message) end def update @@ -41,40 +36,17 @@ class Api::V1::Accounts::AgentsController < Api::V1::Accounts::BaseController def bulk_create emails = params[:emails] - # Lock the account row so concurrent bulk invites can't collectively exceed the seat limit (TOCTOU). - limit_exceeded = false - Current.account.with_lock do - if emails.count > available_agent_count - limit_exceeded = true - next - end - - invite_agents(emails) - end - return render_payment_required('Account limit exceeded. Please purchase more licenses') if limit_exceeded - + 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 - def invite_agents(emails) - emails.each do |email| - AgentBuilder.new( - email: email, - name: email.split('@').first, - inviter: current_user, - account: Current.account - ).perform - rescue ActiveRecord::RecordInvalid => e - Rails.logger.info "[Agent#bulk_create] ignoring email #{email}, errors: #{e.record.errors}" - end - end - def check_authorization super(User) end @@ -103,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/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/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/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 { >
- 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/inbox/Settings.vue b/app/javascript/dashboard/routes/dashboard/settings/inbox/Settings.vue index 5e7f320f3..0ebe8f238 100644 --- a/app/javascript/dashboard/routes/dashboard/settings/inbox/Settings.vue +++ b/app/javascript/dashboard/routes/dashboard/settings/inbox/Settings.vue @@ -4,8 +4,6 @@ import { shouldBeUrl } from 'shared/helpers/Validators'; import { useAlert } from 'dashboard/composables'; import { useVuelidate } from '@vuelidate/core'; import Avatar from 'next/avatar/Avatar.vue'; -import Banner from 'dashboard/components-next/banner/Banner.vue'; -import Icon from 'dashboard/components-next/icon/Icon.vue'; import SettingIntroBanner from 'dashboard/components/widgets/SettingIntroBanner.vue'; import SettingsToggleSection from 'dashboard/components-next/Settings/SettingsToggleSection.vue'; import SettingsFieldSection from 'dashboard/components-next/Settings/SettingsFieldSection.vue'; @@ -46,11 +44,9 @@ import SelectInput from 'dashboard/components-next/select/Select.vue'; import Widget from 'dashboard/modules/widget-preview/components/Widget.vue'; import AccessToken from 'dashboard/routes/dashboard/settings/profile/AccessToken.vue'; import { copyTextToClipboard } from 'shared/helpers/clipboard'; -import { META_RESTRICTION_STATUS_URL } from 'dashboard/constants/globals'; export default { components: { - Banner, BotConfiguration, CollaboratorsPage, ConfigurationPage, @@ -84,7 +80,6 @@ export default { WhatsappManualMigrationBanner, Widget, AccessToken, - Icon, }, mixins: [inboxMixin], setup() { @@ -348,12 +343,6 @@ export default { instagramUnauthorized() { return this.isAnInstagramChannel && this.inbox.reauthorization_required; }, - showInstagramRestrictionSettingsBanner() { - return this.isOnChatwootCloud && this.isAnInstagramChannel; - }, - metaRestrictionStatusUrl() { - return META_RESTRICTION_STATUS_URL; - }, tiktokUnauthorized() { return this.isATiktokChannel && this.inbox.reauthorization_required; }, @@ -820,29 +809,6 @@ export default { :class="bannerMaxWidth" @start="openWhatsAppManualMigrationDialog" /> - -
- - - {{ $t('INBOX_MGMT.ADD.INSTAGRAM.SETTINGS_RESTRICTED_WARNING') }} - - {{ $t('INBOX_MGMT.ADD.INSTAGRAM.STATUS_LINK') }} - - -
-
= 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 4acd42e78..b1ccda7c6 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::Captain::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::Capt @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/controllers/enterprise/api/v1/accounts/agents_controller.rb b/enterprise/app/controllers/enterprise/api/v1/accounts/agents_controller.rb index f3e99b7e8..da5b98a8f 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 @@ -12,9 +14,6 @@ module Enterprise::Api::V1::Accounts::AgentsController private def associate_agent_with_custom_role - # `super` may render payment-required without creating an agent (seat limit lost in the locked check); - # skip the association so that response is preserved instead of raising on a nil agent. - return if @agent.blank? # Custom roles are a premium feature; ignore custom_role_id assignment when the feature is disabled. return unless Current.account.feature_enabled?('custom_roles') 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/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 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/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/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/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