Compare commits

..
234 changed files with 1199 additions and 8663 deletions
-7
View File
@@ -1,21 +1,14 @@
.bundle
.codex
.env
.env.*
.git
.github
.agents
docker-compose.*
docker/Dockerfile
docker/dockerfiles
log
spec
storage
tests
public/system
tmp
.codeclimate.yml
coverage
public/packs
node_modules
vendor/bundle
+2 -8
View File
@@ -78,12 +78,6 @@ jobs:
platforms: ${{ matrix.platform }}
push: ${{ github.event_name == 'push' || github.event_name == 'workflow_dispatch' }}
outputs: type=image,name=${{ env.DOCKER_REPO }},push-by-digest=true,name-canonical=true,push=true
build-args: |
GIT_SHA=${{ github.sha }}
cache-from: |
type=gha,scope=${{ matrix.platform }}
type=gha,scope=${{ matrix.platform }}-ee
cache-to: type=gha,mode=max,scope=${{ matrix.platform }}-ee
- name: Export digest
run: |
@@ -131,7 +125,7 @@ jobs:
else
TAG="${DOCKER_REPO}:${SANITIZED_REF}"
fi
docker buildx imagetools create -t $TAG \
$(printf '${{ env.DOCKER_REPO }}@sha256:%s ' *)
@@ -145,5 +139,5 @@ jobs:
else
TAG="${DOCKER_REPO}:${SANITIZED_REF}"
fi
docker buildx imagetools inspect $TAG
+2 -8
View File
@@ -83,12 +83,6 @@ jobs:
platforms: ${{ matrix.platform }}
push: ${{ github.event_name == 'push' || github.event_name == 'workflow_dispatch' }}
outputs: type=image,name=${{ env.DOCKER_REPO }},push-by-digest=true,name-canonical=true,push=true
build-args: |
GIT_SHA=${{ github.sha }}
cache-from: |
type=gha,scope=${{ matrix.platform }}
type=gha,scope=${{ matrix.platform }}-ce
cache-to: type=gha,mode=max,scope=${{ matrix.platform }}-ce
- name: Export digest
run: |
@@ -136,7 +130,7 @@ jobs:
else
TAG="${DOCKER_REPO}:${SANITIZED_REF}-ce"
fi
docker buildx imagetools create -t $TAG \
$(printf '${{ env.DOCKER_REPO }}@sha256:%s ' *)
@@ -150,5 +144,5 @@ jobs:
else
TAG="${DOCKER_REPO}:${SANITIZED_REF}-ce"
fi
docker buildx imagetools inspect $TAG
+2 -6
View File
@@ -39,9 +39,5 @@ jobs:
platforms: ${{ matrix.platform }}
push: false
load: false
build-args: |
GIT_SHA=${{ github.sha }}
cache-from: |
type=gha,scope=${{ matrix.platform }}
type=gha,scope=${{ matrix.platform }}-ce
type=gha,scope=${{ matrix.platform }}-ee
cache-from: type=gha,scope=${{ matrix.platform }}
cache-to: type=gha,mode=max,scope=${{ matrix.platform }}
+1 -2
View File
@@ -1,7 +1,6 @@
# Variables
APP_NAME := chatwoot
RAILS_ENV ?= development
GIT_SHA ?= $(shell git rev-parse HEAD 2>/dev/null || echo unknown)
# Targets
setup:
@@ -62,6 +61,6 @@ debug_worker:
overmind connect worker
docker:
docker build --build-arg GIT_SHA=$(GIT_SHA) -t $(APP_NAME) -f ./docker/Dockerfile .
docker build -t $(APP_NAME) -f ./docker/Dockerfile .
.PHONY: setup db_create db_migrate db_seed db_reset db console server burn docker run force_run force_run_tunnel debug debug_worker
@@ -2,8 +2,6 @@ class Api::V1::Accounts::AssignableAgentsController < Api::V1::Accounts::BaseCon
before_action :fetch_inboxes
def index
# TODO: Remove this opt-in once mobile clients support AgentBot assignees in this payload.
@include_agent_bots = params[:include_agent_bots].present?
agent_ids = @inboxes.map do |inbox|
authorize inbox, :show?
member_ids = inbox.members.pluck(:user_id)
@@ -12,7 +10,6 @@ class Api::V1::Accounts::AssignableAgentsController < Api::V1::Accounts::BaseCon
agent_ids = agent_ids.inject(:&)
agents = Current.account.users.where(id: agent_ids)
@assignable_agents = (agents + Current.account.administrators).uniq
@agent_bots = @include_agent_bots ? AgentBot.accessible_to(Current.account) : []
end
private
@@ -6,7 +6,6 @@ class Api::V1::Accounts::CallbacksController < Api::V1::Accounts::BaseController
page_access_token = params[:page_access_token]
page_id = params[:page_id]
inbox_name = params[:inbox_name]
ActiveRecord::Base.transaction do
facebook_channel = Current.account.facebook_pages.create!(
page_id: page_id, user_access_token: user_access_token,
@@ -16,8 +15,6 @@ class Api::V1::Accounts::CallbacksController < Api::V1::Accounts::BaseController
set_instagram_id(page_access_token, facebook_channel)
set_avatar(@facebook_inbox, page_id)
end
rescue CustomExceptions::Inbox::LimitExceeded => e
render_error_response(e)
rescue StandardError => e
ChatwootExceptionTracker.new(e).capture_exception
Rails.logger.error "Error in register_facebook_page: #{e.message}"
@@ -66,7 +66,6 @@ class Api::V1::Accounts::Captain::PreferencesController < Api::V1::Accounts::Bas
config = Llm::Models.feature_config(feature_key)
route = Llm::FeatureRouter.resolve(feature: feature_key, account: Current.account)
config.merge(
default: default_model_for(feature_key),
enabled: account_features[feature_key] == true,
model: route[:model],
selected: route[:model],
@@ -75,10 +74,4 @@ class Api::V1::Accounts::Captain::PreferencesController < Api::V1::Accounts::Bas
)
end
end
def default_model_for(feature_key)
return Llm::FeatureRouter::CAPTAIN_V2_ASSISTANT_MODEL if feature_key == 'assistant' && Current.account.feature_enabled?('captain_integration_v2')
Llm::Models.default_model_for(feature_key)
end
end
@@ -6,8 +6,6 @@ class Api::V1::Accounts::Channels::TwilioChannelsController < Api::V1::Accounts:
def create
process_create
rescue CustomExceptions::Inbox::LimitExceeded => e
render_error_response(e)
rescue StandardError => e
render_could_not_create_error(e.message)
end
@@ -1,40 +1,27 @@
class Api::V1::Accounts::Conversations::ParticipantsController < Api::V1::Accounts::Conversations::BaseController
include Events::Types
def show
@participants = @conversation.conversation_participants
end
def create
participant_ids_to_add = participants_to_be_added_ids
ActiveRecord::Base.transaction do
@participants = participant_ids_to_add.map { |user_id| @conversation.conversation_participants.find_or_create_by(user_id: user_id) }
@participants = participants_to_be_added_ids.map { |user_id| @conversation.conversation_participants.find_or_create_by(user_id: user_id) }
end
notify_unread_count_change if participant_ids_to_add.any?
end
def update
participant_ids_to_add = participants_to_be_added_ids
participant_ids_to_remove = participants_to_be_removed_ids
changed_participant_ids = participant_ids_to_add + participant_ids_to_remove
ActiveRecord::Base.transaction do
participant_ids_to_add.each { |user_id| @conversation.conversation_participants.find_or_create_by(user_id: user_id) }
participant_ids_to_remove.each { |user_id| @conversation.conversation_participants.find_by(user_id: user_id)&.destroy }
participants_to_be_added_ids.each { |user_id| @conversation.conversation_participants.find_or_create_by(user_id: user_id) }
participants_to_be_removed_ids.each { |user_id| @conversation.conversation_participants.find_by(user_id: user_id)&.destroy }
end
notify_unread_count_change if changed_participant_ids.any?
@participants = @conversation.conversation_participants
render action: 'show'
end
def destroy
participant_ids_to_remove = current_participant_ids & params[:user_ids]
ActiveRecord::Base.transaction do
params[:user_ids].map { |user_id| @conversation.conversation_participants.find_by(user_id: user_id)&.destroy }
end
notify_unread_count_change if participant_ids_to_remove.any?
head :ok
end
@@ -51,11 +38,4 @@ class Api::V1::Accounts::Conversations::ParticipantsController < Api::V1::Accoun
def current_participant_ids
@current_participant_ids ||= @conversation.conversation_participants.pluck(:user_id)
end
def notify_unread_count_change
return unless Current.account.feature_enabled?('conversation_unread_counts')
return unless Current.account.feature_enabled?('unread_count_for_filters')
Rails.configuration.dispatcher.dispatch(CONVERSATION_UNREAD_COUNT_CHANGED, Time.zone.now, conversation: @conversation)
end
end
@@ -2,28 +2,12 @@ class Api::V1::Accounts::Conversations::UnreadCountsController < Api::V1::Accoun
before_action :ensure_unread_counts_enabled
def index
counts = if filtered_unread_counts_enabled?
instrumentation.summarize_request(account_id: Current.account.id) { unread_counts }
else
unread_counts
end
counts = ::Conversations::UnreadCounts::Counter.new(account: Current.account, user: Current.user).perform
render json: { payload: counts }
end
private
def unread_counts
::Conversations::UnreadCounts::Counter.new(account: Current.account, user: Current.user).perform
end
def filtered_unread_counts_enabled?
Current.account.feature_enabled?(::Conversations::UnreadCounts::FilteredCounter::FEATURE_FLAG)
end
def instrumentation
::Conversations::UnreadCounts::FilteredCountInstrumentation
end
def ensure_unread_counts_enabled
return if Current.account.feature_enabled?('conversation_unread_counts')
@@ -164,7 +164,6 @@ class Api::V1::Accounts::ConversationsController < Api::V1::Accounts::BaseContro
# rubocop:enable Rails/SkipsModelValidations
::Conversations::UnreadCounts::Notifier.new(@conversation).perform
::Conversations::UnreadCounts::FilteredCountInvalidator.new(Current.account).conversation_changed!
end
def should_update_last_seen?
@@ -2,6 +2,7 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController
include Api::V1::InboxesHelper
before_action :fetch_inbox, except: [:index, :create]
before_action :fetch_agent_bot, only: [:set_agent_bot]
before_action :validate_limit, only: [:create]
# we are already handling the authorization in fetch inbox
before_action :check_authorization, except: [:show]
@@ -8,10 +8,8 @@ class Api::V1::Accounts::Whatsapp::AuthorizationsController < Api::V1::Accounts:
validate_embedded_signup_params!
channel = process_embedded_signup
render_success_response(channel.inbox)
rescue CustomExceptions::Inbox::LimitExceeded => e
render_error_response(e)
rescue StandardError => e
render_embedded_signup_error(e)
render_error_response(e)
end
private
@@ -57,7 +55,7 @@ class Api::V1::Accounts::Whatsapp::AuthorizationsController < Api::V1::Accounts:
render json: response
end
def render_embedded_signup_error(error)
def render_error_response(error)
Rails.logger.error "[WHATSAPP AUTHORIZATION] Embedded signup error: #{error.message}"
Rails.logger.error error.backtrace.join("\n")
render json: {
@@ -2,7 +2,6 @@ class Api::V1::Widget::ContactsController < Api::V1::Widget::BaseController
include WidgetHelper
before_action :validate_hmac, only: [:set_user]
before_action :validate_hmac_for_identified_update, only: [:update]
def show; end
@@ -47,16 +46,6 @@ class Api::V1::Widget::ContactsController < Api::V1::Widget::BaseController
@contact.identifier.present? && @contact.identifier != permitted_params[:identifier]
end
# The plain update endpoint is also used for anonymous prechat updates
# (name/email/phone/custom_attributes with no identifier), which must keep
# working on hmac_mandatory inboxes. Only the identity-binding path, where an
# identifier is supplied and the contact can be rebound, requires HMAC.
def validate_hmac_for_identified_update
return if params[:identifier].blank?
validate_hmac
end
def validate_hmac
return unless should_verify_hmac?
@@ -73,15 +62,11 @@ class Api::V1::Widget::ContactsController < Api::V1::Widget::BaseController
end
def valid_hmac?
expected_hash = OpenSSL::HMAC.hexdigest(
params[:identifier_hash] == OpenSSL::HMAC.hexdigest(
'sha256',
@web_widget.hmac_token,
params[:identifier].to_s
)
identifier_hash = params[:identifier_hash].to_s
return false unless identifier_hash.bytesize == expected_hash.bytesize
ActiveSupport::SecurityUtils.secure_compare(identifier_hash, expected_hash)
end
def permitted_params
@@ -1,15 +1,8 @@
module RequestExceptionHandler
extend ActiveSupport::Concern
QUERY_CANCELED_ERROR_MESSAGE_PATTERNS = [
'ActiveRecord::QueryCanceled',
'PG::QueryCanceled',
'canceling statement due to statement timeout'
].freeze
included do
rescue_from ActiveRecord::RecordInvalid, with: :render_record_invalid
rescue_from CustomExceptions::Inbox::LimitExceeded, with: :render_error_response
end
private
@@ -25,9 +18,6 @@ module RequestExceptionHandler
rescue ActionController::ParameterMissing => e
log_handled_error(e)
render_could_not_create_error(e.message)
rescue ActiveRecord::QueryCanceled => e
log_handled_error(e)
render_could_not_create_error(database_query_canceled_message)
ensure
# to address the thread variable leak issues in Puma/Thin webserver
Current.reset
@@ -41,8 +31,8 @@ module RequestExceptionHandler
render json: { error: message }, status: :not_found
end
def render_could_not_create_error(error)
render json: { error: sanitized_error_message(error) }, status: :unprocessable_entity
def render_could_not_create_error(message)
render json: { error: message }, status: :unprocessable_entity
end
def render_payment_required(message)
@@ -69,19 +59,4 @@ module RequestExceptionHandler
def log_handled_error(exception)
logger.info("Handled error: #{exception.inspect}")
end
def sanitized_error_message(message)
return database_query_canceled_message if database_query_canceled_message?(message)
message
end
def database_query_canceled_message?(message)
error_message = message.to_s
QUERY_CANCELED_ERROR_MESSAGE_PATTERNS.any? { |pattern| error_message.include?(pattern) }
end
def database_query_canceled_message
I18n.t('errors.database.query_canceled')
end
end
@@ -11,8 +11,6 @@ class Instagram::CallbacksController < ApplicationController
end
process_successful_authorization
rescue CustomExceptions::Inbox::LimitExceeded => e
handle_limit_error(e)
rescue StandardError => e
handle_error(e)
end
@@ -49,14 +47,6 @@ class Instagram::CallbacksController < ApplicationController
redirect_to_error_page(error_info)
end
def handle_limit_error(error)
redirect_to_error_page(
'error_type' => error.class.name,
'code' => Rack::Utils.status_code(error.http_status),
'error_message' => error.message
)
end
# Extract error details from the exception
def extract_error_info(error)
if error.is_a?(OAuth2::Error)
@@ -35,15 +35,11 @@ class Public::Api::V1::Inboxes::ContactsController < Public::Api::V1::InboxesCon
end
def valid_hmac?
expected_hash = OpenSSL::HMAC.hexdigest(
params[:identifier_hash] == OpenSSL::HMAC.hexdigest(
'sha256',
@inbox_channel.hmac_token,
params[:identifier].to_s
)
identifier_hash = params[:identifier_hash].to_s
return false unless identifier_hash.bytesize == expected_hash.bytesize
ActiveSupport::SecurityUtils.secure_compare(identifier_hash, expected_hash)
end
def permitted_params
@@ -6,8 +6,6 @@ class Tiktok::CallbacksController < ApplicationController
return handle_ungranted_scopes_error unless all_scopes_granted?
process_successful_authorization
rescue CustomExceptions::Inbox::LimitExceeded => e
handle_limit_error(e)
rescue StandardError => e
handle_error(e)
end
@@ -38,14 +36,6 @@ class Tiktok::CallbacksController < ApplicationController
redirect_to_error_page(error_type: error.class.name, code: 500, error_message: error.message)
end
def handle_limit_error(error)
redirect_to_error_page(
error_type: error.class.name,
code: Rack::Utils.status_code(error.http_status),
error_message: error.message
)
end
# Handles the case when a user denies permissions or cancels the authorization flow
def handle_authorization_error
redirect_to_error_page(
+6
View File
@@ -114,4 +114,10 @@ module Api::V1::InboxesHelper
'sms' => Current.account.sms_channels
}[permitted_params[:channel][:type]]
end
def validate_limit
return unless Current.account.inboxes.count >= Current.account.usage_limits[:inboxes]
render_payment_required('Account limit exceeded. Upgrade to a higher plan')
end
end
-12
View File
@@ -22,16 +22,4 @@ module BillingHelper
def agents(account)
account.users.count
end
# current_period_end moved to the subscription item in newer Stripe API versions; read both.
def subscription_period_end(subscription)
subscription['current_period_end'] || subscription['items']['data'].first&.[]('current_period_end')
end
def subscription_ends_on(subscription)
period_end = subscription_period_end(subscription)
return if period_end.blank?
Time.zone.at(period_end)
end
end
-10
View File
@@ -68,8 +68,6 @@ module Filters::FilterHelper
when 'text_case_insensitive'
text_case_insensitive_filter(query_hash, filter_operator_value)
else
return text_cast_filter(query_hash, filter_operator_value) if text_search_on_display_id?(query_hash)
default_filter(query_hash, filter_operator_value)
end
end
@@ -84,18 +82,10 @@ module Filters::FilterHelper
"#{filter_operator_value} #{query_hash[:query_operator]}"
end
def text_cast_filter(query_hash, filter_operator_value)
"(#{filter_config[:table_name]}.#{query_hash[:attribute_key]})::text #{filter_operator_value} #{query_hash[:query_operator]}"
end
def default_filter(query_hash, filter_operator_value)
"#{filter_config[:table_name]}.#{query_hash[:attribute_key]} #{filter_operator_value} #{query_hash[:query_operator]}"
end
def text_search_on_display_id?(query_hash)
query_hash[:attribute_key] == 'display_id' && %w[contains does_not_contain].include?(query_hash[:filter_operator])
end
def validate_single_condition(condition)
return if condition['query_operator'].nil?
return if condition['query_operator'].empty?
+1 -1
View File
@@ -11,7 +11,7 @@ saml:
enterprise: true
custom_branding:
name: 'Custom Branding'
description: 'Apply your own branding to this installation.'
description: 'Apply your own branding to this installation, including email template layout customization.'
enabled: <%= (ChatwootHub.pricing_plan != 'community') %>
icon: 'icon-paint-brush-line'
config_key: 'custom_branding'
+3 -1
View File
@@ -1,6 +1,8 @@
module SuperAdmin::NavigationHelper
def settings_open?
params[:controller].in? %w[super_admin/settings super_admin/app_configs]
return false if params[:controller] == 'super_admin/email_layouts' && params[:account_id].present?
params[:controller].in? %w[super_admin/settings super_admin/app_configs super_admin/email_layouts]
end
def settings_pages
@@ -6,12 +6,9 @@ class AssignableAgents extends ApiClient {
super('assignable_agents', { accountScoped: true });
}
get(inboxIds, { includeAgentBots = false } = {}) {
get(inboxIds) {
return axios.get(this.url, {
params: {
inbox_ids: inboxIds,
...(includeAgentBots ? { include_agent_bots: true } : {}),
},
params: { inbox_ids: inboxIds },
});
}
}
@@ -1,10 +1,6 @@
/* global axios */
import ApiClient from '../ApiClient';
// Viewer's UTC offset in hours, matching the reports API convention so the
// backend can anchor calendar ranges to the viewer's day.
const getTimezoneOffset = () => -new Date().getTimezoneOffset() / 60;
class CaptainAssistant extends ApiClient {
constructor() {
super('captain/assistants', { accountScoped: true });
@@ -25,18 +21,6 @@ class CaptainAssistant extends ApiClient {
message_history: messageHistory,
});
}
getStats({ assistantId, range }) {
return axios.get(`${this.url}/${assistantId}/stats`, {
params: { range, timezone_offset: getTimezoneOffset() },
});
}
getSummary({ assistantId, range }) {
return axios.get(`${this.url}/${assistantId}/summary`, {
params: { range, timezone_offset: getTimezoneOffset() },
});
}
}
export default new CaptainAssistant();
@@ -14,10 +14,6 @@ class EnterpriseAccountAPI extends ApiClient {
return axios.post(`${this.url}subscription`);
}
selectBillingCurrency(currency) {
return axios.post(`${this.url}select_billing_currency`, { currency });
}
getLimits() {
return axios.get(`${this.url}limits`);
}
@@ -31,11 +27,6 @@ class EnterpriseAccountAPI extends ApiClient {
createTopupCheckout(credits) {
return axios.post(`${this.url}topup_checkout`, { credits });
}
// Topup packages for the account's billing currency.
getTopupOptions() {
return axios.get(`${this.url}topup_options`);
}
}
export default new EnterpriseAccountAPI();
@@ -62,10 +62,9 @@ class ConversationApi extends ApiClient {
});
}
assignAgent({ conversationId, agentId, assigneeType }) {
assignAgent({ conversationId, agentId }) {
return axios.post(`${this.url}/${conversationId}/assignments`, {
assignee_id: agentId,
assignee_type: assigneeType,
});
}
@@ -26,15 +26,5 @@ describe('#AssignableAgentsAPI', () => {
},
});
});
it('#getAssignableAgents with agent bots', () => {
assignableAgentsAPI.get([1], { includeAgentBots: true });
expect(axiosMock.get).toHaveBeenCalledWith('/api/v1/assignable_agents', {
params: {
inbox_ids: [1],
include_agent_bots: true,
},
});
});
});
});
@@ -90,16 +90,11 @@ describe('#ConversationAPI', () => {
});
it('#assignAgent', () => {
conversationAPI.assignAgent({
conversationId: 12,
agentId: 34,
assigneeType: 'AgentBot',
});
conversationAPI.assignAgent({ conversationId: 12, agentId: 34 });
expect(axiosMock.post).toHaveBeenCalledWith(
`/api/v1/conversations/12/assignments`,
{
assignee_id: 34,
assignee_type: 'AgentBot',
}
);
});
@@ -182,8 +182,7 @@ const handleCreateAssistant = () => {
</div>
</div>
<div class="flex items-center gap-2">
<slot name="headerActions" />
<div class="flex gap-2">
<slot name="search" />
<div
v-if="!showPaywall && buttonLabel"
@@ -1,96 +0,0 @@
<script setup>
import { computed, ref, watch } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { LocalStorage } from 'shared/helpers/localStorage';
const props = defineProps({
knowledge: {
type: Object,
default: () => ({ approved: 0, pending: 0, documents: 0, coverage: 0 }),
},
});
const route = useRoute();
const router = useRouter();
// Dismissal is remembered per assistant for 24 hours (setFlag's default expiry).
const DISMISS_STORE = 'captain_overview_coverage_banner';
const accountId = computed(() => route.params.accountId);
const assistantId = computed(() => route.params.assistantId);
// Re-read the stored flag whenever the assistant changes, otherwise the banner
// would keep the first assistant's dismissed state after switching.
const dismissed = ref(false);
watch(
[accountId, assistantId],
([account, assistant]) => {
dismissed.value = LocalStorage.getFlag(DISMISS_STORE, account, assistant);
},
{ immediate: true }
);
// Thin coverage paired with a large review backlog: approving the pending FAQs
// is the quickest lever to lift auto-resolution, so nudge the team to act.
const COVERAGE_THRESHOLD = 85;
const PENDING_THRESHOLD = 100;
const showBanner = computed(
() =>
!dismissed.value &&
(props.knowledge?.coverage ?? 0) < COVERAGE_THRESHOLD &&
(props.knowledge?.pending ?? 0) > PENDING_THRESHOLD
);
const dismiss = () => {
LocalStorage.setFlag(DISMISS_STORE, accountId.value, assistantId.value);
dismissed.value = true;
};
const goToPending = () => {
router.push({
name: 'captain_assistants_responses_pending',
params: {
accountId: route.params.accountId,
assistantId: route.params.assistantId,
},
});
};
</script>
<template>
<div
v-if="showBanner"
class="flex items-center justify-between gap-3 px-3 py-2 text-sm border rounded-xl bg-n-amber-3 border-n-amber-4 text-n-amber-11"
>
<div class="flex items-center gap-2 min-w-0">
<span class="shrink-0 i-lucide-triangle-alert size-4" />
<span class="truncate">
{{
$t('CAPTAIN.OVERVIEW.COVERAGE_BANNER.TEXT', {
count: knowledge.pending,
coverage: knowledge.coverage,
})
}}
</span>
</div>
<div class="flex items-center gap-1 shrink-0">
<button
type="button"
class="px-3 py-1 rounded-lg bg-n-amber-4 hover:bg-n-amber-5"
@click="goToPending"
>
{{ $t('CAPTAIN.OVERVIEW.COVERAGE_BANNER.ACTION') }}
</button>
<button
type="button"
class="grid rounded-lg size-7 place-content-center hover:bg-n-amber-4"
:aria-label="$t('CAPTAIN.OVERVIEW.COVERAGE_BANNER.DISMISS')"
@click="dismiss"
>
<span class="i-lucide-x size-4" />
</button>
</div>
</div>
</template>
@@ -1,73 +0,0 @@
<script setup>
import { computed, ref, watch } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { useStore, useMapGetter } from 'dashboard/composables/store';
const route = useRoute();
const router = useRouter();
const store = useStore();
const dismissed = ref(false);
const assistantId = computed(() => route.params.assistantId);
const inboxes = useMapGetter('captainInboxes/getRecords');
const uiFlags = useMapGetter('captainInboxes/getUIFlags');
// Only surface once we know the assistant has no connected inboxes.
const showBanner = computed(
() =>
!dismissed.value &&
!uiFlags.value.fetchingList &&
inboxes.value.length === 0
);
watch(
assistantId,
id => {
dismissed.value = false;
if (id) store.dispatch('captainInboxes/get', { assistantId: id });
},
{ immediate: true }
);
const goToInboxes = () => {
router.push({
name: 'captain_assistants_inboxes_index',
params: {
accountId: route.params.accountId,
assistantId: assistantId.value,
},
});
};
</script>
<template>
<div
v-if="showBanner"
class="flex items-center justify-between gap-3 px-3 py-2 text-sm border rounded-xl bg-n-amber-3 border-n-amber-4 text-n-amber-11"
>
<div class="flex items-center gap-2 min-w-0">
<span class="shrink-0 i-lucide-triangle-alert size-4" />
<span class="truncate">
{{ $t('CAPTAIN.OVERVIEW.INBOX_BANNER.TEXT') }}
</span>
</div>
<div class="flex items-center gap-1 shrink-0">
<button
type="button"
class="px-3 py-1 rounded-lg bg-n-amber-4 hover:bg-n-amber-5"
@click="goToInboxes"
>
{{ $t('CAPTAIN.OVERVIEW.INBOX_BANNER.ACTION') }}
</button>
<button
type="button"
class="grid rounded-lg size-7 place-content-center hover:bg-n-amber-4"
:aria-label="$t('CAPTAIN.OVERVIEW.INBOX_BANNER.DISMISS')"
@click="dismissed = true"
>
<span class="i-lucide-x size-4" />
</button>
</div>
</div>
</template>
@@ -1,87 +0,0 @@
<script setup>
import { computed } from 'vue';
import { useRoute } from 'vue-router';
import { useI18n } from 'vue-i18n';
const props = defineProps({
knowledge: {
type: Object,
default: () => ({ approved: 0, pending: 0, documents: 0, coverage: 0 }),
},
});
const { t } = useI18n();
const route = useRoute();
const approvedPct = computed(() => props.knowledge.coverage ?? 0);
const linkTo = routeName => ({
name: routeName,
params: {
accountId: route.params.accountId,
assistantId: route.params.assistantId,
},
});
const stats = computed(() => [
{
key: 'approved',
value: props.knowledge.approved,
label: t('CAPTAIN.OVERVIEW.KNOWLEDGE.APPROVED'),
to: linkTo('captain_assistants_responses_index'),
},
{
key: 'pending',
value: props.knowledge.pending,
label: t('CAPTAIN.OVERVIEW.KNOWLEDGE.PENDING'),
to: linkTo('captain_assistants_responses_pending'),
},
{
key: 'documents',
value: props.knowledge.documents,
label: t('CAPTAIN.OVERVIEW.KNOWLEDGE.DOCUMENTS'),
to: linkTo('captain_assistants_documents_index'),
},
]);
</script>
<template>
<div
class="flex flex-col gap-4 p-5 border rounded-xl bg-n-solid-1 border-n-weak"
>
<div class="flex items-center justify-between">
<span class="text-sm font-medium text-n-slate-12">
{{ $t('CAPTAIN.OVERVIEW.KNOWLEDGE.TITLE') }}
</span>
<span class="text-sm tabular-nums text-n-slate-11">
{{ $t('CAPTAIN.OVERVIEW.KNOWLEDGE.COVERAGE', { pct: approvedPct }) }}
</span>
</div>
<div class="w-full h-2 overflow-hidden rounded-full bg-n-alpha-2">
<div
class="h-full rounded-full bg-n-brand"
:style="{ width: `${approvedPct}%` }"
/>
</div>
<div class="grid grid-cols-3 gap-3">
<RouterLink
v-for="stat in stats"
:key="stat.key"
:to="stat.to"
class="flex flex-col gap-1 group/stat"
>
<span class="text-xl font-semibold tabular-nums text-n-slate-12">
{{ stat.value }}
</span>
<span
class="inline-flex items-center gap-1 text-xs transition-colors text-n-slate-11 group-hover/stat:text-n-slate-12"
>
{{ stat.label }}
<span
class="transition-opacity opacity-0 i-lucide-arrow-up-right size-3 group-hover/stat:opacity-100"
/>
</span>
</RouterLink>
</div>
</div>
</template>
@@ -1,40 +0,0 @@
<script setup>
import { computed } from 'vue';
const props = defineProps({
label: { type: String, required: true },
value: { type: String, required: true },
trend: { type: String, default: '' },
hint: { type: String, default: '' },
// null = neutral, true = good direction, false = bad direction
trendGood: { type: Boolean, default: null },
});
const trendClass = computed(() => {
if (props.trendGood === null) return 'text-n-slate-11';
return props.trendGood ? 'text-n-teal-11' : 'text-n-ruby-11';
});
</script>
<template>
<div class="flex flex-col gap-3 p-5 group bg-n-solid-1">
<div class="flex items-center gap-1.5">
<span class="text-sm font-medium text-n-slate-11">{{ label }}</span>
<span
v-if="hint"
v-tooltip="hint"
class="transition-opacity opacity-0 cursor-help i-lucide-info size-3.5 text-n-slate-10 group-hover:opacity-100"
/>
</div>
<div class="flex items-end justify-between gap-2">
<span
class="text-3xl font-semibold tracking-tight tabular-nums text-n-slate-12"
>
{{ value }}
</span>
<span class="text-sm font-medium tabular-nums" :class="trendClass">
{{ trend }}
</span>
</div>
</div>
</template>
@@ -1,81 +0,0 @@
<script setup>
import { computed } from 'vue';
import { useRoute, RouterLink } from 'vue-router';
import { useI18n } from 'vue-i18n';
import { useAccount } from 'dashboard/composables/useAccount';
import { getHelpUrlForFeature } from 'dashboard/helper/featureHelper';
const { t } = useI18n();
const route = useRoute();
const { isOnChatwootCloud } = useAccount();
const assistantParams = computed(() => ({
accountId: route.params.accountId,
assistantId: route.params.assistantId,
}));
const links = computed(() => [
{
key: 'docs',
title: t('CAPTAIN.OVERVIEW.LINKS.DOCS.TITLE'),
description: t('CAPTAIN.OVERVIEW.LINKS.DOCS.DESCRIPTION'),
icon: 'i-lucide-book-open',
href: getHelpUrlForFeature('captain'),
},
{
key: 'playground',
title: t('CAPTAIN.OVERVIEW.LINKS.PLAYGROUND.TITLE'),
description: t('CAPTAIN.OVERVIEW.LINKS.PLAYGROUND.DESCRIPTION'),
icon: 'i-lucide-flask-conical',
to: {
name: 'captain_assistants_playground_index',
params: assistantParams.value,
},
},
{
key: 'billing',
title: t('CAPTAIN.OVERVIEW.LINKS.BILLING.TITLE'),
description: t('CAPTAIN.OVERVIEW.LINKS.BILLING.DESCRIPTION'),
icon: 'i-lucide-credit-card',
to: {
name: 'billing_settings_index',
params: { accountId: route.params.accountId },
},
},
]);
</script>
<template>
<div v-if="isOnChatwootCloud" class="grid grid-cols-1 gap-4 sm:grid-cols-3">
<component
:is="link.href ? 'a' : RouterLink"
v-for="link in links"
:key="link.key"
:href="link.href"
:to="link.to"
:target="link.href ? '_blank' : undefined"
:rel="link.href ? 'noopener noreferrer' : undefined"
class="flex items-center gap-3 p-4 transition-colors border rounded-xl bg-n-solid-1 border-n-weak hover:bg-n-alpha-1 group/link"
>
<span
class="grid rounded-lg size-9 shrink-0 place-content-center bg-n-alpha-2 text-n-slate-11"
>
<span :class="link.icon" class="size-4" />
</span>
<div class="flex flex-col min-w-0">
<span class="text-sm font-medium text-n-slate-12">
{{ link.title }}
</span>
<span class="text-xs truncate text-n-slate-11">
{{ link.description }}
</span>
</div>
<span
:class="
link.href ? 'i-lucide-arrow-up-right' : 'i-lucide-chevron-right'
"
class="ml-auto transition-opacity opacity-0 size-4 text-n-slate-10 group-hover/link:opacity-100"
/>
</component>
</div>
</template>
@@ -1,78 +0,0 @@
<script setup>
import { computed } from 'vue';
import { useToggle } from '@vueuse/core';
import { useI18n } from 'vue-i18n';
import { vOnClickOutside } from '@vueuse/components';
import Button from 'dashboard/components-next/button/Button.vue';
import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.vue';
const modelValue = defineModel({ type: String, default: '30' });
const { t } = useI18n();
const [showDropdown, toggleDropdown] = useToggle();
const DAY_RANGES = ['7', '30', '90'];
const decorate = item => ({
...item,
action: 'select',
isSelected: item.value === modelValue.value,
});
const menuSections = computed(() => {
const dayItems = DAY_RANGES.map(value =>
decorate({
value,
label: t('CAPTAIN.OVERVIEW.RANGES.LAST_DAYS', { count: value }),
})
);
const monthItems = [
decorate({
value: 'this_month',
label: t('CAPTAIN.OVERVIEW.RANGES.THIS_MONTH'),
}),
decorate({
value: 'last_month',
label: t('CAPTAIN.OVERVIEW.RANGES.LAST_MONTH'),
}),
];
return [{ items: dayItems }, { items: monthItems }];
});
const menuItems = computed(() =>
menuSections.value.flatMap(section => section.items)
);
const selectedLabel = computed(
() => menuItems.value.find(item => item.isSelected)?.label || ''
);
const handleAction = ({ value }) => {
toggleDropdown(false);
modelValue.value = value;
};
</script>
<template>
<div
v-on-click-outside="() => toggleDropdown(false)"
class="relative flex items-center group"
>
<Button
sm
slate
faded
trailing-icon
icon="i-lucide-chevron-down"
:label="selectedLabel"
class="rounded-md group-hover:bg-n-alpha-2"
@click="toggleDropdown()"
/>
<DropdownMenu
v-if="showDropdown"
:menu-sections="menuSections"
class="mt-1 ltr:right-0 rtl:left-0 top-full"
@action="handleAction($event)"
/>
</div>
</template>
@@ -1,75 +0,0 @@
<script setup>
import { computed, ref, watch } from 'vue';
import { useRoute } from 'vue-router';
import CaptainAssistant from 'dashboard/api/captain/assistant';
import MessageFormatter from 'shared/helpers/MessageFormatter.js';
const props = defineProps({
range: {
type: String,
default: '30',
},
});
const route = useRoute();
const assistantId = computed(() => route.params.assistantId);
// Markdown summary generated by the model from the assistant's stats (served by
// the captain/assistants/:id/summary endpoint). Numbers are emphasised with
// **bold** so we can highlight them (see prose-strong styling below).
const welcomeMarkdown = ref('');
const isLoading = ref(false);
const fetchSummary = async () => {
isLoading.value = true;
try {
const { data } = await CaptainAssistant.getSummary({
assistantId: assistantId.value,
range: props.range,
});
welcomeMarkdown.value = data.message ?? '';
} catch {
welcomeMarkdown.value = '';
} finally {
isLoading.value = false;
}
};
watch([() => props.range, assistantId], fetchSummary, { immediate: true });
// Render through the shared markdown formatter (html disabled, so it is safe)
// used everywhere else for Captain output, instead of a bespoke parser. It
// handles paragraphs, line breaks, links, lists and emphasis consistently.
const formattedSummary = computed(
() => new MessageFormatter(welcomeMarkdown.value).formattedMessage
);
</script>
<!-- eslint-disable-next-line vue/no-root-v-if -->
<template>
<div v-if="isLoading || welcomeMarkdown" class="flex flex-col gap-3">
<div class="flex items-center gap-1.5 text-n-slate-10">
<span class="i-lucide-sparkles size-3.5" />
<span class="text-xs">
{{ $t('CAPTAIN.OVERVIEW.WELCOME.LABEL') }}
</span>
</div>
<div
v-if="isLoading"
class="flex flex-col gap-5"
:aria-label="$t('CAPTAIN.OVERVIEW.WELCOME.LOADING')"
>
<div class="flex flex-col gap-2.5">
<div class="w-full h-5 rounded bg-n-slate-3 animate-pulse" />
<div class="w-11/12 h-5 rounded bg-n-slate-3 animate-pulse" />
<div class="w-4/6 h-5 rounded bg-n-slate-3 animate-pulse" />
</div>
<div class="w-5/6 h-5 rounded bg-n-slate-3 animate-pulse" />
</div>
<div
v-else
v-dompurify-html="formattedSummary"
class="max-w-none prose prose-p:text-lg prose-p:leading-relaxed prose-p:mt-0 prose-p:mb-3 last:prose-p:mb-0 prose-strong:font-bold prose-strong:tabular-nums prose-strong:text-n-brand text-n-slate-12"
/>
</div>
</template>
@@ -65,7 +65,7 @@ const handleAssistantChange = async assistant => {
const currentRouteName = route.name;
const targetRouteName =
currentRouteName || 'captain_assistants_overview_index';
currentRouteName || 'captain_assistants_responses_index';
await fetchDataForRoute(targetRouteName, assistant.id);
@@ -75,16 +75,6 @@ const hasConversationUnreadCounts = computed(() => {
);
});
const hasFilteredUnreadCounts = computed(() => {
return (
hasConversationUnreadCounts.value &&
isFeatureEnabledonAccount.value(
accountId.value,
FEATURE_FLAGS.UNREAD_COUNT_FOR_FILTERS
)
);
});
const fetchConversationUnreadCounts = ([currentAccountId, isEnabled]) => {
if (!currentAccountId) return;
@@ -209,18 +199,6 @@ const getLabelUnreadCount = useMapGetter(
const getTeamUnreadCount = useMapGetter(
'conversationUnreadCounts/getTeamUnreadCount'
);
const mentionsUnreadCount = useMapGetter(
'conversationUnreadCounts/getMentionsUnreadCount'
);
const participatingUnreadCount = useMapGetter(
'conversationUnreadCounts/getParticipatingUnreadCount'
);
const unattendedUnreadCount = useMapGetter(
'conversationUnreadCounts/getUnattendedUnreadCount'
);
const getFolderUnreadCount = useMapGetter(
'conversationUnreadCounts/getFolderUnreadCount'
);
const teams = useMapGetter('teams/getMyTeams');
const contactCustomViews = useMapGetter('customViews/getContactCustomViews');
const conversationCustomViews = useMapGetter(
@@ -248,22 +226,14 @@ watch([accountId, currentUserId], fetchSidebarSortPreferences, {
immediate: true,
});
const hasUnreadCountsForSection = section => {
if (section === SIDEBAR_SORT_SECTIONS.FOLDERS) {
return hasFilteredUnreadCounts.value;
}
return hasConversationUnreadCounts.value;
};
const getSortOptionsForSection = section =>
getSidebarSortOptions(section, {
hasUnreadCounts: hasUnreadCountsForSection(section),
hasUnreadCounts: hasConversationUnreadCounts.value,
});
const getSortForSection = section =>
resolveSidebarSort(section, getSidebarSectionSort.value(section), {
hasUnreadCounts: hasUnreadCountsForSection(section),
hasUnreadCounts: hasConversationUnreadCounts.value,
});
const updateSortPreference = (section, sortBy) => {
@@ -283,7 +253,6 @@ const sortedFolders = computed(() =>
sortSidebarItems(conversationCustomViews.value, {
sortBy: getSortForSection(SIDEBAR_SORT_SECTIONS.FOLDERS),
labelKey: view => view.name,
unreadCountKey: view => getFolderUnreadCount.value(view.id),
})
);
@@ -373,9 +342,6 @@ const menuItems = computed(() => {
name: 'Mentions',
label: t('SIDEBAR.MENTIONED_CONVERSATIONS'),
icon: 'i-lucide-at-sign',
badgeCount: hasFilteredUnreadCounts.value
? mentionsUnreadCount.value
: 0,
activeOn: ['conversation_through_mentions'],
to: accountScopedRoute('conversation_mentions'),
},
@@ -383,9 +349,6 @@ const menuItems = computed(() => {
name: 'Participating',
label: t('SIDEBAR.PARTICIPATING_CONVERSATIONS'),
icon: 'i-lucide-user-round-check',
badgeCount: hasFilteredUnreadCounts.value
? participatingUnreadCount.value
: 0,
activeOn: ['conversation_through_participating'],
to: accountScopedRoute('conversation_participating'),
},
@@ -394,9 +357,6 @@ const menuItems = computed(() => {
activeOn: ['conversation_through_unattended'],
label: t('SIDEBAR.UNATTENDED_CONVERSATIONS'),
icon: 'i-lucide-clock-alert',
badgeCount: hasFilteredUnreadCounts.value
? unattendedUnreadCount.value
: 0,
to: accountScopedRoute('conversation_unattended'),
},
{
@@ -410,9 +370,6 @@ const menuItems = computed(() => {
children: sortedFolders.value.map(view => ({
name: `${view.name}-${view.id}`,
label: view.name,
badgeCount: hasFilteredUnreadCounts.value
? getFolderUnreadCount.value(view.id)
: 0,
to: accountScopedRoute('folder_conversations', { id: view.id }),
})),
},
@@ -483,14 +440,6 @@ const menuItems = computed(() => {
label: t('SIDEBAR.CAPTAIN'),
activeOn: ['captain_assistants_create_index'],
children: [
{
name: 'Overview',
label: t('SIDEBAR.CAPTAIN_OVERVIEW'),
activeOn: ['captain_assistants_overview_index'],
to: accountScopedRoute('captain_assistants_index', {
navigationPath: 'captain_assistants_overview_index',
}),
},
{
name: 'FAQs',
label: t('SIDEBAR.CAPTAIN_RESPONSES'),
@@ -1,7 +1,6 @@
<script setup>
import { computed } from 'vue';
import ChannelSelector from '../ChannelSelector.vue';
import { IS_WHATSAPP_INBOX_CREATION_DISABLED } from 'dashboard/constants/globals';
const props = defineProps({
channel: {
@@ -59,7 +58,6 @@ const isActive = computed(() => {
if (key === 'whatsapp_call') {
return (
!IS_WHATSAPP_INBOX_CREATION_DISABLED &&
props.enabledFeatures.channel_voice &&
!!window.chatwootConfig?.whatsappAppId &&
window.chatwootConfig.whatsappAppId !== 'none'
@@ -62,10 +62,7 @@ export default {
immediate: true,
handler(inboxId) {
if (inboxId) {
this.$store.dispatch('inboxAssignableAgents/fetch', {
inboxIds: [inboxId],
includeAgentBots: true,
});
this.$store.dispatch('inboxAssignableAgents/fetch', [inboxId]);
}
},
},
@@ -2,7 +2,6 @@
import { computed, ref, watch } from 'vue';
import { getLastMessage } from 'dashboard/helper/conversationHelper';
import Avatar from 'next/avatar/Avatar.vue';
import Icon from 'dashboard/components-next/icon/Icon.vue';
import MessagePreview from './MessagePreview.vue';
import InboxName from '../InboxName.vue';
import TimeAgo from 'dashboard/components/ui/TimeAgo.vue';
@@ -58,10 +57,6 @@ const showMetaSection = computed(() => {
);
});
const isAgentBotAssignee = computed(
() => props.chat?.meta?.assignee_type === 'AgentBot'
);
const hasSlaPolicyId = computed(
() => props.chat?.applied_sla?.id && !props.currentContact?.blocked
);
@@ -164,15 +159,10 @@ watch(
>
<span
v-if="showAssignee && assignee.name"
class="text-n-slate-11 text-xs font-medium leading-3 py-0.5 px-0 inline-flex items-center gap-px truncate"
class="text-n-slate-11 text-xs font-medium leading-3 py-0.5 px-0 inline-flex items-center truncate"
>
<Icon
:icon="
isAgentBotAssignee ? 'i-lucide-bot' : 'i-lucide-user-round'
"
class="size-3 text-n-slate-11 flex-shrink-0"
/>
<span class="truncate">{{ assignee.name }}</span>
<fluent-icon icon="person" size="12" class="text-n-slate-11" />
{{ assignee.name }}
</span>
<CardPriorityIcon
:priority="chat.priority"
@@ -123,13 +123,7 @@ export default {
$t('EMAIL_TRANSCRIPT.FORM.SEND_TO_CONTACT')
}}</label>
</div>
<div
v-if="
currentChat.meta.assignee &&
currentChat.meta.assignee_type !== 'AgentBot'
"
class="flex items-center gap-2"
>
<div v-if="currentChat.meta.assignee" class="flex items-center gap-2">
<input
id="assignee"
v-model="selectedType"
@@ -33,19 +33,10 @@ defineProps({
v-if="variant === 'agent'"
:name="option.label"
:src="option.thumbnail"
:icon-name="option.iconName"
:status="option.status === 'online' ? option.status : null"
:size="20"
class="flex-shrink-0"
>
<template v-if="option.iconName && option.thumbnail" #badge>
<div
class="absolute z-20 flex items-center justify-center rounded-full outline outline-1 outline-n-weak bg-n-solid-1 -bottom-0.5 ltr:-right-0.5 rtl:-left-0.5 size-3"
>
<Icon icon="i-lucide-bot" class="text-n-slate-11 size-2" />
</div>
</template>
</Avatar>
/>
<p class="menu-label truncate min-w-0 flex-1">
{{ option.label }}
</p>
@@ -26,12 +26,11 @@ const mockNoneAgent = {
};
const mockUseMapGetter = (overrides = {}) => {
const getAssignableAgents = vi.fn(() => allAgentsData);
const defaultGetters = {
getCurrentUser: ref(allAgentsData[0]),
getSelectedChat: ref({ inbox_id: 1, meta: { assignee: true } }),
getCurrentAccountId: ref(1),
'inboxAssignableAgents/getAssignableAgents': ref(getAssignableAgents),
'inboxAssignableAgents/getAssignableAgents': ref(() => allAgentsData),
};
const mergedGetters = { ...defaultGetters, ...overrides };
@@ -54,24 +53,6 @@ describe('useAgentsList', () => {
const { agentsList, assignableAgents } = useAgentsList();
expect(assignableAgents.value).toEqual(allAgentsData);
expect(
useMapGetter('inboxAssignableAgents/getAssignableAgents').value
).toHaveBeenCalledWith(1, { includeAgentBots: false });
expect(agentsList.value[0]).toEqual(mockNoneAgent);
expect(agentsList.value.length).toBe(
formattedAgentsData.slice(1).length + 1
);
});
it('requests agent bots when explicitly included', () => {
const { agentsList, assignableAgents } = useAgentsList(true, {
includeAgentBots: true,
});
expect(assignableAgents.value).toEqual(allAgentsData);
expect(
useMapGetter('inboxAssignableAgents/getAssignableAgents').value
).toHaveBeenCalledWith(1, { includeAgentBots: true });
expect(agentsList.value[0]).toEqual(mockNoneAgent);
expect(agentsList.value.length).toBe(
formattedAgentsData.slice(1).length + 1
@@ -10,14 +10,9 @@ import {
* A composable function that provides a list of agents for assignment.
*
* @param {boolean} [includeNoneAgent=true] - Whether to include a 'None' agent option.
* @param {Object} [options] - Options for the assignable agents list.
* @param {boolean} [options.includeAgentBots=false] - Whether to include AgentBot assignees. Only pass this from surfaces that thread `assignee_type` through the assignment request.
* @returns {Object} An object containing the agents list and assignable agents.
*/
export function useAgentsList(
includeNoneAgent = true,
{ includeAgentBots = false } = {}
) {
export function useAgentsList(includeNoneAgent = true) {
const { t } = useI18n();
const currentUser = useMapGetter('getCurrentUser');
const currentChat = useMapGetter('getSelectedChat');
@@ -44,9 +39,7 @@ export function useAgentsList(
* @type {import('vue').ComputedRef<Array>}
*/
const assignableAgents = computed(() => {
return inboxId.value
? assignable.value(inboxId.value, { includeAgentBots })
: [];
return inboxId.value ? assignable.value(inboxId.value) : [];
});
/**
@@ -1,35 +0,0 @@
// Single source of truth for billing currencies on the frontend.
// Adding a currency = one entry in BILLING_CURRENCY_CONFIG, add the code to
// SUPPORTED_BILLING_CURRENCIES, and add its label key under
// BILLING_SETTINGS.CURRENCY.OPTIONS in the locale files.
export const DEFAULT_BILLING_CURRENCY = 'usd';
// Order here drives the order of the currency toggle in the UI.
export const SUPPORTED_BILLING_CURRENCIES = ['usd', 'brl'];
export const BILLING_CURRENCY_CONFIG = {
usd: {
code: 'usd',
intlLocale: 'en-US',
i18nLabelKey: 'BILLING_SETTINGS.CURRENCY.OPTIONS.USD',
},
brl: {
code: 'brl',
intlLocale: 'pt-BR',
i18nLabelKey: 'BILLING_SETTINGS.CURRENCY.OPTIONS.BRL',
},
};
export const getCurrencyConfig = code =>
BILLING_CURRENCY_CONFIG[(code || DEFAULT_BILLING_CURRENCY).toLowerCase()] ||
BILLING_CURRENCY_CONFIG[DEFAULT_BILLING_CURRENCY];
export const formatCurrencyAmount = (amount, code, options = {}) => {
const { intlLocale, code: currencyCode } = getCurrencyConfig(code);
return new Intl.NumberFormat(intlLocale, {
style: 'currency',
currency: currencyCode.toUpperCase(),
...options,
}).format(amount);
};
@@ -78,7 +78,3 @@ export default {
},
};
export const DEFAULT_REDIRECT_URL = '/app/';
// Temporarily disables WhatsApp embedded signup and WhatsApp Call inbox
// creation. Flip to false when the channel is brought back.
export const IS_WHATSAPP_INBOX_CREATION_DISABLED = true;
-1
View File
@@ -47,7 +47,6 @@ export const FEATURE_FLAGS = {
ADVANCED_SEARCH: 'advanced_search',
CONVERSATION_REQUIRED_ATTRIBUTES: 'conversation_required_attributes',
CONVERSATION_UNREAD_COUNTS: 'conversation_unread_counts',
UNREAD_COUNT_FOR_FILTERS: 'unread_count_for_filters',
};
export const PREMIUM_FEATURES = [
@@ -17,13 +17,6 @@ import { FEATURE_FLAGS } from 'dashboard/featureFlags';
const { isImpersonating } = useImpersonation();
const UNREAD_COUNTS_REFETCH_THROTTLE_MS = 5000;
const FILTERED_UNREAD_COUNTS_REFRESH_RETRY_MS = 30000;
const FILTERED_UNREAD_COUNTS_REFRESH_RETRY_JITTER_MS = 15000;
const MENTION_UNREAD_COUNTS_REFETCH_DELAY_MS =
UNREAD_COUNTS_REFETCH_THROTTLE_MS;
const getFilteredUnreadCountsRefreshRetryDelay = () =>
FILTERED_UNREAD_COUNTS_REFRESH_RETRY_MS +
Math.random() * FILTERED_UNREAD_COUNTS_REFRESH_RETRY_JITTER_MS;
class ActionCableConnector extends BaseActionCableConnector {
constructor(app, pubsubToken) {
@@ -32,9 +25,6 @@ class ActionCableConnector extends BaseActionCableConnector {
this.CancelTyping = [];
this.lastUnreadCountsFetchAt = null;
this.unreadCountsFetchTimer = null;
this.mentionUnreadCountsFetchTimer = null;
this.mentionUnreadCountsRetryTimer = null;
this.filteredUnreadCountsRetryTimer = null;
this.events = {
'message.created': this.onMessageCreated,
'message.updated': this.onMessageUpdated,
@@ -150,12 +140,7 @@ class ActionCableConnector extends BaseActionCableConnector {
};
onConversationUnreadCountChanged = () => {
this.refreshConversationUnreadCountsWithFilteredRetry();
};
refreshConversationUnreadCountsWithFilteredRetry = () => {
this.throttledFetchConversationUnreadCounts();
this.scheduleFilteredUnreadCountsRetry();
};
throttledFetchConversationUnreadCounts = () => {
@@ -186,51 +171,6 @@ class ActionCableConnector extends BaseActionCableConnector {
this.unreadCountsFetchTimer = null;
};
scheduleMentionUnreadCountsFetch = () => {
if (!this.isFilteredUnreadCountsEnabled()) return;
// Mention invalidation runs through the async dispatcher, and stale snapshots
// can be served until the filtered-count backend refresh window opens.
this.scheduleUnreadCountsFetchAfter(
'mentionUnreadCountsFetchTimer',
MENTION_UNREAD_COUNTS_REFETCH_DELAY_MS
);
this.scheduleUnreadCountsFetchAfter(
'mentionUnreadCountsRetryTimer',
getFilteredUnreadCountsRefreshRetryDelay(),
{ reset: true }
);
};
scheduleFilteredUnreadCountsRetry = () => {
if (!this.isFilteredUnreadCountsEnabled()) return;
// Filtered snapshots can intentionally stay stale until the backend
// refresh window opens.
this.scheduleUnreadCountsFetchAfter(
'filteredUnreadCountsRetryTimer',
getFilteredUnreadCountsRefreshRetryDelay(),
{ reset: true }
);
};
scheduleUnreadCountsFetchAfter = (
timerName,
delay,
{ reset = false } = {}
) => {
if (this[timerName]) {
if (!reset) return;
clearTimeout(this[timerName]);
}
this[timerName] = setTimeout(() => {
this[timerName] = null;
this.throttledFetchConversationUnreadCounts();
}, delay);
};
fetchConversationUnreadCounts = () => {
if (!this.isConversationUnreadCountsEnabled()) return;
@@ -249,17 +189,6 @@ class ActionCableConnector extends BaseActionCableConnector {
);
};
isFilteredUnreadCountsEnabled = () => {
const accountId = this.app.$store.getters.getCurrentAccountId;
const isFeatureEnabled =
this.app.$store.getters['accounts/isFeatureEnabledonAccount'];
return (
isFeatureEnabled?.(accountId, FEATURE_FLAGS.CONVERSATION_UNREAD_COUNTS) &&
isFeatureEnabled?.(accountId, FEATURE_FLAGS.UNREAD_COUNT_FOR_FILTERS)
);
};
onTypingOn = ({ conversation, user }) => {
const conversationId = conversation.id;
@@ -283,7 +212,6 @@ class ActionCableConnector extends BaseActionCableConnector {
onConversationMentioned = data => {
this.app.$store.dispatch('addMentions', data);
this.scheduleMentionUnreadCountsFetch();
};
clearTimer = conversationId => {
@@ -345,12 +273,6 @@ class ActionCableConnector extends BaseActionCableConnector {
this.app.$store.dispatch('labels/revalidate', { newKey: keys.label });
this.app.$store.dispatch('inboxes/revalidate', { newKey: keys.inbox });
this.app.$store.dispatch('teams/revalidate', { newKey: keys.team });
if (this.isFilteredUnreadCountsEnabled()) {
// Inbox/team/label visibility changes can change the accessible set used
// by filtered unread counts even when no conversation row changes.
this.refreshConversationUnreadCountsWithFilteredRetry();
}
};
onVoiceCallIncoming = data => {
@@ -38,7 +38,7 @@ export const getAgentsByUpdatedPresence = (
currentAccountId
) => {
const agentsWithDynamicPresenceUpdate = agents.map(item =>
item.id === currentUser.id && (item.assignee_type || 'User') === 'User'
item.id === currentUser.id
? {
...item,
availability_status: currentUser.accounts.find(
@@ -19,7 +19,6 @@ const FEATURE_HELP_URLS = {
webhook: 'https://chwt.app/hc/webhooks',
billing: 'https://chwt.app/pricing',
saml: 'https://chwt.app/hc/saml',
captain: 'https://chwt.app/captain-docs',
captain_billing: 'https://chwt.app/hc/captain_billing',
};
@@ -20,8 +20,6 @@ export const SIDEBAR_SORT_OPTIONS_BY_SECTION = Object.freeze({
SIDEBAR_SORT_KEYS.CREATED_ASC,
SIDEBAR_SORT_KEYS.ALPHABETICAL_ASC,
SIDEBAR_SORT_KEYS.ALPHABETICAL_DESC,
SIDEBAR_SORT_KEYS.UNREAD_COUNT_DESC,
SIDEBAR_SORT_KEYS.UNREAD_COUNT_ASC,
],
[SIDEBAR_SORT_SECTIONS.TEAMS]: [
SIDEBAR_SORT_KEYS.CREATED_DESC,
@@ -1,6 +1,5 @@
import { describe, it, beforeEach, afterEach, expect, vi } from 'vitest';
import ActionCableConnector from '../actionCable';
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
vi.mock('shared/helpers/mitt', () => ({
emitter: {
@@ -18,9 +17,6 @@ global.chatwootConfig = {
websocketURL: 'wss://test.chatwoot.com',
};
const mockRetryJitter = value =>
vi.spyOn(Math, 'random').mockReturnValue(value);
describe('ActionCableConnector - Copilot Tests', () => {
let store;
let actionCable;
@@ -43,8 +39,6 @@ describe('ActionCableConnector - Copilot Tests', () => {
});
afterEach(() => {
vi.restoreAllMocks();
vi.clearAllTimers();
vi.useRealTimers();
});
describe('copilot event handlers', () => {
@@ -87,223 +81,12 @@ describe('ActionCableConnector - Copilot Tests', () => {
});
it('should refetch unread counts when unread count changes', () => {
vi.useFakeTimers();
vi.setSystemTime(new Date('2026-01-01T00:00:00Z'));
mockRetryJitter(0.5);
actionCable.onReceived({
event: 'conversation.unread_count_changed',
data: { account_id: 1 },
});
expect(mockDispatch).toHaveBeenCalledWith('conversationUnreadCounts/get');
vi.advanceTimersByTime(37499);
expect(mockDispatch).toHaveBeenCalledTimes(1);
vi.advanceTimersByTime(1);
expect(mockDispatch).toHaveBeenCalledTimes(2);
expect(mockDispatch).toHaveBeenLastCalledWith(
'conversationUnreadCounts/get'
);
});
it('does not retry unread count changes when filtered counts are disabled', () => {
vi.useFakeTimers();
vi.setSystemTime(new Date('2026-01-01T00:00:00Z'));
store.$store.getters[
'accounts/isFeatureEnabledonAccount'
].mockImplementation(
(_, featureFlag) =>
featureFlag === FEATURE_FLAGS.CONVERSATION_UNREAD_COUNTS
);
actionCable.onReceived({
event: 'conversation.unread_count_changed',
data: { account_id: 1 },
});
expect(mockDispatch).toHaveBeenCalledTimes(1);
vi.advanceTimersByTime(45000);
expect(mockDispatch).toHaveBeenCalledTimes(1);
});
it('delays unread count refetch when a conversation is mentioned', () => {
vi.useFakeTimers();
vi.setSystemTime(new Date('2026-01-01T00:00:00Z'));
const conversation = { id: 1, account_id: 1 };
actionCable.onReceived({
event: 'conversation.mentioned',
data: conversation,
});
expect(mockDispatch).toHaveBeenCalledWith('addMentions', conversation);
expect(mockDispatch).not.toHaveBeenCalledWith(
'conversationUnreadCounts/get'
);
vi.advanceTimersByTime(4999);
expect(mockDispatch).not.toHaveBeenCalledWith(
'conversationUnreadCounts/get'
);
vi.advanceTimersByTime(1);
expect(mockDispatch).toHaveBeenCalledWith('conversationUnreadCounts/get');
});
it('does not schedule mention unread count fetches when filtered counts are disabled', () => {
vi.useFakeTimers();
store.$store.getters[
'accounts/isFeatureEnabledonAccount'
].mockImplementation(
(_, featureFlag) =>
featureFlag === FEATURE_FLAGS.CONVERSATION_UNREAD_COUNTS
);
const conversation = { id: 1, account_id: 1 };
actionCable.onReceived({
event: 'conversation.mentioned',
data: conversation,
});
expect(mockDispatch).toHaveBeenCalledWith('addMentions', conversation);
vi.advanceTimersByTime(45000);
expect(mockDispatch).not.toHaveBeenCalledWith(
'conversationUnreadCounts/get'
);
});
it('retries mentioned unread counts after the backend refresh window', () => {
vi.useFakeTimers();
vi.setSystemTime(new Date('2026-01-01T00:00:00Z'));
mockRetryJitter(0.5);
actionCable.onReceived({
event: 'conversation.mentioned',
data: { id: 1, account_id: 1 },
});
const unreadCountFetches = () =>
mockDispatch.mock.calls.filter(
([action]) => action === 'conversationUnreadCounts/get'
);
vi.advanceTimersByTime(5000);
expect(unreadCountFetches()).toHaveLength(1);
vi.advanceTimersByTime(32499);
expect(unreadCountFetches()).toHaveLength(1);
vi.advanceTimersByTime(1);
expect(unreadCountFetches()).toHaveLength(2);
});
it('reschedules mentioned unread count retries for later invalidations', () => {
vi.useFakeTimers();
vi.setSystemTime(new Date('2026-01-01T00:00:00Z'));
mockRetryJitter(0);
const unreadCountFetches = () =>
mockDispatch.mock.calls.filter(
([action]) => action === 'conversationUnreadCounts/get'
);
actionCable.onReceived({
event: 'conversation.mentioned',
data: { id: 1, account_id: 1 },
});
vi.advanceTimersByTime(5000);
expect(unreadCountFetches()).toHaveLength(1);
vi.advanceTimersByTime(10000);
actionCable.onReceived({
event: 'conversation.mentioned',
data: { id: 1, account_id: 1 },
});
vi.advanceTimersByTime(5000);
expect(unreadCountFetches()).toHaveLength(2);
vi.advanceTimersByTime(10000);
expect(unreadCountFetches()).toHaveLength(2);
vi.advanceTimersByTime(15000);
expect(unreadCountFetches()).toHaveLength(3);
});
it('refetches filtered unread counts after account cache invalidation', () => {
vi.useFakeTimers();
vi.setSystemTime(new Date('2026-01-01T00:00:00Z'));
mockRetryJitter(0.5);
const cacheKeys = {
label: 'label-key',
inbox: 'inbox-key',
team: 'team-key',
};
const unreadCountFetches = () =>
mockDispatch.mock.calls.filter(
([action]) => action === 'conversationUnreadCounts/get'
);
actionCable.onReceived({
event: 'account.cache_invalidated',
data: { account_id: 1, cache_keys: cacheKeys },
});
expect(mockDispatch).toHaveBeenCalledWith('labels/revalidate', {
newKey: cacheKeys.label,
});
expect(mockDispatch).toHaveBeenCalledWith('inboxes/revalidate', {
newKey: cacheKeys.inbox,
});
expect(mockDispatch).toHaveBeenCalledWith('teams/revalidate', {
newKey: cacheKeys.team,
});
expect(unreadCountFetches()).toHaveLength(1);
vi.advanceTimersByTime(37499);
expect(unreadCountFetches()).toHaveLength(1);
vi.advanceTimersByTime(1);
expect(unreadCountFetches()).toHaveLength(2);
});
it('does not refetch unread counts after cache invalidation when filtered counts are disabled', () => {
vi.useFakeTimers();
store.$store.getters[
'accounts/isFeatureEnabledonAccount'
].mockImplementation(
(_, featureFlag) =>
featureFlag === FEATURE_FLAGS.CONVERSATION_UNREAD_COUNTS
);
actionCable.onReceived({
event: 'account.cache_invalidated',
data: {
account_id: 1,
cache_keys: {
label: 'label-key',
inbox: 'inbox-key',
team: 'team-key',
},
},
});
expect(mockDispatch).not.toHaveBeenCalledWith(
'conversationUnreadCounts/get'
);
vi.advanceTimersByTime(45000);
expect(mockDispatch).not.toHaveBeenCalledWith(
'conversationUnreadCounts/get'
);
});
it('does not refetch unread counts when unread count feature is disabled', () => {
@@ -148,7 +148,7 @@ describe('#normalizeSidebarSortPreferences', () => {
it('falls back to defaults for unsupported preferences', () => {
const preferences = normalizeSidebarSortPreferences({
[SIDEBAR_SORT_SECTIONS.FOLDERS]: 'unsupported_sort',
[SIDEBAR_SORT_SECTIONS.FOLDERS]: SIDEBAR_SORT_KEYS.UNREAD_COUNT_DESC,
});
expect(preferences).toEqual(DEFAULT_SIDEBAR_SORT_PREFERENCES);
@@ -171,15 +171,6 @@ describe('#getSidebarSortOptions', () => {
expect(options).toContain(SIDEBAR_SORT_KEYS.UNREAD_COUNT_ASC);
});
it('keeps folder unread count options when filtered unread counts are enabled', () => {
const options = getSidebarSortOptions(SIDEBAR_SORT_SECTIONS.FOLDERS, {
hasUnreadCounts: true,
});
expect(options).toContain(SIDEBAR_SORT_KEYS.UNREAD_COUNT_DESC);
expect(options).toContain(SIDEBAR_SORT_KEYS.UNREAD_COUNT_ASC);
});
it('removes unread count options when unread counts are disabled', () => {
const options = getSidebarSortOptions(SIDEBAR_SORT_SECTIONS.TEAMS, {
hasUnreadCounts: false,
@@ -189,16 +180,6 @@ describe('#getSidebarSortOptions', () => {
expect(options).not.toContain(SIDEBAR_SORT_KEYS.UNREAD_COUNT_ASC);
expect(options).toContain(SIDEBAR_SORT_KEYS.ALPHABETICAL_ASC);
});
it('removes folder unread count options when filtered unread counts are disabled', () => {
const options = getSidebarSortOptions(SIDEBAR_SORT_SECTIONS.FOLDERS, {
hasUnreadCounts: false,
});
expect(options).not.toContain(SIDEBAR_SORT_KEYS.UNREAD_COUNT_DESC);
expect(options).not.toContain(SIDEBAR_SORT_KEYS.UNREAD_COUNT_ASC);
expect(options).toContain(SIDEBAR_SORT_KEYS.ALPHABETICAL_ASC);
});
});
describe('#resolveSidebarSort', () => {
@@ -221,14 +202,4 @@ describe('#resolveSidebarSort', () => {
expect(sortBy).toBe(SIDEBAR_SORT_KEYS.ALPHABETICAL_ASC);
});
it('falls back to alphabetical sort for folders when filtered unread counts are disabled', () => {
const sortBy = resolveSidebarSort(
SIDEBAR_SORT_SECTIONS.FOLDERS,
SIDEBAR_SORT_KEYS.UNREAD_COUNT_DESC,
{ hasUnreadCounts: false }
);
expect(sortBy).toBe(SIDEBAR_SORT_KEYS.ALPHABETICAL_ASC);
});
});
@@ -392,93 +392,6 @@
"CAPTAIN": {
"NAME": "Captain",
"HEADER_KNOW_MORE": "Know more",
"OVERVIEW": {
"HEADER": "Overview",
"WELCOME": {
"LABEL": "Captain summary",
"LOADING": "Generating summary…"
},
"INBOX_BANNER": {
"TEXT": "This assistant isn't connected to any inbox yet, so it won't respond to conversations.",
"ACTION": "Connect inbox",
"DISMISS": "Dismiss"
},
"COVERAGE_BANNER": {
"TEXT": "{count} FAQs are pending review, keeping coverage at {coverage}%. Approve them so your assistant can resolve more on its own.",
"ACTION": "Review FAQs",
"DISMISS": "Dismiss"
},
"RANGES": {
"LAST_DAYS": "Last {count} days",
"THIS_MONTH": "This month",
"LAST_MONTH": "Last month"
},
"METRICS": {
"HANDLED": {
"LABEL": "Conversations handled",
"HINT": "Distinct conversations this assistant replied in."
},
"AUTO_RESOLUTION": {
"LABEL": "Auto-resolution rate",
"HINT": "Share of handled conversations closed without a human reply."
},
"HANDOFF": {
"LABEL": "Handoff rate",
"HINT": "Share of handled conversations escalated to a human agent."
},
"HOURS_SAVED": {
"LABEL": "Time saved",
"HINT": "Estimate: Captain replies times ~2 minutes of assumed agent effort per reply. Directional, not measured labor."
},
"REOPEN": {
"LABEL": "Reopen rate",
"HINT": "Auto-resolved conversations that were reopened afterwards."
},
"DEPTH": {
"LABEL": "Messages / conversation",
"HINT": "Average replies the assistant sends per conversation."
}
},
"KNOWLEDGE": {
"TITLE": "Knowledge coverage",
"COVERAGE": "{pct}% approved",
"APPROVED": "Approved FAQs",
"PENDING": "Pending FAQs",
"DOCUMENTS": "Documents"
},
"FLAGGED": {
"TITLE": "Response quality",
"TOTAL": "{count} flagged · {rate}"
},
"CREDITS": {
"TITLE": "Credit usage",
"UNIT": "credits",
"LEGEND": "Daily credits used",
"AXIS_START": "{count}d ago",
"AXIS_END": "Today"
},
"LINKS": {
"DOCS": {
"TITLE": "Captain docs",
"DESCRIPTION": "Guides and how-tos for Captain"
},
"PLAYGROUND": {
"TITLE": "Playground",
"DESCRIPTION": "Test this assistant's replies"
},
"BILLING": {
"TITLE": "Billing",
"DESCRIPTION": "Manage credits and plan"
}
},
"FLAG_REASONS": {
"INCORRECT": "Incorrect info",
"INCOMPLETE": "Incomplete",
"OUTDATED": "Outdated",
"INAPPROPRIATE": "Inappropriate",
"OTHER": "Other"
}
},
"ASSISTANT_SWITCHER": {
"ASSISTANTS": "Assistants",
"SWITCH_ASSISTANT": "Switch between assistants",
@@ -324,7 +324,6 @@
"ALL_COMPANIES": "All Companies",
"CAPTAIN": "Captain",
"CAPTAIN_ASSISTANTS": "Assistants",
"CAPTAIN_OVERVIEW": "Overview",
"CAPTAIN_DOCUMENTS": "Documents",
"CAPTAIN_RESPONSES": "FAQs",
"CAPTAIN_TOOLS": "Tools",
@@ -468,18 +467,7 @@
"TITLE": "Current Plan",
"PLAN_NOTE": "You are currently subscribed to the **{plan}** plan with **{quantity}** licenses",
"SEAT_COUNT": "Number of seats",
"RENEWS_ON": "Renews on",
"CURRENCY": "Currency"
},
"CURRENCY": {
"SELECT": {
"TITLE": "Choose your billing currency",
"DESCRIPTION": "Select the currency you'd like to be billed in. This can't be changed once your subscription is created."
},
"OPTIONS": {
"USD": "US Dollar (USD)",
"BRL": "Brazilian Real (BRL)"
}
"RENEWS_ON": "Renews on"
},
"VIEW_PRICING": "View Pricing",
"MANAGE_SUBSCRIPTION": {
@@ -515,7 +503,6 @@
"PURCHASE": "Purchase Credits",
"LOADING": "Loading options...",
"FETCH_ERROR": "Failed to load credit options. Please try again.",
"RETRY": "Retry",
"PURCHASE_ERROR": "Failed to process purchase. Please try again.",
"PURCHASE_SUCCESS": "Successfully added {credits} credits to your account",
"CONFIRM": {
@@ -542,7 +529,7 @@
"SSO_URL": {
"LABEL": "SSO URL",
"HELP": "The URL where SAML authentication requests will be sent",
"PLACEHOLDER": "https://sso.example.com/saml/sso"
"PLACEHOLDER": "https://your-idp.com/saml/sso"
},
"CERTIFICATE": {
"LABEL": "Signing certificate in PEM format",
@@ -562,7 +549,7 @@
"IDP_ENTITY_ID": {
"LABEL": "Identity Provider Entity ID",
"HELP": "Unique identifier for your identity provider (usually found in IdP configuration)",
"PLACEHOLDER": "https://sso.example.com/saml"
"PLACEHOLDER": "https://your-idp.com/saml"
},
"UPDATE_BUTTON": "Update SAML Settings",
"API": {
@@ -501,7 +501,7 @@
"SSO_URL": {
"LABEL": "SSO URL",
"HELP": "A URL para onde as solicitações de autenticação SAML serão enviadas",
"PLACEHOLDER": "https://sso.example.com/saml/sso"
"PLACEHOLDER": "https://your-idp.com/saml/sso"
},
"CERTIFICATE": {
"LABEL": "Certificado de assinatura no formato PEM",
@@ -521,7 +521,7 @@
"IDP_ENTITY_ID": {
"LABEL": "ID da Entidade do Provedor de Identidade",
"HELP": "Identificador exclusivo do seu provedor de identidade (geralmente encontrado na configuração do IdP)",
"PLACEHOLDER": "https://sso.example.com/saml"
"PLACEHOLDER": "https://seu-idp.com/saml"
},
"UPDATE_BUTTON": "Atualizar configurações de SAML",
"API": {
@@ -1,156 +0,0 @@
<script setup>
import { computed, ref, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import { useRoute } from 'vue-router';
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
import CaptainAssistant from 'dashboard/api/captain/assistant';
import PageLayout from 'dashboard/components-next/captain/PageLayout.vue';
import CaptainPaywall from 'dashboard/components-next/captain/pageComponents/Paywall.vue';
import RangeSelector from 'dashboard/components-next/captain/pageComponents/overview/RangeSelector.vue';
import WelcomeCard from 'dashboard/components-next/captain/pageComponents/overview/WelcomeCard.vue';
import MetricCard from 'dashboard/components-next/captain/pageComponents/overview/MetricCard.vue';
import KnowledgeCard from 'dashboard/components-next/captain/pageComponents/overview/KnowledgeCard.vue';
import QuickLinks from 'dashboard/components-next/captain/pageComponents/overview/QuickLinks.vue';
import InboxBanner from 'dashboard/components-next/captain/pageComponents/overview/InboxBanner.vue';
import CoverageBanner from 'dashboard/components-next/captain/pageComponents/overview/CoverageBanner.vue';
const { t } = useI18n();
const route = useRoute();
const selectedRange = ref('this_month');
const assistantId = computed(() => route.params.assistantId);
const stats = ref(null);
const fetchStats = async () => {
try {
const { data } = await CaptainAssistant.getStats({
assistantId: assistantId.value,
range: selectedRange.value,
});
stats.value = data;
} catch {
stats.value = null;
}
};
watch([selectedRange, assistantId], fetchStats, { 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.
const resolveTrendGood = (trendValue, direction) => {
if (direction === 'neutral' || trendValue === 0) return null;
return direction === 'up' ? trendValue > 0 : trendValue < 0;
};
// Trend units mirror the backend pack mode: a relative percent change ('%') for
// :percent metrics, a percentage-point delta (' pts') for rate metrics packed as
// :point, and a plain number for :absolute counts like conversation depth.
const TREND_SUFFIX = { percent: '%', point: ' pts', absolute: '' };
// Hours-saved is reported in hours, but large values read better as days. Past
// 100h we switch the unit so the card stays legible.
const formatDuration = hours =>
hours >= 100 ? `${Math.round(hours / 24)}d` : `${hours}h`;
const metricFor = (statKey, formatValue, direction, trendKind = 'percent') => {
const data = stats.value?.[statKey];
if (!data) return { value: '—', trend: '', trendGood: null };
const sign = data.trend > 0 ? '+' : '';
return {
value: formatValue(data.current),
trend: `${sign}${data.trend}${TREND_SUFFIX[trendKind]}`,
trendGood: resolveTrendGood(data.trend, direction),
};
};
const metrics = computed(() => [
{
key: 'handled',
label: t('CAPTAIN.OVERVIEW.METRICS.HANDLED.LABEL'),
hint: t('CAPTAIN.OVERVIEW.METRICS.HANDLED.HINT'),
...metricFor('conversations_handled', v => v.toLocaleString(), 'up'),
},
{
key: 'autoResolution',
label: t('CAPTAIN.OVERVIEW.METRICS.AUTO_RESOLUTION.LABEL'),
hint: t('CAPTAIN.OVERVIEW.METRICS.AUTO_RESOLUTION.HINT'),
...metricFor('auto_resolution_rate', v => `${v}%`, 'up', 'point'),
},
{
key: 'handoff',
label: t('CAPTAIN.OVERVIEW.METRICS.HANDOFF.LABEL'),
hint: t('CAPTAIN.OVERVIEW.METRICS.HANDOFF.HINT'),
...metricFor('handoff_rate', v => `${v}%`, 'down', 'point'),
},
{
key: 'hoursSaved',
label: t('CAPTAIN.OVERVIEW.METRICS.HOURS_SAVED.LABEL'),
hint: t('CAPTAIN.OVERVIEW.METRICS.HOURS_SAVED.HINT'),
...metricFor('hours_saved', formatDuration, 'up'),
},
{
key: 'reopen',
label: t('CAPTAIN.OVERVIEW.METRICS.REOPEN.LABEL'),
hint: t('CAPTAIN.OVERVIEW.METRICS.REOPEN.HINT'),
...metricFor('reopen_rate', v => `${v}%`, 'down', 'point'),
},
{
key: 'depth',
label: t('CAPTAIN.OVERVIEW.METRICS.DEPTH.LABEL'),
hint: t('CAPTAIN.OVERVIEW.METRICS.DEPTH.HINT'),
...metricFor(
'conversation_depth',
v => v.toFixed(1),
'neutral',
'absolute'
),
},
]);
</script>
<template>
<PageLayout
:header-title="$t('CAPTAIN.OVERVIEW.HEADER')"
:is-empty="false"
:show-pagination-footer="false"
:show-know-more="false"
:feature-flag="FEATURE_FLAGS.CAPTAIN"
>
<template #headerActions>
<RangeSelector v-model="selectedRange" />
</template>
<template #paywall>
<CaptainPaywall />
</template>
<template #body>
<div class="flex flex-col gap-6 pb-8">
<InboxBanner />
<CoverageBanner :knowledge="stats?.knowledge" />
<WelcomeCard :range="selectedRange" />
<div
class="grid grid-cols-1 gap-px overflow-hidden border rounded-xl sm:grid-cols-2 lg:grid-cols-3 bg-n-weak border-n-weak"
>
<MetricCard
v-for="metric in metrics"
:key="metric.key"
:label="metric.label"
:value="metric.value"
:trend="metric.trend"
:hint="metric.hint"
:trend-good="metric.trendGood"
/>
</div>
<KnowledgeCard :knowledge="stats?.knowledge" />
<QuickLinks />
</div>
</template>
</PageLayout>
</template>
@@ -6,7 +6,6 @@ import CaptainPageRouteView from './pages/CaptainPageRouteView.vue';
import AssistantsIndexPage from './pages/AssistantsIndexPage.vue';
import AssistantEmptyStateIndex from './assistants/Index.vue';
import AssistantOverviewIndex from './assistants/overview/Index.vue';
import AssistantSettingsIndex from './assistants/settings/Settings.vue';
import AssistantInboxesIndex from './assistants/inboxes/Index.vue';
import AssistantPlaygroundIndex from './assistants/playground/Index.vue';
@@ -37,12 +36,6 @@ const metaV2 = {
};
const assistantRoutes = [
{
path: frontendURL('accounts/:accountId/captain/:assistantId/overview'),
component: AssistantOverviewIndex,
name: 'captain_assistants_overview_index',
meta,
},
{
path: frontendURL('accounts/:accountId/captain/:assistantId/faqs'),
component: ResponsesIndex,
@@ -136,7 +129,7 @@ export const routes = [
return {
name: 'captain_assistants_index',
params: {
navigationPath: 'captain_assistants_overview_index',
navigationPath: 'captain_assistants_responses_index',
...to.params,
},
};
@@ -53,7 +53,6 @@ const routeToLastActiveAssistant = () => {
const { navigationPath } = route.params;
const isAValidRoute = [
'captain_assistants_overview_index', // Overview page
'captain_assistants_responses_index', // Faq page
'captain_assistants_documents_index', // Document page
'captain_assistants_scenarios_index', // Scenario page
@@ -65,7 +64,7 @@ const routeToLastActiveAssistant = () => {
const navigateTo = isAValidRoute
? navigationPath
: 'captain_assistants_overview_index';
: 'captain_assistants_responses_index';
return routeToView(navigateTo, {
accountId: route.params.accountId,
@@ -25,7 +25,7 @@ export default {
},
},
setup() {
const { agentsList } = useAgentsList(true, { includeAgentBots: true });
const { agentsList } = useAgentsList();
return {
agentsList,
};
@@ -81,27 +81,18 @@ export default {
},
assignedAgent: {
get() {
const assignee = this.currentChat.meta.assignee;
return (
assignee && {
...assignee,
assignee_type: this.currentChat.meta.assignee_type || 'User',
}
);
return this.currentChat.meta.assignee;
},
set(agent) {
const agentId = agent ? agent.id : null;
const assigneeType = agent ? agent.assignee_type || 'User' : null;
this.$store.dispatch('setCurrentChatAssignee', {
conversationId: this.currentChat.id,
assignee: agent,
assigneeType,
});
this.$store
.dispatch('assignAgent', {
conversationId: this.currentChat.id,
agentId,
assigneeType,
})
.then(() => {
useAlert(this.$t('CONVERSATION.CHANGE_AGENT'));
@@ -161,10 +152,7 @@ export default {
if (!this.assignedAgent) {
return true;
}
if (
this.assignedAgent.id !== this.currentUser.id ||
(this.assignedAgent.assignee_type || 'User') !== 'User'
) {
if (this.assignedAgent.id !== this.currentUser.id) {
return true;
}
return false;
@@ -195,11 +183,7 @@ export default {
this.assignedAgent = selfAssign;
},
onClickAssignAgent(selectedItem) {
if (
this.assignedAgent?.id === selectedItem.id &&
(this.assignedAgent?.assignee_type || 'User') ===
(selectedItem.assignee_type || 'User')
) {
if (this.assignedAgent && this.assignedAgent.id === selectedItem.id) {
this.assignedAgent = null;
} else {
this.assignedAgent = selectedItem;
@@ -1,5 +1,4 @@
import { useMapGetter } from 'dashboard/composables/store';
import { IS_WHATSAPP_INBOX_CREATION_DISABLED } from 'dashboard/constants/globals';
// OAuth/SDK channels need installation-level app credentials to be usable. When
// the credential is missing the channel is "not configured" and is hidden from
@@ -14,7 +13,6 @@ export function useChannelConfig() {
// WhatsApp is onboarded only via Meta embedded signup, which needs both the
// app id (not the 'none' sentinel) and the signup configuration id.
whatsapp: () =>
!IS_WHATSAPP_INBOX_CREATION_DISABLED &&
Boolean(installationConfig.whatsappAppId) &&
installationConfig.whatsappAppId !== 'none' &&
Boolean(installationConfig.whatsappConfigurationId),
@@ -6,13 +6,6 @@ import { useDetectedChannels } from '../../inbox-setup/useDetectedChannels';
vi.mock('vue-router');
// Neutralize the temporary WhatsApp kill switch so these specs keep covering
// the credential-based gating it short-circuits.
vi.mock('dashboard/constants/globals', async importOriginal => ({
...(await importOriginal()),
IS_WHATSAPP_INBOX_CREATION_DISABLED: false,
}));
// Mounts the composable against a real store and the real useAccount (only
// useRoute and the underlying getters are faked), so a change to how useAccount
// resolves the current account is exercised here too. The real ./constants are
@@ -15,8 +15,6 @@ import PurchaseCreditsModal from './components/PurchaseCreditsModal.vue';
import BaseSettingsHeader from '../components/BaseSettingsHeader.vue';
import SettingsLayout from '../SettingsLayout.vue';
import ButtonV4 from 'next/button/Button.vue';
import { getCurrencyConfig } from 'dashboard/constants/billing';
import { useI18n } from 'vue-i18n';
const router = useRouter();
const { currentAccount, isOnChatwootCloud } = useAccount();
@@ -31,7 +29,6 @@ const {
const uiFlags = useMapGetter('accounts/getUIFlags');
const store = useStore();
const { t } = useI18n();
const BILLING_REFRESH_ATTEMPTED = 'billing_refresh_attempted';
@@ -39,10 +36,6 @@ const BILLING_REFRESH_ATTEMPTED = 'billing_refresh_attempted';
const isWaitingForBilling = ref(false);
const purchaseCreditsModalRef = ref(null);
// Currency selection shown to new accounts whose locale supports a non-USD currency.
const currencySelectionRequired = ref(false);
const currencyOptions = ref([]);
const customAttributes = computed(() => {
return currentAccount.value.custom_attributes || {};
});
@@ -68,13 +61,6 @@ const subscribedQuantity = computed(() => {
return customAttributes.value.subscribed_quantity;
});
const billingCurrency = computed(() => {
if (!customAttributes.value.billing_currency) return '';
return t(
getCurrencyConfig(customAttributes.value.billing_currency).i18nLabelKey
);
});
const subscriptionRenewsOn = computed(() => {
if (!customAttributes.value.subscription_ends_on) return '';
const endDate = new Date(customAttributes.value.subscription_ends_on);
@@ -92,9 +78,7 @@ const hasABillingPlan = computed(() => {
const fetchAccountDetails = async () => {
if (!hasABillingPlan.value) {
const data = await store.dispatch('accounts/subscription');
currencySelectionRequired.value = !!data?.currency_selection_required;
currencyOptions.value = data?.currency_options || [];
await store.dispatch('accounts/subscription');
}
// Always fetch limits for billing page to show credit usage
fetchLimits();
@@ -113,9 +97,6 @@ const handleBillingPageLogic = async () => {
// If cloud user, fetch account details first
await fetchAccountDetails();
// Waiting on the user to pick a billing currency — don't auto-refresh.
if (currencySelectionRequired.value) return;
// If still no billing plan after fetch
if (!hasABillingPlan.value) {
// If we haven't attempted refresh yet, do it once
@@ -137,13 +118,6 @@ const handleBillingPageLogic = async () => {
}
};
const onSelectCurrency = async code => {
await store.dispatch('accounts/selectBillingCurrency', code);
currencySelectionRequired.value = false;
// Currency stored and customer creation kicked off — resume the standard wait flow.
await handleBillingPageLogic();
};
const onClickBillingPortal = () => {
store.dispatch('accounts/checkout');
};
@@ -174,9 +148,7 @@ onMounted(handleBillingPageLogic);
? $t('BILLING_SETTINGS.NO_BILLING_USER')
: $t('ATTRIBUTES_MGMT.LOADING')
"
:no-records-found="
!hasABillingPlan && !isWaitingForBilling && !currencySelectionRequired
"
:no-records-found="!hasABillingPlan && !isWaitingForBilling"
:no-records-message="$t('BILLING_SETTINGS.NO_BILLING_USER')"
>
<template #header>
@@ -188,30 +160,7 @@ onMounted(handleBillingPageLogic);
/>
</template>
<template #body>
<section v-if="currencySelectionRequired" class="grid gap-4">
<BillingCard
:title="$t('BILLING_SETTINGS.CURRENCY.SELECT.TITLE')"
:description="$t('BILLING_SETTINGS.CURRENCY.SELECT.DESCRIPTION')"
>
<template #action>
<div class="flex gap-2">
<ButtonV4
v-for="code in currencyOptions"
:key="code"
sm
solid
blue
:is-loading="uiFlags.isCheckoutInProcess"
:disabled="uiFlags.isCheckoutInProcess"
@click="onSelectCurrency(code)"
>
{{ $t(getCurrencyConfig(code).i18nLabelKey) }}
</ButtonV4>
</div>
</template>
</BillingCard>
</section>
<section v-else class="grid gap-4">
<section class="grid gap-4">
<BillingCard
:title="$t('BILLING_SETTINGS.MANAGE_SUBSCRIPTION.TITLE')"
:description="$t('BILLING_SETTINGS.MANAGE_SUBSCRIPTION.DESCRIPTION')"
@@ -239,11 +188,6 @@ onMounted(handleBillingPageLogic);
:label="$t('BILLING_SETTINGS.CURRENT_PLAN.RENEWS_ON')"
:value="subscriptionRenewsOn"
/>
<DetailItem
v-if="billingCurrency"
:label="$t('BILLING_SETTINGS.CURRENT_PLAN.CURRENCY')"
:value="billingCurrency"
/>
</div>
</BillingCard>
<BillingCard
@@ -1,6 +1,4 @@
<script setup>
import { formatCurrencyAmount } from 'dashboard/constants/billing';
defineProps({
credits: {
type: Number,
@@ -35,7 +33,11 @@ const formatCredits = credits => {
};
const formatAmount = (amount, currency) => {
return formatCurrencyAmount(amount, currency, { minimumFractionDigits: 0 });
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency: currency.toUpperCase(),
minimumFractionDigits: 0,
}).format(amount);
};
</script>
@@ -4,18 +4,20 @@ import { useI18n } from 'vue-i18n';
import { useAlert } from 'dashboard/composables';
import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
import Button from 'dashboard/components-next/button/Button.vue';
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
import CreditPackageCard from './CreditPackageCard.vue';
import EnterpriseAccountAPI from 'dashboard/api/enterprise/account';
import {
formatCurrencyAmount,
DEFAULT_BILLING_CURRENCY,
} from 'dashboard/constants/billing';
const emit = defineEmits(['success']);
const emit = defineEmits(['close', 'success']);
const { t } = useI18n();
const TOPUP_OPTIONS = [
{ credits: 1000, amount: 20.0, currency: 'usd' },
{ credits: 2500, amount: 50.0, currency: 'usd' },
{ credits: 6000, amount: 100.0, currency: 'usd' },
{ credits: 12000, amount: 200.0, currency: 'usd' },
];
const POPULAR_CREDITS_AMOUNT = 6000;
const STEP_SELECT = 'select';
const STEP_CONFIRM = 'confirm';
@@ -25,20 +27,16 @@ const selectedCredits = ref(null);
const isLoading = ref(false);
const currentStep = ref(STEP_SELECT);
// Topup packages come from the backend for the account's billing currency.
const topupOptions = ref([]);
const optionsCurrency = ref(DEFAULT_BILLING_CURRENCY);
const isFetchingOptions = ref(false);
const fetchError = ref(false);
const selectedOption = computed(() => {
return topupOptions.value.find(o => o.credits === selectedCredits.value);
return TOPUP_OPTIONS.find(o => o.credits === selectedCredits.value);
});
const formattedAmount = computed(() => {
if (!selectedOption.value) return '';
const { amount, currency } = selectedOption.value;
return formatCurrencyAmount(amount, currency || optionsCurrency.value);
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency: selectedOption.value.currency.toUpperCase(),
}).format(selectedOption.value.amount);
});
const formattedCredits = computed(() => {
@@ -66,44 +64,24 @@ const handlePackageSelect = credits => {
selectedCredits.value = credits;
};
const selectDefaultOption = () => {
const popularOption = topupOptions.value.find(
const open = () => {
const popularOption = TOPUP_OPTIONS.find(
o => o.credits === POPULAR_CREDITS_AMOUNT
);
selectedCredits.value =
popularOption?.credits || topupOptions.value[0]?.credits || null;
};
const fetchOptions = async () => {
isFetchingOptions.value = true;
fetchError.value = false;
try {
const { data } = await EnterpriseAccountAPI.getTopupOptions();
topupOptions.value = data.options ?? [];
optionsCurrency.value = (
data.currency || DEFAULT_BILLING_CURRENCY
).toLowerCase();
selectDefaultOption();
} catch {
fetchError.value = true;
topupOptions.value = [];
} finally {
isFetchingOptions.value = false;
}
};
const open = () => {
selectedCredits.value = popularOption?.credits || TOPUP_OPTIONS[0]?.credits;
currentStep.value = STEP_SELECT;
isLoading.value = false;
selectedCredits.value = null;
dialogRef.value?.open();
fetchOptions();
};
const close = () => {
dialogRef.value?.close();
};
const handleClose = () => {
emit('close');
};
const goToConfirmStep = () => {
if (!selectedOption.value) return;
currentStep.value = STEP_CONFIRM;
@@ -149,58 +127,32 @@ defineExpose({ open, close });
:width="dialogWidth"
:show-confirm-button="false"
:show-cancel-button="false"
@close="handleClose"
>
<!-- Step 1: Select Credits Package -->
<template v-if="currentStep === STEP_SELECT">
<div
v-if="isFetchingOptions"
class="flex items-center justify-center gap-2 py-10"
>
<Spinner />
<span class="text-sm text-n-slate-11">{{
$t('BILLING_SETTINGS.TOPUP.LOADING')
}}</span>
</div>
<div
v-else-if="fetchError"
class="flex flex-col items-center justify-center gap-3 py-10"
>
<p class="text-sm text-n-slate-11">
{{ $t('BILLING_SETTINGS.TOPUP.FETCH_ERROR') }}
</p>
<Button
variant="faded"
color="slate"
:label="$t('BILLING_SETTINGS.TOPUP.RETRY')"
@click="fetchOptions"
<template v-if="currentStep === 'select'">
<div class="grid grid-cols-2 gap-4">
<CreditPackageCard
v-for="option in TOPUP_OPTIONS"
:key="option.credits"
name="credit-package"
:credits="option.credits"
:amount="option.amount"
:currency="option.currency"
:is-popular="option.credits === POPULAR_CREDITS_AMOUNT"
:is-selected="selectedCredits === option.credits"
@select="handlePackageSelect(option.credits)"
/>
</div>
<template v-else>
<div class="grid grid-cols-2 gap-4">
<CreditPackageCard
v-for="option in topupOptions"
:key="option.credits"
name="credit-package"
:credits="option.credits"
:amount="option.amount"
:currency="option.currency"
:is-popular="option.credits === POPULAR_CREDITS_AMOUNT"
:is-selected="selectedCredits === option.credits"
@select="handlePackageSelect(option.credits)"
/>
</div>
<div class="p-4 mt-6 rounded-lg bg-n-solid-2 border border-n-weak">
<p class="text-sm text-n-slate-11">
<span class="font-semibold text-n-slate-12">{{
$t('BILLING_SETTINGS.TOPUP.NOTE_TITLE')
}}</span>
{{ $t('BILLING_SETTINGS.TOPUP.NOTE_DESCRIPTION') }}
</p>
</div>
</template>
<div class="p-4 mt-6 rounded-lg bg-n-solid-2 border border-n-weak">
<p class="text-sm text-n-slate-11">
<span class="font-semibold text-n-slate-12">{{
$t('BILLING_SETTINGS.TOPUP.NOTE_TITLE')
}}</span>
{{ $t('BILLING_SETTINGS.TOPUP.NOTE_DESCRIPTION') }}
</p>
</div>
</template>
<!-- Step 2: Confirm Purchase -->
@@ -226,7 +178,7 @@ defineExpose({ open, close });
<template #footer>
<!-- Step 1 Footer -->
<div
v-if="currentStep === STEP_SELECT"
v-if="currentStep === 'select'"
class="flex items-center justify-between w-full gap-3"
>
<Button
@@ -240,7 +192,7 @@ defineExpose({ open, close });
color="blue"
:label="$t('BILLING_SETTINGS.TOPUP.PURCHASE')"
class="w-full"
:disabled="!selectedCredits || isFetchingOptions || fetchError"
:disabled="!selectedCredits"
@click="goToConfirmStep"
/>
</div>
@@ -7,7 +7,6 @@ import ThreeSixtyDialogWhatsapp from './360DialogWhatsapp.vue';
import CloudWhatsapp from './CloudWhatsapp.vue';
import WhatsappEmbeddedSignup from './WhatsappEmbeddedSignup.vue';
import ChannelSelector from 'dashboard/components/ChannelSelector.vue';
import { IS_WHATSAPP_INBOX_CREATION_DISABLED } from 'dashboard/constants/globals';
const route = useRoute();
const router = useRouter();
@@ -24,7 +23,6 @@ const PROVIDER_TYPES = {
const hasWhatsappAppId = computed(() => {
return (
!IS_WHATSAPP_INBOX_CREATION_DISABLED &&
window.chatwootConfig?.whatsappAppId &&
window.chatwootConfig.whatsappAppId !== 'none'
);
@@ -144,20 +144,7 @@ export const actions = {
subscription: async ({ commit }) => {
commit(types.default.SET_ACCOUNT_UI_FLAG, { isCheckoutInProcess: true });
try {
const response = await EnterpriseAccountAPI.subscription();
return response.data;
} catch (error) {
throwErrorMessage(error);
return null;
} finally {
commit(types.default.SET_ACCOUNT_UI_FLAG, { isCheckoutInProcess: false });
}
},
selectBillingCurrency: async ({ commit }, currency) => {
commit(types.default.SET_ACCOUNT_UI_FLAG, { isCheckoutInProcess: true });
try {
await EnterpriseAccountAPI.selectBillingCurrency(currency);
await EnterpriseAccountAPI.subscription();
} catch (error) {
throwErrorMessage(error);
} finally {
@@ -6,10 +6,6 @@ export const state = {
inboxes: {},
labels: {},
teams: {},
mentionsCount: 0,
participatingCount: 0,
unattendedCount: 0,
folders: {},
};
const normalizeCount = count => {
@@ -41,18 +37,6 @@ export const getters = {
getTeamUnreadCount: $state => teamId => {
return $state.teams[String(teamId)] || 0;
},
getMentionsUnreadCount($state) {
return $state.mentionsCount;
},
getParticipatingUnreadCount($state) {
return $state.participatingCount;
},
getUnattendedUnreadCount($state) {
return $state.unattendedCount;
},
getFolderUnreadCount: $state => folderId => {
return $state.folders[String(folderId)] || 0;
},
getInboxUnreadCounts($state) {
return $state.inboxes;
},
@@ -62,9 +46,6 @@ export const getters = {
getTeamUnreadCounts($state) {
return $state.teams;
},
getFolderUnreadCounts($state) {
return $state.folders;
},
};
export const actions = {
@@ -87,10 +68,6 @@ export const mutations = {
$state.inboxes = normalizeCounts(payload.inboxes);
$state.labels = normalizeCounts(payload.labels);
$state.teams = normalizeCounts(payload.teams);
$state.mentionsCount = normalizeCount(payload.mentions_count);
$state.participatingCount = normalizeCount(payload.participating_count);
$state.unattendedCount = normalizeCount(payload.unattended_count);
$state.folders = normalizeCounts(payload.folders);
},
};
@@ -1,15 +1,8 @@
import types from '../mutation-types';
import { throwErrorMessage } from 'dashboard/store/utils/api';
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
import ConversationInboxApi from '../../api/inbox/conversation';
const FILTERED_UNREAD_COUNTS_REFRESH_RETRY_MS = 30000;
const FILTERED_UNREAD_COUNTS_REFRESH_RETRY_JITTER_MS = 15000;
const getFilteredUnreadCountsRefreshRetryDelay = () =>
FILTERED_UNREAD_COUNTS_REFRESH_RETRY_MS +
Math.random() * FILTERED_UNREAD_COUNTS_REFRESH_RETRY_JITTER_MS;
const state = {
records: {},
uiFlags: {
@@ -27,43 +20,6 @@ export const getters = {
},
};
const hasFeatureEnabled = (rootGetters, featureFlag) => {
const accountId = rootGetters?.getCurrentAccountId;
const isFeatureEnabled = rootGetters?.['accounts/isFeatureEnabledonAccount'];
return Boolean(accountId && isFeatureEnabled?.(accountId, featureFlag));
};
const hasCurrentUser = (participants, currentUserId) =>
(Array.isArray(participants) ? participants : []).some(
participant => participant.id === currentUserId
);
const refreshConversationUnreadCounts = dispatch => {
dispatch('conversationUnreadCounts/get', {}, { root: true });
setTimeout(
() => dispatch('conversationUnreadCounts/get', {}, { root: true }),
getFilteredUnreadCountsRefreshRetryDelay()
);
};
const shouldRefreshConversationUnreadCounts = (
{ rootGetters, state: moduleState },
conversationId,
participants
) => {
const currentUserId =
rootGetters?.getCurrentUserID || rootGetters?.getCurrentUser?.id;
return (
currentUserId &&
hasFeatureEnabled(rootGetters, FEATURE_FLAGS.CONVERSATION_UNREAD_COUNTS) &&
hasFeatureEnabled(rootGetters, FEATURE_FLAGS.UNREAD_COUNT_FOR_FILTERS) &&
hasCurrentUser(moduleState.records[conversationId], currentUserId) !==
hasCurrentUser(participants, currentUserId)
);
};
export const actions = {
show: async ({ commit }, { conversationId }) => {
commit(types.SET_CONVERSATION_PARTICIPANTS_UI_FLAG, {
@@ -86,10 +42,7 @@ export const actions = {
}
},
update: async (
{ commit, dispatch, rootGetters, state: moduleState },
{ conversationId, userIds }
) => {
update: async ({ commit }, { conversationId, userIds }) => {
commit(types.SET_CONVERSATION_PARTICIPANTS_UI_FLAG, {
isUpdating: true,
});
@@ -99,18 +52,10 @@ export const actions = {
conversationId,
userIds,
});
const shouldRefreshUnreadCounts = shouldRefreshConversationUnreadCounts(
{ rootGetters, state: moduleState },
conversationId,
response.data
);
commit(types.SET_CONVERSATION_PARTICIPANTS, {
conversationId,
data: response.data,
});
if (shouldRefreshUnreadCounts) {
refreshConversationUnreadCounts(dispatch);
}
} catch (error) {
throwErrorMessage(error);
} finally {
@@ -208,31 +208,23 @@ const actions = {
}
},
assignAgent: async (
{ dispatch },
{ conversationId, agentId, assigneeType }
) => {
assignAgent: async ({ dispatch }, { conversationId, agentId }) => {
try {
const response = await ConversationApi.assignAgent({
conversationId,
agentId,
assigneeType,
});
dispatch('setCurrentChatAssignee', {
conversationId,
assignee: response.data,
assigneeType,
});
} catch (error) {
// Handle error
}
},
setCurrentChatAssignee(
{ commit },
{ conversationId, assignee, assigneeType }
) {
commit(types.ASSIGN_AGENT, { conversationId, assignee, assigneeType });
setCurrentChatAssignee({ commit }, { conversationId, assignee }) {
commit(types.ASSIGN_AGENT, { conversationId, assignee });
},
assignTeam: async ({ dispatch }, { conversationId, teamId }) => {
@@ -108,11 +108,10 @@ export const mutations = {
}
},
[types.ASSIGN_AGENT](_state, { conversationId, assignee, assigneeType }) {
[types.ASSIGN_AGENT](_state, { conversationId, assignee }) {
const chat = getConversationById(_state)(conversationId);
if (chat) {
chat.meta.assignee = assignee;
chat.meta.assignee_type = assigneeType;
}
},
@@ -1,17 +1,11 @@
import * as MutationHelpers from 'shared/helpers/vuex/mutationHelpers';
import types from '../mutation-types';
import CustomViewsAPI from '../../api/customViews';
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
const VIEW_TYPES = {
CONVERSATION: 'conversation',
CONTACT: 'contact',
};
const FILTERED_UNREAD_COUNTS_REFRESH_RETRY_MS = 30000;
const FILTERED_UNREAD_COUNTS_REFRESH_RETRY_JITTER_MS = 15000;
const getFilteredUnreadCountsRefreshRetryDelay = () =>
FILTERED_UNREAD_COUNTS_REFRESH_RETRY_MS +
Math.random() * FILTERED_UNREAD_COUNTS_REFRESH_RETRY_JITTER_MS;
// use to normalize the filter type
const FILTER_KEYS = {
@@ -27,38 +21,6 @@ const getFolderContactId = folder =>
folder?.query?.payload?.find(filter => filter.attribute_key === 'contact_id')
?.values?.[0];
const hasFeatureEnabled = (rootGetters, featureFlag) => {
const accountId = rootGetters?.getCurrentAccountId;
const isFeatureEnabled = rootGetters?.['accounts/isFeatureEnabledonAccount'];
return Boolean(accountId && isFeatureEnabled?.(accountId, featureFlag));
};
const shouldRefreshConversationUnreadCounts = (filterType, rootGetters) => {
return (
FILTER_KEYS[filterType] === VIEW_TYPES.CONVERSATION &&
hasFeatureEnabled(rootGetters, FEATURE_FLAGS.CONVERSATION_UNREAD_COUNTS) &&
hasFeatureEnabled(rootGetters, FEATURE_FLAGS.UNREAD_COUNT_FOR_FILTERS)
);
};
const dispatchConversationUnreadCounts = dispatch => {
dispatch('conversationUnreadCounts/get', {}, { root: true });
};
const refreshConversationUnreadCounts = (
{ dispatch, rootGetters },
filterType
) => {
if (!shouldRefreshConversationUnreadCounts(filterType, rootGetters)) return;
dispatchConversationUnreadCounts(dispatch);
setTimeout(
() => dispatchConversationUnreadCounts(dispatch),
getFilteredUnreadCountsRefreshRetryDelay()
);
};
export const state = {
[VIEW_TYPES.CONVERSATION]: {
records: [],
@@ -109,19 +71,14 @@ export const actions = {
commit(types.SET_CUSTOM_VIEW_UI_FLAG, { isFetching: false });
}
},
create: async function createCustomViews(
{ commit, dispatch, rootGetters },
obj
) {
create: async function createCustomViews({ commit }, obj) {
commit(types.SET_CUSTOM_VIEW_UI_FLAG, { isCreating: true });
try {
const response = await CustomViewsAPI.create(obj);
const filterType = FILTER_KEYS[obj.filter_type];
commit(types.ADD_CUSTOM_VIEW, {
data: response.data,
filterType,
filterType: FILTER_KEYS[obj.filter_type],
});
refreshConversationUnreadCounts({ dispatch, rootGetters }, filterType);
return response;
} catch (error) {
const errorMessage = error?.response?.data?.message;
@@ -130,19 +87,14 @@ export const actions = {
commit(types.SET_CUSTOM_VIEW_UI_FLAG, { isCreating: false });
}
},
update: async function updateCustomViews(
{ commit, dispatch, rootGetters },
obj
) {
update: async function updateCustomViews({ commit }, obj) {
commit(types.SET_CUSTOM_VIEW_UI_FLAG, { isCreating: true });
try {
const response = await CustomViewsAPI.update(obj.id, obj);
const filterType = FILTER_KEYS[obj.filter_type];
commit(types.UPDATE_CUSTOM_VIEW, {
data: response.data,
filterType,
filterType: FILTER_KEYS[obj.filter_type],
});
refreshConversationUnreadCounts({ dispatch, rootGetters }, filterType);
} catch (error) {
const errorMessage = error?.response?.data?.message;
throw new Error(errorMessage);
@@ -150,12 +102,11 @@ export const actions = {
commit(types.SET_CUSTOM_VIEW_UI_FLAG, { isCreating: false });
}
},
delete: async ({ commit, dispatch, rootGetters }, { id, filterType }) => {
delete: async ({ commit }, { id, filterType }) => {
commit(types.SET_CUSTOM_VIEW_UI_FLAG, { isDeleting: true });
try {
await CustomViewsAPI.deleteCustomViews(id, filterType);
commit(types.DELETE_CUSTOM_VIEW, { data: id, filterType });
refreshConversationUnreadCounts({ dispatch, rootGetters }, filterType);
} catch (error) {
throw new Error(error);
} finally {
@@ -7,52 +7,31 @@ const state = {
},
};
const recordKey = (inboxId, { includeAgentBots = false } = {}) =>
includeAgentBots ? `${inboxId}:with_agent_bots` : inboxId;
export const types = {
SET_INBOX_ASSIGNABLE_AGENTS_UI_FLAG: 'SET_INBOX_ASSIGNABLE_AGENTS_UI_FLAG',
SET_INBOX_ASSIGNABLE_AGENTS: 'SET_INBOX_ASSIGNABLE_AGENTS',
};
export const getters = {
getAssignableAgents:
$state =>
(inboxId, options = {}) => {
const includeAgentBots = options.includeAgentBots || false;
const allAgents = $state.records[recordKey(inboxId, options)] || [];
const verifiedAgents = allAgents.filter(
record =>
record.confirmed ||
(includeAgentBots && record.assignee_type === 'AgentBot')
);
return verifiedAgents;
},
getAssignableAgents: $state => inboxId => {
const allAgents = $state.records[inboxId] || [];
const verifiedAgents = allAgents.filter(record => record.confirmed);
return verifiedAgents;
},
getUIFlags($state) {
return $state.uiFlags;
},
};
export const actions = {
async fetch({ commit }, actionPayload) {
const inboxIds = Array.isArray(actionPayload)
? actionPayload
: actionPayload.inboxIds;
const includeAgentBots =
!Array.isArray(actionPayload) && actionPayload.includeAgentBots;
async fetch({ commit }, inboxIds) {
commit(types.SET_INBOX_ASSIGNABLE_AGENTS_UI_FLAG, { isFetching: true });
try {
const {
data: { payload },
} = await AssignableAgentsAPI.get(inboxIds, { includeAgentBots });
if (includeAgentBots) {
commit(types.SET_INBOX_ASSIGNABLE_AGENTS, {
inboxId: inboxIds.join(','),
members: payload,
});
}
} = await AssignableAgentsAPI.get(inboxIds);
commit(types.SET_INBOX_ASSIGNABLE_AGENTS, {
inboxId: recordKey(inboxIds.join(','), { includeAgentBots }),
inboxId: inboxIds.join(','),
members: payload,
});
} catch (error) {
@@ -19,10 +19,6 @@ describe('#actions', () => {
inboxes: { 1: '2' },
labels: { 3: 4 },
teams: { 5: 6 },
mentions_count: 7,
participating_count: 8,
unattended_count: 9,
folders: { 10: 11 },
};
axios.get.mockResolvedValue({ data: { payload } });
@@ -7,7 +7,6 @@ describe('#getters', () => {
inboxes: { 1: 2 },
labels: {},
teams: {},
folders: {},
};
expect(getters.getInboxUnreadCount(state)(1)).toBe(2);
@@ -21,7 +20,6 @@ describe('#getters', () => {
inboxes: {},
labels: { 3: 4 },
teams: {},
folders: {},
};
expect(getters.getLabelUnreadCount(state)(3)).toBe(4);
@@ -35,7 +33,6 @@ describe('#getters', () => {
inboxes: {},
labels: {},
teams: { 5: 6 },
folders: {},
};
expect(getters.getTeamUnreadCount(state)(5)).toBe(6);
@@ -49,44 +46,21 @@ describe('#getters', () => {
inboxes: {},
labels: {},
teams: {},
folders: {},
};
expect(getters.getAllUnreadCount(state)).toBe(7);
});
it('returns filtered unread counts', () => {
const state = {
allCount: 0,
inboxes: {},
labels: {},
teams: {},
mentionsCount: 1,
participatingCount: 2,
unattendedCount: 3,
folders: { 8: 4 },
};
expect(getters.getMentionsUnreadCount(state)).toBe(1);
expect(getters.getParticipatingUnreadCount(state)).toBe(2);
expect(getters.getUnattendedUnreadCount(state)).toBe(3);
expect(getters.getFolderUnreadCount(state)(8)).toBe(4);
expect(getters.getFolderUnreadCount(state)('8')).toBe(4);
expect(getters.getFolderUnreadCount(state)(9)).toBe(0);
});
it('returns unread count maps', () => {
const state = {
allCount: 0,
inboxes: { 1: 2 },
labels: { 3: 4 },
teams: { 5: 6 },
folders: { 7: 8 },
};
expect(getters.getInboxUnreadCounts(state)).toEqual({ 1: 2 });
expect(getters.getLabelUnreadCounts(state)).toEqual({ 3: 4 });
expect(getters.getTeamUnreadCounts(state)).toEqual({ 5: 6 });
expect(getters.getFolderUnreadCounts(state)).toEqual({ 7: 8 });
});
});
@@ -4,16 +4,7 @@ import { mutations } from '../../conversationUnreadCounts';
describe('#mutations', () => {
describe('#SET_CONVERSATION_UNREAD_COUNTS', () => {
it('normalizes unread count payload', () => {
const state = {
allCount: 0,
inboxes: {},
labels: {},
teams: {},
mentionsCount: 0,
participatingCount: 0,
unattendedCount: 0,
folders: {},
};
const state = { allCount: 0, inboxes: {}, labels: {}, teams: {} };
mutations[types.SET_CONVERSATION_UNREAD_COUNTS](state, {
all_count: '3',
@@ -30,13 +21,6 @@ describe('#mutations', () => {
6: '7',
7: 0,
},
mentions_count: '8',
participating_count: 9,
unattended_count: 0,
folders: {
10: '11',
12: -1,
},
});
expect(state).toEqual({
@@ -44,10 +28,6 @@ describe('#mutations', () => {
inboxes: { 1: 2 },
labels: { 4: 5 },
teams: { 6: 7 },
mentionsCount: 8,
participatingCount: 9,
unattendedCount: 0,
folders: { 10: 11 },
});
});
@@ -57,10 +37,6 @@ describe('#mutations', () => {
inboxes: { 1: 2 },
labels: { 4: 5 },
teams: { 6: 7 },
mentionsCount: 8,
participatingCount: 9,
unattendedCount: 10,
folders: { 11: 12 },
};
mutations[types.SET_CONVERSATION_UNREAD_COUNTS](state, {});
@@ -70,36 +46,17 @@ describe('#mutations', () => {
inboxes: {},
labels: {},
teams: {},
mentionsCount: 0,
participatingCount: 0,
unattendedCount: 0,
folders: {},
});
});
it('normalizes invalid aggregate counts to zero', () => {
const state = {
allCount: 2,
inboxes: {},
labels: {},
teams: {},
mentionsCount: 2,
participatingCount: 3,
unattendedCount: 4,
folders: {},
};
const state = { allCount: 2, inboxes: {}, labels: {}, teams: {} };
mutations[types.SET_CONVERSATION_UNREAD_COUNTS](state, {
all_count: 'invalid',
mentions_count: 'invalid',
participating_count: -1,
unattended_count: 0,
});
expect(state.allCount).toBe(0);
expect(state.mentionsCount).toBe(0);
expect(state.participatingCount).toBe(0);
expect(state.unattendedCount).toBe(0);
});
});
});
@@ -1,32 +1,11 @@
import axios from 'axios';
import { actions } from '../../conversationWatchers';
import types from '../../../mutation-types';
import { FEATURE_FLAGS } from '../../../../featureFlags';
const commit = vi.fn();
global.axios = axios;
vi.mock('axios');
const mockRetryJitter = value =>
vi.spyOn(Math, 'random').mockReturnValue(value);
afterEach(() => {
vi.restoreAllMocks();
vi.clearAllTimers();
vi.useRealTimers();
});
const conversationUnreadCountsEnabledRootGetters = {
getCurrentAccountId: 1,
getCurrentUserID: 1,
'accounts/isFeatureEnabledonAccount': vi.fn((_, featureFlag) =>
[
FEATURE_FLAGS.CONVERSATION_UNREAD_COUNTS,
FEATURE_FLAGS.UNREAD_COUNT_FOR_FILTERS,
].includes(featureFlag)
),
};
describe('#actions', () => {
describe('#get', () => {
it('sends correct actions if API is success', async () => {
@@ -69,56 +48,6 @@ describe('#actions', () => {
[types.SET_CONVERSATION_PARTICIPANTS_UI_FLAG, { isUpdating: false }],
]);
});
it('refetches unread counts when the current user starts watching', async () => {
vi.useFakeTimers();
mockRetryJitter(0.5);
const dispatch = vi.fn();
const moduleState = { records: { 2: [] } };
const mutatingCommit = vi.fn((mutation, payload) => {
if (mutation === types.SET_CONVERSATION_PARTICIPANTS) {
moduleState.records[payload.conversationId] = payload.data;
}
});
axios.patch.mockResolvedValue({ data: [{ id: 1 }] });
await actions.update(
{
commit: mutatingCommit,
dispatch,
rootGetters: conversationUnreadCountsEnabledRootGetters,
state: moduleState,
},
{ conversationId: 2, userIds: [1] }
);
expect(dispatch).toHaveBeenCalledWith(
'conversationUnreadCounts/get',
{},
{ root: true }
);
vi.advanceTimersByTime(37499);
expect(dispatch).toHaveBeenCalledTimes(1);
vi.advanceTimersByTime(1);
expect(dispatch).toHaveBeenCalledTimes(2);
});
it('does not refetch unread counts when another watcher changes', async () => {
const dispatch = vi.fn();
axios.patch.mockResolvedValue({ data: [{ id: 1 }, { id: 2 }] });
await actions.update(
{
commit,
dispatch,
rootGetters: conversationUnreadCountsEnabledRootGetters,
state: { records: { 2: [{ id: 1 }] } },
},
{ conversationId: 2, userIds: [1, 2] }
);
expect(dispatch).not.toHaveBeenCalled();
});
it('sends correct actions if API is error', async () => {
axios.patch.mockRejectedValue({ message: 'Incorrect header' });
await expect(
@@ -357,12 +357,11 @@ describe('#actions', () => {
});
await actions.assignAgent(
{ dispatch },
{ conversationId: 1, agentId: 1, assigneeType: 'AgentBot' }
{ conversationId: 1, agentId: 1 }
);
expect(dispatch).toHaveBeenCalledWith('setCurrentChatAssignee', {
conversationId: 1,
assignee: { id: 1, name: 'User' },
assigneeType: 'AgentBot',
});
});
});
@@ -372,7 +371,6 @@ describe('#actions', () => {
const payload = {
conversationId: 1,
assignee: { id: 1, name: 'User' },
assigneeType: 'AgentBot',
};
await actions.setCurrentChatAssignee({ commit }, payload);
expect(commit).toHaveBeenCalledTimes(1);
@@ -712,10 +712,8 @@ describe('#mutations', () => {
mutations[types.ASSIGN_AGENT](state, {
conversationId: 1,
assignee,
assigneeType: 'AgentBot',
});
expect(state.allConversations[0].meta.assignee).toEqual(assignee);
expect(state.allConversations[0].meta.assignee_type).toEqual('AgentBot');
expect(state.allConversations[1].meta.assignee).toBeUndefined();
});
});
@@ -1,7 +1,6 @@
import axios from 'axios';
import * as types from '../../../mutation-types';
import { actions } from '../../customViews';
import { FEATURE_FLAGS } from '../../../../featureFlags';
import {
contactFilterView,
customViewList,
@@ -12,25 +11,6 @@ const commit = vi.fn();
global.axios = axios;
vi.mock('axios');
const mockRetryJitter = value =>
vi.spyOn(Math, 'random').mockReturnValue(value);
const conversationUnreadCountsEnabledRootGetters = {
getCurrentAccountId: 1,
'accounts/isFeatureEnabledonAccount': vi.fn((_, featureFlag) =>
[
FEATURE_FLAGS.CONVERSATION_UNREAD_COUNTS,
FEATURE_FLAGS.UNREAD_COUNT_FOR_FILTERS,
].includes(featureFlag)
),
};
afterEach(() => {
vi.restoreAllMocks();
vi.clearAllTimers();
vi.useRealTimers();
});
describe('#actions', () => {
describe('#get', () => {
it('sends correct actions if API is success', async () => {
@@ -69,36 +49,6 @@ describe('#actions', () => {
[types.default.SET_CUSTOM_VIEW_UI_FLAG, { isCreating: false }],
]);
});
it('refetches unread counts after creating a conversation folder', async () => {
vi.useFakeTimers();
mockRetryJitter(0.5);
const dispatch = vi.fn();
const firstItem = customViewList[0];
axios.post.mockResolvedValue({ data: firstItem });
await actions.create(
{
commit,
dispatch,
rootGetters: conversationUnreadCountsEnabledRootGetters,
},
firstItem
);
expect(dispatch).toHaveBeenCalledWith(
'conversationUnreadCounts/get',
{},
{ root: true }
);
vi.advanceTimersByTime(37499);
expect(dispatch).toHaveBeenCalledTimes(1);
vi.advanceTimersByTime(1);
expect(dispatch).toHaveBeenCalledTimes(2);
});
it('sends correct actions if API is error', async () => {
axios.post.mockRejectedValue({ message: 'Incorrect header' });
await expect(actions.create({ commit })).rejects.toThrow(Error);
@@ -119,44 +69,6 @@ describe('#actions', () => {
[types.default.SET_CUSTOM_VIEW_UI_FLAG, { isDeleting: false }],
]);
});
it('refetches unread counts after deleting a conversation folder', async () => {
vi.useFakeTimers();
const dispatch = vi.fn();
axios.delete.mockResolvedValue({ data: customViewList[0] });
await actions.delete(
{
commit,
dispatch,
rootGetters: conversationUnreadCountsEnabledRootGetters,
},
{ id: 1, filterType: 'conversation' }
);
expect(dispatch).toHaveBeenCalledWith(
'conversationUnreadCounts/get',
{},
{ root: true }
);
});
it('does not refetch unread counts after deleting a contact segment', async () => {
const dispatch = vi.fn();
axios.delete.mockResolvedValue({ data: contactFilterView });
await actions.delete(
{
commit,
dispatch,
rootGetters: conversationUnreadCountsEnabledRootGetters,
},
{ id: 1, filterType: 'contact' }
);
expect(dispatch).not.toHaveBeenCalled();
});
it('sends correct actions if API is error', async () => {
axios.delete.mockRejectedValue({ message: 'Incorrect header' });
await expect(actions.delete({ commit }, 1)).rejects.toThrow(Error);
@@ -181,29 +93,6 @@ describe('#actions', () => {
[types.default.SET_CUSTOM_VIEW_UI_FLAG, { isCreating: false }],
]);
});
it('refetches unread counts after updating a conversation folder', async () => {
vi.useFakeTimers();
const dispatch = vi.fn();
const item = updateCustomViewList[0];
axios.patch.mockResolvedValue({ data: item });
await actions.update(
{
commit,
dispatch,
rootGetters: conversationUnreadCountsEnabledRootGetters,
},
item
);
expect(dispatch).toHaveBeenCalledWith(
'conversationUnreadCounts/get',
{},
{ root: true }
);
});
it('sends correct actions if API is error', async () => {
axios.patch.mockRejectedValue({ message: 'Incorrect header' });
await expect(actions.update({ commit }, 1)).rejects.toThrow(Error);
@@ -7,21 +7,12 @@ global.axios = axios;
vi.mock('axios');
describe('#actions', () => {
beforeEach(() => {
vi.clearAllMocks();
});
describe('#fetch', () => {
it('sends correct actions if API is success', async () => {
axios.get.mockResolvedValue({
data: { payload: agentsData },
});
await actions.fetch({ commit }, [1]);
expect(axios.get).toHaveBeenCalledWith('/api/v1/assignable_agents', {
params: {
inbox_ids: [1],
},
});
expect(commit.mock.calls).toEqual([
[types.SET_INBOX_ASSIGNABLE_AGENTS_UI_FLAG, { isFetching: true }],
[
@@ -33,37 +24,13 @@ describe('#actions', () => {
});
it('sends correct actions if API is error', async () => {
axios.get.mockRejectedValue({ message: 'Incorrect header' });
await expect(actions.fetch({ commit }, [1])).rejects.toThrow(Error);
await expect(actions.fetch({ commit }, { inboxId: 1 })).rejects.toThrow(
Error
);
expect(commit.mock.calls).toEqual([
[types.SET_INBOX_ASSIGNABLE_AGENTS_UI_FLAG, { isFetching: true }],
[types.SET_INBOX_ASSIGNABLE_AGENTS_UI_FLAG, { isFetching: false }],
]);
});
it('requests agent bots only when opted in', async () => {
axios.get.mockResolvedValue({
data: { payload: agentsData },
});
await actions.fetch(
{ commit },
{ inboxIds: [1], includeAgentBots: true }
);
expect(axios.get).toHaveBeenCalledWith('/api/v1/assignable_agents', {
params: {
inbox_ids: [1],
include_agent_bots: true,
},
});
expect(commit).toHaveBeenCalledWith(types.SET_INBOX_ASSIGNABLE_AGENTS, {
inboxId: '1',
members: agentsData,
});
expect(commit).toHaveBeenCalledWith(types.SET_INBOX_ASSIGNABLE_AGENTS, {
inboxId: '1:with_agent_bots',
members: agentsData,
});
});
});
});
@@ -1,4 +1,4 @@
import { getters } from '../../inboxAssignableAgents';
import { getters } from '../../teamMembers';
import agentsData from './fixtures';
describe('#getters', () => {
@@ -8,26 +8,7 @@ describe('#getters', () => {
1: [agentsData[0]],
},
};
expect(getters.getAssignableAgents(state)(1)).toEqual([agentsData[0]]);
});
it('keeps agent bots scoped to bot-inclusive lists', () => {
const agentBot = {
id: 1,
name: 'Captain',
assignee_type: 'AgentBot',
};
const state = {
records: {
1: [agentBot, agentsData[0]],
'1:with_agent_bots': [agentBot, agentsData[0]],
},
};
expect(getters.getAssignableAgents(state)(1)).toEqual([agentsData[0]]);
expect(
getters.getAssignableAgents(state)(1, { includeAgentBots: true })
).toEqual([agentBot, agentsData[0]]);
expect(getters.getTeamMembers(state)(1)).toEqual([agentsData[0]]);
});
it('getUIFlags', () => {
@@ -83,7 +83,7 @@ describe('#actions', () => {
);
});
it('ignores invalid sort values', () => {
it('ignores invalid preferences', () => {
actions.setSectionSort(
{
commit,
@@ -95,7 +95,7 @@ describe('#actions', () => {
},
{
section: SIDEBAR_SORT_SECTIONS.FOLDERS,
sortBy: 'invalid_sort',
sortBy: SIDEBAR_SORT_KEYS.UNREAD_COUNT_DESC,
}
);
@@ -63,14 +63,6 @@ const hasValue = computed(() => {
const hasIcon = computed(() => {
return props.selectedItem?.icon || false;
});
const isAgentBot = computed(
() => props.selectedItem?.assignee_type === 'AgentBot'
);
const selectedThumbnail = computed(
() => props.selectedItem?.thumbnail || props.selectedItem?.avatar_url
);
</script>
<template>
@@ -101,25 +93,16 @@ const selectedThumbnail = computed(
</h4>
</div>
<Avatar
v-if="hasValue && hasThumbnail && (isAgentBot || !hasIcon)"
:src="selectedThumbnail"
v-if="hasValue && hasThumbnail && !hasIcon"
:src="selectedItem.thumbnail"
:status="selectedItem.availability_status"
:name="selectedItem.name"
:icon-name="isAgentBot ? 'i-lucide-bot' : undefined"
:size="24"
hide-offline-status
rounded-full
>
<template v-if="isAgentBot && selectedThumbnail" #badge>
<div
class="absolute z-20 flex items-center justify-center rounded-full outline outline-1 outline-n-weak bg-n-solid-1 -bottom-0.5 ltr:-right-0.5 rtl:-left-0.5 size-3.5"
>
<Icon icon="i-lucide-bot" class="text-n-slate-11 size-2.5" />
</div>
</template>
</Avatar>
/>
<div
v-else-if="hasValue && hasIcon && showEmojiIcon"
v-if="hasValue && hasIcon && showEmojiIcon"
class="flex items-center justify-center flex-shrink-0 text-sm rounded-full size-6 outline outline-1 -outline-offset-1 outline-n-weak"
>
<EmojiIcon
@@ -73,13 +73,7 @@ export default {
this.$refs.searchbar.focus();
},
isActive(option) {
return this.selectedItems.some(item => {
if (!item || option.id !== item.id) return false;
return (
(option.assignee_type || 'User') === (item.assignee_type || 'User')
);
});
return this.selectedItems.some(item => item && option.id === item.id);
},
},
};
@@ -100,10 +94,7 @@ export default {
<div class="flex items-start justify-start flex-auto overflow-auto mt-2">
<div class="w-full max-h-[10rem]">
<WootDropdownMenu>
<WootDropdownItem
v-for="option in filteredOptions"
:key="`${option.assignee_type || 'User'}-${option.id}`"
>
<WootDropdownItem v-for="option in filteredOptions" :key="option.id">
<NextButton
slate
:variant="isActive(option) ? 'faded' : 'ghost'"
@@ -123,38 +114,16 @@ export default {
</span>
</div>
<Avatar
v-if="
hasThumbnail &&
(!option.icon || option.assignee_type === 'AgentBot')
"
v-if="hasThumbnail && !option.icon"
:src="option.thumbnail"
:name="option.name"
:status="option.availability_status"
:icon-name="
option.assignee_type === 'AgentBot'
? 'i-lucide-bot'
: undefined
"
:size="24"
hide-offline-status
rounded-full
>
<template
v-if="option.assignee_type === 'AgentBot' && option.thumbnail"
#badge
>
<div
class="absolute z-20 flex items-center justify-center rounded-full outline outline-1 outline-n-weak bg-n-solid-1 -bottom-0.5 ltr:-right-0.5 rtl:-left-0.5 size-3.5"
>
<Icon
icon="i-lucide-bot"
class="text-n-slate-11 size-2.5"
/>
</div>
</template>
</Avatar>
/>
<div
v-else-if="option.icon && showEmojiIcon"
v-if="option.icon && showEmojiIcon"
class="flex items-center justify-center flex-shrink-0 text-sm rounded-full size-6 outline outline-1 -outline-offset-1 outline-n-weak"
>
<EmojiIcon
+1 -5
View File
@@ -31,11 +31,7 @@ class Agents::DestroyJob < ApplicationJob
def unassign_conversations(account, user)
# rubocop:disable Rails/SkipsModelValidations
unassigned_count = user.assigned_conversations.where(account: account).in_batches.update_all(assignee_id: nil)
user.assigned_conversations.where(account: account).in_batches.update_all(assignee_id: nil)
# rubocop:enable Rails/SkipsModelValidations
return unless unassigned_count.positive?
::Conversations::UnreadCounts::FilteredCountInvalidator.new(account).conversation_changed!
end
end
+1 -2
View File
@@ -7,7 +7,6 @@
# custom_attributes :jsonb
# domain :string(100)
# feature_flags :bigint default(0), not null
# feature_flags_ext_1 :bigint default(0), not null
# internal_attributes :jsonb not null
# limits :jsonb
# locale :integer default("en")
@@ -24,7 +23,7 @@
#
class Account < ApplicationRecord
# used for multi-flag bitset columns
# used for single column multi flags
include FlagShihTzu
include Reportable
include Featurable
-18
View File
@@ -39,8 +39,6 @@ class AccountUser < ApplicationRecord
after_create_commit :notify_creation, :create_notification_setting
after_destroy :notify_deletion, :remove_user_from_account
after_save :update_presence_in_redis, if: :saved_change_to_availability?
after_commit :invalidate_filtered_unread_count_visibility, on: [:create, :destroy]
after_update_commit :invalidate_filtered_unread_count_visibility_update, if: :filtered_unread_count_visibility_changed?
validates :user_id, uniqueness: { scope: :account_id }
@@ -81,22 +79,6 @@ class AccountUser < ApplicationRecord
def update_presence_in_redis
OnlineStatusTracker.set_status(account.id, user.id, availability)
end
def filtered_unread_count_visibility_changed?
previous_changes.key?('role') || previous_changes.key?('custom_role_id')
end
def invalidate_filtered_unread_count_visibility
::Conversations::UnreadCounts::FilteredCountInvalidator.new(account).user_visibility_changed!(user_id: user_id)
end
def invalidate_filtered_unread_count_visibility_update
dispatch_account_cache_invalidated if invalidate_filtered_unread_count_visibility
end
def dispatch_account_cache_invalidated
Rails.configuration.dispatcher.dispatch(ACCOUNT_CACHE_INVALIDATED, Time.zone.now, account: account, cache_keys: account.cache_keys)
end
end
AccountUser.prepend_mod_with('AccountUser')
-10
View File
@@ -53,7 +53,6 @@ class Campaign < ApplicationRecord
before_validation :ensure_correct_campaign_attributes
after_commit :set_display_id, unless: :display_id?
after_destroy_commit :invalidate_filtered_unread_count_filters
def trigger!
return unless one_off?
@@ -89,15 +88,6 @@ class Campaign < ApplicationRecord
end
end
def invalidate_filtered_unread_count_filters
filters_changed = ::Conversations::UnreadCounts::FilteredCountInvalidator.new(account).conversation_changed!
dispatch_account_cache_invalidated if filters_changed
end
def dispatch_account_cache_invalidated
Rails.configuration.dispatcher.dispatch(ACCOUNT_CACHE_INVALIDATED, Time.zone.now, account: account, cache_keys: account.cache_keys)
end
def set_display_id
reload
end
@@ -6,8 +6,6 @@ module AccountCacheRevalidator
end
def update_account_cache
return if account.blank?
account.update_cache_key(self.class.name.underscore)
end
end
+3 -54
View File
@@ -1,10 +1,6 @@
module Featurable
extend ActiveSupport::Concern
DEFAULT_FEATURE_FLAG_COLUMN = 'feature_flags'.freeze
FEATURE_FLAG_COLUMNS = [DEFAULT_FEATURE_FLAG_COLUMN, 'feature_flags_ext_1'].freeze
MAX_FEATURES_PER_COLUMN = 63
QUERY_MODE = {
flag_query_mode: :bit_operator,
check_for_column: false
@@ -12,62 +8,15 @@ module Featurable
FEATURE_LIST = YAML.safe_load(Rails.root.join('config/features.yml').read).freeze
def self.feature_flag_mappings_for(feature_list)
features_by_column = feature_list.group_by { |feature| feature['column'].presence || DEFAULT_FEATURE_FLAG_COLUMN }
mappings = FEATURE_FLAG_COLUMNS.index_with do |column|
features = features_by_column.delete(column) || []
validate_feature_count!(column, features)
features.each_with_index.to_h do |feature, index|
[index + 1, "feature_#{feature['name']}".to_sym]
end
end
validate_feature_columns!(features_by_column)
mappings
FEATURES = FEATURE_LIST.each_with_object({}) do |feature, result|
result[result.keys.size + 1] = "feature_#{feature['name']}".to_sym
end
def self.validate_feature_count!(column, features)
return if features.size <= MAX_FEATURES_PER_COLUMN
raise ArgumentError, "Account feature flag column #{column} supports up to #{MAX_FEATURES_PER_COLUMN} features"
end
def self.validate_feature_columns!(features_by_column)
return if features_by_column.blank?
invalid_columns = features_by_column.keys.join(', ')
raise ArgumentError, "Unknown account feature flag column: #{invalid_columns}"
end
FEATURES_BY_COLUMN = feature_flag_mappings_for(FEATURE_LIST).freeze
included do
include FlagShihTzu
FEATURE_FLAG_COLUMNS.each do |column|
has_flags FEATURES_BY_COLUMN.fetch(column).merge(column: column).merge(QUERY_MODE)
end
has_flags FEATURES.merge(column: 'feature_flags').merge(QUERY_MODE)
before_create :enable_default_features
define_method :all_feature_flags do
FEATURE_FLAG_COLUMNS.flat_map { |column| all_flags(column) }
end
define_method :selected_feature_flags do
FEATURE_FLAG_COLUMNS.flat_map { |column| selected_flags(column) }
end
define_method :selected_feature_flags= do |chosen_flags|
FEATURE_FLAG_COLUMNS.each { |column| unselect_all_flags(column) }
return if chosen_flags.nil?
chosen_flags.each do |selected_flag|
enable_flag(selected_flag.to_sym) if selected_flag.present?
end
end
end
def enable_features(*names)
+4 -26
View File
@@ -62,14 +62,6 @@ class Conversation < ApplicationRecord
include PushDataHelper
include ConversationMuteHelpers
CONVERSATION_UPDATED_ADDITIONAL_ATTRIBUTE_KEYS = %w[conversation_language].freeze
FILTERED_UNREAD_COUNT_ADDITIONAL_ATTRIBUTE_KEYS = %w[browser_language conversation_language mail_subject referer].freeze
FILTERED_UNREAD_COUNT_UPDATE_KEYS = %w[
cached_label_list campaign_id custom_attributes first_reply_created_at label_list last_activity_at priority snoozed_until waiting_since
].freeze
private_constant :CONVERSATION_UPDATED_ADDITIONAL_ATTRIBUTE_KEYS, :FILTERED_UNREAD_COUNT_ADDITIONAL_ATTRIBUTE_KEYS,
:FILTERED_UNREAD_COUNT_UPDATE_KEYS
validates :account_id, presence: true
validates :inbox_id, presence: true
validates :contact_id, presence: true
@@ -255,7 +247,6 @@ class Conversation < ApplicationRecord
handle_resolved_status_change
notify_status_change
create_activity
invalidate_filtered_unread_count_conversation
notify_conversation_updation
end
@@ -322,23 +313,10 @@ class Conversation < ApplicationRecord
end
def allowed_keys?
previous_changes.keys.intersect?(list_of_keys) ||
additional_attributes_changed?(CONVERSATION_UPDATED_ADDITIONAL_ATTRIBUTE_KEYS)
end
def invalidate_filtered_unread_count_conversation
return unless filtered_unread_count_update?
::Conversations::UnreadCounts::FilteredCountInvalidator.new(account).conversation_changed!
end
def filtered_unread_count_update?
previous_changes.keys.intersect?(FILTERED_UNREAD_COUNT_UPDATE_KEYS) ||
additional_attributes_changed?(FILTERED_UNREAD_COUNT_ADDITIONAL_ATTRIBUTE_KEYS)
end
def additional_attributes_changed?(keys)
Array(previous_changes['additional_attributes']).compact.any? { |attributes| attributes.keys.intersect?(keys) }
(
previous_changes.keys.intersect?(list_of_keys) ||
(previous_changes['additional_attributes'].present? && previous_changes['additional_attributes'][1].keys.intersect?(%w[conversation_language]))
)
end
def load_attributes_created_by_db_triggers
-5
View File
@@ -28,7 +28,6 @@ class ConversationParticipant < ApplicationRecord
belongs_to :user
before_validation :ensure_account_id
after_commit :invalidate_filtered_unread_count_visibility, on: [:create, :destroy]
private
@@ -39,8 +38,4 @@ class ConversationParticipant < ApplicationRecord
def ensure_inbox_access
errors.add(:user, 'must have inbox access') if conversation && conversation.inbox.assignable_agents.exclude?(user)
end
def invalidate_filtered_unread_count_visibility
::Conversations::UnreadCounts::FilteredCountInvalidator.new(account).user_visibility_changed!(user_id: user_id)
end
end
-23
View File
@@ -48,8 +48,6 @@ class CustomAttributeDefinition < ApplicationRecord
belongs_to :account
after_update :update_widget_pre_chat_custom_fields, unless: :company_attribute?
after_destroy :sync_widget_pre_chat_custom_fields, unless: :company_attribute?
after_update_commit :invalidate_filtered_unread_count_filters_update, if: :conversation_attribute_before_or_after?
after_destroy_commit :invalidate_filtered_unread_count_filters_destroy, if: :conversation_attribute?
private
@@ -66,27 +64,6 @@ class CustomAttributeDefinition < ApplicationRecord
::Inboxes::UpdateWidgetPreChatCustomFieldsJob.perform_later(account, self)
end
def invalidate_filtered_unread_count_filters_update
invalidate_filtered_unread_count_filters
end
def invalidate_filtered_unread_count_filters_destroy
invalidate_filtered_unread_count_filters
end
def invalidate_filtered_unread_count_filters
filters_changed = ::Conversations::UnreadCounts::FilteredCountInvalidator.new(account).custom_attribute_definition_changed!(self)
dispatch_account_cache_invalidated if filters_changed
end
def dispatch_account_cache_invalidated
Rails.configuration.dispatcher.dispatch(ACCOUNT_CACHE_INVALIDATED, Time.zone.now, account: account, cache_keys: account.cache_keys)
end
def conversation_attribute_before_or_after?
conversation_attribute? || attribute_model_previously_was == 'conversation_attribute'
end
def attribute_must_not_conflict
model_keys = attribute_model.to_s.delete_suffix('_attribute').to_sym
standard_attributes = STANDARD_ATTRIBUTES[model_keys]
-21
View File
@@ -22,31 +22,10 @@ class CustomFilter < ApplicationRecord
enum filter_type: { conversation: 0, contact: 1, report: 2 }
validate :validate_number_of_filters
after_create_commit :invalidate_filtered_unread_count_create
after_update_commit :invalidate_filtered_unread_count_update
after_destroy_commit :invalidate_filtered_unread_count_destroy
def validate_number_of_filters
return true if account.custom_filters.where(user_id: user_id).size < Limits::MAX_CUSTOM_FILTERS_PER_USER
errors.add :account_id, I18n.t('errors.custom_filters.number_of_records')
end
private
def invalidate_filtered_unread_count_create
filtered_count_invalidator.custom_filter_created!(self)
end
def invalidate_filtered_unread_count_update
filtered_count_invalidator.custom_filter_updated!(self)
end
def invalidate_filtered_unread_count_destroy
filtered_count_invalidator.custom_filter_destroyed!(self)
end
def filtered_count_invalidator
::Conversations::UnreadCounts::FilteredCountInvalidator.new(account)
end
end
+1 -1
View File
@@ -20,7 +20,7 @@ class EmailTemplate < ApplicationRecord
enum :template_type, { layout: 0, content: 1 }
belongs_to :account, optional: true
validates :name, uniqueness: { scope: :account }
validates :name, uniqueness: { scope: [:account, :template_type, :locale] }
def self.resolver(options = {})
::EmailTemplates::DbResolverService.using self, options
-14
View File
@@ -77,12 +77,10 @@ class Inbox < ApplicationRecord
enum sender_name_type: { friendly: 0, professional: 1 }
before_destroy :capture_filtered_unread_count_user_ids, prepend: true
after_destroy :delete_round_robin_agents
after_create_commit :dispatch_create_event
after_update_commit :dispatch_update_event
after_destroy_commit :invalidate_filtered_unread_counts_after_destroy
scope :order_by_name, -> { order('lower(name) ASC') }
@@ -263,18 +261,6 @@ class Inbox < ApplicationRecord
::AutoAssignment::InboxRoundRobinService.new(inbox: self).clear_queue
end
def capture_filtered_unread_count_user_ids
return if account.blank?
@filtered_unread_count_user_ids = (inbox_members.pluck(:user_id) + account.account_users.administrator.pluck(:user_id)).uniq
end
def invalidate_filtered_unread_counts_after_destroy
invalidator = ::Conversations::UnreadCounts::FilteredCountInvalidator.new(account)
invalidator.conversation_changed!
invalidator.users_visibility_changed!(user_ids: @filtered_unread_count_user_ids)
end
def check_channel_type?
['Channel::Email', 'Channel::Api', 'Channel::WebWidget'].include?(channel_type)
end

Some files were not shown because too many files have changed in this diff Show More